pr-checkmate 3.0.0 → 3.0.2
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 +1 -1
- package/cspell.json +379 -378
- package/dist/cli.js +1 -1
- package/dist/config/constants.js +1 -0
- 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/quality/coverage.js +6 -1
- package/dist/core/checks/quality/dead-code.js +12 -3
- package/dist/core/checks/quality/package-size.d.ts +36 -0
- package/dist/core/checks/quality/package-size.js +203 -0
- package/dist/core/commands.js +1 -0
- package/dist/core/install/cache.d.ts +20 -1
- package/dist/core/install/cache.js +23 -1
- package/dist/core/install/doctor.js +40 -15
- package/dist/core/install/ensure.d.ts +31 -14
- package/dist/core/install/ensure.js +145 -60
- package/dist/core/install/merge.d.ts +47 -0
- package/dist/core/install/merge.js +91 -0
- package/dist/core/install/prepare.d.ts +13 -12
- package/dist/core/install/prepare.js +32 -36
- package/dist/core/registry.js +2 -0
- package/dist/interfaces/index.d.ts +5 -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/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/package.json +18 -18
- package/pr-checkmate.schema.json +35 -13
- package/src/config/cspell.json +379 -378
- package/src/config/pr-checkmate.schema.json +35 -13
|
@@ -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,84 +13,152 @@ 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
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
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: [] };
|
|
81
130
|
}
|
|
82
131
|
const location = (0, cache_1.resolveCacheDir)(opts.version, install.cacheDir);
|
|
83
132
|
if (!location) {
|
|
84
133
|
return {
|
|
85
|
-
profile,
|
|
86
134
|
status: 'failed',
|
|
87
135
|
reason: 'no writable cache directory (tried the configured path, ~/.cache and the temp dir)',
|
|
136
|
+
profiles: [],
|
|
88
137
|
};
|
|
89
138
|
}
|
|
90
|
-
const dir = (0, cache_1.
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
};
|
|
93
162
|
}
|
|
94
163
|
// Both of these mean "do not fetch", and both are asked AFTER the cache check
|
|
95
164
|
// above — because neither means "do not use what has already been fetched".
|
|
@@ -107,41 +176,50 @@ async function ensureProfile(profile, opts) {
|
|
|
107
176
|
const strategy = process.env.PR_CHECKMATE_INSTALL_STRATEGY ?? install.strategy;
|
|
108
177
|
if (strategy === 'manual') {
|
|
109
178
|
return {
|
|
110
|
-
profile,
|
|
111
179
|
status: 'skipped',
|
|
112
|
-
reason: 'install.strategy is "manual" and the
|
|
180
|
+
reason: 'install.strategy is "manual" and the tooling is not in the cache',
|
|
113
181
|
dir,
|
|
182
|
+
profiles: stamp?.profiles ?? [],
|
|
114
183
|
};
|
|
115
184
|
}
|
|
116
185
|
if (install.offline || process.env.PR_CHECKMATE_OFFLINE === 'true') {
|
|
117
186
|
return {
|
|
118
|
-
profile,
|
|
119
187
|
status: 'skipped',
|
|
120
|
-
reason: 'install.offline is set and the
|
|
188
|
+
reason: 'install.offline is set and the tooling is not in the cache',
|
|
121
189
|
dir,
|
|
190
|
+
profiles: stamp?.profiles ?? [],
|
|
122
191
|
};
|
|
123
192
|
}
|
|
124
|
-
// The lock file lives in the
|
|
193
|
+
// The lock file lives in the tooling directory, so the directory has to exist
|
|
125
194
|
// before there is anything to lock. Creating it inside `withLock` would put
|
|
126
195
|
// the creation itself outside the lock, which is the race it exists to close.
|
|
127
196
|
try {
|
|
128
197
|
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
129
198
|
}
|
|
130
199
|
catch (err) {
|
|
131
|
-
return {
|
|
200
|
+
return {
|
|
201
|
+
status: 'failed',
|
|
202
|
+
reason: `cannot create ${dir}: ${err.message}`,
|
|
203
|
+
profiles: [],
|
|
204
|
+
};
|
|
132
205
|
}
|
|
133
206
|
const release = await (0, lock_1.withLock)(dir, install.waitTimeoutMs ?? 120000);
|
|
134
207
|
if (!release) {
|
|
135
|
-
return {
|
|
208
|
+
return { status: 'failed', reason: `cannot write to ${dir}`, dir, profiles: [] };
|
|
136
209
|
}
|
|
137
210
|
try {
|
|
138
211
|
// Re-checked inside the lock: while we waited, the process we were waiting
|
|
139
212
|
// for probably did the work. Without this, waiting is pointless — every
|
|
140
213
|
// queued job would reinstall in turn.
|
|
141
|
-
if (
|
|
142
|
-
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
|
+
};
|
|
143
221
|
}
|
|
144
|
-
return await runInstall(
|
|
222
|
+
return await runInstall(installing, manifest, dir, opts);
|
|
145
223
|
}
|
|
146
224
|
finally {
|
|
147
225
|
release();
|
|
@@ -162,27 +240,34 @@ async function npmCi(dir, opts) {
|
|
|
162
240
|
return { ok: false, detail: detail || `exit ${result.exitCode}` };
|
|
163
241
|
}
|
|
164
242
|
/** The fetch itself, once we hold the lock and know it is needed. */
|
|
165
|
-
async function runInstall(
|
|
243
|
+
async function runInstall(profiles, manifest, dir, opts) {
|
|
166
244
|
const started = Date.now();
|
|
167
|
-
|
|
168
|
-
const
|
|
245
|
+
writeManifest(dir, manifest);
|
|
246
|
+
const named = profiles.join(', ');
|
|
247
|
+
const count = Object.keys(manifest.lockfile.packages).length - 1;
|
|
169
248
|
// Always logged, never behind --debug: this is a security tool downloading
|
|
170
249
|
// code at run time, and that must be visible in every CI log by default.
|
|
171
|
-
logger_1.logger.info(`[install]: 📦 fetching
|
|
250
|
+
logger_1.logger.info(`[install]: 📦 fetching tooling for ${named} (${count} packages) → ${dir}`);
|
|
172
251
|
try {
|
|
173
252
|
const install = opts.installer ?? ((target) => npmCi(target, opts));
|
|
174
253
|
const result = await install(dir);
|
|
175
254
|
if (!result.ok) {
|
|
176
|
-
return {
|
|
255
|
+
return { status: 'failed', reason: `npm ci failed: ${result.detail}`, dir, profiles: [] };
|
|
177
256
|
}
|
|
178
|
-
|
|
257
|
+
const stamp = { hash: hashOf(manifest), profiles };
|
|
258
|
+
node_fs_1.default.writeFileSync(stampPath(dir), JSON.stringify(stamp));
|
|
179
259
|
const durationMs = Date.now() - started;
|
|
180
|
-
logger_1.logger.info(`[install]: ✅
|
|
181
|
-
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 };
|
|
182
262
|
}
|
|
183
263
|
catch (err) {
|
|
184
264
|
// A timeout arrives here as a thrown error. It is a skip, not a crash: the
|
|
185
265
|
// rest of the run is still worth doing, and the affected checks say why.
|
|
186
|
-
return {
|
|
266
|
+
return {
|
|
267
|
+
status: 'failed',
|
|
268
|
+
reason: `npm ci failed: ${err.message}`,
|
|
269
|
+
dir,
|
|
270
|
+
profiles: [],
|
|
271
|
+
};
|
|
187
272
|
}
|
|
188
273
|
}
|
|
@@ -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
|
+
}
|
|
@@ -5,12 +5,14 @@ import { type Profile } from './profiles';
|
|
|
5
5
|
/**
|
|
6
6
|
* Whether a profile needs fetching at all, or already ships inside the package.
|
|
7
7
|
*
|
|
8
|
-
* This is the hinge the whole adaptive-install rollout turns on.
|
|
9
|
-
* profile's packages sit in our `dependencies`, so
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
14
16
|
*
|
|
15
17
|
* Which is what makes releases 2 and 3 of ticket 064 an edit to `package.json`
|
|
16
18
|
* rather than another change to this logic.
|
|
@@ -42,14 +44,13 @@ interface PrepareOptions {
|
|
|
42
44
|
* The profile catalogue. Defaults to the shipped one; injected by tests.
|
|
43
45
|
*
|
|
44
46
|
* A seam rather than a mock of the module, for the same reason `ensure.ts` has
|
|
45
|
-
* one: every profile
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* `dependencies` must be tested before that day, not on it.
|
|
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.
|
|
50
51
|
*/
|
|
51
52
|
profiles?: Profile[];
|
|
52
|
-
/** The fetch itself, forwarded to `
|
|
53
|
+
/** The fetch itself, forwarded to `ensureTooling`. Injected by tests. */
|
|
53
54
|
installer?: EnsureOptions['installer'];
|
|
54
55
|
}
|
|
55
56
|
/**
|
|
@@ -5,18 +5,19 @@ exports.prepareTooling = prepareTooling;
|
|
|
5
5
|
exports.noTooling = noTooling;
|
|
6
6
|
const logger_1 = require("../../utils/logger");
|
|
7
7
|
const resolve_bin_1 = require("../../utils/resolve-bin");
|
|
8
|
-
const cache_1 = require("./cache");
|
|
9
8
|
const ensure_1 = require("./ensure");
|
|
10
9
|
const profiles_1 = require("./profiles");
|
|
11
10
|
/**
|
|
12
11
|
* Whether a profile needs fetching at all, or already ships inside the package.
|
|
13
12
|
*
|
|
14
|
-
* This is the hinge the whole adaptive-install rollout turns on.
|
|
15
|
-
* profile's packages sit in our `dependencies`, so
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
13
|
+
* This is the hinge the whole adaptive-install rollout turns on. Nearly every
|
|
14
|
+
* profile's packages sit in our `dependencies`, so it answers `true`, nothing is
|
|
15
|
+
* fetched, and a run is byte-for-byte the run it was before any of this existed.
|
|
16
|
+
* `pytypes` is the exception that already proves the other half works: `pyright`
|
|
17
|
+
* is a devDependency, so it reaches no client and is fetched on demand. When a
|
|
18
|
+
* package is moved out of `dependencies` it stops being present, its profile
|
|
19
|
+
* starts answering `false`, and the fetch turns itself on — no flag, no second
|
|
20
|
+
* code path, nothing to remember to flip.
|
|
20
21
|
*
|
|
21
22
|
* Which is what makes releases 2 and 3 of ticket 064 an edit to `package.json`
|
|
22
23
|
* rather than another change to this logic.
|
|
@@ -77,23 +78,10 @@ async function prepareTooling(opts) {
|
|
|
77
78
|
for (const check of profile.checks)
|
|
78
79
|
unavailable.set(check, reason);
|
|
79
80
|
};
|
|
80
|
-
|
|
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
|
-
};
|
|
81
|
+
const toFetch = [];
|
|
94
82
|
for (const profile of wanted) {
|
|
95
|
-
// Bundled tooling is the answer to "where is it" for
|
|
96
|
-
//
|
|
83
|
+
// Bundled tooling is the answer to "where is it" for most profiles, and
|
|
84
|
+
// asking first is what keeps this whole path free of network calls.
|
|
97
85
|
if (profileSatisfiedLocally(profile))
|
|
98
86
|
continue;
|
|
99
87
|
const block = denied(profile, deny);
|
|
@@ -101,30 +89,38 @@ async function prepareTooling(opts) {
|
|
|
101
89
|
miss(profile, block);
|
|
102
90
|
continue;
|
|
103
91
|
}
|
|
104
|
-
// No early exit for `install.strategy: "manual"
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
92
|
+
// No early exit for `install.strategy: "manual"`. It means "never fetch",
|
|
93
|
+
// and `ensureTooling` enforces that — but only after checking the cache, so
|
|
94
|
+
// pre-warmed tooling is still used. Returning here instead meant `manual`
|
|
95
|
+
// also refused tooling that was already downloaded.
|
|
108
96
|
if (onMissing === 'skip') {
|
|
109
97
|
miss(profile, 'install.onMissingTool is "skip"');
|
|
110
98
|
continue;
|
|
111
99
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
100
|
+
toFetch.push(profile);
|
|
101
|
+
}
|
|
102
|
+
// One call, one directory, one lock — for the whole run rather than per profile.
|
|
103
|
+
// Profiles overlap heavily, and a tree each made the client download the shared
|
|
104
|
+
// half once per profile that named it (ticket 087).
|
|
105
|
+
//
|
|
106
|
+
// Reached only when something actually needs fetching, which is also what keeps
|
|
107
|
+
// the cache directory untouched otherwise: resolving it is not a read — it
|
|
108
|
+
// creates the directory and warns when the configured path is unwritable, and a
|
|
109
|
+
// container with a read-only home would print that on every single run.
|
|
110
|
+
if (toFetch.length > 0) {
|
|
111
|
+
const result = await (0, ensure_1.ensureTooling)(toFetch.map(profile => profile.name), {
|
|
118
112
|
cwd: opts.cwd,
|
|
119
113
|
version: opts.version,
|
|
120
114
|
install,
|
|
121
115
|
...(opts.installer ? { installer: opts.installer } : {}),
|
|
122
116
|
});
|
|
123
117
|
if (result.status === 'failed' || result.status === 'skipped') {
|
|
124
|
-
|
|
125
|
-
|
|
118
|
+
for (const profile of toFetch)
|
|
119
|
+
miss(profile, result.reason);
|
|
120
|
+
}
|
|
121
|
+
else if (result.dir) {
|
|
122
|
+
dirs.push(result.dir);
|
|
126
123
|
}
|
|
127
|
-
dirs.push(result.dir ?? (0, cache_1.profileDir)(cache.dir, profile.name));
|
|
128
124
|
}
|
|
129
125
|
if (unavailable.size > 0) {
|
|
130
126
|
// Always logged, never behind --debug. A check that did not run is the
|
package/dist/core/registry.js
CHANGED
|
@@ -37,6 +37,7 @@ const dead_code_1 = require("./checks/quality/dead-code");
|
|
|
37
37
|
const duplicate_check_1 = require("./checks/quality/duplicate-check");
|
|
38
38
|
const license_header_1 = require("./checks/quality/license-header");
|
|
39
39
|
const markdown_lint_1 = require("./checks/quality/markdown-lint");
|
|
40
|
+
const package_size_1 = require("./checks/quality/package-size");
|
|
40
41
|
const spellcheck_1 = require("./checks/quality/spellcheck");
|
|
41
42
|
const symlinks_1 = require("./checks/quality/symlinks");
|
|
42
43
|
const yaml_lint_1 = require("./checks/quality/yaml-lint");
|
|
@@ -90,6 +91,7 @@ exports.REGISTRY = [
|
|
|
90
91
|
spellcheck_1.spellcheckCheck,
|
|
91
92
|
yaml_lint_1.yamlLintCheck,
|
|
92
93
|
symlinks_1.symlinksCheck,
|
|
94
|
+
package_size_1.packageSizeCheck,
|
|
93
95
|
python_lint_1.pythonLintCheck,
|
|
94
96
|
python_typecheck_1.pythonTypecheckCheck,
|
|
95
97
|
dead_code_1.deadCodeCheck,
|
|
@@ -180,6 +180,11 @@ export interface PRCheckMateConfig {
|
|
|
180
180
|
typecheck?: boolean;
|
|
181
181
|
typeChecker?: 'mypy' | 'pyright';
|
|
182
182
|
};
|
|
183
|
+
packageSize?: {
|
|
184
|
+
enabled?: boolean;
|
|
185
|
+
maxGrowthPercent?: number;
|
|
186
|
+
compareWith?: string;
|
|
187
|
+
};
|
|
183
188
|
diffSmell?: {
|
|
184
189
|
enabled?: boolean;
|
|
185
190
|
ignorePaths?: string[];
|