pr-checkmate 1.23.23 → 2.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 (101) hide show
  1. package/README.md +68 -15
  2. package/cspell.json +3 -8
  3. package/dist/cli.js +37 -62
  4. package/dist/config/constants.d.ts +21 -3
  5. package/dist/config/constants.js +33 -6
  6. package/dist/config/pr-checkmate-workflow.yml +33 -327
  7. package/dist/core/checks/dependencies/banned-imports.js +23 -24
  8. package/dist/core/checks/dependencies/circular-deps.js +1 -0
  9. package/dist/core/checks/dependencies/dependency-check.js +1 -0
  10. package/dist/core/checks/dependencies/grype-scan.js +1 -0
  11. package/dist/core/checks/dependencies/npm-audit.js +6 -1
  12. package/dist/core/checks/dependencies/vuln-scan.js +1 -0
  13. package/dist/core/checks/findings.d.ts +198 -19
  14. package/dist/core/checks/findings.js +474 -22
  15. package/dist/core/checks/git/custom-rules.js +21 -16
  16. package/dist/core/checks/git/diff-smell.d.ts +17 -0
  17. package/dist/core/checks/git/diff-smell.js +43 -24
  18. package/dist/core/checks/git/large-files.js +14 -2
  19. package/dist/core/checks/git/leftover-debug.js +23 -14
  20. package/dist/core/checks/git/lockfile-drift.js +2 -1
  21. package/dist/core/checks/git/merge-conflict.js +19 -8
  22. package/dist/core/checks/git/missing-tests.js +22 -6
  23. package/dist/core/checks/git/todo-fixme.js +10 -9
  24. package/dist/core/checks/languages/shellcheck.d.ts +8 -0
  25. package/dist/core/checks/languages/shellcheck.js +55 -0
  26. package/dist/core/checks/languages/typecheck.js +1 -0
  27. package/dist/core/checks/pr/pr-size-check.js +2 -1
  28. package/dist/core/checks/quality/config-validation.d.ts +14 -4
  29. package/dist/core/checks/quality/config-validation.js +119 -17
  30. package/dist/core/checks/quality/dead-code.js +1 -0
  31. package/dist/core/checks/quality/duplicate-check.js +1 -0
  32. package/dist/core/checks/quality/license-header.js +12 -2
  33. package/dist/core/checks/quality/spellcheck.d.ts +50 -0
  34. package/dist/core/checks/quality/spellcheck.js +141 -13
  35. package/dist/core/checks/quality/symlinks.d.ts +16 -0
  36. package/dist/core/checks/quality/symlinks.js +62 -0
  37. package/dist/core/checks/quality/yaml-lint.js +22 -9
  38. package/dist/core/checks/security/diff-security.js +111 -28
  39. package/dist/core/checks/security/dockerfile-security.js +23 -20
  40. package/dist/core/checks/security/migration-safety.js +19 -17
  41. package/dist/core/checks/security/sensitive-file-guard.js +37 -5
  42. package/dist/core/checks/security/workflow-security.d.ts +10 -0
  43. package/dist/core/checks/security/workflow-security.js +48 -26
  44. package/dist/core/commands.d.ts +27 -0
  45. package/dist/core/commands.js +91 -0
  46. package/dist/core/context.d.ts +12 -3
  47. package/dist/core/context.js +118 -16
  48. package/dist/core/install/cache.d.ts +39 -0
  49. package/dist/core/install/cache.js +99 -0
  50. package/dist/core/install/doctor.d.ts +35 -0
  51. package/dist/core/install/doctor.js +145 -0
  52. package/dist/core/install/ensure.d.ts +60 -0
  53. package/dist/core/install/ensure.js +169 -0
  54. package/dist/core/install/lock.d.ts +33 -0
  55. package/dist/core/install/lock.js +99 -0
  56. package/dist/core/install/profiles.d.ts +64 -0
  57. package/dist/core/install/profiles.js +170 -0
  58. package/dist/core/registry.js +4 -0
  59. package/dist/core/reporters.d.ts +13 -2
  60. package/dist/core/reporters.js +145 -104
  61. package/dist/core/run-checks.js +91 -14
  62. package/dist/core/targets.js +26 -4
  63. package/dist/core/types.d.ts +49 -1
  64. package/dist/init.d.ts +29 -14
  65. package/dist/init.js +204 -262
  66. package/dist/interfaces/index.d.ts +130 -0
  67. package/dist/scripts/generate-install-manifests.d.ts +39 -0
  68. package/dist/scripts/generate-install-manifests.js +121 -0
  69. package/dist/scripts/publish-github.js +18 -4
  70. package/dist/utils/detect-language.d.ts +37 -4
  71. package/dist/utils/detect-language.js +113 -19
  72. package/dist/utils/http.d.ts +59 -0
  73. package/dist/utils/http.js +119 -0
  74. package/dist/utils/logger.d.ts +52 -6
  75. package/dist/utils/logger.js +125 -33
  76. package/dist/utils/pool.d.ts +49 -0
  77. package/dist/utils/pool.js +124 -0
  78. package/dist/utils/update-notifier.d.ts +9 -0
  79. package/dist/utils/update-notifier.js +57 -3
  80. package/eslint.config.mjs +14 -2
  81. package/install-manifests/commitlint/package-lock.json +1028 -0
  82. package/install-manifests/commitlint/package.json +9 -0
  83. package/install-manifests/core/package-lock.json +1335 -0
  84. package/install-manifests/core/package.json +11 -0
  85. package/install-manifests/cpp/package-lock.json +29 -0
  86. package/install-manifests/cpp/package.json +8 -0
  87. package/install-manifests/duplicate/package-lock.json +113 -0
  88. package/install-manifests/duplicate/package.json +8 -0
  89. package/install-manifests/js/package-lock.json +5099 -0
  90. package/install-manifests/js/package.json +26 -0
  91. package/install-manifests/markdown/package-lock.json +1281 -0
  92. package/install-manifests/markdown/package.json +8 -0
  93. package/install-manifests/python/package-lock.json +21 -0
  94. package/install-manifests/python/package.json +8 -0
  95. package/install-manifests/spellcheck/package-lock.json +1047 -0
  96. package/install-manifests/spellcheck/package.json +8 -0
  97. package/package.json +13 -28
  98. package/pr-checkmate.schema.json +1070 -96
  99. package/src/config/cspell.json +3 -8
  100. package/src/config/pr-checkmate-workflow.yml +33 -327
  101. package/src/config/pr-checkmate.schema.json +1070 -96
@@ -1,44 +1,63 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.diffSmellCheck = void 0;
3
+ exports.diffSmellCheck = exports.COMMENT_SMELLS = exports.CODE_SMELLS = void 0;
4
4
  const utils_1 = require("../../../utils");
5
5
  const types_1 = require("../../types");
6
6
  const findings_1 = require("../findings");
7
- const SMELL_PATTERNS = [
7
+ /**
8
+ * Smells that are only smells in live code.
9
+ *
10
+ * `view: 'code'` blanks string literals before matching, which is what tells a
11
+ * `debugger` statement apart from the seven places an ESLint config names the
12
+ * rule that bans it — `'no-debugger': 'error'` was reported as a leftover
13
+ * `debugger` in every repo that configures the rule, this one included.
14
+ * Comment stripping (the default) covers the other half: prose *about* a
15
+ * `debugger` is not one.
16
+ */
17
+ exports.CODE_SMELLS = [
8
18
  {
9
- pattern: /^\+(?!\+)(?:[^/]|\/[^/]).*\bconsole\.(log|error|warn|debug|info)\s*\(/,
19
+ pattern: /^\+(?!\+).*\bconsole\.(log|error|warn|debug|info)\s*\(/,
10
20
  label: 'console.*()',
21
+ severity: 'warn',
22
+ view: 'code',
11
23
  },
12
- { pattern: /^\+(?!\+).*\bdebugger\b/, label: 'debugger statement' },
13
- { pattern: /^\+\s*\/\/\s*TODO\b/i, label: 'TODO comment' },
14
- { pattern: /^\+\s*\/\/\s*FIXME\b/i, label: 'FIXME comment' },
15
- { pattern: /^\+\s*\/\/\s*HACK\b/i, label: 'HACK comment' },
16
- { pattern: /^\+\s*\/\/\s*XXX\b/i, label: 'XXX marker' },
24
+ {
25
+ pattern: /^\+(?!\+).*\bdebugger\b/,
26
+ label: 'debugger statement',
27
+ severity: 'warn',
28
+ view: 'code',
29
+ },
30
+ ];
31
+ /**
32
+ * Markers that live *in* comments, so this set is scanned with comment handling
33
+ * off — stripping comments first would leave these patterns nothing to match.
34
+ */
35
+ exports.COMMENT_SMELLS = [
36
+ { pattern: /^\+\s*\/\/\s*TODO\b/i, label: 'TODO comment', severity: 'warn' },
37
+ { pattern: /^\+\s*\/\/\s*FIXME\b/i, label: 'FIXME comment', severity: 'warn' },
38
+ { pattern: /^\+\s*\/\/\s*HACK\b/i, label: 'HACK comment', severity: 'warn' },
39
+ { pattern: /^\+\s*\/\/\s*XXX\b/i, label: 'XXX marker', severity: 'warn' },
17
40
  ];
18
41
  exports.diffSmellCheck = {
19
42
  name: 'Diff Smells',
20
43
  phase: 'informational',
44
+ // No `requiresDiff`: a leftover `debugger` is leftover whether it arrived in
45
+ // this PR or an earlier one (ticket 067).
21
46
  applies(ctx) {
22
- return ctx.config.diffSmell?.enabled !== false && Boolean(ctx.baseSha);
47
+ return ctx.config.diffSmell?.enabled !== false;
23
48
  },
24
49
  async run(ctx) {
25
50
  try {
26
- const stdout = await (0, findings_1.diffAddedLines)(ctx, ['*.ts', '*.tsx', '*.js', '*.jsx']);
27
- const findings = {};
28
- for (const line of stdout.split('\n')) {
29
- for (const { pattern, label } of SMELL_PATTERNS) {
30
- if (pattern.test(line)) {
31
- (findings[label] ?? (findings[label] = [])).push(line.slice(1).trim().substring(0, 100));
32
- }
33
- }
34
- }
35
- if ((0, findings_1.bucketTotal)(findings) === 0)
51
+ const { ignorePaths } = ctx.config.diffSmell ?? {};
52
+ const found = (0, findings_1.emptyBuckets)();
53
+ await (0, findings_1.reviewLines)(ctx, ['*.ts', '*.tsx', '*.js', '*.jsx'], stdout => {
54
+ (0, findings_1.mergeSeverityBuckets)(found, (0, findings_1.scanDiffPatterns)(stdout, exports.CODE_SMELLS, { ignorePaths }));
55
+ (0, findings_1.mergeSeverityBuckets)(found, (0, findings_1.scanDiffPatterns)(stdout, exports.COMMENT_SMELLS, { comments: 'off', ignorePaths }));
56
+ });
57
+ // Every smell here is declared `warn`, so nothing reaches the error bucket.
58
+ if ((0, findings_1.bucketTotal)(found.warnings) === 0)
36
59
  return (0, types_1.pass)();
37
- (0, findings_1.logFindings)(findings, { prefix: 'Diff Smells', sampleLimit: 3 });
38
- const summary = Object.entries(findings)
39
- .map(([label, arr]) => `${arr.length}× ${label}`)
40
- .join(', ');
41
- return (0, types_1.warn)(summary);
60
+ return (0, findings_1.reportCountedFindings)(found.warnings, ctx, { prefix: 'Diff Smells' });
42
61
  }
43
62
  catch (err) {
44
63
  utils_1.logger.warn(`[Diff Smells]: ⚠️ Could not analyze diff\n${err}`);
@@ -53,8 +53,12 @@ function binaryPaths(numstat) {
53
53
  exports.largeFilesCheck = {
54
54
  name: 'Large Files',
55
55
  phase: 'informational',
56
+ // No `requiresDiff`: a committed 40 MB binary bloats every clone whether it
57
+ // landed in this PR or two years ago (ticket 067). `resolveTargetFiles` already
58
+ // falls back to `git ls-files` with no range, so the file list is right either
59
+ // way; only the binary-detection diff below needed a second path.
56
60
  applies(ctx) {
57
- return ctx.config.largeFiles?.enabled !== false && Boolean(ctx.baseSha);
61
+ return ctx.config.largeFiles?.enabled !== false;
58
62
  },
59
63
  async run(ctx) {
60
64
  // resolveTargetFiles (not the scoped variant): a bloat guard should catch
@@ -69,9 +73,17 @@ exports.largeFilesCheck = {
69
73
  const maxKb = ctx.config.largeFiles?.maxSizeKb ?? DEFAULT_MAX_KB;
70
74
  const maxBytes = maxKb * 1024;
71
75
  const allow = [...LOCKFILE_ALLOW, ...(ctx.config.largeFiles?.allow ?? [])];
76
+ if (!ctx.baseSha && ctx.config.run?.fullScanBehaviour === 'skip')
77
+ return (0, types_1.pass)();
72
78
  let binary;
73
79
  try {
74
- const { stdout } = await (0, execa_1.execa)('git', ['diff', '--numstat', '--diff-filter=ACM', ctx.baseSha, ctx.headSha ?? 'HEAD'], { cwd: ctx.cwd });
80
+ // `--numstat` reports `-\t-` for binary files. With no range there is
81
+ // nothing to diff, so binary detection falls back to reading the bytes —
82
+ // the size check is what matters here, and it works either way.
83
+ const args = ctx.baseSha
84
+ ? ['diff', '--numstat', '--diff-filter=ACM', ctx.baseSha, ctx.headSha ?? 'HEAD']
85
+ : null;
86
+ const { stdout } = args ? await (0, execa_1.execa)('git', args, { cwd: ctx.cwd }) : { stdout: '' };
75
87
  binary = binaryPaths(stdout);
76
88
  }
77
89
  catch (err) {
@@ -21,15 +21,29 @@ const KOTLIN_PATTERNS = [
21
21
  // negative lookbehind rejects a preceding `.` or identifier char.
22
22
  { pattern: /^\+(?!\+).*(?<![.\w])println\s*\(/, label: 'println()' },
23
23
  ];
24
- /** Bucket added-line matches by label, skipping lines opted out via directive. */
25
- function collect(stdout, patterns, findings) {
24
+ /** `+++ b/path/to/file.py` — the header that says which file follows. */
25
+ const DIFF_FILE_HEADER = /^\+\+\+ b\/(.+)$/;
26
+ /**
27
+ * Bucket added-line matches by label, skipping lines opted out via directive.
28
+ *
29
+ * Comment stripping follows the file each hunk belongs to (ticket 059). It
30
+ * matters here as much as in Diff Security: `#` is a comment in Python but a
31
+ * private field in Kotlin/Java-adjacent syntax, and this check scans both.
32
+ */
33
+ function collect(stdout, patterns, findings, mode = 'language') {
34
+ let syntax = 'both';
26
35
  for (const line of stdout.split('\n')) {
36
+ const header = DIFF_FILE_HEADER.exec(line);
37
+ if (header) {
38
+ syntax = mode === 'language' ? (0, findings_1.commentSyntaxFor)(header[1]) : 'both';
39
+ continue;
40
+ }
27
41
  // Honour an inline opt-out in any comment style (`// pr-checkmate-ignore`,
28
42
  // `# pr-checkmate-ignore`).
29
43
  if (line.includes('pr-checkmate-ignore'))
30
44
  continue;
31
45
  // Match on the comment-stripped line so `# print(x)` in a comment isn't flagged.
32
- const scanned = (0, findings_1.stripInlineComment)(line);
46
+ const scanned = mode === 'off' ? line : (0, findings_1.stripInlineComment)(line, syntax);
33
47
  for (const { pattern, label } of patterns) {
34
48
  if (pattern.test(scanned)) {
35
49
  (findings[label] ?? (findings[label] = [])).push(line.slice(1).trim().substring(0, 100));
@@ -40,34 +54,29 @@ function collect(stdout, patterns, findings) {
40
54
  exports.leftoverDebugCheck = {
41
55
  name: 'Leftover Debug',
42
56
  phase: 'informational',
57
+ // No `requiresDiff`: a stray `print()` is stray wherever it sits (ticket 067).
43
58
  applies(ctx) {
44
59
  return (ctx.config.leftoverDebug?.enabled !== false &&
45
- Boolean(ctx.baseSha) &&
46
60
  (ctx.languages.includes('python') || ctx.languages.includes('kotlin')));
47
61
  },
48
62
  async run(ctx) {
49
63
  try {
50
64
  const findings = {};
65
+ const comments = ctx.config.leftoverDebug?.commentHandling ?? 'language';
51
66
  if (ctx.languages.includes('python')) {
52
- collect(await (0, findings_1.diffAddedLines)(ctx, ['*.py']), PYTHON_PATTERNS, findings);
67
+ await (0, findings_1.reviewLines)(ctx, ['*.py'], out => collect(out, PYTHON_PATTERNS, findings, comments));
53
68
  }
54
69
  if (ctx.languages.includes('kotlin')) {
55
- collect(await (0, findings_1.diffAddedLines)(ctx, ['*.kt', '*.kts', '*.java']), KOTLIN_PATTERNS, findings);
70
+ await (0, findings_1.reviewLines)(ctx, ['*.kt', '*.kts', '*.java'], out => collect(out, KOTLIN_PATTERNS, findings, comments));
56
71
  }
57
72
  if ((0, findings_1.bucketTotal)(findings) === 0)
58
73
  return (0, types_1.pass)();
59
74
  // Advisory by default; a repo may promote to a gate via
60
75
  // `leftoverDebug.severity: 'error'`.
61
- const sev = (0, findings_1.severityEmitter)(ctx.config.leftoverDebug?.severity, 'warn');
62
- (0, findings_1.logFindings)(findings, {
76
+ return (0, findings_1.reportCountedFindings)(findings, ctx, {
63
77
  prefix: 'Leftover Debug',
64
- sampleLimit: 3,
65
- isError: sev.icon === '❌',
78
+ severity: ctx.config.leftoverDebug?.severity,
66
79
  });
67
- const summary = Object.entries(findings)
68
- .map(([label, arr]) => `${arr.length}× ${label}`)
69
- .join(', ');
70
- return sev.emit(summary);
71
80
  }
72
81
  catch (err) {
73
82
  utils_1.logger.warn(`[Leftover Debug]: ⚠️ Could not analyze diff\n${err}`);
@@ -64,8 +64,9 @@ async function packageDepsChanged(ctx, manifestPath, base) {
64
64
  exports.lockfileDriftCheck = {
65
65
  name: 'Lockfile Drift',
66
66
  phase: 'informational',
67
+ requiresDiff: true,
67
68
  applies(ctx) {
68
- return ctx.config.lockfileDrift?.enabled !== false && Boolean(ctx.baseSha);
69
+ return ctx.config.lockfileDrift?.enabled !== false;
69
70
  },
70
71
  async run(ctx) {
71
72
  try {
@@ -70,8 +70,12 @@ function filterHits(hits) {
70
70
  exports.mergeConflictCheck = {
71
71
  name: 'Merge Conflict',
72
72
  phase: 'blocking',
73
+ // No `requiresDiff`: a committed conflict marker is broken code whether it
74
+ // arrived in this PR or survived an earlier bad merge (ticket 067). This is
75
+ // the check most worth running over a whole repository, since a marker that
76
+ // slipped through once stays there silently.
73
77
  applies(ctx) {
74
- return ctx.config.mergeConflict?.enabled !== false && Boolean(ctx.baseSha);
78
+ return ctx.config.mergeConflict?.enabled !== false;
75
79
  },
76
80
  async run(ctx) {
77
81
  // Resolve the scoped file set the canonical way (delta-aware, honouring the
@@ -84,22 +88,29 @@ exports.mergeConflictCheck = {
84
88
  if (files.length === 0)
85
89
  return (0, types_1.pass)();
86
90
  try {
87
- const { stdout } = await (0, execa_1.execa)('git', ['diff', '--unified=0', ctx.baseSha, ctx.headSha ?? 'HEAD', '--', ...files], { cwd: ctx.cwd });
88
- const hits = filterHits(scanDiff(stdout));
89
- if (hits.length === 0)
91
+ const hits = [];
92
+ if (ctx.baseSha) {
93
+ const { stdout } = await (0, execa_1.execa)('git', ['diff', '--unified=0', ctx.baseSha, ctx.headSha ?? 'HEAD', '--', ...files], { cwd: ctx.cwd });
94
+ hits.push(...scanDiff(stdout));
95
+ }
96
+ else if (ctx.config.run?.fullScanBehaviour !== 'skip') {
97
+ await (0, findings_1.scanTrackedFiles)(ctx, files, asDiff => hits.push(...scanDiff(asDiff)));
98
+ }
99
+ const found = filterHits(hits);
100
+ if (found.length === 0)
90
101
  return (0, types_1.pass)();
91
102
  // Conflict markers are a hard error by default; a repo may downgrade to
92
103
  // `warn` via `mergeConflict.severity`.
93
104
  const sev = (0, findings_1.severityEmitter)(ctx.config.mergeConflict?.severity, 'error');
94
- for (const h of hits) {
105
+ for (const h of found) {
95
106
  sev.log(`[Merge Conflict]: ${sev.icon} ${h.file}:${h.line}`);
96
107
  }
97
- const sample = hits
108
+ const sample = found
98
109
  .slice(0, 5)
99
110
  .map(h => `${h.file}:${h.line}`)
100
111
  .join(', ');
101
- const more = hits.length > 5 ? ` (+${hits.length - 5} more)` : '';
102
- return sev.emit(`${hits.length} unresolved conflict marker(s): ${sample}${more}`);
112
+ const more = found.length > 5 ? ` (+${found.length - 5} more)` : '';
113
+ return sev.emit(` unresolved conflict marker(s): ${sample}${more}`);
103
114
  }
104
115
  catch (err) {
105
116
  utils_1.logger.warn(`[Merge Conflict]: ⚠️ Could not analyze diff\n${err}`);
@@ -38,8 +38,9 @@ const DEFAULT_IGNORE = [
38
38
  exports.missingTestsCheck = {
39
39
  name: 'Missing Tests',
40
40
  phase: 'informational',
41
+ requiresDiff: true,
41
42
  applies(ctx) {
42
- return ctx.config.missingTests?.enabled !== false && Boolean(ctx.baseSha);
43
+ return ctx.config.missingTests?.enabled !== false;
43
44
  },
44
45
  async run(ctx) {
45
46
  // Scope the same way lint does — a client's `sourcePath` and `ignoreDirs`
@@ -63,10 +64,24 @@ exports.missingTestsCheck = {
63
64
  continue;
64
65
  const [status, ...rest] = line.split('\t');
65
66
  const file = rest.join('\t');
66
- if (!file || !scoped.has(file))
67
+ if (!file)
67
68
  continue;
68
- touched.push(file);
69
- if (status === 'A')
69
+ // The two halves are scoped differently on purpose.
70
+ //
71
+ // What counts as *new source needing a test* stays inside `sourcePath`,
72
+ // so generated or out-of-scope directories never raise the question.
73
+ //
74
+ // What counts as *evidence a test was written* must not: tests
75
+ // conventionally live OUTSIDE the source directory — `tests/`,
76
+ // `__tests__/`, `spec/` at the repository root. Scoping this side too
77
+ // made the check unsatisfiable in the most ordinary layout there is
78
+ // (`sourcePath: "src"` with tests in `tests/`): no test change was ever
79
+ // visible, so the warning fired on every run however many tests were
80
+ // added with the code. A check that cannot be satisfied teaches people
81
+ // to ignore it, which costs more than the check is worth.
82
+ if (isTestFile(file))
83
+ touched.push(file);
84
+ if (status === 'A' && scoped.has(file))
70
85
  added.push(file);
71
86
  }
72
87
  }
@@ -78,8 +93,9 @@ exports.missingTestsCheck = {
78
93
  const addedSource = added.filter(f => SOURCE_EXT.test(f) && !isTestFile(f) && !(0, utils_1.matchesAnyGlob)(f, ignore));
79
94
  if (addedSource.length === 0)
80
95
  return (0, types_1.pass)(); // no new source that would need a test
81
- // A test added OR modified anywhere in scope satisfies the check.
82
- if (touched.some(isTestFile))
96
+ // A test added or modified anywhere in the diff satisfies the check —
97
+ // `touched` only ever holds test files, collected without the source scope.
98
+ if (touched.length > 0)
83
99
  return (0, types_1.pass)();
84
100
  const sev = (0, findings_1.severityEmitter)(ctx.config.missingTests?.severity, 'warn');
85
101
  addedSource
@@ -39,9 +39,10 @@ function scan(stdout, pattern, requireTicket, findings) {
39
39
  exports.todoFixmeCheck = {
40
40
  name: 'TODO/FIXME',
41
41
  phase: 'informational',
42
+ // No `requiresDiff`: an untracked TODO is untracked regardless of its age
43
+ // (ticket 067). On a legacy codebase this is exactly the inventory question.
42
44
  applies(ctx) {
43
45
  return (ctx.config.todoFixme?.enabled !== false &&
44
- Boolean(ctx.baseSha) &&
45
46
  (ctx.languages.includes('python') || ctx.languages.includes('kotlin')));
46
47
  },
47
48
  async run(ctx) {
@@ -51,20 +52,20 @@ exports.todoFixmeCheck = {
51
52
  const requireTicket = cfg?.requireTicket ?? false;
52
53
  const findings = {};
53
54
  if (ctx.languages.includes('python')) {
54
- scan(await (0, findings_1.diffAddedLines)(ctx, ['*.py']), buildPattern('#', keywords), requireTicket, findings);
55
+ const pattern = buildPattern('#', keywords);
56
+ await (0, findings_1.reviewLines)(ctx, ['*.py'], out => scan(out, pattern, requireTicket, findings));
55
57
  }
56
58
  if (ctx.languages.includes('kotlin')) {
57
59
  const ktPattern = buildPattern('//|/\\*', keywords);
58
- scan(await (0, findings_1.diffAddedLines)(ctx, ['*.kt', '*.kts', '*.java']), ktPattern, requireTicket, findings);
60
+ await (0, findings_1.reviewLines)(ctx, ['*.kt', '*.kts', '*.java'], out => scan(out, ktPattern, requireTicket, findings));
59
61
  }
60
62
  if ((0, findings_1.bucketTotal)(findings) === 0)
61
63
  return (0, types_1.pass)();
62
- const sev = (0, findings_1.severityEmitter)(cfg?.severity, 'warn');
63
- (0, findings_1.logFindings)(findings, { prefix: 'TODO/FIXME', sampleLimit: 3, isError: sev.icon === '❌' });
64
- const summary = Object.entries(findings)
65
- .map(([label, arr]) => `${arr.length}× ${label}`)
66
- .join(', ') + (requireTicket ? ' (no ticket reference)' : '');
67
- return sev.emit(summary);
64
+ return (0, findings_1.reportCountedFindings)(findings, ctx, {
65
+ prefix: 'TODO/FIXME',
66
+ severity: cfg?.severity,
67
+ suffix: requireTicket ? ' (no ticket reference)' : '',
68
+ });
68
69
  }
69
70
  catch (err) {
70
71
  utils_1.logger.warn(`[TODO/FIXME]: ⚠️ Could not analyze diff\n${err}`);
@@ -0,0 +1,8 @@
1
+ import { Check } from '../../types';
2
+ /**
3
+ * Lints changed shell scripts with ShellCheck. Runner-dependency: needs the
4
+ * `shellcheck` binary on the runner (like Go Vet/Clippy/RuboCop), gracefully
5
+ * skipping when it's absent. `-f gcc` gives one line per finding, ending in
6
+ * an `[SC…]` rule code, which both a human and the count below can read directly.
7
+ */
8
+ export declare const shellcheckCheck: Check;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.shellcheckCheck = void 0;
4
+ const utils_1 = require("../../../utils");
5
+ const execa_1 = require("../../../utils/execa");
6
+ const targets_1 = require("../../targets");
7
+ const types_1 = require("../../types");
8
+ const SHELL_GLOBS = ['*.sh', '*.bash', '*.ksh'];
9
+ /**
10
+ * Lints changed shell scripts with ShellCheck. Runner-dependency: needs the
11
+ * `shellcheck` binary on the runner (like Go Vet/Clippy/RuboCop), gracefully
12
+ * skipping when it's absent. `-f gcc` gives one line per finding, ending in
13
+ * an `[SC…]` rule code, which both a human and the count below can read directly.
14
+ */
15
+ exports.shellcheckCheck = {
16
+ name: 'ShellCheck',
17
+ phase: 'informational',
18
+ applies(ctx) {
19
+ return ctx.config.shellcheck?.enabled !== false;
20
+ },
21
+ async run(ctx) {
22
+ const targets = await (0, targets_1.resolveTargetFiles)(ctx, SHELL_GLOBS);
23
+ if (targets === null)
24
+ return (0, types_1.skip)('git unavailable');
25
+ if (targets.length === 0)
26
+ return (0, types_1.pass)('no shell scripts changed');
27
+ const minSeverity = ctx.config.shellcheck?.minSeverity;
28
+ const ignore = ctx.config.shellcheck?.ignore ?? [];
29
+ const args = ['-f', 'gcc'];
30
+ if (minSeverity)
31
+ args.push('-S', minSeverity);
32
+ if (ignore.length > 0)
33
+ args.push('-e', ignore.join(','));
34
+ args.push(...targets);
35
+ const result = await (0, execa_1.execa)('shellcheck', args, { cwd: ctx.cwd, reject: false });
36
+ // execa v9 with reject:false does NOT throw on a missing binary — it resolves
37
+ // with `code: 'ENOENT'`. Detect that on the result, not via try/catch.
38
+ if (result.code === 'ENOENT')
39
+ return (0, types_1.skip)('shellcheck not installed');
40
+ if (result.exitCode === 0) {
41
+ utils_1.logger.info('[ShellCheck]: ✅ No issues found');
42
+ return (0, types_1.pass)();
43
+ }
44
+ const lines = [result.stdout, result.stderr]
45
+ .filter(Boolean)
46
+ .join('\n')
47
+ .split('\n')
48
+ .filter(Boolean);
49
+ utils_1.logger.warn(`[ShellCheck]: ⚠️ ${lines.length} issue(s):`);
50
+ lines.slice(0, 10).forEach(l => utils_1.logger.warn(` ${l}`));
51
+ if (lines.length > 10)
52
+ utils_1.logger.warn(` ... and ${lines.length - 10} more`);
53
+ return (0, types_1.warn)(`${lines.length} issue(s) in ${targets.length} file(s)`);
54
+ },
55
+ };
@@ -24,6 +24,7 @@ function resolveTsc(cwd) {
24
24
  exports.typecheckCheck = {
25
25
  name: 'TypeScript',
26
26
  phase: 'blocking',
27
+ weight: 'heavy',
27
28
  applies(ctx) {
28
29
  if (ctx.config.typecheck?.enabled === false)
29
30
  return false;
@@ -9,8 +9,9 @@ const DEFAULT_MAX_LINES = 1000;
9
9
  exports.prSizeCheck = {
10
10
  name: 'PR Size',
11
11
  phase: 'informational',
12
+ requiresDiff: true,
12
13
  applies(ctx) {
13
- return ctx.config.prSize?.enabled !== false && Boolean(ctx.baseSha);
14
+ return ctx.config.prSize?.enabled !== false;
14
15
  },
15
16
  async run(ctx) {
16
17
  const maxFiles = ctx.config.prSize?.maxFiles ?? DEFAULT_MAX_FILES;
@@ -1,8 +1,18 @@
1
1
  import { Check } from '../../types';
2
2
  /**
3
- * Validates pr-checkmate.json itself: warns on unknown top-level keys (typos
4
- * like `mergeConfict` that silently do nothing) and on invalid values in the
5
- * `severity` map. Reads the raw file so it checks what the user actually wrote,
6
- * not the merged defaults. `//`-comment keys and `$schema` are ignored.
3
+ * The closest real name to `input`, when one is close enough to be a typo.
4
+ *
5
+ * "unknown check" alone leaves someone rereading their config for the missing
6
+ * character; naming the intended check ends the search. The threshold scales
7
+ * with length so short names need an exact-ish match and long ones tolerate a
8
+ * slip or two.
9
+ */
10
+ export declare function suggestName(input: string, candidates: string[]): string | null;
11
+ /**
12
+ * Validates pr-checkmate.json itself: unknown top-level keys (typos like
13
+ * `mergeConfict` that silently do nothing), invalid values in `severity` and
14
+ * `failOn`, and names in `severity` that match no check. Reads the raw file so it
15
+ * checks what the user actually wrote, not the merged defaults. `//`-comment keys
16
+ * and `$schema` are ignored.
7
17
  */
8
18
  export declare const configValidationCheck: Check;
@@ -4,17 +4,113 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.configValidationCheck = void 0;
7
+ exports.suggestName = suggestName;
7
8
  const node_fs_1 = __importDefault(require("node:fs"));
8
9
  const node_path_1 = __importDefault(require("node:path"));
9
10
  const config_1 = require("../../../config");
10
- const utils_1 = require("../../../utils");
11
11
  const types_1 = require("../../types");
12
+ const findings_1 = require("../findings");
12
13
  const VALID_SEVERITIES = ['error', 'warn', 'off'];
14
+ const VALID_FAIL_ON = ['error', 'warn', 'never'];
15
+ /** Edit distance, capped: we only care whether two names are one or two typos apart. */
16
+ function distance(a, b) {
17
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
18
+ for (let j = 0; j <= b.length; j++)
19
+ rows[0][j] = j;
20
+ for (let i = 1; i <= a.length; i++) {
21
+ for (let j = 1; j <= b.length; j++) {
22
+ const cost = a[i - 1].toLowerCase() === b[j - 1].toLowerCase() ? 0 : 1;
23
+ rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
24
+ }
25
+ }
26
+ return rows[a.length][b.length];
27
+ }
28
+ /**
29
+ * The closest real name to `input`, when one is close enough to be a typo.
30
+ *
31
+ * "unknown check" alone leaves someone rereading their config for the missing
32
+ * character; naming the intended check ends the search. The threshold scales
33
+ * with length so short names need an exact-ish match and long ones tolerate a
34
+ * slip or two.
35
+ */
36
+ function suggestName(input, candidates) {
37
+ let best = null;
38
+ for (const name of candidates) {
39
+ const d = distance(input, name);
40
+ if (!best || d < best.d)
41
+ best = { name, d };
42
+ }
43
+ const limit = Math.max(2, Math.floor(input.length / 4));
44
+ return best && best.d <= limit ? best.name : null;
45
+ }
46
+ /** Describe a bad value without dumping an entire nested object into the report. */
47
+ function shortValue(value) {
48
+ const text = JSON.stringify(value);
49
+ return text === undefined ? String(value) : text.slice(0, 40);
50
+ }
13
51
  /**
14
- * Validates pr-checkmate.json itself: warns on unknown top-level keys (typos
15
- * like `mergeConfict` that silently do nothing) and on invalid values in the
16
- * `severity` map. Reads the raw file so it checks what the user actually wrote,
17
- * not the merged defaults. `//`-comment keys and `$schema` are ignored.
52
+ * Validate the `severity` map: values must be a known level, and — more
53
+ * importantly — keys must name a check that exists.
54
+ *
55
+ * A typo here is the worst kind of silent failure in the whole config. `severity`
56
+ * is how a client promotes an advisory check to a gate, so `"Diff Securty":
57
+ * "error"` reads as "this now blocks merges", does nothing at all, and leaves a
58
+ * green pipeline that looks like proof it worked.
59
+ *
60
+ * The misspelling above is the example, so it is muted here rather than added to
61
+ * the dictionary — a global entry would wave through every real "Securty" typo
62
+ * in the repo, which is the exact failure this function exists to catch.
63
+ * cspell:ignore Securty
64
+ */
65
+ function validateSeverity(json, checkNames) {
66
+ const issues = [];
67
+ const sev = json.severity;
68
+ if (!sev || typeof sev !== 'object')
69
+ return issues;
70
+ for (const [name, val] of Object.entries(sev)) {
71
+ if (!VALID_SEVERITIES.includes(val)) {
72
+ issues.push(`severity["${name}"] must be error|warn|off (got ${shortValue(val)})`);
73
+ }
74
+ if (!checkNames.includes(name)) {
75
+ const hint = suggestName(name, checkNames);
76
+ issues.push(`severity["${name}"] names no check${hint ? ` — did you mean "${hint}"?` : ''}` +
77
+ ' (it has no effect)');
78
+ }
79
+ }
80
+ return issues;
81
+ }
82
+ /** `failOn` and `severityOverrides` are new and easy to mistype; check them too. */
83
+ function validateFailOn(json) {
84
+ const issues = [];
85
+ const check = (where, block) => {
86
+ if (!block || typeof block !== 'object')
87
+ return;
88
+ const { failOn, severityOverrides } = block;
89
+ if (failOn !== undefined && !VALID_FAIL_ON.includes(failOn)) {
90
+ issues.push(`${where}.failOn must be error|warn|never (got ${shortValue(failOn)})`);
91
+ }
92
+ if (severityOverrides && typeof severityOverrides === 'object') {
93
+ for (const [label, level] of Object.entries(severityOverrides)) {
94
+ if (!VALID_SEVERITIES.includes(level)) {
95
+ issues.push(`${where}.severityOverrides["${label}"] must be error|warn|off (got ${shortValue(level)})`);
96
+ }
97
+ }
98
+ }
99
+ };
100
+ check('severityDefaults', json.severityDefaults);
101
+ for (const key of Object.keys(json)) {
102
+ if (key.startsWith('//') || key === 'severityDefaults')
103
+ continue;
104
+ check(key, json[key]);
105
+ }
106
+ return issues;
107
+ }
108
+ /**
109
+ * Validates pr-checkmate.json itself: unknown top-level keys (typos like
110
+ * `mergeConfict` that silently do nothing), invalid values in `severity` and
111
+ * `failOn`, and names in `severity` that match no check. Reads the raw file so it
112
+ * checks what the user actually wrote, not the merged defaults. `//`-comment keys
113
+ * and `$schema` are ignored.
18
114
  */
19
115
  exports.configValidationCheck = {
20
116
  name: 'Config Validation',
@@ -23,6 +119,7 @@ exports.configValidationCheck = {
23
119
  return ctx.config.configValidation?.enabled !== false;
24
120
  },
25
121
  async run(ctx) {
122
+ const cfg = ctx.config.configValidation ?? {};
26
123
  const file = node_path_1.default.join(ctx.cwd, 'pr-checkmate.json');
27
124
  let raw;
28
125
  try {
@@ -40,24 +137,29 @@ exports.configValidationCheck = {
40
137
  return (0, types_1.warn)(`pr-checkmate.json is not valid JSON: ${msg}`);
41
138
  }
42
139
  const issues = [];
140
+ const allowed = new Set(cfg.allowUnknownKeys ?? []);
141
+ // Absent only for a hand-built context; then there is nothing to verify names
142
+ // against, and claiming a name is wrong would be worse than staying quiet.
143
+ const checkNames = ctx.checkNames ?? [];
43
144
  for (const key of Object.keys(json)) {
44
- if (key.startsWith('//') || key === '$schema')
145
+ if (key.startsWith('//') || key === '$schema' || allowed.has(key))
45
146
  continue;
46
- if (!config_1.KNOWN_CONFIG_KEYS.has(key))
47
- issues.push(`unknown key "${key}"`);
48
- }
49
- const sev = json.severity;
50
- if (sev && typeof sev === 'object') {
51
- for (const [name, val] of Object.entries(sev)) {
52
- if (!VALID_SEVERITIES.includes(val)) {
53
- issues.push(`severity["${name}"] must be error|warn|off (got ${JSON.stringify(val)})`);
54
- }
147
+ if (!config_1.KNOWN_CONFIG_KEYS.has(key)) {
148
+ const hint = suggestName(key, [...config_1.KNOWN_CONFIG_KEYS]);
149
+ issues.push(`unknown key "${key}"${hint ? ` — did you mean "${hint}"?` : ''}`);
55
150
  }
56
151
  }
152
+ if (cfg.validateCheckNames !== false && checkNames.length > 0) {
153
+ issues.push(...validateSeverity(json, checkNames));
154
+ }
155
+ issues.push(...validateFailOn(json));
57
156
  if (issues.length === 0)
58
157
  return (0, types_1.pass)();
59
- issues.slice(0, 10).forEach(i => utils_1.logger.warn(`[Config Validation]: ⚠️ ${i}`));
158
+ // Advisory by default: a config problem is worth knowing about, but a repo
159
+ // that wants a broken config to stop the build sets `severity: "error"`.
160
+ const sev = (0, findings_1.severityEmitter)(cfg.severity, 'warn');
161
+ issues.slice(0, 10).forEach(i => sev.log(`[Config Validation]: ${sev.icon} ${i}`));
60
162
  const sample = issues.slice(0, 3).join('; ');
61
- return (0, types_1.warn)(`${issues.length} config issue(s): ${sample}${issues.length > 3 ? ' …' : ''}`);
163
+ return sev.emit(`${issues.length} config issue(s): ${sample}${issues.length > 3 ? ' …' : ''}`);
62
164
  },
63
165
  };
@@ -13,6 +13,7 @@ const types_1 = require("../../types");
13
13
  exports.deadCodeCheck = {
14
14
  name: 'Dead Code',
15
15
  phase: 'informational',
16
+ weight: 'heavy',
16
17
  applies(ctx) {
17
18
  if (ctx.config.deadCode?.enabled === false)
18
19
  return false;