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,223 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* Knip does not expose the serialized JSON reporter result as a public
|
|
7
|
+
* TypeScript type, so the local evidence type below intentionally models
|
|
8
|
+
* that JSON contract. This targets knip's `json` reporter as of knip 6:
|
|
9
|
+
* `{ issues: KnipIssue[] }`, one entry per file, each carrying an array per
|
|
10
|
+
* issue category (`knip 5` additionally had a top-level `files: string[]` for
|
|
11
|
+
* unused files; knip 6 folds those into `issues[].files`). The policy reports
|
|
12
|
+
* the actual issue category and name instead of only returning a count.
|
|
13
|
+
*/
|
|
14
|
+
interface KnipIssueEntry {
|
|
15
|
+
readonly name: string
|
|
16
|
+
readonly line?: number
|
|
17
|
+
readonly col?: number
|
|
18
|
+
readonly pos?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `duplicates`/`cycles` group several symbols together; every other category is a flat entry. */
|
|
22
|
+
type KnipCategory = readonly (KnipIssueEntry | readonly KnipIssueEntry[])[]
|
|
23
|
+
|
|
24
|
+
interface KnipIssue {
|
|
25
|
+
readonly file: string
|
|
26
|
+
readonly dependencies?: KnipCategory
|
|
27
|
+
readonly devDependencies?: KnipCategory
|
|
28
|
+
readonly optionalPeerDependencies?: KnipCategory
|
|
29
|
+
readonly unlisted?: KnipCategory
|
|
30
|
+
readonly unresolved?: KnipCategory
|
|
31
|
+
readonly exports?: KnipCategory
|
|
32
|
+
readonly types?: KnipCategory
|
|
33
|
+
readonly nsExports?: KnipCategory
|
|
34
|
+
readonly nsTypes?: KnipCategory
|
|
35
|
+
readonly namespaceMembers?: KnipCategory
|
|
36
|
+
readonly enumMembers?: KnipCategory
|
|
37
|
+
readonly binaries?: KnipCategory
|
|
38
|
+
readonly duplicates?: KnipCategory
|
|
39
|
+
readonly cycles?: KnipCategory
|
|
40
|
+
readonly files?: KnipCategory
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface KnipReport {
|
|
44
|
+
readonly issues: readonly KnipIssue[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Options accepted by {@link deadCode}. */
|
|
48
|
+
interface DeadCodeOptions {
|
|
49
|
+
/**
|
|
50
|
+
* devDependency names to exclude from the "unused devDependency" issue
|
|
51
|
+
* category -- e.g. CLI tools knip has no way to see are used, because
|
|
52
|
+
* nothing `import`s them. Round-tripped through knip's own
|
|
53
|
+
* `--reporter-options` flag (see `readReporterOptions`) rather than kept in
|
|
54
|
+
* a closure, so the exempt list this check actually ran with is visible on
|
|
55
|
+
* the recorded command line and in persisted evidence, not just in this
|
|
56
|
+
* file's source. Defaults to an empty list -- repo-contract ships no
|
|
57
|
+
* built-in exemptions; add your own repository's here.
|
|
58
|
+
*/
|
|
59
|
+
readonly exemptUnusedDevDependencies?: readonly string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Recovers the JSON payload knip's own `--reporter-options` flag was invoked
|
|
64
|
+
* with, if any. This is how `deadCode`'s exempt list gets from `run` back
|
|
65
|
+
* into the policy purely via `result.args` -- see {@link DeadCodeOptions}.
|
|
66
|
+
* @param args - the exact argv the check's command was spawned with (`CheckEvidence.args`).
|
|
67
|
+
* @returns the parsed `--reporter-options` payload, or `undefined` if the flag is absent or its value isn't valid JSON.
|
|
68
|
+
*/
|
|
69
|
+
function readReporterOptions(args: readonly string[]): DeadCodeOptions | undefined {
|
|
70
|
+
const flagIndex = args.indexOf("--reporter-options")
|
|
71
|
+
const raw = flagIndex === -1 ? undefined : args[flagIndex + 1]
|
|
72
|
+
|
|
73
|
+
// Provably equivalent, not a coverage gap: `JSON.parse(undefined)` (what
|
|
74
|
+
// happens if this guard is removed or forced false) always throws
|
|
75
|
+
// ("undefined" is not valid JSON, confirmed empirically), which the catch
|
|
76
|
+
// block below already converts back to `undefined` -- the exact same
|
|
77
|
+
// observable result this early return produces. Kept for clarity (avoids
|
|
78
|
+
// relying on that throw-and-catch as the mechanism), not for behavior no
|
|
79
|
+
// test can otherwise reach.
|
|
80
|
+
// Stryker disable next-line ConditionalExpression,EqualityOperator,BlockStatement -- JSON.parse(undefined) always throws, and the catch block below already converts that back to undefined, the same result this early return produces
|
|
81
|
+
if (raw === undefined) {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Provably equivalent, not a coverage gap: the catch block below is the
|
|
86
|
+
// last statement in the function, so an empty block there falls off the
|
|
87
|
+
// end and implicitly returns `undefined` anyway -- identical to its
|
|
88
|
+
// explicit `return undefined`. Kept for clarity, not for behavior no
|
|
89
|
+
// test can otherwise reach.
|
|
90
|
+
// Stryker disable BlockStatement -- an empty catch block falls off the end of the function and implicitly returns undefined, identical to its explicit return
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(raw) as DeadCodeOptions
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
// Stryker restore all
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Renders one category entry as `{ name, location }`. `duplicates`/`cycles` entries are a
|
|
101
|
+
* group of symbols (knip pushes `symbols.map(...)`); every other category is a flat entry.
|
|
102
|
+
* @param entry - a single `KnipIssueEntry`, or a `duplicates`/`cycles` group of them.
|
|
103
|
+
* @returns the joined symbol name(s) and a `:line:col` suffix (empty for a group or a
|
|
104
|
+
* location-less entry).
|
|
105
|
+
*/
|
|
106
|
+
function formatEntry(entry: KnipIssueEntry | readonly KnipIssueEntry[]): {
|
|
107
|
+
readonly name: string
|
|
108
|
+
readonly location: string
|
|
109
|
+
} {
|
|
110
|
+
if (Array.isArray(entry)) {
|
|
111
|
+
const group = entry as readonly KnipIssueEntry[]
|
|
112
|
+
return { name: group.map((member) => member.name).join(", "), location: "" }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const single = entry as KnipIssueEntry
|
|
116
|
+
const location =
|
|
117
|
+
typeof single.line === "number" ? `:${String(single.line)}:${String(single.col ?? 1)}` : ""
|
|
118
|
+
return { name: single.name, location }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Dead/unused-code detection via knip.
|
|
123
|
+
* @param options - configuration for this check; see {@link DeadCodeOptions}.
|
|
124
|
+
* @returns the configured check.
|
|
125
|
+
*/
|
|
126
|
+
export function deadCode(options: DeadCodeOptions = {}): CheckDefinitionConfig {
|
|
127
|
+
const { exemptUnusedDevDependencies = [] } = options
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
run: [
|
|
131
|
+
"knip",
|
|
132
|
+
"--reporter",
|
|
133
|
+
"json",
|
|
134
|
+
"--reporter-options",
|
|
135
|
+
JSON.stringify({ exemptUnusedDevDependencies } satisfies DeadCodeOptions),
|
|
136
|
+
],
|
|
137
|
+
output: { format: "json" },
|
|
138
|
+
policy: ({ result }) => {
|
|
139
|
+
const missing = checkDependencyInstalled(result, "knip")
|
|
140
|
+
if (missing) return missing
|
|
141
|
+
|
|
142
|
+
const terminated = checkTerminatedAbnormally(result, "Knip")
|
|
143
|
+
if (terminated) return terminated
|
|
144
|
+
|
|
145
|
+
if (!result.output?.success) {
|
|
146
|
+
return { outcome: "fail", rationale: "Knip output could not be parsed as JSON." }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// `output.success` only means `JSON.parse` didn't throw -- `value` is `unknown` at
|
|
150
|
+
// runtime. Guard that it is a real object (not `null` / a primitive / an array) before
|
|
151
|
+
// trusting the `KnipReport` cast, so a policy TypeError never escapes into the run.
|
|
152
|
+
const value: unknown = result.output.value
|
|
153
|
+
|
|
154
|
+
if (typeof value !== "object" || value === null) {
|
|
155
|
+
return { outcome: "fail", rationale: "Knip produced invalid JSON report data." }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const report = value as KnipReport
|
|
159
|
+
|
|
160
|
+
if (!Array.isArray(report.issues)) {
|
|
161
|
+
return { outcome: "fail", rationale: "Knip produced invalid JSON report data." }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Re-annotated rather than used directly: `Array.isArray` narrows its
|
|
165
|
+
// argument to `any[]` regardless of the checked value's declared type (a
|
|
166
|
+
// long-standing TypeScript limitation), so `report.issues` would
|
|
167
|
+
// otherwise silently lose its element type below.
|
|
168
|
+
const issues: readonly KnipIssue[] = report.issues
|
|
169
|
+
|
|
170
|
+
const exemptDevDependencies = new Set(
|
|
171
|
+
readReporterOptions(result.args)?.exemptUnusedDevDependencies ?? [],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
const details: string[] = []
|
|
175
|
+
|
|
176
|
+
for (const issue of issues) {
|
|
177
|
+
const addEntries = (
|
|
178
|
+
label: string,
|
|
179
|
+
entries: KnipCategory | undefined,
|
|
180
|
+
ignored: ReadonlySet<string> = new Set(),
|
|
181
|
+
): void => {
|
|
182
|
+
for (const entry of entries ?? []) {
|
|
183
|
+
const { name, location } = formatEntry(entry)
|
|
184
|
+
|
|
185
|
+
if (ignored.has(name)) {
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
details.push(`${issue.file}${location} — ${label}: ${name}`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
addEntries("unused dependency", issue.dependencies)
|
|
194
|
+
addEntries("unused devDependency", issue.devDependencies, exemptDevDependencies)
|
|
195
|
+
addEntries("unused optional peer dependency", issue.optionalPeerDependencies)
|
|
196
|
+
addEntries("unlisted dependency", issue.unlisted)
|
|
197
|
+
addEntries("unresolved import", issue.unresolved)
|
|
198
|
+
addEntries("unused export", issue.exports)
|
|
199
|
+
addEntries("unused export (namespace)", issue.nsExports)
|
|
200
|
+
addEntries("unused type", issue.types)
|
|
201
|
+
addEntries("unused type (namespace)", issue.nsTypes)
|
|
202
|
+
addEntries("unused namespace member", issue.namespaceMembers)
|
|
203
|
+
addEntries("unused enum member", issue.enumMembers)
|
|
204
|
+
addEntries("unlisted binary", issue.binaries)
|
|
205
|
+
addEntries("duplicate export", issue.duplicates)
|
|
206
|
+
addEntries("circular dependency", issue.cycles)
|
|
207
|
+
addEntries("unused file", issue.files)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (details.length === 0) {
|
|
211
|
+
return { outcome: "pass", rationale: "Knip reported 0 issues." }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
outcome: "fail",
|
|
216
|
+
rationale: [
|
|
217
|
+
`Knip reported ${String(details.length)} issue(s):`,
|
|
218
|
+
...details.map((detail: string) => `- ${detail}`),
|
|
219
|
+
].join("\n"),
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import type { CheckDefinitionConfig } from "../types.js"
|
|
3
|
+
import { checkDependencyInstalled } from "./shared/missing-dependency.js"
|
|
4
|
+
import { readJsonReport } from "./shared/read-json-report.js"
|
|
5
|
+
import { checkTerminatedAbnormally } from "./shared/terminal-status.js"
|
|
6
|
+
|
|
7
|
+
interface JscpdFileSpan {
|
|
8
|
+
readonly name: string
|
|
9
|
+
readonly start: number
|
|
10
|
+
readonly end: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface JscpdDuplicate {
|
|
14
|
+
readonly format: string
|
|
15
|
+
readonly lines: number
|
|
16
|
+
readonly tokens: number
|
|
17
|
+
readonly firstFile: JscpdFileSpan
|
|
18
|
+
readonly secondFile: JscpdFileSpan
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface JscpdStatisticsTotal {
|
|
22
|
+
readonly clones: number
|
|
23
|
+
readonly duplicatedLines: number
|
|
24
|
+
readonly lines: number
|
|
25
|
+
readonly percentage: number
|
|
26
|
+
readonly sources: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface JscpdReport {
|
|
30
|
+
readonly duplicates?: readonly JscpdDuplicate[]
|
|
31
|
+
readonly statistics?: {
|
|
32
|
+
readonly total?: JscpdStatisticsTotal
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Options accepted by {@link duplication}. */
|
|
37
|
+
interface DuplicationOptions {
|
|
38
|
+
/** Directory to scan for duplicated code, passed straight through to jscpd as its positional target. Defaults to `"."` -- narrow it (e.g. `"src"`) to scope the scan to your own source tree. */
|
|
39
|
+
readonly path?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Kept in lockstep with the `--output reports/jscpd` argument below. Both are
|
|
43
|
+
// relative and resolve against the *host* process's cwd, not the check's own
|
|
44
|
+
// `cwd` option: jscpd writes here and this policy's `readFile` reads from here,
|
|
45
|
+
// so a check configured with a different `cwd` would look in the wrong place.
|
|
46
|
+
// This preset does not expose an output-path option -- a consumer needing one
|
|
47
|
+
// runs jscpd via a custom `run`/`policy` instead.
|
|
48
|
+
const REPORT_PATH = "reports/jscpd/jscpd-report.json"
|
|
49
|
+
|
|
50
|
+
// jscpd's JSON reporter writes its report to disk -- it never prints JSON to
|
|
51
|
+
// stdout, which instead carries decorative progress/summary text -- so the
|
|
52
|
+
// report is read from disk directly. jscpd's own `--threshold`/`--exit-code`
|
|
53
|
+
// flags are deliberately not used: this preset reads the raw clone count
|
|
54
|
+
// from evidence and owns the pass/fail decision itself, exactly as the
|
|
55
|
+
// deadCode/securityDeps presets already do for their own tools.
|
|
56
|
+
/**
|
|
57
|
+
* Duplicated-code detection via jscpd.
|
|
58
|
+
* @param options - configuration for this check; see {@link DuplicationOptions}.
|
|
59
|
+
* @returns the configured check.
|
|
60
|
+
*/
|
|
61
|
+
export function duplication(options: DuplicationOptions = {}): CheckDefinitionConfig {
|
|
62
|
+
const { path = "." } = options
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
run: ["jscpd", path, "--reporters", "json", "--output", "reports/jscpd", "--silent"],
|
|
66
|
+
policy: async ({ result }) => {
|
|
67
|
+
const missing = checkDependencyInstalled(result, "jscpd")
|
|
68
|
+
if (missing) return missing
|
|
69
|
+
|
|
70
|
+
const terminated = checkTerminatedAbnormally(result, "jscpd")
|
|
71
|
+
if (terminated) return terminated
|
|
72
|
+
|
|
73
|
+
const parsed = await readJsonReport<JscpdReport>(
|
|
74
|
+
// Provably equivalent, not a coverage gap: `JSON.parse` coerces
|
|
75
|
+
// whatever `readFile` returns via that value's own `toString()`
|
|
76
|
+
// (confirmed empirically for a Buffer, which is what a mutated/
|
|
77
|
+
// unrecognized encoding argument produces here) -- and `Buffer`'s
|
|
78
|
+
// default `toString()` encoding is itself utf8, so parsing succeeds
|
|
79
|
+
// identically either way. No test can observe a difference through
|
|
80
|
+
// this policy's output.
|
|
81
|
+
// Stryker disable next-line StringLiteral -- JSON.parse coerces via toString(), which defaults to utf8 for a Buffer, so parsing succeeds identically either way
|
|
82
|
+
() => readFile(REPORT_PATH, "utf8"),
|
|
83
|
+
"jscpd did not produce its expected JSON report.",
|
|
84
|
+
"jscpd produced invalid JSON evidence.",
|
|
85
|
+
)
|
|
86
|
+
if (!parsed.ok) return parsed.result
|
|
87
|
+
// `readJsonReport<T>` narrows only at the type level -- the value is `unknown` at
|
|
88
|
+
// runtime. Guard it is a real object before trusting the cast, so `.statistics` /
|
|
89
|
+
// `.duplicates` never throw a TypeError out of the policy (matching markdownlint.ts).
|
|
90
|
+
const value: unknown = parsed.value
|
|
91
|
+
|
|
92
|
+
if (typeof value !== "object" || value === null) {
|
|
93
|
+
return { outcome: "fail", rationale: "jscpd produced invalid JSON report data." }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const report = value as JscpdReport
|
|
97
|
+
const total = report.statistics?.total
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
!Array.isArray(report.duplicates) ||
|
|
101
|
+
!total ||
|
|
102
|
+
typeof total.percentage !== "number" ||
|
|
103
|
+
!Number.isFinite(total.percentage)
|
|
104
|
+
) {
|
|
105
|
+
// `total.percentage` feeds `.toFixed(2)` below -- a missing or non-finite
|
|
106
|
+
// value would throw or render "NaN" instead of failing cleanly.
|
|
107
|
+
return { outcome: "fail", rationale: "jscpd produced invalid JSON report data." }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Re-annotated rather than used directly: `Array.isArray` narrows its
|
|
111
|
+
// argument to `any[]` regardless of the checked value's declared type (a
|
|
112
|
+
// long-standing TypeScript limitation), so `report.duplicates` would
|
|
113
|
+
// otherwise silently lose its `JscpdDuplicate` element type below.
|
|
114
|
+
const duplicates: readonly JscpdDuplicate[] = report.duplicates
|
|
115
|
+
|
|
116
|
+
if (duplicates.length === 0) {
|
|
117
|
+
return {
|
|
118
|
+
outcome: "pass",
|
|
119
|
+
rationale: `jscpd found 0 duplicated block(s) across ${String(total.sources)} file(s) (${String(total.lines)} lines).`,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const details = duplicates.map(
|
|
124
|
+
(duplicate) =>
|
|
125
|
+
`${duplicate.firstFile.name}:${String(duplicate.firstFile.start)} duplicates ${duplicate.secondFile.name}:${String(duplicate.secondFile.start)} -- ${String(duplicate.lines)} lines / ${String(duplicate.tokens)} tokens`,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
outcome: "fail",
|
|
130
|
+
rationale: [
|
|
131
|
+
`jscpd found ${String(duplicates.length)} duplicated block(s) (${total.percentage.toFixed(2)}% of ${String(total.lines)} lines across ${String(total.sources)} file(s)):`,
|
|
132
|
+
...details.map((detail) => `- ${detail}`),
|
|
133
|
+
].join("\n"),
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* Playwright's own `--reporter=json` contract -- not published as a
|
|
7
|
+
* TypeScript type by the tool. Suites nest (a project, then a file, then
|
|
8
|
+
* `describe` blocks), so failing specs are collected recursively.
|
|
9
|
+
*/
|
|
10
|
+
interface PlaywrightTestResult {
|
|
11
|
+
readonly status?: string
|
|
12
|
+
readonly error?: { readonly message?: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PlaywrightTest {
|
|
16
|
+
readonly results?: readonly PlaywrightTestResult[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PlaywrightSpec {
|
|
20
|
+
readonly title: string
|
|
21
|
+
readonly file?: string
|
|
22
|
+
readonly line?: number
|
|
23
|
+
readonly ok: boolean
|
|
24
|
+
readonly tests?: readonly PlaywrightTest[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PlaywrightSuite {
|
|
28
|
+
readonly title: string
|
|
29
|
+
readonly specs?: readonly PlaywrightSpec[]
|
|
30
|
+
readonly suites?: readonly PlaywrightSuite[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PlaywrightStats {
|
|
34
|
+
readonly expected: number
|
|
35
|
+
readonly unexpected: number
|
|
36
|
+
readonly flaky: number
|
|
37
|
+
readonly skipped: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface PlaywrightReport {
|
|
41
|
+
readonly suites?: readonly PlaywrightSuite[]
|
|
42
|
+
readonly stats?: PlaywrightStats
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Recursively collects one human-readable line per failing spec across a (possibly nested) suite tree.
|
|
47
|
+
* @param suites - the suite tree to walk, as `PlaywrightReport.suites` or a suite's own nested `suites`.
|
|
48
|
+
* @returns one rendered `file:line title -- message` line per failing spec, depth-first.
|
|
49
|
+
*/
|
|
50
|
+
function collectFailingSpecs(suites: readonly PlaywrightSuite[]): string[] {
|
|
51
|
+
const details: string[] = []
|
|
52
|
+
|
|
53
|
+
for (const suite of suites) {
|
|
54
|
+
for (const spec of suite.specs ?? []) {
|
|
55
|
+
if (spec.ok) continue
|
|
56
|
+
|
|
57
|
+
const location = spec.line !== undefined ? `:${String(spec.line)}` : ""
|
|
58
|
+
// Provably equivalent, not a coverage gap: whatever non-array/non-test
|
|
59
|
+
// garbage a mutated `?? []` fallback substitutes here, every field
|
|
60
|
+
// access on it below (`.results`, `.error`, `.message`) resolves
|
|
61
|
+
// safely to `undefined` via its own optional chaining -- confirmed by
|
|
62
|
+
// hand-tracing both fallback sites -- converging on the exact same
|
|
63
|
+
// `message: undefined` this line already produces for a genuinely
|
|
64
|
+
// empty `spec.tests`/`t.results`. No test can observe a difference.
|
|
65
|
+
// Stryker disable next-line ArrayDeclaration -- any fallback garbage here resolves safely to undefined via optional chaining below, same as a genuinely empty array
|
|
66
|
+
const lastResult = (spec.tests ?? []).flatMap((t) => t.results ?? []).at(-1)
|
|
67
|
+
const message = lastResult?.error?.message?.trim()
|
|
68
|
+
|
|
69
|
+
details.push(
|
|
70
|
+
`${spec.file ?? suite.title}${location} ${spec.title}${message ? ` — ${message}` : ""}`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (suite.suites) details.push(...collectFailingSpecs(suite.suites))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return details
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** End-to-end test execution via Playwright, reading its JSON reporter output. */
|
|
81
|
+
export const e2e: CheckDefinitionConfig = {
|
|
82
|
+
run: ["playwright", "test", "--reporter=json"],
|
|
83
|
+
output: { format: "json" },
|
|
84
|
+
policy: ({ result }) => {
|
|
85
|
+
const missing = checkDependencyInstalled(result, "@playwright/test")
|
|
86
|
+
if (missing) return missing
|
|
87
|
+
|
|
88
|
+
const terminated = checkTerminatedAbnormally(result, "Playwright")
|
|
89
|
+
if (terminated) return terminated
|
|
90
|
+
|
|
91
|
+
if (!result.output?.success) {
|
|
92
|
+
return { outcome: "fail", rationale: "Playwright output could not be parsed as JSON." }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// `output.success` only means `JSON.parse` didn't throw -- `value` is `unknown` at
|
|
96
|
+
// runtime. Guard it is a real object before trusting the cast, matching how
|
|
97
|
+
// dead-code.ts / duplication.ts guard theirs, so `.stats` never throws out of the policy.
|
|
98
|
+
const value: unknown = result.output.value
|
|
99
|
+
|
|
100
|
+
if (typeof value !== "object" || value === null) {
|
|
101
|
+
return { outcome: "fail", rationale: "Playwright produced invalid JSON report data." }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const report = value as PlaywrightReport
|
|
105
|
+
const stats = report.stats
|
|
106
|
+
|
|
107
|
+
if (!stats || typeof stats.unexpected !== "number" || typeof stats.flaky !== "number") {
|
|
108
|
+
// The outcome checks below compare `unexpected`/`flaky` numerically -- a
|
|
109
|
+
// missing or non-numeric counter must fail cleanly, not slip through every
|
|
110
|
+
// comparison as `false`.
|
|
111
|
+
return { outcome: "fail", rationale: "Playwright produced invalid JSON report data." }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (stats.unexpected === 0 && stats.flaky === 0) {
|
|
115
|
+
return {
|
|
116
|
+
outcome: "pass",
|
|
117
|
+
rationale: `Playwright completed ${String(stats.expected)} test(s) with 0 unexpected failures.`,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (stats.unexpected > 0) {
|
|
122
|
+
// Provably equivalent, not a coverage gap: collectFailingSpecs itself
|
|
123
|
+
// only ever reads `.specs`/`.suites` off each element via safe
|
|
124
|
+
// optional chaining (see its own comment), so substituting any
|
|
125
|
+
// non-suite garbage for a genuinely absent `report.suites` still
|
|
126
|
+
// yields zero collected detail lines either way.
|
|
127
|
+
// Stryker disable next-line ArrayDeclaration -- collectFailingSpecs only reads .specs/.suites via safe optional chaining, so fallback garbage yields zero details either way
|
|
128
|
+
const details = collectFailingSpecs(report.suites ?? [])
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
outcome: "fail",
|
|
132
|
+
rationale: [
|
|
133
|
+
`Playwright reported ${String(stats.unexpected)} unexpected failure(s):`,
|
|
134
|
+
...details.map((detail) => `- ${detail}`),
|
|
135
|
+
].join("\n"),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
outcome: "warn",
|
|
141
|
+
rationale: `Playwright completed with ${String(stats.flaky)} flaky test(s) that eventually passed on retry.`,
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
/** Code formatting via Prettier, applied in place. */
|
|
7
|
+
export const format: CheckDefinitionConfig = {
|
|
8
|
+
run: ["prettier", "--write", "."],
|
|
9
|
+
policy: ({ result }) => {
|
|
10
|
+
const missing = checkDependencyInstalled(result, "prettier")
|
|
11
|
+
if (missing) return missing
|
|
12
|
+
|
|
13
|
+
const terminated = checkTerminatedAbnormally(result, "Prettier")
|
|
14
|
+
if (terminated) return terminated
|
|
15
|
+
|
|
16
|
+
if (result.exitCode === 0) {
|
|
17
|
+
return { outcome: "pass", rationale: "Prettier reported no formatting failures." }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
outcome: "fail",
|
|
22
|
+
rationale: exitCodeFailRationale(result, "Prettier reported formatting failures"),
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated, growing catalog of preset checks for tools common across
|
|
3
|
+
* TypeScript/JavaScript repositories -- each preset encodes how to execute
|
|
4
|
+
* and interpret a common tool, never a repository's definition of quality
|
|
5
|
+
* (see specs/decisions/0004-public-surface-stays-narrow-no-cli-experimental-presets.md). Import a preset,
|
|
6
|
+
* spread it into your own `checks` record, and override whatever you need
|
|
7
|
+
* -- most often `policy`; a factory preset's options are the preferred way
|
|
8
|
+
* to change what it executes, a direct `run` override is an escape hatch.
|
|
9
|
+
* Never re-exported from the package root (`src/index.ts`) -- presets are
|
|
10
|
+
* an opt-in extra, not part of the core execution/evidence/policy surface
|
|
11
|
+
* those exports describe, and this barrel never re-exports anything from
|
|
12
|
+
* there either; the two stay independent.
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
export { arethetypeswrong } from "./arethetypeswrong.js"
|
|
16
|
+
export { brokenLinks } from "./broken-links.js"
|
|
17
|
+
export { commitlint } from "./commitlint.js"
|
|
18
|
+
export { deadCode } from "./dead-code.js"
|
|
19
|
+
export { duplication } from "./duplication.js"
|
|
20
|
+
export { e2e } from "./e2e.js"
|
|
21
|
+
export { format } from "./format.js"
|
|
22
|
+
export { license } from "./license.js"
|
|
23
|
+
export { lint } from "./lint.js"
|
|
24
|
+
export { markdownlint } from "./markdownlint.js"
|
|
25
|
+
export { publint } from "./publint.js"
|
|
26
|
+
export { securityDeps } from "./security-deps.js"
|
|
27
|
+
export { securitySecrets } from "./security-secrets.js"
|
|
28
|
+
export { stylelint } from "./stylelint.js"
|
|
29
|
+
export { test } from "./test.js"
|
|
30
|
+
export { typecheck } from "./typecheck.js"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { CheckDefinitionConfig } from "../types.js"
|
|
2
|
+
import { exitCodeFailRationale } from "./shared/exit-code-fail-rationale.js"
|
|
3
|
+
import { checkDependencyInstalled } from "./shared/missing-dependency.js"
|
|
4
|
+
import { checkTerminatedAbnormally } from "./shared/terminal-status.js"
|
|
5
|
+
|
|
6
|
+
interface LicenseeEntry {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly version: string
|
|
9
|
+
readonly license?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// `--production` scopes evaluation to the dependency graph actually shipped
|
|
13
|
+
// to consumers -- devDependencies never end up in a consumer's install, so
|
|
14
|
+
// their licenses carry no obligation onto this package's own license. Same
|
|
15
|
+
// runtime-only rationale the securityDeps preset already applies to `npm
|
|
16
|
+
// audit --omit=dev`. `--osi` treats Open Source Initiative approval as the
|
|
17
|
+
// approval criterion rather than a repository-maintained SPDX allowlist --
|
|
18
|
+
// the same "policy is repository policy, not a repo-contract opinion"
|
|
19
|
+
// stance securityDeps documents, just resolved to the broadest
|
|
20
|
+
// widely-recognized standard instead of a bespoke list. `--errors-only`
|
|
21
|
+
// keeps the ndjson evidence limited to the packages actually driving the
|
|
22
|
+
// verdict, matching the deadCode/duplication presets' report-issues-only
|
|
23
|
+
// shape.
|
|
24
|
+
/** Dependency license compliance via licensee. */
|
|
25
|
+
export const license: CheckDefinitionConfig = {
|
|
26
|
+
run: ["licensee", "--production", "--osi", "--errors-only", "--ndjson"],
|
|
27
|
+
policy: ({ result }) => {
|
|
28
|
+
const missing = checkDependencyInstalled(result, "licensee")
|
|
29
|
+
if (missing) return missing
|
|
30
|
+
|
|
31
|
+
const terminated = checkTerminatedAbnormally(result, "licensee")
|
|
32
|
+
if (terminated) return terminated
|
|
33
|
+
|
|
34
|
+
const trimmed = result.stdout.trim()
|
|
35
|
+
|
|
36
|
+
// `--errors-only` makes licensee exit non-zero *because* it found an
|
|
37
|
+
// offending dependency -- so a non-zero exit with ndjson on stdout is the
|
|
38
|
+
// expected failure path, handled below. But a non-zero exit with *empty*
|
|
39
|
+
// stdout means licensee never evaluated anything (node_modules not
|
|
40
|
+
// installed, an unreadable dependency tree, an internal `die()`): the
|
|
41
|
+
// error is on stderr and there is nothing to parse. Treating that as
|
|
42
|
+
// "found 0 offending dependencies" would let the compliance gate pass on
|
|
43
|
+
// a run that never actually ran. Only an empty stdout from a process that
|
|
44
|
+
// exited cleanly on its own is a genuine "nothing offending" pass.
|
|
45
|
+
if (trimmed.length === 0) {
|
|
46
|
+
if (result.status === "completed" && result.exitCode === 0) {
|
|
47
|
+
return {
|
|
48
|
+
outcome: "pass",
|
|
49
|
+
rationale: "licensee found 0 production dependencies with a non-OSI-approved license.",
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
outcome: "fail",
|
|
55
|
+
rationale: exitCodeFailRationale(
|
|
56
|
+
result,
|
|
57
|
+
"licensee did not evaluate any dependency licenses (it produced no output)",
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let entries: readonly LicenseeEntry[]
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
entries = trimmed
|
|
66
|
+
.split("\n")
|
|
67
|
+
// licensee emits one JSON object per line; a blank line between
|
|
68
|
+
// records (some versions/locales do this, as can a leading
|
|
69
|
+
// informational line) is not invalid evidence -- skip it rather than
|
|
70
|
+
// feeding "" to JSON.parse and discarding the real violation list.
|
|
71
|
+
.map((line) => line.trim())
|
|
72
|
+
.filter((line) => line.length > 0)
|
|
73
|
+
.map((line) => JSON.parse(line) as LicenseeEntry)
|
|
74
|
+
} catch {
|
|
75
|
+
return { outcome: "fail", rationale: "licensee produced invalid JSON evidence." }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const details = entries
|
|
79
|
+
.map((entry) => `${entry.name}@${entry.version}: ${entry.license ?? "unknown license"}`)
|
|
80
|
+
.sort()
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
outcome: "fail",
|
|
84
|
+
rationale: [
|
|
85
|
+
`licensee found ${String(details.length)} production dependency(ies) without an OSI-approved license:`,
|
|
86
|
+
...details.map((detail) => `- ${detail}`),
|
|
87
|
+
].join("\n"),
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|