@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.
@@ -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 = "6";
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 cols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
131
- return !cols.includes("src_node_id") || !cols.includes("dst_node_id");
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({ since: opts.since ?? null, minSupport: opts.minSupport ?? null })}`;
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
+ }