@pareto4/pp-core 1.12.0 → 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-activation.cjs +19 -9
- 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/config-loader.cjs +151 -8
- 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 +111 -35
- 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 +313 -11
- 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 +20 -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/audit-milestone.md +1 -1
- package/pp-core/workflows/autonomous.md +3 -3
- package/pp-core/workflows/check-todos.md +1 -1
- package/pp-core/workflows/code-review-fix.md +3 -1
- package/pp-core/workflows/code-review.md +1 -1
- 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/discuss-phase.md +2 -2
- 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 +29 -12
- 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 +13 -10
- package/pp-core/workflows/quick.md +5 -1
- package/pp-core/workflows/secure-phase.md +1 -1
- package/pp-core/workflows/ship.md +7 -3
- package/pp-core/workflows/undo.md +1 -1
- package/pp-core/workflows/validate-phase.md +5 -1
- package/pp-core/workflows/vault-review.md +65 -0
- package/pp-core/workflows/verify-work.md +6 -3
- 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,2831 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* src/vault-trace.cts — the vault practice-trace envelope: the frozen
|
|
7
|
+
* contract's constants, the statement builder, the frontmatter serializer,
|
|
8
|
+
* the `## Practices Applied` renderer, the bespoke block parser, and the
|
|
9
|
+
* pure validators a trace gate composes.
|
|
10
|
+
*
|
|
11
|
+
* Responsibility (Phase 03-01 — task 1, the frozen half):
|
|
12
|
+
* The five constants below ARE the contract. `docs/vault-trace-format.md`
|
|
13
|
+
* states the same values in prose, and `tests/vault-trace.test.cjs` asserts
|
|
14
|
+
* the document and this module agree from both ends, so the two cannot
|
|
15
|
+
* drift. They are module constants and are NEVER re-derived at runtime from
|
|
16
|
+
* configuration or from the vault: a version marker a client repository
|
|
17
|
+
* could move is not a version marker.
|
|
18
|
+
*
|
|
19
|
+
* Reuses (no new runtime dependency):
|
|
20
|
+
* - `./vault.cjs` (`computeVaultVerdict`, `readManifest`) — the pin's state
|
|
21
|
+
* is the vault verdict's, and its values are READ through the existing
|
|
22
|
+
* manifest reader, never re-parsed here. A second reader would let the
|
|
23
|
+
* trace disagree with the load gate about one cache.
|
|
24
|
+
* - `./vault-practices.cjs` (`matchPractices`, `boundedToken`) — the practice
|
|
25
|
+
* denominator is the matcher's return set, and the id bounding is the
|
|
26
|
+
* shared helper.
|
|
27
|
+
*
|
|
28
|
+
* DELIBERATELY NOT REUSED — and this omission is load-bearing, not an oversight:
|
|
29
|
+
* This module does NOT use the shared frontmatter extractor/serializer pair
|
|
30
|
+
* in `./frontmatter.cjs`, even though that is the only sanctioned
|
|
31
|
+
* frontmatter parser in this repo and `src/vault-practices.cts` reuses it by
|
|
32
|
+
* name. (The mechanical proof is in `tests/vault-trace.test.cjs`: this module
|
|
33
|
+
* must not IMPORT `./frontmatter.cjs`, and must not CALL any of its four
|
|
34
|
+
* entry points — `extractFrontmatter`, its `parseFrontmatter` alias,
|
|
35
|
+
* `reconstructFrontmatter`, or `spliceFrontmatter`. Naming them here is
|
|
36
|
+
* deliberate and safe: the guard is keyed on import and call shapes, not on
|
|
37
|
+
* the bare strings. It used to be keyed on the strings, which forbade the
|
|
38
|
+
* very citation the explanation needs — so this paragraph claimed the names
|
|
39
|
+
* appeared nowhere in the file while `spliceFrontmatter` was quoted twenty
|
|
40
|
+
* lines below, and the guard covered only two of the four names. 03-SECURITY
|
|
41
|
+
* OF-1 / T-03-07.)
|
|
42
|
+
*
|
|
43
|
+
* The reason for the omission: the trace's practice list is a LIST OF MAPS,
|
|
44
|
+
* and neither half of that shared pair can represent that shape.
|
|
45
|
+
*
|
|
46
|
+
* - The shared EXTRACTOR's key regex (`^(\s*)([a-zA-Z0-9_-]+):\s*(.*)`)
|
|
47
|
+
* cannot match a line beginning with `- ` — the space after the dash
|
|
48
|
+
* fails the character class — so its list branch always wins and pushes
|
|
49
|
+
* `line.trim().slice(2)` as a RAW STRING. A practices list reads back as
|
|
50
|
+
* the string array `["id: x", "status: applied"]`: the structure is
|
|
51
|
+
* gone, silently.
|
|
52
|
+
* - The shared SERIALIZER is worse, and says so itself. Quoting the
|
|
53
|
+
* comment inside `spliceFrontmatter` verbatim: it is "a deliberately
|
|
54
|
+
* lossy serializer — it cannot faithfully re-emit nested object-list
|
|
55
|
+
* items (e.g. must_haves.artifacts / must_haves.prohibitions, whose
|
|
56
|
+
* items are `{ path, provides }` / `{ statement, status, … }` maps)".
|
|
57
|
+
*
|
|
58
|
+
* So the trace gets its own emitter (`serializeTraceBlock`) and its own
|
|
59
|
+
* parser (`parseVaultTraceBlock`), the latter modelled on the in-repo
|
|
60
|
+
* precedent `parseMustHavesBlock` (`src/frontmatter.cts`) — the technique is
|
|
61
|
+
* copied, not the function, because that function is anchored on its own
|
|
62
|
+
* literal `must_haves:` key and widening it would dissolve its whole shape.
|
|
63
|
+
*
|
|
64
|
+
* If you are here to "fix" this module by wiring it to the shared parser:
|
|
65
|
+
* don't. It will flatten every trace it touches, and the flattening is
|
|
66
|
+
* silent — an emptied trace and a genuine coverage failure produce the same
|
|
67
|
+
* verdict from different causes.
|
|
68
|
+
*/
|
|
69
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
70
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
71
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
73
|
+
const vaultMod = require("./vault.cjs");
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
75
|
+
const practicesMod = require("./vault-practices.cjs");
|
|
76
|
+
// The count cap is IMPORTED, never re-declared. `derivePracticeUniverse` below
|
|
77
|
+
// must bound the recorded universe by the same number `discoverPracticeFiles`
|
|
78
|
+
// bounds its return set by; a second literal here would be a second cap that
|
|
79
|
+
// drifts, which is the shape of the defect this whole file's universe
|
|
80
|
+
// derivation was found to carry (04-REVIEW WR-05). This widens an EXISTING
|
|
81
|
+
// destructure over an EXISTING import edge — no new edge is created, and the
|
|
82
|
+
// direction (trace leaf -> practices leaf) is the one D-12 settled on.
|
|
83
|
+
// `defangFenceMarkers` joins the destructure in 10-08 on the reason its own
|
|
84
|
+
// export comment gives: "Authoring a second fence or preamble in the audit
|
|
85
|
+
// module is how CR-02's class returns." The same holds here — the analyzer
|
|
86
|
+
// provenance clause is a client-authored byte written into a committed record
|
|
87
|
+
// an agent reads, and it is defanged with the SHIPPED marker pair, never a
|
|
88
|
+
// second one spelled locally.
|
|
89
|
+
const { boundedToken, PRACTICE_COUNT_CAP, defangFenceMarkers } = practicesMod;
|
|
90
|
+
/**
|
|
91
|
+
* Collapse every run of C0 control characters and DEL to a single space.
|
|
92
|
+
*
|
|
93
|
+
* `boundedToken` collapses CR/LF only — it is the LENGTH bound plus a
|
|
94
|
+
* line-forging guard — so a TAB, a NUL or a DEL in a client-authored value
|
|
95
|
+
* still reaches a message an enforcer prints verbatim and a reviewer agent
|
|
96
|
+
* loads unfenced (03-REVIEW CR-03, T-03-31/T-03-36). This is the character
|
|
97
|
+
* half, applied wherever an untrusted value crosses into generated output.
|
|
98
|
+
*/
|
|
99
|
+
function collapseControlChars(value) {
|
|
100
|
+
return value.replace(/[\u0000-\u001F\u007F]+/g, ' ');
|
|
101
|
+
}
|
|
102
|
+
// ─── The frozen contract (Phase 03-01, TRACE-05) ──────────────────────────────
|
|
103
|
+
// Changing any value below is a breaking change to a schema that other phases
|
|
104
|
+
// develop against in parallel. See docs/vault-trace-format.md § Versioning
|
|
105
|
+
// discipline: the URI major and TRACE_SCHEMA_VERSION move together, or not at
|
|
106
|
+
// all.
|
|
107
|
+
/**
|
|
108
|
+
* The in-toto Statement type. The envelope is in-toto SHAPED — it is never fed
|
|
109
|
+
* to an in-toto verifier and carries no library dependency.
|
|
110
|
+
*/
|
|
111
|
+
const TRACE_STATEMENT_TYPE = 'https://in-toto.io/Statement/v1';
|
|
112
|
+
/**
|
|
113
|
+
* The custom predicate type, and the MAJOR version carrier. pp-core's own
|
|
114
|
+
* namespace: every trace this engine WRITES carries exactly this URI. The URI
|
|
115
|
+
* is an identifier, not an endpoint — nothing fetches it.
|
|
116
|
+
*
|
|
117
|
+
* READERS accept this URI plus any URI the project's org profile lists under
|
|
118
|
+
* `vault.trace.accepted_predicates` (`acceptedPredicateTypes`), so a project
|
|
119
|
+
* that switches engine mid-engagement keeps reading the traces its earlier
|
|
120
|
+
* engine wrote. An accepted URI must carry the same major as this one
|
|
121
|
+
* (`/v<TRACE_SCHEMA_VERSION>`): the URI major and the schema version move
|
|
122
|
+
* together, or not at all.
|
|
123
|
+
*/
|
|
124
|
+
const TRACE_PREDICATE_TYPE = 'https://pareto.ooo/attestation/vault-practice-trace/v1';
|
|
125
|
+
/**
|
|
126
|
+
* The schema version, mirroring the `PRACTICE_SCHEMA_VERSION` discipline in
|
|
127
|
+
* `src/vault-practices.cts`. An integer, not a string. A consumer reading a
|
|
128
|
+
* value above the highest it knows warns loudly and does not silently pass.
|
|
129
|
+
*/
|
|
130
|
+
const TRACE_SCHEMA_VERSION = 1;
|
|
131
|
+
/**
|
|
132
|
+
* The three-state disposition vocabulary. These exact strings ALREADY ship in
|
|
133
|
+
* `agents/pp-executor.md` §<summary_creation> and are read by the code
|
|
134
|
+
* reviewer's cross-plan reconciliation contract — they are copied here, never
|
|
135
|
+
* re-coined. Frozen so a caller cannot mutate the shared array in place.
|
|
136
|
+
*/
|
|
137
|
+
const TRACE_STATUS_VALUES = Object.freeze(['applied', 'not-applicable', 'deviated']);
|
|
138
|
+
/**
|
|
139
|
+
* The permitted charset for a practice `id` — the ONLY client-authored token
|
|
140
|
+
* that reaches the ledger. An alphanumeric first character, then letters,
|
|
141
|
+
* digits, dots, underscores and hyphens, to a maximum of 64 characters.
|
|
142
|
+
*
|
|
143
|
+
* A practice id derives from a client-authored FILENAME, and POSIX permits
|
|
144
|
+
* nearly anything in a filename including an embedded newline (a fact
|
|
145
|
+
* `boundedToken`'s own docstring cites). `boundedToken` bounds length and
|
|
146
|
+
* collapses newlines; it does NOT restrict the charset. This pattern is the
|
|
147
|
+
* charset half, and it exists so `docs/vault-trace-format.md` can state
|
|
148
|
+
* "the only client-authored token is bounded, quoted and charset-validated"
|
|
149
|
+
* as a property rather than a hope.
|
|
150
|
+
*
|
|
151
|
+
* No `g` flag: a global RegExp carries `lastIndex` state across `.test()`
|
|
152
|
+
* calls and would return alternating results for the same input.
|
|
153
|
+
*/
|
|
154
|
+
const TRACE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
155
|
+
const NULL_PIN = Object.freeze({
|
|
156
|
+
ref: null,
|
|
157
|
+
resolvedSha: null,
|
|
158
|
+
tier: null,
|
|
159
|
+
source: null,
|
|
160
|
+
repo: null,
|
|
161
|
+
});
|
|
162
|
+
/**
|
|
163
|
+
* The degraded reason sentences, one per cause. Contract surface: the format
|
|
164
|
+
* page lists them, and a reader six months later needs to know WHICH failure
|
|
165
|
+
* a degraded trace records. Every interpolated value is bounded.
|
|
166
|
+
*/
|
|
167
|
+
const PIN_REASON = Object.freeze({
|
|
168
|
+
noCache: 'no vault cache present at .planning/vault',
|
|
169
|
+
manifestMissing: 'vault manifest missing at .planning/vault/.manifest.json',
|
|
170
|
+
manifestMalformed: 'vault manifest unreadable or malformed at .planning/vault/.manifest.json',
|
|
171
|
+
miss: 'vault manifest records a miss; no vault content was reached',
|
|
172
|
+
empty: 'vault cache holds no files; no vault content was reached',
|
|
173
|
+
noBindings: 'no client vault is bound (.platform-bindings.json needs client.vaultRepo and client.vaultRef); '
|
|
174
|
+
+ 'only a client-tier load at the configured ref records loaded',
|
|
175
|
+
configUnreadable: 'the project config cannot be read, so the vault state is unknown',
|
|
176
|
+
disabled: 'vault.enabled is not true',
|
|
177
|
+
});
|
|
178
|
+
/**
|
|
179
|
+
* Read the pin. The STATE is the vault verdict's — `computeVaultVerdict` in
|
|
180
|
+
* src/vault.cts is the one owner of "is the client vault loaded" (pack 6), so
|
|
181
|
+
* the trace and the load gate cannot disagree about one cache: `loaded`
|
|
182
|
+
* exactly when the verdict is `loaded` (the client tier, at the configured
|
|
183
|
+
* repo and ref, fresh). The pin VALUES (ref, sha, tier, source, repo) are
|
|
184
|
+
* read from the same cache's manifest through `readManifest`, which already
|
|
185
|
+
* returns the display-safe repo; this module parses `.manifest.json` nowhere.
|
|
186
|
+
*
|
|
187
|
+
* A degraded run is a FIRST-CLASS path, not an error branch — the caller
|
|
188
|
+
* always gets a pin object, and a statement is always produced. A stale,
|
|
189
|
+
* fallback-tier or drifted cache keeps its pin: the cached copy really did
|
|
190
|
+
* come from that SHA, and only the reason says why it does not prove the
|
|
191
|
+
* configured standards were applied.
|
|
192
|
+
*
|
|
193
|
+
* WORKTREES. `vaultDirPath` is the directory `buildTraceStatement` resolved
|
|
194
|
+
* once through `vaultMod.resolveVaultCacheDir`: the local `.planning/vault`,
|
|
195
|
+
* or, for the root of a linked git worktree with no local manifest, the
|
|
196
|
+
* owning main checkout's cache. The verdict is judged against that same
|
|
197
|
+
* directory.
|
|
198
|
+
*/
|
|
199
|
+
function resolvePin(cwd, vaultDirPath = node_path_1.default.join(cwd, '.planning', 'vault')) {
|
|
200
|
+
const v = vaultMod.computeVaultVerdict(cwd, { vaultDir: vaultDirPath });
|
|
201
|
+
const manifest = vaultMod.readManifest(vaultDirPath);
|
|
202
|
+
const str = (x) => (typeof x === 'string' && x !== '' ? x : null);
|
|
203
|
+
const pin = manifest
|
|
204
|
+
? {
|
|
205
|
+
ref: str(manifest.ref),
|
|
206
|
+
resolvedSha: str(manifest.resolvedSha),
|
|
207
|
+
tier: str(manifest.tier),
|
|
208
|
+
source: str(manifest.source),
|
|
209
|
+
repo: vaultMod.redactCloneTarget(str(manifest.repo)),
|
|
210
|
+
}
|
|
211
|
+
: { ...NULL_PIN };
|
|
212
|
+
if (v.verdict === 'loaded') {
|
|
213
|
+
return {
|
|
214
|
+
state: 'loaded',
|
|
215
|
+
reason: null,
|
|
216
|
+
...pin,
|
|
217
|
+
manifestFiles: manifest && Array.isArray(manifest.files)
|
|
218
|
+
? manifest.files.filter((f) => typeof f === 'string')
|
|
219
|
+
: [],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
let reason;
|
|
223
|
+
switch (v.reason) {
|
|
224
|
+
case 'never_fetched':
|
|
225
|
+
if (!node_fs_1.default.existsSync(vaultDirPath))
|
|
226
|
+
reason = PIN_REASON.noCache;
|
|
227
|
+
else if (!node_fs_1.default.existsSync(node_path_1.default.join(vaultDirPath, '.manifest.json')))
|
|
228
|
+
reason = PIN_REASON.manifestMissing;
|
|
229
|
+
else
|
|
230
|
+
reason = PIN_REASON.manifestMalformed;
|
|
231
|
+
break;
|
|
232
|
+
case 'fetch_miss':
|
|
233
|
+
reason = vaultMod.manifestRecordsMiss(manifest) ? PIN_REASON.miss : PIN_REASON.empty;
|
|
234
|
+
break;
|
|
235
|
+
case 'fallback_tier':
|
|
236
|
+
reason =
|
|
237
|
+
`vault loaded on the ${boundedToken(pin.tier ?? 'unknown')} tier `
|
|
238
|
+
+ `(${boundedToken(pin.repo ?? 'no repo recorded')}); no client standards were applied `
|
|
239
|
+
+ '— only a client-tier load records loaded';
|
|
240
|
+
break;
|
|
241
|
+
case 'ref_drift':
|
|
242
|
+
// Both refs come FIRST so they survive an 80-character head, and both go
|
|
243
|
+
// through `boundedToken`: a ref is project-authored text reaching an
|
|
244
|
+
// agent's context unfenced.
|
|
245
|
+
reason = pin.ref !== v.configuredRef
|
|
246
|
+
? `vault cache was fetched from ref ${boundedToken(pin.ref ?? 'unknown')} `
|
|
247
|
+
+ `but client.vaultRef is now ${boundedToken(v.configuredRef ?? 'unknown')}; `
|
|
248
|
+
+ 'the cached standards are not the configured ones — only a client-tier load '
|
|
249
|
+
+ 'at the configured ref records loaded'
|
|
250
|
+
: `vault cache was fetched from ${boundedToken(pin.repo ?? 'unknown')} `
|
|
251
|
+
+ `but client.vaultRepo is now ${boundedToken(v.configuredRepo ?? 'unknown')}; `
|
|
252
|
+
+ 'the cached standards are not the configured ones — only a client-tier load '
|
|
253
|
+
+ 'at the configured repo records loaded';
|
|
254
|
+
break;
|
|
255
|
+
case 'stale_cache':
|
|
256
|
+
reason =
|
|
257
|
+
`vault cache is stale (fetched ${boundedToken(str(manifest?.fetchedAt) ?? 'unknown')}); `
|
|
258
|
+
+ 'it exceeds vault.cache_ttl_hours';
|
|
259
|
+
break;
|
|
260
|
+
case 'no_bindings':
|
|
261
|
+
reason = PIN_REASON.noBindings;
|
|
262
|
+
break;
|
|
263
|
+
case 'config_unreadable':
|
|
264
|
+
reason = PIN_REASON.configUnreadable;
|
|
265
|
+
break;
|
|
266
|
+
default:
|
|
267
|
+
reason = PIN_REASON.disabled;
|
|
268
|
+
}
|
|
269
|
+
return { state: 'degraded', reason, ...pin, manifestFiles: null };
|
|
270
|
+
}
|
|
271
|
+
// ─── The universe ──────────────────────────────────────────────────────────────
|
|
272
|
+
/** The manifest-relative directory prefix under which practice files live. */
|
|
273
|
+
const PRACTICE_PATH_PREFIX = 'practices/';
|
|
274
|
+
/** The extension a practice file carries; stripped to yield the id. */
|
|
275
|
+
const PRACTICE_FILE_EXT = '.md';
|
|
276
|
+
/**
|
|
277
|
+
* Derive the practice-ID universe from the manifest file list the pin already
|
|
278
|
+
* carries.
|
|
279
|
+
*
|
|
280
|
+
* THE PROPERTY THIS FUNCTION EXISTS TO HOLD: every identifier the practice
|
|
281
|
+
* MATCHER can put into `predicate.practices` must be present here. The gate
|
|
282
|
+
* resolves the ledger against this list, so any file the matcher accepts and
|
|
283
|
+
* this derivation rejects becomes an `practice_id_unresolved` block whose only
|
|
284
|
+
* remedy — regenerate the trace — reproduces itself, and whose real fix lives
|
|
285
|
+
* in the client's own vault repository (04-REVIEW CR-02). The two sides are
|
|
286
|
+
* therefore filtered by the SAME rules, in the SAME order, and a test drives
|
|
287
|
+
* both over one shared file list so the pair cannot drift again.
|
|
288
|
+
*
|
|
289
|
+
* The three filters, mirroring `discoverPracticeFiles` in
|
|
290
|
+
* `src/vault-practices.cts` exactly:
|
|
291
|
+
*
|
|
292
|
+
* 1. the `practices/` prefix — a vault holds more than its practices
|
|
293
|
+
* directory, and a README must never become a practice identifier;
|
|
294
|
+
* 2. the markdown extension tested CASE-INSENSITIVELY — the matcher
|
|
295
|
+
* lowercases before testing, so `practices/beta.MD` is a practice file to
|
|
296
|
+
* it. Testing case-sensitively here was the CR-02 divergence;
|
|
297
|
+
* 3. the secret-file exclusion — `vaultMod.isSecretFile` is a pure function
|
|
298
|
+
* of the relative path (it opens nothing, so FENCE 1's read discipline is
|
|
299
|
+
* untouched) and the matcher applies it BEFORE any read because such
|
|
300
|
+
* names must never be surfaced into agent or model context (VSEC-03).
|
|
301
|
+
* The universe is written into a durable artifact that agents read, so
|
|
302
|
+
* omitting it here re-opened the exact disclosure the matcher closes.
|
|
303
|
+
*
|
|
304
|
+
* ORDER IS LOAD-BEARING and is the matcher's order: filter, sort the PATHS,
|
|
305
|
+
* then cap.
|
|
306
|
+
*
|
|
307
|
+
* - The exclusions run BEFORE the cap so an excluded entry can never displace
|
|
308
|
+
* an included one out of the bound.
|
|
309
|
+
* - The sort is over the PATHS, not over the derived ids, because that is the
|
|
310
|
+
* order the matcher caps in. Sorting ids instead would select a different
|
|
311
|
+
* hundred at the boundary (`practices/a-b.md` sorts before
|
|
312
|
+
* `practices/a.md`, while the id `a` sorts before `a-b`), and the two sides
|
|
313
|
+
* would disagree about exactly the entries nobody looks at.
|
|
314
|
+
* - `PRACTICE_COUNT_CAP` is the matcher's own exported constant, not a copy.
|
|
315
|
+
* The bound exists so client-vault data cannot dictate artifact size: an
|
|
316
|
+
* unbounded universe writes one line per vault file into every PLAN.md and
|
|
317
|
+
* SUMMARY.md, and past the gate's artifact byte cap the client's file count
|
|
318
|
+
* decides whether the project can ship (04-REVIEW WR-05).
|
|
319
|
+
* - An omission is recorded as a COUNT and never as a name. The matcher emits
|
|
320
|
+
* counts for precisely this reason, and the universe must not be the place
|
|
321
|
+
* an excluded filename leaks back out.
|
|
322
|
+
*
|
|
323
|
+
* The id is derived with `path.basename(rel, PRACTICE_FILE_EXT)` — the SAME
|
|
324
|
+
* call `parsePracticeFile` uses to decide whether a practice's declared id
|
|
325
|
+
* matches its filename (D-03). That is deliberate and it is not the same thing
|
|
326
|
+
* as stripping the last three characters: Node strips the suffix only on an
|
|
327
|
+
* exact match, so `practices/beta.MD` yields the id `beta.MD` on BOTH sides.
|
|
328
|
+
* A length-based strip would yield `beta` here and `beta.MD` there, which fixes
|
|
329
|
+
* the case test and relocates CR-02 rather than closing it. Lowercasing the
|
|
330
|
+
* basename would break it a third way. The derivation must mirror the matcher's
|
|
331
|
+
* operation, not approximate it.
|
|
332
|
+
*
|
|
333
|
+
* No practice file is opened: the basename is the whole input, and
|
|
334
|
+
* `src/vault-practices.cts` enforces (D-03) that a practice's frontmatter
|
|
335
|
+
* identifier equals it, SKIPPING any file that disagrees. A file the matcher
|
|
336
|
+
* skips for that reason leaves the universe a strict superset, which only ever
|
|
337
|
+
* makes MORE identifiers resolve — never fewer.
|
|
338
|
+
*
|
|
339
|
+
* Every derived id is re-validated against `TRACE_ID_PATTERN` here at build
|
|
340
|
+
* time, copying the defence-in-depth shape the practices list uses: a failing
|
|
341
|
+
* id is DROPPED and a note names the rejected value. A vault-authored filename
|
|
342
|
+
* reaches an agent's context through this list; it must not be the first
|
|
343
|
+
* unvalidated string in the statement.
|
|
344
|
+
*
|
|
345
|
+
* Null in, null out: a degraded pin carries no file list and records no
|
|
346
|
+
* universe (D-04).
|
|
347
|
+
*/
|
|
348
|
+
function derivePracticeUniverse(manifestFiles, notes) {
|
|
349
|
+
if (manifestFiles === null)
|
|
350
|
+
return null;
|
|
351
|
+
const kept = [];
|
|
352
|
+
for (const rel of manifestFiles) {
|
|
353
|
+
if (!rel.startsWith(PRACTICE_PATH_PREFIX))
|
|
354
|
+
continue;
|
|
355
|
+
if (!rel.toLowerCase().endsWith(PRACTICE_FILE_EXT))
|
|
356
|
+
continue;
|
|
357
|
+
if (vaultMod.isSecretFile(rel))
|
|
358
|
+
continue;
|
|
359
|
+
kept.push(rel);
|
|
360
|
+
}
|
|
361
|
+
kept.sort();
|
|
362
|
+
const shown = kept.slice(0, PRACTICE_COUNT_CAP);
|
|
363
|
+
const omitted = kept.length - shown.length;
|
|
364
|
+
if (omitted > 0) {
|
|
365
|
+
notes.push(`${omitted} practice universe entries omitted (cap: ${PRACTICE_COUNT_CAP})`);
|
|
366
|
+
}
|
|
367
|
+
const ids = new Set();
|
|
368
|
+
for (const rel of shown) {
|
|
369
|
+
const id = node_path_1.default.basename(rel, PRACTICE_FILE_EXT);
|
|
370
|
+
if (!TRACE_ID_PATTERN.test(id)) {
|
|
371
|
+
notes.push(`practice universe id skipped: "${boundedToken(id)}" does not match the permitted id charset`);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
ids.add(id);
|
|
375
|
+
}
|
|
376
|
+
return [...ids].sort();
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The lead of the analyzer-provenance clause, and the ONLY wording this module
|
|
380
|
+
* puts in front of a rule name (MECH-03, D-10-05).
|
|
381
|
+
*
|
|
382
|
+
* "also enforced by", never "verified by". pp-core runs no analyzer and
|
|
383
|
+
* confirms none; the clause is a claim about what the PRACTICE SAYS,
|
|
384
|
+
* attributable to the vault.
|
|
385
|
+
*
|
|
386
|
+
* ONE SENTENCE, TWO MODULES, AND WHY THAT IS NOT A DRIFT WAITING TO HAPPEN.
|
|
387
|
+
* `src/vault-audit.cts` is the OWNER — 10-06 shipped this wording there as
|
|
388
|
+
* `AUDIT_ENFORCED_BY_LEAD`, and harvesting rather than restating is this
|
|
389
|
+
* project's standing rule. It is not harvested HERE because it cannot be:
|
|
390
|
+
* `src/vault-audit.cts:70-72` requires this module's compiled form and
|
|
391
|
+
* DESTRUCTURES it at top level, and the comment at `:66-68` records the
|
|
392
|
+
* invariant that keeps that safe — "neither imports this module, so this adds
|
|
393
|
+
* no cycle". An edge from here back to the audit leaf, eager or lazy, makes
|
|
394
|
+
* that sentence false and the destructure load-order-dependent. So the copy
|
|
395
|
+
* stays and is PINNED against its owner by a test that fails when the two
|
|
396
|
+
* spellings disagree, which is the remedy this project prescribes for a copy
|
|
397
|
+
* that has to stay.
|
|
398
|
+
*/
|
|
399
|
+
const TRACE_ENFORCED_BY_LEAD = 'also enforced by';
|
|
400
|
+
/**
|
|
401
|
+
* Compose one practice's provenance clause, or `''` when it names nothing.
|
|
402
|
+
*
|
|
403
|
+
* Every rule name is a CLIENT-AUTHORED BYTE on its way into a committed record
|
|
404
|
+
* an agent reads, so each is bounded and defanged here even though
|
|
405
|
+
* `parseEnforcedBy` already bounded it at parse time: this is the boundary that
|
|
406
|
+
* matters, and a statement can be hand-built or mutated between build and
|
|
407
|
+
* serialize.
|
|
408
|
+
*/
|
|
409
|
+
function composeEnforcedByClause(rules) {
|
|
410
|
+
if (!Array.isArray(rules))
|
|
411
|
+
return '';
|
|
412
|
+
const named = [];
|
|
413
|
+
for (const rule of rules) {
|
|
414
|
+
if (typeof rule !== 'string')
|
|
415
|
+
continue;
|
|
416
|
+
const safe = defangFenceMarkers(collapseControlChars(boundedToken(rule))).trim();
|
|
417
|
+
if (safe !== '')
|
|
418
|
+
named.push(safe);
|
|
419
|
+
}
|
|
420
|
+
if (named.length === 0)
|
|
421
|
+
return '';
|
|
422
|
+
return `${TRACE_ENFORCED_BY_LEAD} ${named.join(', ')}`;
|
|
423
|
+
}
|
|
424
|
+
const NO_PINNED_FACTS = Object.freeze({
|
|
425
|
+
checkBearingIds: new Set(),
|
|
426
|
+
clauseById: new Map(),
|
|
427
|
+
read: false,
|
|
428
|
+
});
|
|
429
|
+
/**
|
|
430
|
+
* Read the pinned practice cache ONCE and derive both facts 10-08 adds
|
|
431
|
+
* (MECH-02, MECH-03).
|
|
432
|
+
*
|
|
433
|
+
* Reads through `discoverPracticeFiles` — the ONE practice reader — against the
|
|
434
|
+
* SAME resolved cache directory the pin used (the comment above `resolvePin`'s
|
|
435
|
+
* call site states why one cache directory serves the pin and the matcher; the
|
|
436
|
+
* census must not reach for a second). A second frontmatter scan here would be
|
|
437
|
+
* a second answer to one question, and the two would disagree the day either is
|
|
438
|
+
* edited. This is the widened read profile stated on
|
|
439
|
+
* `ResolvedPin.manifestFiles`, named rather than left implicit.
|
|
440
|
+
*
|
|
441
|
+
* ONE call for both facts, not two: the census and the provenance map are two
|
|
442
|
+
* projections of the same read, and taking the cache twice would let them
|
|
443
|
+
* describe two different moments.
|
|
444
|
+
*
|
|
445
|
+
* A read that FAILS records `read: false` and says so in `notes`. The census
|
|
446
|
+
* then lands on `null`, never `0`: zero is a claim about a vault that WAS read,
|
|
447
|
+
* and collapsing the two is how "we checked nothing" comes to read as "nothing
|
|
448
|
+
* needed checking".
|
|
449
|
+
*/
|
|
450
|
+
function readPinnedPractices(cwd, vaultDir, notes) {
|
|
451
|
+
try {
|
|
452
|
+
const discovery = practicesMod.discoverPracticeFiles(cwd, vaultDir);
|
|
453
|
+
const checkBearingIds = new Set();
|
|
454
|
+
const clauseById = new Map();
|
|
455
|
+
for (const practice of discovery.practices ?? []) {
|
|
456
|
+
if (typeof practice.id !== 'string')
|
|
457
|
+
continue;
|
|
458
|
+
// `check` is present ONLY when a `check:` key was authored AND every rule
|
|
459
|
+
// in `parseCheckBlock` passed. A practice carrying `checkInvalid` instead
|
|
460
|
+
// is deliberately NOT counted: that defect is a fail-closed FAIL at the
|
|
461
|
+
// audit, and counting it as adoption would overstate the vault's
|
|
462
|
+
// mechanical coverage — the over-claim this milestone exists to prevent
|
|
463
|
+
// (T-10-33).
|
|
464
|
+
if (practice.check !== undefined && practice.check !== null) {
|
|
465
|
+
checkBearingIds.add(practice.id);
|
|
466
|
+
}
|
|
467
|
+
const clause = composeEnforcedByClause(practice.enforcedBy);
|
|
468
|
+
if (clause !== '')
|
|
469
|
+
clauseById.set(practice.id, clause);
|
|
470
|
+
}
|
|
471
|
+
return { checkBearingIds, clauseById, read: true };
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
notes.push(`the pinned practice cache could not be read for the check-bearing census (${boundedToken(e?.message ?? e)})`);
|
|
475
|
+
return NO_PINNED_FACTS;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Take the check-block CENSUS of the recorded universe (MECH-02, D-10-13).
|
|
480
|
+
*
|
|
481
|
+
* `universe === null` in, `null` out: a degraded run has no universe to take a
|
|
482
|
+
* census of (D-04), and the census carries the rule of the sibling it sits
|
|
483
|
+
* beside rather than of the per-practice fields further down the statement.
|
|
484
|
+
*
|
|
485
|
+
* Counting is confined to ids the universe records, which is what makes
|
|
486
|
+
* "N of `universe.length`" exactly true of one statement read on its own: a
|
|
487
|
+
* practice file present on disk but absent from the manifest at the pinned SHA
|
|
488
|
+
* is not at that ref.
|
|
489
|
+
*/
|
|
490
|
+
function deriveCheckBearingCensus(universe, facts) {
|
|
491
|
+
if (universe === null || !facts.read)
|
|
492
|
+
return null;
|
|
493
|
+
let checkBearing = 0;
|
|
494
|
+
for (const id of universe) {
|
|
495
|
+
if (facts.checkBearingIds.has(id))
|
|
496
|
+
checkBearing += 1;
|
|
497
|
+
}
|
|
498
|
+
return checkBearing;
|
|
499
|
+
}
|
|
500
|
+
/** At most this many PLAN/SUMMARY artifacts are read for the census. */
|
|
501
|
+
const CENSUS_ARTIFACT_CAP = 200;
|
|
502
|
+
/** Each artifact is read whole or not at all, up to this many bytes. */
|
|
503
|
+
const CENSUS_ARTIFACT_BYTE_CAP = 1024 * 1024;
|
|
504
|
+
/**
|
|
505
|
+
* Read the `check:` census this phase's ledgers RECORDED — never re-derive it.
|
|
506
|
+
*
|
|
507
|
+
* The census is taken once, by `buildTraceStatement`, against the pinned vault
|
|
508
|
+
* the plan or summary was written under. A consumer (the mechanical audit)
|
|
509
|
+
* reads that record back here instead of counting the vault again: a second
|
|
510
|
+
* count could describe a different vault than the ledger does.
|
|
511
|
+
*
|
|
512
|
+
* Execute-stage ledgers (SUMMARY.md) are preferred over plan-stage ones, and
|
|
513
|
+
* within a stage the last artifact in name order — the latest plan — is the
|
|
514
|
+
* one reported. `distinct` counts how many different censuses the phase's
|
|
515
|
+
* ledgers record, so a vault that moved during the phase is visible. A ledger
|
|
516
|
+
* with no census (a degraded run) is skipped. `null` when no ledger records
|
|
517
|
+
* one. Pure read; never throws.
|
|
518
|
+
*/
|
|
519
|
+
function readRecordedCensus(phaseDir) {
|
|
520
|
+
try {
|
|
521
|
+
const names = node_fs_1.default
|
|
522
|
+
.readdirSync(phaseDir)
|
|
523
|
+
.filter((name) => /(^|-)(PLAN|SUMMARY)\.md$/.test(name))
|
|
524
|
+
.sort()
|
|
525
|
+
.slice(0, CENSUS_ARTIFACT_CAP);
|
|
526
|
+
const found = [];
|
|
527
|
+
for (const name of names) {
|
|
528
|
+
const abs = node_path_1.default.join(phaseDir, name);
|
|
529
|
+
let content;
|
|
530
|
+
try {
|
|
531
|
+
const stat = node_fs_1.default.lstatSync(abs);
|
|
532
|
+
if (!stat.isFile() || stat.size > CENSUS_ARTIFACT_BYTE_CAP)
|
|
533
|
+
continue;
|
|
534
|
+
content = node_fs_1.default.readFileSync(abs, 'utf8');
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const parsed = parseVaultTraceBlock(content, name);
|
|
540
|
+
const vault = parsed.present && parsed.statement ? parsed.statement.predicate?.vault : null;
|
|
541
|
+
if (!vault)
|
|
542
|
+
continue;
|
|
543
|
+
const census = vault.checkBearingPractices;
|
|
544
|
+
if (typeof census !== 'number' || !Number.isInteger(census) || census < 0)
|
|
545
|
+
continue;
|
|
546
|
+
if (!Array.isArray(vault.practiceUniverse))
|
|
547
|
+
continue;
|
|
548
|
+
found.push({
|
|
549
|
+
census,
|
|
550
|
+
universe: vault.practiceUniverse.length,
|
|
551
|
+
sha: typeof vault.resolvedSha === 'string' ? vault.resolvedSha : null,
|
|
552
|
+
artifact: name,
|
|
553
|
+
execute: /(^|-)SUMMARY\.md$/.test(name),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
if (found.length === 0)
|
|
557
|
+
return null;
|
|
558
|
+
const pool = found.some((f) => f.execute) ? found.filter((f) => f.execute) : found;
|
|
559
|
+
const chosen = pool[pool.length - 1];
|
|
560
|
+
const distinct = new Set(found.map((f) => `${f.census}/${f.universe}/${f.sha ?? ''}`)).size;
|
|
561
|
+
return {
|
|
562
|
+
checkBearingPractices: chosen.census,
|
|
563
|
+
practiceUniverse: chosen.universe,
|
|
564
|
+
resolvedSha: chosen.sha,
|
|
565
|
+
artifact: chosen.artifact,
|
|
566
|
+
distinct,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// ─── The builder ───────────────────────────────────────────────────────────────
|
|
574
|
+
/**
|
|
575
|
+
* The statement note recorded when the cache was read from the owning main git
|
|
576
|
+
* worktree (worktree executors). A fact, never a path.
|
|
577
|
+
*/
|
|
578
|
+
const TRACE_WORKTREE_CACHE_NOTE = 'vault cache read from the owning main git worktree; this checkout carries no local .planning/vault';
|
|
579
|
+
/**
|
|
580
|
+
* Build a complete trace statement.
|
|
581
|
+
*
|
|
582
|
+
* ALWAYS returns a statement. A vault that cannot be read produces a
|
|
583
|
+
* `state: degraded` record with a non-null reason, never an omitted artifact —
|
|
584
|
+
* an absent trace would be indistinguishable from a phase that was never
|
|
585
|
+
* vault-enabled, and that silence is exactly what this schema exists to end.
|
|
586
|
+
*/
|
|
587
|
+
function buildTraceStatement(opts) {
|
|
588
|
+
const { cwd, stage, plan } = opts;
|
|
589
|
+
const files = Array.isArray(opts.files) ? opts.files : [];
|
|
590
|
+
const notes = [];
|
|
591
|
+
const generatedAt = new Date().toISOString();
|
|
592
|
+
// ONE cache directory for the pin AND the matcher (worktree executors), so
|
|
593
|
+
// the practice set and the ref it is recorded against come from one vault.
|
|
594
|
+
const cache = vaultMod.resolveVaultCacheDir(cwd);
|
|
595
|
+
if (cache.source === 'main-worktree') {
|
|
596
|
+
// The fact, never the path: an absolute path of the owning checkout would
|
|
597
|
+
// leak a machine layout into a committed record.
|
|
598
|
+
notes.push(TRACE_WORKTREE_CACHE_NOTE);
|
|
599
|
+
}
|
|
600
|
+
const pin = resolvePin(cwd, cache.dir);
|
|
601
|
+
// The universe comes from the SAME manifest read that produced the pin
|
|
602
|
+
// (D-01); charset rejects are dropped and noted onto THIS statement's notes.
|
|
603
|
+
const practiceUniverse = derivePracticeUniverse(pin.manifestFiles, notes);
|
|
604
|
+
// ONE read of the pinned cache, two facts (10-08): the check-block census of
|
|
605
|
+
// the universe above, and the per-practice analyzer-provenance clauses the
|
|
606
|
+
// ledger entries below carry. This is the widened read profile this path now
|
|
607
|
+
// owns; it is stated on `ResolvedPin.manifestFiles` rather than left implicit.
|
|
608
|
+
const pinnedFacts = readPinnedPractices(cwd, cache.dir, notes);
|
|
609
|
+
const checkBearingPractices = deriveCheckBearingCensus(practiceUniverse, pinnedFacts);
|
|
610
|
+
// ── subject ──
|
|
611
|
+
const subject = [];
|
|
612
|
+
const names = [];
|
|
613
|
+
for (const entry of files) {
|
|
614
|
+
const rawName = typeof entry === 'string' ? entry : entry?.name;
|
|
615
|
+
if (typeof rawName !== 'string' || rawName === '')
|
|
616
|
+
continue;
|
|
617
|
+
const name = rawName.replace(/\\/g, '/');
|
|
618
|
+
names.push(name);
|
|
619
|
+
if (stage === 'execute') {
|
|
620
|
+
const digest = typeof entry === 'string' ? undefined : entry.gitCommit;
|
|
621
|
+
if (typeof digest === 'string' && /^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(digest)) {
|
|
622
|
+
subject.push({ name, digest: { gitCommit: digest } });
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
// Loud, not silent: an execute-stage entry with no usable digest is a
|
|
626
|
+
// real coverage gap and the reader must be able to see it.
|
|
627
|
+
notes.push(`subject "${boundedToken(name)}" has no usable gitCommit digest at execute stage`);
|
|
628
|
+
subject.push({ name, uri: name });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
// Plan stage: no digest key at all. `uri` is set so the entry still
|
|
633
|
+
// satisfies in-toto's ResourceDescriptor minimum.
|
|
634
|
+
subject.push({ name, uri: name });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// ── practices: the matcher's return set, and nothing else ──
|
|
638
|
+
const dispositionById = new Map();
|
|
639
|
+
for (const d of opts.dispositions ?? []) {
|
|
640
|
+
if (d && typeof d.id === 'string')
|
|
641
|
+
dispositionById.set(d.id, d);
|
|
642
|
+
}
|
|
643
|
+
const practices = [];
|
|
644
|
+
try {
|
|
645
|
+
const match = practicesMod.matchPractices({ cwd, files: names, vaultDir: cache.dir });
|
|
646
|
+
// Concatenate matched THEN global, preserving the matcher's own sort. Do
|
|
647
|
+
// not re-glob, do not read the practices directory, and do not filter by
|
|
648
|
+
// severity — a `not-applicable` entry is exactly the record TRACE-02
|
|
649
|
+
// exists to produce.
|
|
650
|
+
for (const p of [...match.matched, ...match.global]) {
|
|
651
|
+
const id = boundedToken(p.id);
|
|
652
|
+
if (!TRACE_ID_PATTERN.test(id)) {
|
|
653
|
+
notes.push(`practice id skipped: "${id}" does not match the permitted id charset`);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const d = dispositionById.get(id);
|
|
657
|
+
const entry = {
|
|
658
|
+
id,
|
|
659
|
+
scope: p.scope ?? null,
|
|
660
|
+
severity: p.severity ?? null,
|
|
661
|
+
status: typeof d?.status === 'string' ? d.status : '',
|
|
662
|
+
reason: typeof d?.reason === 'string' ? d.reason : '',
|
|
663
|
+
tasks: Array.isArray(d?.tasks) ? d.tasks.map((t) => String(t)) : [],
|
|
664
|
+
};
|
|
665
|
+
// Set ONLY when the practice carries a claim. An `undefined` property
|
|
666
|
+
// would change every deepEqual in every consumer for no behavioural gain
|
|
667
|
+
// — the same reasoning the validator's optional `tail` field already
|
|
668
|
+
// carries — and an entry for a practice with no claim must stay
|
|
669
|
+
// byte-identical to the one it produced before this phase (MECH-03).
|
|
670
|
+
const clause = pinnedFacts.clauseById.get(id);
|
|
671
|
+
if (typeof clause === 'string' && clause !== '')
|
|
672
|
+
entry.enforcedBy = clause;
|
|
673
|
+
// THE TEXT DIGEST (D-05-07). Digested from the SAME unwrapped excerpt
|
|
674
|
+
// `renderPracticeEntry` places under `ask:`/`excerpt:` in the body block
|
|
675
|
+
// — `unwrapFencedExcerpt` is the practices module's own, exported for
|
|
676
|
+
// exactly this, so the two readers cannot strip the fence differently.
|
|
677
|
+
// The scaffold and `vault practices match --block` run the same matcher
|
|
678
|
+
// on the same cache in the same session, which is what makes a digest
|
|
679
|
+
// written here comparable to a block embedded there.
|
|
680
|
+
const fenced = p.scope === 'scoped'
|
|
681
|
+
? p.ask
|
|
682
|
+
: p.excerpt;
|
|
683
|
+
if (typeof fenced === 'string') {
|
|
684
|
+
entry.textSha256 = practiceTextDigest(practicesMod.unwrapFencedExcerpt(fenced));
|
|
685
|
+
}
|
|
686
|
+
practices.push(entry);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
catch (e) {
|
|
690
|
+
// The matcher degrades internally rather than throwing, but a trace is
|
|
691
|
+
// never worth losing to an unexpected read failure.
|
|
692
|
+
notes.push(`practice matching failed: ${boundedToken(e.message)}`);
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
_type: TRACE_STATEMENT_TYPE,
|
|
696
|
+
predicateType: TRACE_PREDICATE_TYPE,
|
|
697
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
698
|
+
subject,
|
|
699
|
+
predicate: {
|
|
700
|
+
stage,
|
|
701
|
+
plan,
|
|
702
|
+
generatedAt,
|
|
703
|
+
vault: {
|
|
704
|
+
state: pin.state,
|
|
705
|
+
reason: pin.reason,
|
|
706
|
+
ref: pin.ref,
|
|
707
|
+
resolvedSha: pin.resolvedSha,
|
|
708
|
+
practiceUniverse,
|
|
709
|
+
checkBearingPractices,
|
|
710
|
+
tier: pin.tier,
|
|
711
|
+
source: pin.source,
|
|
712
|
+
repo: pin.repo,
|
|
713
|
+
},
|
|
714
|
+
practices,
|
|
715
|
+
notes,
|
|
716
|
+
override: null,
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
// ─── The reader allowlist ──────────────────────────────────────────────────────
|
|
721
|
+
/**
|
|
722
|
+
* The predicate types a trace READER accepts for the project at `cwd`:
|
|
723
|
+
* `TRACE_PREDICATE_TYPE` first, then each URI the org profile lists under
|
|
724
|
+
* `vault.trace.accepted_predicates` that carries the same major
|
|
725
|
+
* (`/v<TRACE_SCHEMA_VERSION>`). A listed URI of another major is ignored —
|
|
726
|
+
* the URI major and the schema version move together. Never throws; writers
|
|
727
|
+
* never call it.
|
|
728
|
+
*/
|
|
729
|
+
function acceptedPredicateTypes(cwd) {
|
|
730
|
+
const out = [TRACE_PREDICATE_TYPE];
|
|
731
|
+
try {
|
|
732
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
733
|
+
const orgProfile = require('./org-profile.cjs');
|
|
734
|
+
const majorSuffix = `/v${TRACE_SCHEMA_VERSION}`;
|
|
735
|
+
for (const uri of orgProfile.resolveAcceptedTracePredicates(cwd)) {
|
|
736
|
+
if (typeof uri === 'string' && uri.endsWith(majorSuffix) && !out.includes(uri))
|
|
737
|
+
out.push(uri);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
/* no profile reader: pp-core's own type only */
|
|
742
|
+
}
|
|
743
|
+
return out;
|
|
744
|
+
}
|
|
745
|
+
// ─── The serializer ────────────────────────────────────────────────────────────
|
|
746
|
+
const INDENT = ' ';
|
|
747
|
+
/**
|
|
748
|
+
* Quote a scalar for the emitted block. Every string value is emitted
|
|
749
|
+
* double-quoted — uniformly, rather than only when it "looks dangerous",
|
|
750
|
+
* because a conditional quoting rule is one review away from being widened to
|
|
751
|
+
* the wrong case. Backslashes and quotes are escaped, and any CR/LF is
|
|
752
|
+
* collapsed to a space so no value can ever add a line to the document.
|
|
753
|
+
*/
|
|
754
|
+
function yamlQuote(value) {
|
|
755
|
+
const flat = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]+/g, ' ');
|
|
756
|
+
return `"${flat}"`;
|
|
757
|
+
}
|
|
758
|
+
/** Emit `key: value` where value is a string, a number, or null. */
|
|
759
|
+
function scalarLine(depth, key, value) {
|
|
760
|
+
const rendered = value === null ? 'null' : typeof value === 'number' ? String(value) : yamlQuote(value);
|
|
761
|
+
return `${INDENT.repeat(depth)}${key}: ${rendered}`;
|
|
762
|
+
}
|
|
763
|
+
/** Emit a list of quoted strings, or an explicit `[]`. */
|
|
764
|
+
function stringListLines(depth, key, values) {
|
|
765
|
+
if (values.length === 0)
|
|
766
|
+
return [`${INDENT.repeat(depth)}${key}: []`];
|
|
767
|
+
return [
|
|
768
|
+
`${INDENT.repeat(depth)}${key}:`,
|
|
769
|
+
...values.map((v) => `${INDENT.repeat(depth + 1)}- ${yamlQuote(v)}`),
|
|
770
|
+
];
|
|
771
|
+
}
|
|
772
|
+
/** Emit an inline list of quoted strings (`tasks: ["T1", "T3"]`). */
|
|
773
|
+
function inlineList(values) {
|
|
774
|
+
if (values.length === 0)
|
|
775
|
+
return '[]';
|
|
776
|
+
return `[${values.map(yamlQuote).join(', ')}]`;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Serialize a statement as the `vault:` frontmatter block.
|
|
780
|
+
*
|
|
781
|
+
* Hand-written, with no YAML library: `pp-core/bin/lib/*.cjs` takes no external
|
|
782
|
+
* runtime dependency, and TRACE-01 adopts the in-toto shape without one.
|
|
783
|
+
*
|
|
784
|
+
* The practice list is emitted as a LIST OF MAPS so every id sits in a VALUE
|
|
785
|
+
* position (`- id: "db-naming"`). A map keyed by id would put a
|
|
786
|
+
* client-authored token in a KEY position, where a `:`, a `#`, a leading `-`
|
|
787
|
+
* or a newline changes the document's structure instead of a value's content.
|
|
788
|
+
*
|
|
789
|
+
* An id that fails the charset is SKIPPED with a note recorded inside the
|
|
790
|
+
* emitted statement, never dropped silently — a silent drop is
|
|
791
|
+
* indistinguishable from an agent omission and would break the 1:1 coverage
|
|
792
|
+
* check a gate performs against this list.
|
|
793
|
+
*/
|
|
794
|
+
function serializeTraceBlock(statement) {
|
|
795
|
+
const p = statement.predicate;
|
|
796
|
+
const notes = [...(p.notes ?? [])];
|
|
797
|
+
const lines = ['vault:'];
|
|
798
|
+
lines.push(scalarLine(1, '_type', statement._type));
|
|
799
|
+
lines.push(scalarLine(1, 'predicateType', statement.predicateType));
|
|
800
|
+
lines.push(`${INDENT}schemaVersion: ${Number(statement.schemaVersion)}`);
|
|
801
|
+
// ── subject ──
|
|
802
|
+
const subjects = statement.subject ?? [];
|
|
803
|
+
if (subjects.length === 0) {
|
|
804
|
+
lines.push(`${INDENT}subject: []`);
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
lines.push(`${INDENT}subject:`);
|
|
808
|
+
for (const s of subjects) {
|
|
809
|
+
lines.push(`${INDENT.repeat(2)}- name: ${yamlQuote(s.name)}`);
|
|
810
|
+
if (typeof s.uri === 'string')
|
|
811
|
+
lines.push(scalarLine(3, 'uri', s.uri));
|
|
812
|
+
if (s.digest && typeof s.digest.gitCommit === 'string') {
|
|
813
|
+
lines.push(`${INDENT.repeat(3)}digest:`);
|
|
814
|
+
lines.push(scalarLine(4, 'gitCommit', s.digest.gitCommit));
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// ── predicate ──
|
|
819
|
+
lines.push(`${INDENT}predicate:`);
|
|
820
|
+
lines.push(scalarLine(2, 'stage', p.stage));
|
|
821
|
+
lines.push(scalarLine(2, 'plan', p.plan));
|
|
822
|
+
lines.push(scalarLine(2, 'generatedAt', p.generatedAt));
|
|
823
|
+
lines.push(`${INDENT.repeat(2)}vault:`);
|
|
824
|
+
lines.push(scalarLine(3, 'state', p.vault.state));
|
|
825
|
+
lines.push(scalarLine(3, 'reason', p.vault.reason));
|
|
826
|
+
lines.push(scalarLine(3, 'ref', p.vault.ref));
|
|
827
|
+
lines.push(scalarLine(3, 'resolvedSha', p.vault.resolvedSha));
|
|
828
|
+
// practiceUniverse sits immediately after `resolvedSha`: the two facts come
|
|
829
|
+
// from the same manifest read (D-01). When null or absent, NO line is
|
|
830
|
+
// emitted — an empty universe (`[]`) and an unrecorded one are different
|
|
831
|
+
// facts and plan 04-05 must be able to tell them apart. Re-validated at emit
|
|
832
|
+
// time exactly like the practices list below (defence in depth): the
|
|
833
|
+
// statement may have been hand-built or mutated between build and serialize.
|
|
834
|
+
if (Array.isArray(p.vault.practiceUniverse)) {
|
|
835
|
+
const emittableIds = p.vault.practiceUniverse.filter((id) => {
|
|
836
|
+
const ok = typeof id === 'string' && TRACE_ID_PATTERN.test(id);
|
|
837
|
+
if (!ok) {
|
|
838
|
+
notes.push(`practice universe id skipped at serialize time: "${boundedToken(id)}" does not match the permitted id charset`);
|
|
839
|
+
}
|
|
840
|
+
return ok;
|
|
841
|
+
});
|
|
842
|
+
lines.push(...stringListLines(3, 'practiceUniverse', emittableIds));
|
|
843
|
+
}
|
|
844
|
+
// The census sits with the universe it is a census OF, on the same rule the
|
|
845
|
+
// universe follows: when the value is not a usable count, NO line is emitted,
|
|
846
|
+
// because an unrecorded census and a census of ZERO are different facts and a
|
|
847
|
+
// consumer must be able to tell them apart. Zero IS emitted — "0 of 55" is
|
|
848
|
+
// the fact MECH-02 exists to make legible, and a fact nobody writes down is
|
|
849
|
+
// not a record.
|
|
850
|
+
//
|
|
851
|
+
// Re-validated at emit time exactly like the practices list below (defence in
|
|
852
|
+
// depth): the statement may have been hand-built or mutated between build and
|
|
853
|
+
// serialize, and the YAML boundary is the one that matters. A value that is
|
|
854
|
+
// not an integer >= 0 is DROPPED WITH A NOTE rather than emitted — a silent
|
|
855
|
+
// drop would be indistinguishable from a census that was never taken.
|
|
856
|
+
const census = p.vault.checkBearingPractices;
|
|
857
|
+
if (typeof census === 'number' && Number.isInteger(census) && census >= 0) {
|
|
858
|
+
lines.push(scalarLine(3, 'checkBearingPractices', census));
|
|
859
|
+
}
|
|
860
|
+
else if (census !== null && census !== undefined) {
|
|
861
|
+
notes.push(`check-bearing census dropped at serialize time: "${boundedToken(census)}" is not an integer count of zero or more`);
|
|
862
|
+
}
|
|
863
|
+
lines.push(scalarLine(3, 'tier', p.vault.tier));
|
|
864
|
+
lines.push(scalarLine(3, 'source', p.vault.source));
|
|
865
|
+
lines.push(scalarLine(3, 'repo', p.vault.repo));
|
|
866
|
+
// ── practices ──
|
|
867
|
+
// Re-validate at emit time (defence in depth): the statement may have been
|
|
868
|
+
// hand-built or mutated between build and serialize, and the YAML boundary
|
|
869
|
+
// is the one that matters.
|
|
870
|
+
const emittable = (p.practices ?? []).filter((entry) => {
|
|
871
|
+
const ok = typeof entry.id === 'string' && TRACE_ID_PATTERN.test(entry.id);
|
|
872
|
+
if (!ok) {
|
|
873
|
+
notes.push(`practice id skipped at serialize time: "${boundedToken(entry.id)}" does not match the permitted id charset`);
|
|
874
|
+
}
|
|
875
|
+
return ok;
|
|
876
|
+
});
|
|
877
|
+
if (emittable.length === 0) {
|
|
878
|
+
lines.push(`${INDENT.repeat(2)}practices: []`);
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
lines.push(`${INDENT.repeat(2)}practices:`);
|
|
882
|
+
for (const entry of emittable) {
|
|
883
|
+
lines.push(`${INDENT.repeat(3)}- id: ${yamlQuote(entry.id)}`);
|
|
884
|
+
lines.push(scalarLine(4, 'scope', entry.scope ?? null));
|
|
885
|
+
lines.push(scalarLine(4, 'severity', entry.severity ?? null));
|
|
886
|
+
lines.push(scalarLine(4, 'status', entry.status ?? ''));
|
|
887
|
+
lines.push(scalarLine(4, 'reason', entry.reason ?? ''));
|
|
888
|
+
// Analyzer provenance, between `reason` and `tasks` (10-08, MECH-03).
|
|
889
|
+
// NOTHING is emitted when the practice carries no claim, so an entry with
|
|
890
|
+
// no new fact keeps the shape it had before this phase.
|
|
891
|
+
//
|
|
892
|
+
// Re-bounded and re-defanged HERE even though the builder already did it,
|
|
893
|
+
// on the emit-time re-validation rule the id filter below states: the
|
|
894
|
+
// statement may have been hand-built or mutated between build and
|
|
895
|
+
// serialize, and the YAML boundary is the one that matters. Three
|
|
896
|
+
// distinct hazards, three shipped guards: `renderSafeField` collapses the
|
|
897
|
+
// control characters `boundedToken` does not (it handles CR/LF only) and
|
|
898
|
+
// caps the length so one ledger entry cannot fill an agent's context;
|
|
899
|
+
// `defangFenceMarkers` neutralises the SHIPPED external-content markers,
|
|
900
|
+
// which `yamlQuote` does not touch at all; and `yamlQuote` below owns the
|
|
901
|
+
// CR/LF half, so a newline cannot add a line to the document.
|
|
902
|
+
const clause = defangFenceMarkers(renderSafeField(entry.enforcedBy));
|
|
903
|
+
if (clause !== '')
|
|
904
|
+
lines.push(scalarLine(4, 'enforcedBy', clause));
|
|
905
|
+
// The text digest (D-05-07), between `enforcedBy` and `tasks`. NOTHING
|
|
906
|
+
// is emitted when the entry carries none, so a ledger written from a
|
|
907
|
+
// statement without digests keeps the shape it had. Re-validated at emit
|
|
908
|
+
// time on the same rule as the id filter: only a 64-character lowercase
|
|
909
|
+
// hex string is a digest, and anything else is dropped WITH a note rather
|
|
910
|
+
// than written as a value the gate would then compare against.
|
|
911
|
+
const digest = entry.textSha256;
|
|
912
|
+
if (typeof digest === 'string' && TEXT_DIGEST_PATTERN.test(digest)) {
|
|
913
|
+
lines.push(scalarLine(4, 'textSha256', digest));
|
|
914
|
+
}
|
|
915
|
+
else if (digest !== undefined && digest !== null) {
|
|
916
|
+
notes.push(`text digest dropped at serialize time for "${boundedToken(entry.id)}": not a sha256 hex digest`);
|
|
917
|
+
}
|
|
918
|
+
lines.push(`${INDENT.repeat(4)}tasks: ${inlineList(entry.tasks ?? [])}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
lines.push(...stringListLines(2, 'notes', notes));
|
|
922
|
+
// ── override ──
|
|
923
|
+
if (!p.override) {
|
|
924
|
+
lines.push(`${INDENT.repeat(2)}override: null`);
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
lines.push(`${INDENT.repeat(2)}override:`);
|
|
928
|
+
lines.push(scalarLine(3, 'gate', p.override.gate));
|
|
929
|
+
lines.push(scalarLine(3, 'reason', p.override.reason));
|
|
930
|
+
lines.push(scalarLine(3, 'suppressedReason', p.override.suppressedReason));
|
|
931
|
+
lines.push(scalarLine(3, 'who', p.override.who));
|
|
932
|
+
lines.push(scalarLine(3, 'when', p.override.when));
|
|
933
|
+
}
|
|
934
|
+
// TERMINATED, and the trailing newline is load-bearing (04-REVIEW IN-01).
|
|
935
|
+
// The block's destination is a document's YAML frontmatter, and the agent
|
|
936
|
+
// that puts it there splices by concatenation. Without a terminator the final
|
|
937
|
+
// line runs into whatever follows — `override: null---` — and
|
|
938
|
+
// `collectBlockLines`' frontmatter match then fails against a block that is
|
|
939
|
+
// physically present. The verdict is `trace_absent`, and its remedy is to
|
|
940
|
+
// regenerate the block that is already there: an unrecoverable false block of
|
|
941
|
+
// exactly the shape D-11 exists to prevent. One character removes the failure
|
|
942
|
+
// mode; a caller that joins with its own newline sees a blank line, which
|
|
943
|
+
// `collectBlockLines` already skips.
|
|
944
|
+
return `${lines.join('\n')}\n`;
|
|
945
|
+
}
|
|
946
|
+
// ─── The rendered surface ──────────────────────────────────────────────────────
|
|
947
|
+
const PRACTICES_APPLIED_HEADING = '## Practices Applied';
|
|
948
|
+
/**
|
|
949
|
+
* The cap applied to every client-influenced field the renderer interpolates
|
|
950
|
+
* into a generated markdown line (03-REVIEW WR-12, T-03-34). Wider than
|
|
951
|
+
* `WARNING_TOKEN_MAX` (80) because a one-line reason is not a warning token,
|
|
952
|
+
* and `boundedToken` is deliberately NOT reused here — widening its bound
|
|
953
|
+
* would silently change unrelated warning messages across two modules.
|
|
954
|
+
*/
|
|
955
|
+
const TRACE_RENDERED_FIELD_MAX = 300;
|
|
956
|
+
/**
|
|
957
|
+
* The single line emitted in place of an entry whose id fails the charset. It
|
|
958
|
+
* has to teach a human reader three things at once, because it is the only
|
|
959
|
+
* thing they will see: that an id was refused AT THIS POSITION (the section
|
|
960
|
+
* never silently shrinks), that the reason is the permitted id charset, and
|
|
961
|
+
* that the record is regenerated rather than hand-repaired.
|
|
962
|
+
*/
|
|
963
|
+
const TRACE_REFUSED_ID_LINE = '- (a practice id was refused here: it does not match the permitted id charset — regenerate the trace block rather than hand-repairing it)';
|
|
964
|
+
/**
|
|
965
|
+
* Bound and flatten one client-influenced value for a generated markdown line.
|
|
966
|
+
*
|
|
967
|
+
* The `## Practices Applied` section is loaded UNFENCED by name — the code
|
|
968
|
+
* reviewer's cross-plan reconciliation contract reads it — so this is the
|
|
969
|
+
* shortest injection path in the capability (T-03-34). A non-string coerces to
|
|
970
|
+
* the empty string rather than to `[object Object]`, control characters cannot
|
|
971
|
+
* forge a second line, and the length cap keeps one ledger entry from filling a
|
|
972
|
+
* downstream agent's context.
|
|
973
|
+
*/
|
|
974
|
+
function renderSafeField(value) {
|
|
975
|
+
if (typeof value !== 'string')
|
|
976
|
+
return '';
|
|
977
|
+
const flat = collapseControlChars(value).trim();
|
|
978
|
+
return flat.length > TRACE_RENDERED_FIELD_MAX
|
|
979
|
+
? `${flat.slice(0, TRACE_RENDERED_FIELD_MAX)}…`
|
|
980
|
+
: flat;
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Render the `## Practices Applied` section from the ledger.
|
|
984
|
+
*
|
|
985
|
+
* D-02 retires the hand-authoring of this section; D-03 PRESERVES the surface
|
|
986
|
+
* by generating it, so the two can never disagree and nothing downstream has
|
|
987
|
+
* to move. The heading has far more reach than its size suggests — the code
|
|
988
|
+
* reviewer's cross-plan reconciliation contract reads it by name, a capability
|
|
989
|
+
* contribution reads it, and four test files pin the literal string. The
|
|
990
|
+
* output must therefore be substitutable for what the executor writes today,
|
|
991
|
+
* which is why the line shape and the three-word vocabulary are reproduced
|
|
992
|
+
* rather than improved.
|
|
993
|
+
*/
|
|
994
|
+
function renderPracticesApplied(statement) {
|
|
995
|
+
const entries = statement?.predicate?.practices ?? [];
|
|
996
|
+
const lines = [PRACTICES_APPLIED_HEADING, ''];
|
|
997
|
+
if (entries.length === 0) {
|
|
998
|
+
// Never an empty section: D-04 requires a degraded or empty run to be
|
|
999
|
+
// READABLE as a fact. An omitted section and an unrecorded one look
|
|
1000
|
+
// identical to every downstream reader.
|
|
1001
|
+
lines.push('No client-vault practices were in scope for this plan (the recorded practice ledger is empty).');
|
|
1002
|
+
return lines.join('\n');
|
|
1003
|
+
}
|
|
1004
|
+
for (const entry of entries) {
|
|
1005
|
+
// 03-REVIEW CR-03, render half. The id is the only client-authored token
|
|
1006
|
+
// that reaches this line, and this line is read UNFENCED by another agent.
|
|
1007
|
+
// A refusal keeps the entry's POSITION — pushing nothing would let a
|
|
1008
|
+
// hostile ledger quietly shorten the section, which reads as "fewer
|
|
1009
|
+
// practices were in scope" rather than "something was refused here".
|
|
1010
|
+
if (typeof entry?.id !== 'string' || !TRACE_ID_PATTERN.test(entry.id)) {
|
|
1011
|
+
lines.push(TRACE_REFUSED_ID_LINE);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const statusText = renderSafeField(entry.status);
|
|
1015
|
+
const status = statusText !== '' ? statusText : 'not-applicable';
|
|
1016
|
+
const reason = renderSafeField(entry.reason);
|
|
1017
|
+
lines.push(reason === '' ? `- ${entry.id}: ${status}` : `- ${entry.id}: ${status} — ${reason}`);
|
|
1018
|
+
}
|
|
1019
|
+
return lines.join('\n');
|
|
1020
|
+
}
|
|
1021
|
+
// ─── The parser ────────────────────────────────────────────────────────────────
|
|
1022
|
+
/**
|
|
1023
|
+
* The frontmatter key the trace is anchored on. The parser locates THIS key and
|
|
1024
|
+
* its indent, exactly as `parseMustHavesBlock` anchors on `must_haves:`.
|
|
1025
|
+
*/
|
|
1026
|
+
const TRACE_BLOCK_KEY = 'vault';
|
|
1027
|
+
/** A complete double-quoted scalar, allowing escaped quotes and backslashes. */
|
|
1028
|
+
const QUOTED_RE = /^"(?:[^"\\]|\\.)*"$/;
|
|
1029
|
+
/**
|
|
1030
|
+
* Key-value recognizer, carrying the whitespace-after-colon guard.
|
|
1031
|
+
*
|
|
1032
|
+
* The `\s+` after the colon is NOT cosmetic. YAML key-value always has at
|
|
1033
|
+
* least one space after the colon, and requiring it is what stops
|
|
1034
|
+
* `Class::Method` and `db:seed` being mis-read as a key-value pair when they
|
|
1035
|
+
* appear as a plain scalar (#2757). Without it, a hand-edited note reading
|
|
1036
|
+
* `db:seed was used` silently becomes the object `{ db: 'seed was used' }` and
|
|
1037
|
+
* the round-trip stops being an identity.
|
|
1038
|
+
*/
|
|
1039
|
+
const KV_RE = /^([A-Za-z0-9_-]+):(?:\s+(.*))?$/;
|
|
1040
|
+
function unquoteScalar(s) {
|
|
1041
|
+
return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
1042
|
+
}
|
|
1043
|
+
function parseScalar(raw) {
|
|
1044
|
+
const s = raw.trim();
|
|
1045
|
+
if (s === '')
|
|
1046
|
+
return '';
|
|
1047
|
+
if (s === 'null')
|
|
1048
|
+
return null;
|
|
1049
|
+
if (s === '[]')
|
|
1050
|
+
return [];
|
|
1051
|
+
if (QUOTED_RE.test(s))
|
|
1052
|
+
return unquoteScalar(s);
|
|
1053
|
+
if (/^\[.*\]$/.test(s)) {
|
|
1054
|
+
const inner = s.slice(1, -1);
|
|
1055
|
+
const quoted = inner.match(/"(?:[^"\\]|\\.)*"/g);
|
|
1056
|
+
if (quoted)
|
|
1057
|
+
return quoted.map(unquoteScalar);
|
|
1058
|
+
return inner
|
|
1059
|
+
.split(',')
|
|
1060
|
+
.map((x) => x.trim())
|
|
1061
|
+
.filter((x) => x !== '');
|
|
1062
|
+
}
|
|
1063
|
+
if (/^-?\d+$/.test(s))
|
|
1064
|
+
return Number(s);
|
|
1065
|
+
return s;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* The three keys that are never assigned from document text (03-REVIEW CR-05).
|
|
1069
|
+
*
|
|
1070
|
+
* `KV_RE`'s character class matches `__proto__`, and a key carrying a nested
|
|
1071
|
+
* block is assigned an OBJECT — which reassigns the prototype of the plain
|
|
1072
|
+
* accumulator the parser is building. A block whose whole body sat under
|
|
1073
|
+
* `__proto__` therefore validated clean while neither frozen literal appeared
|
|
1074
|
+
* anywhere a `grep` would find it (T-03-32/T-03-33). For a capability whose
|
|
1075
|
+
* product is a provable, READABLE audit record, the gate and the document text
|
|
1076
|
+
* disagreeing is the defect itself.
|
|
1077
|
+
*
|
|
1078
|
+
* The guard sits at the key-recognition boundary — this project's stated
|
|
1079
|
+
* convention for prototype guards, and the same three-key shape the phase's own
|
|
1080
|
+
* integration-test helper already implements — so BOTH `parseListItem` and
|
|
1081
|
+
* `parseValueLines` inherit one guard instead of each growing their own.
|
|
1082
|
+
*/
|
|
1083
|
+
const FORBIDDEN_KEYS = Object.freeze(new Set(['__proto__', 'constructor', 'prototype']));
|
|
1084
|
+
function matchKeyValue(text) {
|
|
1085
|
+
const m = text.match(KV_RE);
|
|
1086
|
+
if (!m || FORBIDDEN_KEYS.has(m[1]))
|
|
1087
|
+
return null;
|
|
1088
|
+
return { key: m[1], value: (m[2] ?? '').trim() };
|
|
1089
|
+
}
|
|
1090
|
+
function parseListItem(head, children) {
|
|
1091
|
+
const trimmed = head.trim();
|
|
1092
|
+
// A fully-quoted item is a STRING, checked before the key-value branch so a
|
|
1093
|
+
// quoted value containing a colon can never be re-read as a mapping.
|
|
1094
|
+
if (QUOTED_RE.test(trimmed) && children.length === 0)
|
|
1095
|
+
return unquoteScalar(trimmed);
|
|
1096
|
+
const kv = matchKeyValue(trimmed);
|
|
1097
|
+
if (!kv) {
|
|
1098
|
+
if (children.length === 0)
|
|
1099
|
+
return parseScalar(trimmed);
|
|
1100
|
+
return parseValueLines(children);
|
|
1101
|
+
}
|
|
1102
|
+
// Null prototype as defence in depth (CR-05): a key that ever slipped past
|
|
1103
|
+
// the recognizer above still cannot reach a prototype from here.
|
|
1104
|
+
const obj = Object.create(null);
|
|
1105
|
+
obj[kv.key] = kv.value === '' ? {} : parseScalar(kv.value);
|
|
1106
|
+
if (children.length > 0) {
|
|
1107
|
+
const rest = parseValueLines(children);
|
|
1108
|
+
if (rest && typeof rest === 'object' && !Array.isArray(rest)) {
|
|
1109
|
+
Object.assign(obj, rest);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return obj;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Parse a run of block lines that are all more indented than their parent.
|
|
1116
|
+
*
|
|
1117
|
+
* The technique is copied from `parseMustHavesBlock` and not the function
|
|
1118
|
+
* itself: that one is anchored on its own literal key, and widening it would
|
|
1119
|
+
* dissolve the very shape that makes it work. The five moves reproduced here
|
|
1120
|
+
* are — capture the parent indent; require the nested block be more indented;
|
|
1121
|
+
* detect the list-item indent from the first `- `; treat a deeper indent under
|
|
1122
|
+
* an object item as a continuation key-value; and stop at the first line whose
|
|
1123
|
+
* indent is not greater than the parent's.
|
|
1124
|
+
*/
|
|
1125
|
+
function parseValueLines(lines) {
|
|
1126
|
+
if (lines.length === 0)
|
|
1127
|
+
return {};
|
|
1128
|
+
const base = lines[0].indent;
|
|
1129
|
+
if (lines[0].text.startsWith('- ')) {
|
|
1130
|
+
const items = [];
|
|
1131
|
+
let i = 0;
|
|
1132
|
+
while (i < lines.length) {
|
|
1133
|
+
if (lines[i].indent !== base || !lines[i].text.startsWith('- ')) {
|
|
1134
|
+
i += 1;
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
const head = lines[i].text.slice(2);
|
|
1138
|
+
const children = [];
|
|
1139
|
+
let j = i + 1;
|
|
1140
|
+
while (j < lines.length && lines[j].indent > base) {
|
|
1141
|
+
children.push(lines[j]);
|
|
1142
|
+
j += 1;
|
|
1143
|
+
}
|
|
1144
|
+
items.push(parseListItem(head, children));
|
|
1145
|
+
i = j;
|
|
1146
|
+
}
|
|
1147
|
+
return items;
|
|
1148
|
+
}
|
|
1149
|
+
// Null prototype as defence in depth (CR-05) — see `FORBIDDEN_KEYS`.
|
|
1150
|
+
const obj = Object.create(null);
|
|
1151
|
+
let i = 0;
|
|
1152
|
+
while (i < lines.length) {
|
|
1153
|
+
if (lines[i].indent !== base) {
|
|
1154
|
+
i += 1;
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
const kv = matchKeyValue(lines[i].text);
|
|
1158
|
+
if (!kv) {
|
|
1159
|
+
i += 1;
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
const children = [];
|
|
1163
|
+
let j = i + 1;
|
|
1164
|
+
while (j < lines.length && lines[j].indent > base) {
|
|
1165
|
+
children.push(lines[j]);
|
|
1166
|
+
j += 1;
|
|
1167
|
+
}
|
|
1168
|
+
obj[kv.key] = kv.value === '' ? parseValueLines(children) : parseScalar(kv.value);
|
|
1169
|
+
i = j;
|
|
1170
|
+
}
|
|
1171
|
+
return obj;
|
|
1172
|
+
}
|
|
1173
|
+
/** Collect the block's lines, anchored on `vault:` and bounded by its indent. */
|
|
1174
|
+
function collectBlockLines(content) {
|
|
1175
|
+
const fm = String(content ?? '').match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
|
1176
|
+
if (!fm)
|
|
1177
|
+
return null;
|
|
1178
|
+
const lines = fm[1].split(/\r?\n/);
|
|
1179
|
+
// 03-REVIEW WR-09: anchor on the TOP-LEVEL key only — no leading-whitespace
|
|
1180
|
+
// capture. The trace is only ever WRITTEN at the top level of the
|
|
1181
|
+
// frontmatter, so a nested `vault:` sub-key is not a trace. Taking the first
|
|
1182
|
+
// match at any indent let an unrelated nested key (a config echo, a tool
|
|
1183
|
+
// block, a hand-added note) shadow the real ledger: the collected lines were
|
|
1184
|
+
// that node's children, the statement normalised to empty, and the gate
|
|
1185
|
+
// reported `trace_unparsable`. Fail-closed, so never a bypass — but an
|
|
1186
|
+
// UNRECOVERABLE false block, because the block's own remedy ("regenerate the
|
|
1187
|
+
// block") re-emits the same second `vault:` key (T-03-35).
|
|
1188
|
+
const anchorRe = new RegExp(`^${TRACE_BLOCK_KEY}:\\s*$`);
|
|
1189
|
+
const anchorIndent = 0;
|
|
1190
|
+
let anchor = -1;
|
|
1191
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1192
|
+
if (anchorRe.test(lines[i])) {
|
|
1193
|
+
anchor = i;
|
|
1194
|
+
break;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (anchor === -1)
|
|
1198
|
+
return null;
|
|
1199
|
+
const out = [];
|
|
1200
|
+
for (let i = anchor + 1; i < lines.length; i += 1) {
|
|
1201
|
+
const line = lines[i];
|
|
1202
|
+
if (line.trim() === '')
|
|
1203
|
+
continue;
|
|
1204
|
+
const indent = line.match(/^(\s*)/)[1].length;
|
|
1205
|
+
if (indent <= anchorIndent)
|
|
1206
|
+
break;
|
|
1207
|
+
out.push({ indent, text: line.trim() });
|
|
1208
|
+
}
|
|
1209
|
+
return out;
|
|
1210
|
+
}
|
|
1211
|
+
/** How many content lines sit under a nested `practices:` key (0 if inline). */
|
|
1212
|
+
function practicesContentLineCount(lines) {
|
|
1213
|
+
const idx = lines.findIndex((l) => l.text === 'practices:');
|
|
1214
|
+
if (idx === -1)
|
|
1215
|
+
return 0;
|
|
1216
|
+
const base = lines[idx].indent;
|
|
1217
|
+
let n = 0;
|
|
1218
|
+
for (let i = idx + 1; i < lines.length; i += 1) {
|
|
1219
|
+
if (lines[i].indent <= base)
|
|
1220
|
+
break;
|
|
1221
|
+
n += 1;
|
|
1222
|
+
}
|
|
1223
|
+
return n;
|
|
1224
|
+
}
|
|
1225
|
+
function asObject(v) {
|
|
1226
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
1227
|
+
}
|
|
1228
|
+
function asArray(v) {
|
|
1229
|
+
return Array.isArray(v) ? v : [];
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Render a parsed scalar as a string WITHOUT ever falling through to
|
|
1233
|
+
* `[object Object]`. A string passes through byte-identically, which is what
|
|
1234
|
+
* keeps the round-trip an identity; anything else is a shape the emitter never
|
|
1235
|
+
* produces, so it is rendered defensively rather than trusted.
|
|
1236
|
+
*/
|
|
1237
|
+
function stringifyScalar(v) {
|
|
1238
|
+
if (typeof v === 'string')
|
|
1239
|
+
return v;
|
|
1240
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
1241
|
+
return String(v);
|
|
1242
|
+
if (v === null || v === undefined)
|
|
1243
|
+
return '';
|
|
1244
|
+
try {
|
|
1245
|
+
return JSON.stringify(v) ?? '';
|
|
1246
|
+
}
|
|
1247
|
+
catch {
|
|
1248
|
+
return '';
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
function asStringOrNull(v) {
|
|
1252
|
+
if (v === null || v === undefined)
|
|
1253
|
+
return null;
|
|
1254
|
+
return stringifyScalar(v);
|
|
1255
|
+
}
|
|
1256
|
+
function asString(v) {
|
|
1257
|
+
return stringifyScalar(v);
|
|
1258
|
+
}
|
|
1259
|
+
const ABSENT = Object.freeze({
|
|
1260
|
+
present: false,
|
|
1261
|
+
reason: 'no_vault_block',
|
|
1262
|
+
statement: null,
|
|
1263
|
+
});
|
|
1264
|
+
/**
|
|
1265
|
+
* Read a `vault:` trace block back out of a document.
|
|
1266
|
+
*
|
|
1267
|
+
* Returns a documented ABSENT result rather than throwing when the document
|
|
1268
|
+
* carries no block: handlers in this repository never throw and adapters
|
|
1269
|
+
* return a structured reason, and the parser sits on the adapter side of that
|
|
1270
|
+
* line.
|
|
1271
|
+
*
|
|
1272
|
+
* @param content The full document text (frontmatter included).
|
|
1273
|
+
* @param artifactPath Named in the empty-parse diagnostic so an operator knows
|
|
1274
|
+
* WHICH file to look at.
|
|
1275
|
+
*/
|
|
1276
|
+
function parseVaultTraceBlock(content, artifactPath = 'the artifact') {
|
|
1277
|
+
const lines = collectBlockLines(content);
|
|
1278
|
+
if (lines === null || lines.length === 0)
|
|
1279
|
+
return ABSENT;
|
|
1280
|
+
const root = asObject(parseValueLines(lines));
|
|
1281
|
+
const predicate = asObject(root.predicate);
|
|
1282
|
+
const vault = asObject(predicate.vault);
|
|
1283
|
+
const practices = asArray(predicate.practices).map((raw) => {
|
|
1284
|
+
const p = asObject(raw);
|
|
1285
|
+
const entry = {
|
|
1286
|
+
id: asString(p.id),
|
|
1287
|
+
scope: asStringOrNull(p.scope),
|
|
1288
|
+
severity: asStringOrNull(p.severity),
|
|
1289
|
+
status: typeof p.status === 'string' ? p.status : '',
|
|
1290
|
+
reason: typeof p.reason === 'string' ? p.reason : '',
|
|
1291
|
+
tasks: asArray(p.tasks).map((t) => asString(t)),
|
|
1292
|
+
};
|
|
1293
|
+
// Optional (10-08): reconstructed ONLY when the document carried it, so a
|
|
1294
|
+
// trace written before this phase parses back into the exact entry it
|
|
1295
|
+
// parsed into then. An `undefined` property would not be that.
|
|
1296
|
+
if (typeof p.enforcedBy === 'string' && p.enforcedBy !== '')
|
|
1297
|
+
entry.enforcedBy = p.enforcedBy;
|
|
1298
|
+
// Optional (D-05-07): reconstructed ONLY when the document carried it,
|
|
1299
|
+
// on the same reasoning as `enforcedBy` above.
|
|
1300
|
+
if (typeof p.textSha256 === 'string' && p.textSha256 !== '')
|
|
1301
|
+
entry.textSha256 = p.textSha256;
|
|
1302
|
+
return entry;
|
|
1303
|
+
});
|
|
1304
|
+
// A silently empty parse is not merely unhelpful here: a downstream
|
|
1305
|
+
// validator checks that dispositions cover the emitted id set one-to-one, so
|
|
1306
|
+
// an empty parse and a genuine coverage failure would produce the same
|
|
1307
|
+
// verdict from different causes. Say which it was.
|
|
1308
|
+
const contentLines = practicesContentLineCount(lines);
|
|
1309
|
+
if (practices.length === 0 && contentLines > 0) {
|
|
1310
|
+
// 03-REVIEW WR-03: `artifactPath` is built from `fs.readdirSync` output or
|
|
1311
|
+
// straight from argv, and POSIX permits a newline in a filename — the same
|
|
1312
|
+
// fact `boundedToken`'s docstring cites. Sanitized HERE, inside the leaf,
|
|
1313
|
+
// so the guard does not depend on every caller remembering; the check
|
|
1314
|
+
// router sanitizes independently and both defences are wanted (T-03-36).
|
|
1315
|
+
const safeArtifactPath = boundedToken(collapseControlChars(String(artifactPath)));
|
|
1316
|
+
process.stderr.write(`[pp-tools] WARNING: the \`${TRACE_BLOCK_KEY}:\` trace block in ${safeArtifactPath} has ` +
|
|
1317
|
+
`${contentLines} practice content line(s) but parsed 0 entries. The trace is unreadable, ` +
|
|
1318
|
+
`which is NOT the same as an empty practice set — do not treat this as a clean run. ` +
|
|
1319
|
+
`Regenerate the block rather than hand-repairing it.\n`);
|
|
1320
|
+
}
|
|
1321
|
+
const subject = asArray(root.subject).map((raw) => {
|
|
1322
|
+
const s = asObject(raw);
|
|
1323
|
+
const entry = { name: asString(s.name) };
|
|
1324
|
+
if (typeof s.uri === 'string')
|
|
1325
|
+
entry.uri = s.uri;
|
|
1326
|
+
const digest = asObject(s.digest);
|
|
1327
|
+
if (typeof digest.gitCommit === 'string')
|
|
1328
|
+
entry.digest = { gitCommit: digest.gitCommit };
|
|
1329
|
+
return entry;
|
|
1330
|
+
});
|
|
1331
|
+
const rawOverride = predicate.override;
|
|
1332
|
+
let override = null;
|
|
1333
|
+
if (rawOverride && typeof rawOverride === 'object' && !Array.isArray(rawOverride)) {
|
|
1334
|
+
const o = asObject(rawOverride);
|
|
1335
|
+
override = {
|
|
1336
|
+
gate: asString(o.gate),
|
|
1337
|
+
reason: asString(o.reason),
|
|
1338
|
+
suppressedReason: asString(o.suppressedReason),
|
|
1339
|
+
who: asString(o.who),
|
|
1340
|
+
when: asString(o.when),
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
const statement = {
|
|
1344
|
+
_type: asString(root._type),
|
|
1345
|
+
predicateType: asString(root.predicateType),
|
|
1346
|
+
schemaVersion: typeof root.schemaVersion === 'number' ? root.schemaVersion : Number(root.schemaVersion),
|
|
1347
|
+
subject,
|
|
1348
|
+
predicate: {
|
|
1349
|
+
stage: asString(predicate.stage),
|
|
1350
|
+
plan: asString(predicate.plan),
|
|
1351
|
+
generatedAt: asString(predicate.generatedAt),
|
|
1352
|
+
vault: {
|
|
1353
|
+
state: asString(vault.state),
|
|
1354
|
+
reason: asStringOrNull(vault.reason),
|
|
1355
|
+
ref: asStringOrNull(vault.ref),
|
|
1356
|
+
resolvedSha: asStringOrNull(vault.resolvedSha),
|
|
1357
|
+
// The `notes` parse partner's shape, with the optional-key absent case
|
|
1358
|
+
// made explicit: a present list (even `[]`) reconstructs element by
|
|
1359
|
+
// element; an absent or non-list value reconstructs as null, which is
|
|
1360
|
+
// how the writer spells "no universe was recorded" (degraded run).
|
|
1361
|
+
practiceUniverse: Array.isArray(vault.practiceUniverse)
|
|
1362
|
+
? asArray(vault.practiceUniverse).map((v) => asString(v))
|
|
1363
|
+
: null,
|
|
1364
|
+
// The universe's absent-case rule, applied to a number: a present
|
|
1365
|
+
// integer count (INCLUDING zero) reconstructs as itself; anything else
|
|
1366
|
+
// — absent, a string, a float, a negative — reconstructs as null, which
|
|
1367
|
+
// is how the writer spells "no census was recorded". It is deliberately
|
|
1368
|
+
// NOT coerced with `Number(...)`: a coercion would turn a hand-edited
|
|
1369
|
+
// `checkBearingPractices: none` into `NaN` and a blank into `0`, and
|
|
1370
|
+
// that `0` would be a claim the document never made.
|
|
1371
|
+
checkBearingPractices: typeof vault.checkBearingPractices === 'number' &&
|
|
1372
|
+
Number.isInteger(vault.checkBearingPractices) &&
|
|
1373
|
+
vault.checkBearingPractices >= 0
|
|
1374
|
+
? vault.checkBearingPractices
|
|
1375
|
+
: null,
|
|
1376
|
+
tier: asStringOrNull(vault.tier),
|
|
1377
|
+
source: asStringOrNull(vault.source),
|
|
1378
|
+
// VAULT-EGRESS-1: a trace written before the pin was display-safe carries the raw
|
|
1379
|
+
// coordinate; normalised here, at the one parse site every persisted-trace reader shares.
|
|
1380
|
+
repo: vaultMod.redactCloneTarget(asStringOrNull(vault.repo)),
|
|
1381
|
+
},
|
|
1382
|
+
practices,
|
|
1383
|
+
notes: asArray(predicate.notes).map((n) => asString(n)),
|
|
1384
|
+
override,
|
|
1385
|
+
},
|
|
1386
|
+
};
|
|
1387
|
+
return { present: true, reason: null, statement };
|
|
1388
|
+
}
|
|
1389
|
+
// ─── The validator (Phase 03-04, TRACE-04 / D-09) ─────────────────────────────
|
|
1390
|
+
//
|
|
1391
|
+
// SCOPE, AND WHY THE SEAM SITS EXACTLY HERE. This validator implements the
|
|
1392
|
+
// checks that need NO vault read: the block is there, it parsed, the required
|
|
1393
|
+
// keys are present, the pin is non-null when the state is `loaded`, the
|
|
1394
|
+
// dispositions cover the emitted id set one-to-one, every status is in the
|
|
1395
|
+
// three-value vocabulary, and every `deviated` entry carries a non-empty
|
|
1396
|
+
// reason. Plus the version discipline the frozen document states.
|
|
1397
|
+
//
|
|
1398
|
+
// Phase 3 named TWO checks as belonging to Phase 4 and deliberately did not
|
|
1399
|
+
// build them. BOTH are now closed, and the record of what was deferred is kept
|
|
1400
|
+
// rather than deleted — a deleted note loses the fact that the gap existed:
|
|
1401
|
+
//
|
|
1402
|
+
// 1. resolving each practice id against the vault manifest at the pinned SHA
|
|
1403
|
+
// — CLOSED by plan 04-05 (TGATE-01), and closed WITHOUT crossing the seam.
|
|
1404
|
+
// It never needed a vault read: 04-02 recorded the manifest's practice-ID
|
|
1405
|
+
// universe INTO the statement (D-01), so resolution is set membership over
|
|
1406
|
+
// data already in hand. The seam Phase 3 drew — "does the check have to
|
|
1407
|
+
// read the vault" — is exactly what let it close: the answer was no.
|
|
1408
|
+
// 2. proving the executor's disposition list covers the planner's — CLOSED by
|
|
1409
|
+
// plan 04-07 (TGATE-01, D-09), and NOT inside this function. It compares
|
|
1410
|
+
// TWO statements, which a single-statement validator cannot do, so the
|
|
1411
|
+
// closure took the form of a SECOND entry point in this leaf,
|
|
1412
|
+
// `comparePracticeCoverage`, which the gate handler composes into its own
|
|
1413
|
+
// verdict. Nothing below compares two statements, and nothing below should.
|
|
1414
|
+
//
|
|
1415
|
+
// The seam is a PROPERTY OF THE CHECK — whether it has to read the vault — not
|
|
1416
|
+
// a judgement about difficulty. Nothing in this region calls the manifest
|
|
1417
|
+
// reader or the practice matcher; an integration fence asserts that
|
|
1418
|
+
// mechanically over this function's source, and it is what keeps the verdict a
|
|
1419
|
+
// function of the record rather than of the machine it ran on.
|
|
1420
|
+
//
|
|
1421
|
+
// A NOTE ON THE PARSER AND THE REQUIRED-KEY CHECK. `parseVaultTraceBlock`
|
|
1422
|
+
// NORMALISES: it always constructs a full statement, filling an absent scalar
|
|
1423
|
+
// with `''` or `null`. So on the handler's path a missing key arrives as a
|
|
1424
|
+
// present-but-empty one, and the required-key check is not what catches it —
|
|
1425
|
+
// the VALUE checks below are (an empty `_type` fails the literal comparison, a
|
|
1426
|
+
// null `ref` at `loaded` fails the pin, an empty `status` at execute fails
|
|
1427
|
+
// coverage). The required-key list is nonetheless the load-bearing contract
|
|
1428
|
+
// surface: it is what `docs/vault-trace-format.md` states, what plan 03-08
|
|
1429
|
+
// compares the document against from both ends, and what a caller building a
|
|
1430
|
+
// statement by hand — as Phase 4 and Phase 5 will — is checked against.
|
|
1431
|
+
/**
|
|
1432
|
+
* The required-key list, per stage, written in the SAME path vocabulary as the
|
|
1433
|
+
* freeze table in `docs/vault-trace-format.md`: the `vault.` prefix is the
|
|
1434
|
+
* frontmatter key, and `[]` means "for every element of that list".
|
|
1435
|
+
*
|
|
1436
|
+
* The two stages differ in exactly one entry each, which is the document's own
|
|
1437
|
+
* plan/execute split: a plan-stage subject entry carries `uri` and no digest
|
|
1438
|
+
* (the governed files do not exist yet), an execute-stage one carries the
|
|
1439
|
+
* `gitCommit` digest that makes the statement falsifiable.
|
|
1440
|
+
*
|
|
1441
|
+
* Keys the document marks **required key** (present, value may be null) are in
|
|
1442
|
+
* this list; keys it marks optional — `reason`, `scope`, `severity`, `tasks`,
|
|
1443
|
+
* `notes` — are not.
|
|
1444
|
+
*/
|
|
1445
|
+
const TRACE_REQUIRED_KEYS_COMMON = [
|
|
1446
|
+
'vault._type',
|
|
1447
|
+
'vault.predicateType',
|
|
1448
|
+
'vault.schemaVersion',
|
|
1449
|
+
'vault.subject',
|
|
1450
|
+
'vault.subject[].name',
|
|
1451
|
+
'vault.predicate.stage',
|
|
1452
|
+
'vault.predicate.plan',
|
|
1453
|
+
'vault.predicate.generatedAt',
|
|
1454
|
+
'vault.predicate.vault.state',
|
|
1455
|
+
'vault.predicate.vault.reason',
|
|
1456
|
+
'vault.predicate.vault.ref',
|
|
1457
|
+
'vault.predicate.vault.resolvedSha',
|
|
1458
|
+
'vault.predicate.vault.tier',
|
|
1459
|
+
'vault.predicate.vault.source',
|
|
1460
|
+
'vault.predicate.vault.repo',
|
|
1461
|
+
'vault.predicate.practices',
|
|
1462
|
+
'vault.predicate.practices[].id',
|
|
1463
|
+
'vault.predicate.practices[].status',
|
|
1464
|
+
'vault.predicate.override',
|
|
1465
|
+
];
|
|
1466
|
+
const TRACE_REQUIRED_KEYS = Object.freeze({
|
|
1467
|
+
plan: Object.freeze([...TRACE_REQUIRED_KEYS_COMMON, 'vault.subject[].uri']),
|
|
1468
|
+
execute: Object.freeze([...TRACE_REQUIRED_KEYS_COMMON, 'vault.subject[].digest.gitCommit']),
|
|
1469
|
+
});
|
|
1470
|
+
/**
|
|
1471
|
+
* The one required key whose failure the generic message could not resolve
|
|
1472
|
+
* (03-REVIEW WR-10). A governed file with no commit yields no digest, so the
|
|
1473
|
+
* execute-stage trace blocks — and the generic message sent the operator back
|
|
1474
|
+
* to the scaffold, which re-emits the same digest-less entry and the same
|
|
1475
|
+
* block, indefinitely.
|
|
1476
|
+
*
|
|
1477
|
+
* The requirement itself is NOT relaxed: the per-stage table is the frozen,
|
|
1478
|
+
* documented contract, and a digest is what makes an execute-stage statement
|
|
1479
|
+
* falsifiable. Only the message changes, and only for this one path.
|
|
1480
|
+
*/
|
|
1481
|
+
const EXECUTE_DIGEST_KEY = 'vault.subject[].digest.gitCommit';
|
|
1482
|
+
const EXECUTE_DIGEST_REMEDY = ' A governed file in this trace has no git commit, so no digest could be recorded.' +
|
|
1483
|
+
' Commit the governed file first, then re-run the execute-stage trace scaffold —' +
|
|
1484
|
+
' re-running the scaffold against an uncommitted file produces the same digest-less' +
|
|
1485
|
+
' entry and the same block.';
|
|
1486
|
+
/**
|
|
1487
|
+
* The command that regenerates a trace block, named in any failure message that
|
|
1488
|
+
* has a regeneration remedy.
|
|
1489
|
+
*
|
|
1490
|
+
* D-12: the execute-side gate runs in the ORCHESTRATOR, after the SUMMARY has
|
|
1491
|
+
* been rescued out of the executor's worktree and before wave cleanup. By then
|
|
1492
|
+
* the worktree that wrote the artifact is gone, so the remedy can never be
|
|
1493
|
+
* phrased as re-running or asking whoever produced it — it has to be something
|
|
1494
|
+
* the orchestrator can do on its own, against the file named in the message.
|
|
1495
|
+
*
|
|
1496
|
+
* ONE constant, so the plan that ships the writer changes it in one place. It
|
|
1497
|
+
* lives in this leaf rather than in the gate handler (04-05) because the
|
|
1498
|
+
* validator now needs it too, and the handler already imports this module —
|
|
1499
|
+
* the reverse edge would be a cycle (the D-12 split's own lesson).
|
|
1500
|
+
*
|
|
1501
|
+
* The remedy names `pp_run`, the launcher every workflow binds in its
|
|
1502
|
+
* preamble and ships as `pp-core/bin/pp_run`, because a bare `pp-tools` can
|
|
1503
|
+
* resolve to a different install than the project's own. INVARIANT 1 derives
|
|
1504
|
+
* its floor from the command's first token, so the floor moved with the rename.
|
|
1505
|
+
* The literal pins live in the two vault-trace suites and vault-status-check.
|
|
1506
|
+
*/
|
|
1507
|
+
const TRACE_REGENERATE_COMMAND = 'pp_run vault trace scaffold --stage <plan|execute> --plan <plan-id> <files_modified...>';
|
|
1508
|
+
/**
|
|
1509
|
+
* Stable, machine-readable problem kinds. A consumer branches on these; the
|
|
1510
|
+
* `detail` string is for a human and is not a contract.
|
|
1511
|
+
*
|
|
1512
|
+
* The two coverage directions carry DIFFERENT kinds on purpose: "an emitted id
|
|
1513
|
+
* has no disposition" and "a disposition names an id nobody emitted" are two
|
|
1514
|
+
* defects with two different fixes, and collapsing them into one message makes
|
|
1515
|
+
* the second unfixable.
|
|
1516
|
+
*/
|
|
1517
|
+
const TRACE_PROBLEM_KIND = Object.freeze({
|
|
1518
|
+
ABSENT: 'trace_absent',
|
|
1519
|
+
UNPARSABLE: 'trace_unparsable',
|
|
1520
|
+
MALFORMED_INPUT: 'trace_malformed_input',
|
|
1521
|
+
MISSING_REQUIRED_KEY: 'missing_required_key',
|
|
1522
|
+
STAGE_MISMATCH: 'stage_mismatch',
|
|
1523
|
+
STATEMENT_TYPE_MISMATCH: 'statement_type_mismatch',
|
|
1524
|
+
PREDICATE_TYPE_MISMATCH: 'predicate_type_mismatch',
|
|
1525
|
+
SCHEMA_VERSION_INVALID: 'schema_version_invalid',
|
|
1526
|
+
SCHEMA_VERSION_UNSUPPORTED: 'schema_version_unsupported',
|
|
1527
|
+
VAULT_STATE_INVALID: 'vault_state_invalid',
|
|
1528
|
+
PIN_MISSING: 'pin_missing',
|
|
1529
|
+
// 04-05 (TGATE-01, D-01): the statement-shape sibling of PIN_MISSING, and it
|
|
1530
|
+
// sits here beside it rather than with the practice-entry family because the
|
|
1531
|
+
// defect is of the RECORD AS A WHOLE — this trace is incomplete — not of any
|
|
1532
|
+
// one entry. It arises from the same read as the pin: a loaded run that wrote
|
|
1533
|
+
// a pin but no universe recorded half of what it saw. Filing it against an
|
|
1534
|
+
// entry would mean naming an offending id, and there is none to name — every
|
|
1535
|
+
// id is equally unresolvable when the universe is absent.
|
|
1536
|
+
UNIVERSE_MISSING: 'practice_universe_missing',
|
|
1537
|
+
// 04-07 (TGATE-01, D-03): the statement-shape sibling of PIN_MISSING and
|
|
1538
|
+
// UNIVERSE_MISSING, and — uniquely in this table — ADVISORY. The handler
|
|
1539
|
+
// classifies it so, and it must NEVER contribute to a block.
|
|
1540
|
+
//
|
|
1541
|
+
// WHY ADVISORY, ARGUED. Whether the vault has MOVED since this trace was
|
|
1542
|
+
// written is a different question from whether this trace is VALID. The
|
|
1543
|
+
// statement is self-verifying: it carries its own pin and its own universe,
|
|
1544
|
+
// so its verdict is a function of the record rather than of the machine that
|
|
1545
|
+
// happens to hold a cache. A blocking divergence would invert that — every
|
|
1546
|
+
// machine whose cache advanced past the pin would fail its build for a fact
|
|
1547
|
+
// about the cache, not about the artifact. D-03's binding content is "a note,
|
|
1548
|
+
// never a block", and this member is that note's carrier.
|
|
1549
|
+
//
|
|
1550
|
+
// It is a ROUTER-side emission, never a validator one: detecting it requires
|
|
1551
|
+
// reading the local manifest, and FENCE 1 asserts the validator reads no
|
|
1552
|
+
// manifest. The kind lives here because this table owns STATEMENT-LEVEL kinds
|
|
1553
|
+
// — defects of a trace statement, which is what `pin_diverged` names — not
|
|
1554
|
+
// because the validator emits it.
|
|
1555
|
+
//
|
|
1556
|
+
// 04-11 CORRECTS THE CLAIM THAT USED TO SIT HERE. It read: "the table is the
|
|
1557
|
+
// one home for a machine-readable kind". That was never true — the gate
|
|
1558
|
+
// handler emitted nine ENVELOPE kinds as inline string literals with no table
|
|
1559
|
+
// at all, and this comment's confidence was part of why nobody noticed. There
|
|
1560
|
+
// are now two tables and the split is BY CATEGORY:
|
|
1561
|
+
//
|
|
1562
|
+
// - this one owns kinds that describe a STATEMENT: its shape, its version
|
|
1563
|
+
// discipline, its pin, its ledger entries, and the comparison of two
|
|
1564
|
+
// statements against each other;
|
|
1565
|
+
// - `TRACE_HANDLER_KIND` (src/check-command-router.cts) owns kinds that
|
|
1566
|
+
// describe the ARGUMENT the gate was invoked with, or the artifact SET it
|
|
1567
|
+
// found — neither of which a statement validator can see, because it is
|
|
1568
|
+
// handed one statement at a time and no argv at all.
|
|
1569
|
+
//
|
|
1570
|
+
// Stated as a category, and NO MEMBER of the other table is named here. A
|
|
1571
|
+
// list of the router's kinds inside a leaf comment is a stale claim waiting
|
|
1572
|
+
// to happen, and a stale list of somewhere else's kinds is the exact defect
|
|
1573
|
+
// 04-11 exists to stop repeating.
|
|
1574
|
+
PIN_DIVERGED: 'pin_diverged',
|
|
1575
|
+
COVERAGE_MISSING_DISPOSITION: 'coverage_missing_disposition',
|
|
1576
|
+
COVERAGE_UNKNOWN_DISPOSITION: 'coverage_unknown_disposition',
|
|
1577
|
+
COVERAGE_DUPLICATE_ID: 'coverage_duplicate_id',
|
|
1578
|
+
// 04-07 (TGATE-01, TGATE-03, D-09): the coverage family's THIRD direction,
|
|
1579
|
+
// and the first one that spans TWO artifacts. The two members above compare
|
|
1580
|
+
// an emitted set against a disposition set INSIDE one statement; this one
|
|
1581
|
+
// asks whether the executor's record accounts for the planner's, plan by
|
|
1582
|
+
// plan. One problem PER uncovered identifier, and filed against the SUMMARY —
|
|
1583
|
+
// the plan is not the file an operator has to fix.
|
|
1584
|
+
//
|
|
1585
|
+
// A PHASE AGGREGATE WAS REJECTED, and the reason belongs here beside the
|
|
1586
|
+
// member: an aggregate union passes VACUOUSLY. If one plan dropped every
|
|
1587
|
+
// practice while a sibling happened to carry the same identifiers, the union
|
|
1588
|
+
// still covers, and a gate that cannot fail is not a gate. It also cannot
|
|
1589
|
+
// satisfy TGATE-03, which requires naming the file to fix — an aggregate has
|
|
1590
|
+
// no file to name.
|
|
1591
|
+
COVERAGE_PLAN_UNCOVERED: 'coverage_plan_uncovered',
|
|
1592
|
+
// 04-07 (TGATE-01, D-10): a plan statement with no summary counterpart at
|
|
1593
|
+
// all. Coverage family, because "nothing covered any of it" is the limiting
|
|
1594
|
+
// case of the member above rather than a different defect.
|
|
1595
|
+
//
|
|
1596
|
+
// Emitted only where the CALLER declares the point strict. Mid-phase an
|
|
1597
|
+
// unpaired plan is normal — waves are still running and the execute-side
|
|
1598
|
+
// point fires once per wave — so a gate that always emitted this would have
|
|
1599
|
+
// blocked Phase 3 continuously across all seven of its waves. At `ship:pre`
|
|
1600
|
+
// and `verify:post` every plan must have an executed counterpart, and a phase
|
|
1601
|
+
// shipping with a planned-but-never-executed plan is a real defect this gate
|
|
1602
|
+
// can see cheaply.
|
|
1603
|
+
COVERAGE_PAIR_MISSING: 'coverage_pair_missing',
|
|
1604
|
+
// 04-07 (TGATE-01, D-11): the pair exists but the summary was generated
|
|
1605
|
+
// STRICTLY BEFORE its plan — a re-planned plan whose summary answers an
|
|
1606
|
+
// earlier version of it. Its own defect, not a coverage gap: the identifiers
|
|
1607
|
+
// may all match and the record still prove nothing.
|
|
1608
|
+
//
|
|
1609
|
+
// CR-02's lesson binds this member's DETAIL, not just its existence. Phase 3
|
|
1610
|
+
// shipped a block of exactly this shape whose message named no remedy the
|
|
1611
|
+
// reader could perform, and it had to be fixed. So the detail extends the
|
|
1612
|
+
// single `TRACE_REGENERATE_COMMAND` constant and is phrased for the
|
|
1613
|
+
// ORCHESTRATOR: at the execute-side point the executor's worktree is already
|
|
1614
|
+
// gone, and a remedy addressed to whoever wrote the artifact is a trap.
|
|
1615
|
+
COVERAGE_PAIR_STALE: 'coverage_pair_stale',
|
|
1616
|
+
// 04-13 (TGATE-01, CR-01): the pair family's FOURTH member, and the one that
|
|
1617
|
+
// closes this phase's own fail-open. A pair whose halves are both present and
|
|
1618
|
+
// both well-formed can still prove nothing. If either half records a vault
|
|
1619
|
+
// state other than `loaded`, the ledger answering the plan was written
|
|
1620
|
+
// against a vault that was never read — so an empty ledger and a complete one
|
|
1621
|
+
// are indistinguishable, and the two judgements above are both vacuous.
|
|
1622
|
+
// Until this member the comparison returned early on that state with no
|
|
1623
|
+
// problem of any kind, so the strict `ship:pre` and `verify:post` gates
|
|
1624
|
+
// answered `block: false` with ZERO problems over a record accounting for
|
|
1625
|
+
// nothing, and the phase shipped recorded as governed.
|
|
1626
|
+
//
|
|
1627
|
+
// A DEFECT OF THE PAIR — argued, not asserted, which is why it sits here
|
|
1628
|
+
// beside COVERAGE_PAIR_MISSING and COVERAGE_PAIR_STALE.
|
|
1629
|
+
//
|
|
1630
|
+
// - NOT the practice-entry family. An unprovable pair names NO offending
|
|
1631
|
+
// identifier, because when the answering half proved nothing every
|
|
1632
|
+
// identifier is equally unaccounted for. Filing one problem per promised
|
|
1633
|
+
// id would hand an operator N things to chase for ONE cause, and point
|
|
1634
|
+
// each of them at a ledger section where the fix does not live.
|
|
1635
|
+
// - NOT the statement-shape family. Neither half is malformed: each is a
|
|
1636
|
+
// CORRECT record of what really happened (D-04), and Phase 3 requires a
|
|
1637
|
+
// degraded run to write exactly this record. Filing it against a
|
|
1638
|
+
// statement would say the record is wrong when the record is right.
|
|
1639
|
+
// - It IS the relation that fails: this summary cannot answer this plan.
|
|
1640
|
+
// That is the same category as "there is no summary" and "the summary
|
|
1641
|
+
// answers an earlier plan", and it is one problem per PAIR for the same
|
|
1642
|
+
// reason those are.
|
|
1643
|
+
//
|
|
1644
|
+
// EMITTED ONLY UNDER THE STRICT MODE, on COVERAGE_PAIR_MISSING's reasoning
|
|
1645
|
+
// exactly (D-10). At the two write points the question is whether the record
|
|
1646
|
+
// is CORRECT, and a degraded record is correct. At `ship:pre` and
|
|
1647
|
+
// `verify:post` the question is whether it PROVES the phase was governed, and
|
|
1648
|
+
// it does not. One artifact, two questions, one flag already parsed for the
|
|
1649
|
+
// sibling member above — see the state gate in `comparePracticeCoverage` for
|
|
1650
|
+
// the whole argument, including the remedy asymmetry that makes the strict
|
|
1651
|
+
// arm a gate rather than a trap.
|
|
1652
|
+
COVERAGE_PAIR_UNPROVABLE: 'coverage_pair_unprovable',
|
|
1653
|
+
// 03-REVIEW CR-03: the READ path's half of the charset the write path has
|
|
1654
|
+
// always applied. Sits with the practice-entry family because it is a defect
|
|
1655
|
+
// of one entry, not of the statement's shape.
|
|
1656
|
+
ID_CHARSET_INVALID: 'practice_id_invalid',
|
|
1657
|
+
// 04-05 (TGATE-01, D-01): the read side's verdict on the universe 04-02 put
|
|
1658
|
+
// into the statement. Sits with the practice-entry family beside the charset
|
|
1659
|
+
// kind — and for the same reason CR-03 put that one here — because it is a
|
|
1660
|
+
// defect of ONE entry: this id, in this ledger, resolves nowhere. That
|
|
1661
|
+
// entry-level placement is what lets the detail name the id at fault, which
|
|
1662
|
+
// is TGATE-03's core clause and is only possible at all because D-02 chose an
|
|
1663
|
+
// id LIST over a digest: a digest could prove disagreement but never say
|
|
1664
|
+
// which id caused it.
|
|
1665
|
+
ID_UNRESOLVED: 'practice_id_unresolved',
|
|
1666
|
+
STATUS_OUT_OF_VOCABULARY: 'status_out_of_vocabulary',
|
|
1667
|
+
DEVIATED_REASON_MISSING: 'deviated_reason_missing',
|
|
1668
|
+
// ── THE BODY-BLOCK FAMILY (D-05-07) ────────────────────
|
|
1669
|
+
//
|
|
1670
|
+
// Seven kinds that compare the `vault:` LEDGER against the `<vault_practices>`
|
|
1671
|
+
// BODY BLOCK of the same plan artifact. Measured on a fresh project: seven
|
|
1672
|
+
// perturbations that removed or
|
|
1673
|
+
// gutted the body block — the only practice text the executor reads — all
|
|
1674
|
+
// answered `block: false`, because nothing read the block back. A plan could
|
|
1675
|
+
// record full governance and deliver none. These kinds end that silence.
|
|
1676
|
+
//
|
|
1677
|
+
// WHY THE LEAF TABLE. They are defects of ONE artifact's record — its two
|
|
1678
|
+
// halves disagree — not of the argument the gate was invoked with nor of the
|
|
1679
|
+
// artifact SET, which is the category split the two tables draw. The body is
|
|
1680
|
+
// read by `validatePracticesBody`, which is handed the artifact text and the
|
|
1681
|
+
// parsed statement and nothing else: no manifest, no matcher, no disk (D-01).
|
|
1682
|
+
//
|
|
1683
|
+
// ONE-PER-ARTIFACT vs ONE-PER-ID, argued. `absent`, `duplicate`,
|
|
1684
|
+
// `state_mismatch` and `empty` are facts about the block as a whole and name
|
|
1685
|
+
// no identifier, so each is
|
|
1686
|
+
// emitted once and the per-id checks are skipped behind it — a block that
|
|
1687
|
+
// renders nothing would otherwise yield one problem per promised id for ONE
|
|
1688
|
+
// cause (the COVERAGE_PAIR_UNPROVABLE argument). The id and text kinds name
|
|
1689
|
+
// the identifier at fault, one problem each (TGATE-03).
|
|
1690
|
+
//
|
|
1691
|
+
// ALL SEVEN BLOCK. None is advisory: each means the executor is handed less
|
|
1692
|
+
// than, or other than, what the ledger records, which is exactly a record
|
|
1693
|
+
// claiming more governance than was delivered.
|
|
1694
|
+
PRACTICES_BLOCK_ABSENT: 'practices_block_absent',
|
|
1695
|
+
PRACTICES_BLOCK_DUPLICATE: 'practices_block_duplicate',
|
|
1696
|
+
// The block states the vault was NOT read (the absent / unreadable /
|
|
1697
|
+
// could-not-be-checked sentence) while the ledger records `state: loaded`.
|
|
1698
|
+
// Its own kind rather than a clause on `empty`, because it fires whatever the
|
|
1699
|
+
// ledger promises — including nothing — and names a different repair target:
|
|
1700
|
+
// the two halves disagree about the RUN, not merely about a count.
|
|
1701
|
+
PRACTICES_BLOCK_STATE_MISMATCH: 'practices_block_state_mismatch',
|
|
1702
|
+
PRACTICES_BLOCK_EMPTY: 'practices_block_empty',
|
|
1703
|
+
PRACTICES_BLOCK_ID_MISSING: 'practices_block_id_missing',
|
|
1704
|
+
PRACTICES_BLOCK_ID_UNEXPECTED: 'practices_block_id_unexpected',
|
|
1705
|
+
PRACTICES_BLOCK_TEXT_MISMATCH: 'practices_block_text_mismatch',
|
|
1706
|
+
VALIDATOR_ERROR: 'validator_error',
|
|
1707
|
+
});
|
|
1708
|
+
function isPlainObject(v) {
|
|
1709
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
1710
|
+
}
|
|
1711
|
+
/** Does `obj` own every segment of a dotted path with no `[]` in it? */
|
|
1712
|
+
function hasPlainPath(obj, dotted) {
|
|
1713
|
+
if (dotted === '')
|
|
1714
|
+
return isPlainObject(obj);
|
|
1715
|
+
let cursor = obj;
|
|
1716
|
+
for (const seg of dotted.split('.')) {
|
|
1717
|
+
if (!isPlainObject(cursor))
|
|
1718
|
+
return false;
|
|
1719
|
+
if (!Object.prototype.hasOwnProperty.call(cursor, seg))
|
|
1720
|
+
return false;
|
|
1721
|
+
cursor = cursor[seg];
|
|
1722
|
+
}
|
|
1723
|
+
return true;
|
|
1724
|
+
}
|
|
1725
|
+
function readPlainPath(obj, dotted) {
|
|
1726
|
+
if (dotted === '')
|
|
1727
|
+
return obj;
|
|
1728
|
+
let cursor = obj;
|
|
1729
|
+
for (const seg of dotted.split('.')) {
|
|
1730
|
+
if (!isPlainObject(cursor))
|
|
1731
|
+
return undefined;
|
|
1732
|
+
cursor = cursor[seg];
|
|
1733
|
+
}
|
|
1734
|
+
return cursor;
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Presence of one required-key path against a statement. The `vault.` prefix is
|
|
1738
|
+
* stripped because it names the frontmatter key, not a key of the statement.
|
|
1739
|
+
*
|
|
1740
|
+
* An EMPTY list satisfies a `[]` path vacuously and deliberately: the document
|
|
1741
|
+
* says `subject: []` is legal for a doc-only plan, and requiring `name` of a
|
|
1742
|
+
* list with no elements would reject that legal shape.
|
|
1743
|
+
*/
|
|
1744
|
+
function hasRequiredKey(statement, docPath) {
|
|
1745
|
+
const rel = docPath.replace(/^vault\.?/, '');
|
|
1746
|
+
const arrIdx = rel.indexOf('[]');
|
|
1747
|
+
if (arrIdx === -1)
|
|
1748
|
+
return hasPlainPath(statement, rel);
|
|
1749
|
+
const head = rel.slice(0, arrIdx);
|
|
1750
|
+
const tail = rel.slice(arrIdx + 2).replace(/^\./, '');
|
|
1751
|
+
const arr = readPlainPath(statement, head);
|
|
1752
|
+
if (!Array.isArray(arr))
|
|
1753
|
+
return false;
|
|
1754
|
+
if (tail === '')
|
|
1755
|
+
return true;
|
|
1756
|
+
return arr.every((el) => hasPlainPath(el, tail));
|
|
1757
|
+
}
|
|
1758
|
+
/** A short, bounded rendering of an arbitrary value for a problem detail. */
|
|
1759
|
+
function describeValue(v) {
|
|
1760
|
+
if (v === null)
|
|
1761
|
+
return 'null';
|
|
1762
|
+
if (v === undefined)
|
|
1763
|
+
return 'absent';
|
|
1764
|
+
if (typeof v === 'string')
|
|
1765
|
+
return v.length > 120 ? `${v.slice(0, 120)}…` : v;
|
|
1766
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
1767
|
+
return String(v);
|
|
1768
|
+
if (Array.isArray(v))
|
|
1769
|
+
return `a list of ${v.length}`;
|
|
1770
|
+
return 'an object';
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* A statement that reached the parser but from which nothing was read. The
|
|
1774
|
+
* parser NORMALISES an unreadable block into a full-shaped statement with empty
|
|
1775
|
+
* scalars, so "did not parse" cannot be detected from key presence — it is
|
|
1776
|
+
* detected from every identifying value being empty at once. A genuine
|
|
1777
|
+
* statement always carries `_type`.
|
|
1778
|
+
*/
|
|
1779
|
+
function statementReadNothing(st) {
|
|
1780
|
+
const predicate = isPlainObject(st['predicate']) ? st['predicate'] : {};
|
|
1781
|
+
const subject = st['subject'];
|
|
1782
|
+
const practices = predicate['practices'];
|
|
1783
|
+
return ((st['_type'] ?? '') === '' &&
|
|
1784
|
+
(st['predicateType'] ?? '') === '' &&
|
|
1785
|
+
(predicate['stage'] ?? '') === '' &&
|
|
1786
|
+
(predicate['plan'] ?? '') === '' &&
|
|
1787
|
+
(!Array.isArray(subject) || subject.length === 0) &&
|
|
1788
|
+
(!Array.isArray(practices) || practices.length === 0));
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Validate a trace statement against the structure-and-pin half of the contract
|
|
1792
|
+
* (D-09). NEVER throws, for any input: this sits on the read path for an
|
|
1793
|
+
* attacker-influenceable document, handlers in this repository never throw, and
|
|
1794
|
+
* a handler is what calls this.
|
|
1795
|
+
*
|
|
1796
|
+
* Returns EVERY defect rather than the first — an operator fixing a trace wants
|
|
1797
|
+
* the whole list at once, and Phase 4's gate output builds on the same list.
|
|
1798
|
+
*
|
|
1799
|
+
* @param input A statement, a `parseVaultTraceBlock` result, or nothing.
|
|
1800
|
+
* @param options `stage` is the stage being validated; `file` names the
|
|
1801
|
+
* artifact in every problem; `dispositions` supplies the
|
|
1802
|
+
* judgment half when the caller holds it separately.
|
|
1803
|
+
*/
|
|
1804
|
+
function validateTraceStatement(input, options) {
|
|
1805
|
+
const opts = isPlainObject(options) ? options : {};
|
|
1806
|
+
const file = typeof opts.file === 'string' && opts.file !== '' ? opts.file : 'the artifact';
|
|
1807
|
+
const stage = typeof opts.stage === 'string' ? opts.stage : '';
|
|
1808
|
+
const problems = [];
|
|
1809
|
+
// `tail` is optional and is omitted from the pushed object when absent, so a
|
|
1810
|
+
// problem that carries no remedy is byte-identical to what it was before the
|
|
1811
|
+
// field existed — an `undefined` property would change every deepEqual in
|
|
1812
|
+
// every consumer for no behavioural gain.
|
|
1813
|
+
const add = (kind, detail, tail) => {
|
|
1814
|
+
problems.push(tail === undefined ? { file, kind, detail } : { file, kind, detail, tail });
|
|
1815
|
+
};
|
|
1816
|
+
try {
|
|
1817
|
+
// ── the block is there at all (D-11: a hard cut, no grandfather clause) ──
|
|
1818
|
+
let statement = input;
|
|
1819
|
+
if (isPlainObject(input) && typeof input['present'] === 'boolean' && 'statement' in input) {
|
|
1820
|
+
if (input['present'] !== true) {
|
|
1821
|
+
add(TRACE_PROBLEM_KIND.ABSENT, `${file} carries no \`${TRACE_BLOCK_KEY}:\` trace block. A vault-enabled phase records its practice trace in every plan artifact; there is no backfill path and no pre-schema exemption.`);
|
|
1822
|
+
return { block: true, problems };
|
|
1823
|
+
}
|
|
1824
|
+
statement = input['statement'];
|
|
1825
|
+
}
|
|
1826
|
+
if (statement === null || statement === undefined) {
|
|
1827
|
+
add(TRACE_PROBLEM_KIND.ABSENT, `${file} carries no \`${TRACE_BLOCK_KEY}:\` trace block. A vault-enabled phase records its practice trace in every plan artifact; there is no backfill path and no pre-schema exemption.`);
|
|
1828
|
+
return { block: true, problems };
|
|
1829
|
+
}
|
|
1830
|
+
if (!isPlainObject(statement)) {
|
|
1831
|
+
add(TRACE_PROBLEM_KIND.MALFORMED_INPUT, `the \`${TRACE_BLOCK_KEY}:\` block in ${file} is not a mapping (found ${describeValue(statement)}).`);
|
|
1832
|
+
return { block: true, problems };
|
|
1833
|
+
}
|
|
1834
|
+
// ── it parsed ──
|
|
1835
|
+
if (statementReadNothing(statement)) {
|
|
1836
|
+
add(TRACE_PROBLEM_KIND.UNPARSABLE, `the \`${TRACE_BLOCK_KEY}:\` block in ${file} is present but nothing could be read from it — no statement type, no stage and no practice entries parsed. The block is unreadable, which is NOT the same as an empty practice set.`);
|
|
1837
|
+
return { block: true, problems };
|
|
1838
|
+
}
|
|
1839
|
+
// ── required keys present ──
|
|
1840
|
+
const requiredKeys = stage === 'plan' || stage === 'execute'
|
|
1841
|
+
? TRACE_REQUIRED_KEYS[stage]
|
|
1842
|
+
: TRACE_REQUIRED_KEYS.plan;
|
|
1843
|
+
for (const docPath of requiredKeys) {
|
|
1844
|
+
if (!hasRequiredKey(statement, docPath)) {
|
|
1845
|
+
add(TRACE_PROBLEM_KIND.MISSING_REQUIRED_KEY, `required key ${docPath} is missing from the trace in ${file}.` +
|
|
1846
|
+
(docPath === EXECUTE_DIGEST_KEY ? EXECUTE_DIGEST_REMEDY : ''));
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
// ── version discipline (TRACE-05) ──
|
|
1850
|
+
// An unknown OPTIONAL key is never inspected, and that silence is the point:
|
|
1851
|
+
// it is what makes an additive minor change non-breaking for the Phase 4 and
|
|
1852
|
+
// Phase 5 consumers. An unexpected MAJOR marker, by contrast, fails loudly
|
|
1853
|
+
// and names both values — a consumer silently accepting a future major is
|
|
1854
|
+
// the failure mode the whole freeze exists to prevent.
|
|
1855
|
+
if (statement['_type'] !== TRACE_STATEMENT_TYPE) {
|
|
1856
|
+
add(TRACE_PROBLEM_KIND.STATEMENT_TYPE_MISMATCH, `_type must be exactly "${TRACE_STATEMENT_TYPE}"; found "${describeValue(statement['_type'])}".`);
|
|
1857
|
+
}
|
|
1858
|
+
const acceptedTypes = [
|
|
1859
|
+
TRACE_PREDICATE_TYPE,
|
|
1860
|
+
...(Array.isArray(opts.acceptedPredicateTypes)
|
|
1861
|
+
? opts.acceptedPredicateTypes.filter((t) => typeof t === 'string' && t !== '')
|
|
1862
|
+
: []),
|
|
1863
|
+
];
|
|
1864
|
+
if (!acceptedTypes.includes(statement['predicateType'])) {
|
|
1865
|
+
add(TRACE_PROBLEM_KIND.PREDICATE_TYPE_MISMATCH, `predicateType must be exactly "${TRACE_PREDICATE_TYPE}"` +
|
|
1866
|
+
(acceptedTypes.length > 1 ? ` or one of the ${acceptedTypes.length - 1} the org profile accepts` : '') +
|
|
1867
|
+
`; found "${describeValue(statement['predicateType'])}". The URI carries the MAJOR version — this reader is pinned to it and will not silently accept another.`);
|
|
1868
|
+
}
|
|
1869
|
+
const schemaVersion = statement['schemaVersion'];
|
|
1870
|
+
if (typeof schemaVersion !== 'number' || !Number.isInteger(schemaVersion)) {
|
|
1871
|
+
add(TRACE_PROBLEM_KIND.SCHEMA_VERSION_INVALID, `schemaVersion must be an integer; found "${describeValue(schemaVersion)}".`);
|
|
1872
|
+
}
|
|
1873
|
+
else if (schemaVersion > TRACE_SCHEMA_VERSION) {
|
|
1874
|
+
add(TRACE_PROBLEM_KIND.SCHEMA_VERSION_UNSUPPORTED, `schemaVersion ${schemaVersion} is above the highest version this reader knows (${TRACE_SCHEMA_VERSION}). A newer major is refused loudly rather than read as if it were understood.`);
|
|
1875
|
+
}
|
|
1876
|
+
const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
|
|
1877
|
+
// The stage is the one field that tells the two traces apart, so a required
|
|
1878
|
+
// key whose value could be anything would not be a required key in any
|
|
1879
|
+
// useful sense. Validated against the stage the caller asked for.
|
|
1880
|
+
if (stage === 'plan' || stage === 'execute') {
|
|
1881
|
+
if (predicate['stage'] !== stage) {
|
|
1882
|
+
add(TRACE_PROBLEM_KIND.STAGE_MISMATCH, `predicate.stage must be "${stage}" for this artifact; found "${describeValue(predicate['stage'])}".`);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
// ── the pin, scoped to a LOADED state ──
|
|
1886
|
+
// A degraded statement legitimately carries a null pin (D-04), and a stale
|
|
1887
|
+
// cache is degraded WITH a live pin. A validator that checked the pin
|
|
1888
|
+
// unconditionally would reject exactly the record D-04 exists to produce.
|
|
1889
|
+
const vaultBlock = isPlainObject(predicate['vault']) ? predicate['vault'] : {};
|
|
1890
|
+
const vaultState = vaultBlock['state'];
|
|
1891
|
+
if (vaultState !== 'loaded' && vaultState !== 'degraded') {
|
|
1892
|
+
add(TRACE_PROBLEM_KIND.VAULT_STATE_INVALID, `predicate.vault.state must be "loaded" or "degraded"; found "${describeValue(vaultState)}".`);
|
|
1893
|
+
}
|
|
1894
|
+
else if (vaultState === 'loaded') {
|
|
1895
|
+
for (const half of ['ref', 'resolvedSha']) {
|
|
1896
|
+
const value = vaultBlock[half];
|
|
1897
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
1898
|
+
add(TRACE_PROBLEM_KIND.PIN_MISSING, `predicate.vault.${half} must be non-null when predicate.vault.state is "loaded"; found "${describeValue(value)}". Without both halves of the pin nothing about this run is provable.`);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
// ── the practice entries: vocabulary, deviated reasons, coverage ──
|
|
1903
|
+
const practices = Array.isArray(predicate['practices']) ? predicate['practices'] : [];
|
|
1904
|
+
const emittedIds = [];
|
|
1905
|
+
const seenIds = new Set();
|
|
1906
|
+
const duplicateIds = new Set();
|
|
1907
|
+
const decidedIds = new Set();
|
|
1908
|
+
for (const raw of practices) {
|
|
1909
|
+
const entry = isPlainObject(raw) ? raw : {};
|
|
1910
|
+
const id = typeof entry['id'] === 'string' ? entry['id'] : '';
|
|
1911
|
+
// 03-REVIEW CR-03: the charset the write path has always applied, applied
|
|
1912
|
+
// here too. `docs/vault-trace-format.md` states as a FACT that the id is
|
|
1913
|
+
// "bounded, charset-validated, and always emitted quoted"; without this
|
|
1914
|
+
// the claim held only where the engine writes an id, and a ledger whose
|
|
1915
|
+
// id was a whole English sentence validated clean (T-03-31).
|
|
1916
|
+
//
|
|
1917
|
+
// Reads ONLY the module-local pattern — nothing on disk, and nothing from
|
|
1918
|
+
// the vault. Resolving an id against the manifest is Phase 4's.
|
|
1919
|
+
//
|
|
1920
|
+
// Deliberately does NOT `continue`: the entry still joins the duplicate
|
|
1921
|
+
// and coverage sets below, because suppressing it would turn one defect
|
|
1922
|
+
// into a second, quieter one.
|
|
1923
|
+
if (typeof entry['id'] !== 'string' || !TRACE_ID_PATTERN.test(entry['id'])) {
|
|
1924
|
+
add(TRACE_PROBLEM_KIND.ID_CHARSET_INVALID, `practice id "${collapseControlChars(boundedToken(entry['id']))}" is not in the permitted charset (alphanumerics plus . _ -, first character alphanumeric, max 64).`);
|
|
1925
|
+
}
|
|
1926
|
+
emittedIds.push(id);
|
|
1927
|
+
if (seenIds.has(id))
|
|
1928
|
+
duplicateIds.add(id);
|
|
1929
|
+
seenIds.add(id);
|
|
1930
|
+
const status = entry['status'];
|
|
1931
|
+
const statusText = typeof status === 'string' ? status : '';
|
|
1932
|
+
const inVocabulary = TRACE_STATUS_VALUES.includes(statusText);
|
|
1933
|
+
// The empty string is the documented PLAN-stage value — the slot the
|
|
1934
|
+
// executor fills in later — so it is not an out-of-vocabulary word there.
|
|
1935
|
+
const emptyIsLegal = stage !== 'execute';
|
|
1936
|
+
if (!inVocabulary && !(statusText === '' && emptyIsLegal)) {
|
|
1937
|
+
add(TRACE_PROBLEM_KIND.STATUS_OUT_OF_VOCABULARY, `practice "${id}" carries status "${describeValue(status)}", which is not one of ${TRACE_STATUS_VALUES.join(', ')}.`);
|
|
1938
|
+
}
|
|
1939
|
+
if (inVocabulary)
|
|
1940
|
+
decidedIds.add(id);
|
|
1941
|
+
if (statusText === 'deviated') {
|
|
1942
|
+
const reason = entry['reason'];
|
|
1943
|
+
if (typeof reason !== 'string' || reason.trim() === '') {
|
|
1944
|
+
add(TRACE_PROBLEM_KIND.DEVIATED_REASON_MISSING, `practice "${id}" is recorded as deviated with no reason. A deviation without a stated reason is indistinguishable from a practice that was never considered.`);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
for (const dup of duplicateIds) {
|
|
1949
|
+
add(TRACE_PROBLEM_KIND.COVERAGE_DUPLICATE_ID, `practice id "${dup}" appears more than once in the emitted set, so its disposition is ambiguous.`);
|
|
1950
|
+
}
|
|
1951
|
+
// ── identifier resolution, scoped to a LOADED state (04-05, TGATE-01) ────
|
|
1952
|
+
//
|
|
1953
|
+
// A PURE SET OPERATION over data already in the statement. The universe is
|
|
1954
|
+
// an INPUT — 04-02 recorded it from the SAME vault read that produced the
|
|
1955
|
+
// pin — and is never re-derived here. A validator that went to disk for it
|
|
1956
|
+
// would make the verdict a function of the machine it ran on rather than of
|
|
1957
|
+
// the record, which is precisely the environment-dependence D-01 was
|
|
1958
|
+
// written to remove: the same trace must yield the same verdict on any
|
|
1959
|
+
// machine, with or without a cache. Nothing below opens a file, spawns a
|
|
1960
|
+
// process, reads an environment variable or consults the practice matcher.
|
|
1961
|
+
//
|
|
1962
|
+
// SUPPRESSED — not failed, and not silently skipped — when the state is not
|
|
1963
|
+
// `loaded`, on the pin check's reasoning verbatim. A degraded run
|
|
1964
|
+
// legitimately carries a null pin and no universe (D-04), and Phase 3
|
|
1965
|
+
// REQUIRES such a run to write a trace recording the degraded state and its
|
|
1966
|
+
// reason. The miss is therefore already a recorded fact in the artifact; a
|
|
1967
|
+
// validator that additionally rejected the record would be rejecting a
|
|
1968
|
+
// correct record of a real failure. What keeps this from being an escape
|
|
1969
|
+
// hatch is the other half 04-02 pinned: the state is derived by the CLI
|
|
1970
|
+
// from the vault fixture and cannot be selected through any
|
|
1971
|
+
// statement-builder option.
|
|
1972
|
+
if (vaultState === 'loaded') {
|
|
1973
|
+
const universe = vaultBlock['practiceUniverse'];
|
|
1974
|
+
if (!Array.isArray(universe)) {
|
|
1975
|
+
// Fail closed, on the posture the handler already holds at its
|
|
1976
|
+
// unresolved-phase branch: a check that proved nothing must not read as
|
|
1977
|
+
// a check that passed. Unreachable from the shipped writer — every
|
|
1978
|
+
// loaded statement it emits carries a universe (04-02) — so this names
|
|
1979
|
+
// a hand-authored statement, and the remedy is one the reader can
|
|
1980
|
+
// perform without the worktree that wrote the artifact.
|
|
1981
|
+
//
|
|
1982
|
+
// 04-11 (CR-05): the remedy is the TAIL, never the head. The head names
|
|
1983
|
+
// the artifact — an untrusted, unbounded-in-practice path — and the
|
|
1984
|
+
// whole of that sentence used to be followed by the command inside a
|
|
1985
|
+
// single 400-character budget. A 138-character artifact path, which is
|
|
1986
|
+
// an ordinary workstream phase path rather than a hostile one, printed
|
|
1987
|
+
// a PARTIAL command an orchestrator agent could run.
|
|
1988
|
+
add(TRACE_PROBLEM_KIND.UNIVERSE_MISSING, `the trace in ${file} records no practice-identifier universe (predicate.vault.practiceUniverse), so no identifier it lists can be resolved and the ledger proves nothing.`, ` Regenerate the trace in place: ${TRACE_REGENERATE_COMMAND}`);
|
|
1989
|
+
}
|
|
1990
|
+
else {
|
|
1991
|
+
// Built ONCE per validation rather than scanned per id: a linear scan
|
|
1992
|
+
// per identifier turns a large universe into a quadratic walk (T-4-22).
|
|
1993
|
+
const universeIds = new Set(universe.filter((v) => typeof v === 'string'));
|
|
1994
|
+
// `seenIds` is the emitted set already de-duplicated, so a duplicated
|
|
1995
|
+
// unresolvable id yields one problem, not one per occurrence — the
|
|
1996
|
+
// duplication is its own defect above and has its own kind.
|
|
1997
|
+
for (const id of seenIds) {
|
|
1998
|
+
if (universeIds.has(id))
|
|
1999
|
+
continue;
|
|
2000
|
+
// ONE problem PER unresolved id, never one aggregate: the detail has
|
|
2001
|
+
// to name the id at fault (TGATE-03), and a single problem standing
|
|
2002
|
+
// for three broken ids makes the problem count meaningless.
|
|
2003
|
+
add(TRACE_PROBLEM_KIND.ID_UNRESOLVED, `practice id "${collapseControlChars(boundedToken(id))}" is not in the practice-identifier universe this trace records for the vault at its pinned SHA, so it resolves to no practice.`);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
// The 1:1 coverage check runs at EXECUTE stage: at plan stage nothing has
|
|
2008
|
+
// been decided yet by design, so every id would read as uncovered.
|
|
2009
|
+
if (stage === 'execute') {
|
|
2010
|
+
const supplied = Array.isArray(opts.dispositions) ? opts.dispositions : null;
|
|
2011
|
+
const dispositionIds = supplied
|
|
2012
|
+
? new Set(supplied.map((d) => (isPlainObject(d) && typeof d['id'] === 'string' ? d['id'] : '')))
|
|
2013
|
+
: decidedIds;
|
|
2014
|
+
const emittedSet = new Set(emittedIds);
|
|
2015
|
+
const uncovered = [...emittedSet].filter((id) => !dispositionIds.has(id));
|
|
2016
|
+
if (uncovered.length > 0) {
|
|
2017
|
+
add(TRACE_PROBLEM_KIND.COVERAGE_MISSING_DISPOSITION, `these practice ids are in the emitted set with no recorded disposition: ${uncovered.map((id) => `"${id}"`).join(', ')}.`);
|
|
2018
|
+
}
|
|
2019
|
+
const unknown = [...dispositionIds].filter((id) => !emittedSet.has(id));
|
|
2020
|
+
if (unknown.length > 0) {
|
|
2021
|
+
add(TRACE_PROBLEM_KIND.COVERAGE_UNKNOWN_DISPOSITION, `these dispositions name a practice id the emitted set does not contain: ${unknown.map((id) => `"${id}"`).join(', ')}.`);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
return { block: problems.length > 0, problems };
|
|
2025
|
+
}
|
|
2026
|
+
catch (e) {
|
|
2027
|
+
// Totality is the contract, not a courtesy: a throw here would turn a
|
|
2028
|
+
// halt-on-error gate into a permanently stopped build (T-03-16).
|
|
2029
|
+
return {
|
|
2030
|
+
block: true,
|
|
2031
|
+
problems: [
|
|
2032
|
+
{
|
|
2033
|
+
file,
|
|
2034
|
+
kind: TRACE_PROBLEM_KIND.VALIDATOR_ERROR,
|
|
2035
|
+
detail: `the trace validator could not complete: ${boundedToken(String(e?.message ?? e))}`,
|
|
2036
|
+
},
|
|
2037
|
+
],
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
/** The vault state a statement records, or `''` when it records none. */
|
|
2042
|
+
function traceVaultStateOf(statement) {
|
|
2043
|
+
if (!isPlainObject(statement))
|
|
2044
|
+
return '';
|
|
2045
|
+
const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
|
|
2046
|
+
const vault = isPlainObject(predicate['vault']) ? predicate['vault'] : {};
|
|
2047
|
+
return typeof vault['state'] === 'string' ? vault['state'] : '';
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* The degraded reason a statement records, or `''` (04-13).
|
|
2051
|
+
*
|
|
2052
|
+
* The fifth sibling of the four accessors around it, in their register and
|
|
2053
|
+
* with their defensive shape: every hop is type-checked, and a statement that
|
|
2054
|
+
* carries none of them yields the empty string rather than throwing. The
|
|
2055
|
+
* comparison never throws, so nothing it calls may either.
|
|
2056
|
+
*/
|
|
2057
|
+
function traceVaultReasonOf(statement) {
|
|
2058
|
+
if (!isPlainObject(statement))
|
|
2059
|
+
return '';
|
|
2060
|
+
const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
|
|
2061
|
+
const vault = isPlainObject(predicate['vault']) ? predicate['vault'] : {};
|
|
2062
|
+
return typeof vault['reason'] === 'string' ? vault['reason'] : '';
|
|
2063
|
+
}
|
|
2064
|
+
/** The non-empty practice identifiers a statement emits, in recorded order. */
|
|
2065
|
+
function traceEmittedIdsOf(statement) {
|
|
2066
|
+
if (!isPlainObject(statement))
|
|
2067
|
+
return [];
|
|
2068
|
+
const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
|
|
2069
|
+
const practices = Array.isArray(predicate['practices']) ? predicate['practices'] : [];
|
|
2070
|
+
const out = [];
|
|
2071
|
+
for (const raw of practices) {
|
|
2072
|
+
const entry = isPlainObject(raw) ? raw : {};
|
|
2073
|
+
const id = entry['id'];
|
|
2074
|
+
if (typeof id === 'string' && id !== '')
|
|
2075
|
+
out.push(id);
|
|
2076
|
+
}
|
|
2077
|
+
return out;
|
|
2078
|
+
}
|
|
2079
|
+
/** The plan identifier a statement records, or `''`. */
|
|
2080
|
+
function tracePlanIdOf(statement) {
|
|
2081
|
+
if (!isPlainObject(statement))
|
|
2082
|
+
return '';
|
|
2083
|
+
const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
|
|
2084
|
+
return typeof predicate['plan'] === 'string' ? predicate['plan'] : '';
|
|
2085
|
+
}
|
|
2086
|
+
/** The generation timestamp a statement records, or `''`. */
|
|
2087
|
+
function traceGeneratedAtOf(statement) {
|
|
2088
|
+
if (!isPlainObject(statement))
|
|
2089
|
+
return '';
|
|
2090
|
+
const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
|
|
2091
|
+
return typeof predicate['generatedAt'] === 'string' ? predicate['generatedAt'] : '';
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* The performable remedy for an unprovable pair, as a TRUSTED TAIL (04-13).
|
|
2095
|
+
*
|
|
2096
|
+
* TWO ROUTES, ALWAYS BOTH, AND THAT IS THE WHOLE POINT. Phase 3's CR-02
|
|
2097
|
+
* established that a block naming no remedy the reader can perform is a trap,
|
|
2098
|
+
* and this block is emitted on SEVEN distinct degraded causes that do not behave
|
|
2099
|
+
* the same way. Read from the pin resolver above rather than from memory:
|
|
2100
|
+
*
|
|
2101
|
+
* - no vault cache directory, an absent manifest, an unreadable or malformed
|
|
2102
|
+
* manifest — and a stale cache whenever the vault is REACHABLE. Three (and
|
|
2103
|
+
* a half) causes a re-fetch clears: the strict points run on the main
|
|
2104
|
+
* checkout, so regenerating really does produce a loaded statement.
|
|
2105
|
+
* - a recorded MISS, and a stale cache whose refresh FAILS. Two causes
|
|
2106
|
+
* regeneration cannot clear at all: the fetch itself failed on credentials,
|
|
2107
|
+
* network or an unreachable repository, so re-fetching reproduces the miss
|
|
2108
|
+
* and regenerating reproduces the identical record. A tail offering only
|
|
2109
|
+
* "regenerate" would be CR-02's trap reintroduced on exactly these two.
|
|
2110
|
+
*
|
|
2111
|
+
* - a load at a tier that is not `client` (D-08-25). The sixth cause, and
|
|
2112
|
+
* the one NEITHER route above clears on its own: the vault was reached,
|
|
2113
|
+
* freshly and successfully, and it was the wrong vault. Re-fetching
|
|
2114
|
+
* reproduces the same tier and regenerating reproduces the same record,
|
|
2115
|
+
* because the project's own `client.vaultRepo` / `client.vaultRef` point
|
|
2116
|
+
* where they point. Its lead therefore names the configuration edit.
|
|
2117
|
+
*
|
|
2118
|
+
* - a client-tier cache fetched from a ref other than the configured
|
|
2119
|
+
* `client.vaultRef` (D-08-34, UAT F5). The seventh cause, and it needs NO
|
|
2120
|
+
* fourth lead: `vault fetch` reads the CONFIGURED ref, so a successful
|
|
2121
|
+
* fetch genuinely replaces the drifted cache and the default re-fetch lead
|
|
2122
|
+
* clears it. It reproduces only when that ref cannot be fetched — the same
|
|
2123
|
+
* "and a half" the stale cause already carries — and the tail names the
|
|
2124
|
+
* disable route in every case regardless. So its reason is phrased OUTSIDE
|
|
2125
|
+
* all three classifier substrings below, deliberately, and lands on the
|
|
2126
|
+
* re-fetch-first lead (D-08-36).
|
|
2127
|
+
*
|
|
2128
|
+
* So ALL THREE tails name BOTH routes, and the second route — turning the practices
|
|
2129
|
+
* capability off for this project, a local `.planning/config.json` edit — is
|
|
2130
|
+
* performable with the vault down, from the same checkout the gate ran in.
|
|
2131
|
+
* There is no third route: nothing overrides this kind.
|
|
2132
|
+
*
|
|
2133
|
+
* WHAT THE CLASSIFIER DOES, AND WHAT IT DELIBERATELY CANNOT DO. It reads the
|
|
2134
|
+
* recorded reason only to choose which route to state FIRST, so a reader on the
|
|
2135
|
+
* unreachable arm is not sent to re-run a command that will come back
|
|
2136
|
+
* identical. It selects between three FIXED strings; nothing from the artifact is
|
|
2137
|
+
* interpolated, so a hand-edited or hostile reason can change the emphasis and
|
|
2138
|
+
* can never change what is offered. Misclassification therefore costs an
|
|
2139
|
+
* operator one sentence of reading order, never a performable remedy — which is
|
|
2140
|
+
* why a substring probe over our own generated prose is an acceptable
|
|
2141
|
+
* mechanism here and would not be if it gated the routes themselves.
|
|
2142
|
+
*/
|
|
2143
|
+
function unprovablePairTail(reason) {
|
|
2144
|
+
// THREE leads for SEVEN causes now (D-08-25, then D-08-34). The tier cause is tested FIRST
|
|
2145
|
+
// because it is the only one neither existing lead describes: the vault was
|
|
2146
|
+
// reached, freshly, successfully — at the WRONG TIER. Regenerating alone
|
|
2147
|
+
// reproduces it (the scaffold reads the cache, it does not fetch), and so
|
|
2148
|
+
// does restoring reachability (the shared vault was already reachable; that
|
|
2149
|
+
// is how it loaded). The project is pointed at the wrong vault, and only a
|
|
2150
|
+
// configuration edit moves it — which is why this lead names the key.
|
|
2151
|
+
//
|
|
2152
|
+
// A stale FALLBACK cache lands here rather than on the unreachable lead,
|
|
2153
|
+
// because `resolvePin` returns the tier reason ahead of the stale one: the
|
|
2154
|
+
// tier is the stronger fact and its remedy is the one that clears both.
|
|
2155
|
+
//
|
|
2156
|
+
// THE LEAD IS LENGTH-BUDGETED, NOT PHRASED TO TASTE, AND THE BUDGET WAS
|
|
2157
|
+
// MEASURED RATHER THAN ESTIMATED. `TRACE_DETAIL_CAP` is 400 and the router
|
|
2158
|
+
// gives the head `cap - tail.length - 1`. The tier cause's worst realistic
|
|
2159
|
+
// head is the BOTH-HALVES case — a fallback-tier plan and its summary are
|
|
2160
|
+
// written from the same cache, so both degrade together — which opens "both
|
|
2161
|
+
// halves of this pair record" and carries the reason bounded to
|
|
2162
|
+
// `boundedToken`'s 80 characters: 187 characters, measured. With the command
|
|
2163
|
+
// (87) and the disable sentence (63) fixed, the lead has 62 characters. A
|
|
2164
|
+
// longer one truncates the head that names the plan, which is the
|
|
2165
|
+
// truncated-remedy bug arriving from the other end.
|
|
2166
|
+
//
|
|
2167
|
+
// So the lead states the DIAGNOSIS the other two leads do not give — the
|
|
2168
|
+
// project is pointed at the wrong vault — plus the key to change and the
|
|
2169
|
+
// fact that a fetch must follow it. The fuller remedy (re-point
|
|
2170
|
+
// `client.vaultRepo` / `client.vaultRef`, re-fetch, regenerate) is spelled
|
|
2171
|
+
// out in `docs/vault-trace-format.md`, which has no cap.
|
|
2172
|
+
const lead = /no client standards were applied/.test(reason)
|
|
2173
|
+
? ' Wrong vault: fix client.vaultRepo, re-fetch, then '
|
|
2174
|
+
// The two reasons the resolver emits when the vault was REACHED FOR and not
|
|
2175
|
+
// reached. Anything else — including an empty reason — falls to the
|
|
2176
|
+
// re-fetch-first tail, which also names the disable route.
|
|
2177
|
+
: /records a miss|is stale/.test(reason)
|
|
2178
|
+
? ' Regenerating alone reproduces this: restore vault access first, then '
|
|
2179
|
+
: " Re-fetch the vault and regenerate that stage's trace: ";
|
|
2180
|
+
return (lead
|
|
2181
|
+
+ TRACE_REGENERATE_COMMAND
|
|
2182
|
+
// The SECOND route, and it is the same sentence in all three tails on
|
|
2183
|
+
// purpose: it is the one action performable while the vault is down, so it
|
|
2184
|
+
// must not be the clause that a rewording of a first route quietly drops.
|
|
2185
|
+
+ ' Or set vault.practices_enabled=false in .planning/config.json.');
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Compare ONE plan statement against ONE summary statement — the first relation
|
|
2189
|
+
* between two artifacts this module has ever expressed (TGATE-01's third
|
|
2190
|
+
* clause, under D-09).
|
|
2191
|
+
*
|
|
2192
|
+
* THE RELATION. A summary's identifier set must COVER its own plan's,
|
|
2193
|
+
* plan by plan. Not the intersection, not a phase-wide union: a SUPERSET test
|
|
2194
|
+
* against exactly one counterpart. Both rejected alternatives fail the same
|
|
2195
|
+
* way, VACUOUSLY. An intersection test passes for a summary that kept one
|
|
2196
|
+
* identifier and dropped the rest. A phase aggregate passes when one plan
|
|
2197
|
+
* dropped every practice and a sibling happened to carry the same identifiers.
|
|
2198
|
+
* A gate that cannot fail is not a gate, and this workstream has already
|
|
2199
|
+
* shipped one coverage gate that reported `passed` while parsing nothing.
|
|
2200
|
+
*
|
|
2201
|
+
* The aggregate also cannot satisfy TGATE-03, which requires the failure to
|
|
2202
|
+
* name the file and section to fix: an aggregate has no file to name. That is
|
|
2203
|
+
* why every problem returned here is filed against the SUMMARY — it is the
|
|
2204
|
+
* artifact whose record is incomplete and the one an operator must repair.
|
|
2205
|
+
*
|
|
2206
|
+
* RETURNS PROBLEMS, NEVER A BOOLEAN. The caller composes them into its own
|
|
2207
|
+
* verdict alongside every other problem it holds, exactly as it does with the
|
|
2208
|
+
* single-statement validator's. A boolean here would force a second, parallel
|
|
2209
|
+
* verdict composition in the handler.
|
|
2210
|
+
*
|
|
2211
|
+
* PURE. Two parsed statements in, problems out. It opens no file, stats
|
|
2212
|
+
* nothing, and invokes no git — deliberately, and not merely as tidiness. The
|
|
2213
|
+
* two alternatives for pairing were considered and are unusable in this
|
|
2214
|
+
* workflow: artifacts are copied out of executor worktrees as a routine step,
|
|
2215
|
+
* which destroys modification times, and `.planning/` is gitignored, so there
|
|
2216
|
+
* is no history to read. Only the recorded plan identifier and the recorded
|
|
2217
|
+
* generation timestamp — both frozen REQUIRED keys in both stage variants —
|
|
2218
|
+
* survive both. The divergence check, which does read a manifest, is the
|
|
2219
|
+
* router's and lives there.
|
|
2220
|
+
*
|
|
2221
|
+
* NEVER THROWS, on the validator's reasoning verbatim: this sits on the read
|
|
2222
|
+
* path for an attacker-influenceable document, and a throw would turn a
|
|
2223
|
+
* halt-on-error gate into a permanently stopped build.
|
|
2224
|
+
*/
|
|
2225
|
+
function comparePracticeCoverage(planStatement, summaryStatement, options) {
|
|
2226
|
+
const opts = isPlainObject(options) ? options : {};
|
|
2227
|
+
const file = typeof opts.file === 'string' && opts.file !== '' ? opts.file : 'the summary artifact';
|
|
2228
|
+
const planFile = typeof opts.planFile === 'string' && opts.planFile !== '' ? opts.planFile : 'its plan artifact';
|
|
2229
|
+
// Anything that is not the strict word is tolerant, so an absent option, an
|
|
2230
|
+
// undefined one and a misspelt one all land on the silent arm rather than on
|
|
2231
|
+
// the blocking one. Fail-open is the correct default for THIS parameter and
|
|
2232
|
+
// only for this one: it is the caller's declaration of which question is
|
|
2233
|
+
// being asked, and a typo must not invent a halt nobody declared.
|
|
2234
|
+
const strictness = opts.strictness === 'strict' ? 'strict' : 'tolerant';
|
|
2235
|
+
const problems = [];
|
|
2236
|
+
try {
|
|
2237
|
+
// ── the state gate: THREE outcomes, not two (04-13, CR-01) ─────────────
|
|
2238
|
+
//
|
|
2239
|
+
// THE TOLERANT ARM, and its reasoning is 04-07's PRESERVED VERBATIM below
|
|
2240
|
+
// because 04-13 does not challenge a word of it. What 04-13 changes is that
|
|
2241
|
+
// this paragraph used to be the WHOLE answer, at every call site:
|
|
2242
|
+
//
|
|
2243
|
+
// > SUPPRESSED — not failed — unless BOTH halves record a `loaded` vault. A
|
|
2244
|
+
// > degraded run legitimately recorded no universe and may legitimately have
|
|
2245
|
+
// > recorded no practices at all (D-04), and Phase 3 REQUIRES it to write a
|
|
2246
|
+
// > trace stating the degraded state and its reason. The miss is therefore
|
|
2247
|
+
// > already a recorded fact in the artifact; a comparison that additionally
|
|
2248
|
+
// > blocked would be rejecting a correct record of a real failure. What keeps
|
|
2249
|
+
// > this from being an escape hatch is 04-02's pin: the state is derived by
|
|
2250
|
+
// > the CLI from the vault fixture and cannot be selected through any
|
|
2251
|
+
// > statement-builder option.
|
|
2252
|
+
//
|
|
2253
|
+
// THE STRICT ARM, and why the paragraph above stops being the whole answer
|
|
2254
|
+
// there. At the two WRITE points the gate asks *is this a correct record?*
|
|
2255
|
+
// and a degraded record is correct. At `ship:pre` and `verify:post` it asks
|
|
2256
|
+
// a different question: *does this record prove the phase was governed?* A
|
|
2257
|
+
// degraded half answering a loaded plan proves nothing — it records that
|
|
2258
|
+
// the vault was unreachable, which is a fact about the machine and not
|
|
2259
|
+
// about the work. Scoring "proved nothing" as "passed" was the fail-open
|
|
2260
|
+
// (CR-01): a summary written while `.planning/vault/` was absent recorded
|
|
2261
|
+
// an empty ledger and this gate returned zero problems, so a phase that
|
|
2262
|
+
// promised practices and accounted for none SHIPPED RECORDED AS GOVERNED.
|
|
2263
|
+
// The record is neither a valid proof nor a rejectable record; it is an
|
|
2264
|
+
// UNPROVEN one, and that third outcome had no name.
|
|
2265
|
+
//
|
|
2266
|
+
// STRICTNESS IS A PROPERTY OF THE CALL SITE, exactly as D-10 already made
|
|
2267
|
+
// it one flag ago for an unpaired plan — normal mid-phase, a defect at
|
|
2268
|
+
// ship. The same flag carries this, so the tolerant points stay
|
|
2269
|
+
// byte-identically silent on the very fixture the strict points block on.
|
|
2270
|
+
//
|
|
2271
|
+
// WHY THE STRICT ARM IS A GATE AND NOT A TRAP, stated honestly rather than
|
|
2272
|
+
// rounded off: the remedy's availability is PARTIAL. Three of the
|
|
2273
|
+
// resolver's five degraded causes are cleared by re-fetching and
|
|
2274
|
+
// regenerating from the main checkout the strict points run on; a recorded
|
|
2275
|
+
// miss and a stale cache whose refresh fails are NOT — regeneration
|
|
2276
|
+
// reproduces them exactly. There the block is still deliberate, because the
|
|
2277
|
+
// remedy is OPERATIONAL rather than a re-run: restore vault reachability,
|
|
2278
|
+
// or turn the practices capability off for this project. Both are stated in
|
|
2279
|
+
// the tail, both are performable with the vault down, and the residual is
|
|
2280
|
+
// accepted: while a client vault is genuinely unreachable, a correct record
|
|
2281
|
+
// and a blocked ship coexist. That is the intended behaviour of a
|
|
2282
|
+
// workstream whose premise is that a vault miss must never silently produce
|
|
2283
|
+
// ungoverned code.
|
|
2284
|
+
//
|
|
2285
|
+
// REJECTED, one sentence each, so the next reader does not re-open them.
|
|
2286
|
+
// Rejecting a degraded statement at VALIDATION time blocks everywhere and
|
|
2287
|
+
// fails a correct record of a real failure — D-04 is right, and at the
|
|
2288
|
+
// write points the remedy reproduces the block. An ADVISORY-only problem is
|
|
2289
|
+
// measurably invisible: a non-blocking verdict sets no message and the
|
|
2290
|
+
// enforcer prints only the message, so it would restate the silence in a
|
|
2291
|
+
// quieter channel. Letting the existing uncovered-identifier kind fire on
|
|
2292
|
+
// an empty ledger yields one problem per promised id for ONE cause, and
|
|
2293
|
+
// points each at a section where the fix does not live. A phase-level
|
|
2294
|
+
// aggregate cannot name a file, which TGATE-03 requires.
|
|
2295
|
+
//
|
|
2296
|
+
// SYMMETRIC ON PURPOSE — either half, not only the summary. A degraded PLAN
|
|
2297
|
+
// at ship time is equally unproven: it says no practices governed the work
|
|
2298
|
+
// because the vault was unreachable when it was planned. Closing only the
|
|
2299
|
+
// summary arm would leave the mirror-image hole, which is this phase's own
|
|
2300
|
+
// signature failure.
|
|
2301
|
+
//
|
|
2302
|
+
// THE BOUNDARY, AND IT IS A PROHIBITION. `validateTraceStatement`'s
|
|
2303
|
+
// degraded suppression is UNCHANGED, at every strictness, and must stay so.
|
|
2304
|
+
// It takes no strictness parameter and must not be given one. The reason is
|
|
2305
|
+
// informational, not stylistic: a lone degraded statement records no
|
|
2306
|
+
// universe and no practices, so there is nothing to compare and identifier
|
|
2307
|
+
// resolution there is VACUOUS rather than suppressed-with-content. The
|
|
2308
|
+
// judgement below is possible only because the PLAN half says what was
|
|
2309
|
+
// promised. Do not "complete the set" by adding a strict arm there — that
|
|
2310
|
+
// is precisely the everywhere-block D-04 argues against, arrived at from
|
|
2311
|
+
// the other side.
|
|
2312
|
+
const planState = traceVaultStateOf(planStatement);
|
|
2313
|
+
const summaryState = traceVaultStateOf(summaryStatement);
|
|
2314
|
+
if (planState !== 'loaded' || summaryState !== 'loaded') {
|
|
2315
|
+
if (strictness !== 'strict')
|
|
2316
|
+
return problems;
|
|
2317
|
+
const summaryUnproven = summaryState !== 'loaded';
|
|
2318
|
+
const planUnproven = planState !== 'loaded';
|
|
2319
|
+
// ONE problem per pair, and it RETURNS: a ledger that proved nothing
|
|
2320
|
+
// cannot be compared, so continuing into the coverage loop and the
|
|
2321
|
+
// staleness check would report three problems for one cause and give an
|
|
2322
|
+
// operator three things to chase.
|
|
2323
|
+
//
|
|
2324
|
+
// The reason quoted is the SUMMARY's when the summary is unproven, and
|
|
2325
|
+
// the plan's otherwise — one reason, not two, because both halves
|
|
2326
|
+
// degraded is overwhelmingly one environment failing once, and a second
|
|
2327
|
+
// bounded artifact value would double the untrusted share of a head that
|
|
2328
|
+
// already carries two.
|
|
2329
|
+
const reason = summaryUnproven
|
|
2330
|
+
? traceVaultReasonOf(summaryStatement)
|
|
2331
|
+
: traceVaultReasonOf(planStatement);
|
|
2332
|
+
problems.push({
|
|
2333
|
+
// Filed against the UNPROVEN half rather than always the summary, which
|
|
2334
|
+
// is this function's only departure from that rule and is TGATE-03's
|
|
2335
|
+
// own clause: the artifact to regenerate is the one whose run could not
|
|
2336
|
+
// read the vault. With both halves unproven the summary is named, as
|
|
2337
|
+
// the artifact whose record was supposed to answer.
|
|
2338
|
+
file: summaryUnproven ? file : planFile,
|
|
2339
|
+
kind: TRACE_PROBLEM_KIND.COVERAGE_PAIR_UNPROVABLE,
|
|
2340
|
+
detail: `${planUnproven && summaryUnproven
|
|
2341
|
+
? 'both halves of this pair record'
|
|
2342
|
+
: summaryUnproven
|
|
2343
|
+
? 'this summary records'
|
|
2344
|
+
: 'the plan this summary answers records'} no loaded vault, so nothing proves plan ` +
|
|
2345
|
+
`"${collapseControlChars(boundedToken(tracePlanIdOf(planStatement)))}" was governed ` +
|
|
2346
|
+
`— reason: "${collapseControlChars(boundedToken(reason))}".`,
|
|
2347
|
+
// 04-11's mechanism, and the head above contains NO part of the
|
|
2348
|
+
// command: this is the fourth remedy-bearing kind, added by the plan
|
|
2349
|
+
// that follows the fix for the truncated-remedy bug's third occurrence.
|
|
2350
|
+
tail: unprovablePairTail(reason),
|
|
2351
|
+
});
|
|
2352
|
+
return problems;
|
|
2353
|
+
}
|
|
2354
|
+
// Built ONCE, then probed per identifier — a scan per id turns a large
|
|
2355
|
+
// ledger into a quadratic walk (T-4-22), the same bound the resolution
|
|
2356
|
+
// check is written to.
|
|
2357
|
+
const covered = new Set(traceEmittedIdsOf(summaryStatement));
|
|
2358
|
+
const seen = new Set();
|
|
2359
|
+
for (const id of traceEmittedIdsOf(planStatement)) {
|
|
2360
|
+
// De-duplicated: a plan that recorded one id twice has a duplication
|
|
2361
|
+
// defect with its own kind, and must not also produce two coverage
|
|
2362
|
+
// problems for one gap.
|
|
2363
|
+
if (seen.has(id))
|
|
2364
|
+
continue;
|
|
2365
|
+
seen.add(id);
|
|
2366
|
+
if (covered.has(id))
|
|
2367
|
+
continue;
|
|
2368
|
+
// ONE problem PER uncovered identifier, never one aggregate: TGATE-03
|
|
2369
|
+
// requires the failure to name the identifier at fault, and a single
|
|
2370
|
+
// problem standing for three dropped practices makes the count
|
|
2371
|
+
// meaningless.
|
|
2372
|
+
problems.push({
|
|
2373
|
+
file,
|
|
2374
|
+
kind: TRACE_PROBLEM_KIND.COVERAGE_PLAN_UNCOVERED,
|
|
2375
|
+
detail: `practice id "${collapseControlChars(boundedToken(id))}" is recorded in the plan trace of ` +
|
|
2376
|
+
`${planFile} and appears nowhere in this summary's ledger, so the executor's record does ` +
|
|
2377
|
+
`not account for it. A partial overlap is not coverage.`,
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
// ── staleness: the recorded timestamps, compared STRICTLY (D-11) ────────
|
|
2381
|
+
//
|
|
2382
|
+
// Strict on purpose. Equal timestamps are not stale — a plan and a summary
|
|
2383
|
+
// generated inside the same clock tick is an ordinary fast run, and
|
|
2384
|
+
// blocking it would fail correct records for a clock's resolution.
|
|
2385
|
+
// Unparseable on either side yields NO stale problem: an unreadable
|
|
2386
|
+
// timestamp is a required-key defect the single-statement validator already
|
|
2387
|
+
// owns, and reporting it twice from two places gives an operator two
|
|
2388
|
+
// problems for one cause.
|
|
2389
|
+
const planAt = Date.parse(traceGeneratedAtOf(planStatement));
|
|
2390
|
+
const summaryAt = Date.parse(traceGeneratedAtOf(summaryStatement));
|
|
2391
|
+
if (Number.isFinite(planAt) && Number.isFinite(summaryAt) && summaryAt < planAt) {
|
|
2392
|
+
problems.push({
|
|
2393
|
+
file,
|
|
2394
|
+
kind: TRACE_PROBLEM_KIND.COVERAGE_PAIR_STALE,
|
|
2395
|
+
// SHORTNESS WAS THE MITIGATION; THE TAIL IS NOW THE MECHANISM (04-11,
|
|
2396
|
+
// CR-05). The previous reasoning, quoted verbatim so this reads as the
|
|
2397
|
+
// upgrade it is rather than as a loss:
|
|
2398
|
+
//
|
|
2399
|
+
// "THE DETAIL IS DELIBERATELY SHORT, and the reason is CR-02's, not
|
|
2400
|
+
// style. The router bounds every detail at `TRACE_DETAIL_CAP` before
|
|
2401
|
+
// it reaches the message, so a detail that runs long loses its TAIL
|
|
2402
|
+
// — and the tail is the remedy. A stale-pair block whose remedy was
|
|
2403
|
+
// truncated away is exactly the trap D-11 exists to prevent, so the
|
|
2404
|
+
// prose names the plan IDENTIFIER rather than repeating the plan's
|
|
2405
|
+
// path (the summary's own path is already the problem's `file`),
|
|
2406
|
+
// keeping the whole detail inside the cap with the remedy intact."
|
|
2407
|
+
//
|
|
2408
|
+
// Every sentence of that was true and the detail still overflowed. A
|
|
2409
|
+
// budget argument is not a mechanism: the three values interpolated
|
|
2410
|
+
// here are read straight out of an artifact, `boundedToken` allows 80
|
|
2411
|
+
// characters apiece, V8's `Date.parse` accepts trailing parenthesised
|
|
2412
|
+
// text so a 62-character timestamp is trivially reachable, and the
|
|
2413
|
+
// measured worst case came to 418 against the 399-character budget. The
|
|
2414
|
+
// remedy was the part that fell off.
|
|
2415
|
+
//
|
|
2416
|
+
// THIS WAS THE THIRD OCCURRENCE OF ONE BUG. 04-07 shipped a
|
|
2417
|
+
// 480-character stale-pair detail against the same cap and lost the
|
|
2418
|
+
// same remedy; a guard was added. 04-08 built `traceDetail`'s head/tail
|
|
2419
|
+
// split and two cap tests. EACH FIX ADDRESSED ONE SITE — 04-08's guard
|
|
2420
|
+
// measures the tails the ROUTER writes, and a remedy living in a leaf's
|
|
2421
|
+
// HEAD was invisible to it. The prose below is still short, because a
|
|
2422
|
+
// message a reader can read is worth having, but shortness no longer
|
|
2423
|
+
// protects the command: the command is in the tail, concatenated after
|
|
2424
|
+
// the truncation has already happened, so prose and remedy no longer
|
|
2425
|
+
// compete for the same 400 characters at all.
|
|
2426
|
+
detail: `this summary's trace was generated at ` +
|
|
2427
|
+
`"${collapseControlChars(boundedToken(traceGeneratedAtOf(summaryStatement)))}", BEFORE the ` +
|
|
2428
|
+
`plan trace it answers at ` +
|
|
2429
|
+
`"${collapseControlChars(boundedToken(traceGeneratedAtOf(planStatement)))}", so it records ` +
|
|
2430
|
+
`an earlier version of plan ` +
|
|
2431
|
+
`"${collapseControlChars(boundedToken(tracePlanIdOf(planStatement)))}".`,
|
|
2432
|
+
tail: ` Regenerate the execute-stage trace in place, against the file named here: ` +
|
|
2433
|
+
TRACE_REGENERATE_COMMAND,
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
return problems;
|
|
2437
|
+
}
|
|
2438
|
+
catch (e) {
|
|
2439
|
+
return [
|
|
2440
|
+
{
|
|
2441
|
+
file,
|
|
2442
|
+
kind: TRACE_PROBLEM_KIND.VALIDATOR_ERROR,
|
|
2443
|
+
detail: `the coverage comparison could not complete: ${boundedToken(String(e?.message ?? e))}`,
|
|
2444
|
+
},
|
|
2445
|
+
];
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
// ─── The body block, read back against the ledger (D-05-07) ─
|
|
2449
|
+
//
|
|
2450
|
+
// A vault-enabled plan carries TWO surfaces: the `vault:` frontmatter LEDGER
|
|
2451
|
+
// (the audit record) and the `<vault_practices>` BODY BLOCK (the practice text
|
|
2452
|
+
// the executor re-anchors against per task). One CLI emits both from one
|
|
2453
|
+
// matcher run, which is what makes a cross-check cheap — and until this section
|
|
2454
|
+
// nothing performed it. `docs/vault-trace-format.md` § "The two blocks" records
|
|
2455
|
+
// the history: the check was named as Phase 4's, then Phase 5's, then recorded
|
|
2456
|
+
// UNASSIGNED (D-05-07) because no requirement named it. The operator's request
|
|
2457
|
+
// of 2026-10-03, after the 2026-10-02 probe, is the amendment that assigns it.
|
|
2458
|
+
//
|
|
2459
|
+
// PURE, like the validator above it: artifact TEXT and the PARSED STATEMENT in,
|
|
2460
|
+
// problems out. It opens no file, reads no manifest and runs no matcher. Text
|
|
2461
|
+
// is verified against the `textSha256` the ledger RECORDS, never by re-rendering
|
|
2462
|
+
// — a second matcher run would make the verdict a function of this machine's
|
|
2463
|
+
// vault cache, which D-01 removed from this gate on purpose, and would block
|
|
2464
|
+
// every machine whose cache has moved past the pin.
|
|
2465
|
+
const PRACTICES_BLOCK_OPEN = '<vault_practices>';
|
|
2466
|
+
const PRACTICES_BLOCK_CLOSE = '</vault_practices>';
|
|
2467
|
+
/** A sha256 hex digest, and nothing else, is a text digest. */
|
|
2468
|
+
const TEXT_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
2469
|
+
/**
|
|
2470
|
+
* The body-block family as a SET, so the router's message footer can tell a
|
|
2471
|
+
* body remedy from a ledger remedy without spelling the kinds a second time.
|
|
2472
|
+
*/
|
|
2473
|
+
const PRACTICES_BLOCK_KINDS = new Set([
|
|
2474
|
+
TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ABSENT,
|
|
2475
|
+
TRACE_PROBLEM_KIND.PRACTICES_BLOCK_DUPLICATE,
|
|
2476
|
+
TRACE_PROBLEM_KIND.PRACTICES_BLOCK_STATE_MISMATCH,
|
|
2477
|
+
TRACE_PROBLEM_KIND.PRACTICES_BLOCK_EMPTY,
|
|
2478
|
+
TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_MISSING,
|
|
2479
|
+
TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_UNEXPECTED,
|
|
2480
|
+
TRACE_PROBLEM_KIND.PRACTICES_BLOCK_TEXT_MISMATCH,
|
|
2481
|
+
]);
|
|
2482
|
+
/**
|
|
2483
|
+
* The command that regenerates a body block, named in every body-block remedy.
|
|
2484
|
+
* ONE constant, exported, for the reason `TRACE_REGENERATE_COMMAND` is one.
|
|
2485
|
+
*/
|
|
2486
|
+
const PRACTICES_BLOCK_REGENERATE_COMMAND = 'pp_run vault practices match <files_modified...> --block';
|
|
2487
|
+
/**
|
|
2488
|
+
* The trusted tail every body-block problem carries. Author-written, constant,
|
|
2489
|
+
* independent of every fixture input (T-4-43), and measured against the ONE
|
|
2490
|
+
* detail cap: 148 characters, leaving the head 251.
|
|
2491
|
+
*/
|
|
2492
|
+
const PRACTICES_BLOCK_TAIL = ` Regenerate the block and embed the output verbatim, from ${PRACTICES_BLOCK_OPEN} to ` +
|
|
2493
|
+
`${PRACTICES_BLOCK_CLOSE}: ${PRACTICES_BLOCK_REGENERATE_COMMAND}`;
|
|
2494
|
+
/**
|
|
2495
|
+
* The normalisation BOTH digest sites apply, so copy drift that changes no
|
|
2496
|
+
* character of the practice text — CRLF line endings, trailing whitespace a
|
|
2497
|
+
* planner's editor stripped or added, blank lines at either end — does not read
|
|
2498
|
+
* as a content change, while any edit to the text itself does.
|
|
2499
|
+
*/
|
|
2500
|
+
function normalizePracticeText(text) {
|
|
2501
|
+
return (typeof text === 'string' ? text : '')
|
|
2502
|
+
.replace(/\r\n?/g, '\n')
|
|
2503
|
+
.split('\n')
|
|
2504
|
+
.map((line) => line.replace(/[ \t]+$/, ''))
|
|
2505
|
+
.join('\n')
|
|
2506
|
+
.trim();
|
|
2507
|
+
}
|
|
2508
|
+
/** sha256 hex of the normalised practice text. */
|
|
2509
|
+
function practiceTextDigest(text) {
|
|
2510
|
+
return node_crypto_1.default.createHash('sha256').update(normalizePracticeText(text), 'utf8').digest('hex');
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Read the body block(s) out of an artifact.
|
|
2514
|
+
*
|
|
2515
|
+
* The frontmatter is skipped first: the block is a BODY construct and a `---`
|
|
2516
|
+
* document can carry anything in a YAML block scalar. Outside a block, a
|
|
2517
|
+
* markdown code fence and an HTML comment are skipped too — a tag quoted as an
|
|
2518
|
+
* example or commented out is not a block the executor reads as one, and
|
|
2519
|
+
* counting it would let an example satisfy the presence check (plan-checker
|
|
2520
|
+
* remark 2 on D-05-07). Tags are matched at column 0 only, which is where
|
|
2521
|
+
* the renderer puts them and where the planner reference says they go; an
|
|
2522
|
+
* entry is a fenced region whose FIRST `id:` line
|
|
2523
|
+
* precedes its `ask:`/`excerpt:` label — the degraded and refused sections are
|
|
2524
|
+
* fenced too, but render `- **id** — …` lines and never an `id:` line, so they
|
|
2525
|
+
* are not entries. Everything after the label up to the fence end is the text.
|
|
2526
|
+
*/
|
|
2527
|
+
function parsePracticesBlock(content) {
|
|
2528
|
+
const lines = String(content ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
2529
|
+
let start = 0;
|
|
2530
|
+
if (lines[0] === '---') {
|
|
2531
|
+
const close = lines.indexOf('---', 1);
|
|
2532
|
+
if (close > 0)
|
|
2533
|
+
start = close + 1;
|
|
2534
|
+
}
|
|
2535
|
+
const out = { blocks: 0, unterminated: false, entries: [], prose: [] };
|
|
2536
|
+
let i = start;
|
|
2537
|
+
let inCodeFence = false;
|
|
2538
|
+
let inComment = false;
|
|
2539
|
+
while (i < lines.length) {
|
|
2540
|
+
const head = lines[i].trimEnd();
|
|
2541
|
+
if (inComment) {
|
|
2542
|
+
if (head.includes('-->'))
|
|
2543
|
+
inComment = false;
|
|
2544
|
+
i += 1;
|
|
2545
|
+
continue;
|
|
2546
|
+
}
|
|
2547
|
+
if (inCodeFence) {
|
|
2548
|
+
if (/^(`{3,}|~{3,})/.test(head))
|
|
2549
|
+
inCodeFence = false;
|
|
2550
|
+
i += 1;
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
if (/^(`{3,}|~{3,})/.test(head)) {
|
|
2554
|
+
inCodeFence = true;
|
|
2555
|
+
i += 1;
|
|
2556
|
+
continue;
|
|
2557
|
+
}
|
|
2558
|
+
if (head.startsWith('<!--') && !head.includes('-->')) {
|
|
2559
|
+
inComment = true;
|
|
2560
|
+
i += 1;
|
|
2561
|
+
continue;
|
|
2562
|
+
}
|
|
2563
|
+
if (head !== PRACTICES_BLOCK_OPEN) {
|
|
2564
|
+
i += 1;
|
|
2565
|
+
continue;
|
|
2566
|
+
}
|
|
2567
|
+
let j = i + 1;
|
|
2568
|
+
while (j < lines.length && lines[j].trimEnd() !== PRACTICES_BLOCK_CLOSE)
|
|
2569
|
+
j += 1;
|
|
2570
|
+
if (j >= lines.length) {
|
|
2571
|
+
out.unterminated = true;
|
|
2572
|
+
return out;
|
|
2573
|
+
}
|
|
2574
|
+
out.blocks += 1;
|
|
2575
|
+
let cur = null;
|
|
2576
|
+
for (let k = i + 1; k < j; k += 1) {
|
|
2577
|
+
const t = lines[k].trimEnd();
|
|
2578
|
+
if (t === practicesMod.EXTERNAL_CONTENT_FENCE_START) {
|
|
2579
|
+
cur = { id: '', labelled: false, text: [] };
|
|
2580
|
+
continue;
|
|
2581
|
+
}
|
|
2582
|
+
if (t === practicesMod.EXTERNAL_CONTENT_FENCE_END) {
|
|
2583
|
+
if (cur !== null && cur.id !== '') {
|
|
2584
|
+
out.entries.push({ id: cur.id, text: cur.labelled ? cur.text.join('\n') : null });
|
|
2585
|
+
}
|
|
2586
|
+
cur = null;
|
|
2587
|
+
continue;
|
|
2588
|
+
}
|
|
2589
|
+
if (cur !== null) {
|
|
2590
|
+
if (cur.labelled) {
|
|
2591
|
+
cur.text.push(lines[k]);
|
|
2592
|
+
}
|
|
2593
|
+
else if (cur.id === '' && t.startsWith('id: ')) {
|
|
2594
|
+
cur.id = t.slice(4).trim();
|
|
2595
|
+
}
|
|
2596
|
+
else if (cur.id !== '' && (t === 'ask:' || t === 'excerpt:')) {
|
|
2597
|
+
cur.labelled = true;
|
|
2598
|
+
}
|
|
2599
|
+
continue;
|
|
2600
|
+
}
|
|
2601
|
+
// Prose is what is left: not a heading, not a quote, not an HTML comment
|
|
2602
|
+
// (the renderer may stamp the block's first line with a `<!-- … -->`
|
|
2603
|
+
// provenance comment; it is neither an entry nor a sentence).
|
|
2604
|
+
const bare = t.trim();
|
|
2605
|
+
if (bare !== '' && !bare.startsWith('#') && !bare.startsWith('>') && !bare.startsWith('<!--')) {
|
|
2606
|
+
out.prose.push(bare);
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
i = j + 1;
|
|
2610
|
+
}
|
|
2611
|
+
return out;
|
|
2612
|
+
}
|
|
2613
|
+
/** Does a prose line state that the vault itself was never read? */
|
|
2614
|
+
function statesVaultNotRead(line) {
|
|
2615
|
+
return (line === practicesMod.PRACTICES_VAULT_ABSENT_SENTENCE
|
|
2616
|
+
|| line === practicesMod.PRACTICES_VAULT_UNREADABLE_SENTENCE
|
|
2617
|
+
|| line.startsWith('Practices could not be checked'));
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Compare ONE plan artifact's `<vault_practices>` body block against its parsed
|
|
2621
|
+
* `vault:` ledger statement. Returns problems, never a boolean, on the
|
|
2622
|
+
* validator's reasoning; NEVER THROWS, on the validator's reasoning too.
|
|
2623
|
+
*
|
|
2624
|
+
* THE FIVE QUESTIONS, in order, each one short-circuiting the next:
|
|
2625
|
+
* 1. Is there exactly one block? → absent / duplicate
|
|
2626
|
+
* 2. Does the block agree with the ledger about whether the vault was READ?
|
|
2627
|
+
* A loaded ledger beside a vault-not-read sentence → state_mismatch
|
|
2628
|
+
* 3. Does it deliver ANY entry when the ledger promises ≥1? → empty
|
|
2629
|
+
* 4. Do the two id sets agree, both ways? → id_missing / id_unexpected
|
|
2630
|
+
* 5. For every entry: with a `textSha256` on its ledger line, does the text
|
|
2631
|
+
* under `ask:`/`excerpt:` digest to it; without one, is there any text
|
|
2632
|
+
* there at all? → text_mismatch
|
|
2633
|
+
*
|
|
2634
|
+
* An absent block is a defect WHATEVER the ledger lists, including nothing: the
|
|
2635
|
+
* block is written in every degraded state too, and `docs/practices-format.md`
|
|
2636
|
+
* is explicit that a silent omission is indistinguishable from the capability
|
|
2637
|
+
* being off. The `empty` question is asked only when the ledger promises
|
|
2638
|
+
* something, because a block that correctly says "none apply" over an empty
|
|
2639
|
+
* ledger has delivered exactly what was recorded.
|
|
2640
|
+
*
|
|
2641
|
+
* Question 5's DIGEST half is SKIPPED, not failed, for a ledger entry without a
|
|
2642
|
+
* digest: the key is optional (a minor addition), a ledger written before it
|
|
2643
|
+
* existed is a correct record, and blocking it on text alone would halt every
|
|
2644
|
+
* phase planned on an earlier engine at ship time. What such an entry still
|
|
2645
|
+
* must have is SOME text — the renderer never emits a labelled entry with none,
|
|
2646
|
+
* so a blank or label-less entry is a defect whatever the ledger's age. A
|
|
2647
|
+
* placeholder like "(see vault)" on a pre-digest ledger is NOT caught, and is
|
|
2648
|
+
* recorded as the one shape this check leaves open (plan-checker remark 1).
|
|
2649
|
+
* Questions 1–4 need no digest and run on every ledger.
|
|
2650
|
+
*/
|
|
2651
|
+
function validatePracticesBody(content, statement, options) {
|
|
2652
|
+
const opts = isPlainObject(options) ? options : {};
|
|
2653
|
+
const file = typeof opts.file === 'string' && opts.file !== '' ? opts.file : 'the artifact';
|
|
2654
|
+
const problems = [];
|
|
2655
|
+
const add = (kind, detail) => {
|
|
2656
|
+
problems.push({ file, kind, detail, tail: PRACTICES_BLOCK_TAIL });
|
|
2657
|
+
};
|
|
2658
|
+
const safe = (v) => collapseControlChars(boundedToken(v));
|
|
2659
|
+
try {
|
|
2660
|
+
if (opts.stage !== 'plan')
|
|
2661
|
+
return problems;
|
|
2662
|
+
if (!isPlainObject(statement))
|
|
2663
|
+
return problems;
|
|
2664
|
+
// The ledger's half: ids in recorded order, de-duplicated (a duplicated id
|
|
2665
|
+
// is its own defect with its own kind), and the digest each carries.
|
|
2666
|
+
const ledgerIds = [];
|
|
2667
|
+
const digests = new Map();
|
|
2668
|
+
const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
|
|
2669
|
+
const practices = Array.isArray(predicate['practices']) ? predicate['practices'] : [];
|
|
2670
|
+
for (const raw of practices) {
|
|
2671
|
+
const entry = isPlainObject(raw) ? raw : {};
|
|
2672
|
+
const id = typeof entry['id'] === 'string' ? entry['id'] : '';
|
|
2673
|
+
if (id === '' || ledgerIds.includes(id))
|
|
2674
|
+
continue;
|
|
2675
|
+
ledgerIds.push(id);
|
|
2676
|
+
const digest = entry['textSha256'];
|
|
2677
|
+
if (typeof digest === 'string' && digest !== '')
|
|
2678
|
+
digests.set(id, digest);
|
|
2679
|
+
}
|
|
2680
|
+
const promised = ledgerIds.length;
|
|
2681
|
+
const promisedText = `${promised} practice id${promised === 1 ? '' : 's'}`;
|
|
2682
|
+
const parsed = parsePracticesBlock(typeof content === 'string' ? content : '');
|
|
2683
|
+
// ── 1. exactly one block ──────────────────────────────────────────────
|
|
2684
|
+
if (parsed.unterminated) {
|
|
2685
|
+
add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ABSENT, `${file} opens a ${PRACTICES_BLOCK_OPEN} block that is never closed, so no executor can tell ` +
|
|
2686
|
+
`where the client's practice text ends; its vault: ledger records ${promisedText}.`);
|
|
2687
|
+
return problems;
|
|
2688
|
+
}
|
|
2689
|
+
if (parsed.blocks === 0) {
|
|
2690
|
+
add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ABSENT, `${file} carries a vault: ledger recording ${promisedText} but no ${PRACTICES_BLOCK_OPEN} block, ` +
|
|
2691
|
+
`so the executor that reads this plan receives no practice text at all — the ledger records ` +
|
|
2692
|
+
`governance the plan does not deliver.`);
|
|
2693
|
+
return problems;
|
|
2694
|
+
}
|
|
2695
|
+
if (parsed.blocks > 1) {
|
|
2696
|
+
add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_DUPLICATE, `${file} carries ${parsed.blocks} ${PRACTICES_BLOCK_OPEN} blocks where a plan carries exactly one, ` +
|
|
2697
|
+
`so which practice text governs this plan is ambiguous.`);
|
|
2698
|
+
return problems;
|
|
2699
|
+
}
|
|
2700
|
+
// ── 2. the block and the ledger agree about whether the vault was READ ─
|
|
2701
|
+
//
|
|
2702
|
+
// One direction only, and argued: a `degraded` ledger beside a block full
|
|
2703
|
+
// of entries is CONSISTENT (a stale cache still matches), and a `degraded`
|
|
2704
|
+
// ledger beside the absent sentence is consistent too. The disagreement
|
|
2705
|
+
// that can mislead is a block telling the executor the vault was never
|
|
2706
|
+
// fetched about a run whose ledger records a loaded pin — the "fake
|
|
2707
|
+
// degraded" shape the probe planted. Fires whatever the ledger promises,
|
|
2708
|
+
// including nothing: it is a statement about the run, not about a count.
|
|
2709
|
+
if (traceVaultStateOf(statement) === 'loaded' && parsed.prose.some(statesVaultNotRead)) {
|
|
2710
|
+
add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_STATE_MISMATCH, `the ${PRACTICES_BLOCK_OPEN} block in ${file} states that the client vault was not read, while ` +
|
|
2711
|
+
`its vault: ledger records state "loaded" with ${promisedText}. The block tells the executor ` +
|
|
2712
|
+
`nothing was fetched about a run that recorded a pin, so the two halves of this plan's record ` +
|
|
2713
|
+
`disagree about what happened.`);
|
|
2714
|
+
return problems;
|
|
2715
|
+
}
|
|
2716
|
+
// ── 3. the block delivers something when the ledger promises something ─
|
|
2717
|
+
if (promised > 0 && parsed.entries.length === 0) {
|
|
2718
|
+
add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_EMPTY, `the ${PRACTICES_BLOCK_OPEN} block in ${file} renders no practice entry while its vault: ledger ` +
|
|
2719
|
+
`records ${promisedText}. The executor applies what the block delivers, so these practices ` +
|
|
2720
|
+
`govern nothing.`);
|
|
2721
|
+
return problems;
|
|
2722
|
+
}
|
|
2723
|
+
// ── 4. the two id sets agree, both ways ───────────────────────────────
|
|
2724
|
+
const bodyIds = [];
|
|
2725
|
+
for (const e of parsed.entries)
|
|
2726
|
+
if (!bodyIds.includes(e.id))
|
|
2727
|
+
bodyIds.push(e.id);
|
|
2728
|
+
const bodySet = new Set(bodyIds);
|
|
2729
|
+
const ledgerSet = new Set(ledgerIds);
|
|
2730
|
+
for (const id of ledgerIds) {
|
|
2731
|
+
if (bodySet.has(id))
|
|
2732
|
+
continue;
|
|
2733
|
+
add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_MISSING, `practice "${safe(id)}" is recorded in the vault: ledger of ${file} and has no entry in its ` +
|
|
2734
|
+
`${PRACTICES_BLOCK_OPEN} block, so the executor never sees its text.`);
|
|
2735
|
+
}
|
|
2736
|
+
for (const id of bodyIds) {
|
|
2737
|
+
if (ledgerSet.has(id))
|
|
2738
|
+
continue;
|
|
2739
|
+
add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_UNEXPECTED, `the ${PRACTICES_BLOCK_OPEN} block in ${file} carries an entry for practice "${safe(id)}" that its ` +
|
|
2740
|
+
`vault: ledger does not record, so the two halves of this plan's record disagree about which ` +
|
|
2741
|
+
`practices apply.`);
|
|
2742
|
+
}
|
|
2743
|
+
// ── 5. the text is the text the matcher rendered — or, without a digest,
|
|
2744
|
+
// is at least THERE ────────────────────────────────────────────────
|
|
2745
|
+
const checked = new Set();
|
|
2746
|
+
for (const e of parsed.entries) {
|
|
2747
|
+
if (checked.has(e.id) || !ledgerSet.has(e.id))
|
|
2748
|
+
continue;
|
|
2749
|
+
checked.add(e.id);
|
|
2750
|
+
const want = digests.get(e.id);
|
|
2751
|
+
const blank = e.text === null || normalizePracticeText(e.text) === '';
|
|
2752
|
+
if (want === undefined) {
|
|
2753
|
+
// The digest-free floor. The renderer always emits the label and the
|
|
2754
|
+
// text under it, so an entry with neither is not a copy of anything
|
|
2755
|
+
// the matcher produced.
|
|
2756
|
+
if (!blank)
|
|
2757
|
+
continue;
|
|
2758
|
+
add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_TEXT_MISMATCH, `practice "${safe(e.id)}" in the ${PRACTICES_BLOCK_OPEN} block of ${file} carries no practice ` +
|
|
2759
|
+
`text under ask:/excerpt:, so the executor is handed an identifier and nothing to apply.`);
|
|
2760
|
+
continue;
|
|
2761
|
+
}
|
|
2762
|
+
const got = blank ? '' : practiceTextDigest(e.text);
|
|
2763
|
+
if (got === want)
|
|
2764
|
+
continue;
|
|
2765
|
+
add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_TEXT_MISMATCH, `the text under practice "${safe(e.id)}" in the ${PRACTICES_BLOCK_OPEN} block of ${file} is not ` +
|
|
2766
|
+
`the text the matcher rendered when its vault: ledger was written` +
|
|
2767
|
+
(e.text === null
|
|
2768
|
+
? ' (the entry carries no ask:/excerpt: section)'
|
|
2769
|
+
: ' (it was altered, shortened or replaced)') +
|
|
2770
|
+
`, so the executor is not reading the client's practice.`);
|
|
2771
|
+
}
|
|
2772
|
+
return problems;
|
|
2773
|
+
}
|
|
2774
|
+
catch (e) {
|
|
2775
|
+
return [
|
|
2776
|
+
{
|
|
2777
|
+
file,
|
|
2778
|
+
kind: TRACE_PROBLEM_KIND.VALIDATOR_ERROR,
|
|
2779
|
+
detail: `the body-block check could not complete: ${boundedToken(String(e?.message ?? e))}`,
|
|
2780
|
+
},
|
|
2781
|
+
];
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
module.exports = {
|
|
2785
|
+
buildTraceStatement,
|
|
2786
|
+
serializeTraceBlock,
|
|
2787
|
+
renderPracticesApplied,
|
|
2788
|
+
parseVaultTraceBlock,
|
|
2789
|
+
validateTraceStatement,
|
|
2790
|
+
// 04-07 (D-09): the pair comparison. Exported because the RELATION is the
|
|
2791
|
+
// router's to compose — the leaf compares one pair at a time and returns
|
|
2792
|
+
// problems, and the router enumerates, pairs and folds them into its
|
|
2793
|
+
// envelope. Keeping it here rather than in the handler keeps every structural
|
|
2794
|
+
// judgement about a trace in the leaf, which is the property that stops a
|
|
2795
|
+
// second, drifting implementation from growing in an adapter.
|
|
2796
|
+
comparePracticeCoverage,
|
|
2797
|
+
// D-05-07: the body-block check, its digest and its remedy
|
|
2798
|
+
// command. The router composes the problems exactly as it composes the
|
|
2799
|
+
// validator's; the two digest sites (builder and body check) share
|
|
2800
|
+
// `practiceTextDigest`, so the normalisation is written once.
|
|
2801
|
+
validatePracticesBody,
|
|
2802
|
+
practiceTextDigest,
|
|
2803
|
+
PRACTICES_BLOCK_REGENERATE_COMMAND,
|
|
2804
|
+
PRACTICES_BLOCK_KINDS,
|
|
2805
|
+
TRACE_REQUIRED_KEYS,
|
|
2806
|
+
TRACE_PROBLEM_KIND,
|
|
2807
|
+
TRACE_BLOCK_KEY,
|
|
2808
|
+
TRACE_STATEMENT_TYPE,
|
|
2809
|
+
TRACE_PREDICATE_TYPE,
|
|
2810
|
+
TRACE_SCHEMA_VERSION,
|
|
2811
|
+
TRACE_STATUS_VALUES,
|
|
2812
|
+
TRACE_ID_PATTERN,
|
|
2813
|
+
// 10-08 (MECH-03, D-10-05): the analyzer-provenance lead. Exported so the
|
|
2814
|
+
// test that PINS it against its owner — `AUDIT_ENFORCED_BY_LEAD` in
|
|
2815
|
+
// `src/vault-audit.cts` — can compare the two shipped strings rather than a
|
|
2816
|
+
// copy of either. See the constant's own comment for why this module holds a
|
|
2817
|
+
// second spelling instead of importing the first.
|
|
2818
|
+
TRACE_ENFORCED_BY_LEAD,
|
|
2819
|
+
// Moved here from src/check-command-router.cts by 04-05 and exported: the
|
|
2820
|
+
// validator's missing-universe remedy and the handler's block message must
|
|
2821
|
+
// name the SAME command, and the handler already imports this module.
|
|
2822
|
+
TRACE_REGENERATE_COMMAND,
|
|
2823
|
+
PRACTICES_APPLIED_HEADING,
|
|
2824
|
+
// The reader allowlist: pp-core's own predicate type plus any the org
|
|
2825
|
+
// profile lists (same major). The validator takes the list through its
|
|
2826
|
+
// options, so a gate passes `acceptedPredicateTypes(root)`.
|
|
2827
|
+
acceptedPredicateTypes,
|
|
2828
|
+
TRACE_WORKTREE_CACHE_NOTE,
|
|
2829
|
+
// The recorded check: census, read back for the mechanical audit.
|
|
2830
|
+
readRecordedCensus,
|
|
2831
|
+
};
|