From d52880959d9edb819e5f4cf2807cc14b144fc349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Grodziski?= Date: Mon, 26 Oct 2026 12:50:45 +0100 Subject: [PATCH 1/8] refactor(vocabulary): one reading rule per entity The artifact folds what a name says into every scope the entity belongs to; the carriers lookup will keep it per entity. Both now read through reading.ts (the split, the class, the role's weight, the qualifier), so a carrier's share is exactly what the row counted. Snapshots unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/vocabulary/src/aggregate.ts | 121 ++++--------------------- packages/vocabulary/src/reading.ts | 131 +++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 105 deletions(-) create mode 100644 packages/vocabulary/src/reading.ts diff --git a/packages/vocabulary/src/aggregate.ts b/packages/vocabulary/src/aggregate.ts index b704fb5..e88c100 100644 --- a/packages/vocabulary/src/aggregate.ts +++ b/packages/vocabulary/src/aggregate.ts @@ -1,14 +1,5 @@ -import { createHash } from "node:crypto"; import { getProfile, type Entity, type EntityId } from "@codegraph/core"; -import { - compareIds, - entityName, - folderFor, - hasTrait, - identityView, - type CodeGraph, - type View, -} from "@codegraph/analyzer"; +import { compareIds, entityName, folderFor, hasTrait, identityView, type CodeGraph, type View } from "@codegraph/analyzer"; import { VOCABULARY_ARTEFACT_KIND, VOCABULARY_GENERATOR, @@ -19,9 +10,9 @@ import { type VocabularyScope, type VocabularyTerm, } from "./artifact.js"; -import { DEFAULT_TECHNICAL, TERM_CLASSES, createClassifier, type Classify, type TermClass } from "./classify.js"; -import { splitIdentifier } from "./split.js"; -import { DEFAULT_WEIGHTS, ROLES, WEIGHT_KEYS, roleOf, structuralSlots, type Role, type Weights } from "./weight.js"; +import { DEFAULT_TECHNICAL, TERM_CLASSES, type TermClass } from "./classify.js"; +import { Interner, keptEntities, readerFor, technicalDigest } from "./reading.js"; +import { DEFAULT_WEIGHTS, ROLES, WEIGHT_KEYS, type Role, type Weights } from "./weight.js"; export interface VocabularyOptions { readonly view?: View; @@ -58,11 +49,7 @@ export function buildVocabulary(graph: CodeGraph, options: VocabularyOptions = { const folder = folderFor(graph); const corpus = corpusOf(graph, options.name); - const kept: Entity[] = []; - for (const id of graph.ids()) { - const entity = graph.entity(id); - if (entity !== undefined && !graph.isStub(id) && view.entity(entity, graph)) kept.push(entity); - } + const kept = keptEntities(graph, view); // --- the scopes: kept modules and types, by trait -------------------- const root = tally("corpus"); @@ -108,7 +95,6 @@ export function buildVocabulary(graph: CodeGraph, options: VocabularyOptions = { return scopeOf(scope, rows, order, parentScope(graph, scope.entity, scopeIndex), corpus.name); }); - const words = [...new Set(technical)].sort(compareIds); return { kind: VOCABULARY_ARTEFACT_KIND, generatedBy: VOCABULARY_GENERATOR, @@ -117,7 +103,7 @@ export function buildVocabulary(graph: CodeGraph, options: VocabularyOptions = { langs: graph.union.langs, weights: Object.fromEntries(WEIGHT_KEYS.map((key) => [key, weights[key]])) as Weights, classes: TERM_CLASSES, - technical: { words: words.length, sha256: createHash("sha256").update(words.join("\n"), "utf8").digest("hex") }, + technical: technicalDigest(technical), terms: order.map(({ text, class: termClass }, i): VocabularyTerm => { const { modules: speaking, types: typed, exclusive } = spread[i] ?? { modules: 0, types: 0, exclusive: -1 }; return { text, class: termClass, modules: speaking, types: typed, ...(speaking === 1 ? { exclusive } : {}) }; @@ -194,10 +180,9 @@ function isOperation(entity: Entity): boolean { } /** - * Counts one entity's words into every scope given: its role's weight per - * word, `qualifier` before the last separator, one carrier per word however - * often the name says it. The one counting rule of the artifact and of an - * operation's scope. + * Folds one entity's reading into every scope given: one carrier per word + * however often the name says it. The one folding rule of the artifact and of + * an operation's scope; the reading itself is `reading.ts`'s. */ function speakerFor( graph: CodeGraph, @@ -205,47 +190,18 @@ function speakerFor( weights: Weights, technical: readonly string[], ): (entity: Entity, into: readonly Tally[]) => void { - const slots = structuralSlots(graph.entities.values()); - const langOf = languagesOf(graph); - const classifiers = new Map(); - const classifierOf = (lang: string): Classify => { - let classify = classifiers.get(lang); - if (classify === undefined) { - classify = createClassifier(getProfile(lang)?.lexicon, technical); - classifiers.set(lang, classify); - } - return classify; - }; - + const read = readerFor(graph, terms, weights, technical); return (entity, into) => { + const reading = read(entity); for (const scope of into) scope.entities += 1; - const name = entityName(entity); - if (name === undefined) { - for (const scope of into) scope.skipped.unnamed += 1; + if (reading.said === "nothing") { + for (const scope of into) scope.skipped[reading.why] += 1; return; } - const lang = langOf.get(entity.id) ?? ""; - const split = splitIdentifier(name, entity.kind, getProfile(lang)?.lexicon); - if (split.conventional) { - for (const scope of into) scope.skipped.conventional += 1; - return; - } - const role = roleOf(entity, slots); - const classify = classifierOf(lang); - - // This entity's own table first: a word it says twice has one carrier. - const own = new Map(); - split.tokens.forEach((token, i) => { - const term = terms.intern(token, classify(token)); - const row = own.get(term) ?? [0, 0]; - row[0] += i < split.qualifier ? weights.qualifier : weights[role]; - row[1] += 1; - own.set(term, row); - }); for (const scope of into) { - scope.skipped.numeric += split.numeric; - scope.roles[role] += 1; - for (const [term, [weight, occurrences]] of own) { + scope.skipped.numeric += reading.numeric; + scope.roles[reading.role] += 1; + for (const [term, [weight, occurrences]] of reading.words) { const row = scope.table.get(term) ?? [0, 0, 0]; row[0] += weight; row[1] += occurrences; @@ -319,51 +275,6 @@ function parentScope( return 0; } -/** Each id's language, from the model that declares it first — the graph's own rule. */ -function languagesOf(graph: CodeGraph): Map { - const langs = new Map(); - for (const model of graph.union.models) { - for (const entity of model.entities) if (!langs.has(entity.id)) langs.set(entity.id, model.lang); - } - return langs; -} - -/** Words interned once per (text, class): a class is decided per language. */ -class Interner { - private readonly ids = new Map>(); - private readonly entries: { readonly id: number; readonly text: string; readonly class: TermClass }[] = []; - - /** Seeded with an artifact's terms, an interned word IS its index there. */ - constructor(seed: readonly { readonly text: string; readonly class: TermClass }[] = []) { - for (const { text, class: termClass } of seed) this.intern(text, termClass); - } - - size(): number { - return this.entries.length; - } - - intern(text: string, termClass: TermClass): number { - let byClass = this.ids.get(text); - if (byClass === undefined) { - byClass = new Map(); - this.ids.set(text, byClass); - } - const existing = byClass.get(termClass); - if (existing !== undefined) return existing; - const id = this.entries.length; - byClass.set(termClass, id); - this.entries.push({ id, text, class: termClass }); - return id; - } - - /** By text in code-unit order, then by the classes' order: the same bytes on every machine. */ - sorted(): readonly { readonly id: number; readonly text: string; readonly class: TermClass }[] { - return [...this.entries].sort( - (a, b) => compareIds(a.text, b.text) || TERM_CLASSES.indexOf(a.class) - TERM_CLASSES.indexOf(b.class), - ); - } -} - /** * The corpus line of the artifact — the city's and the navigator's rule: * deduped, sorted root basenames (or the caller's override), roots verbatim. diff --git a/packages/vocabulary/src/reading.ts b/packages/vocabulary/src/reading.ts new file mode 100644 index 0000000..32d992f --- /dev/null +++ b/packages/vocabulary/src/reading.ts @@ -0,0 +1,131 @@ +import { createHash } from "node:crypto"; +import { getProfile, type Entity, type EntityId } from "@codegraph/core"; +import { compareIds, entityName, type CodeGraph, type View } from "@codegraph/analyzer"; +import { TERM_CLASSES, createClassifier, type Classify, type TermClass } from "./classify.js"; +import { splitIdentifier } from "./split.js"; +import { roleOf, structuralSlots, type Role, type Weights } from "./weight.js"; + +/** + * THE ONE COUNTING RULE, read once per entity: what its name says, under one + * set of weights and one technical list. The artifact folds a reading into + * every scope the entity belongs to; the carriers keep it per entity. Both + * read through here, so a carrier's share is exactly what the row counted. + * + * Internal: `index.ts` does not export it. + */ + +/** What one kept entity's name contributes: nothing, and why, or its words. */ +export type Reading = + | { readonly said: "nothing"; readonly why: "unnamed" | "conventional" } + | { + readonly said: "words"; + readonly role: Role; + /** Digit-only pieces dropped from the name. */ + readonly numeric: number; + /** Interned term → `[weight, occurrences]`; a word said twice is one entry. */ + readonly words: ReadonlyMap; + }; + +/** Kept is the view's verdict on a non-stub entity: a stub's name is a word another corpus chose. */ +export function keptEntities(graph: CodeGraph, view: View): Entity[] { + const kept: Entity[] = []; + for (const id of graph.ids()) { + const entity = graph.entity(id); + if (entity !== undefined && !graph.isStub(id) && view.entity(entity, graph)) kept.push(entity); + } + return kept; +} + +/** + * Reads one entity's words: its role's weight per word, `qualifier` before the + * last separator of a qualified name. An entity's language is its model's — + * read from the union, never from its id. + */ +export function readerFor( + graph: CodeGraph, + terms: Interner, + weights: Weights, + technical: readonly string[], +): (entity: Entity) => Reading { + const slots = structuralSlots(graph.entities.values()); + const langOf = languagesOf(graph); + const classifiers = new Map(); + const classifierOf = (lang: string): Classify => { + let classify = classifiers.get(lang); + if (classify === undefined) { + classify = createClassifier(getProfile(lang)?.lexicon, technical); + classifiers.set(lang, classify); + } + return classify; + }; + + return (entity) => { + const name = entityName(entity); + if (name === undefined) return { said: "nothing", why: "unnamed" }; + const lang = langOf.get(entity.id) ?? ""; + const split = splitIdentifier(name, entity.kind, getProfile(lang)?.lexicon); + if (split.conventional) return { said: "nothing", why: "conventional" }; + const role = roleOf(entity, slots); + const classify = classifierOf(lang); + const words = new Map(); + split.tokens.forEach((token, i) => { + const term = terms.intern(token, classify(token)); + const row = words.get(term) ?? [0, 0]; + row[0] += i < split.qualifier ? weights.qualifier : weights[role]; + row[1] += 1; + words.set(term, row); + }); + return { said: "words", role, numeric: split.numeric, words }; + }; +} + +/** The technical list as the artifact states it: its distinct words, sorted, and a sha256 over them, one per line. */ +export function technicalDigest(technical: readonly string[]): { readonly words: number; readonly sha256: string } { + const words = [...new Set(technical)].sort(compareIds); + return { words: words.length, sha256: createHash("sha256").update(words.join("\n"), "utf8").digest("hex") }; +} + +/** Each id's language, from the model that declares it first — the graph's own rule. */ +function languagesOf(graph: CodeGraph): Map { + const langs = new Map(); + for (const model of graph.union.models) { + for (const entity of model.entities) if (!langs.has(entity.id)) langs.set(entity.id, model.lang); + } + return langs; +} + +/** Words interned once per (text, class): a class is decided per language. */ +export class Interner { + private readonly ids = new Map>(); + private readonly entries: { readonly id: number; readonly text: string; readonly class: TermClass }[] = []; + + /** Seeded with an artifact's terms, an interned word IS its index there. */ + constructor(seed: readonly { readonly text: string; readonly class: TermClass }[] = []) { + for (const { text, class: termClass } of seed) this.intern(text, termClass); + } + + size(): number { + return this.entries.length; + } + + intern(text: string, termClass: TermClass): number { + let byClass = this.ids.get(text); + if (byClass === undefined) { + byClass = new Map(); + this.ids.set(text, byClass); + } + const existing = byClass.get(termClass); + if (existing !== undefined) return existing; + const id = this.entries.length; + byClass.set(termClass, id); + this.entries.push({ id, text, class: termClass }); + return id; + } + + /** By text in code-unit order, then by the classes' order: the same bytes on every machine. */ + sorted(): readonly { readonly id: number; readonly text: string; readonly class: TermClass }[] { + return [...this.entries].sort( + (a, b) => compareIds(a.text, b.text) || TERM_CLASSES.indexOf(a.class) - TERM_CLASSES.indexOf(b.class), + ); + } +} From 15ca4369a0b89c5745707bcb0afa636aea3fa38e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Grodziski?= Date: Mon, 26 Oct 2026 12:50:45 +0100 Subject: [PATCH 2/8] feat(vocabulary): the carriers of a scope's term vocabularyCarriers(graph, artifact, options) reads every kept entity once and answers which names of a stored scope carry a term, each with what it added to the row. A row is the sum of its carriers: a property over every term of every scope of the six fixtures. A type or module carries its id, a member the owner a page reveals it in; an artifact counted under other weights, list or view is refused. Co-Authored-By: Claude Opus 5.5 --- packages/vocabulary/src/carriers.ts | 228 ++++++++++++++++++++++ packages/vocabulary/src/index.ts | 1 + packages/vocabulary/test/carriers.test.ts | 187 ++++++++++++++++++ 3 files changed, 416 insertions(+) create mode 100644 packages/vocabulary/src/carriers.ts create mode 100644 packages/vocabulary/test/carriers.test.ts diff --git a/packages/vocabulary/src/carriers.ts b/packages/vocabulary/src/carriers.ts new file mode 100644 index 0000000..e7096a3 --- /dev/null +++ b/packages/vocabulary/src/carriers.ts @@ -0,0 +1,228 @@ +import type { EntityId, SourceAnchor } from "@codegraph/core"; +import { compareIds, entityName, folderFor, identityView, type CodeGraph } from "@codegraph/analyzer"; +import type { VocabularyOptions } from "./aggregate.js"; +import type { ScopeLevel, VocabularyArtifact, VocabularyScope } from "./artifact.js"; +import { DEFAULT_TECHNICAL, TERM_CLASSES, type TermClass } from "./classify.js"; +import { Interner, keptEntities, readerFor, technicalDigest } from "./reading.js"; +import { DEFAULT_WEIGHTS, WEIGHT_KEYS, type Role } from "./weight.js"; + +/** + * A SCOPE'S TERM, BACK TO ITS NAMES (PLAN.md §20.2): which entities of a + * stored scope carry a word, and what each added to the row. The artifact + * counts and never lists (a list per row would multiply its size by the + * carriers); this answers one row on demand, from the model a server holds. + * + * A ROW IS THE SUM OF ITS CARRIERS. Each entity is read by the artifact's own + * rule (`reading.ts`) under the artifact's own counting — weights, technical + * list, view — so the carriers' weights and occurrences add up to the row, + * and their number is its `carriers`. An artifact counted otherwise is refused. + * + * THE NAVIGATOR'S HAND-OFF KEY: a type or a module carries its rendered id, + * as a navigator node does; a member carries none, and `owner` names the type + * (else the module) the fold puts it in — the node a page reveals for it. + */ + +export const CARRIERS_ANSWER_KIND = "codegraph.carriers/1"; + +/** How many carriers an answer lists unless asked for another number; `total` always counts them all. */ +export const CARRIERS_LIMIT = 200; + +export interface Carrier { + /** The rendered id of a type or a module; absent on a member. Compared, never parsed. */ + readonly id?: string; + readonly name: string; + readonly kind: string; + readonly role: Role; + /** What this name added to the row. */ + readonly weight: number; + readonly occurrences: number; + readonly anchor?: SourceAnchor; + /** A member's type, else its module: the scope a page reveals it in. */ + readonly owner?: string; +} + +export interface CarriersQuery { + /** A stored scope's id: a module or a type. Absent for the corpus. */ + readonly scope?: string; + readonly term: string; + /** Needed only where the scope speaks the text as two terms: two languages class it apart. */ + readonly class?: TermClass; + /** A whole number >= 0; `CARRIERS_LIMIT` by default. */ + readonly limit?: number; +} + +export interface ScopeRef { + readonly level: ScopeLevel; + readonly id?: string; + readonly name: string; +} + +export type CarriersAnswer = + | { + readonly kind: typeof CARRIERS_ANSWER_KIND; + readonly status: "found"; + readonly scope: ScopeRef; + readonly term: { readonly text: string; readonly class: TermClass }; + /** The row: its weight and occurrences, and `total` its carriers. */ + readonly weight: number; + readonly occurrences: number; + readonly total: number; + /** By weight descending, then id; the first `limit`. */ + readonly carriers: readonly Carrier[]; + } + | { readonly kind: typeof CARRIERS_ANSWER_KIND; readonly status: "unknown-scope"; readonly scope: string } + | { + readonly kind: typeof CARRIERS_ANSWER_KIND; + readonly status: "unknown-term"; + readonly scope: ScopeRef; + readonly term: string; + readonly class?: TermClass; + } + | { + readonly kind: typeof CARRIERS_ANSWER_KIND; + readonly status: "ambiguous"; + readonly scope: ScopeRef; + readonly term: string; + readonly classes: readonly TermClass[]; + }; + +export interface CarrierIndex { + answer(query: CarriersQuery): CarriersAnswer; +} + +/** One kept entity that spoke: who it is, where the fold puts it, what it said. */ +interface Speaker { + readonly id: EntityId; + readonly carrier: Omit; + /** Indexes into the artifact's scopes; -1 when no stored scope of that level holds it. */ + readonly module: number; + readonly type: number; + readonly words: ReadonlyMap; +} + +/** + * Read every kept entity once, and answer rows from that. `options` must be + * the ones `artifact` was built under — the same as `buildVocabulary`'s. + */ +export function vocabularyCarriers( + graph: CodeGraph, + artifact: VocabularyArtifact, + options: VocabularyOptions = {}, +): CarrierIndex { + const view = options.view ?? identityView; + const weights = options.weights ?? DEFAULT_WEIGHTS; + const technical = technicalDigest(options.technical ?? DEFAULT_TECHNICAL); + if ( + JSON.stringify(view.descriptor) !== JSON.stringify(artifact.view) || + WEIGHT_KEYS.some((key) => weights[key] !== artifact.weights[key]) || + technical.sha256 !== artifact.technical.sha256 + ) { + throw new Error("vocabularyCarriers: the artifact was built under other options — its rows are not this count's"); + } + + const modules = new Map(); + const types = new Map(); + artifact.scopes.forEach((scope, i) => { + if (scope.id === undefined) return; + if (scope.level === "module") modules.set(scope.id, i); + else if (scope.level === "type") types.set(scope.id, i); + }); + + const terms = new Interner(artifact.terms); + const read = readerFor(graph, terms, weights, options.technical ?? DEFAULT_TECHNICAL); + const folder = folderFor(graph); + const speakers: Speaker[] = []; + for (const entity of keptEntities(graph, view)) { + const reading = read(entity); + if (reading.said === "nothing") continue; + const module = modules.get(folder.containingModule(entity.id) ?? "") ?? -1; + const type = types.get(folder.containingType(entity.id) ?? "") ?? -1; + const scope = reading.role === "module" || reading.role === "type"; + const owner = artifact.scopes[type]?.id ?? artifact.scopes[module]?.id; + const anchor = anchorOf(entity); + speakers.push({ + id: entity.id, + carrier: { + ...(scope ? { id: entity.id } : {}), + name: entityName(entity) ?? entity.id, + kind: entity.kind, + role: reading.role, + ...(anchor === undefined ? {} : { anchor }), + ...(scope || owner === undefined ? {} : { owner }), + }, + module, + type, + words: reading.words, + }); + } + if (terms.size() !== artifact.terms.length) { + throw new Error("vocabularyCarriers: the model speaks words the artifact does not hold — it was built from another model"); + } + + const scopeIndex = new Map([...modules, ...types]); + return { + answer(query) { + const limit = query.limit ?? CARRIERS_LIMIT; + if (!Number.isInteger(limit) || limit < 0) throw new RangeError(`a carriers limit is a whole number >= 0, not ${limit}`); + const at = query.scope === undefined ? 0 : (scopeIndex.get(query.scope) ?? -1); + const scope = artifact.scopes[at]; + if (scope === undefined) return { kind: CARRIERS_ANSWER_KIND, status: "unknown-scope", scope: query.scope ?? "" }; + const ref = refOf(scope); + + const rows = scope.terms.filter(([term]) => { + const entry = artifact.terms[term]; + return entry?.text === query.term && (query.class === undefined || entry.class === query.class); + }); + const [row, ...others] = rows; + if (row === undefined) { + return { + kind: CARRIERS_ANSWER_KIND, + status: "unknown-term", + scope: ref, + term: query.term, + ...(query.class === undefined ? {} : { class: query.class }), + }; + } + if (others.length > 0) { + const classes = rows.map(([term]) => artifact.terms[term]?.class).filter((cls): cls is TermClass => cls !== undefined); + return { + kind: CARRIERS_ANSWER_KIND, + status: "ambiguous", + scope: ref, + term: query.term, + classes: TERM_CLASSES.filter((cls) => classes.includes(cls)), + }; + } + + const [term, weight, occurrences, total] = row; + const found: { readonly id: EntityId; readonly carrier: Carrier }[] = []; + for (const speaker of speakers) { + if (at !== 0 && speaker.module !== at && speaker.type !== at) continue; + const said = speaker.words.get(term); + if (said !== undefined) found.push({ id: speaker.id, carrier: { ...speaker.carrier, weight: said[0], occurrences: said[1] } }); + } + found.sort((a, b) => b.carrier.weight - a.carrier.weight || compareIds(a.id, b.id)); + return { + kind: CARRIERS_ANSWER_KIND, + status: "found", + scope: ref, + term: { text: query.term, class: artifact.terms[term]?.class ?? "domain" }, + weight, + occurrences, + total, + carriers: found.slice(0, limit).map(({ carrier }) => carrier), + }; + }, + }; +} + +function refOf(scope: VocabularyScope): ScopeRef { + return { level: scope.level, ...(scope.id === undefined ? {} : { id: scope.id }), name: scope.name }; +} + +function anchorOf(entity: object): SourceAnchor | undefined { + const anchor = (entity as { anchor?: { file?: unknown; span?: unknown } }).anchor; + if (typeof anchor?.file !== "string" || !Array.isArray(anchor.span) || anchor.span.length !== 2) return undefined; + const [start, end] = anchor.span as unknown[]; + return typeof start === "number" && typeof end === "number" ? { file: anchor.file, span: [start, end] } : undefined; +} diff --git a/packages/vocabulary/src/index.ts b/packages/vocabulary/src/index.ts index 6ca4f66..b40daea 100644 --- a/packages/vocabulary/src/index.ts +++ b/packages/vocabulary/src/index.ts @@ -12,5 +12,6 @@ export * from "./weight.js"; export * from "./artifact.js"; export * from "./aggregate.js"; export * from "./drift.js"; +export * from "./carriers.js"; export * from "./cloud.js"; export * from "./svg.js"; diff --git a/packages/vocabulary/test/carriers.test.ts b/packages/vocabulary/test/carriers.test.ts new file mode 100644 index 0000000..0dcda31 --- /dev/null +++ b/packages/vocabulary/test/carriers.test.ts @@ -0,0 +1,187 @@ +import { fileURLToPath } from "node:url"; +import { readModelFileSync } from "@codegraph/core"; +import { buildGraph, loadModels, makeView, type CodeGraph } from "@codegraph/analyzer"; +import { describe, expect, it } from "vitest"; +import { buildVocabulary } from "../src/aggregate.js"; +import { CARRIERS_ANSWER_KIND, CARRIERS_LIMIT, vocabularyCarriers, type Carrier } from "../src/carriers.js"; +import { DEFAULT_WEIGHTS } from "../src/weight.js"; +import { graphOf as toyGraph, method, orderCorpus, pkg } from "./toy.js"; + +/** + * A scope's term, back to the names that carry it (PLAN.md §20.2): the + * evidence the artifact counts and does not list. Each carrier's share is + * what the row counted from it, so a row is the sum of its carriers. + */ + +const FIXTURES = ["java", "csharp", "typescript", "elixir", "ruby", "unicode"] as const; + +function fixtureGraph(fixture: string): CodeGraph { + const model = readModelFileSync( + fileURLToPath(new URL(`../../../fixtures/${fixture}/expected/model.jsonl`, import.meta.url)), + ); + return buildGraph(loadModels(model, { sources: [`fixtures/${fixture}`] }).union); +} + +const brief = (carrier: Carrier) => [carrier.name, carrier.role, carrier.weight, `line ${carrier.anchor?.span[0] ?? "—"}`]; + +describe("the carriers of `order` in the Java fixture, counted by hand", () => { + const graph = fixtureGraph("java"); + const artifact = buildVocabulary(graph); + const index = vocabularyCarriers(graph, artifact); + + // fixtures/java/README.md: weight 40, 12 occurrences, 12 carriers. + it("lists the corpus's twelve, heaviest first, then by id", () => { + const answer = index.answer({ term: "order" }); + expect(answer).toMatchObject({ + kind: CARRIERS_ANSWER_KIND, + status: "found", + scope: { level: "corpus", name: "src" }, + term: { text: "order", class: "domain" }, + weight: 40, + occurrences: 12, + total: 12, + }); + if (answer.status !== "found") return; + // Ties by id: Notifications' four parameters, then OrderService's local and parameter. + expect(answer.carriers.map(brief)).toEqual([ + ["com.acme.order", "module", 8, "line —"], + ["AbstractOrder", "type", 8, "line 4"], + ["Order", "type", 8, "line 6"], + ["OrderService", "type", 8, "line 9"], + ["com.acme.order.adapter", "module", 1, "line —"], + ["com.acme.order.legacy", "module", 1, "line —"], + ["order", "parameter", 1, "line 8"], + ["order", "parameter", 1, "line 10"], + ["order", "parameter", 1, "line 21"], + ["order", "parameter", 1, "line 32"], + ["order", "local", 1, "line 24"], + ["order", "parameter", 1, "line 17"], + ]); + }); + + // The navigator's rule: a type or a module carries its id, a member the + // type (else the module) a page reveals for it. + it("gives a type or a module its id, a member its owner, and every carrier its anchor", () => { + const answer = index.answer({ term: "order" }); + if (answer.status !== "found") throw new Error(answer.status); + const [module, , order] = answer.carriers; + expect(module).toMatchObject({ id: "java:com.acme.order", name: "com.acme.order" }); + expect(module).not.toHaveProperty("owner"); + expect(order).toMatchObject({ id: "java:com.acme.order/Order", anchor: { file: "com/acme/order/Order.java" } }); + const members = answer.carriers.filter((carrier) => carrier.role === "parameter" || carrier.role === "local"); + for (const member of members) { + expect(member).not.toHaveProperty("id"); + expect(member.owner).toMatch(/^java:com\.acme\.order\/(OrderService|Notifications|Order|AbstractOrder)/u); + expect(member.anchor?.span[0]).toBeGreaterThan(0); + } + }); + + it("answers within a type's scope, and within a module's", () => { + const type = index.answer({ scope: "java:com.acme.order/OrderService", term: "order" }); + expect(type).toMatchObject({ status: "found", scope: { level: "type", id: "java:com.acme.order/OrderService", name: "OrderService" } }); + if (type.status !== "found") return; + expect(type.carriers.every((carrier) => carrier.id === "java:com.acme.order/OrderService" || carrier.owner === "java:com.acme.order/OrderService")).toBe(true); + const module = index.answer({ scope: "java:com.acme.order", term: "order" }); + expect(module).toMatchObject({ status: "found", scope: { level: "module" } }); + }); + + it("lists at most `limit` carriers and always counts them whole", () => { + const answer = index.answer({ term: "order", limit: 3 }); + expect(answer).toMatchObject({ status: "found", total: 12 }); + if (answer.status === "found") expect(answer.carriers.map((carrier) => carrier.name)).toEqual(["com.acme.order", "AbstractOrder", "Order"]); + expect(CARRIERS_LIMIT).toBeGreaterThanOrEqual(100); + expect(() => index.answer({ term: "order", limit: -1 })).toThrow(RangeError); + expect(() => index.answer({ term: "order", limit: 1.5 })).toThrow(RangeError); + }); + + it("says an unknown scope, or a term the scope does not speak, is unknown", () => { + expect(index.answer({ scope: "java:com.acme.order/Nowhere", term: "order" })).toEqual({ + kind: CARRIERS_ANSWER_KIND, + status: "unknown-scope", + scope: "java:com.acme.order/Nowhere", + }); + expect(index.answer({ term: "zebra" })).toMatchObject({ status: "unknown-term", term: "zebra", scope: { level: "corpus" } }); + expect(index.answer({ term: "order", class: "technical" })).toMatchObject({ status: "unknown-term", class: "technical" }); + // An operation is no stored scope: its words are `vocabulary --scope`'s. + expect(index.answer({ scope: "java:com.acme.order/OrderService.bill(com.acme.order.Order)", term: "order" })).toMatchObject({ + status: "unknown-scope", + }); + }); +}); + +describe("one text, two terms", () => { + // `final` is a Java keyword; a language with no lexicon says it as a domain word. + const graph = toyGraph([ + { lang: "java", entities: [...orderCorpus(), method("java:com.acme.order/Order.isFinal()", "isFinal", "java:com.acme.order/Order")] }, + { lang: "cobol", entities: [pkg("cobol:final", "final")] }, + ]); + const artifact = buildVocabulary(graph); + const index = vocabularyCarriers(graph, artifact); + + it("asks for the class where a scope speaks both", () => { + expect(artifact.terms.filter((term) => term.text === "final").map((term) => term.class)).toEqual(["domain", "keyword"]); + expect(index.answer({ term: "final" })).toEqual({ + kind: CARRIERS_ANSWER_KIND, + status: "ambiguous", + scope: { level: "corpus", name: artifact.corpus.name }, + term: "final", + classes: ["domain", "keyword"], + }); + expect(index.answer({ term: "final", class: "domain" })).toMatchObject({ + status: "found", + total: 1, + carriers: [{ id: "cobol:final", role: "module" }], + }); + expect(index.answer({ term: "final", class: "keyword" })).toMatchObject({ + status: "found", + carriers: [{ name: "isFinal", role: "operation", owner: "java:com.acme.order/Order" }], + }); + }); +}); + +describe("a row is the sum of its carriers", () => { + it.each(FIXTURES)("in fixtures/%s, for every term of every scope", (fixture) => { + const graph = fixtureGraph(fixture); + const artifact = buildVocabulary(graph); + const index = vocabularyCarriers(graph, artifact); + let rows = 0; + for (const scope of artifact.scopes) { + for (const [term, weight, occurrences, carriers] of scope.terms) { + const entry = artifact.terms[term]; + if (entry === undefined) throw new Error(`term ${term}`); + const answer = index.answer({ + ...(scope.id === undefined ? {} : { scope: scope.id }), + term: entry.text, + class: entry.class, + limit: Number.MAX_SAFE_INTEGER, + }); + if (answer.status !== "found") throw new Error(`${scope.name} ${entry.text}: ${answer.status}`); + expect(answer.total).toBe(carriers); + expect(answer.carriers).toHaveLength(carriers); + expect(answer.carriers.reduce((sum, carrier) => sum + carrier.weight, 0)).toBe(weight); + expect(answer.carriers.reduce((sum, carrier) => sum + carrier.occurrences, 0)).toBe(occurrences); + rows += 1; + } + } + expect(rows).toBeGreaterThan(0); + }); +}); + +describe("the counting it answers under", () => { + const graph = fixtureGraph("java"); + const noLocals = makeView("noLocals", (entity) => entity.kind !== "localVariable", () => true); + + it("is the artifact's, or it refuses: other weights, another technical list, another view", () => { + const artifact = buildVocabulary(graph); + expect(() => vocabularyCarriers(graph, artifact, { weights: { ...DEFAULT_WEIGHTS, type: 9 } })).toThrow(/other options/u); + expect(() => vocabularyCarriers(graph, artifact, { technical: [] })).toThrow(/other options/u); + expect(() => vocabularyCarriers(graph, artifact, { view: noLocals })).toThrow(/other options/u); + }); + + it("answers an artifact built under a view with that view", () => { + const artifact = buildVocabulary(graph, { view: noLocals }); + const answer = vocabularyCarriers(graph, artifact, { view: noLocals }).answer({ term: "order" }); + // The local `order` of `archive` is not kept: eleven carriers, weight 39. + expect(answer).toMatchObject({ status: "found", total: 11, weight: 39 }); + }); +}); From 26f20e6aae2c50436d5d23c45e82dc4c1d49f286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Grodziski?= Date: Mon, 26 Oct 2026 12:50:45 +0100 Subject: [PATCH 3/8] feat(vocabulary): a browser entry for the cloud @codegraph/vocabulary/cloud is cloud.ts alone, built as its own entry: a page can run the layout as a value without the analyzer, core, zod and the SQLite loader behind the package's index. A test keeps cloud.ts's imports type-only. Co-Authored-By: Claude Opus 5.5 --- packages/vocabulary/package.json | 8 +++++++- packages/vocabulary/test/cloud.test.ts | 20 ++++++++++++++++++++ packages/vocabulary/tsup.config.ts | 3 ++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/vocabulary/package.json b/packages/vocabulary/package.json index 90136cc..b1a9879 100644 --- a/packages/vocabulary/package.json +++ b/packages/vocabulary/package.json @@ -8,11 +8,17 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./cloud": { + "types": "./dist/cloud.d.ts", + "import": "./dist/cloud.js" } }, "main": "./dist/index.js", "types": "./dist/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsup", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/packages/vocabulary/test/cloud.test.ts b/packages/vocabulary/test/cloud.test.ts index c1c80a0..b0f84f5 100644 --- a/packages/vocabulary/test/cloud.test.ts +++ b/packages/vocabulary/test/cloud.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import fc from "fast-check"; import { describe, expect, it } from "vitest"; import { @@ -181,3 +182,22 @@ describe("properties", () => { ); }); }); + +// The navigator's tab runs this very layout (PLAN.md §20.2). A value import +// here would follow `@codegraph/vocabulary/cloud` into the browser bundle, +// and with it the analyzer, core, zod and the SQLite loader. +describe("the browser entry", () => { + it("imports types only", () => { + const source = readFileSync(new URL("../src/cloud.ts", import.meta.url), "utf8"); + const imports = [...source.matchAll(/^import\s+([^;]*?)\s+from\s+"[^"]+"/gmu)].map((match) => match[1] ?? ""); + expect(imports.length).toBeGreaterThan(0); + expect(imports.filter((what) => !what.startsWith("type "))).toEqual([]); + }); + + it("is published as @codegraph/vocabulary/cloud", () => { + const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + exports: Record; + }; + expect(manifest.exports["./cloud"]).toEqual({ types: "./dist/cloud.d.ts", import: "./dist/cloud.js" }); + }); +}); diff --git a/packages/vocabulary/tsup.config.ts b/packages/vocabulary/tsup.config.ts index 16f4d3e..966e5be 100644 --- a/packages/vocabulary/tsup.config.ts +++ b/packages/vocabulary/tsup.config.ts @@ -1,7 +1,8 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + // `cloud` is its own entry: a page imports the layout without the pipeline behind `index`. + entry: ["src/index.ts", "src/cloud.ts"], format: ["esm"], dts: true, clean: true, From d5b2b41b88db9bd814370a8829899247e9239d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Grodziski?= Date: Mon, 26 Oct 2026 12:56:13 +0100 Subject: [PATCH 4/8] feat(cli): serve the vocabulary and its carriers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codegraph serve builds vocabulary.json from the same graph, view and name as the navigator and the city, and serves it at /vocabulary.json. /carriers.json?term=…[&scope=…][&class=…][&limit=…] answers which names of a stored scope carry a word, from the model the server holds: 404 for an unknown scope or term, 400 for a request it cannot ask or a text the scope speaks as two terms. The index is built at the first answerable request, and the graph is let go then. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/carriers-route.ts | 75 ++++++++++++++++++++++ packages/cli/src/commands/serve.ts | 41 ++++++++---- packages/cli/src/commands/vocabulary.ts | 2 +- packages/cli/test/carriers-route.test.ts | 81 ++++++++++++++++++++++++ packages/cli/test/serve-command.test.ts | 49 ++++++++++++-- 5 files changed, 227 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/carriers-route.ts create mode 100644 packages/cli/test/carriers-route.test.ts diff --git a/packages/cli/src/carriers-route.ts b/packages/cli/src/carriers-route.ts new file mode 100644 index 0000000..b23e992 --- /dev/null +++ b/packages/cli/src/carriers-route.ts @@ -0,0 +1,75 @@ +import { TERM_CLASSES, type CarrierIndex, type CarriersAnswer, type TermClass } from "@codegraph/vocabulary"; +import type { LookupAnswer } from "./insight-route.js"; + +/** + * `/carriers.json?term=…[&scope=…][&class=…][&limit=…]` — the second lookup + * of `codegraph serve` (PLAN.md §20.2): which names of a stored scope carry a + * word, each with what it added to the row. The vocabulary artifact counts + * and never lists, so the evidence is asked for one row at a time, answered by + * `@codegraph/vocabulary`'s carrier index over the model the server holds. + * + * No scope is the corpus. An unknown scope or term is 404, as an unknown id is + * on `/insight.json`; a request that cannot be asked — no term, a class that + * is none, a limit that is no whole number — is 400, and so is a text the + * scope speaks as two terms, with the classes to choose from. + * + * THE INDEX IS BUILT AT THE FIRST ANSWERABLE REQUEST, reading every kept + * entity once; the function that builds it (and the graph it holds) is then + * dropped, so a page nobody clicks costs nothing and a clicked one holds the + * index alone. + */ + +export const CARRIERS_ROUTE = "/carriers.json"; + +const USAGE = "carriers.json needs ?term=, and takes &scope=&class=&limit="; + +export interface CarriersLookup { + handle(query: URLSearchParams): LookupAnswer; +} + +export function carriersLookup(build: () => CarrierIndex): CarriersLookup { + let pending: (() => CarrierIndex) | undefined = build; + let index: CarrierIndex | undefined; + + return { + handle(query) { + const term = query.get("term"); + const termClass = query.get("class"); + const limit = query.get("limit"); + if (term === null || term === "") return refused(USAGE); + if (termClass !== null && !(TERM_CLASSES as readonly string[]).includes(termClass)) { + return refused(`carriers.json: no class ${JSON.stringify(termClass)}; the classes are ${TERM_CLASSES.join(", ")}`); + } + if (limit !== null && !/^\d+$/u.test(limit)) return refused(`carriers.json: a limit is a whole number >= 0, not ${JSON.stringify(limit)}`); + + if (index === undefined && pending !== undefined) { + index = pending(); + pending = undefined; + } + const scope = query.get("scope"); + const answer = (index as CarrierIndex).answer({ + term, + ...(scope === null || scope === "" ? {} : { scope }), + ...(termClass === null ? {} : { class: termClass as TermClass }), + ...(limit === null ? {} : { limit: Number(limit) }), + }); + return { status: statusOf(answer), body: JSON.stringify(answer) }; + }, + }; +} + +function statusOf(answer: CarriersAnswer): number { + switch (answer.status) { + case "found": + return 200; + case "ambiguous": + return 400; + case "unknown-scope": + case "unknown-term": + return 404; + } +} + +function refused(error: string): LookupAnswer { + return { status: 400, body: JSON.stringify({ error }) }; +} diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 010f843..2c6c36d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1,10 +1,12 @@ import { readFileSync, statSync } from "node:fs"; import { buildNavigator, navigatorToJsonString } from "@codegraph/navigator"; import { cityToJsonString, layoutCity } from "@codegraph/city"; +import { buildVocabulary, vocabularyCarriers, vocabularyToJsonString } from "@codegraph/vocabulary"; import type { AppOptions, ServeOptions } from "../args.js"; import { startDaemon, type DaemonOptions } from "../app/daemon.js"; import { createJobRunner } from "../app/jobs.js"; 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"; @@ -15,27 +17,31 @@ import { navigatorAssets, startArtifactServer, type ArtifactServerOptions, type import { resolveView } from "../view.js"; import { cityOf, cityWarnings } from "./city.js"; import { navigatorWarnings } from "./navigator.js"; +import { vocabularyWarnings } from "./vocabulary.js"; /** * `codegraph serve [--port N] [--host ADDR] [city metrics…]`. * * ONE PAGE OVER A MODEL: the navigator (tree, fan-in/fan-out evidence, graph, - * cycles, coupling) with the 3D city as one of its tabs. Both artifacts are - * built here, from the SAME graph under the SAME view, so a building clicked - * in the city names exactly the entity the tree reveals — the two artifacts - * share the entity ids, and the page joins them on nothing else. + * cycles, coupling, vocabulary) with the 3D city as one of its tabs. The three + * artifacts are built here, from the SAME graph under the SAME view, so a + * building clicked in the city names exactly the entity the tree reveals, and + * a type's words are found by the id of its tree node — the artifacts share + * the entity ids, and the page joins them on nothing else. * * The CLI stays inside its box (decision 7): the transforms live in - * `@codegraph/navigator` and `@codegraph/city`, the frontend is the prebuilt - * `@codegraph/navigator-ui` bundle (which embeds `@codegraph/viz`), and this - * command only resolves flags, loads models and hands the server two JSON - * routes — `/navigator.json` and `/city.json`, each exactly the file the - * respective command's `--out` would have written. + * `@codegraph/navigator`, `@codegraph/city` and `@codegraph/vocabulary`, the + * frontend is the prebuilt `@codegraph/navigator-ui` bundle (which embeds + * `@codegraph/viz`), and this command only resolves flags, loads models and + * hands the server three JSON routes — `/navigator.json`, `/city.json` and + * `/vocabulary.json`, each exactly the file the respective command's `--out` + * would have written (the vocabulary under its default weights and list). * - * ONE ROUTE IS NOT AN ARTIFACT: `/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 — a model nobody explained looks exactly - * as it did. + * TWO 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). * * Stdout stays empty: the server is the destination. Warnings and the reach * announcement are stderr. The command returns at once; the live server is @@ -65,6 +71,7 @@ const REAL_SERVE: ServeDeps = { export const NAVIGATOR_ROUTE = "/navigator.json"; export const CITY_ROUTE = "/city.json"; +export const VOCABULARY_ROUTE = "/vocabulary.json"; export function serveCommand(options: ServeOptions, io: IoSink, deps: ServeDeps = REAL_SERVE): ExitCode { // Resolve the assets FIRST: an unbuilt frontend must fail before a large @@ -84,6 +91,8 @@ export function serveCommand(options: ServeOptions, io: IoSink, deps: ServeDeps // The viewer refuses a city with no placement, so the layout is not optional. const history = loadHistoryJoin(options.historyMetrics, options.models); const city = layoutCity(cityOf(graph, options, history)); + const counting = { view, ...(options.name === undefined ? {} : { name: options.name }) }; + const vocabulary = buildVocabulary(graph, counting); // One "not clean" line for the page, then each artifact's own caveats. const built = { clean: true, paths: source.paths }; @@ -96,16 +105,20 @@ export function serveCommand(options: ServeOptions, io: IoSink, deps: ServeDeps ]), ...navigatorWarnings(built, navigator), ...cityWarnings(built, city), + ...vocabularyWarnings(built, vocabulary), ]); // 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 carriers = carriersLookup(() => vocabularyCarriers(graph, vocabulary, counting)); deps.startServer({ routes: { [NAVIGATOR_ROUTE]: navigatorToJsonString(navigator), [CITY_ROUTE]: cityToJsonString(city), + [VOCABULARY_ROUTE]: vocabularyToJsonString(vocabulary), }, - lookups: { [INSIGHT_ROUTE]: insights.handle }, + lookups: { [INSIGHT_ROUTE]: insights.handle, [CARRIERS_ROUTE]: carriers.handle }, onClose: () => insights.close(), label: "codegraph", assets, diff --git a/packages/cli/src/commands/vocabulary.ts b/packages/cli/src/commands/vocabulary.ts index 4440828..81f2b50 100644 --- a/packages/cli/src/commands/vocabulary.ts +++ b/packages/cli/src/commands/vocabulary.ts @@ -502,7 +502,7 @@ function plural(count: number, noun: string, plural_ = `${noun}s`): string { * What makes the counts smaller or vaguer than the model, on stderr: an * unclean load, a language split with no lexicon, entities no module holds. */ -function vocabularyWarnings( +export function vocabularyWarnings( source: { readonly clean: boolean; readonly paths: readonly string[] }, artifact: VocabularyArtifact, ): readonly string[] { diff --git a/packages/cli/test/carriers-route.test.ts b/packages/cli/test/carriers-route.test.ts new file mode 100644 index 0000000..7440b58 --- /dev/null +++ b/packages/cli/test/carriers-route.test.ts @@ -0,0 +1,81 @@ +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { readModelFileSync } from "@codegraph/core"; +import { buildGraph, loadModels } from "@codegraph/analyzer"; +import { CARRIERS_ANSWER_KIND, buildVocabulary, vocabularyCarriers, type CarrierIndex } from "@codegraph/vocabulary"; +import { carriersLookup } from "../src/carriers-route.js"; + +/** + * `/carriers.json?term=…[&scope=…][&class=…][&limit=…]`: which names of a + * stored scope carry a word (PLAN.md §20.2) — the evidence the vocabulary + * artifact counts and never lists, looked up in 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 artifact = buildVocabulary(graph); + +function lookup() { + let builds = 0; + const handle = carriersLookup(() => { + builds += 1; + return vocabularyCarriers(graph, artifact); + }); + 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 }; +} + +describe("carriers.json", () => { + it("answers a corpus row when no scope is named, and a type's when one is", () => { + const { ask } = lookup(); + expect(ask({ term: "order" })).toMatchObject({ + status: 200, + body: { kind: CARRIERS_ANSWER_KIND, status: "found", scope: { level: "corpus" }, total: 12 }, + }); + expect(ask({ term: "order", scope: "" })).toMatchObject({ status: 200, body: { scope: { level: "corpus" } } }); + expect(ask({ term: "order", scope: "java:com.acme.order/Order", class: "domain", limit: "1" })).toMatchObject({ + status: 200, + body: { scope: { level: "type", id: "java:com.acme.order/Order" }, carriers: [{ id: "java:com.acme.order/Order" }] }, + }); + }); + + it("is 404 for a scope or a term it does not know, with the package's answer", () => { + const { ask } = lookup(); + expect(ask({ term: "order", scope: "java:nowhere" })).toEqual({ + status: 404, + body: { kind: CARRIERS_ANSWER_KIND, status: "unknown-scope", scope: "java:nowhere" }, + }); + expect(ask({ term: "zebra" })).toMatchObject({ status: 404, body: { status: "unknown-term" } }); + }); + + it("is 400 for a request it cannot ask: no term, an unknown class, a limit that is no whole number", () => { + const { ask, builds } = lookup(); + for (const query of [{}, { term: "" }, { term: "order", class: "noun" }, { 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(/carriers\.json/u); + } + // Refused before the index is built: a bad request costs nothing. + expect(builds()).toBe(0); + }); + + it("builds the index once, at the first request it can answer", () => { + const { ask, builds } = lookup(); + expect(builds()).toBe(0); + ask({ term: "order" }); + ask({ term: "total" }); + expect(builds()).toBe(1); + }); + + it("is 400 where the scope speaks the text as two terms, naming the classes", () => { + const ambiguous: CarrierIndex = { + answer: () => ({ kind: CARRIERS_ANSWER_KIND, status: "ambiguous", scope: { level: "corpus", name: "c" }, term: "final", classes: ["domain", "keyword"] }), + }; + const answer = carriersLookup(() => ambiguous).handle(new URLSearchParams({ term: "final" })); + expect(answer.status).toBe(400); + expect(JSON.parse(answer.body)).toMatchObject({ status: "ambiguous", classes: ["domain", "keyword"] }); + }); +}); diff --git a/packages/cli/test/serve-command.test.ts b/packages/cli/test/serve-command.test.ts index 37adaeb..0786b69 100644 --- a/packages/cli/test/serve-command.test.ts +++ b/packages/cli/test/serve-command.test.ts @@ -8,7 +8,7 @@ import { INSIGHTS_GENERATOR, INSIGHTS_KIND, INSIGHTS_METAMODEL, PROMPT_VERSION, import { parseInvocation, type ServeOptions } from "../src/args.js"; import type { DaemonOptions } from "../src/app/daemon.js"; import { defaultDataDir } from "../src/app/store.js"; -import { CITY_ROUTE, NAVIGATOR_ROUTE, registrySource, serveCommand } from "../src/commands/serve.js"; +import { CITY_ROUTE, NAVIGATOR_ROUTE, VOCABULARY_ROUTE, registrySource, serveCommand } from "../src/commands/serve.js"; import { EXIT, UsageError } from "../src/exit.js"; import { captureIo } from "../src/io.js"; import { runSync } from "../src/main.js"; @@ -88,15 +88,31 @@ function parse(text: string | undefined): T { return JSON.parse(text ?? "") as T; } -describe("serve: both artifacts, one server", () => { - it("hands the server the navigator AND the city, each at its own route", () => { +describe("serve: three artifacts, one server", () => { + it("hands the server the navigator, the city and the vocabulary, each at its own route", () => { const { code, started } = serveTo(); expect(code).toBe(EXIT.OK); expect(started).toHaveLength(1); const routes = started[0]?.routes ?? {}; - expect(Object.keys(routes).sort()).toEqual([CITY_ROUTE, NAVIGATOR_ROUTE].sort()); + expect(Object.keys(routes).sort()).toEqual([CITY_ROUTE, NAVIGATOR_ROUTE, VOCABULARY_ROUTE].sort()); expect(parse<{ kind: string }>(routes[NAVIGATOR_ROUTE]).kind).toBe("codegraph.navigator/1"); expect(parse<{ kind: string }>(routes[CITY_ROUTE]).kind).toBe("codegraph.city/1"); + expect(parse<{ kind: string }>(routes[VOCABULARY_ROUTE]).kind).toBe("codegraph.vocabulary/1"); + }); + + // The tab finds a selected node's words by its id: the vocabulary's type + // scopes must be the navigator's type nodes, under the same view. + it("counts the vocabulary under the SAME view and name, so its scopes are the navigator's nodes", () => { + const { started } = serveTo({ internalOnly: true, name: "shop" }); + const routes = started[0]?.routes ?? {}; + const navigator = parse<{ corpus: { name: string }; nodes: { id?: string; category: string }[] }>(routes[NAVIGATOR_ROUTE]); + const vocabulary = parse<{ corpus: { name: string }; view: unknown; scopes: { level: string; id?: string }[] }>(routes[VOCABULARY_ROUTE]); + expect(vocabulary.corpus.name).toBe("shop"); + expect(vocabulary.view).toEqual(parse<{ view: unknown }>(routes[NAVIGATOR_ROUTE]).view); + const ids = new Set(navigator.nodes.filter((node) => node.category === "type" || node.category === "module").map((node) => node.id)); + const scoped = vocabulary.scopes.filter((scope) => scope.level !== "corpus"); + expect(scoped.length).toBeGreaterThan(0); + for (const scope of scoped) expect(ids.has(scope.id), scope.id).toBe(true); }); it("lays the city out: the viewer refuses a city with no placement", () => { @@ -313,7 +329,28 @@ describe("city and navigator no longer serve: one command does", () => { }); }); -describe("serve hands the page ONE lookup: a selected node's explanation (M16c)", () => { +describe("serve hands the page the carriers of a word (M19b)", () => { + it("registers /carriers.json, answered from the model it holds under the artifact's own counting", () => { + const { started } = serveTo({ internalOnly: true }); + const lookup = started[0]?.lookups?.["/carriers.json"]; + expect(lookup).toBeTypeOf("function"); + const answer = lookup?.(new URLSearchParams({ term: "order", scope: "java:com.acme.order/OrderService" })); + expect(answer?.status).toBe(200); + expect(JSON.parse(answer?.body ?? "{}")).toMatchObject({ + kind: "codegraph.carriers/1", + status: "found", + scope: { level: "type", name: "OrderService" }, + term: { text: "order", class: "domain" }, + }); + // The vocabulary's row and the lookup's carriers are one count. + const vocabulary = parse<{ terms: { text: string }[]; scopes: { id?: string; terms: number[][] }[] }>(started[0]?.routes[VOCABULARY_ROUTE]); + const order = vocabulary.terms.findIndex((term) => term.text === "order"); + const row = vocabulary.scopes.find((scope) => scope.id === "java:com.acme.order/OrderService")?.terms.find(([term]) => term === order); + expect(JSON.parse(answer?.body ?? "{}")).toMatchObject({ weight: row?.[1], occurrences: row?.[2], total: row?.[3] }); + }); +}); + +describe("serve hands the page a selected node's explanation (M16c)", () => { it("registers /insight.json beside the two artifacts, and a model nobody explained answers 404 — no file created", () => { const { started } = serveTo(); const lookup = started[0]?.lookups?.["/insight.json"]; @@ -321,7 +358,7 @@ describe("serve hands the page ONE lookup: a selected node's explanation (M16c)" // The fixture has no store beside it: quiet, and asking must not make one. 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"]); + expect(Object.keys(started[0]?.routes ?? {}).sort()).toEqual(["/city.json", "/navigator.json", "/vocabulary.json"]); started[0]?.onClose?.(); }); From 7e5e0d92ad7081f7fed42d90da4b52e0f36d4989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Grodziski?= Date: Mon, 26 Oct 2026 12:56:13 +0100 Subject: [PATCH 5/8] feat(cli): the app's job builds the vocabulary The daemon's job writes vocabulary.json beside navigator.json and city.json and serves it under the token. A project directory without one, built before it existed, is rebuilt rather than reused. The daemon holds no model between jobs, so /carriers.json is a 404 there. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/app/daemon.ts | 14 +++++++++---- packages/cli/src/app/jobs.ts | 30 ++++++++++++++++++---------- packages/cli/src/app/store.ts | 3 ++- packages/cli/test/app-daemon.test.ts | 27 +++++++++++++++++++++++-- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/app/daemon.ts b/packages/cli/src/app/daemon.ts index 8878ae3..9295220 100644 --- a/packages/cli/src/app/daemon.ts +++ b/packages/cli/src/app/daemon.ts @@ -22,8 +22,11 @@ import { RECENT_KIND, readRecent } from "./store.js"; * in memory and a port open. SIGTERM does the same; the shell uses both. * * THE PAGE IS THE SAME PAGE. The frontend bundle is served under the token - * with relative URLs, so `navigator.json` and `city.json` are fetched exactly - * as `codegraph serve` hands them out today — no Tauri IPC, no CORS. + * 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". */ export const APP_KIND = "codegraph.app/1"; @@ -140,14 +143,17 @@ export function startDaemon(options: DaemonOptions): Server { return; } case "/navigator.json": - case "/city.json": { + case "/city.json": + case "/vocabulary.json": { if (method !== "GET" && method !== "HEAD") return methodNotAllowed(response, "GET, HEAD"); const artifacts = runner.artifacts; if (artifacts === undefined) { response.writeHead(404).end(); return; } - sendJson(response, 200, route === "/navigator.json" ? artifacts.navigator : artifacts.city, method); + const artifact = + route === "/navigator.json" ? artifacts.navigator : route === "/city.json" ? artifacts.city : artifacts.vocabulary; + sendJson(response, 200, artifact, method); return; } case "/jobs": { diff --git a/packages/cli/src/app/jobs.ts b/packages/cli/src/app/jobs.ts index 1207c7a..5e2c5cb 100644 --- a/packages/cli/src/app/jobs.ts +++ b/packages/cli/src/app/jobs.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no import { basename, join, resolve } from "node:path"; import { buildNavigator, navigatorToJsonString } from "@codegraph/navigator"; import { cityToJsonString, layoutCity } from "@codegraph/city"; +import { buildVocabulary, vocabularyToJsonString } from "@codegraph/vocabulary"; import type { CityBuildOptions } from "../args.js"; import { cityOf } from "../commands/city.js"; import { errLine, type IoSink } from "../io.js"; @@ -31,14 +32,16 @@ import { } from "./store.js"; /** - * THE JOB: a folder in, the page's two artifacts out (PLAN §15.2). + * THE JOB: a folder in, the page's three artifacts out (PLAN §15.2). * * detect census the tree against the registry (registry.ts) * extract run the entry under the §13.5 contract into /models/…, * or skip it when the tree fingerprint has not moved - * build navigator.json + laid-out city.json from ONE graph under ONE view — - * exactly what `serveCommand` does for a model on argv — through - * `openAnalysis`, so the model.db beside the model is the M7 cache + * build navigator.json + laid-out city.json + vocabulary.json from ONE + * graph under ONE view — exactly what `serveCommand` does for a + * model on argv — through `openAnalysis`, so the model.db beside + * the model is the M7 cache. The graph is not kept: the daemon + * answers no `/carriers.json`, and the page shows counts alone * * ONE AT A TIME. The build is synchronous CPU work on the event loop (the CLI's * whole pipeline is), and two extractors racing on one machine help nobody; a @@ -92,6 +95,7 @@ export type StartOutcome = export interface Artifacts { readonly navigator: string; readonly city: string; + readonly vocabulary: string; readonly project: ProjectSummary; } @@ -334,6 +338,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner { const buildKey = buildKeyOf(options.build, cliVersion()); const navigatorPath = join(projectDir, "navigator.json"); const cityPath = join(projectDir, "city.json"); + const vocabularyPath = join(projectDir, "vocabulary.json"); const reusable = previous !== undefined && previous.model === modelPath && @@ -341,29 +346,31 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner { previous.buildKey === buildKey && sameList(previous.measuredBy ?? [], measuredBy) && existsSync(navigatorPath) && - existsSync(cityPath); + existsSync(cityPath) && + existsSync(vocabularyPath); let record: ProjectRecord; let navigator: string; let city: string; + let vocabulary: string; if (reusable) { phase("build", `unchanged; reusing the page built ${previous.builtAt}`); navigator = readFileSync(navigatorPath, "utf8"); city = readFileSync(cityPath, "utf8"); + vocabulary = readFileSync(vocabularyPath, "utf8"); record = { ...previous, extractedAt }; } else { - phase("build", `building the navigator and the city from ${modelPath}`); + phase("build", `building the navigator, the city and the vocabulary from ${modelPath}`); const source = openAnalysis([modelPath], options.build, io); try { const graph = source.graph(); const view = resolveView(options.build); - const navigatorModel = buildNavigator(graph, { - view, - ...(options.build.name === undefined ? {} : { name: options.build.name }), - }); + const named = options.build.name === undefined ? {} : { name: options.build.name }; + const navigatorModel = buildNavigator(graph, { view, ...named }); const cityModel = layoutCity(cityOf(graph, options.build)); navigator = navigatorToJsonString(navigatorModel); city = cityToJsonString(cityModel); + vocabulary = vocabularyToJsonString(buildVocabulary(graph, { view, ...named })); if (!source.clean) errLine(io, `job: warning — the model is not clean; the page was built anyway.`); record = { kind: PROJECT_KIND, @@ -385,6 +392,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner { } writeFileSync(navigatorPath, navigator, "utf8"); writeFileSync(cityPath, city, "utf8"); + writeFileSync(vocabularyPath, vocabulary, "utf8"); } writeProject(projectDir, record); @@ -402,7 +410,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner { nodes: record.nodes, deps: record.deps, }); - artifacts = { navigator, city, project }; + artifacts = { navigator, city, vocabulary, project }; if (current !== undefined) current.state = "done"; errLine(io, `job: done — ${project.name}: ${project.nodes} nodes, ${project.deps} dependency rows`); emit({ event: "done", data: project }); diff --git a/packages/cli/src/app/store.ts b/packages/cli/src/app/store.ts index bb4df4b..d00a77e 100644 --- a/packages/cli/src/app/store.ts +++ b/packages/cli/src/app/store.ts @@ -11,7 +11,8 @@ import { basename, join } from "node:path"; * /models/-/ one directory per opened folder * model.jsonl what the extractor wrote * model.db the M7 cache, built beside it by openCache - * navigator.json, city.json the page's artifacts, as served + * navigator.json, city.json, the page's artifacts, as served + * vocabulary.json * project.json what was extracted from, and when * * A folder is keyed by a hash of its absolute path with the basename in front diff --git a/packages/cli/test/app-daemon.test.ts b/packages/cli/test/app-daemon.test.ts index eceeaf7..7eb886d 100644 --- a/packages/cli/test/app-daemon.test.ts +++ b/packages/cli/test/app-daemon.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import type { Server } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -217,11 +217,12 @@ describe("the daemon's announcement and capability URL", () => { expect(await json(await fetch(`${base}/recent`))).toEqual({ kind: "codegraph.recent/1", projects: [] }); expect((await fetch(`${base}/navigator.json`)).status).toBe(404); expect((await fetch(`${base}/city.json`)).status).toBe(404); + expect((await fetch(`${base}/vocabulary.json`)).status).toBe(404); }); }); describe("POST /jobs: a folder becomes the page", () => { - it("detects the extractor, runs it with progress on the SSE route, builds both artifacts", async () => { + it("detects the extractor, runs it with progress on the SSE route, builds the three artifacts", async () => { const { base, dataDir } = await daemon(); const accepted = await post(base, { src: JAVA_SRC }); expect(accepted.status).toBe(202); @@ -245,6 +246,11 @@ describe("POST /jobs: a folder becomes the page", () => { const city = await json<{ kind: string; layout?: unknown }>(await fetch(`${base}/city.json`)); expect(city.kind).toBe("codegraph.city/1"); expect(city.layout).toBeDefined(); + const vocabulary = await json<{ kind: string; corpus: { name: string } }>(await fetch(`${base}/vocabulary.json`)); + expect(vocabulary).toMatchObject({ kind: "codegraph.vocabulary/1", corpus: { name: "src" } }); + // The carriers are a lookup over a model held in memory; the daemon holds + // none between jobs, so the page shows the counts without the evidence. + expect((await fetch(`${base}/carriers.json?term=order`)).status).toBe(404); // Everything lives under --data-dir: the model, its store, the artifacts. const projects = readFileSync(join(dataDir, "recent.json"), "utf8"); @@ -273,6 +279,23 @@ describe("POST /jobs: a folder becomes the page", () => { expect(readFileSync(log, "utf8").trim().split("\n")).toHaveLength(1); }); + // A project built before the vocabulary existed has no vocabulary.json: its page is rebuilt, not reused. + it("rebuilds the page when an artifact is missing from the project directory", async () => { + const { base, dataDir } = await daemon(); + expect((await post(base, { src: JAVA_SRC })).status).toBe(202); + await events(base); + const vocabularyPath = join(projectDirFor(dataDir, JAVA_SRC), "vocabulary.json"); + expect(existsSync(vocabularyPath)).toBe(true); + rmSync(vocabularyPath); + + expect((await post(base, { src: JAVA_SRC })).status).toBe(202); + const second = await events(base); + const build = second.find((event) => event.event === "phase" && (event.data as { phase: string }).phase === "build"); + expect((build?.data as { detail: string }).detail).toContain("building the navigator, the city and the vocabulary"); + expect(existsSync(vocabularyPath)).toBe(true); + expect((await fetch(`${base}/vocabulary.json`)).status).toBe(200); + }); + it("re-runs the extractor when a claimed file changed", async () => { const log = join(mkdtempSync(join(tmpdir(), "codegraph-app-log-")), "runs.log"); const src = tree({ "A.java": "class A {}" }); From 5f3bce20ce1bc86bc7f4b4c849fe0d36f23a9ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Grodziski?= Date: Mon, 26 Oct 2026 13:04:32 +0100 Subject: [PATCH 6/8] feat(navigator-ui): the Vocabulary tab The sixth tab: the words of the selected scope's names, the corpus until a module or a type is selected (a member shows its type), as a cloud and a table. The cloud is @codegraph/vocabulary/cloud's layout measured with the page's monospace face; size is weight, colour the class alone, and a legend states what was counted. Class chips, a top-N slider, the scope chain to step up. A word asks carriers.json for its names, and each reveals its node in Navigate; with no such route the tab says so. The artifact kind, the carriers kind and the class list are literals pinned to the package's; the package is imported for types only, its ./cloud entry as the one value. The Vite dev server serves VOCABULARY_JSON as it does NAVIGATOR_JSON. Co-Authored-By: Claude Opus 5.5 --- packages/navigator-ui/package.json | 1 + packages/navigator-ui/src/App.tsx | 21 +- .../src/components/VocabularyView.tsx | 378 ++++++++++++++++++ packages/navigator-ui/src/model/words.ts | 87 ++++ packages/navigator-ui/src/style.css | 260 ++++++++++++ packages/navigator-ui/src/vocabulary.ts | 165 ++++++++ packages/navigator-ui/test/fixture.ts | 55 +++ packages/navigator-ui/test/guard.test.ts | 21 +- packages/navigator-ui/test/vocabulary.test.ts | 170 ++++++++ packages/navigator-ui/vite.config.ts | 20 +- pnpm-lock.yaml | 3 + 11 files changed, 1164 insertions(+), 17 deletions(-) create mode 100644 packages/navigator-ui/src/components/VocabularyView.tsx create mode 100644 packages/navigator-ui/src/model/words.ts create mode 100644 packages/navigator-ui/src/vocabulary.ts create mode 100644 packages/navigator-ui/test/vocabulary.test.ts diff --git a/packages/navigator-ui/package.json b/packages/navigator-ui/package.json index bde2b61..3f91c32 100644 --- a/packages/navigator-ui/package.json +++ b/packages/navigator-ui/package.json @@ -19,6 +19,7 @@ "dependencies": { "@codegraph/navigator": "workspace:*", "@codegraph/viz": "workspace:*", + "@codegraph/vocabulary": "workspace:*", "cytoscape": "^3.34.2", "cytoscape-fcose": "^2.2.0", "react": "^19.1.0", diff --git a/packages/navigator-ui/src/App.tsx b/packages/navigator-ui/src/App.tsx index 6469073..3d9de6c 100644 --- a/packages/navigator-ui/src/App.tsx +++ b/packages/navigator-ui/src/App.tsx @@ -8,6 +8,7 @@ import { GraphView } from "./components/GraphView.js"; import { CyclesView } from "./components/CyclesView.js"; import { CouplingView } from "./components/CouplingView.js"; import { CityTab } from "./components/CityTab.js"; +import { VocabularyView } from "./components/VocabularyView.js"; import { ProgressOverlay } from "./components/ProgressOverlay.js"; import { Loader } from "./components/Loader.js"; import { AppHome } from "./components/AppHome.js"; @@ -42,11 +43,12 @@ export const SLOW_LOAD_MS = 2000; * The tabs: NAVIGATE is the working surface (tree + evidence); CITY is the 3D * code city over the same model, full-width — no tree panel there, the city * is the navigation surface; the others are the reports, each on its own tab - * so a report never crowds the navigation. Every report row — and a building - * selected in the city — leads BACK to Navigate through `reveal`: one - * selection, five ways of looking at it. + * so a report never crowds the navigation; VOCABULARY is the words of the + * selected scope's names. Every report row — a building selected in the city, + * a name carrying a word — leads BACK to Navigate through `reveal`: one + * selection, six ways of looking at it. */ -const TABS = ["navigate", "city", "graph", "cycles", "coupling"] as const; +const TABS = ["navigate", "city", "graph", "cycles", "coupling", "vocabulary"] as const; type Tab = (typeof TABS)[number]; const TAB_LABEL: Readonly> = { navigate: "Navigate", @@ -54,6 +56,7 @@ const TAB_LABEL: Readonly> = { graph: "Graph", cycles: "Cycles", coupling: "Coupling", + vocabulary: "Vocabulary", }; export function App() { @@ -62,6 +65,7 @@ export function App() { const [tab, setTab] = useState("navigate"); const [graphVisited, setGraphVisited] = useState(false); const [cityVisited, setCityVisited] = useState(false); + const [vocabularyVisited, setVocabularyVisited] = useState(false); const [expanded, setExpanded] = useState>(new Set()); const [selection, setSelection] = useState(undefined); const [query, setQuery] = useState(""); @@ -92,6 +96,7 @@ export function App() { setTab("navigate"); setGraphVisited(false); setCityVisited(false); + setVocabularyVisited(false); setPhase({ kind: "ready", ix }); } catch (error) { if (!alive()) return; @@ -257,6 +262,7 @@ export function App() { setTab(next); if (next === "graph") setGraphVisited(true); if (next === "city") setCityVisited(true); + if (next === "vocabulary") setVocabularyVisited(true); }, []); if (phase.kind === "loading" && !slow) { @@ -372,6 +378,13 @@ export function App() { )} + {(vocabularyVisited || tab === "vocabulary") && ( + // Mounted on first visit, then kept alive hidden: the artifact is + // fetched once per model, and the scope follows the selection meanwhile. + + )} ); } diff --git a/packages/navigator-ui/src/components/VocabularyView.tsx b/packages/navigator-ui/src/components/VocabularyView.tsx new file mode 100644 index 0000000..c39f361 --- /dev/null +++ b/packages/navigator-ui/src/components/VocabularyView.tsx @@ -0,0 +1,378 @@ +import { useEffect, useMemo, useState } from "react"; +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 { TERM_CLASSES, createCarriersLoader, loadVocabulary, type Carriers, type VocabularyLoad } from "../vocabulary.js"; + +/** + * The Vocabulary tab (PLAN.md §20.2): the words the selected scope's names + * are made of — the corpus until a module or a type is selected, then that + * scope; a member shows its type — as a cloud and as a table, and for one + * word, the names that carry it. + * + * MEANING CONTROLS APPEARANCE, as on the CLI's `--svg`: a word's size is its + * weight, on the square root; its colour is its class and nothing else — + * domain in paper, technical dimmed, keyword and short faint. The layout is + * the package's own function, measured with this page's font, so the cloud is + * the one `codegraph vocabulary --svg` draws up to the face. The legend says + * what was counted: a cloud without it is a picture, not a fact. + * + * 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. + */ + +/** The CLI's `--top` default. */ +const TOP = 40; +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(); + +export interface VocabularyViewProps { + readonly ix: ModelIndexes; + /** The Navigate selection: the scope follows it. */ + readonly selection: number | undefined; + /** Select a scope's node — or none, the corpus — without leaving the tab. */ + readonly onSelect: (node: number | undefined) => void; + /** Select a node and show it in Navigate. */ + readonly onReveal: (node: number) => void; +} + +/** The page's monospace face, measured: what the SVG text below is set in. */ +function pageMeasure(): Measure { + const context = document.createElement("canvas").getContext("2d"); + if (context === null) return monospaceMeasure; + const family = getComputedStyle(document.documentElement).getPropertyValue("--mono").trim() || "monospace"; + return (text, size) => { + context.font = `${size}px ${family}`; + return { width: context.measureText(text).width, height: MONOSPACE_LINE * size }; + }; +} + +const plural = (count: number, noun: string, nouns = `${noun}s`): string => `${count.toLocaleString()} ${count === 1 ? noun : nouns}`; + +/** The anchor's file by its last two segments — the full path is the tooltip. */ +function shortPath(file: string): string { + const parts = file.split("/"); + return parts.length <= 2 ? file : `…/${parts.slice(-2).join("/")}`; +} + +export function VocabularyView({ ix, selection, onSelect, onReveal }: VocabularyViewProps) { + const [load, setLoad] = useState(undefined); + const [classes, setClasses] = useState>(new Set(["domain"])); + 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 measure = useMemo(pageMeasure, []); + + // A new navigator (the app daemon's next job) comes with a new vocabulary. + useEffect(() => { + let current = true; + setLoad(undefined); + void loadVocabulary().then((result) => { + if (current) setLoad(result); + }); + return () => { + current = false; + }; + }, [ix]); + + const vocabulary = useMemo(() => (load?.status === "ready" ? indexVocabulary(load.artifact) : undefined), [load]); + const scope = vocabulary === undefined ? 0 : scopeForNode(ix, vocabulary, selection); + const rows = useMemo( + () => (vocabulary === undefined ? [] : wordRows(vocabulary.artifact, scope, classes)), + [vocabulary, scope, classes], + ); + const layout = useMemo(() => layoutCloud(cloudWords(rows, top), { measure }), [rows, top, measure]); + const pick = picked?.scope === scope ? picked.row : undefined; + const scopeId = vocabulary?.artifact.scopes[scope]?.id; + const pickKey = pick === undefined ? undefined : `${scopeId ?? ""}\u0000${pick.text}\u0000${pick.class}`; + + useEffect(() => { + if (pick === undefined || pickKey === undefined) return; + let current = true; + void LOADER.load({ scope: scopeId, term: pick.text, class: pick.class }).then((answer) => { + if (current) setCarriers({ key: pickKey, answer }); + }); + return () => { + current = false; + }; + }, [pick, pickKey, scopeId]); + + if (load === undefined) return
; + if (load.status === "absent") { + return ( +
+

+ No vocabulary is served with this page. codegraph serve model.jsonl builds one beside the navigator;{" "} + codegraph vocabulary model.jsonl --svg cloud.svg draws a scope's cloud on its own. +

+
+ ); + } + if (load.status === "error" || vocabulary === undefined) { + return ( +
+

{load.status === "error" ? load.message : ""}

+
+ ); + } + + const { artifact } = vocabulary; + const here = artifact.scopes[scope]; + const chain = scopeChain(artifact, scope); + const shown = layout.words.length; + const what = classes.size === TERM_CLASSES.length ? "all classes" : TERM_CLASSES.filter((cls) => classes.has(cls)).join(" + "); + const toggle = (cls: TermClass) => { + const next = new Set(classes); + if (next.has(cls)) next.delete(cls); + else next.add(cls); + if (next.size > 0) setClasses(next); + }; + const choose = (row: WordRow) => setPicked(pick?.term === row.term ? undefined : { scope, row }); + const answer = carriers !== undefined && carriers.key === pickKey ? carriers.answer : undefined; + + return ( +
+
+ +
+ {TERM_CLASSES.map((cls) => ( + + ))} +
+ + + {plural(here?.entities ?? 0, "entity", "entities")} · {plural(here?.distinctDomain ?? 0, "domain term")} of {plural(here?.distinct ?? 0, "term")} + +
+ +
+
+ {shown === 0 ? ( +

This scope speaks no {what} word.

+ ) : ( + + {pick !== undefined && + layout.words + .filter((word) => word.text === pick.text && word.class === pick.class) + .map((word) => ( + + ))} + {layout.words.map((word) => { + const row = rows.find((candidate) => candidate.text === word.text && candidate.class === word.class); + return ( + row !== undefined && choose(row)} + onKeyDown={(event) => { + if ((event.key === "Enter" || event.key === " ") && row !== undefined) { + event.preventDefault(); + choose(row); + } + }} + > + + {`${word.text} (${word.class}) — weight ${word.weight}` + + (row === undefined ? "" : `, ${plural(row.occurrences, "occurrence")}, ${plural(row.carriers, "carrier")}`)} + + {word.text} + + ); + })} + + )} +
+

+ {here?.level} {here?.name} · {what}:{" "} + {rows.length === 0 + ? "no term" + : `${shown === rows.length ? plural(shown, "term") : `${shown} of ${plural(rows.length, "term")}`} by weight, size on √weight`} +

+

+ weights{" "} + {Object.entries(artifact.weights) + .map(([key, value]) => `${key} ${value}`) + .join(" · ")} +

+

+ view {artifact.view.name} · technical: {plural(artifact.technical.words, "shared word")} (sha256{" "} + {artifact.technical.sha256.slice(0, 12)}) + each language's own · {CLOUD_DEFAULTS.minSize}–{CLOUD_DEFAULTS.maxSize} px +

+
+
+ + +
+
+ ); +} + +function CarrierList({ + ix, + answer, + onReveal, +}: { + readonly ix: ModelIndexes; + readonly answer: Carriers | undefined; + readonly onReveal: (node: number) => void; +}) { + if (answer === undefined) return

Asking the server…

; + if (answer.status === "none") { + return ( +

+ The names behind a word are a lookup of codegraph serve, over the model it holds; this page has none. +

+ ); + } + if (answer.status === "unknown") { + return

The server does not know this word here: it was built from another model.

; + } + return ( + <> +
    + {answer.carriers.map((carrier, index) => { + const node = carrierNode(ix, carrier); + const line = carrier.anchor?.span[0]; + return ( +
  • + + +{carrier.weight} + {carrier.anchor !== undefined && ( + + {shortPath(carrier.anchor.file)}:{line} + + )} +
  • + ); + })} +
+ {answer.total > answer.carriers.length && ( +

+ the {answer.carriers.length} heaviest of {answer.total.toLocaleString()} +

+ )} + + ); +} diff --git a/packages/navigator-ui/src/model/words.ts b/packages/navigator-ui/src/model/words.ts new file mode 100644 index 0000000..8cc9856 --- /dev/null +++ b/packages/navigator-ui/src/model/words.ts @@ -0,0 +1,87 @@ +import type { Carrier, TermClass, VocabularyArtifact } from "@codegraph/vocabulary"; +import type { CloudWord } from "@codegraph/vocabulary/cloud"; +import type { ModelIndexes } from "./indexes.js"; + +/** + * The Vocabulary tab's model: the artifact's rows read for one scope, and the + * joins to the navigator — both on the entity id, compared as an opaque token, + * as the city's buildings are. Nothing is counted here; every number is the + * artifact's. + */ + +export interface VocabularyIndex { + readonly artifact: VocabularyArtifact; + /** A module's or a type's id → its scope's index. */ + readonly scopeById: ReadonlyMap; +} + +export function indexVocabulary(artifact: VocabularyArtifact): VocabularyIndex { + const scopeById = new Map(); + artifact.scopes.forEach((scope, index) => { + if (scope.id !== undefined) scopeById.set(scope.id, index); + }); + return { artifact, scopeById }; +} + +/** + * The scope the tab shows for the Navigate selection: the node's own when the + * vocabulary has it (a module, a type), else the nearest ancestor's — a member + * shows its type — and the corpus when nothing is selected or nothing matches. + */ +export function scopeForNode(ix: ModelIndexes, vocabulary: VocabularyIndex, node: number | undefined): number { + const seen = new Set(); + for (let cursor = node; cursor !== undefined && !seen.has(cursor); cursor = ix.model.nodes[cursor]?.parent) { + seen.add(cursor); + const id = ix.model.nodes[cursor]?.id; + const scope = id === undefined ? undefined : vocabulary.scopeById.get(id); + if (scope !== undefined) return scope; + } + return 0; +} + +/** The scope and the stored scopes around it, the corpus first. */ +export function scopeChain(artifact: VocabularyArtifact, scope: number): readonly number[] { + const chain: number[] = []; + const seen = new Set(); + for (let cursor: number | undefined = scope; cursor !== undefined && !seen.has(cursor); cursor = artifact.scopes[cursor]?.parent) { + seen.add(cursor); + chain.unshift(cursor); + } + if (chain[0] !== 0) chain.unshift(0); + return chain; +} + +export interface WordRow { + /** The term's index in the artifact. */ + readonly term: number; + readonly text: string; + readonly class: TermClass; + readonly weight: number; + readonly occurrences: number; + readonly carriers: number; + /** How many module scopes, and type scopes, of the corpus speak it. */ + readonly modules: number; + readonly types: number; +} + +/** A scope's rows of the chosen classes, in the artifact's order: weight descending, then term. */ +export function wordRows(artifact: VocabularyArtifact, scope: number, classes: ReadonlySet): readonly WordRow[] { + const rows: WordRow[] = []; + for (const [term, weight, occurrences, carriers] of artifact.scopes[scope]?.terms ?? []) { + const entry = artifact.terms[term]; + if (entry === undefined || !classes.has(entry.class)) continue; + rows.push({ term, text: entry.text, class: entry.class, weight, occurrences, carriers, modules: entry.modules, types: entry.types }); + } + return rows; +} + +/** The N heaviest as the layout's input: the same words the CLI's `--svg --top N` draws. */ +export function cloudWords(rows: readonly WordRow[], top: number): readonly CloudWord[] { + return rows.slice(0, top).map((row) => ({ text: row.text, weight: row.weight, class: row.class })); +} + +/** The navigator node a carrier reveals: its own when it is a type or a module, else its owner's. */ +export function carrierNode(ix: ModelIndexes, carrier: Carrier): number | undefined { + const id = carrier.id ?? carrier.owner; + return id === undefined ? undefined : ix.nodeById.get(id); +} diff --git a/packages/navigator-ui/src/style.css b/packages/navigator-ui/src/style.css index 6f90a04..7dae009 100644 --- a/packages/navigator-ui/src/style.css +++ b/packages/navigator-ui/src/style.css @@ -1701,3 +1701,263 @@ button.twisty:hover { border-radius: 3px; padding: 3px 6px; } + +/* -------------------------------------------------------- vocabulary tab -- */ + +/* + * The words of the selected scope's names. Size is weight; colour is the + * class and nothing else — the CLI's --svg rule in this page's palette: + * domain words in paper, technical dimmed, keywords and one-letter words + * faint. No hue: the four hues of this page already mean direction, + * inference and selection. The picked word wears the tree's selection. + */ +.vocabulary-view { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + padding: 14px 18px 0; +} + +.vocabulary-toolbar { + flex: none; +} + +.scope-chain { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px; + font-family: var(--mono); + font-size: 12px; +} + +.scope-link button { + color: var(--paper-dim); + text-decoration: underline; + text-decoration-color: var(--rule); + text-underline-offset: 2px; +} + +.scope-link button:hover:not(:disabled) { + color: var(--paper); +} + +.scope-link button.scope-here { + color: var(--paper); + text-decoration: none; + cursor: default; +} + +.scope-level { + font-family: var(--sans); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--paper-faint); +} + +.scope-sep { + margin: 0 2px; + color: var(--paper-faint); +} + +.seg button.chip-technical:not(.seg-on) { + color: var(--paper-dim); +} + +.seg button.chip-keyword:not(.seg-on), +.seg button.chip-short:not(.seg-on) { + color: var(--paper-faint); +} + +.top-slider { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--paper-dim); +} + +.top-slider input { + width: 120px; + accent-color: var(--paper-dim); +} + +.top-value { + min-width: 2.5em; + font-variant-numeric: tabular-nums; + color: var(--paper); +} + +.vocabulary-body { + display: flex; + flex: 1; + gap: 18px; + min-height: 0; +} + +.cloud-pane { + display: flex; + flex-direction: column; + flex: 3; + min-width: 0; + min-height: 0; +} + +.cloud-pane > svg, +.cloud-pane > .report-empty { + flex: 1; + min-height: 0; + width: 100%; + margin: 0 auto; +} + +.cloud { + font-family: var(--mono); +} + +.cloud-word { + text-anchor: middle; + dominant-baseline: central; + cursor: pointer; +} + +.cloud-word:hover, +.cloud-word:focus-visible { + text-decoration: underline; + outline: none; +} + +.cloud-domain { + fill: var(--paper); + color: var(--paper); +} + +.cloud-technical { + fill: var(--paper-dim); + color: var(--paper-dim); +} + +.cloud-keyword, +.cloud-short { + fill: var(--paper-faint); + color: var(--paper-faint); +} + +.cloud-pick { + fill: #223040; + stroke: var(--paper); + stroke-width: 1; +} + +.cloud-legend { + flex: none; + padding: 8px 0 12px; + font-family: var(--mono); + font-size: 11px; + color: var(--paper-faint); +} + +.cloud-legend p { + margin: 2px 0; +} + +.vocabulary-aside { + flex: 2; + min-width: 300px; + max-width: 520px; + overflow: auto; + padding-bottom: 12px; +} + +.words-table td { + font-family: var(--mono); +} + +.words-table tr.selected td { + background: #223040; +} + +.words-table .spread { + color: var(--paper-dim); + white-space: nowrap; +} + +.word-button { + text-align: left; + text-decoration: underline; + text-decoration-color: var(--rule); + text-underline-offset: 2px; +} + +.word-button:hover { + text-decoration-color: var(--paper-dim); +} + +.carriers { + margin-bottom: 14px; + padding: 8px 10px; + border: 1px solid var(--rule); + border-radius: 3px; + background: var(--ink-raised); +} + +.carriers-head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 6px; +} + +.carriers-head h2 { + margin: 0; + font-size: 13px; + font-weight: 600; +} + +.carriers-head h2 span { + font-family: var(--mono); +} + +.carriers-head .report-clear { + margin-left: auto; +} + +.carrier-list { + margin: 0; + padding: 0; + list-style: none; + max-height: 40vh; + overflow: auto; +} + +.carrier-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 10px; + padding: 3px 0; + border-bottom: 1px solid var(--rule-soft); +} + +.carrier-list .counterpart:disabled { + cursor: default; +} + +.carrier-list .counterpart:disabled .counterpart-name { + text-decoration: none; +} + +.carrier-weight { + font-family: var(--mono); + font-size: 11px; + color: var(--paper-dim); + font-variant-numeric: tabular-nums; +} + +.carrier-where { + grid-column: 1 / -1; + margin-top: 0; +} diff --git a/packages/navigator-ui/src/vocabulary.ts b/packages/navigator-ui/src/vocabulary.ts new file mode 100644 index 0000000..0704554 --- /dev/null +++ b/packages/navigator-ui/src/vocabulary.ts @@ -0,0 +1,165 @@ +import type { Carrier, ScopeRef, TermClass, VocabularyArtifact } from "@codegraph/vocabulary"; + +/** + * THE VOCABULARY, AS THE PAGE RECEIVES IT (PLAN.md §20.2): the artifact + * `codegraph serve` hands out at `vocabulary.json`, and the one lookup behind + * it, `carriers.json?scope=…&term=…&class=…` — which names carry a word. + * + * RESTATED, NOT IMPORTED. `@codegraph/vocabulary`'s index is the analyzer, + * core, zod and the SQLite loader; this bundle imports it for TYPES only, and + * the layout through its `./cloud` entry, which imports nothing. The kinds and + * the class vocabulary are literals here, pinned to the package's own by + * `vocabulary.test.ts`. + * + * QUIET WHEN ABSENT, LOUD WHEN WRONG — the navigator's rule. No route (a + * dropped file, a static host, the Vite dev server without VOCABULARY_JSON) + * is `absent`, and the tab says how to get one; bytes that arrive and are no + * vocabulary say so. The carriers are an extra: the app daemon holds no model + * and has no such route, and the tab then shows the counts without evidence. + */ +export const VOCABULARY_ARTEFACT_KIND = "codegraph.vocabulary/1"; +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 VocabularyLoad = + | { readonly status: "ready"; readonly artifact: VocabularyArtifact } + | { readonly status: "absent" } + | { readonly status: "error"; readonly message: string }; + +const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + +/** Structural, as the navigator's guard is: the builder's own suite pins the rows. */ +export function parseVocabularyArtifact(json: unknown): VocabularyArtifact | string { + if (!isObject(json)) return "vocabulary.json is not a vocabulary artifact (not an object)."; + if (json["kind"] !== VOCABULARY_ARTEFACT_KIND) { + return `vocabulary.json is not a vocabulary artifact (kind ${JSON.stringify(json["kind"] ?? "missing")}, expected "${VOCABULARY_ARTEFACT_KIND}").`; + } + for (const key of ["terms", "scopes", "classes", "langs"]) { + if (!Array.isArray(json[key])) return `this vocabulary artifact is missing its '${key}' array.`; + } + for (const key of ["weights", "technical", "view", "corpus"]) { + if (!isObject(json[key])) return `this vocabulary artifact is missing its '${key}'.`; + } + if ((json["scopes"] as unknown[]).length === 0) return "this vocabulary artifact has no scope, not even the corpus."; + return json as unknown as VocabularyArtifact; +} + +/** The page's sibling `vocabulary.json` — RELATIVE, so the app daemon's `//` holds too. */ +export async function loadVocabulary(fetcher: Fetcher = noStore): Promise { + let response: Response; + try { + response = await fetcher("vocabulary.json"); + } catch { + return { status: "absent" }; + } + if (!response.ok) return { status: "absent" }; + let json: unknown; + try { + json = await response.json(); + } catch { + // index.html from a host that answers every path: no such artifact here. + return { status: "absent" }; + } + const parsed = parseVocabularyArtifact(json); + return typeof parsed === "string" ? { status: "error", message: parsed } : { status: "ready", artifact: parsed }; +} + +export type Carriers = + /** No such route, or an answer this page cannot read: nothing to show, said once. */ + | { readonly status: "none" } + /** The server holds the model and does not know this scope or term — another build. */ + | { readonly status: "unknown" } + | { + readonly status: "found"; + readonly scope: ScopeRef; + readonly term: { readonly text: string; readonly class: TermClass }; + readonly weight: number; + readonly occurrences: number; + readonly total: number; + readonly carriers: readonly Carrier[]; + }; + +const NONE: Carriers = { status: "none" }; + +/** Structural, and never a throw. */ +export function parseCarriersAnswer(json: unknown): Carriers { + if (!isObject(json) || json["kind"] !== CARRIERS_ANSWER_KIND) return NONE; + if (json["status"] === "unknown-scope" || json["status"] === "unknown-term") return { status: "unknown" }; + if (json["status"] !== "found" || !Array.isArray(json["carriers"]) || !isObject(json["term"]) || !isObject(json["scope"])) return NONE; + const numbers = ["weight", "occurrences", "total"].map((key) => json[key]); + if (!numbers.every((value) => typeof value === "number")) return NONE; + const carriers = (json["carriers"] as unknown[]).filter( + (carrier): carrier is Carrier => isObject(carrier) && typeof carrier["name"] === "string" && typeof carrier["weight"] === "number", + ); + const [weight, occurrences, total] = numbers as [number, number, number]; + return { + status: "found", + scope: json["scope"] as unknown as ScopeRef, + term: json["term"] as unknown as { text: string; class: TermClass }, + weight, + occurrences, + total, + carriers, + }; +} + +export interface CarriersQuery { + /** A module's or a type's id; absent for the corpus. */ + readonly scope: string | undefined; + readonly term: string; + readonly class: TermClass; +} + +export interface CarriersLoader { + load(query: CarriersQuery): Promise; +} + +export function carriersUrl(query: CarriersQuery): string { + const params = new URLSearchParams({ term: query.term, class: query.class }); + if (query.scope !== undefined) params.set("scope", query.scope); + 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. + */ +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; + }, + }; +} diff --git a/packages/navigator-ui/test/fixture.ts b/packages/navigator-ui/test/fixture.ts index 2eb4c25..b4c6c8c 100644 --- a/packages/navigator-ui/test/fixture.ts +++ b/packages/navigator-ui/test/fixture.ts @@ -1,4 +1,5 @@ import type { DepRow, NavigatorModel, NavNode } from "@codegraph/navigator"; +import type { VocabularyArtifact, VocabularyScope } from "@codegraph/vocabulary"; /** * A hand-built artifact — the UI's own unit tests must not depend on the @@ -52,3 +53,57 @@ export function artifact(): NavigatorModel { diagnostics: { selfDeps: 0, droppedDeps: 0 }, }; } + +const ROLES = { module: 0, type: 0, operation: 0, attribute: 0, parameter: 0, local: 0, other: 0 }; + +function scope( + level: VocabularyScope["level"], + name: string, + terms: VocabularyScope["terms"], + extra: { id?: string; kind?: string; parent?: number } = {}, +): VocabularyScope { + return { + level, + ...extra, + name, + entities: terms.length, + roles: ROLES, + skipped: { unnamed: 0, conventional: 0, numeric: 0 }, + distinct: terms.length, + distinctDomain: 0, + weight: terms.reduce((sum, row) => sum + row[1], 0), + occurrences: terms.reduce((sum, row) => sum + row[2], 0), + terms, + }; +} + +/** + * The words of the toy above, by hand — the CONTRACT again, not the builder: + * the corpus, module p, types A and C, with the navigator's ids. + */ +export function vocabularyArtifact(): VocabularyArtifact { + return { + kind: "codegraph.vocabulary/1", + generatedBy: "@codegraph/vocabulary", + view: { name: "all", filters: [] }, + corpus: { name: "toy", roots: ["toy"] }, + langs: ["java"], + weights: { module: 8, type: 8, operation: 3, attribute: 4, parameter: 1, local: 1, other: 1, qualifier: 1 }, + classes: ["domain", "technical", "keyword", "short"], + technical: { words: 138, sha256: "4f34e69f52f5".padEnd(64, "0") }, + terms: [ + { text: "account", class: "domain", modules: 1, types: 1, exclusive: 1 }, + { text: "get", class: "technical", modules: 1, types: 1, exclusive: 1 }, + { text: "new", class: "keyword", modules: 1, types: 1, exclusive: 1 }, + { text: "order", class: "domain", modules: 1, types: 2, exclusive: 1 }, + { text: "x", class: "short", modules: 1, types: 1, exclusive: 1 }, + ], + scopes: [ + scope("corpus", "toy", [[3, 19, 3, 3], [0, 8, 1, 1], [1, 3, 1, 1], [2, 1, 1, 1], [4, 1, 1, 1]]), + scope("module", "p", [[3, 19, 3, 3], [0, 8, 1, 1], [1, 3, 1, 1], [2, 1, 1, 1], [4, 1, 1, 1]], { id: "java:p/p", kind: "package", parent: 0 }), + scope("type", "A", [[3, 11, 2, 2], [1, 3, 1, 1], [4, 1, 1, 1]], { id: "java:p/A", kind: "class", parent: 1 }), + scope("type", "C", [[0, 8, 1, 1], [3, 8, 1, 1], [2, 1, 1, 1]], { id: "java:p/C", kind: "class", parent: 1 }), + ], + diagnostics: { unplaced: [], withoutLexicon: [] }, + }; +} diff --git a/packages/navigator-ui/test/guard.test.ts b/packages/navigator-ui/test/guard.test.ts index 1edeaae..e243c22 100644 --- a/packages/navigator-ui/test/guard.test.ts +++ b/packages/navigator-ui/test/guard.test.ts @@ -28,15 +28,26 @@ function sourceFiles(directory: string): readonly string[] { * of `DEP_ROLES`. This states the rule instead of relying on remembering it. */ describe("the browser bundle stays browser-only", () => { - it("imports the navigator package for TYPES only", () => { - const offenders = sourceFiles(SRC).filter((path) => { - const source = readFileSync(path, "utf8"); - for (const match of source.matchAll(/^import\s+([^;]*?)\s+from\s+"@codegraph\/navigator"/gm)) { + /** Files importing `specifier` by value — anything but `import type`. */ + function valueImporters(specifier: string): readonly string[] { + const pattern = new RegExp(`^import\\s+([^;]*?)\\s+from\\s+"${specifier.replace(/[/.]/gu, "\\$&")}"`, "gmu"); + return sourceFiles(SRC).filter((path) => { + for (const match of readFileSync(path, "utf8").matchAll(pattern)) { if (!(match[1] as string).startsWith("type ")) return true; } return false; }); - expect(offenders).toEqual([]); + } + + it("imports the navigator package for TYPES only", () => { + expect(valueImporters("@codegraph/navigator")).toEqual([]); + }); + + // The vocabulary's index is the same pipeline. Its `./cloud` entry is the + // layout alone, imports nothing, and is the one value the tab may take. + it("imports the vocabulary package for TYPES only, and its layout from ./cloud", () => { + expect(valueImporters("@codegraph/vocabulary")).toEqual([]); + expect(valueImporters("@codegraph/vocabulary/cloud").length).toBeGreaterThan(0); }); }); diff --git a/packages/navigator-ui/test/vocabulary.test.ts b/packages/navigator-ui/test/vocabulary.test.ts new file mode 100644 index 0000000..4ab4def --- /dev/null +++ b/packages/navigator-ui/test/vocabulary.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { + CARRIERS_ANSWER_KIND as CANONICAL_CARRIERS_KIND, + TERM_CLASSES as CANONICAL_CLASSES, + VOCABULARY_ARTEFACT_KIND as CANONICAL_KIND, +} from "@codegraph/vocabulary"; +import { buildIndexes } from "../src/model/indexes.js"; +import { carrierNode, cloudWords, indexVocabulary, scopeChain, scopeForNode, wordRows } from "../src/model/words.js"; +import { + CARRIERS_ANSWER_KIND, + TERM_CLASSES, + VOCABULARY_ARTEFACT_KIND, + carriersUrl, + createCarriersLoader, + loadVocabulary, + parseCarriersAnswer, + parseVocabularyArtifact, + type Fetcher, +} from "../src/vocabulary.js"; +import { artifact, vocabularyArtifact } from "./fixture.js"; + +/** + * The Vocabulary tab's data (PLAN.md §20.2): the artifact at `vocabulary.json`, + * the lookup at `carriers.json`, and the joins to the navigator on entity ids. + */ + +const json = (status: number, body: unknown): Response => + new Response(typeof body === "string" ? body : JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +describe("the literals the page restates", () => { + it("are the vocabulary package's own: the artifact kind, the answer kind, the classes in order", () => { + expect(VOCABULARY_ARTEFACT_KIND).toBe(CANONICAL_KIND); + expect(CARRIERS_ANSWER_KIND).toBe(CANONICAL_CARRIERS_KIND); + expect([...TERM_CLASSES]).toEqual([...CANONICAL_CLASSES]); + }); +}); + +describe("vocabulary.json", () => { + it("accepts a vocabulary artifact", () => { + const parsed = parseVocabularyArtifact(vocabularyArtifact()); + expect(typeof parsed === "string" ? parsed : parsed.scopes).toHaveLength(4); + }); + + it("refuses another artifact, or one missing what the tab reads, saying which", () => { + expect(parseVocabularyArtifact({ kind: "codegraph.navigator/1" })).toMatch(/codegraph\.vocabulary\/1/u); + expect(parseVocabularyArtifact({ ...vocabularyArtifact(), scopes: undefined })).toMatch(/scopes/u); + expect(parseVocabularyArtifact({ ...vocabularyArtifact(), weights: undefined })).toMatch(/weights/u); + expect(parseVocabularyArtifact({ ...vocabularyArtifact(), scopes: [] })).toMatch(/no scope/u); + expect(parseVocabularyArtifact([1])).toMatch(/not an object/u); + }); + + it("is absent when nothing is served there, and an error when the bytes are wrong", async () => { + expect(await loadVocabulary(async () => json(404, ""))).toEqual({ status: "absent" }); + expect(await loadVocabulary(async () => json(200, ""))).toEqual({ status: "absent" }); + expect(await loadVocabulary(async () => Promise.reject(new Error("refused")))).toEqual({ status: "absent" }); + expect(await loadVocabulary(async () => json(200, { kind: "codegraph.city/1" }))).toMatchObject({ status: "error" }); + expect(await loadVocabulary(async () => json(200, vocabularyArtifact()))).toMatchObject({ status: "ready" }); + }); + + it("is fetched relative to the page, as the app daemon's token prefix needs", async () => { + const asked: string[] = []; + await loadVocabulary(async (url) => { + asked.push(url); + return json(404, ""); + }); + expect(asked).toEqual(["vocabulary.json"]); + }); +}); + +describe("the scope the tab shows", () => { + const ix = buildIndexes(artifact()); + const vocabulary = indexVocabulary(vocabularyArtifact()); + + it("is the corpus with nothing selected, a module's or a type's own, and a member's type", () => { + expect(scopeForNode(ix, vocabulary, undefined)).toBe(0); + expect(scopeForNode(ix, vocabulary, 0)).toBe(1); // p + expect(scopeForNode(ix, vocabulary, 1)).toBe(2); // A + expect(scopeForNode(ix, vocabulary, 2)).toBe(2); // m() of A + expect(scopeForNode(ix, vocabulary, 5)).toBe(3); // n() of C + }); + + it("is the corpus for a node the vocabulary does not have: another view, a stub", () => { + const other = indexVocabulary({ ...vocabularyArtifact(), scopes: [vocabularyArtifact().scopes[0]!] }); + expect(scopeForNode(ix, other, 1)).toBe(0); + }); + + it("sits in a chain from the corpus, through the stored scopes around it", () => { + expect(scopeChain(vocabularyArtifact(), 2)).toEqual([0, 1, 2]); + expect(scopeChain(vocabularyArtifact(), 0)).toEqual([0]); + }); +}); + +describe("a scope's words", () => { + const vocabulary = vocabularyArtifact(); + + it("are the artifact's rows of the chosen classes, in its order", () => { + expect(wordRows(vocabulary, 0, new Set(["domain"])).map((row) => [row.text, row.weight, row.carriers])).toEqual([ + ["order", 19, 3], + ["account", 8, 1], + ]); + expect(wordRows(vocabulary, 2, new Set(TERM_CLASSES)).map((row) => `${row.text}:${row.class}`)).toEqual(["order:domain", "get:technical", "x:short"]); + expect(wordRows(vocabulary, 0, new Set(["domain"]))[0]).toMatchObject({ modules: 1, types: 2, occurrences: 3 }); + }); + + it("feed the cloud its N heaviest", () => { + const rows = wordRows(vocabulary, 0, new Set(TERM_CLASSES)); + expect(cloudWords(rows, 2)).toEqual([ + { text: "order", weight: 19, class: "domain" }, + { text: "account", weight: 8, class: "domain" }, + ]); + }); +}); + +describe("carriers.json", () => { + const found = { + kind: CARRIERS_ANSWER_KIND, + status: "found", + scope: { level: "type", id: "java:p/A", name: "A" }, + term: { text: "order", class: "domain" }, + weight: 11, + occurrences: 2, + total: 2, + carriers: [ + { id: "java:p/A", name: "A", kind: "class", role: "type", weight: 8, occurrences: 1 }, + { name: "order", kind: "parameter", role: "parameter", weight: 3, occurrences: 1, owner: "java:p/A" }, + ], + }; + + it("asks for one term of one class in one scope, the corpus by omission", () => { + expect(carriersUrl({ scope: "java:p/A", term: "order", class: "domain" })).toBe("carriers.json?term=order&class=domain&scope=java%3Ap%2FA"); + expect(carriersUrl({ scope: undefined, term: "a&b", class: "short" })).toBe("carriers.json?term=a%26b&class=short"); + }); + + it("reads a found answer, an unknown one, and nothing else", () => { + expect(parseCarriersAnswer(found)).toMatchObject({ status: "found", total: 2, carriers: [{ name: "A" }, { name: "order" }] }); + expect(parseCarriersAnswer({ kind: CARRIERS_ANSWER_KIND, status: "unknown-term" })).toEqual({ status: "unknown" }); + expect(parseCarriersAnswer({ kind: CARRIERS_ANSWER_KIND, status: "ambiguous" })).toEqual({ status: "none" }); + expect(parseCarriersAnswer({ kind: "codegraph.insight/1", status: "found" })).toEqual({ status: "none" }); + expect(parseCarriersAnswer({ ...found, total: "2" })).toEqual({ status: "none" }); + }); + + it("asks once per question, and once at all where there is no such route", async () => { + let calls = 0; + const answering: Fetcher = async () => { + calls += 1; + return json(200, found); + }; + const loader = createCarriersLoader(answering); + const query = { scope: "java:p/A", term: "order", class: "domain" } as const; + await Promise.all([loader.load(query), loader.load(query)]); + expect(calls).toBe(1); + + let bare = 0; + const daemon = createCarriersLoader(async () => { + bare += 1; + return new Response(null, { status: 404 }); + }); + expect(await daemon.load(query)).toEqual({ status: "none" }); + expect(await daemon.load({ ...query, term: "account" })).toEqual({ status: "none" }); + expect(bare).toBe(1); + }); + + it("reveals a type or a module by its id, and a member by its owner", () => { + const ix = buildIndexes(artifact()); + const [type, member] = found.carriers; + expect(carrierNode(ix, type as never)).toBe(1); + expect(carrierNode(ix, member as never)).toBe(1); + expect(carrierNode(ix, { name: "z", kind: "class", role: "type", weight: 1, occurrences: 1, id: "java:elsewhere" })).toBeUndefined(); + }); +}); diff --git a/packages/navigator-ui/vite.config.ts b/packages/navigator-ui/vite.config.ts index 4934a77..af8c885 100644 --- a/packages/navigator-ui/vite.config.ts +++ b/packages/navigator-ui/vite.config.ts @@ -4,18 +4,18 @@ import type { Connect } from "vite"; import { defineConfig, type Plugin } from "vite"; /** - * Serve the artifact named by the NAVIGATOR_JSON env var as `/navigator.json`, - * so the usual loop is one line: + * Serve the artifact named by the NAVIGATOR_JSON env var as `/navigator.json` + * (and VOCABULARY_JSON's as `/vocabulary.json`), so the usual loop is one line: * * NAVIGATOR_JSON=../../navigator.json pnpm --filter @codegraph/navigator-ui dev * * Without the env var the app falls back to `?src=URL`, drag & drop, or a file * picker — the dev server never becomes a required part of the pipeline. */ -function navigatorJsonPlugin(): Plugin { +function artifactJsonPlugin(route: string, variable: string): Plugin { const handler: Connect.NextHandleFunction = (req, res, next) => { - const path = process.env["NAVIGATOR_JSON"]; - if (path === undefined || !(req.url ?? "").startsWith("/navigator.json")) { + const path = process.env[variable]; + if (path === undefined || !(req.url ?? "").startsWith(route)) { next(); return; } @@ -27,11 +27,11 @@ function navigatorJsonPlugin(): Plugin { }) .catch((error: unknown) => { res.statusCode = 404; - res.end(`NAVIGATOR_JSON not readable: ${String(error)}`); + res.end(`${variable} not readable: ${String(error)}`); }); }; return { - name: "codegraph-navigator-json", + name: `codegraph-${route.slice(1)}`, configureServer(server) { server.middlewares.use(handler); }, @@ -45,6 +45,10 @@ export default defineConfig({ // Relative base so `vite build` output runs from any static file server // with a navigator.json placed beside index.html. base: "./", - plugins: [react(), navigatorJsonPlugin()], + plugins: [ + react(), + artifactJsonPlugin("/navigator.json", "NAVIGATOR_JSON"), + artifactJsonPlugin("/vocabulary.json", "VOCABULARY_JSON"), + ], build: { target: "es2023" }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d4461c..6c6c13c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,6 +286,9 @@ importers: '@codegraph/viz': specifier: workspace:* version: link:../viz + '@codegraph/vocabulary': + specifier: workspace:* + version: link:../vocabulary cytoscape: specifier: ^3.34.2 version: 3.34.2 From 0fa83e2b91ffa39e47351c6e7843789b5a86851b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Grodziski?= Date: Mon, 26 Oct 2026 13:06:03 +0100 Subject: [PATCH 7/8] docs: the Vocabulary tab and the carriers lookup CLAUDE.md's package map, docs/cli.md's serve section and the website's serve, vocabulary, vocabulary.json and environment pages: the sixth tab, the third artifact, the carriers answer and its statuses, the daemon without a carriers route, VOCABULARY_JSON for the dev server. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 19 ++++++++++----- docs/cli.md | 24 +++++++++++++++---- .../reference/artifacts/vocabulary-json.md | 15 ++++++++++++ website/content/docs/reference/cli/serve.md | 11 +++++---- .../content/docs/reference/cli/vocabulary.md | 2 ++ website/content/docs/reference/environment.md | 1 + 6 files changed, 57 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 18795ef..9f010de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,10 @@ packages/navigator/ @codegraph/navigator — the navigator MODEL: the browsable packages/vocabulary/ @codegraph/vocabulary — the words of the model's NAMES (never source): the universal splitter driven by each profile's lexicon, the word classes (domain, technical, - keyword, short) and the role weights. Pure computation. + keyword, short) and the role weights; the cloud's layout + and SVG; the carriers lookup (which names carry a word). + Pure computation. Its `./cloud` entry is the layout alone + and imports nothing: the one value a frontend may take. packages/insights/ @codegraph/insights — the bottom-up EXPLANATION walk: units (operations → types → modules), SCC-condensed walk order, context packs, prompts, Merkle fingerprints, plan/run, the @@ -86,16 +89,20 @@ packages/viz/ @codegraph/viz — Three.js code city: renders a laid-out ONLY package that may import three. Exports ONE thing, `mountCityView(host)`, which navigator-ui embeds; its own Vite app is the standalone page (replay/history). -packages/navigator-ui/ @codegraph/navigator-ui — React model navigator, five +packages/navigator-ui/ @codegraph/navigator-ui — React model navigator, six tabs: Navigate (virtualized tree + fan-in/fan-out evidence), City (the viz view, full-width, a building's panel opens it in Navigate), Graph (Cytoscape+fcose dependency graph, module/type modes), Cycles (the artifact's precomputed tangle report), Coupling (ranked - metrics). Reads navigator.json + city.json (guard-enforced), - plus ONE lookup, `insight.json?id=…`: the selected type's or - module's LLM explanation, shown as an inference, never a fact. - It imports @codegraph/insights not even for types. + metrics), Vocabulary (the selected scope's words as a + cloud and a table). Reads navigator.json + city.json + + vocabulary.json (guard-enforced), plus TWO lookups: + `insight.json?id=…`, the selected type's or module's LLM + explanation, shown as an inference, never a fact; and + `carriers.json?term=…`, the names that carry a word. + It imports @codegraph/insights not even for types, and + @codegraph/vocabulary for types plus its `./cloud` layout. The ONLY package that may import react and cytoscape; the city comes through @codegraph/viz, never through three. Vite app, no library. diff --git a/docs/cli.md b/docs/cli.md index 0c237dc..3e580a5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -151,10 +151,24 @@ for the selection, incoming and outgoing dependencies classified by role, with the member that carries each, its provenance and its source anchor; **City** — the 3D code city, full-width, where a selected building's panel offers *Open in navigator* and lands on that type's Navigate entry with its dependencies; -**Graph** (Cytoscape), **Cycles** (the precomputed tangle report) and -**Coupling** (ranked metrics). The city's channels are the `city` flags -below, so the page's city is exactly the artifact `city` would write. The -server hands out `/navigator.json` and `/city.json`; stdout stays empty. +**Graph** (Cytoscape), **Cycles** (the precomputed tangle report), +**Coupling** (ranked metrics) and **Vocabulary** — the words of the selected +scope's names (the corpus until a module or a type is selected; a member +shows its type) as the cloud `vocabulary --svg` draws, measured in the page's +font, and as a table of weight, occurrences, carriers and spread, with class +chips and a top-N slider. The city's channels are the `city` flags below, so +the page's city is exactly the artifact `city` would write. The server hands +out `/navigator.json`, `/city.json` and `/vocabulary.json` (the vocabulary +under its default weights and technical list); stdout stays empty. + +Clicking a word on the Vocabulary tab lists **the names that carry it** in +that scope, heaviest first, each one click from its node in Navigate (a +member reveals its type). The artifact counts and never lists, so this is the +server's second lookup, `/carriers.json?term=WORD[&scope=ID][&class=CLASS][&limit=N]`, +answered from the model it holds: the row's weight, occurrences and carriers, +and the first `limit` (200) of them, whose weights add up to the row. An +unknown scope or term is `404`; a request with no term, a class that is none +or a text the scope speaks as two terms (`class` then chooses) is `400`. When `explain` has been run, a selected type or module also shows **what the model wrote about it** — the description, the domain concept, the model and @@ -171,6 +185,8 @@ 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), `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/website/content/docs/reference/artifacts/vocabulary-json.md b/website/content/docs/reference/artifacts/vocabulary-json.md index 6ff79fc..1b94e54 100644 --- a/website/content/docs/reference/artifacts/vocabulary-json.md +++ b/website/content/docs/reference/artifacts/vocabulary-json.md @@ -67,3 +67,18 @@ A type's words are those of everything that folds into it (`containingType`), a | `qualifier` | 1 | not a role: the words before the last separator of a qualified name (`com.acme` of `com.acme.order`), whatever carries it | Roles come from traits, never from the kind. `--weights` overrides them, and the artifact records the weights it was counted with. + +## Carriers + +The artifact counts the names behind a row and never lists them, so it stays a few megabytes on a real corpus. [`serve`](/docs/reference/cli/serve/) answers the list one row at a time, at `/carriers.json?term=WORD[&scope=ID][&class=CLASS][&limit=N]`, from the model it holds and under the artifact's own counting: + +| Field | Type | Meaning | +|---|---|---| +| `kind` | `"codegraph.carriers/1"` | | +| `status` | `"found"` \| `"unknown-scope"` \| `"unknown-term"` \| `"ambiguous"` | `404` for the two unknowns; `ambiguous` (`400`) carries `classes` when the scope speaks the text as two terms | +| `scope` | `{level, id?, name}` | the corpus when the request names no scope | +| `term` | `{text, class}` | | +| `weight`, `occurrences`, `total` | number | the row: `total` is its `carriers` | +| `carriers` | object[] | the first `limit` (200 by default), by weight descending, then id: `name`, `kind`, `role`, `weight` and `occurrences` (what this name added to the row), `anchor`, and `id` on a type or module, `owner` (its type, else its module) on a member | + +The carriers' weights and occurrences add up to the row, and their number is its `carriers`. diff --git a/website/content/docs/reference/cli/serve.md b/website/content/docs/reference/cli/serve.md index 18f326a..a6d38a3 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** and **Coupling**. The server hands out `/navigator.json` and `/city.json` — each exactly the file [`navigator`](/docs/reference/cli/navigator/) and [`city --layout`](/docs/reference/cli/city/) would have written — beside the built frontend. 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). 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. ## Synopsis @@ -54,8 +54,9 @@ loop for that app: run it from a checkout and open the URL in a browser. the token every route is `404`; a request whose `Origin` header is not the page's own is `403`. - **Routes.** The page and its assets; `app` (the registered extractors and - the current project); `recent`; `navigator.json` and `city.json` (the - current project's artifacts, `404` until one is open); `POST jobs` with + 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 `{"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), @@ -73,8 +74,8 @@ loop for that app: run it from a checkout and open the URL in a browser. census of the folder's file extensions (dot-directories and `node_modules` skipped) against every entry's `extensions`. - **`--data-dir`** holds one directory per opened folder — its `model.jsonl`, - the `model.db` cache built beside it, `navigator.json`, `city.json` and a - `project.json` — plus `recent.json`. Reopening a folder whose claimed files + the `model.db` cache built beside it, `navigator.json`, `city.json`, + `vocabulary.json` and a `project.json` — plus `recent.json`. Reopening a folder whose claimed files have the same paths, sizes and mtimes skips the extractor; the build is skipped too when the city flags have not changed. - **It dies with its parent:** stdin EOF and SIGTERM both close the server diff --git a/website/content/docs/reference/cli/vocabulary.md b/website/content/docs/reference/cli/vocabulary.md index 3feed86..309d324 100644 --- a/website/content/docs/reference/cli/vocabulary.md +++ b/website/content/docs/reference/cli/vocabulary.md @@ -53,6 +53,8 @@ The glossary is a plain text file, one business term per line; blank lines and ` `--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. + ## Exit codes `0` ok · `1` internal error (a bug) · `2` usage error · `3` findings. diff --git a/website/content/docs/reference/environment.md b/website/content/docs/reference/environment.md index 37d1bbd..348ed79 100644 --- a/website/content/docs/reference/environment.md +++ b/website/content/docs/reference/environment.md @@ -39,6 +39,7 @@ Read only by the Vite configs, so only by `pnpm dev` in those packages. [`codegr |---|---|---| | `CITY_JSON` | `packages/viz/vite.config.ts` | absolute path to a `city.json`; the dev server serves it at `/city.json` | | `NAVIGATOR_JSON` | `packages/navigator-ui/vite.config.ts` | absolute path to a `navigator.json`; the dev server serves it at `/navigator.json` | +| `VOCABULARY_JSON` | `packages/navigator-ui/vite.config.ts` | absolute path to a `vocabulary.json`; the dev server serves it at `/vocabulary.json`, for the Vocabulary tab | ```bash CITY_JSON=$PWD/city.json pnpm --filter @codegraph/viz dev From 14a97a0ebc385fa6ebdfe63b93adf6a2c6775013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Grodziski?= Date: Mon, 26 Oct 2026 13:06:21 +0100 Subject: [PATCH 8/8] docs(plan): the tab and the carriers as built Co-Authored-By: Claude Opus 5.5 --- PLAN.md | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/PLAN.md b/PLAN.md index 2709b78..a430aa0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -5234,7 +5234,21 @@ names carry the word (name, kind, role, anchor, and the id when the entity is a type or module — the hand-off key), from the in-memory model `serve` already holds; an unknown scope or term is 404, like `/insight.json`. The artifact stays small (counts, not lists) and a static host degrades to no -evidence rather than to a bigger file. +evidence rather than to a bigger file. **As built** (`carriers.ts`, +`carriers-route.ts`): the counting rule moved to `reading.ts` so the +artifact and the carriers read a name the same way, and A ROW IS THE SUM OF +ITS CARRIERS — weights, occurrences and count — a property over every term +of every scope of the six fixtures; an artifact counted under other weights, +another list or another view is refused. A member carries no id and an +`owner` (its type, else its module), the navigator's rule. No `scope` is the +corpus; `class` disambiguates a text two languages class apart (`ambiguous`, +400, lists the classes); `limit` defaults to 200 and `total` always counts +them all. The index is built at the first answerable request, reading every +kept entity once, and the graph is released then. The app daemon builds +`vocabulary.json` with the other two, but holds no model between jobs, so it +has no `/carriers.json`: the tab shows the counts and says why there are no +names. On Broadleaf the artifact is 2.9 MB and `order` at the corpus has +3 303 carriers. **The Vocabulary tab** (`navigator-ui`, the sixth): the cloud of the current scope — the corpus until a module or a type is selected in Navigate, then @@ -5247,7 +5261,22 @@ and lists the carriers; a type or module among them reveals in Navigate (its `@codegraph/vocabulary` for TYPES only (the frontend rule), and the layout runs in the browser with the page's own measurer — the same function the CLI ran. Reviewed as screenshots on Broadleaf: the corpus, `catalog.domain`, -`ProductImpl`. +`ProductImpl`. **As built**: the layout is a VALUE import through +`@codegraph/vocabulary/cloud`, a tsup entry of `cloud.ts` alone whose shared +chunk imports nothing; the guard test allows that one specifier and keeps +the package's index types-only. The measurer is `canvas.measureText` in the +page's monospace face (the page sets identifiers in mono; so does the SVG), +the colours the page's paper, dimmed and faint — no hue, since the page's +four hues already mean direction, inference and selection. The scope chain +(corpus › module › … › type) steps up by selecting the scope's node, the +corpus by clearing the selection. The tab mounts on first visit and stays +mounted, refetching `vocabulary.json` with each new navigator (the daemon's +next job). Reviewed on Broadleaf at 1600×1000: the corpus reads `order item +customer offer product admin group fulfillment category payment`; +`ProductImpl` reads `product category all sale sku parent`; `catalog.domain` +with `product` picked lists 239 carriers, and a parameter among them reveals +its owner, `Category`, in Navigate; a page with no carriers route keeps the +counts and says so. **The city channel**: `vocabulary:` as a metric source for `--height`, `--color` and `--carry` — the term's weight in the building's type scope, @@ -5434,11 +5463,11 @@ never looks like a fact. - [x] `layout.ts` and `svg.ts` with their properties and the SVG snapshot; `--svg` (the layout is `cloud.ts`). -- [ ] `serve`: `/vocabulary.json` built with the two others, +- [x] `serve`: `/vocabulary.json` built with the two others, `/carriers.json` as a lookup; both on the app daemon's page for free (the artifact rides the same build), the lookups following the - insight route's precedent. -- [ ] The Vocabulary tab: cloud, chips, table, carriers, reveal; the + insight route's precedent (the daemon has neither lookup). +- [x] The Vocabulary tab: cloud, chips, table, carriers, reveal; the literal-pinning and types-only boundary tests; screenshots on Broadleaf reviewed at user-facing sizes. - [ ] `vocabulary:` metric source through the `metric(name)` hook;