@esneiderbravo/speclaw 0.3.9 → 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,15 +1,19 @@
1
1
  import { explore, search, recall, impact, trace } from "../../modules/compass/query.js";
2
+ import { affectedTests } from "../../modules/compass/affected.js";
3
+ import { list } from "../lib/args.js";
2
4
  import { ui } from "../lib/ui.js";
3
5
  /**
4
6
  * Run a Compass query from the shell — the same surface agents call via MCP.
5
7
  *
6
- * @param cmd - The query verb: `explore`, `search`, `recall`, `impact`, or `trace`.
7
- * @param flags - Parsed flags supplying the positional query arguments in `_`.
8
+ * @param cmd - Query verb: `explore`, `search`, `recall`, `impact`, `trace`, or
9
+ * `affected-tests`.
10
+ * @param flags - Parsed flags supplying positional args and options in `_`.
8
11
  * @throws Exits the process with code 1 on missing arguments or query errors.
9
12
  */
10
13
  export async function runQuery(cmd, flags) {
11
14
  const cwd = process.cwd();
12
15
  const args = flags._;
16
+ const asJson = Boolean(flags.json);
13
17
  try {
14
18
  switch (cmd) {
15
19
  case "explore": {
@@ -41,9 +45,73 @@ export async function runQuery(cmd, flags) {
41
45
  return;
42
46
  }
43
47
  case "impact": {
44
- const nodes = impact(cwd, need(args[0], "impact <node>"));
45
- ui.heading(`Blast radius: ${nodes.length} dependent(s)`);
46
- nodes.forEach((n) => ui.info(`depth ${n.depth}: ${n.name} (${n.file}:${n.line})`));
48
+ const fileList = list(flags.file);
49
+ if (!args[0] && fileList.length === 0) {
50
+ need(undefined, "impact <node> | impact --file <path>");
51
+ }
52
+ const format = flags.flat ? "flat" : "grouped";
53
+ const result = impact(cwd, {
54
+ symbol: args[0],
55
+ files: fileList.length ? fileList : undefined,
56
+ maxDepth: flags.depth ? Number(flags.depth) : 4,
57
+ format,
58
+ target: typeof flags.target === "string" ? flags.target : "any",
59
+ });
60
+ if (asJson) {
61
+ console.log(JSON.stringify(result, null, 2));
62
+ return;
63
+ }
64
+ if (result.global) {
65
+ ui.heading(`Blast radius: repo-wide`);
66
+ ui.warn(result.global.reason);
67
+ result.warnings.forEach((w) => ui.warn(w));
68
+ return;
69
+ }
70
+ ui.heading(`Blast radius: ${result.totals.nodes} node(s) in ${result.totals.modules} module(s)` +
71
+ (result.limits.truncated ? " (truncated)" : ""));
72
+ if (result.target.kind === "symbol" && result.target.definitions.length > 1) {
73
+ ui.warn(`"${result.target.symbol}" is defined in ${result.target.definitions.length} places; impact is the union`);
74
+ }
75
+ if (format === "flat" && result.nodes) {
76
+ result.nodes.forEach((n) => ui.info(`depth ${n.depth} [${n.resolution}]: ${n.name} (${n.file}:${n.line})`));
77
+ }
78
+ else {
79
+ for (const m of result.modules) {
80
+ ui.heading(`${m.module} — ${m.nodes} node(s), ${m.files} file(s), min depth ${m.minDepth}`);
81
+ m.top.forEach((n) => ui.info(`depth ${n.depth} [${n.resolution}]: ${n.name} (${n.file}:${n.line})`));
82
+ }
83
+ }
84
+ if (result.resolution.byName > 0) {
85
+ ui.warn(`${result.resolution.byName} result(s) resolved by name (possible collisions)`);
86
+ }
87
+ result.warnings.forEach((w) => ui.warn(w));
88
+ return;
89
+ }
90
+ case "affected-tests": {
91
+ const files = list(flags.file);
92
+ const fromDiff = typeof flags["from-diff"] === "string"
93
+ ? flags["from-diff"]
94
+ : flags["from-diff"] === true
95
+ ? "HEAD"
96
+ : undefined;
97
+ if (files.length === 0 && !fromDiff && !args[0]) {
98
+ need(undefined, "affected-tests --file <path> | --from-diff <ref>");
99
+ }
100
+ const result = affectedTests(cwd, {
101
+ files: files.length ? files : undefined,
102
+ symbols: args[0] ? [args[0]] : undefined,
103
+ fromDiff,
104
+ });
105
+ if (asJson) {
106
+ console.log(JSON.stringify(result, null, 2));
107
+ return;
108
+ }
109
+ ui.heading(`Affected tests (${result.mode}): ${result.tests.length} file(s) — skipped ${result.skipped.files} (${result.skipped.percent}%)`);
110
+ ui.info(result.reason);
111
+ result.tests.forEach((t) => ui.info(t.file));
112
+ ui.heading("Command");
113
+ console.log(result.command);
114
+ result.warnings.forEach((w) => ui.warn(w));
47
115
  return;
48
116
  }
49
117
  case "trace": {
@@ -108,6 +108,15 @@ const MIGRATIONS = [
108
108
  "reindex with `speclaw index`, then `speclaw drift --reseal` once to photograph current bodies.\n" +
109
109
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
110
110
  },
111
+ {
112
+ version: "0.3.10",
113
+ describe: "Impact v2 + affected-tests: schema 7, grouped blast radius",
114
+ agentPrompt: "- Mention `compass_impact` (grouped by default; `format: flat` escape hatch) and " +
115
+ "`compass_affected_tests` / `speclaw affected-tests --from-diff`. Compass schema is now 7 " +
116
+ "(`files.is_test`, `files.module`) — reindex with `speclaw index`. Optional " +
117
+ "`.speclaw/affected.json` overrides globals/test globs.\n" +
118
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
119
+ },
111
120
  ];
112
121
  /**
113
122
  * Update speclaw and bring the current project up to date without a full re-init:
package/dist/cli/index.js CHANGED
@@ -23,7 +23,8 @@ Compass (code intelligence — the same surface agents use via MCP)
23
23
  explore <node> A node's source + callers/callees
24
24
  search <query> Find nodes by name/keyword
25
25
  recall "<query>" Find code by meaning (semantic)
26
- impact <node> Blast radius: everything that (transitively) calls it
26
+ impact <node> Blast radius (grouped by module; --flat / --json)
27
+ affected-tests Tests affected by a change (--file / --from-diff / --json)
27
28
  trace <from> <to> A call path between two nodes
28
29
  visualize [node] Interactive HTML graph → .speclaw/graph.html
29
30
 
@@ -50,9 +51,9 @@ Other
50
51
  // Commands that open with the one-line branded header. These are the
51
52
  // interactive, human-facing commands whose stdout is prose. Deliberately
52
53
  // excluded: `version`/`--version`/`-v` (bare scriptable value), the Compass
53
- // query family (`explore`/`search`/`recall`/`impact`/`trace`, machine-consumed
54
- // output), `mcp` (a long-running stdio server), and `init` (already opens with
55
- // the fuller `banner()`).
54
+ // query family (`explore`/`search`/`recall`/`impact`/`trace`/`affected-tests`,
55
+ // machine-consumed output), `mcp` (a long-running stdio server), and `init`
56
+ // (already opens with the fuller `banner()`).
56
57
  const HEADER_COMMANDS = new Set([
57
58
  undefined,
58
59
  "help",
@@ -126,6 +127,7 @@ async function dispatch(cmd, flags) {
126
127
  case "recall":
127
128
  case "impact":
128
129
  case "trace":
130
+ case "affected-tests":
129
131
  return (await import("./commands/query.js")).runQuery(cmd, flags);
130
132
  case "visualize":
131
133
  return (await import("./commands/visualize.js")).runVisualize(flags);
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Optional `.speclaw/affected.json` plus embedded defaults for global files,
3
+ * test globs, named targets, and ignores used by impact / affected-tests.
4
+ */
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ const DEFAULT_TARGETS = {
8
+ build: {
9
+ include: ["src/**"],
10
+ exclude: ["**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts}", "test/**", "tests/**"],
11
+ },
12
+ test: {
13
+ include: [
14
+ "src/**",
15
+ "test/**",
16
+ "tests/**",
17
+ "**/*.{test,spec}.{ts,tsx,js,jsx}",
18
+ "**/conftest.py",
19
+ ],
20
+ exclude: [],
21
+ },
22
+ lint: {
23
+ include: ["**/*.{ts,tsx,js,jsx,py}", ".eslintrc*", "eslint.config.*", "ruff.toml"],
24
+ exclude: [],
25
+ },
26
+ };
27
+ /** Embedded defaults when `.speclaw/affected.json` is absent. */
28
+ export const DEFAULT_AFFECTED_CONFIG = {
29
+ version: 1,
30
+ globalFiles: [
31
+ "tsconfig*.json",
32
+ "package.json",
33
+ "package-lock.json",
34
+ "pnpm-lock.yaml",
35
+ "yarn.lock",
36
+ "LAWS.md",
37
+ "lawbook/config.yaml",
38
+ ".speclaw/rules.json",
39
+ "**/*.config.{ts,js,mjs,cjs}",
40
+ ],
41
+ testGlobs: [
42
+ "test/**",
43
+ "tests/**",
44
+ "**/*.test.*",
45
+ "**/*.spec.*",
46
+ "**/conftest.py",
47
+ "**/test_*.py",
48
+ ],
49
+ targets: DEFAULT_TARGETS,
50
+ ignore: [
51
+ "node_modules/**",
52
+ "dist/**",
53
+ "dist-test/**",
54
+ "vendor/**",
55
+ "**/generated/**",
56
+ ".speclaw/**",
57
+ ],
58
+ };
59
+ /**
60
+ * Glob match supporting `**`, `*`, and simple `{a,b}` brace lists.
61
+ *
62
+ * @param relPath - Project-relative path (either slash style).
63
+ * @param pattern - Glob pattern.
64
+ */
65
+ export function matchGlob(relPath, pattern) {
66
+ const norm = relPath.split("\\").join("/");
67
+ for (const expanded of expandBraces(pattern.split("\\").join("/"))) {
68
+ if (globToRegExp(expanded).test(norm))
69
+ return true;
70
+ }
71
+ return false;
72
+ }
73
+ function expandBraces(pattern) {
74
+ const m = pattern.match(/\{([^{}]+)\}/);
75
+ if (!m || m.index === undefined)
76
+ return [pattern];
77
+ const before = pattern.slice(0, m.index);
78
+ const after = pattern.slice(m.index + m[0].length);
79
+ const alts = m[1].split(",");
80
+ const out = [];
81
+ for (const alt of alts) {
82
+ out.push(...expandBraces(before + alt + after));
83
+ }
84
+ return out;
85
+ }
86
+ function globToRegExp(pattern) {
87
+ let i = 0;
88
+ let re = "^";
89
+ while (i < pattern.length) {
90
+ if (pattern.startsWith("**/", i) || (pattern.startsWith("**", i) && i + 2 === pattern.length)) {
91
+ re += ".*";
92
+ i += pattern.startsWith("**/", i) ? 3 : 2;
93
+ continue;
94
+ }
95
+ if (pattern[i] === "*") {
96
+ re += "[^/]*";
97
+ i++;
98
+ continue;
99
+ }
100
+ const ch = pattern[i];
101
+ if (/[.+^${}()|[\]\\]/.test(ch))
102
+ re += `\\${ch}`;
103
+ else
104
+ re += ch;
105
+ i++;
106
+ }
107
+ re += "$";
108
+ return new RegExp(re);
109
+ }
110
+ /**
111
+ * Whether a relative path matches any of the given globs.
112
+ *
113
+ * @param relPath - Project-relative path.
114
+ * @param globs - Patterns to try.
115
+ */
116
+ export function matchesAny(relPath, globs) {
117
+ return globs.some((g) => matchGlob(relPath, g));
118
+ }
119
+ /**
120
+ * Infer the module bucket for grouping (nearest package root or first two path segments).
121
+ *
122
+ * @param relPath - Project-relative file path.
123
+ */
124
+ export function inferModule(relPath) {
125
+ const norm = relPath.split("\\").join("/");
126
+ const parts = norm.split("/").filter(Boolean);
127
+ if (parts.length === 0)
128
+ return ".";
129
+ if (parts.length === 1)
130
+ return parts[0];
131
+ // Prefer src/<area>, test/<area>, packages/<name>, …
132
+ return parts.slice(0, 2).join("/");
133
+ }
134
+ /**
135
+ * Whether a path looks like a test file under the given test globs.
136
+ *
137
+ * @param relPath - Project-relative path.
138
+ * @param testGlobs - Glob list (defaults from config).
139
+ */
140
+ export function isTestPath(relPath, testGlobs = DEFAULT_AFFECTED_CONFIG.testGlobs) {
141
+ return matchesAny(relPath, testGlobs);
142
+ }
143
+ /**
144
+ * Load and validate `.speclaw/affected.json`, or return embedded defaults.
145
+ *
146
+ * @param projectPath - Absolute project root.
147
+ * @returns The effective config.
148
+ * @throws If the file exists but is malformed or has the wrong version shape.
149
+ */
150
+ export function loadAffectedConfig(projectPath) {
151
+ const p = path.join(projectPath, ".speclaw", "affected.json");
152
+ if (!fs.existsSync(p))
153
+ return { ...DEFAULT_AFFECTED_CONFIG, targets: { ...DEFAULT_TARGETS } };
154
+ let raw;
155
+ try {
156
+ raw = JSON.parse(fs.readFileSync(p, "utf8"));
157
+ }
158
+ catch (err) {
159
+ throw new Error(`Invalid .speclaw/affected.json: ${err.message}`, { cause: err });
160
+ }
161
+ if (!raw || typeof raw !== "object") {
162
+ throw new Error("Invalid .speclaw/affected.json: expected a JSON object");
163
+ }
164
+ const doc = raw;
165
+ if (doc.version !== 1 && doc.version !== undefined) {
166
+ throw new Error(`Invalid .speclaw/affected.json: unsupported version ${String(doc.version)}`);
167
+ }
168
+ const cfg = {
169
+ version: 1,
170
+ globalFiles: asStringArray(doc.globalFiles, DEFAULT_AFFECTED_CONFIG.globalFiles),
171
+ testGlobs: asStringArray(doc.testGlobs, DEFAULT_AFFECTED_CONFIG.testGlobs),
172
+ ignore: asStringArray(doc.ignore, DEFAULT_AFFECTED_CONFIG.ignore),
173
+ targets: { ...DEFAULT_TARGETS },
174
+ };
175
+ if (doc.targets && typeof doc.targets === "object") {
176
+ const t = doc.targets;
177
+ for (const name of ["build", "test", "lint"]) {
178
+ const entry = t[name];
179
+ if (!entry || typeof entry !== "object")
180
+ continue;
181
+ const e = entry;
182
+ cfg.targets[name] = {
183
+ include: asStringArray(e.include, DEFAULT_TARGETS[name].include),
184
+ exclude: asStringArray(e.exclude, DEFAULT_TARGETS[name].exclude),
185
+ };
186
+ }
187
+ }
188
+ return cfg;
189
+ }
190
+ function asStringArray(value, fallback) {
191
+ if (!Array.isArray(value))
192
+ return [...fallback];
193
+ const out = value.filter((v) => typeof v === "string" && v.length > 0);
194
+ return out.length > 0 ? out : [...fallback];
195
+ }
196
+ /**
197
+ * Filter changed paths for a named target (Nx-style named inputs).
198
+ *
199
+ * @param files - Candidate relative paths.
200
+ * @param target - Target name; `any` keeps all non-ignored paths.
201
+ * @param cfg - Affected config.
202
+ */
203
+ export function filterFilesForTarget(files, target, cfg) {
204
+ const warnings = [];
205
+ const notIgnored = files.filter((f) => !matchesAny(f, cfg.ignore));
206
+ if (target === "any")
207
+ return { included: notIgnored, warnings };
208
+ const spec = cfg.targets[target];
209
+ const included = notIgnored.filter((f) => {
210
+ if (spec.exclude.some((g) => matchGlob(f, g)))
211
+ return false;
212
+ return spec.include.some((g) => matchGlob(f, g));
213
+ });
214
+ if (included.length === 0 && notIgnored.length > 0) {
215
+ warnings.push(`Change only affects other targets; empty for target "${target}" (${notIgnored.length} path(s) filtered out)`);
216
+ }
217
+ return { included, warnings };
218
+ }
219
+ /**
220
+ * Match changed paths against globalFiles globs.
221
+ *
222
+ * @param files - Relative paths.
223
+ * @param cfg - Affected config.
224
+ */
225
+ export function matchGlobalFiles(files, cfg) {
226
+ const patterns = new Set();
227
+ const matched = [];
228
+ for (const f of files) {
229
+ for (const g of cfg.globalFiles) {
230
+ if (matchGlob(f, g)) {
231
+ matched.push(f);
232
+ patterns.add(g);
233
+ break;
234
+ }
235
+ }
236
+ }
237
+ return { matched, patterns: [...patterns] };
238
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Static affected-test selection: reverse reachability into `files.is_test = 1`,
3
+ * plus a ready-to-run command string.
4
+ */
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { changedFiles, isGitRepo } from "../../shared/git.js";
8
+ import { openDb, indexExists } from "./db.js";
9
+ import { impact } from "./query.js";
10
+ import { loadAffectedConfig, matchGlobalFiles, matchesAny, } from "./affected-config.js";
11
+ /**
12
+ * Select a safe superset of test files affected by a change.
13
+ *
14
+ * @param projectPath - Absolute project root with a Compass index.
15
+ * @param query - Files, symbols, and/or a git diff base ref.
16
+ */
17
+ export function affectedTests(projectPath, query = {}) {
18
+ if (!indexExists(projectPath)) {
19
+ throw new Error("No index found. Build it first with the index_build tool (creates .speclaw/index.db).");
20
+ }
21
+ const cfg = loadAffectedConfig(projectPath);
22
+ const warnings = [];
23
+ warnings.push(...warnUnindexedLanguages(projectPath));
24
+ let files = [...(query.files ?? [])];
25
+ if (query.fromDiff !== undefined) {
26
+ if (!isGitRepo(projectPath)) {
27
+ throw new Error("fromDiff requires a git repository");
28
+ }
29
+ const base = query.fromDiff === "WORKTREE" || query.fromDiff === "" ? "HEAD" : query.fromDiff;
30
+ // WORKTREE ≈ uncommitted: use merge-base against HEAD's first-parent via changedFiles("HEAD")
31
+ // when the caller passes a branch/ref; for literal WORKTREE fall back to HEAD...working tree
32
+ // is not in changedFiles — use the ref as merge-base target.
33
+ const diffFiles = query.fromDiff === "WORKTREE"
34
+ ? listWorktreeChanges(projectPath)
35
+ : changedFiles(projectPath, base);
36
+ files = [...new Set([...files, ...diffFiles])];
37
+ if (files.length === 0) {
38
+ return {
39
+ mode: "static",
40
+ reason: "no changed files",
41
+ tests: [],
42
+ skipped: { files: countTestFiles(projectPath), percent: 100 },
43
+ command: buildTestCommand(projectPath, [], cfg, "none"),
44
+ warnings,
45
+ };
46
+ }
47
+ }
48
+ const glob = matchGlobalFiles(files, cfg);
49
+ if (glob.matched.length > 0) {
50
+ const allTests = listTestFiles(projectPath);
51
+ return {
52
+ mode: "all",
53
+ reason: `global file matched (${glob.matched.join(", ")})`,
54
+ tests: allTests.map((file) => ({ file, nodes: 0, minDepth: 0 })),
55
+ skipped: { files: 0, percent: 0 },
56
+ command: buildTestCommand(projectPath, [], cfg, "all"),
57
+ warnings,
58
+ };
59
+ }
60
+ const impactOpts = {
61
+ files: files.length > 0 ? files : undefined,
62
+ symbol: query.symbols?.length === 1 ? query.symbols[0] : undefined,
63
+ maxDepth: query.maxDepth ?? 6,
64
+ format: "flat",
65
+ target: "test",
66
+ edgeKinds: ["call", "import"],
67
+ };
68
+ // Multiple symbols → union flat impacts.
69
+ const nodes = [...(impact(projectPath, impactOpts).nodes ?? [])];
70
+ if (query.symbols && query.symbols.length > 1) {
71
+ const seen = new Set(nodes.map((n) => n.nodeId));
72
+ for (const sym of query.symbols) {
73
+ for (const n of impact(projectPath, {
74
+ symbol: sym,
75
+ format: "flat",
76
+ maxDepth: impactOpts.maxDepth,
77
+ }).nodes ?? []) {
78
+ if (!seen.has(n.nodeId)) {
79
+ seen.add(n.nodeId);
80
+ nodes.push(n);
81
+ }
82
+ }
83
+ }
84
+ }
85
+ // Also include directly changed test files.
86
+ const testHits = new Map();
87
+ for (const f of files) {
88
+ const norm = f.split("\\").join("/");
89
+ if (matchesAny(norm, cfg.testGlobs)) {
90
+ testHits.set(norm, { file: norm, nodes: 0, minDepth: 0 });
91
+ }
92
+ }
93
+ const db = openDb(projectPath);
94
+ try {
95
+ const isTestByPath = new Map();
96
+ for (const row of db.prepare("SELECT path, is_test FROM files").all()) {
97
+ isTestByPath.set(row.path, row.is_test === 1);
98
+ }
99
+ for (const n of nodes) {
100
+ if (!isTestByPath.get(n.file))
101
+ continue;
102
+ const prior = testHits.get(n.file);
103
+ if (!prior) {
104
+ testHits.set(n.file, { file: n.file, nodes: 1, minDepth: n.depth });
105
+ }
106
+ else {
107
+ prior.nodes += 1;
108
+ prior.minDepth = Math.min(prior.minDepth, n.depth);
109
+ }
110
+ }
111
+ }
112
+ finally {
113
+ db.close();
114
+ }
115
+ const tests = [...testHits.values()].sort((a, b) => a.file.localeCompare(b.file));
116
+ const totalTests = countTestFiles(projectPath);
117
+ const skippedFiles = Math.max(0, totalTests - tests.length);
118
+ const percent = totalTests === 0 ? 0 : Math.round((skippedFiles / totalTests) * 100);
119
+ return {
120
+ mode: "static",
121
+ reason: files.length > 0
122
+ ? `changed ${files.length} file(s)`
123
+ : query.symbols?.length
124
+ ? `symbols ${query.symbols.join(", ")}`
125
+ : "empty selection",
126
+ tests,
127
+ skipped: { files: skippedFiles, percent },
128
+ command: buildTestCommand(projectPath, tests.map((t) => t.file), cfg, tests.length === 0 ? "none" : "subset"),
129
+ warnings,
130
+ };
131
+ }
132
+ function listWorktreeChanges(projectPath) {
133
+ // Prefer merge-base against main/master when available; else HEAD.
134
+ for (const base of ["main", "master", "HEAD"]) {
135
+ const files = changedFiles(projectPath, base);
136
+ if (files.length > 0 || base === "HEAD")
137
+ return files;
138
+ }
139
+ return [];
140
+ }
141
+ function countTestFiles(projectPath) {
142
+ if (!indexExists(projectPath))
143
+ return 0;
144
+ const db = openDb(projectPath);
145
+ try {
146
+ const row = db.prepare("SELECT COUNT(*) AS n FROM files WHERE is_test = 1").get();
147
+ return Number(row.n);
148
+ }
149
+ finally {
150
+ db.close();
151
+ }
152
+ }
153
+ function listTestFiles(projectPath) {
154
+ const db = openDb(projectPath);
155
+ try {
156
+ return db.prepare("SELECT path FROM files WHERE is_test = 1 ORDER BY path").all().map((r) => r.path);
157
+ }
158
+ finally {
159
+ db.close();
160
+ }
161
+ }
162
+ /**
163
+ * Build an executable test command from package.json scripts.test when present.
164
+ *
165
+ * @param projectPath - Project root.
166
+ * @param tests - Selected test paths (ignored for mode `all`).
167
+ * @param _cfg - Reserved for future runner overrides.
168
+ * @param mode - `all` | `subset` | `none`.
169
+ */
170
+ export function buildTestCommand(projectPath, tests, _cfg, mode) {
171
+ const pkgPath = path.join(projectPath, "package.json");
172
+ let script;
173
+ if (fs.existsSync(pkgPath)) {
174
+ try {
175
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
176
+ script = pkg.scripts?.test;
177
+ }
178
+ catch {
179
+ /* ignore */
180
+ }
181
+ }
182
+ if (mode === "all") {
183
+ return script ? "npm test" : "node --test";
184
+ }
185
+ if (mode === "none" || tests.length === 0) {
186
+ return script ? "npm test -- --test-name-pattern=^$" : "node --test --test-name-pattern=^$";
187
+ }
188
+ const args = tests.map(shellQuote).join(" ");
189
+ if (script && /\bnode\s+--test\b/.test(script)) {
190
+ return `node --test ${args}`;
191
+ }
192
+ if (script) {
193
+ // Pass paths after `--` for npm/vitest/jest-style scripts.
194
+ return `npm test -- ${args}`;
195
+ }
196
+ return `node --test ${args}`;
197
+ }
198
+ function shellQuote(p) {
199
+ if (/^[A-Za-z0-9_./-]+$/.test(p))
200
+ return p;
201
+ return `'${p.replace(/'/g, `'\\''`)}'`;
202
+ }
203
+ /** Warn when present extensions are not in the indexed language set. */
204
+ function warnUnindexedLanguages(projectPath) {
205
+ const indexedExts = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py"]);
206
+ const seen = new Set();
207
+ const warnings = [];
208
+ walkQuick(projectPath, (rel) => {
209
+ const ext = path.extname(rel).toLowerCase();
210
+ if (!ext || indexedExts.has(ext) || seen.has(ext))
211
+ return;
212
+ // Only flag common source extensions that Compass does not parse.
213
+ if (![".go", ".rs", ".java", ".kt", ".rb", ".php", ".cs"].includes(ext))
214
+ return;
215
+ seen.add(ext);
216
+ warnings.push(`${ext} files are present but not indexed by Compass`);
217
+ });
218
+ return warnings;
219
+ }
220
+ function walkQuick(root, visit) {
221
+ const skip = new Set([".git", "node_modules", "dist", "dist-test", ".speclaw", "vendor"]);
222
+ const stack = [root];
223
+ let n = 0;
224
+ while (stack.length && n < 5000) {
225
+ const dir = stack.pop();
226
+ let entries;
227
+ try {
228
+ entries = fs.readdirSync(dir, { withFileTypes: true });
229
+ }
230
+ catch {
231
+ continue;
232
+ }
233
+ for (const e of entries) {
234
+ if (e.name.startsWith(".") && e.name !== ".speclaw") {
235
+ if (e.isDirectory() && e.name !== ".github")
236
+ continue;
237
+ }
238
+ const full = path.join(dir, e.name);
239
+ if (e.isDirectory()) {
240
+ if (!skip.has(e.name))
241
+ stack.push(full);
242
+ }
243
+ else if (e.isFile()) {
244
+ n++;
245
+ visit(path.relative(root, full));
246
+ }
247
+ }
248
+ }
249
+ }
@@ -10,8 +10,11 @@ CREATE TABLE IF NOT EXISTS files (
10
10
  id INTEGER PRIMARY KEY,
11
11
  path TEXT UNIQUE NOT NULL,
12
12
  hash TEXT NOT NULL,
13
- lang TEXT NOT NULL
13
+ lang TEXT NOT NULL,
14
+ is_test INTEGER NOT NULL DEFAULT 0,
15
+ module TEXT NOT NULL DEFAULT ''
14
16
  );
17
+ CREATE INDEX IF NOT EXISTS idx_files_is_test ON files(is_test);
15
18
  -- nodes: the definitions in the codebase (functions, classes, methods, types).
16
19
  CREATE TABLE IF NOT EXISTS nodes (
17
20
  id INTEGER PRIMARY KEY,
@@ -101,7 +104,7 @@ CREATE INDEX IF NOT EXISTS idx_anchors_symbol ON spec_anchors(symbol_name);
101
104
  CREATE INDEX IF NOT EXISTS idx_anchors_node ON spec_anchors(node_id);
102
105
  `;
103
106
  /** Schema version stamped into the `meta` table on first creation. */
104
- export const SCHEMA_VERSION = "6";
107
+ export const SCHEMA_VERSION = "7";
105
108
  /** The stamped schema version, or null if the db predates versioning / has no meta table. */
106
109
  function readSchemaVersion(db) {
107
110
  try {
@@ -127,8 +130,11 @@ function isStale(db) {
127
130
  return false;
128
131
  if (readSchemaVersion(db) !== SCHEMA_VERSION)
129
132
  return true;
130
- const cols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
131
- return !cols.includes("src_node_id") || !cols.includes("dst_node_id");
133
+ const edgeCols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
134
+ if (!edgeCols.includes("src_node_id") || !edgeCols.includes("dst_node_id"))
135
+ return true;
136
+ const fileCols = db.prepare("PRAGMA table_info(files)").all().map((c) => c.name);
137
+ return !fileCols.includes("is_test") || !fileCols.includes("module");
132
138
  }
133
139
  /** Drop every table (children first) so the current schema can be recreated cleanly. */
134
140
  function resetSchema(db) {
@@ -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,13 +201,14 @@ 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 = ?");
@@ -143,15 +247,17 @@ 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 = [];
@@ -164,8 +270,12 @@ export async function buildIndex(projectPath, onProgress) {
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,15 +294,17 @@ 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());
197
309
  clearNeedsReindex(db);
198
310
  db.exec("COMMIT");
@@ -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(),
@@ -11,6 +11,7 @@ export const MINIMAL_OMIT = new Set([
11
11
  "compass_index",
12
12
  "compass_watch",
13
13
  "compass_impact",
14
+ "compass_affected_tests",
14
15
  "compass_trace",
15
16
  "compass_visualize",
16
17
  "lawbook_init",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },