pr-checkmate 2.1.0 → 3.0.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 (109) hide show
  1. package/README.md +63 -267
  2. package/cspell.json +155 -133
  3. package/dist/config/constants.d.ts +13 -1
  4. package/dist/config/constants.js +20 -7
  5. package/dist/config/index.d.ts +23 -11
  6. package/dist/config/index.js +27 -33
  7. package/dist/core/checks/dependencies/banned-imports.js +4 -1
  8. package/dist/core/checks/dependencies/circular-deps.js +14 -5
  9. package/dist/core/checks/dependencies/dependency-check.js +2 -2
  10. package/dist/core/checks/dependencies/grype-scan.js +2 -1
  11. package/dist/core/checks/dependencies/license-check.js +1 -6
  12. package/dist/core/checks/dependencies/npm-audit.js +1 -1
  13. package/dist/core/checks/dependencies/outdated-deps.js +1 -6
  14. package/dist/core/checks/dependencies/vuln-scan.js +2 -1
  15. package/dist/core/checks/findings.d.ts +46 -2
  16. package/dist/core/checks/findings.js +140 -2
  17. package/dist/core/checks/git/custom-rules.js +2 -0
  18. package/dist/core/checks/git/diff-smell.js +6 -1
  19. package/dist/core/checks/git/leftover-debug.js +8 -2
  20. package/dist/core/checks/git/lockfile-drift.js +6 -1
  21. package/dist/core/checks/git/merge-conflict.js +1 -1
  22. package/dist/core/checks/git/missing-tests.js +1 -1
  23. package/dist/core/checks/git/todo-fixme.js +10 -2
  24. package/dist/core/checks/languages/cpp-format.js +19 -42
  25. package/dist/core/checks/languages/external-checks.d.ts +77 -0
  26. package/dist/core/checks/languages/external-checks.js +200 -0
  27. package/dist/core/checks/languages/external-runner.d.ts +119 -0
  28. package/dist/core/checks/languages/external-runner.js +143 -0
  29. package/dist/core/checks/languages/go-format.d.ts +4 -4
  30. package/dist/core/checks/languages/go-format.js +151 -30
  31. package/dist/core/checks/languages/lint.js +19 -19
  32. package/dist/core/checks/languages/prettier.js +2 -7
  33. package/dist/core/checks/languages/python-typecheck.d.ts +10 -5
  34. package/dist/core/checks/languages/python-typecheck.js +161 -37
  35. package/dist/core/checks/languages/typecheck.js +10 -8
  36. package/dist/core/checks/pr/commitlint.js +7 -1
  37. package/dist/core/checks/quality/case-collision.js +6 -4
  38. package/dist/core/checks/quality/config-validation.d.ts +0 -7
  39. package/dist/core/checks/quality/config-validation.js +30 -0
  40. package/dist/core/checks/quality/duplicate-check.js +86 -28
  41. package/dist/core/checks/quality/license-header.js +7 -4
  42. package/dist/core/checks/quality/markdown-lint.js +1 -6
  43. package/dist/core/checks/quality/spellcheck.js +34 -5
  44. package/dist/core/checks/quality/symlinks.js +6 -4
  45. package/dist/core/checks/security/diff-security.js +26 -18
  46. package/dist/core/checks/security/dockerfile-security.js +7 -1
  47. package/dist/core/checks/security/migration-safety.js +32 -4
  48. package/dist/core/checks/security/security.d.ts +56 -0
  49. package/dist/core/checks/security/security.js +135 -11
  50. package/dist/core/checks/security/sensitive-file-guard.js +40 -6
  51. package/dist/core/checks/security/workflow-security.js +7 -1
  52. package/dist/core/checks/tool-install.d.ts +102 -0
  53. package/dist/core/checks/tool-install.js +69 -0
  54. package/dist/core/context.js +9 -1
  55. package/dist/core/install/cache.js +42 -11
  56. package/dist/core/install/doctor.d.ts +2 -2
  57. package/dist/core/install/doctor.js +17 -16
  58. package/dist/core/install/ensure.js +22 -3
  59. package/dist/core/install/lock.d.ts +2 -1
  60. package/dist/core/install/prepare.d.ts +75 -0
  61. package/dist/core/install/prepare.js +151 -0
  62. package/dist/core/install/profiles.d.ts +17 -3
  63. package/dist/core/install/profiles.js +27 -0
  64. package/dist/core/install/reuse.d.ts +41 -0
  65. package/dist/core/install/reuse.js +107 -0
  66. package/dist/core/registry.js +10 -18
  67. package/dist/core/reporters.js +8 -2
  68. package/dist/core/run-checks.js +74 -3
  69. package/dist/core/targets.d.ts +116 -5
  70. package/dist/core/targets.js +101 -16
  71. package/dist/core/types.d.ts +26 -2
  72. package/dist/core/types.js +28 -0
  73. package/dist/init.js +21 -1
  74. package/dist/interfaces/index.d.ts +1 -0
  75. package/dist/utils/detect-language.js +11 -1
  76. package/dist/utils/index.d.ts +1 -0
  77. package/dist/utils/index.js +1 -0
  78. package/dist/utils/package-version.d.ts +4 -0
  79. package/dist/utils/package-version.js +41 -0
  80. package/dist/utils/resolve-bin.d.ts +56 -6
  81. package/dist/utils/resolve-bin.js +136 -26
  82. package/dist/utils/update-notifier.js +5 -7
  83. package/gitleaks.toml +14 -0
  84. package/install-manifests/go/package-lock.json +24 -0
  85. package/install-manifests/go/package.json +8 -0
  86. package/install-manifests/pytypes/package-lock.json +45 -0
  87. package/install-manifests/pytypes/package.json +8 -0
  88. package/package.json +4 -2
  89. package/pr-checkmate.schema.json +8 -2
  90. package/src/config/cspell.json +155 -133
  91. package/src/config/pr-checkmate.schema.json +8 -2
  92. package/dist/core/checks/languages/csharp-format.d.ts +0 -13
  93. package/dist/core/checks/languages/csharp-format.js +0 -52
  94. package/dist/core/checks/languages/go-lint.d.ts +0 -8
  95. package/dist/core/checks/languages/go-lint.js +0 -48
  96. package/dist/core/checks/languages/kotlin-lint.d.ts +0 -2
  97. package/dist/core/checks/languages/kotlin-lint.js +0 -42
  98. package/dist/core/checks/languages/php-format.d.ts +0 -9
  99. package/dist/core/checks/languages/php-format.js +0 -49
  100. package/dist/core/checks/languages/ruby-lint.d.ts +0 -8
  101. package/dist/core/checks/languages/ruby-lint.js +0 -50
  102. package/dist/core/checks/languages/rust-format.d.ts +0 -9
  103. package/dist/core/checks/languages/rust-format.js +0 -45
  104. package/dist/core/checks/languages/rust-lint.d.ts +0 -8
  105. package/dist/core/checks/languages/rust-lint.js +0 -50
  106. package/dist/core/checks/languages/shellcheck.d.ts +0 -8
  107. package/dist/core/checks/languages/shellcheck.js +0 -55
  108. package/dist/core/checks/languages/swift-lint.d.ts +0 -2
  109. package/dist/core/checks/languages/swift-lint.js +0 -46
@@ -1,10 +1,107 @@
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.securityCheck = void 0;
7
+ exports.countFindings = countFindings;
8
+ exports.secretsSummary = secretsSummary;
9
+ exports.looksLikeCrash = looksLikeCrash;
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ const node_os_1 = __importDefault(require("node:os"));
12
+ const node_path_1 = __importDefault(require("node:path"));
4
13
  const config_1 = require("../../../config");
5
14
  const utils_1 = require("../../../utils");
6
15
  const execa_1 = require("../../../utils/execa");
7
16
  const types_1 = require("../../types");
17
+ /**
18
+ * A HOME the scanner can actually write to.
19
+ *
20
+ * The bundled wrapper caches the gitleaks binary in `os.homedir()/.gitleaks-cache`
21
+ * and offers no way to move it — the path is hardcoded. So in any environment
22
+ * where HOME is not writable it crashes before scanning a single commit. That is
23
+ * not exotic: it is what the container does under `--user "$(id -u):$(id -g)"`,
24
+ * the invocation our own entrypoint and docs recommend, where HOME is `/`.
25
+ *
26
+ * Redirecting to the temp directory costs nothing that was available anyway — if
27
+ * HOME cannot be written to, there was no `~/.gitconfig` of ours to lose either.
28
+ */
29
+ /**
30
+ * How many secrets the scan actually found.
31
+ *
32
+ * The row used to read `❌ Security Scan: potential secrets detected` whether the
33
+ * number was one or thirty-nine, and that is not a cosmetic gap. It is the reason
34
+ * a real regression nearly shipped: an allowlist entry removed six genuine
35
+ * `Generic API Key` findings from a client repository, 39 → 33, and the A/B sweep
36
+ * that compares report rows across 28 repositories saw two identical lines
37
+ * (ticket 085). A check that finds things has to say how many, or nothing can
38
+ * tell whether it started finding fewer.
39
+ *
40
+ * Read from gitleaks' JSON report rather than counted out of its console output —
41
+ * the same lesson as ticket 078, where the clone count was parsed from an English
42
+ * sentence and would have reported zero on a repository full of duplication.
43
+ *
44
+ * @returns The count, or `null` when the report could not be read — in which case
45
+ * the caller keeps its old wording rather than claiming a number it does not have.
46
+ */
47
+ function countFindings(reportPath) {
48
+ try {
49
+ const report = JSON.parse(node_fs_1.default.readFileSync(reportPath, 'utf-8'));
50
+ return Array.isArray(report) ? report.length : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /**
57
+ * How the outcome names what was found.
58
+ *
59
+ * Its own function so both halves are reachable without running the scanner. The
60
+ * corpus for this check deliberately does not: the wrapper downloads a gitleaks
61
+ * binary on first use, and a unit suite that reaches the network is the thing the
62
+ * `installer` seam in `ensure.ts` exists to avoid. What broke here was the wording
63
+ * — a row that read the same for one secret and for thirty-nine — and the wording
64
+ * is testable on its own.
65
+ *
66
+ * `null` keeps the old sentence rather than inventing a number: a report we could
67
+ * not read is not a reason to claim a count.
68
+ */
69
+ function secretsSummary(found) {
70
+ return found === null ? 'potential secrets detected' : `${found} potential secret(s)`;
71
+ }
72
+ function writableHome() {
73
+ const home = node_os_1.default.homedir();
74
+ try {
75
+ node_fs_1.default.accessSync(home, node_fs_1.default.constants.W_OK);
76
+ return home;
77
+ }
78
+ catch { }
79
+ const fallback = node_os_1.default.tmpdir();
80
+ try {
81
+ node_fs_1.default.accessSync(fallback, node_fs_1.default.constants.W_OK);
82
+ utils_1.logger.info(`[Security Scan]: HOME (${home}) is not writable — caching the scanner in ${fallback}`);
83
+ return fallback;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /**
90
+ * Whether the wrapper died instead of reporting, judged from its stderr.
91
+ *
92
+ * The wrapper is a Node script, and Node exits **1** on an unhandled exception —
93
+ * the same code gitleaks itself uses for "secrets found". Left undistinguished,
94
+ * a scanner that never ran is reported as `potential secrets detected`, pointing
95
+ * at a report file that was never written. A gate accusing a clean repository is
96
+ * the same class of failure as one that stays quiet about a real leak, and this
97
+ * is the only signal available to tell them apart.
98
+ */
99
+ function looksLikeCrash(stderr) {
100
+ // The errno names are written out rather than factored to a shared `E` prefix:
101
+ // `E(?:ACCES|PERM|…)` saves four characters and costs the reader the ability to
102
+ // grep for the thing they are debugging.
103
+ return /^\s*(?:[A-Za-z]*Error|Uncaught|node:internal)|\b(?:EACCES|EPERM|ENOENT|EROFS)\b/m.test(stderr);
104
+ }
8
105
  /**
9
106
  * Secret scanning via the bundled gitleaks package. Language-agnostic — runs on
10
107
  * every repo (Node, Python, C++, mobile). Secrets found → fail (blocking).
@@ -29,11 +126,7 @@ exports.securityCheck = {
29
126
  utils_1.logger.warn('[Security Scan]: ⚠️ bundled gitleaks scanner could not be resolved — skipping');
30
127
  return (0, types_1.skip)('gitleaks unavailable');
31
128
  }
32
- const hasClientConfig = (0, config_1.hasLocalConfig)([
33
- '.gitleaks.toml',
34
- 'gitleaks.toml',
35
- '.gitleaks/config.toml',
36
- ]);
129
+ const hasClientConfig = (0, config_1.hasLocalConfig)(['.gitleaks.toml', 'gitleaks.toml', '.gitleaks/config.toml'], ctx.cwd);
37
130
  // Pin the gitleaks binary version so the scanner never reaches out to the
38
131
  // GitHub API for the "latest" version — that network call adds latency and
39
132
  // times out in locked-down/offline CI (the scanner already falls back to
@@ -62,25 +155,56 @@ exports.securityCheck = {
62
155
  if (!hasClientConfig) {
63
156
  args.push('--config', (0, config_1.getPackageConfigPath)('gitleaks.toml'));
64
157
  }
158
+ // A machine-readable copy of the findings, purely so the outcome can carry a
159
+ // number. Written to a temp directory, never into the repository under review:
160
+ // a check whose side effect is a new untracked file shows up in the client's
161
+ // next commit. The console output is untouched — `stdio: inherit` below still
162
+ // streams the scanner's own report, which is what a reader actually looks at.
163
+ const reportDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pr-checkmate-gitleaks-'));
164
+ const reportPath = node_path_1.default.join(reportDir, 'findings.json');
165
+ args.push('--report-format', 'json', '--report-path', reportPath);
65
166
  utils_1.logger.info(`[Security Scan]: scanning ${isDelta ? 'the diff range' : `full history${depth ? ` (last ${depth} commits)` : ''}`}`);
167
+ const home = writableHome();
168
+ if (!home)
169
+ return (0, types_1.skip)('no writable directory for the scanner cache');
66
170
  const env = {
67
171
  ...process.env,
172
+ HOME: home,
68
173
  ...(ctx.headSha ? { HEAD_SHA: ctx.headSha } : {}),
69
174
  ...(ctx.baseSha ? { BASE_SHA: ctx.baseSha } : {}),
70
175
  };
176
+ // stdout stays inherited so the scanner's own report streams to the log as it
177
+ // always has; stderr is captured because it is the only thing that separates
178
+ // a crash from a finding — see `crashed`.
71
179
  const result = await (0, execa_1.execa)(process.execPath, [gitleaksBin, ...args], {
72
180
  cwd: ctx.cwd,
73
181
  env,
74
- stdio: 'inherit',
182
+ stdio: ['ignore', 'inherit', 'pipe'],
75
183
  reject: false,
76
184
  });
185
+ const stderr = String(result.stderr ?? '');
186
+ if (stderr.trim())
187
+ utils_1.logger.warn(stderr.trimEnd());
188
+ const found = countFindings(reportPath);
189
+ node_fs_1.default.rmSync(reportDir, { recursive: true, force: true });
77
190
  if (result.exitCode === 0)
78
191
  return (0, types_1.pass)();
79
- // gitleaks exits 1 specifically when it finds secrets — that is a real violation.
80
- if (result.exitCode === 1) {
81
- utils_1.logger.warn('⚠️ gitleaks found potential secrets — review gitleaks-report.html');
82
- return (0, types_1.fail)('potential secrets detected');
192
+ // gitleaks exits 1 specifically when it finds secrets — but so does Node when
193
+ // the wrapper around it throws. Only the second one leaves a stack trace.
194
+ if (result.exitCode === 1 && !looksLikeCrash(stderr)) {
195
+ // The findings themselves are already above, streamed from the scanner. This
196
+ // used to say "review gitleaks-report.html" — a file the check never wrote:
197
+ // `--html-report` is passed by our own `scan:secrets` npm script and by
198
+ // nothing else, so a blocking check told the client to open something that
199
+ // does not exist (ticket 085).
200
+ const summary = secretsSummary(found);
201
+ utils_1.logger.warn(`⚠️ gitleaks found ${summary} — see the findings above`);
202
+ return (0, types_1.fail)(summary);
83
203
  }
84
- return (0, types_1.warn)(`scanner error (exit ${result.exitCode})`);
204
+ const detail = looksLikeCrash(stderr)
205
+ ? 'scanner failed to start — nothing was scanned'
206
+ : `scanner error (exit ${result.exitCode})`;
207
+ utils_1.logger.warn(`[Security Scan]: ⚠️ ${detail}`);
208
+ return (0, types_1.warn)(detail);
85
209
  },
86
210
  };
@@ -3,12 +3,36 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sensitiveFileGuardCheck = void 0;
4
4
  const utils_1 = require("../../../utils");
5
5
  const execa_1 = require("../../../utils/execa");
6
+ const targets_1 = require("../../targets");
6
7
  const types_1 = require("../../types");
7
8
  const findings_1 = require("../findings");
9
+ /**
10
+ * The committed template, in the names every ecosystem uses for it.
11
+ *
12
+ * `.env.example` holds placeholders, is checked in on purpose, and is what the
13
+ * `.env` convention tells people to commit — a repository without one is the
14
+ * unusual case. Flagging it as a secret is not a stylistic quibble now that
15
+ * ticket 055 makes this category block: it turns the recommended practice into a
16
+ * red build, and a gate that fires on correct code is a gate people switch off.
17
+ *
18
+ * Confirmed in the field before it was fixed here: a client shipped
19
+ * `"sensitiveFileGuard": { "ignorePaths": [".env.example"] }` in their config to
20
+ * work around it. Needing a hand-written exclusion for the file the ecosystem
21
+ * mandates is the definition of a false positive.
22
+ *
23
+ * Matched only at the end of the path, so `.env.example.local` — a real file with
24
+ * real values, named to sort next to the template — is still flagged.
25
+ */
26
+ const TEMPLATE_SUFFIX = /\.(?:example|sample|template|dist|defaults)$/i;
8
27
  const SENSITIVE_CATEGORIES = [
9
28
  {
10
29
  label: 'Environment files (.env)',
11
- patterns: [/^\.env$/, /^\.env\..+/, /\/.env$/, /\/.env\..+/],
30
+ // The dots are escaped. They were not, and `\/.env$` therefore matched ANY
31
+ // single character before `env`, so a nested file whose name merely ended in
32
+ // those three letters was reported as an environment file. Harmless while
33
+ // this category only warned; a red build over a filename once it blocks.
34
+ patterns: [/^\.env$/, /^\.env\..+/, /\/\.env$/, /\/\.env\..+/],
35
+ except: [TEMPLATE_SUFFIX],
12
36
  severity: 'error',
13
37
  },
14
38
  {
@@ -61,6 +85,8 @@ function categorizeFiles(changedFiles) {
61
85
  const warnings = {};
62
86
  for (const filePath of changedFiles) {
63
87
  for (const category of SENSITIVE_CATEGORIES) {
88
+ if (category.except?.some(p => p.test(filePath)))
89
+ continue;
64
90
  if (category.patterns.some(p => p.test(filePath))) {
65
91
  const bucket = category.severity === 'error' ? errors : warnings;
66
92
  (bucket[_a = category.label] ?? (bucket[_a] = [])).push(filePath);
@@ -82,7 +108,7 @@ exports.sensitiveFileGuardCheck = {
82
108
  async run(ctx) {
83
109
  try {
84
110
  const args = ctx.baseSha
85
- ? ['diff', '--name-only', '--diff-filter=ACMRD', ctx.baseSha, 'HEAD']
111
+ ? ['diff', '--name-only', '--diff-filter=ACMRD', ctx.baseSha, ctx.headSha ?? 'HEAD']
86
112
  : ['ls-files'];
87
113
  if (!ctx.baseSha && ctx.config.run?.fullScanBehaviour === 'skip')
88
114
  return (0, types_1.pass)();
@@ -100,10 +126,18 @@ exports.sensitiveFileGuardCheck = {
100
126
  // Without this the only exclusion a client has is `ignore`, which suppresses
101
127
  // the whole CATEGORY: muting "Private keys and certificates" to excuse one
102
128
  // fixture would also excuse the next real key someone commits.
103
- const changedFiles = result.stdout
104
- .split('\n')
105
- .filter(Boolean)
106
- .filter(file => !(0, utils_1.matchesAnyGlob)(file, cfg.ignorePaths ?? []));
129
+ // `excludePaths` before the check's own `ignorePaths`, and both before
130
+ // categorising.
131
+ //
132
+ // This check deliberately does NOT go through `targets.ts`: a committed
133
+ // `.env` inside `dist/` is still a committed secret, so `ignoreDirs` must
134
+ // not hide it, and a key outside `sourcePath` is still a key. But
135
+ // `excludePaths` is different in kind — it is the client saying "do not
136
+ // look here" in as many words, and the documentation calls it the one
137
+ // exclusion that reaches every check. It did not reach this one, which
138
+ // meant a `.pem` test fixture failed the build and the key the docs told
139
+ // people to reach for did nothing (ticket 080).
140
+ const changedFiles = (0, targets_1.excludeClientPaths)(result.stdout.split('\n').filter(Boolean), ctx.cwd).filter(file => !(0, utils_1.matchesAnyGlob)(file, cfg.ignorePaths ?? []));
107
141
  if (changedFiles.length === 0)
108
142
  return (0, types_1.pass)();
109
143
  const { errors, warnings } = categorizeFiles(changedFiles);
@@ -79,7 +79,13 @@ exports.workflowSecurityCheck = {
79
79
  scanInto(await (0, findings_1.diffAddedLines)(ctx, globs));
80
80
  }
81
81
  else if (ctx.config.run?.fullScanBehaviour !== 'skip') {
82
- const files = await (0, targets_1.listTrackedFiles)(ctx, globs, ['.']);
82
+ // `.github` is globally ignored and is the only place these files live,
83
+ // so this caller has to un-ignore it or scan nothing.
84
+ const files = await (0, targets_1.listTrackedFiles)(ctx, globs, ['.'], {
85
+ keepDirs: ['.github', '.gitlab', '.circleci'],
86
+ });
87
+ if (files === null)
88
+ return (0, types_1.skip)('git unavailable');
83
89
  await (0, findings_1.scanTrackedFiles)(ctx, files, scanInto);
84
90
  }
85
91
  return (0, findings_1.reportSeverityBuckets)(found.errors, found.warnings, {
@@ -0,0 +1,102 @@
1
+ import { CheckOutcome } from '../types';
2
+ /**
3
+ * Where to get the external runners we deliberately do not ship.
4
+ *
5
+ * Eleven checks shell out to a tool that is not ours to bundle — a Kotlin linter,
6
+ * a Swift linter, the Go toolchain. When it is absent the check skips, which is
7
+ * the honest outcome, but `⏭️ ktlint: ktlint not installed` tells the reader
8
+ * something they already knew and nothing they can act on. A skip is only useful
9
+ * if it says what would make the check run.
10
+ *
11
+ * One table rather than a string at each call site: fourteen hand-written
12
+ * messages drift, and half of them end up naming an install route that has since
13
+ * moved.
14
+ *
15
+ * **Repository homepages, not deep documentation links.** A `#installation`
16
+ * anchor is exactly the kind of URL that rots, and a dead link printed into a
17
+ * client's CI log is worse than no link — it reads as carelessness about the
18
+ * thing we are asking them to trust. A project's repository root survives
19
+ * documentation reorganisations and always carries current install instructions.
20
+ *
21
+ * The command is the platform-agnostic one where a project has one, and the
22
+ * common-case one otherwise; the URL covers every other platform.
23
+ */
24
+ declare const INSTALL_HINTS: {
25
+ readonly ktlint: {
26
+ readonly command: "brew install ktlint";
27
+ readonly url: "https://github.com/pinterest/ktlint";
28
+ };
29
+ readonly swiftlint: {
30
+ readonly command: "brew install swiftlint";
31
+ readonly url: "https://github.com/realm/SwiftLint";
32
+ };
33
+ readonly rubocop: {
34
+ readonly command: "gem install rubocop";
35
+ readonly url: "https://github.com/rubocop/rubocop";
36
+ };
37
+ readonly shellcheck: {
38
+ readonly command: "brew install shellcheck";
39
+ readonly url: "https://github.com/koalaman/shellcheck";
40
+ };
41
+ readonly go: {
42
+ readonly command: null;
43
+ readonly url: "https://go.dev/doc/install";
44
+ };
45
+ readonly gofmt: {
46
+ readonly command: null;
47
+ readonly url: "https://go.dev/doc/install";
48
+ };
49
+ readonly clippy: {
50
+ readonly command: "rustup component add clippy";
51
+ readonly url: "https://github.com/rust-lang/rust-clippy";
52
+ };
53
+ readonly rustfmt: {
54
+ readonly command: "rustup component add rustfmt";
55
+ readonly url: "https://github.com/rust-lang/rustfmt";
56
+ };
57
+ readonly 'php-cs-fixer': {
58
+ readonly command: "composer global require friendsofphp/php-cs-fixer";
59
+ readonly url: "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer";
60
+ };
61
+ readonly dotnet: {
62
+ readonly command: null;
63
+ readonly url: "https://dotnet.microsoft.com/download";
64
+ };
65
+ readonly mypy: {
66
+ readonly command: "pip install mypy";
67
+ readonly url: "https://github.com/python/mypy";
68
+ };
69
+ readonly pyright: {
70
+ readonly command: "npm i -g pyright";
71
+ readonly url: "https://github.com/microsoft/pyright";
72
+ };
73
+ readonly grype: {
74
+ readonly command: "brew install grype";
75
+ readonly url: "https://github.com/anchore/grype";
76
+ };
77
+ readonly 'osv-scanner': {
78
+ readonly command: "brew install osv-scanner";
79
+ readonly url: "https://github.com/google/osv-scanner";
80
+ };
81
+ };
82
+ /**
83
+ * A tool this package expects the environment to provide.
84
+ *
85
+ * Exported because the runner specs in `languages/external-checks.ts` name one
86
+ * per check, and that is the point of naming them: a spec that cannot compile
87
+ * with a typo in the key cannot ship a skip pointing at an install route that
88
+ * does not exist.
89
+ */
90
+ export type ExternalTool = keyof typeof INSTALL_HINTS;
91
+ /**
92
+ * Skip because `tool` is not on the path, saying how to install it.
93
+ *
94
+ * @param tool Which runner is missing.
95
+ * @param label How to name it in the message. Omit when the check's own display
96
+ * name already is the tool's name — `⏭️ ktlint: ktlint not installed` repeats
97
+ * itself, which is what made these messages worth revisiting. Pass a label
98
+ * when the two differ (`Go Vet` needs `go`) or when either of two tools would
99
+ * have done (`mypy/pyright`).
100
+ */
101
+ export declare function notInstalled(tool: ExternalTool, label?: string): CheckOutcome;
102
+ export {};
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.notInstalled = notInstalled;
4
+ const types_1 = require("../types");
5
+ /**
6
+ * Where to get the external runners we deliberately do not ship.
7
+ *
8
+ * Eleven checks shell out to a tool that is not ours to bundle — a Kotlin linter,
9
+ * a Swift linter, the Go toolchain. When it is absent the check skips, which is
10
+ * the honest outcome, but `⏭️ ktlint: ktlint not installed` tells the reader
11
+ * something they already knew and nothing they can act on. A skip is only useful
12
+ * if it says what would make the check run.
13
+ *
14
+ * One table rather than a string at each call site: fourteen hand-written
15
+ * messages drift, and half of them end up naming an install route that has since
16
+ * moved.
17
+ *
18
+ * **Repository homepages, not deep documentation links.** A `#installation`
19
+ * anchor is exactly the kind of URL that rots, and a dead link printed into a
20
+ * client's CI log is worse than no link — it reads as carelessness about the
21
+ * thing we are asking them to trust. A project's repository root survives
22
+ * documentation reorganisations and always carries current install instructions.
23
+ *
24
+ * The command is the platform-agnostic one where a project has one, and the
25
+ * common-case one otherwise; the URL covers every other platform.
26
+ */
27
+ const INSTALL_HINTS = {
28
+ ktlint: { command: 'brew install ktlint', url: 'https://github.com/pinterest/ktlint' },
29
+ swiftlint: { command: 'brew install swiftlint', url: 'https://github.com/realm/SwiftLint' },
30
+ rubocop: { command: 'gem install rubocop', url: 'https://github.com/rubocop/rubocop' },
31
+ shellcheck: {
32
+ command: 'brew install shellcheck',
33
+ url: 'https://github.com/koalaman/shellcheck',
34
+ },
35
+ go: { command: null, url: 'https://go.dev/doc/install' },
36
+ gofmt: { command: null, url: 'https://go.dev/doc/install' },
37
+ clippy: {
38
+ command: 'rustup component add clippy',
39
+ url: 'https://github.com/rust-lang/rust-clippy',
40
+ },
41
+ rustfmt: { command: 'rustup component add rustfmt', url: 'https://github.com/rust-lang/rustfmt' },
42
+ 'php-cs-fixer': {
43
+ command: 'composer global require friendsofphp/php-cs-fixer',
44
+ url: 'https://github.com/PHP-CS-Fixer/PHP-CS-Fixer',
45
+ },
46
+ dotnet: { command: null, url: 'https://dotnet.microsoft.com/download' },
47
+ mypy: { command: 'pip install mypy', url: 'https://github.com/python/mypy' },
48
+ pyright: { command: 'npm i -g pyright', url: 'https://github.com/microsoft/pyright' },
49
+ grype: { command: 'brew install grype', url: 'https://github.com/anchore/grype' },
50
+ 'osv-scanner': {
51
+ command: 'brew install osv-scanner',
52
+ url: 'https://github.com/google/osv-scanner',
53
+ },
54
+ };
55
+ /**
56
+ * Skip because `tool` is not on the path, saying how to install it.
57
+ *
58
+ * @param tool Which runner is missing.
59
+ * @param label How to name it in the message. Omit when the check's own display
60
+ * name already is the tool's name — `⏭️ ktlint: ktlint not installed` repeats
61
+ * itself, which is what made these messages worth revisiting. Pass a label
62
+ * when the two differ (`Go Vet` needs `go`) or when either of two tools would
63
+ * have done (`mypy/pyright`).
64
+ */
65
+ function notInstalled(tool, label) {
66
+ const { command, url } = INSTALL_HINTS[tool];
67
+ const subject = label ? `${label} not installed` : 'not installed';
68
+ return (0, types_1.skip)(command ? `${subject} — ${command} · ${url}` : `${subject} — ${url}`);
69
+ }
@@ -192,7 +192,14 @@ async function buildContext(opts = {}) {
192
192
  // rather than calling getIgnoredDirs() — that one resolves against process.cwd(),
193
193
  // which is not necessarily the `cwd` under review.
194
194
  applyLogConfig(config);
195
- const languages = opts.languages ?? (await (0, detect_language_1.detectLanguages)(cwd, config.ignoreDirs, config.detect));
195
+ // Detected as usage (language + file count), not as a bare list: the same scan
196
+ // answers two different questions, and the install layer needs the counts. A
197
+ // `Cargo.toml` beside zero `.rs` files is enough to run Rust checks and not
198
+ // enough to download a toolchain — see `detectLanguageUsage`.
199
+ const usage = opts.languages
200
+ ? opts.languages.map(lang => ({ lang, files: 1, marker: true }))
201
+ : await (0, detect_language_1.detectLanguageUsage)(cwd, config.ignoreDirs, config.detect);
202
+ const languages = usage.map(u => u.lang);
196
203
  const { baseSha, headSha, scope } = await resolveDiffRange(cwd, opts);
197
204
  // `scope` is logged because it decides what the whole run looks at, and getting
198
205
  // it wrong is invisible in the results: a run that reviewed one commit and one
@@ -201,6 +208,7 @@ async function buildContext(opts = {}) {
201
208
  return {
202
209
  cwd,
203
210
  languages,
211
+ usage,
204
212
  config,
205
213
  write: opts.write ?? false,
206
214
  baseSha,
@@ -24,14 +24,13 @@ function expandHome(dir, home = node_os_1.default.homedir()) {
24
24
  return home;
25
25
  return dir.startsWith('~/') ? node_path_1.default.join(home, dir.slice(2)) : dir;
26
26
  }
27
- /** A directory we can actually write to, or null. */
28
- function usable(dir) {
27
+ /** A directory we can actually write to. */
28
+ function writable(dir) {
29
29
  try {
30
30
  node_fs_1.default.mkdirSync(dir, { recursive: true });
31
- // Existence is not permission: a cache directory baked into a container
32
- // image is often present and read-only, and finding that out at install
33
- // time rather than at write time is the difference between a clear message
34
- // and a stack trace halfway through fetching.
31
+ // Existence is not permission, and finding that out here rather than at write
32
+ // time is the difference between a clear message and a stack trace halfway
33
+ // through fetching.
35
34
  node_fs_1.default.accessSync(dir, node_fs_1.default.constants.W_OK);
36
35
  return true;
37
36
  }
@@ -39,6 +38,30 @@ function usable(dir) {
39
38
  return false;
40
39
  }
41
40
  }
41
+ /**
42
+ * A directory that already exists and can at least be read.
43
+ *
44
+ * Only ever asked of a cache path the client named explicitly, and only after
45
+ * {@link writable} has said no. That combination has exactly one meaning: a cache
46
+ * baked into a container image, read-only on purpose, holding tooling somebody
47
+ * warmed at build time. Reading it needs no write permission, and refusing it
48
+ * because a *future* fetch could not write there discards the tooling that is
49
+ * sitting right there — which is how our own image lost `Python Types` under
50
+ * `--user` (ticket 083).
51
+ *
52
+ * A fetch into such a directory still fails, in `ensureProfile`, with the path it
53
+ * could not create. That is the clear message the write check was for; it is just
54
+ * no longer paid for by every read.
55
+ */
56
+ function readable(dir) {
57
+ try {
58
+ node_fs_1.default.accessSync(dir, node_fs_1.default.constants.R_OK | node_fs_1.default.constants.X_OK);
59
+ return node_fs_1.default.statSync(dir).isDirectory();
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
42
65
  /**
43
66
  * Resolve the cache directory, falling back rather than failing.
44
67
  *
@@ -62,12 +85,20 @@ function resolveCacheDir(version, configured, env = process.env) {
62
85
  candidates.push({ dir: node_path_1.default.join(node_os_1.default.tmpdir(), 'pr-checkmate-cache'), reason: 'temp directory' });
63
86
  for (const [index, candidate] of candidates.entries()) {
64
87
  const dir = node_path_1.default.join(candidate.dir, version);
65
- if (!usable(dir))
66
- continue;
67
- if (index > 0) {
68
- logger_1.logger.warn(`[install]: ⚠️ cache directory "${candidates[0].dir}" is not writable — using ${candidate.reason} (${dir}). Fetched tooling will not survive a container rebuild.`);
88
+ if (writable(dir)) {
89
+ if (index > 0) {
90
+ logger_1.logger.warn(`[install]: ⚠️ cache directory "${candidates[0].dir}" is not writable — using ${candidate.reason} (${dir}). Fetched tooling will not survive a container rebuild.`);
91
+ }
92
+ return { dir, preferred: index === 0, reason: candidate.reason };
93
+ }
94
+ // An explicitly named cache that cannot be written but CAN be read is still
95
+ // the answer — see {@link readable} — and it has to win here rather than in a
96
+ // second pass at the end. Otherwise the writable-but-empty temp directory two
97
+ // candidates down takes precedence over a read-only cache holding the very
98
+ // tooling this run needs, which is the shape our own image has.
99
+ if (index === 0 && explicit && readable(dir)) {
100
+ return { dir, preferred: true, reason: 'configured, read-only' };
69
101
  }
70
- return { dir, preferred: index === 0, reason: candidate.reason };
71
102
  }
72
103
  return null;
73
104
  }
@@ -1,8 +1,8 @@
1
1
  import { type LanguageUsage } from '../../utils/detect-language';
2
+ import { packageVersion } from '../../utils/package-version';
2
3
  import { type EnsureResult } from './ensure';
3
4
  import { type ProfileDecision } from './profiles';
4
- /** Our own version, read from the package manifest that ships with us. */
5
- export declare function packageVersion(): string;
5
+ export { packageVersion };
6
6
  /**
7
7
  * Everything the install layer knows about this repository, in one shot.
8
8
  *
@@ -3,7 +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.packageVersion = packageVersion;
6
+ exports.packageVersion = void 0;
7
7
  exports.planInstall = planInstall;
8
8
  exports.installProfiles = installProfiles;
9
9
  exports.runDoctor = runDoctor;
@@ -13,19 +13,12 @@ const node_path_1 = __importDefault(require("node:path"));
13
13
  const loader_1 = require("../../config/loader");
14
14
  const detect_language_1 = require("../../utils/detect-language");
15
15
  const logger_1 = require("../../utils/logger");
16
+ const package_version_1 = require("../../utils/package-version");
17
+ Object.defineProperty(exports, "packageVersion", { enumerable: true, get: function () { return package_version_1.packageVersion; } });
16
18
  const cache_1 = require("./cache");
17
19
  const ensure_1 = require("./ensure");
20
+ const prepare_1 = require("./prepare");
18
21
  const profiles_1 = require("./profiles");
19
- /** Our own version, read from the package manifest that ships with us. */
20
- function packageVersion() {
21
- try {
22
- const manifest = node_path_1.default.resolve(__dirname, '../../..', 'package.json');
23
- return JSON.parse(node_fs_1.default.readFileSync(manifest, 'utf-8')).version ?? '0.0.0';
24
- }
25
- catch {
26
- return '0.0.0';
27
- }
28
- }
29
22
  /**
30
23
  * Everything the install layer knows about this repository, in one shot.
31
24
  *
@@ -36,7 +29,7 @@ function packageVersion() {
36
29
  async function planInstall(cwd = process.cwd(), usage) {
37
30
  const config = (0, loader_1.loadProjectConfig)();
38
31
  const detected = usage ?? (await (0, detect_language_1.detectLanguageUsage)(cwd, config.ignoreDirs, config.detect));
39
- const location = (0, cache_1.resolveCacheDir)(packageVersion(), config.install?.cacheDir);
32
+ const location = (0, cache_1.resolveCacheDir)((0, package_version_1.packageVersion)(), config.install?.cacheDir);
40
33
  return { decisions: (0, profiles_1.planProfiles)(detected, config), cacheDir: location?.dir ?? null };
41
34
  }
42
35
  /** Whether a profile's tooling is present in the cache right now. */
@@ -60,6 +53,14 @@ async function installProfiles(cwd = process.cwd(), all = false) {
60
53
  const denied = new Set(install.deny ?? []);
61
54
  const wanted = (all ? profiles_1.PROFILES.map(p => p.name) : decisions.filter(d => d.required).map(d => d.profile)).filter(name => {
62
55
  const profile = profiles_1.PROFILES.find(p => p.name === name);
56
+ // Nothing to fetch for a profile whose packages already ship with us, and
57
+ // fetching it anyway is not merely wasted: `--all` downloaded a second copy
58
+ // of every bundled profile — some 800 packages — beside the ones already in
59
+ // `node_modules`. On a container build that lands in the image (ticket 083).
60
+ if (profile && (0, prepare_1.profileSatisfiedLocally)(profile)) {
61
+ logger_1.logger.info(`[install]: profile "${name}" already ships with the package`);
62
+ return false;
63
+ }
63
64
  const blocked = profile?.packages.filter(pkg => denied.has(pkg)) ?? [];
64
65
  if (blocked.length === 0)
65
66
  return true;
@@ -71,7 +72,7 @@ async function installProfiles(cwd = process.cwd(), all = false) {
71
72
  // packages fight over npm's cache and gain nothing; the wall-clock cost lands
72
73
  // once, on a cold cache, and clarity of the log matters more there.
73
74
  for (const profile of wanted) {
74
- results.push(await (0, ensure_1.ensureProfile)(profile, { cwd, version: packageVersion(), install }));
75
+ results.push(await (0, ensure_1.ensureProfile)(profile, { cwd, version: (0, package_version_1.packageVersion)(), install }));
75
76
  }
76
77
  return results;
77
78
  }
@@ -90,7 +91,7 @@ async function runDoctor(cwd = process.cwd()) {
90
91
  const lines = [];
91
92
  lines.push('PR CheckMate — install doctor');
92
93
  lines.push('');
93
- lines.push(` version ${packageVersion()}`);
94
+ lines.push(` version ${(0, package_version_1.packageVersion)()}`);
94
95
  lines.push(` repository ${cwd}`);
95
96
  lines.push(` cache ${cacheDir ?? 'unavailable (no writable directory)'}`);
96
97
  lines.push(` strategy ${config.install?.strategy ?? 'adaptive'}`);
@@ -116,7 +117,7 @@ async function runDoctor(cwd = process.cwd()) {
116
117
  lines.push(` ${missing.length} profile(s) not installed yet. They are fetched on the next`);
117
118
  lines.push(' run, or now with: npx pr-checkmate install');
118
119
  }
119
- const stale = (0, cache_1.stalePaths)(packageVersion(), config.install?.cacheDir);
120
+ const stale = (0, cache_1.stalePaths)((0, package_version_1.packageVersion)(), config.install?.cacheDir);
120
121
  if (stale.length > 0) {
121
122
  lines.push('');
122
123
  lines.push(` ${stale.length} directory(ies) from other versions: npx pr-checkmate cache-prune`);
@@ -126,7 +127,7 @@ async function runDoctor(cwd = process.cwd()) {
126
127
  /** Remove cached tooling belonging to other versions of pr-checkmate. */
127
128
  function pruneCache() {
128
129
  const config = (0, loader_1.loadProjectConfig)();
129
- const stale = (0, cache_1.stalePaths)(packageVersion(), config.install?.cacheDir);
130
+ const stale = (0, cache_1.stalePaths)((0, package_version_1.packageVersion)(), config.install?.cacheDir);
130
131
  if (stale.length === 0) {
131
132
  logger_1.logger.print('Nothing to prune — no tooling from other versions is cached.');
132
133
  return;