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,116 @@
|
|
|
1
|
+
import type { CheckDefinitionConfig } from "../types.js"
|
|
2
|
+
import { combinedOutput } from "./shared/exit-code-fail-rationale.js"
|
|
3
|
+
import { errorWarningPassPolicy } from "./shared/error-warning-pass-policy.js"
|
|
4
|
+
import { checkDependencyInstalled } from "./shared/missing-dependency.js"
|
|
5
|
+
import { checkTerminatedAbnormally } from "./shared/terminal-status.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ESLint does not expose its JSON formatter result as a public LintResult
|
|
9
|
+
* export -- these types describe the JSON formatter contract rather than
|
|
10
|
+
* ESLint's internal Linter types.
|
|
11
|
+
*/
|
|
12
|
+
interface EslintMessage {
|
|
13
|
+
readonly ruleId: string | null
|
|
14
|
+
readonly severity: 0 | 1 | 2
|
|
15
|
+
readonly message: string
|
|
16
|
+
readonly line: number
|
|
17
|
+
readonly column: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface EslintResult {
|
|
21
|
+
readonly filePath: string
|
|
22
|
+
readonly messages: readonly EslintMessage[]
|
|
23
|
+
readonly errorCount: number
|
|
24
|
+
readonly warningCount: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Options accepted by {@link lint}. */
|
|
28
|
+
interface LintOptions {
|
|
29
|
+
/** Path (or glob) passed straight through to ESLint as its positional target. Defaults to `"."` -- narrow it (e.g. `"src"`) to scope linting to your own source tree. */
|
|
30
|
+
readonly path?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Renders every message at the given severity across all files as one `file:line:column [rule]: message` line each.
|
|
35
|
+
* @param results - the full ESLint JSON formatter result to filter and render.
|
|
36
|
+
* @param severity - which severity to render (`2` for errors, `1` for warnings).
|
|
37
|
+
* @returns one rendered line per matching message, across every file.
|
|
38
|
+
*/
|
|
39
|
+
function renderMessages(results: readonly EslintResult[], severity: 1 | 2): string[] {
|
|
40
|
+
return results.flatMap((file: EslintResult): string[] =>
|
|
41
|
+
file.messages
|
|
42
|
+
.filter((message: EslintMessage) => message.severity === severity)
|
|
43
|
+
.map((message: EslintMessage) => {
|
|
44
|
+
const rule = message.ruleId ? ` [${message.ruleId}]` : ""
|
|
45
|
+
|
|
46
|
+
return `${file.filePath}:${String(message.line)}:${String(message.column)}${rule}: ${message.message}`
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Static analysis via ESLint, using whatever `eslint.config.js` the
|
|
53
|
+
* consumer's own repository already has -- this preset makes no assumption
|
|
54
|
+
* about rule configuration, only about how to run the tool and interpret
|
|
55
|
+
* its JSON output. Severity `2` (error) blocks; severity `1` (warning) is
|
|
56
|
+
* reported but never blocks -- ESLint's own severities already encode that
|
|
57
|
+
* distinction, so this preset just respects it rather than treating every
|
|
58
|
+
* finding as equally blocking.
|
|
59
|
+
* @param options - configuration for this check; see {@link LintOptions}.
|
|
60
|
+
* @returns the configured check.
|
|
61
|
+
*/
|
|
62
|
+
export function lint(options: LintOptions = {}): CheckDefinitionConfig {
|
|
63
|
+
const { path = "." } = options
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
run: ["eslint", path, "--format", "json"],
|
|
67
|
+
output: { format: "json" },
|
|
68
|
+
policy: ({ result }) => {
|
|
69
|
+
const missing = checkDependencyInstalled(result, "eslint")
|
|
70
|
+
if (missing) return missing
|
|
71
|
+
|
|
72
|
+
const terminated = checkTerminatedAbnormally(result, "ESLint")
|
|
73
|
+
if (terminated) return terminated
|
|
74
|
+
|
|
75
|
+
if (!result.output?.success) {
|
|
76
|
+
// ESLint exits non-zero with no JSON on stdout both for a flat-config
|
|
77
|
+
// load error and for a glob that matched no lintable files. Surface
|
|
78
|
+
// whatever it printed so the consumer sees the real cause instead of
|
|
79
|
+
// only "could not be parsed as JSON".
|
|
80
|
+
const printed = combinedOutput(result)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
outcome: "fail",
|
|
84
|
+
rationale:
|
|
85
|
+
printed.length > 0
|
|
86
|
+
? `ESLint output could not be parsed as JSON. ESLint printed:\n${printed}`
|
|
87
|
+
: "ESLint output could not be parsed as JSON.",
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Valid JSON of an unexpected shape (an ESLint formatter change, a
|
|
92
|
+
// primitive, an entry without a `messages` array) must fail cleanly, not
|
|
93
|
+
// throw a TypeError out of `renderMessages` -- matching the other JSON
|
|
94
|
+
// presets' guards.
|
|
95
|
+
const value: unknown = result.output.value
|
|
96
|
+
if (
|
|
97
|
+
!Array.isArray(value) ||
|
|
98
|
+
!value.every(
|
|
99
|
+
(file): file is EslintResult =>
|
|
100
|
+
typeof file === "object" &&
|
|
101
|
+
file !== null &&
|
|
102
|
+
Array.isArray((file as { messages?: unknown }).messages),
|
|
103
|
+
)
|
|
104
|
+
) {
|
|
105
|
+
return { outcome: "fail", rationale: "ESLint output could not be parsed as JSON." }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const results: readonly EslintResult[] = value
|
|
109
|
+
|
|
110
|
+
const errorDetails = renderMessages(results, 2)
|
|
111
|
+
const warningDetails = renderMessages(results, 1)
|
|
112
|
+
|
|
113
|
+
return errorWarningPassPolicy("ESLint", errorDetails, warningDetails)
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* markdownlint-cli2 has no stdout JSON mode -- this describes its own JSON
|
|
9
|
+
* output-formatter contract (markdownlint-cli2-formatter-json), read back
|
|
10
|
+
* from disk. Not published as a TypeScript type by either package.
|
|
11
|
+
*/
|
|
12
|
+
interface MarkdownlintFinding {
|
|
13
|
+
readonly fileName: string
|
|
14
|
+
readonly lineNumber: number
|
|
15
|
+
readonly ruleNames: readonly string[]
|
|
16
|
+
readonly ruleDescription: string
|
|
17
|
+
readonly errorDetail: string | null
|
|
18
|
+
readonly severity: "error" | "warning"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Options accepted by {@link markdownlint}. */
|
|
22
|
+
interface MarkdownlintOptions {
|
|
23
|
+
/** Glob passed straight through to markdownlint-cli2 as its positional target. Defaults to `"**\/*.md"`. */
|
|
24
|
+
readonly glob?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const REPORT_PATH = "reports/markdownlint.json"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param finding One markdownlint-cli2 finding.
|
|
31
|
+
* @returns A single-line `file:line [rule]: description (detail)` summary.
|
|
32
|
+
*/
|
|
33
|
+
function formatFinding(finding: MarkdownlintFinding): string {
|
|
34
|
+
const rule = finding.ruleNames.join("/")
|
|
35
|
+
const detail = finding.errorDetail ? ` (${finding.errorDetail})` : ""
|
|
36
|
+
|
|
37
|
+
return `${finding.fileName}:${String(finding.lineNumber)} [${rule}]: ${finding.ruleDescription}${detail}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Markdown structure/style lint via markdownlint-cli2. Unlike this
|
|
42
|
+
* package's other file-based-report presets, the report path is
|
|
43
|
+
* config-driven rather than a CLI flag -- markdownlint-cli2 only writes
|
|
44
|
+
* JSON when its own config file requests it. This preset assumes the
|
|
45
|
+
* consumer's `.markdownlint-cli2.jsonc` includes:
|
|
46
|
+
* ```jsonc
|
|
47
|
+
* "outputFormatters": [["markdownlint-cli2-formatter-json", { "name": "reports/markdownlint.json" }]]
|
|
48
|
+
* ```
|
|
49
|
+
* which also requires the `markdownlint-cli2-formatter-json` package
|
|
50
|
+
* alongside `markdownlint-cli2` itself.
|
|
51
|
+
* @param options - configuration for this check; see {@link MarkdownlintOptions}.
|
|
52
|
+
* @returns the configured check.
|
|
53
|
+
*/
|
|
54
|
+
export function markdownlint(options: MarkdownlintOptions = {}): CheckDefinitionConfig {
|
|
55
|
+
const { glob = "**/*.md" } = options
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
run: ["markdownlint-cli2", glob],
|
|
59
|
+
policy: async ({ result }) => {
|
|
60
|
+
const missing = checkDependencyInstalled(result, "markdownlint-cli2")
|
|
61
|
+
if (missing) return missing
|
|
62
|
+
|
|
63
|
+
const terminated = checkTerminatedAbnormally(result, "markdownlint-cli2")
|
|
64
|
+
if (terminated) return terminated
|
|
65
|
+
|
|
66
|
+
const parsed = await readJsonReport<readonly MarkdownlintFinding[]>(
|
|
67
|
+
// Provably equivalent, not a coverage gap -- see the identical
|
|
68
|
+
// comment in src/presets/duplication.ts for why.
|
|
69
|
+
// Stryker disable next-line StringLiteral -- JSON.parse coerces via toString(), which defaults to utf8 for a Buffer, so parsing succeeds identically either way
|
|
70
|
+
() => readFile(REPORT_PATH, "utf8"),
|
|
71
|
+
"markdownlint-cli2 did not produce its expected JSON report -- confirm your " +
|
|
72
|
+
".markdownlint-cli2.jsonc configures outputFormatters to write " +
|
|
73
|
+
`"${REPORT_PATH}" (requires the markdownlint-cli2-formatter-json package).`,
|
|
74
|
+
"markdownlint-cli2 produced invalid JSON evidence.",
|
|
75
|
+
)
|
|
76
|
+
if (!parsed.ok) return parsed.result
|
|
77
|
+
|
|
78
|
+
// `readJsonReport<T>` narrows only at the type level -- the parsed
|
|
79
|
+
// value is `unknown` at runtime. Valid JSON that isn't an array (an
|
|
80
|
+
// object from a different/older formatter, `null`) must fail cleanly
|
|
81
|
+
// rather than throw `.length`/`.map` out of the policy and crash the
|
|
82
|
+
// run, matching duplication.ts's `Array.isArray` guard.
|
|
83
|
+
if (!Array.isArray(parsed.value)) {
|
|
84
|
+
return {
|
|
85
|
+
outcome: "fail",
|
|
86
|
+
rationale: "markdownlint-cli2 produced invalid JSON report data.",
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const findings: readonly MarkdownlintFinding[] = parsed.value
|
|
91
|
+
|
|
92
|
+
if (findings.length === 0) {
|
|
93
|
+
return { outcome: "pass", rationale: "markdownlint-cli2 reported 0 issues." }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
outcome: "fail",
|
|
98
|
+
rationale: [
|
|
99
|
+
`markdownlint-cli2 reported ${String(findings.length)} issue(s):`,
|
|
100
|
+
...findings.map((finding) => `- ${formatFinding(finding)}`),
|
|
101
|
+
].join("\n"),
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CheckDefinitionConfig } from "../types.js"
|
|
2
|
+
import { combinedOutput, 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
|
+
/**
|
|
7
|
+
* publint has no machine-readable output mode -- only a plain-text CLI
|
|
8
|
+
* reporter (see its own `src/cli.js` `formatMessages`). Its process exit
|
|
9
|
+
* code reflects only 'error'-level findings; 'warning'/'suggestion'-level
|
|
10
|
+
* findings never affect it. This preset reads the same three section
|
|
11
|
+
* headers publint's own CLI writes ("Errors:", "Warnings:", "Suggestions:")
|
|
12
|
+
* to distinguish blocking findings from non-blocking ones, without
|
|
13
|
+
* depending on any per-message structure publint doesn't expose. Relevant
|
|
14
|
+
* only to repositories that publish an npm package.
|
|
15
|
+
*/
|
|
16
|
+
export const publint: CheckDefinitionConfig = {
|
|
17
|
+
run: ["publint", "run"],
|
|
18
|
+
policy: ({ result }) => {
|
|
19
|
+
const missing = checkDependencyInstalled(result, "publint")
|
|
20
|
+
if (missing) return missing
|
|
21
|
+
|
|
22
|
+
const terminated = checkTerminatedAbnormally(result, "publint")
|
|
23
|
+
if (terminated) return terminated
|
|
24
|
+
|
|
25
|
+
const output = combinedOutput(result)
|
|
26
|
+
|
|
27
|
+
if (result.exitCode === 0) {
|
|
28
|
+
return output.includes("Warnings:") || output.includes("Suggestions:")
|
|
29
|
+
? { outcome: "warn", rationale: `publint reported non-blocking finding(s):\n${output}` }
|
|
30
|
+
: { outcome: "pass", rationale: "publint reported no packaging errors." }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
outcome: "fail",
|
|
35
|
+
rationale: exitCodeFailRationale(result, "publint reported packaging error(s)"),
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { CheckDefinitionConfig } from "../types.js"
|
|
2
|
+
import { checkTerminatedAbnormally } from "./shared/terminal-status.js"
|
|
3
|
+
|
|
4
|
+
interface NpmAuditVulnerabilityCounts {
|
|
5
|
+
readonly info: number
|
|
6
|
+
readonly low: number
|
|
7
|
+
readonly moderate: number
|
|
8
|
+
readonly high: number
|
|
9
|
+
readonly critical: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface NpmAuditAdvisory {
|
|
13
|
+
readonly severity?: string
|
|
14
|
+
readonly range?: string
|
|
15
|
+
readonly isDirect?: boolean
|
|
16
|
+
readonly via?: readonly unknown[]
|
|
17
|
+
readonly fixAvailable?:
|
|
18
|
+
| boolean
|
|
19
|
+
| {
|
|
20
|
+
readonly name?: string
|
|
21
|
+
readonly version?: string
|
|
22
|
+
readonly isSemVerMajor?: boolean
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface NpmAuditReport {
|
|
27
|
+
readonly auditReportVersion?: number
|
|
28
|
+
readonly vulnerabilities?: Record<string, NpmAuditAdvisory>
|
|
29
|
+
readonly metadata?: {
|
|
30
|
+
readonly vulnerabilities?: NpmAuditVulnerabilityCounts
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --omit=dev evaluates only the dependency graph shipped to consumers.
|
|
35
|
+
// Development dependency vulnerabilities remain evidence but do not block
|
|
36
|
+
// this runtime security contract. The blocking threshold is repository
|
|
37
|
+
// policy rather than an opinion imposed by repo-contract.
|
|
38
|
+
//
|
|
39
|
+
// No missing-dependency check here (see shared/missing-dependency.ts's own
|
|
40
|
+
// doc comment): this preset shells out to `npm` itself, which cannot be
|
|
41
|
+
// "missing" in any environment capable of running `npm run <script>` at
|
|
42
|
+
// all -- the exception is intentional, not an oversight.
|
|
43
|
+
/** Dependency vulnerability scanning via `npm audit`. */
|
|
44
|
+
export const securityDeps: CheckDefinitionConfig = {
|
|
45
|
+
run: ["npm", "audit", "--omit=dev", "--json"],
|
|
46
|
+
output: { format: "json" },
|
|
47
|
+
policy: ({ result }) => {
|
|
48
|
+
const terminated = checkTerminatedAbnormally(result, "npm audit")
|
|
49
|
+
if (terminated) return terminated
|
|
50
|
+
|
|
51
|
+
if (!result.output?.success) {
|
|
52
|
+
return { outcome: "fail", rationale: "npm audit output could not be parsed as JSON." }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// `output.success` only means `JSON.parse` didn't throw -- valid JSON of an
|
|
56
|
+
// unexpected shape (`null`, a primitive) must fail cleanly, not throw
|
|
57
|
+
// `.metadata` out of the policy.
|
|
58
|
+
const value: unknown = result.output.value
|
|
59
|
+
if (typeof value !== "object" || value === null) {
|
|
60
|
+
return { outcome: "fail", rationale: "npm audit produced invalid JSON report data." }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const report = value as NpmAuditReport
|
|
64
|
+
const vulnerabilities = report.metadata?.vulnerabilities
|
|
65
|
+
|
|
66
|
+
if (!vulnerabilities) {
|
|
67
|
+
return {
|
|
68
|
+
outcome: "fail",
|
|
69
|
+
rationale: "npm audit produced no vulnerability summary.",
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Every count is cast from untrusted parsed JSON. A proxy registry, a
|
|
74
|
+
// future npm format, or a truncated report can omit a severity field --
|
|
75
|
+
// `undefined + n` is `NaN`, and both `NaN > 0` and `undefined > 0` are
|
|
76
|
+
// false, which would silently turn real reported vulnerabilities into a
|
|
77
|
+
// "0 of everything" pass. Require every severity count to be a finite
|
|
78
|
+
// number before trusting the summary at all.
|
|
79
|
+
const severities = ["info", "low", "moderate", "high", "critical"] as const
|
|
80
|
+
const missingCount = severities.find((severity) => !Number.isFinite(vulnerabilities[severity]))
|
|
81
|
+
|
|
82
|
+
if (missingCount !== undefined) {
|
|
83
|
+
return {
|
|
84
|
+
outcome: "fail",
|
|
85
|
+
rationale: `npm audit's vulnerability summary is missing a numeric "${missingCount}" count -- the report could not be evaluated.`,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const blocking =
|
|
90
|
+
vulnerabilities.low +
|
|
91
|
+
vulnerabilities.moderate +
|
|
92
|
+
vulnerabilities.high +
|
|
93
|
+
vulnerabilities.critical
|
|
94
|
+
|
|
95
|
+
if (blocking > 0) {
|
|
96
|
+
const details = Object.entries(report.vulnerabilities ?? {})
|
|
97
|
+
.map(([name, vulnerability]: [string, NpmAuditAdvisory]) => {
|
|
98
|
+
const severity = vulnerability.severity ?? "unknown"
|
|
99
|
+
|
|
100
|
+
const dependencyType = vulnerability.isDirect === true ? "direct" : "transitive"
|
|
101
|
+
|
|
102
|
+
const range = vulnerability.range ? ` range=${vulnerability.range}` : ""
|
|
103
|
+
|
|
104
|
+
const fix =
|
|
105
|
+
vulnerability.fixAvailable === true
|
|
106
|
+
? " fix available"
|
|
107
|
+
: vulnerability.fixAvailable
|
|
108
|
+
? " remediation available"
|
|
109
|
+
: " no automatic fix available"
|
|
110
|
+
|
|
111
|
+
return `${name}: ${severity} (${dependencyType})${range};${fix}`
|
|
112
|
+
})
|
|
113
|
+
.sort()
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
outcome: "fail",
|
|
117
|
+
rationale: [
|
|
118
|
+
`npm audit found ${String(blocking)} runtime vulnerability(ies):`,
|
|
119
|
+
...details.map((detail: string) => `- ${detail}`),
|
|
120
|
+
].join("\n"),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `info`-severity findings are excluded from the blocking count above
|
|
125
|
+
// (they were never part of this policy's pass/fail contract), but a pass
|
|
126
|
+
// built on top of some unresolved info-level findings is still worth
|
|
127
|
+
// surfacing explicitly rather than silently folding into an unqualified
|
|
128
|
+
// pass.
|
|
129
|
+
if (vulnerabilities.info > 0) {
|
|
130
|
+
return {
|
|
131
|
+
outcome: "warn",
|
|
132
|
+
rationale: `Runtime dependency policy passed. 0 critical, 0 high, 0 moderate, and 0 low vulnerabilities were found. ${String(vulnerabilities.info)} info-severity finding(s) remain and are non-blocking under repository policy.`,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
outcome: "pass",
|
|
138
|
+
rationale:
|
|
139
|
+
"Runtime dependency policy passed. 0 critical, 0 high, 0 moderate, and 0 low vulnerabilities were found.",
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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 SecretlintMessage {
|
|
8
|
+
readonly message: string
|
|
9
|
+
readonly line: number
|
|
10
|
+
readonly column: number
|
|
11
|
+
readonly ruleId?: string
|
|
12
|
+
readonly severity?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SecretlintResult {
|
|
16
|
+
readonly filePath: string
|
|
17
|
+
readonly messages: readonly SecretlintMessage[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Secret values are deliberately excluded from the policy output. The
|
|
21
|
+
// location and rule identify the remediation target without turning the
|
|
22
|
+
// contract result itself into another secret-disclosure channel. Requires
|
|
23
|
+
// the consumer's own secretlint config (secretlint has no built-in rules).
|
|
24
|
+
/** Secret-leak scanning via secretlint. */
|
|
25
|
+
export const securitySecrets: CheckDefinitionConfig = {
|
|
26
|
+
run: ["secretlint", "--format", "json", "--output", "reports/secretlint.json", "**/*"],
|
|
27
|
+
policy: async ({ result }) => {
|
|
28
|
+
const missing = checkDependencyInstalled(result, "secretlint")
|
|
29
|
+
if (missing) return missing
|
|
30
|
+
|
|
31
|
+
const terminated = checkTerminatedAbnormally(result, "Secretlint")
|
|
32
|
+
if (terminated) return terminated
|
|
33
|
+
|
|
34
|
+
const parsed = await readJsonReport<readonly SecretlintResult[]>(
|
|
35
|
+
// Provably equivalent, not a coverage gap -- see the identical
|
|
36
|
+
// comment in src/presets/duplication.ts for why.
|
|
37
|
+
// Stryker disable next-line StringLiteral -- JSON.parse coerces via toString(), which defaults to utf8 for a Buffer, so parsing succeeds identically either way
|
|
38
|
+
() => readFile("reports/secretlint.json", "utf8"),
|
|
39
|
+
"Secretlint did not produce its expected JSON report.",
|
|
40
|
+
"Secretlint produced invalid JSON evidence.",
|
|
41
|
+
)
|
|
42
|
+
if (!parsed.ok) return parsed.result
|
|
43
|
+
|
|
44
|
+
// `readJsonReport<T>` narrows only at the type level; valid JSON that
|
|
45
|
+
// isn't an array must fail cleanly rather than throw `.flatMap` out of
|
|
46
|
+
// the policy and crash the run.
|
|
47
|
+
if (!Array.isArray(parsed.value)) {
|
|
48
|
+
return { outcome: "fail", rationale: "Secretlint produced invalid JSON report data." }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const results: readonly SecretlintResult[] = parsed.value
|
|
52
|
+
|
|
53
|
+
const findings = results.flatMap((file: unknown) => {
|
|
54
|
+
// A single malformed array element (not an object, or missing its
|
|
55
|
+
// `messages` array) must not throw `.map` out of the policy -- skip it,
|
|
56
|
+
// exactly as the top-level non-array guard fails cleanly.
|
|
57
|
+
if (
|
|
58
|
+
typeof file !== "object" ||
|
|
59
|
+
file === null ||
|
|
60
|
+
!Array.isArray((file as { messages?: unknown }).messages)
|
|
61
|
+
) {
|
|
62
|
+
return []
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const typedFile = file as SecretlintResult
|
|
66
|
+
return typedFile.messages.map((message: SecretlintMessage) => ({
|
|
67
|
+
file: typedFile.filePath,
|
|
68
|
+
line: message.line,
|
|
69
|
+
column: message.column,
|
|
70
|
+
ruleId: message.ruleId,
|
|
71
|
+
}))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (findings.length === 0) {
|
|
75
|
+
return { outcome: "pass", rationale: "Secretlint found 0 potential secrets." }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const details = findings.map((finding) => {
|
|
79
|
+
const rule = finding.ruleId ? ` [${finding.ruleId}]` : ""
|
|
80
|
+
|
|
81
|
+
return `${finding.file}:${String(finding.line)}:${String(finding.column)}${rule}`
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
outcome: "fail",
|
|
86
|
+
rationale: [
|
|
87
|
+
`Secretlint found ${String(findings.length)} potential secret(s):`,
|
|
88
|
+
...details.map((detail: string) => `- ${detail}`),
|
|
89
|
+
"Potential secret values are intentionally omitted. Remove the secret, replace it with an appropriate secret-management mechanism, and rotate any credential that may already have been exposed.",
|
|
90
|
+
].join("\n"),
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PolicyResult } from "../../types.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shared three-branch shape behind every lint-style preset's pass/warn/fail decision: fail,
|
|
5
|
+
* listing every error, if there are any; else warn, listing every warning, if there are any; else
|
|
6
|
+
* pass -- shared by `lint` (ESLint) and `stylelint`, whose only difference was the tool name
|
|
7
|
+
* substituted into each rationale string.
|
|
8
|
+
* @param toolName - the tool's own display name, substituted into every rationale (e.g. "ESLint", "stylelint").
|
|
9
|
+
* @param errorDetails - one rendered line per error-severity finding.
|
|
10
|
+
* @param warningDetails - one rendered line per warning-severity finding.
|
|
11
|
+
* @returns the fail/warn/pass `PolicyResult`.
|
|
12
|
+
*/
|
|
13
|
+
export function errorWarningPassPolicy(
|
|
14
|
+
toolName: string,
|
|
15
|
+
errorDetails: readonly string[],
|
|
16
|
+
warningDetails: readonly string[],
|
|
17
|
+
): PolicyResult {
|
|
18
|
+
if (errorDetails.length > 0) {
|
|
19
|
+
return {
|
|
20
|
+
outcome: "fail",
|
|
21
|
+
rationale: [
|
|
22
|
+
`${toolName} reported ${String(errorDetails.length)} error(s):`,
|
|
23
|
+
...errorDetails.map((detail) => `- ${detail}`),
|
|
24
|
+
].join("\n"),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (warningDetails.length > 0) {
|
|
29
|
+
return {
|
|
30
|
+
outcome: "warn",
|
|
31
|
+
rationale: [
|
|
32
|
+
`${toolName} reported 0 errors but ${String(warningDetails.length)} warning(s):`,
|
|
33
|
+
...warningDetails.map((detail) => `- ${detail}`),
|
|
34
|
+
].join("\n"),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { outcome: "pass", rationale: `${toolName} reported 0 errors and 0 warnings.` }
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CheckEvidence } from "../../types.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A check's captured stdout and stderr, trimmed and joined into one block -- either stream may be
|
|
5
|
+
* empty and is dropped rather than contributing a blank line. Shared by `exitCodeFailRationale`
|
|
6
|
+
* below and by `publint` (which needs the combined text itself, not just a rendered failure
|
|
7
|
+
* message, to detect its own "Warnings:"/"Suggestions:" section headers).
|
|
8
|
+
* @param result - the check's execution evidence (only `stdout`/`stderr` are read).
|
|
9
|
+
* @returns the combined, trimmed stdout+stderr text, or `""` if the tool produced neither.
|
|
10
|
+
*/
|
|
11
|
+
export function combinedOutput(result: CheckEvidence): string {
|
|
12
|
+
return [result.stdout.trim(), result.stderr.trim()]
|
|
13
|
+
.filter((value): value is string => Boolean(value))
|
|
14
|
+
.join("\n")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shared by every preset whose policy is a plain "exitCode 0 = pass, else
|
|
19
|
+
* render captured stdout/stderr (or fall back to an exit-code-only message
|
|
20
|
+
* if the tool produced neither)" -- `format`, `typecheck`, `commitlint`.
|
|
21
|
+
* Each preset still owns its own pass rationale and its own description of
|
|
22
|
+
* what failed; this only renders the shared "here's what the tool printed"
|
|
23
|
+
* half.
|
|
24
|
+
* @param result - the check's execution evidence (only `stdout`/`stderr`/`exitCode` are read).
|
|
25
|
+
* @param description - a present-tense description of the failure, e.g. "Prettier reported formatting failures".
|
|
26
|
+
* @returns `"{description}:\n{output}"` when the tool produced output, or `"{description} (exit code {N})."` otherwise.
|
|
27
|
+
*/
|
|
28
|
+
export function exitCodeFailRationale(result: CheckEvidence, description: string): string {
|
|
29
|
+
const output = combinedOutput(result)
|
|
30
|
+
|
|
31
|
+
return output.length > 0
|
|
32
|
+
? `${description}:\n${output}`
|
|
33
|
+
: `${description} (exit code ${String(result.exitCode)}).`
|
|
34
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CheckEvidence, PolicyResult } from "../../types.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every preset in `src/presets/*.ts` (except `securityDeps`, which shells out
|
|
5
|
+
* to `npm` itself -- something that cannot be "missing" in any environment
|
|
6
|
+
* capable of running `npm run <script>` at all) calls this first, before its
|
|
7
|
+
* own pass/fail/warn logic. `CheckEvidence.status === "spawn_error"` already
|
|
8
|
+
* distinguishes "the OS couldn't even launch the executable" from "the tool
|
|
9
|
+
* ran and exited non-zero" -- but not every spawn failure means the package
|
|
10
|
+
* is missing (a permission error or an invalid executable format lands here
|
|
11
|
+
* too), so this checks the structured `spawnErrorCode` for `"ENOENT"`
|
|
12
|
+
* specifically rather than treating every spawn error as "not installed."
|
|
13
|
+
* A non-ENOENT spawn error falls through to the preset's normal handling,
|
|
14
|
+
* unchanged, rather than getting a misleading "not installed" message.
|
|
15
|
+
* @param result - the check's raw execution evidence to inspect.
|
|
16
|
+
* @param packageName - the npm package a consumer would install to fix this (may differ from the binary name, e.g. `tsc` comes from `typescript`).
|
|
17
|
+
* @returns an actionable, package-manager-neutral `PolicyResult` if the tool appears to be missing, or `undefined` if the caller should proceed with its own interpretation.
|
|
18
|
+
*/
|
|
19
|
+
export function checkDependencyInstalled(
|
|
20
|
+
result: CheckEvidence,
|
|
21
|
+
packageName: string,
|
|
22
|
+
): PolicyResult | undefined {
|
|
23
|
+
if (result.status !== "spawn_error" || result.spawnErrorCode !== "ENOENT") {
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
outcome: "fail",
|
|
29
|
+
rationale: `\`${packageName}\` is required by this preset but was not found. Install \`${packageName}\` as a development dependency and run the contract again.`,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { PolicyResult } from "../../types.js"
|
|
2
|
+
|
|
3
|
+
/** The report, already JSON-parsed and narrowed to `T`, or the fail `PolicyResult` to return verbatim when reading or parsing it failed. See `readJsonReport`. */
|
|
4
|
+
type ReadJsonReportResult<T> =
|
|
5
|
+
{ readonly ok: true; readonly value: T } | { readonly ok: false; readonly result: PolicyResult }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads and JSON-parses a report a tool wrote to disk rather than printing to stdout -- the "read,
|
|
9
|
+
* fail with one rationale if the read itself fails, fail with a different rationale if the JSON is
|
|
10
|
+
* invalid" shape shared by every preset whose tool has no stdout JSON mode (jscpd,
|
|
11
|
+
* markdownlint-cli2, secretlint). Each failure takes its own caller-supplied rationale rather than a
|
|
12
|
+
* generic shared message, since a missing report and invalid JSON usually point a consumer at a
|
|
13
|
+
* different fix (a misconfigured reporter/output flag vs. a genuinely broken tool run), and each
|
|
14
|
+
* tool's own advice differs (markdownlint's own read-failure rationale, for instance, names the
|
|
15
|
+
* specific config field to check).
|
|
16
|
+
*
|
|
17
|
+
* Takes the read itself as a thunk (`readRaw`), rather than a `path` this function reads from
|
|
18
|
+
* directly, so every call site's own `readFile(LITERAL_PATH, "utf8")` stays written as a literal
|
|
19
|
+
* argument in the caller's own file -- required by this repo's `security/detect-non-literal-fs-filename`
|
|
20
|
+
* policy, which forbids suppressing that rule outright (see policy-config.ts's `eslint.rules["security/*"]`)
|
|
21
|
+
* and would otherwise flag a `path: string` function parameter threaded into `readFile` here as
|
|
22
|
+
* non-literal.
|
|
23
|
+
* @param readRaw - reads the report's raw text (typically `() => readFile(REPORT_PATH, "utf8")`).
|
|
24
|
+
* @param onReadFailed - the rationale to fail with if `readRaw` rejects.
|
|
25
|
+
* @param onParseFailed - the rationale to fail with if the read text isn't valid JSON.
|
|
26
|
+
* @returns `{ ok: true, value }` with the parsed JSON narrowed to `T`, or `{ ok: false, result }` with the fail `PolicyResult` to return verbatim.
|
|
27
|
+
*/
|
|
28
|
+
export async function readJsonReport<T>(
|
|
29
|
+
readRaw: () => Promise<string>,
|
|
30
|
+
onReadFailed: string,
|
|
31
|
+
onParseFailed: string,
|
|
32
|
+
): Promise<ReadJsonReportResult<T>> {
|
|
33
|
+
let raw: string
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
raw = await readRaw()
|
|
37
|
+
} catch {
|
|
38
|
+
return { ok: false, result: { outcome: "fail", rationale: onReadFailed } }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
return { ok: true, value: JSON.parse(raw) as T }
|
|
43
|
+
} catch {
|
|
44
|
+
return { ok: false, result: { outcome: "fail", rationale: onParseFailed } }
|
|
45
|
+
}
|
|
46
|
+
}
|