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.
@@ -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 === "unknown")
19
- return "unknown";
20
- return "fresh";
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: /\b(authorization|password|passwd|secret|token|api[_-]?key)(\s*[:=]\s*)(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi,
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
- slot.handle.onMessage((response) => this.onResponse(slot, response));
151
- slot.handle.onError((error) => this.onSlotFailure(slot, error.message));
152
- slot.handle.onExit((code) => {
153
- if (slot.inFlight)
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
  }
@@ -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
- return row ? JSON.parse(row.value) : null;
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
- /** True when this database carries embedded source, i.e. can answer with no checkout. */
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
- return readSealAttestation(db) !== null;
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
- const attestation = readSealAttestation(db);
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
- if (attestedPath && path.resolve(attestedPath) !== path.resolve(mountDir))
90
- registerSealedRepository(attestedPath, db);
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
- unregisterSealedRepository(mountDir);
104
- if (attestedPath)
105
- unregisterSealedRepository(attestedPath);
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 caseSensitive = options.caseSensitive ?? true;
168
- const matches = [];
169
- const uncovered = [];
170
- let filesSearched = 0;
185
+ const state = newSearchState();
171
186
  for (const { file, content } of files) {
172
- const raw = findOccurrences(content, term, caseSensitive);
173
- filesSearched++;
174
- if (raw.length === 0)
175
- continue;
176
- let classified = null;
177
- let language = null;
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 = await loadLanguage(file);
222
+ parser.setLanguage(language);
223
+ classified = classifyAgainstTree(parser, content, raw);
180
224
  }
181
- catch {
182
- // A grammar that will not load costs classification, not the match.
183
- language = null;
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
- if (language) {
186
- const parser = newParser();
236
+ finally {
187
237
  try {
188
- parser.setLanguage(language);
189
- classified = classifyAgainstTree(parser, content, raw);
238
+ parser.delete();
190
239
  }
191
- catch (error) {
192
- if (isWasmAbort(error))
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
- const starts = lineStarts(content);
212
- raw.forEach((match, position) => {
213
- const lineIndex = lineIndexFor(starts, match.startIndex);
214
- const lineStart = starts[lineIndex];
215
- const nextStart = starts[lineIndex + 1] ?? content.length + 1;
216
- const node = classified?.[position] ?? null;
217
- matches.push({
218
- file,
219
- line: lineIndex + 1,
220
- column: match.startIndex - lineStart + 1,
221
- startIndex: match.startIndex,
222
- endIndex: match.endIndex,
223
- lineText: content.slice(lineStart, Math.max(lineStart, nextStart - 1)).replace(/\r$/, ""),
224
- matchedText: match.matchedText,
225
- caseMatchesQuery: match.matchedText === term,
226
- withinLargerWord: isWordCharacter(content[match.startIndex - 1]) ||
227
- isWordCharacter(content[match.endIndex]),
228
- classification: node ? node.classification : classifyByFileType(file),
229
- basis: node ? "parse-node" : language ? "none" : "file-type",
230
- nodeType: node ? node.nodeType : null,
231
- enclosingText: node ? node.enclosingText : null,
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 readable = [];
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
- return { ...result, uncovered: [...uncovered, ...result.uncovered] };
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 "$LOCK_DIR"
482
- mkdir "$LOCK_DIR" 2>/dev/null || exit 0
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
- trap 'rm -rf "$LOCK_DIR"; rm -f "$FAILURE_TMP"' EXIT HUP INT TERM
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", "graph worker restart limit reached");
219
- this.restartTimes.push(Date.now());
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
- fresh = matching.every((file) => {
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
- if (!health.available)
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.10.8",
3
+ "version": "0.11.0",
4
4
  "knodin": {
5
- "compatibility": "compatible"
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",