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