@tiphys/kernel 0.1.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 +56 -4
- package/assurance-modes.yaml +23 -2
- package/dist/bin/tiphys.js +86 -8
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.js +27 -20
- package/dist/src/checks.d.ts +720 -9
- package/dist/src/checks.js +1874 -163
- package/dist/src/cli.js +11 -0
- package/dist/src/commands/brief.js +27 -4
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +229 -0
- package/dist/src/commands/doctor.js +968 -27
- package/dist/src/commands/init.d.ts +3 -3
- package/dist/src/commands/init.js +57 -8
- package/dist/src/commands/lock.d.ts +33 -0
- package/dist/src/commands/lock.js +117 -6
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/pool.js +12 -1
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.js +51 -2
- package/dist/src/commands/status.d.ts +6 -4
- package/dist/src/commands/status.js +6 -4
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.js +10 -2
- package/dist/src/commands/validate.js +70 -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 +152 -2
- package/dist/src/exec/env.js +146 -2
- package/dist/src/fleet.d.ts +172 -0
- package/dist/src/fleet.js +219 -1
- package/dist/src/gates/citations.js +7 -1
- package/dist/src/gates/coverage.d.ts +113 -22
- package/dist/src/gates/coverage.js +166 -31
- package/dist/src/gates/credentials.d.ts +159 -0
- package/dist/src/gates/credentials.js +221 -2
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/red-witness.js +105 -13
- package/dist/src/gates/run.d.ts +49 -1
- package/dist/src/gates/run.js +83 -5
- package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
- package/dist/src/gates/suite.js +48 -7
- package/dist/src/hooks.d.ts +55 -3
- package/dist/src/hooks.js +69 -6
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/lock.d.ts +82 -4
- package/dist/src/lock.js +314 -22
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/path-identity.d.ts +32 -0
- package/dist/src/path-identity.js +38 -0
- package/dist/src/pool.d.ts +197 -1
- package/dist/src/pool.js +289 -22
- package/dist/src/roles.d.ts +31 -0
- package/dist/src/roles.js +42 -0
- package/dist/src/spawn.d.ts +307 -2
- package/dist/src/spawn.js +690 -19
- package/dist/src/status.d.ts +27 -2
- package/dist/src/status.js +34 -5
- package/dist/src/task.d.ts +295 -55
- package/dist/src/task.js +125 -123
- package/dist/src/teardown.d.ts +7 -0
- package/dist/src/teardown.js +120 -12
- package/dist/src/validate.d.ts +44 -11
- package/dist/src/validate.js +44 -34
- package/dist/src/watcher.js +1 -11
- package/dist/src/witness/run.d.ts +32 -7
- package/dist/src/witness/run.js +76 -30
- package/dist/src/witness/spec.d.ts +168 -0
- package/dist/src/witness/spec.js +240 -18
- package/dist/tsconfig.src.tsbuildinfo +1 -1
- package/gate-registry.yaml +136 -0
- package/gates.manifest.json +63 -1
- package/package.json +18 -3
- package/roles/implementer.md +3 -0
- package/schemas/README.md +1 -0
- package/schemas/assurance-modes.schema.json +1 -1
- package/schemas/charter.schema.json +19 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/verdict.schema.json +9 -3
- package/schemas/write-bypass.schema.json +69 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join, relative, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { pathsIdentifySameObject } from "../path-identity.js";
|
|
4
5
|
import { refuseOpenForWrite, singleLine } from "../task.js";
|
|
5
6
|
import { loadManifest } from "./manifest.js";
|
|
6
7
|
import { exitCodeForStatus, makeGateResult, renderGateResult } from "./result.js";
|
|
7
|
-
import { listWitnessSpecFiles, loadWitnessSpec, memberTouchedFiles, } from "../witness/spec.js";
|
|
8
|
+
import { listWitnessSpecFiles, loadWitnessSpec, memberTouchedFiles, parseWitnessSpec, phaseOwnedMemberIndices, } from "../witness/spec.js";
|
|
8
9
|
import { SPAWN_GREP, computePhaseDiff, evaluateWitness, gitIn, makeScratchRoot, readTestFilesAtHead, removeScratchRoot, resolveRepoRoot, shellSpawnsAndParses, } from "../witness/run.js";
|
|
9
10
|
/**
|
|
10
11
|
* THE RED-WITNESS GATE (kernel plan M2, M2-P2 steps 6 and 7).
|
|
@@ -86,9 +87,27 @@ function errorOutcome(startedAt, detail) {
|
|
|
86
87
|
reEvaluationMs: 0,
|
|
87
88
|
};
|
|
88
89
|
}
|
|
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
|
+
*/
|
|
90
107
|
function isAuditedSource(path) {
|
|
91
|
-
return path.startsWith("src/") ||
|
|
108
|
+
return (path.startsWith("src/") ||
|
|
109
|
+
path.startsWith("bin/") ||
|
|
110
|
+
path.startsWith("plugin/src/"));
|
|
92
111
|
}
|
|
93
112
|
/**
|
|
94
113
|
* Run the red-witness gate against a repository. Exported so tests can
|
|
@@ -192,6 +211,47 @@ export function runRedWitnessGate(run) {
|
|
|
192
211
|
};
|
|
193
212
|
const own = specs.filter((entry) => diff.files.has(entry.repoRelative));
|
|
194
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
|
+
};
|
|
195
255
|
const triggeredStored = stored.filter((entry) => entry.spec.dangerousStates.some((member) => memberTouchedFiles(member, readPatchAtHead).some((file) => diff.files.has(file))));
|
|
196
256
|
// Coverage (step 7): source changed with no witness spec covering it is
|
|
197
257
|
// red, never not-applicable. Coverage semantics are decision D-P2-2 in
|
|
@@ -233,7 +293,10 @@ export function runRedWitnessGate(run) {
|
|
|
233
293
|
scratchRoot,
|
|
234
294
|
};
|
|
235
295
|
for (const entry of own) {
|
|
236
|
-
const inputs = {
|
|
296
|
+
const inputs = {
|
|
297
|
+
...baseInputs,
|
|
298
|
+
phaseOwnedMembers: ownedMembersOf(entry),
|
|
299
|
+
};
|
|
237
300
|
if (run.hooks !== undefined) {
|
|
238
301
|
inputs.hooks = run.hooks;
|
|
239
302
|
}
|
|
@@ -245,7 +308,12 @@ export function runRedWitnessGate(run) {
|
|
|
245
308
|
}
|
|
246
309
|
const reEvaluationStart = Date.now();
|
|
247
310
|
for (const entry of triggeredStored) {
|
|
248
|
-
|
|
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
|
+
};
|
|
249
317
|
if (run.hooks !== undefined) {
|
|
250
318
|
inputs.hooks = run.hooks;
|
|
251
319
|
}
|
|
@@ -378,13 +446,37 @@ const invokedDirectly = (() => {
|
|
|
378
446
|
if (entry === undefined) {
|
|
379
447
|
return false;
|
|
380
448
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
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);
|
|
387
454
|
})();
|
|
388
455
|
if (invokedDirectly) {
|
|
389
|
-
process.exit(main(
|
|
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));
|
|
390
482
|
}
|
package/dist/src/gates/run.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GateEntry, GateManifest, RunParameter } from "./manifest.ts";
|
|
2
|
-
import type { GateStatus } from "./result.ts";
|
|
2
|
+
import type { GateStatus, PreconditionRecord } from "./result.ts";
|
|
3
3
|
/**
|
|
4
4
|
* THE GATE RUNNER (kernel plan M2, M2-P1 step 7 and step 8).
|
|
5
5
|
*
|
|
@@ -143,6 +143,13 @@ export interface GateSummaryRow {
|
|
|
143
143
|
unitLabel: string;
|
|
144
144
|
vacuous: boolean;
|
|
145
145
|
applicable: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* M4-P11, DR-0038. True when this gate's `not-applicable` carries a
|
|
148
|
+
* declaration in its precondition evidence. Present on the ROW as well as in
|
|
149
|
+
* the reason line, because the reason line is one line and a reader auditing
|
|
150
|
+
* the bundle needs to reach the gate that declared without parsing prose.
|
|
151
|
+
*/
|
|
152
|
+
declaredNotApplicable?: boolean;
|
|
146
153
|
detail: string;
|
|
147
154
|
record?: string;
|
|
148
155
|
stdout?: string;
|
|
@@ -188,6 +195,13 @@ export interface RunSummary {
|
|
|
188
195
|
};
|
|
189
196
|
/** Named here as well as in the rows, because the reason line is one line. */
|
|
190
197
|
requiredNotApplicable: string[];
|
|
198
|
+
/**
|
|
199
|
+
* M4-P11, DR-0038. Every gate whose `not-applicable` carries a declaration,
|
|
200
|
+
* sorted. Always present, EMPTY when there are none, so a consumer can tell
|
|
201
|
+
* "this run declared nothing" from "this run is from before the field
|
|
202
|
+
* existed"; an absent field would be the silence that reads as permission.
|
|
203
|
+
*/
|
|
204
|
+
declaredNotApplicable: string[];
|
|
191
205
|
/** True when a throw escaped the run and this summary is a partial record. */
|
|
192
206
|
aborted: boolean;
|
|
193
207
|
exitCode: number;
|
|
@@ -209,6 +223,39 @@ export interface RunOutcome {
|
|
|
209
223
|
reason?: string;
|
|
210
224
|
}
|
|
211
225
|
export declare const NO_APPLICABLE_GATE = "no applicable gate";
|
|
226
|
+
/**
|
|
227
|
+
* THE EVIDENCE ENTRY THAT MARKS A NOT-APPLICABLE AS DECLARED (M4-P11, DR-0038).
|
|
228
|
+
*
|
|
229
|
+
* `src/gates/release.ts:1050` already writes this exact string into
|
|
230
|
+
* `precondition.evidence` when a release verification is declared `none`, and
|
|
231
|
+
* `scripts/check-dual-review.mjs` now writes it for the declared single-family
|
|
232
|
+
* review exception. The owner's requirement for that exception is that nobody
|
|
233
|
+
* can hide it; it is stated here as a runner-level property because the place
|
|
234
|
+
* an exception hides is the AGGREGATE, not the gate's own record.
|
|
235
|
+
*
|
|
236
|
+
* WHY AN ARRAY ELEMENT AND NOT A FIELD. A boolean on `PreconditionRecord` would
|
|
237
|
+
* be the right home, and `src/gates/schemas/gate-result.schema.json` is
|
|
238
|
+
* `additionalProperties: false` on that object, so adding one is a schema
|
|
239
|
+
* change outside M4-P11's files-to-touch list. What is done instead is the
|
|
240
|
+
* narrowest available thing that is still structural: an EXACT element of a
|
|
241
|
+
* structured array, compared with `===`, never a pattern over the detail prose.
|
|
242
|
+
* MECHANISMS.md's row about deciding what another program will do by
|
|
243
|
+
* pattern-matching the text of a file it wrote is the failure this avoids, and
|
|
244
|
+
* matching one whole array element is on the safe side of it because the
|
|
245
|
+
* producer writes the element for this purpose and nothing else.
|
|
246
|
+
*/
|
|
247
|
+
export declare const DECLARED_PRECONDITION_EVIDENCE = "declared: true";
|
|
248
|
+
/**
|
|
249
|
+
* Does this record's precondition carry a declaration?
|
|
250
|
+
*
|
|
251
|
+
* A not-applicable with NO precondition is not declared, and neither is one
|
|
252
|
+
* whose precondition carries no evidence: silence is never permission, and it
|
|
253
|
+
* is never a declaration either.
|
|
254
|
+
*/
|
|
255
|
+
export declare function isDeclaredNotApplicable(result: {
|
|
256
|
+
status: GateStatus;
|
|
257
|
+
precondition?: PreconditionRecord;
|
|
258
|
+
}): boolean;
|
|
212
259
|
/** The default assurance mode when `--registry` is given without `--mode`. */
|
|
213
260
|
export declare const DEFAULT_MODE = "full";
|
|
214
261
|
export type RegistryLoad = {
|
|
@@ -525,6 +572,7 @@ export interface AggregateCounts {
|
|
|
525
572
|
export declare function decideAggregate(counts: AggregateCounts, requiredNotApplicable: string[], rows: {
|
|
526
573
|
id: string;
|
|
527
574
|
status: GateStatus;
|
|
575
|
+
declaredNotApplicable?: boolean;
|
|
528
576
|
}[]): {
|
|
529
577
|
exitCode: number;
|
|
530
578
|
reason: string;
|
package/dist/src/gates/run.js
CHANGED
|
@@ -16,6 +16,45 @@ import { loadManifest, validateManifestDocument, validateResultDocument } from "
|
|
|
16
16
|
import { comparePins, describePinDifference } from "./pin.js";
|
|
17
17
|
import { EXIT_GATE_ERROR, EXIT_GREEN, EXIT_NOT_APPLICABLE, EXIT_RED, M2_C_2_DETAIL, exitCodeForStatus, makeGateResult, renderGateResult, statusForExitCode, } from "./result.js";
|
|
18
18
|
export const NO_APPLICABLE_GATE = "no applicable gate";
|
|
19
|
+
/**
|
|
20
|
+
* THE EVIDENCE ENTRY THAT MARKS A NOT-APPLICABLE AS DECLARED (M4-P11, DR-0038).
|
|
21
|
+
*
|
|
22
|
+
* `src/gates/release.ts:1050` already writes this exact string into
|
|
23
|
+
* `precondition.evidence` when a release verification is declared `none`, and
|
|
24
|
+
* `scripts/check-dual-review.mjs` now writes it for the declared single-family
|
|
25
|
+
* review exception. The owner's requirement for that exception is that nobody
|
|
26
|
+
* can hide it; it is stated here as a runner-level property because the place
|
|
27
|
+
* an exception hides is the AGGREGATE, not the gate's own record.
|
|
28
|
+
*
|
|
29
|
+
* WHY AN ARRAY ELEMENT AND NOT A FIELD. A boolean on `PreconditionRecord` would
|
|
30
|
+
* be the right home, and `src/gates/schemas/gate-result.schema.json` is
|
|
31
|
+
* `additionalProperties: false` on that object, so adding one is a schema
|
|
32
|
+
* change outside M4-P11's files-to-touch list. What is done instead is the
|
|
33
|
+
* narrowest available thing that is still structural: an EXACT element of a
|
|
34
|
+
* structured array, compared with `===`, never a pattern over the detail prose.
|
|
35
|
+
* MECHANISMS.md's row about deciding what another program will do by
|
|
36
|
+
* pattern-matching the text of a file it wrote is the failure this avoids, and
|
|
37
|
+
* matching one whole array element is on the safe side of it because the
|
|
38
|
+
* producer writes the element for this purpose and nothing else.
|
|
39
|
+
*/
|
|
40
|
+
export const DECLARED_PRECONDITION_EVIDENCE = "declared: true";
|
|
41
|
+
/**
|
|
42
|
+
* Does this record's precondition carry a declaration?
|
|
43
|
+
*
|
|
44
|
+
* A not-applicable with NO precondition is not declared, and neither is one
|
|
45
|
+
* whose precondition carries no evidence: silence is never permission, and it
|
|
46
|
+
* is never a declaration either.
|
|
47
|
+
*/
|
|
48
|
+
export function isDeclaredNotApplicable(result) {
|
|
49
|
+
if (result.status !== "not-applicable") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const evidence = result.precondition?.evidence;
|
|
53
|
+
if (!Array.isArray(evidence)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return evidence.some((entry) => entry === DECLARED_PRECONDITION_EVIDENCE);
|
|
57
|
+
}
|
|
19
58
|
/** The default assurance mode when `--registry` is given without `--mode`. */
|
|
20
59
|
export const DEFAULT_MODE = "full";
|
|
21
60
|
/**
|
|
@@ -1078,6 +1117,29 @@ function pinRefusal(record) {
|
|
|
1078
1117
|
* at the aggregate level, M2R-012).
|
|
1079
1118
|
*/
|
|
1080
1119
|
export function decideAggregate(counts, requiredNotApplicable, rows) {
|
|
1120
|
+
/* M4-P11, DR-0038. THE ONE THING A DECLARED EXCEPTION MUST NEVER BE IS
|
|
1121
|
+
INVISIBLE, AND THE AGGREGATE IS WHERE IT WOULD BE.
|
|
1122
|
+
`scripts/check-dual-review.mjs` is a CONDITIONAL gate, so its
|
|
1123
|
+
not-applicable never reaches `requiredNotApplicable` and never appears in
|
|
1124
|
+
the reason line. Before this clause, a bundle carrying a gate that had
|
|
1125
|
+
declined DR-0012's cross-family requirement by declaration printed "every
|
|
1126
|
+
applicable gate is green" and exited 0, and the exception appeared nowhere
|
|
1127
|
+
a reader of the bundle would look. That is the same substitution T-009
|
|
1128
|
+
names, one scope smaller: a bundle-level green standing in for a
|
|
1129
|
+
gate-level fact.
|
|
1130
|
+
|
|
1131
|
+
IT IS APPENDED TO EVERY ARM, not only the success path. A declaration is
|
|
1132
|
+
equally worth seeing beside a red, and an arm that reported it on one
|
|
1133
|
+
branch and not another would be a guard that goes quiet exactly when
|
|
1134
|
+
something else is also wrong. */
|
|
1135
|
+
const declared = rows
|
|
1136
|
+
.filter((row) => row.status === "not-applicable" && row.declaredNotApplicable === true)
|
|
1137
|
+
.map((row) => row.id)
|
|
1138
|
+
.sort();
|
|
1139
|
+
const declaredClause = declared.length === 0
|
|
1140
|
+
? ""
|
|
1141
|
+
: `; ${String(declared.length)} gate(s) not applicable by declaration: ${declared.join(", ")}`;
|
|
1142
|
+
const decided = (verdict) => ({ exitCode: verdict.exitCode, reason: `${verdict.reason}${declaredClause}` });
|
|
1081
1143
|
let exitCode = EXIT_GREEN;
|
|
1082
1144
|
let reason = "every applicable gate is green";
|
|
1083
1145
|
if (counts.error > 0) {
|
|
@@ -1124,11 +1186,11 @@ export function decideAggregate(counts, requiredNotApplicable, rows) {
|
|
|
1124
1186
|
.map(([name]) => name)
|
|
1125
1187
|
.sort();
|
|
1126
1188
|
if (badCounts.length > 0) {
|
|
1127
|
-
return {
|
|
1189
|
+
return decided({
|
|
1128
1190
|
exitCode: EXIT_GATE_ERROR,
|
|
1129
1191
|
reason: "internal inconsistency: count(s) that are not non-negative integers: " +
|
|
1130
1192
|
`${badCounts.join(", ")} (${JSON.stringify(counts)})`,
|
|
1131
|
-
};
|
|
1193
|
+
});
|
|
1132
1194
|
}
|
|
1133
1195
|
for (const name of [
|
|
1134
1196
|
"declared",
|
|
@@ -1141,10 +1203,10 @@ export function decideAggregate(counts, requiredNotApplicable, rows) {
|
|
|
1141
1203
|
"vacuous",
|
|
1142
1204
|
]) {
|
|
1143
1205
|
if (!Object.prototype.hasOwnProperty.call(counts, name)) {
|
|
1144
|
-
return {
|
|
1206
|
+
return decided({
|
|
1145
1207
|
exitCode: EXIT_GATE_ERROR,
|
|
1146
1208
|
reason: `internal inconsistency: the count ${name} is missing (${JSON.stringify(counts)})`,
|
|
1147
|
-
};
|
|
1209
|
+
});
|
|
1148
1210
|
}
|
|
1149
1211
|
}
|
|
1150
1212
|
// THE SUCCESS PATH CANNOT DESCRIBE AN EMPTY GREEN BUCKET. The branches
|
|
@@ -1181,7 +1243,7 @@ export function decideAggregate(counts, requiredNotApplicable, rows) {
|
|
|
1181
1243
|
`internal inconsistency: vacuous ${String(counts.vacuous)} exceeds ` +
|
|
1182
1244
|
`error ${String(counts.error)}, and vacuous is a strict subset of error`;
|
|
1183
1245
|
}
|
|
1184
|
-
return { exitCode, reason };
|
|
1246
|
+
return decided({ exitCode, reason });
|
|
1185
1247
|
}
|
|
1186
1248
|
export const RUN_CLAIM_FILE = ".tiphys-gate-run.json";
|
|
1187
1249
|
/**
|
|
@@ -1362,6 +1424,11 @@ function writeAbortedSummary(options, runId, reason) {
|
|
|
1362
1424
|
vacuous: 0,
|
|
1363
1425
|
},
|
|
1364
1426
|
requiredNotApplicable: [],
|
|
1427
|
+
/* An aborted run ran no gate, so nothing declared anything. EMPTY rather
|
|
1428
|
+
than omitted, for the reason the field's own comment gives: an absent
|
|
1429
|
+
field would be indistinguishable from an older summary that could not
|
|
1430
|
+
have carried one. */
|
|
1431
|
+
declaredNotApplicable: [],
|
|
1365
1432
|
aborted: true,
|
|
1366
1433
|
exitCode: EXIT_GATE_ERROR,
|
|
1367
1434
|
reason,
|
|
@@ -1453,6 +1520,7 @@ function runClaimedBundle(options, cwd, startedAt, runId, loaded) {
|
|
|
1453
1520
|
vacuous: 0,
|
|
1454
1521
|
};
|
|
1455
1522
|
const requiredNotApplicable = [];
|
|
1523
|
+
const declaredNotApplicable = [];
|
|
1456
1524
|
for (const entry of selected) {
|
|
1457
1525
|
const outcome = runOneGate(entry, options, cwd, options.evidenceDir, runId);
|
|
1458
1526
|
const result = outcome.result;
|
|
@@ -1469,6 +1537,14 @@ function runClaimedBundle(options, cwd, startedAt, runId, loaded) {
|
|
|
1469
1537
|
if (result.status === "not-applicable" && entry.applicability === "required") {
|
|
1470
1538
|
requiredNotApplicable.push(entry.id);
|
|
1471
1539
|
}
|
|
1540
|
+
/* M4-P11. READ OFF THE INGESTED RECORD, not off the manifest and not off
|
|
1541
|
+
the gate's own claim about itself: `ingestGateRun` has already refused a
|
|
1542
|
+
record whose status and exit code disagree, so by here the precondition
|
|
1543
|
+
belongs to a record the runner accepted. */
|
|
1544
|
+
const declaredHere = isDeclaredNotApplicable(result);
|
|
1545
|
+
if (declaredHere) {
|
|
1546
|
+
declaredNotApplicable.push(entry.id);
|
|
1547
|
+
}
|
|
1472
1548
|
// The runner owns the record on disk whenever it produced or changed
|
|
1473
1549
|
// one: a not-applicable gate never ran and wrote nothing, and a rewritten
|
|
1474
1550
|
// vacuous green must not stay green in the evidence (criterion 4).
|
|
@@ -1485,6 +1561,7 @@ function runClaimedBundle(options, cwd, startedAt, runId, loaded) {
|
|
|
1485
1561
|
unitLabel: result.unitLabel,
|
|
1486
1562
|
vacuous: result.vacuous === true,
|
|
1487
1563
|
applicable: outcome.applicable,
|
|
1564
|
+
declaredNotApplicable: declaredHere,
|
|
1488
1565
|
detail: result.detail,
|
|
1489
1566
|
record: dirRefusalForGate === undefined ? recordPath : undefined,
|
|
1490
1567
|
stdout: outcome.stdoutPath,
|
|
@@ -1523,6 +1600,7 @@ function runClaimedBundle(options, cwd, startedAt, runId, loaded) {
|
|
|
1523
1600
|
gates: rows,
|
|
1524
1601
|
counts,
|
|
1525
1602
|
requiredNotApplicable,
|
|
1603
|
+
declaredNotApplicable: [...declaredNotApplicable].sort(),
|
|
1526
1604
|
aborted: false,
|
|
1527
1605
|
exitCode,
|
|
1528
1606
|
reason,
|
|
@@ -37,6 +37,51 @@
|
|
|
37
37
|
"items": {
|
|
38
38
|
"type": "string"
|
|
39
39
|
}
|
|
40
|
+
},
|
|
41
|
+
"gateClasses": {
|
|
42
|
+
"description": "DR-0029 part 2a (M4-P14). The kernel requires a PHASE to declare at least one gate in each required CLASS and never says what the command is. THE SHAPE IS DECLARED HERE AND THE SEMANTICS ARE NOT, deliberately: an absent reason on a not-applicable class, or a missing establishing phase on a not-yet-establishable one, must reach a reader as a RED gate naming the phase and the class, and a schema rejection would instead surface as the gate's `error` status, which M2-C-3 reserves for a check that could not reach a verdict. So this property admits every shape src/gates/gate-classes.ts is able to report on, and that module refuses the ones DR-0029 forbids. The property is OPTIONAL because every declaration written before this phase lacks it and a required field would make all of them fail to load, turning a red verdict into an error for a defect this schema is not the right instrument for.",
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"properties": {
|
|
46
|
+
"correctness": {
|
|
47
|
+
"$ref": "#/$defs/classDeclaration"
|
|
48
|
+
},
|
|
49
|
+
"scope": {
|
|
50
|
+
"$ref": "#/$defs/classDeclaration"
|
|
51
|
+
},
|
|
52
|
+
"review": {
|
|
53
|
+
"$ref": "#/$defs/classDeclaration"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"$defs": {
|
|
59
|
+
"classDeclaration": {
|
|
60
|
+
"description": "One class's disposition. EITHER `gates` names one or more gate ids that assert this class, OR `status` declares an escape: `not-applicable` carrying a `reason`, or `not-yet-establishable` carrying `establishedBy`, the phase id that will establish it. DR-0029: a phase may start from nothing and may never SILENTLY have nothing, so the escape is data, it is visible, and it is carried forward.",
|
|
61
|
+
"type": "object",
|
|
62
|
+
"additionalProperties": false,
|
|
63
|
+
"properties": {
|
|
64
|
+
"gates": {
|
|
65
|
+
"description": "Gate ids from this repository's own gate registry that assert this class for this phase.",
|
|
66
|
+
"type": "array",
|
|
67
|
+
"items": {
|
|
68
|
+
"type": "string"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"status": {
|
|
72
|
+
"description": "The declared escape, when this phase asserts no gate in this class.",
|
|
73
|
+
"type": "string",
|
|
74
|
+
"enum": ["not-applicable", "not-yet-establishable"]
|
|
75
|
+
},
|
|
76
|
+
"reason": {
|
|
77
|
+
"description": "Required by src/gates/gate-classes.ts when status is not-applicable. Not required HERE: an empty or absent reason is a RED verdict naming the class, not a document that fails to load.",
|
|
78
|
+
"type": "string"
|
|
79
|
+
},
|
|
80
|
+
"establishedBy": {
|
|
81
|
+
"description": "Required by src/gates/gate-classes.ts when status is not-yet-establishable: the phase id that will establish this class. The IOU's due date.",
|
|
82
|
+
"type": "string"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
40
85
|
}
|
|
41
86
|
}
|
|
42
87
|
}
|
package/dist/src/gates/suite.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { pathsIdentifySameObject } from "../path-identity.js";
|
|
5
|
+
import { pathsIdentifySameObject, pathsNameSameObject } from "../path-identity.js";
|
|
6
6
|
import { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
|
|
7
7
|
import { comparePins, describePinDifference, takePin } from "./pin.js";
|
|
8
8
|
import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
|
|
@@ -323,7 +323,20 @@ export const MAPPING_STATEMENT = "pass, fail and skipped map directly; cancelled
|
|
|
323
323
|
export function isFileWrapperPhantom(point, cwd) {
|
|
324
324
|
return (point.entityType === "test" &&
|
|
325
325
|
point.nesting === 0 &&
|
|
326
|
-
|
|
326
|
+
// IDENTITY, NOT STRING EQUALITY, and the paragraph above claims more
|
|
327
|
+
// than `===` delivers. "Invariant across every spelling node produces
|
|
328
|
+
// it in, by construction" is false for one spelling: `resolve` does not
|
|
329
|
+
// resolve SYMLINKS, and node reports the CANONICAL path in its
|
|
330
|
+
// reporter's `file` field whatever spelling it was invoked with.
|
|
331
|
+
// Measured 2026-09-16 on node v26.6.0: invoked as
|
|
332
|
+
// <link>/test/a.test.js, `data.file` came back as <real>/test/a.test.js,
|
|
333
|
+
// so `resolve(cwd, point.name)` and `point.file` were two strings for
|
|
334
|
+
// one file and the phantom was counted as a real test again, which is
|
|
335
|
+
// the CR-1306 defect through the one spelling the enumeration missed.
|
|
336
|
+
// The string comparison is kept and tried first (it answers without
|
|
337
|
+
// touching the filesystem and is the common case); the identity check
|
|
338
|
+
// only ever turns a false "different" into a true "same".
|
|
339
|
+
pathsNameSameObject(resolve(cwd, point.name), point.file));
|
|
327
340
|
}
|
|
328
341
|
export function bucketPoints(points) {
|
|
329
342
|
const counts = {
|
|
@@ -820,21 +833,32 @@ export function runSuiteGate(argv) {
|
|
|
820
833
|
});
|
|
821
834
|
}
|
|
822
835
|
// Discovery parity, both directions (step 3).
|
|
836
|
+
//
|
|
837
|
+
// NOT Set membership on the raw strings (DV2-1): `discoveredFiles` is
|
|
838
|
+
// composed by this gate's own walk from `resolve(cwd, root)`, which does
|
|
839
|
+
// not resolve a symlinked ANCESTOR of the declared root, while
|
|
840
|
+
// `reportedFiles` comes from node's own test reporter, which is canonical
|
|
841
|
+
// whatever spelling node was invoked with (the same reason
|
|
842
|
+
// `isFileWrapperPhantom` above already compares by identity rather than by
|
|
843
|
+
// string). A `--test-root` that reaches its target through a symlinked
|
|
844
|
+
// ancestor then produces one file discovered once and reported once,
|
|
845
|
+
// reported as BOTH "discovered but absent from the reporter" and
|
|
846
|
+
// "reported but outside the declared roots", because the two strings
|
|
847
|
+
// differ even though they name the same object. `pathsNameSameObject`
|
|
848
|
+
// (src/path-identity.ts) is the round's own primitive for exactly this.
|
|
823
849
|
const reportedFiles = [
|
|
824
850
|
...new Set(points
|
|
825
851
|
.filter((point) => point.entityType === "test")
|
|
826
852
|
.map((point) => point.file)),
|
|
827
853
|
].sort();
|
|
828
|
-
const discoveredSet = new Set(discoveredFiles);
|
|
829
|
-
const reportedSet = new Set(reportedFiles);
|
|
830
854
|
const findings = [];
|
|
831
855
|
for (const file of discoveredFiles) {
|
|
832
|
-
if (!
|
|
856
|
+
if (!reportedFiles.some((other) => pathsNameSameObject(other, file))) {
|
|
833
857
|
findings.push(`test file discovered by the walk but absent from the reporter: ${relative(cwd, file)}`);
|
|
834
858
|
}
|
|
835
859
|
}
|
|
836
860
|
for (const file of reportedFiles) {
|
|
837
|
-
if (!
|
|
861
|
+
if (!discoveredFiles.some((other) => pathsNameSameObject(other, file))) {
|
|
838
862
|
findings.push(`test file reported but outside the declared roots and suffix: ${relative(cwd, file)}`);
|
|
839
863
|
}
|
|
840
864
|
}
|
|
@@ -923,5 +947,22 @@ export function runSuiteGate(argv) {
|
|
|
923
947
|
const invokedDirectly = process.argv[1] !== undefined &&
|
|
924
948
|
pathsIdentifySameObject(fileURLToPath(import.meta.url), process.argv[1]);
|
|
925
949
|
if (invokedDirectly) {
|
|
926
|
-
process.exit(runSuiteGate(
|
|
950
|
+
// `process.exitCode`, NEVER `process.exit(runSuiteGate(...))` (M4-P29).
|
|
951
|
+
// This gate runs as a SUBPROCESS, so fd 1 is a buffered stream the parent
|
|
952
|
+
// owns and a write to one is QUEUED rather than completed. `process.exit`
|
|
953
|
+
// ends the process without draining that queue, so everything past the
|
|
954
|
+
// buffer is DISCARDED, while the exit code survives and the loss is
|
|
955
|
+
// silent. The plan names this gate as the plausible future trigger because
|
|
956
|
+
// its `detail` carries up to ten findings and every finding quotes text
|
|
957
|
+
// this gate read out of another program's report.
|
|
958
|
+
//
|
|
959
|
+
// Measured at the pre-fix parent commit on this container, through
|
|
960
|
+
// `spawnSync`, which is how the registry runner invokes gates
|
|
961
|
+
// (src/gates/run.ts:1528) and which hands a child SOCKETPAIRS rather than
|
|
962
|
+
// pipes: 451,014 bytes written, 182,750 delivered. A regular file never
|
|
963
|
+
// truncates, which is why this survives casual testing. Assigning
|
|
964
|
+
// `process.exitCode` lets the process end normally, which drains the queue
|
|
965
|
+
// first, and the full capture is
|
|
966
|
+
// witness/captures/m4-p29-gate-cli-stdio.txt.
|
|
967
|
+
process.exitCode = runSuiteGate(process.argv.slice(2));
|
|
927
968
|
}
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -25,8 +25,60 @@ import type { Fleet } from "./fleet.ts";
|
|
|
25
25
|
export declare function turnEndHookPath(fleet: Fleet, taskId: string): string;
|
|
26
26
|
/**
|
|
27
27
|
* Generate the hook script. The turn-end path is baked in as a literal,
|
|
28
|
-
* so the hook needs no fleet resolution
|
|
28
|
+
* so the hook needs no fleet resolution.
|
|
29
|
+
*
|
|
30
|
+
* THE CHILD-OBSERVED POINTER RECORD (CR-B-001, the half that closes the hole
|
|
31
|
+
* rather than the half that stops mis-asserting it).
|
|
32
|
+
*
|
|
33
|
+
* `observeNames`, when given, is baked in as a literal array and the hook
|
|
34
|
+
* writes an `env` object holding what each of those names ACTUALLY IS in the
|
|
35
|
+
* environment the hook was launched with, or `null` where the name is unset.
|
|
36
|
+
* The kernel compares those against the harness-owned paths it handed over.
|
|
37
|
+
*
|
|
38
|
+
* WHY THIS IS STRONGER THAN AN ADAPTER'S REPORT AND WHY IT IS NOT PROOF.
|
|
39
|
+
* This script is written BY THE KERNEL and, for every adapter that honours
|
|
40
|
+
* the documented contract, runs in the SAME environment as the payload (the
|
|
41
|
+
* built-in adapter spreads the same `request.env` into both spawnSync calls,
|
|
42
|
+
* and M2R-004 edit 4 is the record of why a second unscrubbed launch is
|
|
43
|
+
* itself the leak). Against an adapter that does not invoke this script at
|
|
44
|
+
* all it proves nothing, and the record no longer says otherwise.
|
|
45
|
+
*
|
|
46
|
+
* THE COST SENTENCE THAT STOOD HERE IS WITHDRAWN, BECAUSE IT WAS REFUTED BY
|
|
47
|
+
* MEASUREMENT (CR-F-CRED-001, MEDIUM).
|
|
48
|
+
*
|
|
49
|
+
* It read: an adapter that quietly reverted `HOME` for the payload "has to
|
|
50
|
+
* revert it for the payload and NOT for the hook, which means launching two
|
|
51
|
+
* children with two different environments and is a substantially different
|
|
52
|
+
* act from passing a mutated copy once". Two children is one way to do it and
|
|
53
|
+
* it is not the cheap way. The cheap way is ONE child with the mutated
|
|
54
|
+
* environment plus a single `writeFileSync` of the turn-end path, which is
|
|
55
|
+
* STRICTLY LESS work than the honest path, because the honest path also
|
|
56
|
+
* spawns the hook. The turn-end path is handed to the adapter beside
|
|
57
|
+
* `hookPath`, and this generated script names it as a literal, so an adapter
|
|
58
|
+
* that never runs it can still produce a byte-identical record.
|
|
59
|
+
*
|
|
60
|
+
* WHY A NONCE DOES NOT CLOSE THIS, stated because it is the obvious repair and
|
|
61
|
+
* it was considered and refused rather than overlooked. Baking a per-task
|
|
62
|
+
* secret into this script and requiring it in the record moves the forgery
|
|
63
|
+
* from "know the path" to "read the file", and the adapter is HANDED the path
|
|
64
|
+
* of this file: it runs at the same uid, on the same filesystem, in a
|
|
65
|
+
* directory it must be able to read to invoke the hook at all. A guard whose
|
|
66
|
+
* condition the adversary can satisfy by reading one file is green and
|
|
67
|
+
* worthless, which is this repository's own recorded shape (T-008's
|
|
68
|
+
* postscript, the red-witness rule one level up). No artifact this script can
|
|
69
|
+
* write is unforgeable by a party that can read this script.
|
|
70
|
+
*
|
|
71
|
+
* So the repair is on the RECORD rather than on the check: the value is
|
|
72
|
+
* `turn-end-record`, it names the artifact the values were read from, and
|
|
73
|
+
* `CredentialHandoverRecord` (src/task.ts) says the artifact is
|
|
74
|
+
* adapter-reachable. A dishonest adapter is still not caught here, and that
|
|
75
|
+
* residue is declared at delivery/work-history/m4-p8.md item 3; what changed
|
|
76
|
+
* is that `meta.json` no longer positively asserts a child-side observation
|
|
77
|
+
* that no child made.
|
|
78
|
+
*
|
|
79
|
+
* The hook still reads no environment it was not told to read, and a hook
|
|
80
|
+
* generated with no `observeNames` behaves exactly as it did before.
|
|
29
81
|
*/
|
|
30
|
-
export declare function renderTurnEndHook(turnEndFile: string): string;
|
|
82
|
+
export declare function renderTurnEndHook(turnEndFile: string, observeNames?: readonly string[]): string;
|
|
31
83
|
/** Write the hook for a task and return its path. */
|
|
32
|
-
export declare function writeTurnEndHook(fleet: Fleet, taskId: string): string;
|
|
84
|
+
export declare function writeTurnEndHook(fleet: Fleet, taskId: string, observeNames?: readonly string[]): string;
|