@esneiderbravo/speclaw 0.3.2 → 0.3.4
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 +30 -1
- package/dist/cli/commands/laws.js +48 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/verify.js +118 -0
- package/dist/cli/index.js +6 -0
- package/dist/modules/foundation/assets/laws/laws-manifest.json +49 -0
- package/dist/modules/foundation/assets/workflows/speclaw.yml +26 -0
- package/dist/modules/foundation/ci.js +41 -0
- package/dist/modules/foundation/deps.js +117 -0
- package/dist/modules/foundation/doctor.js +21 -6
- package/dist/modules/foundation/graph.js +215 -0
- package/dist/modules/foundation/laws.js +107 -6
- package/dist/modules/foundation/register.js +17 -0
- package/dist/modules/foundation/report-md.js +33 -0
- package/dist/modules/foundation/sarif.js +96 -0
- package/dist/modules/foundation/scaffold.js +31 -11
- package/dist/modules/foundation/verify-model.js +14 -0
- package/dist/modules/foundation/verify.js +106 -0
- package/dist/shared/git.js +49 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ too (also `pnpm dlx` / `yarn dlx`) — but installing globally means you can run
|
|
|
82
82
|
|
|
83
83
|
| Module | What it does |
|
|
84
84
|
| :-- | :-- |
|
|
85
|
-
| **Foundation** | The project's constitution: `LAWS.md` binding a set of granular standards under `docs/standards/` (base, architecture, backend, frontend, testing, documentation, conventions, lawbook), plus strict `CLAUDE.md` / `AGENTS.md` agent contracts — filled from your real codebase. It also **enforces** them: blocking laws compile into agent hooks that deny a forbidden edit at the keystroke (`speclaw check` / `speclaw_check`). |
|
|
85
|
+
| **Foundation** | The project's constitution: `LAWS.md` binding a set of granular standards under `docs/standards/` (base, architecture, backend, frontend, testing, documentation, conventions, lawbook), plus strict `CLAUDE.md` / `AGENTS.md` agent contracts — filled from your real codebase. It also **enforces** them: blocking laws compile into agent hooks that deny a forbidden edit at the keystroke (`speclaw check` / `speclaw_check`), and architectural laws are verified deterministically against the Compass graph — dependency rules (`deps`) and cycles (`graph`) — via `speclaw verify` (CI orchestrator: exit codes, SARIF, markdown) and `speclaw laws verify` / `law_verify`. Each law is reported as passed, failed, skipped, or unknown (an unresolved reference is *unknown*, never a silent pass). |
|
|
86
86
|
| **Compass** | speclaw's own local code graph. Parses your code (tree-sitter) into nodes + edges plus a local vector store, so an agent finds and understands code with a fraction of the tokens a grep/read loop would cost. No LLM, 100% local, lives in `.speclaw/` (gitignored). |
|
|
87
87
|
| **Lawbook** | speclaw's own spec-driven workflow: `draft → build → sync → archive` (and `explore`), backed by `lawbook_*` engine tools. No external CLI. |
|
|
88
88
|
| **Tools** | Opt-in packs of skills and subagents (currently the dev-agents) that agents use for specific tasks. |
|
|
@@ -210,6 +210,35 @@ context. Agents without hooks (Cursor, Codex) enforce the same laws in CI via
|
|
|
210
210
|
|
|
211
211
|
<br/>
|
|
212
212
|
|
|
213
|
+
## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle"> Verify in CI
|
|
214
|
+
|
|
215
|
+
`speclaw verify` evaluates your `deps` and `graph` laws against the local Compass
|
|
216
|
+
index. It is deterministic: **no model, no API key, no network.**
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
speclaw verify --ci --sarif speclaw.sarif --json speclaw.json
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
| Exit | Meaning |
|
|
223
|
+
| :-- | :-- |
|
|
224
|
+
| **0** | No findings at or above `--fail-on` (default `error`) |
|
|
225
|
+
| **1** | At least one finding at or above `--fail-on` |
|
|
226
|
+
| **2** | Usage error (unknown `--fail-on` / `--format`) |
|
|
227
|
+
| **3** | Environment (shallow clone under `--ci`, or an unwritable `--sarif`/`--json` path) |
|
|
228
|
+
| **4** | At least one law was skipped, and `--strict-engines` was set |
|
|
229
|
+
|
|
230
|
+
On GitHub:
|
|
231
|
+
|
|
232
|
+
```yaml
|
|
233
|
+
- uses: esneiderbravo/speclaw@v1
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`init` / `update` write `.github/workflows/speclaw.yml` only when that path is
|
|
237
|
+
missing — they never overwrite your CI. Make the check required in branch
|
|
238
|
+
protection yourself; speclaw does not.
|
|
239
|
+
|
|
240
|
+
<br/>
|
|
241
|
+
|
|
213
242
|
## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle"> Staying up to date
|
|
214
243
|
|
|
215
244
|
speclaw checks for new releases in the background (at most once a day) and nudges
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { list } from "../lib/args.js";
|
|
2
|
+
import { ui, c } from "../lib/ui.js";
|
|
3
|
+
import { verifyLaws } from "../../modules/foundation/verify.js";
|
|
4
|
+
/**
|
|
5
|
+
* `speclaw laws <subcommand>` — the CLI twin of the batch law tools. Today it
|
|
6
|
+
* exposes `verify`, the twin of the `law_verify` MCP tool: it runs the project's
|
|
7
|
+
* deterministic `deps`/`graph` laws against the Compass index and prints the
|
|
8
|
+
* four-state result. Both transports delegate to the same {@link verifyLaws}
|
|
9
|
+
* core, so the CLI and the tool never diverge.
|
|
10
|
+
*
|
|
11
|
+
* - `laws verify [--engine deps,graph] [--path a,b] [--law id1,id2] [--json]`
|
|
12
|
+
*
|
|
13
|
+
* @param flags - Parsed CLI flags; `flags._[0]` is the subcommand.
|
|
14
|
+
*/
|
|
15
|
+
export async function runLaws(flags) {
|
|
16
|
+
const sub = flags._[0];
|
|
17
|
+
if (sub !== "verify") {
|
|
18
|
+
ui.err(`Unknown laws subcommand: ${sub ?? "(none)"} — try ${ui.code("speclaw laws verify")}.`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const engines = list(flags.engine).filter((e) => e === "deps" || e === "graph");
|
|
22
|
+
const report = verifyLaws({
|
|
23
|
+
projectPath: process.cwd(),
|
|
24
|
+
paths: list(flags.path).length ? list(flags.path) : undefined,
|
|
25
|
+
engines: engines.length ? engines : undefined,
|
|
26
|
+
lawIds: list(flags.law).length ? list(flags.law) : undefined,
|
|
27
|
+
});
|
|
28
|
+
if (flags.json) {
|
|
29
|
+
console.log(JSON.stringify(report, null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const { summary } = report;
|
|
33
|
+
ui.heading("speclaw laws verify");
|
|
34
|
+
ui.info(`${summary.passed} passed · ${c.red(String(summary.failed))} failed · ` +
|
|
35
|
+
`${summary.skipped} skipped · ${summary.unknown} unknown ` +
|
|
36
|
+
`(${report.elapsedMs.toFixed(1)} ms)`);
|
|
37
|
+
for (const f of report.findings) {
|
|
38
|
+
const at = f.line ? `${f.file}:${f.line}` : f.file;
|
|
39
|
+
ui.warn(`${c.cream(f.lawId)} — ${at}${f.detail ? ` ${f.detail}` : ""}`);
|
|
40
|
+
}
|
|
41
|
+
for (const u of report.unknown)
|
|
42
|
+
ui.plain(` ? ${c.cream(u.lawId)} — ${u.detail}`);
|
|
43
|
+
for (const s of report.skipped) {
|
|
44
|
+
ui.plain(` – ${c.cream(s.lawId)} — skipped: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`);
|
|
45
|
+
}
|
|
46
|
+
if (report.findings.length === 0 && summary.evaluated > 0)
|
|
47
|
+
ui.ok("No violations.");
|
|
48
|
+
}
|
|
@@ -60,6 +60,13 @@ const MIGRATIONS = [
|
|
|
60
60
|
"in docs/standards/testing-standards.md (the 'Manual & end-to-end verification' section).\n" +
|
|
61
61
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
62
62
|
},
|
|
63
|
+
{
|
|
64
|
+
version: "0.3.4",
|
|
65
|
+
describe: "CI verify workflow (if missing) and seed-law merge",
|
|
66
|
+
agentPrompt: "- If you want pull requests gated on speclaw, add the `speclaw` GitHub check as a " +
|
|
67
|
+
"required status check in branch protection. speclaw never enables that itself.\n" +
|
|
68
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
69
|
+
},
|
|
63
70
|
];
|
|
64
71
|
/**
|
|
65
72
|
* Update speclaw and bring the current project up to date without a full re-init:
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { list } from "../lib/args.js";
|
|
3
|
+
import { ui, c } from "../lib/ui.js";
|
|
4
|
+
import { isShallowRepo } from "../../shared/git-history.js";
|
|
5
|
+
import { pkgVersion } from "../../shared/version.js";
|
|
6
|
+
import { parseFailOn, verifyExitCode } from "../../modules/foundation/ci.js";
|
|
7
|
+
import { toMarkdown } from "../../modules/foundation/report-md.js";
|
|
8
|
+
import { toSarif } from "../../modules/foundation/sarif.js";
|
|
9
|
+
import { loadManifestForVerify } from "../../modules/foundation/laws.js";
|
|
10
|
+
import { verifyLaws } from "../../modules/foundation/verify.js";
|
|
11
|
+
const FORMATS = new Set(["text", "json", "sarif", "markdown"]);
|
|
12
|
+
/**
|
|
13
|
+
* `speclaw verify` — the CI orchestrator over {@link verifyLaws}. Formats and
|
|
14
|
+
* exit codes live here; graph evaluation stays in `verify.ts`. `speclaw check`
|
|
15
|
+
* (hooks) and `speclaw laws verify` (the thin batch twin) are unchanged.
|
|
16
|
+
*
|
|
17
|
+
* @param flags - Parsed CLI flags.
|
|
18
|
+
*/
|
|
19
|
+
export async function runVerify(flags) {
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
const ci = Boolean(flags.ci);
|
|
22
|
+
if (ci)
|
|
23
|
+
process.env.NO_COLOR = "1";
|
|
24
|
+
const failOn = parseFailOn(flags["fail-on"]);
|
|
25
|
+
if (failOn === null) {
|
|
26
|
+
ui.err(`--fail-on must be error, warn, or info.`);
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
const format = flags.format === undefined || flags.format === true ? "text" : String(flags.format);
|
|
30
|
+
if (!FORMATS.has(format)) {
|
|
31
|
+
ui.err(`--format must be text, json, sarif, or markdown.`);
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
const strict = Boolean(flags["strict-engines"]);
|
|
35
|
+
const engines = list(flags.engine).filter((e) => e === "deps" || e === "graph");
|
|
36
|
+
const paths = list(flags.path);
|
|
37
|
+
if (ci && isShallowRepo(cwd)) {
|
|
38
|
+
ui.err("Shallow clone — speclaw cannot see the merge base. Check out with fetch-depth: 0.");
|
|
39
|
+
process.exit(3);
|
|
40
|
+
}
|
|
41
|
+
const manifest = loadManifestForVerify(cwd);
|
|
42
|
+
const report = verifyLaws({
|
|
43
|
+
projectPath: cwd,
|
|
44
|
+
paths: paths.length ? paths : undefined,
|
|
45
|
+
engines: engines.length ? engines : undefined,
|
|
46
|
+
lawIds: list(flags.law).length ? list(flags.law) : undefined,
|
|
47
|
+
});
|
|
48
|
+
const sarifPath = typeof flags.sarif === "string" ? flags.sarif : undefined;
|
|
49
|
+
const jsonPath = typeof flags.json === "string" ? flags.json : undefined;
|
|
50
|
+
if (sarifPath) {
|
|
51
|
+
if (!writeOut(sarifPath, JSON.stringify(toSarif(report, sarifCtx(manifest.laws)), null, 2))) {
|
|
52
|
+
process.exit(3);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (jsonPath) {
|
|
56
|
+
if (!writeOut(jsonPath, JSON.stringify(report, null, 2)))
|
|
57
|
+
process.exit(3);
|
|
58
|
+
}
|
|
59
|
+
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
|
|
60
|
+
if (summaryFile) {
|
|
61
|
+
try {
|
|
62
|
+
fs.appendFileSync(summaryFile, toMarkdown(report));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
ui.warn(`Could not write $GITHUB_STEP_SUMMARY: ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
printReport(report, format, flags.json === true && !jsonPath);
|
|
69
|
+
process.exit(verifyExitCode(report, { failOn, strictEngines: strict }));
|
|
70
|
+
}
|
|
71
|
+
function sarifCtx(laws) {
|
|
72
|
+
return { speclawVersion: pkgVersion(), laws };
|
|
73
|
+
}
|
|
74
|
+
function writeOut(file, body) {
|
|
75
|
+
try {
|
|
76
|
+
fs.writeFileSync(file, body.endsWith("\n") ? body : body + "\n");
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
ui.err(`Cannot write ${file}: ${err.message}`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function printReport(report, format, jsonStdout) {
|
|
85
|
+
if (jsonStdout || format === "json") {
|
|
86
|
+
console.log(JSON.stringify(report, null, 2));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (format === "sarif") {
|
|
90
|
+
// Rules need the loaded laws; re-read via the same fallback the run used.
|
|
91
|
+
const laws = loadManifestForVerify(process.cwd()).laws;
|
|
92
|
+
console.log(JSON.stringify(toSarif(report, sarifCtx(laws)), null, 2));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (format === "markdown") {
|
|
96
|
+
process.stdout.write(toMarkdown(report));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const { summary } = report;
|
|
100
|
+
ui.heading("speclaw verify");
|
|
101
|
+
ui.info(`${summary.passed} passed · ${c.red(String(summary.failed))} failed · ` +
|
|
102
|
+
`${summary.skipped} skipped · ${summary.unknown} unknown ` +
|
|
103
|
+
`(${report.elapsedMs.toFixed(1)} ms)`);
|
|
104
|
+
if (summary.evaluated === 0 && summary.skipped === 0) {
|
|
105
|
+
ui.warn("0 batch laws evaluated — verify is not checking anything.");
|
|
106
|
+
}
|
|
107
|
+
for (const f of report.findings) {
|
|
108
|
+
const at = f.line ? `${f.file}:${f.line}` : f.file;
|
|
109
|
+
ui.warn(`${c.cream(f.lawId)} — ${at}${f.detail ? ` ${f.detail}` : ""}`);
|
|
110
|
+
}
|
|
111
|
+
for (const u of report.unknown)
|
|
112
|
+
ui.plain(` ? ${c.cream(u.lawId)} — ${u.detail}`);
|
|
113
|
+
for (const s of report.skipped) {
|
|
114
|
+
ui.plain(` – ${c.cream(s.lawId)} — skipped: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`);
|
|
115
|
+
}
|
|
116
|
+
if (report.findings.length === 0 && summary.evaluated > 0)
|
|
117
|
+
ui.ok("No violations.");
|
|
118
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -35,6 +35,8 @@ Lawbook (spec-driven workflow)
|
|
|
35
35
|
Other
|
|
36
36
|
doctor Verify the installation
|
|
37
37
|
check Evaluate an action against the laws (hooks call this; --dry-run to preview)
|
|
38
|
+
laws verify Verify the deterministic dependency/graph laws against the index
|
|
39
|
+
verify Verify laws for CI: exit codes, --sarif, --json, --strict-engines
|
|
38
40
|
mcp Start the MCP server (used by your agent's config)
|
|
39
41
|
help Show this help
|
|
40
42
|
--version Print the installed speclaw version
|
|
@@ -113,6 +115,10 @@ async function dispatch(cmd, flags) {
|
|
|
113
115
|
return (await import("./commands/doctor.js")).runDoctor(flags);
|
|
114
116
|
case "check":
|
|
115
117
|
return (await import("./commands/check.js")).runCheck(flags);
|
|
118
|
+
case "laws":
|
|
119
|
+
return (await import("./commands/laws.js")).runLaws(flags);
|
|
120
|
+
case "verify":
|
|
121
|
+
return (await import("./commands/verify.js")).runVerify(flags);
|
|
116
122
|
default:
|
|
117
123
|
ui.err(`Unknown command: ${cmd}`);
|
|
118
124
|
console.log(HELP);
|
|
@@ -44,6 +44,55 @@
|
|
|
44
44
|
"verification": { "kind": "path" },
|
|
45
45
|
"enforcement": "feedback",
|
|
46
46
|
"source": { "file": "LAWS.md" }
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": "law~shared-stays-inner~1",
|
|
50
|
+
"title": "shared stays the innermost layer",
|
|
51
|
+
"rationale": "shared/ must not import modules/ or cli/, or the layering law is inverted.",
|
|
52
|
+
"severity": "error",
|
|
53
|
+
"scope": ["src/shared/**"],
|
|
54
|
+
"prose": "src/shared must not import from src/modules or src/cli.",
|
|
55
|
+
"verification": {
|
|
56
|
+
"kind": "deps",
|
|
57
|
+
"rule": {
|
|
58
|
+
"from": "^src/shared/",
|
|
59
|
+
"to": "^src/(modules|cli)/",
|
|
60
|
+
"type": "forbidden",
|
|
61
|
+
"edgeKinds": ["import"]
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"enforcement": "gate",
|
|
65
|
+
"source": { "file": "docs/standards/architecture.md" }
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "law~compass-does-not-import-foundation~1",
|
|
69
|
+
"title": "Compass does not import foundation",
|
|
70
|
+
"rationale": "foundation already imports compass; the reverse would be a module cycle.",
|
|
71
|
+
"severity": "error",
|
|
72
|
+
"scope": ["src/modules/compass/**"],
|
|
73
|
+
"prose": "src/modules/compass must not import from src/modules/foundation.",
|
|
74
|
+
"verification": {
|
|
75
|
+
"kind": "deps",
|
|
76
|
+
"rule": {
|
|
77
|
+
"from": "^src/modules/compass/",
|
|
78
|
+
"to": "^src/modules/foundation/",
|
|
79
|
+
"type": "forbidden",
|
|
80
|
+
"edgeKinds": ["import"]
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"enforcement": "gate",
|
|
84
|
+
"source": { "file": "docs/standards/architecture.md" }
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "law~no-module-cycles~1",
|
|
88
|
+
"title": "No circular module dependencies",
|
|
89
|
+
"rationale": "Modules may reuse another module's exported helper, but there are no circular dependencies.",
|
|
90
|
+
"severity": "error",
|
|
91
|
+
"scope": ["src/modules/**"],
|
|
92
|
+
"prose": "There are no circular dependencies between modules.",
|
|
93
|
+
"verification": { "kind": "graph", "rule": { "circular": true } },
|
|
94
|
+
"enforcement": "gate",
|
|
95
|
+
"source": { "file": "docs/standards/architecture.md" }
|
|
47
96
|
}
|
|
48
97
|
]
|
|
49
98
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Consumer template. init/update write this only when the path is missing.
|
|
2
|
+
# Never the untrusted PR trigger. The verify job has no secrets.
|
|
3
|
+
name: ⚖️ speclaw
|
|
4
|
+
on: pull_request
|
|
5
|
+
|
|
6
|
+
permissions: {}
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
verify:
|
|
10
|
+
name: ⚖️ Verify laws
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
security-events: write
|
|
15
|
+
steps:
|
|
16
|
+
- name: 📥 Checkout
|
|
17
|
+
uses: actions/checkout@v5
|
|
18
|
+
with:
|
|
19
|
+
fetch-depth: 0
|
|
20
|
+
- name: ⚖️ Verify
|
|
21
|
+
uses: esneiderbravo/speclaw@v1
|
|
22
|
+
- name: 🛡️ Upload SARIF
|
|
23
|
+
uses: github/codeql-action/upload-sarif@v4
|
|
24
|
+
if: always()
|
|
25
|
+
with:
|
|
26
|
+
sarif_file: speclaw.sarif
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const RANK = { error: 3, warn: 2, info: 1 };
|
|
2
|
+
/**
|
|
3
|
+
* Parse `--fail-on`. An omitted flag is `error`; any other string is invalid
|
|
4
|
+
* (the CLI maps that to exit 2).
|
|
5
|
+
*
|
|
6
|
+
* @param raw - The flag value from `parseFlags`.
|
|
7
|
+
*/
|
|
8
|
+
export function parseFailOn(raw) {
|
|
9
|
+
if (raw === undefined || raw === true)
|
|
10
|
+
return "error";
|
|
11
|
+
if (raw === "error" || raw === "warn" || raw === "info")
|
|
12
|
+
return raw;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Stable identity of a finding, reused as the SARIF `partialFingerprints`
|
|
17
|
+
* value. There is no known-violations baseline in this slice, so the
|
|
18
|
+
* fingerprint is local: law + file + line.
|
|
19
|
+
*
|
|
20
|
+
* @param f - A batch finding.
|
|
21
|
+
*/
|
|
22
|
+
export function fingerprint(f) {
|
|
23
|
+
return `${f.lawId}:${f.file}:${f.line ?? 0}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Map a {@link VerifyReport} onto the public `speclaw verify` exit codes
|
|
27
|
+
* `0` / `1` / `4`. Usage (`2`) and environment (`3`) errors are decided by
|
|
28
|
+
* the CLI before this runs.
|
|
29
|
+
*
|
|
30
|
+
* @param report - The batch report.
|
|
31
|
+
* @param opts.failOn - Minimum severity that fails the process.
|
|
32
|
+
* @param opts.strictEngines - When true, any skip becomes exit `4`.
|
|
33
|
+
*/
|
|
34
|
+
export function verifyExitCode(report, opts) {
|
|
35
|
+
const threshold = RANK[opts.failOn];
|
|
36
|
+
if (report.findings.some((f) => RANK[f.severity] >= threshold))
|
|
37
|
+
return 1;
|
|
38
|
+
if (opts.strictEngines && report.skipped.length > 0)
|
|
39
|
+
return 4;
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { underPaths } from "./verify-model.js";
|
|
2
|
+
/** Substitute `$1`, `$2`, … in a pattern with capture groups from a match. */
|
|
3
|
+
function applyGroups(pattern, match) {
|
|
4
|
+
return pattern.replace(/\$(\d+)/g, (_whole, d) => match[Number(d)] ?? "");
|
|
5
|
+
}
|
|
6
|
+
/** The `IN (?, ?)` clause and params for an optional edge-kind filter. */
|
|
7
|
+
function edgeKindClause(edgeKinds) {
|
|
8
|
+
if (!edgeKinds || edgeKinds.length === 0)
|
|
9
|
+
return { sql: "", params: [] };
|
|
10
|
+
return { sql: ` AND e.kind IN (${edgeKinds.map(() => "?").join(", ")})`, params: edgeKinds };
|
|
11
|
+
}
|
|
12
|
+
/** Load resolved file→file edges (earliest line per pair) from the index. */
|
|
13
|
+
function resolvedEdges(db, edgeKinds) {
|
|
14
|
+
const kind = edgeKindClause(edgeKinds);
|
|
15
|
+
return db
|
|
16
|
+
.prepare(`SELECT sf.path AS src, df.path AS dst, MIN(e.line) AS line
|
|
17
|
+
FROM edges e
|
|
18
|
+
JOIN files sf ON sf.id = e.src_file_id
|
|
19
|
+
JOIN nodes dn ON dn.id = e.dst_node_id
|
|
20
|
+
JOIN files df ON df.id = dn.file_id
|
|
21
|
+
WHERE e.dst_node_id IS NOT NULL${kind.sql}
|
|
22
|
+
GROUP BY sf.path, df.path`)
|
|
23
|
+
.all(...kind.params);
|
|
24
|
+
}
|
|
25
|
+
/** Count unresolved edges (`dst_node_id IS NULL`) per source file. */
|
|
26
|
+
function unresolvedBySource(db, edgeKinds) {
|
|
27
|
+
const kind = edgeKindClause(edgeKinds);
|
|
28
|
+
return db
|
|
29
|
+
.prepare(`SELECT sf.path AS src, COUNT(*) AS n
|
|
30
|
+
FROM edges e
|
|
31
|
+
JOIN files sf ON sf.id = e.src_file_id
|
|
32
|
+
WHERE e.dst_node_id IS NULL${kind.sql}
|
|
33
|
+
GROUP BY sf.path`)
|
|
34
|
+
.all(...kind.params);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Evaluate one `deps` law against the index.
|
|
38
|
+
*
|
|
39
|
+
* A `forbidden` rule emits a finding for every resolved edge whose source
|
|
40
|
+
* matches `from` and whose destination matches `to` (excluding `toNot`); a
|
|
41
|
+
* `required` rule emits a finding for every `from` file with no resolved edge to
|
|
42
|
+
* any `to` destination. `from` may carry a capture group referenced as `$1` in
|
|
43
|
+
* `to`/`toNot`, so one rule expresses "no feature imports another feature".
|
|
44
|
+
*
|
|
45
|
+
* @param db - An open connection to the project's index.
|
|
46
|
+
* @param law - The `deps` law to evaluate.
|
|
47
|
+
* @param paths - Optional project-relative paths restricting the source files.
|
|
48
|
+
* @returns The findings and the count of unresolved in-scope edges.
|
|
49
|
+
*/
|
|
50
|
+
export function runDepsLaw(db, law, paths) {
|
|
51
|
+
const rule = law.verification.rule;
|
|
52
|
+
const fromRe = new RegExp(rule.from);
|
|
53
|
+
const type = rule.type ?? "forbidden";
|
|
54
|
+
const findings = [];
|
|
55
|
+
const inScope = (src) => underPaths(src, paths) ? src.match(fromRe) : null;
|
|
56
|
+
const matchesTo = (dst, m) => {
|
|
57
|
+
const toRe = new RegExp(applyGroups(rule.to, m));
|
|
58
|
+
if (!toRe.test(dst))
|
|
59
|
+
return false;
|
|
60
|
+
if (rule.toNot && new RegExp(applyGroups(rule.toNot, m)).test(dst))
|
|
61
|
+
return false;
|
|
62
|
+
return true;
|
|
63
|
+
};
|
|
64
|
+
const edges = resolvedEdges(db, rule.edgeKinds);
|
|
65
|
+
if (type === "forbidden") {
|
|
66
|
+
for (const e of edges) {
|
|
67
|
+
const m = inScope(e.src);
|
|
68
|
+
if (!m)
|
|
69
|
+
continue;
|
|
70
|
+
if (matchesTo(e.dst, m)) {
|
|
71
|
+
findings.push({
|
|
72
|
+
lawId: law.id,
|
|
73
|
+
severity: law.severity,
|
|
74
|
+
engine: "deps",
|
|
75
|
+
file: e.src,
|
|
76
|
+
line: e.line,
|
|
77
|
+
message: law.prose,
|
|
78
|
+
detail: `→ ${e.dst}`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// required: every `from` file must have at least one edge to a `to` file.
|
|
85
|
+
const bySrc = new Map();
|
|
86
|
+
for (const e of edges) {
|
|
87
|
+
const list = bySrc.get(e.src);
|
|
88
|
+
if (list)
|
|
89
|
+
list.push(e);
|
|
90
|
+
else
|
|
91
|
+
bySrc.set(e.src, [e]);
|
|
92
|
+
}
|
|
93
|
+
const files = db.prepare("SELECT path FROM files").all().map((r) => r.path);
|
|
94
|
+
for (const src of files) {
|
|
95
|
+
const m = inScope(src);
|
|
96
|
+
if (!m)
|
|
97
|
+
continue;
|
|
98
|
+
const satisfied = (bySrc.get(src) ?? []).some((e) => matchesTo(e.dst, m));
|
|
99
|
+
if (!satisfied) {
|
|
100
|
+
findings.push({
|
|
101
|
+
lawId: law.id,
|
|
102
|
+
severity: law.severity,
|
|
103
|
+
engine: "deps",
|
|
104
|
+
file: src,
|
|
105
|
+
message: law.prose,
|
|
106
|
+
detail: `required dependency to ${rule.to} is missing`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let unresolved = 0;
|
|
112
|
+
for (const row of unresolvedBySource(db, rule.edgeKinds)) {
|
|
113
|
+
if (inScope(row.src))
|
|
114
|
+
unresolved += row.n;
|
|
115
|
+
}
|
|
116
|
+
return { findings, unresolved };
|
|
117
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { AGENTS, agentById, detectConfiguredAgents } from "../../shared/agents.js";
|
|
4
|
-
import { globError, hasBackend, readLawManifest } from "./laws.js";
|
|
4
|
+
import { globError, hasBackend, hasBatchBackend, readLawManifest } from "./laws.js";
|
|
5
5
|
/**
|
|
6
6
|
* Run the speclaw installation health checks against a project: ai-specs and
|
|
7
7
|
* LAWS.md presence, agent contracts, the docs/standards set, per-agent IDE
|
|
@@ -136,20 +136,35 @@ function lawEnforcementChecks(projectPath, checks) {
|
|
|
136
136
|
});
|
|
137
137
|
return;
|
|
138
138
|
}
|
|
139
|
-
const
|
|
140
|
-
const
|
|
139
|
+
const withPath = manifest.laws.filter(hasBackend);
|
|
140
|
+
const withBatch = manifest.laws.filter(hasBatchBackend);
|
|
141
|
+
const noBackend = manifest.laws.filter((l) => !hasBackend(l) && !hasBatchBackend(l));
|
|
141
142
|
checks.push({
|
|
142
143
|
name: "law manifest",
|
|
143
144
|
ok: true,
|
|
144
|
-
detail: `${manifest.laws.length} law(s): ${
|
|
145
|
+
detail: `${manifest.laws.length} law(s): ${withPath.length} enforced (path), ` +
|
|
146
|
+
`${withBatch.length} verified (deps/graph)` +
|
|
145
147
|
(noBackend.length
|
|
146
148
|
? `, ${noBackend.length} declared without a backend yet (${noBackend
|
|
147
149
|
.map((l) => l.id)
|
|
148
150
|
.join(", ")})`
|
|
149
151
|
: ""),
|
|
150
152
|
});
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
+
// Graph-engine availability — the deps/graph backends need the Compass index.
|
|
154
|
+
if (withBatch.length > 0) {
|
|
155
|
+
const indexed = fs.existsSync(path.join(projectPath, ".speclaw", "index.db"));
|
|
156
|
+
checks.push({
|
|
157
|
+
name: "graph law engines",
|
|
158
|
+
ok: indexed,
|
|
159
|
+
detail: indexed
|
|
160
|
+
? `index present — ${withBatch.length} deps/graph law(s) evaluable via \`speclaw laws verify\``
|
|
161
|
+
: `${withBatch.length} deps/graph law(s) will be skipped (no-index) — run the \`compass_index\` tool`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// Glob validation — a malformed scope glob must fail loudly here, never
|
|
165
|
+
// silently match zero files at runtime. (A malformed deps/graph regex is
|
|
166
|
+
// rejected earlier, when the manifest is validated, so a manifest that reaches
|
|
167
|
+
// here has none.)
|
|
153
168
|
const badGlobs = [];
|
|
154
169
|
for (const law of manifest.laws) {
|
|
155
170
|
for (const pattern of law.scope) {
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { underPaths } from "./verify-model.js";
|
|
2
|
+
/** Build the cross-file dependency graph, restricted to `paths` when given. */
|
|
3
|
+
function buildGraph(db, paths) {
|
|
4
|
+
const rows = db
|
|
5
|
+
.prepare(`SELECT DISTINCT sf.path AS src, df.path AS dst
|
|
6
|
+
FROM edges e
|
|
7
|
+
JOIN files sf ON sf.id = e.src_file_id
|
|
8
|
+
JOIN nodes dn ON dn.id = e.dst_node_id
|
|
9
|
+
JOIN files df ON df.id = dn.file_id
|
|
10
|
+
WHERE e.dst_node_id IS NOT NULL AND sf.path <> df.path`)
|
|
11
|
+
.all();
|
|
12
|
+
const adj = new Map();
|
|
13
|
+
for (const { src, dst } of rows) {
|
|
14
|
+
if (!underPaths(src, paths) || !underPaths(dst, paths))
|
|
15
|
+
continue;
|
|
16
|
+
const list = adj.get(src);
|
|
17
|
+
if (list)
|
|
18
|
+
list.push(dst);
|
|
19
|
+
else
|
|
20
|
+
adj.set(src, [dst]);
|
|
21
|
+
if (!adj.has(dst))
|
|
22
|
+
adj.set(dst, []);
|
|
23
|
+
}
|
|
24
|
+
return adj;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Iterative Tarjan strongly-connected-components. Written with an explicit work
|
|
28
|
+
* stack so a deep import chain cannot overflow the call stack.
|
|
29
|
+
*
|
|
30
|
+
* @param adj - The directed graph.
|
|
31
|
+
* @returns The list of SCCs, each a list of node ids.
|
|
32
|
+
*/
|
|
33
|
+
export function tarjanSCC(adj) {
|
|
34
|
+
const index = new Map();
|
|
35
|
+
const low = new Map();
|
|
36
|
+
const onStack = new Set();
|
|
37
|
+
const stack = [];
|
|
38
|
+
const sccs = [];
|
|
39
|
+
let counter = 0;
|
|
40
|
+
for (const root of adj.keys()) {
|
|
41
|
+
if (index.has(root))
|
|
42
|
+
continue;
|
|
43
|
+
const work = [{ node: root, i: 0 }];
|
|
44
|
+
while (work.length > 0) {
|
|
45
|
+
const frame = work[work.length - 1];
|
|
46
|
+
const { node } = frame;
|
|
47
|
+
if (frame.i === 0) {
|
|
48
|
+
index.set(node, counter);
|
|
49
|
+
low.set(node, counter);
|
|
50
|
+
counter++;
|
|
51
|
+
stack.push(node);
|
|
52
|
+
onStack.add(node);
|
|
53
|
+
}
|
|
54
|
+
const neighbors = adj.get(node) ?? [];
|
|
55
|
+
if (frame.i < neighbors.length) {
|
|
56
|
+
const next = neighbors[frame.i];
|
|
57
|
+
frame.i++;
|
|
58
|
+
if (!index.has(next)) {
|
|
59
|
+
work.push({ node: next, i: 0 });
|
|
60
|
+
}
|
|
61
|
+
else if (onStack.has(next)) {
|
|
62
|
+
low.set(node, Math.min(low.get(node), index.get(next)));
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// All neighbors visited: settle this node, propagating low-links up.
|
|
67
|
+
if (low.get(node) === index.get(node)) {
|
|
68
|
+
const scc = [];
|
|
69
|
+
for (;;) {
|
|
70
|
+
const w = stack.pop();
|
|
71
|
+
onStack.delete(w);
|
|
72
|
+
scc.push(w);
|
|
73
|
+
if (w === node)
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
sccs.push(scc);
|
|
77
|
+
}
|
|
78
|
+
work.pop();
|
|
79
|
+
const parent = work[work.length - 1];
|
|
80
|
+
if (parent)
|
|
81
|
+
low.set(parent.node, Math.min(low.get(parent.node), low.get(node)));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return sccs;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The shortest cycle passing through `start`, via BFS over the induced subgraph.
|
|
88
|
+
*
|
|
89
|
+
* @param start - The node to find a return path to.
|
|
90
|
+
* @param within - The set of nodes the search is restricted to (one SCC).
|
|
91
|
+
* @param adj - The full graph.
|
|
92
|
+
* @returns The cycle as an ordered node list `[start, …]`, or null if none.
|
|
93
|
+
*/
|
|
94
|
+
function shortestCycleThrough(start, within, adj) {
|
|
95
|
+
const parent = new Map();
|
|
96
|
+
const visited = new Set([start]);
|
|
97
|
+
let queue = [start];
|
|
98
|
+
while (queue.length > 0) {
|
|
99
|
+
const next = [];
|
|
100
|
+
for (const node of queue) {
|
|
101
|
+
for (const neighbor of adj.get(node) ?? []) {
|
|
102
|
+
if (!within.has(neighbor))
|
|
103
|
+
continue;
|
|
104
|
+
if (neighbor === start) {
|
|
105
|
+
// Reconstruct start → … → node, which closes back to start.
|
|
106
|
+
const path = [node];
|
|
107
|
+
let cur = node;
|
|
108
|
+
while (cur !== start) {
|
|
109
|
+
cur = parent.get(cur);
|
|
110
|
+
path.push(cur);
|
|
111
|
+
}
|
|
112
|
+
path.reverse();
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
if (!visited.has(neighbor)) {
|
|
116
|
+
visited.add(neighbor);
|
|
117
|
+
parent.set(neighbor, node);
|
|
118
|
+
next.push(neighbor);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
queue = next;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/** Findings for the `circular` rule: one minimal cycle per multi-node SCC. */
|
|
127
|
+
function circularFindings(law, adj) {
|
|
128
|
+
const findings = [];
|
|
129
|
+
for (const scc of tarjanSCC(adj)) {
|
|
130
|
+
if (scc.length < 2)
|
|
131
|
+
continue;
|
|
132
|
+
const within = new Set(scc);
|
|
133
|
+
let best = null;
|
|
134
|
+
for (const node of scc) {
|
|
135
|
+
const cycle = shortestCycleThrough(node, within, adj);
|
|
136
|
+
if (cycle && (best === null || cycle.length < best.length))
|
|
137
|
+
best = cycle;
|
|
138
|
+
}
|
|
139
|
+
if (!best)
|
|
140
|
+
continue;
|
|
141
|
+
findings.push({
|
|
142
|
+
lawId: law.id,
|
|
143
|
+
severity: law.severity,
|
|
144
|
+
engine: "graph",
|
|
145
|
+
file: best[0],
|
|
146
|
+
message: law.prose,
|
|
147
|
+
detail: `cycle: ${[...best, best[0]].join(" → ")} (SCC size ${scc.length})`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return findings;
|
|
151
|
+
}
|
|
152
|
+
/** Findings for the `reachable` rule: a `from` file transitively reaches a `to` file. */
|
|
153
|
+
function reachableFindings(law, rule, adj) {
|
|
154
|
+
const fromRe = new RegExp(rule.from);
|
|
155
|
+
const toRe = new RegExp(rule.to);
|
|
156
|
+
const findings = [];
|
|
157
|
+
for (const src of adj.keys()) {
|
|
158
|
+
if (!fromRe.test(src))
|
|
159
|
+
continue;
|
|
160
|
+
const seen = new Set([src]);
|
|
161
|
+
let queue = [src];
|
|
162
|
+
let hit = null;
|
|
163
|
+
while (queue.length > 0 && !hit) {
|
|
164
|
+
const next = [];
|
|
165
|
+
for (const node of queue) {
|
|
166
|
+
for (const neighbor of adj.get(node) ?? []) {
|
|
167
|
+
if (seen.has(neighbor))
|
|
168
|
+
continue;
|
|
169
|
+
if (toRe.test(neighbor)) {
|
|
170
|
+
hit = neighbor;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
seen.add(neighbor);
|
|
174
|
+
next.push(neighbor);
|
|
175
|
+
}
|
|
176
|
+
if (hit)
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
queue = next;
|
|
180
|
+
}
|
|
181
|
+
if (hit) {
|
|
182
|
+
findings.push({
|
|
183
|
+
lawId: law.id,
|
|
184
|
+
severity: law.severity,
|
|
185
|
+
engine: "graph",
|
|
186
|
+
file: src,
|
|
187
|
+
message: law.prose,
|
|
188
|
+
detail: `transitively reaches ${hit}`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return findings;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Evaluate one `graph` law: forbidden dependency cycles and/or forbidden
|
|
196
|
+
* transitive reachability, over the file-level import graph.
|
|
197
|
+
*
|
|
198
|
+
* @param db - An open connection to the project's index.
|
|
199
|
+
* @param law - The `graph` law to evaluate.
|
|
200
|
+
* @param paths - Optional project-relative paths restricting the graph.
|
|
201
|
+
* @returns The findings; `unresolved` is always 0 (cycles are read off the
|
|
202
|
+
* resolved graph, so a graph law never reports an unknown here).
|
|
203
|
+
*/
|
|
204
|
+
export function runGraphLaw(db, law, paths) {
|
|
205
|
+
const rule = law.verification.rule;
|
|
206
|
+
const adj = buildGraph(db, paths);
|
|
207
|
+
const findings = [];
|
|
208
|
+
const wantReachable = rule.reachable === true && rule.from != null && rule.to != null;
|
|
209
|
+
const wantCircular = rule.circular === true || (!rule.circular && !wantReachable);
|
|
210
|
+
if (wantCircular)
|
|
211
|
+
findings.push(...circularFindings(law, adj));
|
|
212
|
+
if (wantReachable)
|
|
213
|
+
findings.push(...reachableFindings(law, rule, adj));
|
|
214
|
+
return { findings, unresolved: 0 };
|
|
215
|
+
}
|
|
@@ -8,6 +8,31 @@ import { assetsDir } from "../../shared/paths.js";
|
|
|
8
8
|
// backend; executable-laws extends the same model with `ast`/`deps`/`process`
|
|
9
9
|
// backends by filling in more `verification.kind` cases — it never rewrites it.
|
|
10
10
|
const ASSETS = assetsDir(import.meta.url);
|
|
11
|
+
const depsRuleSchema = z.object({
|
|
12
|
+
name: z.string().optional(),
|
|
13
|
+
from: z.string(),
|
|
14
|
+
to: z.string(),
|
|
15
|
+
toNot: z.string().optional(),
|
|
16
|
+
type: z.enum(["forbidden", "required"]).optional(),
|
|
17
|
+
edgeKinds: z.array(z.string()).optional(),
|
|
18
|
+
});
|
|
19
|
+
const graphRuleSchema = z.object({
|
|
20
|
+
name: z.string().optional(),
|
|
21
|
+
circular: z.boolean().optional(),
|
|
22
|
+
reachable: z.boolean().optional(),
|
|
23
|
+
from: z.string().optional(),
|
|
24
|
+
to: z.string().optional(),
|
|
25
|
+
});
|
|
26
|
+
const verificationSchema = z.discriminatedUnion("kind", [
|
|
27
|
+
z.object({ kind: z.literal("path") }),
|
|
28
|
+
z.object({ kind: z.literal("deps"), rule: depsRuleSchema }),
|
|
29
|
+
z.object({ kind: z.literal("graph"), rule: graphRuleSchema }),
|
|
30
|
+
z.object({ kind: z.literal("ast") }),
|
|
31
|
+
z.object({ kind: z.literal("process") }),
|
|
32
|
+
z.object({ kind: z.literal("traceability") }),
|
|
33
|
+
z.object({ kind: z.literal("semantic") }),
|
|
34
|
+
z.object({ kind: z.literal("none") }),
|
|
35
|
+
]);
|
|
11
36
|
const lawSchema = z.object({
|
|
12
37
|
id: z.string().min(1),
|
|
13
38
|
title: z.string().min(1),
|
|
@@ -15,22 +40,70 @@ const lawSchema = z.object({
|
|
|
15
40
|
severity: z.enum(["error", "warn", "info"]),
|
|
16
41
|
scope: z.array(z.string()),
|
|
17
42
|
prose: z.string().min(1),
|
|
18
|
-
verification:
|
|
19
|
-
kind: z.enum(["path", "ast", "graph", "deps", "process", "traceability", "semantic", "none"]),
|
|
20
|
-
}),
|
|
43
|
+
verification: verificationSchema,
|
|
21
44
|
enforcement: z.enum(["bloqueo", "feedback", "gate"]),
|
|
22
45
|
source: z.object({ file: z.string(), line: z.number().optional() }),
|
|
23
46
|
});
|
|
24
|
-
|
|
47
|
+
// Reject a malformed `from`/`to` regex when the manifest is validated — naming
|
|
48
|
+
// the law id, not a bare array index — rather than letting it explode at verify
|
|
49
|
+
// time. Mirrors the generation-time treatment of malformed globs.
|
|
50
|
+
const manifestSchema = z
|
|
51
|
+
.object({
|
|
25
52
|
version: z.number(),
|
|
26
53
|
laws: z.array(lawSchema),
|
|
54
|
+
})
|
|
55
|
+
.superRefine((manifest, ctx) => {
|
|
56
|
+
manifest.laws.forEach((law, i) => {
|
|
57
|
+
const v = law.verification;
|
|
58
|
+
const patterns = [];
|
|
59
|
+
if (v.kind === "deps") {
|
|
60
|
+
patterns.push(["from", v.rule.from], ["to", v.rule.to], ["toNot", v.rule.toNot]);
|
|
61
|
+
}
|
|
62
|
+
else if (v.kind === "graph") {
|
|
63
|
+
patterns.push(["from", v.rule.from], ["to", v.rule.to]);
|
|
64
|
+
}
|
|
65
|
+
for (const [field, pattern] of patterns) {
|
|
66
|
+
if (pattern == null)
|
|
67
|
+
continue;
|
|
68
|
+
const err = regexError(pattern);
|
|
69
|
+
if (err) {
|
|
70
|
+
ctx.addIssue({
|
|
71
|
+
code: z.ZodIssueCode.custom,
|
|
72
|
+
path: ["laws", i, "verification", "rule", field],
|
|
73
|
+
message: `${law.id}: verification.rule.${field} is not a valid regular expression (${err})`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
27
78
|
});
|
|
28
|
-
/**
|
|
79
|
+
/** Backends evaluated on the action-time hot path (`speclaw_check`) — glob only. */
|
|
29
80
|
export const IMPLEMENTED_BACKENDS = ["path"];
|
|
30
|
-
/**
|
|
81
|
+
/** Backends evaluated by the batch verifier (`law_verify`) — they read the index. */
|
|
82
|
+
export const BATCH_BACKENDS = ["deps", "graph"];
|
|
83
|
+
/** True when a law is evaluated on the action-time hot path (only `path` today). */
|
|
31
84
|
export function hasBackend(law) {
|
|
32
85
|
return IMPLEMENTED_BACKENDS.includes(law.verification.kind);
|
|
33
86
|
}
|
|
87
|
+
/** True when a law is evaluated by the batch verifier (`deps`/`graph`). */
|
|
88
|
+
export function hasBatchBackend(law) {
|
|
89
|
+
return BATCH_BACKENDS.includes(law.verification.kind);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Validate a regular expression without using it, so manifest generation and
|
|
93
|
+
* `doctor` can fail loudly on a malformed `from`/`to` pattern.
|
|
94
|
+
*
|
|
95
|
+
* @param pattern - A regular-expression source string.
|
|
96
|
+
* @returns An error message if the pattern does not compile, else null.
|
|
97
|
+
*/
|
|
98
|
+
export function regexError(pattern) {
|
|
99
|
+
try {
|
|
100
|
+
new RegExp(pattern);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
return err.message;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
34
107
|
/** Absolute path to a project's compiled law manifest (under the gitignored `.speclaw/`). */
|
|
35
108
|
export function manifestPath(projectPath) {
|
|
36
109
|
return path.join(projectPath, ".speclaw", "laws-manifest.json");
|
|
@@ -78,6 +151,34 @@ export function seedManifest() {
|
|
|
78
151
|
const raw = JSON.parse(fs.readFileSync(path.join(ASSETS, "laws", "laws-manifest.json"), "utf8"));
|
|
79
152
|
return manifestSchema.parse(raw);
|
|
80
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* The manifest the batch verifier should use: the project's file when present,
|
|
156
|
+
* otherwise the shipped seed (so a clean CI clone does not silently pass).
|
|
157
|
+
*
|
|
158
|
+
* @param projectPath - Project root to read from.
|
|
159
|
+
*/
|
|
160
|
+
export function loadManifestForVerify(projectPath) {
|
|
161
|
+
return readLawManifest(projectPath) ?? seedManifest();
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Append shipped seed laws whose `id` is not already in `existing`. Existing
|
|
165
|
+
* entries are never overwritten — a curated law keeps its prose, scope, and
|
|
166
|
+
* enforcement across `update`.
|
|
167
|
+
*
|
|
168
|
+
* @param existing - The project's current manifest.
|
|
169
|
+
* @returns The merged manifest and the ids that were added.
|
|
170
|
+
*/
|
|
171
|
+
export function mergeSeedLaws(existing) {
|
|
172
|
+
const seed = seedManifest();
|
|
173
|
+
const have = new Set(existing.laws.map((l) => l.id));
|
|
174
|
+
const extra = seed.laws.filter((l) => !have.has(l.id));
|
|
175
|
+
if (extra.length === 0)
|
|
176
|
+
return { manifest: existing, added: [] };
|
|
177
|
+
return {
|
|
178
|
+
manifest: { ...existing, laws: [...existing.laws, ...extra] },
|
|
179
|
+
added: extra.map((l) => l.id),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
81
182
|
// ─── Glob matching (the `path` backend) ──────────────────────────────────────
|
|
82
183
|
/**
|
|
83
184
|
* Validate a scope glob without compiling it for use, so generation can fail
|
|
@@ -3,6 +3,7 @@ import { text } from "../../shared/mcp.js";
|
|
|
3
3
|
import { scaffold } from "./scaffold.js";
|
|
4
4
|
import { doctor } from "./doctor.js";
|
|
5
5
|
import { checkAction } from "./check.js";
|
|
6
|
+
import { verifyLaws } from "./verify.js";
|
|
6
7
|
import { loadPacks } from "../tools/packs.js";
|
|
7
8
|
import { AGENTS, configureAgent } from "../../shared/agents.js";
|
|
8
9
|
import { emptyReport } from "../../shared/install.js";
|
|
@@ -129,6 +130,22 @@ export function registerFoundation(server) {
|
|
|
129
130
|
payload: z.record(z.unknown()).describe("The raw hook event payload from the agent"),
|
|
130
131
|
},
|
|
131
132
|
}, async ({ projectPath, event, toolName, payload }) => text(checkAction({ projectPath, event: event, toolName, payload })));
|
|
133
|
+
server.registerTool("law_verify", {
|
|
134
|
+
// ≤30 words: the batch counterpart to speclaw_check, for the Stop hook and CI.
|
|
135
|
+
description: "Verify the project's deterministic laws (dependency and graph rules) and return violations by file. Run before claiming an architecture task done.",
|
|
136
|
+
inputSchema: {
|
|
137
|
+
projectPath: z.string().describe("Absolute path to the project"),
|
|
138
|
+
paths: z
|
|
139
|
+
.array(z.string())
|
|
140
|
+
.optional()
|
|
141
|
+
.describe("Restrict to source files under these project-relative paths"),
|
|
142
|
+
engines: z
|
|
143
|
+
.array(z.enum(["deps", "graph"]))
|
|
144
|
+
.optional()
|
|
145
|
+
.describe("Which batch engines to run; omit for all"),
|
|
146
|
+
lawIds: z.array(z.string()).optional().describe("Restrict to these law ids"),
|
|
147
|
+
},
|
|
148
|
+
}, async ({ projectPath, paths, engines, lawIds }) => text(verifyLaws({ projectPath, paths, engines: engines, lawIds })));
|
|
132
149
|
server.registerTool("doctor", {
|
|
133
150
|
description: "Verify a speclaw installation: ai-specs presence, the foundation (LAWS.md + standards + agent contracts), IDE symlinks health, the lawbook/ workflow, the Compass index, and .mcp.json wiring. Returns a checklist with remediation hints.",
|
|
134
151
|
inputSchema: { projectPath: z.string().describe("Absolute path to the project") },
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown projection of a {@link VerifyReport} for `$GITHUB_STEP_SUMMARY`
|
|
3
|
+
* (and stdout `--format markdown`). Honest by construction: it lists findings
|
|
4
|
+
* and skips, and never claims a requirement is covered (trace is not in this
|
|
5
|
+
* slice).
|
|
6
|
+
*
|
|
7
|
+
* @param report - The batch report.
|
|
8
|
+
*/
|
|
9
|
+
export function toMarkdown(report) {
|
|
10
|
+
const lines = [
|
|
11
|
+
"## speclaw · law verification",
|
|
12
|
+
"",
|
|
13
|
+
`**${report.summary.passed}** passed · **${report.summary.failed}** failed · **${report.summary.skipped}** skipped · **${report.summary.unknown}** unknown`,
|
|
14
|
+
"",
|
|
15
|
+
];
|
|
16
|
+
if (report.findings.length > 0) {
|
|
17
|
+
lines.push("### Findings", "", "| Law | Severity | Location |", "| :-- | :-- | :-- |");
|
|
18
|
+
for (const f of report.findings) {
|
|
19
|
+
const at = f.line ? `${f.file}:${f.line}` : f.file;
|
|
20
|
+
lines.push(`| \`${f.lawId}\` | ${f.severity} | \`${at}\` |`);
|
|
21
|
+
}
|
|
22
|
+
lines.push("");
|
|
23
|
+
}
|
|
24
|
+
if (report.skipped.length > 0) {
|
|
25
|
+
lines.push("<details><summary>Not evaluated</summary>", "");
|
|
26
|
+
for (const s of report.skipped) {
|
|
27
|
+
lines.push(`- \`${s.lawId}\` — ${s.reason}${s.detail ? `: ${s.detail}` : ""}`);
|
|
28
|
+
}
|
|
29
|
+
lines.push("", "</details>", "");
|
|
30
|
+
}
|
|
31
|
+
lines.push("_Deterministic · no model · no network. Reproduce with `speclaw verify`._", "");
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { fingerprint } from "./ci.js";
|
|
2
|
+
/** GitHub Code Scanning rejects a run with more than this many results. */
|
|
3
|
+
export const SARIF_RESULT_CAP = 5000;
|
|
4
|
+
function toSarifLevel(s) {
|
|
5
|
+
if (s === "error")
|
|
6
|
+
return "error";
|
|
7
|
+
if (s === "warn")
|
|
8
|
+
return "warning";
|
|
9
|
+
return "note";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A project-relative POSIX URI. Absolute paths (Unix `/…` or Windows `C:\…`)
|
|
13
|
+
* make GitHub drop the annotation; never emit them.
|
|
14
|
+
*
|
|
15
|
+
* @param file - A finding's `file` field (already project-relative POSIX).
|
|
16
|
+
*/
|
|
17
|
+
export function toRepoRelativeUri(file) {
|
|
18
|
+
return file
|
|
19
|
+
.replace(/\\/g, "/")
|
|
20
|
+
.replace(/^[A-Za-z]:/, "")
|
|
21
|
+
.replace(/^\/+/, "");
|
|
22
|
+
}
|
|
23
|
+
const SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
|
|
24
|
+
/**
|
|
25
|
+
* Project a {@link VerifyReport} to SARIF 2.1.0. One `rule` per loaded law
|
|
26
|
+
* (so GitHub groups alerts by law id); results truncated to
|
|
27
|
+
* {@link SARIF_RESULT_CAP} by severity; skipped laws become
|
|
28
|
+
* `toolExecutionNotifications`.
|
|
29
|
+
*
|
|
30
|
+
* @param report - The batch report.
|
|
31
|
+
* @param ctx - Package version and the laws that were loaded.
|
|
32
|
+
* @returns A JSON-serialisable SARIF log.
|
|
33
|
+
*/
|
|
34
|
+
export function toSarif(report, ctx) {
|
|
35
|
+
const sorted = [...report.findings].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
36
|
+
const dropped = Math.max(0, sorted.length - SARIF_RESULT_CAP);
|
|
37
|
+
const kept = dropped > 0 ? sorted.slice(0, SARIF_RESULT_CAP) : sorted;
|
|
38
|
+
const notifications = report.skipped.map((s) => ({
|
|
39
|
+
level: "warning",
|
|
40
|
+
message: {
|
|
41
|
+
text: `Law ${s.lawId} not evaluated: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`,
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
if (dropped > 0) {
|
|
45
|
+
notifications.push({
|
|
46
|
+
level: "warning",
|
|
47
|
+
message: { text: `Truncated ${dropped} findings (SARIF cap ${SARIF_RESULT_CAP})` },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
52
|
+
version: "2.1.0",
|
|
53
|
+
runs: [
|
|
54
|
+
{
|
|
55
|
+
tool: {
|
|
56
|
+
driver: {
|
|
57
|
+
name: "speclaw",
|
|
58
|
+
informationUri: "https://github.com/esneiderbravo/speclaw",
|
|
59
|
+
semanticVersion: ctx.speclawVersion,
|
|
60
|
+
rules: ctx.laws.map((law) => ({
|
|
61
|
+
id: law.id,
|
|
62
|
+
name: law.id.replace(/~/g, "_"),
|
|
63
|
+
shortDescription: { text: law.title },
|
|
64
|
+
fullDescription: { text: law.prose },
|
|
65
|
+
help: {
|
|
66
|
+
text: law.rationale ?? law.prose,
|
|
67
|
+
markdown: `**${law.title}**\n\n${law.prose}`,
|
|
68
|
+
},
|
|
69
|
+
properties: { tags: ["speclaw", law.verification.kind] },
|
|
70
|
+
})),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
results: kept.map((f) => ({
|
|
74
|
+
ruleId: f.lawId,
|
|
75
|
+
level: toSarifLevel(f.severity),
|
|
76
|
+
message: { text: f.detail ? `${f.message} ${f.detail}` : f.message },
|
|
77
|
+
locations: [
|
|
78
|
+
{
|
|
79
|
+
physicalLocation: {
|
|
80
|
+
artifactLocation: { uri: toRepoRelativeUri(f.file) },
|
|
81
|
+
region: { startLine: f.line ?? 1 },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
partialFingerprints: { "speclaw/v1": fingerprint(f) },
|
|
86
|
+
})),
|
|
87
|
+
invocations: [
|
|
88
|
+
{
|
|
89
|
+
executionSuccessful: report.summary.failed === 0,
|
|
90
|
+
toolExecutionNotifications: notifications,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -8,7 +8,7 @@ import { installWorkflow } from "../lawbook/register.js";
|
|
|
8
8
|
import { installPack, loadPacks } from "../tools/packs.js";
|
|
9
9
|
import { readManifest, writeManifest } from "../../shared/manifest.js";
|
|
10
10
|
import { pkgVersion } from "../../shared/version.js";
|
|
11
|
-
import { readLawManifest, seedManifest, writeLawManifest } from "./laws.js";
|
|
11
|
+
import { mergeSeedLaws, readLawManifest, seedManifest, writeLawManifest, } from "./laws.js";
|
|
12
12
|
import { installHooks } from "./hooks.js";
|
|
13
13
|
const ASSETS = assetsDir(import.meta.url);
|
|
14
14
|
// Every {{var}} the foundation templates may reference. Ones the agent didn't
|
|
@@ -25,19 +25,38 @@ const FOUNDATION_DEFAULTS = {
|
|
|
25
25
|
documentation_extra: "",
|
|
26
26
|
};
|
|
27
27
|
/**
|
|
28
|
-
* Ensure the project has a law manifest
|
|
29
|
-
*
|
|
30
|
-
* `.speclaw/`; seeding only when missing keeps a curated manifest (the MVP's
|
|
31
|
-
* authoring surface until executable-laws) from being overwritten on update.
|
|
28
|
+
* Ensure the project has a law manifest. Missing → seed. Present → append any
|
|
29
|
+
* shipped seed law whose `id` is absent (never overwrite a curated entry).
|
|
32
30
|
*/
|
|
33
31
|
function ensureLawManifest(projectPath, report) {
|
|
34
32
|
const existing = readLawManifest(projectPath);
|
|
35
|
-
if (existing)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
if (!existing) {
|
|
34
|
+
const seed = seedManifest();
|
|
35
|
+
writeLawManifest(projectPath, seed);
|
|
36
|
+
report.written.push(path.join(projectPath, ".speclaw", "laws-manifest.json"));
|
|
37
|
+
return seed;
|
|
38
|
+
}
|
|
39
|
+
const { manifest, added } = mergeSeedLaws(existing);
|
|
40
|
+
if (added.length > 0) {
|
|
41
|
+
writeLawManifest(projectPath, manifest);
|
|
42
|
+
report.written.push(path.join(projectPath, ".speclaw", "laws-manifest.json"));
|
|
43
|
+
}
|
|
44
|
+
return manifest;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Write `.github/workflows/speclaw.yml` from the shipped template when the
|
|
48
|
+
* path does not exist. Never overwrite — the user's CI is theirs.
|
|
49
|
+
*/
|
|
50
|
+
function ensureVerifyWorkflow(projectPath, report) {
|
|
51
|
+
const dest = path.join(projectPath, ".github", "workflows", "speclaw.yml");
|
|
52
|
+
if (fs.existsSync(dest)) {
|
|
53
|
+
report.skipped.push(dest);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const src = path.join(ASSETS, "workflows", "speclaw.yml");
|
|
57
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
58
|
+
fs.copyFileSync(src, dest);
|
|
59
|
+
report.written.push(dest);
|
|
41
60
|
}
|
|
42
61
|
/**
|
|
43
62
|
* Render the foundation: walk the module's assets/, mirror its structure into
|
|
@@ -131,6 +150,7 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
|
|
|
131
150
|
// configured. The seam is the manifest: check-dispatcher enforces `path` laws;
|
|
132
151
|
// executable-laws will extend the same manifest with more backends.
|
|
133
152
|
const lawManifest = ensureLawManifest(projectPath, report);
|
|
153
|
+
ensureVerifyWorkflow(projectPath, report);
|
|
134
154
|
report.hooks = installHooks(projectPath, agents, lawManifest, report, {
|
|
135
155
|
baselines: managedOpts.baselines,
|
|
136
156
|
backup: managedOpts.backup,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `file` (POSIX, project-relative) is at or under one of `paths`.
|
|
3
|
+
*
|
|
4
|
+
* @param file - A project-relative POSIX path.
|
|
5
|
+
* @param paths - Optional path prefixes; omitted or empty matches everything.
|
|
6
|
+
*/
|
|
7
|
+
export function underPaths(file, paths) {
|
|
8
|
+
if (!paths || paths.length === 0)
|
|
9
|
+
return true;
|
|
10
|
+
return paths.some((p) => {
|
|
11
|
+
const norm = p.replace(/\/+$/, "");
|
|
12
|
+
return file === norm || file.startsWith(norm + "/");
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { openDb, indexExists } from "../compass/db.js";
|
|
3
|
+
import { hasBatchBackend, loadManifestForVerify } from "./laws.js";
|
|
4
|
+
import { runDepsLaw } from "./deps.js";
|
|
5
|
+
import { runGraphLaw } from "./graph.js";
|
|
6
|
+
export { underPaths } from "./verify-model.js";
|
|
7
|
+
// The batch verifier behind the `law_verify` tool and the `speclaw laws verify`
|
|
8
|
+
// CLI. It evaluates every law whose backend reads the Compass graph (`deps`,
|
|
9
|
+
// `graph`) without a language model, and reports a result honest enough to
|
|
10
|
+
// trust: it distinguishes passed / failed / skipped / unknown and never counts a
|
|
11
|
+
// skip or an unknown as a pass. It is the single home of graph evaluation; the
|
|
12
|
+
// action-time evaluator (`check.ts`) shares this module's model and scope matcher
|
|
13
|
+
// but never runs these engines, so no index query lands on the keystroke budget.
|
|
14
|
+
/**
|
|
15
|
+
* Verify the project's deterministic `deps`/`graph` laws against the Compass
|
|
16
|
+
* index and return a four-state report.
|
|
17
|
+
*
|
|
18
|
+
* When the project has no index, every selected batch law is reported as
|
|
19
|
+
* `skipped` with reason `no-index` (never silently passed). When the gitignored
|
|
20
|
+
* manifest file is missing, the shipped seed is used so a clean clone does not
|
|
21
|
+
* report an empty pass. Each evaluated law lands in exactly one of `passed` /
|
|
22
|
+
* `failed` / `unknown`: it fails when the engine produced a finding, is
|
|
23
|
+
* `unknown` when it produced none but rests on unresolved edges (which could
|
|
24
|
+
* hide a violation), and passes otherwise.
|
|
25
|
+
*
|
|
26
|
+
* @param args - The project, and optional `paths` / `engines` / `lawIds` filters.
|
|
27
|
+
* @returns The {@link VerifyReport}.
|
|
28
|
+
*/
|
|
29
|
+
export function verifyLaws(args) {
|
|
30
|
+
const start = performance.now();
|
|
31
|
+
const findings = [];
|
|
32
|
+
const skipped = [];
|
|
33
|
+
const unknown = [];
|
|
34
|
+
let passed = 0;
|
|
35
|
+
let failed = 0;
|
|
36
|
+
const done = () => ({
|
|
37
|
+
schemaVersion: 1,
|
|
38
|
+
summary: {
|
|
39
|
+
evaluated: passed + failed + unknown.length,
|
|
40
|
+
passed,
|
|
41
|
+
failed,
|
|
42
|
+
skipped: skipped.length,
|
|
43
|
+
unknown: unknown.length,
|
|
44
|
+
},
|
|
45
|
+
findings,
|
|
46
|
+
skipped,
|
|
47
|
+
unknown,
|
|
48
|
+
elapsedMs: performance.now() - start,
|
|
49
|
+
});
|
|
50
|
+
const manifest = loadManifestForVerify(args.projectPath);
|
|
51
|
+
const engines = args.engines;
|
|
52
|
+
const selected = manifest.laws.filter((law) => {
|
|
53
|
+
if (!hasBatchBackend(law))
|
|
54
|
+
return false;
|
|
55
|
+
if (args.lawIds && !args.lawIds.includes(law.id))
|
|
56
|
+
return false;
|
|
57
|
+
if (engines && !engines.includes(law.verification.kind))
|
|
58
|
+
return false;
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
if (selected.length === 0)
|
|
62
|
+
return done();
|
|
63
|
+
if (!indexExists(args.projectPath)) {
|
|
64
|
+
for (const law of selected) {
|
|
65
|
+
skipped.push({
|
|
66
|
+
lawId: law.id,
|
|
67
|
+
reason: "no-index",
|
|
68
|
+
detail: "no .speclaw/index.db — build it with the compass_index tool",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return done();
|
|
72
|
+
}
|
|
73
|
+
const db = openDb(args.projectPath);
|
|
74
|
+
try {
|
|
75
|
+
for (const law of selected) {
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
result =
|
|
79
|
+
law.verification.kind === "deps"
|
|
80
|
+
? runDepsLaw(db, law, args.paths)
|
|
81
|
+
: runGraphLaw(db, law, args.paths);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
skipped.push({ lawId: law.id, reason: "engine-error", detail: err.message });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
findings.push(...result.findings);
|
|
88
|
+
if (result.findings.length > 0) {
|
|
89
|
+
failed++;
|
|
90
|
+
}
|
|
91
|
+
else if (result.unresolved > 0) {
|
|
92
|
+
unknown.push({
|
|
93
|
+
lawId: law.id,
|
|
94
|
+
detail: `evaluated with ${result.unresolved} unresolved reference(s) — result unknown`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
passed++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
db.close();
|
|
104
|
+
}
|
|
105
|
+
return done();
|
|
106
|
+
}
|
package/dist/shared/git.js
CHANGED
|
@@ -37,3 +37,52 @@ export function listTrackedPaths(projectPath, candidates) {
|
|
|
37
37
|
return res.status === 0 && res.stdout.trim().length > 0;
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The merge-base SHA of `ref` and `HEAD`, or `null` when the repo is missing,
|
|
42
|
+
* shallow, or `ref` is unknown. Callers that need a PR diff must treat `null`
|
|
43
|
+
* as "cannot see the base" — never as "nothing changed".
|
|
44
|
+
*
|
|
45
|
+
* @param projectPath - Directory inside the work tree.
|
|
46
|
+
* @param ref - The other end of the range (e.g. `origin/main`, a SHA).
|
|
47
|
+
*/
|
|
48
|
+
export function mergeBase(projectPath, ref) {
|
|
49
|
+
if (!isGitRepo(projectPath))
|
|
50
|
+
return null;
|
|
51
|
+
const res = spawnSync("git", ["-C", projectPath, "merge-base", ref, "HEAD"], {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
});
|
|
54
|
+
if (res.status !== 0)
|
|
55
|
+
return null;
|
|
56
|
+
const sha = res.stdout.trim();
|
|
57
|
+
return sha || null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Project-relative paths changed between `base` and `HEAD` (added, copied,
|
|
61
|
+
* modified, renamed). Uses `merge-base` so merge commits in the range are not
|
|
62
|
+
* counted as the PR's own work. Returns `[]` when the merge base cannot be
|
|
63
|
+
* resolved — callers in CI must fail that case rather than treat it as clean.
|
|
64
|
+
*
|
|
65
|
+
* @param projectPath - Directory inside the work tree.
|
|
66
|
+
* @param base - The other end of the range (branch name or SHA).
|
|
67
|
+
*/
|
|
68
|
+
export function changedFiles(projectPath, base) {
|
|
69
|
+
const mb = mergeBase(projectPath, base);
|
|
70
|
+
if (!mb)
|
|
71
|
+
return [];
|
|
72
|
+
const res = spawnSync("git", [
|
|
73
|
+
"-C",
|
|
74
|
+
projectPath,
|
|
75
|
+
"-c",
|
|
76
|
+
"core.quotePath=false",
|
|
77
|
+
"diff",
|
|
78
|
+
"--name-only",
|
|
79
|
+
"--diff-filter=ACMR",
|
|
80
|
+
`${mb}...HEAD`,
|
|
81
|
+
], { encoding: "utf8" });
|
|
82
|
+
if (res.status !== 0 || typeof res.stdout !== "string")
|
|
83
|
+
return [];
|
|
84
|
+
return res.stdout
|
|
85
|
+
.split("\n")
|
|
86
|
+
.map((l) => l.trim())
|
|
87
|
+
.filter(Boolean);
|
|
88
|
+
}
|