@esneiderbravo/speclaw 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -131,6 +131,8 @@ speclaw budget # human table
131
131
  speclaw budget --json # machine-readable; used by the suite gate
132
132
  speclaw coverage # requirement → impl → test coverage (TAP / table)
133
133
  speclaw coverage --json # machine-readable coverage report
134
+ speclaw drift # sealed spec ↔ code drift (default --fail-on semantic)
135
+ speclaw drift --reseal # photograph current bodies into lawbook/anchors/
134
136
  speclaw init --minimal # omit setup/lifecycle MCP tools from registration
135
137
  ```
136
138
 
@@ -0,0 +1,39 @@
1
+ import { ui } from "../lib/ui.js";
2
+ import { buildDriftReport, parseFailOn, renderDriftAgent, renderDriftTable, } from "../../modules/lawbook/drift.js";
3
+ /**
4
+ * Report deterministic spec↔code drift, or reseal anchors from current bodies.
5
+ *
6
+ * Flags: `--json`, `--capability <name>`, `--fail-on <level>`, `--reverse`,
7
+ * `--reseal`, `--explain`. Default `--fail-on semantic`. Exit 0/1/2.
8
+ */
9
+ export async function runDrift(flags) {
10
+ const cwd = process.cwd();
11
+ const failOn = parseFailOn(flags["fail-on"]);
12
+ if (failOn === null) {
13
+ ui.err(`--fail-on must be none, cosmetic, semantic, or any.`);
14
+ process.exitCode = 2;
15
+ return;
16
+ }
17
+ const capability = typeof flags.capability === "string" ? flags.capability : undefined;
18
+ const report = buildDriftReport(cwd, {
19
+ capability,
20
+ failOn,
21
+ reverse: Boolean(flags.reverse),
22
+ reseal: Boolean(flags.reseal),
23
+ });
24
+ if (flags.json) {
25
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
26
+ }
27
+ else if (!process.stdout.isTTY) {
28
+ process.stdout.write(renderDriftAgent(report) + "\n");
29
+ }
30
+ else {
31
+ ui.heading("speclaw drift");
32
+ console.log(renderDriftTable(report));
33
+ if (flags.explain) {
34
+ ui.plain();
35
+ console.log(renderDriftAgent(report, 50));
36
+ }
37
+ }
38
+ process.exitCode = report.summary.exitCode;
39
+ }
@@ -56,6 +56,13 @@ export async function runSpec(flags) {
56
56
  const r = specArchive(cwd, req(change, "spec archive <change>"), today());
57
57
  ui.ok(`archived to ${r.archivedTo} (${r.promoted.length} spec(s) promoted)`);
58
58
  r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
59
+ for (const s of r.seals) {
60
+ const msg = `sealed ${s.capability}: ${s.unique} unique / ${s.ambiguous} ambiguous / ${s.unresolved} unresolved → ${s.path}`;
61
+ if (s.warned)
62
+ ui.warn(msg + " (no resolvable anchors)");
63
+ else
64
+ ui.info(msg);
65
+ }
59
66
  return;
60
67
  }
61
68
  default:
@@ -1,15 +1,19 @@
1
1
  import { explore, search, recall, impact, trace } from "../../modules/compass/query.js";
2
+ import { affectedTests } from "../../modules/compass/affected.js";
3
+ import { list } from "../lib/args.js";
2
4
  import { ui } from "../lib/ui.js";
3
5
  /**
4
6
  * Run a Compass query from the shell — the same surface agents call via MCP.
5
7
  *
6
- * @param cmd - The query verb: `explore`, `search`, `recall`, `impact`, or `trace`.
7
- * @param flags - Parsed flags supplying the positional query arguments in `_`.
8
+ * @param cmd - Query verb: `explore`, `search`, `recall`, `impact`, `trace`, or
9
+ * `affected-tests`.
10
+ * @param flags - Parsed flags supplying positional args and options in `_`.
8
11
  * @throws Exits the process with code 1 on missing arguments or query errors.
9
12
  */
10
13
  export async function runQuery(cmd, flags) {
11
14
  const cwd = process.cwd();
12
15
  const args = flags._;
16
+ const asJson = Boolean(flags.json);
13
17
  try {
14
18
  switch (cmd) {
15
19
  case "explore": {
@@ -41,9 +45,73 @@ export async function runQuery(cmd, flags) {
41
45
  return;
42
46
  }
43
47
  case "impact": {
44
- const 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})`));
48
+ const fileList = list(flags.file);
49
+ if (!args[0] && fileList.length === 0) {
50
+ need(undefined, "impact <node> | impact --file <path>");
51
+ }
52
+ const format = flags.flat ? "flat" : "grouped";
53
+ const result = impact(cwd, {
54
+ symbol: args[0],
55
+ files: fileList.length ? fileList : undefined,
56
+ maxDepth: flags.depth ? Number(flags.depth) : 4,
57
+ format,
58
+ target: typeof flags.target === "string" ? flags.target : "any",
59
+ });
60
+ if (asJson) {
61
+ console.log(JSON.stringify(result, null, 2));
62
+ return;
63
+ }
64
+ if (result.global) {
65
+ ui.heading(`Blast radius: repo-wide`);
66
+ ui.warn(result.global.reason);
67
+ result.warnings.forEach((w) => ui.warn(w));
68
+ return;
69
+ }
70
+ ui.heading(`Blast radius: ${result.totals.nodes} node(s) in ${result.totals.modules} module(s)` +
71
+ (result.limits.truncated ? " (truncated)" : ""));
72
+ if (result.target.kind === "symbol" && result.target.definitions.length > 1) {
73
+ ui.warn(`"${result.target.symbol}" is defined in ${result.target.definitions.length} places; impact is the union`);
74
+ }
75
+ if (format === "flat" && result.nodes) {
76
+ result.nodes.forEach((n) => ui.info(`depth ${n.depth} [${n.resolution}]: ${n.name} (${n.file}:${n.line})`));
77
+ }
78
+ else {
79
+ for (const m of result.modules) {
80
+ ui.heading(`${m.module} — ${m.nodes} node(s), ${m.files} file(s), min depth ${m.minDepth}`);
81
+ m.top.forEach((n) => ui.info(`depth ${n.depth} [${n.resolution}]: ${n.name} (${n.file}:${n.line})`));
82
+ }
83
+ }
84
+ if (result.resolution.byName > 0) {
85
+ ui.warn(`${result.resolution.byName} result(s) resolved by name (possible collisions)`);
86
+ }
87
+ result.warnings.forEach((w) => ui.warn(w));
88
+ return;
89
+ }
90
+ case "affected-tests": {
91
+ const files = list(flags.file);
92
+ const fromDiff = typeof flags["from-diff"] === "string"
93
+ ? flags["from-diff"]
94
+ : flags["from-diff"] === true
95
+ ? "HEAD"
96
+ : undefined;
97
+ if (files.length === 0 && !fromDiff && !args[0]) {
98
+ need(undefined, "affected-tests --file <path> | --from-diff <ref>");
99
+ }
100
+ const result = affectedTests(cwd, {
101
+ files: files.length ? files : undefined,
102
+ symbols: args[0] ? [args[0]] : undefined,
103
+ fromDiff,
104
+ });
105
+ if (asJson) {
106
+ console.log(JSON.stringify(result, null, 2));
107
+ return;
108
+ }
109
+ ui.heading(`Affected tests (${result.mode}): ${result.tests.length} file(s) — skipped ${result.skipped.files} (${result.skipped.percent}%)`);
110
+ ui.info(result.reason);
111
+ result.tests.forEach((t) => ui.info(t.file));
112
+ ui.heading("Command");
113
+ console.log(result.command);
114
+ result.warnings.forEach((w) => ui.warn(w));
47
115
  return;
48
116
  }
49
117
  case "trace": {
@@ -100,6 +100,23 @@ const MIGRATIONS = [
100
100
  "`speclaw index`. Optionally add coverage.gateArchive / defaultNeeds under lawbook/config.yaml.\n" +
101
101
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
102
102
  },
103
+ {
104
+ version: "0.3.9",
105
+ describe: "Spec drift: speclaw drift + lawbook_drift + schema 6 dual hashes",
106
+ agentPrompt: "- Mention `speclaw drift` / `lawbook_drift` for sealed spec↔code drift " +
107
+ "(committed `lawbook/anchors/*.json`, dual body/norm hashes). Compass schema is now 6 — " +
108
+ "reindex with `speclaw index`, then `speclaw drift --reseal` once to photograph current bodies.\n" +
109
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
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
+ },
103
120
  ];
104
121
  /**
105
122
  * Update speclaw and bring the current project up to date without a full re-init:
@@ -8,6 +8,7 @@ import { toMarkdown } from "../../modules/foundation/report-md.js";
8
8
  import { toSarif } from "../../modules/foundation/sarif.js";
9
9
  import { loadManifestForVerify } from "../../modules/foundation/laws.js";
10
10
  import { verifyLaws } from "../../modules/foundation/verify.js";
11
+ import { driftFindingsForVerify } from "../../modules/lawbook/drift.js";
11
12
  const FORMATS = new Set(["text", "json", "sarif", "markdown"]);
12
13
  /**
13
14
  * `speclaw verify` — the CI orchestrator over {@link verifyLaws}. Formats and
@@ -45,6 +46,19 @@ export async function runVerify(flags) {
45
46
  engines: engines.length ? engines : undefined,
46
47
  lawIds: list(flags.law).length ? list(flags.law) : undefined,
47
48
  });
49
+ // Structural spec↔code drift (when anchors exist) contributes semantic/deleted
50
+ // findings into the same report stream used by SARIF / exit codes.
51
+ for (const f of driftFindingsForVerify(cwd)) {
52
+ report.findings.push({
53
+ lawId: f.ruleId,
54
+ severity: "error",
55
+ engine: "graph",
56
+ file: f.file,
57
+ line: f.line,
58
+ message: f.message,
59
+ });
60
+ report.summary.failed += 1;
61
+ }
48
62
  const sarifPath = typeof flags.sarif === "string" ? flags.sarif : undefined;
49
63
  const jsonPath = typeof flags.json === "string" ? flags.json : undefined;
50
64
  if (sarifPath) {
package/dist/cli/index.js CHANGED
@@ -23,7 +23,8 @@ Compass (code intelligence — the same surface agents use via MCP)
23
23
  explore <node> A node's source + callers/callees
24
24
  search <query> Find nodes by name/keyword
25
25
  recall "<query>" Find code by meaning (semantic)
26
- impact <node> Blast radius: 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)
27
28
  trace <from> <to> A call path between two nodes
28
29
  visualize [node] Interactive HTML graph → .speclaw/graph.html
29
30
 
@@ -38,6 +39,7 @@ Other
38
39
  doctor Verify the installation (--json, --offline, --strict)
39
40
  budget Measure always-on context cost (tools, skills, instructions)
40
41
  coverage Requirement → impl → test coverage (--json, --tap, --adopt, --write)
42
+ drift Spec↔code drift (--json, --reseal, --reverse, --fail-on)
41
43
  telemetry status Confirm speclaw ships no telemetry
42
44
  check Evaluate an action against the laws (hooks call this; --dry-run to preview)
43
45
  laws verify Verify the deterministic dependency/graph laws against the index
@@ -49,9 +51,9 @@ Other
49
51
  // Commands that open with the one-line branded header. These are the
50
52
  // interactive, human-facing commands whose stdout is prose. Deliberately
51
53
  // excluded: `version`/`--version`/`-v` (bare scriptable value), the Compass
52
- // query family (`explore`/`search`/`recall`/`impact`/`trace`, machine-consumed
53
- // output), `mcp` (a long-running stdio server), and `init` (already opens with
54
- // the fuller `banner()`).
54
+ // query family (`explore`/`search`/`recall`/`impact`/`trace`/`affected-tests`,
55
+ // machine-consumed output), `mcp` (a long-running stdio server), and `init`
56
+ // (already opens with the fuller `banner()`).
55
57
  const HEADER_COMMANDS = new Set([
56
58
  undefined,
57
59
  "help",
@@ -62,6 +64,7 @@ const HEADER_COMMANDS = new Set([
62
64
  "doctor",
63
65
  "budget",
64
66
  "coverage",
67
+ "drift",
65
68
  "telemetry",
66
69
  "index",
67
70
  "watch",
@@ -87,6 +90,8 @@ function maybeHeader(cmd, flags) {
87
90
  return;
88
91
  if (cmd === "coverage" && (flags.json || flags.tap))
89
92
  return;
93
+ if (cmd === "drift" && flags.json)
94
+ return;
90
95
  header();
91
96
  }
92
97
  /** Run the handler for a single command. Returns when the command completes. */
@@ -122,6 +127,7 @@ async function dispatch(cmd, flags) {
122
127
  case "recall":
123
128
  case "impact":
124
129
  case "trace":
130
+ case "affected-tests":
125
131
  return (await import("./commands/query.js")).runQuery(cmd, flags);
126
132
  case "visualize":
127
133
  return (await import("./commands/visualize.js")).runVisualize(flags);
@@ -133,6 +139,8 @@ async function dispatch(cmd, flags) {
133
139
  return (await import("./commands/budget.js")).runBudget(flags);
134
140
  case "coverage":
135
141
  return (await import("./commands/coverage.js")).runCoverage(flags);
142
+ case "drift":
143
+ return (await import("./commands/drift.js")).runDrift(flags);
136
144
  case "telemetry":
137
145
  return (await import("./commands/telemetry.js")).runTelemetry(flags);
138
146
  case "check":
@@ -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
+ }