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.
- package/README.md +68 -15
- package/cspell.json +3 -8
- package/dist/cli.js +37 -62
- package/dist/config/constants.d.ts +21 -3
- package/dist/config/constants.js +33 -6
- package/dist/config/pr-checkmate-workflow.yml +33 -327
- package/dist/core/checks/dependencies/banned-imports.js +23 -24
- package/dist/core/checks/dependencies/circular-deps.js +1 -0
- package/dist/core/checks/dependencies/dependency-check.js +1 -0
- package/dist/core/checks/dependencies/grype-scan.js +1 -0
- package/dist/core/checks/dependencies/npm-audit.js +6 -1
- package/dist/core/checks/dependencies/vuln-scan.js +1 -0
- package/dist/core/checks/findings.d.ts +198 -19
- package/dist/core/checks/findings.js +474 -22
- package/dist/core/checks/git/custom-rules.js +21 -16
- package/dist/core/checks/git/diff-smell.d.ts +17 -0
- package/dist/core/checks/git/diff-smell.js +43 -24
- package/dist/core/checks/git/large-files.js +14 -2
- package/dist/core/checks/git/leftover-debug.js +23 -14
- package/dist/core/checks/git/lockfile-drift.js +2 -1
- package/dist/core/checks/git/merge-conflict.js +19 -8
- package/dist/core/checks/git/missing-tests.js +22 -6
- package/dist/core/checks/git/todo-fixme.js +10 -9
- package/dist/core/checks/languages/shellcheck.d.ts +8 -0
- package/dist/core/checks/languages/shellcheck.js +55 -0
- package/dist/core/checks/languages/typecheck.js +1 -0
- package/dist/core/checks/pr/pr-size-check.js +2 -1
- package/dist/core/checks/quality/config-validation.d.ts +14 -4
- package/dist/core/checks/quality/config-validation.js +119 -17
- package/dist/core/checks/quality/dead-code.js +1 -0
- package/dist/core/checks/quality/duplicate-check.js +1 -0
- package/dist/core/checks/quality/license-header.js +12 -2
- package/dist/core/checks/quality/spellcheck.d.ts +50 -0
- package/dist/core/checks/quality/spellcheck.js +141 -13
- package/dist/core/checks/quality/symlinks.d.ts +16 -0
- package/dist/core/checks/quality/symlinks.js +62 -0
- package/dist/core/checks/quality/yaml-lint.js +22 -9
- package/dist/core/checks/security/diff-security.js +111 -28
- package/dist/core/checks/security/dockerfile-security.js +23 -20
- package/dist/core/checks/security/migration-safety.js +19 -17
- package/dist/core/checks/security/sensitive-file-guard.js +37 -5
- package/dist/core/checks/security/workflow-security.d.ts +10 -0
- package/dist/core/checks/security/workflow-security.js +48 -26
- package/dist/core/commands.d.ts +27 -0
- package/dist/core/commands.js +91 -0
- package/dist/core/context.d.ts +12 -3
- package/dist/core/context.js +118 -16
- package/dist/core/install/cache.d.ts +39 -0
- package/dist/core/install/cache.js +99 -0
- package/dist/core/install/doctor.d.ts +35 -0
- package/dist/core/install/doctor.js +145 -0
- package/dist/core/install/ensure.d.ts +60 -0
- package/dist/core/install/ensure.js +169 -0
- package/dist/core/install/lock.d.ts +33 -0
- package/dist/core/install/lock.js +99 -0
- package/dist/core/install/profiles.d.ts +64 -0
- package/dist/core/install/profiles.js +170 -0
- package/dist/core/registry.js +4 -0
- package/dist/core/reporters.d.ts +13 -2
- package/dist/core/reporters.js +145 -104
- package/dist/core/run-checks.js +91 -14
- package/dist/core/targets.js +26 -4
- package/dist/core/types.d.ts +49 -1
- package/dist/init.d.ts +29 -14
- package/dist/init.js +204 -262
- package/dist/interfaces/index.d.ts +130 -0
- package/dist/scripts/generate-install-manifests.d.ts +39 -0
- package/dist/scripts/generate-install-manifests.js +121 -0
- package/dist/scripts/publish-github.js +18 -4
- package/dist/utils/detect-language.d.ts +37 -4
- package/dist/utils/detect-language.js +113 -19
- package/dist/utils/http.d.ts +59 -0
- package/dist/utils/http.js +119 -0
- package/dist/utils/logger.d.ts +52 -6
- package/dist/utils/logger.js +125 -33
- package/dist/utils/pool.d.ts +49 -0
- package/dist/utils/pool.js +124 -0
- package/dist/utils/update-notifier.d.ts +9 -0
- package/dist/utils/update-notifier.js +57 -3
- package/eslint.config.mjs +14 -2
- package/install-manifests/commitlint/package-lock.json +1028 -0
- package/install-manifests/commitlint/package.json +9 -0
- package/install-manifests/core/package-lock.json +1335 -0
- package/install-manifests/core/package.json +11 -0
- package/install-manifests/cpp/package-lock.json +29 -0
- package/install-manifests/cpp/package.json +8 -0
- package/install-manifests/duplicate/package-lock.json +113 -0
- package/install-manifests/duplicate/package.json +8 -0
- package/install-manifests/js/package-lock.json +5099 -0
- package/install-manifests/js/package.json +26 -0
- package/install-manifests/markdown/package-lock.json +1281 -0
- package/install-manifests/markdown/package.json +8 -0
- package/install-manifests/python/package-lock.json +21 -0
- package/install-manifests/python/package.json +8 -0
- package/install-manifests/spellcheck/package-lock.json +1047 -0
- package/install-manifests/spellcheck/package.json +8 -0
- package/package.json +13 -28
- package/pr-checkmate.schema.json +1070 -96
- package/src/config/cspell.json +3 -8
- package/src/config/pr-checkmate-workflow.yml +33 -327
- 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
|
-
|
|
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: /^\+(?!\+)
|
|
19
|
+
pattern: /^\+(?!\+).*\bconsole\.(log|error|warn|debug|info)\s*\(/,
|
|
10
20
|
label: 'console.*()',
|
|
21
|
+
severity: 'warn',
|
|
22
|
+
view: 'code',
|
|
11
23
|
},
|
|
12
|
-
{
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
47
|
+
return ctx.config.diffSmell?.enabled !== false;
|
|
23
48
|
},
|
|
24
49
|
async run(ctx) {
|
|
25
50
|
try {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
(0, findings_1.logFindings)(findings, {
|
|
76
|
+
return (0, findings_1.reportCountedFindings)(findings, ctx, {
|
|
63
77
|
prefix: 'Leftover Debug',
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
105
|
+
for (const h of found) {
|
|
95
106
|
sev.log(`[Merge Conflict]: ${sev.icon} ${h.file}:${h.line}`);
|
|
96
107
|
}
|
|
97
|
-
const sample =
|
|
108
|
+
const sample = found
|
|
98
109
|
.slice(0, 5)
|
|
99
110
|
.map(h => `${h.file}:${h.line}`)
|
|
100
111
|
.join(', ');
|
|
101
|
-
const more =
|
|
102
|
-
return sev.emit(
|
|
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
|
|
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
|
|
67
|
+
if (!file)
|
|
67
68
|
continue;
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
};
|
|
@@ -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
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
163
|
+
return sev.emit(`${issues.length} config issue(s): ${sample}${issues.length > 3 ? ' …' : ''}`);
|
|
62
164
|
},
|
|
63
165
|
};
|