knodin 0.7.6 → 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.
- package/README.md +19 -7
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +2164 -108
- package/dist/bin/launcher.js +25 -3
- package/dist/src/agent-integration.js +304 -0
- package/dist/src/artifact-refresh.js +82 -0
- package/dist/src/cli-args.js +292 -0
- package/dist/src/cli-model.js +384 -0
- package/dist/src/codeflow-replay.js +81 -0
- package/dist/src/compact-structural.js +96 -0
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-cold-mcp.js +40 -0
- package/dist/src/competitive-constraints.js +21 -0
- package/dist/src/competitive-manifest.js +411 -0
- package/dist/src/competitive-measurement.js +183 -0
- package/dist/src/competitive-runner.js +487 -0
- package/dist/src/competitive-sandbox.js +108 -0
- package/dist/src/context-export.js +423 -0
- package/dist/src/context.js +102 -0
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1476 -0
- package/dist/src/docs-sections.js +141 -0
- package/dist/src/doctor.js +382 -0
- package/dist/src/engine/ann-hnsw.js +261 -0
- package/dist/src/engine/embeddings.js +193 -0
- package/dist/src/engine/file-walker.js +49 -0
- package/dist/src/engine/git-history.js +289 -0
- package/dist/src/engine/index.js +14238 -0
- package/dist/src/engine/perf.js +115 -0
- package/dist/src/engine/prune.js +112 -0
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +423 -0
- package/dist/src/engine/source-policy.js +85 -0
- package/dist/src/engine/sqlite.js +71 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/engine/symbol-delete.js +58 -0
- package/dist/src/execution-profile.js +208 -0
- package/dist/src/failure-diagnosis.js +655 -0
- package/dist/src/fleet.js +7 -0
- package/dist/src/git-executable.js +31 -0
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/graph-query-health.js +115 -0
- package/dist/src/hook-manager-integration.js +156 -0
- package/dist/src/index-activity.js +126 -0
- package/dist/src/init-progress-worker.js +106 -2
- package/dist/src/init-progress.js +155 -0
- package/dist/src/init.js +1295 -0
- package/dist/src/lifecycle-health.js +282 -0
- package/dist/src/lsp-readonly.js +217 -0
- package/dist/src/mcp-graph-worker.js +69 -0
- package/dist/src/mcp-reliability.js +154 -0
- package/dist/src/mcp-worker-supervisor.js +350 -0
- package/dist/src/mirror.js +290 -0
- package/dist/src/node-runtime.js +157 -0
- package/dist/src/output-compression.js +630 -0
- package/dist/src/output-telemetry.js +368 -0
- package/dist/src/pr-triage.js +638 -0
- package/dist/src/progressive-evidence.js +477 -0
- package/dist/src/pure-compression-cli.js +102 -0
- package/dist/src/relationship-adapters.js +377 -0
- package/dist/src/release-attestation.js +533 -0
- package/dist/src/release-preflight.js +513 -0
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repair-progress-worker.js +120 -2
- package/dist/src/repair-progress.js +262 -0
- package/dist/src/repository-init-process.js +177 -0
- package/dist/src/repository-management.js +1261 -0
- package/dist/src/response-budget.js +196 -0
- package/dist/src/server.js +217 -0
- package/dist/src/structural-fast-path.js +344 -0
- package/dist/src/structural-snapshot.js +37 -0
- package/dist/src/system-config.js +638 -0
- package/dist/src/terminal-help.js +83 -0
- package/dist/src/tools/knodin-tools.js +1640 -0
- package/dist/src/update-ceremony.js +162 -0
- package/dist/src/update-policy.js +944 -0
- package/dist/src/update-trust.js +504 -0
- package/dist/src/version.js +13 -0
- package/dist/src/visualization.js +515 -0
- package/dist/src/wait-for-fresh.js +98 -0
- package/dist/src/worktree-lifecycle.js +234 -0
- package/docs/BEHAVIORAL-CONTRACT.md +72 -0
- package/docs/CLI.md +20 -1
- package/docs/COMPARISON.md +403 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DIAGNOSTICS.md +80 -0
- package/docs/GIT-HISTORY-REVIEW.md +39 -0
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -18
- package/docs/MCP.md +59 -8
- package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
- package/docs/PT-ACCESS-RECOMMENDATION.md +89 -0
- package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +62 -0
- package/docs/SIGNED-UPDATES.md +151 -0
- package/docs/TELEMETRY.md +46 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/assets/knodin-favicon.svg +4 -0
- package/docs/releases/0.3.0.md +46 -0
- package/docs/releases/0.4.0.md +68 -0
- package/docs/releases/0.4.1.md +28 -0
- package/docs/releases/0.4.2.md +27 -0
- package/docs/releases/0.4.3.md +23 -0
- package/docs/releases/0.5.0.md +29 -0
- package/docs/releases/0.5.1.md +17 -0
- package/docs/releases/0.6.0.md +18 -0
- package/docs/releases/0.7.0.md +24 -0
- package/docs/releases/0.7.1.md +21 -0
- package/docs/releases/0.7.2.md +21 -0
- package/docs/releases/0.7.3.md +23 -0
- package/docs/releases/0.7.4.md +17 -0
- package/docs/releases/0.7.5.md +20 -0
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/package.json +127 -4
- package/roadmap/competitive-roadmap.md +3801 -0
- package/schemas/release-attestation-v1.schema.json +210 -0
- package/schemas/support-bundle-v2.schema.json +212 -0
- package/dist/chunks/chunk-DMQAGX77.js +0 -654
- package/dist/chunks/chunk-F4Z3Z766.js +0 -4
- package/dist/chunks/chunk-SIJAQVSX.js +0 -3
- package/dist/chunks/chunk-X6M4HUUE.js +0 -2
- package/dist/chunks/chunk-YPRMY2LP.js +0 -8
- package/dist/chunks/pure-compression-cli-4TA2TQD5.js +0 -5
- package/dist/chunks/server-7EDF4CBY.js +0 -14
- package/dist/chunks/structural-fast-path-KD5KQSPX.js +0 -4
- package/docs/releases/0.7.6.md +0 -25
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { compareBytes } from "./compare.js";
|
|
6
|
+
import { layoutGraph } from "./graph-layout.js";
|
|
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;
|
|
10
|
+
const MIN_BYTE_BUDGET = 4_096;
|
|
11
|
+
const MAX_BYTE_BUDGET = 2_097_152;
|
|
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
|
+
}
|
|
179
|
+
function html(value) {
|
|
180
|
+
return value
|
|
181
|
+
.replaceAll("&", "&")
|
|
182
|
+
.replaceAll("<", "<")
|
|
183
|
+
.replaceAll(">", ">")
|
|
184
|
+
.replaceAll('"', """);
|
|
185
|
+
}
|
|
186
|
+
function sourceCommit(repo) {
|
|
187
|
+
try {
|
|
188
|
+
return execFileSync("git", ["rev-parse", "HEAD"], {
|
|
189
|
+
cwd: repo,
|
|
190
|
+
encoding: "utf8",
|
|
191
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
192
|
+
}).trim();
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return "unavailable";
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function safeOutput(repo, output) {
|
|
199
|
+
if (!output || path.extname(output).toLowerCase() !== ".html")
|
|
200
|
+
throw new Error("knodin visualize: --output must be a repository-relative .html path");
|
|
201
|
+
const absolute = path.resolve(repo, output);
|
|
202
|
+
if (absolute === repo || !absolute.startsWith(`${repo}${path.sep}`))
|
|
203
|
+
throw new Error("knodin visualize: output path must stay inside the repository");
|
|
204
|
+
const targetStat = fs.lstatSync(absolute, { throwIfNoEntry: false });
|
|
205
|
+
if (targetStat?.isSymbolicLink())
|
|
206
|
+
throw new Error("knodin visualize: output path may not be a symlink");
|
|
207
|
+
if (targetStat) {
|
|
208
|
+
const realTarget = fs.realpathSync(absolute);
|
|
209
|
+
if (!realTarget.startsWith(`${repo}${path.sep}`))
|
|
210
|
+
throw new Error("knodin visualize: output path may not follow a symlink outside the repository");
|
|
211
|
+
}
|
|
212
|
+
let parent = path.dirname(absolute);
|
|
213
|
+
while (!fs.existsSync(parent) && parent !== repo)
|
|
214
|
+
parent = path.dirname(parent);
|
|
215
|
+
const realParent = fs.realpathSync(parent);
|
|
216
|
+
if (realParent !== repo && !realParent.startsWith(`${repo}${path.sep}`))
|
|
217
|
+
throw new Error("knodin visualize: output path may not traverse a symlink outside the repository");
|
|
218
|
+
return { absolute, relative: path.relative(repo, absolute).replaceAll(path.sep, "/") };
|
|
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
|
+
}
|
|
281
|
+
function render(input) {
|
|
282
|
+
const communityRows = input.communities
|
|
283
|
+
.map((community) => {
|
|
284
|
+
const files = (community.files ?? []).slice(0, 12).join(", ") || "no file detail returned";
|
|
285
|
+
const symbols = (community.symbols ?? []).slice(0, 12).join(", ") || "no symbol detail returned";
|
|
286
|
+
return `<details><summary>${html(community.name)} <span>${community.size} symbols</span></summary><p>Cohesion: ${community.cohesion.toFixed(3)}</p><p><strong>Files:</strong> ${html(files)}</p><p><strong>Symbols:</strong> ${html(symbols)}</p></details>`;
|
|
287
|
+
})
|
|
288
|
+
.join("\n");
|
|
289
|
+
const hotspot = (kind, rows) => rows
|
|
290
|
+
.map((node) => {
|
|
291
|
+
const metric = "degree" in node ? `degree ${node.degree}` : `betweenness ${node.betweenness.toFixed(3)}`;
|
|
292
|
+
return `<li><strong>${kind}</strong> ${html(node.symbol)} — ${html(metric)}; ${html(node.filePath || "unresolved file")} (${html(node.communityId ?? "unassigned")})</li>`;
|
|
293
|
+
})
|
|
294
|
+
.join("\n");
|
|
295
|
+
const edgeRows = (edges) => edges
|
|
296
|
+
.map((edge) => `<li data-evidence="${edge.label}"><code>${html(edge.from)}</code> → <code>${html(edge.to)}</code> <span>${html(edge.kind)}</span><br><small>${edge.label === "exact" ? "Source evidence" : "Heuristic source evidence"}: ${html(edge.evidence)}</small></li>`)
|
|
297
|
+
.join("\n") || "<li>No bounded source-evidenced relationships were returned.</li>";
|
|
298
|
+
return `<!doctype html>
|
|
299
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>knodin architecture visualization</title><style>body{font:16px system-ui,sans-serif;margin:2rem auto;max-width:74rem;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}details{border-top:1px solid #e5e9ef;padding:.6rem 0}summary{cursor:pointer;font-weight:650}summary span,small{color:#526170}li{margin:.55rem 0}code{background:#edf1f6;padding:.1rem .3rem;border-radius:.2rem;overflow-wrap:anywhere}[data-evidence=heuristic]{border-left:3px solid #d18a00;padding-left:.55rem}</style></head>
|
|
300
|
+
<body><h1>knodin local architecture visualization</h1><p>Generated locally from the persisted knodin graph. Source commit <code>${html(input.commit)}</code>; Index freshness <code>${html(input.freshness)}</code>; ${input.truncated ? "some lists are bounded by the artifact budget." : "all displayed lists fit the requested artifact budget."}</p>
|
|
301
|
+
<section><h2>Subsystem drill-down</h2>${communityRows || "<p>No communities were returned.</p>"}</section>
|
|
302
|
+
<section><h2>Hub and bridge inspection</h2><ul>${hotspot("Hub", input.hubs)}${hotspot("Bridge", input.bridges)}</ul></section>
|
|
303
|
+
<section><h2>Architecture relationships</h2><ul>${edgeRows(input.architectureEdges)}</ul></section>
|
|
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>
|
|
305
|
+
</body></html>\n`;
|
|
306
|
+
}
|
|
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),
|
|
322
|
+
]);
|
|
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
|
|
394
|
+
.filter((edge) => Boolean(edge.sourceEvidence))
|
|
395
|
+
.slice(0, MAX_CALL_FLOW_EDGES)
|
|
396
|
+
.map((edge) => ({
|
|
397
|
+
from: edge.from,
|
|
398
|
+
to: edge.to,
|
|
399
|
+
kind: edge.kind,
|
|
400
|
+
evidence: edge.sourceEvidence ?? "",
|
|
401
|
+
label: edge.confidenceLabel === "heuristic" || edge.provenance !== "EXTRACTED"
|
|
402
|
+
? "heuristic"
|
|
403
|
+
: "exact",
|
|
404
|
+
}));
|
|
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) => ({
|
|
415
|
+
from: edge.from,
|
|
416
|
+
to: edge.to,
|
|
417
|
+
kind: edge.kind ?? "call",
|
|
418
|
+
evidence: edge.dataFlow?.evidence ??
|
|
419
|
+
`${edge.fromFile ?? "unresolved file"}:${edge.line ?? "unknown line"}`,
|
|
420
|
+
label: edge.dataFlow?.heuristic || edge.provenance === "INFERRED"
|
|
421
|
+
? "heuristic"
|
|
422
|
+
: "exact",
|
|
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 ?? []);
|
|
467
|
+
let communities = map.communities.slice(0, 12);
|
|
468
|
+
let hubs = map.hubs.slice(0, 10);
|
|
469
|
+
let bridges = map.bridges.slice(0, 10);
|
|
470
|
+
let architecture = architectureEdges;
|
|
471
|
+
let callFlow = callFlowEdges;
|
|
472
|
+
let truncated = Boolean(traversal.truncated);
|
|
473
|
+
let artifact = "";
|
|
474
|
+
for (;;) {
|
|
475
|
+
artifact = render({
|
|
476
|
+
commit: sourceCommit(repo),
|
|
477
|
+
freshness: map.staleness ?? "unknown",
|
|
478
|
+
communities,
|
|
479
|
+
hubs,
|
|
480
|
+
bridges,
|
|
481
|
+
architectureEdges: architecture,
|
|
482
|
+
callFlowEdges: callFlow,
|
|
483
|
+
entry,
|
|
484
|
+
depth,
|
|
485
|
+
truncated,
|
|
486
|
+
});
|
|
487
|
+
if (Buffer.byteLength(artifact) <= byteBudget)
|
|
488
|
+
break;
|
|
489
|
+
truncated = true;
|
|
490
|
+
if (architecture.length)
|
|
491
|
+
architecture = architecture.slice(0, -1);
|
|
492
|
+
else if (callFlow.length)
|
|
493
|
+
callFlow = callFlow.slice(0, -1);
|
|
494
|
+
else if (communities.length)
|
|
495
|
+
communities = communities.slice(0, -1);
|
|
496
|
+
else if (hubs.length)
|
|
497
|
+
hubs = hubs.slice(0, -1);
|
|
498
|
+
else if (bridges.length)
|
|
499
|
+
bridges = bridges.slice(0, -1);
|
|
500
|
+
else
|
|
501
|
+
throw new Error("knodin visualize: budget is too small for visualization metadata");
|
|
502
|
+
}
|
|
503
|
+
fs.mkdirSync(path.dirname(output.absolute), { recursive: true });
|
|
504
|
+
fs.writeFileSync(output.absolute, artifact);
|
|
505
|
+
return {
|
|
506
|
+
outputPath: output.relative,
|
|
507
|
+
bytes: Buffer.byteLength(artifact),
|
|
508
|
+
sha256: crypto.createHash("sha256").update(artifact).digest("hex"),
|
|
509
|
+
byteBudget,
|
|
510
|
+
truncated,
|
|
511
|
+
indexFreshness: map.staleness ?? "unknown",
|
|
512
|
+
scope: "call-flow",
|
|
513
|
+
callFlow: { entry, depth, edgeCount: callFlow.length, truncated: Boolean(traversal.truncated) },
|
|
514
|
+
};
|
|
515
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import childProcess from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { attachLifecycleHealth, inspectLifecycleHealth } from "./lifecycle-health.js";
|
|
5
|
+
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
6
|
+
const PROCESSOR_RETRY_MS = 500;
|
|
7
|
+
function installedProcessorIsRunning(repo) {
|
|
8
|
+
try {
|
|
9
|
+
const pid = Number(fs.readFileSync(path.join(repo, ".knodin", "hooks", "refresh.lock", "pid"), "utf-8"));
|
|
10
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
11
|
+
return false;
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function launchInstalledLifecycleProcessor(repo) {
|
|
20
|
+
const processor = path.join(repo, ".knodin", "hooks", "background-index.sh");
|
|
21
|
+
if (fs.statSync(processor).mode & 0o111) {
|
|
22
|
+
const child = childProcess.spawn(processor, [], {
|
|
23
|
+
cwd: repo,
|
|
24
|
+
stdio: "ignore",
|
|
25
|
+
detached: process.platform !== "win32",
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
});
|
|
28
|
+
child.once("error", () => undefined);
|
|
29
|
+
child.unref();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function requiresStructuralRepair(graph) {
|
|
33
|
+
if (Object.values(graph.orphaned).some((count) => count > 0))
|
|
34
|
+
return true;
|
|
35
|
+
const ordinary = [
|
|
36
|
+
"indexed file no longer exists: ",
|
|
37
|
+
"indexed file is no longer eligible: ",
|
|
38
|
+
"indexed snapshot differs from disk: ",
|
|
39
|
+
];
|
|
40
|
+
return graph.missing.records.some((record) => !ordinary.some((prefix) => record.startsWith(prefix)));
|
|
41
|
+
}
|
|
42
|
+
function launchQueuedProcessorIfNeeded(repo, elapsed, timeoutMs, lastAttemptAt, processQueuedEvents) {
|
|
43
|
+
if (elapsed >= timeoutMs ||
|
|
44
|
+
inspectLifecycleHealth(repo).queuedEvents === 0 ||
|
|
45
|
+
installedProcessorIsRunning(repo) ||
|
|
46
|
+
elapsed - lastAttemptAt < PROCESSOR_RETRY_MS)
|
|
47
|
+
return lastAttemptAt;
|
|
48
|
+
try {
|
|
49
|
+
processQueuedEvents(repo);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Lifecycle health retains the queued event and wait remains truthful.
|
|
53
|
+
}
|
|
54
|
+
return elapsed;
|
|
55
|
+
}
|
|
56
|
+
function completedStatus(graph) {
|
|
57
|
+
if (graph.status === "healthy" && graph.freshness.state === "fresh")
|
|
58
|
+
return "fresh";
|
|
59
|
+
if (graph.status === "repair-needed" && requiresStructuralRepair(graph))
|
|
60
|
+
return "repair-needed";
|
|
61
|
+
if (graph.freshness.state === "unknown")
|
|
62
|
+
return "unknown";
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
async function reconcileOrdinaryDrift(engine, repo, graph) {
|
|
66
|
+
const shouldReconcile = (graph.status === "stale" || graph.status === "repair-needed") &&
|
|
67
|
+
graph.freshness.state !== "queued";
|
|
68
|
+
if (!shouldReconcile)
|
|
69
|
+
return graph;
|
|
70
|
+
await engine.query("stats", "", repo);
|
|
71
|
+
return attachLifecycleHealth(repo, await engine.status(repo, { audit: "deep" }));
|
|
72
|
+
}
|
|
73
|
+
function completedResult(status, startedAt, polls, graph) {
|
|
74
|
+
return { status, waitedMs: Date.now() - startedAt, polls, graph };
|
|
75
|
+
}
|
|
76
|
+
/** Wait for queued work and reconcile ordinary drift; structural repair stays explicit. */
|
|
77
|
+
export async function waitForFresh(engine, repo, timeoutMs = 30_000, processQueuedEvents = launchInstalledLifecycleProcessor) {
|
|
78
|
+
const startedAt = Date.now();
|
|
79
|
+
let polls = 0;
|
|
80
|
+
let lastProcessorAttemptAt = Number.NEGATIVE_INFINITY;
|
|
81
|
+
for (;;) {
|
|
82
|
+
polls++;
|
|
83
|
+
const beforeStatusElapsed = Date.now() - startedAt;
|
|
84
|
+
lastProcessorAttemptAt = launchQueuedProcessorIfNeeded(repo, beforeStatusElapsed, timeoutMs, lastProcessorAttemptAt, processQueuedEvents);
|
|
85
|
+
let graph = attachLifecycleHealth(repo, await engine.status(repo, { audit: "cached" }));
|
|
86
|
+
let status = completedStatus(graph);
|
|
87
|
+
if (status)
|
|
88
|
+
return completedResult(status, startedAt, polls, graph);
|
|
89
|
+
graph = await reconcileOrdinaryDrift(engine, repo, graph);
|
|
90
|
+
status = completedStatus(graph);
|
|
91
|
+
if (status)
|
|
92
|
+
return completedResult(status, startedAt, polls, graph);
|
|
93
|
+
const elapsed = Date.now() - startedAt;
|
|
94
|
+
if (elapsed >= timeoutMs)
|
|
95
|
+
return { status: "timeout", waitedMs: elapsed, polls, graph };
|
|
96
|
+
await delay(Math.min(100, timeoutMs - elapsed));
|
|
97
|
+
}
|
|
98
|
+
}
|