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
|
@@ -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 };
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* doc-lint.cjs — markdown frontmatter linter for PAN's own files.
|
|
4
|
+
*
|
|
5
|
+
* Vendored from whooo (https://github.com/oharms/PanWizard experiments/whooo).
|
|
6
|
+
* Wraps doc-lint/{frontmatter,schema,validate,walk,reporter}.js with PAN's
|
|
7
|
+
* core.cjs output() pattern.
|
|
8
|
+
*
|
|
9
|
+
* Spec: docs/specs/self_improvement_loop_featureai.md (whooo experiment outputs)
|
|
10
|
+
* Pattern source: P-201 + P-202 + P-301 (promoted from whooo run, v3.7.0)
|
|
11
|
+
*
|
|
12
|
+
* Usage (CLI):
|
|
13
|
+
* pan-tools doc-lint <dir> [--schema <path>] [--format json|human] [--strict]
|
|
14
|
+
*
|
|
15
|
+
* Default schema: pan-wizard-core/references/schemas/pan-command.schema.yml
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { output, error } = require('./core.cjs');
|
|
21
|
+
|
|
22
|
+
const { parseFrontmatter } = require('./doc-lint/frontmatter.js');
|
|
23
|
+
const { parseSchema } = require('./doc-lint/schema.js');
|
|
24
|
+
const { validateAgainstSchema } = require('./doc-lint/validate.js');
|
|
25
|
+
const { walkMarkdownFiles } = require('./doc-lint/walk.js');
|
|
26
|
+
const { formatHuman, formatJson, summaryLine } = require('./doc-lint/reporter.js');
|
|
27
|
+
|
|
28
|
+
const DEFAULT_SCHEMA_PATH = path.join(
|
|
29
|
+
__dirname,
|
|
30
|
+
'..',
|
|
31
|
+
'..',
|
|
32
|
+
'references',
|
|
33
|
+
'schemas',
|
|
34
|
+
'pan-command.schema.yml'
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Lint a directory of markdown files against a schema.
|
|
39
|
+
* @param {string} cwd - working directory (used to resolve relative paths)
|
|
40
|
+
* @param {string} dir - directory to scan (relative to cwd or absolute)
|
|
41
|
+
* @param {object} opts - { schema: string, format: 'json'|'human', strict: bool, exclude: string[], raw: bool }
|
|
42
|
+
* @returns {void} — writes to stdout via output(); exit code via process.exit
|
|
43
|
+
*/
|
|
44
|
+
function cmdDocLint(cwd, dir, opts = {}) {
|
|
45
|
+
const targetDir = path.isAbsolute(dir) ? dir : path.join(cwd, dir);
|
|
46
|
+
if (!fs.existsSync(targetDir)) {
|
|
47
|
+
error(`directory not found: ${targetDir}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const schemaPath = opts.schema
|
|
51
|
+
? (path.isAbsolute(opts.schema) ? opts.schema : path.join(cwd, opts.schema))
|
|
52
|
+
: DEFAULT_SCHEMA_PATH;
|
|
53
|
+
if (!fs.existsSync(schemaPath)) {
|
|
54
|
+
error(`schema not found: ${schemaPath}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const schemaText = fs.readFileSync(schemaPath, 'utf-8');
|
|
58
|
+
const { schema, errors: schemaErrors } = parseSchema(schemaText);
|
|
59
|
+
if (schemaErrors.length > 0) {
|
|
60
|
+
if (opts.raw) {
|
|
61
|
+
process.stderr.write(`schema has ${schemaErrors.length} error(s):\n`);
|
|
62
|
+
for (const e of schemaErrors) {
|
|
63
|
+
process.stderr.write(` ${schemaPath}:${e.line} — ${e.message}\n`);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
output({ schema_errors: schemaErrors, schema: schemaPath }, false);
|
|
67
|
+
}
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const exclude = opts.exclude || [];
|
|
72
|
+
const files = walkMarkdownFiles(targetDir, { exclude });
|
|
73
|
+
const violations = [];
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
if (file.readError) {
|
|
76
|
+
violations.push({
|
|
77
|
+
file: file.relativePath,
|
|
78
|
+
line: 1, field: null,
|
|
79
|
+
code: 'file-read-error',
|
|
80
|
+
message: file.readError,
|
|
81
|
+
severity: 'error',
|
|
82
|
+
});
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const fm = parseFrontmatter(file.content);
|
|
86
|
+
const v = validateAgainstSchema(fm, schema, file.relativePath, { strict: !!opts.strict });
|
|
87
|
+
violations.push(...v);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const fileCount = files.length;
|
|
91
|
+
const errorCount = violations.filter(v => v.severity === 'error').length;
|
|
92
|
+
const warningCount = violations.filter(v => v.severity === 'warning').length;
|
|
93
|
+
|
|
94
|
+
const format = opts.format || 'human';
|
|
95
|
+
if (opts.raw) {
|
|
96
|
+
if (format === 'json') {
|
|
97
|
+
const txt = formatJson(violations);
|
|
98
|
+
if (txt) process.stdout.write(txt + '\n');
|
|
99
|
+
} else {
|
|
100
|
+
const txt = formatHuman(violations);
|
|
101
|
+
if (txt) process.stdout.write(txt + '\n');
|
|
102
|
+
process.stdout.write(summaryLine(violations, fileCount) + '\n');
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
output({
|
|
106
|
+
directory: dir,
|
|
107
|
+
schema: schemaPath,
|
|
108
|
+
file_count: fileCount,
|
|
109
|
+
error_count: errorCount,
|
|
110
|
+
warning_count: warningCount,
|
|
111
|
+
violations,
|
|
112
|
+
}, false);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
process.exit(errorCount > 0 ? 1 : 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate that a schema file is well-formed.
|
|
120
|
+
*/
|
|
121
|
+
function cmdDocLintSchemaCheck(cwd, schemaPath, opts = {}) {
|
|
122
|
+
const resolved = path.isAbsolute(schemaPath) ? schemaPath : path.join(cwd, schemaPath);
|
|
123
|
+
if (!fs.existsSync(resolved)) {
|
|
124
|
+
error(`schema not found: ${resolved}`);
|
|
125
|
+
}
|
|
126
|
+
const text = fs.readFileSync(resolved, 'utf-8');
|
|
127
|
+
const { errors } = parseSchema(text);
|
|
128
|
+
const result = {
|
|
129
|
+
schema: resolved,
|
|
130
|
+
ok: errors.length === 0,
|
|
131
|
+
error_count: errors.length,
|
|
132
|
+
errors,
|
|
133
|
+
};
|
|
134
|
+
output(result, opts.raw);
|
|
135
|
+
process.exit(result.ok ? 0 : 1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Count-drift lint (IMPROVEMENT-TODO P1, v3.7.10) ────────────────────────
|
|
139
|
+
//
|
|
140
|
+
// Counts (tests, commands, agents, modules, etc.) are supposed to live ONLY
|
|
141
|
+
// in CLAUDE.md. Other docs MUST NOT embed them — they drift instantly. This
|
|
142
|
+
// linter scans markdown files and flags any drift-prone numeric count it
|
|
143
|
+
// finds outside the allowed paths.
|
|
144
|
+
|
|
145
|
+
// Negative lookbehind `(?<!\.)` excludes version-number captures like
|
|
146
|
+
// "v3.5 module" or "v4.7 Commands" — these are version refs, not counts.
|
|
147
|
+
const COUNT_PATTERNS = [
|
|
148
|
+
// "52 commands", "21 agents", "30 modules", "2667 tests", etc.
|
|
149
|
+
// Word boundaries + allowed plurals; case-insensitive matching.
|
|
150
|
+
{ re: /(?<!\.)\b(\d+)\s+(commands?|agents?|modules?|workflows?|templates?|references?|specs?|adrs?|test\s+files?|test\s+suites?)\b/gi,
|
|
151
|
+
label: 'noun-phrase count' },
|
|
152
|
+
// "27th module", "21st agent", "52nd command" — drift-prone ordinals
|
|
153
|
+
{ re: /(?<!\.)\b(\d+)(th|st|nd|rd)\s+(module|reference|agent|command|template|hook|workflow|spec|adr)\b/gi,
|
|
154
|
+
label: 'ordinal' },
|
|
155
|
+
// "(9 files)", "(58 tests)" — parenthetical counts
|
|
156
|
+
{ re: /(?<!\.)\((\d+)\s+(files?|tests?)\)/gi, label: 'parenthetical count' },
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
// Files where counts ARE allowed:
|
|
160
|
+
// - CLAUDE.md (the SSoT)
|
|
161
|
+
// - CHANGELOG.md (frozen historical record)
|
|
162
|
+
// - MEMORY.md (user memory file, not shipped)
|
|
163
|
+
// - SKILLS-FULL-TEXT.md / SKILLS-REFERENCE.md (auto-generated; embed command
|
|
164
|
+
// prompt text that itself may legitimately reference numbers)
|
|
165
|
+
// - EXAMPLES.md (illustrative tool-output scenarios, not authoritative claims)
|
|
166
|
+
const COUNT_ALLOWED_RE = /(^|[\\/])(CLAUDE\.md|CHANGELOG\.md|MEMORY\.md|SKILLS-FULL-TEXT\.md|SKILLS-REFERENCE\.md|EXAMPLES\.md)$/i;
|
|
167
|
+
|
|
168
|
+
// Path SEGMENTS that mark a directory as count-allowed (frozen historical
|
|
169
|
+
// content). Matched as path segments so they catch both project-root-relative
|
|
170
|
+
// paths (e.g. "docs/decisions/X.md") and scan-root-relative paths (e.g.
|
|
171
|
+
// "decisions/X.md" when the scan rooted at docs/).
|
|
172
|
+
const COUNT_ALLOWED_DIR_SEGMENTS = [
|
|
173
|
+
'decisions', // ADRs — frozen
|
|
174
|
+
'specs', // feature specs — frozen
|
|
175
|
+
'experiments', // harvested experiment artifacts
|
|
176
|
+
'learnings', // AI-derived patterns; evidence quotes reference numbers
|
|
177
|
+
'archive', // archived old docs
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
function isCountAllowed(relativePath) {
|
|
181
|
+
if (COUNT_ALLOWED_RE.test(relativePath)) return true;
|
|
182
|
+
const norm = relativePath.replace(/\\/g, '/');
|
|
183
|
+
const segments = norm.split('/');
|
|
184
|
+
return segments.some(seg => COUNT_ALLOWED_DIR_SEGMENTS.includes(seg));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Things that LOOK like counts but are stable identities (allowed everywhere):
|
|
188
|
+
const STABLE_IDENTITIES = [
|
|
189
|
+
/\b5\s+(target\s+)?runtimes\b/i, // 5 target runtimes
|
|
190
|
+
/\b5\s+hooks\b/i, // 5 hooks (named individually)
|
|
191
|
+
/\bLAYER\s+\d+\b/, // architecture layer labels
|
|
192
|
+
/\b5\s+(parallel\s+)?(researchers?|research\s+)/i, // 5 parallel researchers
|
|
193
|
+
/\b6\s+(parallel\s+)?agents\b/i, // 6 parallel agents (codebase mapper)
|
|
194
|
+
/\b6\s+focus\s+areas\b/i, // 6 focus areas (mapper)
|
|
195
|
+
/\b4\s+parallel\s+research/i, // 4 parallel research
|
|
196
|
+
/\bthree\s+phases\b|\bfour\s+phases\b/i, // generic phase counts in narrative
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
function isStableIdentity(matchText, surrounding) {
|
|
200
|
+
for (const re of STABLE_IDENTITIES) {
|
|
201
|
+
if (re.test(matchText) || re.test(surrounding)) return true;
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Scan a directory tree for drift-prone count violations.
|
|
208
|
+
* @param {string} cwd - working directory
|
|
209
|
+
* @param {string} dir - dir to scan (relative or absolute)
|
|
210
|
+
* @param {object} opts - { format, raw, exclude }
|
|
211
|
+
*/
|
|
212
|
+
function cmdDocLintCounts(cwd, dir, opts = {}) {
|
|
213
|
+
const targetDir = path.isAbsolute(dir) ? dir : path.join(cwd, dir);
|
|
214
|
+
if (!fs.existsSync(targetDir)) {
|
|
215
|
+
error(`directory not found: ${targetDir}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const exclude = opts.exclude || [];
|
|
219
|
+
const files = walkMarkdownFiles(targetDir, { exclude });
|
|
220
|
+
const violations = [];
|
|
221
|
+
|
|
222
|
+
for (const file of files) {
|
|
223
|
+
if (isCountAllowed(file.relativePath)) continue;
|
|
224
|
+
if (file.readError) continue;
|
|
225
|
+
|
|
226
|
+
const lines = file.content.split(/\r?\n/);
|
|
227
|
+
let inFence = false; // track ```fenced``` code blocks; skip their content
|
|
228
|
+
for (let i = 0; i < lines.length; i++) {
|
|
229
|
+
const line = lines[i];
|
|
230
|
+
// Toggle fence on lines starting with ```
|
|
231
|
+
if (/^\s{0,3}```/.test(line)) {
|
|
232
|
+
inFence = !inFence;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (inFence) continue;
|
|
236
|
+
|
|
237
|
+
for (const { re, label } of COUNT_PATTERNS) {
|
|
238
|
+
re.lastIndex = 0; // reset for /g
|
|
239
|
+
let m;
|
|
240
|
+
while ((m = re.exec(line)) !== null) {
|
|
241
|
+
// Skip if this whole line is in a stable-identity surrounding
|
|
242
|
+
if (isStableIdentity(m[0], line)) continue;
|
|
243
|
+
violations.push({
|
|
244
|
+
file: file.relativePath,
|
|
245
|
+
line: i + 1,
|
|
246
|
+
column: m.index + 1,
|
|
247
|
+
match: m[0],
|
|
248
|
+
label,
|
|
249
|
+
severity: 'warning',
|
|
250
|
+
message: `Drift-prone count "${m[0]}" outside CLAUDE.md. Counts live only in CLAUDE.md; replace with qualitative phrasing or remove.`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const fileCount = files.length;
|
|
258
|
+
const result = {
|
|
259
|
+
directory: dir,
|
|
260
|
+
file_count: fileCount,
|
|
261
|
+
violation_count: violations.length,
|
|
262
|
+
violations,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (opts.raw) {
|
|
266
|
+
if (violations.length === 0) {
|
|
267
|
+
process.stdout.write(`OK — ${fileCount} files scanned, no count violations\n`);
|
|
268
|
+
} else {
|
|
269
|
+
for (const v of violations) {
|
|
270
|
+
process.stdout.write(`${v.file}:${v.line}:${v.column} — ${v.match} (${v.label})\n`);
|
|
271
|
+
}
|
|
272
|
+
process.stdout.write(`\n${violations.length} violation(s) across ${fileCount} files\n`);
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
output(result, false);
|
|
276
|
+
}
|
|
277
|
+
process.exit(violations.length > 0 ? 1 : 0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = {
|
|
281
|
+
cmdDocLint,
|
|
282
|
+
cmdDocLintSchemaCheck,
|
|
283
|
+
cmdDocLintCounts,
|
|
284
|
+
isCountAllowed,
|
|
285
|
+
COUNT_PATTERNS,
|
|
286
|
+
DEFAULT_SCHEMA_PATH,
|
|
287
|
+
};
|