@titan-design/code-read
v0.3.4
Published
Versioned read API over code-graph snapshots: a contract, a per-snapshot ReadModel, browser-safe query functions, and registry commands
Readme
@titan-design/code-read
A versioned read API over @titan-design/code-graph snapshots, for three kinds of consumer:
a drill-down report UI (live or from a static export), agents over MCP, and workflows. The
package holds one contract, one per-snapshot ReadModel, and one pure query function per
command. The daemon's commands and a browser's static dataset both answer through those
functions.
Tier 2 of the titan-platform DAG (TP-184). Depends on code-graph, registry, and
rpc-protocol; zod is a peer.
Two entry points
| Import | Runs in | Holds |
| --- | --- | --- |
| @titan-design/code-read/query | browser or Node | the contract (CONTRACT, CODE_READ_API_VERSION, zod schemas), ReadModel and buildReadModel, the ReadSource seam, QUERIES, createQueryResolver |
| @titan-design/code-read | Node | everything in ./query, plus loadReadModel, createLiveSource (SQLite plus an LRU of models), and registerCodeReadCommands |
./query imports only its own files, zod, rpc-protocol, and code-graph's browser-safe
./analysis subpath, never code-graph's root. Three rules in
.codewatch/check.json (code-read-query-*) and src/browser-safe.test.ts enforce that.
The test bundles the subpath with esbuild for platform: "browser" and expects no warnings.
Commands (contract 0.1.8)
| Command | Args | Result |
| --- | --- | --- |
| api.describe | none | api, dataset, commands, newest, indexVersions, capabilities, metrics (catalogue descriptors with provenance), rules |
| snapshot.list | ref?, limit (1 to 500, default 50) | snapshots, newest first |
| hierarchy.get | snapshot?, root?, depth (1 to 8, default 2), metrics (default ["loc"]), baseline?, include_symbols, exclude_roles | snapshotId, baselineSnapshotId?, comparable?, nodes (flat, shallowest first, with parentId, depth, childCount, values, missing?, deltas?), truncated |
| node.get | snapshot?, id, baseline?, metrics (default: every one that applies), lenses (any of exports, score, centrality, coupling, tests; default none), window (30d default, for score) | node, ancestors (repo first), childCounts, metrics (value, direction, rollup, percentile, siblingMedian, siblingRank, siblingCount, baseline?, delta?, missing?), lenses? (one key per lens asked for) |
| node.resolve | exactly one of query or path, plus line? with path, limit (1 to 50, default 10) | candidates: node, score, match |
| findings.list | snapshot?, baseline?, scope?, filters rule, severity, tool, provenance, kind, status (arrays, empty means all), sort (severity default, excess, value, path, rule), order (desc default), offset, limit (0 to 500, default 20), facets | snapshotId, baselineSnapshotId?, comparable?, rows (Finding), total, facets? |
| finding.get | snapshot?, id, baseline?, context_lines (0 to 20, default 5) | finding, rule (with text), measured, why, excerpt (or null with excerptMissing), related (at most 10) |
| node.neighbors | snapshot?, id (a stored node), direction (both default), edge_kinds, metrics (default loc, utilization), offset, limit (1 to 100, default 20) | snapshotId, node, inbound, outbound (each node, kind, weight, specifier?, values), total per side |
| hotspots.list | snapshot?, baseline?, grain (file default, symbol), window (30d default, any <n>d, or lifetime), cutoff?, offset, limit (0 to 500, default 20) | snapshotId, baselineSnapshotId?, comparable?, rows (node, churn, complexity, recency, score, utilization?, baselineScore?, mark?), total |
| overview.get | snapshot?, baseline?, window (30d default), cutoff (default 3000), weights (per signal, defaults from code-graph's DEFAULT_HEALTH_WEIGHTS), exclude_rules, combined (default false), reading_limit (default 6), look_limit (default 8; both 0 to 50) | snapshotId, baselineSnapshotId?, comparable?, kpis, signals (key, label, penalty, cap, measured, detail), combined?, readingOrder (node, centrality), lookFirst (node, score, churn, complexity, recency, reasons) |
| changes.get | baseline (required), snapshot?, window (30d default), cutoff (default 3000), limit (rows per list, 0 to 500, default 20) | snapshotId, baselineSnapshotId, comparable, files (crossedCutoff, added), findings (new, worsened, improved, resolved), coupling (measured, added), regressions (node, before, after, delta, findings), counts |
| paths.impact | paths (up to 500), snapshot?, baseline?, root? (absolute checkout directory), window (30d default) | snapshotId, baselineSnapshotId?, comparable?, rows (by status: indexed with node, complexity, hotspot (score, rank), findings, delta?; not-indexed with path; outside-repo), ranked, rollup |
| packages.stats | snapshot?, packages? (package roots, up to 500; default every package the tier config declares) | snapshotId, packagesFrom (args, tiers, none), modularity, totalEdges, unassignedFiles, packages (id, name, fileCount, internalEdges, outgoingEdges, incomingEdges, cohesion, instability, abstractness, band, flags, layer), crossEdges (from, to, edges, intensity, flag) |
Arguments are snake_case and results are camelCase. snapshot and baseline take an id, a
digit string, or a ref name (that ref's newest snapshot). The rest of the design's 14
commands arrive in later minor versions of the contract.
The hierarchy
code-graph stores files and symbols only, so ./query synthesizes the rest per snapshot:
- Ids. The repo is
"". A directory is its path plus/, such assrc/util/; no file id ends in/. Module and external nodes are not in the hierarchy. - Class level. A symbol named
Job.runhangs underJobwhen that symbol exists, else under the nearest existing scope. A bare name (snapshots before code-graph 0.14.0) hangs under the smallest symbol whose span encloses it, else under its file. - Rollups. A directory's value, or a file's value for a symbol-only metric, combines the
stored values below it by the catalogue's
rolluprule:sum,max, ormean. A file metric rolls up from files only, so nothing counts twice.absent: "zero"counts a missing row as 0;absent: "exclude"leaves it out. - Why a value is null.
missingsays why:no-rollup(the rule isnone, as for commit and author counts, bus factor, and fan-in; directory-level history is TP-233),not-measured,not-applicable(such aslocon a symbol), ornot-in-snapshot. - Roles.
exclude_rolesdrops files of those roles from the rows, the rollups, and the baseline alike. - Deltas. Baseline nodes match by id only; alias following is TP-187.
comparableis false when the two snapshots have different index versions. - Size.
hierarchy.getreturns at mostHIERARCHY_ROW_CAP(5,000) rows breadth-first and setstruncated.childCountalways counts every child, symbols included.
node.get percentiles are the share of same-kind nodes whose value is at most the node's,
0 to 100. Siblings are the same-kind children of the same parent, the node included; rank 1
is the largest value. Neither number knows which way is worse. Read direction on the same
metric before labelling anything "top" or "best".
Worked example. A file with loc: 3 among sibling files with 0, 1, 2, and 2 lines gets
siblingRank: 1, and 7 of the snapshot's 10 files have at most 3 lines, so percentile: 70.
loc is direction: "higher-worse", so rank 1 is the file with the most lines. It is the
worst offender in its directory, not the best file. For bus_factor_30d
(direction: "lower-worse"), rank 1 is the file with the most authors covering its churn,
which is the safest file. For a neutral metric such as churn_30d, rank 1 only means
"largest".
node.resolve ports codewatch's rankSearch cascade over directories, files, and symbols,
case-insensitive: exact id or name 100, id suffix after / or # 80, name prefix 60, id
substring 40, name substring 30, ties by id. One addition for qualified names: a symbol whose
last segment equals the query, such as run for Task.run, scores as a suffix. A path
with a line, or a query of the form path:line, maps each matching file to the innermost
symbol whose span holds the line.
Findings
Until the findings store exists (design gap G11), every finding is a check-rule violation
derived on read. The live source runs the product's rules through code-graph's own
snapshotViolations when it loads a snapshot, so findings.list returns exactly what
graph check reports. Each row says provenance: { kind: "derived", source: "check/<rule>" }
and tool: "check". Stored findings, verdicts, and themes will arrive behind the same
Finding schema.
- Ids. A finding's id is code-graph's
violationKey:rule|nodeorrule|node|destination. Treat it as opaque. It stays the same across snapshots while the rule id, the node id, and the destination are unchanged. A moved file gets a new id until rename-aware keys land (TP-187). - Order.
sortpicks the primary key andorderflips only that key. Ties always break the same way: severity (error, warning, info, then unknown), then largerexcess, then node path, rule, and id. The id is unique, so the order is total andoffsetpages never repeat or skip a row. Rows with noexcessorvaluesort last in either direction. - Excess.
value / thresholdfor a maximum rule andthreshold / valuefor a minimum rule, so larger is always worse. Import rules have no threshold, soexcessis null. - Facets. With
facets: true, counts perrule,severity,tool,provenance,kind, andchild, plusstatuswith a baseline. They count every row that passed the filters, so each facet sums tototal.childis the scope's child (a directory id or a file) that holds the finding, which is what a drill-down view shows next.limit: 0withfacets: truegives headline counts in one call. - Scope. A directory scope holds everything under it, a file scope holds the file and
its symbols, and the repo (
"", the default) holds everything. - Status. With
baseline, each row isnew,carryover,worsened, orimproved(byexcess), and findings that no longer occur come back from the baseline asresolved, with the baseline'ssnapshotId. Astatusfilter without a baseline is DATAERR. - Excerpts.
finding.getshows the flagged lines pluscontext_lineseither side, clipped to the file and capped atEXCERPT_LINE_CAP(80) lines withtruncated: true. Edges carry no line numbers, so an import finding's flagged line is the one that names the import's specifier in quotes; the live source finds it when it loads the snapshot. A whole-node finding, such as a metric finding, uses its node's span when the node has one: a symbol finding gets the symbol's lines asrangeand highlight. A node with no span, such as a file, gets norange, no highlight, and an excerpt that starts at line 1. The live source reads the working tree underrepoRootand serves a file only when its hash equals the snapshot's fingerprint. Otherwiseexcerptis null andexcerptMissingsayschanged-since-snapshot. A static source serves the windows it exported and saysnot-in-exportfor the rest. - Neighbours.
node.neighborsneeds a stored node: a file, symbol, module, or external. A synthesized directory is DATAERR. Each side is ranked by edgeweight(code-graph's reference count), heaviest first, then by neighbour id and edge kind, and paged on its own. Emptyedge_kindsmeans every kind exceptreferencesfor a non-symbol node, becausereferencesedges are the symbol layer.
Trap: a derived finding is not a record. It exists only while its rule, at its
current threshold, still fires. Change a rule's threshold, rename a rule, or remove it,
and its findings vanish from every snapshot, old ones included, and their ids never come
back. A finding's status also depends on the baseline you pass: the same row is
carryover against one snapshot and new against another. Do not persist a derived id
as if it were durable until the findings store lands.
Hotspots
hotspots.list computes nothing of its own. It runs code-graph's report derivations from
the browser-safe @titan-design/code-graph/analysis subpath, so the daemon and a static
dataset rank alike.
- File grain is
topHotspots:round(churn_<window> × complexity × recency_<window>), where complexity iscognitive_max, elsecyclomatic_max. A file with no churn or no complexity has no row, and generated files are left out. - Symbol grain is
buildBlastRadius:utilization × complexity × file churn, with the symbol's ownsymbol_cognitive, else its file's. It applies no recency, sorecencyis 1. Symbols of generated files are left out. - Marks come from
computeReportDriftover every row:newwhen the node had no row at the baseline,worsenedwhen its score rose.baselineScoreis null for a new row. - Cutoff is the caller's policy, such as the dashboard's 3000. It filters before paging,
and
totalcounts the rows it keeps. Marks do not depend on it.
Overview
overview.get answers "where do I look first" with named attention signals, not a risk or
defect score. It also reuses code-graph's derivations from ./analysis:
- Signals are
computeHealth's components, weighted and capped by the caller'sweights: files overcutoff, open findings (new and carried over weigh apart), max complexity over budget, and hidden coupling, which is unmeasured until co-change pairs are stored.combined(100 minus the penalties) is optional and secondary. - Reading order is
topCentralFiles: PageRank over files and structural edges. - Look first is the file-grain hotspots, highest first. Each row names its reasons:
over-cutoff,findings, or elsechurn-complexity.
Node lenses
node.get takes optional lenses. With none, the result has no lenses field and is the
same as before. Each lens reuses a code-graph derivation from ./analysis, and answers
null on a node kind it does not describe: score describes files and symbols, the rest
describe files only.
- exports is
buildHotExports: the file's top 8 exported and top 8 internal symbols, each with utilization, its own cognitive complexity, and how many files reference it (computeSymbolConsumers). - score is the node's row in
hotspots.listat its grain forwindow: the score, its factors, and its rank among non-zero scores. A node scoring 0 has no factors and no rank. - centrality is
topCentralFileswith no limit, the ranking behindoverview.get's reading order, so every file in it has a rank. Generated code is left out and gets null. - coupling is
measured: falseuntil co-change pairs are stored. - tests lists tests linked by path convention (
linkTestsToSourceswith no co-edit pairs) beside the index'slinked_test_count, which also counts co-edit links.
Changes
changes.get answers "what moved since the baseline". It recomputes nothing code-graph
already derives:
- Files come from
computeReportDriftover every file-grain hotspot.crossedCutoffholds files belowcutoffat the baseline and at or above it now;addedholds files the baseline does not hold, generated files left out. - Findings are bucketed by
bucketViolations, the store-free core ofdiffCheckResults: new, worsened or improved byexcessasfindings.liststatuses are, and resolved. Ids match as they are; following renames is TP-187. - Coupling is unmeasured (
measured: false) until co-change pairs are stored. - Regressions are files whose score rose that carry an open finding now.
- Across index versions
comparableis false and every list is empty.
Path impact
paths.impact answers "what do these files weigh", for a set such as the files a change
touches. It ranks nothing a second way:
- Complexity is the factor the file hotspot score multiplies (
hotspotComplexityOf: max cognitive, else max cyclomatic), read even for a file with no churn. - Hotspot is the file's row in
hotspots.listatwindow:rankis its row number there andrankedthe list's total. A file scoring 0 has rank null. - Findings are the open findings on the file or a symbol in it, worst first.
- Delta, only with
baseline: score, complexity, and findings counts against the baseline, findings bucketed bybucketViolationsaschanges.getbuckets them, and each open finding gets astatus. Without a baselinedeltais absent; across index versions it is null. - Paths are repo-relative. A leading
./,.and..segments, and doubled slashes are normalized; an absolute path counts only underroot. A path the snapshot holds no file for is anot-indexedrow and one outside the repo anoutside-reporow, never an error. - The rollup counts each status, sums scores and open findings, and takes the top complexity and best rank; with a comparable baseline it sums the deltas, a new file adding its whole score.
Package stats
packages.stats is code-graph's computePartitionQuality over a set of package roots, the
numbers codewatch's graph arch --health prints:
- Roots are repo-relative path prefixes. Given as
packages, those; otherwise every root alayered-depsrule names, the repo's tier config.packagesFromsays which, and isnonewhen neither names one. A file sits in the longest root it falls under; the rest count asunassignedFiles. - Edges are the structural layer the store reads by default, so
referencesandcallsedges are left out, as are test and fixture files, ascomputeArchleaves them out. - Per package: file count, internal, outgoing, and incoming edges,
cohesion,instability,abstractness(the share oftypesfiles), the measuredband, and flags. - Layer is declared, not measured: the root's tier in the first
layered-depsrule that names it, 0 the lowest. A root no rule names haslayer: { status: "undeclared" }. - Cross edges are the package-to-package counts with their intensity and flag, and
modularityis the partition's Newman-Girvan Q.
Serving the commands
import { openCodeGraph, loadCheckRules } from "@titan-design/code-graph";
import { registerCodeReadCommands } from "@titan-design/code-read";
import { startDaemon } from "@titan-design/daemon";
import { createRegistry } from "@titan-design/registry";
const registry = createRegistry();
const rules = await loadCheckRules(".codewatch/check.json");
registerCodeReadCommands(registry, {
openStore: () => openCodeGraph(".codewatch/graph.db"),
rules: () => rules,
repoRoot: gitToplevel,
});
await startDaemon({ registry, createContext: () => ({ warnings: [], format: "json" }), version, stateDir, toolPrefix: "codewatch__" });rules must return the same array while the rules are unchanged: the live source compares
arrays by identity and drops every cached model when a different one comes back.
repoRoot is the git toplevel the snapshots were indexed from; leave it out and
finding.get returns no excerpts. Deriving findings adds a rule-evaluation pass to each model
load, about 25 ms on titan-platform's own index.
The daemon then answers POST /rpc/api.describe and the MCP tool codewatch__api__describe
with the same envelope as an in-process invokeCommand. The daemon lists every registered
command as a tool. To follow the design and put only the agent-shaped commands on MCP,
register only the defineCodeReadCommands(source) entries whose names are in
AGENT_COMMANDS on the registry that serves MCP.
Answering without a daemon
createQueryResolver(source) returns (name, args) => JsonEnvelope. It validates raw args
against the contract, runs the query, and never throws. It uses the same codes as the
daemon: DATAERR for bad args, NOINPUT for a missing snapshot, UNAVAILABLE for a command
the source does not serve, and USAGE for an unknown name. The resolver fits
@titan-design/rpc-client's static resolve option. Any ReadSource works, for example
one decoded from a static dataset.
Versioning
CODE_READ_API_VERSION is the contract's semver. contract.lock.json stores every
command's args and result as JSON Schema. src/contract-lock.test.ts fails when the schemas
change and the version does not. Its failure message says whether the change is breaking
and names each breaking path. After bumping, regenerate the lock:
UPDATE_CONTRACT_LOCK=1 pnpm vitest run packages/code-read/src/contract-lock.test.tsThe lock leaves out descriptions, so a documentation edit needs no bump.
