@esneiderbravo/speclaw 0.3.11 → 0.3.13
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/dist/cli/commands/lawbook.js +84 -6
- package/dist/cli/commands/quick.js +35 -0
- package/dist/cli/commands/update.js +18 -0
- package/dist/cli/index.js +17 -1
- package/dist/modules/foundation/doctor.js +94 -0
- package/dist/modules/lawbook/assets/commands/archive.md +5 -6
- package/dist/modules/lawbook/assets/commands/draft.md +6 -7
- package/dist/modules/lawbook/assets/commands/investigate.md +7 -0
- package/dist/modules/lawbook/assets/commands/quick.md +14 -0
- package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +8 -0
- package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +4 -3
- package/dist/modules/lawbook/assets/skills/draft/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +3 -0
- package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +29 -25
- package/dist/modules/lawbook/assets/skills/investigate/SKILL.md +10 -0
- package/dist/modules/lawbook/assets/skills/investigate/steps/01-investigate.md +7 -0
- package/dist/modules/lawbook/assets/skills/investigate/steps/02-hand-off.md +6 -0
- package/dist/modules/lawbook/assets/skills/quick/SKILL.md +11 -0
- package/dist/modules/lawbook/assets/skills/quick/steps/01-scaffold.md +6 -0
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +7 -0
- package/dist/modules/lawbook/bugfix.js +195 -0
- package/dist/modules/lawbook/engine.js +178 -55
- package/dist/modules/lawbook/investigate.js +358 -0
- package/dist/modules/lawbook/levels.js +468 -0
- package/dist/modules/lawbook/quick.js +86 -0
- package/dist/modules/lawbook/register.js +18 -0
- package/dist/modules/lawbook/stack-parse.js +135 -0
- package/dist/shared/exposure.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { gatherSignals, loadCeremonyConfig, proposeLevel, setCeremonyLevel, writeCeremonyRecord, } from "./levels.js";
|
|
4
|
+
export const BUGFIX_HEADINGS = [
|
|
5
|
+
"1. Observed symptom",
|
|
6
|
+
"2. Minimal reproduction",
|
|
7
|
+
"3. Root cause",
|
|
8
|
+
"4. Blast radius",
|
|
9
|
+
"5. Proposed fix",
|
|
10
|
+
"6. Regression test",
|
|
11
|
+
"7. Prevention",
|
|
12
|
+
];
|
|
13
|
+
/** Parsed section bodies keyed by heading label. */
|
|
14
|
+
export function parseBugfixSections(content) {
|
|
15
|
+
const out = new Map();
|
|
16
|
+
for (const part of content.split(/^##\s+/m).slice(1)) {
|
|
17
|
+
const nl = part.indexOf("\n");
|
|
18
|
+
if (nl < 0) {
|
|
19
|
+
out.set(part.trim(), "");
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
out.set(part.slice(0, nl).trim(), part.slice(nl + 1).trim());
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function sectionBody(sections, key) {
|
|
27
|
+
for (const [h, b] of sections) {
|
|
28
|
+
if (h.toLowerCase().startsWith(key.toLowerCase()))
|
|
29
|
+
return b;
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
function isFilled(body) {
|
|
34
|
+
const t = body.trim();
|
|
35
|
+
if (!t)
|
|
36
|
+
return false;
|
|
37
|
+
if (/^n\/a\s*:/i.test(t))
|
|
38
|
+
return true;
|
|
39
|
+
return t.length > 2;
|
|
40
|
+
}
|
|
41
|
+
/** True when prevention says a canonical requirement was missing. */
|
|
42
|
+
export function preventionRequiresDelta(content) {
|
|
43
|
+
const prev = sectionBody(parseBugfixSections(content), "7. Prevention");
|
|
44
|
+
if (!prev)
|
|
45
|
+
return false;
|
|
46
|
+
return (/\b(requirement|spec)\b.*\b(miss|missing|absent|incomplete|add|update)\b/i.test(prev) ||
|
|
47
|
+
/\bfaltaba\b/i.test(prev) ||
|
|
48
|
+
/\bmissing requirement\b/i.test(prev));
|
|
49
|
+
}
|
|
50
|
+
/** Infer archive resolution from bugfix.md content. */
|
|
51
|
+
export function inferBugResolution(content) {
|
|
52
|
+
if (/\bnot-a-bug\b/i.test(content) || /resolution:\s*not-a-bug/i.test(content)) {
|
|
53
|
+
return "not-a-bug";
|
|
54
|
+
}
|
|
55
|
+
const repro = sectionBody(parseBugfixSections(content), "2. Minimal reproduction");
|
|
56
|
+
if (/unreproducible\s*:/i.test(repro))
|
|
57
|
+
return "mitigated";
|
|
58
|
+
return "fixed";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Validate bugfix.md sections for a ceremony level.
|
|
62
|
+
*
|
|
63
|
+
* @returns Human-readable issues (empty when valid).
|
|
64
|
+
*/
|
|
65
|
+
export function validateBugfixContent(level, content) {
|
|
66
|
+
const issues = [];
|
|
67
|
+
const sections = parseBugfixSections(content);
|
|
68
|
+
for (const h of BUGFIX_HEADINGS) {
|
|
69
|
+
if (!sections.has(h) && ![...sections.keys()].some((k) => k.startsWith(h.split(".")[0]))) {
|
|
70
|
+
issues.push(`bugfix.md missing heading "## ${h}"`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const repro = sectionBody(sections, "2. Minimal reproduction");
|
|
74
|
+
if (!isFilled(repro) && !/unreproducible\s*:/i.test(repro)) {
|
|
75
|
+
issues.push("bugfix.md §2 requires reproduction steps or an `unreproducible:` block");
|
|
76
|
+
}
|
|
77
|
+
const requiredAt0 = [
|
|
78
|
+
"1. Observed symptom",
|
|
79
|
+
"2. Minimal reproduction",
|
|
80
|
+
"3. Root cause",
|
|
81
|
+
"5. Proposed fix",
|
|
82
|
+
"6. Regression test",
|
|
83
|
+
];
|
|
84
|
+
const optionalAt0 = ["4. Blast radius", "7. Prevention"];
|
|
85
|
+
const allRequired = level >= 1 ? [...BUGFIX_HEADINGS] : requiredAt0;
|
|
86
|
+
for (const key of allRequired) {
|
|
87
|
+
const body = sectionBody(sections, key);
|
|
88
|
+
if (!isFilled(body) && !/unreproducible\s*:/i.test(body)) {
|
|
89
|
+
issues.push(`bugfix.md §${key.split(".")[0]} (${key}) is empty`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (level === 0) {
|
|
93
|
+
for (const key of optionalAt0) {
|
|
94
|
+
const body = sectionBody(sections, key);
|
|
95
|
+
if (body && !isFilled(body) && !/^n\/a\s*:/i.test(body)) {
|
|
96
|
+
issues.push(`bugfix.md §${key.split(".")[0]} must be filled or start with \`n/a:\``);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const prevention = sectionBody(sections, "7. Prevention");
|
|
101
|
+
const resolution = inferBugResolution(content);
|
|
102
|
+
if (resolution === "not-a-bug" && !isFilled(prevention)) {
|
|
103
|
+
issues.push("not-a-bug resolution requires a prevention entry (usually a spec clarity fix)");
|
|
104
|
+
}
|
|
105
|
+
else if (level >= 1 && !isFilled(prevention)) {
|
|
106
|
+
issues.push("bugfix.md §7 Prevention must be answered (law, spec gap, or explicit none with reason)");
|
|
107
|
+
}
|
|
108
|
+
const regression = sectionBody(sections, "6. Regression test");
|
|
109
|
+
const mitigated = /unreproducible\s*:/i.test(repro);
|
|
110
|
+
if (!mitigated && !isFilled(regression)) {
|
|
111
|
+
issues.push("bugfix.md §6 Regression test is required unless reproduction is unreproducible");
|
|
112
|
+
}
|
|
113
|
+
if (mitigated && !isFilled(regression) && !/\binstrument/i.test(content)) {
|
|
114
|
+
issues.push("unreproducible bugs require instrumentation (§6 or explicit instrumentation reference)");
|
|
115
|
+
}
|
|
116
|
+
return issues;
|
|
117
|
+
}
|
|
118
|
+
function bugfixTemplate(name, level, seed) {
|
|
119
|
+
const symptom = seed?.inputSymptom ??
|
|
120
|
+
"<What you see: error message, wrong value, screenshot reference. Do not interpret yet.>";
|
|
121
|
+
const root = seed?.suspects?.[0] != null
|
|
122
|
+
? `${seed.suspects[0].name} (${seed.suspects[0].file}:${seed.suspects[0].startLine}) **(candidate — verify)**`
|
|
123
|
+
: "<symbol (file:line) — must resolve against the graph>";
|
|
124
|
+
const blast = seed?.blastRadiusSummary ??
|
|
125
|
+
"<Run compass_impact on the confirmed root cause; list modules and call sites.>";
|
|
126
|
+
return `# Bugfix: ${name}
|
|
127
|
+
|
|
128
|
+
**Level:** ${level} · **Type:** bug · **Severity:** normal
|
|
129
|
+
|
|
130
|
+
## 1. Observed symptom
|
|
131
|
+
${symptom}
|
|
132
|
+
|
|
133
|
+
## 2. Minimal reproduction
|
|
134
|
+
<!-- Steps to reproduce, or \`unreproducible: <reason and what was tried>\` -->
|
|
135
|
+
|
|
136
|
+
## 3. Root cause
|
|
137
|
+
${root}
|
|
138
|
+
|
|
139
|
+
## 4. Blast radius
|
|
140
|
+
${blast}
|
|
141
|
+
|
|
142
|
+
## 5. Proposed fix
|
|
143
|
+
<!-- The change; note discarded alternatives if any. -->
|
|
144
|
+
|
|
145
|
+
## 6. Regression test
|
|
146
|
+
<!-- test/path.test.ts::case name — must fail BEFORE the fix -->
|
|
147
|
+
|
|
148
|
+
## 7. Prevention
|
|
149
|
+
<!-- New law (executable-laws block), missing spec requirement, or "none: <reason>" -->
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Scaffold a bug change: `bugfix.md`, `change.json`, and `reports/`.
|
|
154
|
+
*/
|
|
155
|
+
export function scaffoldBugfix(projectPath, name, opts = {}) {
|
|
156
|
+
const changeDir = path.join(projectPath, "lawbook", "changes", name);
|
|
157
|
+
if (fs.existsSync(changeDir)) {
|
|
158
|
+
throw new Error(`change "${name}" already exists under lawbook/changes/`);
|
|
159
|
+
}
|
|
160
|
+
const targets = opts.targets ?? { paths: [], symbols: [] };
|
|
161
|
+
const { thresholds } = loadCeremonyConfig(projectPath);
|
|
162
|
+
const signals = gatherSignals(projectPath, targets, thresholds);
|
|
163
|
+
const proposal = proposeLevel(signals, thresholds);
|
|
164
|
+
const level = opts.level ?? (proposal.level !== null && proposal.level <= 1 ? proposal.level : 1);
|
|
165
|
+
fs.mkdirSync(path.join(changeDir, "reports"), { recursive: true });
|
|
166
|
+
fs.writeFileSync(path.join(changeDir, "bugfix.md"), bugfixTemplate(name, level, opts.seed));
|
|
167
|
+
fs.writeFileSync(path.join(changeDir, "reports", "README.md"), `# Reports — ${name}\n\nBug reports MUST include the regression test **failing before the fix**.\n`);
|
|
168
|
+
if (level >= 1) {
|
|
169
|
+
fs.writeFileSync(path.join(changeDir, "tasks.md"), `- [ ] Reproduce and confirm root cause\n- [ ] Implement fix\n- [ ] Add regression test (red before, green after)\n- [ ] Complete prevention §7\n- [ ] Write discipline report under reports/\n`);
|
|
170
|
+
}
|
|
171
|
+
if (level >= 2) {
|
|
172
|
+
fs.writeFileSync(path.join(changeDir, "design.md"), `# Design — ${name}\n\n## Approach\n\n(structural bugfix — document the fix architecture)\n`);
|
|
173
|
+
}
|
|
174
|
+
const record = setCeremonyLevel(projectPath, name, {
|
|
175
|
+
proposal,
|
|
176
|
+
level,
|
|
177
|
+
confirmedBy: "human",
|
|
178
|
+
});
|
|
179
|
+
const updated = { ...record, changeType: "bug" };
|
|
180
|
+
writeCeremonyRecord(projectPath, name, updated);
|
|
181
|
+
return { change: name, proposal, record: updated, dir: changeDir };
|
|
182
|
+
}
|
|
183
|
+
/** Read change type from an archived folder path. */
|
|
184
|
+
export function readChangeTypeFromDir(changeDir) {
|
|
185
|
+
const p = path.join(changeDir, "change.json");
|
|
186
|
+
if (!fs.existsSync(p))
|
|
187
|
+
return "feature";
|
|
188
|
+
try {
|
|
189
|
+
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
190
|
+
return raw.changeType === "bug" ? "bug" : "feature";
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return "feature";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { coverageArchiveBlockers } from "./coverage.js";
|
|
4
4
|
import { sealCapability } from "./anchors.js";
|
|
5
|
+
import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasDisciplineReport, loadCeremonyConfig, proposeLevel, readChangeType, readCeremonyRecord, } from "./levels.js";
|
|
6
|
+
import { inferBugResolution, preventionRequiresDelta, validateBugfixContent } from "./bugfix.js";
|
|
5
7
|
// speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
|
|
6
8
|
// (proposals, delta specs, changes, archive) but implemented from scratch and
|
|
7
9
|
// deliberately simpler: a change's specs/ holds the full intended spec for each
|
|
@@ -33,25 +35,33 @@ mandatory_task_steps:
|
|
|
33
35
|
- "Archive the change within the same PR (lawbook:archive)."
|
|
34
36
|
|
|
35
37
|
# A change is required for new behavior, endpoints, schema changes, or UI flows;
|
|
36
|
-
# one-line fixes
|
|
38
|
+
# one-line fixes may use ceremony level 0 (\`speclaw quick\`) instead of full artifacts.
|
|
39
|
+
|
|
40
|
+
# Ceremony levels (adaptive). Defaults match speclaw's built-in thresholds.
|
|
41
|
+
ceremony:
|
|
42
|
+
cuts: [3, 8, 15]
|
|
43
|
+
hotspotFloor: 0.7
|
|
37
44
|
`;
|
|
38
45
|
const README_MD = `# lawbook/ — the spec-driven workflow (speclaw)
|
|
39
46
|
|
|
40
47
|
This directory is managed by speclaw's **lawbook** module.
|
|
41
48
|
|
|
42
49
|
- \`specs/\` — the canonical specifications (the current source of truth).
|
|
43
|
-
- \`changes/<name>/\` — an in-flight change
|
|
44
|
-
|
|
50
|
+
- \`changes/<name>/\` — an in-flight change. Artifact volume follows the
|
|
51
|
+
confirmed ceremony level in \`change.json\` (0=quick … 3=full). Missing
|
|
52
|
+
\`change.json\` means level 3 (proposal, design, tasks, delta specs).
|
|
45
53
|
- \`changes/archive/\` — completed, archived changes.
|
|
46
|
-
- \`config.yaml\` — mandatory task steps and
|
|
54
|
+
- \`config.yaml\` — mandatory task steps, coverage, and optional ceremony cuts.
|
|
47
55
|
|
|
48
56
|
## Workflow
|
|
49
57
|
|
|
50
|
-
1. \`lawbook:
|
|
51
|
-
2. \`lawbook:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
1. \`lawbook:explore\` — think through an idea before or during a change.
|
|
59
|
+
2. \`lawbook:draft\` / \`speclaw quick\` — propose/confirm a ceremony level, then
|
|
60
|
+
scaffold only the artifacts that level requires.
|
|
61
|
+
3. \`lawbook:build\` — implement the tasks.
|
|
62
|
+
4. \`lawbook:sync\` — promote the change's delta specs into \`specs/\` (when the
|
|
63
|
+
level requires specs).
|
|
64
|
+
5. \`lawbook:archive\` — sync (if needed) + move the change to \`changes/archive/\`.
|
|
55
65
|
`;
|
|
56
66
|
/**
|
|
57
67
|
* Initialize the spec/ workspace, creating the specs/, changes/, and archive
|
|
@@ -146,18 +156,17 @@ function deltaSpecFiles(changeDir) {
|
|
|
146
156
|
return out;
|
|
147
157
|
}
|
|
148
158
|
/**
|
|
149
|
-
* Validate a change's artifacts
|
|
150
|
-
*
|
|
151
|
-
* header, and a "#### Scenario:" acceptance criterion.
|
|
159
|
+
* Validate a change's artifacts against its confirmed ceremony level
|
|
160
|
+
* (missing `change.json` ⇒ level 3 / full ceremony).
|
|
152
161
|
*
|
|
153
162
|
* @param projectPath - Absolute path to the project root.
|
|
154
163
|
* @param change - Change name (folder under lawbook/changes/).
|
|
155
|
-
* @
|
|
156
|
-
* for a missing change — it is reported as an issue with `valid: false`.
|
|
164
|
+
* @param remeasure - Optional targets to re-score scope growth (paths/symbols).
|
|
157
165
|
*/
|
|
158
|
-
export function specValidate(projectPath, change) {
|
|
166
|
+
export function specValidate(projectPath, change, remeasure) {
|
|
159
167
|
const changeDir = path.join(specRoot(projectPath), "changes", change);
|
|
160
168
|
const issues = [];
|
|
169
|
+
const warnings = [];
|
|
161
170
|
if (!fs.existsSync(changeDir)) {
|
|
162
171
|
return {
|
|
163
172
|
change,
|
|
@@ -167,18 +176,64 @@ export function specValidate(projectPath, change) {
|
|
|
167
176
|
deltaSpecs: [],
|
|
168
177
|
};
|
|
169
178
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
|
|
179
|
+
const level = confirmedLevel(projectPath, change);
|
|
180
|
+
const changeType = readChangeType(projectPath, change);
|
|
181
|
+
const needs = artifactNeeds(level, changeType);
|
|
182
|
+
if (needs.bugfix) {
|
|
183
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
184
|
+
if (!fs.existsSync(bugPath)) {
|
|
185
|
+
issues.push(`missing bugfix.md (required for bug changes at level ${level})`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
issues.push(...validateBugfixContent(level, fs.readFileSync(bugPath, "utf8")));
|
|
189
|
+
}
|
|
190
|
+
if (fs.existsSync(path.join(changeDir, "proposal.md"))) {
|
|
191
|
+
issues.push("bug changes must not include proposal.md — use bugfix.md");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (needs.record && !fs.existsSync(path.join(changeDir, "record.md"))) {
|
|
195
|
+
issues.push(`missing record.md (required at ceremony level ${level})`);
|
|
196
|
+
}
|
|
197
|
+
if (needs.proposal && !fs.existsSync(path.join(changeDir, "proposal.md"))) {
|
|
198
|
+
issues.push(`missing proposal.md (required at ceremony level ${level})`);
|
|
199
|
+
}
|
|
200
|
+
if (needs.design && !fs.existsSync(path.join(changeDir, "design.md"))) {
|
|
201
|
+
issues.push(`missing design.md (required at ceremony level ${level})`);
|
|
202
|
+
}
|
|
203
|
+
if (needs.designOptionalWithJustification && !fs.existsSync(path.join(changeDir, "design.md"))) {
|
|
204
|
+
const record = path.join(changeDir, "record.md");
|
|
205
|
+
const text = fs.existsSync(record) ? fs.readFileSync(record, "utf8") : "";
|
|
206
|
+
if (!/design\s*(omitted|skipped|n\/a)/i.test(text) && !/why.*design/i.test(text)) {
|
|
207
|
+
issues.push(`level ${level}: design.md omitted without justification in record.md`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (needs.tasksFile && !fs.existsSync(path.join(changeDir, "tasks.md"))) {
|
|
211
|
+
issues.push(`missing tasks.md (required at ceremony level ${level})`);
|
|
212
|
+
}
|
|
175
213
|
const deltas = deltaSpecFiles(changeDir);
|
|
176
|
-
if (deltas.length === 0)
|
|
177
|
-
issues.push(
|
|
214
|
+
if (needs.deltaSpecs && deltas.length === 0) {
|
|
215
|
+
issues.push(`no delta specs under specs/ (required at ceremony level ${level})`);
|
|
216
|
+
}
|
|
217
|
+
if (needs.bugfix) {
|
|
218
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
219
|
+
if (fs.existsSync(bugPath) && preventionRequiresDelta(fs.readFileSync(bugPath, "utf8"))) {
|
|
220
|
+
if (deltas.length === 0) {
|
|
221
|
+
issues.push("prevention §7 indicates a missing canonical requirement — add a delta spec under specs/");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Scope-growth: when remeasure targets provided (or change.json has prior signals
|
|
226
|
+
// with paths we cannot recover), only check if caller passes targets.
|
|
227
|
+
if (remeasure && (remeasure.paths.length > 0 || remeasure.symbols.length > 0)) {
|
|
228
|
+
const { thresholds } = loadCeremonyConfig(projectPath);
|
|
229
|
+
const measured = proposeLevel(gatherSignals(projectPath, remeasure, thresholds), thresholds);
|
|
230
|
+
if (measured.level !== null && measured.level >= level + 2) {
|
|
231
|
+
issues.push(`scope grew: measured level ${measured.level}, recorded ${level} — run promote or justify (${measured.rationale})`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
178
234
|
const root = specRoot(projectPath);
|
|
179
235
|
const changeSpecs = path.join(changeDir, "specs");
|
|
180
236
|
const capabilities = canonicalCapabilities(root);
|
|
181
|
-
const warnings = [];
|
|
182
237
|
for (const file of deltas) {
|
|
183
238
|
const rel = path.relative(changeDir, file);
|
|
184
239
|
const content = fs.readFileSync(file, "utf8");
|
|
@@ -191,7 +246,6 @@ export function specValidate(projectPath, change) {
|
|
|
191
246
|
if (!/^###\s+Requirement:/m.test(content)) {
|
|
192
247
|
issues.push(`${rel}: no "### Requirement:" header`);
|
|
193
248
|
}
|
|
194
|
-
// Advisory divergence checks against the canonical specs.
|
|
195
249
|
const relFromSpecs = path.relative(changeSpecs, file);
|
|
196
250
|
const capability = relFromSpecs.split(path.sep)[0];
|
|
197
251
|
const nearMatch = nearMatchCapability(capability, capabilities);
|
|
@@ -268,10 +322,9 @@ export function specSync(projectPath, change) {
|
|
|
268
322
|
* Deterministic completeness checks that gate archiving a change. Returns the
|
|
269
323
|
* blocking reasons; an empty array means the change may be archived.
|
|
270
324
|
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
* the last spec edit). The reports/README.md scaffold does not count as a report.
|
|
325
|
+
* Gates respect the confirmed ceremony level (missing `change.json` ⇒ level 3).
|
|
326
|
+
* Every level still requires checked tasks and a discipline report. Delta-spec
|
|
327
|
+
* sync is required only when the level demands delta specs.
|
|
275
328
|
*
|
|
276
329
|
* @param projectPath - Absolute path to the project root.
|
|
277
330
|
* @param change - Change name (folder under lawbook/changes/).
|
|
@@ -283,33 +336,67 @@ export function specArchivePreconditions(projectPath, change) {
|
|
|
283
336
|
if (!fs.existsSync(changeDir))
|
|
284
337
|
return [`change "${change}" not found under lawbook/changes/`];
|
|
285
338
|
const blockers = [];
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
339
|
+
const level = confirmedLevel(projectPath, change);
|
|
340
|
+
const changeType = readChangeType(projectPath, change);
|
|
341
|
+
const needs = artifactNeeds(level, changeType);
|
|
342
|
+
// 1. Every task must be checked (tasks.md, record.md, or bugfix checklist at level 0).
|
|
343
|
+
if (needs.tasksFile) {
|
|
344
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
345
|
+
if (!fs.existsSync(tasksPath)) {
|
|
346
|
+
blockers.push("missing tasks.md");
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const unchecked = countUncheckedTasks(fs.readFileSync(tasksPath, "utf8"));
|
|
350
|
+
if (unchecked > 0)
|
|
351
|
+
blockers.push(`${unchecked} unchecked task(s) in tasks.md`);
|
|
352
|
+
}
|
|
290
353
|
}
|
|
291
|
-
else {
|
|
292
|
-
const
|
|
293
|
-
if (
|
|
294
|
-
blockers.push(
|
|
354
|
+
else if (needs.record) {
|
|
355
|
+
const recordPath = path.join(changeDir, "record.md");
|
|
356
|
+
if (!fs.existsSync(recordPath)) {
|
|
357
|
+
blockers.push("missing record.md");
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
const unchecked = countUncheckedTasks(fs.readFileSync(recordPath, "utf8"));
|
|
361
|
+
if (unchecked > 0)
|
|
362
|
+
blockers.push(`${unchecked} unchecked task(s) in record.md`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else if (needs.bugfix && level === 0) {
|
|
366
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
367
|
+
if (fs.existsSync(bugPath)) {
|
|
368
|
+
const unchecked = countUncheckedTasks(fs.readFileSync(bugPath, "utf8"));
|
|
369
|
+
if (unchecked > 0)
|
|
370
|
+
blockers.push(`${unchecked} unchecked item(s) in bugfix.md checklist`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (needs.bugfix) {
|
|
374
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
375
|
+
if (fs.existsSync(bugPath)) {
|
|
376
|
+
const content = fs.readFileSync(bugPath, "utf8");
|
|
377
|
+
const bugIssues = validateBugfixContent(level, content);
|
|
378
|
+
blockers.push(...bugIssues.filter((i) => i.includes("Regression test") || i.includes("§6")));
|
|
379
|
+
}
|
|
295
380
|
}
|
|
296
381
|
// 2. At least one discipline report must exist (README.md scaffold aside).
|
|
297
|
-
|
|
298
|
-
const reports = fs.existsSync(reportsDir)
|
|
299
|
-
? fs.readdirSync(reportsDir).filter((n) => n.endsWith(".md") && n.toLowerCase() !== "readme.md")
|
|
300
|
-
: [];
|
|
301
|
-
if (reports.length === 0) {
|
|
382
|
+
if (!hasDisciplineReport(changeDir)) {
|
|
302
383
|
blockers.push("no discipline report under reports/ (build must record what was tested)");
|
|
303
384
|
}
|
|
304
|
-
// 3. Delta specs must already be synced
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
385
|
+
// 3. Delta specs must already be synced — when required or when bug prevention demands it.
|
|
386
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
387
|
+
const bugNeedsDelta = needs.bugfix &&
|
|
388
|
+
fs.existsSync(bugPath) &&
|
|
389
|
+
preventionRequiresDelta(fs.readFileSync(bugPath, "utf8"));
|
|
390
|
+
if (needs.deltaSpecs || bugNeedsDelta) {
|
|
391
|
+
for (const file of deltaSpecFiles(changeDir)) {
|
|
392
|
+
const rel = path.relative(path.join(changeDir, "specs"), file);
|
|
393
|
+
const canonical = path.join(root, "specs", rel);
|
|
394
|
+
if (!fs.existsSync(canonical)) {
|
|
395
|
+
blockers.push(`spec not synced: lawbook/specs/${rel} missing (run sync first)`);
|
|
396
|
+
}
|
|
397
|
+
else if (fs.readFileSync(file, "utf8") !== fs.readFileSync(canonical, "utf8")) {
|
|
398
|
+
blockers.push(`spec not synced: lawbook/specs/${rel} differs from the delta (run sync first)`);
|
|
399
|
+
}
|
|
313
400
|
}
|
|
314
401
|
}
|
|
315
402
|
// 4. Opt-in coverage gate: only when the change's delta specs declare ids.
|
|
@@ -317,8 +404,8 @@ export function specArchivePreconditions(projectPath, change) {
|
|
|
317
404
|
return blockers;
|
|
318
405
|
}
|
|
319
406
|
/**
|
|
320
|
-
* Finalize a change: promote its delta specs (via {@link specSync})
|
|
321
|
-
* it to changes/archive/<date>-<name>/.
|
|
407
|
+
* Finalize a change: promote its delta specs (via {@link specSync}) when the
|
|
408
|
+
* ceremony level requires them, then move it to changes/archive/<date>-<name>/.
|
|
322
409
|
*
|
|
323
410
|
* @param projectPath - Absolute path to the project root.
|
|
324
411
|
* @param change - Change name (folder under lawbook/changes/).
|
|
@@ -336,7 +423,31 @@ export function specArchive(projectPath, change, date) {
|
|
|
336
423
|
if (blockers.length > 0) {
|
|
337
424
|
throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
|
|
338
425
|
}
|
|
339
|
-
const
|
|
426
|
+
const level = confirmedLevel(projectPath, change);
|
|
427
|
+
const changeType = readChangeType(projectPath, change);
|
|
428
|
+
const needs = artifactNeeds(level, changeType);
|
|
429
|
+
// Record bug resolution before move.
|
|
430
|
+
if (changeType === "bug") {
|
|
431
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
432
|
+
if (fs.existsSync(bugPath)) {
|
|
433
|
+
const rec = readCeremonyRecord(projectPath, change);
|
|
434
|
+
if (rec) {
|
|
435
|
+
rec.changeType = "bug";
|
|
436
|
+
rec.resolution = inferBugResolution(fs.readFileSync(bugPath, "utf8"));
|
|
437
|
+
const cj = path.join(changeDir, "change.json");
|
|
438
|
+
fs.writeFileSync(cj, JSON.stringify(rec, null, 2) + "\n");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
443
|
+
const shouldSync = needs.deltaSpecs ||
|
|
444
|
+
(changeType === "bug" &&
|
|
445
|
+
fs.existsSync(bugPath) &&
|
|
446
|
+
preventionRequiresDelta(fs.readFileSync(bugPath, "utf8")));
|
|
447
|
+
const sync = shouldSync
|
|
448
|
+
? specSync(projectPath, change)
|
|
449
|
+
: { change, promoted: [], created: [], updated: [] };
|
|
450
|
+
const { promoted, created, updated } = sync;
|
|
340
451
|
const seals = sealPromotedCapabilities(projectPath, change, [
|
|
341
452
|
...promoted,
|
|
342
453
|
...created,
|
|
@@ -391,7 +502,13 @@ function sealPromotedCapabilities(projectPath, change, promotedPaths) {
|
|
|
391
502
|
export function specList(projectPath) {
|
|
392
503
|
const root = specRoot(projectPath);
|
|
393
504
|
if (!fs.existsSync(root)) {
|
|
394
|
-
return {
|
|
505
|
+
return {
|
|
506
|
+
initialized: false,
|
|
507
|
+
activeChanges: [],
|
|
508
|
+
activeLevels: {},
|
|
509
|
+
archivedChanges: [],
|
|
510
|
+
capabilities: [],
|
|
511
|
+
};
|
|
395
512
|
}
|
|
396
513
|
const dirsIn = (rel) => {
|
|
397
514
|
const abs = path.join(root, rel);
|
|
@@ -402,9 +519,15 @@ export function specList(projectPath) {
|
|
|
402
519
|
.filter((e) => e.isDirectory() && e.name !== "archive")
|
|
403
520
|
.map((e) => e.name);
|
|
404
521
|
};
|
|
522
|
+
const activeChanges = dirsIn("changes");
|
|
523
|
+
const activeLevels = {};
|
|
524
|
+
for (const name of activeChanges) {
|
|
525
|
+
activeLevels[name] = confirmedLevel(projectPath, name);
|
|
526
|
+
}
|
|
405
527
|
return {
|
|
406
528
|
initialized: true,
|
|
407
|
-
activeChanges
|
|
529
|
+
activeChanges,
|
|
530
|
+
activeLevels,
|
|
408
531
|
archivedChanges: dirsIn("changes/archive"),
|
|
409
532
|
capabilities: dirsIn("specs"),
|
|
410
533
|
};
|