knodin 0.8.7 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/bin/cli.js +341 -91
  2. package/dist/fixtures/update-verification/config/application.xml +4 -0
  3. package/dist/fixtures/update-verification/dbt/manifest.json +17 -0
  4. package/dist/fixtures/update-verification/dbt/models/accounts.sql +1 -0
  5. package/dist/fixtures/update-verification/dbt/models/contacts.sql +1 -0
  6. package/dist/fixtures/update-verification/fixture.json +147 -0
  7. package/dist/fixtures/update-verification/force-app/main/default/classes/UpdateFixture.cls +14 -0
  8. package/dist/fixtures/update-verification/force-app/main/default/flows/Update_Verification.flow-meta.xml +9 -0
  9. package/dist/fixtures/update-verification/package.json +5 -0
  10. package/dist/fixtures/update-verification/prisma/schema.prisma +10 -0
  11. package/dist/fixtures/update-verification/sql/functions.sql +16 -0
  12. package/dist/fixtures/update-verification/src/CsharpFixture.cs +6 -0
  13. package/dist/fixtures/update-verification/src/JavaFixture.java +9 -0
  14. package/dist/fixtures/update-verification/src/javascript.js +7 -0
  15. package/dist/fixtures/update-verification/src/python_fixture.py +6 -0
  16. package/dist/fixtures/update-verification/src/typescript.ts +7 -0
  17. package/dist/fixtures/update-verification/terraform/main.tf +7 -0
  18. package/dist/fixtures/update-verification/terraform/modules/verified-child/main.tf +3 -0
  19. package/dist/src/agent-integration.js +26 -3
  20. package/dist/src/backup-retention.js +811 -0
  21. package/dist/src/cli-model.js +49 -8
  22. package/dist/src/docs-sections.js +1 -0
  23. package/dist/src/doctor.js +142 -12
  24. package/dist/src/engine/embeddings.js +8 -1
  25. package/dist/src/engine/index.js +1272 -645
  26. package/dist/src/engine/parse-pool-resources.js +96 -0
  27. package/dist/src/engine/parse-pool.js +352 -0
  28. package/dist/src/engine/parse-protocol.js +1 -0
  29. package/dist/src/engine/parse-worker.js +51 -0
  30. package/dist/src/engine/reflink-copy.js +23 -0
  31. package/dist/src/engine/seal-command.js +116 -0
  32. package/dist/src/engine/seal.js +270 -0
  33. package/dist/src/engine/sealed-open.js +116 -0
  34. package/dist/src/engine/sealed-query.js +49 -0
  35. package/dist/src/init.js +251 -183
  36. package/dist/src/manager-update.js +455 -0
  37. package/dist/src/release-preflight.js +106 -95
  38. package/dist/src/repair-lease.js +77 -8
  39. package/dist/src/response-budget.js +5 -1
  40. package/dist/src/server.js +18 -5
  41. package/dist/src/shared-index/compatibility.js +4 -2
  42. package/dist/src/shared-index/selection.js +1 -1
  43. package/dist/src/tools/knodin-tools.js +85 -13
  44. package/dist/src/update-ceremony.js +20 -24
  45. package/dist/src/update-coordination.js +158 -0
  46. package/dist/src/update-executor.js +355 -0
  47. package/dist/src/update-verifier.js +358 -0
  48. package/dist/src/worktree-seed.js +172 -0
  49. package/docs/BACKUP-RETENTION.md +54 -0
  50. package/docs/CLI.md +6 -0
  51. package/docs/DOCTOR-AND-UPDATES.md +44 -12
  52. package/docs/SHARED-INDEX-CONTRACT.md +9 -0
  53. package/docs/SIGNED-UPDATES.md +29 -22
  54. package/docs/releases/0.10.0.md +127 -0
  55. package/docs/releases/0.9.0.md +43 -0
  56. package/package.json +8 -1
  57. package/roadmap/competitive-roadmap.md +8 -0
@@ -0,0 +1,270 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import zlib from "node:zlib";
5
+ import { KNODIN_VERSION } from "../version.js";
6
+ import { Database } from "./sqlite.js";
7
+ /** Storage for embedded source. Content-addressed, so duplicate files cost one blob. */
8
+ const SEAL_SCHEMA = [
9
+ `CREATE TABLE IF NOT EXISTS sealed_source (
10
+ sha256 TEXT PRIMARY KEY,
11
+ bytes BLOB NOT NULL,
12
+ compressed INTEGER NOT NULL,
13
+ storedBytes INTEGER NOT NULL,
14
+ rawBytes INTEGER NOT NULL
15
+ )`,
16
+ `CREATE TABLE IF NOT EXISTS sealed_file (
17
+ filePath TEXT PRIMARY KEY,
18
+ sha256 TEXT NOT NULL REFERENCES sealed_source(sha256)
19
+ )`,
20
+ ];
21
+ /** Line count under the same `split("\n")` convention the read paths use. */
22
+ function countLines(buf) {
23
+ return buf.toString("utf8").split("\n").length;
24
+ }
25
+ export function readSealAttestation(db) {
26
+ try {
27
+ const row = db
28
+ .query("SELECT value FROM meta WHERE key = 'sealAttestation'")
29
+ .get();
30
+ return row ? JSON.parse(row.value) : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /**
37
+ * Reads embedded source out of a sealed artifact.
38
+ *
39
+ * Returns `null` — never `""` — for a path the artifact does not carry. An
40
+ * empty string is indistinguishable from an empty file, and downstream that
41
+ * reads as "this symbol has no body" rather than "this was not covered",
42
+ * which is exactly the silent false negative the consumer needs to be able to
43
+ * detect in order to fall back.
44
+ */
45
+ export function readSealedSource(db, filePath) {
46
+ const row = db
47
+ .query("SELECT s.bytes bytes, s.compressed compressed FROM sealed_file f JOIN sealed_source s ON s.sha256 = f.sha256 WHERE f.filePath = ?")
48
+ .get(filePath);
49
+ if (!row)
50
+ return null;
51
+ const raw = Buffer.from(row.bytes);
52
+ return (row.compressed ? zlib.zstdDecompressSync(raw) : raw).toString("utf8");
53
+ }
54
+ /**
55
+ * Every path the sealed artifact carries source for.
56
+ *
57
+ * A sealed repository has no directory tree to walk, so anything that
58
+ * discovers files by traversal (module resolution, package manifests) has to
59
+ * enumerate coverage instead.
60
+ */
61
+ export function listSealedFiles(db) {
62
+ return db
63
+ .query("SELECT filePath FROM sealed_file ORDER BY filePath")
64
+ .all()
65
+ .map((row) => row.filePath);
66
+ }
67
+ /** True when this database carries embedded source, i.e. can answer with no checkout. */
68
+ export function isSealedDatabase(db) {
69
+ return readSealAttestation(db) !== null;
70
+ }
71
+ /**
72
+ * Produce a sealed artifact, or refuse with a machine-readable code.
73
+ *
74
+ * Refusal is the interesting half. A seal of a dirty or diverged index
75
+ * produces citations that look exactly like correct ones — the failure is
76
+ * invisible downstream — so it is refused here rather than annotated.
77
+ */
78
+ export function sealIndex(options) {
79
+ const { repoPath, sourceDatabasePath, outputPath, health, identity, commit, ref, dirtyPaths } = options;
80
+ const stripEmbeddings = options.stripEmbeddings !== false;
81
+ if (!fs.existsSync(sourceDatabasePath))
82
+ return { ok: false, code: "database-missing", reason: `no index at ${sourceDatabasePath}` };
83
+ if (health.status !== "healthy")
84
+ return {
85
+ ok: false,
86
+ code: "index-unhealthy",
87
+ reason: `index status is ${health.status}; seal requires a healthy index`,
88
+ };
89
+ if (dirtyPaths.length > 0)
90
+ return {
91
+ ok: false,
92
+ code: "working-tree-dirty",
93
+ reason: `${dirtyPaths.length} path(s) differ from the index; re-index before sealing`,
94
+ };
95
+ if (!commit)
96
+ return {
97
+ ok: false,
98
+ code: "no-commit",
99
+ reason: "repository has no resolvable commit to attest",
100
+ };
101
+ if (health.pendingPaths > 0)
102
+ return {
103
+ ok: false,
104
+ code: "commit-divergent",
105
+ reason: `${health.pendingPaths} path(s) pending; the graph does not describe ${commit}`,
106
+ };
107
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
108
+ fs.rmSync(outputPath, { force: true });
109
+ // Copy rather than seal in place: the live database keeps serving its own
110
+ // repository, and a half-written artifact can never corrupt it.
111
+ fs.copyFileSync(sourceDatabasePath, outputPath);
112
+ const db = new Database(outputPath);
113
+ try {
114
+ db.run("PRAGMA journal_mode = WAL;");
115
+ for (const statement of SEAL_SCHEMA)
116
+ db.run(statement);
117
+ db.run("DELETE FROM sealed_file;");
118
+ db.run("DELETE FROM sealed_source;");
119
+ let removedEmbeddings = 0;
120
+ if (stripEmbeddings) {
121
+ removedEmbeddings =
122
+ db.query("SELECT COUNT(*) count FROM symbol_embeddings").get()
123
+ ?.count ?? 0;
124
+ db.run("DELETE FROM symbol_embeddings;");
125
+ }
126
+ const indexed = db
127
+ .query("SELECT filePath, mtimeMs, size FROM index_state ORDER BY filePath")
128
+ .all();
129
+ const insertBlob = db.prepare("INSERT OR IGNORE INTO sealed_source (sha256, bytes, compressed, storedBytes, rawBytes) VALUES (?, ?, ?, ?, ?)");
130
+ const insertFile = db.prepare("INSERT OR REPLACE INTO sealed_file (filePath, sha256) VALUES (?, ?)");
131
+ const maxEndLine = new Map();
132
+ for (const row of db
133
+ .query("SELECT filePath, MAX(endLine) lastLine FROM symbols GROUP BY filePath")
134
+ .all())
135
+ maxEndLine.set(row.filePath, row.lastLine);
136
+ const seen = new Set();
137
+ const drifted = [];
138
+ let files = 0;
139
+ let rawBytes = 0;
140
+ let storedBytes = 0;
141
+ db.run("BEGIN TRANSACTION;");
142
+ try {
143
+ for (const row of indexed) {
144
+ if (options.excludeSource?.(row.filePath))
145
+ continue;
146
+ const absolute = path.join(repoPath, row.filePath);
147
+ let buf;
148
+ let stat;
149
+ try {
150
+ stat = fs.statSync(absolute);
151
+ buf = fs.readFileSync(absolute);
152
+ }
153
+ catch {
154
+ // A file that vanished between indexing and sealing is simply not
155
+ // embedded. Queries for it report unavailable rather than empty.
156
+ continue;
157
+ }
158
+ // The drift hazard, and the reason this is a refusal rather than a
159
+ // warning. Seal reads the tree; the graph was built earlier. If a file
160
+ // changed in between, embedded bytes disagree with recorded line
161
+ // ranges and `explain` returns the WRONG function's body with full
162
+ // confidence and no omission — a false positive that looks correct,
163
+ // which is strictly worse than any false negative here.
164
+ if ((row.size !== null && row.size !== stat.size) ||
165
+ (row.mtimeMs !== null && Math.abs(row.mtimeMs - stat.mtimeMs) > 1)) {
166
+ drifted.push(row.filePath);
167
+ continue;
168
+ }
169
+ // Defence in depth behind the mtime/size check above, and the reason
170
+ // it is worth having: mtime+size cannot see a same-size edit that
171
+ // lands inside the mtime comparison window. A range that now runs off
172
+ // the end of the file is proof the graph and these bytes disagree,
173
+ // independent of any timestamp.
174
+ const lastLine = maxEndLine.get(row.filePath);
175
+ if (lastLine !== undefined && lastLine > countLines(buf)) {
176
+ drifted.push(row.filePath);
177
+ continue;
178
+ }
179
+ const hash = crypto.createHash("sha256").update(buf).digest("hex");
180
+ files++;
181
+ rawBytes += buf.length;
182
+ if (!seen.has(hash)) {
183
+ seen.add(hash);
184
+ // Compressed PER BLOB, not just in transit: SQLite does not
185
+ // transparently compress, so a consumer holds whatever is stored
186
+ // here. Transport compression would do nothing for that.
187
+ //
188
+ // Keep the raw bytes when the compressed form is not smaller.
189
+ // zstd framing exceeds the payload on very short files, and
190
+ // storing those compressed would make the artifact larger than
191
+ // the source it embeds.
192
+ const packed = zlib.zstdCompressSync(buf);
193
+ const useCompressed = packed.length < buf.length;
194
+ const stored = useCompressed ? packed : buf;
195
+ storedBytes += stored.length;
196
+ insertBlob.run(hash, stored, useCompressed ? 1 : 0, stored.length, buf.length);
197
+ }
198
+ insertFile.run(row.filePath, hash);
199
+ }
200
+ db.run("COMMIT;");
201
+ }
202
+ catch (error) {
203
+ db.run("ROLLBACK;");
204
+ throw error;
205
+ }
206
+ finally {
207
+ insertBlob.finalize();
208
+ insertFile.finalize();
209
+ }
210
+ if (drifted.length > 0) {
211
+ db.close();
212
+ // Discard the partial artifact rather than shipping one whose coverage
213
+ // silently excludes the very files that moved.
214
+ fs.rmSync(outputPath, { force: true });
215
+ const sample = drifted.slice(0, 5).join(", ");
216
+ return {
217
+ ok: false,
218
+ code: "source-drift",
219
+ reason: `${drifted.length} file(s) changed since indexing (${sample}${drifted.length > 5 ? ", …" : ""}); re-index before sealing`,
220
+ };
221
+ }
222
+ const attestation = {
223
+ sealVersion: 1,
224
+ knodinVersion: KNODIN_VERSION,
225
+ schemaVersion: db.query("PRAGMA user_version").get()?.user_version ?? 0,
226
+ repository: { identity, path: repoPath, remoteUrl: options.remoteUrl ?? null },
227
+ sealedCommit: commit,
228
+ sealedRef: ref,
229
+ sealedAt: new Date().toISOString(),
230
+ health: { ...health },
231
+ source: {
232
+ policy: options.excludeSource ? "excluded" : "all-indexed",
233
+ files,
234
+ uniqueBlobs: seen.size,
235
+ rawBytes,
236
+ storedBytes,
237
+ },
238
+ embeddings: { stripped: stripEmbeddings, removed: removedEmbeddings },
239
+ };
240
+ db.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('sealAttestation', ?)", [
241
+ JSON.stringify(attestation),
242
+ ]);
243
+ // Reclaim the pages freed by stripping embeddings and by any deletion
244
+ // above. Without this the artifact keeps every freed page and stripping
245
+ // saves nothing on disk — the whole reason embeddings are dropped. Must
246
+ // run outside a transaction, which is why it lands here rather than
247
+ // alongside the deletes.
248
+ db.run("VACUUM;");
249
+ const integrity = db.query("PRAGMA integrity_check").get()?.integrity_check ??
250
+ "unknown";
251
+ // Fold the WAL back in: a consumer receives ONE file, and an artifact
252
+ // whose committed state lives in a sidecar it never got is a silent
253
+ // truncation rather than a loud failure.
254
+ db.run("PRAGMA wal_checkpoint(TRUNCATE);");
255
+ db.run("PRAGMA journal_mode = DELETE;");
256
+ db.close();
257
+ for (const suffix of ["-wal", "-shm"])
258
+ fs.rmSync(`${outputPath}${suffix}`, { force: true });
259
+ const bytes = fs.statSync(outputPath).size;
260
+ const sha256 = crypto.createHash("sha256").update(fs.readFileSync(outputPath)).digest("hex");
261
+ return { ok: true, outputPath, bytes, attestation, integrityCheck: integrity, sha256 };
262
+ }
263
+ catch (error) {
264
+ try {
265
+ db.close();
266
+ }
267
+ catch { }
268
+ throw error;
269
+ }
270
+ }
@@ -0,0 +1,116 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { KNODIN_VERSION as RUNTIME_VERSION } from "../version.js";
5
+ import { KNODIN_SCHEMA_VERSION, registerSealedRepository, unregisterSealedRepository, } from "./index.js";
6
+ import { readSealAttestation } from "./seal.js";
7
+ import { Database } from "./sqlite.js";
8
+ export function openSealedArtifact(artifactPath, options = {}) {
9
+ const resolved = path.resolve(artifactPath);
10
+ if (!fs.existsSync(resolved))
11
+ return { ok: false, code: "artifact-missing", reason: `no artifact at ${resolved}` };
12
+ let db;
13
+ try {
14
+ db = new Database(resolved, { readonly: true });
15
+ }
16
+ catch (error) {
17
+ return {
18
+ ok: false,
19
+ code: "unreadable",
20
+ reason: error instanceof Error ? error.message : String(error),
21
+ };
22
+ }
23
+ const attestation = readSealAttestation(db);
24
+ if (!attestation) {
25
+ db.close();
26
+ return {
27
+ ok: false,
28
+ code: "not-sealed",
29
+ reason: "database carries no seal attestation; it is an ordinary index, not an artifact",
30
+ };
31
+ }
32
+ // HARD gate. The graph schema is what every query is written against, so a
33
+ // mismatch is not a degradation — the answers would be wrong rather than
34
+ // narrow. A consumer falls back to live source on this code.
35
+ if (attestation.schemaVersion !== KNODIN_SCHEMA_VERSION) {
36
+ db.close();
37
+ return {
38
+ ok: false,
39
+ code: "schema-mismatch",
40
+ reason: `artifact schema ${attestation.schemaVersion} != engine schema ${KNODIN_SCHEMA_VERSION}`,
41
+ };
42
+ }
43
+ const degraded = [];
44
+ // Stripped embeddings must make semantic search report itself UNAVAILABLE.
45
+ // Left to return zero results it would be indistinguishable from "nothing
46
+ // matched", which is the silent false negative this whole design exists to
47
+ // prevent.
48
+ if (attestation.embeddings.stripped)
49
+ degraded.push({
50
+ capability: "semanticSearch",
51
+ reason: "embeddings were stripped at seal time; semantic search cannot run",
52
+ });
53
+ // SOFT gate, and the asymmetry with the schema check is deliberate: a
54
+ // sealed artifact is never re-parsed, so a knodin version that parses
55
+ // differently does not invalidate symbols already extracted.
56
+ if (attestation.knodinVersion !== KNODIN_VERSION_AT_RUNTIME()) {
57
+ if (options.strictCompat) {
58
+ db.close();
59
+ return {
60
+ ok: false,
61
+ code: "schema-mismatch",
62
+ reason: `--strict-compat: artifact knodin ${attestation.knodinVersion} != runtime`,
63
+ };
64
+ }
65
+ degraded.push({
66
+ capability: "review",
67
+ reason: `sealed by knodin ${attestation.knodinVersion}; heuristics may differ`,
68
+ });
69
+ }
70
+ // A sealed artifact has no working tree to write to, and no way to reindex.
71
+ degraded.push({ capability: "rename", reason: "sealed artifacts are read-only" }, { capability: "index", reason: "sealed artifacts cannot be reindexed" });
72
+ const mountDir = options.mountDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "knodin-sealed-mount-"));
73
+ // The artifact lives under `candidates/` so the engine's explicit-database
74
+ // guard accepts it: that guard refuses a path colliding with the active
75
+ // database, which is a candidate-isolation invariant rather than an
76
+ // obstacle to route around.
77
+ const mountedDb = path.join(mountDir, ".knodin", "candidates", "sealed", "db.sqlite");
78
+ fs.mkdirSync(path.dirname(mountedDb), { recursive: true });
79
+ if (!fs.existsSync(mountedDb))
80
+ fs.copyFileSync(resolved, mountedDb);
81
+ // Registered under BOTH paths. Callers address the artifact by its mount
82
+ // directory, but the engine resolves a repository from paths recorded in
83
+ // the database itself — the checkout the index was built from. Registering
84
+ // only the mount would leave those internal reads falling through to a
85
+ // filesystem that does not have the source, which fails as "" rather than
86
+ // loudly.
87
+ registerSealedRepository(mountDir, db);
88
+ const attestedPath = attestation.repository.path;
89
+ if (attestedPath && path.resolve(attestedPath) !== path.resolve(mountDir))
90
+ registerSealedRepository(attestedPath, db);
91
+ const sealedAt = Date.parse(attestation.sealedAt);
92
+ const ageDays = Number.isNaN(sealedAt)
93
+ ? 0
94
+ : Math.floor((Date.now() - sealedAt) / (24 * 60 * 60 * 1000));
95
+ return {
96
+ ok: true,
97
+ repoPath: mountDir,
98
+ databasePath: mountedDb,
99
+ attestation,
100
+ degraded,
101
+ ageDays,
102
+ close() {
103
+ unregisterSealedRepository(mountDir);
104
+ if (attestedPath)
105
+ unregisterSealedRepository(attestedPath);
106
+ db.close();
107
+ },
108
+ };
109
+ }
110
+ /**
111
+ * Read at call time rather than imported as a constant so that a test can
112
+ * exercise the soft gate without rebuilding the package version.
113
+ */
114
+ function KNODIN_VERSION_AT_RUNTIME() {
115
+ return process.env.KNODIN_VERSION_OVERRIDE ?? RUNTIME_VERSION;
116
+ }
@@ -0,0 +1,49 @@
1
+ import { createEngine } from "./index.js";
2
+ import { openSealedArtifact, } from "./sealed-open.js";
3
+ export async function runSealedQuery(artifactPath, symbol, options = {}) {
4
+ const handle = openSealedArtifact(artifactPath, { strictCompat: options.strictCompat });
5
+ if (!handle.ok)
6
+ return { ok: false, code: handle.code, reason: handle.reason };
7
+ try {
8
+ const summary = {
9
+ ok: true,
10
+ repository: {
11
+ identity: handle.attestation.repository.identity,
12
+ commit: handle.attestation.sealedCommit,
13
+ ref: handle.attestation.sealedRef,
14
+ },
15
+ sealedAt: handle.attestation.sealedAt,
16
+ ageDays: handle.ageDays,
17
+ knodinVersion: handle.attestation.knodinVersion,
18
+ schemaVersion: handle.attestation.schemaVersion,
19
+ coverage: {
20
+ files: handle.attestation.source.files,
21
+ uniqueBlobs: handle.attestation.source.uniqueBlobs,
22
+ rawBytes: handle.attestation.source.rawBytes,
23
+ storedBytes: handle.attestation.source.storedBytes,
24
+ },
25
+ degraded: handle.degraded,
26
+ };
27
+ if (!symbol)
28
+ return summary;
29
+ // `mode: "sealed"` is the whole reason this wrapper exists. Without it the
30
+ // engine reconciles against the empty mount, prunes every symbol, and
31
+ // answers "not found" for content the artifact is carrying — a negative
32
+ // that looks entirely correct. Too important to leave to each caller.
33
+ const engine = createEngine({
34
+ watcher: "disabled",
35
+ mode: "sealed",
36
+ databasePath: handle.databasePath,
37
+ });
38
+ try {
39
+ summary.explained = await engine.explain(symbol, handle.repoPath);
40
+ return summary;
41
+ }
42
+ finally {
43
+ await engine.close();
44
+ }
45
+ }
46
+ finally {
47
+ handle.close();
48
+ }
49
+ }