@esneiderbravo/speclaw 0.3.8 → 0.3.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,10 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
- import { openDb } from "./db.js";
4
+ 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,19 @@ 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
- const insNode = db.prepare(`INSERT INTO nodes(file_id, name, kind, start_line, end_line, start_byte, end_byte, parent_id, signature)
112
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
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)
216
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
113
217
  const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line) VALUES (?, ?, ?, ?, ?)`);
114
218
  const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
115
219
  artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
@@ -143,29 +247,35 @@ export async function buildIndex(projectPath, onProgress) {
143
247
  continue;
144
248
  }
145
249
  let fileId;
250
+ const isTest = isTestPath(rel, cfg.testGlobs) ? 1 : 0;
251
+ const mod = inferModule(rel);
146
252
  if (prior) {
147
- updFile.run(hash, lang.id, prior.id);
253
+ updFile.run(hash, lang.id, isTest, mod, prior.id);
148
254
  delNodes.run(prior.id);
149
255
  delEdges.run(prior.id);
150
256
  delCoverage.run(rel);
151
257
  fileId = prior.id;
152
258
  }
153
259
  else {
154
- fileId = Number(insFile.run(rel, hash, lang.id).lastInsertRowid);
260
+ fileId = Number(insFile.run(rel, hash, lang.id, isTest, mod).lastInsertRowid);
155
261
  }
156
262
  const { symbols, refs, coverage } = await extract(content, lang);
157
263
  const nodeIds = [];
158
264
  for (const s of symbols) {
159
265
  const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
160
- const id = Number(insNode.run(fileId, s.name, s.kind, s.startLine, s.endLine, s.startByte, s.endByte, parentId, s.signature).lastInsertRowid);
266
+ 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
267
  nodeIds.push(id);
162
268
  // embed the node from its name + signature (cheap, meaningful text)
163
269
  const vec = await embedder.embed(`${s.kind} ${s.name} ${s.signature ?? ""}`);
164
270
  insEmbed.run(id, embedder.dim, embedder.id, toBlob(vec));
165
271
  stats.embeddings++;
166
272
  }
273
+ // Prefer a real symbol as import owner when the AST leaves imports file-scoped.
274
+ const fileOwner = nodeIds[0] ?? null;
167
275
  for (const r of refs) {
168
- const srcId = r.ownerIndex !== null ? nodeIds[r.ownerIndex] : null;
276
+ let srcId = r.ownerIndex !== null ? nodeIds[r.ownerIndex] : null;
277
+ if (srcId === null && r.kind === "import")
278
+ srcId = fileOwner;
169
279
  insEdge.run(srcId, fileId, r.name, r.kind, r.line);
170
280
  stats.edges++;
171
281
  }
@@ -184,16 +294,19 @@ export async function buildIndex(projectPath, onProgress) {
184
294
  stats.removed++;
185
295
  }
186
296
  }
187
- // resolve call edges to node definitions by name match
297
+ // Prefer same-file callees so colliding names across files do not share one id.
188
298
  db.exec(`
189
299
  UPDATE edges SET dst_node_id = (
190
300
  SELECT n.id FROM nodes n
191
301
  WHERE n.name = edges.dst_name
302
+ ORDER BY CASE WHEN n.file_id = edges.src_file_id THEN 0 ELSE 1 END, n.id
192
303
  LIMIT 1
193
304
  )
194
305
  WHERE kind = 'call' AND dst_node_id IS NULL
195
306
  `);
307
+ resolveImportEdges(db, projectPath);
196
308
  db.prepare("INSERT INTO meta(key, value) VALUES ('indexed_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(new Date().toISOString());
309
+ clearNeedsReindex(db);
197
310
  db.exec("COMMIT");
198
311
  }
199
312
  catch (err) {
@@ -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,7 @@ 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";
6
7
  import { startWatch, stopWatch, watchStatus } from "./watcher.js";
7
8
  import { visualize } from "./visualize.js";
8
9
  // ─── Compass: speclaw's own code-intelligence engine (no external deps) ───
@@ -23,7 +24,37 @@ export function registerCompass(server, opts = {}) {
23
24
  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
25
  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
26
  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)));
27
+ add("compass_impact", "Blast radius for a symbol or files, grouped by module (not a flat dump).", {
28
+ projectPath: z.string(),
29
+ /** @deprecated Prefer `symbol`. Kept for existing callers. */
30
+ node: z.string().optional(),
31
+ symbol: z.string().optional(),
32
+ files: z.array(z.string()).optional(),
33
+ nodeId: z.number().int().optional(),
34
+ maxDepth: z.number().int().min(1).max(12).optional(),
35
+ edgeKinds: z.array(z.enum(["call", "import"])).optional(),
36
+ target: z.enum(["build", "test", "lint", "any"]).optional(),
37
+ format: z.enum(["grouped", "flat"]).optional(),
38
+ topModules: z.number().int().min(1).max(50).optional(),
39
+ topPerModule: z.number().int().min(1).max(50).optional(),
40
+ }, async (args) => text(impact(args.projectPath, {
41
+ symbol: args.symbol ?? args.node,
42
+ files: args.files,
43
+ nodeId: args.nodeId,
44
+ maxDepth: args.maxDepth ?? 4,
45
+ edgeKinds: args.edgeKinds,
46
+ target: args.target,
47
+ format: args.format ?? "grouped",
48
+ topModules: args.topModules,
49
+ topPerModule: args.topPerModule,
50
+ })));
51
+ add("compass_affected_tests", "Select test files affected by a change; returns a ready-to-run command.", {
52
+ projectPath: z.string(),
53
+ files: z.array(z.string()).optional(),
54
+ symbols: z.array(z.string()).optional(),
55
+ fromDiff: z.string().optional(),
56
+ maxDepth: z.number().int().min(1).max(12).optional(),
57
+ }, async ({ projectPath, files, symbols, fromDiff, maxDepth }) => text(affectedTests(projectPath, { files, symbols, fromDiff, maxDepth })));
27
58
  add("compass_trace", "Find a call path between two symbols within a depth limit.", {
28
59
  projectPath: z.string(),
29
60
  from: z.string(),
@@ -7,6 +7,7 @@ import { readManifest } from "../../shared/manifest.js";
7
7
  import { pkgName, pkgVersion } from "../../shared/version.js";
8
8
  import { indexExists, openDb } from "../compass/db.js";
9
9
  import { specList } from "../lawbook/engine.js";
10
+ import { doctorDriftCheck } from "../lawbook/drift.js";
10
11
  import { globError, hasBackend, hasBatchBackend, readLawManifest } from "./laws.js";
11
12
  import { redactValue } from "../../shared/redact.js";
12
13
  const STATUS_RANK = {
@@ -558,6 +559,16 @@ export async function doctor(projectPath, opts = {}) {
558
559
  configuration.push(await budgetCheck(projectPath));
559
560
  configuration.push(freshnessCheck(projectPath));
560
561
  configuration.push(specsOrphansCheck(projectPath));
562
+ {
563
+ const d = doctorDriftCheck(projectPath);
564
+ addCheck(configuration, {
565
+ id: d.id,
566
+ title: d.title,
567
+ status: d.status,
568
+ detail: d.detail,
569
+ remedy: d.remedy,
570
+ });
571
+ }
561
572
  const configured = detectConfiguredAgents(projectPath);
562
573
  const mcpAgents = AGENTS.filter((a) => a.mcpFile && configured.includes(a.id));
563
574
  if (mcpAgents.length === 0) {