pr-checkmate 1.21.1 → 1.21.3

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
@@ -22,6 +22,11 @@
22
22
  * ✅ Secret scanning via `gitleaks`.
23
23
  * ✅ **Delta mode** — in GitHub PR context only changed files are checked, not the entire project.
24
24
  * ✅ **Parallel execution** — all independent checks run concurrently, cutting CI time ~4×.
25
+ * ✅ TypeScript type check — runs `tsc --noEmit`, catches type errors before merge. Skips if no `tsconfig.json` is found.
26
+ * ✅ Diff code smells — scans PR diff for `console.*()` calls, `debugger` statements, and `TODO/FIXME/HACK/XXX` comments left in added lines.
27
+ * ✅ PR description check — validates the PR body has at least 20 characters and includes a ticket reference (`Closes #123` or `PROJ-456` style).
28
+ * ✅ Duplicate code detection — uses `jscpd` in delta mode (changed files in PR, or `src/` otherwise) with a 5-line / 50-token minimum threshold.
29
+ * ✅ PR summary comment — after `npx pr-checkmate all`, posts a summary table to the CI log and upserts a comment on the PR. No spam on repeated pushes.
25
30
 
26
31
  <br>
27
32
  > ⚠️ **Note:** Currently, PR CheckMate only supports **Node.js projects**.
@@ -52,7 +57,7 @@ Users can override:
52
57
 
53
58
  User configuration is stored in:
54
59
 
55
- ```
60
+ ```text
56
61
  <project-root>/pr-checkmate.json
57
62
  ```
58
63
 
@@ -115,7 +120,7 @@ npx pr-checkmate <job>
115
120
 
116
121
  | Job | Description |
117
122
  |----------------|-------------|
118
- | `all` | Run all checks in parallel (lint, deps, audit, security, circular, license, then spellcheck, pr-size, pr-title, outdated, markdown, then prettier) |
123
+ | `all` | Run all checks in parallel (lint, deps, audit, security, circular, license, then spellcheck, pr-size, pr-title, outdated, markdown, typecheck, diff-smell, pr-body, duplicate, then prettier). Posts a PR summary comment when run in a GitHub Actions PR context. |
119
124
  | `lint` | Lint code using ESLint |
120
125
  | `prettier` | Format code using Prettier |
121
126
  | `deps` | Check unused / missing dependencies via depcheck |
@@ -128,12 +133,16 @@ npx pr-checkmate <job>
128
133
  | `pr-title` | Validate PR title against Conventional Commits format |
129
134
  | `outdated` | Report packages that are major versions behind |
130
135
  | `markdown` | Lint `.md`/`.mdx` files via markdownlint (warning-only, delta mode) |
136
+ | `typecheck` | Run `tsc --noEmit` to catch type errors (blocking). Skips automatically if no `tsconfig.json` is found. |
137
+ | `diff-smell` | Scan PR diff for `console.*()`, `debugger`, and `TODO/FIXME/HACK/XXX` in added lines (warning-only, delta mode) |
138
+ | `pr-body` | Check PR description for minimum length (20 chars) and a ticket reference (warning-only) |
139
+ | `duplicate` | Detect duplicate code with `jscpd` — min 5 lines / 50 tokens (warning-only, delta mode in PR context) |
131
140
 
132
141
  <br>
133
142
 
134
143
  ### ⚡ Delta Mode
135
144
 
136
- In a GitHub Actions PR context, `lint`, `prettier`, `spellcheck`, and `markdown` automatically switch to **delta mode** — only the files changed in the PR are checked instead of the entire project. This is transparent and requires no configuration.
145
+ In a GitHub Actions PR context, `lint`, `prettier`, `spellcheck`, `markdown`, `diff-smell`, and `duplicate` automatically switch to **delta mode** — only the files changed in the PR are checked instead of the entire project. This is transparent and requires no configuration.
137
146
 
138
147
  Outside of a PR context (local runs, push workflows) the checks fall back to the full project scan.
139
148
 
@@ -324,6 +333,30 @@ For the auto-commit feature to work, ensure your repository has the correct perm
324
333
 
325
334
  <br>
326
335
 
336
+ ### PR summary comment
337
+
338
+ After `npx pr-checkmate all` finishes, it prints a summary table to the CI log and posts a comment on the PR. On repeated pushes the comment is updated in place — no duplicate comments accumulate.
339
+
340
+ The comment looks like this:
341
+
342
+ ```text
343
+ ## PR CheckMate Summary
344
+
345
+ **11 passed** · **3 warnings** · **0 failed** · **0 skipped**
346
+
347
+ | Check | Result |
348
+ |---|---|
349
+ | ESLint | pass |
350
+ | TypeScript | pass |
351
+ | Diff Smells | warn — 2x console.*(), 1x TODO comment |
352
+ | PR Body | warn — no ticket reference |
353
+ ...
354
+ ```
355
+
356
+ This requires `GITHUB_TOKEN` with `pull-requests: write` permission. The auto-generated workflow already includes both — no extra setup is needed.
357
+
358
+ <br>
359
+
327
360
  ## 🌍 Multilingual Spellcheck Support
328
361
 
329
362
  PR CheckMate includes built-in dictionaries for:
@@ -84,6 +84,10 @@ exports.DEFAULT_COMMANDS = {
84
84
  'npx pr-checkmate npm-audit': 'Run package vulnerabilities check',
85
85
  'npx pr-checkmate security': 'Security scan',
86
86
  'npx pr-checkmate spellcheck': 'Run spellcheck via cspell',
87
+ 'npx pr-checkmate typecheck': 'TypeScript type check (tsc --noEmit)',
88
+ 'npx pr-checkmate diff-smell': 'Scan git diff for code smells',
89
+ 'npx pr-checkmate pr-body': 'Validate PR description',
90
+ 'npx pr-checkmate duplicate': 'Detect duplicate code blocks (jscpd)',
87
91
  };
88
92
  exports.documentation = {
89
93
  eslint: 'https://eslint.org/docs/latest/rules/',
@@ -54,6 +54,10 @@ function mergeWithDefaults(userConfig) {
54
54
  ],
55
55
  },
56
56
  security: userConfig.security || defaults.security,
57
+ typecheck: { ...defaults.typecheck, ...userConfig.typecheck },
58
+ diffSmell: { ...defaults.diffSmell, ...userConfig.diffSmell },
59
+ prBody: { ...defaults.prBody, ...userConfig.prBody },
60
+ duplicate: { ...defaults.duplicate, ...userConfig.duplicate },
57
61
  };
58
62
  }
59
63
  /**
@@ -110,6 +114,10 @@ function getDefaultConfig() {
110
114
  gitleaks: { enabled: true },
111
115
  'npm-audit': { enabled: true, auditLevel: 'moderate' },
112
116
  },
117
+ typecheck: { enabled: true },
118
+ diffSmell: { enabled: true },
119
+ prBody: { enabled: true, minLength: 20, requireTicket: true },
120
+ duplicate: { enabled: true, minLines: 5, minTokens: 50 },
113
121
  };
114
122
  }
115
123
  /**
@@ -263,3 +263,91 @@ jobs:
263
263
  - name: Run Markdown Lint
264
264
  run: npx pr-checkmate markdown
265
265
  continue-on-error: true
266
+
267
+ typecheck:
268
+ name: TypeScript Type Check
269
+ runs-on: ubuntu-latest
270
+ steps:
271
+ - name: Checkout code
272
+ uses: actions/checkout@v4
273
+ with:
274
+ ref: ${{ github.head_ref }}
275
+ fetch-depth: 0
276
+
277
+ - name: Setup Node.js
278
+ uses: actions/setup-node@v4
279
+ with:
280
+ node-version: 20
281
+
282
+ - name: Install dependencies
283
+ run: npm ci
284
+
285
+ - name: Run TypeScript Type Check
286
+ run: npx pr-checkmate typecheck
287
+ continue-on-error: true
288
+
289
+ diff-smell:
290
+ name: Diff Code Smell
291
+ runs-on: ubuntu-latest
292
+ steps:
293
+ - name: Checkout code
294
+ uses: actions/checkout@v4
295
+ with:
296
+ ref: ${{ github.head_ref }}
297
+ fetch-depth: 0
298
+
299
+ - name: Setup Node.js
300
+ uses: actions/setup-node@v4
301
+ with:
302
+ node-version: 20
303
+
304
+ - name: Install dependencies
305
+ run: npm ci
306
+
307
+ - name: Scan Diff for Code Smells
308
+ run: npx pr-checkmate diff-smell
309
+ continue-on-error: true
310
+
311
+ pr-body:
312
+ name: PR Description
313
+ runs-on: ubuntu-latest
314
+ steps:
315
+ - name: Checkout code
316
+ uses: actions/checkout@v4
317
+ with:
318
+ ref: ${{ github.head_ref }}
319
+ fetch-depth: 0
320
+
321
+ - name: Setup Node.js
322
+ uses: actions/setup-node@v4
323
+ with:
324
+ node-version: 20
325
+
326
+ - name: Install dependencies
327
+ run: npm ci
328
+
329
+ - name: Check PR Description
330
+ run: npx pr-checkmate pr-body
331
+ continue-on-error: true
332
+
333
+ duplicate-check:
334
+ name: Duplicate Code
335
+ runs-on: ubuntu-latest
336
+ steps:
337
+ - name: Checkout code
338
+ uses: actions/checkout@v4
339
+ with:
340
+ ref: ${{ github.head_ref }}
341
+ fetch-depth: 0
342
+
343
+ - name: Setup Node.js
344
+ uses: actions/setup-node@v4
345
+ with:
346
+ node-version: 20
347
+
348
+ - name: Install dependencies
349
+ run: npm ci
350
+
351
+ - name: Check for Duplicate Code
352
+ run: npx pr-checkmate duplicate
353
+ continue-on-error: true
package/dist/init.js CHANGED
@@ -37,6 +37,26 @@ const DEFAULT_CONFIG = {
37
37
  rules: {},
38
38
  ignores: [],
39
39
  },
40
+ // TypeScript type check
41
+ typecheck: {
42
+ enabled: true,
43
+ },
44
+ // Diff code smell detection
45
+ diffSmell: {
46
+ enabled: true,
47
+ },
48
+ // PR description validation
49
+ prBody: {
50
+ enabled: true,
51
+ minLength: 20,
52
+ requireTicket: true,
53
+ },
54
+ // Duplicate code detection (jscpd)
55
+ duplicate: {
56
+ enabled: true,
57
+ minLines: 5,
58
+ minTokens: 50,
59
+ },
40
60
  documentation: config_1.documentation,
41
61
  };
42
62
  async function init() {
@@ -117,6 +137,10 @@ async function init() {
117
137
  eslint: { ...DEFAULT_CONFIG.eslint, ...(json.eslint || {}) },
118
138
  prettier: { ...DEFAULT_CONFIG.prettier, ...(json.prettier || {}) },
119
139
  cspell: { ...DEFAULT_CONFIG.cspell, ...(json.cspell || {}) },
140
+ typecheck: { ...DEFAULT_CONFIG.typecheck, ...(json.typecheck || {}) },
141
+ diffSmell: { ...DEFAULT_CONFIG.diffSmell, ...(json.diffSmell || {}) },
142
+ prBody: { ...DEFAULT_CONFIG.prBody, ...(json.prBody || {}) },
143
+ duplicate: { ...DEFAULT_CONFIG.duplicate, ...(json.duplicate || {}) },
120
144
  };
121
145
  fs_1.default.writeFileSync(exports.CONFIG_FILE, JSON.stringify(updated, null, 2));
122
146
  utils_1.logger.info('[init]: 🔧 Updated pr-checkmate.json with customization options.');
@@ -0,0 +1,97 @@
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.runDiffSmell = runDiffSmell;
7
+ const execa_1 = require("execa");
8
+ const utils_1 = require("../utils");
9
+ const result_collector_1 = require("../utils/result-collector");
10
+ const loader_1 = require("../config/loader");
11
+ const node_fs_1 = __importDefault(require("node:fs"));
12
+ const SMELL_PATTERNS = [
13
+ // console.* calls added in diff (not in comments)
14
+ { pattern: /^\+(?!\+)(?:[^/]|\/[^/]).*\bconsole\.(log|error|warn|debug|info)\s*\(/, label: 'console.*()' },
15
+ { pattern: /^\+(?!\+).*\bdebugger\b/, label: 'debugger statement' },
16
+ { pattern: /^\+\s*\/\/\s*TODO\b/i, label: 'TODO comment' },
17
+ { pattern: /^\+\s*\/\/\s*FIXME\b/i, label: 'FIXME comment' },
18
+ { pattern: /^\+\s*\/\/\s*HACK\b/i, label: 'HACK comment' },
19
+ { pattern: /^\+\s*\/\/\s*XXX\b/i, label: 'XXX marker' },
20
+ ];
21
+ function readBaseSha() {
22
+ const eventName = process.env.GITHUB_EVENT_NAME;
23
+ const eventPath = process.env.GITHUB_EVENT_PATH;
24
+ if (eventName !== 'pull_request' || !eventPath || !node_fs_1.default.existsSync(eventPath)) {
25
+ return null;
26
+ }
27
+ try {
28
+ const event = JSON.parse(node_fs_1.default.readFileSync(eventPath, 'utf-8'));
29
+ return event?.pull_request?.base?.sha ?? null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ async function runDiffSmell() {
36
+ const cfg = (0, loader_1.loadProjectConfig)();
37
+ if (cfg.diffSmell?.enabled === false) {
38
+ utils_1.logger.info('[runDiffSmell]: Disabled in pr-checkmate.json, skipping');
39
+ result_collector_1.resultCollector.register('Diff Smells', 'skip');
40
+ return;
41
+ }
42
+ utils_1.logger.info('[runDiffSmell]: Scanning diff for code smells...');
43
+ const baseSha = readBaseSha();
44
+ if (!baseSha) {
45
+ utils_1.logger.info('[runDiffSmell]: Not in GitHub PR context, skipping');
46
+ result_collector_1.resultCollector.register('Diff Smells', 'skip');
47
+ return;
48
+ }
49
+ try {
50
+ const { stdout } = await (0, execa_1.execa)('git', [
51
+ 'diff',
52
+ '--unified=0',
53
+ '--diff-filter=ACMR',
54
+ baseSha,
55
+ 'HEAD',
56
+ '--',
57
+ '*.ts',
58
+ '*.tsx',
59
+ '*.js',
60
+ '*.jsx',
61
+ ]);
62
+ const lines = stdout.split('\n');
63
+ const findings = {};
64
+ for (const line of lines) {
65
+ for (const { pattern, label } of SMELL_PATTERNS) {
66
+ if (pattern.test(line)) {
67
+ if (!findings[label])
68
+ findings[label] = [];
69
+ // Strip leading + and trim for display
70
+ findings[label].push(line.slice(1).trim().substring(0, 100));
71
+ }
72
+ }
73
+ }
74
+ const totalFindings = Object.values(findings).reduce((sum, arr) => sum + arr.length, 0);
75
+ if (totalFindings === 0) {
76
+ utils_1.logger.info('[runDiffSmell]: ✅ No code smells detected in diff');
77
+ result_collector_1.resultCollector.register('Diff Smells', 'pass');
78
+ return;
79
+ }
80
+ utils_1.logger.warn(`[runDiffSmell]: ⚠️ Found ${totalFindings} code smell(s) in diff:`);
81
+ for (const [label, occurrences] of Object.entries(findings)) {
82
+ utils_1.logger.warn(` ${label}: ${occurrences.length} occurrence(s)`);
83
+ occurrences.slice(0, 3).forEach(line => utils_1.logger.warn(` ${line}`));
84
+ if (occurrences.length > 3) {
85
+ utils_1.logger.warn(` ... and ${occurrences.length - 3} more`);
86
+ }
87
+ }
88
+ const summary = Object.entries(findings)
89
+ .map(([label, arr]) => `${arr.length}× ${label}`)
90
+ .join(', ');
91
+ result_collector_1.resultCollector.register('Diff Smells', 'warn', summary);
92
+ }
93
+ catch (err) {
94
+ utils_1.logger.warn(`[runDiffSmell]: ⚠️ Could not analyze diff\n${err}`);
95
+ result_collector_1.resultCollector.register('Diff Smells', 'skip');
96
+ }
97
+ }
@@ -0,0 +1,67 @@
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.runDuplicateCheck = runDuplicateCheck;
7
+ const execa_1 = require("execa");
8
+ const utils_1 = require("../utils");
9
+ const result_collector_1 = require("../utils/result-collector");
10
+ const loader_1 = require("../config/loader");
11
+ const delta_1 = require("../utils/delta");
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const node_fs_1 = __importDefault(require("node:fs"));
14
+ async function runDuplicateCheck() {
15
+ const cfg = (0, loader_1.loadProjectConfig)();
16
+ if (cfg.duplicate?.enabled === false) {
17
+ utils_1.logger.info('[runDuplicateCheck]: Disabled in pr-checkmate.json, skipping');
18
+ result_collector_1.resultCollector.register('Duplicate Code', 'skip');
19
+ return;
20
+ }
21
+ const minLines = cfg.duplicate?.minLines ?? 5;
22
+ const minTokens = cfg.duplicate?.minTokens ?? 50;
23
+ utils_1.logger.info('[runDuplicateCheck]: Checking for duplicate code...');
24
+ const projectRoot = process.cwd();
25
+ const changedFiles = await (0, delta_1.getChangedFiles)(['.ts', '.tsx', '.js', '.jsx']);
26
+ // In PR context but no relevant files changed
27
+ if (changedFiles !== null && changedFiles.length === 0) {
28
+ utils_1.logger.info('[runDuplicateCheck]: No relevant files changed, skipping');
29
+ result_collector_1.resultCollector.register('Duplicate Code', 'skip');
30
+ return;
31
+ }
32
+ // Fall back to src/ or project root when not in delta mode
33
+ const srcPath = node_path_1.default.join(projectRoot, 'src');
34
+ const targetPaths = changedFiles ?? (node_fs_1.default.existsSync(srcPath) ? [srcPath] : [projectRoot]);
35
+ try {
36
+ const result = await (0, execa_1.execa)('npx', [
37
+ 'jscpd',
38
+ '--min-lines',
39
+ String(minLines),
40
+ '--min-tokens',
41
+ String(minTokens),
42
+ '--reporters',
43
+ 'console',
44
+ '--ignore',
45
+ 'node_modules/**,dist/**,.git/**',
46
+ '--quiet',
47
+ ...targetPaths,
48
+ ], { reject: false });
49
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n');
50
+ const clonesMatch = output.match(/(\d+)\s+clone/i);
51
+ const cloneCount = clonesMatch ? parseInt(clonesMatch[1], 10) : 0;
52
+ if (cloneCount > 0) {
53
+ utils_1.logger.warn(`[runDuplicateCheck]: ⚠️ Found ${cloneCount} duplicate block(s)`);
54
+ if (output)
55
+ utils_1.logger.warn(output);
56
+ result_collector_1.resultCollector.register('Duplicate Code', 'warn', `${cloneCount} duplicate block(s)`);
57
+ }
58
+ else {
59
+ utils_1.logger.info('[runDuplicateCheck]: ✅ No significant code duplication found');
60
+ result_collector_1.resultCollector.register('Duplicate Code', 'pass');
61
+ }
62
+ }
63
+ catch (err) {
64
+ utils_1.logger.warn(`[runDuplicateCheck]: ⚠️ Could not run duplicate check\n${err}`);
65
+ result_collector_1.resultCollector.register('Duplicate Code', 'skip');
66
+ }
67
+ }
@@ -26,7 +26,13 @@ const pr_size_check_1 = require("./pr-size-check");
26
26
  const pr_title_lint_1 = require("./pr-title-lint");
27
27
  const outdated_deps_1 = require("./outdated-deps");
28
28
  const markdown_lint_1 = require("./markdown-lint");
29
+ const typecheck_1 = require("./typecheck");
30
+ const diff_smell_1 = require("./diff-smell");
31
+ const pr_body_1 = require("./pr-body");
32
+ const duplicate_check_1 = require("./duplicate-check");
33
+ const pr_summary_1 = require("./pr-summary");
29
34
  const utils_1 = require("../utils");
35
+ const result_collector_1 = require("../utils/result-collector");
30
36
  const lint_1 = require("./lint");
31
37
  __exportStar(require("./dependency-check"), exports);
32
38
  __exportStar(require("./lint"), exports);
@@ -39,35 +45,69 @@ __exportStar(require("./pr-size-check"), exports);
39
45
  __exportStar(require("./pr-title-lint"), exports);
40
46
  __exportStar(require("./outdated-deps"), exports);
41
47
  __exportStar(require("./markdown-lint"), exports);
48
+ __exportStar(require("./typecheck"), exports);
49
+ __exportStar(require("./diff-smell"), exports);
50
+ __exportStar(require("./pr-body"), exports);
51
+ __exportStar(require("./duplicate-check"), exports);
52
+ __exportStar(require("./pr-summary"), exports);
53
+ /**
54
+ * Wraps a blocking check: registers 'pass' on success, 'fail' on throw (and re-throws).
55
+ */
56
+ async function trackCheck(name, fn) {
57
+ try {
58
+ await fn();
59
+ result_collector_1.resultCollector.register(name, 'pass');
60
+ }
61
+ catch (err) {
62
+ const msg = err instanceof Error ? err.message.split('\n')[0].substring(0, 120) : String(err);
63
+ result_collector_1.resultCollector.register(name, 'fail', msg);
64
+ throw err;
65
+ }
66
+ }
67
+ /**
68
+ * Wraps an informational check: registers 'pass' on success, 'warn' on throw (never re-throws).
69
+ */
70
+ async function trackSoftCheck(name, fn) {
71
+ try {
72
+ await fn();
73
+ result_collector_1.resultCollector.register(name, 'pass');
74
+ }
75
+ catch (err) {
76
+ const msg = err instanceof Error ? err.message.split('\n')[0].substring(0, 120) : String(err);
77
+ result_collector_1.resultCollector.register(name, 'warn', msg);
78
+ }
79
+ }
42
80
  async function runJob(jobName) {
43
81
  utils_1.logger.info(`[runJob]: 🚀 Running job: ${jobName}`);
44
82
  try {
45
83
  switch (jobName) {
46
84
  case 'all':
47
- // Phase 1: blocking checks in parallel
85
+ // Phase 1: blocking quality gates — run in parallel, fail fast on any throw
48
86
  await Promise.all([
49
- (0, lint_1.runLint)(),
50
- (0, dependency_check_1.runDependencyCheck)(),
51
- (0, npm_audit_1.runNpmAudit)(),
52
- (0, security_1.runSecurityScan)(),
53
- (0, circular_deps_1.runCircularDeps)(),
54
- (0, license_check_1.runLicenseCheck)(),
87
+ trackCheck('ESLint', lint_1.runLint),
88
+ trackCheck('Dependencies', dependency_check_1.runDependencyCheck),
89
+ trackCheck('NPM Audit', npm_audit_1.runNpmAudit),
90
+ trackCheck('Security Scan', security_1.runSecurityScan),
91
+ trackCheck('Circular Deps', circular_deps_1.runCircularDeps),
92
+ trackCheck('License Check', license_check_1.runLicenseCheck),
93
+ trackCheck('TypeScript', typecheck_1.runTypecheck),
55
94
  ]);
56
- // Phase 2: informational checks in parallel (never block)
95
+ // Phase 2: informational checks — old scripts tracked via wrapper,
96
+ // new scripts register themselves with accurate warn/skip status
57
97
  await Promise.all([
58
- (0, pr_size_check_1.runPrSizeCheck)(),
59
- (0, pr_title_lint_1.runPrTitleLint)(),
60
- (0, outdated_deps_1.runOutdatedDeps)(),
61
- (0, markdown_lint_1.runMarkdownLint)(),
62
- (0, spellcheck_1.runSpellcheck)().catch(() => utils_1.logger.warn('⚠️ Spellcheck found issues, continuing...')),
98
+ trackSoftCheck('PR Size', pr_size_check_1.runPrSizeCheck),
99
+ trackSoftCheck('PR Title', pr_title_lint_1.runPrTitleLint),
100
+ trackSoftCheck('Outdated Deps', outdated_deps_1.runOutdatedDeps),
101
+ trackSoftCheck('Markdown', markdown_lint_1.runMarkdownLint),
102
+ trackSoftCheck('Spellcheck', spellcheck_1.runSpellcheck),
103
+ (0, diff_smell_1.runDiffSmell)(),
104
+ (0, pr_body_1.runPrBodyCheck)(),
105
+ (0, duplicate_check_1.runDuplicateCheck)(),
63
106
  ]);
64
- // Phase 3: prettier last — writes files
65
- try {
66
- await (0, prettier_autoformat_1.runPrettier)();
67
- }
68
- catch {
69
- utils_1.logger.warn('⚠️ Prettier failed, continuing...');
70
- }
107
+ // Phase 3: prettier last — may write files, then auto-commit in CI
108
+ await trackSoftCheck('Prettier', prettier_autoformat_1.runPrettier);
109
+ // Phase 4: print summary table + post GitHub PR comment
110
+ await (0, pr_summary_1.runPrSummary)();
71
111
  break;
72
112
  case 'lint':
73
113
  await (0, lint_1.runLint)();
@@ -106,8 +146,20 @@ async function runJob(jobName) {
106
146
  case 'markdown':
107
147
  await (0, markdown_lint_1.runMarkdownLint)();
108
148
  break;
149
+ case 'typecheck':
150
+ await (0, typecheck_1.runTypecheck)();
151
+ break;
152
+ case 'diff-smell':
153
+ await (0, diff_smell_1.runDiffSmell)();
154
+ break;
155
+ case 'pr-body':
156
+ await (0, pr_body_1.runPrBodyCheck)();
157
+ break;
158
+ case 'duplicate':
159
+ await (0, duplicate_check_1.runDuplicateCheck)();
160
+ break;
109
161
  default:
110
- utils_1.logger.warn(`[runJob]: Unknown job "${jobName}". Available: all, lint, deps, npm-audit, prettier, spellcheck, security, circular, license, pr-size, pr-title, outdated, markdown`);
162
+ utils_1.logger.warn(`[runJob]: Unknown job "${jobName}". Available: all, lint, deps, npm-audit, prettier, spellcheck, security, circular, license, pr-size, pr-title, outdated, markdown, typecheck, diff-smell, pr-body, duplicate`);
111
163
  }
112
164
  }
113
165
  catch (err) {
@@ -8,8 +8,27 @@ const utils_1 = require("../utils");
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const node_fs_1 = __importDefault(require("node:fs"));
10
10
  const execa_1 = require("execa");
11
+ const config_1 = require("../config");
12
+ const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
13
+ function isSeverityAtLeast(severity, level) {
14
+ const sevIdx = SEVERITY_ORDER.indexOf(severity);
15
+ const lvlIdx = SEVERITY_ORDER.indexOf(level);
16
+ return sevIdx >= 0 && lvlIdx >= 0 && sevIdx >= lvlIdx;
17
+ }
18
+ function getOwnPackageName() {
19
+ try {
20
+ const ownPkg = node_path_1.default.resolve(__dirname, '../../package.json');
21
+ const content = node_fs_1.default.readFileSync(ownPkg, 'utf-8');
22
+ return JSON.parse(content).name ?? null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
11
28
  /**
12
- * Run npm audit on the current project
29
+ * Run npm audit on the current project.
30
+ * Only fails on vulnerabilities in direct dependencies — transitive ones are silently ignored.
31
+ * Vulnerabilities in pr-checkmate itself are also skipped — the client cannot fix those.
13
32
  */
14
33
  async function runNpmAudit() {
15
34
  utils_1.logger.info('Running npm audit...');
@@ -19,25 +38,28 @@ async function runNpmAudit() {
19
38
  utils_1.logger.warn('[runNpmAudit]: ⚠️ No package.json found in project root — skipping npm audit');
20
39
  return;
21
40
  }
41
+ const config = (0, config_1.loadProjectConfig)();
42
+ const auditLevel = config.security?.['npm-audit']?.auditLevel ?? 'moderate';
43
+ const ownName = getOwnPackageName();
44
+ let report;
22
45
  try {
23
- const { stdout, stderr } = await (0, execa_1.execa)('npm', ['audit', '--audit-level=moderate'], {
46
+ const { stdout } = await (0, execa_1.execa)('npm', ['audit', '--json'], {
24
47
  cwd: projectRoot,
48
+ reject: false,
25
49
  });
26
- if (stdout)
27
- utils_1.logger.info(stdout);
28
- if (stderr)
29
- utils_1.logger.warn(stderr);
30
- utils_1.logger.info('[runNpmAudit]: ✅ NPM audit finished successfully');
50
+ report = JSON.parse(stdout);
31
51
  }
32
52
  catch (error) {
33
- const err = error;
34
- utils_1.logger.error('[runNpmAudit]: ❌ NPM audit found issues');
35
- if (err.stdout)
36
- utils_1.logger.error(err.stdout);
37
- if (err.stderr)
38
- utils_1.logger.warn(err.stderr);
39
- if (!err.stdout && !err.stderr)
40
- utils_1.logger.error(err.message);
41
- throw err;
53
+ utils_1.logger.error('[runNpmAudit]: ❌ Failed to parse npm audit output');
54
+ throw error;
55
+ }
56
+ const directVulns = Object.values(report.vulnerabilities ?? {}).filter(v => v.isDirect && v.name !== ownName && isSeverityAtLeast(v.severity, auditLevel));
57
+ if (directVulns.length === 0) {
58
+ utils_1.logger.info('[runNpmAudit]: ✅ NPM audit finished successfully');
59
+ return;
60
+ }
61
+ for (const v of directVulns) {
62
+ utils_1.logger.error(`[runNpmAudit]: ❌ ${v.name} — severity: ${v.severity}`);
42
63
  }
64
+ throw new Error(`[runNpmAudit]: ${directVulns.length} direct dependency vulnerability(ies) found at level "${auditLevel}" or above`);
43
65
  }
@@ -0,0 +1,62 @@
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.runPrBodyCheck = runPrBodyCheck;
7
+ const utils_1 = require("../utils");
8
+ const result_collector_1 = require("../utils/result-collector");
9
+ const loader_1 = require("../config/loader");
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ // Matches "Closes #123", "Fixes #45", "Resolves #7" or JIRA-style "PROJ-456"
12
+ const TICKET_PATTERN = /(?:closes?|fixes?|resolves?)\s+#\d+|[A-Z][A-Z0-9]+-\d+/i;
13
+ async function runPrBodyCheck() {
14
+ const cfg = (0, loader_1.loadProjectConfig)();
15
+ if (cfg.prBody?.enabled === false) {
16
+ utils_1.logger.info('[runPrBodyCheck]: Disabled in pr-checkmate.json, skipping');
17
+ result_collector_1.resultCollector.register('PR Body', 'skip');
18
+ return;
19
+ }
20
+ const minLength = cfg.prBody?.minLength ?? 20;
21
+ const requireTicket = cfg.prBody?.requireTicket ?? true;
22
+ utils_1.logger.info('[runPrBodyCheck]: Checking PR description...');
23
+ const eventPath = process.env.GITHUB_EVENT_PATH;
24
+ if (!eventPath || !node_fs_1.default.existsSync(eventPath)) {
25
+ utils_1.logger.info('[runPrBodyCheck]: Not in GitHub CI, skipping');
26
+ result_collector_1.resultCollector.register('PR Body', 'skip');
27
+ return;
28
+ }
29
+ let event;
30
+ try {
31
+ event = JSON.parse(node_fs_1.default.readFileSync(eventPath, 'utf-8'));
32
+ }
33
+ catch (err) {
34
+ utils_1.logger.warn(`[runPrBodyCheck]: ⚠️ Could not read GitHub event\n${err}`);
35
+ result_collector_1.resultCollector.register('PR Body', 'skip');
36
+ return;
37
+ }
38
+ const pr = event.pull_request;
39
+ if (!pr) {
40
+ utils_1.logger.info('[runPrBodyCheck]: No PR data found, skipping');
41
+ result_collector_1.resultCollector.register('PR Body', 'skip');
42
+ return;
43
+ }
44
+ const body = pr.body ?? '';
45
+ const trimmed = body.trim();
46
+ const issues = [];
47
+ if (trimmed.length < minLength) {
48
+ issues.push(`description too short (${trimmed.length}/${minLength} chars min)`);
49
+ }
50
+ if (requireTicket && trimmed && !TICKET_PATTERN.test(trimmed)) {
51
+ issues.push('no ticket reference (e.g. "Closes #123" or "PROJ-456")');
52
+ }
53
+ if (issues.length > 0) {
54
+ utils_1.logger.warn('[runPrBodyCheck]: ⚠️ PR description issues:');
55
+ issues.forEach(issue => utils_1.logger.warn(` • ${issue}`));
56
+ result_collector_1.resultCollector.register('PR Body', 'warn', issues.join('; '));
57
+ }
58
+ else {
59
+ utils_1.logger.info('[runPrBodyCheck]: ✅ PR description looks good');
60
+ result_collector_1.resultCollector.register('PR Body', 'pass');
61
+ }
62
+ }
@@ -0,0 +1,119 @@
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.runPrSummary = runPrSummary;
7
+ const utils_1 = require("../utils");
8
+ const result_collector_1 = require("../utils/result-collector");
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const STATUS_ICON = {
11
+ pass: '✅',
12
+ warn: '⚠️',
13
+ fail: '❌',
14
+ skip: '⏭️',
15
+ };
16
+ // Unique marker so we can find and update the comment on re-runs
17
+ const COMMENT_MARKER = '<!-- pr-checkmate-summary -->';
18
+ function getPrContext() {
19
+ const token = process.env.GITHUB_TOKEN;
20
+ const repository = process.env.GITHUB_REPOSITORY;
21
+ const eventPath = process.env.GITHUB_EVENT_PATH;
22
+ if (!token || !repository || !eventPath || !node_fs_1.default.existsSync(eventPath)) {
23
+ return null;
24
+ }
25
+ try {
26
+ const event = JSON.parse(node_fs_1.default.readFileSync(eventPath, 'utf-8'));
27
+ const prNumber = event?.pull_request?.number;
28
+ if (!prNumber)
29
+ return null;
30
+ const [owner, repo] = repository.split('/');
31
+ return { token, owner, repo, prNumber };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function buildMarkdownReport(results) {
38
+ const { passed, warned, failed, skipped } = result_collector_1.resultCollector.getSummary();
39
+ const rows = results
40
+ .map(r => {
41
+ const icon = STATUS_ICON[r.status];
42
+ const details = r.details ? ` — ${r.details}` : '';
43
+ return `| ${r.name} | ${icon} ${r.status}${details} |`;
44
+ })
45
+ .join('\n');
46
+ return [
47
+ COMMENT_MARKER,
48
+ '## 🔍 PR CheckMate Summary',
49
+ '',
50
+ `**${passed} passed** · **${warned} warnings** · **${failed} failed** · **${skipped} skipped**`,
51
+ '',
52
+ '| Check | Result |',
53
+ '|---|---|',
54
+ rows,
55
+ '',
56
+ '_Generated by [PR CheckMate](https://www.npmjs.com/package/pr-checkmate)_',
57
+ ].join('\n');
58
+ }
59
+ async function upsertGithubComment(body) {
60
+ const ctx = getPrContext();
61
+ if (!ctx) {
62
+ utils_1.logger.info('[runPrSummary]: Skipping GitHub comment (not in PR context or GITHUB_TOKEN missing)');
63
+ return;
64
+ }
65
+ const { token, owner, repo, prNumber } = ctx;
66
+ const apiBase = `https://api.github.com/repos/${owner}/${repo}`;
67
+ const headers = {
68
+ Authorization: `Bearer ${token}`,
69
+ 'User-Agent': 'pr-checkmate',
70
+ Accept: 'application/vnd.github.v3+json',
71
+ 'Content-Type': 'application/json',
72
+ };
73
+ try {
74
+ // Look for an existing pr-checkmate comment to update (avoids spam on repeated pushes)
75
+ const listRes = await fetch(`${apiBase}/issues/${prNumber}/comments`, { headers });
76
+ if (listRes.ok) {
77
+ const comments = (await listRes.json());
78
+ const existing = comments.find(c => typeof c.body === 'string' && c.body.includes(COMMENT_MARKER));
79
+ if (existing) {
80
+ await fetch(`${apiBase}/issues/comments/${existing.id}`, {
81
+ method: 'PATCH',
82
+ headers,
83
+ body: JSON.stringify({ body }),
84
+ });
85
+ utils_1.logger.info('[runPrSummary]: ✅ Updated PR CheckMate comment on PR');
86
+ return;
87
+ }
88
+ }
89
+ // No existing comment — post a new one
90
+ await fetch(`${apiBase}/issues/${prNumber}/comments`, {
91
+ method: 'POST',
92
+ headers,
93
+ body: JSON.stringify({ body }),
94
+ });
95
+ utils_1.logger.info('[runPrSummary]: ✅ Posted PR CheckMate summary comment to PR');
96
+ }
97
+ catch (err) {
98
+ utils_1.logger.warn(`[runPrSummary]: ⚠️ Could not post GitHub comment: ${err}`);
99
+ }
100
+ }
101
+ async function runPrSummary() {
102
+ const results = result_collector_1.resultCollector.getResults();
103
+ if (results.length === 0)
104
+ return;
105
+ const { passed, warned, failed, skipped } = result_collector_1.resultCollector.getSummary();
106
+ utils_1.logger.info('');
107
+ utils_1.logger.info('═══════════════════════════════════════════════════════════════');
108
+ utils_1.logger.info(' PR CheckMate Summary');
109
+ utils_1.logger.info('═══════════════════════════════════════════════════════════════');
110
+ for (const r of results) {
111
+ const icon = STATUS_ICON[r.status];
112
+ const details = r.details ? `: ${r.details}` : '';
113
+ utils_1.logger.info(` ${icon} ${r.name}${details}`);
114
+ }
115
+ utils_1.logger.info('───────────────────────────────────────────────────────────────');
116
+ utils_1.logger.info(` ✅ ${passed} passed ⚠️ ${warned} warnings ❌ ${failed} failed ⏭️ ${skipped} skipped`);
117
+ utils_1.logger.info('═══════════════════════════════════════════════════════════════');
118
+ await upsertGithubComment(buildMarkdownReport(results));
119
+ }
@@ -0,0 +1,34 @@
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.runTypecheck = runTypecheck;
7
+ const execa_1 = require("execa");
8
+ const utils_1 = require("../utils");
9
+ const loader_1 = require("../config/loader");
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ async function runTypecheck() {
13
+ const cfg = (0, loader_1.loadProjectConfig)();
14
+ if (cfg.typecheck?.enabled === false) {
15
+ utils_1.logger.info('[runTypecheck]: Disabled in pr-checkmate.json, skipping');
16
+ return;
17
+ }
18
+ utils_1.logger.info('[runTypecheck]: Running TypeScript type check...');
19
+ const tsConfigPath = node_path_1.default.join(process.cwd(), 'tsconfig.json');
20
+ if (!node_fs_1.default.existsSync(tsConfigPath)) {
21
+ utils_1.logger.info('[runTypecheck]: No tsconfig.json found, skipping');
22
+ return;
23
+ }
24
+ const result = await (0, execa_1.execa)('npx', ['tsc', '--noEmit'], { reject: false });
25
+ if (result.exitCode !== 0) {
26
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n');
27
+ const errorCount = (output.match(/error TS\d+/g) ?? []).length;
28
+ utils_1.logger.warn(`[runTypecheck]: ⚠️ TypeScript found ${errorCount} type error(s):`);
29
+ if (output)
30
+ utils_1.logger.warn(output);
31
+ throw new Error(`TypeScript: ${errorCount} type error(s)`);
32
+ }
33
+ utils_1.logger.info('[runTypecheck]: ✅ TypeScript type check passed');
34
+ }
@@ -22,6 +22,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
22
22
  const logger_1 = require("./logger");
23
23
  __exportStar(require("./logger"), exports);
24
24
  __exportStar(require("./delta"), exports);
25
+ __exportStar(require("./result-collector"), exports);
25
26
  function cleanupTempFile(filePath) {
26
27
  try {
27
28
  if (node_fs_1.default.existsSync(filePath)) {
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resultCollector = void 0;
4
+ class ResultCollector {
5
+ constructor() {
6
+ this.results = [];
7
+ }
8
+ register(name, status, details) {
9
+ const idx = this.results.findIndex(r => r.name === name);
10
+ const entry = { name, status, ...(details && { details }) };
11
+ if (idx >= 0) {
12
+ this.results[idx] = entry;
13
+ }
14
+ else {
15
+ this.results.push(entry);
16
+ }
17
+ }
18
+ getResults() {
19
+ return [...this.results];
20
+ }
21
+ clear() {
22
+ this.results = [];
23
+ }
24
+ getSummary() {
25
+ return {
26
+ passed: this.results.filter(r => r.status === 'pass').length,
27
+ warned: this.results.filter(r => r.status === 'warn').length,
28
+ failed: this.results.filter(r => r.status === 'fail').length,
29
+ skipped: this.results.filter(r => r.status === 'skip').length,
30
+ };
31
+ }
32
+ }
33
+ exports.resultCollector = new ResultCollector();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-checkmate",
3
- "version": "1.21.1",
3
+ "version": "1.21.3",
4
4
  "description": "Automated PR quality checks: linting, formatting, dependency analysis, and spellcheck",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -74,8 +74,9 @@
74
74
  "eslint-plugin-prettier": "^5.5.4",
75
75
  "execa": "^9.6.0",
76
76
  "gitleaks-secret-scanner-fixed": "^2.1.3",
77
- "markdownlint-cli": "^0.45.0",
78
77
  "inquirer": "^13.0.1",
78
+ "jscpd": "^3.5.10",
79
+ "markdownlint-cli": "^0.49.0",
79
80
  "prettier": ">=3.0.0",
80
81
  "ts-node": "^10.9.1",
81
82
  "typescript": "^5.9.3"
@@ -263,3 +263,91 @@ jobs:
263
263
  - name: Run Markdown Lint
264
264
  run: npx pr-checkmate markdown
265
265
  continue-on-error: true
266
+
267
+ typecheck:
268
+ name: TypeScript Type Check
269
+ runs-on: ubuntu-latest
270
+ steps:
271
+ - name: Checkout code
272
+ uses: actions/checkout@v4
273
+ with:
274
+ ref: ${{ github.head_ref }}
275
+ fetch-depth: 0
276
+
277
+ - name: Setup Node.js
278
+ uses: actions/setup-node@v4
279
+ with:
280
+ node-version: 20
281
+
282
+ - name: Install dependencies
283
+ run: npm ci
284
+
285
+ - name: Run TypeScript Type Check
286
+ run: npx pr-checkmate typecheck
287
+ continue-on-error: true
288
+
289
+ diff-smell:
290
+ name: Diff Code Smell
291
+ runs-on: ubuntu-latest
292
+ steps:
293
+ - name: Checkout code
294
+ uses: actions/checkout@v4
295
+ with:
296
+ ref: ${{ github.head_ref }}
297
+ fetch-depth: 0
298
+
299
+ - name: Setup Node.js
300
+ uses: actions/setup-node@v4
301
+ with:
302
+ node-version: 20
303
+
304
+ - name: Install dependencies
305
+ run: npm ci
306
+
307
+ - name: Scan Diff for Code Smells
308
+ run: npx pr-checkmate diff-smell
309
+ continue-on-error: true
310
+
311
+ pr-body:
312
+ name: PR Description
313
+ runs-on: ubuntu-latest
314
+ steps:
315
+ - name: Checkout code
316
+ uses: actions/checkout@v4
317
+ with:
318
+ ref: ${{ github.head_ref }}
319
+ fetch-depth: 0
320
+
321
+ - name: Setup Node.js
322
+ uses: actions/setup-node@v4
323
+ with:
324
+ node-version: 20
325
+
326
+ - name: Install dependencies
327
+ run: npm ci
328
+
329
+ - name: Check PR Description
330
+ run: npx pr-checkmate pr-body
331
+ continue-on-error: true
332
+
333
+ duplicate-check:
334
+ name: Duplicate Code
335
+ runs-on: ubuntu-latest
336
+ steps:
337
+ - name: Checkout code
338
+ uses: actions/checkout@v4
339
+ with:
340
+ ref: ${{ github.head_ref }}
341
+ fetch-depth: 0
342
+
343
+ - name: Setup Node.js
344
+ uses: actions/setup-node@v4
345
+ with:
346
+ node-version: 20
347
+
348
+ - name: Install dependencies
349
+ run: npm ci
350
+
351
+ - name: Check for Duplicate Code
352
+ run: npx pr-checkmate duplicate
353
+ continue-on-error: true