pr-checkmate 1.23.18 → 1.23.19

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",
@@ -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))
@@ -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
@@ -109,6 +109,19 @@ const DEFAULT_CONFIG = {
109
109
  // which knip can't see as imports and would report as unused).
110
110
  ignore: [],
111
111
  },
112
+ // Circular-import check (dpdm, bundled — nothing needed on the runner). Uses
113
+ // the project's own dpdm when it has one. Advisory by default (severity
114
+ // "warn"): most repos have long-standing cycles through barrel files, so see
115
+ // yours first, then set severity "error" to gate merges on them. enabled false
116
+ // disables the check; `ignore` takes glob patterns kept out of the analysis
117
+ // (generated or vendored code, or barrels whose cycles you tolerate). When the
118
+ // analyzer cannot run, the check skips rather than passing or failing.
119
+ circularDeps: {
120
+ enabled: true,
121
+ severity: 'warn',
122
+ ignore: [],
123
+ timeoutMs: 120000,
124
+ },
112
125
  // Unused-exports check (ts-unused-exports)
113
126
  deadCode: {
114
127
  enabled: true,
@@ -204,6 +204,12 @@ export interface PRCheckMateConfig {
204
204
  severity?: 'error' | 'warn';
205
205
  ignore?: string[];
206
206
  };
207
+ circularDeps?: {
208
+ enabled?: boolean;
209
+ severity?: 'error' | 'warn';
210
+ ignore?: string[];
211
+ timeoutMs?: number;
212
+ };
207
213
  vulnScan?: {
208
214
  enabled?: boolean;
209
215
  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.19",
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",
@@ -157,6 +157,15 @@
157
157
  "ignore": { "type": "array", "items": { "type": "string" } }
158
158
  }
159
159
  },
160
+ "circularDeps": {
161
+ "type": "object",
162
+ "properties": {
163
+ "enabled": { "type": "boolean" },
164
+ "severity": { "enum": ["error", "warn"] },
165
+ "ignore": { "type": "array", "items": { "type": "string" } },
166
+ "timeoutMs": { "type": "number" }
167
+ }
168
+ },
160
169
  "typecheck": { "$ref": "#/$defs/toggle" },
161
170
  "prBody": {
162
171
  "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",
@@ -157,6 +157,15 @@
157
157
  "ignore": { "type": "array", "items": { "type": "string" } }
158
158
  }
159
159
  },
160
+ "circularDeps": {
161
+ "type": "object",
162
+ "properties": {
163
+ "enabled": { "type": "boolean" },
164
+ "severity": { "enum": ["error", "warn"] },
165
+ "ignore": { "type": "array", "items": { "type": "string" } },
166
+ "timeoutMs": { "type": "number" }
167
+ }
168
+ },
160
169
  "typecheck": { "$ref": "#/$defs/toggle" },
161
170
  "prBody": {
162
171
  "type": "object",