pan-wizard 3.28.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.
@@ -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
+ };