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,494 @@
1
+ import crossSpawn from "cross-spawn"
2
+ import { StringDecoder } from "node:string_decoder"
3
+ import { tokenizeRunString } from "../config/tokenize-command.js"
4
+ import { InvalidCheckConfigError } from "../errors.js"
5
+ import type { CheckDefinition, CheckEvidence, CheckStatus } from "../types.js"
6
+ import { composeSignals } from "./abort-signals.js"
7
+ import { killTree, shouldSpawnDetached } from "./process-tree.js"
8
+
9
+ // A configured check's command is repository-controlled, not attacker-input
10
+ // in the usual sense, but its OUTPUT is whatever that command chooses to
11
+ // print -- a misbehaving or compromised tool can still write without bound.
12
+ // Every check's stdout/stderr is retained in full for the life of the
13
+ // process (concatenated into a JS string) and then persisted verbatim into
14
+ // evidence/history.json forever, so leaving this uncapped is an unbounded
15
+ // memory and disk sink, not just a cosmetic concern. None of this
16
+ // repository's own checks rely on more than a few hundred KB of stdout
17
+ // content (the ones that produce large structured output write it to a
18
+ // `reports/*.json` file via `--output` instead -- see securitySecrets and
19
+ // arethetypeswrong's own run arrays -- precisely so stdout stays small); 10
20
+ // MiB per stream is generous headroom above that while still bounding a
21
+ // pathological or malicious command.
22
+ const MAX_CAPTURED_OUTPUT_BYTES = 10 * 1024 * 1024
23
+
24
+ // SIGTERM asks a process tree to exit cooperatively; nothing obliges it to.
25
+ // A check that traps or ignores SIGTERM (or a detached descendant that
26
+ // never receives it at all) would otherwise hang this promise -- and with
27
+ // it, the whole run -- forever, silently defeating both `timeoutMs` and a
28
+ // host SIGINT/SIGTERM. 2 seconds is generous for the short-lived CLI tools
29
+ // (linters, test runners, formatters) this library spawns to flush output
30
+ // and exit; it is not meant to accommodate a long-running service's graceful
31
+ // shutdown. Exported so run-checks.ts's own host-signal handler can wait out
32
+ // this same window before letting the host process itself exit -- otherwise
33
+ // the escalation this constant times would never get a chance to fire.
34
+ export const SIGKILL_GRACE_PERIOD_MS = 2000
35
+
36
+ /**
37
+ * Accumulates a child process's stdout or stderr up to
38
+ * `MAX_CAPTURED_OUTPUT_BYTES`, appending a truncation marker and discarding
39
+ * further chunks once the cap is hit -- the process itself keeps running to
40
+ * its real exit, only the retained text is bounded.
41
+ *
42
+ * Decodes with a stateful `StringDecoder` rather than `chunk.toString("utf8")` per chunk: Node
43
+ * delivers `"data"` events at arbitrary byte boundaries that don't respect UTF-8 character
44
+ * boundaries, so a multi-byte character split across two chunks would otherwise decode to a
45
+ * replacement character on both sides of the split -- `StringDecoder` buffers a trailing partial
46
+ * sequence internally and completes it once the rest arrives. The cap itself is tracked in real
47
+ * bytes fed in (`chunk.byteLength`), not `value.length` (UTF-16 code units) -- for 3-byte-per-
48
+ * character UTF-8 text, code-unit length undercounts actual bytes 3:1, which would otherwise let
49
+ * up to ~3x the documented byte cap accumulate before truncation fires.
50
+ * @returns an `append`/`value` pair: feed it chunks as they arrive, read the (possibly truncated) accumulated text once the process ends
51
+ */
52
+ function createBoundedCollector(): { append(chunk: Buffer): void; value(): string } {
53
+ // Node's own encoding normalization treats a falsy/unrecognized encoding argument as "utf8"
54
+ // (confirmed empirically: `new StringDecoder("").encoding === "utf8"`, and it decodes identically
55
+ // to an explicit `new StringDecoder("utf8")`, including for a multi-byte character split across
56
+ // writes) -- this is the only encoding this collector is ever used with, so no other string value
57
+ // is reachable here to distinguish "utf8" from any other literal.
58
+ // Stryker disable next-line StringLiteral -- Node's own encoding normalization treats a falsy/unrecognized encoding argument as "utf8" (confirmed empirically: `new StringDecoder("").encoding === "utf8"`, decoding identically to an explicit "utf8", including for a multi-byte character split across writes); this is the only encoding this collector is ever used with, so no other string value is reachable here to distinguish it.
59
+ const decoder = new StringDecoder("utf8")
60
+ let value = ""
61
+ let byteCount = 0
62
+ let truncated = false
63
+ return {
64
+ append(chunk) {
65
+ // Once truncated, `value`'s retained prefix (positions 0..CAP-1) can
66
+ // never change again no matter what further chunks arrive -- slicing
67
+ // to the same CAP from a longer string always yields the same prefix
68
+ // it already had. So skipping further chunks here is unobservable
69
+ // from the *content* `value()` ever returns; what it actually saves
70
+ // is the repeated O(cap) slice+concat this function would otherwise
71
+ // redo on every single subsequent chunk for a process that keeps
72
+ // writing past the cap -- real, unbounded-with-chunk-count CPU work a
73
+ // pathological or malicious command could otherwise force. No
74
+ // deterministic, non-flaky test can observe that difference (only
75
+ // timing can, which this project's own mutation policy already
76
+ // refuses to treat as a real kill -- see checks/mutation.ts's
77
+ // "Timed out" handling).
78
+ // Stryker disable next-line ConditionalExpression -- removing this guard has no effect on the content `value()` ever returns (see comment above); it only removes an unbounded-with-chunk-count amount of wasted repeated work for a process that keeps writing past the cap, which no deterministic test can observe without relying on flaky timing.
79
+ if (truncated) return
80
+ byteCount += chunk.byteLength
81
+ value += decoder.write(chunk)
82
+ if (byteCount > MAX_CAPTURED_OUTPUT_BYTES) {
83
+ // Same reasoning as the `if (truncated) return` guard above: leaving
84
+ // this `false` never changes any content `value()` returns, only
85
+ // how much repeated, wasted slice+concat work later chunks cause.
86
+ // Stryker disable next-line BooleanLiteral -- leaving this false has no effect on returned content (see comment above and the identical reasoning on the `if (truncated) return` guard); it only removes an unbounded-with-chunk-count amount of wasted repeated work, which no deterministic test can observe without relying on flaky timing.
87
+ truncated = true
88
+ value = `${value.slice(0, MAX_CAPTURED_OUTPUT_BYTES)}\n...[output truncated at ${String(MAX_CAPTURED_OUTPUT_BYTES)} bytes]`
89
+ }
90
+ },
91
+ value: () => value,
92
+ }
93
+ }
94
+
95
+ /** Handle a caller can use to forcibly terminate one in-flight check's process tree, independent of that check's own timeout/abort wiring -- used by run-checks.ts to clean up every active check when the host process itself receives SIGINT/SIGTERM. */
96
+ export interface ActiveCheckHandle {
97
+ kill(signal: NodeJS.Signals): void
98
+ }
99
+
100
+ /**
101
+ *
102
+ * @param checkId - the check's id, used only to attach context to a thrown `InvalidCheckConfigError`
103
+ * @param check - the check definition whose `run` (and `shell`) determines the command and args to execute
104
+ * @returns the resolved `command` and `args` to spawn, with `args` empty when `run` is a shell string
105
+ */
106
+ function resolveCommand(
107
+ checkId: string,
108
+ check: CheckDefinition,
109
+ ): { command: string; args: readonly string[] } {
110
+ const run = check.run
111
+ // `typeof run === "string"` narrows this two-member union reliably;
112
+ // `Array.isArray` does not narrow a `readonly T[]` union member the same
113
+ // way it narrows a mutable `T[]` one.
114
+ if (typeof run !== "string") {
115
+ const [command, ...args] = run
116
+ // validate-config.ts already rejects an empty run array before this
117
+ // function is ever reached in the normal runRepoContract pipeline; this
118
+ // check re-asserts that same invariant defensively at this function's
119
+ // own boundary, since resolveCommand has no compile-time guarantee of
120
+ // it if called some other way (e.g. directly from a test).
121
+ if (command === undefined) {
122
+ throw new InvalidCheckConfigError(checkId, "run array must not be empty.")
123
+ }
124
+ return { command, args }
125
+ }
126
+ if (check.shell === true) {
127
+ // Passed to the platform shell as a single command line; cross-spawn's
128
+ // own `shell` option handles the platform-specific invocation. There is
129
+ // no meaningful separate argv to report, so the whole string is
130
+ // recorded as `command` with empty `args`.
131
+ return { command: run, args: [] }
132
+ }
133
+ const [command, ...args] = tokenizeRunString(run, checkId)
134
+ // tokenizeRunString already rejects an empty/whitespace-only string, so
135
+ // this is unreachable in practice -- kept for the same reason as above.
136
+ // Unlike the array-form check above, this one has no way to be exercised
137
+ // directly (tokenizeRunString's own contract guarantees a non-empty
138
+ // result or a throw, with no way to bypass it short of calling this
139
+ // private function directly, which isn't exported).
140
+ // Stryker disable EqualityOperator,ConditionalExpression,BlockStatement,StringLiteral,CallExpression -- unlike the array-form check above, this one has no way to be exercised directly: tokenizeRunString's own contract guarantees a non-empty result or a throw, with no way to bypass it short of calling this private function directly, which isn't exported.
141
+ if (command === undefined) {
142
+ throw new InvalidCheckConfigError(checkId, "run string is empty or contains only whitespace.")
143
+ }
144
+ // Stryker restore all
145
+ return { command, args }
146
+ }
147
+
148
+ /**
149
+ *
150
+ * @param check - the check definition; unless `inheritEnv` is explicitly `false`, this process's own env is inherited and then overlaid with `check.env`
151
+ * @returns the environment variables to pass to the spawned process
152
+ */
153
+ function buildEnv(check: CheckDefinition): Record<string, string> {
154
+ const base: Record<string, string> = {}
155
+ if (check.inheritEnv !== false) {
156
+ // eslint-disable-next-line n/no-process-env -- reading the ambient environment isn't a config smell here, it's the feature: `inheritEnv` (on by default) means the spawned check inherits this process's real env, exactly what this loop builds.
157
+ for (const [key, value] of Object.entries(process.env)) {
158
+ // `process.env`'s index signature is typed `string | undefined`, but
159
+ // a real Node process never actually produces an `undefined` value
160
+ // here for an own enumerable key -- assigning `undefined` to an env
161
+ // var coerces it to the literal string `"undefined"` (confirmed
162
+ // empirically). Kept only to satisfy that type, not because it
163
+ // changes observed behavior.
164
+ // Stryker disable next-line ConditionalExpression -- process.env's index signature is typed string | undefined, but a real Node process never actually produces an undefined value here for an own enumerable key; kept only to satisfy that type, not because it changes observed behavior.
165
+ if (value !== undefined) base[key] = value
166
+ }
167
+ }
168
+ return { ...base, ...check.env }
169
+ }
170
+
171
+ /**
172
+ *
173
+ * @param command - the resolved executable that was (or would have been) run
174
+ * @param args - the resolved argument list passed to `command`
175
+ * @param startedAt - when the check began running; used with the current time to compute `durationMs`
176
+ * @param status - the terminal status to record (e.g. "completed", "timed_out", "aborted", "spawn_error")
177
+ * @param exitCode - the process's exit code, or null if it never exited normally
178
+ * @param signal - the signal that terminated the process, or null if it exited normally or never spawned
179
+ * @param stdout - the process's captured stdout, if any was produced before it ended
180
+ * @param stderr - the process's captured stderr, if any was produced before it ended
181
+ * @param spawnError - the underlying spawn error message, present only when `status` is "spawn_error"
182
+ * @param spawnErrorCode - the underlying spawn error's structured `ErrnoException.code`, present only when `status` is "spawn_error" and Node provided one
183
+ * @returns a fully-formed `CheckEvidence`, with `completedAt`/`durationMs` computed from `startedAt` to now
184
+ */
185
+ function terminalEvidence(
186
+ command: string,
187
+ args: readonly string[],
188
+ startedAt: Date,
189
+ status: CheckStatus,
190
+ exitCode: number | null,
191
+ signal: NodeJS.Signals | null,
192
+ stdout = "",
193
+ stderr = "",
194
+ spawnError?: string,
195
+ spawnErrorCode?: string,
196
+ ): CheckEvidence {
197
+ const completedAt = new Date()
198
+ return {
199
+ command,
200
+ args,
201
+ startedAt: startedAt.toISOString(),
202
+ completedAt: completedAt.toISOString(),
203
+ durationMs: completedAt.getTime() - startedAt.getTime(),
204
+ exitCode,
205
+ signal,
206
+ stdout,
207
+ stderr,
208
+ status,
209
+ ...(spawnError !== undefined ? { spawnError } : {}),
210
+ ...(spawnErrorCode !== undefined ? { spawnErrorCode } : {}),
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Runs one configured check end to end: resolves its command, spawns it,
216
+ * enforces its timeout (if any), reacts to the run-level `AbortSignal` (if
217
+ * any), captures stdout/stderr, and resolves to a fully-formed
218
+ * `CheckEvidence` no matter how the process ended -- this function never
219
+ * rejects. `activeHandles` is a shared registry the caller (run-checks.ts)
220
+ * uses to kill every in-flight check on a host-process SIGINT/SIGTERM; this
221
+ * function adds its own handle once the process has a pid and removes it
222
+ * once the process has settled.
223
+ *
224
+ * A check whose `runSignal` is already aborted before this function is even
225
+ * invoked (queued behind the concurrency limit when the run was cancelled)
226
+ * never spawns at all, but still resolves to a well-formed `status:
227
+ * "aborted"` evidence entry -- every configured check gets evidence and a
228
+ * policy invocation regardless of whether it ever ran (see
229
+ * specs/architecture.md).
230
+ * @param checkId - the check's id, used for logging and passed through to `resolveCommand`
231
+ * @param check - the check definition to run (command, timeout, env, cwd, shell, etc.)
232
+ * @param runSignal - the whole run's abort signal, if any; already-aborted before this is called means the check never spawns
233
+ * @param activeHandles - the shared registry this check's kill handle is added to while running, so a host-process SIGINT/SIGTERM can terminate it
234
+ * @returns a fully-formed `CheckEvidence` reflecting however the process ended; this function itself never rejects
235
+ */
236
+ export async function spawnCheck(
237
+ checkId: string,
238
+ check: CheckDefinition,
239
+ runSignal: AbortSignal | undefined,
240
+ activeHandles: Set<ActiveCheckHandle>,
241
+ ): Promise<CheckEvidence> {
242
+ const startedAt = new Date()
243
+ const { command, args } = resolveCommand(checkId, check)
244
+
245
+ if (runSignal?.aborted === true) {
246
+ return terminalEvidence(command, args, startedAt, "aborted", null, null)
247
+ }
248
+
249
+ const env = buildEnv(check)
250
+
251
+ let terminationReason: "aborted" | "timed_out" | null = null
252
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined
253
+ const timeoutController = new AbortController()
254
+ if (check.timeoutMs !== undefined) {
255
+ // `setTimeout` truncates any delay above the 32-bit signed limit to 1ms
256
+ // (with a `TimeoutOverflowWarning`), so a caller asking for a genuinely
257
+ // long timeout -- e.g. `timeoutMs: 2_592_000_000` (30 days) -- would
258
+ // otherwise fire the abort almost immediately and record `status:
259
+ // "timed_out"` milliseconds after spawn. Clamp to the maximum delay
260
+ // `setTimeout` can actually represent; anything at or above it is, in
261
+ // practice, "effectively no timeout".
262
+ const MAX_TIMER_DELAY_MS = 2_147_483_647
263
+ const delay = Math.min(check.timeoutMs, MAX_TIMER_DELAY_MS)
264
+ timeoutHandle = setTimeout(() => {
265
+ timeoutController.abort()
266
+ }, delay)
267
+ }
268
+
269
+ const { signal: effectiveSignal, dispose: disposeEffectiveSignal } = composeSignals(
270
+ runSignal !== undefined ? [runSignal, timeoutController.signal] : [timeoutController.signal],
271
+ )
272
+
273
+ return new Promise<CheckEvidence>((resolve) => {
274
+ const child = crossSpawn(command, args, {
275
+ cwd: check.cwd,
276
+ env,
277
+ shell: check.shell === true,
278
+ detached: shouldSpawnDetached(),
279
+ // Windows-only cosmetic behavior (suppresses a console window flash)
280
+ // with no effect on stdout/stderr/exitCode/signal on any platform, and
281
+ // no Node API exposes it back for a test to observe either way.
282
+ // Stryker disable next-line BooleanLiteral -- Windows-only cosmetic behavior (suppresses a console window flash) with no effect on stdout/stderr/exitCode/signal on any platform, and no Node API exposes it back for a test to observe either way.
283
+ windowsHide: true,
284
+ })
285
+
286
+ const stdoutCollector = createBoundedCollector()
287
+ const stderrCollector = createBoundedCollector()
288
+ let handle: ActiveCheckHandle | undefined
289
+ let escalationHandle: ReturnType<typeof setTimeout> | undefined
290
+ let hostTerminated = false
291
+
292
+ /**
293
+ * Best-effort: swallows whatever `killTree` throws rather than letting it
294
+ * escape. `killTree` deliberately rethrows a genuinely unexpected errno
295
+ * (see process-tree.test.ts) so a real bug there isn't silently hidden
296
+ * from a caller equipped to handle it -- but `killWithEscalation` is
297
+ * invoked from inside an `AbortSignal` "abort" listener and, via
298
+ * `ActiveCheckHandle.kill`, a `process.once(signal, ...)` handler in
299
+ * run-checks.ts, and an exception escaping either of those crashes the
300
+ * whole host process instead of merely failing this one check's cleanup.
301
+ * @param pid - the root pid of the process tree to kill
302
+ * @param signal - the signal to send
303
+ */
304
+ const bestEffortKillTree = (pid: number, signal: NodeJS.Signals): void => {
305
+ try {
306
+ killTree(pid, signal)
307
+ } catch {
308
+ // best-effort; see doc comment above
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Kills the tree with `signal`, then schedules a SIGKILL follow-up
314
+ * unless `signal` already was SIGKILL -- see `SIGKILL_GRACE_PERIOD_MS`.
315
+ * A prior pending escalation is replaced, not stacked, since only the
316
+ * most recent kill's grace period should apply.
317
+ * @param pid - the root pid of the process tree to kill, forwarded to `killTree`
318
+ * @param signal - the signal to send now; anything other than SIGKILL schedules a SIGKILL follow-up if the tree hasn't exited by then
319
+ */
320
+ const killWithEscalation = (pid: number, signal: NodeJS.Signals): void => {
321
+ bestEffortKillTree(pid, signal)
322
+ clearTimeout(escalationHandle)
323
+ if (signal === "SIGKILL") return
324
+ escalationHandle = setTimeout(() => {
325
+ bestEffortKillTree(pid, "SIGKILL")
326
+ }, SIGKILL_GRACE_PERIOD_MS)
327
+ }
328
+
329
+ if (child.pid !== undefined) {
330
+ const pid = child.pid
331
+ handle = {
332
+ kill: (signal) => {
333
+ // `ActiveCheckHandle.kill` is invoked only by run-checks.ts's host-process
334
+ // SIGINT/SIGTERM cleanup (see that interface's own doc comment) -- repo-contract itself
335
+ // is requesting this signal, just not via `options.signal`/`timeoutMs`, so the eventual
336
+ // "close" status must not fall through to `"signaled"` (reserved for a signal
337
+ // repo-contract did *not* request) the way it would if `terminationReason` were left
338
+ // unset here too.
339
+ hostTerminated = true
340
+ killWithEscalation(pid, signal)
341
+ },
342
+ }
343
+ activeHandles.add(handle)
344
+ }
345
+
346
+ // `child.stdout`/`child.stderr` are typed `Readable | null` (Node is
347
+ // only ever `null` when `stdio` overrides that stream to something
348
+ // other than "pipe"), but this call site never passes a `stdio` option,
349
+ // so both are always real streams in practice -- confirmed empirically,
350
+ // including for a spawn that fails outright (ENOENT). Kept only to
351
+ // satisfy the type, not because it changes observed behavior.
352
+ // Stryker disable next-line OptionalChaining -- child.stdout is typed Readable | null (Node is only ever null when stdio overrides that stream to something other than "pipe"), but this call site never passes a stdio option, so it's always a real stream in practice, confirmed empirically including for a spawn that fails outright (ENOENT).
353
+ child.stdout?.on("data", (chunk: Buffer) => {
354
+ stdoutCollector.append(chunk)
355
+ })
356
+ // Same reasoning as the stdout guard above (which already covers
357
+ // "both"), confirmed empirically here too: un-exempting this mutant and
358
+ // running Stryker scoped to this file showed `OptionalChaining`'s
359
+ // `child.stderr.on` replacement survives.
360
+ // Stryker disable next-line OptionalChaining -- same reasoning as the stdout guard above (which already covers "both"), confirmed empirically here too: un-exempting this mutant and running Stryker scoped to this file showed OptionalChaining's child.stderr.on replacement survives.
361
+ child.stderr?.on("data", (chunk: Buffer) => {
362
+ stderrCollector.append(chunk)
363
+ })
364
+
365
+ const onEffectiveAbort = (): void => {
366
+ terminationReason = runSignal?.aborted === true ? "aborted" : "timed_out"
367
+ // Deliberately NOT simplified to an unconditional `killTree` call: if
368
+ // `child.pid` were ever genuinely undefined here (spawn failed but
369
+ // "error" hasn't been reported yet), calling `killTree(undefined,
370
+ // ...)` would throw synchronously inside this AbortSignal listener,
371
+ // which Node reschedules onto `process.nextTick` as an *uncaught*
372
+ // exception -- verified directly, not assumed -- which would crash
373
+ // the whole test process rather than fail one assertion, making this
374
+ // guard's absence unsafe to exercise via a real test.
375
+ // The ConditionalExpression guard is covered by the rationale just
376
+ // above. The "SIGTERM" string literal is separately unkillable:
377
+ // confirmed empirically that Node's own process.kill() normalizes a
378
+ // falsy/empty signal argument back to SIGTERM, so the OS-observed
379
+ // signal is identical either way.
380
+ // Stryker disable next-line EqualityOperator,ConditionalExpression,StringLiteral,CallExpression -- if child.pid were ever genuinely undefined here, calling killWithEscalation(undefined, ...) would throw synchronously inside this AbortSignal listener, which Node reschedules onto process.nextTick as an uncaught exception, crashing the whole test process rather than failing one assertion; the "SIGTERM" string literal is separately unkillable since Node's own process.kill() normalizes a falsy/empty signal argument back to SIGTERM, so the OS-observed signal is identical either way.
381
+ if (child.pid !== undefined) killWithEscalation(child.pid, "SIGTERM")
382
+ }
383
+ // `effectiveSignal` fires "abort" at most once in its lifetime (an
384
+ // AbortSignal cannot transition from aborted back to unaborted, so it
385
+ // can never fire "abort" a second time) and is discarded once this
386
+ // function resolves, so `{ once: true }` is provably redundant --
387
+ // omitted rather than kept only to be marked equivalent.
388
+ effectiveSignal.addEventListener("abort", onEffectiveAbort)
389
+
390
+ const cleanup = (): void => {
391
+ // `clearTimeout` silently no-ops for `undefined` (confirmed
392
+ // empirically), so the `timeoutHandle !== undefined` guard that used
393
+ // to wrap this call was provably redundant -- omitted rather than
394
+ // kept only to be marked equivalent. Same reasoning covers
395
+ // `escalationHandle`: a check that exited before any kill was ever
396
+ // issued leaves it `undefined`, and a check that died from the
397
+ // initial signal (the common case) never lets its escalation fire.
398
+ clearTimeout(timeoutHandle)
399
+ clearTimeout(escalationHandle)
400
+ // Removing the wrong event name here has no test-observable effect
401
+ // within a short-lived test (the real listener is attached to an
402
+ // AbortController that's garbage-collected with the test, and
403
+ // `effectiveSignal` only ever fires "abort" once in its lifetime
404
+ // regardless) -- it matters for a long-running consumer process
405
+ // avoiding a listener leak, not for anything this suite can directly
406
+ // assert on.
407
+ // Stryker disable next-line StringLiteral,CallExpression -- removing the wrong event name here has no test-observable effect within a short-lived test: the real listener is attached to an AbortController that's garbage-collected with the test, and effectiveSignal only ever fires "abort" once in its lifetime regardless; it matters for a long-running consumer process avoiding a listener leak, not for anything this suite can directly assert on.
408
+ effectiveSignal.removeEventListener("abort", onEffectiveAbort)
409
+ // Releases the manual fallback's own listeners on `runSignal`/
410
+ // `timeoutController.signal` (a no-op on the native AbortSignal.any
411
+ // path) -- without this, every check spawned during a run would leave
412
+ // one permanent listener on the run's own long-lived shared signal.
413
+ // See abort-signals.ts's composeSignals doc comment.
414
+ disposeEffectiveSignal()
415
+ // `handle` is `ActiveCheckHandle | undefined` (undefined exactly when
416
+ // spawning itself failed and no pid was ever obtained, see below) --
417
+ // this guard exists to satisfy Set#delete's parameter type, not
418
+ // because calling delete(undefined) would behave differently at
419
+ // runtime (Set#delete on a non-member value is already a silent
420
+ // no-op), so no test can observe a difference either way.
421
+ // Stryker disable next-line ConditionalExpression -- handle is ActiveCheckHandle | undefined exactly when spawning itself failed and no pid was ever obtained (see below); this guard exists to satisfy Set#delete's parameter type, not because calling delete(undefined) would behave differently at runtime -- Set#delete on a non-member value is already a silent no-op.
422
+ if (handle !== undefined) activeHandles.delete(handle)
423
+ }
424
+
425
+ // A spawn failure (e.g. the executable does not exist) emits "error" --
426
+ // Node does not throw synchronously from spawn() for this case.
427
+ child.once("error", (error: NodeJS.ErrnoException) => {
428
+ cleanup()
429
+ resolve(
430
+ terminalEvidence(
431
+ command,
432
+ args,
433
+ startedAt,
434
+ "spawn_error",
435
+ null,
436
+ null,
437
+ stdoutCollector.value(),
438
+ stderrCollector.value(),
439
+ error.message,
440
+ error.code,
441
+ ),
442
+ )
443
+ })
444
+
445
+ // "close", not "exit": Node's own docs warn that "exit" can fire before
446
+ // the child's stdio streams have finished delivering their final
447
+ // buffered data -- resolving on "exit" risks silently truncating
448
+ // stdout/stderr for a process that writes a lot of output right before
449
+ // exiting (confirmed for real during implementation against secretlint's
450
+ // own output, not a hypothetical). "close" fires only after every stdio
451
+ // stream has ended, and still carries the same (code, signal) pair.
452
+ child.once("close", (code, signal) => {
453
+ cleanup()
454
+ const status: CheckStatus =
455
+ // A kill via ActiveCheckHandle.kill (host SIGINT/SIGTERM cleanup, see that interface's own
456
+ // doc comment) is classified first -- ahead of terminationReason. run-checks.ts's handler
457
+ // aborts `hostAbortController` (composed into `runSignal`) *before* calling handle.kill(),
458
+ // which it must, so the scheduler stops launching queued checks synchronously; that abort
459
+ // fires this check's own effectiveSignal listener, which sets terminationReason to
460
+ // "aborted" (runSignal is aborted by then). Checking terminationReason first would
461
+ // therefore misreport every host-Ctrl+C-killed check as "aborted" -- indistinguishable
462
+ // from an options.signal cancellation -- and leave "host_terminated" unreachable.
463
+ hostTerminated
464
+ ? "host_terminated"
465
+ : terminationReason === "aborted"
466
+ ? "aborted"
467
+ : terminationReason === "timed_out"
468
+ ? "timed_out"
469
+ : // Node's own child_process contract guarantees exactly one of
470
+ // code/signal is non-null on a normal "exit" event, making
471
+ // `signal !== null` here effectively redundant given
472
+ // `code === null` already -- kept as a belt-and-suspenders
473
+ // check against that contract rather than assumed absolute,
474
+ // since it is difficult to construct a real counterexample to
475
+ // test against (both null, or both non-null, simultaneously).
476
+ // Stryker disable next-line ConditionalExpression,EqualityOperator,LogicalOperator -- Node's own child_process contract guarantees exactly one of code/signal is non-null on a normal "exit" event, making signal !== null here effectively redundant given code === null already; kept as a belt-and-suspenders check against that contract rather than assumed absolute, since it is difficult to construct a real counterexample to test against (both null, or both non-null, simultaneously).
477
+ code === null && signal !== null
478
+ ? "signaled"
479
+ : "completed"
480
+ resolve(
481
+ terminalEvidence(
482
+ command,
483
+ args,
484
+ startedAt,
485
+ status,
486
+ code,
487
+ signal,
488
+ stdoutCollector.value(),
489
+ stderrCollector.value(),
490
+ ),
491
+ )
492
+ })
493
+ })
494
+ }
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Public API of repo-contract. Curated barrel -- internal implementation
3
+ * modules (src/execution/, src/parsing/, src/policy/, src/config/'s
4
+ * lower-level pieces) are not re-exported here even though they exist as
5
+ * real files; only the two functions and the types a consumer needs to
6
+ * author a config and interpret its result are part of the public surface.
7
+ * @packageDocumentation
8
+ */
9
+
10
+ export { defineRepoContract } from "./config/define-repo-contract.js"
11
+ export { runRepoContract } from "./run-repo-contract.js"
12
+
13
+ export {
14
+ DependencyDeclaredLaterError,
15
+ InvalidCheckConfigError,
16
+ InvalidRepoContractConfigError,
17
+ ParserDependencyMissingError,
18
+ PolicyReadFailedParseValueError,
19
+ PolicyReadUnrequestedOutputError,
20
+ PolicyThrewError,
21
+ RepoContractError,
22
+ UnknownCheckIdError,
23
+ } from "./errors.js"
24
+
25
+ export type {
26
+ CheckDefinition,
27
+ CheckDefinitionConfig,
28
+ CheckEvidence,
29
+ CheckSchema,
30
+ CheckStatus,
31
+ Evidence,
32
+ OutputFormat,
33
+ ParsedOutput,
34
+ ParsedOutputFailure,
35
+ ParsedOutputSuccess,
36
+ Policy,
37
+ PolicyContext,
38
+ PolicyOutcome,
39
+ PolicyResult,
40
+ RepoContractConfig,
41
+ RunRepoContractOptions,
42
+ ValidatedCheckSchema,
43
+ Verdict,
44
+ } from "./types.js"
@@ -0,0 +1,18 @@
1
+ import type { ParsedOutput } from "../types.js"
2
+
3
+ /**
4
+ * Parses `stdout` as JSON. A malformed-JSON failure preserves the raw stdout on the caller's `CheckEvidence` unchanged and never throws -- the failure is reported as data, per repo-contract's "parsing failures are deterministic evidence, never a silent reinterpretation" contract.
5
+ * @param stdout - the check's raw stdout to parse as JSON.
6
+ * @returns the parsed value on success, or the unparsed failure with `stdout` preserved.
7
+ */
8
+ export function parseJson(stdout: string): ParsedOutput<unknown> {
9
+ try {
10
+ return { format: "json", success: true, value: JSON.parse(stdout) }
11
+ } catch (error) {
12
+ return {
13
+ format: "json",
14
+ success: false,
15
+ error: error instanceof Error ? error.message : String(error),
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,26 @@
1
+ import type { OutputFormat, ParsedOutput } from "../types.js"
2
+ import { parseJson } from "./parse-json.js"
3
+ import { parseText } from "./parse-text.js"
4
+ import { parseYaml } from "./parse-yaml.js"
5
+
6
+ /**
7
+ * Dispatches to the parser for `format`. Never throws for a malformed-output parse failure -- see each parser's own documentation. May throw `ParserDependencyMissingError` for `format: "yaml"` if the optional `yaml` peer dependency is unavailable.
8
+ * @param format - which parser to dispatch to.
9
+ * @param stdout - the check's raw stdout to parse.
10
+ * @param checkId - identifies which check's output is being parsed, used in the `ParserDependencyMissingError` thrown for a missing `yaml` dependency.
11
+ * @returns the parsed output produced by the selected parser.
12
+ */
13
+ export async function parseOutput(
14
+ format: OutputFormat,
15
+ stdout: string,
16
+ checkId: string,
17
+ ): Promise<ParsedOutput<unknown>> {
18
+ switch (format) {
19
+ case "json":
20
+ return parseJson(stdout)
21
+ case "yaml":
22
+ return parseYaml(stdout, checkId)
23
+ case "text":
24
+ return parseText(stdout)
25
+ }
26
+ }
@@ -0,0 +1,10 @@
1
+ import type { ParsedOutput } from "../types.js"
2
+
3
+ /**
4
+ * "Parses" `stdout` as text -- a trimmed passthrough that always succeeds. Exists as an explicit, self-documenting alternative to omitting `output` entirely: `format: "text"` gives a consumer `result.output.value` as a plain trimmed string, rather than needing to read `result.stdout` and trim it themselves every time.
5
+ * @param stdout - the check's raw stdout to trim.
6
+ * @returns the always-successful result wrapping the trimmed string.
7
+ */
8
+ export function parseText(stdout: string): ParsedOutput<string> {
9
+ return { format: "text", success: true, value: stdout.trim() }
10
+ }
@@ -0,0 +1,40 @@
1
+ import type * as Yaml from "yaml"
2
+ import { ParserDependencyMissingError } from "../errors.js"
3
+ import type { ParsedOutput } from "../types.js"
4
+
5
+ /**
6
+ * Parses `stdout` as YAML using the optional `yaml` peer dependency, loaded
7
+ * via a dynamic `import()` only when a check actually requests
8
+ * `output.format: "yaml"` -- consumers who never use YAML never pay for it.
9
+ * If `yaml` cannot be loaded at all (not installed, or any other import-time
10
+ * failure), throws `ParserDependencyMissingError` with the original failure
11
+ * preserved as `cause`, rather than guessing at a specific module-resolution
12
+ * error code that could vary across Node versions and module systems (ESM
13
+ * vs. the package's own CJS build output).
14
+ *
15
+ * A malformed-YAML failure (the dependency loaded fine, but `stdout` isn't
16
+ * valid YAML) is a normal parse failure, not a missing-dependency error --
17
+ * it's returned as `{ success: false }`, following the same "preserve raw
18
+ * output, report failure as data" contract as `parseJson`.
19
+ * @param stdout - the check's raw stdout to parse as YAML.
20
+ * @param checkId - identifies which check's output is being parsed, used in the thrown `ParserDependencyMissingError`.
21
+ * @returns the parsed value on success, or the unparsed failure with `stdout` preserved.
22
+ */
23
+ export async function parseYaml(stdout: string, checkId: string): Promise<ParsedOutput<unknown>> {
24
+ let yamlModule: typeof Yaml
25
+ try {
26
+ yamlModule = await import("yaml")
27
+ } catch (error) {
28
+ throw new ParserDependencyMissingError(checkId, "yaml", error)
29
+ }
30
+
31
+ try {
32
+ return { format: "yaml", success: true, value: yamlModule.parse(stdout) }
33
+ } catch (error) {
34
+ return {
35
+ format: "yaml",
36
+ success: false,
37
+ error: error instanceof Error ? error.message : String(error),
38
+ }
39
+ }
40
+ }