pan-wizard 3.5.1 → 3.7.10
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 +10 -10
- 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/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/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 +501 -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/optimize.cjs +474 -1
- package/pan-wizard-core/bin/lib/runner.cjs +472 -0
- package/pan-wizard-core/bin/pan-tools.cjs +222 -2
- 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/release-check.js +184 -0
|
@@ -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 };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* walk.js — recursive .md file walker with --exclude glob support.
|
|
4
|
+
*
|
|
5
|
+
* Synchronous (no need for async — file count is bounded; perf budget allows
|
|
6
|
+
* it; matches PAN's CLI shape). Returns an array of {path, content} where
|
|
7
|
+
* `path` is an absolute path with forward-slash normalization (POSIX).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
/** Normalize a path to POSIX-style forward slashes. */
|
|
14
|
+
function toPosix(p) {
|
|
15
|
+
return p.replace(/\\/g, '/');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Walk a directory recursively and return all .md files.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} dir - absolute directory path
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
* @param {Array<string>} [opts.exclude] - simple glob patterns to exclude
|
|
24
|
+
* (matches against the POSIX path; supported tokens: ** and *)
|
|
25
|
+
* @returns {Array<{path: string, content: string, relativePath: string}>}
|
|
26
|
+
*/
|
|
27
|
+
function walkMarkdownFiles(dir, opts = {}) {
|
|
28
|
+
const exclude = opts.exclude || [];
|
|
29
|
+
const excludeRegexes = exclude.map(globToRegex);
|
|
30
|
+
|
|
31
|
+
if (!fs.existsSync(dir)) {
|
|
32
|
+
throw new Error(`directory not found: ${dir}`);
|
|
33
|
+
}
|
|
34
|
+
const stat = fs.statSync(dir);
|
|
35
|
+
if (!stat.isDirectory()) {
|
|
36
|
+
throw new Error(`not a directory: ${dir}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const out = [];
|
|
40
|
+
const baseAbs = path.resolve(dir);
|
|
41
|
+
walkRecursive(baseAbs, baseAbs, excludeRegexes, out);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function walkRecursive(currentDir, baseDir, excludeRegexes, out) {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Permission denied, etc. — emit a synthetic entry the caller can convert
|
|
51
|
+
// to a violation.
|
|
52
|
+
out.push({
|
|
53
|
+
path: toPosix(currentDir),
|
|
54
|
+
content: null,
|
|
55
|
+
relativePath: toPosix(path.relative(baseDir, currentDir)),
|
|
56
|
+
readError: err.message,
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
63
|
+
const posixFull = toPosix(fullPath);
|
|
64
|
+
const relative = toPosix(path.relative(baseDir, fullPath));
|
|
65
|
+
|
|
66
|
+
if (matchesAny(posixFull, excludeRegexes) || matchesAny(relative, excludeRegexes)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
walkRecursive(fullPath, baseDir, excludeRegexes, out);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
76
|
+
let content = '';
|
|
77
|
+
let readError = null;
|
|
78
|
+
try {
|
|
79
|
+
content = fs.readFileSync(fullPath, 'utf-8');
|
|
80
|
+
} catch (err) {
|
|
81
|
+
readError = err.message;
|
|
82
|
+
}
|
|
83
|
+
out.push({
|
|
84
|
+
path: posixFull,
|
|
85
|
+
relativePath: relative,
|
|
86
|
+
content,
|
|
87
|
+
readError,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function matchesAny(s, regexes) {
|
|
94
|
+
for (const re of regexes) if (re.test(s)) return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Convert a simple glob to a RegExp.
|
|
100
|
+
* Supports: ** (matches any chars including /), * (matches any chars except /)
|
|
101
|
+
* Anchored to full string match.
|
|
102
|
+
*
|
|
103
|
+
* Convention: when `**` is followed by `/`, the slash is also optional —
|
|
104
|
+
* `**\/foo.md` matches both `foo.md` (root) and `a/b/foo.md` (nested).
|
|
105
|
+
* Mirrors gitignore / minimatch behavior.
|
|
106
|
+
*/
|
|
107
|
+
function globToRegex(glob) {
|
|
108
|
+
let re = '^';
|
|
109
|
+
for (let i = 0; i < glob.length; i++) {
|
|
110
|
+
const ch = glob[i];
|
|
111
|
+
if (ch === '*') {
|
|
112
|
+
if (glob[i + 1] === '*') {
|
|
113
|
+
// Match the standard `**\/<name>` pattern: the trailing slash is
|
|
114
|
+
// optional, so `**\/foo.md` matches root-level `foo.md` too.
|
|
115
|
+
if (glob[i + 2] === '/') {
|
|
116
|
+
re += '(?:.*/)?';
|
|
117
|
+
i += 2; // skip ** and /
|
|
118
|
+
} else {
|
|
119
|
+
re += '.*';
|
|
120
|
+
i++; // skip next *
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
re += '[^/]*';
|
|
124
|
+
}
|
|
125
|
+
} else if ('.+?^$()|{}[]\\'.includes(ch)) {
|
|
126
|
+
re += '\\' + ch;
|
|
127
|
+
} else {
|
|
128
|
+
re += ch;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
re += '$';
|
|
132
|
+
return new RegExp(re);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { walkMarkdownFiles, toPosix, globToRegex };
|