@esneiderbravo/speclaw 2.0.8 → 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.
Files changed (41) hide show
  1. package/README.md +38 -9
  2. package/dist/cli/commands/index-build.js +2 -15
  3. package/dist/cli/commands/init.js +11 -3
  4. package/dist/cli/commands/lawbook.js +2 -0
  5. package/dist/cli/commands/laws.js +149 -19
  6. package/dist/cli/commands/query.js +12 -2
  7. package/dist/cli/commands/update.js +110 -24
  8. package/dist/cli/index.js +24 -66
  9. package/dist/cli/lib/help.js +489 -0
  10. package/dist/cli/lib/self-update.js +107 -0
  11. package/dist/cli/lib/update-check.js +17 -10
  12. package/dist/modules/compass/affected-config.js +5 -0
  13. package/dist/modules/compass/affected.js +344 -27
  14. package/dist/modules/compass/db.js +88 -16
  15. package/dist/modules/compass/diff-context.js +4 -2
  16. package/dist/modules/compass/explore-rich.js +14 -7
  17. package/dist/modules/compass/extract.js +163 -4
  18. package/dist/modules/compass/hotspots.js +11 -6
  19. package/dist/modules/compass/hybrid.js +9 -4
  20. package/dist/modules/compass/indexer.js +365 -95
  21. package/dist/modules/compass/map.js +2 -2
  22. package/dist/modules/compass/query.js +70 -24
  23. package/dist/modules/compass/register.js +1 -1
  24. package/dist/modules/compass/visualize.js +5 -3
  25. package/dist/modules/cortex/brief.js +3 -1
  26. package/dist/modules/cortex/harness.js +51 -5
  27. package/dist/modules/cortex/paths.js +39 -0
  28. package/dist/modules/cortex/status.js +4 -1
  29. package/dist/modules/foundation/assets/docs/compass.template.md +16 -0
  30. package/dist/modules/foundation/compile-laws.js +23 -6
  31. package/dist/modules/foundation/doctor.js +11 -1
  32. package/dist/modules/foundation/lock.js +224 -20
  33. package/dist/modules/foundation/scaffold.js +24 -8
  34. package/dist/modules/lawbook/assets/agents/archiver.md +5 -2
  35. package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +4 -1
  36. package/dist/modules/lawbook/drift.js +2 -2
  37. package/dist/modules/lawbook/engine.js +24 -2
  38. package/dist/modules/lawbook/investigate.js +2 -1
  39. package/dist/shared/agents.js +56 -7
  40. package/dist/shared/output-budget.js +2 -0
  41. package/package.json +1 -1
@@ -48,6 +48,13 @@ CREATE TABLE IF NOT EXISTS node_metrics (
48
48
  branches INTEGER NOT NULL
49
49
  );
50
50
  -- edges: a reference from one node to a named target, resolved lazily.
51
+ -- src_node_id is never NULL for an edge indexed under schema 11: a reference
52
+ -- outside any definition is owned by its file's file-owner node (kind 'file').
53
+ -- is_member = 1 marks a member call on a foreign receiver (never bound by name);
54
+ -- 2 a member call on an import binding, bound only once the import with the
55
+ -- same spec in the same file resolves to a project file (a package otherwise).
56
+ -- spec: JS/TS module specifier of an import edge, or of the import binding a
57
+ -- call's receiver/callee comes from.
51
58
  CREATE TABLE IF NOT EXISTS edges (
52
59
  id INTEGER PRIMARY KEY,
53
60
  src_node_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE,
@@ -55,9 +62,12 @@ CREATE TABLE IF NOT EXISTS edges (
55
62
  dst_name TEXT NOT NULL,
56
63
  dst_node_id INTEGER,
57
64
  kind TEXT NOT NULL,
58
- line INTEGER NOT NULL
65
+ line INTEGER NOT NULL,
66
+ is_member INTEGER NOT NULL DEFAULT 0,
67
+ spec TEXT
59
68
  );
60
69
  CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst_name);
70
+ CREATE INDEX IF NOT EXISTS idx_edges_srcfile ON edges(src_file_id, kind);
61
71
  CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src_node_id);
62
72
  CREATE INDEX IF NOT EXISTS idx_edges_dstid ON edges(dst_node_id);
63
73
  -- embedding_cache: vectors keyed by embedder-input content hash (survives reindex).
@@ -224,7 +234,13 @@ export function probeFts5Support() {
224
234
  }
225
235
  }
226
236
  /** Schema version stamped into the `meta` table on first creation. */
227
- export const SCHEMA_VERSION = "10";
237
+ export const SCHEMA_VERSION = "11";
238
+ /**
239
+ * `nodes.kind` of the synthetic per-file owner node. It owns the references that
240
+ * sit outside any definition (top-level imports, calls in callbacks) and is
241
+ * hidden from find, name lookup, PageRank, FTS, embeddings, and metrics.
242
+ */
243
+ export const FILE_NODE_KIND = "file";
228
244
  /** The stamped schema version, or null if the db predates versioning / has no meta table. */
229
245
  function readSchemaVersion(db) {
230
246
  try {
@@ -237,7 +253,7 @@ function readSchemaVersion(db) {
237
253
  }
238
254
  /**
239
255
  * Decide whether an existing database is from an incompatible schema and must be
240
- * rebuilt. Schema 8→9 and 9→10 are handled by migrators instead of a wipe.
256
+ * rebuilt. Schema 8→9, 9→10, and 10→11 are handled by migrators instead of a wipe.
241
257
  */
242
258
  function isStale(db) {
243
259
  const hasEdges = db
@@ -246,13 +262,19 @@ function isStale(db) {
246
262
  if (!hasEdges)
247
263
  return false;
248
264
  const ver = readSchemaVersion(db);
249
- if (ver === "8" || ver === "9")
265
+ if (ver === "8" || ver === "9" || ver === "10")
250
266
  return false; // migrate in openDb
267
+ if (ver === "11" && !hasEdgeSpec(db))
268
+ return false; // pre-release 11: migrate in openDb
251
269
  if (ver !== SCHEMA_VERSION)
252
270
  return true;
253
271
  const edgeCols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
254
- if (!edgeCols.includes("src_node_id") || !edgeCols.includes("dst_node_id"))
272
+ if (!edgeCols.includes("src_node_id") ||
273
+ !edgeCols.includes("dst_node_id") ||
274
+ !edgeCols.includes("is_member") ||
275
+ !edgeCols.includes("spec")) {
255
276
  return true;
277
+ }
256
278
  const fileCols = db.prepare("PRAGMA table_info(files)").all().map((c) => c.name);
257
279
  if (!fileCols.includes("is_test") || !fileCols.includes("module"))
258
280
  return true;
@@ -402,15 +424,62 @@ export function migrate9to10(db) {
402
424
  throw new Error(`schema 9→10 migration failed — delete .speclaw/index.db to rebuild: ${err.message}`, { cause: err });
403
425
  }
404
426
  }
427
+ /** Whether `edges` already has the schema-11 `spec` column (or does not exist yet). */
428
+ function hasEdgeSpec(db) {
429
+ const cols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
430
+ return cols.length === 0 || cols.includes("spec");
431
+ }
432
+ /** Reason recorded with the needs-reindex marker by the 10→11 migration. */
433
+ export const SCHEMA_11_REINDEX_REASON = "schema 11 adds file-owner nodes, member-call flags and full import text; reindex required";
434
+ /**
435
+ * Migrate schema 10 → 11: add `edges.is_member` and `edges.spec` (each only
436
+ * when missing, so a pre-release schema-11 index lacking `spec` takes the same
437
+ * path), set the needs-reindex marker, and stamp `"11"`, all in one
438
+ * `BEGIN IMMEDIATE` transaction. The next index
439
+ * run re-extracts every file (edge ownership and import text change), while
440
+ * `embedding_cache` is untouched so unchanged symbols reuse their vectors.
441
+ *
442
+ * @param db - Open connection already at schema 10.
443
+ * @throws If any step fails; the transaction is rolled back and the stamp stays `"10"`.
444
+ */
445
+ export function migrate10to11(db) {
446
+ // Covers: req~schema-edge-membership~1
447
+ db.exec("BEGIN IMMEDIATE");
448
+ try {
449
+ const cols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
450
+ // No edges table yet (a partial older index): the schema DDL creates it with the column.
451
+ if (cols.length > 0 && !cols.includes("is_member")) {
452
+ db.exec("ALTER TABLE edges ADD COLUMN is_member INTEGER NOT NULL DEFAULT 0");
453
+ }
454
+ if (cols.length > 0 && !cols.includes("spec")) {
455
+ db.exec("ALTER TABLE edges ADD COLUMN spec TEXT");
456
+ }
457
+ db.prepare("INSERT INTO meta(key, value) VALUES ('needs_reindex', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
458
+ db.prepare("INSERT INTO meta(key, value) VALUES ('reindex_reason', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(SCHEMA_11_REINDEX_REASON);
459
+ db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run("11");
460
+ db.exec("COMMIT");
461
+ }
462
+ catch (err) {
463
+ try {
464
+ db.exec("ROLLBACK");
465
+ }
466
+ catch {
467
+ /* already rolled back */
468
+ }
469
+ throw new Error(`schema 10→11 migration failed — delete .speclaw/index.db to rebuild: ${err.message}`, { cause: err });
470
+ }
471
+ }
405
472
  /**
406
473
  * Open (creating if needed) the index database at `<projectPath>/.speclaw/index.db`.
407
474
  *
408
475
  * Ensures the `.speclaw` directory exists, enables WAL journaling and foreign
409
- * keys, and applies the schema. Schema 8→9 and 9→10 migrate in place
410
- * (embeddings preserved). Other incompatible schemas are wiped and rebuilt.
476
+ * keys, and applies the schema. Schema 8→9, 9→10, and 10→11 migrate in place
477
+ * and chain forward (embeddings preserved). Other incompatible schemas are wiped
478
+ * and rebuilt.
411
479
  *
412
480
  * @param projectPath - Absolute path to the project root.
413
481
  * @returns An open connection to the index database.
482
+ * @throws If an in-place migration fails (the connection is closed first).
414
483
  */
415
484
  export function openDb(projectPath) {
416
485
  const dir = path.join(projectPath, ".speclaw");
@@ -425,15 +494,18 @@ export function openDb(projectPath) {
425
494
  return null;
426
495
  }
427
496
  })();
428
- if (ver === "8") {
429
- migrate8to9(db, projectPath);
430
- migrate9to10(db);
431
- db.exec(SCHEMA);
432
- ensureEmbeddingsView(db);
433
- ensureFts(db);
434
- }
435
- else if (ver === "9") {
436
- migrate9to10(db);
497
+ if (ver === "8" || ver === "9" || ver === "10" || (ver === "11" && !hasEdgeSpec(db))) {
498
+ try {
499
+ if (ver === "8")
500
+ migrate8to9(db, projectPath);
501
+ if (ver === "8" || ver === "9")
502
+ migrate9to10(db);
503
+ migrate10to11(db);
504
+ }
505
+ catch (err) {
506
+ db.close();
507
+ throw err;
508
+ }
437
509
  db.exec(SCHEMA);
438
510
  ensureEmbeddingsView(db);
439
511
  ensureFts(db);
@@ -1,5 +1,5 @@
1
1
  import { isGitRepo, changedFiles, worktreeChangedFiles } from "../../shared/git.js";
2
- import { openDb, indexExists } from "./db.js";
2
+ import { openDb, indexExists, FILE_NODE_KIND } from "./db.js";
3
3
  import { impact } from "./query.js";
4
4
  import { affectedTests } from "./affected.js";
5
5
  import { hotspots } from "./hotspots.js";
@@ -26,7 +26,7 @@ function symbolsForFiles(projectPath, files, overestimate) {
26
26
  const nodes = db
27
27
  .prepare(`SELECT n.name, n.kind, n.start_line AS line, f.path AS file
28
28
  FROM nodes n JOIN files f ON f.id = n.file_id
29
- WHERE f.path = ?`)
29
+ WHERE f.path = ? AND n.kind <> '${FILE_NODE_KIND}'`)
30
30
  .all(file);
31
31
  if (overestimate || nodes.length === 0) {
32
32
  for (const n of nodes)
@@ -100,6 +100,8 @@ export function diffContext(query) {
100
100
  result.affectedTests = {
101
101
  tests: at.tests,
102
102
  command: at.command,
103
+ commandReason: at.commandReason,
104
+ commands: at.commands,
103
105
  mode: at.mode,
104
106
  reason: at.reason,
105
107
  };
@@ -1,5 +1,6 @@
1
1
  import { explore, impact, trace } from "./query.js";
2
2
  import { affectedTests } from "./affected.js";
3
+ import { FILE_NODE_KIND } from "./db.js";
3
4
  import { hotspots } from "./hotspots.js";
4
5
  import { summarizeImpact } from "./impact-summary.js";
5
6
  import { budgetExploreShape, applyTextBudget, } from "../../shared/output-budget.js";
@@ -41,8 +42,10 @@ export async function exploreRich(query) {
41
42
  out.symbol = withoutSource(out.symbol);
42
43
  if (!includes.includes("callers"))
43
44
  out.callers = [];
44
- if (!includes.includes("callees"))
45
+ if (!includes.includes("callees")) {
45
46
  out.callees = [];
47
+ delete out.unresolvedCallees;
48
+ }
46
49
  budgetExploreShape(out, mode, truncated);
47
50
  return out;
48
51
  }
@@ -52,15 +55,17 @@ export async function exploreRich(query) {
52
55
  out.symbol = withoutSource(out.symbol);
53
56
  if (!includes.includes("callers"))
54
57
  out.callers = [];
55
- if (!includes.includes("callees"))
58
+ if (!includes.includes("callees")) {
56
59
  out.callees = [];
60
+ delete out.unresolvedCallees;
61
+ }
57
62
  if (base.found && base.symbol) {
58
63
  const sym = base.symbol.name;
59
64
  const file = base.symbol.file;
60
65
  if (includes.includes("blast_radius")) {
61
66
  try {
62
67
  const imp = impact(query.projectPath, {
63
- symbol: sym,
68
+ ...(base.symbol.kind === FILE_NODE_KIND ? { files: [file] } : { symbol: sym }),
64
69
  maxDepth: query.maxDepth ?? 4,
65
70
  format: "grouped",
66
71
  });
@@ -72,14 +77,16 @@ export async function exploreRich(query) {
72
77
  }
73
78
  if (includes.includes("tests")) {
74
79
  try {
75
- const at = affectedTests(query.projectPath, {
76
- symbols: [sym],
77
- maxDepth: query.maxDepth ?? 6,
78
- });
80
+ // A file-owner node is named by its path: select by file, not by name.
81
+ const at = affectedTests(query.projectPath, base.symbol.kind === FILE_NODE_KIND
82
+ ? { files: [file], maxDepth: query.maxDepth ?? 6 }
83
+ : { symbols: [sym], maxDepth: query.maxDepth ?? 6 });
79
84
  out.affectedTests = {
80
85
  count: at.tests.length,
81
86
  files: at.tests.map((t) => t.file),
82
87
  command: at.command,
88
+ commandReason: at.commandReason,
89
+ commands: at.commands,
83
90
  };
84
91
  }
85
92
  catch {
@@ -34,6 +34,115 @@ function calleeName(node, lang) {
34
34
  return fn.text;
35
35
  return fn.text.split(/[.\s(]/)[0] || null;
36
36
  }
37
+ /** Python receivers that mean "this class/instance". */
38
+ const PY_SELF = new Set(["self", "cls"]);
39
+ /**
40
+ * The receiver of a member call (`recv.f()`), or null for a plain call.
41
+ * `kind` is `self` for `this`/`super`/`self`/`cls`, `ident` for a bare
42
+ * identifier (classified later against the file's import bindings), and
43
+ * `other` for any chain, subscript, or call result.
44
+ */
45
+ function callReceiver(node, lang) {
46
+ const fn = node.childForFieldName(lang.callField);
47
+ if (!fn)
48
+ return null;
49
+ if (fn.type === "identifier" || fn.type === "super")
50
+ return null;
51
+ if (fn.type !== "member_expression" && fn.type !== "attribute") {
52
+ return { kind: "other", text: fn.text };
53
+ }
54
+ const obj = fn.childForFieldName("object");
55
+ if (!obj)
56
+ return { kind: "other", text: fn.text };
57
+ if (obj.type === "this" || obj.type === "super")
58
+ return { kind: "self", text: obj.text };
59
+ if (obj.type === "identifier") {
60
+ // Python's `self`/`cls` are conventions, not keywords; JS `self` is a global.
61
+ if (lang.id === "python" && PY_SELF.has(obj.text))
62
+ return { kind: "self", text: obj.text };
63
+ return { kind: "ident", text: obj.text };
64
+ }
65
+ return { kind: "other", text: obj.text };
66
+ }
67
+ /** Longest import text stored on an edge (`dst_name`). */
68
+ export const IMPORT_TEXT_CAP = 1024;
69
+ /**
70
+ * Module specifier of a raw import statement (`from "x"`, `import "x"`,
71
+ * `require("x")`, `import("x")`), or null when the text has none.
72
+ *
73
+ * @param text - Import statement text, whitespace collapsed.
74
+ */
75
+ export function importSpecifier(text) {
76
+ const m = text.match(/\bfrom\s+['"]([^'"]+)['"]/) ??
77
+ text.match(/^import\s+['"]([^'"]+)['"]/) ??
78
+ text.match(/\brequire\s*\(\s*['"]([^'"]+)['"]/) ??
79
+ text.match(/\bimport\s*\(\s*['"]([^'"]+)['"]/);
80
+ return m ? m[1] : null;
81
+ }
82
+ /**
83
+ * Import text as stored on its edge: unchanged up to {@link IMPORT_TEXT_CAP}
84
+ * characters, else the head of the statement, ` … `, and its trailing `from`
85
+ * clause, so the specifier at the end survives the cap. Bindings and the
86
+ * specifier are read from the full text before capping; this only bounds what
87
+ * is stored.
88
+ *
89
+ * @param text - Import statement text, whitespace collapsed.
90
+ */
91
+ export function capImportText(text) {
92
+ if (text.length <= IMPORT_TEXT_CAP)
93
+ return text;
94
+ const tail = text.match(/\s(from\s+['"][^'"]+['"].*)$/)?.[1] ??
95
+ text.match(/((?:require|import)\s*\(\s*['"][^'"]+['"].*)$/)?.[1];
96
+ if (!tail || tail.length > IMPORT_TEXT_CAP / 2)
97
+ return text.slice(0, IMPORT_TEXT_CAP);
98
+ return `${text.slice(0, IMPORT_TEXT_CAP - tail.length - 3)} … ${tail}`;
99
+ }
100
+ /**
101
+ * Local names an import statement binds: default, namespace (`* as ns`), and
102
+ * named (`{ a, b as c }` → `a`, `c`) for JS/TS; `import a.b` → `a`,
103
+ * `import x as y` → `y`, `from m import a, b as c` → `a`, `c` for Python.
104
+ *
105
+ * @param text - Import statement text, whitespace collapsed.
106
+ * @param langId - Language id of the file.
107
+ */
108
+ export function importBindings(text, langId) {
109
+ const out = [];
110
+ const ident = /^[A-Za-z_$][\w$]*$/;
111
+ const localOf = (part) => {
112
+ const bits = part.trim().split(/\s+as\s+/);
113
+ const name = (bits[1] ?? bits[0] ?? "").trim().replace(/^type\s+/, "");
114
+ return ident.test(name) ? name : null;
115
+ };
116
+ if (langId === "python") {
117
+ const from = text.match(/^from\s+\S+\s+import\s+(.+)$/);
118
+ const list = from ? from[1] : (text.match(/^import\s+(.+)$/)?.[1] ?? "");
119
+ for (const raw of list.replace(/[()]/g, "").split(",")) {
120
+ const bits = raw.trim().split(/\s+as\s+/);
121
+ const name = bits[1] ?? (from ? bits[0] : bits[0]?.split(".")[0]);
122
+ if (name && ident.test(name.trim()))
123
+ out.push(name.trim());
124
+ }
125
+ return out;
126
+ }
127
+ const clause = text.match(/^import\s+(?:type\s+)?(.+?)\s+from\s/)?.[1];
128
+ if (!clause)
129
+ return out;
130
+ const ns = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
131
+ if (ns)
132
+ out.push(ns[1]);
133
+ const brace = clause.match(/\{([^}]*)\}/);
134
+ if (brace) {
135
+ for (const part of brace[1].split(",")) {
136
+ const name = localOf(part);
137
+ if (name)
138
+ out.push(name);
139
+ }
140
+ }
141
+ const def = clause.match(/^([A-Za-z_$][\w$]*)\s*(?:,|$)/);
142
+ if (def)
143
+ out.push(def[1]);
144
+ return out;
145
+ }
37
146
  /** First line of the node's text, trimmed — a lightweight signature. */
38
147
  function signatureOf(node) {
39
148
  return node.text.split("\n")[0].trim().slice(0, 200);
@@ -223,6 +332,17 @@ export async function extract(source, lang) {
223
332
  const symbols = [];
224
333
  const refs = [];
225
334
  const rawCoverage = [];
335
+ // Identifier receivers and bare callees are classified after the walk, once
336
+ // every import binding of the file is known (imports may follow their first
337
+ // use). Whether a binding is a package is not decided here: that needs the
338
+ // project's alias/baseUrl config, so resolveEdges decides it from whether the
339
+ // binding's import (matched by `spec`) resolved to a project file.
340
+ const identReceivers = [];
341
+ const bareCalls = [];
342
+ const bindings = new Set();
343
+ // JS/TS import binding → its import's module specifier.
344
+ const bindingSpecs = new Map();
345
+ const reexports = [];
226
346
  const walk = (node, ownerIndex) => {
227
347
  let nextOwner = ownerIndex;
228
348
  if (kinds.has(node.type)) {
@@ -252,20 +372,52 @@ export async function extract(source, lang) {
252
372
  }
253
373
  else if (node.type === lang.callNode) {
254
374
  const name = calleeName(node, lang);
255
- if (name)
256
- refs.push({ name, kind: "call", line: node.startPosition.row + 1, ownerIndex });
375
+ if (name) {
376
+ const receiver = callReceiver(node, lang);
377
+ const ref = {
378
+ name,
379
+ kind: "call",
380
+ line: node.startPosition.row + 1,
381
+ ownerIndex,
382
+ member: receiver !== null && receiver.kind === "other" ? 1 : 0,
383
+ spec: null,
384
+ };
385
+ if (receiver?.kind === "ident")
386
+ identReceivers.push({ ref, receiver: receiver.text });
387
+ else if (receiver === null)
388
+ bareCalls.push(ref);
389
+ refs.push(ref);
390
+ }
257
391
  }
258
392
  else if (importSet.has(node.type)) {
393
+ // Covers: req~import-resolution~1
394
+ // The whole statement, so a specifier on a later line is still parseable.
395
+ // Bindings and the specifier come from the full text; only storage is capped.
396
+ const text = node.text.replace(/\s+/g, " ").trim();
397
+ const bound = importBindings(text, lang.id);
398
+ const spec = lang.id === "python" ? null : importSpecifier(text);
399
+ for (const b of bound) {
400
+ bindings.add(b);
401
+ if (spec !== null)
402
+ bindingSpecs.set(b, spec);
403
+ }
259
404
  refs.push({
260
- name: node.text.split("\n")[0].trim().slice(0, 200),
405
+ name: capImportText(text),
261
406
  kind: "import",
262
407
  line: node.startPosition.row + 1,
263
408
  ownerIndex,
409
+ member: 0,
410
+ spec,
264
411
  });
265
412
  }
266
413
  else if (COMMENT_TYPES.has(node.type)) {
267
414
  rawCoverage.push(...parseCoverageComment(node, ownerIndex));
268
415
  }
416
+ else if (node.type === "export_statement" && lang.id !== "python") {
417
+ const source = node.childForFieldName("source");
418
+ if (source && source.text.length >= 2)
419
+ reexports.push(source.text.slice(1, -1));
420
+ }
269
421
  for (let i = 0; i < node.childCount; i++) {
270
422
  const child = node.child(i);
271
423
  if (child)
@@ -274,5 +426,12 @@ export async function extract(source, lang) {
274
426
  };
275
427
  walk(tree.rootNode, null);
276
428
  tree.delete();
277
- return { symbols, refs, coverage: attachCoverage(rawCoverage, symbols) };
429
+ for (const { ref, receiver } of identReceivers) {
430
+ const spec = bindingSpecs.get(receiver) ?? null;
431
+ ref.member = !bindings.has(receiver) ? 1 : spec !== null ? 2 : 0;
432
+ ref.spec = spec;
433
+ }
434
+ for (const ref of bareCalls)
435
+ ref.spec = bindingSpecs.get(ref.name) ?? null;
436
+ return { symbols, refs, coverage: attachCoverage(rawCoverage, symbols), reexports };
278
437
  }
@@ -1,4 +1,5 @@
1
- import { openDb } from "./db.js";
1
+ import { openDb, FILE_NODE_KIND } from "./db.js";
2
+ import { BUILTIN_SQL_LIST } from "./indexer.js";
2
3
  import { cachedCoChanges, cachedFileActivity } from "./git-history-cache.js";
3
4
  import { jaccardStrength } from "../../shared/git-history.js";
4
5
  /** Default history window for hotspot / coupling ranking. */
@@ -31,7 +32,7 @@ function loadFileHealth(projectPath) {
31
32
  COALESCE(MAX(m.max_nesting), 0) AS worst_nesting,
32
33
  COALESCE(MAX(m.branches), 0) AS worst_branches
33
34
  FROM files f
34
- LEFT JOIN nodes n ON n.file_id = f.id
35
+ LEFT JOIN nodes n ON n.file_id = f.id AND n.kind <> '${FILE_NODE_KIND}'
35
36
  LEFT JOIN node_metrics m ON m.node_id = n.id
36
37
  GROUP BY f.id`)
37
38
  .all();
@@ -148,17 +149,21 @@ function pairInGraph(projectPath, a, b) {
148
149
  .get(a, b, b, a);
149
150
  if (row)
150
151
  return true;
151
- // Name-only imports: dst_node_id NULL — check import edge text contains other path basename loosely via file paths of same module is hard;
152
- // also match unresolved edges where dst resolves by file path of an indexed import target is out of scope.
153
- // Fallback: any edge from a whose dst_name matches a symbol defined in b (or reverse).
152
+ // Fallback: an unresolved edge from a whose dst_name names a symbol defined
153
+ // in b (or reverse). Same rule as the query-time by-name arms: member calls
154
+ // (is_member 1 = foreign receiver, 2 = package receiver) never match by
155
+ // name, and a builtin global name only matches within its own file, which
156
+ // a cross-file pair never is.
154
157
  const byName = db
155
158
  .prepare(`SELECT 1 AS ok
156
159
  FROM edges e
157
160
  JOIN files sf ON sf.id = e.src_file_id
158
- JOIN nodes dn ON dn.name = e.dst_name
161
+ JOIN nodes dn ON dn.name = e.dst_name AND dn.kind <> '${FILE_NODE_KIND}'
159
162
  JOIN files df ON df.id = dn.file_id
160
163
  WHERE e.dst_node_id IS NULL
161
164
  AND e.kind IN ('call', 'import')
165
+ AND e.is_member = 0
166
+ AND e.dst_name NOT IN (${BUILTIN_SQL_LIST})
162
167
  AND ((sf.path = ? AND df.path = ?) OR (sf.path = ? AND df.path = ?))
163
168
  LIMIT 1`)
164
169
  .get(a, b, b, a);
@@ -2,7 +2,7 @@
2
2
  * Hybrid retrieval pipeline: BM25 + KNN + name → RRF → ego expand →
3
3
  * personalized PageRank → structural rerank → token budget.
4
4
  */
5
- import { openDb, indexExists, ftsAvailable } from "./db.js";
5
+ import { openDb, indexExists, ftsAvailable, FILE_NODE_KIND } from "./db.js";
6
6
  import { getEmbedder, fromBlob, cosine } from "./embedder.js";
7
7
  import { isGitRepo, worktreeChangedFiles } from "../../shared/git.js";
8
8
  import { escapeFtsQuery, nameBoost, routeWeights, rrfFuse, structuralScore, MAX_DEGREE, isSymbolQuery, } from "./rank.js";
@@ -113,7 +113,7 @@ export async function hybridSearch(projectPath, query, opts = {}) {
113
113
  const likeRows = db
114
114
  .prepare(`SELECT n.id
115
115
  FROM nodes n
116
- WHERE n.name LIKE ?
116
+ WHERE n.name LIKE ? AND n.kind <> '${FILE_NODE_KIND}'
117
117
  ORDER BY (n.name = ?) DESC, length(n.name) ASC
118
118
  LIMIT 20`)
119
119
  .all(`%${q}%`, q);
@@ -125,8 +125,11 @@ export async function hybridSearch(projectPath, query, opts = {}) {
125
125
  const fused = rrfFuse({ bm25: bm25Ids, knn: knnIds, name: nameIds }, { bm25: weights.bm25, knn: weights.knn, name: weights.name });
126
126
  // Apply name boost using node names.
127
127
  const idMeta = new Map();
128
+ // File-owner nodes have no metadata here, so they never become a hit, even
129
+ // when the ego-graph expansion reaches one through a call edge.
128
130
  const loadMeta = db.prepare(`SELECT n.id, n.name, n.kind, f.path AS file, n.start_line AS line, n.signature
129
- FROM nodes n JOIN files f ON f.id = n.file_id WHERE n.id = ?`);
131
+ FROM nodes n JOIN files f ON f.id = n.file_id
132
+ WHERE n.id = ? AND n.kind <> '${FILE_NODE_KIND}'`);
130
133
  for (const id of fused.keys()) {
131
134
  const row = loadMeta.get(id);
132
135
  if (row) {
@@ -208,7 +211,9 @@ export async function hybridSearch(projectPath, query, opts = {}) {
208
211
  }
209
212
  const prNodeIds = [...subgraphNodes, ...[...fileIdsNeeded].map((fid) => fileNodeId(fid))];
210
213
  const defCount = new Map();
211
- const allNames = db.prepare("SELECT name FROM nodes").all();
214
+ const allNames = db
215
+ .prepare(`SELECT name FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`)
216
+ .all();
212
217
  for (const r of allNames)
213
218
  defCount.set(r.name, (defCount.get(r.name) ?? 0) + 1);
214
219
  const refCount = new Map();