pr-checkmate 3.0.0 → 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.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/cspell.json +379 -378
  3. package/dist/cli.js +1 -1
  4. package/dist/config/constants.js +1 -0
  5. package/dist/core/checks/languages/ruff-shared.d.ts +3 -3
  6. package/dist/core/checks/languages/ruff-shared.js +27 -4
  7. package/dist/core/checks/quality/coverage.js +6 -1
  8. package/dist/core/checks/quality/dead-code.js +12 -3
  9. package/dist/core/checks/quality/package-size.d.ts +15 -0
  10. package/dist/core/checks/quality/package-size.js +130 -0
  11. package/dist/core/commands.js +1 -0
  12. package/dist/core/install/cache.d.ts +20 -1
  13. package/dist/core/install/cache.js +23 -1
  14. package/dist/core/install/doctor.js +40 -15
  15. package/dist/core/install/ensure.d.ts +31 -14
  16. package/dist/core/install/ensure.js +145 -60
  17. package/dist/core/install/merge.d.ts +47 -0
  18. package/dist/core/install/merge.js +91 -0
  19. package/dist/core/install/prepare.d.ts +13 -12
  20. package/dist/core/install/prepare.js +32 -36
  21. package/dist/core/registry.js +2 -0
  22. package/dist/interfaces/index.d.ts +5 -0
  23. package/install-manifests/commitlint/package-lock.json +128 -250
  24. package/install-manifests/commitlint/package.json +2 -2
  25. package/install-manifests/core/package-lock.json +76 -76
  26. package/install-manifests/core/package.json +1 -1
  27. package/install-manifests/duplicate/package-lock.json +45 -45
  28. package/install-manifests/duplicate/package.json +1 -1
  29. package/install-manifests/js/package-lock.json +248 -283
  30. package/install-manifests/js/package.json +9 -9
  31. package/install-manifests/python/package-lock.json +4 -4
  32. package/install-manifests/python/package.json +1 -1
  33. package/package.json +18 -18
  34. package/pr-checkmate.schema.json +35 -13
  35. package/src/config/cspell.json +379 -378
  36. 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.ensureProfile = ensureProfile;
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
- /** The manifest pair copied into the cache; `npm ci` reads exactly these two. */
21
- const MANIFEST_FILES = ['package.json', 'package-lock.json'];
22
- const LOCKFILE = MANIFEST_FILES[1];
22
+ const MANIFEST = 'package.json';
23
+ const LOCKFILE = 'package-lock.json';
23
24
  /**
24
- * A fingerprint of the manifest a profile directory was installed from.
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 manifestHash(dir) {
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 lock = node_fs_1.default.readFileSync(node_path_1.default.join(dir, LOCKFILE));
34
- return node_crypto_1.default.createHash('sha256').update(lock).digest('hex');
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
- /** Whether this profile directory already holds exactly this manifest, installed. */
41
- function isInstalled(dir, expected) {
42
- if (!expected)
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
- try {
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
- /** Copy the shipped manifest into the cache so `npm ci` has something to read. */
53
- function stageManifest(from, to) {
54
- node_fs_1.default.mkdirSync(to, { recursive: true });
55
- for (const file of MANIFEST_FILES) {
56
- node_fs_1.default.copyFileSync(node_path_1.default.join(from, file), node_path_1.default.join(to, file));
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 one profile's tooling available, fetching it only if it is not already there.
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 postinstall
63
- * warm-up and the lazy path taken when postinstall never ran (`npm ci
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
- * The install itself is a plain `npm ci` against a manifest we generated from our
69
- * own lockfile, run from the client's repository so their `.npmrc`, registry and
70
- * proxy settings apply unchanged. `--ignore-scripts` is not negotiable: we are a
71
- * supply-chain tool, and running arbitrary install hooks from a security check
72
- * would be indefensible. Integrity is npm's own, over the whole transitive tree —
73
- * a tampered tarball aborts with `EINTEGRITY` rather than being installed.
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 ensureProfile(profile, opts) {
125
+ async function ensureTooling(wanted, opts) {
76
126
  const { install } = opts;
77
- const source = manifestDir(profile);
78
- const expected = manifestHash(source);
79
- if (!expected) {
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.profileDir)(location.dir, profile);
91
- if (isInstalled(dir, expected)) {
92
- return { profile, status: 'cached', reason: 'already installed', dir };
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 profile is not in the cache',
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 profile is not in the cache',
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 profile directory, so the directory has to exist
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 { profile, status: 'failed', reason: `cannot create ${dir}: ${err.message}` };
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 { profile, status: 'failed', reason: `cannot write to ${dir}`, dir };
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 (isInstalled(dir, expected)) {
142
- return { profile, status: 'cached', reason: 'installed by another process', dir };
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(profile, source, dir, expected, opts);
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(profile, source, dir, expected, opts) {
243
+ async function runInstall(profiles, manifest, dir, opts) {
166
244
  const started = Date.now();
167
- stageManifest(source, dir);
168
- const count = Object.keys(JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, LOCKFILE), 'utf-8')).packages).length;
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 profile "${profile}" (${count} packages) → ${dir}`);
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 { profile, status: 'failed', reason: `npm ci failed: ${result.detail}`, dir };
255
+ return { status: 'failed', reason: `npm ci failed: ${result.detail}`, dir, profiles: [] };
177
256
  }
178
- node_fs_1.default.writeFileSync(stampPath(dir), expected);
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]: ✅ profile "${profile}" ready in ${(durationMs / 1000).toFixed(1)}s`);
181
- return { profile, status: 'installed', reason: `${count} packages`, dir, durationMs };
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 { profile, status: 'failed', reason: `npm ci failed: ${err.message}`, dir };
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. Today every
9
- * profile's packages sit in our `dependencies`, so every profile answers `true`
10
- * here, nothing is ever fetched, and a run is byte-for-byte the run it was
11
- * before any of this existed. When a package is moved out of `dependencies` it
12
- * stops being present, its profile starts answering `false`, and the fetch turns
13
- * itself on — no flag, no second code path, nothing to remember to flip.
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 currently ships inside the package, so the interesting
46
- * half of this function — a profile that is genuinely absent — is unreachable
47
- * without either a network install in a unit test or a way to describe a
48
- * profile that is not here. The code that runs when a package finally leaves
49
- * `dependencies` must be tested before that day, not on it.
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 `ensureProfile`. Injected by tests. */
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. Today every
15
- * profile's packages sit in our `dependencies`, so every profile answers `true`
16
- * here, nothing is ever fetched, and a run is byte-for-byte the run it was
17
- * before any of this existed. When a package is moved out of `dependencies` it
18
- * stops being present, its profile starts answering `false`, and the fetch turns
19
- * itself on — no flag, no second code path, nothing to remember to flip.
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
- // Resolved lazily, and at most once.
81
- //
82
- // Lazily because resolving is not a read: it creates the directory and, when the
83
- // configured one is unwritable, warns about where fetched tooling will live
84
- // instead. On a run that fetches nothing — every run, while the tooling still
85
- // ships in `dependencies` — that warning is about a thing that never happens,
86
- // and a container with a read-only home would print it on every single run.
87
- // At most once because the warning belongs to the run, not to each profile.
88
- let location;
89
- const cacheDir = () => {
90
- if (location === undefined)
91
- location = (0, cache_1.resolveCacheDir)(opts.version, install.cacheDir);
92
- return location;
93
- };
81
+ const toFetch = [];
94
82
  for (const profile of wanted) {
95
- // Bundled tooling is the answer to "where is it" for every profile today,
96
- // and asking first is what keeps this whole path free of network calls.
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"` any more. It means "never
105
- // fetch", and `ensureProfile` enforces that — but only after checking the
106
- // cache, so a pre-warmed profile is still used. Returning here instead
107
- // meant `manual` also refused tooling that was already downloaded.
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
- const cache = cacheDir();
113
- if (!cache) {
114
- miss(profile, 'no writable cache directory for fetched tooling');
115
- continue;
116
- }
117
- const result = await (0, ensure_1.ensureProfile)(profile.name, {
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
- miss(profile, result.reason);
125
- continue;
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
@@ -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[];