@esneiderbravo/speclaw 0.3.2 → 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
@@ -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 laws verify` / `law_verify`, which reports each law 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. |
@@ -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
@@ -35,6 +35,7 @@ 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
38
39
  mcp Start the MCP server (used by your agent's config)
39
40
  help Show this help
40
41
  --version Print the installed speclaw version
@@ -113,6 +114,8 @@ async function dispatch(cmd, flags) {
113
114
  return (await import("./commands/doctor.js")).runDoctor(flags);
114
115
  case "check":
115
116
  return (await import("./commands/check.js")).runCheck(flags);
117
+ case "laws":
118
+ return (await import("./commands/laws.js")).runLaws(flags);
116
119
  default:
117
120
  ui.err(`Unknown command: ${cmd}`);
118
121
  console.log(HELP);
@@ -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,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 withBackend = manifest.laws.filter(hasBackend);
140
- const noBackend = manifest.laws.filter((l) => !hasBackend(l));
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): ${withBackend.length} enforced (path)` +
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
- // Glob validationa malformed scope must fail loudly here, never silently
152
- // match zero files at runtime.
153
+ // Graph-engine availabilitythe 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.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: z.object({
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
- const manifestSchema = z.object({
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
- /** The verification backends this change actually evaluates at runtime. */
79
+ /** Backends evaluated on the action-time hot path (`speclaw_check`) — glob only. */
29
80
  export const IMPLEMENTED_BACKENDS = ["path"];
30
- /** True when a law's verification backend is evaluated at runtime (only `path` today). */
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");
@@ -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,107 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { openDb, indexExists } from "../compass/db.js";
3
+ import { hasBatchBackend, readLawManifest } from "./laws.js";
4
+ import { runDepsLaw } from "./deps.js";
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
+ }
15
+ /**
16
+ * Verify the project's deterministic `deps`/`graph` laws against the Compass
17
+ * index and return a four-state report.
18
+ *
19
+ * 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.
24
+ *
25
+ * @param args - The project, and optional `paths` / `engines` / `lawIds` filters.
26
+ * @returns The {@link VerifyReport}.
27
+ */
28
+ export function verifyLaws(args) {
29
+ const start = performance.now();
30
+ const findings = [];
31
+ const skipped = [];
32
+ const unknown = [];
33
+ let passed = 0;
34
+ let failed = 0;
35
+ const done = () => ({
36
+ schemaVersion: 1,
37
+ summary: {
38
+ evaluated: passed + failed + unknown.length,
39
+ passed,
40
+ failed,
41
+ skipped: skipped.length,
42
+ unknown: unknown.length,
43
+ },
44
+ findings,
45
+ skipped,
46
+ unknown,
47
+ elapsedMs: performance.now() - start,
48
+ });
49
+ const manifest = readLawManifest(args.projectPath);
50
+ if (!manifest)
51
+ return done();
52
+ const engines = args.engines;
53
+ const selected = manifest.laws.filter((law) => {
54
+ if (!hasBatchBackend(law))
55
+ return false;
56
+ if (args.lawIds && !args.lawIds.includes(law.id))
57
+ return false;
58
+ if (engines && !engines.includes(law.verification.kind))
59
+ return false;
60
+ return true;
61
+ });
62
+ if (selected.length === 0)
63
+ return done();
64
+ if (!indexExists(args.projectPath)) {
65
+ for (const law of selected) {
66
+ skipped.push({
67
+ lawId: law.id,
68
+ reason: "no-index",
69
+ detail: "no .speclaw/index.db — build it with the compass_index tool",
70
+ });
71
+ }
72
+ return done();
73
+ }
74
+ const db = openDb(args.projectPath);
75
+ try {
76
+ for (const law of selected) {
77
+ let result;
78
+ try {
79
+ result =
80
+ law.verification.kind === "deps"
81
+ ? runDepsLaw(db, law, args.paths)
82
+ : runGraphLaw(db, law, args.paths);
83
+ }
84
+ catch (err) {
85
+ skipped.push({ lawId: law.id, reason: "engine-error", detail: err.message });
86
+ continue;
87
+ }
88
+ findings.push(...result.findings);
89
+ if (result.findings.length > 0) {
90
+ failed++;
91
+ }
92
+ else if (result.unresolved > 0) {
93
+ unknown.push({
94
+ lawId: law.id,
95
+ detail: `evaluated with ${result.unresolved} unresolved reference(s) — result unknown`,
96
+ });
97
+ }
98
+ else {
99
+ passed++;
100
+ }
101
+ }
102
+ }
103
+ finally {
104
+ db.close();
105
+ }
106
+ return done();
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },