@tiphys/kernel 0.0.0 → 0.1.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/AGENTS.md +611 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +278 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +14 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +59 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +828 -0
- package/dist/src/checks.js +3314 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +57 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +320 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/doctor.d.ts +35 -0
- package/dist/src/commands/doctor.js +605 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +128 -0
- package/dist/src/commands/lock.d.ts +1 -0
- package/dist/src/commands/lock.js +229 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +128 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +146 -0
- package/dist/src/commands/status.d.ts +19 -0
- package/dist/src/commands/status.js +120 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +79 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +360 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/exec/env.d.ts +128 -0
- package/dist/src/exec/env.js +190 -0
- package/dist/src/fleet.d.ts +51 -0
- package/dist/src/fleet.js +80 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1163 -0
- package/dist/src/gates/coverage.d.ts +284 -0
- package/dist/src/gates/coverage.js +701 -0
- package/dist/src/gates/credentials.d.ts +74 -0
- package/dist/src/gates/credentials.js +533 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +390 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +566 -0
- package/dist/src/gates/run.js +1536 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +927 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +32 -0
- package/dist/src/hooks.js +62 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +178 -0
- package/dist/src/lock.js +500 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +2 -0
- package/dist/src/path-identity.js +10 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +130 -0
- package/dist/src/pool.js +721 -0
- package/dist/src/roles.d.ts +430 -0
- package/dist/src/roles.js +734 -0
- package/dist/src/spawn.d.ts +177 -0
- package/dist/src/spawn.js +332 -0
- package/dist/src/status.d.ts +91 -0
- package/dist/src/status.js +119 -0
- package/dist/src/task.d.ts +264 -0
- package/dist/src/task.js +305 -0
- package/dist/src/teardown.d.ts +32 -0
- package/dist/src/teardown.js +314 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +230 -0
- package/dist/src/validate.js +732 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +859 -0
- package/dist/src/witness/run.d.ts +274 -0
- package/dist/src/witness/run.js +1327 -0
- package/dist/src/witness/spec.d.ts +102 -0
- package/dist/src/witness/spec.js +253 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +390 -0
- package/gates.manifest.json +195 -0
- package/package.json +57 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +460 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +81 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +166 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +289 -0
- package/schemas/work-history.schema.json +183 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
|
@@ -0,0 +1,3314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DERIVED-CHECK REGISTRY (kernel plan M3, section 2.3 Kind B; step 8).
|
|
3
|
+
*
|
|
4
|
+
* JSON Schema expresses properties of ONE document reachable by one keyword.
|
|
5
|
+
* A property that compares array elements to each other, resolves a reference
|
|
6
|
+
* into another document, computes arithmetic over sibling fields or touches
|
|
7
|
+
* the filesystem is not expressible by any keyword under any DR-0013 option,
|
|
8
|
+
* and this module is where the plan stopped pretending otherwise (M3R-002).
|
|
9
|
+
*
|
|
10
|
+
* Each check runs AFTER schema validation succeeds and reports through the
|
|
11
|
+
* same contract with its own id attached:
|
|
12
|
+
*
|
|
13
|
+
* INVALID <json-pointer> <message> (check: <check-id>)
|
|
14
|
+
*
|
|
15
|
+
* A check that needs a CONTEXT it was not given reports
|
|
16
|
+
* `SKIPPED <check-id> no context` and the command exits nonzero. That is the
|
|
17
|
+
* whole point of the mechanism: a cross-document rule must never be able to
|
|
18
|
+
* pass BY NOT RUNNING, which is the vacuous-pass shape SC-011 and M2-C-2 both
|
|
19
|
+
* exist to prevent, one layer up.
|
|
20
|
+
*
|
|
21
|
+
* DR-0013 clause 8: Kind B rules stay HERE and are never encoded as Ajv
|
|
22
|
+
* extensions. The Kind A / Kind B boundary is binding.
|
|
23
|
+
*
|
|
24
|
+
* D-M3-22: a check that belongs in section 2.3's table and is not in it is a
|
|
25
|
+
* PLAN DEFECT to escalate, not a script to add quietly.
|
|
26
|
+
*/
|
|
27
|
+
import { readdirSync } from "node:fs";
|
|
28
|
+
import { createRequire } from "node:module";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { decodeDocument, readOperatorPath } from "./validate.js";
|
|
31
|
+
/* M3-P8. The two tuition checks resolve operator-supplied paths against the
|
|
32
|
+
tree, so they classify an entry before deciding anything about it rather
|
|
33
|
+
than opening it (D-M3-27, the mechanism index's row
|
|
34
|
+
`reading-a-path-whose-type-is-not-established`). */
|
|
35
|
+
import { classifyEntry } from "./task.js";
|
|
36
|
+
/** Every artifact type one check runs on, `type` first and then `alsoTypes`. */
|
|
37
|
+
export function typesOf(check) {
|
|
38
|
+
return [check.type, ...(check.alsoTypes ?? [])];
|
|
39
|
+
}
|
|
40
|
+
const EMPTY = { violations: [], reports: [] };
|
|
41
|
+
function asRecord(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
43
|
+
? value
|
|
44
|
+
: undefined;
|
|
45
|
+
}
|
|
46
|
+
function asArray(value) {
|
|
47
|
+
return Array.isArray(value) ? value : [];
|
|
48
|
+
}
|
|
49
|
+
/* ------------------------------------------------------------------ */
|
|
50
|
+
/* plan-verification-first-present (R-012, M3R-002) */
|
|
51
|
+
/* ------------------------------------------------------------------ */
|
|
52
|
+
/**
|
|
53
|
+
* A `report-code-disagreement` entry with `verified: false` names a claim
|
|
54
|
+
* that has NOT been confirmed against the code. R-012 says such a claim
|
|
55
|
+
* becomes a verification-first step: step 1 is confirm, write down, then
|
|
56
|
+
* build. So the owning phase must carry a step with `kind:
|
|
57
|
+
* verification-first`.
|
|
58
|
+
*
|
|
59
|
+
* No schema keyword reaches this: it matches an element of ONE array against
|
|
60
|
+
* a step nested inside an element of ANOTHER array, selected by phase id. A
|
|
61
|
+
* foreign-key lookup across arrays is not a keyword property.
|
|
62
|
+
*/
|
|
63
|
+
export const planVerificationFirstPresent = {
|
|
64
|
+
id: "plan-verification-first-present",
|
|
65
|
+
type: "plan",
|
|
66
|
+
requiresContext: false,
|
|
67
|
+
run(instance) {
|
|
68
|
+
const plan = asRecord(instance);
|
|
69
|
+
if (plan === undefined) {
|
|
70
|
+
return EMPTY;
|
|
71
|
+
}
|
|
72
|
+
const violations = [];
|
|
73
|
+
const phases = asArray(plan["phases"]);
|
|
74
|
+
const disagreements = asArray(plan["report-code-disagreement"]);
|
|
75
|
+
for (let index = 0; index < disagreements.length; index += 1) {
|
|
76
|
+
const entry = asRecord(disagreements[index]);
|
|
77
|
+
if (entry === undefined || entry["verified"] !== false) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const phaseId = entry["phase"];
|
|
81
|
+
const owning = phases.find((candidate) => asRecord(candidate)?.["id"] === phaseId);
|
|
82
|
+
const pointer = `#/report-code-disagreement/${String(index)}`;
|
|
83
|
+
if (owning === undefined) {
|
|
84
|
+
violations.push({
|
|
85
|
+
pointer,
|
|
86
|
+
message: `unverified claim names phase ${String(phaseId)}, which this plan does not contain`,
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const steps = asArray(asRecord(owning)?.["steps"]);
|
|
91
|
+
const hasVerificationFirst = steps.some((step) => asRecord(step)?.["kind"] === "verification-first");
|
|
92
|
+
if (!hasVerificationFirst) {
|
|
93
|
+
violations.push({
|
|
94
|
+
pointer,
|
|
95
|
+
message: `unverified claim is owned by phase ${String(phaseId)}, which declares no verification-first step`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { violations, reports: [] };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
/* ------------------------------------------------------------------ */
|
|
103
|
+
/* plan-dispatchable (R-014) */
|
|
104
|
+
/* ------------------------------------------------------------------ */
|
|
105
|
+
/**
|
|
106
|
+
* A phase whose `fill-in` is present and unfilled is VALID FOR REVIEW and
|
|
107
|
+
* INVALID FOR DISPATCH. That is a derived boolean over the slots rather than
|
|
108
|
+
* a property of any one field, so the validator computes and REPORTS it. A
|
|
109
|
+
* schema cannot express it, and rejecting the document would be wrong: the
|
|
110
|
+
* plan is legitimately reviewable in that state.
|
|
111
|
+
*/
|
|
112
|
+
export const planDispatchable = {
|
|
113
|
+
id: "plan-dispatchable",
|
|
114
|
+
type: "plan",
|
|
115
|
+
requiresContext: false,
|
|
116
|
+
run(instance) {
|
|
117
|
+
const plan = asRecord(instance);
|
|
118
|
+
if (plan === undefined) {
|
|
119
|
+
return EMPTY;
|
|
120
|
+
}
|
|
121
|
+
const unfilled = [];
|
|
122
|
+
for (const phase of asArray(plan["phases"])) {
|
|
123
|
+
const record = asRecord(phase);
|
|
124
|
+
const fillIn = asRecord(record?.["fill-in"]);
|
|
125
|
+
if (fillIn === undefined) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (fillIn["filled"] !== true) {
|
|
129
|
+
unfilled.push(String(record?.["id"]));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const dispatchable = unfilled.length === 0;
|
|
133
|
+
const reports = [`dispatchable: ${dispatchable ? "true" : "false"}`];
|
|
134
|
+
if (!dispatchable) {
|
|
135
|
+
reports.push(`not dispatchable because these phases carry an unfilled fill-in: ${unfilled.sort().join(", ")}`);
|
|
136
|
+
}
|
|
137
|
+
return { violations: [], reports };
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
/* ------------------------------------------------------------------ */
|
|
141
|
+
/* plan-hazard-classes-addressed-by-resolves (section 2.6, D-M3-35) */
|
|
142
|
+
/* ------------------------------------------------------------------ */
|
|
143
|
+
/**
|
|
144
|
+
* Every `hazard-classes[].addressed-by` must RESOLVE. Its two arms resolve
|
|
145
|
+
* against DIFFERENT things, which is why one witness is not a class here:
|
|
146
|
+
*
|
|
147
|
+
* `criterion <id>` resolves into the SAME phase's `acceptance[]` ids;
|
|
148
|
+
* `later-phase: <id>` resolves into the PLAN's `phases[]` ids.
|
|
149
|
+
*
|
|
150
|
+
* `enum` cannot express either, because the admissible values are computed
|
|
151
|
+
* per phase rather than fixed. The schema's `pattern` is the Kind A half and
|
|
152
|
+
* checks only the SHAPE of the string; a shape that resolves to nothing is
|
|
153
|
+
* precisely the defect section 2.6 was written after finding: a hazard class
|
|
154
|
+
* that names a criterion which does not exist has documented an obligation
|
|
155
|
+
* instead of creating one.
|
|
156
|
+
*/
|
|
157
|
+
export const planHazardClassesAddressedByResolves = {
|
|
158
|
+
id: "plan-hazard-classes-addressed-by-resolves",
|
|
159
|
+
type: "plan",
|
|
160
|
+
requiresContext: false,
|
|
161
|
+
run(instance) {
|
|
162
|
+
const plan = asRecord(instance);
|
|
163
|
+
if (plan === undefined) {
|
|
164
|
+
return EMPTY;
|
|
165
|
+
}
|
|
166
|
+
const violations = [];
|
|
167
|
+
const phases = asArray(plan["phases"]);
|
|
168
|
+
const phaseIds = new Set(phases
|
|
169
|
+
.map((phase) => asRecord(phase)?.["id"])
|
|
170
|
+
.filter((id) => typeof id === "string"));
|
|
171
|
+
for (let phaseIndex = 0; phaseIndex < phases.length; phaseIndex += 1) {
|
|
172
|
+
const phase = asRecord(phases[phaseIndex]);
|
|
173
|
+
if (phase === undefined) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
/* COUNTED, not just collected. B-003 (fix round 1): a phase with two
|
|
177
|
+
acceptance entries sharing an id lets `addressed-by: "criterion 3"`
|
|
178
|
+
resolve to a DECOY, so the hazard class points at a criterion that
|
|
179
|
+
exists and does not redden against it. T-007's completeness
|
|
180
|
+
guarantee then fails one level INSIDE the mechanism built to enforce
|
|
181
|
+
it, and the resolution still reports success. An ambiguous resolution
|
|
182
|
+
is therefore a violation of THIS check rather than a new one: what
|
|
183
|
+
the check promises is that `addressed-by` resolves to A criterion,
|
|
184
|
+
and it cannot promise that when it resolves to two. */
|
|
185
|
+
const criterionCounts = new Map();
|
|
186
|
+
for (const entry of asArray(phase["acceptance"])) {
|
|
187
|
+
const id = asRecord(entry)?.["id"];
|
|
188
|
+
if (typeof id === "string") {
|
|
189
|
+
criterionCounts.set(id, (criterionCounts.get(id) ?? 0) + 1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const criterionIds = new Set(criterionCounts.keys());
|
|
193
|
+
const hazards = asArray(phase["hazard-classes"]);
|
|
194
|
+
for (let hazardIndex = 0; hazardIndex < hazards.length; hazardIndex += 1) {
|
|
195
|
+
const hazard = asRecord(hazards[hazardIndex]);
|
|
196
|
+
const addressedBy = hazard?.["addressed-by"];
|
|
197
|
+
if (typeof addressedBy !== "string") {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const pointer = `#/phases/${String(phaseIndex)}/hazard-classes/${String(hazardIndex)}/addressed-by`;
|
|
201
|
+
if (addressedBy.startsWith("criterion ")) {
|
|
202
|
+
const criterionId = addressedBy.slice("criterion ".length).trim();
|
|
203
|
+
if (!criterionIds.has(criterionId)) {
|
|
204
|
+
violations.push({
|
|
205
|
+
pointer,
|
|
206
|
+
message: `criterion ${criterionId} is not an acceptance criterion of phase ${String(phase["id"])}`,
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const occurrences = criterionCounts.get(criterionId) ?? 0;
|
|
211
|
+
if (occurrences > 1) {
|
|
212
|
+
violations.push({
|
|
213
|
+
pointer,
|
|
214
|
+
message: `criterion ${criterionId} is declared ${String(occurrences)} times in phase ${String(phase["id"])}, so this hazard class resolves ambiguously`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (addressedBy.startsWith("later-phase: ")) {
|
|
220
|
+
const target = addressedBy.slice("later-phase: ".length).trim();
|
|
221
|
+
if (!phaseIds.has(target)) {
|
|
222
|
+
violations.push({
|
|
223
|
+
pointer,
|
|
224
|
+
message: `deferred to phase ${target}, which this plan does not contain`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { violations, reports: [] };
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
/* ================================================================== */
|
|
234
|
+
/* M3-P3: the assurance-mode checks */
|
|
235
|
+
/* ================================================================== */
|
|
236
|
+
/** The mode whose pipeline every other mode's downgrades are measured against. */
|
|
237
|
+
const REFERENCE_MODE_ID = "full";
|
|
238
|
+
/** The document name these checks report against when naming the instance. */
|
|
239
|
+
const MODES_DOCUMENT = "assurance-modes.yaml";
|
|
240
|
+
/**
|
|
241
|
+
* Do two string lists hold the same values in the same order?
|
|
242
|
+
*
|
|
243
|
+
* ELEMENT-WISE, WITH NO SEPARATOR (M3-P3 fix round 1, finding A-001/B-001).
|
|
244
|
+
* This comparison was written as `a.join(sep) !== b.join(sep)`, and the
|
|
245
|
+
* separator in the source was two LITERAL NUL BYTES. Two things were wrong and
|
|
246
|
+
* only one of them was the bytes.
|
|
247
|
+
*
|
|
248
|
+
* The bytes: `src/checks.ts` is the file every later M3 phase extends, and a
|
|
249
|
+
* NUL past git's sniff window is worse than an unreviewable diff, because
|
|
250
|
+
* `git diff --stat` reports no `Bin` and the hunk renders as `join("")`,
|
|
251
|
+
* which LOOKS CORRECT. CLAUDE.md's prescribed control-character grep could
|
|
252
|
+
* not see it either, for the reason T-010 records.
|
|
253
|
+
*
|
|
254
|
+
* The mechanism: a separator join answers "are these lists equal" with a
|
|
255
|
+
* PROXY, and the proxy is only faithful for separators the values cannot
|
|
256
|
+
* contain. That is the same shape as the two findings this fix round is
|
|
257
|
+
* mostly about, one layer down. Replacing NUL with a space would have been
|
|
258
|
+
* the instance fix and would have made `["a b"]` compare equal to
|
|
259
|
+
* `["a", "b"]`.
|
|
260
|
+
*
|
|
261
|
+
* SO THE SEMANTICS DID CHANGE, and it is stated rather than slipped in: this
|
|
262
|
+
* is now exact list equality for every input, where `join(NUL)` was exact list
|
|
263
|
+
* equality for every input that contains no NUL. No caller can produce one
|
|
264
|
+
* today (both lists come from YAML/JSON decoding of documents whose values are
|
|
265
|
+
* enum-constrained), so no behaviour observable from any test moved. The
|
|
266
|
+
* registered test `charter-mode-enum-drift-detected` covers both arms and a
|
|
267
|
+
* new arm covers the separator class directly.
|
|
268
|
+
*/
|
|
269
|
+
function sameStringList(left, right) {
|
|
270
|
+
if (left.length !== right.length) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
return left.every((value, index) => value === right[index]);
|
|
274
|
+
}
|
|
275
|
+
/** Every string in an array field, in order, with non-strings dropped. */
|
|
276
|
+
function stringsAt(record, key) {
|
|
277
|
+
return asArray(record?.[key]).filter((value) => typeof value === "string");
|
|
278
|
+
}
|
|
279
|
+
/** `{index, record, id}` for every element of `modes[]` that is an object. */
|
|
280
|
+
function eachMode(instance) {
|
|
281
|
+
const document = asRecord(instance);
|
|
282
|
+
const modes = asArray(document?.["modes"]);
|
|
283
|
+
const rows = [];
|
|
284
|
+
for (let index = 0; index < modes.length; index += 1) {
|
|
285
|
+
const mode = asRecord(modes[index]);
|
|
286
|
+
if (mode === undefined) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
rows.push({ index, mode, id: String(mode["id"] ?? "") });
|
|
290
|
+
}
|
|
291
|
+
return rows;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Read and decode a document from the CONTEXT directory, or say why not.
|
|
295
|
+
*
|
|
296
|
+
* FAIL CLOSED. A cross-document rule whose other document is missing must not
|
|
297
|
+
* become a pass: that is the vacuous shape this whole module exists to
|
|
298
|
+
* prevent, one level down from `SKIPPED <id> no context`. The path is not one
|
|
299
|
+
* this program created, so it is classified before it is opened
|
|
300
|
+
* (`readOperatorPath`, D-M3-27) rather than opened and hoped about.
|
|
301
|
+
*/
|
|
302
|
+
function readContextDocument(contextDirectory, relativePath) {
|
|
303
|
+
const path = join(contextDirectory, relativePath);
|
|
304
|
+
const read = readOperatorPath(path);
|
|
305
|
+
if (!read.ok) {
|
|
306
|
+
return { ok: false, reason: read.reason };
|
|
307
|
+
}
|
|
308
|
+
const decoded = decodeDocument(read.body, path);
|
|
309
|
+
if (!decoded.ok) {
|
|
310
|
+
return { ok: false, reason: decoded.reason };
|
|
311
|
+
}
|
|
312
|
+
return { ok: true, value: decoded.value, path };
|
|
313
|
+
}
|
|
314
|
+
/* ------------------------------------------------------------------ */
|
|
315
|
+
/* mode-no-undeclared-downgrade (blueprint section 8, M3-P3 criterion 3a) */
|
|
316
|
+
/* ------------------------------------------------------------------ */
|
|
317
|
+
/**
|
|
318
|
+
* "Downgrades are declared, never improvised" (blueprint section 8), made
|
|
319
|
+
* falsifiable: every stage the reference mode `full` runs and this mode does
|
|
320
|
+
* not must appear in this mode's `skips[]`.
|
|
321
|
+
*
|
|
322
|
+
* NO SCHEMA KEYWORD REACHES THIS. It is a set difference between the
|
|
323
|
+
* `pipeline` of ONE array element and the `pipeline` of a SIBLING element,
|
|
324
|
+
* selected by id, compared against a third field of the first (M3R-002). The
|
|
325
|
+
* schema's whole share is that `skips` exists and holds stage ids.
|
|
326
|
+
*
|
|
327
|
+
* TWO STRUCTURALLY DIFFERENT WAYS TO EVADE IT, and both are violations here
|
|
328
|
+
* rather than one being left implied:
|
|
329
|
+
*
|
|
330
|
+
* 1. a mode omits a stage and declares NOTHING (`skips: []`);
|
|
331
|
+
* 2. a mode omits two stages and declares ONE of them, so the document reads
|
|
332
|
+
* as a mode that has accounted for itself while one downgrade is silent.
|
|
333
|
+
*
|
|
334
|
+
* AND A THIRD, WHICH IS WHY THE MISSING REFERENCE IS A VIOLATION AND NOT A
|
|
335
|
+
* QUIET RETURN: deleting the `full` mode from the document disables the
|
|
336
|
+
* comparison for every remaining mode at once, so a document with one
|
|
337
|
+
* `direct-pr` mode, an empty `skips[]` and no `clean-room-review` would pass a
|
|
338
|
+
* check that returned early. That is the same defect one level up, so the
|
|
339
|
+
* absent reference fails closed.
|
|
340
|
+
*
|
|
341
|
+
* SOUNDNESS, THE CONVERSE DIRECTION, ADDED IN ROUND 9 (CR-002). Everything
|
|
342
|
+
* above asks ONE question: is every stage this mode omits DECLARED? It never
|
|
343
|
+
* asked the converse: is every stage this mode DECLARES actually omitted? A
|
|
344
|
+
* set checked in one direction only is a set nothing constrains, and `skips[]`
|
|
345
|
+
* is shipped DATA that any edit can change. The measured consequence was not
|
|
346
|
+
* hypothetical: `full` keeping its complete twelve-stage pipeline and gaining
|
|
347
|
+
* ONE bogus `skips[]` entry validated at exit 0, and `tiphys mode show --mode
|
|
348
|
+
* full` then printed that no phase of the tiphys project had ever been
|
|
349
|
+
* delivered under the mode this project has delivered every phase under
|
|
350
|
+
* (delivery/review/clean-room-m3-p3-r8-criteria.md:217).
|
|
351
|
+
*
|
|
352
|
+
* SOUNDNESS HAS TWO DIRECTIONS AND ROUND 9 SHIPPED ONE (round 10, V-1).
|
|
353
|
+
* `skips[]` is defined by the document itself as every stage in `full`'s
|
|
354
|
+
* pipeline that this mode's pipeline omits AND NOTHING ELSE, so "actually
|
|
355
|
+
* omitted" is measured against the REFERENCE and an entry can fail it two
|
|
356
|
+
* ways: (A) this mode's own pipeline runs the stage, and (B) NOTHING runs it,
|
|
357
|
+
* that is, it is absent from this mode's pipeline and from `full`'s as well.
|
|
358
|
+
* Round 9 implemented the predicate the reviewer wrote down (A) rather than
|
|
359
|
+
* the property the same reviewer described thirteen lines earlier, and then
|
|
360
|
+
* recorded in two shipped documents that the check ran in both directions.
|
|
361
|
+
* B was reachable on the shipped data with a one-line edit, because the stage
|
|
362
|
+
* vocabulary has thirteen ids and `full`'s pipeline has twelve: `direct-pr`
|
|
363
|
+
* gaining `orchestrator-diff-review` validated at exit 0 and `tiphys mode
|
|
364
|
+
* show` then reported a skipped-stage count one too high with a `skips:` row
|
|
365
|
+
* naming a stage that is no downgrade at all.
|
|
366
|
+
*
|
|
367
|
+
* WHICH SIDE OF THE COMPARISON IS EDITED DOES NOT MATTER, and that is why B is
|
|
368
|
+
* not merely "a typo in skips". Shrinking `full`'s PIPELINE, touching no
|
|
369
|
+
* `skips[]` anywhere, turns every other mode's previously correct entry for
|
|
370
|
+
* that stage into a phantom. The reference is one half of the relation and
|
|
371
|
+
* either half moving breaks it.
|
|
372
|
+
*
|
|
373
|
+
* THE DIRECTION-A PREDICATE RUNS OVER EVERY MODE INCLUDING THE REFERENCE, and
|
|
374
|
+
* that is load-bearing rather than a detail. The completeness loop `continue`s
|
|
375
|
+
* past `full` because a mode cannot omit a stage relative to itself; the
|
|
376
|
+
* soundness question is well posed for `full` too, and `full` is precisely the
|
|
377
|
+
* mode the sharpest member targeted. A soundness loop that inherited the
|
|
378
|
+
* completeness loop's skip would have been green against the finding that
|
|
379
|
+
* caused it to be written.
|
|
380
|
+
*
|
|
381
|
+
* DIRECTION A NEEDS NO REFERENCE MODE, so it runs BEFORE the reference is
|
|
382
|
+
* resolved and its violations survive an absent `full`. A document that both
|
|
383
|
+
* deletes `full` and carries a contradictory `skips[]` reports both facts
|
|
384
|
+
* rather than the first one only. DIRECTION B cannot: it is defined by the
|
|
385
|
+
* reference pipeline, so it runs after the resolution and an absent `full` is
|
|
386
|
+
* already a violation in its own right.
|
|
387
|
+
*/
|
|
388
|
+
export const modeNoUndeclaredDowngrade = {
|
|
389
|
+
id: "mode-no-undeclared-downgrade",
|
|
390
|
+
type: "assurance-modes",
|
|
391
|
+
requiresContext: false,
|
|
392
|
+
run(instance) {
|
|
393
|
+
const rows = eachMode(instance);
|
|
394
|
+
if (rows.length === 0) {
|
|
395
|
+
return EMPTY;
|
|
396
|
+
}
|
|
397
|
+
const violations = [];
|
|
398
|
+
for (const row of rows) {
|
|
399
|
+
const running = new Set(stringsAt(row.mode, "pipeline"));
|
|
400
|
+
for (const stage of stringsAt(row.mode, "skips")) {
|
|
401
|
+
if (running.has(stage)) {
|
|
402
|
+
violations.push({
|
|
403
|
+
pointer: `#/modes/${String(row.index)}/skips`,
|
|
404
|
+
message: `mode ${row.id} declares stage ${stage} in skips while its own pipeline runs it, so skips does not describe what this mode omits`,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const reference = rows.find((row) => row.id === REFERENCE_MODE_ID);
|
|
410
|
+
if (reference === undefined) {
|
|
411
|
+
violations.push({
|
|
412
|
+
pointer: "#/modes",
|
|
413
|
+
message: `no mode declares id ${REFERENCE_MODE_ID}, so no mode's omitted stages can be measured against the reference pipeline`,
|
|
414
|
+
});
|
|
415
|
+
return { violations, reports: [] };
|
|
416
|
+
}
|
|
417
|
+
const referenceStages = stringsAt(reference.mode, "pipeline");
|
|
418
|
+
/* SOUNDNESS, DIRECTION B (round 10, V-1 and CRB9-02). "Omitted" is
|
|
419
|
+
measured RELATIVE TO THE REFERENCE, so an entry is unsound either
|
|
420
|
+
because this mode runs it (direction A, above) or because NOTHING runs
|
|
421
|
+
it. This loop is the second case and it needs `referenceStages`, which
|
|
422
|
+
is why it sits after the resolution rather than beside direction A.
|
|
423
|
+
|
|
424
|
+
IT RUNS OVER EVERY ROW INCLUDING THE REFERENCE, and on the reference the
|
|
425
|
+
two directions together say `full.skips` must be EMPTY: an entry is
|
|
426
|
+
either in `full`'s own pipeline (direction A rejects it) or outside it
|
|
427
|
+
(this loop rejects it). That is not a side effect, it is CRB9-02's fix.
|
|
428
|
+
`executionStatus` keys the un-downgraded sentence off `mode.id`, and
|
|
429
|
+
that is honest only while the reference really declares no downgrade;
|
|
430
|
+
before this loop a `full` whose stage had MOVED from `pipeline` into
|
|
431
|
+
`skips` validated at exit 0 and `tiphys mode show --mode full` printed
|
|
432
|
+
"the un-downgraded process" fifteen lines above a `skips: deploy-verify`
|
|
433
|
+
row. A registered test asserted the shipped document was clean, which
|
|
434
|
+
guards THIS repository's document and not the check, so any other
|
|
435
|
+
document carrying a downgraded reference was served that contradiction.
|
|
436
|
+
A property asserted in one place and not enforced where it is consumed
|
|
437
|
+
is the CR-002 mechanism itself, one level up. */
|
|
438
|
+
const referenceRunning = new Set(referenceStages);
|
|
439
|
+
for (const row of rows) {
|
|
440
|
+
for (const stage of stringsAt(row.mode, "skips")) {
|
|
441
|
+
if (!referenceRunning.has(stage)) {
|
|
442
|
+
violations.push({
|
|
443
|
+
pointer: `#/modes/${String(row.index)}/skips`,
|
|
444
|
+
message: `mode ${row.id} declares stage ${stage} in skips, but mode ${REFERENCE_MODE_ID} does not run it, so it is not a downgrade relative to the reference pipeline`,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const row of rows) {
|
|
450
|
+
if (row.index === reference.index) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const own = new Set(stringsAt(row.mode, "pipeline"));
|
|
454
|
+
const declared = new Set(stringsAt(row.mode, "skips"));
|
|
455
|
+
const undeclared = referenceStages.filter((stage) => !own.has(stage) && !declared.has(stage));
|
|
456
|
+
for (const stage of undeclared) {
|
|
457
|
+
violations.push({
|
|
458
|
+
pointer: `#/modes/${String(row.index)}/skips`,
|
|
459
|
+
message: `mode ${row.id} omits stage ${stage}, which mode ${REFERENCE_MODE_ID} runs, and does not declare it in skips`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return { violations, reports: [] };
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
/* ------------------------------------------------------------------ */
|
|
467
|
+
/* mode-stage-order (R-024, M3-P3 criterion 3b) */
|
|
468
|
+
/* ------------------------------------------------------------------ */
|
|
469
|
+
/**
|
|
470
|
+
* R-024: an adversarial plan review happens before anyone builds.
|
|
471
|
+
*
|
|
472
|
+
* THE RELATIVE POSITION OF TWO VALUES IN A VARIABLE-LENGTH ARRAY IS NOT A
|
|
473
|
+
* KEYWORD PROPERTY (M3R-002). `contains` can say both are present and nothing
|
|
474
|
+
* in the vocabulary can say which comes first.
|
|
475
|
+
*
|
|
476
|
+
* The rule has TWO ARMS because there are two ways to build before a review,
|
|
477
|
+
* and the plan states both: reorder them, or delete the review. So a mode
|
|
478
|
+
* whose pipeline contains `implement` and NOT `adversarial-plan-review` must
|
|
479
|
+
* list the review in `skips[]`, which is the same declared-downgrade
|
|
480
|
+
* discipline applied to the one stage R-024 is about.
|
|
481
|
+
*/
|
|
482
|
+
export const modeStageOrder = {
|
|
483
|
+
id: "mode-stage-order",
|
|
484
|
+
type: "assurance-modes",
|
|
485
|
+
requiresContext: false,
|
|
486
|
+
run(instance) {
|
|
487
|
+
const violations = [];
|
|
488
|
+
for (const row of eachMode(instance)) {
|
|
489
|
+
const pipeline = stringsAt(row.mode, "pipeline");
|
|
490
|
+
const review = pipeline.indexOf("adversarial-plan-review");
|
|
491
|
+
const implement = pipeline.indexOf("implement");
|
|
492
|
+
if (implement === -1) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (review === -1) {
|
|
496
|
+
if (!stringsAt(row.mode, "skips").includes("adversarial-plan-review")) {
|
|
497
|
+
violations.push({
|
|
498
|
+
pointer: `#/modes/${String(row.index)}/skips`,
|
|
499
|
+
message: `mode ${row.id} runs implement without adversarial-plan-review and does not declare that stage in skips (R-024)`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (review > implement) {
|
|
505
|
+
violations.push({
|
|
506
|
+
pointer: `#/modes/${String(row.index)}/pipeline`,
|
|
507
|
+
message: `mode ${row.id} places implement at position ${String(implement)} and adversarial-plan-review at position ${String(review)}, so building starts before the review (R-024)`,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return { violations, reports: [] };
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
/* ------------------------------------------------------------------ */
|
|
515
|
+
/* mode-gate-sets-resolve (M3-P3 criterion 3d) */
|
|
516
|
+
/* ------------------------------------------------------------------ */
|
|
517
|
+
/**
|
|
518
|
+
* Every `gate-sets[]` entry RESOLVES against `gate-registry.yaml`.
|
|
519
|
+
*
|
|
520
|
+
* WHAT "RESOLVES" MEANS HERE, stated because a checker whose promise is vague
|
|
521
|
+
* is a checker nobody can falsify: the entry names a gate the registry
|
|
522
|
+
* declares, AND that gate's own `modes` list names this mode. Both halves are
|
|
523
|
+
* needed, because a reference that resolves to a gate which never runs in this
|
|
524
|
+
* mode is a mode whose assurance is a name with no gates behind it, which is
|
|
525
|
+
* the hazard exactly as the plan words it.
|
|
526
|
+
*
|
|
527
|
+
* `requiresContext` is TRUE, so invoking the validator without `--context`
|
|
528
|
+
* prints `SKIPPED mode-gate-sets-resolve no context` and exits nonzero. That
|
|
529
|
+
* is the point of the mechanism (M3-P1 criterion 4c): a cross-document rule
|
|
530
|
+
* must never be able to pass BY NOT RUNNING.
|
|
531
|
+
*/
|
|
532
|
+
export const modeGateSetsResolve = {
|
|
533
|
+
id: "mode-gate-sets-resolve",
|
|
534
|
+
type: "assurance-modes",
|
|
535
|
+
requiresContext: true,
|
|
536
|
+
run(instance, contextDirectory) {
|
|
537
|
+
if (contextDirectory === undefined) {
|
|
538
|
+
/* Unreachable through `runChecks`, which SKIPS first. Kept fail-closed
|
|
539
|
+
rather than trusting a caller that reaches the check directly. */
|
|
540
|
+
return {
|
|
541
|
+
violations: [
|
|
542
|
+
{ pointer: "#/modes", message: "no context directory was supplied" },
|
|
543
|
+
],
|
|
544
|
+
reports: [],
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const registryDocument = readContextDocument(contextDirectory, "gate-registry.yaml");
|
|
548
|
+
if (!registryDocument.ok) {
|
|
549
|
+
return {
|
|
550
|
+
violations: [
|
|
551
|
+
{
|
|
552
|
+
pointer: "#/modes",
|
|
553
|
+
message: `the gate registry could not be read, so no gate set reference could be resolved: ${registryDocument.reason}`,
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
reports: [],
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const declared = new Map();
|
|
560
|
+
for (const gate of asArray(asRecord(registryDocument.value)?.["gates"])) {
|
|
561
|
+
const record = asRecord(gate);
|
|
562
|
+
const id = record?.["id"];
|
|
563
|
+
if (typeof id === "string") {
|
|
564
|
+
declared.set(id, new Set(stringsAt(record, "modes")));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const violations = [];
|
|
568
|
+
for (const row of eachMode(instance)) {
|
|
569
|
+
const references = stringsAt(row.mode, "gate-sets");
|
|
570
|
+
for (let position = 0; position < references.length; position += 1) {
|
|
571
|
+
const reference = references[position];
|
|
572
|
+
const pointer = `#/modes/${String(row.index)}/gate-sets/${String(position)}`;
|
|
573
|
+
const modesOfGate = declared.get(reference);
|
|
574
|
+
if (modesOfGate === undefined) {
|
|
575
|
+
violations.push({
|
|
576
|
+
pointer,
|
|
577
|
+
message: `gate set ${reference} is not declared in ${registryDocument.path}`,
|
|
578
|
+
});
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (!modesOfGate.has(row.id)) {
|
|
582
|
+
violations.push({
|
|
583
|
+
pointer,
|
|
584
|
+
message: `gate set ${reference} is declared in ${registryDocument.path} and its modes list does not name ${row.id}, so it never runs in this mode`,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return { violations, reports: [] };
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
/* ------------------------------------------------------------------ */
|
|
593
|
+
/* charter-mode-enum-matches-modes (M3-P3 step 4, criterion 4) */
|
|
594
|
+
/* ------------------------------------------------------------------ */
|
|
595
|
+
/**
|
|
596
|
+
* The charter schema's mode enums equal the ids declared here.
|
|
597
|
+
*
|
|
598
|
+
* `schemas/charter.schema.json` declares the mode vocabulary a project charter
|
|
599
|
+
* may use, and this document declares what those modes ARE. Two lists, one
|
|
600
|
+
* fact. Without this check they are a duplication that drifts silently the
|
|
601
|
+
* first time a mode is added, which is the same drift hole M3-P2 closed for
|
|
602
|
+
* the gate list.
|
|
603
|
+
*
|
|
604
|
+
* BOTH FIELDS, not one. The charter carries `delivery-mode` AND
|
|
605
|
+
* `assurance-tier`, M3-P1 shipped the identical placeholder enum on both, and
|
|
606
|
+
* step 4 names both ("Add `mode` and `assurance-tier` validation to the
|
|
607
|
+
* charter schema's enum"). A check that watched only one would leave the other
|
|
608
|
+
* free to drift, which is the hazard rather than a smaller version of it.
|
|
609
|
+
*/
|
|
610
|
+
export const charterModeEnumMatchesModes = {
|
|
611
|
+
id: "charter-mode-enum-matches-modes",
|
|
612
|
+
type: "assurance-modes",
|
|
613
|
+
requiresContext: true,
|
|
614
|
+
run(instance, contextDirectory) {
|
|
615
|
+
if (contextDirectory === undefined) {
|
|
616
|
+
return {
|
|
617
|
+
violations: [
|
|
618
|
+
{ pointer: "#/modes", message: "no context directory was supplied" },
|
|
619
|
+
],
|
|
620
|
+
reports: [],
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const charter = readContextDocument(contextDirectory, join("schemas", "charter.schema.json"));
|
|
624
|
+
if (!charter.ok) {
|
|
625
|
+
return {
|
|
626
|
+
violations: [
|
|
627
|
+
{
|
|
628
|
+
pointer: "#/modes",
|
|
629
|
+
message: `the charter schema could not be read, so its mode enum could not be compared with ${MODES_DOCUMENT}: ${charter.reason}`,
|
|
630
|
+
},
|
|
631
|
+
],
|
|
632
|
+
reports: [],
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const declaredIds = eachMode(instance)
|
|
636
|
+
.map((row) => row.id)
|
|
637
|
+
.sort();
|
|
638
|
+
const properties = asRecord(asRecord(charter.value)?.["properties"]);
|
|
639
|
+
const violations = [];
|
|
640
|
+
for (const field of ["delivery-mode", "assurance-tier"]) {
|
|
641
|
+
const definition = asRecord(properties?.[field]);
|
|
642
|
+
if (definition === undefined) {
|
|
643
|
+
violations.push({
|
|
644
|
+
pointer: "#/modes",
|
|
645
|
+
message: `${charter.path} declares no ${field} property, so the mode ids in ${MODES_DOCUMENT} have nothing to agree with`,
|
|
646
|
+
});
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const enumerated = stringsAt(definition, "enum").slice().sort();
|
|
650
|
+
if (!sameStringList(enumerated, declaredIds)) {
|
|
651
|
+
violations.push({
|
|
652
|
+
pointer: "#/modes",
|
|
653
|
+
message: `${MODES_DOCUMENT} declares mode ids [${declaredIds.join(", ")}] and the ${field} enum in ${charter.path} is [${enumerated.join(", ")}]; the two must be equal`,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return { violations, reports: [] };
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
/* ------------------------------------------------------------------ */
|
|
661
|
+
/* IDENTITY UNIQUENESS (M3-P3 fix round 1, findings B-002 and B-004) */
|
|
662
|
+
/* ------------------------------------------------------------------ */
|
|
663
|
+
/**
|
|
664
|
+
* THE MECHANISM, named before the instances: A UNIQUENESS CONSTRAINT ASSERTED
|
|
665
|
+
* BY A PREDICATE THAT DOES NOT TEST IDENTITY.
|
|
666
|
+
*
|
|
667
|
+
* `uniqueItems` is DEEP-OBJECT equality. On an array of records keyed by an id
|
|
668
|
+
* field it says "no two entries are identical", which is not the property
|
|
669
|
+
* anything relies on: two entries may share an `id` and differ anywhere else
|
|
670
|
+
* and the array is `uniqueItems`-clean. Every consumer that looks an entry up
|
|
671
|
+
* BY ID then silently takes one of them, and which one depends on document
|
|
672
|
+
* order.
|
|
673
|
+
*
|
|
674
|
+
* Measured on `assurance-modes.yaml` with a crippled duplicate placed FIRST:
|
|
675
|
+
* `tiphys mode show --mode full` printed eleven stages with
|
|
676
|
+
* `clean-room-review` absent and `skips` empty, exit 0. That is the invisible
|
|
677
|
+
* downgrade this whole phase exists to prevent, on the path a brief uses.
|
|
678
|
+
*
|
|
679
|
+
* IT IS A RECURRENCE. M3-P1's B-003 was the same predicate on
|
|
680
|
+
* `acceptance[].id`, fixed at the instance (that check counts occurrences).
|
|
681
|
+
* The class was not swept, so it came back one phase later in a different
|
|
682
|
+
* document. The sweep is published in delivery/work-history/m3-p3.md.
|
|
683
|
+
*
|
|
684
|
+
* `charter-mode-enum-matches-modes` DOES currently reject a duplicate id, and
|
|
685
|
+
* that is not a defence: it compares the declared id LIST against the charter
|
|
686
|
+
* enum, so it is multiplicity-sensitive BY ACCIDENT. The accident disappears
|
|
687
|
+
* the moment that comparison is rewritten to compare sets, and
|
|
688
|
+
* `role-model-config.yaml` never had it at all.
|
|
689
|
+
*/
|
|
690
|
+
function makeIdUniquenessCheck(id, type, arrayField, idField, noun) {
|
|
691
|
+
return {
|
|
692
|
+
id,
|
|
693
|
+
type,
|
|
694
|
+
requiresContext: false,
|
|
695
|
+
run(instance) {
|
|
696
|
+
const document = asRecord(instance);
|
|
697
|
+
const entries = asArray(document?.[arrayField]);
|
|
698
|
+
const seen = new Map();
|
|
699
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
700
|
+
const value = asRecord(entries[index])?.[idField];
|
|
701
|
+
if (typeof value !== "string") {
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const at = seen.get(value);
|
|
705
|
+
if (at === undefined) {
|
|
706
|
+
seen.set(value, [index]);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
at.push(index);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const violations = [];
|
|
713
|
+
for (const [value, indexes] of [...seen.entries()].sort()) {
|
|
714
|
+
if (indexes.length < 2) {
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
/* Reported at the SECOND occurrence and later, so the pointer names an
|
|
718
|
+
entry a reader can delete, and the message names every index so the
|
|
719
|
+
first one is findable too. */
|
|
720
|
+
for (const index of indexes.slice(1)) {
|
|
721
|
+
violations.push({
|
|
722
|
+
pointer: `#/${arrayField}/${String(index)}/${idField}`,
|
|
723
|
+
message: `${noun} ${value} is declared ${String(indexes.length)} times, at ${arrayField} ${indexes.map(String).join(", ")}; an id selects one entry and these select ${String(indexes.length)}`,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return { violations, reports: [] };
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
/** `modes[].id` selects exactly one mode. */
|
|
732
|
+
export const modeIdsAreUnique = makeIdUniquenessCheck("mode-ids-are-unique", "assurance-modes", "modes", "id", "mode id");
|
|
733
|
+
/**
|
|
734
|
+
* `roles[].role` selects exactly one binding. B-004: the SAME defect, in the
|
|
735
|
+
* document nothing consumes yet, which is why it was latent rather than
|
|
736
|
+
* demonstrable. It is fixed in the same act because the mechanism is one thing.
|
|
737
|
+
*/
|
|
738
|
+
export const roleIdsAreUnique = makeIdUniquenessCheck("role-ids-are-unique", "role-model-config", "roles", "role", "role id");
|
|
739
|
+
/* ------------------------------------------------------------------ */
|
|
740
|
+
/* mode-conditions-quote-granted-by (fix round 1, finding B-003) */
|
|
741
|
+
/* ------------------------------------------------------------------ */
|
|
742
|
+
/**
|
|
743
|
+
* THE MECHANISM: A CONSTRAINT VERIFIED BY CARDINALITY INSTEAD OF CONTENT.
|
|
744
|
+
*
|
|
745
|
+
* `merge-authority: delegated-under-conditions` requires `conditions[]` and a
|
|
746
|
+
* `granted-by` decision-record reference. Until this round, the only thing
|
|
747
|
+
* anyone compared was HOW MANY conditions there were: the schema required a
|
|
748
|
+
* non-empty array of non-empty strings, `granted-by` had to match a pattern,
|
|
749
|
+
* and one registered test asserted `length === 6`. So all six sentences could
|
|
750
|
+
* be replaced with fabrications, keeping the count, and the schema, every
|
|
751
|
+
* derived check and the test all stayed green. The document that says who may
|
|
752
|
+
* merge could be rewritten to say something else.
|
|
753
|
+
*
|
|
754
|
+
* THIS CHECK BINDS THE CONDITIONS TO THEIR SOURCE. `granted-by` already names
|
|
755
|
+
* the record, so the record is resolved and every condition must OCCUR in it,
|
|
756
|
+
* compared on whitespace-normalized text because YAML folded scalars re-wrap
|
|
757
|
+
* lines and markdown wraps them differently again. A condition that is not in
|
|
758
|
+
* the record it cites is a violation naming the index and quoting the opening
|
|
759
|
+
* of the offending text.
|
|
760
|
+
*
|
|
761
|
+
* WHAT THIS DOES NOT DO, stated here and not only in the work history: it is
|
|
762
|
+
* the NO-FABRICATION direction only. It cannot see an OMISSION, because
|
|
763
|
+
* "which paragraphs of a prose decision record are its conditions" is not
|
|
764
|
+
* derivable without assuming that record's internal structure, and a kernel
|
|
765
|
+
* check that hard-coded one project's heading text would be a check that
|
|
766
|
+
* reddens on formatting. The omission direction is covered one layer up, by a
|
|
767
|
+
* registered test that parses THIS repository's DR-0012 and requires every
|
|
768
|
+
* condition it declares to be present; that test may know the record's shape
|
|
769
|
+
* because it ships with the record.
|
|
770
|
+
*
|
|
771
|
+
* FAIL CLOSED at every step: no decisions directory, no matching record, or
|
|
772
|
+
* more than one matching record are all violations, never a quiet pass.
|
|
773
|
+
*/
|
|
774
|
+
const DECISION_DIRECTORIES = [join("delivery", "decisions"), "decisions"];
|
|
775
|
+
function normalizeProse(text) {
|
|
776
|
+
return text.replace(/\s+/g, " ").trim();
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* `commonmark` IS LOADED LAZILY, FOR THE REASON `src/validate.ts` STATES AT
|
|
780
|
+
* LENGTH FOR `ajv` AND `yaml`, and it is not a style choice here either.
|
|
781
|
+
*
|
|
782
|
+
* `copyInstallation` in `test/scope-gate.test.ts` copies `src/` to a scratch
|
|
783
|
+
* location outside the repository and runs it there, where no `node_modules`
|
|
784
|
+
* sits above the copy. A top-level `import ... from "commonmark"` in this
|
|
785
|
+
* module makes that test fail with `ERR_MODULE_NOT_FOUND` at module load,
|
|
786
|
+
* before the condition it exists to exercise can happen. `createRequire` defers
|
|
787
|
+
* the resolution to the first record actually parsed.
|
|
788
|
+
*/
|
|
789
|
+
const requireDependency = createRequire(import.meta.url);
|
|
790
|
+
function commonMarkModule() {
|
|
791
|
+
return requireDependency("commonmark");
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* The block types whose own text belongs to NO quotable unit: DECLARED INTENT,
|
|
795
|
+
* NOT THE MECHANISM THAT PERFORMS THE EXCLUSION. Read the next paragraph before
|
|
796
|
+
* relying on this set for anything.
|
|
797
|
+
*
|
|
798
|
+
* Headings (ATX and setext are one node type here, which is the point), code
|
|
799
|
+
* blocks (fenced and indented, likewise), HTML blocks and thematic breaks.
|
|
800
|
+
* A link reference definition produces no node at all, so it needs no entry:
|
|
801
|
+
* the parser removes it before this walk ever sees the document.
|
|
802
|
+
*
|
|
803
|
+
* WHAT THIS SET ACTUALLY DOES TODAY, corrected after a clean-room review found
|
|
804
|
+
* the docstring claiming more than the code performs (CR-003, round 7). Under
|
|
805
|
+
* `commonmark` 0.31.2 EMPTYING THIS SET CHANGES NO ANSWER, and the reason is
|
|
806
|
+
* structural rather than "no test covers it": all four types are LEAF blocks in
|
|
807
|
+
* that parser's AST. `code_block`, `html_block` and `thematic_break` have no
|
|
808
|
+
* children at all, and a `heading`'s children are INLINE nodes, never
|
|
809
|
+
* `paragraph`. Both walkers below emit a unit only for a `paragraph` child, so
|
|
810
|
+
* descending into any of these four reaches nothing that can produce a unit.
|
|
811
|
+
* Measured, `commonmark` 0.31.2, node v26.6.0:
|
|
812
|
+
*
|
|
813
|
+
* heading children: ["text","code","text","strong"]
|
|
814
|
+
* code_block children: []
|
|
815
|
+
* html_block children: []
|
|
816
|
+
* thematic_break children: []
|
|
817
|
+
*
|
|
818
|
+
* The true sentence is therefore: these types cannot contribute a unit under
|
|
819
|
+
* `commonmark` 0.31.2 whether or not they appear here; THE SET EXISTS SO THAT A
|
|
820
|
+
* PARSER CHANGE CANNOT MAKE THEM CONTRIBUTE ONE. Keeping it is what makes the
|
|
821
|
+
* exclusion intentional rather than incidental to one parser version, and a
|
|
822
|
+
* release that gave `html_block` block children, or a Markdown extension in a
|
|
823
|
+
* consuming project, is exactly the event it is here for.
|
|
824
|
+
*
|
|
825
|
+
* The registered tests named "code block content ... is not a quotable unit"
|
|
826
|
+
* and "heading text ... is not a quotable unit" therefore guard the shared
|
|
827
|
+
* `paragraph`-versus-`else` branches, not this set; their witness specs mutate
|
|
828
|
+
* those branches for that reason.
|
|
829
|
+
*/
|
|
830
|
+
const NOT_QUOTABLE = new Set(["code_block", "heading", "html_block", "thematic_break"]);
|
|
831
|
+
/**
|
|
832
|
+
* Whether a paragraph node still carries prose, asked STRUCTURALLY.
|
|
833
|
+
*
|
|
834
|
+
* A paragraph with NO inline children is a paragraph the parser emptied, and it
|
|
835
|
+
* is not a curiosity: `commonmark` 0.31.2 leaves exactly one behind, WITH ITS
|
|
836
|
+
* ORIGINAL `sourcepos` STILL SPANNING THE TEXT IT REMOVED. The shape is a link
|
|
837
|
+
* reference definition immediately followed by a setext underline of `-`:
|
|
838
|
+
*
|
|
839
|
+
* "[zeta]: https://example.invalid/delta\n---\n"
|
|
840
|
+
* renders <p></p><hr />, and the AST is
|
|
841
|
+
* paragraph [[1,1],[1,37]] firstChild=null
|
|
842
|
+
* thematic_break [[2,1],[2,3]]
|
|
843
|
+
*
|
|
844
|
+
* The setext-heading start rule strips leading reference definitions from the
|
|
845
|
+
* paragraph and then DECLINES to make a heading because nothing is left, so the
|
|
846
|
+
* document's own reference sweep never sees them (they are already gone) and
|
|
847
|
+
* never advances the start line the way it does in every other case. Slicing
|
|
848
|
+
* that paragraph's source yields the reference definition as a quotable unit,
|
|
849
|
+
* which is the fail-open direction.
|
|
850
|
+
*
|
|
851
|
+
* FOUND BY THE DIFFERENTIAL FUZZ, NOT BY READING: 13 divergences in 4,973
|
|
852
|
+
* adjudicated documents at seed 20260809, every one this shape. Both oracles
|
|
853
|
+
* agreed the correct answer is no unit at all. Recorded in
|
|
854
|
+
* `delivery/work-history/m3-p3.md` with the captures.
|
|
855
|
+
*
|
|
856
|
+
* This is a structure question and is answered with a structure test. Reading
|
|
857
|
+
* the inline text to decide would settle the same case and would be the first
|
|
858
|
+
* step back towards option A, which is the thing DR-0022 rules out.
|
|
859
|
+
*/
|
|
860
|
+
function carriesProse(paragraph) {
|
|
861
|
+
return paragraph.firstChild !== null;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* The RAW SOURCE spanned by a node, as written, markup and all.
|
|
865
|
+
*
|
|
866
|
+
* `quoteDepth` is how many block quotes enclose the node. `sourcepos` gives the
|
|
867
|
+
* FIRST line a column past the `>` markers and says nothing about the node's
|
|
868
|
+
* CONTINUATION lines, which still carry theirs, so each continuation has up to
|
|
869
|
+
* that many markers stripped. Without it a two-line quoted paragraph comes back
|
|
870
|
+
* carrying a `>` in the middle of the unit. Measured on `commonmark` 0.31.2:
|
|
871
|
+
*
|
|
872
|
+
* "> 1. an item in a quote\n> continued here\n"
|
|
873
|
+
* paragraph [[1,6],[2,19]], sliced naively: "an item in a quote > continued here"
|
|
874
|
+
*
|
|
875
|
+
* A LAZY continuation line carries no marker at all, so the strip is written to
|
|
876
|
+
* be a no-op when the marker is absent rather than to assume it is present.
|
|
877
|
+
*/
|
|
878
|
+
const SPACE = 0x20;
|
|
879
|
+
const TAB = 0x09;
|
|
880
|
+
const GREATER_THAN = 0x3e;
|
|
881
|
+
const HYPHEN = 0x2d;
|
|
882
|
+
const ASTERISK = 0x2a;
|
|
883
|
+
const PLUS = 0x2b;
|
|
884
|
+
const PERIOD = 0x2e;
|
|
885
|
+
const RIGHT_PAREN = 0x29;
|
|
886
|
+
const DIGIT_ZERO = 0x30;
|
|
887
|
+
const DIGIT_NINE = 0x39;
|
|
888
|
+
/** A space or a tab, the only two characters CommonMark counts as indentation
|
|
889
|
+
* inside a container prefix. `charCodeAt` past the end is NaN, which compares
|
|
890
|
+
* false against both, so no caller needs a separate bounds test. */
|
|
891
|
+
function isIndent(code) {
|
|
892
|
+
return code === SPACE || code === TAB;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* ONE BLOCK-QUOTE MARKER at `from`, with the indentation in front of it, as a
|
|
896
|
+
* LENGTH: how many characters it occupies, or 0 when there is no marker there.
|
|
897
|
+
* Declared once because THREE places consume exactly this (the prefix scan
|
|
898
|
+
* below and BOTH recovery strips) and a second copy of a grammar is how the
|
|
899
|
+
* three models described under `isSkippablePrefix` came to disagree in the
|
|
900
|
+
* first place. It is deliberately NARROWER than the prefix scan: see
|
|
901
|
+
* `startOffset`.
|
|
902
|
+
*
|
|
903
|
+
* NOTE THE ZERO CASE. Indentation with no `>` after it is NOT a quote marker
|
|
904
|
+
* and returns 0, not the indentation's length, which is what the regex this
|
|
905
|
+
* replaced did (it matched as a whole or not at all).
|
|
906
|
+
*
|
|
907
|
+
* ROUND 8 MADE THIS A SCAN RATHER THAN A SHARED REGEX OBJECT, and that is
|
|
908
|
+
* verification finding V-6 rather than a style preference. Round 7 shared one
|
|
909
|
+
* regex OBJECT between an `.exec` and a `.replace`. That was correct, but only
|
|
910
|
+
* because the literal carried no `g` flag: `lastIndex` lives on the OBJECT, so
|
|
911
|
+
* adding `g` would have made the `.exec` in `startOffset` stateful across
|
|
912
|
+
* calls and silently stopped the second iteration of its loop. A function has
|
|
913
|
+
* no `lastIndex`, so that hazard cannot be written here at all. Removing a
|
|
914
|
+
* class beats guarding an instance of it, and here it also costs nothing,
|
|
915
|
+
* because the V-1 fix below needs to consume this same grammar and would
|
|
916
|
+
* otherwise have introduced a FOURTH copy of it.
|
|
917
|
+
*/
|
|
918
|
+
function quoteMarkerLength(text, from) {
|
|
919
|
+
let at = from;
|
|
920
|
+
while (isIndent(text.charCodeAt(at))) {
|
|
921
|
+
at += 1;
|
|
922
|
+
}
|
|
923
|
+
if (text.charCodeAt(at) !== GREATER_THAN) {
|
|
924
|
+
return 0;
|
|
925
|
+
}
|
|
926
|
+
at += 1;
|
|
927
|
+
if (isIndent(text.charCodeAt(at))) {
|
|
928
|
+
at += 1;
|
|
929
|
+
}
|
|
930
|
+
return at - from;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* ONE LIST MARKER at `from`, bullet or ordered, with the indentation in front
|
|
934
|
+
* of it and the indentation after it, as a LENGTH, or 0 when there is none.
|
|
935
|
+
*
|
|
936
|
+
* The ordered form is MAX MUNCH capped at nine digits, which is CommonMark's
|
|
937
|
+
* own limit and is exactly what `[0-9]{1,9}[.)]` accepted. A run of ten or
|
|
938
|
+
* more digits therefore matches NOTHING rather than matching its first nine:
|
|
939
|
+
* every shorter prefix of the run is followed by another digit, so no shorter
|
|
940
|
+
* reading can find the `.` or `)` either. That equivalence is not asserted
|
|
941
|
+
* here, it is measured by exhaustive enumeration (see the work history).
|
|
942
|
+
*/
|
|
943
|
+
function listMarkerLength(text, from) {
|
|
944
|
+
let at = from;
|
|
945
|
+
while (isIndent(text.charCodeAt(at))) {
|
|
946
|
+
at += 1;
|
|
947
|
+
}
|
|
948
|
+
const opener = text.charCodeAt(at);
|
|
949
|
+
if (opener === HYPHEN || opener === ASTERISK || opener === PLUS) {
|
|
950
|
+
at += 1;
|
|
951
|
+
}
|
|
952
|
+
else {
|
|
953
|
+
let digits = 0;
|
|
954
|
+
while (digits < 9) {
|
|
955
|
+
const code = text.charCodeAt(at + digits);
|
|
956
|
+
if (code < DIGIT_ZERO || code > DIGIT_NINE) {
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
digits += 1;
|
|
960
|
+
}
|
|
961
|
+
if (digits === 0) {
|
|
962
|
+
return 0;
|
|
963
|
+
}
|
|
964
|
+
const delimiter = text.charCodeAt(at + digits);
|
|
965
|
+
if (delimiter !== PERIOD && delimiter !== RIGHT_PAREN) {
|
|
966
|
+
return 0;
|
|
967
|
+
}
|
|
968
|
+
at += digits + 1;
|
|
969
|
+
}
|
|
970
|
+
while (isIndent(text.charCodeAt(at))) {
|
|
971
|
+
at += 1;
|
|
972
|
+
}
|
|
973
|
+
return at - from;
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Is `span` ENTIRELY skippable before a node's content: ANY NUMBER of
|
|
977
|
+
* block-opening markers (quote, bullet or ordered), in ANY ORDER, plus
|
|
978
|
+
* indentation, and NOTHING ELSE. A test of the WHOLE span and not a prefix
|
|
979
|
+
* match, which is what the two anchors of the regex this replaced provided.
|
|
980
|
+
*
|
|
981
|
+
* THE WIDENING TO THE FULL CONTAINER GRAMMAR IS ROUND 7's CR-001 FIX, and the
|
|
982
|
+
* mechanism it closes is not "the regex was incomplete". The module carried
|
|
983
|
+
* THREE models of one grammar and they disagreed: this predicate allowed quote
|
|
984
|
+
* markers plus AT MOST ONE list marker (its own previous comment said so in
|
|
985
|
+
* those words), while the two recovery strips allow a quote marker only.
|
|
986
|
+
* CommonMark lets a container prefix open ANY NUMBER of blocks on one line, in
|
|
987
|
+
* any order (`- - x`, `- 1. x`, `1. - x`, `- > x`, `- - - x`), so a CORRECT
|
|
988
|
+
* column whose prefix this predicate could not spell was sent down the recovery
|
|
989
|
+
* path, which strips no list marker at all and returned offset 0: the raw
|
|
990
|
+
* markers became part of the unit. Fail-open (a fabricated condition equal to
|
|
991
|
+
* `- - x` is accepted) and fail-closed (the real unit `x` is rejected) at the
|
|
992
|
+
* same time.
|
|
993
|
+
*
|
|
994
|
+
* TESTING THE WHOLE SPAN IS WHAT MAKES WIDENING SAFE, and this is the argument
|
|
995
|
+
* the fix rests on rather than a table of examples. Acceptance means EVERY
|
|
996
|
+
* character of the span is marker-or-indentation, so the span can contain no
|
|
997
|
+
* content, and skipping it is right whichever line the column came from. What
|
|
998
|
+
* markers may repeat does not touch that. The four column-is-lying spans this
|
|
999
|
+
* guard exists to reject ("ep", "re", "alp", "sil") are still rejected, because
|
|
1000
|
+
* a prose fragment contains characters no branch here can consume.
|
|
1001
|
+
*
|
|
1002
|
+
* REPETITION IS UNBOUNDED ON PURPOSE. A model allowing two markers would move
|
|
1003
|
+
* the boundary to three and leave the same defect standing there, which is the
|
|
1004
|
+
* shape this project keeps paying for. A model allowing THREE is not
|
|
1005
|
+
* hypothetical: round 7 shipped a witness whose deepest fixture member was
|
|
1006
|
+
* three, so a `{0,3}` bound restored CR-001 verbatim at depth four with the
|
|
1007
|
+
* whole suite green (verification finding V-2). The fixture now carries a
|
|
1008
|
+
* five-marker member for that reason.
|
|
1009
|
+
*
|
|
1010
|
+
* ROUND 8 MADE THIS A SCAN RATHER THAN AN ANCHORED REGEX, and that is
|
|
1011
|
+
* verification finding V-1, a HIGH. The pattern round 7 shipped was
|
|
1012
|
+
*
|
|
1013
|
+
* /^(?:[ \t]*(?:>[ \t]?|(?:[0-9]{1,9}[.)]|[-*+])[ \t]*))*[ \t]*$/
|
|
1014
|
+
*
|
|
1015
|
+
* and it BACKTRACKS EXPONENTIALLY. The leading `[ \t]*` of an iteration and the
|
|
1016
|
+
* trailing `[ \t]*` inside two of its three branches can consume the same run
|
|
1017
|
+
* of whitespace, so every gap between two markers is an ambiguity the engine
|
|
1018
|
+
* must try both ways, and the choices MULTIPLY. Acceptance is still fast, but
|
|
1019
|
+
* on a subject that ultimately FAILS the engine must exhaust the whole product
|
|
1020
|
+
* before it can say so, and FAILING is precisely the arm `startOffset` exists
|
|
1021
|
+
* to take. Measured at `986f58a`, node v26.6.0: a 119-byte two-line document
|
|
1022
|
+
* cost 45 ms through `quotableUnits` and each further marker DOUBLED it, so a
|
|
1023
|
+
* 269-byte record cost 73 seconds and the same document through the shipped
|
|
1024
|
+
* CLI cost 88. A gate that never returns is worse than a red gate.
|
|
1025
|
+
*
|
|
1026
|
+
* A SCAN CANNOT BACKTRACK, which is why this is a scan and not a cleverer
|
|
1027
|
+
* pattern. Each iteration consumes at least one character and never revisits
|
|
1028
|
+
* one, so the cost is linear in the span and the same for acceptance and
|
|
1029
|
+
* rejection. That removes the CLASS (no ambiguity can be reintroduced by a
|
|
1030
|
+
* later widening of the grammar) rather than the one instance of it that a
|
|
1031
|
+
* disambiguated pattern would remove. The language is UNCHANGED, which is
|
|
1032
|
+
* measured by exhaustive differential enumeration against the round-7 pattern
|
|
1033
|
+
* rather than argued: see `delivery/work-history/m3-p3.md`, fix round 8.
|
|
1034
|
+
*/
|
|
1035
|
+
function isSkippablePrefix(span) {
|
|
1036
|
+
let at = 0;
|
|
1037
|
+
for (;;) {
|
|
1038
|
+
const quote = quoteMarkerLength(span, at);
|
|
1039
|
+
if (quote > 0) {
|
|
1040
|
+
at += quote;
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
const list = listMarkerLength(span, at);
|
|
1044
|
+
if (list > 0) {
|
|
1045
|
+
at += list;
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
while (isIndent(span.charCodeAt(at))) {
|
|
1049
|
+
at += 1;
|
|
1050
|
+
}
|
|
1051
|
+
return at === span.length;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Where a node's content starts on its FIRST line, WITH THE START COLUMN
|
|
1056
|
+
* VERIFIED RATHER THAN TRUSTED.
|
|
1057
|
+
*
|
|
1058
|
+
* `sourcepos[0][0]` is advanced past leading link reference definitions but
|
|
1059
|
+
* `sourcepos[0][1]` IS NOT, so after that advance the column describes a line
|
|
1060
|
+
* the node no longer starts on, and the two lines need not share a prefix. The
|
|
1061
|
+
* measured shape is a reference definition inside a block quote followed by a
|
|
1062
|
+
* LAZY continuation:
|
|
1063
|
+
*
|
|
1064
|
+
* "> [eta]: https://example.invalid/theta\nepsilon eta.\n"
|
|
1065
|
+
* paragraph [[2,3],[2,12]]; line 2 is "epsilon eta.", 12 characters long.
|
|
1066
|
+
*
|
|
1067
|
+
* Column 3 came from `"> "` on line 1. Line 2 has no marker, so slicing from
|
|
1068
|
+
* index 2 yields "silon eta." and the unit is CORRUPT, not merely wrong: it is
|
|
1069
|
+
* a truncated string that no condition can ever equal, and the same defect one
|
|
1070
|
+
* character further along would silently make a fragment quotable.
|
|
1071
|
+
*
|
|
1072
|
+
* FOUND BY THE DIFFERENTIAL FUZZ, and only after the empty-paragraph defect
|
|
1073
|
+
* above was fixed, which is why one fuzz run is not a clearance. The list form
|
|
1074
|
+
* ("- [a]: ...\nreal text here\n", paragraph [[2,3],[2,14]]) is a second,
|
|
1075
|
+
* structurally different member: a list marker rather than a quote marker.
|
|
1076
|
+
*
|
|
1077
|
+
* The test is the invariant, not the symptom: whatever the column skips on the
|
|
1078
|
+
* start line must BE a block prefix. When it is not, the column is describing
|
|
1079
|
+
* some other line and this line's own quote markers are stripped instead,
|
|
1080
|
+
* exactly as a continuation line's are.
|
|
1081
|
+
*
|
|
1082
|
+
* WHY THE FALLBACK IS DELIBERATELY NARROWER THAN THE VERIFIER, corrected in
|
|
1083
|
+
* round 7 (CR-001). Before that round the guard had TWO causes it could not
|
|
1084
|
+
* tell apart: (1) the column is lying, which is the hazard above, and (2) the
|
|
1085
|
+
* column is CORRECT and merely describes a prefix richer than the verifier
|
|
1086
|
+
* could spell. It took this fallback on both, and on a line opening with a LIST
|
|
1087
|
+
* marker `quoteDepth` is 0, so the fallback returned 0 and the slice was the
|
|
1088
|
+
* ENTIRE RAW LINE. Widening the verifier (`isSkippablePrefix`) to the full
|
|
1089
|
+
* container grammar removes cause (2) from the conflation, which is what made
|
|
1090
|
+
* the fallback dangerous; it is now reached only for cause (1).
|
|
1091
|
+
*
|
|
1092
|
+
* The fallback still consumes QUOTE MARKERS ONLY, bounded by `quoteDepth`, and
|
|
1093
|
+
* that is a choice rather than an oversight. `quoteDepth` is KNOWN STRUCTURE
|
|
1094
|
+
* (the walk counted the enclosing block quotes), so the strip cannot eat prose;
|
|
1095
|
+
* an unbounded grammar-shaped strip here would have no such bound. A cause-(1)
|
|
1096
|
+
* line is a paragraph CONTINUATION line, and a continuation line cannot carry a
|
|
1097
|
+
* list marker without ending the paragraph it continues, so there should be
|
|
1098
|
+
* nothing else on it to strip.
|
|
1099
|
+
*
|
|
1100
|
+
* MEASURED rather than asserted, round 7, `commonmark` 0.31.2, node v26.6.0: an
|
|
1101
|
+
* instrumented copy over a 6,000-document differential fuzz (seed 20260809)
|
|
1102
|
+
* entered this fallback 1,463 times, and in ZERO of them did the line carry a
|
|
1103
|
+
* leading block marker. I did not find a way to force this arm with a
|
|
1104
|
+
* marker-carrying line; that is a statement about my search and not a proof
|
|
1105
|
+
* that none exists, and the derivation is in
|
|
1106
|
+
* `delivery/work-history/m3-p3.md`. Because no probe I could build reddens a
|
|
1107
|
+
* wider strip here, widening it would be code no witness could guard, which is
|
|
1108
|
+
* exactly what CR-002 was raised about.
|
|
1109
|
+
*/
|
|
1110
|
+
function startOffset(text, startColumn, quoteDepth) {
|
|
1111
|
+
const offset = startColumn - 1;
|
|
1112
|
+
if (offset <= text.length && isSkippablePrefix(text.slice(0, offset))) {
|
|
1113
|
+
return offset;
|
|
1114
|
+
}
|
|
1115
|
+
let consumed = 0;
|
|
1116
|
+
for (let level = 0; level < quoteDepth; level += 1) {
|
|
1117
|
+
const marker = quoteMarkerLength(text, consumed);
|
|
1118
|
+
if (marker === 0) {
|
|
1119
|
+
break;
|
|
1120
|
+
}
|
|
1121
|
+
consumed += marker;
|
|
1122
|
+
}
|
|
1123
|
+
return consumed;
|
|
1124
|
+
}
|
|
1125
|
+
function sourceSlice(lines, position, quoteDepth) {
|
|
1126
|
+
const [[startLine, startColumn], [endLine, endColumn]] = position;
|
|
1127
|
+
const pieces = [];
|
|
1128
|
+
for (let line = startLine; line <= endLine; line += 1) {
|
|
1129
|
+
const text = lines[line - 1] ?? "";
|
|
1130
|
+
const from = line === startLine ? startOffset(text, startColumn, quoteDepth) : 0;
|
|
1131
|
+
const to = line === endLine ? endColumn : text.length;
|
|
1132
|
+
let piece = text.slice(from, to);
|
|
1133
|
+
if (line !== startLine) {
|
|
1134
|
+
for (let level = 0; level < quoteDepth; level += 1) {
|
|
1135
|
+
piece = piece.slice(quoteMarkerLength(piece, 0));
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
pieces.push(piece);
|
|
1139
|
+
}
|
|
1140
|
+
return pieces.join(" ");
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Every paragraph beneath `container`, in document order, joined into one
|
|
1144
|
+
* string. This is what makes a LIST ITEM'S UNIT THE WHOLE ITEM: its
|
|
1145
|
+
* continuation paragraphs and its nested sub-items are descendants, so they
|
|
1146
|
+
* join the item rather than standing alone, and its headings, fences, indented
|
|
1147
|
+
* code and rules contribute nothing while ending nothing. That last part is
|
|
1148
|
+
* what makes an interrupter inside an item not split the item; the walk simply
|
|
1149
|
+
* never emits for a non-`paragraph` child. `NOT_QUOTABLE` states the intent and
|
|
1150
|
+
* would stop a future parser handing those types block children, but under
|
|
1151
|
+
* `commonmark` 0.31.2 it is not what performs the exclusion (CR-003, round 7).
|
|
1152
|
+
*
|
|
1153
|
+
* Nested lists are deliberately NOT in `NOT_QUOTABLE`: the walk descends into
|
|
1154
|
+
* them, which is what glues a sub-item into the item that encloses it.
|
|
1155
|
+
*/
|
|
1156
|
+
function paragraphsBeneath(container, lines, quoteDepth) {
|
|
1157
|
+
const parts = [];
|
|
1158
|
+
const visit = (node, depth) => {
|
|
1159
|
+
for (let child = node.firstChild; child !== null; child = child.next) {
|
|
1160
|
+
if (child.type === "paragraph") {
|
|
1161
|
+
if (carriesProse(child)) {
|
|
1162
|
+
parts.push(sourceSlice(lines, child.sourcepos, depth));
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
else if (!NOT_QUOTABLE.has(child.type)) {
|
|
1166
|
+
visit(child, child.type === "block_quote" ? depth + 1 : depth);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
visit(container, quoteDepth);
|
|
1171
|
+
return normalizeProse(parts.join(" "));
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Walk one container's CHILDREN and add the units they carry.
|
|
1175
|
+
*
|
|
1176
|
+
* A paragraph is a unit. A list contributes one unit per OUTERMOST item. A
|
|
1177
|
+
* block quote's contents are treated exactly like the document's, which is a
|
|
1178
|
+
* DECLARED POLICY CHOICE and not a derivation: "nothing inside a block quote is
|
|
1179
|
+
* quotable" is equally defensible, and both are defensible where the behaviour
|
|
1180
|
+
* this replaces was neither, because it admitted the marker-carrying string
|
|
1181
|
+
* `> A quoted sentence` while rejecting the same sentence without its marker.
|
|
1182
|
+
* Flipping the policy is this one branch.
|
|
1183
|
+
*/
|
|
1184
|
+
function collectUnits(node, lines, units, quoteDepth) {
|
|
1185
|
+
for (let child = node.firstChild; child !== null; child = child.next) {
|
|
1186
|
+
if (child.type === "paragraph") {
|
|
1187
|
+
const unit = carriesProse(child)
|
|
1188
|
+
? normalizeProse(sourceSlice(lines, child.sourcepos, quoteDepth))
|
|
1189
|
+
: "";
|
|
1190
|
+
if (unit !== "") {
|
|
1191
|
+
units.add(unit);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
else if (child.type === "list") {
|
|
1195
|
+
for (let item = child.firstChild; item !== null; item = item.next) {
|
|
1196
|
+
const unit = paragraphsBeneath(item, lines, quoteDepth);
|
|
1197
|
+
if (unit !== "") {
|
|
1198
|
+
units.add(unit);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
else if (child.type === "block_quote") {
|
|
1203
|
+
collectUnits(child, lines, units, quoteDepth + 1);
|
|
1204
|
+
}
|
|
1205
|
+
else if (!NOT_QUOTABLE.has(child.type)) {
|
|
1206
|
+
collectUnits(child, lines, units, quoteDepth);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* The QUOTABLE UNITS of a prose record: every top-level PARAGRAPH and every
|
|
1212
|
+
* OUTERMOST LIST ITEM, each with its marker stripped and its whitespace
|
|
1213
|
+
* normalized.
|
|
1214
|
+
*
|
|
1215
|
+
* WHY THIS EXISTS, and it is the whole of fix round 2. The first version of
|
|
1216
|
+
* this check asked whether each condition OCCURRED ANYWHERE in the record, as
|
|
1217
|
+
* one normalized blob. That is a CONTAINMENT predicate standing in for an
|
|
1218
|
+
* EQUALITY predicate, and containment is trivially satisfiable by short
|
|
1219
|
+
* strings: `conditions: ["a", "the", "review", "merge", "is", "of"]` replaced
|
|
1220
|
+
* every one of DR-0012's six merge-authority conditions with junk and the
|
|
1221
|
+
* check exited 0. Every one of those words occurs in the record.
|
|
1222
|
+
*
|
|
1223
|
+
* The signal was already in this phase's own evidence and was read past: an
|
|
1224
|
+
* earlier probe fabricated `"one"` through `"six"` and got findings for
|
|
1225
|
+
* indices 3, 4 and 5 ONLY, because "one", "two" and "three" occur inside the
|
|
1226
|
+
* record's prose. Three of six caught looked like the check working.
|
|
1227
|
+
*
|
|
1228
|
+
* Comparing against UNITS rather than against the blob makes the predicate an
|
|
1229
|
+
* equality: a condition matches only if it is a WHOLE quoted item of the
|
|
1230
|
+
* record. Both halves matter. Whole, so a fragment cannot match; item rather
|
|
1231
|
+
* than whole document, so a record may carry other prose around the conditions
|
|
1232
|
+
* without anyone having to say which section holds them, which is the
|
|
1233
|
+
* structure assumption that would have made this check project-specific.
|
|
1234
|
+
*
|
|
1235
|
+
* THE COST, stated because it is a real constraint on a consuming project: a
|
|
1236
|
+
* condition must be quoted as a complete list item or paragraph of the record.
|
|
1237
|
+
* A condition that paraphrases, or that quotes half of a longer item, is now
|
|
1238
|
+
* a violation. That is what "quoted from the decision record rather than
|
|
1239
|
+
* summarized" already claimed to mean, and it is now enforced rather than
|
|
1240
|
+
* asserted.
|
|
1241
|
+
*
|
|
1242
|
+
* A LIST ITEM'S UNIT IS THE WHOLE ITEM. An item's continuation paragraphs and
|
|
1243
|
+
* its nested sub-items are CONTENT OF THE ITEM in CommonMark, so emitting them
|
|
1244
|
+
* as units of their own would leave the item's FIRST PARAGRAPH standing as a
|
|
1245
|
+
* whole unit while the item itself carried more, which is a fragment passing as
|
|
1246
|
+
* a whole quote: the defect this check exists to prevent, arriving through the
|
|
1247
|
+
* extractor. It is live in this repository:
|
|
1248
|
+
* `delivery/decisions/DR-0004-elevated-permissions.md` has the shape (an item,
|
|
1249
|
+
* a blank, then its commands indented under it) and
|
|
1250
|
+
* `delivery/decisions/DR-0013-schema-validator-implementation.md` has the
|
|
1251
|
+
* nested-list form. THE COST, stated because it is real: a nested sub-item is
|
|
1252
|
+
* not separately quotable, so a record whose conditions are sub-bullets must
|
|
1253
|
+
* quote the enclosing item whole.
|
|
1254
|
+
*
|
|
1255
|
+
* ------------------------------------------------------------------
|
|
1256
|
+
* THE BLOCK STRUCTURE IS READ FROM A COMMONMARK PARSER (DR-0022, owner
|
|
1257
|
+
* decision, option A2). THE TEXT IS SLICED FROM THE ORIGINAL SOURCE.
|
|
1258
|
+
* ------------------------------------------------------------------
|
|
1259
|
+
*
|
|
1260
|
+
* What stood here until 2026-08-09 was a HAND-ROLLED CommonMark block parser:
|
|
1261
|
+
* a line loop carrying fence state, indented-code state, a list content column
|
|
1262
|
+
* and a deferred-blank flag, with six sites that could end a unit. It took FIVE
|
|
1263
|
+
* fix rounds and produced FIVE defects, the fifth a regression of a shape the
|
|
1264
|
+
* fourth had correct. The owner's decision records the measurement that ended
|
|
1265
|
+
* it: against two independent conformant parsers over 15,000 generated
|
|
1266
|
+
* documents, the hand-rolled loop agreed on about 35 per cent of them.
|
|
1267
|
+
*
|
|
1268
|
+
* The reason the rounds could not converge is worth keeping, because it is a
|
|
1269
|
+
* property of the problem and not of the agents. Whether a line is prose
|
|
1270
|
+
* depends on which block encloses it, and which block encloses it depends on
|
|
1271
|
+
* lines above and sometimes below (a setext underline retroactively makes the
|
|
1272
|
+
* block above it a heading). A loop that decides one line at a time is
|
|
1273
|
+
* reconstructing a parser, and every reconstruction has to be kept in agreement
|
|
1274
|
+
* with the reference BY HAND, with no mechanism that detects divergence. That
|
|
1275
|
+
* is the "guard narrower than the property" family, and this repository has now
|
|
1276
|
+
* recorded it five times in this one function.
|
|
1277
|
+
*
|
|
1278
|
+
* TWO OF THE ELEVEN FINDINGS ACROSS THOSE ROUNDS WERE NOT DEFECTS AT ALL. V-3
|
|
1279
|
+
* ("adjacent paragraphs merge") and the fifth member of V-5 (a nested sub-item
|
|
1280
|
+
* followed by a dedented line) were both cases where a hand-reading of markdown
|
|
1281
|
+
* disagreed with CommonMark and the HAND-READING WAS WRONG: lazy continuation
|
|
1282
|
+
* makes both fusions correct. A round can only find defects it already believes
|
|
1283
|
+
* in, which is the other half of the cost.
|
|
1284
|
+
*
|
|
1285
|
+
* WHY `sourcepos` SLICING AND NOT THE PARSER'S INLINE TEXT, which is the whole
|
|
1286
|
+
* of A2 versus A and is the single most expensive detail here. Walking the AST
|
|
1287
|
+
* and reading each paragraph's inline text is the obvious implementation and it
|
|
1288
|
+
* SILENTLY CHANGES THE SHIPPED CONTRACT, because inline text drops markup:
|
|
1289
|
+
* `` `delivery/review/` `` becomes `delivery/review/`. DR-0012's first
|
|
1290
|
+
* merge-authority condition contains exactly that, so `assurance-modes.yaml`
|
|
1291
|
+
* stops resolving, and 11 of this repository's 19 decision records produce
|
|
1292
|
+
* different unit sets. Slicing the ORIGINAL SOURCE by the parser's own
|
|
1293
|
+
* `sourcepos` offsets keeps the bytes as written, which is what every existing
|
|
1294
|
+
* record and every existing condition relies on.
|
|
1295
|
+
*
|
|
1296
|
+
* SO: this function reads the parser for STRUCTURE ONLY. It never reads
|
|
1297
|
+
* `literal` and never concatenates inline nodes, and a change that starts doing
|
|
1298
|
+
* either is option A, which is a defect. `CommonMarkNode` above declares six
|
|
1299
|
+
* members and none of them is inline text, so the type is the guard.
|
|
1300
|
+
*
|
|
1301
|
+
* WHAT THE FOUR PREVIOUSLY UNMODELLED BLOCK FORMS DO NOW, since the old
|
|
1302
|
+
* docstring listed them as latent hazards:
|
|
1303
|
+
* - block quote: its contents are treated like the document's, so the quoted
|
|
1304
|
+
* paragraph is a unit and the `>` marker is NOT part of it. This is a
|
|
1305
|
+
* DECLARED POLICY CHOICE (see `collectUnits`), not a derivation.
|
|
1306
|
+
* - HTML block: contributes no unit. Corrected in round 7 (CR-003): it is
|
|
1307
|
+
* listed in `NOT_QUOTABLE`, but under `commonmark` 0.31.2 that listing is
|
|
1308
|
+
* not what excludes it. An `html_block` is an AST LEAF, and a unit is only
|
|
1309
|
+
* ever emitted for a `paragraph` child, so it could contribute nothing even
|
|
1310
|
+
* if the set were empty. Read `NOT_QUOTABLE`'s own docstring for what the
|
|
1311
|
+
* set is really for.
|
|
1312
|
+
* - link reference definition: excluded, and by construction rather than by a
|
|
1313
|
+
* rule, because the parser removes it before this walk sees the document.
|
|
1314
|
+
* - pipe table: never was a hazard. CommonMark core has no tables, so a table
|
|
1315
|
+
* IS a paragraph and treating its lines as prose is correct.
|
|
1316
|
+
*
|
|
1317
|
+
* WHERE THIS IS STILL NOT AN ORACLE: it is right in the sense of "agrees with
|
|
1318
|
+
* `commonmark` 0.31.2". Two conformant CommonMark implementations disagree on
|
|
1319
|
+
* roughly half a per cent of generated documents (an indented line immediately
|
|
1320
|
+
* after a link reference definition is the measured instance), and any
|
|
1321
|
+
* structure-reading option inherits that.
|
|
1322
|
+
*/
|
|
1323
|
+
export function quotableUnits(text) {
|
|
1324
|
+
/* SPLIT ON THE SAME LINE ENDINGS THE PARSER DOES. `sourcepos` line numbers
|
|
1325
|
+
index the parser's own line array, so splitting on "\n" alone would put
|
|
1326
|
+
every slice on the wrong line in a document using lone CR. */
|
|
1327
|
+
const lines = text.split(/\r\n|\n|\r/);
|
|
1328
|
+
const { Parser } = commonMarkModule();
|
|
1329
|
+
const units = new Set();
|
|
1330
|
+
collectUnits(new Parser().parse(text), lines, units, 0);
|
|
1331
|
+
return units;
|
|
1332
|
+
}
|
|
1333
|
+
export const modeConditionsQuoteGrantedBy = {
|
|
1334
|
+
id: "mode-conditions-quote-granted-by",
|
|
1335
|
+
type: "assurance-modes",
|
|
1336
|
+
requiresContext: true,
|
|
1337
|
+
run(instance, contextDirectory) {
|
|
1338
|
+
if (contextDirectory === undefined) {
|
|
1339
|
+
return {
|
|
1340
|
+
violations: [
|
|
1341
|
+
{ pointer: "#/modes", message: "no context directory was supplied" },
|
|
1342
|
+
],
|
|
1343
|
+
reports: [],
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
const violations = [];
|
|
1347
|
+
const cache = new Map();
|
|
1348
|
+
const resolveRecord = (record) => {
|
|
1349
|
+
const cached = cache.get(record);
|
|
1350
|
+
if (cached !== undefined) {
|
|
1351
|
+
return cached;
|
|
1352
|
+
}
|
|
1353
|
+
const matches = [];
|
|
1354
|
+
const searched = [];
|
|
1355
|
+
for (const directory of DECISION_DIRECTORIES) {
|
|
1356
|
+
const path = join(contextDirectory, directory);
|
|
1357
|
+
searched.push(directory);
|
|
1358
|
+
let entries;
|
|
1359
|
+
try {
|
|
1360
|
+
entries = readdirSync(path);
|
|
1361
|
+
}
|
|
1362
|
+
catch {
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
for (const name of entries.sort()) {
|
|
1366
|
+
if (name === `${record}.md` || name.startsWith(`${record}-`)) {
|
|
1367
|
+
matches.push(join(path, name));
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
let outcome;
|
|
1372
|
+
if (matches.length === 0) {
|
|
1373
|
+
outcome = {
|
|
1374
|
+
ok: false,
|
|
1375
|
+
reason: `no decision record ${record} was found under ${searched.join(" or ")} of the context, so the grant it names cannot be checked`,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
else if (matches.length > 1) {
|
|
1379
|
+
outcome = {
|
|
1380
|
+
ok: false,
|
|
1381
|
+
reason: `${String(matches.length)} files match decision record ${record} (${matches.join(", ")}), so the grant it names resolves ambiguously`,
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
else {
|
|
1385
|
+
const read = readOperatorPath(matches[0]);
|
|
1386
|
+
outcome = read.ok
|
|
1387
|
+
? { ok: true, units: quotableUnits(read.body) }
|
|
1388
|
+
: { ok: false, reason: read.reason };
|
|
1389
|
+
}
|
|
1390
|
+
cache.set(record, outcome);
|
|
1391
|
+
return outcome;
|
|
1392
|
+
};
|
|
1393
|
+
for (const row of eachMode(instance)) {
|
|
1394
|
+
const conditions = stringsAt(row.mode, "conditions");
|
|
1395
|
+
if (conditions.length === 0) {
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
const grantedBy = row.mode["granted-by"];
|
|
1399
|
+
if (typeof grantedBy !== "string") {
|
|
1400
|
+
violations.push({
|
|
1401
|
+
pointer: `#/modes/${String(row.index)}/conditions`,
|
|
1402
|
+
message: `mode ${row.id} declares ${String(conditions.length)} condition(s) and names no granted-by record, so nothing can be compared against them`,
|
|
1403
|
+
});
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
const resolved = resolveRecord(grantedBy);
|
|
1407
|
+
if (!resolved.ok) {
|
|
1408
|
+
violations.push({
|
|
1409
|
+
pointer: `#/modes/${String(row.index)}/granted-by`,
|
|
1410
|
+
message: resolved.reason,
|
|
1411
|
+
});
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
for (let position = 0; position < conditions.length; position += 1) {
|
|
1415
|
+
/* EQUALITY AGAINST A WHOLE UNIT, never containment in the blob. An
|
|
1416
|
+
EMPTY condition is a violation here rather than a skip: the schema
|
|
1417
|
+
already forbids it, and a check that quietly accepted one would be
|
|
1418
|
+
accepting the shortest fabrication of all. */
|
|
1419
|
+
const condition = normalizeProse(conditions[position]);
|
|
1420
|
+
if (!resolved.units.has(condition)) {
|
|
1421
|
+
const opening = condition.length > 60 ? `${condition.slice(0, 60)}...` : condition;
|
|
1422
|
+
violations.push({
|
|
1423
|
+
pointer: `#/modes/${String(row.index)}/conditions/${String(position)}`,
|
|
1424
|
+
message: `mode ${row.id} cites ${grantedBy} for a condition that is not a whole quoted item of that record: "${opening}"`,
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return { violations, reports: [] };
|
|
1430
|
+
},
|
|
1431
|
+
};
|
|
1432
|
+
/* ------------------------------------------------------------------ */
|
|
1433
|
+
/* report-parity-arithmetic (M3-P4, R-048, R-049, R-086) */
|
|
1434
|
+
/* ------------------------------------------------------------------ */
|
|
1435
|
+
/**
|
|
1436
|
+
* The FIVE buckets whose sum must equal `discovered`.
|
|
1437
|
+
*
|
|
1438
|
+
* `todo` is the sixth count and was added by M3-P4 fix round 2, on the
|
|
1439
|
+
* orchestrator's arbitration of round 1 rather than on an implementer's
|
|
1440
|
+
* initiative. The M2-P3 wrapper's own identity is
|
|
1441
|
+
* `pass + fail + skipped + todo + did-not-run == reported`
|
|
1442
|
+
* (src/gates/suite.ts:350), and the plan's field list named five counts, so a
|
|
1443
|
+
* run reporting `todo > 0` could not be recorded at all without breaking
|
|
1444
|
+
* parity. A contract that REFUSES A LEGITIMATE RUN is worse than a missing
|
|
1445
|
+
* field, which is why the arbitration amended the plan rather than leaving
|
|
1446
|
+
* the gap disclosed.
|
|
1447
|
+
*/
|
|
1448
|
+
const PARITY_BUCKETS = ["passed", "failed", "skipped", "todo", "did-not-run"];
|
|
1449
|
+
/** Every count field a gate result may carry, `discovered` first. */
|
|
1450
|
+
const COUNT_FIELDS = ["discovered", ...PARITY_BUCKETS];
|
|
1451
|
+
/**
|
|
1452
|
+
* WHERE THE SHARED `gateResult` DEFINITION IS REACHED FROM, one row per
|
|
1453
|
+
* artifact type, naming the KEY that type stores its gate results under.
|
|
1454
|
+
*
|
|
1455
|
+
* This table is the concrete form of CR-001's mechanism. The definition is
|
|
1456
|
+
* one object reached by `$ref` from two documents; the PROPERTY NAME differs
|
|
1457
|
+
* between them (`gate-results` in a report, `gate-evidence` in a work
|
|
1458
|
+
* history), so a check that hard-codes one key is blind on the other type
|
|
1459
|
+
* even after it is registered for it. Both halves are needed and only one of
|
|
1460
|
+
* them is visible from the `$ref`.
|
|
1461
|
+
*/
|
|
1462
|
+
export const GATE_RESULT_SITES = [
|
|
1463
|
+
{ type: "report", key: "gate-results" },
|
|
1464
|
+
{ type: "work-history", key: "gate-evidence" },
|
|
1465
|
+
];
|
|
1466
|
+
/**
|
|
1467
|
+
* `discovered == passed + failed + skipped + did-not-run`, over one gate
|
|
1468
|
+
* result's sibling fields.
|
|
1469
|
+
*
|
|
1470
|
+
* NO SCHEMA KEYWORD COMPUTES ARITHMETIC over sibling fields, which is what
|
|
1471
|
+
* makes this Kind B rather than a keyword (M3R-002 corrected revision 0's
|
|
1472
|
+
* classification of exactly this check). The property it guards is R-048's:
|
|
1473
|
+
* a suite that reports fewer tests than it discovered is the
|
|
1474
|
+
* silently-dropped-tests case, and it adds up to a green everywhere else.
|
|
1475
|
+
*
|
|
1476
|
+
* THREE THINGS THIS CHECKS, and the second and third are the CONVERSES the
|
|
1477
|
+
* criterion's letter does not name. The plan's criterion 2b(a) names only
|
|
1478
|
+
* `discovered` EXCEEDING the sum. A check that tested only that direction
|
|
1479
|
+
* would pass a record whose sum exceeds `discovered`, which is a different
|
|
1480
|
+
* lie with the same shape, so the test here is EQUALITY. And a count field
|
|
1481
|
+
* that is NEGATIVE is arithmetic nonsense that equality alone can satisfy
|
|
1482
|
+
* (`discovered: 0` with `passed: 1` and `failed: -1` adds up); negativity is
|
|
1483
|
+
* not reachable by any keyword in the declared authoring vocabulary, which
|
|
1484
|
+
* has no `minimum`, so it is checked here beside the sum rather than left to
|
|
1485
|
+
* a keyword that does not exist.
|
|
1486
|
+
*
|
|
1487
|
+
* WHAT IT DOES NOT REACH, stated rather than implied: a gate result carrying
|
|
1488
|
+
* NO count field at all is not examined, because the schema requires the six
|
|
1489
|
+
* counts only of a `green`, and a `red` result that records none of them is a
|
|
1490
|
+
* legitimate record rather than a false one. So this check cannot see a
|
|
1491
|
+
* dropped test in a run nobody counted; it sees one in a run that claims a
|
|
1492
|
+
* count. Nor does it reach a BALANCED loss: an author who drops the same row
|
|
1493
|
+
* from `discovered` and from a bucket satisfies the identity, because nothing
|
|
1494
|
+
* here anchors `discovered` to what the wrapper actually discovered.
|
|
1495
|
+
*
|
|
1496
|
+
* WHERE IT RUNS, and this is CR-001's whole content. It runs on EVERY type
|
|
1497
|
+
* that reaches the shared `gateResult` definition, enumerated by
|
|
1498
|
+
* `GATE_RESULT_SITES` rather than by one hard-coded key. Until M3-P4 fix
|
|
1499
|
+
* round 2 it was registered for `report` alone and read `gate-results` alone,
|
|
1500
|
+
* so a work history recording 9999 discovered and 1 passed exited 0 while the
|
|
1501
|
+
* identical counts in a report exited 1, and the shared definition's own
|
|
1502
|
+
* comment said the check applied.
|
|
1503
|
+
*/
|
|
1504
|
+
export const reportParityArithmetic = {
|
|
1505
|
+
id: "report-parity-arithmetic",
|
|
1506
|
+
type: "report",
|
|
1507
|
+
alsoTypes: ["work-history"],
|
|
1508
|
+
guards: ["report.schema.json#/$defs/gateResult"],
|
|
1509
|
+
requiresContext: false,
|
|
1510
|
+
run(instance) {
|
|
1511
|
+
const record = asRecord(instance);
|
|
1512
|
+
if (record === undefined) {
|
|
1513
|
+
return EMPTY;
|
|
1514
|
+
}
|
|
1515
|
+
const violations = [];
|
|
1516
|
+
/* EVERY site key, not the one belonging to the type this run was
|
|
1517
|
+
dispatched for. A document carries exactly one of these keys
|
|
1518
|
+
(`additionalProperties: false` at the top level of both schemas), so
|
|
1519
|
+
the loop visits one array in practice and cannot be defeated by a
|
|
1520
|
+
caller that passes the wrong type name. */
|
|
1521
|
+
for (const site of GATE_RESULT_SITES) {
|
|
1522
|
+
const results = asArray(record[site.key]);
|
|
1523
|
+
results.forEach((entry, index) => {
|
|
1524
|
+
const result = asRecord(entry);
|
|
1525
|
+
if (result === undefined) {
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const present = COUNT_FIELDS.filter((field) => result[field] !== undefined);
|
|
1529
|
+
if (present.length === 0) {
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
const pointer = `#/${site.key}/${String(index)}`;
|
|
1533
|
+
const missing = COUNT_FIELDS.filter((field) => result[field] === undefined);
|
|
1534
|
+
if (missing.length > 0) {
|
|
1535
|
+
violations.push({
|
|
1536
|
+
pointer,
|
|
1537
|
+
message: `gate result records ${String(present.length)} of the ${String(COUNT_FIELDS.length)} counts and omits ${missing.join(", ")}, so parity cannot be computed`,
|
|
1538
|
+
});
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
const values = new Map();
|
|
1542
|
+
for (const field of COUNT_FIELDS) {
|
|
1543
|
+
const value = result[field];
|
|
1544
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
1545
|
+
/* The schema already rejects a non-integer here; this is the
|
|
1546
|
+
belt that stops the arithmetic below producing NaN if this
|
|
1547
|
+
check is ever run on an instance that skipped validation. */
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
values.set(field, value);
|
|
1551
|
+
}
|
|
1552
|
+
const negative = COUNT_FIELDS.filter((field) => values.get(field) < 0);
|
|
1553
|
+
if (negative.length > 0) {
|
|
1554
|
+
violations.push({
|
|
1555
|
+
pointer,
|
|
1556
|
+
message: `count(s) ${negative.join(", ")} are negative, which no run can produce`,
|
|
1557
|
+
});
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
const sum = PARITY_BUCKETS.reduce((total, field) => total + values.get(field), 0);
|
|
1561
|
+
const discovered = values.get("discovered");
|
|
1562
|
+
if (discovered !== sum) {
|
|
1563
|
+
violations.push({
|
|
1564
|
+
pointer,
|
|
1565
|
+
message: `discovered ${String(discovered)} does not equal ${PARITY_BUCKETS.join(" + ")} = ${String(sum)}`,
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
return { violations, reports: [] };
|
|
1571
|
+
},
|
|
1572
|
+
};
|
|
1573
|
+
/* ------------------------------------------------------------------ */
|
|
1574
|
+
/* final-report-finding-parity (M3-P4, R-089a) */
|
|
1575
|
+
/* ------------------------------------------------------------------ */
|
|
1576
|
+
/**
|
|
1577
|
+
* Every id in `inputs[]` appears in `input-findings[]`, exactly once, and no
|
|
1578
|
+
* `input-findings[]` row names an id `inputs[]` does not carry.
|
|
1579
|
+
*
|
|
1580
|
+
* A CROSS-ARRAY COMPLETENESS PROPERTY, which no keyword reaches: `contains`
|
|
1581
|
+
* asks about a fixed shape, not about a value computed from a sibling array.
|
|
1582
|
+
* Revision 0 of the plan listed this once as a schema witness, which was
|
|
1583
|
+
* wrong (M3R-002).
|
|
1584
|
+
*
|
|
1585
|
+
* THREE DIRECTIONS, and only the first is in the criterion's letter. The
|
|
1586
|
+
* criterion names the ORPHAN: an id in `inputs[]` with no row. The PHANTOM
|
|
1587
|
+
* (a row whose id is not an input) and the DUPLICATE (two rows for one id)
|
|
1588
|
+
* are the converses, and they are here because M2-P6 paid for both by
|
|
1589
|
+
* measurement rather than by argument: CR-988 records that its parity mode
|
|
1590
|
+
* scanned inventory ids only, so a row for a renumbered id was silently
|
|
1591
|
+
* accepted, and CR-985 records that a duplicated id defeated the orphan and
|
|
1592
|
+
* phantom checks TOGETHER while inflating every count. A guard narrower than
|
|
1593
|
+
* its own description is what this project keeps re-buying, so the check is
|
|
1594
|
+
* as wide as the relation.
|
|
1595
|
+
*
|
|
1596
|
+
* WHAT IT DOES NOT REACH: a finding dropped from BOTH arrays. The two
|
|
1597
|
+
* documents then agree with each other, and no comparison between them can
|
|
1598
|
+
* see it. That is the same residue `src/gates/coverage.ts` answers with a
|
|
1599
|
+
* config-stated `expectedUnits` anchor, and this schema has no such anchor
|
|
1600
|
+
* because nothing in the plan states one.
|
|
1601
|
+
*/
|
|
1602
|
+
export const finalReportFindingParity = {
|
|
1603
|
+
id: "final-report-finding-parity",
|
|
1604
|
+
type: "final-report",
|
|
1605
|
+
requiresContext: false,
|
|
1606
|
+
run(instance) {
|
|
1607
|
+
const record = asRecord(instance);
|
|
1608
|
+
if (record === undefined) {
|
|
1609
|
+
return EMPTY;
|
|
1610
|
+
}
|
|
1611
|
+
const violations = [];
|
|
1612
|
+
const inputs = asArray(record["inputs"]).filter((value) => typeof value === "string");
|
|
1613
|
+
const rows = asArray(record["input-findings"]);
|
|
1614
|
+
const rowIds = [];
|
|
1615
|
+
for (const row of rows) {
|
|
1616
|
+
const entry = asRecord(row);
|
|
1617
|
+
const id = entry?.["id"];
|
|
1618
|
+
rowIds.push(typeof id === "string" ? id : "");
|
|
1619
|
+
}
|
|
1620
|
+
const counts = new Map();
|
|
1621
|
+
for (const id of rowIds) {
|
|
1622
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
1623
|
+
}
|
|
1624
|
+
inputs.forEach((id, index) => {
|
|
1625
|
+
const seen = counts.get(id) ?? 0;
|
|
1626
|
+
if (seen === 0) {
|
|
1627
|
+
violations.push({
|
|
1628
|
+
pointer: `#/inputs/${String(index)}`,
|
|
1629
|
+
message: `finding ${id} has no row in input-findings, so the table has a hole`,
|
|
1630
|
+
});
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
if (seen > 1) {
|
|
1634
|
+
violations.push({
|
|
1635
|
+
pointer: `#/inputs/${String(index)}`,
|
|
1636
|
+
message: `finding ${id} has ${String(seen)} rows in input-findings and must have exactly one`,
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
const inputSet = new Set(inputs);
|
|
1641
|
+
rowIds.forEach((id, index) => {
|
|
1642
|
+
if (!inputSet.has(id)) {
|
|
1643
|
+
violations.push({
|
|
1644
|
+
pointer: `#/input-findings/${String(index)}`,
|
|
1645
|
+
message: `input-findings names ${id === "" ? "an id-less row" : id}, which is not in inputs, so the coverage is phantom`,
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
});
|
|
1649
|
+
return { violations, reports: [] };
|
|
1650
|
+
},
|
|
1651
|
+
};
|
|
1652
|
+
/* ------------------------------------------------------------------ */
|
|
1653
|
+
/* report-no-findings-statement (M3-P4 fix round 2, hazard 1) */
|
|
1654
|
+
/* ------------------------------------------------------------------ */
|
|
1655
|
+
/**
|
|
1656
|
+
* A report with an EMPTY `findings` array carries a `no-findings-statement`,
|
|
1657
|
+
* and a report that files findings does NOT carry one.
|
|
1658
|
+
*
|
|
1659
|
+
* KIND B BY NECESSITY, AND THE NECESSITY IS MEASURED RATHER THAN ASSERTED.
|
|
1660
|
+
* The natural keyword shape is `if findings has maxItems 0 then require
|
|
1661
|
+
* no-findings-statement`, and `maxItems` is ABSENT from the sixteen keywords
|
|
1662
|
+
* of `AUTHORING_VOCABULARY` (src/validate.ts:111). No other permitted keyword
|
|
1663
|
+
* says "this array is empty": `minItems` says the opposite, `contains` asks
|
|
1664
|
+
* about a member that exists, and `const: []` is not reachable because `const`
|
|
1665
|
+
* is used on scalars here and an array `const` would pin the CONTENTS. So the
|
|
1666
|
+
* emptiness of a sibling array is not a keyword property, which is the same
|
|
1667
|
+
* boundary `report-parity-arithmetic` sits on one field over.
|
|
1668
|
+
*
|
|
1669
|
+
* WHY IT IS HERE AT ALL. `no-findings-statement` exists to price silence: a
|
|
1670
|
+
* report claiming nothing was found must say WHY nothing was found. Optional,
|
|
1671
|
+
* it is absent in exactly the situation it exists for, and the shipped schema
|
|
1672
|
+
* disclosed that as a residue rather than closing it. The orchestrator's
|
|
1673
|
+
* arbitration of M3-P4 round 1 amended section 2.3's table to three rows for
|
|
1674
|
+
* this phase and directed the check to be written; D-M3-22 is satisfied by
|
|
1675
|
+
* that amendment, not by this comment.
|
|
1676
|
+
*
|
|
1677
|
+
* BOTH DIRECTIONS, because the phase's own converse discipline demands it.
|
|
1678
|
+
* The requirement's letter names only the empty-with-no-statement case. A
|
|
1679
|
+
* report that files three findings and ALSO carries "no findings were found"
|
|
1680
|
+
* is the opposite misdeclaration and is equally a false record, so it is a
|
|
1681
|
+
* violation too.
|
|
1682
|
+
*
|
|
1683
|
+
* WHAT IT DOES NOT REACH: whether the statement SAYS anything. The schema
|
|
1684
|
+
* makes an empty or whitespace-only one impossible; a statement reading "n/a"
|
|
1685
|
+
* satisfies both this check and those keywords, and that is M3-P7's
|
|
1686
|
+
* `contract-avoidance` probe rather than anything a schema or a check can see.
|
|
1687
|
+
* It also does not reach a report with NO `findings` key at all, because
|
|
1688
|
+
* `findings` is `required` and the schema rejects that before any check runs.
|
|
1689
|
+
*/
|
|
1690
|
+
export const reportNoFindingsStatement = {
|
|
1691
|
+
id: "report-no-findings-statement",
|
|
1692
|
+
type: "report",
|
|
1693
|
+
requiresContext: false,
|
|
1694
|
+
run(instance) {
|
|
1695
|
+
const record = asRecord(instance);
|
|
1696
|
+
if (record === undefined || !Array.isArray(record["findings"])) {
|
|
1697
|
+
return EMPTY;
|
|
1698
|
+
}
|
|
1699
|
+
const empty = record["findings"].length === 0;
|
|
1700
|
+
const stated = record["no-findings-statement"] !== undefined;
|
|
1701
|
+
if (empty && !stated) {
|
|
1702
|
+
return {
|
|
1703
|
+
violations: [
|
|
1704
|
+
{
|
|
1705
|
+
pointer: "#/no-findings-statement",
|
|
1706
|
+
message: "findings is empty and no-findings-statement is missing, so the report claims nothing was found without saying why",
|
|
1707
|
+
},
|
|
1708
|
+
],
|
|
1709
|
+
reports: [],
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
if (!empty && stated) {
|
|
1713
|
+
return {
|
|
1714
|
+
violations: [
|
|
1715
|
+
{
|
|
1716
|
+
pointer: "#/no-findings-statement",
|
|
1717
|
+
message: `no-findings-statement is present beside ${String(record["findings"].length)} finding(s), so the report contradicts itself`,
|
|
1718
|
+
},
|
|
1719
|
+
],
|
|
1720
|
+
reports: [],
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
return EMPTY;
|
|
1724
|
+
},
|
|
1725
|
+
};
|
|
1726
|
+
/* ------------------------------------------------------------------ */
|
|
1727
|
+
/* checklist-probe-ids-unique (M3-P7 step 6b, criterion 1) */
|
|
1728
|
+
/* ------------------------------------------------------------------ */
|
|
1729
|
+
/**
|
|
1730
|
+
* No two probes in one checklist share an `id`.
|
|
1731
|
+
*
|
|
1732
|
+
* KIND B, AND THE REASON IS A KEYWORD'S SEMANTICS RATHER THAN A DOCUMENT
|
|
1733
|
+
* BOUNDARY. `uniqueItems` compares WHOLE array items, so two probes sharing
|
|
1734
|
+
* an id and differing in any other field are already unique to it, and the
|
|
1735
|
+
* pair that shares an id is exactly the dangerous instance: `checklist
|
|
1736
|
+
* resolve` looks a probe up by id, so a duplicate makes the resolved list
|
|
1737
|
+
* depend on which one the lookup reached. Uniqueness of a NESTED PROPERTY
|
|
1738
|
+
* across array items is not a keyword property under any DR-0013 option,
|
|
1739
|
+
* which is why the review did not name it and why it lands here.
|
|
1740
|
+
*
|
|
1741
|
+
* `requiresContext` is FALSE: the whole comparison is inside one document.
|
|
1742
|
+
*/
|
|
1743
|
+
export const checklistProbeIdsUnique = {
|
|
1744
|
+
id: "checklist-probe-ids-unique",
|
|
1745
|
+
type: "checklist",
|
|
1746
|
+
requiresContext: false,
|
|
1747
|
+
run(instance) {
|
|
1748
|
+
const probes = asArray(asRecord(instance)?.["probes"]);
|
|
1749
|
+
const firstIndexById = new Map();
|
|
1750
|
+
const violations = [];
|
|
1751
|
+
for (let index = 0; index < probes.length; index += 1) {
|
|
1752
|
+
const id = asRecord(probes[index])?.["id"];
|
|
1753
|
+
if (typeof id !== "string") {
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
const first = firstIndexById.get(id);
|
|
1757
|
+
if (first === undefined) {
|
|
1758
|
+
firstIndexById.set(id, index);
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
/* NAMES BOTH POSITIONS. An author told only that an id is duplicated
|
|
1762
|
+
has to find the other one; the two pointers are what make the
|
|
1763
|
+
message a diagnosis. */
|
|
1764
|
+
violations.push({
|
|
1765
|
+
pointer: `#/probes/${String(index)}/id`,
|
|
1766
|
+
message: `probe id ${id} is already declared at #/probes/${String(first)}/id, and checklist resolve looks probes up by id`,
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
return { violations, reports: [] };
|
|
1770
|
+
},
|
|
1771
|
+
};
|
|
1772
|
+
/* ------------------------------------------------------------------ */
|
|
1773
|
+
/* checklist-framing-ids-unique (M3-P7 fix round 2, H-2 member 1) */
|
|
1774
|
+
/* ------------------------------------------------------------------ */
|
|
1775
|
+
/**
|
|
1776
|
+
* No two framings in one checklist share an `id`.
|
|
1777
|
+
*
|
|
1778
|
+
* THE SAME SHAPE AND THE SAME KEYWORD LIMITATION AS THE PROBE CHECK ABOVE,
|
|
1779
|
+
* one array along. `uniqueItems` on `framings` compares WHOLE items, so two
|
|
1780
|
+
* framings sharing an id and differing in their entry point or their scope
|
|
1781
|
+
* order are already unique to it, and that pair is exactly the dangerous
|
|
1782
|
+
* instance: `resolveChecklist` looks a framing up with `.find()`, first match
|
|
1783
|
+
* wins, so which of two declared entry points a reviewer is handed depends on
|
|
1784
|
+
* FILE POSITION and nothing says so.
|
|
1785
|
+
*
|
|
1786
|
+
* WHY IT MATTERS MORE HERE THAN THE PROBE CASE LOOKS LIKE IT WOULD. A
|
|
1787
|
+
* framing IS the entry point, and T-001's lesson that decorrelation comes
|
|
1788
|
+
* from the starting question is the whole reason `--framing` exists. A
|
|
1789
|
+
* duplicate id means the reviewer's starting question is decided by which
|
|
1790
|
+
* copy sat first in the file, which is the phase's own hazard class ("a
|
|
1791
|
+
* framing that reorders the list without changing the entry point") reached
|
|
1792
|
+
* from the other side.
|
|
1793
|
+
*
|
|
1794
|
+
* `requiresContext` is FALSE: the whole comparison is inside one document.
|
|
1795
|
+
*/
|
|
1796
|
+
export const checklistFramingIdsUnique = {
|
|
1797
|
+
id: "checklist-framing-ids-unique",
|
|
1798
|
+
type: "checklist",
|
|
1799
|
+
requiresContext: false,
|
|
1800
|
+
run(instance) {
|
|
1801
|
+
const framings = asArray(asRecord(instance)?.["framings"]);
|
|
1802
|
+
const firstIndexById = new Map();
|
|
1803
|
+
const violations = [];
|
|
1804
|
+
for (let index = 0; index < framings.length; index += 1) {
|
|
1805
|
+
const id = asRecord(framings[index])?.["id"];
|
|
1806
|
+
if (typeof id !== "string") {
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
const first = firstIndexById.get(id);
|
|
1810
|
+
if (first === undefined) {
|
|
1811
|
+
firstIndexById.set(id, index);
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
/* NAMES BOTH POSITIONS, for the reason the probe check records. */
|
|
1815
|
+
violations.push({
|
|
1816
|
+
pointer: `#/framings/${String(index)}/id`,
|
|
1817
|
+
message: `framing id ${id} is already declared at #/framings/${String(first)}/id, and checklist resolve looks framings up by id`,
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
return { violations, reports: [] };
|
|
1821
|
+
},
|
|
1822
|
+
};
|
|
1823
|
+
/* ------------------------------------------------------------------ */
|
|
1824
|
+
/* gate-probes-resolve (M3-P7 step 6b, criteria 3 and 3c) */
|
|
1825
|
+
/* ------------------------------------------------------------------ */
|
|
1826
|
+
/**
|
|
1827
|
+
* The join M3-P2 deliberately left open, closed in BOTH DIRECTIONS.
|
|
1828
|
+
*
|
|
1829
|
+
* `gate-registry.yaml` carries entries whose `verified-by` is
|
|
1830
|
+
* `clean-room-checklist` and whose `probe` names a probe id this phase
|
|
1831
|
+
* supplies. Nothing on the registry side can check that the probe exists,
|
|
1832
|
+
* because the checklist did not exist when the registry shipped.
|
|
1833
|
+
*
|
|
1834
|
+
* DIRECTION 1, REGISTRY TO CHECKLIST (criterion 3). Every registry entry
|
|
1835
|
+
* verified by a checklist names a probe that RESOLVES in that checklist, and
|
|
1836
|
+
* that probe carries the `verifies-gate` back-reference to the entry. WHICH
|
|
1837
|
+
* checklist is derived from the registry's own vocabulary rather than
|
|
1838
|
+
* hardcoded: `verified-by: clean-room-checklist` names the checklist whose id
|
|
1839
|
+
* is `clean-room`, so an entry is only asserted against the document it
|
|
1840
|
+
* actually names, and running this check on `plan-review.yaml` does not
|
|
1841
|
+
* demand the clean-room probes there.
|
|
1842
|
+
*
|
|
1843
|
+
* DIRECTION 2, CHECKLIST TO REGISTRY (criterion 3c). Every probe carrying
|
|
1844
|
+
* `verifies-gate` names a gate id present in the registry. THE ASYMMETRY IS
|
|
1845
|
+
* THE WHOLE POINT: direction 1 starts from the registry and therefore cannot
|
|
1846
|
+
* see a probe pointing at a gate that no longer exists, which is what the
|
|
1847
|
+
* phase's own hazard class calls an orphan invisible by construction. The two
|
|
1848
|
+
* ways a registry edit orphans a probe fail through DIFFERENT lookups: a gate
|
|
1849
|
+
* id RENAMED leaves the probe pointing at a name that never existed, and a
|
|
1850
|
+
* gate entry DELETED leaves it pointing at a name that used to. Both land
|
|
1851
|
+
* here; neither is reachable from direction 1.
|
|
1852
|
+
*
|
|
1853
|
+
* `requiresContext` is TRUE, so invoking the validator without `--context`
|
|
1854
|
+
* prints `SKIPPED gate-probes-resolve no context` and exits nonzero. A
|
|
1855
|
+
* cross-document rule must never be able to pass BY NOT RUNNING.
|
|
1856
|
+
*/
|
|
1857
|
+
export const gateProbesResolve = {
|
|
1858
|
+
id: "gate-probes-resolve",
|
|
1859
|
+
type: "checklist",
|
|
1860
|
+
requiresContext: true,
|
|
1861
|
+
run(instance, contextDirectory) {
|
|
1862
|
+
if (contextDirectory === undefined) {
|
|
1863
|
+
/* Unreachable through `runChecks`, which SKIPS first. Kept fail-closed
|
|
1864
|
+
rather than trusting a caller that reaches the check directly. */
|
|
1865
|
+
return {
|
|
1866
|
+
violations: [
|
|
1867
|
+
{ pointer: "#/probes", message: "no context directory was supplied" },
|
|
1868
|
+
],
|
|
1869
|
+
reports: [],
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
const registryDocument = readContextDocument(contextDirectory, "gate-registry.yaml");
|
|
1873
|
+
if (!registryDocument.ok) {
|
|
1874
|
+
return {
|
|
1875
|
+
violations: [
|
|
1876
|
+
{
|
|
1877
|
+
pointer: "#/probes",
|
|
1878
|
+
message: `the gate registry could not be read, so no probe reference could be resolved in either direction: ${registryDocument.reason}`,
|
|
1879
|
+
},
|
|
1880
|
+
],
|
|
1881
|
+
reports: [],
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
const document = asRecord(instance);
|
|
1885
|
+
const checklistId = typeof document?.["id"] === "string" ? document["id"] : "";
|
|
1886
|
+
const probes = asArray(document?.["probes"]);
|
|
1887
|
+
const probeIndexById = new Map();
|
|
1888
|
+
const verifiesGateByProbe = new Map();
|
|
1889
|
+
for (let index = 0; index < probes.length; index += 1) {
|
|
1890
|
+
const probe = asRecord(probes[index]);
|
|
1891
|
+
const id = probe?.["id"];
|
|
1892
|
+
if (typeof id !== "string") {
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
if (!probeIndexById.has(id)) {
|
|
1896
|
+
probeIndexById.set(id, index);
|
|
1897
|
+
}
|
|
1898
|
+
if (typeof probe?.["verifies-gate"] === "string") {
|
|
1899
|
+
verifiesGateByProbe.set(id, probe["verifies-gate"]);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
const gateIds = new Set();
|
|
1903
|
+
const registryEntries = [];
|
|
1904
|
+
for (const gate of asArray(asRecord(registryDocument.value)?.["gates"])) {
|
|
1905
|
+
const record = asRecord(gate);
|
|
1906
|
+
const id = record?.["id"];
|
|
1907
|
+
if (record === undefined || typeof id !== "string") {
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
gateIds.add(id);
|
|
1911
|
+
const verifiedBy = record["verified-by"];
|
|
1912
|
+
const probe = record["probe"];
|
|
1913
|
+
if (typeof verifiedBy === "string" &&
|
|
1914
|
+
verifiedBy.endsWith("-checklist") &&
|
|
1915
|
+
typeof probe === "string") {
|
|
1916
|
+
registryEntries.push({
|
|
1917
|
+
id,
|
|
1918
|
+
probe,
|
|
1919
|
+
checklist: verifiedBy.slice(0, -"-checklist".length),
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
const violations = [];
|
|
1924
|
+
/* DIRECTION 1. */
|
|
1925
|
+
for (const entry of registryEntries) {
|
|
1926
|
+
if (entry.checklist !== checklistId) {
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
const index = probeIndexById.get(entry.probe);
|
|
1930
|
+
if (index === undefined) {
|
|
1931
|
+
violations.push({
|
|
1932
|
+
pointer: "#/probes",
|
|
1933
|
+
message: `gate ${entry.id} in ${registryDocument.path} names probe ${entry.probe}, which no probe in this checklist declares`,
|
|
1934
|
+
});
|
|
1935
|
+
continue;
|
|
1936
|
+
}
|
|
1937
|
+
const backReference = verifiesGateByProbe.get(entry.probe);
|
|
1938
|
+
if (backReference !== entry.id) {
|
|
1939
|
+
violations.push({
|
|
1940
|
+
pointer: `#/probes/${String(index)}/verifies-gate`,
|
|
1941
|
+
message: backReference === undefined
|
|
1942
|
+
? `probe ${entry.probe} is named by gate ${entry.id} in ${registryDocument.path} and carries no verifies-gate, so the checklist-to-registry direction cannot see it`
|
|
1943
|
+
: `probe ${entry.probe} is named by gate ${entry.id} in ${registryDocument.path} and its verifies-gate says ${backReference}`,
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
/* DIRECTION 2. */
|
|
1948
|
+
for (const [probeId, gateId] of verifiesGateByProbe) {
|
|
1949
|
+
if (gateIds.has(gateId)) {
|
|
1950
|
+
continue;
|
|
1951
|
+
}
|
|
1952
|
+
const index = probeIndexById.get(probeId) ?? 0;
|
|
1953
|
+
violations.push({
|
|
1954
|
+
pointer: `#/probes/${String(index)}/verifies-gate`,
|
|
1955
|
+
message: `probe ${probeId} verifies gate ${gateId}, which ${registryDocument.path} does not declare`,
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
return { violations, reports: [] };
|
|
1959
|
+
},
|
|
1960
|
+
};
|
|
1961
|
+
/* ------------------------------------------------------------------ */
|
|
1962
|
+
/* The verdict's three cross-document completeness checks */
|
|
1963
|
+
/* ------------------------------------------------------------------ */
|
|
1964
|
+
/**
|
|
1965
|
+
* Read the plan phase a verdict names, or say why not.
|
|
1966
|
+
*
|
|
1967
|
+
* THE JOIN KEY IS THE VERDICT'S `phase`, and the plan is read from a FIXED
|
|
1968
|
+
* relative path in the context directory, which is the shape
|
|
1969
|
+
* `mode-gate-sets-resolve` already uses for `gate-registry.yaml`. Fail closed
|
|
1970
|
+
* at every step: an unreadable plan, a plan declaring no such phase and a
|
|
1971
|
+
* plan whose phases are not a list are all violations, never silent passes,
|
|
1972
|
+
* because a completeness rule that cannot find its other document has not
|
|
1973
|
+
* been satisfied, it has not run.
|
|
1974
|
+
*/
|
|
1975
|
+
function readVerdictPlanPhase(instance, contextDirectory, pointer) {
|
|
1976
|
+
const verdict = asRecord(instance);
|
|
1977
|
+
const phaseId = verdict?.["phase"];
|
|
1978
|
+
if (typeof phaseId !== "string") {
|
|
1979
|
+
return {
|
|
1980
|
+
ok: false,
|
|
1981
|
+
violation: {
|
|
1982
|
+
pointer: "#/phase",
|
|
1983
|
+
message: "the verdict names no phase, so no plan phase can be resolved",
|
|
1984
|
+
},
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
const planDocument = readContextDocument(contextDirectory, "plan.yaml");
|
|
1988
|
+
if (!planDocument.ok) {
|
|
1989
|
+
return {
|
|
1990
|
+
ok: false,
|
|
1991
|
+
violation: {
|
|
1992
|
+
pointer,
|
|
1993
|
+
message: `the plan could not be read, so completeness against phase ${phaseId} could not be checked: ${planDocument.reason}`,
|
|
1994
|
+
},
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
for (const candidate of asArray(asRecord(planDocument.value)?.["phases"])) {
|
|
1998
|
+
const record = asRecord(candidate);
|
|
1999
|
+
if (record?.["id"] === phaseId) {
|
|
2000
|
+
return { ok: true, phase: record, path: planDocument.path };
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
return {
|
|
2004
|
+
ok: false,
|
|
2005
|
+
violation: {
|
|
2006
|
+
pointer: "#/phase",
|
|
2007
|
+
message: `${planDocument.path} declares no phase ${phaseId}, so this verdict reviews a phase the plan does not have`,
|
|
2008
|
+
},
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
/** The `id` of every element of one array-of-objects field, in order. */
|
|
2012
|
+
function idsOf(record, key, idKey) {
|
|
2013
|
+
const ids = [];
|
|
2014
|
+
for (const entry of asArray(record?.[key])) {
|
|
2015
|
+
const value = asRecord(entry)?.[idKey];
|
|
2016
|
+
if (typeof value === "string") {
|
|
2017
|
+
ids.push(value);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
return ids;
|
|
2021
|
+
}
|
|
2022
|
+
/* ------------------------------------------------------------------ */
|
|
2023
|
+
/* verdict-criteria-complete (M3-P7 step 6b, criterion 4b(a)) */
|
|
2024
|
+
/* ------------------------------------------------------------------ */
|
|
2025
|
+
/**
|
|
2026
|
+
* A verdict's `criteria[]` carries one entry per acceptance criterion of the
|
|
2027
|
+
* plan phase it reviews.
|
|
2028
|
+
*
|
|
2029
|
+
* THE DANGEROUS INSTANCE is a review that quietly skipped a criterion: every
|
|
2030
|
+
* entry present is well formed, the schema is satisfied, and the one
|
|
2031
|
+
* criterion nobody walked is invisible. R-053 says each criterion is quoted
|
|
2032
|
+
* with evidence and a verdict, and "each" is a comparison against a DIFFERENT
|
|
2033
|
+
* document, which no keyword reaches.
|
|
2034
|
+
*
|
|
2035
|
+
* BOTH DIRECTIONS, because they are different mistakes. A criterion the
|
|
2036
|
+
* verdict omits is an unwalked criterion; a verdict entry naming a criterion
|
|
2037
|
+
* the phase does not declare is a review walking something that is not in the
|
|
2038
|
+
* contract, usually a criterion id left behind by a plan revision.
|
|
2039
|
+
*/
|
|
2040
|
+
export const verdictCriteriaComplete = {
|
|
2041
|
+
id: "verdict-criteria-complete",
|
|
2042
|
+
type: "verdict",
|
|
2043
|
+
requiresContext: true,
|
|
2044
|
+
run(instance, contextDirectory) {
|
|
2045
|
+
if (contextDirectory === undefined) {
|
|
2046
|
+
return {
|
|
2047
|
+
violations: [
|
|
2048
|
+
{ pointer: "#/criteria", message: "no context directory was supplied" },
|
|
2049
|
+
],
|
|
2050
|
+
reports: [],
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
const resolved = readVerdictPlanPhase(instance, contextDirectory, "#/criteria");
|
|
2054
|
+
if (!resolved.ok) {
|
|
2055
|
+
return { violations: [resolved.violation], reports: [] };
|
|
2056
|
+
}
|
|
2057
|
+
const declared = idsOf(resolved.phase, "acceptance", "id");
|
|
2058
|
+
const walked = new Set(idsOf(asRecord(instance), "criteria", "id"));
|
|
2059
|
+
const violations = [];
|
|
2060
|
+
for (const id of declared) {
|
|
2061
|
+
if (!walked.has(id)) {
|
|
2062
|
+
violations.push({
|
|
2063
|
+
pointer: "#/criteria",
|
|
2064
|
+
message: `acceptance criterion ${id} of phase ${String(asRecord(instance)?.["phase"])} in ${resolved.path} has no entry, so this review did not walk it`,
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
const declaredSet = new Set(declared);
|
|
2069
|
+
const walkedIds = idsOf(asRecord(instance), "criteria", "id");
|
|
2070
|
+
for (let index = 0; index < walkedIds.length; index += 1) {
|
|
2071
|
+
const id = walkedIds[index];
|
|
2072
|
+
if (!declaredSet.has(id)) {
|
|
2073
|
+
violations.push({
|
|
2074
|
+
pointer: `#/criteria/${String(index)}/id`,
|
|
2075
|
+
message: `criterion ${id} is walked here and ${resolved.path} declares no such acceptance criterion on this phase`,
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
return { violations, reports: [] };
|
|
2080
|
+
},
|
|
2081
|
+
};
|
|
2082
|
+
/* ------------------------------------------------------------------ */
|
|
2083
|
+
/* verdict-deviations-judged (M3-P7 step 6b, criterion 4b(b), M3R-005) */
|
|
2084
|
+
/* ------------------------------------------------------------------ */
|
|
2085
|
+
/**
|
|
2086
|
+
* A verdict's `deviations-judged[]` carries one entry per deviation declared
|
|
2087
|
+
* in the phase's work history.
|
|
2088
|
+
*
|
|
2089
|
+
* M3R-005 IS WHY THIS IS A CHECK AND NOT A PROBE. R-057b's "judged, never
|
|
2090
|
+
* assumed by the implementer" has exactly the same completeness shape as
|
|
2091
|
+
* criteria coverage, and revision 0 had left it as a bare probe question for
|
|
2092
|
+
* no stated reason, so a reviewer could silently skip judging one of three
|
|
2093
|
+
* declared deviations and every criterion still passed.
|
|
2094
|
+
*
|
|
2095
|
+
* THE OTHER DOCUMENT IS `work-history.yaml` in the context directory, and it
|
|
2096
|
+
* must be the work history OF THE PHASE THIS VERDICT NAMES: a work history
|
|
2097
|
+
* for another phase would let the check pass by comparing against the wrong
|
|
2098
|
+
* deviation list, which is a vacuous pass wearing a cross-document check's
|
|
2099
|
+
* clothes.
|
|
2100
|
+
*/
|
|
2101
|
+
export const verdictDeviationsJudged = {
|
|
2102
|
+
id: "verdict-deviations-judged",
|
|
2103
|
+
type: "verdict",
|
|
2104
|
+
requiresContext: true,
|
|
2105
|
+
run(instance, contextDirectory) {
|
|
2106
|
+
if (contextDirectory === undefined) {
|
|
2107
|
+
return {
|
|
2108
|
+
violations: [
|
|
2109
|
+
{ pointer: "#/deviations-judged", message: "no context directory was supplied" },
|
|
2110
|
+
],
|
|
2111
|
+
reports: [],
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
const verdict = asRecord(instance);
|
|
2115
|
+
const phaseId = verdict?.["phase"];
|
|
2116
|
+
if (typeof phaseId !== "string") {
|
|
2117
|
+
return {
|
|
2118
|
+
violations: [
|
|
2119
|
+
{
|
|
2120
|
+
pointer: "#/phase",
|
|
2121
|
+
message: "the verdict names no phase, so no work history can be resolved",
|
|
2122
|
+
},
|
|
2123
|
+
],
|
|
2124
|
+
reports: [],
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
const history = readContextDocument(contextDirectory, "work-history.yaml");
|
|
2128
|
+
if (!history.ok) {
|
|
2129
|
+
return {
|
|
2130
|
+
violations: [
|
|
2131
|
+
{
|
|
2132
|
+
pointer: "#/deviations-judged",
|
|
2133
|
+
message: `the work history could not be read, so the declared deviations could not be compared: ${history.reason}`,
|
|
2134
|
+
},
|
|
2135
|
+
],
|
|
2136
|
+
reports: [],
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
const historyRecord = asRecord(history.value);
|
|
2140
|
+
if (historyRecord?.["phase"] !== phaseId) {
|
|
2141
|
+
return {
|
|
2142
|
+
violations: [
|
|
2143
|
+
{
|
|
2144
|
+
pointer: "#/deviations-judged",
|
|
2145
|
+
message: `${history.path} is the work history of phase ${String(historyRecord?.["phase"])} and this verdict reviews ${phaseId}, so the deviations compared would be the wrong ones`,
|
|
2146
|
+
},
|
|
2147
|
+
],
|
|
2148
|
+
reports: [],
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
const declared = idsOf(historyRecord, "deviations", "plan-clause");
|
|
2152
|
+
const judged = idsOf(verdict, "deviations-judged", "deviation");
|
|
2153
|
+
const judgedSet = new Set(judged);
|
|
2154
|
+
const violations = [];
|
|
2155
|
+
for (const clause of declared) {
|
|
2156
|
+
if (!judgedSet.has(clause)) {
|
|
2157
|
+
violations.push({
|
|
2158
|
+
pointer: "#/deviations-judged",
|
|
2159
|
+
message: `deviation ${clause} is declared in ${history.path} and this review did not judge it`,
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
const declaredSet = new Set(declared);
|
|
2164
|
+
for (let index = 0; index < judged.length; index += 1) {
|
|
2165
|
+
const clause = judged[index];
|
|
2166
|
+
if (!declaredSet.has(clause)) {
|
|
2167
|
+
violations.push({
|
|
2168
|
+
pointer: `#/deviations-judged/${String(index)}/deviation`,
|
|
2169
|
+
message: `deviation ${clause} is judged here and ${history.path} declares no such deviation`,
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
return { violations, reports: [] };
|
|
2174
|
+
},
|
|
2175
|
+
};
|
|
2176
|
+
/* ------------------------------------------------------------------ */
|
|
2177
|
+
/* verdict-hazard-classes-addressed (M3-P7 step 6b, criterion 4e) */
|
|
2178
|
+
/* ------------------------------------------------------------------ */
|
|
2179
|
+
/**
|
|
2180
|
+
* A HAZARD verdict's `hazard-classes-addressed[]` carries one entry per
|
|
2181
|
+
* hazard class declared by the plan phase it reviews.
|
|
2182
|
+
*
|
|
2183
|
+
* T-007 IS THE INPUT AND M3R-005 IS THE SHAPE. This has exactly the shape
|
|
2184
|
+
* `verdict-criteria-complete` has for criteria, one field along, and for
|
|
2185
|
+
* exactly the same reason: a reviewer could otherwise silently skip one of
|
|
2186
|
+
* three declared hazard classes while every other criterion still passed.
|
|
2187
|
+
* T-007's measured case is a phase meeting fifteen of fifteen executed
|
|
2188
|
+
* criteria while live-locking every supervision command.
|
|
2189
|
+
*
|
|
2190
|
+
* IT APPLIES EXACTLY WHERE THE CONTRACT APPLIES. A verdict whose
|
|
2191
|
+
* `review-contract` is `criteria` is not asserted against, because the
|
|
2192
|
+
* criteria contract is not the one that owes hazard statements, and a check
|
|
2193
|
+
* that reddened on it would push reviewers to fill the array with nothing.
|
|
2194
|
+
* That the criteria arm is unaffected is asserted by a test rather than left
|
|
2195
|
+
* as an implication.
|
|
2196
|
+
*/
|
|
2197
|
+
export const verdictHazardClassesAddressed = {
|
|
2198
|
+
id: "verdict-hazard-classes-addressed",
|
|
2199
|
+
type: "verdict",
|
|
2200
|
+
requiresContext: true,
|
|
2201
|
+
run(instance, contextDirectory) {
|
|
2202
|
+
if (contextDirectory === undefined) {
|
|
2203
|
+
return {
|
|
2204
|
+
violations: [
|
|
2205
|
+
{
|
|
2206
|
+
pointer: "#/hazard-classes-addressed",
|
|
2207
|
+
message: "no context directory was supplied",
|
|
2208
|
+
},
|
|
2209
|
+
],
|
|
2210
|
+
reports: [],
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
const verdict = asRecord(instance);
|
|
2214
|
+
if (verdict?.["review-contract"] !== "hazard") {
|
|
2215
|
+
return EMPTY;
|
|
2216
|
+
}
|
|
2217
|
+
const resolved = readVerdictPlanPhase(instance, contextDirectory, "#/hazard-classes-addressed");
|
|
2218
|
+
if (!resolved.ok) {
|
|
2219
|
+
return { violations: [resolved.violation], reports: [] };
|
|
2220
|
+
}
|
|
2221
|
+
const declared = idsOf(resolved.phase, "hazard-classes", "id");
|
|
2222
|
+
const addressed = idsOf(verdict, "hazard-classes-addressed", "class-id");
|
|
2223
|
+
const addressedSet = new Set(addressed);
|
|
2224
|
+
const violations = [];
|
|
2225
|
+
for (const id of declared) {
|
|
2226
|
+
if (!addressedSet.has(id)) {
|
|
2227
|
+
violations.push({
|
|
2228
|
+
pointer: "#/hazard-classes-addressed",
|
|
2229
|
+
message: `hazard class ${id} of phase ${String(verdict["phase"])} in ${resolved.path} has no entry, so this hazard review did not address it`,
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
const declaredSet = new Set(declared);
|
|
2234
|
+
for (let index = 0; index < addressed.length; index += 1) {
|
|
2235
|
+
const id = addressed[index];
|
|
2236
|
+
if (!declaredSet.has(id)) {
|
|
2237
|
+
violations.push({
|
|
2238
|
+
pointer: `#/hazard-classes-addressed/${String(index)}/class-id`,
|
|
2239
|
+
message: `hazard class ${id} is addressed here and ${resolved.path} declares no such class on this phase`,
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
return { violations, reports: [] };
|
|
2244
|
+
},
|
|
2245
|
+
};
|
|
2246
|
+
/* ------------------------------------------------------------------ */
|
|
2247
|
+
/* verdict-finding-references-resolve (M3-P7 fix round 2, H-1) */
|
|
2248
|
+
/* ------------------------------------------------------------------ */
|
|
2249
|
+
/**
|
|
2250
|
+
* Every `hazard-classes-addressed[].finding` names a `findings[].id` that
|
|
2251
|
+
* exists in the SAME verdict.
|
|
2252
|
+
*
|
|
2253
|
+
* KIND B FOR THE SAME REASON `checklist-probe-ids-unique` IS, AND IT IS THE
|
|
2254
|
+
* ONLY INTRA-DOCUMENT ID REFERENCE THE SHIPPED SCHEMAS DECLARE. The
|
|
2255
|
+
* verdict schema's own `$comment` on `finding` calls it "the `findings[].id`
|
|
2256
|
+
* this class produced", so the join is DECLARED; nothing resolved it, so it
|
|
2257
|
+
* was a bare string with `minLength: 1`. Resolving one array's entry against
|
|
2258
|
+
* another array's ids is not a keyword property under any DR-0013 option,
|
|
2259
|
+
* which is why it lands here and not in the schema.
|
|
2260
|
+
*
|
|
2261
|
+
* WHAT IT PROTECTS, and it is not merely tidiness. The verdict schema ships
|
|
2262
|
+
* exactly ONE rule that can force a verdict off APPROVE: a `findings[]` set
|
|
2263
|
+
* containing a `high` or `critical` entry must carry FIX-ROUND-NEEDED. That
|
|
2264
|
+
* rule reads `findings[]` and nothing else. So a hazard reviewer who records
|
|
2265
|
+
* a class as having produced a finding, and leaves that finding out of
|
|
2266
|
+
* `findings[]`, gets a schema-valid APPROVE with an empty findings array and
|
|
2267
|
+
* the escalation rule never sees the finding it would have fired on. Measured
|
|
2268
|
+
* at 4bfa790 before this check: such a document validated at exit 0, and the
|
|
2269
|
+
* same document with the finding moved into `findings[]` at `severity: high`
|
|
2270
|
+
* exited 1.
|
|
2271
|
+
*
|
|
2272
|
+
* A DANGLING REFERENCE IS ITSELF THE ERROR, not only one that lets the
|
|
2273
|
+
* escalation be evaded, and the reason is that the narrower rule is not
|
|
2274
|
+
* computable. A finding absent from `findings[]` has NO severity, so nothing
|
|
2275
|
+
* can decide whether it would have escalated; the narrower reading would have
|
|
2276
|
+
* to guess, and would clear exactly the document that withheld the most.
|
|
2277
|
+
* Requiring the reference to resolve is decidable, and it puts the severity
|
|
2278
|
+
* back under the escalation rule where the reader can see it.
|
|
2279
|
+
*
|
|
2280
|
+
* `requiresContext` is FALSE: the whole comparison is inside one document.
|
|
2281
|
+
*/
|
|
2282
|
+
export const verdictFindingReferencesResolve = {
|
|
2283
|
+
id: "verdict-finding-references-resolve",
|
|
2284
|
+
type: "verdict",
|
|
2285
|
+
requiresContext: false,
|
|
2286
|
+
run(instance) {
|
|
2287
|
+
const verdict = asRecord(instance);
|
|
2288
|
+
const findingIds = new Set();
|
|
2289
|
+
for (const entry of asArray(verdict?.["findings"])) {
|
|
2290
|
+
const id = asRecord(entry)?.["id"];
|
|
2291
|
+
if (typeof id === "string") {
|
|
2292
|
+
findingIds.add(id);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
const addressed = asArray(verdict?.["hazard-classes-addressed"]);
|
|
2296
|
+
const violations = [];
|
|
2297
|
+
for (let index = 0; index < addressed.length; index += 1) {
|
|
2298
|
+
const reference = asRecord(addressed[index])?.["finding"];
|
|
2299
|
+
if (typeof reference !== "string" || findingIds.has(reference)) {
|
|
2300
|
+
continue;
|
|
2301
|
+
}
|
|
2302
|
+
/* NAMES THE CONSEQUENCE, not just the dangling id. An author told only
|
|
2303
|
+
that a reference does not resolve reads it as a typo; the sentence
|
|
2304
|
+
that matters is that the escalation rule reads `findings[]` alone. */
|
|
2305
|
+
violations.push({
|
|
2306
|
+
pointer: `#/hazard-classes-addressed/${String(index)}/finding`,
|
|
2307
|
+
message: `finding ${reference} is named by hazard class ${String(asRecord(addressed[index])?.["class-id"] ?? "(unnamed)")} and no findings[] entry declares that id, so the verdict's escalation rule cannot see it`,
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
return { violations, reports: [] };
|
|
2311
|
+
},
|
|
2312
|
+
};
|
|
2313
|
+
/* ------------------------------------------------------------------ */
|
|
2314
|
+
/* tuition-target-exists (M3-P8 criterion 3a) */
|
|
2315
|
+
/* ------------------------------------------------------------------ */
|
|
2316
|
+
/**
|
|
2317
|
+
* A `structural-consequence` marked `applied` names a target path that EXISTS.
|
|
2318
|
+
*
|
|
2319
|
+
* KIND B BY NECESSITY: it resolves a string against the filesystem, which no
|
|
2320
|
+
* keyword under any DR-0013 option reaches. `requiresContext` is TRUE, so
|
|
2321
|
+
* running the validator without `--context` prints `SKIPPED
|
|
2322
|
+
* tuition-target-exists no context` and exits nonzero rather than passing by
|
|
2323
|
+
* not running.
|
|
2324
|
+
*
|
|
2325
|
+
* ONLY `applied` IS CHECKED, and that is the point rather than a limitation.
|
|
2326
|
+
* `proposed` names a change nobody has made and `ticketed` names one carried
|
|
2327
|
+
* by a record, so neither claims anything about the tree; `applied` claims the
|
|
2328
|
+
* change is IN the tree, and T-003 is the entry recording that a document can
|
|
2329
|
+
* carry exactly that claim falsely.
|
|
2330
|
+
*
|
|
2331
|
+
* WHAT IT DOES NOT REACH, named here because criterion 3 reads at a glance as
|
|
2332
|
+
* though it covered the whole hazard: whether the file CONTAINS the change
|
|
2333
|
+
* claimed. That is a semantic relation between a prose sentence and a file,
|
|
2334
|
+
* and the plan's own hazard table assigns it to review rather than to a check
|
|
2335
|
+
* (section 2.6 reason 1). The two halves are exactly what this project has
|
|
2336
|
+
* repeatedly found to differ, so the check states which half it is.
|
|
2337
|
+
*/
|
|
2338
|
+
export const tuitionTargetExists = {
|
|
2339
|
+
id: "tuition-target-exists",
|
|
2340
|
+
type: "tuition",
|
|
2341
|
+
requiresContext: true,
|
|
2342
|
+
run(instance, contextDirectory) {
|
|
2343
|
+
if (contextDirectory === undefined) {
|
|
2344
|
+
/* Unreachable through `runChecks`, which SKIPS first. Fail closed rather
|
|
2345
|
+
than trusting a caller that reaches the check directly. */
|
|
2346
|
+
return {
|
|
2347
|
+
violations: [
|
|
2348
|
+
{
|
|
2349
|
+
pointer: "#/structural-consequence",
|
|
2350
|
+
message: "no context directory was supplied",
|
|
2351
|
+
},
|
|
2352
|
+
],
|
|
2353
|
+
reports: [],
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
const record = asRecord(instance);
|
|
2357
|
+
if (record === undefined) {
|
|
2358
|
+
return EMPTY;
|
|
2359
|
+
}
|
|
2360
|
+
const violations = [];
|
|
2361
|
+
const consequences = asArray(record["structural-consequence"]);
|
|
2362
|
+
let resolved = 0;
|
|
2363
|
+
let unresolvable = 0;
|
|
2364
|
+
const trees = new Set();
|
|
2365
|
+
for (let index = 0; index < consequences.length; index += 1) {
|
|
2366
|
+
const consequence = asRecord(consequences[index]);
|
|
2367
|
+
if (consequence === undefined || consequence["status"] !== "applied") {
|
|
2368
|
+
continue;
|
|
2369
|
+
}
|
|
2370
|
+
const target = consequence["target"];
|
|
2371
|
+
if (typeof target !== "string") {
|
|
2372
|
+
continue;
|
|
2373
|
+
}
|
|
2374
|
+
/* HRB-8's mechanism reaches THIS check too, and neither review named it.
|
|
2375
|
+
A target is a kernel-artifact path relative to the repository the entry
|
|
2376
|
+
came from; four of them name `src/` and one names `test/`, neither of
|
|
2377
|
+
which ships. See unresolvableCitationTree. */
|
|
2378
|
+
const absentTree = unresolvableCitationTree(contextDirectory, target);
|
|
2379
|
+
if (absentTree !== undefined) {
|
|
2380
|
+
unresolvable += 1;
|
|
2381
|
+
trees.add(`${absentTree}/`);
|
|
2382
|
+
continue;
|
|
2383
|
+
}
|
|
2384
|
+
resolved += 1;
|
|
2385
|
+
if (classifyEntry(join(contextDirectory, target)).kind === "absent") {
|
|
2386
|
+
violations.push({
|
|
2387
|
+
pointer: `#/structural-consequence/${String(index)}/target`,
|
|
2388
|
+
message: `structural consequence is marked applied and its target ${target} does not exist`,
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
return {
|
|
2393
|
+
violations,
|
|
2394
|
+
reports: [
|
|
2395
|
+
...(resolved === 0
|
|
2396
|
+
? []
|
|
2397
|
+
: [`REPORT tuition-target-exists ${String(resolved)} applied target(s) resolved`]),
|
|
2398
|
+
...unresolvedTreeReport("tuition-target-exists", unresolvable, trees),
|
|
2399
|
+
],
|
|
2400
|
+
};
|
|
2401
|
+
},
|
|
2402
|
+
};
|
|
2403
|
+
/* ------------------------------------------------------------------ */
|
|
2404
|
+
/* mechanism-rule-evidence-resolves (M3-P8 criteria 3b and 4b) */
|
|
2405
|
+
/* ------------------------------------------------------------------ */
|
|
2406
|
+
/**
|
|
2407
|
+
* A PATH REFERENCE inside a `mechanisms[]` entry resolves against the tree,
|
|
2408
|
+
* and a `machine-readable-form` resolves to a real document AND a real key
|
|
2409
|
+
* inside it.
|
|
2410
|
+
*
|
|
2411
|
+
* T-005's checkability rule has two halves and they need two instruments. The
|
|
2412
|
+
* SCHEMA half is `evidence` with `minItems: 1`: a rule with no citation is not
|
|
2413
|
+
* a rule. THIS half is that a citation naming a file which does not exist is
|
|
2414
|
+
* not a citation, which is a filesystem question and therefore Kind B.
|
|
2415
|
+
*
|
|
2416
|
+
* WHAT COUNTS AS A PATH REFERENCE, stated mechanically because a checker whose
|
|
2417
|
+
* subject is vague cannot be falsified: a whitespace-delimited token holding at
|
|
2418
|
+
* least one `/` and ending in a short extension, with surrounding backticks,
|
|
2419
|
+
* brackets and trailing punctuation stripped. Real evidence in this feed reads
|
|
2420
|
+
* `delivery/review/verification-m1-p3-fix-round.md V-1 and V-3`, so the
|
|
2421
|
+
* reference is a token inside a sentence rather than the whole string.
|
|
2422
|
+
*
|
|
2423
|
+
* A `path.ext:LINE` CITATION IS A PATH REFERENCE (HRB-1, fix round 3). It is the
|
|
2424
|
+
* form CLAUDE.md:155 mandates, and the earlier token test silently dropped every
|
|
2425
|
+
* one of them; see `pathReferencesIn` for the measurement and the grammar.
|
|
2426
|
+
*
|
|
2427
|
+
* WHAT IT DOES NOT REACH, and these are real holes rather than tidy ones.
|
|
2428
|
+
*
|
|
2429
|
+
* PROSE-ONLY evidence. `M1-P5 round 4, verified pre-existing against a pristine
|
|
2430
|
+
* build` names no path, so nothing about it is resolvable and this check says
|
|
2431
|
+
* nothing about it. Requiring every citation to be a path would redden entries
|
|
2432
|
+
* whose evidence is a measurement rather than a document, which is a real form
|
|
2433
|
+
* of evidence this project uses. The residue is therefore deliberate: the check
|
|
2434
|
+
* establishes that the paths cited EXIST, never that a rule is supported.
|
|
2435
|
+
*
|
|
2436
|
+
* A CITATION INTO A TREE THIS CONTEXT DOES NOT HAVE (HRB-8, fix round 3). The
|
|
2437
|
+
* feed ships and `delivery/` does not, so in a consumer's install most citations
|
|
2438
|
+
* name a repository that is not there. Those are REPORTED, with their count and
|
|
2439
|
+
* the trees involved, and never counted as violations; see
|
|
2440
|
+
* `unresolvableCitationTree` for why that is the correct answer rather than a
|
|
2441
|
+
* softening, and for the reason it is not a silent pass.
|
|
2442
|
+
*
|
|
2443
|
+
* REGISTERED FOR BOTH TYPES. `mechanisms[]` appears in a tuition entry (where
|
|
2444
|
+
* a rule is authored) and in the mechanism index (where it is projected). A
|
|
2445
|
+
* check registered only for the first would leave the shipped index unchecked,
|
|
2446
|
+
* which is the shared-definition asymmetry `alsoTypes` exists for.
|
|
2447
|
+
*/
|
|
2448
|
+
export const mechanismRuleEvidenceResolves = {
|
|
2449
|
+
id: "mechanism-rule-evidence-resolves",
|
|
2450
|
+
type: "tuition",
|
|
2451
|
+
alsoTypes: ["mechanism-index"],
|
|
2452
|
+
requiresContext: true,
|
|
2453
|
+
run(instance, contextDirectory) {
|
|
2454
|
+
if (contextDirectory === undefined) {
|
|
2455
|
+
return {
|
|
2456
|
+
violations: [
|
|
2457
|
+
{ pointer: "#/mechanisms", message: "no context directory was supplied" },
|
|
2458
|
+
],
|
|
2459
|
+
reports: [],
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
const record = asRecord(instance);
|
|
2463
|
+
if (record === undefined) {
|
|
2464
|
+
return EMPTY;
|
|
2465
|
+
}
|
|
2466
|
+
const violations = [];
|
|
2467
|
+
const mechanisms = asArray(record["mechanisms"]);
|
|
2468
|
+
let resolved = 0;
|
|
2469
|
+
let unresolvable = 0;
|
|
2470
|
+
const trees = new Set();
|
|
2471
|
+
for (let index = 0; index < mechanisms.length; index += 1) {
|
|
2472
|
+
const mechanism = asRecord(mechanisms[index]);
|
|
2473
|
+
if (mechanism === undefined) {
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
const evidence = asArray(mechanism["evidence"]);
|
|
2477
|
+
for (let position = 0; position < evidence.length; position += 1) {
|
|
2478
|
+
const reference = evidence[position];
|
|
2479
|
+
if (typeof reference !== "string") {
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
for (const path of pathReferencesIn(reference)) {
|
|
2483
|
+
const absentTree = unresolvableCitationTree(contextDirectory, path);
|
|
2484
|
+
if (absentTree !== undefined) {
|
|
2485
|
+
unresolvable += 1;
|
|
2486
|
+
trees.add(`${absentTree}/`);
|
|
2487
|
+
continue;
|
|
2488
|
+
}
|
|
2489
|
+
resolved += 1;
|
|
2490
|
+
if (classifyEntry(join(contextDirectory, path)).kind === "absent") {
|
|
2491
|
+
violations.push({
|
|
2492
|
+
pointer: `#/mechanisms/${String(index)}/evidence/${String(position)}`,
|
|
2493
|
+
message: `evidence names ${path}, which does not exist`,
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
const machine = asRecord(mechanism["machine-readable-form"]);
|
|
2499
|
+
if (machine === undefined) {
|
|
2500
|
+
continue;
|
|
2501
|
+
}
|
|
2502
|
+
const pointer = `#/mechanisms/${String(index)}/machine-readable-form`;
|
|
2503
|
+
const path = machine["path"];
|
|
2504
|
+
const key = machine["key"];
|
|
2505
|
+
if (typeof path !== "string" || typeof key !== "string") {
|
|
2506
|
+
continue;
|
|
2507
|
+
}
|
|
2508
|
+
/* The same predicate on the third site the derivation found. The one real
|
|
2509
|
+
`machine-readable-form` names `gates.manifest.json`, which SHIPS and
|
|
2510
|
+
still resolves; a future one naming a non-shipping tree would otherwise
|
|
2511
|
+
redden every consumer's install for a fact they cannot check. */
|
|
2512
|
+
const absentTree = unresolvableCitationTree(contextDirectory, path);
|
|
2513
|
+
if (absentTree !== undefined) {
|
|
2514
|
+
unresolvable += 1;
|
|
2515
|
+
trees.add(`${absentTree}/`);
|
|
2516
|
+
continue;
|
|
2517
|
+
}
|
|
2518
|
+
resolved += 1;
|
|
2519
|
+
const document = readContextDocument(contextDirectory, path);
|
|
2520
|
+
if (!document.ok) {
|
|
2521
|
+
violations.push({
|
|
2522
|
+
pointer: `${pointer}/path`,
|
|
2523
|
+
message: `machine-readable form names ${path}, which could not be read: ${document.reason}`,
|
|
2524
|
+
});
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2527
|
+
/* THE KEY IS RESOLVED, NOT THE PATH ALONE (D-M3-26, criterion 4b). A
|
|
2528
|
+
document that still exists under a key M2 renamed is exactly the drift
|
|
2529
|
+
this coupling exists to catch, and a path-only check would call it
|
|
2530
|
+
green. */
|
|
2531
|
+
if (asRecord(document.value)?.[key] === undefined) {
|
|
2532
|
+
violations.push({
|
|
2533
|
+
pointer: `${pointer}/key`,
|
|
2534
|
+
message: `machine-readable form names key ${key}, which ${path} does not carry`,
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
return {
|
|
2539
|
+
violations,
|
|
2540
|
+
reports: [
|
|
2541
|
+
...(resolved === 0
|
|
2542
|
+
? []
|
|
2543
|
+
: [
|
|
2544
|
+
`REPORT mechanism-rule-evidence-resolves ${String(resolved)} citation(s) resolved`,
|
|
2545
|
+
]),
|
|
2546
|
+
...unresolvedTreeReport("mechanism-rule-evidence-resolves", unresolvable, trees),
|
|
2547
|
+
],
|
|
2548
|
+
};
|
|
2549
|
+
},
|
|
2550
|
+
};
|
|
2551
|
+
/**
|
|
2552
|
+
* Every path-like token in one prose reference. See the check's header for the
|
|
2553
|
+
* definition and for what it deliberately does not treat as a path.
|
|
2554
|
+
*
|
|
2555
|
+
* THE `:LINE` SUFFIX IS STRIPPED BEFORE THE EXTENSION TEST (HRB-1, M3-P8 fix
|
|
2556
|
+
* round 3). CLAUDE.md:155 makes `path.ext:LINE` THE citation form in this
|
|
2557
|
+
* project ("a bare path is not a citation at all") and src/gates/citations.ts
|
|
2558
|
+
* is the gate that enforces it. The earlier form tested the extension at
|
|
2559
|
+
* end-of-string, and a line number sits after it, so every citation written the
|
|
2560
|
+
* way this repository REQUIRES resolved to nothing: an entry whose paths were
|
|
2561
|
+
* entirely fabricated validated at exit 0, and the byte-identical entry with
|
|
2562
|
+
* the suffixes removed went red. A check that passes exactly the mandated form
|
|
2563
|
+
* is not a check.
|
|
2564
|
+
*
|
|
2565
|
+
* The suffix grammar is the citations gate's own, narrowed to what a suffix can
|
|
2566
|
+
* be rather than re-derived: `:<line>`, an optional `-<line>` range, and an
|
|
2567
|
+
* optional `@sha256:<hex>` content pin (src/gates/citations.ts:453). Stripping
|
|
2568
|
+
* is deliberately conservative: a token that does not match keeps its colon and
|
|
2569
|
+
* is then judged by the extension test as before, so `http://x/y.md` and
|
|
2570
|
+
* `a/b.md:notaline` are unchanged.
|
|
2571
|
+
*/
|
|
2572
|
+
export function pathReferencesIn(reference) {
|
|
2573
|
+
const found = [];
|
|
2574
|
+
for (const raw of reference.split(/\s+/)) {
|
|
2575
|
+
const trimmed = raw.replace(/^[`("'[]+/, "").replace(/[`)"'\].,;]+$/, "");
|
|
2576
|
+
const token = trimmed
|
|
2577
|
+
.replace(/:\d+(?:-\d+)?(?:@sha256:[0-9a-zA-Z]+)?$/, "")
|
|
2578
|
+
.replace(/[`)"'\].,;:]+$/, "");
|
|
2579
|
+
if (token.includes("/") && /\.[A-Za-z0-9]{1,6}$/.test(token) && !token.startsWith("/")) {
|
|
2580
|
+
found.push(token);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
return found;
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* THE TREE A CITATION IS ROOTED IN, when this context does not contain it.
|
|
2587
|
+
* Returns that top-level name, or undefined when the citation IS resolvable
|
|
2588
|
+
* here and absence would therefore be a real defect.
|
|
2589
|
+
*
|
|
2590
|
+
* WHY (HRB-8, M3-P8 fix round 3). A citation is relative to the repository that
|
|
2591
|
+
* AUTHORED it. The tuition feed and its index ship in the npm package;
|
|
2592
|
+
* `delivery/`, `src/`, `scripts/` and `test/` do not (package.json's `files`).
|
|
2593
|
+
* So the checks that resolve a document-supplied path were asking a consumer's
|
|
2594
|
+
* install a question only the kernel repository can answer, and answering it
|
|
2595
|
+
* INVALID. Measured at 26ee653: the shipped index produced 16 INVALID lines
|
|
2596
|
+
* from a pristine `npm pack` extraction, and eight of the fifteen shipped
|
|
2597
|
+
* entries produced more. CI never saw it because this repository has
|
|
2598
|
+
* `delivery/`, which is T-009's shape one scope out.
|
|
2599
|
+
*
|
|
2600
|
+
* schemas/mechanism-index.schema.json:5 already stated the governing fact
|
|
2601
|
+
* before this round: resolution "is not computable from an installed package".
|
|
2602
|
+
* This is that sentence made operative rather than decorative.
|
|
2603
|
+
*
|
|
2604
|
+
* THE PREDICATE IS THE TOP-LEVEL SEGMENT, and it is the coarsest one that still
|
|
2605
|
+
* catches everything the kernel repository could catch before. A citation into a
|
|
2606
|
+
* tree that IS present must still resolve, so a fabricated
|
|
2607
|
+
* `delivery/review/invented.md` is as red here as it ever was; only a citation
|
|
2608
|
+
* into a tree that is wholly absent is excused. A path with no directory
|
|
2609
|
+
* component is NEVER excused, because the context root always exists: measured
|
|
2610
|
+
* against the real feed, every `applied` root-level target ships, and the one
|
|
2611
|
+
* root-level absentee (`AGENTS.md`) is `ticketed`, which the check does not read.
|
|
2612
|
+
*
|
|
2613
|
+
* THIS IS NOT A LICENCE TO GO QUIET. Every caller REPORTS what it declined to
|
|
2614
|
+
* resolve and why. "Nothing to check here" and "everything checked and fine"
|
|
2615
|
+
* must never print the same line, which is the SC-011 shape the plan's hazard
|
|
2616
|
+
* row at delivery/plan/kernel-plan-m3.md:4042 polices.
|
|
2617
|
+
*/
|
|
2618
|
+
export function unresolvableCitationTree(contextDirectory, path) {
|
|
2619
|
+
const slash = path.indexOf("/");
|
|
2620
|
+
if (slash <= 0) {
|
|
2621
|
+
return undefined;
|
|
2622
|
+
}
|
|
2623
|
+
const tree = path.slice(0, slash);
|
|
2624
|
+
return classifyEntry(join(contextDirectory, tree)).kind === "absent" ? tree : undefined;
|
|
2625
|
+
}
|
|
2626
|
+
/** One report line naming the trees a check declined to resolve into. */
|
|
2627
|
+
function unresolvedTreeReport(check, count, trees) {
|
|
2628
|
+
if (count === 0) {
|
|
2629
|
+
return [];
|
|
2630
|
+
}
|
|
2631
|
+
const named = [...trees].sort().join(", ");
|
|
2632
|
+
return [
|
|
2633
|
+
`REPORT ${check} ${String(count)} citation(s) not resolvable in this context: ` +
|
|
2634
|
+
`no ${named} tree here, so they name a repository this is not`,
|
|
2635
|
+
];
|
|
2636
|
+
}
|
|
2637
|
+
/* ------------------------------------------------------------------ */
|
|
2638
|
+
/* dual-review-decorrelation (M3-P9 step 3b, criteria 7 and 7b) */
|
|
2639
|
+
/* ------------------------------------------------------------------ */
|
|
2640
|
+
/** Where a project's committed review verdicts live (DR-0012 condition 1). */
|
|
2641
|
+
const REVIEW_DIRECTORY = join("delivery", "review");
|
|
2642
|
+
/** The three dimensions two verdicts of one head must differ on. */
|
|
2643
|
+
export const DECORRELATION_DIMENSIONS = [
|
|
2644
|
+
"produced-by",
|
|
2645
|
+
"framing",
|
|
2646
|
+
"review-contract",
|
|
2647
|
+
];
|
|
2648
|
+
/** The merge-authority value that makes decorrelation a precondition of merge. */
|
|
2649
|
+
export const DELEGATED_MERGE_AUTHORITY = "delegated-under-conditions";
|
|
2650
|
+
/**
|
|
2651
|
+
* Every verdict document committed under `<context>/delivery/review/`.
|
|
2652
|
+
*
|
|
2653
|
+
* A file that is not a regular file, does not decode, or does not carry
|
|
2654
|
+
* `kind: verdict` is SKIPPED rather than reported, because that directory also
|
|
2655
|
+
* holds this project's prose reviews and a check that reddened on a markdown
|
|
2656
|
+
* file would be unusable. What is NOT skipped is the directory being
|
|
2657
|
+
* unreadable, which the caller turns into a violation: "nothing to compare" and
|
|
2658
|
+
* "could not look" are different facts.
|
|
2659
|
+
*/
|
|
2660
|
+
function loadCommittedVerdicts(contextDirectory) {
|
|
2661
|
+
const directory = join(contextDirectory, REVIEW_DIRECTORY);
|
|
2662
|
+
/* `classifyEntry` HAS NO `directory` KIND: a directory lands in `irregular`,
|
|
2663
|
+
which is the kind that means "present and not safe to OPEN AS A FILE". So
|
|
2664
|
+
the shape here is the one `listWitnessSpecFiles` already uses: classify to
|
|
2665
|
+
rule out absent and unexaminable, then LIST, and read the classification
|
|
2666
|
+
again only to explain a listing failure. Testing for a kind that does not
|
|
2667
|
+
exist would have been dead code that always took the error arm. */
|
|
2668
|
+
const entry = classifyEntry(directory);
|
|
2669
|
+
if (entry.kind === "absent" || entry.kind === "dangling") {
|
|
2670
|
+
return { ok: true, verdicts: [] };
|
|
2671
|
+
}
|
|
2672
|
+
if (entry.kind === "unexaminable") {
|
|
2673
|
+
return { ok: false, reason: entry.reason };
|
|
2674
|
+
}
|
|
2675
|
+
let names;
|
|
2676
|
+
try {
|
|
2677
|
+
names = readdirSync(directory);
|
|
2678
|
+
}
|
|
2679
|
+
catch (error) {
|
|
2680
|
+
if (entry.kind === "regular") {
|
|
2681
|
+
return {
|
|
2682
|
+
ok: false,
|
|
2683
|
+
reason: `${directory} is a regular file, not a directory, so the committed verdicts cannot be enumerated`,
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
return { ok: false, reason: `${directory} could not be listed: ${String(error)}` };
|
|
2687
|
+
}
|
|
2688
|
+
const verdicts = [];
|
|
2689
|
+
for (const name of names.sort()) {
|
|
2690
|
+
if (!/\.(ya?ml|json)$/i.test(name)) {
|
|
2691
|
+
continue;
|
|
2692
|
+
}
|
|
2693
|
+
const path = join(directory, name);
|
|
2694
|
+
const read = readOperatorPath(path);
|
|
2695
|
+
if (!read.ok) {
|
|
2696
|
+
continue;
|
|
2697
|
+
}
|
|
2698
|
+
const decoded = decodeDocument(read.body, path);
|
|
2699
|
+
if (!decoded.ok) {
|
|
2700
|
+
continue;
|
|
2701
|
+
}
|
|
2702
|
+
const record = asRecord(decoded.value);
|
|
2703
|
+
/* CANONICAL HERE TOO, AND THE REASON IS THE SAME ONE ONE LAYER OUT. This
|
|
2704
|
+
`===` decides MEMBERSHIP OF THE GROUP the decorrelation decision is made
|
|
2705
|
+
over, so a lookalike character in `kind` does not produce a wrong
|
|
2706
|
+
comparison, it silently removes a document from the comparison. With
|
|
2707
|
+
three verdicts, two of them sharing a model family, dropping one of the
|
|
2708
|
+
correlated pair leaves two distinct ones and a green run. That is the
|
|
2709
|
+
same fail-open outcome as the reported finding, reached by making the
|
|
2710
|
+
check look at less rather than by making it compare wrongly.
|
|
2711
|
+
|
|
2712
|
+
Canonicalising ADMITS more documents, which is the fail-closed direction
|
|
2713
|
+
here: more verdicts in the group means more chances to find a shared
|
|
2714
|
+
value, never fewer. A file that is not a verdict at all still fails this
|
|
2715
|
+
test, because no canonical form turns a prose review into `verdict`. */
|
|
2716
|
+
if (record === undefined) {
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
const kindReading = establishField(record, "kind");
|
|
2720
|
+
if (kindReading.kind !== "established" || kindReading.value !== "verdict") {
|
|
2721
|
+
continue;
|
|
2722
|
+
}
|
|
2723
|
+
verdicts.push({ path, record });
|
|
2724
|
+
}
|
|
2725
|
+
return { ok: true, verdicts };
|
|
2726
|
+
}
|
|
2727
|
+
/**
|
|
2728
|
+
* THE CANONICAL FORM OF A GOVERNANCE SCALAR, DECLARED HERE BECAUSE A
|
|
2729
|
+
* COMPARISON WITHOUT A DECLARED CANONICAL FORM IS THE FIX-ROUND-2 MECHANISM.
|
|
2730
|
+
*
|
|
2731
|
+
* THE MECHANISM: two strings are compared for EQUALITY or DISTINCTNESS without
|
|
2732
|
+
* a declared canonical form, so two REPRESENTATIONS of one value read as two
|
|
2733
|
+
* different values. Round 1 closed "absent versus present-and-differing". This
|
|
2734
|
+
* closes "differently represented versus different", which is the same check
|
|
2735
|
+
* one layer down.
|
|
2736
|
+
*
|
|
2737
|
+
* WHY IT IS SAFE TO COLLAPSE HARD HERE, which is the argument that decides
|
|
2738
|
+
* every choice below. This check REFUSES when two reviews are NOT distinct, so
|
|
2739
|
+
* any rule that makes MORE strings compare as equal produces MORE refusals.
|
|
2740
|
+
* Aggressive canonicalisation is the FAIL-CLOSED direction; timid
|
|
2741
|
+
* canonicalisation is what leaves the hole. The one call site where collapsing
|
|
2742
|
+
* is instead mildly permissive is named at `decorrelationTriple` below rather
|
|
2743
|
+
* than left to be found.
|
|
2744
|
+
*
|
|
2745
|
+
* THE FORM, in order, and the order is load-bearing:
|
|
2746
|
+
*
|
|
2747
|
+
* 1. NFKC. Folds compatibility variants onto their ordinary forms, so
|
|
2748
|
+
* FULLWIDTH LATIN SMALL LETTER A (U+FF41) becomes `a` and NO-BREAK SPACE
|
|
2749
|
+
* (U+00A0) becomes a space. Measured: of the five lookalike substitutions
|
|
2750
|
+
* that defeated the previous code, NFKC folds exactly ONE. That
|
|
2751
|
+
* measurement is why step 2 exists and is not decoration.
|
|
2752
|
+
* 2. PRINTABLE ASCII ONLY (U+0020 to U+007E). Anything else is REFUSED, not
|
|
2753
|
+
* repaired. This is what actually closes the class: NFKC leaves CYRILLIC
|
|
2754
|
+
* SMALL LETTER A (U+0430), EN DASH (U+2013), ZERO WIDTH SPACE (U+200B)
|
|
2755
|
+
* and SOFT HYPHEN (U+00AD) exactly as they were, all four measured, and
|
|
2756
|
+
* no Unicode normalisation form folds a cross-script homoglyph onto its
|
|
2757
|
+
* lookalike. Closing those by normalisation would need a confusables
|
|
2758
|
+
* table this package does not carry and which goes stale; refusing the
|
|
2759
|
+
* character set needs no table and cannot go stale.
|
|
2760
|
+
* 3. Whitespace runs collapse to one space, then trim. Whitespace carries no
|
|
2761
|
+
* information in a scalar identifier (round 1's argument, kept).
|
|
2762
|
+
* 4. ASCII case fold. See the CR-003 note at `establishField`.
|
|
2763
|
+
*
|
|
2764
|
+
* WHY REFUSE AN INVISIBLE CHARACTER RATHER THAN STRIP IT. Stripping is also
|
|
2765
|
+
* fail-closed and was the other real option. Refusing is chosen because a
|
|
2766
|
+
* document carrying a zero-width space in a model-family id is a document that
|
|
2767
|
+
* reads one way to a human and another way to the program, and silently
|
|
2768
|
+
* repairing it would hand back a green having never said so. That is SC-011's
|
|
2769
|
+
* rule, which this file already applies one screen up: "could not look" must
|
|
2770
|
+
* never print as "looked and fine", and "looked, and what I found was built to
|
|
2771
|
+
* deceive the reader" is the same fact. A refusal names the codepoint and its
|
|
2772
|
+
* position, so the person holding the file can see what they cannot see.
|
|
2773
|
+
*/
|
|
2774
|
+
const CANONICAL_MAX_CODE = 0x7e;
|
|
2775
|
+
const CANONICAL_MIN_CODE = 0x20;
|
|
2776
|
+
function canonicalScalar(raw) {
|
|
2777
|
+
const folded = raw.normalize("NFKC");
|
|
2778
|
+
for (const character of folded) {
|
|
2779
|
+
const code = character.codePointAt(0);
|
|
2780
|
+
if (code < CANONICAL_MIN_CODE || code > CANONICAL_MAX_CODE) {
|
|
2781
|
+
/* The POSITION is in the NFKC-folded string, and it is reported because
|
|
2782
|
+
the whole point of this arm is characters a reader cannot see. A
|
|
2783
|
+
codepoint alone does not tell them WHERE to look. */
|
|
2784
|
+
const at = [...folded].indexOf(character);
|
|
2785
|
+
const point = `U+${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
2786
|
+
return { ok: false, found: `${point} at position ${String(at + 1)}` };
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
const collapsed = folded.replace(/\s+/g, " ").trim();
|
|
2790
|
+
if (collapsed === "") {
|
|
2791
|
+
return { ok: false, found: "no printable characters" };
|
|
2792
|
+
}
|
|
2793
|
+
return { ok: true, value: collapsed.toLowerCase() };
|
|
2794
|
+
}
|
|
2795
|
+
function establishField(record, field) {
|
|
2796
|
+
if (record === undefined || !(field in record)) {
|
|
2797
|
+
return { kind: "absent" };
|
|
2798
|
+
}
|
|
2799
|
+
const raw = record[field];
|
|
2800
|
+
if (typeof raw !== "string") {
|
|
2801
|
+
/* The vocabulary is the DOCUMENT's, not JavaScript's: a reader looking at
|
|
2802
|
+
their own YAML is helped by "a list" and "a map" and not by "an object". */
|
|
2803
|
+
const found = raw === null
|
|
2804
|
+
? "null"
|
|
2805
|
+
: Array.isArray(raw)
|
|
2806
|
+
? "a list"
|
|
2807
|
+
: typeof raw === "object"
|
|
2808
|
+
? "a map"
|
|
2809
|
+
: `a ${typeof raw}`;
|
|
2810
|
+
return { kind: "unusable", found };
|
|
2811
|
+
}
|
|
2812
|
+
if (raw.trim() === "") {
|
|
2813
|
+
return { kind: "unusable", found: raw === "" ? "an empty string" : "only whitespace" };
|
|
2814
|
+
}
|
|
2815
|
+
/* CANONICALISED, AND THAT IS THE WHOLE OF FIX ROUND 2. An established value is
|
|
2816
|
+
what the document MEANS, and neither surrounding whitespace nor the choice
|
|
2817
|
+
of codepoint used to draw a letter is part of a model family's name. The
|
|
2818
|
+
form itself, and the argument for its aggressiveness, is at
|
|
2819
|
+
`canonicalScalar` one screen up.
|
|
2820
|
+
|
|
2821
|
+
CASE IS NOW FOLDED, REVERSING ROUND 1, AND THE CITATION ROUND 1 INHERITED
|
|
2822
|
+
WAS CHECKED RATHER THAN CARRIED FORWARD. Round 1 declined to fold case on
|
|
2823
|
+
the grounds that "the review that found CR-001 names case-insensitive
|
|
2824
|
+
comparison as an example of a WEAKENING of this check". CR-003 is a LOW
|
|
2825
|
+
finding about WITNESS SPEC CONSTRUCTION, not about this comparison. Its
|
|
2826
|
+
words, at delivery/review/clean-room-m3-p9-criteria.md:527, are that "a
|
|
2827
|
+
stronger second member would be a different way to break the comparison,
|
|
2828
|
+
for example comparing the dimension case-insensitively or grouping on the
|
|
2829
|
+
wrong key". That is a suggestion for a MUTATION to put in a witness spec's
|
|
2830
|
+
`dangerousStates`, which is a deliberate defect a test must redden against.
|
|
2831
|
+
It is not a ruling that the shipped comparison should be case-sensitive.
|
|
2832
|
+
|
|
2833
|
+
And the direction settles it independently of what the reviewer meant: this
|
|
2834
|
+
check refuses when values are NOT distinct, so folding case makes more
|
|
2835
|
+
values compare as equal, which produces MORE refusals. A case-insensitive
|
|
2836
|
+
comparison here cannot be a weakening, because there is no input it lets
|
|
2837
|
+
through that a case-sensitive one refuses. Measured before this line
|
|
2838
|
+
existed: `produced-by: Family-A` against `produced-by: family-a` on a pair
|
|
2839
|
+
sharing one model family exited 0 GREEN, and `merge-authority:
|
|
2840
|
+
Delegated-Under-Conditions` disabled the check entirely. Both now redden. */
|
|
2841
|
+
const canonical = canonicalScalar(raw);
|
|
2842
|
+
if (!canonical.ok) {
|
|
2843
|
+
return { kind: "uncanonical", found: canonical.found };
|
|
2844
|
+
}
|
|
2845
|
+
return { kind: "established", value: canonical.value };
|
|
2846
|
+
}
|
|
2847
|
+
/**
|
|
2848
|
+
* The sentence for a reading that is NOT established, so absence and
|
|
2849
|
+
* unusability never share a message with each other or with a comparison.
|
|
2850
|
+
* Returns `undefined` for an established reading, which no caller asks about.
|
|
2851
|
+
*/
|
|
2852
|
+
function unestablishedReason(reading, field) {
|
|
2853
|
+
if (reading.kind === "established") {
|
|
2854
|
+
return undefined;
|
|
2855
|
+
}
|
|
2856
|
+
if (reading.kind === "absent") {
|
|
2857
|
+
return `declares no ${field}`;
|
|
2858
|
+
}
|
|
2859
|
+
if (reading.kind === "uncanonical") {
|
|
2860
|
+
/* ITS OWN SENTENCE, because it is its own fact. "Names no value" is false
|
|
2861
|
+
here: the field names a value perfectly well, and the value is drawn in
|
|
2862
|
+
characters that no reader can tell from another value's. Printing that as
|
|
2863
|
+
"names no value" would send the reader looking for a missing field. */
|
|
2864
|
+
return (`declares ${field} using the character ${reading.found}, which is outside the printable ASCII ` +
|
|
2865
|
+
`a governance identifier is compared as, so it cannot be told apart from a value drawn in ordinary characters`);
|
|
2866
|
+
}
|
|
2867
|
+
return `declares ${field} as ${reading.found}, which names no value`;
|
|
2868
|
+
}
|
|
2869
|
+
/**
|
|
2870
|
+
* The triple that identifies one review's decorrelation position.
|
|
2871
|
+
*
|
|
2872
|
+
* BUILT FROM ESTABLISHED READINGS rather than from `?? ""`, for the same reason
|
|
2873
|
+
* as everything else in this section: the old form mapped an ABSENT field and a
|
|
2874
|
+
* field carrying the empty string onto the same token, so two documents that
|
|
2875
|
+
* were merely both incomplete compared as the same review.
|
|
2876
|
+
*
|
|
2877
|
+
* WHAT IT STILL DOES NOT SEPARATE, said here rather than left to be found: two
|
|
2878
|
+
* documents each missing the SAME dimension still produce the same token for it,
|
|
2879
|
+
* because identity-by-triple cannot distinguish two absences. That is not a way
|
|
2880
|
+
* to a wrong decorrelation verdict any more, because the per-dimension loop now
|
|
2881
|
+
* refuses an unestablished dimension outright; it can still let a verdict that
|
|
2882
|
+
* is not the committed one pass the membership test when both are incomplete in
|
|
2883
|
+
* the same way.
|
|
2884
|
+
*
|
|
2885
|
+
* THIS IS THE ONE SITE WHERE FIX ROUND 2's CANONICALISATION IS PERMISSIVE
|
|
2886
|
+
* RATHER THAN REFUSING, AND IT IS DECLARED HERE RATHER THAN DISCOVERED. Every
|
|
2887
|
+
* other comparison in this check refuses more inputs once values are collapsed
|
|
2888
|
+
* onto one form. This one accepts more: a verdict differing from a committed
|
|
2889
|
+
* one only in case or in a compatibility variant now passes the membership test
|
|
2890
|
+
* where it previously did not. That is accepted deliberately, on the ground
|
|
2891
|
+
* that it wins an attacker nothing: membership only decides whether this check
|
|
2892
|
+
* proceeds, and what it proceeds to compare is the COMMITTED group, which the
|
|
2893
|
+
* non-committed document is not a member of and does not change. An attacker
|
|
2894
|
+
* who wants the comparison to run can always submit the committed file itself.
|
|
2895
|
+
*/
|
|
2896
|
+
function decorrelationTriple(record) {
|
|
2897
|
+
return DECORRELATION_DIMENSIONS.map((dimension) => {
|
|
2898
|
+
const reading = establishField(record, dimension);
|
|
2899
|
+
return reading.kind === "established" ? `=${reading.value}` : `<${reading.kind}>`;
|
|
2900
|
+
}).join(" | ");
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* DR-0012's merge precondition, made into a comparison a command can make
|
|
2904
|
+
* against the verdict FILES rather than against a session's memory (M3R-004).
|
|
2905
|
+
*
|
|
2906
|
+
* WHY THIS IS KIND B AND COULD NOT BE A KEYWORD. Every dimension it compares
|
|
2907
|
+
* lives in a DIFFERENT DOCUMENT from the instance: distinctness is a property
|
|
2908
|
+
* of a PAIR of verdicts, and no keyword under any DR-0013 option can see the
|
|
2909
|
+
* sibling.
|
|
2910
|
+
*
|
|
2911
|
+
* IT ESTABLISHES PRESENCE ITSELF AND DOES NOT BORROW IT FROM THE SCHEMA. An
|
|
2912
|
+
* earlier version of this comment said the verdict schema's `required` buys
|
|
2913
|
+
* absence-freedom, so this check only had to decide difference. That division of
|
|
2914
|
+
* labour was never composed: nothing on the shipped path validates the SIBLING
|
|
2915
|
+
* documents, so a document with `kind: verdict` and a missing required field is
|
|
2916
|
+
* loaded here and compared. The rule the whole section now follows is
|
|
2917
|
+
* `establishField`, one screen up: a value is not comparable until it has been
|
|
2918
|
+
* established, and absence, unusability and difference are three verdicts, not
|
|
2919
|
+
* one.
|
|
2920
|
+
*
|
|
2921
|
+
* IT APPLIES EXACTLY WHERE THE GRANT APPLIES. The regime is read from the
|
|
2922
|
+
* declared mode, not assumed: `charter.yaml` names the delivery mode and
|
|
2923
|
+
* `assurance-modes.yaml` says what that mode's `merge-authority` is. A mode
|
|
2924
|
+
* whose authority is not a delegated grant has no decorrelation precondition to
|
|
2925
|
+
* satisfy, and this check REPORTS that rather than passing silently, because
|
|
2926
|
+
* "nothing to check here" and "everything checked and fine" must never print
|
|
2927
|
+
* the same line (SC-011).
|
|
2928
|
+
*
|
|
2929
|
+
* FIVE DIMENSIONS, AND (e) IS NOT A REFINEMENT OF (b). T-007's whole finding is
|
|
2930
|
+
* that model decorrelation and CONTRACT decorrelation are different properties
|
|
2931
|
+
* and this project had the second by accident: two reviewers on different model
|
|
2932
|
+
* families walked all fifteen criteria of one phase, agreed on every mechanical
|
|
2933
|
+
* fact, and one missed a high-severity defect because both had been given the
|
|
2934
|
+
* criteria contract. So `review-contract` is compared separately and is
|
|
2935
|
+
* witnessed separately (criterion 7b).
|
|
2936
|
+
*
|
|
2937
|
+
* WHAT IT DOES NOT REACH, named rather than left to be found. Condition (d) of
|
|
2938
|
+
* step 3b, that neither verdict carries an unresolved high or medium finding,
|
|
2939
|
+
* is NOT checked here: the verdict schema's own root `if`/`then` already
|
|
2940
|
+
* forbids APPROVE beside a high or critical finding, and "unresolved" is a
|
|
2941
|
+
* state of the review thread rather than of the document. Nothing here decides
|
|
2942
|
+
* whether the two verdicts describe the same HEAD either: the verdict schema
|
|
2943
|
+
* carries no head field, so `phase` is the join key and the DIRECTORY is what
|
|
2944
|
+
* scopes a set of verdicts to one head. Both are stated in
|
|
2945
|
+
* delivery/work-history/m3-p9.md as declared readings rather than absorbed.
|
|
2946
|
+
*/
|
|
2947
|
+
export const dualReviewDecorrelation = {
|
|
2948
|
+
id: "dual-review-decorrelation",
|
|
2949
|
+
type: "verdict",
|
|
2950
|
+
requiresContext: true,
|
|
2951
|
+
run(instance, contextDirectory) {
|
|
2952
|
+
if (contextDirectory === undefined) {
|
|
2953
|
+
/* Unreachable through `runChecks`, which SKIPS first. Fail closed rather
|
|
2954
|
+
than trusting a caller that reaches the check directly. */
|
|
2955
|
+
return {
|
|
2956
|
+
violations: [
|
|
2957
|
+
{ pointer: "#/produced-by", message: "no context directory was supplied" },
|
|
2958
|
+
],
|
|
2959
|
+
reports: [],
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
const verdict = asRecord(instance);
|
|
2963
|
+
/* THE JOIN KEY IS CANONICALISED TOO, AND IT IS NOT AN AFTERTHOUGHT. `phase`
|
|
2964
|
+
selects the GROUP the distinctness comparison runs over, so a lookalike
|
|
2965
|
+
character here shrinks the group instead of changing a dimension. With
|
|
2966
|
+
three verdicts, two of them sharing a family, drawing one sibling's
|
|
2967
|
+
`phase` with a homoglyph drops it from the group and leaves two distinct
|
|
2968
|
+
ones behind, which is the same fail-open outcome by a different route.
|
|
2969
|
+
Canonicalising GROWS the group, which is the fail-closed direction: more
|
|
2970
|
+
verdicts compared means more chances to find a shared value. */
|
|
2971
|
+
const phaseReading = establishField(verdict, "phase");
|
|
2972
|
+
if (phaseReading.kind !== "established") {
|
|
2973
|
+
return {
|
|
2974
|
+
violations: [
|
|
2975
|
+
{
|
|
2976
|
+
pointer: "#/phase",
|
|
2977
|
+
message: `the verdict ${unestablishedReason(phaseReading, "phase")}, so the other reviews of the same work cannot be selected`,
|
|
2978
|
+
},
|
|
2979
|
+
],
|
|
2980
|
+
reports: [],
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
/* TWO JOBS, TWO VALUES, AND CONFLATING THEM IS ITS OWN SMALL DEFECT. The
|
|
2984
|
+
phase is a JOIN KEY, which must be canonical so the group is assembled
|
|
2985
|
+
correctly, and it is also a LABEL printed back at a reader, which must be
|
|
2986
|
+
the reader's OWN spelling so the sentence matches the file they are
|
|
2987
|
+
holding. Printing the canonical form would tell someone whose charter says
|
|
2988
|
+
`M3-P9` about a phase called `m3-p9`, which is a document they do not
|
|
2989
|
+
have. Only `phaseKey` is ever compared; only `phase` is ever printed. */
|
|
2990
|
+
const phaseKey = phaseReading.value;
|
|
2991
|
+
const phase = verdict?.["phase"];
|
|
2992
|
+
/* THE REGIME IS READ, NEVER ASSUMED, AND "ABSENT" IS NOT THE SAME FACT AS
|
|
2993
|
+
"PRESENT AND BROKEN". This distinction was NOT in the first version of
|
|
2994
|
+
this check and it cost eight red tests belonging to M3-P7, one of them
|
|
2995
|
+
that phase's own acceptance criterion.
|
|
2996
|
+
|
|
2997
|
+
The mechanism behind those eight, stated at the field rather than at the
|
|
2998
|
+
failure: an applicability determination that needs a PROJECT WORKSPACE
|
|
2999
|
+
was being made inside a check that runs on ANY verdict with ANY context,
|
|
3000
|
+
and a verdict context built to exercise criteria completeness carries a
|
|
3001
|
+
plan and a work history and no charter, because a charter is not what
|
|
3002
|
+
those rules are about.
|
|
3003
|
+
|
|
3004
|
+
So: a charter that is ABSENT means this context declares no delivery
|
|
3005
|
+
mode, which is REPORTED rather than failed. A charter that is THERE and
|
|
3006
|
+
unreadable, or that names a mode nothing defines, or a mode document
|
|
3007
|
+
absent while a charter names a mode, is a VIOLATION, because a document
|
|
3008
|
+
that exists and is wrong is a different fact from one that does not.
|
|
3009
|
+
|
|
3010
|
+
THE FAIL-CLOSED TEETH DID NOT DISAPPEAR, THEY MOVED TO THE CALLER THAT
|
|
3011
|
+
MAKES THE MERGE DECISION. `scripts/check-dual-review.mjs` refuses a
|
|
3012
|
+
directory carrying no charter or no mode document, with gate status
|
|
3013
|
+
`error`. That is the path DR-0012's grant runs through, and it must never
|
|
3014
|
+
report green without knowing the regime. Imposing the same refusal here
|
|
3015
|
+
imposed it on a path the grant has nothing to do with. */
|
|
3016
|
+
const charterPresent = classifyEntry(join(contextDirectory, "charter.yaml")).kind !== "absent";
|
|
3017
|
+
if (!charterPresent) {
|
|
3018
|
+
return {
|
|
3019
|
+
violations: [],
|
|
3020
|
+
reports: [
|
|
3021
|
+
`REPORT dual-review-decorrelation ${contextDirectory} declares no delivery mode ` +
|
|
3022
|
+
`(no charter.yaml), so the verdicts for phase ${phase} were NOT evaluated against a ` +
|
|
3023
|
+
`merge-authority regime; scripts/check-dual-review.mjs refuses such a directory outright`,
|
|
3024
|
+
],
|
|
3025
|
+
};
|
|
3026
|
+
}
|
|
3027
|
+
const charter = readContextDocument(contextDirectory, "charter.yaml");
|
|
3028
|
+
if (!charter.ok) {
|
|
3029
|
+
return {
|
|
3030
|
+
violations: [
|
|
3031
|
+
{
|
|
3032
|
+
pointer: "#/produced-by",
|
|
3033
|
+
message: `the charter is present and could not be read, so the declared mode's merge-authority is unknown and decorrelation could not be evaluated: ${charter.reason}`,
|
|
3034
|
+
},
|
|
3035
|
+
],
|
|
3036
|
+
reports: [],
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
3039
|
+
/* SITE TWO OF THE SAME MECHANISM. `asRecord(charter.value)?.["delivery-mode"]`
|
|
3040
|
+
used to flow into `String(modeId)` and into an `===` against every mode's
|
|
3041
|
+
id, so a charter declaring NO delivery mode reddened with the sentence
|
|
3042
|
+
"declares delivery mode undefined, which ... does not define". The verdict
|
|
3043
|
+
was right by luck and the sentence was false: the charter declares no mode
|
|
3044
|
+
rather than one called "undefined". Establishing it first gives absence its
|
|
3045
|
+
own sentence, and gives the `===` below a non-empty string, which is also
|
|
3046
|
+
what stops an id-less mode row (`eachMode` defaults a missing id to "")
|
|
3047
|
+
from matching a charter whose delivery-mode is the empty string. */
|
|
3048
|
+
const modeReading = establishField(asRecord(charter.value), "delivery-mode");
|
|
3049
|
+
if (modeReading.kind !== "established") {
|
|
3050
|
+
return {
|
|
3051
|
+
violations: [
|
|
3052
|
+
{
|
|
3053
|
+
pointer: "#/produced-by",
|
|
3054
|
+
message: `${charter.path} ${unestablishedReason(modeReading, "delivery-mode")}, so no mode's merge-authority can be looked up and whether the delegated grant applies to phase ${phase} could not be established`,
|
|
3055
|
+
},
|
|
3056
|
+
],
|
|
3057
|
+
reports: [],
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
const modeId = modeReading.value;
|
|
3061
|
+
const modesDocument = readContextDocument(contextDirectory, MODES_DOCUMENT);
|
|
3062
|
+
if (!modesDocument.ok) {
|
|
3063
|
+
return {
|
|
3064
|
+
violations: [
|
|
3065
|
+
{
|
|
3066
|
+
pointer: "#/produced-by",
|
|
3067
|
+
message: `${charter.path} declares delivery mode ${String(modeId)} and ${MODES_DOCUMENT} could not be read, so that mode's merge-authority is unknown and decorrelation could not be evaluated: ${modesDocument.reason}`,
|
|
3068
|
+
},
|
|
3069
|
+
],
|
|
3070
|
+
reports: [],
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
/* BOTH SIDES CANONICAL, and the direction here is worth stating because it
|
|
3074
|
+
is the one place in this function where collapsing makes a lookup SUCCEED
|
|
3075
|
+
more often rather than fail. `eachMode` builds `row.id` with its own
|
|
3076
|
+
`String(... ?? "")` and is shared with six other consumers, so it is left
|
|
3077
|
+
alone and its output is canonicalised at THIS use site. Finding the mode
|
|
3078
|
+
a charter actually names is the correct reading; the security-relevant
|
|
3079
|
+
comparison is the `merge-authority` one below, and THAT one is fail-closed
|
|
3080
|
+
under collapsing, because more values matching the delegated constant
|
|
3081
|
+
means the decorrelation requirement applies more often, never less. */
|
|
3082
|
+
const mode = eachMode(modesDocument.value).find((row) => {
|
|
3083
|
+
const reading = canonicalScalar(row.id);
|
|
3084
|
+
return reading.ok && reading.value === modeId;
|
|
3085
|
+
});
|
|
3086
|
+
if (mode === undefined) {
|
|
3087
|
+
return {
|
|
3088
|
+
violations: [
|
|
3089
|
+
{
|
|
3090
|
+
pointer: "#/produced-by",
|
|
3091
|
+
message: `${charter.path} declares delivery mode ${String(modeId)}, which ${modesDocument.path} does not define, so its merge-authority is unknown`,
|
|
3092
|
+
},
|
|
3093
|
+
],
|
|
3094
|
+
reports: [],
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
/* SITE THREE, AND IT IS THE WORST OF THE FOUR BECAUSE IT DISABLES THE WHOLE
|
|
3098
|
+
CHECK RATHER THAN ONE DIMENSION. `String(mode.mode["merge-authority"] ?? "")`
|
|
3099
|
+
made a mode that declares NO merge-authority indistinguishable from one
|
|
3100
|
+
declaring some other authority, and the not-a-delegated-grant arm below is
|
|
3101
|
+
a REPORT rather than a violation. Measured on the shipped script before
|
|
3102
|
+
this repair (probe P1 in delivery/work-history/m3-p9.md): a pair sharing
|
|
3103
|
+
one model family, under a mode with its `merge-authority` line deleted,
|
|
3104
|
+
exited 0 GREEN printing "mode full declares merge-authority , which is not
|
|
3105
|
+
a delegated grant". That sentence is false and the exit code authorises
|
|
3106
|
+
the merge the check exists to refuse. The reviewer did not find this one;
|
|
3107
|
+
the derivation did. */
|
|
3108
|
+
const authorityReading = establishField(mode.mode, "merge-authority");
|
|
3109
|
+
if (authorityReading.kind !== "established") {
|
|
3110
|
+
return {
|
|
3111
|
+
violations: [
|
|
3112
|
+
{
|
|
3113
|
+
pointer: "#/produced-by",
|
|
3114
|
+
message: `${modesDocument.path} ${unestablishedReason(authorityReading, "merge-authority")} for mode ${modeId}, so whether the delegated grant applies to phase ${phase} could not be established, and a merge check that cannot determine the regime must not report that no decorrelation is required`,
|
|
3115
|
+
},
|
|
3116
|
+
],
|
|
3117
|
+
reports: [],
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
const authority = authorityReading.value;
|
|
3121
|
+
if (authority !== DELEGATED_MERGE_AUTHORITY) {
|
|
3122
|
+
return {
|
|
3123
|
+
violations: [],
|
|
3124
|
+
reports: [
|
|
3125
|
+
`REPORT dual-review-decorrelation mode ${String(modeId)} declares merge-authority ${authority}, ` +
|
|
3126
|
+
`which is not a delegated grant, so no decorrelation is required of the reviews of phase ${phase}`,
|
|
3127
|
+
],
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
const committed = loadCommittedVerdicts(contextDirectory);
|
|
3131
|
+
if (!committed.ok) {
|
|
3132
|
+
return {
|
|
3133
|
+
violations: [{ pointer: "#/produced-by", message: committed.reason }],
|
|
3134
|
+
reports: [],
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
const group = committed.verdicts.filter(
|
|
3138
|
+
/* BOTH SIDES CANONICAL. `phase` above is already canonical; the sibling's
|
|
3139
|
+
is read through the same function so the two are compared in one form
|
|
3140
|
+
rather than one canonical value against one raw one. */
|
|
3141
|
+
(candidate) => {
|
|
3142
|
+
const reading = establishField(candidate.record, "phase");
|
|
3143
|
+
return reading.kind === "established" && reading.value === phaseKey;
|
|
3144
|
+
});
|
|
3145
|
+
/* MEMBERSHIP FIRST. DR-0012 condition 1 says the two reviews are WRITTEN TO
|
|
3146
|
+
`delivery/review/` AND COMMITTED, so a verdict that is not among them is
|
|
3147
|
+
not a review this rule can be satisfied by, however well decorrelated the
|
|
3148
|
+
committed pair happens to be. Without this the check would pass on a
|
|
3149
|
+
document that had nothing to do with the directory it was given. */
|
|
3150
|
+
const wanted = decorrelationTriple(verdict);
|
|
3151
|
+
if (!group.some((candidate) => decorrelationTriple(candidate.record) === wanted)) {
|
|
3152
|
+
return {
|
|
3153
|
+
violations: [
|
|
3154
|
+
{
|
|
3155
|
+
pointer: "#/phase",
|
|
3156
|
+
message: `this verdict is not among the ${String(group.length)} verdict document(s) committed under ${REVIEW_DIRECTORY} for phase ${phase}, so it is not a review the delegated grant can be satisfied by`,
|
|
3157
|
+
},
|
|
3158
|
+
],
|
|
3159
|
+
reports: [],
|
|
3160
|
+
};
|
|
3161
|
+
}
|
|
3162
|
+
const violations = [];
|
|
3163
|
+
if (group.length < 2) {
|
|
3164
|
+
violations.push({
|
|
3165
|
+
pointer: "#/phase",
|
|
3166
|
+
message: `only ${String(group.length)} verdict document(s) exist under ${REVIEW_DIRECTORY} for phase ${phase}, and a delegated grant requires two independent clean-room reviews of the exact head`,
|
|
3167
|
+
});
|
|
3168
|
+
}
|
|
3169
|
+
for (const dimension of DECORRELATION_DIMENSIONS) {
|
|
3170
|
+
/* SITE ONE, THE ONE CR-001 REPORTS. ABSENCE IS ITS OWN VERDICT AND IT IS A
|
|
3171
|
+
FAIL, and the choice was deliberate rather than inherited.
|
|
3172
|
+
|
|
3173
|
+
The alternative the plan permits elsewhere, a not-applicable carrying a
|
|
3174
|
+
reason, is the RIGHT answer where the check has established that the
|
|
3175
|
+
regime does not apply: that is why an absent charter above REPORTS. It
|
|
3176
|
+
is the WRONG answer here, because by this line the regime HAS been
|
|
3177
|
+
established as a delegated grant, these documents ARE the ones the grant
|
|
3178
|
+
rests on, and a dimension no verdict states is a precondition that has
|
|
3179
|
+
not been shown. Under a grant, unshown must be refused; anything else is
|
|
3180
|
+
the fail-open direction this finding is about.
|
|
3181
|
+
|
|
3182
|
+
The message is deliberately UNLIKE the correlation message below, so
|
|
3183
|
+
"could not be shown decorrelated" and "was shown correlated" never print
|
|
3184
|
+
the same line. Note also that an unestablished dimension does not
|
|
3185
|
+
suppress the comparison over the rest of the group: with three verdicts,
|
|
3186
|
+
one absent and two sharing a family, a reader is owed both facts. */
|
|
3187
|
+
const counts = new Map();
|
|
3188
|
+
for (const candidate of group) {
|
|
3189
|
+
const reading = establishField(candidate.record, dimension);
|
|
3190
|
+
if (reading.kind !== "established") {
|
|
3191
|
+
violations.push({
|
|
3192
|
+
pointer: `#/${dimension}`,
|
|
3193
|
+
message: `${candidate.path} ${unestablishedReason(reading, dimension)}, so the ${String(group.length)} verdicts for phase ${phase} cannot be shown decorrelated on ${dimension}, and a delegated grant is not satisfied by a dimension a verdict does not state`,
|
|
3194
|
+
});
|
|
3195
|
+
continue;
|
|
3196
|
+
}
|
|
3197
|
+
counts.set(reading.value, [...(counts.get(reading.value) ?? []), candidate.path]);
|
|
3198
|
+
}
|
|
3199
|
+
for (const value of [...counts.keys()].sort()) {
|
|
3200
|
+
const paths = counts.get(value);
|
|
3201
|
+
if (paths.length < 2) {
|
|
3202
|
+
continue;
|
|
3203
|
+
}
|
|
3204
|
+
violations.push({
|
|
3205
|
+
pointer: `#/${dimension}`,
|
|
3206
|
+
message: `${dimension} value ${value} occurs in ${String(paths.length)} of the ${String(group.length)} verdicts for phase ${phase} (${paths.sort().join(", ")}), so the reviews are not decorrelated on ${dimension}`,
|
|
3207
|
+
});
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
return {
|
|
3211
|
+
violations,
|
|
3212
|
+
reports: violations.length > 0
|
|
3213
|
+
? []
|
|
3214
|
+
: [
|
|
3215
|
+
`REPORT dual-review-decorrelation ${String(group.length)} verdict(s) for phase ${phase} are distinct on ${DECORRELATION_DIMENSIONS.join(", ")}`,
|
|
3216
|
+
],
|
|
3217
|
+
};
|
|
3218
|
+
},
|
|
3219
|
+
};
|
|
3220
|
+
/* ------------------------------------------------------------------ */
|
|
3221
|
+
/* The registry */
|
|
3222
|
+
/* ------------------------------------------------------------------ */
|
|
3223
|
+
const registry = [
|
|
3224
|
+
charterModeEnumMatchesModes,
|
|
3225
|
+
finalReportFindingParity,
|
|
3226
|
+
modeConditionsQuoteGrantedBy,
|
|
3227
|
+
modeGateSetsResolve,
|
|
3228
|
+
modeIdsAreUnique,
|
|
3229
|
+
modeNoUndeclaredDowngrade,
|
|
3230
|
+
modeStageOrder,
|
|
3231
|
+
planDispatchable,
|
|
3232
|
+
planHazardClassesAddressedByResolves,
|
|
3233
|
+
planVerificationFirstPresent,
|
|
3234
|
+
reportNoFindingsStatement,
|
|
3235
|
+
reportParityArithmetic,
|
|
3236
|
+
roleIdsAreUnique,
|
|
3237
|
+
/* M3-P7 step 6b. Appended, never inserted: `checksFor` filters by declared
|
|
3238
|
+
type and sorts by id, and `registeredChecks` returns a copy, so the
|
|
3239
|
+
array's position carries no meaning any check reads. That is the property
|
|
3240
|
+
the M3-P7 beside M3-P8 pre-pass asks whoever resolves a both-sides-add
|
|
3241
|
+
conflict at this tail to confirm before keeping both entries. */
|
|
3242
|
+
checklistProbeIdsUnique,
|
|
3243
|
+
gateProbesResolve,
|
|
3244
|
+
verdictCriteriaComplete,
|
|
3245
|
+
verdictDeviationsJudged,
|
|
3246
|
+
verdictHazardClassesAddressed,
|
|
3247
|
+
/* M3-P7 FIX ROUND 2. Appended for the reason recorded above the M3-P7
|
|
3248
|
+
block: position carries no meaning any check reads. */
|
|
3249
|
+
checklistFramingIdsUnique,
|
|
3250
|
+
verdictFindingReferencesResolve,
|
|
3251
|
+
/* M3-P8 step 8. Appended rather than inserted: `checksFor` filters by
|
|
3252
|
+
declared type and sorts by id, so this array's order carries no meaning
|
|
3253
|
+
any check reads. */
|
|
3254
|
+
tuitionTargetExists,
|
|
3255
|
+
mechanismRuleEvidenceResolves,
|
|
3256
|
+
/* M3-P9 step 3b. Appended rather than inserted, for the reason recorded on
|
|
3257
|
+
the M3-P7 block above: `checksFor` filters by declared type and sorts by
|
|
3258
|
+
id, and `registeredChecks` returns a copy, so this array's position carries
|
|
3259
|
+
no meaning any check reads. */
|
|
3260
|
+
dualReviewDecorrelation,
|
|
3261
|
+
];
|
|
3262
|
+
/** Register a check. Later phases append their own (section 2.3's table). */
|
|
3263
|
+
export function registerCheck(check) {
|
|
3264
|
+
registry.push(check);
|
|
3265
|
+
}
|
|
3266
|
+
/** Remove a check by id. Returns whether one was removed. */
|
|
3267
|
+
export function deregisterCheck(id) {
|
|
3268
|
+
const index = registry.findIndex((check) => check.id === id);
|
|
3269
|
+
if (index === -1) {
|
|
3270
|
+
return false;
|
|
3271
|
+
}
|
|
3272
|
+
registry.splice(index, 1);
|
|
3273
|
+
return true;
|
|
3274
|
+
}
|
|
3275
|
+
/** Every check registered for an artifact type, in stable id order. */
|
|
3276
|
+
export function checksFor(type) {
|
|
3277
|
+
return registry
|
|
3278
|
+
.filter((check) => typesOf(check).includes(type))
|
|
3279
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
3280
|
+
}
|
|
3281
|
+
/** Every registered check, in registration order. Read by the enumeration. */
|
|
3282
|
+
export function registeredChecks() {
|
|
3283
|
+
return [...registry];
|
|
3284
|
+
}
|
|
3285
|
+
/**
|
|
3286
|
+
* Run every registered check for `type`.
|
|
3287
|
+
*
|
|
3288
|
+
* A check whose `requiresContext` is true and which was given none is
|
|
3289
|
+
* SKIPPED and the run FAILS. It is deliberately not an ordinary violation:
|
|
3290
|
+
* "this rule did not run" and "this rule found a problem" are different
|
|
3291
|
+
* facts and a reader must be able to tell them apart, but both are reasons
|
|
3292
|
+
* not to trust a green.
|
|
3293
|
+
*/
|
|
3294
|
+
export function runChecks(type, instance, contextDirectory) {
|
|
3295
|
+
const violationLines = [];
|
|
3296
|
+
const reportLines = [];
|
|
3297
|
+
const skippedLines = [];
|
|
3298
|
+
for (const check of checksFor(type)) {
|
|
3299
|
+
if (check.requiresContext && contextDirectory === undefined) {
|
|
3300
|
+
skippedLines.push(`SKIPPED ${check.id} no context`);
|
|
3301
|
+
continue;
|
|
3302
|
+
}
|
|
3303
|
+
const outcome = check.run(instance, contextDirectory);
|
|
3304
|
+
for (const violation of outcome.violations) {
|
|
3305
|
+
violationLines.push(`INVALID ${violation.pointer} ${violation.message} (check: ${check.id})`);
|
|
3306
|
+
}
|
|
3307
|
+
reportLines.push(...outcome.reports);
|
|
3308
|
+
}
|
|
3309
|
+
violationLines.sort();
|
|
3310
|
+
return {
|
|
3311
|
+
lines: [...skippedLines, ...violationLines, ...reportLines],
|
|
3312
|
+
failed: violationLines.length > 0 || skippedLines.length > 0,
|
|
3313
|
+
};
|
|
3314
|
+
}
|