pr-checkmate 3.2.3 → 3.3.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 (73) hide show
  1. package/.eslintrc.js +3 -9
  2. package/README.md +1 -1
  3. package/dist/cjs/cli.js +13 -0
  4. package/dist/cjs/config/pr-checkmate.template.json +1 -1
  5. package/dist/cjs/core/checks/dependencies/grype-scan.js +18 -0
  6. package/dist/cjs/core/checks/languages/lint.js +12 -0
  7. package/dist/cjs/core/checks/languages/ruff-shared.js +6 -1
  8. package/dist/cjs/core/checks/pr/commitlint.js +4 -2
  9. package/dist/cjs/core/checks/quality/package-size.d.ts +23 -1
  10. package/dist/cjs/core/checks/quality/package-size.js +20 -4
  11. package/dist/cjs/core/checks/quality/spellcheck.js +8 -5
  12. package/dist/cjs/core/install/doctor.js +12 -0
  13. package/dist/cjs/core/install/grype-db.d.ts +25 -0
  14. package/dist/cjs/core/install/grype-db.js +45 -0
  15. package/dist/cjs/core/install/profiles.d.ts +1 -1
  16. package/dist/cjs/core/install/profiles.js +1 -1
  17. package/dist/cjs/core/install/reuse.js +6 -3
  18. package/dist/cjs/init.d.ts +0 -8
  19. package/dist/cjs/init.js +1 -2
  20. package/dist/cjs/utils/own-deps.js +8 -1
  21. package/dist/cjs/utils/resolve-bin.d.ts +2 -0
  22. package/dist/cjs/utils/resolve-bin.js +5 -0
  23. package/dist/esm/cli.js +14 -1
  24. package/dist/esm/config/pr-checkmate.template.json +1 -1
  25. package/dist/esm/core/checks/dependencies/dependency-check.js +2 -2
  26. package/dist/esm/core/checks/dependencies/grype-scan.js +18 -0
  27. package/dist/esm/core/checks/dependencies/license-check.js +2 -2
  28. package/dist/esm/core/checks/dependencies/npm-audit.js +2 -2
  29. package/dist/esm/core/checks/git/diff-smell.js +1 -1
  30. package/dist/esm/core/checks/languages/cpp-format.js +1 -1
  31. package/dist/esm/core/checks/languages/lint.js +12 -3
  32. package/dist/esm/core/checks/languages/prettier.js +3 -3
  33. package/dist/esm/core/checks/languages/python-format.js +2 -2
  34. package/dist/esm/core/checks/languages/python-lint.js +2 -2
  35. package/dist/esm/core/checks/languages/ruff-shared.js +6 -1
  36. package/dist/esm/core/checks/languages/typecheck.js +1 -1
  37. package/dist/esm/core/checks/pr/commitlint.js +4 -2
  38. package/dist/esm/core/checks/pr/pr-body.js +1 -1
  39. package/dist/esm/core/checks/pr/pr-size-check.js +1 -1
  40. package/dist/esm/core/checks/pr/pr-title-lint.js +1 -1
  41. package/dist/esm/core/checks/quality/dead-code.js +1 -1
  42. package/dist/esm/core/checks/quality/duplicate-check.js +1 -1
  43. package/dist/esm/core/checks/quality/markdown-lint.js +1 -1
  44. package/dist/esm/core/checks/quality/package-size.d.ts +23 -1
  45. package/dist/esm/core/checks/quality/package-size.js +19 -4
  46. package/dist/esm/core/checks/quality/spellcheck.js +11 -8
  47. package/dist/esm/core/checks/security/diff-security.js +1 -1
  48. package/dist/esm/core/checks/security/security.js +1 -1
  49. package/dist/esm/core/install/doctor.js +12 -0
  50. package/dist/esm/core/install/grype-db.d.ts +25 -0
  51. package/dist/esm/core/install/grype-db.js +41 -0
  52. package/dist/esm/core/install/prepare.js +1 -1
  53. package/dist/esm/core/install/profiles.d.ts +1 -1
  54. package/dist/esm/core/install/profiles.js +1 -1
  55. package/dist/esm/core/install/reuse.js +6 -3
  56. package/dist/esm/core/run-checks.js +2 -2
  57. package/dist/esm/init.d.ts +0 -8
  58. package/dist/esm/init.js +4 -4
  59. package/dist/esm/utils/own-deps.js +8 -1
  60. package/dist/esm/utils/resolve-bin.d.ts +2 -0
  61. package/dist/esm/utils/resolve-bin.js +4 -0
  62. package/eslint-default-rules.json +2 -8
  63. package/eslint.config.mjs +37 -8
  64. package/install-manifests/commitlint/package-lock.json +5 -4
  65. package/install-manifests/core/package-lock.json +5 -3
  66. package/install-manifests/js/package-lock.json +250 -571
  67. package/install-manifests/js/package.json +10 -8
  68. package/install-manifests/markdown/package-lock.json +16 -13
  69. package/install-manifests/markdown/package.json +3 -0
  70. package/install-manifests/spellcheck/package-lock.json +3 -3
  71. package/package.json +19 -15
  72. package/src/config/.eslintrc.js +3 -9
  73. package/src/config/eslint-default-rules.json +2 -8
package/.eslintrc.js CHANGED
@@ -41,7 +41,7 @@ module.exports = {
41
41
  sourceType: 'module',
42
42
  project: './tsconfig.json',
43
43
  },
44
- plugins: ['@typescript-eslint', 'prettier', 'import-x', 'jsdoc'],
44
+ plugins: ['@typescript-eslint', 'prettier', 'simple-import-sort', 'jsdoc'],
45
45
  extends: [
46
46
  'eslint:recommended',
47
47
  'plugin:@typescript-eslint/recommended',
@@ -85,14 +85,8 @@ module.exports = {
85
85
  // Complexity analysis (#61)
86
86
  complexity: ['warn', 30],
87
87
  // Import ordering (#65)
88
- 'import-x/order': [
89
- 'warn',
90
- {
91
- groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
92
- alphabetize: { order: 'asc', caseInsensitive: true },
93
- },
94
- ],
95
- 'import-x/no-duplicates': 'error',
88
+ 'simple-import-sort/imports': 'warn',
89
+ 'no-duplicate-imports': ['error', { allowSeparateTypeImports: true }],
96
90
  // Documentation (#62). `jsdoc/require-jsdoc` is deliberately absent — see the
97
91
  // note in eslint-flat-config.mjs. These three check docblocks that exist and
98
92
  // fix only inside them; they never fabricate one.
package/README.md CHANGED
@@ -72,7 +72,7 @@ tool installed; the check skips gracefully when the tool is absent.
72
72
  | Language | Checks | Toolchain |
73
73
  |---|---|---|
74
74
  | TypeScript / JavaScript | ESLint, Prettier, `tsc --noEmit` | Bundled |
75
- | Python | Ruff (lint + format), mypy/pyright (types) | Bundled — Ruff, and pyright for types; the runner's own mypy is preferred when it has one |
75
+ | Python | Ruff (lint + format), mypy/pyright (types) | Fetched on first use — Ruff, and pyright for types; the runner's own mypy is preferred when it has one |
76
76
  | C++ | clang-format | Bundled |
77
77
  | Swift | SwiftLint | `swiftlint` on the runner |
78
78
  | Kotlin / Java | ktlint | `ktlint` on the runner |
package/dist/cjs/cli.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
+ const loader_js_1 = require("./config/loader.js");
4
5
  const commands_js_1 = require("./core/commands.js");
5
6
  const index_js_1 = require("./core/index.js");
6
7
  const doctor_js_1 = require("./core/install/doctor.js");
8
+ const grype_db_js_1 = require("./core/install/grype-db.js");
7
9
  const init_js_1 = require("./init.js");
8
10
  const index_js_2 = require("./utils/index.js");
9
11
  const argv = process.argv.slice(2);
@@ -90,6 +92,17 @@ void (async () => {
90
92
  for (const result of failed) {
91
93
  index_js_2.logger.error(`[install]: ❌ ${result.profiles.join(', ') || 'tooling'}: ${result.reason}`);
92
94
  }
95
+ // Warm grype's vulnerability database too, with no time cap: the download is
96
+ // hundreds of MB and the scan itself would only be allowed a couple of minutes.
97
+ // Best-effort — an absent grype or a failed download is a warning, not a
98
+ // failed install, since Grype Scan skips cleanly without it.
99
+ if ((0, loader_js_1.loadProjectConfig)().grypeScan?.enabled !== false) {
100
+ const why = await (0, grype_db_js_1.ensureGrypeDb)(process.cwd());
101
+ if (why && why !== 'ENOENT')
102
+ index_js_2.logger.warn(`[install]: ⚠️ grype: ${why}`);
103
+ else if (!why)
104
+ index_js_2.logger.info('[install]: grype vulnerability database is up to date');
105
+ }
93
106
  process.exit(failed.length > 0 ? 1 : 0);
94
107
  }
95
108
  if (cmd === 'doctor') {
@@ -318,7 +318,7 @@
318
318
  "//ignores": "APPENDED to the built-in list: node_modules, dist, coverage, graphify-out, .claude and CHANGELOG.md.",
319
319
  "ignores": []
320
320
  },
321
- "//7b-install": "WHICH TOOLING IS FETCHED. The package ships ~233 MB and no repository needs all of it. \"adaptive\" (default) fetches only what this repository turns out to need; \"eager\" fetches everything up front, which is what a closed network or a prebuilt image wants and is also the exact previous behaviour; \"manual\" fetches nothing because the tools are already on the runner. A missing tool never fails silently: the affected checks skip with a reason in the report, or fail the run outright with onMissingTool: \"error\".",
321
+ "//7b-install": "WHICH TOOLING IS FETCHED. The tooling totals ~233 MB and no repository needs all of it. \"adaptive\" (default) fetches only what this repository turns out to need; \"eager\" fetches everything up front, which is what a closed network or a prebuilt image wants and is also the exact previous behaviour; \"manual\" fetches nothing because the tools are already on the runner. A missing tool never fails silently: the affected checks skip with a reason in the report, or fail the run outright with onMissingTool: \"error\".",
322
322
  "install": {
323
323
  "strategy": "adaptive",
324
324
  "//profiles": "Extra profiles beyond what detection concluded, for generated code or a monorepo layout detection cannot see through. Names: core, js, python, cpp, spellcheck, markdown, commitlint, duplicate.",
@@ -4,6 +4,7 @@ exports.grypeScanCheck = void 0;
4
4
  exports.resolveGrypeScan = resolveGrypeScan;
5
5
  const execa_js_1 = require("../../../utils/execa.js");
6
6
  const index_js_1 = require("../../../utils/index.js");
7
+ const grype_db_js_1 = require("../../install/grype-db.js");
7
8
  const types_js_1 = require("../../types.js");
8
9
  const tool_install_js_1 = require("../tool-install.js");
9
10
  const SEVERITY_ORDER = ['negligible', 'low', 'medium', 'high', 'critical'];
@@ -21,6 +22,14 @@ const TYPE_TO_ECOSYSTEM = {
21
22
  * looks like a hung commit — an advisory check must never be able to do that.
22
23
  */
23
24
  const DEFAULT_TIMEOUT_MS = 120000;
25
+ /**
26
+ * Cap for refreshing grype's vulnerability database. A stale database (grype
27
+ * refuses one older than 5 days) is re-downloaded, hundreds of MB, which takes
28
+ * longer than the scan's own cap. Kept below the per-check timeout so the check
29
+ * itself reports the outcome instead of being cut off by the runner. `npx
30
+ * pr-checkmate install` refreshes it without any cap, ahead of time.
31
+ */
32
+ const DB_UPDATE_TIMEOUT_MS = 240000;
24
33
  function severityRank(severity) {
25
34
  return SEVERITY_ORDER.indexOf((severity ?? '').toLowerCase());
26
35
  }
@@ -76,10 +85,19 @@ exports.grypeScanCheck = {
76
85
  // third-party code is not something the client could fix anyway (see below).
77
86
  const excludes = (ctx.config.ignoreDirs ?? []).flatMap(dir => ['--exclude', `**/${dir}/**`]);
78
87
  const timeout = settings.timeoutMs ?? DEFAULT_TIMEOUT_MS;
88
+ const unusable = await (0, grype_db_js_1.ensureGrypeDb)(ctx.cwd, DB_UPDATE_TIMEOUT_MS);
89
+ if (unusable === 'ENOENT')
90
+ return (0, tool_install_js_1.notInstalled)('grype');
91
+ if (unusable) {
92
+ index_js_1.logger.warn(`[Grype Scan]: ⚠️ ${unusable} — skipping`);
93
+ return (0, types_js_1.skip)(unusable);
94
+ }
95
+ // The database was just checked, so the scan must not try to fetch one itself.
79
96
  const result = await (0, execa_js_1.execa)('grype', ['dir:.', '-o', 'json', '-q', ...excludes], {
80
97
  cwd: ctx.cwd,
81
98
  reject: false,
82
99
  timeout,
100
+ env: { GRYPE_DB_AUTO_UPDATE: 'false' },
83
101
  });
84
102
  // execa v9 with reject:false resolves (not throws) with `code: 'ENOENT'`
85
103
  // when the binary is absent — detect the missing scanner on the result.
@@ -1,6 +1,10 @@
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.lintCheck = void 0;
7
+ const node_path_1 = __importDefault(require("node:path"));
4
8
  const index_js_1 = require("../../../config/index.js");
5
9
  const execa_js_1 = require("../../../utils/execa.js");
6
10
  const index_js_2 = require("../../../utils/index.js");
@@ -76,10 +80,18 @@ exports.lintCheck = {
76
80
  index_js_2.logger.info(`[ESLint]: Using pr-checkmate config (ESLint v${version}, ${configFile})`);
77
81
  }
78
82
  index_js_1.IGNORE_PATTERNS.forEach(p => args.push('--ignore-pattern', p));
83
+ // Our bundled config loads its plugins through these roots, and an ESLint
84
+ // older than 9 resolves legacy-config plugins relative to the path below — in
85
+ // both cases the plugins are in a fetched profile rather than beside us.
86
+ const toolRoots = (0, index_js_2.registeredToolPaths)();
87
+ if (!hasLocal && version < 9 && toolRoots.length > 0) {
88
+ args.push('--resolve-plugins-relative-to', toolRoots[0]);
89
+ }
79
90
  const result = await (0, execa_js_1.execa)(process.execPath, [eslintBin, ...args], {
80
91
  cwd: ctx.cwd,
81
92
  stdio: 'inherit',
82
93
  reject: false,
94
+ env: { PR_CHECKMATE_TOOL_ROOTS: toolRoots.join(node_path_1.default.delimiter) },
83
95
  });
84
96
  if (result.exitCode === 0)
85
97
  return (0, types_js_1.pass)();
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PY_GLOBS = void 0;
4
4
  exports.createRuffWorkspace = createRuffWorkspace;
5
+ const index_js_1 = require("../../../utils/index.js");
5
6
  exports.PY_GLOBS = ['*.py'];
6
7
  /**
7
8
  * The rules we lint Python with — pinned here rather than left to Ruff's defaults.
@@ -28,7 +29,11 @@ const SELECTED_RULES = ['E4', 'E7', 'E9', 'F'];
28
29
  * singleton) so the WASM module loads only when a check runs.
29
30
  */
30
31
  function createRuffWorkspace() {
31
- const { Workspace, PositionEncoding } = require('@astral-sh/ruff-wasm-nodejs');
32
+ // Resolved rather than required by name: the package lives in the `python`
33
+ // profile, which is fetched into the cache directory when this repository
34
+ // needs it, and a bare specifier only searches our own tree.
35
+ const entry = (0, index_js_1.resolvePackageBin)('@astral-sh/ruff-wasm-nodejs', 'ruff_wasm.js');
36
+ const { Workspace, PositionEncoding } = require(entry);
32
37
  const settings = {
33
38
  ...Workspace.defaultSettings(),
34
39
  lint: { select: SELECTED_RULES },
@@ -40,8 +40,10 @@ const DEFAULT_IGNORES = ['^Release\\b', '^chore\\(release\\):'];
40
40
  function buildTempConfig(types, ignores) {
41
41
  // Emit a CJS module so `ignores` can hold functions — commitlint requires
42
42
  // predicate functions there, which a JSON config cannot express.
43
- // Resolve config-conventional via Node module resolution — robust to hoisting.
44
- const configConventional = require.resolve('@commitlint/config-conventional');
43
+ // Resolved through the same roots as the binary, not by bare specifier: both
44
+ // live in the `commitlint` profile, which is fetched into the cache directory,
45
+ // and `require.resolve` alone only searches our own tree.
46
+ const configConventional = (0, index_js_2.resolvePackageBin)('@commitlint/config-conventional', 'lib/index.js');
45
47
  const content = `const patterns = ${JSON.stringify(ignores)}.map((p) => new RegExp(p, 'i'));
46
48
  module.exports = {
47
49
  extends: [${JSON.stringify(configConventional)}],
@@ -1,6 +1,27 @@
1
1
  import { Check } from '../../types.js';
2
+ /** The slice of `npm pack --dry-run --json` this check reads. */
3
+ interface PackedFile {
4
+ path: string;
5
+ size: number;
6
+ }
7
+ interface PackManifest {
8
+ name: string;
9
+ version: string;
10
+ size: number;
11
+ unpackedSize: number;
12
+ entryCount: number;
13
+ files: PackedFile[];
14
+ }
15
+ /**
16
+ * The manifest out of whatever `npm pack --json` printed.
17
+ *
18
+ * npm 10 prints `[ {manifest} ]`; npm 11 prints `{ "<name>": {manifest} }`. Reading
19
+ * only the first form turned every npm 11 runner into "could not describe this
20
+ * package" and the check into a permanent skip.
21
+ */
22
+ export declare function pickManifest(parsed: unknown): PackManifest | null;
2
23
  /** Why we could not read a published version. */
3
- export type QueryFailure = 'absent' | 'unauthenticated' | 'unreadable';
24
+ type QueryFailure = 'absent' | 'unauthenticated' | 'unreadable';
4
25
  /**
5
26
  * Tell "never published" apart from "we were not let in".
6
27
  *
@@ -34,3 +55,4 @@ export declare function classifyQueryFailure(stderr: string): QueryFailure;
34
55
  * { "Package Size": "error" }` makes it a gate for a repository that wants one.
35
56
  */
36
57
  export declare const packageSizeCheck: Check;
58
+ export {};
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.packageSizeCheck = void 0;
7
+ exports.pickManifest = pickManifest;
7
8
  exports.classifyQueryFailure = classifyQueryFailure;
8
9
  const node_fs_1 = __importDefault(require("node:fs"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
@@ -15,6 +16,22 @@ const findings_js_1 = require("../findings.js");
15
16
  const DEFAULT_MAX_GROWTH = 20;
16
17
  /** How many of the files behind the growth are named. */
17
18
  const NAMED_FILES = 5;
19
+ /**
20
+ * The manifest out of whatever `npm pack --json` printed.
21
+ *
22
+ * npm 10 prints `[ {manifest} ]`; npm 11 prints `{ "<name>": {manifest} }`. Reading
23
+ * only the first form turned every npm 11 runner into "could not describe this
24
+ * package" and the check into a permanent skip.
25
+ */
26
+ function pickManifest(parsed) {
27
+ const entries = Array.isArray(parsed)
28
+ ? parsed
29
+ : parsed !== null && typeof parsed === 'object'
30
+ ? Object.values(parsed)
31
+ : [];
32
+ const first = entries[0];
33
+ return first !== null && typeof first === 'object' ? first : null;
34
+ }
18
35
  /**
19
36
  * Ask npm what a tarball would contain, without producing one.
20
37
  *
@@ -48,10 +65,9 @@ async function packManifest(cwd, spec, registry) {
48
65
  if (result.exitCode !== 0)
49
66
  return { failure: classifyQueryFailure(result.stderr ?? '') };
50
67
  try {
51
- const parsed = JSON.parse(result.stdout);
52
- if (Array.isArray(parsed) && parsed.length > 0) {
53
- return { manifest: parsed[0] };
54
- }
68
+ const manifest = pickManifest(JSON.parse(result.stdout));
69
+ if (manifest)
70
+ return { manifest };
55
71
  }
56
72
  catch {
57
73
  // Fall through: npm answered with something that is not a manifest.
@@ -204,12 +204,15 @@ function dropMissingLanguages(language, missing) {
204
204
  */
205
205
  function resolveFrom(cwd) {
206
206
  return id => {
207
- try {
208
- return require.resolve(id, { paths: [cwd] });
209
- }
210
- catch {
211
- return require.resolve(id);
207
+ // The client's copy first, then the fetched `spellcheck` profile (cspell's own
208
+ // dictionaries live there), then our tree.
209
+ for (const root of [cwd, ...(0, index_js_2.registeredToolPaths)()]) {
210
+ try {
211
+ return require.resolve(id, { paths: [root] });
212
+ }
213
+ catch { }
212
214
  }
215
+ return require.resolve(id);
213
216
  };
214
217
  }
215
218
  function createTempCspellConfig(cwd) {
@@ -16,6 +16,7 @@ const package_version_js_1 = require("../../utils/package-version.js");
16
16
  Object.defineProperty(exports, "packageVersion", { enumerable: true, get: function () { return package_version_js_1.packageVersion; } });
17
17
  const cache_js_1 = require("./cache.js");
18
18
  const ensure_js_1 = require("./ensure.js");
19
+ const grype_db_js_1 = require("./grype-db.js");
19
20
  const prepare_js_1 = require("./prepare.js");
20
21
  const profiles_js_1 = require("./profiles.js");
21
22
  /**
@@ -142,6 +143,17 @@ async function runDoctor(cwd = process.cwd()) {
142
143
  lines.push(` ${missing.length} profile(s) not installed yet. They are fetched on the next`);
143
144
  lines.push(' run, or now with: npx pr-checkmate install');
144
145
  }
146
+ if (config.grypeScan?.enabled !== false) {
147
+ const db = await (0, grype_db_js_1.grypeDbState)(cwd);
148
+ lines.push('');
149
+ lines.push(' Grype database (shared by all projects on this machine)');
150
+ if (db.state === 'missing-tool')
151
+ lines.push(' — grype is not installed (brew install grype)');
152
+ else if (db.state === 'ready')
153
+ lines.push(` ✅ up to date${db.built ? ` (built ${db.built})` : ''}`);
154
+ else
155
+ lines.push(` ⬇️ stale${db.built ? ` (built ${db.built})` : ''} — refresh: npx pr-checkmate install`);
156
+ }
145
157
  const stale = (0, cache_js_1.stalePaths)((0, package_version_js_1.packageVersion)(), config.install?.cacheDir);
146
158
  if (stale.length > 0) {
147
159
  lines.push('');
@@ -0,0 +1,25 @@
1
+ type GrypeDbState = {
2
+ state: 'missing-tool';
3
+ } | {
4
+ state: 'ready';
5
+ built?: string;
6
+ } | {
7
+ state: 'stale';
8
+ built?: string;
9
+ };
10
+ /**
11
+ * Is grype's vulnerability database usable right now? grype refuses one older than
12
+ * 5 days (exit code 1 from `db status`). The database lives in grype's own cache,
13
+ * shared by every project on the machine, so one refresh serves them all.
14
+ */
15
+ export declare function grypeDbState(cwd: string): Promise<GrypeDbState>;
16
+ /**
17
+ * Download a fresh database when the current one is missing or stale.
18
+ *
19
+ * `timeoutMs` bounds the download: a check inside a run passes one so it can never
20
+ * look like a hung commit, `install` passes none because a person asked for exactly
21
+ * this and the download is the whole job. Returns why it is still unusable, or
22
+ * `null` when it is ready.
23
+ */
24
+ export declare function ensureGrypeDb(cwd: string, timeoutMs?: number): Promise<string | null>;
25
+ export {};
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.grypeDbState = grypeDbState;
4
+ exports.ensureGrypeDb = ensureGrypeDb;
5
+ const execa_js_1 = require("../../utils/execa.js");
6
+ const logger_js_1 = require("../../utils/logger.js");
7
+ /** `Built:` line of `grype db status`, so `doctor` can say how old the data is. */
8
+ const builtOf = (stdout) => typeof stdout === 'string' ? /^Built:\s*(.+)$/m.exec(stdout)?.[1]?.trim() : undefined;
9
+ /**
10
+ * Is grype's vulnerability database usable right now? grype refuses one older than
11
+ * 5 days (exit code 1 from `db status`). The database lives in grype's own cache,
12
+ * shared by every project on the machine, so one refresh serves them all.
13
+ */
14
+ async function grypeDbState(cwd) {
15
+ const status = await (0, execa_js_1.execa)('grype', ['db', 'status'], { cwd, reject: false, timeout: 30000 });
16
+ if (status.code === 'ENOENT')
17
+ return { state: 'missing-tool' };
18
+ const built = builtOf(status.stdout);
19
+ return status.exitCode === 0 ? { state: 'ready', built } : { state: 'stale', built };
20
+ }
21
+ /**
22
+ * Download a fresh database when the current one is missing or stale.
23
+ *
24
+ * `timeoutMs` bounds the download: a check inside a run passes one so it can never
25
+ * look like a hung commit, `install` passes none because a person asked for exactly
26
+ * this and the download is the whole job. Returns why it is still unusable, or
27
+ * `null` when it is ready.
28
+ */
29
+ async function ensureGrypeDb(cwd, timeoutMs) {
30
+ const current = await grypeDbState(cwd);
31
+ if (current.state === 'missing-tool')
32
+ return 'ENOENT';
33
+ if (current.state === 'ready')
34
+ return null;
35
+ logger_js_1.logger.info('[Grype Scan]: vulnerability database missing or stale — updating it');
36
+ const update = await (0, execa_js_1.execa)('grype', ['db', 'update'], {
37
+ cwd,
38
+ reject: false,
39
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
40
+ });
41
+ if (update.timedOut) {
42
+ return `database update exceeded ${timeoutMs}ms — run "grype db update" once, then retry`;
43
+ }
44
+ return update.exitCode === 0 ? null : 'database update failed (offline?) — run "grype db update"';
45
+ }
@@ -3,7 +3,7 @@ import type { Language, LanguageUsage } from '../../utils/detect-language.js';
3
3
  /**
4
4
  * A named bundle of tooling, and the checks that stop working without it.
5
5
  *
6
- * The package ships ~233 MB of tooling, and no repository needs all of it: a
6
+ * The tooling totals ~233 MB, and no repository needs all of it: a
7
7
  * Python service pays for the whole JavaScript toolchain, a repository with
8
8
  * Spellcheck switched off still pays for cspell. Profiles are how that stops —
9
9
  * they name which slice of the tooling a given repository actually needs.
@@ -25,9 +25,9 @@ exports.PROFILES = [
25
25
  'typescript',
26
26
  'eslint',
27
27
  'eslint-config-prettier',
28
- 'eslint-plugin-import-x',
29
28
  'eslint-plugin-jsdoc',
30
29
  'eslint-plugin-prettier',
30
+ 'eslint-plugin-simple-import-sort',
31
31
  'eslint-plugin-sonarjs',
32
32
  '@typescript-eslint/eslint-plugin',
33
33
  '@typescript-eslint/parser',
@@ -12,15 +12,18 @@ const resolve_bin_js_1 = require("../../utils/resolve-bin.js");
12
12
  /**
13
13
  * The major version we declare for a tool, from our own manifest.
14
14
  *
15
- * Read from `dependencies` rather than from the installed tree: what matters is
15
+ * Read from the manifest rather than from the installed tree: what matters is
16
16
  * the version our configuration was written against, and a hoisted install can
17
17
  * resolve to something else entirely.
18
18
  */
19
19
  function ownMajor(pkgName) {
20
20
  try {
21
21
  const manifest = node_path_1.default.join((0, resolve_bin_js_1.ownPackageRoot)(), 'package.json');
22
- const deps = JSON.parse(node_fs_1.default.readFileSync(manifest, 'utf-8')).dependencies;
23
- const range = deps?.[pkgName];
22
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(manifest, 'utf-8'));
23
+ // Profile tooling is declared in `devDependencies` — it is fetched on demand
24
+ // rather than installed with us — but it is still the version our config was
25
+ // written against, and the manifest keeps both.
26
+ const range = parsed.dependencies?.[pkgName] ?? parsed.devDependencies?.[pkgName];
24
27
  if (!range)
25
28
  return null;
26
29
  // `^10.6.0`, `>=3.0.0`, `~5.1` — the leading number is the major in every
@@ -30,14 +30,6 @@ export declare function setupWorkflow(root: string, mode: GateMode): void;
30
30
  * heart about what should block.
31
31
  */
32
32
  export declare const BLOCKING_BY_DEFAULT: readonly ["Security Scan", "Sensitive Files", "Diff Security", "Workflow Security", "Merge Conflict", "NPM Audit", "License Check", "Dependencies", "TypeScript", "ESLint", "YAML Lint"];
33
- /**
34
- * Every option the template documents that the file does not already carry, at
35
- * its default. Without this, options such as `vulnScan` (off by default) or
36
- * `licenseCheck` exist only in the docs, and nobody edits what they cannot see.
37
- * Comment keys travel with the option they describe, section headers with the
38
- * first option after them.
39
- */
40
- export declare function templateExtras(existing: Record<string, unknown>): Record<string, unknown>;
41
33
  export declare function buildInitialConfig(sourcePath: string, languages: string[]): Record<string, unknown>;
42
34
  /**
43
35
  * Initialize (or refresh) pr-checkmate configuration in the current project.
package/dist/cjs/init.js CHANGED
@@ -5,12 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.BLOCKING_BY_DEFAULT = void 0;
7
7
  exports.setupWorkflow = setupWorkflow;
8
- exports.templateExtras = templateExtras;
9
8
  exports.buildInitialConfig = buildInitialConfig;
10
9
  exports.init = init;
11
10
  const fs_1 = __importDefault(require("fs"));
12
- const path_1 = __importDefault(require("path"));
13
11
  const inquirer_1 = __importDefault(require("inquirer"));
12
+ const path_1 = __importDefault(require("path"));
14
13
  const index_js_1 = require("./config/index.js");
15
14
  const findings_js_1 = require("./core/checks/findings.js");
16
15
  const index_js_2 = require("./utils/index.js");
@@ -7,6 +7,7 @@ exports.findOwnPackageRoot = findOwnPackageRoot;
7
7
  exports.getOwnDependencyClosure = getOwnDependencyClosure;
8
8
  const node_fs_1 = __importDefault(require("node:fs"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
+ const profiles_js_1 = require("../core/install/profiles.js");
10
11
  const OWN_PACKAGE_NAME = 'pr-checkmate';
11
12
  const PACKAGE_JSON = 'package.json';
12
13
  let cachedClosure;
@@ -89,7 +90,13 @@ function getOwnDependencyClosure() {
89
90
  }
90
91
  // BFS over the dependency graph, resolving each package relative to the parent
91
92
  // that declared it so nested (non-hoisted) installs resolve correctly.
92
- const queue = readDependencyNames(root).map(name => ({
93
+ //
94
+ // Profile tooling is ours even though it no longer sits in `dependencies`: it is
95
+ // fetched on demand, so the roots are what we declare plus what the profiles
96
+ // name. Without the second half every fetched tool is "the client's" and its
97
+ // licence blocks the client for a dependency they cannot remove.
98
+ const roots = new Set([...readDependencyNames(root), ...profiles_js_1.PROFILES.flatMap(p => p.packages)]);
99
+ const queue = [...roots].map(name => ({
93
100
  name,
94
101
  fromDir: root,
95
102
  }));
@@ -16,6 +16,8 @@ export interface ResolvedTool {
16
16
  * to a run from before there was anything to fetch.
17
17
  */
18
18
  export declare function registerToolPaths(dirs: string[]): void;
19
+ /** The roots registered for this run, for tools that load plugins by themselves. */
20
+ export declare function registeredToolPaths(): string[];
19
21
  /** Drop every registered profile root. For tests, and for a second run in-process. */
20
22
  export declare function clearToolPaths(): void;
21
23
  /** The root of the installed pr-checkmate package — where our own tooling lives. */
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.registerToolPaths = registerToolPaths;
7
+ exports.registeredToolPaths = registeredToolPaths;
7
8
  exports.clearToolPaths = clearToolPaths;
8
9
  exports.ownPackageRoot = ownPackageRoot;
9
10
  exports.packagePresent = packagePresent;
@@ -33,6 +34,10 @@ let profileRoots = [];
33
34
  function registerToolPaths(dirs) {
34
35
  profileRoots = [...dirs];
35
36
  }
37
+ /** The roots registered for this run, for tools that load plugins by themselves. */
38
+ function registeredToolPaths() {
39
+ return [...profileRoots];
40
+ }
36
41
  /** Drop every registered profile root. For tests, and for a second run in-process. */
37
42
  function clearToolPaths() {
38
43
  profileRoots = [];
package/dist/esm/cli.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ import { loadProjectConfig } from './config/loader.js';
2
3
  import { COMMAND_TO_CHECK, FLAGS, STANDALONE_COMMANDS, TOOLING_COMMANDS } from './core/commands.js';
3
4
  import { runChecks } from './core/index.js';
4
5
  import { installProfiles, pruneCache, runDoctor } from './core/install/doctor.js';
6
+ import { ensureGrypeDb } from './core/install/grype-db.js';
5
7
  import { init } from './init.js';
6
- import { logger, getUpdateNotice } from './utils/index.js';
8
+ import { getUpdateNotice, logger } from './utils/index.js';
7
9
  const argv = process.argv.slice(2);
8
10
  /**
9
11
  * Flags are separated from positional arguments so they compose with every command
@@ -88,6 +90,17 @@ void (async () => {
88
90
  for (const result of failed) {
89
91
  logger.error(`[install]: ❌ ${result.profiles.join(', ') || 'tooling'}: ${result.reason}`);
90
92
  }
93
+ // Warm grype's vulnerability database too, with no time cap: the download is
94
+ // hundreds of MB and the scan itself would only be allowed a couple of minutes.
95
+ // Best-effort — an absent grype or a failed download is a warning, not a
96
+ // failed install, since Grype Scan skips cleanly without it.
97
+ if (loadProjectConfig().grypeScan?.enabled !== false) {
98
+ const why = await ensureGrypeDb(process.cwd());
99
+ if (why && why !== 'ENOENT')
100
+ logger.warn(`[install]: ⚠️ grype: ${why}`);
101
+ else if (!why)
102
+ logger.info('[install]: grype vulnerability database is up to date');
103
+ }
91
104
  process.exit(failed.length > 0 ? 1 : 0);
92
105
  }
93
106
  if (cmd === 'doctor') {
@@ -318,7 +318,7 @@
318
318
  "//ignores": "APPENDED to the built-in list: node_modules, dist, coverage, graphify-out, .claude and CHANGELOG.md.",
319
319
  "ignores": []
320
320
  },
321
- "//7b-install": "WHICH TOOLING IS FETCHED. The package ships ~233 MB and no repository needs all of it. \"adaptive\" (default) fetches only what this repository turns out to need; \"eager\" fetches everything up front, which is what a closed network or a prebuilt image wants and is also the exact previous behaviour; \"manual\" fetches nothing because the tools are already on the runner. A missing tool never fails silently: the affected checks skip with a reason in the report, or fail the run outright with onMissingTool: \"error\".",
321
+ "//7b-install": "WHICH TOOLING IS FETCHED. The tooling totals ~233 MB and no repository needs all of it. \"adaptive\" (default) fetches only what this repository turns out to need; \"eager\" fetches everything up front, which is what a closed network or a prebuilt image wants and is also the exact previous behaviour; \"manual\" fetches nothing because the tools are already on the runner. A missing tool never fails silently: the affected checks skip with a reason in the report, or fail the run outright with onMissingTool: \"error\".",
322
322
  "install": {
323
323
  "strategy": "adaptive",
324
324
  "//profiles": "Extra profiles beyond what detection concluded, for generated code or a monorepo layout detection cannot see through. Names: core, js, python, cpp, spellcheck, markdown, commitlint, duplicate.",
@@ -2,8 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { hasLocalConfig } from '../../../config/index.js';
4
4
  import { execa } from '../../../utils/execa.js';
5
- import { logger, cleanupTempFile, resolvePackageBin } from '../../../utils/index.js';
6
- import { isNpmProject, pass, fail, warn, skip } from '../../types.js';
5
+ import { cleanupTempFile, logger, resolvePackageBin } from '../../../utils/index.js';
6
+ import { fail, isNpmProject, pass, skip, warn } from '../../types.js';
7
7
  /**
8
8
  * Config files knip discovers on its own. When a client ships one, it is the source
9
9
  * of truth and pr-checkmate passes no config of its own — the same rule spellcheck
@@ -1,5 +1,6 @@
1
1
  import { execa } from '../../../utils/execa.js';
2
2
  import { collectDirectDeps, isCiEnvironment, logger, normalizeName, } from '../../../utils/index.js';
3
+ import { ensureGrypeDb } from '../../install/grype-db.js';
3
4
  import { pass, skip, warn } from '../../types.js';
4
5
  import { notInstalled } from '../tool-install.js';
5
6
  const SEVERITY_ORDER = ['negligible', 'low', 'medium', 'high', 'critical'];
@@ -17,6 +18,14 @@ const TYPE_TO_ECOSYSTEM = {
17
18
  * looks like a hung commit — an advisory check must never be able to do that.
18
19
  */
19
20
  const DEFAULT_TIMEOUT_MS = 120000;
21
+ /**
22
+ * Cap for refreshing grype's vulnerability database. A stale database (grype
23
+ * refuses one older than 5 days) is re-downloaded, hundreds of MB, which takes
24
+ * longer than the scan's own cap. Kept below the per-check timeout so the check
25
+ * itself reports the outcome instead of being cut off by the runner. `npx
26
+ * pr-checkmate install` refreshes it without any cap, ahead of time.
27
+ */
28
+ const DB_UPDATE_TIMEOUT_MS = 240000;
20
29
  function severityRank(severity) {
21
30
  return SEVERITY_ORDER.indexOf((severity ?? '').toLowerCase());
22
31
  }
@@ -72,10 +81,19 @@ export const grypeScanCheck = {
72
81
  // third-party code is not something the client could fix anyway (see below).
73
82
  const excludes = (ctx.config.ignoreDirs ?? []).flatMap(dir => ['--exclude', `**/${dir}/**`]);
74
83
  const timeout = settings.timeoutMs ?? DEFAULT_TIMEOUT_MS;
84
+ const unusable = await ensureGrypeDb(ctx.cwd, DB_UPDATE_TIMEOUT_MS);
85
+ if (unusable === 'ENOENT')
86
+ return notInstalled('grype');
87
+ if (unusable) {
88
+ logger.warn(`[Grype Scan]: ⚠️ ${unusable} — skipping`);
89
+ return skip(unusable);
90
+ }
91
+ // The database was just checked, so the scan must not try to fetch one itself.
75
92
  const result = await execa('grype', ['dir:.', '-o', 'json', '-q', ...excludes], {
76
93
  cwd: ctx.cwd,
77
94
  reject: false,
78
95
  timeout,
96
+ env: { GRYPE_DB_AUTO_UPDATE: 'false' },
79
97
  });
80
98
  // execa v9 with reject:false resolves (not throws) with `code: 'ENOENT'`
81
99
  // when the binary is absent — detect the missing scanner on the result.
@@ -1,6 +1,6 @@
1
1
  import { execa } from '../../../utils/execa.js';
2
- import { logger, resolvePackageBin, getOwnDependencyClosure } from '../../../utils/index.js';
3
- import { isNpmProject, pass, fail, skip } from '../../types.js';
2
+ import { getOwnDependencyClosure, logger, resolvePackageBin } from '../../../utils/index.js';
3
+ import { fail, isNpmProject, pass, skip } from '../../types.js';
4
4
  const FORBIDDEN_LICENSES = ['GPL-2.0', 'GPL-3.0', 'AGPL-3.0', 'LGPL-2.0', 'LGPL-2.1', 'LGPL-3.0'];
5
5
  /** Strip the trailing "@version" to get the bare package name (scoped-safe). */
6
6
  function bareName(pkgKey) {