pr-checkmate 1.23.17 → 1.23.19
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/README.md +0 -3
- package/cspell.json +2 -0
- package/dist/cli.js +28 -5
- package/dist/config/constants.js +1 -0
- package/dist/core/checks/dependencies/circular-deps.js +132 -14
- package/dist/core/checks/dependencies/dependency-check.d.ts +41 -0
- package/dist/core/checks/dependencies/dependency-check.js +150 -37
- package/dist/core/checks/dependencies/grype-scan.d.ts +5 -0
- package/dist/core/checks/dependencies/grype-scan.js +24 -1
- package/dist/core/checks/dependencies/npm-audit.js +22 -1
- package/dist/core/checks/dependencies/vuln-scan.d.ts +0 -7
- package/dist/core/checks/dependencies/vuln-scan.js +15 -1
- package/dist/core/checks/languages/lint.js +10 -1
- package/dist/core/checks/languages/typecheck.js +7 -0
- package/dist/core/checks/quality/markdown-lint.js +10 -1
- package/dist/core/checks/security/security.js +12 -1
- package/dist/init.d.ts +19 -0
- package/dist/init.js +70 -14
- package/dist/interfaces/index.d.ts +21 -5
- package/package.json +3 -5
- package/pr-checkmate.schema.json +12 -2
- package/src/config/cspell.json +2 -0
- package/src/config/pr-checkmate.schema.json +12 -2
package/README.md
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|

|
|
11
11
|
[](https://pr-checkmate-docs.pages.dev)
|
|
12
12
|
[](https://github.com/Mybono/pr_checkmate_public/releases/latest)
|
|
13
|
-
[](https://scorecard.dev/viewer/?uri=github.com/Mybono/pr_checkmate_public)
|
|
14
13
|
|
|
15
14
|
> PR CheckMate is a universal PR quality checker: one `npm install`, 51 checks across
|
|
16
15
|
> 11 languages, running in CI on every pull request. The bundled languages need no
|
|
@@ -20,8 +19,6 @@ Every release is published with a CycloneDX SBOM and an independent
|
|
|
20
19
|
[VirusTotal scan report](https://github.com/Mybono/pr_checkmate_public/releases/latest)
|
|
21
20
|
of the exact npm tarball, attached to that version's
|
|
22
21
|
[GitHub release notes](https://github.com/Mybono/pr_checkmate_public/releases/latest).
|
|
23
|
-
The public repo also runs an [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/Mybono/pr_checkmate_public)
|
|
24
|
-
analysis of its own supply-chain hygiene.
|
|
25
22
|
|
|
26
23
|
## Why PR CheckMate
|
|
27
24
|
|
package/cspell.json
CHANGED
package/dist/cli.js
CHANGED
|
@@ -54,9 +54,28 @@ const COMMAND_TO_CHECK = {
|
|
|
54
54
|
'ruby-lint': 'RuboCop',
|
|
55
55
|
'php-format': 'PHP CS Fixer',
|
|
56
56
|
};
|
|
57
|
-
|
|
57
|
+
/** Commands that run the whole pipeline (or no pipeline at all) and take no siblings. */
|
|
58
|
+
const STANDALONE_COMMANDS = ['init', 'all', 'precommit'];
|
|
59
|
+
const args = process.argv.slice(2);
|
|
60
|
+
const cmd = args[0];
|
|
61
|
+
/**
|
|
62
|
+
* Reject extra arguments after a standalone command. Silently ignoring them would
|
|
63
|
+
* report success for work that never ran — `pr-checkmate init lint` must not look
|
|
64
|
+
* like it linted anything.
|
|
65
|
+
*
|
|
66
|
+
* Driven by {@link STANDALONE_COMMANDS} so the list stays the single source of truth:
|
|
67
|
+
* a per-branch guard would leave the next standalone command unguarded the day it is
|
|
68
|
+
* added to the list but not to the branch.
|
|
69
|
+
*/
|
|
70
|
+
function rejectExtraArgs() {
|
|
71
|
+
if (!cmd || !STANDALONE_COMMANDS.includes(cmd) || args.length <= 1)
|
|
72
|
+
return;
|
|
73
|
+
utils_1.logger.error(`[cli]: "${cmd}" takes no other arguments, got: ${args.slice(1).join(', ')}. Run them as a separate command.`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
58
76
|
void (async () => {
|
|
59
77
|
try {
|
|
78
|
+
rejectExtraArgs();
|
|
60
79
|
if (cmd === 'init') {
|
|
61
80
|
await (0, init_1.init)();
|
|
62
81
|
process.exit(0);
|
|
@@ -81,12 +100,16 @@ void (async () => {
|
|
|
81
100
|
const report = await (0, core_1.runChecks)({ staged: true, reporter: 'stdout', write: false });
|
|
82
101
|
process.exit(report.ok ? 0 : 1);
|
|
83
102
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
// Every remaining argument is a check name, so `pr-checkmate lint spellcheck`
|
|
104
|
+
// runs both in one pass. Unknown names abort the whole run — a typo must never
|
|
105
|
+
// shrink the requested check set silently.
|
|
106
|
+
const unknown = args.filter(arg => !COMMAND_TO_CHECK[arg]);
|
|
107
|
+
if (unknown.length > 0) {
|
|
108
|
+
utils_1.logger.error(`[cli]: Unknown command(s) "${unknown.join('", "')}". Available: ${STANDALONE_COMMANDS.join(', ')}, ${Object.keys(COMMAND_TO_CHECK).join(', ')}`);
|
|
87
109
|
process.exit(1);
|
|
88
110
|
}
|
|
89
|
-
const
|
|
111
|
+
const checkNames = [...new Set(args.map(arg => COMMAND_TO_CHECK[arg]))];
|
|
112
|
+
const report = await (0, core_1.runChecks)({ only: checkNames, reporter: 'stdout', write: true });
|
|
90
113
|
process.exit(report.ok ? 0 : 1);
|
|
91
114
|
}
|
|
92
115
|
catch (err) {
|
package/dist/config/constants.js
CHANGED
|
@@ -1,33 +1,151 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.circularDepsCheck = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
4
9
|
const config_1 = require("../../../config");
|
|
5
10
|
const utils_1 = require("../../../utils");
|
|
6
11
|
const execa_1 = require("../../../utils/execa");
|
|
7
12
|
const targets_1 = require("../../targets");
|
|
8
13
|
const types_1 = require("../../types");
|
|
14
|
+
const findings_1 = require("../findings");
|
|
15
|
+
/**
|
|
16
|
+
* Cap for the analysis. dpdm builds the whole module graph, which on a large repo
|
|
17
|
+
* is the slowest part of a run — an uncapped analysis looks like a hung commit.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
20
|
+
/**
|
|
21
|
+
* Ceiling for the assembled argument list. Passing every tracked source file as a
|
|
22
|
+
* separate argument overflows the OS argument limit (`E2BIG`) on a large repo, and
|
|
23
|
+
* a spawn that never starts writes no report. Past the ceiling dpdm gets globs
|
|
24
|
+
* instead — it expands them itself, over the same source paths.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_ARGV_CHARS = 100000;
|
|
27
|
+
/** Extensions dpdm resolves for us; also the globs used in the fallback. */
|
|
28
|
+
const SOURCE_GLOBS = ['*.ts', '*.tsx', '*.js', '*.jsx'];
|
|
29
|
+
/**
|
|
30
|
+
* Resolve dpdm, preferring the consumer project's own copy (so a repo pinned to a
|
|
31
|
+
* different major keeps its own behaviour) and falling back to the copy bundled
|
|
32
|
+
* with pr-checkmate — the order `lint` and `typecheck` use for eslint/tsc.
|
|
33
|
+
*
|
|
34
|
+
* Returns null instead of throwing: an unresolvable analyzer is a skip, never a
|
|
35
|
+
* failed PR. This check used to shell out to `npx dpdm`, which silently fetched
|
|
36
|
+
* dpdm from the registry on first use — and when that fetch could not happen
|
|
37
|
+
* (offline or locked-down CI, private registry, exit 127) nothing was analyzed.
|
|
38
|
+
*/
|
|
39
|
+
function resolveDpdm(cwd) {
|
|
40
|
+
for (const options of [{ paths: [cwd] }, undefined]) {
|
|
41
|
+
try {
|
|
42
|
+
return (0, utils_1.resolvePackageBin)('dpdm', 'lib/bin/dpdm.js', options);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Try the next location; both failing is a skip, handled by the caller.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the file arguments for dpdm, falling back to globs when the explicit list
|
|
52
|
+
* would overflow the OS argument limit. `sourcePaths` scopes the globs the same way
|
|
53
|
+
* `listTrackedFiles` scopes the explicit list.
|
|
54
|
+
*/
|
|
55
|
+
function toFileArgs(files, sourcePaths) {
|
|
56
|
+
const explicitLength = files.reduce((sum, file) => sum + file.length + 1, 0);
|
|
57
|
+
if (explicitLength <= MAX_ARGV_CHARS)
|
|
58
|
+
return files;
|
|
59
|
+
const roots = sourcePaths.length > 0 ? sourcePaths : ['.'];
|
|
60
|
+
const globs = roots.flatMap(root => SOURCE_GLOBS.map(ext => `${root.replace(/\/+$/, '')}/**/${ext}`));
|
|
61
|
+
utils_1.logger.info(`[Circular Deps]: ${files.length} files exceed the argument limit — analyzing via globs (${globs.join(', ')})`);
|
|
62
|
+
return globs;
|
|
63
|
+
}
|
|
64
|
+
/** Read dpdm's report file; null when it is absent or unparseable. */
|
|
65
|
+
function readReport(reportPath) {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(node_fs_1.default.readFileSync(reportPath, 'utf-8'));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
9
73
|
exports.circularDepsCheck = {
|
|
10
74
|
name: 'Circular Deps',
|
|
11
75
|
phase: 'blocking',
|
|
12
76
|
applies(ctx) {
|
|
77
|
+
if (ctx.config.circularDeps?.enabled === false)
|
|
78
|
+
return false;
|
|
13
79
|
return ctx.languages.includes('typescript') || ctx.languages.includes('javascript');
|
|
14
80
|
},
|
|
15
81
|
async run(ctx) {
|
|
16
|
-
const
|
|
17
|
-
if (
|
|
82
|
+
const allFiles = await (0, targets_1.listTrackedFiles)(ctx, SOURCE_GLOBS, (0, config_1.getSourcePaths)());
|
|
83
|
+
if (allFiles.length === 0)
|
|
18
84
|
return (0, types_1.pass)();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
utils_1.logger.
|
|
29
|
-
return (0, types_1.
|
|
85
|
+
// Files the client exempted (generated code, vendored bundles) are dropped
|
|
86
|
+
// before analysis — and again from the cycles below, since the graph can reach
|
|
87
|
+
// an ignored file by following imports from one that is analyzed.
|
|
88
|
+
const ignore = ctx.config.circularDeps?.ignore ?? [];
|
|
89
|
+
const files = ignore.length > 0 ? allFiles.filter(f => !(0, utils_1.matchesAnyGlob)(f, ignore)) : allFiles;
|
|
90
|
+
if (files.length === 0)
|
|
91
|
+
return (0, types_1.skip)('all files ignored');
|
|
92
|
+
const dpdmBin = resolveDpdm(ctx.cwd);
|
|
93
|
+
if (!dpdmBin) {
|
|
94
|
+
utils_1.logger.warn('[Circular Deps]: ⚠️ dpdm could not be resolved — skipping');
|
|
95
|
+
return (0, types_1.skip)('dpdm unavailable');
|
|
96
|
+
}
|
|
97
|
+
const timeout = ctx.config.circularDeps?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
98
|
+
const reportPath = node_path_1.default.join(ctx.cwd, '.dpdm.temp.json');
|
|
99
|
+
try {
|
|
100
|
+
// Read the cycles from dpdm's JSON report rather than its console output.
|
|
101
|
+
// The console shapes this check used to grep for ("Circular Dependencies:",
|
|
102
|
+
// "[1] a -> b") are not what dpdm 4 prints ("• Circular Dependencies",
|
|
103
|
+
// "1) a -> b"), so no cycle ever matched and the check passed every repo,
|
|
104
|
+
// including one built solely out of a cycle. `--exit-code circular:1` gives
|
|
105
|
+
// the same answer independently: dpdm otherwise exits 0 even when it finds
|
|
106
|
+
// cycles, so the exit code alone cannot tell a clean repo from a broken run.
|
|
107
|
+
const result = await (0, execa_1.execa)(process.execPath, [
|
|
108
|
+
dpdmBin,
|
|
109
|
+
'--no-warning',
|
|
110
|
+
'--no-tree',
|
|
111
|
+
'--no-circular',
|
|
112
|
+
'--exit-code',
|
|
113
|
+
'circular:1',
|
|
114
|
+
'-o',
|
|
115
|
+
reportPath,
|
|
116
|
+
...toFileArgs(files, (0, config_1.getSourcePaths)()),
|
|
117
|
+
], { cwd: ctx.cwd, reject: false, timeout });
|
|
118
|
+
if (result.timedOut) {
|
|
119
|
+
utils_1.logger.warn(`[Circular Deps]: ⚠️ dpdm timed out after ${timeout}ms — skipping`);
|
|
120
|
+
return (0, types_1.skip)('dpdm timed out');
|
|
121
|
+
}
|
|
122
|
+
const report = readReport(reportPath);
|
|
123
|
+
// No report means dpdm never got far enough to write one (a tsconfig it
|
|
124
|
+
// cannot read, a file it cannot parse, a killed process). Skip: reporting
|
|
125
|
+
// that as "no cycles" hides a check that did not run, and failing on it
|
|
126
|
+
// blocks a PR that may have no cycle at all.
|
|
127
|
+
if (!report) {
|
|
128
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
129
|
+
utils_1.logger.warn(`[Circular Deps]: ⚠️ dpdm wrote no report (exit ${result.exitCode ?? 'killed'})${output ? `:\n${output}` : ''}`);
|
|
130
|
+
return (0, types_1.skip)(`dpdm failed to run (exit ${result.exitCode ?? 'killed'})`);
|
|
131
|
+
}
|
|
132
|
+
const cycles = (report.circulars ?? []).filter(cycle => ignore.length === 0 || !cycle.some(file => (0, utils_1.matchesAnyGlob)(file, ignore)));
|
|
133
|
+
if (cycles.length === 0)
|
|
134
|
+
return (0, types_1.pass)();
|
|
135
|
+
// Advisory by default. This check reported nothing for its whole existence
|
|
136
|
+
// (see the note above), so switching it on turns every long-standing cycle
|
|
137
|
+
// in every client repo into a finding at once — barrel files alone account
|
|
138
|
+
// for most of them. Warning first lets a repo see its cycles before they
|
|
139
|
+
// gate anything; promote with `circularDeps.severity: "error"`.
|
|
140
|
+
const sev = (0, findings_1.severityEmitter)(ctx.config.circularDeps?.severity, 'warn');
|
|
141
|
+
sev.log(`[Circular Deps]: ${sev.icon} ${cycles.length} circular dependency(ies):`);
|
|
142
|
+
cycles.slice(0, 10).forEach((cycle, i) => sev.log(` ${i + 1}) ${cycle.join(' -> ')}`));
|
|
143
|
+
if (cycles.length > 10)
|
|
144
|
+
sev.log(` ... and ${cycles.length - 10} more`);
|
|
145
|
+
return sev.emit(`${cycles.length} circular dependency(ies) found`);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
(0, utils_1.cleanupTempFile)(reportPath);
|
|
30
149
|
}
|
|
31
|
-
return (0, types_1.pass)();
|
|
32
150
|
},
|
|
33
151
|
};
|
|
@@ -1,2 +1,43 @@
|
|
|
1
1
|
import { Check } from '../../types';
|
|
2
|
+
/** One entry of knip's JSON reporter — a file plus the issues found for it. */
|
|
3
|
+
interface KnipIssue {
|
|
4
|
+
file: string;
|
|
5
|
+
dependencies?: {
|
|
6
|
+
name: string;
|
|
7
|
+
}[];
|
|
8
|
+
unlisted?: {
|
|
9
|
+
name: string;
|
|
10
|
+
}[];
|
|
11
|
+
}
|
|
12
|
+
interface KnipReport {
|
|
13
|
+
issues?: KnipIssue[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Compile one `dependency.ignore` entry into a pattern knip matches the way this
|
|
17
|
+
* config documents it — a glob over the whole dependency name.
|
|
18
|
+
*
|
|
19
|
+
* knip turns any value containing `* + \ ( | { ^ $` into an *unanchored* `RegExp`
|
|
20
|
+
* and compares everything else by string equality. A glob passed straight through
|
|
21
|
+
* would therefore silence far more than it names: `eslint-*` reads as "eslint
|
|
22
|
+
* followed by zero or more dashes", tested as a substring, so it also swallows
|
|
23
|
+
* `eslint` itself. Escaping first and anchoring the result keeps `eslint-*` matching
|
|
24
|
+
* only `eslint-…`, and keeps a plain name matching exactly that name.
|
|
25
|
+
*/
|
|
26
|
+
export declare function toKnipIgnorePattern(entry: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Pull knip's report out of a stdout stream that may carry unrelated output.
|
|
29
|
+
*
|
|
30
|
+
* knip imports the project's own config files while analyzing (a `wdio.conf.ts`
|
|
31
|
+
* for its WebdriverIO plugin, say), so any top-level side effect in them —
|
|
32
|
+
* a dotenv banner, a `console.log` in a config module — is printed before the
|
|
33
|
+
* report. Slicing from the first `{` to the last `}` therefore captured that
|
|
34
|
+
* noise instead: a dotenvx tip ending in `{ path: '…/.env' }` made the whole
|
|
35
|
+
* check fail on a repo whose report was perfectly valid.
|
|
36
|
+
*
|
|
37
|
+
* The json reporter writes the report as a single line (`JSON.stringify` with no
|
|
38
|
+
* indentation), so the last line that parses AND carries `issues` is the report.
|
|
39
|
+
* Anything else on stdout is noise, whatever braces it happens to contain.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseKnipReport(stdout: string): KnipReport | null;
|
|
2
42
|
export declare const dependencyCheck: Check;
|
|
43
|
+
export {};
|
|
@@ -4,11 +4,101 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.dependencyCheck = void 0;
|
|
7
|
+
exports.toKnipIgnorePattern = toKnipIgnorePattern;
|
|
8
|
+
exports.parseKnipReport = parseKnipReport;
|
|
7
9
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const config_1 = require("../../../config");
|
|
9
12
|
const utils_1 = require("../../../utils");
|
|
10
13
|
const execa_1 = require("../../../utils/execa");
|
|
11
14
|
const types_1 = require("../../types");
|
|
15
|
+
/**
|
|
16
|
+
* Config files knip discovers on its own. When a client ships one, it is the source
|
|
17
|
+
* of truth and pr-checkmate passes no config of its own — the same rule spellcheck
|
|
18
|
+
* follows for cspell.json.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors knip's own `KNIP_CONFIG_LOCATIONS` exactly. Listing a name knip does not
|
|
21
|
+
* read (`knip.config.mjs`, say) would be worse than useless: the repo would count as
|
|
22
|
+
* "configures knip itself", so `dependency.ignore` would be dropped, while knip
|
|
23
|
+
* ignored that file too — leaving the client with warnings they had silenced.
|
|
24
|
+
*/
|
|
25
|
+
const KNIP_CONFIG_FILES = [
|
|
26
|
+
'knip.json',
|
|
27
|
+
'knip.jsonc',
|
|
28
|
+
'.knip.json',
|
|
29
|
+
'.knip.jsonc',
|
|
30
|
+
'knip.ts',
|
|
31
|
+
'knip.js',
|
|
32
|
+
'knip.config.ts',
|
|
33
|
+
'knip.config.js',
|
|
34
|
+
];
|
|
35
|
+
/** Regex metacharacters to neutralise before a glob is compiled. */
|
|
36
|
+
const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
|
|
37
|
+
/**
|
|
38
|
+
* Compile one `dependency.ignore` entry into a pattern knip matches the way this
|
|
39
|
+
* config documents it — a glob over the whole dependency name.
|
|
40
|
+
*
|
|
41
|
+
* knip turns any value containing `* + \ ( | { ^ $` into an *unanchored* `RegExp`
|
|
42
|
+
* and compares everything else by string equality. A glob passed straight through
|
|
43
|
+
* would therefore silence far more than it names: `eslint-*` reads as "eslint
|
|
44
|
+
* followed by zero or more dashes", tested as a substring, so it also swallows
|
|
45
|
+
* `eslint` itself. Escaping first and anchoring the result keeps `eslint-*` matching
|
|
46
|
+
* only `eslint-…`, and keeps a plain name matching exactly that name.
|
|
47
|
+
*/
|
|
48
|
+
function toKnipIgnorePattern(entry) {
|
|
49
|
+
const escaped = entry.replace(REGEX_SPECIALS, '\\$&');
|
|
50
|
+
return `^${escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.')}$`;
|
|
51
|
+
}
|
|
52
|
+
/** knip also reads a `knip` key from package.json. */
|
|
53
|
+
function hasPackageJsonConfig(cwd) {
|
|
54
|
+
try {
|
|
55
|
+
return 'knip' in JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, 'package.json'), 'utf-8'));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Pull knip's report out of a stdout stream that may carry unrelated output.
|
|
63
|
+
*
|
|
64
|
+
* knip imports the project's own config files while analyzing (a `wdio.conf.ts`
|
|
65
|
+
* for its WebdriverIO plugin, say), so any top-level side effect in them —
|
|
66
|
+
* a dotenv banner, a `console.log` in a config module — is printed before the
|
|
67
|
+
* report. Slicing from the first `{` to the last `}` therefore captured that
|
|
68
|
+
* noise instead: a dotenvx tip ending in `{ path: '…/.env' }` made the whole
|
|
69
|
+
* check fail on a repo whose report was perfectly valid.
|
|
70
|
+
*
|
|
71
|
+
* The json reporter writes the report as a single line (`JSON.stringify` with no
|
|
72
|
+
* indentation), so the last line that parses AND carries `issues` is the report.
|
|
73
|
+
* Anything else on stdout is noise, whatever braces it happens to contain.
|
|
74
|
+
*/
|
|
75
|
+
function parseKnipReport(stdout) {
|
|
76
|
+
const lines = stdout.split('\n');
|
|
77
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
78
|
+
const line = lines[i].trim();
|
|
79
|
+
if (!line.startsWith('{'))
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(line);
|
|
83
|
+
if (Array.isArray(parsed.issues))
|
|
84
|
+
return parsed;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// A noise line that merely looks like JSON — keep scanning upwards.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** Collect the names reported for one issue type across every file in the report. */
|
|
93
|
+
function collectNames(issues, key) {
|
|
94
|
+
return [...new Set(issues.flatMap(issue => (issue[key] ?? []).map(entry => entry.name)))];
|
|
95
|
+
}
|
|
96
|
+
/** Files a given unlisted dependency is imported from, for the failure message. */
|
|
97
|
+
function filesUsing(issues, name) {
|
|
98
|
+
return issues
|
|
99
|
+
.filter(issue => (issue.unlisted ?? []).some(entry => entry.name === name))
|
|
100
|
+
.map(issue => issue.file);
|
|
101
|
+
}
|
|
12
102
|
exports.dependencyCheck = {
|
|
13
103
|
name: 'Dependencies',
|
|
14
104
|
phase: 'blocking',
|
|
@@ -18,51 +108,74 @@ exports.dependencyCheck = {
|
|
|
18
108
|
return node_fs_1.default.existsSync(node_path_1.default.join(ctx.cwd, 'package.json'));
|
|
19
109
|
},
|
|
20
110
|
async run(ctx) {
|
|
21
|
-
// Resolve the bundled
|
|
22
|
-
// shell out to `npx`, which fails (exit 127) when
|
|
23
|
-
let
|
|
111
|
+
// Resolve the bundled knip binary and run it via the current node — never
|
|
112
|
+
// shell out to `npx`, which fails (exit 127) when knip isn't on the path.
|
|
113
|
+
let knipBin;
|
|
24
114
|
try {
|
|
25
|
-
|
|
115
|
+
knipBin = (0, utils_1.resolvePackageBin)('knip', 'bin/knip.js');
|
|
26
116
|
}
|
|
27
117
|
catch {
|
|
28
|
-
return (0, types_1.skip)('
|
|
118
|
+
return (0, types_1.skip)('knip unavailable');
|
|
29
119
|
}
|
|
30
|
-
// Dependency names
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// (letting a client's own .depcheckrc still apply when nothing is configured).
|
|
120
|
+
// Dependency names to skip, configured in pr-checkmate.json. knip takes these
|
|
121
|
+
// from a config file only, so they go into a temp one — but a client's own knip
|
|
122
|
+
// config wins outright, exactly as it does for cspell.
|
|
34
123
|
const ignore = ctx.config.dependency?.ignore ?? [];
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
cwd: ctx.cwd,
|
|
41
|
-
reject: false,
|
|
42
|
-
});
|
|
43
|
-
const start = stdout.indexOf('{');
|
|
44
|
-
const end = stdout.lastIndexOf('}');
|
|
45
|
-
// No JSON at all → the tool couldn't produce a result; skip rather than fail the client's PR.
|
|
46
|
-
if (start === -1 || end === -1)
|
|
47
|
-
return (0, types_1.skip)('depcheck produced no output');
|
|
48
|
-
const json = stdout.slice(start, end + 1);
|
|
49
|
-
let result;
|
|
50
|
-
try {
|
|
51
|
-
result = JSON.parse(json);
|
|
124
|
+
const clientOwnsConfig = (0, config_1.hasLocalConfig)(KNIP_CONFIG_FILES) || hasPackageJsonConfig(ctx.cwd);
|
|
125
|
+
let tempConfigPath = null;
|
|
126
|
+
if (ignore.length > 0 && clientOwnsConfig) {
|
|
127
|
+
// Say so out loud: a silently ignored `ignore` list looks like a broken check.
|
|
128
|
+
utils_1.logger.info(`[Dependencies]: ℹ️ this repo has its own knip config, so dependency.ignore (${ignore.join(', ')}) is not applied — add those names to knip's ignoreDependencies instead.`);
|
|
52
129
|
}
|
|
53
|
-
|
|
54
|
-
|
|
130
|
+
if (ignore.length > 0 && !clientOwnsConfig) {
|
|
131
|
+
tempConfigPath = node_path_1.default.join(ctx.cwd, '.knip.temp.json');
|
|
132
|
+
node_fs_1.default.writeFileSync(tempConfigPath, JSON.stringify({ ignoreDependencies: ignore.map(toKnipIgnorePattern) }, null, 2));
|
|
55
133
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
134
|
+
// Only unused prod dependencies and imports missing from package.json are
|
|
135
|
+
// reported. knip's other issue types (unused devDependencies, binaries,
|
|
136
|
+
// unresolved paths) are noise here: CLI tools and shell commands legitimately
|
|
137
|
+
// have no import to find.
|
|
138
|
+
const args = [
|
|
139
|
+
knipBin,
|
|
140
|
+
'--reporter',
|
|
141
|
+
'json',
|
|
142
|
+
'--include',
|
|
143
|
+
'dependencies,unlisted',
|
|
144
|
+
'--no-config-hints',
|
|
145
|
+
'--no-exit-code',
|
|
146
|
+
'--no-progress',
|
|
147
|
+
];
|
|
148
|
+
if (tempConfigPath)
|
|
149
|
+
args.push('--config', tempConfigPath);
|
|
150
|
+
try {
|
|
151
|
+
// reject:false — knip can still exit non-zero (e.g. config hints) while
|
|
152
|
+
// writing a perfectly good report to stdout.
|
|
153
|
+
const { stdout } = await (0, execa_1.execa)(process.execPath, args, {
|
|
154
|
+
cwd: ctx.cwd,
|
|
155
|
+
reject: false,
|
|
156
|
+
});
|
|
157
|
+
const result = parseKnipReport(stdout);
|
|
158
|
+
// No report on stdout → the tool couldn't produce a result; skip rather than
|
|
159
|
+
// fail the client's PR. Never fail here: a check that blocks a PR because a
|
|
160
|
+
// tool misbehaved reports our problem as the client's.
|
|
161
|
+
if (!result)
|
|
162
|
+
return (0, types_1.skip)('knip produced no report');
|
|
163
|
+
const issues = result.issues ?? [];
|
|
164
|
+
const unused = collectNames(issues, 'dependencies');
|
|
165
|
+
const missing = collectNames(issues, 'unlisted');
|
|
166
|
+
if (missing.length > 0) {
|
|
167
|
+
missing.forEach(dep => utils_1.logger.error(`[Dependencies]: ❌ missing ${dep} (used in: ${filesUsing(issues, dep).join(', ')})`));
|
|
168
|
+
return (0, types_1.fail)(`missing: ${missing.join(', ')}`);
|
|
169
|
+
}
|
|
170
|
+
if (unused.length > 0) {
|
|
171
|
+
unused.forEach(dep => utils_1.logger.warn(`[Dependencies]: ⚠️ unused ${dep}`));
|
|
172
|
+
return (0, types_1.warn)(`${unused.length} unused dependency(ies)`);
|
|
173
|
+
}
|
|
174
|
+
return (0, types_1.pass)();
|
|
61
175
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
176
|
+
finally {
|
|
177
|
+
if (tempConfigPath)
|
|
178
|
+
(0, utils_1.cleanupTempFile)(tempConfigPath);
|
|
65
179
|
}
|
|
66
|
-
return (0, types_1.pass)();
|
|
67
180
|
},
|
|
68
181
|
};
|
|
@@ -13,5 +13,10 @@ import { Check } from '../../types';
|
|
|
13
13
|
* `collectDirectDeps`): the set the maintainer actually controls. Findings whose
|
|
14
14
|
* ecosystem has no manifest we can parse are suppressed too, and the count of
|
|
15
15
|
* everything dropped is logged so nothing is hidden silently.
|
|
16
|
+
*
|
|
17
|
+
* The scan is confined to the project's own files: `ignoreDirs` is passed to grype
|
|
18
|
+
* as `--exclude`, and a timeout backstops the run. Both matter — cataloguing an
|
|
19
|
+
* installed `node_modules` is minutes of work whose every finding this check then
|
|
20
|
+
* throws away as third-party.
|
|
16
21
|
*/
|
|
17
22
|
export declare const grypeScanCheck: Check;
|
|
@@ -14,6 +14,11 @@ const TYPE_TO_ECOSYSTEM = {
|
|
|
14
14
|
gem: 'ruby',
|
|
15
15
|
'php-composer': 'php',
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Cap for the scan. grype has no internal limit, so without this a slow scan just
|
|
19
|
+
* looks like a hung commit — an advisory check must never be able to do that.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
17
22
|
function severityRank(severity) {
|
|
18
23
|
return SEVERITY_ORDER.indexOf((severity ?? '').toLowerCase());
|
|
19
24
|
}
|
|
@@ -31,6 +36,11 @@ function severityRank(severity) {
|
|
|
31
36
|
* `collectDirectDeps`): the set the maintainer actually controls. Findings whose
|
|
32
37
|
* ecosystem has no manifest we can parse are suppressed too, and the count of
|
|
33
38
|
* everything dropped is logged so nothing is hidden silently.
|
|
39
|
+
*
|
|
40
|
+
* The scan is confined to the project's own files: `ignoreDirs` is passed to grype
|
|
41
|
+
* as `--exclude`, and a timeout backstops the run. Both matter — cataloguing an
|
|
42
|
+
* installed `node_modules` is minutes of work whose every finding this check then
|
|
43
|
+
* throws away as third-party.
|
|
34
44
|
*/
|
|
35
45
|
exports.grypeScanCheck = {
|
|
36
46
|
name: 'Grype Scan',
|
|
@@ -42,14 +52,27 @@ exports.grypeScanCheck = {
|
|
|
42
52
|
const minSeverity = (ctx.config.grypeScan?.minSeverity ?? 'medium').toLowerCase();
|
|
43
53
|
const minRank = severityRank(minSeverity);
|
|
44
54
|
const ignore = (ctx.config.grypeScan?.ignore ?? []).map(s => s.toLowerCase());
|
|
45
|
-
|
|
55
|
+
// Never walk `ignoreDirs` — the same contract every other check honours, and
|
|
56
|
+
// here it is the difference between a usable check and an unusable one. A bare
|
|
57
|
+
// `grype dir:.` catalogs every file under `node_modules`, which on this repo took
|
|
58
|
+
// 355s versus 0.8s with the directory excluded, for byte-for-byte the same
|
|
59
|
+
// findings: grype resolves package versions from the lockfile, and installed
|
|
60
|
+
// third-party code is not something the client could fix anyway (see below).
|
|
61
|
+
const excludes = (ctx.config.ignoreDirs ?? []).flatMap(dir => ['--exclude', `**/${dir}/**`]);
|
|
62
|
+
const timeout = ctx.config.grypeScan?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
63
|
+
const result = await (0, execa_1.execa)('grype', ['dir:.', '-o', 'json', '-q', ...excludes], {
|
|
46
64
|
cwd: ctx.cwd,
|
|
47
65
|
reject: false,
|
|
66
|
+
timeout,
|
|
48
67
|
});
|
|
49
68
|
// execa v9 with reject:false resolves (not throws) with `code: 'ENOENT'`
|
|
50
69
|
// when the binary is absent — detect the missing scanner on the result.
|
|
51
70
|
if (result.code === 'ENOENT')
|
|
52
71
|
return (0, types_1.skip)('grype not installed');
|
|
72
|
+
if (result.timedOut) {
|
|
73
|
+
utils_1.logger.warn(`[Grype Scan]: ⚠️ grype timed out after ${timeout}ms — skipping`);
|
|
74
|
+
return (0, types_1.skip)('grype timed out');
|
|
75
|
+
}
|
|
53
76
|
let report;
|
|
54
77
|
try {
|
|
55
78
|
report = JSON.parse(result.stdout);
|
|
@@ -41,7 +41,28 @@ exports.npmAuditCheck = {
|
|
|
41
41
|
const ignore = new Set((ctx.config.security?.['npm-audit']?.ignore ?? []).map(name => name.toLowerCase()));
|
|
42
42
|
const ownName = getOwnPackageName();
|
|
43
43
|
const { stdout } = await (0, execa_1.execa)('npm', ['audit', '--json'], { cwd: ctx.cwd, reject: false });
|
|
44
|
-
|
|
44
|
+
// `npm audit` reports registry/lockfile problems as JSON on stdout, but not
|
|
45
|
+
// always: an absent npm (execa resolves with `code: 'ENOENT'`), a killed
|
|
46
|
+
// process or a proxy that answers with HTML all leave stdout unparseable.
|
|
47
|
+
// Parsing it bare made those cases throw, and a throw in a blocking check is
|
|
48
|
+
// a failed PR — the client saw "NPM Audit threw: Unexpected end of JSON
|
|
49
|
+
// input" and a red gate for an environment problem they did not cause.
|
|
50
|
+
let report;
|
|
51
|
+
try {
|
|
52
|
+
report = JSON.parse(stdout);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
utils_1.logger.warn('[NPM Audit]: ⚠️ npm audit produced no parseable report — skipping');
|
|
56
|
+
return (0, types_1.skip)('npm audit produced no parseable report');
|
|
57
|
+
}
|
|
58
|
+
// A repo with no lockfile, or a registry npm cannot reach, yields
|
|
59
|
+
// `{"error":{"code":"ENOLOCK",…}}` — no `vulnerabilities` key at all. Passing
|
|
60
|
+
// on that reads as "no vulnerabilities" when nothing was actually audited.
|
|
61
|
+
if (report.error) {
|
|
62
|
+
const detail = report.error.summary ?? report.error.code ?? 'unknown error';
|
|
63
|
+
utils_1.logger.warn(`[NPM Audit]: ⚠️ npm could not audit this project — ${detail}`);
|
|
64
|
+
return (0, types_1.skip)(`npm audit unavailable: ${report.error.code ?? 'error'}`);
|
|
65
|
+
}
|
|
45
66
|
const relevant = Object.values(report.vulnerabilities ?? {}).filter(v => v.isDirect && v.name !== ownName && isSeverityAtLeast(v.severity, auditLevel));
|
|
46
67
|
const [ignored, directVulns] = relevant.reduce(([skip, block], v) => {
|
|
47
68
|
(ignore.has(v.name.toLowerCase()) ? skip : block).push(v);
|
|
@@ -1,9 +1,2 @@
|
|
|
1
1
|
import { Check } from '../../types';
|
|
2
|
-
/**
|
|
3
|
-
* Cross-language dependency vulnerability scan via osv-scanner, which reads
|
|
4
|
-
* lockfiles across ecosystems (npm, PyPI, Go, crates, Maven, …) against the OSV
|
|
5
|
-
* database. Runner-dependency and opt-in (`vulnScan.enabled: true`): osv-scanner
|
|
6
|
-
* must be on the runner, and the check skips gracefully when it is absent.
|
|
7
|
-
* Complements the JS-only `npm-audit` with coverage for every other ecosystem.
|
|
8
|
-
*/
|
|
9
2
|
export declare const vulnScanCheck: Check;
|
|
@@ -10,7 +10,12 @@ const types_1 = require("../../types");
|
|
|
10
10
|
* database. Runner-dependency and opt-in (`vulnScan.enabled: true`): osv-scanner
|
|
11
11
|
* must be on the runner, and the check skips gracefully when it is absent.
|
|
12
12
|
* Complements the JS-only `npm-audit` with coverage for every other ecosystem.
|
|
13
|
+
*
|
|
14
|
+
* `-r .` walks the tree, so the run is capped: an advisory check must never be able
|
|
15
|
+
* to look like a hung commit, which is exactly what an uncapped directory scan does
|
|
16
|
+
* on a repo with dependencies installed.
|
|
13
17
|
*/
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
14
19
|
exports.vulnScanCheck = {
|
|
15
20
|
name: 'Vuln Scan',
|
|
16
21
|
phase: 'informational',
|
|
@@ -18,11 +23,20 @@ exports.vulnScanCheck = {
|
|
|
18
23
|
return ctx.config.vulnScan?.enabled === true;
|
|
19
24
|
},
|
|
20
25
|
async run(ctx) {
|
|
21
|
-
const
|
|
26
|
+
const timeout = ctx.config.vulnScan?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
27
|
+
const result = await (0, execa_1.execa)('osv-scanner', ['-r', '.'], {
|
|
28
|
+
cwd: ctx.cwd,
|
|
29
|
+
reject: false,
|
|
30
|
+
timeout,
|
|
31
|
+
});
|
|
22
32
|
// execa v9 with reject:false resolves (not throws) with `code: 'ENOENT'`
|
|
23
33
|
// when the binary is absent — detect the missing scanner on the result.
|
|
24
34
|
if (result.code === 'ENOENT')
|
|
25
35
|
return (0, types_1.skip)('osv-scanner not installed');
|
|
36
|
+
if (result.timedOut) {
|
|
37
|
+
utils_1.logger.warn(`[Vuln Scan]: ⚠️ osv-scanner timed out after ${timeout}ms — skipping`);
|
|
38
|
+
return (0, types_1.skip)('osv-scanner timed out');
|
|
39
|
+
}
|
|
26
40
|
if (result.exitCode === 0) {
|
|
27
41
|
utils_1.logger.info('[Vuln Scan]: ✅ No known vulnerabilities');
|
|
28
42
|
return (0, types_1.pass)();
|
|
@@ -74,6 +74,15 @@ exports.lintCheck = {
|
|
|
74
74
|
stdio: 'inherit',
|
|
75
75
|
reject: false,
|
|
76
76
|
});
|
|
77
|
-
|
|
77
|
+
if (result.exitCode === 0)
|
|
78
|
+
return (0, types_1.pass)();
|
|
79
|
+
// ESLint exits 1 for lint errors and 2 for a fatal error — a config it cannot
|
|
80
|
+
// load, an unresolvable plugin, a parser it cannot construct. Only the
|
|
81
|
+
// former is the client's code. Treating exit 2 as "lint errors found" turned
|
|
82
|
+
// a config incompatibility (often between our bundled config and the client's
|
|
83
|
+
// toolchain) into a red gate on their PR, and named the wrong cause.
|
|
84
|
+
if (result.exitCode === 1)
|
|
85
|
+
return (0, types_1.fail)('lint errors found');
|
|
86
|
+
return (0, types_1.warn)(`eslint failed to run (exit ${result.exitCode ?? 'killed'})${hasLocal ? '' : ' — using the pr-checkmate config'}`);
|
|
78
87
|
},
|
|
79
88
|
};
|
|
@@ -52,6 +52,13 @@ exports.typecheckCheck = {
|
|
|
52
52
|
const errorCount = (output.match(/error TS\d+/g) ?? []).length;
|
|
53
53
|
if (output)
|
|
54
54
|
utils_1.logger.warn(output);
|
|
55
|
+
// A non-zero exit with no diagnostic in it is not a type error — tsc was
|
|
56
|
+
// killed (OOM on a large project), crashed, or never started. Reporting that
|
|
57
|
+
// as `0 type error(s)` blocked PRs with a message naming nothing to fix.
|
|
58
|
+
if (errorCount === 0) {
|
|
59
|
+
utils_1.logger.warn(`[TypeScript]: ⚠️ tsc exited ${result.exitCode ?? 'abnormally'} without reporting a type error — treating as a tooling failure, not a type error`);
|
|
60
|
+
return (0, types_1.warn)(`tsc failed to run (exit ${result.exitCode ?? 'killed'})`);
|
|
61
|
+
}
|
|
55
62
|
return (0, types_1.fail)(`${errorCount} type error(s)`);
|
|
56
63
|
},
|
|
57
64
|
};
|
|
@@ -91,7 +91,16 @@ exports.markdownLintCheck = {
|
|
|
91
91
|
reject: false,
|
|
92
92
|
stdio: 'inherit',
|
|
93
93
|
});
|
|
94
|
-
|
|
94
|
+
if (result.exitCode === 0)
|
|
95
|
+
return (0, types_1.pass)();
|
|
96
|
+
// markdownlint-cli2 exits 1 for lint findings and 2 when it could not run
|
|
97
|
+
// (an unreadable config, a bad glob). Reporting the latter as "markdown lint
|
|
98
|
+
// issues found" names the wrong cause — and under `severity: error` it
|
|
99
|
+
// becomes a gate on a tooling problem.
|
|
100
|
+
if (result.exitCode === 2) {
|
|
101
|
+
return (0, types_1.warn)('markdownlint failed to run (exit 2) — check the markdownlint config');
|
|
102
|
+
}
|
|
103
|
+
return (0, types_1.warn)('markdown lint issues found');
|
|
95
104
|
}
|
|
96
105
|
finally {
|
|
97
106
|
if (tempConfigFile && node_fs_1.default.existsSync(tempConfigFile))
|
|
@@ -17,7 +17,18 @@ exports.securityCheck = {
|
|
|
17
17
|
return ctx.config.security?.gitleaks?.enabled !== false;
|
|
18
18
|
},
|
|
19
19
|
async run(ctx) {
|
|
20
|
-
|
|
20
|
+
// A binary that cannot be resolved throws, and a throw in a blocking check is
|
|
21
|
+
// a failed PR — the client would see "Security Scan threw: Cannot resolve
|
|
22
|
+
// binary" as a red gate on a repo with no secrets in it. Every other bundled
|
|
23
|
+
// tool (knip, license-checker) already skips here; this one did not.
|
|
24
|
+
let gitleaksBin;
|
|
25
|
+
try {
|
|
26
|
+
gitleaksBin = (0, utils_1.resolvePackageBin)('gitleaks-secret-scanner-fixed', 'bin/cli.js');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
utils_1.logger.warn('[Security Scan]: ⚠️ bundled gitleaks scanner could not be resolved — skipping');
|
|
30
|
+
return (0, types_1.skip)('gitleaks unavailable');
|
|
31
|
+
}
|
|
21
32
|
const hasClientConfig = (0, config_1.hasLocalConfig)([
|
|
22
33
|
'.gitleaks.toml',
|
|
23
34
|
'gitleaks.toml',
|
package/dist/init.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge current defaults into an existing config, recursively.
|
|
3
|
+
*
|
|
4
|
+
* The user's value always wins, so hand-tuned settings survive an `init` re-run,
|
|
5
|
+
* while every new default field — at any nesting depth — gets added. Arrays are
|
|
6
|
+
* replaced wholesale rather than concatenated: lists like `ignoreDirs` or
|
|
7
|
+
* `cspell.words` are meant to be edited, and merging them would resurrect entries
|
|
8
|
+
* the user deliberately removed. {@link REPLACED_OBJECT_PATHS} extends that rule to
|
|
9
|
+
* the object-shaped lists.
|
|
10
|
+
*
|
|
11
|
+
* Informational `//`-prefixed keys are the one exception — they are documentation,
|
|
12
|
+
* not settings, so the refreshed default text wins whenever a default exists.
|
|
13
|
+
*
|
|
14
|
+
* @param defaults Current defaults for this level of the config tree.
|
|
15
|
+
* @param user The existing pr-checkmate.json content for the same level.
|
|
16
|
+
* @param path Dot-joined position of `defaults` in the config tree, used to match
|
|
17
|
+
* {@link REPLACED_OBJECT_PATHS}. Internal to the recursion; callers pass nothing.
|
|
18
|
+
*/
|
|
19
|
+
export declare function mergeWithDefaults(defaults: Record<string, unknown>, user: Record<string, unknown>, path?: string): Record<string, unknown>;
|
|
1
20
|
/**
|
|
2
21
|
* Initialize (or refresh) pr-checkmate configuration in the current project.
|
|
3
22
|
*/
|
package/dist/init.js
CHANGED
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.mergeWithDefaults = mergeWithDefaults;
|
|
6
7
|
exports.init = init;
|
|
7
8
|
const fs_1 = __importDefault(require("fs"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
@@ -101,13 +102,26 @@ const DEFAULT_CONFIG = {
|
|
|
101
102
|
// are intentionally absent — add them here to detect duplication in them too.
|
|
102
103
|
formats: config_1.DUPLICATE_CODE_FORMATS,
|
|
103
104
|
},
|
|
104
|
-
// Dependency usage check (
|
|
105
|
+
// Dependency usage check (knip)
|
|
105
106
|
dependency: {
|
|
106
107
|
enabled: true,
|
|
107
|
-
// Dependency names
|
|
108
|
-
// which
|
|
108
|
+
// Dependency names to skip (e.g. CLI-only tools invoked as binaries,
|
|
109
|
+
// which knip can't see as imports and would report as unused).
|
|
109
110
|
ignore: [],
|
|
110
111
|
},
|
|
112
|
+
// Circular-import check (dpdm, bundled — nothing needed on the runner). Uses
|
|
113
|
+
// the project's own dpdm when it has one. Advisory by default (severity
|
|
114
|
+
// "warn"): most repos have long-standing cycles through barrel files, so see
|
|
115
|
+
// yours first, then set severity "error" to gate merges on them. enabled false
|
|
116
|
+
// disables the check; `ignore` takes glob patterns kept out of the analysis
|
|
117
|
+
// (generated or vendored code, or barrels whose cycles you tolerate). When the
|
|
118
|
+
// analyzer cannot run, the check skips rather than passing or failing.
|
|
119
|
+
circularDeps: {
|
|
120
|
+
enabled: true,
|
|
121
|
+
severity: 'warn',
|
|
122
|
+
ignore: [],
|
|
123
|
+
timeoutMs: 120000,
|
|
124
|
+
},
|
|
111
125
|
// Unused-exports check (ts-unused-exports)
|
|
112
126
|
deadCode: {
|
|
113
127
|
enabled: true,
|
|
@@ -144,7 +158,9 @@ const DEFAULT_CONFIG = {
|
|
|
144
158
|
dockerfileSecurity: { enabled: true }, // Dockerfile: :latest, USER root, curl|bash, …
|
|
145
159
|
caseCollision: { enabled: true }, // files differing only by case
|
|
146
160
|
vulnScan: { enabled: false }, // cross-language dep vulns (osv-scanner, runner-dependency)
|
|
147
|
-
|
|
161
|
+
// dep vulns (grype, non-blocking, runner-dependency; skips if grype absent).
|
|
162
|
+
// Never walks `ignoreDirs`; `timeoutMs` caps the scan so it cannot stall a commit.
|
|
163
|
+
grypeScan: { enabled: true, minSeverity: 'medium', timeoutMs: 120000 },
|
|
148
164
|
// Test-coverage presence. Opt-in: enable once your CI produces a coverage
|
|
149
165
|
// report (lcov/cobertura/jacoco/…); warns if none is found.
|
|
150
166
|
coverage: { enabled: false },
|
|
@@ -270,6 +286,52 @@ async function createConfig(root) {
|
|
|
270
286
|
setupWorkflow(root);
|
|
271
287
|
setupTsConfig(root, sourcePath);
|
|
272
288
|
}
|
|
289
|
+
function isPlainObject(value) {
|
|
290
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Config paths whose object value is a curated list, not a set of settings — the
|
|
294
|
+
* user is meant to add and remove entries, exactly like an array.
|
|
295
|
+
*
|
|
296
|
+
* These are merged the way arrays are: the user's object replaces the default one
|
|
297
|
+
* wholesale. Deep-merging them would hand back every rule the user deleted, and for
|
|
298
|
+
* `eslint.rules` that is not cosmetic — Lint is a blocking check, so a re-run of
|
|
299
|
+
* `init` would silently reinstate rules and start failing the next PR on them. The
|
|
300
|
+
* documented way to switch a rule off is `"off"`, which survives either way.
|
|
301
|
+
*/
|
|
302
|
+
const REPLACED_OBJECT_PATHS = new Set(['eslint.rules', 'prettier.rules', 'markdownlint.rules']);
|
|
303
|
+
/**
|
|
304
|
+
* Merge current defaults into an existing config, recursively.
|
|
305
|
+
*
|
|
306
|
+
* The user's value always wins, so hand-tuned settings survive an `init` re-run,
|
|
307
|
+
* while every new default field — at any nesting depth — gets added. Arrays are
|
|
308
|
+
* replaced wholesale rather than concatenated: lists like `ignoreDirs` or
|
|
309
|
+
* `cspell.words` are meant to be edited, and merging them would resurrect entries
|
|
310
|
+
* the user deliberately removed. {@link REPLACED_OBJECT_PATHS} extends that rule to
|
|
311
|
+
* the object-shaped lists.
|
|
312
|
+
*
|
|
313
|
+
* Informational `//`-prefixed keys are the one exception — they are documentation,
|
|
314
|
+
* not settings, so the refreshed default text wins whenever a default exists.
|
|
315
|
+
*
|
|
316
|
+
* @param defaults Current defaults for this level of the config tree.
|
|
317
|
+
* @param user The existing pr-checkmate.json content for the same level.
|
|
318
|
+
* @param path Dot-joined position of `defaults` in the config tree, used to match
|
|
319
|
+
* {@link REPLACED_OBJECT_PATHS}. Internal to the recursion; callers pass nothing.
|
|
320
|
+
*/
|
|
321
|
+
function mergeWithDefaults(defaults, user, path = '') {
|
|
322
|
+
const merged = { ...defaults };
|
|
323
|
+
for (const [key, userValue] of Object.entries(user)) {
|
|
324
|
+
const defaultValue = defaults[key];
|
|
325
|
+
const keyPath = path ? `${path}.${key}` : key;
|
|
326
|
+
if (key.startsWith('//') && defaultValue !== undefined)
|
|
327
|
+
continue;
|
|
328
|
+
merged[key] =
|
|
329
|
+
isPlainObject(defaultValue) && isPlainObject(userValue) && !REPLACED_OBJECT_PATHS.has(keyPath)
|
|
330
|
+
? mergeWithDefaults(defaultValue, userValue, keyPath)
|
|
331
|
+
: userValue;
|
|
332
|
+
}
|
|
333
|
+
return merged;
|
|
334
|
+
}
|
|
273
335
|
/** Re-run setup: merge the existing config with current defaults. */
|
|
274
336
|
async function updateConfig(root) {
|
|
275
337
|
utils_1.logger.info('[init]: pr-checkmate.json already exists — updating it.');
|
|
@@ -278,19 +340,13 @@ async function updateConfig(root) {
|
|
|
278
340
|
const json = JSON.parse(raw);
|
|
279
341
|
const languages = await (0, utils_1.detectLanguages)(root);
|
|
280
342
|
utils_1.logger.info(`[init]: 🔍 Detected languages: ${languages.length ? languages.join(', ') : 'none'}`);
|
|
281
|
-
// Merge with defaults to add new fields
|
|
343
|
+
// Merge with defaults to add new fields at every depth — a hand-maintained
|
|
344
|
+
// list of sections to merge would silently stop delivering new defaults to
|
|
345
|
+
// existing configs the moment a section is forgotten.
|
|
282
346
|
const updated = {
|
|
283
|
-
...DEFAULT_CONFIG,
|
|
284
|
-
...json,
|
|
347
|
+
...mergeWithDefaults(DEFAULT_CONFIG, json),
|
|
285
348
|
// Refresh detected languages on every init so new source dirs are picked up
|
|
286
349
|
languages,
|
|
287
|
-
eslint: { ...DEFAULT_CONFIG.eslint, ...(json.eslint || {}) },
|
|
288
|
-
prettier: { ...DEFAULT_CONFIG.prettier, ...(json.prettier || {}) },
|
|
289
|
-
cspell: { ...DEFAULT_CONFIG.cspell, ...(json.cspell || {}) },
|
|
290
|
-
typecheck: { ...DEFAULT_CONFIG.typecheck, ...(json.typecheck || {}) },
|
|
291
|
-
diffSmell: { ...DEFAULT_CONFIG.diffSmell, ...(json.diffSmell || {}) },
|
|
292
|
-
prBody: { ...DEFAULT_CONFIG.prBody, ...(json.prBody || {}) },
|
|
293
|
-
duplicate: { ...DEFAULT_CONFIG.duplicate, ...(json.duplicate || {}) },
|
|
294
350
|
};
|
|
295
351
|
fs_1.default.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2));
|
|
296
352
|
utils_1.logger.info('[init]: 🔧 Updated pr-checkmate.json with customization options.');
|
|
@@ -145,11 +145,13 @@ export interface PRCheckMateConfig {
|
|
|
145
145
|
dependency?: {
|
|
146
146
|
enabled?: boolean;
|
|
147
147
|
/**
|
|
148
|
-
* Dependency names to exclude from the unused/missing check (
|
|
149
|
-
* Supports globs, e.g. "@types/*"
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
148
|
+
* Dependency names to exclude from the unused/missing check (knip).
|
|
149
|
+
* Supports globs matched against the whole name, e.g. "@types/*" — so
|
|
150
|
+
* "eslint-*" covers "eslint-plugin-x" but never "eslint" itself. Use for
|
|
151
|
+
* packages that are invoked only as CLI binaries or referenced from config —
|
|
152
|
+
* knip sees no `import` for them and would otherwise report them as unused.
|
|
153
|
+
* Compiled into a temporary knip config, so a repo that ships its own knip
|
|
154
|
+
* config keeps full control instead.
|
|
153
155
|
*/
|
|
154
156
|
ignore?: string[];
|
|
155
157
|
};
|
|
@@ -202,13 +204,27 @@ export interface PRCheckMateConfig {
|
|
|
202
204
|
severity?: 'error' | 'warn';
|
|
203
205
|
ignore?: string[];
|
|
204
206
|
};
|
|
207
|
+
circularDeps?: {
|
|
208
|
+
enabled?: boolean;
|
|
209
|
+
severity?: 'error' | 'warn';
|
|
210
|
+
ignore?: string[];
|
|
211
|
+
timeoutMs?: number;
|
|
212
|
+
};
|
|
205
213
|
vulnScan?: {
|
|
206
214
|
enabled?: boolean;
|
|
215
|
+
timeoutMs?: number;
|
|
207
216
|
};
|
|
208
217
|
grypeScan?: {
|
|
209
218
|
enabled?: boolean;
|
|
210
219
|
minSeverity?: 'negligible' | 'low' | 'medium' | 'high' | 'critical';
|
|
211
220
|
ignore?: string[];
|
|
221
|
+
/**
|
|
222
|
+
* Max time (ms) for the grype scan before the check skips. Default 120000.
|
|
223
|
+
* `ignoreDirs` already keeps `node_modules`, `dist` and friends out of the walk,
|
|
224
|
+
* which is what makes the scan fast; this is the backstop so an advisory check
|
|
225
|
+
* can never stall a commit.
|
|
226
|
+
*/
|
|
227
|
+
timeoutMs?: number;
|
|
212
228
|
};
|
|
213
229
|
bannedImports?: Array<{
|
|
214
230
|
name: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-checkmate",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.19",
|
|
4
4
|
"description": "Automated PR quality checks: linting, formatting, dependency analysis, and spellcheck",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github-actions",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"@typescript-eslint/parser": "^8.62.1",
|
|
84
84
|
"@wasm-fmt/clang-format": "^22.1.8",
|
|
85
85
|
"cspell": "^10.0.1",
|
|
86
|
-
"
|
|
86
|
+
"dpdm": "^4.2.0",
|
|
87
87
|
"eslint": "^10.6.0",
|
|
88
88
|
"eslint-config-prettier": "^10.1.8",
|
|
89
89
|
"eslint-plugin-import-x": "^4.17.1",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"gitleaks-secret-scanner-fixed": "^2.1.3",
|
|
95
95
|
"inquirer": "^14.0.2",
|
|
96
96
|
"jscpd": "^5.0.11",
|
|
97
|
+
"knip": "^6.29.0",
|
|
97
98
|
"license-checker-rseidelsohn": "^5.0.1",
|
|
98
99
|
"markdownlint-cli2": "^0.23.1",
|
|
99
100
|
"prettier": ">=3.0.0",
|
|
@@ -123,9 +124,6 @@
|
|
|
123
124
|
"follow-redirects": "1.16.0",
|
|
124
125
|
"lodash": "4.18.1",
|
|
125
126
|
"minimatch": "10.2.5",
|
|
126
|
-
"depcheck": {
|
|
127
|
-
"minimatch": "^7.4.9"
|
|
128
|
-
},
|
|
129
127
|
"postcss": "8.5.23",
|
|
130
128
|
"handlebars": "4.7.9"
|
|
131
129
|
}
|
package/pr-checkmate.schema.json
CHANGED
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
},
|
|
88
88
|
"vulnScan": {
|
|
89
89
|
"type": "object",
|
|
90
|
-
"properties": { "enabled": { "type": "boolean" } }
|
|
90
|
+
"properties": { "enabled": { "type": "boolean" }, "timeoutMs": { "type": "number" } }
|
|
91
91
|
},
|
|
92
92
|
"grypeScan": {
|
|
93
93
|
"type": "object",
|
|
@@ -97,7 +97,8 @@
|
|
|
97
97
|
"type": "string",
|
|
98
98
|
"enum": ["negligible", "low", "medium", "high", "critical"]
|
|
99
99
|
},
|
|
100
|
-
"ignore": { "type": "array", "items": { "type": "string" } }
|
|
100
|
+
"ignore": { "type": "array", "items": { "type": "string" } },
|
|
101
|
+
"timeoutMs": { "type": "number" }
|
|
101
102
|
}
|
|
102
103
|
},
|
|
103
104
|
"security": {
|
|
@@ -156,6 +157,15 @@
|
|
|
156
157
|
"ignore": { "type": "array", "items": { "type": "string" } }
|
|
157
158
|
}
|
|
158
159
|
},
|
|
160
|
+
"circularDeps": {
|
|
161
|
+
"type": "object",
|
|
162
|
+
"properties": {
|
|
163
|
+
"enabled": { "type": "boolean" },
|
|
164
|
+
"severity": { "enum": ["error", "warn"] },
|
|
165
|
+
"ignore": { "type": "array", "items": { "type": "string" } },
|
|
166
|
+
"timeoutMs": { "type": "number" }
|
|
167
|
+
}
|
|
168
|
+
},
|
|
159
169
|
"typecheck": { "$ref": "#/$defs/toggle" },
|
|
160
170
|
"prBody": {
|
|
161
171
|
"type": "object",
|
package/src/config/cspell.json
CHANGED
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
},
|
|
88
88
|
"vulnScan": {
|
|
89
89
|
"type": "object",
|
|
90
|
-
"properties": { "enabled": { "type": "boolean" } }
|
|
90
|
+
"properties": { "enabled": { "type": "boolean" }, "timeoutMs": { "type": "number" } }
|
|
91
91
|
},
|
|
92
92
|
"grypeScan": {
|
|
93
93
|
"type": "object",
|
|
@@ -97,7 +97,8 @@
|
|
|
97
97
|
"type": "string",
|
|
98
98
|
"enum": ["negligible", "low", "medium", "high", "critical"]
|
|
99
99
|
},
|
|
100
|
-
"ignore": { "type": "array", "items": { "type": "string" } }
|
|
100
|
+
"ignore": { "type": "array", "items": { "type": "string" } },
|
|
101
|
+
"timeoutMs": { "type": "number" }
|
|
101
102
|
}
|
|
102
103
|
},
|
|
103
104
|
"security": {
|
|
@@ -156,6 +157,15 @@
|
|
|
156
157
|
"ignore": { "type": "array", "items": { "type": "string" } }
|
|
157
158
|
}
|
|
158
159
|
},
|
|
160
|
+
"circularDeps": {
|
|
161
|
+
"type": "object",
|
|
162
|
+
"properties": {
|
|
163
|
+
"enabled": { "type": "boolean" },
|
|
164
|
+
"severity": { "enum": ["error", "warn"] },
|
|
165
|
+
"ignore": { "type": "array", "items": { "type": "string" } },
|
|
166
|
+
"timeoutMs": { "type": "number" }
|
|
167
|
+
}
|
|
168
|
+
},
|
|
159
169
|
"typecheck": { "$ref": "#/$defs/toggle" },
|
|
160
170
|
"prBody": {
|
|
161
171
|
"type": "object",
|