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.
Files changed (145) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +967 -0
  4. package/dist/.dts/config/define-repo-contract.d.ts +36 -0
  5. package/dist/.dts/config/define-repo-contract.d.ts.map +1 -0
  6. package/dist/.dts/config/tokenize-command.d.ts +33 -0
  7. package/dist/.dts/config/tokenize-command.d.ts.map +1 -0
  8. package/dist/.dts/config/validate-config.d.ts +32 -0
  9. package/dist/.dts/config/validate-config.d.ts.map +1 -0
  10. package/dist/.dts/errors.d.ts +155 -0
  11. package/dist/.dts/errors.d.ts.map +1 -0
  12. package/dist/.dts/evidence/build-evidence.d.ts +26 -0
  13. package/dist/.dts/evidence/build-evidence.d.ts.map +1 -0
  14. package/dist/.dts/execution/abort-signals.d.ts +29 -0
  15. package/dist/.dts/execution/abort-signals.d.ts.map +1 -0
  16. package/dist/.dts/execution/concurrency-pool.d.ts +14 -0
  17. package/dist/.dts/execution/concurrency-pool.d.ts.map +1 -0
  18. package/dist/.dts/execution/dependency-scheduler.d.ts +30 -0
  19. package/dist/.dts/execution/dependency-scheduler.d.ts.map +1 -0
  20. package/dist/.dts/execution/process-tree.d.ts +48 -0
  21. package/dist/.dts/execution/process-tree.d.ts.map +1 -0
  22. package/dist/.dts/execution/run-checks.d.ts +29 -0
  23. package/dist/.dts/execution/run-checks.d.ts.map +1 -0
  24. package/dist/.dts/execution/spawn-check.d.ts +30 -0
  25. package/dist/.dts/execution/spawn-check.d.ts.map +1 -0
  26. package/dist/.dts/index.d.ts +13 -0
  27. package/dist/.dts/index.d.ts.map +1 -0
  28. package/dist/.dts/parsing/parse-json.d.ts +8 -0
  29. package/dist/.dts/parsing/parse-json.d.ts.map +1 -0
  30. package/dist/.dts/parsing/parse-output.d.ts +10 -0
  31. package/dist/.dts/parsing/parse-output.d.ts.map +1 -0
  32. package/dist/.dts/parsing/parse-text.d.ts +8 -0
  33. package/dist/.dts/parsing/parse-text.d.ts.map +1 -0
  34. package/dist/.dts/parsing/parse-yaml.d.ts +21 -0
  35. package/dist/.dts/parsing/parse-yaml.d.ts.map +1 -0
  36. package/dist/.dts/policy/run-policies.d.ts +38 -0
  37. package/dist/.dts/policy/run-policies.d.ts.map +1 -0
  38. package/dist/.dts/presets/arethetypeswrong.d.ts +39 -0
  39. package/dist/.dts/presets/arethetypeswrong.d.ts.map +1 -0
  40. package/dist/.dts/presets/broken-links.d.ts +16 -0
  41. package/dist/.dts/presets/broken-links.d.ts.map +1 -0
  42. package/dist/.dts/presets/commitlint.d.ts +22 -0
  43. package/dist/.dts/presets/commitlint.d.ts.map +1 -0
  44. package/dist/.dts/presets/dead-code.d.ts +23 -0
  45. package/dist/.dts/presets/dead-code.d.ts.map +1 -0
  46. package/dist/.dts/presets/duplication.d.ts +14 -0
  47. package/dist/.dts/presets/duplication.d.ts.map +1 -0
  48. package/dist/.dts/presets/e2e.d.ts +4 -0
  49. package/dist/.dts/presets/e2e.d.ts.map +1 -0
  50. package/dist/.dts/presets/format.d.ts +4 -0
  51. package/dist/.dts/presets/format.d.ts.map +1 -0
  52. package/dist/.dts/presets/index.d.ts +31 -0
  53. package/dist/.dts/presets/index.d.ts.map +1 -0
  54. package/dist/.dts/presets/license.d.ts +4 -0
  55. package/dist/.dts/presets/license.d.ts.map +1 -0
  56. package/dist/.dts/presets/lint.d.ts +20 -0
  57. package/dist/.dts/presets/lint.d.ts.map +1 -0
  58. package/dist/.dts/presets/markdownlint.d.ts +23 -0
  59. package/dist/.dts/presets/markdownlint.d.ts.map +1 -0
  60. package/dist/.dts/presets/publint.d.ts +13 -0
  61. package/dist/.dts/presets/publint.d.ts.map +1 -0
  62. package/dist/.dts/presets/security-deps.d.ts +4 -0
  63. package/dist/.dts/presets/security-deps.d.ts.map +1 -0
  64. package/dist/.dts/presets/security-secrets.d.ts +4 -0
  65. package/dist/.dts/presets/security-secrets.d.ts.map +1 -0
  66. package/dist/.dts/presets/shared/error-warning-pass-policy.d.ts +13 -0
  67. package/dist/.dts/presets/shared/error-warning-pass-policy.d.ts.map +1 -0
  68. package/dist/.dts/presets/shared/exit-code-fail-rationale.d.ts +23 -0
  69. package/dist/.dts/presets/shared/exit-code-fail-rationale.d.ts.map +1 -0
  70. package/dist/.dts/presets/shared/missing-dependency.d.ts +19 -0
  71. package/dist/.dts/presets/shared/missing-dependency.d.ts.map +1 -0
  72. package/dist/.dts/presets/shared/read-json-report.d.ts +33 -0
  73. package/dist/.dts/presets/shared/read-json-report.d.ts.map +1 -0
  74. package/dist/.dts/presets/shared/terminal-status.d.ts +25 -0
  75. package/dist/.dts/presets/shared/terminal-status.d.ts.map +1 -0
  76. package/dist/.dts/presets/shared/vitest-json-policy.d.ts +20 -0
  77. package/dist/.dts/presets/shared/vitest-json-policy.d.ts.map +1 -0
  78. package/dist/.dts/presets/stylelint.d.ts +17 -0
  79. package/dist/.dts/presets/stylelint.d.ts.map +1 -0
  80. package/dist/.dts/presets/test.d.ts +4 -0
  81. package/dist/.dts/presets/test.d.ts.map +1 -0
  82. package/dist/.dts/presets/typecheck.d.ts +4 -0
  83. package/dist/.dts/presets/typecheck.d.ts.map +1 -0
  84. package/dist/.dts/run-repo-contract.d.ts +38 -0
  85. package/dist/.dts/run-repo-contract.d.ts.map +1 -0
  86. package/dist/.dts/types.d.ts +324 -0
  87. package/dist/.dts/types.d.ts.map +1 -0
  88. package/dist/index.cjs +46 -0
  89. package/dist/index.cjs.map +1 -0
  90. package/dist/index.d.cts +1 -0
  91. package/dist/index.d.ts +1 -0
  92. package/dist/index.js +11 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/presets.cjs +30 -0
  95. package/dist/presets.cjs.map +1 -0
  96. package/dist/presets.d.cts +1 -0
  97. package/dist/presets.d.ts +1 -0
  98. package/dist/presets.js +13 -0
  99. package/dist/presets.js.map +1 -0
  100. package/package.json +192 -0
  101. package/presets/package.json +5 -0
  102. package/schemas/evidence.schema.json +253 -0
  103. package/schemas/verdict.schema.json +66 -0
  104. package/src/config/define-repo-contract.ts +38 -0
  105. package/src/config/tokenize-command.ts +214 -0
  106. package/src/config/validate-config.ts +368 -0
  107. package/src/errors.ts +229 -0
  108. package/src/evidence/build-evidence.ts +91 -0
  109. package/src/execution/abort-signals.ts +56 -0
  110. package/src/execution/concurrency-pool.ts +64 -0
  111. package/src/execution/dependency-scheduler.ts +216 -0
  112. package/src/execution/process-tree.ts +107 -0
  113. package/src/execution/run-checks.ts +348 -0
  114. package/src/execution/spawn-check.ts +494 -0
  115. package/src/index.ts +44 -0
  116. package/src/parsing/parse-json.ts +18 -0
  117. package/src/parsing/parse-output.ts +26 -0
  118. package/src/parsing/parse-text.ts +10 -0
  119. package/src/parsing/parse-yaml.ts +40 -0
  120. package/src/policy/run-policies.ts +261 -0
  121. package/src/presets/arethetypeswrong.ts +116 -0
  122. package/src/presets/broken-links.ts +95 -0
  123. package/src/presets/commitlint.ts +77 -0
  124. package/src/presets/dead-code.ts +223 -0
  125. package/src/presets/duplication.ts +137 -0
  126. package/src/presets/e2e.ts +144 -0
  127. package/src/presets/format.ts +25 -0
  128. package/src/presets/index.ts +30 -0
  129. package/src/presets/license.ts +90 -0
  130. package/src/presets/lint.ts +116 -0
  131. package/src/presets/markdownlint.ts +105 -0
  132. package/src/presets/publint.ts +38 -0
  133. package/src/presets/security-deps.ts +142 -0
  134. package/src/presets/security-secrets.ts +93 -0
  135. package/src/presets/shared/error-warning-pass-policy.ts +39 -0
  136. package/src/presets/shared/exit-code-fail-rationale.ts +34 -0
  137. package/src/presets/shared/missing-dependency.ts +31 -0
  138. package/src/presets/shared/read-json-report.ts +46 -0
  139. package/src/presets/shared/terminal-status.ts +70 -0
  140. package/src/presets/shared/vitest-json-policy.ts +95 -0
  141. package/src/presets/stylelint.ts +101 -0
  142. package/src/presets/test.ts +19 -0
  143. package/src/presets/typecheck.ts +25 -0
  144. package/src/run-repo-contract.ts +80 -0
  145. package/src/types.ts +340 -0
@@ -0,0 +1,91 @@
1
+ import type { CheckExecutionEntry } from "../execution/run-checks.js"
2
+ import type { ParserDependencyMissingError } from "../errors.js"
3
+ import { parseOutput } from "../parsing/parse-output.js"
4
+ import type { CheckDefinition, CheckEvidence, Evidence } from "../types.js"
5
+
6
+ /** One check's id, its original definition, and its final evidence (parsed output attached, if requested) -- the parsed-output counterpart to `CheckExecutionEntry`, consumed directly by the policy phase so it never needs to look a check up by id either. */
7
+ export type ParsedCheckEntry = readonly [string, CheckDefinition, CheckEvidence]
8
+
9
+ // Not exported -- nothing outside this file references it by name; callers
10
+ // (run-repo-contract.ts) destructure `{ evidence, entries }` directly.
11
+ interface BuiltEvidence {
12
+ readonly evidence: Evidence
13
+ readonly entries: readonly ParsedCheckEntry[]
14
+ }
15
+
16
+ /**
17
+ * Attaches parsed output (if requested) to every check's raw execution
18
+ * evidence and assembles the versioned, immutable `Evidence` object for the
19
+ * run as a whole. Also returns the same information as a flat entries array
20
+ * (see `ParsedCheckEntry`) for the policy phase to consume directly -- by
21
+ * the time this function returns, every check's evidence -- including every
22
+ * sibling check's -- is fully assembled; nothing here is generated lazily
23
+ * or streamed, which is what makes it safe for a policy to read the full
24
+ * `evidence` object, not just its own check's `result` (see
25
+ * specs/architecture.md).
26
+ * @param results - each check's id, definition, and raw execution evidence from the run phase
27
+ * @param startedAt - when the overall run began, recorded on the assembled `Evidence`
28
+ * @param completedAt - when the overall run finished, used with `startedAt` to compute the assembled `Evidence`'s `durationMs`
29
+ * @returns the assembled `Evidence` for the whole run, plus the same checks as a flat `ParsedCheckEntry` array for the policy phase
30
+ */
31
+ export async function buildEvidence(
32
+ results: readonly CheckExecutionEntry[],
33
+ startedAt: Date,
34
+ completedAt: Date,
35
+ ): Promise<BuiltEvidence> {
36
+ // Mirrors src/policy/run-policies.ts's own thrown-error aggregation: each
37
+ // mapped entry catches its own failure and records it rather than letting
38
+ // it reject `Promise.all` directly, so that two checks concurrently
39
+ // requesting an output format whose parser dependency is missing (e.g.
40
+ // "yaml" without the optional peer dependency installed) are both
41
+ // reported, not just whichever rejected first.
42
+ const thrown: unknown[] = []
43
+
44
+ const entries = await Promise.all(
45
+ results.map(async ([checkId, check, raw]): Promise<ParsedCheckEntry> => {
46
+ if (check.output === undefined) return [checkId, check, raw]
47
+ try {
48
+ const output = await parseOutput(check.output.format, raw.stdout, checkId)
49
+ return [checkId, check, { ...raw, output }]
50
+ } catch (error) {
51
+ thrown.push(error)
52
+ // Never actually consumed -- every branch below that follows a
53
+ // non-empty `thrown` throws before `entries` is read.
54
+ // Stryker disable next-line ArrayDeclaration -- this tuple is never read: both branches below that can run when `thrown` is non-empty (thrown.length === 1 or > 1) throw before `entries` -- the array this returns into -- is ever returned to a caller.
55
+ return [checkId, check, raw]
56
+ }
57
+ }),
58
+ )
59
+
60
+ if (thrown.length === 1) {
61
+ const [only] = thrown as [ParserDependencyMissingError]
62
+ throw only
63
+ }
64
+ // "> 1" vs. ">= 1" are equivalent here for the same reason as the
65
+ // identical comparison in src/policy/run-policies.ts: the `=== 1` early
66
+ // return just above already consumes the length-1 case, so by the time
67
+ // this line runs, thrown.length is never exactly 1 -- either 0 (falls
68
+ // through below either way) or >= 2 (takes this branch either way).
69
+ // Stryker disable next-line EqualityOperator -- "> 1" vs. ">= 1" are equivalent here given the early return just above: by the time this line runs, thrown.length is never exactly 1 (either 0 or >= 2 either way), documented so a future refactor that removes the early return doesn't quietly widen this comparison's real behavior without anyone noticing.
70
+ if (thrown.length > 1) {
71
+ throw new AggregateError(thrown, `${String(thrown.length)} check output(s) failed to parse.`)
72
+ }
73
+
74
+ // `Evidence["checks"]` is a mapped type over the *specific* CheckSchema a
75
+ // consumer's config declares (see runRepoContract's own generic
76
+ // signature) -- internal pipeline code works with the erased/default
77
+ // CheckSchema instead, whose `keyof` is a plain `string`. The precise
78
+ // generic Evidence<TChecks> is asserted only once, at runRepoContract's
79
+ // own public boundary.
80
+ const evidence = {
81
+ version: 1,
82
+ startedAt: startedAt.toISOString(),
83
+ completedAt: completedAt.toISOString(),
84
+ durationMs: completedAt.getTime() - startedAt.getTime(),
85
+ checks: Object.fromEntries(
86
+ entries.map(([checkId, , checkEvidence]) => [checkId, checkEvidence]),
87
+ ),
88
+ } as Evidence
89
+
90
+ return { evidence, entries }
91
+ }
@@ -0,0 +1,56 @@
1
+ /** The composed signal, and a `dispose` to release any resources this composition holds once it's no longer needed. */
2
+ interface ComposedSignal {
3
+ readonly signal: AbortSignal
4
+ readonly dispose: () => void
5
+ }
6
+
7
+ /**
8
+ * Combines multiple signals into one that aborts as soon as any input does.
9
+ * Prefers the native `AbortSignal.any()` where available; falls back to a
10
+ * manual composition for engines without it. `AbortSignal.any` landed in
11
+ * Node 20.3.0 -- genuinely newer than this package's `engines.node >=20.0.0`
12
+ * floor, so the fallback is live code for a real gap (a 20.0-20.2 patch
13
+ * release), not dead code kept out of caution.
14
+ *
15
+ * The native path's `dispose` is a no-op -- `AbortSignal.any` manages its own
16
+ * source-signal listeners internally and doesn't leak them. The manual
17
+ * fallback's `dispose` removes the "abort" listeners it added to each input
18
+ * `signal`; without calling it, a long-lived input (like a whole run's shared
19
+ * `AbortSignal`, composed fresh for every check the run spawns) would
20
+ * accumulate one permanent, never-removed listener per composed signal for
21
+ * the rest of its own lifetime, regardless of whether that particular
22
+ * composition ever actually needed to abort. Callers should call `dispose()`
23
+ * once the composed signal is no longer needed, whether or not it ever
24
+ * aborted.
25
+ * @param signals - the signals to combine; the composed signal aborts as soon as any one of them does
26
+ * @returns the composed signal (aborting with that input's abort reason as soon as any signal in `signals` aborts), and a `dispose` to release this composition's own resources
27
+ */
28
+ export function composeSignals(signals: readonly AbortSignal[]): ComposedSignal {
29
+ if (typeof AbortSignal.any === "function") {
30
+ // Expression-bodied no-op, not `() => {}` (an empty block body trips
31
+ // @typescript-eslint/no-empty-function): the native AbortSignal.any
32
+ // manages its own source-signal listeners internally and doesn't leak
33
+ // them, so there's nothing for this composition's own dispose to
34
+ // release.
35
+ return { signal: AbortSignal.any(signals as AbortSignal[]), dispose: () => undefined }
36
+ }
37
+ const controller = new AbortController()
38
+ const attached: { signal: AbortSignal; listener: () => void }[] = []
39
+ for (const signal of signals) {
40
+ if (signal.aborted) {
41
+ controller.abort(signal.reason)
42
+ break
43
+ }
44
+ const listener = (): void => {
45
+ controller.abort(signal.reason)
46
+ }
47
+ signal.addEventListener("abort", listener)
48
+ attached.push({ signal, listener })
49
+ }
50
+ return {
51
+ signal: controller.signal,
52
+ dispose: () => {
53
+ for (const { signal, listener } of attached) signal.removeEventListener("abort", listener)
54
+ },
55
+ }
56
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Runs `worker` over `items` with at most `concurrency` invocations in
3
+ * flight at once, preserving each result at its original index regardless
4
+ * of completion order. Has no knowledge of aborting, killing, or check
5
+ * evidence -- a pure, reusable bounded-parallelism primitive; abort-
6
+ * awareness lives entirely in the caller's `worker` function (see
7
+ * spawn-check.ts).
8
+ * @param items - the items to process, each passed to `worker` along with its index
9
+ * @param concurrency - the maximum number of `worker` calls allowed in flight at once
10
+ * @param worker - the async function run per item; its resolved value becomes that item's result
11
+ * @returns the results, one per item, in the same order as `items` regardless of completion order
12
+ */
13
+ export async function runWithConcurrency<T, R>(
14
+ items: readonly T[],
15
+ concurrency: number,
16
+ worker: (item: T, index: number) => Promise<R>,
17
+ ): Promise<R[]> {
18
+ const results: R[] = []
19
+ const entries = items.map((item, index) => [item, index] as const)
20
+ const iterator = entries[Symbol.iterator]()
21
+
22
+ // The first worker rejection is recorded and rethrown after every
23
+ // still-running sibling worker has settled, rather than let `Promise.all`
24
+ // reject eagerly while other `runNext` loops keep awaiting workers whose
25
+ // later rejections would then have no handler -- an `unhandledRejection`
26
+ // (fatal under Node's `--unhandled-rejections=throw`). The error itself is
27
+ // still propagated verbatim, matching how `dependency-scheduler.ts`
28
+ // forwards a worker rejection unwrapped.
29
+ let firstRejection: { readonly error: unknown } | undefined
30
+
31
+ /**
32
+ *
33
+ */
34
+ async function runNext(): Promise<void> {
35
+ for (let next = iterator.next(); !next.done; next = iterator.next()) {
36
+ if (firstRejection !== undefined) return
37
+ const [item, index] = next.value
38
+ try {
39
+ results[index] = await worker(item, index)
40
+ } catch (error) {
41
+ firstRejection ??= { error }
42
+ return
43
+ }
44
+ }
45
+ }
46
+
47
+ // `Math.max`/`Math.min` both propagate a `NaN` argument straight through
48
+ // (NaN poisons them), so a caller-supplied `concurrency` of `NaN` would
49
+ // otherwise survive as `workerCount`, and `Array.from({ length: NaN }, ...)`
50
+ // spec-clamps a NaN length to zero -- silently returning an empty result
51
+ // array for every item, with no error to explain why. `validateRepoContractConfig`
52
+ // already rejects a non-integer/NaN `concurrency` before it can reach this
53
+ // internal, non-exported function via the public API, but this guard
54
+ // defends the primitive itself rather than relying solely on that upstream promise.
55
+ const effectiveConcurrency = Number.isFinite(concurrency) ? concurrency : 1
56
+ const workerCount = Math.max(1, Math.min(effectiveConcurrency, items.length))
57
+ await Promise.all(Array.from({ length: workerCount }, () => runNext()))
58
+ if (firstRejection !== undefined) {
59
+ // Propagated verbatim, exactly as Promise.all would have surfaced it and
60
+ // as dependency-scheduler.ts forwards a worker rejection unwrapped.
61
+ throw firstRejection.error
62
+ }
63
+ return results
64
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Runs `worker` over `items` with at most `concurrency` invocations in
3
+ * flight at once, like `concurrency-pool.ts`'s `runWithConcurrency`, but
4
+ * additionally respecting a dependency graph: an item whose
5
+ * `dependencyIndexes` are non-empty is not started until every one of
6
+ * those indexes has settled. Has no knowledge of checks, evidence, or
7
+ * abort semantics -- a pure, reusable bounded-parallelism-plus-ordering
8
+ * primitive, kept in its own file rather than folded into
9
+ * `concurrency-pool.ts` so that file's simpler, more heavily-used flat
10
+ * primitive stays untouched.
11
+ *
12
+ * A reactive, event-driven scheduler (conceptually Kahn's algorithm run
13
+ * incrementally): a completing item immediately re-evaluates and unblocks
14
+ * its own dependents, with no artificial "wave" boundary -- a wave/layer
15
+ * design was considered and rejected because it would stall a dependent
16
+ * whose single dependency finished early behind an unrelated slow item
17
+ * sharing its layer, a real regression for a diamond or fan-out shape.
18
+ *
19
+ * The caller is responsible for ensuring `dependencyIndexes` describes an
20
+ * acyclic graph (see `validate-config.ts`'s cycle detection) -- this
21
+ * function's own stall guard below exists only as defense in depth against
22
+ * that invariant being violated, not as the primary means of catching it.
23
+ * @param items - the items to process, each passed to `worker` and `dependencyIndexes` along with its index
24
+ * @param concurrency - the maximum number of `worker` calls allowed in flight at once
25
+ * @param dependencyIndexes - given an item and its index, returns the indexes into `items` that must settle first
26
+ * @param worker - the async function run per item once its dependencies have settled; its resolved value becomes that item's result
27
+ * @returns the results, one per item, in the same order as `items` regardless of completion order
28
+ */
29
+ export async function runWithConcurrencyGraph<T, R>(
30
+ items: readonly T[],
31
+ concurrency: number,
32
+ dependencyIndexes: (item: T, index: number) => readonly number[],
33
+ worker: (item: T, index: number) => Promise<R>,
34
+ ): Promise<R[]> {
35
+ if (items.length === 0) return []
36
+
37
+ // `dependencyIndexes` is called exactly once per item and snapshotted --
38
+ // never invoked again below -- so the graph this function schedules
39
+ // against is fixed for the whole run, even if the callback isn't
40
+ // perfectly deterministic or does nontrivial work. Each item's own
41
+ // indexes are deduplicated defensively (a caller-supplied duplicate,
42
+ // e.g. `dependsOn: ["a", "a"]`, would otherwise double-decrement its
43
+ // dependent's `remaining` count -- harmless by coincidence today, but
44
+ // not a graph shape this primitive should silently rely on staying
45
+ // harmless).
46
+ const dependencies = items.map((item, index) =>
47
+ // An out-of-range index (`< 0`, `>= items.length`, or non-integer) is
48
+ // dropped here, at the single snapshot point, rather than carried into
49
+ // `remaining` below: it can never be decremented (nothing pushes the
50
+ // dependent onto a `dependents[outOfRange]` bucket that doesn't exist),
51
+ // so keeping it would leave `remaining[index]` permanently above zero
52
+ // and stall the whole run with a bogus "not acyclic" rejection. Dropping
53
+ // it is what "tolerates an out-of-range caller-supplied dependency
54
+ // index" (see dependency-scheduler.test.ts) actually means.
55
+ [...new Set(dependencyIndexes(item, index))].filter(
56
+ (depIndex) => Number.isInteger(depIndex) && depIndex >= 0 && depIndex < items.length,
57
+ ),
58
+ )
59
+ const remaining = dependencies.map((indexes) => indexes.length)
60
+ // Each entry's own initial value is unobservable regardless of its exact
61
+ // contents: confirmed empirically (diamond, fan-out, and no-dependency
62
+ // shapes) that seeding it with garbage produces identical scheduling
63
+ // results either way, since a stray non-index entry only ever causes a
64
+ // harmless, unused `remaining[...]` property to be created when
65
+ // processed, never affecting `ready` or the final results.
66
+ // Stryker disable next-line ArrayDeclaration -- each dependents[] entry's initial contents are unobservable regardless of value, confirmed empirically across diamond, fan-out, and no-dependency shapes that seeding it with garbage produces identical scheduling results.
67
+ const dependents: number[][] = items.map(() => [])
68
+ dependencies.forEach((indexes, index) => {
69
+ for (const depIndex of indexes) {
70
+ // `depIndex` is already guaranteed in-range: the snapshot above
71
+ // filters every out-of-range caller-supplied index out before this
72
+ // loop. The `?.` only satisfies `noUncheckedIndexedAccess` (`dependents`
73
+ // is built with exactly `items.length` entries, one per valid index),
74
+ // matching this function's other index guards -- see "tolerates an
75
+ // out-of-range caller-supplied dependency index" in
76
+ // dependency-scheduler.test.ts.
77
+ dependents[depIndex]?.push(index)
78
+ }
79
+ })
80
+
81
+ const ready: number[] = remaining.flatMap((count, index) => (count === 0 ? [index] : []))
82
+ // `Math.max`/`Math.min` both propagate a `NaN` argument straight through to
83
+ // their result (NaN poisons them), so a caller-supplied `concurrency` of
84
+ // `NaN` would otherwise survive `Math.max(1, concurrency)` below as `NaN`
85
+ // itself, making `active < effectiveConcurrency` always false and hanging
86
+ // this function's returned promise forever, with no worker ever launched
87
+ // and no error to explain why. `validateRepoContractConfig` already rejects
88
+ // a non-integer/NaN `concurrency` before it can reach this internal,
89
+ // non-exported function via the public API, but this guard defends the
90
+ // primitive itself rather than relying solely on that upstream promise.
91
+ const concurrencyIsUsable = Number.isFinite(concurrency)
92
+ // Pre-sizing here only matters while the run is in flight (so `results[index]
93
+ // = result` below never needs to extend the array); by the time this promise
94
+ // resolves, `settled === items.length` guarantees every index has already
95
+ // been assigned exactly once (see the property test "every item runs
96
+ // exactly once ..." in dependency-scheduler.property.test.ts), which turns
97
+ // any initially-sparse array just as dense as a pre-sized one -- the two are
98
+ // indistinguishable in the returned result. Confirmed empirically: an
99
+ // unexempted run showed the `ObjectLiteral` replacement (`{}`, i.e. starting
100
+ // from a zero-length array instead) survives.
101
+ // Stryker disable next-line ObjectLiteral -- pre-sizing only matters while the run is in flight; by completion every index is guaranteed filled exactly once, so a sparse vs. dense start is indistinguishable in the returned result, confirmed empirically that the unexempted {} mutant survives.
102
+ const results: R[] = Array.from({ length: items.length })
103
+ const effectiveConcurrency = concurrencyIsUsable ? Math.max(1, concurrency) : 1
104
+ let active = 0
105
+ let settled = 0
106
+ let done = false
107
+
108
+ return new Promise<R[]>((resolvePromise, rejectPromise) => {
109
+ // `fail` is a hoisted function declaration, usable below before its
110
+ // textual definition.
111
+ // A graph where *every* item has at least one dependency (the whole
112
+ // graph forms a cycle, not just part of it) never launches a single
113
+ // worker -- the post-settlement stall check below can only ever run
114
+ // once at least one worker has settled, so this covers the one stall
115
+ // shape that check cannot: a stall from the very start.
116
+ if (ready.length === 0) {
117
+ fail(
118
+ new Error(
119
+ "runWithConcurrencyGraph: stalled before starting -- the dependency graph passed " +
120
+ "in is not acyclic.",
121
+ ),
122
+ )
123
+ return
124
+ }
125
+
126
+ /**
127
+ *
128
+ * @param error - the error to reject the returned promise with, propagated verbatim (unwrapped)
129
+ */
130
+ function fail(error: unknown): void {
131
+ // A second call after the promise has already settled is harmless
132
+ // even without this guard: native Promise resolution is idempotent,
133
+ // so a later rejectPromise call would have no observable effect on
134
+ // the promise's own outcome regardless -- kept for clarity/intent,
135
+ // not because it changes behavior.
136
+ // Stryker disable next-line ConditionalExpression -- native Promise resolution is idempotent, so a second rejectPromise call after settling has no observable effect either way; kept only for clarity/intent, not because it changes behavior.
137
+ if (done) return
138
+ done = true
139
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- propagated verbatim, not wrapped: matches runWithConcurrency's own existing behavior (a worker rejection passes straight through Promise.all unmodified), which this function is meant to be a drop-in graph-aware replacement for.
140
+ rejectPromise(error)
141
+ }
142
+
143
+ // launchNext is only ever called (both its initial call below and its
144
+ // own recursive call inside .then()) at a point already known to have
145
+ // done === false -- every call site is guarded by a done check before
146
+ // reaching it -- so an equivalent internal guard here was removed
147
+ // rather than kept only to be marked equivalent.
148
+ /**
149
+ *
150
+ */
151
+ function launchNext(): void {
152
+ // Loosening `ready.length > 0` (e.g. to always-true) is behaviorally
153
+ // invisible: the loop would simply enter once more with `ready`
154
+ // genuinely empty, `ready.shift()` would return `undefined` (a safe
155
+ // no-op on an empty array), and the very next line's `index ===
156
+ // undefined` check -- itself unmutatable for the same reason --
157
+ // immediately breaks out, identically to the condition correctly
158
+ // stopping the loop one iteration earlier. `ready.length > 0` also
159
+ // already guarantees `.shift()` returns a real index, and every
160
+ // index this function ever pushes onto `ready` is a valid index into
161
+ // `items`/`dependents`/`remaining` (all built with exactly
162
+ // `items.length` entries) -- these guards are kept only because
163
+ // `noUncheckedIndexedAccess` can't itself express any of these
164
+ // invariants, confirmed equivalent by exhaustive differential
165
+ // testing, not assumed.
166
+ // Stryker disable ConditionalExpression,EqualityOperator,LogicalOperator,BlockStatement -- loosening the while condition or its guard checks to always-true is behaviorally invisible: the loop just runs one harmless extra iteration where ready.shift() returns undefined and the immediately-following index === undefined check breaks back out, and every index ever pushed onto ready is already guaranteed valid so the equivalent item === undefined guard is confirmed equivalent by exhaustive differential testing, not assumed.
167
+ while (active < effectiveConcurrency && ready.length > 0) {
168
+ const index = ready.shift()
169
+ if (index === undefined) break
170
+ const item = items[index]
171
+ if (item === undefined) continue
172
+ // Stryker restore all
173
+ active += 1
174
+ worker(item, index)
175
+ .then((result) => {
176
+ results[index] = result
177
+ active -= 1
178
+ settled += 1
179
+ // `dependents[index]` can never actually be undefined -- `dependents`
180
+ // is built above with exactly `items.length` entries, one per valid
181
+ // index, same invariant as `launchNext`'s own guards. Confirmed
182
+ // empirically, not just by analogy: un-exempting this mutant and
183
+ // running Stryker scoped to this file showed the `?? []` fallback's
184
+ // own content (`ArrayDeclaration`) is NoCoverage -- the fallback
185
+ // branch never executes at all. The surrounding `BlockStatement`/
186
+ // `LogicalOperator` mutants on this same line are not exempted:
187
+ // confirmed Killed under the same run, so they stay live.
188
+ // Stryker disable next-line ArrayDeclaration -- dependents[index] can never actually be undefined (dependents is built with exactly items.length entries, one per valid index), confirmed empirically -- not just by analogy -- that un-exempting this mutant and running Stryker scoped to this file shows the ?? [] fallback's own ArrayDeclaration content is NoCoverage, i.e. the fallback branch never executes.
189
+ for (const dependentIndex of dependents[index] ?? []) {
190
+ const nextRemaining = (remaining[dependentIndex] ?? 0) - 1
191
+ remaining[dependentIndex] = nextRemaining
192
+ if (nextRemaining === 0) ready.push(dependentIndex)
193
+ }
194
+ if (done) return
195
+ if (settled === items.length) {
196
+ resolvePromise(results)
197
+ return
198
+ }
199
+ if (active === 0 && ready.length === 0) {
200
+ fail(
201
+ new Error(
202
+ "runWithConcurrencyGraph: stalled with unsettled items remaining -- the " +
203
+ "dependency graph passed in is not acyclic.",
204
+ ),
205
+ )
206
+ return
207
+ }
208
+ launchNext()
209
+ })
210
+ .catch(fail)
211
+ }
212
+ }
213
+
214
+ launchNext()
215
+ })
216
+ }
@@ -0,0 +1,107 @@
1
+ import { spawnSync } from "node:child_process"
2
+
3
+ /**
4
+ * Whether a check's process should be spawned with `detached: true`. On
5
+ * POSIX this makes the spawned process the leader of a new process group
6
+ * (sharing its own pid as the group id), which is what lets `killTree`
7
+ * target the whole group rather than just the immediate child. On Windows,
8
+ * process groups work differently and `detached: true` would instead launch
9
+ * the process in its own console window -- not what's wanted here, since
10
+ * Windows cleanup goes through `taskkill /t` instead (see `killTree`).
11
+ * @returns true on POSIX (spawn as its own process group leader, killable via `killTree`); false on Windows
12
+ */
13
+ export function shouldSpawnDetached(): boolean {
14
+ return process.platform !== "win32"
15
+ }
16
+
17
+ /**
18
+ *
19
+ * @param error - the caught value to narrow
20
+ * @returns true if `error` is an `Error` carrying a `code` property, as Node's errno exceptions do
21
+ */
22
+ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
23
+ return error instanceof Error && "code" in error
24
+ }
25
+
26
+ /**
27
+ * Best-effort termination of an entire process tree rooted at `pid`, not
28
+ * just the immediate process -- necessary because a check's command is
29
+ * often itself a wrapper (`npm run test` spawns `npm`, which spawns the
30
+ * actual test runner), and killing only the wrapper would orphan its
31
+ * descendants. `spawn`'s own `timeout`/`signal` options only ever affect the
32
+ * directly spawned process, never its descendants, which is why this exists
33
+ * as a separate utility rather than relying on those.
34
+ *
35
+ * On POSIX, sends `signal` to the whole process group via the negative-pid
36
+ * convention (requires the process to have been spawned with
37
+ * `detached: true`, see `shouldSpawnDetached`). On Windows, process groups
38
+ * don't work the same way, so this shells out to `taskkill /pid <pid> /t
39
+ * /f` -- the same technique the `tree-kill` package uses internally -- which
40
+ * walks the system process table for descendants of `pid` regardless of how
41
+ * it was spawned.
42
+ *
43
+ * Swallows the expected best-effort-cleanup failures, but not every failure: a
44
+ * process that has already exited (POSIX `ESRCH`, or `taskkill`'s "not found"
45
+ * case) is a no-op, and a POSIX `EPERM` permission error is swallowed the same
46
+ * way -- by the time cleanup runs the process may well have exited on its own,
47
+ * and a failed cleanup should not crash the run. Any *other* failure is
48
+ * rethrown: an unexpected POSIX errno, or (on Windows) a JS-level spawn failure
49
+ * of `taskkill` itself (`result.error`, e.g. the tool missing from PATH), which
50
+ * would otherwise silently leave an orphaned process tree behind.
51
+ *
52
+ * On Windows this always runs `taskkill` with `/f` regardless of which `signal` was requested --
53
+ * not a partial implementation of POSIX's cooperative-SIGTERM-then-SIGKILL escalation, but a
54
+ * reflection of a real platform difference: Windows has no signal-delivery mechanism for an
55
+ * arbitrary process tree by pid at all (this is also why Node's own `ChildProcess.kill()` treats
56
+ * every signal identically on Windows, per Node's own child_process documentation), so there is no
57
+ * more-cooperative alternative to fall back to here the way there is on POSIX.
58
+ * @param pid - the pid of the tree's root process (the process group id on POSIX, since it was spawned detached)
59
+ * @param signal - the POSIX signal to send (on Windows, ignored -- see doc comment above)
60
+ */
61
+ export function killTree(pid: number, signal: NodeJS.Signals): void {
62
+ // `process.kill(-pid, ...)` signals a process *group*, and `-0` coerces to
63
+ // `0`, which POSIX interprets as "every process in the caller's own group"
64
+ // -- i.e. this would signal the repo-contract host itself. `taskkill /pid
65
+ // 0` is likewise not a real target. A pid of `0` (or negative, or
66
+ // non-integer) is never a real child here; spawn-check.ts guards its own
67
+ // kill calls with `child.pid !== undefined`, but that admits `0`. Refuse
68
+ // it rather than turn a best-effort cleanup call into self-harm.
69
+ if (!Number.isInteger(pid) || pid <= 0) {
70
+ return
71
+ }
72
+
73
+ /* v8 ignore start -- Windows-only; exercised for real by
74
+ * test/unit/cross-platform/windows-taskkill.test.ts on the CI Windows runner
75
+ * (see vitest.config.ts's coverage thresholds for the rationale -- this
76
+ * branch cannot run on the OS any other CI job/local dev uses). Mutation
77
+ * testing only runs in the ubuntu-only `contract` CI job (see
78
+ * .github/workflows/ci.yml), so this branch is never covered there either
79
+ * -- disabled for the same reason, not left to inflate "no coverage"
80
+ * counts against a branch that genuinely is tested, just on a different
81
+ * platform than the one that runs Stryker. */
82
+ // Stryker disable ConditionalExpression,EqualityOperator,StringLiteral,ArrayDeclaration,ObjectLiteral,BlockStatement,CallExpression -- this whole branch only runs on Windows, and mutation testing only runs in the ubuntu-only CI job (see the v8-ignore comment above), so every mutator that could apply to it would surface as an unreachable "no coverage" survivor rather than a real test gap.
83
+ if (process.platform === "win32") {
84
+ // eslint-disable-next-line n/no-sync -- `killTree` is itself synchronous best-effort cleanup, callable from error/signal-handling paths that don't await anything else; an async `spawn` here would need every caller threaded through `await` for what's already a fire-and-forget `taskkill`.
85
+ const result = spawnSync("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore" })
86
+ // `result.error` is a JS-level spawn failure (e.g. ENOENT if `taskkill.exe` itself is
87
+ // missing from PATH, or EPERM from a policy blocking process creation) -- distinct from
88
+ // `result.status`, taskkill's own process-table-dependent exit code for "pid not found" vs.
89
+ // other in-process failures, which isn't documented precisely enough to safely distinguish
90
+ // an expected no-op from a genuine bug the same way the POSIX branch's ESRCH/EPERM check
91
+ // does. Rethrowing only `result.error` still closes the most likely silent-orphan gap (the
92
+ // tool being unavailable at all) without risking a false rethrow on Windows's legitimate
93
+ // "already exited" no-op case, which this file's own test (`windows-taskkill.test.ts`, "is a
94
+ // no-op, not a throw, for a PID taskkill cannot find") pins as a passing case.
95
+ if (result.error !== undefined) throw result.error
96
+ return
97
+ }
98
+ // Stryker restore all
99
+ /* v8 ignore stop */
100
+
101
+ try {
102
+ process.kill(-pid, signal)
103
+ } catch (error) {
104
+ if (isErrnoException(error) && (error.code === "ESRCH" || error.code === "EPERM")) return
105
+ throw error
106
+ }
107
+ }