@titan-design/code-read 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henry Jewkes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # @titan-design/code-read
2
+
3
+ A versioned read API over `@titan-design/code-graph` snapshots, for three kinds of consumer:
4
+ a drill-down report UI (live or from a static export), agents over MCP, and workflows. The
5
+ package holds one contract, one per-snapshot `ReadModel`, and one pure query function per
6
+ command. The daemon's commands and a browser's static dataset both answer through those
7
+ functions.
8
+
9
+ Tier 2 of the titan-platform DAG (TP-184). Depends on `code-graph`, `registry`, and
10
+ `rpc-protocol`; `zod` is a peer.
11
+
12
+ ## Two entry points
13
+
14
+ | Import | Runs in | Holds |
15
+ | --- | --- | --- |
16
+ | `@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` |
17
+ | `@titan-design/code-read` | Node | everything in `./query`, plus `loadReadModel`, `createLiveSource` (SQLite plus an LRU of models), and `registerCodeReadCommands` |
18
+
19
+ `./query` imports only its own files, `zod`, and `rpc-protocol`. Three rules in
20
+ `.codewatch/check.json` (`code-read-query-*`) and `src/browser-safe.test.ts` enforce that.
21
+ The test bundles the subpath with esbuild for `platform: "browser"` and expects no warnings.
22
+
23
+ ## Commands (contract 0.1.2)
24
+
25
+ | Command | Args | Result |
26
+ | --- | --- | --- |
27
+ | `api.describe` | none | `api`, `dataset`, `commands`, `newest`, `indexVersions`, `capabilities`, `metrics` (catalogue descriptors with provenance), `rules` |
28
+ | `snapshot.list` | `ref?`, `limit` (1 to 500, default 50) | `snapshots`, newest first |
29
+ | `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` |
30
+ | `node.get` | `snapshot?`, `id`, `baseline?`, `metrics` (default: every one that applies) | `node`, `ancestors` (repo first), `childCounts`, `metrics` (value, `direction`, `rollup`, `percentile`, `siblingMedian`, `siblingRank`, `siblingCount`, `baseline?`, `delta?`, `missing?`) |
31
+ | `node.resolve` | exactly one of `query` or `path`, plus `line?` with `path`, `limit` (1 to 50, default 10) | `candidates`: `node`, `score`, `match` |
32
+ | `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?` |
33
+ | `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) |
34
+ | `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 |
35
+
36
+ Arguments are snake_case and results are camelCase. `snapshot` and `baseline` take an id, a
37
+ digit string, or a ref name (that ref's newest snapshot). The rest of the design's 14
38
+ commands arrive in later minor versions of the contract.
39
+
40
+ ## The hierarchy
41
+
42
+ code-graph stores files and symbols only, so `./query` synthesizes the rest per snapshot:
43
+
44
+ - **Ids.** The repo is `""`. A directory is its path plus `/`, such as `src/util/`; no file id
45
+ ends in `/`. Module and external nodes are not in the hierarchy.
46
+ - **Class level.** A symbol named `Job.run` hangs under `Job` when that symbol exists, else
47
+ under the nearest existing scope. A bare name (snapshots before code-graph 0.14.0) hangs
48
+ under the smallest symbol whose span encloses it, else under its file.
49
+ - **Rollups.** A directory's value, or a file's value for a symbol-only metric, combines the
50
+ stored values below it by the catalogue's `rollup` rule: `sum`, `max`, or `mean`. A file
51
+ metric rolls up from files only, so nothing counts twice. `absent: "zero"` counts a
52
+ missing row as 0; `absent: "exclude"` leaves it out.
53
+ - **Why a value is null.** `missing` says why: `no-rollup` (the rule is `none`, as for commit
54
+ and author counts, bus factor, and fan-in; directory-level history is TP-233),
55
+ `not-measured`, `not-applicable` (such as `loc` on a symbol), or `not-in-snapshot`.
56
+ - **Roles.** `exclude_roles` drops files of those roles from the rows, the rollups, and the
57
+ baseline alike.
58
+ - **Deltas.** Baseline nodes match by id only; alias following is TP-187. `comparable` is
59
+ false when the two snapshots have different index versions.
60
+ - **Size.** `hierarchy.get` returns at most `HIERARCHY_ROW_CAP` (5,000) rows breadth-first
61
+ and sets `truncated`. `childCount` always counts every child, symbols included.
62
+
63
+ `node.get` percentiles are the share of same-kind nodes whose value is at most the node's,
64
+ 0 to 100. Siblings are the same-kind children of the same parent, the node included; rank 1
65
+ is the largest value. Neither number knows which way is worse. Read `direction` on the same
66
+ metric before labelling anything "top" or "best".
67
+
68
+ Worked example. A file with `loc: 3` among sibling files with 0, 1, 2, and 2 lines gets
69
+ `siblingRank: 1`, and 7 of the snapshot's 10 files have at most 3 lines, so `percentile: 70`.
70
+ `loc` is `direction: "higher-worse"`, so rank 1 is the file with the most lines. It is the
71
+ worst offender in its directory, not the best file. For `bus_factor_30d`
72
+ (`direction: "lower-worse"`), rank 1 is the file with the most authors covering its churn,
73
+ which is the safest file. For a `neutral` metric such as `churn_30d`, rank 1 only means
74
+ "largest".
75
+
76
+ `node.resolve` ports codewatch's `rankSearch` cascade over directories, files, and symbols,
77
+ case-insensitive: exact id or name 100, id suffix after `/` or `#` 80, name prefix 60, id
78
+ substring 40, name substring 30, ties by id. One addition for qualified names: a symbol whose
79
+ last segment equals the query, such as `run` for `Task.run`, scores as a suffix. A `path`
80
+ with a `line`, or a `query` of the form `path:line`, maps each matching file to the innermost
81
+ symbol whose span holds the line.
82
+
83
+ ## Findings
84
+
85
+ Until the findings store exists (design gap G11), every finding is a check-rule violation
86
+ **derived on read**. The live source runs the product's rules through code-graph's own
87
+ `snapshotViolations` when it loads a snapshot, so `findings.list` returns exactly what
88
+ `graph check` reports. Each row says `provenance: { kind: "derived", source: "check/<rule>" }`
89
+ and `tool: "check"`. Stored findings, verdicts, and themes will arrive behind the same
90
+ `Finding` schema.
91
+
92
+ - **Ids.** A finding's id is code-graph's `violationKey`: `rule|node` or
93
+ `rule|node|destination`. Treat it as opaque. It stays the same across snapshots while
94
+ the rule id, the node id, and the destination are unchanged. A moved file gets a new id
95
+ until rename-aware keys land (TP-187).
96
+ - **Order.** `sort` picks the primary key and `order` flips only that key. Ties always break
97
+ the same way: severity (error, warning, info, then unknown), then larger `excess`, then
98
+ node path, rule, and id. The id is unique, so the order is total and `offset` pages never
99
+ repeat or skip a row. Rows with no `excess` or `value` sort last in either direction.
100
+ - **Excess.** `value / threshold` for a maximum rule and `threshold / value` for a minimum
101
+ rule, so larger is always worse. Import rules have no threshold, so `excess` is null.
102
+ - **Facets.** With `facets: true`, counts per `rule`, `severity`, `tool`, `provenance`,
103
+ `kind`, and `child`, plus `status` with a baseline. They count every row that passed the
104
+ filters, so each facet sums to `total`. `child` is the scope's child (a directory id or a
105
+ file) that holds the finding, which is what a drill-down view shows next. `limit: 0` with
106
+ `facets: true` gives headline counts in one call.
107
+ - **Scope.** A directory scope holds everything under it, a file scope holds the file and
108
+ its symbols, and the repo (`""`, the default) holds everything.
109
+ - **Status.** With `baseline`, each row is `new`, `carryover`, `worsened`, or `improved`
110
+ (by `excess`), and findings that no longer occur come back from the baseline as
111
+ `resolved`, with the baseline's `snapshotId`. A `status` filter without a baseline is
112
+ DATAERR.
113
+ - **Excerpts.** `finding.get` shows the flagged lines plus `context_lines` either side,
114
+ clipped to the file and capped at `EXCERPT_LINE_CAP` (80) lines with `truncated: true`.
115
+ Edges carry no line numbers, so an import finding's flagged line is the one that names
116
+ the import's specifier in quotes; the live source finds it when it loads the snapshot. A
117
+ metric finding covers its whole file: no `range`, no highlight, and the excerpt starts at
118
+ line 1. The live source reads the working tree under `repoRoot` and serves a file only
119
+ when its hash equals the snapshot's fingerprint. Otherwise `excerpt` is null and
120
+ `excerptMissing` says `changed-since-snapshot`. A static source serves the windows it
121
+ exported and says `not-in-export` for the rest.
122
+ - **Neighbours.** `node.neighbors` needs a stored node: a file, symbol, module, or
123
+ external. A synthesized directory is DATAERR. Each side is ranked by edge `weight`
124
+ (code-graph's reference count), heaviest first, then by neighbour id and edge kind, and
125
+ paged on its own. Empty `edge_kinds` means every kind except `references` for a
126
+ non-symbol node, because `references` edges are the symbol layer.
127
+
128
+ **Trap: a derived finding is not a record.** It exists only while its rule, at its
129
+ current threshold, still fires. Change a rule's threshold, rename a rule, or remove it,
130
+ and its findings vanish from every snapshot, old ones included, and their ids never come
131
+ back. A finding's `status` also depends on the baseline you pass: the same row is
132
+ `carryover` against one snapshot and `new` against another. Do not persist a derived id
133
+ as if it were durable until the findings store lands.
134
+
135
+ ## Serving the commands
136
+
137
+ ```ts
138
+ import { openCodeGraph, loadCheckRules } from "@titan-design/code-graph";
139
+ import { registerCodeReadCommands } from "@titan-design/code-read";
140
+ import { startDaemon } from "@titan-design/daemon";
141
+ import { createRegistry } from "@titan-design/registry";
142
+
143
+ const registry = createRegistry();
144
+ const rules = await loadCheckRules(".codewatch/check.json");
145
+ registerCodeReadCommands(registry, {
146
+ openStore: () => openCodeGraph(".codewatch/graph.db"),
147
+ rules: () => rules,
148
+ repoRoot: gitToplevel,
149
+ });
150
+ await startDaemon({ registry, createContext: () => ({ warnings: [], format: "json" }), version, stateDir, toolPrefix: "codewatch__" });
151
+ ```
152
+
153
+ `rules` must return the same array while the rules are unchanged: the live source compares
154
+ arrays by identity and drops every cached model when a different one comes back.
155
+ `repoRoot` is the git toplevel the snapshots were indexed from; leave it out and
156
+ `finding.get` returns no excerpts. Deriving findings adds a rule-evaluation pass to each model
157
+ load, about 25 ms on titan-platform's own index.
158
+
159
+ The daemon then answers `POST /rpc/api.describe` and the MCP tool `codewatch__api__describe`
160
+ with the same envelope as an in-process `invokeCommand`. The daemon lists every registered
161
+ command as a tool. To follow the design and put only the agent-shaped commands on MCP,
162
+ register only the `defineCodeReadCommands(source)` entries whose names are in
163
+ `AGENT_COMMANDS` on the registry that serves MCP.
164
+
165
+ ## Answering without a daemon
166
+
167
+ `createQueryResolver(source)` returns `(name, args) => JsonEnvelope`. It validates raw args
168
+ against the contract, runs the query, and never throws. It uses the same codes as the
169
+ daemon: `DATAERR` for bad args, `NOINPUT` for a missing snapshot, `UNAVAILABLE` for a command
170
+ the source does not serve, and `USAGE` for an unknown name. The resolver fits
171
+ `@titan-design/rpc-client`'s static `resolve` option. Any `ReadSource` works, for example
172
+ one decoded from a static dataset.
173
+
174
+ ## Versioning
175
+
176
+ `CODE_READ_API_VERSION` is the contract's semver. `contract.lock.json` stores every
177
+ command's args and result as JSON Schema. `src/contract-lock.test.ts` fails when the schemas
178
+ change and the version does not. Its failure message says whether the change is breaking
179
+ and names each breaking path. After bumping, regenerate the lock:
180
+
181
+ ```sh
182
+ UPDATE_CONTRACT_LOCK=1 pnpm vitest run packages/code-read/src/contract-lock.test.ts
183
+ ```
184
+
185
+ The lock leaves out descriptions, so a documentation edit needs no bump.