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.
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 +36 -0
  10. package/dist/core/checks/quality/package-size.js +203 -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
package/dist/cli.js CHANGED
@@ -88,7 +88,7 @@ void (async () => {
88
88
  const results = await (0, doctor_1.installProfiles)(process.cwd(), flags.includes('--all'));
89
89
  const failed = results.filter(r => r.status === 'failed');
90
90
  for (const result of failed) {
91
- utils_1.logger.error(`[install]: ❌ ${result.profile}: ${result.reason}`);
91
+ utils_1.logger.error(`[install]: ❌ ${result.profiles.join(', ') || 'tooling'}: ${result.reason}`);
92
92
  }
93
93
  process.exit(failed.length > 0 ? 1 : 0);
94
94
  }
@@ -346,6 +346,7 @@ exports.KNOWN_CONFIG_KEYS = new Set([
346
346
  'licenseHeader',
347
347
  'duplicate',
348
348
  'deadCode',
349
+ 'packageSize',
349
350
  'prBody',
350
351
  'prSize',
351
352
  'commitlint',
@@ -2,9 +2,9 @@ type RuffModule = typeof import('@astral-sh/ruff-wasm-nodejs');
2
2
  type RuffWorkspace = InstanceType<RuffModule['Workspace']>;
3
3
  export declare const PY_GLOBS: string[];
4
4
  /**
5
- * Instantiate a Ruff WASM workspace with default settings. Shared by the Ruff
6
- * lint and format checks so their setup stays identical, and kept as a function
7
- * (not a module-level singleton) so the WASM module loads only when a check runs.
5
+ * Instantiate a Ruff WASM workspace. Shared by the Ruff lint and format checks so
6
+ * their setup stays identical, and kept as a function (not a module-level
7
+ * singleton) so the WASM module loads only when a check runs.
8
8
  */
9
9
  export declare function createRuffWorkspace(): RuffWorkspace;
10
10
  export {};
@@ -4,11 +4,34 @@ exports.PY_GLOBS = void 0;
4
4
  exports.createRuffWorkspace = createRuffWorkspace;
5
5
  exports.PY_GLOBS = ['*.py'];
6
6
  /**
7
- * Instantiate a Ruff WASM workspace with default settings. Shared by the Ruff
8
- * lint and format checks so their setup stays identical, and kept as a function
9
- * (not a module-level singleton) so the WASM module loads only when a check runs.
7
+ * The rules we lint Python with — pinned here rather than left to Ruff's defaults.
8
+ *
9
+ * `E4`/`E7`/`E9` (import placement, statement style, syntax) plus `F` (pyflakes:
10
+ * undefined names, unused imports, unreachable code) — the set Ruff itself
11
+ * shipped as its default through 0.15.
12
+ *
13
+ * Written down because leaving it implicit made our verdict change under us.
14
+ * Ruff 0.16 added the isort rules (`I`) to its defaults, and a routine dependency
15
+ * bump turned that into a 5- to 48-fold increase in findings across seventeen
16
+ * Python repositories — every one of them `I001`, "import block is un-sorted".
17
+ * Two repositories went from a clean report to a warning on code nobody had
18
+ * touched. Import order is a matter of taste, and a check that fires on every
19
+ * file in a repository is how people learn to ignore the whole report.
20
+ *
21
+ * Adding a rule to this list is a decision with a measurement behind it, which
22
+ * is the point: it is ours to make, not our dependency's to make for us.
23
+ */
24
+ const SELECTED_RULES = ['E4', 'E7', 'E9', 'F'];
25
+ /**
26
+ * Instantiate a Ruff WASM workspace. Shared by the Ruff lint and format checks so
27
+ * their setup stays identical, and kept as a function (not a module-level
28
+ * singleton) so the WASM module loads only when a check runs.
10
29
  */
11
30
  function createRuffWorkspace() {
12
31
  const { Workspace, PositionEncoding } = require('@astral-sh/ruff-wasm-nodejs');
13
- return new Workspace(Workspace.defaultSettings(), PositionEncoding.Utf16);
32
+ const settings = {
33
+ ...Workspace.defaultSettings(),
34
+ lint: { select: SELECTED_RULES },
35
+ };
36
+ return new Workspace(settings, PositionEncoding.Utf16);
14
37
  }
@@ -48,7 +48,12 @@ exports.coverageCheck = {
48
48
  : DEFAULT_REPORTS;
49
49
  const found = candidates.find(rel => {
50
50
  try {
51
- return node_fs_1.default.statSync(node_path_1.default.join(ctx.cwd, rel)).size > 0;
51
+ // `isFile()` as well as the size: `statSync` reports a non-zero size for
52
+ // a directory too, so a path that happens to be one would read as a
53
+ // report. Non-empty matters because a zero-byte report is what a test
54
+ // run that died halfway leaves behind — the case this check exists for.
55
+ const stat = node_fs_1.default.statSync(node_path_1.default.join(ctx.cwd, rel));
56
+ return stat.isFile() && stat.size > 0;
52
57
  }
53
58
  catch {
54
59
  return false;
@@ -44,7 +44,7 @@ exports.deadCodeCheck = {
44
44
  const ignore = compileIgnorePatterns(ctx.config.deadCode?.ignoreFiles ?? constants_1.DEAD_CODE_IGNORE_DEFAULTS);
45
45
  // ts-unused-exports prints one `path: sym1, sym2` line per file, preceded
46
46
  // by a `N modules with unused exports` summary line we skip.
47
- const entries = output
47
+ const parsed = output
48
48
  .split('\n')
49
49
  .map(line => line.trim().match(/^(.+\.[cm]?tsx?): (.+)$/))
50
50
  .filter((match) => match !== null)
@@ -54,8 +54,17 @@ exports.deadCodeCheck = {
54
54
  .split(',')
55
55
  .map(sym => sym.trim())
56
56
  .filter(Boolean),
57
- }))
58
- .filter(entry => !ignore.some(re => re.test(entry.file)));
57
+ }));
58
+ // The tool said something and none of it was a report. That is not a clean
59
+ // repository — it is an answer this check cannot read, and calling it a
60
+ // pass is how Circular Deps stayed green on every repository for its whole
61
+ // existence while grepping for console wording that had changed. Same
62
+ // resolution as Grype Scan: say so rather than invent a verdict.
63
+ if (parsed.length === 0) {
64
+ utils_1.logger.warn(`[Dead Code]: ⚠️ ts-unused-exports produced no parseable output:\n${output}`);
65
+ return (0, types_1.warn)('ts-unused-exports produced no parseable output');
66
+ }
67
+ const entries = parsed.filter(entry => !ignore.some(re => re.test(entry.file)));
59
68
  if (entries.length === 0) {
60
69
  utils_1.logger.info('[Dead Code]: ✅ No unused exports found');
61
70
  return (0, types_1.pass)();
@@ -0,0 +1,36 @@
1
+ import { Check } from '../../types';
2
+ /** Why we could not read a published version. */
3
+ export type QueryFailure = 'absent' | 'unauthenticated' | 'unreadable';
4
+ /**
5
+ * Tell "never published" apart from "we were not let in".
6
+ *
7
+ * Lumped into one sentence they read the same and they are not: one is a fact
8
+ * about the package, the other is about this run's credentials. A skip whose
9
+ * reason is a guess between two causes is the defect of ticket 057, one layer up.
10
+ *
11
+ * A 401 is also evidence in itself, and it points one way: a name nobody ever
12
+ * published answers 404 on the public registry, while 401 comes from a host that
13
+ * has the package and will not show it to a stranger. So the message leads with
14
+ * the token, which is what that situation actually needs.
15
+ *
16
+ * It deliberately does NOT suggest `"private": true` as the way out. That field
17
+ * means "never publish this", and a package behind a private registry is
18
+ * published — advising it would break the publish step of the very repository
19
+ * asking. `packageSize.enabled: false` silences the row and touches nothing
20
+ * else, so that is the opt-out worth naming.
21
+ */
22
+ export declare function classifyQueryFailure(stderr: string): QueryFailure;
23
+ /**
24
+ * Reports how much the published package grew since the last release.
25
+ *
26
+ * The question this answers is "is it growing without anyone noticing", not "has
27
+ * it crossed a line" — so the comparison is against the previously published
28
+ * version rather than a threshold somebody had to invent. A number a client never
29
+ * configured is a number they will not trust, and a check that fires on every
30
+ * release teaches people to skim the whole report.
31
+ *
32
+ * Advisory by design. Growth is usually earned — a language profile was added, a
33
+ * WASM binary landed — and a red build for it would be wrong. `severity:
34
+ * { "Package Size": "error" }` makes it a gate for a repository that wants one.
35
+ */
36
+ export declare const packageSizeCheck: Check;
@@ -0,0 +1,203 @@
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.packageSizeCheck = void 0;
7
+ exports.classifyQueryFailure = classifyQueryFailure;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const utils_1 = require("../../../utils");
11
+ const execa_1 = require("../../../utils/execa");
12
+ const types_1 = require("../../types");
13
+ const findings_1 = require("../findings");
14
+ /** Growth that is worth a row in the report, unless the client says otherwise. */
15
+ const DEFAULT_MAX_GROWTH = 20;
16
+ /** How many of the files behind the growth are named. */
17
+ const NAMED_FILES = 5;
18
+ /**
19
+ * Ask npm what a tarball would contain, without producing one.
20
+ *
21
+ * `--dry-run` writes nothing to the working tree — which matters, because a check
22
+ * whose side effect is a `.tgz` beside the source shows up in the client's next
23
+ * commit. Given a package spec instead of nothing, npm answers for the PUBLISHED
24
+ * version, with the same shape and the same per-file detail; that is what makes a
25
+ * real comparison possible rather than a bare number.
26
+ *
27
+ * `--ignore-scripts` is not negotiable. `npm pack` runs `prepare` and `prepack`,
28
+ * and in a client's repository those are arbitrary commands — frequently a full
29
+ * build. A quality check that executes them has stopped being a check and started
30
+ * being a build step with side effects nobody asked for. We are a supply-chain
31
+ * tool; the same rule already governs the install path and the Docker build.
32
+ *
33
+ * @param cwd Repository to run in.
34
+ * @param spec `undefined` for the working tree, or `name@tag` for a release.
35
+ * @param registry Where to look for a published version; the manifest's own
36
+ * `publishConfig.registry` when it names one.
37
+ */
38
+ async function packManifest(cwd, spec, registry) {
39
+ const args = [
40
+ 'pack',
41
+ ...(spec ? [spec] : []),
42
+ '--dry-run',
43
+ '--json',
44
+ '--ignore-scripts',
45
+ ...(registry ? ['--registry', registry] : []),
46
+ ];
47
+ const result = await (0, execa_1.execa)('npm', args, { cwd, reject: false });
48
+ if (result.exitCode !== 0)
49
+ return { failure: classifyQueryFailure(result.stderr ?? '') };
50
+ try {
51
+ const parsed = JSON.parse(result.stdout);
52
+ if (Array.isArray(parsed) && parsed.length > 0) {
53
+ return { manifest: parsed[0] };
54
+ }
55
+ }
56
+ catch {
57
+ // Fall through: npm answered with something that is not a manifest.
58
+ }
59
+ return { failure: 'unreadable' };
60
+ }
61
+ /**
62
+ * Tell "never published" apart from "we were not let in".
63
+ *
64
+ * Lumped into one sentence they read the same and they are not: one is a fact
65
+ * about the package, the other is about this run's credentials. A skip whose
66
+ * reason is a guess between two causes is the defect of ticket 057, one layer up.
67
+ *
68
+ * A 401 is also evidence in itself, and it points one way: a name nobody ever
69
+ * published answers 404 on the public registry, while 401 comes from a host that
70
+ * has the package and will not show it to a stranger. So the message leads with
71
+ * the token, which is what that situation actually needs.
72
+ *
73
+ * It deliberately does NOT suggest `"private": true` as the way out. That field
74
+ * means "never publish this", and a package behind a private registry is
75
+ * published — advising it would break the publish step of the very repository
76
+ * asking. `packageSize.enabled: false` silences the row and touches nothing
77
+ * else, so that is the opt-out worth naming.
78
+ */
79
+ function classifyQueryFailure(stderr) {
80
+ if (/\bE?40[13]\b|Unauthorized|Forbidden|authentication token/i.test(stderr)) {
81
+ return 'unauthenticated';
82
+ }
83
+ if (/\bE?404\b|Not found/i.test(stderr))
84
+ return 'absent';
85
+ return 'unreadable';
86
+ }
87
+ /** `1.15 MB`, at the precision a human compares two of these at. */
88
+ function mb(bytes) {
89
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
90
+ }
91
+ /**
92
+ * Which files account for the growth, biggest first.
93
+ *
94
+ * A percentage alone is a finding nobody can act on — the same defect as a
95
+ * duplicate-code report with no file names (ticket 078). The published manifest
96
+ * carries per-file sizes, so the answer is a plain diff of the two lists: files
97
+ * that appeared, and files that grew.
98
+ */
99
+ function growthByFile(before, after) {
100
+ const old = new Map(before.map(f => [f.path, f.size]));
101
+ return after
102
+ .map(f => ({ path: f.path, delta: f.size - (old.get(f.path) ?? 0), isNew: !old.has(f.path) }))
103
+ .filter(f => f.delta > 0)
104
+ .sort((a, b) => b.delta - a.delta)
105
+ .slice(0, NAMED_FILES)
106
+ .map(f => `${f.path} +${mb(f.delta)}${f.isNew ? ' (new)' : ''}`);
107
+ }
108
+ /** What this repository publishes, and where — or `null` if it publishes nothing. */
109
+ function published(cwd) {
110
+ try {
111
+ const pkg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, 'package.json'), 'utf-8'));
112
+ if (pkg.private || !pkg.name)
113
+ return null;
114
+ // `publishConfig.registry` is the manifest's own statement of where this
115
+ // package lives, and npm uses it for `publish` but not for a fetch. Asking
116
+ // the default registry instead reports "no published version" for every
117
+ // package on GitHub Packages or a company mirror — which is not a fact about
118
+ // the package, it is us having asked the wrong host. Measured on a real
119
+ // client: the default registry answered 404 for a package published at
120
+ // version 1.2.15.
121
+ const registry = pkg.publishConfig?.registry;
122
+ return registry ? { name: pkg.name, registry } : { name: pkg.name };
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ /**
129
+ * Reports how much the published package grew since the last release.
130
+ *
131
+ * The question this answers is "is it growing without anyone noticing", not "has
132
+ * it crossed a line" — so the comparison is against the previously published
133
+ * version rather than a threshold somebody had to invent. A number a client never
134
+ * configured is a number they will not trust, and a check that fires on every
135
+ * release teaches people to skim the whole report.
136
+ *
137
+ * Advisory by design. Growth is usually earned — a language profile was added, a
138
+ * WASM binary landed — and a red build for it would be wrong. `severity:
139
+ * { "Package Size": "error" }` makes it a gate for a repository that wants one.
140
+ */
141
+ exports.packageSizeCheck = {
142
+ name: 'Package Size',
143
+ phase: 'informational',
144
+ weight: 'heavy',
145
+ applies(ctx) {
146
+ if (ctx.config.packageSize?.enabled === false)
147
+ return false;
148
+ // A private package is never published, so there is nothing to compare with
149
+ // and no consumer to pay for the growth.
150
+ return published(ctx.cwd) !== null;
151
+ },
152
+ async run(ctx) {
153
+ const target = published(ctx.cwd);
154
+ if (!target)
155
+ return (0, types_1.skip)('no publishable package.json');
156
+ const tag = ctx.config.packageSize?.compareWith ?? 'latest';
157
+ const maxGrowth = ctx.config.packageSize?.maxGrowthPercent ?? DEFAULT_MAX_GROWTH;
158
+ const spec = `${target.name}@${tag}`;
159
+ const local = await packManifest(ctx.cwd);
160
+ if (!('manifest' in local))
161
+ return (0, types_1.skip)('npm could not describe this package');
162
+ const current = local.manifest;
163
+ // Never a pass: a green row over a comparison that did not happen is the
164
+ // mistake of ticket 072. And never one reason standing in for three — the
165
+ // three are different situations for the reader, and only one of them is a
166
+ // fact about the package rather than about the run.
167
+ const remote = await packManifest(ctx.cwd, spec, target.registry);
168
+ if (!('manifest' in remote)) {
169
+ // The fact, and nothing past it. The remedy for a private registry is three
170
+ // lines in a workflow — `setup-node` with `registry-url`, a scope, and
171
+ // `NODE_AUTH_TOKEN`, because npm reads the token through a reference in
172
+ // `.npmrc` — and instructions that do not fit cannot be followed. They
173
+ // belong on the check's page, which the report already links to.
174
+ //
175
+ // The package name is left out on purpose: the reader owns it. The host is
176
+ // not, because it is the one part they cannot infer — and the difference
177
+ // between "we asked the wrong registry" and "we were refused" is exactly
178
+ // what this row got wrong before.
179
+ // Named only when we know it. Without `publishConfig.registry` npm used
180
+ // whatever the client's own config points at, and we do not know what that
181
+ // was — "at the registry" would be filler dressed up as information.
182
+ const host = target.registry ? new URL(target.registry).host : null;
183
+ const at = host ? ` at ${host}` : '';
184
+ const reason = {
185
+ absent: `nothing published under this name${at} yet`,
186
+ unauthenticated: `no credentials for ${host ?? 'the registry'}`,
187
+ unreadable: `could not read the published version${at}`,
188
+ }[remote.failure];
189
+ return (0, types_1.skip)(reason);
190
+ }
191
+ const previous = remote.manifest;
192
+ const delta = current.unpackedSize - previous.unpackedSize;
193
+ const growth = previous.unpackedSize > 0 ? (delta / previous.unpackedSize) * 100 : 0;
194
+ const summary = `${mb(previous.unpackedSize)} → ${mb(current.unpackedSize)} ` +
195
+ `(${growth >= 0 ? '+' : ''}${growth.toFixed(1)}%) vs ${previous.version}`;
196
+ if (growth <= maxGrowth) {
197
+ utils_1.logger.info(`[Package Size]: ✅ ${summary}`);
198
+ return (0, types_1.pass)(summary);
199
+ }
200
+ (0, findings_1.logSample)('Package Size', `${summary}:`, growthByFile(previous.files, current.files));
201
+ return (0, types_1.warn)(summary);
202
+ },
203
+ };
@@ -33,6 +33,7 @@ exports.COMMAND_TO_CHECK = {
33
33
  markdown: 'Markdown',
34
34
  'yaml-lint': 'YAML Lint',
35
35
  symlinks: 'Broken Symlinks',
36
+ 'package-size': 'Package Size',
36
37
  shellcheck: 'ShellCheck',
37
38
  typecheck: 'TypeScript',
38
39
  'diff-smell': 'Diff Smells',
@@ -27,7 +27,26 @@ export declare function expandHome(dir: string, home?: string): string;
27
27
  * @param configured `install.cacheDir`, if the client set one.
28
28
  */
29
29
  export declare function resolveCacheDir(version: string, configured?: string, env?: NodeJS.ProcessEnv): CacheLocation | null;
30
- /** Where a single profile's tooling is installed. */
30
+ /**
31
+ * Where fetched tooling is installed — one tree for the whole run, not one per profile.
32
+ *
33
+ * A directory per profile left npm nothing to deduplicate, and the profiles
34
+ * overlap heavily: `commitlint` and `js` share 30 MB, so two trees fetched both
35
+ * copies. Measured on a JavaScript repository, per-profile directories came to
36
+ * 267 MB where a single merged tree costs 213 (ticket 087).
37
+ *
38
+ * The name is deliberately not derived from which profiles are inside. What is
39
+ * installed is recorded in the directory's own stamp and checked as a superset,
40
+ * so a cache warmed by `install --all` serves every narrower run — a path keyed
41
+ * by the profile set would miss on the first one.
42
+ */
43
+ export declare function toolingDir(cacheDir: string): string;
44
+ /**
45
+ * Where a single profile's tooling used to be installed.
46
+ *
47
+ * Kept only so `cache prune` can still recognise and remove the directories left
48
+ * by versions that used the per-profile layout.
49
+ */
31
50
  export declare function profileDir(cacheDir: string, profile: string): string;
32
51
  /**
33
52
  * Profile directories left behind by other versions of pr-checkmate.
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.expandHome = expandHome;
7
7
  exports.resolveCacheDir = resolveCacheDir;
8
+ exports.toolingDir = toolingDir;
8
9
  exports.profileDir = profileDir;
9
10
  exports.stalePaths = stalePaths;
10
11
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -102,7 +103,28 @@ function resolveCacheDir(version, configured, env = process.env) {
102
103
  }
103
104
  return null;
104
105
  }
105
- /** Where a single profile's tooling is installed. */
106
+ /**
107
+ * Where fetched tooling is installed — one tree for the whole run, not one per profile.
108
+ *
109
+ * A directory per profile left npm nothing to deduplicate, and the profiles
110
+ * overlap heavily: `commitlint` and `js` share 30 MB, so two trees fetched both
111
+ * copies. Measured on a JavaScript repository, per-profile directories came to
112
+ * 267 MB where a single merged tree costs 213 (ticket 087).
113
+ *
114
+ * The name is deliberately not derived from which profiles are inside. What is
115
+ * installed is recorded in the directory's own stamp and checked as a superset,
116
+ * so a cache warmed by `install --all` serves every narrower run — a path keyed
117
+ * by the profile set would miss on the first one.
118
+ */
119
+ function toolingDir(cacheDir) {
120
+ return node_path_1.default.join(cacheDir, 'tooling');
121
+ }
122
+ /**
123
+ * Where a single profile's tooling used to be installed.
124
+ *
125
+ * Kept only so `cache prune` can still recognise and remove the directories left
126
+ * by versions that used the per-profile layout.
127
+ */
106
128
  function profileDir(cacheDir, profile) {
107
129
  return node_path_1.default.join(cacheDir, profile);
108
130
  }
@@ -9,7 +9,6 @@ exports.installProfiles = installProfiles;
9
9
  exports.runDoctor = runDoctor;
10
10
  exports.pruneCache = pruneCache;
11
11
  const node_fs_1 = __importDefault(require("node:fs"));
12
- const node_path_1 = __importDefault(require("node:path"));
13
12
  const loader_1 = require("../../config/loader");
14
13
  const detect_language_1 = require("../../utils/detect-language");
15
14
  const logger_1 = require("../../utils/logger");
@@ -32,11 +31,31 @@ async function planInstall(cwd = process.cwd(), usage) {
32
31
  const location = (0, cache_1.resolveCacheDir)((0, package_version_1.packageVersion)(), config.install?.cacheDir);
33
32
  return { decisions: (0, profiles_1.planProfiles)(detected, config), cacheDir: location?.dir ?? null };
34
33
  }
35
- /** Whether a profile's tooling is present in the cache right now. */
34
+ /**
35
+ * Which profiles are usable right now, and why — fetched, or shipped with us.
36
+ *
37
+ * The cache is only half the answer, and reporting only that half was a lie in
38
+ * the client's favour's opposite direction: most profiles ship inside the
39
+ * package, are never fetched, and were listed as "not installed yet" on a machine
40
+ * where they worked perfectly. `doctor` exists to answer "why did my typecheck
41
+ * not run", so telling someone seven profiles are missing when six of them are
42
+ * present sends them to debug nothing.
43
+ *
44
+ * The cached half is read from the tooling directory's own stamp rather than
45
+ * inferred from paths: there is one tree for everything fetched, so what went
46
+ * into it is recorded rather than visible in the layout (ticket 087).
47
+ */
36
48
  function installedProfiles(cacheDir) {
37
- if (!cacheDir)
38
- return new Set();
39
- return new Set(profiles_1.PROFILES.filter(p => node_fs_1.default.existsSync(node_path_1.default.join((0, cache_1.profileDir)(cacheDir, p.name), 'node_modules'))).map(p => p.name));
49
+ const present = new Map();
50
+ for (const profile of profiles_1.PROFILES) {
51
+ if ((0, prepare_1.profileSatisfiedLocally)(profile))
52
+ present.set(profile.name, 'ships with the package');
53
+ }
54
+ if (cacheDir) {
55
+ for (const name of (0, ensure_1.cachedProfiles)(cacheDir))
56
+ present.set(name, 'fetched');
57
+ }
58
+ return present;
40
59
  }
41
60
  /**
42
61
  * Fetch every profile this repository needs.
@@ -67,14 +86,15 @@ async function installProfiles(cwd = process.cwd(), all = false) {
67
86
  logger_1.logger.info(`[install]: profile "${name}" skipped — install.deny lists ${blocked.join(', ')}`);
68
87
  return false;
69
88
  });
70
- const results = [];
71
- // Sequential on purpose. Parallel installs of profiles that share hundreds of
72
- // packages fight over npm's cache and gain nothing; the wall-clock cost lands
73
- // once, on a cold cache, and clarity of the log matters more there.
74
- for (const profile of wanted) {
75
- results.push(await (0, ensure_1.ensureProfile)(profile, { cwd, version: (0, package_version_1.packageVersion)(), install }));
76
- }
77
- return results;
89
+ // One install for the whole set. Profiles that share hundreds of packages used
90
+ // to be fetched into a tree each, which fought over npm's cache and downloaded
91
+ // the shared half once per profile (ticket 087).
92
+ const result = await (0, ensure_1.ensureTooling)(wanted, {
93
+ cwd,
94
+ version: (0, package_version_1.packageVersion)(),
95
+ install,
96
+ });
97
+ return [result];
78
98
  }
79
99
  /**
80
100
  * Explain what this repository needs, what it has, and what is missing — and why.
@@ -108,8 +128,13 @@ async function runDoctor(cwd = process.cwd()) {
108
128
  lines.push('');
109
129
  lines.push(' Profiles');
110
130
  for (const decision of decisions) {
111
- const state = decision.required ? (present.has(decision.profile) ? '✅' : '⬇️ ') : '—';
112
- lines.push(` ${state} ${decision.profile.padEnd(12)} ${decision.reason}`);
131
+ const source = present.get(decision.profile);
132
+ const state = decision.required ? (source ? '✅' : '⬇️ ') : '—';
133
+ // Where a present profile came from, because "it works" and "it works
134
+ // because it was downloaded onto this runner" are different situations for
135
+ // anyone debugging a cache or an air-gapped build.
136
+ const how = decision.required && source ? ` (${source})` : '';
137
+ lines.push(` ${state} ${decision.profile.padEnd(12)} ${decision.reason}${how}`);
113
138
  }
114
139
  const missing = decisions.filter(d => d.required && !present.has(d.profile));
115
140
  if (missing.length > 0) {
@@ -1,7 +1,7 @@
1
1
  import type { InstallConfig } from '../../interfaces';
2
2
  import type { ProfileName } from './profiles';
3
3
  /**
4
- * What happened to one profile, in the words the report and `doctor` use.
4
+ * What happened to the tooling cache, in the words the report and `doctor` use.
5
5
  *
6
6
  * `skipped` and `failed` are deliberately distinct. Skipped means we chose not
7
7
  * to fetch — offline, `strategy: "manual"`, an explicit exclusion — and the
@@ -9,12 +9,13 @@ import type { ProfileName } from './profiles';
9
9
  * case that has to reach the report: ticket 057 is the standing reminder that a
10
10
  * check quietly vanishing is worse than a check failing loudly.
11
11
  */
12
- export type EnsureStatus = 'cached' | 'installed' | 'skipped' | 'failed';
12
+ type EnsureStatus = 'cached' | 'installed' | 'skipped' | 'failed';
13
13
  export interface EnsureResult {
14
- profile: ProfileName;
15
14
  status: EnsureStatus;
16
15
  reason: string;
17
16
  dir?: string;
17
+ /** Profiles the directory holds once this call is done. */
18
+ profiles: ProfileName[];
18
19
  durationMs?: number;
19
20
  }
20
21
  /** Whether the fetch succeeded, and what to quote in the report when it did not. */
@@ -22,7 +23,7 @@ export interface InstallOutcome {
22
23
  ok: boolean;
23
24
  detail?: string;
24
25
  }
25
- /** Options `ensureProfile` needs that do not come from the config file. */
26
+ /** Options `ensureTooling` needs that do not come from the config file. */
26
27
  export interface EnsureOptions {
27
28
  /** Repository under review — the cwd npm runs in, so the client's `.npmrc` applies. */
28
29
  cwd: string;
@@ -42,19 +43,35 @@ export interface EnsureOptions {
42
43
  /** Where the shipped manifests live inside the installed package. */
43
44
  export declare function manifestDir(profile: string): string;
44
45
  /**
45
- * Make one profile's tooling available, fetching it only if it is not already there.
46
+ * Which profiles the tooling cache holds, as recorded by the install that filled it.
46
47
  *
47
- * Idempotent by design: this is the single entry point for both the postinstall
48
- * warm-up and the lazy path taken when postinstall never ran (`npm ci
48
+ * The one way to ask, so the stamp's format has a single owner. There is one tree
49
+ * for all fetched tooling now, so what is in it is written down rather than
50
+ * readable from the directory layout.
51
+ */
52
+ export declare function cachedProfiles(cacheDir: string): ProfileName[];
53
+ /**
54
+ * Make a set of profiles' tooling available, fetching only what is not already there.
55
+ *
56
+ * Idempotent by design: this is the single entry point for both the `install`
57
+ * command and the lazy path taken when it was never run (`npm ci
49
58
  * --ignore-scripts`, a `node_modules` restored from a CI cache). Two code paths
50
59
  * for the same job is how the two drift apart and how one of them silently stops
51
60
  * working.
52
61
  *
53
- * The install itself is a plain `npm ci` against a manifest we generated from our
54
- * own lockfile, run from the client's repository so their `.npmrc`, registry and
55
- * proxy settings apply unchanged. `--ignore-scripts` is not negotiable: we are a
56
- * supply-chain tool, and running arbitrary install hooks from a security check
57
- * would be indefensible. Integrity is npm's own, over the whole transitive tree —
58
- * a tampered tarball aborts with `EINTEGRITY` rather than being installed.
62
+ * Everything needed goes into **one** tree. Profiles overlap heavily — `js` and
63
+ * `commitlint` share 30 MB — and a directory each left npm nothing to
64
+ * deduplicate: 267 MB against the 211 one merged tree costs for the same set
65
+ * (ticket 087). The set only ever grows: a run needing a profile the directory
66
+ * lacks reinstalls the union, so nothing already fetched is lost.
67
+ *
68
+ * The install itself is a plain `npm ci` against a manifest merged from the ones
69
+ * we generated from our own lockfile, run from the client's repository so their
70
+ * `.npmrc`, registry and proxy settings apply unchanged. `--ignore-scripts` is
71
+ * not negotiable: we are a supply-chain tool, and running arbitrary install hooks
72
+ * from a security check would be indefensible. Integrity is npm's own, over the
73
+ * whole transitive tree — a tampered tarball aborts with `EINTEGRITY` rather than
74
+ * being installed.
59
75
  */
60
- export declare function ensureProfile(profile: ProfileName, opts: EnsureOptions): Promise<EnsureResult>;
76
+ export declare function ensureTooling(wanted: ProfileName[], opts: EnsureOptions): Promise<EnsureResult>;
77
+ export {};