pr-checkmate 1.23.16 → 1.23.18

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/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
- const cmd = process.argv[2];
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
- const checkName = COMMAND_TO_CHECK[cmd];
85
- if (!checkName) {
86
- utils_1.logger.warn(`[cli]: Unknown command "${cmd}". Available: all, init, ${Object.keys(COMMAND_TO_CHECK).join(', ')}`);
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 report = await (0, core_1.runChecks)({ only: [checkName], reporter: 'stdout', write: true });
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) {
@@ -1,2 +1,14 @@
1
1
  import { Check } from '../../types';
2
+ /**
3
+ * Compile one `dependency.ignore` entry into a pattern knip matches the way this
4
+ * config documents it — a glob over the whole dependency name.
5
+ *
6
+ * knip turns any value containing `* + \ ( | { ^ $` into an *unanchored* `RegExp`
7
+ * and compares everything else by string equality. A glob passed straight through
8
+ * would therefore silence far more than it names: `eslint-*` reads as "eslint
9
+ * followed by zero or more dashes", tested as a substring, so it also swallows
10
+ * `eslint` itself. Escaping first and anchoring the result keeps `eslint-*` matching
11
+ * only `eslint-…`, and keeps a plain name matching exactly that name.
12
+ */
13
+ export declare function toKnipIgnorePattern(entry: string): string;
2
14
  export declare const dependencyCheck: Check;
@@ -4,11 +4,69 @@ 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;
7
8
  const node_fs_1 = __importDefault(require("node:fs"));
8
9
  const node_path_1 = __importDefault(require("node:path"));
10
+ const config_1 = require("../../../config");
9
11
  const utils_1 = require("../../../utils");
10
12
  const execa_1 = require("../../../utils/execa");
11
13
  const types_1 = require("../../types");
14
+ /**
15
+ * Config files knip discovers on its own. When a client ships one, it is the source
16
+ * of truth and pr-checkmate passes no config of its own — the same rule spellcheck
17
+ * follows for cspell.json.
18
+ *
19
+ * Mirrors knip's own `KNIP_CONFIG_LOCATIONS` exactly. Listing a name knip does not
20
+ * read (`knip.config.mjs`, say) would be worse than useless: the repo would count as
21
+ * "configures knip itself", so `dependency.ignore` would be dropped, while knip
22
+ * ignored that file too — leaving the client with warnings they had silenced.
23
+ */
24
+ const KNIP_CONFIG_FILES = [
25
+ 'knip.json',
26
+ 'knip.jsonc',
27
+ '.knip.json',
28
+ '.knip.jsonc',
29
+ 'knip.ts',
30
+ 'knip.js',
31
+ 'knip.config.ts',
32
+ 'knip.config.js',
33
+ ];
34
+ /** Regex metacharacters to neutralise before a glob is compiled. */
35
+ const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
36
+ /**
37
+ * Compile one `dependency.ignore` entry into a pattern knip matches the way this
38
+ * config documents it — a glob over the whole dependency name.
39
+ *
40
+ * knip turns any value containing `* + \ ( | { ^ $` into an *unanchored* `RegExp`
41
+ * and compares everything else by string equality. A glob passed straight through
42
+ * would therefore silence far more than it names: `eslint-*` reads as "eslint
43
+ * followed by zero or more dashes", tested as a substring, so it also swallows
44
+ * `eslint` itself. Escaping first and anchoring the result keeps `eslint-*` matching
45
+ * only `eslint-…`, and keeps a plain name matching exactly that name.
46
+ */
47
+ function toKnipIgnorePattern(entry) {
48
+ const escaped = entry.replace(REGEX_SPECIALS, '\\$&');
49
+ return `^${escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.')}$`;
50
+ }
51
+ /** knip also reads a `knip` key from package.json. */
52
+ function hasPackageJsonConfig(cwd) {
53
+ try {
54
+ return 'knip' in JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, 'package.json'), 'utf-8'));
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ /** Collect the names reported for one issue type across every file in the report. */
61
+ function collectNames(issues, key) {
62
+ return [...new Set(issues.flatMap(issue => (issue[key] ?? []).map(entry => entry.name)))];
63
+ }
64
+ /** Files a given unlisted dependency is imported from, for the failure message. */
65
+ function filesUsing(issues, name) {
66
+ return issues
67
+ .filter(issue => (issue.unlisted ?? []).some(entry => entry.name === name))
68
+ .map(issue => issue.file);
69
+ }
12
70
  exports.dependencyCheck = {
13
71
  name: 'Dependencies',
14
72
  phase: 'blocking',
@@ -18,51 +76,80 @@ exports.dependencyCheck = {
18
76
  return node_fs_1.default.existsSync(node_path_1.default.join(ctx.cwd, 'package.json'));
19
77
  },
20
78
  async run(ctx) {
21
- // Resolve the bundled depcheck binary and run it via the current node — never
22
- // shell out to `npx`, which fails (exit 127) when depcheck isn't on the path.
23
- let depcheckBin;
79
+ // Resolve the bundled knip binary and run it via the current node — never
80
+ // shell out to `npx`, which fails (exit 127) when knip isn't on the path.
81
+ let knipBin;
24
82
  try {
25
- depcheckBin = (0, utils_1.resolvePackageBin)('depcheck', 'bin/depcheck.js');
83
+ knipBin = (0, utils_1.resolvePackageBin)('knip', 'bin/knip.js');
26
84
  }
27
85
  catch {
28
- return (0, types_1.skip)('depcheck unavailable');
86
+ return (0, types_1.skip)('knip unavailable');
29
87
  }
30
- // Dependency names/globs to skip, configured in pr-checkmate.json. Passed via
31
- // --ignores so a single pr-checkmate.json is the source of truth; depcheck's
32
- // CLI --ignores overrides any .depcheckrc, so we only pass it when non-empty
33
- // (letting a client's own .depcheckrc still apply when nothing is configured).
88
+ // Dependency names to skip, configured in pr-checkmate.json. knip takes these
89
+ // from a config file only, so they go into a temp one — but a client's own knip
90
+ // config wins outright, exactly as it does for cspell.
34
91
  const ignore = ctx.config.dependency?.ignore ?? [];
35
- const args = [depcheckBin, '--json'];
36
- if (ignore.length > 0)
37
- args.push(`--ignores=${ignore.join(',')}`);
38
- // reject:false — depcheck exits non-zero when it finds issues but still emits JSON on stdout.
39
- const { stdout } = await (0, execa_1.execa)(process.execPath, args, {
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);
92
+ const clientOwnsConfig = (0, config_1.hasLocalConfig)(KNIP_CONFIG_FILES) || hasPackageJsonConfig(ctx.cwd);
93
+ let tempConfigPath = null;
94
+ if (ignore.length > 0 && clientOwnsConfig) {
95
+ // Say so out loud: a silently ignored `ignore` list looks like a broken check.
96
+ 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
97
  }
53
- catch {
54
- return (0, types_1.fail)('depcheck produced invalid JSON');
98
+ if (ignore.length > 0 && !clientOwnsConfig) {
99
+ tempConfigPath = node_path_1.default.join(ctx.cwd, '.knip.temp.json');
100
+ node_fs_1.default.writeFileSync(tempConfigPath, JSON.stringify({ ignoreDependencies: ignore.map(toKnipIgnorePattern) }, null, 2));
55
101
  }
56
- const unused = result.dependencies ?? [];
57
- const missing = Object.keys(result.missing ?? {});
58
- if (missing.length > 0) {
59
- missing.forEach(dep => utils_1.logger.error(`[Dependencies]: ❌ missing ${dep} (used in: ${result.missing[dep]?.join(', ')})`));
60
- return (0, types_1.fail)(`missing: ${missing.join(', ')}`);
102
+ // Only unused prod dependencies and imports missing from package.json are
103
+ // reported. knip's other issue types (unused devDependencies, binaries,
104
+ // unresolved paths) are noise here: CLI tools and shell commands legitimately
105
+ // have no import to find.
106
+ const args = [
107
+ knipBin,
108
+ '--reporter',
109
+ 'json',
110
+ '--include',
111
+ 'dependencies,unlisted',
112
+ '--no-config-hints',
113
+ '--no-exit-code',
114
+ '--no-progress',
115
+ ];
116
+ if (tempConfigPath)
117
+ args.push('--config', tempConfigPath);
118
+ try {
119
+ // reject:false — knip can still exit non-zero (e.g. config hints) while
120
+ // writing a perfectly good report to stdout.
121
+ const { stdout } = await (0, execa_1.execa)(process.execPath, args, {
122
+ cwd: ctx.cwd,
123
+ reject: false,
124
+ });
125
+ const start = stdout.indexOf('{');
126
+ const end = stdout.lastIndexOf('}');
127
+ // No JSON at all → the tool couldn't produce a result; skip rather than fail the client's PR.
128
+ if (start === -1 || end === -1)
129
+ return (0, types_1.skip)('knip produced no output');
130
+ let result;
131
+ try {
132
+ result = JSON.parse(stdout.slice(start, end + 1));
133
+ }
134
+ catch {
135
+ return (0, types_1.fail)('knip produced invalid JSON');
136
+ }
137
+ const issues = result.issues ?? [];
138
+ const unused = collectNames(issues, 'dependencies');
139
+ const missing = collectNames(issues, 'unlisted');
140
+ if (missing.length > 0) {
141
+ missing.forEach(dep => utils_1.logger.error(`[Dependencies]: ❌ missing ${dep} (used in: ${filesUsing(issues, dep).join(', ')})`));
142
+ return (0, types_1.fail)(`missing: ${missing.join(', ')}`);
143
+ }
144
+ if (unused.length > 0) {
145
+ unused.forEach(dep => utils_1.logger.warn(`[Dependencies]: ⚠️ unused ${dep}`));
146
+ return (0, types_1.warn)(`${unused.length} unused dependency(ies)`);
147
+ }
148
+ return (0, types_1.pass)();
61
149
  }
62
- if (unused.length > 0) {
63
- unused.forEach(dep => utils_1.logger.warn(`[Dependencies]: ⚠️ unused ${dep}`));
64
- return (0, types_1.warn)(`${unused.length} unused dependency(ies)`);
150
+ finally {
151
+ if (tempConfigPath)
152
+ (0, utils_1.cleanupTempFile)(tempConfigPath);
65
153
  }
66
- return (0, types_1.pass)();
67
154
  },
68
155
  };
@@ -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
- const result = await (0, execa_1.execa)('grype', ['dir:.', '-o', 'json', '-q'], {
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);
@@ -33,7 +33,7 @@ exports.licenseCheck = {
33
33
  // shell out to `npx`, which fails (exit 127) when license-checker isn't on the path.
34
34
  let licenseCheckerBin;
35
35
  try {
36
- licenseCheckerBin = (0, utils_1.resolvePackageBin)('license-checker', 'bin/license-checker');
36
+ licenseCheckerBin = (0, utils_1.resolvePackageBin)('license-checker-rseidelsohn', 'bin/license-checker-rseidelsohn.js');
37
37
  }
38
38
  catch {
39
39
  return (0, types_1.skip)('license-checker unavailable');
@@ -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 result = await (0, execa_1.execa)('osv-scanner', ['-r', '.'], { cwd: ctx.cwd, reject: false });
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)();
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,11 +102,11 @@ 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 (depcheck)
105
+ // Dependency usage check (knip)
105
106
  dependency: {
106
107
  enabled: true,
107
- // Dependency names/globs to skip (e.g. CLI-only tools invoked as binaries,
108
- // which depcheck can't see as imports and would report as unused).
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
  },
111
112
  // Unused-exports check (ts-unused-exports)
@@ -144,7 +145,9 @@ const DEFAULT_CONFIG = {
144
145
  dockerfileSecurity: { enabled: true }, // Dockerfile: :latest, USER root, curl|bash, …
145
146
  caseCollision: { enabled: true }, // files differing only by case
146
147
  vulnScan: { enabled: false }, // cross-language dep vulns (osv-scanner, runner-dependency)
147
- grypeScan: { enabled: true, minSeverity: 'medium' }, // dep vulns (grype, non-blocking, runner-dependency; skips if grype absent)
148
+ // dep vulns (grype, non-blocking, runner-dependency; skips if grype absent).
149
+ // Never walks `ignoreDirs`; `timeoutMs` caps the scan so it cannot stall a commit.
150
+ grypeScan: { enabled: true, minSeverity: 'medium', timeoutMs: 120000 },
148
151
  // Test-coverage presence. Opt-in: enable once your CI produces a coverage
149
152
  // report (lcov/cobertura/jacoco/…); warns if none is found.
150
153
  coverage: { enabled: false },
@@ -270,6 +273,52 @@ async function createConfig(root) {
270
273
  setupWorkflow(root);
271
274
  setupTsConfig(root, sourcePath);
272
275
  }
276
+ function isPlainObject(value) {
277
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
278
+ }
279
+ /**
280
+ * Config paths whose object value is a curated list, not a set of settings — the
281
+ * user is meant to add and remove entries, exactly like an array.
282
+ *
283
+ * These are merged the way arrays are: the user's object replaces the default one
284
+ * wholesale. Deep-merging them would hand back every rule the user deleted, and for
285
+ * `eslint.rules` that is not cosmetic — Lint is a blocking check, so a re-run of
286
+ * `init` would silently reinstate rules and start failing the next PR on them. The
287
+ * documented way to switch a rule off is `"off"`, which survives either way.
288
+ */
289
+ const REPLACED_OBJECT_PATHS = new Set(['eslint.rules', 'prettier.rules', 'markdownlint.rules']);
290
+ /**
291
+ * Merge current defaults into an existing config, recursively.
292
+ *
293
+ * The user's value always wins, so hand-tuned settings survive an `init` re-run,
294
+ * while every new default field — at any nesting depth — gets added. Arrays are
295
+ * replaced wholesale rather than concatenated: lists like `ignoreDirs` or
296
+ * `cspell.words` are meant to be edited, and merging them would resurrect entries
297
+ * the user deliberately removed. {@link REPLACED_OBJECT_PATHS} extends that rule to
298
+ * the object-shaped lists.
299
+ *
300
+ * Informational `//`-prefixed keys are the one exception — they are documentation,
301
+ * not settings, so the refreshed default text wins whenever a default exists.
302
+ *
303
+ * @param defaults Current defaults for this level of the config tree.
304
+ * @param user The existing pr-checkmate.json content for the same level.
305
+ * @param path Dot-joined position of `defaults` in the config tree, used to match
306
+ * {@link REPLACED_OBJECT_PATHS}. Internal to the recursion; callers pass nothing.
307
+ */
308
+ function mergeWithDefaults(defaults, user, path = '') {
309
+ const merged = { ...defaults };
310
+ for (const [key, userValue] of Object.entries(user)) {
311
+ const defaultValue = defaults[key];
312
+ const keyPath = path ? `${path}.${key}` : key;
313
+ if (key.startsWith('//') && defaultValue !== undefined)
314
+ continue;
315
+ merged[key] =
316
+ isPlainObject(defaultValue) && isPlainObject(userValue) && !REPLACED_OBJECT_PATHS.has(keyPath)
317
+ ? mergeWithDefaults(defaultValue, userValue, keyPath)
318
+ : userValue;
319
+ }
320
+ return merged;
321
+ }
273
322
  /** Re-run setup: merge the existing config with current defaults. */
274
323
  async function updateConfig(root) {
275
324
  utils_1.logger.info('[init]: pr-checkmate.json already exists — updating it.');
@@ -278,19 +327,13 @@ async function updateConfig(root) {
278
327
  const json = JSON.parse(raw);
279
328
  const languages = await (0, utils_1.detectLanguages)(root);
280
329
  utils_1.logger.info(`[init]: 🔍 Detected languages: ${languages.length ? languages.join(', ') : 'none'}`);
281
- // Merge with defaults to add new fields
330
+ // Merge with defaults to add new fields at every depth — a hand-maintained
331
+ // list of sections to merge would silently stop delivering new defaults to
332
+ // existing configs the moment a section is forgotten.
282
333
  const updated = {
283
- ...DEFAULT_CONFIG,
284
- ...json,
334
+ ...mergeWithDefaults(DEFAULT_CONFIG, json),
285
335
  // Refresh detected languages on every init so new source dirs are picked up
286
336
  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
337
  };
295
338
  fs_1.default.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2));
296
339
  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 (depcheck).
149
- * Supports globs, e.g. "@types/*". Use for packages that are invoked only as
150
- * CLI binaries or referenced from config — depcheck sees no `import` for them
151
- * and would otherwise report them as unused. Passed to depcheck's `--ignores`,
152
- * which takes precedence over any `.depcheckrc` file.
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
  };
@@ -204,11 +206,19 @@ export interface PRCheckMateConfig {
204
206
  };
205
207
  vulnScan?: {
206
208
  enabled?: boolean;
209
+ timeoutMs?: number;
207
210
  };
208
211
  grypeScan?: {
209
212
  enabled?: boolean;
210
213
  minSeverity?: 'negligible' | 'low' | 'medium' | 'high' | 'critical';
211
214
  ignore?: string[];
215
+ /**
216
+ * Max time (ms) for the grype scan before the check skips. Default 120000.
217
+ * `ignoreDirs` already keeps `node_modules`, `dist` and friends out of the walk,
218
+ * which is what makes the scan fast; this is the backstop so an advisory check
219
+ * can never stall a commit.
220
+ */
221
+ timeoutMs?: number;
212
222
  };
213
223
  bannedImports?: Array<{
214
224
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-checkmate",
3
- "version": "1.23.16",
3
+ "version": "1.23.18",
4
4
  "description": "Automated PR quality checks: linting, formatting, dependency analysis, and spellcheck",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -83,7 +83,6 @@
83
83
  "@typescript-eslint/parser": "^8.62.1",
84
84
  "@wasm-fmt/clang-format": "^22.1.8",
85
85
  "cspell": "^10.0.1",
86
- "depcheck": "^1.4.7",
87
86
  "eslint": "^10.6.0",
88
87
  "eslint-config-prettier": "^10.1.8",
89
88
  "eslint-plugin-import-x": "^4.17.1",
@@ -94,7 +93,8 @@
94
93
  "gitleaks-secret-scanner-fixed": "^2.1.3",
95
94
  "inquirer": "^14.0.2",
96
95
  "jscpd": "^5.0.11",
97
- "license-checker": "^25.0.1",
96
+ "knip": "^6.29.0",
97
+ "license-checker-rseidelsohn": "^5.0.1",
98
98
  "markdownlint-cli2": "^0.23.1",
99
99
  "prettier": ">=3.0.0",
100
100
  "ts-node": "^10.9.1",
@@ -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": {
@@ -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": {