pan-wizard 3.5.2 → 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 +8 -8
- 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
|
@@ -52,6 +52,7 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
|
|
|
52
52
|
plan_check: true,
|
|
53
53
|
verifier: true,
|
|
54
54
|
nyquist_validation: false,
|
|
55
|
+
phase_record_compact: false,
|
|
55
56
|
},
|
|
56
57
|
parallelization: true,
|
|
57
58
|
brave_search: hasBraveSearch,
|
|
@@ -422,17 +423,27 @@ function cmdStandardsRecommend(cwd, raw) {
|
|
|
422
423
|
if (/\b(enterprise|togaf|architecture\s*governance|compliance)\b/.test(lower)) detectedTypes.push('enterprise');
|
|
423
424
|
if (/\b(cli|command.line|terminal|shell|argv)\b/.test(lower)) detectedTypes.push('cli');
|
|
424
425
|
|
|
425
|
-
|
|
426
|
+
const explicitlyDetected = detectedTypes.length > 0;
|
|
427
|
+
if (!explicitlyDetected) detectedTypes.push('general');
|
|
426
428
|
|
|
427
429
|
const seen = new Set();
|
|
428
430
|
const recommendations = [];
|
|
429
431
|
for (const type of detectedTypes) {
|
|
430
432
|
const recs = STANDARDS_RECOMMENDATIONS[type] || [];
|
|
433
|
+
let perTypeIndex = 0;
|
|
431
434
|
for (const id of recs) {
|
|
432
435
|
if (seen.has(id)) continue;
|
|
433
436
|
seen.add(id);
|
|
434
437
|
const s = STANDARDS_CATALOG[id];
|
|
435
|
-
|
|
438
|
+
const isHigh = explicitlyDetected && type !== 'general' && perTypeIndex === 0;
|
|
439
|
+
recommendations.push({
|
|
440
|
+
id,
|
|
441
|
+
name: s.name,
|
|
442
|
+
reason: type + ' project detected',
|
|
443
|
+
priority: isHigh ? 'high' : 'medium',
|
|
444
|
+
source_type: type,
|
|
445
|
+
});
|
|
446
|
+
perTypeIndex += 1;
|
|
436
447
|
}
|
|
437
448
|
}
|
|
438
449
|
|
|
@@ -21,6 +21,51 @@ function estimateTokens(text) {
|
|
|
21
21
|
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Estimate a relevance signal for per-phase markdown content (P-RES-002).
|
|
26
|
+
*
|
|
27
|
+
* Per Chroma's "Context Rot" research (Hong & Huber, July 2025): a single
|
|
28
|
+
* semantically-similar-but-irrelevant distractor degrades performance even
|
|
29
|
+
* at modest context sizes. Distractor density matters more than token count.
|
|
30
|
+
*
|
|
31
|
+
* Computing TRUE topic-relevance requires embeddings or keyword analysis
|
|
32
|
+
* we can't do cheaply at zero deps. This v0 heuristic reports a simpler
|
|
33
|
+
* signal: structure-vs-content ratio. Markdown files heavy on headers,
|
|
34
|
+
* separators, empty bullet lists, and placeholder text are LESS dense in
|
|
35
|
+
* actual signal than files of equal length with concrete prose. The ratio
|
|
36
|
+
* isn't true distractor density but is correlated with it for the
|
|
37
|
+
* "thin/template-only context" failure mode.
|
|
38
|
+
*
|
|
39
|
+
* Returns ratio in [0, 1] where 1 = all content lines, 0 = all structure.
|
|
40
|
+
* Returns null if not enough lines to compute meaningfully.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @returns {number|null}
|
|
44
|
+
*/
|
|
45
|
+
function estimateRelevanceRatio(text) {
|
|
46
|
+
if (!text) return null;
|
|
47
|
+
const lines = text.split(/\r?\n/);
|
|
48
|
+
if (lines.length < 5) return null;
|
|
49
|
+
let contentLines = 0;
|
|
50
|
+
let totalLines = 0;
|
|
51
|
+
for (const raw of lines) {
|
|
52
|
+
const line = raw.trim();
|
|
53
|
+
if (!line) continue; // skip blank
|
|
54
|
+
totalLines++;
|
|
55
|
+
if (/^#{1,6}\s/.test(line)) continue; // skip header
|
|
56
|
+
if (/^[-*_]{3,}$/.test(line)) continue; // skip separator
|
|
57
|
+
if (/^[-*]\s*$/.test(line)) continue; // skip empty bullet
|
|
58
|
+
if (/^[-*]\s*\[\s*[\]x_]\s*\]\s*$/.test(line)) continue; // empty checkbox
|
|
59
|
+
if (/^\|\s*-+\s*\|/.test(line)) continue; // table separator row
|
|
60
|
+
if (/^>\s*$/.test(line)) continue; // empty blockquote
|
|
61
|
+
if (line.length < 10) continue; // very short — likely scaffolding
|
|
62
|
+
if (/^(TODO|TBD|FIXME|placeholder|todo|tbd|fixme|placeholder|coming soon)\b/i.test(line)) continue;
|
|
63
|
+
contentLines++;
|
|
64
|
+
}
|
|
65
|
+
if (totalLines === 0) return null;
|
|
66
|
+
return Math.round((contentLines / totalLines) * 1000) / 1000;
|
|
67
|
+
}
|
|
68
|
+
|
|
24
69
|
/**
|
|
25
70
|
* Compute context budget for the current project state.
|
|
26
71
|
* @param {string} cwd - Project root directory
|
|
@@ -101,6 +146,32 @@ function cmdContextBudget(cwd, raw) {
|
|
|
101
146
|
recommendation = `Within budget. ~${additionalPlans} more plans could fit before degradation.`;
|
|
102
147
|
}
|
|
103
148
|
|
|
149
|
+
// P-RES-002 signal: structure-vs-content ratio for per-phase markdown.
|
|
150
|
+
// High structure (lots of empty bullets, headers, placeholders) suggests
|
|
151
|
+
// the per-phase context is thin/templatey rather than substantive — a
|
|
152
|
+
// proxy for "context that's wasting tokens on filler."
|
|
153
|
+
let relevanceSignal = null;
|
|
154
|
+
if (phaseDir) {
|
|
155
|
+
const fullPhasePath = path.join(cwd, phaseDir);
|
|
156
|
+
const samples = [];
|
|
157
|
+
for (const fname of ['research.md', 'context.md']) {
|
|
158
|
+
const candidate = path.join(fullPhasePath, fname);
|
|
159
|
+
const content = safeReadFile(candidate);
|
|
160
|
+
if (content) {
|
|
161
|
+
const ratio = estimateRelevanceRatio(content);
|
|
162
|
+
if (ratio !== null) samples.push({ file: toPosix(path.join(phaseDir, fname)), ratio });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (samples.length > 0) {
|
|
166
|
+
const avg = samples.reduce((a, s) => a + s.ratio, 0) / samples.length;
|
|
167
|
+
relevanceSignal = {
|
|
168
|
+
avg_ratio: Math.round(avg * 1000) / 1000,
|
|
169
|
+
samples,
|
|
170
|
+
note: 'P-RES-002 v0 heuristic — structure/content ratio. <0.4 suggests thin per-phase context (heavy on headers + empty buckets + placeholders).',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
104
175
|
// E-8: cache metrics — surface how much of the total context would be
|
|
105
176
|
// served from prompt cache when Opus 4.7 cache_control is active.
|
|
106
177
|
const { buildCachedContext } = require('./core.cjs');
|
|
@@ -141,6 +212,7 @@ function cmdContextBudget(cwd, raw) {
|
|
|
141
212
|
contextWindow: CONTEXT_WINDOW,
|
|
142
213
|
budgetUtilization: Math.round(utilization * 1000) / 1000,
|
|
143
214
|
cache,
|
|
215
|
+
relevanceSignal,
|
|
144
216
|
recommendation,
|
|
145
217
|
};
|
|
146
218
|
|
|
@@ -174,4 +246,5 @@ function cmdContextBudget(cwd, raw) {
|
|
|
174
246
|
module.exports = {
|
|
175
247
|
cmdContextBudget,
|
|
176
248
|
estimateTokens,
|
|
249
|
+
estimateRelevanceRatio,
|
|
177
250
|
};
|
|
@@ -48,6 +48,7 @@ const COST_MULTIPLIERS = { reasoning: 15, mid: 3, fast: 1 };
|
|
|
48
48
|
// ─── Model Profile Table ─────────────────────────────────────────────────────
|
|
49
49
|
|
|
50
50
|
const MODEL_PROFILES = {
|
|
51
|
+
// Original planning/execution agents (pre-v3.0)
|
|
51
52
|
'pan-planner': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
52
53
|
'pan-roadmapper': { quality: 'reasoning', balanced: 'mid', budget: 'mid' },
|
|
53
54
|
'pan-executor': { quality: 'reasoning', balanced: 'mid', budget: 'mid' },
|
|
@@ -60,6 +61,18 @@ const MODEL_PROFILES = {
|
|
|
60
61
|
'pan-plan-checker': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
|
|
61
62
|
'pan-integration-checker': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
|
|
62
63
|
'pan-reviewer': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
|
|
64
|
+
// Spec B v2 agents (v3.0–v3.4) — added v3.7.5 to close MODEL_PROFILES drift
|
|
65
|
+
'pan-conductor': { quality: 'reasoning', balanced: 'reasoning', budget: 'mid' },
|
|
66
|
+
'pan-counterfactual': { quality: 'reasoning', balanced: 'mid', budget: 'mid' },
|
|
67
|
+
'pan-hardener': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
|
|
68
|
+
'pan-meta-reviewer': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
|
|
69
|
+
'pan-knowledge': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
|
|
70
|
+
'pan-previewer': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
|
|
71
|
+
// v3.5 agents
|
|
72
|
+
'pan-optimizer': { quality: 'reasoning', balanced: 'mid', budget: 'fast' },
|
|
73
|
+
'pan-distiller': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
|
|
74
|
+
// v3.7.0 self-improvement loop — observation-only watchdog
|
|
75
|
+
'pan-experiment-runner': { quality: 'reasoning', balanced: 'fast', budget: 'fast' },
|
|
63
76
|
};
|
|
64
77
|
|
|
65
78
|
// ─── Output helpers ───────────────────────────────────────────────────────────
|
|
@@ -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 };
|