@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 +2 -0
- package/dist/cli/commands/drift.js +39 -0
- package/dist/cli/commands/lawbook.js +7 -0
- package/dist/cli/commands/query.js +73 -5
- package/dist/cli/commands/update.js +17 -0
- package/dist/cli/commands/verify.js +14 -0
- package/dist/cli/index.js +12 -4
- package/dist/modules/compass/affected-config.js +238 -0
- package/dist/modules/compass/affected.js +249 -0
- package/dist/modules/compass/db.js +85 -6
- package/dist/modules/compass/extract.js +3 -0
- package/dist/modules/compass/hash.js +77 -0
- package/dist/modules/compass/indexer.js +123 -10
- package/dist/modules/compass/query.js +255 -30
- package/dist/modules/compass/register.js +32 -1
- package/dist/modules/foundation/doctor.js +11 -0
- package/dist/modules/lawbook/anchors.js +299 -0
- package/dist/modules/lawbook/drift.js +491 -0
- package/dist/modules/lawbook/engine.js +39 -1
- package/dist/modules/lawbook/register.js +17 -0
- package/dist/shared/exposure.js +2 -1
- package/package.json +1 -1
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 -
|
|
7
|
-
*
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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`,
|
|
53
|
-
// output), `mcp` (a long-running stdio server), and `init`
|
|
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
|
+
}
|