@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 +24 -2
- package/dist/cli/commands/check.js +100 -0
- package/dist/cli/commands/laws.js +48 -0
- package/dist/cli/index.js +6 -0
- package/dist/modules/foundation/assets/laws/laws-manifest.json +49 -0
- package/dist/modules/foundation/check.js +144 -0
- package/dist/modules/foundation/deps.js +117 -0
- package/dist/modules/foundation/doctor.js +107 -1
- package/dist/modules/foundation/graph.js +215 -0
- package/dist/modules/foundation/hooks.js +165 -0
- package/dist/modules/foundation/laws.js +284 -0
- package/dist/modules/foundation/register.js +30 -0
- package/dist/modules/foundation/scaffold.js +26 -0
- package/dist/modules/foundation/verify.js +107 -0
- package/dist/shared/agents.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { agentById } from "../../shared/agents.js";
|
|
4
|
+
import { sha256 } from "../../shared/install.js";
|
|
5
|
+
import { globError, hasBackend } from "./laws.js";
|
|
6
|
+
/** The speclaw hook object — its `{type, server}` pair is the merge identity. */
|
|
7
|
+
const SPECLAW_HOOK = {
|
|
8
|
+
type: "mcp_tool",
|
|
9
|
+
server: "speclaw",
|
|
10
|
+
tool: "speclaw_check",
|
|
11
|
+
timeout: 5,
|
|
12
|
+
};
|
|
13
|
+
/** Tool-name matcher for the file-mutating tools the `path` backend can evaluate. */
|
|
14
|
+
const MUTATION_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
|
|
15
|
+
/** True when a hook object is one speclaw owns (safe to replace on merge). */
|
|
16
|
+
function isSpeclawHook(h) {
|
|
17
|
+
const o = h;
|
|
18
|
+
return o?.type === "mcp_tool" && o?.server === "speclaw";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Compile a law manifest into the hook groups speclaw contributes, one per event
|
|
22
|
+
* the laws demand: `PreToolUse` when any `bloqueo` law exists, `PostToolUse` for
|
|
23
|
+
* `feedback`, `Stop` for `gate`, and `InstructionsLoaded` whenever any law exists
|
|
24
|
+
* (the context-coverage audit). A law whose scope contains a malformed glob is
|
|
25
|
+
* excluded and reported, so a bad pattern fails loudly at generation rather than
|
|
26
|
+
* silently matching nothing at runtime.
|
|
27
|
+
*
|
|
28
|
+
* @param manifest - The project's law manifest.
|
|
29
|
+
* @returns The per-event hook groups and the list of laws rejected for bad globs.
|
|
30
|
+
*/
|
|
31
|
+
export function compileHooks(manifest) {
|
|
32
|
+
const invalid = [];
|
|
33
|
+
const valid = [];
|
|
34
|
+
for (const law of manifest.laws) {
|
|
35
|
+
const bad = law.scope.map((p) => ({ p, e: globError(p) })).find((x) => x.e);
|
|
36
|
+
if (bad)
|
|
37
|
+
invalid.push({ lawId: law.id, pattern: bad.p, error: bad.e });
|
|
38
|
+
else
|
|
39
|
+
valid.push(law);
|
|
40
|
+
}
|
|
41
|
+
const byEvent = {};
|
|
42
|
+
const hasBloqueo = valid.some((l) => l.enforcement === "bloqueo" && hasBackend(l));
|
|
43
|
+
const hasFeedback = valid.some((l) => l.enforcement === "feedback" && hasBackend(l));
|
|
44
|
+
const hasGate = valid.some((l) => l.enforcement === "gate");
|
|
45
|
+
if (hasBloqueo)
|
|
46
|
+
byEvent.PreToolUse = [{ matcher: MUTATION_MATCHER, hooks: [{ ...SPECLAW_HOOK }] }];
|
|
47
|
+
if (hasFeedback)
|
|
48
|
+
byEvent.PostToolUse = [{ matcher: MUTATION_MATCHER, hooks: [{ ...SPECLAW_HOOK }] }];
|
|
49
|
+
if (hasGate)
|
|
50
|
+
byEvent.Stop = [{ hooks: [{ ...SPECLAW_HOOK }] }];
|
|
51
|
+
if (valid.length > 0)
|
|
52
|
+
byEvent.InstructionsLoaded = [{ hooks: [{ ...SPECLAW_HOOK }] }];
|
|
53
|
+
return { byEvent, invalid };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Merge speclaw's compiled hook groups into an existing `hooks` object by
|
|
57
|
+
* identity: for every event, drop the groups speclaw owns (a group whose hooks
|
|
58
|
+
* are all speclaw's) and re-add the freshly compiled ones, never touching a
|
|
59
|
+
* group with a foreign `server` or `type`. Idempotent, marker-free, and it
|
|
60
|
+
* cannot delete another tool's hooks.
|
|
61
|
+
*
|
|
62
|
+
* @param existing - The current `hooks` object from the agent's settings (any shape).
|
|
63
|
+
* @param compiled - speclaw's per-event hook groups from {@link compileHooks}.
|
|
64
|
+
* @returns A new `hooks` object with speclaw's entries reconciled in.
|
|
65
|
+
*/
|
|
66
|
+
export function mergeHooks(existing, compiled) {
|
|
67
|
+
const out = {};
|
|
68
|
+
const events = new Set([...Object.keys(existing ?? {}), ...Object.keys(compiled)]);
|
|
69
|
+
for (const event of events) {
|
|
70
|
+
const prior = Array.isArray(existing?.[event]) ? existing[event] : [];
|
|
71
|
+
// Keep foreign groups: drop speclaw hooks from each group, then any group left empty.
|
|
72
|
+
const kept = prior
|
|
73
|
+
.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !isSpeclawHook(h)) }))
|
|
74
|
+
.filter((g) => g.hooks.length > 0);
|
|
75
|
+
const mine = compiled[event] ?? [];
|
|
76
|
+
const merged = [...kept, ...mine];
|
|
77
|
+
if (merged.length > 0)
|
|
78
|
+
out[event] = merged;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Install (or refresh) speclaw's hooks into one agent's settings file, merging by
|
|
84
|
+
* identity and honoring the managed-file baseline: a settings file that diverged
|
|
85
|
+
* from what speclaw last wrote is backed up to `<file>.bak` first when `backup`
|
|
86
|
+
* is set, and always reported. The baseline sha of the written file is recorded.
|
|
87
|
+
*
|
|
88
|
+
* @param projectPath - Project root.
|
|
89
|
+
* @param agent - The agent whose `hooks` capability names the settings file and key.
|
|
90
|
+
* @param compiled - speclaw's compiled hook groups.
|
|
91
|
+
* @param report - Install report mutated in place.
|
|
92
|
+
* @param opts - Managed-file behavior: recorded baselines, backup, and a record sink.
|
|
93
|
+
*/
|
|
94
|
+
function installForAgent(projectPath, agent, compiled, report, opts) {
|
|
95
|
+
if (!agent.hooks)
|
|
96
|
+
return;
|
|
97
|
+
const settingsPath = path.join(projectPath, agent.hooks.file);
|
|
98
|
+
const rel = path.relative(projectPath, settingsPath);
|
|
99
|
+
let settings = {};
|
|
100
|
+
let current = null;
|
|
101
|
+
if (fs.existsSync(settingsPath)) {
|
|
102
|
+
current = fs.readFileSync(settingsPath, "utf8");
|
|
103
|
+
try {
|
|
104
|
+
settings = JSON.parse(current);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A settings file we cannot parse is the user's — never clobber it silently.
|
|
108
|
+
report.skipped.push(`${settingsPath} (unparseable — left untouched)`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
settings[agent.hooks.key] = mergeHooks(settings[agent.hooks.key], compiled);
|
|
113
|
+
const content = JSON.stringify(settings, null, 2) + "\n";
|
|
114
|
+
const newSha = sha256(content);
|
|
115
|
+
if (current !== null) {
|
|
116
|
+
if (sha256(current) === newSha) {
|
|
117
|
+
if (opts.record)
|
|
118
|
+
opts.record[rel] = newSha;
|
|
119
|
+
return; // already current — no drift
|
|
120
|
+
}
|
|
121
|
+
const baseline = opts.baselines?.[rel];
|
|
122
|
+
if (!baseline || sha256(current) !== baseline) {
|
|
123
|
+
if (opts.backup) {
|
|
124
|
+
fs.copyFileSync(settingsPath, settingsPath + ".bak");
|
|
125
|
+
report.backedUp.push(settingsPath);
|
|
126
|
+
}
|
|
127
|
+
report.refreshedDiverged.push(settingsPath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
131
|
+
fs.writeFileSync(settingsPath, content);
|
|
132
|
+
report.written.push(`${settingsPath} (speclaw hooks)`);
|
|
133
|
+
if (opts.record)
|
|
134
|
+
opts.record[rel] = newSha;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Compile the manifest and install speclaw's hooks into every hook-capable agent
|
|
138
|
+
* among those selected, skipping agents without a `hooks` capability (Cursor,
|
|
139
|
+
* Codex, Windsurf) by construction. A malformed glob excludes only that law and
|
|
140
|
+
* is surfaced in the result.
|
|
141
|
+
*
|
|
142
|
+
* @param projectPath - Project root.
|
|
143
|
+
* @param agentIds - Ids of the agents configured for this project.
|
|
144
|
+
* @param manifest - The project's law manifest.
|
|
145
|
+
* @param report - Install report mutated in place.
|
|
146
|
+
* @param opts - Managed-file behavior: recorded baselines, backup, and a record sink.
|
|
147
|
+
* @returns Which agents were hooked, which were skipped, and any rejected laws.
|
|
148
|
+
*/
|
|
149
|
+
export function installHooks(projectPath, agentIds, manifest, report, opts) {
|
|
150
|
+
const { byEvent, invalid } = compileHooks(manifest);
|
|
151
|
+
const hooked = [];
|
|
152
|
+
const unhooked = [];
|
|
153
|
+
for (const id of agentIds) {
|
|
154
|
+
const agent = agentById(id);
|
|
155
|
+
if (!agent)
|
|
156
|
+
continue;
|
|
157
|
+
if (!agent.hooks) {
|
|
158
|
+
unhooked.push(id);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
installForAgent(projectPath, agent, byEvent, report, opts);
|
|
162
|
+
hooked.push(id);
|
|
163
|
+
}
|
|
164
|
+
return { hooked, unhooked, invalid };
|
|
165
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { assetsDir } from "../../shared/paths.js";
|
|
5
|
+
// The machine-readable law model and its manifest. This is the contract seam
|
|
6
|
+
// (`.speclaw/laws-manifest.json`) between where laws come from and how they are
|
|
7
|
+
// enforced: check-dispatcher owns the schema and the single `path` verification
|
|
8
|
+
// backend; executable-laws extends the same model with `ast`/`deps`/`process`
|
|
9
|
+
// backends by filling in more `verification.kind` cases — it never rewrites it.
|
|
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
|
+
]);
|
|
36
|
+
const lawSchema = z.object({
|
|
37
|
+
id: z.string().min(1),
|
|
38
|
+
title: z.string().min(1),
|
|
39
|
+
rationale: z.string().optional(),
|
|
40
|
+
severity: z.enum(["error", "warn", "info"]),
|
|
41
|
+
scope: z.array(z.string()),
|
|
42
|
+
prose: z.string().min(1),
|
|
43
|
+
verification: verificationSchema,
|
|
44
|
+
enforcement: z.enum(["bloqueo", "feedback", "gate"]),
|
|
45
|
+
source: z.object({ file: z.string(), line: z.number().optional() }),
|
|
46
|
+
});
|
|
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({
|
|
52
|
+
version: z.number(),
|
|
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
|
+
});
|
|
78
|
+
});
|
|
79
|
+
/** Backends evaluated on the action-time hot path (`speclaw_check`) — glob only. */
|
|
80
|
+
export const IMPLEMENTED_BACKENDS = ["path"];
|
|
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). */
|
|
84
|
+
export function hasBackend(law) {
|
|
85
|
+
return IMPLEMENTED_BACKENDS.includes(law.verification.kind);
|
|
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
|
+
}
|
|
107
|
+
/** Absolute path to a project's compiled law manifest (under the gitignored `.speclaw/`). */
|
|
108
|
+
export function manifestPath(projectPath) {
|
|
109
|
+
return path.join(projectPath, ".speclaw", "laws-manifest.json");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Read and validate a project's law manifest.
|
|
113
|
+
*
|
|
114
|
+
* @param projectPath - Project root to read from.
|
|
115
|
+
* @returns The parsed manifest, or null if it is missing or unparseable (the
|
|
116
|
+
* caller fails open — a broken manifest never blocks the agent).
|
|
117
|
+
*/
|
|
118
|
+
export function readLawManifest(projectPath) {
|
|
119
|
+
try {
|
|
120
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath(projectPath), "utf8"));
|
|
121
|
+
return manifestSchema.parse(raw);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Write a project's law manifest, validating every law first.
|
|
129
|
+
*
|
|
130
|
+
* @param projectPath - Project root to write into.
|
|
131
|
+
* @param manifest - The manifest to persist; each law is schema-validated.
|
|
132
|
+
* @throws If any law fails validation.
|
|
133
|
+
*/
|
|
134
|
+
export function writeLawManifest(projectPath, manifest) {
|
|
135
|
+
const validated = manifestSchema.parse(manifest);
|
|
136
|
+
const p = manifestPath(projectPath);
|
|
137
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
138
|
+
fs.writeFileSync(p, JSON.stringify(validated, null, 2) + "\n");
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The starter law manifest shipped with speclaw, seeded from a speclaw-style
|
|
142
|
+
* project's own `path`-verifiable Project-specific laws. It is the source the
|
|
143
|
+
* MVP compiles into `.speclaw/laws-manifest.json`; once executable-laws lands,
|
|
144
|
+
* laws are authored in `docs/standards/*` and compiled here instead. Laws whose
|
|
145
|
+
* scope does not match a given repo are simply inert there.
|
|
146
|
+
*
|
|
147
|
+
* @returns The validated seed manifest read from the module's assets.
|
|
148
|
+
* @throws If the seed asset is missing or fails validation.
|
|
149
|
+
*/
|
|
150
|
+
export function seedManifest() {
|
|
151
|
+
const raw = JSON.parse(fs.readFileSync(path.join(ASSETS, "laws", "laws-manifest.json"), "utf8"));
|
|
152
|
+
return manifestSchema.parse(raw);
|
|
153
|
+
}
|
|
154
|
+
// ─── Glob matching (the `path` backend) ──────────────────────────────────────
|
|
155
|
+
/**
|
|
156
|
+
* Validate a scope glob without compiling it for use, so generation can fail
|
|
157
|
+
* loudly on a malformed pattern (e.g. an unclosed `[`) rather than silently
|
|
158
|
+
* matching zero files at runtime.
|
|
159
|
+
*
|
|
160
|
+
* @param pattern - A single scope glob (a leading `!` negation is allowed).
|
|
161
|
+
* @returns An error message if the glob is malformed, else null.
|
|
162
|
+
*/
|
|
163
|
+
export function globError(pattern) {
|
|
164
|
+
try {
|
|
165
|
+
compileGlob(pattern);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
return err.message;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Regex-special characters that are literals in a glob and must be escaped. */
|
|
173
|
+
const REGEX_SPECIALS = new Set([".", "+", "^", "$", "(", ")", "|", "\\"]);
|
|
174
|
+
/**
|
|
175
|
+
* Compile a glob into an anchored regular expression matching a POSIX-style
|
|
176
|
+
* relative path. Supports `**` (any run of segments), `*`/`?` (within a
|
|
177
|
+
* segment), `{a,b}` alternation, and `[...]`/`[!...]` character classes.
|
|
178
|
+
*
|
|
179
|
+
* @param pattern - A single scope glob; a leading `!` is stripped by the caller.
|
|
180
|
+
* @returns A `RegExp` anchored to the whole path.
|
|
181
|
+
* @throws If the glob contains an unclosed `[` or `{`.
|
|
182
|
+
*/
|
|
183
|
+
export function compileGlob(pattern) {
|
|
184
|
+
let re = "";
|
|
185
|
+
let braceDepth = 0;
|
|
186
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
187
|
+
const ch = pattern[i];
|
|
188
|
+
if (ch === "*") {
|
|
189
|
+
if (pattern[i + 1] === "*") {
|
|
190
|
+
// `**` (optionally followed by `/`) spans any number of segments.
|
|
191
|
+
i++;
|
|
192
|
+
if (pattern[i + 1] === "/")
|
|
193
|
+
i++;
|
|
194
|
+
re += "(?:[^/]*(?:/|$))*";
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
re += "[^/]*";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else if (ch === "?") {
|
|
201
|
+
re += "[^/]";
|
|
202
|
+
}
|
|
203
|
+
else if (ch === "{") {
|
|
204
|
+
braceDepth++;
|
|
205
|
+
re += "(?:";
|
|
206
|
+
}
|
|
207
|
+
else if (ch === "}") {
|
|
208
|
+
if (braceDepth === 0)
|
|
209
|
+
throw new Error(`unmatched '}' in glob: ${pattern}`);
|
|
210
|
+
braceDepth--;
|
|
211
|
+
re += ")";
|
|
212
|
+
}
|
|
213
|
+
else if (ch === "," && braceDepth > 0) {
|
|
214
|
+
re += "|";
|
|
215
|
+
}
|
|
216
|
+
else if (ch === "[") {
|
|
217
|
+
const close = pattern.indexOf("]", i + 1);
|
|
218
|
+
if (close === -1)
|
|
219
|
+
throw new Error(`unclosed '[' in glob: ${pattern}`);
|
|
220
|
+
let cls = pattern.slice(i + 1, close);
|
|
221
|
+
if (cls.startsWith("!"))
|
|
222
|
+
cls = "^" + cls.slice(1);
|
|
223
|
+
re += `[${cls}]`;
|
|
224
|
+
i = close;
|
|
225
|
+
}
|
|
226
|
+
else if (REGEX_SPECIALS.has(ch)) {
|
|
227
|
+
re += "\\" + ch;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
re += ch;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (braceDepth !== 0)
|
|
234
|
+
throw new Error(`unclosed '{' in glob: ${pattern}`);
|
|
235
|
+
return new RegExp(`^${re}$`);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Compile a law's scope globs into regexes once, so runtime matching does no
|
|
239
|
+
* regex compilation on the critical path. Malformed globs are dropped (they are
|
|
240
|
+
* caught and reported at generation time).
|
|
241
|
+
*
|
|
242
|
+
* @param scope - The law's scope globs.
|
|
243
|
+
* @returns The compiled positive and negative matchers.
|
|
244
|
+
*/
|
|
245
|
+
export function compileScope(scope) {
|
|
246
|
+
const positives = [];
|
|
247
|
+
const negatives = [];
|
|
248
|
+
for (const g of scope) {
|
|
249
|
+
const negated = g.startsWith("!");
|
|
250
|
+
try {
|
|
251
|
+
(negated ? negatives : positives).push(compileGlob(negated ? g.slice(1) : g));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Skip malformed globs — generation already flagged them.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { matchAll: scope.length === 0, positives, negatives };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Test a target path against a pre-compiled scope. Positive globs are OR-ed; a
|
|
261
|
+
* `!`-prefixed glob excludes; an empty scope matches everything.
|
|
262
|
+
*
|
|
263
|
+
* @param compiled - The scope compiled by {@link compileScope}.
|
|
264
|
+
* @param target - A POSIX-style project-relative path (forward slashes).
|
|
265
|
+
* @returns True when the target is in scope.
|
|
266
|
+
*/
|
|
267
|
+
export function matchCompiled(compiled, target) {
|
|
268
|
+
const included = compiled.matchAll ||
|
|
269
|
+
compiled.positives.length === 0 ||
|
|
270
|
+
compiled.positives.some((r) => r.test(target));
|
|
271
|
+
return included && !compiled.negatives.some((r) => r.test(target));
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Test whether a target path matches a law's scope, compiling on the spot.
|
|
275
|
+
* Convenience for non-hot paths (doctor, dry-run); the evaluator uses
|
|
276
|
+
* {@link compileScope} + {@link matchCompiled} to stay off the compiler.
|
|
277
|
+
*
|
|
278
|
+
* @param scope - The law's scope globs.
|
|
279
|
+
* @param target - A POSIX-style project-relative path (forward slashes).
|
|
280
|
+
* @returns True when the target is in scope. Malformed globs never match.
|
|
281
|
+
*/
|
|
282
|
+
export function matchesScope(scope, target) {
|
|
283
|
+
return matchCompiled(compileScope(scope), target);
|
|
284
|
+
}
|