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
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[];
|
|
@@ -0,0 +1,99 @@
|
|
|
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.expandHome = expandHome;
|
|
7
|
+
exports.resolveCacheDir = resolveCacheDir;
|
|
8
|
+
exports.profileDir = profileDir;
|
|
9
|
+
exports.stalePaths = stalePaths;
|
|
10
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
11
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const logger_1 = require("../../utils/logger");
|
|
14
|
+
/**
|
|
15
|
+
* Expand a leading `~`.
|
|
16
|
+
*
|
|
17
|
+
* Config files are written by hand, `~/.cache/pr-checkmate` is what a person
|
|
18
|
+
* writes, and nothing in Node expands it — the directory would be created
|
|
19
|
+
* literally, as a folder named `~`, in whatever the working directory happened
|
|
20
|
+
* to be.
|
|
21
|
+
*/
|
|
22
|
+
function expandHome(dir, home = node_os_1.default.homedir()) {
|
|
23
|
+
if (dir === '~')
|
|
24
|
+
return home;
|
|
25
|
+
return dir.startsWith('~/') ? node_path_1.default.join(home, dir.slice(2)) : dir;
|
|
26
|
+
}
|
|
27
|
+
/** A directory we can actually write to, or null. */
|
|
28
|
+
function usable(dir) {
|
|
29
|
+
try {
|
|
30
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
31
|
+
// Existence is not permission: a cache directory baked into a container
|
|
32
|
+
// image is often present and read-only, and finding that out at install
|
|
33
|
+
// time rather than at write time is the difference between a clear message
|
|
34
|
+
// and a stack trace halfway through fetching.
|
|
35
|
+
node_fs_1.default.accessSync(dir, node_fs_1.default.constants.W_OK);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the cache directory, falling back rather than failing.
|
|
44
|
+
*
|
|
45
|
+
* Every candidate below is a real environment: containers without `HOME`, CI
|
|
46
|
+
* images with a read-only home, and runners where only the temp directory is
|
|
47
|
+
* writable. A tool that refuses to work in any of them is a tool that fails on
|
|
48
|
+
* CI for reasons the client cannot see.
|
|
49
|
+
*
|
|
50
|
+
* @param version The pr-checkmate version, kept in the path so two projects on
|
|
51
|
+
* different versions never share fetched tooling.
|
|
52
|
+
* @param configured `install.cacheDir`, if the client set one.
|
|
53
|
+
*/
|
|
54
|
+
function resolveCacheDir(version, configured, env = process.env) {
|
|
55
|
+
const explicit = env.PR_CHECKMATE_CACHE_DIR ?? configured;
|
|
56
|
+
const candidates = [];
|
|
57
|
+
if (explicit)
|
|
58
|
+
candidates.push({ dir: expandHome(explicit), reason: 'configured' });
|
|
59
|
+
// `homedir()` on a container without HOME returns something like `/` — hence
|
|
60
|
+
// the write check rather than a bare existence test.
|
|
61
|
+
candidates.push({ dir: node_path_1.default.join(node_os_1.default.homedir(), '.cache', 'pr-checkmate'), reason: 'home cache' });
|
|
62
|
+
candidates.push({ dir: node_path_1.default.join(node_os_1.default.tmpdir(), 'pr-checkmate-cache'), reason: 'temp directory' });
|
|
63
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
64
|
+
const dir = node_path_1.default.join(candidate.dir, version);
|
|
65
|
+
if (!usable(dir))
|
|
66
|
+
continue;
|
|
67
|
+
if (index > 0) {
|
|
68
|
+
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.`);
|
|
69
|
+
}
|
|
70
|
+
return { dir, preferred: index === 0, reason: candidate.reason };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/** Where a single profile's tooling is installed. */
|
|
75
|
+
function profileDir(cacheDir, profile) {
|
|
76
|
+
return node_path_1.default.join(cacheDir, profile);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Profile directories left behind by other versions of pr-checkmate.
|
|
80
|
+
*
|
|
81
|
+
* Nothing removes these automatically: an upgrade must not delete tooling a
|
|
82
|
+
* concurrently running older version is in the middle of using. `cache prune`
|
|
83
|
+
* is the deliberate, separate act.
|
|
84
|
+
*/
|
|
85
|
+
function stalePaths(version, configured) {
|
|
86
|
+
const location = resolveCacheDir(version, configured);
|
|
87
|
+
if (!location)
|
|
88
|
+
return [];
|
|
89
|
+
const root = node_path_1.default.dirname(location.dir);
|
|
90
|
+
try {
|
|
91
|
+
return node_fs_1.default
|
|
92
|
+
.readdirSync(root, { withFileTypes: true })
|
|
93
|
+
.filter(entry => entry.isDirectory() && entry.name !== version)
|
|
94
|
+
.map(entry => node_path_1.default.join(root, entry.name));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type LanguageUsage } from '../../utils/detect-language';
|
|
2
|
+
import { type EnsureResult } from './ensure';
|
|
3
|
+
import { type ProfileDecision } from './profiles';
|
|
4
|
+
/** Our own version, read from the package manifest that ships with us. */
|
|
5
|
+
export declare function packageVersion(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Everything the install layer knows about this repository, in one shot.
|
|
8
|
+
*
|
|
9
|
+
* `usage` is a parameter rather than always detected here so a caller that
|
|
10
|
+
* already has it — `doctor` prints it — does not scan the repository twice and
|
|
11
|
+
* log two contradictory-looking detection lines for one command.
|
|
12
|
+
*/
|
|
13
|
+
export declare function planInstall(cwd?: string, usage?: LanguageUsage[]): Promise<{
|
|
14
|
+
decisions: ProfileDecision[];
|
|
15
|
+
cacheDir: string | null;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* Fetch every profile this repository needs.
|
|
19
|
+
*
|
|
20
|
+
* Shared by the `install` command and by the run itself, so a client who never
|
|
21
|
+
* runs `install` gets the same result lazily. `all` is what a container build or
|
|
22
|
+
* an air-gapped mirror wants: everything, regardless of what this repository
|
|
23
|
+
* happens to contain.
|
|
24
|
+
*/
|
|
25
|
+
export declare function installProfiles(cwd?: string, all?: boolean): Promise<EnsureResult[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Explain what this repository needs, what it has, and what is missing — and why.
|
|
28
|
+
*
|
|
29
|
+
* The command exists because adaptive installation makes "why did my typecheck
|
|
30
|
+
* not run" a routine question, and the answer must be a command rather than a
|
|
31
|
+
* reading of our source.
|
|
32
|
+
*/
|
|
33
|
+
export declare function runDoctor(cwd?: string): Promise<void>;
|
|
34
|
+
/** Remove cached tooling belonging to other versions of pr-checkmate. */
|
|
35
|
+
export declare function pruneCache(): void;
|
|
@@ -0,0 +1,145 @@
|
|
|
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.packageVersion = packageVersion;
|
|
7
|
+
exports.planInstall = planInstall;
|
|
8
|
+
exports.installProfiles = installProfiles;
|
|
9
|
+
exports.runDoctor = runDoctor;
|
|
10
|
+
exports.pruneCache = pruneCache;
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const loader_1 = require("../../config/loader");
|
|
14
|
+
const detect_language_1 = require("../../utils/detect-language");
|
|
15
|
+
const logger_1 = require("../../utils/logger");
|
|
16
|
+
const cache_1 = require("./cache");
|
|
17
|
+
const ensure_1 = require("./ensure");
|
|
18
|
+
const profiles_1 = require("./profiles");
|
|
19
|
+
/** Our own version, read from the package manifest that ships with us. */
|
|
20
|
+
function packageVersion() {
|
|
21
|
+
try {
|
|
22
|
+
const manifest = node_path_1.default.resolve(__dirname, '../../..', 'package.json');
|
|
23
|
+
return JSON.parse(node_fs_1.default.readFileSync(manifest, 'utf-8')).version ?? '0.0.0';
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Everything the install layer knows about this repository, in one shot.
|
|
31
|
+
*
|
|
32
|
+
* `usage` is a parameter rather than always detected here so a caller that
|
|
33
|
+
* already has it — `doctor` prints it — does not scan the repository twice and
|
|
34
|
+
* log two contradictory-looking detection lines for one command.
|
|
35
|
+
*/
|
|
36
|
+
async function planInstall(cwd = process.cwd(), usage) {
|
|
37
|
+
const config = (0, loader_1.loadProjectConfig)();
|
|
38
|
+
const detected = usage ?? (await (0, detect_language_1.detectLanguageUsage)(cwd, config.ignoreDirs, config.detect));
|
|
39
|
+
const location = (0, cache_1.resolveCacheDir)(packageVersion(), config.install?.cacheDir);
|
|
40
|
+
return { decisions: (0, profiles_1.planProfiles)(detected, config), cacheDir: location?.dir ?? null };
|
|
41
|
+
}
|
|
42
|
+
/** Whether a profile's tooling is present in the cache right now. */
|
|
43
|
+
function installedProfiles(cacheDir) {
|
|
44
|
+
if (!cacheDir)
|
|
45
|
+
return new Set();
|
|
46
|
+
return new Set(profiles_1.PROFILES.filter(p => node_fs_1.default.existsSync(node_path_1.default.join((0, cache_1.profileDir)(cacheDir, p.name), 'node_modules'))).map(p => p.name));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Fetch every profile this repository needs.
|
|
50
|
+
*
|
|
51
|
+
* Shared by the `install` command and by the run itself, so a client who never
|
|
52
|
+
* runs `install` gets the same result lazily. `all` is what a container build or
|
|
53
|
+
* an air-gapped mirror wants: everything, regardless of what this repository
|
|
54
|
+
* happens to contain.
|
|
55
|
+
*/
|
|
56
|
+
async function installProfiles(cwd = process.cwd(), all = false) {
|
|
57
|
+
const config = (0, loader_1.loadProjectConfig)();
|
|
58
|
+
const install = config.install ?? {};
|
|
59
|
+
const { decisions } = await planInstall(cwd);
|
|
60
|
+
const denied = new Set(install.deny ?? []);
|
|
61
|
+
const wanted = (all ? profiles_1.PROFILES.map(p => p.name) : decisions.filter(d => d.required).map(d => d.profile)).filter(name => {
|
|
62
|
+
const profile = profiles_1.PROFILES.find(p => p.name === name);
|
|
63
|
+
const blocked = profile?.packages.filter(pkg => denied.has(pkg)) ?? [];
|
|
64
|
+
if (blocked.length === 0)
|
|
65
|
+
return true;
|
|
66
|
+
logger_1.logger.info(`[install]: profile "${name}" skipped — install.deny lists ${blocked.join(', ')}`);
|
|
67
|
+
return false;
|
|
68
|
+
});
|
|
69
|
+
const results = [];
|
|
70
|
+
// Sequential on purpose. Parallel installs of profiles that share hundreds of
|
|
71
|
+
// packages fight over npm's cache and gain nothing; the wall-clock cost lands
|
|
72
|
+
// once, on a cold cache, and clarity of the log matters more there.
|
|
73
|
+
for (const profile of wanted) {
|
|
74
|
+
results.push(await (0, ensure_1.ensureProfile)(profile, { cwd, version: packageVersion(), install }));
|
|
75
|
+
}
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Explain what this repository needs, what it has, and what is missing — and why.
|
|
80
|
+
*
|
|
81
|
+
* The command exists because adaptive installation makes "why did my typecheck
|
|
82
|
+
* not run" a routine question, and the answer must be a command rather than a
|
|
83
|
+
* reading of our source.
|
|
84
|
+
*/
|
|
85
|
+
async function runDoctor(cwd = process.cwd()) {
|
|
86
|
+
const config = (0, loader_1.loadProjectConfig)();
|
|
87
|
+
const usage = await (0, detect_language_1.detectLanguageUsage)(cwd, config.ignoreDirs, config.detect);
|
|
88
|
+
const { decisions, cacheDir } = await planInstall(cwd, usage);
|
|
89
|
+
const present = installedProfiles(cacheDir);
|
|
90
|
+
const lines = [];
|
|
91
|
+
lines.push('PR CheckMate — install doctor');
|
|
92
|
+
lines.push('');
|
|
93
|
+
lines.push(` version ${packageVersion()}`);
|
|
94
|
+
lines.push(` repository ${cwd}`);
|
|
95
|
+
lines.push(` cache ${cacheDir ?? 'unavailable (no writable directory)'}`);
|
|
96
|
+
lines.push(` strategy ${config.install?.strategy ?? 'adaptive'}`);
|
|
97
|
+
lines.push('');
|
|
98
|
+
lines.push(' Languages');
|
|
99
|
+
if (usage.length === 0)
|
|
100
|
+
lines.push(' none detected');
|
|
101
|
+
for (const u of usage) {
|
|
102
|
+
// The distinction that decides downloads: a marker declares a language, a
|
|
103
|
+
// file proves it. Only the second is worth a toolchain.
|
|
104
|
+
const evidence = u.files > 0 ? `${u.files} file(s)` : 'marker file only';
|
|
105
|
+
lines.push(` ${u.lang.padEnd(12)} ${evidence}`);
|
|
106
|
+
}
|
|
107
|
+
lines.push('');
|
|
108
|
+
lines.push(' Profiles');
|
|
109
|
+
for (const decision of decisions) {
|
|
110
|
+
const state = decision.required ? (present.has(decision.profile) ? '✅' : '⬇️ ') : '—';
|
|
111
|
+
lines.push(` ${state} ${decision.profile.padEnd(12)} ${decision.reason}`);
|
|
112
|
+
}
|
|
113
|
+
const missing = decisions.filter(d => d.required && !present.has(d.profile));
|
|
114
|
+
if (missing.length > 0) {
|
|
115
|
+
lines.push('');
|
|
116
|
+
lines.push(` ${missing.length} profile(s) not installed yet. They are fetched on the next`);
|
|
117
|
+
lines.push(' run, or now with: npx pr-checkmate install');
|
|
118
|
+
}
|
|
119
|
+
const stale = (0, cache_1.stalePaths)(packageVersion(), config.install?.cacheDir);
|
|
120
|
+
if (stale.length > 0) {
|
|
121
|
+
lines.push('');
|
|
122
|
+
lines.push(` ${stale.length} directory(ies) from other versions: npx pr-checkmate cache-prune`);
|
|
123
|
+
}
|
|
124
|
+
logger_1.logger.print(lines.join('\n'));
|
|
125
|
+
}
|
|
126
|
+
/** Remove cached tooling belonging to other versions of pr-checkmate. */
|
|
127
|
+
function pruneCache() {
|
|
128
|
+
const config = (0, loader_1.loadProjectConfig)();
|
|
129
|
+
const stale = (0, cache_1.stalePaths)(packageVersion(), config.install?.cacheDir);
|
|
130
|
+
if (stale.length === 0) {
|
|
131
|
+
logger_1.logger.print('Nothing to prune — no tooling from other versions is cached.');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
for (const dir of stale) {
|
|
135
|
+
try {
|
|
136
|
+
node_fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
137
|
+
logger_1.logger.print(`removed ${dir}`);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
// Another version may be running right now; refusing to delete its
|
|
141
|
+
// tooling is the correct outcome, not an error worth failing on.
|
|
142
|
+
logger_1.logger.warn(`[cache-prune]: could not remove ${dir}: ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { InstallConfig } from '../../interfaces';
|
|
2
|
+
import type { ProfileName } from './profiles';
|
|
3
|
+
/**
|
|
4
|
+
* What happened to one profile, in the words the report and `doctor` use.
|
|
5
|
+
*
|
|
6
|
+
* `skipped` and `failed` are deliberately distinct. Skipped means we chose not
|
|
7
|
+
* to fetch — offline, `strategy: "manual"`, an explicit exclusion — and the
|
|
8
|
+
* client already knows why. Failed means we tried and could not, which is the
|
|
9
|
+
* case that has to reach the report: ticket 057 is the standing reminder that a
|
|
10
|
+
* check quietly vanishing is worse than a check failing loudly.
|
|
11
|
+
*/
|
|
12
|
+
export type EnsureStatus = 'cached' | 'installed' | 'skipped' | 'failed';
|
|
13
|
+
export interface EnsureResult {
|
|
14
|
+
profile: ProfileName;
|
|
15
|
+
status: EnsureStatus;
|
|
16
|
+
reason: string;
|
|
17
|
+
dir?: string;
|
|
18
|
+
durationMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Whether the fetch succeeded, and what to quote in the report when it did not. */
|
|
21
|
+
export interface InstallOutcome {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
detail?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Options `ensureProfile` needs that do not come from the config file. */
|
|
26
|
+
export interface EnsureOptions {
|
|
27
|
+
/** Repository under review — the cwd npm runs in, so the client's `.npmrc` applies. */
|
|
28
|
+
cwd: string;
|
|
29
|
+
/** pr-checkmate's own version; part of the cache path. */
|
|
30
|
+
version: string;
|
|
31
|
+
install: InstallConfig;
|
|
32
|
+
/**
|
|
33
|
+
* The fetch itself. Defaults to `npm ci`; injected by tests.
|
|
34
|
+
*
|
|
35
|
+
* A seam rather than a mock of the module: everything around the fetch — the
|
|
36
|
+
* lock, the cache stamp, the offline and strategy branches, the reasons that
|
|
37
|
+
* end up in the report — is logic worth pinning, and without this the only way
|
|
38
|
+
* to reach it is to actually download tooling from the network in a unit test.
|
|
39
|
+
*/
|
|
40
|
+
installer?: (dir: string) => Promise<InstallOutcome>;
|
|
41
|
+
}
|
|
42
|
+
/** Where the shipped manifests live inside the installed package. */
|
|
43
|
+
export declare function manifestDir(profile: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Make one profile's tooling available, fetching it only if it is not already there.
|
|
46
|
+
*
|
|
47
|
+
* Idempotent by design: this is the single entry point for both the postinstall
|
|
48
|
+
* warm-up and the lazy path taken when postinstall never ran (`npm ci
|
|
49
|
+
* --ignore-scripts`, a `node_modules` restored from a CI cache). Two code paths
|
|
50
|
+
* for the same job is how the two drift apart and how one of them silently stops
|
|
51
|
+
* working.
|
|
52
|
+
*
|
|
53
|
+
* The install itself is a plain `npm ci` against a manifest we generated from our
|
|
54
|
+
* own lockfile, run from the client's repository so their `.npmrc`, registry and
|
|
55
|
+
* proxy settings apply unchanged. `--ignore-scripts` is not negotiable: we are a
|
|
56
|
+
* supply-chain tool, and running arbitrary install hooks from a security check
|
|
57
|
+
* would be indefensible. Integrity is npm's own, over the whole transitive tree —
|
|
58
|
+
* a tampered tarball aborts with `EINTEGRITY` rather than being installed.
|
|
59
|
+
*/
|
|
60
|
+
export declare function ensureProfile(profile: ProfileName, opts: EnsureOptions): Promise<EnsureResult>;
|