@tiphys/kernel 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +663 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +299 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +92 -0
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +66 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +1539 -0
- package/dist/src/checks.js +5025 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +68 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +343 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +264 -0
- package/dist/src/commands/doctor.js +1546 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +177 -0
- package/dist/src/commands/lock.d.ts +34 -0
- package/dist/src/commands/lock.js +340 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +139 -0
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +195 -0
- package/dist/src/commands/status.d.ts +21 -0
- package/dist/src/commands/status.js +122 -0
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +87 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +430 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +278 -0
- package/dist/src/exec/env.js +334 -0
- package/dist/src/fleet.d.ts +223 -0
- package/dist/src/fleet.js +298 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1169 -0
- package/dist/src/gates/coverage.d.ts +375 -0
- package/dist/src/gates/coverage.js +836 -0
- package/dist/src/gates/credentials.d.ts +233 -0
- package/dist/src/gates/credentials.js +752 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +482 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +614 -0
- package/dist/src/gates/run.js +1614 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +968 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +84 -0
- package/dist/src/hooks.js +125 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +256 -0
- package/dist/src/lock.js +792 -0
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +34 -0
- package/dist/src/path-identity.js +48 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +326 -0
- package/dist/src/pool.js +988 -0
- package/dist/src/roles.d.ts +461 -0
- package/dist/src/roles.js +776 -0
- package/dist/src/spawn.d.ts +482 -0
- package/dist/src/spawn.js +1003 -0
- package/dist/src/status.d.ts +116 -0
- package/dist/src/status.js +148 -0
- package/dist/src/task.d.ts +504 -0
- package/dist/src/task.js +307 -0
- package/dist/src/teardown.d.ts +39 -0
- package/dist/src/teardown.js +422 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +263 -0
- package/dist/src/validate.js +742 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +849 -0
- package/dist/src/witness/run.d.ts +299 -0
- package/dist/src/witness/run.js +1373 -0
- package/dist/src/witness/spec.d.ts +270 -0
- package/dist/src/witness/spec.js +475 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +526 -0
- package/gates.manifest.json +257 -0
- package/package.json +72 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +463 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +82 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +185 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +295 -0
- package/schemas/work-history.schema.json +183 -0
- package/schemas/write-bypass.schema.json +69 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { pathsIdentifySameObject } from "../path-identity.js";
|
|
5
|
+
import { Script, createContext } from "node:vm";
|
|
6
|
+
import { cpus, loadavg } from "node:os";
|
|
7
|
+
import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
|
|
8
|
+
import { loadSchema, validate } from "./validate.js";
|
|
9
|
+
import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
|
|
10
|
+
/**
|
|
11
|
+
* THE KERNEL CONFIG (kernel plan M2-P6 step 2): this repository's real
|
|
12
|
+
* pair and the four bucket kinds the plan states verbatim. It is a plain
|
|
13
|
+
* exported constant, not a checked-in data file, because M2-C-1's
|
|
14
|
+
* verification-first reading of the plan's files-to-touch list for this
|
|
15
|
+
* phase names the module, its schema and its test, and no committed
|
|
16
|
+
* configuration document alongside them; a `--config` flag (validated
|
|
17
|
+
* against `coverage-config.schema.json`) exists for a caller that wants a
|
|
18
|
+
* different pair, and every fixture test in `test/coverage-gate.test.ts`
|
|
19
|
+
* uses it rather than editing this constant.
|
|
20
|
+
*/
|
|
21
|
+
export const KERNEL_COVERAGE_CONFIG = {
|
|
22
|
+
inventory: {
|
|
23
|
+
path: "delivery/requirements/migration-table.md",
|
|
24
|
+
idPattern: "R-[0-9]+[a-z]?",
|
|
25
|
+
},
|
|
26
|
+
coverageTable: {
|
|
27
|
+
path: "delivery/plan/kernel-plan-v1.md",
|
|
28
|
+
idPattern: "R-[0-9]+[a-z]?",
|
|
29
|
+
bucketColumn: 1,
|
|
30
|
+
noteColumn: 2,
|
|
31
|
+
},
|
|
32
|
+
bucketKinds: [
|
|
33
|
+
{ kind: "phase", pattern: "M([0-9]+)-P[0-9]+", requiresNote: false },
|
|
34
|
+
{ kind: "milestone", pattern: "M([0-9]+)", requiresNote: false },
|
|
35
|
+
{
|
|
36
|
+
kind: "decision",
|
|
37
|
+
pattern: "DR-[0-9]{4}|D-[0-9]+|M2-D-[0-9]+",
|
|
38
|
+
requiresNote: false,
|
|
39
|
+
},
|
|
40
|
+
{ kind: "parked", pattern: "parked", requiresNote: true },
|
|
41
|
+
],
|
|
42
|
+
expectedUnits: 115,
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* THE SHARED EMPTINESS PREDICATE (fix round 1, CR-987).
|
|
46
|
+
*
|
|
47
|
+
* Before this round, `checkCoverage`'s note check compared a value already
|
|
48
|
+
* passed through `String.prototype.trim` (applied once, at extraction) to
|
|
49
|
+
* `""`, and `checkFindingOutcomeParity`'s outcome check compared a value
|
|
50
|
+
* that was never trimmed at all to `""`. Two definitions, only one of
|
|
51
|
+
* which trimmed, and NEITHER strips a zero-width character: `trim()`
|
|
52
|
+
* removes Unicode whitespace (which includes U+00A0 and U+FEFF) but not
|
|
53
|
+
* U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER or U+200D ZERO
|
|
54
|
+
* WIDTH JOINER, so a cell or an outcome containing only one of those reads
|
|
55
|
+
* as non-empty to a bare `=== ""` comparison, trimmed or not. One
|
|
56
|
+
* predicate, called at every point this module asks "is this empty",
|
|
57
|
+
* closes both instances at once.
|
|
58
|
+
*/
|
|
59
|
+
export function isEmptyCell(value) {
|
|
60
|
+
// U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER, U+200D ZERO
|
|
61
|
+
// WIDTH JOINER, U+FEFF ZERO WIDTH NO-BREAK SPACE (BOM). Written as
|
|
62
|
+
// \u escapes rather than embedded literally: this repository's
|
|
63
|
+
// authored files are pure ASCII (CLAUDE.md convention 3), which a
|
|
64
|
+
// literal zero-width character in source would silently violate.
|
|
65
|
+
return value.replace(/[\u200B\u200C\u200D\uFEFF]/g, "").trim() === "";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A REUSED v8 CONTEXT for every bounded regex execution this module
|
|
69
|
+
* performs (fix round 1, the regex mechanism, CR-991). Created once at
|
|
70
|
+
* module load rather than per call: `vm.createContext` builds a real v8
|
|
71
|
+
* context and doing that once per row per bucket kind measured
|
|
72
|
+
* meaningfully slower in this round's own benchmark than reusing one.
|
|
73
|
+
*/
|
|
74
|
+
const regexSandbox = createContext(Object.create(null));
|
|
75
|
+
/**
|
|
76
|
+
* THE VERDICT-PRODUCING BOUND IS CPU WORK, NOT ELAPSED TIME (M4-P28).
|
|
77
|
+
*
|
|
78
|
+
* What this constant replaces, and why the replacement is not a bigger
|
|
79
|
+
* number. Until this phase the bound was `REGEX_EXEC_TIMEOUT_MS = 250`,
|
|
80
|
+
* handed to `vm` as a WALL-CLOCK timeout, and exceeding it was reported as
|
|
81
|
+
* "did not complete within 250ms ... (possible catastrophic
|
|
82
|
+
* backtracking)". Elapsed wall time is complexity DIVIDED BY available
|
|
83
|
+
* CPU, so that condition did not test the property it named: it reddened
|
|
84
|
+
* whenever the machine was busy. Measured and recorded in
|
|
85
|
+
* delivery/verification/wall-clock-budgets-are-load-dependent.md:1, five
|
|
86
|
+
* independent witnesses hit it on six structurally different patterns,
|
|
87
|
+
* including `^(?:parked)$`, a doubly anchored literal with no quantifier,
|
|
88
|
+
* no character class and no alternation, which has nothing to backtrack
|
|
89
|
+
* over at any input length. One million executions of
|
|
90
|
+
* `^(?:R-[0-9]+[a-z]?)$` against `R-094a` take 154.2ms in total, so ONE is
|
|
91
|
+
* roughly 1.6 million times under the old budget; for one of them to
|
|
92
|
+
* exceed it the thread must be descheduled for a quarter of a second,
|
|
93
|
+
* which is a fact about the machine.
|
|
94
|
+
*
|
|
95
|
+
* Raising 250 to a larger number was considered and is REJECTED by the
|
|
96
|
+
* plan (delivery/plan/kernel-plan-m4.md:3784): it keeps the same
|
|
97
|
+
* instrument and only moves the load at which it lies.
|
|
98
|
+
*
|
|
99
|
+
* WHAT IS MEASURED NOW. `process.threadCpuUsage()` reports the CPU time
|
|
100
|
+
* this thread has consumed, in microseconds. Another process being busy
|
|
101
|
+
* does not add to it, because a descheduled thread consumes no CPU while
|
|
102
|
+
* it is not running. The regex runs on THIS thread inside
|
|
103
|
+
* `runInContext`, so the delta across that call is the work the regex
|
|
104
|
+
* did, and nothing else. On an interpreter without
|
|
105
|
+
* `process.threadCpuUsage` (added in Node 22.15; the package floor is 26)
|
|
106
|
+
* the fallback is `process.cpuUsage()`, which is process-wide and
|
|
107
|
+
* therefore an over-estimate, never an under-estimate, so the fallback
|
|
108
|
+
* cannot let a catastrophic pattern through.
|
|
109
|
+
*/
|
|
110
|
+
export const REGEX_EXEC_CPU_BUDGET_MS = 250;
|
|
111
|
+
/**
|
|
112
|
+
* HOW LONG ONE ATTEMPT IS WILLING TO WAIT. This is PATIENCE, not a
|
|
113
|
+
* verdict: nothing is ever reported about a pattern because this elapsed.
|
|
114
|
+
* A wall-clock interrupt is still needed, because a catastrophic match
|
|
115
|
+
* never returns on its own and `vm`'s `timeout` is the only mechanism
|
|
116
|
+
* here that can stop one (v8 checks for the termination request during a
|
|
117
|
+
* regex match, not only between statements).
|
|
118
|
+
*
|
|
119
|
+
* It is set to TWICE the CPU budget so that a thread receiving at least
|
|
120
|
+
* half of one CPU reaches the budget inside a single attempt; measured on
|
|
121
|
+
* an unloaded box, `(a+)+b` against 30 a's consumes 230.6ms of CPU in a
|
|
122
|
+
* 252ms wall window, so a backstop equal to the budget would have needed
|
|
123
|
+
* a second attempt to reach a verdict it had nearly earned.
|
|
124
|
+
*/
|
|
125
|
+
export const REGEX_EXEC_WALL_BACKSTOP_MS = 500;
|
|
126
|
+
/**
|
|
127
|
+
* How many interrupted attempts before this module admits it cannot
|
|
128
|
+
* reach a verdict. Each attempt doubles its own patience, and the CPU
|
|
129
|
+
* consumed ACCUMULATES across attempts, so a genuinely spinning pattern
|
|
130
|
+
* is still caught on a loaded machine (it burns CPU whenever it runs)
|
|
131
|
+
* while a benign one accumulates microseconds however often it is
|
|
132
|
+
* interrupted.
|
|
133
|
+
*/
|
|
134
|
+
export const REGEX_EXEC_MAX_ATTEMPTS = 4;
|
|
135
|
+
/** The pattern did too much WORK. A verdict about the pattern. */
|
|
136
|
+
export class RegexBoundExceededError extends Error {
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* No verdict was reached: every attempt was interrupted before the
|
|
140
|
+
* pattern finished, and the CPU it consumed never came near the budget,
|
|
141
|
+
* which is the signature of a thread that is not being scheduled rather
|
|
142
|
+
* than one that is spinning. Under M2-C-3 a check that cannot reach a
|
|
143
|
+
* verdict reports ERROR, never a verdict it did not earn, so this is
|
|
144
|
+
* deliberately NOT a subclass of `RegexBoundExceededError`: the two must
|
|
145
|
+
* not be confusable by a `catch` or an `instanceof`.
|
|
146
|
+
*/
|
|
147
|
+
export class RegexBudgetUndeterminedError extends Error {
|
|
148
|
+
}
|
|
149
|
+
/** Thread CPU time where the interpreter has it, process CPU otherwise. */
|
|
150
|
+
function cpuSample() {
|
|
151
|
+
const threadReader = process.threadCpuUsage;
|
|
152
|
+
if (typeof threadReader === "function") {
|
|
153
|
+
return threadReader.call(process);
|
|
154
|
+
}
|
|
155
|
+
return process.cpuUsage();
|
|
156
|
+
}
|
|
157
|
+
/** Milliseconds of CPU (user plus system) between two samples. */
|
|
158
|
+
function cpuMillisBetween(before, after) {
|
|
159
|
+
return (after.user - before.user + (after.system - before.system)) / 1000;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Execute `compiled.exec(value)` inside a v8 context, bounded by the CPU
|
|
163
|
+
* WORK it does rather than by the time it takes.
|
|
164
|
+
*
|
|
165
|
+
* One attempt runs the match with a wall-clock interrupt. Whatever
|
|
166
|
+
* happens, the CPU consumed by this thread during that call is added to a
|
|
167
|
+
* running total:
|
|
168
|
+
*
|
|
169
|
+
* - total CPU at or above the budget: the thread really did burn that
|
|
170
|
+
* much CPU on this one match, which no anchored non-backtracking
|
|
171
|
+
* pattern can do, so `RegexBoundExceededError` is thrown. A busy
|
|
172
|
+
* machine cannot cause this, because a descheduled thread accumulates
|
|
173
|
+
* no CPU.
|
|
174
|
+
* - the match finished: return its result, which is the only path that
|
|
175
|
+
* produces a match.
|
|
176
|
+
* - interrupted with the total still far below the budget: the machine
|
|
177
|
+
* was busy, not the pattern. Double the patience and try again.
|
|
178
|
+
*
|
|
179
|
+
* After `maxAttempts` interruptions with the CPU total still under
|
|
180
|
+
* budget, `RegexBudgetUndeterminedError` is thrown, naming the CPU
|
|
181
|
+
* consumed, the wall clock spent and the load average, so the record says
|
|
182
|
+
* what it observed instead of asserting something about the pattern.
|
|
183
|
+
*
|
|
184
|
+
* An error from `runInContext` that is NOT the timeout is also
|
|
185
|
+
* undetermined rather than a finding: the old code funnelled every throw
|
|
186
|
+
* into the catastrophic-backtracking message, which is the same
|
|
187
|
+
* substitution one cause over.
|
|
188
|
+
*/
|
|
189
|
+
export function boundedExec(compiled, value, bounds = {}) {
|
|
190
|
+
const cpuBudgetMs = bounds.cpuBudgetMs ?? REGEX_EXEC_CPU_BUDGET_MS;
|
|
191
|
+
const maxAttempts = bounds.maxAttempts ?? REGEX_EXEC_MAX_ATTEMPTS;
|
|
192
|
+
let patienceMs = bounds.wallBackstopMs ?? REGEX_EXEC_WALL_BACKSTOP_MS;
|
|
193
|
+
let cpuSpentMs = 0;
|
|
194
|
+
let wallSpentMs = 0;
|
|
195
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
196
|
+
Object.assign(regexSandbox, { __pattern: compiled, __value: value, __out: undefined });
|
|
197
|
+
const cpuBefore = cpuSample();
|
|
198
|
+
const wallBefore = Date.now();
|
|
199
|
+
let failure;
|
|
200
|
+
let interrupted = false;
|
|
201
|
+
try {
|
|
202
|
+
new Script("__out = __pattern.exec(__value);").runInContext(regexSandbox, {
|
|
203
|
+
timeout: patienceMs,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
failure = error;
|
|
208
|
+
interrupted = error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT";
|
|
209
|
+
}
|
|
210
|
+
cpuSpentMs += cpuMillisBetween(cpuBefore, cpuSample());
|
|
211
|
+
wallSpentMs += Date.now() - wallBefore;
|
|
212
|
+
if (cpuSpentMs >= cpuBudgetMs) {
|
|
213
|
+
throw new RegexBoundExceededError(`pattern ${compiled.source} consumed ${cpuSpentMs.toFixed(1)}ms of CPU time ` +
|
|
214
|
+
`(budget ${String(cpuBudgetMs)}ms) against a value of length ${String(value.length)} ` +
|
|
215
|
+
`over ${String(attempt)} attempt(s) spanning ${String(wallSpentMs)}ms of wall clock ` +
|
|
216
|
+
"(catastrophic backtracking)");
|
|
217
|
+
}
|
|
218
|
+
if (failure !== undefined && !interrupted) {
|
|
219
|
+
throw new RegexBudgetUndeterminedError(`pattern ${compiled.source} against a value of length ${String(value.length)} ` +
|
|
220
|
+
`failed to execute: ${String(failure.message ?? failure)}; no verdict ` +
|
|
221
|
+
"about the pattern was reached");
|
|
222
|
+
}
|
|
223
|
+
if (!interrupted) {
|
|
224
|
+
const out = regexSandbox.__out;
|
|
225
|
+
return out ?? null;
|
|
226
|
+
}
|
|
227
|
+
patienceMs *= 2;
|
|
228
|
+
}
|
|
229
|
+
throw new RegexBudgetUndeterminedError(`pattern ${compiled.source} against a value of length ${String(value.length)} was ` +
|
|
230
|
+
`interrupted on all ${String(maxAttempts)} attempts after ${String(wallSpentMs)}ms of wall ` +
|
|
231
|
+
`clock, having consumed only ${cpuSpentMs.toFixed(1)}ms of CPU time against a budget of ` +
|
|
232
|
+
`${String(cpuBudgetMs)}ms; load average ${loadavg()[0]?.toFixed(2) ?? "unknown"} on ` +
|
|
233
|
+
`${String(cpus().length)} cpu(s). No verdict about the pattern was reached: this record ` +
|
|
234
|
+
"reports that the machine was too busy to establish one, not that the pattern is dangerous");
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* A STATIC heuristic for the single most common catastrophic-backtracking
|
|
238
|
+
* shape (fix round 1, CR-991): a parenthesised group containing an
|
|
239
|
+
* unbounded quantifier (`+` or `*`), itself immediately followed by
|
|
240
|
+
* another unbounded quantifier, e.g. `(a+)+`, `(a*)+`, `(a+)*`. This is
|
|
241
|
+
* exactly the shape the round's own red witness constructs
|
|
242
|
+
* (`(a+)+b`). It is a heuristic, not a proof: it does not see every
|
|
243
|
+
* catastrophic shape (a documented residue below), which is why every
|
|
244
|
+
* EXECUTION is also bounded by `boundedExec` regardless of whether a
|
|
245
|
+
* pattern passes this check.
|
|
246
|
+
*/
|
|
247
|
+
export function catastrophicShapeReason(pattern) {
|
|
248
|
+
if (/\([^()]*[+*][^()]*\)[+*]/.test(pattern)) {
|
|
249
|
+
return (`contains a group ending in a repeated + or * immediately followed ` +
|
|
250
|
+
`by another + or * (a known catastrophic-backtracking shape); ` +
|
|
251
|
+
`rewrite it to avoid nested unbounded repetition`);
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Validate one config-supplied pattern SOURCE before it is ever compiled
|
|
257
|
+
* into a live regex the gate will execute (fix round 1, CR-990/991):
|
|
258
|
+
* compiles cleanly, and does not match the static catastrophic-shape
|
|
259
|
+
* heuristic. `label` identifies which config field failed, so a config
|
|
260
|
+
* error names the field, not just "a pattern".
|
|
261
|
+
*/
|
|
262
|
+
export function validatePatternSource(label, pattern) {
|
|
263
|
+
try {
|
|
264
|
+
// eslint-disable-next-line no-new
|
|
265
|
+
new RegExp(pattern);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
return `${label} ${JSON.stringify(pattern)} is not a valid regular expression: ${error.message}`;
|
|
269
|
+
}
|
|
270
|
+
const shape = catastrophicShapeReason(pattern);
|
|
271
|
+
if (shape !== undefined) {
|
|
272
|
+
return `${label} ${JSON.stringify(pattern)} ${shape}`;
|
|
273
|
+
}
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Validate every config-supplied pattern this module will ever compile
|
|
278
|
+
* and execute: both `idPattern`s and every `bucketKinds[].pattern`. Run
|
|
279
|
+
* once per config, before the config is used, so a malformed or dangerous
|
|
280
|
+
* pattern is a named CONFIG error (a clean result record) rather than an
|
|
281
|
+
* uncaught exception with no record (CR-990) or an unbounded hang
|
|
282
|
+
* (CR-991).
|
|
283
|
+
*/
|
|
284
|
+
export function validateConfigPatterns(config) {
|
|
285
|
+
const checks = [
|
|
286
|
+
["inventory.idPattern", config.inventory.idPattern],
|
|
287
|
+
["coverageTable.idPattern", config.coverageTable.idPattern],
|
|
288
|
+
...config.bucketKinds.map((bucketKind, index) => [
|
|
289
|
+
`bucketKinds[${String(index)}] (kind ${bucketKind.kind}).pattern`,
|
|
290
|
+
bucketKind.pattern,
|
|
291
|
+
]),
|
|
292
|
+
];
|
|
293
|
+
for (const [label, pattern] of checks) {
|
|
294
|
+
const problem = validatePatternSource(label, pattern);
|
|
295
|
+
if (problem !== undefined) {
|
|
296
|
+
return problem;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Walk a document's lines and return every markdown-table row whose first
|
|
303
|
+
* cell matches `idPattern` (compiled anchored). Separator rows
|
|
304
|
+
* (`|---|:--:|---|`) are skipped by construction: every cell in one matches
|
|
305
|
+
* `/^:?-+:?$/`, which cannot also match a realistic id pattern, but the
|
|
306
|
+
* separator test is applied explicitly rather than relied on implicitly, so
|
|
307
|
+
* a future id pattern that could coincide does not silently absorb one.
|
|
308
|
+
* Every match against a config-supplied pattern is BOUNDED (fix round 1,
|
|
309
|
+
* `boundedExec`), so a hostile `idPattern` cannot hang this walk.
|
|
310
|
+
*/
|
|
311
|
+
export function extractIdRows(text, idPattern) {
|
|
312
|
+
const anchored = new RegExp(`^(?:${idPattern})$`);
|
|
313
|
+
const rows = [];
|
|
314
|
+
const lines = text.split(/\r?\n/);
|
|
315
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
316
|
+
const line = (lines[index] ?? "").trim();
|
|
317
|
+
if (!line.startsWith("|") || !line.endsWith("|") || line.length < 2) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const cells = line
|
|
321
|
+
.slice(1, -1)
|
|
322
|
+
.split("|")
|
|
323
|
+
.map((cell) => cell.trim());
|
|
324
|
+
if (cells.length === 0) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (cells.every((cell) => /^:?-+:?$/.test(cell))) {
|
|
328
|
+
continue; // header separator row
|
|
329
|
+
}
|
|
330
|
+
const first = cells[0] ?? "";
|
|
331
|
+
if (boundedExec(anchored, first) !== null) {
|
|
332
|
+
rows.push({ id: first, cells, line: index + 1 });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return rows;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The core check. Pure: takes the two documents' TEXT (already read through
|
|
339
|
+
* M2-C-6's guarded read at the call site) and the config, and returns a
|
|
340
|
+
* report with nothing hidden inside a status string.
|
|
341
|
+
*/
|
|
342
|
+
export function checkCoverage(config, inventoryText, coverageTableText) {
|
|
343
|
+
const inventoryRows = extractIdRows(inventoryText, config.inventory.idPattern);
|
|
344
|
+
const coverageRows = extractIdRows(coverageTableText, config.coverageTable.idPattern);
|
|
345
|
+
// CR-985: THE INVENTORY-SIDE CARDINALITY INVARIANT. `inventoryRows` can
|
|
346
|
+
// carry the same id more than once (the raw text says so); `units` is a
|
|
347
|
+
// count of DISTINCT ids, stated here rather than left implicit, and a
|
|
348
|
+
// repeated id is its own finding, never silently folded into "one more
|
|
349
|
+
// unit". `uniqueInventoryIds` preserves first-occurrence order so the
|
|
350
|
+
// classification loop below processes each distinct id exactly once,
|
|
351
|
+
// the same way the coverage side's `byId` grouping processes each
|
|
352
|
+
// distinct coverage-table id exactly once.
|
|
353
|
+
const inventoryIdOccurrences = new Map();
|
|
354
|
+
for (const row of inventoryRows) {
|
|
355
|
+
inventoryIdOccurrences.set(row.id, (inventoryIdOccurrences.get(row.id) ?? 0) + 1);
|
|
356
|
+
}
|
|
357
|
+
const uniqueInventoryIds = [...inventoryIdOccurrences.keys()];
|
|
358
|
+
const inventorySet = new Set(uniqueInventoryIds);
|
|
359
|
+
const byId = new Map();
|
|
360
|
+
for (const row of coverageRows) {
|
|
361
|
+
const existing = byId.get(row.id);
|
|
362
|
+
if (existing === undefined) {
|
|
363
|
+
byId.set(row.id, [row]);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
existing.push(row);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const findings = [];
|
|
370
|
+
const perKind = {};
|
|
371
|
+
const perMilestone = {};
|
|
372
|
+
for (const [id, occurrences] of inventoryIdOccurrences) {
|
|
373
|
+
if (occurrences > 1) {
|
|
374
|
+
findings.push({
|
|
375
|
+
kind: "duplicate-inventory-id",
|
|
376
|
+
id,
|
|
377
|
+
detail: `inventory id ${id} appears ${String(occurrences)} times in the inventory document`,
|
|
378
|
+
});
|
|
379
|
+
// The id is still checked against the coverage table below: a
|
|
380
|
+
// duplicated id can ALSO be an orphan, and both are worth naming.
|
|
381
|
+
}
|
|
382
|
+
const rows = byId.get(id) ?? [];
|
|
383
|
+
if (rows.length === 0) {
|
|
384
|
+
findings.push({
|
|
385
|
+
kind: "orphan",
|
|
386
|
+
id,
|
|
387
|
+
detail: `inventory id ${id} has no row in the coverage table`,
|
|
388
|
+
});
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (rows.length > 1) {
|
|
392
|
+
findings.push({
|
|
393
|
+
kind: "double-bucketed",
|
|
394
|
+
id,
|
|
395
|
+
detail: `inventory id ${id} appears in ${String(rows.length)} coverage-table rows (lines ${rows
|
|
396
|
+
.map((row) => String(row.line))
|
|
397
|
+
.join(", ")})`,
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const row = rows[0];
|
|
402
|
+
const bucketValue = row.cells[config.coverageTable.bucketColumn] ?? "";
|
|
403
|
+
const note = row.cells[config.coverageTable.noteColumn] ?? "";
|
|
404
|
+
// CR-992: EVERY declared kind is tested, never only until the first
|
|
405
|
+
// match, so a value matching more than one kind is a named finding
|
|
406
|
+
// rather than a silent first-match-wins (which used to let a second
|
|
407
|
+
// kind's `requiresNote` go uninspected with nothing said).
|
|
408
|
+
const matches = [];
|
|
409
|
+
for (const bucketKind of config.bucketKinds) {
|
|
410
|
+
const compiled = new RegExp(`^(?:${bucketKind.pattern})$`);
|
|
411
|
+
const match = boundedExec(compiled, bucketValue);
|
|
412
|
+
if (match !== null) {
|
|
413
|
+
matches.push({ kind: bucketKind, milestone: match[1] });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (matches.length === 0) {
|
|
417
|
+
findings.push({
|
|
418
|
+
kind: "unknown-kind",
|
|
419
|
+
id,
|
|
420
|
+
detail: `bucket value ${JSON.stringify(bucketValue)} for id ${id} (line ${String(row.line)}) matches no declared bucket kind`,
|
|
421
|
+
});
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (matches.length > 1) {
|
|
425
|
+
findings.push({
|
|
426
|
+
kind: "ambiguous-kind",
|
|
427
|
+
id,
|
|
428
|
+
detail: `bucket value ${JSON.stringify(bucketValue)} for id ${id} (line ${String(row.line)}) matches more than one declared bucket kind: ${matches
|
|
429
|
+
.map((entry) => entry.kind.kind)
|
|
430
|
+
.join(", ")}`,
|
|
431
|
+
});
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const classified = matches[0];
|
|
435
|
+
// CR-987: ONE shared emptiness predicate, not a bare `=== ""`, so a
|
|
436
|
+
// whitespace-only or zero-width-only note is empty here the same way
|
|
437
|
+
// it is empty in `checkFindingOutcomeParity` below.
|
|
438
|
+
if (classified.kind.requiresNote && isEmptyCell(note)) {
|
|
439
|
+
findings.push({
|
|
440
|
+
kind: "missing-note",
|
|
441
|
+
id,
|
|
442
|
+
detail: `${classified.kind.kind} row for id ${id} (line ${String(row.line)}) has an empty note`,
|
|
443
|
+
});
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const kindName = classified.kind.kind;
|
|
447
|
+
perKind[kindName] = (perKind[kindName] ?? 0) + 1;
|
|
448
|
+
const milestoneKey = classified.milestone === undefined ? kindName : `M${classified.milestone}`;
|
|
449
|
+
perMilestone[milestoneKey] = (perMilestone[milestoneKey] ?? 0) + 1;
|
|
450
|
+
}
|
|
451
|
+
for (const row of coverageRows) {
|
|
452
|
+
if (!inventorySet.has(row.id)) {
|
|
453
|
+
findings.push({
|
|
454
|
+
kind: "phantom",
|
|
455
|
+
id: row.id,
|
|
456
|
+
detail: `coverage-table row ${row.id} (line ${String(row.line)}) has no matching inventory id`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const totalInventoryIds = uniqueInventoryIds.length;
|
|
461
|
+
// CR-986: THE EXPECTED-UNITS FLOOR. A row deleted from BOTH documents
|
|
462
|
+
// leaves them agreeing with each other, so orphan, phantom and
|
|
463
|
+
// duplicate-inventory-id are all silent about it; only a config-stated
|
|
464
|
+
// expectation can catch a computed total that is smaller (or larger)
|
|
465
|
+
// than the number of ids this pair is supposed to carry.
|
|
466
|
+
if (config.expectedUnits !== undefined && totalInventoryIds !== config.expectedUnits) {
|
|
467
|
+
findings.push({
|
|
468
|
+
kind: "expected-units-mismatch",
|
|
469
|
+
id: "(total)",
|
|
470
|
+
detail: `expected ${String(config.expectedUnits)} distinct inventory id(s) but found ${String(totalInventoryIds)}`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
totalInventoryIds,
|
|
475
|
+
perKind,
|
|
476
|
+
perMilestone,
|
|
477
|
+
findings,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
export function checkFindingOutcomeParity(inventoryIds, findings) {
|
|
481
|
+
const inventorySet = new Set(inventoryIds);
|
|
482
|
+
const byId = new Map();
|
|
483
|
+
for (const finding of findings) {
|
|
484
|
+
const existing = byId.get(finding.id);
|
|
485
|
+
if (existing === undefined) {
|
|
486
|
+
byId.set(finding.id, [finding]);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
existing.push(finding);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const missing = [];
|
|
493
|
+
const duplicated = [];
|
|
494
|
+
const empty = [];
|
|
495
|
+
const phantom = [];
|
|
496
|
+
for (const id of inventoryIds) {
|
|
497
|
+
const rows = byId.get(id) ?? [];
|
|
498
|
+
if (rows.length === 0) {
|
|
499
|
+
missing.push(id);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (rows.length > 1) {
|
|
503
|
+
duplicated.push(id);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
// CR-987: the shared predicate, not a bare `=== ""` (a whitespace-only
|
|
507
|
+
// or zero-width-only outcome used to read as a non-empty say).
|
|
508
|
+
if (isEmptyCell(rows[0].outcome)) {
|
|
509
|
+
empty.push(id);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// CR-988: THE OTHER DIRECTION. `inventoryIds` alone cannot see a report
|
|
513
|
+
// row for an id that no longer exists; that requires scanning the
|
|
514
|
+
// REPORT'S OWN ids against the inventory, the same shape as
|
|
515
|
+
// `checkCoverage`'s phantom scan over coverage-table rows.
|
|
516
|
+
for (const id of byId.keys()) {
|
|
517
|
+
if (!inventorySet.has(id)) {
|
|
518
|
+
phantom.push(id);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
ok: missing.length === 0 &&
|
|
523
|
+
duplicated.length === 0 &&
|
|
524
|
+
empty.length === 0 &&
|
|
525
|
+
phantom.length === 0,
|
|
526
|
+
checked: inventoryIds.length,
|
|
527
|
+
missing,
|
|
528
|
+
duplicated,
|
|
529
|
+
empty,
|
|
530
|
+
phantom,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/* -------------------------------------------------------------------- */
|
|
534
|
+
/* The CLI entry: `node src/gates/coverage.ts --result <path> --evidence */
|
|
535
|
+
/* <dir> [--config <path>]`, the gate subprocess contract src/gates/run.ts */
|
|
536
|
+
/* documents. Registered in gates.manifest.json with no declared */
|
|
537
|
+
/* parameters, so the runner's invocation is exactly these two flags. */
|
|
538
|
+
/* -------------------------------------------------------------------- */
|
|
539
|
+
const schemaUrl = new URL("./schemas/coverage-config.schema.json", import.meta.url);
|
|
540
|
+
let cachedConfigSchema;
|
|
541
|
+
function configSchema() {
|
|
542
|
+
if (cachedConfigSchema !== undefined) {
|
|
543
|
+
return { ok: true, schema: cachedConfigSchema };
|
|
544
|
+
}
|
|
545
|
+
const path = fileURLToPath(schemaUrl);
|
|
546
|
+
const read = readRegularFileIfPresent(path);
|
|
547
|
+
if (read.kind !== "read") {
|
|
548
|
+
return {
|
|
549
|
+
ok: false,
|
|
550
|
+
loadError: read.kind === "absent"
|
|
551
|
+
? `schema document ${path} is missing from this installation`
|
|
552
|
+
: read.reason,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
let parsed;
|
|
556
|
+
try {
|
|
557
|
+
parsed = JSON.parse(read.body);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
return { ok: false, loadError: `${path} does not parse as JSON: ${error.message}` };
|
|
561
|
+
}
|
|
562
|
+
const loaded = loadSchema(parsed, path);
|
|
563
|
+
if (!loaded.ok) {
|
|
564
|
+
return { ok: false, loadError: loaded.reason };
|
|
565
|
+
}
|
|
566
|
+
cachedConfigSchema = loaded.schema;
|
|
567
|
+
return { ok: true, schema: cachedConfigSchema };
|
|
568
|
+
}
|
|
569
|
+
const VALUE_FLAGS = ["--result", "--evidence", "--config"];
|
|
570
|
+
function parseFlags(args) {
|
|
571
|
+
const flags = {};
|
|
572
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
573
|
+
const flag = args[index];
|
|
574
|
+
const value = args[index + 1];
|
|
575
|
+
if (flag === undefined || !VALUE_FLAGS.includes(flag)) {
|
|
576
|
+
return undefined;
|
|
577
|
+
}
|
|
578
|
+
if (value === undefined || value.startsWith("--")) {
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
if (flag === "--result") {
|
|
582
|
+
flags.result = value;
|
|
583
|
+
}
|
|
584
|
+
else if (flag === "--evidence") {
|
|
585
|
+
flags.evidence = value;
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
flags.config = value;
|
|
589
|
+
}
|
|
590
|
+
index += 1;
|
|
591
|
+
}
|
|
592
|
+
return flags;
|
|
593
|
+
}
|
|
594
|
+
/** Load and validate a `--config` document, or fall back to the kernel config. */
|
|
595
|
+
function resolveConfig(configPath) {
|
|
596
|
+
if (configPath === undefined) {
|
|
597
|
+
return { ok: true, config: KERNEL_COVERAGE_CONFIG };
|
|
598
|
+
}
|
|
599
|
+
const read = readRegularFileIfPresent(configPath);
|
|
600
|
+
if (read.kind !== "read") {
|
|
601
|
+
return {
|
|
602
|
+
ok: false,
|
|
603
|
+
reason: read.kind === "absent"
|
|
604
|
+
? `config ${configPath} does not exist`
|
|
605
|
+
: read.reason,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
let parsed;
|
|
609
|
+
try {
|
|
610
|
+
parsed = JSON.parse(read.body);
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
reason: `config ${configPath} does not parse as JSON: ${error.message}`,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
const schema = configSchema();
|
|
619
|
+
if (!schema.ok) {
|
|
620
|
+
return { ok: false, reason: schema.loadError };
|
|
621
|
+
}
|
|
622
|
+
const diagnostics = validate(schema.schema, parsed);
|
|
623
|
+
if (diagnostics.length > 0) {
|
|
624
|
+
return {
|
|
625
|
+
ok: false,
|
|
626
|
+
reason: `config ${configPath} is not a valid coverage config: ${diagnostics
|
|
627
|
+
.map((diagnostic) => `INVALID ${diagnostic.pointer} ${diagnostic.message}`)
|
|
628
|
+
.join("; ")}`,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
return { ok: true, config: parsed };
|
|
632
|
+
}
|
|
633
|
+
function readConfiguredDocument(label, path) {
|
|
634
|
+
const read = readRegularFileIfPresent(path);
|
|
635
|
+
if (read.kind === "read") {
|
|
636
|
+
return { ok: true, text: read.body };
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
reason: read.kind === "absent"
|
|
641
|
+
? `${label} ${path} does not exist`
|
|
642
|
+
: read.reason,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
function formatCounts(counts) {
|
|
646
|
+
const keys = Object.keys(counts).sort();
|
|
647
|
+
if (keys.length === 0) {
|
|
648
|
+
return "(none)";
|
|
649
|
+
}
|
|
650
|
+
return keys.map((key) => `${key} ${String(counts[key])}`).join(", ");
|
|
651
|
+
}
|
|
652
|
+
export function main(argv) {
|
|
653
|
+
const flags = parseFlags(argv);
|
|
654
|
+
const startedAt = new Date().toISOString();
|
|
655
|
+
if (flags === undefined || flags.result === undefined) {
|
|
656
|
+
process.stderr.write("usage: node src/gates/coverage.ts --result <file> --evidence <dir> [--config <file>]\n");
|
|
657
|
+
return 64;
|
|
658
|
+
}
|
|
659
|
+
const resolvedConfig = resolveConfig(flags.config);
|
|
660
|
+
if (!resolvedConfig.ok) {
|
|
661
|
+
return emit(flags.result, {
|
|
662
|
+
status: "error",
|
|
663
|
+
units: 0,
|
|
664
|
+
startedAt,
|
|
665
|
+
detail: resolvedConfig.reason,
|
|
666
|
+
evidence: [],
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
const config = resolvedConfig.config;
|
|
670
|
+
// CR-990/991, THE REGEX MECHANISM, VALIDATE HALF. Every config-supplied
|
|
671
|
+
// pattern is checked BEFORE it is compiled into a live regex anywhere
|
|
672
|
+
// else in this run: a malformed pattern used to throw out of
|
|
673
|
+
// `extractIdRows` and escape all the way to the top-level handler,
|
|
674
|
+
// which writes NO result record (CR-990); a syntactically valid but
|
|
675
|
+
// catastrophic pattern used to hang (CR-991). Both are now a named
|
|
676
|
+
// config error with a clean record, never an uncaught exception.
|
|
677
|
+
const patternProblem = validateConfigPatterns(config);
|
|
678
|
+
if (patternProblem !== undefined) {
|
|
679
|
+
return emit(flags.result, {
|
|
680
|
+
status: "error",
|
|
681
|
+
units: 0,
|
|
682
|
+
startedAt,
|
|
683
|
+
detail: `invalid config: ${patternProblem}`,
|
|
684
|
+
evidence: [],
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
const inventory = readConfiguredDocument("inventory", config.inventory.path);
|
|
688
|
+
if (!inventory.ok) {
|
|
689
|
+
return emit(flags.result, {
|
|
690
|
+
status: "error",
|
|
691
|
+
units: 0,
|
|
692
|
+
startedAt,
|
|
693
|
+
detail: inventory.reason,
|
|
694
|
+
evidence: [],
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
const coverageTable = readConfiguredDocument("coverage table", config.coverageTable.path);
|
|
698
|
+
if (!coverageTable.ok) {
|
|
699
|
+
return emit(flags.result, {
|
|
700
|
+
status: "error",
|
|
701
|
+
units: 0,
|
|
702
|
+
startedAt,
|
|
703
|
+
detail: coverageTable.reason,
|
|
704
|
+
evidence: [],
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
// CR-990/991, THE REGEX MECHANISM, BOUND HALF (defense in depth). The
|
|
708
|
+
// static validation above rejects the shapes `catastrophicShapeReason`
|
|
709
|
+
// recognises; this catches anything an exception escapes with anyway
|
|
710
|
+
// (a `RegexBoundExceededError` from `boundedExec`, or any other throw),
|
|
711
|
+
// converting it into a normal error record instead of letting it reach
|
|
712
|
+
// the top-level handler with no record written, which is the general
|
|
713
|
+
// form of CR-990's finding rather than only its regex instance.
|
|
714
|
+
let report;
|
|
715
|
+
try {
|
|
716
|
+
report = checkCoverage(config, inventory.text, coverageTable.text);
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
return emit(flags.result, {
|
|
720
|
+
status: "error",
|
|
721
|
+
units: 0,
|
|
722
|
+
startedAt,
|
|
723
|
+
detail: `coverage check failed: ${singleLine(error.message ?? String(error))}`,
|
|
724
|
+
evidence: [],
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
// M2-C-2: an empty inventory is reported GREEN with zero units so that
|
|
728
|
+
// the shared constructor's rewrite (never duplicated here) turns it into
|
|
729
|
+
// `error` with `vacuous: true`, rather than reporting whatever the
|
|
730
|
+
// (vacuous) findings pass happened to compute.
|
|
731
|
+
const status = report.totalInventoryIds === 0
|
|
732
|
+
? "green"
|
|
733
|
+
: report.findings.length > 0
|
|
734
|
+
? "red"
|
|
735
|
+
: "green";
|
|
736
|
+
// CR-989: THE EVIDENCE-SIDE REFUSAL IS NOW HONOURED, not computed and
|
|
737
|
+
// discarded. A FIFO or an absent parent directory at the evidence path
|
|
738
|
+
// used to leave `refusal` and `written.reason` unread and report GREEN
|
|
739
|
+
// with empty evidence; this is now the same M2-C-6 discipline the
|
|
740
|
+
// result path already had (loud, and the gate reports `error`), applied
|
|
741
|
+
// to the write side rather than only the read side.
|
|
742
|
+
const evidenceFiles = [];
|
|
743
|
+
if (flags.evidence !== undefined) {
|
|
744
|
+
const countsPath = join(flags.evidence, "counts.json");
|
|
745
|
+
const refusal = refuseOpenForWrite(countsPath);
|
|
746
|
+
if (refusal !== undefined) {
|
|
747
|
+
return emit(flags.result, {
|
|
748
|
+
status: "error",
|
|
749
|
+
units: report.totalInventoryIds,
|
|
750
|
+
startedAt,
|
|
751
|
+
detail: `evidence write refused: ${refusal}`,
|
|
752
|
+
evidence: [],
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
const written = runStep(`writing ${countsPath}`, () => writeFileSync(countsPath, `${JSON.stringify({
|
|
756
|
+
totalInventoryIds: report.totalInventoryIds,
|
|
757
|
+
perKind: report.perKind,
|
|
758
|
+
perMilestone: report.perMilestone,
|
|
759
|
+
findings: report.findings,
|
|
760
|
+
}, null, 2)}\n`));
|
|
761
|
+
if (!written.ok) {
|
|
762
|
+
return emit(flags.result, {
|
|
763
|
+
status: "error",
|
|
764
|
+
units: report.totalInventoryIds,
|
|
765
|
+
startedAt,
|
|
766
|
+
detail: `evidence write failed: ${written.reason}`,
|
|
767
|
+
evidence: [],
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
evidenceFiles.push("counts.json");
|
|
771
|
+
}
|
|
772
|
+
const detail = report.findings.length > 0
|
|
773
|
+
? `${String(report.findings.length)} finding(s): ${report.findings
|
|
774
|
+
.map((finding) => `${finding.kind} ${finding.id}`)
|
|
775
|
+
.join("; ")}`
|
|
776
|
+
: `${String(report.totalInventoryIds)} inventory id(s) checked; ` +
|
|
777
|
+
`per-kind: ${formatCounts(report.perKind)}; ` +
|
|
778
|
+
`per-milestone: ${formatCounts(report.perMilestone)}`;
|
|
779
|
+
return emit(flags.result, {
|
|
780
|
+
status,
|
|
781
|
+
units: report.totalInventoryIds,
|
|
782
|
+
startedAt,
|
|
783
|
+
detail,
|
|
784
|
+
evidence: evidenceFiles,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
function emit(resultPath, fields) {
|
|
788
|
+
const result = makeGateResult({
|
|
789
|
+
gate: "coverage",
|
|
790
|
+
status: fields.status,
|
|
791
|
+
units: fields.units,
|
|
792
|
+
unitLabel: "finding ids checked",
|
|
793
|
+
startedAt: fields.startedAt,
|
|
794
|
+
endedAt: new Date().toISOString(),
|
|
795
|
+
detail: fields.detail,
|
|
796
|
+
evidence: fields.evidence,
|
|
797
|
+
});
|
|
798
|
+
const refusal = refuseOpenForWrite(resultPath);
|
|
799
|
+
if (refusal !== undefined) {
|
|
800
|
+
process.stderr.write(`tiphys coverage: ${refusal}\n`);
|
|
801
|
+
return EXIT_GATE_ERROR;
|
|
802
|
+
}
|
|
803
|
+
const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
|
|
804
|
+
if (!written.ok) {
|
|
805
|
+
process.stderr.write(`tiphys coverage: ${written.reason}\n`);
|
|
806
|
+
return EXIT_GATE_ERROR;
|
|
807
|
+
}
|
|
808
|
+
process.stdout.write(`coverage: ${result.status} (${String(result.units)} ${result.unitLabel})\n`);
|
|
809
|
+
if (result.detail !== "") {
|
|
810
|
+
process.stdout.write(`${result.detail}\n`);
|
|
811
|
+
}
|
|
812
|
+
return exitCodeForStatus(result.status);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Run only when invoked directly (`node src/gates/coverage.ts ...`), never
|
|
816
|
+
* on import: `test/coverage-gate.test.ts` imports this module's pure
|
|
817
|
+
* functions through the computed-URL pattern and must not trigger a CLI
|
|
818
|
+
* run as a side effect of that import.
|
|
819
|
+
*/
|
|
820
|
+
// IDENTITY, NOT STRING EQUALITY (M4-P2 fix round, 2026-09-16). Measured:
|
|
821
|
+
// through a symlinked directory in the invocation path, or through a
|
|
822
|
+
// symlink to this file, argv[1] carries the caller's spelling while
|
|
823
|
+
// import.meta.url carries the canonical one, so the bare comparison is
|
|
824
|
+
// false and this gate silently does nothing and exits 0. That is a guard
|
|
825
|
+
// that cannot go red (T-008), and six sibling gates already compare by
|
|
826
|
+
// identity.
|
|
827
|
+
if (process.argv[1] !== undefined &&
|
|
828
|
+
pathsIdentifySameObject(fileURLToPath(import.meta.url), process.argv[1])) {
|
|
829
|
+
try {
|
|
830
|
+
process.exitCode = main(process.argv.slice(2));
|
|
831
|
+
}
|
|
832
|
+
catch (error) {
|
|
833
|
+
process.stderr.write(`tiphys coverage: ${singleLine(error.message ?? String(error))}\n`);
|
|
834
|
+
process.exitCode = EXIT_GATE_ERROR;
|
|
835
|
+
}
|
|
836
|
+
}
|