knodin 0.7.5 → 0.8.2

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.
Files changed (75) hide show
  1. package/README.md +18 -3
  2. package/benchmarks/competitors/SYNTHESIS.md +66 -0
  3. package/dist/bin/cli.js +371 -66
  4. package/dist/bin/launcher.js +16 -1
  5. package/dist/src/agent-integration.js +82 -16
  6. package/dist/src/artifact-refresh.js +2 -1
  7. package/dist/src/cli-args.js +19 -1
  8. package/dist/src/cli-model.js +28 -2
  9. package/dist/src/codeflow-replay.js +2 -1
  10. package/dist/src/compare.js +39 -0
  11. package/dist/src/competitive-constraints.js +2 -1
  12. package/dist/src/competitive-runner.js +4 -4
  13. package/dist/src/context-export.js +3 -2
  14. package/dist/src/context.js +1 -1
  15. package/dist/src/deterministic-random.js +34 -0
  16. package/dist/src/diagnostics-write-helper.js +473 -0
  17. package/dist/src/diagnostics.js +1160 -133
  18. package/dist/src/doctor.js +3 -1
  19. package/dist/src/engine/ann-hnsw.js +2 -12
  20. package/dist/src/engine/file-walker.js +8 -2
  21. package/dist/src/engine/git-history.js +12 -12
  22. package/dist/src/engine/index.js +1174 -313
  23. package/dist/src/engine/sarif-import.js +341 -0
  24. package/dist/src/engine/scip-import.js +28 -13
  25. package/dist/src/engine/source-policy.js +16 -0
  26. package/dist/src/engine/state-paths.js +175 -0
  27. package/dist/src/execution-profile.js +15 -10
  28. package/dist/src/failure-diagnosis.js +7 -1
  29. package/dist/src/graph-layout.js +173 -0
  30. package/dist/src/index-activity.js +2 -1
  31. package/dist/src/init.js +86 -45
  32. package/dist/src/lifecycle-health.js +41 -9
  33. package/dist/src/mcp-graph-worker.js +69 -0
  34. package/dist/src/mcp-reliability.js +154 -0
  35. package/dist/src/mcp-worker-supervisor.js +350 -0
  36. package/dist/src/mirror.js +290 -0
  37. package/dist/src/node-runtime.js +157 -0
  38. package/dist/src/output-compression.js +2 -1
  39. package/dist/src/output-telemetry.js +16 -11
  40. package/dist/src/progressive-evidence.js +30 -26
  41. package/dist/src/pure-compression-cli.js +4 -3
  42. package/dist/src/relationship-adapters.js +15 -8
  43. package/dist/src/release-preflight.js +13 -10
  44. package/dist/src/repair-lease.js +85 -0
  45. package/dist/src/repository-init-process.js +13 -9
  46. package/dist/src/repository-management.js +34 -4
  47. package/dist/src/response-budget.js +8 -6
  48. package/dist/src/server.js +80 -35
  49. package/dist/src/structural-fast-path.js +16 -10
  50. package/dist/src/structural-snapshot.js +6 -2
  51. package/dist/src/system-config.js +25 -2
  52. package/dist/src/tools/knodin-tools.js +142 -31
  53. package/dist/src/update-ceremony.js +9 -5
  54. package/dist/src/update-trust.js +5 -4
  55. package/dist/src/visualization.js +372 -19
  56. package/dist/src/worktree-lifecycle.js +5 -2
  57. package/docs/BEHAVIORAL-CONTRACT.md +72 -0
  58. package/docs/CLI.md +20 -1
  59. package/docs/COMPARISON.md +403 -0
  60. package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
  61. package/docs/DIAGNOSTICS.md +46 -11
  62. package/docs/HANDOFF.md +180 -0
  63. package/docs/INSTALLATION.md +21 -2
  64. package/docs/MCP.md +59 -8
  65. package/docs/PT-ACCESS-RECOMMENDATION.md +5 -7
  66. package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
  67. package/docs/SCIP-IMPORT.md +5 -0
  68. package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
  69. package/docs/releases/0.5.1.md +4 -4
  70. package/docs/releases/0.8.0.md +74 -0
  71. package/docs/releases/0.8.2.md +34 -0
  72. package/package.json +17 -4
  73. package/roadmap/competitive-roadmap.md +3801 -0
  74. package/schemas/release-attestation-v1.schema.json +1 -1
  75. package/schemas/support-bundle-v2.schema.json +212 -0
@@ -2,9 +2,180 @@ import { execFileSync } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { compareBytes } from "./compare.js";
6
+ import { layoutGraph } from "./graph-layout.js";
5
7
  const DEFAULT_BYTE_BUDGET = 32_768;
8
+ /** Repo scope draws coordinates for many nodes, so it needs a larger default. */
9
+ const DEFAULT_REPO_BYTE_BUDGET = 524_288;
6
10
  const MIN_BYTE_BUDGET = 4_096;
11
+ const MAX_BYTE_BUDGET = 2_097_152;
7
12
  const MAX_CALL_FLOW_EDGES = 20;
13
+ /** Repo-scope caps. Exceeding either truncates and is reported honestly. */
14
+ const MAX_REPO_NODES = 400;
15
+ const MAX_REPO_EDGES = 900;
16
+ /** Communities requested from `map()` for repo scope; 12 is the call-flow bound. */
17
+ const REPO_COMMUNITY_TOP_N = 60;
18
+ /** Palette for community fills. Fixed order keeps colour assignment stable. */
19
+ const COMMUNITY_COLORS = [
20
+ "#4c78d8",
21
+ "#f58518",
22
+ "#e45756",
23
+ "#72b7b2",
24
+ "#54a24b",
25
+ "#eeca3b",
26
+ "#b279a2",
27
+ "#ff9da6",
28
+ "#9d755d",
29
+ "#bab0ac",
30
+ ];
31
+ function round2(value) {
32
+ return Math.round(value * 100) / 100;
33
+ }
34
+ function shortLabel(id, granularity) {
35
+ if (granularity === "symbol")
36
+ return id;
37
+ const segments = id.split("/");
38
+ return segments.length <= 2 ? id : segments.slice(-2).join("/");
39
+ }
40
+ /**
41
+ * Builds the repo-scope graph from `map()` output.
42
+ *
43
+ * Returns pre-cap totals alongside the capped node and edge lists so the
44
+ * caller can report what was withheld rather than presenting a sample as if it
45
+ * were the whole graph.
46
+ */
47
+ function buildRepoGraph(map, graph, granularity) {
48
+ // Rank so that truncation keeps exact evidence before heuristics.
49
+ const ranked = [...mergeEdges(graph).values()].sort(compareArtifactEdges);
50
+ const edges = ranked.slice(0, MAX_REPO_EDGES);
51
+ const nodes = buildRepoNodes(endpointsOf(edges), granularity, {
52
+ degreeOf: indexDegrees(map, graph, granularity),
53
+ communityOf: indexCommunities(map),
54
+ });
55
+ // Keep the highest-degree nodes, then drop edges that lost an endpoint.
56
+ const kept = nodes.slice(0, MAX_REPO_NODES);
57
+ const keptIds = new Set(kept.map((node) => node.id));
58
+ return {
59
+ nodes: kept,
60
+ edges: edges.filter((edge) => keptIds.has(edge.from) && keptIds.has(edge.to)),
61
+ nodeTotal: endpointsOf(ranked).size,
62
+ // Distinct relationships before the node/edge caps. Duplicate records
63
+ // that merged into one drawn edge are not "withheld", so they must not
64
+ // inflate the denominator and make an untruncated graph look truncated.
65
+ edgeTotal: ranked.length,
66
+ };
67
+ }
68
+ /** Symbol/file -> community name, from whichever membership map() populated. */
69
+ function indexCommunities(map) {
70
+ const communityOf = new Map();
71
+ for (const community of map.communities) {
72
+ for (const file of community.files ?? [])
73
+ communityOf.set(file, community.name);
74
+ for (const symbol of community.symbols ?? [])
75
+ communityOf.set(symbol, community.name);
76
+ }
77
+ return communityOf;
78
+ }
79
+ /**
80
+ * Node id -> reference degree. Symbol granularity keeps map()'s symbol-scoped
81
+ * hubs; file granularity uses the per-file degree the dependency graph already
82
+ * computed.
83
+ */
84
+ function indexDegrees(map, graph, granularity) {
85
+ const degreeOf = new Map();
86
+ if (granularity === "symbol")
87
+ for (const hub of map.hubs)
88
+ degreeOf.set(hub.symbol, hub.degree);
89
+ else
90
+ for (const [file, degree] of Object.entries(graph.degrees))
91
+ degreeOf.set(file, degree);
92
+ return degreeOf;
93
+ }
94
+ /**
95
+ * Collapses the dependency graph's records into the edges the artifact draws:
96
+ * one per distinct (from, to, kind), keyed so that duplicates merge instead of
97
+ * being drawn on top of each other.
98
+ */
99
+ function mergeEdges(graph) {
100
+ const merged = new Map();
101
+ for (const edge of graph.edges) {
102
+ // Endpoints are used exactly as stored under both granularities. A file is
103
+ // a valid node in the symbol view too, and inventing a symbol for one would
104
+ // be a fabrication — so there is nothing to resolve. This was a function
105
+ // whose two branches both returned their argument, which read as though a
106
+ // mapping happened here.
107
+ const from = edge.fromFile;
108
+ const to = edge.toFile;
109
+ if (!from || !to || from === to)
110
+ continue;
111
+ // JSON encoding rather than a delimiter. Both endpoints are file paths,
112
+ // which may contain any byte except NUL and `/`, so no printable separator
113
+ // is collision-proof — and reaching for NUL is how this file acquired two
114
+ // raw 0x00 bytes, which blinds grep across the whole file while still
115
+ // reporting success. Encoding the tuple sidesteps the choice entirely.
116
+ const key = JSON.stringify([from, to, edge.kind]);
117
+ const existing = merged.get(key);
118
+ // Exact only when a narrow source declaration backs the edge at full
119
+ // confidence; anything weaker must read as heuristic.
120
+ const label = edge.sourceEvidence && edge.confidence >= 1 ? "exact" : "heuristic";
121
+ if (existing) {
122
+ // A merged pair is only "exact" when every contributing edge was.
123
+ if (label === "heuristic")
124
+ existing.label = "heuristic";
125
+ continue;
126
+ }
127
+ merged.set(key, {
128
+ from,
129
+ to,
130
+ kind: edge.kind,
131
+ evidence: edge.sourceEvidence ?? "no narrow source declaration recorded",
132
+ label,
133
+ });
134
+ }
135
+ return merged;
136
+ }
137
+ /** Distinct node ids touched by `edges`, in first-appearance order. */
138
+ function endpointsOf(edges) {
139
+ const ids = new Set();
140
+ for (const edge of edges) {
141
+ ids.add(edge.from);
142
+ ids.add(edge.to);
143
+ }
144
+ return ids;
145
+ }
146
+ /**
147
+ * Materializes drawable nodes for `ids`, ordered by the degree that decides
148
+ * which of them survive the node cap.
149
+ */
150
+ function buildRepoNodes(ids, granularity, index) {
151
+ return [...ids]
152
+ .map((id) => ({
153
+ id,
154
+ label: shortLabel(id, granularity),
155
+ weight: index.degreeOf.get(id) ?? 1,
156
+ community: index.communityOf.get(id),
157
+ }))
158
+ .sort(compareRepoNodes);
159
+ }
160
+ /**
161
+ * Byte order, not localeCompare: these sorts decide which edges and nodes
162
+ * survive the caps, so collation differing by host locale or ICU build would
163
+ * change the artifact's contents, not merely its ordering — and this file
164
+ * promises byte-identical output from identical evidence.
165
+ */
166
+ function compareArtifactEdges(left, right) {
167
+ if (left.label !== right.label)
168
+ return left.label === "exact" ? -1 : 1;
169
+ if (left.from !== right.from)
170
+ return compareBytes(left.from, right.from);
171
+ return compareBytes(left.to, right.to);
172
+ }
173
+ /** Highest degree first; byte order breaks ties, for the reason above. */
174
+ function compareRepoNodes(left, right) {
175
+ return left.weight === right.weight
176
+ ? compareBytes(left.id, right.id)
177
+ : right.weight - left.weight;
178
+ }
8
179
  function html(value) {
9
180
  return value
10
181
  .replaceAll("&", "&amp;")
@@ -46,6 +217,67 @@ function safeOutput(repo, output) {
46
217
  throw new Error("knodin visualize: output path may not traverse a symlink outside the repository");
47
218
  return { absolute, relative: path.relative(repo, absolute).replaceAll(path.sep, "/") };
48
219
  }
220
+ /**
221
+ * Renders the repo-scope artifact: an inline SVG with coordinates computed at
222
+ * generation time, plus a community legend and honest totals. No script tags
223
+ * and no external references, so the file works offline and unchanged evidence
224
+ * reproduces identical bytes.
225
+ */
226
+ function renderRepo(input) {
227
+ const { positions, viewport } = layoutGraph(input.nodes.map((node) => ({ id: node.id, weight: node.weight })), input.edges.map((edge) => ({ from: edge.from, to: edge.to })), input.commit);
228
+ const at = new Map(positions.map((position) => [position.id, position]));
229
+ // Colour by community, in first-appearance order for stable assignment.
230
+ const colorOf = new Map();
231
+ for (const node of input.nodes) {
232
+ if (!node.community || colorOf.has(node.community))
233
+ continue;
234
+ colorOf.set(node.community, COMMUNITY_COLORS[colorOf.size % COMMUNITY_COLORS.length]);
235
+ }
236
+ const unassigned = "#9aa5b1";
237
+ const maxWeight = Math.max(1, ...input.nodes.map((node) => node.weight));
238
+ const edgeSvg = input.edges
239
+ .map((edge) => {
240
+ const from = at.get(edge.from);
241
+ const to = at.get(edge.to);
242
+ if (!from || !to)
243
+ return "";
244
+ // Dashed strokes mark heuristic or inferred evidence; solid means the
245
+ // edge is backed by a narrow source declaration.
246
+ const dash = edge.label === "heuristic" ? ' stroke-dasharray="4 3"' : "";
247
+ const title = html(`${edge.from} → ${edge.to} (${edge.kind}, ${edge.label})`);
248
+ return `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="#c3ccd8" stroke-width="1"${dash}><title>${title}</title></line>`;
249
+ })
250
+ .join("");
251
+ const nodeSvg = input.nodes
252
+ .map((node) => {
253
+ const position = at.get(node.id);
254
+ if (!position)
255
+ return "";
256
+ const radius = 3 + 9 * Math.sqrt(node.weight / maxWeight);
257
+ const fill = node.community ? (colorOf.get(node.community) ?? unassigned) : unassigned;
258
+ const community = node.community ? ` — ${node.community}` : " — unassigned";
259
+ const title = `${node.label}${community} (degree ${node.weight})`;
260
+ return `<circle cx="${position.x}" cy="${position.y}" r="${round2(radius)}" fill="${fill}" fill-opacity="0.85" stroke="#ffffff" stroke-width="1"><title>${html(title)}</title></circle>`;
261
+ })
262
+ .join("");
263
+ const legendRows = input.communities
264
+ .map((community) => {
265
+ const swatch = colorOf.get(community.name) ?? unassigned;
266
+ return `<li><span class="sw" style="background:${swatch}"></span>${html(community.name)} <span class="meta">${community.size} symbols, cohesion ${community.cohesion.toFixed(3)}</span></li>`;
267
+ })
268
+ .join("");
269
+ const shown = (count, total, noun) => count === total
270
+ ? `all ${total} ${noun}`
271
+ : `${count} of ${total} ${noun} (${total - count} withheld by the artifact budget)`;
272
+ return `<!doctype html>
273
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>knodin repository graph</title><style>body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:78rem;color:#172033;background:#f6f8fb;line-height:1.45}section{background:#fff;border:1px solid #d9dee7;border-radius:.55rem;padding:1rem 1.25rem;margin:1rem 0}svg{width:100%;height:auto;background:#fdfefe;border-radius:.4rem}ul{list-style:none;padding:0;columns:2;column-gap:2rem}li{margin:.35rem 0;break-inside:avoid}.sw{display:inline-block;width:.75rem;height:.75rem;border-radius:.2rem;margin-right:.5rem;vertical-align:baseline}.meta{color:#526170;font-size:.85em}code{background:#edf1f6;padding:.1rem .3rem;border-radius:.2rem}.key{color:#526170;font-size:.9em}</style></head>
274
+ <body><h1>knodin repository graph</h1><p>Generated locally from the persisted knodin graph; no hosted service was contacted. Source commit <code>${html(input.commit)}</code>; index freshness <code>${html(input.freshness)}</code>; granularity <code>${html(input.granularity)}</code>.</p>
275
+ <section><h2>Architecture</h2><svg viewBox="0 0 ${viewport} ${viewport}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Repository dependency graph">${edgeSvg}${nodeSvg}</svg>
276
+ <p class="key">Solid edges carry a narrow source declaration. Dashed edges are heuristic or inferred and are not proof of a relationship. Node size follows reference degree.</p></section>
277
+ <section><h2>Coverage</h2><p>Showing ${shown(input.nodes.length, input.nodeTotal, input.granularity === "file" ? "files" : "symbols")}, ${shown(input.edges.length, input.edgeTotal, "relationships")}, and ${shown(input.communities.length, input.communityTotal, "communities")}.</p></section>
278
+ <section><h2>Communities</h2><ul>${legendRows || "<li>No communities were returned.</li>"}</ul></section>
279
+ </body></html>\n`;
280
+ }
49
281
  function render(input) {
50
282
  const communityRows = input.communities
51
283
  .map((community) => {
@@ -72,25 +304,93 @@ function render(input) {
72
304
  <section><h2>Call-flow navigation</h2><p>Static downstream traversal from <code>${html(input.entry)}</code>, depth ${input.depth}; this is not a runtime trace.</p><ul>${edgeRows(input.callFlowEdges)}</ul></section>
73
305
  </body></html>\n`;
74
306
  }
75
- export async function writeVisualization(engine, repoPath, request) {
76
- const repo = fs.realpathSync(repoPath);
77
- const output = safeOutput(repo, request.outputPath);
78
- const entry = request.entry.trim();
79
- if (!entry)
80
- throw new Error("knodin visualize: an entry selector is required");
81
- const depth = request.depth ?? 3;
82
- if (!Number.isInteger(depth) || depth < 1 || depth > 6)
83
- throw new Error("knodin visualize: --depth must be an integer from 1 through 6");
84
- const byteBudget = request.byteBudget ?? DEFAULT_BYTE_BUDGET;
85
- if (!Number.isInteger(byteBudget) || byteBudget < MIN_BYTE_BUDGET || byteBudget > 65_536)
86
- throw new Error("knodin visualize: --max-bytes must be an integer from 4096 through 65536");
87
- const [map, traversal] = await Promise.all([
88
- engine.map(repo, "standard", { topN: 12, sort: "name" }),
89
- engine.query("traverse", entry, repo, undefined, MAX_CALL_FLOW_EDGES, depth, "standard", request.selector, undefined, { direction: "downstream", relationKinds: ["call"], includeDataFlow: true }),
307
+ /**
308
+ * Repo-scope artifact. Renders the whole architecture graph rather than a
309
+ * traversal from one entry, so it consults `map()` alone.
310
+ *
311
+ * Budget pressure sheds edges first, then nodes, then communities — losing
312
+ * relationships degrades the drawing least, and the coverage section keeps
313
+ * reporting the pre-truncation totals throughout.
314
+ */
315
+ async function writeRepoVisualization(engine, repo, output, options) {
316
+ // map() supplies community membership and naming; dependencyGraph() supplies
317
+ // the complete edge set. Using map()'s ranked edges here would draw a sample
318
+ // while the coverage line implied it was the whole graph.
319
+ const [map, dependencies] = await Promise.all([
320
+ engine.map(repo, "standard", { topN: REPO_COMMUNITY_TOP_N, sort: "size" }),
321
+ engine.dependencyGraph(repo),
90
322
  ]);
91
- if (traversal.ambiguity || traversal.count === 0)
92
- throw new Error("knodin visualize: entry selector did not resolve to a traversable symbol");
93
- const architectureEdges = map.edges
323
+ const graph = buildRepoGraph(map, dependencies, options.granularity);
324
+ const communityTotal = map.communityCount ?? map.communities.length;
325
+ const commit = sourceCommit(repo);
326
+ const freshness = map.staleness ?? "unknown";
327
+ let nodes = graph.nodes;
328
+ let edges = graph.edges;
329
+ let communities = map.communities.map((community) => ({
330
+ name: community.name,
331
+ size: community.size,
332
+ cohesion: community.cohesion,
333
+ }));
334
+ let truncated = graph.nodes.length < graph.nodeTotal ||
335
+ graph.edges.length < graph.edgeTotal ||
336
+ communities.length < communityTotal;
337
+ let artifact = "";
338
+ for (;;) {
339
+ artifact = renderRepo({
340
+ commit,
341
+ freshness,
342
+ granularity: options.granularity,
343
+ nodes,
344
+ edges,
345
+ communities,
346
+ nodeTotal: graph.nodeTotal,
347
+ edgeTotal: graph.edgeTotal,
348
+ communityTotal,
349
+ truncated,
350
+ });
351
+ if (Buffer.byteLength(artifact) <= options.byteBudget)
352
+ break;
353
+ truncated = true;
354
+ if (edges.length)
355
+ edges = edges.slice(0, -1);
356
+ else if (nodes.length)
357
+ nodes = nodes.slice(0, -1);
358
+ else if (communities.length)
359
+ communities = communities.slice(0, -1);
360
+ else
361
+ throw new Error("knodin visualize: budget is too small for visualization metadata");
362
+ }
363
+ fs.mkdirSync(path.dirname(output.absolute), { recursive: true });
364
+ fs.writeFileSync(output.absolute, artifact);
365
+ return {
366
+ outputPath: output.relative,
367
+ bytes: Buffer.byteLength(artifact),
368
+ sha256: crypto.createHash("sha256").update(artifact).digest("hex"),
369
+ byteBudget: options.byteBudget,
370
+ truncated,
371
+ indexFreshness: freshness,
372
+ scope: "repo",
373
+ callFlow: { entry: "", depth: 0, edgeCount: 0, truncated: false },
374
+ repo: {
375
+ granularity: options.granularity,
376
+ nodeCount: nodes.length,
377
+ nodeTotal: graph.nodeTotal,
378
+ edgeCount: edges.length,
379
+ edgeTotal: graph.edgeTotal,
380
+ communityCount: communities.length,
381
+ communityTotal,
382
+ },
383
+ };
384
+ }
385
+ /**
386
+ * Bounded architecture relationships from `map()`.
387
+ *
388
+ * Only edges carrying source evidence are drawn, and an edge is "exact" only
389
+ * when it was extracted at full confidence — anything the engine labelled
390
+ * heuristic, or merely inferred, must read as heuristic here too.
391
+ */
392
+ function toArchitectureEdges(edges) {
393
+ return edges
94
394
  .filter((edge) => Boolean(edge.sourceEvidence))
95
395
  .slice(0, MAX_CALL_FLOW_EDGES)
96
396
  .map((edge) => ({
@@ -102,7 +402,16 @@ export async function writeVisualization(engine, repoPath, request) {
102
402
  ? "heuristic"
103
403
  : "exact",
104
404
  }));
105
- const callFlowEdges = (traversal.edges ?? []).slice(0, MAX_CALL_FLOW_EDGES).map((edge) => ({
405
+ }
406
+ /**
407
+ * Bounded call-flow relationships from the downstream traversal.
408
+ *
409
+ * Traversal edges carry their evidence on the data-flow record when there is
410
+ * one; otherwise the file and line stand in, naming what is missing rather than
411
+ * rendering a blank.
412
+ */
413
+ function toCallFlowEdges(edges) {
414
+ return edges.slice(0, MAX_CALL_FLOW_EDGES).map((edge) => ({
106
415
  from: edge.from,
107
416
  to: edge.to,
108
417
  kind: edge.kind ?? "call",
@@ -112,6 +421,49 @@ export async function writeVisualization(engine, repoPath, request) {
112
421
  ? "heuristic"
113
422
  : "exact",
114
423
  }));
424
+ }
425
+ /**
426
+ * Applies defaults and rejects an unusable request before any work is done.
427
+ *
428
+ * Every ceiling and enum names the flag that sets it, because these surface on
429
+ * the CLI and an error the operator cannot act on is just a failure.
430
+ */
431
+ function validateRequest(request) {
432
+ const scope = request.scope ?? "call-flow";
433
+ if (scope !== "call-flow" && scope !== "repo")
434
+ throw new Error("knodin visualize: --scope must be call-flow or repo");
435
+ const granularity = request.granularity ?? "file";
436
+ if (granularity !== "file" && granularity !== "symbol")
437
+ throw new Error("knodin visualize: --granularity must be file or symbol");
438
+ const entry = (request.entry ?? "").trim();
439
+ // Repo scope draws the whole graph, so it has no entry to resolve.
440
+ if (scope === "call-flow" && !entry)
441
+ throw new Error("knodin visualize: an entry selector is required");
442
+ const depth = request.depth ?? 3;
443
+ if (!Number.isInteger(depth) || depth < 1 || depth > 6)
444
+ throw new Error("knodin visualize: --depth must be an integer from 1 through 6");
445
+ const byteBudget = request.byteBudget ?? (scope === "repo" ? DEFAULT_REPO_BYTE_BUDGET : DEFAULT_BYTE_BUDGET);
446
+ if (!Number.isInteger(byteBudget) || byteBudget < MIN_BYTE_BUDGET || byteBudget > MAX_BYTE_BUDGET)
447
+ throw new Error(`knodin visualize: --max-bytes must be an integer from ${MIN_BYTE_BUDGET} through ${MAX_BYTE_BUDGET}`);
448
+ return { scope, granularity, entry, depth, byteBudget };
449
+ }
450
+ export async function writeVisualization(engine, repoPath, request) {
451
+ const repo = fs.realpathSync(repoPath);
452
+ const output = safeOutput(repo, request.outputPath);
453
+ const { scope, granularity, entry, depth, byteBudget } = validateRequest(request);
454
+ if (scope === "repo")
455
+ return writeRepoVisualization(engine, repo, output, {
456
+ granularity,
457
+ byteBudget,
458
+ });
459
+ const [map, traversal] = await Promise.all([
460
+ engine.map(repo, "standard", { topN: 12, sort: "name" }),
461
+ engine.query("traverse", entry, repo, undefined, MAX_CALL_FLOW_EDGES, depth, "standard", request.selector, undefined, { direction: "downstream", relationKinds: ["call"], includeDataFlow: true }),
462
+ ]);
463
+ if (traversal.ambiguity || traversal.count === 0)
464
+ throw new Error("knodin visualize: entry selector did not resolve to a traversable symbol");
465
+ const architectureEdges = toArchitectureEdges(map.edges);
466
+ const callFlowEdges = toCallFlowEdges(traversal.edges ?? []);
115
467
  let communities = map.communities.slice(0, 12);
116
468
  let hubs = map.hubs.slice(0, 10);
117
469
  let bridges = map.bridges.slice(0, 10);
@@ -157,6 +509,7 @@ export async function writeVisualization(engine, repoPath, request) {
157
509
  byteBudget,
158
510
  truncated,
159
511
  indexFreshness: map.staleness ?? "unknown",
512
+ scope: "call-flow",
160
513
  callFlow: { entry, depth, edgeCount: callFlow.length, truncated: Boolean(traversal.truncated) },
161
514
  };
162
515
  }
@@ -2,6 +2,7 @@ import childProcess from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { compareBytes } from "./compare.js";
5
6
  function git(repo, args) {
6
7
  return childProcess.execFileSync("git", args, {
7
8
  cwd: repo,
@@ -112,7 +113,8 @@ export function registerInitializedWorktree(repoPath) {
112
113
  lastSeenAt: now,
113
114
  });
114
115
  }
115
- registry.entries.sort((left, right) => left.path.localeCompare(right.path));
116
+ // Byte order: the registry is written to disk in this order.
117
+ registry.entries.sort((left, right) => compareBytes(left.path, right.path));
116
118
  writeRegistry(repo, registry);
117
119
  }
118
120
  export async function inspectWorktrees(repoPath, status) {
@@ -207,7 +209,8 @@ export function reconcileWorktrees(repoPath) {
207
209
  removed.push(entry.path);
208
210
  }
209
211
  }
210
- registry.entries.sort((left, right) => left.path.localeCompare(right.path));
212
+ // Byte order: the registry is written to disk in this order.
213
+ registry.entries.sort((left, right) => compareBytes(left.path, right.path));
211
214
  writeRegistry(repo, registry);
212
215
  return { schemaVersion: 1, added, restored, removed };
213
216
  }
@@ -0,0 +1,72 @@
1
+ # Behavioral contract
2
+
3
+ Knodin's intended product contract is the composition of seven behaviors behind the
4
+ single local gateway: truthful budgets, explicit freshness and availability,
5
+ source evidence and provenance, ambiguity-safe stable identity, recoverable
6
+ handles, bounded diagnosis confidence, and privacy/locality. The authoritative
7
+ operation-to-clause declaration matrix is
8
+ [`contracts/behavior-contract-v1.json`](../contracts/behavior-contract-v1.json),
9
+ and `npm run verify:c95` replays adversarial evidence before release. The gate
10
+ dispatches all 22 operations and every schema-declared query/action
11
+ discriminator against an offline linked-worktree fixture. Positive,
12
+ action-specific response semantics fail closed when a required signal is absent.
13
+
14
+ ## Product answers, with checked evidence
15
+
16
+ - **Value:** less manual context assembly and fewer missed dependencies. The
17
+ paired task method and its bounded results are checked in under
18
+ [`benchmarks/evaluations/c93-engineering-outcomes/`](../benchmarks/evaluations/c93-engineering-outcomes/).
19
+ - **ROI:** fewer corrective agent round trips without hosted-service, account,
20
+ authentication, or source-egress overhead. C93 reports measured local task
21
+ outcomes and explicitly leaves billed cost and token counts unavailable when
22
+ the harness cannot observe them.
23
+ - **Tomorrow:** run `knodin init`, inspect `knodin status`, then use the existing
24
+ checkout through the CLI or the single `knodin.knodin` MCP gateway. The
25
+ installation and client readiness evidence remains bounded by C91 below.
26
+ - **Secret sauce:** stable identity, source provenance, graph freshness, and
27
+ truthful response budgets remain composed across applicable operations. The
28
+ C95 matrix and representative replay fail closed if an operation declaration,
29
+ mandatory clause, or representative fixture is omitted.
30
+
31
+ ## Composition invariants
32
+
33
+ A continuation or retained artifact may expand a bounded response only while
34
+ preserving its stable identity and evidence provenance. Stale, unavailable, or
35
+ ambiguous graph evidence must remain explicit and cannot be promoted to a
36
+ confident failure diagnosis, impact claim, or runtime-causality proof.
37
+
38
+ The C95 fixtures run in a linked non-default worktree. A child harness installs
39
+ guards before importing the production dispatcher and observes global `fetch`,
40
+ HTTP/HTTPS request and get, TCP/TLS connection, callback and promise DNS
41
+ resolution, JavaScript-visible native-addon loading, and production-owned child
42
+ process argv, inherited or explicit environment, synchronous input, and streamed
43
+ stdin boundaries. The release gate derives
44
+ privacy and source-egress results from those observations: network attempts,
45
+ credential forwarding, source bytes at network/subprocess boundaries, and
46
+ native-addon loads must remain zero. Local subprocess execution is counted and
47
+ permitted only when those observed payload checks remain clean.
48
+
49
+ ## Known bounds
50
+
51
+ This replay proves the checked operation/action fixtures, not all repositories,
52
+ languages, frameworks, dynamic runtime behavior, clients, or deployment
53
+ environments. Static relationships and failure correlations are candidates,
54
+ not runtime-causality proof. Budgets can truncate results; indexes can be stale
55
+ or unavailable; names can be ambiguous; generated code, dynamic dispatch,
56
+ framework wiring, and language coverage can leave relationships unresolved.
57
+
58
+ C91 remains parked pending native client, runtime-manager, Homebrew, and
59
+ Artifactory certification evidence. C94 remains blocked on C91 and therefore
60
+ does not establish a certified five-minute client demonstration. No fixture,
61
+ benchmark, or contract replay supports a universal superiority claim. The
62
+ authoritative roadmap remains the complete source for limitations and status.
63
+
64
+ A new production operation or schema-declared action must add an applicability
65
+ row, positive action semantics, and isolated adversarial mutation before
66
+ release; omission causes `verify:c95` and release preflight to fail. Every mapped
67
+ fixture/clause row is independently redispatched through the real production
68
+ handler, mutated at the guarded pre-serialization boundary, and rejected by the
69
+ same oracle. Network construction is denied before body streaming. JavaScript
70
+ cannot observe opaque operating-system activity performed entirely inside an
71
+ already-loaded native library; these remain explicit bounds rather than claims
72
+ of universal egress detection.
package/docs/CLI.md CHANGED
@@ -32,7 +32,7 @@ example repository existence, mutually exclusive configuration modes, and
32
32
  graph-query target rules), but syntax cannot reach a handler unless the shared
33
33
  declarative model accepts it first.
34
34
 
35
- For support evidence, `knodin diagnostics enable|status|collect|inspect|clear|disable`
35
+ For support evidence, `knodin diagnostics enable|status|preview|archive|inspect|clear|disable`
36
36
  manages an explicit local failure journal and redacted gzip JSON bundles.
37
37
  Collection does not upload data. See `docs/DIAGNOSTICS.md` for retention,
38
38
  redaction, inspection, and known bounds.
@@ -54,6 +54,25 @@ centrality, churn, co-change, and coupling evidence. Git history has explicit
54
54
  commit/file/time bounds and truthful unavailable or truncation states; see
55
55
  `docs/GIT-HISTORY-REVIEW.md`.
56
56
 
57
+ `knodin visualize` writes a self-contained local HTML artifact and takes two
58
+ scopes. The default, `--scope call-flow <entry>`, draws a bounded static
59
+ downstream traversal from one entry symbol; `--depth` accepts 1 through 6.
60
+ `--scope repo` needs no entry and draws the repository's dependency graph as
61
+ inline SVG, with `--granularity file` (default) or `symbol`.
62
+
63
+ Repo scope reads every recorded dependency rather than the ranked summary
64
+ `knodin map` returns, so its coverage section measures against the whole graph.
65
+ Node and edge caps and the `--max-bytes` budget (4096 through 2097152; repo
66
+ scope defaults to 524288) can still withhold detail, and the artifact always
67
+ states how much: "showing 200 of 1,847 relationships". Solid edges carry a
68
+ narrow source declaration at full confidence; dashed edges are heuristic or
69
+ inferred and are not proof of a relationship.
70
+
71
+ Coordinates are computed when the artifact is written, from a PRNG seeded by
72
+ the source commit, so the file contains no script and no network reference and
73
+ identical evidence reproduces byte-identical output. That makes the artifact
74
+ safe to open offline and its SHA-256 meaningful.
75
+
57
76
  `knodin pack grep` evaluates user patterns with exact-pinned RE2 WebAssembly,
58
77
  not Node's backtracking regular-expression engine. Backreferences and lookaround
59
78
  are rejected because they cannot retain RE2's linear-time guarantee. Route