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,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
+ }
@@ -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,
@@ -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<void>;
18
+ export declare function report(kind: ReporterKind, result: CheckReport, cfg?: ReporterConfig): Promise<PostOutcome>;