@esneiderbravo/speclaw 2.0.10 → 2.0.12

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.
@@ -1,13 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
- import { openDb, clearNeedsReindex, needsReindex, FILE_NODE_KIND } from "./db.js";
4
+ import { openDb, openCurrentDb, isCurrentIndex, clearNeedsReindex, needsReindex, postPending, setPostPending, clearPostPending, FILE_NODE_KIND, } from "./db.js";
5
5
  import { langForPath } from "./languages.js";
6
6
  import { extract, importSpecifier } from "./extract.js";
7
7
  import { getEmbedder, toBlob } from "./embedder.js";
8
8
  import { contentHashFor, defaultEmbedText } from "./embed-input.js";
9
- import { buildDirHashMap } from "./merkle.js";
10
- import { loadAffectedConfig, isTestPath, inferModule } from "./affected-config.js";
9
+ import { buildDirHashMap, dirHash, HASH_EMPTY } from "./merkle.js";
10
+ import { loadAffectedConfig, isTestPath, inferModule, } from "./affected-config.js";
11
+ import { realPathOf } from "../../shared/paths.js";
11
12
  import { personalizedPageRank, edgeWeightMul } from "./pagerank.js";
12
13
  const SKIP_DIRS = new Set([
13
14
  ".git",
@@ -30,6 +31,132 @@ const SKIP_DIRS = new Set([
30
31
  ".cache",
31
32
  ]);
32
33
  const MAX_FILE_BYTES = 1_500_000;
34
+ /**
35
+ * Whether a directory or file name inside the project is walked by a full index.
36
+ * The single source of the skip rule shared by the walk and per-file reindex.
37
+ */
38
+ function isSkippedDir(name) {
39
+ return SKIP_DIRS.has(name);
40
+ }
41
+ /**
42
+ * `p` with symlinks resolved and, where the platform reports it (macOS,
43
+ * Windows), each existing segment spelled as stored on disk. A missing tail
44
+ * keeps its given spelling. Falls back to {@link realPathOf} when the native
45
+ * resolver fails.
46
+ *
47
+ * @param p - An absolute path.
48
+ */
49
+ function canonicalPathOf(p) {
50
+ const tail = [];
51
+ let cur = path.resolve(p);
52
+ for (;;) {
53
+ try {
54
+ return path.join(fs.realpathSync.native(cur), ...tail.reverse());
55
+ }
56
+ catch (err) {
57
+ const code = err.code;
58
+ if (code !== "ENOENT" && code !== "ENOTDIR")
59
+ return realPathOf(p);
60
+ const parent = path.dirname(cur);
61
+ if (parent === cur)
62
+ return realPathOf(p);
63
+ tail.push(path.basename(cur));
64
+ cur = parent;
65
+ }
66
+ }
67
+ }
68
+ /**
69
+ * Classify one path against the full walk's file-set rules, so a per-file
70
+ * reindex and a full index agree on which files are indexed (and the directory
71
+ * Merkle tree stays the hash of the same file set).
72
+ *
73
+ * A path is eligible when it lies inside `projectPath` (symlinks in its existing
74
+ * prefix resolved, and its spelling taken from the filesystem where the platform
75
+ * reports it, so a case variant on a case-insensitive volume yields the stored
76
+ * spelling), no directory segment is a skipped directory, its extension
77
+ * maps to an indexed language, it is a regular file, and it does not exceed the
78
+ * walk's size cap. A path that does not exist yields `missing` with its
79
+ * repo-relative form, so the caller can drop a deleted file's rows.
80
+ *
81
+ * @param projectPath - Absolute project root.
82
+ * @param absPath - Absolute path to classify.
83
+ */
84
+ export function classifyIndexPath(projectPath, absPath) {
85
+ const relNative = path.relative(canonicalPathOf(projectPath), canonicalPathOf(absPath));
86
+ if (!relNative || relNative.startsWith("..") || path.isAbsolute(relNative)) {
87
+ return { eligible: false, rel: null, reason: "outside" };
88
+ }
89
+ const rel = relNative.split(path.sep).join("/");
90
+ const parts = rel.split("/");
91
+ if (parts.slice(0, -1).some(isSkippedDir))
92
+ return { eligible: false, rel, reason: "ignored" };
93
+ const lang = langForPath(rel);
94
+ if (!lang)
95
+ return { eligible: false, rel, reason: "language" };
96
+ let stat;
97
+ try {
98
+ stat = fs.statSync(path.join(projectPath, rel));
99
+ }
100
+ catch {
101
+ return { eligible: false, rel, reason: "missing" };
102
+ }
103
+ if (!stat.isFile())
104
+ return { eligible: false, rel, reason: "ignored" };
105
+ if (stat.size > MAX_FILE_BYTES)
106
+ return { eligible: false, rel, reason: "size" };
107
+ return { eligible: true, rel, lang, size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) };
108
+ }
109
+ /**
110
+ * Whether a full walk of `projectPath` would yield exactly `rel`: every segment
111
+ * is listed by its parent directory with exactly that spelling (a case-sensitive
112
+ * compare, so a case-only rename on a case-insensitive volume does not match),
113
+ * every directory segment is a real, non-skipped directory and the leaf a
114
+ * regular file (no segment is a symlink, as the walk never follows one), and
115
+ * {@link classifyIndexPath} finds it eligible under the same `rel`.
116
+ *
117
+ * A full run keeps a stored row its walk did not see only when this holds;
118
+ * any other row would duplicate the file's symbols under a second path.
119
+ *
120
+ * @param projectPath - Absolute project root (as walked).
121
+ * @param rel - Repo-relative path with `/` separators.
122
+ * @param listings - Optional cache of directory listings, keyed by absolute
123
+ * directory, shared across calls in one run.
124
+ */
125
+ export function walkWouldYield(projectPath, rel, listings = new Map()) {
126
+ const parts = rel.split("/");
127
+ if (parts.some((p) => p === "" || p === "." || p === ".."))
128
+ return false;
129
+ let dir = projectPath;
130
+ for (let i = 0; i < parts.length; i++) {
131
+ const name = parts[i];
132
+ let names = listings.get(dir);
133
+ if (names === undefined) {
134
+ try {
135
+ names = new Set(fs.readdirSync(dir));
136
+ }
137
+ catch {
138
+ names = null;
139
+ }
140
+ listings.set(dir, names);
141
+ }
142
+ if (!names?.has(name))
143
+ return false;
144
+ const full = path.join(dir, name);
145
+ let stat;
146
+ try {
147
+ stat = fs.lstatSync(full);
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ const leaf = i === parts.length - 1;
153
+ if (leaf ? !stat.isFile() : !stat.isDirectory() || isSkippedDir(name))
154
+ return false;
155
+ dir = full;
156
+ }
157
+ const c = classifyIndexPath(projectPath, path.join(projectPath, rel));
158
+ return c.eligible && c.rel === rel;
159
+ }
33
160
  function hashOf(content) {
34
161
  return createHash("sha256").update(content).digest("hex");
35
162
  }
@@ -431,7 +558,7 @@ function* walkFiles(root) {
431
558
  for (const entry of entries) {
432
559
  const full = path.join(dir, entry.name);
433
560
  if (entry.isDirectory()) {
434
- if (!SKIP_DIRS.has(entry.name))
561
+ if (!isSkippedDir(entry.name))
435
562
  stack.push(full);
436
563
  }
437
564
  else if (entry.isFile()) {
@@ -441,6 +568,152 @@ function* walkFiles(root) {
441
568
  }
442
569
  }
443
570
  }
571
+ function prepareFileWriter(db, embedder, cfg, touch = true) {
572
+ return {
573
+ db,
574
+ touch,
575
+ collectOwners: !touch,
576
+ embedder,
577
+ cfg,
578
+ insFile: db.prepare("INSERT INTO files(path, hash, lang, is_test, module, mtime_ms, size) VALUES (?, ?, ?, ?, ?, ?, ?)"),
579
+ updFile: db.prepare("UPDATE files SET hash = ?, lang = ?, is_test = ?, module = ?, mtime_ms = ?, size = ? WHERE id = ?"),
580
+ detach: db.prepare("UPDATE edges SET dst_node_id = NULL WHERE dst_node_id IN (SELECT id FROM nodes WHERE file_id = ?)"),
581
+ detachedOwners: db.prepare(`SELECT DISTINCT src_file_id AS id FROM edges
582
+ WHERE src_file_id <> ? AND dst_node_id IN (SELECT id FROM nodes WHERE file_id = ?)`),
583
+ delNodes: db.prepare("DELETE FROM nodes WHERE file_id = ?"),
584
+ delEdges: db.prepare("DELETE FROM edges WHERE src_file_id = ?"),
585
+ delCoverage: db.prepare("DELETE FROM coverage_links WHERE file_path = ?"),
586
+ delFile: db.prepare("DELETE FROM files WHERE id = ?"),
587
+ insNode: db.prepare(`INSERT INTO nodes(file_id, name, kind, start_line, end_line, start_byte, end_byte, parent_id, signature, body_hash, norm_hash, content_hash)
588
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
589
+ insMetrics: db.prepare(`INSERT INTO node_metrics(node_id, loc, max_nesting, branches) VALUES (?, ?, ?, ?)`),
590
+ insEdge: db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line, is_member, spec) VALUES (?, ?, ?, ?, ?, ?, ?)`),
591
+ insCoverage: db.prepare(`INSERT OR REPLACE INTO coverage_links(
592
+ artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
593
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
594
+ insCache: db.prepare(`INSERT INTO embedding_cache(content_hash, model, dim, vec, created_at, last_seen_at)
595
+ VALUES (?, ?, ?, ?, ?, ?)
596
+ ON CONFLICT(content_hash, model) DO UPDATE SET last_seen_at = excluded.last_seen_at`),
597
+ hasCache: db.prepare(`SELECT 1 AS ok FROM embedding_cache WHERE content_hash = ? AND model = ?`),
598
+ touchCache: db.prepare(`UPDATE embedding_cache SET last_seen_at = ? WHERE content_hash = ? AND model = ?`),
599
+ insNodeText: db.prepare(`INSERT INTO node_text(node_id, name, subtokens, signature, doc) VALUES (?, ?, ?, ?, ?)
600
+ ON CONFLICT(node_id) DO UPDATE SET
601
+ name = excluded.name,
602
+ subtokens = excluded.subtokens,
603
+ signature = excluded.signature,
604
+ doc = excluded.doc`),
605
+ };
606
+ }
607
+ /**
608
+ * Reset every edge whose destination is one of a file's nodes, before those
609
+ * nodes are deleted. Without this, a rowid freed by the delete and reused by a
610
+ * node inserted later in the same run would silently re-point another file's
611
+ * edge at an unrelated symbol; a post-hoc "id not in nodes" check cannot see
612
+ * that. The run's resolution pass then re-binds the edges by their rules.
613
+ *
614
+ * @returns The ids of the other files whose edges were reset (empty on a full run, which does not need them).
615
+ */
616
+ function detachFileNodes(w, fileId) {
617
+ // Covers: req~edge-ids-survive-reindex~1
618
+ const owners = w.collectOwners
619
+ ? w.detachedOwners.all(fileId, fileId).map((r) => r.id)
620
+ : [];
621
+ w.detach.run(fileId);
622
+ return owners;
623
+ }
624
+ /**
625
+ * Drop a removed file: detach edges into it, delete its coverage links (the
626
+ * file-level ones have no node to cascade from), then delete its row (nodes and
627
+ * edges cascade).
628
+ */
629
+ function removeFileRows(w, fileId, rel) {
630
+ const owners = detachFileNodes(w, fileId);
631
+ w.delCoverage.run(rel);
632
+ w.delFile.run(fileId);
633
+ return owners;
634
+ }
635
+ /**
636
+ * (Re-)extract one file and write its whole fragment: upsert its `files` row,
637
+ * replace its nodes, file-owner node, edges, `node_text`/FTS rows, metrics, and
638
+ * coverage links, and embed cache misses through `embedding_cache`. Edges are
639
+ * written unresolved; the caller runs {@link resolveEdges}.
640
+ *
641
+ * @returns The file id and the ids of other files whose edges were detached.
642
+ */
643
+ async function writeFileFragment(w, f) {
644
+ const { rel, lang, content, stats } = f;
645
+ let fileId;
646
+ let detachedOwners = [];
647
+ const isTest = isTestPath(rel, w.cfg.testGlobs) ? 1 : 0;
648
+ const mod = inferModule(rel);
649
+ if (f.priorId !== null) {
650
+ w.updFile.run(f.hash, lang.id, isTest, mod, f.mtimeMs, f.size, f.priorId);
651
+ // Covers: req~edge-ids-survive-reindex~1
652
+ detachedOwners = detachFileNodes(w, f.priorId);
653
+ w.delNodes.run(f.priorId);
654
+ w.delEdges.run(f.priorId);
655
+ w.delCoverage.run(rel);
656
+ fileId = f.priorId;
657
+ }
658
+ else {
659
+ fileId = Number(w.insFile.run(rel, f.hash, lang.id, isTest, mod, f.mtimeMs, f.size).lastInsertRowid);
660
+ }
661
+ const { symbols, refs, coverage, reexports } = await extract(content, lang);
662
+ const nodeIds = [];
663
+ const now = Date.now();
664
+ for (const s of symbols) {
665
+ const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
666
+ const embedText = defaultEmbedText(s.kind, s.name, s.signature);
667
+ const ch = contentHashFor({
668
+ lang: lang.id,
669
+ kind: s.kind,
670
+ name: s.name,
671
+ signature: s.signature,
672
+ embedText,
673
+ });
674
+ const id = Number(w.insNode.run(fileId, s.name, s.kind, s.startLine, s.endLine, s.startByte, s.endByte, parentId, s.signature, s.bodyHash, s.normHash, ch).lastInsertRowid);
675
+ nodeIds.push(id);
676
+ w.insMetrics.run(id, s.loc, s.maxNesting, s.branches);
677
+ w.insNodeText.run(id, s.name, s.subtokens, s.signature ?? "", s.docstring);
678
+ const hit = w.hasCache.get(ch, w.embedder.id);
679
+ if (hit) {
680
+ if (w.touch)
681
+ w.touchCache.run(now, ch, w.embedder.id);
682
+ stats.fromCache++;
683
+ }
684
+ else {
685
+ const vec = await w.embedder.embed(embedText);
686
+ w.insCache.run(ch, w.embedder.id, w.embedder.dim, toBlob(vec), now, now);
687
+ stats.computed++;
688
+ }
689
+ stats.embeddings++;
690
+ }
691
+ // Covers: req~impact-id-first~1
692
+ // Imports and calls outside any definition belong to the file: give the
693
+ // file one hidden owner node so no edge is ever stored ownerless. A file
694
+ // with no symbols or with re-exports (a barrel: `export * from "./x"`)
695
+ // gets one too, so an import of it resolves to the file instead of
696
+ // looking like a package. It gets no text, embedding, or metrics.
697
+ const isOrphan = (r) => r.kind === "import" || r.ownerIndex === null;
698
+ let fileOwner = null;
699
+ if (refs.some(isOrphan) || symbols.length === 0 || reexports.length > 0) {
700
+ const lastLine = content.length === 0 ? 1 : content.replace(/\n$/, "").split("\n").length;
701
+ fileOwner = Number(w.insNode.run(fileId, rel, FILE_NODE_KIND, 1, lastLine, 0, content.length, null, null, null, null, null).lastInsertRowid);
702
+ }
703
+ for (const r of refs) {
704
+ const srcId = isOrphan(r) ? fileOwner : nodeIds[r.ownerIndex];
705
+ w.insEdge.run(srcId, fileId, r.name, r.kind, r.line, r.member, r.spec);
706
+ stats.edges++;
707
+ }
708
+ const sourceType = inferSourceType(rel);
709
+ for (const c of coverage) {
710
+ const nodeId = c.ownerIndex !== null ? nodeIds[c.ownerIndex] : null;
711
+ w.insCoverage.run(c.artifactType, c.name, c.revision, c.kind, rel, c.line, nodeId, sourceType, "comment");
712
+ }
713
+ stats.files++;
714
+ stats.nodes += symbols.length;
715
+ return { fileId, detachedOwners };
716
+ }
444
717
  /**
445
718
  * Build or incrementally refresh the index for a project.
446
719
  *
@@ -467,7 +740,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
467
740
  const maxCacheMB = opts.maxCacheMB ?? DEFAULT_MAX_CACHE_MB;
468
741
  const retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS;
469
742
  const db = openDb(projectPath);
470
- const force = Boolean(opts.force) || needsReindex(db);
743
+ opts.hooks?.onOpen?.(db);
471
744
  const embedder = getEmbedder();
472
745
  const stats = {
473
746
  files: 0,
@@ -485,61 +758,45 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
485
758
  nextStep: "",
486
759
  };
487
760
  const cfg = loadAffectedConfig(projectPath);
488
- const existing = new Map();
489
- for (const row of db.prepare("SELECT id, path, hash, mtime_ms, size FROM files").all()) {
490
- existing.set(row.path, {
491
- id: row.id,
492
- hash: row.hash,
493
- mtime_ms: row.mtime_ms,
494
- size: row.size,
495
- });
496
- }
497
- const prevRoot = db.prepare("SELECT hash FROM dir_hashes WHERE path = ''").get();
498
761
  const seen = new Set();
499
762
  const fileHashes = new Map();
500
- const insFile = db.prepare("INSERT INTO files(path, hash, lang, is_test, module, mtime_ms, size) VALUES (?, ?, ?, ?, ?, ?, ?)");
501
- const updFile = db.prepare("UPDATE files SET hash = ?, lang = ?, is_test = ?, module = ?, mtime_ms = ?, size = ? WHERE id = ?");
502
- const delNodes = db.prepare("DELETE FROM nodes WHERE file_id = ?");
503
- const delEdges = db.prepare("DELETE FROM edges WHERE src_file_id = ?");
504
- const delCoverage = db.prepare("DELETE FROM coverage_links WHERE file_path = ?");
505
- const insNode = db.prepare(`INSERT INTO nodes(file_id, name, kind, start_line, end_line, start_byte, end_byte, parent_id, signature, body_hash, norm_hash, content_hash)
506
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
507
- const insMetrics = db.prepare(`INSERT INTO node_metrics(node_id, loc, max_nesting, branches) VALUES (?, ?, ?, ?)`);
508
- const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line, is_member, spec) VALUES (?, ?, ?, ?, ?, ?, ?)`);
509
- const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
510
- artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
511
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
512
- const insCache = db.prepare(`INSERT INTO embedding_cache(content_hash, model, dim, vec, created_at, last_seen_at)
513
- VALUES (?, ?, ?, ?, ?, ?)
514
- ON CONFLICT(content_hash, model) DO UPDATE SET last_seen_at = excluded.last_seen_at`);
515
- const hasCache = db.prepare(`SELECT 1 AS ok FROM embedding_cache WHERE content_hash = ? AND model = ?`);
516
- const insNodeText = db.prepare(`INSERT INTO node_text(node_id, name, subtokens, signature, doc) VALUES (?, ?, ?, ?, ?)
517
- ON CONFLICT(node_id) DO UPDATE SET
518
- name = excluded.name,
519
- subtokens = excluded.subtokens,
520
- signature = excluded.signature,
521
- doc = excluded.doc`);
763
+ const w = prepareFileWriter(db, embedder, cfg);
522
764
  const allFiles = [...walkFiles(projectPath)];
523
765
  // Set inside the transaction; read after it to decide whether the map is written.
524
766
  let noop;
525
- db.exec("BEGIN");
526
767
  try {
527
- let done = 0;
528
- for (const filePath of allFiles) {
529
- const rel = path.relative(projectPath, filePath).split(path.sep).join("/");
530
- done++;
531
- if (onProgress)
532
- onProgress({ file: rel, done, total: allFiles.length });
768
+ await opts.hooks?.afterWalk?.();
769
+ // Take the write lock before reading the stored file set: a per-file
770
+ // reindex committing a new file's row between that read and this run's
771
+ // insert would otherwise fail the whole run on the unique path. Per-file
772
+ // runs wait for (or give up on) this lock instead. Inside the try so a
773
+ // SQLITE_BUSY here still closes the connection.
774
+ db.exec("BEGIN IMMEDIATE");
775
+ const force = Boolean(opts.force) || needsReindex(db);
776
+ const existing = new Map();
777
+ for (const row of db
778
+ .prepare("SELECT id, path, hash, mtime_ms, size FROM files")
779
+ .all()) {
780
+ existing.set(row.path, {
781
+ id: row.id,
782
+ hash: row.hash,
783
+ mtime_ms: row.mtime_ms,
784
+ size: row.size,
785
+ });
786
+ }
787
+ const prevRoot = db.prepare("SELECT hash FROM dir_hashes WHERE path = ''").get();
788
+ // Stat, hash, and (when changed) re-extract one walked file.
789
+ const visit = async (filePath, rel) => {
533
790
  seen.add(rel);
534
791
  const lang = langForPath(filePath);
535
792
  let stat;
536
793
  try {
537
794
  stat = fs.statSync(filePath);
538
795
  if (stat.size > MAX_FILE_BYTES)
539
- continue;
796
+ return;
540
797
  }
541
798
  catch {
542
- continue;
799
+ return;
543
800
  }
544
801
  const prior = existing.get(rel);
545
802
  const mtimeMs = Math.trunc(stat.mtimeMs);
@@ -553,98 +810,60 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
553
810
  fileHashes.set(rel, prior.hash);
554
811
  stats.skippedByStat++;
555
812
  stats.unchanged++;
556
- continue;
813
+ return;
557
814
  }
558
815
  let content;
559
816
  try {
560
817
  content = fs.readFileSync(filePath, "utf8");
561
818
  }
562
819
  catch {
563
- continue;
820
+ return;
564
821
  }
565
822
  const hash = hashOf(content);
566
823
  fileHashes.set(rel, hash);
567
824
  if (!force && prior && prior.hash === hash) {
568
- updFile.run(hash, lang.id, isTestPath(rel, cfg.testGlobs) ? 1 : 0, inferModule(rel), mtimeMs, size, prior.id);
825
+ w.updFile.run(hash, lang.id, isTestPath(rel, cfg.testGlobs) ? 1 : 0, inferModule(rel), mtimeMs, size, prior.id);
569
826
  stats.unchanged++;
570
- continue;
571
- }
572
- let fileId;
573
- const isTest = isTestPath(rel, cfg.testGlobs) ? 1 : 0;
574
- const mod = inferModule(rel);
575
- if (prior) {
576
- updFile.run(hash, lang.id, isTest, mod, mtimeMs, size, prior.id);
577
- delNodes.run(prior.id);
578
- delEdges.run(prior.id);
579
- delCoverage.run(rel);
580
- fileId = prior.id;
581
- }
582
- else {
583
- fileId = Number(insFile.run(rel, hash, lang.id, isTest, mod, mtimeMs, size).lastInsertRowid);
827
+ return;
584
828
  }
585
- const { symbols, refs, coverage, reexports } = await extract(content, lang);
586
- const nodeIds = [];
587
- const now = Date.now();
588
- const touchCache = db.prepare(`UPDATE embedding_cache SET last_seen_at = ? WHERE content_hash = ? AND model = ?`);
589
- for (const s of symbols) {
590
- const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
591
- const embedText = defaultEmbedText(s.kind, s.name, s.signature);
592
- const ch = contentHashFor({
593
- lang: lang.id,
594
- kind: s.kind,
595
- name: s.name,
596
- signature: s.signature,
597
- embedText,
598
- });
599
- const id = Number(insNode.run(fileId, s.name, s.kind, s.startLine, s.endLine, s.startByte, s.endByte, parentId, s.signature, s.bodyHash, s.normHash, ch).lastInsertRowid);
600
- nodeIds.push(id);
601
- insMetrics.run(id, s.loc, s.maxNesting, s.branches);
602
- insNodeText.run(id, s.name, s.subtokens, s.signature ?? "", s.docstring);
603
- const hit = hasCache.get(ch, embedder.id);
604
- if (hit) {
605
- touchCache.run(now, ch, embedder.id);
606
- stats.fromCache++;
607
- }
608
- else {
609
- const vec = await embedder.embed(embedText);
610
- insCache.run(ch, embedder.id, embedder.dim, toBlob(vec), now, now);
611
- stats.computed++;
612
- }
613
- stats.embeddings++;
614
- }
615
- // Covers: req~impact-id-first~1
616
- // Imports and calls outside any definition belong to the file: give the
617
- // file one hidden owner node so no edge is ever stored ownerless. A file
618
- // with no symbols or with re-exports (a barrel: `export * from "./x"`)
619
- // gets one too, so an import of it resolves to the file instead of
620
- // looking like a package. It gets no text, embedding, or metrics.
621
- const isOrphan = (r) => r.kind === "import" || r.ownerIndex === null;
622
- let fileOwner = null;
623
- if (refs.some(isOrphan) || symbols.length === 0 || reexports.length > 0) {
624
- const lastLine = content.length === 0 ? 1 : content.replace(/\n$/, "").split("\n").length;
625
- fileOwner = Number(insNode.run(fileId, rel, FILE_NODE_KIND, 1, lastLine, 0, content.length, null, null, null, null, null).lastInsertRowid);
626
- }
627
- for (const r of refs) {
628
- const srcId = isOrphan(r) ? fileOwner : nodeIds[r.ownerIndex];
629
- insEdge.run(srcId, fileId, r.name, r.kind, r.line, r.member, r.spec);
630
- stats.edges++;
631
- }
632
- const sourceType = inferSourceType(rel);
633
- for (const c of coverage) {
634
- const nodeId = c.ownerIndex !== null ? nodeIds[c.ownerIndex] : null;
635
- insCoverage.run(c.artifactType, c.name, c.revision, c.kind, rel, c.line, nodeId, sourceType, "comment");
636
- }
637
- stats.files++;
638
- stats.nodes += symbols.length;
829
+ await writeFileFragment(w, {
830
+ rel,
831
+ lang,
832
+ content,
833
+ hash,
834
+ mtimeMs,
835
+ size,
836
+ priorId: prior?.id ?? null,
837
+ stats,
838
+ });
839
+ };
840
+ let done = 0;
841
+ for (const filePath of allFiles) {
842
+ const rel = path.relative(projectPath, filePath).split(path.sep).join("/");
843
+ done++;
844
+ if (onProgress)
845
+ onProgress({ file: rel, done, total: allFiles.length });
846
+ await visit(filePath, rel);
639
847
  }
848
+ const listings = new Map();
640
849
  for (const [rel, row] of existing) {
641
850
  if (!seen.has(rel)) {
642
- db.prepare("DELETE FROM files WHERE id = ?").run(row.id);
643
- stats.removed++;
851
+ // The walk ran before the lock: a per-file reindex may have committed
852
+ // a row for a file created after the walk passed its directory. Keep
853
+ // and index a row only when the walk would now yield that exact path;
854
+ // drop the rest (deleted, ineligible, renamed by case only, or reached
855
+ // only through a symlink) so no file is held under two paths.
856
+ const abs = path.join(projectPath, rel);
857
+ if (!walkWouldYield(projectPath, rel, listings)) {
858
+ // Covers: req~edge-ids-survive-reindex~1
859
+ removeFileRows(w, row.id, rel);
860
+ stats.removed++;
861
+ continue;
862
+ }
863
+ await visit(abs, rel);
644
864
  }
645
- else if (!fileHashes.has(rel)) {
865
+ if (!fileHashes.has(rel))
646
866
  fileHashes.set(rel, row.hash);
647
- }
648
867
  }
649
868
  const dirMap = buildDirHashMap(fileHashes);
650
869
  const rootHash = dirMap.get("") ?? "";
@@ -655,7 +874,14 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
655
874
  // would write — skip them so a run on every session start stays cheap.
656
875
  // `rootUnchanged` already implies no force (explicit or needs_reindex); an
657
876
  // explicit prune or cache cap is a maintenance request and runs in full.
658
- noop = stats.rootUnchanged && !prune && opts.maxCacheMB === undefined && stats.removed === 0;
877
+ // A per-file reindex already moved the stored hashes to match the tree but
878
+ // deferred PageRank and the map to here: its marker disables the fast path.
879
+ noop =
880
+ stats.rootUnchanged &&
881
+ !prune &&
882
+ opts.maxCacheMB === undefined &&
883
+ stats.removed === 0 &&
884
+ !postPending(db);
659
885
  if (!noop) {
660
886
  const now = Date.now();
661
887
  db.prepare("DELETE FROM dir_hashes").run();
@@ -666,6 +892,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
666
892
  }
667
893
  resolveEdges(db);
668
894
  recomputeGlobalPagerank(db);
895
+ clearPostPending(db);
669
896
  // Touch last_seen for all live content hashes under active model
670
897
  db.prepare(`UPDATE embedding_cache SET last_seen_at = ?
671
898
  WHERE model = ?
@@ -688,7 +915,10 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
688
915
  db.exec("COMMIT");
689
916
  }
690
917
  catch (err) {
691
- db.exec("ROLLBACK");
918
+ // SQLite may already have rolled back (or BEGIN never succeeded): an
919
+ // unguarded ROLLBACK would throw and mask the original error.
920
+ if (db.isTransaction)
921
+ db.exec("ROLLBACK");
692
922
  throw err;
693
923
  }
694
924
  finally {
@@ -706,6 +936,202 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
706
936
  }
707
937
  return stats;
708
938
  }
939
+ /**
940
+ * Re-index exactly the given files in an existing, current index — the
941
+ * per-edit path behind `speclaw reindex-file`.
942
+ *
943
+ * Inside one `BEGIN IMMEDIATE` transaction (so each file is read after the
944
+ * write lock is held, and the last run to begin saw the last edit), each path
945
+ * is classified with the full walk's rules ({@link classifyIndexPath}): a
946
+ * changed file is re-extracted with the same fragment writer as
947
+ * {@link buildIndex}, a deleted file's rows are dropped, an unchanged file only
948
+ * refreshes its stat columns, and anything else is skipped. Edges are then
949
+ * resolved by {@link resolveEdges} scoped to the re-indexed files plus the files
950
+ * whose edges were detached from them (that scope also re-tries NULL edges that
951
+ * name a symbol the re-indexed files now define), and the directory hashes of
952
+ * the touched files' ancestors are recomputed.
953
+ *
954
+ * Global post-processing is deferred: no PageRank recomputation (the old rows of
955
+ * replaced nodes drop by cascade; new symbols have none until the next full
956
+ * run), no embedding-cache touch or eviction, no `docs/compass.md` write, no
957
+ * `meta.indexed_at` change, and no Compass call log entry. When it writes any
958
+ * row it sets `meta.post_pending`, which makes the next full run skip its no-op
959
+ * fast path and do that work.
960
+ *
961
+ * Caveat inherited from scoped resolution: an edge already bound to a node
962
+ * elsewhere is never re-ranked, so when an edit adds a better candidate for an
963
+ * ambiguous name the old binding stays until the next full run that re-extracts
964
+ * the calling file.
965
+ *
966
+ * Never creates, migrates, or wipes the database: a missing index or one that
967
+ * needs a full reindex returns `stale: true` with nothing written.
968
+ *
969
+ * @param projectPath - Absolute project root.
970
+ * @param paths - File paths, absolute or relative to `projectPath`.
971
+ * @throws When the database stays locked past the busy timeout (`SQLITE_BUSY`) or a write fails; the transaction is rolled back.
972
+ */
973
+ export async function indexFiles(projectPath, paths) {
974
+ // Covers: req~reindex-on-edit~1
975
+ const result = {
976
+ reindexed: [],
977
+ removed: [],
978
+ unchanged: [],
979
+ skipped: [],
980
+ stale: false,
981
+ };
982
+ const db = openCurrentDb(projectPath);
983
+ if (!db) {
984
+ result.stale = true;
985
+ return result;
986
+ }
987
+ try {
988
+ const embedder = getEmbedder();
989
+ const w = prepareFileWriter(db, embedder, loadAffectedConfig(projectPath), false);
990
+ const getFile = db.prepare("SELECT id, hash FROM files WHERE path = ?");
991
+ const stats = {
992
+ files: 0,
993
+ nodes: 0,
994
+ edges: 0,
995
+ embeddings: 0,
996
+ computed: 0,
997
+ fromCache: 0,
998
+ };
999
+ const scope = new Set();
1000
+ const changed = [];
1001
+ const targets = [...new Set(paths.map((p) => path.resolve(projectPath, p)))];
1002
+ db.exec("BEGIN IMMEDIATE");
1003
+ try {
1004
+ // The probe ran before the lock: a full run (or another version) may
1005
+ // have migrated, wiped, or flagged the index since. Re-check under the
1006
+ // lock and leave it to the next full run if so.
1007
+ if (!isCurrentIndex(db)) {
1008
+ db.exec("ROLLBACK");
1009
+ result.stale = true;
1010
+ return result;
1011
+ }
1012
+ for (const abs of targets) {
1013
+ let c = classifyIndexPath(projectPath, abs);
1014
+ // Register only the path a full walk would yield (exact spelling, no
1015
+ // symlink segment); a full run would otherwise drop the row again.
1016
+ if (c.eligible && !walkWouldYield(projectPath, c.rel)) {
1017
+ c = { eligible: false, rel: c.rel, reason: "ignored" };
1018
+ }
1019
+ if (!c.eligible) {
1020
+ const row = c.reason === "missing" && c.rel !== null
1021
+ ? getFile.get(c.rel)
1022
+ : undefined;
1023
+ if (row && c.rel !== null) {
1024
+ // Covers: req~edge-ids-survive-reindex~1
1025
+ for (const id of removeFileRows(w, row.id, c.rel))
1026
+ scope.add(id);
1027
+ result.removed.push(c.rel);
1028
+ changed.push(c.rel);
1029
+ }
1030
+ else {
1031
+ result.skipped.push({ path: c.rel ?? abs, reason: c.reason });
1032
+ }
1033
+ continue;
1034
+ }
1035
+ let content;
1036
+ try {
1037
+ content = fs.readFileSync(path.join(projectPath, c.rel), "utf8");
1038
+ }
1039
+ catch {
1040
+ result.skipped.push({ path: c.rel, reason: "missing" });
1041
+ continue;
1042
+ }
1043
+ const hash = hashOf(content);
1044
+ const prior = getFile.get(c.rel);
1045
+ if (prior && prior.hash === hash) {
1046
+ w.updFile.run(hash, c.lang.id, isTestPath(c.rel, w.cfg.testGlobs) ? 1 : 0, inferModule(c.rel), c.mtimeMs, c.size, prior.id);
1047
+ result.unchanged.push(c.rel);
1048
+ continue;
1049
+ }
1050
+ const written = await writeFileFragment(w, {
1051
+ rel: c.rel,
1052
+ lang: c.lang,
1053
+ content,
1054
+ hash,
1055
+ mtimeMs: c.mtimeMs,
1056
+ size: c.size,
1057
+ priorId: prior?.id ?? null,
1058
+ stats,
1059
+ });
1060
+ scope.add(written.fileId);
1061
+ for (const id of written.detachedOwners)
1062
+ scope.add(id);
1063
+ result.reindexed.push(c.rel);
1064
+ changed.push(c.rel);
1065
+ }
1066
+ if (changed.length > 0) {
1067
+ resolveEdges(db, [...scope]);
1068
+ updateAncestorDirHashes(db, changed);
1069
+ setPostPending(db);
1070
+ }
1071
+ db.exec("COMMIT");
1072
+ }
1073
+ catch (err) {
1074
+ if (db.isTransaction)
1075
+ db.exec("ROLLBACK");
1076
+ throw err;
1077
+ }
1078
+ }
1079
+ finally {
1080
+ db.close();
1081
+ }
1082
+ return result;
1083
+ }
1084
+ /**
1085
+ * Recompute the `dir_hashes` rows of every ancestor directory of the given
1086
+ * files (deepest first, root last) from the stored `files.hash` values and the
1087
+ * child directory rows, with the same hash rule and `n_files` count as the full
1088
+ * walk ({@link buildDirHashMap}). A directory left with no indexed file loses
1089
+ * its row; the root row always exists.
1090
+ */
1091
+ function updateAncestorDirHashes(db, rels) {
1092
+ const dirs = new Set([""]);
1093
+ for (const rel of rels) {
1094
+ const parts = rel.split("/");
1095
+ for (let i = 1; i < parts.length; i++)
1096
+ dirs.add(parts.slice(0, i).join("/"));
1097
+ }
1098
+ const depth = (d) => (d === "" ? 0 : d.split("/").length);
1099
+ const ordered = [...dirs].sort((a, b) => depth(b) - depth(a));
1100
+ const all = db.prepare("SELECT path, hash FROM files");
1101
+ // '/' + 1 is '0': [dir/, dir0) is every path under dir/ in byte order.
1102
+ const under = db.prepare("SELECT path, hash FROM files WHERE path >= ? AND path < ?");
1103
+ const dirRow = db.prepare("SELECT hash FROM dir_hashes WHERE path = ?");
1104
+ const upsert = db.prepare(`INSERT INTO dir_hashes(path, hash, n_files, updated_at) VALUES (?, ?, ?, ?)
1105
+ ON CONFLICT(path) DO UPDATE SET hash = excluded.hash, n_files = excluded.n_files,
1106
+ updated_at = excluded.updated_at`);
1107
+ const del = db.prepare("DELETE FROM dir_hashes WHERE path = ?");
1108
+ const now = Date.now();
1109
+ for (const dir of ordered) {
1110
+ const prefix = dir === "" ? "" : `${dir}/`;
1111
+ const rows = (dir === "" ? all.all() : under.all(prefix, `${dir}0`));
1112
+ if (rows.length === 0 && dir !== "") {
1113
+ del.run(dir);
1114
+ continue;
1115
+ }
1116
+ const children = new Map();
1117
+ for (const r of rows) {
1118
+ const rest = r.path.slice(prefix.length);
1119
+ const slash = rest.indexOf("/");
1120
+ if (slash < 0) {
1121
+ children.set(rest, r.hash);
1122
+ }
1123
+ else {
1124
+ const name = rest.slice(0, slash);
1125
+ if (!children.has(name)) {
1126
+ const sub = dirRow.get(prefix + name);
1127
+ children.set(name, sub?.hash ?? HASH_EMPTY);
1128
+ }
1129
+ }
1130
+ }
1131
+ const hash = dirHash([...children].map(([name, h]) => ({ name, hash: h })));
1132
+ upsert.run(dir, hash, rows.length, now);
1133
+ }
1134
+ }
709
1135
  function countTotals(db) {
710
1136
  const count = (table) => Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
711
1137
  const nodes = Number(db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`).get().n);