pan-wizard 3.28.0 → 3.30.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 +39 -39
- package/bin/install-lib.cjs +65 -58
- package/bin/install.js +199 -188
- package/commands/pan/army.md +2 -2
- package/commands/pan/audit-deployment.md +2 -2
- package/commands/pan/cost.md +19 -7
- package/commands/pan/exec-phase.md +2 -0
- package/commands/pan/focus-auto.md +5 -5
- package/hooks/dist/pan-check-update.js +4 -0
- package/hooks/dist/pan-cost-logger.js +322 -43
- package/hooks/dist/pan-stop-guard.js +81 -2
- package/hooks/dist/pan-trace-logger.js +275 -32
- package/package.json +4 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +3 -2
- package/pan-wizard-core/bin/lib/commands.cjs +3 -1
- package/pan-wizard-core/bin/lib/constants.cjs +17 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +10 -0
- package/pan-wizard-core/bin/lib/core.cjs +17 -4
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +511 -0
- package/pan-wizard-core/bin/lib/cost.cjs +165 -55
- 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 +22 -25
- package/pan-wizard-core/bin/lib/memory-rebuild.cjs +3 -3
- 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-deploy.cjs +1 -1
- package/pan-wizard-core/bin/lib/verify.cjs +24 -10
- package/pan-wizard-core/bin/pan-tools.cjs +8 -1
- package/pan-wizard-core/references/model-profiles.md +4 -4
- package/pan-wizard-core/references/planning-config.md +19 -23
- package/pan-wizard-core/workflows/health.md +1 -0
- package/pan-wizard-core/workflows/settings.md +2 -4
- package/scripts/coverage-gate.cjs +257 -0
- package/scripts/install-git-hooks.js +5 -0
- package/scripts/mutation-probe.cjs +272 -0
- package/scripts/release-check.js +33 -12
- package/scripts/test-quality-lint.cjs +240 -0
- package/scripts/test-surface.cjs +336 -0
|
@@ -182,10 +182,32 @@ function hasBraveSearchKey() {
|
|
|
182
182
|
return fileAccessible(path.join(os.homedir(), '.pan-wizard', 'brave_api_key'));
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Which workflow model a planning tree runs (PLANNING_MODEL_MARKERS). The phase model
|
|
187
|
+
* wins when its markers are present, since a phase project may also hold focus
|
|
188
|
+
* artifacts; `fragment` means entries exist but none of them mark a deliberate
|
|
189
|
+
* workflow, and `absent` that the directory could not be read.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} planningDir - absolute path to the tree (e.g. planningPath(cwd))
|
|
192
|
+
* @returns {{model: 'phase'|'focus'|'campaign'|'fragment'|'empty'|'absent', evidence: string[], entries: number}}
|
|
193
|
+
*/
|
|
194
|
+
function detectPlanningModel(planningDir) {
|
|
195
|
+
const { PLANNING_MODEL_MARKERS } = require('./constants.cjs');
|
|
196
|
+
let entries;
|
|
197
|
+
try { entries = fs.readdirSync(planningDir); } catch { return { model: 'absent', evidence: [], entries: 0 }; }
|
|
198
|
+
const lower = new Set(entries.map(e => String(e).toLowerCase()));
|
|
199
|
+
for (const model of ['phase', 'focus', 'campaign']) {
|
|
200
|
+
const evidence = PLANNING_MODEL_MARKERS[model].filter(m => lower.has(m));
|
|
201
|
+
if (evidence.length) return { model, evidence, entries: entries.length };
|
|
202
|
+
}
|
|
203
|
+
return { model: entries.length ? 'fragment' : 'empty', evidence: [], entries: entries.length };
|
|
204
|
+
}
|
|
205
|
+
|
|
185
206
|
module.exports = {
|
|
186
207
|
readJsonFile,
|
|
187
208
|
removeQuotes,
|
|
188
209
|
planningPath,
|
|
210
|
+
detectPlanningModel,
|
|
189
211
|
planningRel,
|
|
190
212
|
phasesPath,
|
|
191
213
|
milestonesPath,
|
|
@@ -107,7 +107,7 @@ function validateRuntimeInstall(cwd, configDir, runtime) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
-
// Copilot
|
|
110
|
+
// statusLine (Claude, Copilot; PAN writes none for Gemini since 2026-09-23)
|
|
111
111
|
if (settings.statusLine && settings.statusLine.command) {
|
|
112
112
|
hookCommands.push(settings.statusLine.command);
|
|
113
113
|
}
|
|
@@ -14,7 +14,7 @@ const {
|
|
|
14
14
|
PLAN_SUFFIX, SUMMARY_SUFFIX, STANDARDS_FILE, STANDARDS_CATALOG, HEALTH_STATUS,
|
|
15
15
|
BUILTIN_DRIFT_RULES, DRIFT_VERDICTS, BINARY_EXTENSIONS, DRIFT_MAX_FILES, DRIFT_MAX_FILE_SIZE, DRIFT_SEVERITY_WEIGHTS,
|
|
16
16
|
} = require('./constants.cjs');
|
|
17
|
-
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible } = require('./utils.cjs');
|
|
17
|
+
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible, detectPlanningModel } = require('./utils.cjs');
|
|
18
18
|
const { detectForeignPlanningTree } = require('./foreign-planning.cjs');
|
|
19
19
|
// Drift detection lives in verify-drift.cjs; re-exported below so consumers of
|
|
20
20
|
// verify.cjs are unaffected by the decomposition.
|
|
@@ -1320,19 +1320,33 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1320
1320
|
return;
|
|
1321
1321
|
}
|
|
1322
1322
|
|
|
1323
|
+
// Check 1c: which workflow model is this tree running? Checks 2-8b below are the
|
|
1324
|
+
// PHASE model's — a focus-model project (`/pan:focus`, no project/roadmap/state by
|
|
1325
|
+
// design) and an orchestration campaign would each fail all of them and be called
|
|
1326
|
+
// broken, which is what eight of fourteen field projects hit (sweep 2026-09-17).
|
|
1327
|
+
// config.json is the one check every model shares.
|
|
1328
|
+
const shape = detectPlanningModel(planningPath(cwd));
|
|
1329
|
+
const phaseModel = shape.model === 'phase' || shape.model === 'fragment' || shape.model === 'empty';
|
|
1330
|
+
|
|
1323
1331
|
// Checks 2-8: individual structure and consistency checks
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1332
|
+
if (phaseModel) {
|
|
1333
|
+
checkProjectFile(cwd, addIssue);
|
|
1334
|
+
checkRoadmapFile(cwd, addIssue);
|
|
1335
|
+
checkStateFile(cwd, addIssue, repairs);
|
|
1336
|
+
} else {
|
|
1337
|
+
addIssue('info', 'I003', `${shape.model}-model project (${shape.evidence.join(', ')}) — the phase-model checks (project.md, roadmap.md, state.md, phases/) do not apply`, null);
|
|
1338
|
+
}
|
|
1327
1339
|
checkConfigFile(cwd, addIssue, repairs);
|
|
1328
|
-
|
|
1329
|
-
|
|
1340
|
+
if (phaseModel) {
|
|
1341
|
+
checkPhaseDirectories(cwd, addIssue);
|
|
1342
|
+
checkPhaseContents(cwd, addIssue);
|
|
1330
1343
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1344
|
+
// Check 8b: cross-document state consistency
|
|
1345
|
+
checkStateConsistency(cwd, addIssue, repairs);
|
|
1333
1346
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1347
|
+
// Check 8c: verification gate (phases with verifier enabled need verification.md)
|
|
1348
|
+
checkVerificationGate(cwd, addIssue);
|
|
1349
|
+
}
|
|
1336
1350
|
|
|
1337
1351
|
// Check 9 (optional): standards compliance
|
|
1338
1352
|
if (options.standards) {
|
|
@@ -203,6 +203,7 @@ const codebase = require('./lib/codebase.cjs');
|
|
|
203
203
|
const memory = require('./lib/memory.cjs');
|
|
204
204
|
const bus = require('./lib/bus.cjs');
|
|
205
205
|
const cost = require('./lib/cost.cjs');
|
|
206
|
+
const costRebuild = require('./lib/cost-rebuild.cjs');
|
|
206
207
|
const preview = require('./lib/preview.cjs');
|
|
207
208
|
const reviewDeep = require('./lib/review-deep.cjs');
|
|
208
209
|
const knowledge = require('./lib/knowledge.cjs');
|
|
@@ -1207,8 +1208,14 @@ async function main() {
|
|
|
1207
1208
|
cost.cmdCostAppend(cwd, rec, raw);
|
|
1208
1209
|
} else if (subcommand === 'clear') {
|
|
1209
1210
|
cost.cmdCostClear(cwd, raw);
|
|
1211
|
+
} else if (subcommand === 'rebuild') {
|
|
1212
|
+
costRebuild.cmdCostRebuild(cwd, {
|
|
1213
|
+
apply: args.includes('--apply'),
|
|
1214
|
+
mainThread: !args.includes('--no-main-thread'),
|
|
1215
|
+
claudeDir: getArgValue(args, '--claude-dir'),
|
|
1216
|
+
}, raw);
|
|
1210
1217
|
} else {
|
|
1211
|
-
error('Unknown cost subcommand. Available: report, append, clear');
|
|
1218
|
+
error('Unknown cost subcommand. Available: report, append, clear, rebuild');
|
|
1212
1219
|
}
|
|
1213
1220
|
break;
|
|
1214
1221
|
}
|
|
@@ -11,8 +11,8 @@ PAN uses three abstract tiers instead of hardcoded model names:
|
|
|
11
11
|
| Tier | Purpose | Anthropic | OpenAI | Google |
|
|
12
12
|
|------|---------|-----------|--------|--------|
|
|
13
13
|
| `reasoning` | Architecture, planning, complex decisions | inherit (your session's top-tier model) | inherit | inherit |
|
|
14
|
-
| `mid` | Execution, research, verification | Sonnet |
|
|
15
|
-
| `fast` | Read-only extraction, budget tasks | Haiku |
|
|
14
|
+
| `mid` | Execution, research, verification | Sonnet | gpt-6-sol | gemini-3.8-flash |
|
|
15
|
+
| `fast` | Read-only extraction, budget tasks | Haiku | gpt-6-luna | gemini-3.5-flash-lite |
|
|
16
16
|
|
|
17
17
|
**Why `inherit` for reasoning?** Host runtimes map "opus" to a specific model version. PAN returns `inherit` for reasoning-tier agents, so they use whatever top-tier model the user has configured. This avoids version conflicts and silent fallbacks.
|
|
18
18
|
|
|
@@ -39,7 +39,7 @@ At install time PAN also runs a **best-effort, advisory** capability check on th
|
|
|
39
39
|
| Class | Example model IDs | Role in PAN | Context | Relative cost | Notes |
|
|
40
40
|
|-------|-------------------|-------------|---------|---------------|-------|
|
|
41
41
|
| Fable / Mythos | `claude-fable-5` | **Recommended flagship** — deepest long-horizon reasoning; best for the bot army's Mission Control + planning | 1M | ~2× Opus | Runs input safety classifiers (see caveat below); requires 30-day data retention |
|
|
42
|
-
| Opus | `claude-opus-5`, `claude-opus-4-8` | **Cost-conscious pick** — same 1M context + thinking, about half the cost, no cyber classifier | 1M | 1× | The safe pick when you want Opus behavior without Fable's refusal surface |
|
|
42
|
+
| Opus | `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8` | **Cost-conscious pick** — same 1M context + thinking, about half the cost, no cyber classifier | 1M | 1× | The safe pick when you want Opus behavior without Fable's refusal surface |
|
|
43
43
|
|
|
44
44
|
**Why the Fable class is the recommended flagship.** It is Anthropic's deepest class for demanding, long-horizon agentic work — exactly what PAN's hierarchical bot army (Mission Control → squads → workers) asks of its reasoning tier. Select the current release in that class in your host runtime and `inherit` routes the reasoning-tier agents to it automatically.
|
|
45
45
|
|
|
@@ -117,7 +117,7 @@ PAN auto-detects the LLM provider to map tiers to the right model names:
|
|
|
117
117
|
|
|
118
118
|
1. **Explicit config** — `routing.provider` in config.json (if not `"auto"`)
|
|
119
119
|
2. **Environment variable** — `PAN_PROVIDER` env var
|
|
120
|
-
3. **Runtime directory** — `.claude/` → Anthropic, `.codex/` → OpenAI, `.gemini/` → Google
|
|
120
|
+
3. **Runtime directory** — `.claude/` → Anthropic, `.codex/` → OpenAI, `.gemini/` → Google, `.opencode/` → OpenAI, `.github/` → default (first match wins)
|
|
121
121
|
4. **Fallback** — Default provider map (Anthropic-style names)
|
|
122
122
|
|
|
123
123
|
---
|
|
@@ -4,24 +4,22 @@ Configuration options for `.planning/` directory behavior.
|
|
|
4
4
|
|
|
5
5
|
<config_schema>
|
|
6
6
|
```json
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
},
|
|
11
|
-
"
|
|
12
|
-
"branching_strategy": "none",
|
|
13
|
-
"phase_branch_template": "pan/phase-{phase}-{slug}",
|
|
14
|
-
"milestone_branch_template": "pan/{milestone}-{slug}"
|
|
15
|
-
}
|
|
7
|
+
"commit_docs": true,
|
|
8
|
+
"search_gitignored": false,
|
|
9
|
+
"branching_strategy": "none",
|
|
10
|
+
"phase_branch_template": "pan/phase-{phase}-{slug}",
|
|
11
|
+
"milestone_branch_template": "pan/{milestone}-{slug}"
|
|
16
12
|
```
|
|
17
13
|
|
|
18
14
|
| Option | Default | Description |
|
|
19
15
|
|--------|---------|-------------|
|
|
20
16
|
| `commit_docs` | `true` | Whether to commit planning artifacts to git |
|
|
21
17
|
| `search_gitignored` | `false` | Add `--no-ignore` to broad rg searches |
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
18
|
+
| `branching_strategy` | `"none"` | Git branching approach: `"none"`, `"phase"`, or `"milestone"` |
|
|
19
|
+
| `phase_branch_template` | `"pan/phase-{phase}-{slug}"` | Branch template for phase strategy |
|
|
20
|
+
| `milestone_branch_template` | `"pan/{milestone}-{slug}"` | Branch template for milestone strategy |
|
|
21
|
+
|
|
22
|
+
These keys are top-level. The nested `planning.*` / `git.*` forms older versions wrote are read only when the top-level key is absent. A config `config-ensure-section` creates carries every top-level key, and one `/pan:new-project` writes carries a top-level `commit_docs` — so write the top-level key; a nested one is ignored whenever the top-level key exists.
|
|
25
23
|
</config_schema>
|
|
26
24
|
|
|
27
25
|
<commit_docs_behavior>
|
|
@@ -84,10 +82,8 @@ To use uncommitted mode:
|
|
|
84
82
|
|
|
85
83
|
1. **Set config:**
|
|
86
84
|
```json
|
|
87
|
-
"
|
|
88
|
-
|
|
89
|
-
"search_gitignored": true
|
|
90
|
-
}
|
|
85
|
+
"commit_docs": false,
|
|
86
|
+
"search_gitignored": true
|
|
91
87
|
```
|
|
92
88
|
|
|
93
89
|
2. **Add to .gitignore:**
|
|
@@ -101,7 +97,7 @@ To use uncommitted mode:
|
|
|
101
97
|
git commit -m "chore: stop tracking planning docs"
|
|
102
98
|
```
|
|
103
99
|
|
|
104
|
-
4. **Branch merges:**
|
|
100
|
+
4. **Branch merges:** PAN never merges branches; when you merge a phase or milestone branch yourself with `commit_docs: false`, keep `.planning/` out of the merge commit.
|
|
105
101
|
|
|
106
102
|
</setup_uncommitted_mode>
|
|
107
103
|
|
|
@@ -113,24 +109,24 @@ To use uncommitted mode:
|
|
|
113
109
|
|----------|---------------------|--------------|-------------|
|
|
114
110
|
| `none` | Never | N/A | N/A |
|
|
115
111
|
| `phase` | At `execute-phase` start | Single phase | User merges after phase |
|
|
116
|
-
| `milestone` | At first `execute-phase` of milestone | Entire milestone |
|
|
112
|
+
| `milestone` | At first `execute-phase` of milestone | Entire milestone | User merges after milestone |
|
|
117
113
|
|
|
118
|
-
**When `
|
|
114
|
+
**When `branching_strategy: "none"` (default):**
|
|
119
115
|
- All work commits to current branch
|
|
120
116
|
- Standard PAN behavior
|
|
121
117
|
|
|
122
|
-
**When `
|
|
118
|
+
**When `branching_strategy: "phase"`:**
|
|
123
119
|
- `execute-phase` creates/switches to a branch before execution
|
|
124
120
|
- Branch name from `phase_branch_template` (e.g., `pan/phase-03-authentication`)
|
|
125
121
|
- All plan commits go to that branch
|
|
126
122
|
- User merges branches manually after phase completion
|
|
127
|
-
- `milestone-done`
|
|
123
|
+
- `milestone-done` does not merge — merge the phase branches yourself
|
|
128
124
|
|
|
129
|
-
**When `
|
|
125
|
+
**When `branching_strategy: "milestone"`:**
|
|
130
126
|
- First `execute-phase` of milestone creates the milestone branch
|
|
131
127
|
- Branch name from `milestone_branch_template` (e.g., `pan/v1.0-mvp`)
|
|
132
128
|
- All phases in milestone commit to same branch
|
|
133
|
-
- `milestone-done`
|
|
129
|
+
- `milestone-done` archives and tags but does not merge — merge the milestone branch yourself
|
|
134
130
|
|
|
135
131
|
**Template variables:**
|
|
136
132
|
|
|
@@ -162,6 +162,7 @@ Report final status.
|
|
|
162
162
|
| W007 | warning | Phase on disk but not in ROADMAP | No |
|
|
163
163
|
| I001 | info | Plan without SUMMARY (may be in progress) | No |
|
|
164
164
|
| I002 | info | Phase in ROADMAP ahead of current phase, not planned yet | No |
|
|
165
|
+
| I003 | info | Focus-model or campaign tree — the phase-model checks do not apply | No |
|
|
165
166
|
| STATE_REQ_DRIFT | warning | state.md complete but REQUIREMENTS.md has unchecked boxes | Yes |
|
|
166
167
|
| STATE_ROADMAP_DRIFT | warning | state.md complete but roadmap.md has unchecked plan boxes | Yes |
|
|
167
168
|
| VERIFICATION_GATE_MISSING | warning | Phase has completed plans but no verification record | No |
|
|
@@ -31,7 +31,7 @@ Parse current values (default to `true` if not present):
|
|
|
31
31
|
- `workflow.nyquist_validation` — validation architecture research during plan-phase
|
|
32
32
|
- `model_profile` — which model each agent uses (default: `balanced`)
|
|
33
33
|
- `routing.strategy` — how model tiers are adjusted at runtime (default: `static`)
|
|
34
|
-
- `
|
|
34
|
+
- `branching_strategy` — branching approach (default: `"none"`; a top-level key — a nested `git.branching_strategy` is ignored when the top-level key exists, as it does in every config `config-ensure-section` creates — write the top-level key)
|
|
35
35
|
</step>
|
|
36
36
|
|
|
37
37
|
<step name="present_settings">
|
|
@@ -131,9 +131,7 @@ Merge new settings into existing config.json:
|
|
|
131
131
|
"auto_advance": true/false,
|
|
132
132
|
"nyquist_validation": true/false
|
|
133
133
|
},
|
|
134
|
-
"
|
|
135
|
-
"branching_strategy": "none" | "phase" | "milestone"
|
|
136
|
-
},
|
|
134
|
+
"branching_strategy": "none" | "phase" | "milestone",
|
|
137
135
|
"routing": {
|
|
138
136
|
"strategy": "static" | "complexity"
|
|
139
137
|
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* coverage-gate.cjs — did the shipped code actually run under the tests?
|
|
5
|
+
*
|
|
6
|
+
* Runs the whole suite under Node's own coverage instrumentation (no dependency:
|
|
7
|
+
* `node --test --experimental-test-coverage`, lcov reporter; the child processes
|
|
8
|
+
* the tests spawn — pan-tools, the installer, the hooks — are captured through the
|
|
9
|
+
* inherited NODE_V8_COVERAGE), then enforces:
|
|
10
|
+
* - line and function floors overall and per module group (tests/fixtures/
|
|
11
|
+
* coverage-policy.json — a floor sits a point below the measured baseline, so
|
|
12
|
+
* a real regression fails and normal churn does not);
|
|
13
|
+
* - every dispatcher `case` arm executed at least once — the binary rule that
|
|
14
|
+
* catches "a verb no test dispatches", which a percentage hides. An arm may be
|
|
15
|
+
* allowlisted in the policy with a reason (interactive, network, a P2 item).
|
|
16
|
+
* The never-called functions are printed, ranked, so a gap has a name.
|
|
17
|
+
*
|
|
18
|
+
* node scripts/coverage-gate.cjs run the suite, evaluate, exit 1 on a violation
|
|
19
|
+
* node scripts/coverage-gate.cjs --lcov f evaluate an existing lcov file (no run)
|
|
20
|
+
* node scripts/coverage-gate.cjs --json machine-readable result
|
|
21
|
+
*
|
|
22
|
+
* Node < 22 lacks the coverage include/exclude flags: the gate reports "skipped"
|
|
23
|
+
* and exits 0 there, so the 18/20 CI jobs stay green and the 22 job carries it.
|
|
24
|
+
* Wired as release-check Gate 9 and as an advisory CI step on the Node 22 job.
|
|
25
|
+
*/
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const { spawnSync } = require('child_process');
|
|
30
|
+
const { parseCaseArms } = require('./test-surface.cjs');
|
|
31
|
+
|
|
32
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
33
|
+
const POLICY_REL = path.join('tests', 'fixtures', 'coverage-policy.json');
|
|
34
|
+
const DISPATCHER_REL = 'pan-wizard-core/bin/pan-tools.cjs';
|
|
35
|
+
const TEST_DIRS = ['tests', 'tests/scenarios'];
|
|
36
|
+
const INCLUDE = ['pan-wizard-core/**/*.cjs', 'pan-wizard-core/**/*.js', 'bin/**', 'hooks/*.js', 'scripts/**'];
|
|
37
|
+
const EXCLUDE = ['tests/**', '**/node_modules/**'];
|
|
38
|
+
const MIN_NODE_MAJOR = 22;
|
|
39
|
+
|
|
40
|
+
const DEFAULT_POLICY = Object.freeze({
|
|
41
|
+
floors: { overall_lines: 92, overall_functions: 93, groups: { lib: 92, installer: 90, hooks: 90, mcp: 95 } },
|
|
42
|
+
arms_allow: [],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ─── lcov ───────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Parse lcov text into [{ path, lines: Map(line→hits), fn: Map(name→line), fnda: Map(name→hits), lf, lh, brf, brh }]. */
|
|
48
|
+
function parseLcov(text) {
|
|
49
|
+
const files = [];
|
|
50
|
+
let cur = null;
|
|
51
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
52
|
+
const ci = raw.indexOf(':');
|
|
53
|
+
const k = ci >= 0 ? raw.slice(0, ci) : raw;
|
|
54
|
+
const v = ci >= 0 ? raw.slice(ci + 1) : '';
|
|
55
|
+
if (k === 'SF') { cur = { path: v.split('\\').join('/'), lines: new Map(), fn: new Map(), fnda: new Map(), lf: 0, lh: 0, brf: 0, brh: 0 }; continue; }
|
|
56
|
+
if (!cur) continue;
|
|
57
|
+
if (k === 'DA') { const [ln, c] = v.split(',').map(Number); cur.lines.set(ln, c); }
|
|
58
|
+
else if (k === 'FN') { const i = v.indexOf(','); cur.fn.set(v.slice(i + 1), Number(v.slice(0, i))); }
|
|
59
|
+
else if (k === 'FNDA') { const i = v.indexOf(','); cur.fnda.set(v.slice(i + 1), Number(v.slice(0, i))); }
|
|
60
|
+
else if (k === 'LF') cur.lf = Number(v); else if (k === 'LH') cur.lh = Number(v);
|
|
61
|
+
else if (k === 'BRF') cur.brf = Number(v); else if (k === 'BRH') cur.brh = Number(v);
|
|
62
|
+
else if (raw === 'end_of_record') { files.push(cur); cur = null; }
|
|
63
|
+
}
|
|
64
|
+
return files;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function relPath(p, root = ROOT) {
|
|
68
|
+
const r = root.split('\\').join('/');
|
|
69
|
+
const i = p.indexOf(r);
|
|
70
|
+
return i >= 0 ? p.slice(i + r.length).replace(/^\//, '') : p;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function groupOf(rel) {
|
|
74
|
+
if (/^pan-wizard-core\/bin\/lib\//.test(rel)) return 'lib';
|
|
75
|
+
if (/^pan-wizard-core\/mcp\//.test(rel)) return 'mcp';
|
|
76
|
+
if (/^pan-wizard-core\/bin\//.test(rel)) return 'cli';
|
|
77
|
+
if (/^pan-wizard-core\/workflows\//.test(rel)) return 'native-workflows';
|
|
78
|
+
if (/^bin\//.test(rel)) return 'installer';
|
|
79
|
+
if (/^hooks\//.test(rel)) return 'hooks';
|
|
80
|
+
if (/^scripts\//.test(rel)) return 'scripts';
|
|
81
|
+
return 'other';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Which case arms executed. An arm is executed when the first instrumented line
|
|
86
|
+
* after its label (before the next arm at the same or a shallower indent) ran; a
|
|
87
|
+
* label immediately followed by another label shares that arm's body (fallthrough).
|
|
88
|
+
*/
|
|
89
|
+
function armCoverage(dispatcherSrc, dispatcherFile) {
|
|
90
|
+
const arms = parseCaseArms(dispatcherSrc);
|
|
91
|
+
const byLine = new Map(arms.map((a) => [a.line, a]));
|
|
92
|
+
const lines = dispatcherSrc.split(/\r?\n/);
|
|
93
|
+
const da = dispatcherFile ? dispatcherFile.lines : new Map();
|
|
94
|
+
const result = [];
|
|
95
|
+
for (const a of arms) {
|
|
96
|
+
let verdict = null;
|
|
97
|
+
for (let ln = a.line + 1; ln <= lines.length; ln++) {
|
|
98
|
+
const nxt = byLine.get(ln);
|
|
99
|
+
if (nxt && nxt.indent <= a.indent) {
|
|
100
|
+
if (nxt.indent === a.indent && /^\s*case\s+'/.test(lines[ln - 1])) verdict = 'fallthrough';
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (da.has(ln)) { verdict = da.get(ln) > 0; break; }
|
|
104
|
+
}
|
|
105
|
+
result.push({ id: a.parent ? `${a.parent} > ${a.label}` : a.label, line: a.line, verdict });
|
|
106
|
+
}
|
|
107
|
+
// A fallthrough label takes the verdict of the arm it shares.
|
|
108
|
+
for (let i = 0; i < result.length; i++) {
|
|
109
|
+
if (result[i].verdict === 'fallthrough') {
|
|
110
|
+
let j = i + 1;
|
|
111
|
+
while (j < result.length && result[j].verdict === 'fallthrough') j++;
|
|
112
|
+
result[i].verdict = j < result.length ? result[j].verdict : null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validatePolicy(policy) {
|
|
119
|
+
const errors = [];
|
|
120
|
+
for (const a of policy.arms_allow || []) {
|
|
121
|
+
if (!a || typeof a.arm !== 'string') errors.push(`arms_allow entry without an arm: ${JSON.stringify(a)}`);
|
|
122
|
+
else if (!a.reason || !String(a.reason).trim()) errors.push(`arms_allow entry "${a.arm}" has no reason`);
|
|
123
|
+
}
|
|
124
|
+
return errors;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Evaluate parsed lcov against the policy and the dispatcher source.
|
|
129
|
+
* Pure. Returns { ok, violations[], groups, overall, arms, never_called, policy_errors }.
|
|
130
|
+
*/
|
|
131
|
+
function evaluateCoverage(lcovFiles, { dispatcherSrc, policy = DEFAULT_POLICY, root = ROOT } = {}) {
|
|
132
|
+
const violations = [];
|
|
133
|
+
const policyErrors = validatePolicy(policy);
|
|
134
|
+
violations.push(...policyErrors.map((e) => `policy: ${e}`));
|
|
135
|
+
if (!lcovFiles.length) violations.push('lcov parsed no files — malformed or empty coverage output (failing closed)');
|
|
136
|
+
|
|
137
|
+
const groups = {};
|
|
138
|
+
const overall = { lf: 0, lh: 0, ff: 0, fh: 0 };
|
|
139
|
+
const neverCalled = [];
|
|
140
|
+
let dispatcherFile = null;
|
|
141
|
+
for (const f of lcovFiles) {
|
|
142
|
+
const rel = relPath(f.path, root);
|
|
143
|
+
if (rel.endsWith(DISPATCHER_REL) || rel === DISPATCHER_REL) dispatcherFile = f;
|
|
144
|
+
const g = groupOf(rel);
|
|
145
|
+
const fns = [...f.fn.keys()];
|
|
146
|
+
const fh = fns.filter((n) => (f.fnda.get(n) || 0) > 0).length;
|
|
147
|
+
const acc = groups[g] = groups[g] || { files: 0, lf: 0, lh: 0, ff: 0, fh: 0 };
|
|
148
|
+
acc.files++; acc.lf += f.lf; acc.lh += f.lh; acc.ff += fns.length; acc.fh += fh;
|
|
149
|
+
overall.lf += f.lf; overall.lh += f.lh; overall.ff += fns.length; overall.fh += fh;
|
|
150
|
+
for (const n of fns) if (!((f.fnda.get(n) || 0) > 0)) neverCalled.push(`${rel}:${f.fn.get(n)} ${n || '(anonymous)'}`);
|
|
151
|
+
}
|
|
152
|
+
const pct = (h, t) => (t ? Math.round((h / t) * 1000) / 10 : 100);
|
|
153
|
+
const overallPct = { lines: pct(overall.lh, overall.lf), functions: pct(overall.fh, overall.ff) };
|
|
154
|
+
const floors = policy.floors || DEFAULT_POLICY.floors;
|
|
155
|
+
if (lcovFiles.length) {
|
|
156
|
+
if (overallPct.lines < floors.overall_lines) violations.push(`overall line coverage ${overallPct.lines}% is below the floor ${floors.overall_lines}%`);
|
|
157
|
+
if (overallPct.functions < floors.overall_functions) violations.push(`overall function coverage ${overallPct.functions}% is below the floor ${floors.overall_functions}%`);
|
|
158
|
+
for (const [g, floor] of Object.entries(floors.groups || {})) {
|
|
159
|
+
const acc = groups[g];
|
|
160
|
+
if (!acc) { violations.push(`group "${g}" has no files under coverage — include globs or layout changed`); continue; }
|
|
161
|
+
const p = pct(acc.lh, acc.lf);
|
|
162
|
+
if (p < floor) violations.push(`${g} line coverage ${p}% is below the floor ${floor}%`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let arms = [];
|
|
167
|
+
if (dispatcherSrc) {
|
|
168
|
+
arms = armCoverage(dispatcherSrc, dispatcherFile);
|
|
169
|
+
const allow = new Map((policy.arms_allow || []).map((a) => [a.arm, a.reason]));
|
|
170
|
+
if (!dispatcherFile && lcovFiles.length) violations.push('the dispatcher was not in the coverage output — no test ran pan-tools.cjs?');
|
|
171
|
+
for (const a of arms) {
|
|
172
|
+
if (a.verdict === true) continue;
|
|
173
|
+
if (allow.has(a.id)) { a.allowlisted = allow.get(a.id); continue; }
|
|
174
|
+
violations.push(`dispatcher arm never executed: ${a.id} (line ${a.line}) — add a test that dispatches it, or allowlist it in ${POLICY_REL} with a reason`);
|
|
175
|
+
}
|
|
176
|
+
for (const [arm] of allow) if (!arms.some((a) => a.id === arm)) violations.push(`policy: arms_allow names an arm that no longer exists: ${arm}`);
|
|
177
|
+
for (const [arm, reason] of allow) { const a = arms.find((x) => x.id === arm); if (a && a.verdict === true) violations.push(`policy: arm "${arm}" is executed now — remove its allowlist entry (${reason})`); }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const groupTable = Object.fromEntries(Object.entries(groups).map(([g, a]) => [g, { files: a.files, lines: pct(a.lh, a.lf), functions: pct(a.fh, a.ff) }]));
|
|
181
|
+
return { ok: violations.length === 0, violations, overall: overallPct, groups: groupTable, arms, never_called: neverCalled.sort(), policy_errors: policyErrors };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Running the suite ──────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
function expandTestFiles(root = ROOT, dirs = TEST_DIRS) {
|
|
187
|
+
const files = [];
|
|
188
|
+
for (const dir of dirs) {
|
|
189
|
+
const abs = path.join(root, dir);
|
|
190
|
+
let entries = [];
|
|
191
|
+
try { entries = fs.readdirSync(abs); } catch { continue; }
|
|
192
|
+
for (const f of entries) if (f.endsWith('.test.cjs')) files.push(path.join(abs, f));
|
|
193
|
+
}
|
|
194
|
+
return files.sort();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function runSuiteWithCoverage(root = ROOT, lcovPath) {
|
|
198
|
+
const specLog = lcovPath + '.spec.log';
|
|
199
|
+
const args = ['--test', '--experimental-test-coverage'];
|
|
200
|
+
for (const g of INCLUDE) args.push(`--test-coverage-include=${g}`);
|
|
201
|
+
for (const g of EXCLUDE) args.push(`--test-coverage-exclude=${g}`);
|
|
202
|
+
args.push('--test-reporter=lcov', `--test-reporter-destination=${lcovPath}`, '--test-reporter=spec', `--test-reporter-destination=${specLog}`);
|
|
203
|
+
args.push(...expandTestFiles(root));
|
|
204
|
+
const r = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
205
|
+
return { status: r.status, specLog, stderr: r.stderr || '' };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function loadPolicy(root = ROOT) {
|
|
209
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, POLICY_REL), 'utf8')); } catch { return DEFAULT_POLICY; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function render(result) {
|
|
213
|
+
const lines = [];
|
|
214
|
+
lines.push(`coverage gate — ${result.ok ? 'OK' : 'FAIL'}`);
|
|
215
|
+
lines.push(` overall: lines ${result.overall.lines}% · functions ${result.overall.functions}%`);
|
|
216
|
+
for (const [g, a] of Object.entries(result.groups).sort()) lines.push(` ${g.padEnd(18)} files ${String(a.files).padStart(3)} lines ${String(a.lines).padStart(5)}% functions ${String(a.functions).padStart(5)}%`);
|
|
217
|
+
const executed = result.arms.filter((a) => a.verdict === true).length;
|
|
218
|
+
const allowed = result.arms.filter((a) => a.allowlisted).length;
|
|
219
|
+
lines.push(` dispatcher arms: ${executed}/${result.arms.length} executed${allowed ? `, ${allowed} allowlisted` : ''}`);
|
|
220
|
+
if (result.never_called.length) {
|
|
221
|
+
lines.push(` never-called functions: ${result.never_called.length} (first 12)`);
|
|
222
|
+
for (const n of result.never_called.slice(0, 12)) lines.push(` ${n}`);
|
|
223
|
+
}
|
|
224
|
+
for (const v of result.violations) lines.push(` ✖ ${v}`);
|
|
225
|
+
return lines.join('\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function main(argv) {
|
|
229
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
230
|
+
const lcovArg = argv.includes('--lcov') ? argv[argv.indexOf('--lcov') + 1] : null;
|
|
231
|
+
if (!lcovArg && major < MIN_NODE_MAJOR) {
|
|
232
|
+
console.log(`coverage gate — skipped on Node ${process.versions.node} (needs ${MIN_NODE_MAJOR}+ for coverage include/exclude flags)`);
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
let lcovPath = lcovArg;
|
|
236
|
+
let tmp = null;
|
|
237
|
+
if (!lcovPath) {
|
|
238
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-coverage-'));
|
|
239
|
+
lcovPath = path.join(tmp, 'coverage.lcov');
|
|
240
|
+
const r = runSuiteWithCoverage(ROOT, lcovPath);
|
|
241
|
+
if (r.status !== 0) {
|
|
242
|
+
console.error(`coverage gate — the suite itself failed (exit ${r.status}); see ${r.specLog}`);
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
let text = '';
|
|
247
|
+
try { text = fs.readFileSync(lcovPath, 'utf8'); } catch (e) { console.error(`coverage gate — cannot read ${lcovPath}: ${e.message}`); return 1; }
|
|
248
|
+
const result = evaluateCoverage(parseLcov(text), { dispatcherSrc: fs.readFileSync(path.join(ROOT, DISPATCHER_REL), 'utf8'), policy: loadPolicy(ROOT), root: ROOT });
|
|
249
|
+
if (argv.includes('--json')) console.log(JSON.stringify(result, null, 2));
|
|
250
|
+
else console.log(render(result));
|
|
251
|
+
if (tmp && !argv.includes('--keep')) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ } }
|
|
252
|
+
return result.ok ? 0 : 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (require.main === module) process.exit(main(process.argv.slice(2)));
|
|
256
|
+
|
|
257
|
+
module.exports = { parseLcov, groupOf, armCoverage, evaluateCoverage, validatePolicy, expandTestFiles, render, DEFAULT_POLICY, POLICY_REL, INCLUDE, EXCLUDE, MIN_NODE_MAJOR };
|
|
@@ -52,6 +52,11 @@ try {
|
|
|
52
52
|
|
|
53
53
|
// 3. Confirm the hook file is executable on Unix. On Windows the bit doesn't
|
|
54
54
|
// matter — Git Bash treats `.sh` and shebanged scripts as executable.
|
|
55
|
+
// The file is TRACKED as 100755, so this is a safety net rather than the source
|
|
56
|
+
// of truth: it used to be tracked 100644, and since npm ci runs this script
|
|
57
|
+
// through `prepare`, every Linux and macOS checkout was left with a one-bit dirty
|
|
58
|
+
// tree that nothing looked at until CI began asserting the tree is unchanged
|
|
59
|
+
// (2026-09-17).
|
|
55
60
|
const hookFile = path.join(REPO_ROOT, HOOKS_DIR, 'pre-commit');
|
|
56
61
|
if (process.platform !== 'win32') {
|
|
57
62
|
try {
|