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.
Files changed (98) hide show
  1. package/README.md +28 -9
  2. package/agents/pan-executor.md +18 -0
  3. package/agents/pan-experiment-runner.md +126 -0
  4. package/agents/pan-phase-researcher.md +16 -0
  5. package/agents/pan-plan-checker.md +80 -0
  6. package/agents/pan-planner.md +19 -0
  7. package/agents/pan-reviewer.md +2 -0
  8. package/agents/pan-verifier.md +41 -0
  9. package/bin/install-lib.cjs +55 -0
  10. package/bin/install.js +71 -22
  11. package/commands/pan/debug.md +1 -1
  12. package/commands/pan/experiment.md +219 -0
  13. package/commands/pan/health.md +1 -1
  14. package/commands/pan/learn.md +15 -1
  15. package/commands/pan/links.md +102 -0
  16. package/commands/pan/optimize.md +13 -0
  17. package/commands/pan/patches.md +10 -1
  18. package/commands/pan/phase-tests.md +1 -4
  19. package/commands/pan/todo-add.md +1 -1
  20. package/commands/pan/todo-check.md +1 -1
  21. package/hooks/dist/pan-cost-logger.js +54 -4
  22. package/hooks/dist/pan-trace-logger.js +72 -3
  23. package/package.json +67 -66
  24. package/pan-wizard-core/bin/lib/codebase.cjs +2 -0
  25. package/pan-wizard-core/bin/lib/commands.cjs +8 -0
  26. package/pan-wizard-core/bin/lib/config.cjs +13 -2
  27. package/pan-wizard-core/bin/lib/context-budget.cjs +73 -0
  28. package/pan-wizard-core/bin/lib/core.cjs +13 -0
  29. package/pan-wizard-core/bin/lib/doc-lint/frontmatter.js +270 -0
  30. package/pan-wizard-core/bin/lib/doc-lint/reporter.js +45 -0
  31. package/pan-wizard-core/bin/lib/doc-lint/schema.js +202 -0
  32. package/pan-wizard-core/bin/lib/doc-lint/validate.js +190 -0
  33. package/pan-wizard-core/bin/lib/doc-lint/walk.js +135 -0
  34. package/pan-wizard-core/bin/lib/doc-lint.cjs +287 -0
  35. package/pan-wizard-core/bin/lib/experiment.cjs +502 -0
  36. package/pan-wizard-core/bin/lib/learn-index.cjs +235 -0
  37. package/pan-wizard-core/bin/lib/learn-lint.cjs +292 -0
  38. package/pan-wizard-core/bin/lib/links.cjs +549 -0
  39. package/pan-wizard-core/bin/lib/optimize.cjs +474 -1
  40. package/pan-wizard-core/bin/lib/runner.cjs +473 -0
  41. package/pan-wizard-core/bin/lib/verify.cjs +23 -0
  42. package/pan-wizard-core/bin/pan-tools.cjs +247 -3
  43. package/pan-wizard-core/learnings/README.md +70 -0
  44. package/pan-wizard-core/learnings/index.json +540 -0
  45. package/pan-wizard-core/learnings/internal/.gitkeep +2 -0
  46. package/pan-wizard-core/learnings/internal/experiment-runner.md +81 -0
  47. package/pan-wizard-core/learnings/internal/external-research.md +93 -0
  48. package/pan-wizard-core/learnings/internal/loop-design.md +33 -0
  49. package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +181 -0
  50. package/pan-wizard-core/learnings/universal/.gitkeep +2 -0
  51. package/pan-wizard-core/learnings/universal/atomic-state.md +21 -0
  52. package/pan-wizard-core/learnings/universal/binary-io.md +21 -0
  53. package/pan-wizard-core/learnings/universal/comment-syntax.md +21 -0
  54. package/pan-wizard-core/learnings/universal/composition.md +33 -0
  55. package/pan-wizard-core/learnings/universal/concurrency.md +33 -0
  56. package/pan-wizard-core/learnings/universal/dag-scheduler.md +33 -0
  57. package/pan-wizard-core/learnings/universal/data-driven-design.md +21 -0
  58. package/pan-wizard-core/learnings/universal/design-process.md +21 -0
  59. package/pan-wizard-core/learnings/universal/empirical-spike.md +21 -0
  60. package/pan-wizard-core/learnings/universal/error-handling.md +23 -0
  61. package/pan-wizard-core/learnings/universal/error-paths.md +21 -0
  62. package/pan-wizard-core/learnings/universal/glob-semantics.md +21 -0
  63. package/pan-wizard-core/learnings/universal/idempotency.md +21 -0
  64. package/pan-wizard-core/learnings/universal/invariants.md +21 -0
  65. package/pan-wizard-core/learnings/universal/io-patterns.md +21 -0
  66. package/pan-wizard-core/learnings/universal/numeric-edge-cases.md +21 -0
  67. package/pan-wizard-core/learnings/universal/output-conventions.md +21 -0
  68. package/pan-wizard-core/learnings/universal/parser-design.md +21 -0
  69. package/pan-wizard-core/learnings/universal/phase-locking.md +21 -0
  70. package/pan-wizard-core/learnings/universal/pipe-friendly-cli.md +21 -0
  71. package/pan-wizard-core/learnings/universal/schema-design.md +21 -0
  72. package/pan-wizard-core/learnings/universal/secret-handling.md +21 -0
  73. package/pan-wizard-core/learnings/universal/streaming-io.md +21 -0
  74. package/pan-wizard-core/learnings/universal/test-patterns.md +57 -0
  75. package/pan-wizard-core/learnings/universal/test-strategy.md +33 -0
  76. package/pan-wizard-core/learnings/universal/unicode.md +21 -0
  77. package/pan-wizard-core/learnings/universal/vendor-pattern.md +21 -0
  78. package/pan-wizard-core/references/guardrails.md +58 -0
  79. package/pan-wizard-core/references/handoff-decisions.md +156 -0
  80. package/pan-wizard-core/references/schemas/pan-command.schema.yml +39 -0
  81. package/pan-wizard-core/references/verification-patterns.md +31 -0
  82. package/pan-wizard-core/templates/config.json +2 -1
  83. package/pan-wizard-core/templates/idea.md +52 -0
  84. package/pan-wizard-core/templates/summary-complex.md +14 -5
  85. package/pan-wizard-core/templates/summary-minimal.md +6 -0
  86. package/pan-wizard-core/templates/summary-standard.md +14 -3
  87. package/pan-wizard-core/workflows/discuss-phase.md +108 -1
  88. package/pan-wizard-core/workflows/exec-phase.md +37 -1
  89. package/pan-wizard-core/workflows/execute-plan.md +14 -0
  90. package/pan-wizard-core/workflows/health.md +23 -0
  91. package/pan-wizard-core/workflows/new-project.md +65 -81
  92. package/pan-wizard-core/workflows/plan-phase.md +58 -0
  93. package/pan-wizard-core/workflows/transition.md +102 -7
  94. package/pan-wizard-core/workflows/verify-phase.md +14 -0
  95. package/scripts/build-hooks.js +7 -1
  96. package/scripts/generate-skills-docs.py +10 -8
  97. package/scripts/git-hooks/pre-commit +40 -0
  98. 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 };