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,261 @@
1
+ import type { ParsedCheckEntry } from "../evidence/build-evidence.js"
2
+ import {
3
+ PolicyReadFailedParseValueError,
4
+ PolicyReadUnrequestedOutputError,
5
+ PolicyThrewError,
6
+ } from "../errors.js"
7
+ import type {
8
+ CheckEvidence,
9
+ Evidence,
10
+ ParsedOutput,
11
+ PolicyOutcome,
12
+ PolicyResult,
13
+ Verdict,
14
+ } from "../types.js"
15
+
16
+ const POLICY_OUTCOMES: readonly PolicyOutcome[] = ["pass", "fail", "warn"]
17
+
18
+ /** `ParsedOutput`'s own field names -- the only properties reading `result.output` when it's `undefined` can throw on, so only these can trigger `PolicyReadUnrequestedOutputError` below. */
19
+ const OUTPUT_PROPERTIES: ReadonlySet<string> = new Set(["success", "value", "error", "format"])
20
+
21
+ /**
22
+ * Recognizes the one `TypeError` message shape Node/V8 produces for reading a
23
+ * property off `undefined` ("Cannot read properties of undefined (reading
24
+ * 'x')"), and returns `x`. Returns `undefined` for any other error -- a
25
+ * `TypeError` with a different message, or anything that isn't a `TypeError`
26
+ * at all.
27
+ * @param error - the value caught from invoking (or awaiting) a check's `policy`
28
+ * @returns the read property name, or `undefined` if `error` doesn't match this exact shape
29
+ */
30
+ function readPropertyOfUndefined(error: unknown): string | undefined {
31
+ if (!(error instanceof TypeError)) return undefined
32
+ const match = /^Cannot read properties of undefined \(reading '([^']+)'\)$/.exec(error.message)
33
+ return match?.[1]
34
+ }
35
+
36
+ /**
37
+ * Narrows `readPropertyOfUndefined` to the specific shape
38
+ * `PolicyReadUnrequestedOutputError` covers: the read property must also be
39
+ * one of `ParsedOutput`'s own fields. This meaningfully narrows false
40
+ * positives -- an unrelated `TypeError` reading some other property name
41
+ * never matches -- but is not proof the read actually came from
42
+ * `result.output` itself: V8's own error message carries only the property
43
+ * name, never the object expression it was read from, so a policy's own
44
+ * unrelated bug that happens to read `.success`/`.value`/`.error`/`.format`
45
+ * off some other `undefined` value (a `Result`-shaped helper's own return
46
+ * value, for instance) can still be misclassified as this specific mistake.
47
+ * The residual risk from that is a misleading message, never a lost error --
48
+ * `cause` always preserves the original `TypeError` verbatim regardless of
49
+ * which class wraps it, so the true cause is still recoverable by a consumer
50
+ * that inspects it.
51
+ * @param error - the value caught from invoking (or awaiting) a check's `policy`
52
+ * @returns the read property name, or `undefined` if `error` doesn't match this exact shape
53
+ */
54
+ function unrequestedOutputProperty(error: unknown): string | undefined {
55
+ const property = readPropertyOfUndefined(error)
56
+ // Provably equivalent, not a coverage gap: OUTPUT_PROPERTIES is a
57
+ // ReadonlySet<string>, so `.has(undefined)` is always false regardless of
58
+ // its argument's runtime value -- the `property !== undefined` guard is
59
+ // therefore redundant with the `.has()` call it short-circuits into, and
60
+ // no test can observe a difference between keeping and removing it. Kept
61
+ // anyway for readability (it documents "must be a recognized property
62
+ // name" without requiring the reader to already know Set.has's behavior
63
+ // on undefined), not because it changes behavior.
64
+ // Stryker disable next-line ConditionalExpression -- OUTPUT_PROPERTIES is a ReadonlySet<string>, so .has(undefined) is always false regardless of argument, making the property !== undefined guard redundant with the .has() call it short-circuits into; removing it changes no observable behavior.
65
+ return property !== undefined && OUTPUT_PROPERTIES.has(property) ? property : undefined
66
+ }
67
+
68
+ /**
69
+ * Chooses which error wraps one check's caught policy failure. Defaults to a
70
+ * plain `PolicyThrewError`, upgrading to a more specific, more actionable
71
+ * class for two known mistakes, each recognized by cross-checking the
72
+ * failure's own `TypeError` message shape against this check's own `output`
73
+ * evidence:
74
+ * - `output === undefined` (this check never requested a format) and the
75
+ * read property is one of `ParsedOutput`'s own fields --
76
+ * `PolicyReadUnrequestedOutputError`.
77
+ * - `output.success === false` (this check requested a format, but that
78
+ * parse failed, so `result.output` has no `value` field) and *any*
79
+ * property was read off the resulting `undefined` -- `PolicyReadFailedParseValueError`.
80
+ * A `TypeError` that doesn't match either shape, or a check whose `output`
81
+ * matches neither state, always falls back to the plain `PolicyThrewError`
82
+ * every other policy failure already gets. Both upgrades are best-effort
83
+ * inference from the failure's own message text, not a verified trace back
84
+ * to `result.output` -- see `unrequestedOutputProperty`'s own doc comment
85
+ * for the specific, known false-positive channel this leaves open.
86
+ * @param checkId - the failing check's id
87
+ * @param output - that check's own `CheckEvidence.output`, tested against the two shapes above
88
+ * @param error - the value caught from invoking (or awaiting) the check's `policy`
89
+ * @returns the error to record for this check
90
+ */
91
+ function wrapPolicyFailure(
92
+ checkId: string,
93
+ output: ParsedOutput<unknown> | undefined,
94
+ error: unknown,
95
+ ): PolicyThrewError | PolicyReadUnrequestedOutputError | PolicyReadFailedParseValueError {
96
+ if (output === undefined) {
97
+ const property = unrequestedOutputProperty(error)
98
+ if (property !== undefined) {
99
+ return new PolicyReadUnrequestedOutputError(checkId, property, error)
100
+ }
101
+ } else if (!output.success) {
102
+ const property = readPropertyOfUndefined(error)
103
+ if (property !== undefined) {
104
+ return new PolicyReadFailedParseValueError(checkId, property, error)
105
+ }
106
+ }
107
+ return new PolicyThrewError(checkId, error)
108
+ }
109
+
110
+ /**
111
+ * Describes why `value` is not a valid `PolicyResult`, or `undefined` if it
112
+ * is one. A TypeScript-authored policy can never fail this (the type
113
+ * checker already guarantees it), but nothing stops a JavaScript consumer,
114
+ * or a typo'd literal (`"failed"` instead of `"fail"`), from returning
115
+ * something else at runtime -- and `passed` below is computed by comparing
116
+ * `outcome` against `"fail"`, so an unvalidated garbage value would
117
+ * otherwise be silently treated as non-failing.
118
+ * @param value - the raw, `await`-ed return value of a check's `policy` call
119
+ * @returns a human-readable reason `value` is invalid, or `undefined` if it is a valid `PolicyResult`
120
+ */
121
+ function invalidPolicyResultReason(value: unknown): string | undefined {
122
+ if (typeof value !== "object" || value === null) {
123
+ // `typeof null === "object"` in JavaScript, so falling back to `typeof value`
124
+ // here would nonsensically report "got object" for a null result.
125
+ return `expected an object with "outcome" and "rationale", got ${value === null ? "null" : typeof value}`
126
+ }
127
+ const outcome = (value as { outcome?: unknown }).outcome
128
+ if (!POLICY_OUTCOMES.includes(outcome as PolicyOutcome)) {
129
+ return `"outcome" must be "pass", "fail", or "warn", got ${typeof outcome === "string" ? `"${outcome}"` : typeof outcome}`
130
+ }
131
+ const rationale = (value as { rationale?: unknown }).rationale
132
+ if (typeof rationale !== "string") {
133
+ return `"rationale" must be a string, got ${typeof rationale}`
134
+ }
135
+ return undefined
136
+ }
137
+
138
+ /**
139
+ * Invokes every check's `policy` against the complete, already-assembled
140
+ * `evidence` and aggregates the results into a `Verdict`. Each check's
141
+ * `PolicyResult` is stored verbatim under `checks[checkId]`; `passed` is
142
+ * `true` only if every check's `outcome` is `"pass"` or `"warn"` -- no
143
+ * check's failure is ever collapsed into another's or into one generic
144
+ * message.
145
+ *
146
+ * Every `policy` call is isolated: the call itself is wrapped in `try/catch`
147
+ * (a policy doesn't have to be `async` to misbehave), and its returned
148
+ * value is `await`-ed inside that same `try`, so a promise that rejects
149
+ * later is caught identically to a synchronous throw. The resolved value is
150
+ * then validated against the `PolicyResult` contract itself (`outcome` is
151
+ * exactly `"pass"`, `"fail"`, or `"warn"`; `rationale` is a string) before
152
+ * it is trusted -- a malformed result is treated exactly like a throw
153
+ * rather than silently coerced into a pass. Every one of these failure
154
+ * modes is wrapped in `PolicyThrewError`, with the original thrown value
155
+ * (or a descriptive validation error, for a malformed result) preserved via
156
+ * `cause` -- never stringified or discarded. Two specific, common mistakes
157
+ * get a more actionable error instead, chosen by `wrapPolicyFailure` (see
158
+ * its own doc comment): reading a `result.output` property on a check that
159
+ * never requested a format (`PolicyReadUnrequestedOutputError`), and reading
160
+ * `result.output.value` on a check whose requested parse actually failed
161
+ * (`PolicyReadFailedParseValueError`) -- both still preserve the original
162
+ * `TypeError` via `cause`, exactly like the plain `PolicyThrewError` case.
163
+ * One policy failing this way never stops any other check's policy from
164
+ * running. If more than one policy fails this way in the same run, this
165
+ * function throws a native `AggregateError` whose `errors` holds one such
166
+ * error per failing check, rather than surfacing only the first one found;
167
+ * if exactly one policy fails, it throws that single error directly.
168
+ * @param entries - each check's id, definition, and final (possibly parsed-output) evidence to invoke its policy against
169
+ * @param evidence - the complete, already-assembled evidence for the whole run, passed to every policy call
170
+ * @returns the aggregated `Verdict` (throws instead if one or more policies failed to produce a valid `PolicyResult`)
171
+ */
172
+ export async function runPolicies(
173
+ entries: readonly ParsedCheckEntry[],
174
+ evidence: Evidence,
175
+ ): Promise<Verdict> {
176
+ const thrown: (
177
+ PolicyThrewError | PolicyReadUnrequestedOutputError | PolicyReadFailedParseValueError
178
+ )[] = []
179
+ // Indexed by each entry's position, not appended on promise resolution: the
180
+ // policies run concurrently and settle in arbitrary order, but `Verdict.checks`
181
+ // must follow declaration order, consistently with `Evidence.checks`.
182
+ const checkResults: (readonly [string, PolicyResult] | undefined)[] = []
183
+
184
+ await Promise.all(
185
+ entries.map(async ([checkId, check, checkEvidence], entryIndex) => {
186
+ // Derived on the fly from data that already exists (this check's own
187
+ // dependsOn plus the already-fully-assembled evidence.checks) --
188
+ // never persisted, so Evidence's own shape/schema doesn't grow. `{}`
189
+ // for a check with no dependsOn, never undefined.
190
+ const dependencies: Record<string, CheckEvidence> = {}
191
+ // `check.dependsOn` is `undefined` exactly for a check with no
192
+ // declared dependencies, in which case this fallback's own contents
193
+ // are unobservable regardless of what they are: any id it iterated
194
+ // would look up `evidence.checks[depId]`, find nothing (no real
195
+ // check has that id), and be filtered out by the guard below anyway
196
+ // -- confirmed by "a policy with no dependsOn sees ctx.dependencies
197
+ // as an empty object" in run-policies.test.ts, which passes
198
+ // regardless of this fallback's specific value.
199
+ // Stryker disable next-line ArrayDeclaration -- check.dependsOn is undefined exactly when a check has no declared dependencies, in which case this fallback's contents are unobservable regardless of value: any id iterated would look up evidence.checks[depId], find nothing, and get filtered out by the guard below anyway, confirmed by the "sees ctx.dependencies as an empty object" test in run-policies.test.ts, which passes regardless of the fallback's specific value.
200
+ for (const depId of check.dependsOn ?? []) {
201
+ const depEvidence = evidence.checks[depId]
202
+ // validate-config.ts already guarantees every dependsOn id names a
203
+ // check that exists in this run, and the phasing invariant
204
+ // guarantees its evidence is already assembled by the time any
205
+ // policy runs -- this guard exists only to satisfy
206
+ // noUncheckedIndexedAccess.
207
+ // Stryker disable next-line ConditionalExpression -- validate-config.ts already guarantees every dependsOn id names a check that exists in this run, and the phasing invariant guarantees its evidence is already assembled by the time any policy runs; this guard exists only to satisfy noUncheckedIndexedAccess.
208
+ if (depEvidence !== undefined) dependencies[depId] = depEvidence
209
+ }
210
+
211
+ let outcome: PolicyResult
212
+ try {
213
+ const rawResult: unknown = await check.policy({
214
+ result: checkEvidence,
215
+ evidence,
216
+ dependencies,
217
+ })
218
+ const invalidReason = invalidPolicyResultReason(rawResult)
219
+ if (invalidReason !== undefined) {
220
+ throw new Error(
221
+ `Policy for check "${checkId}" returned an invalid PolicyResult: ${invalidReason}.`,
222
+ )
223
+ }
224
+ outcome = rawResult as PolicyResult
225
+ } catch (error) {
226
+ thrown.push(wrapPolicyFailure(checkId, checkEvidence.output, error))
227
+ return
228
+ }
229
+ checkResults[entryIndex] = [checkId, outcome]
230
+ }),
231
+ )
232
+
233
+ if (thrown.length === 1) {
234
+ const [only] = thrown as [
235
+ PolicyThrewError | PolicyReadUnrequestedOutputError | PolicyReadFailedParseValueError,
236
+ ]
237
+ throw only
238
+ }
239
+ // `> 1` vs `>= 1` are equivalent here given the early return just above --
240
+ // by the time this line can even run, thrown.length is never 1 (either 0,
241
+ // falling through to the success path below either way, or >= 2, taking
242
+ // this branch either way). Documented rather than silently accepted so a
243
+ // future refactor that removes the early return doesn't quietly widen it.
244
+ // Stryker disable next-line EqualityOperator -- "> 1" vs. ">= 1" are equivalent here given the early return just above: by the time this line runs, thrown.length is never exactly 1 (either 0 or >= 2 either way), documented so a future refactor that removes the early return doesn't quietly widen this silently.
245
+ if (thrown.length > 1) {
246
+ throw new AggregateError(
247
+ thrown,
248
+ `${String(thrown.length)} check policies threw instead of returning a PolicyResult.`,
249
+ )
250
+ }
251
+
252
+ // Every slot is filled and in declaration order by now: a policy that threw
253
+ // took the early-return path above, so `thrown.length === 0` here means every
254
+ // `entries.map` callback assigned its `checkResults[entryIndex]`. The cast
255
+ // just drops the `| undefined` the sparse-write type carries.
256
+ const orderedResults = checkResults as readonly (readonly [string, PolicyResult])[]
257
+ const checks = Object.fromEntries(orderedResults)
258
+ const passed = orderedResults.every(([, result]) => result.outcome !== "fail")
259
+
260
+ return { version: 2, passed, checks }
261
+ }
@@ -0,0 +1,116 @@
1
+ import type { CheckDefinitionConfig, PolicyResult } from "../types.js"
2
+ import { checkDependencyInstalled } from "./shared/missing-dependency.js"
3
+ import { checkTerminatedAbnormally } from "./shared/terminal-status.js"
4
+
5
+ /**
6
+ * attw's own `--format json` contract -- not published as a TypeScript type
7
+ * by the tool. `problems` is a flat record keyed by problem kind (e.g.
8
+ * "FalseCJS", "NoResolution"), each entry carrying whichever context fields
9
+ * that kind of problem produces.
10
+ */
11
+ export interface AttwReport {
12
+ readonly problems?: Record<string, readonly AttwProblem[]>
13
+ }
14
+
15
+ export interface AttwProblem {
16
+ readonly kind: string
17
+ readonly entrypoint?: string
18
+ readonly resolutionKind?: string
19
+ readonly typesFileName?: string
20
+ readonly implementationFileName?: string
21
+ }
22
+
23
+ /**
24
+ * Renders one attw problem as a single human-readable line, appending whichever context fields (entrypoint, resolution kind, types/impl file names) that problem kind happens to carry.
25
+ * @param problem - the attw problem to render.
26
+ * @returns the rendered `kind: context...` line, or just the kind if it carries no context fields.
27
+ */
28
+ function formatProblem(problem: AttwProblem): string {
29
+ const context = [
30
+ problem.entrypoint ? `entrypoint=${problem.entrypoint}` : undefined,
31
+ problem.resolutionKind ? `resolution=${problem.resolutionKind}` : undefined,
32
+ problem.typesFileName ? `types=${problem.typesFileName}` : undefined,
33
+ problem.implementationFileName ? `impl=${problem.implementationFileName}` : undefined,
34
+ ].filter((value): value is string => value !== undefined)
35
+
36
+ return context.length > 0 ? `${problem.kind}: ${context.join(" ")}` : problem.kind
37
+ }
38
+
39
+ /**
40
+ * Interprets an already-parsed attw JSON report -- exported (unlike this
41
+ * package's other internal evaluators) because a repository whose package
42
+ * has enough entrypoints to make attw's own `--format json` output exceed
43
+ * ~64KB can hit a real, reproducible upstream bug: attw truncates its own
44
+ * stdout when it's a pipe (confirmed independently of repo-contract, via
45
+ * plain shell piping) rather than a TTY or regular file. Redirecting attw's
46
+ * output to a file sidesteps that bug; a consumer doing so needs this same
47
+ * interpretation logic without re-deriving it. repo-contract's own
48
+ * `repo-contract.config.ts` does exactly this once it has more than one real
49
+ * entrypoint (see that file's `arethetypeswrong` override).
50
+ * Accepts `unknown` and validates the shape itself: its inputs are always
51
+ * untrusted parsed JSON (a tool's stdout, a report file), and valid JSON of an
52
+ * unexpected shape must produce a clean fail verdict here rather than a
53
+ * `TypeError` in the caller.
54
+ * @param report - the parsed attw JSON report to evaluate.
55
+ * @returns the pass/fail outcome and its rationale.
56
+ */
57
+ export function evaluateAttwReport(report: unknown): PolicyResult {
58
+ if (typeof report !== "object" || report === null) {
59
+ return {
60
+ outcome: "fail",
61
+ rationale: "@arethetypeswrong/cli produced invalid JSON report data.",
62
+ }
63
+ }
64
+
65
+ const rawProblems = (report as { problems?: unknown }).problems
66
+ const problemGroups =
67
+ typeof rawProblems === "object" && rawProblems !== null ? Object.values(rawProblems) : []
68
+ const problems = problemGroups
69
+ .flat()
70
+ .filter((problem): problem is AttwProblem => typeof problem === "object" && problem !== null)
71
+
72
+ if (problems.length === 0) {
73
+ return {
74
+ outcome: "pass",
75
+ rationale: "@arethetypeswrong/cli found 0 packaged type-resolution problem(s).",
76
+ }
77
+ }
78
+
79
+ return {
80
+ outcome: "fail",
81
+ rationale: [
82
+ `@arethetypeswrong/cli found ${String(problems.length)} packaged type-resolution problem(s):`,
83
+ ...problems.map((problem) => `- ${formatProblem(problem)}`),
84
+ ].join("\n"),
85
+ }
86
+ }
87
+
88
+ // `--pack` runs `npm pack` against the current directory so this preset
89
+ // evaluates exactly what `files`/`exports` in package.json would ship, not
90
+ // the working tree directly. Relevant only to repositories that publish an
91
+ // npm package. Unlike repo-contract's own internal `arethetypeswrong` check,
92
+ // this preset does not exclude any entrypoint -- `--exclude-entrypoints` is
93
+ // specific to whichever of a consumer's own `exports` subpaths are
94
+ // non-code (e.g. a bare JSON schema export), so it's left as a `run`
95
+ // override for the consumer to add, rather than guessed at generically here.
96
+ /** Published-package type-resolution correctness via `@arethetypeswrong/cli`. */
97
+ export const arethetypeswrong: CheckDefinitionConfig = {
98
+ run: ["attw", "--pack", ".", "--format", "json"],
99
+ output: { format: "json" },
100
+ policy: ({ result }) => {
101
+ const missing = checkDependencyInstalled(result, "@arethetypeswrong/cli")
102
+ if (missing) return missing
103
+
104
+ const terminated = checkTerminatedAbnormally(result, "@arethetypeswrong/cli")
105
+ if (terminated) return terminated
106
+
107
+ if (!result.output?.success) {
108
+ return {
109
+ outcome: "fail",
110
+ rationale: "@arethetypeswrong/cli output could not be parsed as JSON.",
111
+ }
112
+ }
113
+
114
+ return evaluateAttwReport(result.output.value)
115
+ },
116
+ }
@@ -0,0 +1,95 @@
1
+ import type { CheckDefinitionConfig } from "../types.js"
2
+ import { checkDependencyInstalled } from "./shared/missing-dependency.js"
3
+ import { checkTerminatedAbnormally } from "./shared/terminal-status.js"
4
+
5
+ /** linkinator's own `--format json` contract -- not published as a TypeScript type by the tool. */
6
+ interface LinkinatorLink {
7
+ readonly url: string
8
+ readonly status: number
9
+ readonly state: "OK" | "BROKEN" | "SKIPPED"
10
+ readonly parent?: string
11
+ }
12
+
13
+ interface LinkinatorReport {
14
+ readonly links: readonly LinkinatorLink[]
15
+ }
16
+
17
+ /** Options accepted by {@link brokenLinks}. */
18
+ interface BrokenLinksOptions {
19
+ /** File or directory passed straight through to linkinator as its positional target. Defaults to `"."`. */
20
+ readonly start?: string
21
+ }
22
+
23
+ /**
24
+ * @param link One linkinator link result with `state === "BROKEN"`.
25
+ * @returns A single-line `url -- HTTP status (linked from parent)` summary.
26
+ */
27
+ function formatBrokenLink(link: LinkinatorLink): string {
28
+ const parent = link.parent ? ` (linked from ${link.parent})` : ""
29
+
30
+ return `${link.url} -- HTTP ${String(link.status)}${parent}`
31
+ }
32
+
33
+ /**
34
+ * Broken-link detection via linkinator, recursing through local files and
35
+ * following both local and remote links. `--skip node_modules` avoids
36
+ * wasting the crawl on vendored files that were never authored content.
37
+ * @param options - configuration for this check; see {@link BrokenLinksOptions}.
38
+ * @returns the configured check.
39
+ */
40
+ export function brokenLinks(options: BrokenLinksOptions = {}): CheckDefinitionConfig {
41
+ const { start = "." } = options
42
+
43
+ return {
44
+ run: ["linkinator", start, "--recurse", "--format", "json", "--skip", "node_modules"],
45
+ output: { format: "json" },
46
+ policy: ({ result }) => {
47
+ const missing = checkDependencyInstalled(result, "linkinator")
48
+ if (missing) return missing
49
+
50
+ const terminated = checkTerminatedAbnormally(result, "linkinator")
51
+ if (terminated) return terminated
52
+
53
+ if (!result.output?.success) {
54
+ return { outcome: "fail", rationale: "linkinator output could not be parsed as JSON." }
55
+ }
56
+
57
+ // Valid JSON of an unexpected shape (a future linkinator reporter
58
+ // change, `null`, a primitive, or any leading non-JSON line that still
59
+ // parses to something without `.links`) must produce a clean fail, not
60
+ // a TypeError that escapes the policy and crashes the whole run --
61
+ // matching how dead-code.ts / duplication.ts guard their own reports.
62
+ const value: unknown = result.output.value
63
+
64
+ if (typeof value !== "object" || value === null) {
65
+ return { outcome: "fail", rationale: "linkinator produced invalid JSON report data." }
66
+ }
67
+
68
+ const report = value as LinkinatorReport
69
+
70
+ if (!Array.isArray(report.links)) {
71
+ return { outcome: "fail", rationale: "linkinator produced invalid JSON report data." }
72
+ }
73
+
74
+ // Re-annotated rather than used directly: `Array.isArray` narrows to
75
+ // `any[]` regardless of the checked value's declared type.
76
+ const links: readonly LinkinatorLink[] = report.links
77
+ const brokenLinkResults = links.filter((link) => link.state === "BROKEN")
78
+
79
+ if (brokenLinkResults.length === 0) {
80
+ return {
81
+ outcome: "pass",
82
+ rationale: `linkinator found 0 broken link(s) across ${String(links.length)} checked.`,
83
+ }
84
+ }
85
+
86
+ return {
87
+ outcome: "fail",
88
+ rationale: [
89
+ `linkinator found ${String(brokenLinkResults.length)} broken link(s):`,
90
+ ...brokenLinkResults.map((link) => `- ${formatBrokenLink(link)}`),
91
+ ].join("\n"),
92
+ }
93
+ },
94
+ }
95
+ }
@@ -0,0 +1,77 @@
1
+ import type { CheckDefinitionConfig } from "../types.js"
2
+ import { checkDependencyInstalled } from "./shared/missing-dependency.js"
3
+ import { checkTerminatedAbnormally } from "./shared/terminal-status.js"
4
+ import { exitCodeFailRationale } from "./shared/exit-code-fail-rationale.js"
5
+
6
+ /** Options accepted by {@link commitlint}. */
7
+ interface CommitlintOptions {
8
+ /** The ref commitlint lints commits *from* (exclusive). Defaults to `"origin/main"`. */
9
+ readonly from?: string
10
+ /** The ref commitlint lints commits *to* (inclusive). Defaults to `"HEAD"`. */
11
+ readonly to?: string
12
+ }
13
+
14
+ /**
15
+ * The git commands a contributor runs to fix commit messages already on their branch. Appended
16
+ * to every failure rationale so the fix is in the error itself, not a doc they have to go find.
17
+ * @param from - the ref the rebase is based on (commitlint's own `--from`).
18
+ * @returns the remediation block, leading with a blank line.
19
+ */
20
+ function remediation(from: string): string {
21
+ return [
22
+ "",
23
+ "Rewrite the offending commit message(s) already on this branch:",
24
+ "",
25
+ ` git rebase -i ${from}`,
26
+ "",
27
+ "In the editor, for each commit flagged above change `pick` to:",
28
+ " - `reword` to keep the commit but fix its message (a valid Conventional Commit:",
29
+ " `type(scope): subject`, lowercase type, no trailing period), or",
30
+ " - `squash` / `fixup` to fold it into the commit before it, or",
31
+ " - `drop` to remove the commit entirely.",
32
+ "",
33
+ "Then, if the branch is already pushed:",
34
+ "",
35
+ " git push --force-with-lease",
36
+ ].join("\n")
37
+ }
38
+
39
+ /**
40
+ * Commit-message governance via commitlint, using whatever commitlint
41
+ * config the consumer's own repository already has (commitlint ships no
42
+ * rules of its own -- e.g. `@commitlint/config-conventional`). Exit-code
43
+ * based rather than `--format json`: commitlint has no broadly-documented,
44
+ * stable JSON CLI output, so this preset reads its plain-text report the
45
+ * same way the `format`/`typecheck` presets already do for their tools,
46
+ * rather than relying on an unconfirmed flag.
47
+ * @param options - configuration for this check; see {@link CommitlintOptions}.
48
+ * @returns the configured check.
49
+ */
50
+ export function commitlint(options: CommitlintOptions = {}): CheckDefinitionConfig {
51
+ const { from = "origin/main", to = "HEAD" } = options
52
+
53
+ return {
54
+ run: ["commitlint", "--from", from, "--to", to],
55
+ policy: ({ result }) => {
56
+ const missing = checkDependencyInstalled(result, "@commitlint/cli")
57
+ if (missing) return missing
58
+
59
+ const terminated = checkTerminatedAbnormally(result, "commitlint")
60
+ if (terminated) return terminated
61
+
62
+ if (result.exitCode === 0) {
63
+ return {
64
+ outcome: "pass",
65
+ rationale: `commitlint found 0 commit message violations between ${from} and ${to}.`,
66
+ }
67
+ }
68
+
69
+ return {
70
+ outcome: "fail",
71
+ rationale:
72
+ exitCodeFailRationale(result, "commitlint reported commit message violations") +
73
+ `\n${remediation(from)}`,
74
+ }
75
+ },
76
+ }
77
+ }