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.
Files changed (101) hide show
  1. package/README.md +68 -15
  2. package/cspell.json +3 -8
  3. package/dist/cli.js +37 -62
  4. package/dist/config/constants.d.ts +21 -3
  5. package/dist/config/constants.js +33 -6
  6. package/dist/config/pr-checkmate-workflow.yml +33 -327
  7. package/dist/core/checks/dependencies/banned-imports.js +23 -24
  8. package/dist/core/checks/dependencies/circular-deps.js +1 -0
  9. package/dist/core/checks/dependencies/dependency-check.js +1 -0
  10. package/dist/core/checks/dependencies/grype-scan.js +1 -0
  11. package/dist/core/checks/dependencies/npm-audit.js +6 -1
  12. package/dist/core/checks/dependencies/vuln-scan.js +1 -0
  13. package/dist/core/checks/findings.d.ts +198 -19
  14. package/dist/core/checks/findings.js +474 -22
  15. package/dist/core/checks/git/custom-rules.js +21 -16
  16. package/dist/core/checks/git/diff-smell.d.ts +17 -0
  17. package/dist/core/checks/git/diff-smell.js +43 -24
  18. package/dist/core/checks/git/large-files.js +14 -2
  19. package/dist/core/checks/git/leftover-debug.js +23 -14
  20. package/dist/core/checks/git/lockfile-drift.js +2 -1
  21. package/dist/core/checks/git/merge-conflict.js +19 -8
  22. package/dist/core/checks/git/missing-tests.js +22 -6
  23. package/dist/core/checks/git/todo-fixme.js +10 -9
  24. package/dist/core/checks/languages/shellcheck.d.ts +8 -0
  25. package/dist/core/checks/languages/shellcheck.js +55 -0
  26. package/dist/core/checks/languages/typecheck.js +1 -0
  27. package/dist/core/checks/pr/pr-size-check.js +2 -1
  28. package/dist/core/checks/quality/config-validation.d.ts +14 -4
  29. package/dist/core/checks/quality/config-validation.js +119 -17
  30. package/dist/core/checks/quality/dead-code.js +1 -0
  31. package/dist/core/checks/quality/duplicate-check.js +1 -0
  32. package/dist/core/checks/quality/license-header.js +12 -2
  33. package/dist/core/checks/quality/spellcheck.d.ts +50 -0
  34. package/dist/core/checks/quality/spellcheck.js +141 -13
  35. package/dist/core/checks/quality/symlinks.d.ts +16 -0
  36. package/dist/core/checks/quality/symlinks.js +62 -0
  37. package/dist/core/checks/quality/yaml-lint.js +22 -9
  38. package/dist/core/checks/security/diff-security.js +111 -28
  39. package/dist/core/checks/security/dockerfile-security.js +23 -20
  40. package/dist/core/checks/security/migration-safety.js +19 -17
  41. package/dist/core/checks/security/sensitive-file-guard.js +37 -5
  42. package/dist/core/checks/security/workflow-security.d.ts +10 -0
  43. package/dist/core/checks/security/workflow-security.js +48 -26
  44. package/dist/core/commands.d.ts +27 -0
  45. package/dist/core/commands.js +91 -0
  46. package/dist/core/context.d.ts +12 -3
  47. package/dist/core/context.js +118 -16
  48. package/dist/core/install/cache.d.ts +39 -0
  49. package/dist/core/install/cache.js +99 -0
  50. package/dist/core/install/doctor.d.ts +35 -0
  51. package/dist/core/install/doctor.js +145 -0
  52. package/dist/core/install/ensure.d.ts +60 -0
  53. package/dist/core/install/ensure.js +169 -0
  54. package/dist/core/install/lock.d.ts +33 -0
  55. package/dist/core/install/lock.js +99 -0
  56. package/dist/core/install/profiles.d.ts +64 -0
  57. package/dist/core/install/profiles.js +170 -0
  58. package/dist/core/registry.js +4 -0
  59. package/dist/core/reporters.d.ts +13 -2
  60. package/dist/core/reporters.js +145 -104
  61. package/dist/core/run-checks.js +91 -14
  62. package/dist/core/targets.js +26 -4
  63. package/dist/core/types.d.ts +49 -1
  64. package/dist/init.d.ts +29 -14
  65. package/dist/init.js +204 -262
  66. package/dist/interfaces/index.d.ts +130 -0
  67. package/dist/scripts/generate-install-manifests.d.ts +39 -0
  68. package/dist/scripts/generate-install-manifests.js +121 -0
  69. package/dist/scripts/publish-github.js +18 -4
  70. package/dist/utils/detect-language.d.ts +37 -4
  71. package/dist/utils/detect-language.js +113 -19
  72. package/dist/utils/http.d.ts +59 -0
  73. package/dist/utils/http.js +119 -0
  74. package/dist/utils/logger.d.ts +52 -6
  75. package/dist/utils/logger.js +125 -33
  76. package/dist/utils/pool.d.ts +49 -0
  77. package/dist/utils/pool.js +124 -0
  78. package/dist/utils/update-notifier.d.ts +9 -0
  79. package/dist/utils/update-notifier.js +57 -3
  80. package/eslint.config.mjs +14 -2
  81. package/install-manifests/commitlint/package-lock.json +1028 -0
  82. package/install-manifests/commitlint/package.json +9 -0
  83. package/install-manifests/core/package-lock.json +1335 -0
  84. package/install-manifests/core/package.json +11 -0
  85. package/install-manifests/cpp/package-lock.json +29 -0
  86. package/install-manifests/cpp/package.json +8 -0
  87. package/install-manifests/duplicate/package-lock.json +113 -0
  88. package/install-manifests/duplicate/package.json +8 -0
  89. package/install-manifests/js/package-lock.json +5099 -0
  90. package/install-manifests/js/package.json +26 -0
  91. package/install-manifests/markdown/package-lock.json +1281 -0
  92. package/install-manifests/markdown/package.json +8 -0
  93. package/install-manifests/python/package-lock.json +21 -0
  94. package/install-manifests/python/package.json +8 -0
  95. package/install-manifests/spellcheck/package-lock.json +1047 -0
  96. package/install-manifests/spellcheck/package.json +8 -0
  97. package/package.json +13 -28
  98. package/pr-checkmate.schema.json +1070 -96
  99. package/src/config/cspell.json +3 -8
  100. package/src/config/pr-checkmate-workflow.yml +33 -327
  101. package/src/config/pr-checkmate.schema.json +1070 -96
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Outcome of one request: never throws, so a reporting hiccup cannot kill a run.
3
+ *
4
+ * Internal to this module — `utils/index.ts` does not re-export `http`, and the
5
+ * reporters consume the result inline rather than naming the type.
6
+ */
7
+ interface HttpResult<T = unknown> {
8
+ ok: boolean;
9
+ /** 0 when the request never reached a server (timeout, DNS, offline). */
10
+ status: number;
11
+ body?: T;
12
+ /** Human-readable reason, present whenever `ok` is false. */
13
+ error?: string;
14
+ }
15
+ export interface HttpOptions {
16
+ /** Per-attempt timeout. A hung API must not hold the process open. */
17
+ timeoutMs?: number;
18
+ /** Attempts for retryable failures (5xx, 429, network). 1 disables retrying. */
19
+ retries?: number;
20
+ /** Label used in log lines, e.g. `GitHub`. */
21
+ label?: string;
22
+ }
23
+ /**
24
+ * One HTTP request with a timeout and bounded retries, returning a result rather
25
+ * than throwing.
26
+ *
27
+ * The reason this exists: every write in the reporter used to be a bare `await
28
+ * fetch(...)` whose result was discarded, followed by a log line claiming
29
+ * success. A 403 from a workflow missing `pull-requests: write` produced
30
+ * "✅ Posted PR CheckMate summary" and no comment — the user had nothing to
31
+ * debug from.
32
+ */
33
+ export declare function request<T = unknown>(url: string, init?: RequestInit, opts?: HttpOptions): Promise<HttpResult<T>>;
34
+ /**
35
+ * Walk a paginated collection until `nextUrl` returns nothing or `maxPages` is
36
+ * reached, returning every item found.
37
+ *
38
+ * Without this the reporter read only the first page — 30 comments on GitHub, 20
39
+ * on GitLab — so on any PR with more discussion than that its own marker fell off
40
+ * the end, the upsert became an insert, and every push added another comment.
41
+ *
42
+ * Stopping at `maxPages` is logged rather than silent: a truncated search can
43
+ * only produce a duplicate comment, and that should be explainable.
44
+ */
45
+ export declare function paginate<T>(firstUrl: string, init: RequestInit, extract: (body: unknown) => T[],
46
+ /**
47
+ * URL of the page after this one, or null when the collection is exhausted.
48
+ * Receives the page's item count so page-number APIs (GitHub, GitLab) can stop
49
+ * on a short page, while cursor APIs (Bitbucket) read a link out of the body.
50
+ */
51
+ nextUrl: (body: unknown, page: number, count: number) => string | null, opts?: HttpOptions & {
52
+ maxPages?: number;
53
+ }): Promise<{
54
+ items: T[];
55
+ ok: boolean;
56
+ truncated: boolean;
57
+ error?: string;
58
+ }>;
59
+ export {};
@@ -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
+ }
@@ -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 getTimestamp;
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 colorize;
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
- getLogs(): string[];
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;
@@ -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
- getTimestamp() {
17
- return new Date().toISOString();
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 this.levelOrder[level] <= this.levelOrder[this.currentLevel];
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
- colorize(level, message) {
23
- const colors = {
24
- error: '\x1b[31m', // red
25
- warn: '\x1b[33m', // yellow
26
- info: '\x1b[32m', // green
27
- log: '\x1b[37m', // white
28
- debug: '\x1b[36m', // cyan
29
- };
30
- const reset = '\x1b[0m';
31
- return `${colors[level]}${message}${reset}`;
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 timestamp = this.getTimestamp();
37
- const fullMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}` +
38
- (error instanceof Error ? `\n${error.stack}` : '');
39
- this.logs.push(fullMessage);
40
- /* eslint-disable-next-line no-console */
41
- console.log(this.colorize(level, fullMessage));
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
- getLogs() {
59
- return this.logs;
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>;