pr-checkmate 1.23.24 → 2.1.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 +89 -13
- package/cspell.json +11 -9
- package/dist/cli.js +37 -64
- package/dist/config/constants.d.ts +51 -3
- package/dist/config/constants.js +73 -11
- package/dist/config/pr-checkmate-workflow-docker.yml +79 -0
- package/dist/config/pr-checkmate-workflow.yml +65 -326
- 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 +1 -1
- package/dist/core/checks/languages/shellcheck.js +1 -1
- 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/markdown-lint.js +7 -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/yaml-lint.js +22 -9
- package/dist/core/checks/security/diff-security.js +115 -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/reporters.d.ts +13 -2
- package/dist/core/reporters.js +145 -104
- package/dist/core/run-checks.d.ts +25 -1
- package/dist/core/run-checks.js +124 -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 +284 -263
- package/dist/interfaces/index.d.ts +120 -0
- package/dist/scripts/copy-configs.js +4 -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-default-rules.json +1 -1
- 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 +14 -28
- package/pr-checkmate.schema.json +1062 -104
- package/src/config/cspell.json +11 -9
- package/src/config/eslint-default-rules.json +1 -1
- package/src/config/pr-checkmate-workflow-docker.yml +79 -0
- package/src/config/pr-checkmate-workflow.yml +65 -326
- package/src/config/pr-checkmate.schema.json +1062 -104
|
@@ -65,8 +65,11 @@ function scan(stdout, findings) {
|
|
|
65
65
|
exports.migrationSafetyCheck = {
|
|
66
66
|
name: 'Migration Safety',
|
|
67
67
|
phase: 'informational',
|
|
68
|
+
// No `requiresDiff`: a destructive migration is destructive whenever it was
|
|
69
|
+
// written (ticket 067). Over a whole repository this becomes an inventory of
|
|
70
|
+
// the irreversible migrations already in the history, which is worth having.
|
|
68
71
|
applies(ctx) {
|
|
69
|
-
return ctx.config.migrationSafety?.enabled !== false
|
|
72
|
+
return ctx.config.migrationSafety?.enabled !== false;
|
|
70
73
|
},
|
|
71
74
|
async run(ctx) {
|
|
72
75
|
// Not sourcePath-scoped: migrations commonly live outside `src` (db/, alembic/).
|
|
@@ -79,28 +82,27 @@ exports.migrationSafetyCheck = {
|
|
|
79
82
|
if (migrationFiles.length === 0)
|
|
80
83
|
return (0, types_1.pass)();
|
|
81
84
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
'--unified=0',
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
let stdout = '';
|
|
86
|
+
if (ctx.baseSha) {
|
|
87
|
+
stdout = (await (0, execa_1.execa)('git', ['diff', '--unified=0', ctx.baseSha, ctx.headSha ?? 'HEAD', '--', ...migrationFiles], { cwd: ctx.cwd })).stdout;
|
|
88
|
+
}
|
|
89
|
+
else if (ctx.config.run?.fullScanBehaviour !== 'skip') {
|
|
90
|
+
await (0, findings_1.scanTrackedFiles)(ctx, migrationFiles, asDiff => {
|
|
91
|
+
stdout += `${asDiff}\n`;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
90
94
|
const findings = {};
|
|
91
95
|
scan(stdout, findings);
|
|
92
96
|
if ((0, findings_1.bucketTotal)(findings) === 0)
|
|
93
97
|
return (0, types_1.pass)();
|
|
94
|
-
|
|
95
|
-
(0, findings_1.logFindings)(findings, {
|
|
98
|
+
return (0, findings_1.reportCountedFindings)(findings, ctx, {
|
|
96
99
|
prefix: 'Migration Safety',
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
severity: ctx.config.migrationSafety?.severity,
|
|
101
|
+
sampleLimit: ctx.config.log?.sampleLimit ?? 2,
|
|
102
|
+
// The labels carry an explanatory tail ("… — rewrites every row"); the
|
|
103
|
+
// summary is a one-line tally, so only the name of the risk fits.
|
|
104
|
+
summaryLabel: label => label.split(' — ')[0],
|
|
99
105
|
});
|
|
100
|
-
const summary = Object.entries(findings)
|
|
101
|
-
.map(([label, arr]) => `${arr.length}× ${label.split(' — ')[0]}`)
|
|
102
|
-
.join(', ');
|
|
103
|
-
return sev.emit(summary);
|
|
104
106
|
}
|
|
105
107
|
catch (err) {
|
|
106
108
|
utils_1.logger.warn(`[Migration Safety]: ⚠️ Could not analyze diff\n${err}`);
|
|
@@ -72,21 +72,53 @@ function categorizeFiles(changedFiles) {
|
|
|
72
72
|
exports.sensitiveFileGuardCheck = {
|
|
73
73
|
name: 'Sensitive Files',
|
|
74
74
|
phase: 'informational',
|
|
75
|
+
// No `requiresDiff`: a committed `.env` or private key is committed whether
|
|
76
|
+
// this PR touched it or not (ticket 067). In a full scan the question becomes
|
|
77
|
+
// "what sensitive files does this repository contain", which is the more
|
|
78
|
+
// useful one when auditing a repo rather than reviewing a change.
|
|
75
79
|
applies(ctx) {
|
|
76
|
-
return ctx.config.sensitiveFileGuard?.enabled !== false
|
|
80
|
+
return ctx.config.sensitiveFileGuard?.enabled !== false;
|
|
77
81
|
},
|
|
78
82
|
async run(ctx) {
|
|
79
83
|
try {
|
|
80
|
-
const
|
|
81
|
-
|
|
84
|
+
const args = ctx.baseSha
|
|
85
|
+
? ['diff', '--name-only', '--diff-filter=ACMRD', ctx.baseSha, 'HEAD']
|
|
86
|
+
: ['ls-files'];
|
|
87
|
+
if (!ctx.baseSha && ctx.config.run?.fullScanBehaviour === 'skip')
|
|
88
|
+
return (0, types_1.pass)();
|
|
89
|
+
// `reject: false` so a directory that is not a repository is an ordinary
|
|
90
|
+
// outcome rather than a thrown error whose message carries git's raw
|
|
91
|
+
// `fatal: not a git repository` into the log. The check still skips — but
|
|
92
|
+
// a skip with a reason reads as a decision, while a bare `fatal:` reads as
|
|
93
|
+
// a crash, and someone has to spend time proving it was not one.
|
|
94
|
+
const result = await (0, execa_1.execa)('git', args, { cwd: ctx.cwd, reject: false });
|
|
95
|
+
if (result.exitCode !== 0)
|
|
96
|
+
return (0, types_1.skip)('git unavailable');
|
|
97
|
+
const cfg = ctx.config.sensitiveFileGuard ?? {};
|
|
98
|
+
// Path exclusions are applied before categorising, so one false positive —
|
|
99
|
+
// a `.pem` test fixture, a committed `.env.example` — is excused by name.
|
|
100
|
+
// Without this the only exclusion a client has is `ignore`, which suppresses
|
|
101
|
+
// the whole CATEGORY: muting "Private keys and certificates" to excuse one
|
|
102
|
+
// fixture would also excuse the next real key someone commits.
|
|
103
|
+
const changedFiles = result.stdout
|
|
104
|
+
.split('\n')
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.filter(file => !(0, utils_1.matchesAnyGlob)(file, cfg.ignorePaths ?? []));
|
|
82
107
|
if (changedFiles.length === 0)
|
|
83
108
|
return (0, types_1.pass)();
|
|
84
109
|
const { errors, warnings } = categorizeFiles(changedFiles);
|
|
85
110
|
return (0, findings_1.reportSeverityBuckets)(errors, warnings, {
|
|
86
111
|
prefix: 'Sensitive Files',
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
// "changed" is only true of a diff; a full scan is answering a different
|
|
113
|
+
// question — what the repository contains — and saying otherwise would
|
|
114
|
+
// send someone looking for a change that never happened.
|
|
115
|
+
errorSummary: n => `${n} sensitive file(s) ${ctx.baseSha ? 'changed' : 'in the repo'}`,
|
|
116
|
+
warningSummary: n => `${n} notable file(s) ${ctx.baseSha ? 'changed' : 'in the repo'}`,
|
|
89
117
|
showCount: false,
|
|
118
|
+
...(0, findings_1.clientFindingOptions)(ctx, cfg),
|
|
119
|
+
// Every sensitive path is listed rather than sampled: knowing that eight
|
|
120
|
+
// files are in play but only which three is not an answer anyone can act on.
|
|
121
|
+
sampleLimit: undefined,
|
|
90
122
|
});
|
|
91
123
|
}
|
|
92
124
|
catch (err) {
|
|
@@ -1,2 +1,12 @@
|
|
|
1
1
|
import { Check } from '../../types';
|
|
2
|
+
import { DiffPattern } from '../findings';
|
|
3
|
+
/**
|
|
4
|
+
* GitHub Actions workflow risks, matched on added diff lines of workflow files.
|
|
5
|
+
* These are the high-frequency supply-chain / injection footguns.
|
|
6
|
+
*
|
|
7
|
+
* Exported for the corpus in `workflow-security.test.ts`: these rules turn on a
|
|
8
|
+
* context distinction subtle enough that only worked examples can hold it in
|
|
9
|
+
* place.
|
|
10
|
+
*/
|
|
11
|
+
export declare const WORKFLOW_PATTERNS: DiffPattern[];
|
|
2
12
|
export declare const workflowSecurityCheck: Check;
|
|
@@ -1,23 +1,46 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.workflowSecurityCheck = void 0;
|
|
3
|
+
exports.workflowSecurityCheck = exports.WORKFLOW_PATTERNS = void 0;
|
|
4
4
|
const utils_1 = require("../../../utils");
|
|
5
|
+
const targets_1 = require("../../targets");
|
|
5
6
|
const types_1 = require("../../types");
|
|
6
7
|
const findings_1 = require("../findings");
|
|
8
|
+
/**
|
|
9
|
+
* A YAML key binding — `HEAD_REF: ${{ … }}`, `ref: ${{ … }}` — rather than an
|
|
10
|
+
* interpolation inside a script body.
|
|
11
|
+
*
|
|
12
|
+
* This is the distinction the rules below turn on. Binding an untrusted value to
|
|
13
|
+
* an `env:` entry or an action input is *the recommended fix*: the value reaches
|
|
14
|
+
* the shell as data, expanded after parsing. Flagging it made the check punish
|
|
15
|
+
* the exact remediation it recommends, which is worse than not checking at all —
|
|
16
|
+
* a warning on correct code teaches people to ignore the warnings.
|
|
17
|
+
*
|
|
18
|
+
* The risky shape is the same expression inside a `run:` block, where GitHub
|
|
19
|
+
* substitutes it into the script *before* the shell parses it.
|
|
20
|
+
*/
|
|
21
|
+
const YAML_KEY_BINDING = String.raw `(?!\s*[\w.-]+:\s*\$\{\{)`;
|
|
7
22
|
/**
|
|
8
23
|
* GitHub Actions workflow risks, matched on added diff lines of workflow files.
|
|
9
24
|
* These are the high-frequency supply-chain / injection footguns.
|
|
25
|
+
*
|
|
26
|
+
* Exported for the corpus in `workflow-security.test.ts`: these rules turn on a
|
|
27
|
+
* context distinction subtle enough that only worked examples can hold it in
|
|
28
|
+
* place.
|
|
10
29
|
*/
|
|
11
|
-
|
|
30
|
+
exports.WORKFLOW_PATTERNS = [
|
|
12
31
|
{
|
|
13
32
|
// Untrusted PR-controlled input interpolated into an expression — the classic
|
|
14
33
|
// `run:` script-injection vector when it reaches a shell.
|
|
15
|
-
pattern:
|
|
34
|
+
pattern: new RegExp([
|
|
35
|
+
String.raw `^\+(?!\+)${YAML_KEY_BINDING}.*\$\{\{\s*github\.event\.`,
|
|
36
|
+
String.raw `(?:issue|pull_request|comment|review|discussion|head_commit|commits)[^}]*`,
|
|
37
|
+
String.raw `\.(?:title|body|head_ref|ref|label|name|email|message|url)`,
|
|
38
|
+
].join('')),
|
|
16
39
|
label: 'untrusted github.event input in expression (script injection risk)',
|
|
17
40
|
severity: 'error',
|
|
18
41
|
},
|
|
19
42
|
{
|
|
20
|
-
pattern:
|
|
43
|
+
pattern: new RegExp(String.raw `^\+(?!\+)${YAML_KEY_BINDING}.*\$\{\{\s*github\.head_ref\s*\}\}`),
|
|
21
44
|
label: 'github.head_ref interpolation (injection risk — use an env var)',
|
|
22
45
|
severity: 'warn',
|
|
23
46
|
},
|
|
@@ -33,38 +56,37 @@ const WORKFLOW_PATTERNS = [
|
|
|
33
56
|
severity: 'warn',
|
|
34
57
|
},
|
|
35
58
|
];
|
|
36
|
-
function scanDiff(stdout) {
|
|
37
|
-
const errors = {};
|
|
38
|
-
const warnings = {};
|
|
39
|
-
for (const line of stdout.split('\n')) {
|
|
40
|
-
for (const { pattern, label, severity } of WORKFLOW_PATTERNS) {
|
|
41
|
-
if (pattern.test(line)) {
|
|
42
|
-
const bucket = severity === 'error' ? errors : warnings;
|
|
43
|
-
(bucket[label] ?? (bucket[label] = [])).push(line.slice(1).trim().substring(0, 120));
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return { errors, warnings };
|
|
48
|
-
}
|
|
49
59
|
exports.workflowSecurityCheck = {
|
|
50
60
|
name: 'Workflow Security',
|
|
51
61
|
phase: 'informational',
|
|
62
|
+
// No `requiresDiff`: an injectable workflow is injectable whether the line
|
|
63
|
+
// arrived in this PR or long before it (ticket 067). Ours had lived there for
|
|
64
|
+
// months when ticket 060 found it — by hand, because no mode looked.
|
|
52
65
|
applies(ctx) {
|
|
53
|
-
return ctx.config.workflowSecurity?.enabled !== false
|
|
66
|
+
return ctx.config.workflowSecurity?.enabled !== false;
|
|
54
67
|
},
|
|
55
68
|
async run(ctx) {
|
|
56
69
|
try {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
const globs = ['.github/workflows/*.yml', '.github/workflows/*.yaml'];
|
|
71
|
+
const cfg = ctx.config.workflowSecurity ?? {};
|
|
72
|
+
const found = (0, findings_1.emptyBuckets)();
|
|
73
|
+
const scanInto = (stdout) => {
|
|
74
|
+
// `diffAddedLines` already dropped `pr-checkmate-ignore` lines, and
|
|
75
|
+
// `scanTrackedFiles` does the same for the whole-file path.
|
|
76
|
+
(0, findings_1.mergeSeverityBuckets)(found, (0, findings_1.scanDiffPatterns)(stdout, exports.WORKFLOW_PATTERNS, { ignorePaths: cfg.ignorePaths }));
|
|
77
|
+
};
|
|
78
|
+
if (ctx.baseSha) {
|
|
79
|
+
scanInto(await (0, findings_1.diffAddedLines)(ctx, globs));
|
|
80
|
+
}
|
|
81
|
+
else if (ctx.config.run?.fullScanBehaviour !== 'skip') {
|
|
82
|
+
const files = await (0, targets_1.listTrackedFiles)(ctx, globs, ['.']);
|
|
83
|
+
await (0, findings_1.scanTrackedFiles)(ctx, files, scanInto);
|
|
84
|
+
}
|
|
85
|
+
return (0, findings_1.reportSeverityBuckets)(found.errors, found.warnings, {
|
|
63
86
|
prefix: 'Workflow Security',
|
|
64
87
|
errorSummary: n => `${n} issue(s)`,
|
|
65
88
|
warningSummary: n => `${n} warning(s)`,
|
|
66
|
-
|
|
67
|
-
ignore: ctx.config.workflowSecurity?.ignore,
|
|
89
|
+
...(0, findings_1.clientFindingOptions)(ctx, cfg),
|
|
68
90
|
});
|
|
69
91
|
}
|
|
70
92
|
catch (err) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI vocabulary, kept out of `cli.ts` so it can be imported without side
|
|
3
|
+
* effects: that module is an executable entry point — it parses `process.argv`
|
|
4
|
+
* and calls `process.exit` at import time, so anything importing it to read
|
|
5
|
+
* these tables would run the CLI instead.
|
|
6
|
+
*
|
|
7
|
+
* Separating them is what lets `commands.test.ts` assert the invariant that
|
|
8
|
+
* matters here: every command must resolve to a check that actually exists.
|
|
9
|
+
* A command pointing at a name no longer in the registry does not error — the
|
|
10
|
+
* `only` filter simply matches nothing, every check is filtered out, and the run
|
|
11
|
+
* reports success having verified nothing at all.
|
|
12
|
+
*/
|
|
13
|
+
/** Maps short CLI command names to their registry check names. */
|
|
14
|
+
export declare const COMMAND_TO_CHECK: Record<string, string>;
|
|
15
|
+
/**
|
|
16
|
+
* Commands that run the whole pipeline (or no pipeline at all) and take no siblings.
|
|
17
|
+
*
|
|
18
|
+
* The three install commands are here rather than in {@link COMMAND_TO_CHECK}
|
|
19
|
+
* because they run no checks at all: they manage the tooling the checks need.
|
|
20
|
+
* `doctor` in particular is the answer to "why did my typecheck not run" once
|
|
21
|
+
* tooling is fetched on demand — a question that must have a command behind it
|
|
22
|
+
* and not a reading of our source.
|
|
23
|
+
*/
|
|
24
|
+
export declare const TOOLING_COMMANDS: readonly string[];
|
|
25
|
+
export declare const STANDALONE_COMMANDS: readonly string[];
|
|
26
|
+
/** Flags accepted anywhere on the command line, with what each one means. */
|
|
27
|
+
export declare const FLAGS: Record<string, string>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CLI vocabulary, kept out of `cli.ts` so it can be imported without side
|
|
4
|
+
* effects: that module is an executable entry point — it parses `process.argv`
|
|
5
|
+
* and calls `process.exit` at import time, so anything importing it to read
|
|
6
|
+
* these tables would run the CLI instead.
|
|
7
|
+
*
|
|
8
|
+
* Separating them is what lets `commands.test.ts` assert the invariant that
|
|
9
|
+
* matters here: every command must resolve to a check that actually exists.
|
|
10
|
+
* A command pointing at a name no longer in the registry does not error — the
|
|
11
|
+
* `only` filter simply matches nothing, every check is filtered out, and the run
|
|
12
|
+
* reports success having verified nothing at all.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.FLAGS = exports.STANDALONE_COMMANDS = exports.TOOLING_COMMANDS = exports.COMMAND_TO_CHECK = void 0;
|
|
16
|
+
/** Maps short CLI command names to their registry check names. */
|
|
17
|
+
exports.COMMAND_TO_CHECK = {
|
|
18
|
+
// Every check that can fail a run is reachable on its own, so "why does this
|
|
19
|
+
// block me" is answered by running that one check instead of the whole suite.
|
|
20
|
+
'merge-conflict': 'Merge Conflict',
|
|
21
|
+
lint: 'ESLint',
|
|
22
|
+
deps: 'Dependencies',
|
|
23
|
+
dependencies: 'Dependencies',
|
|
24
|
+
'npm-audit': 'NPM Audit',
|
|
25
|
+
prettier: 'Prettier',
|
|
26
|
+
spellcheck: 'Spellcheck',
|
|
27
|
+
security: 'Security Scan',
|
|
28
|
+
circular: 'Circular Deps',
|
|
29
|
+
license: 'License Check',
|
|
30
|
+
'pr-size': 'PR Size',
|
|
31
|
+
'pr-title': 'PR Title',
|
|
32
|
+
outdated: 'Outdated Deps',
|
|
33
|
+
markdown: 'Markdown',
|
|
34
|
+
'yaml-lint': 'YAML Lint',
|
|
35
|
+
symlinks: 'Broken Symlinks',
|
|
36
|
+
shellcheck: 'ShellCheck',
|
|
37
|
+
typecheck: 'TypeScript',
|
|
38
|
+
'diff-smell': 'Diff Smells',
|
|
39
|
+
'diff-security': 'Diff Security',
|
|
40
|
+
'workflow-security': 'Workflow Security',
|
|
41
|
+
'custom-rules': 'Custom Rules',
|
|
42
|
+
'banned-imports': 'Banned Imports',
|
|
43
|
+
'license-header': 'License Header',
|
|
44
|
+
'config-validation': 'Config Validation',
|
|
45
|
+
'dockerfile-security': 'Dockerfile Security',
|
|
46
|
+
'case-collision': 'Case Collision',
|
|
47
|
+
'vuln-scan': 'Vuln Scan',
|
|
48
|
+
'leftover-debug': 'Leftover Debug',
|
|
49
|
+
'lockfile-drift': 'Lockfile Drift',
|
|
50
|
+
'large-files': 'Large Files',
|
|
51
|
+
'todo-fixme': 'TODO/FIXME',
|
|
52
|
+
'missing-tests': 'Missing Tests',
|
|
53
|
+
coverage: 'Coverage',
|
|
54
|
+
'migration-safety': 'Migration Safety',
|
|
55
|
+
'sensitive-files': 'Sensitive Files',
|
|
56
|
+
'pr-body': 'PR Body',
|
|
57
|
+
duplicate: 'Duplicate Code',
|
|
58
|
+
commitlint: 'Commitlint',
|
|
59
|
+
'cpp-format': 'C++ Format',
|
|
60
|
+
'python-lint': 'Ruff Lint',
|
|
61
|
+
'python-format': 'Ruff Format',
|
|
62
|
+
'python-typecheck': 'Python Types',
|
|
63
|
+
'go-lint': 'Go Vet',
|
|
64
|
+
'go-format': 'Go Format',
|
|
65
|
+
'rust-lint': 'Clippy',
|
|
66
|
+
'rust-format': 'Rustfmt',
|
|
67
|
+
'csharp-format': 'C# Format',
|
|
68
|
+
'ruby-lint': 'RuboCop',
|
|
69
|
+
'php-format': 'PHP CS Fixer',
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Commands that run the whole pipeline (or no pipeline at all) and take no siblings.
|
|
73
|
+
*
|
|
74
|
+
* The three install commands are here rather than in {@link COMMAND_TO_CHECK}
|
|
75
|
+
* because they run no checks at all: they manage the tooling the checks need.
|
|
76
|
+
* `doctor` in particular is the answer to "why did my typecheck not run" once
|
|
77
|
+
* tooling is fetched on demand — a question that must have a command behind it
|
|
78
|
+
* and not a reading of our source.
|
|
79
|
+
*/
|
|
80
|
+
exports.TOOLING_COMMANDS = ['install', 'doctor', 'cache-prune'];
|
|
81
|
+
exports.STANDALONE_COMMANDS = [
|
|
82
|
+
'init',
|
|
83
|
+
'all',
|
|
84
|
+
'precommit',
|
|
85
|
+
...exports.TOOLING_COMMANDS,
|
|
86
|
+
];
|
|
87
|
+
/** Flags accepted anywhere on the command line, with what each one means. */
|
|
88
|
+
exports.FLAGS = {
|
|
89
|
+
'--full': 'review every tracked file instead of the diff (the default outside CI)',
|
|
90
|
+
'--all': 'with "install": fetch every profile, not only the ones this repository needs',
|
|
91
|
+
};
|
package/dist/core/context.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ type RangePlan = {
|
|
|
22
22
|
headSha?: string;
|
|
23
23
|
} | {
|
|
24
24
|
kind: 'github-event';
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'branch';
|
|
25
27
|
} | {
|
|
26
28
|
kind: 'ref';
|
|
27
29
|
ref: string;
|
|
@@ -37,9 +39,16 @@ type RangePlan = {
|
|
|
37
39
|
* branch) — a gate exists to judge the diff.
|
|
38
40
|
* 4. A CI build with no PR context still diffs `HEAD^`: it is a push/branch build,
|
|
39
41
|
* and delta is why CI runs are fast.
|
|
40
|
-
* 5. Anything else is a human at a terminal
|
|
41
|
-
*
|
|
42
|
-
*
|
|
42
|
+
* 5. Anything else is a human at a terminal, working on a branch → review what the
|
|
43
|
+
* branch changed, i.e. everything since it diverged from the default branch.
|
|
44
|
+
* That is the same set of changes a pull request would show, which is what
|
|
45
|
+
* someone running the tool before opening one is asking about.
|
|
46
|
+
*
|
|
47
|
+
* `HEAD^` was tried here once and was wrong for a different reason: it judged
|
|
48
|
+
* only the last commit, so a `debugger` committed earlier on the branch passed.
|
|
49
|
+
* The merge base has neither problem — it covers every commit on the branch —
|
|
50
|
+
* and on the default branch itself, where there is nothing to diverge from,
|
|
51
|
+
* {@link resolveDiffRange} falls back to a full scan.
|
|
43
52
|
*
|
|
44
53
|
* @param opts Run options as passed to `runChecks`.
|
|
45
54
|
* @param env Environment to read CI markers from. Defaults to the current process.
|
package/dist/core/context.js
CHANGED
|
@@ -20,9 +20,16 @@ const execa_1 = require("../utils/execa");
|
|
|
20
20
|
* branch) — a gate exists to judge the diff.
|
|
21
21
|
* 4. A CI build with no PR context still diffs `HEAD^`: it is a push/branch build,
|
|
22
22
|
* and delta is why CI runs are fast.
|
|
23
|
-
* 5. Anything else is a human at a terminal
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* 5. Anything else is a human at a terminal, working on a branch → review what the
|
|
24
|
+
* branch changed, i.e. everything since it diverged from the default branch.
|
|
25
|
+
* That is the same set of changes a pull request would show, which is what
|
|
26
|
+
* someone running the tool before opening one is asking about.
|
|
27
|
+
*
|
|
28
|
+
* `HEAD^` was tried here once and was wrong for a different reason: it judged
|
|
29
|
+
* only the last commit, so a `debugger` committed earlier on the branch passed.
|
|
30
|
+
* The merge base has neither problem — it covers every commit on the branch —
|
|
31
|
+
* and on the default branch itself, where there is nothing to diverge from,
|
|
32
|
+
* {@link resolveDiffRange} falls back to a full scan.
|
|
26
33
|
*
|
|
27
34
|
* @param opts Run options as passed to `runChecks`.
|
|
28
35
|
* @param env Environment to read CI markers from. Defaults to the current process.
|
|
@@ -47,20 +54,30 @@ function planDiffRange(opts, env = process.env) {
|
|
|
47
54
|
}
|
|
48
55
|
if ((0, utils_1.isCiEnvironment)(env))
|
|
49
56
|
return { kind: 'ref', ref: 'HEAD^', reason: 'CI build, no PR context' };
|
|
50
|
-
return { kind: '
|
|
57
|
+
return { kind: 'branch' };
|
|
51
58
|
}
|
|
59
|
+
/** A full scan the caller or the environment asked for, not one we fell back to. */
|
|
60
|
+
const intended = (reason) => ({ kind: 'full', reason, degraded: false });
|
|
61
|
+
/**
|
|
62
|
+
* A full scan we ended up with because a diff range could not be built. The
|
|
63
|
+
* distinction matters: the delta-only checks drop out either way, but here that
|
|
64
|
+
* is a symptom (shallow clone, missing base) rather than the intent — and it is
|
|
65
|
+
* the case worth telling the client about, because it silently removes most of
|
|
66
|
+
* the security surface.
|
|
67
|
+
*/
|
|
68
|
+
const degraded = (reason) => ({ kind: 'full', reason, degraded: true });
|
|
52
69
|
/**
|
|
53
70
|
* Carry out a {@link RangePlan}. Every path that cannot produce a usable base
|
|
54
71
|
* commit returns no SHAs, which the checks read as "scan everything" — degrading
|
|
55
72
|
* to a full scan is always safer than diffing against a ref that isn't there and
|
|
56
73
|
* silently reviewing nothing.
|
|
57
74
|
*
|
|
58
|
-
* @returns The resolved range, plus
|
|
75
|
+
* @returns The resolved range, plus the scope that produced it.
|
|
59
76
|
*/
|
|
60
77
|
async function resolveDiffRange(cwd, opts) {
|
|
61
78
|
const plan = planDiffRange(opts, process.env);
|
|
62
79
|
if (plan.kind === 'full')
|
|
63
|
-
return { scope:
|
|
80
|
+
return { scope: intended(plan.reason) };
|
|
64
81
|
// Pre-commit mode: diff HEAD against the current index. `git write-tree`
|
|
65
82
|
// materialises the staged index as a real tree object, so every check that
|
|
66
83
|
// diffs baseSha..headSha sees exactly the staged changes — no per-check code.
|
|
@@ -68,14 +85,18 @@ async function resolveDiffRange(cwd, opts) {
|
|
|
68
85
|
try {
|
|
69
86
|
const head = (await (0, execa_1.execa)('git', ['rev-parse', 'HEAD'], { cwd })).stdout.trim();
|
|
70
87
|
const tree = (await (0, execa_1.execa)('git', ['write-tree'], { cwd })).stdout.trim();
|
|
71
|
-
return { baseSha: head, headSha: tree, scope: 'staged' };
|
|
88
|
+
return { baseSha: head, headSha: tree, scope: { kind: 'staged', degraded: false } };
|
|
72
89
|
}
|
|
73
90
|
catch {
|
|
74
|
-
return { scope: '
|
|
91
|
+
return { scope: degraded('no staged range') };
|
|
75
92
|
}
|
|
76
93
|
}
|
|
77
94
|
if (plan.kind === 'explicit') {
|
|
78
|
-
return {
|
|
95
|
+
return {
|
|
96
|
+
baseSha: plan.baseSha,
|
|
97
|
+
headSha: plan.headSha,
|
|
98
|
+
scope: { kind: 'delta', reason: 'explicit', degraded: false },
|
|
99
|
+
};
|
|
79
100
|
}
|
|
80
101
|
let headSha;
|
|
81
102
|
try {
|
|
@@ -83,7 +104,7 @@ async function resolveDiffRange(cwd, opts) {
|
|
|
83
104
|
headSha = stdout.trim();
|
|
84
105
|
}
|
|
85
106
|
catch {
|
|
86
|
-
return { scope: '
|
|
107
|
+
return { scope: degraded('no HEAD') };
|
|
87
108
|
}
|
|
88
109
|
// GitHub Actions: the PR event payload carries the base branch SHA. Read it
|
|
89
110
|
// directly so delta works without relying on any client-set variable
|
|
@@ -95,19 +116,69 @@ async function resolveDiffRange(cwd, opts) {
|
|
|
95
116
|
// Only use it if the commit is actually present in this clone; otherwise
|
|
96
117
|
// fall through so a shallow clone degrades to a full scan, not 0 files.
|
|
97
118
|
const baseSha = eventBase ? await (0, utils_1.resolveGitRef)(cwd, eventBase) : undefined;
|
|
98
|
-
if (baseSha)
|
|
99
|
-
return { baseSha, headSha, scope: 'delta
|
|
119
|
+
if (baseSha) {
|
|
120
|
+
return { baseSha, headSha, scope: { kind: 'delta', reason: 'PR event', degraded: false } };
|
|
121
|
+
}
|
|
100
122
|
}
|
|
101
123
|
catch {
|
|
102
124
|
// Malformed/unreadable event — fall through to a full scan below.
|
|
103
125
|
}
|
|
104
|
-
return { headSha, scope: '
|
|
126
|
+
return { headSha, scope: degraded('PR base unavailable') };
|
|
105
127
|
}
|
|
128
|
+
if (plan.kind === 'branch')
|
|
129
|
+
return resolveBranchRange(cwd, headSha);
|
|
106
130
|
const baseSha = await (0, utils_1.resolveGitRef)(cwd, plan.ref);
|
|
131
|
+
if (!baseSha)
|
|
132
|
+
return { headSha, scope: degraded(`${plan.ref} unresolvable`) };
|
|
133
|
+
return {
|
|
134
|
+
baseSha,
|
|
135
|
+
headSha,
|
|
136
|
+
scope: { kind: 'delta', reason: `${plan.ref}, ${plan.reason}`, degraded: false },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Branches a repository's trunk is normally called, most likely first. */
|
|
140
|
+
const DEFAULT_BRANCHES = ['main', 'master', 'develop'];
|
|
141
|
+
/**
|
|
142
|
+
* Where this branch diverged from the trunk, or nothing when it has not.
|
|
143
|
+
*
|
|
144
|
+
* Prefers `origin/HEAD` — the remote's own answer to "what is the default
|
|
145
|
+
* branch" — and falls back to the usual names, local or remote. Returns
|
|
146
|
+
* `undefined` when no trunk is found or when HEAD is already on it, which the
|
|
147
|
+
* caller turns into a full scan: on the trunk there is no branch to review.
|
|
148
|
+
*/
|
|
149
|
+
async function findMergeBase(cwd, headSha) {
|
|
150
|
+
const candidates = ['origin/HEAD'];
|
|
151
|
+
for (const name of DEFAULT_BRANCHES)
|
|
152
|
+
candidates.push(`origin/${name}`, name);
|
|
153
|
+
for (const candidate of candidates) {
|
|
154
|
+
const ref = await (0, utils_1.resolveGitRef)(cwd, candidate);
|
|
155
|
+
// A candidate that resolves to HEAD is the branch we are on, not a base.
|
|
156
|
+
if (!ref || ref === headSha)
|
|
157
|
+
continue;
|
|
158
|
+
const { stdout } = await (0, execa_1.execa)('git', ['merge-base', headSha, ref], { cwd, reject: false });
|
|
159
|
+
const base = stdout.trim();
|
|
160
|
+
// An unrelated history has no merge base, and a base equal to HEAD means the
|
|
161
|
+
// branch is behind rather than ahead — neither describes local changes.
|
|
162
|
+
if (base && base !== headSha)
|
|
163
|
+
return base;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Local run on a branch: review what the branch changed.
|
|
169
|
+
*
|
|
170
|
+
* On the trunk itself there is nothing to diff against, so this degrades to a
|
|
171
|
+
* full scan — deliberately not marked `degraded`, because it is the expected
|
|
172
|
+
* answer there rather than a configuration problem worth warning about.
|
|
173
|
+
*/
|
|
174
|
+
async function resolveBranchRange(cwd, headSha) {
|
|
175
|
+
const baseSha = await findMergeBase(cwd, headSha);
|
|
176
|
+
if (!baseSha)
|
|
177
|
+
return { headSha, scope: intended('no branch base — on the default branch?') };
|
|
107
178
|
return {
|
|
108
179
|
baseSha,
|
|
109
180
|
headSha,
|
|
110
|
-
scope:
|
|
181
|
+
scope: { kind: 'delta', reason: 'branch changes', degraded: false },
|
|
111
182
|
};
|
|
112
183
|
}
|
|
113
184
|
/**
|
|
@@ -120,12 +191,13 @@ async function buildContext(opts = {}) {
|
|
|
120
191
|
// Detection reads the client's `ignoreDirs` from the config already loaded here,
|
|
121
192
|
// rather than calling getIgnoredDirs() — that one resolves against process.cwd(),
|
|
122
193
|
// which is not necessarily the `cwd` under review.
|
|
123
|
-
|
|
194
|
+
applyLogConfig(config);
|
|
195
|
+
const languages = opts.languages ?? (await (0, detect_language_1.detectLanguages)(cwd, config.ignoreDirs, config.detect));
|
|
124
196
|
const { baseSha, headSha, scope } = await resolveDiffRange(cwd, opts);
|
|
125
197
|
// `scope` is logged because it decides what the whole run looks at, and getting
|
|
126
198
|
// it wrong is invisible in the results: a run that reviewed one commit and one
|
|
127
199
|
// that reviewed the repository both just print passes.
|
|
128
|
-
utils_1.logger.info(`[buildContext]: cwd=${cwd} languages=[${languages.join(', ') || 'none'}] write=${opts.write ?? false} scope=${scope}`);
|
|
200
|
+
utils_1.logger.info(`[buildContext]: cwd=${cwd} languages=[${languages.join(', ') || 'none'}] write=${opts.write ?? false} scope=${describeScope(scope)}`);
|
|
129
201
|
return {
|
|
130
202
|
cwd,
|
|
131
203
|
languages,
|
|
@@ -133,6 +205,36 @@ async function buildContext(opts = {}) {
|
|
|
133
205
|
write: opts.write ?? false,
|
|
134
206
|
baseSha,
|
|
135
207
|
headSha,
|
|
208
|
+
scope,
|
|
136
209
|
cache: new Map(),
|
|
137
210
|
};
|
|
138
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Human-readable one-liner for a scope, e.g. `full (PR base unavailable)`.
|
|
214
|
+
*
|
|
215
|
+
* Deliberately not exported: `buildContext` is the only caller, and the scope it
|
|
216
|
+
* describes already travels on the context for anyone who wants to phrase it
|
|
217
|
+
* differently. An `export` here would only be API surface Dead Code reports.
|
|
218
|
+
*/
|
|
219
|
+
function describeScope(scope) {
|
|
220
|
+
return scope.reason ? `${scope.kind} (${scope.reason})` : scope.kind;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Apply the client's `log` block, with environment variables taking precedence.
|
|
224
|
+
*
|
|
225
|
+
* Env wins because the config file is committed and the environment is not: a
|
|
226
|
+
* one-off `PR_CHECKMATE_LOG_LEVEL=debug` to diagnose a CI run should not require
|
|
227
|
+
* editing — and later reverting — a tracked file.
|
|
228
|
+
*/
|
|
229
|
+
function applyLogConfig(config) {
|
|
230
|
+
const log = config.log ?? {};
|
|
231
|
+
const level = process.env.PR_CHECKMATE_LOG_LEVEL ?? log.level;
|
|
232
|
+
const format = process.env.PR_CHECKMATE_LOG_FORMAT ?? log.format;
|
|
233
|
+
utils_1.logger.configure({
|
|
234
|
+
...(level ? { level } : {}),
|
|
235
|
+
...(format ? { format } : {}),
|
|
236
|
+
...(log.color ? { color: log.color } : {}),
|
|
237
|
+
...(log.timestamps !== undefined ? { timestamps: log.timestamps } : {}),
|
|
238
|
+
...(log.stderrLevels ? { stderrLevels: log.stderrLevels } : {}),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Where fetched tooling lives, and why it ended up there. */
|
|
2
|
+
export interface CacheLocation {
|
|
3
|
+
dir: string;
|
|
4
|
+
/** `false` when the configured location was unusable and this is a fallback. */
|
|
5
|
+
preferred: boolean;
|
|
6
|
+
reason?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Expand a leading `~`.
|
|
10
|
+
*
|
|
11
|
+
* Config files are written by hand, `~/.cache/pr-checkmate` is what a person
|
|
12
|
+
* writes, and nothing in Node expands it — the directory would be created
|
|
13
|
+
* literally, as a folder named `~`, in whatever the working directory happened
|
|
14
|
+
* to be.
|
|
15
|
+
*/
|
|
16
|
+
export declare function expandHome(dir: string, home?: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the cache directory, falling back rather than failing.
|
|
19
|
+
*
|
|
20
|
+
* Every candidate below is a real environment: containers without `HOME`, CI
|
|
21
|
+
* images with a read-only home, and runners where only the temp directory is
|
|
22
|
+
* writable. A tool that refuses to work in any of them is a tool that fails on
|
|
23
|
+
* CI for reasons the client cannot see.
|
|
24
|
+
*
|
|
25
|
+
* @param version The pr-checkmate version, kept in the path so two projects on
|
|
26
|
+
* different versions never share fetched tooling.
|
|
27
|
+
* @param configured `install.cacheDir`, if the client set one.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveCacheDir(version: string, configured?: string, env?: NodeJS.ProcessEnv): CacheLocation | null;
|
|
30
|
+
/** Where a single profile's tooling is installed. */
|
|
31
|
+
export declare function profileDir(cacheDir: string, profile: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Profile directories left behind by other versions of pr-checkmate.
|
|
34
|
+
*
|
|
35
|
+
* Nothing removes these automatically: an upgrade must not delete tooling a
|
|
36
|
+
* concurrently running older version is in the middle of using. `cache prune`
|
|
37
|
+
* is the deliberate, separate act.
|
|
38
|
+
*/
|
|
39
|
+
export declare function stalePaths(version: string, configured?: string): string[];
|