pr-checkmate 1.23.18 → 1.23.20

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 CHANGED
@@ -10,7 +10,6 @@
10
10
  ![jscpd](https://img.shields.io/badge/jscpd-5-blue)
11
11
  [![API Docs](https://img.shields.io/badge/API_docs-TypeDoc-9600FF?logo=readthedocs&logoColor=white)](https://pr-checkmate-docs.pages.dev)
12
12
  [![Security scan](https://img.shields.io/badge/security-SBOM_%2B_VirusTotal-2ea44f)](https://github.com/Mybono/pr_checkmate_public/releases/latest)
13
- [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Mybono/pr_checkmate_public/badge)](https://scorecard.dev/viewer/?uri=github.com/Mybono/pr_checkmate_public)
14
13
 
15
14
  > PR CheckMate is a universal PR quality checker: one `npm install`, 51 checks across
16
15
  > 11 languages, running in CI on every pull request. The bundled languages need no
@@ -20,8 +19,6 @@ Every release is published with a CycloneDX SBOM and an independent
20
19
  [VirusTotal scan report](https://github.com/Mybono/pr_checkmate_public/releases/latest)
21
20
  of the exact npm tarball, attached to that version's
22
21
  [GitHub release notes](https://github.com/Mybono/pr_checkmate_public/releases/latest).
23
- The public repo also runs an [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/Mybono/pr_checkmate_public)
24
- analysis of its own supply-chain hygiene.
25
22
 
26
23
  ## Why PR CheckMate
27
24
 
package/cspell.json CHANGED
@@ -309,6 +309,7 @@
309
309
  "dont",
310
310
  "Dorg",
311
311
  "dotenv",
312
+ "dotenvx",
312
313
  "dotnet",
313
314
  "DPARALLEL",
314
315
  "dpdm",
@@ -343,6 +344,7 @@
343
344
  "endtable",
344
345
  "engineio",
345
346
  "ENOENT",
347
+ "ENOLOCK",
346
348
  "eoilist",
347
349
  "Escaneá",
348
350
  "Escaneás",
@@ -1235,23 +1237,23 @@
1235
1237
  "רטינול"
1236
1238
  ],
1237
1239
  "ignorePaths": [
1238
- "**/node_modules/**",
1239
- "**/dist/**",
1240
- "**/.git/**",
1241
- "**/build/**",
1242
- "**/coverage/**",
1243
- "**/.next/**",
1244
- "**/.nuxt/**",
1245
- "**/out/**",
1246
- "**/__pycache__/**",
1247
- "**/.pytest_cache/**",
1248
- "**/.venv/**",
1249
- "**/venv/**",
1250
- "**/package-lock.json",
1251
- "**/yarn.lock",
1252
- "**/pnpm-lock.yaml",
1253
- "**/*.min.js",
1254
- "**/*.min.css"
1240
+ "/**/node_modules/**",
1241
+ "/**/dist/**",
1242
+ "/**/.git/**",
1243
+ "/**/build/**",
1244
+ "/**/coverage/**",
1245
+ "/**/.next/**",
1246
+ "/**/.nuxt/**",
1247
+ "/**/out/**",
1248
+ "/**/__pycache__/**",
1249
+ "/**/.pytest_cache/**",
1250
+ "/**/.venv/**",
1251
+ "/**/venv/**",
1252
+ "/**/package-lock.json",
1253
+ "/**/yarn.lock",
1254
+ "/**/pnpm-lock.yaml",
1255
+ "/**/*.min.js",
1256
+ "/**/*.min.css"
1255
1257
  ],
1256
1258
  "enableFiletypes": ["typescript", "javascript", "markdown", "json"],
1257
1259
  "ignoreRegExpList": ["/https?:\\/\\/[^\\s]+/g", "/\\b[0-9a-f]{7,40}\\b/gi"]
@@ -251,6 +251,7 @@ exports.KNOWN_CONFIG_KEYS = new Set([
251
251
  'migrationSafety',
252
252
  // dependencies
253
253
  'dependency',
254
+ 'circularDeps',
254
255
  'outdatedDeps',
255
256
  'licenseCheck',
256
257
  'vulnScan',
@@ -1,33 +1,151 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.circularDepsCheck = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
4
9
  const config_1 = require("../../../config");
5
10
  const utils_1 = require("../../../utils");
6
11
  const execa_1 = require("../../../utils/execa");
7
12
  const targets_1 = require("../../targets");
8
13
  const types_1 = require("../../types");
14
+ const findings_1 = require("../findings");
15
+ /**
16
+ * Cap for the analysis. dpdm builds the whole module graph, which on a large repo
17
+ * is the slowest part of a run — an uncapped analysis looks like a hung commit.
18
+ */
19
+ const DEFAULT_TIMEOUT_MS = 120000;
20
+ /**
21
+ * Ceiling for the assembled argument list. Passing every tracked source file as a
22
+ * separate argument overflows the OS argument limit (`E2BIG`) on a large repo, and
23
+ * a spawn that never starts writes no report. Past the ceiling dpdm gets globs
24
+ * instead — it expands them itself, over the same source paths.
25
+ */
26
+ const MAX_ARGV_CHARS = 100000;
27
+ /** Extensions dpdm resolves for us; also the globs used in the fallback. */
28
+ const SOURCE_GLOBS = ['*.ts', '*.tsx', '*.js', '*.jsx'];
29
+ /**
30
+ * Resolve dpdm, preferring the consumer project's own copy (so a repo pinned to a
31
+ * different major keeps its own behaviour) and falling back to the copy bundled
32
+ * with pr-checkmate — the order `lint` and `typecheck` use for eslint/tsc.
33
+ *
34
+ * Returns null instead of throwing: an unresolvable analyzer is a skip, never a
35
+ * failed PR. This check used to shell out to `npx dpdm`, which silently fetched
36
+ * dpdm from the registry on first use — and when that fetch could not happen
37
+ * (offline or locked-down CI, private registry, exit 127) nothing was analyzed.
38
+ */
39
+ function resolveDpdm(cwd) {
40
+ for (const options of [{ paths: [cwd] }, undefined]) {
41
+ try {
42
+ return (0, utils_1.resolvePackageBin)('dpdm', 'lib/bin/dpdm.js', options);
43
+ }
44
+ catch {
45
+ // Try the next location; both failing is a skip, handled by the caller.
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+ /**
51
+ * Build the file arguments for dpdm, falling back to globs when the explicit list
52
+ * would overflow the OS argument limit. `sourcePaths` scopes the globs the same way
53
+ * `listTrackedFiles` scopes the explicit list.
54
+ */
55
+ function toFileArgs(files, sourcePaths) {
56
+ const explicitLength = files.reduce((sum, file) => sum + file.length + 1, 0);
57
+ if (explicitLength <= MAX_ARGV_CHARS)
58
+ return files;
59
+ const roots = sourcePaths.length > 0 ? sourcePaths : ['.'];
60
+ const globs = roots.flatMap(root => SOURCE_GLOBS.map(ext => `${root.replace(/\/+$/, '')}/**/${ext}`));
61
+ utils_1.logger.info(`[Circular Deps]: ${files.length} files exceed the argument limit — analyzing via globs (${globs.join(', ')})`);
62
+ return globs;
63
+ }
64
+ /** Read dpdm's report file; null when it is absent or unparseable. */
65
+ function readReport(reportPath) {
66
+ try {
67
+ return JSON.parse(node_fs_1.default.readFileSync(reportPath, 'utf-8'));
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
9
73
  exports.circularDepsCheck = {
10
74
  name: 'Circular Deps',
11
75
  phase: 'blocking',
12
76
  applies(ctx) {
77
+ if (ctx.config.circularDeps?.enabled === false)
78
+ return false;
13
79
  return ctx.languages.includes('typescript') || ctx.languages.includes('javascript');
14
80
  },
15
81
  async run(ctx) {
16
- const files = await (0, targets_1.listTrackedFiles)(ctx, ['*.ts', '*.tsx', '*.js', '*.jsx'], (0, config_1.getSourcePaths)());
17
- if (files.length === 0)
82
+ const allFiles = await (0, targets_1.listTrackedFiles)(ctx, SOURCE_GLOBS, (0, config_1.getSourcePaths)());
83
+ if (allFiles.length === 0)
18
84
  return (0, types_1.pass)();
19
- const result = await (0, execa_1.execa)('npx', ['dpdm', '--no-warning', '--no-tree', '--circular', ...files], {
20
- cwd: ctx.cwd,
21
- reject: false,
22
- });
23
- const output = (result.stdout + result.stderr).trim();
24
- // dpdm's success message also contains "circular" — match only its error shapes:
25
- // the "Circular Dependencies:" header or a numbered entry "[1] src/a.ts -> src/b.ts".
26
- const hasCircular = /circular dependencies:/i.test(output) || /^\s*\[\d+\]/m.test(output);
27
- if (hasCircular) {
28
- utils_1.logger.error(`[Circular Deps]: ❌ circular dependencies detected:\n${output}`);
29
- return (0, types_1.fail)('circular dependencies found');
85
+ // Files the client exempted (generated code, vendored bundles) are dropped
86
+ // before analysis — and again from the cycles below, since the graph can reach
87
+ // an ignored file by following imports from one that is analyzed.
88
+ const ignore = ctx.config.circularDeps?.ignore ?? [];
89
+ const files = ignore.length > 0 ? allFiles.filter(f => !(0, utils_1.matchesAnyGlob)(f, ignore)) : allFiles;
90
+ if (files.length === 0)
91
+ return (0, types_1.skip)('all files ignored');
92
+ const dpdmBin = resolveDpdm(ctx.cwd);
93
+ if (!dpdmBin) {
94
+ utils_1.logger.warn('[Circular Deps]: ⚠️ dpdm could not be resolved — skipping');
95
+ return (0, types_1.skip)('dpdm unavailable');
96
+ }
97
+ const timeout = ctx.config.circularDeps?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
98
+ const reportPath = node_path_1.default.join(ctx.cwd, '.dpdm.temp.json');
99
+ try {
100
+ // Read the cycles from dpdm's JSON report rather than its console output.
101
+ // The console shapes this check used to grep for ("Circular Dependencies:",
102
+ // "[1] a -> b") are not what dpdm 4 prints ("• Circular Dependencies",
103
+ // "1) a -> b"), so no cycle ever matched and the check passed every repo,
104
+ // including one built solely out of a cycle. `--exit-code circular:1` gives
105
+ // the same answer independently: dpdm otherwise exits 0 even when it finds
106
+ // cycles, so the exit code alone cannot tell a clean repo from a broken run.
107
+ const result = await (0, execa_1.execa)(process.execPath, [
108
+ dpdmBin,
109
+ '--no-warning',
110
+ '--no-tree',
111
+ '--no-circular',
112
+ '--exit-code',
113
+ 'circular:1',
114
+ '-o',
115
+ reportPath,
116
+ ...toFileArgs(files, (0, config_1.getSourcePaths)()),
117
+ ], { cwd: ctx.cwd, reject: false, timeout });
118
+ if (result.timedOut) {
119
+ utils_1.logger.warn(`[Circular Deps]: ⚠️ dpdm timed out after ${timeout}ms — skipping`);
120
+ return (0, types_1.skip)('dpdm timed out');
121
+ }
122
+ const report = readReport(reportPath);
123
+ // No report means dpdm never got far enough to write one (a tsconfig it
124
+ // cannot read, a file it cannot parse, a killed process). Skip: reporting
125
+ // that as "no cycles" hides a check that did not run, and failing on it
126
+ // blocks a PR that may have no cycle at all.
127
+ if (!report) {
128
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
129
+ utils_1.logger.warn(`[Circular Deps]: ⚠️ dpdm wrote no report (exit ${result.exitCode ?? 'killed'})${output ? `:\n${output}` : ''}`);
130
+ return (0, types_1.skip)(`dpdm failed to run (exit ${result.exitCode ?? 'killed'})`);
131
+ }
132
+ const cycles = (report.circulars ?? []).filter(cycle => ignore.length === 0 || !cycle.some(file => (0, utils_1.matchesAnyGlob)(file, ignore)));
133
+ if (cycles.length === 0)
134
+ return (0, types_1.pass)();
135
+ // Advisory by default. This check reported nothing for its whole existence
136
+ // (see the note above), so switching it on turns every long-standing cycle
137
+ // in every client repo into a finding at once — barrel files alone account
138
+ // for most of them. Warning first lets a repo see its cycles before they
139
+ // gate anything; promote with `circularDeps.severity: "error"`.
140
+ const sev = (0, findings_1.severityEmitter)(ctx.config.circularDeps?.severity, 'warn');
141
+ sev.log(`[Circular Deps]: ${sev.icon} ${cycles.length} circular dependency(ies):`);
142
+ cycles.slice(0, 10).forEach((cycle, i) => sev.log(` ${i + 1}) ${cycle.join(' -> ')}`));
143
+ if (cycles.length > 10)
144
+ sev.log(` ... and ${cycles.length - 10} more`);
145
+ return sev.emit(`${cycles.length} circular dependency(ies) found`);
146
+ }
147
+ finally {
148
+ (0, utils_1.cleanupTempFile)(reportPath);
30
149
  }
31
- return (0, types_1.pass)();
32
150
  },
33
151
  };
@@ -1,4 +1,17 @@
1
1
  import { Check } from '../../types';
2
+ /** One entry of knip's JSON reporter — a file plus the issues found for it. */
3
+ interface KnipIssue {
4
+ file: string;
5
+ dependencies?: {
6
+ name: string;
7
+ }[];
8
+ unlisted?: {
9
+ name: string;
10
+ }[];
11
+ }
12
+ interface KnipReport {
13
+ issues?: KnipIssue[];
14
+ }
2
15
  /**
3
16
  * Compile one `dependency.ignore` entry into a pattern knip matches the way this
4
17
  * config documents it — a glob over the whole dependency name.
@@ -11,4 +24,20 @@ import { Check } from '../../types';
11
24
  * only `eslint-…`, and keeps a plain name matching exactly that name.
12
25
  */
13
26
  export declare function toKnipIgnorePattern(entry: string): string;
27
+ /**
28
+ * Pull knip's report out of a stdout stream that may carry unrelated output.
29
+ *
30
+ * knip imports the project's own config files while analyzing (a `wdio.conf.ts`
31
+ * for its WebdriverIO plugin, say), so any top-level side effect in them —
32
+ * a dotenv banner, a `console.log` in a config module — is printed before the
33
+ * report. Slicing from the first `{` to the last `}` therefore captured that
34
+ * noise instead: a dotenvx tip ending in `{ path: '…/.env' }` made the whole
35
+ * check fail on a repo whose report was perfectly valid.
36
+ *
37
+ * The json reporter writes the report as a single line (`JSON.stringify` with no
38
+ * indentation), so the last line that parses AND carries `issues` is the report.
39
+ * Anything else on stdout is noise, whatever braces it happens to contain.
40
+ */
41
+ export declare function parseKnipReport(stdout: string): KnipReport | null;
14
42
  export declare const dependencyCheck: Check;
43
+ export {};
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.dependencyCheck = void 0;
7
7
  exports.toKnipIgnorePattern = toKnipIgnorePattern;
8
+ exports.parseKnipReport = parseKnipReport;
8
9
  const node_fs_1 = __importDefault(require("node:fs"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
10
11
  const config_1 = require("../../../config");
@@ -57,6 +58,37 @@ function hasPackageJsonConfig(cwd) {
57
58
  return false;
58
59
  }
59
60
  }
61
+ /**
62
+ * Pull knip's report out of a stdout stream that may carry unrelated output.
63
+ *
64
+ * knip imports the project's own config files while analyzing (a `wdio.conf.ts`
65
+ * for its WebdriverIO plugin, say), so any top-level side effect in them —
66
+ * a dotenv banner, a `console.log` in a config module — is printed before the
67
+ * report. Slicing from the first `{` to the last `}` therefore captured that
68
+ * noise instead: a dotenvx tip ending in `{ path: '…/.env' }` made the whole
69
+ * check fail on a repo whose report was perfectly valid.
70
+ *
71
+ * The json reporter writes the report as a single line (`JSON.stringify` with no
72
+ * indentation), so the last line that parses AND carries `issues` is the report.
73
+ * Anything else on stdout is noise, whatever braces it happens to contain.
74
+ */
75
+ function parseKnipReport(stdout) {
76
+ const lines = stdout.split('\n');
77
+ for (let i = lines.length - 1; i >= 0; i--) {
78
+ const line = lines[i].trim();
79
+ if (!line.startsWith('{'))
80
+ continue;
81
+ try {
82
+ const parsed = JSON.parse(line);
83
+ if (Array.isArray(parsed.issues))
84
+ return parsed;
85
+ }
86
+ catch {
87
+ // A noise line that merely looks like JSON — keep scanning upwards.
88
+ }
89
+ }
90
+ return null;
91
+ }
60
92
  /** Collect the names reported for one issue type across every file in the report. */
61
93
  function collectNames(issues, key) {
62
94
  return [...new Set(issues.flatMap(issue => (issue[key] ?? []).map(entry => entry.name)))];
@@ -122,18 +154,12 @@ exports.dependencyCheck = {
122
154
  cwd: ctx.cwd,
123
155
  reject: false,
124
156
  });
125
- const start = stdout.indexOf('{');
126
- const end = stdout.lastIndexOf('}');
127
- // No JSON at all → the tool couldn't produce a result; skip rather than fail the client's PR.
128
- if (start === -1 || end === -1)
129
- return (0, types_1.skip)('knip produced no output');
130
- let result;
131
- try {
132
- result = JSON.parse(stdout.slice(start, end + 1));
133
- }
134
- catch {
135
- return (0, types_1.fail)('knip produced invalid JSON');
136
- }
157
+ const result = parseKnipReport(stdout);
158
+ // No report on stdout → the tool couldn't produce a result; skip rather than
159
+ // fail the client's PR. Never fail here: a check that blocks a PR because a
160
+ // tool misbehaved reports our problem as the client's.
161
+ if (!result)
162
+ return (0, types_1.skip)('knip produced no report');
137
163
  const issues = result.issues ?? [];
138
164
  const unused = collectNames(issues, 'dependencies');
139
165
  const missing = collectNames(issues, 'unlisted');
@@ -41,7 +41,28 @@ exports.npmAuditCheck = {
41
41
  const ignore = new Set((ctx.config.security?.['npm-audit']?.ignore ?? []).map(name => name.toLowerCase()));
42
42
  const ownName = getOwnPackageName();
43
43
  const { stdout } = await (0, execa_1.execa)('npm', ['audit', '--json'], { cwd: ctx.cwd, reject: false });
44
- const report = JSON.parse(stdout);
44
+ // `npm audit` reports registry/lockfile problems as JSON on stdout, but not
45
+ // always: an absent npm (execa resolves with `code: 'ENOENT'`), a killed
46
+ // process or a proxy that answers with HTML all leave stdout unparseable.
47
+ // Parsing it bare made those cases throw, and a throw in a blocking check is
48
+ // a failed PR — the client saw "NPM Audit threw: Unexpected end of JSON
49
+ // input" and a red gate for an environment problem they did not cause.
50
+ let report;
51
+ try {
52
+ report = JSON.parse(stdout);
53
+ }
54
+ catch {
55
+ utils_1.logger.warn('[NPM Audit]: ⚠️ npm audit produced no parseable report — skipping');
56
+ return (0, types_1.skip)('npm audit produced no parseable report');
57
+ }
58
+ // A repo with no lockfile, or a registry npm cannot reach, yields
59
+ // `{"error":{"code":"ENOLOCK",…}}` — no `vulnerabilities` key at all. Passing
60
+ // on that reads as "no vulnerabilities" when nothing was actually audited.
61
+ if (report.error) {
62
+ const detail = report.error.summary ?? report.error.code ?? 'unknown error';
63
+ utils_1.logger.warn(`[NPM Audit]: ⚠️ npm could not audit this project — ${detail}`);
64
+ return (0, types_1.skip)(`npm audit unavailable: ${report.error.code ?? 'error'}`);
65
+ }
45
66
  const relevant = Object.values(report.vulnerabilities ?? {}).filter(v => v.isDirect && v.name !== ownName && isSeverityAtLeast(v.severity, auditLevel));
46
67
  const [ignored, directVulns] = relevant.reduce(([skip, block], v) => {
47
68
  (ignore.has(v.name.toLowerCase()) ? skip : block).push(v);
@@ -74,6 +74,15 @@ exports.lintCheck = {
74
74
  stdio: 'inherit',
75
75
  reject: false,
76
76
  });
77
- return result.exitCode === 0 ? (0, types_1.pass)() : (0, types_1.fail)('lint errors found');
77
+ if (result.exitCode === 0)
78
+ return (0, types_1.pass)();
79
+ // ESLint exits 1 for lint errors and 2 for a fatal error — a config it cannot
80
+ // load, an unresolvable plugin, a parser it cannot construct. Only the
81
+ // former is the client's code. Treating exit 2 as "lint errors found" turned
82
+ // a config incompatibility (often between our bundled config and the client's
83
+ // toolchain) into a red gate on their PR, and named the wrong cause.
84
+ if (result.exitCode === 1)
85
+ return (0, types_1.fail)('lint errors found');
86
+ return (0, types_1.warn)(`eslint failed to run (exit ${result.exitCode ?? 'killed'})${hasLocal ? '' : ' — using the pr-checkmate config'}`);
78
87
  },
79
88
  };
@@ -52,6 +52,13 @@ exports.typecheckCheck = {
52
52
  const errorCount = (output.match(/error TS\d+/g) ?? []).length;
53
53
  if (output)
54
54
  utils_1.logger.warn(output);
55
+ // A non-zero exit with no diagnostic in it is not a type error — tsc was
56
+ // killed (OOM on a large project), crashed, or never started. Reporting that
57
+ // as `0 type error(s)` blocked PRs with a message naming nothing to fix.
58
+ if (errorCount === 0) {
59
+ utils_1.logger.warn(`[TypeScript]: ⚠️ tsc exited ${result.exitCode ?? 'abnormally'} without reporting a type error — treating as a tooling failure, not a type error`);
60
+ return (0, types_1.warn)(`tsc failed to run (exit ${result.exitCode ?? 'killed'})`);
61
+ }
55
62
  return (0, types_1.fail)(`${errorCount} type error(s)`);
56
63
  },
57
64
  };
@@ -91,7 +91,16 @@ exports.markdownLintCheck = {
91
91
  reject: false,
92
92
  stdio: 'inherit',
93
93
  });
94
- return result.exitCode === 0 ? (0, types_1.pass)() : (0, types_1.warn)('markdown lint issues found');
94
+ if (result.exitCode === 0)
95
+ return (0, types_1.pass)();
96
+ // markdownlint-cli2 exits 1 for lint findings and 2 when it could not run
97
+ // (an unreadable config, a bad glob). Reporting the latter as "markdown lint
98
+ // issues found" names the wrong cause — and under `severity: error` it
99
+ // becomes a gate on a tooling problem.
100
+ if (result.exitCode === 2) {
101
+ return (0, types_1.warn)('markdownlint failed to run (exit 2) — check the markdownlint config');
102
+ }
103
+ return (0, types_1.warn)('markdown lint issues found');
95
104
  }
96
105
  finally {
97
106
  if (tempConfigFile && node_fs_1.default.existsSync(tempConfigFile))
@@ -1,2 +1,27 @@
1
+ import { CspellConfig } from '../../../interfaces';
1
2
  import { Check } from '../../types';
3
+ /**
4
+ * Anchors an ignore glob to the repository root.
5
+ *
6
+ * cspell matches `ignorePaths` against each file's ABSOLUTE path, so an unanchored
7
+ * `**\/build/**` also matches every ancestor directory of the workspace. On Bitbucket
8
+ * Pipelines the clone lives in `/opt/atlassian/pipelines/agent/build`, so that one
9
+ * pattern excluded the entire repository and the check silently spellchecked nothing.
10
+ * A leading `/` anchors the pattern to the config's directory (the repo root) while
11
+ * `/**\/dir/**` still matches at any depth INSIDE the repo — verified against cspell 10.
12
+ *
13
+ * Patterns that are already anchored (`/…`) or plainly relative (`dir/**`,
14
+ * `CHANGELOG.md`) are left untouched: they cannot match an ancestor.
15
+ */
16
+ export declare function anchorIgnoreGlob(pattern: string): string;
17
+ /**
18
+ * Folds the client's `cspell` block from pr-checkmate.json into the bundled
19
+ * defaults. `enabled` is deliberately not carried over — it gates the check
20
+ * itself (see `applies`) and is not a cspell setting.
21
+ *
22
+ * Lists are appended so a client extends the curated vocabulary instead of
23
+ * replacing it; `language` is the one exception and replaces the default, because
24
+ * a client writing `"en"` means exactly that and appending would be meaningless.
25
+ */
26
+ export declare function mergeCspellConfig(defaultConfig: CspellConfig, userConfig: Partial<CspellConfig>): CspellConfig;
2
27
  export declare const spellcheckCheck: Check;
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.spellcheckCheck = void 0;
7
+ exports.anchorIgnoreGlob = anchorIgnoreGlob;
8
+ exports.mergeCspellConfig = mergeCspellConfig;
7
9
  const node_fs_1 = __importDefault(require("node:fs"));
8
10
  const node_path_1 = __importDefault(require("node:path"));
9
11
  const config_1 = require("../../../config");
@@ -23,6 +25,47 @@ function loadUserCspellConfig(cwd) {
23
25
  return {};
24
26
  }
25
27
  }
28
+ /**
29
+ * Anchors an ignore glob to the repository root.
30
+ *
31
+ * cspell matches `ignorePaths` against each file's ABSOLUTE path, so an unanchored
32
+ * `**\/build/**` also matches every ancestor directory of the workspace. On Bitbucket
33
+ * Pipelines the clone lives in `/opt/atlassian/pipelines/agent/build`, so that one
34
+ * pattern excluded the entire repository and the check silently spellchecked nothing.
35
+ * A leading `/` anchors the pattern to the config's directory (the repo root) while
36
+ * `/**\/dir/**` still matches at any depth INSIDE the repo — verified against cspell 10.
37
+ *
38
+ * Patterns that are already anchored (`/…`) or plainly relative (`dir/**`,
39
+ * `CHANGELOG.md`) are left untouched: they cannot match an ancestor.
40
+ */
41
+ function anchorIgnoreGlob(pattern) {
42
+ return pattern.startsWith('**/') ? `/${pattern}` : pattern;
43
+ }
44
+ /**
45
+ * Folds the client's `cspell` block from pr-checkmate.json into the bundled
46
+ * defaults. `enabled` is deliberately not carried over — it gates the check
47
+ * itself (see `applies`) and is not a cspell setting.
48
+ *
49
+ * Lists are appended so a client extends the curated vocabulary instead of
50
+ * replacing it; `language` is the one exception and replaces the default, because
51
+ * a client writing `"en"` means exactly that and appending would be meaningless.
52
+ */
53
+ function mergeCspellConfig(defaultConfig, userConfig) {
54
+ const dictionaries = [...(defaultConfig.dictionaries ?? []), ...(userConfig.dictionaries ?? [])];
55
+ return {
56
+ ...defaultConfig,
57
+ language: userConfig.language || defaultConfig.language,
58
+ words: [...defaultConfig.words, ...(userConfig.words || [])],
59
+ // Client globs are anchored too — a repo's own pattern is meant to be relative
60
+ // to that repo, never to whatever directories sit above the checkout.
61
+ ignorePaths: [...defaultConfig.ignorePaths, ...(userConfig.ignorePaths || [])].map(anchorIgnoreGlob),
62
+ ignoreRegExpList: [...defaultConfig.ignoreRegExpList, ...(userConfig.ignoreRegExpList || [])],
63
+ // The bundled dictionaries arrive through `import`, so there is nothing to
64
+ // override here. cspell ignores names it cannot resolve, so a client typo
65
+ // costs coverage but never fails the run.
66
+ ...(dictionaries.length > 0 ? { dictionaries } : {}),
67
+ };
68
+ }
26
69
  function createTempCspellConfig(cwd) {
27
70
  const packageConfigPath = (0, config_1.getPackageConfigPath)('cspell.json');
28
71
  const packageConfigDir = node_path_1.default.dirname(packageConfigPath);
@@ -40,11 +83,8 @@ function createTempCspellConfig(cwd) {
40
83
  }
41
84
  });
42
85
  const mergedConfig = {
43
- ...defaultConfig,
86
+ ...mergeCspellConfig(defaultConfig, userConfig),
44
87
  import: resolvedImports,
45
- words: [...defaultConfig.words, ...(userConfig.words || [])],
46
- ignorePaths: [...defaultConfig.ignorePaths, ...(userConfig.ignorePaths || [])],
47
- ignoreRegExpList: [...defaultConfig.ignoreRegExpList, ...(userConfig.ignoreRegExpList || [])],
48
88
  };
49
89
  const tempConfigPath = node_path_1.default.join(cwd, '.cspell.temp.json');
50
90
  node_fs_1.default.writeFileSync(tempConfigPath, JSON.stringify(mergedConfig, null, 2));
@@ -53,26 +93,46 @@ function createTempCspellConfig(cwd) {
53
93
  exports.spellcheckCheck = {
54
94
  name: 'Spellcheck',
55
95
  phase: 'informational',
56
- applies() {
57
- return true;
96
+ /**
97
+ * Clients turn the check off with `cspell.enabled: false` (or the `spellcheck`
98
+ * alias the config interface uses). The universal `severity: { Spellcheck: "off" }`
99
+ * switch keeps working too — this is the per-check flag every other check exposes.
100
+ */
101
+ applies(ctx) {
102
+ return ctx.config.cspell?.enabled !== false && ctx.config.spellcheck?.enabled !== false;
58
103
  },
59
104
  async run(ctx) {
60
- const files = await (0, targets_1.resolveScopedTargetFiles)(ctx, ['*.ts', '*.tsx', '*.js', '*.jsx', '*.md', '*.mdx', '*.json'], (0, config_1.getSourcePaths)());
61
- if (files === null)
105
+ const targets = await (0, targets_1.resolveScopedTargetFiles)(ctx, ['*.ts', '*.tsx', '*.js', '*.jsx', '*.md', '*.mdx', '*.json'], (0, config_1.getSourcePaths)());
106
+ if (targets === null)
62
107
  return (0, types_1.skip)('git unavailable');
108
+ // Committed build output and test reports are dropped here, in JS, against
109
+ // repo-RELATIVE paths — a client cannot fix a typo in a generated file, so
110
+ // reporting one is noise that buries the real findings. Neither cspell
111
+ // mechanism can do this safely: `--exclude` ignores glob anchoring outright
112
+ // (`--exclude=/**/build/**` still matches an ancestor `build` dir, which on
113
+ // Bitbucket Pipelines is the checkout itself), and `ignorePaths` only reaches
114
+ // repos that have no cspell config of their own. Filtering the list keeps one
115
+ // rule for every repo. `ignoreDirs` already covers plain directory names via
116
+ // `resolveTargetFiles`; this adds the composite globs it cannot express
117
+ // (`**/cypress/videos/**`, `**/jest-html-reporters-*/**`).
118
+ const files = targets.filter(f => !(0, utils_1.matchesAnyGlob)(f, config_1.IGNORE_PATTERNS));
63
119
  if (files.length === 0)
64
120
  return (0, types_1.pass)('no files to spellcheck');
65
- const patterns = [...files, ...config_1.IGNORE_PATTERNS.map(p => `--exclude=${p}`)];
66
121
  const hasLocal = (0, config_1.hasLocalConfig)(['cspell.json', '.cspell.json', 'cspell.config.json']);
67
122
  let tempConfigPath = null;
68
123
  const cspellBin = (0, utils_1.resolvePackageBin)('cspell', 'bin.mjs');
124
+ // `--no-must-find-files`: cspell exits 1 when it checked nothing, which the
125
+ // exit-code mapping below would report as "spelling issues found" — a warning
126
+ // about problems that do not exist. Every file being excluded is a pass, not a
127
+ // finding.
128
+ const baseArgs = [...files, '--no-progress', '--no-must-find-files'];
69
129
  let args;
70
130
  if (hasLocal) {
71
- args = [...patterns, '--no-progress'];
131
+ args = baseArgs;
72
132
  }
73
133
  else {
74
134
  tempConfigPath = createTempCspellConfig(ctx.cwd);
75
- args = [...patterns, '--config', tempConfigPath, '--no-progress'];
135
+ args = [...baseArgs, '--config', tempConfigPath];
76
136
  }
77
137
  try {
78
138
  const result = await (0, execa_1.execa)(process.execPath, [cspellBin, ...args], {
@@ -17,7 +17,18 @@ exports.securityCheck = {
17
17
  return ctx.config.security?.gitleaks?.enabled !== false;
18
18
  },
19
19
  async run(ctx) {
20
- const gitleaksBin = (0, utils_1.resolvePackageBin)('gitleaks-secret-scanner-fixed', 'bin/cli.js');
20
+ // A binary that cannot be resolved throws, and a throw in a blocking check is
21
+ // a failed PR — the client would see "Security Scan threw: Cannot resolve
22
+ // binary" as a red gate on a repo with no secrets in it. Every other bundled
23
+ // tool (knip, license-checker) already skips here; this one did not.
24
+ let gitleaksBin;
25
+ try {
26
+ gitleaksBin = (0, utils_1.resolvePackageBin)('gitleaks-secret-scanner-fixed', 'bin/cli.js');
27
+ }
28
+ catch {
29
+ utils_1.logger.warn('[Security Scan]: ⚠️ bundled gitleaks scanner could not be resolved — skipping');
30
+ return (0, types_1.skip)('gitleaks unavailable');
31
+ }
21
32
  const hasClientConfig = (0, config_1.hasLocalConfig)([
22
33
  '.gitleaks.toml',
23
34
  'gitleaks.toml',
package/dist/init.js CHANGED
@@ -58,8 +58,14 @@ const DEFAULT_CONFIG = {
58
58
  prettier: {
59
59
  rules: {},
60
60
  },
61
- // Cspell customization
61
+ // Spellcheck (cspell). `enabled: false` turns the check off — that part always
62
+ // applies. The lists feed the cspell config pr-checkmate generates, so they are
63
+ // ignored when the repo has a cspell.json of its own; all of them are appended to
64
+ // the bundled defaults. `ignorePaths` takes globs to skip (e.g. "CHANGELOG.md",
65
+ // "generated/**"), anchored to the repo root so a pattern never matches a
66
+ // directory above the checkout.
62
67
  cspell: {
68
+ enabled: true,
63
69
  words: [],
64
70
  ignorePaths: [],
65
71
  ignoreRegExpList: [],
@@ -109,6 +115,19 @@ const DEFAULT_CONFIG = {
109
115
  // which knip can't see as imports and would report as unused).
110
116
  ignore: [],
111
117
  },
118
+ // Circular-import check (dpdm, bundled — nothing needed on the runner). Uses
119
+ // the project's own dpdm when it has one. Advisory by default (severity
120
+ // "warn"): most repos have long-standing cycles through barrel files, so see
121
+ // yours first, then set severity "error" to gate merges on them. enabled false
122
+ // disables the check; `ignore` takes glob patterns kept out of the analysis
123
+ // (generated or vendored code, or barrels whose cycles you tolerate). When the
124
+ // analyzer cannot run, the check skips rather than passing or failing.
125
+ circularDeps: {
126
+ enabled: true,
127
+ severity: 'warn',
128
+ ignore: [],
129
+ timeoutMs: 120000,
130
+ },
112
131
  // Unused-exports check (ts-unused-exports)
113
132
  deadCode: {
114
133
  enabled: true,
@@ -19,6 +19,8 @@ export interface CspellConfig {
19
19
  ignorePaths: string[];
20
20
  enableFiletypes: string[];
21
21
  ignoreRegExpList: string[];
22
+ dictionaries?: string[];
23
+ enabled?: boolean;
22
24
  }
23
25
  export interface PRCheckMateConfig {
24
26
  sourcePath: string | string[];
@@ -42,10 +44,19 @@ export interface PRCheckMateConfig {
42
44
  config?: Record<string, any>;
43
45
  };
44
46
  spellcheck?: {
47
+ enabled?: boolean;
45
48
  words?: string[];
46
49
  ignoreWords?: string[];
47
50
  ignorePaths?: string[];
48
51
  };
52
+ cspell?: {
53
+ enabled?: boolean;
54
+ words?: string[];
55
+ ignorePaths?: string[];
56
+ ignoreRegExpList?: string[];
57
+ dictionaries?: string[];
58
+ language?: string;
59
+ };
49
60
  security?: {
50
61
  gitleaks?: {
51
62
  enabled?: boolean;
@@ -204,6 +215,12 @@ export interface PRCheckMateConfig {
204
215
  severity?: 'error' | 'warn';
205
216
  ignore?: string[];
206
217
  };
218
+ circularDeps?: {
219
+ enabled?: boolean;
220
+ severity?: 'error' | 'warn';
221
+ ignore?: string[];
222
+ timeoutMs?: number;
223
+ };
207
224
  vulnScan?: {
208
225
  enabled?: boolean;
209
226
  timeoutMs?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-checkmate",
3
- "version": "1.23.18",
3
+ "version": "1.23.20",
4
4
  "description": "Automated PR quality checks: linting, formatting, dependency analysis, and spellcheck",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -83,6 +83,7 @@
83
83
  "@typescript-eslint/parser": "^8.62.1",
84
84
  "@wasm-fmt/clang-format": "^22.1.8",
85
85
  "cspell": "^10.0.1",
86
+ "dpdm": "^4.2.0",
86
87
  "eslint": "^10.6.0",
87
88
  "eslint-config-prettier": "^10.1.8",
88
89
  "eslint-plugin-import-x": "^4.17.1",
@@ -149,6 +149,18 @@
149
149
  "caseCollision": { "$ref": "#/$defs/toggle" },
150
150
  "sensitiveFileGuard": { "$ref": "#/$defs/toggle" },
151
151
  "configValidation": { "$ref": "#/$defs/toggle" },
152
+ "cspell": {
153
+ "description": "Spellcheck control. 'enabled: false' turns the check off. The other fields configure the cspell config pr-checkmate generates, so they apply only when the repository has no cspell.json of its own: 'words', 'ignorePaths', 'ignoreRegExpList' and 'dictionaries' are appended to the bundled defaults, 'language' replaces it. Ignore globs are anchored to the repository root, so '**/dist/**' never matches a directory above the checkout.",
154
+ "type": "object",
155
+ "properties": {
156
+ "enabled": { "type": "boolean" },
157
+ "language": { "type": "string" },
158
+ "words": { "type": "array", "items": { "type": "string" } },
159
+ "dictionaries": { "type": "array", "items": { "type": "string" } },
160
+ "ignorePaths": { "type": "array", "items": { "type": "string" } },
161
+ "ignoreRegExpList": { "type": "array", "items": { "type": "string" } }
162
+ }
163
+ },
152
164
  "yamlLint": {
153
165
  "type": "object",
154
166
  "properties": {
@@ -157,6 +169,15 @@
157
169
  "ignore": { "type": "array", "items": { "type": "string" } }
158
170
  }
159
171
  },
172
+ "circularDeps": {
173
+ "type": "object",
174
+ "properties": {
175
+ "enabled": { "type": "boolean" },
176
+ "severity": { "enum": ["error", "warn"] },
177
+ "ignore": { "type": "array", "items": { "type": "string" } },
178
+ "timeoutMs": { "type": "number" }
179
+ }
180
+ },
160
181
  "typecheck": { "$ref": "#/$defs/toggle" },
161
182
  "prBody": {
162
183
  "type": "object",
@@ -309,6 +309,7 @@
309
309
  "dont",
310
310
  "Dorg",
311
311
  "dotenv",
312
+ "dotenvx",
312
313
  "dotnet",
313
314
  "DPARALLEL",
314
315
  "dpdm",
@@ -343,6 +344,7 @@
343
344
  "endtable",
344
345
  "engineio",
345
346
  "ENOENT",
347
+ "ENOLOCK",
346
348
  "eoilist",
347
349
  "Escaneá",
348
350
  "Escaneás",
@@ -1235,23 +1237,23 @@
1235
1237
  "רטינול"
1236
1238
  ],
1237
1239
  "ignorePaths": [
1238
- "**/node_modules/**",
1239
- "**/dist/**",
1240
- "**/.git/**",
1241
- "**/build/**",
1242
- "**/coverage/**",
1243
- "**/.next/**",
1244
- "**/.nuxt/**",
1245
- "**/out/**",
1246
- "**/__pycache__/**",
1247
- "**/.pytest_cache/**",
1248
- "**/.venv/**",
1249
- "**/venv/**",
1250
- "**/package-lock.json",
1251
- "**/yarn.lock",
1252
- "**/pnpm-lock.yaml",
1253
- "**/*.min.js",
1254
- "**/*.min.css"
1240
+ "/**/node_modules/**",
1241
+ "/**/dist/**",
1242
+ "/**/.git/**",
1243
+ "/**/build/**",
1244
+ "/**/coverage/**",
1245
+ "/**/.next/**",
1246
+ "/**/.nuxt/**",
1247
+ "/**/out/**",
1248
+ "/**/__pycache__/**",
1249
+ "/**/.pytest_cache/**",
1250
+ "/**/.venv/**",
1251
+ "/**/venv/**",
1252
+ "/**/package-lock.json",
1253
+ "/**/yarn.lock",
1254
+ "/**/pnpm-lock.yaml",
1255
+ "/**/*.min.js",
1256
+ "/**/*.min.css"
1255
1257
  ],
1256
1258
  "enableFiletypes": ["typescript", "javascript", "markdown", "json"],
1257
1259
  "ignoreRegExpList": ["/https?:\\/\\/[^\\s]+/g", "/\\b[0-9a-f]{7,40}\\b/gi"]
@@ -149,6 +149,18 @@
149
149
  "caseCollision": { "$ref": "#/$defs/toggle" },
150
150
  "sensitiveFileGuard": { "$ref": "#/$defs/toggle" },
151
151
  "configValidation": { "$ref": "#/$defs/toggle" },
152
+ "cspell": {
153
+ "description": "Spellcheck control. 'enabled: false' turns the check off. The other fields configure the cspell config pr-checkmate generates, so they apply only when the repository has no cspell.json of its own: 'words', 'ignorePaths', 'ignoreRegExpList' and 'dictionaries' are appended to the bundled defaults, 'language' replaces it. Ignore globs are anchored to the repository root, so '**/dist/**' never matches a directory above the checkout.",
154
+ "type": "object",
155
+ "properties": {
156
+ "enabled": { "type": "boolean" },
157
+ "language": { "type": "string" },
158
+ "words": { "type": "array", "items": { "type": "string" } },
159
+ "dictionaries": { "type": "array", "items": { "type": "string" } },
160
+ "ignorePaths": { "type": "array", "items": { "type": "string" } },
161
+ "ignoreRegExpList": { "type": "array", "items": { "type": "string" } }
162
+ }
163
+ },
152
164
  "yamlLint": {
153
165
  "type": "object",
154
166
  "properties": {
@@ -157,6 +169,15 @@
157
169
  "ignore": { "type": "array", "items": { "type": "string" } }
158
170
  }
159
171
  },
172
+ "circularDeps": {
173
+ "type": "object",
174
+ "properties": {
175
+ "enabled": { "type": "boolean" },
176
+ "severity": { "enum": ["error", "warn"] },
177
+ "ignore": { "type": "array", "items": { "type": "string" } },
178
+ "timeoutMs": { "type": "number" }
179
+ }
180
+ },
160
181
  "typecheck": { "$ref": "#/$defs/toggle" },
161
182
  "prBody": {
162
183
  "type": "object",