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