repo-contract 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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +967 -0
- package/dist/.dts/config/define-repo-contract.d.ts +36 -0
- package/dist/.dts/config/define-repo-contract.d.ts.map +1 -0
- package/dist/.dts/config/tokenize-command.d.ts +33 -0
- package/dist/.dts/config/tokenize-command.d.ts.map +1 -0
- package/dist/.dts/config/validate-config.d.ts +32 -0
- package/dist/.dts/config/validate-config.d.ts.map +1 -0
- package/dist/.dts/errors.d.ts +155 -0
- package/dist/.dts/errors.d.ts.map +1 -0
- package/dist/.dts/evidence/build-evidence.d.ts +26 -0
- package/dist/.dts/evidence/build-evidence.d.ts.map +1 -0
- package/dist/.dts/execution/abort-signals.d.ts +29 -0
- package/dist/.dts/execution/abort-signals.d.ts.map +1 -0
- package/dist/.dts/execution/concurrency-pool.d.ts +14 -0
- package/dist/.dts/execution/concurrency-pool.d.ts.map +1 -0
- package/dist/.dts/execution/dependency-scheduler.d.ts +30 -0
- package/dist/.dts/execution/dependency-scheduler.d.ts.map +1 -0
- package/dist/.dts/execution/process-tree.d.ts +48 -0
- package/dist/.dts/execution/process-tree.d.ts.map +1 -0
- package/dist/.dts/execution/run-checks.d.ts +29 -0
- package/dist/.dts/execution/run-checks.d.ts.map +1 -0
- package/dist/.dts/execution/spawn-check.d.ts +30 -0
- package/dist/.dts/execution/spawn-check.d.ts.map +1 -0
- package/dist/.dts/index.d.ts +13 -0
- package/dist/.dts/index.d.ts.map +1 -0
- package/dist/.dts/parsing/parse-json.d.ts +8 -0
- package/dist/.dts/parsing/parse-json.d.ts.map +1 -0
- package/dist/.dts/parsing/parse-output.d.ts +10 -0
- package/dist/.dts/parsing/parse-output.d.ts.map +1 -0
- package/dist/.dts/parsing/parse-text.d.ts +8 -0
- package/dist/.dts/parsing/parse-text.d.ts.map +1 -0
- package/dist/.dts/parsing/parse-yaml.d.ts +21 -0
- package/dist/.dts/parsing/parse-yaml.d.ts.map +1 -0
- package/dist/.dts/policy/run-policies.d.ts +38 -0
- package/dist/.dts/policy/run-policies.d.ts.map +1 -0
- package/dist/.dts/presets/arethetypeswrong.d.ts +39 -0
- package/dist/.dts/presets/arethetypeswrong.d.ts.map +1 -0
- package/dist/.dts/presets/broken-links.d.ts +16 -0
- package/dist/.dts/presets/broken-links.d.ts.map +1 -0
- package/dist/.dts/presets/commitlint.d.ts +22 -0
- package/dist/.dts/presets/commitlint.d.ts.map +1 -0
- package/dist/.dts/presets/dead-code.d.ts +23 -0
- package/dist/.dts/presets/dead-code.d.ts.map +1 -0
- package/dist/.dts/presets/duplication.d.ts +14 -0
- package/dist/.dts/presets/duplication.d.ts.map +1 -0
- package/dist/.dts/presets/e2e.d.ts +4 -0
- package/dist/.dts/presets/e2e.d.ts.map +1 -0
- package/dist/.dts/presets/format.d.ts +4 -0
- package/dist/.dts/presets/format.d.ts.map +1 -0
- package/dist/.dts/presets/index.d.ts +31 -0
- package/dist/.dts/presets/index.d.ts.map +1 -0
- package/dist/.dts/presets/license.d.ts +4 -0
- package/dist/.dts/presets/license.d.ts.map +1 -0
- package/dist/.dts/presets/lint.d.ts +20 -0
- package/dist/.dts/presets/lint.d.ts.map +1 -0
- package/dist/.dts/presets/markdownlint.d.ts +23 -0
- package/dist/.dts/presets/markdownlint.d.ts.map +1 -0
- package/dist/.dts/presets/publint.d.ts +13 -0
- package/dist/.dts/presets/publint.d.ts.map +1 -0
- package/dist/.dts/presets/security-deps.d.ts +4 -0
- package/dist/.dts/presets/security-deps.d.ts.map +1 -0
- package/dist/.dts/presets/security-secrets.d.ts +4 -0
- package/dist/.dts/presets/security-secrets.d.ts.map +1 -0
- package/dist/.dts/presets/shared/error-warning-pass-policy.d.ts +13 -0
- package/dist/.dts/presets/shared/error-warning-pass-policy.d.ts.map +1 -0
- package/dist/.dts/presets/shared/exit-code-fail-rationale.d.ts +23 -0
- package/dist/.dts/presets/shared/exit-code-fail-rationale.d.ts.map +1 -0
- package/dist/.dts/presets/shared/missing-dependency.d.ts +19 -0
- package/dist/.dts/presets/shared/missing-dependency.d.ts.map +1 -0
- package/dist/.dts/presets/shared/read-json-report.d.ts +33 -0
- package/dist/.dts/presets/shared/read-json-report.d.ts.map +1 -0
- package/dist/.dts/presets/shared/terminal-status.d.ts +25 -0
- package/dist/.dts/presets/shared/terminal-status.d.ts.map +1 -0
- package/dist/.dts/presets/shared/vitest-json-policy.d.ts +20 -0
- package/dist/.dts/presets/shared/vitest-json-policy.d.ts.map +1 -0
- package/dist/.dts/presets/stylelint.d.ts +17 -0
- package/dist/.dts/presets/stylelint.d.ts.map +1 -0
- package/dist/.dts/presets/test.d.ts +4 -0
- package/dist/.dts/presets/test.d.ts.map +1 -0
- package/dist/.dts/presets/typecheck.d.ts +4 -0
- package/dist/.dts/presets/typecheck.d.ts.map +1 -0
- package/dist/.dts/run-repo-contract.d.ts +38 -0
- package/dist/.dts/run-repo-contract.d.ts.map +1 -0
- package/dist/.dts/types.d.ts +324 -0
- package/dist/.dts/types.d.ts.map +1 -0
- package/dist/index.cjs +46 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/presets.cjs +30 -0
- package/dist/presets.cjs.map +1 -0
- package/dist/presets.d.cts +1 -0
- package/dist/presets.d.ts +1 -0
- package/dist/presets.js +13 -0
- package/dist/presets.js.map +1 -0
- package/package.json +192 -0
- package/presets/package.json +5 -0
- package/schemas/evidence.schema.json +253 -0
- package/schemas/verdict.schema.json +66 -0
- package/src/config/define-repo-contract.ts +38 -0
- package/src/config/tokenize-command.ts +214 -0
- package/src/config/validate-config.ts +368 -0
- package/src/errors.ts +229 -0
- package/src/evidence/build-evidence.ts +91 -0
- package/src/execution/abort-signals.ts +56 -0
- package/src/execution/concurrency-pool.ts +64 -0
- package/src/execution/dependency-scheduler.ts +216 -0
- package/src/execution/process-tree.ts +107 -0
- package/src/execution/run-checks.ts +348 -0
- package/src/execution/spawn-check.ts +494 -0
- package/src/index.ts +44 -0
- package/src/parsing/parse-json.ts +18 -0
- package/src/parsing/parse-output.ts +26 -0
- package/src/parsing/parse-text.ts +10 -0
- package/src/parsing/parse-yaml.ts +40 -0
- package/src/policy/run-policies.ts +261 -0
- package/src/presets/arethetypeswrong.ts +116 -0
- package/src/presets/broken-links.ts +95 -0
- package/src/presets/commitlint.ts +77 -0
- package/src/presets/dead-code.ts +223 -0
- package/src/presets/duplication.ts +137 -0
- package/src/presets/e2e.ts +144 -0
- package/src/presets/format.ts +25 -0
- package/src/presets/index.ts +30 -0
- package/src/presets/license.ts +90 -0
- package/src/presets/lint.ts +116 -0
- package/src/presets/markdownlint.ts +105 -0
- package/src/presets/publint.ts +38 -0
- package/src/presets/security-deps.ts +142 -0
- package/src/presets/security-secrets.ts +93 -0
- package/src/presets/shared/error-warning-pass-policy.ts +39 -0
- package/src/presets/shared/exit-code-fail-rationale.ts +34 -0
- package/src/presets/shared/missing-dependency.ts +31 -0
- package/src/presets/shared/read-json-report.ts +46 -0
- package/src/presets/shared/terminal-status.ts +70 -0
- package/src/presets/shared/vitest-json-policy.ts +95 -0
- package/src/presets/stylelint.ts +101 -0
- package/src/presets/test.ts +19 -0
- package/src/presets/typecheck.ts +25 -0
- package/src/run-repo-contract.ts +80 -0
- package/src/types.ts +340 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { UnknownCheckIdError } from "../errors.js"
|
|
2
|
+
import type {
|
|
3
|
+
CheckDefinition,
|
|
4
|
+
CheckEvidence,
|
|
5
|
+
CheckSchema,
|
|
6
|
+
RunRepoContractOptions,
|
|
7
|
+
} from "../types.js"
|
|
8
|
+
import { composeSignals } from "./abort-signals.js"
|
|
9
|
+
import { runWithConcurrency } from "./concurrency-pool.js"
|
|
10
|
+
import { runWithConcurrencyGraph } from "./dependency-scheduler.js"
|
|
11
|
+
import type { ActiveCheckHandle } from "./spawn-check.js"
|
|
12
|
+
import { SIGKILL_GRACE_PERIOD_MS, spawnCheck } from "./spawn-check.js"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One check's id, its original definition, and its raw execution evidence,
|
|
16
|
+
* threaded together as a triple rather than three separately-keyed maps --
|
|
17
|
+
* every later stage (parsing, policy evaluation) consumes this directly
|
|
18
|
+
* instead of re-looking a check up by id, which under `noUncheckedIndexedAccess`
|
|
19
|
+
* would otherwise force handling an "undefined" case that can't actually
|
|
20
|
+
* happen (every checkId here comes from the same `Object.entries(checks)`
|
|
21
|
+
* that produced it).
|
|
22
|
+
*/
|
|
23
|
+
export type CheckExecutionEntry = readonly [string, CheckDefinition, CheckEvidence]
|
|
24
|
+
|
|
25
|
+
const TERMINATION_SIGNALS: readonly NodeJS.Signals[] = ["SIGINT", "SIGTERM"]
|
|
26
|
+
|
|
27
|
+
// Extra headroom over SIGKILL_GRACE_PERIOD_MS itself: killWithEscalation arms its own SIGKILL
|
|
28
|
+
// follow-up timer with that exact delay, in the same synchronous pass as the self-terminate timer
|
|
29
|
+
// below -- waiting only that same delay would race Node's timer ordering rather than reliably
|
|
30
|
+
// outlasting it. This margin ensures every active check's scheduled SIGKILL follow-up has already
|
|
31
|
+
// had the chance to run and observe the process exit before this process re-signals itself.
|
|
32
|
+
// Exported so a unit test can pin its exact value against SIGKILL_GRACE_PERIOD_MS without needing
|
|
33
|
+
// to trigger the real, only-testable-in-a-child-process SIGINT handler this constant feeds (see
|
|
34
|
+
// installTerminationHandlers below).
|
|
35
|
+
export const SELF_TERMINATE_DELAY_MS = SIGKILL_GRACE_PERIOD_MS + 250
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* While any check is in flight, installs a handler for SIGINT/SIGTERM that
|
|
39
|
+
* kills every currently-active check's process tree before the host process
|
|
40
|
+
* itself terminates -- otherwise a Ctrl+C during `runRepoContract()` would
|
|
41
|
+
* leave every spawned check (and their own descendants) running as orphans.
|
|
42
|
+
* After cleanup, removes its own handler and re-sends the signal to this
|
|
43
|
+
* process so default termination behavior (or any other listener the host
|
|
44
|
+
* application registered) still applies -- this never itself decides to
|
|
45
|
+
* keep the process alive or call `process.exit()`, it only ensures cleanup
|
|
46
|
+
* happens first.
|
|
47
|
+
*
|
|
48
|
+
* Also aborts `hostAbortController` before killing anything: `activeHandles` only ever contains
|
|
49
|
+
* checks that have *already* spawned, so killing every current member does nothing to stop the
|
|
50
|
+
* concurrency pool/scheduler from immediately launching the *next* queued check the instant a
|
|
51
|
+
* killed one's promise settles -- by then this handler has already returned and `uninstall()` has
|
|
52
|
+
* already removed these very listeners, so nothing would be left to kill that newly-spawned
|
|
53
|
+
* process. Aborting `hostAbortController` first means `runChecks`' composed `runSignal` is already
|
|
54
|
+
* `aborted` by the time any such check is considered, so `spawnCheck` takes its documented
|
|
55
|
+
* already-aborted path (see spawn-check.ts's own doc comment) and resolves without spawning at
|
|
56
|
+
* all, instead of racing this cleanup.
|
|
57
|
+
* @param activeHandles - the currently-running checks' kill handles, killed with the received signal on termination
|
|
58
|
+
* @param hostAbortController - aborted before any active check is killed, so a check still queued behind the concurrency limit never spawns instead of racing this cleanup
|
|
59
|
+
* @returns a function that removes the installed signal handlers without killing anything, for cleanup once no check is in flight
|
|
60
|
+
*/
|
|
61
|
+
function installTerminationHandlers(
|
|
62
|
+
activeHandles: Set<ActiveCheckHandle>,
|
|
63
|
+
hostAbortController: AbortController,
|
|
64
|
+
): () => void {
|
|
65
|
+
const handlers = new Map<NodeJS.Signals, () => void>()
|
|
66
|
+
|
|
67
|
+
for (const signal of TERMINATION_SIGNALS) {
|
|
68
|
+
/* v8 ignore start -- this body only runs when the host *process* actually
|
|
69
|
+
* receives a real SIGINT/SIGTERM. test/unit/execution/run-checks.test.ts's
|
|
70
|
+
* "SIGINT while checks are in flight..." test exercises it for real, but
|
|
71
|
+
* necessarily in a separate child process (sending SIGINT to this test
|
|
72
|
+
* worker's own process would kill the test runner) -- v8 coverage is
|
|
73
|
+
* per-process, so that real exercise is invisible here. Same reasoning
|
|
74
|
+
* applies to Stryker's mutation testing, which only observes this
|
|
75
|
+
* process's own test run. */
|
|
76
|
+
// Stryker disable BlockStatement,CallExpression,ConditionalExpression,EqualityOperator,BooleanLiteral -- this handler body only executes when the host process receives a real SIGINT/SIGTERM; the test exercising it necessarily runs in a separate child process (v8 coverage and Stryker's own instrumentation are both per-process), so mutating this body -- including the hadActiveChecks/!hadActiveChecks branch below -- always reports an uncoverable-looking survivor rather than a real gap.
|
|
77
|
+
const handler = (): void => {
|
|
78
|
+
// Must run before killing anything currently active (see this
|
|
79
|
+
// function's own doc comment): synchronous, so every check the
|
|
80
|
+
// scheduler considers launching from this point forward -- including
|
|
81
|
+
// one whose turn comes only after a check killed below actually exits
|
|
82
|
+
// -- already observes `aborted === true`.
|
|
83
|
+
hostAbortController.abort()
|
|
84
|
+
// Captured before killing anything: `activeHandles` only loses entries
|
|
85
|
+
// once each check's own process actually exits (asynchronously, via
|
|
86
|
+
// spawn-check.ts's cleanup), so it still reads non-empty immediately
|
|
87
|
+
// after calling kill() on every one of them below -- this check must
|
|
88
|
+
// run first to know whether there's anything worth waiting on.
|
|
89
|
+
const hadActiveChecks = activeHandles.size > 0
|
|
90
|
+
for (const handle of activeHandles) handle.kill(signal)
|
|
91
|
+
uninstall()
|
|
92
|
+
if (!hadActiveChecks) {
|
|
93
|
+
process.kill(process.pid, signal)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
// Previously this re-signaled (and thereby terminated, via Node's
|
|
97
|
+
// default disposition once uninstall() removed this process's own
|
|
98
|
+
// listener) the host process immediately after arming the kills above
|
|
99
|
+
// -- starving every check's own SIGKILL-escalation timer
|
|
100
|
+
// (spawn-check.ts's killWithEscalation) of the time it needs to fire,
|
|
101
|
+
// orphaning any check whose command traps or ignores the initial
|
|
102
|
+
// signal. Waiting SELF_TERMINATE_DELAY_MS first gives every one of
|
|
103
|
+
// those timers a real chance to run before this process exits. This
|
|
104
|
+
// delay is sized against `SIGKILL_GRACE_PERIOD_MS` alone -- it is
|
|
105
|
+
// deliberately not tied to whether `runChecks()`'s own promise (or any
|
|
106
|
+
// work a caller does with its result, e.g. persisting evidence) has
|
|
107
|
+
// resolved by then: a real Ctrl+C is a request to stop now, and this
|
|
108
|
+
// process is going to re-terminate itself via the signal re-sent below
|
|
109
|
+
// regardless of what the caller is doing, the same as if this handler
|
|
110
|
+
// did not exist at all -- the only thing this delay buys is letting
|
|
111
|
+
// already-spawned child processes actually die first.
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
process.kill(process.pid, signal)
|
|
114
|
+
}, SELF_TERMINATE_DELAY_MS)
|
|
115
|
+
}
|
|
116
|
+
// Stryker restore all
|
|
117
|
+
/* v8 ignore stop */
|
|
118
|
+
handlers.set(signal, handler)
|
|
119
|
+
process.once(signal, handler)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
*
|
|
124
|
+
*/
|
|
125
|
+
function uninstall(): void {
|
|
126
|
+
for (const [signal, handler] of handlers) process.removeListener(signal, handler)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return uninstall
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolves `requestedChecks` plus every check transitively required to satisfy their own
|
|
134
|
+
* `dependsOn` -- never `isolated`, whose implied positional edges (see `dependencyIndexesFor`
|
|
135
|
+
* below) are scheduling-only and must not pull unrelated checks into a partial run just because
|
|
136
|
+
* one happens to sit after an isolated barrier (see `CheckDefinitionConfig.isolated`'s own doc
|
|
137
|
+
* comment). No cycle guard is needed: `validateRepoContractConfig` already guarantees, before
|
|
138
|
+
* `runChecks` is ever reached, that every `dependsOn` id names a check declared *earlier* than the
|
|
139
|
+
* check declaring it -- a cycle is structurally impossible once every edge points backward.
|
|
140
|
+
* @param checks - the full set of configured checks, keyed by id, in declaration order
|
|
141
|
+
* @param requestedChecks - the check ids explicitly requested for this run
|
|
142
|
+
* @returns the requested checks plus every transitive `dependsOn` dependency, in declaration order
|
|
143
|
+
*/
|
|
144
|
+
function resolveCheckDependencies(
|
|
145
|
+
checks: Record<string, CheckDefinition>,
|
|
146
|
+
requestedChecks: readonly string[],
|
|
147
|
+
): [string, CheckDefinition][] {
|
|
148
|
+
const required = new Set<string>()
|
|
149
|
+
|
|
150
|
+
const visit = (checkId: string): void => {
|
|
151
|
+
// Real, tested logic -- "throws when a requested subset's dependsOn forms a cycle" in
|
|
152
|
+
// run-checks.test.ts proves this guard fires correctly for the one caller shape that can still
|
|
153
|
+
// reach a cycle here (runChecks invoked directly, bypassing validateRepoContractConfig's own
|
|
154
|
+
// backward-reference check). In a plain, uninstrumented process, disabling it and reproducing
|
|
155
|
+
// the resulting unbounded recursion throws a stack-overflow RangeError in single-digit
|
|
156
|
+
// milliseconds (confirmed empirically, not assumed). Mutation-tested here it is not: disabling
|
|
157
|
+
// this condition turns a would-be 2-node cycle into unbounded mutual recursion between `visit`
|
|
158
|
+
// calls, and Stryker's own per-statement coverage instrumentation adds enough overhead per
|
|
159
|
+
// recursive frame that reaching V8's actual stack limit -- still the only thing that stops it --
|
|
160
|
+
// takes long enough to exceed stryker.config.mjs's timeoutMS. A "Timeout" verdict under
|
|
161
|
+
// instrumentation here reflects that mismatch between wall-clock budget and V8's stack limit,
|
|
162
|
+
// not a gap in test coverage -- a known limitation of mutation testing for recursion/loop-guard
|
|
163
|
+
// code generally, not specific to this function.
|
|
164
|
+
// Stryker disable next-line ConditionalExpression -- disabling this in an uninstrumented process empirically produces unbounded mutual recursion that hits V8's stack limit in single-digit milliseconds, but under Stryker's own per-statement instrumentation overhead reaching that same stack limit takes long enough to exceed stryker.config.mjs's timeoutMS, surfacing as a "Timeout" verdict rather than a real test gap.
|
|
165
|
+
if (required.has(checkId)) return
|
|
166
|
+
|
|
167
|
+
const check = checks[checkId]
|
|
168
|
+
if (!check) {
|
|
169
|
+
throw new UnknownCheckIdError(checkId)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
required.add(checkId)
|
|
173
|
+
for (const dependency of check.dependsOn ?? []) {
|
|
174
|
+
visit(dependency)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const checkId of requestedChecks) visit(checkId)
|
|
179
|
+
|
|
180
|
+
return Object.entries(checks).filter(([checkId]) => required.has(checkId))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Executes every resolved check with at most `concurrency` running at once,
|
|
185
|
+
* returning each check's raw evidence keyed by check id. For a full run
|
|
186
|
+
* (`options.checks` omitted), every configured check id is resolved and
|
|
187
|
+
* therefore appears in the result exactly once, regardless of whether it
|
|
188
|
+
* ever actually spawned (see spawnCheck's documentation for the pre-aborted
|
|
189
|
+
* case). When `options.checks` restricts execution to a subset, only the
|
|
190
|
+
* requested ids and their transitive `dependsOn` are resolved (see
|
|
191
|
+
* `resolveCheckDependencies` above) -- every other configured check id is
|
|
192
|
+
* simply absent from the result, not present with some placeholder value.
|
|
193
|
+
* @param checks - the full set of configured checks, keyed by id
|
|
194
|
+
* @param concurrency - the maximum number of checks to run in parallel at once
|
|
195
|
+
* @param options - run options; `options.checks` restricts execution to those ids (plus their dependencies), `options.signal` cancels the whole run
|
|
196
|
+
* @returns each executed check's id, definition, and raw evidence, one entry per resolved check regardless of whether it actually spawned
|
|
197
|
+
*/
|
|
198
|
+
export async function runChecks(
|
|
199
|
+
checks: CheckSchema,
|
|
200
|
+
concurrency: number,
|
|
201
|
+
options?: RunRepoContractOptions,
|
|
202
|
+
): Promise<readonly CheckExecutionEntry[]> {
|
|
203
|
+
const entries = options?.checks
|
|
204
|
+
? resolveCheckDependencies(checks, options.checks)
|
|
205
|
+
: Object.entries(checks)
|
|
206
|
+
|
|
207
|
+
const activeHandles = new Set<ActiveCheckHandle>()
|
|
208
|
+
const hostAbortController = new AbortController()
|
|
209
|
+
const uninstall = installTerminationHandlers(activeHandles, hostAbortController)
|
|
210
|
+
// Composed rather than passing `options?.signal` straight through: a host-process
|
|
211
|
+
// SIGINT/SIGTERM must abort every not-yet-spawned check exactly like an
|
|
212
|
+
// explicit `options.signal` cancellation already does, or a check queued behind the
|
|
213
|
+
// concurrency limit would spawn unsupervised after installTerminationHandlers' own
|
|
214
|
+
// listeners are already removed (see that function's doc comment). The `if` branch below
|
|
215
|
+
// remains fully covered without further exemption: removing `options.signal` from that array
|
|
216
|
+
// breaks "a global AbortSignal fired mid-run..." (run-checks.test.ts), which supplies
|
|
217
|
+
// `options.signal` and asserts on its effect directly, in-process.
|
|
218
|
+
const { signal: runSignal, dispose: disposeRunSignal } = composeSignals(
|
|
219
|
+
options?.signal !== undefined
|
|
220
|
+
? [options.signal, hostAbortController.signal]
|
|
221
|
+
: // The `else` branch's inclusion of `hostAbortController.signal` is only observably
|
|
222
|
+
// meaningful once `hostAbortController.abort()` is actually called, which happens
|
|
223
|
+
// exclusively inside installTerminationHandlers' real-OS-signal handler above -- and per
|
|
224
|
+
// that handler's own doc comment, that handler body only executes when the host
|
|
225
|
+
// *process* actually receives a real SIGINT/SIGTERM, which cannot be triggered from this
|
|
226
|
+
// same in-process test worker without killing it (v8/Stryker coverage is per-process, so
|
|
227
|
+
// the real exercise -- run-checks.test.ts's "does not spawn a check still queued behind
|
|
228
|
+
// the concurrency limit..." test, run in a separate child process -- is invisible here
|
|
229
|
+
// for the identical reason already documented on that handler).
|
|
230
|
+
// Stryker disable next-line ArrayDeclaration -- see comment immediately above: only provable via a real signal delivered to a separate child process, invisible to this process's own Stryker instrumentation, the same per-process-invisible situation already documented on installTerminationHandlers' handler.
|
|
231
|
+
[hostAbortController.signal],
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
const worker = async ([checkId, check]: readonly [
|
|
235
|
+
string,
|
|
236
|
+
CheckDefinition,
|
|
237
|
+
]): Promise<CheckExecutionEntry> => {
|
|
238
|
+
const evidence = await spawnCheck(checkId, check, runSignal, activeHandles)
|
|
239
|
+
return [checkId, check, evidence]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
// A fully-disconnected graph (no check declares dependsOn or isolated --
|
|
244
|
+
// the default) takes the exact same runWithConcurrency path as before
|
|
245
|
+
// this feature existed, not a "generalized but behaviorally equivalent"
|
|
246
|
+
// one -- zero risk to the common case. This choice of code path is
|
|
247
|
+
// provably unobservable either way: dependency-scheduler.test.ts's own
|
|
248
|
+
// "zero-edges equivalence" suite confirms runWithConcurrencyGraph
|
|
249
|
+
// behaves identically to runWithConcurrency for a graph with no real
|
|
250
|
+
// edges (order, concurrency clamping, rejection propagation, all
|
|
251
|
+
// matching) -- routing every call through the graph-aware scheduler
|
|
252
|
+
// regardless of hasDependencies would produce the same observable
|
|
253
|
+
// results for every existing no-dependsOn/no-isolated test, making that
|
|
254
|
+
// decision a pure performance optimization, not a behavioral one (an
|
|
255
|
+
// isolated check alone in `entries`, with nothing else to wait on,
|
|
256
|
+
// resolves to zero effective edges too -- the same proven-equivalent
|
|
257
|
+
// case).
|
|
258
|
+
// Stryker disable CallExpression,ArrowFunction,OptionalChaining,LogicalOperator,EqualityOperator,UnaryOperator,ConditionalExpression,BlockStatement -- dependency-scheduler.test.ts's own "zero-edges equivalence" suite already proves runWithConcurrencyGraph behaves identically to runWithConcurrency for a graph with no real edges, so this fast path is a pure performance optimization: every existing no-dependsOn/no-isolated test would pass identically either way, making this branch provably unobservable rather than undertested.
|
|
259
|
+
const hasDependencies = entries.some(
|
|
260
|
+
([, check]) => (check.dependsOn?.length ?? 0) > 0 || check.isolated === true,
|
|
261
|
+
)
|
|
262
|
+
if (!hasDependencies) {
|
|
263
|
+
return await runWithConcurrency(entries, concurrency, worker)
|
|
264
|
+
}
|
|
265
|
+
// Stryker restore all
|
|
266
|
+
|
|
267
|
+
const indexById = new Map(entries.map(([checkId], index) => [checkId, index]))
|
|
268
|
+
// Precomputed once per run rather than re-scanned inside
|
|
269
|
+
// dependencyIndexesFor's own per-check closure below: every plain
|
|
270
|
+
// (non-isolated) check needs this same list to find which isolated
|
|
271
|
+
// checks it must wait for (see that edge's own comment below).
|
|
272
|
+
//
|
|
273
|
+
// This array's own initial contents are unobservable regardless of what they are: every real
|
|
274
|
+
// isolated index is still pushed on below, and dependencyIndexesFor's own `earlierIsolated`
|
|
275
|
+
// filter (further down) compares each entry against a numeric `index` with `<` -- a stray
|
|
276
|
+
// non-numeric seed value fails that numeric comparison (JavaScript's `<` coerces a non-numeric
|
|
277
|
+
// operand to NaN, and every comparison against NaN is false) and is silently filtered out,
|
|
278
|
+
// never appearing in any real edge list. Confirmed empirically, not assumed: `["Stryker was
|
|
279
|
+
// here", 2, 5].filter((i) => i < 10)` evaluates to `[2, 5]`, dropping the bogus entry with no
|
|
280
|
+
// trace.
|
|
281
|
+
// Stryker disable next-line ArrayDeclaration -- this array's own initial contents are unobservable regardless of what they are: every real isolated index is still pushed on below, and dependencyIndexesFor's own earlierIsolated filter compares each entry against a numeric index with `<`, which coerces a non-numeric seed value to NaN and silently filters it out (every comparison against NaN is false) -- confirmed empirically that a bogus seed entry never appears in any real edge list.
|
|
282
|
+
const isolatedIndexes: number[] = []
|
|
283
|
+
for (const [entryIndex, [, entryCheck]] of entries.entries()) {
|
|
284
|
+
if (entryCheck.isolated === true) isolatedIndexes.push(entryIndex)
|
|
285
|
+
}
|
|
286
|
+
// Declaration order in `entries` doubles as the required scheduling order (see
|
|
287
|
+
// CheckDefinitionConfig.isolated and CheckDefinition.dependsOn's own doc comments,
|
|
288
|
+
// specs/architecture.md, and validate-config.ts's backward-reference validation, which
|
|
289
|
+
// guarantees every declared `dependsOn` id resolves to an index strictly less than this
|
|
290
|
+
// check's own before this function is ever reached): a check runs concurrently with whatever's
|
|
291
|
+
// declared around it, launched in declaration order and bounded by `concurrency`, EXCEPT that
|
|
292
|
+
// (a) an explicit `dependsOn` id must have already reached a terminal status, and (b) an
|
|
293
|
+
// `isolated` check is a full barrier at its own declared position -- it waits for every check
|
|
294
|
+
// declared earlier (nothing "currently in flight" when its turn comes can be anything other
|
|
295
|
+
// than an earlier-declared check, since nothing later has been reached in the walk yet), and
|
|
296
|
+
// every check declared *after* it waits for it in turn, so nothing overlaps it either
|
|
297
|
+
// direction. Two isolated checks are therefore always sequential relative to each other (the
|
|
298
|
+
// later one's "everything declared earlier" already includes the earlier one).
|
|
299
|
+
const dependencyIndexesFor = (
|
|
300
|
+
[, check]: readonly [string, CheckDefinition],
|
|
301
|
+
index: number,
|
|
302
|
+
): number[] => {
|
|
303
|
+
const declared = (check.dependsOn ?? []).map((depId) => {
|
|
304
|
+
const depIndex = indexById.get(depId)
|
|
305
|
+
// validate-config.ts has already guaranteed, before runChecks is
|
|
306
|
+
// ever invoked, that every dependsOn id names a check that exists
|
|
307
|
+
// in this same `checks` record -- reaching this would be a bug in
|
|
308
|
+
// that guarantee, not a user-input problem, so it fails loudly
|
|
309
|
+
// rather than silently miscounting the dependency graph. Provably
|
|
310
|
+
// unreachable given that guarantee, same as this file's other
|
|
311
|
+
// upstream-validated invariants.
|
|
312
|
+
/* v8 ignore start */
|
|
313
|
+
// Stryker disable EqualityOperator,ConditionalExpression,BlockStatement,StringLiteral,CallExpression -- validate-config.ts has already guaranteed, before runChecks is ever invoked, that every dependsOn id names a check that exists in this same checks record; reaching this would be a bug in that guarantee, not a user-input problem.
|
|
314
|
+
if (depIndex === undefined) {
|
|
315
|
+
throw new Error(`internal: dependsOn references unknown check id "${depId}".`)
|
|
316
|
+
}
|
|
317
|
+
// Stryker restore all
|
|
318
|
+
/* v8 ignore stop */
|
|
319
|
+
return depIndex
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
if (check.isolated === true) {
|
|
323
|
+
// Every index declared earlier than this one -- see this function's own doc comment above.
|
|
324
|
+
const earlierIndexes = Array.from({ length: index }, (_, earlierIndex) => earlierIndex)
|
|
325
|
+
return [...new Set([...declared, ...earlierIndexes])]
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// A plain check waits for every isolated check declared earlier than it (so it never starts
|
|
329
|
+
// while that barrier is still draining or running), but not for every plain check earlier
|
|
330
|
+
// than it -- those remain free to run concurrently, unchanged from the disconnected-graph
|
|
331
|
+
// default.
|
|
332
|
+
//
|
|
333
|
+
// `<` vs `<=` here is provably equivalent, not a coverage gap: this branch only ever runs for
|
|
334
|
+
// a check whose own `isolated !== true` (the `if (check.isolated === true)` branch above
|
|
335
|
+
// already returned otherwise), and `isolatedIndexes` contains only indexes of checks whose
|
|
336
|
+
// `isolated === true` -- so `index` (this check's own) can never itself be a member of
|
|
337
|
+
// `isolatedIndexes`, making `isolatedIndex === index` unconditionally false for every value
|
|
338
|
+
// this filter is ever called with. `<` and `<=` therefore select the identical subset here.
|
|
339
|
+
// Stryker disable next-line EqualityOperator -- provably equivalent: this branch only runs when check.isolated !== true, and isolatedIndexes contains only indexes of checks where isolated === true, so this check's own `index` can never be a member of isolatedIndexes -- isolatedIndex === index is unconditionally false, making `<` and `<=` select the identical subset for every value this filter is ever called with.
|
|
340
|
+
const earlierIsolated = isolatedIndexes.filter((isolatedIndex) => isolatedIndex < index)
|
|
341
|
+
return [...new Set([...declared, ...earlierIsolated])]
|
|
342
|
+
}
|
|
343
|
+
return await runWithConcurrencyGraph(entries, concurrency, dependencyIndexesFor, worker)
|
|
344
|
+
} finally {
|
|
345
|
+
uninstall()
|
|
346
|
+
disposeRunSignal()
|
|
347
|
+
}
|
|
348
|
+
}
|