@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,701 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Script, createContext } from "node:vm";
|
|
5
|
+
import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
|
|
6
|
+
import { loadSchema, validate } from "./validate.js";
|
|
7
|
+
import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
|
|
8
|
+
/**
|
|
9
|
+
* THE KERNEL CONFIG (kernel plan M2-P6 step 2): this repository's real
|
|
10
|
+
* pair and the four bucket kinds the plan states verbatim. It is a plain
|
|
11
|
+
* exported constant, not a checked-in data file, because M2-C-1's
|
|
12
|
+
* verification-first reading of the plan's files-to-touch list for this
|
|
13
|
+
* phase names the module, its schema and its test, and no committed
|
|
14
|
+
* configuration document alongside them; a `--config` flag (validated
|
|
15
|
+
* against `coverage-config.schema.json`) exists for a caller that wants a
|
|
16
|
+
* different pair, and every fixture test in `test/coverage-gate.test.ts`
|
|
17
|
+
* uses it rather than editing this constant.
|
|
18
|
+
*/
|
|
19
|
+
export const KERNEL_COVERAGE_CONFIG = {
|
|
20
|
+
inventory: {
|
|
21
|
+
path: "delivery/requirements/migration-table.md",
|
|
22
|
+
idPattern: "R-[0-9]+[a-z]?",
|
|
23
|
+
},
|
|
24
|
+
coverageTable: {
|
|
25
|
+
path: "delivery/plan/kernel-plan-v1.md",
|
|
26
|
+
idPattern: "R-[0-9]+[a-z]?",
|
|
27
|
+
bucketColumn: 1,
|
|
28
|
+
noteColumn: 2,
|
|
29
|
+
},
|
|
30
|
+
bucketKinds: [
|
|
31
|
+
{ kind: "phase", pattern: "M([0-9]+)-P[0-9]+", requiresNote: false },
|
|
32
|
+
{ kind: "milestone", pattern: "M([0-9]+)", requiresNote: false },
|
|
33
|
+
{
|
|
34
|
+
kind: "decision",
|
|
35
|
+
pattern: "DR-[0-9]{4}|D-[0-9]+|M2-D-[0-9]+",
|
|
36
|
+
requiresNote: false,
|
|
37
|
+
},
|
|
38
|
+
{ kind: "parked", pattern: "parked", requiresNote: true },
|
|
39
|
+
],
|
|
40
|
+
expectedUnits: 115,
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* THE SHARED EMPTINESS PREDICATE (fix round 1, CR-987).
|
|
44
|
+
*
|
|
45
|
+
* Before this round, `checkCoverage`'s note check compared a value already
|
|
46
|
+
* passed through `String.prototype.trim` (applied once, at extraction) to
|
|
47
|
+
* `""`, and `checkFindingOutcomeParity`'s outcome check compared a value
|
|
48
|
+
* that was never trimmed at all to `""`. Two definitions, only one of
|
|
49
|
+
* which trimmed, and NEITHER strips a zero-width character: `trim()`
|
|
50
|
+
* removes Unicode whitespace (which includes U+00A0 and U+FEFF) but not
|
|
51
|
+
* U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER or U+200D ZERO
|
|
52
|
+
* WIDTH JOINER, so a cell or an outcome containing only one of those reads
|
|
53
|
+
* as non-empty to a bare `=== ""` comparison, trimmed or not. One
|
|
54
|
+
* predicate, called at every point this module asks "is this empty",
|
|
55
|
+
* closes both instances at once.
|
|
56
|
+
*/
|
|
57
|
+
export function isEmptyCell(value) {
|
|
58
|
+
// U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER, U+200D ZERO
|
|
59
|
+
// WIDTH JOINER, U+FEFF ZERO WIDTH NO-BREAK SPACE (BOM). Written as
|
|
60
|
+
// \u escapes rather than embedded literally: this repository's
|
|
61
|
+
// authored files are pure ASCII (CLAUDE.md convention 3), which a
|
|
62
|
+
// literal zero-width character in source would silently violate.
|
|
63
|
+
return value.replace(/[\u200B\u200C\u200D\uFEFF]/g, "").trim() === "";
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A REUSED v8 CONTEXT for every bounded regex execution this module
|
|
67
|
+
* performs (fix round 1, the regex mechanism, CR-991). Created once at
|
|
68
|
+
* module load rather than per call: `vm.createContext` builds a real v8
|
|
69
|
+
* context and doing that once per row per bucket kind measured
|
|
70
|
+
* meaningfully slower in this round's own benchmark than reusing one.
|
|
71
|
+
*/
|
|
72
|
+
const regexSandbox = createContext(Object.create(null));
|
|
73
|
+
/** Wall-clock bound on one regex execution. Measured (this round): a real
|
|
74
|
+
* catastrophic pattern, `(a+)+b` against 30 a's, which never returns on
|
|
75
|
+
* its own, is interrupted within 251-267ms under this mechanism; a safe
|
|
76
|
+
* pattern executes in under a millisecond, so the bound is not on the
|
|
77
|
+
* critical path for any pattern this repository's own config uses. */
|
|
78
|
+
export const REGEX_EXEC_TIMEOUT_MS = 250;
|
|
79
|
+
export class RegexBoundExceededError extends Error {
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Execute `compiled.exec(value)` inside a v8 context with a wall-clock
|
|
83
|
+
* timeout. `node:vm`'s `timeout` option interrupts synchronous JavaScript
|
|
84
|
+
* execution, INCLUDING regex backtracking (v8 checks for the termination
|
|
85
|
+
* request during a regex match, not only between statements), which is
|
|
86
|
+
* why this bound can stop a hung `.exec()` where a plain try/catch around
|
|
87
|
+
* a synchronous call cannot: a catastrophic match never throws on its
|
|
88
|
+
* own, it simply never returns. Measured directly (this round): the same
|
|
89
|
+
* `(a+)+b` pattern against inputs of length 18 through 40 completes in
|
|
90
|
+
* under 40ms up to length 22, then 63ms, 302ms, and is interrupted at the
|
|
91
|
+
* 250ms bound from length 26 onward, rather than running to the multi-
|
|
92
|
+
* second and then multi-minute times the unbounded engine produces at
|
|
93
|
+
* length 26 and 40.
|
|
94
|
+
*/
|
|
95
|
+
export function boundedExec(compiled, value) {
|
|
96
|
+
Object.assign(regexSandbox, { __pattern: compiled, __value: value, __out: undefined });
|
|
97
|
+
try {
|
|
98
|
+
new Script("__out = __pattern.exec(__value);").runInContext(regexSandbox, {
|
|
99
|
+
timeout: REGEX_EXEC_TIMEOUT_MS,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new RegexBoundExceededError(`pattern ${compiled.source} did not complete within ${String(REGEX_EXEC_TIMEOUT_MS)}ms ` +
|
|
104
|
+
`against a value of length ${String(value.length)} (possible catastrophic backtracking)`);
|
|
105
|
+
}
|
|
106
|
+
const out = regexSandbox.__out;
|
|
107
|
+
return out ?? null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A STATIC heuristic for the single most common catastrophic-backtracking
|
|
111
|
+
* shape (fix round 1, CR-991): a parenthesised group containing an
|
|
112
|
+
* unbounded quantifier (`+` or `*`), itself immediately followed by
|
|
113
|
+
* another unbounded quantifier, e.g. `(a+)+`, `(a*)+`, `(a+)*`. This is
|
|
114
|
+
* exactly the shape the round's own red witness constructs
|
|
115
|
+
* (`(a+)+b`). It is a heuristic, not a proof: it does not see every
|
|
116
|
+
* catastrophic shape (a documented residue below), which is why every
|
|
117
|
+
* EXECUTION is also bounded by `boundedExec` regardless of whether a
|
|
118
|
+
* pattern passes this check.
|
|
119
|
+
*/
|
|
120
|
+
export function catastrophicShapeReason(pattern) {
|
|
121
|
+
if (/\([^()]*[+*][^()]*\)[+*]/.test(pattern)) {
|
|
122
|
+
return (`contains a group ending in a repeated + or * immediately followed ` +
|
|
123
|
+
`by another + or * (a known catastrophic-backtracking shape); ` +
|
|
124
|
+
`rewrite it to avoid nested unbounded repetition`);
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Validate one config-supplied pattern SOURCE before it is ever compiled
|
|
130
|
+
* into a live regex the gate will execute (fix round 1, CR-990/991):
|
|
131
|
+
* compiles cleanly, and does not match the static catastrophic-shape
|
|
132
|
+
* heuristic. `label` identifies which config field failed, so a config
|
|
133
|
+
* error names the field, not just "a pattern".
|
|
134
|
+
*/
|
|
135
|
+
export function validatePatternSource(label, pattern) {
|
|
136
|
+
try {
|
|
137
|
+
// eslint-disable-next-line no-new
|
|
138
|
+
new RegExp(pattern);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
return `${label} ${JSON.stringify(pattern)} is not a valid regular expression: ${error.message}`;
|
|
142
|
+
}
|
|
143
|
+
const shape = catastrophicShapeReason(pattern);
|
|
144
|
+
if (shape !== undefined) {
|
|
145
|
+
return `${label} ${JSON.stringify(pattern)} ${shape}`;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Validate every config-supplied pattern this module will ever compile
|
|
151
|
+
* and execute: both `idPattern`s and every `bucketKinds[].pattern`. Run
|
|
152
|
+
* once per config, before the config is used, so a malformed or dangerous
|
|
153
|
+
* pattern is a named CONFIG error (a clean result record) rather than an
|
|
154
|
+
* uncaught exception with no record (CR-990) or an unbounded hang
|
|
155
|
+
* (CR-991).
|
|
156
|
+
*/
|
|
157
|
+
export function validateConfigPatterns(config) {
|
|
158
|
+
const checks = [
|
|
159
|
+
["inventory.idPattern", config.inventory.idPattern],
|
|
160
|
+
["coverageTable.idPattern", config.coverageTable.idPattern],
|
|
161
|
+
...config.bucketKinds.map((bucketKind, index) => [
|
|
162
|
+
`bucketKinds[${String(index)}] (kind ${bucketKind.kind}).pattern`,
|
|
163
|
+
bucketKind.pattern,
|
|
164
|
+
]),
|
|
165
|
+
];
|
|
166
|
+
for (const [label, pattern] of checks) {
|
|
167
|
+
const problem = validatePatternSource(label, pattern);
|
|
168
|
+
if (problem !== undefined) {
|
|
169
|
+
return problem;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Walk a document's lines and return every markdown-table row whose first
|
|
176
|
+
* cell matches `idPattern` (compiled anchored). Separator rows
|
|
177
|
+
* (`|---|:--:|---|`) are skipped by construction: every cell in one matches
|
|
178
|
+
* `/^:?-+:?$/`, which cannot also match a realistic id pattern, but the
|
|
179
|
+
* separator test is applied explicitly rather than relied on implicitly, so
|
|
180
|
+
* a future id pattern that could coincide does not silently absorb one.
|
|
181
|
+
* Every match against a config-supplied pattern is BOUNDED (fix round 1,
|
|
182
|
+
* `boundedExec`), so a hostile `idPattern` cannot hang this walk.
|
|
183
|
+
*/
|
|
184
|
+
export function extractIdRows(text, idPattern) {
|
|
185
|
+
const anchored = new RegExp(`^(?:${idPattern})$`);
|
|
186
|
+
const rows = [];
|
|
187
|
+
const lines = text.split(/\r?\n/);
|
|
188
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
189
|
+
const line = (lines[index] ?? "").trim();
|
|
190
|
+
if (!line.startsWith("|") || !line.endsWith("|") || line.length < 2) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const cells = line
|
|
194
|
+
.slice(1, -1)
|
|
195
|
+
.split("|")
|
|
196
|
+
.map((cell) => cell.trim());
|
|
197
|
+
if (cells.length === 0) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (cells.every((cell) => /^:?-+:?$/.test(cell))) {
|
|
201
|
+
continue; // header separator row
|
|
202
|
+
}
|
|
203
|
+
const first = cells[0] ?? "";
|
|
204
|
+
if (boundedExec(anchored, first) !== null) {
|
|
205
|
+
rows.push({ id: first, cells, line: index + 1 });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return rows;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The core check. Pure: takes the two documents' TEXT (already read through
|
|
212
|
+
* M2-C-6's guarded read at the call site) and the config, and returns a
|
|
213
|
+
* report with nothing hidden inside a status string.
|
|
214
|
+
*/
|
|
215
|
+
export function checkCoverage(config, inventoryText, coverageTableText) {
|
|
216
|
+
const inventoryRows = extractIdRows(inventoryText, config.inventory.idPattern);
|
|
217
|
+
const coverageRows = extractIdRows(coverageTableText, config.coverageTable.idPattern);
|
|
218
|
+
// CR-985: THE INVENTORY-SIDE CARDINALITY INVARIANT. `inventoryRows` can
|
|
219
|
+
// carry the same id more than once (the raw text says so); `units` is a
|
|
220
|
+
// count of DISTINCT ids, stated here rather than left implicit, and a
|
|
221
|
+
// repeated id is its own finding, never silently folded into "one more
|
|
222
|
+
// unit". `uniqueInventoryIds` preserves first-occurrence order so the
|
|
223
|
+
// classification loop below processes each distinct id exactly once,
|
|
224
|
+
// the same way the coverage side's `byId` grouping processes each
|
|
225
|
+
// distinct coverage-table id exactly once.
|
|
226
|
+
const inventoryIdOccurrences = new Map();
|
|
227
|
+
for (const row of inventoryRows) {
|
|
228
|
+
inventoryIdOccurrences.set(row.id, (inventoryIdOccurrences.get(row.id) ?? 0) + 1);
|
|
229
|
+
}
|
|
230
|
+
const uniqueInventoryIds = [...inventoryIdOccurrences.keys()];
|
|
231
|
+
const inventorySet = new Set(uniqueInventoryIds);
|
|
232
|
+
const byId = new Map();
|
|
233
|
+
for (const row of coverageRows) {
|
|
234
|
+
const existing = byId.get(row.id);
|
|
235
|
+
if (existing === undefined) {
|
|
236
|
+
byId.set(row.id, [row]);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
existing.push(row);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const findings = [];
|
|
243
|
+
const perKind = {};
|
|
244
|
+
const perMilestone = {};
|
|
245
|
+
for (const [id, occurrences] of inventoryIdOccurrences) {
|
|
246
|
+
if (occurrences > 1) {
|
|
247
|
+
findings.push({
|
|
248
|
+
kind: "duplicate-inventory-id",
|
|
249
|
+
id,
|
|
250
|
+
detail: `inventory id ${id} appears ${String(occurrences)} times in the inventory document`,
|
|
251
|
+
});
|
|
252
|
+
// The id is still checked against the coverage table below: a
|
|
253
|
+
// duplicated id can ALSO be an orphan, and both are worth naming.
|
|
254
|
+
}
|
|
255
|
+
const rows = byId.get(id) ?? [];
|
|
256
|
+
if (rows.length === 0) {
|
|
257
|
+
findings.push({
|
|
258
|
+
kind: "orphan",
|
|
259
|
+
id,
|
|
260
|
+
detail: `inventory id ${id} has no row in the coverage table`,
|
|
261
|
+
});
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (rows.length > 1) {
|
|
265
|
+
findings.push({
|
|
266
|
+
kind: "double-bucketed",
|
|
267
|
+
id,
|
|
268
|
+
detail: `inventory id ${id} appears in ${String(rows.length)} coverage-table rows (lines ${rows
|
|
269
|
+
.map((row) => String(row.line))
|
|
270
|
+
.join(", ")})`,
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const row = rows[0];
|
|
275
|
+
const bucketValue = row.cells[config.coverageTable.bucketColumn] ?? "";
|
|
276
|
+
const note = row.cells[config.coverageTable.noteColumn] ?? "";
|
|
277
|
+
// CR-992: EVERY declared kind is tested, never only until the first
|
|
278
|
+
// match, so a value matching more than one kind is a named finding
|
|
279
|
+
// rather than a silent first-match-wins (which used to let a second
|
|
280
|
+
// kind's `requiresNote` go uninspected with nothing said).
|
|
281
|
+
const matches = [];
|
|
282
|
+
for (const bucketKind of config.bucketKinds) {
|
|
283
|
+
const compiled = new RegExp(`^(?:${bucketKind.pattern})$`);
|
|
284
|
+
const match = boundedExec(compiled, bucketValue);
|
|
285
|
+
if (match !== null) {
|
|
286
|
+
matches.push({ kind: bucketKind, milestone: match[1] });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (matches.length === 0) {
|
|
290
|
+
findings.push({
|
|
291
|
+
kind: "unknown-kind",
|
|
292
|
+
id,
|
|
293
|
+
detail: `bucket value ${JSON.stringify(bucketValue)} for id ${id} (line ${String(row.line)}) matches no declared bucket kind`,
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (matches.length > 1) {
|
|
298
|
+
findings.push({
|
|
299
|
+
kind: "ambiguous-kind",
|
|
300
|
+
id,
|
|
301
|
+
detail: `bucket value ${JSON.stringify(bucketValue)} for id ${id} (line ${String(row.line)}) matches more than one declared bucket kind: ${matches
|
|
302
|
+
.map((entry) => entry.kind.kind)
|
|
303
|
+
.join(", ")}`,
|
|
304
|
+
});
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const classified = matches[0];
|
|
308
|
+
// CR-987: ONE shared emptiness predicate, not a bare `=== ""`, so a
|
|
309
|
+
// whitespace-only or zero-width-only note is empty here the same way
|
|
310
|
+
// it is empty in `checkFindingOutcomeParity` below.
|
|
311
|
+
if (classified.kind.requiresNote && isEmptyCell(note)) {
|
|
312
|
+
findings.push({
|
|
313
|
+
kind: "missing-note",
|
|
314
|
+
id,
|
|
315
|
+
detail: `${classified.kind.kind} row for id ${id} (line ${String(row.line)}) has an empty note`,
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const kindName = classified.kind.kind;
|
|
320
|
+
perKind[kindName] = (perKind[kindName] ?? 0) + 1;
|
|
321
|
+
const milestoneKey = classified.milestone === undefined ? kindName : `M${classified.milestone}`;
|
|
322
|
+
perMilestone[milestoneKey] = (perMilestone[milestoneKey] ?? 0) + 1;
|
|
323
|
+
}
|
|
324
|
+
for (const row of coverageRows) {
|
|
325
|
+
if (!inventorySet.has(row.id)) {
|
|
326
|
+
findings.push({
|
|
327
|
+
kind: "phantom",
|
|
328
|
+
id: row.id,
|
|
329
|
+
detail: `coverage-table row ${row.id} (line ${String(row.line)}) has no matching inventory id`,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const totalInventoryIds = uniqueInventoryIds.length;
|
|
334
|
+
// CR-986: THE EXPECTED-UNITS FLOOR. A row deleted from BOTH documents
|
|
335
|
+
// leaves them agreeing with each other, so orphan, phantom and
|
|
336
|
+
// duplicate-inventory-id are all silent about it; only a config-stated
|
|
337
|
+
// expectation can catch a computed total that is smaller (or larger)
|
|
338
|
+
// than the number of ids this pair is supposed to carry.
|
|
339
|
+
if (config.expectedUnits !== undefined && totalInventoryIds !== config.expectedUnits) {
|
|
340
|
+
findings.push({
|
|
341
|
+
kind: "expected-units-mismatch",
|
|
342
|
+
id: "(total)",
|
|
343
|
+
detail: `expected ${String(config.expectedUnits)} distinct inventory id(s) but found ${String(totalInventoryIds)}`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
totalInventoryIds,
|
|
348
|
+
perKind,
|
|
349
|
+
perMilestone,
|
|
350
|
+
findings,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
export function checkFindingOutcomeParity(inventoryIds, findings) {
|
|
354
|
+
const inventorySet = new Set(inventoryIds);
|
|
355
|
+
const byId = new Map();
|
|
356
|
+
for (const finding of findings) {
|
|
357
|
+
const existing = byId.get(finding.id);
|
|
358
|
+
if (existing === undefined) {
|
|
359
|
+
byId.set(finding.id, [finding]);
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
existing.push(finding);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const missing = [];
|
|
366
|
+
const duplicated = [];
|
|
367
|
+
const empty = [];
|
|
368
|
+
const phantom = [];
|
|
369
|
+
for (const id of inventoryIds) {
|
|
370
|
+
const rows = byId.get(id) ?? [];
|
|
371
|
+
if (rows.length === 0) {
|
|
372
|
+
missing.push(id);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (rows.length > 1) {
|
|
376
|
+
duplicated.push(id);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
// CR-987: the shared predicate, not a bare `=== ""` (a whitespace-only
|
|
380
|
+
// or zero-width-only outcome used to read as a non-empty say).
|
|
381
|
+
if (isEmptyCell(rows[0].outcome)) {
|
|
382
|
+
empty.push(id);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// CR-988: THE OTHER DIRECTION. `inventoryIds` alone cannot see a report
|
|
386
|
+
// row for an id that no longer exists; that requires scanning the
|
|
387
|
+
// REPORT'S OWN ids against the inventory, the same shape as
|
|
388
|
+
// `checkCoverage`'s phantom scan over coverage-table rows.
|
|
389
|
+
for (const id of byId.keys()) {
|
|
390
|
+
if (!inventorySet.has(id)) {
|
|
391
|
+
phantom.push(id);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
ok: missing.length === 0 &&
|
|
396
|
+
duplicated.length === 0 &&
|
|
397
|
+
empty.length === 0 &&
|
|
398
|
+
phantom.length === 0,
|
|
399
|
+
checked: inventoryIds.length,
|
|
400
|
+
missing,
|
|
401
|
+
duplicated,
|
|
402
|
+
empty,
|
|
403
|
+
phantom,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
/* -------------------------------------------------------------------- */
|
|
407
|
+
/* The CLI entry: `node src/gates/coverage.ts --result <path> --evidence */
|
|
408
|
+
/* <dir> [--config <path>]`, the gate subprocess contract src/gates/run.ts */
|
|
409
|
+
/* documents. Registered in gates.manifest.json with no declared */
|
|
410
|
+
/* parameters, so the runner's invocation is exactly these two flags. */
|
|
411
|
+
/* -------------------------------------------------------------------- */
|
|
412
|
+
const schemaUrl = new URL("./schemas/coverage-config.schema.json", import.meta.url);
|
|
413
|
+
let cachedConfigSchema;
|
|
414
|
+
function configSchema() {
|
|
415
|
+
if (cachedConfigSchema !== undefined) {
|
|
416
|
+
return { ok: true, schema: cachedConfigSchema };
|
|
417
|
+
}
|
|
418
|
+
const path = fileURLToPath(schemaUrl);
|
|
419
|
+
const read = readRegularFileIfPresent(path);
|
|
420
|
+
if (read.kind !== "read") {
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
loadError: read.kind === "absent"
|
|
424
|
+
? `schema document ${path} is missing from this installation`
|
|
425
|
+
: read.reason,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
let parsed;
|
|
429
|
+
try {
|
|
430
|
+
parsed = JSON.parse(read.body);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
return { ok: false, loadError: `${path} does not parse as JSON: ${error.message}` };
|
|
434
|
+
}
|
|
435
|
+
const loaded = loadSchema(parsed, path);
|
|
436
|
+
if (!loaded.ok) {
|
|
437
|
+
return { ok: false, loadError: loaded.reason };
|
|
438
|
+
}
|
|
439
|
+
cachedConfigSchema = loaded.schema;
|
|
440
|
+
return { ok: true, schema: cachedConfigSchema };
|
|
441
|
+
}
|
|
442
|
+
const VALUE_FLAGS = ["--result", "--evidence", "--config"];
|
|
443
|
+
function parseFlags(args) {
|
|
444
|
+
const flags = {};
|
|
445
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
446
|
+
const flag = args[index];
|
|
447
|
+
const value = args[index + 1];
|
|
448
|
+
if (flag === undefined || !VALUE_FLAGS.includes(flag)) {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
if (value === undefined || value.startsWith("--")) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
if (flag === "--result") {
|
|
455
|
+
flags.result = value;
|
|
456
|
+
}
|
|
457
|
+
else if (flag === "--evidence") {
|
|
458
|
+
flags.evidence = value;
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
flags.config = value;
|
|
462
|
+
}
|
|
463
|
+
index += 1;
|
|
464
|
+
}
|
|
465
|
+
return flags;
|
|
466
|
+
}
|
|
467
|
+
/** Load and validate a `--config` document, or fall back to the kernel config. */
|
|
468
|
+
function resolveConfig(configPath) {
|
|
469
|
+
if (configPath === undefined) {
|
|
470
|
+
return { ok: true, config: KERNEL_COVERAGE_CONFIG };
|
|
471
|
+
}
|
|
472
|
+
const read = readRegularFileIfPresent(configPath);
|
|
473
|
+
if (read.kind !== "read") {
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
reason: read.kind === "absent"
|
|
477
|
+
? `config ${configPath} does not exist`
|
|
478
|
+
: read.reason,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
let parsed;
|
|
482
|
+
try {
|
|
483
|
+
parsed = JSON.parse(read.body);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
reason: `config ${configPath} does not parse as JSON: ${error.message}`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const schema = configSchema();
|
|
492
|
+
if (!schema.ok) {
|
|
493
|
+
return { ok: false, reason: schema.loadError };
|
|
494
|
+
}
|
|
495
|
+
const diagnostics = validate(schema.schema, parsed);
|
|
496
|
+
if (diagnostics.length > 0) {
|
|
497
|
+
return {
|
|
498
|
+
ok: false,
|
|
499
|
+
reason: `config ${configPath} is not a valid coverage config: ${diagnostics
|
|
500
|
+
.map((diagnostic) => `INVALID ${diagnostic.pointer} ${diagnostic.message}`)
|
|
501
|
+
.join("; ")}`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
return { ok: true, config: parsed };
|
|
505
|
+
}
|
|
506
|
+
function readConfiguredDocument(label, path) {
|
|
507
|
+
const read = readRegularFileIfPresent(path);
|
|
508
|
+
if (read.kind === "read") {
|
|
509
|
+
return { ok: true, text: read.body };
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
reason: read.kind === "absent"
|
|
514
|
+
? `${label} ${path} does not exist`
|
|
515
|
+
: read.reason,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
function formatCounts(counts) {
|
|
519
|
+
const keys = Object.keys(counts).sort();
|
|
520
|
+
if (keys.length === 0) {
|
|
521
|
+
return "(none)";
|
|
522
|
+
}
|
|
523
|
+
return keys.map((key) => `${key} ${String(counts[key])}`).join(", ");
|
|
524
|
+
}
|
|
525
|
+
export function main(argv) {
|
|
526
|
+
const flags = parseFlags(argv);
|
|
527
|
+
const startedAt = new Date().toISOString();
|
|
528
|
+
if (flags === undefined || flags.result === undefined) {
|
|
529
|
+
process.stderr.write("usage: node src/gates/coverage.ts --result <file> --evidence <dir> [--config <file>]\n");
|
|
530
|
+
return 64;
|
|
531
|
+
}
|
|
532
|
+
const resolvedConfig = resolveConfig(flags.config);
|
|
533
|
+
if (!resolvedConfig.ok) {
|
|
534
|
+
return emit(flags.result, {
|
|
535
|
+
status: "error",
|
|
536
|
+
units: 0,
|
|
537
|
+
startedAt,
|
|
538
|
+
detail: resolvedConfig.reason,
|
|
539
|
+
evidence: [],
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
const config = resolvedConfig.config;
|
|
543
|
+
// CR-990/991, THE REGEX MECHANISM, VALIDATE HALF. Every config-supplied
|
|
544
|
+
// pattern is checked BEFORE it is compiled into a live regex anywhere
|
|
545
|
+
// else in this run: a malformed pattern used to throw out of
|
|
546
|
+
// `extractIdRows` and escape all the way to the top-level handler,
|
|
547
|
+
// which writes NO result record (CR-990); a syntactically valid but
|
|
548
|
+
// catastrophic pattern used to hang (CR-991). Both are now a named
|
|
549
|
+
// config error with a clean record, never an uncaught exception.
|
|
550
|
+
const patternProblem = validateConfigPatterns(config);
|
|
551
|
+
if (patternProblem !== undefined) {
|
|
552
|
+
return emit(flags.result, {
|
|
553
|
+
status: "error",
|
|
554
|
+
units: 0,
|
|
555
|
+
startedAt,
|
|
556
|
+
detail: `invalid config: ${patternProblem}`,
|
|
557
|
+
evidence: [],
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
const inventory = readConfiguredDocument("inventory", config.inventory.path);
|
|
561
|
+
if (!inventory.ok) {
|
|
562
|
+
return emit(flags.result, {
|
|
563
|
+
status: "error",
|
|
564
|
+
units: 0,
|
|
565
|
+
startedAt,
|
|
566
|
+
detail: inventory.reason,
|
|
567
|
+
evidence: [],
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
const coverageTable = readConfiguredDocument("coverage table", config.coverageTable.path);
|
|
571
|
+
if (!coverageTable.ok) {
|
|
572
|
+
return emit(flags.result, {
|
|
573
|
+
status: "error",
|
|
574
|
+
units: 0,
|
|
575
|
+
startedAt,
|
|
576
|
+
detail: coverageTable.reason,
|
|
577
|
+
evidence: [],
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
// CR-990/991, THE REGEX MECHANISM, BOUND HALF (defense in depth). The
|
|
581
|
+
// static validation above rejects the shapes `catastrophicShapeReason`
|
|
582
|
+
// recognises; this catches anything an exception escapes with anyway
|
|
583
|
+
// (a `RegexBoundExceededError` from `boundedExec`, or any other throw),
|
|
584
|
+
// converting it into a normal error record instead of letting it reach
|
|
585
|
+
// the top-level handler with no record written, which is the general
|
|
586
|
+
// form of CR-990's finding rather than only its regex instance.
|
|
587
|
+
let report;
|
|
588
|
+
try {
|
|
589
|
+
report = checkCoverage(config, inventory.text, coverageTable.text);
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
return emit(flags.result, {
|
|
593
|
+
status: "error",
|
|
594
|
+
units: 0,
|
|
595
|
+
startedAt,
|
|
596
|
+
detail: `coverage check failed: ${singleLine(error.message ?? String(error))}`,
|
|
597
|
+
evidence: [],
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
// M2-C-2: an empty inventory is reported GREEN with zero units so that
|
|
601
|
+
// the shared constructor's rewrite (never duplicated here) turns it into
|
|
602
|
+
// `error` with `vacuous: true`, rather than reporting whatever the
|
|
603
|
+
// (vacuous) findings pass happened to compute.
|
|
604
|
+
const status = report.totalInventoryIds === 0
|
|
605
|
+
? "green"
|
|
606
|
+
: report.findings.length > 0
|
|
607
|
+
? "red"
|
|
608
|
+
: "green";
|
|
609
|
+
// CR-989: THE EVIDENCE-SIDE REFUSAL IS NOW HONOURED, not computed and
|
|
610
|
+
// discarded. A FIFO or an absent parent directory at the evidence path
|
|
611
|
+
// used to leave `refusal` and `written.reason` unread and report GREEN
|
|
612
|
+
// with empty evidence; this is now the same M2-C-6 discipline the
|
|
613
|
+
// result path already had (loud, and the gate reports `error`), applied
|
|
614
|
+
// to the write side rather than only the read side.
|
|
615
|
+
const evidenceFiles = [];
|
|
616
|
+
if (flags.evidence !== undefined) {
|
|
617
|
+
const countsPath = join(flags.evidence, "counts.json");
|
|
618
|
+
const refusal = refuseOpenForWrite(countsPath);
|
|
619
|
+
if (refusal !== undefined) {
|
|
620
|
+
return emit(flags.result, {
|
|
621
|
+
status: "error",
|
|
622
|
+
units: report.totalInventoryIds,
|
|
623
|
+
startedAt,
|
|
624
|
+
detail: `evidence write refused: ${refusal}`,
|
|
625
|
+
evidence: [],
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const written = runStep(`writing ${countsPath}`, () => writeFileSync(countsPath, `${JSON.stringify({
|
|
629
|
+
totalInventoryIds: report.totalInventoryIds,
|
|
630
|
+
perKind: report.perKind,
|
|
631
|
+
perMilestone: report.perMilestone,
|
|
632
|
+
findings: report.findings,
|
|
633
|
+
}, null, 2)}\n`));
|
|
634
|
+
if (!written.ok) {
|
|
635
|
+
return emit(flags.result, {
|
|
636
|
+
status: "error",
|
|
637
|
+
units: report.totalInventoryIds,
|
|
638
|
+
startedAt,
|
|
639
|
+
detail: `evidence write failed: ${written.reason}`,
|
|
640
|
+
evidence: [],
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
evidenceFiles.push("counts.json");
|
|
644
|
+
}
|
|
645
|
+
const detail = report.findings.length > 0
|
|
646
|
+
? `${String(report.findings.length)} finding(s): ${report.findings
|
|
647
|
+
.map((finding) => `${finding.kind} ${finding.id}`)
|
|
648
|
+
.join("; ")}`
|
|
649
|
+
: `${String(report.totalInventoryIds)} inventory id(s) checked; ` +
|
|
650
|
+
`per-kind: ${formatCounts(report.perKind)}; ` +
|
|
651
|
+
`per-milestone: ${formatCounts(report.perMilestone)}`;
|
|
652
|
+
return emit(flags.result, {
|
|
653
|
+
status,
|
|
654
|
+
units: report.totalInventoryIds,
|
|
655
|
+
startedAt,
|
|
656
|
+
detail,
|
|
657
|
+
evidence: evidenceFiles,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
function emit(resultPath, fields) {
|
|
661
|
+
const result = makeGateResult({
|
|
662
|
+
gate: "coverage",
|
|
663
|
+
status: fields.status,
|
|
664
|
+
units: fields.units,
|
|
665
|
+
unitLabel: "finding ids checked",
|
|
666
|
+
startedAt: fields.startedAt,
|
|
667
|
+
endedAt: new Date().toISOString(),
|
|
668
|
+
detail: fields.detail,
|
|
669
|
+
evidence: fields.evidence,
|
|
670
|
+
});
|
|
671
|
+
const refusal = refuseOpenForWrite(resultPath);
|
|
672
|
+
if (refusal !== undefined) {
|
|
673
|
+
process.stderr.write(`tiphys coverage: ${refusal}\n`);
|
|
674
|
+
return EXIT_GATE_ERROR;
|
|
675
|
+
}
|
|
676
|
+
const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
|
|
677
|
+
if (!written.ok) {
|
|
678
|
+
process.stderr.write(`tiphys coverage: ${written.reason}\n`);
|
|
679
|
+
return EXIT_GATE_ERROR;
|
|
680
|
+
}
|
|
681
|
+
process.stdout.write(`coverage: ${result.status} (${String(result.units)} ${result.unitLabel})\n`);
|
|
682
|
+
if (result.detail !== "") {
|
|
683
|
+
process.stdout.write(`${result.detail}\n`);
|
|
684
|
+
}
|
|
685
|
+
return exitCodeForStatus(result.status);
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Run only when invoked directly (`node src/gates/coverage.ts ...`), never
|
|
689
|
+
* on import: `test/coverage-gate.test.ts` imports this module's pure
|
|
690
|
+
* functions through the computed-URL pattern and must not trigger a CLI
|
|
691
|
+
* run as a side effect of that import.
|
|
692
|
+
*/
|
|
693
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
694
|
+
try {
|
|
695
|
+
process.exitCode = main(process.argv.slice(2));
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
process.stderr.write(`tiphys coverage: ${singleLine(error.message ?? String(error))}\n`);
|
|
699
|
+
process.exitCode = EXIT_GATE_ERROR;
|
|
700
|
+
}
|
|
701
|
+
}
|