pr-checkmate 2.1.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +63 -268
  2. package/cspell.json +470 -447
  3. package/dist/cli.js +1 -1
  4. package/dist/config/constants.d.ts +6 -1
  5. package/dist/config/constants.js +9 -7
  6. package/dist/config/index.d.ts +23 -11
  7. package/dist/config/index.js +27 -33
  8. package/dist/core/checks/dependencies/banned-imports.js +4 -1
  9. package/dist/core/checks/dependencies/circular-deps.js +14 -5
  10. package/dist/core/checks/dependencies/dependency-check.js +1 -1
  11. package/dist/core/checks/dependencies/grype-scan.js +2 -1
  12. package/dist/core/checks/dependencies/vuln-scan.js +2 -1
  13. package/dist/core/checks/findings.d.ts +46 -2
  14. package/dist/core/checks/findings.js +140 -2
  15. package/dist/core/checks/git/custom-rules.js +2 -0
  16. package/dist/core/checks/git/diff-smell.js +6 -1
  17. package/dist/core/checks/git/leftover-debug.js +8 -2
  18. package/dist/core/checks/git/lockfile-drift.js +6 -1
  19. package/dist/core/checks/git/merge-conflict.js +1 -1
  20. package/dist/core/checks/git/missing-tests.js +1 -1
  21. package/dist/core/checks/git/todo-fixme.js +10 -2
  22. package/dist/core/checks/languages/cpp-format.js +19 -42
  23. package/dist/core/checks/languages/external-checks.d.ts +77 -0
  24. package/dist/core/checks/languages/external-checks.js +200 -0
  25. package/dist/core/checks/languages/external-runner.d.ts +119 -0
  26. package/dist/core/checks/languages/external-runner.js +143 -0
  27. package/dist/core/checks/languages/go-format.d.ts +4 -4
  28. package/dist/core/checks/languages/go-format.js +151 -30
  29. package/dist/core/checks/languages/lint.js +19 -19
  30. package/dist/core/checks/languages/prettier.js +2 -7
  31. package/dist/core/checks/languages/python-typecheck.d.ts +10 -5
  32. package/dist/core/checks/languages/python-typecheck.js +161 -37
  33. package/dist/core/checks/languages/ruff-shared.d.ts +3 -3
  34. package/dist/core/checks/languages/ruff-shared.js +27 -4
  35. package/dist/core/checks/languages/typecheck.js +10 -8
  36. package/dist/core/checks/pr/commitlint.js +7 -1
  37. package/dist/core/checks/quality/case-collision.js +6 -4
  38. package/dist/core/checks/quality/config-validation.d.ts +0 -7
  39. package/dist/core/checks/quality/config-validation.js +30 -0
  40. package/dist/core/checks/quality/coverage.js +6 -1
  41. package/dist/core/checks/quality/dead-code.js +12 -3
  42. package/dist/core/checks/quality/duplicate-check.js +54 -27
  43. package/dist/core/checks/quality/license-header.js +7 -4
  44. package/dist/core/checks/quality/markdown-lint.js +1 -6
  45. package/dist/core/checks/quality/package-size.d.ts +15 -0
  46. package/dist/core/checks/quality/package-size.js +130 -0
  47. package/dist/core/checks/quality/spellcheck.js +34 -5
  48. package/dist/core/checks/quality/symlinks.js +6 -4
  49. package/dist/core/checks/security/diff-security.js +26 -18
  50. package/dist/core/checks/security/dockerfile-security.js +7 -1
  51. package/dist/core/checks/security/migration-safety.js +32 -4
  52. package/dist/core/checks/security/security.d.ts +56 -0
  53. package/dist/core/checks/security/security.js +135 -11
  54. package/dist/core/checks/security/sensitive-file-guard.js +40 -6
  55. package/dist/core/checks/security/workflow-security.js +7 -1
  56. package/dist/core/checks/tool-install.d.ts +102 -0
  57. package/dist/core/checks/tool-install.js +69 -0
  58. package/dist/core/commands.js +1 -0
  59. package/dist/core/context.js +9 -1
  60. package/dist/core/install/cache.d.ts +20 -1
  61. package/dist/core/install/cache.js +65 -12
  62. package/dist/core/install/doctor.js +49 -15
  63. package/dist/core/install/ensure.d.ts +31 -14
  64. package/dist/core/install/ensure.js +165 -61
  65. package/dist/core/install/lock.d.ts +2 -1
  66. package/dist/core/install/merge.d.ts +47 -0
  67. package/dist/core/install/merge.js +91 -0
  68. package/dist/core/install/prepare.d.ts +76 -0
  69. package/dist/core/install/prepare.js +147 -0
  70. package/dist/core/install/profiles.d.ts +17 -3
  71. package/dist/core/install/profiles.js +27 -0
  72. package/dist/core/install/reuse.d.ts +41 -0
  73. package/dist/core/install/reuse.js +107 -0
  74. package/dist/core/registry.js +12 -18
  75. package/dist/core/run-checks.js +74 -3
  76. package/dist/core/targets.d.ts +116 -5
  77. package/dist/core/targets.js +101 -16
  78. package/dist/core/types.d.ts +8 -2
  79. package/dist/init.js +21 -1
  80. package/dist/interfaces/index.d.ts +6 -0
  81. package/dist/utils/resolve-bin.d.ts +56 -6
  82. package/dist/utils/resolve-bin.js +136 -26
  83. package/gitleaks.toml +14 -0
  84. package/install-manifests/commitlint/package-lock.json +128 -250
  85. package/install-manifests/commitlint/package.json +2 -2
  86. package/install-manifests/core/package-lock.json +76 -76
  87. package/install-manifests/core/package.json +1 -1
  88. package/install-manifests/duplicate/package-lock.json +45 -45
  89. package/install-manifests/duplicate/package.json +1 -1
  90. package/install-manifests/go/package-lock.json +24 -0
  91. package/install-manifests/go/package.json +8 -0
  92. package/install-manifests/js/package-lock.json +248 -283
  93. package/install-manifests/js/package.json +9 -9
  94. package/install-manifests/python/package-lock.json +4 -4
  95. package/install-manifests/python/package.json +1 -1
  96. package/install-manifests/pytypes/package-lock.json +45 -0
  97. package/install-manifests/pytypes/package.json +8 -0
  98. package/package.json +21 -19
  99. package/pr-checkmate.schema.json +41 -13
  100. package/src/config/cspell.json +470 -447
  101. package/src/config/pr-checkmate.schema.json +41 -13
  102. package/dist/core/checks/languages/csharp-format.d.ts +0 -13
  103. package/dist/core/checks/languages/csharp-format.js +0 -52
  104. package/dist/core/checks/languages/go-lint.d.ts +0 -8
  105. package/dist/core/checks/languages/go-lint.js +0 -48
  106. package/dist/core/checks/languages/kotlin-lint.d.ts +0 -2
  107. package/dist/core/checks/languages/kotlin-lint.js +0 -42
  108. package/dist/core/checks/languages/php-format.d.ts +0 -9
  109. package/dist/core/checks/languages/php-format.js +0 -49
  110. package/dist/core/checks/languages/ruby-lint.d.ts +0 -8
  111. package/dist/core/checks/languages/ruby-lint.js +0 -50
  112. package/dist/core/checks/languages/rust-format.d.ts +0 -9
  113. package/dist/core/checks/languages/rust-format.js +0 -45
  114. package/dist/core/checks/languages/rust-lint.d.ts +0 -8
  115. package/dist/core/checks/languages/rust-lint.js +0 -50
  116. package/dist/core/checks/languages/shellcheck.d.ts +0 -8
  117. package/dist/core/checks/languages/shellcheck.js +0 -55
  118. package/dist/core/checks/languages/swift-lint.d.ts +0 -2
  119. package/dist/core/checks/languages/swift-lint.js +0 -46
@@ -4,7 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.manifestDir = manifestDir;
7
- exports.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,117 +13,213 @@ const execa_1 = require("../../utils/execa");
12
13
  const logger_1 = require("../../utils/logger");
13
14
  const cache_1 = require("./cache");
14
15
  const lock_1 = require("./lock");
16
+ const merge_1 = require("./merge");
15
17
  /** Where the shipped manifests live inside the installed package. */
16
18
  function manifestDir(profile) {
17
19
  return node_path_1.default.resolve(__dirname, '../../..', 'install-manifests', profile);
18
20
  }
19
21
  const stampPath = (dir) => node_path_1.default.join(dir, '.pr-checkmate-installed');
20
- /** 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
- if (install.strategy === 'manual') {
78
- return { profile, status: 'skipped', reason: 'install.strategy is "manual"' };
79
- }
80
- const source = manifestDir(profile);
81
- const expected = manifestHash(source);
82
- if (!expected) {
83
- return { profile, status: 'failed', reason: `no shipped manifest for profile "${profile}"` };
127
+ const needed = normalise(wanted);
128
+ if (needed.length === 0) {
129
+ return { status: 'cached', reason: 'nothing to fetch', profiles: [] };
84
130
  }
85
131
  const location = (0, cache_1.resolveCacheDir)(opts.version, install.cacheDir);
86
132
  if (!location) {
87
133
  return {
88
- profile,
89
134
  status: 'failed',
90
135
  reason: 'no writable cache directory (tried the configured path, ~/.cache and the temp dir)',
136
+ profiles: [],
91
137
  };
92
138
  }
93
- const dir = (0, cache_1.profileDir)(location.dir, profile);
94
- if (isInstalled(dir, expected)) {
95
- 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
+ };
162
+ }
163
+ // Both of these mean "do not fetch", and both are asked AFTER the cache check
164
+ // above — because neither means "do not use what has already been fetched".
165
+ //
166
+ // `offline` was always in the right place; `manual` was not, and returned before
167
+ // the cache was ever looked at. So `install --all`, the command the product's
168
+ // own log recommends for pre-warming, did nothing for anyone who then set
169
+ // `manual` — including our Docker image, which sets it precisely because
170
+ // everything is supposed to already be there. `Python Types` in that image
171
+ // could not run at all (ticket 083).
172
+ // The env var as well as the config key, exactly as `PR_CHECKMATE_OFFLINE` is
173
+ // read below: the Docker image sets the strategy through the environment, and a
174
+ // guarantee that only holds when it is written in a config file is not a
175
+ // guarantee.
176
+ const strategy = process.env.PR_CHECKMATE_INSTALL_STRATEGY ?? install.strategy;
177
+ if (strategy === 'manual') {
178
+ return {
179
+ status: 'skipped',
180
+ reason: 'install.strategy is "manual" and the tooling is not in the cache',
181
+ dir,
182
+ profiles: stamp?.profiles ?? [],
183
+ };
96
184
  }
97
185
  if (install.offline || process.env.PR_CHECKMATE_OFFLINE === 'true') {
98
186
  return {
99
- profile,
100
187
  status: 'skipped',
101
- reason: 'install.offline is set and the profile is not in the cache',
188
+ reason: 'install.offline is set and the tooling is not in the cache',
102
189
  dir,
190
+ profiles: stamp?.profiles ?? [],
103
191
  };
104
192
  }
105
- // The lock file lives in the profile directory, so the directory has to exist
193
+ // The lock file lives in the tooling directory, so the directory has to exist
106
194
  // before there is anything to lock. Creating it inside `withLock` would put
107
195
  // the creation itself outside the lock, which is the race it exists to close.
108
196
  try {
109
197
  node_fs_1.default.mkdirSync(dir, { recursive: true });
110
198
  }
111
199
  catch (err) {
112
- return { profile, status: 'failed', reason: `cannot create ${dir}: ${err.message}` };
200
+ return {
201
+ status: 'failed',
202
+ reason: `cannot create ${dir}: ${err.message}`,
203
+ profiles: [],
204
+ };
113
205
  }
114
206
  const release = await (0, lock_1.withLock)(dir, install.waitTimeoutMs ?? 120000);
115
207
  if (!release) {
116
- return { profile, status: 'failed', reason: `cannot write to ${dir}`, dir };
208
+ return { status: 'failed', reason: `cannot write to ${dir}`, dir, profiles: [] };
117
209
  }
118
210
  try {
119
211
  // Re-checked inside the lock: while we waited, the process we were waiting
120
212
  // for probably did the work. Without this, waiting is pointless — every
121
213
  // queued job would reinstall in turn.
122
- if (isInstalled(dir, expected)) {
123
- 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
+ };
124
221
  }
125
- return await runInstall(profile, source, dir, expected, opts);
222
+ return await runInstall(installing, manifest, dir, opts);
126
223
  }
127
224
  finally {
128
225
  release();
@@ -143,27 +240,34 @@ async function npmCi(dir, opts) {
143
240
  return { ok: false, detail: detail || `exit ${result.exitCode}` };
144
241
  }
145
242
  /** The fetch itself, once we hold the lock and know it is needed. */
146
- async function runInstall(profile, source, dir, expected, opts) {
243
+ async function runInstall(profiles, manifest, dir, opts) {
147
244
  const started = Date.now();
148
- stageManifest(source, dir);
149
- 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;
150
248
  // Always logged, never behind --debug: this is a security tool downloading
151
249
  // code at run time, and that must be visible in every CI log by default.
152
- logger_1.logger.info(`[install]: 📦 fetching profile "${profile}" (${count} packages) → ${dir}`);
250
+ logger_1.logger.info(`[install]: 📦 fetching tooling for ${named} (${count} packages) → ${dir}`);
153
251
  try {
154
252
  const install = opts.installer ?? ((target) => npmCi(target, opts));
155
253
  const result = await install(dir);
156
254
  if (!result.ok) {
157
- return { profile, status: 'failed', reason: `npm ci failed: ${result.detail}`, dir };
255
+ return { status: 'failed', reason: `npm ci failed: ${result.detail}`, dir, profiles: [] };
158
256
  }
159
- 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));
160
259
  const durationMs = Date.now() - started;
161
- logger_1.logger.info(`[install]: ✅ profile "${profile}" ready in ${(durationMs / 1000).toFixed(1)}s`);
162
- 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 };
163
262
  }
164
263
  catch (err) {
165
264
  // A timeout arrives here as a thrown error. It is a skip, not a crash: the
166
265
  // rest of the run is still worth doing, and the affected checks say why.
167
- return { 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
+ };
168
272
  }
169
273
  }
@@ -25,9 +25,10 @@ export declare function processAlive(pid: number): boolean;
25
25
  * @returns A release function, or `null` when the lock could not be created at
26
26
  * all (an unwritable directory — the caller reports that, it is not fatal here).
27
27
  */
28
- export interface LockDeps {
28
+ interface LockDeps {
29
29
  sleep?: (ms: number) => Promise<void>;
30
30
  now?: () => number;
31
31
  alive?: (pid: number) => boolean;
32
32
  }
33
33
  export declare function withLock(dir: string, timeoutMs: number, deps?: LockDeps): Promise<(() => void) | null>;
34
+ export {};
@@ -0,0 +1,47 @@
1
+ /** The `package.json` half of a shipped profile manifest. */
2
+ interface ProfilePkg {
3
+ dependencies?: Record<string, string>;
4
+ overrides?: Record<string, unknown>;
5
+ }
6
+ /** The `package-lock.json` half. Entries are opaque to us beyond their identity. */
7
+ interface ProfileLock {
8
+ packages: Record<string, unknown>;
9
+ }
10
+ /** A manifest pair `npm ci` can be pointed at. */
11
+ export interface MergedManifest {
12
+ pkg: object;
13
+ lockfile: object;
14
+ }
15
+ /**
16
+ * Merge several profile manifests into the one a single `npm ci` installs.
17
+ *
18
+ * This is what stops the tooling cache from paying for the same package twice.
19
+ * Installing each profile into its own directory leaves npm nothing to
20
+ * deduplicate: `commitlint` and `js` share 30 MB, and separate trees download
21
+ * both copies. One tree costs the union instead of the sum — measured at 213 MB
22
+ * against 234 for a JavaScript repository, where per-profile directories came to
23
+ * 267 (ticket 087).
24
+ *
25
+ * The merge is a union, not a resolution, and it never reaches the network. Every
26
+ * shipped manifest was cut out of our own `package-lock.json` at build time, so
27
+ * two profiles that both contain a package contain the *same* entry for it,
28
+ * byte for byte. That is the whole reason this is safe to do at run time on a
29
+ * client's machine.
30
+ *
31
+ * Which makes a disagreement impossible unless we broke the build — so it throws
32
+ * rather than picking a winner. Choosing silently would hand a client a tree
33
+ * neither profile was tested against, and the integrity hashes that make this
34
+ * design defensible would be describing something we never ran.
35
+ *
36
+ * @param profiles Directories holding the shipped manifests, in any order.
37
+ * @param read Injected by tests; defaults to reading the two files.
38
+ * @throws If two profiles disagree about a package, or a manifest cannot be read.
39
+ */
40
+ export declare function mergeManifests(profiles: Array<{
41
+ name: string;
42
+ dir: string;
43
+ }>, read?: (dir: string) => {
44
+ pkg: ProfilePkg;
45
+ lock: ProfileLock;
46
+ }): MergedManifest;
47
+ export {};
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.mergeManifests = mergeManifests;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const MERGED_NAME = 'pr-checkmate-tooling';
10
+ const MERGED_VERSION = '1.0.0';
11
+ /** Read a profile's shipped manifest pair from the installed package. */
12
+ function readShippedManifest(dir) {
13
+ return {
14
+ pkg: JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, 'package.json'), 'utf-8')),
15
+ lock: JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, 'package-lock.json'), 'utf-8')),
16
+ };
17
+ }
18
+ /**
19
+ * Merge several profile manifests into the one a single `npm ci` installs.
20
+ *
21
+ * This is what stops the tooling cache from paying for the same package twice.
22
+ * Installing each profile into its own directory leaves npm nothing to
23
+ * deduplicate: `commitlint` and `js` share 30 MB, and separate trees download
24
+ * both copies. One tree costs the union instead of the sum — measured at 213 MB
25
+ * against 234 for a JavaScript repository, where per-profile directories came to
26
+ * 267 (ticket 087).
27
+ *
28
+ * The merge is a union, not a resolution, and it never reaches the network. Every
29
+ * shipped manifest was cut out of our own `package-lock.json` at build time, so
30
+ * two profiles that both contain a package contain the *same* entry for it,
31
+ * byte for byte. That is the whole reason this is safe to do at run time on a
32
+ * client's machine.
33
+ *
34
+ * Which makes a disagreement impossible unless we broke the build — so it throws
35
+ * rather than picking a winner. Choosing silently would hand a client a tree
36
+ * neither profile was tested against, and the integrity hashes that make this
37
+ * design defensible would be describing something we never ran.
38
+ *
39
+ * @param profiles Directories holding the shipped manifests, in any order.
40
+ * @param read Injected by tests; defaults to reading the two files.
41
+ * @throws If two profiles disagree about a package, or a manifest cannot be read.
42
+ */
43
+ function mergeManifests(profiles, read = readShippedManifest) {
44
+ const dependencies = {};
45
+ const overrides = {};
46
+ const packages = {};
47
+ // Which profile first claimed a given key, so a conflict names both sides.
48
+ const claimedBy = new Map();
49
+ const claim = (kind, key, value, by, into) => {
50
+ const id = `${kind}:${key}`;
51
+ const seen = claimedBy.get(id);
52
+ if (seen === undefined) {
53
+ claimedBy.set(id, by);
54
+ into[key] = value;
55
+ return;
56
+ }
57
+ if (JSON.stringify(into[key]) !== JSON.stringify(value)) {
58
+ throw new Error(`profiles "${seen}" and "${by}" disagree about ${kind} "${key}" — ` +
59
+ 'the shipped manifests are out of sync with our lockfile');
60
+ }
61
+ };
62
+ for (const { name, dir } of profiles) {
63
+ const { pkg, lock } = read(dir);
64
+ for (const [dep, version] of Object.entries(pkg.dependencies ?? {})) {
65
+ claim('dependency', dep, version, name, dependencies);
66
+ }
67
+ for (const [target, rule] of Object.entries(pkg.overrides ?? {})) {
68
+ claim('override', target, rule, name, overrides);
69
+ }
70
+ for (const [entryPath, entry] of Object.entries(lock.packages ?? {})) {
71
+ // The root entry describes the profile itself — its name, its direct
72
+ // dependencies. It is replaced by the merged root below, not merged: two
73
+ // profiles always "disagree" about it, and rightly so.
74
+ if (entryPath === '')
75
+ continue;
76
+ claim('package', entryPath, entry, name, packages);
77
+ }
78
+ }
79
+ const scoped = Object.keys(overrides).length > 0 ? { overrides } : {};
80
+ const root = { name: MERGED_NAME, version: MERGED_VERSION, dependencies, ...scoped };
81
+ return {
82
+ pkg: { ...root, private: true },
83
+ lockfile: {
84
+ name: MERGED_NAME,
85
+ version: MERGED_VERSION,
86
+ lockfileVersion: 3,
87
+ requires: true,
88
+ packages: { '': root, ...packages },
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,76 @@
1
+ import type { PRCheckMateConfig } from '../../interfaces';
2
+ import type { LanguageUsage } from '../../utils/detect-language';
3
+ import { type EnsureOptions } from './ensure';
4
+ import { type Profile } from './profiles';
5
+ /**
6
+ * Whether a profile needs fetching at all, or already ships inside the package.
7
+ *
8
+ * This is the hinge the whole adaptive-install rollout turns on. Nearly every
9
+ * profile's packages sit in our `dependencies`, so it answers `true`, nothing is
10
+ * fetched, and a run is byte-for-byte the run it was before any of this existed.
11
+ * `pytypes` is the exception that already proves the other half works: `pyright`
12
+ * is a devDependency, so it reaches no client and is fetched on demand. When a
13
+ * package is moved out of `dependencies` it stops being present, its profile
14
+ * starts answering `false`, and the fetch turns itself on — no flag, no second
15
+ * code path, nothing to remember to flip.
16
+ *
17
+ * Which is what makes releases 2 and 3 of ticket 064 an edit to `package.json`
18
+ * rather than another change to this logic.
19
+ */
20
+ export declare function profileSatisfiedLocally(profile: Profile): boolean;
21
+ /** What the run knows about tooling before the first check starts. */
22
+ export interface ToolingPlan {
23
+ /** Check name → why its tooling is not available. Empty on a healthy run. */
24
+ unavailable: Map<string, string>;
25
+ /** Profile directories tool resolution should search. */
26
+ dirs: string[];
27
+ /**
28
+ * True when tooling is missing and `install.onMissingTool` is `"error"`. The
29
+ * affected checks fail instead of skipping: in a closed contour a check that
30
+ * quietly stops running is worse than a red build.
31
+ */
32
+ fatal: boolean;
33
+ }
34
+ interface PrepareOptions {
35
+ cwd: string;
36
+ config: PRCheckMateConfig;
37
+ /** Detected languages with file counts — reused so the tree is scanned once. */
38
+ usage: LanguageUsage[];
39
+ /** Names of the checks this run intends to execute. */
40
+ selected: string[];
41
+ /** pr-checkmate's own version; part of the cache path. */
42
+ version: string;
43
+ /**
44
+ * The profile catalogue. Defaults to the shipped one; injected by tests.
45
+ *
46
+ * A seam rather than a mock of the module, for the same reason `ensure.ts` has
47
+ * one: nearly every profile ships inside the package, so the interesting half
48
+ * of this function — a profile that is genuinely absent — is otherwise reachable
49
+ * only through a network install in a unit test. The code that runs when a
50
+ * package leaves `dependencies` must be tested before that day, not on it.
51
+ */
52
+ profiles?: Profile[];
53
+ /** The fetch itself, forwarded to `ensureTooling`. Injected by tests. */
54
+ installer?: EnsureOptions['installer'];
55
+ }
56
+ /**
57
+ * Make sure the tooling this run needs is present, and say what is missing.
58
+ *
59
+ * Runs once, before the first check, and answers two questions the run cannot
60
+ * proceed honestly without: which directories tool resolution should search, and
61
+ * which checks have no tool to run. The second is the one ticket 057 is about —
62
+ * a check whose tooling never arrived must appear in the report saying so, not
63
+ * vanish from it.
64
+ *
65
+ * Only profiles backing checks that are actually selected are considered, so
66
+ * `severity: { "Spellcheck": "off" }` stops costing a download rather than only a
67
+ * row in the report (lever B of ticket 064).
68
+ */
69
+ export declare function prepareTooling(opts: PrepareOptions): Promise<ToolingPlan>;
70
+ /**
71
+ * The plan for a run where preparation did not happen — nothing missing, nothing
72
+ * registered. Built fresh each time rather than shared: a caller holding a plan
73
+ * must not be able to see another run's.
74
+ */
75
+ export declare function noTooling(): ToolingPlan;
76
+ export {};