@tiphys/kernel 0.0.0 → 0.2.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 +663 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +299 -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 +92 -0
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +66 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +1539 -0
- package/dist/src/checks.js +5025 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +68 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +343 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +264 -0
- package/dist/src/commands/doctor.js +1546 -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 +177 -0
- package/dist/src/commands/lock.d.ts +34 -0
- package/dist/src/commands/lock.js +340 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -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 +139 -0
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +195 -0
- package/dist/src/commands/status.d.ts +21 -0
- package/dist/src/commands/status.js +122 -0
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +87 -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 +430 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +278 -0
- package/dist/src/exec/env.js +334 -0
- package/dist/src/fleet.d.ts +223 -0
- package/dist/src/fleet.js +298 -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 +1169 -0
- package/dist/src/gates/coverage.d.ts +375 -0
- package/dist/src/gates/coverage.js +836 -0
- package/dist/src/gates/credentials.d.ts +233 -0
- package/dist/src/gates/credentials.js +752 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -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 +482 -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 +614 -0
- package/dist/src/gates/run.js +1614 -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 +87 -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 +968 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +84 -0
- package/dist/src/hooks.js +125 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +256 -0
- package/dist/src/lock.js +792 -0
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +34 -0
- package/dist/src/path-identity.js +48 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +326 -0
- package/dist/src/pool.js +988 -0
- package/dist/src/roles.d.ts +461 -0
- package/dist/src/roles.js +776 -0
- package/dist/src/spawn.d.ts +482 -0
- package/dist/src/spawn.js +1003 -0
- package/dist/src/status.d.ts +116 -0
- package/dist/src/status.js +148 -0
- package/dist/src/task.d.ts +504 -0
- package/dist/src/task.js +307 -0
- package/dist/src/teardown.d.ts +39 -0
- package/dist/src/teardown.js +422 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +263 -0
- package/dist/src/validate.js +742 -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 +849 -0
- package/dist/src/witness/run.d.ts +299 -0
- package/dist/src/witness/run.js +1373 -0
- package/dist/src/witness/spec.d.ts +270 -0
- package/dist/src/witness/spec.js +475 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +526 -0
- package/gates.manifest.json +257 -0
- package/package.json +72 -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 +463 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +82 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +185 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/executor-record.schema.json +36 -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/model-resolution.schema.json +362 -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 +295 -0
- package/schemas/work-history.schema.json +183 -0
- package/schemas/write-bypass.schema.json +69 -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,482 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { pathsIdentifySameObject } from "../path-identity.js";
|
|
5
|
+
import { refuseOpenForWrite, singleLine } from "../task.js";
|
|
6
|
+
import { loadManifest } from "./manifest.js";
|
|
7
|
+
import { exitCodeForStatus, makeGateResult, renderGateResult } from "./result.js";
|
|
8
|
+
import { listWitnessSpecFiles, loadWitnessSpec, memberTouchedFiles, parseWitnessSpec, phaseOwnedMemberIndices, } from "../witness/spec.js";
|
|
9
|
+
import { SPAWN_GREP, computePhaseDiff, evaluateWitness, gitIn, makeScratchRoot, readTestFilesAtHead, removeScratchRoot, resolveRepoRoot, shellSpawnsAndParses, } from "../witness/run.js";
|
|
10
|
+
/**
|
|
11
|
+
* THE RED-WITNESS GATE (kernel plan M2, M2-P2 steps 6 and 7).
|
|
12
|
+
*
|
|
13
|
+
* Registered in gates.manifest.json as `red-witness`, applicability
|
|
14
|
+
* `required`, precondition `diff-touches` on `src/` and `bin/`, unitLabel
|
|
15
|
+
* `witnesses evaluated`. Invoked by the runner as
|
|
16
|
+
*
|
|
17
|
+
* node src/gates/red-witness.ts --result <path> --evidence <dir>
|
|
18
|
+
* --base <ref> --head <ref>
|
|
19
|
+
*
|
|
20
|
+
* plus `--baseline <ref>` when invoked directly: merge-time re-verification
|
|
21
|
+
* is a PARAMETER, not an enforcement (M2-D-08); it defaults to `--base`.
|
|
22
|
+
*
|
|
23
|
+
* WHAT THE GATE DECIDES.
|
|
24
|
+
* - Every witness spec changed in the phase diff (the phase's OWN
|
|
25
|
+
* witnesses) is evaluated by the harness.
|
|
26
|
+
* - Every STORED witness any of whose dangerous-state members touches a
|
|
27
|
+
* changed file is re-evaluated; one now green against any of its own
|
|
28
|
+
* members is red with reason "witness no longer guards its behavior"
|
|
29
|
+
* naming the witness and the measured rate (M2R-002, the N-401 shape).
|
|
30
|
+
* - A changed source file under src/ or bin/ with no witness spec
|
|
31
|
+
* covering it is RED, never not-applicable (step 7).
|
|
32
|
+
* - `--base` absent is `error` (M2-C-3).
|
|
33
|
+
*
|
|
34
|
+
* DEPTH REQUIREMENT, documented rather than assumed (STATE.md CR-902
|
|
35
|
+
* carry-forward): the gate needs `--base` and `--head` resolvable with
|
|
36
|
+
* history (the diff is base...head, a merge-base diff) and an UNSHALLOW
|
|
37
|
+
* repository, because the harness scratch-clones it and git refuses to
|
|
38
|
+
* clone from a shallow source. Both are satisfied by `fetch-depth: 0` on
|
|
39
|
+
* the CI checkout, which is owned by the workflow's owner, not this phase.
|
|
40
|
+
* A shallow repository is `error` naming the requirement. On pull_request
|
|
41
|
+
* events the checkout HEAD is a synthetic merge commit, so the audited
|
|
42
|
+
* head is always taken from `--head`, never from the checkout.
|
|
43
|
+
*/
|
|
44
|
+
const USAGE = "usage: node src/gates/red-witness.ts --result <path> --evidence <dir> " +
|
|
45
|
+
"--base <ref> [--head <ref>] [--baseline <ref>] [--phase <id>]";
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const options = {};
|
|
48
|
+
const known = new Map([
|
|
49
|
+
["--result", "result"],
|
|
50
|
+
["--evidence", "evidence"],
|
|
51
|
+
["--base", "base"],
|
|
52
|
+
["--head", "head"],
|
|
53
|
+
["--baseline", "baseline"],
|
|
54
|
+
["--phase", "phase"],
|
|
55
|
+
]);
|
|
56
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
57
|
+
const flag = argv[index];
|
|
58
|
+
const field = known.get(flag);
|
|
59
|
+
if (field === undefined) {
|
|
60
|
+
return { usageError: `unknown option ${flag}` };
|
|
61
|
+
}
|
|
62
|
+
const value = argv[index + 1];
|
|
63
|
+
if (value === undefined || value.startsWith("--")) {
|
|
64
|
+
return { usageError: `${flag} requires a value` };
|
|
65
|
+
}
|
|
66
|
+
options[field] = value;
|
|
67
|
+
}
|
|
68
|
+
return { options };
|
|
69
|
+
}
|
|
70
|
+
function now() {
|
|
71
|
+
return new Date().toISOString();
|
|
72
|
+
}
|
|
73
|
+
function errorOutcome(startedAt, detail) {
|
|
74
|
+
const result = makeGateResult({
|
|
75
|
+
gate: "red-witness",
|
|
76
|
+
status: "error",
|
|
77
|
+
units: 0,
|
|
78
|
+
unitLabel: "witnesses evaluated",
|
|
79
|
+
startedAt,
|
|
80
|
+
endedAt: now(),
|
|
81
|
+
detail,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
result,
|
|
85
|
+
exitCode: exitCodeForStatus(result.status),
|
|
86
|
+
evaluations: [],
|
|
87
|
+
reEvaluationMs: 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* True when the repo-relative path is a phase-audited source path.
|
|
92
|
+
*
|
|
93
|
+
* WHY `plugin/src/` AND NOT `plugin/`. This list is the COVERAGE OBLIGATION:
|
|
94
|
+
* a changed path here must be touched by some witness's dangerous state or the
|
|
95
|
+
* gate reports it uncovered. `src/` and `bin/` are source trees, and the plugin
|
|
96
|
+
* package's source tree is `plugin/src/`. Adding bare `plugin/` would pull in
|
|
97
|
+
* `plugin/package.json` and `plugin/tsconfig.json`, which no witness mutates
|
|
98
|
+
* and which would therefore redden every plugin phase for its own packaging.
|
|
99
|
+
*
|
|
100
|
+
* WHY IT IS NOT THE SAME LIST AS THE GATE'S PRECONDITION, which reads
|
|
101
|
+
* `src/`, `bin/`, `plugin/`. The precondition decides whether the gate RUNS.
|
|
102
|
+
* This decides what it REQUIRES. T-038 widened the first and left the second,
|
|
103
|
+
* so from that fix until this one a diff touching only `plugin/` ran the gate
|
|
104
|
+
* and took no obligation from it: a plugin phase shipping ZERO witnesses was
|
|
105
|
+
* green. Found by M4-P29 while reading this file for a different reason.
|
|
106
|
+
*/
|
|
107
|
+
function isAuditedSource(path) {
|
|
108
|
+
return (path.startsWith("src/") ||
|
|
109
|
+
path.startsWith("bin/") ||
|
|
110
|
+
path.startsWith("plugin/src/"));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Run the red-witness gate against a repository. Exported so tests can
|
|
114
|
+
* drive it without a subprocess; the CLI below is a thin shell over it.
|
|
115
|
+
*/
|
|
116
|
+
export function runRedWitnessGate(run) {
|
|
117
|
+
const startedAt = now();
|
|
118
|
+
const rootProbe = resolveRepoRoot(run.repoRoot);
|
|
119
|
+
if (rootProbe.root === undefined) {
|
|
120
|
+
return errorOutcome(startedAt, rootProbe.reason);
|
|
121
|
+
}
|
|
122
|
+
const repoRoot = rootProbe.root;
|
|
123
|
+
const shallow = gitIn(repoRoot, ["rev-parse", "--is-shallow-repository"]);
|
|
124
|
+
if (!shallow.ok) {
|
|
125
|
+
return errorOutcome(startedAt, shallow.reason);
|
|
126
|
+
}
|
|
127
|
+
if (shallow.stdout.trim() === "true") {
|
|
128
|
+
return errorOutcome(startedAt, "this repository is a shallow clone; the red-witness gate requires " +
|
|
129
|
+
"full history (fetch-depth: 0) because the phase diff is a " +
|
|
130
|
+
"merge-base diff and the harness scratch-clones the repository, " +
|
|
131
|
+
"which git refuses from a shallow source");
|
|
132
|
+
}
|
|
133
|
+
const diffOutcome = computePhaseDiff(repoRoot, run.base, run.head ?? "HEAD");
|
|
134
|
+
if (!diffOutcome.ok) {
|
|
135
|
+
return errorOutcome(startedAt, diffOutcome.reason);
|
|
136
|
+
}
|
|
137
|
+
const diff = diffOutcome.diff;
|
|
138
|
+
const manifestPath = join(repoRoot, "gates.manifest.json");
|
|
139
|
+
const manifest = loadManifest(manifestPath);
|
|
140
|
+
if (!manifest.ok) {
|
|
141
|
+
return errorOutcome(startedAt, `the destructiveCommands list could not be read (rule (e) needs it): ` +
|
|
142
|
+
`${manifest.reason}${manifest.diagnostics.length > 0 ? `: ${manifest.diagnostics.join("; ")}` : ""}`);
|
|
143
|
+
}
|
|
144
|
+
const behaviorsShown = gitIn(repoRoot, [
|
|
145
|
+
"show",
|
|
146
|
+
`${diff.headSha}:test/behaviors.json`,
|
|
147
|
+
]);
|
|
148
|
+
if (!behaviorsShown.ok) {
|
|
149
|
+
return errorOutcome(startedAt, `test/behaviors.json could not be read at the audited head: ${behaviorsShown.reason}`);
|
|
150
|
+
}
|
|
151
|
+
let behaviors;
|
|
152
|
+
try {
|
|
153
|
+
behaviors = new Set(Object.keys(JSON.parse(behaviorsShown.stdout)));
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return errorOutcome(startedAt, `test/behaviors.json does not parse at the audited head: ${singleLine(String(error))}`);
|
|
157
|
+
}
|
|
158
|
+
const testFilesOutcome = readTestFilesAtHead(repoRoot, diff.headSha);
|
|
159
|
+
if (!testFilesOutcome.ok) {
|
|
160
|
+
return errorOutcome(startedAt, testFilesOutcome.reason);
|
|
161
|
+
}
|
|
162
|
+
// Rule (f)'s derivation: the spawn grep over the changed files' head
|
|
163
|
+
// contents (deleted files have no head content and cannot be touched by a
|
|
164
|
+
// member either). Shell scripts (*.sh) are screened by the shell
|
|
165
|
+
// spawn-and-parse derivation instead of the JS token grep (CR-H2), so a
|
|
166
|
+
// bin/ shell script that classifies another program's output (the V-2
|
|
167
|
+
// shape) is not invisible to the capture obligation.
|
|
168
|
+
const spawningChangedFiles = [];
|
|
169
|
+
for (const [path, file] of diff.files) {
|
|
170
|
+
if (file.status === "D") {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const shown = gitIn(repoRoot, ["show", `${diff.headSha}:${path}`]);
|
|
174
|
+
if (!shown.ok) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const spawns = path.endsWith(".sh")
|
|
178
|
+
? shellSpawnsAndParses(shown.stdout)
|
|
179
|
+
: SPAWN_GREP.test(shown.stdout);
|
|
180
|
+
if (spawns) {
|
|
181
|
+
spawningChangedFiles.push(path);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
spawningChangedFiles.sort();
|
|
185
|
+
const witnessDir = join(repoRoot, "witness");
|
|
186
|
+
const listing = listWitnessSpecFiles(witnessDir);
|
|
187
|
+
if (!listing.ok) {
|
|
188
|
+
return errorOutcome(startedAt, listing.reason);
|
|
189
|
+
}
|
|
190
|
+
const reasons = [];
|
|
191
|
+
const specs = [];
|
|
192
|
+
const seenIds = new Map();
|
|
193
|
+
for (const path of listing.paths) {
|
|
194
|
+
const loaded = loadWitnessSpec(path);
|
|
195
|
+
const repoRelative = relative(repoRoot, path).split("\\").join("/");
|
|
196
|
+
if (!loaded.ok) {
|
|
197
|
+
reasons.push(`${loaded.reason}${loaded.diagnostics.length > 0 ? `: ${loaded.diagnostics.join("; ")}` : ""}`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const previous = seenIds.get(loaded.spec.id);
|
|
201
|
+
if (previous !== undefined) {
|
|
202
|
+
reasons.push(`witness id ${loaded.spec.id} is declared by both ${previous} and ${repoRelative}`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
seenIds.set(loaded.spec.id, repoRelative);
|
|
206
|
+
specs.push({ spec: loaded.spec, path, repoRelative });
|
|
207
|
+
}
|
|
208
|
+
const readPatchAtHead = (patchPath) => {
|
|
209
|
+
const shown = gitIn(repoRoot, ["show", `${diff.headSha}:${patchPath}`]);
|
|
210
|
+
return shown.ok ? shown.stdout : undefined;
|
|
211
|
+
};
|
|
212
|
+
const own = specs.filter((entry) => diff.files.has(entry.repoRelative));
|
|
213
|
+
const stored = specs.filter((entry) => !diff.files.has(entry.repoRelative));
|
|
214
|
+
/**
|
|
215
|
+
* A patch member's body at the MERGE BASE, the old side of the ownership
|
|
216
|
+
* comparison. `readPatchAtHead` above is the new side. Two readers rather
|
|
217
|
+
* than one because the whole point of reading the body is that the same path
|
|
218
|
+
* can hold different content on the two revisions.
|
|
219
|
+
*/
|
|
220
|
+
const readPatchAtMergeBase = (patchPath) => {
|
|
221
|
+
const shown = gitIn(repoRoot, ["show", `${diff.mergeBaseSha}:${patchPath}`]);
|
|
222
|
+
return shown.ok ? shown.stdout : undefined;
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* The members of an own spec that THIS PHASE AUTHORED, which is rule (d)'s
|
|
226
|
+
* scope. Membership in `own` is file-granular ("some byte of this spec
|
|
227
|
+
* changed") and rule (d)'s obligation is member-granular ("this declared
|
|
228
|
+
* dangerous state must intersect the diff"), so the two are reconciled here
|
|
229
|
+
* rather than by handing the harness a boolean for the whole file.
|
|
230
|
+
*
|
|
231
|
+
* The old side is read at the MERGE BASE, which is the revision the diff
|
|
232
|
+
* itself is taken against. A baseline that is absent, unreadable or invalid
|
|
233
|
+
* yields `undefined`, and `phaseOwnedMemberIndices` then owns every member:
|
|
234
|
+
* an added spec is wholly the phase's, and so is one whose previous version
|
|
235
|
+
* cannot be established.
|
|
236
|
+
*
|
|
237
|
+
* The WHOLE spec goes in, not just its members, because the spec's claim
|
|
238
|
+
* (`behavior` and `tests`) is part of what rule (d) is an obligation on. See
|
|
239
|
+
* `claimRePointed` for which fields are in that set, why the other four are
|
|
240
|
+
* not, and why the comparison is DIRECTIONAL: re-pointing a claim takes the
|
|
241
|
+
* obligation, extending its named tests does not.
|
|
242
|
+
*/
|
|
243
|
+
const ownedMembersOf = (entry) => {
|
|
244
|
+
const readers = { head: readPatchAtHead, baseline: readPatchAtMergeBase };
|
|
245
|
+
const shown = gitIn(repoRoot, [
|
|
246
|
+
"show",
|
|
247
|
+
`${diff.mergeBaseSha}:${entry.repoRelative}`,
|
|
248
|
+
]);
|
|
249
|
+
if (!shown.ok) {
|
|
250
|
+
return phaseOwnedMemberIndices(entry.spec, undefined, readers);
|
|
251
|
+
}
|
|
252
|
+
const baseline = parseWitnessSpec(shown.stdout, `${diff.mergeBaseSha}:${entry.repoRelative}`);
|
|
253
|
+
return phaseOwnedMemberIndices(entry.spec, baseline.ok ? baseline.spec : undefined, readers);
|
|
254
|
+
};
|
|
255
|
+
const triggeredStored = stored.filter((entry) => entry.spec.dangerousStates.some((member) => memberTouchedFiles(member, readPatchAtHead).some((file) => diff.files.has(file))));
|
|
256
|
+
// Coverage (step 7): source changed with no witness spec covering it is
|
|
257
|
+
// red, never not-applicable. Coverage semantics are decision D-P2-2 in
|
|
258
|
+
// the work history.
|
|
259
|
+
const covered = new Set();
|
|
260
|
+
for (const entry of specs) {
|
|
261
|
+
for (const member of entry.spec.dangerousStates) {
|
|
262
|
+
for (const file of memberTouchedFiles(member, readPatchAtHead)) {
|
|
263
|
+
covered.add(file);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const uncovered = [];
|
|
268
|
+
for (const [path, file] of diff.files) {
|
|
269
|
+
if (file.status === "D" || !isAuditedSource(path)) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (!covered.has(path)) {
|
|
273
|
+
uncovered.push(path);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
uncovered.sort();
|
|
277
|
+
if (uncovered.length > 0) {
|
|
278
|
+
reasons.push(`source changed with no witness spec covering it: ${uncovered.join(", ")}`);
|
|
279
|
+
}
|
|
280
|
+
const evaluations = [];
|
|
281
|
+
let reEvaluationMs = 0;
|
|
282
|
+
const scratchRoot = makeScratchRoot();
|
|
283
|
+
try {
|
|
284
|
+
const baseInputs = {
|
|
285
|
+
repoRoot,
|
|
286
|
+
headSha: diff.headSha,
|
|
287
|
+
baselineRef: run.baseline ?? run.base,
|
|
288
|
+
diff,
|
|
289
|
+
destructiveCommands: manifest.manifest.destructiveCommands,
|
|
290
|
+
behaviors,
|
|
291
|
+
testFiles: testFilesOutcome.files,
|
|
292
|
+
spawningChangedFiles,
|
|
293
|
+
scratchRoot,
|
|
294
|
+
};
|
|
295
|
+
for (const entry of own) {
|
|
296
|
+
const inputs = {
|
|
297
|
+
...baseInputs,
|
|
298
|
+
phaseOwnedMembers: ownedMembersOf(entry),
|
|
299
|
+
};
|
|
300
|
+
if (run.hooks !== undefined) {
|
|
301
|
+
inputs.hooks = run.hooks;
|
|
302
|
+
}
|
|
303
|
+
const evaluation = evaluateWitness(entry.spec, entry.repoRelative, inputs);
|
|
304
|
+
evaluations.push(evaluation);
|
|
305
|
+
if (evaluation.status !== "green") {
|
|
306
|
+
reasons.push(`witness ${evaluation.witness}: ${evaluation.status}: ${evaluation.reasons.join("; ")}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const reEvaluationStart = Date.now();
|
|
310
|
+
for (const entry of triggeredStored) {
|
|
311
|
+
// A stored witness is one the phase diff does not touch at all, so no
|
|
312
|
+
// member of it is the phase's and rule (d) has nothing to apply to.
|
|
313
|
+
const inputs = {
|
|
314
|
+
...baseInputs,
|
|
315
|
+
phaseOwnedMembers: new Set(),
|
|
316
|
+
};
|
|
317
|
+
if (run.hooks !== undefined) {
|
|
318
|
+
inputs.hooks = run.hooks;
|
|
319
|
+
}
|
|
320
|
+
const evaluation = evaluateWitness(entry.spec, entry.repoRelative, inputs);
|
|
321
|
+
evaluations.push(evaluation);
|
|
322
|
+
if (evaluation.status === "red") {
|
|
323
|
+
const rates = evaluation.members
|
|
324
|
+
.filter((member) => member.rate !== undefined)
|
|
325
|
+
.map((member) => `member ${String(member.index)} red ` +
|
|
326
|
+
`${String(member.rate?.red)}/${String(member.rate?.total)}`)
|
|
327
|
+
.join(", ");
|
|
328
|
+
reasons.push(`witness ${evaluation.witness} no longer guards its behavior ` +
|
|
329
|
+
`(${rates === "" ? evaluation.reasons.join("; ") : rates})`);
|
|
330
|
+
}
|
|
331
|
+
else if (evaluation.status === "error") {
|
|
332
|
+
reasons.push(`witness ${evaluation.witness}: error: ${evaluation.reasons.join("; ")}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
reEvaluationMs = Date.now() - reEvaluationStart;
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
removeScratchRoot(scratchRoot);
|
|
339
|
+
}
|
|
340
|
+
let status = "green";
|
|
341
|
+
if (evaluations.some((evaluation) => evaluation.status === "error")) {
|
|
342
|
+
status = "error";
|
|
343
|
+
}
|
|
344
|
+
else if (reasons.length > 0) {
|
|
345
|
+
status = "red";
|
|
346
|
+
}
|
|
347
|
+
const evidence = [];
|
|
348
|
+
if (run.evidenceDir !== undefined) {
|
|
349
|
+
const recordsPath = join(run.evidenceDir, "witness-records.json");
|
|
350
|
+
const refusal = refuseOpenForWrite(recordsPath);
|
|
351
|
+
if (refusal === undefined) {
|
|
352
|
+
try {
|
|
353
|
+
mkdirSync(run.evidenceDir, { recursive: true });
|
|
354
|
+
writeFileSync(recordsPath, `${JSON.stringify({
|
|
355
|
+
base: diff.baseSha,
|
|
356
|
+
head: diff.headSha,
|
|
357
|
+
spawningChangedFiles,
|
|
358
|
+
uncoveredSources: uncovered,
|
|
359
|
+
reEvaluationMs,
|
|
360
|
+
evaluations,
|
|
361
|
+
}, null, 2)}\n`);
|
|
362
|
+
evidence.push("witness-records.json");
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
// The record still carries the verdict; evidence is best-effort.
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const detail = `${String(evaluations.length)} witness(es) evaluated ` +
|
|
370
|
+
`(${String(own.length)} own, ${String(triggeredStored.length)} stored ` +
|
|
371
|
+
`re-evaluated in ${String(reEvaluationMs)}ms); ` +
|
|
372
|
+
(reasons.length === 0
|
|
373
|
+
? "every witness red against every declared dangerous state and green at head"
|
|
374
|
+
: reasons.join("; "));
|
|
375
|
+
const result = makeGateResult({
|
|
376
|
+
gate: "red-witness",
|
|
377
|
+
status,
|
|
378
|
+
units: evaluations.length,
|
|
379
|
+
unitLabel: "witnesses evaluated",
|
|
380
|
+
startedAt,
|
|
381
|
+
endedAt: now(),
|
|
382
|
+
detail,
|
|
383
|
+
evidence,
|
|
384
|
+
});
|
|
385
|
+
return {
|
|
386
|
+
result,
|
|
387
|
+
exitCode: exitCodeForStatus(result.status),
|
|
388
|
+
evaluations,
|
|
389
|
+
reEvaluationMs,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function main(argv) {
|
|
393
|
+
const parsed = parseArgs(argv);
|
|
394
|
+
if (parsed.options === undefined) {
|
|
395
|
+
process.stderr.write(`tiphys red-witness: ${parsed.usageError}\n${USAGE}\n`);
|
|
396
|
+
return 64;
|
|
397
|
+
}
|
|
398
|
+
const options = parsed.options;
|
|
399
|
+
if (options.result === undefined) {
|
|
400
|
+
process.stderr.write(`tiphys red-witness: --result is required\n${USAGE}\n`);
|
|
401
|
+
return 64;
|
|
402
|
+
}
|
|
403
|
+
let outcome;
|
|
404
|
+
if (options.base === undefined) {
|
|
405
|
+
// M2-C-3 (M2R-003): a required invocation parameter absent is error,
|
|
406
|
+
// never not-applicable and never a guess.
|
|
407
|
+
outcome = errorOutcome(now(), "--base was not supplied; the phase diff cannot be computed (M2-C-3)");
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
const run = { repoRoot: process.cwd(), base: options.base };
|
|
411
|
+
if (options.head !== undefined) {
|
|
412
|
+
run.head = options.head;
|
|
413
|
+
}
|
|
414
|
+
if (options.baseline !== undefined) {
|
|
415
|
+
run.baseline = options.baseline;
|
|
416
|
+
}
|
|
417
|
+
if (options.evidence !== undefined) {
|
|
418
|
+
run.evidenceDir = options.evidence;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
outcome = runRedWitnessGate(run);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
// No throw may escape as exit 1: that is the RED code (the runner's
|
|
425
|
+
// own crash-discipline rule, applied to this gate).
|
|
426
|
+
outcome = errorOutcome(now(), `the red-witness gate failed: ${singleLine(error.message ?? String(error))}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const refusal = refuseOpenForWrite(options.result);
|
|
430
|
+
if (refusal !== undefined) {
|
|
431
|
+
process.stderr.write(`tiphys red-witness: ${refusal}\n`);
|
|
432
|
+
return 21;
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
writeFileSync(options.result, renderGateResult(outcome.result));
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
process.stderr.write(`tiphys red-witness: the result record could not be written: ${singleLine(String(error))}\n`);
|
|
439
|
+
return 21;
|
|
440
|
+
}
|
|
441
|
+
process.stdout.write(`red-witness: ${outcome.result.status} (${outcome.result.detail})\n`);
|
|
442
|
+
return outcome.exitCode;
|
|
443
|
+
}
|
|
444
|
+
const invokedDirectly = (() => {
|
|
445
|
+
const entry = process.argv[1];
|
|
446
|
+
if (entry === undefined) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
// IDENTITY, NOT STRING EQUALITY (M4-P2 fix round, 2026-09-16). Going
|
|
450
|
+
// through a URL does not change what is compared: `resolve` leaves the
|
|
451
|
+
// caller's spelling intact while `import.meta.url` is canonical, so an
|
|
452
|
+
// invocation through a symlink leaves this gate silently not running.
|
|
453
|
+
return pathsIdentifySameObject(fileURLToPath(import.meta.url), entry);
|
|
454
|
+
})();
|
|
455
|
+
if (invokedDirectly) {
|
|
456
|
+
// `process.exitCode`, NEVER `process.exit(main(...))` (M4-P29). This gate
|
|
457
|
+
// runs as a SUBPROCESS, so fd 1 is a buffered stream the parent owns and a
|
|
458
|
+
// write to one is QUEUED rather than completed. `process.exit` ends the
|
|
459
|
+
// process without draining that queue, so everything past the buffer is
|
|
460
|
+
// DISCARDED, while the exit code survives and the loss is silent.
|
|
461
|
+
//
|
|
462
|
+
// BE EXACT ABOUT WHICH STREAM, because the ceiling differs by a factor of
|
|
463
|
+
// two and the obvious word is the wrong one. `spawnSync`, which is how the
|
|
464
|
+
// registry runner invokes gates (src/gates/run.ts:1528), hands a child
|
|
465
|
+
// SOCKETPAIRS, not pipes; a shell pipeline, a `tee` or a CI log collector
|
|
466
|
+
// hands it a real pipe. Measured at the pre-fix parent commit on this
|
|
467
|
+
// container, same invocation, only the reader changed:
|
|
468
|
+
//
|
|
469
|
+
// reader wrote delivered
|
|
470
|
+
// regular file 176,530 176,530
|
|
471
|
+
// shell pipe 176,530 65,536 (one pipe buffer)
|
|
472
|
+
// spawnSync 434,530 146,176 (the socket send buffer)
|
|
473
|
+
//
|
|
474
|
+
// A regular file never truncates, which is why this survives casual
|
|
475
|
+
// testing. This gate is the measured instance rather than a hypothetical
|
|
476
|
+
// one: its single stdout line carries the whole `detail`, and `detail`
|
|
477
|
+
// grows with the number of uncovered sources and of red witnesses.
|
|
478
|
+
// Assigning `process.exitCode` lets the process end normally, which drains
|
|
479
|
+
// the queue first, and the full capture is
|
|
480
|
+
// witness/captures/m4-p29-gate-cli-stdio.txt.
|
|
481
|
+
process.exitCode = main(process.argv.slice(2));
|
|
482
|
+
}
|