pr-checkmate 1.23.23 → 2.0.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 +68 -15
- package/cspell.json +3 -8
- package/dist/cli.js +37 -62
- package/dist/config/constants.d.ts +21 -3
- package/dist/config/constants.js +33 -6
- package/dist/config/pr-checkmate-workflow.yml +33 -327
- 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 +8 -0
- package/dist/core/checks/languages/shellcheck.js +55 -0
- 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/spellcheck.d.ts +50 -0
- package/dist/core/checks/quality/spellcheck.js +141 -13
- package/dist/core/checks/quality/symlinks.d.ts +16 -0
- package/dist/core/checks/quality/symlinks.js +62 -0
- package/dist/core/checks/quality/yaml-lint.js +22 -9
- package/dist/core/checks/security/diff-security.js +111 -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/registry.js +4 -0
- package/dist/core/reporters.d.ts +13 -2
- package/dist/core/reporters.js +145 -104
- package/dist/core/run-checks.js +91 -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 +204 -262
- package/dist/interfaces/index.d.ts +130 -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.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 +13 -28
- package/pr-checkmate.schema.json +1070 -96
- package/src/config/cspell.json +3 -8
- package/src/config/pr-checkmate-workflow.yml +33 -327
- package/src/config/pr-checkmate.schema.json +1070 -96
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PRCheckMateConfig } from '../../interfaces';
|
|
2
|
+
import type { Language, LanguageUsage } from '../../utils/detect-language';
|
|
3
|
+
/**
|
|
4
|
+
* A named bundle of tooling, and the checks that stop working without it.
|
|
5
|
+
*
|
|
6
|
+
* The package ships ~233 MB of tooling, and no repository needs all of it: a
|
|
7
|
+
* Python service pays for the whole JavaScript toolchain, a repository with
|
|
8
|
+
* Spellcheck switched off still pays for cspell. Profiles are how that stops —
|
|
9
|
+
* they name which slice of the tooling a given repository actually needs.
|
|
10
|
+
*
|
|
11
|
+
* `checks` is not documentation. It is what turns a failed install into an
|
|
12
|
+
* honest report: when a profile is missing, exactly those checks skip, with the
|
|
13
|
+
* reason, instead of quietly disappearing (the mistake of ticket 057).
|
|
14
|
+
*/
|
|
15
|
+
interface Profile {
|
|
16
|
+
name: ProfileName;
|
|
17
|
+
/** Top-level packages installed for this profile. */
|
|
18
|
+
packages: string[];
|
|
19
|
+
/** Registry names of the checks that need it. */
|
|
20
|
+
checks: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Languages that make this profile necessary. Absent for profiles driven by
|
|
23
|
+
* enabled checks rather than by what the repository is written in.
|
|
24
|
+
*/
|
|
25
|
+
languages?: Language[];
|
|
26
|
+
}
|
|
27
|
+
export type ProfileName = 'core' | 'js' | 'python' | 'cpp' | 'spellcheck' | 'markdown' | 'commitlint' | 'duplicate';
|
|
28
|
+
/**
|
|
29
|
+
* The catalogue.
|
|
30
|
+
*
|
|
31
|
+
* Sizes in the comments are measured exclusive footprints — what leaves with the
|
|
32
|
+
* profile and is shared with nothing else (see ticket 069).
|
|
33
|
+
*/
|
|
34
|
+
export declare const PROFILES: Profile[];
|
|
35
|
+
/** The profile a check belongs to, or `undefined` when it needs no extra tooling. */
|
|
36
|
+
export declare function profileForCheck(check: string): Profile | undefined;
|
|
37
|
+
/** Why a profile is (or is not) part of this run — the answer `doctor` prints. */
|
|
38
|
+
export interface ProfileDecision {
|
|
39
|
+
profile: ProfileName;
|
|
40
|
+
required: boolean;
|
|
41
|
+
reason: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Decide which profiles this repository needs, and why.
|
|
45
|
+
*
|
|
46
|
+
* The two inputs are deliberately different in kind:
|
|
47
|
+
*
|
|
48
|
+
* - **Languages are counted in files, never in markers.** A Python service that
|
|
49
|
+
* installed us through npm has a `package.json`, which is a JavaScript marker
|
|
50
|
+
* — and downloading 152 MB of JavaScript tooling because of the file that
|
|
51
|
+
* installed us would be the whole feature backfiring. `LanguageUsage.files`
|
|
52
|
+
* exists for exactly this (ticket 068).
|
|
53
|
+
* - **Universal profiles are driven by enabled checks**, so `severity: {
|
|
54
|
+
* "Spellcheck": "off" }` starts saving bandwidth instead of only a report row.
|
|
55
|
+
*
|
|
56
|
+
* @param usage Detected languages with their tracked file counts.
|
|
57
|
+
* @param config The client's config; `install.profiles` and `install.excludeProfiles`
|
|
58
|
+
* have the last word, in that order — an explicit exclusion beats an explicit
|
|
59
|
+
* request, because it is the one that can be a hard requirement (air-gapped,
|
|
60
|
+
* licence policy, a runner that must not download a wasm binary).
|
|
61
|
+
* @returns One decision per profile, in catalogue order.
|
|
62
|
+
*/
|
|
63
|
+
export declare function planProfiles(usage: LanguageUsage[], config: PRCheckMateConfig): ProfileDecision[];
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PROFILES = void 0;
|
|
4
|
+
exports.profileForCheck = profileForCheck;
|
|
5
|
+
exports.planProfiles = planProfiles;
|
|
6
|
+
/**
|
|
7
|
+
* The catalogue.
|
|
8
|
+
*
|
|
9
|
+
* Sizes in the comments are measured exclusive footprints — what leaves with the
|
|
10
|
+
* profile and is shared with nothing else (see ticket 069).
|
|
11
|
+
*/
|
|
12
|
+
exports.PROFILES = [
|
|
13
|
+
{
|
|
14
|
+
// Always installed. Everything here backs a check that runs on any
|
|
15
|
+
// repository whatever it is written in, so there is nothing to decide.
|
|
16
|
+
name: 'core',
|
|
17
|
+
packages: ['execa', 'yaml', 'inquirer', 'gitleaks-secret-scanner-fixed'],
|
|
18
|
+
checks: ['Security Scan'],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
// 152 MB, the largest by far, and the one a Python repository pays for
|
|
22
|
+
// today with nothing to show for it.
|
|
23
|
+
name: 'js',
|
|
24
|
+
packages: [
|
|
25
|
+
'typescript',
|
|
26
|
+
'eslint',
|
|
27
|
+
'eslint-config-prettier',
|
|
28
|
+
'eslint-plugin-import-x',
|
|
29
|
+
'eslint-plugin-jsdoc',
|
|
30
|
+
'eslint-plugin-prettier',
|
|
31
|
+
'eslint-plugin-sonarjs',
|
|
32
|
+
'@typescript-eslint/eslint-plugin',
|
|
33
|
+
'@typescript-eslint/parser',
|
|
34
|
+
'prettier',
|
|
35
|
+
'dpdm',
|
|
36
|
+
'knip',
|
|
37
|
+
'ts-unused-exports',
|
|
38
|
+
'license-checker-rseidelsohn',
|
|
39
|
+
],
|
|
40
|
+
checks: [
|
|
41
|
+
'ESLint',
|
|
42
|
+
'Prettier',
|
|
43
|
+
'TypeScript',
|
|
44
|
+
'Circular Deps',
|
|
45
|
+
'Dependencies',
|
|
46
|
+
'Dead Code',
|
|
47
|
+
'License Check',
|
|
48
|
+
],
|
|
49
|
+
languages: ['typescript', 'javascript'],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'python',
|
|
53
|
+
packages: ['@astral-sh/ruff-wasm-nodejs'],
|
|
54
|
+
checks: ['Ruff Lint', 'Ruff Format'],
|
|
55
|
+
languages: ['python'],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'cpp',
|
|
59
|
+
packages: ['@wasm-fmt/clang-format'],
|
|
60
|
+
checks: ['C++ Format'],
|
|
61
|
+
languages: ['cpp'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
// The four below are not languages. They are single checks with heavy
|
|
65
|
+
// tooling behind them, which is exactly why they are separate: switching
|
|
66
|
+
// one off should stop costing megabytes, not just a line in the report.
|
|
67
|
+
name: 'spellcheck',
|
|
68
|
+
packages: ['cspell'],
|
|
69
|
+
checks: ['Spellcheck'],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'markdown',
|
|
73
|
+
packages: ['markdownlint-cli2'],
|
|
74
|
+
checks: ['Markdown'],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'commitlint',
|
|
78
|
+
packages: ['@commitlint/cli', '@commitlint/config-conventional'],
|
|
79
|
+
checks: ['Commitlint'],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'duplicate',
|
|
83
|
+
packages: ['jscpd'],
|
|
84
|
+
checks: ['Duplicate Code'],
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
/** The profile a check belongs to, or `undefined` when it needs no extra tooling. */
|
|
88
|
+
function profileForCheck(check) {
|
|
89
|
+
return exports.PROFILES.find(p => p.checks.includes(check));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A check counts as enabled unless it is switched off.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately asymmetric with how the runner selects checks: this must not
|
|
95
|
+
* import the registry (that would drag every check into the install path), and
|
|
96
|
+
* being wrong in the "enabled" direction only costs a download, while being
|
|
97
|
+
* wrong the other way silently removes a check. So anything not explicitly off
|
|
98
|
+
* is treated as on.
|
|
99
|
+
*/
|
|
100
|
+
function isCheckEnabled(check, config) {
|
|
101
|
+
return config.severity?.[check] !== 'off';
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Decide which profiles this repository needs, and why.
|
|
105
|
+
*
|
|
106
|
+
* The two inputs are deliberately different in kind:
|
|
107
|
+
*
|
|
108
|
+
* - **Languages are counted in files, never in markers.** A Python service that
|
|
109
|
+
* installed us through npm has a `package.json`, which is a JavaScript marker
|
|
110
|
+
* — and downloading 152 MB of JavaScript tooling because of the file that
|
|
111
|
+
* installed us would be the whole feature backfiring. `LanguageUsage.files`
|
|
112
|
+
* exists for exactly this (ticket 068).
|
|
113
|
+
* - **Universal profiles are driven by enabled checks**, so `severity: {
|
|
114
|
+
* "Spellcheck": "off" }` starts saving bandwidth instead of only a report row.
|
|
115
|
+
*
|
|
116
|
+
* @param usage Detected languages with their tracked file counts.
|
|
117
|
+
* @param config The client's config; `install.profiles` and `install.excludeProfiles`
|
|
118
|
+
* have the last word, in that order — an explicit exclusion beats an explicit
|
|
119
|
+
* request, because it is the one that can be a hard requirement (air-gapped,
|
|
120
|
+
* licence policy, a runner that must not download a wasm binary).
|
|
121
|
+
* @returns One decision per profile, in catalogue order.
|
|
122
|
+
*/
|
|
123
|
+
function planProfiles(usage, config) {
|
|
124
|
+
const install = config.install ?? {};
|
|
125
|
+
const minFiles = Math.max(1, install.minFilesPerLanguage ?? 1);
|
|
126
|
+
const requested = new Set(install.profiles ?? []);
|
|
127
|
+
const excluded = new Set(install.excludeProfiles ?? []);
|
|
128
|
+
return exports.PROFILES.map(profile => {
|
|
129
|
+
const decision = decide(profile, usage, config, minFiles);
|
|
130
|
+
if (excluded.has(profile.name)) {
|
|
131
|
+
return {
|
|
132
|
+
profile: profile.name,
|
|
133
|
+
required: false,
|
|
134
|
+
reason: 'excluded by install.excludeProfiles',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (!decision.required && requested.has(profile.name)) {
|
|
138
|
+
return { profile: profile.name, required: true, reason: 'requested by install.profiles' };
|
|
139
|
+
}
|
|
140
|
+
return { profile: profile.name, required: decision.required, reason: decision.reason };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** The automatic half of {@link planProfiles}, before the client's overrides. */
|
|
144
|
+
function decide(profile, usage, config, minFiles) {
|
|
145
|
+
if (profile.name === 'core')
|
|
146
|
+
return { required: true, reason: 'always installed' };
|
|
147
|
+
if (profile.languages) {
|
|
148
|
+
const found = profile.languages
|
|
149
|
+
.map(lang => usage.find(u => u.lang === lang))
|
|
150
|
+
.filter((u) => Boolean(u) && u.files >= minFiles);
|
|
151
|
+
if (found.length > 0) {
|
|
152
|
+
const detail = found.map(u => `${u.files} ${u.lang} file(s)`).join(', ');
|
|
153
|
+
return { required: true, reason: detail };
|
|
154
|
+
}
|
|
155
|
+
// Named separately from "no files": a marker with no source files is the
|
|
156
|
+
// OctoBot case — a `Cargo.toml` beside zero `.rs`. The language is real
|
|
157
|
+
// enough to run checks for; it is not real enough to download a toolchain.
|
|
158
|
+
const markerOnly = profile.languages.some(lang => usage.some(u => u.lang === lang));
|
|
159
|
+
return {
|
|
160
|
+
required: false,
|
|
161
|
+
reason: markerOnly
|
|
162
|
+
? 'declared by a marker file but no source files tracked'
|
|
163
|
+
: 'no source files tracked',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const enabled = profile.checks.filter(check => isCheckEnabled(check, config));
|
|
167
|
+
return enabled.length > 0
|
|
168
|
+
? { required: true, reason: `${enabled.join(', ')} enabled` }
|
|
169
|
+
: { required: false, reason: `${profile.checks.join(', ')} switched off` };
|
|
170
|
+
}
|
package/dist/core/registry.js
CHANGED
|
@@ -31,6 +31,7 @@ const python_typecheck_1 = require("./checks/languages/python-typecheck");
|
|
|
31
31
|
const ruby_lint_1 = require("./checks/languages/ruby-lint");
|
|
32
32
|
const rust_format_1 = require("./checks/languages/rust-format");
|
|
33
33
|
const rust_lint_1 = require("./checks/languages/rust-lint");
|
|
34
|
+
const shellcheck_1 = require("./checks/languages/shellcheck");
|
|
34
35
|
const swift_lint_1 = require("./checks/languages/swift-lint");
|
|
35
36
|
const typecheck_1 = require("./checks/languages/typecheck");
|
|
36
37
|
const commitlint_1 = require("./checks/pr/commitlint");
|
|
@@ -45,6 +46,7 @@ const duplicate_check_1 = require("./checks/quality/duplicate-check");
|
|
|
45
46
|
const license_header_1 = require("./checks/quality/license-header");
|
|
46
47
|
const markdown_lint_1 = require("./checks/quality/markdown-lint");
|
|
47
48
|
const spellcheck_1 = require("./checks/quality/spellcheck");
|
|
49
|
+
const symlinks_1 = require("./checks/quality/symlinks");
|
|
48
50
|
const yaml_lint_1 = require("./checks/quality/yaml-lint");
|
|
49
51
|
const diff_security_1 = require("./checks/security/diff-security");
|
|
50
52
|
const dockerfile_security_1 = require("./checks/security/dockerfile-security");
|
|
@@ -95,6 +97,7 @@ exports.REGISTRY = [
|
|
|
95
97
|
markdown_lint_1.markdownLintCheck,
|
|
96
98
|
spellcheck_1.spellcheckCheck,
|
|
97
99
|
yaml_lint_1.yamlLintCheck,
|
|
100
|
+
symlinks_1.symlinksCheck,
|
|
98
101
|
python_lint_1.pythonLintCheck,
|
|
99
102
|
python_typecheck_1.pythonTypecheckCheck,
|
|
100
103
|
dead_code_1.deadCodeCheck,
|
|
@@ -103,6 +106,7 @@ exports.REGISTRY = [
|
|
|
103
106
|
go_lint_1.goLintCheck,
|
|
104
107
|
rust_lint_1.rustLintCheck,
|
|
105
108
|
ruby_lint_1.rubyLintCheck,
|
|
109
|
+
shellcheck_1.shellcheckCheck,
|
|
106
110
|
// format
|
|
107
111
|
prettier_1.prettierCheck,
|
|
108
112
|
cpp_format_1.cppFormatCheck,
|
package/dist/core/reporters.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import type { CheckReport, ReporterKind } from './types';
|
|
1
|
+
import type { CheckReport, ReporterConfig, ReporterKind } from './types';
|
|
2
|
+
/** Whether the comment reached the PR, so the caller can act on a failure. */
|
|
3
|
+
export interface PostOutcome {
|
|
4
|
+
attempted: boolean;
|
|
5
|
+
ok: boolean;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function buildMarkdownReport(report: CheckReport, cfg?: ReporterConfig): string;
|
|
2
9
|
/**
|
|
3
10
|
* Emit the report through the selected channel.
|
|
4
11
|
* none: nothing · stdout: console table · github/gitlab/bitbucket: table +
|
|
5
12
|
* upsert the PR/MR comment · auto: detect the platform from the environment.
|
|
13
|
+
*
|
|
14
|
+
* Returns whether the comment reached the PR, so `runChecks` can honour
|
|
15
|
+
* `reporter.onPostFailure` — for teams whose review process depends on the
|
|
16
|
+
* comment, a silently lost report is worse than a red build.
|
|
6
17
|
*/
|
|
7
|
-
export declare function report(kind: ReporterKind, result: CheckReport): Promise<
|
|
18
|
+
export declare function report(kind: ReporterKind, result: CheckReport, cfg?: ReporterConfig): Promise<PostOutcome>;
|