repo-contract 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +967 -0
  4. package/dist/.dts/config/define-repo-contract.d.ts +36 -0
  5. package/dist/.dts/config/define-repo-contract.d.ts.map +1 -0
  6. package/dist/.dts/config/tokenize-command.d.ts +33 -0
  7. package/dist/.dts/config/tokenize-command.d.ts.map +1 -0
  8. package/dist/.dts/config/validate-config.d.ts +32 -0
  9. package/dist/.dts/config/validate-config.d.ts.map +1 -0
  10. package/dist/.dts/errors.d.ts +155 -0
  11. package/dist/.dts/errors.d.ts.map +1 -0
  12. package/dist/.dts/evidence/build-evidence.d.ts +26 -0
  13. package/dist/.dts/evidence/build-evidence.d.ts.map +1 -0
  14. package/dist/.dts/execution/abort-signals.d.ts +29 -0
  15. package/dist/.dts/execution/abort-signals.d.ts.map +1 -0
  16. package/dist/.dts/execution/concurrency-pool.d.ts +14 -0
  17. package/dist/.dts/execution/concurrency-pool.d.ts.map +1 -0
  18. package/dist/.dts/execution/dependency-scheduler.d.ts +30 -0
  19. package/dist/.dts/execution/dependency-scheduler.d.ts.map +1 -0
  20. package/dist/.dts/execution/process-tree.d.ts +48 -0
  21. package/dist/.dts/execution/process-tree.d.ts.map +1 -0
  22. package/dist/.dts/execution/run-checks.d.ts +29 -0
  23. package/dist/.dts/execution/run-checks.d.ts.map +1 -0
  24. package/dist/.dts/execution/spawn-check.d.ts +30 -0
  25. package/dist/.dts/execution/spawn-check.d.ts.map +1 -0
  26. package/dist/.dts/index.d.ts +13 -0
  27. package/dist/.dts/index.d.ts.map +1 -0
  28. package/dist/.dts/parsing/parse-json.d.ts +8 -0
  29. package/dist/.dts/parsing/parse-json.d.ts.map +1 -0
  30. package/dist/.dts/parsing/parse-output.d.ts +10 -0
  31. package/dist/.dts/parsing/parse-output.d.ts.map +1 -0
  32. package/dist/.dts/parsing/parse-text.d.ts +8 -0
  33. package/dist/.dts/parsing/parse-text.d.ts.map +1 -0
  34. package/dist/.dts/parsing/parse-yaml.d.ts +21 -0
  35. package/dist/.dts/parsing/parse-yaml.d.ts.map +1 -0
  36. package/dist/.dts/policy/run-policies.d.ts +38 -0
  37. package/dist/.dts/policy/run-policies.d.ts.map +1 -0
  38. package/dist/.dts/presets/arethetypeswrong.d.ts +39 -0
  39. package/dist/.dts/presets/arethetypeswrong.d.ts.map +1 -0
  40. package/dist/.dts/presets/broken-links.d.ts +16 -0
  41. package/dist/.dts/presets/broken-links.d.ts.map +1 -0
  42. package/dist/.dts/presets/commitlint.d.ts +22 -0
  43. package/dist/.dts/presets/commitlint.d.ts.map +1 -0
  44. package/dist/.dts/presets/dead-code.d.ts +23 -0
  45. package/dist/.dts/presets/dead-code.d.ts.map +1 -0
  46. package/dist/.dts/presets/duplication.d.ts +14 -0
  47. package/dist/.dts/presets/duplication.d.ts.map +1 -0
  48. package/dist/.dts/presets/e2e.d.ts +4 -0
  49. package/dist/.dts/presets/e2e.d.ts.map +1 -0
  50. package/dist/.dts/presets/format.d.ts +4 -0
  51. package/dist/.dts/presets/format.d.ts.map +1 -0
  52. package/dist/.dts/presets/index.d.ts +31 -0
  53. package/dist/.dts/presets/index.d.ts.map +1 -0
  54. package/dist/.dts/presets/license.d.ts +4 -0
  55. package/dist/.dts/presets/license.d.ts.map +1 -0
  56. package/dist/.dts/presets/lint.d.ts +20 -0
  57. package/dist/.dts/presets/lint.d.ts.map +1 -0
  58. package/dist/.dts/presets/markdownlint.d.ts +23 -0
  59. package/dist/.dts/presets/markdownlint.d.ts.map +1 -0
  60. package/dist/.dts/presets/publint.d.ts +13 -0
  61. package/dist/.dts/presets/publint.d.ts.map +1 -0
  62. package/dist/.dts/presets/security-deps.d.ts +4 -0
  63. package/dist/.dts/presets/security-deps.d.ts.map +1 -0
  64. package/dist/.dts/presets/security-secrets.d.ts +4 -0
  65. package/dist/.dts/presets/security-secrets.d.ts.map +1 -0
  66. package/dist/.dts/presets/shared/error-warning-pass-policy.d.ts +13 -0
  67. package/dist/.dts/presets/shared/error-warning-pass-policy.d.ts.map +1 -0
  68. package/dist/.dts/presets/shared/exit-code-fail-rationale.d.ts +23 -0
  69. package/dist/.dts/presets/shared/exit-code-fail-rationale.d.ts.map +1 -0
  70. package/dist/.dts/presets/shared/missing-dependency.d.ts +19 -0
  71. package/dist/.dts/presets/shared/missing-dependency.d.ts.map +1 -0
  72. package/dist/.dts/presets/shared/read-json-report.d.ts +33 -0
  73. package/dist/.dts/presets/shared/read-json-report.d.ts.map +1 -0
  74. package/dist/.dts/presets/shared/terminal-status.d.ts +25 -0
  75. package/dist/.dts/presets/shared/terminal-status.d.ts.map +1 -0
  76. package/dist/.dts/presets/shared/vitest-json-policy.d.ts +20 -0
  77. package/dist/.dts/presets/shared/vitest-json-policy.d.ts.map +1 -0
  78. package/dist/.dts/presets/stylelint.d.ts +17 -0
  79. package/dist/.dts/presets/stylelint.d.ts.map +1 -0
  80. package/dist/.dts/presets/test.d.ts +4 -0
  81. package/dist/.dts/presets/test.d.ts.map +1 -0
  82. package/dist/.dts/presets/typecheck.d.ts +4 -0
  83. package/dist/.dts/presets/typecheck.d.ts.map +1 -0
  84. package/dist/.dts/run-repo-contract.d.ts +38 -0
  85. package/dist/.dts/run-repo-contract.d.ts.map +1 -0
  86. package/dist/.dts/types.d.ts +324 -0
  87. package/dist/.dts/types.d.ts.map +1 -0
  88. package/dist/index.cjs +46 -0
  89. package/dist/index.cjs.map +1 -0
  90. package/dist/index.d.cts +1 -0
  91. package/dist/index.d.ts +1 -0
  92. package/dist/index.js +11 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/presets.cjs +30 -0
  95. package/dist/presets.cjs.map +1 -0
  96. package/dist/presets.d.cts +1 -0
  97. package/dist/presets.d.ts +1 -0
  98. package/dist/presets.js +13 -0
  99. package/dist/presets.js.map +1 -0
  100. package/package.json +192 -0
  101. package/presets/package.json +5 -0
  102. package/schemas/evidence.schema.json +253 -0
  103. package/schemas/verdict.schema.json +66 -0
  104. package/src/config/define-repo-contract.ts +38 -0
  105. package/src/config/tokenize-command.ts +214 -0
  106. package/src/config/validate-config.ts +368 -0
  107. package/src/errors.ts +229 -0
  108. package/src/evidence/build-evidence.ts +91 -0
  109. package/src/execution/abort-signals.ts +56 -0
  110. package/src/execution/concurrency-pool.ts +64 -0
  111. package/src/execution/dependency-scheduler.ts +216 -0
  112. package/src/execution/process-tree.ts +107 -0
  113. package/src/execution/run-checks.ts +348 -0
  114. package/src/execution/spawn-check.ts +494 -0
  115. package/src/index.ts +44 -0
  116. package/src/parsing/parse-json.ts +18 -0
  117. package/src/parsing/parse-output.ts +26 -0
  118. package/src/parsing/parse-text.ts +10 -0
  119. package/src/parsing/parse-yaml.ts +40 -0
  120. package/src/policy/run-policies.ts +261 -0
  121. package/src/presets/arethetypeswrong.ts +116 -0
  122. package/src/presets/broken-links.ts +95 -0
  123. package/src/presets/commitlint.ts +77 -0
  124. package/src/presets/dead-code.ts +223 -0
  125. package/src/presets/duplication.ts +137 -0
  126. package/src/presets/e2e.ts +144 -0
  127. package/src/presets/format.ts +25 -0
  128. package/src/presets/index.ts +30 -0
  129. package/src/presets/license.ts +90 -0
  130. package/src/presets/lint.ts +116 -0
  131. package/src/presets/markdownlint.ts +105 -0
  132. package/src/presets/publint.ts +38 -0
  133. package/src/presets/security-deps.ts +142 -0
  134. package/src/presets/security-secrets.ts +93 -0
  135. package/src/presets/shared/error-warning-pass-policy.ts +39 -0
  136. package/src/presets/shared/exit-code-fail-rationale.ts +34 -0
  137. package/src/presets/shared/missing-dependency.ts +31 -0
  138. package/src/presets/shared/read-json-report.ts +46 -0
  139. package/src/presets/shared/terminal-status.ts +70 -0
  140. package/src/presets/shared/vitest-json-policy.ts +95 -0
  141. package/src/presets/stylelint.ts +101 -0
  142. package/src/presets/test.ts +19 -0
  143. package/src/presets/typecheck.ts +25 -0
  144. package/src/run-repo-contract.ts +80 -0
  145. package/src/types.ts +340 -0
@@ -0,0 +1,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
+ }