@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,99 @@
|
|
|
1
|
+
import type { SchemaDocument } from "./validate.ts";
|
|
2
|
+
/**
|
|
3
|
+
* THE GATE MANIFEST (kernel plan M2, M2-P1 step 6).
|
|
4
|
+
*
|
|
5
|
+
* One manifest per repository. It declares which gates exist, how each is
|
|
6
|
+
* invoked, what one unit of its work is, whether an unmet precondition
|
|
7
|
+
* fails the run, and which commands can destroy work.
|
|
8
|
+
*
|
|
9
|
+
* SCHEMA RESOLUTION FROM SOURCE AND FROM dist/ (step 5). The schema
|
|
10
|
+
* documents sit beside this module at `./schemas/*.json` and are resolved
|
|
11
|
+
* through `import.meta.url`, so the same code finds
|
|
12
|
+
* `src/gates/schemas/...` when run from source and
|
|
13
|
+
* `dist/src/gates/schemas/...` when run from the compiled entry. `tsc` does
|
|
14
|
+
* not copy JSON, which was MEASURED rather than assumed (the measurement is
|
|
15
|
+
* in the work history), so the build script copies the directory into
|
|
16
|
+
* `dist/` and criterion 10 witnesses both documents in `npm pack` output.
|
|
17
|
+
* They are deliberately NOT imported as modules: `resolveJsonModule` is off
|
|
18
|
+
* in both tsconfigs, and turning it on would inline the schema into the
|
|
19
|
+
* bundle and make the shipped document and the enforced document two
|
|
20
|
+
* different things.
|
|
21
|
+
*
|
|
22
|
+
* M2-C-6: the manifest path comes from the caller, so it is read through the
|
|
23
|
+
* DELIVERED `readRegularFileIfPresent`. A named pipe at the manifest path
|
|
24
|
+
* reports `error` naming the path and the type, and never blocks.
|
|
25
|
+
*/
|
|
26
|
+
export type PreconditionKind = "file-exists" | "file-absent" | "branch-matches" | "diff-touches" | "command-exit-zero";
|
|
27
|
+
export declare const PRECONDITION_KINDS: readonly PreconditionKind[];
|
|
28
|
+
export type RunParameter = "base" | "head" | "phase";
|
|
29
|
+
export interface PreconditionSpec {
|
|
30
|
+
id: string;
|
|
31
|
+
kind: PreconditionKind;
|
|
32
|
+
path?: string;
|
|
33
|
+
pattern?: string;
|
|
34
|
+
paths?: string[];
|
|
35
|
+
command?: string[];
|
|
36
|
+
}
|
|
37
|
+
export interface GateEntry {
|
|
38
|
+
id: string;
|
|
39
|
+
command: string[];
|
|
40
|
+
unitLabel: string;
|
|
41
|
+
applicability: "required" | "conditional";
|
|
42
|
+
parameters?: RunParameter[];
|
|
43
|
+
precondition?: PreconditionSpec;
|
|
44
|
+
modes?: string[];
|
|
45
|
+
}
|
|
46
|
+
export interface GateManifest {
|
|
47
|
+
version: number;
|
|
48
|
+
gates: GateEntry[];
|
|
49
|
+
destructiveCommands: string[];
|
|
50
|
+
}
|
|
51
|
+
export type ManifestLoad = {
|
|
52
|
+
ok: true;
|
|
53
|
+
manifest: GateManifest;
|
|
54
|
+
sha256: string;
|
|
55
|
+
body: string;
|
|
56
|
+
} | {
|
|
57
|
+
ok: false;
|
|
58
|
+
reason: string;
|
|
59
|
+
diagnostics: string[];
|
|
60
|
+
};
|
|
61
|
+
export declare function manifestSchema(): SchemaDocument;
|
|
62
|
+
export declare function resultSchema(): SchemaDocument;
|
|
63
|
+
/**
|
|
64
|
+
* Absolute paths of every schema document `manifest-self-check` validates.
|
|
65
|
+
*
|
|
66
|
+
* ENUMERATED, NOT ENUMERABLE-BY-HAND (fix round, M2-P1). This milestone's
|
|
67
|
+
* plan (kernel-plan-v1.md) adds four more schema documents to this same
|
|
68
|
+
* directory across M2-P2, M2-P4, M2-P5, M2-P6 and M2-P7. A fixed two-entry
|
|
69
|
+
* list here was the mechanism, not a fluke: it named the two documents that
|
|
70
|
+
* existed at the time this function was written and would keep returning
|
|
71
|
+
* exactly those two forever, so manifest-self-check would silently stop
|
|
72
|
+
* validating every document a later phase ships, while test/gates.test.ts's
|
|
73
|
+
* independent readdir-based parity check reddens correctly the moment a
|
|
74
|
+
* phase adds one (CR-812's test, `test/gates.test.ts` near line 2378). The
|
|
75
|
+
* fix is to make the SAME enumeration the source of truth here: read the
|
|
76
|
+
* directory beside this module, keep entries matching `*.schema.json`, and
|
|
77
|
+
* sort them by filename so the order is deterministic across platforms and
|
|
78
|
+
* across runs (readdir order is not guaranteed by POSIX). Every document a
|
|
79
|
+
* phase drops into `src/gates/schemas/` is then validated automatically,
|
|
80
|
+
* and the parity property the test checks becomes structural rather than
|
|
81
|
+
* a thing two call sites have to be kept in sync by hand.
|
|
82
|
+
*/
|
|
83
|
+
export declare function schemaDocumentPaths(): string[];
|
|
84
|
+
/**
|
|
85
|
+
* Validate an already-parsed manifest document. Returns the diagnostics in
|
|
86
|
+
* the contract's deterministic order (by pointer, then message).
|
|
87
|
+
*/
|
|
88
|
+
export declare function validateManifestDocument(document: unknown): string[];
|
|
89
|
+
/** Validate an already-parsed gate result record. */
|
|
90
|
+
export declare function validateResultDocument(document: unknown): string[];
|
|
91
|
+
/**
|
|
92
|
+
* Load and validate a manifest from a path the caller supplied.
|
|
93
|
+
*
|
|
94
|
+
* Fail closed at every step: not a regular file, absent, unparseable, or
|
|
95
|
+
* schema-invalid all produce `ok: false` with a reason. None of them
|
|
96
|
+
* produces a manifest with fewer gates in it, which is the failure this
|
|
97
|
+
* whole milestone exists to make impossible.
|
|
98
|
+
*/
|
|
99
|
+
export declare function loadManifest(path: string): ManifestLoad;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { readRegularFileIfPresent } from "../task.js";
|
|
5
|
+
import { DIAGNOSTIC_MESSAGES, formatDiagnostics, loadSchema, validate, } from "./validate.js";
|
|
6
|
+
export const PRECONDITION_KINDS = [
|
|
7
|
+
"file-exists",
|
|
8
|
+
"file-absent",
|
|
9
|
+
"branch-matches",
|
|
10
|
+
"diff-touches",
|
|
11
|
+
"command-exit-zero",
|
|
12
|
+
];
|
|
13
|
+
const schemaDirectory = new URL("./schemas/", import.meta.url);
|
|
14
|
+
function readSchemaDocument(name) {
|
|
15
|
+
const path = fileURLToPath(new URL(name, schemaDirectory));
|
|
16
|
+
const read = readRegularFileIfPresent(path);
|
|
17
|
+
if (read.kind !== "read") {
|
|
18
|
+
throw new Error(read.kind === "absent"
|
|
19
|
+
? `schema document ${path} is missing from this installation`
|
|
20
|
+
: read.reason);
|
|
21
|
+
}
|
|
22
|
+
const parsed = JSON.parse(read.body);
|
|
23
|
+
const loaded = loadSchema(parsed, name);
|
|
24
|
+
if (!loaded.ok) {
|
|
25
|
+
throw new Error(loaded.reason);
|
|
26
|
+
}
|
|
27
|
+
return loaded.schema;
|
|
28
|
+
}
|
|
29
|
+
/** The two schema documents this milestone ships, loaded once. */
|
|
30
|
+
let cachedManifestSchema;
|
|
31
|
+
let cachedResultSchema;
|
|
32
|
+
export function manifestSchema() {
|
|
33
|
+
if (cachedManifestSchema === undefined) {
|
|
34
|
+
cachedManifestSchema = readSchemaDocument("gate-manifest.schema.json");
|
|
35
|
+
}
|
|
36
|
+
return cachedManifestSchema;
|
|
37
|
+
}
|
|
38
|
+
export function resultSchema() {
|
|
39
|
+
if (cachedResultSchema === undefined) {
|
|
40
|
+
cachedResultSchema = readSchemaDocument("gate-result.schema.json");
|
|
41
|
+
}
|
|
42
|
+
return cachedResultSchema;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Absolute paths of every schema document `manifest-self-check` validates.
|
|
46
|
+
*
|
|
47
|
+
* ENUMERATED, NOT ENUMERABLE-BY-HAND (fix round, M2-P1). This milestone's
|
|
48
|
+
* plan (kernel-plan-v1.md) adds four more schema documents to this same
|
|
49
|
+
* directory across M2-P2, M2-P4, M2-P5, M2-P6 and M2-P7. A fixed two-entry
|
|
50
|
+
* list here was the mechanism, not a fluke: it named the two documents that
|
|
51
|
+
* existed at the time this function was written and would keep returning
|
|
52
|
+
* exactly those two forever, so manifest-self-check would silently stop
|
|
53
|
+
* validating every document a later phase ships, while test/gates.test.ts's
|
|
54
|
+
* independent readdir-based parity check reddens correctly the moment a
|
|
55
|
+
* phase adds one (CR-812's test, `test/gates.test.ts` near line 2378). The
|
|
56
|
+
* fix is to make the SAME enumeration the source of truth here: read the
|
|
57
|
+
* directory beside this module, keep entries matching `*.schema.json`, and
|
|
58
|
+
* sort them by filename so the order is deterministic across platforms and
|
|
59
|
+
* across runs (readdir order is not guaranteed by POSIX). Every document a
|
|
60
|
+
* phase drops into `src/gates/schemas/` is then validated automatically,
|
|
61
|
+
* and the parity property the test checks becomes structural rather than
|
|
62
|
+
* a thing two call sites have to be kept in sync by hand.
|
|
63
|
+
*/
|
|
64
|
+
export function schemaDocumentPaths() {
|
|
65
|
+
const directory = fileURLToPath(schemaDirectory);
|
|
66
|
+
return readdirSync(directory)
|
|
67
|
+
.filter((name) => name.endsWith(".schema.json"))
|
|
68
|
+
.sort()
|
|
69
|
+
.map((name) => fileURLToPath(new URL(name, schemaDirectory)));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The kind-specific required fields, checked here rather than in the schema.
|
|
73
|
+
*
|
|
74
|
+
* WHY NOT IN THE SCHEMA. Expressing "kind file-exists requires path" needs
|
|
75
|
+
* conditional composition (`if`/`then`, or `oneOf`), and both are outside
|
|
76
|
+
* M2-D-04's closed keyword set, which DR-0013 decided against growing. So
|
|
77
|
+
* the check lives in code and emits the SAME `INVALID <pointer> <message>`
|
|
78
|
+
* diagnostics, which keeps one diagnostic contract rather than two. When
|
|
79
|
+
* M3-P1 swaps in Ajv the constraint can move into the schema without any
|
|
80
|
+
* consumer noticing, because the output line is unchanged.
|
|
81
|
+
*/
|
|
82
|
+
const REQUIRED_FIELD_FOR_KIND = {
|
|
83
|
+
"file-exists": "path",
|
|
84
|
+
"file-absent": "path",
|
|
85
|
+
"branch-matches": "pattern",
|
|
86
|
+
"diff-touches": "paths",
|
|
87
|
+
"command-exit-zero": "command",
|
|
88
|
+
};
|
|
89
|
+
function preconditionFieldDiagnostics(manifest) {
|
|
90
|
+
const found = [];
|
|
91
|
+
const gates = manifest.gates;
|
|
92
|
+
if (!Array.isArray(gates)) {
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
for (let index = 0; index < gates.length; index += 1) {
|
|
96
|
+
const gate = gates[index];
|
|
97
|
+
const precondition = gate?.precondition;
|
|
98
|
+
if (precondition === undefined || precondition === null) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const kind = precondition["kind"];
|
|
102
|
+
if (typeof kind !== "string" || !PRECONDITION_KINDS.includes(kind)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const field = REQUIRED_FIELD_FOR_KIND[kind];
|
|
106
|
+
if (precondition[field] === undefined) {
|
|
107
|
+
found.push({
|
|
108
|
+
pointer: `#/gates/${index}/precondition/${field}`,
|
|
109
|
+
message: DIAGNOSTIC_MESSAGES.required(field),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
/** Every duplicate gate id, as diagnostics. A registry keyed by name cannot hold two. */
|
|
116
|
+
function duplicateIdDiagnostics(manifest) {
|
|
117
|
+
const found = [];
|
|
118
|
+
const gates = manifest.gates;
|
|
119
|
+
if (!Array.isArray(gates)) {
|
|
120
|
+
return found;
|
|
121
|
+
}
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
for (let index = 0; index < gates.length; index += 1) {
|
|
124
|
+
const id = gates[index]?.id;
|
|
125
|
+
if (typeof id !== "string") {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (seen.has(id)) {
|
|
129
|
+
found.push({
|
|
130
|
+
pointer: `#/gates/${index}/id`,
|
|
131
|
+
message: DIAGNOSTIC_MESSAGES.duplicateId(JSON.stringify(id)),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
seen.add(id);
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Validate an already-parsed manifest document. Returns the diagnostics in
|
|
140
|
+
* the contract's deterministic order (by pointer, then message).
|
|
141
|
+
*/
|
|
142
|
+
export function validateManifestDocument(document) {
|
|
143
|
+
const diagnostics = [
|
|
144
|
+
...validate(manifestSchema(), document),
|
|
145
|
+
...preconditionFieldDiagnostics(document),
|
|
146
|
+
...duplicateIdDiagnostics(document),
|
|
147
|
+
];
|
|
148
|
+
diagnostics.sort((a, b) => {
|
|
149
|
+
if (a.pointer !== b.pointer) {
|
|
150
|
+
return a.pointer < b.pointer ? -1 : 1;
|
|
151
|
+
}
|
|
152
|
+
if (a.message === b.message) {
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
return a.message < b.message ? -1 : 1;
|
|
156
|
+
});
|
|
157
|
+
return formatDiagnostics(diagnostics);
|
|
158
|
+
}
|
|
159
|
+
/** Validate an already-parsed gate result record. */
|
|
160
|
+
export function validateResultDocument(document) {
|
|
161
|
+
return formatDiagnostics(validate(resultSchema(), document));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Load and validate a manifest from a path the caller supplied.
|
|
165
|
+
*
|
|
166
|
+
* Fail closed at every step: not a regular file, absent, unparseable, or
|
|
167
|
+
* schema-invalid all produce `ok: false` with a reason. None of them
|
|
168
|
+
* produces a manifest with fewer gates in it, which is the failure this
|
|
169
|
+
* whole milestone exists to make impossible.
|
|
170
|
+
*/
|
|
171
|
+
export function loadManifest(path) {
|
|
172
|
+
const read = readRegularFileIfPresent(path);
|
|
173
|
+
if (read.kind === "absent") {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
reason: `manifest ${path} does not exist`,
|
|
177
|
+
diagnostics: [],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (read.kind === "refused") {
|
|
181
|
+
return { ok: false, reason: read.reason, diagnostics: [] };
|
|
182
|
+
}
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = JSON.parse(read.body);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
reason: `manifest ${path} does not parse as JSON: ${error.message}`,
|
|
191
|
+
diagnostics: [],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const diagnostics = validateManifestDocument(parsed);
|
|
195
|
+
if (diagnostics.length > 0) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
reason: `manifest ${path} is not a valid gate manifest`,
|
|
199
|
+
diagnostics,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
manifest: parsed,
|
|
205
|
+
sha256: createHash("sha256").update(read.body).digest("hex"),
|
|
206
|
+
body: read.body,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { runReleaseGate } from "./release.js";
|
|
4
|
+
/**
|
|
5
|
+
* THE MIGRATIONS GATE ENTRY (kernel plan M2, M2-P7 step 4). A thin entry
|
|
6
|
+
* point that names the declared `migrations` verification and calls the one
|
|
7
|
+
* contract module, `src/gates/release.ts`. Deploy verification and
|
|
8
|
+
* migration verification are two INSTANCES of one post-merge verification
|
|
9
|
+
* contract, not two contracts (DR-0014, investigation section 1): the two
|
|
10
|
+
* registry entries exist so that a project can declare one and not the
|
|
11
|
+
* other, and so the evidence bundle shows two lines rather than one
|
|
12
|
+
* aggregate that hides which half was skipped.
|
|
13
|
+
*
|
|
14
|
+
* The two inventories are not symmetric (plan criterion 8): a declared
|
|
15
|
+
* REPOSITORY migrations location containing zero migrations is
|
|
16
|
+
* not-applicable, but a non-empty repository inventory with an EMPTY
|
|
17
|
+
* applied inventory is red, never not-applicable, because that is the
|
|
18
|
+
* recorded incident exactly ("migrations skipped by a flake while the code
|
|
19
|
+
* deployed anyway"). The asymmetry lives in the migrations-command adapter;
|
|
20
|
+
* this entry is shape-identical to deploy's.
|
|
21
|
+
*/
|
|
22
|
+
function isMain() {
|
|
23
|
+
const argv1 = process.argv[1];
|
|
24
|
+
if (argv1 === undefined) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(import.meta.url));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (isMain()) {
|
|
35
|
+
process.exitCode = await runReleaseGate("migrations", process.argv.slice(2));
|
|
36
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RUN PINNING (kernel plan M2, M2-P1 step 3; M2-C-5; M2-D-06).
|
|
3
|
+
*
|
|
4
|
+
* T-004 lesson 3: "a test run is only evidence if you can prove what it
|
|
5
|
+
* ran". In that incident no artifact recorded the content of the source
|
|
6
|
+
* files during a failing run, so a forensic reconstruction was the only way
|
|
7
|
+
* to establish that the tree had been rewritten underneath the run, and the
|
|
8
|
+
* root cause was never awarded.
|
|
9
|
+
*
|
|
10
|
+
* A pin is that artifact: the file set of the tree that was actually
|
|
11
|
+
* executed, with sha256, size and mtimeMs per file, taken at the start and
|
|
12
|
+
* at the end of the run. ANY difference in ANY of the four fields (the
|
|
13
|
+
* path's presence included) is a difference and makes the run's record
|
|
14
|
+
* `error`.
|
|
15
|
+
*
|
|
16
|
+
* WHY ctimeMs IS PINNED TOO (CR-809, added in fix round 1). mtime is
|
|
17
|
+
* SETTABLE FROM USERSPACE. `cp -p`, `rsync -a` and `tar -x` all restore it
|
|
18
|
+
* through `utimensat` at nanosecond precision, so a rewrite performed by any
|
|
19
|
+
* of those three ordinary commands passes a pin of {sha256, size, mtimeMs}
|
|
20
|
+
* without a mark, and T-004's incident is a rewrite. ctime is the inode
|
|
21
|
+
* change time: no userspace call sets it, and `utimensat` BUMPS it, so the
|
|
22
|
+
* very act of restoring mtime is what makes the change visible. It also
|
|
23
|
+
* catches replace-by-rename, which changes the inode.
|
|
24
|
+
*
|
|
25
|
+
* This is a strict ADDITION to M2-C-5's four fields, never a substitution:
|
|
26
|
+
* the constraint says any difference in file set, sha256, size or mtime makes
|
|
27
|
+
* the record `error`, and pinning a fifth field can only make more runs
|
|
28
|
+
* `error`, never fewer. M2-P2 criterion 7 and M2-P3 criterion 8 are pin
|
|
29
|
+
* witnesses and should be read against five fields, not four.
|
|
30
|
+
*
|
|
31
|
+
* The residue, stated rather than left to be discovered: ctime is not
|
|
32
|
+
* forgeable from userspace, but it is not a cryptographic seal either. A
|
|
33
|
+
* privileged actor with raw device access can write any inode field it
|
|
34
|
+
* likes. That is outside anything this kernel can measure.
|
|
35
|
+
*
|
|
36
|
+
* WHY mtimeMs IS PINNED AND A CONTENT HASH IS NOT ENOUGH. T-004's forensics
|
|
37
|
+
* describe a BYTE-IDENTICAL rewrite: `src/lock.ts` was replaced with
|
|
38
|
+
* pristine content 42.8 seconds into the failing run. A content-only pin
|
|
39
|
+
* passes that unchanged, and the whole value of the pin would be lost at
|
|
40
|
+
* precisely the shape the incident actually had. M2-P2 criterion 7 turns
|
|
41
|
+
* that into a witness rather than an assertion.
|
|
42
|
+
*
|
|
43
|
+
* M2-C-6 IS OBEYED BY REUSE, NOT BY REIMPLEMENTATION. The walk reads paths
|
|
44
|
+
* it did not create, so every open is preceded by the DELIVERED
|
|
45
|
+
* `classifyEntry` from src/task.ts. There is no second copy of "may this
|
|
46
|
+
* path be opened" here: T-005 records that this project paid for that class
|
|
47
|
+
* twice because the second component reimplemented the mechanism. The one
|
|
48
|
+
* `readFileSync` in this module sits on the line after the probe, and it is
|
|
49
|
+
* the only read of an externally supplied path anywhere in `src/gates/` that
|
|
50
|
+
* is not `readRegularFileIfPresent`, which returns UTF-8 text and therefore
|
|
51
|
+
* cannot hash a binary file honestly.
|
|
52
|
+
*
|
|
53
|
+
* `lstatSync` and `statSync` here are PROBES, not opens: they are the same
|
|
54
|
+
* two syscalls `classifyEntry` itself performs, they answer questions about
|
|
55
|
+
* the directory entry, and neither can block on a named pipe. They are used
|
|
56
|
+
* only to decide directory-ness and to read size and mtime, never to decide
|
|
57
|
+
* whether a path may be opened, which stays the delivered helper's job.
|
|
58
|
+
*
|
|
59
|
+
* FAIL CLOSED ON ANYTHING THAT IS NOT A REGULAR FILE OR A REAL DIRECTORY.
|
|
60
|
+
* A named pipe, socket or device node inside a pinned root makes `takePin`
|
|
61
|
+
* THROW with a reason naming the path and the observed type. It never opens
|
|
62
|
+
* the path, so it never blocks, and the caller turns the throw into an
|
|
63
|
+
* `error` record. Directory symlinks are deliberately not followed: a pin
|
|
64
|
+
* that follows them can be made to walk outside its declared roots or to
|
|
65
|
+
* loop, and neither is a measurement.
|
|
66
|
+
*/
|
|
67
|
+
export interface PinFile {
|
|
68
|
+
/** `join(root, ...)` for the root exactly as the caller supplied it. */
|
|
69
|
+
path: string;
|
|
70
|
+
sha256: string;
|
|
71
|
+
size: number;
|
|
72
|
+
mtimeMs: number;
|
|
73
|
+
/** Inode change time. Not settable from userspace; see the header. */
|
|
74
|
+
ctimeMs: number;
|
|
75
|
+
}
|
|
76
|
+
export interface Pin {
|
|
77
|
+
roots: string[];
|
|
78
|
+
takenAt: string;
|
|
79
|
+
/**
|
|
80
|
+
* `files.length`, carried explicitly so a vacuous pin is visible in the
|
|
81
|
+
* RECORD and not only to a caller who thinks to check (CR-804). Nothing in
|
|
82
|
+
* the shape used to distinguish "the tree did not change" from "no tree was
|
|
83
|
+
* measured", at the module two other phases consume as primary evidence.
|
|
84
|
+
*/
|
|
85
|
+
fileCount: number;
|
|
86
|
+
files: PinFile[];
|
|
87
|
+
}
|
|
88
|
+
export type PinFieldName = "sha256" | "size" | "mtimeMs" | "ctimeMs";
|
|
89
|
+
export type PinDifference = {
|
|
90
|
+
path: string;
|
|
91
|
+
kind: "added";
|
|
92
|
+
} | {
|
|
93
|
+
path: string;
|
|
94
|
+
kind: "removed";
|
|
95
|
+
} | {
|
|
96
|
+
path: string;
|
|
97
|
+
kind: "changed";
|
|
98
|
+
fields: PinFieldName[];
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Pin the given roots. Throws with a reason naming the path and the observed
|
|
102
|
+
* type when a root, or anything under it, is neither a regular file nor a
|
|
103
|
+
* real directory (M2-C-6, fail closed). Callers wrap this with `runStep`
|
|
104
|
+
* from src/task.ts and report `error`.
|
|
105
|
+
*/
|
|
106
|
+
export declare function takePin(roots: string[]): Pin;
|
|
107
|
+
/**
|
|
108
|
+
* Every difference between two pins, in a deterministic order (by path).
|
|
109
|
+
* A byte-identical rewrite that changes only mtimeMs is a difference, which
|
|
110
|
+
* is the whole reason mtimeMs is in the record.
|
|
111
|
+
*/
|
|
112
|
+
export declare function comparePins(a: Pin, b: Pin): PinDifference[];
|
|
113
|
+
/** One line per difference, for a record's detail text. */
|
|
114
|
+
export declare function describePinDifference(difference: PinDifference): string;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { classifyEntry } from "../task.js";
|
|
5
|
+
function hashFile(path) {
|
|
6
|
+
// THE PROBE, then the open. The order is the point (MECHANISMS.md, CR-520).
|
|
7
|
+
const entry = classifyEntry(path);
|
|
8
|
+
if (entry.kind !== "regular") {
|
|
9
|
+
throw new Error(entry.kind === "absent" || entry.kind === "dangling"
|
|
10
|
+
? `${path} vanished while the pin was being taken`
|
|
11
|
+
: entry.reason);
|
|
12
|
+
}
|
|
13
|
+
const stats = statSync(path);
|
|
14
|
+
const body = readFileSync(path);
|
|
15
|
+
return {
|
|
16
|
+
path,
|
|
17
|
+
sha256: createHash("sha256").update(body).digest("hex"),
|
|
18
|
+
size: stats.size,
|
|
19
|
+
mtimeMs: stats.mtimeMs,
|
|
20
|
+
ctimeMs: stats.ctimeMs,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* True only for a REAL directory, never for a symlink that resolves to one.
|
|
25
|
+
* lstat answers about the link itself, so a symlinked directory falls
|
|
26
|
+
* through to the regular-file probe and is refused there.
|
|
27
|
+
*/
|
|
28
|
+
function isRealDirectory(path) {
|
|
29
|
+
try {
|
|
30
|
+
return lstatSync(path).isDirectory();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function walk(root, dir, into) {
|
|
37
|
+
const names = readdirSync(dir).sort();
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const path = join(dir, name);
|
|
40
|
+
if (isRealDirectory(path)) {
|
|
41
|
+
walk(root, path, into);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const entry = classifyEntry(path);
|
|
45
|
+
if (entry.kind === "absent" || entry.kind === "dangling") {
|
|
46
|
+
// Raced away, or a dangling symlink: nothing to hash, and nothing
|
|
47
|
+
// hidden either, because a later pin reports it as added.
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (entry.kind !== "regular") {
|
|
51
|
+
throw new Error(`${entry.reason}; refusing to pin ${root}`);
|
|
52
|
+
}
|
|
53
|
+
into.push(hashFile(path));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Pin the given roots. Throws with a reason naming the path and the observed
|
|
58
|
+
* type when a root, or anything under it, is neither a regular file nor a
|
|
59
|
+
* real directory (M2-C-6, fail closed). Callers wrap this with `runStep`
|
|
60
|
+
* from src/task.ts and report `error`.
|
|
61
|
+
*/
|
|
62
|
+
export function takePin(roots) {
|
|
63
|
+
const files = [];
|
|
64
|
+
if (roots.length === 0) {
|
|
65
|
+
throw new Error("a pin over no roots measures nothing (M2-C-5)");
|
|
66
|
+
}
|
|
67
|
+
for (const root of roots) {
|
|
68
|
+
const before = files.length;
|
|
69
|
+
if (isRealDirectory(root)) {
|
|
70
|
+
walk(root, root, files);
|
|
71
|
+
// THE VACUITY FLOOR (CR-804). A root a caller DECLARED that holds no
|
|
72
|
+
// files is a configuration error, not a measurement of an unchanged
|
|
73
|
+
// tree, and the two are indistinguishable in the result. M2-P2 pins
|
|
74
|
+
// "the clone's source and test roots"; if a scratch clone puts sources
|
|
75
|
+
// one directory deeper than the computed root, a silent empty pin
|
|
76
|
+
// reports no difference and the gate goes green on evidence nobody
|
|
77
|
+
// took. The rule lives HERE, in the module that carries the mechanism,
|
|
78
|
+
// rather than in each consumer (T-005).
|
|
79
|
+
if (files.length === before) {
|
|
80
|
+
throw new Error(`pin root ${root} contributed no files; a declared root that holds nothing is a configuration error, not an unchanged tree (M2-C-5)`);
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const entry = classifyEntry(root);
|
|
85
|
+
if (entry.kind === "absent" || entry.kind === "dangling") {
|
|
86
|
+
throw new Error(`pin root ${root} does not exist`);
|
|
87
|
+
}
|
|
88
|
+
if (entry.kind === "regular") {
|
|
89
|
+
files.push(hashFile(root));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`${entry.reason}; refusing to pin ${root}`);
|
|
93
|
+
}
|
|
94
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
95
|
+
return {
|
|
96
|
+
roots: [...roots],
|
|
97
|
+
takenAt: new Date().toISOString(),
|
|
98
|
+
fileCount: files.length,
|
|
99
|
+
files,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Every difference between two pins, in a deterministic order (by path).
|
|
104
|
+
* A byte-identical rewrite that changes only mtimeMs is a difference, which
|
|
105
|
+
* is the whole reason mtimeMs is in the record.
|
|
106
|
+
*/
|
|
107
|
+
export function comparePins(a, b) {
|
|
108
|
+
const before = new Map(a.files.map((f) => [f.path, f]));
|
|
109
|
+
const after = new Map(b.files.map((f) => [f.path, f]));
|
|
110
|
+
const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
|
|
111
|
+
const differences = [];
|
|
112
|
+
for (const path of paths) {
|
|
113
|
+
const start = before.get(path);
|
|
114
|
+
const end = after.get(path);
|
|
115
|
+
if (start === undefined && end !== undefined) {
|
|
116
|
+
differences.push({ path, kind: "added" });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (start !== undefined && end === undefined) {
|
|
120
|
+
differences.push({ path, kind: "removed" });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (start === undefined || end === undefined) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const fields = [];
|
|
127
|
+
if (start.sha256 !== end.sha256) {
|
|
128
|
+
fields.push("sha256");
|
|
129
|
+
}
|
|
130
|
+
if (start.size !== end.size) {
|
|
131
|
+
fields.push("size");
|
|
132
|
+
}
|
|
133
|
+
if (start.mtimeMs !== end.mtimeMs) {
|
|
134
|
+
fields.push("mtimeMs");
|
|
135
|
+
}
|
|
136
|
+
if (start.ctimeMs !== end.ctimeMs) {
|
|
137
|
+
fields.push("ctimeMs");
|
|
138
|
+
}
|
|
139
|
+
if (fields.length > 0) {
|
|
140
|
+
differences.push({ path, kind: "changed", fields });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return differences;
|
|
144
|
+
}
|
|
145
|
+
/** One line per difference, for a record's detail text. */
|
|
146
|
+
export function describePinDifference(difference) {
|
|
147
|
+
if (difference.kind === "added") {
|
|
148
|
+
return `${difference.path} was added during the run`;
|
|
149
|
+
}
|
|
150
|
+
if (difference.kind === "removed") {
|
|
151
|
+
return `${difference.path} was removed during the run`;
|
|
152
|
+
}
|
|
153
|
+
return `${difference.path} changed during the run (${difference.fields.join(", ")})`;
|
|
154
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { GateResult } from "./result.ts";
|
|
2
|
+
import type { WitnessEvaluation, WitnessHooks } from "../witness/run.ts";
|
|
3
|
+
export interface RedWitnessOutcome {
|
|
4
|
+
result: GateResult;
|
|
5
|
+
exitCode: number;
|
|
6
|
+
evaluations: WitnessEvaluation[];
|
|
7
|
+
/** Wall-clock cost of the stored-witness re-evaluation, milliseconds. */
|
|
8
|
+
reEvaluationMs: number;
|
|
9
|
+
}
|
|
10
|
+
export interface RedWitnessRun {
|
|
11
|
+
repoRoot: string;
|
|
12
|
+
base: string;
|
|
13
|
+
head?: string;
|
|
14
|
+
baseline?: string;
|
|
15
|
+
evidenceDir?: string;
|
|
16
|
+
hooks?: WitnessHooks;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Run the red-witness gate against a repository. Exported so tests can
|
|
20
|
+
* drive it without a subprocess; the CLI below is a thin shell over it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function runRedWitnessGate(run: RedWitnessRun): RedWitnessOutcome;
|