@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,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ROLE BRIEFS: frontmatter, the shared clause include, the clause round trip,
|
|
3
|
+
* and the phase projection `tiphys brief compose` emits (kernel plan M3,
|
|
4
|
+
* M3-P5 step 6).
|
|
5
|
+
*
|
|
6
|
+
* A role brief is markdown with YAML frontmatter, which section 1.5 of the
|
|
7
|
+
* plan grants as a JUSTIFIED EXCEPTION to the structured-artifact rule: a
|
|
8
|
+
* brief's effect comes from argument, ordering and emphasis, and splitting it
|
|
9
|
+
* into fields produces either one giant string or a set of fragments no agent
|
|
10
|
+
* reads as an argument. The frontmatter carries everything enumerable and is
|
|
11
|
+
* schema-validated; this module is what makes the BODY checkable in the one
|
|
12
|
+
* respect a machine can reach.
|
|
13
|
+
*
|
|
14
|
+
* THREE MECHANISMS LIVE HERE AND THEY ARE DELIBERATELY SEPARATE.
|
|
15
|
+
*
|
|
16
|
+
* 1. Frontmatter split. A brief is decoded before it is validated, exactly
|
|
17
|
+
* as `tiphys validate` decodes before it validates: a malformed
|
|
18
|
+
* frontmatter block names the decode, a failing contract names the
|
|
19
|
+
* pointer, and neither produces a stack trace.
|
|
20
|
+
* 2. The include. `$include: <name>` on a line of its own is resolved
|
|
21
|
+
* against the brief's own directory. It exists because M3-P5's three
|
|
22
|
+
* briefs and M3-P6's two carry the SAME dispatch-contract clause text,
|
|
23
|
+
* and five independently editable copies of a rule drift into five
|
|
24
|
+
* different rules. There is one copy, `roles/_shared-dispatch-contract.md`.
|
|
25
|
+
* 3. The clause round trip. Every id in `clauses[]` occurs exactly once as a
|
|
26
|
+
* body heading anchor of the include-expanded body, and every anchor
|
|
27
|
+
* occurs in `clauses[]`. This is what stops a clause id being a label
|
|
28
|
+
* with nothing behind it, which would make the clause map a rubber stamp.
|
|
29
|
+
* It proves PRESENCE and never content: whether the text under a heading
|
|
30
|
+
* says the opposite of the row it discharges is judgment, and the phase's
|
|
31
|
+
* own hazard-class table records that no criterion reaches it.
|
|
32
|
+
*
|
|
33
|
+
* D-M3-27 BINDS EVERY PATH THIS MODULE TOUCHES. Composition's whole job is
|
|
34
|
+
* resolving and reading paths it did not create, so every one of them goes
|
|
35
|
+
* through `classifyEntry` and `refuseOpenForWrite` in src/task.ts. A named
|
|
36
|
+
* pipe at a mandated-reading path is a reported refusal naming the path and
|
|
37
|
+
* the observed entry type, never a blocked open. This module adds no bare
|
|
38
|
+
* `readFileSync` and does not patch `src/brief.ts`, which is M1-P4's and
|
|
39
|
+
* carries its own open instance of that class.
|
|
40
|
+
*/
|
|
41
|
+
import { readdirSync } from "node:fs";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
43
|
+
import { dirname, join, posix as posixPath } from "node:path";
|
|
44
|
+
import { classifyEntry, readRegularFileIfPresent } from "./task.js";
|
|
45
|
+
/**
|
|
46
|
+
* The six roles of blueprint section 6 and of the process document's role
|
|
47
|
+
* table. Identical to `schemas/role-brief.schema.json`'s `role` enum and to
|
|
48
|
+
* `role-model-config.yaml`'s `role` values; test/roles.test.ts asserts the
|
|
49
|
+
* three agree rather than trusting this comment.
|
|
50
|
+
*/
|
|
51
|
+
export const ROLE_IDS = [
|
|
52
|
+
"orchestrator",
|
|
53
|
+
"investigator",
|
|
54
|
+
"plan-writer",
|
|
55
|
+
"adversarial-plan-reviewer",
|
|
56
|
+
"implementer",
|
|
57
|
+
"clean-room-reviewer",
|
|
58
|
+
];
|
|
59
|
+
/** The fence a role brief's YAML frontmatter block is delimited by. */
|
|
60
|
+
export const FRONTMATTER_FENCE = "---";
|
|
61
|
+
/** The include directive, on a line of its own. */
|
|
62
|
+
export const INCLUDE_PATTERN = /^\$include:[ \t]+(\S+)[ \t]*$/;
|
|
63
|
+
/**
|
|
64
|
+
* A body heading anchor: `## clause <id>` with an optional `: title`.
|
|
65
|
+
*
|
|
66
|
+
* DELIBERATELY EXPLICIT. An anchor form that guessed (say, "a heading whose
|
|
67
|
+
* text looks like an identifier") would classify ordinary headings such as
|
|
68
|
+
* `## Scope` as anchors and redden every brief that has one. The word
|
|
69
|
+
* `clause` is the marker, and a heading without it is prose.
|
|
70
|
+
*/
|
|
71
|
+
export const CLAUSE_ANCHOR_PATTERN = /^#{1,6}[ \t]+clause[ \t]+([A-Za-z][A-Za-z0-9-]*)[ \t]*(?::[^\n]*)?$/;
|
|
72
|
+
/**
|
|
73
|
+
* Split a role brief into its frontmatter text and its body.
|
|
74
|
+
*
|
|
75
|
+
* The document must OPEN with the fence. A markdown file with a `---` rule
|
|
76
|
+
* somewhere in the middle is not a role brief with frontmatter, and reading
|
|
77
|
+
* it as one is how a horizontal rule becomes a contract.
|
|
78
|
+
*/
|
|
79
|
+
export function splitFrontmatter(text, label) {
|
|
80
|
+
const lines = text.split("\n");
|
|
81
|
+
if (lines[0]?.trim() !== FRONTMATTER_FENCE) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: `${label} does not open with a ${FRONTMATTER_FENCE} frontmatter fence, so it has no role-brief frontmatter to validate`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
88
|
+
if (lines[index].trim() === FRONTMATTER_FENCE) {
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
frontmatter: lines.slice(1, index).join("\n"),
|
|
92
|
+
body: lines.slice(index + 1).join("\n"),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: `${label} opens a ${FRONTMATTER_FENCE} frontmatter fence that is never closed`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve every `$include:` directive in a body against `baseDirectory`.
|
|
103
|
+
*
|
|
104
|
+
* ONE LEVEL, NOT RECURSIVE, and that is a decision rather than an omission: a
|
|
105
|
+
* nested include is a second place the shared text could come from, and the
|
|
106
|
+
* whole point of the shared block is that there is exactly one. An include
|
|
107
|
+
* inside an included file is refused by name.
|
|
108
|
+
*/
|
|
109
|
+
export function expandIncludes(body, baseDirectory, label) {
|
|
110
|
+
const included = [];
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const line of body.split("\n")) {
|
|
113
|
+
const match = INCLUDE_PATTERN.exec(line);
|
|
114
|
+
if (match === null) {
|
|
115
|
+
out.push(line);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const name = match[1];
|
|
119
|
+
const path = join(baseDirectory, name);
|
|
120
|
+
const read = readRegularFileIfPresent(path);
|
|
121
|
+
if (read.kind === "absent") {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason: `${label} includes ${name}, and ${path} does not exist`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (read.kind === "refused") {
|
|
128
|
+
return { ok: false, reason: `${label} includes ${name}: ${read.reason}` };
|
|
129
|
+
}
|
|
130
|
+
if (read.body.split("\n").some((candidate) => INCLUDE_PATTERN.test(candidate))) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: `${label} includes ${name}, which itself carries an include directive; includes are one level deep so the shared text has exactly one source`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
included.push(name);
|
|
137
|
+
out.push(read.body.replace(/\n$/, ""));
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, text: out.join("\n"), included };
|
|
140
|
+
}
|
|
141
|
+
/** Every clause anchor in a body, in the order they occur, duplicates kept. */
|
|
142
|
+
export function clauseAnchors(body) {
|
|
143
|
+
const found = [];
|
|
144
|
+
for (const line of body.split("\n")) {
|
|
145
|
+
const match = CLAUSE_ANCHOR_PATTERN.exec(line);
|
|
146
|
+
if (match !== null) {
|
|
147
|
+
found.push(match[1]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return found;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The round trip, in both directions, as diagnostics in the same
|
|
154
|
+
* `INVALID <pointer> <message>` contract the schema validator emits.
|
|
155
|
+
*
|
|
156
|
+
* A frontmatter clause with no anchor is ORPHANED: the id is declared, the
|
|
157
|
+
* clause map resolves it because the id occurs somewhere in the file, and
|
|
158
|
+
* there is no text behind it. A stray anchor is the mirror failure: text
|
|
159
|
+
* exists under a clause id nothing declared, so nothing tracks it.
|
|
160
|
+
*/
|
|
161
|
+
export function clauseRoundTripDiagnostics(clauses, body) {
|
|
162
|
+
const anchors = clauseAnchors(body);
|
|
163
|
+
const counts = new Map();
|
|
164
|
+
for (const anchor of anchors) {
|
|
165
|
+
counts.set(anchor, (counts.get(anchor) ?? 0) + 1);
|
|
166
|
+
}
|
|
167
|
+
const diagnostics = [];
|
|
168
|
+
const declared = new Set();
|
|
169
|
+
for (let index = 0; index < clauses.length; index += 1) {
|
|
170
|
+
const clause = clauses[index];
|
|
171
|
+
declared.add(clause);
|
|
172
|
+
const count = counts.get(clause) ?? 0;
|
|
173
|
+
if (count === 0) {
|
|
174
|
+
diagnostics.push({
|
|
175
|
+
pointer: `#/clauses/${String(index)}`,
|
|
176
|
+
message: `clause id ${clause} is declared in frontmatter and has no body heading anchor, so the clause is orphaned`,
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (count > 1) {
|
|
181
|
+
diagnostics.push({
|
|
182
|
+
pointer: `#/clauses/${String(index)}`,
|
|
183
|
+
message: `clause id ${clause} has ${String(count)} body heading anchors and must have exactly one`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
for (const anchor of [...counts.keys()].sort()) {
|
|
188
|
+
if (!declared.has(anchor)) {
|
|
189
|
+
diagnostics.push({
|
|
190
|
+
pointer: "#/clauses",
|
|
191
|
+
message: `body heading anchor ${anchor} is not declared in frontmatter`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return diagnostics;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* THE OUTPUT CONTRACT CHECK (M3-P5 fix round 1, clean-room finding 1).
|
|
199
|
+
*
|
|
200
|
+
* Every artifact type a brief declares in `outputs[]` has a schema document
|
|
201
|
+
* that governs it, and the brief must put that document on its
|
|
202
|
+
* `mandated-reading` list. A brief that does not is a brief whose agent is
|
|
203
|
+
* never told where the shape of its own deliverable is written.
|
|
204
|
+
*
|
|
205
|
+
* THE MECHANISM, NOT THE INSTANCE. The instance found in review was
|
|
206
|
+
* `roles/investigator.md` declaring `outputs: [report]` while its reading list
|
|
207
|
+
* carried `schemas/finding.schema.json`, the contract of a DIFFERENT role's
|
|
208
|
+
* artifact. Repairing that one list leaves the method that produced it, and
|
|
209
|
+
* three correct lists that can drift are worth less than one check that cannot
|
|
210
|
+
* be forgotten. M3-P6 ships two more briefs; this runs on them the day they
|
|
211
|
+
* land, with nobody having to remember it.
|
|
212
|
+
*
|
|
213
|
+
* WHY A CHECK AND NOT A DERIVATION, which was the alternative considered.
|
|
214
|
+
* `resolveMandatedReading` could have INJECTED `schemas/<type>.schema.json`
|
|
215
|
+
* into the list from `outputs[]`, making the omission impossible rather than
|
|
216
|
+
* merely refused. Rejected, for three reasons that are properties of the
|
|
217
|
+
* artifact rather than preferences. (1) It would split the truth in two: the
|
|
218
|
+
* brief file on disk would say one thing and the composed brief another, so a
|
|
219
|
+
* reader of `roles/investigator.md` could no longer see what its agent reads,
|
|
220
|
+
* and `tiphys validate --type role-brief` (which reads the file) and
|
|
221
|
+
* `tiphys brief compose` (which would read the file plus an injection) would
|
|
222
|
+
* hold two different opinions about one property. (2) The list is ORDERED and
|
|
223
|
+
* the order is authored; an injected entry has no authored position, and
|
|
224
|
+
* criterion 3 asserts the composed output's ordering. (3) Injection makes the
|
|
225
|
+
* defect invisible instead of absent: the wrong entry that pointed the
|
|
226
|
+
* investigator at the wrong document would still be sitting on the list,
|
|
227
|
+
* silently, with the right one bolted on beside it. The check makes the author
|
|
228
|
+
* fix the file, which is the artifact a consumer of the kernel reads.
|
|
229
|
+
*
|
|
230
|
+
* WHAT IT DOES NOT REACH, stated at the definition site. An `outputs` entry
|
|
231
|
+
* naming a type NO schema is registered for is SKIPPED rather than refused:
|
|
232
|
+
* there is no document to mandate, and schemas/role-brief.schema.json declares
|
|
233
|
+
* that residue deliberately (an enum there would serialise M3-P6, M3-P7 and
|
|
234
|
+
* M3-P8 against one file). It reaches the FRONTMATTER only: a brief whose body
|
|
235
|
+
* prose describes the wrong output shape passes this, and that is the judgment
|
|
236
|
+
* case the plan's hazard table hands to M3-P7's `clause-text-matches-row`
|
|
237
|
+
* probe. And it is one sub-case of the hazard row at
|
|
238
|
+
* delivery/plan/kernel-plan-m3.md:3021 ("nothing can compute which document a
|
|
239
|
+
* role NEEDS"); the output contract is the part of that which IS computable,
|
|
240
|
+
* and the rest of the row stands.
|
|
241
|
+
*/
|
|
242
|
+
/**
|
|
243
|
+
* THE ONE CANONICAL FORM OF A MANDATED-READING ENTRY (fix round 2, D-3).
|
|
244
|
+
*
|
|
245
|
+
* Entries are kernel-root-relative by definition
|
|
246
|
+
* (schemas/role-brief.schema.json:60 says resolution is "against the kernel
|
|
247
|
+
* root"), so `schemas/report.schema.json`, `./schemas/report.schema.json` and
|
|
248
|
+
* `schemas/../schemas/report.schema.json` are three spellings of one document.
|
|
249
|
+
*
|
|
250
|
+
* IT EXISTS BECAUSE TWO COMMANDS WERE COMPARING THE SAME ENTRY DIFFERENTLY.
|
|
251
|
+
* `outputContractDiagnostics` tested raw string membership while
|
|
252
|
+
* `resolveMandatedReading` resolved with `join`, which normalises; so a brief
|
|
253
|
+
* writing `./schemas/report.schema.json` COMPOSED cleanly and was REFUSED by
|
|
254
|
+
* `tiphys validate --type role-brief`. That divergence is fail-safe (the
|
|
255
|
+
* refusal is the strict side) and it is still the shape this round's own
|
|
256
|
+
* argument against injecting the entry rejected: two commands holding two
|
|
257
|
+
* opinions about one property. Both now ask this function.
|
|
258
|
+
*
|
|
259
|
+
* A LEADING `/` IS STRIPPED rather than treated as an absolute path, because
|
|
260
|
+
* `join(root, "/schemas/x")` already resolves to `<root>/schemas/x`: stripping
|
|
261
|
+
* makes the comparison agree with the resolution that was always happening,
|
|
262
|
+
* instead of introducing a form the two commands read differently. This
|
|
263
|
+
* function changes what is COMPARED; it changes nothing about what is OPENED.
|
|
264
|
+
*/
|
|
265
|
+
export function canonicalReadingEntry(entry) {
|
|
266
|
+
return posixPath.normalize(entry).replace(/^\/+/, "");
|
|
267
|
+
}
|
|
268
|
+
export function outputContractDiagnostics(outputs, reading, schemaFileForType) {
|
|
269
|
+
const diagnostics = [];
|
|
270
|
+
const declared = new Set(reading.map(canonicalReadingEntry));
|
|
271
|
+
for (let index = 0; index < outputs.length; index += 1) {
|
|
272
|
+
const output = outputs[index];
|
|
273
|
+
const file = schemaFileForType(output);
|
|
274
|
+
if (file === undefined) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const wanted = `schemas/${file}`;
|
|
278
|
+
if (!declared.has(wanted)) {
|
|
279
|
+
diagnostics.push({
|
|
280
|
+
pointer: `#/outputs/${String(index)}`,
|
|
281
|
+
message: `output type ${output} is governed by ${wanted}, which is not on mandated-reading, so this brief never tells its agent where the contract for its own output is written`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return diagnostics;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Locate the installed kernel root by walking UP from this module and testing
|
|
289
|
+
* for a `roles/` directory holding at least one brief.
|
|
290
|
+
*
|
|
291
|
+
* Counting `..` would be right in exactly one of the two layouts this code
|
|
292
|
+
* runs in (`src/` from source, `dist/src/` from the built entry), which is
|
|
293
|
+
* the layout-dependent break `schemasDirectory` in src/commands/validate.ts
|
|
294
|
+
* already documents. Walking up and TESTING is right in both, and in a
|
|
295
|
+
* relocated copy as well.
|
|
296
|
+
*/
|
|
297
|
+
export function kernelRoot() {
|
|
298
|
+
let directory = dirname(fileURLToPath(import.meta.url));
|
|
299
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
300
|
+
try {
|
|
301
|
+
const entries = readdirSync(join(directory, "roles"));
|
|
302
|
+
if (entries.some((name) => name.endsWith(".md"))) {
|
|
303
|
+
return directory;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
/* not here; keep walking */
|
|
308
|
+
}
|
|
309
|
+
const parent = dirname(directory);
|
|
310
|
+
if (parent === directory) {
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
directory = parent;
|
|
314
|
+
}
|
|
315
|
+
throw new Error("the shipped roles/ directory was not found above this module; the installation is incomplete");
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Resolve every mandated-reading path against the kernel root, IN ORDER, and
|
|
319
|
+
* stop at the first one that is not a regular file.
|
|
320
|
+
*
|
|
321
|
+
* THE PATH IS NEVER OPENED. Existence and type are established by
|
|
322
|
+
* `classifyEntry`, so a named pipe at a mandated-reading path is refused with
|
|
323
|
+
* its observed type in bounded time instead of blocking this command forever.
|
|
324
|
+
* A MISSING path and a NON-REGULAR path are different states with different
|
|
325
|
+
* failure modes and they are reported differently on purpose (criteria 2 and
|
|
326
|
+
* 6c, which the plan is explicit are not the same criterion).
|
|
327
|
+
*/
|
|
328
|
+
export function resolveMandatedReading(reading, root) {
|
|
329
|
+
const resolved = [];
|
|
330
|
+
for (const declared of reading) {
|
|
331
|
+
/* THE SAME CANONICAL FORM THE OUTPUT-CONTRACT CHECK COMPARES (D-3). It is
|
|
332
|
+
a no-op for resolution, because `join` already normalises and already
|
|
333
|
+
treats a leading `/` as root-relative; asking the shared function here
|
|
334
|
+
is what makes "one opinion, two commands" a property of the code rather
|
|
335
|
+
than a claim in a comment. The AUTHORED string is what is reported and
|
|
336
|
+
what `brief compose` renders, so the brief on disk stays readable as
|
|
337
|
+
written. */
|
|
338
|
+
const path = join(root, canonicalReadingEntry(declared));
|
|
339
|
+
const entry = classifyEntry(path);
|
|
340
|
+
if (entry.kind === "absent" || entry.kind === "dangling") {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
reason: `mandated-reading path ${declared} does not exist (looked for ${path})`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
if (entry.kind === "irregular" || entry.kind === "unexaminable") {
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
reason: `mandated-reading path ${declared}: ${entry.reason}`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
resolved.push(declared);
|
|
353
|
+
}
|
|
354
|
+
return { ok: true, paths: resolved };
|
|
355
|
+
}
|
|
356
|
+
/* ------------------------------------------------------------------ */
|
|
357
|
+
/* M3-P6: the six required sections of the implementer brief */
|
|
358
|
+
/* ------------------------------------------------------------------ */
|
|
359
|
+
/**
|
|
360
|
+
* A body SECTION anchor: `## section <id>` with an optional `: title`.
|
|
361
|
+
*
|
|
362
|
+
* A SECOND MARKER RATHER THAN A REUSE OF THE CLAUSE ONE, and the separation is
|
|
363
|
+
* the point. A clause discharges a requirement row and round-trips against
|
|
364
|
+
* `clauses[]`; a section is a structural part of the brief that R-033a
|
|
365
|
+
* enumerates, and it has no frontmatter list to round-trip against. Marking
|
|
366
|
+
* both with `clause` would mean either declaring six section ids in `clauses[]`
|
|
367
|
+
* (where the clause map would then try to resolve them as rows) or exempting
|
|
368
|
+
* six anchors from the round trip, which is a hole in the check that exists to
|
|
369
|
+
* stop labels with nothing behind them.
|
|
370
|
+
*/
|
|
371
|
+
export const SECTION_ANCHOR_PATTERN = /^#{1,6}[ \t]+section[ \t]+([A-Za-z][A-Za-z0-9-]*)[ \t]*(?::[^\n]*)?$/;
|
|
372
|
+
/**
|
|
373
|
+
* The six sections R-033a enumerates, IN THE ORDER THE ROW GIVES THEM.
|
|
374
|
+
*
|
|
375
|
+
* HAND-WRITTEN, and there is nowhere to derive it from: R-033a is a row of a
|
|
376
|
+
* markdown table in a plan document, and parsing a requirement row's prose to
|
|
377
|
+
* recover six section names would be the "deciding what another program will
|
|
378
|
+
* do by pattern-matching the text of a file" mechanism, applied to a file that
|
|
379
|
+
* is not even machine-readable. The list is short, closed, and named in the
|
|
380
|
+
* plan; a phase that changes it changes this line and the criterion that
|
|
381
|
+
* witnesses it.
|
|
382
|
+
*/
|
|
383
|
+
export const R033A_SECTIONS = [
|
|
384
|
+
"mandated-reading",
|
|
385
|
+
"phase-scope",
|
|
386
|
+
"push-protocol",
|
|
387
|
+
"gate-list",
|
|
388
|
+
"environment-warnings",
|
|
389
|
+
"reporting-contract",
|
|
390
|
+
];
|
|
391
|
+
/** Every section anchor in a body, in order, duplicates kept. */
|
|
392
|
+
export function sectionAnchors(body) {
|
|
393
|
+
const found = [];
|
|
394
|
+
for (const line of body.split("\n")) {
|
|
395
|
+
const match = SECTION_ANCHOR_PATTERN.exec(line);
|
|
396
|
+
if (match !== null) {
|
|
397
|
+
found.push(match[1]);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return found;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The text under one section anchor: everything from the line after the anchor
|
|
404
|
+
* up to the next heading of any level, or the end of the body.
|
|
405
|
+
*/
|
|
406
|
+
export function sectionBody(body, section) {
|
|
407
|
+
const lines = body.split("\n");
|
|
408
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
409
|
+
const match = SECTION_ANCHOR_PATTERN.exec(lines[index]);
|
|
410
|
+
if (match === null || match[1] !== section) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const rest = lines.slice(index + 1);
|
|
414
|
+
const end = rest.findIndex((line) => /^#{1,6}[ \t]/.test(line));
|
|
415
|
+
return (end === -1 ? rest : rest.slice(0, end)).join("\n");
|
|
416
|
+
}
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Why an include-expanded implementer body does not satisfy R-033a, or the
|
|
421
|
+
* empty list when it does.
|
|
422
|
+
*
|
|
423
|
+
* NON-EMPTY IS CHECKED, NOT ONLY PRESENT, and criterion 2 says so in as many
|
|
424
|
+
* words. A section reduced to its heading is the dangerous state here rather
|
|
425
|
+
* than a deleted one: the brief still has six anchors, still composes, still
|
|
426
|
+
* looks complete, and instructs nobody. A check that only counted anchors
|
|
427
|
+
* would be green against exactly that.
|
|
428
|
+
*/
|
|
429
|
+
export function missingRequiredSections(body) {
|
|
430
|
+
const problems = [];
|
|
431
|
+
const seen = sectionAnchors(body);
|
|
432
|
+
for (const section of R033A_SECTIONS) {
|
|
433
|
+
if (!seen.includes(section)) {
|
|
434
|
+
problems.push(`required section ${section} is missing from the brief body, and R-033a requires all six of ${R033A_SECTIONS.join(", ")}`);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if ((sectionBody(body, section) ?? "").trim() === "") {
|
|
438
|
+
problems.push(`required section ${section} is present and empty, so the brief carries the heading and none of the instruction`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return problems;
|
|
442
|
+
}
|
|
443
|
+
/* ------------------------------------------------------------------ */
|
|
444
|
+
/* M3-P6: the generated gate-list block */
|
|
445
|
+
/* ------------------------------------------------------------------ */
|
|
446
|
+
/**
|
|
447
|
+
* The markers delimiting the generated gate list inside a brief.
|
|
448
|
+
*
|
|
449
|
+
* THE MODE IS IN THE BEGIN MARKER, which is where the brief DECLARES which
|
|
450
|
+
* mode's gate set it carries. It cannot go in the frontmatter: the frontmatter
|
|
451
|
+
* schema is closed (`additionalProperties: false`) and belongs to M3-P5, and a
|
|
452
|
+
* phase that needed a new frontmatter key would be editing another phase's
|
|
453
|
+
* merged contract. The marker is body text, it is visible to a reader of the
|
|
454
|
+
* brief, and `scripts/check-brief-drift.mjs` reads the mode back out of it, so
|
|
455
|
+
* the declaration and the rendering cannot disagree about which mode was meant.
|
|
456
|
+
*
|
|
457
|
+
* HTML comments, so they are invisible in rendered markdown and unambiguous to
|
|
458
|
+
* a line scanner, and they name the producing script so the next person to edit
|
|
459
|
+
* the block by hand is told what to edit instead. The same shape M3-P2 used for
|
|
460
|
+
* CLAUDE.md, deliberately: two drift checks that look different are two things
|
|
461
|
+
* to learn.
|
|
462
|
+
*/
|
|
463
|
+
export function briefGateBlockBeginMarker(mode) {
|
|
464
|
+
return (`<!-- BEGIN GENERATED GATE LIST (mode: ${mode}): rendered from gate-registry.yaml ` +
|
|
465
|
+
"by scripts/check-brief-drift.mjs. Do not edit by hand; edit the registry. -->");
|
|
466
|
+
}
|
|
467
|
+
export const BRIEF_GATE_BLOCK_END_MARKER = "<!-- END GENERATED GATE LIST -->";
|
|
468
|
+
/**
|
|
469
|
+
* THE MODE THE SHIPPED BRIEF'S GATE BLOCK MUST DECLARE, pinned HERE and not in
|
|
470
|
+
* the brief (M3-P6 fix round 1, CV-1).
|
|
471
|
+
*
|
|
472
|
+
* The mechanism this closes, stated as a mechanism rather than as the instance
|
|
473
|
+
* that exposed it: A CHECK WHOSE SUBJECT IS SELECTED BY A VALUE READ FROM THE
|
|
474
|
+
* ARTIFACT IT AUDITS CAN BE SILENTLY NARROWED BY EDITING THAT ARTIFACT. The
|
|
475
|
+
* mode above is read out of the brief's own begin marker, deliberately, so that
|
|
476
|
+
* no CALLER can point the comparison at a mode the brief never claimed. That
|
|
477
|
+
* left the EDITOR of the brief holding the same power: switching the marker to
|
|
478
|
+
* a narrower mode and re-rendering produces a brief advertising five gates
|
|
479
|
+
* instead of fifteen with the drift check green, which is an instruction-surface
|
|
480
|
+
* defect every future implementer reads.
|
|
481
|
+
*
|
|
482
|
+
* Two clean-room contracts reached this from different directions on the same
|
|
483
|
+
* head, one by forcing the narrowing and one by deriving it from the unit
|
|
484
|
+
* arithmetic below, and neither was pointed at it.
|
|
485
|
+
*
|
|
486
|
+
* WHY IT IS A CONSTANT HERE AND NOT A REGISTRY KEY. `gate-registry.yaml` is
|
|
487
|
+
* closed (`additionalProperties: false`) and its schema belongs to M3-P2, so a
|
|
488
|
+
* registry key would be this phase editing another phase's merged contract, the
|
|
489
|
+
* same reasoning that put the mode in the marker rather than in the frontmatter.
|
|
490
|
+
* WHY `full` IS THE RIGHT VALUE is not asserted here as a bare literal: the
|
|
491
|
+
* registered test derives from the registry that this mode selects every gate
|
|
492
|
+
* any mode selects, so narrowing is the only direction the value can move.
|
|
493
|
+
*/
|
|
494
|
+
export const BRIEF_GATE_BLOCK_MODE = "full";
|
|
495
|
+
/** The begin marker's shape, with the mode captured. */
|
|
496
|
+
const BEGIN_MARKER_PATTERN = /<!-- BEGIN GENERATED GATE LIST \(mode: ([a-z][a-z0-9-]*)\): rendered from gate-registry\.yaml by scripts\/check-brief-drift\.mjs\. Do not edit by hand; edit the registry\. -->/;
|
|
497
|
+
/**
|
|
498
|
+
* Locate the generated block in a brief, or say why it cannot be located.
|
|
499
|
+
*
|
|
500
|
+
* A MISSING MARKER IS A REFUSAL AND NEVER A SILENT "NO DRIFT". A check that
|
|
501
|
+
* reports clean because it could not find the thing it compares is the
|
|
502
|
+
* guard-condition failure this repository has recorded twice: the watchdog that
|
|
503
|
+
* tested existence instead of freshness, and the byte check that could not see
|
|
504
|
+
* the one byte it existed to catch.
|
|
505
|
+
*/
|
|
506
|
+
export function locateGateBlock(text, path) {
|
|
507
|
+
const match = BEGIN_MARKER_PATTERN.exec(text);
|
|
508
|
+
if (match === null) {
|
|
509
|
+
return {
|
|
510
|
+
ok: false,
|
|
511
|
+
reason: `${path} carries no generated gate-list begin marker naming a mode`,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const mode = match[1];
|
|
515
|
+
const begin = match.index;
|
|
516
|
+
if (BEGIN_MARKER_PATTERN.exec(text.slice(begin + 1)) !== null) {
|
|
517
|
+
return { ok: false, reason: `${path} carries more than one gate-list begin marker` };
|
|
518
|
+
}
|
|
519
|
+
const end = text.indexOf(BRIEF_GATE_BLOCK_END_MARKER, begin);
|
|
520
|
+
if (end === -1) {
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
reason: `${path} carries a gate-list begin marker with no matching ${BRIEF_GATE_BLOCK_END_MARKER}`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
ok: true,
|
|
528
|
+
mode,
|
|
529
|
+
block: text.slice(begin, end + BRIEF_GATE_BLOCK_END_MARKER.length),
|
|
530
|
+
begin,
|
|
531
|
+
end,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Render the brief's gate-list block from the registry ALONE, for one mode.
|
|
536
|
+
*
|
|
537
|
+
* IT DERIVES, IT DOES NOT READ THE BLOCK. The hazard the plan names for this
|
|
538
|
+
* criterion by name is "a generated gate-list block whose drift check compares
|
|
539
|
+
* the block TO ITSELF rather than to the registry", and that check is green
|
|
540
|
+
* forever. This function takes the decoded registry and a mode string, and
|
|
541
|
+
* nothing else; the brief file is opened only to compare against or write into.
|
|
542
|
+
*
|
|
543
|
+
* ONE RENDERER, TWO CALLERS. `scripts/check-brief-drift.mjs` calls it to
|
|
544
|
+
* compare and to write, and the registered test calls it to assert the composed
|
|
545
|
+
* brief is byte-identical to the registry's rendering. A second copy of this
|
|
546
|
+
* table in the test would be the test asserting agreement with itself.
|
|
547
|
+
*/
|
|
548
|
+
export function renderBriefGateBlock(registry, mode) {
|
|
549
|
+
const selected = registry.gates.filter((gate) => (gate.modes ?? []).includes(mode));
|
|
550
|
+
const lines = [];
|
|
551
|
+
lines.push(briefGateBlockBeginMarker(mode));
|
|
552
|
+
lines.push("");
|
|
553
|
+
lines.push("Every change must pass these, in order:");
|
|
554
|
+
lines.push("");
|
|
555
|
+
let step = 0;
|
|
556
|
+
for (const entry of registry.preflight) {
|
|
557
|
+
step += 1;
|
|
558
|
+
lines.push(`${String(step)}. \`${entry.command.join(" ")}\` (${entry.note})`);
|
|
559
|
+
}
|
|
560
|
+
lines.push("");
|
|
561
|
+
lines.push(`Then the gates \`${mode}\` mode selects, run by ` +
|
|
562
|
+
`\`tiphys gates run --registry gate-registry.yaml --mode ${mode}\`:`);
|
|
563
|
+
lines.push("");
|
|
564
|
+
lines.push("| Gate | Verified by | Applicability | One unit is |");
|
|
565
|
+
lines.push("|---|---|---|---|");
|
|
566
|
+
for (const gate of selected) {
|
|
567
|
+
lines.push(`| \`${gate.id}\` | ${gate["verified-by"]}` +
|
|
568
|
+
`${gate.probe === undefined ? "" : ` (probe \`${gate.probe}\`)`}` +
|
|
569
|
+
` | ${gate.applicability} | ${gate.unitLabel} |`);
|
|
570
|
+
}
|
|
571
|
+
lines.push("");
|
|
572
|
+
lines.push(BRIEF_GATE_BLOCK_END_MARKER);
|
|
573
|
+
return { text: lines.join("\n"), units: selected.length };
|
|
574
|
+
}
|
|
575
|
+
/* ------------------------------------------------------------------ */
|
|
576
|
+
/* M3-P6: the clean-room reviewer's two review contracts (T-007) */
|
|
577
|
+
/* ------------------------------------------------------------------ */
|
|
578
|
+
/**
|
|
579
|
+
* The two review contracts of `assurance-modes.yaml`'s `review-contracts`.
|
|
580
|
+
*
|
|
581
|
+
* TWO CONTRACTS, NOT TWO REVIEWERS, and the two are different axes that full
|
|
582
|
+
* mode requires both of. The measured evidence is that both reviews of one
|
|
583
|
+
* phase walked all fifteen acceptance criteria and agreed on every mechanical
|
|
584
|
+
* fact, and the one briefed on hazards found a high-severity defect the other's
|
|
585
|
+
* report does not name.
|
|
586
|
+
*/
|
|
587
|
+
export const REVIEW_CONTRACTS = ["criteria", "hazard"];
|
|
588
|
+
/** The clause id carrying one contract's instructions. */
|
|
589
|
+
export function reviewContractClause(contract) {
|
|
590
|
+
return `review-contract-${contract}`;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The role whose brief carries a contract per value. Named rather than assumed,
|
|
594
|
+
* because `--review-contract` on any other role is a usage error and the check
|
|
595
|
+
* that says so needs something to compare against.
|
|
596
|
+
*/
|
|
597
|
+
export const REVIEW_CONTRACT_ROLE = "clean-room-reviewer";
|
|
598
|
+
/**
|
|
599
|
+
* Keep the selected contract's clause block and DROP the others.
|
|
600
|
+
*
|
|
601
|
+
* The composed brief is what a dispatched reviewer reads, and a brief carrying
|
|
602
|
+
* both contracts has told the reviewer to start from the criteria and not to
|
|
603
|
+
* start from the criteria. Dropping happens at COMPOSE time and never in the
|
|
604
|
+
* file: the file declares both clause ids and carries both blocks, so the
|
|
605
|
+
* clause round trip still sees a complete brief and `tiphys validate` still
|
|
606
|
+
* checks both texts. A design that split the two into two files would have put
|
|
607
|
+
* the shared four-fifths of the brief in two places.
|
|
608
|
+
*/
|
|
609
|
+
export function selectReviewContract(body, contract) {
|
|
610
|
+
const keep = reviewContractClause(contract);
|
|
611
|
+
const anchors = clauseAnchors(body);
|
|
612
|
+
if (!anchors.includes(keep)) {
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
reason: `review contract ${contract} selects clause ${keep}, which the brief body does not carry`,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
const drop = new Set(REVIEW_CONTRACTS.filter((other) => other !== contract).map(reviewContractClause));
|
|
619
|
+
const lines = body.split("\n");
|
|
620
|
+
const out = [];
|
|
621
|
+
let dropping = false;
|
|
622
|
+
for (const line of lines) {
|
|
623
|
+
const match = CLAUSE_ANCHOR_PATTERN.exec(line);
|
|
624
|
+
if (match !== null) {
|
|
625
|
+
dropping = drop.has(match[1]);
|
|
626
|
+
}
|
|
627
|
+
else if (dropping && /^#{1,6}[ \t]/.test(line)) {
|
|
628
|
+
dropping = false;
|
|
629
|
+
}
|
|
630
|
+
if (!dropping) {
|
|
631
|
+
out.push(line);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return { ok: true, text: out.join("\n") };
|
|
635
|
+
}
|
|
636
|
+
/* ------------------------------------------------------------------ */
|
|
637
|
+
/* The phase projection */
|
|
638
|
+
/* ------------------------------------------------------------------ */
|
|
639
|
+
/**
|
|
640
|
+
* THE FIELDS OF A PLAN PHASE THAT `brief compose` RENDERS, in order.
|
|
641
|
+
*
|
|
642
|
+
* HAND-WRITTEN ON PURPOSE, AND THE TEST THAT GUARDS IT IS NOT. Criterion 3b
|
|
643
|
+
* requires the rendered phase text to be a COMPLETE projection of the phase
|
|
644
|
+
* object, and requires the assertion to be driven FROM
|
|
645
|
+
* `schemas/plan.schema.json`'s phase `required` array rather than from a
|
|
646
|
+
* hand-written list. If this list were also derived from the schema the two
|
|
647
|
+
* would move together and the test could never redden, which is the shape of
|
|
648
|
+
* a guard whose condition does not test the property that matters.
|
|
649
|
+
*
|
|
650
|
+
* So: the SCHEMA is the test's source and this list is the renderer's, they
|
|
651
|
+
* are independent, and a later phase adding a required phase field reddens
|
|
652
|
+
* `brief compose renders every required field of the plan schema's phase` until
|
|
653
|
+
* this list is extended. The dangerous state the criterion names is the
|
|
654
|
+
* realistic one, a renderer that silently drops a field while the composed
|
|
655
|
+
* output still contains a mandated-reading list, a body and SOME phase text,
|
|
656
|
+
* and deleting an entry from this list is exactly that state.
|
|
657
|
+
*/
|
|
658
|
+
export const PHASE_FIELD_ORDER = [
|
|
659
|
+
"id",
|
|
660
|
+
"branch",
|
|
661
|
+
"intent",
|
|
662
|
+
"grounding",
|
|
663
|
+
"severity",
|
|
664
|
+
"verified-root-cause",
|
|
665
|
+
"steps",
|
|
666
|
+
"files-to-touch",
|
|
667
|
+
"extras",
|
|
668
|
+
"acceptance",
|
|
669
|
+
"hazard-classes",
|
|
670
|
+
"migrations",
|
|
671
|
+
"conflicts-with",
|
|
672
|
+
"parallelizable",
|
|
673
|
+
"citations",
|
|
674
|
+
"fill-in",
|
|
675
|
+
];
|
|
676
|
+
function renderScalar(value) {
|
|
677
|
+
if (typeof value === "string") {
|
|
678
|
+
return value.replace(/\n+$/, "");
|
|
679
|
+
}
|
|
680
|
+
return JSON.stringify(value) ?? String(value);
|
|
681
|
+
}
|
|
682
|
+
/** Render one field value as markdown lines. */
|
|
683
|
+
export function renderFieldValue(value, indent) {
|
|
684
|
+
if (Array.isArray(value)) {
|
|
685
|
+
if (value.length === 0) {
|
|
686
|
+
return [`${indent}(none)`];
|
|
687
|
+
}
|
|
688
|
+
const lines = [];
|
|
689
|
+
for (const element of value) {
|
|
690
|
+
if (element !== null && typeof element === "object" && !Array.isArray(element)) {
|
|
691
|
+
const entries = Object.entries(element);
|
|
692
|
+
const [firstKey, firstValue] = entries[0];
|
|
693
|
+
lines.push(`${indent}- ${firstKey}: ${renderScalar(firstValue)}`);
|
|
694
|
+
for (const [key, nested] of entries.slice(1)) {
|
|
695
|
+
if (Array.isArray(nested) || (nested !== null && typeof nested === "object")) {
|
|
696
|
+
lines.push(`${indent} ${key}:`);
|
|
697
|
+
lines.push(...renderFieldValue(nested, `${indent} `));
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
lines.push(`${indent} ${key}: ${renderScalar(nested)}`);
|
|
701
|
+
}
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
lines.push(`${indent}- ${renderScalar(element)}`);
|
|
705
|
+
}
|
|
706
|
+
return lines;
|
|
707
|
+
}
|
|
708
|
+
if (value !== null && typeof value === "object") {
|
|
709
|
+
const lines = [];
|
|
710
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
711
|
+
if (Array.isArray(nested) || (nested !== null && typeof nested === "object")) {
|
|
712
|
+
lines.push(`${indent}${key}:`);
|
|
713
|
+
lines.push(...renderFieldValue(nested, `${indent} `));
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
lines.push(`${indent}${key}: ${renderScalar(nested)}`);
|
|
717
|
+
}
|
|
718
|
+
return lines.length === 0 ? [`${indent}(none)`] : lines;
|
|
719
|
+
}
|
|
720
|
+
return [`${indent}${renderScalar(value)}`];
|
|
721
|
+
}
|
|
722
|
+
/** Render one plan phase as the brief's phase section. */
|
|
723
|
+
export function renderPhase(phase) {
|
|
724
|
+
const lines = [`# Phase ${String(phase["id"] ?? "(unnamed)")}`, ""];
|
|
725
|
+
for (const field of PHASE_FIELD_ORDER) {
|
|
726
|
+
if (!(field in phase)) {
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
lines.push(`### ${field}`);
|
|
730
|
+
lines.push(...renderFieldValue(phase[field], ""));
|
|
731
|
+
lines.push("");
|
|
732
|
+
}
|
|
733
|
+
return lines;
|
|
734
|
+
}
|