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.
- package/README.md +89 -13
- package/cspell.json +11 -9
- package/dist/cli.js +37 -64
- package/dist/config/constants.d.ts +51 -3
- package/dist/config/constants.js +73 -11
- package/dist/config/pr-checkmate-workflow-docker.yml +79 -0
- package/dist/config/pr-checkmate-workflow.yml +65 -326
- package/dist/core/checks/dependencies/banned-imports.js +23 -24
- package/dist/core/checks/dependencies/circular-deps.js +1 -0
- package/dist/core/checks/dependencies/dependency-check.js +1 -0
- package/dist/core/checks/dependencies/grype-scan.js +1 -0
- package/dist/core/checks/dependencies/npm-audit.js +6 -1
- package/dist/core/checks/dependencies/vuln-scan.js +1 -0
- package/dist/core/checks/findings.d.ts +198 -19
- package/dist/core/checks/findings.js +474 -22
- package/dist/core/checks/git/custom-rules.js +21 -16
- package/dist/core/checks/git/diff-smell.d.ts +17 -0
- package/dist/core/checks/git/diff-smell.js +43 -24
- package/dist/core/checks/git/large-files.js +14 -2
- package/dist/core/checks/git/leftover-debug.js +23 -14
- package/dist/core/checks/git/lockfile-drift.js +2 -1
- package/dist/core/checks/git/merge-conflict.js +19 -8
- package/dist/core/checks/git/missing-tests.js +22 -6
- package/dist/core/checks/git/todo-fixme.js +10 -9
- package/dist/core/checks/languages/shellcheck.d.ts +1 -1
- package/dist/core/checks/languages/shellcheck.js +1 -1
- package/dist/core/checks/languages/typecheck.js +1 -0
- package/dist/core/checks/pr/pr-size-check.js +2 -1
- package/dist/core/checks/quality/config-validation.d.ts +14 -4
- package/dist/core/checks/quality/config-validation.js +119 -17
- package/dist/core/checks/quality/dead-code.js +1 -0
- package/dist/core/checks/quality/duplicate-check.js +1 -0
- package/dist/core/checks/quality/license-header.js +12 -2
- package/dist/core/checks/quality/markdown-lint.js +7 -2
- package/dist/core/checks/quality/spellcheck.d.ts +50 -0
- package/dist/core/checks/quality/spellcheck.js +141 -13
- package/dist/core/checks/quality/yaml-lint.js +22 -9
- package/dist/core/checks/security/diff-security.js +115 -28
- package/dist/core/checks/security/dockerfile-security.js +23 -20
- package/dist/core/checks/security/migration-safety.js +19 -17
- package/dist/core/checks/security/sensitive-file-guard.js +37 -5
- package/dist/core/checks/security/workflow-security.d.ts +10 -0
- package/dist/core/checks/security/workflow-security.js +48 -26
- package/dist/core/commands.d.ts +27 -0
- package/dist/core/commands.js +91 -0
- package/dist/core/context.d.ts +12 -3
- package/dist/core/context.js +118 -16
- package/dist/core/install/cache.d.ts +39 -0
- package/dist/core/install/cache.js +99 -0
- package/dist/core/install/doctor.d.ts +35 -0
- package/dist/core/install/doctor.js +145 -0
- package/dist/core/install/ensure.d.ts +60 -0
- package/dist/core/install/ensure.js +169 -0
- package/dist/core/install/lock.d.ts +33 -0
- package/dist/core/install/lock.js +99 -0
- package/dist/core/install/profiles.d.ts +64 -0
- package/dist/core/install/profiles.js +170 -0
- package/dist/core/reporters.d.ts +13 -2
- package/dist/core/reporters.js +145 -104
- package/dist/core/run-checks.d.ts +25 -1
- package/dist/core/run-checks.js +124 -14
- package/dist/core/targets.js +26 -4
- package/dist/core/types.d.ts +49 -1
- package/dist/init.d.ts +29 -14
- package/dist/init.js +284 -263
- package/dist/interfaces/index.d.ts +120 -0
- package/dist/scripts/copy-configs.js +4 -0
- package/dist/scripts/generate-install-manifests.d.ts +39 -0
- package/dist/scripts/generate-install-manifests.js +121 -0
- package/dist/scripts/publish-github.js +18 -4
- package/dist/utils/detect-language.d.ts +37 -4
- package/dist/utils/detect-language.js +113 -19
- package/dist/utils/http.d.ts +59 -0
- package/dist/utils/http.js +119 -0
- package/dist/utils/logger.d.ts +52 -6
- package/dist/utils/logger.js +125 -33
- package/dist/utils/pool.d.ts +49 -0
- package/dist/utils/pool.js +124 -0
- package/dist/utils/update-notifier.d.ts +9 -0
- package/dist/utils/update-notifier.js +57 -3
- package/eslint-default-rules.json +1 -1
- package/eslint.config.mjs +14 -2
- package/install-manifests/commitlint/package-lock.json +1028 -0
- package/install-manifests/commitlint/package.json +9 -0
- package/install-manifests/core/package-lock.json +1335 -0
- package/install-manifests/core/package.json +11 -0
- package/install-manifests/cpp/package-lock.json +29 -0
- package/install-manifests/cpp/package.json +8 -0
- package/install-manifests/duplicate/package-lock.json +113 -0
- package/install-manifests/duplicate/package.json +8 -0
- package/install-manifests/js/package-lock.json +5099 -0
- package/install-manifests/js/package.json +26 -0
- package/install-manifests/markdown/package-lock.json +1281 -0
- package/install-manifests/markdown/package.json +8 -0
- package/install-manifests/python/package-lock.json +21 -0
- package/install-manifests/python/package.json +8 -0
- package/install-manifests/spellcheck/package-lock.json +1047 -0
- package/install-manifests/spellcheck/package.json +8 -0
- package/package.json +14 -28
- package/pr-checkmate.schema.json +1062 -104
- package/src/config/cspell.json +11 -9
- package/src/config/eslint-default-rules.json +1 -1
- package/src/config/pr-checkmate-workflow-docker.yml +79 -0
- package/src/config/pr-checkmate-workflow.yml +65 -326
- 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
|
+
}
|