@esneiderbravo/speclaw 0.4.0 → 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 +32 -10
- package/dist/cli/commands/update.js +28 -0
- package/dist/cli/commands/verify.js +8 -0
- package/dist/cli/index.js +13 -4
- package/dist/modules/compass/budget.js +128 -0
- package/dist/modules/compass/db.js +290 -30
- package/dist/modules/compass/embed-input.js +28 -0
- package/dist/modules/compass/embedder.js +3 -1
- package/dist/modules/compass/explore-rich.js +10 -5
- package/dist/modules/compass/extract.js +86 -0
- package/dist/modules/compass/hybrid.js +318 -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 +8 -4
- package/dist/modules/foundation/check.js +4 -2
- package/dist/modules/foundation/compile-laws.js +212 -0
- 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 +220 -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/scaffold.js +25 -0
- package/dist/modules/foundation/scan.js +227 -0
- package/dist/modules/foundation/verify.js +9 -1
- 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/spec-items.js +4 -1
- package/dist/modules/team/owners.js +464 -0
- package/package.json +4 -3
|
@@ -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.
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speclaw.lock — committed digests for rule files (never under `.speclaw/`).
|
|
3
|
+
* A gitignored lock would be invisible in PR diffs and would not detect
|
|
4
|
+
* Rules File Backdoor edits. Like package-lock.json / go.sum.
|
|
5
|
+
*/
|
|
6
|
+
// Covers: req~speclaw-lock~1
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { pkgName, pkgVersion } from "../../shared/version.js";
|
|
11
|
+
export const LOCKFILE_NAME = "speclaw.lock";
|
|
12
|
+
export const LOCKFILE_VERSION = 1;
|
|
13
|
+
/** Delimited provenance block excluded from digests (self-reference). */
|
|
14
|
+
export const PROVENANCE_START = "<!-- speclaw:begin-provenance";
|
|
15
|
+
export const PROVENANCE_END = "speclaw:end-provenance -->";
|
|
16
|
+
/** Project-relative path of the committed lockfile. */
|
|
17
|
+
export function lockfilePath(projectPath) {
|
|
18
|
+
return path.join(projectPath, LOCKFILE_NAME);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Canonical bytes for hashing: LF endings, strip provenance, trim EOL spaces,
|
|
22
|
+
* ensure a single trailing newline.
|
|
23
|
+
*/
|
|
24
|
+
export function canonicalize(raw) {
|
|
25
|
+
let text = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
26
|
+
text = stripProvenanceBlock(text);
|
|
27
|
+
text = text
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((line) => line.replace(/[ \t]+$/g, ""))
|
|
30
|
+
.join("\n");
|
|
31
|
+
if (!text.endsWith("\n"))
|
|
32
|
+
text += "\n";
|
|
33
|
+
else if (text.endsWith("\n\n")) {
|
|
34
|
+
// collapse to exactly one trailing newline
|
|
35
|
+
text = text.replace(/\n+$/g, "\n");
|
|
36
|
+
}
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
/** Remove speclaw provenance HTML comment blocks. */
|
|
40
|
+
export function stripProvenanceBlock(text) {
|
|
41
|
+
const re = new RegExp(`${escapeRegExp(PROVENANCE_START)}[\\s\\S]*?${escapeRegExp(PROVENANCE_END)}\\n?`, "g");
|
|
42
|
+
return text.replace(re, "");
|
|
43
|
+
}
|
|
44
|
+
function escapeRegExp(s) {
|
|
45
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
46
|
+
}
|
|
47
|
+
/** sha256 digest with `sha256:` prefix. */
|
|
48
|
+
export function digestOf(canonical) {
|
|
49
|
+
const hex = crypto.createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
50
|
+
return `sha256:${hex}`;
|
|
51
|
+
}
|
|
52
|
+
/** Digest raw file text after canonicalization. */
|
|
53
|
+
export function digestText(raw) {
|
|
54
|
+
return digestOf(canonicalize(raw));
|
|
55
|
+
}
|
|
56
|
+
/** Root hash over sorted path → digest pairs. */
|
|
57
|
+
export function rootDigest(files) {
|
|
58
|
+
const paths = Object.keys(files).sort();
|
|
59
|
+
let acc = "";
|
|
60
|
+
for (const p of paths) {
|
|
61
|
+
acc += `${p}\0${files[p].digest}\n`;
|
|
62
|
+
}
|
|
63
|
+
return digestOf(acc);
|
|
64
|
+
}
|
|
65
|
+
/** Read lockfile or null if missing. Throws on unknown version / parse error. */
|
|
66
|
+
export function readLockfile(projectPath) {
|
|
67
|
+
const abs = lockfilePath(projectPath);
|
|
68
|
+
if (!fs.existsSync(abs))
|
|
69
|
+
return null;
|
|
70
|
+
const raw = JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
71
|
+
if (typeof raw.lockfileVersion !== "number") {
|
|
72
|
+
throw new Error("speclaw.lock: missing lockfileVersion");
|
|
73
|
+
}
|
|
74
|
+
if (raw.lockfileVersion > LOCKFILE_VERSION) {
|
|
75
|
+
throw new Error(`speclaw.lock: unsupported lockfileVersion ${raw.lockfileVersion} (max ${LOCKFILE_VERSION})`);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
lockfileVersion: raw.lockfileVersion,
|
|
79
|
+
generator: String(raw.generator ?? ""),
|
|
80
|
+
algorithm: "sha256",
|
|
81
|
+
root: String(raw.root ?? ""),
|
|
82
|
+
files: raw.files && typeof raw.files === "object" ? raw.files : {},
|
|
83
|
+
symlinks: raw.symlinks && typeof raw.symlinks === "object" ? raw.symlinks : {},
|
|
84
|
+
accepted: Array.isArray(raw.accepted) ? raw.accepted : [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Write lockfile with stable JSON formatting. */
|
|
88
|
+
export function writeLockfile(projectPath, lock) {
|
|
89
|
+
const abs = lockfilePath(projectPath);
|
|
90
|
+
const body = JSON.stringify(lock, null, 2) + "\n";
|
|
91
|
+
fs.writeFileSync(abs, body);
|
|
92
|
+
}
|
|
93
|
+
/** Build a fresh lock object from file digests + symlinks. */
|
|
94
|
+
export function buildLock(opts) {
|
|
95
|
+
const files = { ...opts.files };
|
|
96
|
+
return {
|
|
97
|
+
lockfileVersion: LOCKFILE_VERSION,
|
|
98
|
+
generator: `${pkgName()}@${pkgVersion()}`,
|
|
99
|
+
algorithm: "sha256",
|
|
100
|
+
root: rootDigest(files),
|
|
101
|
+
files,
|
|
102
|
+
symlinks: { ...(opts.symlinks ?? {}) },
|
|
103
|
+
accepted: [...(opts.accepted ?? [])],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Integrity severity policy for a project-relative path. */
|
|
107
|
+
export function integrityPolicy(relPath) {
|
|
108
|
+
const n = relPath.split("\\").join("/");
|
|
109
|
+
// `.cursor/rules/` mirrors regenerable `ai-specs/` (gitignored) — lock/CI must
|
|
110
|
+
// not treat them as strict committed files; scan when present, never pin.
|
|
111
|
+
if (n === "AGENTS.md" ||
|
|
112
|
+
n === "CLAUDE.md" ||
|
|
113
|
+
n.startsWith(".github/instructions/") ||
|
|
114
|
+
n === ".coderabbit.yaml" ||
|
|
115
|
+
n === ".claude/rules/speclaw") {
|
|
116
|
+
return "strict";
|
|
117
|
+
}
|
|
118
|
+
if (n === "LAWS.md" || n === "docs/compass.md" || n.startsWith("docs/standards/")) {
|
|
119
|
+
return "advisory";
|
|
120
|
+
}
|
|
121
|
+
return "scan-only";
|
|
122
|
+
}
|
|
123
|
+
/** True when a path is an IDE mirror of regenerable (typically gitignored) content. */
|
|
124
|
+
export function isRegenerableIdeMirror(relPath) {
|
|
125
|
+
const n = relPath.split("\\").join("/");
|
|
126
|
+
return (n.startsWith(".cursor/rules/") ||
|
|
127
|
+
n.startsWith(".cursor/skills/") ||
|
|
128
|
+
n.startsWith(".cursor/commands/") ||
|
|
129
|
+
n.startsWith(".claude/skills/") ||
|
|
130
|
+
n.startsWith(".claude/commands/") ||
|
|
131
|
+
n.startsWith("ai-specs/"));
|
|
132
|
+
}
|
|
133
|
+
/** Discover candidate paths under the project for locking / scanning. */
|
|
134
|
+
export function discoverIntegrityPaths(projectPath) {
|
|
135
|
+
const files = [];
|
|
136
|
+
const symlinks = [];
|
|
137
|
+
const addFile = (rel) => {
|
|
138
|
+
const abs = path.join(projectPath, rel);
|
|
139
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile())
|
|
140
|
+
files.push(rel.split("\\").join("/"));
|
|
141
|
+
};
|
|
142
|
+
for (const f of ["AGENTS.md", "CLAUDE.md", "LAWS.md", "docs/compass.md", ".coderabbit.yaml"]) {
|
|
143
|
+
addFile(f);
|
|
144
|
+
}
|
|
145
|
+
walkFiles(path.join(projectPath, "docs", "standards"), projectPath, files, (p) => p.endsWith(".md"));
|
|
146
|
+
walkFiles(path.join(projectPath, ".cursor", "rules"), projectPath, files, () => true);
|
|
147
|
+
walkFiles(path.join(projectPath, ".github", "instructions"), projectPath, files, () => true);
|
|
148
|
+
// Outside-pipeline / skills (scan-only)
|
|
149
|
+
for (const f of [".clinerules", ".windsurfrules", "BUGBOT.md", ".cursorrules"])
|
|
150
|
+
addFile(f);
|
|
151
|
+
walkFiles(path.join(projectPath, "ai-specs", "skills"), projectPath, files, (p) => p.endsWith("SKILL.md") || p.endsWith(".md"));
|
|
152
|
+
walkFiles(path.join(projectPath, "ai-specs", "agents"), projectPath, files, (p) => p.endsWith(".md"));
|
|
153
|
+
walkFiles(path.join(projectPath, ".claude", "skills"), projectPath, files, (p) => p.endsWith("SKILL.md") || p.endsWith(".md"));
|
|
154
|
+
const linkRel = ".claude/rules/speclaw";
|
|
155
|
+
const linkAbs = path.join(projectPath, linkRel);
|
|
156
|
+
try {
|
|
157
|
+
const st = fs.lstatSync(linkAbs);
|
|
158
|
+
if (st.isSymbolicLink()) {
|
|
159
|
+
symlinks.push({ path: linkRel, target: fs.readlinkSync(linkAbs) });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* missing */
|
|
164
|
+
}
|
|
165
|
+
return { files: [...new Set(files)].sort(), symlinks };
|
|
166
|
+
}
|
|
167
|
+
function walkFiles(dir, projectPath, out, pred) {
|
|
168
|
+
if (!fs.existsSync(dir))
|
|
169
|
+
return;
|
|
170
|
+
const stack = [dir];
|
|
171
|
+
while (stack.length) {
|
|
172
|
+
const cur = stack.pop();
|
|
173
|
+
let entries;
|
|
174
|
+
try {
|
|
175
|
+
entries = fs.readdirSync(cur, { withFileTypes: true });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
for (const e of entries) {
|
|
181
|
+
const full = path.join(cur, e.name);
|
|
182
|
+
if (e.isDirectory())
|
|
183
|
+
stack.push(full);
|
|
184
|
+
else if (e.isFile()) {
|
|
185
|
+
const rel = path.relative(projectPath, full).split(path.sep).join("/");
|
|
186
|
+
if (pred(rel))
|
|
187
|
+
out.push(rel);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Markers for the regenerable map block inside `docs/compass.md` (see compass/map.ts). */
|
|
193
|
+
export const COMPASS_MAP_START = "<!-- speclaw:map:start -->";
|
|
194
|
+
export const COMPASS_MAP_END = "<!-- speclaw:map:end -->";
|
|
195
|
+
/**
|
|
196
|
+
* Strip the regenerable map body between markers so integrity digests stay stable
|
|
197
|
+
* across `speclaw index` (which rewrites the map in CI before verify).
|
|
198
|
+
*
|
|
199
|
+
* @param text - Full docs/compass.md contents.
|
|
200
|
+
* @returns The same text with an empty map body, or `text` if markers are missing.
|
|
201
|
+
*/
|
|
202
|
+
export function stripCompassMapBlock(text) {
|
|
203
|
+
const start = text.indexOf(COMPASS_MAP_START);
|
|
204
|
+
const end = text.indexOf(COMPASS_MAP_END);
|
|
205
|
+
if (start < 0 || end < 0 || end < start)
|
|
206
|
+
return text;
|
|
207
|
+
return text.slice(0, start + COMPASS_MAP_START.length) + "\n" + text.slice(end);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Path-specific bytes that feed {@link digestText}: speclaw-owned coderabbit
|
|
211
|
+
* region, regenerable Compass map body stripped, otherwise the file as-is.
|
|
212
|
+
*
|
|
213
|
+
* @param relPath - Project-relative path.
|
|
214
|
+
* @param raw - File contents.
|
|
215
|
+
* @returns Text to canonicalize and hash for this path.
|
|
216
|
+
*/
|
|
217
|
+
export function prepareIntegrityText(relPath, raw) {
|
|
218
|
+
const n = relPath.split("\\").join("/");
|
|
219
|
+
if (n === ".coderabbit.yaml")
|
|
220
|
+
return extractSpeclawYamlBlock(raw) ?? raw;
|
|
221
|
+
if (n === "docs/compass.md")
|
|
222
|
+
return stripCompassMapBlock(raw);
|
|
223
|
+
return raw;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Snapshot digests for discovered files with ownership policy.
|
|
227
|
+
* For `.coderabbit.yaml`, digests only the speclaw delimited block when present.
|
|
228
|
+
* For `docs/compass.md`, digests with the regenerable map body stripped.
|
|
229
|
+
*/
|
|
230
|
+
export function snapshotLockEntries(projectPath) {
|
|
231
|
+
const { files: paths, symlinks } = discoverIntegrityPaths(projectPath);
|
|
232
|
+
const files = {};
|
|
233
|
+
for (const rel of paths) {
|
|
234
|
+
const ownership = integrityPolicy(rel);
|
|
235
|
+
if (ownership === "scan-only")
|
|
236
|
+
continue; // locked only when previously accepted / explicit
|
|
237
|
+
const abs = path.join(projectPath, rel);
|
|
238
|
+
const raw = prepareIntegrityText(rel, fs.readFileSync(abs, "utf8"));
|
|
239
|
+
files[rel] = { digest: digestText(raw), ownership };
|
|
240
|
+
}
|
|
241
|
+
const symlinkMap = {};
|
|
242
|
+
for (const s of symlinks)
|
|
243
|
+
symlinkMap[s.path] = { target: s.target };
|
|
244
|
+
return { files, symlinks: symlinkMap };
|
|
245
|
+
}
|
|
246
|
+
/** Extract a speclaw-marked region from coderabbit yaml if present. */
|
|
247
|
+
export function extractSpeclawYamlBlock(raw) {
|
|
248
|
+
const m = /# speclaw:begin[\s\S]*?# speclaw:end/.exec(raw);
|
|
249
|
+
if (m)
|
|
250
|
+
return m[0];
|
|
251
|
+
const m2 = /<!-- speclaw:laws:start -->[\s\S]*?<!-- speclaw:laws:end -->/.exec(raw) ??
|
|
252
|
+
/<!-- speclaw:begin-provenance[\s\S]*?speclaw:end-provenance -->/.exec(raw);
|
|
253
|
+
return m2 ? m2[0] : null;
|
|
254
|
+
}
|
|
255
|
+
/** Create or refresh speclaw.lock from the current tree. */
|
|
256
|
+
export function refreshLockfile(projectPath) {
|
|
257
|
+
const prev = (() => {
|
|
258
|
+
try {
|
|
259
|
+
return readLockfile(projectPath);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
})();
|
|
265
|
+
const { files, symlinks } = snapshotLockEntries(projectPath);
|
|
266
|
+
const lock = buildLock({
|
|
267
|
+
files,
|
|
268
|
+
symlinks,
|
|
269
|
+
accepted: prev?.accepted ?? [],
|
|
270
|
+
});
|
|
271
|
+
writeLockfile(projectPath, lock);
|
|
272
|
+
return lock;
|
|
273
|
+
}
|
|
274
|
+
/** Render a data-only provenance HTML comment (no imperatives). */
|
|
275
|
+
export function provenanceBlock(opts) {
|
|
276
|
+
const laws = (opts.lawIds ?? []).map((l) => ` law: ${l}`).join("\n");
|
|
277
|
+
return (`${PROVENANCE_START}\n` +
|
|
278
|
+
(laws ? laws + "\n" : "") +
|
|
279
|
+
(opts.source ? ` source: ${opts.source}\n` : "") +
|
|
280
|
+
` digest: ${opts.digest}\n` +
|
|
281
|
+
` generator: ${pkgName()}@${pkgVersion()}\n` +
|
|
282
|
+
`speclaw:end-provenance -->\n`);
|
|
283
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Which scaffolded files speclaw owns vs the user owns. `speclaw update` uses
|
|
2
2
|
// this split to decide what it may overwrite automatically (managed) and what it
|
|
3
3
|
// must leave to the user's agent via a prompt (personalized).
|
|
4
|
+
//
|
|
5
|
+
// Integrity severity (`strict` / `advisory` / `scan-only` in lock.ts) is a
|
|
6
|
+
// separate axis: CLAUDE.md / AGENTS.md stay PERSONALIZED for update, but are
|
|
7
|
+
// `strict` for `verifyIntegrity` digest mismatches.
|
|
4
8
|
/**
|
|
5
9
|
* Project-relative trees that hold speclaw's workflow machinery. The user is not
|
|
6
10
|
* meant to edit these, so `speclaw update` overwrites them with the current
|
|
@@ -10,6 +10,9 @@ import { readManifest, writeManifest } from "../../shared/manifest.js";
|
|
|
10
10
|
import { pkgVersion } from "../../shared/version.js";
|
|
11
11
|
import { mergeSeedLaws, readLawManifest, seedManifest, writeLawManifest, } from "./laws.js";
|
|
12
12
|
import { installHooks } from "./hooks.js";
|
|
13
|
+
import { compileLaws } from "./compile-laws.js";
|
|
14
|
+
import { refreshLockfile } from "./lock.js";
|
|
15
|
+
import { refreshOwnersIfConfigured } from "../team/owners.js";
|
|
13
16
|
const ASSETS = assetsDir(import.meta.url);
|
|
14
17
|
// Every {{var}} the foundation templates may reference. Ones the agent didn't
|
|
15
18
|
// provide default to empty so a bare `scaffold` never leaves a raw {{tag}}.
|
|
@@ -150,12 +153,34 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
|
|
|
150
153
|
// configured. The seam is the manifest: check-dispatcher enforces `path` laws;
|
|
151
154
|
// executable-laws will extend the same manifest with more backends.
|
|
152
155
|
const lawManifest = ensureLawManifest(projectPath, report);
|
|
156
|
+
try {
|
|
157
|
+
compileLaws({ projectPath, agents, writeManifest: false });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Compilation must not fail scaffold; `speclaw laws compile` surfaces errors.
|
|
161
|
+
}
|
|
162
|
+
// Covers: req~lock-refresh-update~1
|
|
163
|
+
try {
|
|
164
|
+
refreshLockfile(projectPath);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// Lock refresh must not fail scaffold; `speclaw laws lock` surfaces errors.
|
|
168
|
+
}
|
|
153
169
|
ensureVerifyWorkflow(projectPath, report);
|
|
154
170
|
report.hooks = installHooks(projectPath, agents, lawManifest, report, {
|
|
155
171
|
baselines: managedOpts.baselines,
|
|
156
172
|
backup: managedOpts.backup,
|
|
157
173
|
record,
|
|
158
174
|
});
|
|
175
|
+
// Refresh CODEOWNERS managed block when the project declared team.owners —
|
|
176
|
+
// never invent owners when the key is absent.
|
|
177
|
+
try {
|
|
178
|
+
refreshOwnersIfConfigured(projectPath);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
// Surface as a soft note — invalid tokens should not abort scaffold/update.
|
|
182
|
+
report.skipped.push(`owners refresh skipped: ${err.message}`);
|
|
183
|
+
}
|
|
159
184
|
// Record what was installed so `speclaw update` can re-apply these packs and
|
|
160
185
|
// gate feature migrations by version, plus the managed-file baselines that let
|
|
161
186
|
// a later update tell user edits from stale files.
|