pr-checkmate 2.1.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -268
- package/cspell.json +155 -133
- package/dist/config/constants.d.ts +6 -1
- package/dist/config/constants.js +8 -7
- package/dist/config/index.d.ts +23 -11
- package/dist/config/index.js +27 -33
- package/dist/core/checks/dependencies/banned-imports.js +4 -1
- package/dist/core/checks/dependencies/circular-deps.js +14 -5
- package/dist/core/checks/dependencies/dependency-check.js +1 -1
- package/dist/core/checks/dependencies/grype-scan.js +2 -1
- package/dist/core/checks/dependencies/vuln-scan.js +2 -1
- package/dist/core/checks/findings.d.ts +46 -2
- package/dist/core/checks/findings.js +140 -2
- package/dist/core/checks/git/custom-rules.js +2 -0
- package/dist/core/checks/git/diff-smell.js +6 -1
- package/dist/core/checks/git/leftover-debug.js +8 -2
- package/dist/core/checks/git/lockfile-drift.js +6 -1
- package/dist/core/checks/git/merge-conflict.js +1 -1
- package/dist/core/checks/git/missing-tests.js +1 -1
- package/dist/core/checks/git/todo-fixme.js +10 -2
- package/dist/core/checks/languages/cpp-format.js +19 -42
- package/dist/core/checks/languages/external-checks.d.ts +77 -0
- package/dist/core/checks/languages/external-checks.js +200 -0
- package/dist/core/checks/languages/external-runner.d.ts +119 -0
- package/dist/core/checks/languages/external-runner.js +143 -0
- package/dist/core/checks/languages/go-format.d.ts +4 -4
- package/dist/core/checks/languages/go-format.js +151 -30
- package/dist/core/checks/languages/lint.js +19 -19
- package/dist/core/checks/languages/prettier.js +2 -7
- package/dist/core/checks/languages/python-typecheck.d.ts +10 -5
- package/dist/core/checks/languages/python-typecheck.js +161 -37
- package/dist/core/checks/languages/typecheck.js +10 -8
- package/dist/core/checks/pr/commitlint.js +7 -1
- package/dist/core/checks/quality/case-collision.js +6 -4
- package/dist/core/checks/quality/config-validation.d.ts +0 -7
- package/dist/core/checks/quality/config-validation.js +30 -0
- package/dist/core/checks/quality/duplicate-check.js +54 -27
- package/dist/core/checks/quality/license-header.js +7 -4
- package/dist/core/checks/quality/markdown-lint.js +1 -6
- package/dist/core/checks/quality/spellcheck.js +34 -5
- package/dist/core/checks/quality/symlinks.js +6 -4
- package/dist/core/checks/security/diff-security.js +26 -18
- package/dist/core/checks/security/dockerfile-security.js +7 -1
- package/dist/core/checks/security/migration-safety.js +32 -4
- package/dist/core/checks/security/security.d.ts +56 -0
- package/dist/core/checks/security/security.js +135 -11
- package/dist/core/checks/security/sensitive-file-guard.js +40 -6
- package/dist/core/checks/security/workflow-security.js +7 -1
- package/dist/core/checks/tool-install.d.ts +102 -0
- package/dist/core/checks/tool-install.js +69 -0
- package/dist/core/context.js +9 -1
- package/dist/core/install/cache.js +42 -11
- package/dist/core/install/doctor.js +9 -0
- package/dist/core/install/ensure.js +22 -3
- package/dist/core/install/lock.d.ts +2 -1
- package/dist/core/install/prepare.d.ts +75 -0
- package/dist/core/install/prepare.js +151 -0
- package/dist/core/install/profiles.d.ts +17 -3
- package/dist/core/install/profiles.js +27 -0
- package/dist/core/install/reuse.d.ts +41 -0
- package/dist/core/install/reuse.js +107 -0
- package/dist/core/registry.js +10 -18
- package/dist/core/run-checks.js +74 -3
- package/dist/core/targets.d.ts +116 -5
- package/dist/core/targets.js +101 -16
- package/dist/core/types.d.ts +8 -2
- package/dist/init.js +21 -1
- package/dist/interfaces/index.d.ts +1 -0
- package/dist/utils/resolve-bin.d.ts +56 -6
- package/dist/utils/resolve-bin.js +136 -26
- package/gitleaks.toml +14 -0
- package/install-manifests/go/package-lock.json +24 -0
- package/install-manifests/go/package.json +8 -0
- package/install-manifests/pytypes/package-lock.json +45 -0
- package/install-manifests/pytypes/package.json +8 -0
- package/package.json +4 -2
- package/pr-checkmate.schema.json +8 -2
- package/src/config/cspell.json +155 -133
- package/src/config/pr-checkmate.schema.json +8 -2
- package/dist/core/checks/languages/csharp-format.d.ts +0 -13
- package/dist/core/checks/languages/csharp-format.js +0 -52
- package/dist/core/checks/languages/go-lint.d.ts +0 -8
- package/dist/core/checks/languages/go-lint.js +0 -48
- package/dist/core/checks/languages/kotlin-lint.d.ts +0 -2
- package/dist/core/checks/languages/kotlin-lint.js +0 -42
- package/dist/core/checks/languages/php-format.d.ts +0 -9
- package/dist/core/checks/languages/php-format.js +0 -49
- package/dist/core/checks/languages/ruby-lint.d.ts +0 -8
- package/dist/core/checks/languages/ruby-lint.js +0 -50
- package/dist/core/checks/languages/rust-format.d.ts +0 -9
- package/dist/core/checks/languages/rust-format.js +0 -45
- package/dist/core/checks/languages/rust-lint.d.ts +0 -8
- package/dist/core/checks/languages/rust-lint.js +0 -50
- package/dist/core/checks/languages/shellcheck.d.ts +0 -8
- package/dist/core/checks/languages/shellcheck.js +0 -55
- package/dist/core/checks/languages/swift-lint.d.ts +0 -2
- package/dist/core/checks/languages/swift-lint.js +0 -46
package/dist/core/context.js
CHANGED
|
@@ -192,7 +192,14 @@ async function buildContext(opts = {}) {
|
|
|
192
192
|
// rather than calling getIgnoredDirs() — that one resolves against process.cwd(),
|
|
193
193
|
// which is not necessarily the `cwd` under review.
|
|
194
194
|
applyLogConfig(config);
|
|
195
|
-
|
|
195
|
+
// Detected as usage (language + file count), not as a bare list: the same scan
|
|
196
|
+
// answers two different questions, and the install layer needs the counts. A
|
|
197
|
+
// `Cargo.toml` beside zero `.rs` files is enough to run Rust checks and not
|
|
198
|
+
// enough to download a toolchain — see `detectLanguageUsage`.
|
|
199
|
+
const usage = opts.languages
|
|
200
|
+
? opts.languages.map(lang => ({ lang, files: 1, marker: true }))
|
|
201
|
+
: await (0, detect_language_1.detectLanguageUsage)(cwd, config.ignoreDirs, config.detect);
|
|
202
|
+
const languages = usage.map(u => u.lang);
|
|
196
203
|
const { baseSha, headSha, scope } = await resolveDiffRange(cwd, opts);
|
|
197
204
|
// `scope` is logged because it decides what the whole run looks at, and getting
|
|
198
205
|
// it wrong is invisible in the results: a run that reviewed one commit and one
|
|
@@ -201,6 +208,7 @@ async function buildContext(opts = {}) {
|
|
|
201
208
|
return {
|
|
202
209
|
cwd,
|
|
203
210
|
languages,
|
|
211
|
+
usage,
|
|
204
212
|
config,
|
|
205
213
|
write: opts.write ?? false,
|
|
206
214
|
baseSha,
|
|
@@ -24,14 +24,13 @@ function expandHome(dir, home = node_os_1.default.homedir()) {
|
|
|
24
24
|
return home;
|
|
25
25
|
return dir.startsWith('~/') ? node_path_1.default.join(home, dir.slice(2)) : dir;
|
|
26
26
|
}
|
|
27
|
-
/** A directory we can actually write to
|
|
28
|
-
function
|
|
27
|
+
/** A directory we can actually write to. */
|
|
28
|
+
function writable(dir) {
|
|
29
29
|
try {
|
|
30
30
|
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
31
|
-
// Existence is not permission
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// and a stack trace halfway through fetching.
|
|
31
|
+
// Existence is not permission, and finding that out here rather than at write
|
|
32
|
+
// time is the difference between a clear message and a stack trace halfway
|
|
33
|
+
// through fetching.
|
|
35
34
|
node_fs_1.default.accessSync(dir, node_fs_1.default.constants.W_OK);
|
|
36
35
|
return true;
|
|
37
36
|
}
|
|
@@ -39,6 +38,30 @@ function usable(dir) {
|
|
|
39
38
|
return false;
|
|
40
39
|
}
|
|
41
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* A directory that already exists and can at least be read.
|
|
43
|
+
*
|
|
44
|
+
* Only ever asked of a cache path the client named explicitly, and only after
|
|
45
|
+
* {@link writable} has said no. That combination has exactly one meaning: a cache
|
|
46
|
+
* baked into a container image, read-only on purpose, holding tooling somebody
|
|
47
|
+
* warmed at build time. Reading it needs no write permission, and refusing it
|
|
48
|
+
* because a *future* fetch could not write there discards the tooling that is
|
|
49
|
+
* sitting right there — which is how our own image lost `Python Types` under
|
|
50
|
+
* `--user` (ticket 083).
|
|
51
|
+
*
|
|
52
|
+
* A fetch into such a directory still fails, in `ensureProfile`, with the path it
|
|
53
|
+
* could not create. That is the clear message the write check was for; it is just
|
|
54
|
+
* no longer paid for by every read.
|
|
55
|
+
*/
|
|
56
|
+
function readable(dir) {
|
|
57
|
+
try {
|
|
58
|
+
node_fs_1.default.accessSync(dir, node_fs_1.default.constants.R_OK | node_fs_1.default.constants.X_OK);
|
|
59
|
+
return node_fs_1.default.statSync(dir).isDirectory();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
42
65
|
/**
|
|
43
66
|
* Resolve the cache directory, falling back rather than failing.
|
|
44
67
|
*
|
|
@@ -62,12 +85,20 @@ function resolveCacheDir(version, configured, env = process.env) {
|
|
|
62
85
|
candidates.push({ dir: node_path_1.default.join(node_os_1.default.tmpdir(), 'pr-checkmate-cache'), reason: 'temp directory' });
|
|
63
86
|
for (const [index, candidate] of candidates.entries()) {
|
|
64
87
|
const dir = node_path_1.default.join(candidate.dir, version);
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
88
|
+
if (writable(dir)) {
|
|
89
|
+
if (index > 0) {
|
|
90
|
+
logger_1.logger.warn(`[install]: ⚠️ cache directory "${candidates[0].dir}" is not writable — using ${candidate.reason} (${dir}). Fetched tooling will not survive a container rebuild.`);
|
|
91
|
+
}
|
|
92
|
+
return { dir, preferred: index === 0, reason: candidate.reason };
|
|
93
|
+
}
|
|
94
|
+
// An explicitly named cache that cannot be written but CAN be read is still
|
|
95
|
+
// the answer — see {@link readable} — and it has to win here rather than in a
|
|
96
|
+
// second pass at the end. Otherwise the writable-but-empty temp directory two
|
|
97
|
+
// candidates down takes precedence over a read-only cache holding the very
|
|
98
|
+
// tooling this run needs, which is the shape our own image has.
|
|
99
|
+
if (index === 0 && explicit && readable(dir)) {
|
|
100
|
+
return { dir, preferred: true, reason: 'configured, read-only' };
|
|
69
101
|
}
|
|
70
|
-
return { dir, preferred: index === 0, reason: candidate.reason };
|
|
71
102
|
}
|
|
72
103
|
return null;
|
|
73
104
|
}
|
|
@@ -17,6 +17,7 @@ const package_version_1 = require("../../utils/package-version");
|
|
|
17
17
|
Object.defineProperty(exports, "packageVersion", { enumerable: true, get: function () { return package_version_1.packageVersion; } });
|
|
18
18
|
const cache_1 = require("./cache");
|
|
19
19
|
const ensure_1 = require("./ensure");
|
|
20
|
+
const prepare_1 = require("./prepare");
|
|
20
21
|
const profiles_1 = require("./profiles");
|
|
21
22
|
/**
|
|
22
23
|
* Everything the install layer knows about this repository, in one shot.
|
|
@@ -52,6 +53,14 @@ async function installProfiles(cwd = process.cwd(), all = false) {
|
|
|
52
53
|
const denied = new Set(install.deny ?? []);
|
|
53
54
|
const wanted = (all ? profiles_1.PROFILES.map(p => p.name) : decisions.filter(d => d.required).map(d => d.profile)).filter(name => {
|
|
54
55
|
const profile = profiles_1.PROFILES.find(p => p.name === name);
|
|
56
|
+
// Nothing to fetch for a profile whose packages already ship with us, and
|
|
57
|
+
// fetching it anyway is not merely wasted: `--all` downloaded a second copy
|
|
58
|
+
// of every bundled profile — some 800 packages — beside the ones already in
|
|
59
|
+
// `node_modules`. On a container build that lands in the image (ticket 083).
|
|
60
|
+
if (profile && (0, prepare_1.profileSatisfiedLocally)(profile)) {
|
|
61
|
+
logger_1.logger.info(`[install]: profile "${name}" already ships with the package`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
55
64
|
const blocked = profile?.packages.filter(pkg => denied.has(pkg)) ?? [];
|
|
56
65
|
if (blocked.length === 0)
|
|
57
66
|
return true;
|
|
@@ -74,9 +74,6 @@ function stageManifest(from, to) {
|
|
|
74
74
|
*/
|
|
75
75
|
async function ensureProfile(profile, opts) {
|
|
76
76
|
const { install } = opts;
|
|
77
|
-
if (install.strategy === 'manual') {
|
|
78
|
-
return { profile, status: 'skipped', reason: 'install.strategy is "manual"' };
|
|
79
|
-
}
|
|
80
77
|
const source = manifestDir(profile);
|
|
81
78
|
const expected = manifestHash(source);
|
|
82
79
|
if (!expected) {
|
|
@@ -94,6 +91,28 @@ async function ensureProfile(profile, opts) {
|
|
|
94
91
|
if (isInstalled(dir, expected)) {
|
|
95
92
|
return { profile, status: 'cached', reason: 'already installed', dir };
|
|
96
93
|
}
|
|
94
|
+
// Both of these mean "do not fetch", and both are asked AFTER the cache check
|
|
95
|
+
// above — because neither means "do not use what has already been fetched".
|
|
96
|
+
//
|
|
97
|
+
// `offline` was always in the right place; `manual` was not, and returned before
|
|
98
|
+
// the cache was ever looked at. So `install --all`, the command the product's
|
|
99
|
+
// own log recommends for pre-warming, did nothing for anyone who then set
|
|
100
|
+
// `manual` — including our Docker image, which sets it precisely because
|
|
101
|
+
// everything is supposed to already be there. `Python Types` in that image
|
|
102
|
+
// could not run at all (ticket 083).
|
|
103
|
+
// The env var as well as the config key, exactly as `PR_CHECKMATE_OFFLINE` is
|
|
104
|
+
// read below: the Docker image sets the strategy through the environment, and a
|
|
105
|
+
// guarantee that only holds when it is written in a config file is not a
|
|
106
|
+
// guarantee.
|
|
107
|
+
const strategy = process.env.PR_CHECKMATE_INSTALL_STRATEGY ?? install.strategy;
|
|
108
|
+
if (strategy === 'manual') {
|
|
109
|
+
return {
|
|
110
|
+
profile,
|
|
111
|
+
status: 'skipped',
|
|
112
|
+
reason: 'install.strategy is "manual" and the profile is not in the cache',
|
|
113
|
+
dir,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
97
116
|
if (install.offline || process.env.PR_CHECKMATE_OFFLINE === 'true') {
|
|
98
117
|
return {
|
|
99
118
|
profile,
|
|
@@ -25,9 +25,10 @@ export declare function processAlive(pid: number): boolean;
|
|
|
25
25
|
* @returns A release function, or `null` when the lock could not be created at
|
|
26
26
|
* all (an unwritable directory — the caller reports that, it is not fatal here).
|
|
27
27
|
*/
|
|
28
|
-
|
|
28
|
+
interface LockDeps {
|
|
29
29
|
sleep?: (ms: number) => Promise<void>;
|
|
30
30
|
now?: () => number;
|
|
31
31
|
alive?: (pid: number) => boolean;
|
|
32
32
|
}
|
|
33
33
|
export declare function withLock(dir: string, timeoutMs: number, deps?: LockDeps): Promise<(() => void) | null>;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { PRCheckMateConfig } from '../../interfaces';
|
|
2
|
+
import type { LanguageUsage } from '../../utils/detect-language';
|
|
3
|
+
import { type EnsureOptions } from './ensure';
|
|
4
|
+
import { type Profile } from './profiles';
|
|
5
|
+
/**
|
|
6
|
+
* Whether a profile needs fetching at all, or already ships inside the package.
|
|
7
|
+
*
|
|
8
|
+
* This is the hinge the whole adaptive-install rollout turns on. Today every
|
|
9
|
+
* profile's packages sit in our `dependencies`, so every profile answers `true`
|
|
10
|
+
* here, nothing is ever fetched, and a run is byte-for-byte the run it was
|
|
11
|
+
* before any of this existed. When a package is moved out of `dependencies` it
|
|
12
|
+
* stops being present, its profile starts answering `false`, and the fetch turns
|
|
13
|
+
* itself on — no flag, no second code path, nothing to remember to flip.
|
|
14
|
+
*
|
|
15
|
+
* Which is what makes releases 2 and 3 of ticket 064 an edit to `package.json`
|
|
16
|
+
* rather than another change to this logic.
|
|
17
|
+
*/
|
|
18
|
+
export declare function profileSatisfiedLocally(profile: Profile): boolean;
|
|
19
|
+
/** What the run knows about tooling before the first check starts. */
|
|
20
|
+
export interface ToolingPlan {
|
|
21
|
+
/** Check name → why its tooling is not available. Empty on a healthy run. */
|
|
22
|
+
unavailable: Map<string, string>;
|
|
23
|
+
/** Profile directories tool resolution should search. */
|
|
24
|
+
dirs: string[];
|
|
25
|
+
/**
|
|
26
|
+
* True when tooling is missing and `install.onMissingTool` is `"error"`. The
|
|
27
|
+
* affected checks fail instead of skipping: in a closed contour a check that
|
|
28
|
+
* quietly stops running is worse than a red build.
|
|
29
|
+
*/
|
|
30
|
+
fatal: boolean;
|
|
31
|
+
}
|
|
32
|
+
interface PrepareOptions {
|
|
33
|
+
cwd: string;
|
|
34
|
+
config: PRCheckMateConfig;
|
|
35
|
+
/** Detected languages with file counts — reused so the tree is scanned once. */
|
|
36
|
+
usage: LanguageUsage[];
|
|
37
|
+
/** Names of the checks this run intends to execute. */
|
|
38
|
+
selected: string[];
|
|
39
|
+
/** pr-checkmate's own version; part of the cache path. */
|
|
40
|
+
version: string;
|
|
41
|
+
/**
|
|
42
|
+
* The profile catalogue. Defaults to the shipped one; injected by tests.
|
|
43
|
+
*
|
|
44
|
+
* A seam rather than a mock of the module, for the same reason `ensure.ts` has
|
|
45
|
+
* one: every profile currently ships inside the package, so the interesting
|
|
46
|
+
* half of this function — a profile that is genuinely absent — is unreachable
|
|
47
|
+
* without either a network install in a unit test or a way to describe a
|
|
48
|
+
* profile that is not here. The code that runs when a package finally leaves
|
|
49
|
+
* `dependencies` must be tested before that day, not on it.
|
|
50
|
+
*/
|
|
51
|
+
profiles?: Profile[];
|
|
52
|
+
/** The fetch itself, forwarded to `ensureProfile`. Injected by tests. */
|
|
53
|
+
installer?: EnsureOptions['installer'];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Make sure the tooling this run needs is present, and say what is missing.
|
|
57
|
+
*
|
|
58
|
+
* Runs once, before the first check, and answers two questions the run cannot
|
|
59
|
+
* proceed honestly without: which directories tool resolution should search, and
|
|
60
|
+
* which checks have no tool to run. The second is the one ticket 057 is about —
|
|
61
|
+
* a check whose tooling never arrived must appear in the report saying so, not
|
|
62
|
+
* vanish from it.
|
|
63
|
+
*
|
|
64
|
+
* Only profiles backing checks that are actually selected are considered, so
|
|
65
|
+
* `severity: { "Spellcheck": "off" }` stops costing a download rather than only a
|
|
66
|
+
* row in the report (lever B of ticket 064).
|
|
67
|
+
*/
|
|
68
|
+
export declare function prepareTooling(opts: PrepareOptions): Promise<ToolingPlan>;
|
|
69
|
+
/**
|
|
70
|
+
* The plan for a run where preparation did not happen — nothing missing, nothing
|
|
71
|
+
* registered. Built fresh each time rather than shared: a caller holding a plan
|
|
72
|
+
* must not be able to see another run's.
|
|
73
|
+
*/
|
|
74
|
+
export declare function noTooling(): ToolingPlan;
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.profileSatisfiedLocally = profileSatisfiedLocally;
|
|
4
|
+
exports.prepareTooling = prepareTooling;
|
|
5
|
+
exports.noTooling = noTooling;
|
|
6
|
+
const logger_1 = require("../../utils/logger");
|
|
7
|
+
const resolve_bin_1 = require("../../utils/resolve-bin");
|
|
8
|
+
const cache_1 = require("./cache");
|
|
9
|
+
const ensure_1 = require("./ensure");
|
|
10
|
+
const profiles_1 = require("./profiles");
|
|
11
|
+
/**
|
|
12
|
+
* Whether a profile needs fetching at all, or already ships inside the package.
|
|
13
|
+
*
|
|
14
|
+
* This is the hinge the whole adaptive-install rollout turns on. Today every
|
|
15
|
+
* profile's packages sit in our `dependencies`, so every profile answers `true`
|
|
16
|
+
* here, nothing is ever fetched, and a run is byte-for-byte the run it was
|
|
17
|
+
* before any of this existed. When a package is moved out of `dependencies` it
|
|
18
|
+
* stops being present, its profile starts answering `false`, and the fetch turns
|
|
19
|
+
* itself on — no flag, no second code path, nothing to remember to flip.
|
|
20
|
+
*
|
|
21
|
+
* Which is what makes releases 2 and 3 of ticket 064 an edit to `package.json`
|
|
22
|
+
* rather than another change to this logic.
|
|
23
|
+
*/
|
|
24
|
+
function profileSatisfiedLocally(profile) {
|
|
25
|
+
return profile.packages.every(pkg => (0, resolve_bin_1.packagePresent)(pkg));
|
|
26
|
+
}
|
|
27
|
+
/** Profiles blocked outright by `install.deny`, with a line saying so. */
|
|
28
|
+
function denied(profile, deny) {
|
|
29
|
+
const blocked = profile.packages.filter(pkg => deny.has(pkg));
|
|
30
|
+
if (blocked.length === 0)
|
|
31
|
+
return null;
|
|
32
|
+
return `install.deny lists ${blocked.join(', ')}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Make sure the tooling this run needs is present, and say what is missing.
|
|
36
|
+
*
|
|
37
|
+
* Runs once, before the first check, and answers two questions the run cannot
|
|
38
|
+
* proceed honestly without: which directories tool resolution should search, and
|
|
39
|
+
* which checks have no tool to run. The second is the one ticket 057 is about —
|
|
40
|
+
* a check whose tooling never arrived must appear in the report saying so, not
|
|
41
|
+
* vanish from it.
|
|
42
|
+
*
|
|
43
|
+
* Only profiles backing checks that are actually selected are considered, so
|
|
44
|
+
* `severity: { "Spellcheck": "off" }` stops costing a download rather than only a
|
|
45
|
+
* row in the report (lever B of ticket 064).
|
|
46
|
+
*/
|
|
47
|
+
async function prepareTooling(opts) {
|
|
48
|
+
// Every run starts from no registered roots. The SDK can call `runChecks` more
|
|
49
|
+
// than once in one process, and a directory registered by a previous run — for
|
|
50
|
+
// a different repository, with a different profile set — must not still be on
|
|
51
|
+
// the search path for this one.
|
|
52
|
+
(0, resolve_bin_1.clearToolPaths)();
|
|
53
|
+
const install = opts.config.install ?? {};
|
|
54
|
+
const strategy = process.env.PR_CHECKMATE_INSTALL_STRATEGY ?? install.strategy ?? 'adaptive';
|
|
55
|
+
const onMissing = install.onMissingTool ?? 'auto';
|
|
56
|
+
const deny = new Set(install.deny ?? []);
|
|
57
|
+
// Which profiles this repository needs: languages by file count, universal
|
|
58
|
+
// profiles by whether their check survived config. `install.profiles` and
|
|
59
|
+
// `excludeProfiles` have already had their say inside `planProfiles`.
|
|
60
|
+
const required = new Set((0, profiles_1.planProfiles)(opts.usage, opts.config)
|
|
61
|
+
.filter(decision => decision.required)
|
|
62
|
+
.map(decision => decision.profile));
|
|
63
|
+
// A profile nobody in this run would use is not worth a thought, let alone a
|
|
64
|
+
// download. `eager` opts out of that reasoning by definition: it exists so a
|
|
65
|
+
// container build or an air-gapped mirror can hold everything up front.
|
|
66
|
+
const chosen = opts.selected.length > 0 ? new Set(opts.selected) : null;
|
|
67
|
+
const wanted = (opts.profiles ?? profiles_1.PROFILES).filter(profile => {
|
|
68
|
+
if (strategy === 'eager')
|
|
69
|
+
return true;
|
|
70
|
+
if (!required.has(profile.name))
|
|
71
|
+
return false;
|
|
72
|
+
return !chosen || profile.checks.some(check => chosen.has(check));
|
|
73
|
+
});
|
|
74
|
+
const unavailable = new Map();
|
|
75
|
+
const dirs = [];
|
|
76
|
+
const miss = (profile, reason) => {
|
|
77
|
+
for (const check of profile.checks)
|
|
78
|
+
unavailable.set(check, reason);
|
|
79
|
+
};
|
|
80
|
+
// Resolved lazily, and at most once.
|
|
81
|
+
//
|
|
82
|
+
// Lazily because resolving is not a read: it creates the directory and, when the
|
|
83
|
+
// configured one is unwritable, warns about where fetched tooling will live
|
|
84
|
+
// instead. On a run that fetches nothing — every run, while the tooling still
|
|
85
|
+
// ships in `dependencies` — that warning is about a thing that never happens,
|
|
86
|
+
// and a container with a read-only home would print it on every single run.
|
|
87
|
+
// At most once because the warning belongs to the run, not to each profile.
|
|
88
|
+
let location;
|
|
89
|
+
const cacheDir = () => {
|
|
90
|
+
if (location === undefined)
|
|
91
|
+
location = (0, cache_1.resolveCacheDir)(opts.version, install.cacheDir);
|
|
92
|
+
return location;
|
|
93
|
+
};
|
|
94
|
+
for (const profile of wanted) {
|
|
95
|
+
// Bundled tooling is the answer to "where is it" for every profile today,
|
|
96
|
+
// and asking first is what keeps this whole path free of network calls.
|
|
97
|
+
if (profileSatisfiedLocally(profile))
|
|
98
|
+
continue;
|
|
99
|
+
const block = denied(profile, deny);
|
|
100
|
+
if (block) {
|
|
101
|
+
miss(profile, block);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// No early exit for `install.strategy: "manual"` any more. It means "never
|
|
105
|
+
// fetch", and `ensureProfile` enforces that — but only after checking the
|
|
106
|
+
// cache, so a pre-warmed profile is still used. Returning here instead
|
|
107
|
+
// meant `manual` also refused tooling that was already downloaded.
|
|
108
|
+
if (onMissing === 'skip') {
|
|
109
|
+
miss(profile, 'install.onMissingTool is "skip"');
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const cache = cacheDir();
|
|
113
|
+
if (!cache) {
|
|
114
|
+
miss(profile, 'no writable cache directory for fetched tooling');
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const result = await (0, ensure_1.ensureProfile)(profile.name, {
|
|
118
|
+
cwd: opts.cwd,
|
|
119
|
+
version: opts.version,
|
|
120
|
+
install,
|
|
121
|
+
...(opts.installer ? { installer: opts.installer } : {}),
|
|
122
|
+
});
|
|
123
|
+
if (result.status === 'failed' || result.status === 'skipped') {
|
|
124
|
+
miss(profile, result.reason);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
dirs.push(result.dir ?? (0, cache_1.profileDir)(cache.dir, profile.name));
|
|
128
|
+
}
|
|
129
|
+
if (unavailable.size > 0) {
|
|
130
|
+
// Always logged, never behind --debug. A check that did not run is the
|
|
131
|
+
// failure mode this whole layer exists to make loud.
|
|
132
|
+
const names = [...new Set(unavailable.keys())].join(', ');
|
|
133
|
+
logger_1.logger.warn(`[install]: ⚠️ tooling unavailable — these checks cannot run: ${names}`);
|
|
134
|
+
if (onMissing === 'error') {
|
|
135
|
+
logger_1.logger.error('[install]: ❌ install.onMissingTool is "error" — the affected checks fail');
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
logger_1.logger.info('[install]: Pre-warm the cache with: npx pr-checkmate install --all');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
(0, resolve_bin_1.registerToolPaths)(dirs);
|
|
142
|
+
return { unavailable, dirs, fatal: onMissing === 'error' && unavailable.size > 0 };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The plan for a run where preparation did not happen — nothing missing, nothing
|
|
146
|
+
* registered. Built fresh each time rather than shared: a caller holding a plan
|
|
147
|
+
* must not be able to see another run's.
|
|
148
|
+
*/
|
|
149
|
+
function noTooling() {
|
|
150
|
+
return { unavailable: new Map(), dirs: [], fatal: false };
|
|
151
|
+
}
|
|
@@ -12,7 +12,7 @@ import type { Language, LanguageUsage } from '../../utils/detect-language';
|
|
|
12
12
|
* honest report: when a profile is missing, exactly those checks skip, with the
|
|
13
13
|
* reason, instead of quietly disappearing (the mistake of ticket 057).
|
|
14
14
|
*/
|
|
15
|
-
interface Profile {
|
|
15
|
+
export interface Profile {
|
|
16
16
|
name: ProfileName;
|
|
17
17
|
/** Top-level packages installed for this profile. */
|
|
18
18
|
packages: string[];
|
|
@@ -23,8 +23,23 @@ interface Profile {
|
|
|
23
23
|
* enabled checks rather than by what the repository is written in.
|
|
24
24
|
*/
|
|
25
25
|
languages?: Language[];
|
|
26
|
+
/**
|
|
27
|
+
* An extra condition on the client's config, for a language profile whose
|
|
28
|
+
* checks can be switched off by a key of their own.
|
|
29
|
+
*
|
|
30
|
+
* `severity: { "X": "off" }` is already honoured for every profile, but it is
|
|
31
|
+
* not the only off switch: `python.typecheck: false` and `go.enabled: false`
|
|
32
|
+
* are the ones a client actually writes, and a language profile decided on
|
|
33
|
+
* file counts alone downloads the tooling regardless. For `pytypes` that is
|
|
34
|
+
* 19 MB fetched to run nothing.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately a predicate over the config rather than a list of keys: the
|
|
37
|
+
* condition it mirrors lives in the check's own `applies`, and expressing it
|
|
38
|
+
* the same way is what makes the two readable side by side.
|
|
39
|
+
*/
|
|
40
|
+
enabledBy?(config: PRCheckMateConfig): boolean;
|
|
26
41
|
}
|
|
27
|
-
export type ProfileName = 'core' | 'js' | 'python' | 'cpp' | 'spellcheck' | 'markdown' | 'commitlint' | 'duplicate';
|
|
42
|
+
export type ProfileName = 'core' | 'js' | 'python' | 'pytypes' | 'cpp' | 'go' | 'spellcheck' | 'markdown' | 'commitlint' | 'duplicate';
|
|
28
43
|
/**
|
|
29
44
|
* The catalogue.
|
|
30
45
|
*
|
|
@@ -61,4 +76,3 @@ export interface ProfileDecision {
|
|
|
61
76
|
* @returns One decision per profile, in catalogue order.
|
|
62
77
|
*/
|
|
63
78
|
export declare function planProfiles(usage: LanguageUsage[], config: PRCheckMateConfig): ProfileDecision[];
|
|
64
|
-
export {};
|
|
@@ -54,12 +54,33 @@ exports.PROFILES = [
|
|
|
54
54
|
checks: ['Ruff Lint', 'Ruff Format'],
|
|
55
55
|
languages: ['python'],
|
|
56
56
|
},
|
|
57
|
+
{
|
|
58
|
+
// Separate from `python` because it is 19 MB against ruff's 12, and because
|
|
59
|
+
// the two answer different questions: plenty of repositories want the linter
|
|
60
|
+
// and have their own opinion about type checking. Bundling them would make
|
|
61
|
+
// `python.typecheck: false` cost the download anyway, which is the feature
|
|
62
|
+
// defeating itself.
|
|
63
|
+
name: 'pytypes',
|
|
64
|
+
packages: ['pyright'],
|
|
65
|
+
checks: ['Python Types'],
|
|
66
|
+
languages: ['python'],
|
|
67
|
+
enabledBy: config => config.python?.typecheck !== false && config.python?.enabled !== false,
|
|
68
|
+
},
|
|
57
69
|
{
|
|
58
70
|
name: 'cpp',
|
|
59
71
|
packages: ['@wasm-fmt/clang-format'],
|
|
60
72
|
checks: ['C++ Format'],
|
|
61
73
|
languages: ['cpp'],
|
|
62
74
|
},
|
|
75
|
+
{
|
|
76
|
+
// Go Format only. `go vet` needs the toolchain itself, which is not ours to
|
|
77
|
+
// ship, so it stays a runner dependency and is deliberately absent here.
|
|
78
|
+
name: 'go',
|
|
79
|
+
packages: ['@wasm-fmt/gofmt'],
|
|
80
|
+
checks: ['Go Format'],
|
|
81
|
+
languages: ['go'],
|
|
82
|
+
enabledBy: config => config.go?.enabled !== false,
|
|
83
|
+
},
|
|
63
84
|
{
|
|
64
85
|
// The four below are not languages. They are single checks with heavy
|
|
65
86
|
// tooling behind them, which is exactly why they are separate: switching
|
|
@@ -145,6 +166,12 @@ function decide(profile, usage, config, minFiles) {
|
|
|
145
166
|
if (profile.name === 'core')
|
|
146
167
|
return { required: true, reason: 'always installed' };
|
|
147
168
|
if (profile.languages) {
|
|
169
|
+
// Asked before the file counts, because it is the cheaper and the more
|
|
170
|
+
// definite answer: a client who wrote `python.typecheck: false` has settled
|
|
171
|
+
// the question, and counting `.py` files can only override them.
|
|
172
|
+
if (profile.enabledBy && !profile.enabledBy(config)) {
|
|
173
|
+
return { required: false, reason: `${profile.checks.join(', ')} switched off` };
|
|
174
|
+
}
|
|
148
175
|
const found = profile.languages
|
|
149
176
|
.map(lang => usage.find(u => u.lang === lang))
|
|
150
177
|
.filter((u) => Boolean(u) && u.files >= minFiles);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CheckContext } from '../types';
|
|
2
|
+
/** One reuse decision, with the sentence that explains it. */
|
|
3
|
+
interface ReuseDecision {
|
|
4
|
+
/** Search roots to prefer, or `undefined` to use our own copy. */
|
|
5
|
+
paths?: string[];
|
|
6
|
+
reason: string;
|
|
7
|
+
}
|
|
8
|
+
interface ReuseOptions {
|
|
9
|
+
/**
|
|
10
|
+
* The tool will read the client's OWN configuration file.
|
|
11
|
+
*
|
|
12
|
+
* When it will, the version check is the wrong question and must not be asked:
|
|
13
|
+
* a repository with ESLint 8 and a legacy `.eslintrc` can only be linted by
|
|
14
|
+
* ESLint 8, because ESLint 10 does not read eslintrc — and falling back to ours
|
|
15
|
+
* on the strength of a major mismatch would break precisely the case the reuse
|
|
16
|
+
* was there to serve. TypeScript is the same shape permanently: the check only
|
|
17
|
+
* applies when the repository has a `tsconfig.json`, so their compiler is
|
|
18
|
+
* always the one their config was written for.
|
|
19
|
+
*
|
|
20
|
+
* The version check exists for the opposite case — the client's old tool being
|
|
21
|
+
* handed configuration WE generated.
|
|
22
|
+
*/
|
|
23
|
+
clientOwnsConfig?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Decide whether this run should prefer the client's copy of `pkgName`.
|
|
27
|
+
*
|
|
28
|
+
* `compatible` (the default) reuses when the client's major matches ours, or when
|
|
29
|
+
* the client's own configuration is what the tool will read. A mismatch is not an
|
|
30
|
+
* error — we have our own copy — so it downgrades to ours with a line saying why.
|
|
31
|
+
*/
|
|
32
|
+
export declare function reuseDecision(pkgName: string, ctx: CheckContext, opts?: ReuseOptions): ReuseDecision;
|
|
33
|
+
/**
|
|
34
|
+
* The search roots a check should prefer for `pkgName`, logging the reason once.
|
|
35
|
+
*
|
|
36
|
+
* The reason is logged rather than returned to each caller because it answers the
|
|
37
|
+
* question every confusing lint result starts with — "whose ESLint ran" — and no
|
|
38
|
+
* check should have to remember to say so.
|
|
39
|
+
*/
|
|
40
|
+
export declare function clientToolPaths(pkgName: string, ctx: CheckContext, opts?: ReuseOptions): string[] | undefined;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.reuseDecision = reuseDecision;
|
|
7
|
+
exports.clientToolPaths = clientToolPaths;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const logger_1 = require("../../utils/logger");
|
|
11
|
+
const resolve_bin_1 = require("../../utils/resolve-bin");
|
|
12
|
+
/**
|
|
13
|
+
* The major version we declare for a tool, from our own manifest.
|
|
14
|
+
*
|
|
15
|
+
* Read from `dependencies` rather than from the installed tree: what matters is
|
|
16
|
+
* the version our configuration was written against, and a hoisted install can
|
|
17
|
+
* resolve to something else entirely.
|
|
18
|
+
*/
|
|
19
|
+
function ownMajor(pkgName) {
|
|
20
|
+
try {
|
|
21
|
+
const manifest = node_path_1.default.join((0, resolve_bin_1.ownPackageRoot)(), 'package.json');
|
|
22
|
+
const deps = JSON.parse(node_fs_1.default.readFileSync(manifest, 'utf-8')).dependencies;
|
|
23
|
+
const range = deps?.[pkgName];
|
|
24
|
+
if (!range)
|
|
25
|
+
return null;
|
|
26
|
+
// `^10.6.0`, `>=3.0.0`, `~5.1` — the leading number is the major in every
|
|
27
|
+
// form we declare. Deliberately not a semver parser: this package ships zero
|
|
28
|
+
// runtime dependencies on purpose, and a major comparison is the only
|
|
29
|
+
// question being asked.
|
|
30
|
+
const major = /(\d+)/.exec(range)?.[1];
|
|
31
|
+
return major ? Number(major) : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** The major version of a package as the client actually has it installed. */
|
|
38
|
+
function installedMajor(pkgName, from) {
|
|
39
|
+
let dir = from;
|
|
40
|
+
while (dir !== node_path_1.default.dirname(dir)) {
|
|
41
|
+
const manifest = node_path_1.default.join(dir, 'node_modules', pkgName, 'package.json');
|
|
42
|
+
try {
|
|
43
|
+
if (node_fs_1.default.existsSync(manifest)) {
|
|
44
|
+
const version = JSON.parse(node_fs_1.default.readFileSync(manifest, 'utf-8'))
|
|
45
|
+
.version;
|
|
46
|
+
const major = version ? /(\d+)/.exec(version)?.[1] : undefined;
|
|
47
|
+
return major ? Number(major) : null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
dir = node_path_1.default.dirname(dir);
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Decide whether this run should prefer the client's copy of `pkgName`.
|
|
59
|
+
*
|
|
60
|
+
* `compatible` (the default) reuses when the client's major matches ours, or when
|
|
61
|
+
* the client's own configuration is what the tool will read. A mismatch is not an
|
|
62
|
+
* error — we have our own copy — so it downgrades to ours with a line saying why.
|
|
63
|
+
*/
|
|
64
|
+
function reuseDecision(pkgName, ctx, opts = {}) {
|
|
65
|
+
const policy = ctx.config.install?.reuseLocal ?? 'compatible';
|
|
66
|
+
if (policy === 'off') {
|
|
67
|
+
return { reason: 'install.reuseLocal is "off" — using our own copy for reproducibility' };
|
|
68
|
+
}
|
|
69
|
+
const clientMajor = installedMajor(pkgName, ctx.cwd);
|
|
70
|
+
if (clientMajor === null) {
|
|
71
|
+
return { reason: `${pkgName} is not installed in the repository — using our own copy` };
|
|
72
|
+
}
|
|
73
|
+
if (policy === 'always') {
|
|
74
|
+
return {
|
|
75
|
+
paths: [ctx.cwd],
|
|
76
|
+
reason: `install.reuseLocal is "always" — using ${pkgName}@${clientMajor} from the repository`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (opts.clientOwnsConfig) {
|
|
80
|
+
return {
|
|
81
|
+
paths: [ctx.cwd],
|
|
82
|
+
reason: `using ${pkgName}@${clientMajor} from the repository — it reads the repository's own config`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const ourMajor = ownMajor(pkgName);
|
|
86
|
+
if (ourMajor === null || clientMajor === ourMajor) {
|
|
87
|
+
return {
|
|
88
|
+
paths: [ctx.cwd],
|
|
89
|
+
reason: `using ${pkgName}@${clientMajor} from the repository (matches ours)`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
reason: `the repository has ${pkgName}@${clientMajor} and our config is written for @${ourMajor} — using our own copy`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The search roots a check should prefer for `pkgName`, logging the reason once.
|
|
98
|
+
*
|
|
99
|
+
* The reason is logged rather than returned to each caller because it answers the
|
|
100
|
+
* question every confusing lint result starts with — "whose ESLint ran" — and no
|
|
101
|
+
* check should have to remember to say so.
|
|
102
|
+
*/
|
|
103
|
+
function clientToolPaths(pkgName, ctx, opts = {}) {
|
|
104
|
+
const decision = reuseDecision(pkgName, ctx, opts);
|
|
105
|
+
logger_1.logger.debug(`[reuseLocal]: ${decision.reason}`);
|
|
106
|
+
return decision.paths;
|
|
107
|
+
}
|