pr-checkmate 2.1.2 → 3.0.1
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 +470 -447
- package/dist/cli.js +1 -1
- package/dist/config/constants.d.ts +6 -1
- package/dist/config/constants.js +9 -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/ruff-shared.d.ts +3 -3
- package/dist/core/checks/languages/ruff-shared.js +27 -4
- 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/coverage.js +6 -1
- package/dist/core/checks/quality/dead-code.js +12 -3
- 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/package-size.d.ts +15 -0
- package/dist/core/checks/quality/package-size.js +130 -0
- 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/commands.js +1 -0
- package/dist/core/context.js +9 -1
- package/dist/core/install/cache.d.ts +20 -1
- package/dist/core/install/cache.js +65 -12
- package/dist/core/install/doctor.js +49 -15
- package/dist/core/install/ensure.d.ts +31 -14
- package/dist/core/install/ensure.js +165 -61
- package/dist/core/install/lock.d.ts +2 -1
- package/dist/core/install/merge.d.ts +47 -0
- package/dist/core/install/merge.js +91 -0
- package/dist/core/install/prepare.d.ts +76 -0
- package/dist/core/install/prepare.js +147 -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 +12 -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 +6 -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/commitlint/package-lock.json +128 -250
- package/install-manifests/commitlint/package.json +2 -2
- package/install-manifests/core/package-lock.json +76 -76
- package/install-manifests/core/package.json +1 -1
- package/install-manifests/duplicate/package-lock.json +45 -45
- package/install-manifests/duplicate/package.json +1 -1
- package/install-manifests/go/package-lock.json +24 -0
- package/install-manifests/go/package.json +8 -0
- package/install-manifests/js/package-lock.json +248 -283
- package/install-manifests/js/package.json +9 -9
- package/install-manifests/python/package-lock.json +4 -4
- package/install-manifests/python/package.json +1 -1
- package/install-manifests/pytypes/package-lock.json +45 -0
- package/install-manifests/pytypes/package.json +8 -0
- package/package.json +21 -19
- package/pr-checkmate.schema.json +41 -13
- package/src/config/cspell.json +470 -447
- package/src/config/pr-checkmate.schema.json +41 -13
- 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
|
@@ -4,7 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.manifestDir = manifestDir;
|
|
7
|
-
exports.
|
|
7
|
+
exports.cachedProfiles = cachedProfiles;
|
|
8
|
+
exports.ensureTooling = ensureTooling;
|
|
8
9
|
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
9
10
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
11
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -12,117 +13,213 @@ const execa_1 = require("../../utils/execa");
|
|
|
12
13
|
const logger_1 = require("../../utils/logger");
|
|
13
14
|
const cache_1 = require("./cache");
|
|
14
15
|
const lock_1 = require("./lock");
|
|
16
|
+
const merge_1 = require("./merge");
|
|
15
17
|
/** Where the shipped manifests live inside the installed package. */
|
|
16
18
|
function manifestDir(profile) {
|
|
17
19
|
return node_path_1.default.resolve(__dirname, '../../..', 'install-manifests', profile);
|
|
18
20
|
}
|
|
19
21
|
const stampPath = (dir) => node_path_1.default.join(dir, '.pr-checkmate-installed');
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const LOCKFILE = MANIFEST_FILES[1];
|
|
22
|
+
const MANIFEST = 'package.json';
|
|
23
|
+
const LOCKFILE = 'package-lock.json';
|
|
23
24
|
/**
|
|
24
|
-
*
|
|
25
|
+
* Merge the shipped manifests for a set of profiles, or say which one is missing.
|
|
26
|
+
*
|
|
27
|
+
* Returns `null` rather than throwing on a missing manifest, because that is an
|
|
28
|
+
* ordinary condition: a stamp written by another version can name a profile this
|
|
29
|
+
* one no longer ships. A *disagreement* between two manifests is different and
|
|
30
|
+
* still throws — see {@link mergeManifests}.
|
|
31
|
+
*/
|
|
32
|
+
function manifestFor(profiles) {
|
|
33
|
+
const dirs = profiles.map(name => ({ name, dir: manifestDir(name) }));
|
|
34
|
+
if (dirs.some(d => !node_fs_1.default.existsSync(node_path_1.default.join(d.dir, LOCKFILE))))
|
|
35
|
+
return null;
|
|
36
|
+
return (0, merge_1.mergeManifests)(dirs);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A fingerprint of the manifest a tooling directory was installed from.
|
|
25
40
|
*
|
|
26
41
|
* Comparing the lockfile hash rather than merely checking that `node_modules`
|
|
27
42
|
* exists is what makes an upgrade correct: the cache path already includes our
|
|
28
43
|
* version, but a client can also pin a `cacheDir` shared between checkouts, and
|
|
29
44
|
* a half-written directory from an interrupted install must not read as ready.
|
|
30
45
|
*/
|
|
31
|
-
function
|
|
46
|
+
function hashOf(manifest) {
|
|
47
|
+
return node_crypto_1.default.createHash('sha256').update(JSON.stringify(manifest.lockfile)).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
function readStamp(dir) {
|
|
32
50
|
try {
|
|
33
|
-
const
|
|
34
|
-
return
|
|
51
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(stampPath(dir), 'utf-8'));
|
|
52
|
+
return Array.isArray(parsed.profiles) && typeof parsed.hash === 'string' ? parsed : null;
|
|
35
53
|
}
|
|
36
54
|
catch {
|
|
55
|
+
// Unreadable, absent, or written by a version that stamped a bare hash.
|
|
56
|
+
// All three mean the same thing here: we cannot claim to know what is there.
|
|
37
57
|
return null;
|
|
38
58
|
}
|
|
39
59
|
}
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Whether the directory already holds everything asked for.
|
|
62
|
+
*
|
|
63
|
+
* A **superset** check, not an equality one, and that is the point of the shared
|
|
64
|
+
* directory. `install --all` warms the cache with every profile; a run that needs
|
|
65
|
+
* two of them must read that as ready, or the pre-warming built into our Docker
|
|
66
|
+
* image buys nothing — the failure ticket 083 was about. It is also what makes a
|
|
67
|
+
* language added six months later cost one install rather than one per run.
|
|
68
|
+
*
|
|
69
|
+
* The stamp's own hash is re-derived from the manifests we ship *now*, so a
|
|
70
|
+
* directory installed by a build with different manifests is correctly rejected
|
|
71
|
+
* even when its profile list still looks right.
|
|
72
|
+
*/
|
|
73
|
+
function holdsAll(dir, stamp, needed) {
|
|
74
|
+
if (!stamp || !node_fs_1.default.existsSync(node_path_1.default.join(dir, 'node_modules')))
|
|
43
75
|
return false;
|
|
44
|
-
|
|
45
|
-
return (node_fs_1.default.readFileSync(stampPath(dir), 'utf-8').trim() === expected &&
|
|
46
|
-
node_fs_1.default.existsSync(node_path_1.default.join(dir, 'node_modules')));
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
76
|
+
if (!needed.every(p => stamp.profiles.includes(p)))
|
|
49
77
|
return false;
|
|
50
|
-
|
|
78
|
+
const installed = manifestFor(stamp.profiles);
|
|
79
|
+
return installed !== null && hashOf(installed) === stamp.hash;
|
|
51
80
|
}
|
|
52
|
-
/**
|
|
53
|
-
function
|
|
54
|
-
node_fs_1.default.mkdirSync(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
/** Write the merged manifest into the cache so `npm ci` has something to read. */
|
|
82
|
+
function writeManifest(dir, manifest) {
|
|
83
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
84
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(dir, MANIFEST), JSON.stringify(manifest.pkg, null, 2));
|
|
85
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(dir, LOCKFILE), JSON.stringify(manifest.lockfile, null, 2));
|
|
86
|
+
}
|
|
87
|
+
/** Sorted, de-duplicated, so a set is one set however it was assembled. */
|
|
88
|
+
const normalise = (profiles) => [...new Set(profiles)].sort();
|
|
89
|
+
/**
|
|
90
|
+
* Which profiles the tooling cache holds, as recorded by the install that filled it.
|
|
91
|
+
*
|
|
92
|
+
* The one way to ask, so the stamp's format has a single owner. There is one tree
|
|
93
|
+
* for all fetched tooling now, so what is in it is written down rather than
|
|
94
|
+
* readable from the directory layout.
|
|
95
|
+
*/
|
|
96
|
+
function cachedProfiles(cacheDir) {
|
|
97
|
+
const dir = (0, cache_1.toolingDir)(cacheDir);
|
|
98
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, 'node_modules')))
|
|
99
|
+
return [];
|
|
100
|
+
return readStamp(dir)?.profiles ?? [];
|
|
58
101
|
}
|
|
59
102
|
/**
|
|
60
|
-
* Make
|
|
103
|
+
* Make a set of profiles' tooling available, fetching only what is not already there.
|
|
61
104
|
*
|
|
62
|
-
* Idempotent by design: this is the single entry point for both the
|
|
63
|
-
*
|
|
105
|
+
* Idempotent by design: this is the single entry point for both the `install`
|
|
106
|
+
* command and the lazy path taken when it was never run (`npm ci
|
|
64
107
|
* --ignore-scripts`, a `node_modules` restored from a CI cache). Two code paths
|
|
65
108
|
* for the same job is how the two drift apart and how one of them silently stops
|
|
66
109
|
* working.
|
|
67
110
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
111
|
+
* Everything needed goes into **one** tree. Profiles overlap heavily — `js` and
|
|
112
|
+
* `commitlint` share 30 MB — and a directory each left npm nothing to
|
|
113
|
+
* deduplicate: 267 MB against the 211 one merged tree costs for the same set
|
|
114
|
+
* (ticket 087). The set only ever grows: a run needing a profile the directory
|
|
115
|
+
* lacks reinstalls the union, so nothing already fetched is lost.
|
|
116
|
+
*
|
|
117
|
+
* The install itself is a plain `npm ci` against a manifest merged from the ones
|
|
118
|
+
* we generated from our own lockfile, run from the client's repository so their
|
|
119
|
+
* `.npmrc`, registry and proxy settings apply unchanged. `--ignore-scripts` is
|
|
120
|
+
* not negotiable: we are a supply-chain tool, and running arbitrary install hooks
|
|
121
|
+
* from a security check would be indefensible. Integrity is npm's own, over the
|
|
122
|
+
* whole transitive tree — a tampered tarball aborts with `EINTEGRITY` rather than
|
|
123
|
+
* being installed.
|
|
74
124
|
*/
|
|
75
|
-
async function
|
|
125
|
+
async function ensureTooling(wanted, opts) {
|
|
76
126
|
const { install } = opts;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const source = manifestDir(profile);
|
|
81
|
-
const expected = manifestHash(source);
|
|
82
|
-
if (!expected) {
|
|
83
|
-
return { profile, status: 'failed', reason: `no shipped manifest for profile "${profile}"` };
|
|
127
|
+
const needed = normalise(wanted);
|
|
128
|
+
if (needed.length === 0) {
|
|
129
|
+
return { status: 'cached', reason: 'nothing to fetch', profiles: [] };
|
|
84
130
|
}
|
|
85
131
|
const location = (0, cache_1.resolveCacheDir)(opts.version, install.cacheDir);
|
|
86
132
|
if (!location) {
|
|
87
133
|
return {
|
|
88
|
-
profile,
|
|
89
134
|
status: 'failed',
|
|
90
135
|
reason: 'no writable cache directory (tried the configured path, ~/.cache and the temp dir)',
|
|
136
|
+
profiles: [],
|
|
91
137
|
};
|
|
92
138
|
}
|
|
93
|
-
const dir = (0, cache_1.
|
|
94
|
-
|
|
95
|
-
|
|
139
|
+
const dir = (0, cache_1.toolingDir)(location.dir);
|
|
140
|
+
const stamp = readStamp(dir);
|
|
141
|
+
if (holdsAll(dir, stamp, needed)) {
|
|
142
|
+
return { status: 'cached', reason: 'already installed', dir, profiles: stamp?.profiles ?? [] };
|
|
143
|
+
}
|
|
144
|
+
// Never shrink: whatever the directory already holds stays, or a repository
|
|
145
|
+
// that alternates between two languages would reinstall on every run.
|
|
146
|
+
const target = normalise([...(stamp?.profiles ?? []), ...needed]);
|
|
147
|
+
let installing = target;
|
|
148
|
+
let manifest = manifestFor(target);
|
|
149
|
+
if (!manifest) {
|
|
150
|
+
// The stamp names a profile this version no longer ships. Keeping it would
|
|
151
|
+
// make the directory permanently un-installable; dropping it costs a refetch
|
|
152
|
+
// of tooling that is obsolete anyway.
|
|
153
|
+
installing = needed;
|
|
154
|
+
manifest = manifestFor(needed);
|
|
155
|
+
}
|
|
156
|
+
if (!manifest) {
|
|
157
|
+
return {
|
|
158
|
+
status: 'failed',
|
|
159
|
+
reason: `no shipped manifest for ${needed.join(', ')}`,
|
|
160
|
+
profiles: [],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Both of these mean "do not fetch", and both are asked AFTER the cache check
|
|
164
|
+
// above — because neither means "do not use what has already been fetched".
|
|
165
|
+
//
|
|
166
|
+
// `offline` was always in the right place; `manual` was not, and returned before
|
|
167
|
+
// the cache was ever looked at. So `install --all`, the command the product's
|
|
168
|
+
// own log recommends for pre-warming, did nothing for anyone who then set
|
|
169
|
+
// `manual` — including our Docker image, which sets it precisely because
|
|
170
|
+
// everything is supposed to already be there. `Python Types` in that image
|
|
171
|
+
// could not run at all (ticket 083).
|
|
172
|
+
// The env var as well as the config key, exactly as `PR_CHECKMATE_OFFLINE` is
|
|
173
|
+
// read below: the Docker image sets the strategy through the environment, and a
|
|
174
|
+
// guarantee that only holds when it is written in a config file is not a
|
|
175
|
+
// guarantee.
|
|
176
|
+
const strategy = process.env.PR_CHECKMATE_INSTALL_STRATEGY ?? install.strategy;
|
|
177
|
+
if (strategy === 'manual') {
|
|
178
|
+
return {
|
|
179
|
+
status: 'skipped',
|
|
180
|
+
reason: 'install.strategy is "manual" and the tooling is not in the cache',
|
|
181
|
+
dir,
|
|
182
|
+
profiles: stamp?.profiles ?? [],
|
|
183
|
+
};
|
|
96
184
|
}
|
|
97
185
|
if (install.offline || process.env.PR_CHECKMATE_OFFLINE === 'true') {
|
|
98
186
|
return {
|
|
99
|
-
profile,
|
|
100
187
|
status: 'skipped',
|
|
101
|
-
reason: 'install.offline is set and the
|
|
188
|
+
reason: 'install.offline is set and the tooling is not in the cache',
|
|
102
189
|
dir,
|
|
190
|
+
profiles: stamp?.profiles ?? [],
|
|
103
191
|
};
|
|
104
192
|
}
|
|
105
|
-
// The lock file lives in the
|
|
193
|
+
// The lock file lives in the tooling directory, so the directory has to exist
|
|
106
194
|
// before there is anything to lock. Creating it inside `withLock` would put
|
|
107
195
|
// the creation itself outside the lock, which is the race it exists to close.
|
|
108
196
|
try {
|
|
109
197
|
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
110
198
|
}
|
|
111
199
|
catch (err) {
|
|
112
|
-
return {
|
|
200
|
+
return {
|
|
201
|
+
status: 'failed',
|
|
202
|
+
reason: `cannot create ${dir}: ${err.message}`,
|
|
203
|
+
profiles: [],
|
|
204
|
+
};
|
|
113
205
|
}
|
|
114
206
|
const release = await (0, lock_1.withLock)(dir, install.waitTimeoutMs ?? 120000);
|
|
115
207
|
if (!release) {
|
|
116
|
-
return {
|
|
208
|
+
return { status: 'failed', reason: `cannot write to ${dir}`, dir, profiles: [] };
|
|
117
209
|
}
|
|
118
210
|
try {
|
|
119
211
|
// Re-checked inside the lock: while we waited, the process we were waiting
|
|
120
212
|
// for probably did the work. Without this, waiting is pointless — every
|
|
121
213
|
// queued job would reinstall in turn.
|
|
122
|
-
if (
|
|
123
|
-
return {
|
|
214
|
+
if (holdsAll(dir, readStamp(dir), needed)) {
|
|
215
|
+
return {
|
|
216
|
+
status: 'cached',
|
|
217
|
+
reason: 'installed by another process',
|
|
218
|
+
dir,
|
|
219
|
+
profiles: readStamp(dir)?.profiles ?? [],
|
|
220
|
+
};
|
|
124
221
|
}
|
|
125
|
-
return await runInstall(
|
|
222
|
+
return await runInstall(installing, manifest, dir, opts);
|
|
126
223
|
}
|
|
127
224
|
finally {
|
|
128
225
|
release();
|
|
@@ -143,27 +240,34 @@ async function npmCi(dir, opts) {
|
|
|
143
240
|
return { ok: false, detail: detail || `exit ${result.exitCode}` };
|
|
144
241
|
}
|
|
145
242
|
/** The fetch itself, once we hold the lock and know it is needed. */
|
|
146
|
-
async function runInstall(
|
|
243
|
+
async function runInstall(profiles, manifest, dir, opts) {
|
|
147
244
|
const started = Date.now();
|
|
148
|
-
|
|
149
|
-
const
|
|
245
|
+
writeManifest(dir, manifest);
|
|
246
|
+
const named = profiles.join(', ');
|
|
247
|
+
const count = Object.keys(manifest.lockfile.packages).length - 1;
|
|
150
248
|
// Always logged, never behind --debug: this is a security tool downloading
|
|
151
249
|
// code at run time, and that must be visible in every CI log by default.
|
|
152
|
-
logger_1.logger.info(`[install]: 📦 fetching
|
|
250
|
+
logger_1.logger.info(`[install]: 📦 fetching tooling for ${named} (${count} packages) → ${dir}`);
|
|
153
251
|
try {
|
|
154
252
|
const install = opts.installer ?? ((target) => npmCi(target, opts));
|
|
155
253
|
const result = await install(dir);
|
|
156
254
|
if (!result.ok) {
|
|
157
|
-
return {
|
|
255
|
+
return { status: 'failed', reason: `npm ci failed: ${result.detail}`, dir, profiles: [] };
|
|
158
256
|
}
|
|
159
|
-
|
|
257
|
+
const stamp = { hash: hashOf(manifest), profiles };
|
|
258
|
+
node_fs_1.default.writeFileSync(stampPath(dir), JSON.stringify(stamp));
|
|
160
259
|
const durationMs = Date.now() - started;
|
|
161
|
-
logger_1.logger.info(`[install]: ✅
|
|
162
|
-
return {
|
|
260
|
+
logger_1.logger.info(`[install]: ✅ tooling ready in ${(durationMs / 1000).toFixed(1)}s (${named})`);
|
|
261
|
+
return { status: 'installed', reason: `${count} packages`, dir, profiles, durationMs };
|
|
163
262
|
}
|
|
164
263
|
catch (err) {
|
|
165
264
|
// A timeout arrives here as a thrown error. It is a skip, not a crash: the
|
|
166
265
|
// rest of the run is still worth doing, and the affected checks say why.
|
|
167
|
-
return {
|
|
266
|
+
return {
|
|
267
|
+
status: 'failed',
|
|
268
|
+
reason: `npm ci failed: ${err.message}`,
|
|
269
|
+
dir,
|
|
270
|
+
profiles: [],
|
|
271
|
+
};
|
|
168
272
|
}
|
|
169
273
|
}
|
|
@@ -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,47 @@
|
|
|
1
|
+
/** The `package.json` half of a shipped profile manifest. */
|
|
2
|
+
interface ProfilePkg {
|
|
3
|
+
dependencies?: Record<string, string>;
|
|
4
|
+
overrides?: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
/** The `package-lock.json` half. Entries are opaque to us beyond their identity. */
|
|
7
|
+
interface ProfileLock {
|
|
8
|
+
packages: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
/** A manifest pair `npm ci` can be pointed at. */
|
|
11
|
+
export interface MergedManifest {
|
|
12
|
+
pkg: object;
|
|
13
|
+
lockfile: object;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Merge several profile manifests into the one a single `npm ci` installs.
|
|
17
|
+
*
|
|
18
|
+
* This is what stops the tooling cache from paying for the same package twice.
|
|
19
|
+
* Installing each profile into its own directory leaves npm nothing to
|
|
20
|
+
* deduplicate: `commitlint` and `js` share 30 MB, and separate trees download
|
|
21
|
+
* both copies. One tree costs the union instead of the sum — measured at 213 MB
|
|
22
|
+
* against 234 for a JavaScript repository, where per-profile directories came to
|
|
23
|
+
* 267 (ticket 087).
|
|
24
|
+
*
|
|
25
|
+
* The merge is a union, not a resolution, and it never reaches the network. Every
|
|
26
|
+
* shipped manifest was cut out of our own `package-lock.json` at build time, so
|
|
27
|
+
* two profiles that both contain a package contain the *same* entry for it,
|
|
28
|
+
* byte for byte. That is the whole reason this is safe to do at run time on a
|
|
29
|
+
* client's machine.
|
|
30
|
+
*
|
|
31
|
+
* Which makes a disagreement impossible unless we broke the build — so it throws
|
|
32
|
+
* rather than picking a winner. Choosing silently would hand a client a tree
|
|
33
|
+
* neither profile was tested against, and the integrity hashes that make this
|
|
34
|
+
* design defensible would be describing something we never ran.
|
|
35
|
+
*
|
|
36
|
+
* @param profiles Directories holding the shipped manifests, in any order.
|
|
37
|
+
* @param read Injected by tests; defaults to reading the two files.
|
|
38
|
+
* @throws If two profiles disagree about a package, or a manifest cannot be read.
|
|
39
|
+
*/
|
|
40
|
+
export declare function mergeManifests(profiles: Array<{
|
|
41
|
+
name: string;
|
|
42
|
+
dir: string;
|
|
43
|
+
}>, read?: (dir: string) => {
|
|
44
|
+
pkg: ProfilePkg;
|
|
45
|
+
lock: ProfileLock;
|
|
46
|
+
}): MergedManifest;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
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.mergeManifests = mergeManifests;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const MERGED_NAME = 'pr-checkmate-tooling';
|
|
10
|
+
const MERGED_VERSION = '1.0.0';
|
|
11
|
+
/** Read a profile's shipped manifest pair from the installed package. */
|
|
12
|
+
function readShippedManifest(dir) {
|
|
13
|
+
return {
|
|
14
|
+
pkg: JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, 'package.json'), 'utf-8')),
|
|
15
|
+
lock: JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, 'package-lock.json'), 'utf-8')),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Merge several profile manifests into the one a single `npm ci` installs.
|
|
20
|
+
*
|
|
21
|
+
* This is what stops the tooling cache from paying for the same package twice.
|
|
22
|
+
* Installing each profile into its own directory leaves npm nothing to
|
|
23
|
+
* deduplicate: `commitlint` and `js` share 30 MB, and separate trees download
|
|
24
|
+
* both copies. One tree costs the union instead of the sum — measured at 213 MB
|
|
25
|
+
* against 234 for a JavaScript repository, where per-profile directories came to
|
|
26
|
+
* 267 (ticket 087).
|
|
27
|
+
*
|
|
28
|
+
* The merge is a union, not a resolution, and it never reaches the network. Every
|
|
29
|
+
* shipped manifest was cut out of our own `package-lock.json` at build time, so
|
|
30
|
+
* two profiles that both contain a package contain the *same* entry for it,
|
|
31
|
+
* byte for byte. That is the whole reason this is safe to do at run time on a
|
|
32
|
+
* client's machine.
|
|
33
|
+
*
|
|
34
|
+
* Which makes a disagreement impossible unless we broke the build — so it throws
|
|
35
|
+
* rather than picking a winner. Choosing silently would hand a client a tree
|
|
36
|
+
* neither profile was tested against, and the integrity hashes that make this
|
|
37
|
+
* design defensible would be describing something we never ran.
|
|
38
|
+
*
|
|
39
|
+
* @param profiles Directories holding the shipped manifests, in any order.
|
|
40
|
+
* @param read Injected by tests; defaults to reading the two files.
|
|
41
|
+
* @throws If two profiles disagree about a package, or a manifest cannot be read.
|
|
42
|
+
*/
|
|
43
|
+
function mergeManifests(profiles, read = readShippedManifest) {
|
|
44
|
+
const dependencies = {};
|
|
45
|
+
const overrides = {};
|
|
46
|
+
const packages = {};
|
|
47
|
+
// Which profile first claimed a given key, so a conflict names both sides.
|
|
48
|
+
const claimedBy = new Map();
|
|
49
|
+
const claim = (kind, key, value, by, into) => {
|
|
50
|
+
const id = `${kind}:${key}`;
|
|
51
|
+
const seen = claimedBy.get(id);
|
|
52
|
+
if (seen === undefined) {
|
|
53
|
+
claimedBy.set(id, by);
|
|
54
|
+
into[key] = value;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (JSON.stringify(into[key]) !== JSON.stringify(value)) {
|
|
58
|
+
throw new Error(`profiles "${seen}" and "${by}" disagree about ${kind} "${key}" — ` +
|
|
59
|
+
'the shipped manifests are out of sync with our lockfile');
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
for (const { name, dir } of profiles) {
|
|
63
|
+
const { pkg, lock } = read(dir);
|
|
64
|
+
for (const [dep, version] of Object.entries(pkg.dependencies ?? {})) {
|
|
65
|
+
claim('dependency', dep, version, name, dependencies);
|
|
66
|
+
}
|
|
67
|
+
for (const [target, rule] of Object.entries(pkg.overrides ?? {})) {
|
|
68
|
+
claim('override', target, rule, name, overrides);
|
|
69
|
+
}
|
|
70
|
+
for (const [entryPath, entry] of Object.entries(lock.packages ?? {})) {
|
|
71
|
+
// The root entry describes the profile itself — its name, its direct
|
|
72
|
+
// dependencies. It is replaced by the merged root below, not merged: two
|
|
73
|
+
// profiles always "disagree" about it, and rightly so.
|
|
74
|
+
if (entryPath === '')
|
|
75
|
+
continue;
|
|
76
|
+
claim('package', entryPath, entry, name, packages);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const scoped = Object.keys(overrides).length > 0 ? { overrides } : {};
|
|
80
|
+
const root = { name: MERGED_NAME, version: MERGED_VERSION, dependencies, ...scoped };
|
|
81
|
+
return {
|
|
82
|
+
pkg: { ...root, private: true },
|
|
83
|
+
lockfile: {
|
|
84
|
+
name: MERGED_NAME,
|
|
85
|
+
version: MERGED_VERSION,
|
|
86
|
+
lockfileVersion: 3,
|
|
87
|
+
requires: true,
|
|
88
|
+
packages: { '': root, ...packages },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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. Nearly every
|
|
9
|
+
* profile's packages sit in our `dependencies`, so it answers `true`, nothing is
|
|
10
|
+
* fetched, and a run is byte-for-byte the run it was before any of this existed.
|
|
11
|
+
* `pytypes` is the exception that already proves the other half works: `pyright`
|
|
12
|
+
* is a devDependency, so it reaches no client and is fetched on demand. When a
|
|
13
|
+
* package is moved out of `dependencies` it stops being present, its profile
|
|
14
|
+
* starts answering `false`, and the fetch turns itself on — no flag, no second
|
|
15
|
+
* code path, nothing to remember to flip.
|
|
16
|
+
*
|
|
17
|
+
* Which is what makes releases 2 and 3 of ticket 064 an edit to `package.json`
|
|
18
|
+
* rather than another change to this logic.
|
|
19
|
+
*/
|
|
20
|
+
export declare function profileSatisfiedLocally(profile: Profile): boolean;
|
|
21
|
+
/** What the run knows about tooling before the first check starts. */
|
|
22
|
+
export interface ToolingPlan {
|
|
23
|
+
/** Check name → why its tooling is not available. Empty on a healthy run. */
|
|
24
|
+
unavailable: Map<string, string>;
|
|
25
|
+
/** Profile directories tool resolution should search. */
|
|
26
|
+
dirs: string[];
|
|
27
|
+
/**
|
|
28
|
+
* True when tooling is missing and `install.onMissingTool` is `"error"`. The
|
|
29
|
+
* affected checks fail instead of skipping: in a closed contour a check that
|
|
30
|
+
* quietly stops running is worse than a red build.
|
|
31
|
+
*/
|
|
32
|
+
fatal: boolean;
|
|
33
|
+
}
|
|
34
|
+
interface PrepareOptions {
|
|
35
|
+
cwd: string;
|
|
36
|
+
config: PRCheckMateConfig;
|
|
37
|
+
/** Detected languages with file counts — reused so the tree is scanned once. */
|
|
38
|
+
usage: LanguageUsage[];
|
|
39
|
+
/** Names of the checks this run intends to execute. */
|
|
40
|
+
selected: string[];
|
|
41
|
+
/** pr-checkmate's own version; part of the cache path. */
|
|
42
|
+
version: string;
|
|
43
|
+
/**
|
|
44
|
+
* The profile catalogue. Defaults to the shipped one; injected by tests.
|
|
45
|
+
*
|
|
46
|
+
* A seam rather than a mock of the module, for the same reason `ensure.ts` has
|
|
47
|
+
* one: nearly every profile ships inside the package, so the interesting half
|
|
48
|
+
* of this function — a profile that is genuinely absent — is otherwise reachable
|
|
49
|
+
* only through a network install in a unit test. The code that runs when a
|
|
50
|
+
* package leaves `dependencies` must be tested before that day, not on it.
|
|
51
|
+
*/
|
|
52
|
+
profiles?: Profile[];
|
|
53
|
+
/** The fetch itself, forwarded to `ensureTooling`. Injected by tests. */
|
|
54
|
+
installer?: EnsureOptions['installer'];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Make sure the tooling this run needs is present, and say what is missing.
|
|
58
|
+
*
|
|
59
|
+
* Runs once, before the first check, and answers two questions the run cannot
|
|
60
|
+
* proceed honestly without: which directories tool resolution should search, and
|
|
61
|
+
* which checks have no tool to run. The second is the one ticket 057 is about —
|
|
62
|
+
* a check whose tooling never arrived must appear in the report saying so, not
|
|
63
|
+
* vanish from it.
|
|
64
|
+
*
|
|
65
|
+
* Only profiles backing checks that are actually selected are considered, so
|
|
66
|
+
* `severity: { "Spellcheck": "off" }` stops costing a download rather than only a
|
|
67
|
+
* row in the report (lever B of ticket 064).
|
|
68
|
+
*/
|
|
69
|
+
export declare function prepareTooling(opts: PrepareOptions): Promise<ToolingPlan>;
|
|
70
|
+
/**
|
|
71
|
+
* The plan for a run where preparation did not happen — nothing missing, nothing
|
|
72
|
+
* registered. Built fresh each time rather than shared: a caller holding a plan
|
|
73
|
+
* must not be able to see another run's.
|
|
74
|
+
*/
|
|
75
|
+
export declare function noTooling(): ToolingPlan;
|
|
76
|
+
export {};
|