@esneiderbravo/speclaw 0.3.13 → 1.0.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 (74) hide show
  1. package/README.md +88 -72
  2. package/dist/cli/commands/index-build.js +12 -3
  3. package/dist/cli/commands/lawbook.js +1 -0
  4. package/dist/cli/commands/laws.js +149 -8
  5. package/dist/cli/commands/owners.js +44 -0
  6. package/dist/cli/commands/query.js +52 -10
  7. package/dist/cli/commands/update.js +35 -5
  8. package/dist/cli/commands/verify.js +8 -0
  9. package/dist/cli/index.js +15 -4
  10. package/dist/modules/compass/budget.js +128 -0
  11. package/dist/modules/compass/db.js +290 -30
  12. package/dist/modules/compass/diff-context.js +134 -0
  13. package/dist/modules/compass/embed-input.js +28 -0
  14. package/dist/modules/compass/embedder.js +3 -1
  15. package/dist/modules/compass/explore-rich.js +134 -0
  16. package/dist/modules/compass/extract.js +86 -0
  17. package/dist/modules/compass/hybrid.js +318 -0
  18. package/dist/modules/compass/impact-summary.js +33 -0
  19. package/dist/modules/compass/indexer.js +204 -33
  20. package/dist/modules/compass/merkle.js +76 -0
  21. package/dist/modules/compass/pagerank.js +122 -0
  22. package/dist/modules/compass/rank.js +95 -0
  23. package/dist/modules/compass/register.js +169 -75
  24. package/dist/modules/foundation/check.js +4 -2
  25. package/dist/modules/foundation/compile-laws.js +212 -0
  26. package/dist/modules/foundation/context-budget.js +1 -14
  27. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  28. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  29. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  30. package/dist/modules/foundation/dialects/copilot.js +35 -0
  31. package/dist/modules/foundation/dialects/index.js +5 -0
  32. package/dist/modules/foundation/dialects/types.js +58 -0
  33. package/dist/modules/foundation/doctor.js +266 -14
  34. package/dist/modules/foundation/import-rules.js +67 -0
  35. package/dist/modules/foundation/integrity.js +307 -0
  36. package/dist/modules/foundation/laws-parse.js +131 -0
  37. package/dist/modules/foundation/laws.js +5 -0
  38. package/dist/modules/foundation/lock.js +283 -0
  39. package/dist/modules/foundation/ownership.js +4 -0
  40. package/dist/modules/foundation/register-core.js +57 -88
  41. package/dist/modules/foundation/register.js +1 -21
  42. package/dist/modules/foundation/scaffold.js +25 -0
  43. package/dist/modules/foundation/scan.js +227 -0
  44. package/dist/modules/foundation/setup-tool.js +96 -0
  45. package/dist/modules/foundation/verify.js +9 -1
  46. package/dist/modules/lawbook/assets/commands/archive.md +1 -1
  47. package/dist/modules/lawbook/assets/commands/draft.md +1 -1
  48. package/dist/modules/lawbook/assets/commands/explore.md +1 -1
  49. package/dist/modules/lawbook/assets/commands/sync.md +2 -2
  50. package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
  51. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
  52. package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
  53. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
  54. package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
  55. package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
  56. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
  57. package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
  58. package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
  59. package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
  60. package/dist/modules/lawbook/change-tool.js +90 -0
  61. package/dist/modules/lawbook/coverage.js +45 -6
  62. package/dist/modules/lawbook/ears.js +417 -0
  63. package/dist/modules/lawbook/engine.js +29 -0
  64. package/dist/modules/lawbook/register.js +96 -54
  65. package/dist/modules/lawbook/spec-items.js +4 -1
  66. package/dist/modules/team/owners.js +464 -0
  67. package/dist/modules/tools/register.js +4 -26
  68. package/dist/shared/deprecation.js +99 -0
  69. package/dist/shared/exposure.js +4 -19
  70. package/dist/shared/git.js +25 -0
  71. package/dist/shared/mcp.js +29 -3
  72. package/dist/shared/output-budget.js +68 -0
  73. package/dist/shared/tool-catalog.js +49 -0
  74. package/package.json +4 -3
@@ -1,6 +1,7 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { contentHashFor, defaultEmbedText } from "./embed-input.js";
4
5
  const SCHEMA = `
5
6
  CREATE TABLE IF NOT EXISTS meta (
6
7
  key TEXT PRIMARY KEY,
@@ -12,7 +13,9 @@ CREATE TABLE IF NOT EXISTS files (
12
13
  hash TEXT NOT NULL,
13
14
  lang TEXT NOT NULL,
14
15
  is_test INTEGER NOT NULL DEFAULT 0,
15
- module TEXT NOT NULL DEFAULT ''
16
+ module TEXT NOT NULL DEFAULT '',
17
+ mtime_ms INTEGER,
18
+ size INTEGER
16
19
  );
17
20
  CREATE INDEX IF NOT EXISTS idx_files_is_test ON files(is_test);
18
21
  -- nodes: the definitions in the codebase (functions, classes, methods, types).
@@ -28,11 +31,13 @@ CREATE TABLE IF NOT EXISTS nodes (
28
31
  parent_id INTEGER,
29
32
  signature TEXT,
30
33
  body_hash TEXT,
31
- norm_hash TEXT
34
+ norm_hash TEXT,
35
+ content_hash TEXT
32
36
  );
33
37
  CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
34
38
  CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_id);
35
39
  CREATE INDEX IF NOT EXISTS idx_nodes_norm_hash ON nodes(norm_hash);
40
+ CREATE INDEX IF NOT EXISTS idx_nodes_content_hash ON nodes(content_hash);
36
41
  -- node_metrics: AST health frames (LOC / nesting / branches) per definition.
37
42
  CREATE TABLE IF NOT EXISTS node_metrics (
38
43
  node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
@@ -53,12 +58,23 @@ CREATE TABLE IF NOT EXISTS edges (
53
58
  CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst_name);
54
59
  CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src_node_id);
55
60
  CREATE INDEX IF NOT EXISTS idx_edges_dstid ON edges(dst_node_id);
56
- -- node_embeddings: the local vector store (one embedding per node).
57
- CREATE TABLE IF NOT EXISTS node_embeddings (
58
- node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
59
- dim INTEGER NOT NULL,
61
+ -- embedding_cache: vectors keyed by embedder-input content hash (survives reindex).
62
+ CREATE TABLE IF NOT EXISTS embedding_cache (
63
+ content_hash TEXT NOT NULL,
60
64
  model TEXT NOT NULL,
61
- vec BLOB NOT NULL
65
+ dim INTEGER NOT NULL,
66
+ vec BLOB NOT NULL,
67
+ created_at INTEGER NOT NULL,
68
+ last_seen_at INTEGER NOT NULL,
69
+ PRIMARY KEY (content_hash, model)
70
+ );
71
+ CREATE INDEX IF NOT EXISTS idx_embedding_cache_seen ON embedding_cache(last_seen_at);
72
+ -- dir_hashes: Merkle tree of indexed directories ("" = project root).
73
+ CREATE TABLE IF NOT EXISTS dir_hashes (
74
+ path TEXT PRIMARY KEY,
75
+ hash TEXT NOT NULL,
76
+ n_files INTEGER NOT NULL,
77
+ updated_at INTEGER NOT NULL
62
78
  );
63
79
  -- git_history_cache: memoized results of the expensive git-history scans
64
80
  -- (churn, co-change), keyed by query and invalidated when HEAD moves.
@@ -109,9 +125,104 @@ CREATE TABLE IF NOT EXISTS spec_anchors (
109
125
  CREATE INDEX IF NOT EXISTS idx_anchors_capability ON spec_anchors(capability);
110
126
  CREATE INDEX IF NOT EXISTS idx_anchors_symbol ON spec_anchors(symbol_name);
111
127
  CREATE INDEX IF NOT EXISTS idx_anchors_node ON spec_anchors(node_id);
128
+ -- node_text: searchable name/subtokens/signature/doc (FTS content source).
129
+ CREATE TABLE IF NOT EXISTS node_text (
130
+ node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
131
+ name TEXT NOT NULL,
132
+ subtokens TEXT NOT NULL DEFAULT '',
133
+ signature TEXT NOT NULL DEFAULT '',
134
+ doc TEXT NOT NULL DEFAULT ''
135
+ );
136
+ -- pagerank: global (non-personalized) scores recomputed at index time.
137
+ CREATE TABLE IF NOT EXISTS pagerank (
138
+ node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
139
+ score REAL NOT NULL
140
+ );
141
+ CREATE INDEX IF NOT EXISTS idx_pagerank_score ON pagerank(score DESC);
112
142
  `;
143
+ const FTS_DDL = `
144
+ CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
145
+ name, subtokens, signature, doc,
146
+ content='node_text', content_rowid='node_id',
147
+ tokenize="unicode61 remove_diacritics 0 tokenchars '_$'",
148
+ prefix='2 3'
149
+ );
150
+ CREATE TRIGGER IF NOT EXISTS node_text_ai AFTER INSERT ON node_text BEGIN
151
+ INSERT INTO nodes_fts(rowid, name, subtokens, signature, doc)
152
+ VALUES (new.node_id, new.name, new.subtokens, new.signature, new.doc);
153
+ END;
154
+ CREATE TRIGGER IF NOT EXISTS node_text_ad AFTER DELETE ON node_text BEGIN
155
+ INSERT INTO nodes_fts(nodes_fts, rowid, name, subtokens, signature, doc)
156
+ VALUES ('delete', old.node_id, old.name, old.subtokens, old.signature, old.doc);
157
+ END;
158
+ CREATE TRIGGER IF NOT EXISTS node_text_au AFTER UPDATE ON node_text BEGIN
159
+ INSERT INTO nodes_fts(nodes_fts, rowid, name, subtokens, signature, doc)
160
+ VALUES ('delete', old.node_id, old.name, old.subtokens, old.signature, old.doc);
161
+ INSERT INTO nodes_fts(rowid, name, subtokens, signature, doc)
162
+ VALUES (new.node_id, new.name, new.subtokens, new.signature, new.doc);
163
+ END;
164
+ `;
165
+ /** Ensure node_embeddings is a VIEW over embedding_cache (idempotent). */
166
+ function ensureEmbeddingsView(db) {
167
+ const row = db
168
+ .prepare("SELECT type FROM sqlite_master WHERE name = 'node_embeddings' LIMIT 1")
169
+ .get();
170
+ if (row?.type === "view")
171
+ return;
172
+ if (row?.type === "table") {
173
+ db.exec("DROP TABLE node_embeddings");
174
+ }
175
+ db.exec(`
176
+ CREATE VIEW node_embeddings AS
177
+ SELECT n.id AS node_id, ec.dim AS dim, ec.model AS model, ec.vec AS vec
178
+ FROM nodes n
179
+ JOIN embedding_cache ec ON ec.content_hash = n.content_hash
180
+ `);
181
+ }
182
+ /**
183
+ * Try to create the FTS5 virtual table + sync triggers. Soft-degrades when the
184
+ * Node SQLite build lacks FTS5 (pre-22.16).
185
+ *
186
+ * @returns Whether FTS5 is usable after this call.
187
+ */
188
+ export function ensureFts(db) {
189
+ const existing = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'nodes_fts' LIMIT 1").get();
190
+ if (existing) {
191
+ db.prepare("INSERT INTO meta(key, value) VALUES ('fts5', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
192
+ return true;
193
+ }
194
+ try {
195
+ db.exec(FTS_DDL);
196
+ db.prepare("INSERT INTO meta(key, value) VALUES ('fts5', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
197
+ return true;
198
+ }
199
+ catch {
200
+ db.prepare("INSERT INTO meta(key, value) VALUES ('fts5', '0') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
201
+ return false;
202
+ }
203
+ }
204
+ /** Whether the open database has a usable FTS5 index. */
205
+ export function ftsAvailable(db) {
206
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'fts5'").get();
207
+ if (row?.value === "0")
208
+ return false;
209
+ const tbl = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'nodes_fts' LIMIT 1").get();
210
+ return Boolean(tbl);
211
+ }
212
+ /** Probe whether this Node build can create an FTS5 virtual table. */
213
+ export function probeFts5Support() {
214
+ try {
215
+ const mem = new DatabaseSync(":memory:");
216
+ mem.exec("CREATE VIRTUAL TABLE t USING fts5(x)");
217
+ mem.close();
218
+ return true;
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ }
113
224
  /** Schema version stamped into the `meta` table on first creation. */
114
- export const SCHEMA_VERSION = "8";
225
+ export const SCHEMA_VERSION = "10";
115
226
  /** The stamped schema version, or null if the db predates versioning / has no meta table. */
116
227
  function readSchemaVersion(db) {
117
228
  try {
@@ -124,10 +235,7 @@ function readSchemaVersion(db) {
124
235
  }
125
236
  /**
126
237
  * Decide whether an existing database is from an incompatible schema and must be
127
- * rebuilt. A fresh database (no tables) needs no reset the schema will create
128
- * them. An existing one is stale if its stamped version differs from the current
129
- * one, or if the `edges` table is missing a column the current code writes to
130
- * (guards against past schema changes that weren't version-bumped).
238
+ * rebuilt. Schema 8→9 and 9→10 are handled by migrators instead of a wipe.
131
239
  */
132
240
  function isStale(db) {
133
241
  const hasEdges = db
@@ -135,7 +243,10 @@ function isStale(db) {
135
243
  .get();
136
244
  if (!hasEdges)
137
245
  return false;
138
- if (readSchemaVersion(db) !== SCHEMA_VERSION)
246
+ const ver = readSchemaVersion(db);
247
+ if (ver === "8" || ver === "9")
248
+ return false; // migrate in openDb
249
+ if (ver !== SCHEMA_VERSION)
139
250
  return true;
140
251
  const edgeCols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
141
252
  if (!edgeCols.includes("src_node_id") || !edgeCols.includes("dst_node_id"))
@@ -151,10 +262,18 @@ function isStale(db) {
151
262
  /** Drop every table (children first) so the current schema can be recreated cleanly. */
152
263
  function resetSchema(db) {
153
264
  db.exec(`
265
+ DROP VIEW IF EXISTS node_embeddings;
266
+ DROP TRIGGER IF EXISTS node_text_ai;
267
+ DROP TRIGGER IF EXISTS node_text_ad;
268
+ DROP TRIGGER IF EXISTS node_text_au;
269
+ DROP TABLE IF EXISTS nodes_fts;
270
+ DROP TABLE IF EXISTS pagerank;
271
+ DROP TABLE IF EXISTS node_text;
154
272
  DROP TABLE IF EXISTS spec_anchors;
155
273
  DROP TABLE IF EXISTS coverage_links;
156
274
  DROP TABLE IF EXISTS git_history_cache;
157
- DROP TABLE IF EXISTS node_embeddings;
275
+ DROP TABLE IF EXISTS embedding_cache;
276
+ DROP TABLE IF EXISTS dir_hashes;
158
277
  DROP TABLE IF EXISTS edges;
159
278
  DROP TABLE IF EXISTS node_metrics;
160
279
  DROP TABLE IF EXISTS nodes;
@@ -162,14 +281,131 @@ function resetSchema(db) {
162
281
  DROP TABLE IF EXISTS meta;
163
282
  `);
164
283
  }
284
+ /**
285
+ * Migrate schema 8 → 9: embedding_cache, dir_hashes, mtime/size, content_hash,
286
+ * preserve vectors into the cache, replace node_embeddings table with a view.
287
+ *
288
+ * @param db - Open connection already at schema 8.
289
+ * @param projectPath - Project root for backfilling content hashes from disk.
290
+ */
291
+ export function migrate8to9(db, projectPath) {
292
+ db.exec("BEGIN IMMEDIATE");
293
+ try {
294
+ db.exec(`
295
+ CREATE TABLE IF NOT EXISTS embedding_cache (
296
+ content_hash TEXT NOT NULL,
297
+ model TEXT NOT NULL,
298
+ dim INTEGER NOT NULL,
299
+ vec BLOB NOT NULL,
300
+ created_at INTEGER NOT NULL,
301
+ last_seen_at INTEGER NOT NULL,
302
+ PRIMARY KEY (content_hash, model)
303
+ );
304
+ CREATE INDEX IF NOT EXISTS idx_embedding_cache_seen ON embedding_cache(last_seen_at);
305
+ CREATE TABLE IF NOT EXISTS dir_hashes (
306
+ path TEXT PRIMARY KEY,
307
+ hash TEXT NOT NULL,
308
+ n_files INTEGER NOT NULL,
309
+ updated_at INTEGER NOT NULL
310
+ );
311
+ `);
312
+ const fileCols = db.prepare("PRAGMA table_info(files)").all().map((c) => c.name);
313
+ if (!fileCols.includes("mtime_ms"))
314
+ db.exec("ALTER TABLE files ADD COLUMN mtime_ms INTEGER");
315
+ if (!fileCols.includes("size"))
316
+ db.exec("ALTER TABLE files ADD COLUMN size INTEGER");
317
+ const nodeCols = db.prepare("PRAGMA table_info(nodes)").all().map((c) => c.name);
318
+ if (!nodeCols.includes("content_hash")) {
319
+ db.exec("ALTER TABLE nodes ADD COLUMN content_hash TEXT");
320
+ db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_content_hash ON nodes(content_hash)");
321
+ }
322
+ const upd = db.prepare("UPDATE nodes SET content_hash = ? WHERE id = ?");
323
+ const rows = db
324
+ .prepare(`SELECT n.id, n.kind, n.name, n.signature, f.lang
325
+ FROM nodes n JOIN files f ON f.id = n.file_id`)
326
+ .all();
327
+ for (const r of rows) {
328
+ const embedText = defaultEmbedText(r.kind, r.name, r.signature);
329
+ upd.run(contentHashFor({
330
+ lang: r.lang,
331
+ kind: r.kind,
332
+ name: r.name,
333
+ signature: r.signature,
334
+ embedText,
335
+ }), r.id);
336
+ }
337
+ void projectPath;
338
+ const now = Date.now();
339
+ const embType = db
340
+ .prepare("SELECT type FROM sqlite_master WHERE name = 'node_embeddings' LIMIT 1")
341
+ .get();
342
+ if (embType?.type === "table") {
343
+ db.prepare(`INSERT OR IGNORE INTO embedding_cache(content_hash, model, dim, vec, created_at, last_seen_at)
344
+ SELECT n.content_hash, ne.model, ne.dim, ne.vec, ?, ?
345
+ FROM node_embeddings ne
346
+ JOIN nodes n ON n.id = ne.node_id
347
+ WHERE n.content_hash IS NOT NULL`).run(now, now);
348
+ db.exec("DROP TABLE node_embeddings");
349
+ }
350
+ ensureEmbeddingsView(db);
351
+ db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run("9");
352
+ db.exec("COMMIT");
353
+ }
354
+ catch (err) {
355
+ try {
356
+ db.exec("ROLLBACK");
357
+ }
358
+ catch {
359
+ /* already rolled back */
360
+ }
361
+ throw new Error(`schema 8→9 migration failed — delete .speclaw/index.db to rebuild: ${err.message}`, { cause: err });
362
+ }
363
+ }
364
+ /**
365
+ * Migrate schema 9 → 10: `node_text`, optional FTS5, `pagerank`. Embedding cache
366
+ * is left intact; a reindex is required to populate text rows.
367
+ *
368
+ * @param db - Open connection already at schema 9.
369
+ */
370
+ export function migrate9to10(db) {
371
+ db.exec("BEGIN IMMEDIATE");
372
+ try {
373
+ db.exec(`
374
+ CREATE TABLE IF NOT EXISTS node_text (
375
+ node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
376
+ name TEXT NOT NULL,
377
+ subtokens TEXT NOT NULL DEFAULT '',
378
+ signature TEXT NOT NULL DEFAULT '',
379
+ doc TEXT NOT NULL DEFAULT ''
380
+ );
381
+ CREATE TABLE IF NOT EXISTS pagerank (
382
+ node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
383
+ score REAL NOT NULL
384
+ );
385
+ CREATE INDEX IF NOT EXISTS idx_pagerank_score ON pagerank(score DESC);
386
+ `);
387
+ ensureFts(db);
388
+ db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run("10");
389
+ db.prepare("INSERT INTO meta(key, value) VALUES ('needs_reindex', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
390
+ db.prepare("INSERT INTO meta(key, value) VALUES ('reindex_reason', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run("schema 10 adds full-text index (names, subtokens, signatures, docs); reindex required");
391
+ db.exec("COMMIT");
392
+ }
393
+ catch (err) {
394
+ try {
395
+ db.exec("ROLLBACK");
396
+ }
397
+ catch {
398
+ /* already rolled back */
399
+ }
400
+ throw new Error(`schema 9→10 migration failed — delete .speclaw/index.db to rebuild: ${err.message}`, { cause: err });
401
+ }
402
+ }
165
403
  /**
166
404
  * Open (creating if needed) the index database at `<projectPath>/.speclaw/index.db`.
167
405
  *
168
406
  * Ensures the `.speclaw` directory exists, enables WAL journaling and foreign
169
- * keys, and applies the schema. If an existing database is from an incompatible
170
- * schema (e.g. after a speclaw upgrade), it is dropped and rebuilt — `.speclaw`
171
- * is fully regenerable, so the next index just repopulates it. The schema
172
- * version is stamped on a fresh (or freshly reset) database.
407
+ * keys, and applies the schema. Schema 8→9 and 9→10 migrate in place
408
+ * (embeddings preserved). Other incompatible schemas are wiped and rebuilt.
173
409
  *
174
410
  * @param projectPath - Absolute path to the project root.
175
411
  * @returns An open connection to the index database.
@@ -178,19 +414,43 @@ export function openDb(projectPath) {
178
414
  const dir = path.join(projectPath, ".speclaw");
179
415
  fs.mkdirSync(dir, { recursive: true });
180
416
  const db = new DatabaseSync(path.join(dir, "index.db"));
181
- db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
182
- const wiped = isStale(db);
183
- if (wiped)
184
- resetSchema(db);
185
- db.exec(SCHEMA);
186
- const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
187
- if (!row) {
188
- db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
189
- }
190
- if (wiped) {
191
- db.prepare("INSERT INTO meta(key, value) VALUES ('needs_reindex', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
417
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
418
+ const ver = (() => {
419
+ try {
420
+ return readSchemaVersion(db);
421
+ }
422
+ catch {
423
+ return null;
424
+ }
425
+ })();
426
+ if (ver === "8") {
427
+ migrate8to9(db, projectPath);
428
+ migrate9to10(db);
429
+ db.exec(SCHEMA);
430
+ ensureEmbeddingsView(db);
431
+ ensureFts(db);
432
+ }
433
+ else if (ver === "9") {
434
+ migrate9to10(db);
435
+ db.exec(SCHEMA);
436
+ ensureEmbeddingsView(db);
437
+ ensureFts(db);
438
+ }
439
+ else {
440
+ const wiped = isStale(db);
441
+ if (wiped)
442
+ resetSchema(db);
443
+ db.exec(SCHEMA);
444
+ ensureEmbeddingsView(db);
445
+ ensureFts(db);
446
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
447
+ if (!row) {
448
+ db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
449
+ }
450
+ if (wiped) {
451
+ db.prepare("INSERT INTO meta(key, value) VALUES ('needs_reindex', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
452
+ }
192
453
  }
193
- // Projection from committed JSON — safe even when nodes are empty (node_id null).
194
454
  rehydrateAnchors(db, projectPath);
195
455
  return db;
196
456
  }
@@ -0,0 +1,134 @@
1
+ import { isGitRepo, changedFiles, worktreeChangedFiles } from "../../shared/git.js";
2
+ import { openDb, indexExists } from "./db.js";
3
+ import { impact } from "./query.js";
4
+ import { affectedTests } from "./affected.js";
5
+ import { hotspots } from "./hotspots.js";
6
+ import { summarizeImpact } from "./impact-summary.js";
7
+ import { applyTextBudget, } from "../../shared/output-budget.js";
8
+ function listWorktreeChanges(projectPath) {
9
+ const wt = worktreeChangedFiles(projectPath);
10
+ if (wt.length > 0)
11
+ return wt;
12
+ for (const base of ["main", "master"]) {
13
+ const files = changedFiles(projectPath, base);
14
+ if (files.length > 0)
15
+ return files;
16
+ }
17
+ return [];
18
+ }
19
+ function symbolsForFiles(projectPath, files, overestimate) {
20
+ if (!indexExists(projectPath))
21
+ return [];
22
+ const db = openDb(projectPath);
23
+ try {
24
+ const out = [];
25
+ for (const file of files) {
26
+ const nodes = db
27
+ .prepare(`SELECT n.name, n.kind, n.start_line AS line, f.path AS file
28
+ FROM nodes n JOIN files f ON f.id = n.file_id
29
+ WHERE f.path = ?`)
30
+ .all(file);
31
+ if (overestimate || nodes.length === 0) {
32
+ for (const n of nodes)
33
+ out.push(n);
34
+ }
35
+ else {
36
+ for (const n of nodes)
37
+ out.push(n);
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ finally {
43
+ db.close();
44
+ }
45
+ }
46
+ /**
47
+ * Graph context for a set of changed files (git rev, working tree, or explicit paths).
48
+ *
49
+ * @param query - Diff scope and output mode.
50
+ */
51
+ export function diffContext(query) {
52
+ const mode = query.mode ?? "brief";
53
+ const truncated = [];
54
+ let files = [...(query.paths ?? [])];
55
+ let message;
56
+ if (files.length === 0) {
57
+ if (!isGitRepo(query.projectPath)) {
58
+ throw new Error("not a git repository — pass `paths` explicitly to compass_diff_context");
59
+ }
60
+ files =
61
+ query.rev && query.rev !== "WORKTREE"
62
+ ? changedFiles(query.projectPath, query.rev)
63
+ : listWorktreeChanges(query.projectPath);
64
+ }
65
+ if (files.length === 0) {
66
+ return {
67
+ changedFiles: [],
68
+ changedSymbols: [],
69
+ message: "no changed files in scope",
70
+ truncated,
71
+ };
72
+ }
73
+ if (files.length > 50) {
74
+ message = `diff touches ${files.length} files — returning aggregated blast radius only; pass paths to narrow`;
75
+ files = files.slice(0, 50);
76
+ }
77
+ const overestimate = Boolean(query.paths?.length && !query.rev);
78
+ if (overestimate) {
79
+ message = "symbol set is an overestimate (paths without hunks — all nodes in those files)";
80
+ }
81
+ const changedSymbols = symbolsForFiles(query.projectPath, files, overestimate);
82
+ const result = { changedFiles: files, changedSymbols, truncated, message };
83
+ try {
84
+ const imp = impact(query.projectPath, {
85
+ files,
86
+ maxDepth: query.maxDepth ?? 4,
87
+ format: "grouped",
88
+ });
89
+ result.blastRadius = summarizeImpact(imp);
90
+ }
91
+ catch {
92
+ /* no index */
93
+ }
94
+ try {
95
+ const at = affectedTests(query.projectPath, {
96
+ files,
97
+ fromDiff: query.rev === "WORKTREE" || !query.rev ? "WORKTREE" : query.rev,
98
+ maxDepth: query.maxDepth ?? 6,
99
+ });
100
+ result.affectedTests = {
101
+ tests: at.tests,
102
+ command: at.command,
103
+ mode: at.mode,
104
+ reason: at.reason,
105
+ };
106
+ }
107
+ catch {
108
+ /* degrade */
109
+ }
110
+ try {
111
+ const hs = hotspots(query.projectPath, { sortBy: "combined", limit: 200 });
112
+ const touched = hs.hotspots.filter((h) => files.includes(h.file));
113
+ result.hotspotsTouched = touched.slice(0, 10).map((h) => ({
114
+ file: h.file,
115
+ combinedScore: h.combinedScore,
116
+ }));
117
+ }
118
+ catch {
119
+ /* degrade */
120
+ }
121
+ const json = applyTextBudget(JSON.stringify(result, null, 2), mode);
122
+ if (json.truncated) {
123
+ truncated.push({
124
+ field: "response",
125
+ omitted: json.omittedChars,
126
+ hint: 'use mode:"full" or pass explicit paths',
127
+ });
128
+ }
129
+ return result;
130
+ }
131
+ /** Format diff context with output budget. */
132
+ export function formatDiffContext(result, mode = "brief") {
133
+ return applyTextBudget(JSON.stringify(result, null, 2), mode).text;
134
+ }
@@ -0,0 +1,28 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Bump when the text (or metadata) fed to the embedder changes shape.
4
+ * Combined into LexicalEmbedder.id so stale cache rows never match.
5
+ */
6
+ export const EMBED_INPUT_VERSION = "in2";
7
+ /**
8
+ * Content-addressable key for `embedding_cache`: hash of the embedder recipe,
9
+ * not the file path and not solely `body_hash` (drift uses a different recipe).
10
+ *
11
+ * @param input - Language, kind, name, optional signature, and embedder text.
12
+ */
13
+ export function contentHashFor(input) {
14
+ return createHash("sha256")
15
+ .update([
16
+ EMBED_INPUT_VERSION,
17
+ input.lang,
18
+ input.kind,
19
+ input.name,
20
+ input.signature ?? "",
21
+ input.embedText,
22
+ ].join("\0"))
23
+ .digest("hex");
24
+ }
25
+ /** Default embedder text for a symbol (matches historical indexer behaviour). */
26
+ export function defaultEmbedText(kind, name, signature) {
27
+ return `${kind} ${name} ${signature ?? ""}`;
28
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { EMBED_INPUT_VERSION } from "./embed-input.js";
2
3
  /**
3
4
  * Split identifiers into lowercase subtokens.
4
5
  *
@@ -23,7 +24,8 @@ export function tokenize(text) {
23
24
  */
24
25
  export class LexicalEmbedder {
25
26
  dim;
26
- id = "lexical-hash-v1";
27
+ /** Identity includes embed-input recipe version so cache invalidates on bump. */
28
+ id = `lexical-hash-v1+${EMBED_INPUT_VERSION}`;
27
29
  constructor(dim = 256) {
28
30
  this.dim = dim;
29
31
  }