@esneiderbravo/speclaw 0.3.1 → 0.3.3

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
@@ -55,7 +55,8 @@ The `speclaw` command is now available everywhere — run `speclaw index`,
55
55
 
56
56
  1. **Ask which agents you use** (Claude Code, Cursor, Codex, …) — and configure
57
57
  only those. Add more later; nothing is forced on you.
58
- 2. Write the **foundation** (constitution + standards) and the **lawbook workflow**.
58
+ 2. Write the **foundation** (constitution + standards) and the **lawbook workflow**,
59
+ and compile your blocking laws into **agent hooks** for the agents that support them.
59
60
  3. **Index your code** with a live progress bar and a summary of what it found.
60
61
  4. Register the speclaw **MCP server** in each chosen agent's config.
61
62
  5. Print a prompt to paste into your agent so it fills the constitution with your
@@ -81,7 +82,7 @@ too (also `pnpm dlx` / `yarn dlx`) — but installing globally means you can run
81
82
 
82
83
  | Module | What it does |
83
84
  | :-- | :-- |
84
- | **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. |
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
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). |
86
87
  | **Lawbook** | speclaw's own spec-driven workflow: `draft → build → sync → archive` (and `explore`), backed by `lawbook_*` engine tools. No external CLI. |
87
88
  | **Tools** | Opt-in packs of skills and subagents (currently the dev-agents) that agents use for specific tasks. |
@@ -175,6 +176,12 @@ ai-specs` command to stop tracking it (they never touch your git index
175
176
  themselves). The agent directories (`.claude/`, `.cursor/`, …) are **left to
176
177
  you** — commit your own skills and commands there if you want to.
177
178
 
179
+ **Enforcement artifacts.** For agents that support hooks, speclaw merges its law
180
+ hooks into that agent's settings (e.g. `.claude/settings.json`) **by identity** —
181
+ it never touches hooks you added yourself. The compiled law manifest lives in
182
+ `.speclaw/laws-manifest.json` (gitignored, regenerated on `init`/`update`), and a
183
+ context-coverage log in `.speclaw/context-log.jsonl` feeds `speclaw doctor`.
184
+
178
185
  <br/>
179
186
 
180
187
  ## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle">&nbsp; Philosophy — why "laws"?
@@ -186,6 +193,21 @@ you** — commit your own skills and commands there if you want to.
186
193
  > knowledge explicit, executable, and binding, and gives agents a local map
187
194
  > (Compass) and a disciplined workflow (Lawbook) to act on it — without burning tokens.
188
195
 
196
+ This is why "enforced" is literal, not a metaphor. Anthropic's own guidance puts
197
+ it plainly:
198
+
199
+ > _"An instruction like 'never edit .env' in CLAUDE.md or a skill is **a request,
200
+ > not a guarantee**. A `PreToolUse` hook that blocks the edit is enforcement. If a
201
+ > rule must hold every time, make it a hook rather than a prompt instruction."_
202
+ > — [Claude Code — Hooks](https://code.claude.com/docs/en/hooks)
203
+
204
+ So `speclaw init` compiles your blocking laws into agent hooks: a law marked
205
+ `bloqueo` is denied at the keystroke (`PreToolUse`), citing the law's id, text,
206
+ and source. `speclaw check --dry-run --path <file>` previews what would block, and
207
+ `speclaw doctor` reports how many of your laws actually reached the agent's
208
+ context. Agents without hooks (Cursor, Codex) enforce the same laws in CI via
209
+ `speclaw verify`.
210
+
189
211
  <br/>
190
212
 
191
213
  ## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle">&nbsp; Staying up to date
@@ -0,0 +1,100 @@
1
+ import { ui, c } from "../lib/ui.js";
2
+ import { checkAction } from "../../modules/foundation/check.js";
3
+ import { hasBackend, readLawManifest } from "../../modules/foundation/laws.js";
4
+ /** Read all of stdin as UTF-8 text (used for `--hook-payload -`). */
5
+ async function readStdin() {
6
+ const chunks = [];
7
+ for await (const chunk of process.stdin)
8
+ chunks.push(chunk);
9
+ return Buffer.concat(chunks).toString("utf8");
10
+ }
11
+ /**
12
+ * `speclaw check` — the CLI twin of the `speclaw_check` MCP tool, and the
13
+ * command-hook fallback for agents driven purely by the CLI.
14
+ *
15
+ * - `--hook-payload -` reads a hook event JSON from stdin, evaluates it, prints
16
+ * the `hookSpecificOutput` contract, and exits 2 on `deny` (the documented
17
+ * command-hook block signal).
18
+ * - `--dry-run [--path P] [--event E]` previews the verdict for a synthetic
19
+ * action against path `P`, without blocking anything (always exits 0).
20
+ * - with no flags, prints a summary of the project's declared laws.
21
+ *
22
+ * @param flags - Parsed CLI flags.
23
+ */
24
+ export async function runCheck(flags) {
25
+ const cwd = process.cwd();
26
+ if (flags["hook-payload"]) {
27
+ const raw = await readStdin();
28
+ let payload;
29
+ try {
30
+ payload = JSON.parse(raw);
31
+ }
32
+ catch {
33
+ // Fail open: an unreadable payload must never block the agent.
34
+ console.log(JSON.stringify({ hookSpecificOutput: { permissionDecision: "allow" } }));
35
+ return;
36
+ }
37
+ const event = (payload.hook_event_name ?? payload.event ?? "PreToolUse");
38
+ const toolName = (payload.tool_name ?? payload.toolName);
39
+ const result = checkAction({ projectPath: cwd, event, toolName, payload });
40
+ const decision = result.verdict === "deny" ? "deny" : "allow";
41
+ console.log(JSON.stringify({
42
+ hookSpecificOutput: {
43
+ hookEventName: event,
44
+ permissionDecision: decision,
45
+ permissionDecisionReason: result.reason ?? "",
46
+ },
47
+ }));
48
+ if (result.verdict === "deny")
49
+ process.exit(2);
50
+ return;
51
+ }
52
+ if (flags["dry-run"]) {
53
+ const target = typeof flags.path === "string" ? flags.path : "";
54
+ const event = (typeof flags.event === "string" ? flags.event : "PreToolUse");
55
+ if (!target) {
56
+ ui.err(`Pass ${ui.code("--path <file>")} to preview what a law would do to that path.`);
57
+ process.exit(1);
58
+ }
59
+ const result = checkAction({
60
+ projectPath: cwd,
61
+ event,
62
+ payload: { tool_input: { file_path: target } },
63
+ });
64
+ ui.heading(`speclaw check --dry-run (${event})`);
65
+ ui.info(`target: ${c.cream(target)}`);
66
+ if (result.diagnostic)
67
+ ui.warn(result.diagnostic);
68
+ if (result.evaluated.length === 0) {
69
+ ui.ok("No law applies to this path.");
70
+ }
71
+ else {
72
+ for (const e of result.evaluated) {
73
+ // The message already opens with the law id (see check.ts `cite`).
74
+ const line = e.message ?? e.lawId;
75
+ if (e.passed)
76
+ ui.info(line);
77
+ else
78
+ ui.warn(line);
79
+ }
80
+ }
81
+ ui.plain();
82
+ const verb = result.verdict === "deny" ? c.red("would BLOCK") : c.green("would allow");
83
+ ui.plain(` verdict: ${verb} · evaluated in ${result.elapsedMs.toFixed(1)} ms`);
84
+ return;
85
+ }
86
+ // Default: summarize the declared laws.
87
+ const manifest = readLawManifest(cwd);
88
+ ui.heading("speclaw check");
89
+ if (!manifest) {
90
+ ui.warn("No law manifest — run `speclaw init` to seed .speclaw/laws-manifest.json.");
91
+ return;
92
+ }
93
+ ui.info(`${manifest.laws.length} law(s) declared:`);
94
+ for (const law of manifest.laws) {
95
+ const backend = hasBackend(law)
96
+ ? law.verification.kind
97
+ : `${law.verification.kind} (no backend yet)`;
98
+ ui.plain(` · ${c.cream(law.id)} — ${law.enforcement} · ${backend} · [${law.scope.join(", ")}]`);
99
+ }
100
+ }
@@ -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
+ }
package/dist/cli/index.js CHANGED
@@ -34,6 +34,8 @@ Lawbook (spec-driven workflow)
34
34
 
35
35
  Other
36
36
  doctor Verify the installation
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
37
39
  mcp Start the MCP server (used by your agent's config)
38
40
  help Show this help
39
41
  --version Print the installed speclaw version
@@ -110,6 +112,10 @@ async function dispatch(cmd, flags) {
110
112
  return (await import("./commands/lawbook.js")).runSpec(flags);
111
113
  case "doctor":
112
114
  return (await import("./commands/doctor.js")).runDoctor(flags);
115
+ case "check":
116
+ return (await import("./commands/check.js")).runCheck(flags);
117
+ case "laws":
118
+ return (await import("./commands/laws.js")).runLaws(flags);
113
119
  default:
114
120
  ui.err(`Unknown command: ${cmd}`);
115
121
  console.log(HELP);
@@ -0,0 +1,49 @@
1
+ {
2
+ "version": 1,
3
+ "laws": [
4
+ {
5
+ "id": "law~no-secrets-in-repo~1",
6
+ "title": "No secrets in the repository",
7
+ "rationale": "A committed secret is a leaked secret; enforcement at the keystroke is the only reliable guard.",
8
+ "severity": "error",
9
+ "scope": ["**/.env", "**/.env.*", "**/*.env"],
10
+ "prose": "Never write a .env file into the repository. Secrets live in the environment, not in version control.",
11
+ "verification": { "kind": "path" },
12
+ "enforcement": "bloqueo",
13
+ "source": { "file": "docs/standards/base-standards.md" }
14
+ },
15
+ {
16
+ "id": "law~local-first~1",
17
+ "title": "Local-first is non-negotiable",
18
+ "rationale": "speclaw runs entirely on the user's machine; every new dependency must be justified in the PR.",
19
+ "severity": "warn",
20
+ "scope": ["package.json"],
21
+ "prose": "Justify any new dependency in the PR: it must not break 'runs offline with no API keys'.",
22
+ "verification": { "kind": "path" },
23
+ "enforcement": "feedback",
24
+ "source": { "file": "LAWS.md" }
25
+ },
26
+ {
27
+ "id": "law~protect-templates~1",
28
+ "title": "Protect the templates",
29
+ "rationale": "Assets under src/modules/*/assets/** are product output; the build must copy them and the {{placeholder}} contracts must stay intact.",
30
+ "severity": "warn",
31
+ "scope": ["src/modules/*/assets/**"],
32
+ "prose": "Assets under src/modules/*/assets/** are product output — keep the {{placeholder}} and speclaw-init contracts intact, and let the build copy them (never hand-copy into dist/).",
33
+ "verification": { "kind": "path" },
34
+ "enforcement": "feedback",
35
+ "source": { "file": "LAWS.md" }
36
+ },
37
+ {
38
+ "id": "law~honest-attribution~1",
39
+ "title": "Keep attribution honest",
40
+ "rationale": "Compass reimplements CodeGraph and Lawbook reimplements OpenSpec (both MIT); ATTRIBUTION.md must stay accurate.",
41
+ "severity": "info",
42
+ "scope": ["ATTRIBUTION.md"],
43
+ "prose": "Keep ATTRIBUTION.md accurate as the Compass and Lawbook modules evolve.",
44
+ "verification": { "kind": "path" },
45
+ "enforcement": "feedback",
46
+ "source": { "file": "LAWS.md" }
47
+ }
48
+ ]
49
+ }
@@ -0,0 +1,144 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { performance } from "node:perf_hooks";
4
+ import { compileScope, hasBackend, manifestPath, matchCompiled, readLawManifest, } from "./laws.js";
5
+ const cache = new Map();
6
+ /**
7
+ * Load the project's compiled law index, using the in-process cache when the
8
+ * manifest is unchanged (keeps `PreToolUse` off the disk and off the regex
9
+ * compiler on the hot path). Returns null when the manifest is missing or
10
+ * unparseable, so the caller fails open.
11
+ */
12
+ function loadLaws(projectPath) {
13
+ let mtimeMs;
14
+ try {
15
+ mtimeMs = fs.statSync(manifestPath(projectPath)).mtimeMs;
16
+ }
17
+ catch {
18
+ cache.delete(projectPath);
19
+ return null;
20
+ }
21
+ const hit = cache.get(projectPath);
22
+ if (hit && hit.mtimeMs === mtimeMs)
23
+ return hit.laws;
24
+ const manifest = readLawManifest(projectPath);
25
+ if (!manifest)
26
+ return null;
27
+ const laws = manifest.laws.map((law) => ({ law, scope: compileScope(law.scope) }));
28
+ cache.set(projectPath, { mtimeMs, laws });
29
+ return laws;
30
+ }
31
+ /** Clear the manifest cache (used by tests and by a manifest rewrite in-process). */
32
+ export function clearLawCache() {
33
+ cache.clear();
34
+ }
35
+ /** POSIX-normalize a path and make it project-relative when it is absolute. */
36
+ function toRelPosix(projectPath, p) {
37
+ let rel = p;
38
+ if (path.isAbsolute(p))
39
+ rel = path.relative(projectPath, p);
40
+ return rel.split(path.sep).join("/").replace(/^\.\//, "");
41
+ }
42
+ /** Extract the file the action targets from a tool payload, across common shapes. */
43
+ function targetPath(projectPath, payload) {
44
+ const input = (payload.tool_input ?? payload.toolInput ?? payload);
45
+ const candidate = input.file_path ?? input.filePath ?? input.path ?? input.notebook_path ?? input.notebookPath;
46
+ return typeof candidate === "string" ? toRelPosix(projectPath, candidate) : null;
47
+ }
48
+ /** Extract the instructions file that was loaded, across common payload shapes. */
49
+ function loadedFile(projectPath, payload) {
50
+ const candidate = payload.file ?? payload.filePath ?? payload.path ?? payload.file_path;
51
+ return typeof candidate === "string" ? toRelPosix(projectPath, candidate) : null;
52
+ }
53
+ /** Format the citation for a matched law: id, source, and its literal prose. */
54
+ function cite(law) {
55
+ const at = law.source.line ? `${law.source.file}:${law.source.line}` : law.source.file;
56
+ return `${law.id} (${at}): «${law.prose}»`;
57
+ }
58
+ /** Append the ids of the laws a just-loaded instructions file declares to the context log. */
59
+ function recordContextCoverage(projectPath, laws, file) {
60
+ const ids = laws.filter((l) => (file ? l.source.file === file : true)).map((l) => l.id);
61
+ if (ids.length === 0)
62
+ return ids;
63
+ try {
64
+ const logDir = path.join(projectPath, ".speclaw");
65
+ fs.mkdirSync(logDir, { recursive: true });
66
+ const line = JSON.stringify({ at: new Date().toISOString(), file, lawIds: ids }) + "\n";
67
+ fs.appendFileSync(path.join(logDir, "context-log.jsonl"), line);
68
+ }
69
+ catch {
70
+ // Best-effort audit — never let a logging failure affect the verdict.
71
+ }
72
+ return ids;
73
+ }
74
+ /**
75
+ * Evaluate an agent action against every law whose scope matches its target and
76
+ * return an ACS-aligned verdict. Only the `path` backend is evaluated; laws with
77
+ * another (unimplemented) backend are skipped. The evaluator fails open: a
78
+ * missing or unparseable manifest, or any exception, yields `allow` with a
79
+ * diagnostic — an enforcement layer that blocks when its own checker crashes is
80
+ * worse than none.
81
+ *
82
+ * @param args - The project, hook event, optional tool name, and raw payload.
83
+ * @returns The verdict, the laws evaluated, an optional reason, and `elapsedMs`.
84
+ */
85
+ export function checkAction(args) {
86
+ const start = performance.now();
87
+ const done = (r) => ({
88
+ ...r,
89
+ elapsedMs: performance.now() - start,
90
+ });
91
+ try {
92
+ const laws = loadLaws(args.projectPath);
93
+ if (!laws) {
94
+ return done({
95
+ verdict: "allow",
96
+ evaluated: [],
97
+ diagnostic: "law manifest missing or unparseable — failing open",
98
+ });
99
+ }
100
+ if (args.event === "InstructionsLoaded") {
101
+ const ids = recordContextCoverage(args.projectPath, laws.map((il) => il.law), loadedFile(args.projectPath, args.payload));
102
+ return done({
103
+ verdict: "allow",
104
+ evaluated: ids.map((id) => ({ lawId: id, severity: "info", passed: true })),
105
+ });
106
+ }
107
+ const target = targetPath(args.projectPath, args.payload);
108
+ if (target === null)
109
+ return done({ verdict: "allow", evaluated: [] });
110
+ const matched = laws
111
+ .filter((il) => hasBackend(il.law) && matchCompiled(il.scope, target))
112
+ .map((il) => il.law);
113
+ const evaluated = matched.map((l) => ({
114
+ lawId: l.id,
115
+ severity: l.severity,
116
+ passed: l.enforcement !== "bloqueo" || args.event !== "PreToolUse",
117
+ message: cite(l),
118
+ file: target,
119
+ }));
120
+ // Only a `bloqueo` law, and only on PreToolUse, stops the keystroke. Every
121
+ // other match still enters context as a message the agent reads.
122
+ const blocking = args.event === "PreToolUse" ? matched.filter((l) => l.enforcement === "bloqueo") : [];
123
+ if (blocking.length > 0) {
124
+ return done({
125
+ verdict: "deny",
126
+ evaluated,
127
+ reason: `Blocked by ${blocking.map(cite).join("; ")}`,
128
+ });
129
+ }
130
+ const messages = evaluated.map((e) => e.message).filter(Boolean);
131
+ return done({
132
+ verdict: "allow",
133
+ evaluated,
134
+ reason: messages.length ? messages.join("; ") : undefined,
135
+ });
136
+ }
137
+ catch (err) {
138
+ return done({
139
+ verdict: "allow",
140
+ evaluated: [],
141
+ diagnostic: `check failed open: ${err.message}`,
142
+ });
143
+ }
144
+ }
@@ -0,0 +1,117 @@
1
+ import { underPaths } from "./verify.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,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { AGENTS, detectConfiguredAgents } from "../../shared/agents.js";
3
+ import { AGENTS, agentById, detectConfiguredAgents } from "../../shared/agents.js";
4
+ import { globError, hasBackend, hasBatchBackend, readLawManifest } from "./laws.js";
4
5
  /**
5
6
  * Run the speclaw installation health checks against a project: ai-specs and
6
7
  * LAWS.md presence, agent contracts, the docs/standards set, per-agent IDE
@@ -99,5 +100,110 @@ export function doctor(projectPath) {
99
100
  ok: has(".mcp.json"),
100
101
  detail: has(".mcp.json") ? "present" : "missing — scaffold writes it",
101
102
  });
103
+ lawEnforcementChecks(projectPath, checks);
102
104
  return checks;
103
105
  }
106
+ /** The law ids recorded as loaded into agent context, from the append-only log. */
107
+ function loadedLawIds(projectPath) {
108
+ const loaded = new Set();
109
+ try {
110
+ const log = fs.readFileSync(path.join(projectPath, ".speclaw", "context-log.jsonl"), "utf8");
111
+ for (const line of log.split(/\r?\n/)) {
112
+ if (!line.trim())
113
+ continue;
114
+ const ids = JSON.parse(line).lawIds ?? [];
115
+ for (const id of ids)
116
+ loaded.add(id);
117
+ }
118
+ }
119
+ catch {
120
+ // No log yet — hooks have not recorded any context loads.
121
+ }
122
+ return loaded;
123
+ }
124
+ /**
125
+ * Append the law-enforcement health checks: manifest presence and backend
126
+ * coverage, glob validity (caught here rather than at runtime), context-coverage
127
+ * with the post-compact caveat, and the agents where blocking laws don't apply.
128
+ */
129
+ function lawEnforcementChecks(projectPath, checks) {
130
+ const manifest = readLawManifest(projectPath);
131
+ if (!manifest) {
132
+ checks.push({
133
+ name: "law manifest",
134
+ ok: false,
135
+ detail: "missing — run `speclaw init`/`update` to seed .speclaw/laws-manifest.json",
136
+ });
137
+ return;
138
+ }
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));
142
+ checks.push({
143
+ name: "law manifest",
144
+ ok: true,
145
+ detail: `${manifest.laws.length} law(s): ${withPath.length} enforced (path), ` +
146
+ `${withBatch.length} verified (deps/graph)` +
147
+ (noBackend.length
148
+ ? `, ${noBackend.length} declared without a backend yet (${noBackend
149
+ .map((l) => l.id)
150
+ .join(", ")})`
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.)
168
+ const badGlobs = [];
169
+ for (const law of manifest.laws) {
170
+ for (const pattern of law.scope) {
171
+ const err = globError(pattern);
172
+ if (err)
173
+ badGlobs.push(`${law.id}: ${pattern} (${err})`);
174
+ }
175
+ }
176
+ checks.push({
177
+ name: "law scope globs",
178
+ ok: badGlobs.length === 0,
179
+ detail: badGlobs.length === 0 ? "all valid" : `malformed: ${badGlobs.join("; ")}`,
180
+ });
181
+ // Context coverage — which laws actually entered the agent's context.
182
+ const loaded = loadedLawIds(projectPath);
183
+ const declared = manifest.laws.map((l) => l.id);
184
+ const missing = declared.filter((id) => !loaded.has(id));
185
+ checks.push({
186
+ name: "law context coverage",
187
+ ok: true,
188
+ detail: `${declared.length - missing.length} of ${declared.length} laws loaded into context` +
189
+ (missing.length ? ` — not yet loaded: ${missing.join(", ")}` : "") +
190
+ ". Note: after a compact, root CLAUDE.md is re-injected but `paths:`-scoped rules are not," +
191
+ " until a matching file is next touched — so a path-scoped law can be out of context" +
192
+ " exactly when it matters, which is why it is also a hook.",
193
+ });
194
+ // Agent asymmetry — where blocking laws cannot be enforced at the keystroke.
195
+ const configured = detectConfiguredAgents(projectPath);
196
+ const unhooked = configured
197
+ .map((id) => agentById(id))
198
+ .filter((a) => a && !a.hooks)
199
+ .map((a) => a.label);
200
+ if (unhooked.length) {
201
+ const blocking = manifest.laws.filter((l) => l.enforcement === "bloqueo").length;
202
+ checks.push({
203
+ name: "hook coverage across agents",
204
+ ok: true,
205
+ detail: `no hook support for ${unhooked.join(", ")} — your ${blocking} blocking law(s) apply ` +
206
+ "there only via `speclaw verify`.",
207
+ });
208
+ }
209
+ }