@esneiderbravo/speclaw 0.3.13 → 1.0.0
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 +88 -72
- package/dist/cli/commands/index-build.js +12 -3
- package/dist/cli/commands/lawbook.js +1 -0
- package/dist/cli/commands/laws.js +149 -8
- package/dist/cli/commands/owners.js +44 -0
- package/dist/cli/commands/query.js +52 -10
- package/dist/cli/commands/update.js +35 -5
- package/dist/cli/commands/verify.js +8 -0
- package/dist/cli/index.js +15 -4
- package/dist/modules/compass/budget.js +128 -0
- package/dist/modules/compass/db.js +290 -30
- package/dist/modules/compass/diff-context.js +134 -0
- package/dist/modules/compass/embed-input.js +28 -0
- package/dist/modules/compass/embedder.js +3 -1
- package/dist/modules/compass/explore-rich.js +134 -0
- package/dist/modules/compass/extract.js +86 -0
- package/dist/modules/compass/hybrid.js +318 -0
- package/dist/modules/compass/impact-summary.js +33 -0
- package/dist/modules/compass/indexer.js +204 -33
- package/dist/modules/compass/merkle.js +76 -0
- package/dist/modules/compass/pagerank.js +122 -0
- package/dist/modules/compass/rank.js +95 -0
- package/dist/modules/compass/register.js +169 -75
- package/dist/modules/foundation/check.js +4 -2
- package/dist/modules/foundation/compile-laws.js +212 -0
- package/dist/modules/foundation/context-budget.js +1 -14
- package/dist/modules/foundation/dialects/agentsmd.js +95 -0
- package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
- package/dist/modules/foundation/dialects/coderabbit.js +27 -0
- package/dist/modules/foundation/dialects/copilot.js +35 -0
- package/dist/modules/foundation/dialects/index.js +5 -0
- package/dist/modules/foundation/dialects/types.js +58 -0
- package/dist/modules/foundation/doctor.js +266 -14
- package/dist/modules/foundation/import-rules.js +67 -0
- package/dist/modules/foundation/integrity.js +307 -0
- package/dist/modules/foundation/laws-parse.js +131 -0
- package/dist/modules/foundation/laws.js +5 -0
- package/dist/modules/foundation/lock.js +283 -0
- package/dist/modules/foundation/ownership.js +4 -0
- package/dist/modules/foundation/register-core.js +57 -88
- package/dist/modules/foundation/register.js +1 -21
- package/dist/modules/foundation/scaffold.js +25 -0
- package/dist/modules/foundation/scan.js +227 -0
- package/dist/modules/foundation/setup-tool.js +96 -0
- package/dist/modules/foundation/verify.js +9 -1
- package/dist/modules/lawbook/assets/commands/archive.md +1 -1
- package/dist/modules/lawbook/assets/commands/draft.md +1 -1
- package/dist/modules/lawbook/assets/commands/explore.md +1 -1
- package/dist/modules/lawbook/assets/commands/sync.md +2 -2
- package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
- package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
- package/dist/modules/lawbook/change-tool.js +90 -0
- package/dist/modules/lawbook/coverage.js +45 -6
- package/dist/modules/lawbook/ears.js +417 -0
- package/dist/modules/lawbook/engine.js +29 -0
- package/dist/modules/lawbook/register.js +96 -54
- package/dist/modules/lawbook/spec-items.js +4 -1
- package/dist/modules/team/owners.js +464 -0
- package/dist/modules/tools/register.js +4 -26
- package/dist/shared/deprecation.js +99 -0
- package/dist/shared/exposure.js +4 -19
- package/dist/shared/git.js +25 -0
- package/dist/shared/mcp.js +29 -3
- package/dist/shared/output-budget.js +68 -0
- package/dist/shared/tool-catalog.js +49 -0
- package/package.json +4 -3
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule-file integrity verification (digests + injection scan).
|
|
3
|
+
* Distinct from {@link verifyLaws} (deps/graph engines).
|
|
4
|
+
*/
|
|
5
|
+
// Covers: req~integrity-verify~1, req~laws-accept-human~1
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { digestText, discoverIntegrityPaths, integrityPolicy, isRegenerableIdeMirror, lockfilePath, prepareIntegrityText, readLockfile, refreshLockfile, rootDigest, writeLockfile, } from "./lock.js";
|
|
9
|
+
import { loadScanSuppressions, scanPaths } from "./scan.js";
|
|
10
|
+
/**
|
|
11
|
+
* Verify digests and/or scan rule files. Missing lockfile is soft (ok=true)
|
|
12
|
+
* with guidance to run `speclaw laws lock`.
|
|
13
|
+
*/
|
|
14
|
+
export function verifyIntegrity(opts) {
|
|
15
|
+
const projectPath = opts.projectPath;
|
|
16
|
+
const checks = opts.checks ?? "both";
|
|
17
|
+
const doIntegrity = checks === "integrity" || checks === "both";
|
|
18
|
+
const doScan = checks === "scan" || checks === "both";
|
|
19
|
+
let lock;
|
|
20
|
+
try {
|
|
21
|
+
lock = readLockfile(projectPath);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
lockPresent: fs.existsSync(lockfilePath(projectPath)),
|
|
27
|
+
rootMatches: false,
|
|
28
|
+
guidance: err.message,
|
|
29
|
+
files: [],
|
|
30
|
+
symlinks: [],
|
|
31
|
+
findings: [],
|
|
32
|
+
verifyFindings: [
|
|
33
|
+
{
|
|
34
|
+
lawId: "integrity~lockfile~1",
|
|
35
|
+
severity: "error",
|
|
36
|
+
engine: "integrity",
|
|
37
|
+
file: "speclaw.lock",
|
|
38
|
+
message: err.message,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (!lock && doIntegrity) {
|
|
44
|
+
const scanned = doScan ? scanAll(projectPath) : [];
|
|
45
|
+
const scanErrors = scanned.filter((f) => f.severity === "error");
|
|
46
|
+
return {
|
|
47
|
+
ok: scanErrors.length === 0,
|
|
48
|
+
lockPresent: false,
|
|
49
|
+
rootMatches: false,
|
|
50
|
+
guidance: "No speclaw.lock — run `speclaw laws lock` to create the baseline.",
|
|
51
|
+
files: [],
|
|
52
|
+
symlinks: [],
|
|
53
|
+
findings: scanned,
|
|
54
|
+
verifyFindings: scanned
|
|
55
|
+
.filter((f) => f.severity === "error" || f.severity === "warn")
|
|
56
|
+
.map(scanToFinding),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const files = [];
|
|
60
|
+
const symlinks = [];
|
|
61
|
+
const verifyFindings = [];
|
|
62
|
+
let ok = true;
|
|
63
|
+
if (lock && doIntegrity) {
|
|
64
|
+
const accepted = new Map(lock.accepted.map((a) => [a.path, a.digest]));
|
|
65
|
+
for (const [rel, entry] of Object.entries(lock.files)) {
|
|
66
|
+
const abs = path.join(projectPath, rel);
|
|
67
|
+
if (!fs.existsSync(abs)) {
|
|
68
|
+
// Stale lock entries for regenerable IDE mirrors (ai-specs gitignored) —
|
|
69
|
+
// warn only; never fail CI on a clean clone.
|
|
70
|
+
if (isRegenerableIdeMirror(rel)) {
|
|
71
|
+
files.push({
|
|
72
|
+
path: rel,
|
|
73
|
+
ownership: entry.ownership,
|
|
74
|
+
status: "missing",
|
|
75
|
+
expected: entry.digest,
|
|
76
|
+
});
|
|
77
|
+
verifyFindings.push({
|
|
78
|
+
lawId: "integrity~missing~1",
|
|
79
|
+
severity: "warn",
|
|
80
|
+
engine: "integrity",
|
|
81
|
+
file: rel,
|
|
82
|
+
message: "Regenerable IDE rule mirror missing — run `speclaw update` or `speclaw laws lock` to drop stale entries",
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const severity = entry.ownership === "strict" ? "error" : "warn";
|
|
87
|
+
if (entry.ownership === "strict")
|
|
88
|
+
ok = false;
|
|
89
|
+
files.push({
|
|
90
|
+
path: rel,
|
|
91
|
+
ownership: entry.ownership,
|
|
92
|
+
status: "missing",
|
|
93
|
+
expected: entry.digest,
|
|
94
|
+
});
|
|
95
|
+
verifyFindings.push({
|
|
96
|
+
lawId: "integrity~missing~1",
|
|
97
|
+
severity,
|
|
98
|
+
engine: "integrity",
|
|
99
|
+
file: rel,
|
|
100
|
+
message: entry.ownership === "strict"
|
|
101
|
+
? `Managed rule file missing (expected ${entry.digest})`
|
|
102
|
+
: `Advisory rule/source file missing (expected ${entry.digest})`,
|
|
103
|
+
});
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const raw = prepareIntegrityText(rel, fs.readFileSync(abs, "utf8"));
|
|
107
|
+
const actual = digestText(raw);
|
|
108
|
+
if (actual === entry.digest) {
|
|
109
|
+
files.push({
|
|
110
|
+
path: rel,
|
|
111
|
+
ownership: entry.ownership,
|
|
112
|
+
status: "ok",
|
|
113
|
+
expected: entry.digest,
|
|
114
|
+
actual,
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (accepted.get(rel) === actual) {
|
|
119
|
+
files.push({
|
|
120
|
+
path: rel,
|
|
121
|
+
ownership: entry.ownership,
|
|
122
|
+
status: "accepted",
|
|
123
|
+
expected: entry.digest,
|
|
124
|
+
actual,
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const unified = `--- speclaw.lock (${entry.digest})\n+++ ${rel} (${actual})\n`;
|
|
129
|
+
const isStrict = entry.ownership === "strict";
|
|
130
|
+
if (isStrict)
|
|
131
|
+
ok = false;
|
|
132
|
+
files.push({
|
|
133
|
+
path: rel,
|
|
134
|
+
ownership: entry.ownership,
|
|
135
|
+
status: isStrict ? "modified" : "advisory-modified",
|
|
136
|
+
expected: entry.digest,
|
|
137
|
+
actual,
|
|
138
|
+
diff: unified,
|
|
139
|
+
});
|
|
140
|
+
verifyFindings.push({
|
|
141
|
+
lawId: isStrict ? "integrity~digest-mismatch~1" : "integrity~advisory-mismatch~1",
|
|
142
|
+
severity: isStrict ? "error" : "warn",
|
|
143
|
+
engine: "integrity",
|
|
144
|
+
file: rel,
|
|
145
|
+
message: isStrict
|
|
146
|
+
? `Digest mismatch for strict rule file`
|
|
147
|
+
: `Advisory rule/source file changed`,
|
|
148
|
+
detail: `expected ${entry.digest} found ${actual}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const { files: discovered } = discoverIntegrityPaths(projectPath);
|
|
152
|
+
for (const rel of discovered) {
|
|
153
|
+
if (lock.files[rel])
|
|
154
|
+
continue;
|
|
155
|
+
const ownership = integrityPolicy(rel);
|
|
156
|
+
if (ownership === "scan-only")
|
|
157
|
+
continue;
|
|
158
|
+
files.push({ path: rel, ownership, status: "untracked-by-lock" });
|
|
159
|
+
verifyFindings.push({
|
|
160
|
+
lawId: "integrity~untracked~1",
|
|
161
|
+
severity: "warn",
|
|
162
|
+
engine: "integrity",
|
|
163
|
+
file: rel,
|
|
164
|
+
message: `Rule file not listed in speclaw.lock`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
for (const [rel, entry] of Object.entries(lock.symlinks)) {
|
|
168
|
+
const abs = path.join(projectPath, rel);
|
|
169
|
+
let actual = null;
|
|
170
|
+
try {
|
|
171
|
+
if (fs.lstatSync(abs).isSymbolicLink())
|
|
172
|
+
actual = fs.readlinkSync(abs);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
actual = null;
|
|
176
|
+
}
|
|
177
|
+
if (actual === null) {
|
|
178
|
+
symlinks.push({
|
|
179
|
+
path: rel,
|
|
180
|
+
expectedTarget: entry.target,
|
|
181
|
+
actualTarget: null,
|
|
182
|
+
status: "missing",
|
|
183
|
+
});
|
|
184
|
+
ok = false;
|
|
185
|
+
verifyFindings.push({
|
|
186
|
+
lawId: "integrity~symlink~1",
|
|
187
|
+
severity: "error",
|
|
188
|
+
engine: "integrity",
|
|
189
|
+
file: rel,
|
|
190
|
+
message: `Managed symlink missing (expected → ${entry.target})`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
else if (normalizeLink(actual) !== normalizeLink(entry.target)) {
|
|
194
|
+
symlinks.push({
|
|
195
|
+
path: rel,
|
|
196
|
+
expectedTarget: entry.target,
|
|
197
|
+
actualTarget: actual,
|
|
198
|
+
status: "mismatch",
|
|
199
|
+
});
|
|
200
|
+
ok = false;
|
|
201
|
+
verifyFindings.push({
|
|
202
|
+
lawId: "integrity~symlink~1",
|
|
203
|
+
severity: "error",
|
|
204
|
+
engine: "integrity",
|
|
205
|
+
file: rel,
|
|
206
|
+
message: `Managed symlink retargeted`,
|
|
207
|
+
detail: `expected ${entry.target} found ${actual}`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
symlinks.push({
|
|
212
|
+
path: rel,
|
|
213
|
+
expectedTarget: entry.target,
|
|
214
|
+
actualTarget: actual,
|
|
215
|
+
status: "ok",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const findings = doScan ? scanAll(projectPath) : [];
|
|
221
|
+
for (const f of findings.filter((x) => x.severity === "error")) {
|
|
222
|
+
ok = false;
|
|
223
|
+
verifyFindings.push(scanToFinding(f));
|
|
224
|
+
}
|
|
225
|
+
for (const f of findings.filter((x) => x.severity === "warn")) {
|
|
226
|
+
verifyFindings.push(scanToFinding(f));
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
ok,
|
|
230
|
+
lockPresent: lock !== null,
|
|
231
|
+
rootMatches: lock ? rootDigest(lock.files) === lock.root : false,
|
|
232
|
+
guidance: !lock && doIntegrity
|
|
233
|
+
? "No speclaw.lock — run `speclaw laws lock` to create the baseline."
|
|
234
|
+
: undefined,
|
|
235
|
+
files,
|
|
236
|
+
symlinks,
|
|
237
|
+
findings,
|
|
238
|
+
verifyFindings,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Fold integrity findings into a batch {@link VerifyReport} (SARIF / exit codes).
|
|
243
|
+
* Error-severity findings increment `summary.failed`.
|
|
244
|
+
*/
|
|
245
|
+
export function foldIntegrityIntoReport(report, integrity) {
|
|
246
|
+
for (const f of integrity.verifyFindings) {
|
|
247
|
+
report.findings.push(f);
|
|
248
|
+
if (f.severity === "error")
|
|
249
|
+
report.summary.failed += 1;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function normalizeLink(t) {
|
|
253
|
+
return t.split("\\").join("/").replace(/\/+$/, "");
|
|
254
|
+
}
|
|
255
|
+
function scanAll(projectPath) {
|
|
256
|
+
const { files } = discoverIntegrityPaths(projectPath);
|
|
257
|
+
return scanPaths(projectPath, files, {
|
|
258
|
+
suppressions: loadScanSuppressions(projectPath),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function scanToFinding(f) {
|
|
262
|
+
return {
|
|
263
|
+
lawId: f.detector.replace(/\//g, "~"),
|
|
264
|
+
severity: f.severity,
|
|
265
|
+
engine: "integrity",
|
|
266
|
+
file: f.path,
|
|
267
|
+
line: f.line,
|
|
268
|
+
message: f.message,
|
|
269
|
+
detail: f.excerpt,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Accept a changed file's current digest into the lock (caller must ensure TTY).
|
|
274
|
+
* Updates digest, root, and accepted[].
|
|
275
|
+
*/
|
|
276
|
+
export function acceptLockPath(projectPath, relPath, meta) {
|
|
277
|
+
const lock = readLockfile(projectPath);
|
|
278
|
+
if (!lock)
|
|
279
|
+
throw new Error("No speclaw.lock — run `speclaw laws lock` first.");
|
|
280
|
+
const abs = path.join(projectPath, relPath);
|
|
281
|
+
if (!fs.existsSync(abs))
|
|
282
|
+
throw new Error(`File not found: ${relPath}`);
|
|
283
|
+
const raw = prepareIntegrityText(relPath, fs.readFileSync(abs, "utf8"));
|
|
284
|
+
const dig = digestText(raw);
|
|
285
|
+
const ownership = lock.files[relPath]?.ownership ?? integrityPolicy(relPath);
|
|
286
|
+
if (ownership === "scan-only") {
|
|
287
|
+
throw new Error(`${relPath} is scan-only — digests are not locked for this path.`);
|
|
288
|
+
}
|
|
289
|
+
lock.files[relPath] = { digest: dig, ownership };
|
|
290
|
+
lock.root = rootDigest(lock.files);
|
|
291
|
+
const entry = {
|
|
292
|
+
path: relPath,
|
|
293
|
+
digest: dig,
|
|
294
|
+
at: meta.at ?? new Date().toISOString(),
|
|
295
|
+
by: meta.by,
|
|
296
|
+
note: meta.note,
|
|
297
|
+
};
|
|
298
|
+
lock.accepted = [...lock.accepted.filter((a) => a.path !== relPath), entry];
|
|
299
|
+
writeLockfile(projectPath, lock);
|
|
300
|
+
return lock;
|
|
301
|
+
}
|
|
302
|
+
/** Re-export refresh for CLI `laws lock`. */
|
|
303
|
+
export { refreshLockfile };
|
|
304
|
+
/** True when stdin is a TTY (accept requires this). */
|
|
305
|
+
export function isInteractiveTty() {
|
|
306
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
307
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Parse laws declared in markdown via HTML comment blocks:
|
|
5
|
+
*
|
|
6
|
+
* ```html
|
|
7
|
+
* <!-- speclaw:law
|
|
8
|
+
* id: law~example~1
|
|
9
|
+
* title: Example
|
|
10
|
+
* severity: warn
|
|
11
|
+
* scope: src/**\/*.ts
|
|
12
|
+
* enforcement: feedback
|
|
13
|
+
* verification: semantic
|
|
14
|
+
* -->
|
|
15
|
+
* Prose that follows until the next heading or comment.
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
const BLOCK_RE = /<!--\s*speclaw:law\b([\s\S]*?)-->/gi;
|
|
19
|
+
function parseMeta(raw) {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
22
|
+
const m = /^\s*([a-zA-Z_][\w-]*)\s*:\s*(.*?)\s*$/.exec(line);
|
|
23
|
+
if (m)
|
|
24
|
+
out[m[1].toLowerCase()] = m[2];
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function verificationOf(kind) {
|
|
29
|
+
switch (kind) {
|
|
30
|
+
case "path":
|
|
31
|
+
return { kind: "path" };
|
|
32
|
+
case "ast":
|
|
33
|
+
return { kind: "ast" };
|
|
34
|
+
case "deps":
|
|
35
|
+
return { kind: "deps", rule: { from: "^", to: "^", type: "forbidden" } };
|
|
36
|
+
case "graph":
|
|
37
|
+
return { kind: "graph", rule: { circular: true } };
|
|
38
|
+
case "process":
|
|
39
|
+
return { kind: "process" };
|
|
40
|
+
case "traceability":
|
|
41
|
+
return { kind: "traceability" };
|
|
42
|
+
case "none":
|
|
43
|
+
return { kind: "none" };
|
|
44
|
+
case "semantic":
|
|
45
|
+
default:
|
|
46
|
+
return { kind: "semantic" };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function proseAfter(content, endIndex) {
|
|
50
|
+
const rest = content.slice(endIndex);
|
|
51
|
+
const next = rest.search(/\n#{1,3}\s|\n<!--\s*speclaw:law\b/i);
|
|
52
|
+
const chunk = (next === -1 ? rest : rest.slice(0, next)).trim();
|
|
53
|
+
return chunk.replace(/^#+\s*.*$/m, "").trim() || "(no prose)";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse one markdown file for `speclaw:law` blocks.
|
|
57
|
+
*
|
|
58
|
+
* @param fileRel - Project-relative path recorded on each law's `source`.
|
|
59
|
+
* @param content - File contents.
|
|
60
|
+
*/
|
|
61
|
+
export function parseLawsFromMarkdown(fileRel, content) {
|
|
62
|
+
const laws = [];
|
|
63
|
+
let m;
|
|
64
|
+
const re = new RegExp(BLOCK_RE.source, "gi");
|
|
65
|
+
while ((m = re.exec(content)) !== null) {
|
|
66
|
+
const meta = parseMeta(m[1] ?? "");
|
|
67
|
+
const id = meta.id?.trim();
|
|
68
|
+
if (!id)
|
|
69
|
+
continue;
|
|
70
|
+
const line = content.slice(0, m.index).split(/\r?\n/).length;
|
|
71
|
+
const scope = (meta.scope ?? "")
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((s) => s.trim())
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
const severity = (meta.severity ?? "warn");
|
|
76
|
+
const enforcement = (meta.enforcement ?? "feedback");
|
|
77
|
+
const title = meta.title?.trim() || id;
|
|
78
|
+
const prose = proseAfter(content, m.index + m[0].length);
|
|
79
|
+
laws.push({
|
|
80
|
+
id,
|
|
81
|
+
title,
|
|
82
|
+
severity: ["error", "warn", "info"].includes(severity) ? severity : "warn",
|
|
83
|
+
scope,
|
|
84
|
+
prose,
|
|
85
|
+
verification: verificationOf(meta.verification),
|
|
86
|
+
enforcement: ["bloqueo", "feedback", "gate"].includes(enforcement) ? enforcement : "feedback",
|
|
87
|
+
source: { file: fileRel, line },
|
|
88
|
+
status: meta.status === "draft" ? "draft" : "active",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return laws;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Walk `docs/standards/**\/*.md` under the project and parse law blocks.
|
|
95
|
+
* Collects duplicate ids across files into {@link ParseLawsResult.duplicates}.
|
|
96
|
+
*/
|
|
97
|
+
export function parseLawsFromStandards(projectPath) {
|
|
98
|
+
const root = path.join(projectPath, "docs", "standards");
|
|
99
|
+
const laws = [];
|
|
100
|
+
const seen = new Map();
|
|
101
|
+
if (!fs.existsSync(root))
|
|
102
|
+
return { laws, duplicates: seen };
|
|
103
|
+
const walk = (dir) => {
|
|
104
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
105
|
+
const abs = path.join(dir, ent.name);
|
|
106
|
+
if (ent.isDirectory()) {
|
|
107
|
+
walk(abs);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!ent.name.endsWith(".md"))
|
|
111
|
+
continue;
|
|
112
|
+
const rel = path.relative(projectPath, abs).split(path.sep).join("/");
|
|
113
|
+
const content = fs.readFileSync(abs, "utf8");
|
|
114
|
+
for (const law of parseLawsFromMarkdown(rel, content)) {
|
|
115
|
+
const locs = seen.get(law.id) ?? [];
|
|
116
|
+
locs.push(`${law.source.file}:${law.source.line ?? "?"}`);
|
|
117
|
+
seen.set(law.id, locs);
|
|
118
|
+
laws.push(law);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
walk(root);
|
|
123
|
+
const duplicates = new Map();
|
|
124
|
+
for (const [id, locs] of seen) {
|
|
125
|
+
if (locs.length > 1)
|
|
126
|
+
duplicates.set(id, locs);
|
|
127
|
+
}
|
|
128
|
+
// Keep first occurrence of each id in `laws` when reporting merge later;
|
|
129
|
+
// callers must fail if duplicates.size > 0.
|
|
130
|
+
return { laws, duplicates };
|
|
131
|
+
}
|
|
@@ -44,7 +44,12 @@ const lawSchema = z.object({
|
|
|
44
44
|
verification: verificationSchema,
|
|
45
45
|
enforcement: z.enum(["bloqueo", "feedback", "gate"]),
|
|
46
46
|
source: z.object({ file: z.string(), line: z.number().optional() }),
|
|
47
|
+
status: z.enum(["active", "draft"]).optional(),
|
|
47
48
|
});
|
|
49
|
+
/** True when a law participates in enforcement (default active). */
|
|
50
|
+
export function isActiveLaw(law) {
|
|
51
|
+
return (law.status ?? "active") !== "draft";
|
|
52
|
+
}
|
|
48
53
|
// Reject a malformed `from`/`to` regex when the manifest is validated — naming
|
|
49
54
|
// the law id, not a bare array index — rather than letting it explode at verify
|
|
50
55
|
// time. Mirrors the generation-time treatment of malformed globs.
|