@tiphys/kernel 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/AGENTS.md +56 -4
  2. package/assurance-modes.yaml +23 -2
  3. package/dist/bin/tiphys.js +86 -8
  4. package/dist/src/adapters/load.d.ts +202 -0
  5. package/dist/src/adapters/load.js +440 -0
  6. package/dist/src/brief.js +27 -20
  7. package/dist/src/checks.d.ts +720 -9
  8. package/dist/src/checks.js +1874 -163
  9. package/dist/src/cli.js +11 -0
  10. package/dist/src/commands/brief.js +27 -4
  11. package/dist/src/commands/cutover.d.ts +35 -0
  12. package/dist/src/commands/cutover.js +448 -0
  13. package/dist/src/commands/doctor.d.ts +229 -0
  14. package/dist/src/commands/doctor.js +968 -27
  15. package/dist/src/commands/init.d.ts +3 -3
  16. package/dist/src/commands/init.js +57 -8
  17. package/dist/src/commands/lock.d.ts +33 -0
  18. package/dist/src/commands/lock.js +117 -6
  19. package/dist/src/commands/next.d.ts +130 -0
  20. package/dist/src/commands/next.js +597 -0
  21. package/dist/src/commands/pool.js +12 -1
  22. package/dist/src/commands/resume.d.ts +1 -0
  23. package/dist/src/commands/resume.js +88 -0
  24. package/dist/src/commands/spawn.js +51 -2
  25. package/dist/src/commands/status.d.ts +6 -4
  26. package/dist/src/commands/status.js +6 -4
  27. package/dist/src/commands/sync.d.ts +47 -0
  28. package/dist/src/commands/sync.js +341 -0
  29. package/dist/src/commands/teardown.js +10 -2
  30. package/dist/src/commands/validate.js +70 -0
  31. package/dist/src/cutover.d.ts +584 -0
  32. package/dist/src/cutover.js +1444 -0
  33. package/dist/src/exclusion.d.ts +389 -0
  34. package/dist/src/exclusion.js +843 -0
  35. package/dist/src/exec/env.d.ts +152 -2
  36. package/dist/src/exec/env.js +146 -2
  37. package/dist/src/fleet.d.ts +172 -0
  38. package/dist/src/fleet.js +219 -1
  39. package/dist/src/gates/citations.js +7 -1
  40. package/dist/src/gates/coverage.d.ts +113 -22
  41. package/dist/src/gates/coverage.js +166 -31
  42. package/dist/src/gates/credentials.d.ts +159 -0
  43. package/dist/src/gates/credentials.js +221 -2
  44. package/dist/src/gates/gate-classes.d.ts +56 -0
  45. package/dist/src/gates/gate-classes.js +633 -0
  46. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  47. package/dist/src/gates/merge-preconditions.js +932 -0
  48. package/dist/src/gates/red-witness.js +105 -13
  49. package/dist/src/gates/run.d.ts +49 -1
  50. package/dist/src/gates/run.js +83 -5
  51. package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
  52. package/dist/src/gates/suite.js +48 -7
  53. package/dist/src/hooks.d.ts +55 -3
  54. package/dist/src/hooks.js +69 -6
  55. package/dist/src/index.d.ts +31 -0
  56. package/dist/src/index.js +30 -0
  57. package/dist/src/lock.d.ts +82 -4
  58. package/dist/src/lock.js +314 -22
  59. package/dist/src/model-resolution.d.ts +159 -0
  60. package/dist/src/model-resolution.js +307 -0
  61. package/dist/src/path-identity.d.ts +32 -0
  62. package/dist/src/path-identity.js +38 -0
  63. package/dist/src/pool.d.ts +197 -1
  64. package/dist/src/pool.js +289 -22
  65. package/dist/src/roles.d.ts +31 -0
  66. package/dist/src/roles.js +42 -0
  67. package/dist/src/spawn.d.ts +307 -2
  68. package/dist/src/spawn.js +690 -19
  69. package/dist/src/status.d.ts +27 -2
  70. package/dist/src/status.js +34 -5
  71. package/dist/src/task.d.ts +295 -55
  72. package/dist/src/task.js +125 -123
  73. package/dist/src/teardown.d.ts +7 -0
  74. package/dist/src/teardown.js +120 -12
  75. package/dist/src/validate.d.ts +44 -11
  76. package/dist/src/validate.js +44 -34
  77. package/dist/src/watcher.js +1 -11
  78. package/dist/src/witness/run.d.ts +32 -7
  79. package/dist/src/witness/run.js +76 -30
  80. package/dist/src/witness/spec.d.ts +168 -0
  81. package/dist/src/witness/spec.js +240 -18
  82. package/dist/tsconfig.src.tsbuildinfo +1 -1
  83. package/gate-registry.yaml +136 -0
  84. package/gates.manifest.json +63 -1
  85. package/package.json +18 -3
  86. package/roles/implementer.md +3 -0
  87. package/schemas/README.md +1 -0
  88. package/schemas/assurance-modes.schema.json +1 -1
  89. package/schemas/charter.schema.json +19 -0
  90. package/schemas/cutover-state.schema.json +64 -0
  91. package/schemas/executor-record.schema.json +36 -0
  92. package/schemas/model-resolution.schema.json +362 -0
  93. package/schemas/verdict.schema.json +9 -3
  94. package/schemas/write-bypass.schema.json +69 -0
package/dist/src/fleet.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, statSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  /**
4
4
  * Fleet-home layout (kernel plan v1, M1-P2 step 1; blueprint section 3 with
@@ -78,3 +78,221 @@ export function loadFleet(dir) {
78
78
  beaconPath: join(root, BEACON_FILE),
79
79
  };
80
80
  }
81
+ /**
82
+ * Classify a layout path without opening it. lstat first, so a symlink is
83
+ * seen as a symlink; then stat, so a symlink to a directory is a directory
84
+ * and a dangling one is reported as such rather than as absent.
85
+ */
86
+ export function classifyLayoutEntry(path) {
87
+ try {
88
+ lstatSync(path);
89
+ }
90
+ catch (error) {
91
+ if (error.code === "ENOENT") {
92
+ return { kind: "absent" };
93
+ }
94
+ return {
95
+ kind: "unexaminable",
96
+ reason: `${path} could not be examined: ${String(error)}`,
97
+ };
98
+ }
99
+ let stats;
100
+ try {
101
+ stats = statSync(path);
102
+ }
103
+ catch (error) {
104
+ if (error.code === "ENOENT") {
105
+ return { kind: "other", reason: `${path} is a symlink to nothing` };
106
+ }
107
+ return {
108
+ kind: "unexaminable",
109
+ reason: `${path} could not be examined: ${String(error)}`,
110
+ };
111
+ }
112
+ if (stats.isDirectory()) {
113
+ return { kind: "directory" };
114
+ }
115
+ return { kind: "other", reason: `${path} exists and is not a directory` };
116
+ }
117
+ /**
118
+ * The EPHEMERAL directories: exactly the gitignored set, with the trailing
119
+ * slash that `.gitignore` needs stripped off. DERIVED from FLEET_IGNORED
120
+ * rather than listed again, because a second list is a second thing to keep
121
+ * in step and the first divergence would be silent: `tiphys resume` would
122
+ * rebuild one set while `.gitignore` ignored another.
123
+ */
124
+ export const EPHEMERAL_DIRS = FLEET_IGNORED.map((entry) => entry.endsWith("/") ? entry.slice(0, -1) : entry);
125
+ /**
126
+ * The DURABLE directories: every fleet directory that is not ephemeral.
127
+ * A clone of a fleet home carries these and not the ephemeral ones, which
128
+ * is the fact `tiphys resume` exists to act on.
129
+ */
130
+ export const DURABLE_DIRS = FLEET_DIRS.filter((name) => !EPHEMERAL_DIRS.includes(name));
131
+ /**
132
+ * The durable layout entries missing from dir, in declaration order:
133
+ * directories first with a trailing slash, then the root files. An empty
134
+ * result means the directory carries everything a clone of a fleet home
135
+ * carries, which is the precondition `tiphys resume` requires and never
136
+ * fabricates.
137
+ */
138
+ export function missingDurableEntries(dir) {
139
+ const missing = [];
140
+ for (const name of DURABLE_DIRS) {
141
+ if (classifyLayoutEntry(join(dir, name)).kind !== "directory") {
142
+ missing.push(`${name}/`);
143
+ }
144
+ }
145
+ for (const name of FLEET_FILES) {
146
+ const p = join(dir, name);
147
+ if (!existsSync(p) || !statSync(p).isFile()) {
148
+ missing.push(name);
149
+ }
150
+ }
151
+ return missing;
152
+ }
153
+ function describePathType(stats) {
154
+ if (stats.isDirectory()) {
155
+ return "a directory";
156
+ }
157
+ if (stats.isFIFO()) {
158
+ return "a named pipe";
159
+ }
160
+ if (stats.isSocket()) {
161
+ return "a socket";
162
+ }
163
+ if (stats.isCharacterDevice()) {
164
+ return "a character device";
165
+ }
166
+ if (stats.isBlockDevice()) {
167
+ return "a block device";
168
+ }
169
+ return "an entry of an unrecognized type";
170
+ }
171
+ /**
172
+ * lstat first, so a symlink is seen as a symlink; then stat, so a symlink to
173
+ * a regular file is regular and a dangling one is reported as such rather
174
+ * than as absent. The path is never opened.
175
+ */
176
+ export function classifyPathEntry(path) {
177
+ try {
178
+ lstatSync(path);
179
+ }
180
+ catch (error) {
181
+ if (error.code === "ENOENT") {
182
+ return { kind: "absent" };
183
+ }
184
+ return {
185
+ kind: "unexaminable",
186
+ reason: `${path} could not be examined: ${String(error)}`,
187
+ };
188
+ }
189
+ let stats;
190
+ try {
191
+ stats = statSync(path);
192
+ }
193
+ catch (error) {
194
+ if (error.code === "ENOENT") {
195
+ return { kind: "dangling" };
196
+ }
197
+ return {
198
+ kind: "unexaminable",
199
+ reason: `${path} could not be examined: ${String(error)}`,
200
+ };
201
+ }
202
+ if (stats.isFile()) {
203
+ return { kind: "regular" };
204
+ }
205
+ return {
206
+ kind: "irregular",
207
+ reason: `${path} is ${describePathType(stats)}, not a regular file, so it was not opened`,
208
+ };
209
+ }
210
+ /** THE ONE READ of a path that might not be there and might not be a file. */
211
+ export function readRegularPathIfPresent(path) {
212
+ const entry = classifyPathEntry(path);
213
+ if (entry.kind === "absent" || entry.kind === "dangling") {
214
+ return { kind: "absent" };
215
+ }
216
+ if (entry.kind === "irregular" || entry.kind === "unexaminable") {
217
+ return { kind: "refused", reason: entry.reason };
218
+ }
219
+ let body;
220
+ try {
221
+ body = readFileSync(path, "utf8");
222
+ }
223
+ catch (error) {
224
+ if (error.code === "ENOENT") {
225
+ // Removed between the probe and the read.
226
+ return { kind: "absent" };
227
+ }
228
+ return {
229
+ kind: "refused",
230
+ reason: `${path} could not be read: ${String(error)}`,
231
+ };
232
+ }
233
+ return { kind: "read", body };
234
+ }
235
+ /**
236
+ * Refuse an open-for-WRITE of a path that is not a regular file. The hazard
237
+ * is symmetric: open(2) for writing on a FIFO with no reader blocks exactly
238
+ * as reading one with no writer does, so a staged write is as dangerous as a
239
+ * read. Returns the reason, or undefined when the path may be opened (absent
240
+ * included: creating it is the point).
241
+ */
242
+ export function refuseOpenPathForWrite(path) {
243
+ const entry = classifyPathEntry(path);
244
+ if (entry.kind === "irregular" || entry.kind === "unexaminable") {
245
+ return entry.reason;
246
+ }
247
+ return undefined;
248
+ }
249
+ /* ------------------------------------------------------------------ */
250
+ /* The fleet home's SYNC CLASSES */
251
+ /* ------------------------------------------------------------------ */
252
+ /**
253
+ * THE KERNEL'S OWN WRITE-THEN-RENAME SCRATCH SUFFIXES.
254
+ *
255
+ * `FLEET_IGNORED` above is a DENYLIST of three directory prefixes, and
256
+ * `tiphys sync` derives "durable" as "not covered by it". That derivation is
257
+ * right for everything the ignore rules were written to cover and blind to a
258
+ * class they were never asked about: a transient artifact the KERNEL ITSELF
259
+ * creates beside a TRACKED target, which git therefore reports as an
260
+ * ordinary new durable path.
261
+ *
262
+ * The enumeration behind this list is every path the kernel writes under a
263
+ * fleet home, classified against `FLEET_IGNORED`. The three that land
264
+ * DURABLE without being fleet content are:
265
+ *
266
+ * status/current.json.tmp src/status.ts:142, a fixed name inside the
267
+ * tracked status/ directory.
268
+ * .cutover.<random>.tmp src/cutover.ts:278, a dot-prefixed random
269
+ * name at the fleet ROOT.
270
+ * tiphys-environment.json src/exclusion.ts:303, durable ON PURPOSE
271
+ * (M4-P21 criterion 3) and therefore NOT in
272
+ * this list.
273
+ *
274
+ * Two members, two directories, two naming shapes, which is why the rule is
275
+ * a SUFFIX and not a filename: a rule naming `current.json.tmp` would close
276
+ * the first and leave the second open.
277
+ *
278
+ * `FLEET_IGNORED` IS DELIBERATELY UNCHANGED. It drives `EPHEMERAL_DIRS`,
279
+ * `DURABLE_DIRS` and the `.gitignore` that `tiphys init` writes, so a glob
280
+ * added there would become a directory name `tiphys resume` tried to rebuild.
281
+ * Nothing stops being synced because of this constant; `tiphys sync` gains a
282
+ * refusal, and only for paths matching a suffix below.
283
+ */
284
+ export const FLEET_SCRATCH_SUFFIXES = [
285
+ ".tmp",
286
+ ".stage",
287
+ ".mutex",
288
+ ];
289
+ /**
290
+ * True when a fleet-relative path is a kernel scratch artifact by its name
291
+ * alone. Name-only on purpose: `tiphys sync` asks this about a path git
292
+ * REPORTED, which may already have been renamed away by the time the
293
+ * question is asked, so a stat here would answer about a different world
294
+ * than the one being committed.
295
+ */
296
+ export function isFleetScratchPath(relativePath) {
297
+ return FLEET_SCRATCH_SUFFIXES.find((suffix) => relativePath.endsWith(suffix));
298
+ }
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import { lstatSync, readdirSync, writeFileSync } from "node:fs";
4
4
  import { join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { pathsIdentifySameObject } from "../path-identity.js";
6
7
  import { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
7
8
  import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
8
9
  import { formatDiagnostics, loadSchema, validate } from "./validate.js";
@@ -1156,8 +1157,13 @@ export function main(argv) {
1156
1157
  return EXIT_GATE_ERROR;
1157
1158
  }
1158
1159
  }
1160
+ // IDENTITY, NOT STRING EQUALITY (M4-P2 fix round, 2026-09-16). `resolve`
1161
+ // normalizes relative segments and does NOT resolve symlinks, so an
1162
+ // invocation reached through a symlinked directory or through a symlink to
1163
+ // this file leaves the two sides as different spellings of one file and
1164
+ // this gate silently does nothing. Measured; see src/path-identity.ts:13.
1159
1165
  const invokedDirectly = process.argv[1] !== undefined &&
1160
- resolve(process.argv[1]) === fileURLToPath(import.meta.url);
1166
+ pathsIdentifySameObject(fileURLToPath(import.meta.url), process.argv[1]);
1161
1167
  if (invokedDirectly) {
1162
1168
  process.exitCode = main(process.argv.slice(2));
1163
1169
  }
@@ -96,10 +96,13 @@
96
96
  * config-supplied pattern (both `idPattern`s and every `bucketKinds[].pattern`)
97
97
  * is VALIDATED (compiles, and is rejected if it matches a known
98
98
  * catastrophic-backtracking shape) before it is ever executed, and every
99
- * EXECUTION of a config-supplied pattern is BOUNDED by a wall-clock timeout
100
- * (`boundedExec`, below), so neither a malformed pattern (CR-990, used to
99
+ * EXECUTION of a config-supplied pattern is BOUNDED (`boundedExec`, below),
100
+ * so neither a malformed pattern (CR-990, used to
101
101
  * throw with no result record written) nor a ReDoS pattern (CR-991, used
102
- * to hang indefinitely) can defeat this gate; CR-992 (overlapping kinds)
102
+ * to hang indefinitely) can defeat this gate. M4-P28 changed WHAT that
103
+ * bound measures, from elapsed wall clock to CPU work, because the first
104
+ * one reddened for well-behaved patterns whenever the machine was busy.
105
+ * CR-992 (overlapping kinds)
103
106
  * is folded into the same fix because it is the same "a config string is
104
107
  * trusted further than its syntax justifies" mechanism one property over.
105
108
  */
@@ -162,29 +165,117 @@ export declare const KERNEL_COVERAGE_CONFIG: CoverageConfig;
162
165
  * closes both instances at once.
163
166
  */
164
167
  export declare function isEmptyCell(value: string): boolean;
165
- /** Wall-clock bound on one regex execution. Measured (this round): a real
166
- * catastrophic pattern, `(a+)+b` against 30 a's, which never returns on
167
- * its own, is interrupted within 251-267ms under this mechanism; a safe
168
- * pattern executes in under a millisecond, so the bound is not on the
169
- * critical path for any pattern this repository's own config uses. */
170
- export declare const REGEX_EXEC_TIMEOUT_MS = 250;
168
+ /**
169
+ * THE VERDICT-PRODUCING BOUND IS CPU WORK, NOT ELAPSED TIME (M4-P28).
170
+ *
171
+ * What this constant replaces, and why the replacement is not a bigger
172
+ * number. Until this phase the bound was `REGEX_EXEC_TIMEOUT_MS = 250`,
173
+ * handed to `vm` as a WALL-CLOCK timeout, and exceeding it was reported as
174
+ * "did not complete within 250ms ... (possible catastrophic
175
+ * backtracking)". Elapsed wall time is complexity DIVIDED BY available
176
+ * CPU, so that condition did not test the property it named: it reddened
177
+ * whenever the machine was busy. Measured and recorded in
178
+ * delivery/verification/wall-clock-budgets-are-load-dependent.md:1, five
179
+ * independent witnesses hit it on six structurally different patterns,
180
+ * including `^(?:parked)$`, a doubly anchored literal with no quantifier,
181
+ * no character class and no alternation, which has nothing to backtrack
182
+ * over at any input length. One million executions of
183
+ * `^(?:R-[0-9]+[a-z]?)$` against `R-094a` take 154.2ms in total, so ONE is
184
+ * roughly 1.6 million times under the old budget; for one of them to
185
+ * exceed it the thread must be descheduled for a quarter of a second,
186
+ * which is a fact about the machine.
187
+ *
188
+ * Raising 250 to a larger number was considered and is REJECTED by the
189
+ * plan (delivery/plan/kernel-plan-m4.md:3784): it keeps the same
190
+ * instrument and only moves the load at which it lies.
191
+ *
192
+ * WHAT IS MEASURED NOW. `process.threadCpuUsage()` reports the CPU time
193
+ * this thread has consumed, in microseconds. Another process being busy
194
+ * does not add to it, because a descheduled thread consumes no CPU while
195
+ * it is not running. The regex runs on THIS thread inside
196
+ * `runInContext`, so the delta across that call is the work the regex
197
+ * did, and nothing else. On an interpreter without
198
+ * `process.threadCpuUsage` (added in Node 22.15; the package floor is 26)
199
+ * the fallback is `process.cpuUsage()`, which is process-wide and
200
+ * therefore an over-estimate, never an under-estimate, so the fallback
201
+ * cannot let a catastrophic pattern through.
202
+ */
203
+ export declare const REGEX_EXEC_CPU_BUDGET_MS = 250;
204
+ /**
205
+ * HOW LONG ONE ATTEMPT IS WILLING TO WAIT. This is PATIENCE, not a
206
+ * verdict: nothing is ever reported about a pattern because this elapsed.
207
+ * A wall-clock interrupt is still needed, because a catastrophic match
208
+ * never returns on its own and `vm`'s `timeout` is the only mechanism
209
+ * here that can stop one (v8 checks for the termination request during a
210
+ * regex match, not only between statements).
211
+ *
212
+ * It is set to TWICE the CPU budget so that a thread receiving at least
213
+ * half of one CPU reaches the budget inside a single attempt; measured on
214
+ * an unloaded box, `(a+)+b` against 30 a's consumes 230.6ms of CPU in a
215
+ * 252ms wall window, so a backstop equal to the budget would have needed
216
+ * a second attempt to reach a verdict it had nearly earned.
217
+ */
218
+ export declare const REGEX_EXEC_WALL_BACKSTOP_MS = 500;
219
+ /**
220
+ * How many interrupted attempts before this module admits it cannot
221
+ * reach a verdict. Each attempt doubles its own patience, and the CPU
222
+ * consumed ACCUMULATES across attempts, so a genuinely spinning pattern
223
+ * is still caught on a loaded machine (it burns CPU whenever it runs)
224
+ * while a benign one accumulates microseconds however often it is
225
+ * interrupted.
226
+ */
227
+ export declare const REGEX_EXEC_MAX_ATTEMPTS = 4;
228
+ /** The pattern did too much WORK. A verdict about the pattern. */
171
229
  export declare class RegexBoundExceededError extends Error {
172
230
  }
173
231
  /**
174
- * Execute `compiled.exec(value)` inside a v8 context with a wall-clock
175
- * timeout. `node:vm`'s `timeout` option interrupts synchronous JavaScript
176
- * execution, INCLUDING regex backtracking (v8 checks for the termination
177
- * request during a regex match, not only between statements), which is
178
- * why this bound can stop a hung `.exec()` where a plain try/catch around
179
- * a synchronous call cannot: a catastrophic match never throws on its
180
- * own, it simply never returns. Measured directly (this round): the same
181
- * `(a+)+b` pattern against inputs of length 18 through 40 completes in
182
- * under 40ms up to length 22, then 63ms, 302ms, and is interrupted at the
183
- * 250ms bound from length 26 onward, rather than running to the multi-
184
- * second and then multi-minute times the unbounded engine produces at
185
- * length 26 and 40.
232
+ * No verdict was reached: every attempt was interrupted before the
233
+ * pattern finished, and the CPU it consumed never came near the budget,
234
+ * which is the signature of a thread that is not being scheduled rather
235
+ * than one that is spinning. Under M2-C-3 a check that cannot reach a
236
+ * verdict reports ERROR, never a verdict it did not earn, so this is
237
+ * deliberately NOT a subclass of `RegexBoundExceededError`: the two must
238
+ * not be confusable by a `catch` or an `instanceof`.
239
+ */
240
+ export declare class RegexBudgetUndeterminedError extends Error {
241
+ }
242
+ /** Overrides for one call, used by the witness tests to stand in for a
243
+ * machine slower or busier than this one. Only the PATIENCE is varied
244
+ * there; the verdict instrument stays the CPU budget. */
245
+ export interface RegexExecBounds {
246
+ cpuBudgetMs?: number;
247
+ wallBackstopMs?: number;
248
+ maxAttempts?: number;
249
+ }
250
+ /**
251
+ * Execute `compiled.exec(value)` inside a v8 context, bounded by the CPU
252
+ * WORK it does rather than by the time it takes.
253
+ *
254
+ * One attempt runs the match with a wall-clock interrupt. Whatever
255
+ * happens, the CPU consumed by this thread during that call is added to a
256
+ * running total:
257
+ *
258
+ * - total CPU at or above the budget: the thread really did burn that
259
+ * much CPU on this one match, which no anchored non-backtracking
260
+ * pattern can do, so `RegexBoundExceededError` is thrown. A busy
261
+ * machine cannot cause this, because a descheduled thread accumulates
262
+ * no CPU.
263
+ * - the match finished: return its result, which is the only path that
264
+ * produces a match.
265
+ * - interrupted with the total still far below the budget: the machine
266
+ * was busy, not the pattern. Double the patience and try again.
267
+ *
268
+ * After `maxAttempts` interruptions with the CPU total still under
269
+ * budget, `RegexBudgetUndeterminedError` is thrown, naming the CPU
270
+ * consumed, the wall clock spent and the load average, so the record says
271
+ * what it observed instead of asserting something about the pattern.
272
+ *
273
+ * An error from `runInContext` that is NOT the timeout is also
274
+ * undetermined rather than a finding: the old code funnelled every throw
275
+ * into the catastrophic-backtracking message, which is the same
276
+ * substitution one cause over.
186
277
  */
187
- export declare function boundedExec(compiled: RegExp, value: string): RegExpExecArray | null;
278
+ export declare function boundedExec(compiled: RegExp, value: string, bounds?: RegexExecBounds): RegExpExecArray | null;
188
279
  /**
189
280
  * A STATIC heuristic for the single most common catastrophic-backtracking
190
281
  * shape (fix round 1, CR-991): a parenthesised group containing an
@@ -1,7 +1,9 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { pathsIdentifySameObject } from "../path-identity.js";
4
5
  import { Script, createContext } from "node:vm";
6
+ import { cpus, loadavg } from "node:os";
5
7
  import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
6
8
  import { loadSchema, validate } from "./validate.js";
7
9
  import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
@@ -70,41 +72,166 @@ export function isEmptyCell(value) {
70
72
  * meaningfully slower in this round's own benchmark than reusing one.
71
73
  */
72
74
  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;
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. */
79
136
  export class RegexBoundExceededError extends Error {
80
137
  }
81
138
  /**
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.
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`.
94
146
  */
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)`);
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);
105
154
  }
106
- const out = regexSandbox.__out;
107
- return out ?? null;
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");
108
235
  }
109
236
  /**
110
237
  * A STATIC heuristic for the single most common catastrophic-backtracking
@@ -690,7 +817,15 @@ function emit(resultPath, fields) {
690
817
  * functions through the computed-URL pattern and must not trigger a CLI
691
818
  * run as a side effect of that import.
692
819
  */
693
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
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])) {
694
829
  try {
695
830
  process.exitCode = main(process.argv.slice(2));
696
831
  }