pr-checkmate 2.1.2 → 3.0.1

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 (119) hide show
  1. package/README.md +63 -268
  2. package/cspell.json +470 -447
  3. package/dist/cli.js +1 -1
  4. package/dist/config/constants.d.ts +6 -1
  5. package/dist/config/constants.js +9 -7
  6. package/dist/config/index.d.ts +23 -11
  7. package/dist/config/index.js +27 -33
  8. package/dist/core/checks/dependencies/banned-imports.js +4 -1
  9. package/dist/core/checks/dependencies/circular-deps.js +14 -5
  10. package/dist/core/checks/dependencies/dependency-check.js +1 -1
  11. package/dist/core/checks/dependencies/grype-scan.js +2 -1
  12. package/dist/core/checks/dependencies/vuln-scan.js +2 -1
  13. package/dist/core/checks/findings.d.ts +46 -2
  14. package/dist/core/checks/findings.js +140 -2
  15. package/dist/core/checks/git/custom-rules.js +2 -0
  16. package/dist/core/checks/git/diff-smell.js +6 -1
  17. package/dist/core/checks/git/leftover-debug.js +8 -2
  18. package/dist/core/checks/git/lockfile-drift.js +6 -1
  19. package/dist/core/checks/git/merge-conflict.js +1 -1
  20. package/dist/core/checks/git/missing-tests.js +1 -1
  21. package/dist/core/checks/git/todo-fixme.js +10 -2
  22. package/dist/core/checks/languages/cpp-format.js +19 -42
  23. package/dist/core/checks/languages/external-checks.d.ts +77 -0
  24. package/dist/core/checks/languages/external-checks.js +200 -0
  25. package/dist/core/checks/languages/external-runner.d.ts +119 -0
  26. package/dist/core/checks/languages/external-runner.js +143 -0
  27. package/dist/core/checks/languages/go-format.d.ts +4 -4
  28. package/dist/core/checks/languages/go-format.js +151 -30
  29. package/dist/core/checks/languages/lint.js +19 -19
  30. package/dist/core/checks/languages/prettier.js +2 -7
  31. package/dist/core/checks/languages/python-typecheck.d.ts +10 -5
  32. package/dist/core/checks/languages/python-typecheck.js +161 -37
  33. package/dist/core/checks/languages/ruff-shared.d.ts +3 -3
  34. package/dist/core/checks/languages/ruff-shared.js +27 -4
  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/coverage.js +6 -1
  41. package/dist/core/checks/quality/dead-code.js +12 -3
  42. package/dist/core/checks/quality/duplicate-check.js +54 -27
  43. package/dist/core/checks/quality/license-header.js +7 -4
  44. package/dist/core/checks/quality/markdown-lint.js +1 -6
  45. package/dist/core/checks/quality/package-size.d.ts +15 -0
  46. package/dist/core/checks/quality/package-size.js +130 -0
  47. package/dist/core/checks/quality/spellcheck.js +34 -5
  48. package/dist/core/checks/quality/symlinks.js +6 -4
  49. package/dist/core/checks/security/diff-security.js +26 -18
  50. package/dist/core/checks/security/dockerfile-security.js +7 -1
  51. package/dist/core/checks/security/migration-safety.js +32 -4
  52. package/dist/core/checks/security/security.d.ts +56 -0
  53. package/dist/core/checks/security/security.js +135 -11
  54. package/dist/core/checks/security/sensitive-file-guard.js +40 -6
  55. package/dist/core/checks/security/workflow-security.js +7 -1
  56. package/dist/core/checks/tool-install.d.ts +102 -0
  57. package/dist/core/checks/tool-install.js +69 -0
  58. package/dist/core/commands.js +1 -0
  59. package/dist/core/context.js +9 -1
  60. package/dist/core/install/cache.d.ts +20 -1
  61. package/dist/core/install/cache.js +65 -12
  62. package/dist/core/install/doctor.js +49 -15
  63. package/dist/core/install/ensure.d.ts +31 -14
  64. package/dist/core/install/ensure.js +165 -61
  65. package/dist/core/install/lock.d.ts +2 -1
  66. package/dist/core/install/merge.d.ts +47 -0
  67. package/dist/core/install/merge.js +91 -0
  68. package/dist/core/install/prepare.d.ts +76 -0
  69. package/dist/core/install/prepare.js +147 -0
  70. package/dist/core/install/profiles.d.ts +17 -3
  71. package/dist/core/install/profiles.js +27 -0
  72. package/dist/core/install/reuse.d.ts +41 -0
  73. package/dist/core/install/reuse.js +107 -0
  74. package/dist/core/registry.js +12 -18
  75. package/dist/core/run-checks.js +74 -3
  76. package/dist/core/targets.d.ts +116 -5
  77. package/dist/core/targets.js +101 -16
  78. package/dist/core/types.d.ts +8 -2
  79. package/dist/init.js +21 -1
  80. package/dist/interfaces/index.d.ts +6 -0
  81. package/dist/utils/resolve-bin.d.ts +56 -6
  82. package/dist/utils/resolve-bin.js +136 -26
  83. package/gitleaks.toml +14 -0
  84. package/install-manifests/commitlint/package-lock.json +128 -250
  85. package/install-manifests/commitlint/package.json +2 -2
  86. package/install-manifests/core/package-lock.json +76 -76
  87. package/install-manifests/core/package.json +1 -1
  88. package/install-manifests/duplicate/package-lock.json +45 -45
  89. package/install-manifests/duplicate/package.json +1 -1
  90. package/install-manifests/go/package-lock.json +24 -0
  91. package/install-manifests/go/package.json +8 -0
  92. package/install-manifests/js/package-lock.json +248 -283
  93. package/install-manifests/js/package.json +9 -9
  94. package/install-manifests/python/package-lock.json +4 -4
  95. package/install-manifests/python/package.json +1 -1
  96. package/install-manifests/pytypes/package-lock.json +45 -0
  97. package/install-manifests/pytypes/package.json +8 -0
  98. package/package.json +21 -19
  99. package/pr-checkmate.schema.json +41 -13
  100. package/src/config/cspell.json +470 -447
  101. package/src/config/pr-checkmate.schema.json +41 -13
  102. package/dist/core/checks/languages/csharp-format.d.ts +0 -13
  103. package/dist/core/checks/languages/csharp-format.js +0 -52
  104. package/dist/core/checks/languages/go-lint.d.ts +0 -8
  105. package/dist/core/checks/languages/go-lint.js +0 -48
  106. package/dist/core/checks/languages/kotlin-lint.d.ts +0 -2
  107. package/dist/core/checks/languages/kotlin-lint.js +0 -42
  108. package/dist/core/checks/languages/php-format.d.ts +0 -9
  109. package/dist/core/checks/languages/php-format.js +0 -49
  110. package/dist/core/checks/languages/ruby-lint.d.ts +0 -8
  111. package/dist/core/checks/languages/ruby-lint.js +0 -50
  112. package/dist/core/checks/languages/rust-format.d.ts +0 -9
  113. package/dist/core/checks/languages/rust-format.js +0 -45
  114. package/dist/core/checks/languages/rust-lint.d.ts +0 -8
  115. package/dist/core/checks/languages/rust-lint.js +0 -50
  116. package/dist/core/checks/languages/shellcheck.d.ts +0 -8
  117. package/dist/core/checks/languages/shellcheck.js +0 -55
  118. package/dist/core/checks/languages/swift-lint.d.ts +0 -2
  119. package/dist/core/checks/languages/swift-lint.js +0 -46
@@ -63,12 +63,18 @@ exports.leftoverDebugCheck = {
63
63
  try {
64
64
  const findings = {};
65
65
  const comments = ctx.config.leftoverDebug?.commentHandling ?? 'language';
66
+ // One failed language set fails the check — see todo-fixme, same reason.
67
+ let reviewed = true;
66
68
  if (ctx.languages.includes('python')) {
67
- await (0, findings_1.reviewLines)(ctx, ['*.py'], out => collect(out, PYTHON_PATTERNS, findings, comments));
69
+ reviewed =
70
+ (await (0, findings_1.reviewLines)(ctx, ['*.py'], out => collect(out, PYTHON_PATTERNS, findings, comments))) && reviewed;
68
71
  }
69
72
  if (ctx.languages.includes('kotlin')) {
70
- await (0, findings_1.reviewLines)(ctx, ['*.kt', '*.kts', '*.java'], out => collect(out, KOTLIN_PATTERNS, findings, comments));
73
+ reviewed =
74
+ (await (0, findings_1.reviewLines)(ctx, ['*.kt', '*.kts', '*.java'], out => collect(out, KOTLIN_PATTERNS, findings, comments))) && reviewed;
71
75
  }
76
+ if (!reviewed)
77
+ return (0, types_1.skip)('git unavailable');
72
78
  if ((0, findings_1.bucketTotal)(findings) === 0)
73
79
  return (0, types_1.pass)();
74
80
  // Advisory by default; a repo may promote to a gate via
@@ -8,6 +8,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const utils_1 = require("../../../utils");
10
10
  const execa_1 = require("../../../utils/execa");
11
+ const targets_1 = require("../../targets");
11
12
  const types_1 = require("../../types");
12
13
  const findings_1 = require("../findings");
13
14
  /**
@@ -73,6 +74,10 @@ exports.lockfileDriftCheck = {
73
74
  // Raw name-only diff — manifests/locks live at the repo root, outside any
74
75
  // configured `sourcePath`, so this deliberately does NOT scope (same as
75
76
  // the Sensitive Files guard). Scoping would hide the very files we check.
77
+ //
78
+ // `excludePaths` is still applied, below: not scoping is an argument about
79
+ // WHERE these files live, not a licence to ignore a client who named one
80
+ // (ticket 080). A vendored sub-project's lockfile is a real case.
76
81
  const { stdout } = await (0, execa_1.execa)('git', [
77
82
  'diff',
78
83
  '--name-only',
@@ -80,7 +85,7 @@ exports.lockfileDriftCheck = {
80
85
  ctx.baseSha,
81
86
  ctx.headSha ?? 'HEAD',
82
87
  ], { cwd: ctx.cwd });
83
- const changed = new Set(stdout.split('\n').filter(Boolean));
88
+ const changed = new Set((0, targets_1.excludeClientPaths)(stdout.split('\n').filter(Boolean), ctx.cwd));
84
89
  const warnings = [];
85
90
  for (const changedPath of changed) {
86
91
  const pair = LOCK_PAIRS.find(p => p.manifest === node_path_1.default.basename(changedPath));
@@ -82,7 +82,7 @@ exports.mergeConflictCheck = {
82
82
  // client's `ignoreDirs` and `sourcePath`), then diff only those files for
83
83
  // content. `['*']` matches every path since conflicts are language-agnostic;
84
84
  // scoping still keeps build/vendor/report dirs out, like every other check.
85
- const files = await (0, targets_1.resolveScopedTargetFiles)(ctx, ['*'], (0, config_1.getSourcePaths)());
85
+ const files = await (0, targets_1.resolveScopedTargetFiles)(ctx, ['*'], (0, config_1.getSourcePaths)(ctx.cwd));
86
86
  if (files === null)
87
87
  return (0, types_1.skip)('git unavailable');
88
88
  if (files.length === 0)
@@ -45,7 +45,7 @@ exports.missingTestsCheck = {
45
45
  async run(ctx) {
46
46
  // Scope the same way lint does — a client's `sourcePath` and `ignoreDirs`
47
47
  // both apply, so generated/out-of-scope dirs never count as "new source".
48
- const scopedList = await (0, targets_1.resolveScopedTargetFiles)(ctx, ['*'], (0, config_1.getSourcePaths)());
48
+ const scopedList = await (0, targets_1.resolveScopedTargetFiles)(ctx, ['*'], (0, config_1.getSourcePaths)(ctx.cwd));
49
49
  if (scopedList === null)
50
50
  return (0, types_1.skip)('git unavailable');
51
51
  const scoped = new Set(scopedList);
@@ -51,14 +51,22 @@ exports.todoFixmeCheck = {
51
51
  const keywords = cfg?.keywords ?? DEFAULT_KEYWORDS;
52
52
  const requireTicket = cfg?.requireTicket ?? false;
53
53
  const findings = {};
54
+ // One failed language set fails the check: a partial answer reported as a
55
+ // pass says the repository is clean when half of it was never read (072).
56
+ let reviewed = true;
54
57
  if (ctx.languages.includes('python')) {
55
58
  const pattern = buildPattern('#', keywords);
56
- await (0, findings_1.reviewLines)(ctx, ['*.py'], out => scan(out, pattern, requireTicket, findings));
59
+ reviewed =
60
+ (await (0, findings_1.reviewLines)(ctx, ['*.py'], out => scan(out, pattern, requireTicket, findings))) &&
61
+ reviewed;
57
62
  }
58
63
  if (ctx.languages.includes('kotlin')) {
59
64
  const ktPattern = buildPattern('//|/\\*', keywords);
60
- await (0, findings_1.reviewLines)(ctx, ['*.kt', '*.kts', '*.java'], out => scan(out, ktPattern, requireTicket, findings));
65
+ reviewed =
66
+ (await (0, findings_1.reviewLines)(ctx, ['*.kt', '*.kts', '*.java'], out => scan(out, ktPattern, requireTicket, findings))) && reviewed;
61
67
  }
68
+ if (!reviewed)
69
+ return (0, types_1.skip)('git unavailable');
62
70
  if ((0, findings_1.bucketTotal)(findings) === 0)
63
71
  return (0, types_1.pass)();
64
72
  return (0, findings_1.reportCountedFindings)(findings, ctx, {
@@ -6,8 +6,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.cppFormatCheck = void 0;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
+ const config_1 = require("../../../config");
9
10
  const utils_1 = require("../../../utils");
10
11
  const execa_1 = require("../../../utils/execa");
12
+ const targets_1 = require("../../targets");
11
13
  const types_1 = require("../../types");
12
14
  /**
13
15
  * C/C++ source & header extensions clang-format understands. `.h` is intentionally
@@ -27,46 +29,6 @@ function resolveStyleArg(ctx) {
27
29
  const style = ctx.config.cpp?.clangFormat?.style ?? DEFAULT_STYLE;
28
30
  return `--style=${style}`;
29
31
  }
30
- /**
31
- * Returns the C/C++ files to check. Uses the PR diff range when available
32
- * (delta mode), else scans all tracked C/C++ files. Returns an empty array
33
- * when there is nothing to check, `null` when git is unusable.
34
- */
35
- async function resolveTargets(ctx) {
36
- try {
37
- if (ctx.baseSha) {
38
- const { stdout } = await (0, execa_1.execa)('git', [
39
- 'diff',
40
- '--name-only',
41
- '--diff-filter=ACMR',
42
- ctx.baseSha,
43
- ctx.headSha ?? 'HEAD',
44
- '--',
45
- ...CPP_GLOBS,
46
- ], { cwd: ctx.cwd, reject: false });
47
- const files = stdout
48
- .split('\n')
49
- .map(f => f.trim())
50
- .filter(Boolean);
51
- utils_1.logger.info(`[C++ Format]: 📂 Delta mode: ${files.length} changed C/C++ file(s)`);
52
- return files;
53
- }
54
- const { stdout } = await (0, execa_1.execa)('git', ['ls-files', ...CPP_GLOBS], {
55
- cwd: ctx.cwd,
56
- reject: false,
57
- });
58
- const files = stdout
59
- .split('\n')
60
- .map(f => f.trim())
61
- .filter(Boolean);
62
- utils_1.logger.info(`[C++ Format]: Full scan: ${files.length} tracked C/C++ file(s)`);
63
- return files;
64
- }
65
- catch (err) {
66
- utils_1.logger.warn(`[C++ Format]: ⚠️ Could not list C/C++ files\n${err}`);
67
- return null;
68
- }
69
- }
70
32
  /** Counts distinct files flagged in clang-format's `--dry-run --Werror` diagnostics. */
71
33
  function countOffendingFiles(stderr) {
72
34
  const files = new Set();
@@ -89,7 +51,14 @@ exports.cppFormatCheck = {
89
51
  return ctx.config.cpp?.enabled !== false && ctx.languages.includes('cpp');
90
52
  },
91
53
  async run(ctx) {
92
- const targets = await resolveTargets(ctx);
54
+ // Through `targets.ts`, not `git` directly. This check used to run its own
55
+ // `git ls-files`, which is the one mistake the authoring guide exists to
56
+ // prevent: it inherits neither `ignoreDirs` nor `sourcePath`. On a real C++
57
+ // repository that meant 403 files including a vendored copy of OpenCV — and
58
+ // in `write` mode clang-format was rewriting third-party sources the client
59
+ // does not own. It also crashed the WASM formatter, which is how this was
60
+ // found.
61
+ const targets = await (0, targets_1.resolveScopedTargetFiles)(ctx, CPP_GLOBS, (0, config_1.getSourcePaths)(ctx.cwd));
93
62
  if (targets === null)
94
63
  return (0, types_1.skip)('git unavailable');
95
64
  if (targets.length === 0)
@@ -112,7 +81,15 @@ exports.cppFormatCheck = {
112
81
  });
113
82
  if (fix.exitCode !== 0) {
114
83
  utils_1.logger.warn(`[C++ Format]: ⚠️ clang-format -i failed\n${fix.stderr}`);
115
- return (0, types_1.warn)('clang-format failed to rewrite files');
84
+ // A WASM runtime error is the formatter giving up on a file, not a verdict
85
+ // about the code — and `-i` means whatever it managed before the crash is
86
+ // already written, so the tree is half-formatted. Saying which it was
87
+ // matters: "failed to rewrite files" sent people looking for a style
88
+ // problem that does not exist.
89
+ const crashed = /RuntimeError|memory access out of bounds|abort\(/.test(fix.stderr);
90
+ return (0, types_1.warn)(crashed
91
+ ? 'clang-format crashed on one of these files — some may already have been rewritten'
92
+ : `clang-format could not rewrite the files (exit ${fix.exitCode ?? 'killed'})`);
116
93
  }
117
94
  utils_1.logger.info(`[C++ Format]: ✅ Reformatted ${offending} C/C++ file(s)`);
118
95
  return (0, types_1.pass)(`reformatted ${offending} file(s)`);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Every check that runs a linter or formatter we do not ship, as a table.
3
+ *
4
+ * The engine is in `external-runner.ts`; this file is only the data. What is
5
+ * genuinely per-language turns out to be four things — the binary, its
6
+ * arguments, the files it looks at, and how to read its output — and everything
7
+ * else was the same in all eleven of the modules this replaces.
8
+ *
9
+ * Keeping the entries together rather than one per file is the point: it is now
10
+ * possible to read what nine checks do at once, and a divergence between two of
11
+ * them is visible instead of two directories apart. Adding a tenth is an entry,
12
+ * not a file.
13
+ *
14
+ * The user-facing documentation stays per check, in
15
+ * `documentation/checks/languages/`, because that is organised around the reader's
16
+ * question ("how do I configure ktlint") rather than around ours.
17
+ */
18
+ /**
19
+ * Lints changed Kotlin files with ktlint (style and formatting both, so it
20
+ * doubles as the Kotlin format signal). Uses the repo's own `.editorconfig`
21
+ * when present.
22
+ */
23
+ export declare const kotlinLintCheck: import("../..").Check;
24
+ /**
25
+ * Lints Swift with SwiftLint, which needs SourceKit and therefore a macOS
26
+ * runner. Runs over the directory rather than a file list — SwiftLint resolves
27
+ * its own `.swiftlint.yml` scope, and a file list would bypass the excludes a
28
+ * repo wrote there — so the resolved targets only decide whether it runs at all.
29
+ */
30
+ export declare const swiftLintCheck: import("../..").Check;
31
+ /**
32
+ * Lints changed Ruby files with RuboCop, which covers layout as well as style,
33
+ * so it doubles as the Ruby format signal. Uses the repo's own `.rubocop.yml`
34
+ * when present.
35
+ */
36
+ export declare const rubyLintCheck: import("../..").Check;
37
+ /**
38
+ * Lints changed shell scripts with ShellCheck. `-f gcc` gives one line per
39
+ * finding ending in an `[SC…]` code, which both a human and the count below can
40
+ * read directly.
41
+ *
42
+ * No language gate: shell is not something detection reports, and a `deploy.sh`
43
+ * in a Python repository is exactly the file this check exists for.
44
+ */
45
+ export declare const shellcheckCheck: import("../..").Check;
46
+ /**
47
+ * Vets changed Go code with `go vet ./...`, which needs the Go toolchain (the
48
+ * formatter next door no longer does — see `go-format.ts`). `go vet` operates
49
+ * per package, so it runs over the module rather than a file list; the delta
50
+ * only decides whether it runs.
51
+ */
52
+ export declare const goLintCheck: import("../..").Check;
53
+ /**
54
+ * Lints changed Rust with Clippy. It compiles the crate, so it runs over the
55
+ * crate rather than a file list; the delta only decides whether it runs.
56
+ */
57
+ export declare const rustLintCheck: import("../..").Check;
58
+ /**
59
+ * Formats changed Rust with `cargo fmt`, not a bare `rustfmt`: cargo reads the
60
+ * crate's edition from `Cargo.toml`, and rustfmt invoked directly does not —
61
+ * which silently formats 2015-edition code by 2024 rules.
62
+ */
63
+ export declare const rustFormatCheck: import("../..").Check;
64
+ /**
65
+ * Verifies C# formatting with `dotnet format`, which also runs the project's
66
+ * configured analyzers and style rules. It operates on the project or solution
67
+ * (and expects a prior `dotnet restore`), so it runs over the whole project; the
68
+ * delta only decides whether it runs.
69
+ */
70
+ export declare const csharpFormatCheck: import("../..").Check;
71
+ /**
72
+ * Formats changed PHP with PHP-CS-Fixer, using the repo's own
73
+ * `.php-cs-fixer.php` when present. `--using-cache=no` because the cache keys on
74
+ * absolute paths, and a CI checkout directory that changes between runs makes a
75
+ * stale cache report files as clean that were never examined.
76
+ */
77
+ export declare const phpFormatCheck: import("../..").Check;
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.phpFormatCheck = exports.csharpFormatCheck = exports.rustFormatCheck = exports.rustLintCheck = exports.goLintCheck = exports.shellcheckCheck = exports.rubyLintCheck = exports.swiftLintCheck = exports.kotlinLintCheck = void 0;
4
+ const external_runner_1 = require("./external-runner");
5
+ /**
6
+ * Every check that runs a linter or formatter we do not ship, as a table.
7
+ *
8
+ * The engine is in `external-runner.ts`; this file is only the data. What is
9
+ * genuinely per-language turns out to be four things — the binary, its
10
+ * arguments, the files it looks at, and how to read its output — and everything
11
+ * else was the same in all eleven of the modules this replaces.
12
+ *
13
+ * Keeping the entries together rather than one per file is the point: it is now
14
+ * possible to read what nine checks do at once, and a divergence between two of
15
+ * them is visible instead of two directories apart. Adding a tenth is an entry,
16
+ * not a file.
17
+ *
18
+ * The user-facing documentation stays per check, in
19
+ * `documentation/checks/languages/`, because that is organised around the reader's
20
+ * question ("how do I configure ktlint") rather than around ours.
21
+ */
22
+ // ── lint ────────────────────────────────────────────────────────────────────
23
+ /**
24
+ * Lints changed Kotlin files with ktlint (style and formatting both, so it
25
+ * doubles as the Kotlin format signal). Uses the repo's own `.editorconfig`
26
+ * when present.
27
+ */
28
+ exports.kotlinLintCheck = (0, external_runner_1.defineExternalLint)({
29
+ name: 'ktlint',
30
+ bin: 'ktlint',
31
+ tool: 'ktlint',
32
+ globs: ['*.kt', '*.kts'],
33
+ language: 'kotlin',
34
+ configKey: 'kotlin',
35
+ subject: 'Kotlin files',
36
+ args: (_ctx, targets) => ['--reporter=plain', ...targets],
37
+ });
38
+ /**
39
+ * Lints Swift with SwiftLint, which needs SourceKit and therefore a macOS
40
+ * runner. Runs over the directory rather than a file list — SwiftLint resolves
41
+ * its own `.swiftlint.yml` scope, and a file list would bypass the excludes a
42
+ * repo wrote there — so the resolved targets only decide whether it runs at all.
43
+ */
44
+ exports.swiftLintCheck = (0, external_runner_1.defineExternalLint)({
45
+ name: 'SwiftLint',
46
+ bin: 'swiftlint',
47
+ tool: 'swiftlint',
48
+ globs: ['*.swift'],
49
+ language: 'swift',
50
+ configKey: 'swift',
51
+ subject: 'Swift files',
52
+ args: ctx => ['lint', '--quiet', '--path', ctx.cwd],
53
+ // SwiftLint's plain output carries progress and summary lines too; only the
54
+ // diagnostics are findings, and the two levels are worth reporting apart.
55
+ keep: line => line.includes(': warning:') || line.includes(': error:'),
56
+ summary: lines => {
57
+ const errors = lines.filter(line => line.includes(': error:')).length;
58
+ return `${errors} error(s), ${lines.length - errors} warning(s)`;
59
+ },
60
+ });
61
+ /**
62
+ * Lints changed Ruby files with RuboCop, which covers layout as well as style,
63
+ * so it doubles as the Ruby format signal. Uses the repo's own `.rubocop.yml`
64
+ * when present.
65
+ */
66
+ exports.rubyLintCheck = (0, external_runner_1.defineExternalLint)({
67
+ name: 'RuboCop',
68
+ bin: 'rubocop',
69
+ tool: 'rubocop',
70
+ globs: ['*.rb', '*.rake'],
71
+ language: 'ruby',
72
+ configKey: 'ruby',
73
+ subject: 'Ruby files',
74
+ args: (_ctx, targets) => ['--format', 'simple', ...targets],
75
+ // RuboCop ends with its own count — "12 offenses detected" — which says more
76
+ // than a line tally of an output format that mixes offences and file headers.
77
+ summary: lines => lines.find(line => /offense/i.test(line))?.trim() ?? `${lines.length} line(s) of output`,
78
+ });
79
+ /**
80
+ * Lints changed shell scripts with ShellCheck. `-f gcc` gives one line per
81
+ * finding ending in an `[SC…]` code, which both a human and the count below can
82
+ * read directly.
83
+ *
84
+ * No language gate: shell is not something detection reports, and a `deploy.sh`
85
+ * in a Python repository is exactly the file this check exists for.
86
+ */
87
+ exports.shellcheckCheck = (0, external_runner_1.defineExternalLint)({
88
+ name: 'ShellCheck',
89
+ bin: 'shellcheck',
90
+ tool: 'shellcheck',
91
+ globs: ['*.sh', '*.bash', '*.ksh'],
92
+ configKey: 'shellcheck',
93
+ subject: 'shell scripts',
94
+ args: (ctx, targets) => {
95
+ const args = ['-f', 'gcc'];
96
+ const { minSeverity, ignore = [] } = ctx.config.shellcheck ?? {};
97
+ if (minSeverity)
98
+ args.push('-S', minSeverity);
99
+ if (ignore.length > 0)
100
+ args.push('-e', ignore.join(','));
101
+ return [...args, ...targets];
102
+ },
103
+ summary: (lines, targets) => `${lines.length} issue(s) in ${targets.length} file(s)`,
104
+ });
105
+ /**
106
+ * Vets changed Go code with `go vet ./...`, which needs the Go toolchain (the
107
+ * formatter next door no longer does — see `go-format.ts`). `go vet` operates
108
+ * per package, so it runs over the module rather than a file list; the delta
109
+ * only decides whether it runs.
110
+ */
111
+ exports.goLintCheck = (0, external_runner_1.defineExternalLint)({
112
+ name: 'Go Vet',
113
+ bin: 'go',
114
+ tool: 'go',
115
+ toolLabel: 'go',
116
+ globs: ['*.go'],
117
+ language: 'go',
118
+ configKey: 'go',
119
+ subject: 'Go files',
120
+ args: () => ['vet', './...'],
121
+ // Diagnostics and build errors both land on stderr.
122
+ stderrFirst: true,
123
+ summary: lines => `${lines.length} diagnostic line(s)`,
124
+ });
125
+ /**
126
+ * Lints changed Rust with Clippy. It compiles the crate, so it runs over the
127
+ * crate rather than a file list; the delta only decides whether it runs.
128
+ */
129
+ exports.rustLintCheck = (0, external_runner_1.defineExternalLint)({
130
+ name: 'Clippy',
131
+ bin: 'cargo',
132
+ tool: 'clippy',
133
+ toolLabel: 'cargo/clippy',
134
+ globs: ['*.rs'],
135
+ language: 'rust',
136
+ configKey: 'rust',
137
+ subject: 'Rust files',
138
+ args: () => ['clippy', '--quiet', '--', '-D', 'warnings'],
139
+ stderrFirst: true,
140
+ // Clippy interleaves diagnostics with compilation progress; only the lines
141
+ // that carry a level are findings.
142
+ keep: line => /\b(warning|error)\b/.test(line),
143
+ summary: lines => `${lines.length} clippy diagnostic(s)`,
144
+ });
145
+ // ── format ──────────────────────────────────────────────────────────────────
146
+ /**
147
+ * Formats changed Rust with `cargo fmt`, not a bare `rustfmt`: cargo reads the
148
+ * crate's edition from `Cargo.toml`, and rustfmt invoked directly does not —
149
+ * which silently formats 2015-edition code by 2024 rules.
150
+ */
151
+ exports.rustFormatCheck = (0, external_runner_1.defineExternalFormat)({
152
+ name: 'Rustfmt',
153
+ bin: 'cargo',
154
+ tool: 'rustfmt',
155
+ toolLabel: 'cargo',
156
+ globs: ['*.rs'],
157
+ language: 'rust',
158
+ configKey: 'rust',
159
+ subject: 'Rust files',
160
+ code: 'Rust code',
161
+ checkArgs: () => ['fmt', '--check'],
162
+ writeArgs: () => ['fmt'],
163
+ });
164
+ /**
165
+ * Verifies C# formatting with `dotnet format`, which also runs the project's
166
+ * configured analyzers and style rules. It operates on the project or solution
167
+ * (and expects a prior `dotnet restore`), so it runs over the whole project; the
168
+ * delta only decides whether it runs.
169
+ */
170
+ exports.csharpFormatCheck = (0, external_runner_1.defineExternalFormat)({
171
+ name: 'C# Format',
172
+ bin: 'dotnet',
173
+ tool: 'dotnet',
174
+ toolLabel: 'dotnet SDK',
175
+ globs: ['*.cs'],
176
+ language: 'csharp',
177
+ configKey: 'csharp',
178
+ subject: 'C# files',
179
+ code: 'C# code',
180
+ checkArgs: () => ['format', '--verify-no-changes'],
181
+ writeArgs: () => ['format'],
182
+ });
183
+ /**
184
+ * Formats changed PHP with PHP-CS-Fixer, using the repo's own
185
+ * `.php-cs-fixer.php` when present. `--using-cache=no` because the cache keys on
186
+ * absolute paths, and a CI checkout directory that changes between runs makes a
187
+ * stale cache report files as clean that were never examined.
188
+ */
189
+ exports.phpFormatCheck = (0, external_runner_1.defineExternalFormat)({
190
+ name: 'PHP CS Fixer',
191
+ bin: 'php-cs-fixer',
192
+ tool: 'php-cs-fixer',
193
+ globs: ['*.php'],
194
+ language: 'php',
195
+ configKey: 'php',
196
+ subject: 'PHP files',
197
+ code: 'PHP code',
198
+ checkArgs: targets => ['fix', '--using-cache=no', ...targets, '--dry-run'],
199
+ writeArgs: targets => ['fix', '--using-cache=no', ...targets],
200
+ });
@@ -0,0 +1,119 @@
1
+ import { Check, CheckContext, type Language } from '../../types';
2
+ import { type ExternalTool } from '../tool-install';
3
+ /**
4
+ * The one shape every check that shells out to somebody else's linter has.
5
+ *
6
+ * Eleven of these were written by copying a neighbour, and copying is what they
7
+ * had in common: `resolveTargetFiles` → `execa` → the same seven-line comment
8
+ * about `ENOENT` → split the output → log ten lines and a "… and N more". Of the
9
+ * ~600 lines that produced, about sixty said anything specific to a language.
10
+ *
11
+ * The cost of that was not the duplication. It was that each copy diverged
12
+ * slightly, so a fix landed in one of eleven places: the `git unavailable` skip
13
+ * was missing from some, the ten-line truncation counted differently in others,
14
+ * and `⏭️ ktlint: ktlint not installed` outlived four rewrites of the message it
15
+ * was supposed to match. Behaviour that is meant to be identical everywhere
16
+ * belongs in one function; what is genuinely per-language belongs in a table.
17
+ *
18
+ * Two factories rather than one, because a linter and a formatter differ in the
19
+ * only part that matters — a linter reads output, a formatter runs twice and may
20
+ * write. Folding them together would mean a spec with half its fields unused.
21
+ */
22
+ /**
23
+ * Config blocks that gate a language check with `enabled: false`.
24
+ *
25
+ * Named as a union rather than typed as `keyof PRCheckMateConfig` so a spec
26
+ * cannot point at `reporter` or `install` and typecheck.
27
+ */
28
+ type LanguageConfigKey = 'csharp' | 'go' | 'kotlin' | 'php' | 'ruby' | 'rust' | 'shellcheck' | 'swift';
29
+ /**
30
+ * A finished tool run, with "the tool is not here" as a case of its own rather
31
+ * than an exception.
32
+ */
33
+ type ToolRun = {
34
+ missing: true;
35
+ } | {
36
+ missing: false;
37
+ exitCode: number | undefined;
38
+ stdout: string;
39
+ stderr: string;
40
+ };
41
+ /**
42
+ * Run an external tool, telling an absent binary apart from a failing one.
43
+ *
44
+ * execa v9 with `reject: false` does NOT throw on a missing binary — it resolves
45
+ * with `code: 'ENOENT'`. Detecting that on the result rather than in a
46
+ * `try`/`catch` (which never fires here) is the difference between an honest skip
47
+ * and a missing linter reporting a clean pass. This comment used to be repeated
48
+ * verbatim in eleven files, which is roughly how long it takes for one of the
49
+ * copies to lose it.
50
+ */
51
+ export declare function runTool(ctx: CheckContext, bin: string, args: string[]): Promise<ToolRun>;
52
+ /** The fields both factories read; each adds the part that differs. */
53
+ interface ExternalSpecBase {
54
+ /** Display name — the report row, and the log prefix. */
55
+ name: string;
56
+ /** The binary to run. */
57
+ bin: string;
58
+ /** Key into the install-hint table, so the skip says how to fix itself. */
59
+ tool: ExternalTool;
60
+ /**
61
+ * How to name the tool in the skip. Omit when the check's own name already is
62
+ * the binary's name, or the message stutters (`ktlint: ktlint not installed`).
63
+ */
64
+ toolLabel?: string;
65
+ /** Pathspecs the check operates on, e.g. `['*.kt', '*.kts']`. */
66
+ globs: string[];
67
+ /**
68
+ * Language gate. Omit for a check that applies to any repository — ShellCheck
69
+ * runs wherever there is a `.sh`, and shell is not a detected language.
70
+ */
71
+ language?: Language;
72
+ /** Config block whose `enabled: false` switches the check off. */
73
+ configKey: LanguageConfigKey;
74
+ /** Plural noun for the nothing-to-do pass: `no Kotlin files changed`. */
75
+ subject: string;
76
+ }
77
+ /** A check that runs somebody else's linter and reports what it said. */
78
+ export interface ExternalLintSpec extends ExternalSpecBase {
79
+ /**
80
+ * Arguments for the run. Given `ctx` as well as the files because not every
81
+ * linter takes a file list — `go vet` works per package and SwiftLint per
82
+ * directory, and for those the resolved targets only decide whether to run.
83
+ */
84
+ args(ctx: CheckContext, targets: string[]): string[];
85
+ /** Which output lines are findings. Default: every non-blank line. */
86
+ keep?(line: string): boolean;
87
+ /** How to phrase the count. Default: `N issue(s)`. */
88
+ summary?(lines: string[], targets: string[]): string;
89
+ /** Read stderr ahead of stdout, for tools that diagnose there. */
90
+ stderrFirst?: boolean;
91
+ }
92
+ /** A check that runs somebody else's formatter, read-only unless `write`. */
93
+ export interface ExternalFormatSpec extends ExternalSpecBase {
94
+ /** Singular noun for the verdicts: `PHP code needs formatting`. */
95
+ code: string;
96
+ /** Arguments for the read-only pass. */
97
+ checkArgs(targets: string[]): string[];
98
+ /** Arguments for the pass that rewrites files. */
99
+ writeArgs(targets: string[]): string[];
100
+ }
101
+ /**
102
+ * Build an informational lint check from its spec.
103
+ *
104
+ * Informational for all of them, and not a field: these checks depend on a tool
105
+ * we do not ship, so on a runner without it they cannot distinguish clean code
106
+ * from unexamined code. A gate that silently stops gating is worse than no gate,
107
+ * and a client who wants one promotes the check with `severity`.
108
+ */
109
+ export declare function defineExternalLint(spec: ExternalLintSpec): Check;
110
+ /**
111
+ * Build a format check from its spec: detect, then rewrite only when asked.
112
+ *
113
+ * The two passes are deliberate. A check run must never change the tree unless
114
+ * the caller said `write`, and in `write` mode CI needs the files actually
115
+ * rewritten so it can commit them — so the read-only pass is the one that decides
116
+ * the verdict, and the second only carries it out.
117
+ */
118
+ export declare function defineExternalFormat(spec: ExternalFormatSpec): Check;
119
+ export {};