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,368 @@
1
+ import {
2
+ DependencyDeclaredLaterError,
3
+ InvalidCheckConfigError,
4
+ InvalidRepoContractConfigError,
5
+ } from "../errors.js"
6
+ import type { OutputFormat, RepoContractConfig } from "../types.js"
7
+ import { tokenizeRunString } from "./tokenize-command.js"
8
+
9
+ const OUTPUT_FORMATS: readonly OutputFormat[] = ["json", "yaml", "text"]
10
+
11
+ /**
12
+ * Validates a `RepoContractConfig` structurally and throws before any
13
+ * process spawns. A config with zero checks is valid (an empty `checks`
14
+ * object vacuously satisfies "every check passed"); everything else here is
15
+ * a genuine structural problem. Runtime checks are deliberately defensive
16
+ * about field types rather than trusting the compile-time type, since a
17
+ * config can arrive from plain JavaScript or a widened/cast value.
18
+ * @param config - the config to validate; throws if structurally invalid.
19
+ */
20
+ export function validateRepoContractConfig(config: RepoContractConfig): void {
21
+ // Typed as `unknown` at this internal boundary (not the declared
22
+ // `RepoContractConfig` parameter type) deliberately -- this function's
23
+ // whole purpose is verifying something whose actual runtime shape isn't
24
+ // trusted (plain JavaScript callers, a widened or cast value), so
25
+ // narrowing from `unknown` keeps every check below genuinely meaningful
26
+ // rather than flagged as redundant against a type that's assumed valid.
27
+ const untrusted: unknown = config
28
+
29
+ if (untrusted === null || typeof untrusted !== "object") {
30
+ throw new InvalidRepoContractConfigError("config must be an object.")
31
+ }
32
+
33
+ const { checks, concurrency } = untrusted as Record<string, unknown>
34
+
35
+ if (checks === null || typeof checks !== "object" || Array.isArray(checks)) {
36
+ throw new InvalidRepoContractConfigError(
37
+ "checks must be an object mapping check id to check definition.",
38
+ )
39
+ }
40
+
41
+ if (concurrency !== undefined) {
42
+ // `typeof concurrency !== "number"` is behaviorally redundant with the
43
+ // clause after it: `Number.isInteger` returns `false` (never throws or
44
+ // coerces) for every non-number value, so `!Number.isInteger(...)`
45
+ // alone already rejects every case the typeof check would -- kept for
46
+ // readability at the call site, not because it changes behavior.
47
+ // Stryker disable next-line ConditionalExpression -- the typeof check is redundant with Number.isInteger, which returns false (never throws) for every non-number value, so it's kept only for readability at the call site.
48
+ if (typeof concurrency !== "number" || !Number.isInteger(concurrency) || concurrency < 1) {
49
+ throw new InvalidRepoContractConfigError(
50
+ "concurrency must be a positive integer when provided.",
51
+ )
52
+ }
53
+ }
54
+
55
+ for (const [checkId, check] of Object.entries(checks)) {
56
+ validateCheckDefinition(checkId, check)
57
+ }
58
+
59
+ validateDependencyGraph(checks as Record<string, { dependsOn?: readonly string[] }>)
60
+ }
61
+
62
+ /**
63
+ * Orchestrates one check's field-level validators. Deliberately just a flat
64
+ * sequence of calls with no branching of its own -- each field's validation
65
+ * logic (and its own complexity) lives in its own small function below, so
66
+ * this function's own cyclomatic complexity (and CRAP score) stays low
67
+ * regardless of how many fields exist to check.
68
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
69
+ * @param check - the check definition to validate.
70
+ */
71
+ function validateCheckDefinition(checkId: string, check: unknown): void {
72
+ // An integer-like key ("0", "42", ...) is enumerated by `Object.keys` in
73
+ // ascending numeric order ahead of every other key, regardless of insertion
74
+ // order -- so `validateDependencyGraph`'s `ids`/`indexById` (and the runtime
75
+ // scheduler) would see a different order than the source declares, silently
76
+ // breaking the "declaration order is the required topological order" contract.
77
+ if (/^(?:0|[1-9]\d*)$/.test(checkId)) {
78
+ throw new InvalidCheckConfigError(
79
+ checkId,
80
+ "check id must not be an integer-like string -- JavaScript would reorder it ahead of every " +
81
+ "other key and break the declaration-order-is-topological-order contract.",
82
+ )
83
+ }
84
+
85
+ if (check === null || typeof check !== "object") {
86
+ throw new InvalidCheckConfigError(checkId, "check definition must be an object.")
87
+ }
88
+
89
+ const fields = check as Record<string, unknown>
90
+ const usesShell = validateShell(checkId, fields.shell)
91
+ validateRun(checkId, fields.run, usesShell)
92
+ validateCwd(checkId, fields.cwd)
93
+ validateEnv(checkId, fields.env)
94
+ validateInheritEnv(checkId, fields.inheritEnv)
95
+ validateTimeoutMs(checkId, fields.timeoutMs)
96
+ validateOutput(checkId, fields.output)
97
+ validateDependsOn(checkId, fields.dependsOn)
98
+ validateIsolated(checkId, fields.isolated)
99
+ validatePolicy(checkId, fields.policy)
100
+ }
101
+
102
+ /**
103
+ * Returns whether `shell: true` was set, having already validated `shell`'s own type.
104
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
105
+ * @param shell - the check's raw `shell` field to validate.
106
+ * @returns `true` if `shell` was set to `true`, `false` otherwise (including when omitted).
107
+ */
108
+ function validateShell(checkId: string, shell: unknown): boolean {
109
+ if (shell !== undefined && typeof shell !== "boolean") {
110
+ throw new InvalidCheckConfigError(checkId, "shell must be a boolean when provided.")
111
+ }
112
+ return shell === true
113
+ }
114
+
115
+ /**
116
+ * Validates that `run` is a string or an array of strings, then delegates to the matching shape-specific validator.
117
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
118
+ * @param run - the check's raw `run` field to validate.
119
+ * @param usesShell - whether `shell: true` was set, from `validateShell`.
120
+ */
121
+ function validateRun(checkId: string, run: unknown, usesShell: boolean): void {
122
+ if (typeof run !== "string" && !Array.isArray(run)) {
123
+ throw new InvalidCheckConfigError(checkId, "run must be a string or an array of strings.")
124
+ }
125
+ if (typeof run === "string") {
126
+ validateStringRun(checkId, run, usesShell)
127
+ } else {
128
+ validateArrayRun(checkId, run as unknown[], usesShell)
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Validates a string-form `run`: tokenizes it (for its side effect of rejecting unquoted shell operators) when no shell is used, or rejects an empty/whitespace-only string when a shell is used.
134
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
135
+ * @param run - the check's raw `run` string to validate.
136
+ * @param usesShell - whether `shell: true` was set, from `validateShell`.
137
+ */
138
+ function validateStringRun(checkId: string, run: string, usesShell: boolean): void {
139
+ if (!usesShell) {
140
+ // Tokenized here for its side effect (throws on an empty string or an
141
+ // unquoted shell operator); the result is recomputed at spawn time,
142
+ // since tokenization is cheap and pure. The first token is additionally
143
+ // checked for emptiness: `run: "''"` / `run: "' ' build"` tokenizes to
144
+ // a non-empty array whose executable is `""`, which would otherwise fail
145
+ // only later as an opaque spawn error rather than a synchronous config
146
+ // error like every other structurally-broken `run`.
147
+ const [executable] = tokenizeRunString(run, checkId)
148
+ // `noUncheckedIndexedAccess` types the destructured `executable` as
149
+ // `string | undefined`, but `tokenizeRunString` throws on any input that
150
+ // would produce an empty token array (it rejects an empty or
151
+ // whitespace-only string outright), so `executable` is always a real
152
+ // string by the time control reaches here. The `?.` exists only to
153
+ // satisfy the compiler; removing it is behaviourally equivalent, so the
154
+ // OptionalChaining mutant here is an equivalent mutant with no test that
155
+ // could ever distinguish it.
156
+ // Stryker disable next-line OptionalChaining -- equivalent mutant: `executable` is provably always a string here (see comment above), so `executable?.trim()` and `executable.trim()` are identical.
157
+ if (executable?.trim().length === 0) {
158
+ throw new InvalidCheckConfigError(
159
+ checkId,
160
+ "run string's first token (the executable) is empty or contains only whitespace.",
161
+ )
162
+ }
163
+ return
164
+ }
165
+ if (run.trim().length === 0) {
166
+ throw new InvalidCheckConfigError(checkId, "run string is empty or contains only whitespace.")
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Validates an array-form `run`: rejects it outright when a shell is used (array args can't express shell operators), and otherwise requires a non-empty array of strings.
172
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
173
+ * @param items - the check's raw `run` array to validate.
174
+ * @param usesShell - whether `shell: true` was set, from `validateShell`.
175
+ */
176
+ function validateArrayRun(checkId: string, items: readonly unknown[], usesShell: boolean): void {
177
+ if (usesShell) {
178
+ throw new InvalidCheckConfigError(
179
+ checkId,
180
+ "shell: true requires run to be a string -- an array of arguments is individually " +
181
+ "escaped for the shell and cannot express shell operators like pipes or redirects, " +
182
+ "so combining the two forms would silently do nothing useful.",
183
+ )
184
+ }
185
+ if (items.length === 0) {
186
+ throw new InvalidCheckConfigError(checkId, "run array must not be empty.")
187
+ }
188
+ if (items.some((item) => typeof item !== "string")) {
189
+ throw new InvalidCheckConfigError(checkId, "run array must contain only strings.")
190
+ }
191
+ // The first element is the executable; an empty or whitespace-only value
192
+ // there fails only later as an opaque spawn error, so reject it here like
193
+ // every other structurally-broken `run`.
194
+ if ((items[0] as string).trim().length === 0) {
195
+ throw new InvalidCheckConfigError(
196
+ checkId,
197
+ "run array's first element (the executable) is empty or contains only whitespace.",
198
+ )
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Validates that `cwd`, if provided, is a string.
204
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
205
+ * @param cwd - the check's raw `cwd` field to validate.
206
+ */
207
+ function validateCwd(checkId: string, cwd: unknown): void {
208
+ if (cwd !== undefined && typeof cwd !== "string") {
209
+ throw new InvalidCheckConfigError(checkId, "cwd must be a string when provided.")
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Validates that `env`, if provided, is an object mapping names to string values.
215
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
216
+ * @param env - the check's raw `env` field to validate.
217
+ */
218
+ function validateEnv(checkId: string, env: unknown): void {
219
+ if (env === undefined) return
220
+ if (env === null || typeof env !== "object" || Array.isArray(env)) {
221
+ throw new InvalidCheckConfigError(
222
+ checkId,
223
+ "env must be an object mapping name to value when provided.",
224
+ )
225
+ }
226
+ for (const [key, value] of Object.entries(env)) {
227
+ if (typeof value !== "string") {
228
+ throw new InvalidCheckConfigError(checkId, `env["${key}"] must be a string.`)
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Validates that `inheritEnv`, if provided, is a boolean.
235
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
236
+ * @param inheritEnv - the check's raw `inheritEnv` field to validate.
237
+ */
238
+ function validateInheritEnv(checkId: string, inheritEnv: unknown): void {
239
+ if (inheritEnv !== undefined && typeof inheritEnv !== "boolean") {
240
+ throw new InvalidCheckConfigError(checkId, "inheritEnv must be a boolean when provided.")
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Validates that `timeoutMs`, if provided, is a positive finite number.
246
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
247
+ * @param timeoutMs - the check's raw `timeoutMs` field to validate.
248
+ */
249
+ function validateTimeoutMs(checkId: string, timeoutMs: unknown): void {
250
+ if (timeoutMs === undefined) return
251
+ // Same redundancy as concurrency's typeof check above: `Number.isFinite`
252
+ // returns `false` (never throws or coerces) for every non-number value.
253
+ // Stryker disable next-line ConditionalExpression -- same redundancy as the concurrency check above: the typeof check is redundant with Number.isFinite, which returns false for every non-number value.
254
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
255
+ throw new InvalidCheckConfigError(checkId, "timeoutMs must be a positive number when provided.")
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Validates that `output`, if provided, is an object whose `format` is one of the supported `OUTPUT_FORMATS`.
261
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
262
+ * @param output - the check's raw `output` field to validate.
263
+ */
264
+ function validateOutput(checkId: string, output: unknown): void {
265
+ if (output === undefined) return
266
+ if (output === null || typeof output !== "object") {
267
+ throw new InvalidCheckConfigError(checkId, "output must be an object when provided.")
268
+ }
269
+ const { format } = output as Record<string, unknown>
270
+ // `typeof format !== "string"` is behaviorally redundant with the clause
271
+ // after it: `Array#includes` uses strict equality, so a non-string
272
+ // `format` can never match an entry of `OUTPUT_FORMATS` (all strings),
273
+ // meaning `!OUTPUT_FORMATS.includes(...)` alone already rejects every
274
+ // case the typeof check would.
275
+ // Stryker disable next-line ConditionalExpression -- the typeof check is redundant with Array#includes' strict equality against OUTPUT_FORMATS (all strings), so a non-string format can never match regardless of the typeof check.
276
+ if (typeof format !== "string" || !OUTPUT_FORMATS.includes(format as OutputFormat)) {
277
+ throw new InvalidCheckConfigError(
278
+ checkId,
279
+ `output.format must be one of ${OUTPUT_FORMATS.map((f) => `"${f}"`).join(", ")}.`,
280
+ )
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Validates that `policy` is a function.
286
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
287
+ * @param policy - the check's raw `policy` field to validate.
288
+ */
289
+ function validatePolicy(checkId: string, policy: unknown): void {
290
+ if (typeof policy !== "function") {
291
+ throw new InvalidCheckConfigError(checkId, "policy must be a function.")
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Only this check's own shape (array of strings) and self-dependency --
297
+ * both need nothing beyond this one check's own id/field. Self-dependency
298
+ * is a degenerate one-node cycle, but is deliberately caught here rather
299
+ * than deferred to `validateDependencyGraph`'s cycle detector below: it
300
+ * needs no other check's data, gives a clearer message, and fails in this
301
+ * same per-check pass instead of waiting for a second one.
302
+ * @param checkId - identifies which check is being validated, used in thrown error messages and to detect self-dependency.
303
+ * @param dependsOn - the check's raw `dependsOn` field to validate.
304
+ */
305
+ function validateDependsOn(checkId: string, dependsOn: unknown): void {
306
+ if (dependsOn === undefined) return
307
+ if (!Array.isArray(dependsOn) || dependsOn.some((id) => typeof id !== "string")) {
308
+ throw new InvalidCheckConfigError(
309
+ checkId,
310
+ "dependsOn must be an array of check ids (strings) when provided.",
311
+ )
312
+ }
313
+ if (dependsOn.includes(checkId)) {
314
+ throw new InvalidCheckConfigError(checkId, "dependsOn must not include the check's own id.")
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Validates that `isolated`, if provided, is a boolean.
320
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
321
+ * @param isolated - the check's raw `isolated` field to validate.
322
+ */
323
+ function validateIsolated(checkId: string, isolated: unknown): void {
324
+ if (isolated !== undefined && typeof isolated !== "boolean") {
325
+ throw new InvalidCheckConfigError(checkId, "isolated must be a boolean when provided.")
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Whole-graph properties that can't be checked per-check in isolation --
331
+ * run once, after every check's own `dependsOn` shape has already been
332
+ * validated by `validateDependsOn` above, so this can walk every
333
+ * `dependsOn` array without re-checking its shape.
334
+ *
335
+ * Declaration order in the `checks` object doubles as the required topological order (see
336
+ * `CheckDefinition.dependsOn`'s own doc comment): every `dependsOn` id must name a check declared
337
+ * earlier* than the check declaring it. This single backward-reference check subsumes what used
338
+ * to be two separate passes (an unknown-id check, then a DFS cycle detector) -- a cycle is no
339
+ * longer expressible at all once every edge is required to point backward, so there is nothing
340
+ * left for a separate cycle detector to catch. `isolated` needs no validation-time edge computation
341
+ * here: its own implied positional edges (see `run-checks.ts`'s `dependencyIndexesFor`) always
342
+ * point backward (to earlier-declared checks) or are pointed at by later-declared checks, by
343
+ * construction, so they can never introduce a cycle either.
344
+ * @param checks - the full check map, keyed by check id, in declaration order.
345
+ */
346
+ export function validateDependencyGraph(
347
+ checks: Record<string, { dependsOn?: readonly string[] }>,
348
+ ): void {
349
+ const ids = Object.keys(checks)
350
+ const indexById = new Map(ids.map((id, index) => [id, index]))
351
+
352
+ for (const [index, id] of ids.entries()) {
353
+ const check = checks[id]
354
+ // `id` always comes from `Object.keys(checks)`, so `checks[id]` can
355
+ // never actually be undefined -- kept only because
356
+ // `noUncheckedIndexedAccess` can't itself express that invariant.
357
+ // Stryker disable next-line OptionalChaining -- id always comes from Object.keys(checks), so check can never be undefined here; the optional chaining exists only to satisfy noUncheckedIndexedAccess.
358
+ for (const depId of check?.dependsOn ?? []) {
359
+ const depIndex = indexById.get(depId)
360
+ if (depIndex === undefined) {
361
+ throw new InvalidCheckConfigError(id, `dependsOn references unknown check id "${depId}".`)
362
+ }
363
+ if (depIndex >= index) {
364
+ throw new DependencyDeclaredLaterError(id, depId)
365
+ }
366
+ }
367
+ }
368
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Error hierarchy for repo-contract. Every concrete error carries a stable
3
+ * `code` string for programmatic handling, and never embeds raw
4
+ * stdout/stderr/env values in its message -- only check ids and field names
5
+ * (see SECURITY.md).
6
+ *
7
+ * Three distinct failure classes are kept deliberately separate (see
8
+ * specs/architecture.md):
9
+ * - Structural config problems throw synchronously, before any process
10
+ * spawns (`InvalidRepoContractConfigError`, `InvalidCheckConfigError`).
11
+ * - Anything only discoverable by attempting execution becomes evidence,
12
+ * never a throw (a bad `cwd`, a missing binary -- recorded as
13
+ * `status: "spawn_error"` on that check's `CheckEvidence`).
14
+ * - A policy function throwing or rejecting is a bug in consumer code, not
15
+ * a check failing its contract -- it propagates as a rejected
16
+ * `runRepoContract()` promise (`PolicyThrewError`), never silently
17
+ * turned into a failed verdict entry.
18
+ */
19
+ export abstract class RepoContractError extends Error {
20
+ /** Stable, machine-readable identifier for this error's specific failure mode. */
21
+ abstract readonly code: string
22
+ }
23
+
24
+ /** The top-level `RepoContractConfig` itself is structurally invalid -- e.g. `checks` is not an object, or `concurrency` is not a positive integer. (A `checks` object with zero entries is deliberately valid: the run produces an empty, passing `Verdict`.) Thrown synchronously by `runRepoContract`, before anything spawns -- not by `defineRepoContract`, which performs no runtime validation of its own (see its own doc comment). */
25
+ export class InvalidRepoContractConfigError extends RepoContractError {
26
+ /** Always `"REPO_CONTRACT_INVALID_CONFIG"`. */
27
+ readonly code = "REPO_CONTRACT_INVALID_CONFIG"
28
+
29
+ constructor(reason: string) {
30
+ super(`Invalid repo-contract config -- ${reason}`)
31
+ this.name = "InvalidRepoContractConfigError"
32
+ }
33
+ }
34
+
35
+ /** One check's `CheckDefinition` is structurally invalid -- e.g. an empty `run`, a `run` string containing an unquoted shell operator without `shell: true`, or a missing `policy`. Thrown synchronously, before that check (or any other) spawns. */
36
+ export class InvalidCheckConfigError extends RepoContractError {
37
+ /** Always `"REPO_CONTRACT_INVALID_CHECK_CONFIG"`. */
38
+ readonly code = "REPO_CONTRACT_INVALID_CHECK_CONFIG"
39
+ /** The id of the check whose configuration was invalid. */
40
+ readonly checkId: string
41
+
42
+ constructor(checkId: string, reason: string) {
43
+ super(`Invalid check config for "${checkId}" -- ${reason}`)
44
+ this.name = "InvalidCheckConfigError"
45
+ this.checkId = checkId
46
+ }
47
+ }
48
+
49
+ /**
50
+ * `RunRepoContractOptions.checks` (a partial-run request) names a check id that doesn't exist in
51
+ * the configured `checks`. Unlike a `dependsOn` id (already validated to exist by
52
+ * `validateRepoContractConfig` before any run starts), `options.checks` is only ever checked once
53
+ * `runChecks` actually resolves it -- there is no earlier structural-validation pass for it.
54
+ */
55
+ export class UnknownCheckIdError extends RepoContractError {
56
+ /** Always `"REPO_CONTRACT_UNKNOWN_CHECK_ID"`. */
57
+ readonly code = "REPO_CONTRACT_UNKNOWN_CHECK_ID"
58
+ /** The unrecognized check id named in `options.checks`. */
59
+ readonly checkId: string
60
+
61
+ constructor(checkId: string) {
62
+ super(
63
+ `options.checks names "${checkId}", which is not a check id in this config's checks object.`,
64
+ )
65
+ this.name = "UnknownCheckIdError"
66
+ this.checkId = checkId
67
+ }
68
+ }
69
+
70
+ /**
71
+ * A check's `dependsOn` names a check declared *later* in the same `checks` object.
72
+ * `dependsOn` may only reference a check declared earlier -- see `CheckDefinition.dependsOn`'s own
73
+ * doc comment. Declaration order doubles as the required topological order, so this is the only
74
+ * way an invalid dependency graph can arise; a real cycle is structurally impossible once every
75
+ * edge points backward. Thrown synchronously, before any check spawns.
76
+ */
77
+ export class DependencyDeclaredLaterError extends RepoContractError {
78
+ /** Always `"REPO_CONTRACT_DEPENDENCY_DECLARED_LATER"`. */
79
+ readonly code = "REPO_CONTRACT_DEPENDENCY_DECLARED_LATER"
80
+ /** The id of the check whose `dependsOn` names a later-declared check. */
81
+ readonly checkId: string
82
+ /** The later-declared check id named in `checkId`'s `dependsOn`. */
83
+ readonly dependencyId: string
84
+
85
+ constructor(checkId: string, dependencyId: string) {
86
+ super(
87
+ `Invalid check config for "${checkId}" -- dependsOn: ["${dependencyId}"], but "${dependencyId}" ` +
88
+ `is declared later in the checks object. dependsOn may only reference a check declared ` +
89
+ `earlier -- reorder the checks object so "${dependencyId}" is declared before "${checkId}".`,
90
+ )
91
+ this.name = "DependencyDeclaredLaterError"
92
+ this.checkId = checkId
93
+ this.dependencyId = dependencyId
94
+ }
95
+ }
96
+
97
+ /** A check requested `output: { format: "yaml" }` but the optional `yaml` peer dependency is not installed. Thrown when that check's output is parsed, not at config-validation time (parsing only happens after the process has already run). */
98
+ export class ParserDependencyMissingError extends RepoContractError {
99
+ /** Always `"REPO_CONTRACT_PARSER_DEPENDENCY_MISSING"`. */
100
+ readonly code = "REPO_CONTRACT_PARSER_DEPENDENCY_MISSING"
101
+ /** The id of the check whose output could not be parsed. */
102
+ readonly checkId: string
103
+ /** The output format that was requested but whose optional peer dependency is missing. */
104
+ readonly format: OutputFormatForError
105
+
106
+ constructor(checkId: string, format: OutputFormatForError, cause: unknown) {
107
+ super(
108
+ `Check "${checkId}" requested output.format: "${format}", but the optional "${format}" ` +
109
+ `peer dependency is not installed -- run \`npm install ${format}\` to enable it.`,
110
+ { cause },
111
+ )
112
+ this.name = "ParserDependencyMissingError"
113
+ this.checkId = checkId
114
+ this.format = format
115
+ }
116
+ }
117
+
118
+ // Kept narrow and local rather than importing OutputFormat from types.ts --
119
+ // only "yaml" can ever produce this error today (json/text have no external
120
+ // dependency to be missing), but the field stays named/typed generically in
121
+ // case a future optional format needs the same treatment.
122
+ type OutputFormatForError = "yaml"
123
+
124
+ /**
125
+ * A check's `policy` function threw synchronously, or returned a `Promise`
126
+ * that later rejected. Both failure modes are wrapped identically. The
127
+ * original thrown/rejected value is preserved verbatim via the native
128
+ * `Error` `cause` chain -- never stringified, summarized, or discarded --
129
+ * so a consumer catching this can still inspect exactly what their own
130
+ * policy code did wrong.
131
+ *
132
+ * A policy throwing never stops any other check's policy from running --
133
+ * every configured check's policy is invoked exactly once regardless of
134
+ * what any other policy does. If more than one policy fails this way in the
135
+ * same run, `runRepoContract()` rejects with a native `AggregateError` whose
136
+ * `errors` array holds one error per failing check -- `PolicyThrewError`,
137
+ * or one of its two narrower siblings below (`PolicyReadUnrequestedOutputError`,
138
+ * `PolicyReadFailedParseValueError`) when the failure matches one of their
139
+ * more specific shapes -- rather than surfacing only the first one found.
140
+ */
141
+ export class PolicyThrewError extends RepoContractError {
142
+ /** Always `"REPO_CONTRACT_POLICY_THREW"`. */
143
+ readonly code = "REPO_CONTRACT_POLICY_THREW"
144
+ /** The id of the check whose policy threw or rejected. */
145
+ readonly checkId: string
146
+
147
+ constructor(checkId: string, cause: unknown) {
148
+ super(`Policy for check "${checkId}" threw instead of returning a PolicyResult`, { cause })
149
+ this.name = "PolicyThrewError"
150
+ this.checkId = checkId
151
+ }
152
+ }
153
+
154
+ /**
155
+ * A narrower `runPolicies` throws instead of `PolicyThrewError` when the
156
+ * thrown `TypeError`'s own message shows it came from reading a
157
+ * `result.output` property (`.success`, `.value`, `.error`, or `.format`),
158
+ * and this check's `CheckEvidence.output` really is `undefined` -- which
159
+ * happens only when its config never requested `output: { format: ... }` in
160
+ * the first place (see `CheckEvidence.output` in types.ts). Detected from
161
+ * the `TypeError`'s exact V8-produced message ("Cannot read properties of
162
+ * undefined (reading '...')") cross-checked against that `undefined` fact,
163
+ * so it is thrown only for a policy that plausibly hit this exact mistake --
164
+ * any other `TypeError`, or a check that did request a format, still becomes
165
+ * a plain `PolicyThrewError`. This is best-effort inference from the
166
+ * `TypeError`'s message text, not a verified trace back to `result.output`
167
+ * itself -- V8's message carries only the property name, so an unrelated
168
+ * bug that happens to read the same property name off some other
169
+ * `undefined` value can still be misclassified this way (see
170
+ * `unrequestedOutputProperty` in run-policies.ts). `cause` still holds the
171
+ * original `TypeError` verbatim, exactly as `PolicyThrewError` guarantees
172
+ * for every other policy failure, so the true cause remains recoverable
173
+ * either way.
174
+ */
175
+ export class PolicyReadUnrequestedOutputError extends RepoContractError {
176
+ /** Always `"REPO_CONTRACT_POLICY_READ_UNREQUESTED_OUTPUT"`. */
177
+ readonly code = "REPO_CONTRACT_POLICY_READ_UNREQUESTED_OUTPUT"
178
+ /** The id of the check whose policy read `result.output` without requesting a format. */
179
+ readonly checkId: string
180
+
181
+ constructor(checkId: string, property: string, cause: unknown) {
182
+ super(
183
+ `Policy for check "${checkId}" read \`result.output.${property}\`, but "${checkId}" never ` +
184
+ `requested an output format, so \`result.output\` is undefined -- add ` +
185
+ `\`output: { format: "json" }\` (or "yaml"/"text") to check "${checkId}"'s definition to ` +
186
+ `parse its stdout, then narrow with \`result.output?.success\` before reading ` +
187
+ `\`.value\`/\`.error\`.`,
188
+ { cause },
189
+ )
190
+ this.name = "PolicyReadUnrequestedOutputError"
191
+ this.checkId = checkId
192
+ }
193
+ }
194
+
195
+ /**
196
+ * `PolicyThrewError`'s other special case, alongside
197
+ * `PolicyReadUnrequestedOutputError`: this check *did* request
198
+ * `output: { format: ... }`, but that parse itself failed
199
+ * (`result.output.success === false` -- see `ParsedOutputFailure` in types.ts, which
200
+ * has no `value` field, only `error`), and the policy read a property off
201
+ * `result.output.value` anyway without checking `.success` first. Detected the same
202
+ * way `runPolicies` detects the sibling case -- a thrown `TypeError` whose own
203
+ * message shows a property read off `undefined` -- cross-checked against this
204
+ * check's own evidence having `output.success === false`, so a genuinely unrelated
205
+ * `TypeError`, or a check whose parse actually succeeded, still becomes a plain
206
+ * `PolicyThrewError`. Unlike `PolicyReadUnrequestedOutputError`, the read property
207
+ * isn't restricted to a known list here -- `result.output.value`'s shape is whatever
208
+ * the external tool printed, entirely unknown to repo-contract -- so this match is
209
+ * necessarily a little broader. `cause` still holds the original `TypeError`
210
+ * verbatim; the message never repeats `result.output.error`'s own text, which may
211
+ * contain raw stdout content (see SECURITY.md).
212
+ */
213
+ export class PolicyReadFailedParseValueError extends RepoContractError {
214
+ /** Always `"REPO_CONTRACT_POLICY_READ_FAILED_PARSE_VALUE"`. */
215
+ readonly code = "REPO_CONTRACT_POLICY_READ_FAILED_PARSE_VALUE"
216
+ /** The id of the check whose policy read `result.output.value` after a failed parse. */
217
+ readonly checkId: string
218
+
219
+ constructor(checkId: string, property: string, cause: unknown) {
220
+ super(
221
+ `Policy for check "${checkId}" read \`result.output.value.${property}\`, but "${checkId}"'s ` +
222
+ `output failed to parse -- a failed parse has \`result.output.error\`, never \`.value\` -- ` +
223
+ `check \`result.output.success\` before reading \`.value\`.`,
224
+ { cause },
225
+ )
226
+ this.name = "PolicyReadFailedParseValueError"
227
+ this.checkId = checkId
228
+ }
229
+ }