knodin 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/cli.js CHANGED
@@ -138,16 +138,29 @@ function formatRepairHuman(result) {
138
138
  const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
139
139
  return `Repair finished with ${outstanding.toLocaleString()} remaining issue(s).${detail} Run \`knodin status --deep\` for details.\n`;
140
140
  }
141
+ /**
142
+ * The semantic gap, stated rather than left to be discovered.
143
+ *
144
+ * An index that deferred embeddings leaves `search` returning a short list, and
145
+ * a short list is indistinguishable from a thorough search that found little.
146
+ * Saying nothing here is what turns a deliberate deferral into a silent one.
147
+ */
148
+ function formatSemanticGap(readiness) {
149
+ if (readiness === undefined || readiness === "ready")
150
+ return "";
151
+ return ` Semantic coverage is ${readiness}: \`search\` will under-return until \`knodin index\` completes embeddings.`;
152
+ }
141
153
  function formatIndexHuman(result) {
154
+ const semantic = formatSemanticGap(result.semanticReadiness);
142
155
  if (result.indexed.length === 0 && result.unchanged.length > 0) {
143
156
  const noun = result.unchanged.length === 1 ? "file" : "files";
144
- return `Graph already current: ${result.unchanged.length.toLocaleString()} requested ${noun} needed no work; health verified.\n`;
157
+ return `Graph already current: ${result.unchanged.length.toLocaleString()} requested ${noun} needed no work; health verified.${semantic}\n`;
145
158
  }
146
159
  const unchanged = result.unchanged.length > 0
147
160
  ? `; ${result.unchanged.length.toLocaleString()} already current`
148
161
  : "";
149
162
  const noun = result.indexed.length === 1 ? "file" : "files";
150
- return `Index complete: ${result.indexed.length.toLocaleString()} ${noun} indexed${unchanged}; graph health verified.\n`;
163
+ return `Index complete: ${result.indexed.length.toLocaleString()} ${noun} indexed${unchanged}; graph health verified.${semantic}\n`;
151
164
  }
152
165
  function formatIndexVerificationError(result) {
153
166
  const firstIssue = result.verification.missing.files[0] ?? result.verification.missing.records[0];
@@ -2168,6 +2181,8 @@ async function main() {
2168
2181
  process.exit(1);
2169
2182
  }
2170
2183
  const clean = rest.includes("--clean") || rest.includes("--force");
2184
+ const skipEmbeddings = rest.includes("--skip-embeddings");
2185
+ const under = selectorValue("--under");
2171
2186
  const scipPath = selectorValue("--scip");
2172
2187
  const sarifPath = selectorValue("--sarif");
2173
2188
  // A bound the operator cannot move is just a failure, so every import
@@ -2198,6 +2213,8 @@ async function main() {
2198
2213
  let indexResult;
2199
2214
  try {
2200
2215
  indexResult = await engine.index(plan.repo, plan.files, clean, {
2216
+ skipEmbeddings,
2217
+ ...(under !== undefined ? { under } : {}),
2201
2218
  scip: scipPath
2202
2219
  ? {
2203
2220
  path: scipPath,
@@ -2904,6 +2921,17 @@ async function main() {
2904
2921
  process.exitCode = finalExitCode;
2905
2922
  return;
2906
2923
  }
2924
+ // `search` has no human formatter — results fall through to the generic JSON
2925
+ // emitter — so a semantically incomplete graph produced a short list with
2926
+ // nothing saying why. The list looks like a thorough answer, which is the
2927
+ // whole failure. Printed to stderr so it annotates without corrupting output
2928
+ // anyone is piping.
2929
+ if (cmd === "search" && !jsonOutput) {
2930
+ const readiness = result.semanticReadiness;
2931
+ const gap = formatSemanticGap(readiness);
2932
+ if (gap)
2933
+ process.stderr.write(`${gap.trim()}\n`);
2934
+ }
2907
2935
  if (cmd === "status" && !jsonOutput) {
2908
2936
  process.stdout.write(formatStatusHuman(boundedResult));
2909
2937
  process.exitCode = finalExitCode;
@@ -36,6 +36,10 @@ const GLOBAL_BOOLEAN_FLAGS = new Set(["--exclude-tests", "--data-flow", "--json"
36
36
  * not to index the log as a source file.
37
37
  */
38
38
  const COMMAND_VALUE_FLAGS = new Set([
39
+ // Takes a directory. Without this its argument reads as a positional, and
40
+ // `knodin index --under src` would be rejected as "a directory positional"
41
+ // by the very rule that exists to stop people scoping this way by accident.
42
+ "--under",
39
43
  "--scip",
40
44
  "--scip-max-bytes",
41
45
  "--scip-max-files",
@@ -288,6 +288,8 @@ function createCliProgram(capture = () => { }) {
288
288
  leaf(program, "index [files...]", "index a repository or selected files", capture)
289
289
  .option("--clean", "rebuild selected index state")
290
290
  .option("--force", "force clean indexing")
291
+ .option("--skip-embeddings", "build structure only and defer semantic embeddings; `search` under-returns until a later `knodin index` completes them")
292
+ .option("--under <dir>", "rebuild only the files under a repository-relative directory; coverage is then reported as a lower bound until a full index runs")
291
293
  .option("--scip <file>", "opt in to a bounded local SCIP protobuf import")
292
294
  .addOption(option("--scip-max-bytes <count>", "raise the SCIP input size ceiling", "integer"))
293
295
  .addOption(option("--scip-max-files <count>", "raise the SCIP document ceiling", "integer"))
@@ -66,6 +66,59 @@ export function candidateRoot(repo) {
66
66
  export function promotionMarkerPath(repo) {
67
67
  return path.join(resolveStateDir(repo), PROMOTION_MARKER);
68
68
  }
69
+ /**
70
+ * Candidates left on disk for this repository, newest first.
71
+ *
72
+ * A clean index discards its candidate on failure, but a killed process runs no
73
+ * discard — so survivors are exactly the runs that were interrupted rather than
74
+ * the ones that failed. Listing them is what lets the next run continue instead
75
+ * of repeating hours of work.
76
+ *
77
+ * Returns descriptors only. Whether any of them is safe to resume is decided by
78
+ * the caller from the database's own contents, not from the fact it exists.
79
+ */
80
+ export function listCandidates(repo) {
81
+ const resolvedRepo = path.resolve(repo);
82
+ const root = candidateRoot(resolvedRepo);
83
+ let entries;
84
+ try {
85
+ entries = fs.readdirSync(root, { withFileTypes: true });
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ const found = [];
91
+ for (const entry of entries) {
92
+ if (!entry.isDirectory() || !entry.name.startsWith("candidate-"))
93
+ continue;
94
+ const databasePath = path.join(root, entry.name, "db.sqlite");
95
+ let modifiedMs;
96
+ try {
97
+ modifiedMs = fs.statSync(databasePath).mtimeMs;
98
+ }
99
+ catch {
100
+ // A directory with no database is a half-created candidate, not a
101
+ // resumable one.
102
+ continue;
103
+ }
104
+ found.push({
105
+ candidate: {
106
+ schemaVersion: 1,
107
+ id: entry.name,
108
+ repo: resolvedRepo,
109
+ databasePath,
110
+ createdAt: new Date(modifiedMs).toISOString(),
111
+ },
112
+ modifiedMs,
113
+ });
114
+ }
115
+ // Sorted as its own statement rather than mid-chain: an in-place `sort`
116
+ // inside an expression mutates the array being read, which is harmless for
117
+ // this local but reads as a side effect at a glance. (`toSorted` would be
118
+ // the nicer form but needs a newer `lib` than this project targets.)
119
+ found.sort((a, b) => b.modifiedMs - a.modifiedMs);
120
+ return found.map((item) => item.candidate);
121
+ }
69
122
  export function allocateCandidate(repo) {
70
123
  const resolvedRepo = path.resolve(repo);
71
124
  const root = candidateRoot(resolvedRepo);
@@ -28,7 +28,7 @@ import { contentFingerprint, writeStructuralSnapshot, } from "../structural-snap
28
28
  import { acquireLifecycleCoordination } from "../update-coordination.js";
29
29
  import { KNODIN_VERSION } from "../version.js";
30
30
  import * as ann from "./ann-hnsw.js";
31
- import { allocateCandidate, assertCandidate, discardCandidateFiles, promoteCandidateFile, recoverInterruptedPromotion, } from "./candidate-database.js";
31
+ import { allocateCandidate, assertCandidate, discardCandidateFiles, listCandidates, promoteCandidateFile, recoverInterruptedPromotion, } from "./candidate-database.js";
32
32
  import { computeSimilarity, generateEmbedding, generateEmbeddings, } from "./embeddings.js";
33
33
  import { walkRepoFiles } from "./file-walker.js";
34
34
  import { clearGitHistorySignalCache, collectGitHistorySignals, } from "./git-history.js";
@@ -373,6 +373,36 @@ export function extractMcpToolRegistrations(source, file, root) {
373
373
  }
374
374
  /** `meta` key holding the last index's unparsed-file tally, as a JSON object. */
375
375
  export const COVERAGE_UNPARSED_META_KEY = "coverageUnparsedByExtension";
376
+ /**
377
+ * `meta` key set while a full index is running and cleared when it finishes.
378
+ *
379
+ * The tally is written incrementally so an interrupted index does not lose it,
380
+ * which means a present tally no longer implies a *complete* one. This marker
381
+ * is what keeps the two apart: while it is set, the tally describes however far
382
+ * the run got, and `unparsedUnknown` reports it as a lower bound rather than a
383
+ * measurement.
384
+ *
385
+ * Without it, an index killed at 71% would leave a plausible-looking tally that
386
+ * `status` presents as fact — the precise confusion `unparsedUnknown` exists to
387
+ * prevent, reintroduced through the back door.
388
+ */
389
+ export const COVERAGE_TALLY_IN_PROGRESS_META_KEY = "coverageUnparsedIncomplete";
390
+ /**
391
+ * `meta` keys marking a candidate database as a clean index still in progress.
392
+ *
393
+ * A clean index builds into a candidate and promotes it atomically, discarding
394
+ * it on failure — which is why a partial rebuild can never be observed as the
395
+ * live graph, and why that behaviour is preserved exactly. But a killed process
396
+ * runs no discard, so the candidate simply survives on disk, and until now the
397
+ * next run allocated a fresh one and repeated hours of work.
398
+ *
399
+ * These make the survivor recognisable. The marker says "a build was underway";
400
+ * the head says "of this tree". Both are needed: resuming a build of a tree that
401
+ * has since moved on would promote a graph that never described any single state
402
+ * of the repository, which is worse than starting over.
403
+ */
404
+ export const CLEAN_INDEX_IN_PROGRESS_META_KEY = "cleanIndexInProgress";
405
+ export const CLEAN_INDEX_HEAD_META_KEY = "cleanIndexHead";
376
406
  function repairMetadataFamily(filePath) {
377
407
  const segments = filePath.replaceAll("\\", "/").split("/");
378
408
  const extension = path.extname(filePath).toLowerCase();
@@ -7059,6 +7089,12 @@ function createIndexProgressReporter(onProgress) {
7059
7089
  elapsedMs: emittedAt - startedAt,
7060
7090
  message,
7061
7091
  ...(details.modelFile === undefined ? {} : { modelFile: details.modelFile }),
7092
+ ...(details.phaseBytesCompleted === undefined
7093
+ ? {}
7094
+ : { phaseBytesCompleted: details.phaseBytesCompleted }),
7095
+ ...(details.phaseBytesTotal === undefined
7096
+ ? {}
7097
+ : { phaseBytesTotal: details.phaseBytesTotal }),
7062
7098
  });
7063
7099
  }
7064
7100
  catch {
@@ -7224,6 +7260,75 @@ function sumCounts(counts) {
7224
7260
  *
7225
7261
  * Still never throws: a status call must not fail because a tally is corrupt.
7226
7262
  */
7263
+ /**
7264
+ * A candidate left mid-build that it is safe to continue, or null.
7265
+ *
7266
+ * Deliberately conservative: every uncertainty resolves to "start over", which
7267
+ * costs time, versus resuming onto the wrong tree, which produces a graph that
7268
+ * never described any single state of the repository and then promotes it.
7269
+ *
7270
+ * Stale and unusable candidates are removed as they are found, so a repository
7271
+ * does not accumulate abandoned copies of its own database.
7272
+ */
7273
+ function findResumableCandidate(repoPath) {
7274
+ const head = gitHead(repoPath) ?? "";
7275
+ let resumable = null;
7276
+ for (const candidate of listCandidates(repoPath)) {
7277
+ let usable = false;
7278
+ try {
7279
+ const probe = new Database(candidate.databasePath, { readonly: true });
7280
+ try {
7281
+ usable =
7282
+ getMeta(probe, CLEAN_INDEX_IN_PROGRESS_META_KEY) === "1" &&
7283
+ getMeta(probe, CLEAN_INDEX_HEAD_META_KEY) === head;
7284
+ }
7285
+ finally {
7286
+ probe.close();
7287
+ }
7288
+ }
7289
+ catch {
7290
+ // Unreadable, not a database, or schema too old to query. Not resumable,
7291
+ // and not worth keeping.
7292
+ usable = false;
7293
+ }
7294
+ // Keep only the newest usable one. An older survivor is from an even
7295
+ // earlier interrupted run and has nothing to add.
7296
+ if (usable && resumable === null) {
7297
+ resumable = candidate;
7298
+ continue;
7299
+ }
7300
+ try {
7301
+ discardCandidateFiles(repoPath, candidate);
7302
+ }
7303
+ catch {
7304
+ // Best effort: failing to clean up a stale candidate must not stop the
7305
+ // rebuild that is about to replace it.
7306
+ }
7307
+ }
7308
+ return resumable;
7309
+ }
7310
+ /** Write the coverage tally as it stands. Safe to call repeatedly mid-index. */
7311
+ function persistUnparsedTally(db, tally) {
7312
+ setMeta(db, COVERAGE_UNPARSED_META_KEY, JSON.stringify(Object.fromEntries(tally)));
7313
+ }
7314
+ /**
7315
+ * True when the stored tally came from a run that did not finish.
7316
+ *
7317
+ * Read separately from the tally itself because the two answer different
7318
+ * questions: the tally says what was seen, this says whether that is all there
7319
+ * was to see.
7320
+ */
7321
+ function unparsedTallyIsIncomplete(db) {
7322
+ if (!db)
7323
+ return false;
7324
+ try {
7325
+ return getMeta(db, COVERAGE_TALLY_IN_PROGRESS_META_KEY) === "1";
7326
+ }
7327
+ catch {
7328
+ // Unreadable meta is not a claim that the tally is complete.
7329
+ return true;
7330
+ }
7331
+ }
7227
7332
  function readUnparsedTally(db) {
7228
7333
  if (!db)
7229
7334
  return null;
@@ -7290,6 +7395,10 @@ function worstSemanticReadiness(states) {
7290
7395
  function buildCoverageSkips(skippedByExtension, db) {
7291
7396
  const byExtension = sortedTally(skippedByExtension);
7292
7397
  const unparsed = readUnparsedTally(db);
7398
+ // A tally that exists but came from an unfinished run is as unmeasured as an
7399
+ // absent one: it counts only the files that run happened to reach. Both cases
7400
+ // set `unparsedUnknown`, which downstream already renders as a lower bound.
7401
+ const incomplete = unparsed === null || unparsedTallyIsIncomplete(db);
7293
7402
  return {
7294
7403
  byExtension,
7295
7404
  // Reported as empty when unknown so consumers reading only this field are
@@ -7297,7 +7406,7 @@ function buildCoverageSkips(skippedByExtension, db) {
7297
7406
  // measurement. `total` is then a lower bound, not a count.
7298
7407
  unparsedByExtension: unparsed ?? {},
7299
7408
  total: sumCounts(byExtension) + sumCounts(unparsed ?? {}),
7300
- ...(unparsed === null ? { unparsedUnknown: true } : {}),
7409
+ ...(incomplete ? { unparsedUnknown: true } : {}),
7301
7410
  };
7302
7411
  }
7303
7412
  /**
@@ -7554,13 +7663,23 @@ function buildFreshnessEnvelope(repoPath, db, verifiedAt, stateOverride, gitProb
7554
7663
  * Snapshot a file's on-disk mtime/size into `index_state`, so a later cold start
7555
7664
  * can tell whether it drifted while no knodin process was watching.
7556
7665
  */
7666
+ /**
7667
+ * Record a file's mtime and size, returning the size.
7668
+ *
7669
+ * The size is returned rather than discarded so byte-based progress costs no
7670
+ * extra I/O: this already stats every indexed file, and a second pass over
7671
+ * 900,000 files to learn what it just measured would be pure waste. Returns 0
7672
+ * when the file could not be stat'd, which the caller adds harmlessly.
7673
+ */
7557
7674
  function recordIndexState(db, repoPath, relPath) {
7558
7675
  try {
7559
7676
  const st = fs.statSync(path.join(repoPath, relPath));
7560
7677
  db.run("INSERT INTO index_state(filePath, mtimeMs, size) VALUES (?, ?, ?) ON CONFLICT(filePath) DO UPDATE SET mtimeMs = excluded.mtimeMs, size = excluded.size", [relPath, st.mtimeMs, st.size]);
7678
+ return st.size;
7561
7679
  }
7562
7680
  catch (_) {
7563
7681
  // File vanished between indexing and stat — reconcile-delete handles it.
7682
+ return 0;
7564
7683
  }
7565
7684
  }
7566
7685
  /** Drop a file's `index_state` row (used when a file is deleted). */
@@ -7638,7 +7757,7 @@ function detectDriftByStat(repoPath, db, canonicalFiles) {
7638
7757
  }
7639
7758
  return changed;
7640
7759
  }
7641
- async function reconcileIndex(repoPath, db, progress) {
7760
+ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
7642
7761
  try {
7643
7762
  progress?.("collecting-files", 0, "Checking existing local graph for changes");
7644
7763
  const changed = new Set();
@@ -7757,18 +7876,26 @@ async function reconcileIndex(repoPath, db, progress) {
7757
7876
  // Re-embed ONLY what changed (indexEmbeddings embeds symbols lacking an
7758
7877
  // embedding), so a no-op reconcile triggers no embedding work at all.
7759
7878
  if (reindexed > 0) {
7760
- progress?.("finalizing", 0, "Refreshing identities and semantic embeddings");
7879
+ progress?.("finalizing", 0, skipEmbeddings
7880
+ ? "Refreshing identities (semantic embeddings deferred)"
7881
+ : "Refreshing identities and semantic embeddings");
7761
7882
  persistSymbolIdentities(db, repoPath, reindexedPaths);
7762
7883
  reconcileTypeScriptDi(db, repoPath);
7763
- await indexEmbeddings(db, repoPath, progress);
7884
+ if (!skipEmbeddings)
7885
+ await indexEmbeddings(db, repoPath, progress);
7764
7886
  indexGeneration++;
7765
7887
  }
7766
- else if (semanticReadinessFor(db) !== "ready") {
7888
+ else if (!skipEmbeddings && semanticReadinessFor(db) !== "ready") {
7767
7889
  // Unchanged files do NOT imply current embeddings: a deferred
7768
7890
  // (`skipEmbeddings`) or interrupted pass leaves symbols unembedded while
7769
7891
  // every file looks reconciled. Without this, the follow-up index a user
7770
7892
  // is told to run would do nothing and semantic search would stay
7771
7893
  // silently short forever.
7894
+ //
7895
+ // Which is also why `skipEmbeddings` has to suppress it: this branch
7896
+ // exists to COMPLETE a deferred pass, so leaving it unguarded would make
7897
+ // `--skip-embeddings` a silent no-op on exactly the warm graphs large
7898
+ // enough to want it — the flag would appear to work and do the opposite.
7772
7899
  progress?.("finalizing", 0, "Completing deferred semantic embeddings");
7773
7900
  await indexEmbeddings(db, repoPath, progress);
7774
7901
  indexGeneration++;
@@ -8146,7 +8273,17 @@ function stalenessFor(repoPath) {
8146
8273
  return freshnessProbes.get(path.resolve(repoPath))?.staleness ?? "unknown";
8147
8274
  }
8148
8275
  /** Recursively indexes all matching files within the repository. */
8149
- async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publishProcessState = true) {
8276
+ async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publishProcessState = true,
8277
+ /**
8278
+ * Continue a build already in this database instead of starting over.
8279
+ *
8280
+ * Skips the wipe below and skips files whose `index_state` row still matches
8281
+ * disk. Only ever set for a candidate database that a previous run left
8282
+ * mid-build: the live graph is never resumed into, because resuming implies
8283
+ * partially-populated intermediate state and the live graph must never be
8284
+ * observable in that condition.
8285
+ */
8286
+ resume = false) {
8150
8287
  progress?.("collecting-files", 0, "Discovering indexable files");
8151
8288
  const collected = measurePerfPhaseSync("file_collection", () => collectRepoFilesWithCoverage(repoPath));
8152
8289
  const files = collected.files;
@@ -8154,15 +8291,82 @@ async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publish
8154
8291
  // during the parse. Persisted below so `status` can report the gap without
8155
8292
  // re-indexing the repository to rediscover it.
8156
8293
  const unparsedByExtension = new Map();
8294
+ // Claim the tally as in-progress BEFORE the first file. An index killed
8295
+ // partway used to leave the previous run's tally in place and reported as
8296
+ // fact; now whatever is stored is flagged a lower bound until this run
8297
+ // finishes and clears the marker.
8298
+ setMeta(db, COVERAGE_TALLY_IN_PROGRESS_META_KEY, "1");
8299
+ // One stat pass to learn the weight of the work before starting it. Counted
8300
+ // separately from files because the two diverge sharply: on a real Salesforce
8301
+ // checkout 0.05% of the files hold 78% of the bytes, so a files-only estimate
8302
+ // is confidently wrong rather than merely rough.
8303
+ //
8304
+ // `size` is a floor on cost, not a proxy for it — a large XML file is not
8305
+ // exactly proportional to a large TypeScript one — but it tracks the actual
8306
+ // shape of the work far better than a file count, which treats a 7 MB profile
8307
+ // and a 200-byte translation as equal.
8308
+ let totalBytes = 0;
8309
+ for (const file of files) {
8310
+ try {
8311
+ totalBytes += fs.statSync(path.join(repoPath, file)).size;
8312
+ }
8313
+ catch {
8314
+ // Unreadable or vanished: it will fail in the loop below too, where the
8315
+ // failure is reported. Excluding it here only makes the total a floor.
8316
+ }
8317
+ }
8318
+ let completedBytes = 0;
8157
8319
  progress?.("indexing-files", 0, `Indexing ${files.length.toLocaleString()} files`, {
8158
8320
  phaseTotal: files.length,
8321
+ phaseBytesCompleted: 0,
8322
+ phaseBytesTotal: totalBytes,
8159
8323
  });
8160
- // Clean out existing data to ensure consistency on full re-index
8161
- deleteAllSymbols(db);
8162
- db.run('DELETE FROM "references";');
8163
- db.run("DELETE FROM dependencies;");
8164
- db.run("DELETE FROM mcp_tools;");
8165
- db.run("DELETE FROM index_state;");
8324
+ // Clean out existing data to ensure consistency on full re-index.
8325
+ //
8326
+ // Skipped when resuming, which is the whole point: this wipe is why an
8327
+ // interrupted clean index used to lose everything it had done. Resuming into
8328
+ // a database it had just emptied would be indistinguishable from starting
8329
+ // over.
8330
+ if (!resume) {
8331
+ deleteAllSymbols(db);
8332
+ db.run('DELETE FROM "references";');
8333
+ db.run("DELETE FROM dependencies;");
8334
+ db.run("DELETE FROM mcp_tools;");
8335
+ db.run("DELETE FROM index_state;");
8336
+ }
8337
+ // On a resume, everything already recorded and still matching disk is done.
8338
+ // Counted toward progress rather than dropped from it, so the totals stay
8339
+ // whole-repository and the run visibly picks up where it stopped instead of
8340
+ // appearing to start a smaller job.
8341
+ //
8342
+ // Sizes come from the stored `index_state` rows, not fresh stats: the drift
8343
+ // check just proved they still match, so re-measuring 600,000 files to learn
8344
+ // what the database already knows would be the expensive way to be no more
8345
+ // correct.
8346
+ let workList = files;
8347
+ let resumedFiles = 0;
8348
+ let resumedBytes = 0;
8349
+ if (resume) {
8350
+ const pending = [];
8351
+ for (const file of files) {
8352
+ if (fileDriftedFromIndexState(db, repoPath, file)) {
8353
+ pending.push(file);
8354
+ continue;
8355
+ }
8356
+ resumedFiles++;
8357
+ resumedBytes +=
8358
+ db
8359
+ .query("SELECT size FROM index_state WHERE filePath = ?")
8360
+ .get(file)?.size ?? 0;
8361
+ }
8362
+ workList = pending;
8363
+ completedBytes = resumedBytes;
8364
+ progress?.("indexing-files", resumedFiles, `Resuming: ${resumedFiles.toLocaleString()} of ${files.length.toLocaleString()} files already indexed`, {
8365
+ phaseTotal: files.length,
8366
+ phaseBytesCompleted: resumedBytes,
8367
+ phaseBytesTotal: totalBytes,
8368
+ });
8369
+ }
8166
8370
  // Process files sequentially to ensure thread-safe SQLite transactions.
8167
8371
  // Extraction (buildFileIndexResult, pure) still happens one file at a
8168
8372
  // time here; only the write is batched, via `pendingWrites`, so the
@@ -8183,14 +8387,21 @@ async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publish
8183
8387
  };
8184
8388
  // Progress is reported against a single counter so the pool and the
8185
8389
  // sequential path can interleave without the count going backwards.
8186
- let processed = 0;
8390
+ let processed = resumedFiles;
8187
8391
  const afterFile = async (file) => {
8188
8392
  if (pendingWrites.length >= INDEX_WRITE_BATCH_SIZE)
8189
8393
  flushPendingWrites();
8190
- recordIndexState(db, repoPath, file);
8394
+ completedBytes += recordIndexState(db, repoPath, file);
8191
8395
  processed++;
8396
+ // Persisted on the same cadence as the write batches rather than only at
8397
+ // the end, so an interrupted index keeps what it learned. Cheap: one meta
8398
+ // row per 200 files, not per file.
8399
+ if (processed % INDEX_WRITE_BATCH_SIZE === 0)
8400
+ persistUnparsedTally(db, unparsedByExtension);
8192
8401
  progress?.("indexing-files", processed, `Indexing ${files.length.toLocaleString()} files`, {
8193
8402
  phaseTotal: files.length,
8403
+ phaseBytesCompleted: completedBytes,
8404
+ phaseBytesTotal: totalBytes,
8194
8405
  });
8195
8406
  if (Date.now() - lastYieldAt >= INDEX_EVENT_LOOP_YIELD_MS) {
8196
8407
  await yieldToIndexEventLoop();
@@ -8215,7 +8426,7 @@ async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publish
8215
8426
  // cannot disagree about where a file belongs.
8216
8427
  const eligible = [];
8217
8428
  const inline = [];
8218
- for (const file of files) {
8429
+ for (const file of workList) {
8219
8430
  if (isWorkerEligibleFile(path.join(repoPath, file), file))
8220
8431
  eligible.push({ absolutePath: path.join(repoPath, file), relativePath: file, repoPath });
8221
8432
  else
@@ -8246,7 +8457,7 @@ async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publish
8246
8457
  }
8247
8458
  }
8248
8459
  else {
8249
- for (const file of files) {
8460
+ for (const file of workList) {
8250
8461
  await indexOneInline(file);
8251
8462
  await afterFile(file);
8252
8463
  }
@@ -8270,7 +8481,11 @@ async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publish
8270
8481
  setMeta(db, "knodinVersion", KNODIN_VERSION);
8271
8482
  setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
8272
8483
  setMeta(db, "mcpBackfillVersion", "17");
8273
- setMeta(db, COVERAGE_UNPARSED_META_KEY, JSON.stringify(Object.fromEntries(unparsedByExtension)));
8484
+ persistUnparsedTally(db, unparsedByExtension);
8485
+ // Only now is the tally a measurement rather than a lower bound. Clearing the
8486
+ // marker LAST, after the final write, means any failure above leaves it set
8487
+ // and the tally honestly flagged incomplete.
8488
+ setMeta(db, COVERAGE_TALLY_IN_PROGRESS_META_KEY, "");
8274
8489
  recordFreshnessBaseline(repoPath, db);
8275
8490
  if (publishProcessState)
8276
8491
  indexGeneration++;
@@ -11654,7 +11869,18 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
11654
11869
  if (!db)
11655
11870
  throw new Error("candidate database did not open");
11656
11871
  if (!changes) {
11657
- await indexRepo(resolved, db, options?.onProgress, options?.skipEmbeddings === true, false);
11872
+ // Claim the build before touching anything, and record which tree it
11873
+ // is for. A candidate carrying this marker is one a previous run left
11874
+ // mid-build; without the commit, a resume could silently continue a
11875
+ // build of a tree that has since moved on and promote a graph that
11876
+ // never described any single state of the repository.
11877
+ setMeta(db, CLEAN_INDEX_IN_PROGRESS_META_KEY, "1");
11878
+ setMeta(db, CLEAN_INDEX_HEAD_META_KEY, gitHead(resolved) ?? "");
11879
+ await indexRepo(resolved, db, options?.onProgress, options?.skipEmbeddings === true, false, options?.resume === true);
11880
+ // Cleared only after indexRepo returns. Anything that fails or is
11881
+ // killed above leaves it set, which is exactly what makes the
11882
+ // candidate recognisable as resumable rather than abandoned.
11883
+ setMeta(db, CLEAN_INDEX_IN_PROGRESS_META_KEY, "");
11658
11884
  return { reconciled: collectRepoFiles(resolved) };
11659
11885
  }
11660
11886
  const reconciled = [...new Set(changes)]
@@ -13491,6 +13717,27 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13491
13717
  },
13492
13718
  async index(repoPath, files, clean = false, options) {
13493
13719
  const resolved = path.resolve(repoPath);
13720
+ // Expand `--under` before anything else, so everything downstream sees an
13721
+ // ordinary explicit-file index and no path can route a scoped rebuild
13722
+ // into the destructive full-index branch.
13723
+ const scopedUnder = options?.under;
13724
+ if (scopedUnder !== undefined && (!files || files.length === 0)) {
13725
+ const prefix = path
13726
+ .relative(resolved, path.resolve(resolved, scopedUnder))
13727
+ .split(path.sep)
13728
+ .join("/");
13729
+ if (prefix.startsWith("..") || path.isAbsolute(prefix))
13730
+ throw new Error(`knodin index --under: ${scopedUnder} is outside the repository`);
13731
+ const collected = collectRepoFilesWithCoverage(resolved);
13732
+ files =
13733
+ prefix === ""
13734
+ ? collected.files
13735
+ : collected.files.filter((file) => file === prefix || file.startsWith(`${prefix}/`));
13736
+ // Failing loudly rather than indexing nothing and reporting success:
13737
+ // a silent no-op here would look identical to a completed rebuild.
13738
+ if (files.length === 0)
13739
+ throw new Error(`knodin index --under: no indexable files under ${scopedUnder || "the repository root"}`);
13740
+ }
13494
13741
  const indexCoordination = acquireLifecycleCoordination({
13495
13742
  command: "index",
13496
13743
  repository: resolved,
@@ -13503,13 +13750,27 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13503
13750
  !process.env.VITEST &&
13504
13751
  process.env.NODE_ENV !== "test") {
13505
13752
  const progress = createIndexProgressReporter(options?.onProgress);
13506
- progress("starting", 0, "Creating isolated clean-index candidate");
13507
- const candidate = await engine.createCandidate(resolved);
13753
+ // Look for a build a previous run left unfinished before starting a
13754
+ // new one. A candidate survives only when the process was killed —
13755
+ // a failure discards it — so a survivor is precisely the case worth
13756
+ // continuing.
13757
+ const resumable = findResumableCandidate(resolved);
13758
+ // Adopt it into the engine's registry. Ownership is what
13759
+ // reconcile/audit/promote check, and a candidate recovered from disk
13760
+ // was never registered because the process that created it is gone.
13761
+ if (resumable)
13762
+ candidates.set(resumable.id, resumable);
13763
+ const candidate = resumable ?? (await engine.createCandidate(resolved));
13764
+ if (resumable)
13765
+ progress("starting", 0, "Resuming the interrupted clean index (previous progress kept)");
13766
+ else
13767
+ progress("starting", 0, "Creating isolated clean-index candidate");
13508
13768
  let promoted = false;
13509
13769
  try {
13510
13770
  const reconciliation = await engine.reconcileCandidate(candidate, resolved, undefined, {
13511
13771
  onProgress: progress,
13512
13772
  skipEmbeddings: options?.skipEmbeddings,
13773
+ resume: resumable !== null,
13513
13774
  });
13514
13775
  progress("verifying", 0, "Deep-auditing clean-index candidate", {
13515
13776
  phaseTotal: 1,
@@ -13650,7 +13911,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13650
13911
  catch (_) { }
13651
13912
  if (!clean && hasSymbols) {
13652
13913
  progress("collecting-files", 0, "Checking existing local graph for changes");
13653
- await reconcileIndex(repoPath, db, progress);
13914
+ await reconcileIndex(repoPath, db, progress, options?.skipEmbeddings === true);
13654
13915
  }
13655
13916
  else {
13656
13917
  await indexRepo(repoPath, db, progress, options?.skipEmbeddings === true);
@@ -13768,9 +14029,26 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13768
14029
  progress("completed", completed, health.status === "healthy"
13769
14030
  ? completionMessage
13770
14031
  : `${completionMessage}; verification found ${issueCount.toLocaleString()} issue(s)`, { phaseTotal: completed });
14032
+ // A scoped rebuild leaves the stored coverage tally describing a
14033
+ // different run than the graph it now sits beside. Left alone it
14034
+ // would still be present, and therefore still reported as measured
14035
+ // fact — the stale-and-authoritative case. Marking it incomplete
14036
+ // makes `status` report a lower bound until a full index restores a
14037
+ // whole-repository measurement.
14038
+ //
14039
+ // Only for an explicit `--under`, not for ordinary file arguments:
14040
+ // the lifecycle hooks index changed files constantly, and flagging
14041
+ // the tally on every commit would make the signal meaningless noise.
14042
+ if (scopedUnder !== undefined)
14043
+ setMeta(db, COVERAGE_TALLY_IN_PROGRESS_META_KEY, "1");
13771
14044
  return {
13772
14045
  indexed,
13773
14046
  unchanged,
14047
+ // Measured from the graph rather than inferred from the flag: a
14048
+ // run that deferred embeddings and a run that never had any are
14049
+ // the same state to a consumer, and a partially embedded graph is
14050
+ // neither.
14051
+ semanticReadiness: semanticReadinessFor(db),
13774
14052
  scip: scipReport,
13775
14053
  sarif: sarifReport,
13776
14054
  verification: {
@@ -20,7 +20,7 @@ function formatDuration(seconds) {
20
20
  return `${Math.ceil(seconds)}s`;
21
21
  return `${Math.floor(seconds / 60)}m ${Math.ceil(seconds % 60)}s`;
22
22
  }
23
- export function formatProgress(event, ratePerSecond, operation = "init") {
23
+ export function formatProgress(event, ratePerSecond, operation = "init", bytesPerSecond) {
24
24
  const model = event.modelFile ? ` (${event.modelFile})` : "";
25
25
  const total = event.phaseTotal;
26
26
  if (total === undefined)
@@ -29,13 +29,34 @@ export function formatProgress(event, ratePerSecond, operation = "init") {
29
29
  ? `${formatBytes(event.phaseCompleted)} / ${formatBytes(total)}`
30
30
  : `${formatCount(event.phaseCompleted)} / ${formatCount(total)}`;
31
31
  const percent = total > 0 ? ` (${Math.floor((event.phaseCompleted / total) * 100)}%)` : "";
32
- const eta = ratePerSecond && total > event.phaseCompleted
33
- ? ` ~${formatDuration((total - event.phaseCompleted) / ratePerSecond)} remaining`
32
+ // Both counters, because they answer different questions and on a real
33
+ // repository they disagree. Measured on a 902,960-file Salesforce checkout:
34
+ // 435 files over 1 MB hold 78% of the bytes, so "71% of files" was about a
35
+ // quarter of the work. Showing only files is what made a working index look
36
+ // wedged for twenty minutes.
37
+ const bytesTotal = event.phaseBytesTotal;
38
+ const bytesDone = event.phaseBytesCompleted;
39
+ const bytes = bytesTotal !== undefined && bytesDone !== undefined && bytesTotal > 0
40
+ ? ` • ${formatBytes(bytesDone)} / ${formatBytes(bytesTotal)} (${Math.floor((bytesDone / bytesTotal) * 100)}%)`
34
41
  : "";
42
+ // Prefer the byte rate: it tracks the shape of the work rather than the
43
+ // length of the list. Falls back to the item rate when the phase carries no
44
+ // byte size, which keeps model download and embedding phases as they were.
45
+ const byteEta = bytesPerSecond && bytesTotal !== undefined && bytesDone !== undefined && bytesTotal > bytesDone
46
+ ? (bytesTotal - bytesDone) / bytesPerSecond
47
+ : undefined;
48
+ const itemEta = ratePerSecond && total > event.phaseCompleted
49
+ ? (total - event.phaseCompleted) / ratePerSecond
50
+ : undefined;
51
+ const remaining = byteEta ?? itemEta;
52
+ // Labelled an estimate because it is one, and because a confidently wrong
53
+ // number is worse than an obviously approximate one — this workload spans
54
+ // three orders of magnitude in cost per file.
55
+ const eta = remaining === undefined ? "" : ` • ~${formatDuration(remaining)} remaining (est.)`;
35
56
  const rate = ratePerSecond && event.phase !== "embedding-model"
36
57
  ? ` • ${ratePerSecond.toFixed(ratePerSecond >= 10 ? 0 : 1)}/s`
37
58
  : "";
38
- return `[${operation}:${event.phase}] ${counter}${percent} ${event.message}${model}${rate}${eta}`;
59
+ return `[${operation}:${event.phase}] ${counter}${percent}${bytes} ${event.message}${model}${rate}${eta}`;
39
60
  }
40
61
  /**
41
62
  * Human-only init feedback. It writes to stderr, leaving stdout stable for the
@@ -61,6 +82,7 @@ export function createInitProgressRenderer(options) {
61
82
  let lastPercentBucket = -1;
62
83
  let phaseStartedElapsedMs = 0;
63
84
  let phaseStartedCompleted = 0;
85
+ let phaseStartedBytes = 0;
64
86
  const scheduleHeartbeat = () => {
65
87
  if (heartbeat !== undefined || stopped)
66
88
  return;
@@ -126,17 +148,26 @@ export function createInitProgressRenderer(options) {
126
148
  if (phaseChanged) {
127
149
  phaseStartedElapsedMs = event.elapsedMs;
128
150
  phaseStartedCompleted = event.phaseCompleted;
151
+ phaseStartedBytes = event.phaseBytesCompleted ?? 0;
129
152
  }
130
153
  const phaseElapsedSeconds = Math.max(0, event.elapsedMs - phaseStartedElapsedMs) / 1_000;
131
154
  const ratePerSecond = phaseElapsedSeconds > 0 && event.phaseCompleted > phaseStartedCompleted
132
155
  ? (event.phaseCompleted - phaseStartedCompleted) / phaseElapsedSeconds
133
156
  : undefined;
157
+ // Measured from the start of the phase, not from process start, so the
158
+ // cold-start window (wasm init, grammar load, embedder setup) does not
159
+ // drag the estimate. Extrapolating from that window overstated a real
160
+ // run by roughly ten times.
161
+ const bytesDone = event.phaseBytesCompleted;
162
+ const bytesPerSecond = phaseElapsedSeconds > 0 && bytesDone !== undefined && bytesDone > phaseStartedBytes
163
+ ? (bytesDone - phaseStartedBytes) / phaseElapsedSeconds
164
+ : undefined;
134
165
  const completed = event.phaseTotal !== undefined && event.phaseCompleted >= event.phaseTotal;
135
166
  if (tty && !phaseChanged && !completed && now() - lastWriteAt < TTY_THROTTLE_MS)
136
167
  return;
137
168
  if (!tty && !phaseChanged && bucket <= lastPercentBucket && !completed)
138
169
  return;
139
- write(formatProgress(event, ratePerSecond, operation));
170
+ write(formatProgress(event, ratePerSecond, operation, bytesPerSecond));
140
171
  lastPhase = event.phase;
141
172
  lastPercentBucket = Math.max(lastPercentBucket, bucket);
142
173
  },
@@ -0,0 +1,95 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parseVersion } from "./update-policy.js";
4
+ /**
5
+ * Classify a version transition.
6
+ *
7
+ * Returns `null` when the transition is not a forward release — equal versions,
8
+ * a downgrade, or an unparseable input — so callers distinguish "cannot say"
9
+ * from a confident answer. Returning a default here would put a guess into
10
+ * published metadata, which is the failure this whole change removes.
11
+ *
12
+ * ## The 0.x rule, stated rather than inherited
13
+ *
14
+ * Pre-1.0, the MINOR is the breaking axis: `0.10.x` to `0.11.0` breaks, while
15
+ * `0.10.1` to `0.10.2` does not. This is conventional SemVer for major-zero and
16
+ * it is also what this project's own history shows — `0.9.0` and `0.10.0` were
17
+ * genuinely breaking and were labelled so, while `0.10.1` and `0.10.2` were
18
+ * bugfix releases published under the same label because the field was a
19
+ * constant.
20
+ *
21
+ * `policyAllows` in update-policy.ts deliberately does NOT encode this: it
22
+ * treats major-zero like any other major, which is correct for deciding whether
23
+ * an automatic update is safe but would classify every 0.x release as
24
+ * non-breaking here. Reusing it would be the obvious mistake.
25
+ */
26
+ export function classifyCompatibility(previous, next) {
27
+ const from = parseVersion(previous);
28
+ const to = parseVersion(next);
29
+ if (!from || !to)
30
+ return null;
31
+ const [fromMajor, fromMinor, fromPatch] = from.core;
32
+ const [toMajor, toMinor, toPatch] = to.core;
33
+ // Not a forward release. A caller asking about a downgrade or a no-op has a
34
+ // different problem than a mislabelled release.
35
+ if (toMajor < fromMajor)
36
+ return null;
37
+ if (toMajor === fromMajor && toMinor < fromMinor)
38
+ return null;
39
+ if (toMajor === fromMajor && toMinor === fromMinor && toPatch <= fromPatch)
40
+ return null;
41
+ if (toMajor !== fromMajor)
42
+ return "breaking";
43
+ // Major-zero: the minor carries what the major carries after 1.0.
44
+ if (toMajor === 0)
45
+ return toMinor !== fromMinor ? "breaking" : "compatible";
46
+ return toMinor !== fromMinor ? "compatible-with-additions" : "compatible";
47
+ }
48
+ /**
49
+ * The version of the release immediately before `version`, taken from the
50
+ * release-notes directory.
51
+ *
52
+ * Derived from `docs/releases/` rather than git tags on purpose: the repository
53
+ * has no previous-tag lookup anywhere, this needs no subprocess, and the notes
54
+ * are already required to exist for the release being cut — `docs-integrity`
55
+ * asserts the current version's file is present and packaged. Using a source
56
+ * that is already load-bearing means this cannot silently disagree with what
57
+ * ships.
58
+ *
59
+ * Returns `null` when there is no earlier release, which is a real state for the
60
+ * first one.
61
+ */
62
+ export function previousReleaseVersion(releasesDir, version) {
63
+ let entries;
64
+ try {
65
+ entries = fs.readdirSync(releasesDir);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ const target = parseVersion(version);
71
+ if (!target)
72
+ return null;
73
+ let best = null;
74
+ for (const entry of entries) {
75
+ if (path.extname(entry) !== ".md")
76
+ continue;
77
+ const candidateText = path.basename(entry, ".md");
78
+ const candidate = parseVersion(candidateText);
79
+ if (!candidate)
80
+ continue;
81
+ if (compareCore(candidate.core, target.core) >= 0)
82
+ continue;
83
+ if (!best || compareCore(candidate.core, best.core) > 0)
84
+ best = { text: candidateText, core: candidate.core };
85
+ }
86
+ return best?.text ?? null;
87
+ }
88
+ function compareCore(left, right) {
89
+ for (let index = 0; index < 3; index++) {
90
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
91
+ if (difference !== 0)
92
+ return difference;
93
+ }
94
+ return 0;
95
+ }
@@ -3,6 +3,7 @@ import { createHash, createPublicKey } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { compareBytes } from "./compare.js";
6
+ import { classifyCompatibility, previousReleaseVersion } from "./release-compatibility.js";
6
7
  import { validateRootCeremonyManifest } from "./update-ceremony.js";
7
8
  import { canonicalizeUpdateMetadata, verifyUpdateRootChain, } from "./update-trust.js";
8
9
  export const RELEASE_PREFLIGHT_REPOSITORY_COMMANDS = [
@@ -486,6 +487,23 @@ export function evaluateReleasePreflight(options) {
486
487
  const packageManifest = record(parseJson(packageFile.bytes, "package manifest"), "package manifest");
487
488
  if (packageManifest.version !== plan.version)
488
489
  fail("package version does not equal release-plan version");
490
+ // `knodin.compatibility` ships in the tarball and is read out of it by the
491
+ // Homebrew tap. Nothing inside this repository consumes it, so a wrong value
492
+ // reaches consumers without anything failing — it was a hardcoded "breaking"
493
+ // across four releases, two of which were bugfix patches. This is the last
494
+ // gate that sees the manifest before it is published.
495
+ const declaredCompatibility = record(packageManifest.knodin ?? {}, "package manifest knodin block").compatibility;
496
+ const previousRelease = previousReleaseVersion(path.join(repositoryRoot, "docs", "releases"), plan.version);
497
+ if (previousRelease !== null) {
498
+ const derived = classifyCompatibility(previousRelease, plan.version);
499
+ // A null derivation means the version did not move forward, which the
500
+ // version check above should already have caught; failing here rather than
501
+ // skipping keeps an unexplained state from passing silently.
502
+ if (derived === null)
503
+ fail(`cannot classify compatibility from ${previousRelease} to ${plan.version}`);
504
+ else if (declaredCompatibility !== derived)
505
+ fail(`package knodin.compatibility is ${JSON.stringify(declaredCompatibility)} but ${previousRelease} to ${plan.version} is ${derived}`);
506
+ }
489
507
  const workflowFile = readBoundedRegularFile(repositoryRoot, path.join(repositoryRoot, plan.workflow.path), "C64 candidate workflow", MAX_MANIFEST_BYTES);
490
508
  if (!git.isTracked(workflowFile.relativePath))
491
509
  fail("C64 candidate workflow must be checked in");
@@ -234,7 +234,12 @@ function baseResult(options, status) {
234
234
  networkUsed: false,
235
235
  };
236
236
  }
237
- function parseVersion(version) {
237
+ /**
238
+ * Exported so release classification can reuse this parser rather than adding a
239
+ * third hand-rolled copy (`src/manager-update.ts:424` is the second, and a
240
+ * de-duplication candidate). Strict SemVer: leading zeros are rejected.
241
+ */
242
+ export function parseVersion(version) {
238
243
  const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
239
244
  if (!match)
240
245
  return null;
@@ -0,0 +1,93 @@
1
+ # knodin 0.10.3
2
+
3
+ Four changes, all from one observation: after 0.10.2 fixed a parser leak that had
4
+ left 174,205 files silently unparsed on a large repository, the recovery advice
5
+ turned out to be unusable. A full reindex of that checkout has no knowable cost,
6
+ no way to ask for less work, and no way to survive an interruption.
7
+
8
+ ## Indexing reports how much work is left, not just how many files
9
+
10
+ A full index of a 902,960-file Salesforce checkout appeared to hang for twenty
11
+ minutes at 71%. It had not hung — it had reached the profiles.
12
+
13
+ Progress was counted in files while the cost lives in bytes, and on that
14
+ repository the two disagree violently: **435 files over 1 MB hold 78% of all
15
+ source bytes.** So the counter raced through the cheap files and appeared to
16
+ stall exactly when the expensive ones began, which is the moment an operator is
17
+ most likely to conclude the process is wedged and kill it. The only way to tell
18
+ grinding from hung was to read the process's accumulated CPU time.
19
+
20
+ Both counters are now reported, and the estimate is derived from the byte rate.
21
+ Files answer "how far through the list"; bytes answer "how much work is left".
22
+
23
+ The estimate is labelled an estimate. During that one run, three separate
24
+ completion figures were derived from observed rates — 38 hours, 4 hours, and 15
25
+ minutes — and every one was wrong, because the rate ranged from 6.5 to 361 files
26
+ per second before collapsing to 0.065. Rates are now measured from the start of
27
+ the phase rather than process start, so the cold-start window no longer drags the
28
+ estimate; extrapolating from it is what produced the 38-hour figure.
29
+
30
+ ## Rebuild one subtree with `--under`
31
+
32
+ `knodin index --under <dir>` rebuilds only that directory. Previously the
33
+ intuitive attempt was also the worst one: a lone directory argument means "index
34
+ this whole repository".
35
+
36
+ The file set is resolved inside the engine rather than by expanding paths
37
+ yourself, so a scoped rebuild indexes exactly the files a full index would. A
38
+ scope that matches nothing fails rather than reporting a successful rebuild of
39
+ nothing.
40
+
41
+ Coverage is reported as a lower bound afterwards, because a whole-repository
42
+ tally no longer describes a partially rebuilt graph. Trading a graph you were
43
+ told to distrust for one that is silently partial would be worse than doing
44
+ nothing.
45
+
46
+ ## Defer embeddings with `--skip-embeddings`
47
+
48
+ The embedding phase dominates indexing cost, and the engine has always supported
49
+ deferring it — only mirrors could ask. `knodin index --skip-embeddings` now
50
+ builds structure alone, leaving `explain`, `query` and impact answerable while
51
+ semantic search waits.
52
+
53
+ The gap is stated rather than left to be discovered. An index that defers
54
+ embeddings says so, and `knodin search` now announces that it will under-return
55
+ until they are built. A short result list is otherwise indistinguishable from a
56
+ thorough search that found little.
57
+
58
+ Running `knodin index` again completes the deferred pass, and only the symbols
59
+ still lacking embeddings are processed.
60
+
61
+ ## An interrupted `--clean` resumes
62
+
63
+ A clean index builds into a candidate database and promotes it atomically. That
64
+ is deliberate — it is why a failed rebuild cannot leave a half-built graph in
65
+ place of a working one, and it is unchanged.
66
+
67
+ But a killed process runs no discard, so the candidate survived on disk and the
68
+ next run ignored it and started over. On a repository where the rebuild takes
69
+ hours, an interruption cost all of it.
70
+
71
+ The next `knodin index --clean` now continues that build, skipping files already
72
+ recorded whose contents still match disk, and says how many it kept. Resumption
73
+ is refused when the tree has moved on since — continuing there would promote a
74
+ graph that never described any single state of the repository — and refused for
75
+ a candidate that was never marked as an interrupted build. Both cases fall back
76
+ to a full rebuild rather than guessing.
77
+
78
+ ## Release compatibility is derived, not declared
79
+
80
+ `knodin.compatibility` had been the literal string `"breaking"` since it was
81
+ introduced, across four releases. Two of those genuinely were breaking and two
82
+ were bugfix patches, so it was accidentally correct half the time — which is
83
+ worse than being consistently wrong, because the correct entries make the field
84
+ look maintained.
85
+
86
+ It is now derived from the version change, with the pre-1.0 rule stated
87
+ explicitly: below 1.0 the minor is the breaking axis, so `0.10.x` to `0.11.0`
88
+ breaks and `0.10.2` to `0.10.3` does not. Nothing inside this repository reads
89
+ the field — the Homebrew tap reads it from the published package — so two guards
90
+ now check it, because otherwise nothing would.
91
+
92
+ Releases already published keep the label they shipped with. Published metadata
93
+ is immutable by design.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "knodin": {
5
- "compatibility": "breaking"
5
+ "compatibility": "compatible"
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",
@@ -74,6 +74,7 @@
74
74
  "docs/releases/0.10.0.md",
75
75
  "docs/releases/0.10.1.md",
76
76
  "docs/releases/0.10.2.md",
77
+ "docs/releases/0.10.3.md",
77
78
  "docs/assets/knodin-favicon.svg",
78
79
  "docs/SYSTEMS-AND-RELATIONSHIPS.md",
79
80
  "docs/TELEMETRY.md",