@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.
@@ -1,15 +1,20 @@
1
1
  import { explore, search, recall, impact, trace } from "../../modules/compass/query.js";
2
+ import { affectedTests } from "../../modules/compass/affected.js";
3
+ import { hotspots, coupling } from "../../modules/compass/hotspots.js";
4
+ import { list } from "../lib/args.js";
2
5
  import { ui } from "../lib/ui.js";
3
6
  /**
4
7
  * Run a Compass query from the shell — the same surface agents call via MCP.
5
8
  *
6
- * @param cmd - The query verb: `explore`, `search`, `recall`, `impact`, or `trace`.
7
- * @param flags - Parsed flags supplying the positional query arguments in `_`.
9
+ * @param cmd - Query verb: `explore`, `search`, `recall`, `impact`, `trace`,
10
+ * `affected-tests`, `hotspots`, or `coupling`.
11
+ * @param flags - Parsed flags supplying positional args and options in `_`.
8
12
  * @throws Exits the process with code 1 on missing arguments or query errors.
9
13
  */
10
14
  export async function runQuery(cmd, flags) {
11
15
  const cwd = process.cwd();
12
16
  const args = flags._;
17
+ const asJson = Boolean(flags.json);
13
18
  try {
14
19
  switch (cmd) {
15
20
  case "explore": {
@@ -41,9 +46,119 @@ export async function runQuery(cmd, flags) {
41
46
  return;
42
47
  }
43
48
  case "impact": {
44
- const nodes = impact(cwd, need(args[0], "impact <node>"));
45
- ui.heading(`Blast radius: ${nodes.length} dependent(s)`);
46
- nodes.forEach((n) => ui.info(`depth ${n.depth}: ${n.name} (${n.file}:${n.line})`));
49
+ const fileList = list(flags.file);
50
+ if (!args[0] && fileList.length === 0) {
51
+ need(undefined, "impact <node> | impact --file <path>");
52
+ }
53
+ const format = flags.flat ? "flat" : "grouped";
54
+ const result = impact(cwd, {
55
+ symbol: args[0],
56
+ files: fileList.length ? fileList : undefined,
57
+ maxDepth: flags.depth ? Number(flags.depth) : 4,
58
+ format,
59
+ target: typeof flags.target === "string" ? flags.target : "any",
60
+ });
61
+ if (asJson) {
62
+ console.log(JSON.stringify(result, null, 2));
63
+ return;
64
+ }
65
+ if (result.global) {
66
+ ui.heading(`Blast radius: repo-wide`);
67
+ ui.warn(result.global.reason);
68
+ result.warnings.forEach((w) => ui.warn(w));
69
+ return;
70
+ }
71
+ ui.heading(`Blast radius: ${result.totals.nodes} node(s) in ${result.totals.modules} module(s)` +
72
+ (result.limits.truncated ? " (truncated)" : ""));
73
+ if (result.target.kind === "symbol" && result.target.definitions.length > 1) {
74
+ ui.warn(`"${result.target.symbol}" is defined in ${result.target.definitions.length} places; impact is the union`);
75
+ }
76
+ if (format === "flat" && result.nodes) {
77
+ result.nodes.forEach((n) => ui.info(`depth ${n.depth} [${n.resolution}]: ${n.name} (${n.file}:${n.line})`));
78
+ }
79
+ else {
80
+ for (const m of result.modules) {
81
+ ui.heading(`${m.module} — ${m.nodes} node(s), ${m.files} file(s), min depth ${m.minDepth}`);
82
+ m.top.forEach((n) => ui.info(`depth ${n.depth} [${n.resolution}]: ${n.name} (${n.file}:${n.line})`));
83
+ }
84
+ }
85
+ if (result.resolution.byName > 0) {
86
+ ui.warn(`${result.resolution.byName} result(s) resolved by name (possible collisions)`);
87
+ }
88
+ result.warnings.forEach((w) => ui.warn(w));
89
+ return;
90
+ }
91
+ case "affected-tests": {
92
+ const files = list(flags.file);
93
+ const fromDiff = typeof flags["from-diff"] === "string"
94
+ ? flags["from-diff"]
95
+ : flags["from-diff"] === true
96
+ ? "HEAD"
97
+ : undefined;
98
+ if (files.length === 0 && !fromDiff && !args[0]) {
99
+ need(undefined, "affected-tests --file <path> | --from-diff <ref>");
100
+ }
101
+ const result = affectedTests(cwd, {
102
+ files: files.length ? files : undefined,
103
+ symbols: args[0] ? [args[0]] : undefined,
104
+ fromDiff,
105
+ });
106
+ if (asJson) {
107
+ console.log(JSON.stringify(result, null, 2));
108
+ return;
109
+ }
110
+ ui.heading(`Affected tests (${result.mode}): ${result.tests.length} file(s) — skipped ${result.skipped.files} (${result.skipped.percent}%)`);
111
+ ui.info(result.reason);
112
+ result.tests.forEach((t) => ui.info(t.file));
113
+ ui.heading("Command");
114
+ console.log(result.command);
115
+ result.warnings.forEach((w) => ui.warn(w));
116
+ return;
117
+ }
118
+ case "hotspots": {
119
+ const sortRaw = typeof flags.sort === "string" ? flags.sort : undefined;
120
+ const sortBy = sortRaw === "churn" || sortRaw === "complexity" || sortRaw === "combined"
121
+ ? sortRaw
122
+ : "combined";
123
+ const result = hotspots(cwd, {
124
+ days: flags.days ? Number(flags.days) : undefined,
125
+ since: typeof flags.since === "string" ? flags.since : undefined,
126
+ sortBy,
127
+ limit: flags.limit ? Number(flags.limit) : undefined,
128
+ });
129
+ if (asJson) {
130
+ console.log(JSON.stringify(result, null, 2));
131
+ return;
132
+ }
133
+ ui.heading(`Hotspots (${result.window.label}, sort=${result.sortBy}): ${result.hotspots.length}`);
134
+ for (const h of result.hotspots) {
135
+ const health = h.health
136
+ ? `branches=${h.health.worstBranches} nest=${h.health.worstNesting} loc=${h.health.worstLoc}`
137
+ : "health=n/a";
138
+ ui.info(`${h.file} commits=${h.activity.commits} authors=${h.activity.authors} ${health}`);
139
+ }
140
+ result.warnings.forEach((w) => ui.warn(w));
141
+ return;
142
+ }
143
+ case "coupling": {
144
+ const file = need(args[0], "coupling <file>");
145
+ const result = coupling(cwd, file, {
146
+ days: flags.days ? Number(flags.days) : undefined,
147
+ since: typeof flags.since === "string" ? flags.since : undefined,
148
+ minShared: flags["min-shared"] ? Number(flags["min-shared"]) : undefined,
149
+ maxFilesPerCommit: flags["max-files"] ? Number(flags["max-files"]) : undefined,
150
+ limit: flags.limit ? Number(flags.limit) : undefined,
151
+ });
152
+ if (asJson) {
153
+ console.log(JSON.stringify(result, null, 2));
154
+ return;
155
+ }
156
+ ui.heading(`Coupling for ${result.file} (${result.window.label}): ${result.partners.length} partner(s)`);
157
+ ui.info(`scanned=${result.diagnostics.commitsScanned} skippedTooLarge=${result.diagnostics.skippedTooLarge}`);
158
+ for (const p of result.partners) {
159
+ ui.info(`${p.file} both=${p.both} strength=${p.strength.toFixed(3)} in_graph=${p.inGraph} isTestPair=${p.isTestPair}`);
160
+ }
161
+ result.warnings.forEach((w) => ui.warn(w));
47
162
  return;
48
163
  }
49
164
  case "trace": {
@@ -108,6 +108,23 @@ 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
+ },
120
+ {
121
+ version: "0.3.11",
122
+ describe: "Hotspots + coupling: schema 8 node_metrics, 90d activity window",
123
+ agentPrompt: "- Mention `compass_hotspots` / `speclaw hotspots` and `compass_coupling` / `speclaw coupling` " +
124
+ "(activity × AST health; Jaccard strength + in_graph + isTestPair). Compass schema is now 8 " +
125
+ "(`node_metrics`) — reindex with `speclaw index`. Default history window is 90 days.\n" +
126
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
127
+ },
111
128
  ];
112
129
  /**
113
130
  * Update speclaw and bring the current project up to date without a full re-init:
package/dist/cli/index.js CHANGED
@@ -23,7 +23,10 @@ Compass (code intelligence — the same surface agents use via MCP)
23
23
  explore <node> A node's source + callers/callees
24
24
  search <query> Find nodes by name/keyword
25
25
  recall "<query>" Find code by meaning (semantic)
26
- impact <node> Blast radius: everything that (transitively) calls it
26
+ impact <node> Blast radius (grouped by module; --flat / --json)
27
+ affected-tests Tests affected by a change (--file / --from-diff / --json)
28
+ hotspots Rank files by recent churn × AST complexity (--json / --sort)
29
+ coupling <file> Temporal co-change partners for a file (--json)
27
30
  trace <from> <to> A call path between two nodes
28
31
  visualize [node] Interactive HTML graph → .speclaw/graph.html
29
32
 
@@ -50,9 +53,9 @@ Other
50
53
  // Commands that open with the one-line branded header. These are the
51
54
  // interactive, human-facing commands whose stdout is prose. Deliberately
52
55
  // excluded: `version`/`--version`/`-v` (bare scriptable value), the Compass
53
- // query family (`explore`/`search`/`recall`/`impact`/`trace`, machine-consumed
54
- // output), `mcp` (a long-running stdio server), and `init` (already opens with
55
- // the fuller `banner()`).
56
+ // query family (`explore`/`search`/`recall`/`impact`/`trace`/`affected-tests`/
57
+ // `hotspots`/`coupling`, machine-consumed output), `mcp` (a long-running stdio
58
+ // server), and `init` (already opens with the fuller `banner()`).
56
59
  const HEADER_COMMANDS = new Set([
57
60
  undefined,
58
61
  "help",
@@ -126,6 +129,9 @@ async function dispatch(cmd, flags) {
126
129
  case "recall":
127
130
  case "impact":
128
131
  case "trace":
132
+ case "affected-tests":
133
+ case "hotspots":
134
+ case "coupling":
129
135
  return (await import("./commands/query.js")).runQuery(cmd, flags);
130
136
  case "visualize":
131
137
  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
+ }