knodin 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/cli.js CHANGED
@@ -27,7 +27,7 @@ import { exportContext, grepPackedArtifact, readPackedArtifact } from "../src/co
27
27
  import { clearDiagnostics, collectDiagnostics, diagnosticsStatus, disableDiagnostics, enableDiagnostics, inspectDiagnosticsBundle, persistDiagnosticsPreview, recordDiagnosticFailure, } from "../src/diagnostics.js";
28
28
  import { getDocSection, listDocTopics } from "../src/docs-sections.js";
29
29
  import { diagnoseInstallation } from "../src/doctor.js";
30
- import { createEngine, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../src/engine/index.js";
30
+ import { createEngine, describeThrown, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../src/engine/index.js";
31
31
  import { runSeal } from "../src/engine/seal-command.js";
32
32
  import { runSealedQuery } from "../src/engine/sealed-query.js";
33
33
  import { resolveDbPath } from "../src/engine/state-paths.js";
@@ -199,10 +199,16 @@ function formatCoverageGaps(skipped) {
199
199
  if (byExtension)
200
200
  clauses.push(`not indexed: ${byExtension}`);
201
201
  const unparsed = formatTally(skipped.unparsedByExtension);
202
+ // "not parsed", not "parsed but empty". Nothing in this tally was parsed:
203
+ // it collects files with no tree-sitter grammar, minified sources, and files
204
+ // whose indexing threw. Calling that "parsed but empty" asserts the parser
205
+ // looked and found nothing, which is the one thing it did not do — and it
206
+ // made a real outage unreadable, since a dead wasm module dumped thousands
207
+ // of never-parsed files into this bucket under a label saying they were fine.
202
208
  if (unparsed)
203
- clauses.push(`parsed but empty: ${unparsed}`);
209
+ clauses.push(`not parsed: ${unparsed}`);
204
210
  if (skipped.unparsedUnknown)
205
- clauses.push("parsed-but-empty tally unavailable — run a full index; this is a lower bound");
211
+ clauses.push("not-parsed tally unavailable — run a full index; this is a lower bound");
206
212
  const count = skipped.unparsedUnknown ? `${skipped.total}+` : `${skipped.total}`;
207
213
  return `. Not in graph: ${count} files (${clauses.join("; ")})`;
208
214
  }
@@ -2124,17 +2130,27 @@ async function main() {
2124
2130
  const agents = scope === "team" ? [] : integrationAgents(repo);
2125
2131
  const paths = await initializeRepository(repo, {
2126
2132
  command: runtimeCommand,
2127
- index: indexOrSeed(engine, KNODIN_SCHEMA_VERSION),
2133
+ // Not `indexOrSeed`: seeding exists for a worktree with no database,
2134
+ // and this command has already refused to run without one. Indexing
2135
+ // the written files directly is the whole point of the narrow scope.
2136
+ index: (target, indexOptions, files) => engine.index(target, files, false, indexOptions),
2137
+ indexScope: "configuration",
2128
2138
  scope,
2129
2139
  agents,
2130
2140
  allowTrackedTransition: true,
2131
2141
  auditConfigurationChanges: true,
2132
2142
  configureIntegration: true,
2133
2143
  });
2144
+ const indexed = paths.scopedIndexPaths ?? [];
2134
2145
  result = {
2135
2146
  status: "success",
2136
2147
  message: `knodin agent integration changed to ${scope}.`,
2137
- graphInitialization: "refreshed",
2148
+ // Say what happened. Claiming "refreshed" after touching a few
2149
+ // configuration files misdescribes the graph's state, and did so most
2150
+ // misleadingly on a repository whose graph was in fact empty.
2151
+ graphInitialization: indexed.length === 0
2152
+ ? "unchanged — no configuration files needed indexing"
2153
+ : `configuration files indexed (${indexed.length})`,
2138
2154
  nextAction: "run `knodin status` and reload the configured client",
2139
2155
  paths,
2140
2156
  };
@@ -2985,7 +3001,7 @@ catch (err) {
2985
3001
  error: err,
2986
3002
  });
2987
3003
  const correlation = diagnostic.recorded ? ` [diagnostic ${diagnostic.correlationId}]` : "";
2988
- console.error(`${err instanceof Error ? err.message : String(err)}${correlation}`);
3004
+ console.error(`${describeThrown(err)}${correlation}`);
2989
3005
  process.exit(1);
2990
3006
  }
2991
3007
  }
@@ -4938,13 +4938,22 @@ async function indexRazorFile(content, relativePath, _repoPath, db) {
4938
4938
  const parser = new Parser();
4939
4939
  parser.setLanguage(language);
4940
4940
  const tree = parser.parse(projected);
4941
- if (!tree)
4941
+ if (!tree) {
4942
+ parser.delete();
4942
4943
  return;
4944
+ }
4943
4945
  // `filePath` drives the extractor's language-specific branches, so it must
4944
4946
  // look like C#; the rows are persisted against the real Razor path below.
4945
- const extracted = extractSymbolsAndReferences(tree.rootNode, false, false, "razor-projection.cs");
4946
- tree.delete();
4947
- parser.delete();
4947
+ // The `finally` matters: a throw out of the extractor previously leaked both
4948
+ // objects, on exactly the malformed inputs most likely to throw.
4949
+ let extracted;
4950
+ try {
4951
+ extracted = extractSymbolsAndReferences(tree.rootNode, false, false, "razor-projection.cs");
4952
+ }
4953
+ finally {
4954
+ freeQuietly(tree);
4955
+ freeQuietly(parser);
4956
+ }
4948
4957
  // The synthetic wrapper class exists only to make members parseable.
4949
4958
  const definitions = extracted.definitions.filter((def) => def.name !== "__RazorCode");
4950
4959
  db.run("BEGIN TRANSACTION;");
@@ -6178,6 +6187,58 @@ const INDEX_WRITE_BATCH_SIZE = (() => {
6178
6187
  const raw = Number(process.env.KNODIN_INDEX_WRITE_BATCH_SIZE);
6179
6188
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 200;
6180
6189
  })();
6190
+ /**
6191
+ * True for an Emscripten abort: the wasm module itself calling `exit()`.
6192
+ *
6193
+ * This is categorically different from "this file did not parse" and must never
6194
+ * be handled by a per-file recovery path. The module does not come back — every
6195
+ * subsequent parse, even on a freshly constructed `Parser`, fails with the same
6196
+ * error. Treating it per-file turns one real failure into thousands of
6197
+ * misattributed ones against innocent files, and still reports the run as
6198
+ * successful, so the graph ends up quietly missing everything after the abort.
6199
+ */
6200
+ export function isWasmAbort(error) {
6201
+ return (typeof error === "object" &&
6202
+ error !== null &&
6203
+ error.name === "ExitStatus");
6204
+ }
6205
+ /**
6206
+ * Render any throwable as a message, including one that is not an `Error`.
6207
+ *
6208
+ * `error instanceof Error ? error.message : String(error)` is the reflex, and it
6209
+ * is wrong for exactly the failure this release exists to surface: Emscripten's
6210
+ * `ExitStatus` is a plain object carrying `name`/`message`/`status`, so the
6211
+ * ternary falls through to `String(error)` and prints `[object Object]` —
6212
+ * destroying the diagnosis at the last step, in the CLI's top-level handler and
6213
+ * in the parse worker's hand-serialization alike.
6214
+ */
6215
+ export function describeThrown(error) {
6216
+ if (error instanceof Error)
6217
+ return error.message;
6218
+ if (typeof error === "object" && error !== null) {
6219
+ const { name, message } = error;
6220
+ if (typeof message === "string" && message.length > 0)
6221
+ return typeof name === "string" && name.length > 0 ? `${name}: ${message}` : message;
6222
+ }
6223
+ return String(error);
6224
+ }
6225
+ /**
6226
+ * Free a wasm object without letting the free itself throw.
6227
+ *
6228
+ * Used only where the free sits in a `finally`. If the module has aborted, its
6229
+ * heap is already gone and `delete()` throws too — from a `finally` that
6230
+ * replaces the in-flight error with a second, less informative one, losing the
6231
+ * cause at exactly the moment it matters. There is nothing to reclaim from a
6232
+ * dead module anyway, so dropping that failure loses nothing real.
6233
+ */
6234
+ function freeQuietly(target) {
6235
+ try {
6236
+ target.delete();
6237
+ }
6238
+ catch {
6239
+ // Intentionally ignored; see above.
6240
+ }
6241
+ }
6181
6242
  /**
6182
6243
  * Read, parse, and extract one file through the generic tree-sitter path.
6183
6244
  *
@@ -6212,40 +6273,78 @@ export async function extractGenericFileForIndex(absolutePath, relativePath, rep
6212
6273
  tree = measurePerfPhaseSync("parse_extract", () => parser.parse(parseContent));
6213
6274
  }
6214
6275
  catch (e) {
6276
+ // Rethrow BEFORE attempting to free: once the module has aborted its heap
6277
+ // is gone, there is nothing left to reclaim, and calling delete() would
6278
+ // throw a second ExitStatus that replaces the real cause.
6279
+ if (isWasmAbort(e))
6280
+ throw e;
6281
+ // Otherwise free before returning. Skipping one file must not also cost a
6282
+ // parser, or a repository full of pathological inputs would leak its way
6283
+ // to an abort through the recovery path itself.
6284
+ freeQuietly(parser);
6215
6285
  console.warn(`knodin: skipped ${relativePath} (parser error: ${e.message})`);
6216
6286
  return { kind: "skipped", reason: e.message };
6217
6287
  }
6218
- if (!tree)
6288
+ if (!tree) {
6289
+ freeQuietly(parser);
6219
6290
  return { kind: "skipped", reason: "parser returned no tree" };
6220
- const mcpRegistrations = /\.[cm]?[jt]sx?$/.test(absolutePath) && /@modelcontextprotocol\/sdk/.test(content)
6221
- ? extractMcpToolRegistrations(content, relativePath, tree.rootNode)
6222
- : [];
6223
- const isPython = absolutePath.endsWith(".py");
6224
- const isApex = absolutePath.endsWith(".cls") || absolutePath.endsWith(".trigger");
6225
- const isVisualforce = absolutePath.endsWith(".page") || absolutePath.endsWith(".component");
6226
- const isPrisma = absolutePath.endsWith(".prisma");
6227
- const isWorkday = absolutePath.endsWith(".clp") ||
6228
- absolutePath.endsWith(".ws") ||
6229
- (absolutePath.endsWith(".xml") && isWorkdayStudioFile(content, absolutePath));
6230
- const special = (route) => ({
6231
- kind: "special",
6232
- route,
6233
- content,
6234
- parseContent,
6235
- tree: tree,
6236
- });
6237
- if (isWorkday)
6238
- return special("workday");
6239
- if (isVisualforce)
6240
- return special("visualforce");
6241
- if (isSql)
6242
- return special("sql");
6243
- if (isPrisma)
6244
- return special("prisma");
6245
- return {
6246
- kind: "result",
6247
- result: buildFileIndexResult(tree, content, isPython, isApex, absolutePath, relativePath, repoPath, mcpRegistrations),
6248
- };
6291
+ }
6292
+ // Everything from here on can throw — `extractMcpToolRegistrations` and the
6293
+ // symbol extractor both walk attacker-shaped input — and this runs once per
6294
+ // file, so a leak on the throwing path scales with repository size exactly
6295
+ // like the leak this whole change removes. The `finally` is the guarantee;
6296
+ // `handedOff` is the one case where the tree deliberately escapes.
6297
+ let handedOff = false;
6298
+ try {
6299
+ const mcpRegistrations = /\.[cm]?[jt]sx?$/.test(absolutePath) && /@modelcontextprotocol\/sdk/.test(content)
6300
+ ? extractMcpToolRegistrations(content, relativePath, tree.rootNode)
6301
+ : [];
6302
+ const isPython = absolutePath.endsWith(".py");
6303
+ const isApex = absolutePath.endsWith(".cls") || absolutePath.endsWith(".trigger");
6304
+ const isVisualforce = absolutePath.endsWith(".page") || absolutePath.endsWith(".component");
6305
+ const isPrisma = absolutePath.endsWith(".prisma");
6306
+ const isWorkday = absolutePath.endsWith(".clp") ||
6307
+ absolutePath.endsWith(".ws") ||
6308
+ (absolutePath.endsWith(".xml") && isWorkdayStudioFile(content, absolutePath));
6309
+ const special = (route) => {
6310
+ handedOff = true;
6311
+ freeQuietly(parser);
6312
+ return {
6313
+ kind: "special",
6314
+ route,
6315
+ content,
6316
+ parseContent,
6317
+ tree: tree,
6318
+ };
6319
+ };
6320
+ // The parser has done its job by here and nothing downstream needs it: a
6321
+ // `Tree` owns its own wasm memory and stays readable after its parser is
6322
+ // freed (pinned by parser-lifetime.spec.ts, because a use-after-free here
6323
+ // would surface as corrupt nodes rather than a crash). Freeing it at the
6324
+ // point of return is what makes the escaping-tree routes below safe to
6325
+ // hand out — the caller then owns exactly one object, the tree.
6326
+ if (isWorkday)
6327
+ return special("workday");
6328
+ if (isVisualforce)
6329
+ return special("visualforce");
6330
+ if (isSql)
6331
+ return special("sql");
6332
+ if (isPrisma)
6333
+ return special("prisma");
6334
+ // `FileIndexResult` is plain data — it must be, since it is structured-cloned
6335
+ // out of a parse worker — so both objects are dead the moment it is built,
6336
+ // and the `finally` below frees them.
6337
+ return {
6338
+ kind: "result",
6339
+ result: buildFileIndexResult(tree, content, isPython, isApex, absolutePath, relativePath, repoPath, mcpRegistrations),
6340
+ };
6341
+ }
6342
+ finally {
6343
+ if (!handedOff) {
6344
+ freeQuietly(tree);
6345
+ freeQuietly(parser);
6346
+ }
6347
+ }
6249
6348
  }
6250
6349
  /**
6251
6350
  * The single source of truth for indexer routing.
@@ -6405,20 +6504,35 @@ async function indexFile(absolutePath, relativePath, repoPath, db, unparsed, col
6405
6504
  return;
6406
6505
  if (extraction.kind === "special") {
6407
6506
  const { content, parseContent, tree } = extraction;
6408
- switch (extraction.route) {
6409
- case "workday":
6410
- await indexWorkdayFile(content, tree, relativePath, repoPath, db);
6411
- return;
6412
- case "visualforce":
6413
- await indexVisualforceFile(content, tree, relativePath, repoPath, db);
6414
- return;
6415
- case "sql":
6416
- await indexSqlFile(parseContent, tree, relativePath, repoPath, db);
6417
- return;
6418
- case "prisma":
6419
- await indexPrismaFile(content, tree, relativePath, repoPath, db);
6420
- return;
6507
+ // The tree outlived the function that parsed it, so this is the only
6508
+ // place that can free it. `finally` rather than a delete per branch,
6509
+ // because a throw inside an indexer would otherwise leak silently on
6510
+ // exactly the inputs most likely to throw.
6511
+ //
6512
+ // The cases `break` rather than `return` so the single exit below is
6513
+ // reachable. Returning from inside the `try` left that trailing
6514
+ // statement unreachable — dead code the compiler flagged and coverage
6515
+ // could never account for.
6516
+ try {
6517
+ switch (extraction.route) {
6518
+ case "workday":
6519
+ await indexWorkdayFile(content, tree, relativePath, repoPath, db);
6520
+ break;
6521
+ case "visualforce":
6522
+ await indexVisualforceFile(content, tree, relativePath, repoPath, db);
6523
+ break;
6524
+ case "sql":
6525
+ await indexSqlFile(parseContent, tree, relativePath, repoPath, db);
6526
+ break;
6527
+ case "prisma":
6528
+ await indexPrismaFile(content, tree, relativePath, repoPath, db);
6529
+ break;
6530
+ }
6421
6531
  }
6532
+ finally {
6533
+ freeQuietly(tree);
6534
+ }
6535
+ return;
6422
6536
  }
6423
6537
  if (collectResult) {
6424
6538
  collectResult(extraction.result);
@@ -6428,6 +6542,13 @@ async function indexFile(absolutePath, relativePath, repoPath, db, unparsed, col
6428
6542
  }
6429
6543
  }
6430
6544
  catch (error) {
6545
+ // A dead wasm module is not this file's fault and is not survivable.
6546
+ // Swallowing it here is what made one abort look like thousands of
6547
+ // broken files followed by a successful run: every file after the abort
6548
+ // failed identically, got tallied as a coverage gap, and the missing
6549
+ // symbols became indistinguishable from files that genuinely have none.
6550
+ if (isWasmAbort(error))
6551
+ throw error;
6431
6552
  console.error(`Error indexing file ${relativePath}:`, error);
6432
6553
  if (unparsed)
6433
6554
  tallyOne(unparsed, extensionBucket(relativePath));
@@ -7790,11 +7911,16 @@ export function getWatcherQueueStats(repoPath) {
7790
7911
  generationAdvances: 0,
7791
7912
  };
7792
7913
  }
7793
- /** Inject one queue failure for the R45 recovery regression test. */
7794
- export function failNextWatcherFlush(repoPath) {
7914
+ /**
7915
+ * Inject one queue failure for the R45 recovery regression test.
7916
+ *
7917
+ * `kind: "wasm-abort"` injects an Emscripten-shaped abort instead of an
7918
+ * ordinary error, which the flush must treat as terminal rather than retryable.
7919
+ */
7920
+ export function failNextWatcherFlush(repoPath, kind = "error") {
7795
7921
  const queue = watchQueues.get(path.resolve(repoPath));
7796
7922
  if (queue)
7797
- queue.failNextFlush = true;
7923
+ queue.failNextFlush = kind;
7798
7924
  }
7799
7925
  /** Hold one queue flush at a deterministic boundary for R45 backpressure tests. */
7800
7926
  export function gateNextWatcherFlushForTests(repoPath, gate) {
@@ -8210,7 +8336,18 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
8210
8336
  if (testGate)
8211
8337
  await testGate;
8212
8338
  if (queue.failNextFlush) {
8339
+ const injected = queue.failNextFlush;
8213
8340
  queue.failNextFlush = false;
8341
+ if (injected === "wasm-abort") {
8342
+ // Deliberately NOT an Error: the real ExitStatus is a plain object,
8343
+ // and an Error-shaped stand-in would let a naive `instanceof Error`
8344
+ // check pass and hide the very bug this pins.
8345
+ throw Object.assign(Object.create(null), {
8346
+ name: "ExitStatus",
8347
+ message: "Program terminated with exit(1)",
8348
+ status: 1,
8349
+ });
8350
+ }
8214
8351
  throw new Error("injected watcher flush failure");
8215
8352
  }
8216
8353
  for (const relativePath of paths) {
@@ -8238,6 +8375,16 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
8238
8375
  catch (error) {
8239
8376
  queue.errors++;
8240
8377
  console.error(`Error flushing watcher updates for ${resolvedRepoPath}:`, error);
8378
+ if (isWasmAbort(error)) {
8379
+ // Unrecoverable and process-wide: re-queueing would retry the same
8380
+ // files every debounce interval, forever, each pass failing at the
8381
+ // first parse and logging again. Stop the watcher instead — the
8382
+ // graph is stale from here and only a fresh process can fix it.
8383
+ queue.closed = true;
8384
+ queue.pending.clear();
8385
+ watcherStates.set(resolvedRepoPath, { watcher: "disabled" });
8386
+ break;
8387
+ }
8241
8388
  for (const relativePath of paths)
8242
8389
  queue.pending.add(relativePath);
8243
8390
  if (!queue.closed) {
@@ -8732,23 +8879,34 @@ export async function getOrInitDb(repoPath, options = {}) {
8732
8879
  tree = parser.parse(source);
8733
8880
  }
8734
8881
  catch {
8882
+ parser.delete();
8735
8883
  backfillComplete = false;
8736
8884
  continue;
8737
8885
  }
8738
- if (!tree)
8886
+ if (!tree) {
8887
+ parser.delete();
8739
8888
  continue;
8740
- db.run("DELETE FROM mcp_tools WHERE filePath = ?", [filePath]);
8741
- for (const registration of extractMcpToolRegistrations(source, filePath, tree.rootNode)) {
8742
- db.run("INSERT INTO mcp_tools(name, description, schemaSymbol, handlerSymbol, filePath, line, confidence, associationKey) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
8743
- registration.name,
8744
- registration.description ?? null,
8745
- registration.schemaSymbol ?? null,
8746
- registration.handlerSymbol ?? null,
8747
- filePath,
8748
- registration.line,
8749
- registration.confidence,
8750
- registration.associationKey ?? null,
8751
- ]);
8889
+ }
8890
+ // This loop runs once per indexed file, so anything it fails to
8891
+ // free scales with repository size.
8892
+ try {
8893
+ db.run("DELETE FROM mcp_tools WHERE filePath = ?", [filePath]);
8894
+ for (const registration of extractMcpToolRegistrations(source, filePath, tree.rootNode)) {
8895
+ db.run("INSERT INTO mcp_tools(name, description, schemaSymbol, handlerSymbol, filePath, line, confidence, associationKey) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
8896
+ registration.name,
8897
+ registration.description ?? null,
8898
+ registration.schemaSymbol ?? null,
8899
+ registration.handlerSymbol ?? null,
8900
+ filePath,
8901
+ registration.line,
8902
+ registration.confidence,
8903
+ registration.associationKey ?? null,
8904
+ ]);
8905
+ }
8906
+ }
8907
+ finally {
8908
+ freeQuietly(tree);
8909
+ freeQuietly(parser);
8752
8910
  }
8753
8911
  }
8754
8912
  catch {
@@ -11874,7 +12032,15 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
11874
12032
  if (lang) {
11875
12033
  const parser = new Parser();
11876
12034
  parser.setLanguage(lang);
11877
- tree = parser.parse(source.text);
12035
+ // The binding dies with this block, so nothing else could ever
12036
+ // free it — including when `parse` itself throws, which the
12037
+ // outer `catch` only logs. The tree stays valid without it.
12038
+ try {
12039
+ tree = parser.parse(source.text);
12040
+ }
12041
+ finally {
12042
+ freeQuietly(parser);
12043
+ }
11878
12044
  }
11879
12045
  }
11880
12046
  }
@@ -11940,6 +12106,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
11940
12106
  symbolRisks.push(risk);
11941
12107
  }
11942
12108
  }
12109
+ // Last use of this file's tree; the next iteration reassigns the
12110
+ // binding, so not freeing here loses it for the rest of the review.
12111
+ if (tree)
12112
+ freeQuietly(tree);
12113
+ tree = null;
11943
12114
  }
11944
12115
  }
11945
12116
  finally {
@@ -15483,7 +15654,18 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15483
15654
  return undefined;
15484
15655
  const parser = new Parser();
15485
15656
  parser.setLanguage(language);
15486
- const tree = parser.parse(content);
15657
+ // This runs once per traversal edge, not once per file, so the
15658
+ // same file can be reparsed many times within a single query.
15659
+ // Both objects are freed on every exit from this closure — the
15660
+ // parser here even when `parse` throws, which the outer `catch`
15661
+ // would otherwise swallow along with the allocation.
15662
+ let tree;
15663
+ try {
15664
+ tree = parser.parse(content);
15665
+ }
15666
+ finally {
15667
+ freeQuietly(parser);
15668
+ }
15487
15669
  let matched;
15488
15670
  const visit = (node) => {
15489
15671
  if (matched ||
@@ -15502,17 +15684,26 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15502
15684
  for (const child of node.namedChildren)
15503
15685
  visit(child);
15504
15686
  };
15505
- visit(tree.rootNode);
15506
- const argumentsNode = matched?.childForFieldName("arguments");
15507
- if (!matched || !argumentsNode)
15687
+ if (!tree)
15508
15688
  return undefined;
15509
- return {
15510
- kind: "call-arguments",
15511
- arguments: argumentsNode.namedChildren.map((node) => node.text),
15512
- evidence: matched.text.slice(0, 240),
15513
- grounding: "tree-sitter",
15514
- heuristic: true,
15515
- };
15689
+ try {
15690
+ visit(tree.rootNode);
15691
+ const argumentsNode = matched?.childForFieldName("arguments");
15692
+ if (!matched || !argumentsNode)
15693
+ return undefined;
15694
+ return {
15695
+ kind: "call-arguments",
15696
+ // `.text` copies into JS strings, so the returned evidence
15697
+ // holds no reference into the tree being freed.
15698
+ arguments: argumentsNode.namedChildren.map((node) => node.text),
15699
+ evidence: matched.text.slice(0, 240),
15700
+ grounding: "tree-sitter",
15701
+ heuristic: true,
15702
+ };
15703
+ }
15704
+ finally {
15705
+ freeQuietly(tree);
15706
+ }
15516
15707
  }
15517
15708
  catch {
15518
15709
  return undefined;
@@ -1,5 +1,5 @@
1
1
  import { parentPort } from "node:worker_threads";
2
- import { extractGenericFileForIndex } from "./index.js";
2
+ import { describeThrown, extractGenericFileForIndex, isWasmAbort } from "./index.js";
3
3
  /**
4
4
  * Parse-pool worker entry.
5
5
  *
@@ -32,6 +32,10 @@ port.on("message", (task) => {
32
32
  break;
33
33
  default:
34
34
  // `special` carries a live tree; hand the file back instead.
35
+ // Free it first — this branch is the tree's only owner, and the
36
+ // main thread reparses the file from scratch, so leaving it
37
+ // alive leaks wasm memory per file until the module aborts.
38
+ extraction.tree.delete();
35
39
  response = { id: task.id, kind: "special" };
36
40
  break;
37
41
  }
@@ -40,11 +44,20 @@ port.on("message", (task) => {
40
44
  // Serialize by hand. A thrown Error clones without its message on some
41
45
  // paths, and losing the message turns a diagnosable failure into a
42
46
  // mystery at exactly the moment the pool decides whether to retry.
43
- response = {
44
- id: task.id,
45
- kind: "error",
46
- message: error instanceof Error ? error.message : String(error),
47
- };
47
+ //
48
+ // `instanceof Error` is not enough on its own: Emscripten's `ExitStatus`
49
+ // is a plain object with `name`/`message`, so the naive ternary reports
50
+ // the wasm abort as "[object Object]". Shared with the CLI's top-level
51
+ // handler, which had the identical bug — one rule, one place.
52
+ response = { id: task.id, kind: "error", message: describeThrown(error) };
53
+ // The module is dead and does not come back: every later task in THIS
54
+ // worker would fail identically and fall back to the main thread, so the
55
+ // pool would quietly lose a lane for the rest of the run without any
56
+ // signal that it had. Report this file, then exit so the pool replaces
57
+ // the worker (the exit is deferred so the message actually flushes; if
58
+ // it does not, the pool's exit handler retries the file anyway).
59
+ if (isWasmAbort(error))
60
+ setImmediate(() => process.exit(1));
48
61
  }
49
62
  port.postMessage(response);
50
63
  })();
package/dist/src/init.js CHANGED
@@ -230,6 +230,63 @@ function fileSignature(filePath, allowOversized = false) {
230
230
  throw error;
231
231
  }
232
232
  }
233
+ /**
234
+ * The configuration files a `configure` run actually changed, as repo-relative
235
+ * paths that indexing can accept.
236
+ *
237
+ * `configure` used to hand the indexer no file list at all, which means "the
238
+ * whole repository" — a whole-tree walk to account for a change to three
239
+ * dotfiles, and minutes of work on a large checkout. Narrowing it here keeps
240
+ * the cost a function of the configuration, not of the repository.
241
+ *
242
+ * Directories and anything outside the worktree are dropped: they are audited
243
+ * for change reporting but are not indexable files.
244
+ *
245
+ * So is anything the prune policy excludes. `.git/` and `.knodin/` are INSIDE
246
+ * the worktree in an ordinary checkout, so the `..` check below does not
247
+ * exclude them — it only happens to, in a linked worktree, where the git common
248
+ * dir lives elsewhere. Without this filter `configure` hands the indexer
249
+ * `.git/hooks/post-commit`, `.git/info/exclude` and `.knodin/integration.json`,
250
+ * which the explicit-file index path does not re-filter, so it records
251
+ * `index_state` rows for paths a full index would never visit and the next
252
+ * reconciliation then has to purge.
253
+ */
254
+ function changedConfigurationFiles(repo, targets, before) {
255
+ const after = mutationSnapshot(targets, true);
256
+ const changed = [];
257
+ for (const target of targets) {
258
+ if (before.get(target.label) === after.get(target.label))
259
+ continue;
260
+ const relative = path.relative(repo, target.absolute);
261
+ if (relative.startsWith("..") || path.isAbsolute(relative))
262
+ continue;
263
+ // The SAME predicate a full index uses, not merely the prune check.
264
+ // `isIndexableSourcePath` is the canonical candidate policy shared by
265
+ // full-repository collection, the watcher, repair selection and hook
266
+ // refresh, so filtering through it makes this list a strict subset of what
267
+ // `init` would have indexed.
268
+ //
269
+ // Anything looser indexes files no other path in the system ever selects:
270
+ // `.mcp.json` and `.codex/config.toml` are configuration, not source, and
271
+ // including them writes `index_state` rows that the next reconciliation
272
+ // purges again — churn that looks like drift.
273
+ if (!isIndexableSourcePath(relative))
274
+ continue;
275
+ let stat;
276
+ try {
277
+ stat = fs.statSync(target.absolute);
278
+ }
279
+ catch {
280
+ // Removed by this run. There is nothing to index, and pruning the
281
+ // deleted rows is the lifecycle refresh's job, not this list's.
282
+ continue;
283
+ }
284
+ if (!stat.isFile())
285
+ continue;
286
+ changed.push(relative);
287
+ }
288
+ return changed.sort(compareBytes);
289
+ }
233
290
  function mutationSnapshot(targets, allowOversized = false) {
234
291
  const snapshot = new Map();
235
292
  for (const target of targets) {
@@ -1309,11 +1366,23 @@ export async function initializeRepository(repo, options) {
1309
1366
  // Configuration is graph-visible, so explicit configuration must complete
1310
1367
  // before the final index and health verification. Plain init reaches this
1311
1368
  // point without touching agent instructions, MCP files, skills, or receipts.
1312
- const indexResult = await options.index(resolvedRepo, {
1313
- onProgress: options.onProgress,
1314
- });
1315
- if (isIndexResult(indexResult) && indexResult.verification.status !== "healthy") {
1316
- throw new InitializationHealthError(indexResult);
1369
+ //
1370
+ // "Graph-visible" justifies indexing the files configuration wrote. It does
1371
+ // not justify walking the whole repository to find them, which is what an
1372
+ // undefined file list means.
1373
+ const scopedIndexPaths = options.indexScope === "configuration"
1374
+ ? changedConfigurationFiles(resolvedRepo, mutationTargets, beforeMutation)
1375
+ : undefined;
1376
+ if (!scopedIndexPaths || scopedIndexPaths.length > 0) {
1377
+ const indexResult = await options.index(resolvedRepo, { onProgress: options.onProgress }, scopedIndexPaths);
1378
+ // Only a full index has verified the whole graph, so only a full index
1379
+ // may fail the run on graph health. A configuration-scoped pass saw a
1380
+ // handful of files and knows nothing about the rest.
1381
+ if (!scopedIndexPaths &&
1382
+ isIndexResult(indexResult) &&
1383
+ indexResult.verification.status !== "healthy") {
1384
+ throw new InitializationHealthError(indexResult);
1385
+ }
1317
1386
  }
1318
1387
  const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
1319
1388
  await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), {
@@ -1336,6 +1405,7 @@ export async function initializeRepository(repo, options) {
1336
1405
  excluded,
1337
1406
  lifecycleRefresh,
1338
1407
  hookManagerIntegration,
1408
+ scopedIndexPaths,
1339
1409
  };
1340
1410
  }
1341
1411
  finally {
@@ -0,0 +1,76 @@
1
+ # knodin 0.10.2
2
+
3
+ Two defects found by running 0.10.1 against a 902,960-file Salesforce checkout.
4
+ One of them means graphs built by earlier releases on large repositories are
5
+ incomplete in a way that nothing reported.
6
+
7
+ ## Parsers were never freed, and the run reported success anyway
8
+
9
+ Indexing built a tree-sitter `Parser` for every file and freed neither it nor
10
+ the resulting syntax tree. web-tree-sitter objects live in the Emscripten heap
11
+ and are reclaimed only by an explicit `delete()`; of the five parse sites in the
12
+ engine, exactly one had it.
13
+
14
+ The heap therefore grew with every file until allocation failed and the wasm
15
+ module called `abort()`. Measured on a 219-byte XML file: leaking died at the
16
+ 87,305th parse with `ExitStatus: Program terminated with exit(1)`, while freeing
17
+ survived 200,000 with no failure.
18
+
19
+ Three things went wrong from there, and the last is the reason this is a
20
+ correctness fix rather than a performance one.
21
+
22
+ The module never comes back. After the first abort, every later parse failed
23
+ identically — verified on freshly constructed parsers.
24
+
25
+ The error then named the wrong file. Every file processed after the abort was
26
+ reported as failing regardless of its contents, which is why the observed
27
+ failure list was a contiguous alphabetical run of Salesforce package manifests.
28
+ Those files are fine; all 144 of them parse cleanly on a shared parser.
29
+
30
+ And the failure was swallowed. The per-file handler logged, counted the file as
31
+ a coverage gap, and continued, so the run finished successfully. Files that were
32
+ never parsed became indistinguishable from files that genuinely contain no
33
+ symbols, and every later query answered confidently from a graph that was
34
+ quietly missing everything after the abort.
35
+
36
+ All five sites now free both objects on every path, including early returns and
37
+ error paths. A wasm abort is no longer treated as a per-file problem: it fails
38
+ the run, because it is not survivable and pretending otherwise is what turned
39
+ one real failure into thousands of misattributed ones.
40
+
41
+ **Reindex to trust an existing graph.** If a large repository was indexed with
42
+ an earlier release, symbol coverage may be short by an unknown amount and
43
+ nothing in `knodin status` would have said so. Smaller repositories that never
44
+ approached the threshold are unaffected.
45
+
46
+ The coverage line also stops claiming more than it knows. Files with no
47
+ tree-sitter grammar, minified sources, and files whose indexing failed were all
48
+ reported as `parsed but empty`, which asserts the parser looked and found
49
+ nothing — the one thing it did not do. They are now reported as `not parsed`.
50
+
51
+ ## `knodin configure` no longer indexes the entire repository
52
+
53
+ `configure` refuses to run against an uninitialized repository on the grounds
54
+ that it "changes agent integration only", and then passed the indexer no file
55
+ list, which means every file in the tree. On a large checkout that is minutes of
56
+ work to account for a change to three dotfiles, with nothing in the output
57
+ saying an index was happening at all.
58
+
59
+ It now indexes only the files it actually wrote that a full index would also
60
+ have selected, and reports what it did instead of claiming the graph was
61
+ refreshed. That claim was most misleading precisely where it mattered: on a
62
+ repository whose graph was empty, `configure` silently rebuilt it from scratch,
63
+ which is `init`'s job and is what made a routine scope change look inexplicably
64
+ slow.
65
+
66
+ In practice `knodin configure --scope personal` now indexes nothing at all.
67
+ Personal scope writes only `.agents/`, `.codex/` and `.gemini/`, none of which
68
+ is source, so there is nothing for the indexer to do and it is not invoked.
69
+ Team scope still indexes the agent instructions it writes.
70
+
71
+ The filter is the same predicate a full index uses, rather than a looser one
72
+ written for this path. Anything looser records `index_state` rows for files no
73
+ other part of the system ever selects, which the next reconciliation then
74
+ purges — churn that reads as drift.
75
+
76
+ `knodin init` is unchanged and still indexes everything and verifies health.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "knodin": {
5
5
  "compatibility": "breaking"
6
6
  },
@@ -73,6 +73,7 @@
73
73
  "docs/releases/0.9.0.md",
74
74
  "docs/releases/0.10.0.md",
75
75
  "docs/releases/0.10.1.md",
76
+ "docs/releases/0.10.2.md",
76
77
  "docs/assets/knodin-favicon.svg",
77
78
  "docs/SYSTEMS-AND-RELATIONSHIPS.md",
78
79
  "docs/TELEMETRY.md",