From 1b2715e7bc88db772c769885fee94fed26739be3 Mon Sep 17 00:00:00 2001 From: Jeremie Grodziski Date: Wed, 28 Oct 2026 22:24:22 +0000 Subject: [PATCH 1/6] feat(insights): a word's glossary under a scope answerGlossary(store, { term, scope, limit }, placement): the entries of the entities under a module or a type, and the modules' reconciliations, for serve's /glossary.json. Which entity is under a scope is the model's, so the placement is injected: this package still never opens a model. GLOSSARY_ANSWER_KIND is the page's pin. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKCVgyop39vhNa45r7zM8w --- packages/insights/src/glossary.ts | 95 +++++++++++++++++++++ packages/insights/test/glossary.test.ts | 107 +++++++++++++++++++++++- 2 files changed, 201 insertions(+), 1 deletion(-) diff --git a/packages/insights/src/glossary.ts b/packages/insights/src/glossary.ts index d2d1cd4..1bbf731 100644 --- a/packages/insights/src/glossary.ts +++ b/packages/insights/src/glossary.ts @@ -163,3 +163,98 @@ export function concernsReport(store: InsightsStore): ConcernsReport { modules.sort((a, b) => unplaced(a) - unplaced(b) || b.words - a.words || byModule(a.module, b.module)); return { modules, withoutWords: store.withoutWords() }; } + +/** + * WHAT A PAGE IS TOLD OF A WORD UNDER A SCOPE (M19c) — `codegraph serve`'s + * `/glossary.json?term=…&scope=…`, under the Vocabulary tab's clicked word. + * The kind is restated by the page and pinned, as `INSIGHT_ANSWER_KIND` is. + */ +export const GLOSSARY_ANSWER_KIND = "codegraph.glossary/1"; + +/** How many of each list an answer carries unless asked for another number; `total` always counts them all. */ +export const GLOSSARY_LIMIT = 200; + +/** The module and the type the model folds an entity into: the scopes it counts under. */ +export interface Place { + readonly module?: string; + readonly type?: string; +} + +/** + * Where an id sits in the model, or undefined for one it does not hold (or + * whose view drops it). Injected: this package never opens a model, and only + * the model knows an operation's type. + */ +export type Placement = (id: string) => Place | undefined; + +export interface GlossaryQuery { + readonly term: string; + /** A module's or a type's id; absent for the corpus. */ + readonly scope?: string; + /** A whole number >= 0; `GLOSSARY_LIMIT` by default. */ + readonly limit?: number; +} + +/** One entity's entry; `owner` names the type (else the module) a member folds into — the node a page reveals for it. */ +export type ScopedWord = Omit & { readonly owner?: string }; +export type ScopedGlossary = Omit; + +export type GlossaryAnswer = + | { + readonly kind: typeof GLOSSARY_ANSWER_KIND; + readonly status: "found"; + /** As the splitter spells it: lowercase. */ + readonly term: string; + readonly scope?: string; + readonly total: { readonly entries: number; readonly glossary: number }; + /** The module, then its types, then its operations; by id within a level. The first `limit`. */ + readonly entries: readonly ScopedWord[]; + /** The modules' reconciliations, by id. The first `limit`. */ + readonly glossary: readonly ScopedGlossary[]; + readonly withoutWords: number; + } + | { readonly kind: typeof GLOSSARY_ANSWER_KIND; readonly status: "unknown-scope"; readonly scope: string } + /** The server's answer when it has no store it may read — never this function's. */ + | { readonly kind: typeof GLOSSARY_ANSWER_KIND; readonly status: "no-store" }; + +/** + * The entries of the entities under a scope, by the fold the vocabulary counts + * with: under a module, what folds into it (its own record included); under a + * type, the type and its members. An id the model does not place is left out, + * as the vocabulary leaves it. + */ +export function answerGlossary(store: InsightsStore, query: GlossaryQuery, placement: Placement): GlossaryAnswer { + const limit = query.limit ?? GLOSSARY_LIMIT; + if (!Number.isInteger(limit) || limit < 0) throw new RangeError(`a glossary limit is a whole number >= 0, not ${limit}`); + const scope = query.scope; + if (scope !== undefined) { + const own = placement(scope); + if (own?.module !== scope && own?.type !== scope) return { kind: GLOSSARY_ANSWER_KIND, status: "unknown-scope", scope }; + } + const under = (place: Place | undefined): place is Place => + place !== undefined && (scope === undefined || place.module === scope || place.type === scope); + + const term = query.term.toLowerCase(); + const entries: ScopedWord[] = []; + for (const { key: _key, ...hit } of store.wordHits(term)) { + const place = placement(hit.id); + if (!under(place)) continue; + const owner = place.type === hit.id || place.module === hit.id ? undefined : (place.type ?? place.module); + entries.push(owner === undefined ? hit : { ...hit, owner }); + } + entries.sort((a, b) => TOP_DOWN[a.level] - TOP_DOWN[b.level] || byCodeUnits(a.id, b.id)); + const glossary: ScopedGlossary[] = []; + for (const { key: _key, ...hit } of store.glossaryEntries(term)) if (under(placement(hit.id))) glossary.push(hit); + glossary.sort((a, b) => byCodeUnits(a.id, b.id)); + + return { + kind: GLOSSARY_ANSWER_KIND, + status: "found", + term, + ...(scope === undefined ? {} : { scope }), + total: { entries: entries.length, glossary: glossary.length }, + entries: entries.slice(0, limit), + glossary: glossary.slice(0, limit), + withoutWords: store.withoutWords(), + }; +} diff --git a/packages/insights/test/glossary.test.ts b/packages/insights/test/glossary.test.ts index d348451..69f1208 100644 --- a/packages/insights/test/glossary.test.ts +++ b/packages/insights/test/glossary.test.ts @@ -18,7 +18,19 @@ import { } from "../src/schema.js"; import { decodeInsights, encodeInsightsToString } from "../src/sidecar.js"; import { sqliteInsightsStore } from "../src/store-sqlite.js"; -import { concernsReport, moduleOf, polysemyReport, wordReport, wordsAbsence } from "../src/glossary.js"; +import { + GLOSSARY_ANSWER_KIND, + GLOSSARY_LIMIT, + answerGlossary, + concernsReport, + moduleOf, + polysemyReport, + wordReport, + wordsAbsence, + type GlossaryAnswer, + type Place, + type Placement, +} from "../src/glossary.js"; /** * LAYER 3 OF PLAN.md §20.3: the readers of the words, over a real store. Two @@ -189,3 +201,96 @@ describe("moduleOf: where a record sits, rendered from its natural key — never expect(moduleOf({ lang: "java", module: "a/b", symbol: "C" })).toBeUndefined(); }); }); + +describe("answerGlossary: what a page is told of a word under a scope (serve's /glossary.json)", () => { + /** What the model folds each id into; `loose/Thing` it does not hold, `legacy/Old` it does. */ + const PLACES: Readonly> = { + [ORDERING]: { module: ORDERING }, + [`${ORDERING}/Order`]: { module: ORDERING, type: `${ORDERING}/Order` }, + [`${ORDERING}/SortSpec`]: { module: ORDERING, type: `${ORDERING}/SortSpec` }, + [`${ORDERING}/Order.place(Order)`]: { module: ORDERING, type: `${ORDERING}/Order` }, + [BILLING]: { module: BILLING }, + [`${BILLING}/Invoice`]: { module: BILLING, type: `${BILLING}/Invoice` }, + "java:com.acme.legacy/Old": { module: "java:com.acme.legacy", type: "java:com.acme.legacy/Old" }, + }; + const placement: Placement = (id) => PLACES[id]; + const found = (answer: GlossaryAnswer) => { + if (answer.status !== "found") throw new Error(`expected found, got ${answer.status}`); + return answer; + }; + const lines = (answer: GlossaryAnswer) => found(answer).entries.map((e) => `${e.level} ${e.id} ${e.entry.concern}${e.owner === undefined ? "" : ` in ${e.owner}`}`); + + it("over the corpus: every placed entity's entry, modules then types then operations, and every module's reconciliation", () => { + const answer = found(answerGlossary(seeded(), { term: "order" }, placement)); + expect(answer.kind).toBe(GLOSSARY_ANSWER_KIND); + expect(answer.scope).toBeUndefined(); + expect(lines(answer)).toEqual([ + `module ${ORDERING} domain`, + `type ${BILLING}/Invoice domain`, + `type ${ORDERING}/Order domain`, + `type ${ORDERING}/SortSpec technical`, + // An operation is no node of the navigator: it names the type a page reveals instead. + `operation ${ORDERING}/Order.place(Order) ambiguous in ${ORDERING}/Order`, + ]); + // `loose/Thing` says order too, and the model holds no such entity: left out, as the vocabulary leaves it. + expect(answer.glossary.map((g) => `${g.id}: ${g.entry.sense}`)).toEqual([`${BILLING}: a purchase order`, `${ORDERING}: a customer's request`]); + expect(answer.total).toEqual({ entries: 5, glossary: 2 }); + expect(answer.withoutWords).toBe(1); + }); + + it("an entry is the projection's, without the natural key a page has no use for; a glossary entry is whole", () => { + const answer = found(answerGlossary(seeded(), { term: "order", scope: `${ORDERING}/SortSpec` }, placement)); + expect(answer.entries).toEqual([ + { id: `${ORDERING}/SortSpec`, level: "type", kind: "class", name: "SortSpec", model: "m", entry: { word: "order", in: ["sortOrder"], concern: "technical", sense: "a sequence", confidence: 0.6 } }, + ]); + expect(found(answerGlossary(seeded(), { term: "order", scope: ORDERING }, placement)).glossary).toEqual([ + { id: ORDERING, name: ORDERING, model: "m", entry: RECORDS[0]?.level === "module" ? RECORDS[0].block.glossary?.[0] : undefined }, + ]); + }); + + it("under a module: what folds into it, its own glossary; under a type: the type and its members, no module's glossary", () => { + const ordering = found(answerGlossary(seeded(), { term: "order", scope: ORDERING }, placement)); + expect(ordering.scope).toBe(ORDERING); + expect(lines(ordering)).toEqual([ + `module ${ORDERING} domain`, + `type ${ORDERING}/Order domain`, + `type ${ORDERING}/SortSpec technical`, + `operation ${ORDERING}/Order.place(Order) ambiguous in ${ORDERING}/Order`, + ]); + expect(ordering.glossary.map((g) => g.id)).toEqual([ORDERING]); + + const order = found(answerGlossary(seeded(), { term: "order", scope: `${ORDERING}/Order` }, placement)); + expect(lines(order)).toEqual([`type ${ORDERING}/Order domain`, `operation ${ORDERING}/Order.place(Order) ambiguous in ${ORDERING}/Order`]); + expect(order.glossary).toEqual([]); + expect(order.total).toEqual({ entries: 2, glossary: 0 }); + }); + + it("a scope is a module or a type the model holds; an operation, an unknown id, a stranger's are not", () => { + const store = seeded(); + for (const scope of [`${ORDERING}/Order.place(Order)`, "java:nowhere", "loose/Thing"]) { + expect(answerGlossary(store, { term: "order", scope }, placement)).toEqual({ kind: GLOSSARY_ANSWER_KIND, status: "unknown-scope", scope }); + } + // A scope nobody explained is a scope still: its answer is empty, not unknown. + expect(found(answerGlossary(store, { term: "order", scope: "java:com.acme.legacy/Old" }, placement)).entries).toEqual([]); + }); + + it("asks the word as the splitter spells it, lowercase; a word nobody defined is found and empty", () => { + const store = seeded(); + expect(answerGlossary(store, { term: "ORDER" }, placement)).toEqual(answerGlossary(store, { term: "order" }, placement)); + expect(answerGlossary(store, { term: "nothing" }, placement)).toEqual({ + kind: GLOSSARY_ANSWER_KIND, status: "found", term: "nothing", total: { entries: 0, glossary: 0 }, entries: [], glossary: [], withoutWords: 1, + }); + }); + + it("lists the first `limit` of each list and counts them all; a limit is a whole number", () => { + const store = seeded(); + const cut = found(answerGlossary(store, { term: "order", limit: 2 }, placement)); + expect(cut.entries.map((e) => e.id)).toEqual([ORDERING, `${BILLING}/Invoice`]); + expect(cut.glossary.map((g) => g.id)).toEqual([BILLING, ORDERING]); + expect(cut.total).toEqual({ entries: 5, glossary: 2 }); + expect(found(answerGlossary(store, { term: "order", limit: 0 }, placement)).entries).toEqual([]); + expect(GLOSSARY_LIMIT).toBe(200); + expect(() => answerGlossary(store, { term: "order", limit: 1.5 }, placement)).toThrow(RangeError); + expect(() => answerGlossary(store, { term: "order", limit: -1 }, placement)).toThrow(RangeError); + }); +}); From b43ba2d26749db8f40727ae7f19f1663cf06f424 Mon Sep 17 00:00:00 2001 From: Jeremie Grodziski Date: Wed, 28 Oct 2026 22:24:23 +0000 Subject: [PATCH 2/6] feat(cli): serve's /glossary.json The third lookup of codegraph serve: what the explained entities under a scope said a word means. It reads the store /insight.json reads (one reader, opened on first request, now shared) and places entities by the fold the carriers use, built at the first request a store can answer. No store is a quiet 404, as for /insight.json. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKCVgyop39vhNa45r7zM8w --- packages/cli/src/app/daemon.ts | 4 +- packages/cli/src/commands/serve.ts | 21 +-- packages/cli/src/glossary-route.ts | 92 +++++++++++++ packages/cli/src/insight-route.ts | 57 ++++---- packages/cli/test/glossary-route.test.ts | 165 +++++++++++++++++++++++ packages/cli/test/insight-route.test.ts | 27 ++-- packages/cli/test/serve-command.test.ts | 9 ++ 7 files changed, 330 insertions(+), 45 deletions(-) create mode 100644 packages/cli/src/glossary-route.ts create mode 100644 packages/cli/test/glossary-route.test.ts diff --git a/packages/cli/src/app/daemon.ts b/packages/cli/src/app/daemon.ts index 9295220..a1720e5 100644 --- a/packages/cli/src/app/daemon.ts +++ b/packages/cli/src/app/daemon.ts @@ -25,8 +25,8 @@ import { RECENT_KIND, readRecent } from "./store.js"; * with relative URLs, so `navigator.json`, `city.json` and `vocabulary.json` * are fetched exactly as `codegraph serve` hands them out — no Tauri IPC, no * CORS. The lookups are not: the daemon holds no store open and no model - * between jobs, so `insight.json` and `carriers.json` are 404s the page - * reads as "not here". + * between jobs, so `insight.json`, `carriers.json` and `glossary.json` are + * 404s the page reads as "not here". */ export const APP_KIND = "codegraph.app/1"; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 4795534..8dd7951 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -9,7 +9,8 @@ import { parseRegistry, type Registry } from "../app/registry.js"; import { CARRIERS_ROUTE, carriersLookup } from "../carriers-route.js"; import { EXIT, UsageError, type ExitCode } from "../exit.js"; import { loadHistoryJoin } from "../history-metrics.js"; -import { INSIGHT_ROUTE, insightLookup } from "../insight-route.js"; +import { GLOSSARY_ROUTE, glossaryLookup, modelPlacement } from "../glossary-route.js"; +import { INSIGHT_ROUTE, insightLookup, storeReader } from "../insight-route.js"; import { storePathForModel } from "../insights-paths.js"; import { errLine, errLines, type IoSink } from "../io.js"; import { openAnalysis } from "../source.js"; @@ -37,11 +38,13 @@ import { vocabularyWarnings } from "./vocabulary.js"; * `/vocabulary.json`, each exactly the file the respective command's `--out` * would have written (the vocabulary under its default weights and list). * - * TWO ROUTES ARE NOT ARTIFACTS: `/insight.json?id=…`, a selected node's + * THREE ROUTES ARE NOT ARTIFACTS: `/insight.json?id=…`, a selected node's * explanation, looked up in `.insights.db` when asked for (M16c) — no * store means a 404 the page ignores, so a model nobody explained looks - * exactly as it did; and `/carriers.json?term=…`, the names that carry a word - * of the vocabulary, answered from the model this command holds (M19b). + * exactly as it did; `/carriers.json?term=…`, the names that carry a word + * of the vocabulary, answered from the model this command holds (M19b); and + * `/glossary.json?term=…`, what the explained entities under a scope said + * that word means — the same store, placed by the same model (M19c). * * Stdout stays empty: the server is the destination. Warnings and the reach * announcement are stderr. The command returns at once; the live server is @@ -109,17 +112,19 @@ export function serveCommand(options: ServeOptions, io: IoSink, deps: ServeDeps ]); // Beside the FIRST model, like explain's default; --insights names it otherwise. Opened on first request. - const insights = insightLookup(options.insights ?? storePathForModel(options.models[0] ?? "")); - // Holds the graph until the first answerable request builds the index, then lets it go. + const store = storeReader(options.insights ?? storePathForModel(options.models[0] ?? "")); + const insights = insightLookup(store); + // Each holds the graph until its first answerable request builds what it needs, then lets it go. const carriers = carriersLookup(() => vocabularyCarriers(graph, vocabulary, counting)); + const glossary = glossaryLookup(store, () => modelPlacement(graph, view)); deps.startServer({ routes: { [NAVIGATOR_ROUTE]: navigatorToJsonString(navigator), [CITY_ROUTE]: cityToJsonString(city), [VOCABULARY_ROUTE]: vocabularyToJsonString(vocabulary), }, - lookups: { [INSIGHT_ROUTE]: insights.handle, [CARRIERS_ROUTE]: carriers.handle }, - onClose: () => insights.close(), + lookups: { [INSIGHT_ROUTE]: insights.handle, [CARRIERS_ROUTE]: carriers.handle, [GLOSSARY_ROUTE]: glossary.handle }, + onClose: () => store.close(), label: "codegraph", assets, port: options.port, diff --git a/packages/cli/src/glossary-route.ts b/packages/cli/src/glossary-route.ts new file mode 100644 index 0000000..0dad7be --- /dev/null +++ b/packages/cli/src/glossary-route.ts @@ -0,0 +1,92 @@ +import type { EntityId } from "@codegraph/core"; +import { folderFor, type CodeGraph, type View } from "@codegraph/analyzer"; +import { GLOSSARY_ANSWER_KIND, answerGlossary, type GlossaryAnswer, type Place, type Placement } from "@codegraph/insights"; +import type { LookupAnswer, StoreReader } from "./insight-route.js"; + +/** + * `/glossary.json?term=…[&scope=…][&limit=…]` — the third lookup of `codegraph + * serve` (PLAN.md §20.3): what the explained entities under a scope said a + * word means, from the store's `insight_word` projection, through the same + * reader as `/insight.json`. The answer is `@codegraph/insights`'; which + * entities are under a scope is the model's, by the fold the vocabulary counts + * with, so a word's carriers and its glossary are of the same entities. + * + * QUIET LIKE `/insight.json`: no store, or one this build must not read, is a + * 404 the page ignores. An unknown scope is 404, as on `/carriers.json`; a + * request that cannot be asked — no term, a limit that is no whole number — + * is 400. + * + * THE PLACEMENT IS BUILT AT THE FIRST REQUEST A STORE CAN ANSWER, reading + * every kept entity once; the function that builds it (and the graph it + * holds) is then dropped. + */ + +export const GLOSSARY_ROUTE = "/glossary.json"; + +const USAGE = "glossary.json needs ?term=, and takes &scope=&limit="; + +export interface GlossaryLookup { + handle(query: URLSearchParams): LookupAnswer; +} + +export function glossaryLookup(reader: StoreReader, build: () => Placement): GlossaryLookup { + let pending: (() => Placement) | undefined = build; + let placement: Placement | undefined; + + return { + handle(query) { + const term = query.get("term"); + const limit = query.get("limit"); + if (term === null || term === "") return refused(USAGE); + if (limit !== null && !/^\d+$/u.test(limit)) return refused(`glossary.json: a limit is a whole number >= 0, not ${JSON.stringify(limit)}`); + + const store = reader.get(); + if (store === undefined) return answered({ kind: GLOSSARY_ANSWER_KIND, status: "no-store" }); + if (placement === undefined && pending !== undefined) { + placement = pending(); + pending = undefined; + } + const scope = query.get("scope"); + return answered( + answerGlossary( + store, + { term, ...(scope === null || scope === "" ? {} : { scope }), ...(limit === null ? {} : { limit: Number(limit) }) }, + placement as Placement, + ), + ); + }, + }; +} + +/** + * Where the model folds each kept entity — the vocabulary's rule: a non-stub + * entity the view keeps, under its containing module and type. Entities that + * share a place share its object. + */ +export function modelPlacement(graph: CodeGraph, view: View): Placement { + const folder = folderFor(graph); + const shared = new Map(); + const places = new Map(); + for (const id of graph.ids()) { + const entity = graph.entity(id); + if (entity === undefined || graph.isStub(id) || !view.entity(entity, graph)) continue; + const module = folder.containingModule(id); + const type = folder.containingType(id); + const key = `${module ?? ""}\u0000${type ?? ""}`; + let place = shared.get(key); + if (place === undefined) { + place = { ...(module === undefined ? {} : { module }), ...(type === undefined ? {} : { type }) }; + shared.set(key, place); + } + places.set(id, place); + } + return (id) => places.get(id); +} + +function answered(answer: GlossaryAnswer): LookupAnswer { + return { status: answer.status === "found" ? 200 : 404, body: JSON.stringify(answer) }; +} + +function refused(error: string): LookupAnswer { + return { status: 400, body: JSON.stringify({ error }) }; +} diff --git a/packages/cli/src/insight-route.ts b/packages/cli/src/insight-route.ts index 842f2cf..6e0839d 100644 --- a/packages/cli/src/insight-route.ts +++ b/packages/cli/src/insight-route.ts @@ -25,42 +25,51 @@ export interface LookupAnswer { readonly body: string; } -export interface InsightLookup { - handle(query: URLSearchParams): LookupAnswer; +/** The store the lookups read, opened at the first request that finds it; `/glossary.json` reads the same one. */ +export interface StoreReader { + /** Undefined while there is no store this build may read. */ + get(): InsightsStore | undefined; close(): void; } -export const INSIGHT_ROUTE = "/insight.json"; - -export function insightLookup(storePath: string): InsightLookup { +export function storeReader(storePath: string): StoreReader { let store: InsightsStore | undefined; - - const reader = (): InsightsStore | undefined => { - if (store !== undefined) return store; - // Checked first: opening a missing path would CREATE it, and a page asking must not leave a file behind. - if (!existsSync(storePath)) return undefined; - const db = loadSqlite().open(storePath); - try { - store = sqliteInsightsStore(db, { readOnly: true }); - } catch { - // Not ours, from a newer build, or from an older one no explain has migrated yet: nothing this page can show. - // `codegraph insights` says why. - db.close(); - } - return store; + return { + get() { + if (store !== undefined) return store; + // Checked first: opening a missing path would CREATE it, and a page asking must not leave a file behind. + if (!existsSync(storePath)) return undefined; + const db = loadSqlite().open(storePath); + try { + store = sqliteInsightsStore(db, { readOnly: true }); + } catch { + // Not ours, from a newer build, or from an older one no explain has migrated yet: nothing this page can show. + // `codegraph insights` says why. + db.close(); + } + return store; + }, + close() { + store?.close(); + store = undefined; + }, }; +} + +export interface InsightLookup { + handle(query: URLSearchParams): LookupAnswer; +} +export const INSIGHT_ROUTE = "/insight.json"; + +export function insightLookup(reader: StoreReader): InsightLookup { return { handle(query) { const id = query.get("id"); if (id === null || id === "") return { status: 400, body: JSON.stringify({ error: "insight.json needs ?id=" }) }; - const open = reader(); + const open = reader.get(); const answer = open === undefined ? { kind: INSIGHT_ANSWER_KIND, id, status: "unknown" as const } : answerInsight(open, id); return { status: answer.status === "unknown" ? 404 : 200, body: JSON.stringify(answer) }; }, - close() { - store?.close(); - store = undefined; - }, }; } diff --git a/packages/cli/test/glossary-route.test.ts b/packages/cli/test/glossary-route.test.ts new file mode 100644 index 0000000..21605db --- /dev/null +++ b/packages/cli/test/glossary-route.test.ts @@ -0,0 +1,165 @@ +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, describe, expect, it } from "vitest"; +import { readModelFileSync } from "@codegraph/core"; +import { buildGraph, identityView, loadModels, loadSqlite, makeView } from "@codegraph/analyzer"; +import { + GLOSSARY_ANSWER_KIND, + INSIGHTS_GENERATOR, + INSIGHTS_KIND, + INSIGHTS_METAMODEL, + PROMPT_VERSION, + decodeInsights, + encodeInsightsToString, + sqliteInsightsStore, + type InsightRecord, + type InsightsHeader, + type WordConcern, + type WordEntry, +} from "@codegraph/insights"; +import { glossaryLookup, modelPlacement } from "../src/glossary-route.js"; +import { storeReader } from "../src/insight-route.js"; + +/** + * `/glossary.json?term=…[&scope=…][&limit=…]`: what the explained entities under + * a scope said a word means (PLAN.md §20.3), from the store `/insight.json` + * reads, placed by the model `serve` holds. + */ + +const FIXTURE = fileURLToPath(new URL("../../../fixtures/java/expected/model.jsonl", import.meta.url)); +const graph = buildGraph(loadModels(readModelFileSync(FIXTURE), { sources: [FIXTURE] }).union); +const MODULE = "java:com.acme.order"; +const ORDER = "java:com.acme.order/Order"; +const SERVICE = "java:com.acme.order/OrderService"; +const BILL = "java:com.acme.order/OrderService.bill(com.acme.order.Order)"; + +const said = (word: string, concern: WordConcern, sense: string, inside: string[], confidence = 0.8): WordEntry => + ({ word, in: inside, concern, sense, meaning: `${sense}, here.`, confidence }); + +function record(id: string, level: "module" | "type" | "operation", words: WordEntry[] | undefined, glossary?: object[]): InsightRecord { + const block = + level === "module" + ? { name: "M", description: "A module.", apis: [], spis: [], dependsOn: [], concepts: [], boundedContextHint: null, sharedKernelHint: null, ubiquitousLanguage: [], confidence: 0.6, glossary: glossary ?? [] } + : level === "type" + ? { name: "T", description: "A type.", concept: "entity", eventKind: null, interfaceRole: null, aggregateRoot: false, containedIn: null, syncPattern: null, identity: null, fields: [], invariants: [], stateMachine: null, relatesTo: [], exposes: [], dependsOn: [], domainTerms: [], confidence: 0.7 } + : { name: "op", description: "Bills.", safe: false, idempotent: false, owner: "domainService", handlesCommand: null, emits: [], preconditions: [], postconditions: [], invariantsEnforced: [], usesSpi: [], domainTerms: [], confidence: 0.7 }; + const kind = level === "module" ? "package" : level === "type" ? "class" : "method"; + return { t: "i", id, level, kind, origin: "llm", model: "strong/model", fingerprint: "a".repeat(64), block: { ...block, ...(words === undefined ? {} : { words }) } } as InsightRecord; +} + +const dir = mkdtempSync(join(tmpdir(), "glossary-route-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +function storeAt(name: string, records: readonly InsightRecord[]): string { + const header: InsightsHeader = { + t: "header", kind: INSIGHTS_KIND, generatedBy: INSIGHTS_GENERATOR, promptVersion: PROMPT_VERSION, metamodel: INSIGHTS_METAMODEL, + models: { leaf: "cheap/model", rollup: "strong/model" }, provider: "openrouter", depth: 1, + source: { paths: ["model.jsonl"], langs: ["java"], view: { name: "all", filters: [] } }, + }; + const path = join(dir, name); + const store = sqliteInsightsStore(loadSqlite().open(path)); + const eof = { t: "eof" as const, counts: { records: records.length, llm: records.length, template: 0, reused: 0, failed: 0 }, usage: { promptTokens: 0, completionTokens: 0 }, generatedAt: "2026-10-28T10:00:00.000Z" }; + store.importFile(decodeInsights(encodeInsightsToString(header, records, eof))); + store.close(); + return path; +} + +const RECORDS = [ + record(MODULE, "module", [said("order", "domain", "the ordering business", ["com.acme.order"])], [ + { word: "order", concern: "domain", sense: "a customer's request", meaning: "What a customer asks for.", polysemous: false, senses: [] }, + ]), + record(ORDER, "type", [said("order", "domain", "a customer's request", ["Order"])]), + record(SERVICE, "type", [said("order", "domain", "a customer's request", ["OrderService"]), said("service", "domain", "after-sales care", ["OrderService"], 0.6)]), + record(BILL, "operation", [said("bill", "domain", "to invoice", ["bill"]), said("order", "domain", "the order billed", ["order"])]), + // No such entity in this model: explained against another version of it. + record("java:com.acme.order/Refund", "type", [said("order", "domain", "a refunded order", ["orderId"])]), +]; +const STORE = storeAt("model.insights.db", RECORDS); + +function lookup(path = STORE, view = identityView) { + let builds = 0; + const reader = storeReader(path); + const handle = glossaryLookup(reader, () => { + builds += 1; + return modelPlacement(graph, view); + }); + const ask = (query: Record) => { + const answer = handle.handle(new URLSearchParams(query)); + return { status: answer.status, body: JSON.parse(answer.body) as Record }; + }; + return { ask, builds: () => builds, close: () => reader.close() }; +} + +const ids = (body: Record): string[] => (body["entries"] as { id: string; owner?: string }[]).map((e) => (e.owner === undefined ? e.id : `${e.id} in ${e.owner}`)); + +describe("glossary.json", () => { + it("answers the corpus when no scope is named: every entry of an entity the model holds, and the modules' reconciliations", () => { + const { ask, close } = lookup(); + const corpus = ask({ term: "order" }); + expect(corpus.status).toBe(200); + expect(corpus.body).toMatchObject({ kind: GLOSSARY_ANSWER_KIND, status: "found", term: "order", total: { entries: 4, glossary: 1 }, withoutWords: 0 }); + // Refund is not in this model: left out. An operation names the type a page reveals for it. + expect(ids(corpus.body)).toEqual([MODULE, ORDER, SERVICE, `${BILL} in ${SERVICE}`]); + expect(ask({ term: "order", scope: "" }).body).toEqual(corpus.body); + close(); + }); + + it("answers a type with the type and its members, a module with what folds into it — the carriers' fold", () => { + const { ask, close } = lookup(); + expect(ids(ask({ term: "order", scope: SERVICE }).body)).toEqual([SERVICE, `${BILL} in ${SERVICE}`]); + expect(ask({ term: "order", scope: SERVICE }).body["glossary"]).toEqual([]); + expect(ids(ask({ term: "order", scope: MODULE }).body)).toEqual([MODULE, ORDER, SERVICE, `${BILL} in ${SERVICE}`]); + expect(ask({ term: "order", scope: MODULE }).body["glossary"]).toMatchObject([{ id: MODULE, entry: { sense: "a customer's request", meaning: "What a customer asks for." } }]); + // `adapter` is another module: nothing of com.acme.order folds into it. + expect(ask({ term: "order", scope: "java:com.acme.order.adapter" }).body).toMatchObject({ status: "found", entries: [], glossary: [] }); + expect(ids(ask({ term: "order", limit: "1" }).body)).toEqual([MODULE]); + close(); + }); + + it("is 404 for a scope that is no module or type of the model — an operation, an unknown id, a stub", () => { + const { ask, close } = lookup(); + expect(graph.isStub("java:java.lang/String")).toBe(true); + for (const scope of [BILL, "java:nowhere", "java:java.lang/String"]) { + expect(ask({ term: "order", scope })).toEqual({ status: 404, body: { kind: GLOSSARY_ANSWER_KIND, status: "unknown-scope", scope } }); + } + close(); + }); + + it("places by the page's view: an entity the view drops is under no scope, and no scope itself", () => { + const noMethods = makeView("noMethods", (entity) => entity.kind !== "method", () => true); + const { ask, close } = lookup(STORE, noMethods); + expect(ids(ask({ term: "order" }).body)).toEqual([MODULE, ORDER, SERVICE]); + const noOrder = makeView("noOrder", (entity) => entity.id !== ORDER, () => true); + const without = lookup(STORE, noOrder); + expect(without.ask({ term: "order", scope: ORDER }).body).toMatchObject({ status: "unknown-scope" }); + without.close(); + close(); + }); + + it("is 400 for a request it cannot ask: no term, a limit that is no whole number — and builds nothing for it", () => { + const { ask, builds, close } = lookup(); + for (const query of [{}, { term: "" }, { term: "order", limit: "-1" }, { term: "order", limit: "2.5" }]) { + const answer = ask(query); + expect(answer.status, JSON.stringify(query)).toBe(400); + expect(answer.body["error"]).toMatch(/glossary\.json/u); + } + expect(builds()).toBe(0); + close(); + }); + + it("with no store it is 404, quietly: no file created, no placement built — and a store that comes later is found", () => { + const late = join(dir, "late.insights.db"); + const { ask, builds, close } = lookup(late); + expect(ask({ term: "order" })).toEqual({ status: 404, body: { kind: GLOSSARY_ANSWER_KIND, status: "no-store" } }); + expect(readdirSync(dir)).not.toContain("late.insights.db"); + expect(builds()).toBe(0); + storeAt("late.insights.db", RECORDS.slice(0, 2)); + expect(ids(ask({ term: "order" }).body)).toEqual([MODULE, ORDER]); + ask({ term: "order", scope: ORDER }); + // Built once, at the first request a store could answer. + expect(builds()).toBe(1); + close(); + }); +}); diff --git a/packages/cli/test/insight-route.test.ts b/packages/cli/test/insight-route.test.ts index e8f9daf..0f7a8cd 100644 --- a/packages/cli/test/insight-route.test.ts +++ b/packages/cli/test/insight-route.test.ts @@ -18,7 +18,7 @@ import { type InsightsHeader, } from "@codegraph/insights"; import { directoryAssets } from "../src/assets.js"; -import { insightLookup } from "../src/insight-route.js"; +import { insightLookup, storeReader } from "../src/insight-route.js"; import { captureIo } from "../src/io.js"; import { startArtifactServer } from "../src/serve.js"; @@ -55,7 +55,8 @@ const query = (id?: string): URLSearchParams => new URLSearchParams(id === undef describe("insightLookup", () => { it("answers an explained id with its record, a failed one with why, an unknown one with 404", () => { - const lookup = insightLookup(db); + const reader = storeReader(db); + const lookup = insightLookup(reader); try { const found = lookup.handle(query(ORDER)); expect(found.status).toBe(200); @@ -68,27 +69,29 @@ describe("insightLookup", () => { expect(JSON.parse(unknown.body)).toEqual({ kind: INSIGHT_ANSWER_KIND, id: "java:nope/Nothing", status: "unknown" }); expect(lookup.handle(query()).status).toBe(400); } finally { - lookup.close(); + reader.close(); } }); it("with no store there is nothing to say — 404, quietly, and asking creates no file", () => { const absent = join(dir, "absent.insights.db"); - const lookup = insightLookup(absent); + const reader = storeReader(absent); + const lookup = insightLookup(reader); expect(lookup.handle(query(ORDER)).status).toBe(404); expect(readdirSync(dir)).not.toContain("absent.insights.db"); - lookup.close(); + reader.close(); }); it("finds a store that comes to exist AFTER the server started: `explain` may be run while the page is open", () => { const late = join(dir, "late.insights.db"); - const lookup = insightLookup(late); + const reader = storeReader(late); + const lookup = insightLookup(reader); expect(lookup.handle(query(ORDER)).status).toBe(404); const store = sqliteInsightsStore(loadSqlite().open(late)); store.importFile(decodeInsights(encodeInsightsToString(HEADER, [RECORD], { t: "eof", counts: { records: 1, llm: 1, template: 0, reused: 0, failed: 0 }, usage: { promptTokens: 1, completionTokens: 1 }, generatedAt: "t" }))); store.close(); expect(lookup.handle(query(ORDER)).status).toBe(200); - lookup.close(); + reader.close(); }); it("a file that is not an insights store is 404 for the page, not a crash for the server", () => { @@ -96,9 +99,10 @@ describe("insightLookup", () => { const foreign = loadSqlite().open(other); foreign.exec("CREATE TABLE entity (id INTEGER PRIMARY KEY)"); foreign.close(); - const lookup = insightLookup(other); + const reader = storeReader(other); + const lookup = insightLookup(reader); expect(lookup.handle(query(ORDER)).status).toBe(404); - lookup.close(); + reader.close(); }); }); @@ -111,13 +115,14 @@ describe("the artifact server answers lookup routes", () => { it("GET /insight.json?id=… is answered at request time, uncached; artifacts and the jail are as before", async () => { const assets = mkdtempSync(join(tmpdir(), "codegraph-serve-")); writeFileSync(join(assets, "index.html"), "fake"); - const lookup = insightLookup(db); + const reader = storeReader(db); + const lookup = insightLookup(reader); let closed = false; const server = startArtifactServer({ routes: { "/navigator.json": `{"kind":"codegraph.navigator/1"}` }, lookups: { "/insight.json": lookup.handle }, onClose: () => { - lookup.close(); + reader.close(); closed = true; }, label: "codegraph", assets: directoryAssets(assets), port: 0, io: captureIo(), diff --git a/packages/cli/test/serve-command.test.ts b/packages/cli/test/serve-command.test.ts index e241356..140fe99 100644 --- a/packages/cli/test/serve-command.test.ts +++ b/packages/cli/test/serve-command.test.ts @@ -381,6 +381,11 @@ describe("serve hands the page a selected node's explanation (M16c)", () => { expect(lookup?.(new URLSearchParams({ id: "java:com.acme.order/Order" })).status).toBe(404); expect(existsSync(FIXTURE.replace(/\.jsonl$/u, ".insights.db"))).toBe(false); expect(Object.keys(started[0]?.routes ?? {}).sort()).toEqual(["/city.json", "/navigator.json", "/vocabulary.json"]); + // The glossary is as quiet over the same missing store. + const glossary = started[0]?.lookups?.["/glossary.json"]?.(new URLSearchParams({ term: "order" })); + expect(glossary?.status).toBe(404); + expect(JSON.parse(glossary?.body ?? "{}")).toEqual({ kind: "codegraph.glossary/1", status: "no-store" }); + expect(existsSync(FIXTURE.replace(/\.jsonl$/u, ".insights.db"))).toBe(false); started[0]?.onClose?.(); }); @@ -404,6 +409,10 @@ describe("serve hands the page a selected node's explanation (M16c)", () => { const answer = started[0]?.lookups?.["/insight.json"]?.(new URLSearchParams({ id: record.id })); expect(answer?.status).toBe(200); expect(JSON.parse(answer?.body ?? "{}")).toMatchObject({ status: "explained", record: { block: { description: "Ordering." } } }); + // The glossary reads the same store: a record under prompt 2's absence of words is counted, not listed. + const glossary = started[0]?.lookups?.["/glossary.json"]?.(new URLSearchParams({ term: "order" })); + expect(glossary?.status).toBe(200); + expect(JSON.parse(glossary?.body ?? "{}")).toMatchObject({ kind: "codegraph.glossary/1", status: "found", entries: [], withoutWords: 1 }); started[0]?.onClose?.(); } finally { rmSync(dir, { recursive: true, force: true }); From caa436f5fffb971245d6bd7205e6f6b611ce4dc8 Mon Sep 17 00:00:00 2001 From: Jeremie Grodziski Date: Wed, 28 Oct 2026 22:26:00 +0000 Subject: [PATCH 3/6] refactor(navigator-ui): one remembered lookup The insight and carriers loaders each carried the same "ask once per URL, stop asking a server with no such route" logic; lookup.ts holds it once, before a third lookup needs it too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKCVgyop39vhNa45r7zM8w --- packages/navigator-ui/src/insight.ts | 48 +++++-------------------- packages/navigator-ui/src/lookup.ts | 45 +++++++++++++++++++++++ packages/navigator-ui/src/vocabulary.ts | 44 +++-------------------- 3 files changed, 58 insertions(+), 79 deletions(-) create mode 100644 packages/navigator-ui/src/lookup.ts diff --git a/packages/navigator-ui/src/insight.ts b/packages/navigator-ui/src/insight.ts index 7d2f208..abbb9af 100644 --- a/packages/navigator-ui/src/insight.ts +++ b/packages/navigator-ui/src/insight.ts @@ -1,3 +1,7 @@ +import { noStore, rememberedLookup, type Fetcher } from "./lookup.js"; + +export type { Fetcher } from "./lookup.js"; + /** * A SELECTED NODE'S EXPLANATION — what `codegraph explain` wrote about it, * asked of the server the page came from (`insight.json?id=…`, the one route @@ -78,50 +82,14 @@ export function parseInsightAnswer(json: unknown): Insight { return NONE; } -export type Fetcher = (url: string) => Promise; - export interface InsightLoader { load(id: string): Promise; } -/** - * One request per id, shared by concurrent askers and remembered. A server - * that answers the route with something that is not JSON (index.html, from a - * dev server or a static host) has no such route: it is asked once, not once - * per click. - */ -export function createInsightLoader(fetcher: Fetcher = (url) => fetch(url, { cache: "no-store" })): InsightLoader { - const asked = new Map>(); - let noRoute = false; - - const ask = async (id: string): Promise => { - try { - // RELATIVE: under the app daemon the page lives at //, and so must this. - const response = await fetcher(`insight.json?id=${encodeURIComponent(id)}`); - let json: unknown; - try { - json = await response.json(); - } catch { - noRoute = true; - return NONE; - } - return parseInsightAnswer(json); - } catch { - return NONE; - } - }; - - return { - load(id) { - if (noRoute) return Promise.resolve(NONE); - let pending = asked.get(id); - if (pending === undefined) { - pending = ask(id); - asked.set(id, pending); - } - return pending; - }, - }; +/** One request per id, shared by concurrent askers and remembered; quiet where the server has no such route. */ +export function createInsightLoader(fetcher: Fetcher = noStore): InsightLoader { + const ask = rememberedLookup(parseInsightAnswer, NONE, fetcher); + return { load: (id) => ask(`insight.json?id=${encodeURIComponent(id)}`) }; } export interface BlockFact { diff --git a/packages/navigator-ui/src/lookup.ts b/packages/navigator-ui/src/lookup.ts new file mode 100644 index 0000000..816a1fa --- /dev/null +++ b/packages/navigator-ui/src/lookup.ts @@ -0,0 +1,45 @@ +/** + * A LOOKUP OF THE SERVER THE PAGE CAME FROM — `insight.json`, `carriers.json`, + * `glossary.json`: answers `codegraph serve` computes per request, which no + * static host, Vite dev server or app daemon has. + * + * Asked once per URL, shared by concurrent askers and remembered. A server + * that answers with something that is not JSON (index.html from a host that + * answers every path, the daemon's bare 404) has no such route: it is asked + * once, not once per click. Every failure is the caller's `none`. + */ +export type Fetcher = (url: string) => Promise; + +export const noStore: Fetcher = (url) => fetch(url, { cache: "no-store" }); + +/** URLs are RELATIVE: under the app daemon the page lives at //, and so must they. */ +export function rememberedLookup(parse: (json: unknown) => A, none: A, fetcher: Fetcher = noStore): (url: string) => Promise { + const asked = new Map>(); + let noRoute = false; + + const ask = async (url: string): Promise => { + try { + const response = await fetcher(url); + let json: unknown; + try { + json = await response.json(); + } catch { + noRoute = true; + return none; + } + return parse(json); + } catch { + return none; + } + }; + + return (url) => { + if (noRoute) return Promise.resolve(none); + let pending = asked.get(url); + if (pending === undefined) { + pending = ask(url); + asked.set(url, pending); + } + return pending; + }; +} diff --git a/packages/navigator-ui/src/vocabulary.ts b/packages/navigator-ui/src/vocabulary.ts index 0704554..51290fc 100644 --- a/packages/navigator-ui/src/vocabulary.ts +++ b/packages/navigator-ui/src/vocabulary.ts @@ -1,4 +1,5 @@ import type { Carrier, ScopeRef, TermClass, VocabularyArtifact } from "@codegraph/vocabulary"; +import { noStore, rememberedLookup, type Fetcher } from "./lookup.js"; /** * THE VOCABULARY, AS THE PAGE RECEIVES IT (PLAN.md §20.2): the artifact @@ -22,9 +23,7 @@ export const CARRIERS_ANSWER_KIND = "codegraph.carriers/1"; /** In the package's order: what the chips and the key list, and how ties of one text sort. */ export const TERM_CLASSES = ["domain", "technical", "keyword", "short"] as const; -export type Fetcher = (url: string) => Promise; - -const noStore: Fetcher = (url) => fetch(url, { cache: "no-store" }); +export type { Fetcher } from "./lookup.js"; export type VocabularyLoad = | { readonly status: "ready"; readonly artifact: VocabularyArtifact } @@ -125,41 +124,8 @@ export function carriersUrl(query: CarriersQuery): string { return `carriers.json?${params.toString()}`; } -/** - * One request per (scope, term, class), shared and remembered. A server that - * answers with something that is not JSON — the daemon's bare 404, index.html - * — has no such route: asked once, not once per click. - */ +/** One request per (scope, term, class), shared and remembered; quiet where the server has no such route. */ export function createCarriersLoader(fetcher: Fetcher = noStore): CarriersLoader { - const asked = new Map>(); - let noRoute = false; - - const ask = async (query: CarriersQuery): Promise => { - try { - const response = await fetcher(carriersUrl(query)); - let json: unknown; - try { - json = await response.json(); - } catch { - noRoute = true; - return NONE; - } - return parseCarriersAnswer(json); - } catch { - return NONE; - } - }; - - return { - load(query) { - if (noRoute) return Promise.resolve(NONE); - const key = carriersUrl(query); - let pending = asked.get(key); - if (pending === undefined) { - pending = ask(query); - asked.set(key, pending); - } - return pending; - }, - }; + const ask = rememberedLookup(parseCarriersAnswer, NONE, fetcher); + return { load: (query) => ask(carriersUrl(query)) }; } From 680c2792a2a3de3935f6c733e67506bb08511c7a Mon Sep 17 00:00:00 2001 From: Jeremie Grodziski Date: Wed, 28 Oct 2026 22:34:30 +0000 Subject: [PATCH 4/6] feat(navigator-ui): the glossary in both panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vocabulary tab lists, under a clicked word's carriers, what the explained entities of its scope said the word means: the modules' reconciliations, then each entity's entry, in the explanation panel's inference colour and badge. An entry reveals its entity in Navigate (a member, its type). No store, no route: nothing is shown. The Navigate panel reads a block's words and glossary by name, as "order (domain) — sense. Meaning. In Order · 0.90": generically the identifiers stood where a definition reads, and the types that disagree on a word were dropped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKCVgyop39vhNa45r7zM8w --- .../src/components/VocabularyView.tsx | 127 +++++++++++++++++- packages/navigator-ui/src/glossary.ts | 122 +++++++++++++++++ packages/navigator-ui/src/insight.ts | 42 +++++- packages/navigator-ui/src/style.css | 56 ++++++++ packages/navigator-ui/test/glossary.test.ts | 87 ++++++++++++ packages/navigator-ui/test/insight.test.ts | 36 +++++ 6 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 packages/navigator-ui/src/glossary.ts create mode 100644 packages/navigator-ui/test/glossary.test.ts diff --git a/packages/navigator-ui/src/components/VocabularyView.tsx b/packages/navigator-ui/src/components/VocabularyView.tsx index c39f361..64da76b 100644 --- a/packages/navigator-ui/src/components/VocabularyView.tsx +++ b/packages/navigator-ui/src/components/VocabularyView.tsx @@ -3,6 +3,7 @@ import type { TermClass } from "@codegraph/vocabulary"; import { CLOUD_DEFAULTS, MONOSPACE_LINE, layoutCloud, monospaceMeasure, type Measure } from "@codegraph/vocabulary/cloud"; import type { ModelIndexes } from "../model/indexes.js"; import { carrierNode, cloudWords, indexVocabulary, scopeChain, scopeForNode, wordRows, type WordRow } from "../model/words.js"; +import { createGlossaryLoader, type Glossary } from "../glossary.js"; import { TERM_CLASSES, createCarriersLoader, loadVocabulary, type Carriers, type VocabularyLoad } from "../vocabulary.js"; /** @@ -20,7 +21,9 @@ import { TERM_CLASSES, createCarriersLoader, loadVocabulary, type Carriers, type * * Every number is the artifact's; the carriers are the server's lookup, and a * page served without one (the app daemon, a static host) shows the counts - * and says why there is no evidence. + * and says why there is no evidence. Under the carriers, what the explained + * entities of the scope said the word means — a model's reading, in the + * inference colour, and nothing at all where no store was explained. */ /** The CLI's `--top` default. */ @@ -29,6 +32,7 @@ const TOP_MAX = 200; /** Table rows at once; the cloud's top N is what the slider moves. */ const ROW_CAP = 500; const LOADER = createCarriersLoader(); +const GLOSSARY = createGlossaryLoader(); export interface VocabularyViewProps { readonly ix: ModelIndexes; @@ -65,6 +69,7 @@ export function VocabularyView({ ix, selection, onSelect, onReveal }: Vocabulary const [top, setTop] = useState(TOP); const [picked, setPicked] = useState<{ readonly scope: number; readonly row: WordRow } | undefined>(undefined); const [carriers, setCarriers] = useState<{ readonly key: string; readonly answer: Carriers } | undefined>(undefined); + const [glossary, setGlossary] = useState<{ readonly key: string; readonly answer: Glossary } | undefined>(undefined); const measure = useMemo(pageMeasure, []); // A new navigator (the app daemon's next job) comes with a new vocabulary. @@ -96,6 +101,9 @@ export function VocabularyView({ ix, selection, onSelect, onReveal }: Vocabulary void LOADER.load({ scope: scopeId, term: pick.text, class: pick.class }).then((answer) => { if (current) setCarriers({ key: pickKey, answer }); }); + void GLOSSARY.load({ scope: scopeId, term: pick.text }).then((answer) => { + if (current) setGlossary({ key: pickKey, answer }); + }); return () => { current = false; }; @@ -133,6 +141,7 @@ export function VocabularyView({ ix, selection, onSelect, onReveal }: Vocabulary }; const choose = (row: WordRow) => setPicked(pick?.term === row.term ? undefined : { scope, row }); const answer = carriers !== undefined && carriers.key === pickKey ? carriers.answer : undefined; + const glossed = glossary !== undefined && glossary.key === pickKey ? glossary.answer : undefined; return (
@@ -272,6 +281,7 @@ export function VocabularyView({ ix, selection, onSelect, onReveal }: Vocabulary + )} @@ -376,3 +386,118 @@ function CarrierList({ ); } + +/** + * What the explanations under the scope said the word means: the modules' + * reconciliations, then each entity's entry. A language model's reading — + * the explanation panel's colour and badge — and nothing at all without a + * store (`none`) or for a scope the server does not know. + */ +function GlossaryList({ + ix, + answer, + onReveal, +}: { + readonly ix: ModelIndexes; + readonly answer: Glossary | undefined; + readonly onReveal: (node: number) => void; +}) { + if (answer?.status !== "found") return null; + const { entries, glossary, total, withoutWords } = answer; + const models = [...new Set([...glossary, ...entries].flatMap((item) => (item.model === undefined ? [] : [item.model])))]; + const older = + withoutWords > 0 ? ( +

+ The store also holds {plural(withoutWords, "explanation")} from before words were asked: {withoutWords === 1 ? "it says" : "they say"} nothing here. +

+ ) : null; + if (total.entries + total.glossary === 0) { + return ( +
+

+ LLM glossary + no explained entity of this scope defines it +

+ {older} +
+ ); + } + return ( +
+

+ LLM glossary + + {plural(total.entries, "entity", "entities")} + {total.glossary > 0 ? ` · ${plural(total.glossary, "module")} reconciling` : ""} + + {models.length > 0 && {models.join(", ")}} +

+
+ {glossary.map((gloss) => { + const node = ix.nodeById.get(gloss.id); + return ( +
+ +

{gloss.entry.sense}

+ {gloss.entry.meaning !== "" &&

{gloss.entry.meaning}

} + {gloss.entry.polysemous && gloss.entry.senses.length > 0 && ( +

+ its types disagree: {gloss.entry.senses.map((sense) => `${sense.in} — ${sense.sense}`).join("; ")} +

+ )} +
+ ); + })} +
    + {entries.map((word, index) => { + const node = ix.nodeById.get(word.id) ?? (word.owner === undefined ? undefined : ix.nodeById.get(word.owner)); + const member = node !== undefined && ix.model.nodes[node]?.id !== word.id; + return ( +
  • + + + {word.entry.confidence.toFixed(2)} + +

    + {word.entry.sense} · in {word.entry.in.join(", ")} +

    +
  • + ); + })} +
+
+ {(total.entries > entries.length || total.glossary > glossary.length) && ( +

+ the first {entries.length} of {plural(total.entries, "entry", "entries")} + {total.glossary > glossary.length ? `, ${glossary.length} of ${plural(total.glossary, "module")}` : ""} +

+ )} + {older} +
+ ); +} diff --git a/packages/navigator-ui/src/glossary.ts b/packages/navigator-ui/src/glossary.ts new file mode 100644 index 0000000..f176d0b --- /dev/null +++ b/packages/navigator-ui/src/glossary.ts @@ -0,0 +1,122 @@ +import { noStore, rememberedLookup, type Fetcher } from "./lookup.js"; + +export type { Fetcher } from "./lookup.js"; + +/** + * WHAT THE EXPLANATIONS SAY A WORD MEANS, under the Vocabulary tab's clicked + * word (PLAN.md §20.3): `glossary.json?term=…&scope=…`, the entries of the + * explained entities under the tab's scope, looked up in the store + * `insight.json` reads. + * + * RESTATED, NOT IMPORTED, as `insight.ts` restates its answer: the kind is a + * literal pinned to `@codegraph/insights`' by `glossary.test.ts`, and the + * shapes are this page's own. QUIET BY DESIGN: no store, no such route, an + * answer this page cannot read — all are `none`, and the tab shows the counts + * and the carriers as it did before explanations had words. + */ +export const GLOSSARY_ANSWER_KIND = "codegraph.glossary/1"; + +/** One explained entity's entry for the word: what its model judged it to mean there. */ +export interface GlossaryWord { + readonly id: string; + readonly level: string; + readonly kind: string; + readonly name: string | undefined; + readonly model: string | undefined; + /** A member's type, else its module: the node a page reveals for it. */ + readonly owner?: string; + readonly entry: { + readonly word: string; + /** The identifiers of the entity the word is in. */ + readonly in: readonly string[]; + readonly concern: string; + readonly sense: string; + readonly confidence: number; + }; +} + +/** A module's reconciliation of what its types said of the word. */ +export interface ModuleGloss { + readonly id: string; + readonly name: string | undefined; + readonly model: string | undefined; + readonly entry: { + readonly word: string; + readonly concern: string; + readonly sense: string; + readonly meaning: string; + readonly polysemous: boolean; + readonly senses: readonly { readonly in: string; readonly sense: string }[]; + }; +} + +export type Glossary = + | { readonly status: "none" } + /** The server does not know this scope: it was built from another model. */ + | { readonly status: "unknown" } + | { + readonly status: "found"; + readonly term: string; + readonly total: { readonly entries: number; readonly glossary: number }; + readonly entries: readonly GlossaryWord[]; + readonly glossary: readonly ModuleGloss[]; + /** Records explained before words were asked (prompt 1): what no answer can show. */ + readonly withoutWords: number; + }; + +const NONE: Glossary = { status: "none" }; + +const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const isString = (value: unknown): value is string => typeof value === "string"; +const isNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); + +function isWord(value: unknown): value is GlossaryWord { + if (!isObject(value) || !isString(value["id"]) || !isString(value["level"]) || !isObject(value["entry"])) return false; + const entry = value["entry"]; + return isString(entry["word"]) && Array.isArray(entry["in"]) && isString(entry["concern"]) && isString(entry["sense"]) && isNumber(entry["confidence"]); +} + +function isGloss(value: unknown): value is ModuleGloss { + if (!isObject(value) || !isString(value["id"]) || !isObject(value["entry"])) return false; + const entry = value["entry"]; + return isString(entry["word"]) && isString(entry["concern"]) && isString(entry["sense"]) && isString(entry["meaning"]) && Array.isArray(entry["senses"]); +} + +/** Structural, and never a throw: an entry this page cannot show is dropped, an answer it cannot read is `none`. */ +export function parseGlossaryAnswer(json: unknown): Glossary { + if (!isObject(json) || json["kind"] !== GLOSSARY_ANSWER_KIND) return NONE; + if (json["status"] === "unknown-scope") return { status: "unknown" }; + const total = json["total"]; + if (json["status"] !== "found" || !isString(json["term"]) || !Array.isArray(json["entries"]) || !Array.isArray(json["glossary"])) return NONE; + if (!isObject(total) || !isNumber(total["entries"]) || !isNumber(total["glossary"])) return NONE; + return { + status: "found", + term: json["term"], + total: { entries: total["entries"], glossary: total["glossary"] }, + entries: (json["entries"] as unknown[]).filter(isWord), + glossary: (json["glossary"] as unknown[]).filter(isGloss), + withoutWords: isNumber(json["withoutWords"]) ? json["withoutWords"] : 0, + }; +} + +export interface GlossaryQuery { + readonly term: string; + /** A module's or a type's id; absent for the corpus. */ + readonly scope: string | undefined; +} + +export interface GlossaryLoader { + load(query: GlossaryQuery): Promise; +} + +export function glossaryUrl(query: GlossaryQuery): string { + const params = new URLSearchParams({ term: query.term }); + if (query.scope !== undefined) params.set("scope", query.scope); + return `glossary.json?${params.toString()}`; +} + +/** One request per (scope, term), shared and remembered; quiet where the server has no such route. */ +export function createGlossaryLoader(fetcher: Fetcher = noStore): GlossaryLoader { + const ask = rememberedLookup(parseGlossaryAnswer, NONE, fetcher); + return { load: (query) => ask(glossaryUrl(query)) }; +} diff --git a/packages/navigator-ui/src/insight.ts b/packages/navigator-ui/src/insight.ts index abbb9af..2c7287d 100644 --- a/packages/navigator-ui/src/insight.ts +++ b/packages/navigator-ui/src/insight.ts @@ -148,15 +148,53 @@ function valuesOf(value: unknown): string[] { return text === undefined ? [] : [text]; } +/** `order (domain) — a customer's request. Its meaning.` — the head of a word entry and of a glossary entry alike. */ +function definitionText(item: Record): string | undefined { + const [word, concern, sense, meaning] = ["word", "concern", "sense", "meaning"].map((key) => stringOf(item[key])); + if (word === undefined || concern === undefined || sense === undefined) return undefined; + return `${word} (${concern}) — ${sense}.${meaning === undefined || meaning === "" ? "" : ` ${meaning}`}`; +} + +/** + * THE GLOSSARY'S TWO SHAPES (M19c), read by name: generically, a word entry + * put the identifiers it is in where a definition reads, and a glossary entry + * lost the types that disagree on it. An item of any other shape falls back. + */ +const DEFINED: Readonly) => string | undefined>> = { + words(item) { + const head = definitionText(item); + const inside = item["in"]; + const confidence = numberOf(item["confidence"]); + if (head === undefined || !Array.isArray(inside) || confidence === undefined) return undefined; + const names = inside.filter((name): name is string => typeof name === "string"); + return `${head} In ${names.join(", ")} · ${confidence.toFixed(2)}`; + }, + glossary(item) { + const head = definitionText(item); + const senses = item["senses"]; + if (head === undefined || !Array.isArray(senses)) return undefined; + const disagree = senses.flatMap((s) => (isObject(s) && typeof s["in"] === "string" && typeof s["sense"] === "string" ? [`${s["in"]} — ${s["sense"]}`] : [])); + return item["polysemous"] === true && disagree.length > 0 ? `${head} Its types disagree: ${disagree.join("; ")}` : head; + }, +}; + /** * The block as a list of facts, GENERICALLY: its shape moves with the prompt * version and differs per level, and a panel that named fields would lag every - * change. What says nothing — null, empty, false — is dropped. + * change. What says nothing — null, empty, false — is dropped. The glossary's + * two lists are the exception (`DEFINED`). */ export function blockFacts(block: Readonly>): BlockFact[] { return Object.entries(block).flatMap(([key, value]) => { if (NOT_A_FACT.has(key)) return []; - const values = valuesOf(value); + const defined = Object.hasOwn(DEFINED, key) ? DEFINED[key] : undefined; + const values = + defined !== undefined && Array.isArray(value) + ? value.flatMap((item) => { + const text = isObject(item) ? (defined(item) ?? objectText(item)) : scalarText(item); + return text === undefined ? [] : [text]; + }) + : valuesOf(value); return values.length === 0 ? [] : [{ label: labelOf(key), values }]; }); } diff --git a/packages/navigator-ui/src/style.css b/packages/navigator-ui/src/style.css index 7dae009..7be07d9 100644 --- a/packages/navigator-ui/src/style.css +++ b/packages/navigator-ui/src/style.css @@ -1961,3 +1961,59 @@ button.twisty:hover { grid-column: 1 / -1; margin-top: 0; } + +/* + * The glossary under a word's carriers: what the explanations said the word + * means. A model's reading, so it wears the explanation panel's border and + * badge (`.insight`): an inference never looks like a fact. + */ +.glossary { + margin-top: 10px; +} + +.glossary-scroll { + max-height: 40vh; + overflow: auto; +} + +.glossary .carrier-list { + max-height: none; + overflow: visible; +} + +.gloss-module { + padding: 3px 0 5px; + border-bottom: 1px solid var(--rule-soft); +} + +.gloss-sense { + grid-column: 1 / -1; + margin: 1px 0 0; + font-size: 12px; + color: var(--paper); +} + +.gloss-meaning { + margin: 2px 0 0; + font-size: 11px; + color: var(--paper-dim); +} + +.gloss-in { + font-family: var(--mono); + font-size: 11px; + color: var(--paper-faint); +} + +/* An operation's name can be a long signature: it shrinks, the confidence beside it stays in view. */ +.glossary .counterpart { + min-width: 0; +} + +.glossary .counterpart-name { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/navigator-ui/test/glossary.test.ts b/packages/navigator-ui/test/glossary.test.ts new file mode 100644 index 0000000..82347e5 --- /dev/null +++ b/packages/navigator-ui/test/glossary.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { GLOSSARY_ANSWER_KIND as CANONICAL_KIND } from "@codegraph/insights"; +import { GLOSSARY_ANSWER_KIND, createGlossaryLoader, glossaryUrl, parseGlossaryAnswer, type Fetcher } from "../src/glossary.js"; + +const MODULE = "java:com.acme.order"; +const SERVICE = "java:com.acme.order/OrderService"; +const BILL = "java:com.acme.order/OrderService.bill(com.acme.order.Order)"; + +const entry = (id: string, level: string, owner?: string) => ({ + id, level, kind: level === "operation" ? "method" : "class", name: id.slice(id.lastIndexOf("/") + 1), model: "m", + ...(owner === undefined ? {} : { owner }), + entry: { word: "order", in: ["OrderService"], concern: "domain", sense: "a customer's request", confidence: 0.8 }, +}); +const gloss = { id: MODULE, name: "com.acme.order", model: "m", entry: { word: "order", concern: "domain", sense: "a request", meaning: "What a customer asks for.", polysemous: true, senses: [{ in: "SortSpec", sense: "a sequence" }] } }; +const found = (extra: object = {}) => ({ + kind: CANONICAL_KIND, status: "found", term: "order", scope: SERVICE, total: { entries: 3, glossary: 1 }, + entries: [entry(SERVICE, "type"), entry(BILL, "operation", SERVICE)], glossary: [gloss], withoutWords: 2, ...extra, +}); +const respond = (status: number, body: unknown): Response => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +describe("the glossary's literal", () => { + /** THE DRIFT ALARM: the page cannot import @codegraph/insights, even for types. */ + it("restates the answer's kind exactly", () => { + expect(GLOSSARY_ANSWER_KIND).toBe(CANONICAL_KIND); + }); +}); + +describe("parseGlossaryAnswer", () => { + it("reads a found answer down to what the tab lists", () => { + expect(parseGlossaryAnswer(found())).toEqual({ + status: "found", term: "order", total: { entries: 3, glossary: 1 }, + entries: [entry(SERVICE, "type"), entry(BILL, "operation", SERVICE)], glossary: [gloss], withoutWords: 2, + }); + }); + + it("drops an entry it cannot show, keeps the rest", () => { + const answer = parseGlossaryAnswer(found({ entries: [entry(SERVICE, "type"), { id: BILL }, null, { ...entry(BILL, "operation"), entry: { word: "order" } }], glossary: [gloss, { id: MODULE }] })); + expect(answer.status === "found" && [answer.entries.map((e) => e.id), answer.glossary.length]).toEqual([[SERVICE], 1]); + }); + + it("an unknown scope is unknown; no store, another kind, anything malformed is nothing to show — never a throw", () => { + expect(parseGlossaryAnswer({ kind: CANONICAL_KIND, status: "unknown-scope", scope: "x" })).toEqual({ status: "unknown" }); + expect(parseGlossaryAnswer({ kind: CANONICAL_KIND, status: "no-store" })).toEqual({ status: "none" }); + expect(parseGlossaryAnswer({ kind: "codegraph.carriers/1", status: "found" })).toEqual({ status: "none" }); + expect(parseGlossaryAnswer(found({ total: 3 }))).toEqual({ status: "none" }); + expect(parseGlossaryAnswer(found({ entries: "none" }))).toEqual({ status: "none" }); + expect(parseGlossaryAnswer(null)).toEqual({ status: "none" }); + expect(parseGlossaryAnswer("")).toEqual({ status: "none" }); + }); +}); + +describe("glossary.json", () => { + it("asks for one word in one scope, the corpus by omission, relative to the page", () => { + expect(glossaryUrl({ term: "order", scope: SERVICE })).toBe(`glossary.json?term=order&scope=${encodeURIComponent(SERVICE)}`); + expect(glossaryUrl({ term: "order", scope: undefined })).toBe("glossary.json?term=order"); + }); + + it("asks once per question, and once at all where there is no such route", async () => { + const urls: string[] = []; + const loader = createGlossaryLoader(((url) => { + urls.push(url); + return Promise.resolve(respond(200, found())); + }) satisfies Fetcher); + await Promise.all([loader.load({ term: "order", scope: SERVICE }), loader.load({ term: "order", scope: SERVICE })]); + expect((await loader.load({ term: "order", scope: undefined })).status).toBe("found"); + expect(urls).toEqual([glossaryUrl({ term: "order", scope: SERVICE }), "glossary.json?term=order"]); + + // A 404 with the package's answer is an answer, and asking goes on. + let asked = 0; + const absent = createGlossaryLoader(() => { + asked += 1; + return Promise.resolve(respond(404, { kind: CANONICAL_KIND, status: "no-store" })); + }); + expect(await absent.load({ term: "order", scope: undefined })).toEqual({ status: "none" }); + await absent.load({ term: "line", scope: undefined }); + expect(asked).toBe(2); + // The app daemon has no such route: its bare 404 is not JSON, asked once. + let probes = 0; + const daemon = createGlossaryLoader(() => { + probes += 1; + return Promise.resolve(new Response("Not Found", { status: 404 })); + }); + await daemon.load({ term: "order", scope: undefined }); + await daemon.load({ term: "line", scope: undefined }); + expect(probes).toBe(1); + }); +}); diff --git a/packages/navigator-ui/test/insight.test.ts b/packages/navigator-ui/test/insight.test.ts index 6d651b0..7373fe2 100644 --- a/packages/navigator-ui/test/insight.test.ts +++ b/packages/navigator-ui/test/insight.test.ts @@ -121,3 +121,39 @@ describe("blockFacts: the block as the panel lists it", () => { ]); }); }); + +describe("blockFacts: the words a block defines (M19c)", () => { + it("reads each word as word (concern) — sense, its meaning, where it is and how sure — never the identifiers in a definition's place", () => { + expect(blockFacts({ + words: [ + { word: "order", in: ["Order", "sortOrder"], concern: "domain", sense: "a customer's request", meaning: "What a customer asks the shop for.", confidence: 0.9 }, + { word: "service", in: ["OrderService"], concern: "technical", sense: "a layer", meaning: "", confidence: 0.55 }, + ], + })).toEqual([{ + label: "words", + values: [ + "order (domain) — a customer's request. What a customer asks the shop for. In Order, sortOrder · 0.90", + "service (technical) — a layer. In OrderService · 0.55", + ], + }]); + }); + + it("reads a module's glossary with the types that disagree, when they do", () => { + expect(blockFacts({ + glossary: [ + { word: "order", concern: "domain", sense: "a customer's request", meaning: "What a customer asks for.", polysemous: true, senses: [{ in: "Order", sense: "a request" }, { in: "SortSpec", sense: "a sequence" }] }, + { word: "line", concern: "domain", sense: "an order line", meaning: "One article of an order.", polysemous: false, senses: [] }, + ], + })).toEqual([{ + label: "glossary", + values: [ + "order (domain) — a customer's request. What a customer asks for. Its types disagree: Order — a request; SortSpec — a sequence", + "line (domain) — an order line. One article of an order.", + ], + }]); + }); + + it("falls back to the generic reading for an entry of another shape", () => { + expect(blockFacts({ words: [{ word: "order", note: "kept" }], glossary: [] })).toEqual([{ label: "words", values: ["order — kept"] }]); + }); +}); From 1db44a13dd77653b5ccd993e6a57525e37f15f6a Mon Sep 17 00:00:00 2001 From: Jeremie Grodziski Date: Wed, 28 Oct 2026 22:39:00 +0000 Subject: [PATCH 5/6] docs: the glossary in the page serve's third lookup, /glossary.json, in docs/cli.md and the website's serve page (and the daemon's lack of it); the Vocabulary tab's glossary on the vocabulary page; answerGlossary and its placement in docs/insights.md. The --insights help now names both panels it feeds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKCVgyop39vhNa45r7zM8w --- docs/cli.md | 18 ++++++++++++++++-- docs/insights.md | 17 +++++++++++++++-- packages/cli/src/args.ts | 4 ++-- website/content/docs/reference/cli/serve.md | 6 +++--- .../content/docs/reference/cli/vocabulary.md | 2 +- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index db6efc7..94217e2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -181,14 +181,28 @@ artifact, `/insight.json?id=…`, looked up in `.insights.db` beside the first model (or `--insights FILE`) when asked for. With no store the route answers 404 and the page looks exactly as it did. +Over the same store, a clicked word on the Vocabulary tab also shows **what +the explanations under that scope said it means**: each module's +reconciliation (and the types that disagree on it), then each explained +entity's entry — its concern, sense and confidence, and the identifiers the +word is in — in the same inference colour and badge, each one click from its +node (an operation reveals its type). That is the server's third lookup, +`/glossary.json?term=WORD[&scope=ID][&limit=N]`: the entries of the entities +the model folds under the scope, as the carriers are, from the store's word +index; the first `limit` (200) of each list, and their totals. An unknown +scope is `404`, and so is having no store (the tab then shows nothing); no +term, or a limit that is no whole number, is `400`. Navigate's explanation +panel shows the same words for a selected type or module, with their +meaning, in its structured block. + **`--app`** is the same page as the daemon behind the desktop app (and the development loop for it: run it from a checkout, open the URL in a browser). No model on argv. It binds `127.0.0.1:0` unless `--host`/`--port` say otherwise, prints exactly one JSON line on stdout — `{"port":N,"token":"…"}` — and serves every route under `//`: the page, `app` (the registry and the current project), `recent`, `navigator.json`, `city.json`, -`vocabulary.json` (no `carriers.json`: the daemon holds no model between -jobs, and the tab shows the counts without the names), +`vocabulary.json` (no `carriers.json` or `glossary.json`: the daemon holds no +model or store between jobs, and the tab shows the counts without the names), `POST jobs` with `{"src": DIR-or-model.jsonl, "extractor"?: NAME}` and the `jobs/current` event stream (`started`, `phase`, `progress` — the extractor's own stderr lines — `done`, `failed`; `idle` when nothing runs). diff --git a/docs/insights.md b/docs/insights.md index b02e0f5..67d30f2 100644 --- a/docs/insights.md +++ b/docs/insights.md @@ -264,8 +264,8 @@ id: *explained* (the record), *failed* (the unit's failure — matched by member so every member of a failed cycle is answered), or *unknown*. "No explanation" is two different facts, and only one of them is silence. -Two readers answer about records (a third, `vocabulary --drift-insights`, -about words: below). `codegraph insights` (cli.md) opens the store and nothing +Two readers answer about records (two more, `vocabulary --drift-insights` and +`serve`'s `/glossary.json`, about words: below). `codegraph insights` (cli.md) opens the store and nothing else — not the model, not `explain.ts`, whose module graph is the provider SDK. And `codegraph serve` answers `/insight.json?id=…`, its one route that is not an artifact: tens of megabytes of prose do not belong in `navigator.json`, so a @@ -309,6 +309,19 @@ every entry through `wordHits()`, hands them to `@codegraph/vocabulary` in that package's own shape (neither package imports the other), and sets the model's concern beside the list's class, word by word, with the entities. +The page asks the same question of one word: `codegraph serve` answers +`/glossary.json?term=…&scope=…` with `answerGlossary(store, query, placement)`, +the entries of the entities under a module or a type, and the modules' +reconciliations. Which entities are under a scope is the model's (only the +model knows an operation's type), so the CLI hands in a `Placement` — each +kept entity's containing module and type, by the fold the vocabulary counts +with — and this package still opens no model. The route reads the store +`/insight.json` reads, through one reader. The kind (`codegraph.glossary/1`) +is restated by the page and pinned, and the page shows the entries in the +inference colour; the explanation panel reads a block's `words` and `glossary` +by name (`order (domain) — sense. Meaning. In Order · 0.90`), the one exception +to its generic rendering. + `@codegraph/insights` stays pure: `store.ts` is a port, and `store-sqlite.ts` is handed an OPEN database, so the path, the file and `node:sqlite` itself (one import site, in the analyzer's `store/sqlite.ts`) stay with the CLI. diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index f29d350..531afc1 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -527,7 +527,7 @@ export const SERVE_SPEC: CommandSpec = { name: "insights", type: "string", describe: - "The insights store behind the page's explanation panel (what 'codegraph explain' wrote). " + + "The insights store behind the page's explanation panel and the Vocabulary tab's glossary (what 'codegraph explain' wrote). " + "Default: .insights.db beside the first model; with none, or one an older codegraph wrote that no 'codegraph explain' has migrated yet, the page shows no explanations.", placeholder: "FILE", }, @@ -1309,7 +1309,7 @@ export interface ServeOptions extends ModelInputOptions, CityBuildOptions, Cache readonly host: string; /** `--app`: the daemon form; `models` is then empty. */ readonly app: AppOptions | undefined; - /** `--insights FILE`: the insights store behind the explanation panel; undefined = beside the first model. */ + /** `--insights FILE`: the insights store behind the explanation panel and the glossary; undefined = beside the first model. */ readonly insights: string | undefined; } diff --git a/website/content/docs/reference/cli/serve.md b/website/content/docs/reference/cli/serve.md index a15a78a..1cba346 100644 --- a/website/content/docs/reference/cli/serve.md +++ b/website/content/docs/reference/cli/serve.md @@ -5,7 +5,7 @@ weight: 5 Serve the navigator with the code city as a tab, over this model. -One page, built from one graph under one view: the **Navigate** tab (tree and per-node incoming/outgoing dependencies), the **City** tab (the 3D city, full-width; a selected building offers *Open in navigator*, which reveals that type on Navigate), then **Graph**, **Cycles**, **Coupling** and **Vocabulary** (the selected scope's words as a cloud and a table; a word lists the names that carry it, each one click from Navigate). The server hands out `/navigator.json`, `/city.json` and `/vocabulary.json` — each exactly the file [`navigator`](/docs/reference/cli/navigator/), [`city --layout`](/docs/reference/cli/city/) and [`vocabulary --out`](/docs/reference/cli/vocabulary/) would have written — beside the built frontend, and answers two lookups: `/insight.json?id=ID`, a node's explanation from the insights store, and `/carriers.json?term=WORD[&scope=ID][&class=CLASS][&limit=N]`, the names of a scope that carry a word, from the model it holds (`404` for an unknown scope or term, `400` for a request it cannot ask). Stdout stays empty; the process runs until Ctrl-C. +One page, built from one graph under one view: the **Navigate** tab (tree and per-node incoming/outgoing dependencies), the **City** tab (the 3D city, full-width; a selected building offers *Open in navigator*, which reveals that type on Navigate), then **Graph**, **Cycles**, **Coupling** and **Vocabulary** (the selected scope's words as a cloud and a table; a word lists the names that carry it, each one click from Navigate, and what the explanations under the scope said it means). The server hands out `/navigator.json`, `/city.json` and `/vocabulary.json` — each exactly the file [`navigator`](/docs/reference/cli/navigator/), [`city --layout`](/docs/reference/cli/city/) and [`vocabulary --out`](/docs/reference/cli/vocabulary/) would have written — beside the built frontend, and answers three lookups: `/insight.json?id=ID`, a node's explanation from the insights store; `/carriers.json?term=WORD[&scope=ID][&class=CLASS][&limit=N]`, the names of a scope that carry a word, from the model it holds (`404` for an unknown scope or term, `400` for a request it cannot ask); and `/glossary.json?term=WORD[&scope=ID][&limit=N]`, what the explained entities under a scope said the word means, from the insights store (`404` for an unknown scope or with no store, `400` for a request it cannot ask). Stdout stays empty; the process runs until Ctrl-C. ## Synopsis @@ -37,7 +37,7 @@ codegraph serve --app [--data-dir DIR] [--extractors FILE] [--port N] [--host AD | `--internal-only` | Drop stub (external) entities and every edge touching one. | — | | `--declared-only` | Keep only `declared` facts; drop derived and dynamic-candidate edges. | — | | `--no-cache` | Read the model.jsonl directly; never build or reuse a sibling model.db. | — | -| `--insights FILE` | The insights store behind the page's explanation panel (what 'codegraph explain' wrote). With none, or one an older codegraph wrote that no 'codegraph explain' has migrated yet, the page shows no explanations. | `.insights.db` beside the first model | +| `--insights FILE` | The insights store behind the page's explanation panel and the Vocabulary tab's glossary (what 'codegraph explain' wrote). With none, or one an older codegraph wrote that no 'codegraph explain' has migrated yet, the page shows no explanations. | `.insights.db` beside the first model | | `--app` | The desktop app's daemon: no model on argv; bind 127.0.0.1:0 under a per-launch token, print one JSON line {port, token} on stdout, open folders through POST /jobs, exit when stdin closes. | — | | `--data-dir DIR` | With --app: where models, their model.db caches, the page's artifacts and the recents live. | the OS application-data directory, `codegraph/` under it | | `--extractors FILE` | With --app: the extractor registry, a JSON list of { name, path, extensions[], launch?, env? } — the daemon runs an entry, never a language. | — (only model.jsonl files open) | @@ -56,7 +56,7 @@ loop for that app: run it from a checkout and open the URL in a browser. - **Routes.** The page and its assets; `app` (the registered extractors and the current project); `recent`; `navigator.json`, `city.json` and `vocabulary.json` (the current project's artifacts, `404` until one is - open; no `carriers.json`, since the daemon holds no model between jobs); `POST jobs` with + open; no `carriers.json` or `glossary.json`, since the daemon holds no model or store between jobs); `POST jobs` with `{"src": "", "extractor"?: ""}` → `202` with the job, `409` while another runs, `422` when several extractors claim the folder (`candidates`, most files first — the page asks, never guesses), diff --git a/website/content/docs/reference/cli/vocabulary.md b/website/content/docs/reference/cli/vocabulary.md index ef7a8b5..bb30f40 100644 --- a/website/content/docs/reference/cli/vocabulary.md +++ b/website/content/docs/reference/cli/vocabulary.md @@ -67,7 +67,7 @@ The judgements are inferences, so they never set the exit code; `--drift` still `--svg FILE` draws the cloud of the one scope the report shows, the corpus or the one `--scope` names, from the words `--class` and `--top` pick. A word's size follows the square root of its weight, so its area reads as the weight, and its colour says its class and nothing else: `domain` in ink, `technical` muted, `keyword` and `short` faint, with a dark palette for a dark page. Words are placed heaviest first, each at the first free point of a spiral out from the centre. The legend states the scope, the counts, the weights, the view and the technical list. The font is monospace, so the bytes are the same on every run and any machine. -[`serve`](/docs/reference/cli/serve/)'s **Vocabulary** tab runs the same layout in the browser, measured in the page's own monospace face, over the scope selected in Navigate, and lists the names that carry a clicked word. +[`serve`](/docs/reference/cli/serve/)'s **Vocabulary** tab runs the same layout in the browser, measured in the page's own monospace face, over the scope selected in Navigate, and lists the names that carry a clicked word. Where `explain` has been run, it also lists what the explanations under that scope said the word means, each entry marked as a model's reading, never as a fact. ## In the city From bbdf663c6f4032d2941cf3debd6ccee4ba1eb86a Mon Sep 17 00:00:00 2001 From: Jeremie Grodziski Date: Wed, 28 Oct 2026 22:39:00 +0000 Subject: [PATCH 6/6] docs(plan): the glossary in the page as built Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKCVgyop39vhNa45r7zM8w --- PLAN.md | 46 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/PLAN.md b/PLAN.md index 94d93de..f6ff752 100644 --- a/PLAN.md +++ b/PLAN.md @@ -5586,6 +5586,46 @@ store's `wordHits()`, which with no word now returns every entry. The decisions: M1 of §20.1), at the same peak memory (≈ 0.5 GB). A store a model wrote is owed, as above. +**As built — the glossary in the page (2026-10-28).** `/glossary.json`, the +Vocabulary tab's entries and the Navigate panel's, over `answerGlossary` in +`@codegraph/insights` (`glossary.ts`). The decisions: + +- **The scope is the model's, injected.** `answerGlossary(store, query, + placement)` takes a `Placement` (an id → its containing module and type); + the CLI builds it from the analyzer's fold over the entities the view keeps, + so a word's carriers and its glossary are of the same entities, and the + package still opens no model. Under a module: what folds into it, its own + record included; under a type: the type and its members. A module's + reconciliation is under its module and the corpus, not under a type. A scope + is a kept module or type; anything else is `unknown-scope`, 404. +- **One store reader.** `insightLookup` and `glossaryLookup` share + `storeReader(path)`, opened at the first request that finds the file. No + store is `{status: "no-store"}`, 404, and the tab shows nothing. The + placement is built at the first request a store can answer, then the graph + is let go, as for the carriers. +- **The answer** is the projection's entries without the natural key (the + page has no use for it), an operation's `owner` (its type, else its module: + the node a page reveals), ordered module, types, operations, then by id; the + modules' glossary entries whole. Each list is cut at `limit` (200) with its + total. No `meaning` per entity: the projection does not hold it, and the + panel shows it from the block. `withoutWords` is the store's. +- **The tab** lists them under the clicked word's carriers, inside the + explanation panel's border and badge (`LLM glossary`), with the models that + wrote them; an entry reveals its entity in Navigate (an operation, its type); + the prompt-1 records are counted in a footnote. +- **The panel reads `words` and `glossary` by name** — the one exception to + its generic block rendering, since generically a word entry showed the + identifiers where a definition reads, and a glossary entry lost the types + that disagree: `order (domain) — sense. Meaning. In Order · 0.90`. +- **One remembered lookup** (`lookup.ts`): the insight, carriers and glossary + loaders ask once per URL and stop asking a server with no such route. +- **Measured** on the Broadleaf store of the previous note: the first request + takes 0.21 s with the placement built, later ones 0.11 s; `order` over the + corpus is 1 908 entries, 200 sent (53 KB). The server's RSS after a click + is the same within noise (605 MB, against 619 MB without the route). + Screenshots reviewed at 1600×1000 on the Java fixture (a hand-worded store + over its ids) and on Broadleaf. + ### 20.4 Milestone split **M19a — the lexicon, the split, the artifact** (closes the profile half of #19) @@ -5660,11 +5700,9 @@ store's `wordHits()`, which with no word now returns every entry. The decisions: Broadleaf measurement. *(In: `--terms`, the estimate's per-word figures, the fake-client run, the prompt-1 answer. Owed: the measurement, live runs.)* -- [ ] Readers: `insights --word`, `--glossary`, `--polysemy`, `--concerns`; +- [x] Readers: `insights --word`, `--glossary`, `--polysemy`, `--concerns`; `vocabulary --drift-insights`; `/glossary.json`; the entries in the - Vocabulary tab and in the Navigate panel as inferences. *(In: the four - `insights` readers, `--drift-insights`. Owed: `/glossary.json`, the tab - and the panel.)* + Vocabulary tab and in the Navigate panel as inferences. - [ ] `docs/vocabulary.md` (the idioms per profile, the classes, the weights, the artifact, the three layers, the readers, the drift reports); README names the feature; `docs/cli.md` the command and the