@esneiderbravo/speclaw 2.0.9 → 2.0.10

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,9 +1,9 @@
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 } from "./db.js";
4
+ import { openDb, clearNeedsReindex, needsReindex, FILE_NODE_KIND } from "./db.js";
5
5
  import { langForPath } from "./languages.js";
6
- import { extract } from "./extract.js";
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
9
  import { buildDirHashMap } from "./merkle.js";
@@ -36,106 +36,368 @@ function hashOf(content) {
36
36
  const DEFAULT_MAX_CACHE_MB = 256;
37
37
  const DEFAULT_RETENTION_DAYS = 30;
38
38
  /**
39
- * Point import edges at a representative node in the imported file so reverse
40
- * reachability can walk file-level dependencies (not just calls).
39
+ * Call names that are ambient globals in common runtimes and test frameworks.
40
+ * A plain call to one of them binds only to a definition in the same file, so
41
+ * a project symbol that happens to share the name never collects every test
42
+ * file as a caller.
43
+ */
44
+ export const BUILTIN_GLOBALS = [
45
+ "describe",
46
+ "it",
47
+ "test",
48
+ "expect",
49
+ "beforeEach",
50
+ "afterEach",
51
+ "beforeAll",
52
+ "afterAll",
53
+ "setTimeout",
54
+ "setInterval",
55
+ "clearTimeout",
56
+ "clearInterval",
57
+ "queueMicrotask",
58
+ "parseInt",
59
+ "parseFloat",
60
+ "require",
61
+ "structuredClone",
62
+ "fetch",
63
+ "print",
64
+ "len",
65
+ "isinstance",
66
+ "super",
67
+ ];
68
+ /** {@link BUILTIN_GLOBALS} as a quoted SQL `IN (...)` list. */
69
+ export const BUILTIN_SQL_LIST = BUILTIN_GLOBALS.map((n) => `'${n}'`).join(", ");
70
+ /** Most bind parameters a scoped {@link resolveEdges} call uses before running unscoped. */
71
+ const MAX_SCOPE_PARAMS = 10_000;
72
+ /**
73
+ * Resolve call and import edges to node ids — the one place edge resolution
74
+ * lives, shared by the full index run and incremental re-indexing.
75
+ *
76
+ * - Edges whose `dst_node_id` points at a node that no longer exists (its file
77
+ * was re-extracted) are reset to unresolved first.
78
+ * - Imports are resolved first (below). A call's "import target" is the file
79
+ * the same-file import edge with the call's `spec` resolved to.
80
+ * - Calls with `is_member = 1` are never resolved by name. Calls with
81
+ * `is_member = 2` (a member call on an import binding) resolve only when their
82
+ * import target exists: an import that no relative/alias/baseUrl resolution
83
+ * maps to a project file is a package, so `path.parse()` stays foreign.
84
+ * - A call to a {@link BUILTIN_GLOBALS} name resolves only to a definition in
85
+ * the same file or in its import target (`import { fetch } from "./http"`).
86
+ * - Otherwise a call prefers its import target, then a definition under the
87
+ * import target's directory (a barrel's subtree), then a same-file
88
+ * definition, else the lowest-id node with that name. File-owner nodes are
89
+ * never call targets. An import of a symbol-less or re-exporting file (a
90
+ * barrel) resolves to that file's file-owner node, so member calls through
91
+ * it bind by name like this.
92
+ * - Imports resolve their specifier relative to the importing file, then
93
+ * through the nearest `tsconfig.json`/`jsconfig.json` `paths`/`baseUrl`, and
94
+ * point at the target file's file-owner node when it has one, otherwise at
95
+ * its first node by `start_line`.
96
+ *
97
+ * The project root is read from the database's own path
98
+ * (`<root>/.speclaw/index.db`), so callers pass only the connection.
99
+ *
100
+ * @param db - Open index database (the caller owns the transaction).
101
+ * @param fileIds - When set, only calls owned by these files or by files whose
102
+ * import this pass resolved, and unresolved calls whose name a node in these
103
+ * files defines; unresolved imports are always all processed (a new file may
104
+ * satisfy any of them). When omitted, every
105
+ * unresolved edge.
106
+ * @returns How many call and import edges this call resolved.
107
+ */
108
+ export function resolveEdges(db, fileIds) {
109
+ // Covers: req~impact-id-first~1
110
+ db.exec(`UPDATE edges SET dst_node_id = NULL
111
+ WHERE dst_node_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM nodes n WHERE n.id = edges.dst_node_id)`);
112
+ const scoped = fileIds !== undefined;
113
+ const ids = fileIds ?? [];
114
+ const idList = ids.map(() => "?").join(", ") || "NULL";
115
+ const resolvedCalls = () => Number(db
116
+ .prepare("SELECT COUNT(*) AS n FROM edges WHERE kind = 'call' AND dst_node_id IS NOT NULL")
117
+ .get().n);
118
+ // Imports first: whether a call on an import binding may bind depends on
119
+ // whether that binding's import resolved to a project file.
120
+ const { resolved: imports, srcFiles } = resolveImportEdges(db, projectRootOf(db));
121
+ const before = resolvedCalls();
122
+ // A file whose import just resolved may have pending is_member = 2 calls.
123
+ // Past a bound (SQLite caps bind parameters) the pass is simply unscoped.
124
+ const importScope = [...srcFiles];
125
+ const importList = importScope.map(() => "?").join(", ") || "NULL";
126
+ const narrow = scoped && 2 * ids.length + importScope.length <= MAX_SCOPE_PARAMS;
127
+ const callScope = narrow
128
+ ? `AND (src_file_id IN (${idList}) OR src_file_id IN (${importList})
129
+ OR dst_name IN (SELECT name FROM nodes WHERE file_id IN (${idList}) AND kind <> '${FILE_NODE_KIND}'))`
130
+ : "";
131
+ // File of the node the same-file import with this call's spec resolved to;
132
+ // NULL when the call has no spec (the subquery is skipped) or that import
133
+ // stayed unresolved (a package).
134
+ const importTarget = `(CASE WHEN edges.spec IS NULL THEN NULL ELSE (
135
+ SELECT t.file_id FROM edges i JOIN nodes t ON t.id = i.dst_node_id
136
+ WHERE i.src_file_id = edges.src_file_id AND i.kind = 'import' AND i.spec = edges.spec
137
+ ORDER BY i.id LIMIT 1) END)`;
138
+ // Candidate n lies under the import target's directory: a barrel
139
+ // (`libs/core/src/index.ts`) usually re-exports its own subtree, so this
140
+ // stands in for "reachable through the barrel's re-exports" without storing
141
+ // them. rtrim(p, <p without '/'>) is p's directory with its trailing slash.
142
+ const underTargetDir = `EXISTS (
143
+ SELECT 1 FROM files tf, files nf
144
+ WHERE tf.id = ${importTarget} AND nf.id = n.file_id
145
+ AND substr(nf.path, 1, length(rtrim(tf.path, replace(tf.path, '/', ''))))
146
+ = rtrim(tf.path, replace(tf.path, '/', '')))`;
147
+ db.prepare(`UPDATE edges SET dst_node_id = (
148
+ SELECT n.id FROM nodes n
149
+ WHERE n.name = edges.dst_name
150
+ AND n.kind <> '${FILE_NODE_KIND}'
151
+ AND (edges.dst_name NOT IN (${BUILTIN_SQL_LIST}) OR n.file_id = edges.src_file_id
152
+ OR n.file_id = ${importTarget})
153
+ ORDER BY CASE WHEN n.file_id = ${importTarget} THEN 0
154
+ WHEN edges.spec IS NOT NULL AND ${underTargetDir} THEN 1
155
+ WHEN n.file_id = edges.src_file_id THEN 2 ELSE 3 END, n.id
156
+ LIMIT 1
157
+ )
158
+ WHERE kind = 'call' AND dst_node_id IS NULL
159
+ AND (is_member = 0
160
+ OR (is_member = 2 AND edges.spec IS NOT NULL AND ${importTarget} IS NOT NULL))
161
+ ${callScope}`).run(...(narrow ? [...ids, ...importScope, ...ids] : []));
162
+ const calls = resolvedCalls() - before;
163
+ return { calls, imports };
164
+ }
165
+ /** Project root of an index database stored at `<root>/.speclaw/index.db`. */
166
+ function projectRootOf(db) {
167
+ const row = db.prepare("SELECT file FROM pragma_database_list WHERE name = 'main'").get();
168
+ return path.dirname(path.dirname(row?.file ?? ""));
169
+ }
170
+ /**
171
+ * Point every unresolved import edge at a representative node of the imported
172
+ * file (its file-owner node, else its first node) so reverse reachability walks
173
+ * file-level dependencies, not just calls. Not narrowed by file: a newly
174
+ * indexed file can satisfy an import anywhere.
175
+ *
176
+ * @returns How many import edges were resolved, and the importing files' ids.
41
177
  */
42
178
  function resolveImportEdges(db, projectPath) {
179
+ // Covers: req~import-resolution~1
43
180
  const files = db.prepare("SELECT id, path FROM files").all();
44
181
  const byNorm = new Map();
45
- for (const f of files) {
182
+ for (const f of files)
46
183
  byNorm.set(f.path.split("\\").join("/"), f.id);
47
- }
48
- const firstNode = db.prepare("SELECT id FROM nodes WHERE file_id = ? ORDER BY start_line ASC, id ASC LIMIT 1");
49
- const namedNode = db.prepare("SELECT id FROM nodes WHERE file_id = ? AND name = ? ORDER BY id ASC LIMIT 1");
184
+ const target = db.prepare(`SELECT id FROM nodes WHERE file_id = ?
185
+ ORDER BY kind = '${FILE_NODE_KIND}' DESC, start_line ASC, id ASC LIMIT 1`);
50
186
  const upd = db.prepare("UPDATE edges SET dst_node_id = ? WHERE id = ?");
51
187
  const imports = db
52
- .prepare(`SELECT e.id, e.dst_name, e.src_file_id, f.path AS src_path
188
+ .prepare(`SELECT e.id, e.dst_name, e.spec, e.src_file_id, f.path AS src_path
53
189
  FROM edges e JOIN files f ON f.id = e.src_file_id
54
190
  WHERE e.kind = 'import' AND e.dst_node_id IS NULL`)
55
191
  .all();
192
+ const aliases = new AliasResolver(projectPath);
193
+ let resolved = 0;
194
+ const srcFiles = new Set();
56
195
  for (const edge of imports) {
57
- const spec = parseImportSpecifier(edge.dst_name);
196
+ const spec = edge.spec ?? importSpecifier(edge.dst_name);
58
197
  if (!spec)
59
198
  continue;
60
- const targetRel = resolveImportPath(projectPath, edge.src_path, spec.from);
199
+ const targetRel = resolveImportPath(projectPath, edge.src_path, spec, byNorm, aliases);
61
200
  if (!targetRel)
62
201
  continue;
63
202
  const fileId = byNorm.get(targetRel);
64
203
  if (fileId === undefined)
65
204
  continue;
66
- let nodeId;
67
- for (const name of spec.names) {
68
- const row = namedNode.get(fileId, name);
69
- if (row) {
70
- nodeId = row.id;
71
- break;
205
+ const row = target.get(fileId);
206
+ if (!row)
207
+ continue;
208
+ upd.run(row.id, edge.id);
209
+ resolved++;
210
+ srcFiles.add(edge.src_file_id);
211
+ }
212
+ return { resolved, srcFiles };
213
+ }
214
+ /** Repo-relative candidate files for an import target (extension and index probing). */
215
+ function importCandidates(projectPath, absBase) {
216
+ const rel = path.relative(projectPath, absBase).split(path.sep).join("/");
217
+ if (rel.startsWith("..") || path.isAbsolute(rel))
218
+ return [];
219
+ return [
220
+ rel,
221
+ rel.replace(/\.js$/, ".ts"),
222
+ rel.replace(/\.js$/, ".tsx"),
223
+ rel.replace(/\.jsx$/, ".tsx"),
224
+ rel.replace(/\.mjs$/, ".mts"),
225
+ rel.replace(/\.cjs$/, ".cts"),
226
+ `${rel}.ts`,
227
+ `${rel}.tsx`,
228
+ `${rel}.js`,
229
+ `${rel}.jsx`,
230
+ `${rel}.mjs`,
231
+ `${rel}.cjs`,
232
+ `${rel}/index.ts`,
233
+ `${rel}/index.tsx`,
234
+ `${rel}/index.js`,
235
+ ];
236
+ }
237
+ /**
238
+ * Resolve an import specifier to an indexed repo-relative path: relative
239
+ * specifiers against the importing file (including `.js` → `.ts`), then
240
+ * tsconfig/jsconfig `paths` aliases and bare `baseUrl` lookups. Bare package
241
+ * names that match no project file stay unresolved (null).
242
+ */
243
+ function resolveImportPath(projectPath, srcRel, spec, indexed, aliases) {
244
+ const firstIndexed = (absBase) => importCandidates(projectPath, absBase).find((c) => indexed.has(c)) ?? null;
245
+ const srcDir = path.dirname(path.join(projectPath, srcRel));
246
+ if (spec.startsWith(".") || spec.startsWith("/")) {
247
+ return firstIndexed(path.resolve(srcDir, spec));
248
+ }
249
+ for (const absBase of aliases.candidates(srcDir, spec)) {
250
+ const hit = firstIndexed(absBase);
251
+ if (hit)
252
+ return hit;
253
+ }
254
+ return null;
255
+ }
256
+ /**
257
+ * Best-effort tsconfig/jsconfig alias lookup, cached per directory for one
258
+ * resolution pass. Never throws: an unreadable or malformed config disables
259
+ * alias resolution for the files under it, and relative imports still resolve.
260
+ */
261
+ class AliasResolver {
262
+ root;
263
+ byDir = new Map();
264
+ constructor(root) {
265
+ this.root = root;
266
+ }
267
+ /** Absolute import bases to probe for a non-relative specifier, in order. */
268
+ candidates(fromDir, spec) {
269
+ const cfg = this.configFor(fromDir);
270
+ if (!cfg)
271
+ return [];
272
+ const out = [];
273
+ for (const { pattern, targets } of cfg.paths) {
274
+ const star = pattern.indexOf("*");
275
+ let captured = null;
276
+ if (star < 0) {
277
+ if (spec === pattern)
278
+ captured = "";
279
+ }
280
+ else {
281
+ const pre = pattern.slice(0, star);
282
+ const post = pattern.slice(star + 1);
283
+ if (spec.startsWith(pre) &&
284
+ spec.endsWith(post) &&
285
+ spec.length >= pre.length + post.length) {
286
+ captured = spec.slice(pre.length, spec.length - post.length);
287
+ }
288
+ }
289
+ if (captured === null)
290
+ continue;
291
+ for (const t of targets) {
292
+ out.push(path.resolve(cfg.baseUrl ?? cfg.pathsBase, t.replace("*", captured)));
72
293
  }
73
294
  }
74
- if (nodeId === undefined) {
75
- const row = firstNode.get(fileId);
76
- nodeId = row?.id;
295
+ if (cfg.baseUrl)
296
+ out.push(path.resolve(cfg.baseUrl, spec));
297
+ return out;
298
+ }
299
+ /** Nearest tsconfig/jsconfig at or above `dir`, bounded by the project root. */
300
+ configFor(dir) {
301
+ const cached = this.byDir.get(dir);
302
+ if (cached !== undefined)
303
+ return cached;
304
+ let result = null;
305
+ const rel = path.relative(this.root, dir);
306
+ if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
307
+ const own = ["tsconfig.json", "jsconfig.json"]
308
+ .map((n) => path.join(dir, n))
309
+ .find((f) => fs.existsSync(f));
310
+ if (own)
311
+ result = loadAliasConfig(own, this.root);
312
+ else if (dir !== this.root)
313
+ result = this.configFor(path.dirname(dir));
77
314
  }
78
- if (nodeId !== undefined)
79
- upd.run(nodeId, edge.id);
315
+ this.byDir.set(dir, result);
316
+ return result;
80
317
  }
81
318
  }
82
- /** Pull `from` path and optional named imports out of a raw import statement text. */
83
- function parseImportSpecifier(text) {
84
- const fromMatch = text.match(/\bfrom\s+['"]([^'"]+)['"]/) ?? text.match(/require\s*\(\s*['"]([^'"]+)['"]/);
85
- if (!fromMatch)
86
- return null;
87
- const from = fromMatch[1];
88
- const names = [];
89
- const brace = text.match(/\{([^}]+)\}/);
90
- if (brace) {
91
- for (const part of brace[1].split(",")) {
92
- const id = part
93
- .trim()
94
- .split(/\s+as\s+/i)[0]
95
- .trim();
96
- if (id && /^[A-Za-z_$][\w$]*$/.test(id))
97
- names.push(id);
319
+ /**
320
+ * Strip `//` and block comments and trailing commas from JSONC text, leaving
321
+ * string literals untouched.
322
+ */
323
+ export function stripJsonComments(text) {
324
+ let out = "";
325
+ let i = 0;
326
+ while (i < text.length) {
327
+ const ch = text[i];
328
+ if (ch === '"') {
329
+ let j = i + 1;
330
+ while (j < text.length && text[j] !== '"')
331
+ j += text[j] === "\\" ? 2 : 1;
332
+ out += text.slice(i, j + 1);
333
+ i = j + 1;
334
+ }
335
+ else if (ch === "/" && text[i + 1] === "/") {
336
+ while (i < text.length && text[i] !== "\n")
337
+ i++;
338
+ }
339
+ else if (ch === "/" && text[i + 1] === "*") {
340
+ const close = text.indexOf("*/", i + 2);
341
+ i = close < 0 ? text.length : close + 2;
342
+ }
343
+ else {
344
+ out += ch;
345
+ i++;
98
346
  }
99
347
  }
100
- const def = text.match(/\bimport\s+([A-Za-z_$][\w$]*)\s+/);
101
- if (def && !text.includes("{"))
102
- names.push(def[1]);
103
- return { from, names };
348
+ return out.replace(/,(\s*[}\]])/g, "$1");
104
349
  }
105
350
  /**
106
- * Resolve a relative/absolute-ish import specifier to a project-relative indexed path.
351
+ * Read a tsconfig and its relative `extends` chain (max depth 5, cycle-safe,
352
+ * inside the project root). The nearest definition of `baseUrl` and of `paths`
353
+ * wins, each resolved against the config file that declares it.
354
+ *
355
+ * @returns The effective alias config, or null when the file cannot be parsed.
107
356
  */
108
- function resolveImportPath(projectPath, srcRel, spec) {
109
- if (!spec.startsWith(".") && !spec.startsWith("/"))
110
- return null; // bare package — skip
111
- const srcDir = path.dirname(path.join(projectPath, srcRel));
112
- const absBase = path.resolve(srcDir, spec);
113
- const candidates = [
114
- absBase,
115
- absBase.replace(/\.js$/, ".ts"),
116
- absBase.replace(/\.js$/, ".tsx"),
117
- absBase.replace(/\.jsx$/, ".tsx"),
118
- `${absBase}.ts`,
119
- `${absBase}.tsx`,
120
- `${absBase}.js`,
121
- `${absBase}.jsx`,
122
- `${absBase}.mjs`,
123
- `${absBase}.cjs`,
124
- path.join(absBase, "index.ts"),
125
- path.join(absBase, "index.js"),
126
- ];
127
- for (const abs of candidates) {
128
- if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
129
- continue;
130
- return path.relative(projectPath, abs).split("\\").join("/");
357
+ function loadAliasConfig(file, root) {
358
+ let baseUrl;
359
+ let paths;
360
+ let pathsBase = path.dirname(file);
361
+ const seen = new Set();
362
+ let current = file;
363
+ for (let depth = 0; current && depth < 6 && !seen.has(current); depth++) {
364
+ seen.add(current);
365
+ let parsed;
366
+ try {
367
+ parsed = JSON.parse(stripJsonComments(fs.readFileSync(current, "utf8")));
368
+ }
369
+ catch {
370
+ if (depth === 0)
371
+ return null;
372
+ break;
373
+ }
374
+ const dir = path.dirname(current);
375
+ const co = parsed.compilerOptions ?? {};
376
+ if (baseUrl === undefined && typeof co.baseUrl === "string") {
377
+ baseUrl = path.resolve(dir, co.baseUrl);
378
+ }
379
+ if (paths === undefined && co.paths && typeof co.paths === "object") {
380
+ paths = co.paths;
381
+ pathsBase = dir;
382
+ }
383
+ current = null;
384
+ if (typeof parsed.extends === "string" && parsed.extends.startsWith(".")) {
385
+ let next = path.resolve(dir, parsed.extends);
386
+ if (!next.endsWith(".json"))
387
+ next += ".json";
388
+ const rel = path.relative(root, next);
389
+ if (!rel.startsWith("..") && !path.isAbsolute(rel) && fs.existsSync(next))
390
+ current = next;
391
+ }
131
392
  }
132
- // Fall back without existence check — strip a trailing .js for TS sources.
133
- let rel = path.relative(projectPath, absBase).split("\\").join("/");
134
- if (rel.endsWith(".js"))
135
- rel = rel.slice(0, -3) + ".ts";
136
- else if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(rel))
137
- rel = `${rel}.ts`;
138
- return rel.replace(/^\.\//, "");
393
+ const entries = Object.entries(paths ?? {})
394
+ .filter(([, t]) => Array.isArray(t))
395
+ .map(([pattern, targets]) => ({
396
+ pattern,
397
+ targets: targets.filter((t) => typeof t === "string"),
398
+ }))
399
+ .sort((a, b) => b.pattern.split("*")[0].length - a.pattern.split("*")[0].length);
400
+ return { baseUrl, paths: entries, pathsBase };
139
401
  }
140
402
  /**
141
403
  * Infer a covering artifact's type from its project-relative path.
@@ -243,7 +505,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
243
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)
244
506
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
245
507
  const insMetrics = db.prepare(`INSERT INTO node_metrics(node_id, loc, max_nesting, branches) VALUES (?, ?, ?, ?)`);
246
- const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line) VALUES (?, ?, ?, ?, ?)`);
508
+ const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line, is_member, spec) VALUES (?, ?, ?, ?, ?, ?, ?)`);
247
509
  const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
248
510
  artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
249
511
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
@@ -320,7 +582,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
320
582
  else {
321
583
  fileId = Number(insFile.run(rel, hash, lang.id, isTest, mod, mtimeMs, size).lastInsertRowid);
322
584
  }
323
- const { symbols, refs, coverage } = await extract(content, lang);
585
+ const { symbols, refs, coverage, reexports } = await extract(content, lang);
324
586
  const nodeIds = [];
325
587
  const now = Date.now();
326
588
  const touchCache = db.prepare(`UPDATE embedding_cache SET last_seen_at = ? WHERE content_hash = ? AND model = ?`);
@@ -350,12 +612,21 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
350
612
  }
351
613
  stats.embeddings++;
352
614
  }
353
- const fileOwner = nodeIds[0] ?? null;
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
+ }
354
627
  for (const r of refs) {
355
- let srcId = r.ownerIndex !== null ? nodeIds[r.ownerIndex] : null;
356
- if (srcId === null && r.kind === "import")
357
- srcId = fileOwner;
358
- insEdge.run(srcId, fileId, r.name, r.kind, r.line);
628
+ const srcId = isOrphan(r) ? fileOwner : nodeIds[r.ownerIndex];
629
+ insEdge.run(srcId, fileId, r.name, r.kind, r.line, r.member, r.spec);
359
630
  stats.edges++;
360
631
  }
361
632
  const sourceType = inferSourceType(rel);
@@ -393,16 +664,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
393
664
  const nFiles = [...fileHashes.keys()].filter((f) => dir === "" ? true : f === dir || f.startsWith(dir + "/")).length;
394
665
  insDir.run(dir, hash, nFiles, now);
395
666
  }
396
- db.exec(`
397
- UPDATE edges SET dst_node_id = (
398
- SELECT n.id FROM nodes n
399
- WHERE n.name = edges.dst_name
400
- ORDER BY CASE WHEN n.file_id = edges.src_file_id THEN 0 ELSE 1 END, n.id
401
- LIMIT 1
402
- )
403
- WHERE kind = 'call' AND dst_node_id IS NULL
404
- `);
405
- resolveImportEdges(db, projectPath);
667
+ resolveEdges(db);
406
668
  recomputeGlobalPagerank(db);
407
669
  // Touch last_seen for all live content hashes under active model
408
670
  db.prepare(`UPDATE embedding_cache SET last_seen_at = ?
@@ -446,7 +708,8 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
446
708
  }
447
709
  function countTotals(db) {
448
710
  const count = (table) => Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
449
- return { files: count("files"), nodes: count("nodes"), edges: count("edges") };
711
+ const nodes = Number(db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`).get().n);
712
+ return { files: count("files"), nodes, edges: count("edges") };
450
713
  }
451
714
  /** Next-step hint printed after an index run; names the query tools, not grep. */
452
715
  export function indexNextStep(totals) {
@@ -482,7 +745,11 @@ function evictCacheBySize(db, maxCacheMB) {
482
745
  */
483
746
  export function recomputeGlobalPagerank(db) {
484
747
  const files = db.prepare("SELECT id, path FROM files").all();
485
- const nodes = db.prepare("SELECT id, name, file_id FROM nodes").all();
748
+ // File-owner nodes are not ranked symbols; their outgoing calls count as
749
+ // references from their file in the file–symbol graph.
750
+ const nodes = db
751
+ .prepare(`SELECT id, name, file_id FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`)
752
+ .all();
486
753
  if (nodes.length === 0) {
487
754
  db.exec("DELETE FROM pagerank");
488
755
  return;
@@ -499,10 +766,12 @@ export function recomputeGlobalPagerank(db) {
499
766
  defCount.set(n.name, (defCount.get(n.name) ?? 0) + 1);
500
767
  const refCount = new Map();
501
768
  const callEdges = db
502
- .prepare(`SELECT e.src_node_id, e.dst_node_id, e.dst_name, f.path AS src_path
769
+ .prepare(`SELECT e.src_node_id, e.src_file_id, src.kind AS src_kind, e.dst_node_id, e.dst_name,
770
+ f.path AS src_path
503
771
  FROM edges e
504
772
  JOIN files f ON f.id = e.src_file_id
505
- WHERE e.kind = 'call' AND e.src_node_id IS NOT NULL`)
773
+ JOIN nodes src ON src.id = e.src_node_id
774
+ WHERE e.kind = 'call'`)
506
775
  .all();
507
776
  for (const e of callEdges) {
508
777
  refCount.set(e.dst_name, (refCount.get(e.dst_name) ?? 0) + 1);
@@ -521,7 +790,8 @@ export function recomputeGlobalPagerank(db) {
521
790
  if (e.dst_node_id == null)
522
791
  continue;
523
792
  const w = edgeWeightMul(e.dst_name, e.src_path, ctx);
524
- edges.push({ from: e.src_node_id, to: e.dst_node_id, weight: w });
793
+ const from = e.src_kind === FILE_NODE_KIND ? fileNodeId(e.src_file_id) : e.src_node_id;
794
+ edges.push({ from, to: e.dst_node_id, weight: w });
525
795
  }
526
796
  const scores = personalizedPageRank(nodeIds, edges, []);
527
797
  db.exec("DELETE FROM pagerank");
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { openDb } from "./db.js";
3
+ import { openDb, FILE_NODE_KIND } from "./db.js";
4
4
  import { estimateTokens } from "../../shared/tokens.js";
5
5
  import { loadDeclaredBudget } from "../../shared/exposure.js";
6
6
  export const MAP_START = "<!-- speclaw:map:start -->";
@@ -19,7 +19,7 @@ export function generateCompactMap(projectPath) {
19
19
  const db = openDb(projectPath);
20
20
  try {
21
21
  const fileCount = db.prepare("SELECT COUNT(*) AS c FROM files").get().c;
22
- const nodeCount = db.prepare("SELECT COUNT(*) AS c FROM nodes").get().c;
22
+ const nodeCount = db.prepare(`SELECT COUNT(*) AS c FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`).get().c;
23
23
  if (fileCount === 0)
24
24
  return null;
25
25
  const hubs = db