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 +30 -2
- package/dist/src/cli-args.js +4 -0
- package/dist/src/cli-model.js +2 -0
- package/dist/src/engine/candidate-database.js +53 -0
- package/dist/src/engine/index.js +300 -22
- package/dist/src/init-progress.js +36 -5
- package/dist/src/release-compatibility.js +95 -0
- package/dist/src/release-preflight.js +18 -0
- package/dist/src/update-policy.js +6 -1
- package/docs/releases/0.10.3.md +93 -0
- package/package.json +3 -2
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
|
|
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
|
|
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;
|
package/dist/src/cli-args.js
CHANGED
|
@@ -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",
|
package/dist/src/cli-model.js
CHANGED
|
@@ -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);
|
package/dist/src/engine/index.js
CHANGED
|
@@ -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
|
-
...(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13507
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"knodin": {
|
|
5
|
-
"compatibility": "
|
|
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",
|