@esneiderbravo/speclaw 0.4.0 → 1.0.1

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.
Files changed (48) hide show
  1. package/README.md +91 -72
  2. package/dist/cli/commands/index-build.js +12 -3
  3. package/dist/cli/commands/lawbook.js +1 -0
  4. package/dist/cli/commands/laws.js +149 -8
  5. package/dist/cli/commands/owners.js +44 -0
  6. package/dist/cli/commands/query.js +32 -10
  7. package/dist/cli/commands/update.js +39 -0
  8. package/dist/cli/commands/verify.js +8 -0
  9. package/dist/cli/index.js +13 -4
  10. package/dist/modules/compass/budget.js +128 -0
  11. package/dist/modules/compass/db.js +290 -30
  12. package/dist/modules/compass/embed-input.js +28 -0
  13. package/dist/modules/compass/embedder.js +3 -1
  14. package/dist/modules/compass/explore-rich.js +10 -5
  15. package/dist/modules/compass/extract.js +86 -0
  16. package/dist/modules/compass/hybrid.js +318 -0
  17. package/dist/modules/compass/indexer.js +204 -33
  18. package/dist/modules/compass/merkle.js +76 -0
  19. package/dist/modules/compass/pagerank.js +122 -0
  20. package/dist/modules/compass/rank.js +95 -0
  21. package/dist/modules/compass/register.js +8 -4
  22. package/dist/modules/foundation/assets/laws/laws-manifest.json +16 -7
  23. package/dist/modules/foundation/check.js +4 -2
  24. package/dist/modules/foundation/compile-laws.js +210 -0
  25. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  26. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  27. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  28. package/dist/modules/foundation/dialects/copilot.js +35 -0
  29. package/dist/modules/foundation/dialects/index.js +5 -0
  30. package/dist/modules/foundation/dialects/types.js +58 -0
  31. package/dist/modules/foundation/doctor.js +220 -14
  32. package/dist/modules/foundation/graph.js +7 -3
  33. package/dist/modules/foundation/import-rules.js +67 -0
  34. package/dist/modules/foundation/integrity.js +307 -0
  35. package/dist/modules/foundation/laws-parse.js +131 -0
  36. package/dist/modules/foundation/laws.js +35 -32
  37. package/dist/modules/foundation/lock.js +283 -0
  38. package/dist/modules/foundation/ownership.js +4 -0
  39. package/dist/modules/foundation/scaffold.js +34 -6
  40. package/dist/modules/foundation/scan.js +227 -0
  41. package/dist/modules/foundation/seed-laws.js +263 -0
  42. package/dist/modules/foundation/verify.js +11 -3
  43. package/dist/modules/lawbook/coverage.js +45 -6
  44. package/dist/modules/lawbook/ears.js +417 -0
  45. package/dist/modules/lawbook/engine.js +29 -0
  46. package/dist/modules/lawbook/spec-items.js +4 -1
  47. package/dist/modules/team/owners.js +464 -0
  48. 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
+ }
@@ -1,13 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
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);
4
+ import { adaptedLaws, catalogLaws, mergeAdaptedSeed } from "./seed-laws.js";
11
5
  const depsRuleSchema = z.object({
12
6
  name: z.string().optional(),
13
7
  from: z.string(),
@@ -44,7 +38,12 @@ const lawSchema = z.object({
44
38
  verification: verificationSchema,
45
39
  enforcement: z.enum(["bloqueo", "feedback", "gate"]),
46
40
  source: z.object({ file: z.string(), line: z.number().optional() }),
41
+ status: z.enum(["active", "draft"]).optional(),
47
42
  });
43
+ /** True when a law participates in enforcement (default active). */
44
+ export function isActiveLaw(law) {
45
+ return (law.status ?? "active") !== "draft";
46
+ }
48
47
  // Reject a malformed `from`/`to` regex when the manifest is validated — naming
49
48
  // the law id, not a bare array index — rather than letting it explode at verify
50
49
  // time. Mirrors the generation-time treatment of malformed globs.
@@ -139,46 +138,50 @@ export function writeLawManifest(projectPath, manifest) {
139
138
  fs.writeFileSync(p, JSON.stringify(validated, null, 2) + "\n");
140
139
  }
141
140
  /**
142
- * The starter law manifest shipped with speclaw, seeded from a speclaw-style
143
- * project's own `path`-verifiable Project-specific laws. It is the source the
144
- * MVP compiles into `.speclaw/laws-manifest.json`; once executable-laws lands,
145
- * laws are authored in `docs/standards/*` and compiled here instead. Laws whose
146
- * scope does not match a given repo are simply inert there.
141
+ * The full shipped catalog as persistable laws (adapter metadata stripped).
142
+ * Prefer {@link seedManifestFor} at init/verify time so consumer repos do not
143
+ * inherit speclaw-dogfood paths that do not exist there.
147
144
  *
148
- * @returns The validated seed manifest read from the module's assets.
149
- * @throws If the seed asset is missing or fails validation.
145
+ * @returns The validated catalog.
146
+ * @throws If the catalog fails the law schema.
150
147
  */
151
148
  export function seedManifest() {
152
- const raw = JSON.parse(fs.readFileSync(path.join(ASSETS, "laws", "laws-manifest.json"), "utf8"));
153
- return manifestSchema.parse(raw);
149
+ return manifestSchema.parse({ version: 1, laws: catalogLaws() });
150
+ }
151
+ /**
152
+ * Catalog laws adapted to `projectPath`: only laws whose `requires` paths exist,
153
+ * with the cycle law scoped to detected source roots.
154
+ *
155
+ * @param projectPath - Project root whose tree is inspected.
156
+ * @returns The validated adapted manifest.
157
+ */
158
+ export function seedManifestFor(projectPath) {
159
+ // Covers: req~adapt-seed-to-repo~1
160
+ return manifestSchema.parse({ version: 1, laws: adaptedLaws(projectPath) });
154
161
  }
155
162
  /**
156
163
  * The manifest the batch verifier should use: the project's file when present,
157
- * otherwise the shipped seed (so a clean CI clone does not silently pass).
164
+ * otherwise the **adapted** seed (so a clean CI clone does not silently pass,
165
+ * and does not evaluate inapplicable dogfood laws).
158
166
  *
159
167
  * @param projectPath - Project root to read from.
160
168
  */
161
169
  export function loadManifestForVerify(projectPath) {
162
- return readLawManifest(projectPath) ?? seedManifest();
170
+ return readLawManifest(projectPath) ?? seedManifestFor(projectPath);
163
171
  }
164
172
  /**
165
- * Append shipped seed laws whose `id` is not already in `existing`. Existing
166
- * entries are never overwritten a curated law keeps its prose, scope, and
167
- * enforcement across `update`.
173
+ * Merge an on-disk manifest with the adapted catalog. Existing curated entries
174
+ * (title or prose differs from the catalog) are never overwritten. Unmodified
175
+ * shipped laws whose required paths are absent are removed; unmodified cycle
176
+ * laws are replaced with the adapted scope.
168
177
  *
169
178
  * @param existing - The project's current manifest.
170
- * @returns The merged manifest and the ids that were added.
179
+ * @param projectPath - Project root used to adapt the catalog.
180
+ * @returns The merged manifest and the ids that were added or removed.
171
181
  */
172
- export function mergeSeedLaws(existing) {
173
- const seed = seedManifest();
174
- const have = new Set(existing.laws.map((l) => l.id));
175
- const extra = seed.laws.filter((l) => !have.has(l.id));
176
- if (extra.length === 0)
177
- return { manifest: existing, added: [] };
178
- return {
179
- manifest: { ...existing, laws: [...existing.laws, ...extra] },
180
- added: extra.map((l) => l.id),
181
- };
182
+ export function mergeSeedLaws(existing, projectPath) {
183
+ const { manifest, added, removed } = mergeAdaptedSeed({ version: existing.version, laws: existing.laws }, projectPath);
184
+ return { manifest: manifestSchema.parse(manifest), added, removed };
182
185
  }
183
186
  // ─── Glob matching (the `path` backend) ──────────────────────────────────────
184
187
  /**