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,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
|
+
}
|