pan-wizard 3.5.2 → 3.8.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 +28 -9
- package/agents/pan-executor.md +18 -0
- package/agents/pan-experiment-runner.md +126 -0
- package/agents/pan-phase-researcher.md +16 -0
- package/agents/pan-plan-checker.md +80 -0
- package/agents/pan-planner.md +19 -0
- package/agents/pan-reviewer.md +2 -0
- package/agents/pan-verifier.md +41 -0
- package/bin/install-lib.cjs +55 -0
- package/bin/install.js +71 -22
- package/commands/pan/debug.md +1 -1
- package/commands/pan/experiment.md +219 -0
- package/commands/pan/health.md +1 -1
- package/commands/pan/learn.md +15 -1
- package/commands/pan/links.md +102 -0
- package/commands/pan/optimize.md +13 -0
- package/commands/pan/patches.md +10 -1
- package/commands/pan/phase-tests.md +1 -4
- package/commands/pan/todo-add.md +1 -1
- package/commands/pan/todo-check.md +1 -1
- package/hooks/dist/pan-cost-logger.js +54 -4
- package/hooks/dist/pan-trace-logger.js +72 -3
- package/package.json +67 -66
- package/pan-wizard-core/bin/lib/codebase.cjs +2 -0
- package/pan-wizard-core/bin/lib/commands.cjs +8 -0
- package/pan-wizard-core/bin/lib/config.cjs +13 -2
- package/pan-wizard-core/bin/lib/context-budget.cjs +73 -0
- package/pan-wizard-core/bin/lib/core.cjs +13 -0
- package/pan-wizard-core/bin/lib/doc-lint/frontmatter.js +270 -0
- package/pan-wizard-core/bin/lib/doc-lint/reporter.js +45 -0
- package/pan-wizard-core/bin/lib/doc-lint/schema.js +202 -0
- package/pan-wizard-core/bin/lib/doc-lint/validate.js +190 -0
- package/pan-wizard-core/bin/lib/doc-lint/walk.js +135 -0
- package/pan-wizard-core/bin/lib/doc-lint.cjs +287 -0
- package/pan-wizard-core/bin/lib/experiment.cjs +502 -0
- package/pan-wizard-core/bin/lib/learn-index.cjs +235 -0
- package/pan-wizard-core/bin/lib/learn-lint.cjs +292 -0
- package/pan-wizard-core/bin/lib/links.cjs +549 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +474 -1
- package/pan-wizard-core/bin/lib/runner.cjs +473 -0
- package/pan-wizard-core/bin/lib/verify.cjs +23 -0
- package/pan-wizard-core/bin/pan-tools.cjs +247 -3
- package/pan-wizard-core/learnings/README.md +70 -0
- package/pan-wizard-core/learnings/index.json +540 -0
- package/pan-wizard-core/learnings/internal/.gitkeep +2 -0
- package/pan-wizard-core/learnings/internal/experiment-runner.md +81 -0
- package/pan-wizard-core/learnings/internal/external-research.md +93 -0
- package/pan-wizard-core/learnings/internal/loop-design.md +33 -0
- package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +181 -0
- package/pan-wizard-core/learnings/universal/.gitkeep +2 -0
- package/pan-wizard-core/learnings/universal/atomic-state.md +21 -0
- package/pan-wizard-core/learnings/universal/binary-io.md +21 -0
- package/pan-wizard-core/learnings/universal/comment-syntax.md +21 -0
- package/pan-wizard-core/learnings/universal/composition.md +33 -0
- package/pan-wizard-core/learnings/universal/concurrency.md +33 -0
- package/pan-wizard-core/learnings/universal/dag-scheduler.md +33 -0
- package/pan-wizard-core/learnings/universal/data-driven-design.md +21 -0
- package/pan-wizard-core/learnings/universal/design-process.md +21 -0
- package/pan-wizard-core/learnings/universal/empirical-spike.md +21 -0
- package/pan-wizard-core/learnings/universal/error-handling.md +23 -0
- package/pan-wizard-core/learnings/universal/error-paths.md +21 -0
- package/pan-wizard-core/learnings/universal/glob-semantics.md +21 -0
- package/pan-wizard-core/learnings/universal/idempotency.md +21 -0
- package/pan-wizard-core/learnings/universal/invariants.md +21 -0
- package/pan-wizard-core/learnings/universal/io-patterns.md +21 -0
- package/pan-wizard-core/learnings/universal/numeric-edge-cases.md +21 -0
- package/pan-wizard-core/learnings/universal/output-conventions.md +21 -0
- package/pan-wizard-core/learnings/universal/parser-design.md +21 -0
- package/pan-wizard-core/learnings/universal/phase-locking.md +21 -0
- package/pan-wizard-core/learnings/universal/pipe-friendly-cli.md +21 -0
- package/pan-wizard-core/learnings/universal/schema-design.md +21 -0
- package/pan-wizard-core/learnings/universal/secret-handling.md +21 -0
- package/pan-wizard-core/learnings/universal/streaming-io.md +21 -0
- package/pan-wizard-core/learnings/universal/test-patterns.md +57 -0
- package/pan-wizard-core/learnings/universal/test-strategy.md +33 -0
- package/pan-wizard-core/learnings/universal/unicode.md +21 -0
- package/pan-wizard-core/learnings/universal/vendor-pattern.md +21 -0
- package/pan-wizard-core/references/guardrails.md +58 -0
- package/pan-wizard-core/references/handoff-decisions.md +156 -0
- package/pan-wizard-core/references/schemas/pan-command.schema.yml +39 -0
- package/pan-wizard-core/references/verification-patterns.md +31 -0
- package/pan-wizard-core/templates/config.json +2 -1
- package/pan-wizard-core/templates/idea.md +52 -0
- package/pan-wizard-core/templates/summary-complex.md +14 -5
- package/pan-wizard-core/templates/summary-minimal.md +6 -0
- package/pan-wizard-core/templates/summary-standard.md +14 -3
- package/pan-wizard-core/workflows/discuss-phase.md +108 -1
- package/pan-wizard-core/workflows/exec-phase.md +37 -1
- package/pan-wizard-core/workflows/execute-plan.md +14 -0
- package/pan-wizard-core/workflows/health.md +23 -0
- package/pan-wizard-core/workflows/new-project.md +65 -81
- package/pan-wizard-core/workflows/plan-phase.md +58 -0
- package/pan-wizard-core/workflows/transition.md +102 -7
- package/pan-wizard-core/workflows/verify-phase.md +14 -0
- package/scripts/build-hooks.js +7 -1
- package/scripts/generate-skills-docs.py +10 -8
- package/scripts/git-hooks/pre-commit +40 -0
- package/scripts/release-check.js +184 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* frontmatter.js — minimal YAML-ish frontmatter parser.
|
|
4
|
+
*
|
|
5
|
+
* Supports the subset documented in DESIGN_SPEC.md §"YAML subset":
|
|
6
|
+
* - Scalars: strings (quoted or bare), numbers, booleans, null
|
|
7
|
+
* - Flow lists: [a, b, c]
|
|
8
|
+
* - Block maps: key: value (one per line)
|
|
9
|
+
* - Comments: # ... (skipped)
|
|
10
|
+
*
|
|
11
|
+
* Anything beyond this subset → error code 'frontmatter-malformed'.
|
|
12
|
+
*
|
|
13
|
+
* Returns: {data, bodyStart, errors}
|
|
14
|
+
* data — parsed object (empty {} if no frontmatter)
|
|
15
|
+
* bodyStart — line number (1-indexed) where the body starts (after closing ---)
|
|
16
|
+
* Used for line-number arithmetic in violation reports.
|
|
17
|
+
* errors — array of {line, message} for parse problems (NOT validation —
|
|
18
|
+
* that's validate.js's job)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const FENCE = '---';
|
|
22
|
+
|
|
23
|
+
function parseFrontmatter(text) {
|
|
24
|
+
if (text == null) return { data: {}, bodyStart: 1, errors: [] };
|
|
25
|
+
|
|
26
|
+
const lines = text.split(/\r?\n/);
|
|
27
|
+
if (lines.length === 0 || lines[0] !== FENCE) {
|
|
28
|
+
// No frontmatter. Body starts at line 1.
|
|
29
|
+
return { data: {}, bodyStart: 1, errors: [], hasFrontmatter: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Find closing fence
|
|
33
|
+
let closingIndex = -1;
|
|
34
|
+
for (let i = 1; i < lines.length; i++) {
|
|
35
|
+
if (lines[i] === FENCE) {
|
|
36
|
+
closingIndex = i;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (closingIndex === -1) {
|
|
42
|
+
return {
|
|
43
|
+
data: {},
|
|
44
|
+
bodyStart: 1,
|
|
45
|
+
errors: [{ line: 1, message: 'frontmatter opening --- has no matching closing ---' }],
|
|
46
|
+
hasFrontmatter: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fmLines = lines.slice(1, closingIndex);
|
|
51
|
+
const result = parseFrontmatterBlock(fmLines);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
data: result.data,
|
|
55
|
+
bodyStart: closingIndex + 2, // 1-indexed line AFTER the closing ---
|
|
56
|
+
errors: result.errors,
|
|
57
|
+
hasFrontmatter: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseFrontmatterBlock(lines) {
|
|
62
|
+
const data = {};
|
|
63
|
+
const errors = [];
|
|
64
|
+
|
|
65
|
+
// Extended after dogfood gate (see .planning/optimization/traces/.../trace.jsonl event 2026-04-27T11:50:00Z):
|
|
66
|
+
// Block-style lists are the dominant real-world format. The DESIGN_SPEC originally
|
|
67
|
+
// scoped them out — that decision was wrong. Block-list support added per deviation R1.
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const lineNum = i + 2; // 1-indexed in source file (line 1 is opening ---)
|
|
71
|
+
let line = lines[i];
|
|
72
|
+
|
|
73
|
+
// Strip trailing comments
|
|
74
|
+
line = stripTrailingComment(line);
|
|
75
|
+
|
|
76
|
+
// Skip blank/comment-only lines
|
|
77
|
+
if (line.trim() === '') continue;
|
|
78
|
+
|
|
79
|
+
// Match `key:` (no value) — start of a block list or block map
|
|
80
|
+
const blockKeyMatch = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*$/);
|
|
81
|
+
if (blockKeyMatch) {
|
|
82
|
+
const key = blockKeyMatch[1];
|
|
83
|
+
|
|
84
|
+
// Look ahead: collect subsequent lines that are list items (" - x") or
|
|
85
|
+
// sub-map entries (" k: v") indented under this key. Stop at next top-level
|
|
86
|
+
// key (no leading whitespace) or end of block.
|
|
87
|
+
const childLines = [];
|
|
88
|
+
let j = i + 1;
|
|
89
|
+
while (j < lines.length) {
|
|
90
|
+
const next = lines[j];
|
|
91
|
+
if (next.trim() === '' || next.trim().startsWith('#')) { j++; continue; }
|
|
92
|
+
// Top-level key (no indent) ends the child block
|
|
93
|
+
if (/^[A-Za-z_]/.test(next)) break;
|
|
94
|
+
// Indented line — accumulate
|
|
95
|
+
childLines.push({ raw: next, line: j + 2 });
|
|
96
|
+
j++;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Detect: is this a block list (- items) or a block map (key: val)?
|
|
100
|
+
const looksList = childLines.length > 0 && childLines.every(c => /^\s+-\s+/.test(c.raw));
|
|
101
|
+
if (looksList) {
|
|
102
|
+
const items = [];
|
|
103
|
+
for (const cl of childLines) {
|
|
104
|
+
const itemMatch = cl.raw.match(/^\s+-\s+(.*)$/);
|
|
105
|
+
if (!itemMatch) {
|
|
106
|
+
errors.push({ line: cl.line, message: `expected "- value" in block list, got: ${JSON.stringify(cl.raw)}` });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const itemRaw = stripTrailingComment(itemMatch[1]).trim();
|
|
110
|
+
const parsed = parseScalarOrList(itemRaw, cl.line);
|
|
111
|
+
if (parsed.error) {
|
|
112
|
+
errors.push({ line: cl.line, message: `in list item: ${parsed.error}` });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
items.push(parsed.value);
|
|
116
|
+
}
|
|
117
|
+
if (key in data) {
|
|
118
|
+
errors.push({ line: lineNum, message: `duplicate key "${key}"` });
|
|
119
|
+
} else {
|
|
120
|
+
data[key] = items;
|
|
121
|
+
}
|
|
122
|
+
i = j - 1; // resume after the block
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// Block map shape: not supported in v0.1, but don't error — treat the
|
|
126
|
+
// key as null-valued and let validation handle it.
|
|
127
|
+
if (childLines.length > 0) {
|
|
128
|
+
// Block-style maps are out of scope per DESIGN_SPEC. Surface a warning
|
|
129
|
+
// rather than a crash so the rest of the file still validates.
|
|
130
|
+
errors.push({ line: lineNum, message: `block-map values not supported in v0.1 for key "${key}" (use a flow map or scalar)` });
|
|
131
|
+
i = j - 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// No child lines → just an empty value
|
|
135
|
+
if (key in data) {
|
|
136
|
+
errors.push({ line: lineNum, message: `duplicate key "${key}"` });
|
|
137
|
+
} else {
|
|
138
|
+
data[key] = null;
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Match `key: value` (scalar / flow list / etc.)
|
|
144
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
|
145
|
+
if (!m) {
|
|
146
|
+
errors.push({ line: lineNum, message: `expected "key: value", got: ${JSON.stringify(line)}` });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const key = m[1];
|
|
151
|
+
const rawValue = m[2];
|
|
152
|
+
|
|
153
|
+
if (key in data) {
|
|
154
|
+
errors.push({ line: lineNum, message: `duplicate key "${key}"` });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const parsed = parseScalarOrList(rawValue, lineNum);
|
|
159
|
+
if (parsed.error) {
|
|
160
|
+
errors.push({ line: lineNum, message: parsed.error });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
data[key] = parsed.value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { data, errors };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function stripTrailingComment(line) {
|
|
170
|
+
// Naive: strip from first # not inside quotes. PAN frontmatter doesn't put
|
|
171
|
+
// # in values, so this is safe for our subset. If a user does, they'll see
|
|
172
|
+
// a frontmatter-malformed error and can quote it.
|
|
173
|
+
const inSingle = (s, idx) => {
|
|
174
|
+
let q = 0;
|
|
175
|
+
for (let j = 0; j < idx; j++) if (s[j] === "'") q++;
|
|
176
|
+
return q % 2 === 1;
|
|
177
|
+
};
|
|
178
|
+
const inDouble = (s, idx) => {
|
|
179
|
+
let q = 0;
|
|
180
|
+
for (let j = 0; j < idx; j++) if (s[j] === '"') q++;
|
|
181
|
+
return q % 2 === 1;
|
|
182
|
+
};
|
|
183
|
+
for (let i = 0; i < line.length; i++) {
|
|
184
|
+
if (line[i] === '#' && !inSingle(line, i) && !inDouble(line, i)) {
|
|
185
|
+
return line.slice(0, i).trimEnd();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return line;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseScalarOrList(raw, lineNum) {
|
|
192
|
+
const trimmed = raw.trim();
|
|
193
|
+
|
|
194
|
+
if (trimmed === '') return { value: null };
|
|
195
|
+
|
|
196
|
+
// Flow list: [a, b, c]
|
|
197
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
198
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
199
|
+
if (inner === '') return { value: [] };
|
|
200
|
+
const items = splitFlowItems(inner);
|
|
201
|
+
const parsedItems = [];
|
|
202
|
+
for (const item of items) {
|
|
203
|
+
const sub = parseScalarOrList(item, lineNum);
|
|
204
|
+
if (sub.error) return { error: `in list: ${sub.error}` };
|
|
205
|
+
parsedItems.push(sub.value);
|
|
206
|
+
}
|
|
207
|
+
return { value: parsedItems };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (trimmed.startsWith('{')) {
|
|
211
|
+
return { error: `inline maps not supported in v0.1 (got ${JSON.stringify(trimmed)})` };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Quoted strings
|
|
215
|
+
if (
|
|
216
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
217
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
218
|
+
) {
|
|
219
|
+
if (trimmed.length < 2) return { error: 'unterminated quoted string' };
|
|
220
|
+
return { value: trimmed.slice(1, -1) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Bare keywords
|
|
224
|
+
if (trimmed === 'true') return { value: true };
|
|
225
|
+
if (trimmed === 'false') return { value: false };
|
|
226
|
+
if (trimmed === 'null' || trimmed === '~') return { value: null };
|
|
227
|
+
|
|
228
|
+
// Numbers
|
|
229
|
+
if (/^-?\d+$/.test(trimmed)) return { value: parseInt(trimmed, 10) };
|
|
230
|
+
if (/^-?\d+\.\d+$/.test(trimmed)) return { value: parseFloat(trimmed) };
|
|
231
|
+
|
|
232
|
+
// Bare string (catch-all)
|
|
233
|
+
return { value: trimmed };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function splitFlowItems(inner) {
|
|
237
|
+
// Simple comma-split that respects quoted strings and nested brackets.
|
|
238
|
+
const items = [];
|
|
239
|
+
let depth = 0;
|
|
240
|
+
let inSingle = false;
|
|
241
|
+
let inDouble = false;
|
|
242
|
+
let current = '';
|
|
243
|
+
|
|
244
|
+
for (const ch of inner) {
|
|
245
|
+
if (inSingle) {
|
|
246
|
+
current += ch;
|
|
247
|
+
if (ch === "'") inSingle = false;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (inDouble) {
|
|
251
|
+
current += ch;
|
|
252
|
+
if (ch === '"') inDouble = false;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (ch === "'") { inSingle = true; current += ch; continue; }
|
|
256
|
+
if (ch === '"') { inDouble = true; current += ch; continue; }
|
|
257
|
+
if (ch === '[' || ch === '{') { depth++; current += ch; continue; }
|
|
258
|
+
if (ch === ']' || ch === '}') { depth--; current += ch; continue; }
|
|
259
|
+
if (ch === ',' && depth === 0) {
|
|
260
|
+
items.push(current.trim());
|
|
261
|
+
current = '';
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
current += ch;
|
|
265
|
+
}
|
|
266
|
+
if (current.trim() !== '') items.push(current.trim());
|
|
267
|
+
return items;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = { parseFrontmatter };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* reporter.js — format violations for output.
|
|
4
|
+
*
|
|
5
|
+
* Two formats per DESIGN_SPEC.md §"CLI surface":
|
|
6
|
+
* - human: <file>:<line> — <code> — <message> (multi-line, colorless for v0.1)
|
|
7
|
+
* - json: NDJSON, one violation per line
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Array<Violation>} violations
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function formatHuman(violations) {
|
|
15
|
+
if (!violations || violations.length === 0) return '';
|
|
16
|
+
const lines = [];
|
|
17
|
+
for (const v of violations) {
|
|
18
|
+
const sev = v.severity === 'warning' ? '[warn]' : '[err] ';
|
|
19
|
+
lines.push(`${sev} ${v.file}:${v.line} — ${v.code} — ${v.message}`);
|
|
20
|
+
}
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {Array<Violation>} violations
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function formatJson(violations) {
|
|
29
|
+
if (!violations || violations.length === 0) return '';
|
|
30
|
+
return violations.map(v => JSON.stringify(v)).join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns a one-line summary suitable for end-of-run output.
|
|
35
|
+
* @param {Array<Violation>} violations
|
|
36
|
+
* @param {number} fileCount
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function summaryLine(violations, fileCount) {
|
|
40
|
+
const errors = violations.filter(v => v.severity === 'error').length;
|
|
41
|
+
const warnings = violations.filter(v => v.severity === 'warning').length;
|
|
42
|
+
return `Linted ${fileCount} file(s): ${errors} error(s), ${warnings} warning(s)`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { formatHuman, formatJson, summaryLine };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* schema.js — parse + validate schema definition files.
|
|
4
|
+
*
|
|
5
|
+
* Schema source format (also YAML-ish, parsed by frontmatter.js's parser
|
|
6
|
+
* applied to a fenced wrapper):
|
|
7
|
+
*
|
|
8
|
+
* fields:
|
|
9
|
+
* title:
|
|
10
|
+
* required: true
|
|
11
|
+
* type: string
|
|
12
|
+
* pattern: ^[A-Z]
|
|
13
|
+
*
|
|
14
|
+
* Returns: {schema, errors} where schema is the normalized form with regexes
|
|
15
|
+
* compiled, types validated, etc.
|
|
16
|
+
*
|
|
17
|
+
* Note: we don't reuse the frontmatter parser's block style — schemas use a
|
|
18
|
+
* deeper nested map shape than v0.1 frontmatter supports. We hand-roll a tiny
|
|
19
|
+
* indentation-aware parser tuned for the schema shape only. This is a
|
|
20
|
+
* scope-bounded compromise documented in DESIGN_SPEC §"YAML subset".
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const VALID_TYPES = ['string', 'number', 'boolean', 'enum', 'array'];
|
|
24
|
+
const FIELD_KEYS = ['required', 'type', 'pattern', 'values', 'default', 'items'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a schema source string into a normalized schema or return errors.
|
|
28
|
+
* @returns {{schema: object|null, errors: Array<{line:number, message:string}>}}
|
|
29
|
+
*/
|
|
30
|
+
function parseSchema(text) {
|
|
31
|
+
const errors = [];
|
|
32
|
+
if (text == null || text.trim() === '') {
|
|
33
|
+
return { schema: null, errors: [{ line: 1, message: 'schema is empty' }] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const lines = text.split(/\r?\n/);
|
|
37
|
+
|
|
38
|
+
// Find `fields:` line
|
|
39
|
+
let fieldsLine = -1;
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
if (lines[i].trim() === 'fields:') {
|
|
42
|
+
fieldsLine = i;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (fieldsLine === -1) {
|
|
48
|
+
errors.push({ line: 1, message: 'schema must start with `fields:` block' });
|
|
49
|
+
return { schema: null, errors };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Parse fields block
|
|
53
|
+
const fields = {};
|
|
54
|
+
let currentField = null;
|
|
55
|
+
let currentFieldLine = 0;
|
|
56
|
+
|
|
57
|
+
for (let i = fieldsLine + 1; i < lines.length; i++) {
|
|
58
|
+
const lineNum = i + 1; // 1-indexed
|
|
59
|
+
const raw = lines[i];
|
|
60
|
+
|
|
61
|
+
// Skip blank lines
|
|
62
|
+
if (raw.trim() === '') continue;
|
|
63
|
+
// Skip comment lines
|
|
64
|
+
if (raw.trim().startsWith('#')) continue;
|
|
65
|
+
|
|
66
|
+
// Field name: 2-space indent, ends with `:`
|
|
67
|
+
const fieldMatch = raw.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):\s*$/);
|
|
68
|
+
if (fieldMatch) {
|
|
69
|
+
currentField = fieldMatch[1];
|
|
70
|
+
currentFieldLine = lineNum;
|
|
71
|
+
if (currentField in fields) {
|
|
72
|
+
errors.push({ line: lineNum, message: `duplicate field "${currentField}"` });
|
|
73
|
+
}
|
|
74
|
+
fields[currentField] = { _line: lineNum };
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Field property: 4-space indent
|
|
79
|
+
const propMatch = raw.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
80
|
+
if (propMatch && currentField) {
|
|
81
|
+
const key = propMatch[1];
|
|
82
|
+
const rawValue = propMatch[2].trim();
|
|
83
|
+
|
|
84
|
+
if (!FIELD_KEYS.includes(key)) {
|
|
85
|
+
errors.push({ line: lineNum, message: `unknown field property "${key}" (allowed: ${FIELD_KEYS.join(', ')})` });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const parsed = parseSchemaValue(key, rawValue, lineNum);
|
|
90
|
+
if (parsed.error) {
|
|
91
|
+
errors.push({ line: lineNum, message: parsed.error });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
fields[currentField][key] = parsed.value;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Anything else (including incorrectly-indented lines)
|
|
99
|
+
if (raw.length > 0 && !/^\s*$/.test(raw)) {
|
|
100
|
+
errors.push({ line: lineNum, message: `unexpected line in fields block: ${JSON.stringify(raw)}` });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate each field
|
|
105
|
+
const normalized = {};
|
|
106
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
107
|
+
const fieldErrors = checkFieldDefinition(name, def);
|
|
108
|
+
errors.push(...fieldErrors);
|
|
109
|
+
if (fieldErrors.length === 0) {
|
|
110
|
+
// Strip the _line marker from the public shape; keep all other props
|
|
111
|
+
const { _line, ...publicDef } = def;
|
|
112
|
+
normalized[name] = publicDef;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
schema: errors.length === 0 ? { fields: normalized } : null,
|
|
118
|
+
errors,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseSchemaValue(key, rawValue, lineNum) {
|
|
123
|
+
if (key === 'required') {
|
|
124
|
+
if (rawValue === 'true') return { value: true };
|
|
125
|
+
if (rawValue === 'false') return { value: false };
|
|
126
|
+
return { error: `required must be true|false, got ${JSON.stringify(rawValue)}` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (key === 'type') {
|
|
130
|
+
if (!VALID_TYPES.includes(rawValue)) {
|
|
131
|
+
return { error: `type must be one of ${VALID_TYPES.join(', ')}, got ${JSON.stringify(rawValue)}` };
|
|
132
|
+
}
|
|
133
|
+
return { value: rawValue };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (key === 'pattern') {
|
|
137
|
+
try {
|
|
138
|
+
return { value: new RegExp(rawValue) };
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return { error: `invalid regex pattern: ${e.message}` };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (key === 'values') {
|
|
145
|
+
// Inline list shape: [a, b, c]
|
|
146
|
+
if (!rawValue.startsWith('[') || !rawValue.endsWith(']')) {
|
|
147
|
+
return { error: `values must be a flow list [a, b, c], got ${JSON.stringify(rawValue)}` };
|
|
148
|
+
}
|
|
149
|
+
const inner = rawValue.slice(1, -1).trim();
|
|
150
|
+
if (inner === '') return { value: [] };
|
|
151
|
+
return { value: inner.split(',').map(s => s.trim()).filter(Boolean) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (key === 'default') {
|
|
155
|
+
return { value: rawValue }; // store raw
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (key === 'items') {
|
|
159
|
+
if (!VALID_TYPES.includes(rawValue)) {
|
|
160
|
+
return { error: `items must be one of ${VALID_TYPES.join(', ')}, got ${JSON.stringify(rawValue)}` };
|
|
161
|
+
}
|
|
162
|
+
return { value: rawValue };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { error: `unknown property ${key}` };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function checkFieldDefinition(name, def) {
|
|
169
|
+
const errors = [];
|
|
170
|
+
const line = def._line || 1;
|
|
171
|
+
|
|
172
|
+
if (!('type' in def)) {
|
|
173
|
+
errors.push({ line, message: `field "${name}" missing required property "type"` });
|
|
174
|
+
return errors;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (def.type === 'enum' && !('values' in def)) {
|
|
178
|
+
errors.push({ line, message: `field "${name}" type=enum requires "values:"` });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (def.type === 'array' && !('items' in def)) {
|
|
182
|
+
errors.push({ line, message: `field "${name}" type=array requires "items:"` });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (def.required === true && 'default' in def) {
|
|
186
|
+
errors.push({ line, message: `field "${name}" cannot have both required:true and default:` });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return errors;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Public API: validate that a parsed schema is well-formed (no extra checks
|
|
194
|
+
* beyond what parseSchema already does, but exposed for the CLI's
|
|
195
|
+
* `whooo schema check` subcommand).
|
|
196
|
+
*/
|
|
197
|
+
function checkSchema(schemaText) {
|
|
198
|
+
const result = parseSchema(schemaText);
|
|
199
|
+
return { ok: result.errors.length === 0, errors: result.errors };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = { parseSchema, checkSchema, VALID_TYPES, FIELD_KEYS };
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* validate.js — validate parsed frontmatter data against a parsed schema.
|
|
4
|
+
*
|
|
5
|
+
* Returns an array of violations matching the shape documented in
|
|
6
|
+
* DESIGN_SPEC.md §"Key data shapes":
|
|
7
|
+
*
|
|
8
|
+
* { file, line, field, code, message, severity }
|
|
9
|
+
*
|
|
10
|
+
* Implements the 8 error codes in DESIGN_SPEC.md §"Error codes":
|
|
11
|
+
* frontmatter-missing, frontmatter-malformed, required-missing,
|
|
12
|
+
* type-mismatch, enum-violation, pattern-mismatch, array-item-type,
|
|
13
|
+
* unknown-field
|
|
14
|
+
*
|
|
15
|
+
* The {data, errors, hasFrontmatter} input is whatever frontmatter.js
|
|
16
|
+
* returned for a given file. line numbers are absolute in the source file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} fmResult - {data, bodyStart, errors, hasFrontmatter} from parseFrontmatter
|
|
21
|
+
* @param {object} schema - parsed schema from parseSchema (i.e., {fields: {...}})
|
|
22
|
+
* @param {string} sourceFile - POSIX-style relative path for the violation report
|
|
23
|
+
* @param {object} [opts] - {strict: bool} — if true, frontmatter-missing is error severity
|
|
24
|
+
* @returns {Array<Violation>}
|
|
25
|
+
*/
|
|
26
|
+
function validateAgainstSchema(fmResult, schema, sourceFile, opts = {}) {
|
|
27
|
+
const violations = [];
|
|
28
|
+
const strict = !!opts.strict;
|
|
29
|
+
|
|
30
|
+
// 1. Frontmatter parse errors → frontmatter-malformed
|
|
31
|
+
if (fmResult.errors && fmResult.errors.length > 0) {
|
|
32
|
+
for (const err of fmResult.errors) {
|
|
33
|
+
violations.push(violation({
|
|
34
|
+
file: sourceFile, line: err.line, field: null,
|
|
35
|
+
code: 'frontmatter-malformed',
|
|
36
|
+
message: err.message,
|
|
37
|
+
severity: 'error',
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
return violations; // can't validate fields if parse failed
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. No frontmatter at all
|
|
44
|
+
if (fmResult.hasFrontmatter === false) {
|
|
45
|
+
violations.push(violation({
|
|
46
|
+
file: sourceFile, line: 1, field: null,
|
|
47
|
+
code: 'frontmatter-missing',
|
|
48
|
+
message: 'file has no frontmatter',
|
|
49
|
+
severity: strict ? 'error' : 'warning',
|
|
50
|
+
}));
|
|
51
|
+
return violations;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const data = fmResult.data || {};
|
|
55
|
+
const fields = schema.fields || {};
|
|
56
|
+
|
|
57
|
+
// 3. Per-field checks
|
|
58
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
59
|
+
const present = name in data;
|
|
60
|
+
const value = data[name];
|
|
61
|
+
|
|
62
|
+
if (!present) {
|
|
63
|
+
if (def.required) {
|
|
64
|
+
violations.push(violation({
|
|
65
|
+
file: sourceFile, line: 1, field: name,
|
|
66
|
+
code: 'required-missing',
|
|
67
|
+
message: `field "${name}" is required`,
|
|
68
|
+
severity: 'error',
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Type check
|
|
75
|
+
const typeError = checkType(value, def);
|
|
76
|
+
if (typeError) {
|
|
77
|
+
violations.push(violation({
|
|
78
|
+
file: sourceFile, line: 1, field: name,
|
|
79
|
+
code: typeError.code,
|
|
80
|
+
message: typeError.message,
|
|
81
|
+
severity: 'error',
|
|
82
|
+
}));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Enum check
|
|
87
|
+
if (def.type === 'enum' && Array.isArray(def.values) && !def.values.includes(String(value))) {
|
|
88
|
+
violations.push(violation({
|
|
89
|
+
file: sourceFile, line: 1, field: name,
|
|
90
|
+
code: 'enum-violation',
|
|
91
|
+
message: `field "${name}" value ${JSON.stringify(value)} not in [${def.values.join(', ')}]`,
|
|
92
|
+
severity: 'error',
|
|
93
|
+
}));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Pattern check
|
|
98
|
+
if (def.pattern instanceof RegExp && typeof value === 'string' && !def.pattern.test(value)) {
|
|
99
|
+
violations.push(violation({
|
|
100
|
+
file: sourceFile, line: 1, field: name,
|
|
101
|
+
code: 'pattern-mismatch',
|
|
102
|
+
message: `field "${name}" value ${JSON.stringify(value)} does not match pattern ${def.pattern}`,
|
|
103
|
+
severity: 'error',
|
|
104
|
+
}));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 4. Unknown fields (warning only — info-level)
|
|
110
|
+
for (const key of Object.keys(data)) {
|
|
111
|
+
if (!(key in fields)) {
|
|
112
|
+
violations.push(violation({
|
|
113
|
+
file: sourceFile, line: 1, field: key,
|
|
114
|
+
code: 'unknown-field',
|
|
115
|
+
message: `field "${key}" is not in schema (consider adding it or removing from file)`,
|
|
116
|
+
severity: 'warning',
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return violations;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function checkType(value, def) {
|
|
125
|
+
switch (def.type) {
|
|
126
|
+
case 'string':
|
|
127
|
+
if (typeof value !== 'string') {
|
|
128
|
+
return { code: 'type-mismatch', message: `expected string, got ${typeOf(value)}` };
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
case 'number':
|
|
132
|
+
if (typeof value !== 'number') {
|
|
133
|
+
return { code: 'type-mismatch', message: `expected number, got ${typeOf(value)}` };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
case 'boolean':
|
|
137
|
+
if (typeof value !== 'boolean') {
|
|
138
|
+
return { code: 'type-mismatch', message: `expected boolean, got ${typeOf(value)}` };
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
case 'enum':
|
|
142
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
|
143
|
+
return { code: 'type-mismatch', message: `expected scalar (for enum), got ${typeOf(value)}` };
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
case 'array':
|
|
147
|
+
if (!Array.isArray(value)) {
|
|
148
|
+
return { code: 'type-mismatch', message: `expected array, got ${typeOf(value)}` };
|
|
149
|
+
}
|
|
150
|
+
// Item type check
|
|
151
|
+
if (def.items) {
|
|
152
|
+
for (let i = 0; i < value.length; i++) {
|
|
153
|
+
const itemType = typeOf(value[i]);
|
|
154
|
+
const expected = def.items;
|
|
155
|
+
// Note: the item-type contract treats string/number/boolean only;
|
|
156
|
+
// enum/array items are out of scope for v0.1.
|
|
157
|
+
const matches = (
|
|
158
|
+
(expected === 'string' && itemType === 'string') ||
|
|
159
|
+
(expected === 'number' && itemType === 'number') ||
|
|
160
|
+
(expected === 'boolean' && itemType === 'boolean')
|
|
161
|
+
);
|
|
162
|
+
if (!matches) {
|
|
163
|
+
return { code: 'array-item-type', message: `array item [${i}] expected ${expected}, got ${itemType}` };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
default:
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function typeOf(v) {
|
|
174
|
+
if (v === null) return 'null';
|
|
175
|
+
if (Array.isArray(v)) return 'array';
|
|
176
|
+
return typeof v;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function violation(v) {
|
|
180
|
+
return {
|
|
181
|
+
file: v.file,
|
|
182
|
+
line: v.line || 1,
|
|
183
|
+
field: v.field || null,
|
|
184
|
+
code: v.code,
|
|
185
|
+
message: v.message,
|
|
186
|
+
severity: v.severity || 'error',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { validateAgainstSchema };
|