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,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* Mutation probe — does the suite NOTICE when the code is wrong?
|
|
5
|
+
*
|
|
6
|
+
* Coverage says a line executed. It does not say an assertion depended on it. This probe
|
|
7
|
+
* answers the harder question by breaking the code on purpose: apply one small mutation,
|
|
8
|
+
* run the tests that claim to cover that file, and see whether anything turns red. A
|
|
9
|
+
* mutation the suite does not catch is a **survivor** — a line that runs during the tests
|
|
10
|
+
* and that no assertion actually constrains.
|
|
11
|
+
*
|
|
12
|
+
* Phase 6 of docs/specs/testing-system-redesign-2026-09.md. That phase proposed Stryker as
|
|
13
|
+
* a devDependency; this is the same idea without one, for a specific reason: nearly every
|
|
14
|
+
* test here asserts through a SPAWNED `pan-tools` process, so a mutation-testing framework
|
|
15
|
+
* built around in-process instrumentation would have to re-run whole subprocess suites per
|
|
16
|
+
* mutant anyway. What it would add over this file is a mutant catalogue and an HTML report,
|
|
17
|
+
* at the cost of a large dependency tree in a repo whose headline claim is zero
|
|
18
|
+
* dependencies. So: a sampled probe, dependency-free, and REPORT-ONLY.
|
|
19
|
+
*
|
|
20
|
+
* **This is not a gate and must never become one.** A survivor is a question ("should an
|
|
21
|
+
* assertion pin this?"), and some survivors are correct — equivalent mutants, defensive
|
|
22
|
+
* branches, log strings. Release-check does not run it and CI does not run it.
|
|
23
|
+
*
|
|
24
|
+
* Safety: mutations are applied inside a throwaway `git worktree`, never in your checkout.
|
|
25
|
+
* The worktree is removed at the end, including after a crash.
|
|
26
|
+
*
|
|
27
|
+
* node scripts/mutation-probe.cjs # default targets, 20 mutants
|
|
28
|
+
* node scripts/mutation-probe.cjs --max 50
|
|
29
|
+
* node scripts/mutation-probe.cjs --target hooks/pan-cost-logger.js
|
|
30
|
+
* node scripts/mutation-probe.cjs --seed 7 --json
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const os = require('os');
|
|
36
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
37
|
+
|
|
38
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What to probe, and which tests are supposed to catch a break in it. The spec names the
|
|
42
|
+
* hooks and the dispatcher first: the hooks because they are the code that writes the
|
|
43
|
+
* ledger every field number comes from, the dispatcher because its arms are the surface a
|
|
44
|
+
* user and an orchestrator hit.
|
|
45
|
+
*/
|
|
46
|
+
const TARGETS = [
|
|
47
|
+
{ file: 'hooks/pan-cost-logger.js', tests: ['tests/cost-logger-hook.test.cjs', 'tests/cost-logger.test.cjs'] },
|
|
48
|
+
{ file: 'hooks/pan-trace-logger.js', tests: ['tests/trace-logger.test.cjs'] },
|
|
49
|
+
{ file: 'pan-wizard-core/bin/lib/cost.cjs', tests: ['tests/cost.test.cjs'] },
|
|
50
|
+
{ file: 'pan-wizard-core/bin/lib/cost-rebuild.cjs', tests: ['tests/cost-rebuild.test.cjs'] },
|
|
51
|
+
{ file: 'pan-wizard-core/bin/pan-tools.cjs', tests: ['tests/dispatcher.test.cjs', 'tests/dispatcher-arms.test.cjs'] },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The mutation operators. Each is a plain textual swap with a token that must appear
|
|
56
|
+
* OUTSIDE a string or comment to be worth mutating (see mutableLine). They are deliberately
|
|
57
|
+
* few and blunt: the point is to find unconstrained logic, not to enumerate every possible
|
|
58
|
+
* defect.
|
|
59
|
+
*/
|
|
60
|
+
const OPERATORS = [
|
|
61
|
+
{ id: 'gte→gt', find: ' >= ', replace: ' > ' },
|
|
62
|
+
{ id: 'gt→gte', find: ' > ', replace: ' >= ' },
|
|
63
|
+
{ id: 'lte→lt', find: ' <= ', replace: ' < ' },
|
|
64
|
+
{ id: 'lt→lte', find: ' < ', replace: ' <= ' },
|
|
65
|
+
{ id: 'eq→neq', find: ' === ', replace: ' !== ' },
|
|
66
|
+
{ id: 'neq→eq', find: ' !== ', replace: ' === ' },
|
|
67
|
+
{ id: 'and→or', find: ' && ', replace: ' || ' },
|
|
68
|
+
{ id: 'or→and', find: ' || ', replace: ' && ' },
|
|
69
|
+
{ id: 'true→false', find: 'return true', replace: 'return false' },
|
|
70
|
+
{ id: 'false→true', find: 'return false', replace: 'return true' },
|
|
71
|
+
{ id: 'plus→minus', find: ' + 1', replace: ' - 1' },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Is this line worth mutating? Skips comments, and skips a line whose only occurrence of
|
|
76
|
+
* the token is inside a string literal — mutating a message changes nothing a test should
|
|
77
|
+
* be pinning, and a survivor there would be noise.
|
|
78
|
+
*
|
|
79
|
+
* Crude but conservative: a line containing a quote is only mutated when the token also
|
|
80
|
+
* occurs before the first quote.
|
|
81
|
+
*/
|
|
82
|
+
function mutableLine(line, token) {
|
|
83
|
+
const trimmed = line.trim();
|
|
84
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return false;
|
|
85
|
+
const at = line.indexOf(token);
|
|
86
|
+
if (at === -1) return false;
|
|
87
|
+
const firstQuote = Math.min(
|
|
88
|
+
...["'", '"', '`'].map((q) => { const i = line.indexOf(q); return i === -1 ? Infinity : i; }),
|
|
89
|
+
);
|
|
90
|
+
return at < firstQuote;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every mutation available in a source file: one per (line, operator) pair, first
|
|
95
|
+
* occurrence on the line.
|
|
96
|
+
*
|
|
97
|
+
* @returns {Array<{line: number, op: string, before: string, after: string}>}
|
|
98
|
+
*/
|
|
99
|
+
function mutationsFor(src) {
|
|
100
|
+
const lines = src.split(/\r?\n/);
|
|
101
|
+
const out = [];
|
|
102
|
+
lines.forEach((line, i) => {
|
|
103
|
+
for (const op of OPERATORS) {
|
|
104
|
+
if (!mutableLine(line, op.find)) continue;
|
|
105
|
+
out.push({ line: i + 1, op: op.id, before: line, after: line.replace(op.find, op.replace) });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Apply one mutation to a source string, by line number. Throws if the line moved. */
|
|
112
|
+
function applyMutation(src, mutation) {
|
|
113
|
+
const eol = src.includes('\r\n') ? '\r\n' : '\n';
|
|
114
|
+
const lines = src.split(/\r?\n/);
|
|
115
|
+
const idx = mutation.line - 1;
|
|
116
|
+
if (lines[idx] !== mutation.before) {
|
|
117
|
+
throw new Error(`line ${mutation.line} is not what the mutation was built from`);
|
|
118
|
+
}
|
|
119
|
+
lines[idx] = mutation.after;
|
|
120
|
+
return lines.join(eol);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Deterministic shuffle, so a run is reproducible from its seed. */
|
|
124
|
+
function sample(items, n, seed) {
|
|
125
|
+
let s = seed >>> 0;
|
|
126
|
+
const rand = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 0x100000000; };
|
|
127
|
+
const copy = items.slice();
|
|
128
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
129
|
+
const j = Math.floor(rand() * (i + 1));
|
|
130
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
131
|
+
}
|
|
132
|
+
return copy.slice(0, n);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseArgs(argv) {
|
|
136
|
+
const a = { max: 20, seed: 1, json: false, targets: [], timeoutMs: 600000, help: false };
|
|
137
|
+
for (let i = 0; i < argv.length; i++) {
|
|
138
|
+
const k = argv[i]; const v = argv[i + 1];
|
|
139
|
+
if (k === '--max') { a.max = Math.max(1, Number(v) || 1); i++; }
|
|
140
|
+
else if (k === '--seed') { a.seed = Number(v) || 1; i++; }
|
|
141
|
+
else if (k === '--target') { a.targets.push(v); i++; }
|
|
142
|
+
else if (k === '--timeout') { a.timeoutMs = Math.max(30000, Number(v) || 30000); i++; }
|
|
143
|
+
else if (k === '--json') a.json = true;
|
|
144
|
+
else if (k === '--help' || k === '-h') a.help = true;
|
|
145
|
+
else throw new Error(`unknown argument: ${k}`);
|
|
146
|
+
}
|
|
147
|
+
return a;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Run one target's tests inside `cwd`. A mutation is CAUGHT when they fail.
|
|
152
|
+
* A test run that cannot start at all counts as caught too — the code is broken enough
|
|
153
|
+
* that nothing ran, which is not a survivor.
|
|
154
|
+
*/
|
|
155
|
+
function testsFail(cwd, tests, timeoutMs) {
|
|
156
|
+
const r = spawnSync(process.execPath, ['--test', ...tests], {
|
|
157
|
+
cwd, encoding: 'utf-8', timeout: timeoutMs, stdio: ['ignore', 'pipe', 'pipe'],
|
|
158
|
+
});
|
|
159
|
+
if (r.error && r.error.code === 'ETIMEDOUT') return true; // an infinite loop is a detection
|
|
160
|
+
return r.status !== 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function main() {
|
|
164
|
+
const args = parseArgs(process.argv.slice(2));
|
|
165
|
+
if (args.help) {
|
|
166
|
+
process.stdout.write('node scripts/mutation-probe.cjs [--max n] [--seed n] [--target <file>]... [--timeout ms] [--json]\n');
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const targets = args.targets.length
|
|
171
|
+
? TARGETS.filter((t) => args.targets.some((want) => t.file.endsWith(want.replace(/\\/g, '/'))))
|
|
172
|
+
: TARGETS;
|
|
173
|
+
if (!targets.length) {
|
|
174
|
+
process.stderr.write(`no known target matched ${args.targets.join(', ')}\nknown: ${TARGETS.map((t) => t.file).join(', ')}\n`);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build the candidate list from the real sources, then sample across all targets so one
|
|
179
|
+
// large file cannot crowd the others out.
|
|
180
|
+
const candidates = [];
|
|
181
|
+
for (const t of targets) {
|
|
182
|
+
const src = fs.readFileSync(path.join(ROOT, t.file), 'utf-8');
|
|
183
|
+
for (const m of mutationsFor(src)) candidates.push({ ...m, ...t });
|
|
184
|
+
}
|
|
185
|
+
const chosen = sample(candidates, args.max, args.seed);
|
|
186
|
+
|
|
187
|
+
// Everything happens in a throwaway worktree: the probe never edits your checkout.
|
|
188
|
+
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-mutation-'));
|
|
189
|
+
let created = false;
|
|
190
|
+
const survivors = [];
|
|
191
|
+
let caught = 0;
|
|
192
|
+
try {
|
|
193
|
+
execFileSync('git', ['worktree', 'add', '--detach', wt, 'HEAD'], { cwd: ROOT, stdio: 'ignore' });
|
|
194
|
+
created = true;
|
|
195
|
+
// The hooks are copied to hooks/dist by the build; tests that spawn the built copy
|
|
196
|
+
// need it present in the worktree.
|
|
197
|
+
spawnSync(process.execPath, ['scripts/build-hooks.js'], { cwd: wt, stdio: 'ignore' });
|
|
198
|
+
|
|
199
|
+
let i = 0;
|
|
200
|
+
for (const m of chosen) {
|
|
201
|
+
i += 1;
|
|
202
|
+
const file = path.join(wt, m.file);
|
|
203
|
+
const original = fs.readFileSync(file, 'utf-8');
|
|
204
|
+
let mutated;
|
|
205
|
+
try {
|
|
206
|
+
mutated = applyMutation(original, m);
|
|
207
|
+
} catch {
|
|
208
|
+
continue; // the worktree's copy differs from the working tree — skip, do not guess
|
|
209
|
+
}
|
|
210
|
+
if (mutated === original) continue;
|
|
211
|
+
try {
|
|
212
|
+
fs.writeFileSync(file, mutated);
|
|
213
|
+
// A hook mutation must reach the built copy the tests spawn.
|
|
214
|
+
if (m.file.startsWith('hooks/')) spawnSync(process.execPath, ['scripts/build-hooks.js'], { cwd: wt, stdio: 'ignore' });
|
|
215
|
+
const detected = testsFail(wt, m.tests, args.timeoutMs);
|
|
216
|
+
if (detected) caught += 1;
|
|
217
|
+
else survivors.push(m);
|
|
218
|
+
if (!args.json) {
|
|
219
|
+
process.stdout.write(` [${String(i).padStart(2)}/${chosen.length}] ${detected ? 'caught ' : 'SURVIVED'} ${m.file}:${m.line} ${m.op}\n`);
|
|
220
|
+
}
|
|
221
|
+
} finally {
|
|
222
|
+
fs.writeFileSync(file, original);
|
|
223
|
+
if (m.file.startsWith('hooks/')) spawnSync(process.execPath, ['scripts/build-hooks.js'], { cwd: wt, stdio: 'ignore' });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} finally {
|
|
227
|
+
if (created) {
|
|
228
|
+
try { execFileSync('git', ['worktree', 'remove', '--force', wt], { cwd: ROOT, stdio: 'ignore' }); } catch { /* fall through */ }
|
|
229
|
+
}
|
|
230
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
231
|
+
try { execFileSync('git', ['worktree', 'prune'], { cwd: ROOT, stdio: 'ignore' }); } catch { /* best effort */ }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const ran = caught + survivors.length;
|
|
235
|
+
const report = {
|
|
236
|
+
candidates: candidates.length,
|
|
237
|
+
sampled: chosen.length,
|
|
238
|
+
ran,
|
|
239
|
+
caught,
|
|
240
|
+
survived: survivors.length,
|
|
241
|
+
score: ran ? Number(((caught / ran) * 100).toFixed(1)) : null,
|
|
242
|
+
seed: args.seed,
|
|
243
|
+
survivors: survivors.map((s) => ({ file: s.file, line: s.line, op: s.op, code: s.before.trim().slice(0, 120) })),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
if (args.json) {
|
|
247
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
process.stdout.write(`\nmutation probe — REPORT ONLY, never a gate\n`);
|
|
252
|
+
process.stdout.write(` ${report.caught} caught / ${report.ran} run (${report.score === null ? 'n/a' : report.score + '%'}), from ${report.candidates} candidates, seed ${report.seed}\n`);
|
|
253
|
+
if (survivors.length) {
|
|
254
|
+
process.stdout.write(`\n survivors — the suite ran this line and no assertion constrained it:\n`);
|
|
255
|
+
for (const s of report.survivors) process.stdout.write(` ${s.file}:${s.line} ${s.op}\n ${s.code}\n`);
|
|
256
|
+
process.stdout.write('\n Each is a question, not a verdict: some are equivalent mutants or defensive\n branches where no assertion is owed. Pin the ones that describe real behaviour.\n');
|
|
257
|
+
} else {
|
|
258
|
+
process.stdout.write('\n no survivors in this sample\n');
|
|
259
|
+
}
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = { mutationsFor, applyMutation, mutableLine, sample, OPERATORS, TARGETS };
|
|
264
|
+
|
|
265
|
+
if (require.main === module) {
|
|
266
|
+
try {
|
|
267
|
+
process.exit(main());
|
|
268
|
+
} catch (e) {
|
|
269
|
+
process.stderr.write(`mutation probe failed: ${e.message}\n`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
}
|
package/scripts/release-check.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* release-check.js — Pre-publish validation gate.
|
|
4
4
|
*
|
|
5
5
|
* Wired into `prepublishOnly` so `npm publish` fails BEFORE upload if any
|
|
6
|
-
* gate is red. Runs
|
|
6
|
+
* gate is red. Runs nine checks in order; first failure aborts.
|
|
7
7
|
*
|
|
8
8
|
* 1. build:hooks — hook scripts copy/build cleanly
|
|
9
9
|
* 2. test:all — full test suite (unit + scenario) passes
|
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
* (we have zero runtime deps, but the dev-deps are checked)
|
|
12
12
|
* 4. doc-lint counts — no drift-prone count violations in user-facing docs
|
|
13
13
|
* 5. links validate — doc↔code link graph resolves (no broken references)
|
|
14
|
-
* 6. npm pack dry-run — package builds; size is sane
|
|
14
|
+
* 6. npm pack dry-run — package builds; size is sane; zero runtime dependencies
|
|
15
15
|
* 7. smoke install — npm pack + install into temp dir + run pan-tools list
|
|
16
16
|
* catches "ships but doesn't actually work" failures
|
|
17
|
+
* 8. bundles — both plugin builders build; dist/pan-agent-plugin is fresh
|
|
18
|
+
* 9. coverage gate — the suite under Node's coverage: every dispatcher arm
|
|
19
|
+
* executed, line/function floors per module group
|
|
20
|
+
* (scripts/coverage-gate.cjs; skipped on Node < 22)
|
|
17
21
|
*
|
|
18
22
|
* Usage:
|
|
19
23
|
* node scripts/release-check.js # all gates
|
|
@@ -57,7 +61,7 @@ function run(cmd, args, opts = {}) {
|
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
// Gate 1: build:hooks
|
|
60
|
-
process.stderr.write('\n[release-check] Gate 1/
|
|
64
|
+
process.stderr.write('\n[release-check] Gate 1/9: build:hooks\n');
|
|
61
65
|
{
|
|
62
66
|
const r = run('npm', ['run', 'build:hooks']);
|
|
63
67
|
logGate('build:hooks', r.status === 0, r.status !== 0 ? `exit ${r.status}` : '');
|
|
@@ -65,7 +69,7 @@ process.stderr.write('\n[release-check] Gate 1/7: build:hooks\n');
|
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
// Gate 2: test:all
|
|
68
|
-
process.stderr.write('\n[release-check] Gate 2/
|
|
72
|
+
process.stderr.write('\n[release-check] Gate 2/9: test:all\n');
|
|
69
73
|
{
|
|
70
74
|
const r = run('npm', ['run', 'test:all']);
|
|
71
75
|
logGate('test:all', r.status === 0, r.status !== 0 ? `exit ${r.status}` : '');
|
|
@@ -74,9 +78,9 @@ process.stderr.write('\n[release-check] Gate 2/7: test:all\n');
|
|
|
74
78
|
|
|
75
79
|
// Gate 3: npm audit (production deps only)
|
|
76
80
|
if (SKIP_AUDIT) {
|
|
77
|
-
process.stderr.write('\n[release-check] Gate 3/
|
|
81
|
+
process.stderr.write('\n[release-check] Gate 3/9: npm audit (SKIPPED)\n');
|
|
78
82
|
} else {
|
|
79
|
-
process.stderr.write('\n[release-check] Gate 3/
|
|
83
|
+
process.stderr.write('\n[release-check] Gate 3/9: npm audit --omit=dev\n');
|
|
80
84
|
const r = run('npm', ['audit', '--omit=dev', '--audit-level=high'], { capture: true });
|
|
81
85
|
// npm audit exits non-zero on findings. We tolerate moderate; fail on high+.
|
|
82
86
|
const ok = r.status === 0;
|
|
@@ -88,7 +92,7 @@ if (SKIP_AUDIT) {
|
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
// Gate 4: doc-lint counts on user-facing docs (count-SSoT enforcement)
|
|
91
|
-
process.stderr.write('\n[release-check] Gate 4/
|
|
95
|
+
process.stderr.write('\n[release-check] Gate 4/9: doc-lint counts docs/\n');
|
|
92
96
|
{
|
|
93
97
|
const tools = path.join(REPO_ROOT, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
|
|
94
98
|
const docsDir = path.join(REPO_ROOT, 'docs');
|
|
@@ -103,7 +107,7 @@ process.stderr.write('\n[release-check] Gate 4/7: doc-lint counts docs/\n');
|
|
|
103
107
|
|
|
104
108
|
// Gate 5: doc↔code link graph resolves (anti-fake — a doc cannot reference a
|
|
105
109
|
// code anchor that doesn't exist; deterministic, self-enforcing exit 1).
|
|
106
|
-
process.stderr.write('\n[release-check] Gate 5/
|
|
110
|
+
process.stderr.write('\n[release-check] Gate 5/9: links validate\n');
|
|
107
111
|
{
|
|
108
112
|
const tools = path.join(REPO_ROOT, 'pan-wizard-core', 'bin', 'pan-tools.cjs');
|
|
109
113
|
const r = run('node', [tools, 'links', 'validate', '--raw'], { capture: true });
|
|
@@ -125,16 +129,21 @@ process.stderr.write('\n[release-check] Gate 5/7: links validate\n');
|
|
|
125
129
|
|
|
126
130
|
// Gate 6: npm pack — produces a non-empty, sanely-sized tarball (read the file,
|
|
127
131
|
// never parse stdout)
|
|
128
|
-
process.stderr.write('\n[release-check] Gate 6/
|
|
132
|
+
process.stderr.write('\n[release-check] Gate 6/9: npm pack (size sanity)\n');
|
|
129
133
|
{
|
|
130
134
|
const tmp6 = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-release-pack-'));
|
|
131
135
|
const r = run('npm', ['pack', '--pack-destination', tmp6], { capture: true });
|
|
132
136
|
const tgz = r.status === 0 ? fs.readdirSync(tmp6).find(f => f.endsWith('.tgz')) : null;
|
|
133
137
|
const size = tgz ? fs.statSync(path.join(tmp6, tgz)).size : 0;
|
|
134
138
|
const sizeMB = (size / 1024 / 1024).toFixed(2);
|
|
139
|
+
// R8: "zero runtime dependencies" is a headline claim (README, COMPARISON.md); a
|
|
140
|
+
// dependency added by accident must turn the release red before it ships. The unit
|
|
141
|
+
// pin is tests/package-contract.test.cjs; this is the publish-time backstop.
|
|
142
|
+
const deps = Object.keys(require(path.join(REPO_ROOT, 'package.json')).dependencies || {});
|
|
143
|
+
const zeroDeps = deps.length === 0;
|
|
135
144
|
// Sane = a non-empty tarball under 50MB (large for a zero-runtime-dep tool)
|
|
136
|
-
const ok = r.status === 0 && !!tgz && size > 0 && size < 50 * 1024 * 1024;
|
|
137
|
-
logGate('npm pack', ok, tgz ? `${sizeMB}MB tarball` : `no tarball (exit ${r.status})`);
|
|
145
|
+
const ok = r.status === 0 && !!tgz && size > 0 && size < 50 * 1024 * 1024 && zeroDeps;
|
|
146
|
+
logGate('npm pack', ok, (tgz ? `${sizeMB}MB tarball` : `no tarball (exit ${r.status})`) + (zeroDeps ? '' : `; runtime dependencies present: ${deps.join(', ')}`));
|
|
138
147
|
fs.rmSync(tmp6, { recursive: true, force: true });
|
|
139
148
|
if (!ok) {
|
|
140
149
|
process.stderr.write((r.stderr || '') + '\n');
|
|
@@ -144,9 +153,9 @@ process.stderr.write('\n[release-check] Gate 6/7: npm pack (size sanity)\n');
|
|
|
144
153
|
|
|
145
154
|
// Gate 7: smoke install — pack and install into temp dir, run pan-tools
|
|
146
155
|
if (SKIP_SMOKE) {
|
|
147
|
-
process.stderr.write('\n[release-check] Gate 7/
|
|
156
|
+
process.stderr.write('\n[release-check] Gate 7/9: smoke install (SKIPPED)\n');
|
|
148
157
|
} else {
|
|
149
|
-
process.stderr.write('\n[release-check] Gate 7/
|
|
158
|
+
process.stderr.write('\n[release-check] Gate 7/9: smoke install (npm pack + install + sanity)\n');
|
|
150
159
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-release-smoke-'));
|
|
151
160
|
try {
|
|
152
161
|
// Pack — read the .tgz npm writes to tmpDir; never parse its stdout (see note).
|
|
@@ -192,6 +201,64 @@ if (SKIP_SMOKE) {
|
|
|
192
201
|
}
|
|
193
202
|
}
|
|
194
203
|
|
|
204
|
+
// Gate 8: distribution bundles — both plugin builders produce a manifest. Built into
|
|
205
|
+
// temp dirs (never dist/) so the gate cannot race a concurrently running test and
|
|
206
|
+
// leaves the checkout untouched. A bundle that fails to build is a release that
|
|
207
|
+
// ships a broken marketplace entry.
|
|
208
|
+
process.stderr.write('\n[release-check] Gate 8/9: distribution bundles (build:plugin + build:agent-plugin)\n');
|
|
209
|
+
{
|
|
210
|
+
const tmp8 = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-release-bundles-'));
|
|
211
|
+
try {
|
|
212
|
+
const claudeOut = path.join(tmp8, 'claude');
|
|
213
|
+
const agentOut = path.join(tmp8, 'agent');
|
|
214
|
+
const a = run('node', [path.join(REPO_ROOT, 'scripts', 'build-plugin.js')], { capture: true, env: { ...process.env, PAN_PLUGIN_OUT: claudeOut } });
|
|
215
|
+
const b = run('node', [path.join(REPO_ROOT, 'scripts', 'build-agent-plugin.js')], { capture: true, env: { ...process.env, PAN_AGENT_PLUGIN_OUT: agentOut } });
|
|
216
|
+
const okA = a.status === 0 && fs.existsSync(path.join(claudeOut, '.claude-plugin', 'plugin.json'));
|
|
217
|
+
const okB = b.status === 0 && fs.existsSync(path.join(agentOut, 'plugin.json')) && fs.existsSync(path.join(agentOut, 'mcp.json'));
|
|
218
|
+
// R10: .agents/plugins/marketplace.json (Codex) and .github/plugin/marketplace.json
|
|
219
|
+
// (Copilot) resolve to ./dist/pan-agent-plugin with no rebuild-on-resolve — unlike
|
|
220
|
+
// the Claude `command` source. A stale dist/ shipped silently on 2026-09-10 (built
|
|
221
|
+
// before the vendor-directory commit). Compare it with the fresh build when it
|
|
222
|
+
// exists; the gate stays read-only and never writes dist/.
|
|
223
|
+
let staleDetail = '';
|
|
224
|
+
const distAgent = path.join(REPO_ROOT, 'dist', 'pan-agent-plugin');
|
|
225
|
+
if (okB && fs.existsSync(distAgent)) {
|
|
226
|
+
const { dirDigest } = require(path.join(REPO_ROOT, 'bin', 'install-lib.cjs'));
|
|
227
|
+
if (dirDigest(distAgent) !== dirDigest(agentOut)) {
|
|
228
|
+
staleDetail = 'dist/pan-agent-plugin is STALE — run `npm run build:agent-plugin` (the Codex and Copilot marketplaces install from it)';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const ok8 = okA && okB && !staleDetail;
|
|
232
|
+
const detail = ok8
|
|
233
|
+
? 'Claude plugin + Agent Plugins bundle built' + (fs.existsSync(distAgent) ? '; dist/pan-agent-plugin matches the fresh build' : '')
|
|
234
|
+
: staleDetail || `claude:${okA ? 'ok' : 'FAIL exit ' + a.status} agent-plugins:${okB ? 'ok' : 'FAIL exit ' + b.status}`;
|
|
235
|
+
logGate('distribution bundles', ok8, detail);
|
|
236
|
+
if (!ok8) {
|
|
237
|
+
process.stderr.write((a.stderr || '') + (b.stderr || '') + '\n');
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
try { fs.rmSync(tmp8, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Gate 9: coverage gate — the whole suite once more, under Node's own coverage
|
|
246
|
+
// instrumentation, then: every dispatcher case arm executed (or allowlisted with a
|
|
247
|
+
// reason in tests/fixtures/coverage-policy.json) and line/function floors per module
|
|
248
|
+
// group. Gate 2 says the tests pass; this gate says the shipped code ran. On Node
|
|
249
|
+
// below 22 the script reports "skipped" and exits 0 — the CI Node-22 job carries it.
|
|
250
|
+
process.stderr.write('\n[release-check] Gate 9/9: coverage gate (dispatcher arms + coverage floors)\n');
|
|
251
|
+
{
|
|
252
|
+
const r = run('node', [path.join(REPO_ROOT, 'scripts', 'coverage-gate.cjs')], { capture: true });
|
|
253
|
+
const text = ((r.stdout || '') + (r.stderr || '')).trim();
|
|
254
|
+
const first = text.split('\n')[0] || '';
|
|
255
|
+
logGate('coverage gate', r.status === 0, first.replace(/^coverage gate — /, ''));
|
|
256
|
+
if (r.status !== 0) {
|
|
257
|
+
process.stderr.write(text + '\n');
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
195
262
|
// Summary
|
|
196
263
|
process.stderr.write('\n[release-check] Summary:\n');
|
|
197
264
|
for (const c of checks) {
|