Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
39 changes: 34 additions & 5 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:<term>` as a metric source for `--height`,
`--color` and `--carry` — the term's weight in the building's type scope,
Expand Down Expand Up @@ -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:<term>` metric source through the `metric(name)` hook;
Expand Down
24 changes: 20 additions & 4 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `/<token>/`: 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).
Expand Down
14 changes: 10 additions & 4 deletions packages/cli/src/app/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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": {
Expand Down
30 changes: 19 additions & 11 deletions packages/cli/src/app/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <data-dir>/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
Expand Down Expand Up @@ -92,6 +95,7 @@ export type StartOutcome =
export interface Artifacts {
readonly navigator: string;
readonly city: string;
readonly vocabulary: string;
readonly project: ProjectSummary;
}

Expand Down Expand Up @@ -334,36 +338,39 @@ 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 &&
previous.fingerprint === fingerprint &&
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,
Expand All @@ -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);

Expand All @@ -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 });
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { basename, join } from "node:path";
* <data-dir>/models/<name>-<hash>/ 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
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/src/carriers-route.ts
Original file line number Diff line number Diff line change
@@ -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=<word>, and takes &scope=<module or type id>&class=<class>&limit=<n>";

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 }) };
}
Loading
Loading