@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.
- package/dist/cli/commands/query.js +73 -5
- package/dist/cli/commands/update.js +9 -0
- package/dist/cli/index.js +6 -4
- package/dist/modules/compass/affected-config.js +238 -0
- package/dist/modules/compass/affected.js +249 -0
- package/dist/modules/compass/db.js +10 -4
- package/dist/modules/compass/indexer.js +118 -6
- package/dist/modules/compass/query.js +255 -30
- package/dist/modules/compass/register.js +32 -1
- package/dist/shared/exposure.js +1 -0
- package/package.json +1 -1
|
@@ -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 -
|
|
7
|
-
*
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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`,
|
|
54
|
-
// output), `mcp` (a long-running stdio server), and `init`
|
|
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 = "
|
|
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
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
|
174
|
-
* @param maxDepth -
|
|
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,
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
|
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", "
|
|
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(),
|
package/dist/shared/exposure.js
CHANGED