pan-wizard 3.27.0 → 3.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -48
- package/agents/pan-previewer.md +1 -1
- package/bin/install-lib.cjs +580 -18
- package/bin/install.js +25 -44
- package/commands/pan/army.md +1 -1
- package/commands/pan/cost.md +14 -2
- package/commands/pan/preview.md +2 -2
- package/hooks/dist/pan-check-update.js +4 -0
- package/hooks/dist/pan-cost-logger.js +322 -43
- package/hooks/dist/pan-trace-logger.js +275 -32
- package/package.json +8 -2
- package/pan-wizard-core/bin/lib/commands.cjs +3 -1
- package/pan-wizard-core/bin/lib/constants.cjs +39 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +80 -0
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
- package/pan-wizard-core/bin/lib/cost.cjs +174 -18
- package/pan-wizard-core/bin/lib/foreign-planning.cjs +56 -0
- package/pan-wizard-core/bin/lib/git.cjs +5 -1
- package/pan-wizard-core/bin/lib/hud.cjs +5 -3
- package/pan-wizard-core/bin/lib/hygiene.cjs +52 -24
- package/pan-wizard-core/bin/lib/init.cjs +8 -0
- package/pan-wizard-core/bin/lib/memory.cjs +14 -8
- package/pan-wizard-core/bin/lib/optimize.cjs +78 -2
- package/pan-wizard-core/bin/lib/utils.cjs +22 -0
- package/pan-wizard-core/bin/lib/verify.cjs +46 -12
- package/pan-wizard-core/bin/pan-tools.cjs +8 -1
- package/pan-wizard-core/mcp/server.cjs +92 -8
- package/pan-wizard-core/mcp/tool-registry.cjs +50 -3
- package/pan-wizard-core/references/model-profiles.md +2 -2
- package/pan-wizard-core/workflows/health.md +2 -0
- package/pan-zcode/README.md +1 -1
- package/scripts/build-agent-plugin.js +220 -0
- package/scripts/build-plugin.js +48 -3
- package/scripts/coverage-gate.cjs +257 -0
- package/scripts/generate-skills-docs.py +1 -1
- package/scripts/install-git-hooks.js +5 -0
- package/scripts/mutation-probe.cjs +272 -0
- package/scripts/release-check.js +80 -13
- package/scripts/test-quality-lint.cjs +240 -0
- package/scripts/test-surface.cjs +335 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* test-quality-lint.cjs — the assertion shapes that proved vacuous, as lint rules.
|
|
4
|
+
*
|
|
5
|
+
* Each rule came from a test that passed while the feature it named was broken
|
|
6
|
+
* (audit of 2026-09-17, spec docs/specs/testing-system-redesign-2026-09.md §3.1):
|
|
7
|
+
* Q1 an OR-shaped liveness assert — `assert.ok(a.length > 0 || b.length > 0)` is
|
|
8
|
+
* satisfied by a crash with a stack trace;
|
|
9
|
+
* Q2 an in-process call to a lib module's `cmd*` function — they end in output()/
|
|
10
|
+
* error(), which exit the process, so the test child dies and node --test
|
|
11
|
+
* reports the file as one passing test;
|
|
12
|
+
* Q3 `assert.ok(true)` / `assert(true)`;
|
|
13
|
+
* Q4 a runPanTools result asserted only by `.output.length`;
|
|
14
|
+
* Q5 a platform conditional that bare-`return`s (counted as a pass) instead of
|
|
15
|
+
* `t.skip(reason)`;
|
|
16
|
+
* Q6 a wall-clock upper bound under two seconds on a spawned process;
|
|
17
|
+
* Q7 a read of the developer's real HOME / USERPROFILE / os.homedir();
|
|
18
|
+
* Q8 a committed `test.todo` (scaffold stubs must be filled before commit);
|
|
19
|
+
* Q9 an OR of bare property reads inside assert.ok — `assert.ok(a.x || a.y)` asks only
|
|
20
|
+
* whether one of them exists, which an empty object, the wrong field, or a payload
|
|
21
|
+
* that means failure all satisfy. Q1 covers the result-status fields; this covers
|
|
22
|
+
* the same vacuity for arbitrary ones.
|
|
23
|
+
*
|
|
24
|
+
* `lintTestSource(src, file)` is pure; tests/test-quality.test.cjs applies it to the
|
|
25
|
+
* suite with tests/fixtures/test-quality-allowlist.json (entries { file, rule,
|
|
26
|
+
* count, reason } — allowed occurrences per file and rule; an entry that allows more
|
|
27
|
+
* than the file has is stale and fails too).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
function stripComments(line) {
|
|
31
|
+
// Good enough for test sources: drop a trailing // comment that is not inside
|
|
32
|
+
// quotes, and ignore the body lines of a /** … */ block comment.
|
|
33
|
+
if (/^\s*(\*|\/\*)/.test(line)) return '';
|
|
34
|
+
let inS = null;
|
|
35
|
+
for (let i = 0; i < line.length; i++) {
|
|
36
|
+
const c = line[i];
|
|
37
|
+
if (inS) { if (c === '\\') i++; else if (c === inS) inS = null; continue; }
|
|
38
|
+
if (c === '\'' || c === '"' || c === '`') inS = c;
|
|
39
|
+
else if (c === '/' && line[i + 1] === '/') return line.slice(0, i);
|
|
40
|
+
}
|
|
41
|
+
return line;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Join an assert statement that spans lines until its parentheses balance (max 8 lines). */
|
|
45
|
+
function statementAt(lines, i) {
|
|
46
|
+
let text = lines[i];
|
|
47
|
+
let depth = 0;
|
|
48
|
+
for (let j = i; j < Math.min(lines.length, i + 8); j++) {
|
|
49
|
+
const t = stripComments(lines[j]);
|
|
50
|
+
if (j > i) text += ' ' + t.trim();
|
|
51
|
+
for (const c of t) { if (c === '(') depth++; else if (c === ')') depth--; }
|
|
52
|
+
if (depth <= 0 && j > i) break;
|
|
53
|
+
if (depth <= 0 && j === i && /\)\s*;?\s*$/.test(t)) break;
|
|
54
|
+
}
|
|
55
|
+
return text;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const TIME_WORDS = /\b(ms|elapsed|duration|took|Date\.now|hrtime|performance\.now|timing)\b/;
|
|
59
|
+
|
|
60
|
+
// The result-status fields whose bare truthiness a liveness assert ORs together:
|
|
61
|
+
// `result.output || result.error`, `!r.success || r.error`, `parsed.error || parsed.state`.
|
|
62
|
+
// A crash satisfies every one of them — stderr is non-empty. An OR between two
|
|
63
|
+
// content checks (`x.includes('a') || x.includes('b')`) is a legitimate either-format
|
|
64
|
+
// assert and is not this rule's business.
|
|
65
|
+
const STATUS_FIELD = /^!?\(?\s*[\w$.]*\b(success|error|output|stderr|stdout|reason|state|status)\b\s*\)?$/;
|
|
66
|
+
const LENGTH_LIVENESS = /^!?\(?\s*[\w$.]*\.(?:output|error|stderr|stdout)\.length\s*>\s*0\s*\)?$/;
|
|
67
|
+
|
|
68
|
+
function isLivenessOr(inner) {
|
|
69
|
+
const operands = inner.split(/\|\|/).map((s) => s.trim());
|
|
70
|
+
if (operands.length < 2) return false;
|
|
71
|
+
const bare = operands.filter((o) => STATUS_FIELD.test(o) || LENGTH_LIVENESS.test(o));
|
|
72
|
+
return bare.length >= 2;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const RULES = [
|
|
76
|
+
{
|
|
77
|
+
id: 'Q1',
|
|
78
|
+
title: 'OR-shaped liveness assert',
|
|
79
|
+
fix: 'assert the exit code and parse the JSON payload (or assert each branch on its own)',
|
|
80
|
+
detect(lines) {
|
|
81
|
+
const out = [];
|
|
82
|
+
lines.forEach((raw, i) => {
|
|
83
|
+
const line = stripComments(raw);
|
|
84
|
+
if (!/\bassert(?:\.ok)?\s*\(/.test(line)) return;
|
|
85
|
+
const stmt = statementAt(lines, i);
|
|
86
|
+
// The asserted expression: everything before the message argument.
|
|
87
|
+
let inner = stmt.slice(stmt.indexOf('(') + 1).replace(/\)\s*;?\s*$/, '');
|
|
88
|
+
const msg = inner.search(/,\s*(['"`])/);
|
|
89
|
+
if (msg >= 0) inner = inner.slice(0, msg);
|
|
90
|
+
if (isLivenessOr(inner)) out.push({ line: i + 1, text: raw.trim() });
|
|
91
|
+
});
|
|
92
|
+
return out;
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'Q2',
|
|
97
|
+
title: 'in-process call to a lib cmd* function',
|
|
98
|
+
fix: 'go through runPanTools (or the module’s pure functions) — cmd* functions end in output()/error(), which exit the process',
|
|
99
|
+
detect(lines) {
|
|
100
|
+
const out = [];
|
|
101
|
+
lines.forEach((raw, i) => {
|
|
102
|
+
const line = stripComments(raw);
|
|
103
|
+
if (/\bcmd[A-Z][A-Za-z0-9]*\s*\(/.test(line) && !/^\s*(const|let|var)\s+\{/.test(line)) out.push({ line: i + 1, text: raw.trim() });
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'Q3',
|
|
110
|
+
title: 'assert(true)',
|
|
111
|
+
fix: 'assert a specific value',
|
|
112
|
+
detect(lines) {
|
|
113
|
+
const out = [];
|
|
114
|
+
lines.forEach((raw, i) => { if (/\bassert(?:\.ok)?\(\s*true\s*[,)]/.test(stripComments(raw))) out.push({ line: i + 1, text: raw.trim() }); });
|
|
115
|
+
return out;
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'Q4',
|
|
120
|
+
title: 'CLI output asserted only by its length',
|
|
121
|
+
fix: 'parse the JSON payload and assert its fields; assert success/exit code',
|
|
122
|
+
detect(lines) {
|
|
123
|
+
const out = [];
|
|
124
|
+
lines.forEach((raw, i) => { if (/assert\.ok\(\s*[A-Za-z_$][\w$.]*\.output\.length\s*[><!=]/.test(stripComments(raw))) out.push({ line: i + 1, text: raw.trim() }); });
|
|
125
|
+
return out;
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'Q5',
|
|
130
|
+
title: 'platform conditional that bare-returns',
|
|
131
|
+
fix: 'use t.skip(reason) so the skip is reported, never counted as a pass',
|
|
132
|
+
detect(lines) {
|
|
133
|
+
const out = [];
|
|
134
|
+
lines.forEach((raw, i) => {
|
|
135
|
+
const line = stripComments(raw);
|
|
136
|
+
if (!/process\.platform/.test(line) || !/\bif\s*\(/.test(line)) return;
|
|
137
|
+
const window = [line, lines[i + 1] || '', lines[i + 2] || ''].map(stripComments).join(' ');
|
|
138
|
+
if (/\bskip\s*\(/.test(window) || /\btodo\s*\(/.test(window)) return;
|
|
139
|
+
if (/\)\s*\{?\s*return\b/.test(line) || /^\s*return\s*;?\s*\}?\s*$/.test(stripComments(lines[i + 1] || ''))) out.push({ line: i + 1, text: raw.trim() });
|
|
140
|
+
});
|
|
141
|
+
return out;
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'Q6',
|
|
146
|
+
title: 'wall-clock upper bound under two seconds',
|
|
147
|
+
fix: 'assert the behaviour, not the speed; if timing is the contract, bound it generously and allowlist with the reason',
|
|
148
|
+
detect(lines) {
|
|
149
|
+
const out = [];
|
|
150
|
+
lines.forEach((raw, i) => {
|
|
151
|
+
const line = stripComments(raw);
|
|
152
|
+
if (!/\bassert(?:\.ok)?\s*\(/.test(line) || !TIME_WORDS.test(line)) return;
|
|
153
|
+
const m = /<\s*=?\s*(\d{2,4})\b/.exec(line);
|
|
154
|
+
if (m && Number(m[1]) < 2000) out.push({ line: i + 1, text: raw.trim() });
|
|
155
|
+
});
|
|
156
|
+
return out;
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'Q7',
|
|
161
|
+
title: 'read of the real HOME',
|
|
162
|
+
fix: 'sandbox with withFakeHome() from tests/helpers.cjs, or pass an explicit config dir',
|
|
163
|
+
detect(lines) {
|
|
164
|
+
const out = [];
|
|
165
|
+
lines.forEach((raw, i) => {
|
|
166
|
+
const line = stripComments(raw);
|
|
167
|
+
if (/os\.homedir\(\)|process\.env\.(?:HOME|USERPROFILE)\b(?!\s*=[^=])/.test(line)) out.push({ line: i + 1, text: raw.trim() });
|
|
168
|
+
});
|
|
169
|
+
return out;
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'Q8',
|
|
174
|
+
title: 'committed todo',
|
|
175
|
+
fix: 'fill the stub or delete it; todo stubs are scaffold output, not tests',
|
|
176
|
+
detect(lines) {
|
|
177
|
+
const out = [];
|
|
178
|
+
lines.forEach((raw, i) => { if (/\b(?:test|it|describe)\.todo\s*\(|\{\s*todo\s*:/.test(stripComments(raw))) out.push({ line: i + 1, text: raw.trim() }); });
|
|
179
|
+
return out;
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'Q9',
|
|
184
|
+
title: 'OR of bare property reads in assert.ok',
|
|
185
|
+
fix: 'assert the specific field and its expected value, not that one of several exists',
|
|
186
|
+
detect(lines) {
|
|
187
|
+
const out = [];
|
|
188
|
+
lines.forEach((raw, i) => {
|
|
189
|
+
const line = stripComments(raw);
|
|
190
|
+
const m = line.match(/assert\.ok\(\s*([^;]*?)\s*(?:,\s*['"`][^;]*)?\)\s*;/);
|
|
191
|
+
if (!m || !m[1].includes('||')) return;
|
|
192
|
+
const expr = m[1];
|
|
193
|
+
// A comparison, a call, a negation or a length check is making a claim about a
|
|
194
|
+
// value; only a bare existence test is vacuous in this way.
|
|
195
|
+
if (/[=<>]|\(|\)|!|\.length|typeof|Array\.isArray/.test(expr)) return;
|
|
196
|
+
const operands = expr.split('||').map((s) => s.trim());
|
|
197
|
+
if (operands.length < 2) return;
|
|
198
|
+
if (!operands.every((o) => /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/.test(o))) return;
|
|
199
|
+
out.push({ line: i + 1, text: raw.trim() });
|
|
200
|
+
});
|
|
201
|
+
return out;
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
/** Findings for one test source: [{ rule, title, fix, line, text }]. */
|
|
207
|
+
function lintTestSource(src, file = '') {
|
|
208
|
+
const lines = src.split(/\r?\n/);
|
|
209
|
+
const findings = [];
|
|
210
|
+
for (const rule of RULES) {
|
|
211
|
+
for (const f of rule.detect(lines)) findings.push({ rule: rule.id, title: rule.title, fix: rule.fix, file, line: f.line, text: f.text });
|
|
212
|
+
}
|
|
213
|
+
return findings;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Apply an allowlist ([{ file, rule, count, reason }]) to findings.
|
|
218
|
+
* Returns { remaining, stale } — stale entries allow more than the file has, or
|
|
219
|
+
* carry no reason. Both fail the suite: an allowlist is a debt register, not a bin.
|
|
220
|
+
*/
|
|
221
|
+
function applyAllowlist(findings, allowlist) {
|
|
222
|
+
const remaining = [];
|
|
223
|
+
const stale = [];
|
|
224
|
+
const byKey = new Map();
|
|
225
|
+
for (const f of findings) { const k = `${f.file}|${f.rule}`; byKey.set(k, (byKey.get(k) || []).concat(f)); }
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
for (const entry of allowlist || []) {
|
|
228
|
+
const k = `${entry.file}|${entry.rule}`;
|
|
229
|
+
seen.add(k);
|
|
230
|
+
const have = (byKey.get(k) || []).length;
|
|
231
|
+
if (!entry.reason || !String(entry.reason).trim()) stale.push({ ...entry, why: 'no reason given' });
|
|
232
|
+
else if (have === 0) stale.push({ ...entry, why: 'file has no such finding any more — remove the entry' });
|
|
233
|
+
else if (have < (entry.count || 0)) stale.push({ ...entry, why: `allows ${entry.count} but the file has ${have} — lower the count` });
|
|
234
|
+
else if (have > (entry.count || 0)) remaining.push(...(byKey.get(k) || []).slice(entry.count || 0));
|
|
235
|
+
}
|
|
236
|
+
for (const [k, list] of byKey) if (!seen.has(k)) remaining.push(...list);
|
|
237
|
+
return { remaining, stale };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = { RULES, lintTestSource, applyAllowlist, stripComments };
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* test-surface.cjs — the shipped surface derived from the code, and its map to the tests.
|
|
5
|
+
*
|
|
6
|
+
* What must be tested is read off the code, never off the tests (spec:
|
|
7
|
+
* docs/specs/testing-system-redesign-2026-09.md). The surface is:
|
|
8
|
+
* - every top-level verb, from the dispatcher's own usage line;
|
|
9
|
+
* - every subcommand, from the dispatcher's "Unknown <group> subcommand. Available: …"
|
|
10
|
+
* strings (the same parse `suggest.cjs` and the doc-command-surface lint use);
|
|
11
|
+
* - every dispatcher `case` arm (dynamic coverage only — see coverage-gate.cjs);
|
|
12
|
+
* - every installer flag literal in bin/install.js;
|
|
13
|
+
* - every hook × runtime registration, from install-lib's HOOK_EVENT_MAP;
|
|
14
|
+
* - every MCP tool and resource, from the bridge's registry;
|
|
15
|
+
* - every config default key, from buildConfigDefaults();
|
|
16
|
+
* - the shipped content directories (commands, agents, workflows), one row each —
|
|
17
|
+
* a test that iterates the directory covers every file in it.
|
|
18
|
+
*
|
|
19
|
+
* Modes:
|
|
20
|
+
* node scripts/test-surface.cjs summary
|
|
21
|
+
* node scripts/test-surface.cjs --write write tests/fixtures/surface.json (the committed registry)
|
|
22
|
+
* node scripts/test-surface.cjs --check exit 1 when the committed registry differs from the code
|
|
23
|
+
* node scripts/test-surface.cjs --map which test files reference each surface row; lists the misses
|
|
24
|
+
* node scripts/test-surface.cjs --scaffold <dir> one todo stub per unreferenced row (for a suite rebuilt from scratch)
|
|
25
|
+
*
|
|
26
|
+
* The static map here says "a test names this surface as the code names it". Whether
|
|
27
|
+
* the code actually ran is the coverage gate's job. Both are needed: a test can name a
|
|
28
|
+
* subcommand in a comment, and a subcommand can run without any test naming it.
|
|
29
|
+
*/
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
34
|
+
const REGISTRY_REL = path.join('tests', 'fixtures', 'surface.json');
|
|
35
|
+
const ALLOWLIST_REL = path.join('tests', 'fixtures', 'surface-allowlist.json');
|
|
36
|
+
|
|
37
|
+
const SOURCES = Object.freeze({
|
|
38
|
+
dispatcher: 'pan-wizard-core/bin/pan-tools.cjs',
|
|
39
|
+
installer: 'bin/install.js',
|
|
40
|
+
installLib: 'bin/install-lib.cjs',
|
|
41
|
+
suggest: 'pan-wizard-core/bin/lib/suggest.cjs',
|
|
42
|
+
config: 'pan-wizard-core/bin/lib/config.cjs',
|
|
43
|
+
mcpRegistry: 'pan-wizard-core/mcp/tool-registry.cjs',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const CONTENT_DIRS = Object.freeze({
|
|
47
|
+
'commands/pan': /\.md$/,
|
|
48
|
+
agents: /\.md$/,
|
|
49
|
+
'pan-wizard-core/workflows': /\.(md|js)$/,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Hooks the installer wires outside HOOK_EVENT_MAP (bin/install.js: the Stop guard
|
|
53
|
+
// on the two runtimes with a Stop event, the statusline on Claude Code only).
|
|
54
|
+
const EXTRA_HOOK_ROWS = Object.freeze([
|
|
55
|
+
{ runtime: 'claude', hook: 'pan-stop-guard.js', event: 'Stop', surface: 'settings.json' },
|
|
56
|
+
{ runtime: 'gemini', hook: 'pan-stop-guard.js', event: 'Stop', surface: 'settings.json' },
|
|
57
|
+
{ runtime: 'claude', hook: 'pan-statusline.js', event: 'statusLine', surface: 'settings.json' },
|
|
58
|
+
// Gemini and Copilot register a statusline too. Both were missing here until a real
|
|
59
|
+
// install was read back (2026-09-17) — the registry's whole purpose is that a shipped
|
|
60
|
+
// registration cannot sit outside it, so a hand-maintained list is the weak point and
|
|
61
|
+
// these rows are the evidence for why it must be checked against an install.
|
|
62
|
+
{ runtime: 'gemini', hook: 'pan-statusline.js', event: 'statusLine', surface: 'settings.json' },
|
|
63
|
+
// Copilot keeps its hooks in hooks/pan.json but its statusline in copilot/settings.json.
|
|
64
|
+
{ runtime: 'copilot', hook: 'pan-statusline.js', event: 'statusLine', surface: 'copilot/settings.json' },
|
|
65
|
+
]);
|
|
66
|
+
const EVENT_HOOKS = Object.freeze({
|
|
67
|
+
sessionStart: ['pan-check-update.js'],
|
|
68
|
+
postToolUse: ['pan-context-monitor.js'],
|
|
69
|
+
subagentStop: ['pan-cost-logger.js', 'pan-trace-logger.js'],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ─── Parsers (pure) ─────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function parseTopLevelCommands(src) {
|
|
75
|
+
const m = src.match(/Commands: ([^']+)'/);
|
|
76
|
+
if (!m) throw new Error('dispatcher source carries no "Commands: …" usage line');
|
|
77
|
+
return [...new Set(m[1].split(',').map((s) => s.trim()).filter(Boolean))].sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Per-group subcommands from every "Unknown <group> subcommand … Available: …" string,
|
|
82
|
+
* quoted or template literal (`state` and `links` interpolate the bad value, which
|
|
83
|
+
* suggest.cjs's index skips). Entries like "phase <N>" contribute their first token.
|
|
84
|
+
* The same parse tests/doc-command-surface.test.cjs uses.
|
|
85
|
+
*/
|
|
86
|
+
function parseGroupSubcommands(src) {
|
|
87
|
+
const groups = {};
|
|
88
|
+
for (const m of src.matchAll(/Unknown ([a-z][a-z-]*) subcommand[^`']*Available: ([^`']+)/g)) {
|
|
89
|
+
const subs = m[2].split(',').map((s) => s.trim().split(/\s+/)[0]).filter(Boolean);
|
|
90
|
+
groups[m[1]] = [...new Set([...(groups[m[1]] || []), ...subs])];
|
|
91
|
+
}
|
|
92
|
+
return groups;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `case '<label>':` arms with nesting by indentation; returns [{ label, parent, line, indent }]. */
|
|
96
|
+
function parseCaseArms(src) {
|
|
97
|
+
const arms = [];
|
|
98
|
+
src.split(/\r?\n/).forEach((text, i) => {
|
|
99
|
+
const m = /^(\s*)case\s+'([^']+)'\s*:/.exec(text);
|
|
100
|
+
if (!m) return;
|
|
101
|
+
const indent = m[1].length;
|
|
102
|
+
const parent = [...arms].reverse().find((a) => a.indent < indent);
|
|
103
|
+
arms.push({ label: m[2], parent: parent ? parent.label : null, line: i + 1, indent });
|
|
104
|
+
});
|
|
105
|
+
return arms;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseInstallerFlags(src) {
|
|
109
|
+
return [...new Set([...src.matchAll(/'(--[a-z][a-z-]*)'/g)].map((m) => m[1]))].sort();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function flattenKeys(obj, prefix = '') {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
115
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
116
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) out.push(...flattenKeys(v, key));
|
|
117
|
+
else out.push(key);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hookMatrix(hookEventMap) {
|
|
123
|
+
const rows = [];
|
|
124
|
+
for (const [runtime, spec] of Object.entries(hookEventMap || {})) {
|
|
125
|
+
if (!spec) continue; // a runtime with no hook system (OpenCode)
|
|
126
|
+
for (const [slot, hooks] of Object.entries(EVENT_HOOKS)) {
|
|
127
|
+
if (!spec[slot]) continue;
|
|
128
|
+
for (const hook of hooks) rows.push({ runtime, hook, event: spec[slot], surface: spec.surface });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Each extra row names its own surface; these registrations are not all in the file
|
|
132
|
+
// the runtime's hooks live in.
|
|
133
|
+
rows.push(...EXTRA_HOOK_ROWS.map((r) => ({ ...r })));
|
|
134
|
+
return rows.sort((a, b) => `${a.runtime}/${a.hook}`.localeCompare(`${b.runtime}/${b.hook}`));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function listContent(root, dir, re) {
|
|
138
|
+
try { return fs.readdirSync(path.join(root, dir)).filter((f) => re.test(f)).sort(); } catch { return []; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Extraction ─────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The surface, from the code. `overrides` maps a SOURCES rel path to source text
|
|
145
|
+
* (tests inject a modified dispatcher or installer); modules are always required.
|
|
146
|
+
*/
|
|
147
|
+
function extractSurface(root = ROOT, overrides = {}) {
|
|
148
|
+
const read = (rel) => (overrides[rel] != null ? overrides[rel] : fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
149
|
+
const dispatcherSrc = read(SOURCES.dispatcher);
|
|
150
|
+
const { buildSubcommandIndex } = require(path.join(root, SOURCES.suggest));
|
|
151
|
+
// Union of the dispatcher's own index and the error-string parse: the index is what
|
|
152
|
+
// `pan-tools` suggests on a typo, the parse is what the docs lint checks; a group
|
|
153
|
+
// either misses is still a surface.
|
|
154
|
+
const subIndex = buildSubcommandIndex(dispatcherSrc);
|
|
155
|
+
for (const [group, subs] of Object.entries(parseGroupSubcommands(dispatcherSrc))) {
|
|
156
|
+
subIndex[group] = [...new Set([...(subIndex[group] || []), ...subs])];
|
|
157
|
+
}
|
|
158
|
+
const { HOOK_EVENT_MAP } = require(path.join(root, SOURCES.installLib));
|
|
159
|
+
const registry = require(path.join(root, SOURCES.mcpRegistry));
|
|
160
|
+
const { buildConfigDefaults } = require(path.join(root, SOURCES.config));
|
|
161
|
+
const content = {};
|
|
162
|
+
for (const [dir, re] of Object.entries(CONTENT_DIRS)) content[dir] = listContent(root, dir, re);
|
|
163
|
+
return {
|
|
164
|
+
verbs: parseTopLevelCommands(dispatcherSrc),
|
|
165
|
+
subcommands: Object.entries(subIndex).flatMap(([v, subs]) => subs.map((s) => `${v} ${s}`)).sort(),
|
|
166
|
+
case_arms: parseCaseArms(dispatcherSrc).map((a) => (a.parent ? `${a.parent} > ${a.label}` : a.label)).sort(),
|
|
167
|
+
installer_flags: parseInstallerFlags(read(SOURCES.installer)),
|
|
168
|
+
hooks: hookMatrix(HOOK_EVENT_MAP),
|
|
169
|
+
mcp: {
|
|
170
|
+
tools: (registry.TOOLS || []).map((t) => t.name).sort(),
|
|
171
|
+
resources: (registry.RESOURCES || []).map((r) => r.uri).sort(),
|
|
172
|
+
},
|
|
173
|
+
config_keys: flattenKeys(buildConfigDefaults(false, {})).sort(),
|
|
174
|
+
content,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Rows the static map checks (case arms are dynamic-only). */
|
|
179
|
+
function surfaceRows(surface) {
|
|
180
|
+
const rows = [];
|
|
181
|
+
for (const v of surface.verbs) rows.push({ id: `verb:${v}`, kind: 'verb', verb: v });
|
|
182
|
+
for (const s of surface.subcommands) { const [verb, sub] = s.split(' '); rows.push({ id: `sub:${s}`, kind: 'sub', verb, sub }); }
|
|
183
|
+
for (const f of surface.installer_flags) rows.push({ id: `flag:${f}`, kind: 'flag', flag: f });
|
|
184
|
+
for (const h of surface.hooks) rows.push({ id: `hook:${h.runtime}/${h.hook}`, kind: 'hook', runtime: h.runtime, hook: h.hook });
|
|
185
|
+
for (const t of surface.mcp.tools) rows.push({ id: `mcp-tool:${t}`, kind: 'mcp', name: t });
|
|
186
|
+
for (const r of surface.mcp.resources) rows.push({ id: `mcp-resource:${r}`, kind: 'mcp', name: r });
|
|
187
|
+
for (const k of surface.config_keys) rows.push({ id: `config:${k}`, kind: 'config', key: k });
|
|
188
|
+
for (const dir of Object.keys(surface.content)) rows.push({ id: `content:${dir}`, kind: 'content', dir });
|
|
189
|
+
return rows;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
193
|
+
|
|
194
|
+
/** Does this test source name the row the way the code names it? */
|
|
195
|
+
function referencePattern(row) {
|
|
196
|
+
switch (row.kind) {
|
|
197
|
+
case 'verb': {
|
|
198
|
+
const v = esc(row.verb);
|
|
199
|
+
return new RegExp(`(['"\`])${v}\\1|[\`'"]${v}\\s+(?:--)?[a-z]|pan-tools(?:\\.cjs)?['"\`]?,?\\s*['"\`]?${v}\\b`);
|
|
200
|
+
}
|
|
201
|
+
case 'sub': {
|
|
202
|
+
const v = esc(row.verb), s = esc(row.sub);
|
|
203
|
+
return new RegExp(`['"\`]${v}['"\`]\\s*,\\s*['"\`]${s}['"\`]|${v}\\s+${s}\\b`);
|
|
204
|
+
}
|
|
205
|
+
case 'flag':
|
|
206
|
+
// The flag as a CLI argument: quoted on its own, or inside a longer command string.
|
|
207
|
+
return new RegExp(`(?:['"\`]|\\s)${esc(row.flag)}(?:['"\`]|\\s|=)`);
|
|
208
|
+
case 'hook': {
|
|
209
|
+
const rt = esc(row.runtime);
|
|
210
|
+
const dir = { claude: '\\.claude', codex: '\\.codex', gemini: '\\.gemini', copilot: '\\.github', opencode: '\\.opencode' }[row.runtime] || rt;
|
|
211
|
+
return { all: [new RegExp(esc(row.hook.replace(/\.js$/, ''))), new RegExp(`${dir}\\b|--${rt}\\b|['"\`]${rt}['"\`]`)] };
|
|
212
|
+
}
|
|
213
|
+
case 'mcp':
|
|
214
|
+
return new RegExp(`['"\`]${esc(row.name)}['"\`]`);
|
|
215
|
+
case 'config': {
|
|
216
|
+
const last = esc(row.key.split('.').pop());
|
|
217
|
+
return new RegExp(`['"\`]${esc(row.key)}['"\`]|\\b${last}\\s*:`);
|
|
218
|
+
}
|
|
219
|
+
case 'content': {
|
|
220
|
+
const word = row.dir.split('/')[0] === 'commands' ? 'commands' : row.dir.split('/').pop();
|
|
221
|
+
return new RegExp(`readdirSync\\([^)]*${esc(word)}`);
|
|
222
|
+
}
|
|
223
|
+
default:
|
|
224
|
+
return /$^/;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function matches(pattern, src) {
|
|
229
|
+
if (pattern instanceof RegExp) return pattern.test(src);
|
|
230
|
+
return pattern.all.every((re) => re.test(src));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function listTestFiles(root = ROOT) {
|
|
234
|
+
const out = [];
|
|
235
|
+
for (const dir of ['tests', 'tests/scenarios']) {
|
|
236
|
+
try {
|
|
237
|
+
for (const f of fs.readdirSync(path.join(root, dir))) if (f.endsWith('.test.cjs')) out.push(path.posix.join(dir, f));
|
|
238
|
+
} catch { /* no such dir */ }
|
|
239
|
+
}
|
|
240
|
+
return out.sort();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Map rows to the test files that reference them. `testSources` = [{ file, src }]. */
|
|
244
|
+
function mapSurface(rows, testSources) {
|
|
245
|
+
return rows.map((row) => {
|
|
246
|
+
const pattern = referencePattern(row);
|
|
247
|
+
const hits = testSources.filter((t) => matches(pattern, t.src)).map((t) => t.file);
|
|
248
|
+
return { ...row, hits };
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function loadTestSources(root = ROOT) {
|
|
253
|
+
return listTestFiles(root).map((file) => ({ file, src: fs.readFileSync(path.join(root, file), 'utf8') }));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Registry diff by row id (case arms compared as their own list). */
|
|
257
|
+
function diffSurface(committed, fresh) {
|
|
258
|
+
const ids = (s) => new Set([...surfaceRows(s).map((r) => r.id), ...(s.case_arms || []).map((a) => `arm:${a}`), ...Object.entries(s.content || {}).flatMap(([d, files]) => files.map((f) => `file:${d}/${f}`))]);
|
|
259
|
+
const a = ids(committed), b = ids(fresh);
|
|
260
|
+
return { added: [...b].filter((x) => !a.has(x)).sort(), removed: [...a].filter((x) => !b.has(x)).sort() };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function slug(id) { return id.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase(); }
|
|
264
|
+
|
|
265
|
+
/** One todo stub per unreferenced row. Todo stubs fail the test-quality lint until filled. */
|
|
266
|
+
function scaffold(missingRows, outDir) {
|
|
267
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
268
|
+
const written = [];
|
|
269
|
+
for (const row of missingRows) {
|
|
270
|
+
const file = path.join(outDir, `scaffold-${slug(row.id)}.test.cjs`);
|
|
271
|
+
const hint = row.kind === 'sub' ? `run \`pan-tools ${row.verb} ${row.sub}\` through runPanTools and assert exit code + parsed JSON`
|
|
272
|
+
: row.kind === 'verb' ? `run \`pan-tools ${row.verb}\` through runPanTools and assert the exit-code contract`
|
|
273
|
+
: row.kind === 'flag' ? `install with ${row.flag} into a temp dir and assert the observable effect`
|
|
274
|
+
: row.kind === 'hook' ? `spawn ${row.hook} through the ${row.runtime} install with a captured payload`
|
|
275
|
+
: row.kind === 'mcp' ? `call ${row.name} through the stdio bridge and assert the payload`
|
|
276
|
+
: row.kind === 'config' ? `set ${row.key} in .planning/config.json and assert the behaviour it governs`
|
|
277
|
+
: `iterate ${row.dir} and assert every file's contract`;
|
|
278
|
+
fs.writeFileSync(file, [
|
|
279
|
+
"const { test } = require('node:test');",
|
|
280
|
+
'',
|
|
281
|
+
`// Surface row without a test: ${row.id}`,
|
|
282
|
+
`// ${hint}`,
|
|
283
|
+
`test.todo(${JSON.stringify(`${row.id} — ${hint}`)});`,
|
|
284
|
+
'',
|
|
285
|
+
].join('\n'));
|
|
286
|
+
written.push(file);
|
|
287
|
+
}
|
|
288
|
+
return written;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function readJson(p) { return JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
292
|
+
|
|
293
|
+
// ─── CLI ────────────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
function main(argv) {
|
|
296
|
+
const surface = extractSurface(ROOT);
|
|
297
|
+
const registryPath = path.join(ROOT, REGISTRY_REL);
|
|
298
|
+
if (argv.includes('--write')) {
|
|
299
|
+
fs.writeFileSync(registryPath, JSON.stringify(surface, null, 2) + '\n');
|
|
300
|
+
console.log(`wrote ${REGISTRY_REL}`);
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
if (argv.includes('--check')) {
|
|
304
|
+
let committed;
|
|
305
|
+
try { committed = readJson(registryPath); } catch { console.error(`no committed registry at ${REGISTRY_REL} — run --write`); return 1; }
|
|
306
|
+
const d = diffSurface(committed, surface);
|
|
307
|
+
if (!d.added.length && !d.removed.length) { console.log('surface registry matches the code'); return 0; }
|
|
308
|
+
console.error(`surface registry is stale — run \`node scripts/test-surface.cjs --write\` and commit it`);
|
|
309
|
+
for (const x of d.added) console.error(` + ${x}`);
|
|
310
|
+
for (const x of d.removed) console.error(` - ${x}`);
|
|
311
|
+
return 1;
|
|
312
|
+
}
|
|
313
|
+
const rows = mapSurface(surfaceRows(surface), loadTestSources(ROOT));
|
|
314
|
+
const missing = rows.filter((r) => !r.hits.length);
|
|
315
|
+
if (argv.includes('--scaffold')) {
|
|
316
|
+
const dir = argv[argv.indexOf('--scaffold') + 1];
|
|
317
|
+
if (!dir) { console.error('--scaffold needs a directory'); return 1; }
|
|
318
|
+
const written = scaffold(missing, path.resolve(dir));
|
|
319
|
+
console.log(`${written.length} stub(s) written to ${path.resolve(dir)}`);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
if (argv.includes('--map')) {
|
|
323
|
+
for (const r of rows) console.log(`${r.hits.length ? 'ok ' : 'MISS'} ${r.id.padEnd(44)} ${r.hits.slice(0, 3).join(', ')}${r.hits.length > 3 ? ` +${r.hits.length - 3}` : ''}`);
|
|
324
|
+
}
|
|
325
|
+
console.log(`surface rows: ${rows.length} · referenced: ${rows.length - missing.length} · unreferenced: ${missing.length} · case arms (dynamic): ${surface.case_arms.length}`);
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
330
|
+
|
|
331
|
+
module.exports = {
|
|
332
|
+
ROOT, REGISTRY_REL, ALLOWLIST_REL, SOURCES, EXTRA_HOOK_ROWS,
|
|
333
|
+
parseTopLevelCommands, parseGroupSubcommands, parseCaseArms, parseInstallerFlags, flattenKeys, hookMatrix,
|
|
334
|
+
extractSurface, surfaceRows, referencePattern, mapSurface, listTestFiles, loadTestSources, diffSurface, scaffold,
|
|
335
|
+
};
|