knodin 0.10.4 → 0.10.6
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 +107 -8
- package/dist/src/diagnostics.js +13 -2
- package/dist/src/engine/candidate-database.js +105 -0
- package/dist/src/engine/index.js +94 -7
- package/dist/src/failure-diagnosis.js +7 -1
- package/dist/src/init.js +16 -2
- package/docs/releases/0.10.5.md +172 -0
- package/docs/releases/0.10.6.md +139 -0
- package/package.json +30 -28
package/dist/bin/cli.js
CHANGED
|
@@ -174,7 +174,13 @@ function formatIndexHuman(result) {
|
|
|
174
174
|
function formatIndexVerificationError(result) {
|
|
175
175
|
const firstIssue = result.verification.missing.files[0] ?? result.verification.missing.records[0];
|
|
176
176
|
const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
|
|
177
|
-
|
|
177
|
+
// Describes whichever condition actually gated. Printing the issue count
|
|
178
|
+
// unconditionally produced "0 graph issue(s) remain" as a failure reason —
|
|
179
|
+
// a sentence that reports no problem and then recommends repair (KNODIN-7).
|
|
180
|
+
const condition = result.verification.issueCount > 0
|
|
181
|
+
? `${result.verification.issueCount.toLocaleString()} graph issue(s) remain`
|
|
182
|
+
: `graph verification reported status "${result.verification.status}"`;
|
|
183
|
+
return `knodin index: requested work completed, but ${condition}.${detail} Run \`knodin repair\`.\n`;
|
|
178
184
|
}
|
|
179
185
|
/**
|
|
180
186
|
* The hooks/lifecycle line of `status`.
|
|
@@ -395,7 +401,13 @@ function formatStatusHuman(result) {
|
|
|
395
401
|
const semanticNote = result.semanticReadiness && result.semanticReadiness !== "ready"
|
|
396
402
|
? ` Semantic search coverage is ${result.semanticReadiness}: \`search\` will under-return until embedding completes (\`knodin index\`).`
|
|
397
403
|
: "";
|
|
398
|
-
|
|
404
|
+
// `countsUnknown` means the graph could not be read, so these are placeholders
|
|
405
|
+
// rather than measurements. Printing "0 indexed files" makes an absent graph
|
|
406
|
+
// indistinguishable from a fully destroyed one.
|
|
407
|
+
const indexedCounts = result.coverage.countsUnknown
|
|
408
|
+
? "indexed and symbol counts unknown; the graph could not be read"
|
|
409
|
+
: `${result.coverage.indexedFiles} indexed files, ${result.coverage.filesWithSymbols} files with symbols`;
|
|
410
|
+
const coverage = `${result.coverage.sourceFiles} source files, ${indexedCounts}${formatCoverageGaps(result.coverage.skipped)}${mirrorNote}${semanticNote}`;
|
|
399
411
|
if (result.status === "indexing" && result.activity) {
|
|
400
412
|
const count = result.activity.phaseTotal === undefined
|
|
401
413
|
? ""
|
|
@@ -431,8 +443,18 @@ function formatStatusHuman(result) {
|
|
|
431
443
|
return `Graph content is intact but evidence is stale (${coverage}).${result.verification.mode === "persisted-audit"
|
|
432
444
|
? ` Cached deep-audit evidence is from ${result.verification.auditVerifiedAt ?? "an earlier run"}; freshness was probed ${result.verification.verifiedAt ?? "now"}. Run \`knodin status --deep\` for an exact current audit.`
|
|
433
445
|
: ""}\n${freshnessLine}${lifecycleLine}${integrationLine}Run \`knodin wait --fresh\` or issue a graph query to reconcile bounded drift.\n`;
|
|
434
|
-
|
|
435
|
-
|
|
446
|
+
// When the graph could not be read, every source file lands in `missing.files`
|
|
447
|
+
// because none of them are indexed. That is ONE structural problem, not one
|
|
448
|
+
// per file: a 94-file repository with no database reported "95 issue(s)" and
|
|
449
|
+
// led with an arbitrary source filename, so an absent graph read as damage
|
|
450
|
+
// proportional to repository size. `missing.files` still carries the repair
|
|
451
|
+
// worklist — only the count and the headline shown to a human change here.
|
|
452
|
+
const outstanding = result.coverage.countsUnknown
|
|
453
|
+
? result.missing.records.length
|
|
454
|
+
: result.missing.files.length + result.missing.records.length;
|
|
455
|
+
const firstIssue = result.coverage.countsUnknown
|
|
456
|
+
? result.missing.records[0]
|
|
457
|
+
: (result.missing.files[0] ?? result.missing.records[0]);
|
|
436
458
|
const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
|
|
437
459
|
const repairCommand = result.lifecycle?.status === "degraded" &&
|
|
438
460
|
result.missing.records.every((record) => result.lifecycle?.issues.includes(record))
|
|
@@ -2256,11 +2278,39 @@ async function main() {
|
|
|
2256
2278
|
result = indexResult;
|
|
2257
2279
|
const indexedCheck = checkIndexed([...indexResult.indexed, ...indexResult.unchanged], plan.repo);
|
|
2258
2280
|
if (!indexedCheck.ok) {
|
|
2281
|
+
// Same obligation as the verification gate below: `--json` must mean
|
|
2282
|
+
// parseable output on every exit path, and this one wrote zero bytes
|
|
2283
|
+
// to stdout and a bare sentence to stderr (KNODIN-5).
|
|
2284
|
+
if (jsonOutput)
|
|
2285
|
+
process.stdout.write(`${JSON.stringify({
|
|
2286
|
+
available: false,
|
|
2287
|
+
status: "unavailable",
|
|
2288
|
+
state: "unknown",
|
|
2289
|
+
error: indexedCheck.error,
|
|
2290
|
+
})}\n`);
|
|
2259
2291
|
process.stderr.write(`${indexedCheck.error}\n`);
|
|
2260
2292
|
await engine.close();
|
|
2261
2293
|
process.exit(1);
|
|
2262
2294
|
}
|
|
2263
|
-
|
|
2295
|
+
// Gated on damage, not on `status !== "healthy"`. That older condition
|
|
2296
|
+
// rejected `stale`, and a freshly promoted graph is routinely stale
|
|
2297
|
+
// because freshness has not been probed since the index wrote — so the
|
|
2298
|
+
// FIRST clean index of any repository exited 1 while its own progress
|
|
2299
|
+
// output said "Promoted graph has 0 issue(s)" and its error said "0
|
|
2300
|
+
// graph issue(s) remain. Run `knodin repair`", recommending repair for
|
|
2301
|
+
// a graph it had just verified as undamaged (KNODIN-7).
|
|
2302
|
+
//
|
|
2303
|
+
// `repair-needed` and a non-zero issue count keep every real failure.
|
|
2304
|
+
// Staleness is a freshness fact that `status` reports and ordinary use
|
|
2305
|
+
// reconciles; it is not a verification result, and treating it as one
|
|
2306
|
+
// made a new repository's first command fail for doing nothing wrong.
|
|
2307
|
+
if (indexResult.verification.status === "repair-needed" ||
|
|
2308
|
+
indexResult.verification.issueCount > 0) {
|
|
2309
|
+
// Emitted before exiting so `--json` is not silently empty on this
|
|
2310
|
+
// path: it previously wrote zero bytes to stdout and a human
|
|
2311
|
+
// sentence to stderr, which is the same defect as KNODIN-5.
|
|
2312
|
+
if (jsonOutput)
|
|
2313
|
+
process.stdout.write(`${JSON.stringify(indexResult)}\n`);
|
|
2264
2314
|
process.stderr.write(formatIndexVerificationError(indexResult));
|
|
2265
2315
|
await engine.close();
|
|
2266
2316
|
process.exit(1);
|
|
@@ -2484,14 +2534,38 @@ async function main() {
|
|
|
2484
2534
|
stripEmbeddings: !rest.includes("--keep-embeddings"),
|
|
2485
2535
|
includeExcluded: rest.includes("--include-excluded"),
|
|
2486
2536
|
});
|
|
2537
|
+
// `ok: false` and exit 0 are contradictory claims from one command, and
|
|
2538
|
+
// the exit code is what every shell idiom reads. `knodin seal --output X
|
|
2539
|
+
// && upload X` ran the upload after a refusal (KNODIN-4).
|
|
2540
|
+
//
|
|
2541
|
+
// seal already fails closed on content — it writes no file when it
|
|
2542
|
+
// refuses — so this closes the last gap rather than papering over one.
|
|
2543
|
+
if (result.ok === false)
|
|
2544
|
+
process.exitCode = 1;
|
|
2487
2545
|
break;
|
|
2488
2546
|
}
|
|
2489
2547
|
case "sealed": {
|
|
2490
|
-
|
|
2548
|
+
// Positionals are extracted rather than read off `rest` by index.
|
|
2549
|
+
// `rest[1]` took whatever token followed the artifact, so
|
|
2550
|
+
// `sealed <artifact> --tokens 200` explained a symbol literally named
|
|
2551
|
+
// "--tokens" — and at that budget the fabricated empty explain block
|
|
2552
|
+
// evicted commit, ref and sealedAt, which are the fields an
|
|
2553
|
+
// inspect-only call exists to return (KNODIN-3).
|
|
2554
|
+
//
|
|
2555
|
+
// `extractPositionals` already knows which flags consume the token
|
|
2556
|
+
// after them, so `200` is not mistaken for a symbol either.
|
|
2557
|
+
const positionals = extractPositionals(rest);
|
|
2558
|
+
if (!positionals[0])
|
|
2491
2559
|
throw new Error("knodin sealed requires an artifact path");
|
|
2492
|
-
result = await runSealedQuery(
|
|
2560
|
+
result = await runSealedQuery(positionals[0], positionals[1], {
|
|
2493
2561
|
strictCompat: rest.includes("--strict-compat"),
|
|
2494
2562
|
});
|
|
2563
|
+
// Same contradiction as `seal`, found while testing the fix for it:
|
|
2564
|
+
// `sealed <missing-artifact>` reported ok: false and exited 0, so a
|
|
2565
|
+
// caller checking $? saw success (KNODIN-4 R2 — audit the other
|
|
2566
|
+
// commands emitting an `ok` field).
|
|
2567
|
+
if (result.ok === false)
|
|
2568
|
+
process.exitCode = 1;
|
|
2495
2569
|
break;
|
|
2496
2570
|
}
|
|
2497
2571
|
case "pack": {
|
|
@@ -3038,7 +3112,32 @@ catch (err) {
|
|
|
3038
3112
|
error: err,
|
|
3039
3113
|
});
|
|
3040
3114
|
const correlation = diagnostic.recorded ? ` [diagnostic ${diagnostic.correlationId}]` : "";
|
|
3041
|
-
|
|
3115
|
+
const message = describeThrown(err);
|
|
3116
|
+
// `--json` has to mean parseable output on EVERY exit path, not only the
|
|
3117
|
+
// successful one. A bare string here makes a consumer's parser throw a
|
|
3118
|
+
// decode error, which gets triaged as a malformed response or a version
|
|
3119
|
+
// mismatch — while the string it failed to parse already named the cause
|
|
3120
|
+
// (KNODIN-5, found as "attempt to write a readonly database" from
|
|
3121
|
+
// `knodin --json sealed` against a read-only artifact).
|
|
3122
|
+
//
|
|
3123
|
+
// Shaped like the unavailability envelope the graph-backed paths already
|
|
3124
|
+
// return for the same class of condition, so a caller can handle both
|
|
3125
|
+
// surfaces with one branch. Written to stdout because that is where a
|
|
3126
|
+
// caller reading `--json` is looking; the human path keeps stderr.
|
|
3127
|
+
// The envelope is ADDITIONAL, not a replacement. stdout is the JSON
|
|
3128
|
+
// channel and stderr is the human one, so writing the envelope instead of
|
|
3129
|
+
// the message silently emptied stderr for every existing caller that reads
|
|
3130
|
+
// diagnostics there — `configure --json` asserts its refusal on stderr, and
|
|
3131
|
+
// it went blank.
|
|
3132
|
+
if (argv.includes("--json"))
|
|
3133
|
+
process.stdout.write(`${JSON.stringify({
|
|
3134
|
+
available: false,
|
|
3135
|
+
status: "unavailable",
|
|
3136
|
+
state: "unknown",
|
|
3137
|
+
error: message,
|
|
3138
|
+
...(diagnostic.recorded ? { diagnosticId: diagnostic.correlationId } : {}),
|
|
3139
|
+
})}\n`);
|
|
3140
|
+
console.error(`${message}${correlation}`);
|
|
3042
3141
|
process.exit(1);
|
|
3043
3142
|
}
|
|
3044
3143
|
}
|
package/dist/src/diagnostics.js
CHANGED
|
@@ -528,8 +528,19 @@ function scrubText(raw, repo) {
|
|
|
528
528
|
};
|
|
529
529
|
for (const exact of [repo, os.homedir()].filter(Boolean).sort((a, b) => b.length - a.length))
|
|
530
530
|
replace(new RegExp(exact.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`), "g"), "<path>");
|
|
531
|
-
|
|
532
|
-
|
|
531
|
+
// Either separator. GitHub issues its tokens with an UNDERSCORE after the
|
|
532
|
+
// prefix, so a hyphen-only pattern matched a shape GitHub never mints and
|
|
533
|
+
// missed every shape it does — a real token in an error message survived
|
|
534
|
+
// scrubbing and reached the bundle a user shares (KNODIN-10). Slack and
|
|
535
|
+
// OpenAI really do use a hyphen, so both separators must be accepted.
|
|
536
|
+
replace(/\b(?:ghp|github_pat|sk|xox[baprs])[-_][-A-Za-z0-9_]{10,}\b/g, "<secret>");
|
|
537
|
+
// The value pattern stops at whitespace, so `authorization: Bearer <token>`
|
|
538
|
+
// used to match only the word "Bearer" and leave the credential in place —
|
|
539
|
+
// the standard header form leaked the one thing worth redacting (KNODIN-10).
|
|
540
|
+
// An optional scheme is consumed first so the credential after it is the
|
|
541
|
+
// part that gets replaced. Only known schemes are skipped, so an ordinary
|
|
542
|
+
// `password=x next word` still redacts one value rather than eating prose.
|
|
543
|
+
replace(/\b(?:password|passwd|token|secret|api[_-]?key|authorization)\s*[=:]\s*(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi, "<secret>");
|
|
533
544
|
replace(/\b[A-Z]:\\(?:[^\s<>:"|?*]+\\)*[^\s<>:"|?*]*/g, "<path>");
|
|
534
545
|
replace(/(?:^|[\s('"`])\/(?:[^\s)'"`]+\/)*[^\s)'"`]*/g, "<path>");
|
|
535
546
|
replace(/\b(?:[A-Za-z0-9_.-]+\/)+(?:[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,12})\b/g, "<path>");
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { isSealedDatabase } from "./seal.js";
|
|
5
|
+
import { Database } from "./sqlite.js";
|
|
4
6
|
import { resolveDbPath, resolveStateDir } from "./state-paths.js";
|
|
5
7
|
const PROMOTION_SCHEMA_VERSION = 1;
|
|
6
8
|
const CANDIDATES_DIRECTORY = "candidates";
|
|
@@ -166,6 +168,29 @@ export function recoverInterruptedPromotion(repo) {
|
|
|
166
168
|
fsyncDirectory(path.dirname(marker));
|
|
167
169
|
return outcome;
|
|
168
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Whether the database currently at the active path carries embedded source.
|
|
173
|
+
*
|
|
174
|
+
* An active database that cannot be opened reports false ON PURPOSE. Replacing
|
|
175
|
+
* a damaged graph is exactly what promotion exists to do, and a graph too
|
|
176
|
+
* corrupt to open is the ordinary case for that — refusing here would break
|
|
177
|
+
* repair for the situation it is most needed in. The trade is deliberate: a
|
|
178
|
+
* sealed artifact that is ALSO unreadable can still be replaced, but such an
|
|
179
|
+
* artifact is already unrecoverable.
|
|
180
|
+
*/
|
|
181
|
+
function activeDatabaseIsSealed(activePath) {
|
|
182
|
+
let db = null;
|
|
183
|
+
try {
|
|
184
|
+
db = new Database(activePath, { readonly: true });
|
|
185
|
+
return isSealedDatabase(db);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
db?.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
169
194
|
/** Same-filesystem, marker-backed promotion. Caller must close and audit both databases first. */
|
|
170
195
|
export function promoteCandidateFile(repo, candidate) {
|
|
171
196
|
assertCandidate(repo, candidate);
|
|
@@ -176,6 +201,18 @@ export function promoteCandidateFile(repo, candidate) {
|
|
|
176
201
|
for (const suffix of ["-wal", "-shm"])
|
|
177
202
|
if (fs.existsSync(`${candidate.databasePath}${suffix}`))
|
|
178
203
|
throw new Error("candidate database has uncheckpointed sidecar files");
|
|
204
|
+
// Promotion REPLACES the active database. When that file is a sealed
|
|
205
|
+
// artifact, replacing it destroys the only copy of the source embedded in
|
|
206
|
+
// it. `repair` reaches here with a candidate built from the working tree,
|
|
207
|
+
// so an artifact sitting at the active path was emptied by a command whose
|
|
208
|
+
// entire purpose is to make a graph healthier (KNODIN-9).
|
|
209
|
+
//
|
|
210
|
+
// This is the chokepoint rather than a guard inside `repair`, because every
|
|
211
|
+
// promotion has the same consequence regardless of which caller arrives.
|
|
212
|
+
// An ordinary working graph is never sealed, so normal promotion and repair
|
|
213
|
+
// are unaffected.
|
|
214
|
+
if (fs.existsSync(activePath) && activeDatabaseIsSealed(activePath))
|
|
215
|
+
throw new Error("active database is a sealed artifact; promotion would replace it");
|
|
179
216
|
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
180
217
|
fsyncFile(candidate.databasePath);
|
|
181
218
|
const backupPath = path.join(stateDir, `db.sqlite.backup.${Date.now()}.${crypto.randomUUID().slice(0, 8)}`);
|
|
@@ -200,6 +237,30 @@ export function promoteCandidateFile(repo, candidate) {
|
|
|
200
237
|
fsyncDirectory(stateDir);
|
|
201
238
|
fs.rmSync(marker, { force: true });
|
|
202
239
|
fsyncDirectory(stateDir);
|
|
240
|
+
// The rename above moves the database out and nothing else, and the
|
|
241
|
+
// candidate directory is NOT empty at this point: the clean-index audit
|
|
242
|
+
// writes `status-audit-v1.json` beside the candidate database. So every
|
|
243
|
+
// SUCCESSFUL promotion left a directory behind — and `listCandidates`
|
|
244
|
+
// skips directories without a database, so the only cleanup path could
|
|
245
|
+
// never see them (KNODIN-2). Twelve had accumulated in this repository
|
|
246
|
+
// since 11 Aug and thirty in a reporting checkout, where they were
|
|
247
|
+
// reasonably read as promotions that had failed.
|
|
248
|
+
//
|
|
249
|
+
// Removed after the marker, so an interruption before this point still
|
|
250
|
+
// recovers through the marker rather than losing a candidate it needed.
|
|
251
|
+
//
|
|
252
|
+
// Swallowed rather than left to the enclosing `catch`: the promotion is
|
|
253
|
+
// already committed here — the marker is gone and the new database is
|
|
254
|
+
// fsynced into place — so a failure to delete leftover residue (a locked
|
|
255
|
+
// file, EPERM on a read-only parent) must not reach the rollback below,
|
|
256
|
+
// which would move the just-promoted database back into the candidate
|
|
257
|
+
// directory and restore the old graph over a promotion that succeeded.
|
|
258
|
+
try {
|
|
259
|
+
fs.rmSync(path.dirname(candidate.databasePath), { recursive: true, force: true });
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Residue only. `sweepAbandonedCandidates` collects it on the next run.
|
|
263
|
+
}
|
|
203
264
|
return fs.existsSync(backupPath) ? backupPath : null;
|
|
204
265
|
}
|
|
205
266
|
catch (error) {
|
|
@@ -214,6 +275,50 @@ export function promoteCandidateFile(repo, candidate) {
|
|
|
214
275
|
throw error;
|
|
215
276
|
}
|
|
216
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Remove candidate directories that hold no database, and report how many.
|
|
280
|
+
*
|
|
281
|
+
* `listCandidates` deliberately answers "what could be resumed", so it skips a
|
|
282
|
+
* directory with no `db.sqlite`. Cleanup used to run only over that list, which
|
|
283
|
+
* meant the one kind of directory guaranteed to have no database — the residue a
|
|
284
|
+
* successful promotion leaves once the database is renamed out — was the one
|
|
285
|
+
* kind nothing could ever delete (KNODIN-2).
|
|
286
|
+
*
|
|
287
|
+
* Promotion now removes its own directory, so this exists for two other cases:
|
|
288
|
+
* residue already accumulated by earlier versions, and directories left by a
|
|
289
|
+
* process that died between `allocateCandidate` and writing a database.
|
|
290
|
+
*
|
|
291
|
+
* Returns the count rather than nothing so callers can say what they swept
|
|
292
|
+
* instead of tidying up silently.
|
|
293
|
+
*/
|
|
294
|
+
export function sweepAbandonedCandidates(repo) {
|
|
295
|
+
const resolvedRepo = path.resolve(repo);
|
|
296
|
+
const root = candidateRoot(resolvedRepo);
|
|
297
|
+
let entries;
|
|
298
|
+
try {
|
|
299
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
let removed = 0;
|
|
305
|
+
for (const entry of entries) {
|
|
306
|
+
if (!entry.isDirectory() || !entry.name.startsWith("candidate-"))
|
|
307
|
+
continue;
|
|
308
|
+
const directory = path.join(root, entry.name);
|
|
309
|
+
if (fs.existsSync(path.join(directory, "db.sqlite")))
|
|
310
|
+
continue;
|
|
311
|
+
try {
|
|
312
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
313
|
+
removed++;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// Best effort. Failing to remove residue must not stop the caller's
|
|
317
|
+
// actual work, which is what this is being cleaned up alongside.
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return removed;
|
|
321
|
+
}
|
|
217
322
|
export function discardCandidateFiles(repo, candidate) {
|
|
218
323
|
assertCandidate(repo, candidate);
|
|
219
324
|
fs.rmSync(path.dirname(candidate.databasePath), { recursive: true, force: true });
|
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, listCandidates, promoteCandidateFile, recoverInterruptedPromotion, } from "./candidate-database.js";
|
|
31
|
+
import { allocateCandidate, assertCandidate, discardCandidateFiles, listCandidates, promoteCandidateFile, recoverInterruptedPromotion, sweepAbandonedCandidates, } 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";
|
|
@@ -41,7 +41,7 @@ import { isIndexablePath, makeWatchIgnorePredicate } from "./prune.js";
|
|
|
41
41
|
import { reflinkCopyFile } from "./reflink-copy.js";
|
|
42
42
|
import { readSarifLog, SARIF_DEFAULT_LIMITS, } from "./sarif-import.js";
|
|
43
43
|
import { readScipIndex, SCIP_DEFAULT_LIMITS, } from "./scip-import.js";
|
|
44
|
-
import { listSealedFiles, readSealedSource } from "./seal.js";
|
|
44
|
+
import { isSealedDatabase, listSealedFiles, readSealedSource } from "./seal.js";
|
|
45
45
|
import { isIndexableSourcePath } from "./source-policy.js";
|
|
46
46
|
import { Database } from "./sqlite.js";
|
|
47
47
|
import { lookupMirror, mayWriteToRepository, resolveDbPath, resolveStateDir, } from "./state-paths.js";
|
|
@@ -7273,6 +7273,12 @@ function sumCounts(counts) {
|
|
|
7273
7273
|
*/
|
|
7274
7274
|
function findResumableCandidate(repoPath) {
|
|
7275
7275
|
const head = gitHead(repoPath) ?? "";
|
|
7276
|
+
// Swept first, because the loop below can only discard what `listCandidates`
|
|
7277
|
+
// returns and that deliberately excludes database-less directories — which is
|
|
7278
|
+
// exactly what a promoted candidate leaves behind (KNODIN-2). Without this,
|
|
7279
|
+
// the comment above about not accumulating abandoned copies was false for the
|
|
7280
|
+
// most common case.
|
|
7281
|
+
sweepAbandonedCandidates(repoPath);
|
|
7276
7282
|
let resumable = null;
|
|
7277
7283
|
for (const candidate of listCandidates(repoPath)) {
|
|
7278
7284
|
let usable = false;
|
|
@@ -7683,8 +7689,54 @@ function recordIndexState(db, repoPath, relPath) {
|
|
|
7683
7689
|
return 0;
|
|
7684
7690
|
}
|
|
7685
7691
|
}
|
|
7692
|
+
/**
|
|
7693
|
+
* Whether this database is a sealed artifact — one carrying its own source.
|
|
7694
|
+
*
|
|
7695
|
+
* Decided from the seal attestation, which is what `seal` writes to declare an
|
|
7696
|
+
* artifact sealed, rather than from a row count in `sealed_file`: an artifact of
|
|
7697
|
+
* a repository that indexed no files carries no `sealed_file` rows and is still
|
|
7698
|
+
* sealed, and a count answers "does it embed source for anything" instead of
|
|
7699
|
+
* "is this an artifact".
|
|
7700
|
+
*
|
|
7701
|
+
* Cached per database: this is asked once per file during reconciliation, and
|
|
7702
|
+
* the answer cannot change for the life of a connection.
|
|
7703
|
+
*/
|
|
7704
|
+
const sealedArtifactDatabases = new WeakMap();
|
|
7705
|
+
function databaseIsSealedArtifact(db) {
|
|
7706
|
+
const known = sealedArtifactDatabases.get(db);
|
|
7707
|
+
if (known !== undefined)
|
|
7708
|
+
return known;
|
|
7709
|
+
// `isSealedDatabase` returns false for an ordinary working graph, including
|
|
7710
|
+
// one with no `meta` table at all, so no extra guard is needed here.
|
|
7711
|
+
const sealed = isSealedDatabase(db);
|
|
7712
|
+
sealedArtifactDatabases.set(db, sealed);
|
|
7713
|
+
return sealed;
|
|
7714
|
+
}
|
|
7715
|
+
/**
|
|
7716
|
+
* Whether a file missing from disk should be purged from this graph.
|
|
7717
|
+
*
|
|
7718
|
+
* For a working checkout, purging is right — the file really was deleted. For an
|
|
7719
|
+
* artifact whose source was materialized from `sealed_source`, a missing file
|
|
7720
|
+
* means the extraction was incomplete, not that the repository changed, and
|
|
7721
|
+
* purging silently rewrote the graph to match a truncated tree: 858 index_state
|
|
7722
|
+
* rows fell to 627 on one query while `sealed_file` still claimed 858, leaving
|
|
7723
|
+
* the artifact contradicting itself and answering confidently from the smaller
|
|
7724
|
+
* graph (KNODIN-6).
|
|
7725
|
+
*
|
|
7726
|
+
* Keeping every file-owned row makes the discrepancy visible instead: health
|
|
7727
|
+
* reports the file as indexed-but-missing, which is the truth, and a symbol
|
|
7728
|
+
* query against it can be told apart from a symbol that genuinely has no
|
|
7729
|
+
* callers. Guarding only the `index_state` row would be worse than not guarding
|
|
7730
|
+
* at all — the symbols, references and dependencies would still be gone, and
|
|
7731
|
+
* `index_state` would then claim coverage the graph no longer has.
|
|
7732
|
+
*/
|
|
7733
|
+
function shouldPurgeMissingFile(db) {
|
|
7734
|
+
return !databaseIsSealedArtifact(db);
|
|
7735
|
+
}
|
|
7686
7736
|
/** Drop a file's `index_state` row (used when a file is deleted). */
|
|
7687
7737
|
function removeIndexState(db, relPath) {
|
|
7738
|
+
if (!shouldPurgeMissingFile(db))
|
|
7739
|
+
return;
|
|
7688
7740
|
try {
|
|
7689
7741
|
db.run("DELETE FROM index_state WHERE filePath = ?", [relPath]);
|
|
7690
7742
|
}
|
|
@@ -7854,7 +7906,7 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
|
|
|
7854
7906
|
reindexedPaths.push(rel);
|
|
7855
7907
|
}
|
|
7856
7908
|
}
|
|
7857
|
-
else {
|
|
7909
|
+
else if (shouldPurgeMissingFile(db)) {
|
|
7858
7910
|
// Deletion or source-policy retirement — purge every file-owned row.
|
|
7859
7911
|
db.run("BEGIN TRANSACTION;");
|
|
7860
7912
|
try {
|
|
@@ -8524,6 +8576,8 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
|
|
|
8524
8576
|
};
|
|
8525
8577
|
watchQueues.set(resolvedRepoPath, queue);
|
|
8526
8578
|
const purgeFile = (relativePath) => {
|
|
8579
|
+
if (!shouldPurgeMissingFile(db))
|
|
8580
|
+
return;
|
|
8527
8581
|
db.run("BEGIN TRANSACTION;");
|
|
8528
8582
|
try {
|
|
8529
8583
|
deleteSymbolsForFile(db, relativePath);
|
|
@@ -8806,6 +8860,16 @@ export async function getOrInitDb(repoPath, options = {}) {
|
|
|
8806
8860
|
// Runs after the CREATE TABLEs below, since the table must exist.
|
|
8807
8861
|
const needsOrphanPurge = storedVersion < KNODIN_SCHEMA_VERSION;
|
|
8808
8862
|
const needsEmbeddingRepresentationRefresh = storedVersion >= 22 && storedVersion < 23;
|
|
8863
|
+
// An artifact sealed by an older build carries that build's schema
|
|
8864
|
+
// version, so a newer knodin opening it for write lands here and drops
|
|
8865
|
+
// every table below — including the embedded source, which no working
|
|
8866
|
+
// tree can supply again. Today's artifacts report the current version
|
|
8867
|
+
// and never reach it; one from a single release ago would.
|
|
8868
|
+
//
|
|
8869
|
+
// Refuse rather than skip: skipping would leave the caller holding a
|
|
8870
|
+
// database it believes is writable and current, and it is neither.
|
|
8871
|
+
if (storedVersion < LAST_REBUILD_SCHEMA_VERSION && isSealedDatabase(db))
|
|
8872
|
+
throw new Error("refusing to rebuild the schema of a sealed artifact: it would drop the embedded source, which is the only copy");
|
|
8809
8873
|
if (storedVersion < LAST_REBUILD_SCHEMA_VERSION) {
|
|
8810
8874
|
db.run("DROP TRIGGER IF EXISTS after_symbol_insert;");
|
|
8811
8875
|
db.run("DROP TRIGGER IF EXISTS after_symbol_delete;");
|
|
@@ -11901,7 +11965,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
11901
11965
|
await indexFile(absolute, file, resolved, db);
|
|
11902
11966
|
recordIndexState(db, resolved, file);
|
|
11903
11967
|
}
|
|
11904
|
-
else {
|
|
11968
|
+
else if (shouldPurgeMissingFile(db)) {
|
|
11905
11969
|
db.run("BEGIN TRANSACTION;");
|
|
11906
11970
|
try {
|
|
11907
11971
|
deleteSymbolsForFile(db, file);
|
|
@@ -12887,6 +12951,9 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
12887
12951
|
let indexedFiles = 0;
|
|
12888
12952
|
let filesWithSymbols = 0;
|
|
12889
12953
|
let lastSuccessfulReconciliation = null;
|
|
12954
|
+
// No database, or counters that threw, means the zeros below were
|
|
12955
|
+
// never measured.
|
|
12956
|
+
let countsUnknown = !db;
|
|
12890
12957
|
if (db) {
|
|
12891
12958
|
try {
|
|
12892
12959
|
schemaVersion =
|
|
@@ -12903,7 +12970,10 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
12903
12970
|
}
|
|
12904
12971
|
catch {
|
|
12905
12972
|
// A schema migration can briefly make counters unavailable;
|
|
12906
|
-
// the live operation remains the authoritative status.
|
|
12973
|
+
// the live operation remains the authoritative status. Whatever
|
|
12974
|
+
// was read before the throw is partial, so report the counts as
|
|
12975
|
+
// unknown rather than presenting a half-filled tally as measured.
|
|
12976
|
+
countsUnknown = true;
|
|
12907
12977
|
}
|
|
12908
12978
|
if (!activeDb)
|
|
12909
12979
|
db.close();
|
|
@@ -12926,6 +12996,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
12926
12996
|
? Math.round((Math.min(indexedFiles, sourceFiles.length) / sourceFiles.length) * 10000) / 100
|
|
12927
12997
|
: 100,
|
|
12928
12998
|
skipped,
|
|
12999
|
+
...(countsUnknown ? { countsUnknown: true } : {}),
|
|
12929
13000
|
},
|
|
12930
13001
|
orphaned: { embeddings: 0, references: 0, dependencies: 0 },
|
|
12931
13002
|
missing: { files: [], records: [] },
|
|
@@ -12957,6 +13028,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
12957
13028
|
filesWithSymbols: 0,
|
|
12958
13029
|
percent: sourceFiles.length ? 0 : 100,
|
|
12959
13030
|
skipped: buildCoverageSkips(collected.skippedByExtension, null),
|
|
13031
|
+
// There is no database to count, so these are placeholders.
|
|
13032
|
+
countsUnknown: true,
|
|
12960
13033
|
},
|
|
12961
13034
|
orphaned: { embeddings: 0, references: 0, dependencies: 0 },
|
|
12962
13035
|
missing: {
|
|
@@ -13129,6 +13202,9 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13129
13202
|
filesWithSymbols: 0,
|
|
13130
13203
|
percent: sourceFiles.length ? 0 : 100,
|
|
13131
13204
|
skipped: coverageSkips,
|
|
13205
|
+
// The schema is not one this build can read, so index_state is
|
|
13206
|
+
// not counted here even though rows may well exist.
|
|
13207
|
+
countsUnknown: true,
|
|
13132
13208
|
},
|
|
13133
13209
|
orphaned: { embeddings: 0, references: 0, dependencies: 0 },
|
|
13134
13210
|
missing: { files: sourceFiles, records: schemaProblems },
|
|
@@ -13477,7 +13553,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13477
13553
|
else
|
|
13478
13554
|
counts.indexed++;
|
|
13479
13555
|
}
|
|
13480
|
-
else {
|
|
13556
|
+
else if (shouldPurgeMissingFile(db)) {
|
|
13481
13557
|
db.run("BEGIN TRANSACTION;");
|
|
13482
13558
|
try {
|
|
13483
13559
|
deleteSymbolsForFile(db, file);
|
|
@@ -13493,6 +13569,17 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13493
13569
|
}
|
|
13494
13570
|
counts.removed++;
|
|
13495
13571
|
}
|
|
13572
|
+
else {
|
|
13573
|
+
// A sealed artifact's rows ARE the artifact: it has no working
|
|
13574
|
+
// tree, so "this file is missing" is its normal condition rather
|
|
13575
|
+
// than drift to clean up (KNODIN-9).
|
|
13576
|
+
//
|
|
13577
|
+
// This was the sixth purge site and the only unguarded one.
|
|
13578
|
+
// `removeIndexState` above carries its own guard, which is why
|
|
13579
|
+
// the damage looked so strange: index_state survived while the
|
|
13580
|
+
// symbols, references and dependencies around it were deleted.
|
|
13581
|
+
counts.skipped++;
|
|
13582
|
+
}
|
|
13496
13583
|
committedWork = true;
|
|
13497
13584
|
setMeta(db, "repairPostprocessingPending", "1");
|
|
13498
13585
|
completedFiles = fileIndex + 1;
|
|
@@ -13866,7 +13953,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13866
13953
|
await indexFile(absPath, relativePath, repoPath, db);
|
|
13867
13954
|
recordIndexState(db, repoPath, relativePath);
|
|
13868
13955
|
}
|
|
13869
|
-
else {
|
|
13956
|
+
else if (shouldPurgeMissingFile(db)) {
|
|
13870
13957
|
// File was deleted
|
|
13871
13958
|
db.run("BEGIN TRANSACTION;");
|
|
13872
13959
|
try {
|
|
@@ -26,7 +26,13 @@ const CODE_EXTENSIONS = [
|
|
|
26
26
|
".cpp",
|
|
27
27
|
".h",
|
|
28
28
|
".hpp",
|
|
29
|
-
|
|
29
|
+
// Longest first. `referenceAt` takes the FIRST extension that matches at a
|
|
30
|
+
// cursor, so a shorter extension listed earlier wins and truncates the path:
|
|
31
|
+
// `src/main.cpp:3:4` parsed as `src/main.c` with no line, pointing diagnosis
|
|
32
|
+
// at a file that does not exist. `.tsx` only worked because it happened to
|
|
33
|
+
// precede `.ts`. Sorting makes longest-match the rule rather than an
|
|
34
|
+
// accident of list order.
|
|
35
|
+
].sort((left, right) => right.length - left.length);
|
|
30
36
|
const MAX_ANALYSIS_BYTES = 4 * 1024 * 1024;
|
|
31
37
|
const MAX_SOURCE_FILE_BYTES = 1024 * 1024;
|
|
32
38
|
const MAX_MANIFEST_BYTES = 256 * 1024;
|
package/dist/src/init.js
CHANGED
|
@@ -20,7 +20,14 @@ export class InitializationHealthError extends Error {
|
|
|
20
20
|
constructor(indexResult) {
|
|
21
21
|
const firstIssue = indexResult.verification.missing.files[0] ?? indexResult.verification.missing.records[0];
|
|
22
22
|
const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
|
|
23
|
-
|
|
23
|
+
// Describes whichever condition actually gated, for the same reason
|
|
24
|
+
// `formatIndexVerificationError` does: printing the issue count
|
|
25
|
+
// unconditionally produced "0 graph issue(s) remain" as a failure reason —
|
|
26
|
+
// a sentence that reports no problem and then recommends repair (KNODIN-7).
|
|
27
|
+
const condition = indexResult.verification.issueCount > 0
|
|
28
|
+
? `${indexResult.verification.issueCount.toLocaleString()} graph issue(s) remain`
|
|
29
|
+
: `graph verification reported status "${indexResult.verification.status}"`;
|
|
30
|
+
super(`knodin init: lifecycle hooks were installed, but ${condition}.${detail} Run \`knodin repair\`.`);
|
|
24
31
|
this.name = "InitializationHealthError";
|
|
25
32
|
this.indexResult = indexResult;
|
|
26
33
|
}
|
|
@@ -1385,9 +1392,16 @@ export async function initializeRepository(repo, options) {
|
|
|
1385
1392
|
// Only a full index has verified the whole graph, so only a full index
|
|
1386
1393
|
// may fail the run on graph health. A configuration-scoped pass saw a
|
|
1387
1394
|
// handful of files and knows nothing about the rest.
|
|
1395
|
+
// Gated on damage, not on `status !== "healthy"` — the same correction
|
|
1396
|
+
// `knodin index` needed (KNODIN-7). A freshly written graph is routinely
|
|
1397
|
+
// `stale` because freshness has not been probed since the index wrote,
|
|
1398
|
+
// and an untracked file makes that the normal outcome, so the old
|
|
1399
|
+
// condition failed `knodin init` on the very first run in a new
|
|
1400
|
+
// repository while its own verification had found nothing wrong.
|
|
1388
1401
|
if (!scopedIndexPaths &&
|
|
1389
1402
|
isIndexResult(indexResult) &&
|
|
1390
|
-
indexResult.verification.status
|
|
1403
|
+
(indexResult.verification.status === "repair-needed" ||
|
|
1404
|
+
indexResult.verification.issueCount > 0)) {
|
|
1391
1405
|
throw new InitializationHealthError(indexResult);
|
|
1392
1406
|
}
|
|
1393
1407
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# knodin 0.10.5
|
|
2
|
+
|
|
3
|
+
Six fixes, all found by treating knodin's own artifacts as something another
|
|
4
|
+
program has to consume. A verifier team spent a day characterizing sealed
|
|
5
|
+
artifacts and the `--repo` query path; every defect below came out of measuring
|
|
6
|
+
state before and after an operation rather than reading the code.
|
|
7
|
+
|
|
8
|
+
The theme is the one 0.10.2 through 0.10.4 have been working on: **a result that
|
|
9
|
+
looks complete while quietly being wrong**, and a command that reports success it
|
|
10
|
+
has not earned.
|
|
11
|
+
|
|
12
|
+
## Candidate directories no longer accumulate
|
|
13
|
+
|
|
14
|
+
A checkout had thirty candidate directories under `.knodin/candidates/` going
|
|
15
|
+
back weeks, none promoted, while `knodin repair` exited 0. It read as clean
|
|
16
|
+
indexes silently failing to promote. This repository had twelve of its own.
|
|
17
|
+
|
|
18
|
+
**They were the residue of successful promotions, not failures.** Three facts
|
|
19
|
+
composed:
|
|
20
|
+
|
|
21
|
+
- Promotion renames the database out and leaves the directory.
|
|
22
|
+
- The directory is not empty: the clean-index audit writes
|
|
23
|
+
`status-audit-v1.json` beside the candidate database, so something always
|
|
24
|
+
remains after the database leaves.
|
|
25
|
+
- `listCandidates` skips directories without a database — correctly, since it
|
|
26
|
+
answers "what can be resumed" — and it is the only input to the sole cleanup
|
|
27
|
+
path. So the one kind of directory guaranteed to have no database was the one
|
|
28
|
+
kind nothing could ever delete.
|
|
29
|
+
|
|
30
|
+
Promotion now removes its own directory, and a sweep clears residue left by
|
|
31
|
+
earlier versions and by processes that died before writing a database. A
|
|
32
|
+
resumable candidate — one that still holds a database — is untouched, so
|
|
33
|
+
0.10.3's resumable clean index is unaffected.
|
|
34
|
+
|
|
35
|
+
If you have accumulated directories, the next clean index removes them.
|
|
36
|
+
|
|
37
|
+
## `seal` and `sealed` exit non-zero when they refuse
|
|
38
|
+
|
|
39
|
+
Both reported `ok: false` and exited **0**. The exit code carried no
|
|
40
|
+
information at all:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
$ knodin seal --output artifact.sqlite # in a directory with no index
|
|
44
|
+
seal:
|
|
45
|
+
ok: false
|
|
46
|
+
code: database-missing
|
|
47
|
+
$ echo $?
|
|
48
|
+
0
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
So `knodin seal --output X && upload X` ran the upload after a refusal. `sealed`
|
|
52
|
+
had the same contradiction on a missing artifact.
|
|
53
|
+
|
|
54
|
+
Both now exit non-zero when they report `ok: false`. Worth stating what was
|
|
55
|
+
already right: `seal` fails closed on content — it writes no file when it
|
|
56
|
+
refuses, so nothing broken was ever shipped by this. The failure simply arrived
|
|
57
|
+
one step late and attributed to the wrong command.
|
|
58
|
+
|
|
59
|
+
## `--json` produces JSON on failure, not a bare string
|
|
60
|
+
|
|
61
|
+
`knodin --json sealed <artifact> <symbol>` against a read-only artifact printed:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
attempt to write a readonly database
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Not JSON. A consumer's parser throws a decode error, which gets triaged as a
|
|
68
|
+
malformed response or a version mismatch — while the string it failed to parse
|
|
69
|
+
already named the cause.
|
|
70
|
+
|
|
71
|
+
Thrown failures under `--json` now emit an envelope shaped like the one the
|
|
72
|
+
graph-backed paths already return for the same class of condition, so a caller
|
|
73
|
+
can handle both surfaces with one branch:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{"available": false, "status": "unavailable", "state": "unknown",
|
|
77
|
+
"error": "attempt to write a readonly database"}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Without `--json` the human message on stderr is unchanged.
|
|
81
|
+
|
|
82
|
+
## `sealed` no longer mistakes a flag for a symbol
|
|
83
|
+
|
|
84
|
+
`knodin sealed <artifact> --tokens 200` explained a symbol literally named
|
|
85
|
+
`--tokens`, because the symbol was read positionally as "whatever followed the
|
|
86
|
+
artifact". Under a tight budget the fabricated empty explain block then evicted
|
|
87
|
+
`commit`, `ref` and `sealedAt` — the fields an inspect-only call exists to
|
|
88
|
+
return.
|
|
89
|
+
|
|
90
|
+
Arguments are now extracted with the shared parser, which already knows which
|
|
91
|
+
flags consume the token after them, so neither `--tokens` nor its value `200` is
|
|
92
|
+
taken as a symbol.
|
|
93
|
+
|
|
94
|
+
## A sealed artifact is no longer silently rewritten to match a truncated tree
|
|
95
|
+
|
|
96
|
+
The sharpest finding. A sealed artifact can be materialized — its embedded
|
|
97
|
+
source extracted next to it — and queried through `--repo`. If that extraction
|
|
98
|
+
is incomplete, knodin reconciled the graph to match:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
index_state: 858 → 627 persisted to disk
|
|
102
|
+
symbols: 5358 → 4791 567 symbols destroyed
|
|
103
|
+
sealed_file: 858 → 858 the artifact's own file list, untouched
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The artifact ends up **contradicting itself** and still answering confidently.
|
|
107
|
+
Querying a symbol that lived only in the missing files returned exit 0, an empty
|
|
108
|
+
result and a null identity — indistinguishable from a symbol that genuinely has
|
|
109
|
+
no callers. A file truncated mid-content behaved identically.
|
|
110
|
+
|
|
111
|
+
For a working checkout, pruning rows for deleted files is right: the files really
|
|
112
|
+
were deleted. For an artifact carrying its own source, a missing file means the
|
|
113
|
+
extraction was incomplete, not that the repository changed.
|
|
114
|
+
|
|
115
|
+
Every file-owned row — `index_state`, symbols, references, dependencies — is now
|
|
116
|
+
preserved on a database carrying a seal attestation. Health reports those files
|
|
117
|
+
as indexed-but-missing, which is the truth, instead of quietly forgetting them.
|
|
118
|
+
|
|
119
|
+
Preserving `index_state` alone would have been worse than preserving nothing: the
|
|
120
|
+
567 symbols in the table above would still have been destroyed, and `index_state`
|
|
121
|
+
would then have claimed coverage the graph no longer had.
|
|
122
|
+
|
|
123
|
+
## The first clean index of a repository no longer fails
|
|
124
|
+
|
|
125
|
+
Found while building a test fixture for the fix above, and it affects every new
|
|
126
|
+
checkout. With any untracked file present, the **first** `knodin index --clean`
|
|
127
|
+
exited 1 — after its own progress output said the work succeeded:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
[index:verifying] Promoted graph has 0 issue(s)
|
|
131
|
+
[index:completed] Clean index completed
|
|
132
|
+
knodin index: requested work completed, but 0 graph issue(s) remain. Run `knodin repair`.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Zero issues, and a failure — recommending repair for a graph it had just
|
|
136
|
+
verified as undamaged. The second run exited 0, so the first run's work had been
|
|
137
|
+
fine all along.
|
|
138
|
+
|
|
139
|
+
The gate read `status !== "healthy"` while the message printed an issue count.
|
|
140
|
+
An untracked file makes freshness `stale-working-tree` and verification `stale`,
|
|
141
|
+
so a correct index was rejected for a fact about the working tree.
|
|
142
|
+
|
|
143
|
+
Failure is now gated on damage — `repair-needed`, or a non-zero issue count — and
|
|
144
|
+
the message names whichever condition actually gated instead of printing a count
|
|
145
|
+
that contradicts it. `--json` also emits the result on this path, which
|
|
146
|
+
previously wrote zero bytes to stdout.
|
|
147
|
+
|
|
148
|
+
`knodin init` carried the identical gate and the identical message, and it is the
|
|
149
|
+
first command anyone runs in a new repository — so it is corrected the same way.
|
|
150
|
+
|
|
151
|
+
Anything running `knodin init` or `knodin index --clean && …` on a fresh checkout
|
|
152
|
+
was failing on the first run, every time.
|
|
153
|
+
|
|
154
|
+
## Known, not fixed here
|
|
155
|
+
|
|
156
|
+
- **Querying through `--repo` still writes to the database**, so a read-only
|
|
157
|
+
mount cannot be used. It fails loudly — `attempt to write a readonly database`
|
|
158
|
+
— rather than silently. `sealed` does not mutate the artifact at all, but still
|
|
159
|
+
requires a writable file for symbol queries.
|
|
160
|
+
- **Sealing the same commit twice produces different bytes**; `sealedAt` is the
|
|
161
|
+
only differing field, so a hash identifies an upload rather than a commit.
|
|
162
|
+
- **`sealAttestation` embeds the sealer's absolute local path**, git remote URL
|
|
163
|
+
and current branch, which travel with any artifact that crosses a trust
|
|
164
|
+
boundary.
|
|
165
|
+
|
|
166
|
+
The last three are recorded on KNODIN-6.
|
|
167
|
+
|
|
168
|
+
## Compatibility
|
|
169
|
+
|
|
170
|
+
Compatible. No interface is removed or renamed. Exit codes change on paths that
|
|
171
|
+
previously reported success incorrectly — a script relying on `seal` exiting 0
|
|
172
|
+
after a refusal will now see the failure, which is the point.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# knodin 0.10.6
|
|
2
|
+
|
|
3
|
+
Four fixes, continuing the theme 0.10.2 through 0.10.5 have been working on: **a
|
|
4
|
+
result that looks complete while quietly being wrong**. Three of the four were
|
|
5
|
+
found the same way — by writing a test per case and watching which ones failed,
|
|
6
|
+
rather than by reading the code and deciding it looked correct.
|
|
7
|
+
|
|
8
|
+
Two of them disclose credentials, so read that section first if you have ever
|
|
9
|
+
attached a diagnostics bundle to a support request.
|
|
10
|
+
|
|
11
|
+
## Credentials survived redaction
|
|
12
|
+
|
|
13
|
+
`knodin` scrubs secrets out of recorded failures before they can reach a
|
|
14
|
+
diagnostics bundle — the artefact a user hands to someone else when asking for
|
|
15
|
+
help. Two things it claimed to redact, it did not.
|
|
16
|
+
|
|
17
|
+
**GitHub tokens were never redacted at all.** The pattern required a hyphen
|
|
18
|
+
after the prefix:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
\b(?:ghp|github_pat|sk|xox[baprs])-[-A-Za-z0-9_]{10,}\b
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
GitHub issues both its formats with an *underscore*. So the two GitHub
|
|
25
|
+
alternatives matched a shape GitHub never mints and missed every shape it does.
|
|
26
|
+
Slack and OpenAI genuinely do use a hyphen, so the separator now accepts either
|
|
27
|
+
rather than being swapped.
|
|
28
|
+
|
|
29
|
+
**An authorization header leaked the credential and redacted its label.** The
|
|
30
|
+
value pattern stops at whitespace, so for the standard form
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
authorization: Bearer <token>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
it matched only the word `Bearer` — replacing the part that identifies the
|
|
37
|
+
header and preserving the part that authenticates. Exactly inverted. An optional
|
|
38
|
+
known scheme is now consumed first, so the credential after it is what gets
|
|
39
|
+
replaced.
|
|
40
|
+
|
|
41
|
+
This was quiet in an unhelpful way. The journal stores only a *fingerprint* of
|
|
42
|
+
the scrubbed message, never the message, so an unredacted credential is
|
|
43
|
+
invisible locally and appears only in the bundle that gets shared. The tests
|
|
44
|
+
assert redaction by fingerprint equivalence for that reason, and by
|
|
45
|
+
value-independence — two messages differing only in the secret must fingerprint
|
|
46
|
+
identically — which is how the `Bearer` case was found. An assertion written
|
|
47
|
+
against expected output text would have passed, because the output *did* contain
|
|
48
|
+
a redaction marker.
|
|
49
|
+
|
|
50
|
+
Redaction is on by default, so this needed no unusual configuration to hit.
|
|
51
|
+
|
|
52
|
+
A second redactor, in output compression, has different gaps in the opposite
|
|
53
|
+
direction: it catches GitHub classic but not fine-grained, and misses Slack and
|
|
54
|
+
OpenAI. Neither pattern list is a superset of the other. That one is filed
|
|
55
|
+
rather than patched here, because adding three regexes to one of two diverging
|
|
56
|
+
lists would only re-create the problem later.
|
|
57
|
+
|
|
58
|
+
## `repair` emptied a sealed artifact
|
|
59
|
+
|
|
60
|
+
Pointed at a sealed artifact, `knodin repair` deleted its symbols, references
|
|
61
|
+
and dependencies. A sealed artifact's embedded source is the only copy of that
|
|
62
|
+
source, so this was unrecoverable.
|
|
63
|
+
|
|
64
|
+
0.10.5 audited five sites that purge rows for files missing from disk and
|
|
65
|
+
guarded all five. This was a **sixth**, in repair's own removal branch, which
|
|
66
|
+
never consulted the guard.
|
|
67
|
+
|
|
68
|
+
It also explains a symptom that made no sense while the mechanism was unknown:
|
|
69
|
+
`index_state` survived intact while everything around it was deleted.
|
|
70
|
+
`removeIndexState`, called *inside* that same block, carries its own guard and
|
|
71
|
+
declined correctly — so the damage was the guard working on one line and being
|
|
72
|
+
absent from the four above it.
|
|
73
|
+
|
|
74
|
+
Two adjacent hazards are guarded alongside it: a schema rebuild would drop every
|
|
75
|
+
table of an artifact sealed by an older build, and promotion would overwrite a
|
|
76
|
+
sealed artifact sitting at the active path.
|
|
77
|
+
|
|
78
|
+
## `status` reported an unreadable graph as a measured zero
|
|
79
|
+
|
|
80
|
+
A 94-file repository whose database was missing printed:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
Graph or lifecycle needs repair: 95 issue(s) found
|
|
84
|
+
(94 source files, 0 indexed files, 0 files with symbols)
|
|
85
|
+
First issue: <an arbitrary source file>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`repair`, seconds later, reported 94 indexed and 53 with symbols. The two
|
|
89
|
+
commands never counted differently. `status` was rendering a graph it could not
|
|
90
|
+
read as a measurement of zero.
|
|
91
|
+
|
|
92
|
+
The `95` was arithmetic, not a tally: the issue count is
|
|
93
|
+
`missing.files + missing.records`, and the no-database branch fills
|
|
94
|
+
`missing.files` with every source file. Ninety-four files plus one record. So
|
|
95
|
+
the count scaled with repository size, and a graph that had simply never been
|
|
96
|
+
built read as damage proportional to how much code you have.
|
|
97
|
+
|
|
98
|
+
Three sites emitted zeros they had not measured, and now report the counts as
|
|
99
|
+
unknown instead:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
Graph or lifecycle needs repair: 1 issue(s) found
|
|
103
|
+
(3 source files, indexed and symbol counts unknown; the graph could not be read)
|
|
104
|
+
First issue: local index database is missing
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`missing.files` is deliberately still populated. `repair` consumes it as its
|
|
108
|
+
worklist and `seal` reads it as dirty paths, so emptying it — the obvious way to
|
|
109
|
+
stop the count inflating — would have left repair with nothing to do.
|
|
110
|
+
|
|
111
|
+
## C++ diagnostics pointed at files that do not exist
|
|
112
|
+
|
|
113
|
+
Source extensions are matched first-listed rather than longest, and `.c`
|
|
114
|
+
preceded `.cpp`. A C++ stack frame did not merely lose precision:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
in at f (src/main.cpp:3:4)
|
|
118
|
+
was { path: "src/main.c", line: null, column: null }
|
|
119
|
+
now { path: "src/main.cpp", line: 3, column: 4 }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A path to a file that does not exist, with the location dropped. `.tsx` worked
|
|
123
|
+
only because it happened to precede `.ts`; longest-match is now the rule rather
|
|
124
|
+
than an accident of list order.
|
|
125
|
+
|
|
126
|
+
## Coverage measures the parse path again
|
|
127
|
+
|
|
128
|
+
Not a shipped behaviour change, but worth recording because it changes what the
|
|
129
|
+
project's own numbers mean.
|
|
130
|
+
|
|
131
|
+
Generic source files are parsed off the main thread, and v8 coverage instruments
|
|
132
|
+
only the isolate it runs in — so everything the parse path did executed inside a
|
|
133
|
+
worker thread where it could not be observed, and was reported as untested. On
|
|
134
|
+
an identical set of shards, with no test changed, disabling parse workers for
|
|
135
|
+
the coverage run alone moved branch coverage from 74.85% to 77.16%: 414 branches
|
|
136
|
+
the suite was already exercising.
|
|
137
|
+
|
|
138
|
+
Sixteen functions had been sitting in the uncovered map looking like dead code.
|
|
139
|
+
They were not.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knodin",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.6",
|
|
4
4
|
"knodin": {
|
|
5
5
|
"compatibility": "compatible"
|
|
6
6
|
},
|
|
@@ -23,32 +23,44 @@
|
|
|
23
23
|
"knodin": "dist/bin/launcher.js"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
|
+
"*.wasm",
|
|
27
|
+
"benchmarks/competitors/SYNTHESIS.md",
|
|
26
28
|
"dist",
|
|
27
|
-
"skills",
|
|
28
|
-
"docs/prompts/declare-multi-repository-system.md",
|
|
29
|
-
"docs/DEAD-CODE-AND-IMPACT.md",
|
|
30
|
-
"docs/DOCTOR-AND-UPDATES.md",
|
|
31
29
|
"docs/BACKUP-RETENTION.md",
|
|
32
|
-
"docs/DIAGNOSTICS.md",
|
|
33
30
|
"docs/BEHAVIORAL-CONTRACT.md",
|
|
34
|
-
"docs/
|
|
31
|
+
"docs/CLI.md",
|
|
32
|
+
"docs/COMMAND-OUTPUT-COMPRESSION.md",
|
|
35
33
|
"docs/COMPARISON.md",
|
|
36
34
|
"docs/COMPETITIVE-LANDSCAPE-2026-08.md",
|
|
37
|
-
"docs/
|
|
35
|
+
"docs/CONTAINED-EXECUTION.md",
|
|
36
|
+
"docs/DEAD-CODE-AND-IMPACT.md",
|
|
37
|
+
"docs/DEMO.md",
|
|
38
|
+
"docs/DIAGNOSTICS.md",
|
|
39
|
+
"docs/DOCTOR-AND-UPDATES.md",
|
|
40
|
+
"docs/GIT-HISTORY-REVIEW.md",
|
|
38
41
|
"docs/HANDOFF.md",
|
|
42
|
+
"docs/INDEXING-POLICY-AND-PROVENANCE.md",
|
|
39
43
|
"docs/INSTALLATION.md",
|
|
40
44
|
"docs/MCP.md",
|
|
41
|
-
"docs/COMMAND-OUTPUT-COMPRESSION.md",
|
|
42
|
-
"docs/CONTAINED-EXECUTION.md",
|
|
43
45
|
"docs/PROGRESSIVE-EVIDENCE.md",
|
|
44
|
-
"docs/GIT-HISTORY-REVIEW.md",
|
|
45
|
-
"docs/SCIP-IMPORT.md",
|
|
46
|
-
"docs/CLI.md",
|
|
47
46
|
"docs/PT-ACCESS-RECOMMENDATION.md",
|
|
48
|
-
"docs/REPOSITORIES-AND-WORKTREES.md",
|
|
49
47
|
"docs/RELEASE-0.3-EVIDENCE.md",
|
|
50
|
-
"docs/
|
|
48
|
+
"docs/REPOSITORIES-AND-WORKTREES.md",
|
|
49
|
+
"docs/SCIP-IMPORT.md",
|
|
51
50
|
"docs/SHARED-INDEX-CONTRACT.md",
|
|
51
|
+
"docs/SIGNED-UPDATES.md",
|
|
52
|
+
"docs/SYSTEMS-AND-RELATIONSHIPS.md",
|
|
53
|
+
"docs/TELEMETRY.md",
|
|
54
|
+
"docs/TOKEN-OPTIMIZER-SCORECARD.md",
|
|
55
|
+
"docs/assets/knodin-favicon.svg",
|
|
56
|
+
"docs/prompts/declare-multi-repository-system.md",
|
|
57
|
+
"docs/releases/0.10.0.md",
|
|
58
|
+
"docs/releases/0.10.1.md",
|
|
59
|
+
"docs/releases/0.10.2.md",
|
|
60
|
+
"docs/releases/0.10.3.md",
|
|
61
|
+
"docs/releases/0.10.4.md",
|
|
62
|
+
"docs/releases/0.10.5.md",
|
|
63
|
+
"docs/releases/0.10.6.md",
|
|
52
64
|
"docs/releases/0.3.0.md",
|
|
53
65
|
"docs/releases/0.4.0.md",
|
|
54
66
|
"docs/releases/0.4.1.md",
|
|
@@ -71,24 +83,14 @@
|
|
|
71
83
|
"docs/releases/0.8.6.md",
|
|
72
84
|
"docs/releases/0.8.7.md",
|
|
73
85
|
"docs/releases/0.9.0.md",
|
|
74
|
-
"docs/releases/0.10.0.md",
|
|
75
|
-
"docs/releases/0.10.1.md",
|
|
76
|
-
"docs/releases/0.10.2.md",
|
|
77
|
-
"docs/releases/0.10.3.md",
|
|
78
|
-
"docs/releases/0.10.4.md",
|
|
79
|
-
"docs/assets/knodin-favicon.svg",
|
|
80
|
-
"docs/SYSTEMS-AND-RELATIONSHIPS.md",
|
|
81
|
-
"docs/TELEMETRY.md",
|
|
82
|
-
"docs/TOKEN-OPTIMIZER-SCORECARD.md",
|
|
83
|
-
"benchmarks/competitors/SYNTHESIS.md",
|
|
84
86
|
"roadmap/competitive-roadmap.md",
|
|
85
87
|
"schemas/release-attestation-v1.schema.json",
|
|
86
|
-
"schemas/support-bundle-v2.schema.json",
|
|
87
|
-
"schemas/shared-index-config-v1.schema.json",
|
|
88
88
|
"schemas/shared-index-branch-pointer-v1.schema.json",
|
|
89
|
+
"schemas/shared-index-config-v1.schema.json",
|
|
89
90
|
"schemas/shared-index-manifest-v1.schema.json",
|
|
90
91
|
"schemas/shared-index-provenance-v1.schema.json",
|
|
91
|
-
"
|
|
92
|
+
"schemas/support-bundle-v2.schema.json",
|
|
93
|
+
"skills"
|
|
92
94
|
],
|
|
93
95
|
"engines": {
|
|
94
96
|
"node": ">=24.0.0"
|