@esneiderbravo/speclaw 0.3.3 → 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 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`), and architectural laws are verified deterministically against the Compass graph — dependency rules (`deps`) and cycles (`graph`) — via `speclaw laws verify` / `law_verify`, which reports each law as passed, failed, skipped, or unknown (an unresolved reference is *unknown*, never a silent pass). |
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">&nbsp; 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">&nbsp; Staying up to date
214
243
 
215
244
  speclaw checks for new releases in the background (at most once a day) and nudges
@@ -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
@@ -36,6 +36,7 @@ Other
36
36
  doctor Verify the installation
37
37
  check Evaluate an action against the laws (hooks call this; --dry-run to preview)
38
38
  laws verify Verify the deterministic dependency/graph laws against the index
39
+ verify Verify laws for CI: exit codes, --sarif, --json, --strict-engines
39
40
  mcp Start the MCP server (used by your agent's config)
40
41
  help Show this help
41
42
  --version Print the installed speclaw version
@@ -116,6 +117,8 @@ async function dispatch(cmd, flags) {
116
117
  return (await import("./commands/check.js")).runCheck(flags);
117
118
  case "laws":
118
119
  return (await import("./commands/laws.js")).runLaws(flags);
120
+ case "verify":
121
+ return (await import("./commands/verify.js")).runVerify(flags);
119
122
  default:
120
123
  ui.err(`Unknown command: ${cmd}`);
121
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
+ }
@@ -1,4 +1,4 @@
1
- import { underPaths } from "./verify.js";
1
+ import { underPaths } from "./verify-model.js";
2
2
  /** Substitute `$1`, `$2`, … in a pattern with capture groups from a match. */
3
3
  function applyGroups(pattern, match) {
4
4
  return pattern.replace(/\$(\d+)/g, (_whole, d) => match[Number(d)] ?? "");
@@ -1,4 +1,4 @@
1
- import { underPaths } from "./verify.js";
1
+ import { underPaths } from "./verify-model.js";
2
2
  /** Build the cross-file dependency graph, restricted to `paths` when given. */
3
3
  function buildGraph(db, paths) {
4
4
  const rows = db
@@ -151,6 +151,34 @@ export function seedManifest() {
151
151
  const raw = JSON.parse(fs.readFileSync(path.join(ASSETS, "laws", "laws-manifest.json"), "utf8"));
152
152
  return manifestSchema.parse(raw);
153
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
+ }
154
182
  // ─── Glob matching (the `path` backend) ──────────────────────────────────────
155
183
  /**
156
184
  * Validate a scope glob without compiling it for use, so generation can fail
@@ -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, seeding it from the package's starter
29
- * laws when absent. The manifest is a derived artifact under the gitignored
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
- return existing;
37
- const seed = seedManifest();
38
- writeLawManifest(projectPath, seed);
39
- report.written.push(path.join(projectPath, ".speclaw", "laws-manifest.json"));
40
- return seed;
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
+ }
@@ -1,26 +1,27 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { openDb, indexExists } from "../compass/db.js";
3
- import { hasBatchBackend, readLawManifest } from "./laws.js";
3
+ import { hasBatchBackend, loadManifestForVerify } from "./laws.js";
4
4
  import { runDepsLaw } from "./deps.js";
5
5
  import { runGraphLaw } from "./graph.js";
6
- /** True when `file` (POSIX, project-relative) is at or under one of `paths`. */
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
- }
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.
15
14
  /**
16
15
  * Verify the project's deterministic `deps`/`graph` laws against the Compass
17
16
  * index and return a four-state report.
18
17
  *
19
18
  * When the project has no index, every selected batch law is reported as
20
- * `skipped` with reason `no-index` (never silently passed). Each evaluated law
21
- * lands in exactly one of `passed` / `failed` / `unknown`: it fails when the
22
- * engine produced a finding, is `unknown` when it produced none but rests on
23
- * unresolved edges (which could hide a violation), and passes otherwise.
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.
24
25
  *
25
26
  * @param args - The project, and optional `paths` / `engines` / `lawIds` filters.
26
27
  * @returns The {@link VerifyReport}.
@@ -46,9 +47,7 @@ export function verifyLaws(args) {
46
47
  unknown,
47
48
  elapsedMs: performance.now() - start,
48
49
  });
49
- const manifest = readLawManifest(args.projectPath);
50
- if (!manifest)
51
- return done();
50
+ const manifest = loadManifestForVerify(args.projectPath);
52
51
  const engines = args.engines;
53
52
  const selected = manifest.laws.filter((law) => {
54
53
  if (!hasBatchBackend(law))
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },