@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
|
@@ -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,
|
|
@@ -30,6 +33,13 @@ CREATE TABLE IF NOT EXISTS nodes (
|
|
|
30
33
|
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
|
|
31
34
|
CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_id);
|
|
32
35
|
CREATE INDEX IF NOT EXISTS idx_nodes_norm_hash ON nodes(norm_hash);
|
|
36
|
+
-- node_metrics: AST health frames (LOC / nesting / branches) per definition.
|
|
37
|
+
CREATE TABLE IF NOT EXISTS node_metrics (
|
|
38
|
+
node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
|
39
|
+
loc INTEGER NOT NULL,
|
|
40
|
+
max_nesting INTEGER NOT NULL,
|
|
41
|
+
branches INTEGER NOT NULL
|
|
42
|
+
);
|
|
33
43
|
-- edges: a reference from one node to a named target, resolved lazily.
|
|
34
44
|
CREATE TABLE IF NOT EXISTS edges (
|
|
35
45
|
id INTEGER PRIMARY KEY,
|
|
@@ -101,7 +111,7 @@ CREATE INDEX IF NOT EXISTS idx_anchors_symbol ON spec_anchors(symbol_name);
|
|
|
101
111
|
CREATE INDEX IF NOT EXISTS idx_anchors_node ON spec_anchors(node_id);
|
|
102
112
|
`;
|
|
103
113
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
104
|
-
export const SCHEMA_VERSION = "
|
|
114
|
+
export const SCHEMA_VERSION = "8";
|
|
105
115
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
106
116
|
function readSchemaVersion(db) {
|
|
107
117
|
try {
|
|
@@ -127,8 +137,16 @@ function isStale(db) {
|
|
|
127
137
|
return false;
|
|
128
138
|
if (readSchemaVersion(db) !== SCHEMA_VERSION)
|
|
129
139
|
return true;
|
|
130
|
-
const
|
|
131
|
-
|
|
140
|
+
const edgeCols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
|
|
141
|
+
if (!edgeCols.includes("src_node_id") || !edgeCols.includes("dst_node_id"))
|
|
142
|
+
return true;
|
|
143
|
+
const fileCols = db.prepare("PRAGMA table_info(files)").all().map((c) => c.name);
|
|
144
|
+
if (!fileCols.includes("is_test") || !fileCols.includes("module"))
|
|
145
|
+
return true;
|
|
146
|
+
const hasMetrics = db
|
|
147
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'node_metrics'")
|
|
148
|
+
.get();
|
|
149
|
+
return !hasMetrics;
|
|
132
150
|
}
|
|
133
151
|
/** Drop every table (children first) so the current schema can be recreated cleanly. */
|
|
134
152
|
function resetSchema(db) {
|
|
@@ -138,6 +156,7 @@ function resetSchema(db) {
|
|
|
138
156
|
DROP TABLE IF EXISTS git_history_cache;
|
|
139
157
|
DROP TABLE IF EXISTS node_embeddings;
|
|
140
158
|
DROP TABLE IF EXISTS edges;
|
|
159
|
+
DROP TABLE IF EXISTS node_metrics;
|
|
141
160
|
DROP TABLE IF EXISTS nodes;
|
|
142
161
|
DROP TABLE IF EXISTS files;
|
|
143
162
|
DROP TABLE IF EXISTS meta;
|
|
@@ -37,6 +37,46 @@ function calleeName(node, lang) {
|
|
|
37
37
|
function signatureOf(node) {
|
|
38
38
|
return node.text.split("\n")[0].trim().slice(0, 200);
|
|
39
39
|
}
|
|
40
|
+
const BOOL_OPS = new Set(["&&", "||", "and", "or"]);
|
|
41
|
+
/**
|
|
42
|
+
* Compute LOC / max nesting / branch counts for a definition subtree.
|
|
43
|
+
* Nesting depth is relative to the definition body (starts at 0).
|
|
44
|
+
*/
|
|
45
|
+
export function metricsOf(defNode, lang) {
|
|
46
|
+
const nesting = new Set(lang.nestingNodes);
|
|
47
|
+
const branchesSet = new Set(lang.branchNodes);
|
|
48
|
+
let maxNesting = 0;
|
|
49
|
+
let branches = 0;
|
|
50
|
+
const walk = (node, depth) => {
|
|
51
|
+
const nestHere = nesting.has(node.type);
|
|
52
|
+
const nextDepth = nestHere ? depth + 1 : depth;
|
|
53
|
+
if (nestHere)
|
|
54
|
+
maxNesting = Math.max(maxNesting, nextDepth);
|
|
55
|
+
if (branchesSet.has(node.type)) {
|
|
56
|
+
branches++;
|
|
57
|
+
}
|
|
58
|
+
else if (node.type === "binary_expression") {
|
|
59
|
+
const op = node.childForFieldName("operator")?.text ?? "";
|
|
60
|
+
if (BOOL_OPS.has(op))
|
|
61
|
+
branches++;
|
|
62
|
+
}
|
|
63
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
64
|
+
const child = node.child(i);
|
|
65
|
+
if (child)
|
|
66
|
+
walk(child, nextDepth);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
for (let i = 0; i < defNode.childCount; i++) {
|
|
70
|
+
const child = defNode.child(i);
|
|
71
|
+
if (child)
|
|
72
|
+
walk(child, 0);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
loc: defNode.endPosition.row - defNode.startPosition.row + 1,
|
|
76
|
+
maxNesting,
|
|
77
|
+
branches,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
40
80
|
/** Parse Covers:/Needs: directives from a comment node's text. */
|
|
41
81
|
function parseCoverageComment(node, ownerIndex) {
|
|
42
82
|
const text = node.text;
|
|
@@ -105,6 +145,7 @@ export async function extract(source, lang) {
|
|
|
105
145
|
const name = defName(node);
|
|
106
146
|
if (name) {
|
|
107
147
|
const index = symbols.length;
|
|
148
|
+
const health = metricsOf(node, lang);
|
|
108
149
|
symbols.push({
|
|
109
150
|
name,
|
|
110
151
|
kind: kinds.get(node.type),
|
|
@@ -116,6 +157,9 @@ export async function extract(source, lang) {
|
|
|
116
157
|
signature: signatureOf(node),
|
|
117
158
|
bodyHash: rawHash(source, node.startIndex, node.endIndex),
|
|
118
159
|
normHash: structuralHash(node),
|
|
160
|
+
loc: health.loc,
|
|
161
|
+
maxNesting: health.maxNesting,
|
|
162
|
+
branches: health.branches,
|
|
119
163
|
});
|
|
120
164
|
nextOwner = index;
|
|
121
165
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { churn, coChanges, headSha, } from "../../shared/git-history.js";
|
|
1
|
+
import { churn, coChanges, fileActivity, headSha, } from "../../shared/git-history.js";
|
|
2
2
|
import { openDb } from "./db.js";
|
|
3
3
|
/**
|
|
4
4
|
* Look up a cached payload valid at the current HEAD, or compute it and store it.
|
|
@@ -52,6 +52,19 @@ export function cachedChurn(projectPath, opts = {}) {
|
|
|
52
52
|
return { shallow: parsed.shallow, byPath: new Map(parsed.byPath) };
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* {@link fileActivity}, memoized in the Compass index until `HEAD` moves.
|
|
57
|
+
*/
|
|
58
|
+
export function cachedFileActivity(projectPath, opts = {}) {
|
|
59
|
+
const key = `fileActivity:${JSON.stringify({ since: opts.since ?? null, pathspec: opts.pathspec ?? null })}`;
|
|
60
|
+
return readThrough(projectPath, headSha(projectPath), key, () => fileActivity(projectPath, opts), (value) => JSON.stringify({
|
|
61
|
+
shallow: value.shallow,
|
|
62
|
+
byPath: [...value.byPath],
|
|
63
|
+
}), (payload) => {
|
|
64
|
+
const parsed = JSON.parse(payload);
|
|
65
|
+
return { shallow: parsed.shallow, byPath: new Map(parsed.byPath) };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
55
68
|
/**
|
|
56
69
|
* {@link coChanges}, memoized in the Compass index until `HEAD` moves.
|
|
57
70
|
*
|
|
@@ -60,6 +73,10 @@ export function cachedChurn(projectPath, opts = {}) {
|
|
|
60
73
|
* @returns The co-change pairs and the shallow marker, cached per HEAD.
|
|
61
74
|
*/
|
|
62
75
|
export function cachedCoChanges(projectPath, opts = {}) {
|
|
63
|
-
const key = `coChanges:${JSON.stringify({
|
|
76
|
+
const key = `coChanges:${JSON.stringify({
|
|
77
|
+
since: opts.since ?? null,
|
|
78
|
+
minSupport: opts.minSupport ?? null,
|
|
79
|
+
maxFilesPerCommit: opts.maxFilesPerCommit ?? null,
|
|
80
|
+
})}`;
|
|
64
81
|
return readThrough(projectPath, headSha(projectPath), key, () => coChanges(projectPath, opts), (value) => JSON.stringify(value), (payload) => JSON.parse(payload));
|
|
65
82
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { openDb } from "./db.js";
|
|
2
|
+
import { cachedCoChanges, cachedFileActivity } from "./git-history-cache.js";
|
|
3
|
+
import { jaccardStrength } from "../../shared/git-history.js";
|
|
4
|
+
/** Default history window for hotspot / coupling ranking. */
|
|
5
|
+
export const DEFAULT_WINDOW_DAYS = 90;
|
|
6
|
+
/** Default max files in a commit before coupling discards it. */
|
|
7
|
+
export const DEFAULT_MAX_FILES_PER_COMMIT = 50;
|
|
8
|
+
/** Default minimum shared commits for a coupling pair. */
|
|
9
|
+
export const DEFAULT_MIN_SHARED = 2;
|
|
10
|
+
/** ISO date string for `git --since` N days ago (UTC calendar day). */
|
|
11
|
+
export function sinceDaysAgo(days, now = new Date()) {
|
|
12
|
+
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
13
|
+
d.setUTCDate(d.getUTCDate() - days);
|
|
14
|
+
return d.toISOString().slice(0, 10);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Combined sort heuristic: activity commits × (1 + worstBranches + worstNesting/2).
|
|
18
|
+
* Axes remain on each entry; this score is only for ordering.
|
|
19
|
+
*/
|
|
20
|
+
function combinedScore(activity, health) {
|
|
21
|
+
const complexity = health ? 1 + health.worstBranches + health.worstNesting / 2 : 1;
|
|
22
|
+
return activity.commits * complexity;
|
|
23
|
+
}
|
|
24
|
+
function loadFileHealth(projectPath) {
|
|
25
|
+
const db = openDb(projectPath);
|
|
26
|
+
try {
|
|
27
|
+
const rows = db
|
|
28
|
+
.prepare(`SELECT f.path AS path,
|
|
29
|
+
COUNT(n.id) AS symbols,
|
|
30
|
+
COALESCE(MAX(m.loc), 0) AS worst_loc,
|
|
31
|
+
COALESCE(MAX(m.max_nesting), 0) AS worst_nesting,
|
|
32
|
+
COALESCE(MAX(m.branches), 0) AS worst_branches
|
|
33
|
+
FROM files f
|
|
34
|
+
LEFT JOIN nodes n ON n.file_id = f.id
|
|
35
|
+
LEFT JOIN node_metrics m ON m.node_id = n.id
|
|
36
|
+
GROUP BY f.id`)
|
|
37
|
+
.all();
|
|
38
|
+
const map = new Map();
|
|
39
|
+
for (const r of rows) {
|
|
40
|
+
map.set(r.path, {
|
|
41
|
+
worstLoc: Number(r.worst_loc),
|
|
42
|
+
worstNesting: Number(r.worst_nesting),
|
|
43
|
+
worstBranches: Number(r.worst_branches),
|
|
44
|
+
symbols: Number(r.symbols),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return map;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
db.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Rank files by git activity × AST health for agent attention.
|
|
55
|
+
*
|
|
56
|
+
* @param projectPath - Project root with `.speclaw/index.db` and git history.
|
|
57
|
+
* @param opts - Window, sort, and result limit.
|
|
58
|
+
*/
|
|
59
|
+
export function hotspots(projectPath, opts = {}) {
|
|
60
|
+
const days = opts.days ?? DEFAULT_WINDOW_DAYS;
|
|
61
|
+
const since = opts.since ?? sinceDaysAgo(days);
|
|
62
|
+
const sortBy = opts.sortBy ?? "combined";
|
|
63
|
+
const limit = opts.limit ?? 25;
|
|
64
|
+
const warnings = [];
|
|
65
|
+
const activity = cachedFileActivity(projectPath, { since });
|
|
66
|
+
if (activity.shallow) {
|
|
67
|
+
warnings.push("Repository is a shallow clone; history may be truncated.");
|
|
68
|
+
}
|
|
69
|
+
const healthByFile = loadFileHealth(projectPath);
|
|
70
|
+
const entries = [];
|
|
71
|
+
for (const [file, act] of activity.byPath) {
|
|
72
|
+
if (act.commits <= 0)
|
|
73
|
+
continue;
|
|
74
|
+
const health = healthByFile.get(file) ?? null;
|
|
75
|
+
entries.push({
|
|
76
|
+
file,
|
|
77
|
+
activity: {
|
|
78
|
+
commits: act.commits,
|
|
79
|
+
linesAdded: act.linesAdded,
|
|
80
|
+
linesDeleted: act.linesDeleted,
|
|
81
|
+
authors: act.authors,
|
|
82
|
+
},
|
|
83
|
+
health,
|
|
84
|
+
combinedScore: combinedScore(act, health),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const rank = (a, b) => {
|
|
88
|
+
if (sortBy === "churn") {
|
|
89
|
+
return (b.activity.commits - a.activity.commits ||
|
|
90
|
+
b.activity.linesAdded +
|
|
91
|
+
b.activity.linesDeleted -
|
|
92
|
+
(a.activity.linesAdded + a.activity.linesDeleted) ||
|
|
93
|
+
a.file.localeCompare(b.file));
|
|
94
|
+
}
|
|
95
|
+
if (sortBy === "complexity") {
|
|
96
|
+
const bw = b.health?.worstBranches ?? -1;
|
|
97
|
+
const aw = a.health?.worstBranches ?? -1;
|
|
98
|
+
return (bw - aw ||
|
|
99
|
+
(b.health?.worstNesting ?? -1) - (a.health?.worstNesting ?? -1) ||
|
|
100
|
+
(b.health?.worstLoc ?? -1) - (a.health?.worstLoc ?? -1) ||
|
|
101
|
+
a.file.localeCompare(b.file));
|
|
102
|
+
}
|
|
103
|
+
return b.combinedScore - a.combinedScore || a.file.localeCompare(b.file);
|
|
104
|
+
};
|
|
105
|
+
entries.sort(rank);
|
|
106
|
+
return {
|
|
107
|
+
window: { days, since, label: `last ${days} days (since ${since})` },
|
|
108
|
+
sortBy,
|
|
109
|
+
hotspots: entries.slice(0, limit),
|
|
110
|
+
diagnostics: {
|
|
111
|
+
filesWithActivity: entries.length,
|
|
112
|
+
indexedHealthFiles: [...healthByFile.keys()].length,
|
|
113
|
+
},
|
|
114
|
+
warnings,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function fileMeta(projectPath, paths) {
|
|
118
|
+
const db = openDb(projectPath);
|
|
119
|
+
try {
|
|
120
|
+
const map = new Map();
|
|
121
|
+
if (paths.length === 0)
|
|
122
|
+
return map;
|
|
123
|
+
const placeholders = paths.map(() => "?").join(",");
|
|
124
|
+
const rows = db
|
|
125
|
+
.prepare(`SELECT id, path, is_test FROM files WHERE path IN (${placeholders})`)
|
|
126
|
+
.all(...paths);
|
|
127
|
+
for (const r of rows)
|
|
128
|
+
map.set(r.path, { isTest: r.is_test === 1, id: r.id });
|
|
129
|
+
return map;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
db.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** True when any call/import edge links symbols in the two files (either direction). */
|
|
136
|
+
function pairInGraph(projectPath, a, b) {
|
|
137
|
+
const db = openDb(projectPath);
|
|
138
|
+
try {
|
|
139
|
+
const row = db
|
|
140
|
+
.prepare(`SELECT 1 AS ok
|
|
141
|
+
FROM edges e
|
|
142
|
+
JOIN files sf ON sf.id = e.src_file_id
|
|
143
|
+
JOIN nodes dn ON dn.id = e.dst_node_id
|
|
144
|
+
JOIN files df ON df.id = dn.file_id
|
|
145
|
+
WHERE e.kind IN ('call', 'import')
|
|
146
|
+
AND ((sf.path = ? AND df.path = ?) OR (sf.path = ? AND df.path = ?))
|
|
147
|
+
LIMIT 1`)
|
|
148
|
+
.get(a, b, b, a);
|
|
149
|
+
if (row)
|
|
150
|
+
return true;
|
|
151
|
+
// Name-only imports: dst_node_id NULL — check import edge text contains other path basename loosely via file paths of same module is hard;
|
|
152
|
+
// also match unresolved edges where dst resolves by file path of an indexed import target is out of scope.
|
|
153
|
+
// Fallback: any edge from a whose dst_name matches a symbol defined in b (or reverse).
|
|
154
|
+
const byName = db
|
|
155
|
+
.prepare(`SELECT 1 AS ok
|
|
156
|
+
FROM edges e
|
|
157
|
+
JOIN files sf ON sf.id = e.src_file_id
|
|
158
|
+
JOIN nodes dn ON dn.name = e.dst_name
|
|
159
|
+
JOIN files df ON df.id = dn.file_id
|
|
160
|
+
WHERE e.dst_node_id IS NULL
|
|
161
|
+
AND e.kind IN ('call', 'import')
|
|
162
|
+
AND ((sf.path = ? AND df.path = ?) OR (sf.path = ? AND df.path = ?))
|
|
163
|
+
LIMIT 1`)
|
|
164
|
+
.get(a, b, b, a);
|
|
165
|
+
return Boolean(byName);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
db.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Temporal coupling partners for a seed file, with graph contrast facts.
|
|
173
|
+
*/
|
|
174
|
+
export function coupling(projectPath, file, opts = {}) {
|
|
175
|
+
const days = opts.days ?? DEFAULT_WINDOW_DAYS;
|
|
176
|
+
const since = opts.since ?? sinceDaysAgo(days);
|
|
177
|
+
const minShared = opts.minShared ?? DEFAULT_MIN_SHARED;
|
|
178
|
+
const maxFilesPerCommit = opts.maxFilesPerCommit ?? DEFAULT_MAX_FILES_PER_COMMIT;
|
|
179
|
+
const limit = opts.limit ?? 25;
|
|
180
|
+
const warnings = [];
|
|
181
|
+
const rel = file.replace(/^\.\//, "");
|
|
182
|
+
const co = cachedCoChanges(projectPath, {
|
|
183
|
+
since,
|
|
184
|
+
minSupport: minShared,
|
|
185
|
+
maxFilesPerCommit,
|
|
186
|
+
});
|
|
187
|
+
if (co.shallow) {
|
|
188
|
+
warnings.push("Repository is a shallow clone; history may be truncated.");
|
|
189
|
+
}
|
|
190
|
+
const activity = cachedFileActivity(projectPath, { since });
|
|
191
|
+
const commitsSelf = activity.byPath.get(rel)?.commits ?? 0;
|
|
192
|
+
const partnersRaw = [];
|
|
193
|
+
for (const p of co.pairs) {
|
|
194
|
+
if (p.a === rel)
|
|
195
|
+
partnersRaw.push({ other: p.b, both: p.count });
|
|
196
|
+
else if (p.b === rel)
|
|
197
|
+
partnersRaw.push({ other: p.a, both: p.count });
|
|
198
|
+
}
|
|
199
|
+
const paths = [rel, ...partnersRaw.map((p) => p.other)];
|
|
200
|
+
const meta = fileMeta(projectPath, paths);
|
|
201
|
+
const selfTest = meta.get(rel)?.isTest ?? false;
|
|
202
|
+
const partners = partnersRaw
|
|
203
|
+
.map(({ other, both }) => {
|
|
204
|
+
const commitsOther = activity.byPath.get(other)?.commits ?? 0;
|
|
205
|
+
const otherTest = meta.get(other)?.isTest ?? false;
|
|
206
|
+
return {
|
|
207
|
+
file: other,
|
|
208
|
+
both,
|
|
209
|
+
commitsSelf,
|
|
210
|
+
commitsOther,
|
|
211
|
+
strength: jaccardStrength(both, commitsSelf, commitsOther),
|
|
212
|
+
inGraph: pairInGraph(projectPath, rel, other),
|
|
213
|
+
isTestPair: selfTest !== otherTest && (selfTest || otherTest),
|
|
214
|
+
};
|
|
215
|
+
})
|
|
216
|
+
.sort((a, b) => b.strength - a.strength || b.both - a.both || a.file.localeCompare(b.file))
|
|
217
|
+
.slice(0, limit);
|
|
218
|
+
return {
|
|
219
|
+
file: rel,
|
|
220
|
+
window: { days, since, label: `last ${days} days (since ${since})` },
|
|
221
|
+
partners,
|
|
222
|
+
diagnostics: {
|
|
223
|
+
commitsScanned: co.commitsScanned ?? 0,
|
|
224
|
+
skippedTooLarge: co.skippedTooLarge ?? 0,
|
|
225
|
+
maxFilesPerCommit,
|
|
226
|
+
minShared,
|
|
227
|
+
},
|
|
228
|
+
warnings,
|
|
229
|
+
};
|
|
230
|
+
}
|