knodin 0.7.6 → 0.8.3
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 +41 -12
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +2181 -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 +142 -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 +15094 -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 +70 -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/progress-worker-runtime.js +46 -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 +83 -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/resource-reachability.js +456 -0
- package/dist/src/response-budget.js +200 -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 +1645 -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 +114 -0
- package/docs/CLI.md +30 -1
- package/docs/COMPARISON.md +413 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DEMO.md +49 -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 +64 -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/docs/releases/0.8.3.md +47 -0
- package/package.json +139 -4
- package/roadmap/competitive-roadmap.md +3896 -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,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic force-directed layout.
|
|
3
|
+
*
|
|
4
|
+
* Coordinates are computed here, at artifact-generation time, rather than by a
|
|
5
|
+
* script in the rendered page. That keeps a generated artifact reproducible:
|
|
6
|
+
* the same graph over the same evidence lays out identically, so its SHA-256 is
|
|
7
|
+
* stable and the output is snapshot-testable. It also means the artifact needs
|
|
8
|
+
* no JavaScript and no external library, which keeps it self-contained.
|
|
9
|
+
*
|
|
10
|
+
* The simulation is a bounded Fruchterman-Reingold: repulsion between every
|
|
11
|
+
* pair, attraction along every edge, and a linearly decaying temperature that
|
|
12
|
+
* caps per-iteration displacement. Iteration count is fixed rather than
|
|
13
|
+
* convergence-driven so that runtime is a function of graph size alone.
|
|
14
|
+
*/
|
|
15
|
+
import { mulberry32, seedFromString } from "./deterministic-random.js";
|
|
16
|
+
/** Square viewport side, in user units, that coordinates are fitted into. */
|
|
17
|
+
const VIEWPORT = 1000;
|
|
18
|
+
/** Fixed iteration count. Bounded work; not a convergence guarantee. */
|
|
19
|
+
const ITERATIONS = 300;
|
|
20
|
+
/** Fraction of the viewport a node may move on the first iteration. */
|
|
21
|
+
const INITIAL_TEMPERATURE = 0.1;
|
|
22
|
+
/** Minimum separation used when two nodes land on the same point. */
|
|
23
|
+
const EPSILON = 0.01;
|
|
24
|
+
/**
|
|
25
|
+
* Lays out `nodes` connected by `edges` inside a square viewport.
|
|
26
|
+
*
|
|
27
|
+
* `seed` should be derived from repository-scoped evidence (a commit id, say)
|
|
28
|
+
* so that unchanged evidence reproduces an identical layout. Edges naming a
|
|
29
|
+
* node that is not in `nodes` are ignored rather than throwing: callers cap
|
|
30
|
+
* node and edge counts independently, and a dangling edge is a truncation
|
|
31
|
+
* artifact, not a caller error.
|
|
32
|
+
*/
|
|
33
|
+
export function layoutGraph(nodes, edges, seed) {
|
|
34
|
+
if (nodes.length === 0)
|
|
35
|
+
return { positions: [], viewport: VIEWPORT };
|
|
36
|
+
if (nodes.length === 1) {
|
|
37
|
+
return {
|
|
38
|
+
positions: [{ id: nodes[0].id, x: VIEWPORT / 2, y: VIEWPORT / 2 }],
|
|
39
|
+
viewport: VIEWPORT,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const random = mulberry32(seedFromString(seed));
|
|
43
|
+
const index = new Map();
|
|
44
|
+
for (let position = 0; position < nodes.length; position++)
|
|
45
|
+
index.set(nodes[position].id, position);
|
|
46
|
+
// Seeded initial placement. A deterministic ring with a jittered radius
|
|
47
|
+
// avoids the degenerate all-collinear start that a pure grid produces.
|
|
48
|
+
const xs = new Float64Array(nodes.length);
|
|
49
|
+
const ys = new Float64Array(nodes.length);
|
|
50
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
51
|
+
const angle = (2 * Math.PI * i) / nodes.length;
|
|
52
|
+
const radius = (VIEWPORT / 2) * (0.35 + 0.6 * random());
|
|
53
|
+
xs[i] = VIEWPORT / 2 + radius * Math.cos(angle);
|
|
54
|
+
ys[i] = VIEWPORT / 2 + radius * Math.sin(angle);
|
|
55
|
+
}
|
|
56
|
+
// Resolve edges to index pairs once; drop any endpoint we do not hold.
|
|
57
|
+
const links = [];
|
|
58
|
+
for (const edge of edges) {
|
|
59
|
+
const from = index.get(edge.from);
|
|
60
|
+
const to = index.get(edge.to);
|
|
61
|
+
if (from === undefined || to === undefined || from === to)
|
|
62
|
+
continue;
|
|
63
|
+
links.push([from, to]);
|
|
64
|
+
}
|
|
65
|
+
// Ideal edge length: spread nodes evenly over the viewport area.
|
|
66
|
+
const k = Math.sqrt((VIEWPORT * VIEWPORT) / nodes.length);
|
|
67
|
+
const dx = new Float64Array(nodes.length);
|
|
68
|
+
const dy = new Float64Array(nodes.length);
|
|
69
|
+
const state = { count: nodes.length, xs, ys, dx, dy };
|
|
70
|
+
for (let iteration = 0; iteration < ITERATIONS; iteration++) {
|
|
71
|
+
dx.fill(0);
|
|
72
|
+
dy.fill(0);
|
|
73
|
+
accumulateRepulsion(state, k);
|
|
74
|
+
accumulateAttraction(state, links, k);
|
|
75
|
+
// Linear cooling caps displacement and lets the layout settle.
|
|
76
|
+
applyDisplacement(state, VIEWPORT * INITIAL_TEMPERATURE * (1 - iteration / ITERATIONS));
|
|
77
|
+
}
|
|
78
|
+
return { positions: fitToViewport(nodes, xs, ys), viewport: VIEWPORT };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Repulsion between every pair. O(n^2): callers cap node count, and the cap is
|
|
82
|
+
* small enough that a Barnes-Hut tree would cost more than it saves.
|
|
83
|
+
*/
|
|
84
|
+
function accumulateRepulsion({ count, xs, ys, dx, dy }, k) {
|
|
85
|
+
for (let i = 0; i < count; i++) {
|
|
86
|
+
for (let j = i + 1; j < count; j++) {
|
|
87
|
+
let deltaX = xs[i] - xs[j];
|
|
88
|
+
let deltaY = ys[i] - ys[j];
|
|
89
|
+
let distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
90
|
+
if (distance < EPSILON) {
|
|
91
|
+
// Coincident nodes: nudge along a deterministic direction so the
|
|
92
|
+
// pair separates instead of dividing by ~zero forever.
|
|
93
|
+
deltaX = EPSILON;
|
|
94
|
+
deltaY = 0;
|
|
95
|
+
distance = EPSILON;
|
|
96
|
+
}
|
|
97
|
+
const force = (k * k) / distance;
|
|
98
|
+
const unitX = (deltaX / distance) * force;
|
|
99
|
+
const unitY = (deltaY / distance) * force;
|
|
100
|
+
dx[i] += unitX;
|
|
101
|
+
dy[i] += unitY;
|
|
102
|
+
dx[j] -= unitX;
|
|
103
|
+
dy[j] -= unitY;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Attraction along every resolved edge, pulling its endpoints together. */
|
|
108
|
+
function accumulateAttraction({ xs, ys, dx, dy }, links, k) {
|
|
109
|
+
for (const [from, to] of links) {
|
|
110
|
+
const deltaX = xs[from] - xs[to];
|
|
111
|
+
const deltaY = ys[from] - ys[to];
|
|
112
|
+
const distance = Math.max(Math.sqrt(deltaX * deltaX + deltaY * deltaY), EPSILON);
|
|
113
|
+
const force = (distance * distance) / k;
|
|
114
|
+
const unitX = (deltaX / distance) * force;
|
|
115
|
+
const unitY = (deltaY / distance) * force;
|
|
116
|
+
dx[from] -= unitX;
|
|
117
|
+
dy[from] -= unitY;
|
|
118
|
+
dx[to] += unitX;
|
|
119
|
+
dy[to] += unitY;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Moves every node along its accumulated displacement, capped at `temperature`
|
|
124
|
+
* so late iterations refine rather than reshuffle.
|
|
125
|
+
*/
|
|
126
|
+
function applyDisplacement({ count, xs, ys, dx, dy }, temperature) {
|
|
127
|
+
for (let i = 0; i < count; i++) {
|
|
128
|
+
const magnitude = Math.max(Math.sqrt(dx[i] * dx[i] + dy[i] * dy[i]), EPSILON);
|
|
129
|
+
const step = Math.min(magnitude, temperature);
|
|
130
|
+
xs[i] += (dx[i] / magnitude) * step;
|
|
131
|
+
ys[i] += (dy[i] / magnitude) * step;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Rescales raw simulation output into the viewport with a uniform margin,
|
|
136
|
+
* preserving aspect ratio so the drawing is not distorted. Coordinates are
|
|
137
|
+
* rounded to two decimals: enough precision to draw, few enough digits to keep
|
|
138
|
+
* the artifact small and its bytes stable.
|
|
139
|
+
*/
|
|
140
|
+
function fitToViewport(nodes, xs, ys) {
|
|
141
|
+
const margin = VIEWPORT * 0.04;
|
|
142
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
143
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
144
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
145
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
146
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
147
|
+
if (xs[i] < minX)
|
|
148
|
+
minX = xs[i];
|
|
149
|
+
if (xs[i] > maxX)
|
|
150
|
+
maxX = xs[i];
|
|
151
|
+
if (ys[i] < minY)
|
|
152
|
+
minY = ys[i];
|
|
153
|
+
if (ys[i] > maxY)
|
|
154
|
+
maxY = ys[i];
|
|
155
|
+
}
|
|
156
|
+
const spanX = maxX - minX;
|
|
157
|
+
const spanY = maxY - minY;
|
|
158
|
+
const usable = VIEWPORT - 2 * margin;
|
|
159
|
+
// One shared scale keeps the aspect ratio; a zero span means every node
|
|
160
|
+
// collapsed onto one axis, so that axis simply centres.
|
|
161
|
+
const scale = Math.min(spanX > EPSILON ? usable / spanX : Number.POSITIVE_INFINITY, spanY > EPSILON ? usable / spanY : Number.POSITIVE_INFINITY);
|
|
162
|
+
const effective = Number.isFinite(scale) ? scale : 1;
|
|
163
|
+
const offsetX = margin + (usable - spanX * effective) / 2;
|
|
164
|
+
const offsetY = margin + (usable - spanY * effective) / 2;
|
|
165
|
+
return nodes.map((node, i) => ({
|
|
166
|
+
id: node.id,
|
|
167
|
+
x: round2((xs[i] - minX) * effective + offsetX),
|
|
168
|
+
y: round2((ys[i] - minY) * effective + offsetY),
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
function round2(value) {
|
|
172
|
+
return Math.round(value * 100) / 100;
|
|
173
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
function hasNonReconciliableDamage(graph) {
|
|
2
|
+
if (Object.values(graph.orphaned).some((count) => count > 0))
|
|
3
|
+
return true;
|
|
4
|
+
const lifecycleIssues = new Set(graph.lifecycle?.issues ?? []);
|
|
5
|
+
const reconcilablePrefixes = [
|
|
6
|
+
"indexed file no longer exists: ",
|
|
7
|
+
"indexed file is no longer eligible: ",
|
|
8
|
+
"indexed snapshot differs from disk: ",
|
|
9
|
+
];
|
|
10
|
+
return graph.missing.records.some((record) => !lifecycleIssues.has(record) &&
|
|
11
|
+
!reconcilablePrefixes.some((prefix) => record.startsWith(prefix)));
|
|
12
|
+
}
|
|
13
|
+
function unavailableState(graph) {
|
|
14
|
+
if (graph.freshness?.state === "queued")
|
|
15
|
+
return "queued";
|
|
16
|
+
if (graph.freshness?.state === "indexing")
|
|
17
|
+
return "indexing";
|
|
18
|
+
if (graph.status === "indexing")
|
|
19
|
+
return "indexing";
|
|
20
|
+
if (graph.schemaVersion === 0)
|
|
21
|
+
return graph.coverage.sourceFiles === 0 ? "empty-repository" : "not-initialized";
|
|
22
|
+
if (graph.coverage.sourceFiles === 0)
|
|
23
|
+
return "empty-repository";
|
|
24
|
+
if (graph.coverage.indexedFiles === 0)
|
|
25
|
+
return "empty-index";
|
|
26
|
+
if (graph.status === "repair-needed" && hasNonReconciliableDamage(graph))
|
|
27
|
+
return "repair-needed";
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export async function inspectGraphQueryHealth(repo, status) {
|
|
31
|
+
try {
|
|
32
|
+
const graph = await status(repo);
|
|
33
|
+
const state = unavailableState(graph);
|
|
34
|
+
if (!state)
|
|
35
|
+
return {
|
|
36
|
+
available: true,
|
|
37
|
+
state: graph.lifecycle && graph.lifecycle.status !== "healthy"
|
|
38
|
+
? "lifecycle-degraded"
|
|
39
|
+
: "healthy",
|
|
40
|
+
graph,
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
available: false,
|
|
44
|
+
status: "unavailable",
|
|
45
|
+
state,
|
|
46
|
+
graph,
|
|
47
|
+
remediation: graph.repairSteps,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
available: false,
|
|
53
|
+
status: "unavailable",
|
|
54
|
+
state: "unknown",
|
|
55
|
+
remediation: ["Run `knodin doctor` and `knodin status --deep`."],
|
|
56
|
+
error: error instanceof Error ? error.message : String(error),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function hasNoMatches(value) {
|
|
61
|
+
if (Array.isArray(value.results))
|
|
62
|
+
return value.results.length === 0;
|
|
63
|
+
if (typeof value.count === "number")
|
|
64
|
+
return value.count === 0;
|
|
65
|
+
if (typeof value.changedFileCount === "number")
|
|
66
|
+
return value.changedFileCount === 0;
|
|
67
|
+
if (typeof value.summary === "string" && /\bnot found\b/i.test(value.summary))
|
|
68
|
+
return true;
|
|
69
|
+
if (typeof value.refused === "string" &&
|
|
70
|
+
/\b(?:not found|no edit sites|no matches)\b/i.test(value.refused))
|
|
71
|
+
return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/** Add an explicit healthy/no-match outcome without replacing operation data. */
|
|
75
|
+
export function decorateGraphQueryResult(value, graphState = "healthy", freshness) {
|
|
76
|
+
if (value === null || value === undefined) {
|
|
77
|
+
return {
|
|
78
|
+
status: "no-match",
|
|
79
|
+
result: value ?? null,
|
|
80
|
+
...(freshness ? { freshness } : {}),
|
|
81
|
+
availability: { state: "no-match", graphState, outcome: "no-match" },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(value)) {
|
|
85
|
+
return {
|
|
86
|
+
status: value.length === 0 ? "no-match" : "ok",
|
|
87
|
+
results: value,
|
|
88
|
+
...(freshness ? { freshness } : {}),
|
|
89
|
+
availability: {
|
|
90
|
+
state: value.length === 0 ? "no-match" : graphState,
|
|
91
|
+
graphState,
|
|
92
|
+
outcome: value.length === 0 ? "no-match" : "matches",
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (typeof value !== "object") {
|
|
97
|
+
return {
|
|
98
|
+
result: value,
|
|
99
|
+
...(freshness ? { freshness } : {}),
|
|
100
|
+
availability: { state: graphState, outcome: "complete" },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const root = value;
|
|
104
|
+
const noMatch = hasNoMatches(root) && root.ambiguity === undefined;
|
|
105
|
+
return {
|
|
106
|
+
...root,
|
|
107
|
+
...(freshness ? { freshness } : {}),
|
|
108
|
+
...(noMatch && root.status === undefined ? { status: "no-match" } : {}),
|
|
109
|
+
availability: {
|
|
110
|
+
state: noMatch ? "no-match" : graphState,
|
|
111
|
+
graphState,
|
|
112
|
+
outcome: noMatch ? "no-match" : "matches",
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isMap, parseDocument } from "yaml";
|
|
4
|
+
export const KNODIN_LEFTHOOK_COMMAND = "knodin-refresh";
|
|
5
|
+
const MANAGED_COMMENT = "Managed locally by knodin init; repository hook configuration is not modified.";
|
|
6
|
+
const LEFTHOOK_CONFIGS = [
|
|
7
|
+
{ main: "lefthook.yml", local: "lefthook-local.yml" },
|
|
8
|
+
{ main: "lefthook.yaml", local: "lefthook-local.yaml" },
|
|
9
|
+
{ main: ".lefthook.yml", local: ".lefthook-local.yml" },
|
|
10
|
+
{ main: ".lefthook.yaml", local: ".lefthook-local.yaml" },
|
|
11
|
+
{ main: ".config/lefthook.yml", local: ".config/lefthook-local.yml" },
|
|
12
|
+
{ main: ".config/lefthook.yaml", local: ".config/lefthook-local.yaml" },
|
|
13
|
+
];
|
|
14
|
+
const HOOK_COMMANDS = {
|
|
15
|
+
"post-commit": { run: ".knodin/hooks/manager-dispatch.sh post-commit" },
|
|
16
|
+
"post-checkout": {
|
|
17
|
+
run: ".knodin/hooks/manager-dispatch.sh post-checkout {1} {2} {3}",
|
|
18
|
+
},
|
|
19
|
+
"post-merge": { run: ".knodin/hooks/manager-dispatch.sh post-merge {1}" },
|
|
20
|
+
"post-rewrite": {
|
|
21
|
+
run: ".knodin/hooks/manager-dispatch.sh post-rewrite {1}",
|
|
22
|
+
use_stdin: true,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export const LEFTHOOK_DISPATCHER = `#!/bin/sh
|
|
26
|
+
# knodin manager-native lifecycle dispatcher. Generated by knodin init.
|
|
27
|
+
set -u
|
|
28
|
+
[ "\${KNODIN_WRAPPER_ACTIVE:-0}" = "1" ] && exit 0
|
|
29
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
30
|
+
[ -n "$REPO_ROOT" ] || exit 0
|
|
31
|
+
BACKGROUND="$REPO_ROOT/.knodin/hooks/background-index.sh"
|
|
32
|
+
[ -x "$BACKGROUND" ] || exit 0
|
|
33
|
+
HOOK="\${1:-}"
|
|
34
|
+
shift || true
|
|
35
|
+
case "$HOOK" in
|
|
36
|
+
post-commit)
|
|
37
|
+
set -- commit
|
|
38
|
+
;;
|
|
39
|
+
post-checkout)
|
|
40
|
+
[ "$#" -ge 2 ] || exit 0
|
|
41
|
+
set -- checkout "$1" "$2"
|
|
42
|
+
;;
|
|
43
|
+
post-merge)
|
|
44
|
+
BEFORE="$(git rev-parse ORIG_HEAD 2>/dev/null || true)"
|
|
45
|
+
AFTER="$(git rev-parse HEAD 2>/dev/null || true)"
|
|
46
|
+
[ -n "$BEFORE" ] && [ -n "$AFTER" ] || exit 0
|
|
47
|
+
set -- merge "$BEFORE" "$AFTER"
|
|
48
|
+
;;
|
|
49
|
+
post-rewrite)
|
|
50
|
+
INPUT="$(mktemp "$REPO_ROOT/.knodin/hooks/rewrite-input.XXXXXX")" || exit 0
|
|
51
|
+
cat > "$INPUT"
|
|
52
|
+
set -- rewrite "$INPUT"
|
|
53
|
+
;;
|
|
54
|
+
*) exit 0 ;;
|
|
55
|
+
esac
|
|
56
|
+
nohup "$BACKGROUND" "$@" >/dev/null 2>&1 &
|
|
57
|
+
exit 0
|
|
58
|
+
`;
|
|
59
|
+
function lefthookFiles(repo) {
|
|
60
|
+
for (const candidate of LEFTHOOK_CONFIGS) {
|
|
61
|
+
if (fs.existsSync(path.join(repo, candidate.main)))
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function assertRegularOrMissing(target) {
|
|
67
|
+
try {
|
|
68
|
+
const stat = fs.lstatSync(target);
|
|
69
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
70
|
+
throw new Error(`knodin init: refusing non-regular hook-manager config: ${target}`);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code !== "ENOENT")
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function atomicWrite(target, content, mode) {
|
|
78
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
79
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
80
|
+
fs.writeFileSync(temporary, content, { encoding: "utf-8", mode, flag: "wx" });
|
|
81
|
+
fs.renameSync(temporary, target);
|
|
82
|
+
if (mode !== undefined)
|
|
83
|
+
fs.chmodSync(target, mode);
|
|
84
|
+
}
|
|
85
|
+
function ensureMap(document, keys) {
|
|
86
|
+
const existing = document.getIn(keys, true);
|
|
87
|
+
if (existing !== undefined && !isMap(existing))
|
|
88
|
+
throw new Error(`knodin init: cannot merge Lefthook integration because ${keys.join(".")} is not a map`);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Add a local-only Lefthook overlay. The repository-owned main configuration is
|
|
92
|
+
* never edited; an existing local overlay is parsed and merged by key.
|
|
93
|
+
*/
|
|
94
|
+
export function installHookManagerIntegration(repoPath) {
|
|
95
|
+
const repo = path.resolve(repoPath);
|
|
96
|
+
const files = lefthookFiles(repo);
|
|
97
|
+
if (!files)
|
|
98
|
+
return null;
|
|
99
|
+
const localPath = path.join(repo, files.local);
|
|
100
|
+
assertRegularOrMissing(localPath);
|
|
101
|
+
const existing = fs.existsSync(localPath) ? fs.readFileSync(localPath, "utf-8") : "";
|
|
102
|
+
const document = parseDocument(existing || "{}\n", { keepSourceTokens: true });
|
|
103
|
+
if (document.errors.length > 0)
|
|
104
|
+
throw new Error(`knodin init: cannot parse ${files.local}: ${document.errors[0]?.message}`);
|
|
105
|
+
if (!isMap(document.contents))
|
|
106
|
+
throw new Error(`knodin init: cannot merge ${files.local}: root must be a map`);
|
|
107
|
+
if (!existing)
|
|
108
|
+
document.commentBefore = MANAGED_COMMENT;
|
|
109
|
+
for (const [hook, command] of Object.entries(HOOK_COMMANDS)) {
|
|
110
|
+
ensureMap(document, [hook]);
|
|
111
|
+
ensureMap(document, [hook, "commands"]);
|
|
112
|
+
document.setIn([hook, "commands", KNODIN_LEFTHOOK_COMMAND], command);
|
|
113
|
+
}
|
|
114
|
+
atomicWrite(localPath, document.toString({ lineWidth: 0 }));
|
|
115
|
+
const dispatcher = path.join(repo, ".knodin", "hooks", "manager-dispatch.sh");
|
|
116
|
+
assertRegularOrMissing(dispatcher);
|
|
117
|
+
atomicWrite(dispatcher, LEFTHOOK_DISPATCHER, 0o755);
|
|
118
|
+
return {
|
|
119
|
+
manager: "lefthook",
|
|
120
|
+
localConfig: files.local,
|
|
121
|
+
dispatcher: ".knodin/hooks/manager-dispatch.sh",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export function inspectLefthookIntegration(repoPath) {
|
|
125
|
+
const repo = path.resolve(repoPath);
|
|
126
|
+
const files = lefthookFiles(repo);
|
|
127
|
+
if (!files)
|
|
128
|
+
return { configured: false, localConfig: null };
|
|
129
|
+
try {
|
|
130
|
+
const document = parseDocument(fs.readFileSync(path.join(repo, files.local), "utf-8"));
|
|
131
|
+
if (document.errors.length > 0)
|
|
132
|
+
return { configured: false, localConfig: files.local };
|
|
133
|
+
for (const [hook, command] of Object.entries(HOOK_COMMANDS)) {
|
|
134
|
+
const run = document.getIn([hook, "commands", KNODIN_LEFTHOOK_COMMAND, "run"]);
|
|
135
|
+
if (run !== command.run)
|
|
136
|
+
return { configured: false, localConfig: files.local };
|
|
137
|
+
if ("use_stdin" in command &&
|
|
138
|
+
document.getIn([hook, "commands", KNODIN_LEFTHOOK_COMMAND, "use_stdin"]) !== true)
|
|
139
|
+
return { configured: false, localConfig: files.local };
|
|
140
|
+
}
|
|
141
|
+
const dispatcher = path.join(repo, ".knodin", "hooks", "manager-dispatch.sh");
|
|
142
|
+
const stat = fs.statSync(dispatcher);
|
|
143
|
+
return {
|
|
144
|
+
configured: stat.isFile() &&
|
|
145
|
+
(stat.mode & 0o111) !== 0 &&
|
|
146
|
+
fs.readFileSync(dispatcher, "utf-8") === LEFTHOOK_DISPATCHER,
|
|
147
|
+
localConfig: files.local,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return { configured: false, localConfig: files.local };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export function isActiveLefthookHook(content) {
|
|
155
|
+
return /\bcall_lefthook\b|\blefthook(?:\s|")/.test(content);
|
|
156
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import child_process from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveStateDir } from "./engine/state-paths.js";
|
|
5
|
+
function activityPath(repo) {
|
|
6
|
+
return path.join(resolveStateDir(repo), "index-activity.json");
|
|
7
|
+
}
|
|
8
|
+
const ACTIVITY_CLOCK_SKEW_MS = 2_000;
|
|
9
|
+
const MAX_ACTIVITY_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
10
|
+
const INDEX_PHASES = new Set([
|
|
11
|
+
"starting",
|
|
12
|
+
"collecting-files",
|
|
13
|
+
"indexing-files",
|
|
14
|
+
"embedding-model",
|
|
15
|
+
"embedding-symbols",
|
|
16
|
+
"finalizing",
|
|
17
|
+
"verifying",
|
|
18
|
+
"completed",
|
|
19
|
+
]);
|
|
20
|
+
function processIsAlive(pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function processStartedAt(pid) {
|
|
30
|
+
if (pid === process.pid)
|
|
31
|
+
return Date.now() - process.uptime() * 1_000;
|
|
32
|
+
if (process.platform === "win32")
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
const output = child_process.execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
38
|
+
});
|
|
39
|
+
const startedAt = Date.parse(output.trim());
|
|
40
|
+
return Number.isFinite(startedAt) ? startedAt : null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Cross-process, source-safe status for a currently running index operation. */
|
|
47
|
+
export function createIndexActivityReporter(repo) {
|
|
48
|
+
const target = activityPath(repo);
|
|
49
|
+
const startedAt = new Date().toISOString();
|
|
50
|
+
const write = (activity) => {
|
|
51
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
52
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(temporary, `${JSON.stringify(activity)}\n`, "utf8");
|
|
55
|
+
fs.renameSync(temporary, target);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
fs.rmSync(temporary, { force: true });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
start() {
|
|
63
|
+
write({
|
|
64
|
+
pid: process.pid,
|
|
65
|
+
startedAt,
|
|
66
|
+
updatedAt: startedAt,
|
|
67
|
+
phase: "starting",
|
|
68
|
+
phaseCompleted: 0,
|
|
69
|
+
message: "Opening local graph database",
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
update(event) {
|
|
73
|
+
write({
|
|
74
|
+
pid: process.pid,
|
|
75
|
+
startedAt,
|
|
76
|
+
updatedAt: new Date().toISOString(),
|
|
77
|
+
phase: event.phase,
|
|
78
|
+
phaseCompleted: event.phaseCompleted,
|
|
79
|
+
...(event.phaseTotal === undefined ? {} : { phaseTotal: event.phaseTotal }),
|
|
80
|
+
message: event.message,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
stop() {
|
|
84
|
+
try {
|
|
85
|
+
const current = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
86
|
+
if (current.pid === process.pid)
|
|
87
|
+
fs.unlinkSync(target);
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Return only a live operation; crash-left activity files never claim freshness. */
|
|
94
|
+
export function readIndexActivity(repo) {
|
|
95
|
+
try {
|
|
96
|
+
const activity = JSON.parse(fs.readFileSync(activityPath(repo), "utf8"));
|
|
97
|
+
if (!Number.isInteger(activity.pid) || activity.pid <= 0 || !processIsAlive(activity.pid))
|
|
98
|
+
return null;
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const startedAt = Date.parse(activity.startedAt);
|
|
101
|
+
const updatedAt = Date.parse(activity.updatedAt);
|
|
102
|
+
if (!Number.isFinite(startedAt) ||
|
|
103
|
+
!Number.isFinite(updatedAt) ||
|
|
104
|
+
startedAt > now + ACTIVITY_CLOCK_SKEW_MS ||
|
|
105
|
+
updatedAt < startedAt ||
|
|
106
|
+
updatedAt > now + ACTIVITY_CLOCK_SKEW_MS ||
|
|
107
|
+
now - startedAt > MAX_ACTIVITY_AGE_MS ||
|
|
108
|
+
!INDEX_PHASES.has(activity.phase) ||
|
|
109
|
+
!Number.isFinite(activity.phaseCompleted) ||
|
|
110
|
+
activity.phaseCompleted < 0 ||
|
|
111
|
+
(activity.phaseTotal !== undefined &&
|
|
112
|
+
(!Number.isFinite(activity.phaseTotal) ||
|
|
113
|
+
activity.phaseTotal < 0 ||
|
|
114
|
+
activity.phaseCompleted > activity.phaseTotal)) ||
|
|
115
|
+
typeof activity.message !== "string") {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const processStart = processStartedAt(activity.pid);
|
|
119
|
+
if (processStart !== null && processStart > startedAt + ACTIVITY_CLOCK_SKEW_MS)
|
|
120
|
+
return null;
|
|
121
|
+
return activity;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -1,3 +1,71 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { formatProgress } from "./init-progress.js";
|
|
4
|
+
import { consumeProgressLines, createProgressStream } from "./progress-worker-runtime.js";
|
|
5
|
+
const terminalEnabled = process.stderr.isTTY === true || process.env.KNODIN_FORCE_PROGRESS_TTY === "1";
|
|
6
|
+
// Ora divides rendered text width by `stream.columns`. Some pseudo-terminals
|
|
7
|
+
// report zero columns, which turns its clear loop into an infinite loop. Keep
|
|
8
|
+
// the real terminal methods while guaranteeing finite viewport dimensions.
|
|
9
|
+
const progressStream = createProgressStream(terminalEnabled);
|
|
10
|
+
const spinner = ora({
|
|
11
|
+
text: "[init:starting] Opening local graph database (0s elapsed)",
|
|
12
|
+
stream: progressStream,
|
|
13
|
+
isEnabled: terminalEnabled,
|
|
14
|
+
discardStdin: false,
|
|
15
|
+
});
|
|
16
|
+
let stopped = false;
|
|
17
|
+
let exiting = false;
|
|
18
|
+
let startedAt = 0;
|
|
19
|
+
let operation = "init";
|
|
20
|
+
let current = "[init:starting] Opening local graph database";
|
|
21
|
+
let clock;
|
|
22
|
+
function refreshText() {
|
|
23
|
+
const elapsed = Math.max(0, Math.floor((Date.now() - startedAt) / 1_000));
|
|
24
|
+
spinner.text = `${current} (${elapsed}s elapsed)`;
|
|
25
|
+
}
|
|
26
|
+
function start(nextOperation = operation) {
|
|
27
|
+
if (spinner.isSpinning)
|
|
28
|
+
return;
|
|
29
|
+
operation = nextOperation;
|
|
30
|
+
current = `[${operation}:starting] Opening local graph database`;
|
|
31
|
+
startedAt = Date.now();
|
|
32
|
+
refreshText();
|
|
33
|
+
spinner.start();
|
|
34
|
+
clock = setInterval(refreshText, 250);
|
|
35
|
+
}
|
|
36
|
+
function stop(exitAfter = false) {
|
|
37
|
+
if (!stopped) {
|
|
38
|
+
stopped = true;
|
|
39
|
+
if (clock)
|
|
40
|
+
clearInterval(clock);
|
|
41
|
+
spinner.stop();
|
|
42
|
+
}
|
|
43
|
+
if (exitAfter && !exiting) {
|
|
44
|
+
exiting = true;
|
|
45
|
+
const exitTimer = setTimeout(() => process.exit(0), 100);
|
|
46
|
+
process.stderr.write("\r\x1b[2K", () => {
|
|
47
|
+
clearTimeout(exitTimer);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function handle(line) {
|
|
53
|
+
if (!line)
|
|
54
|
+
return;
|
|
55
|
+
const message = JSON.parse(line);
|
|
56
|
+
if (message.type === "start")
|
|
57
|
+
start(message.operation);
|
|
58
|
+
else if (message.type === "progress") {
|
|
59
|
+
if (!spinner.isSpinning)
|
|
60
|
+
start();
|
|
61
|
+
current = formatProgress(message.event, undefined, operation);
|
|
62
|
+
refreshText();
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
stop(true);
|
|
66
|
+
}
|
|
67
|
+
consumeProgressLines(handle, () => stop(true));
|
|
68
|
+
process.on("SIGTERM", () => {
|
|
69
|
+
stop();
|
|
70
|
+
process.exit(0);
|
|
71
|
+
});
|