@warnyin/sdlc 0.8.0 → 0.9.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/CHANGELOG.md +274 -258
- package/LICENSE +21 -21
- package/README.md +92 -92
- package/bin/cli.mjs +682 -682
- package/lib/active.mjs +199 -199
- package/lib/caps.mjs +46 -46
- package/lib/config.mjs +41 -41
- package/lib/delta.mjs +227 -227
- package/lib/frontmatter.mjs +59 -59
- package/lib/glob.mjs +29 -29
- package/lib/lenses.mjs +48 -48
- package/lib/manifest.mjs +99 -99
- package/lib/settings-merge.mjs +63 -63
- package/lib/skills.mjs +148 -148
- package/lib/validate.mjs +198 -198
- package/package.json +42 -42
- package/payload/adapters/agents-md.md +8 -8
- package/payload/adapters/claude/agents/sdlc-architect.md +12 -12
- package/payload/adapters/claude/agents/sdlc-builder.md +14 -14
- package/payload/adapters/claude/agents/sdlc-contractor.md +13 -13
- package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -13
- package/payload/adapters/claude/agents/sdlc-learner.md +16 -16
- package/payload/adapters/claude/agents/sdlc-ops.md +11 -11
- package/payload/adapters/claude/agents/sdlc-quality.md +13 -13
- package/payload/adapters/claude/agents/sdlc-security.md +12 -12
- package/payload/adapters/claude/commands/sdlc/converge.md +5 -5
- package/payload/adapters/claude/commands/sdlc/init.md +4 -4
- package/payload/adapters/claude/commands/sdlc/next.md +4 -4
- package/payload/adapters/claude/commands/sdlc/observe.md +4 -4
- package/payload/adapters/claude/commands/sdlc/steer.md +4 -4
- package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -26
- package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +36 -36
- package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +30 -30
- package/payload/adapters/cline.md +8 -8
- package/payload/adapters/copilot.md +8 -8
- package/payload/adapters/cursor.mdc +7 -7
- package/payload/adapters/gemini.md +8 -8
- package/payload/adapters/windsurf.md +4 -4
- package/payload/hooks/_shared.mjs +138 -138
- package/payload/hooks/guard-writes.mjs +87 -87
- package/payload/hooks/inject-context.mjs +57 -57
- package/payload/hooks/journal.mjs +66 -66
- package/payload/hooks/session-summary.mjs +52 -52
- package/payload/hooks/validate-artifact.mjs +84 -84
- package/payload/playbook/README.md +32 -32
- package/payload/playbook/context.md +26 -26
- package/payload/playbook/contract.md +29 -29
- package/payload/playbook/converge.md +19 -19
- package/payload/playbook/design.md +28 -28
- package/payload/playbook/init.md +22 -22
- package/payload/playbook/lenses.md +64 -64
- package/payload/playbook/new.md +41 -33
- package/payload/playbook/next.md +24 -24
- package/payload/playbook/observe.md +20 -20
- package/payload/playbook/principles.md +28 -28
- package/payload/playbook/review.md +31 -31
- package/payload/playbook/routing.md +19 -19
- package/payload/playbook/rules-card.md +17 -16
- package/payload/playbook/ship.md +35 -35
- package/payload/playbook/steer.md +21 -21
- package/payload/playbook/verify.md +42 -42
- package/payload/templates/change-deep.md +29 -29
- package/payload/templates/change-standard.md +28 -28
- package/payload/templates/change-vibe.md +19 -19
- package/payload/templates/config.yaml +8 -8
- package/payload/templates/constitution.md +14 -14
- package/payload/templates/contract-evals.md +9 -9
- package/payload/templates/contract-tests.md +9 -9
- package/payload/templates/harness.md +34 -34
- package/payload/templates/spec.md +14 -14
- package/payload/templates/steering.md +9 -9
- package/scripts/validate.mjs +47 -47
package/lib/delta.mjs
CHANGED
|
@@ -1,227 +1,227 @@
|
|
|
1
|
-
// Delta-spec grammar (parser keys are frozen English regardless of any
|
|
2
|
-
// UI language):
|
|
3
|
-
//
|
|
4
|
-
// ## Delta: <capability>
|
|
5
|
-
// ### ADDED Requirement: <name>
|
|
6
|
-
// The system SHALL <behavior>.
|
|
7
|
-
// #### Scenario: <name>
|
|
8
|
-
// - WHEN <condition>
|
|
9
|
-
// - THEN <outcome>
|
|
10
|
-
// ### MODIFIED Requirement: <existing name> ← full replacement body
|
|
11
|
-
// ### REMOVED Requirement: <existing name>
|
|
12
|
-
//
|
|
13
|
-
// Requirement heading text is the identity key (OpenSpec convention).
|
|
14
|
-
// `mergeDelta` applies ops mechanically to a living spec; a missing key on
|
|
15
|
-
// MODIFIED/REMOVED is a hard error — never merge silently. MODIFIED replaces
|
|
16
|
-
// the whole body, so it can also drop a scenario the spec still carries: that
|
|
17
|
-
// is legal (removing a promise is sometimes the point) but never silent —
|
|
18
|
-
// `scenarioDrift` reports it and callers surface it as a warning.
|
|
19
|
-
|
|
20
|
-
const DELTA_HEAD = /^## Delta:\s*(.+?)\s*$/;
|
|
21
|
-
const OP_HEAD = /^### (ADDED|MODIFIED|REMOVED) Requirement:\s*(.+?)\s*$/;
|
|
22
|
-
const BAD_OP_HEAD = /^### (\w+) Requirement:/;
|
|
23
|
-
const SPEC_REQ_HEAD = /^### Requirement:\s*(.+?)\s*$/;
|
|
24
|
-
const SCENARIO_HEAD = /^#### Scenario:\s*(.+?)\s*$/;
|
|
25
|
-
const ANY_HEAD = /^#{1,6}\s/;
|
|
26
|
-
|
|
27
|
-
export function parseDelta(changeText) {
|
|
28
|
-
const lines = (changeText ?? '').split(/\r?\n/);
|
|
29
|
-
const deltas = [];
|
|
30
|
-
const errors = [];
|
|
31
|
-
let current = null; // { capability, ops }
|
|
32
|
-
let currentOp = null; // { op, name, bodyLines }
|
|
33
|
-
|
|
34
|
-
const closeOp = () => {
|
|
35
|
-
if (!currentOp) return;
|
|
36
|
-
const body = currentOp.bodyLines.join('\n').trim();
|
|
37
|
-
if (currentOp.op !== 'REMOVED' && body === '') {
|
|
38
|
-
errors.push(`${currentOp.op} Requirement "${currentOp.name}" has an empty body`);
|
|
39
|
-
}
|
|
40
|
-
current.ops.push({ op: currentOp.op, name: currentOp.name, body });
|
|
41
|
-
currentOp = null;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
for (const line of lines) {
|
|
45
|
-
const deltaMatch = line.match(DELTA_HEAD);
|
|
46
|
-
if (deltaMatch) {
|
|
47
|
-
closeOp();
|
|
48
|
-
// Repeated `## Delta: <cap>` blocks merge into one entry — otherwise the
|
|
49
|
-
// last block would silently overwrite the first at ship time.
|
|
50
|
-
const existing = deltas.find((d) => d.capability.toLowerCase() === deltaMatch[1].toLowerCase());
|
|
51
|
-
current = existing ?? { capability: deltaMatch[1], ops: [] };
|
|
52
|
-
if (!existing) deltas.push(current);
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
if (/^## /.test(line) && !deltaMatch) {
|
|
56
|
-
closeOp();
|
|
57
|
-
current = null; // left the delta section
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
if (!current) continue;
|
|
61
|
-
|
|
62
|
-
const opMatch = line.match(OP_HEAD);
|
|
63
|
-
if (opMatch) {
|
|
64
|
-
closeOp();
|
|
65
|
-
currentOp = { op: opMatch[1], name: opMatch[2], bodyLines: [] };
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
const badOp = line.match(BAD_OP_HEAD);
|
|
69
|
-
if (badOp && !opMatch) {
|
|
70
|
-
errors.push(`Unknown delta operation "${badOp[1]}" (use ADDED, MODIFIED, or REMOVED)`);
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
if (currentOp) currentOp.bodyLines.push(line);
|
|
74
|
-
}
|
|
75
|
-
closeOp();
|
|
76
|
-
|
|
77
|
-
for (const d of deltas) {
|
|
78
|
-
const seen = new Set();
|
|
79
|
-
for (const op of d.ops) {
|
|
80
|
-
const key = op.name.toLowerCase();
|
|
81
|
-
if (seen.has(key)) errors.push(`Duplicate requirement "${op.name}" in Delta: ${d.capability}`);
|
|
82
|
-
seen.add(key);
|
|
83
|
-
}
|
|
84
|
-
if (d.ops.length === 0) errors.push(`Delta: ${d.capability} declares no requirement operations`);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return { deltas, errors };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function parseSpec(specText) {
|
|
91
|
-
const lines = (specText ?? '').split(/\r?\n/);
|
|
92
|
-
const requirements = [];
|
|
93
|
-
const preamble = [];
|
|
94
|
-
let current = null;
|
|
95
|
-
|
|
96
|
-
for (const line of lines) {
|
|
97
|
-
const reqMatch = line.match(SPEC_REQ_HEAD);
|
|
98
|
-
if (reqMatch) {
|
|
99
|
-
current = { name: reqMatch[1], bodyLines: [] };
|
|
100
|
-
requirements.push(current);
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
if (current) current.bodyLines.push(line);
|
|
104
|
-
else preamble.push(line);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return {
|
|
108
|
-
preamble: preamble.join('\n').replace(/\n+$/, ''),
|
|
109
|
-
requirements: requirements.map((r) => ({
|
|
110
|
-
name: r.name,
|
|
111
|
-
body: r.bodyLines.join('\n').trim(),
|
|
112
|
-
})),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function renderSpec({ preamble, requirements }) {
|
|
117
|
-
const parts = [preamble.replace(/\n+$/, '')];
|
|
118
|
-
for (const r of requirements) {
|
|
119
|
-
parts.push(`\n### Requirement: ${r.name}\n${r.body}`);
|
|
120
|
-
}
|
|
121
|
-
return parts.join('\n').replace(/\n+$/, '\n');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function newSpecPreamble(capability) {
|
|
125
|
-
return [
|
|
126
|
-
`# Spec: ${capability}`,
|
|
127
|
-
'',
|
|
128
|
-
'## Purpose',
|
|
129
|
-
'<!-- one or two lines; commands grep this header first (progressive disclosure) -->',
|
|
130
|
-
'',
|
|
131
|
-
'## Requirements',
|
|
132
|
-
].join('\n');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ---------- scenario drift (what a MODIFIED body silently drops) ----------
|
|
136
|
-
|
|
137
|
-
// A clause is one WHEN/THEN line; compare on normalized text so indentation,
|
|
138
|
-
// bullet marker and trailing whitespace never read as a behavior change.
|
|
139
|
-
function normalizeClause(line) {
|
|
140
|
-
const stripped = line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').trim();
|
|
141
|
-
if (stripped === '' || /^<!--.*-->$/.test(stripped)) return null;
|
|
142
|
-
return stripped.replace(/\s+/g, ' ');
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function parseScenarios(body) {
|
|
146
|
-
const scenarios = [];
|
|
147
|
-
let current = null;
|
|
148
|
-
for (const line of (body ?? '').split(/\r?\n/)) {
|
|
149
|
-
const head = line.match(SCENARIO_HEAD);
|
|
150
|
-
if (head) {
|
|
151
|
-
current = { name: head[1], clauses: [] };
|
|
152
|
-
scenarios.push(current);
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
if (ANY_HEAD.test(line)) { current = null; continue; }
|
|
156
|
-
if (!current) continue;
|
|
157
|
-
const clause = normalizeClause(line);
|
|
158
|
-
if (clause) current.clauses.push(clause);
|
|
159
|
-
}
|
|
160
|
-
return scenarios;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Two shapes of loss, both invisible in the merged output:
|
|
164
|
-
// dropped — the scenario name is gone from the replacement body
|
|
165
|
-
// weakened — the name survives but clauses the spec stated have no counterpart
|
|
166
|
-
export function scenarioDrift(oldBody, newBody) {
|
|
167
|
-
const after = new Map(parseScenarios(newBody).map((s) => [s.name.toLowerCase(), s]));
|
|
168
|
-
const dropped = [];
|
|
169
|
-
const weakened = [];
|
|
170
|
-
for (const before of parseScenarios(oldBody)) {
|
|
171
|
-
const next = after.get(before.name.toLowerCase());
|
|
172
|
-
if (!next) { dropped.push(before.name); continue; }
|
|
173
|
-
const kept = new Set(next.clauses.map((c) => c.toLowerCase()));
|
|
174
|
-
const lost = before.clauses.filter((c) => !kept.has(c.toLowerCase()));
|
|
175
|
-
if (lost.length) weakened.push({ name: before.name, lost });
|
|
176
|
-
}
|
|
177
|
-
return { dropped, weakened };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// One wording, used by both the archiver and the validator.
|
|
181
|
-
export function describeDrift(capability, requirement, drift) {
|
|
182
|
-
const where = `specs/${capability}/spec.md`;
|
|
183
|
-
const messages = [];
|
|
184
|
-
for (const name of drift.dropped) {
|
|
185
|
-
messages.push(`MODIFIED Requirement "${requirement}" drops scenario "${name}" that ${where} still carries`);
|
|
186
|
-
}
|
|
187
|
-
for (const { name, lost } of drift.weakened) {
|
|
188
|
-
const shown = lost.slice(0, 2).map((c) => `"${c}"`).join(', ');
|
|
189
|
-
const rest = lost.length > 2 ? `, +${lost.length - 2} more` : '';
|
|
190
|
-
messages.push(`MODIFIED Requirement "${requirement}" rewrites scenario "${name}": ${lost.length} clause(s) in ${where} have no counterpart in the new body (${shown}${rest})`);
|
|
191
|
-
}
|
|
192
|
-
return messages;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Apply one capability's ops to a living spec (or null to create it).
|
|
196
|
-
// Returns { ok, content, errors, warnings }.
|
|
197
|
-
export function mergeDelta(specText, ops, capability) {
|
|
198
|
-
const errors = [];
|
|
199
|
-
const warnings = [];
|
|
200
|
-
const spec = specText == null
|
|
201
|
-
? { preamble: newSpecPreamble(capability), requirements: [] }
|
|
202
|
-
: parseSpec(specText);
|
|
203
|
-
|
|
204
|
-
const byName = new Map(spec.requirements.map((r) => [r.name.toLowerCase(), r]));
|
|
205
|
-
|
|
206
|
-
for (const { op, name, body } of ops) {
|
|
207
|
-
const key = name.toLowerCase();
|
|
208
|
-
const existing = byName.get(key);
|
|
209
|
-
if (op === 'ADDED') {
|
|
210
|
-
if (existing) { errors.push(`ADDED Requirement "${name}" already exists in spec "${capability}"`); continue; }
|
|
211
|
-
const req = { name, body };
|
|
212
|
-
spec.requirements.push(req);
|
|
213
|
-
byName.set(key, req);
|
|
214
|
-
} else if (op === 'MODIFIED') {
|
|
215
|
-
if (!existing) { errors.push(`MODIFIED Requirement "${name}" not found in spec "${capability}"`); continue; }
|
|
216
|
-
warnings.push(...describeDrift(capability, name, scenarioDrift(existing.body, body)));
|
|
217
|
-
existing.body = body;
|
|
218
|
-
} else if (op === 'REMOVED') {
|
|
219
|
-
if (!existing) { errors.push(`REMOVED Requirement "${name}" not found in spec "${capability}"`); continue; }
|
|
220
|
-
spec.requirements = spec.requirements.filter((r) => r.name.toLowerCase() !== key);
|
|
221
|
-
byName.delete(key);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (errors.length) return { ok: false, content: null, errors, warnings };
|
|
226
|
-
return { ok: true, content: renderSpec(spec), errors: [], warnings };
|
|
227
|
-
}
|
|
1
|
+
// Delta-spec grammar (parser keys are frozen English regardless of any
|
|
2
|
+
// UI language):
|
|
3
|
+
//
|
|
4
|
+
// ## Delta: <capability>
|
|
5
|
+
// ### ADDED Requirement: <name>
|
|
6
|
+
// The system SHALL <behavior>.
|
|
7
|
+
// #### Scenario: <name>
|
|
8
|
+
// - WHEN <condition>
|
|
9
|
+
// - THEN <outcome>
|
|
10
|
+
// ### MODIFIED Requirement: <existing name> ← full replacement body
|
|
11
|
+
// ### REMOVED Requirement: <existing name>
|
|
12
|
+
//
|
|
13
|
+
// Requirement heading text is the identity key (OpenSpec convention).
|
|
14
|
+
// `mergeDelta` applies ops mechanically to a living spec; a missing key on
|
|
15
|
+
// MODIFIED/REMOVED is a hard error — never merge silently. MODIFIED replaces
|
|
16
|
+
// the whole body, so it can also drop a scenario the spec still carries: that
|
|
17
|
+
// is legal (removing a promise is sometimes the point) but never silent —
|
|
18
|
+
// `scenarioDrift` reports it and callers surface it as a warning.
|
|
19
|
+
|
|
20
|
+
const DELTA_HEAD = /^## Delta:\s*(.+?)\s*$/;
|
|
21
|
+
const OP_HEAD = /^### (ADDED|MODIFIED|REMOVED) Requirement:\s*(.+?)\s*$/;
|
|
22
|
+
const BAD_OP_HEAD = /^### (\w+) Requirement:/;
|
|
23
|
+
const SPEC_REQ_HEAD = /^### Requirement:\s*(.+?)\s*$/;
|
|
24
|
+
const SCENARIO_HEAD = /^#### Scenario:\s*(.+?)\s*$/;
|
|
25
|
+
const ANY_HEAD = /^#{1,6}\s/;
|
|
26
|
+
|
|
27
|
+
export function parseDelta(changeText) {
|
|
28
|
+
const lines = (changeText ?? '').split(/\r?\n/);
|
|
29
|
+
const deltas = [];
|
|
30
|
+
const errors = [];
|
|
31
|
+
let current = null; // { capability, ops }
|
|
32
|
+
let currentOp = null; // { op, name, bodyLines }
|
|
33
|
+
|
|
34
|
+
const closeOp = () => {
|
|
35
|
+
if (!currentOp) return;
|
|
36
|
+
const body = currentOp.bodyLines.join('\n').trim();
|
|
37
|
+
if (currentOp.op !== 'REMOVED' && body === '') {
|
|
38
|
+
errors.push(`${currentOp.op} Requirement "${currentOp.name}" has an empty body`);
|
|
39
|
+
}
|
|
40
|
+
current.ops.push({ op: currentOp.op, name: currentOp.name, body });
|
|
41
|
+
currentOp = null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const deltaMatch = line.match(DELTA_HEAD);
|
|
46
|
+
if (deltaMatch) {
|
|
47
|
+
closeOp();
|
|
48
|
+
// Repeated `## Delta: <cap>` blocks merge into one entry — otherwise the
|
|
49
|
+
// last block would silently overwrite the first at ship time.
|
|
50
|
+
const existing = deltas.find((d) => d.capability.toLowerCase() === deltaMatch[1].toLowerCase());
|
|
51
|
+
current = existing ?? { capability: deltaMatch[1], ops: [] };
|
|
52
|
+
if (!existing) deltas.push(current);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (/^## /.test(line) && !deltaMatch) {
|
|
56
|
+
closeOp();
|
|
57
|
+
current = null; // left the delta section
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!current) continue;
|
|
61
|
+
|
|
62
|
+
const opMatch = line.match(OP_HEAD);
|
|
63
|
+
if (opMatch) {
|
|
64
|
+
closeOp();
|
|
65
|
+
currentOp = { op: opMatch[1], name: opMatch[2], bodyLines: [] };
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const badOp = line.match(BAD_OP_HEAD);
|
|
69
|
+
if (badOp && !opMatch) {
|
|
70
|
+
errors.push(`Unknown delta operation "${badOp[1]}" (use ADDED, MODIFIED, or REMOVED)`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (currentOp) currentOp.bodyLines.push(line);
|
|
74
|
+
}
|
|
75
|
+
closeOp();
|
|
76
|
+
|
|
77
|
+
for (const d of deltas) {
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
for (const op of d.ops) {
|
|
80
|
+
const key = op.name.toLowerCase();
|
|
81
|
+
if (seen.has(key)) errors.push(`Duplicate requirement "${op.name}" in Delta: ${d.capability}`);
|
|
82
|
+
seen.add(key);
|
|
83
|
+
}
|
|
84
|
+
if (d.ops.length === 0) errors.push(`Delta: ${d.capability} declares no requirement operations`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { deltas, errors };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseSpec(specText) {
|
|
91
|
+
const lines = (specText ?? '').split(/\r?\n/);
|
|
92
|
+
const requirements = [];
|
|
93
|
+
const preamble = [];
|
|
94
|
+
let current = null;
|
|
95
|
+
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const reqMatch = line.match(SPEC_REQ_HEAD);
|
|
98
|
+
if (reqMatch) {
|
|
99
|
+
current = { name: reqMatch[1], bodyLines: [] };
|
|
100
|
+
requirements.push(current);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (current) current.bodyLines.push(line);
|
|
104
|
+
else preamble.push(line);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
preamble: preamble.join('\n').replace(/\n+$/, ''),
|
|
109
|
+
requirements: requirements.map((r) => ({
|
|
110
|
+
name: r.name,
|
|
111
|
+
body: r.bodyLines.join('\n').trim(),
|
|
112
|
+
})),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function renderSpec({ preamble, requirements }) {
|
|
117
|
+
const parts = [preamble.replace(/\n+$/, '')];
|
|
118
|
+
for (const r of requirements) {
|
|
119
|
+
parts.push(`\n### Requirement: ${r.name}\n${r.body}`);
|
|
120
|
+
}
|
|
121
|
+
return parts.join('\n').replace(/\n+$/, '\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function newSpecPreamble(capability) {
|
|
125
|
+
return [
|
|
126
|
+
`# Spec: ${capability}`,
|
|
127
|
+
'',
|
|
128
|
+
'## Purpose',
|
|
129
|
+
'<!-- one or two lines; commands grep this header first (progressive disclosure) -->',
|
|
130
|
+
'',
|
|
131
|
+
'## Requirements',
|
|
132
|
+
].join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------- scenario drift (what a MODIFIED body silently drops) ----------
|
|
136
|
+
|
|
137
|
+
// A clause is one WHEN/THEN line; compare on normalized text so indentation,
|
|
138
|
+
// bullet marker and trailing whitespace never read as a behavior change.
|
|
139
|
+
function normalizeClause(line) {
|
|
140
|
+
const stripped = line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').trim();
|
|
141
|
+
if (stripped === '' || /^<!--.*-->$/.test(stripped)) return null;
|
|
142
|
+
return stripped.replace(/\s+/g, ' ');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function parseScenarios(body) {
|
|
146
|
+
const scenarios = [];
|
|
147
|
+
let current = null;
|
|
148
|
+
for (const line of (body ?? '').split(/\r?\n/)) {
|
|
149
|
+
const head = line.match(SCENARIO_HEAD);
|
|
150
|
+
if (head) {
|
|
151
|
+
current = { name: head[1], clauses: [] };
|
|
152
|
+
scenarios.push(current);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (ANY_HEAD.test(line)) { current = null; continue; }
|
|
156
|
+
if (!current) continue;
|
|
157
|
+
const clause = normalizeClause(line);
|
|
158
|
+
if (clause) current.clauses.push(clause);
|
|
159
|
+
}
|
|
160
|
+
return scenarios;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Two shapes of loss, both invisible in the merged output:
|
|
164
|
+
// dropped — the scenario name is gone from the replacement body
|
|
165
|
+
// weakened — the name survives but clauses the spec stated have no counterpart
|
|
166
|
+
export function scenarioDrift(oldBody, newBody) {
|
|
167
|
+
const after = new Map(parseScenarios(newBody).map((s) => [s.name.toLowerCase(), s]));
|
|
168
|
+
const dropped = [];
|
|
169
|
+
const weakened = [];
|
|
170
|
+
for (const before of parseScenarios(oldBody)) {
|
|
171
|
+
const next = after.get(before.name.toLowerCase());
|
|
172
|
+
if (!next) { dropped.push(before.name); continue; }
|
|
173
|
+
const kept = new Set(next.clauses.map((c) => c.toLowerCase()));
|
|
174
|
+
const lost = before.clauses.filter((c) => !kept.has(c.toLowerCase()));
|
|
175
|
+
if (lost.length) weakened.push({ name: before.name, lost });
|
|
176
|
+
}
|
|
177
|
+
return { dropped, weakened };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// One wording, used by both the archiver and the validator.
|
|
181
|
+
export function describeDrift(capability, requirement, drift) {
|
|
182
|
+
const where = `specs/${capability}/spec.md`;
|
|
183
|
+
const messages = [];
|
|
184
|
+
for (const name of drift.dropped) {
|
|
185
|
+
messages.push(`MODIFIED Requirement "${requirement}" drops scenario "${name}" that ${where} still carries`);
|
|
186
|
+
}
|
|
187
|
+
for (const { name, lost } of drift.weakened) {
|
|
188
|
+
const shown = lost.slice(0, 2).map((c) => `"${c}"`).join(', ');
|
|
189
|
+
const rest = lost.length > 2 ? `, +${lost.length - 2} more` : '';
|
|
190
|
+
messages.push(`MODIFIED Requirement "${requirement}" rewrites scenario "${name}": ${lost.length} clause(s) in ${where} have no counterpart in the new body (${shown}${rest})`);
|
|
191
|
+
}
|
|
192
|
+
return messages;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Apply one capability's ops to a living spec (or null to create it).
|
|
196
|
+
// Returns { ok, content, errors, warnings }.
|
|
197
|
+
export function mergeDelta(specText, ops, capability) {
|
|
198
|
+
const errors = [];
|
|
199
|
+
const warnings = [];
|
|
200
|
+
const spec = specText == null
|
|
201
|
+
? { preamble: newSpecPreamble(capability), requirements: [] }
|
|
202
|
+
: parseSpec(specText);
|
|
203
|
+
|
|
204
|
+
const byName = new Map(spec.requirements.map((r) => [r.name.toLowerCase(), r]));
|
|
205
|
+
|
|
206
|
+
for (const { op, name, body } of ops) {
|
|
207
|
+
const key = name.toLowerCase();
|
|
208
|
+
const existing = byName.get(key);
|
|
209
|
+
if (op === 'ADDED') {
|
|
210
|
+
if (existing) { errors.push(`ADDED Requirement "${name}" already exists in spec "${capability}"`); continue; }
|
|
211
|
+
const req = { name, body };
|
|
212
|
+
spec.requirements.push(req);
|
|
213
|
+
byName.set(key, req);
|
|
214
|
+
} else if (op === 'MODIFIED') {
|
|
215
|
+
if (!existing) { errors.push(`MODIFIED Requirement "${name}" not found in spec "${capability}"`); continue; }
|
|
216
|
+
warnings.push(...describeDrift(capability, name, scenarioDrift(existing.body, body)));
|
|
217
|
+
existing.body = body;
|
|
218
|
+
} else if (op === 'REMOVED') {
|
|
219
|
+
if (!existing) { errors.push(`REMOVED Requirement "${name}" not found in spec "${capability}"`); continue; }
|
|
220
|
+
spec.requirements = spec.requirements.filter((r) => r.name.toLowerCase() !== key);
|
|
221
|
+
byName.delete(key);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (errors.length) return { ok: false, content: null, errors, warnings };
|
|
226
|
+
return { ok: true, content: renderSpec(spec), errors: [], warnings };
|
|
227
|
+
}
|
package/lib/frontmatter.mjs
CHANGED
|
@@ -1,59 +1,59 @@
|
|
|
1
|
-
// Minimal YAML-frontmatter reader (zero-dep). Supports the subset this
|
|
2
|
-
// framework writes: strings, numbers, booleans, inline arrays, and
|
|
3
|
-
// simple `- item` lists. Anything fancier is a validation error upstream.
|
|
4
|
-
|
|
5
|
-
const FENCE = '---';
|
|
6
|
-
|
|
7
|
-
export function parseFrontmatter(text) {
|
|
8
|
-
if (typeof text !== 'string') return { data: {}, body: '' };
|
|
9
|
-
const lines = text.split(/\r?\n/);
|
|
10
|
-
if (lines[0]?.trim() !== FENCE) return { data: {}, body: text };
|
|
11
|
-
|
|
12
|
-
let end = -1;
|
|
13
|
-
for (let i = 1; i < lines.length; i++) {
|
|
14
|
-
if (lines[i].trim() === FENCE) { end = i; break; }
|
|
15
|
-
}
|
|
16
|
-
if (end === -1) return { data: {}, body: text };
|
|
17
|
-
|
|
18
|
-
const data = {};
|
|
19
|
-
let currentListKey = null;
|
|
20
|
-
for (let i = 1; i < end; i++) {
|
|
21
|
-
const raw = lines[i];
|
|
22
|
-
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
23
|
-
|
|
24
|
-
const listItem = raw.match(/^\s+-\s+(.*)$/);
|
|
25
|
-
if (listItem && currentListKey) {
|
|
26
|
-
data[currentListKey].push(coerce(listItem[1].trim()));
|
|
27
|
-
continue;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const kv = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
31
|
-
if (!kv) continue;
|
|
32
|
-
const [, key, rawValue] = kv;
|
|
33
|
-
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
34
|
-
const value = rawValue.trim();
|
|
35
|
-
if (value === '') {
|
|
36
|
-
data[key] = [];
|
|
37
|
-
currentListKey = key;
|
|
38
|
-
} else {
|
|
39
|
-
data[key] = coerce(value);
|
|
40
|
-
currentListKey = null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return { data, body: lines.slice(end + 1).join('\n') };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function coerce(value) {
|
|
48
|
-
if (value.startsWith('[') && value.endsWith(']')) {
|
|
49
|
-
const inner = value.slice(1, -1).trim();
|
|
50
|
-
if (!inner) return [];
|
|
51
|
-
return inner.split(',').map((v) => coerce(v.trim()));
|
|
52
|
-
}
|
|
53
|
-
const unquoted = value.replace(/^["']|["']$/g, '');
|
|
54
|
-
if (unquoted !== value) return unquoted;
|
|
55
|
-
if (value === 'true') return true;
|
|
56
|
-
if (value === 'false') return false;
|
|
57
|
-
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
58
|
-
return value;
|
|
59
|
-
}
|
|
1
|
+
// Minimal YAML-frontmatter reader (zero-dep). Supports the subset this
|
|
2
|
+
// framework writes: strings, numbers, booleans, inline arrays, and
|
|
3
|
+
// simple `- item` lists. Anything fancier is a validation error upstream.
|
|
4
|
+
|
|
5
|
+
const FENCE = '---';
|
|
6
|
+
|
|
7
|
+
export function parseFrontmatter(text) {
|
|
8
|
+
if (typeof text !== 'string') return { data: {}, body: '' };
|
|
9
|
+
const lines = text.split(/\r?\n/);
|
|
10
|
+
if (lines[0]?.trim() !== FENCE) return { data: {}, body: text };
|
|
11
|
+
|
|
12
|
+
let end = -1;
|
|
13
|
+
for (let i = 1; i < lines.length; i++) {
|
|
14
|
+
if (lines[i].trim() === FENCE) { end = i; break; }
|
|
15
|
+
}
|
|
16
|
+
if (end === -1) return { data: {}, body: text };
|
|
17
|
+
|
|
18
|
+
const data = {};
|
|
19
|
+
let currentListKey = null;
|
|
20
|
+
for (let i = 1; i < end; i++) {
|
|
21
|
+
const raw = lines[i];
|
|
22
|
+
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
23
|
+
|
|
24
|
+
const listItem = raw.match(/^\s+-\s+(.*)$/);
|
|
25
|
+
if (listItem && currentListKey) {
|
|
26
|
+
data[currentListKey].push(coerce(listItem[1].trim()));
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const kv = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
31
|
+
if (!kv) continue;
|
|
32
|
+
const [, key, rawValue] = kv;
|
|
33
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
34
|
+
const value = rawValue.trim();
|
|
35
|
+
if (value === '') {
|
|
36
|
+
data[key] = [];
|
|
37
|
+
currentListKey = key;
|
|
38
|
+
} else {
|
|
39
|
+
data[key] = coerce(value);
|
|
40
|
+
currentListKey = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { data, body: lines.slice(end + 1).join('\n') };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function coerce(value) {
|
|
48
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
49
|
+
const inner = value.slice(1, -1).trim();
|
|
50
|
+
if (!inner) return [];
|
|
51
|
+
return inner.split(',').map((v) => coerce(v.trim()));
|
|
52
|
+
}
|
|
53
|
+
const unquoted = value.replace(/^["']|["']$/g, '');
|
|
54
|
+
if (unquoted !== value) return unquoted;
|
|
55
|
+
if (value === 'true') return true;
|
|
56
|
+
if (value === 'false') return false;
|
|
57
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
package/lib/glob.mjs
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
// Tiny glob matcher for steering pathMatch patterns. Supports the subset we
|
|
2
|
-
// document: `**` (any depth), `*` (within a segment), literal text.
|
|
3
|
-
// Paths are compared as POSIX, relative to the project root.
|
|
4
|
-
|
|
5
|
-
export function globToRegExp(glob) {
|
|
6
|
-
let re = '';
|
|
7
|
-
for (let i = 0; i < glob.length; i++) {
|
|
8
|
-
const c = glob[i];
|
|
9
|
-
if (c === '*') {
|
|
10
|
-
if (glob[i + 1] === '*') {
|
|
11
|
-
// `**/` or trailing `**` — match any depth including nothing
|
|
12
|
-
if (glob[i + 2] === '/') { re += '(?:[^/]+/)*'; i += 2; }
|
|
13
|
-
else { re += '.*'; i += 1; }
|
|
14
|
-
} else {
|
|
15
|
-
re += '[^/]*';
|
|
16
|
-
}
|
|
17
|
-
} else if ('.+^$()[]{}|\\?'.includes(c)) {
|
|
18
|
-
re += '\\' + c;
|
|
19
|
-
} else {
|
|
20
|
-
re += c;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return new RegExp(`^${re}$`);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function matchGlob(relPosixPath, patterns) {
|
|
27
|
-
const list = Array.isArray(patterns) ? patterns : [patterns];
|
|
28
|
-
return list.some((p) => globToRegExp(String(p)).test(relPosixPath));
|
|
29
|
-
}
|
|
1
|
+
// Tiny glob matcher for steering pathMatch patterns. Supports the subset we
|
|
2
|
+
// document: `**` (any depth), `*` (within a segment), literal text.
|
|
3
|
+
// Paths are compared as POSIX, relative to the project root.
|
|
4
|
+
|
|
5
|
+
export function globToRegExp(glob) {
|
|
6
|
+
let re = '';
|
|
7
|
+
for (let i = 0; i < glob.length; i++) {
|
|
8
|
+
const c = glob[i];
|
|
9
|
+
if (c === '*') {
|
|
10
|
+
if (glob[i + 1] === '*') {
|
|
11
|
+
// `**/` or trailing `**` — match any depth including nothing
|
|
12
|
+
if (glob[i + 2] === '/') { re += '(?:[^/]+/)*'; i += 2; }
|
|
13
|
+
else { re += '.*'; i += 1; }
|
|
14
|
+
} else {
|
|
15
|
+
re += '[^/]*';
|
|
16
|
+
}
|
|
17
|
+
} else if ('.+^$()[]{}|\\?'.includes(c)) {
|
|
18
|
+
re += '\\' + c;
|
|
19
|
+
} else {
|
|
20
|
+
re += c;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return new RegExp(`^${re}$`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function matchGlob(relPosixPath, patterns) {
|
|
27
|
+
const list = Array.isArray(patterns) ? patterns : [patterns];
|
|
28
|
+
return list.some((p) => globToRegExp(String(p)).test(relPosixPath));
|
|
29
|
+
}
|