@warnyin/sdlc 0.5.2 → 0.6.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -21
  3. package/README.md +1 -1
  4. package/bin/cli.mjs +49 -4
  5. package/lib/caps.mjs +45 -45
  6. package/lib/config.mjs +41 -41
  7. package/lib/delta.mjs +227 -227
  8. package/lib/frontmatter.mjs +59 -59
  9. package/lib/glob.mjs +29 -29
  10. package/lib/journal.mjs +128 -0
  11. package/lib/manifest.mjs +99 -99
  12. package/lib/observe.mjs +19 -16
  13. package/lib/settings-merge.mjs +63 -63
  14. package/lib/validate.mjs +196 -196
  15. package/package.json +1 -1
  16. package/payload/adapters/agents-md.md +8 -8
  17. package/payload/adapters/claude/agents/sdlc-architect.md +12 -12
  18. package/payload/adapters/claude/agents/sdlc-builder.md +14 -14
  19. package/payload/adapters/claude/agents/sdlc-contractor.md +13 -13
  20. package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -13
  21. package/payload/adapters/claude/agents/sdlc-learner.md +16 -16
  22. package/payload/adapters/claude/agents/sdlc-ops.md +11 -11
  23. package/payload/adapters/claude/agents/sdlc-quality.md +13 -13
  24. package/payload/adapters/claude/agents/sdlc-security.md +12 -12
  25. package/payload/adapters/claude/commands/sdlc/converge.md +5 -5
  26. package/payload/adapters/claude/commands/sdlc/init.md +4 -4
  27. package/payload/adapters/claude/commands/sdlc/next.md +4 -4
  28. package/payload/adapters/claude/commands/sdlc/observe.md +4 -4
  29. package/payload/adapters/claude/commands/sdlc/steer.md +4 -4
  30. package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -26
  31. package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +36 -36
  32. package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +2 -1
  33. package/payload/adapters/cline.md +8 -8
  34. package/payload/adapters/copilot.md +8 -8
  35. package/payload/adapters/cursor.mdc +7 -7
  36. package/payload/adapters/gemini.md +8 -8
  37. package/payload/adapters/windsurf.md +4 -4
  38. package/payload/hooks/_shared.mjs +150 -154
  39. package/payload/hooks/guard-writes.mjs +83 -83
  40. package/payload/hooks/inject-context.mjs +55 -55
  41. package/payload/hooks/journal.mjs +58 -58
  42. package/payload/hooks/session-summary.mjs +50 -50
  43. package/payload/hooks/validate-artifact.mjs +80 -80
  44. package/payload/playbook/auto.md +12 -0
  45. package/payload/playbook/context.md +26 -26
  46. package/payload/playbook/converge.md +19 -19
  47. package/payload/playbook/init.md +22 -22
  48. package/payload/playbook/observe.md +20 -20
  49. package/payload/playbook/principles.md +28 -28
  50. package/payload/playbook/routing.md +19 -19
  51. package/payload/playbook/rules-card.md +16 -16
  52. package/payload/playbook/ship.md +35 -35
  53. package/payload/playbook/steer.md +21 -21
  54. package/payload/templates/change-deep.md +29 -29
  55. package/payload/templates/change-standard.md +28 -28
  56. package/payload/templates/change-vibe.md +19 -19
  57. package/payload/templates/config.yaml +8 -8
  58. package/payload/templates/constitution.md +14 -14
  59. package/payload/templates/contract-evals.md +9 -9
  60. package/payload/templates/contract-tests.md +9 -9
  61. package/payload/templates/harness.md +33 -33
  62. package/payload/templates/spec.md +14 -14
  63. package/payload/templates/steering.md +9 -9
  64. 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
+ }
@@ -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
+ }