pr-checkmate 1.23.24 → 2.1.0

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 (105) hide show
  1. package/README.md +89 -13
  2. package/cspell.json +11 -9
  3. package/dist/cli.js +37 -64
  4. package/dist/config/constants.d.ts +51 -3
  5. package/dist/config/constants.js +73 -11
  6. package/dist/config/pr-checkmate-workflow-docker.yml +79 -0
  7. package/dist/config/pr-checkmate-workflow.yml +65 -326
  8. package/dist/core/checks/dependencies/banned-imports.js +23 -24
  9. package/dist/core/checks/dependencies/circular-deps.js +1 -0
  10. package/dist/core/checks/dependencies/dependency-check.js +1 -0
  11. package/dist/core/checks/dependencies/grype-scan.js +1 -0
  12. package/dist/core/checks/dependencies/npm-audit.js +6 -1
  13. package/dist/core/checks/dependencies/vuln-scan.js +1 -0
  14. package/dist/core/checks/findings.d.ts +198 -19
  15. package/dist/core/checks/findings.js +474 -22
  16. package/dist/core/checks/git/custom-rules.js +21 -16
  17. package/dist/core/checks/git/diff-smell.d.ts +17 -0
  18. package/dist/core/checks/git/diff-smell.js +43 -24
  19. package/dist/core/checks/git/large-files.js +14 -2
  20. package/dist/core/checks/git/leftover-debug.js +23 -14
  21. package/dist/core/checks/git/lockfile-drift.js +2 -1
  22. package/dist/core/checks/git/merge-conflict.js +19 -8
  23. package/dist/core/checks/git/missing-tests.js +22 -6
  24. package/dist/core/checks/git/todo-fixme.js +10 -9
  25. package/dist/core/checks/languages/shellcheck.d.ts +1 -1
  26. package/dist/core/checks/languages/shellcheck.js +1 -1
  27. package/dist/core/checks/languages/typecheck.js +1 -0
  28. package/dist/core/checks/pr/pr-size-check.js +2 -1
  29. package/dist/core/checks/quality/config-validation.d.ts +14 -4
  30. package/dist/core/checks/quality/config-validation.js +119 -17
  31. package/dist/core/checks/quality/dead-code.js +1 -0
  32. package/dist/core/checks/quality/duplicate-check.js +1 -0
  33. package/dist/core/checks/quality/license-header.js +12 -2
  34. package/dist/core/checks/quality/markdown-lint.js +7 -2
  35. package/dist/core/checks/quality/spellcheck.d.ts +50 -0
  36. package/dist/core/checks/quality/spellcheck.js +141 -13
  37. package/dist/core/checks/quality/yaml-lint.js +22 -9
  38. package/dist/core/checks/security/diff-security.js +115 -28
  39. package/dist/core/checks/security/dockerfile-security.js +23 -20
  40. package/dist/core/checks/security/migration-safety.js +19 -17
  41. package/dist/core/checks/security/sensitive-file-guard.js +37 -5
  42. package/dist/core/checks/security/workflow-security.d.ts +10 -0
  43. package/dist/core/checks/security/workflow-security.js +48 -26
  44. package/dist/core/commands.d.ts +27 -0
  45. package/dist/core/commands.js +91 -0
  46. package/dist/core/context.d.ts +12 -3
  47. package/dist/core/context.js +118 -16
  48. package/dist/core/install/cache.d.ts +39 -0
  49. package/dist/core/install/cache.js +99 -0
  50. package/dist/core/install/doctor.d.ts +35 -0
  51. package/dist/core/install/doctor.js +145 -0
  52. package/dist/core/install/ensure.d.ts +60 -0
  53. package/dist/core/install/ensure.js +169 -0
  54. package/dist/core/install/lock.d.ts +33 -0
  55. package/dist/core/install/lock.js +99 -0
  56. package/dist/core/install/profiles.d.ts +64 -0
  57. package/dist/core/install/profiles.js +170 -0
  58. package/dist/core/reporters.d.ts +13 -2
  59. package/dist/core/reporters.js +145 -104
  60. package/dist/core/run-checks.d.ts +25 -1
  61. package/dist/core/run-checks.js +124 -14
  62. package/dist/core/targets.js +26 -4
  63. package/dist/core/types.d.ts +49 -1
  64. package/dist/init.d.ts +29 -14
  65. package/dist/init.js +284 -263
  66. package/dist/interfaces/index.d.ts +120 -0
  67. package/dist/scripts/copy-configs.js +4 -0
  68. package/dist/scripts/generate-install-manifests.d.ts +39 -0
  69. package/dist/scripts/generate-install-manifests.js +121 -0
  70. package/dist/scripts/publish-github.js +18 -4
  71. package/dist/utils/detect-language.d.ts +37 -4
  72. package/dist/utils/detect-language.js +113 -19
  73. package/dist/utils/http.d.ts +59 -0
  74. package/dist/utils/http.js +119 -0
  75. package/dist/utils/logger.d.ts +52 -6
  76. package/dist/utils/logger.js +125 -33
  77. package/dist/utils/pool.d.ts +49 -0
  78. package/dist/utils/pool.js +124 -0
  79. package/dist/utils/update-notifier.d.ts +9 -0
  80. package/dist/utils/update-notifier.js +57 -3
  81. package/eslint-default-rules.json +1 -1
  82. package/eslint.config.mjs +14 -2
  83. package/install-manifests/commitlint/package-lock.json +1028 -0
  84. package/install-manifests/commitlint/package.json +9 -0
  85. package/install-manifests/core/package-lock.json +1335 -0
  86. package/install-manifests/core/package.json +11 -0
  87. package/install-manifests/cpp/package-lock.json +29 -0
  88. package/install-manifests/cpp/package.json +8 -0
  89. package/install-manifests/duplicate/package-lock.json +113 -0
  90. package/install-manifests/duplicate/package.json +8 -0
  91. package/install-manifests/js/package-lock.json +5099 -0
  92. package/install-manifests/js/package.json +26 -0
  93. package/install-manifests/markdown/package-lock.json +1281 -0
  94. package/install-manifests/markdown/package.json +8 -0
  95. package/install-manifests/python/package-lock.json +21 -0
  96. package/install-manifests/python/package.json +8 -0
  97. package/install-manifests/spellcheck/package-lock.json +1047 -0
  98. package/install-manifests/spellcheck/package.json +8 -0
  99. package/package.json +14 -28
  100. package/pr-checkmate.schema.json +1062 -104
  101. package/src/config/cspell.json +11 -9
  102. package/src/config/eslint-default-rules.json +1 -1
  103. package/src/config/pr-checkmate-workflow-docker.yml +79 -0
  104. package/src/config/pr-checkmate-workflow.yml +65 -326
  105. package/src/config/pr-checkmate.schema.json +1062 -104
@@ -0,0 +1,99 @@
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.expandHome = expandHome;
7
+ exports.resolveCacheDir = resolveCacheDir;
8
+ exports.profileDir = profileDir;
9
+ exports.stalePaths = stalePaths;
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ const node_os_1 = __importDefault(require("node:os"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const logger_1 = require("../../utils/logger");
14
+ /**
15
+ * Expand a leading `~`.
16
+ *
17
+ * Config files are written by hand, `~/.cache/pr-checkmate` is what a person
18
+ * writes, and nothing in Node expands it — the directory would be created
19
+ * literally, as a folder named `~`, in whatever the working directory happened
20
+ * to be.
21
+ */
22
+ function expandHome(dir, home = node_os_1.default.homedir()) {
23
+ if (dir === '~')
24
+ return home;
25
+ return dir.startsWith('~/') ? node_path_1.default.join(home, dir.slice(2)) : dir;
26
+ }
27
+ /** A directory we can actually write to, or null. */
28
+ function usable(dir) {
29
+ try {
30
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
31
+ // Existence is not permission: a cache directory baked into a container
32
+ // image is often present and read-only, and finding that out at install
33
+ // time rather than at write time is the difference between a clear message
34
+ // and a stack trace halfway through fetching.
35
+ node_fs_1.default.accessSync(dir, node_fs_1.default.constants.W_OK);
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /**
43
+ * Resolve the cache directory, falling back rather than failing.
44
+ *
45
+ * Every candidate below is a real environment: containers without `HOME`, CI
46
+ * images with a read-only home, and runners where only the temp directory is
47
+ * writable. A tool that refuses to work in any of them is a tool that fails on
48
+ * CI for reasons the client cannot see.
49
+ *
50
+ * @param version The pr-checkmate version, kept in the path so two projects on
51
+ * different versions never share fetched tooling.
52
+ * @param configured `install.cacheDir`, if the client set one.
53
+ */
54
+ function resolveCacheDir(version, configured, env = process.env) {
55
+ const explicit = env.PR_CHECKMATE_CACHE_DIR ?? configured;
56
+ const candidates = [];
57
+ if (explicit)
58
+ candidates.push({ dir: expandHome(explicit), reason: 'configured' });
59
+ // `homedir()` on a container without HOME returns something like `/` — hence
60
+ // the write check rather than a bare existence test.
61
+ candidates.push({ dir: node_path_1.default.join(node_os_1.default.homedir(), '.cache', 'pr-checkmate'), reason: 'home cache' });
62
+ candidates.push({ dir: node_path_1.default.join(node_os_1.default.tmpdir(), 'pr-checkmate-cache'), reason: 'temp directory' });
63
+ for (const [index, candidate] of candidates.entries()) {
64
+ const dir = node_path_1.default.join(candidate.dir, version);
65
+ if (!usable(dir))
66
+ continue;
67
+ if (index > 0) {
68
+ logger_1.logger.warn(`[install]: ⚠️ cache directory "${candidates[0].dir}" is not writable — using ${candidate.reason} (${dir}). Fetched tooling will not survive a container rebuild.`);
69
+ }
70
+ return { dir, preferred: index === 0, reason: candidate.reason };
71
+ }
72
+ return null;
73
+ }
74
+ /** Where a single profile's tooling is installed. */
75
+ function profileDir(cacheDir, profile) {
76
+ return node_path_1.default.join(cacheDir, profile);
77
+ }
78
+ /**
79
+ * Profile directories left behind by other versions of pr-checkmate.
80
+ *
81
+ * Nothing removes these automatically: an upgrade must not delete tooling a
82
+ * concurrently running older version is in the middle of using. `cache prune`
83
+ * is the deliberate, separate act.
84
+ */
85
+ function stalePaths(version, configured) {
86
+ const location = resolveCacheDir(version, configured);
87
+ if (!location)
88
+ return [];
89
+ const root = node_path_1.default.dirname(location.dir);
90
+ try {
91
+ return node_fs_1.default
92
+ .readdirSync(root, { withFileTypes: true })
93
+ .filter(entry => entry.isDirectory() && entry.name !== version)
94
+ .map(entry => node_path_1.default.join(root, entry.name));
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ }
@@ -0,0 +1,35 @@
1
+ import { type LanguageUsage } from '../../utils/detect-language';
2
+ import { type EnsureResult } from './ensure';
3
+ import { type ProfileDecision } from './profiles';
4
+ /** Our own version, read from the package manifest that ships with us. */
5
+ export declare function packageVersion(): string;
6
+ /**
7
+ * Everything the install layer knows about this repository, in one shot.
8
+ *
9
+ * `usage` is a parameter rather than always detected here so a caller that
10
+ * already has it — `doctor` prints it — does not scan the repository twice and
11
+ * log two contradictory-looking detection lines for one command.
12
+ */
13
+ export declare function planInstall(cwd?: string, usage?: LanguageUsage[]): Promise<{
14
+ decisions: ProfileDecision[];
15
+ cacheDir: string | null;
16
+ }>;
17
+ /**
18
+ * Fetch every profile this repository needs.
19
+ *
20
+ * Shared by the `install` command and by the run itself, so a client who never
21
+ * runs `install` gets the same result lazily. `all` is what a container build or
22
+ * an air-gapped mirror wants: everything, regardless of what this repository
23
+ * happens to contain.
24
+ */
25
+ export declare function installProfiles(cwd?: string, all?: boolean): Promise<EnsureResult[]>;
26
+ /**
27
+ * Explain what this repository needs, what it has, and what is missing — and why.
28
+ *
29
+ * The command exists because adaptive installation makes "why did my typecheck
30
+ * not run" a routine question, and the answer must be a command rather than a
31
+ * reading of our source.
32
+ */
33
+ export declare function runDoctor(cwd?: string): Promise<void>;
34
+ /** Remove cached tooling belonging to other versions of pr-checkmate. */
35
+ export declare function pruneCache(): void;
@@ -0,0 +1,145 @@
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.packageVersion = packageVersion;
7
+ exports.planInstall = planInstall;
8
+ exports.installProfiles = installProfiles;
9
+ exports.runDoctor = runDoctor;
10
+ exports.pruneCache = pruneCache;
11
+ const node_fs_1 = __importDefault(require("node:fs"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const loader_1 = require("../../config/loader");
14
+ const detect_language_1 = require("../../utils/detect-language");
15
+ const logger_1 = require("../../utils/logger");
16
+ const cache_1 = require("./cache");
17
+ const ensure_1 = require("./ensure");
18
+ const profiles_1 = require("./profiles");
19
+ /** Our own version, read from the package manifest that ships with us. */
20
+ function packageVersion() {
21
+ try {
22
+ const manifest = node_path_1.default.resolve(__dirname, '../../..', 'package.json');
23
+ return JSON.parse(node_fs_1.default.readFileSync(manifest, 'utf-8')).version ?? '0.0.0';
24
+ }
25
+ catch {
26
+ return '0.0.0';
27
+ }
28
+ }
29
+ /**
30
+ * Everything the install layer knows about this repository, in one shot.
31
+ *
32
+ * `usage` is a parameter rather than always detected here so a caller that
33
+ * already has it — `doctor` prints it — does not scan the repository twice and
34
+ * log two contradictory-looking detection lines for one command.
35
+ */
36
+ async function planInstall(cwd = process.cwd(), usage) {
37
+ const config = (0, loader_1.loadProjectConfig)();
38
+ const detected = usage ?? (await (0, detect_language_1.detectLanguageUsage)(cwd, config.ignoreDirs, config.detect));
39
+ const location = (0, cache_1.resolveCacheDir)(packageVersion(), config.install?.cacheDir);
40
+ return { decisions: (0, profiles_1.planProfiles)(detected, config), cacheDir: location?.dir ?? null };
41
+ }
42
+ /** Whether a profile's tooling is present in the cache right now. */
43
+ function installedProfiles(cacheDir) {
44
+ if (!cacheDir)
45
+ return new Set();
46
+ 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));
47
+ }
48
+ /**
49
+ * Fetch every profile this repository needs.
50
+ *
51
+ * Shared by the `install` command and by the run itself, so a client who never
52
+ * runs `install` gets the same result lazily. `all` is what a container build or
53
+ * an air-gapped mirror wants: everything, regardless of what this repository
54
+ * happens to contain.
55
+ */
56
+ async function installProfiles(cwd = process.cwd(), all = false) {
57
+ const config = (0, loader_1.loadProjectConfig)();
58
+ const install = config.install ?? {};
59
+ const { decisions } = await planInstall(cwd);
60
+ const denied = new Set(install.deny ?? []);
61
+ const wanted = (all ? profiles_1.PROFILES.map(p => p.name) : decisions.filter(d => d.required).map(d => d.profile)).filter(name => {
62
+ const profile = profiles_1.PROFILES.find(p => p.name === name);
63
+ const blocked = profile?.packages.filter(pkg => denied.has(pkg)) ?? [];
64
+ if (blocked.length === 0)
65
+ return true;
66
+ logger_1.logger.info(`[install]: profile "${name}" skipped — install.deny lists ${blocked.join(', ')}`);
67
+ return false;
68
+ });
69
+ const results = [];
70
+ // Sequential on purpose. Parallel installs of profiles that share hundreds of
71
+ // packages fight over npm's cache and gain nothing; the wall-clock cost lands
72
+ // once, on a cold cache, and clarity of the log matters more there.
73
+ for (const profile of wanted) {
74
+ results.push(await (0, ensure_1.ensureProfile)(profile, { cwd, version: packageVersion(), install }));
75
+ }
76
+ return results;
77
+ }
78
+ /**
79
+ * Explain what this repository needs, what it has, and what is missing — and why.
80
+ *
81
+ * The command exists because adaptive installation makes "why did my typecheck
82
+ * not run" a routine question, and the answer must be a command rather than a
83
+ * reading of our source.
84
+ */
85
+ async function runDoctor(cwd = process.cwd()) {
86
+ const config = (0, loader_1.loadProjectConfig)();
87
+ const usage = await (0, detect_language_1.detectLanguageUsage)(cwd, config.ignoreDirs, config.detect);
88
+ const { decisions, cacheDir } = await planInstall(cwd, usage);
89
+ const present = installedProfiles(cacheDir);
90
+ const lines = [];
91
+ lines.push('PR CheckMate — install doctor');
92
+ lines.push('');
93
+ lines.push(` version ${packageVersion()}`);
94
+ lines.push(` repository ${cwd}`);
95
+ lines.push(` cache ${cacheDir ?? 'unavailable (no writable directory)'}`);
96
+ lines.push(` strategy ${config.install?.strategy ?? 'adaptive'}`);
97
+ lines.push('');
98
+ lines.push(' Languages');
99
+ if (usage.length === 0)
100
+ lines.push(' none detected');
101
+ for (const u of usage) {
102
+ // The distinction that decides downloads: a marker declares a language, a
103
+ // file proves it. Only the second is worth a toolchain.
104
+ const evidence = u.files > 0 ? `${u.files} file(s)` : 'marker file only';
105
+ lines.push(` ${u.lang.padEnd(12)} ${evidence}`);
106
+ }
107
+ lines.push('');
108
+ lines.push(' Profiles');
109
+ for (const decision of decisions) {
110
+ const state = decision.required ? (present.has(decision.profile) ? '✅' : '⬇️ ') : '—';
111
+ lines.push(` ${state} ${decision.profile.padEnd(12)} ${decision.reason}`);
112
+ }
113
+ const missing = decisions.filter(d => d.required && !present.has(d.profile));
114
+ if (missing.length > 0) {
115
+ lines.push('');
116
+ lines.push(` ${missing.length} profile(s) not installed yet. They are fetched on the next`);
117
+ lines.push(' run, or now with: npx pr-checkmate install');
118
+ }
119
+ const stale = (0, cache_1.stalePaths)(packageVersion(), config.install?.cacheDir);
120
+ if (stale.length > 0) {
121
+ lines.push('');
122
+ lines.push(` ${stale.length} directory(ies) from other versions: npx pr-checkmate cache-prune`);
123
+ }
124
+ logger_1.logger.print(lines.join('\n'));
125
+ }
126
+ /** Remove cached tooling belonging to other versions of pr-checkmate. */
127
+ function pruneCache() {
128
+ const config = (0, loader_1.loadProjectConfig)();
129
+ const stale = (0, cache_1.stalePaths)(packageVersion(), config.install?.cacheDir);
130
+ if (stale.length === 0) {
131
+ logger_1.logger.print('Nothing to prune — no tooling from other versions is cached.');
132
+ return;
133
+ }
134
+ for (const dir of stale) {
135
+ try {
136
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
137
+ logger_1.logger.print(`removed ${dir}`);
138
+ }
139
+ catch (err) {
140
+ // Another version may be running right now; refusing to delete its
141
+ // tooling is the correct outcome, not an error worth failing on.
142
+ logger_1.logger.warn(`[cache-prune]: could not remove ${dir}: ${err.message}`);
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,60 @@
1
+ import type { InstallConfig } from '../../interfaces';
2
+ import type { ProfileName } from './profiles';
3
+ /**
4
+ * What happened to one profile, in the words the report and `doctor` use.
5
+ *
6
+ * `skipped` and `failed` are deliberately distinct. Skipped means we chose not
7
+ * to fetch — offline, `strategy: "manual"`, an explicit exclusion — and the
8
+ * client already knows why. Failed means we tried and could not, which is the
9
+ * case that has to reach the report: ticket 057 is the standing reminder that a
10
+ * check quietly vanishing is worse than a check failing loudly.
11
+ */
12
+ export type EnsureStatus = 'cached' | 'installed' | 'skipped' | 'failed';
13
+ export interface EnsureResult {
14
+ profile: ProfileName;
15
+ status: EnsureStatus;
16
+ reason: string;
17
+ dir?: string;
18
+ durationMs?: number;
19
+ }
20
+ /** Whether the fetch succeeded, and what to quote in the report when it did not. */
21
+ export interface InstallOutcome {
22
+ ok: boolean;
23
+ detail?: string;
24
+ }
25
+ /** Options `ensureProfile` needs that do not come from the config file. */
26
+ export interface EnsureOptions {
27
+ /** Repository under review — the cwd npm runs in, so the client's `.npmrc` applies. */
28
+ cwd: string;
29
+ /** pr-checkmate's own version; part of the cache path. */
30
+ version: string;
31
+ install: InstallConfig;
32
+ /**
33
+ * The fetch itself. Defaults to `npm ci`; injected by tests.
34
+ *
35
+ * A seam rather than a mock of the module: everything around the fetch — the
36
+ * lock, the cache stamp, the offline and strategy branches, the reasons that
37
+ * end up in the report — is logic worth pinning, and without this the only way
38
+ * to reach it is to actually download tooling from the network in a unit test.
39
+ */
40
+ installer?: (dir: string) => Promise<InstallOutcome>;
41
+ }
42
+ /** Where the shipped manifests live inside the installed package. */
43
+ export declare function manifestDir(profile: string): string;
44
+ /**
45
+ * Make one profile's tooling available, fetching it only if it is not already there.
46
+ *
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
49
+ * --ignore-scripts`, a `node_modules` restored from a CI cache). Two code paths
50
+ * for the same job is how the two drift apart and how one of them silently stops
51
+ * working.
52
+ *
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.
59
+ */
60
+ export declare function ensureProfile(profile: ProfileName, opts: EnsureOptions): Promise<EnsureResult>;
@@ -0,0 +1,169 @@
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.manifestDir = manifestDir;
7
+ exports.ensureProfile = ensureProfile;
8
+ const node_crypto_1 = __importDefault(require("node:crypto"));
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const execa_1 = require("../../utils/execa");
12
+ const logger_1 = require("../../utils/logger");
13
+ const cache_1 = require("./cache");
14
+ const lock_1 = require("./lock");
15
+ /** Where the shipped manifests live inside the installed package. */
16
+ function manifestDir(profile) {
17
+ return node_path_1.default.resolve(__dirname, '../../..', 'install-manifests', profile);
18
+ }
19
+ 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];
23
+ /**
24
+ * A fingerprint of the manifest a profile directory was installed from.
25
+ *
26
+ * Comparing the lockfile hash rather than merely checking that `node_modules`
27
+ * exists is what makes an upgrade correct: the cache path already includes our
28
+ * version, but a client can also pin a `cacheDir` shared between checkouts, and
29
+ * a half-written directory from an interrupted install must not read as ready.
30
+ */
31
+ function manifestHash(dir) {
32
+ 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');
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /** Whether this profile directory already holds exactly this manifest, installed. */
41
+ function isInstalled(dir, expected) {
42
+ if (!expected)
43
+ 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 {
49
+ return false;
50
+ }
51
+ }
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
+ }
58
+ }
59
+ /**
60
+ * Make one profile's tooling available, fetching it only if it is not already there.
61
+ *
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
64
+ * --ignore-scripts`, a `node_modules` restored from a CI cache). Two code paths
65
+ * for the same job is how the two drift apart and how one of them silently stops
66
+ * working.
67
+ *
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.
74
+ */
75
+ async function ensureProfile(profile, opts) {
76
+ 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}"` };
84
+ }
85
+ const location = (0, cache_1.resolveCacheDir)(opts.version, install.cacheDir);
86
+ if (!location) {
87
+ return {
88
+ profile,
89
+ status: 'failed',
90
+ reason: 'no writable cache directory (tried the configured path, ~/.cache and the temp dir)',
91
+ };
92
+ }
93
+ const dir = (0, cache_1.profileDir)(location.dir, profile);
94
+ if (isInstalled(dir, expected)) {
95
+ return { profile, status: 'cached', reason: 'already installed', dir };
96
+ }
97
+ if (install.offline || process.env.PR_CHECKMATE_OFFLINE === 'true') {
98
+ return {
99
+ profile,
100
+ status: 'skipped',
101
+ reason: 'install.offline is set and the profile is not in the cache',
102
+ dir,
103
+ };
104
+ }
105
+ // The lock file lives in the profile directory, so the directory has to exist
106
+ // before there is anything to lock. Creating it inside `withLock` would put
107
+ // the creation itself outside the lock, which is the race it exists to close.
108
+ try {
109
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
110
+ }
111
+ catch (err) {
112
+ return { profile, status: 'failed', reason: `cannot create ${dir}: ${err.message}` };
113
+ }
114
+ const release = await (0, lock_1.withLock)(dir, install.waitTimeoutMs ?? 120000);
115
+ if (!release) {
116
+ return { profile, status: 'failed', reason: `cannot write to ${dir}`, dir };
117
+ }
118
+ try {
119
+ // Re-checked inside the lock: while we waited, the process we were waiting
120
+ // for probably did the work. Without this, waiting is pointless — every
121
+ // queued job would reinstall in turn.
122
+ if (isInstalled(dir, expected)) {
123
+ return { profile, status: 'cached', reason: 'installed by another process', dir };
124
+ }
125
+ return await runInstall(profile, source, dir, expected, opts);
126
+ }
127
+ finally {
128
+ release();
129
+ }
130
+ }
131
+ /**
132
+ * The real fetch: `npm ci` against the staged manifest.
133
+ *
134
+ * Run from the repository under review so the client's `.npmrc` — registry,
135
+ * scopes, proxy, auth — is the one npm reads; we never substitute our own.
136
+ * `--ignore-scripts` is not negotiable in a supply-chain tool.
137
+ */
138
+ async function npmCi(dir, opts) {
139
+ const result = await (0, execa_1.execa)('npm', ['ci', '--prefix', dir, '--ignore-scripts', '--no-audit', '--no-fund', '--loglevel', 'error'], { cwd: opts.cwd, reject: false, timeout: opts.install.installTimeoutMs ?? 300000 });
140
+ if (result.exitCode === 0)
141
+ return { ok: true };
142
+ const detail = (result.stderr || result.stdout || '').split('\n').slice(-3).join(' ').trim();
143
+ return { ok: false, detail: detail || `exit ${result.exitCode}` };
144
+ }
145
+ /** The fetch itself, once we hold the lock and know it is needed. */
146
+ async function runInstall(profile, source, dir, expected, opts) {
147
+ 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;
150
+ // Always logged, never behind --debug: this is a security tool downloading
151
+ // 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}`);
153
+ try {
154
+ const install = opts.installer ?? ((target) => npmCi(target, opts));
155
+ const result = await install(dir);
156
+ if (!result.ok) {
157
+ return { profile, status: 'failed', reason: `npm ci failed: ${result.detail}`, dir };
158
+ }
159
+ node_fs_1.default.writeFileSync(stampPath(dir), expected);
160
+ 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 };
163
+ }
164
+ catch (err) {
165
+ // A timeout arrives here as a thrown error. It is a skip, not a crash: the
166
+ // 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 };
168
+ }
169
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Whether the process named in a lock is still alive.
3
+ *
4
+ * Signal 0 performs the permission and existence checks without delivering
5
+ * anything. `EPERM` means the process exists but belongs to another user — alive,
6
+ * and not ours to override.
7
+ */
8
+ export declare function processAlive(pid: number): boolean;
9
+ /**
10
+ * Take the lock, waiting for whoever holds it.
11
+ *
12
+ * A held lock is not an error — it is the normal path when jobs run in parallel,
13
+ * and the right response is to wait and then use what the other process fetched.
14
+ * Waiting is announced and its progress printed: a silent wait is indistinguishable
15
+ * from a hang, and the whole point of the log is that a client can tell the
16
+ * difference at a glance.
17
+ *
18
+ * @param dir Profile directory to lock.
19
+ * @param timeoutMs How long to wait before taking the lock by force.
20
+ * @param deps Injected for tests: waiting, the clock, and how liveness of the
21
+ * holding process is decided. The last one matters because `process.kill(pid, 0)`
22
+ * is the one part whose answer depends on the operating system, and a test that
23
+ * depends on it is a test that behaves differently on a CI runner than on the
24
+ * machine it was written on.
25
+ * @returns A release function, or `null` when the lock could not be created at
26
+ * all (an unwritable directory — the caller reports that, it is not fatal here).
27
+ */
28
+ export interface LockDeps {
29
+ sleep?: (ms: number) => Promise<void>;
30
+ now?: () => number;
31
+ alive?: (pid: number) => boolean;
32
+ }
33
+ export declare function withLock(dir: string, timeoutMs: number, deps?: LockDeps): Promise<(() => void) | null>;
@@ -0,0 +1,99 @@
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.processAlive = processAlive;
7
+ exports.withLock = withLock;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const logger_1 = require("../../utils/logger");
11
+ const lockPath = (dir) => node_path_1.default.join(dir, '.pr-checkmate-install.lock');
12
+ function readLock(file) {
13
+ try {
14
+ return JSON.parse(node_fs_1.default.readFileSync(file, 'utf-8'));
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ /**
21
+ * Whether the process named in a lock is still alive.
22
+ *
23
+ * Signal 0 performs the permission and existence checks without delivering
24
+ * anything. `EPERM` means the process exists but belongs to another user — alive,
25
+ * and not ours to override.
26
+ */
27
+ function processAlive(pid) {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ }
32
+ catch (err) {
33
+ return err.code === 'EPERM';
34
+ }
35
+ }
36
+ async function withLock(dir, timeoutMs, deps = {}) {
37
+ const sleep = deps.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
38
+ const now = deps.now ?? Date.now;
39
+ const alive = deps.alive ?? processAlive;
40
+ const file = lockPath(dir);
41
+ const deadline = now() + timeoutMs;
42
+ let announced = false;
43
+ for (;;) {
44
+ try {
45
+ const record = { pid: process.pid, startedAt: now(), host: 'local' };
46
+ node_fs_1.default.writeFileSync(file, JSON.stringify(record), { flag: 'wx' });
47
+ return () => {
48
+ try {
49
+ node_fs_1.default.unlinkSync(file);
50
+ }
51
+ catch {
52
+ // Already gone: another process broke a lock it believed to be stale.
53
+ // Nothing to undo, and nothing worth failing a run over.
54
+ }
55
+ };
56
+ }
57
+ catch (err) {
58
+ if (err.code !== 'EEXIST')
59
+ return null;
60
+ }
61
+ const held = readLock(file);
62
+ // A lock whose owner died — the job was cancelled, the container was killed —
63
+ // would otherwise block every later run forever. Breaking it needs to be
64
+ // loud: it is the one case where two installs could genuinely overlap.
65
+ if (held && !alive(held.pid)) {
66
+ logger_1.logger.warn(`[install]: ⚠️ removing a stale lock left by process ${held.pid}, which is no longer running.`);
67
+ try {
68
+ node_fs_1.default.unlinkSync(file);
69
+ }
70
+ catch {
71
+ // Someone else got there first; the next iteration will find it gone.
72
+ }
73
+ // Yield before retrying. Both `continue`s below used to loop straight back
74
+ // to `writeFileSync`, so an unlink that kept failing became a tight loop
75
+ // that never returned to the event loop: no output, no timer, no timeout
76
+ // — a process pegged at 100% until something killed it. A hang with no
77
+ // symptom is the worst failure mode this code could have.
78
+ await sleep(50);
79
+ continue;
80
+ }
81
+ if (now() >= deadline) {
82
+ logger_1.logger.warn(`[install]: ⚠️ waited ${Math.round(timeoutMs / 1000)}s for another process to finish installing — taking over.`);
83
+ try {
84
+ node_fs_1.default.unlinkSync(file);
85
+ }
86
+ catch {
87
+ // Same as above.
88
+ }
89
+ await sleep(50);
90
+ continue;
91
+ }
92
+ if (!announced) {
93
+ const age = held ? Math.round((now() - held.startedAt) / 1000) : 0;
94
+ logger_1.logger.info(`[install]: ⏳ another process is installing into ${node_path_1.default.basename(dir)} (started ${age}s ago) — waiting up to ${Math.round(timeoutMs / 1000)}s`);
95
+ announced = true;
96
+ }
97
+ await sleep(500);
98
+ }
99
+ }