scrumrun 2.1.0 → 2.1.1

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.
@@ -12,8 +12,10 @@ const { containsSecret } = require("../security/secrets");
12
12
  const { canonicalWatchSnapshot, fileWatchSnapshot } = require("../runtime/canonical-snapshot");
13
13
 
14
14
  const CACHE_RELATIVE = path.join(".cache", "semantic-index.sqlite");
15
- const INDEX_SCHEMA_VERSION = 3;
15
+ const INDEX_SCHEMA_VERSION = 4;
16
16
  const INACTIVE = new Set(["rejected", "invalidated", "deprecated", "archived"]);
17
+ const SEARCH_BACKENDS = new Set(["auto", "fts5", "like"]);
18
+ const MAX_SEARCH_TOKENS = 32;
17
19
 
18
20
  function indexPath(projectRoot) {
19
21
  return path.join(projectRoot, ".scrumrun", CACHE_RELATIVE);
@@ -23,6 +25,10 @@ function titleFromBody(body, fallback) {
23
25
  return ((String(body || "").match(/^#\s+([^\r\n]+)/m) || [])[1] || fallback).trim();
24
26
  }
25
27
 
28
+ function normalizeSearchText(...values) {
29
+ return values.map((value) => String(value || "")).join(" ").normalize("NFKC").toLowerCase();
30
+ }
31
+
26
32
  function canonicalArtifacts(scrumDir) {
27
33
  const repository = new ArtifactRepository(scrumDir);
28
34
  const artifacts = [];
@@ -104,7 +110,8 @@ function explicitEdges(artifact) {
104
110
  return [...unique.values()];
105
111
  }
106
112
 
107
- function createSchema(database) {
113
+ function createSchema(database, { searchBackend = "auto" } = {}) {
114
+ if (!SEARCH_BACKENDS.has(searchBackend)) throw new Error(`Unknown semantic search backend: ${searchBackend}`);
108
115
  database.exec(`
109
116
  PRAGMA journal_mode = DELETE;
110
117
  PRAGMA synchronous = FULL;
@@ -121,6 +128,7 @@ function createSchema(database) {
121
128
  valid_until TEXT,
122
129
  last_verified_commit TEXT,
123
130
  review_trigger TEXT,
131
+ search_text TEXT NOT NULL,
124
132
  active INTEGER NOT NULL CHECK (active IN (0, 1))
125
133
  );
126
134
  CREATE TABLE edges (
@@ -143,7 +151,8 @@ function createSchema(database) {
143
151
  status TEXT NOT NULL CHECK (status IN ('current', 'orphaned')),
144
152
  language TEXT NOT NULL,
145
153
  exported INTEGER NOT NULL CHECK (exported IN (0, 1)),
146
- remapped_from TEXT
154
+ remapped_from TEXT,
155
+ search_text TEXT NOT NULL
147
156
  );
148
157
  CREATE TABLE evidence_snapshots (
149
158
  artifact_id TEXT NOT NULL,
@@ -167,9 +176,18 @@ function createSchema(database) {
167
176
  CREATE INDEX edges_from ON edges(from_id);
168
177
  CREATE INDEX edges_to ON edges(to_id);
169
178
  CREATE INDEX artifacts_status ON artifacts(status);
170
- CREATE VIRTUAL TABLE artifacts_fts USING fts5(id UNINDEXED, title, content, tokenize='unicode61');
171
- CREATE VIRTUAL TABLE code_fts USING fts5(id UNINDEXED, name, qualified_name, path, tokenize='unicode61');
172
179
  `);
180
+ if (searchBackend === "like") return "like";
181
+ try {
182
+ database.exec(`
183
+ CREATE VIRTUAL TABLE artifacts_fts USING fts5(id UNINDEXED, title, content, tokenize='unicode61');
184
+ CREATE VIRTUAL TABLE code_fts USING fts5(id UNINDEXED, name, qualified_name, path, tokenize='unicode61');
185
+ `);
186
+ return "fts5";
187
+ } catch (error) {
188
+ if (searchBackend === "fts5" || !/no such module:\s*fts5/i.test(error.message)) throw error;
189
+ return "like";
190
+ }
173
191
  }
174
192
 
175
193
  function tableExists(database, name) {
@@ -342,7 +360,7 @@ function derivedInvalidations(projectRoot, artifacts, codeNodes, prior, evidence
342
360
  return { invalidations: [...invalidations.values()], subjects };
343
361
  }
344
362
 
345
- function rebuildIndex(projectRoot) {
363
+ function rebuildIndex(projectRoot, { searchBackend = "auto" } = {}) {
346
364
  const scrumDir = path.join(projectRoot, ".scrumrun");
347
365
  if (!fs.existsSync(scrumDir)) throw new Error("Not a ScrumRun project: .scrumrun/ is missing.");
348
366
  const cacheDir = path.join(scrumDir, ".cache");
@@ -358,14 +376,19 @@ function rebuildIndex(projectRoot) {
358
376
  const evidence = evidenceSnapshots(projectRoot, artifacts);
359
377
  const derived = derivedInvalidations(projectRoot, artifacts, remapped.nodes, prior, evidence);
360
378
  let database;
379
+ let selectedSearchBackend;
361
380
  try {
362
381
  database = new DatabaseSync(temp);
363
- createSchema(database);
364
- const insertArtifact = database.prepare("INSERT INTO artifacts(id, kind, status, title, path, hash, content, valid_from, valid_until, last_verified_commit, review_trigger, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
365
- const insertFts = database.prepare("INSERT INTO artifacts_fts(id, title, content) VALUES (?, ?, ?)");
382
+ selectedSearchBackend = createSchema(database, { searchBackend });
383
+ const insertArtifact = database.prepare("INSERT INTO artifacts(id, kind, status, title, path, hash, content, valid_from, valid_until, last_verified_commit, review_trigger, search_text, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
384
+ const insertFts = selectedSearchBackend === "fts5"
385
+ ? database.prepare("INSERT INTO artifacts_fts(id, title, content) VALUES (?, ?, ?)")
386
+ : null;
366
387
  const insertEdge = database.prepare("INSERT OR IGNORE INTO edges(from_id, relation, to_id, evidence, confidence) VALUES (?, ?, ?, ?, ?)");
367
- const insertCode = database.prepare("INSERT INTO code_nodes(id, kind, name, qualified_name, path, line, fingerprint, commit_hash, status, language, exported, remapped_from) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
368
- const insertCodeFts = database.prepare("INSERT INTO code_fts(id, name, qualified_name, path) VALUES (?, ?, ?, ?)");
388
+ const insertCode = database.prepare("INSERT INTO code_nodes(id, kind, name, qualified_name, path, line, fingerprint, commit_hash, status, language, exported, remapped_from, search_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
389
+ const insertCodeFts = selectedSearchBackend === "fts5"
390
+ ? database.prepare("INSERT INTO code_fts(id, name, qualified_name, path) VALUES (?, ?, ?, ?)")
391
+ : null;
369
392
  database.exec("BEGIN IMMEDIATE");
370
393
  try {
371
394
  for (const artifact of artifacts) {
@@ -381,14 +404,29 @@ function rebuildIndex(projectRoot) {
381
404
  artifact.record.valid_until || null,
382
405
  artifact.record.last_verified_commit || null,
383
406
  artifact.record.review_trigger || null,
407
+ normalizeSearchText(artifact.title, artifact.content),
384
408
  INACTIVE.has(artifact.record.status) ? 0 : 1
385
409
  );
386
- insertFts.run(artifact.record.id, artifact.title, artifact.content);
410
+ if (insertFts) insertFts.run(artifact.record.id, artifact.title, artifact.content);
387
411
  for (const edge of explicitEdges(artifact)) insertEdge.run(artifact.record.id, edge.relation, edge.target, edge.evidence, "canonical");
388
412
  }
389
413
  for (const node of remapped.nodes) {
390
- insertCode.run(node.id, node.kind, node.name, node.qualifiedName, node.path, node.line, node.fingerprint, node.commit || null, node.status, node.language, node.exported ? 1 : 0, node.remappedFrom || null);
391
- insertCodeFts.run(node.id, node.name, node.qualifiedName, node.path);
414
+ insertCode.run(
415
+ node.id,
416
+ node.kind,
417
+ node.name,
418
+ node.qualifiedName,
419
+ node.path,
420
+ node.line,
421
+ node.fingerprint,
422
+ node.commit || null,
423
+ node.status,
424
+ node.language,
425
+ node.exported ? 1 : 0,
426
+ node.remappedFrom || null,
427
+ normalizeSearchText(node.name, node.qualifiedName, node.path)
428
+ );
429
+ if (insertCodeFts) insertCodeFts.run(node.id, node.name, node.qualifiedName, node.path);
392
430
  }
393
431
  for (const edge of snapshot.code.edges) insertEdge.run(edge.from, edge.relation, edge.to, edge.evidence, edge.confidence);
394
432
  for (const edge of prior.edges.filter((item) => remapped.orphanIds.has(item.from_id))) {
@@ -403,6 +441,7 @@ function rebuildIndex(projectRoot) {
403
441
  const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
404
442
  metadata.run("schema_version", String(INDEX_SCHEMA_VERSION));
405
443
  metadata.run("method_version", "2.0.0");
444
+ metadata.run("search_backend", selectedSearchBackend);
406
445
  metadata.run("source_fingerprint", snapshot.fingerprint);
407
446
  metadata.run("source_watch_fingerprint", snapshot.watch.fingerprint);
408
447
  metadata.run("artifact_count", String(artifacts.length));
@@ -422,7 +461,24 @@ function rebuildIndex(projectRoot) {
422
461
  if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
423
462
  throw error;
424
463
  }
425
- return { file: target, fingerprint: snapshot.fingerprint, watchFingerprint: snapshot.watch.fingerprint, artifacts: artifacts.length, codeNodes: remapped.nodes.length, warnings: [...snapshot.code.warnings, ...snapshot.watch.warnings] };
464
+ return {
465
+ file: target,
466
+ fingerprint: snapshot.fingerprint,
467
+ watchFingerprint: snapshot.watch.fingerprint,
468
+ searchBackend: selectedSearchBackend,
469
+ artifacts: artifacts.length,
470
+ codeNodes: remapped.nodes.length,
471
+ warnings: [...snapshot.code.warnings, ...snapshot.watch.warnings]
472
+ };
473
+ }
474
+
475
+ function runtimeSupportsFts5(database) {
476
+ try {
477
+ database.prepare("SELECT 1 FROM artifacts_fts LIMIT 1").get();
478
+ return true;
479
+ } catch {
480
+ return false;
481
+ }
426
482
  }
427
483
 
428
484
  function indexStatus(projectRoot) {
@@ -434,12 +490,20 @@ function indexStatus(projectRoot) {
434
490
  database = new DatabaseSync(file, { readOnly: true });
435
491
  const metadata = new Map(database.prepare("SELECT key, value FROM metadata").all().map((row) => [row.key, row.value]));
436
492
  const stored = metadata.get("source_fingerprint") || null;
437
- if (metadata.get("schema_version") !== String(INDEX_SCHEMA_VERSION) || !metadata.get("source_watch_fingerprint")) {
493
+ const searchBackend = metadata.get("search_backend") || null;
494
+ if (
495
+ metadata.get("schema_version") !== String(INDEX_SCHEMA_VERSION)
496
+ || !metadata.get("source_watch_fingerprint")
497
+ || !["fts5", "like"].includes(searchBackend)
498
+ ) {
438
499
  return { exists: true, stale: true, file, stored, check: "schema", reason: `semantic index schema ${INDEX_SCHEMA_VERSION} rebuild required` };
439
500
  }
501
+ if (searchBackend === "fts5" && !runtimeSupportsFts5(database)) {
502
+ return { exists: true, stale: true, file, stored, searchBackend, check: "schema", reason: "semantic index requires unavailable FTS5; fallback rebuild required" };
503
+ }
440
504
  const watch = sourceWatchSnapshot(projectRoot);
441
505
  if (metadata.get("source_watch_fingerprint") === watch.fingerprint) {
442
- return { exists: true, stale: false, file, stored, actual: stored, watch: watch.fingerprint, check: "metadata" };
506
+ return { exists: true, stale: false, file, stored, actual: stored, watch: watch.fingerprint, searchBackend, check: "metadata" };
443
507
  }
444
508
  const actual = sourceSnapshot(projectRoot, { watch }).fingerprint;
445
509
  return {
@@ -449,6 +513,7 @@ function indexStatus(projectRoot) {
449
513
  stored,
450
514
  actual,
451
515
  watch: watch.fingerprint,
516
+ searchBackend,
452
517
  check: "hash",
453
518
  reason: stored === actual ? "file metadata changed but semantic content is unchanged" : "canonical or source content changed"
454
519
  };
@@ -459,11 +524,40 @@ function indexStatus(projectRoot) {
459
524
  }
460
525
  }
461
526
 
527
+ function searchTokens(query) {
528
+ return (String(query || "").normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) || []).slice(0, MAX_SEARCH_TOKENS);
529
+ }
530
+
462
531
  function ftsQuery(query) {
463
- const tokens = String(query || "").normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) || [];
532
+ const tokens = searchTokens(query);
464
533
  return tokens.map((token) => `"${token.replace(/"/g, "")}"`).join(" AND ");
465
534
  }
466
535
 
536
+ function fallbackArtifactRows(database, tokens, activeClause, cap) {
537
+ if (!tokens.length) return [];
538
+ const clauses = tokens.map(() => "instr(a.search_text, ?) > 0").join(" AND ");
539
+ const parameters = tokens.map((token) => normalizeSearchText(token));
540
+ return database.prepare(`
541
+ SELECT a.id, a.kind, a.status, a.title, a.path, a.active, a.valid_until, a.review_trigger
542
+ FROM artifacts a
543
+ WHERE ${clauses} ${activeClause}
544
+ ORDER BY a.id LIMIT ?
545
+ `).all(...parameters, cap);
546
+ }
547
+
548
+ function fallbackCodeRows(database, tokens, cap) {
549
+ if (!tokens.length) return [];
550
+ const clauses = tokens.map(() => "instr(c.search_text, ?) > 0").join(" AND ");
551
+ const parameters = tokens.map((token) => normalizeSearchText(token));
552
+ return database.prepare(`
553
+ SELECT c.id, c.kind, c.name AS title, c.qualified_name, c.path, c.line, c.fingerprint,
554
+ c.commit_hash, c.status, c.language, c.remapped_from
555
+ FROM code_nodes c
556
+ WHERE ${clauses}
557
+ ORDER BY CASE c.status WHEN 'current' THEN 0 ELSE 1 END, c.id LIMIT ?
558
+ `).all(...parameters, cap);
559
+ }
560
+
467
561
  function queryIndex(projectRoot, query, { includeInactive = false, limit = DEFAULT_CONTEXT_RESULTS, relationLimit = DEFAULT_RELATIONS_PER_RESULT, rebuild = true } = {}) {
468
562
  const text = String(query || "").trim();
469
563
  if (!text) throw new Error("A non-empty memory query is required.");
@@ -476,18 +570,23 @@ function queryIndex(projectRoot, query, { includeInactive = false, limit = DEFAU
476
570
  }
477
571
  const database = new DatabaseSync(indexPath(projectRoot), { readOnly: true });
478
572
  try {
573
+ const metadata = new Map(database.prepare("SELECT key, value FROM metadata").all().map((row) => [row.key, row.value]));
574
+ const searchBackend = metadata.get("search_backend");
479
575
  const cap = Math.max(1, Math.min(Number(limit) || DEFAULT_CONTEXT_RESULTS, MAX_CONTEXT_RESULTS));
480
576
  const relationCap = Math.max(1, Math.min(Number(relationLimit) || DEFAULT_RELATIONS_PER_RESULT, MAX_RELATIONS_PER_RESULT));
481
577
  const activeClause = includeInactive ? "" : "AND a.active = 1";
482
578
  const matches = new Map();
579
+ const tokens = searchTokens(text);
483
580
  const match = ftsQuery(text);
484
- if (match) {
485
- const rows = database.prepare(`
486
- SELECT a.id, a.kind, a.status, a.title, a.path, a.active, a.valid_until, a.review_trigger, bm25(artifacts_fts) AS rank
487
- FROM artifacts_fts JOIN artifacts a ON a.id = artifacts_fts.id
488
- WHERE artifacts_fts MATCH ? ${activeClause}
489
- ORDER BY rank, a.id LIMIT ?
490
- `).all(match, cap);
581
+ if (tokens.length) {
582
+ const rows = searchBackend === "fts5"
583
+ ? database.prepare(`
584
+ SELECT a.id, a.kind, a.status, a.title, a.path, a.active, a.valid_until, a.review_trigger, bm25(artifacts_fts) AS rank
585
+ FROM artifacts_fts JOIN artifacts a ON a.id = artifacts_fts.id
586
+ WHERE artifacts_fts MATCH ? ${activeClause}
587
+ ORDER BY rank, a.id LIMIT ?
588
+ `).all(match, cap)
589
+ : fallbackArtifactRows(database, tokens, activeClause, cap);
491
590
  for (const row of rows) matches.set(`artifact:${row.id}`, { ...row, nodeType: "artifact", match: "content" });
492
591
  }
493
592
  const like = `%${text.toLowerCase()}%`;
@@ -504,14 +603,16 @@ function queryIndex(projectRoot, query, { includeInactive = false, limit = DEFAU
504
603
  if (existing) existing.match = "content+relation";
505
604
  else matches.set(key, { ...row, nodeType: "artifact", match: "relation" });
506
605
  }
507
- if (match) {
508
- const codeRows = database.prepare(`
509
- SELECT c.id, c.kind, c.name AS title, c.qualified_name, c.path, c.line, c.fingerprint,
510
- c.commit_hash, c.status, c.language, c.remapped_from, bm25(code_fts) AS rank
511
- FROM code_fts JOIN code_nodes c ON c.id = code_fts.id
512
- WHERE code_fts MATCH ?
513
- ORDER BY CASE c.status WHEN 'current' THEN 0 ELSE 1 END, rank, c.id LIMIT ?
514
- `).all(match, cap);
606
+ if (tokens.length) {
607
+ const codeRows = searchBackend === "fts5"
608
+ ? database.prepare(`
609
+ SELECT c.id, c.kind, c.name AS title, c.qualified_name, c.path, c.line, c.fingerprint,
610
+ c.commit_hash, c.status, c.language, c.remapped_from, bm25(code_fts) AS rank
611
+ FROM code_fts JOIN code_nodes c ON c.id = code_fts.id
612
+ WHERE code_fts MATCH ?
613
+ ORDER BY CASE c.status WHEN 'current' THEN 0 ELSE 1 END, rank, c.id LIMIT ?
614
+ `).all(match, cap)
615
+ : fallbackCodeRows(database, tokens, cap);
515
616
  for (const row of codeRows) matches.set(`code:${row.id}`, { ...row, nodeType: "code", match: "code" });
516
617
  }
517
618
  const codeRelationRows = database.prepare(`
@@ -575,7 +676,7 @@ function queryIndex(projectRoot, query, { includeInactive = false, limit = DEFAU
575
676
  relations
576
677
  };
577
678
  });
578
- return { indexFile: indexPath(projectRoot), rebuilt, query: text, results };
679
+ return { indexFile: indexPath(projectRoot), rebuilt, searchBackend, query: text, results };
579
680
  } finally {
580
681
  database.close();
581
682
  }
@@ -15,6 +15,7 @@ const {
15
15
  } = require("../v2/artifacts");
16
16
  const { extractEvidence, renderBullets } = require("./markdown");
17
17
  const { assertNoSecret } = require("../security/secrets");
18
+ const { assertCanonicalWrite } = require("../runtime/mutation-gateway");
18
19
 
19
20
  const MEMORY_KINDS = new Set(["knowledge", "decision", "insight", "dossier"]);
20
21
  const DEFAULT_STATUS = Object.freeze({ knowledge: "candidate", decision: "open", insight: "candidate", dossier: "active" });
@@ -203,6 +204,7 @@ function createMemoryUnlocked(projectRoot, kind, options = {}) {
203
204
  "",
204
205
  `- ${created}: ${record.status} — proposed by ${sourceType}.`
205
206
  ].join("\n");
207
+ assertCanonicalWrite(projectRoot, `create-${kind}`, [title, content, body]);
206
208
  repository.write(record, body);
207
209
  return repository.read(kind, record.id);
208
210
  }
@@ -267,6 +269,7 @@ function transitionMemoryUnlocked(projectRoot, kind, id, action, options = {}) {
267
269
  const parsed = parseArtifact(next);
268
270
  const errors = [...parsed.errors, ...validateArtifact(parsed.record, kind)];
269
271
  if (errors.length) throw new Error(`Memory transition validation failed: ${errors.join("; ")}`);
272
+ assertCanonicalWrite(projectRoot, `transition-${kind}`, [next]);
270
273
  atomicWrite(artifact.file, next);
271
274
  return repository.read(kind, id);
272
275
  }