pr-checkmate 1.23.24 → 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 +61 -11
- package/cspell.json +3 -8
- package/dist/cli.js +37 -64
- 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 +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/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 +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/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 +120 -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 +1062 -104
- package/src/config/cspell.json +3 -8
- package/src/config/pr-checkmate-workflow.yml +33 -327
- package/src/config/pr-checkmate.schema.json +1062 -104
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.request = request;
|
|
4
|
+
exports.paginate = paginate;
|
|
5
|
+
const logger_1 = require("./logger");
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 10000;
|
|
7
|
+
const DEFAULT_RETRIES = 3;
|
|
8
|
+
/** 5xx and 429 are worth another attempt; a 4xx will fail identically forever. */
|
|
9
|
+
function isRetryable(status) {
|
|
10
|
+
return status === 0 || status === 429 || status >= 500;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Turn a response into a diagnosis a person can act on.
|
|
14
|
+
*
|
|
15
|
+
* "Could not post comment: 403" sends someone hunting through Actions docs;
|
|
16
|
+
* naming the likely cause is the difference between a fixable message and a
|
|
17
|
+
* shrug. These three account for nearly every real failure.
|
|
18
|
+
*/
|
|
19
|
+
function explain(status, body) {
|
|
20
|
+
if (status === 401 || status === 403) {
|
|
21
|
+
return `${status} — token missing, expired, or lacking write scope (GitHub needs \`permissions: pull-requests: write\` AND \`env: GITHUB_TOKEN\`)`;
|
|
22
|
+
}
|
|
23
|
+
if (status === 404)
|
|
24
|
+
return `${status} — repository or PR not found, or the token cannot see it`;
|
|
25
|
+
if (status === 429)
|
|
26
|
+
return `${status} — rate limited`;
|
|
27
|
+
return `${status}${body ? ` — ${body.slice(0, 200)}` : ''}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Never let a token reach a log line, whatever an API decides to echo back.
|
|
31
|
+
*
|
|
32
|
+
* `pousr` and `glpat` below are not words: the first is the character class of
|
|
33
|
+
* GitHub's token prefixes (`ghp_`/`gho_`/`ghu_`/`ghs_`/`ghr_`), the second is
|
|
34
|
+
* GitLab's literal `glpat-`. Muted here rather than in the dictionary, which
|
|
35
|
+
* ships to every client and should not carry our regex fragments.
|
|
36
|
+
* cspell:ignore pousr glpat
|
|
37
|
+
*/
|
|
38
|
+
function redact(text) {
|
|
39
|
+
return text.replace(/\b(gh[pousr]_|glpat-|xox[baprs]-)[A-Za-z0-9_-]{8,}/g, '$1***');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* One HTTP request with a timeout and bounded retries, returning a result rather
|
|
43
|
+
* than throwing.
|
|
44
|
+
*
|
|
45
|
+
* The reason this exists: every write in the reporter used to be a bare `await
|
|
46
|
+
* fetch(...)` whose result was discarded, followed by a log line claiming
|
|
47
|
+
* success. A 403 from a workflow missing `pull-requests: write` produced
|
|
48
|
+
* "✅ Posted PR CheckMate summary" and no comment — the user had nothing to
|
|
49
|
+
* debug from.
|
|
50
|
+
*/
|
|
51
|
+
async function request(url, init = {}, opts = {}) {
|
|
52
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
53
|
+
const attempts = Math.max(1, opts.retries ?? DEFAULT_RETRIES);
|
|
54
|
+
const label = opts.label ? `[${opts.label}] ` : '';
|
|
55
|
+
let last = { ok: false, status: 0, error: 'no attempt made' };
|
|
56
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
57
|
+
last = await attempt_(url, init, timeoutMs);
|
|
58
|
+
if (last.ok || !isRetryable(last.status))
|
|
59
|
+
return last;
|
|
60
|
+
if (attempt < attempts) {
|
|
61
|
+
// Exponential backoff, capped so three attempts stay well inside a CI step.
|
|
62
|
+
const waitMs = Math.min(2000, 250 * 2 ** (attempt - 1));
|
|
63
|
+
logger_1.logger.warn(`${label}retrying after ${last.error} (attempt ${attempt}/${attempts})`);
|
|
64
|
+
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return last;
|
|
68
|
+
}
|
|
69
|
+
/** A single attempt, with every failure mode flattened into an HttpResult. */
|
|
70
|
+
async function attempt_(url, init, timeoutMs) {
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const text = await res.text().catch(() => '');
|
|
75
|
+
return { ok: false, status: res.status, error: redact(explain(res.status, text)) };
|
|
76
|
+
}
|
|
77
|
+
// A successful response with an unreadable body is still a successful write;
|
|
78
|
+
// only callers that need the body care, and they check for `undefined`.
|
|
79
|
+
const body = (await res.json().catch(() => undefined));
|
|
80
|
+
return { ok: true, status: res.status, body };
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
84
|
+
return { ok: false, status: 0, error: redact(`request failed — ${reason}`) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Walk a paginated collection until `nextUrl` returns nothing or `maxPages` is
|
|
89
|
+
* reached, returning every item found.
|
|
90
|
+
*
|
|
91
|
+
* Without this the reporter read only the first page — 30 comments on GitHub, 20
|
|
92
|
+
* on GitLab — so on any PR with more discussion than that its own marker fell off
|
|
93
|
+
* the end, the upsert became an insert, and every push added another comment.
|
|
94
|
+
*
|
|
95
|
+
* Stopping at `maxPages` is logged rather than silent: a truncated search can
|
|
96
|
+
* only produce a duplicate comment, and that should be explainable.
|
|
97
|
+
*/
|
|
98
|
+
async function paginate(firstUrl, init, extract,
|
|
99
|
+
/**
|
|
100
|
+
* URL of the page after this one, or null when the collection is exhausted.
|
|
101
|
+
* Receives the page's item count so page-number APIs (GitHub, GitLab) can stop
|
|
102
|
+
* on a short page, while cursor APIs (Bitbucket) read a link out of the body.
|
|
103
|
+
*/
|
|
104
|
+
nextUrl, opts = {}) {
|
|
105
|
+
const maxPages = Math.max(1, opts.maxPages ?? 10);
|
|
106
|
+
const items = [];
|
|
107
|
+
let url = firstUrl;
|
|
108
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
109
|
+
const res = await request(url, init, opts);
|
|
110
|
+
if (!res.ok)
|
|
111
|
+
return { items, ok: false, truncated: false, error: res.error };
|
|
112
|
+
const pageItems = extract(res.body);
|
|
113
|
+
items.push(...pageItems);
|
|
114
|
+
url = nextUrl(res.body, page, pageItems.length);
|
|
115
|
+
if (!url)
|
|
116
|
+
return { items, ok: true, truncated: false };
|
|
117
|
+
}
|
|
118
|
+
return { items, ok: true, truncated: true };
|
|
119
|
+
}
|
package/dist/utils/logger.d.ts
CHANGED
|
@@ -1,19 +1,65 @@
|
|
|
1
|
+
/** Destination for log lines; see {@link Logger.setSink}. */
|
|
2
|
+
export interface LogSink {
|
|
3
|
+
out(chunk: string): void;
|
|
4
|
+
err(chunk: string): void;
|
|
5
|
+
}
|
|
1
6
|
export type LogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug';
|
|
7
|
+
/** How a line is rendered. */
|
|
8
|
+
export type LogFormat = 'pretty' | 'ci' | 'json';
|
|
9
|
+
export interface LoggerOptions {
|
|
10
|
+
level?: LogLevel;
|
|
11
|
+
format?: LogFormat;
|
|
12
|
+
color?: 'auto' | 'always' | 'never';
|
|
13
|
+
timestamps?: boolean;
|
|
14
|
+
/** Levels routed to stderr rather than stdout. */
|
|
15
|
+
stderrLevels?: LogLevel[];
|
|
16
|
+
}
|
|
2
17
|
export declare class Logger {
|
|
3
|
-
private logs;
|
|
4
|
-
private levelOrder;
|
|
5
18
|
private currentLevel;
|
|
6
|
-
private
|
|
19
|
+
private format;
|
|
20
|
+
private color;
|
|
21
|
+
private timestamps;
|
|
22
|
+
private stderrLevels;
|
|
23
|
+
constructor(options?: LoggerOptions);
|
|
24
|
+
/** Apply options; unset fields keep their current value. */
|
|
25
|
+
configure(options: LoggerOptions): void;
|
|
7
26
|
private shouldLog;
|
|
8
|
-
private
|
|
27
|
+
private streamFor;
|
|
28
|
+
/**
|
|
29
|
+
* Where lines actually go. Swappable so tests can capture output without
|
|
30
|
+
* touching `process.stdout`.
|
|
31
|
+
*
|
|
32
|
+
* That distinction is not stylistic. Node's test runner reports each file's
|
|
33
|
+
* results to the parent process over stdout, so a test that replaces
|
|
34
|
+
* `process.stdout.write` and swallows what it receives also swallows those
|
|
35
|
+
* frames: the parent then waits forever for a file that already finished. It
|
|
36
|
+
* only shows up under load — on a two-core CI runner the frames land inside
|
|
37
|
+
* the capture window, on a developer's machine they usually do not — which
|
|
38
|
+
* made it a three-hour hang that never reproduced locally.
|
|
39
|
+
*/
|
|
40
|
+
private sink;
|
|
41
|
+
/**
|
|
42
|
+
* Redirect output, returning the previous sink so the caller can restore it.
|
|
43
|
+
* Passing `null` restores the real streams.
|
|
44
|
+
*/
|
|
45
|
+
setSink(sink: LogSink | null): LogSink | null;
|
|
46
|
+
private emit;
|
|
47
|
+
private render;
|
|
9
48
|
private logMessage;
|
|
10
49
|
log(msg: string): void;
|
|
11
50
|
info(msg: string): void;
|
|
12
51
|
warn(msg: string): void;
|
|
13
52
|
error(msg: string, err?: unknown): void;
|
|
14
53
|
debug(msg: string): void;
|
|
15
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Product output: the run summary, printed verbatim.
|
|
56
|
+
*
|
|
57
|
+
* Distinct from `info` because the summary is the thing the run exists to
|
|
58
|
+
* produce, not a note about producing it. Routing it through the log formatter
|
|
59
|
+
* prefixed every line with a timestamp and a level, which pushed the box
|
|
60
|
+
* borders out of alignment and made the headline artefact look broken.
|
|
61
|
+
*/
|
|
62
|
+
print(msg: string): void;
|
|
16
63
|
setLevel(level: LogLevel): void;
|
|
17
|
-
clear(): void;
|
|
18
64
|
}
|
|
19
65
|
export declare const logger: Logger;
|
package/dist/utils/logger.js
CHANGED
|
@@ -1,44 +1,131 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.logger = exports.Logger = void 0;
|
|
4
|
+
const LEVEL_ORDER = {
|
|
5
|
+
error: 0,
|
|
6
|
+
warn: 1,
|
|
7
|
+
info: 2,
|
|
8
|
+
log: 2,
|
|
9
|
+
debug: 3,
|
|
10
|
+
};
|
|
11
|
+
const COLORS = {
|
|
12
|
+
error: '\x1b[31m', // red
|
|
13
|
+
warn: '\x1b[33m', // yellow
|
|
14
|
+
info: '\x1b[32m', // green
|
|
15
|
+
log: '\x1b[37m', // white
|
|
16
|
+
debug: '\x1b[36m', // cyan
|
|
17
|
+
};
|
|
18
|
+
const RESET = '\x1b[0m';
|
|
19
|
+
/**
|
|
20
|
+
* Whether ANSI colour is wanted on `stream`.
|
|
21
|
+
*
|
|
22
|
+
* Colour used to be emitted unconditionally, so a piped or redirected run — and
|
|
23
|
+
* every CI log — was littered with escape sequences. `NO_COLOR` is the de-facto
|
|
24
|
+
* standard (no-color.org) and `FORCE_COLOR` the usual override; both are honoured
|
|
25
|
+
* here, and the TTY test asks about the stream actually being written to.
|
|
26
|
+
*/
|
|
27
|
+
function wantsColor(mode, stream) {
|
|
28
|
+
if (mode === 'never')
|
|
29
|
+
return false;
|
|
30
|
+
if (mode === 'always')
|
|
31
|
+
return true;
|
|
32
|
+
if (process.env.FORCE_COLOR)
|
|
33
|
+
return process.env.FORCE_COLOR !== '0';
|
|
34
|
+
if (process.env.NO_COLOR !== undefined)
|
|
35
|
+
return false;
|
|
36
|
+
return Boolean(stream.isTTY);
|
|
37
|
+
}
|
|
4
38
|
class Logger {
|
|
5
|
-
constructor() {
|
|
6
|
-
this.logs = [];
|
|
7
|
-
this.levelOrder = {
|
|
8
|
-
error: 0,
|
|
9
|
-
warn: 1,
|
|
10
|
-
info: 2,
|
|
11
|
-
log: 2,
|
|
12
|
-
debug: 3,
|
|
13
|
-
};
|
|
39
|
+
constructor(options = {}) {
|
|
14
40
|
this.currentLevel = 'info';
|
|
41
|
+
this.color = 'auto';
|
|
42
|
+
this.stderrLevels = new Set(['error', 'warn']);
|
|
43
|
+
/**
|
|
44
|
+
* Where lines actually go. Swappable so tests can capture output without
|
|
45
|
+
* touching `process.stdout`.
|
|
46
|
+
*
|
|
47
|
+
* That distinction is not stylistic. Node's test runner reports each file's
|
|
48
|
+
* results to the parent process over stdout, so a test that replaces
|
|
49
|
+
* `process.stdout.write` and swallows what it receives also swallows those
|
|
50
|
+
* frames: the parent then waits forever for a file that already finished. It
|
|
51
|
+
* only shows up under load — on a two-core CI runner the frames land inside
|
|
52
|
+
* the capture window, on a developer's machine they usually do not — which
|
|
53
|
+
* made it a three-hour hang that never reproduced locally.
|
|
54
|
+
*/
|
|
55
|
+
this.sink = null;
|
|
56
|
+
// CI logs are read long after the fact and benefit from timestamps; a
|
|
57
|
+
// terminal is read as it scrolls, where they are noise that also breaks the
|
|
58
|
+
// alignment of the summary box.
|
|
59
|
+
const inCi = Boolean(process.env.CI);
|
|
60
|
+
this.format = options.format ?? (inCi ? 'ci' : 'pretty');
|
|
61
|
+
this.timestamps = options.timestamps ?? this.format === 'ci';
|
|
62
|
+
this.configure(options);
|
|
15
63
|
}
|
|
16
|
-
|
|
17
|
-
|
|
64
|
+
/** Apply options; unset fields keep their current value. */
|
|
65
|
+
configure(options) {
|
|
66
|
+
if (options.level)
|
|
67
|
+
this.currentLevel = options.level;
|
|
68
|
+
if (options.format) {
|
|
69
|
+
this.format = options.format;
|
|
70
|
+
if (options.timestamps === undefined)
|
|
71
|
+
this.timestamps = options.format === 'ci';
|
|
72
|
+
}
|
|
73
|
+
if (options.color)
|
|
74
|
+
this.color = options.color;
|
|
75
|
+
if (options.timestamps !== undefined)
|
|
76
|
+
this.timestamps = options.timestamps;
|
|
77
|
+
if (options.stderrLevels)
|
|
78
|
+
this.stderrLevels = new Set(options.stderrLevels);
|
|
18
79
|
}
|
|
19
80
|
shouldLog(level) {
|
|
20
|
-
return
|
|
81
|
+
return LEVEL_ORDER[level] <= LEVEL_ORDER[this.currentLevel];
|
|
82
|
+
}
|
|
83
|
+
streamFor(level) {
|
|
84
|
+
return this.stderrLevels.has(level) ? process.stderr : process.stdout;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Redirect output, returning the previous sink so the caller can restore it.
|
|
88
|
+
* Passing `null` restores the real streams.
|
|
89
|
+
*/
|
|
90
|
+
setSink(sink) {
|
|
91
|
+
const previous = this.sink;
|
|
92
|
+
this.sink = sink;
|
|
93
|
+
return previous;
|
|
21
94
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
95
|
+
emit(stream, text) {
|
|
96
|
+
if (!this.sink) {
|
|
97
|
+
stream.write(text);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (stream === process.stderr)
|
|
101
|
+
this.sink.err(text);
|
|
102
|
+
else
|
|
103
|
+
this.sink.out(text);
|
|
104
|
+
}
|
|
105
|
+
render(level, message, error) {
|
|
106
|
+
const stack = error instanceof Error ? `\n${error.stack}` : '';
|
|
107
|
+
if (this.format === 'json') {
|
|
108
|
+
return JSON.stringify({
|
|
109
|
+
level,
|
|
110
|
+
message,
|
|
111
|
+
...(stack ? { stack: stack.trim() } : {}),
|
|
112
|
+
time: new Date().toISOString(),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const time = this.timestamps ? `[${new Date().toISOString()}] ` : '';
|
|
116
|
+
const tag = this.format === 'ci' ? `[${level.toUpperCase()}] ` : '';
|
|
117
|
+
return `${time}${tag}${message}${stack}`;
|
|
32
118
|
}
|
|
33
119
|
logMessage(level, message, error) {
|
|
34
120
|
if (!this.shouldLog(level))
|
|
35
121
|
return;
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
this.
|
|
40
|
-
|
|
41
|
-
|
|
122
|
+
const stream = this.streamFor(level);
|
|
123
|
+
const line = this.render(level, message, error);
|
|
124
|
+
// JSON output is meant to be parsed; coloring it would corrupt the payload.
|
|
125
|
+
const colored = this.format !== 'json' && wantsColor(this.color, stream)
|
|
126
|
+
? `${COLORS[level]}${line}${RESET}`
|
|
127
|
+
: line;
|
|
128
|
+
this.emit(stream, `${colored}\n`);
|
|
42
129
|
}
|
|
43
130
|
log(msg) {
|
|
44
131
|
this.logMessage('log', msg);
|
|
@@ -55,15 +142,20 @@ class Logger {
|
|
|
55
142
|
debug(msg) {
|
|
56
143
|
this.logMessage('debug', msg);
|
|
57
144
|
}
|
|
58
|
-
|
|
59
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Product output: the run summary, printed verbatim.
|
|
147
|
+
*
|
|
148
|
+
* Distinct from `info` because the summary is the thing the run exists to
|
|
149
|
+
* produce, not a note about producing it. Routing it through the log formatter
|
|
150
|
+
* prefixed every line with a timestamp and a level, which pushed the box
|
|
151
|
+
* borders out of alignment and made the headline artefact look broken.
|
|
152
|
+
*/
|
|
153
|
+
print(msg) {
|
|
154
|
+
this.emit(process.stdout, `${msg}\n`);
|
|
60
155
|
}
|
|
61
156
|
setLevel(level) {
|
|
62
157
|
this.currentLevel = level;
|
|
63
158
|
}
|
|
64
|
-
clear() {
|
|
65
|
-
this.logs = [];
|
|
66
|
-
}
|
|
67
159
|
}
|
|
68
160
|
exports.Logger = Logger;
|
|
69
161
|
exports.logger = new Logger();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run tasks with a bounded number in flight.
|
|
3
|
+
*
|
|
4
|
+
* `Promise.all` over a whole phase started every check at once — thirty-seven of
|
|
5
|
+
* them in the informational phase, many spawning heavy processes (cspell, jscpd,
|
|
6
|
+
* knip, dpdm, grype, tsc). On a two-core runner that is context-switch thrashing:
|
|
7
|
+
* the wall clock gets worse, not better, and a large monorepo can run the machine
|
|
8
|
+
* out of memory.
|
|
9
|
+
*
|
|
10
|
+
* Results come back in the order the tasks were given, so callers can pair them
|
|
11
|
+
* with their inputs.
|
|
12
|
+
*/
|
|
13
|
+
export declare function runPool<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<Array<T | undefined>>;
|
|
14
|
+
/**
|
|
15
|
+
* Default concurrency for this machine.
|
|
16
|
+
*
|
|
17
|
+
* One core is left for the orchestrator and whatever else the runner is doing,
|
|
18
|
+
* and the ceiling of four keeps a 32-core developer laptop from starting thirty
|
|
19
|
+
* subprocesses that then fight over disk. On the standard two-core CI runner this
|
|
20
|
+
* resolves to 1, which is the case the old code handled worst.
|
|
21
|
+
*/
|
|
22
|
+
export declare function defaultConcurrency(): number;
|
|
23
|
+
/** Resolve the client's `run.concurrency` setting into a number. */
|
|
24
|
+
export declare function resolveConcurrency(setting: number | 'auto' | 'unlimited' | undefined): number;
|
|
25
|
+
/**
|
|
26
|
+
* A counting semaphore, used to admit only a few expensive checks at a time.
|
|
27
|
+
*
|
|
28
|
+
* The overall limit is not enough on its own: four concurrent checks are fine
|
|
29
|
+
* when they are regex scans and ruinous when they are knip, jscpd, dpdm and tsc,
|
|
30
|
+
* each of which wants a core and a few hundred megabytes to itself.
|
|
31
|
+
*/
|
|
32
|
+
export declare class Semaphore {
|
|
33
|
+
private available;
|
|
34
|
+
private readonly waiting;
|
|
35
|
+
constructor(limit: number);
|
|
36
|
+
/** Run `fn` once a slot is free, releasing it even if `fn` throws. */
|
|
37
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
38
|
+
private acquire;
|
|
39
|
+
private release;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Race a promise against a timeout, resolving to `onTimeout` if it wins.
|
|
43
|
+
*
|
|
44
|
+
* A check that never returns used to hold the whole phase — and with it the run —
|
|
45
|
+
* open indefinitely. Note the underlying work is not killed: there is no portable
|
|
46
|
+
* way to do that here, so it is left to finish in the background while the run
|
|
47
|
+
* moves on and reports the timeout.
|
|
48
|
+
*/
|
|
49
|
+
export declare function withTimeout<T>(work: Promise<T>, timeoutMs: number, onTimeout: () => T): Promise<T>;
|
|
@@ -0,0 +1,124 @@
|
|
|
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.Semaphore = void 0;
|
|
7
|
+
exports.runPool = runPool;
|
|
8
|
+
exports.defaultConcurrency = defaultConcurrency;
|
|
9
|
+
exports.resolveConcurrency = resolveConcurrency;
|
|
10
|
+
exports.withTimeout = withTimeout;
|
|
11
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
12
|
+
/**
|
|
13
|
+
* Run tasks with a bounded number in flight.
|
|
14
|
+
*
|
|
15
|
+
* `Promise.all` over a whole phase started every check at once — thirty-seven of
|
|
16
|
+
* them in the informational phase, many spawning heavy processes (cspell, jscpd,
|
|
17
|
+
* knip, dpdm, grype, tsc). On a two-core runner that is context-switch thrashing:
|
|
18
|
+
* the wall clock gets worse, not better, and a large monorepo can run the machine
|
|
19
|
+
* out of memory.
|
|
20
|
+
*
|
|
21
|
+
* Results come back in the order the tasks were given, so callers can pair them
|
|
22
|
+
* with their inputs.
|
|
23
|
+
*/
|
|
24
|
+
async function runPool(tasks, limit) {
|
|
25
|
+
const bound = Math.max(1, Math.floor(limit));
|
|
26
|
+
const results = new Array(tasks.length);
|
|
27
|
+
let next = 0;
|
|
28
|
+
const worker = async () => {
|
|
29
|
+
while (next < tasks.length) {
|
|
30
|
+
const index = next++;
|
|
31
|
+
results[index] = await tasks[index]();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
await Promise.all(Array.from({ length: Math.min(bound, tasks.length) }, worker));
|
|
35
|
+
return results;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Default concurrency for this machine.
|
|
39
|
+
*
|
|
40
|
+
* One core is left for the orchestrator and whatever else the runner is doing,
|
|
41
|
+
* and the ceiling of four keeps a 32-core developer laptop from starting thirty
|
|
42
|
+
* subprocesses that then fight over disk. On the standard two-core CI runner this
|
|
43
|
+
* resolves to 1, which is the case the old code handled worst.
|
|
44
|
+
*/
|
|
45
|
+
function defaultConcurrency() {
|
|
46
|
+
const cores = node_os_1.default.cpus()?.length ?? 2;
|
|
47
|
+
return Math.max(1, Math.min(4, cores - 1));
|
|
48
|
+
}
|
|
49
|
+
/** Resolve the client's `run.concurrency` setting into a number. */
|
|
50
|
+
function resolveConcurrency(setting) {
|
|
51
|
+
const fromEnv = process.env.PR_CHECKMATE_CONCURRENCY;
|
|
52
|
+
const value = fromEnv ?? setting ?? 'auto';
|
|
53
|
+
if (value === 'unlimited')
|
|
54
|
+
return Number.MAX_SAFE_INTEGER;
|
|
55
|
+
if (value === 'auto')
|
|
56
|
+
return defaultConcurrency();
|
|
57
|
+
const parsed = typeof value === 'number' ? value : parseInt(value, 10);
|
|
58
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultConcurrency();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A counting semaphore, used to admit only a few expensive checks at a time.
|
|
62
|
+
*
|
|
63
|
+
* The overall limit is not enough on its own: four concurrent checks are fine
|
|
64
|
+
* when they are regex scans and ruinous when they are knip, jscpd, dpdm and tsc,
|
|
65
|
+
* each of which wants a core and a few hundred megabytes to itself.
|
|
66
|
+
*/
|
|
67
|
+
class Semaphore {
|
|
68
|
+
constructor(limit) {
|
|
69
|
+
this.waiting = [];
|
|
70
|
+
this.available = Math.max(1, limit);
|
|
71
|
+
}
|
|
72
|
+
/** Run `fn` once a slot is free, releasing it even if `fn` throws. */
|
|
73
|
+
async run(fn) {
|
|
74
|
+
await this.acquire();
|
|
75
|
+
try {
|
|
76
|
+
return await fn();
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
this.release();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async acquire() {
|
|
83
|
+
if (this.available > 0) {
|
|
84
|
+
this.available--;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
return new Promise(resolve => this.waiting.push(resolve));
|
|
88
|
+
}
|
|
89
|
+
release() {
|
|
90
|
+
const next = this.waiting.shift();
|
|
91
|
+
if (next)
|
|
92
|
+
next();
|
|
93
|
+
else
|
|
94
|
+
this.available++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
exports.Semaphore = Semaphore;
|
|
98
|
+
/**
|
|
99
|
+
* Race a promise against a timeout, resolving to `onTimeout` if it wins.
|
|
100
|
+
*
|
|
101
|
+
* A check that never returns used to hold the whole phase — and with it the run —
|
|
102
|
+
* open indefinitely. Note the underlying work is not killed: there is no portable
|
|
103
|
+
* way to do that here, so it is left to finish in the background while the run
|
|
104
|
+
* moves on and reports the timeout.
|
|
105
|
+
*/
|
|
106
|
+
async function withTimeout(work, timeoutMs, onTimeout) {
|
|
107
|
+
if (timeoutMs <= 0)
|
|
108
|
+
return work;
|
|
109
|
+
let timer;
|
|
110
|
+
try {
|
|
111
|
+
return await Promise.race([
|
|
112
|
+
work,
|
|
113
|
+
new Promise(resolve => {
|
|
114
|
+
timer = setTimeout(() => resolve(onTimeout()), timeoutMs);
|
|
115
|
+
// Do not keep the event loop alive purely for this timer.
|
|
116
|
+
timer.unref?.();
|
|
117
|
+
}),
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
if (timer)
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** True when the upgrade crosses a major version, i.e. behaviour may change. */
|
|
2
|
+
export declare function isMajorUpgrade(latest: string, current: string): boolean;
|
|
1
3
|
/**
|
|
2
4
|
* Best-effort check for a newer published version. Returns the notice lines when
|
|
3
5
|
* an update exists, or `null` (up-to-date, offline, timed out, or opted out).
|
|
@@ -6,5 +8,12 @@
|
|
|
6
8
|
* the caller can print them at the very end — after the summary — where they're
|
|
7
9
|
* noticeable. Deliberately called ONLY from the CLI (not `runChecks`) so
|
|
8
10
|
* programmatic consumers (e.g. a wrapping SDK) never trigger a network call.
|
|
11
|
+
*
|
|
12
|
+
* The notice is framed like the run summary so it is noticed, but it is now a
|
|
13
|
+
* one-liner: upgrading is just installing. It used to demand `npx pr-checkmate
|
|
14
|
+
* init` afterwards, because `init` copied the release's new defaults into the
|
|
15
|
+
* client's config — a step nobody could safely skip, for a file that should
|
|
16
|
+
* never have held those defaults in the first place. They are merged in when the
|
|
17
|
+
* config loads, so the second step no longer exists.
|
|
9
18
|
*/
|
|
10
19
|
export declare function getUpdateNotice(timeoutMs?: number): Promise<string[] | null>;
|
|
@@ -3,9 +3,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isMajorUpgrade = isMajorUpgrade;
|
|
6
7
|
exports.getUpdateNotice = getUpdateNotice;
|
|
7
8
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
// Imported from the module rather than the `../config` barrel on purpose:
|
|
11
|
+
// `config/index.ts` imports `../utils`, so going through the barrel would make
|
|
12
|
+
// utils ⇄ config circular. `constants.ts` itself pulls in nothing from utils.
|
|
13
|
+
const constants_1 = require("../config/constants");
|
|
14
|
+
// Same widths as the run summary in `reporters.ts`, so the two boxes line up
|
|
15
|
+
// when both are printed at the end of a run.
|
|
16
|
+
const NOTICE_BORDER = '═'.repeat(63);
|
|
17
|
+
const NOTICE_RULE = '─'.repeat(63);
|
|
9
18
|
/** Compare dotted versions; true when `latest` is strictly greater than `current`. */
|
|
10
19
|
function isNewer(latest, current) {
|
|
11
20
|
const a = latest.split('.').map(n => parseInt(n, 10) || 0);
|
|
@@ -16,6 +25,33 @@ function isNewer(latest, current) {
|
|
|
16
25
|
}
|
|
17
26
|
return false;
|
|
18
27
|
}
|
|
28
|
+
/** True when the upgrade crosses a major version, i.e. behaviour may change. */
|
|
29
|
+
function isMajorUpgrade(latest, current) {
|
|
30
|
+
return (parseInt(latest.split('.')[0], 10) || 0) > (parseInt(current.split('.')[0], 10) || 0);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Extra lines for a major upgrade. A patch bump is a routine "install it"; a
|
|
34
|
+
* major one can change what fails a build, and finding that out from a red PR is
|
|
35
|
+
* the wrong way to learn it. Says what to read and how to stage the rollout,
|
|
36
|
+
* because the answer to "everything went red" should arrive before it happens.
|
|
37
|
+
*/
|
|
38
|
+
function majorNotice(pkgName) {
|
|
39
|
+
return [
|
|
40
|
+
NOTICE_RULE,
|
|
41
|
+
' ⚠️ MAJOR VERSION — behaviour changes, read before upgrading',
|
|
42
|
+
'',
|
|
43
|
+
' Findings a check rates "error" now BLOCK the run. Previously they were',
|
|
44
|
+
' reported and the run still passed, so a repo with existing findings will',
|
|
45
|
+
' start failing its PRs.',
|
|
46
|
+
'',
|
|
47
|
+
' To stage it, make everything advisory first and promote checks back one',
|
|
48
|
+
' at a time — in pr-checkmate.json:',
|
|
49
|
+
' "severityDefaults": { "failOn": "warn" }',
|
|
50
|
+
' "severity": { "Security Scan": "error" }',
|
|
51
|
+
'',
|
|
52
|
+
` Full notes: https://www.npmjs.com/package/${pkgName}?activeTab=versions`,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
19
55
|
/**
|
|
20
56
|
* Best-effort check for a newer published version. Returns the notice lines when
|
|
21
57
|
* an update exists, or `null` (up-to-date, offline, timed out, or opted out).
|
|
@@ -24,6 +60,13 @@ function isNewer(latest, current) {
|
|
|
24
60
|
* the caller can print them at the very end — after the summary — where they're
|
|
25
61
|
* noticeable. Deliberately called ONLY from the CLI (not `runChecks`) so
|
|
26
62
|
* programmatic consumers (e.g. a wrapping SDK) never trigger a network call.
|
|
63
|
+
*
|
|
64
|
+
* The notice is framed like the run summary so it is noticed, but it is now a
|
|
65
|
+
* one-liner: upgrading is just installing. It used to demand `npx pr-checkmate
|
|
66
|
+
* init` afterwards, because `init` copied the release's new defaults into the
|
|
67
|
+
* client's config — a step nobody could safely skip, for a file that should
|
|
68
|
+
* never have held those defaults in the first place. They are merged in when the
|
|
69
|
+
* config loads, so the second step no longer exists.
|
|
27
70
|
*/
|
|
28
71
|
async function getUpdateNotice(timeoutMs = 2500) {
|
|
29
72
|
if (process.env.PR_CHECKMATE_NO_UPDATE_CHECK)
|
|
@@ -45,10 +88,21 @@ async function getUpdateNotice(timeoutMs = 2500) {
|
|
|
45
88
|
clearTimeout(timer);
|
|
46
89
|
}
|
|
47
90
|
if (latest && isNewer(latest, pkg.version)) {
|
|
91
|
+
const major = isMajorUpgrade(latest, pkg.version);
|
|
48
92
|
return [
|
|
49
|
-
|
|
50
|
-
`
|
|
51
|
-
|
|
93
|
+
NOTICE_BORDER,
|
|
94
|
+
` ⬆️ UPDATE AVAILABLE — ${pkg.name} ${pkg.version} → ${latest}`,
|
|
95
|
+
...(major ? majorNotice(pkg.name) : []),
|
|
96
|
+
NOTICE_RULE,
|
|
97
|
+
` Install: npm install ${pkg.name}@latest`,
|
|
98
|
+
'',
|
|
99
|
+
' That is the whole upgrade. New checks and new defaults apply on their',
|
|
100
|
+
' own — your pr-checkmate.json holds only what you changed, and nothing',
|
|
101
|
+
' in it needs topping up.',
|
|
102
|
+
NOTICE_RULE,
|
|
103
|
+
` 📚 Docs: ${constants_1.DOCS_URL}`,
|
|
104
|
+
` 📦 Release: https://www.npmjs.com/package/${pkg.name}`,
|
|
105
|
+
NOTICE_BORDER,
|
|
52
106
|
];
|
|
53
107
|
}
|
|
54
108
|
return null;
|