@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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/pp-code-reviewer.md +1 -1
- package/agents/pp-executor.md +2 -13
- package/agents/pp-planner.md +1 -1
- package/agents/pp-vault-reviewer.md +51 -0
- package/commands/pp-ns-review.md +2 -1
- package/commands/pp-vault-review.md +33 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/pp-core/bin/lib/capability-registry.cjs +200 -10
- package/pp-core/bin/lib/check-command-router.cjs +1242 -37
- package/pp-core/bin/lib/clusters.cjs +1 -0
- package/pp-core/bin/lib/command-aliases.cjs +7 -0
- package/pp-core/bin/lib/compose-bindings.cjs +41 -27
- package/pp-core/bin/lib/decisions.cjs +104 -27
- package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
- package/pp-core/bin/lib/frontmatter.cjs +37 -0
- package/pp-core/bin/lib/git-base-branch.cjs +136 -10
- package/pp-core/bin/lib/init-contract.cjs +180 -66
- package/pp-core/bin/lib/init-interactive.cjs +4 -20
- package/pp-core/bin/lib/loop-gates.cjs +213 -0
- package/pp-core/bin/lib/loop-resolver.cjs +107 -45
- package/pp-core/bin/lib/org-profile.cjs +218 -44
- package/pp-core/bin/lib/phase-command-router.cjs +7 -1
- package/pp-core/bin/lib/phase-manifest.cjs +527 -0
- package/pp-core/bin/lib/phase-tier.cjs +15 -2
- package/pp-core/bin/lib/phase.cjs +51 -1
- package/pp-core/bin/lib/vault-audit.cjs +2070 -0
- package/pp-core/bin/lib/vault-command-router.cjs +460 -1
- package/pp-core/bin/lib/vault-practices.cjs +1142 -104
- package/pp-core/bin/lib/vault-review.cjs +2359 -0
- package/pp-core/bin/lib/vault-trace.cjs +2831 -0
- package/pp-core/bin/lib/vault-update.cjs +266 -0
- package/pp-core/bin/lib/vault-write-back.cjs +19 -0
- package/pp-core/bin/lib/vault.cjs +277 -12
- package/pp-core/bin/pp-tools.cjs +30 -13
- package/pp-core/bin/shared/model-catalog.json +2 -1
- package/pp-core/references/executor-vault-trace.md +47 -0
- package/pp-core/references/loop-hook-dispatch.md +13 -2
- package/pp-core/references/planner-vault-practices.md +46 -1
- package/pp-core/references/planning-config.md +1 -1
- package/pp-core/templates/practices/README.md +140 -11
- package/pp-core/templates/project-charter.md +226 -0
- package/pp-core/workflows/add-tests.md +4 -0
- package/pp-core/workflows/audit-fix.md +2 -0
- package/pp-core/workflows/check-todos.md +1 -1
- package/pp-core/workflows/code-review-fix.md +2 -0
- package/pp-core/workflows/debug.md +4 -0
- package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
- package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
- package/pp-core/workflows/discovery-sprint.md +1 -1
- package/pp-core/workflows/discovery-to-build.md +1 -1
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
- package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
- package/pp-core/workflows/execute-phase.md +22 -5
- package/pp-core/workflows/execute-plan.md +4 -0
- package/pp-core/workflows/fast.md +4 -0
- package/pp-core/workflows/help/modes/full.md +3 -2
- package/pp-core/workflows/init.md +2 -2
- package/pp-core/workflows/new-project.md +1 -1
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
- package/pp-core/workflows/plan-phase.md +7 -4
- package/pp-core/workflows/quick.md +4 -0
- package/pp-core/workflows/ship.md +5 -1
- package/pp-core/workflows/undo.md +1 -1
- package/pp-core/workflows/validate-phase.md +4 -0
- package/pp-core/workflows/vault-review.md +65 -0
- package/pp-core/workflows/verify-work.md +3 -0
- package/scripts/gen-capability-registry.cjs +11 -0
- package/scripts/lint-test-file-count.allowlist.json +10 -1
- 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
|
+
};
|