@esneiderbravo/speclaw 0.3.9 → 0.3.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.
@@ -5,6 +5,7 @@ import { openDb, clearNeedsReindex } from "./db.js";
5
5
  import { langForPath } from "./languages.js";
6
6
  import { extract } from "./extract.js";
7
7
  import { getEmbedder, toBlob } from "./embedder.js";
8
+ import { loadAffectedConfig, isTestPath, inferModule } from "./affected-config.js";
8
9
  const SKIP_DIRS = new Set([
9
10
  ".git",
10
11
  "node_modules",
@@ -29,6 +30,108 @@ const MAX_FILE_BYTES = 1_500_000;
29
30
  function hashOf(content) {
30
31
  return createHash("sha256").update(content).digest("hex");
31
32
  }
33
+ /**
34
+ * Point import edges at a representative node in the imported file so reverse
35
+ * reachability can walk file-level dependencies (not just calls).
36
+ */
37
+ function resolveImportEdges(db, projectPath) {
38
+ const files = db.prepare("SELECT id, path FROM files").all();
39
+ const byNorm = new Map();
40
+ for (const f of files) {
41
+ byNorm.set(f.path.split("\\").join("/"), f.id);
42
+ }
43
+ const firstNode = db.prepare("SELECT id FROM nodes WHERE file_id = ? ORDER BY start_line ASC, id ASC LIMIT 1");
44
+ const namedNode = db.prepare("SELECT id FROM nodes WHERE file_id = ? AND name = ? ORDER BY id ASC LIMIT 1");
45
+ const upd = db.prepare("UPDATE edges SET dst_node_id = ? WHERE id = ?");
46
+ const imports = db
47
+ .prepare(`SELECT e.id, e.dst_name, e.src_file_id, f.path AS src_path
48
+ FROM edges e JOIN files f ON f.id = e.src_file_id
49
+ WHERE e.kind = 'import' AND e.dst_node_id IS NULL`)
50
+ .all();
51
+ for (const edge of imports) {
52
+ const spec = parseImportSpecifier(edge.dst_name);
53
+ if (!spec)
54
+ continue;
55
+ const targetRel = resolveImportPath(projectPath, edge.src_path, spec.from);
56
+ if (!targetRel)
57
+ continue;
58
+ const fileId = byNorm.get(targetRel);
59
+ if (fileId === undefined)
60
+ continue;
61
+ let nodeId;
62
+ for (const name of spec.names) {
63
+ const row = namedNode.get(fileId, name);
64
+ if (row) {
65
+ nodeId = row.id;
66
+ break;
67
+ }
68
+ }
69
+ if (nodeId === undefined) {
70
+ const row = firstNode.get(fileId);
71
+ nodeId = row?.id;
72
+ }
73
+ if (nodeId !== undefined)
74
+ upd.run(nodeId, edge.id);
75
+ }
76
+ }
77
+ /** Pull `from` path and optional named imports out of a raw import statement text. */
78
+ function parseImportSpecifier(text) {
79
+ const fromMatch = text.match(/\bfrom\s+['"]([^'"]+)['"]/) ?? text.match(/require\s*\(\s*['"]([^'"]+)['"]/);
80
+ if (!fromMatch)
81
+ return null;
82
+ const from = fromMatch[1];
83
+ const names = [];
84
+ const brace = text.match(/\{([^}]+)\}/);
85
+ if (brace) {
86
+ for (const part of brace[1].split(",")) {
87
+ const id = part
88
+ .trim()
89
+ .split(/\s+as\s+/i)[0]
90
+ .trim();
91
+ if (id && /^[A-Za-z_$][\w$]*$/.test(id))
92
+ names.push(id);
93
+ }
94
+ }
95
+ const def = text.match(/\bimport\s+([A-Za-z_$][\w$]*)\s+/);
96
+ if (def && !text.includes("{"))
97
+ names.push(def[1]);
98
+ return { from, names };
99
+ }
100
+ /**
101
+ * Resolve a relative/absolute-ish import specifier to a project-relative indexed path.
102
+ */
103
+ function resolveImportPath(projectPath, srcRel, spec) {
104
+ if (!spec.startsWith(".") && !spec.startsWith("/"))
105
+ return null; // bare package — skip
106
+ const srcDir = path.dirname(path.join(projectPath, srcRel));
107
+ const absBase = path.resolve(srcDir, spec);
108
+ const candidates = [
109
+ absBase,
110
+ absBase.replace(/\.js$/, ".ts"),
111
+ absBase.replace(/\.js$/, ".tsx"),
112
+ absBase.replace(/\.jsx$/, ".tsx"),
113
+ `${absBase}.ts`,
114
+ `${absBase}.tsx`,
115
+ `${absBase}.js`,
116
+ `${absBase}.jsx`,
117
+ `${absBase}.mjs`,
118
+ `${absBase}.cjs`,
119
+ path.join(absBase, "index.ts"),
120
+ path.join(absBase, "index.js"),
121
+ ];
122
+ for (const abs of candidates) {
123
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
124
+ continue;
125
+ return path.relative(projectPath, abs).split("\\").join("/");
126
+ }
127
+ // Fall back without existence check — strip a trailing .js for TS sources.
128
+ let rel = path.relative(projectPath, absBase).split("\\").join("/");
129
+ if (rel.endsWith(".js"))
130
+ rel = rel.slice(0, -3) + ".ts";
131
+ else if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(rel))
132
+ rel = `${rel}.ts`;
133
+ return rel.replace(/^\.\//, "");
134
+ }
32
135
  /**
33
136
  * Infer a covering artifact's type from its project-relative path.
34
137
  * Full glob config lives in lawbook; this is the indexer default so links are
@@ -98,18 +201,20 @@ export async function buildIndex(projectPath, onProgress) {
98
201
  removed: 0,
99
202
  embedder: embedder.id,
100
203
  };
204
+ const cfg = loadAffectedConfig(projectPath);
101
205
  const existing = new Map();
102
206
  for (const row of db.prepare("SELECT id, path, hash FROM files").all()) {
103
207
  existing.set(row.path, { id: row.id, hash: row.hash });
104
208
  }
105
209
  const seen = new Set();
106
- const insFile = db.prepare("INSERT INTO files(path, hash, lang) VALUES (?, ?, ?)");
107
- const updFile = db.prepare("UPDATE files SET hash = ?, lang = ? WHERE id = ?");
210
+ const insFile = db.prepare("INSERT INTO files(path, hash, lang, is_test, module) VALUES (?, ?, ?, ?, ?)");
211
+ const updFile = db.prepare("UPDATE files SET hash = ?, lang = ?, is_test = ?, module = ? WHERE id = ?");
108
212
  const delNodes = db.prepare("DELETE FROM nodes WHERE file_id = ?");
109
213
  const delEdges = db.prepare("DELETE FROM edges WHERE src_file_id = ?");
110
214
  const delCoverage = db.prepare("DELETE FROM coverage_links WHERE file_path = ?");
111
215
  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)
112
216
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
217
+ const insMetrics = db.prepare(`INSERT INTO node_metrics(node_id, loc, max_nesting, branches) VALUES (?, ?, ?, ?)`);
113
218
  const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line) VALUES (?, ?, ?, ?, ?)`);
114
219
  const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
115
220
  artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
@@ -143,15 +248,17 @@ export async function buildIndex(projectPath, onProgress) {
143
248
  continue;
144
249
  }
145
250
  let fileId;
251
+ const isTest = isTestPath(rel, cfg.testGlobs) ? 1 : 0;
252
+ const mod = inferModule(rel);
146
253
  if (prior) {
147
- updFile.run(hash, lang.id, prior.id);
254
+ updFile.run(hash, lang.id, isTest, mod, prior.id);
148
255
  delNodes.run(prior.id);
149
256
  delEdges.run(prior.id);
150
257
  delCoverage.run(rel);
151
258
  fileId = prior.id;
152
259
  }
153
260
  else {
154
- fileId = Number(insFile.run(rel, hash, lang.id).lastInsertRowid);
261
+ fileId = Number(insFile.run(rel, hash, lang.id, isTest, mod).lastInsertRowid);
155
262
  }
156
263
  const { symbols, refs, coverage } = await extract(content, lang);
157
264
  const nodeIds = [];
@@ -159,13 +266,18 @@ export async function buildIndex(projectPath, onProgress) {
159
266
  const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
160
267
  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).lastInsertRowid);
161
268
  nodeIds.push(id);
269
+ insMetrics.run(id, s.loc, s.maxNesting, s.branches);
162
270
  // embed the node from its name + signature (cheap, meaningful text)
163
271
  const vec = await embedder.embed(`${s.kind} ${s.name} ${s.signature ?? ""}`);
164
272
  insEmbed.run(id, embedder.dim, embedder.id, toBlob(vec));
165
273
  stats.embeddings++;
166
274
  }
275
+ // Prefer a real symbol as import owner when the AST leaves imports file-scoped.
276
+ const fileOwner = nodeIds[0] ?? null;
167
277
  for (const r of refs) {
168
- const srcId = r.ownerIndex !== null ? nodeIds[r.ownerIndex] : null;
278
+ let srcId = r.ownerIndex !== null ? nodeIds[r.ownerIndex] : null;
279
+ if (srcId === null && r.kind === "import")
280
+ srcId = fileOwner;
169
281
  insEdge.run(srcId, fileId, r.name, r.kind, r.line);
170
282
  stats.edges++;
171
283
  }
@@ -184,15 +296,17 @@ export async function buildIndex(projectPath, onProgress) {
184
296
  stats.removed++;
185
297
  }
186
298
  }
187
- // resolve call edges to node definitions by name match
299
+ // Prefer same-file callees so colliding names across files do not share one id.
188
300
  db.exec(`
189
301
  UPDATE edges SET dst_node_id = (
190
302
  SELECT n.id FROM nodes n
191
303
  WHERE n.name = edges.dst_name
304
+ ORDER BY CASE WHEN n.file_id = edges.src_file_id THEN 0 ELSE 1 END, n.id
192
305
  LIMIT 1
193
306
  )
194
307
  WHERE kind = 'call' AND dst_node_id IS NULL
195
308
  `);
309
+ resolveImportEdges(db, projectPath);
196
310
  db.prepare("INSERT INTO meta(key, value) VALUES ('indexed_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(new Date().toISOString());
197
311
  clearNeedsReindex(db);
198
312
  db.exec("COMMIT");
@@ -17,6 +17,19 @@ export const LANGUAGES = [
17
17
  callNode: "call",
18
18
  callField: "function",
19
19
  importNodes: ["import_statement", "import_from_statement"],
20
+ nestingNodes: ["block", "suite"],
21
+ branchNodes: [
22
+ "if_statement",
23
+ "elif_clause",
24
+ "for_statement",
25
+ "while_statement",
26
+ "match_statement",
27
+ "case_clause",
28
+ "conditional_expression",
29
+ "except_clause",
30
+ "with_statement",
31
+ "boolean_operator",
32
+ ],
20
33
  },
21
34
  {
22
35
  id: "javascript",
@@ -30,6 +43,19 @@ export const LANGUAGES = [
30
43
  callNode: "call_expression",
31
44
  callField: "function",
32
45
  importNodes: ["import_statement"],
46
+ nestingNodes: ["statement_block", "class_body"],
47
+ branchNodes: [
48
+ "if_statement",
49
+ "else_clause",
50
+ "for_statement",
51
+ "for_in_statement",
52
+ "while_statement",
53
+ "do_statement",
54
+ "switch_case",
55
+ "switch_default",
56
+ "catch_clause",
57
+ "ternary_expression",
58
+ ],
33
59
  },
34
60
  {
35
61
  id: "typescript",
@@ -46,6 +72,19 @@ export const LANGUAGES = [
46
72
  callNode: "call_expression",
47
73
  callField: "function",
48
74
  importNodes: ["import_statement"],
75
+ nestingNodes: ["statement_block", "class_body"],
76
+ branchNodes: [
77
+ "if_statement",
78
+ "else_clause",
79
+ "for_statement",
80
+ "for_in_statement",
81
+ "while_statement",
82
+ "do_statement",
83
+ "switch_case",
84
+ "switch_default",
85
+ "catch_clause",
86
+ "ternary_expression",
87
+ ],
49
88
  },
50
89
  ];
51
90
  const BY_EXT = new Map();
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { openDb, indexExists } from "./db.js";
4
4
  import { getEmbedder, fromBlob, cosine } from "./embedder.js";
5
+ import { loadAffectedConfig, filterFilesForTarget, matchGlobalFiles } from "./affected-config.js";
5
6
  function requireIndex(projectPath) {
6
7
  if (!indexExists(projectPath)) {
7
8
  throw new Error("No index found. Build it first with the index_build tool (creates .speclaw/index.db).");
@@ -164,50 +165,274 @@ export async function recall(projectPath, query, limit = 15) {
164
165
  db.close();
165
166
  }
166
167
  }
168
+ const DEFAULT_HARD_LIMIT = 500;
167
169
  /**
168
- * Transitive blast radius: every node that (transitively) calls the target,
169
- * up to maxDepth hops. Expansion is by call-name, so dynamic dispatch is
170
- * included the conservative answer to "what could break if I change this?".
170
+ * Reverse dependency closure (blast radius) for a symbol or set of files.
171
+ *
172
+ * Uses one recursive SQL CTE that prefers `edges.dst_node_id` and falls back to
173
+ * `dst_name` only when the id is NULL. Default edge kinds are `call` and
174
+ * `import`. Results are grouped by module unless `format: "flat"`.
171
175
  *
172
176
  * @param projectPath - Absolute path to the indexed project.
173
- * @param nodeName - Name of the node whose dependents are wanted.
174
- * @param maxDepth - Maximum number of call hops to traverse outward.
175
- * @returns The reached nodes, each tagged with its discovery depth.
176
- * @throws If no index exists for the project.
177
+ * @param symbolOrOpts - Symbol name (legacy) or full {@link ImpactQuery}.
178
+ * @param maxDepth - Used only with the legacy string form.
177
179
  */
178
- export function impact(projectPath, nodeName, maxDepth = 4) {
180
+ export function impact(projectPath, symbolOrOpts, maxDepth = 4) {
181
+ const opts = typeof symbolOrOpts === "string"
182
+ ? { symbol: symbolOrOpts, maxDepth }
183
+ : { maxDepth: 4, ...symbolOrOpts };
179
184
  requireIndex(projectPath);
185
+ const cfg = loadAffectedConfig(projectPath);
186
+ const depth = Math.max(1, Math.min(12, opts.maxDepth ?? 4));
187
+ const edgeKinds = opts.edgeKinds?.length ? opts.edgeKinds : ["call", "import"];
188
+ const hardLimit = opts.hardLimit ?? DEFAULT_HARD_LIMIT;
189
+ const topModules = opts.topModules ?? 8;
190
+ const topPerModule = opts.topPerModule ?? 5;
191
+ const format = opts.format ?? "grouped";
192
+ const target = opts.target ?? "any";
193
+ const warnings = [];
180
194
  const db = openDb(projectPath);
181
195
  try {
182
- const visited = new Set();
183
- const results = [];
184
- let frontier = [nodeName];
185
- for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
186
- const placeholders = frontier.map(() => "?").join(",");
187
- const callers = db
188
- .prepare(`SELECT DISTINCT owner.id, owner.name, owner.kind, f.path AS file, MIN(owner.start_line) AS line
189
- FROM edges e
190
- JOIN nodes owner ON owner.id = e.src_node_id
191
- JOIN files f ON f.id = owner.file_id
192
- WHERE e.kind = 'call' AND e.dst_name IN (${placeholders})
193
- GROUP BY owner.id`)
194
- .all(...frontier);
195
- const nextNames = new Set();
196
- for (const c of callers) {
197
- if (visited.has(c.id))
198
- continue;
199
- visited.add(c.id);
200
- results.push({ name: c.name, kind: c.kind, file: c.file, line: c.line, depth });
201
- nextNames.add(c.name);
196
+ let seedFiles = opts.files ? [...opts.files] : [];
197
+ if (seedFiles.length > 0) {
198
+ const filtered = filterFilesForTarget(seedFiles, target, cfg);
199
+ warnings.push(...filtered.warnings);
200
+ seedFiles = filtered.included;
201
+ const glob = matchGlobalFiles(seedFiles, cfg);
202
+ if (glob.matched.length > 0) {
203
+ return {
204
+ target: { kind: "files", files: seedFiles },
205
+ totals: { nodes: 0, files: 0, modules: 0 },
206
+ global: {
207
+ matched: glob.patterns,
208
+ blastRadius: "repo",
209
+ reason: `Global file(s) matched (${glob.matched.join(", ")}); treat blast radius as the whole repository`,
210
+ },
211
+ modules: [],
212
+ resolution: { exact: 0, byName: 0 },
213
+ limits: { maxDepth: depth, maxDepthReached: false, truncated: false },
214
+ warnings,
215
+ };
216
+ }
217
+ }
218
+ const definitions = resolveImpactSeeds(db, opts, seedFiles, warnings);
219
+ if (definitions.length === 0 && !opts.symbol && seedFiles.length === 0) {
220
+ return emptyImpact(opts, seedFiles, depth, warnings);
221
+ }
222
+ if (definitions.length === 0) {
223
+ warnings.push("No seed definitions found in the index for the given target");
224
+ return emptyImpact(opts, seedFiles, depth, warnings);
225
+ }
226
+ const kindPlaceholders = edgeKinds.map(() => "?").join(",");
227
+ const seedPlaceholders = definitions.map(() => "(?, ?, 0, 0)").join(",");
228
+ const seedArgs = [];
229
+ for (const d of definitions) {
230
+ seedArgs.push(d.nodeId, d.name);
231
+ }
232
+ // Sticky by_name: MAX(frontier.by_name, CASE WHEN edge unresolved THEN 1 ELSE 0).
233
+ // Import edges that resolve to ANY node in the frontier node's file count as hits.
234
+ const sql = `
235
+ WITH RECURSIVE
236
+ frontier(node_id, node_name, depth, by_name) AS (
237
+ SELECT * FROM (VALUES ${seedPlaceholders})
238
+ UNION
239
+ SELECT owner.id,
240
+ owner.name,
241
+ f.depth + 1,
242
+ MAX(f.by_name, CASE
243
+ WHEN e.kind = 'import' THEN 0
244
+ WHEN e.dst_node_id IS NULL THEN 1
245
+ ELSE 0
246
+ END)
247
+ FROM frontier f
248
+ JOIN edges e ON (
249
+ e.kind IN (${kindPlaceholders})
250
+ AND (
251
+ (e.kind = 'call' AND (
252
+ e.dst_node_id = f.node_id
253
+ OR (e.dst_node_id IS NULL AND e.dst_name = f.node_name)
254
+ ))
255
+ OR (
256
+ e.kind = 'import'
257
+ AND e.dst_node_id IS NOT NULL
258
+ AND EXISTS (
259
+ SELECT 1 FROM nodes dn
260
+ WHERE dn.id = e.dst_node_id
261
+ AND dn.file_id = (SELECT file_id FROM nodes WHERE id = f.node_id)
262
+ )
263
+ )
264
+ )
265
+ )
266
+ JOIN nodes owner ON owner.id = e.src_node_id
267
+ WHERE f.depth < ?
268
+ )
269
+ SELECT r.node_id AS nodeId,
270
+ r.node_name AS name,
271
+ n.kind AS kind,
272
+ fl.path AS file,
273
+ n.start_line AS line,
274
+ MIN(r.depth) AS depth,
275
+ MIN(r.by_name) AS byName,
276
+ fl.module AS module
277
+ FROM frontier r
278
+ JOIN nodes n ON n.id = r.node_id
279
+ JOIN files fl ON fl.id = n.file_id
280
+ WHERE r.depth > 0
281
+ GROUP BY r.node_id
282
+ ORDER BY depth ASC, file ASC, line ASC
283
+ LIMIT ?
284
+ `;
285
+ const rows = db.prepare(sql).all(...seedArgs, ...edgeKinds, depth, hardLimit + 1);
286
+ const truncated = rows.length > hardLimit;
287
+ const sliced = truncated ? rows.slice(0, hardLimit) : rows;
288
+ const nodes = sliced.map((r) => ({
289
+ nodeId: r.nodeId,
290
+ name: r.name,
291
+ kind: r.kind,
292
+ file: r.file,
293
+ line: r.line,
294
+ depth: r.depth,
295
+ resolution: r.byName > 0 ? "by-name" : "exact",
296
+ module: r.module || inferModuleFallback(r.file),
297
+ }));
298
+ const exact = nodes.filter((n) => n.resolution === "exact").length;
299
+ const byName = nodes.length - exact;
300
+ const maxDepthReached = nodes.some((n) => n.depth >= depth);
301
+ const targetDesc = opts.symbol || opts.nodeId !== undefined
302
+ ? {
303
+ kind: "symbol",
304
+ symbol: opts.symbol ?? `#${opts.nodeId}`,
305
+ definitions: definitions.map((d) => ({
306
+ nodeId: d.nodeId,
307
+ file: d.file,
308
+ line: d.line,
309
+ })),
202
310
  }
203
- frontier = [...nextNames];
311
+ : { kind: "files", files: seedFiles };
312
+ if (format === "flat") {
313
+ const files = new Set(nodes.map((n) => n.file));
314
+ const modules = new Set(nodes.map((n) => n.module));
315
+ return {
316
+ target: targetDesc,
317
+ totals: { nodes: nodes.length, files: files.size, modules: modules.size },
318
+ modules: [],
319
+ nodes,
320
+ resolution: { exact, byName },
321
+ limits: { maxDepth: depth, maxDepthReached, truncated },
322
+ warnings,
323
+ };
204
324
  }
205
- return results;
325
+ return {
326
+ target: targetDesc,
327
+ totals: {
328
+ nodes: nodes.length,
329
+ files: new Set(nodes.map((n) => n.file)).size,
330
+ modules: new Set(nodes.map((n) => n.module)).size,
331
+ },
332
+ modules: groupImpactModules(nodes, topModules, topPerModule),
333
+ resolution: { exact, byName },
334
+ limits: { maxDepth: depth, maxDepthReached, truncated },
335
+ warnings,
336
+ };
206
337
  }
207
338
  finally {
208
339
  db.close();
209
340
  }
210
341
  }
342
+ function inferModuleFallback(file) {
343
+ const parts = file.split(/[/\\]/).filter(Boolean);
344
+ if (parts.length <= 1)
345
+ return parts[0] ?? ".";
346
+ return parts.slice(0, 2).join("/");
347
+ }
348
+ function emptyImpact(opts, seedFiles, depth, warnings) {
349
+ return {
350
+ target: opts.symbol || opts.nodeId !== undefined
351
+ ? {
352
+ kind: "symbol",
353
+ symbol: opts.symbol ?? `#${opts.nodeId}`,
354
+ definitions: [],
355
+ }
356
+ : { kind: "files", files: seedFiles },
357
+ totals: { nodes: 0, files: 0, modules: 0 },
358
+ modules: [],
359
+ nodes: opts.format === "flat" ? [] : undefined,
360
+ resolution: { exact: 0, byName: 0 },
361
+ limits: { maxDepth: depth, maxDepthReached: false, truncated: false },
362
+ warnings,
363
+ };
364
+ }
365
+ function resolveImpactSeeds(db, opts, seedFiles, warnings) {
366
+ if (opts.nodeId !== undefined) {
367
+ const row = db
368
+ .prepare(`SELECT n.id AS nodeId, n.name, f.path AS file, n.start_line AS line
369
+ FROM nodes n JOIN files f ON f.id = n.file_id WHERE n.id = ?`)
370
+ .get(opts.nodeId);
371
+ return row ? [row] : [];
372
+ }
373
+ if (opts.symbol) {
374
+ const rows = db
375
+ .prepare(`SELECT n.id AS nodeId, n.name, f.path AS file, n.start_line AS line
376
+ FROM nodes n JOIN files f ON f.id = n.file_id
377
+ WHERE n.name = ?
378
+ ORDER BY n.kind = 'function' DESC, n.kind = 'class' DESC, n.id ASC
379
+ LIMIT 50`)
380
+ .all(opts.symbol);
381
+ if (rows.length > 1) {
382
+ warnings.push(`"${opts.symbol}" is defined in ${rows.length} places; impact is the union. Pass nodeId to disambiguate.`);
383
+ }
384
+ return rows;
385
+ }
386
+ if (seedFiles.length === 0)
387
+ return [];
388
+ db.exec("CREATE TEMP TABLE IF NOT EXISTS changed(path TEXT PRIMARY KEY)");
389
+ db.exec("DELETE FROM changed");
390
+ const ins = db.prepare("INSERT OR IGNORE INTO changed(path) VALUES (?)");
391
+ for (const f of seedFiles)
392
+ ins.run(f.split("\\").join("/"));
393
+ const indexed = db
394
+ .prepare(`SELECT n.id AS nodeId, n.name, f.path AS file, n.start_line AS line
395
+ FROM nodes n
396
+ JOIN files f ON f.id = n.file_id
397
+ JOIN changed c ON c.path = f.path`)
398
+ .all();
399
+ const indexedPaths = new Set(indexed.map((r) => r.file));
400
+ for (const f of seedFiles) {
401
+ const norm = f.split("\\").join("/");
402
+ if (!indexedPaths.has(norm)) {
403
+ warnings.push(`${norm} is not indexed; run compass_index`);
404
+ }
405
+ }
406
+ return indexed;
407
+ }
408
+ function groupImpactModules(nodes, topModules, topPerModule) {
409
+ const byMod = new Map();
410
+ for (const n of nodes) {
411
+ const list = byMod.get(n.module) ?? [];
412
+ list.push(n);
413
+ byMod.set(n.module, list);
414
+ }
415
+ const modules = [];
416
+ for (const [module, list] of byMod) {
417
+ list.sort((a, b) => {
418
+ if (a.depth !== b.depth)
419
+ return a.depth - b.depth;
420
+ if (a.resolution !== b.resolution)
421
+ return a.resolution === "exact" ? -1 : 1;
422
+ return a.file.localeCompare(b.file) || a.line - b.line;
423
+ });
424
+ modules.push({
425
+ module,
426
+ nodes: list.length,
427
+ files: new Set(list.map((n) => n.file)).size,
428
+ minDepth: list[0]?.depth ?? 0,
429
+ byName: list.filter((n) => n.resolution === "by-name").length,
430
+ top: list.slice(0, topPerModule),
431
+ });
432
+ }
433
+ modules.sort((a, b) => b.nodes - a.nodes || a.module.localeCompare(b.module));
434
+ return modules.slice(0, topModules);
435
+ }
211
436
  /**
212
437
  * Trace a call path from one node to another: BFS forward over call edges (by
213
438
  * name) from `from` until `to` is reached, returning the chain of names. null
@@ -3,6 +3,8 @@ import { defineTool, text } from "../../shared/mcp.js";
3
3
  import { shouldExpose } from "../../shared/exposure.js";
4
4
  import { buildIndex } from "./indexer.js";
5
5
  import { explore, search, recall, impact, trace } from "./query.js";
6
+ import { affectedTests } from "./affected.js";
7
+ import { hotspots, coupling } from "./hotspots.js";
6
8
  import { startWatch, stopWatch, watchStatus } from "./watcher.js";
7
9
  import { visualize } from "./visualize.js";
8
10
  // ─── Compass: speclaw's own code-intelligence engine (no external deps) ───
@@ -23,7 +25,53 @@ export function registerCompass(server, opts = {}) {
23
25
  add("compass_explore", "Read a symbol's source plus callers and callees. Prefer this before grep or Read.", { projectPath: z.string(), node: z.string() }, async ({ projectPath, node }) => text(explore(projectPath, node)));
24
26
  add("compass_search", "Find symbols by name or keyword (substring). Cheaper structural search than grep.", { projectPath: z.string(), query: z.string(), limit: z.number().optional() }, async ({ projectPath, query, limit }) => text(search(projectPath, query, limit ?? 25)));
25
27
  add("compass_recall", "Find symbols by meaning via local embeddings. Use when names are unknown.", { projectPath: z.string(), query: z.string(), limit: z.number().optional() }, async ({ projectPath, query, limit }) => text(await recall(projectPath, query, limit ?? 15)));
26
- add("compass_impact", "List transitive callers of a symbol (blast radius) before editing.", { projectPath: z.string(), node: z.string(), maxDepth: z.number().optional() }, async ({ projectPath, node, maxDepth }) => text(impact(projectPath, node, maxDepth ?? 4)));
28
+ add("compass_impact", "Blast radius for a symbol or files, grouped by module (not a flat dump).", {
29
+ projectPath: z.string(),
30
+ /** @deprecated Prefer `symbol`. Kept for existing callers. */
31
+ node: z.string().optional(),
32
+ symbol: z.string().optional(),
33
+ files: z.array(z.string()).optional(),
34
+ nodeId: z.number().int().optional(),
35
+ maxDepth: z.number().int().min(1).max(12).optional(),
36
+ edgeKinds: z.array(z.enum(["call", "import"])).optional(),
37
+ target: z.enum(["build", "test", "lint", "any"]).optional(),
38
+ format: z.enum(["grouped", "flat"]).optional(),
39
+ topModules: z.number().int().min(1).max(50).optional(),
40
+ topPerModule: z.number().int().min(1).max(50).optional(),
41
+ }, async (args) => text(impact(args.projectPath, {
42
+ symbol: args.symbol ?? args.node,
43
+ files: args.files,
44
+ nodeId: args.nodeId,
45
+ maxDepth: args.maxDepth ?? 4,
46
+ edgeKinds: args.edgeKinds,
47
+ target: args.target,
48
+ format: args.format ?? "grouped",
49
+ topModules: args.topModules,
50
+ topPerModule: args.topPerModule,
51
+ })));
52
+ add("compass_affected_tests", "Select test files affected by a change; returns a ready-to-run command.", {
53
+ projectPath: z.string(),
54
+ files: z.array(z.string()).optional(),
55
+ symbols: z.array(z.string()).optional(),
56
+ fromDiff: z.string().optional(),
57
+ maxDepth: z.number().int().min(1).max(12).optional(),
58
+ }, async ({ projectPath, files, symbols, fromDiff, maxDepth }) => text(affectedTests(projectPath, { files, symbols, fromDiff, maxDepth })));
59
+ add("compass_hotspots", "Rank files by recent churn and AST complexity; two axes, no magic score.", {
60
+ projectPath: z.string(),
61
+ days: z.number().int().min(1).max(3650).optional(),
62
+ since: z.string().optional(),
63
+ sortBy: z.enum(["churn", "complexity", "combined"]).optional(),
64
+ limit: z.number().int().min(1).max(200).optional(),
65
+ }, async ({ projectPath, days, since, sortBy, limit }) => text(hotspots(projectPath, { days, since, sortBy, limit })));
66
+ add("compass_coupling", "Files that co-change with a target; strength, graph edge, and test-pair facts.", {
67
+ projectPath: z.string(),
68
+ file: z.string(),
69
+ days: z.number().int().min(1).max(3650).optional(),
70
+ since: z.string().optional(),
71
+ minShared: z.number().int().min(1).optional(),
72
+ maxFilesPerCommit: z.number().int().min(2).optional(),
73
+ limit: z.number().int().min(1).max(200).optional(),
74
+ }, async ({ projectPath, file, days, since, minShared, maxFilesPerCommit, limit }) => text(coupling(projectPath, file, { days, since, minShared, maxFilesPerCommit, limit })));
27
75
  add("compass_trace", "Find a call path between two symbols within a depth limit.", {
28
76
  projectPath: z.string(),
29
77
  from: z.string(),
@@ -11,6 +11,9 @@ export const MINIMAL_OMIT = new Set([
11
11
  "compass_index",
12
12
  "compass_watch",
13
13
  "compass_impact",
14
+ "compass_affected_tests",
15
+ "compass_hotspots",
16
+ "compass_coupling",
14
17
  "compass_trace",
15
18
  "compass_visualize",
16
19
  "lawbook_init",