@esneiderbravo/speclaw 0.3.9 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/query.js +120 -5
- package/dist/cli/commands/update.js +17 -0
- package/dist/cli/index.js +10 -4
- package/dist/modules/compass/affected-config.js +238 -0
- package/dist/modules/compass/affected.js +249 -0
- package/dist/modules/compass/db.js +23 -4
- package/dist/modules/compass/extract.js +44 -0
- package/dist/modules/compass/git-history-cache.js +19 -2
- package/dist/modules/compass/hotspots.js +230 -0
- package/dist/modules/compass/indexer.js +120 -6
- package/dist/modules/compass/languages.js +39 -0
- package/dist/modules/compass/query.js +255 -30
- package/dist/modules/compass/register.js +49 -1
- package/dist/shared/exposure.js +3 -0
- package/dist/shared/git-history.js +85 -5
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import { openDb, clearNeedsReindex } from "./db.js";
|
|
|
5
5
|
import { langForPath } from "./languages.js";
|
|
6
6
|
import { extract } from "./extract.js";
|
|
7
7
|
import { getEmbedder, toBlob } from "./embedder.js";
|
|
8
|
+
import { loadAffectedConfig, isTestPath, inferModule } from "./affected-config.js";
|
|
8
9
|
const SKIP_DIRS = new Set([
|
|
9
10
|
".git",
|
|
10
11
|
"node_modules",
|
|
@@ -29,6 +30,108 @@ const MAX_FILE_BYTES = 1_500_000;
|
|
|
29
30
|
function hashOf(content) {
|
|
30
31
|
return createHash("sha256").update(content).digest("hex");
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Point import edges at a representative node in the imported file so reverse
|
|
35
|
+
* reachability can walk file-level dependencies (not just calls).
|
|
36
|
+
*/
|
|
37
|
+
function resolveImportEdges(db, projectPath) {
|
|
38
|
+
const files = db.prepare("SELECT id, path FROM files").all();
|
|
39
|
+
const byNorm = new Map();
|
|
40
|
+
for (const f of files) {
|
|
41
|
+
byNorm.set(f.path.split("\\").join("/"), f.id);
|
|
42
|
+
}
|
|
43
|
+
const firstNode = db.prepare("SELECT id FROM nodes WHERE file_id = ? ORDER BY start_line ASC, id ASC LIMIT 1");
|
|
44
|
+
const namedNode = db.prepare("SELECT id FROM nodes WHERE file_id = ? AND name = ? ORDER BY id ASC LIMIT 1");
|
|
45
|
+
const upd = db.prepare("UPDATE edges SET dst_node_id = ? WHERE id = ?");
|
|
46
|
+
const imports = db
|
|
47
|
+
.prepare(`SELECT e.id, e.dst_name, e.src_file_id, f.path AS src_path
|
|
48
|
+
FROM edges e JOIN files f ON f.id = e.src_file_id
|
|
49
|
+
WHERE e.kind = 'import' AND e.dst_node_id IS NULL`)
|
|
50
|
+
.all();
|
|
51
|
+
for (const edge of imports) {
|
|
52
|
+
const spec = parseImportSpecifier(edge.dst_name);
|
|
53
|
+
if (!spec)
|
|
54
|
+
continue;
|
|
55
|
+
const targetRel = resolveImportPath(projectPath, edge.src_path, spec.from);
|
|
56
|
+
if (!targetRel)
|
|
57
|
+
continue;
|
|
58
|
+
const fileId = byNorm.get(targetRel);
|
|
59
|
+
if (fileId === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
let nodeId;
|
|
62
|
+
for (const name of spec.names) {
|
|
63
|
+
const row = namedNode.get(fileId, name);
|
|
64
|
+
if (row) {
|
|
65
|
+
nodeId = row.id;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (nodeId === undefined) {
|
|
70
|
+
const row = firstNode.get(fileId);
|
|
71
|
+
nodeId = row?.id;
|
|
72
|
+
}
|
|
73
|
+
if (nodeId !== undefined)
|
|
74
|
+
upd.run(nodeId, edge.id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Pull `from` path and optional named imports out of a raw import statement text. */
|
|
78
|
+
function parseImportSpecifier(text) {
|
|
79
|
+
const fromMatch = text.match(/\bfrom\s+['"]([^'"]+)['"]/) ?? text.match(/require\s*\(\s*['"]([^'"]+)['"]/);
|
|
80
|
+
if (!fromMatch)
|
|
81
|
+
return null;
|
|
82
|
+
const from = fromMatch[1];
|
|
83
|
+
const names = [];
|
|
84
|
+
const brace = text.match(/\{([^}]+)\}/);
|
|
85
|
+
if (brace) {
|
|
86
|
+
for (const part of brace[1].split(",")) {
|
|
87
|
+
const id = part
|
|
88
|
+
.trim()
|
|
89
|
+
.split(/\s+as\s+/i)[0]
|
|
90
|
+
.trim();
|
|
91
|
+
if (id && /^[A-Za-z_$][\w$]*$/.test(id))
|
|
92
|
+
names.push(id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const def = text.match(/\bimport\s+([A-Za-z_$][\w$]*)\s+/);
|
|
96
|
+
if (def && !text.includes("{"))
|
|
97
|
+
names.push(def[1]);
|
|
98
|
+
return { from, names };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a relative/absolute-ish import specifier to a project-relative indexed path.
|
|
102
|
+
*/
|
|
103
|
+
function resolveImportPath(projectPath, srcRel, spec) {
|
|
104
|
+
if (!spec.startsWith(".") && !spec.startsWith("/"))
|
|
105
|
+
return null; // bare package — skip
|
|
106
|
+
const srcDir = path.dirname(path.join(projectPath, srcRel));
|
|
107
|
+
const absBase = path.resolve(srcDir, spec);
|
|
108
|
+
const candidates = [
|
|
109
|
+
absBase,
|
|
110
|
+
absBase.replace(/\.js$/, ".ts"),
|
|
111
|
+
absBase.replace(/\.js$/, ".tsx"),
|
|
112
|
+
absBase.replace(/\.jsx$/, ".tsx"),
|
|
113
|
+
`${absBase}.ts`,
|
|
114
|
+
`${absBase}.tsx`,
|
|
115
|
+
`${absBase}.js`,
|
|
116
|
+
`${absBase}.jsx`,
|
|
117
|
+
`${absBase}.mjs`,
|
|
118
|
+
`${absBase}.cjs`,
|
|
119
|
+
path.join(absBase, "index.ts"),
|
|
120
|
+
path.join(absBase, "index.js"),
|
|
121
|
+
];
|
|
122
|
+
for (const abs of candidates) {
|
|
123
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
|
|
124
|
+
continue;
|
|
125
|
+
return path.relative(projectPath, abs).split("\\").join("/");
|
|
126
|
+
}
|
|
127
|
+
// Fall back without existence check — strip a trailing .js for TS sources.
|
|
128
|
+
let rel = path.relative(projectPath, absBase).split("\\").join("/");
|
|
129
|
+
if (rel.endsWith(".js"))
|
|
130
|
+
rel = rel.slice(0, -3) + ".ts";
|
|
131
|
+
else if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(rel))
|
|
132
|
+
rel = `${rel}.ts`;
|
|
133
|
+
return rel.replace(/^\.\//, "");
|
|
134
|
+
}
|
|
32
135
|
/**
|
|
33
136
|
* Infer a covering artifact's type from its project-relative path.
|
|
34
137
|
* Full glob config lives in lawbook; this is the indexer default so links are
|
|
@@ -98,18 +201,20 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
98
201
|
removed: 0,
|
|
99
202
|
embedder: embedder.id,
|
|
100
203
|
};
|
|
204
|
+
const cfg = loadAffectedConfig(projectPath);
|
|
101
205
|
const existing = new Map();
|
|
102
206
|
for (const row of db.prepare("SELECT id, path, hash FROM files").all()) {
|
|
103
207
|
existing.set(row.path, { id: row.id, hash: row.hash });
|
|
104
208
|
}
|
|
105
209
|
const seen = new Set();
|
|
106
|
-
const insFile = db.prepare("INSERT INTO files(path, hash, lang) VALUES (?, ?, ?)");
|
|
107
|
-
const updFile = db.prepare("UPDATE files SET hash = ?, lang = ? WHERE id = ?");
|
|
210
|
+
const insFile = db.prepare("INSERT INTO files(path, hash, lang, is_test, module) VALUES (?, ?, ?, ?, ?)");
|
|
211
|
+
const updFile = db.prepare("UPDATE files SET hash = ?, lang = ?, is_test = ?, module = ? WHERE id = ?");
|
|
108
212
|
const delNodes = db.prepare("DELETE FROM nodes WHERE file_id = ?");
|
|
109
213
|
const delEdges = db.prepare("DELETE FROM edges WHERE src_file_id = ?");
|
|
110
214
|
const delCoverage = db.prepare("DELETE FROM coverage_links WHERE file_path = ?");
|
|
111
215
|
const insNode = db.prepare(`INSERT INTO nodes(file_id, name, kind, start_line, end_line, start_byte, end_byte, parent_id, signature, body_hash, norm_hash)
|
|
112
216
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
217
|
+
const insMetrics = db.prepare(`INSERT INTO node_metrics(node_id, loc, max_nesting, branches) VALUES (?, ?, ?, ?)`);
|
|
113
218
|
const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line) VALUES (?, ?, ?, ?, ?)`);
|
|
114
219
|
const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
|
|
115
220
|
artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
|
|
@@ -143,15 +248,17 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
143
248
|
continue;
|
|
144
249
|
}
|
|
145
250
|
let fileId;
|
|
251
|
+
const isTest = isTestPath(rel, cfg.testGlobs) ? 1 : 0;
|
|
252
|
+
const mod = inferModule(rel);
|
|
146
253
|
if (prior) {
|
|
147
|
-
updFile.run(hash, lang.id, prior.id);
|
|
254
|
+
updFile.run(hash, lang.id, isTest, mod, prior.id);
|
|
148
255
|
delNodes.run(prior.id);
|
|
149
256
|
delEdges.run(prior.id);
|
|
150
257
|
delCoverage.run(rel);
|
|
151
258
|
fileId = prior.id;
|
|
152
259
|
}
|
|
153
260
|
else {
|
|
154
|
-
fileId = Number(insFile.run(rel, hash, lang.id).lastInsertRowid);
|
|
261
|
+
fileId = Number(insFile.run(rel, hash, lang.id, isTest, mod).lastInsertRowid);
|
|
155
262
|
}
|
|
156
263
|
const { symbols, refs, coverage } = await extract(content, lang);
|
|
157
264
|
const nodeIds = [];
|
|
@@ -159,13 +266,18 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
159
266
|
const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
|
|
160
267
|
const id = Number(insNode.run(fileId, s.name, s.kind, s.startLine, s.endLine, s.startByte, s.endByte, parentId, s.signature, s.bodyHash, s.normHash).lastInsertRowid);
|
|
161
268
|
nodeIds.push(id);
|
|
269
|
+
insMetrics.run(id, s.loc, s.maxNesting, s.branches);
|
|
162
270
|
// embed the node from its name + signature (cheap, meaningful text)
|
|
163
271
|
const vec = await embedder.embed(`${s.kind} ${s.name} ${s.signature ?? ""}`);
|
|
164
272
|
insEmbed.run(id, embedder.dim, embedder.id, toBlob(vec));
|
|
165
273
|
stats.embeddings++;
|
|
166
274
|
}
|
|
275
|
+
// Prefer a real symbol as import owner when the AST leaves imports file-scoped.
|
|
276
|
+
const fileOwner = nodeIds[0] ?? null;
|
|
167
277
|
for (const r of refs) {
|
|
168
|
-
|
|
278
|
+
let srcId = r.ownerIndex !== null ? nodeIds[r.ownerIndex] : null;
|
|
279
|
+
if (srcId === null && r.kind === "import")
|
|
280
|
+
srcId = fileOwner;
|
|
169
281
|
insEdge.run(srcId, fileId, r.name, r.kind, r.line);
|
|
170
282
|
stats.edges++;
|
|
171
283
|
}
|
|
@@ -184,15 +296,17 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
184
296
|
stats.removed++;
|
|
185
297
|
}
|
|
186
298
|
}
|
|
187
|
-
//
|
|
299
|
+
// Prefer same-file callees so colliding names across files do not share one id.
|
|
188
300
|
db.exec(`
|
|
189
301
|
UPDATE edges SET dst_node_id = (
|
|
190
302
|
SELECT n.id FROM nodes n
|
|
191
303
|
WHERE n.name = edges.dst_name
|
|
304
|
+
ORDER BY CASE WHEN n.file_id = edges.src_file_id THEN 0 ELSE 1 END, n.id
|
|
192
305
|
LIMIT 1
|
|
193
306
|
)
|
|
194
307
|
WHERE kind = 'call' AND dst_node_id IS NULL
|
|
195
308
|
`);
|
|
309
|
+
resolveImportEdges(db, projectPath);
|
|
196
310
|
db.prepare("INSERT INTO meta(key, value) VALUES ('indexed_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(new Date().toISOString());
|
|
197
311
|
clearNeedsReindex(db);
|
|
198
312
|
db.exec("COMMIT");
|
|
@@ -17,6 +17,19 @@ export const LANGUAGES = [
|
|
|
17
17
|
callNode: "call",
|
|
18
18
|
callField: "function",
|
|
19
19
|
importNodes: ["import_statement", "import_from_statement"],
|
|
20
|
+
nestingNodes: ["block", "suite"],
|
|
21
|
+
branchNodes: [
|
|
22
|
+
"if_statement",
|
|
23
|
+
"elif_clause",
|
|
24
|
+
"for_statement",
|
|
25
|
+
"while_statement",
|
|
26
|
+
"match_statement",
|
|
27
|
+
"case_clause",
|
|
28
|
+
"conditional_expression",
|
|
29
|
+
"except_clause",
|
|
30
|
+
"with_statement",
|
|
31
|
+
"boolean_operator",
|
|
32
|
+
],
|
|
20
33
|
},
|
|
21
34
|
{
|
|
22
35
|
id: "javascript",
|
|
@@ -30,6 +43,19 @@ export const LANGUAGES = [
|
|
|
30
43
|
callNode: "call_expression",
|
|
31
44
|
callField: "function",
|
|
32
45
|
importNodes: ["import_statement"],
|
|
46
|
+
nestingNodes: ["statement_block", "class_body"],
|
|
47
|
+
branchNodes: [
|
|
48
|
+
"if_statement",
|
|
49
|
+
"else_clause",
|
|
50
|
+
"for_statement",
|
|
51
|
+
"for_in_statement",
|
|
52
|
+
"while_statement",
|
|
53
|
+
"do_statement",
|
|
54
|
+
"switch_case",
|
|
55
|
+
"switch_default",
|
|
56
|
+
"catch_clause",
|
|
57
|
+
"ternary_expression",
|
|
58
|
+
],
|
|
33
59
|
},
|
|
34
60
|
{
|
|
35
61
|
id: "typescript",
|
|
@@ -46,6 +72,19 @@ export const LANGUAGES = [
|
|
|
46
72
|
callNode: "call_expression",
|
|
47
73
|
callField: "function",
|
|
48
74
|
importNodes: ["import_statement"],
|
|
75
|
+
nestingNodes: ["statement_block", "class_body"],
|
|
76
|
+
branchNodes: [
|
|
77
|
+
"if_statement",
|
|
78
|
+
"else_clause",
|
|
79
|
+
"for_statement",
|
|
80
|
+
"for_in_statement",
|
|
81
|
+
"while_statement",
|
|
82
|
+
"do_statement",
|
|
83
|
+
"switch_case",
|
|
84
|
+
"switch_default",
|
|
85
|
+
"catch_clause",
|
|
86
|
+
"ternary_expression",
|
|
87
|
+
],
|
|
49
88
|
},
|
|
50
89
|
];
|
|
51
90
|
const BY_EXT = new Map();
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { openDb, indexExists } from "./db.js";
|
|
4
4
|
import { getEmbedder, fromBlob, cosine } from "./embedder.js";
|
|
5
|
+
import { loadAffectedConfig, filterFilesForTarget, matchGlobalFiles } from "./affected-config.js";
|
|
5
6
|
function requireIndex(projectPath) {
|
|
6
7
|
if (!indexExists(projectPath)) {
|
|
7
8
|
throw new Error("No index found. Build it first with the index_build tool (creates .speclaw/index.db).");
|
|
@@ -164,50 +165,274 @@ export async function recall(projectPath, query, limit = 15) {
|
|
|
164
165
|
db.close();
|
|
165
166
|
}
|
|
166
167
|
}
|
|
168
|
+
const DEFAULT_HARD_LIMIT = 500;
|
|
167
169
|
/**
|
|
168
|
-
*
|
|
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,8 @@ import { defineTool, text } from "../../shared/mcp.js";
|
|
|
3
3
|
import { shouldExpose } from "../../shared/exposure.js";
|
|
4
4
|
import { buildIndex } from "./indexer.js";
|
|
5
5
|
import { explore, search, recall, impact, trace } from "./query.js";
|
|
6
|
+
import { affectedTests } from "./affected.js";
|
|
7
|
+
import { hotspots, coupling } from "./hotspots.js";
|
|
6
8
|
import { startWatch, stopWatch, watchStatus } from "./watcher.js";
|
|
7
9
|
import { visualize } from "./visualize.js";
|
|
8
10
|
// ─── Compass: speclaw's own code-intelligence engine (no external deps) ───
|
|
@@ -23,7 +25,53 @@ export function registerCompass(server, opts = {}) {
|
|
|
23
25
|
add("compass_explore", "Read a symbol's source plus callers and callees. Prefer this before grep or Read.", { projectPath: z.string(), node: z.string() }, async ({ projectPath, node }) => text(explore(projectPath, node)));
|
|
24
26
|
add("compass_search", "Find symbols by name or keyword (substring). Cheaper structural search than grep.", { projectPath: z.string(), query: z.string(), limit: z.number().optional() }, async ({ projectPath, query, limit }) => text(search(projectPath, query, limit ?? 25)));
|
|
25
27
|
add("compass_recall", "Find symbols by meaning via local embeddings. Use when names are unknown.", { projectPath: z.string(), query: z.string(), limit: z.number().optional() }, async ({ projectPath, query, limit }) => text(await recall(projectPath, query, limit ?? 15)));
|
|
26
|
-
add("compass_impact", "
|
|
28
|
+
add("compass_impact", "Blast radius for a symbol or files, grouped by module (not a flat dump).", {
|
|
29
|
+
projectPath: z.string(),
|
|
30
|
+
/** @deprecated Prefer `symbol`. Kept for existing callers. */
|
|
31
|
+
node: z.string().optional(),
|
|
32
|
+
symbol: z.string().optional(),
|
|
33
|
+
files: z.array(z.string()).optional(),
|
|
34
|
+
nodeId: z.number().int().optional(),
|
|
35
|
+
maxDepth: z.number().int().min(1).max(12).optional(),
|
|
36
|
+
edgeKinds: z.array(z.enum(["call", "import"])).optional(),
|
|
37
|
+
target: z.enum(["build", "test", "lint", "any"]).optional(),
|
|
38
|
+
format: z.enum(["grouped", "flat"]).optional(),
|
|
39
|
+
topModules: z.number().int().min(1).max(50).optional(),
|
|
40
|
+
topPerModule: z.number().int().min(1).max(50).optional(),
|
|
41
|
+
}, async (args) => text(impact(args.projectPath, {
|
|
42
|
+
symbol: args.symbol ?? args.node,
|
|
43
|
+
files: args.files,
|
|
44
|
+
nodeId: args.nodeId,
|
|
45
|
+
maxDepth: args.maxDepth ?? 4,
|
|
46
|
+
edgeKinds: args.edgeKinds,
|
|
47
|
+
target: args.target,
|
|
48
|
+
format: args.format ?? "grouped",
|
|
49
|
+
topModules: args.topModules,
|
|
50
|
+
topPerModule: args.topPerModule,
|
|
51
|
+
})));
|
|
52
|
+
add("compass_affected_tests", "Select test files affected by a change; returns a ready-to-run command.", {
|
|
53
|
+
projectPath: z.string(),
|
|
54
|
+
files: z.array(z.string()).optional(),
|
|
55
|
+
symbols: z.array(z.string()).optional(),
|
|
56
|
+
fromDiff: z.string().optional(),
|
|
57
|
+
maxDepth: z.number().int().min(1).max(12).optional(),
|
|
58
|
+
}, async ({ projectPath, files, symbols, fromDiff, maxDepth }) => text(affectedTests(projectPath, { files, symbols, fromDiff, maxDepth })));
|
|
59
|
+
add("compass_hotspots", "Rank files by recent churn and AST complexity; two axes, no magic score.", {
|
|
60
|
+
projectPath: z.string(),
|
|
61
|
+
days: z.number().int().min(1).max(3650).optional(),
|
|
62
|
+
since: z.string().optional(),
|
|
63
|
+
sortBy: z.enum(["churn", "complexity", "combined"]).optional(),
|
|
64
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
65
|
+
}, async ({ projectPath, days, since, sortBy, limit }) => text(hotspots(projectPath, { days, since, sortBy, limit })));
|
|
66
|
+
add("compass_coupling", "Files that co-change with a target; strength, graph edge, and test-pair facts.", {
|
|
67
|
+
projectPath: z.string(),
|
|
68
|
+
file: z.string(),
|
|
69
|
+
days: z.number().int().min(1).max(3650).optional(),
|
|
70
|
+
since: z.string().optional(),
|
|
71
|
+
minShared: z.number().int().min(1).optional(),
|
|
72
|
+
maxFilesPerCommit: z.number().int().min(2).optional(),
|
|
73
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
74
|
+
}, async ({ projectPath, file, days, since, minShared, maxFilesPerCommit, limit }) => text(coupling(projectPath, file, { days, since, minShared, maxFilesPerCommit, limit })));
|
|
27
75
|
add("compass_trace", "Find a call path between two symbols within a depth limit.", {
|
|
28
76
|
projectPath: z.string(),
|
|
29
77
|
from: z.string(),
|
package/dist/shared/exposure.js
CHANGED