@pareto4/pp-core 1.17.1 → 1.18.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.
Files changed (86) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -3
  3. package/agents/pp-code-fixer.md +0 -11
  4. package/agents/pp-code-reviewer.md +1 -78
  5. package/agents/pp-executor.md +9 -23
  6. package/agents/pp-planner.md +11 -11
  7. package/agents/pp-vault-reviewer.md +43 -189
  8. package/agents/pp-verifier.md +0 -6
  9. package/commands/pp-ns-manage.md +1 -1
  10. package/commands/pp-ns-review.md +1 -1
  11. package/commands/pp-vault-promote.md +7 -8
  12. package/commands/pp-vault-review.md +6 -7
  13. package/gemini-extension.json +1 -1
  14. package/package.json +3 -6
  15. package/pp-core/bin/lib/capability-registry.cjs +13 -355
  16. package/pp-core/bin/lib/capability-state.cjs +15 -79
  17. package/pp-core/bin/lib/capability-writer.cjs +2 -16
  18. package/pp-core/bin/lib/check-command-router.cjs +277 -2273
  19. package/pp-core/bin/lib/clusters.cjs +2 -2
  20. package/pp-core/bin/lib/compose-bindings.cjs +2 -2
  21. package/pp-core/bin/lib/config.cjs +3 -6
  22. package/pp-core/bin/lib/git-base-branch.cjs +10 -13
  23. package/pp-core/bin/lib/git-provider.cjs +5 -6
  24. package/pp-core/bin/lib/init-contract.cjs +7 -35
  25. package/pp-core/bin/lib/init-interactive.cjs +6 -20
  26. package/pp-core/bin/lib/io.cjs +2 -8
  27. package/pp-core/bin/lib/loop-resolver.cjs +8 -32
  28. package/pp-core/bin/lib/org-profile.cjs +16 -92
  29. package/pp-core/bin/lib/vault-command-router.cjs +174 -1206
  30. package/pp-core/bin/lib/vault-coordinates.cjs +3 -3
  31. package/pp-core/bin/lib/vault-index.cjs +27 -70
  32. package/pp-core/bin/lib/vault-seal.cjs +37 -122
  33. package/pp-core/bin/lib/vault-standards-promote.cjs +577 -0
  34. package/pp-core/bin/lib/vault-standards-review.cjs +293 -0
  35. package/pp-core/bin/lib/vault-standards.cjs +15 -16
  36. package/pp-core/bin/lib/vault-trace.cjs +40 -3016
  37. package/pp-core/bin/lib/vault-update.cjs +1 -26
  38. package/pp-core/bin/lib/vault.cjs +120 -314
  39. package/pp-core/bin/pp-tools.cjs +5 -32
  40. package/pp-core/references/autonomous-best-effort.md +1 -1
  41. package/pp-core/references/planner-vault-standards.md +16 -25
  42. package/pp-core/templates/README.md +1 -1
  43. package/pp-core/templates/VAULT-REVIEW.md +24 -170
  44. package/pp-core/workflows/add-tests.md +3 -3
  45. package/pp-core/workflows/audit-fix.md +3 -3
  46. package/pp-core/workflows/code-review-fix.md +3 -3
  47. package/pp-core/workflows/code-review.md +1 -57
  48. package/pp-core/workflows/debug.md +2 -2
  49. package/pp-core/workflows/discovery-sprint/prototype.md +2 -2
  50. package/pp-core/workflows/discovery-sprint/vault-context.md +4 -10
  51. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +11 -13
  52. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +5 -25
  53. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +6 -6
  54. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +6 -109
  55. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +1 -17
  56. package/pp-core/workflows/execute-phase.md +2 -5
  57. package/pp-core/workflows/execute-plan.md +6 -8
  58. package/pp-core/workflows/help/modes/full.md +1 -1
  59. package/pp-core/workflows/quick.md +8 -8
  60. package/pp-core/workflows/validate-phase.md +3 -3
  61. package/pp-core/workflows/vault-review.md +64 -186
  62. package/pp-core/workflows/vault-write-back.md +73 -110
  63. package/pp-core/workflows/verify-work.md +1 -1
  64. package/scripts/gen-capability-registry.cjs +12 -93
  65. package/scripts/lint-test-file-count.allowlist.json +1 -19
  66. package/scripts/lint-test-file-count.cjs +5 -6
  67. package/scripts/prompt-injection-scan.sh +0 -3
  68. package/pp-core/bin/lib/practices-review-check.cjs +0 -944
  69. package/pp-core/bin/lib/vault-audit.cjs +0 -2594
  70. package/pp-core/bin/lib/vault-mode.cjs +0 -235
  71. package/pp-core/bin/lib/vault-practices.cjs +0 -2057
  72. package/pp-core/bin/lib/vault-review.cjs +0 -2102
  73. package/pp-core/bin/lib/vault-write-back.cjs +0 -905
  74. package/pp-core/references/planner-vault-practices.md +0 -95
  75. package/pp-core/references/practice-arithmetic-contract.md +0 -121
  76. package/pp-core/references/verifier-practice-verdicts.md +0 -263
  77. package/pp-core/templates/practices/README.md +0 -248
  78. package/pp-core/templates/practices/error-handling.md +0 -18
  79. package/pp-core/templates/practices/minimal-comments.md +0 -14
  80. package/pp-core/templates/practices/naming-convention.md +0 -18
  81. package/pp-core/templates/practices/practice-template.md +0 -23
  82. package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +0 -165
  83. package/scripts/gen-practice-arithmetic-contract.cjs +0 -412
  84. package/scripts/practice-arithmetic-contract.allow.json +0 -35
  85. package/scripts/record-vault-mode-golden.cjs +0 -89
  86. package/scripts/vault-calibrate.cjs +0 -996
@@ -1,2594 +0,0 @@
1
- 'use strict';
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- /**
6
- * src/vault-audit.cts — the pure `vault audit` evaluator: the ONE engine both
7
- * surfaces in plan 05-06 call (Vault Assurance #71924, phase 05 plan 05;
8
- * CHECK-03, CHECK-04).
9
- *
10
- * Responsibility. Given the practices the cached vault holds and the files a
11
- * phase actually changed, decide — per practice — one of exactly four verdicts
12
- * and return them all. It owns the diff scoping, the fail-closed exhaustion
13
- * arms, and the human rendering. It owns NO parsing and NO matching of its own.
14
- *
15
- * Reuses (no new runtime dependency):
16
- * - `./vault-practices.cjs` — `globMatch` (the matching path), `isSupportedGlob`
17
- * (the pattern GATE, by reference), `boundedToken` (every client-authored
18
- * value that may reach a rendered string), `discoverPracticeFiles` (the
19
- * practice denominator), and the two cost bounds `MATCH_PAIR_CAP` /
20
- * `MATCH_BUDGET_MS` plus `MATCHED_FILES_CAP`.
21
- *
22
- * Nothing on the matching path is re-implemented here and no grammar is
23
- * restated in prose. ADR-71924 §2 refuses to restate the glob grammar
24
- * precisely so the two cannot drift, and a second matcher would be the
25
- * drifting copy it refuses. §3's prohibition — no client-authored byte is
26
- * ever compiled into a regular expression — is why content tokens below are
27
- * tested by `String.prototype.includes` and nothing else. That prohibition
28
- * is mechanised, not merely stated: `eslint-rules/practices-boundary-inputs.cjs`
29
- * is wired at this file in `eslint.config.mjs`.
30
- * - `./git-base-branch.cjs` (`resolveBaseBranchResult`) — the 5-tier ladder is
31
- * never re-derived; issue #1146 records what the naive form cost. The RESULT
32
- * form, so a REFUSED `git.base_branch` becomes a stated note rather than a
33
- * silent change of range (W16).
34
- * - `./shell-command-projection.cjs` (`execGitLocal`, D-116) — the mandated, timeout-bounded
35
- * git seam. `execFileSync` is not used anywhere in this module.
36
- *
37
- * WHAT `require` MEANS HERE, stated because a recorded number depends on it.
38
- * A `require` token is satisfied when EVERY matched file contains it. This is
39
- * the per-file reading, and it is the reading the CHECK-05 census in
40
- * `docs/adr/71924-declarative-check-blocks.md` states as its premise ("`require`
41
- * is read per matched file") and measured its 19-of-55 count under. It is
42
- * chosen because it is the only reading under which a FAIL can name a SPECIFIC
43
- * offending file — and a verdict that cannot name what offended it is a verdict
44
- * an auditor cannot act on, which is the failure D-05-03's vocabulary exists to
45
- * prevent. Changing this to "some matched file contains the token" would move
46
- * the recorded census; it is a decision with a paper trail, not an
47
- * implementation detail.
48
- *
49
- * TWO KNOWN LIMITS OF THE DIFF, recorded here rather than rediscovered later.
50
- * Both are inherited deliberately from `cmdShipPractices`' scoping mechanism,
51
- * which this module lifts verbatim except for the range:
52
- *
53
- * 1. A RENAME contributes only its NEW path. `git diff --name-only` reports
54
- * the destination, so a check that asserts a file MOVED cannot see where it
55
- * came from.
56
- * 2. DELETIONS are excluded (`--diff-filter=d`), and that is correct for both
57
- * assertion kinds. For a `forbid`/`forbidMatch` check a deleted violation
58
- * is the fix, so reporting it would FAIL a repair. For a `require` check a
59
- * deleted file has no content left to satisfy anything, so requiring a
60
- * token of it would FAIL a legitimate removal.
61
- */
62
- const node_path_1 = __importDefault(require("node:path"));
63
- const node_fs_1 = __importDefault(require("node:fs"));
64
- const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
65
- const git_base_branch_cjs_1 = require("./git-base-branch.cjs");
66
- // eslint-disable-next-line @typescript-eslint/no-require-imports
67
- const practicesMod = require("./vault-practices.cjs");
68
- // 260915-ik2 (UAT-6-F1): the trace leaf, for the SUMMARY trace-digest rung of
69
- // `resolvePhaseRange`. `vault-trace.cts` imports `vault.cjs` and the practices
70
- // leaf only, and neither imports this module, so this adds no cycle.
71
- // eslint-disable-next-line @typescript-eslint/no-require-imports
72
- const traceMod = require("./vault-trace.cjs");
73
- // 16-03: the one `.planning/` path test. Direction: this module imports the
74
- // seal leaf statically; the leaf requires this module only LAZILY, inside
75
- // `collectRecordedArtifacts`, so there is no load-time cycle (D-41).
76
- // eslint-disable-next-line @typescript-eslint/no-require-imports
77
- const vaultSeal = require("./vault-seal.cjs");
78
- const { parseVaultTraceBlock } = traceMod;
79
- const { globMatch, isSupportedGlob, boundedToken, discoverPracticeFiles, MATCH_PAIR_CAP, MATCH_BUDGET_MS, MATCHED_FILES_CAP,
80
- // 05-REVIEW CR-02/CR-03: the shared fencing idiom and the errno redactor —
81
- // reused, never re-authored, so the two modules cannot disagree about how a
82
- // client-authored byte reaches an agent-read channel.
83
- defangFenceMarkers, EXTERNAL_CONTENT_FENCE_START, EXTERNAL_CONTENT_FENCE_END, FENCE_NOTICE, errnoOf, } = practicesMod;
84
- // ─── The frozen vocabulary ────────────────────────────────────────────────────
85
- // These constants ARE the contract. They are module constants and are NEVER
86
- // re-derived at run time from configuration or from the vault — the
87
- // `src/vault-trace.cts` rule, for the same reason: a verdict vocabulary a
88
- // client repository could move is not a verdict vocabulary.
89
- /**
90
- * D-05-03's four verdicts, verbatim. `NO_CHECK` and `NOT_MATCHED` are two
91
- * distinct governance facts and must never collapse into one N-A: `NO_CHECK` is
92
- * a gap in the VAULT's coverage (a candidate for CHECK-05 authoring work);
93
- * `NOT_MATCHED` is a check that was live and simply had nothing to look at.
94
- */
95
- const AUDIT_VERDICT = Object.freeze({
96
- PASS: 'PASS',
97
- FAIL: 'FAIL',
98
- NO_CHECK: 'NO_CHECK',
99
- NOT_MATCHED: 'NOT_MATCHED',
100
- });
101
- /**
102
- * Why a FAIL is a FAIL. Every one of these is fail-CLOSED: an exhausted budget,
103
- * a refused pair count, an unreadable file, an unreadable diff, an unreadable
104
- * VAULT and an invalid check block all land here rather than on PASS or on a
105
- * skip (CHECK-03g). "Proved nothing" reading as "passed" is the silence this
106
- * workstream exists to end.
107
- *
108
- * 05-10 added `VAULT_UNREADABLE`. Until then that sentence was true of the
109
- * diff side only: an absent or unreadable vault fell through to `status: 'ok',
110
- * block: false, message: ''` from a gate declared `blocking: true` at both
111
- * ship:pre and verify:post (G-1a). The member is what makes the sentence above
112
- * true of the vault side too.
113
- *
114
- * 05-10 also added `PRACTICE_UNLOADABLE` — a file `discoverPracticeFiles`
115
- * could not parse at all. It is a FAIL and not a NO_CHECK because a rejected
116
- * file's check-bearing-ness is UNKNOWN: folding it into the vault-coverage-gap
117
- * verdict would let a one-character typo buy a non-blocking outcome, which is
118
- * the same trade `CHECK_INVALID` already refuses.
119
- *
120
- * Quick 261015-at3 added `VAULT_STANDARDS_ONLY`: a standards vault (INDEX.md,
121
- * no practice files) read in practices mode. A FAIL and not a NO_CHECK on the
122
- * `PRACTICE_UNLOADABLE` argument — a practice layer the project is not reading
123
- * has unknown check-bearing-ness, so it cannot buy a non-blocking outcome.
124
- *
125
- * NINE members, and all nine block.
126
- */
127
- const AUDIT_FAIL_REASON = Object.freeze({
128
- UNSATISFIED: 'unsatisfied',
129
- PAIR_CAP_EXCEEDED: 'pair_cap_exceeded',
130
- BUDGET_EXHAUSTED: 'budget_exhausted',
131
- CHECK_INVALID: 'check_invalid',
132
- FILE_UNREADABLE: 'file_unreadable',
133
- DIFF_UNREADABLE: 'diff_unreadable',
134
- VAULT_UNREADABLE: 'vault_unreadable',
135
- PRACTICE_UNLOADABLE: 'practice_unloadable',
136
- VAULT_STANDARDS_ONLY: 'vault_standards_only',
137
- });
138
- /**
139
- * Where the compared range came from — recorded beside the range itself.
140
- *
141
- * In ladder order (260915-ik2, UAT-6-F1): the phase's recorded start commit,
142
- * the parent of its first recorded commit, the parent of the ancestry-earliest
143
- * SUMMARY trace digest, and only then the base-branch fallback. Every source
144
- * but the last is resolved FROM THE PHASE, which is what WR-04's block predicate
145
- * keys on.
146
- *
147
- * `OPERATOR_RANGE` (08-04) sits OUTSIDE the ladder entirely — see its own
148
- * comment below.
149
- */
150
- const AUDIT_RANGE_SOURCE = Object.freeze({
151
- PHASE_START_COMMIT: 'phase-start-commit',
152
- PHASE_FIRST_COMMIT: 'phase-first-commit',
153
- TRACE_DIGEST: 'trace-digest',
154
- BASE_BRANCH: 'base-branch',
155
- /**
156
- * The range came from an operator flag, not from the ladder (08-04, D-08-19).
157
- * The ladder is not consulted at all when this fires, so no `fallbackReason`
158
- * can exist and none is set.
159
- *
160
- * WHY IT EXISTS. UAT test 7a could not be re-run by hand: `vault audit`
161
- * exposed no `--range` / `--base` / `--from`, so an operator who watched the
162
- * gate block vacuously had no way to ask it about a range they knew was the
163
- * phase's. This is the diagnostic channel that answer needs.
164
- *
165
- * DELIBERATELY UNMAPPED in the review tier's
166
- * `REVIEW_SOURCE_FOR_AUDIT_SOURCE` (`src/vault-review.cts:450-454`), for the
167
- * same reason `base-branch` is: the review never accepts a range flag, so an
168
- * operator range can never reach it and a mapping would assert a path that
169
- * does not exist. Kebab-cased like its four siblings — it IS a source, unlike
170
- * `AUDIT_RANGE_OUTCOME`'s members.
171
- */
172
- OPERATOR_RANGE: 'operator-range',
173
- });
174
- /**
175
- * The longest operator-supplied range this module will hand to git (08-04).
176
- *
177
- * Two refs, each allowed a full `refs/...` spelling plus `^`/`~` suffixes, fit
178
- * inside 200 characters with room to spare; nothing legitimate needs more, and
179
- * a cap keeps an absurd value out of an argv and out of an error path long
180
- * before git sees it.
181
- */
182
- const AUDIT_OPERATOR_RANGE_MAX_LENGTH = 200;
183
- /**
184
- * The shape an operator-supplied range must match before it may become an argv
185
- * element (08-04, threat T-08-22).
186
- *
187
- * `diffFilesFor` passes the range as ONE element of
188
- * `['-c', 'core.quotepath=off', 'diff', '--name-only', '--diff-filter=d', range]`.
189
- * There is no shell here, so quoting is not the exposure — OPTION SHAPE is: a
190
- * value beginning with `-` is read by git as a flag, not as a revision range,
191
- * and `--output=/tmp/x..HEAD` would have git write a file. Hence the
192
- * LEADING-ALPHANUMERIC requirement on both sides, which no `-` or `--` prefix
193
- * can satisfy.
194
- *
195
- * T-6-49 is the precedent class this closes in advance: a configured
196
- * `git.base_branch` reached argv unvalidated. The charset is an allow-list, so
197
- * whitespace, control characters, `$`, backticks and quotes are excluded by
198
- * omission rather than by enumeration — the direction that stays safe when
199
- * something new is invented. Two dots or three, and nothing else.
200
- */
201
- const AUDIT_OPERATOR_RANGE_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/^~-]*\.\.\.?[A-Za-z0-9][A-Za-z0-9._/^~-]*$/;
202
- /**
203
- * Fail-closed: is this value one this module will compare over? (08-04.)
204
- *
205
- * Takes `unknown` on purpose — the value arrives from a CLI flag two layers up
206
- * (plan 08-06), where its type is whatever an argv parser produced. Exported so
207
- * the router HARVESTS this predicate rather than restating it; a second copy of
208
- * a security check is a second copy that can be relaxed alone.
209
- */
210
- function isValidOperatorRange(value) {
211
- if (typeof value !== 'string')
212
- return false;
213
- if (value.length === 0 || value.length > AUDIT_OPERATOR_RANGE_MAX_LENGTH)
214
- return false;
215
- return AUDIT_OPERATOR_RANGE_SHAPE.test(value);
216
- }
217
- /**
218
- * WHY a phase-scoped range could not be resolved (05-10, G-1c).
219
- *
220
- * `phaseCommitsFor` returned `[]` for five structurally different grounds and
221
- * `resolvePhaseRange` distinguished none of them, so `rangeSource:
222
- * 'base-branch'` was the whole story an operator got. The executing checkout
223
- * writes the manifest itself (260915-ik2), but a checkout that never ran
224
- * `/pp-execute-phase` — a fresh clone or a CI runner where `.planning/` is
225
- * gitignored — has no manifest to read, and there the fallback fires whenever
226
- * no SUMMARY trace digest verifies either. That is precisely the case where
227
- * knowing WHICH ground fired is the difference between a repairable
228
- * configuration and a permanent one, and what makes D-05-08's range record
229
- * auditable there.
230
- */
231
- const AUDIT_FALLBACK_REASON = Object.freeze({
232
- /** The phase directory's name does not begin with an `NN` or `NN.M` token. */
233
- PHASE_SLUG_UNRECOGNISED: 'phase-slug-unrecognised',
234
- /** No `.phase-manifest.json` exists anywhere on the walk up from the phase dir. */
235
- MANIFEST_NOT_FOUND: 'manifest-not-found',
236
- /** A manifest was found but is not parseable JSON of the expected shape. */
237
- MANIFEST_MALFORMED: 'manifest-malformed',
238
- /** A manifest parsed, but holds no entry for this phase under either key spelling. */
239
- PHASE_ENTRY_MISSING: 'phase-entry-missing',
240
- /** The phase's entry exists and records no commits. */
241
- NO_COMMITS_RECORDED: 'no-commits-recorded',
242
- /** Commits were recorded, and git verified none of them. */
243
- COMMIT_UNVERIFIABLE: 'commit-unverifiable',
244
- });
245
- /**
246
- * WHAT the compared range ESTABLISHES — the outcome, not the source (08-04,
247
- * D-08-17).
248
- *
249
- * `AUDIT_RANGE_SOURCE` answers WHERE the range came from. This answers what an
250
- * operator may conclude from what was compared over it, and the two are
251
- * genuinely different questions: `base-branch` is a source that sometimes
252
- * establishes something (files were compared) and sometimes establishes
253
- * nothing at all (zero files over a range nobody tied to this phase). Until
254
- * 08-04 the second case had no name — it produced `status: 'warn'`,
255
- * `block: true` and a prose sentence, and `range_unresolvable` occurred
256
- * nowhere in `src/`, so no consumer could tell "this gate blocked because a
257
- * practice failed" from "this gate blocked because it proved nothing".
258
- *
259
- * `range_unresolvable` is deliberately NOT a fifth `AUDIT_RANGE_SOURCE` member
260
- * (RESEARCH R7, Open Question 2): nothing was resolved, so there is no source
261
- * to name, and `REVIEW_SOURCE_FOR_AUDIT_SOURCE` would then owe it a mapping it
262
- * has no meaning for.
263
- *
264
- * CASING. These three values are underscore-cased while the kebab-cased
265
- * `AUDIT_RANGE_SOURCE` / `AUDIT_FALLBACK_REASON` sit beside them. That is a
266
- * conscious deviation, not drift: `range_unresolvable` is quoted VERBATIM as
267
- * contract text by REQUIREMENTS.md HGATE-04, by ROADMAP.md's third criterion
268
- * and by the `vault-phase-range-unresolvable-on-main` todo, all of which
269
- * predate this table. Re-spelling it kebab here would make the shipped token
270
- * disagree with the three documents that specify it; re-spelling THEM would
271
- * rewrite a requirement to suit an implementation. The older tables are not
272
- * renamed either — a vocabulary a consumer already serializes is not churned
273
- * for symmetry.
274
- */
275
- const AUDIT_RANGE_OUTCOME = Object.freeze({
276
- /**
277
- * The range came from the phase itself — one of the three phase-derived
278
- * rungs (`phase-start-commit`, `phase-first-commit`, `trace-digest`), or an
279
- * operator who named the range and thereby asserted its scope. An EMPTY diff
280
- * here is a fact ABOUT THE PHASE and is stated, never blocked: D-05-02's
281
- * reasoning, preserved by D-08-18 — a legitimately empty phase diff is
282
- * common (docs-only phases, re-runs, a re-audit after a merge) and blocking
283
- * on it would teach operators to ignore the gate.
284
- */
285
- PHASE_SCOPED: 'phase_scoped',
286
- /**
287
- * The base-branch rung with at least one file. Something WAS compared, and
288
- * the verdicts speak for it — but nothing established that the range is this
289
- * phase's, so a FAIL may name a file belonging to a different phase (D-05-08).
290
- * Audited, and not phase-scoped: the two facts an operator needs held apart.
291
- */
292
- FALLBACK_AUDITED: 'fallback_audited',
293
- /**
294
- * The base-branch rung with ZERO files: nothing was verified. This is the
295
- * predicate WR-04 already wrote and 08-04 merely NAMES (D-08-17). With
296
- * `git.branching_strategy: none` — pp-core's default — `main...HEAD` is
297
- * empty whenever HEAD sits at or behind the base, so this is the steady
298
- * state of a default-config project, not an edge case.
299
- */
300
- RANGE_UNRESOLVABLE: 'range_unresolvable',
301
- });
302
- /**
303
- * The ONE predicate that decides which `AUDIT_RANGE_OUTCOME` a comparison
304
- * earned (08-04, D-08-17 / D-08-18). Pure: same inputs, same answer, no git.
305
- *
306
- * EXPORTED on purpose. The review tier (plan 08-05) consults THIS function
307
- * rather than re-deriving the cell from `rangeSource` and a file count of its
308
- * own — two predicates for one claim are two claims, and they diverge on the
309
- * day one of them is edited (the 04-18 lesson, and exactly how the audit and
310
- * the review each came to own a range resolver that failed the same way).
311
- *
312
- * FAIL-CLOSED on a malformed count: anything that is not a non-negative
313
- * integer is normalised to 0, which on a non-phase-scoped range lands on
314
- * `range_unresolvable` — the outcome that blocks. A count we cannot trust must
315
- * never buy the audited reading.
316
- */
317
- function classifyRangeOutcome(input) {
318
- const raw = input.filesConsidered;
319
- const files = typeof raw === 'number' && Number.isInteger(raw) && raw > 0 ? raw : 0;
320
- if (input.phaseScoped)
321
- return AUDIT_RANGE_OUTCOME.PHASE_SCOPED;
322
- return files > 0 ? AUDIT_RANGE_OUTCOME.FALLBACK_AUDITED : AUDIT_RANGE_OUTCOME.RANGE_UNRESOLVABLE;
323
- }
324
- /**
325
- * Is this range source one that ties the range to the phase? (08-04.)
326
- *
327
- * Stated as "everything but the base-branch fallback", in that direction and
328
- * not as an allow-list, because that is exactly the shape of WR-04's own
329
- * predicate — `rangeSource === AUDIT_RANGE_SOURCE.BASE_BRANCH` — and 08-04
330
- * must not change its truth table (D-08-18). Written once so the three
331
- * `runVaultAudit` arms cannot answer the question differently.
332
- */
333
- function isPhaseScopedSource(rangeSource) {
334
- return rangeSource !== AUDIT_RANGE_SOURCE.BASE_BRANCH;
335
- }
336
- /**
337
- * Why `recordPhaseStart` / `recordPhaseCommits` wrote nothing (260915-ik2).
338
- *
339
- * Two members REUSE the reader's strings, deliberately: a phase slug the
340
- * writer cannot key and a manifest it refuses to overwrite are the same facts
341
- * the reader reports on the same file, and two spellings of one fact is the
342
- * drift this module's frozen vocabularies exist to prevent.
343
- */
344
- const PHASE_MANIFEST_WRITE_REASON = Object.freeze({
345
- /** The phase directory is not an existing directory inside the project root. */
346
- PHASE_DIR_UNRESOLVABLE: 'phase-dir-unresolvable',
347
- /** The phase directory's name does not begin with an `NN` or `NN.M` token. */
348
- PHASE_SLUG_UNRECOGNISED: AUDIT_FALLBACK_REASON.PHASE_SLUG_UNRECOGNISED,
349
- /** git could not name the commit HEAD points at (an unborn branch, or no repository). */
350
- HEAD_UNRESOLVABLE: 'head-unresolvable',
351
- /** The manifest exists and is not a JSON object with an object `phases`; it is left untouched. */
352
- MANIFEST_MALFORMED: AUDIT_FALLBACK_REASON.MANIFEST_MALFORMED,
353
- /** `record` found no start commit for this phase — `start` never ran for it. */
354
- START_NOT_RECORDED: 'start-not-recorded',
355
- /** `record` found a start commit that git does not verify. */
356
- START_UNVERIFIABLE: 'start-unverifiable',
357
- /**
358
- * `start` found no anchor for this phase, and its directory already holds a
359
- * `*-SUMMARY.md`: the phase has been executed, so HEAD sits AFTER its own
360
- * commits and an anchor there would scope the audit past them (R2). Nothing
361
- * is written; the SUMMARY trace-digest rung is the range source instead.
362
- */
363
- PHASE_ALREADY_EXECUTED: 'phase-already-executed',
364
- /** The atomic write (temp file + rename) failed; the temp file was removed. */
365
- WRITE_FAILED: 'write-failed',
366
- });
367
- /**
368
- * The bounded WHOLE-file read for a content check (1 MiB).
369
- *
370
- * Over-size is a FAIL, never a partial scan, and the direction matters in both
371
- * assertion kinds (T-5-14): a truncated read can miss a `forbid` token that
372
- * sits past the cut and false-PASS the check, and can miss a `require` token
373
- * that sits past the cut and false-FAIL it. Reading changed-file CONTENT is a
374
- * new I/O axis that none of the six pre-existing caps governed, so it gets its
375
- * own bound rather than borrowing one that means something else.
376
- */
377
- const AUDIT_FILE_READ_MAX = 1048576;
378
- /** How many FAILing practices the gate message itemizes before it counts the rest. */
379
- const AUDIT_MESSAGE_PROBLEM_CAP = 20;
380
- /**
381
- * The changed-file cap. A leaf-local constant that MIRRORS `SHIP_DIFF_FILE_CAP`
382
- * in `src/check-command-router.cts` — the precedent it copies — deliberately
383
- * rather than importing it: that constant is not exported, and importing an
384
- * adapter from a leaf is the cycle D-12 forbids. Over the cap the list is
385
- * truncated with a COUNTED note, never silently.
386
- */
387
- const AUDIT_DIFF_FILE_CAP = 2000;
388
- /**
389
- * Git's well-known empty-tree object id. Diffing against it includes a root
390
- * commit's own files, which is the degenerate case a phase whose first commit
391
- * IS the repository root would otherwise crash on.
392
- */
393
- const GIT_EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
394
- /**
395
- * The gate message's closing REPAIR sentence.
396
- *
397
- * Gate details are agent-EXECUTABLE — an orchestrator reading this may perform
398
- * what it names — and `.planning/` is gitignored in projects like this one, so
399
- * a deletion instruction is unrecoverable (T-4-75). Every sentence this module
400
- * emits names a repair and never a removal, and the repair is the CLOSING
401
- * sentence so no truncation above it can ever cut the remedy off.
402
- */
403
- const AUDIT_REPAIR_SENTENCE = 'Repair: change the named files so they satisfy the practice, or correct that practice’s check: block in the client vault and re-fetch the vault.';
404
- /** The diff arm's repair. Also a repair: nothing anywhere is removed. */
405
- const AUDIT_DIFF_REPAIR_SENTENCE = 'Repair: run this audit from inside the repository’s checkout and confirm `git status` succeeds there, then run it again.';
406
- /**
407
- * The refused-operator-range arm's repair (08-04, D-08-19).
408
- *
409
- * It names the SHAPE rather than quoting what was supplied: the value failed
410
- * validation, so echoing it would put an unvalidated string into a gate message
411
- * an orchestrating agent reads verbatim — the one channel this module's own
412
- * header calls agent-EXECUTABLE. A repair, never a removal, like its three
413
- * siblings.
414
- */
415
- const AUDIT_OPERATOR_RANGE_REPAIR_SENTENCE = 'Repair: re-run the audit with a well-formed two-dot or three-dot range such as `main..HEAD` or `<sha>^..HEAD`, or omit the range entirely and let the phase-range ladder resolve it.';
416
- /**
417
- * The vault arm's repair (05-10). Also a repair, and for a sharper reason than
418
- * the other two: the thing that is missing here is the CLIENT VAULT, and the
419
- * cache it would be restored into lives under `.planning/`, which is gitignored
420
- * in projects like this one. An instruction to remove anything on this path is
421
- * therefore unrecoverable, so this sentence names re-fetching and re-running
422
- * and nothing else (T-4-75).
423
- */
424
- const AUDIT_VAULT_REPAIR_SENTENCE = 'Repair: re-fetch the client vault with `pp-tools vault fetch`, confirm `.planning/vault` is readable, then run this audit again.';
425
- /**
426
- * The standards-only arm's repair (quick 261015-at3). A sibling of the vault
427
- * arm's sentence and deliberately not that sentence: re-fetching reproduces
428
- * the same standards vault, so the only repair is the mode. A repair, never a
429
- * removal (T-4-75).
430
- */
431
- const AUDIT_STANDARDS_ONLY_REPAIR_SENTENCE = 'Repair: set "vault": {"mode": "standards"} in .planning/config.json (`pp-tools config-set vault.mode standards`, pp-core 1.16.0 or later), re-plan the phase, then run this audit again.';
432
- // ─── Shape predicates ─────────────────────────────────────────────────────────
433
- //
434
- // SHAPE-PREDICATES-BEGIN
435
- //
436
- // Every predicate in this region compares its argument against one of this
437
- // module's own frozen verdict constants and nothing else. No regular
438
- // expression, no text-search primitive, no character-code probe, no inline
439
- // literal may appear here or reach a boundary decision anywhere in this file —
440
- // ADR-71924 §3 states the prohibition and `eslint-rules/practices-boundary-inputs.cjs`,
441
- // wired at this file in `eslint.config.mjs`, is what keeps it: a future edit
442
- // reintroducing one fails `npm run lint`, not a review.
443
- //
444
- // The matching and token-testing functions sit deliberately OUTSIDE this region
445
- // and outside the configured boundary-function set. `globMatch` reaches
446
- // `.replace()` and `.test()` and a content token is tested with `.includes()` —
447
- // all three are banned in here, which is exactly why the verdict decision and
448
- // the text search are kept as separate, separately-audited surfaces.
449
- /** Is this verdict the satisfied one? */
450
- function isPassVerdict(verdict) {
451
- return verdict === AUDIT_VERDICT.PASS;
452
- }
453
- /** Is this verdict a failure — the only verdict that blocks? */
454
- function isFailVerdict(verdict) {
455
- return verdict === AUDIT_VERDICT.FAIL;
456
- }
457
- /** Is this the vault-coverage gap — a practice that carries no check block at all? */
458
- function isNoCheckVerdict(verdict) {
459
- return verdict === AUDIT_VERDICT.NO_CHECK;
460
- }
461
- /** Is this a live check whose globs matched nothing in this diff? */
462
- function isNotMatchedVerdict(verdict) {
463
- return verdict === AUDIT_VERDICT.NOT_MATCHED;
464
- }
465
- //
466
- // SHAPE-PREDICATES-END
467
- /**
468
- * The frozen registry of every predicate declared in the region above, in
469
- * declaration order. Its consumer is the lint rule's registry-drift check,
470
- * which compares this array against the region's own declarations in BOTH
471
- * directions: a predicate declared there and not registered here, or registered
472
- * here and not declared there, fails lint rather than review.
473
- */
474
- const SHAPE_PREDICATE_NAMES = Object.freeze([
475
- 'isPassVerdict',
476
- 'isFailVerdict',
477
- 'isNoCheckVerdict',
478
- 'isNotMatchedVerdict',
479
- ]);
480
- /**
481
- * The verdict ladder itself — the one place a verdict is chosen.
482
- *
483
- * ORDER IS LOAD-BEARING. `hasInvalidCheck` is tested FIRST and independently of
484
- * every cost bound, because an invalid check block is a fact about the practice
485
- * rather than about the comparison: it needs no matching, so neither the pair
486
- * cap nor the wall-clock budget can change it, and folding it into `NO_CHECK`
487
- * would let a typo in the vault buy a green verdict (05-01's stated contract).
488
- */
489
- function verdictForPractice(state) {
490
- if (state.hasInvalidCheck)
491
- return AUDIT_VERDICT.FAIL;
492
- if (!state.hasCheck)
493
- return AUDIT_VERDICT.NO_CHECK;
494
- if (state.failed)
495
- return AUDIT_VERDICT.FAIL;
496
- if (state.matchedNothing)
497
- return AUDIT_VERDICT.NOT_MATCHED;
498
- return AUDIT_VERDICT.PASS;
499
- }
500
- /**
501
- * Count the four verdicts through the registered predicates and nothing else,
502
- * and derive the run-level coverage facts from those counts.
503
- *
504
- * THE DERIVATION LIVES HERE ON PURPOSE (D-10-09, settled by planting and
505
- * running rather than by argument). `countVerdicts` is already one of the three
506
- * `boundaryFunctions` reviewed in `eslint.config.mjs`, so the derivation is
507
- * AUDITED where it sits. A new helper called by name from a boundary function
508
- * fails `local/practices-boundary-inputs`; a new decider called only from
509
- * `runVaultAudit` passes the rule and is entirely UNAUDITED by it, which is the
510
- * shape not to take. The rule flags string and template operands of a
511
- * comparison but not numeric ones, which is why counting arithmetic is
512
- * admissible in an audited body.
513
- */
514
- function countVerdicts(entries) {
515
- const counts = {
516
- pass: 0,
517
- fail: 0,
518
- noCheck: 0,
519
- notMatched: 0,
520
- mechanicallyChecked: 0,
521
- applicablePractices: 0,
522
- evaluatedNothing: true,
523
- };
524
- for (const entry of entries) {
525
- if (isPassVerdict(entry.verdict))
526
- counts.pass += 1;
527
- else if (isFailVerdict(entry.verdict))
528
- counts.fail += 1;
529
- else if (isNoCheckVerdict(entry.verdict))
530
- counts.noCheck += 1;
531
- else if (isNotMatchedVerdict(entry.verdict))
532
- counts.notMatched += 1;
533
- }
534
- counts.mechanicallyChecked = counts.pass + counts.fail;
535
- counts.applicablePractices = counts.pass + counts.fail + counts.noCheck;
536
- counts.evaluatedNothing = counts.mechanicallyChecked === 0;
537
- return counts;
538
- }
539
- /**
540
- * The run-level outcome token for an audit that mechanically checked nothing
541
- * (MECH-02, D-10-01), in the frozen style of this file's other vocabularies.
542
- *
543
- * ONE member, and it is NOT an `AUDIT_VERDICT` member and NOT an entry in the
544
- * lint rule's `allowedConstants`: it is a run-level fact, never a per-practice
545
- * verdict, and it is only ever interpolated into a note — never compared inside
546
- * a boundary function. `AUDIT_RANGE_OUTCOME` is the precedent, on this same
547
- * envelope, for exactly that placement.
548
- *
549
- * EXPORTED so tests and the review tier HARVEST the token rather than copying
550
- * the literal — a copied token stops guarding the day the shipped one moves.
551
- */
552
- const AUDIT_COVERAGE_OUTCOME = Object.freeze({
553
- /**
554
- * Not one applicable practice reached a PASS or a FAIL. The vault was read,
555
- * the diff was read, and no `check:` block was compared against any file —
556
- * so nothing was verified mechanically and no passing claim can be drawn
557
- * from the run. Stated, never blocked (D-10-02).
558
- */
559
- EVALUATED_NOTHING: 'evaluated_nothing',
560
- });
561
- /**
562
- * The counts-only coverage line (D-10-03, D-10-18), modelled on
563
- * `renderRateLine` (`src/vault-review.cts:845`) and inheriting its two rules
564
- * verbatim:
565
- *
566
- * - the figures are DERIVED from the four verdict counts and a supplied `n`
567
- * or `m` is deliberately ignored, so a caller cannot narrate a denominator
568
- * the rows do not support;
569
- * - counts only. No ratio, no percentage, no letter grade, no single roll-up
570
- * — one number invites being quoted as a measured-sounding compliance claim,
571
- * which is the over-claim this milestone exists to prevent.
572
- *
573
- * EXPORTED so every surface harvests ONE renderer. A second renderer is a
574
- * second place the surfaces can come to disagree about the same fact.
575
- */
576
- function renderMechanicalCoverageLine(counts) {
577
- const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
578
- const pass = n(counts?.pass);
579
- const fail = n(counts?.fail);
580
- const noCheck = n(counts?.noCheck);
581
- const notMatched = n(counts?.notMatched);
582
- const checked = pass + fail;
583
- const applicable = pass + fail + noCheck;
584
- return (`${checked} of ${applicable} practices mechanically checked · ` +
585
- `${pass} PASS · ${fail} FAIL · ${notMatched} NOT_MATCHED · ${noCheck} NO_CHECK`);
586
- }
587
- /**
588
- * Does this verdict set halt the gate? A blocking decision made on client data,
589
- * so it is audited: only a FAIL blocks, and it is emitted as a strict boolean
590
- * because the enforcer tests `.block === true`.
591
- */
592
- function blocksOnVerdicts(entries) {
593
- for (const entry of entries) {
594
- if (isFailVerdict(entry.verdict))
595
- return true;
596
- }
597
- return false;
598
- }
599
- // ─── Matching and token testing (deliberately NOT boundary functions) ─────────
600
- /**
601
- * Which of `files` sit inside this practice's `applies-to` ∩ `check.files`
602
- * intersection (MECH-01, D-10-12).
603
- *
604
- * A file is matched when it matches at least one `check.files` glob AND the
605
- * practice applies to it. Before 10-01 only the first half existed, so a
606
- * practice whose `applies-to` named only Vue files reported a FAIL against a
607
- * `.cs` file — governance the practice never claimed.
608
- *
609
- * `isSupportedGlob` gates each pattern BY REFERENCE — a pattern the shipped
610
- * gate rejects matches nothing here rather than being interpreted by some
611
- * second, more permissive reading of the same string. `globMatch` does the
612
- * matching; this module never writes one. That holds for the applies-to side
613
- * exactly as it already held for the check side (ADR-71924 §2 and §3).
614
- */
615
- function matchCheckFiles(check, files, appliesTo) {
616
- const scope = Array.isArray(appliesTo) ? appliesTo : [];
617
- // THE FAIL-OPEN THIS BRANCH EXISTS TO AVOID. An ABSENT or EMPTY applies-to
618
- // means APPLIES EVERYWHERE, so the scope test is skipped rather than run
619
- // against nothing. A `scope: global` practice carries an EMPTY `appliesTo`
620
- // by construction (`src/vault-practices.cts:1034` refuses a global practice
621
- // that declares globs), so reading empty as *matches nothing* would silently
622
- // un-govern every global practice in the fleet — a fail-open on the exact
623
- // axis this milestone exists to close. The shipped `matchPractices`
624
- // (`src/vault-practices.cts:1474`) already answers this question the same
625
- // way in its `scope === 'scoped'` else-branch; this agrees with it rather
626
- // than inventing a second answer.
627
- const scoped = scope.length > 0;
628
- const matched = [];
629
- for (const file of files) {
630
- if (scoped) {
631
- let inScope = false;
632
- for (const glob of scope) {
633
- if (!isSupportedGlob(glob))
634
- continue;
635
- if (globMatch(file, glob)) {
636
- inScope = true;
637
- break;
638
- }
639
- }
640
- if (!inScope)
641
- continue;
642
- }
643
- for (const glob of check.files) {
644
- if (!isSupportedGlob(glob))
645
- continue;
646
- if (globMatch(file, glob)) {
647
- matched.push(file);
648
- break;
649
- }
650
- }
651
- }
652
- return matched;
653
- }
654
- /**
655
- * Does `content` contain the literal token?
656
- *
657
- * Plain substring containment, and nothing else, ever. The token is a
658
- * client-authored byte sequence from the vault repository; ADR-71924 §3 bars it
659
- * from every pattern compiler, so a token that LOOKS like a pattern is tested
660
- * as the literal text it is.
661
- */
662
- function contentHasToken(content, token) {
663
- return content.includes(token);
664
- }
665
- // ─── The evaluator ────────────────────────────────────────────────────────────
666
- function boundedList(values) {
667
- return values.slice(0, MATCHED_FILES_CAP).map((v) => boundedToken(v));
668
- }
669
- // ─── The analyzer-provenance clause (MECH-03, D-10-05) ───────────────────────
670
- /**
671
- * The lead of the `enforced-by:` clause, and the ONLY wording this module
672
- * composes for it.
673
- *
674
- * "also enforced by" and NOT "verified by". pp-core runs no analyzer and
675
- * confirms no analyzer's result, so the sentence surfaced is a claim about
676
- * what the PRACTICE SAYS, attributable to the vault that wrote it — never a
677
- * claim about a run that happened here. A confirmation-shaped wording would be
678
- * a record asserting more governance than was granted, which is the exact
679
- * over-claim this milestone exists to end.
680
- *
681
- * EXPORTED on the same harvest rule as `AUDIT_REPAIR_SENTENCE` below: a test
682
- * that copies the wording it asserts stops guarding the day the wording is
683
- * reworded.
684
- */
685
- const AUDIT_ENFORCED_BY_LEAD = 'also enforced by';
686
- /**
687
- * Compose the clause for one practice's `enforced-by:` list, or `''` when
688
- * there is nothing to say.
689
- *
690
- * DELIBERATELY NOT A BOUNDARY FUNCTION and deliberately not named in
691
- * `eslint.config.mjs`'s `boundaryFunctions` (D-10-09): composing a display
692
- * string is not deciding on client data. Nothing here reads a verdict, and no
693
- * caller's verdict changes because of what it returns.
694
- *
695
- * Each rule name is client-authored. `boundedList` re-applies `boundedToken`
696
- * AND the list cap at this interpolation site even though plan 10-03's parser
697
- * already bounded the value — `boundedToken` at every interpolation is the
698
- * shipped discipline (T-24-07), and a bound that exists once is a bound that
699
- * can be removed once.
700
- */
701
- function enforcedByClause(enforcedBy) {
702
- if (!Array.isArray(enforcedBy))
703
- return '';
704
- const named = enforcedBy.filter((r) => typeof r === 'string' && r.length > 0);
705
- if (named.length === 0)
706
- return '';
707
- return `${AUDIT_ENFORCED_BY_LEAD} ${boundedList(named).join(', ')}`;
708
- }
709
- /**
710
- * The entry, with the vault's analyzer claim APPENDED to whatever detail the
711
- * verdict already carries — never replacing it.
712
- *
713
- * The separator is the one the coverage line already uses, and the order is
714
- * load-bearing: an operator reading a FAIL line is reading it for the reason
715
- * the gate blocked, and a provenance clause that displaced that reason would
716
- * bury the only actionable half of the line behind a claim nobody ran.
717
- *
718
- * Never reaches `notes`. `noteLine`'s length bound was removed on the stated
719
- * property that no client byte reaches a note (see its comment); putting one
720
- * there would break that property rather than merely stretch it.
721
- */
722
- function withEnforcedBy(entry, enforcedBy) {
723
- const clause = enforcedByClause(enforcedBy);
724
- if (clause === '')
725
- return entry;
726
- return { ...entry, detail: entry.detail ? `${entry.detail} · ${clause}` : clause };
727
- }
728
- /** A reader that delivers nothing — the fail-closed default (never a silent read). */
729
- const NO_READER = () => ({ ok: false, reason: 'no file reader was provided' });
730
- /**
731
- * Run one check's content assertions over its matched files.
732
- *
733
- * Returns `{ reason: null }` when satisfied. A file that cannot be delivered
734
- * WHOLE (missing, unreadable, or longer than `AUDIT_FILE_READ_MAX`) is
735
- * `file_unreadable` — never a partial scan, never a silent pass.
736
- */
737
- function evaluateContent(check, matched, readFile) {
738
- const requireTokens = check.require ?? [];
739
- const forbidTokens = check.forbid ?? [];
740
- for (const file of matched) {
741
- let read;
742
- try {
743
- read = readFile(file);
744
- }
745
- catch (e) {
746
- read = { ok: false, reason: e?.message };
747
- }
748
- if (!read || read.ok !== true || typeof read.content !== 'string') {
749
- const why = read && read.ok === false && read.reason ? boundedToken(read.reason) : 'could not be read';
750
- return {
751
- reason: AUDIT_FAIL_REASON.FILE_UNREADABLE,
752
- detail: `${boundedToken(file)} ${why}`,
753
- };
754
- }
755
- if (read.content.length > AUDIT_FILE_READ_MAX) {
756
- return {
757
- reason: AUDIT_FAIL_REASON.FILE_UNREADABLE,
758
- detail: `${boundedToken(file)} is larger than the ${AUDIT_FILE_READ_MAX}-byte whole-file read bound, so it was not scanned`,
759
- };
760
- }
761
- for (const token of requireTokens) {
762
- if (!contentHasToken(read.content, token)) {
763
- return {
764
- reason: AUDIT_FAIL_REASON.UNSATISFIED,
765
- detail: `${boundedToken(file)} does not contain the required token "${boundedToken(token)}"`,
766
- };
767
- }
768
- }
769
- for (const token of forbidTokens) {
770
- if (contentHasToken(read.content, token)) {
771
- return {
772
- reason: AUDIT_FAIL_REASON.UNSATISFIED,
773
- detail: `${boundedToken(file)} contains the forbidden token "${boundedToken(token)}"`,
774
- };
775
- }
776
- }
777
- }
778
- return { reason: null, detail: '' };
779
- }
780
- /**
781
- * Evaluate every discovered practice against the changed-file list.
782
- *
783
- * PURE, with both side-effecting inputs injected: `readFile` is the whole-file
784
- * reader and `now` is a monotonic clock. `now` is called exactly ONCE at entry
785
- * and ONCE per practice, which is what lets the budget arm be driven
786
- * deterministically by a counting clock instead of by a sleep.
787
- *
788
- * THE DENOMINATOR IS EVERY DISCOVERED PRACTICE AND EVERY FILE DISCOVERY COULD
789
- * NOT LOAD. A practice never disappears from the result; a coverage gap shows
790
- * up as `NO_CHECK` rather than as an absence, because an absence is
791
- * indistinguishable from a practice the evaluator quietly dropped.
792
- *
793
- * The second clause is 05-10's (G-1b). This function only ever sees practices
794
- * that PARSED, so the rejected files are joined on by `runVaultAudit` as
795
- * `FAIL(practice_unloadable)` records — see `unloadableEntries`. Until then the
796
- * sentence above was written as though it held and did not: a one-character
797
- * `severity:` typo left the file out of the envelope entirely, which is the
798
- * exact absence this contract forbids.
799
- */
800
- function evaluatePractices(opts) {
801
- const practices = Array.isArray(opts.practices) ? opts.practices : [];
802
- const files = Array.isArray(opts.files) ? opts.files : [];
803
- const readFile = typeof opts.readFile === 'function' ? opts.readFile : NO_READER;
804
- const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
805
- // The DETERMINISTIC guard, decided before any matching runs (D-05-06,
806
- // pattern 2). `pairs` is arithmetic on counts, so a refusal is reproducible
807
- // on any machine — unlike a wall-clock threshold, which would make the
808
- // verdict a property of the laptop and is exactly the non-determinism
809
- // VERIFY-03 bars from a deterministic gate. The cap itself is IMPORTED from
810
- // the shared matcher: one pair of constants, all call sites.
811
- let checkGlobCount = 0;
812
- for (const practice of practices) {
813
- if (practice.check)
814
- checkGlobCount += practice.check.files.length;
815
- }
816
- const pairs = checkGlobCount * files.length;
817
- const pairCapExceeded = pairs > MATCH_PAIR_CAP;
818
- const start = now();
819
- let budgetExhausted = false;
820
- let pairsEvaluated = 0;
821
- const entries = [];
822
- // ONE composition point for the analyzer claim (MECH-03, D-10-05). Every arm
823
- // below pushes through here rather than appending the clause itself, so no
824
- // arm can be the one that quietly drops it — the same reason `countVerdicts`
825
- // is called once per return arm rather than restated per arm.
826
- const pushEntry = (practice, entry) => {
827
- entries.push(withEnforcedBy(entry, practice.enforcedBy));
828
- };
829
- for (const practice of practices) {
830
- const elapsed = now() - start;
831
- if (!pairCapExceeded && elapsed >= MATCH_BUDGET_MS)
832
- budgetExhausted = true;
833
- const id = boundedToken(practice.id);
834
- const hasInvalidCheck = typeof practice.checkInvalid === 'string' && practice.checkInvalid.length > 0;
835
- const check = practice.check;
836
- const hasCheck = Boolean(check);
837
- if (hasInvalidCheck) {
838
- pushEntry(practice, {
839
- id,
840
- verdict: verdictForPractice({ hasInvalidCheck: true, hasCheck, failed: true, matchedNothing: false }),
841
- reason: AUDIT_FAIL_REASON.CHECK_INVALID,
842
- detail: boundedToken(practice.checkInvalid),
843
- matchedFiles: [],
844
- matchedCount: 0,
845
- });
846
- continue;
847
- }
848
- if (!check) {
849
- pushEntry(practice, {
850
- id,
851
- verdict: verdictForPractice({ hasInvalidCheck: false, hasCheck: false, failed: false, matchedNothing: false }),
852
- reason: null,
853
- detail: '',
854
- matchedFiles: [],
855
- matchedCount: 0,
856
- });
857
- continue;
858
- }
859
- if (pairCapExceeded) {
860
- pushEntry(practice, {
861
- id,
862
- verdict: verdictForPractice({ hasInvalidCheck: false, hasCheck: true, failed: true, matchedNothing: false }),
863
- reason: AUDIT_FAIL_REASON.PAIR_CAP_EXCEEDED,
864
- detail: `this vault and change set produce ${pairs} (glob, file) pair(s), over the ${MATCH_PAIR_CAP}-pair cap — this practice was never compared against the diff`,
865
- matchedFiles: [],
866
- matchedCount: 0,
867
- });
868
- continue;
869
- }
870
- if (budgetExhausted) {
871
- pushEntry(practice, {
872
- id,
873
- verdict: verdictForPractice({ hasInvalidCheck: false, hasCheck: true, failed: true, matchedNothing: false }),
874
- reason: AUDIT_FAIL_REASON.BUDGET_EXHAUSTED,
875
- detail: `the ${MATCH_BUDGET_MS}ms evaluation budget was exhausted before this practice was reached, so it was never compared against the diff`,
876
- matchedFiles: [],
877
- matchedCount: 0,
878
- });
879
- continue;
880
- }
881
- // MECH-01 (D-10-12): the practice's own applies-to bounds the comparison,
882
- // beside `check.files`.
883
- const matched = matchCheckFiles(check, files, practice.appliesTo);
884
- // DELIBERATELY NOT NARROWED BY applies-to. `pairs`, `MATCH_PAIR_CAP` and
885
- // this counter are arithmetic on counts decided before any matching runs,
886
- // and that is what makes a cap refusal reproducible on any machine
887
- // (D-05-06). Narrowing it by applies-to would make the refusal a property
888
- // of the practice set rather than of the work — a different decision, and
889
- // not one 10-01 takes.
890
- pairsEvaluated += check.files.length * files.length;
891
- // `forbidMatch` asserts the matched set must be EMPTY, so a match IS the
892
- // violation and no content is inspected. The asymmetry below is deliberate:
893
- // an empty match set is a PASS here, not NOT_MATCHED, because the assertion
894
- // was evaluated over the whole diff and HELD. NOT_MATCHED means "this check
895
- // had nothing to look at", which is a different fact from "this check
896
- // looked at everything and found nothing forbidden".
897
- if (check.forbidMatch === true) {
898
- const violated = matched.length > 0;
899
- pushEntry(practice, {
900
- id,
901
- verdict: verdictForPractice({
902
- hasInvalidCheck: false,
903
- hasCheck: true,
904
- failed: violated,
905
- matchedNothing: false,
906
- }),
907
- reason: violated ? AUDIT_FAIL_REASON.UNSATISFIED : null,
908
- detail: violated
909
- ? `${matched.length} file(s) match a forbidden path pattern: ${boundedList(matched).join(', ')}`
910
- : '',
911
- matchedFiles: boundedList(matched),
912
- matchedCount: matched.length,
913
- });
914
- continue;
915
- }
916
- if (matched.length === 0) {
917
- pushEntry(practice, {
918
- id,
919
- verdict: verdictForPractice({
920
- hasInvalidCheck: false,
921
- hasCheck: true,
922
- failed: false,
923
- matchedNothing: true,
924
- }),
925
- reason: null,
926
- detail: '',
927
- matchedFiles: [],
928
- matchedCount: 0,
929
- });
930
- continue;
931
- }
932
- const outcome = evaluateContent(check, matched, readFile);
933
- pushEntry(practice, {
934
- id,
935
- verdict: verdictForPractice({
936
- hasInvalidCheck: false,
937
- hasCheck: true,
938
- failed: outcome.reason !== null,
939
- matchedNothing: false,
940
- }),
941
- reason: outcome.reason,
942
- detail: outcome.detail,
943
- matchedFiles: boundedList(matched),
944
- matchedCount: matched.length,
945
- });
946
- }
947
- return {
948
- practices: entries,
949
- pairsEvaluated,
950
- pairCap: MATCH_PAIR_CAP,
951
- budgetMs: MATCH_BUDGET_MS,
952
- pairCapExceeded,
953
- budgetExhausted,
954
- };
955
- }
956
- // ─── Diff scoping (D-05-08) ───────────────────────────────────────────────────
957
- /** How far up from a phase directory the manifest walk may climb. */
958
- const MANIFEST_WALK_MAX = 12;
959
- /** The manifest file `/pp-undo` already reads for exactly this purpose. */
960
- const PHASE_MANIFEST_FILE = '.phase-manifest.json';
961
- /**
962
- * An engine-authored guard over engine-written values.
963
- *
964
- * The shas come from `.phase-manifest.json` — written by `recordPhaseStart` /
965
- * `recordPhaseCommits` below, reached through `pp-tools vault manifest
966
- * start|record` from the execute-phase step files — and, on the trace-digest
967
- * rung, from the `gitCommit` digests in a phase's SUMMARY `vault:` blocks.
968
- * Both files are editable planning data and every sha is interpolated into a
969
- * git ARGV, where a leading `-` would be read as a flag rather than as a
970
- * revision. This is the one shape a sha may have. It is NOT the client-authored
971
- * path: no vault byte reaches this, so ADR-71924 §3 is untouched (it bans
972
- * compiling a CLIENT-authored byte into a pattern, which nothing here does).
973
- */
974
- const COMMIT_SHA_SHAPE = /^[0-9a-fA-F]{7,64}$/;
975
- /** The leading `NN` or `NN.M` token of a phase directory slug. */
976
- const PHASE_SLUG_SHAPE = /^(\d+(?:\.\d+)?)/;
977
- /**
978
- * Is this manifest entry stamped for a DIFFERENT phase directory? (R1)
979
- *
980
- * Entries are keyed by phase NUMBER, and `/pp-new-milestone
981
- * --reset-phase-numbers` reuses numbers, so a new milestone's `01-new` would
982
- * otherwise inherit `01-old`'s anchor and commits. The writers stamp
983
- * `phaseDir` with the directory basename; an entry whose stamp names another
984
- * basename belongs to another phase. An entry with NO stamp (written before
985
- * the stamp existed, or by hand) is not stale — it keeps the behaviour it had.
986
- */
987
- function isStaleEntry(entry, slug) {
988
- return typeof entry.phaseDir === 'string' && entry.phaseDir !== slug;
989
- }
990
- /** The one sentence a stale entry is disclosed with, shared by the reader and nothing else. */
991
- function staleEntryNote(key, recorded, slug) {
992
- return (`the phase manifest entry "${boundedToken(key)}" is stale: it was recorded for phase directory ` +
993
- `"${boundedToken(recorded)}", not "${boundedToken(slug)}" (a phase number reused after a milestone reset), ` +
994
- 'so its startCommit and commits were ignored');
995
- }
996
- /**
997
- * The commit list this phase's manifest entry records, plus the NAMED ground
998
- * on which there was none (05-10).
999
- *
1000
- * Walks UP from the phase directory and tries the manifest at each ancestor,
1001
- * nearest first, which covers both the flat `.planning/phases/NN-slug` layout
1002
- * and the partitioned `.planning/workstreams/<name>/phases/NN-slug` one without
1003
- * either being special-cased. Never throws: every failure at every step is a
1004
- * fallback to the base-branch range, which is a WIDER comparison, never a
1005
- * narrower one.
1006
- *
1007
- * The miss ground is recorded NEAREST-FIRST and never overwritten by a further
1008
- * ancestor: the manifest closest to the phase directory is the one an operator
1009
- * would go and look at, so it is the one worth naming.
1010
- *
1011
- * `startCommit` is ADDITIVE (260915-ik2): an entry may carry it beside
1012
- * `commits`, and the `{ phases: { "05": { commits: [...] } } }` shape
1013
- * `/pp-undo` reads stays valid — an entry with only `commits` resolves exactly
1014
- * as it did before the field existed.
1015
- */
1016
- function phaseCommitsFor(root, phaseDir) {
1017
- const phaseDirAbs = node_path_1.default.resolve(phaseDir);
1018
- const slug = node_path_1.default.basename(phaseDirAbs);
1019
- const slugMatch = PHASE_SLUG_SHAPE.exec(slug);
1020
- if (!slugMatch)
1021
- return { commits: [], miss: AUDIT_FALLBACK_REASON.PHASE_SLUG_UNRECOGNISED, startCommit: null, notes: [] };
1022
- const rawToken = slugMatch[1];
1023
- const notes = [];
1024
- // A directory named `05-x` and a manifest keyed `5` are the same phase; try
1025
- // both spellings rather than guessing which writer produced the file.
1026
- const keys = [rawToken, String(Number(rawToken))];
1027
- let current = node_path_1.default.dirname(phaseDirAbs);
1028
- const rootAbs = node_path_1.default.resolve(root);
1029
- // Nearest-first, and never widened once set — see the header note.
1030
- let miss = AUDIT_FALLBACK_REASON.MANIFEST_NOT_FOUND;
1031
- const recordMiss = (ground) => {
1032
- if (miss === AUDIT_FALLBACK_REASON.MANIFEST_NOT_FOUND)
1033
- miss = ground;
1034
- };
1035
- for (let depth = 0; depth < MANIFEST_WALK_MAX; depth += 1) {
1036
- const candidate = node_path_1.default.join(current, PHASE_MANIFEST_FILE);
1037
- if (node_fs_1.default.existsSync(candidate)) {
1038
- try {
1039
- const parsed = JSON.parse(node_fs_1.default.readFileSync(candidate, 'utf8'));
1040
- const phases = parsed && typeof parsed === 'object' ? parsed.phases : undefined;
1041
- if (phases && typeof phases === 'object') {
1042
- // A key present with an empty commit list is a DIFFERENT operator
1043
- // problem from a key that is not there at all, so the two grounds
1044
- // are separated rather than both reading "no entry".
1045
- // A STALE entry (R1) is another phase's record, so for this phase it
1046
- // is no entry at all: it counts as missing, and it is disclosed.
1047
- const usable = (key) => {
1048
- if (!Object.prototype.hasOwnProperty.call(phases, key))
1049
- return false;
1050
- const entry = phases[key];
1051
- return !(entry && typeof entry === 'object' && isStaleEntry(entry, slug));
1052
- };
1053
- const present = keys.some(usable);
1054
- recordMiss(present ? AUDIT_FALLBACK_REASON.NO_COMMITS_RECORDED : AUDIT_FALLBACK_REASON.PHASE_ENTRY_MISSING);
1055
- // Both fields are read across both key spellings before returning,
1056
- // so an anchor under `5` is not hidden by a commit list under `05`.
1057
- let startCommit = null;
1058
- let commits = null;
1059
- for (const key of keys) {
1060
- const entry = Object.prototype.hasOwnProperty.call(phases, key) ? phases[key] : undefined;
1061
- if (!entry)
1062
- continue;
1063
- if (typeof entry === 'object' && isStaleEntry(entry, slug)) {
1064
- const note = staleEntryNote(key, entry.phaseDir, slug);
1065
- if (!notes.includes(note))
1066
- notes.push(note);
1067
- continue;
1068
- }
1069
- const start = entry.startCommit;
1070
- if (startCommit === null && typeof start === 'string' && COMMIT_SHA_SHAPE.test(start)) {
1071
- startCommit = start;
1072
- }
1073
- const listed = entry.commits;
1074
- if (commits === null && Array.isArray(listed) && listed.length > 0) {
1075
- commits = listed.filter((c) => typeof c === 'string');
1076
- }
1077
- }
1078
- if (commits !== null)
1079
- return { commits, miss: null, startCommit, notes };
1080
- // An anchor with no commit list yet (execute:pre ran, verify:post has
1081
- // not) IS a record, so it carries no miss ground.
1082
- if (startCommit !== null)
1083
- return { commits: [], miss: null, startCommit, notes };
1084
- }
1085
- else {
1086
- recordMiss(AUDIT_FALLBACK_REASON.MANIFEST_MALFORMED);
1087
- }
1088
- }
1089
- catch {
1090
- // A malformed manifest is not a reason to invent a range; fall through
1091
- // to the base-branch fallback, which is over-broad but honest. The
1092
- // GROUND is recorded, because "the manifest you are looking at is not
1093
- // valid JSON" is a repairable fact and "there is no manifest" is not
1094
- // the same one.
1095
- recordMiss(AUDIT_FALLBACK_REASON.MANIFEST_MALFORMED);
1096
- }
1097
- }
1098
- const parent = node_path_1.default.dirname(current);
1099
- if (parent === current)
1100
- break;
1101
- if (current === rootAbs)
1102
- break;
1103
- current = parent;
1104
- }
1105
- return { commits: [], miss, startCommit: null, notes };
1106
- }
1107
- /**
1108
- * Does the phase manifest hold a usable entry for this phase directory? (16-03, D-20)
1109
- *
1110
- * The seal leaf's `phase_manifest` artifact kind asks THIS reader, so "this
1111
- * phase has a manifest record" means exactly what the audit's own range ladder
1112
- * reads: an entry under either key spelling, not stale (R1), carrying a
1113
- * `commits` list or a `startCommit`. A second walk in the leaf could disagree
1114
- * with this one about a stale or malformed manifest. Never throws.
1115
- */
1116
- function hasPhaseManifestEntry(root, phaseDir) {
1117
- try {
1118
- return phaseCommitsFor(root, phaseDir).miss === null;
1119
- }
1120
- catch {
1121
- return false;
1122
- }
1123
- }
1124
- /**
1125
- * Resolve the git range this audit compares, and record WHERE it came from.
1126
- *
1127
- * WHY A PHASE-SCOPED RANGE. This project runs `git.branching_strategy: none`,
1128
- * so `base...HEAD` spans every phase since the base — under which
1129
- * `NOT_MATCHED` becomes rare and a FAIL can name a file belonging to a
1130
- * DIFFERENT phase, which is the misleading verdict D-05-03's vocabulary exists
1131
- * to prevent (D-05-08). And when work is committed straight onto the base
1132
- * branch, `base...HEAD` is EMPTY — the UAT-6-F1 halt.
1133
- *
1134
- * A FOUR-RUNG LADDER, in this order and no other (260915-ik2):
1135
- *
1136
- * 1. `phase-start-commit` — the manifest entry's `startCommit`, the commit
1137
- * HEAD sat at when `/pp-execute-phase` began the phase (written at
1138
- * execute:pre by `recordPhaseStart`). The range is `<sha>..HEAD` with NO
1139
- * caret: the anchor's own tree is the baseline the phase started from.
1140
- * OLDER-OF (R2, defence in depth behind the writer's refusal): when the
1141
- * phase's SUMMARY digests also yield an anchor, and the ancestry-earliest
1142
- * digest is an ancestor of (or is) the start commit, the start commit was
1143
- * recorded AFTER the phase's own traced work — a late anchor that would
1144
- * put that work outside the range. The older, digest-derived range is
1145
- * returned instead, with source `trace-digest` (the source always names
1146
- * where the range STRING came from) and a note saying the recorded start
1147
- * commit postdates the phase's traced commits. A digest that is not an
1148
- * ancestor leaves the start commit in place, which is the normal flow. A
1149
- * comparison git cannot decide keeps the start commit — the ladder's
1150
- * stated precedence — and says the two could not be compared.
1151
- * 2. `phase-first-commit` — the entry's `commits` list, anchored on the
1152
- * parent of the first verifiable one. `recordPhaseCommits` records the
1153
- * list OLDEST-FIRST (`git rev-list --reverse`), so the first verifiable
1154
- * commit in recorded order is the earliest by construction. When that
1155
- * commit IS the repository root there is no parent, so the range anchors
1156
- * at git's empty-tree object — still `phase-first-commit`, because a
1157
- * phase-scoped range is exactly what was resolved.
1158
- * 3. `trace-digest` — for a phase built before the manifest writer existed:
1159
- * the parent of the ancestry-earliest verified `gitCommit` digest in the
1160
- * phase's SUMMARY `vault:` blocks (`traceDigestRange`). A digest records
1161
- * only a file's LAST commit at plan end, so this range may under-scope
1162
- * the phase, and it always says so in `notes`.
1163
- * 4. `base-branch` — `resolveBaseBranchResult(root)...HEAD`, exactly as
1164
- * before. It now fires only for a phase with no usable manifest entry AND
1165
- * no verifiable SUMMARY digest. The 5-tier ladder is never re-derived here
1166
- * (issue #1146 records what the naive `origin/HEAD` + `:-main` form
1167
- * cost), the RESULT form carries a refused `git.base_branch` out as
1168
- * `baseBranchRefused` (W16), and the trace-digest rung's notes travel
1169
- * with it so an operator can see the third rung was tried.
1170
- */
1171
- function resolvePhaseRange(opts) {
1172
- const exec = typeof opts.exec === 'function' ? opts.exec : shell_command_projection_cjs_1.execGitLocal;
1173
- const found = (() => {
1174
- try {
1175
- return phaseCommitsFor(opts.root, opts.phaseDir);
1176
- }
1177
- catch {
1178
- return { commits: [], miss: AUDIT_FALLBACK_REASON.MANIFEST_NOT_FOUND, startCommit: null, notes: [] };
1179
- }
1180
- })();
1181
- // A stale entry's disclosure (R1) leads every rung's notes: whichever range
1182
- // is returned, the operator learns that a recorded entry was set aside.
1183
- const withLead = (tail) => {
1184
- const all = [...found.notes, ...tail];
1185
- return all.length === 0 ? {} : { notes: all };
1186
- };
1187
- // The digest rung is consulted by rung 1 (older-of) and by rung 3; it is
1188
- // computed at most once, and only when one of them needs it.
1189
- let digestMemo = null;
1190
- const digestOutcome = () => (digestMemo ??= traceDigestRange({ root: opts.root, phaseDir: opts.phaseDir, exec }));
1191
- // Rung 1 — the phase's recorded start commit.
1192
- let startVerified = null;
1193
- try {
1194
- const start = found.startCommit;
1195
- if (start !== null && COMMIT_SHA_SHAPE.test(start)) {
1196
- const verified = exec(['rev-parse', '--verify', `${start}^{commit}`], { cwd: opts.root });
1197
- if (verified.exitCode === 0 && !verified.error)
1198
- startVerified = start;
1199
- }
1200
- }
1201
- catch {
1202
- // Fall through: an unverifiable anchor is not an anchor.
1203
- }
1204
- if (startVerified !== null) {
1205
- const start = startVerified;
1206
- const fromStart = { range: `${start}..HEAD`, rangeSource: AUDIT_RANGE_SOURCE.PHASE_START_COMMIT };
1207
- const digest = digestOutcome();
1208
- if (digest.range === null || digest.earliest === null)
1209
- return { ...fromStart, ...withLead([]) };
1210
- const earliest = digest.earliest;
1211
- // Exit 0: the digest is an ancestor of (or is) the start commit, so the
1212
- // start commit postdates it. Exit 1: it is not, the normal flow. Anything
1213
- // else — a git error, a throw — is undecided.
1214
- let digestIsOlder = null;
1215
- try {
1216
- const probe = exec(['merge-base', '--is-ancestor', earliest, start], { cwd: opts.root });
1217
- if (!probe.error && probe.exitCode === 0)
1218
- digestIsOlder = true;
1219
- else if (!probe.error && probe.exitCode === 1)
1220
- digestIsOlder = false;
1221
- }
1222
- catch {
1223
- digestIsOlder = null;
1224
- }
1225
- if (digestIsOlder === true) {
1226
- return {
1227
- range: digest.range,
1228
- rangeSource: AUDIT_RANGE_SOURCE.TRACE_DIGEST,
1229
- ...withLead([
1230
- `the recorded start commit ${start.slice(0, 12)} postdates the phase's own traced commits: SUMMARY digest ` +
1231
- `${earliest.slice(0, 12)} is ${earliest === start ? 'the start commit itself' : 'an ancestor of it'}, so ` +
1232
- `${start.slice(0, 12)}..HEAD would leave that work outside the range. The older, digest-derived range is ` +
1233
- 'used instead',
1234
- ...digest.notes,
1235
- ]),
1236
- };
1237
- }
1238
- if (digestIsOlder === null) {
1239
- return {
1240
- ...fromStart,
1241
- ...withLead([
1242
- `the recorded start commit ${start.slice(0, 12)} and SUMMARY digest ${earliest.slice(0, 12)} could not be ` +
1243
- 'compared by ancestry, so the start commit is used as recorded; if it was recorded after the phase\'s own ' +
1244
- 'commits, this range may under-scope the phase',
1245
- ]),
1246
- };
1247
- }
1248
- return { ...fromStart, ...withLead([]) };
1249
- }
1250
- // Rung 2 — the recorded commit list.
1251
- try {
1252
- for (const sha of found.commits) {
1253
- if (!COMMIT_SHA_SHAPE.test(sha))
1254
- continue;
1255
- const verified = exec(['rev-parse', '--verify', `${sha}^{commit}`], { cwd: opts.root });
1256
- if (verified.exitCode !== 0 || verified.error)
1257
- continue;
1258
- const parent = exec(['rev-parse', '--verify', `${sha}^`], { cwd: opts.root });
1259
- const anchor = parent.exitCode === 0 && !parent.error ? `${sha}^` : GIT_EMPTY_TREE;
1260
- // A resolved phase-scoped range carries NO fallbackReason: nothing fell
1261
- // back, so nothing claims to have.
1262
- return { range: `${anchor}..HEAD`, rangeSource: AUDIT_RANGE_SOURCE.PHASE_FIRST_COMMIT, ...withLead([]) };
1263
- }
1264
- }
1265
- catch {
1266
- // Fall through: an unresolvable phase range degrades to the next rung.
1267
- }
1268
- // Rung 3 — the SUMMARY trace digests.
1269
- const digest = digestOutcome();
1270
- if (digest.range !== null) {
1271
- return { range: digest.range, rangeSource: AUDIT_RANGE_SOURCE.TRACE_DIGEST, ...withLead(digest.notes) };
1272
- }
1273
- // Rung 4 — the base-branch fallback, unchanged.
1274
- let base = 'main';
1275
- let baseBranchRefused;
1276
- try {
1277
- // The RESULT form, not the bare string (W16). This is the only value on the
1278
- // audit's command line that comes from a file a cloned repository can carry,
1279
- // and a refusal that fell back to "main" in silence would leave the BLOCKING
1280
- // tier reporting a verdict computed over a range nobody configured.
1281
- const resolved = (0, git_base_branch_cjs_1.resolveBaseBranchResult)(opts.root, { execGit: exec });
1282
- base = resolved.branch;
1283
- if (resolved.configRejected !== null)
1284
- baseBranchRefused = resolved.configRejected.reason;
1285
- }
1286
- catch {
1287
- base = 'main';
1288
- }
1289
- return {
1290
- range: `${base}...HEAD`,
1291
- rangeSource: AUDIT_RANGE_SOURCE.BASE_BRANCH,
1292
- // Commits WERE recorded and git verified none of them — a distinct ground
1293
- // from anything the manifest walk itself could observe.
1294
- fallbackReason: found.miss ?? AUDIT_FALLBACK_REASON.COMMIT_UNVERIFIABLE,
1295
- ...(baseBranchRefused === undefined ? {} : { baseBranchRefused }),
1296
- ...withLead(digest.notes),
1297
- };
1298
- }
1299
- // ─── The trace-digest rung (260915-ik2, UAT-6-F1) ────────────────────────────
1300
- /** The `gitCommit` shape the trace builder records — 40- or 64-hex, lowercase. */
1301
- const TRACE_DIGEST_SHAPE = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;
1302
- /** At most this many distinct digests are verified and ranked, with a note when it bites. */
1303
- const TRACE_DIGEST_CANDIDATE_CAP = 64;
1304
- /**
1305
- * The phase range derived from the `gitCommit` digests in the phase's SUMMARY
1306
- * `vault:` blocks — the rung for phases built before the manifest writer.
1307
- *
1308
- * Every `*-SUMMARY.md` directly in the phase directory is read (sorted, each
1309
- * bounded by `AUDIT_FILE_READ_MAX`), parsed with the SHIPPED trace parser, and
1310
- * its subjects' digests collected when they have the builder's own shape; the
1311
- * wider `COMMIT_SHA_SHAPE` guard is applied again before any sha reaches argv
1312
- * (T-ik2-02). The distinct set is capped, then each is verified with
1313
- * `rev-parse --verify <sha>^{commit}`: an unverifiable digest is never an
1314
- * anchor.
1315
- *
1316
- * RANKING is `git rev-list --count <sha>` ascending, tie-broken by the sha
1317
- * string ascending — on a linear history the fewest ancestors is the oldest,
1318
- * and the tie-break keeps the answer deterministic. A VERIFIED digest whose
1319
- * count git cannot report is KEPT and ranked after every counted one: it is a
1320
- * real commit, so it may still anchor when nothing better exists, but an
1321
- * unknown position must never outrank a known one. That makes the `^{commit}`
1322
- * verification the sole trust gate on which shas may anchor — the count only
1323
- * orders shas that already passed it.
1324
- *
1325
- * The anchor is the earliest digest's parent, or git's empty-tree object when
1326
- * it has none. Never throws: every failure becomes a note, and when no digest
1327
- * survives the note states that no SUMMARY carried a verifiable gitCommit
1328
- * digest.
1329
- */
1330
- function traceDigestRange(opts) {
1331
- const notes = [];
1332
- const none = (why) => ({
1333
- range: null,
1334
- earliest: null,
1335
- notes: [
1336
- ...notes,
1337
- `no SUMMARY in the phase directory carried a verifiable gitCommit digest (${why}), so no trace-digest ` +
1338
- 'range could be derived',
1339
- ],
1340
- });
1341
- try {
1342
- const rootAbs = node_path_1.default.resolve(opts.root);
1343
- const phaseDirAbs = node_path_1.default.resolve(opts.phaseDir);
1344
- let names;
1345
- try {
1346
- names = node_fs_1.default
1347
- .readdirSync(phaseDirAbs)
1348
- .filter((name) => name.endsWith('-SUMMARY.md'))
1349
- .sort();
1350
- }
1351
- catch {
1352
- return none('the phase directory could not be listed');
1353
- }
1354
- if (names.length === 0)
1355
- return none('no *-SUMMARY.md file was found');
1356
- const candidates = [];
1357
- let capBit = false;
1358
- for (const name of names) {
1359
- const abs = node_path_1.default.join(phaseDirAbs, name);
1360
- try {
1361
- const stat = node_fs_1.default.statSync(abs);
1362
- if (!stat.isFile())
1363
- continue;
1364
- if (stat.size > AUDIT_FILE_READ_MAX) {
1365
- notes.push(`${boundedToken(name)} is over the ${AUDIT_FILE_READ_MAX}-byte read bound, so its trace digests were not read`);
1366
- continue;
1367
- }
1368
- const content = node_fs_1.default.readFileSync(abs, 'utf8');
1369
- const rel = node_path_1.default.relative(rootAbs, abs).split(node_path_1.default.sep).join('/');
1370
- const parsed = parseVaultTraceBlock(content, rel);
1371
- if (!parsed.present || !parsed.statement)
1372
- continue;
1373
- for (const subject of parsed.statement.subject ?? []) {
1374
- const sha = subject?.digest?.gitCommit;
1375
- if (typeof sha !== 'string' || !TRACE_DIGEST_SHAPE.test(sha) || !COMMIT_SHA_SHAPE.test(sha))
1376
- continue;
1377
- if (candidates.includes(sha))
1378
- continue;
1379
- if (candidates.length >= TRACE_DIGEST_CANDIDATE_CAP) {
1380
- capBit = true;
1381
- continue;
1382
- }
1383
- candidates.push(sha);
1384
- }
1385
- }
1386
- catch (err) {
1387
- notes.push(`${boundedToken(name)} could not be read (${errnoOf(err)}), so its trace digests were not read`);
1388
- }
1389
- }
1390
- if (capBit) {
1391
- notes.push(`more than ${TRACE_DIGEST_CANDIDATE_CAP} distinct gitCommit digests were recorded, so only the first ` +
1392
- `${TRACE_DIGEST_CANDIDATE_CAP} were considered`);
1393
- }
1394
- if (candidates.length === 0)
1395
- return none(`${names.length} SUMMARY file(s) read, no gitCommit digest recorded`);
1396
- const verified = candidates.filter((sha) => {
1397
- const probe = opts.exec(['rev-parse', '--verify', `${sha}^{commit}`], { cwd: opts.root });
1398
- return probe.exitCode === 0 && !probe.error;
1399
- });
1400
- if (verified.length === 0)
1401
- return none(`${candidates.length} digest(s) recorded, git verified none`);
1402
- const ranked = verified
1403
- .map((sha) => {
1404
- const counted = opts.exec(['rev-list', '--count', sha], { cwd: opts.root });
1405
- const n = counted.exitCode === 0 && !counted.error ? Number.parseInt(String(counted.stdout).trim(), 10) : NaN;
1406
- return { sha, count: Number.isFinite(n) ? n : Number.POSITIVE_INFINITY };
1407
- })
1408
- .sort((a, b) => {
1409
- if (a.count !== b.count)
1410
- return a.count < b.count ? -1 : 1;
1411
- return a.sha < b.sha ? -1 : a.sha > b.sha ? 1 : 0;
1412
- });
1413
- const earliest = ranked[0].sha;
1414
- const parent = opts.exec(['rev-parse', '--verify', `${earliest}^`], { cwd: opts.root });
1415
- const anchor = parent.exitCode === 0 && !parent.error ? `${earliest}^` : GIT_EMPTY_TREE;
1416
- notes.push(`phase range derived from SUMMARY trace digests: anchored on ${anchor === GIT_EMPTY_TREE ? "git's empty tree" : `the parent of ${earliest}`}, ` +
1417
- `the ancestry-earliest of ${verified.length} verified gitCommit digest(s). Each digest records only a file's ` +
1418
- 'LAST commit at plan end, so this range may under-scope the phase: an earlier phase commit whose files were ' +
1419
- 'committed again later sits outside it');
1420
- return { range: `${anchor}..HEAD`, earliest, notes };
1421
- }
1422
- catch (err) {
1423
- return none(`the SUMMARY trace digests could not be read: ${errnoOf(err)}`);
1424
- }
1425
- }
1426
- /**
1427
- * Where this phase's manifest lives, or the named ground on which it cannot.
1428
- *
1429
- * `phaseDir` resolves against `cwd`, then against `root`; the first that is an
1430
- * existing directory CONTAINED in the root wins (T-ik2-03), lexically or with
1431
- * symlinks resolved on both sides (a macOS temp root is `/var` and
1432
- * `/private/var` at once). The manifest sits two levels above the phase
1433
- * directory: `.planning/.phase-manifest.json` on the flat layout — the file
1434
- * `/pp-undo` reads — and `.planning/workstreams/<ws>/.phase-manifest.json` on
1435
- * a partitioned one, which the reader's walk finds at depth one and which keeps
1436
- * two workstreams' `05` entries apart.
1437
- */
1438
- function resolveManifestTarget(opts) {
1439
- const unresolvable = { ok: false, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_DIR_UNRESOLVABLE };
1440
- if (typeof opts?.root !== 'string' || opts.root === '')
1441
- return unresolvable;
1442
- if (typeof opts?.phaseDir !== 'string' || opts.phaseDir === '' || opts.phaseDir.includes('\0'))
1443
- return unresolvable;
1444
- const rootLexical = node_path_1.default.resolve(opts.root);
1445
- const rootReal = resolvedRootOf(rootLexical);
1446
- const bases = [typeof opts.cwd === 'string' && opts.cwd !== '' ? opts.cwd : process.cwd(), rootLexical];
1447
- let phaseDirAbs = null;
1448
- let rootAbs = rootLexical;
1449
- for (const base of bases) {
1450
- const abs = node_path_1.default.resolve(base, opts.phaseDir);
1451
- try {
1452
- if (!node_fs_1.default.statSync(abs).isDirectory())
1453
- continue;
1454
- }
1455
- catch {
1456
- continue;
1457
- }
1458
- if (isContainedIn(abs, rootLexical)) {
1459
- phaseDirAbs = abs;
1460
- rootAbs = rootLexical;
1461
- break;
1462
- }
1463
- const real = resolvedRootOf(abs);
1464
- if (isContainedIn(real, rootReal)) {
1465
- phaseDirAbs = real;
1466
- rootAbs = rootReal;
1467
- break;
1468
- }
1469
- }
1470
- if (phaseDirAbs === null)
1471
- return unresolvable;
1472
- const slugMatch = PHASE_SLUG_SHAPE.exec(node_path_1.default.basename(phaseDirAbs));
1473
- if (!slugMatch)
1474
- return { ok: false, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_SLUG_UNRECOGNISED };
1475
- const manifestAbs = node_path_1.default.join(node_path_1.default.dirname(node_path_1.default.dirname(phaseDirAbs)), PHASE_MANIFEST_FILE);
1476
- if (!isContainedIn(manifestAbs, rootAbs))
1477
- return unresolvable;
1478
- return {
1479
- ok: true,
1480
- target: {
1481
- rootAbs,
1482
- manifestAbs,
1483
- manifestPath: node_path_1.default.relative(rootAbs, manifestAbs).split(node_path_1.default.sep).join('/'),
1484
- rawToken: slugMatch[1],
1485
- phaseDirAbs,
1486
- phaseSlug: node_path_1.default.basename(phaseDirAbs),
1487
- },
1488
- };
1489
- }
1490
- /**
1491
- * The existing manifest, parsed for an ADDITIVE write — or `null` when it
1492
- * must not be overwritten: unparseable JSON, a non-object document, a
1493
- * non-object `phases`, or a non-object entry for this phase (T-ik2-05). An
1494
- * absent file is an empty document, not a refusal.
1495
- */
1496
- function readManifestForWrite(manifestAbs) {
1497
- if (!node_fs_1.default.existsSync(manifestAbs))
1498
- return { doc: {}, phases: {}, raw: null };
1499
- try {
1500
- const raw = node_fs_1.default.readFileSync(manifestAbs, 'utf8');
1501
- const parsed = JSON.parse(raw);
1502
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1503
- return null;
1504
- const doc = parsed;
1505
- if (doc.phases === undefined)
1506
- return { doc, phases: {}, raw };
1507
- if (!doc.phases || typeof doc.phases !== 'object' || Array.isArray(doc.phases))
1508
- return null;
1509
- return { doc, phases: doc.phases, raw };
1510
- }
1511
- catch {
1512
- return null;
1513
- }
1514
- }
1515
- /** The key this phase's entry lives under: an existing spelling wins, else the slug's own token. */
1516
- function manifestKeyFor(phases, rawToken) {
1517
- const spellings = [rawToken, String(Number(rawToken))];
1518
- return spellings.find((key) => Object.prototype.hasOwnProperty.call(phases, key)) ?? rawToken;
1519
- }
1520
- /** The entry under `key` as a plain object, `{}` when absent, `null` when present and not an object. */
1521
- function manifestEntryOf(phases, key) {
1522
- if (!Object.prototype.hasOwnProperty.call(phases, key))
1523
- return {};
1524
- const entry = phases[key];
1525
- return entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : null;
1526
- }
1527
- /** Two-space JSON with a trailing newline — the one serialization, so a re-record is byte-stable. */
1528
- function serializeManifest(doc) {
1529
- return `${JSON.stringify(doc, null, 2)}\n`;
1530
- }
1531
- /**
1532
- * Write atomically: a sibling temp file named with the pid, then `renameSync`
1533
- * over the target — the pattern `src/runtime-hooks-surface.cts` uses. The temp
1534
- * file is removed on any failure. Returns `null` on success, else an errno.
1535
- */
1536
- function writeManifestAtomic(manifestAbs, serialized) {
1537
- const tmp = `${manifestAbs}.${process.pid}.tmp`;
1538
- try {
1539
- node_fs_1.default.writeFileSync(tmp, serialized, 'utf8');
1540
- node_fs_1.default.renameSync(tmp, manifestAbs);
1541
- return null;
1542
- }
1543
- catch (err) {
1544
- try {
1545
- node_fs_1.default.unlinkSync(tmp);
1546
- }
1547
- catch {
1548
- // Nothing to remove, or it cannot be removed; the refusal below stands.
1549
- }
1550
- return errnoOf(err);
1551
- }
1552
- }
1553
- /**
1554
- * Has this phase already been executed? (R2) `true` when its directory holds
1555
- * any `*-SUMMARY.md` — the same file set the trace-digest rung reads — and
1556
- * `null` when the directory cannot be listed, which the caller refuses on
1557
- * rather than guessing.
1558
- */
1559
- function phaseHasSummary(phaseDirAbs) {
1560
- try {
1561
- return node_fs_1.default.readdirSync(phaseDirAbs).some((name) => name.endsWith('-SUMMARY.md'));
1562
- }
1563
- catch {
1564
- return null;
1565
- }
1566
- }
1567
- /** A verified commit sha from a `rev-parse --verify` probe, or `null`. */
1568
- function verifiedSha(exec, rev, cwd) {
1569
- const probe = exec(['rev-parse', '--verify', rev], { cwd });
1570
- if (probe.exitCode !== 0 || probe.error)
1571
- return null;
1572
- const sha = String(probe.stdout || '').trim();
1573
- return COMMIT_SHA_SHAPE.test(sha) ? sha : null;
1574
- }
1575
- /**
1576
- * Anchor the phase: `phases[NN].startCommit = HEAD`, WRITE-ONCE.
1577
- *
1578
- * Called at execute:pre, in this order:
1579
- *
1580
- * 1. WRITE-ONCE. An entry for THIS phase directory that already carries a
1581
- * start commit is left byte-identical and reported `unchanged: true`, so
1582
- * a resume or a `--gaps-only` run keeps the anchor the phase actually
1583
- * started from. An unstamped (legacy) entry counts as this phase's.
1584
- * 2. ALREADY EXECUTED (R2). With no usable anchor, a phase directory that
1585
- * already holds a `*-SUMMARY.md` is refused as `phase-already-executed`
1586
- * and nothing is written: execute:pre fires on every re-run, and HEAD on
1587
- * an executed phase sits after its own commits, so an anchor there would
1588
- * put the phase's work outside its own range. The trace-digest rung is
1589
- * the range source for such a phase.
1590
- * 3. STALE (R1). An entry stamped for another phase directory — a number
1591
- * reused after a milestone reset — is REPLACED by a fresh
1592
- * `{ startCommit, phaseDir }`; its old commits are dropped. The result
1593
- * carries `replacedStale: true`.
1594
- *
1595
- * Every write stamps `phaseDir` with the directory basename. Any `commits`
1596
- * beside a non-stale entry are preserved. Never throws; a refusal is
1597
- * `{ ok: false, action, reason }` with a `PHASE_MANIFEST_WRITE_REASON` member.
1598
- */
1599
- function recordPhaseStart(opts) {
1600
- const action = 'start';
1601
- try {
1602
- const exec = typeof opts?.exec === 'function' ? opts.exec : shell_command_projection_cjs_1.execGitLocal;
1603
- const resolved = resolveManifestTarget(opts);
1604
- if (!resolved.ok)
1605
- return { ok: false, action, reason: resolved.reason };
1606
- const { target } = resolved;
1607
- const read = readManifestForWrite(target.manifestAbs);
1608
- if (read === null)
1609
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
1610
- const key = manifestKeyFor(read.phases, target.rawToken);
1611
- const entry = manifestEntryOf(read.phases, key);
1612
- if (entry === null)
1613
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
1614
- const stale = isStaleEntry(entry, target.phaseSlug);
1615
- // Write-once: the anchor a phase began from is never moved by a later run.
1616
- if (!stale && typeof entry.startCommit === 'string' && COMMIT_SHA_SHAPE.test(entry.startCommit)) {
1617
- return {
1618
- ok: true,
1619
- action,
1620
- manifestPath: target.manifestPath,
1621
- phase: key,
1622
- startCommit: entry.startCommit,
1623
- unchanged: true,
1624
- };
1625
- }
1626
- // Checked before HEAD is read: a refusal asks git nothing (R2).
1627
- const executed = phaseHasSummary(target.phaseDirAbs);
1628
- if (executed === null) {
1629
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_DIR_UNRESOLVABLE };
1630
- }
1631
- if (executed)
1632
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_ALREADY_EXECUTED };
1633
- const head = verifiedSha(exec, 'HEAD^{commit}', target.rootAbs);
1634
- if (head === null)
1635
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.HEAD_UNRESOLVABLE };
1636
- const fresh = stale
1637
- ? { startCommit: head, phaseDir: target.phaseSlug }
1638
- : { ...entry, startCommit: head, phaseDir: target.phaseSlug };
1639
- const doc = { ...read.doc, phases: { ...read.phases, [key]: fresh } };
1640
- const failure = writeManifestAtomic(target.manifestAbs, serializeManifest(doc));
1641
- if (failure !== null) {
1642
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: failure };
1643
- }
1644
- return {
1645
- ok: true,
1646
- action,
1647
- manifestPath: target.manifestPath,
1648
- phase: key,
1649
- startCommit: head,
1650
- unchanged: false,
1651
- ...(stale ? { replacedStale: true } : {}),
1652
- };
1653
- }
1654
- catch (err) {
1655
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: errnoOf(err) };
1656
- }
1657
- }
1658
- /**
1659
- * Close the phase's range: `phases[NN].commits` =
1660
- * `git rev-list --reverse --no-merges <startCommit>..HEAD`, oldest first.
1661
- *
1662
- * Called at verify:post, before the `vault.audit` gate. Recomputed from the
1663
- * anchor on every call, so two consecutive records leave the file
1664
- * byte-identical (and the second writes nothing). An empty list is a valid,
1665
- * honest record. A phase with no anchor answers `start-not-recorded` — the case
1666
- * the trace-digest rung covers — and so does an entry stamped for ANOTHER
1667
- * phase directory (R1): that anchor is another phase's, so nothing is listed
1668
- * from it and the file is left for `start` to replace. A successful record
1669
- * stamps `phaseDir`, which also adopts an unstamped (legacy) entry. Never
1670
- * throws.
1671
- */
1672
- function recordPhaseCommits(opts) {
1673
- const action = 'record';
1674
- try {
1675
- const exec = typeof opts?.exec === 'function' ? opts.exec : shell_command_projection_cjs_1.execGitLocal;
1676
- const resolved = resolveManifestTarget(opts);
1677
- if (!resolved.ok)
1678
- return { ok: false, action, reason: resolved.reason };
1679
- const { target } = resolved;
1680
- const read = readManifestForWrite(target.manifestAbs);
1681
- if (read === null)
1682
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
1683
- const key = manifestKeyFor(read.phases, target.rawToken);
1684
- const entry = manifestEntryOf(read.phases, key);
1685
- if (entry === null)
1686
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
1687
- if (isStaleEntry(entry, target.phaseSlug)) {
1688
- return {
1689
- ok: false,
1690
- action,
1691
- reason: PHASE_MANIFEST_WRITE_REASON.START_NOT_RECORDED,
1692
- detail: 'the entry under this phase number was recorded for another phase directory',
1693
- };
1694
- }
1695
- const start = entry.startCommit;
1696
- if (typeof start !== 'string' || !COMMIT_SHA_SHAPE.test(start)) {
1697
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.START_NOT_RECORDED };
1698
- }
1699
- if (verifiedSha(exec, `${start}^{commit}`, target.rootAbs) === null) {
1700
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.START_UNVERIFIABLE };
1701
- }
1702
- const listed = exec(['rev-list', '--reverse', '--no-merges', `${start}..HEAD`], { cwd: target.rootAbs });
1703
- if (listed.exitCode !== 0 || listed.error) {
1704
- return {
1705
- ok: false,
1706
- action,
1707
- reason: PHASE_MANIFEST_WRITE_REASON.HEAD_UNRESOLVABLE,
1708
- detail: 'git rev-list could not list the commits since the start commit',
1709
- };
1710
- }
1711
- const commits = String(listed.stdout || '')
1712
- .split('\n')
1713
- .map((line) => line.trim())
1714
- .filter((line) => COMMIT_SHA_SHAPE.test(line));
1715
- const doc = {
1716
- ...read.doc,
1717
- phases: { ...read.phases, [key]: { ...entry, commits, phaseDir: target.phaseSlug } },
1718
- };
1719
- const serialized = serializeManifest(doc);
1720
- if (serialized !== read.raw) {
1721
- const failure = writeManifestAtomic(target.manifestAbs, serialized);
1722
- if (failure !== null) {
1723
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: failure };
1724
- }
1725
- }
1726
- return {
1727
- ok: true,
1728
- action,
1729
- manifestPath: target.manifestPath,
1730
- phase: key,
1731
- startCommit: start,
1732
- commits,
1733
- commitCount: commits.length,
1734
- };
1735
- }
1736
- catch (err) {
1737
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: errnoOf(err) };
1738
- }
1739
- }
1740
- /** The one-line operator rendering of a writer result; the step files print it and continue. */
1741
- function renderManifestResult(result) {
1742
- const r = (result && typeof result === 'object' ? result : {});
1743
- if (r.ok !== true)
1744
- return `Phase manifest not written: ${boundedToken(r.reason ?? 'unknown')}`;
1745
- const sha7 = typeof r.startCommit === 'string' ? r.startCommit.slice(0, 7) : '';
1746
- if (r.action === 'record') {
1747
- return `Phase ${boundedToken(r.phase)}: ${Number(r.commitCount)} commit(s) recorded from ${sha7}..HEAD in ${boundedToken(r.manifestPath)}`;
1748
- }
1749
- return (`Phase ${boundedToken(r.phase)} anchored at ${sha7} in ${boundedToken(r.manifestPath)}` +
1750
- (r.unchanged === true ? ' (already anchored; unchanged)' : '') +
1751
- (r.replacedStale === true ? ' (replaced a stale entry recorded for another phase directory)' : ''));
1752
- }
1753
- // ─── The audit envelope ───────────────────────────────────────────────────────
1754
- /** Is this resolved absolute path the root itself, or somewhere beneath it? */
1755
- function isContainedIn(abs, rootAbs) {
1756
- return abs === rootAbs || abs.startsWith(rootAbs + node_path_1.default.sep);
1757
- }
1758
- /**
1759
- * The root with every symlinked component resolved away.
1760
- *
1761
- * The post-lstat re-containment below compares two paths, and both sides have
1762
- * to live in the SAME namespace or the comparison is meaningless. A macOS
1763
- * temporary root handed in as `/var/folders/...` really lives at
1764
- * `/private/var/folders/...`, so re-containing a fully-resolved file path
1765
- * against the UNRESOLVED root would refuse every legitimate read there. A root
1766
- * that cannot be resolved falls back to itself: the lexical check still ran,
1767
- * and a reader that refuses everything is worse than one that refuses the
1768
- * things this contract names.
1769
- */
1770
- function resolvedRootOf(rootAbs) {
1771
- try {
1772
- return node_fs_1.default.realpathSync(rootAbs);
1773
- }
1774
- catch {
1775
- return rootAbs;
1776
- }
1777
- }
1778
- /**
1779
- * The default whole-file reader: bounded, contained, and never partial.
1780
- *
1781
- * REFUSES, each refusal a `file_unreadable` FAIL upstream and each one pinned
1782
- * by its own case in `tests/vault-audit.test.cjs`:
1783
- *
1784
- * 1. a path that resolves outside the repository root;
1785
- * 2. a SYMBOLIC LINK at the leaf, categorically — including one pointing at
1786
- * another in-root file;
1787
- * 3. a path whose resolved form escapes the root through an INTERMEDIATE
1788
- * symlinked directory, which neither of the two checks above can see;
1789
- * 4. anything that is not a regular file;
1790
- * 5. anything over `AUDIT_FILE_READ_MAX` — never a truncated read that could
1791
- * false-PASS a `forbid` token sitting past the cut.
1792
- *
1793
- * WHY THE LEAF REFUSAL IS CATEGORICAL. A diff path names what git tracked, and
1794
- * a tracked symlink is a link blob; reading THROUGH one was never this
1795
- * reader's contract. Admitting in-root links while refusing out-of-root ones
1796
- * would mean trusting a resolution step on input a `check:` block steers, where
1797
- * the flat rule is strictly safer. Practice content is vault-authored data
1798
- * under this phase's trust model, so a `forbid` list that could reach a file
1799
- * outside the repository is a one-token-per-run content oracle, surfaced in a
1800
- * message the enforcer prints verbatim. If a later phase needs in-root symlink
1801
- * reads, that is a widening to argue for then, with its own case.
1802
- *
1803
- * No refusal phrase carries a filesystem path. The generic catch reports by
1804
- * errno only (05-REVIEW CR-03 — it previously repeated the raw Node message,
1805
- * absolute path included, into a message the enforcer prints verbatim).
1806
- */
1807
- function createBoundedFileReader(root) {
1808
- const rootAbs = node_path_1.default.resolve(root);
1809
- const rootReal = resolvedRootOf(rootAbs);
1810
- return (rel) => {
1811
- try {
1812
- const abs = node_path_1.default.resolve(rootAbs, rel);
1813
- if (!isContainedIn(abs, rootAbs)) {
1814
- return { ok: false, reason: 'resolves outside the repository root' };
1815
- }
1816
- // LINK-PRESERVING, and that is the whole point: the previous stat call
1817
- // followed symlinks, so a tracked link out of the root satisfied both
1818
- // the lexical check above (which does not resolve links either) and the
1819
- // stat, and was then read end to end.
1820
- const lst = node_fs_1.default.lstatSync(abs);
1821
- if (lst.isSymbolicLink()) {
1822
- return { ok: false, reason: 'is a symbolic link, which is never read through' };
1823
- }
1824
- if (!lst.isFile())
1825
- return { ok: false, reason: 'is not a regular file' };
1826
- if (lst.size > AUDIT_FILE_READ_MAX) {
1827
- return { ok: false, reason: `is larger than the ${AUDIT_FILE_READ_MAX}-byte whole-file read bound` };
1828
- }
1829
- // Belt and braces for INTERMEDIATE components. A symlinked parent
1830
- // directory is invisible to the lexical check and to a leaf-level lstat
1831
- // alike; `realpathSync` on a regular file resolves every component, so
1832
- // re-running containment on its result closes that arm too.
1833
- if (!isContainedIn(node_fs_1.default.realpathSync(abs), rootReal)) {
1834
- return { ok: false, reason: 'resolves outside the repository root' };
1835
- }
1836
- return { ok: true, content: node_fs_1.default.readFileSync(abs, 'utf8') };
1837
- }
1838
- catch (e) {
1839
- // 05-REVIEW CR-03: the raw Node message interpolates the absolute path
1840
- // it failed on, and this reason flows into `detail` → `message`, printed
1841
- // verbatim into an agent's context. The errno is the whole diagnostic
1842
- // value; the relative path is already carried beside it by the caller.
1843
- return { ok: false, reason: `could not be read (${errnoOf(e)})` };
1844
- }
1845
- };
1846
- }
1847
- /**
1848
- * The changed-file list for `range`.
1849
- *
1850
- * The MECHANISM is lifted verbatim from `cmdShipPractices` — `core.quotepath=off`
1851
- * at the seam (with git's default quoting a non-ASCII path arrives C-quoted,
1852
- * which both bypasses the `.planning/` exclusion and matches no glob),
1853
- * `--name-only`, `--diff-filter=d`, then the filter chain and the file cap. The
1854
- * `${base}...HEAD` literal is the ONE part that became an input (D-05-08).
1855
- */
1856
- function diffFilesFor(root, range, exec) {
1857
- const result = exec(['-c', 'core.quotepath=off', 'diff', '--name-only', '--diff-filter=d', range], {
1858
- cwd: root,
1859
- });
1860
- if (result.exitCode !== 0 || result.error)
1861
- return { ok: false };
1862
- const all = result.stdout
1863
- .split('\n')
1864
- .map((f) => f.trim())
1865
- .filter((f) => f.length > 0)
1866
- .map((f) => f.replace(/\\/g, '/'))
1867
- // Load-bearing: a practice glob like `**/*.md` would otherwise match the
1868
- // phase's own planning artifacts and FAIL a practice about product code.
1869
- // 16-03: the seal leaf owns the `.planning/` path test.
1870
- .filter((f) => !vaultSeal.isPlanningPath(f));
1871
- const capped = all.length > AUDIT_DIFF_FILE_CAP;
1872
- return { ok: true, files: capped ? all.slice(0, AUDIT_DIFF_FILE_CAP) : all, capped, total: all.length };
1873
- }
1874
- /** Every FAILing entry, in envelope order. */
1875
- function failingEntries(entries) {
1876
- const fails = [];
1877
- for (const entry of entries) {
1878
- if (isFailVerdict(entry.verdict))
1879
- fails.push(entry);
1880
- }
1881
- return fails;
1882
- }
1883
- /**
1884
- * The capped, counted itemization of the FAILing entries — `[]` when none
1885
- * failed.
1886
- *
1887
- * Extracted from `composeAuditMessage` (05-10) so the fail-closed ARMS can use
1888
- * it too. Before that, a `practice_unloadable` record on the diff-unreadable
1889
- * or vault-unreadable path reached the envelope and the render but never the
1890
- * gate message, and `message` is one of the two fields the enforcer reads.
1891
- */
1892
- function failItemLines(entries) {
1893
- const fails = failingEntries(entries);
1894
- if (fails.length === 0)
1895
- return [];
1896
- // 05-REVIEW CR-02: `id` and `detail` carry vault-authored bytes (the parser
1897
- // pins `id` to the vault filename; `detail` quotes require/forbid tokens),
1898
- // and `check-query-gate.md` prints `message` verbatim into an orchestrating
1899
- // agent's context on every run — the channel this module's own header calls
1900
- // agent-EXECUTABLE. So the itemized region is defanged and fenced with the
1901
- // idiom `renderPracticeEntry` already uses: a planted fence marker cannot
1902
- // close the fence early, and the notice tells the reader these lines are
1903
- // reference data. The omitted-count line is engine-authored end to end and
1904
- // stays OUTSIDE the fence — the notice's claim about what is inside must
1905
- // stay true. The repair sentence is appended by the callers, after this
1906
- // return, so it remains the closing line a prepend can never cut off.
1907
- const items = [];
1908
- for (const fail of fails.slice(0, AUDIT_MESSAGE_PROBLEM_CAP)) {
1909
- const detail = fail.detail ? ` — ${defangFenceMarkers(fail.detail)}` : '';
1910
- items.push(`- ${defangFenceMarkers(fail.id)}: ${String(fail.reason)}${detail}`);
1911
- }
1912
- const lines = [EXTERNAL_CONTENT_FENCE_START, ...FENCE_NOTICE, '', ...items, EXTERNAL_CONTENT_FENCE_END];
1913
- const omitted = fails.length - Math.min(fails.length, AUDIT_MESSAGE_PROBLEM_CAP);
1914
- if (omitted > 0) {
1915
- lines.push(`- ${omitted} further FAILing practice(s) are not listed here.`);
1916
- }
1917
- return lines;
1918
- }
1919
- /** The line that opens the notes block. Engine-authored, like every note. */
1920
- const AUDIT_NOTES_LEAD = 'Vault audit recorded the following limits on what was compared:';
1921
- /**
1922
- * One note, rendered as one line — collapsed, and deliberately NOT truncated.
1923
- *
1924
- * CR/LF collapse to a space so a note can never forge an extra line in a
1925
- * rendered block (the `boundedToken` discipline, for the same reason).
1926
- *
1927
- * The LENGTH bound is deliberately absent, and 05-10 removed it from the
1928
- * renderer too. Every note this module emits is ENGINE-COMPOSED: the four in
1929
- * `runVaultAudit` interpolate integers and the engine-derived git range, and
1930
- * discovery's three interpolate an integer and an already-bounded errno. No
1931
- * client-authored byte reaches a note. Truncating one at 80 characters
1932
- * therefore protected nothing and cost the fact — the counted omissions and
1933
- * the fallback GROUND both sit at the tail of their sentence, so an 80-column
1934
- * cut turned "N file(s) were not audited" into a sentence that names a cap and
1935
- * no consequence. A counted omission reported with its count cut off is the
1936
- * same false-record class this workstream keeps closing.
1937
- */
1938
- function noteLine(note) {
1939
- return String(note).split('\r').join(' ').split('\n').join(' ');
1940
- }
1941
- /**
1942
- * Prepend the counted omissions to whatever body a return path composed.
1943
- *
1944
- * THE ONE COMPOSITION POINT, used by every return path of `runVaultAudit`
1945
- * (05-10, G-1c). Before it, `notes[]` reached neither `block` nor `message` and
1946
- * `check-query-gate.md` reads nothing else — so a truncated diff, a refused
1947
- * pair count, an unreadable practices directory and a base-branch fallback were
1948
- * all invisible at the gate. The notes go FIRST because they are the limits
1949
- * that may explain the findings below them, and because putting them first is
1950
- * what keeps every path's repair sentence the LAST line: a prepend can never
1951
- * cut a remedy off, which is the 04-08 / 04-11 discipline.
1952
- *
1953
- * When the body is empty (nothing FAILed) the message is the notes block alone
1954
- * and carries NO repair sentence — nothing is broken to repair, and naming a
1955
- * remedy for a healthy run would be a false record.
1956
- *
1957
- * Every byte of the lead is engine-authored, and each note is already bounded
1958
- * at its push site. That claim is scoped to the NOTES BLOCK and does not extend
1959
- * to `message` as a whole — `failItemLines` emits vault-authored ids and
1960
- * details by a different door (T-5-37).
1961
- */
1962
- function composeWithNotes(notes, body) {
1963
- if (notes.length === 0)
1964
- return body;
1965
- const lines = [AUDIT_NOTES_LEAD];
1966
- for (const note of notes)
1967
- lines.push(`- ${noteLine(note)}`);
1968
- if (body.length > 0)
1969
- lines.push(body);
1970
- return lines.join('\n');
1971
- }
1972
- /**
1973
- * The gate message: a counted lead, capped items, a counted omission, and the
1974
- * repair as the closing sentence. `''` when nothing FAILed.
1975
- *
1976
- * NOT the whole message any more (05-10): `runVaultAudit` wraps every return
1977
- * in `composeWithNotes`, so the envelope's `message` is empty only when nothing
1978
- * failed AND nothing was omitted.
1979
- */
1980
- function composeAuditMessage(range, rangeSource, entries) {
1981
- const items = failItemLines(entries);
1982
- if (items.length === 0)
1983
- return '';
1984
- const fails = failingEntries(entries);
1985
- return [
1986
- `Vault audit: ${fails.length} practice check(s) FAILED over ${range} (source: ${rangeSource}).`,
1987
- ...items,
1988
- AUDIT_REPAIR_SENTENCE,
1989
- ].join('\n');
1990
- }
1991
- /** Every practice, verdicted without any comparison having happened. */
1992
- function verdictsWithoutComparison(practices, reason, detail) {
1993
- const entries = [];
1994
- // The same ONE composition point the evaluator uses (MECH-03, D-10-05). The
1995
- // degraded arms render through the same report, and a claim that appeared on
1996
- // one arm and not the other would be the envelope varying by arm — which is
1997
- // how a consumer learns to read an absence as a fact.
1998
- const pushEntry = (practice, entry) => {
1999
- entries.push(withEnforcedBy(entry, practice.enforcedBy));
2000
- };
2001
- for (const practice of practices) {
2002
- const id = boundedToken(practice.id);
2003
- const hasInvalidCheck = typeof practice.checkInvalid === 'string' && practice.checkInvalid.length > 0;
2004
- if (hasInvalidCheck) {
2005
- pushEntry(practice, {
2006
- id,
2007
- verdict: verdictForPractice({ hasInvalidCheck: true, hasCheck: false, failed: true, matchedNothing: false }),
2008
- reason: AUDIT_FAIL_REASON.CHECK_INVALID,
2009
- detail: boundedToken(practice.checkInvalid),
2010
- matchedFiles: [],
2011
- matchedCount: 0,
2012
- });
2013
- continue;
2014
- }
2015
- const hasCheck = Boolean(practice.check);
2016
- pushEntry(practice, {
2017
- id,
2018
- verdict: verdictForPractice({ hasInvalidCheck: false, hasCheck, failed: hasCheck, matchedNothing: false }),
2019
- reason: hasCheck ? reason : null,
2020
- detail: hasCheck ? detail : '',
2021
- matchedFiles: [],
2022
- matchedCount: 0,
2023
- });
2024
- }
2025
- return entries;
2026
- }
2027
- /**
2028
- * Call the discovery seam and reduce its answer to what the audit decides on.
2029
- *
2030
- * `vaultUnreadable` is computed from TYPED FIELDS and from the catch, never
2031
- * from note prose. That is the whole point: before 05-10 the only vault-side
2032
- * signal was a string in `notes[]`, `notes[]` reached neither `block` nor
2033
- * `message`, and the enforcer at check-query-gate.md reads nothing else — so
2034
- * every vault-side miss rendered as a clean pass (G-1a). A `startsWith()` over
2035
- * the note would have re-created the prose-matching this codebase already
2036
- * rejected once (`matchBudgetNotes`).
2037
- */
2038
- function runDiscovery(discover, root) {
2039
- try {
2040
- const discovery = discover(root);
2041
- return {
2042
- practices: Array.isArray(discovery?.practices) ? discovery.practices : [],
2043
- notes: [...(discovery?.notes ?? [])],
2044
- skipped: Array.isArray(discovery?.skipped) ? discovery.skipped : [],
2045
- vaultUnreadable: discovery?.readFailed === true || discovery?.vaultPresent === false,
2046
- vaultStandardsOnly: discovery?.vaultStandardsOnly === true,
2047
- };
2048
- }
2049
- catch {
2050
- // Discovery already never throws; this is belt-and-braces so an unreadable
2051
- // vault cannot take the whole gate down with a stack trace. A seam that
2052
- // threw told this audit nothing, which is the governance equivalent of a
2053
- // vault that could not be read.
2054
- return {
2055
- practices: [],
2056
- notes: ['the vault could not be read; no practice was evaluated'],
2057
- skipped: [],
2058
- vaultUnreadable: true,
2059
- vaultStandardsOnly: false,
2060
- };
2061
- }
2062
- }
2063
- /**
2064
- * One FAIL record per file discovery could not load (05-10, G-1b).
2065
- *
2066
- * The verdict is produced through `verdictForPractice`'s FIRST rung, the one
2067
- * that is decided independently of every cost bound. That rung already encodes
2068
- * the exact governance fact needed here: this is a statement about the
2069
- * PRACTICE, not about the comparison, so neither the pair cap nor the budget
2070
- * can change it. It deliberately does NOT reuse `verdictsWithoutComparison`'s
2071
- * `failed: hasCheck` trick — a skipped file's check-bearing-ness is unknown,
2072
- * and it must FAIL unconditionally rather than conditionally.
2073
- *
2074
- * Both fields are CLIENT-AUTHORED (the filename the vault chose and the
2075
- * parser's warning about it) and both are bounded here, at the one place they
2076
- * enter the envelope. See T-5-37: bounding is length-only, and the trust model
2077
- * for vault-authored text reaching `message` is the open WR-03 question, not
2078
- * something this helper settles.
2079
- */
2080
- function unloadableEntries(skipped) {
2081
- const entries = [];
2082
- for (const item of skipped) {
2083
- const rel = typeof item?.path === 'string' ? item.path : '';
2084
- const segments = rel.split('\\').join('/').split('/');
2085
- const leaf = segments[segments.length - 1] || rel;
2086
- entries.push({
2087
- id: boundedToken(leaf),
2088
- verdict: verdictForPractice({ hasInvalidCheck: true, hasCheck: false, failed: true, matchedNothing: false }),
2089
- reason: AUDIT_FAIL_REASON.PRACTICE_UNLOADABLE,
2090
- detail: boundedToken(item?.warning),
2091
- matchedFiles: [],
2092
- matchedCount: 0,
2093
- });
2094
- }
2095
- return entries;
2096
- }
2097
- /**
2098
- * The `range` field on a refused operator range (08-04).
2099
- *
2100
- * A placeholder rather than the supplied value, and that IS the point: the
2101
- * value failed validation, so it is never echoed into an envelope whose
2102
- * `message` an orchestrating agent reads verbatim (T-08-22). The field still
2103
- * has to say something, and "(refused)" says the true thing.
2104
- */
2105
- const AUDIT_REFUSED_RANGE = '(refused)';
2106
- /**
2107
- * The fail-closed envelope for an operator range this module will not compare
2108
- * over (08-04, D-08-19, threat T-08-22).
2109
- *
2110
- * BEFORE DISCOVERY AND BEFORE THE LADDER. Nothing is read — not the vault, not
2111
- * the manifest, not git — because the input was refused, and work performed on
2112
- * behalf of a refused input is work an attacker chose. The shape follows the
2113
- * diff-unreadable arm: `status: 'warn'`, `block: true`, zero files, a repair
2114
- * sentence closing the message.
2115
- *
2116
- * `block: true` is EXPLICIT, not `blocksOnVerdicts([])` — which is false. This
2117
- * is the same trap 05-10 measured as G-1a: emptiness here means nothing was
2118
- * verified, and nothing-verified must never read as passed.
2119
- */
2120
- function refusedOperatorRangeResult() {
2121
- // The coverage pair on this arm, from the SAME derivation every other arm
2122
- // uses. No practice was compared, so the counts are zero by construction and
2123
- // `evaluatedNothing` is true — which is the honest reading and the loud one.
2124
- const counts = countVerdicts([]);
2125
- const notes = [
2126
- `${AUDIT_RANGE_OUTCOME.RANGE_UNRESOLVABLE}: the supplied range is not a well-formed git range, so it was ` +
2127
- `refused before any comparison and no practice was compared against anything`,
2128
- ];
2129
- const lead = 'Vault audit: the range supplied to this audit is not a well-formed `<a>..<b>` or `<a>...<b>` git range ' +
2130
- 'and was refused, so no practice was compared and nothing was verified. The supplied value is not ' +
2131
- 'reproduced here because it failed validation.';
2132
- return {
2133
- status: 'warn',
2134
- block: true,
2135
- message: composeWithNotes(notes, [lead, AUDIT_OPERATOR_RANGE_REPAIR_SENTENCE].join('\n')),
2136
- range: AUDIT_REFUSED_RANGE,
2137
- rangeSource: AUDIT_RANGE_SOURCE.OPERATOR_RANGE,
2138
- rangeOutcome: AUDIT_RANGE_OUTCOME.RANGE_UNRESOLVABLE,
2139
- mechanicallyChecked: counts.mechanicallyChecked,
2140
- applicablePractices: counts.applicablePractices,
2141
- evaluatedNothing: counts.evaluatedNothing,
2142
- practices: [],
2143
- pairsEvaluated: 0,
2144
- pairCap: MATCH_PAIR_CAP,
2145
- budgetMs: MATCH_BUDGET_MS,
2146
- filesConsidered: 0,
2147
- filesCapped: false,
2148
- notes,
2149
- };
2150
- }
2151
- /**
2152
- * Run the whole audit: resolve the range, read the diff, discover the vault's
2153
- * practices, evaluate them, and compose the envelope both 05-06 surfaces
2154
- * serialize.
2155
- *
2156
- * THE UNREADABLE-DIFF ARM FAILS CLOSED, and that is a DELIBERATE divergence
2157
- * from `cmdShipPractices`, which warns and passes on the same condition. The
2158
- * difference is what the verdict feeds: that handler's arm is advisory, and
2159
- * this one's is a BLOCKING gate (D-05-02). A comparison that never ran, read as
2160
- * "passed", is precisely the silence this workstream exists to end — so do not
2161
- * harmonise the two arms later without reversing D-05-02 first.
2162
- *
2163
- * THE UNREADABLE-VAULT ARM FAILS CLOSED FOR THE SAME REASON (05-10, G-1a), and
2164
- * diverges from the sibling matcher channels' tolerate-and-render posture on
2165
- * exactly the same grounds: those channels advise, this envelope feeds the
2166
- * blocking gate.
2167
- */
2168
- function runVaultAudit(opts) {
2169
- const exec = typeof opts.exec === 'function' ? opts.exec : shell_command_projection_cjs_1.execGitLocal;
2170
- const discover = typeof opts.discover === 'function' ? opts.discover : discoverPracticeFiles;
2171
- const readFile = typeof opts.readFile === 'function' ? opts.readFile : createBoundedFileReader(opts.root);
2172
- // D-08-19. An operator range REPLACES the ladder rather than joining it: the
2173
- // operator is answering the question the ladder failed to answer, so
2174
- // consulting it first would be asking a question whose answer is being
2175
- // overridden. Refusal comes BEFORE the resolver, and long before
2176
- // `diffFilesFor` builds an argv (T-08-22).
2177
- const operatorRange = typeof opts.range === 'string' ? opts.range : null;
2178
- if (operatorRange !== null && !isValidOperatorRange(operatorRange)) {
2179
- return refusedOperatorRangeResult();
2180
- }
2181
- const resolved = operatorRange !== null
2182
- ? { range: operatorRange, rangeSource: AUDIT_RANGE_SOURCE.OPERATOR_RANGE }
2183
- : resolvePhaseRange({ root: opts.root, phaseDir: opts.phaseDir, exec });
2184
- const { range, rangeSource, fallbackReason, baseBranchRefused, notes: rangeNotes } = resolved;
2185
- const discovery = runDiscovery(discover, opts.root);
2186
- const practices = discovery.practices;
2187
- const notes = [...discovery.notes];
2188
- if (operatorRange !== null) {
2189
- // THE DISCLOSURE (D-08-19, threat T-08-25 — accepted, on this sentence).
2190
- //
2191
- // An operator range is treated as phase-scoped by the WR-04 predicate: the
2192
- // operator named the range, so an empty diff over it is a fact about that
2193
- // range and is STATED rather than blocked, which is D-05-02's reasoning
2194
- // applied to an assertion instead of to a manifest. But `phase_scoped`
2195
- // earned that way is the OPERATOR'S claim, not the ladder's, and
2196
- // HGATE-04's own hazard is an audit that established nothing about the
2197
- // phase reading as if it had. So the difference travels in two places that
2198
- // cannot be lost independently: this note, and `rangeSource:
2199
- // operator-range` on the envelope.
2200
- notes.push(`range supplied by the operator (--range); the phase-start ladder was not consulted, so this range is ` +
2201
- `scoped by the operator's assertion, not by the phase manifest — a ${AUDIT_RANGE_OUTCOME.PHASE_SCOPED} ` +
2202
- `outcome over this range records that assertion and is not a phase-derived scope; ` +
2203
- `rangeSource: ${AUDIT_RANGE_SOURCE.OPERATOR_RANGE} is what tells the two apart`);
2204
- }
2205
- if (baseBranchRefused) {
2206
- // FIRST, ahead of the fallback ground: an operator reading this list needs
2207
- // to know the range is not the one they configured before they read
2208
- // anything computed over it. The sentence is the resolver's own export,
2209
- // stated verbatim by all three consumers, so this gate and the review
2210
- // cannot describe the same refusal differently (W16).
2211
- notes.push(`${baseBranchRefused} This audit is computed over "${range}" instead.`);
2212
- }
2213
- if (fallbackReason) {
2214
- // The phase-scoped range D-05-08 exists to deliver did not resolve, and
2215
- // the ground is a repairable fact rather than an unavoidable one. Stating
2216
- // it is what makes the range record auditable on a checkout that has no
2217
- // manifest to read — a fresh clone or CI runner where `.planning/` is
2218
- // gitignored, since only the executing checkout writes one (260915-ik2).
2219
- notes.push(`phase range fell back to the base-branch form (${range}) because the phase-scoped range could not be ` +
2220
- `resolved: ${fallbackReason}`);
2221
- }
2222
- // The ladder's own notes (260915-ik2): a trace-digest range's under-scope
2223
- // disclosure, or the statement that the digest rung was tried and found
2224
- // nothing verifiable. Either is a limit on what the range establishes.
2225
- for (const note of rangeNotes ?? [])
2226
- notes.push(note);
2227
- // Computed ONCE and appended on every arm below. Both arms consume the same
2228
- // discovery, so a record that survived only one of them would be a record an
2229
- // operator loses exactly when things are worst (05-10, G-1b).
2230
- const unloadable = unloadableEntries(discovery.skipped);
2231
- if (discovery.vaultUnreadable) {
2232
- const entries = [
2233
- ...verdictsWithoutComparison(practices, AUDIT_FAIL_REASON.VAULT_UNREADABLE, `the client vault could not be read, so this practice was never compared against ${range}`),
2234
- ...unloadable,
2235
- ];
2236
- const vaultArmCounts = countVerdicts(entries);
2237
- const lead = `Vault audit: the client vault at .planning/vault was absent or could not be read, ` +
2238
- `so no practice was compared against ${range} (source: ${rangeSource}) and nothing was verified. ` +
2239
- `${failingEntries(entries).length} practice check(s) are recorded as FAILED.`;
2240
- return {
2241
- status: 'warn',
2242
- // EXPLICITLY true, not `blocksOnVerdicts(entries)`. On this arm `entries`
2243
- // is usually EMPTY — the vault yielded nothing to verdict — and
2244
- // `blocksOnVerdicts([])` is false, which is precisely how G-1a shipped: a
2245
- // gate that was handed no vault reported a clean pass. Emptiness HERE
2246
- // means nothing was verified, and nothing-verified must never read as
2247
- // passed. The diff arm's header comment argues this for the diff side and
2248
- // the reasoning applies verbatim.
2249
- block: true,
2250
- message: composeWithNotes(notes, [lead, ...failItemLines(entries), AUDIT_VAULT_REPAIR_SENTENCE].join('\n')),
2251
- range,
2252
- rangeSource,
2253
- // Nothing was compared on this arm, so the count is 0 by construction and
2254
- // the outcome is whatever the RANGE alone establishes (08-04).
2255
- rangeOutcome: classifyRangeOutcome({ phaseScoped: isPhaseScopedSource(rangeSource), filesConsidered: 0 }),
2256
- // The SAME derivation as every other arm (D-10-18) — `countVerdicts` and
2257
- // nothing else, so no arm can answer the coverage question differently.
2258
- mechanicallyChecked: vaultArmCounts.mechanicallyChecked,
2259
- applicablePractices: vaultArmCounts.applicablePractices,
2260
- evaluatedNothing: vaultArmCounts.evaluatedNothing,
2261
- practices: entries,
2262
- pairsEvaluated: 0,
2263
- pairCap: MATCH_PAIR_CAP,
2264
- budgetMs: MATCH_BUDGET_MS,
2265
- filesConsidered: 0,
2266
- filesCapped: false,
2267
- notes,
2268
- };
2269
- }
2270
- if (discovery.vaultStandardsOnly) {
2271
- // Quick 261015-at3 (QD-06, Vince 2026-10-15): the client vault WAS read,
2272
- // and it is a standards vault — INDEX.md at its root, no practice files —
2273
- // read in practices mode. Discovery yields zero practices by construction,
2274
- // and before this arm the audit compared nothing, warned, and did not
2275
- // block. Placed after the vault-unreadable arm (an unread vault is the more
2276
- // basic fact) and before the diff is read (nothing here needs it).
2277
- const entries = [
2278
- ...verdictsWithoutComparison(practices, AUDIT_FAIL_REASON.VAULT_STANDARDS_ONLY, `the client vault holds standards (INDEX.md) and no practice files, so this practice was never compared against ${range}`),
2279
- ...unloadable,
2280
- ];
2281
- const armCounts = countVerdicts(entries);
2282
- const lead = `Vault audit: the client vault at .planning/vault holds standards (INDEX.md) and no practice files, ` +
2283
- `but vault.mode is practices, so no practice was compared against ${range} (source: ${rangeSource}) ` +
2284
- `and nothing was verified. ${failingEntries(entries).length} practice check(s) are recorded as FAILED.`;
2285
- return {
2286
- status: 'warn',
2287
- // EXPLICITLY true, for the vault-unreadable arm's reason verbatim: on this
2288
- // arm `entries` is empty by construction, `blocksOnVerdicts([])` is false,
2289
- // and a gate handed no practice layer verified nothing (G-1a).
2290
- block: true,
2291
- message: composeWithNotes(notes, [lead, ...failItemLines(entries), AUDIT_STANDARDS_ONLY_REPAIR_SENTENCE].join('\n')),
2292
- range,
2293
- rangeSource,
2294
- rangeOutcome: classifyRangeOutcome({ phaseScoped: isPhaseScopedSource(rangeSource), filesConsidered: 0 }),
2295
- mechanicallyChecked: armCounts.mechanicallyChecked,
2296
- applicablePractices: armCounts.applicablePractices,
2297
- evaluatedNothing: armCounts.evaluatedNothing,
2298
- practices: entries,
2299
- pairsEvaluated: 0,
2300
- pairCap: MATCH_PAIR_CAP,
2301
- budgetMs: MATCH_BUDGET_MS,
2302
- filesConsidered: 0,
2303
- filesCapped: false,
2304
- notes,
2305
- };
2306
- }
2307
- const diff = diffFilesFor(opts.root, range, exec);
2308
- if (!diff.ok) {
2309
- const entries = [
2310
- ...verdictsWithoutComparison(practices, AUDIT_FAIL_REASON.DIFF_UNREADABLE, `the phase diff over ${range} could not be read, so this practice was never compared against it`),
2311
- ...unloadable,
2312
- ];
2313
- const diffArmCounts = countVerdicts(entries);
2314
- const lead = `Vault audit: the phase diff over ${range} (source: ${rangeSource}) could not be read, ` +
2315
- `so no practice was compared against it. ${failingEntries(entries).length} ` +
2316
- `practice check(s) are recorded as FAILED because nothing was verified.`;
2317
- return {
2318
- status: 'warn',
2319
- block: blocksOnVerdicts(entries),
2320
- message: composeWithNotes(notes, [lead, ...failItemLines(entries), AUDIT_DIFF_REPAIR_SENTENCE].join('\n')),
2321
- range,
2322
- rangeSource,
2323
- // The diff could not be read at all, so nothing was considered (08-04).
2324
- rangeOutcome: classifyRangeOutcome({ phaseScoped: isPhaseScopedSource(rangeSource), filesConsidered: 0 }),
2325
- // The SAME derivation as every other arm (D-10-18).
2326
- mechanicallyChecked: diffArmCounts.mechanicallyChecked,
2327
- applicablePractices: diffArmCounts.applicablePractices,
2328
- evaluatedNothing: diffArmCounts.evaluatedNothing,
2329
- practices: entries,
2330
- pairsEvaluated: 0,
2331
- pairCap: MATCH_PAIR_CAP,
2332
- budgetMs: MATCH_BUDGET_MS,
2333
- filesConsidered: 0,
2334
- filesCapped: false,
2335
- notes,
2336
- };
2337
- }
2338
- if (diff.capped) {
2339
- notes.push(`changed-file list truncated: ${diff.total} file(s) in the diff, capped at ${AUDIT_DIFF_FILE_CAP} — ` +
2340
- `${diff.total - AUDIT_DIFF_FILE_CAP} file(s) were not audited`);
2341
- }
2342
- // WR-04: a zero-file comparison means two different things depending on
2343
- // where the range came from, and only one of them is a pass.
2344
- //
2345
- // D-05-02 (05-10) is PRESERVED for a range resolved from the phase: a
2346
- // legitimately empty phase diff is common — docs-only phases, re-runs, a
2347
- // re-audit after a merge — so blocking on it would teach operators to ignore
2348
- // the gate, which costs more than the silence does. It is stated, not blocked.
2349
- //
2350
- // On the FALLBACK range that reasoning does not hold, because nothing
2351
- // established that the range corresponds to this phase. The fallback is the
2352
- // FOURTH rung of `resolvePhaseRange`'s ladder (260915-ik2): it is reached
2353
- // only when the phase has no usable manifest entry (no verifiable startCommit
2354
- // or commit) AND no verifiable SUMMARY trace digest. With
2355
- // `git.branching_strategy: none` — this project's own setting — `main...HEAD`
2356
- // is empty whenever HEAD sits at or behind the base. The measured consequence,
2357
- // before the ladder existed, was a live `forbid` practice, a file violating
2358
- // it, every check NOT_MATCHED and `block: false`: a gate declared
2359
- // `blocking: true` returning precisely what a clean pass returns, over a range
2360
- // nobody established was the phase's. An empty diff there is a fact about
2361
- // nothing, and a gate with no verdict to report must not report the passing
2362
- // one. The three phase-derived sources (`phase-start-commit`,
2363
- // `phase-first-commit`, `trace-digest`) sit outside this predicate by
2364
- // construction, which is the narrowing UAT-6-F1 required.
2365
- //
2366
- // 08-04 (D-08-17) gave this predicate a NAME and did not change its truth
2367
- // table (D-08-18). `unverifiableEmptyRange` is now read off
2368
- // `classifyRangeOutcome`, which computes the same comparison — zero files on
2369
- // a range no phase-derived rung produced — and returns the machine-readable
2370
- // `range_unresolvable` instead of only a boolean. The block expression below
2371
- // is byte-for-byte the one `d00e0f0` shipped: a resolved range with zero
2372
- // files is still stated and still not blocked, and the fallback range with
2373
- // zero files still blocks.
2374
- const rangeOutcome = classifyRangeOutcome({
2375
- phaseScoped: isPhaseScopedSource(rangeSource),
2376
- filesConsidered: diff.files.length,
2377
- });
2378
- const unverifiableEmptyRange = rangeOutcome === AUDIT_RANGE_OUTCOME.RANGE_UNRESOLVABLE;
2379
- if (diff.files.length === 0) {
2380
- notes.push(`the diff over ${range} (source: ${rangeSource}) contained no auditable file, so no practice was ` +
2381
- `compared against any file`);
2382
- }
2383
- if (unverifiableEmptyRange) {
2384
- // PREFIXED with the outcome code (08-04, D-08-17). `message` is composed
2385
- // from `notes`, and `check-query-gate.md` prints `message` verbatim — so
2386
- // putting the code at the head of this sentence is what makes
2387
- // `range_unresolvable` greppable in BOTH of the two fields the enforcer
2388
- // reads, not only in the envelope field a workflow would have to parse.
2389
- notes.push(`${AUDIT_RANGE_OUTCOME.RANGE_UNRESOLVABLE}: nothing was verified — the range was not resolved from the ` +
2390
- `phase, so an empty diff over it establishes nothing about this phase and no passing verdict can be ` +
2391
- `drawn from it`);
2392
- }
2393
- const evaluated = evaluatePractices({ practices, files: diff.files, readFile, now: opts.now });
2394
- if (evaluated.pairCapExceeded) {
2395
- notes.push(`practice evaluation refused: this vault and change set produce more than the ${MATCH_PAIR_CAP}-pair cap, ` +
2396
- `so no check was compared against the diff`);
2397
- }
2398
- if (evaluated.budgetExhausted) {
2399
- notes.push(`practice evaluation was cut short: the ${MATCH_BUDGET_MS}ms budget was exhausted, so the practices ` +
2400
- `reported after that point were never compared against the diff`);
2401
- }
2402
- // The denominator, assembled: every practice the evaluator saw PLUS every
2403
- // file discovery could not load. A rejection surfaces as a FAIL, never as an
2404
- // absence (05-10, G-1b).
2405
- const audited = [...evaluated.practices, ...unloadable];
2406
- // ONE `countVerdicts(audited)` call, feeding both the status and the
2407
- // envelope. Two calls would be two places the report can come to disagree
2408
- // with the gate that blocks on it.
2409
- const counts = countVerdicts(audited);
2410
- if (counts.evaluatedNothing) {
2411
- // PREFIXED with the outcome token, following the `range_unresolvable`
2412
- // precedent above: `message` is composed from `notes` and
2413
- // `check-query-gate.md` prints `message` verbatim, so putting the token at
2414
- // the head of this sentence is what makes the fact greppable in BOTH of the
2415
- // fields the enforcer reads, not only in an envelope field a workflow would
2416
- // have to parse. Composed entirely from engine strings and integers — no
2417
- // client-authored byte reaches it, which is the property `noteLine` relies
2418
- // on.
2419
- notes.push(`${AUDIT_COVERAGE_OUTCOME.EVALUATED_NOTHING}: ${counts.mechanicallyChecked} of ` +
2420
- `${counts.applicablePractices} applicable practice(s) were mechanically checked over ${range} ` +
2421
- `(source: ${rangeSource}) — no check block was compared against any file, so this run verified ` +
2422
- `nothing mechanically and no passing claim can be drawn from it`);
2423
- }
2424
- return {
2425
- // D-10-11: a run in which nothing was mechanically checked is `warn`, never
2426
- // the literal `ok` SC2 forbids. `warn` is not invented for this — it is
2427
- // already the shipped "we could not verify" arm that `unverifiableEmptyRange`
2428
- // uses, and this is the same species of fact. The union stays two-valued on
2429
- // purpose: a third value would break every consumer coding against two, for
2430
- // no gain.
2431
- status: unverifiableEmptyRange || counts.evaluatedNothing ? 'warn' : 'ok',
2432
- // WR-04: `blocksOnVerdicts` speaks for the practices that WERE compared.
2433
- // On an unverifiable empty range none were, so there is no verdict for it
2434
- // to speak from and the absence itself is the blocking fact.
2435
- //
2436
- // D-10-02: `evaluatedNothing` is DELIBERATELY absent from this expression,
2437
- // and the line is byte-identical to the one 08-04 shipped. Blocking on "we
2438
- // checked nothing" cannot distinguish an unchecked vault from a failing
2439
- // one, and on the day a client enables the capability against a vault that
2440
- // carries zero check blocks every phase would halt at `verify:post` until a
2441
- // different repository was edited. The fact is loud on three channels
2442
- // instead — `status: 'warn'`, a prefixed note, and the composed message —
2443
- // so no surface says `ok` about a run that checked nothing.
2444
- block: blocksOnVerdicts(audited) || unverifiableEmptyRange,
2445
- message: composeWithNotes(notes, composeAuditMessage(range, rangeSource, audited)),
2446
- range,
2447
- rangeSource,
2448
- rangeOutcome,
2449
- // From the SAME `counts` the status above was drawn from (D-10-18).
2450
- mechanicallyChecked: counts.mechanicallyChecked,
2451
- applicablePractices: counts.applicablePractices,
2452
- evaluatedNothing: counts.evaluatedNothing,
2453
- practices: audited,
2454
- pairsEvaluated: evaluated.pairsEvaluated,
2455
- pairCap: evaluated.pairCap,
2456
- budgetMs: evaluated.budgetMs,
2457
- filesConsidered: diff.files.length,
2458
- filesCapped: diff.capped,
2459
- notes,
2460
- };
2461
- }
2462
- // ─── The human rendering ──────────────────────────────────────────────────────
2463
- const AUDIT_SECTIONS = [
2464
- { verdict: AUDIT_VERDICT.FAIL, gloss: 'a check ran and was not satisfied, or could not be run at all' },
2465
- { verdict: AUDIT_VERDICT.PASS, gloss: 'a check ran and was satisfied' },
2466
- {
2467
- verdict: AUDIT_VERDICT.NOT_MATCHED,
2468
- gloss: 'a check was live and its globs matched nothing in this diff',
2469
- },
2470
- {
2471
- verdict: AUDIT_VERDICT.NO_CHECK,
2472
- gloss: 'this practice carries no check: block — a gap in the vault’s own coverage',
2473
- },
2474
- ];
2475
- /**
2476
- * The human rendering: four labelled sections, one line per practice, every
2477
- * line carrying its own verdict token.
2478
- *
2479
- * The verdict token is repeated on each LINE and not left to the section
2480
- * heading alone, deliberately. SC1 asks for `NO_CHECK` and `NOT_MATCHED` to be
2481
- * structurally distinct from `PASS` at every rendered channel; a distinction
2482
- * that lives only in a heading disappears the moment a line is quoted, grepped
2483
- * or copied out of its section.
2484
- */
2485
- function renderAuditReport(result) {
2486
- const counts = countVerdicts(result.practices);
2487
- const countFor = {
2488
- [AUDIT_VERDICT.PASS]: counts.pass,
2489
- [AUDIT_VERDICT.FAIL]: counts.fail,
2490
- [AUDIT_VERDICT.NO_CHECK]: counts.noCheck,
2491
- [AUDIT_VERDICT.NOT_MATCHED]: counts.notMatched,
2492
- };
2493
- const lines = [
2494
- // 08-04: the OUTCOME travels beside the source on the operator's own line,
2495
- // so the rendered report and the gate envelope state the same fact. An
2496
- // operator reading `outcome: range_unresolvable` learns in one line what
2497
- // previously took reading a note at the bottom of the render.
2498
- `Vault audit — range ${result.range} (source: ${result.rangeSource}, outcome: ${result.rangeOutcome})`,
2499
- `${result.practices.length} practice(s) audited over ${result.filesConsidered} changed file(s).`,
2500
- // D-10-03: how much of that was actually CHECKED, immediately below how
2501
- // much was audited. The two sentences answer the same question and an
2502
- // operator reads them together; counts only, never a percentage or a score.
2503
- renderMechanicalCoverageLine(counts),
2504
- ];
2505
- for (const section of AUDIT_SECTIONS) {
2506
- lines.push('');
2507
- lines.push(`${section.verdict} (${countFor[section.verdict]}) — ${section.gloss}`);
2508
- for (const entry of result.practices) {
2509
- if (entry.verdict !== section.verdict)
2510
- continue;
2511
- const reason = entry.reason ? ` ${entry.reason}` : '';
2512
- const detail = entry.detail ? ` — ${entry.detail}` : '';
2513
- lines.push(`- ${entry.id} [${entry.verdict}]${reason}${detail}`);
2514
- }
2515
- }
2516
- for (const note of result.notes)
2517
- lines.push(`\nnote: ${noteLine(note)}`);
2518
- return lines.join('\n');
2519
- }
2520
- module.exports = {
2521
- // The frozen vocabulary — the contract 05-06's two surfaces serialize.
2522
- AUDIT_VERDICT,
2523
- AUDIT_FAIL_REASON,
2524
- AUDIT_RANGE_SOURCE,
2525
- AUDIT_FALLBACK_REASON,
2526
- // 08-04 (D-08-17): WHAT the compared range established, and the ONE predicate
2527
- // that decides it. Exported so the review tier (08-05) and the two thin
2528
- // surfaces (08-06) HARVEST this cell rather than restating the comparison —
2529
- // a second predicate for one claim is a second claim.
2530
- AUDIT_RANGE_OUTCOME,
2531
- classifyRangeOutcome,
2532
- // 10-01 (MECH-02, D-10-01/D-10-03): the run-level coverage outcome and the
2533
- // ONE renderer for the coverage line. Exported on the same harvest rule as
2534
- // the vocabularies above — a test that copies the token, and a second surface
2535
- // that composes its own line, are each a second claim that drifts from this
2536
- // one the day either is reworded.
2537
- AUDIT_COVERAGE_OUTCOME,
2538
- renderMechanicalCoverageLine,
2539
- // 10-06 (MECH-03, D-10-05): the ONE lead this module composes for an
2540
- // `enforced-by:` claim. Exported so the tests — and any surface that must
2541
- // prove it never says "verified by" — assert against the SHIPPED string.
2542
- AUDIT_ENFORCED_BY_LEAD,
2543
- // 08-04 (D-08-19): the operator-range channel's shape contract. Exported so
2544
- // the CLI surface (08-06) validates with THIS predicate — a security check
2545
- // that exists twice is a security check that can be relaxed in one place.
2546
- AUDIT_OPERATOR_RANGE_SHAPE,
2547
- AUDIT_OPERATOR_RANGE_MAX_LENGTH,
2548
- isValidOperatorRange,
2549
- // 260915-ik2: why a manifest writer wrote nothing.
2550
- PHASE_MANIFEST_WRITE_REASON,
2551
- AUDIT_FILE_READ_MAX,
2552
- AUDIT_MESSAGE_PROBLEM_CAP,
2553
- AUDIT_DIFF_FILE_CAP,
2554
- GIT_EMPTY_TREE,
2555
- // The three repair sentences. Exported so a test HARVESTS the wording it
2556
- // asserts on rather than copying it — a copied remedy sentence rots the day
2557
- // the shipped one is reworded, and the pin that guards it stops guarding.
2558
- AUDIT_REPAIR_SENTENCE,
2559
- AUDIT_DIFF_REPAIR_SENTENCE,
2560
- AUDIT_VAULT_REPAIR_SENTENCE,
2561
- // Quick 261015-at3: the standards-only arm's repair, same harvest rule.
2562
- AUDIT_STANDARDS_ONLY_REPAIR_SENTENCE,
2563
- // 08-04: the refused-operator-range arm's repair, exported on the same
2564
- // harvest rule as the three above.
2565
- AUDIT_OPERATOR_RANGE_REPAIR_SENTENCE,
2566
- // The pure evaluator. Exported as the ONE engine; 05-06's surfaces choose a
2567
- // serializer and compute nothing, so they cannot disagree.
2568
- evaluatePractices,
2569
- // The diff scoping, exported separately from the envelope so a surface that
2570
- // only needs to STATE the range never has to run an audit to learn it.
2571
- resolvePhaseRange,
2572
- // 16-03 (D-20): the manifest-presence half of the seal leaf's artifact predicate.
2573
- hasPhaseManifestEntry,
2574
- // 260915-ik2 (UAT-6-F1): the manifest writers `vault manifest start|record`
2575
- // reaches, and their one-line operator rendering.
2576
- recordPhaseStart,
2577
- recordPhaseCommits,
2578
- renderManifestResult,
2579
- createBoundedFileReader,
2580
- // The whole audit, and its human rendering.
2581
- runVaultAudit,
2582
- renderAuditReport,
2583
- // The four registered predicates. Exported so a channel-distinctness test can
2584
- // ask each one directly rather than re-deriving the comparison it makes.
2585
- isPassVerdict,
2586
- isFailVerdict,
2587
- isNoCheckVerdict,
2588
- isNotMatchedVerdict,
2589
- SHAPE_PREDICATE_NAMES,
2590
- // The verdict counters, exported for the renderer's own headings and for a
2591
- // consumer that needs a summary without walking the list itself.
2592
- countVerdicts,
2593
- blocksOnVerdicts,
2594
- };