knodin 0.10.8 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/compact-structural.js +8 -3
- package/dist/src/credential-patterns.js +14 -1
- package/dist/src/engine/parse-pool.js +21 -4
- package/dist/src/engine/seal.js +122 -3
- package/dist/src/engine/sealed-open.js +125 -8
- package/dist/src/engine/text-matches.js +121 -79
- package/dist/src/init.js +28 -3
- package/dist/src/mcp-worker-supervisor.js +10 -2
- package/dist/src/structural-fast-path.js +64 -1
- package/dist/src/tools/knodin-tools.js +16 -9
- package/docs/releases/0.11.0.md +227 -0
- package/package.json +3 -2
|
@@ -12,12 +12,17 @@ export function compactIdentity(identity) {
|
|
|
12
12
|
export function expandCompactIdentity(identity) {
|
|
13
13
|
return identity?.startsWith("~") ? `${IDENTITY_PREFIX}${identity.slice(1)}*` : identity;
|
|
14
14
|
}
|
|
15
|
+
// Missing evidence is "unknown", never "fresh" — the same default the engine's
|
|
16
|
+
// own `stalenessFor()` uses. Search stamps staleness per row and `SearchPage`
|
|
17
|
+
// carries no page-level field, so a zero-row page has no row to read it from:
|
|
18
|
+
// defaulting to "fresh" rendered an empty result on a stale or unprobed graph as
|
|
19
|
+
// `fresh 0/0`, an authoritative-looking negative (KNODIN-22).
|
|
15
20
|
function freshness(value) {
|
|
16
21
|
if (value === "reconciled")
|
|
17
22
|
return "reconciled";
|
|
18
|
-
if (value === "
|
|
19
|
-
return "
|
|
20
|
-
return "
|
|
23
|
+
if (value === "fresh")
|
|
24
|
+
return "fresh";
|
|
25
|
+
return "unknown";
|
|
21
26
|
}
|
|
22
27
|
function terseSignature(row) {
|
|
23
28
|
const signature = row.signature?.replace(/\s+/g, " ").trim() ?? "";
|
|
@@ -25,8 +25,21 @@ export function credentialPatterns() {
|
|
|
25
25
|
// consumed BEFORE the value, because `authorization: Bearer <token>`
|
|
26
26
|
// otherwise matches only the word "Bearer" — redacting the label and
|
|
27
27
|
// preserving the credential, exactly inverted (KNODIN-10).
|
|
28
|
+
//
|
|
29
|
+
// The keyword is surrounded by `[\w-]*` rather than anchored with `\b`,
|
|
30
|
+
// because `\b` does not exist between `_` and a letter: a leading `\b`
|
|
31
|
+
// could never match inside `AWS_SECRET_ACCESS_KEY`, and a trailing one
|
|
32
|
+
// stopped the name short of the `=`. That excluded the dominant spelling
|
|
33
|
+
// of every real secret — `AWS_SECRET_ACCESS_KEY=`, `DB_PASSWORD=`,
|
|
34
|
+
// `NPM_TOKEN=`, any `*_SECRET=` — while `password=` alone was redacted
|
|
35
|
+
// (KNODIN-19).
|
|
36
|
+
//
|
|
37
|
+
// The trailing half is `[_-]`-led rather than a bare `[\w-]*` so the
|
|
38
|
+
// keyword has to be a whole segment of the name. knodin echoes source
|
|
39
|
+
// into diagnostics and compressed output, and an unanchored tail
|
|
40
|
+
// redacted the value of a plain `const tokenCount = 5`.
|
|
28
41
|
label: "credential",
|
|
29
|
-
pattern:
|
|
42
|
+
pattern: /(?<![\w-])([\w-]*(?:authorization|password|passwd|secret|token|api[_-]?key)(?:[_-][\w-]*)?)(\s*[:=]\s*)(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi,
|
|
30
43
|
preservedGroups: 2,
|
|
31
44
|
},
|
|
32
45
|
{
|
|
@@ -147,10 +147,27 @@ class PoolRun {
|
|
|
147
147
|
return this.buckets.get(bestKey)?.pop() ?? null;
|
|
148
148
|
}
|
|
149
149
|
attach(slot) {
|
|
150
|
-
|
|
151
|
-
slot.
|
|
152
|
-
slot
|
|
153
|
-
|
|
150
|
+
// Bind these listeners to the handle they were registered on, not just to
|
|
151
|
+
// the slot. `PoolWorkerHandle` offers no listener removal, so a replaced
|
|
152
|
+
// worker's listeners stay live and still close over the slot — whose
|
|
153
|
+
// `.handle` is by then the replacement. A crashed worker's inevitable late
|
|
154
|
+
// `exit` therefore re-entered `onSlotFailure` and terminated its OWN
|
|
155
|
+
// replacement, cancelling the one documented retry and leaving the lane
|
|
156
|
+
// dead; on a one-worker pool that dropped the whole run to inline parsing
|
|
157
|
+
// (KNODIN-20). Mirrors the `pending.child !== child` guard the MCP
|
|
158
|
+
// supervisor already carries.
|
|
159
|
+
const handle = slot.handle;
|
|
160
|
+
const current = () => slot.handle === handle;
|
|
161
|
+
handle.onMessage((response) => {
|
|
162
|
+
if (current())
|
|
163
|
+
this.onResponse(slot, response);
|
|
164
|
+
});
|
|
165
|
+
handle.onError((error) => {
|
|
166
|
+
if (current())
|
|
167
|
+
this.onSlotFailure(slot, error.message);
|
|
168
|
+
});
|
|
169
|
+
handle.onExit((code) => {
|
|
170
|
+
if (current() && slot.inFlight)
|
|
154
171
|
this.onSlotFailure(slot, `worker exited with code ${code}`);
|
|
155
172
|
});
|
|
156
173
|
}
|
package/dist/src/engine/seal.js
CHANGED
|
@@ -22,16 +22,114 @@ const SEAL_SCHEMA = [
|
|
|
22
22
|
function countLines(buf) {
|
|
23
23
|
return buf.toString("utf8").split("\n").length;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* A database claims to be an artifact but carries an attestation the reader
|
|
27
|
+
* cannot trust.
|
|
28
|
+
*
|
|
29
|
+
* Distinct from "no attestation at all", which is an ordinary index and a
|
|
30
|
+
* routine answer. Every field here is consumed as a decision downstream — the
|
|
31
|
+
* schema gate, the version gate, and the repository path the artifact is
|
|
32
|
+
* allowed to serve source for — so a half-populated object must stop at the
|
|
33
|
+
* reader rather than reach them.
|
|
34
|
+
*/
|
|
35
|
+
export class SealAttestationError extends Error {
|
|
36
|
+
code = "malformed-attestation";
|
|
37
|
+
constructor(reason) {
|
|
38
|
+
super(reason);
|
|
39
|
+
this.name = "SealAttestationError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validates the WHOLE shape, not just the fields the immediate caller reads.
|
|
47
|
+
* A sealed artifact is transported between machines, so it is untrusted input
|
|
48
|
+
* rather than something this process wrote (KNODIN-21), and an unchecked cast
|
|
49
|
+
* would let `undefined` stand where a version or a path belongs.
|
|
50
|
+
*/
|
|
51
|
+
function validateAttestation(value) {
|
|
52
|
+
const bad = (reason) => {
|
|
53
|
+
throw new SealAttestationError(`seal attestation is malformed: ${reason}`);
|
|
54
|
+
};
|
|
55
|
+
if (!isRecord(value))
|
|
56
|
+
return bad("not an object");
|
|
57
|
+
const str = (key, on) => typeof on[key] === "string" ? on[key] : bad(`${key} is not a string`);
|
|
58
|
+
const num = (key, on) => typeof on[key] === "number" && Number.isFinite(on[key])
|
|
59
|
+
? on[key]
|
|
60
|
+
: bad(`${key} is not a finite number`);
|
|
61
|
+
const nullableStr = (key, on) => on[key] === null || typeof on[key] === "string"
|
|
62
|
+
? on[key]
|
|
63
|
+
: bad(`${key} is not a string or null`);
|
|
64
|
+
const obj = (key) => isRecord(value[key]) ? value[key] : bad(`${key} is not an object`);
|
|
65
|
+
if (value.sealVersion !== 1)
|
|
66
|
+
bad(`unsupported sealVersion ${String(value.sealVersion)}`);
|
|
67
|
+
const repository = obj("repository");
|
|
68
|
+
const health = obj("health");
|
|
69
|
+
const source = obj("source");
|
|
70
|
+
const embeddings = obj("embeddings");
|
|
71
|
+
const policy = source.policy;
|
|
72
|
+
if (policy !== "all-indexed" && policy !== "excluded")
|
|
73
|
+
bad(`unsupported source policy ${String(policy)}`);
|
|
74
|
+
const stripped = embeddings.stripped;
|
|
75
|
+
if (typeof stripped !== "boolean")
|
|
76
|
+
bad("embeddings.stripped is not a boolean");
|
|
77
|
+
return {
|
|
78
|
+
sealVersion: 1,
|
|
79
|
+
knodinVersion: str("knodinVersion", value),
|
|
80
|
+
schemaVersion: num("schemaVersion", value),
|
|
81
|
+
repository: {
|
|
82
|
+
identity: str("identity", repository),
|
|
83
|
+
path: str("path", repository),
|
|
84
|
+
remoteUrl: nullableStr("remoteUrl", repository),
|
|
85
|
+
},
|
|
86
|
+
sealedCommit: str("sealedCommit", value),
|
|
87
|
+
sealedRef: nullableStr("sealedRef", value),
|
|
88
|
+
sealedAt: str("sealedAt", value),
|
|
89
|
+
health: {
|
|
90
|
+
status: str("status", health),
|
|
91
|
+
pendingPaths: num("pendingPaths", health),
|
|
92
|
+
freshness: str("freshness", health),
|
|
93
|
+
},
|
|
94
|
+
source: {
|
|
95
|
+
policy: policy,
|
|
96
|
+
files: num("files", source),
|
|
97
|
+
uniqueBlobs: num("uniqueBlobs", source),
|
|
98
|
+
rawBytes: num("rawBytes", source),
|
|
99
|
+
storedBytes: num("storedBytes", source),
|
|
100
|
+
},
|
|
101
|
+
embeddings: { stripped: stripped, removed: num("removed", embeddings) },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* `null` when the database carries no attestation; THROWS
|
|
106
|
+
* `SealAttestationError` when it carries one that does not validate. The split
|
|
107
|
+
* has teeth: "ordinary index" is something a caller falls back from, while a
|
|
108
|
+
* database misrepresenting itself must not be quietly downgraded to the same
|
|
109
|
+
* answer.
|
|
110
|
+
*/
|
|
25
111
|
export function readSealAttestation(db) {
|
|
112
|
+
let raw;
|
|
26
113
|
try {
|
|
27
114
|
const row = db
|
|
28
115
|
.query("SELECT value FROM meta WHERE key = 'sealAttestation'")
|
|
29
116
|
.get();
|
|
30
|
-
|
|
117
|
+
if (!row)
|
|
118
|
+
return null;
|
|
119
|
+
raw = row.value;
|
|
31
120
|
}
|
|
32
121
|
catch {
|
|
122
|
+
// No `meta` table, or an unreadable one: not a sealed artifact.
|
|
33
123
|
return null;
|
|
34
124
|
}
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(raw);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw new SealAttestationError(`seal attestation is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
}
|
|
132
|
+
return validateAttestation(parsed);
|
|
35
133
|
}
|
|
36
134
|
/**
|
|
37
135
|
* Reads embedded source out of a sealed artifact.
|
|
@@ -64,9 +162,30 @@ export function listSealedFiles(db) {
|
|
|
64
162
|
.all()
|
|
65
163
|
.map((row) => row.filePath);
|
|
66
164
|
}
|
|
67
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* Content hash of every embedded file, as recorded at seal time.
|
|
167
|
+
*
|
|
168
|
+
* The hash is over the RAW bytes, so a caller can compare against a file on
|
|
169
|
+
* disk without decompressing the blob.
|
|
170
|
+
*/
|
|
171
|
+
export function listSealedFileHashes(db) {
|
|
172
|
+
return db
|
|
173
|
+
.query("SELECT filePath, sha256 FROM sealed_file ORDER BY filePath")
|
|
174
|
+
.all();
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* True when this database carries embedded source, i.e. can answer with no
|
|
178
|
+
* checkout. A malformed attestation answers false: it is not an artifact any
|
|
179
|
+
* reader here can serve from, and this predicate's callers route on it rather
|
|
180
|
+
* than handle it.
|
|
181
|
+
*/
|
|
68
182
|
export function isSealedDatabase(db) {
|
|
69
|
-
|
|
183
|
+
try {
|
|
184
|
+
return readSealAttestation(db) !== null;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
70
189
|
}
|
|
71
190
|
/**
|
|
72
191
|
* Produce a sealed artifact, or refuse with a machine-readable code.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { KNODIN_VERSION as RUNTIME_VERSION } from "../version.js";
|
|
5
6
|
import { KNODIN_SCHEMA_VERSION, registerSealedRepository, unregisterSealedRepository, } from "./index.js";
|
|
6
|
-
import { readSealAttestation } from "./seal.js";
|
|
7
|
+
import { listSealedFileHashes, readSealAttestation, SealAttestationError, } from "./seal.js";
|
|
7
8
|
import { Database } from "./sqlite.js";
|
|
8
9
|
export function openSealedArtifact(artifactPath, options = {}) {
|
|
9
10
|
const resolved = path.resolve(artifactPath);
|
|
@@ -20,7 +21,18 @@ export function openSealedArtifact(artifactPath, options = {}) {
|
|
|
20
21
|
reason: error instanceof Error ? error.message : String(error),
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
+
let attestation;
|
|
25
|
+
try {
|
|
26
|
+
attestation = readSealAttestation(db);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
db.close();
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: error instanceof SealAttestationError ? "malformed-attestation" : "unreadable",
|
|
33
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
24
36
|
if (!attestation) {
|
|
25
37
|
db.close();
|
|
26
38
|
return {
|
|
@@ -84,10 +96,31 @@ export function openSealedArtifact(artifactPath, options = {}) {
|
|
|
84
96
|
// only the mount would leave those internal reads falling through to a
|
|
85
97
|
// filesystem that does not have the source, which fails as "" rather than
|
|
86
98
|
// loudly.
|
|
87
|
-
registerSealedRepository(mountDir, db);
|
|
88
99
|
const attestedPath = attestation.repository.path;
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
const registerAttested = attestedPath && path.resolve(attestedPath) !== path.resolve(mountDir);
|
|
101
|
+
// The attested path is chosen by the artifact, and the registry it lands in
|
|
102
|
+
// takes precedence over the filesystem for every source read in the
|
|
103
|
+
// process. An artifact naming a path that exists here would therefore serve
|
|
104
|
+
// its own bytes to live queries against that checkout (KNODIN-21). Sealing
|
|
105
|
+
// and opening on ONE machine is a real flow, so this refuses on
|
|
106
|
+
// DISAGREEMENT rather than on existence: where the embedded blobs match the
|
|
107
|
+
// files on disk the artifact describes that tree and shadowing it changes
|
|
108
|
+
// nothing, and where they do not, whichever side is wrong, no read of that
|
|
109
|
+
// path can be trusted.
|
|
110
|
+
if (registerAttested) {
|
|
111
|
+
const conflict = firstSourceDisagreement(db, path.resolve(attestedPath));
|
|
112
|
+
if (conflict) {
|
|
113
|
+
db.close();
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
code: "path-conflict",
|
|
117
|
+
reason: `artifact attests ${path.resolve(attestedPath)}, which exists here with different source (${conflict}); re-seal from this checkout, or open the artifact where that path is absent`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
acquireSealedRepository(mountDir, db);
|
|
122
|
+
if (registerAttested)
|
|
123
|
+
acquireSealedRepository(attestedPath, db);
|
|
91
124
|
const sealedAt = Date.parse(attestation.sealedAt);
|
|
92
125
|
const ageDays = Number.isNaN(sealedAt)
|
|
93
126
|
? 0
|
|
@@ -100,13 +133,97 @@ export function openSealedArtifact(artifactPath, options = {}) {
|
|
|
100
133
|
degraded,
|
|
101
134
|
ageDays,
|
|
102
135
|
close() {
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
136
|
+
releaseSealedRepository(mountDir, db);
|
|
137
|
+
if (registerAttested)
|
|
138
|
+
releaseSealedRepository(attestedPath, db);
|
|
106
139
|
db.close();
|
|
107
140
|
},
|
|
108
141
|
};
|
|
109
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Open handles per registered path, most recent last.
|
|
145
|
+
*
|
|
146
|
+
* The engine's registry is a bare path→database map, so two artifacts
|
|
147
|
+
* attesting the same repository would have the first `close()` unregister the
|
|
148
|
+
* other's source and leave it reading `""` (KNODIN-21). Held here rather than
|
|
149
|
+
* widened there because the map is the engine's chokepoint for a single
|
|
150
|
+
* answer to "where do bytes for this path come from"; ownership of the
|
|
151
|
+
* handles that claim it belongs to whoever opened them.
|
|
152
|
+
*/
|
|
153
|
+
const sealedRegistrations = new Map();
|
|
154
|
+
function acquireSealedRepository(repoPath, db) {
|
|
155
|
+
const key = path.resolve(repoPath);
|
|
156
|
+
const held = sealedRegistrations.get(key) ?? [];
|
|
157
|
+
held.push(db);
|
|
158
|
+
sealedRegistrations.set(key, held);
|
|
159
|
+
registerSealedRepository(key, db);
|
|
160
|
+
}
|
|
161
|
+
function releaseSealedRepository(repoPath, db) {
|
|
162
|
+
const key = path.resolve(repoPath);
|
|
163
|
+
const held = sealedRegistrations.get(key);
|
|
164
|
+
if (!held)
|
|
165
|
+
return;
|
|
166
|
+
const last = held.lastIndexOf(db);
|
|
167
|
+
if (last === -1)
|
|
168
|
+
return;
|
|
169
|
+
held.splice(last, 1);
|
|
170
|
+
// Re-point at a surviving handle rather than leaving the closed one
|
|
171
|
+
// registered: an unregister here would fall through to a filesystem that
|
|
172
|
+
// has no source, and a closed database would throw on the next read.
|
|
173
|
+
const survivor = held.at(-1);
|
|
174
|
+
if (survivor)
|
|
175
|
+
registerSealedRepository(key, survivor);
|
|
176
|
+
else {
|
|
177
|
+
sealedRegistrations.delete(key);
|
|
178
|
+
unregisterSealedRepository(key);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* First embedded file whose bytes differ from the file at the same relative
|
|
183
|
+
* path under `dir`, or `null` when every one that exists there agrees.
|
|
184
|
+
*
|
|
185
|
+
* Files absent from `dir` are not disagreement — a partially or wholly removed
|
|
186
|
+
* checkout is precisely what an artifact is for. Compared by hash against the
|
|
187
|
+
* one recorded at seal time, which is over raw bytes, so nothing is
|
|
188
|
+
* decompressed to answer this.
|
|
189
|
+
*
|
|
190
|
+
* `filePath` comes out of the artifact and is therefore attacker-controlled, so
|
|
191
|
+
* every candidate is resolved and required to stay under `dir` before it is
|
|
192
|
+
* opened. A `..` segment would otherwise have this check read and hash files
|
|
193
|
+
* outside the attested checkout — reachable during `openSealedArtifact`, and
|
|
194
|
+
* pure attack surface, since a repository-relative path never needs to escape.
|
|
195
|
+
* An entry that does is reported as disagreement rather than skipped: it is not
|
|
196
|
+
* a path this artifact can honestly claim, and failing closed keeps a traversal
|
|
197
|
+
* from being a way to hide an entry from this comparison.
|
|
198
|
+
*/
|
|
199
|
+
function firstSourceDisagreement(db, dir) {
|
|
200
|
+
let stat;
|
|
201
|
+
try {
|
|
202
|
+
stat = fs.statSync(dir);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
if (!stat.isDirectory())
|
|
208
|
+
return null;
|
|
209
|
+
const root = path.resolve(dir);
|
|
210
|
+
for (const entry of listSealedFileHashes(db)) {
|
|
211
|
+
const candidate = path.resolve(root, entry.filePath);
|
|
212
|
+
if (candidate !== root && !candidate.startsWith(root + path.sep))
|
|
213
|
+
return entry.filePath;
|
|
214
|
+
let onDisk;
|
|
215
|
+
try {
|
|
216
|
+
onDisk = fs.readFileSync(candidate);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const hash = crypto.createHash("sha256").update(onDisk).digest("hex");
|
|
222
|
+
if (hash !== entry.sha256)
|
|
223
|
+
return entry.filePath;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
110
227
|
/**
|
|
111
228
|
* Read at call time rather than imported as a constant so that a test can
|
|
112
229
|
* exercise the soft gate without rebuilding the package version.
|
|
@@ -25,6 +25,24 @@ import { walkRepoFiles } from "./file-walker.js";
|
|
|
25
25
|
const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
|
|
26
26
|
/** Upper bound on `TextMatch.enclosingText`, so one row cannot flood a preview. */
|
|
27
27
|
const ENCLOSING_TEXT_LIMIT = 120;
|
|
28
|
+
/**
|
|
29
|
+
* Largest file this search will read, in bytes.
|
|
30
|
+
*
|
|
31
|
+
* R1 deliberately drops the source-extension filter, so the walk reaches
|
|
32
|
+
* lockfiles, NDJSON dumps, fixtures and generated JSON as well as prose. Four
|
|
33
|
+
* mebibytes clears every hand-written source or document file by a wide margin
|
|
34
|
+
* while excluding the generated artefacts that would otherwise decide the
|
|
35
|
+
* process's peak memory. Oversized files are reported in `uncovered`, never
|
|
36
|
+
* dropped.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_SEARCHABLE_FILE_BYTES = 4 * 1024 * 1024;
|
|
39
|
+
/**
|
|
40
|
+
* Largest number of matches this search will accumulate.
|
|
41
|
+
*
|
|
42
|
+
* Past this the result is no longer reviewable and the match objects alone
|
|
43
|
+
* dominate memory; the honest answer is a bounded list plus `truncated: true`.
|
|
44
|
+
*/
|
|
45
|
+
export const MAX_TEXT_MATCHES = 5_000;
|
|
28
46
|
function isWordCharacter(value) {
|
|
29
47
|
return value !== undefined && WORD_CHARACTER.test(value);
|
|
30
48
|
}
|
|
@@ -164,86 +182,101 @@ function classifyAgainstTree(parser, content, raw) {
|
|
|
164
182
|
* over a large repository touches enough files to reproduce it exactly.
|
|
165
183
|
*/
|
|
166
184
|
export async function searchFiles(files, term, loadLanguage, newParser, options = {}) {
|
|
167
|
-
const
|
|
168
|
-
const matches = [];
|
|
169
|
-
const uncovered = [];
|
|
170
|
-
let filesSearched = 0;
|
|
185
|
+
const state = newSearchState();
|
|
171
186
|
for (const { file, content } of files) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
187
|
+
await searchOneFile(state, file, content, term, loadLanguage, newParser, options);
|
|
188
|
+
if (state.truncated)
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
return { ...state, searched: true };
|
|
192
|
+
}
|
|
193
|
+
function newSearchState() {
|
|
194
|
+
return { matches: [], uncovered: [], filesSearched: 0, truncated: false };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Search and classify one file, appending to `state`.
|
|
198
|
+
*
|
|
199
|
+
* Takes a single file's content rather than a list so that a repository-wide
|
|
200
|
+
* search never has to hold more than one file in memory at once (KNODIN-24).
|
|
201
|
+
* Sets `state.truncated` when the match cap is reached; the caller is
|
|
202
|
+
* responsible for stopping there.
|
|
203
|
+
*/
|
|
204
|
+
async function searchOneFile(state, file, content, term, loadLanguage, newParser, options) {
|
|
205
|
+
const caseSensitive = options.caseSensitive ?? true;
|
|
206
|
+
const raw = findOccurrences(content, term, caseSensitive);
|
|
207
|
+
state.filesSearched++;
|
|
208
|
+
if (raw.length === 0)
|
|
209
|
+
return;
|
|
210
|
+
let classified = null;
|
|
211
|
+
let language = null;
|
|
212
|
+
try {
|
|
213
|
+
language = await loadLanguage(file);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// A grammar that will not load costs classification, not the match.
|
|
217
|
+
language = null;
|
|
218
|
+
}
|
|
219
|
+
if (language) {
|
|
220
|
+
const parser = newParser();
|
|
178
221
|
try {
|
|
179
|
-
language
|
|
222
|
+
parser.setLanguage(language);
|
|
223
|
+
classified = classifyAgainstTree(parser, content, raw);
|
|
180
224
|
}
|
|
181
|
-
catch {
|
|
182
|
-
|
|
183
|
-
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (isWasmAbort(error))
|
|
227
|
+
throw error;
|
|
228
|
+
// Parsing failed on this file only. The matches are still real and
|
|
229
|
+
// still reported — just without node evidence.
|
|
230
|
+
classified = null;
|
|
231
|
+
state.uncovered.push({
|
|
232
|
+
file,
|
|
233
|
+
reason: `matched but could not be parsed for classification: ${error instanceof Error ? error.message : String(error)}`,
|
|
234
|
+
});
|
|
184
235
|
}
|
|
185
|
-
|
|
186
|
-
const parser = newParser();
|
|
236
|
+
finally {
|
|
187
237
|
try {
|
|
188
|
-
parser.
|
|
189
|
-
classified = classifyAgainstTree(parser, content, raw);
|
|
238
|
+
parser.delete();
|
|
190
239
|
}
|
|
191
|
-
catch
|
|
192
|
-
|
|
193
|
-
throw error;
|
|
194
|
-
// Parsing failed on this file only. The matches are still real and
|
|
195
|
-
// still reported — just without node evidence.
|
|
196
|
-
classified = null;
|
|
197
|
-
uncovered.push({
|
|
198
|
-
file,
|
|
199
|
-
reason: `matched but could not be parsed for classification: ${error instanceof Error ? error.message : String(error)}`,
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
finally {
|
|
203
|
-
try {
|
|
204
|
-
parser.delete();
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
/* a dead module has nothing to reclaim */
|
|
208
|
-
}
|
|
240
|
+
catch {
|
|
241
|
+
/* a dead module has nothing to reclaim */
|
|
209
242
|
}
|
|
210
243
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
244
|
+
}
|
|
245
|
+
const starts = lineStarts(content);
|
|
246
|
+
for (const [position, match] of raw.entries()) {
|
|
247
|
+
if (state.matches.length >= MAX_TEXT_MATCHES) {
|
|
248
|
+
state.truncated = true;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const lineIndex = lineIndexFor(starts, match.startIndex);
|
|
252
|
+
const lineStart = starts[lineIndex];
|
|
253
|
+
const nextStart = starts[lineIndex + 1] ?? content.length + 1;
|
|
254
|
+
const node = classified?.[position] ?? null;
|
|
255
|
+
state.matches.push({
|
|
256
|
+
file,
|
|
257
|
+
line: lineIndex + 1,
|
|
258
|
+
column: match.startIndex - lineStart + 1,
|
|
259
|
+
startIndex: match.startIndex,
|
|
260
|
+
endIndex: match.endIndex,
|
|
261
|
+
lineText: content.slice(lineStart, Math.max(lineStart, nextStart - 1)).replace(/\r$/, ""),
|
|
262
|
+
matchedText: match.matchedText,
|
|
263
|
+
caseMatchesQuery: match.matchedText === term,
|
|
264
|
+
withinLargerWord: isWordCharacter(content[match.startIndex - 1]) || isWordCharacter(content[match.endIndex]),
|
|
265
|
+
classification: node ? node.classification : classifyByFileType(file),
|
|
266
|
+
basis: node ? "parse-node" : language ? "none" : "file-type",
|
|
267
|
+
nodeType: node ? node.nodeType : null,
|
|
268
|
+
enclosingText: node ? node.enclosingText : null,
|
|
233
269
|
});
|
|
234
270
|
}
|
|
235
|
-
return { matches, uncovered, filesSearched, searched: true };
|
|
236
271
|
}
|
|
237
|
-
|
|
238
|
-
* Read a file for searching, or say why it could not be.
|
|
239
|
-
*
|
|
240
|
-
* Binary detection is a NUL-byte sniff over the head of the file rather than an
|
|
241
|
-
* extension list: the extensions that matter here are open-ended, and a
|
|
242
|
-
* misjudged binary read produces garbage matches rather than an honest skip.
|
|
243
|
-
*/
|
|
244
|
-
function readSearchable(absolute) {
|
|
272
|
+
export function readSearchable(absolute) {
|
|
245
273
|
let buffer;
|
|
246
274
|
try {
|
|
275
|
+
const stats = fs.statSync(absolute);
|
|
276
|
+
if (stats.size > MAX_SEARCHABLE_FILE_BYTES)
|
|
277
|
+
return {
|
|
278
|
+
reason: `too large to search: ${stats.size} bytes exceeds the ${MAX_SEARCHABLE_FILE_BYTES}-byte limit`,
|
|
279
|
+
};
|
|
247
280
|
buffer = fs.readFileSync(absolute);
|
|
248
281
|
}
|
|
249
282
|
catch (error) {
|
|
@@ -264,8 +297,15 @@ function readSearchable(absolute) {
|
|
|
264
297
|
* Every file that could not be searched is returned in `uncovered` (R5). A
|
|
265
298
|
* rename that silently skipped files would leave a half-renamed repository
|
|
266
299
|
* looking finished, which is the worst available outcome for this operation.
|
|
300
|
+
*
|
|
301
|
+
* Files are read and searched one at a time. Reading them all up front held the
|
|
302
|
+
* entire non-pruned working tree in memory at once, which on a tree carrying
|
|
303
|
+
* generated dumps or vendored data is gigabytes for a single call (KNODIN-24).
|
|
304
|
+
*
|
|
305
|
+
* `readFile` is injected only so a test can observe that reads interleave with
|
|
306
|
+
* searching rather than preceding it.
|
|
267
307
|
*/
|
|
268
|
-
export async function searchRepoText(repoPath, term, loadLanguage, newParser, options = {}) {
|
|
308
|
+
export async function searchRepoText(repoPath, term, loadLanguage, newParser, options = {}, readFile = readSearchable) {
|
|
269
309
|
let candidates;
|
|
270
310
|
try {
|
|
271
311
|
// Checked explicitly because `walkRepoFiles` swallows a missing directory
|
|
@@ -291,19 +331,21 @@ export async function searchRepoText(repoPath, term, loadLanguage, newParser, op
|
|
|
291
331
|
],
|
|
292
332
|
filesSearched: 0,
|
|
293
333
|
searched: false,
|
|
334
|
+
truncated: false,
|
|
294
335
|
};
|
|
295
336
|
}
|
|
296
|
-
const
|
|
297
|
-
const uncovered = [];
|
|
298
|
-
for (const file of candidates) {
|
|
299
|
-
const outcome = readSearchable(path.join(repoPath, file));
|
|
300
|
-
if ("reason" in outcome)
|
|
301
|
-
uncovered.push({ file, reason: outcome.reason });
|
|
302
|
-
else
|
|
303
|
-
readable.push({ file, content: outcome.content });
|
|
304
|
-
}
|
|
305
|
-
const result = await searchFiles(readable, term, loadLanguage, newParser, options);
|
|
337
|
+
const state = newSearchState();
|
|
306
338
|
// Files skipped at read time and files that failed classification are both
|
|
307
339
|
// gaps in the same claim, so they are reported through one list.
|
|
308
|
-
|
|
340
|
+
for (const file of candidates) {
|
|
341
|
+
const outcome = readFile(path.join(repoPath, file));
|
|
342
|
+
if ("reason" in outcome) {
|
|
343
|
+
state.uncovered.push({ file, reason: outcome.reason });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
await searchOneFile(state, file, outcome.content, term, loadLanguage, newParser, options);
|
|
347
|
+
if (state.truncated)
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
return { ...state, searched: true };
|
|
309
351
|
}
|
package/dist/src/init.js
CHANGED
|
@@ -478,8 +478,15 @@ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
|
478
478
|
if [ -f "$LOCK_DIR/pid" ]; then
|
|
479
479
|
LOCK_PID="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
|
|
480
480
|
if [ -n "$LOCK_PID" ] && ! kill -0 "$LOCK_PID" 2>/dev/null; then
|
|
481
|
-
rm -rf "
|
|
482
|
-
|
|
481
|
+
# Claim the stale lock by RENAMING it: "rm -rf then mkdir" leaves a window
|
|
482
|
+
# in which two processes that both saw the same dead pid can each succeed.
|
|
483
|
+
# Only one rename can win, because the loser's source no longer exists.
|
|
484
|
+
if mv "$LOCK_DIR" "$LOCK_DIR.stale.$$" 2>/dev/null; then
|
|
485
|
+
rm -rf "$LOCK_DIR.stale.$$"
|
|
486
|
+
mkdir "$LOCK_DIR" 2>/dev/null || exit 0
|
|
487
|
+
else
|
|
488
|
+
exit 0
|
|
489
|
+
fi
|
|
483
490
|
else
|
|
484
491
|
exit 0
|
|
485
492
|
fi
|
|
@@ -488,7 +495,25 @@ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
|
488
495
|
fi
|
|
489
496
|
fi
|
|
490
497
|
printf '%s\n' "$$" > "$LOCK_DIR/pid"
|
|
491
|
-
|
|
498
|
+
# Release only a lock this process still owns. The previous handler removed
|
|
499
|
+
# $LOCK_DIR unconditionally, so a script that had already lost the lock deleted
|
|
500
|
+
# its SUCCESSOR's — and init sends SIGTERM here on every drain that outruns its
|
|
501
|
+
# 30s spawnSync timeout, which made that the common case rather than the rare
|
|
502
|
+
# one (KNODIN-23).
|
|
503
|
+
knodin_release_lock() {
|
|
504
|
+
if [ "$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" = "$$" ]; then
|
|
505
|
+
rm -rf "$LOCK_DIR"
|
|
506
|
+
fi
|
|
507
|
+
rm -f "$FAILURE_TMP"
|
|
508
|
+
}
|
|
509
|
+
trap 'knodin_release_lock' EXIT
|
|
510
|
+
# Each signal exits explicitly. POSIX sh defers a trap until the foreground
|
|
511
|
+
# command finishes and then RESUMES where it left off, so a handler that only
|
|
512
|
+
# cleaned up dropped the lock and kept draining — two processors at once, and
|
|
513
|
+
# Ctrl-C failed to cancel a foreground run at all.
|
|
514
|
+
trap 'knodin_release_lock; trap - EXIT; exit 129' HUP
|
|
515
|
+
trap 'knodin_release_lock; trap - EXIT; exit 130' INT
|
|
516
|
+
trap 'knodin_release_lock; trap - EXIT; exit 143' TERM
|
|
492
517
|
LOG_PATH="$REPO_ROOT/.knodin/indexer.log"
|
|
493
518
|
if [ -f "$LOG_PATH" ]; then
|
|
494
519
|
LOG_BYTES="$(wc -c < "$LOG_PATH" 2>/dev/null | tr -d '[:space:]')"
|
|
@@ -214,9 +214,17 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
214
214
|
if (signal.aborted)
|
|
215
215
|
throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker dispatch");
|
|
216
216
|
if (!this.child) {
|
|
217
|
+
// Prune HERE as well as in `exited`. Once this gate throws, no child is
|
|
218
|
+
// spawned, so no exit event ever fires to prune the array — filtering
|
|
219
|
+
// only on exit turned the 60s rate window into a permanent per-repository
|
|
220
|
+
// latch that outlived whatever caused the crashes, and the cached
|
|
221
|
+
// RepositoryWorker is never evicted, so only a gateway restart cleared
|
|
222
|
+
// it (KNODIN-17).
|
|
223
|
+
const startedAt = Date.now();
|
|
224
|
+
this.restartTimes = this.restartTimes.filter((at) => startedAt - at < RESTART_WINDOW_MS);
|
|
217
225
|
if (this.restartTimes.length >= MAX_RESTARTS)
|
|
218
|
-
throw codedError("KNODIN_RESTART_LIMIT",
|
|
219
|
-
this.restartTimes.push(
|
|
226
|
+
throw codedError("KNODIN_RESTART_LIMIT", `graph worker restarted ${MAX_RESTARTS} times within ${RESTART_WINDOW_MS / 1000}s; it will be retried once that window elapses`);
|
|
227
|
+
this.restartTimes.push(startedAt);
|
|
220
228
|
}
|
|
221
229
|
const startup = this.start();
|
|
222
230
|
const remainingForStartup = Math.max(1, deadlineAt - Date.now());
|
|
@@ -169,6 +169,55 @@ function directFile(repo, filePath) {
|
|
|
169
169
|
symbols: directSymbols(filePath, content),
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
|
+
function directoryOf(filePath) {
|
|
173
|
+
const slash = filePath.lastIndexOf("/");
|
|
174
|
+
return slash === -1 ? "." : filePath.slice(0, slash);
|
|
175
|
+
}
|
|
176
|
+
function extensionOf(name) {
|
|
177
|
+
const dot = name.lastIndexOf(".");
|
|
178
|
+
return dot > 0 ? name.slice(dot) : null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* True when every directory the aggregate covers holds only files the snapshot
|
|
182
|
+
* already knows about.
|
|
183
|
+
*
|
|
184
|
+
* The indexable extensions are taken from the snapshot's own file list rather
|
|
185
|
+
* than hardcoded, so this stays calibrated to whatever the walker actually
|
|
186
|
+
* indexed. Membership is tested against the WHOLE snapshot, not the filtered
|
|
187
|
+
* subset, so a sibling that is indexed but outside the caller's target does not
|
|
188
|
+
* read as an omission.
|
|
189
|
+
*/
|
|
190
|
+
function snapshotCoversDirectories(repo, snapshot, covered) {
|
|
191
|
+
const indexed = new Set((snapshot?.files ?? []).map((file) => file.path));
|
|
192
|
+
if (indexed.size === 0)
|
|
193
|
+
return false;
|
|
194
|
+
const extensions = new Set();
|
|
195
|
+
for (const known of indexed) {
|
|
196
|
+
const extension = extensionOf(known.slice(known.lastIndexOf("/") + 1));
|
|
197
|
+
if (extension)
|
|
198
|
+
extensions.add(extension);
|
|
199
|
+
}
|
|
200
|
+
for (const directory of new Set(covered.map((file) => directoryOf(file.path)))) {
|
|
201
|
+
let entries;
|
|
202
|
+
try {
|
|
203
|
+
entries = fs.readdirSync(path.resolve(repo, directory), { withFileTypes: true });
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
if (!entry.isFile())
|
|
210
|
+
continue;
|
|
211
|
+
const extension = extensionOf(entry.name);
|
|
212
|
+
if (!extension || !extensions.has(extension))
|
|
213
|
+
continue;
|
|
214
|
+
const relative = directory === "." ? entry.name : `${directory}/${entry.name}`;
|
|
215
|
+
if (!indexed.has(relative))
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
172
221
|
export function runStructuralFastPath(repo, pattern, target, limit = 100) {
|
|
173
222
|
const snapshot = readStructuralSnapshot(repo);
|
|
174
223
|
if (!snapshot && pattern !== "file_summary")
|
|
@@ -195,7 +244,7 @@ export function runStructuralFastPath(repo, pattern, target, limit = 100) {
|
|
|
195
244
|
let fingerprint;
|
|
196
245
|
let fresh = false;
|
|
197
246
|
if (pattern === "project_overview") {
|
|
198
|
-
|
|
247
|
+
const unchanged = matching.every((file) => {
|
|
199
248
|
try {
|
|
200
249
|
const stat = fs.statSync(path.resolve(repo, file.path));
|
|
201
250
|
return stat.size === file.sizeBytes && stat.mtimeMs === file.mtimeMs;
|
|
@@ -204,6 +253,20 @@ export function runStructuralFastPath(repo, pattern, target, limit = 100) {
|
|
|
204
253
|
return false;
|
|
205
254
|
}
|
|
206
255
|
});
|
|
256
|
+
// A size+mtime sweep can only ever prove that the files the snapshot
|
|
257
|
+
// ALREADY knows about are unchanged. A file added since the snapshot is not
|
|
258
|
+
// in that set, so it was never checked, never counted, and never mentioned
|
|
259
|
+
// — while the overview still reported itself "fresh" (KNODIN-25). So also
|
|
260
|
+
// read back the directories being aggregated and look for an indexable
|
|
261
|
+
// sibling the snapshot has never seen.
|
|
262
|
+
//
|
|
263
|
+
// Known bound: this sees additions to directories the snapshot already
|
|
264
|
+
// covers, which is where source files are overwhelmingly added. A brand new
|
|
265
|
+
// top-level directory is still missed, because recognising one would mean
|
|
266
|
+
// re-deriving the walker's prune rules here and re-walking the tree, which
|
|
267
|
+
// is the cost this fast path exists to avoid. `upgrade` already points at
|
|
268
|
+
// the full `architecture_overview` query for callers that need certainty.
|
|
269
|
+
fresh = unchanged && snapshotCoversDirectories(repo, snapshot, matching);
|
|
207
270
|
const directories = new Map();
|
|
208
271
|
for (const file of matching) {
|
|
209
272
|
const directory = file.path.includes("/") ? file.path.split("/", 1)[0] : ".";
|
|
@@ -70,10 +70,8 @@ export async function closeKnodinToolEngine() {
|
|
|
70
70
|
if (initializedEngine)
|
|
71
71
|
await initializedEngine.close();
|
|
72
72
|
initializedEngine = null;
|
|
73
|
-
compactReadyRepos.clear();
|
|
74
73
|
}
|
|
75
74
|
const localTelemetry = [];
|
|
76
|
-
const compactReadyRepos = new Set();
|
|
77
75
|
let cachedSchemaTokens;
|
|
78
76
|
function gatewaySchemaTokens() {
|
|
79
77
|
cachedSchemaTokens ??= countOutputTokens(JSON.stringify(buildDocumentedKnodinTools()[0]?.inputSchema ?? {}));
|
|
@@ -82,15 +80,24 @@ function gatewaySchemaTokens() {
|
|
|
82
80
|
function inspectGatewayGraphHealth(repo) {
|
|
83
81
|
return inspectGraphQueryHealth(repo, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })));
|
|
84
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Probe on EVERY compact call, exactly as the non-compact path does.
|
|
85
|
+
*
|
|
86
|
+
* This used to latch a repository as ready after one successful probe and never
|
|
87
|
+
* look again for the life of the process. Nothing invalidated that latch —
|
|
88
|
+
* `closeKnodinToolEngine` clears it but has no production caller — so once
|
|
89
|
+
* `.knodin/` was deleted or re-inited from another terminal, a long-lived MCP
|
|
90
|
+
* server kept answering compact queries against a database the open path had
|
|
91
|
+
* silently recreated as an empty schema (`CREATE TABLE IF NOT EXISTS`). The
|
|
92
|
+
* result was "no matches": indistinguishable from a true negative, and the exact
|
|
93
|
+
* confident-but-wrong answer this product exists to avoid (KNODIN-18).
|
|
94
|
+
*
|
|
95
|
+
* The probe is cheap — `engine.status(..., { audit: "cached" })` — so the latch
|
|
96
|
+
* was buying very little in exchange for that failure mode.
|
|
97
|
+
*/
|
|
85
98
|
async function compactGraphUnavailable(repo) {
|
|
86
|
-
const key = nodePath.resolve(repo);
|
|
87
|
-
if (compactReadyRepos.has(key))
|
|
88
|
-
return null;
|
|
89
99
|
const health = await inspectGatewayGraphHealth(repo);
|
|
90
|
-
|
|
91
|
-
return health;
|
|
92
|
-
compactReadyRepos.add(key);
|
|
93
|
-
return null;
|
|
100
|
+
return health.available ? null : health;
|
|
94
101
|
}
|
|
95
102
|
/** Runs `fn`, converting any thrown error into a structured `{ error }` result
|
|
96
103
|
* instead of letting it escape into the stdio transport. Extracted from the
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# knodin 0.11.0
|
|
2
|
+
|
|
3
|
+
Nine defects from one adversarial review of `main`, plus the two things that
|
|
4
|
+
review's own fixes exposed. The theme running through most of them is the
|
|
5
|
+
failure this tool exists to prevent: an answer that is wrong and looks fine.
|
|
6
|
+
|
|
7
|
+
## A compact answer could report "no matches" from a graph that was gone
|
|
8
|
+
|
|
9
|
+
`compactGraphUnavailable` cached a repository as ready after one successful
|
|
10
|
+
probe and never looked again for the life of the process. Nothing invalidated
|
|
11
|
+
that latch — `closeKnodinToolEngine` clears it but has no production caller — so
|
|
12
|
+
in a long-lived MCP server, deleting or re-initialising `.knodin` from another
|
|
13
|
+
terminal left compact operations answering from a database the open path had
|
|
14
|
+
silently recreated as an empty schema. `CREATE TABLE IF NOT EXISTS` does not
|
|
15
|
+
error; it just produces a graph with nothing in it.
|
|
16
|
+
|
|
17
|
+
The result was "no matches", which is indistinguishable from a true negative.
|
|
18
|
+
The non-compact path had it right all along: it re-probes before **and** after
|
|
19
|
+
every query. Compact now does the same. The probe is
|
|
20
|
+
`engine.status(..., { audit: "cached" })`, so the latch was buying very little
|
|
21
|
+
in exchange for that failure mode.
|
|
22
|
+
|
|
23
|
+
Two other things pointed the same direction and are fixed with it. The compact
|
|
24
|
+
renderer defaulted a **missing** staleness to `fresh`, the opposite of the
|
|
25
|
+
engine's own `stalenessFor()` default of `unknown` — and because search stamps
|
|
26
|
+
staleness per row while `SearchPage` carries no page-level field, a zero-row
|
|
27
|
+
page had nothing to read it from. An empty compact search on a stale or
|
|
28
|
+
unprobed graph therefore rendered as `fresh 0/0`: an authoritative-looking
|
|
29
|
+
negative assembled entirely from absent evidence. Missing evidence is now
|
|
30
|
+
`unknown`.
|
|
31
|
+
|
|
32
|
+
And the CLI's `project_overview` fast path reported `fresh` on the strength of a
|
|
33
|
+
size-and-mtime sweep over the files the snapshot **already knew about**. A file
|
|
34
|
+
added since the snapshot is not in that set, so it was never checked, never
|
|
35
|
+
counted, and never mentioned — while the overview still called itself fresh. It
|
|
36
|
+
now also reads back the directories it aggregates and looks for an indexable
|
|
37
|
+
sibling the snapshot has never seen, with the indexable extensions derived from
|
|
38
|
+
the snapshot's own file list rather than hardcoded.
|
|
39
|
+
|
|
40
|
+
That last check has a bound worth stating: it sees additions to directories the
|
|
41
|
+
snapshot already covers, which is where source files are overwhelmingly added,
|
|
42
|
+
but a brand-new top-level directory is still missed. Recognising one would mean
|
|
43
|
+
re-deriving the walker's prune rules and re-walking the tree, which is the cost
|
|
44
|
+
this fast path exists to avoid. The result's `upgrade` pointer already routes a
|
|
45
|
+
caller who needs certainty to the full `architecture_overview` query.
|
|
46
|
+
|
|
47
|
+
## The MCP worker restart limit was a permanent latch
|
|
48
|
+
|
|
49
|
+
`MAX_RESTARTS` is meant to be a rate — three crashes inside a sixty-second
|
|
50
|
+
window. It was not. `restartTimes` was pruned only in the child-exit handler,
|
|
51
|
+
and once the gate threw `KNODIN_RESTART_LIMIT` no child was ever spawned, so no
|
|
52
|
+
exit event could ever fire to prune it. The window never elapsed.
|
|
53
|
+
|
|
54
|
+
Three transient crashes — an OOM under load, a full disk, a corrupt database
|
|
55
|
+
later repaired — therefore killed that repository for the life of the gateway
|
|
56
|
+
process. The `RepositoryWorker` is cached per repository and never evicted, so
|
|
57
|
+
nothing short of restarting the whole server cleared it, and the error message
|
|
58
|
+
never said so. The gate now prunes on the way in, and names the window.
|
|
59
|
+
|
|
60
|
+
## One crash killed a parse-pool lane for the whole run
|
|
61
|
+
|
|
62
|
+
`PoolWorkerHandle` has no listener removal, so a replaced worker's listeners
|
|
63
|
+
stay live — and they closed over the *slot*, whose `.handle` is by then the
|
|
64
|
+
replacement. A crashed worker's inevitable late `exit` re-entered
|
|
65
|
+
`onSlotFailure` and terminated its own replacement, cancelling the one
|
|
66
|
+
documented retry and leaving the lane dead. On a single-worker pool the whole
|
|
67
|
+
run degraded to sequential inline parsing; if the replacement's response landed
|
|
68
|
+
first, the late exit instead killed an unrelated in-flight file.
|
|
69
|
+
|
|
70
|
+
Listeners are now bound to the handle they were registered on, mirroring the
|
|
71
|
+
`pending.child !== child` guard the MCP supervisor already carried.
|
|
72
|
+
|
|
73
|
+
Worth recording how this survived review: `FakeWorker` in `parse-pool.spec.ts`
|
|
74
|
+
never emitted `exit` — not after an error, not on `terminate()`. The pool never
|
|
75
|
+
saw the second event, so the bug had no way to appear. With realistic process
|
|
76
|
+
semantics restored, removing the new guard fails the new test **and** the
|
|
77
|
+
pre-existing "retries a crashed task once" test. That retry path was dead code
|
|
78
|
+
against real workers, and had been asserted as working.
|
|
79
|
+
|
|
80
|
+
## Secrets spelled the way secrets are actually spelled
|
|
81
|
+
|
|
82
|
+
The shared redaction pattern began with `\b`, which cannot match between `_` and
|
|
83
|
+
a letter. So it caught a bare `password=` and let through
|
|
84
|
+
`AWS_SECRET_ACCESS_KEY=`, `DB_PASSWORD=`, `NPM_TOKEN=`, and every `*_SECRET=` —
|
|
85
|
+
the dominant real-world spelling, and the one that appears in a `.env` line
|
|
86
|
+
quoted into an error message or a CI log dump. Both disclosure paths, the
|
|
87
|
+
diagnostics bundle and the compressed output artifact, drew on that one pattern,
|
|
88
|
+
so both leaked.
|
|
89
|
+
|
|
90
|
+
The keyword now takes a `[\w-]*` prefix and a `[_-]`-led suffix, so the whole
|
|
91
|
+
identifier matches and the name is preserved while the value is not. The suffix
|
|
92
|
+
is `[_-]`-led rather than a bare `[\w-]*` deliberately: knodin echoes source
|
|
93
|
+
into both artifacts, and an unanchored tail redacted the value of a plain
|
|
94
|
+
`const tokenCount = 5`.
|
|
95
|
+
|
|
96
|
+
Not added, deliberately: a standalone value-shape pattern for AWS secret keys.
|
|
97
|
+
Those are `[A-Za-z0-9/+]{40}`, which also matches every 40-character git SHA and
|
|
98
|
+
any similar-length base64 blob. A context-free pattern would redact commit
|
|
99
|
+
hashes throughout diagnostics, and every realistic carrier of that value — an
|
|
100
|
+
environment variable, a credentials file — is name-prefixed and now covered.
|
|
101
|
+
|
|
102
|
+
## A sealed artifact could serve source for a checkout it does not describe
|
|
103
|
+
|
|
104
|
+
Sealed artifacts are designed to be shared across machines, which makes an
|
|
105
|
+
artifact untrusted input. Its attestation was read with a bare
|
|
106
|
+
`JSON.parse(...) as SealAttestation` — an unchecked cast — and the
|
|
107
|
+
self-declared `repository.path` was registered into a process-global map that
|
|
108
|
+
every engine source read consults **before** the filesystem. A crafted artifact
|
|
109
|
+
naming a victim's real checkout path could therefore serve its own bytes to a
|
|
110
|
+
concurrent live query on that repository.
|
|
111
|
+
|
|
112
|
+
The attestation is now validated as a whole shape, and a database that
|
|
113
|
+
misrepresents itself raises a typed error rather than being quietly downgraded
|
|
114
|
+
to "not a sealed artifact". The attested path is refused on **content
|
|
115
|
+
disagreement** rather than on mere existence: sealing and then querying on the
|
|
116
|
+
same checkout is a real flow that an existence check would break, so instead
|
|
117
|
+
every embedded file that also exists under the attested directory is compared
|
|
118
|
+
against the hash recorded at seal time. Files absent there are not disagreement
|
|
119
|
+
— a removed checkout is exactly what an artifact is for.
|
|
120
|
+
|
|
121
|
+
Identity and `sealedCommit` are deliberately **not** used for this. Both are
|
|
122
|
+
attacker-supplied strings, and an attacker targeting a specific victim already
|
|
123
|
+
knows their `owner/name`. Content hashes are the only fingerprint in the
|
|
124
|
+
attestation an attacker cannot satisfy while still doing harm: to poison a live
|
|
125
|
+
checkout the embedded bytes must differ from the real files, which is precisely
|
|
126
|
+
what trips the gate.
|
|
127
|
+
|
|
128
|
+
Registrations are also refcounted now. Two artifacts attesting the same path
|
|
129
|
+
used to have the first `close()` unregister the other's source, leaving it
|
|
130
|
+
reading `""`.
|
|
131
|
+
|
|
132
|
+
## A repo-wide text search held the whole working tree in memory
|
|
133
|
+
|
|
134
|
+
`searchRepoText` read every readable file's full content into one array before
|
|
135
|
+
searching any of it, with no size cap, and the binary NUL-sniff happened *after*
|
|
136
|
+
the read rather than before. The walk deliberately skips the source-extension
|
|
137
|
+
filter, so lockfiles, NDJSON dumps, fixtures and generated JSON all came in
|
|
138
|
+
whole. On a tree carrying gigabytes of non-pruned text, one call drove RSS to
|
|
139
|
+
the total text size. The response budget could not help: it applies to a result
|
|
140
|
+
object that by then already exists.
|
|
141
|
+
|
|
142
|
+
Files are now stat-checked and skipped above 4 MiB — reported through the same
|
|
143
|
+
`uncovered` list, never dropped — and searched one at a time, so at most one
|
|
144
|
+
file's content is live. Matches are capped at 5,000 with a new `truncated` flag,
|
|
145
|
+
documented as meaning the remaining files were **not** searched, so an absent
|
|
146
|
+
file proves nothing.
|
|
147
|
+
|
|
148
|
+
## The background refresh script deleted locks it did not own
|
|
149
|
+
|
|
150
|
+
The generated `background-index.sh` trapped `HUP`/`INT`/`TERM` with a handler
|
|
151
|
+
that removed the lock directory and did not exit. POSIX `sh` defers a trap until
|
|
152
|
+
the foreground command finishes and then resumes where it left off — so when
|
|
153
|
+
`init` sent `SIGTERM` at its thirty-second drain timeout, the script freed the
|
|
154
|
+
lock and **kept draining**. The next commit's instance acquired the now-free
|
|
155
|
+
lock, and the first script's eventual `EXIT` trap deleted its successor's lock
|
|
156
|
+
unconditionally, because that `rm -rf` had no ownership check. Stale-lock
|
|
157
|
+
takeover was also `rm -rf` followed by `mkdir`, which two processes observing
|
|
158
|
+
the same dead pid could both complete.
|
|
159
|
+
|
|
160
|
+
Release is now guarded on the recorded pid, each signal exits explicitly, and
|
|
161
|
+
takeover claims the stale lock by renaming it, which only one racer can win. The
|
|
162
|
+
inner repair lease kept this away from database corruption; what it produced was
|
|
163
|
+
spurious failure markers, retained events, and `Ctrl-C` failing to cancel a
|
|
164
|
+
foreground run.
|
|
165
|
+
|
|
166
|
+
## Each commit is now keyed to its own Jira
|
|
167
|
+
|
|
168
|
+
`scripts/jira-work-guard.sh` required every non-merge commit to carry the branch
|
|
169
|
+
name's **first** Jira key, which made a branch equivalent to exactly one issue.
|
|
170
|
+
Work spanning several tickets then had two options and both lose something: land
|
|
171
|
+
it as one commit labelled with a ticket covering a fraction of it, or split it
|
|
172
|
+
into one branch per ticket and multiply CI runs for a single coherent change.
|
|
173
|
+
This release hit exactly that — nine fixes from one review.
|
|
174
|
+
|
|
175
|
+
A branch is a container for work. It still has to be Jira-backed, but the
|
|
176
|
+
per-issue claim now belongs to each commit, which is the more durable record
|
|
177
|
+
anyway: branch names are deleted after merge and commit subjects are not.
|
|
178
|
+
`pre-push` also names the offending commit's short SHA now, because a push
|
|
179
|
+
carries many commits and "commit message has no JIRA key" left the author
|
|
180
|
+
bisecting their own branch.
|
|
181
|
+
|
|
182
|
+
The trade-off is real and worth naming: this drops the check that caught a
|
|
183
|
+
commit keyed to an unrelated project — a typo, or a message copied from another
|
|
184
|
+
branch. Restoring it would mean validating keys against Jira, which this guard
|
|
185
|
+
deliberately does not do. It is a local hook with no network.
|
|
186
|
+
|
|
187
|
+
## Timing budgets in the test suite were measuring the scheduler
|
|
188
|
+
|
|
189
|
+
Found because this release's own two new spec files made the suite fail, twice,
|
|
190
|
+
on two different tests that had nothing to do with the changes.
|
|
191
|
+
|
|
192
|
+
Eleven specs independently hard-coded a fifteen-second budget for a spawned
|
|
193
|
+
child process. Shard 12 also holds `behavior-contract.spec.ts`, which runs
|
|
194
|
+
CPU-bound for about 380 seconds — 85% of that shard's wall time. A child spawned
|
|
195
|
+
beside it is starved, not slow: a CLI spawn costs about 0.4s idle and a progress
|
|
196
|
+
worker about 1.7s, so fifteen seconds looks like a 40x margin right up until
|
|
197
|
+
contention eats it whole.
|
|
198
|
+
|
|
199
|
+
Which spec pays is decided by where vitest queues its file, and that moves
|
|
200
|
+
whenever any spec file is added anywhere in the repository. Measured on an
|
|
201
|
+
uninstrumented `npm test`, same host, identical shard membership: the progress
|
|
202
|
+
worker started in 1.7s queued at t=28s, and blew the whole fifteen seconds
|
|
203
|
+
queued at t=408s. The shard's total wall time was unchanged (454s against 448s
|
|
204
|
+
before the change) and `behavior-contract` was in fact 8s faster, so this was
|
|
205
|
+
never added load — separately confirmed by measuring CLI spawn cost directly at
|
|
206
|
+
367–468ms against 374–571ms before.
|
|
207
|
+
|
|
208
|
+
`SUBPROCESS_BUDGET_MS` now lives once, in the support helper those specs already
|
|
209
|
+
import, carrying that measurement. It is unconditional: two specs had already
|
|
210
|
+
been widened only under `KNODIN_COVERAGE_SHARD`, on the theory that
|
|
211
|
+
instrumentation causes the contention. It does not — the sixteen concurrent
|
|
212
|
+
shards do, in uninstrumented runs too — and gating on that is why this kept
|
|
213
|
+
being rediscovered one file at a time. The budget is an upper bound, not a
|
|
214
|
+
sleep, so it costs nothing in the passing case and no assertion is weakened.
|
|
215
|
+
|
|
216
|
+
One thing this does not fix, recorded so it is not rediscovered: a single spec
|
|
217
|
+
at ~380 seconds sets the critical path for the entire gate and is what starves
|
|
218
|
+
everything scheduled beside it. Widening budgets treats the symptom.
|
|
219
|
+
|
|
220
|
+
## Verification
|
|
221
|
+
|
|
222
|
+
`npm test` (sixteen shards, 2,088 tests), `npm run lint`, `npm run typecheck`,
|
|
223
|
+
`npm run check:hygiene`, and `npm run test:pack-install` pass. Every fix above
|
|
224
|
+
is pinned by a test, including two new specs —
|
|
225
|
+
`background-refresh-lock.spec.ts` and `compact-graph-availability.spec.ts` — and
|
|
226
|
+
new cases in the credential-parity, supervisor, parse-pool, structural-fast-path,
|
|
227
|
+
sealed-open, text-matches and jira-work-guard specs.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knodin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"knodin": {
|
|
5
|
-
"compatibility": "
|
|
5
|
+
"compatibility": "breaking"
|
|
6
6
|
},
|
|
7
7
|
"description": "knodin — source-evidenced local code intelligence with known bounds. Stable identity, fresh evidence, truthful budgets, and recoverable bounded views.",
|
|
8
8
|
"license": "MIT",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"docs/releases/0.10.6.md",
|
|
64
64
|
"docs/releases/0.10.7.md",
|
|
65
65
|
"docs/releases/0.10.8.md",
|
|
66
|
+
"docs/releases/0.11.0.md",
|
|
66
67
|
"docs/releases/0.3.0.md",
|
|
67
68
|
"docs/releases/0.4.0.md",
|
|
68
69
|
"docs/releases/0.4.1.md",
|