@esneiderbravo/speclaw 2.0.9 → 2.0.11

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,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();