pr-checkmate 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,7 @@
10
10
  ![cspell](https://img.shields.io/badge/cspell-10-4285F4)
11
11
  ![jscpd](https://img.shields.io/badge/jscpd-5-blue)
12
12
  [![API Docs](https://img.shields.io/badge/API_docs-TypeDoc-9600FF?logo=readthedocs&logoColor=white)](https://pr-checkmate-docs.pages.dev)
13
+ [![Container](https://img.shields.io/badge/ghcr.io-pr--checkmate-2496ED?logo=docker&logoColor=white)](https://github.com/users/Mybono/packages/container/package/pr-checkmate)
13
14
 
14
15
  > PR CheckMate is a security-first PR gate: secret scanning, dependency
15
16
  > vulnerability checks, and a signed SBOM + independent VirusTotal scan on
@@ -121,13 +122,38 @@ npx pr-checkmate all --full
121
122
  npx pr-checkmate lint --full
122
123
  ```
123
124
 
124
- The 16 checks that only make sense on a diff (PR Size, Diff Security, Merge Conflict, Custom
125
- Rules, Banned Imports, the git-diff family) drop out of the report on a full scan. The
125
+ Only three checks genuinely need a diff and drop out of a full scan — PR Size, Missing
126
+ Tests and Lockfile Drift. Everything else, the whole security family included, scans the
127
+ working tree when there is no diff to read. The
126
128
  [per-check reference](https://github.com/Mybono/pr_checkmate_public/blob/main/documentation/checks/INDEX.md)
127
129
  spells out the rules under "Review scope".
128
130
 
129
131
  ### Running in CI without installing
130
132
 
133
+ **Use the container.** With no `node_modules` to reuse, a cold `npx` downloads 547 MB and
134
+ takes about five minutes — on every job. The image carries the whole toolchain already:
135
+
136
+ ```bash
137
+ docker run --rm -v "$PWD:/repo" --user "$(id -u):$(id -g)" \
138
+ ghcr.io/mybono/pr-checkmate:latest all
139
+ ```
140
+
141
+ 161 MB pulled once and cached by the runner, and the checks that need a binary on the
142
+ machine — ShellCheck among them — are inside, so they stop silently skipping. Pin the
143
+ version tag (`:2.0.0`) for a reproducible pipeline; `--user` keeps files that formatters
144
+ rewrite owned by you rather than by root.
145
+
146
+ Verify what you pulled — the image carries build provenance signed by the workflow
147
+ that produced it:
148
+
149
+ ```bash
150
+ gh attestation verify oci://ghcr.io/mybono/pr-checkmate:2 --owner Mybono
151
+ ```
152
+
153
+ [All tags →](https://github.com/users/Mybono/packages/container/package/pr-checkmate)
154
+
155
+ The `npx` route still works, and the rest of this section is about making it comfortable.
156
+
131
157
  Plenty of repos never add pr-checkmate to `package.json` and call it on the fly in CI
132
158
  instead (`npx pr-checkmate`). That works, but there is no `node_modules` to generate a
133
159
  config from, so the run silently falls back to the defaults with no file to tune.
package/cspell.json CHANGED
@@ -1752,7 +1752,14 @@
1752
1752
  "/**/yarn.lock",
1753
1753
  "/**/pnpm-lock.yaml",
1754
1754
  "/**/*.min.js",
1755
- "/**/*.min.css"
1755
+ "/**/*.min.css",
1756
+ "/CHANGELOG.md",
1757
+ "/**/__tests__/**",
1758
+ "/**/__mocks__/**",
1759
+ "/**/*.test.*",
1760
+ "/**/*.spec.*",
1761
+ "/**/test/**",
1762
+ "/**/tests/**"
1756
1763
  ],
1757
1764
  "enableFiletypes": ["typescript", "javascript", "markdown", "json"],
1758
1765
  "ignoreRegExpList": ["/https?:\\/\\/[^\\s]+/g", "/\\b[0-9a-f]{7,40}\\b/gi"]
@@ -17,6 +17,18 @@ export declare const IGNORED_DIRS: string[];
17
17
  * nested report globs that are not plain directory names.
18
18
  */
19
19
  export declare const IGNORE_PATTERNS: string[];
20
+ /**
21
+ * Where test code lives, as globs.
22
+ *
23
+ * Deliberately NOT part of {@link IGNORED_DIRS}: that list hides a directory from
24
+ * every check, and test code still wants linting, type-checking and formatting.
25
+ * What test code does not want is the handful of checks whose findings are noise
26
+ * on it — duplication (setup boilerplate repeats by design), unused exports (the
27
+ * runner consumes them), spelling (fixture identifiers) and the diff security
28
+ * scan (a test's whole job may be to call the dangerous thing). Each of those
29
+ * defaults to this list, and each lets a client replace it.
30
+ */
31
+ export declare const TEST_PATH_GLOBS: string[];
20
32
  /**
21
33
  * Test files/folders excluded from duplicate-code detection by default.
22
34
  * Test suites legitimately repeat setup/arrange/assert boilerplate, so counting
@@ -24,6 +36,24 @@ export declare const IGNORE_PATTERNS: string[];
24
36
  * override this list via `duplicate.ignore` in pr-checkmate.json.
25
37
  */
26
38
  export declare const DUPLICATE_IGNORE_DEFAULTS: string[];
39
+ /**
40
+ * Paths the diff security scan skips by default.
41
+ *
42
+ * A security test corpus is built out of the exact constructs the scan hunts for
43
+ * — `eval()`, `document.write()`, `__proto__` — so scanning it turns every added
44
+ * test case into a finding, and under `severity: error` into a blocked merge.
45
+ * Clients can override via `diffSecurity.ignorePaths` (which REPLACES this list).
46
+ */
47
+ export declare const DIFF_SECURITY_IGNORE_DEFAULTS: string[];
48
+ /**
49
+ * Files written by a release tool rather than by a person.
50
+ *
51
+ * `CHANGELOG.md` is assembled from commit subjects, so every typo anyone has ever
52
+ * committed reappears in it — 39 of 42 spellcheck findings in one real repository
53
+ * came from that one file, none of them actionable: the text cannot be corrected
54
+ * without rewriting git history, and the next release regenerates it anyway.
55
+ */
56
+ export declare const GENERATED_DOC_FILES: string[];
27
57
  /**
28
58
  * Files excluded from the dead-code (unused-exports) report by default. These are
29
59
  * REGEX sources (matched against each reported file path), not globs — dead-code's
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.KNOWN_CONFIG_KEYS = exports.CONFIG_FILE = exports.documentation = exports.FULL_CONFIG_URL = exports.DOCS_URL = exports.DEFAULT_ALLOW_PACKAGES = exports.DEFAULT_COMMANDS = exports.SOURCE_EXTENSIONS = exports.DUPLICATE_CODE_FORMATS = exports.CONFIG_FILE_PATTERNS = exports.DEAD_CODE_IGNORE_DEFAULTS = exports.DUPLICATE_IGNORE_DEFAULTS = exports.IGNORE_PATTERNS = exports.IGNORED_DIRS = void 0;
6
+ exports.KNOWN_CONFIG_KEYS = exports.CONFIG_FILE = exports.documentation = exports.FULL_CONFIG_URL = exports.DOCS_URL = exports.DEFAULT_ALLOW_PACKAGES = exports.DEFAULT_COMMANDS = exports.SOURCE_EXTENSIONS = exports.DUPLICATE_CODE_FORMATS = exports.CONFIG_FILE_PATTERNS = exports.DEAD_CODE_IGNORE_DEFAULTS = exports.GENERATED_DOC_FILES = exports.DIFF_SECURITY_IGNORE_DEFAULTS = exports.DUPLICATE_IGNORE_DEFAULTS = exports.TEST_PATH_GLOBS = exports.IGNORE_PATTERNS = exports.IGNORED_DIRS = void 0;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
8
  /**
9
9
  * Build output, dependency, tooling, and report directories that are never
@@ -86,6 +86,11 @@ exports.IGNORED_DIRS = [
86
86
  'allure-results',
87
87
  'playwright-report',
88
88
  '.wdio',
89
+ // Generated knowledge-graph output. Committed by repos that keep the graph in
90
+ // version control, and machine-written from end to end: a multi-megabyte
91
+ // `graph.json` trips the large-file check, and `GRAPH_REPORT.md` carries every
92
+ // identifier in the codebase past the spellchecker. Neither is anyone's source.
93
+ 'graphify-out',
89
94
  ];
90
95
  /**
91
96
  * Glob form of the ignore list, for tools that take glob ignores directly
@@ -110,12 +115,17 @@ exports.IGNORE_PATTERNS = [
110
115
  '**/audit-report/**',
111
116
  ];
112
117
  /**
113
- * Test files/folders excluded from duplicate-code detection by default.
114
- * Test suites legitimately repeat setup/arrange/assert boilerplate, so counting
115
- * them as clones is noise that drowns out real production duplication. Clients can
116
- * override this list via `duplicate.ignore` in pr-checkmate.json.
118
+ * Where test code lives, as globs.
119
+ *
120
+ * Deliberately NOT part of {@link IGNORED_DIRS}: that list hides a directory from
121
+ * every check, and test code still wants linting, type-checking and formatting.
122
+ * What test code does not want is the handful of checks whose findings are noise
123
+ * on it — duplication (setup boilerplate repeats by design), unused exports (the
124
+ * runner consumes them), spelling (fixture identifiers) and the diff security
125
+ * scan (a test's whole job may be to call the dangerous thing). Each of those
126
+ * defaults to this list, and each lets a client replace it.
117
127
  */
118
- exports.DUPLICATE_IGNORE_DEFAULTS = [
128
+ exports.TEST_PATH_GLOBS = [
119
129
  '**/__tests__/**',
120
130
  '**/__mocks__/**',
121
131
  '**/*.test.*',
@@ -123,6 +133,31 @@ exports.DUPLICATE_IGNORE_DEFAULTS = [
123
133
  '**/test/**',
124
134
  '**/tests/**',
125
135
  ];
136
+ /**
137
+ * Test files/folders excluded from duplicate-code detection by default.
138
+ * Test suites legitimately repeat setup/arrange/assert boilerplate, so counting
139
+ * them as clones is noise that drowns out real production duplication. Clients can
140
+ * override this list via `duplicate.ignore` in pr-checkmate.json.
141
+ */
142
+ exports.DUPLICATE_IGNORE_DEFAULTS = [...exports.TEST_PATH_GLOBS];
143
+ /**
144
+ * Paths the diff security scan skips by default.
145
+ *
146
+ * A security test corpus is built out of the exact constructs the scan hunts for
147
+ * — `eval()`, `document.write()`, `__proto__` — so scanning it turns every added
148
+ * test case into a finding, and under `severity: error` into a blocked merge.
149
+ * Clients can override via `diffSecurity.ignorePaths` (which REPLACES this list).
150
+ */
151
+ exports.DIFF_SECURITY_IGNORE_DEFAULTS = [...exports.TEST_PATH_GLOBS];
152
+ /**
153
+ * Files written by a release tool rather than by a person.
154
+ *
155
+ * `CHANGELOG.md` is assembled from commit subjects, so every typo anyone has ever
156
+ * committed reappears in it — 39 of 42 spellcheck findings in one real repository
157
+ * came from that one file, none of them actionable: the text cannot be corrected
158
+ * without rewriting git history, and the next release regenerates it anyway.
159
+ */
160
+ exports.GENERATED_DOC_FILES = ['CHANGELOG.md'];
126
161
  /**
127
162
  * Files excluded from the dead-code (unused-exports) report by default. These are
128
163
  * REGEX sources (matched against each reported file path), not globs — dead-code's
@@ -0,0 +1,79 @@
1
+ name: PR CheckMate
2
+
3
+ # The container variant, scaffolded because this repository does not carry
4
+ # pr-checkmate as a dependency.
5
+ #
6
+ # The alternative would be `npx pr-checkmate`, and with no node_modules to reuse
7
+ # that resolves to whatever is newest on the registry and downloads it again on
8
+ # every job: 547 MB and about five minutes, measured. Worse, a major release
9
+ # would land on you without a line changing here — and a major release of a gate
10
+ # can start failing builds that passed yesterday.
11
+ #
12
+ # The image is pulled once and cached by the runner, and the tag below pins the
13
+ # major so nothing moves under you.
14
+
15
+ on:
16
+ pull_request:
17
+ branches: [main, master, develop]
18
+ types: [opened, synchronize, reopened]
19
+
20
+ # Least privilege. `pull-requests: write` is what lets the run upsert its summary
21
+ # comment on the PR; drop it and the checks still run, they just report to the log.
22
+ permissions:
23
+ contents: read
24
+ pull-requests: write
25
+
26
+ # Push three times in a minute and the first two runs become answers to questions
27
+ # nobody is asking any more — they still hold a runner, and they still race to
28
+ # upsert the summary comment, so a stale verdict can land on top of a fresh one.
29
+ # Keyed per PR, so runs on different pull requests never cancel each other.
30
+ concurrency:
31
+ group: pr-checkmate-${{ github.workflow }}-${{ github.ref }}
32
+ cancel-in-progress: true
33
+
34
+ jobs:
35
+ pr-checkmate:
36
+ name: PR CheckMate
37
+ runs-on: ubuntu-latest
38
+ # A hung step otherwise runs to GitHub's six-hour default before anyone hears
39
+ # about it. Every check has its own internal timeout; this is the backstop.
40
+ timeout-minutes: 20
41
+
42
+ steps:
43
+ - name: Checkout code
44
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
45
+ with:
46
+ # Required, not optional. Without the full history the PR base commit is
47
+ # not in the clone, no diff range can be built, and every diff-based
48
+ # check skips itself. The run then says `Scan Scope: … set
49
+ # fetch-depth: 0`, which is this line.
50
+ fetch-depth: 0
51
+
52
+ # One job runs the whole registry. Which checks run is decided in
53
+ # pr-checkmate.json (`severity`), not by splitting this into jobs.
54
+ #
55
+ # `--user` is not a detail: without it every file a formatter rewrites comes
56
+ # back owned by root, and the next step in your pipeline fails on
57
+ # permissions long after this one reported success.
58
+ #
59
+ # The environment variables are passed through by name rather than by value:
60
+ # the run reads them to find the PR, and interpolating them into the command
61
+ # line would put their contents into a shell.
62
+ - name: Run PR CheckMate
63
+ env:
64
+ # Not injected automatically: without this the run cannot authenticate,
65
+ # and the summary comment silently never reaches the PR.
66
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
67
+ run: |
68
+ docker run --rm \
69
+ -v "$PWD:/repo" \
70
+ --user "$(id -u):$(id -g)" \
71
+ -e GITHUB_TOKEN \
72
+ -e GITHUB_REPOSITORY \
73
+ -e GITHUB_EVENT_NAME \
74
+ -e GITHUB_EVENT_PATH \
75
+ -e GITHUB_REF \
76
+ -e GITHUB_SHA \
77
+ -e CI \
78
+ -v "$GITHUB_EVENT_PATH:$GITHUB_EVENT_PATH:ro" \
79
+ ghcr.io/mybono/pr-checkmate:__IMAGE_TAG__ all
@@ -11,10 +11,23 @@ permissions:
11
11
  contents: read
12
12
  pull-requests: write
13
13
 
14
+ # Push three times in a minute and the first two runs become answers to questions
15
+ # nobody is asking any more — they still hold a runner, and they still race to
16
+ # upsert the summary comment, so a stale verdict can land on top of a fresh one.
17
+ # Keyed per PR, so runs on different pull requests never cancel each other.
18
+ concurrency:
19
+ group: pr-checkmate-${{ github.workflow }}-${{ github.ref }}
20
+ cancel-in-progress: true
21
+
14
22
  jobs:
15
23
  pr-checkmate:
16
24
  name: PR CheckMate
17
25
  runs-on: ubuntu-latest
26
+ # A hung step otherwise runs to GitHub's six-hour default before anyone hears
27
+ # about it. Every check here has its own internal timeout; this is the backstop
28
+ # for the ones that cannot fail themselves — a wedged network fetch, a tool
29
+ # waiting on stdin. Raise it for a large monorepo.
30
+ timeout-minutes: 20
18
31
  steps:
19
32
  - name: Checkout code
20
33
  uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -48,9 +61,29 @@ jobs:
48
61
  restore-keys: |
49
62
  pr-checkmate-${{ runner.os }}-
50
63
 
64
+ # Grype keeps its own vulnerability database, and it is the single largest
65
+ # download in the whole run: 134 MB compressed, 1.9 GB unpacked, refreshed
66
+ # daily upstream. Without this step every job pays for it again. The key is
67
+ # dated rather than content-hashed, because the thing that changes is the
68
+ # upstream database, not anything in this repository — a stale entry is
69
+ # restored by the prefix and grype updates it in place.
70
+ - name: Cache Grype vulnerability database
71
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
72
+ with:
73
+ path: ~/.cache/grype
74
+ key: grype-db-${{ runner.os }}-${{ github.run_id }}
75
+ restore-keys: |
76
+ grype-db-${{ runner.os }}-
77
+
51
78
  # One job runs the whole registry. Splitting it per check would repeat the
52
79
  # checkout and install for every one of them, and the selection of what runs
53
80
  # belongs in pr-checkmate.json (`severity`), not in copied YAML.
81
+ #
82
+ # This template is the one `init` scaffolds when pr-checkmate IS a
83
+ # dependency: `npm ci` above installs it, so npx runs that copy at the
84
+ # version your lockfile pins. A repository that does not install it gets
85
+ # the container variant instead, because bare `npx` would resolve to the
86
+ # newest release and download it on every job.
54
87
  - name: Run PR CheckMate
55
88
  env:
56
89
  # Not injected automatically: without this the run cannot authenticate,
@@ -72,15 +72,20 @@ exports.markdownLintCheck = {
72
72
  'coverage/**',
73
73
  'graphify-out/**',
74
74
  '.claude/**',
75
+ // Release-tool output, not prose anyone edits — see GENERATED_DOC_FILES.
76
+ ...config_1.GENERATED_DOC_FILES,
75
77
  ...(userConfig.ignores ?? []),
76
78
  ];
77
79
  // markdownlint-cli2 has no --ignore-path flag; exclusions are expressed as
78
80
  // negated globs (`#pattern`). A bare directory name also needs `#name/**`
79
- // to exclude its contents.
81
+ // to exclude its contents — but a filename does not, and `#CHANGELOG.md/**`
82
+ // is a glob that can never match. `path.extname` is what tells them apart,
83
+ // and it reads a leading dot as part of the name, so `.claude` is still
84
+ // treated as the directory it is.
80
85
  const ignoreGlobs = defaultIgnorePatterns.flatMap(pattern => {
81
86
  const clean = pattern.replace(/\/+$/, '');
82
87
  const globs = [`#${clean}`];
83
- if (!clean.includes('*'))
88
+ if (!clean.includes('*') && !node_path_1.default.extname(clean))
84
89
  globs.push(`#${clean}/**`);
85
90
  return globs;
86
91
  });
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.diffSecurityCheck = void 0;
4
+ const config_1 = require("../../../config");
4
5
  const utils_1 = require("../../../utils");
5
6
  const targets_1 = require("../../targets");
6
7
  const types_1 = require("../../types");
@@ -293,7 +294,10 @@ exports.diffSecurityCheck = {
293
294
  const found = (0, findings_1.emptyBuckets)();
294
295
  const scanOpts = {
295
296
  comments: cfg.commentHandling ?? 'language',
296
- ignorePaths: cfg.ignorePaths,
297
+ // Setting `ignorePaths` REPLACES the test-path defaults rather than adding
298
+ // to them, matching `duplicate.ignore` and `deadCode.ignoreFiles`. A client
299
+ // who wants their tests scanned says so by listing something else here.
300
+ ignorePaths: cfg.ignorePaths ?? config_1.DIFF_SECURITY_IGNORE_DEFAULTS,
297
301
  };
298
302
  const extra = compileExtraPatterns(ctx);
299
303
  const disabled = cfg.disablePatterns ?? [];
@@ -1,4 +1,28 @@
1
- import { CheckReport, RunOptions } from './types';
1
+ import type { RunScope } from './types';
2
+ import { CheckContext, CheckReport, CheckStatus, RunOptions } from './types';
3
+ /**
4
+ * Apply the universal `config.severity` override for a check. Only remaps
5
+ * between warn↔fail (pass/skip are never changed); `off` is handled earlier by
6
+ * excluding the check from the run entirely. This is what lets a repo turn any
7
+ * advisory check into a gate, or downgrade a blocking one, by name.
8
+ */
9
+ export declare function applySeverity(name: string, status: CheckStatus, ctx: CheckContext): CheckStatus;
10
+ /**
11
+ * Whether a diff-only check's skip is worth a row in the report.
12
+ *
13
+ * The two cases look identical from inside the check and are opposites to the
14
+ * person reading the result:
15
+ *
16
+ * - The range is missing because the run FELL BACK to a full scan — a shallow
17
+ * clone, an unreachable PR base. Coverage was lost without anyone choosing
18
+ * that, and saying nothing is how it stays lost (ticket 057).
19
+ * - The full scan was ASKED FOR. `--full` means "look at everything now", so
20
+ * three rows explaining that three checks review a diff repeat what the reader
21
+ * just said and push the real findings further down.
22
+ *
23
+ * `always` and `never` remain absolute; only `auto` distinguishes them.
24
+ */
25
+ export declare function shouldReportDiffSkip(scope: RunScope | undefined, reportSkipped?: 'auto' | 'always' | 'never'): boolean;
2
26
  /**
3
27
  * Run all applicable checks and return a structured report.
4
28
  *
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applySeverity = applySeverity;
4
+ exports.shouldReportDiffSkip = shouldReportDiffSkip;
3
5
  exports.runChecks = runChecks;
4
6
  const utils_1 = require("../utils");
5
7
  const pool_1 = require("../utils/pool");
@@ -71,6 +73,29 @@ function applySeverity(name, status, ctx) {
71
73
  return 'warn';
72
74
  return status;
73
75
  }
76
+ /**
77
+ * Whether a diff-only check's skip is worth a row in the report.
78
+ *
79
+ * The two cases look identical from inside the check and are opposites to the
80
+ * person reading the result:
81
+ *
82
+ * - The range is missing because the run FELL BACK to a full scan — a shallow
83
+ * clone, an unreachable PR base. Coverage was lost without anyone choosing
84
+ * that, and saying nothing is how it stays lost (ticket 057).
85
+ * - The full scan was ASKED FOR. `--full` means "look at everything now", so
86
+ * three rows explaining that three checks review a diff repeat what the reader
87
+ * just said and push the real findings further down.
88
+ *
89
+ * `always` and `never` remain absolute; only `auto` distinguishes them.
90
+ */
91
+ function shouldReportDiffSkip(scope, reportSkipped = 'auto') {
92
+ if (reportSkipped === 'never')
93
+ return false;
94
+ if (reportSkipped === 'always')
95
+ return true;
96
+ const askedForFullScan = scope?.kind === 'full' && !scope.degraded;
97
+ return !askedForFullScan;
98
+ }
74
99
  /**
75
100
  * Why a `requiresDiff` check could not run, phrased for the person reading the
76
101
  * report. A full scan the client asked for is expected; one we fell back to is
@@ -90,11 +115,19 @@ function noDiffReason(ctx) {
90
115
  */
91
116
  async function runOne(check, ctx, collector, heavy) {
92
117
  try {
93
- // A diff-only check without a range is a hole in coverage, not an
94
- // irrelevance: report it as a skip so the gap is visible in the summary.
118
+ // A diff-only check without a range is usually a hole in coverage rather
119
+ // than an irrelevance, so it is reported as a skip and the gap stays visible
120
+ // (ticket 057).
121
+ //
122
+ // Except when the whole-repository scan was ASKED FOR. `--full` means "look
123
+ // at everything now", and answering with three rows explaining that three
124
+ // checks review a diff tells the reader something they just said themselves.
125
+ // A degraded scope still reports: there the missing range is a symptom —
126
+ // a shallow clone, an unreachable base — and staying quiet about it is how
127
+ // coverage disappears unnoticed.
95
128
  if (check.requiresDiff && !ctx.baseSha) {
96
- const show = ctx.config.run?.reportSkipped ?? 'auto';
97
- if (show !== 'never' && (await check.applies(ctx))) {
129
+ if (shouldReportDiffSkip(ctx.scope, ctx.config.run?.reportSkipped) &&
130
+ (await check.applies(ctx))) {
98
131
  collector.register(check.name, 'skip', noDiffReason(ctx));
99
132
  }
100
133
  return;
package/dist/init.js CHANGED
@@ -91,7 +91,21 @@ const ADVISORY_FLAG = ' continue-on-error: true';
91
91
  * were just asked about.
92
92
  */
93
93
  function setupWorkflow(root, mode) {
94
- const workflowSource = path_1.default.join(__dirname, 'config', 'pr-checkmate-workflow.yml');
94
+ // Which template a repository gets is decided by whether it actually installs
95
+ // us. Both are correct for their own case and wrong for the other:
96
+ //
97
+ // - a dependency means `npx pr-checkmate` runs the copy the lockfile pins,
98
+ // which is fast and reproducible;
99
+ // - no dependency means `npx` resolves to the newest release and downloads it
100
+ // on every job — 547 MB, about five minutes, and a major version can land
101
+ // without anyone changing a line.
102
+ //
103
+ // Asking the client to notice that difference in a comment is asking them to
104
+ // read a file they scaffolded precisely so they would not have to.
105
+ const source = installsPackage(root)
106
+ ? 'pr-checkmate-workflow.yml'
107
+ : 'pr-checkmate-workflow-docker.yml';
108
+ const workflowSource = path_1.default.join(__dirname, 'config', source);
95
109
  const workflowDir = path_1.default.join(root, '.github', 'workflows');
96
110
  const workflowDest = path_1.default.join(workflowDir, 'pr-checkmate.yml');
97
111
  if (!fs_1.default.existsSync(workflowSource)) {
@@ -105,13 +119,46 @@ function setupWorkflow(root, mode) {
105
119
  if (!fs_1.default.existsSync(workflowDir)) {
106
120
  fs_1.default.mkdirSync(workflowDir, { recursive: true });
107
121
  }
108
- const template = fs_1.default.readFileSync(workflowSource, 'utf-8');
122
+ const template = fs_1.default.readFileSync(workflowSource, 'utf-8').replace(/__IMAGE_TAG__/g, imageTag());
109
123
  const workflow = mode === 'advisory'
110
124
  ? template.replace(RUN_STEP_ANCHOR, `${ADVISORY_NOTICE}\n${RUN_STEP_ANCHOR}\n${ADVISORY_FLAG}`)
111
125
  : template;
112
126
  fs_1.default.writeFileSync(workflowDest, workflow);
113
127
  utils_1.logger.info(`[init]: ✅ Created .github/workflows/pr-checkmate.yml (${mode === 'strict' ? 'blocking' : 'advisory'})`);
114
128
  }
129
+ /**
130
+ * Whether this repository installs pr-checkmate rather than reaching for it on
131
+ * the pipeline. A missing or unreadable package.json answers "no", which is the
132
+ * safe way round: the container variant works either way, while the npm variant
133
+ * assumes an install that may not happen.
134
+ */
135
+ function installsPackage(root) {
136
+ try {
137
+ const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(root, 'package.json'), 'utf-8'));
138
+ return Boolean(pkg.dependencies?.['pr-checkmate'] ?? pkg.devDependencies?.['pr-checkmate']);
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ /**
145
+ * The image tag written into the generated workflow: our own major.
146
+ *
147
+ * Pinned to a major rather than to `latest`, so patches and features arrive on
148
+ * their own while a change that can start failing builds does not. Pinned to a
149
+ * major rather than an exact version, so a client is not left on a release with
150
+ * a known problem because nobody remembered to bump a YAML file.
151
+ */
152
+ function imageTag() {
153
+ try {
154
+ const manifest = path_1.default.resolve(__dirname, '..', 'package.json');
155
+ const version = JSON.parse(fs_1.default.readFileSync(manifest, 'utf-8')).version;
156
+ return version.split('.')[0] || 'latest';
157
+ }
158
+ catch {
159
+ return 'latest';
160
+ }
161
+ }
115
162
  /** Where the advisory banner is spliced in — the step that runs the checks. */
116
163
  const RUN_STEP_ANCHOR = ' - name: Run PR CheckMate';
117
164
  /** Create a tsconfig.json scaffold if the consumer repo lacks one. */
@@ -217,30 +264,62 @@ function buildInitialConfig(sourcePath, languages) {
217
264
  commands: config_1.DEFAULT_COMMANDS,
218
265
  '// reporting': REPORTING_HINT,
219
266
  sourcePath,
220
- // What can stop a merge, and how to stand any of it down. "warn" keeps the
221
- // finding in the report without failing the run; "off" removes the check.
222
- severity: Object.fromEntries(exports.BLOCKING_BY_DEFAULT.map(name => [name, 'error'])),
223
- // The knobs people reach for first, seeded EMPTY.
267
+ // Which checks can stop a merge — named, but NOT written as values.
268
+ //
269
+ // Writing `"Workflow Security": "error"` reads like a statement of the
270
+ // default and is not one: the map promotes as well as demotes, so an entry
271
+ // of "error" turns that check's ADVISORY findings into failures too. On a
272
+ // real client repository that flipped 54 warnings into a red build. The
273
+ // names belong in a comment; the map stays empty until the client means
274
+ // something by it.
275
+ '// severity': [
276
+ `These checks fail a run by default: ${exports.BLOCKING_BY_DEFAULT.join(', ')}.`,
277
+ 'To stand one down, add it below with "warn" (stays in the report, stops',
278
+ 'blocking) or "off" (does not run at all).',
279
+ 'Adding a check with "error" is not a restatement of the default — it also',
280
+ "PROMOTES that check's advisory findings to failures, which is stricter.",
281
+ ].join(' '),
282
+ severity: {},
283
+ // The knobs people reach for first.
224
284
  //
225
- // Empty is the whole point: an empty container changes nothing, so it cannot
226
- // freeze today's default in someone else's repository, but it puts the edit
227
- // one line away instead of behind a lookup. Which blocks appear here is
228
- // decided by what actually makes noise in real repositories — spelling,
229
- // lint rules, generated files, knip's guesses about unused dependencies.
285
+ // Two shapes appear here, and the difference is not cosmetic. A list whose
286
+ // values are APPENDED to the defaults is seeded empty: empty changes nothing,
287
+ // so it cannot freeze today's default in someone else's repository. A list
288
+ // that REPLACES its default is written out in full instead — seeded empty it
289
+ // would silently switch the default off, and left out entirely it would be
290
+ // invisible, which is how a repository ends up spellchecking its own
291
+ // CHANGELOG and reporting every test fixture as duplication.
230
292
  //
231
- // Three blocks are deliberately NOT here, because for them an empty value is
232
- // not neutral — it REPLACES a non-empty default and makes things worse:
233
- // ignoreDirs → seeded empty, nothing is ignored at all
234
- // duplicate.ignore → test boilerplate starts reporting as duplication
235
- // deadCode.ignoreFiles → test helpers start reporting as unused exports
236
- // Anyone who needs those sets them deliberately, with the defaults in view.
293
+ // Written-out lists are a deliberate trade: they stop tracking the package,
294
+ // so a later release that adds a directory to the defaults will not reach a
295
+ // config that already spells the list out. That is the price of being able
296
+ // to see and edit the value, and it is the one people asked for.
237
297
  excludePaths: [],
298
+ // REPLACES the default. Copied out so the list is editable in place —
299
+ // note that ESLint does not read it; ESLint honours `eslint.ignorePatterns`.
300
+ ignoreDirs: [...config_1.IGNORED_DIRS],
301
+ // Spelling: `words` and `ignorePaths` are both APPENDED to the bundled lists,
302
+ // so both stay empty. What the bundled list already skips is spelled out in
303
+ // the sibling comment rather than duplicated here.
304
+ '// cspell': `Already skipped by default: ${config_1.GENERATED_DOC_FILES.join(', ')}, test and mock paths, lockfiles, minified bundles, and every directory in "ignoreDirs". Anything listed below is ADDED to that.`,
238
305
  cspell: { words: [], ignorePaths: [] },
239
- eslint: { rules: {}, ignorePatterns: [] },
306
+ // `max-len` is written out at its default rather than left implicit: it is
307
+ // the rule people hit first and tune first, and a line limit is a house
308
+ // style, not a defect — arguing with it through documentation wastes
309
+ // everyone's time when the number could just be sitting here, editable.
310
+ eslint: { rules: { 'max-len': ['warn', { code: 300 }] }, ignorePatterns: [] },
240
311
  markdownlint: { ignores: [] },
241
312
  dependency: { ignore: [] },
242
313
  circularDeps: { ignore: [] },
243
314
  yamlLint: { ignore: [] },
315
+ // The three lists that REPLACE their defaults, written out. Each one exists
316
+ // because test code trips a check that production code does not: suites
317
+ // repeat their setup, the runner is the only consumer of what they export,
318
+ // and a security test's whole job may be to call the dangerous function.
319
+ // Delete an entry to have that check start reporting on tests again.
320
+ duplicate: { ignore: [...config_1.DUPLICATE_IGNORE_DEFAULTS] },
321
+ deadCode: { ignoreFiles: [...config_1.DEAD_CODE_IGNORE_DEFAULTS] },
322
+ diffSecurity: { ignorePaths: [...config_1.DIFF_SECURITY_IGNORE_DEFAULTS] },
244
323
  documentation: config_1.documentation,
245
324
  languages,
246
325
  };
@@ -17,6 +17,10 @@ const configFiles = [
17
17
  { source: 'src/config/cspell.json', dest: 'cspell.json' },
18
18
  { source: 'tsconfig.json', dest: 'tsconfig.json' },
19
19
  { source: 'src/config/pr-checkmate-workflow.yml', dest: 'dist/config/pr-checkmate-workflow.yml' },
20
+ {
21
+ source: 'src/config/pr-checkmate-workflow-docker.yml',
22
+ dest: 'dist/config/pr-checkmate-workflow-docker.yml',
23
+ },
20
24
  { source: 'src/config/pr-checkmate.schema.json', dest: 'pr-checkmate.schema.json' },
21
25
  ];
22
26
  /**
@@ -12,7 +12,7 @@
12
12
  "@typescript-eslint/no-floating-promises": ["error"],
13
13
  "space-before-function-paren": "off",
14
14
  "operator-linebreak": "off",
15
- "max-len": ["warn", { "code": 100 }],
15
+ "max-len": ["warn", { "code": 300 }],
16
16
  "semi": ["warn", "always"],
17
17
  "quotes": ["warn", "single", { "avoidEscape": true }],
18
18
  "object-curly-spacing": ["warn", "always"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-checkmate",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Security-first PR gate for CI: secret scanning, grype dependency scanning, SBOM — plus linting and formatting across 11 languages",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -34,6 +34,7 @@
34
34
  ".prettierrc",
35
35
  "cspell.json",
36
36
  "src/config/pr-checkmate-workflow.yml",
37
+ "src/config/pr-checkmate-workflow-docker.yml",
37
38
  "pr-checkmate.schema.json",
38
39
  ".markdownlint.json",
39
40
  "gitleaks.toml",
@@ -1752,7 +1752,14 @@
1752
1752
  "/**/yarn.lock",
1753
1753
  "/**/pnpm-lock.yaml",
1754
1754
  "/**/*.min.js",
1755
- "/**/*.min.css"
1755
+ "/**/*.min.css",
1756
+ "/CHANGELOG.md",
1757
+ "/**/__tests__/**",
1758
+ "/**/__mocks__/**",
1759
+ "/**/*.test.*",
1760
+ "/**/*.spec.*",
1761
+ "/**/test/**",
1762
+ "/**/tests/**"
1756
1763
  ],
1757
1764
  "enableFiletypes": ["typescript", "javascript", "markdown", "json"],
1758
1765
  "ignoreRegExpList": ["/https?:\\/\\/[^\\s]+/g", "/\\b[0-9a-f]{7,40}\\b/gi"]
@@ -12,7 +12,7 @@
12
12
  "@typescript-eslint/no-floating-promises": ["error"],
13
13
  "space-before-function-paren": "off",
14
14
  "operator-linebreak": "off",
15
- "max-len": ["warn", { "code": 100 }],
15
+ "max-len": ["warn", { "code": 300 }],
16
16
  "semi": ["warn", "always"],
17
17
  "quotes": ["warn", "single", { "avoidEscape": true }],
18
18
  "object-curly-spacing": ["warn", "always"],
@@ -0,0 +1,79 @@
1
+ name: PR CheckMate
2
+
3
+ # The container variant, scaffolded because this repository does not carry
4
+ # pr-checkmate as a dependency.
5
+ #
6
+ # The alternative would be `npx pr-checkmate`, and with no node_modules to reuse
7
+ # that resolves to whatever is newest on the registry and downloads it again on
8
+ # every job: 547 MB and about five minutes, measured. Worse, a major release
9
+ # would land on you without a line changing here — and a major release of a gate
10
+ # can start failing builds that passed yesterday.
11
+ #
12
+ # The image is pulled once and cached by the runner, and the tag below pins the
13
+ # major so nothing moves under you.
14
+
15
+ on:
16
+ pull_request:
17
+ branches: [main, master, develop]
18
+ types: [opened, synchronize, reopened]
19
+
20
+ # Least privilege. `pull-requests: write` is what lets the run upsert its summary
21
+ # comment on the PR; drop it and the checks still run, they just report to the log.
22
+ permissions:
23
+ contents: read
24
+ pull-requests: write
25
+
26
+ # Push three times in a minute and the first two runs become answers to questions
27
+ # nobody is asking any more — they still hold a runner, and they still race to
28
+ # upsert the summary comment, so a stale verdict can land on top of a fresh one.
29
+ # Keyed per PR, so runs on different pull requests never cancel each other.
30
+ concurrency:
31
+ group: pr-checkmate-${{ github.workflow }}-${{ github.ref }}
32
+ cancel-in-progress: true
33
+
34
+ jobs:
35
+ pr-checkmate:
36
+ name: PR CheckMate
37
+ runs-on: ubuntu-latest
38
+ # A hung step otherwise runs to GitHub's six-hour default before anyone hears
39
+ # about it. Every check has its own internal timeout; this is the backstop.
40
+ timeout-minutes: 20
41
+
42
+ steps:
43
+ - name: Checkout code
44
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
45
+ with:
46
+ # Required, not optional. Without the full history the PR base commit is
47
+ # not in the clone, no diff range can be built, and every diff-based
48
+ # check skips itself. The run then says `Scan Scope: … set
49
+ # fetch-depth: 0`, which is this line.
50
+ fetch-depth: 0
51
+
52
+ # One job runs the whole registry. Which checks run is decided in
53
+ # pr-checkmate.json (`severity`), not by splitting this into jobs.
54
+ #
55
+ # `--user` is not a detail: without it every file a formatter rewrites comes
56
+ # back owned by root, and the next step in your pipeline fails on
57
+ # permissions long after this one reported success.
58
+ #
59
+ # The environment variables are passed through by name rather than by value:
60
+ # the run reads them to find the PR, and interpolating them into the command
61
+ # line would put their contents into a shell.
62
+ - name: Run PR CheckMate
63
+ env:
64
+ # Not injected automatically: without this the run cannot authenticate,
65
+ # and the summary comment silently never reaches the PR.
66
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
67
+ run: |
68
+ docker run --rm \
69
+ -v "$PWD:/repo" \
70
+ --user "$(id -u):$(id -g)" \
71
+ -e GITHUB_TOKEN \
72
+ -e GITHUB_REPOSITORY \
73
+ -e GITHUB_EVENT_NAME \
74
+ -e GITHUB_EVENT_PATH \
75
+ -e GITHUB_REF \
76
+ -e GITHUB_SHA \
77
+ -e CI \
78
+ -v "$GITHUB_EVENT_PATH:$GITHUB_EVENT_PATH:ro" \
79
+ ghcr.io/mybono/pr-checkmate:__IMAGE_TAG__ all
@@ -11,10 +11,23 @@ permissions:
11
11
  contents: read
12
12
  pull-requests: write
13
13
 
14
+ # Push three times in a minute and the first two runs become answers to questions
15
+ # nobody is asking any more — they still hold a runner, and they still race to
16
+ # upsert the summary comment, so a stale verdict can land on top of a fresh one.
17
+ # Keyed per PR, so runs on different pull requests never cancel each other.
18
+ concurrency:
19
+ group: pr-checkmate-${{ github.workflow }}-${{ github.ref }}
20
+ cancel-in-progress: true
21
+
14
22
  jobs:
15
23
  pr-checkmate:
16
24
  name: PR CheckMate
17
25
  runs-on: ubuntu-latest
26
+ # A hung step otherwise runs to GitHub's six-hour default before anyone hears
27
+ # about it. Every check here has its own internal timeout; this is the backstop
28
+ # for the ones that cannot fail themselves — a wedged network fetch, a tool
29
+ # waiting on stdin. Raise it for a large monorepo.
30
+ timeout-minutes: 20
18
31
  steps:
19
32
  - name: Checkout code
20
33
  uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -48,9 +61,29 @@ jobs:
48
61
  restore-keys: |
49
62
  pr-checkmate-${{ runner.os }}-
50
63
 
64
+ # Grype keeps its own vulnerability database, and it is the single largest
65
+ # download in the whole run: 134 MB compressed, 1.9 GB unpacked, refreshed
66
+ # daily upstream. Without this step every job pays for it again. The key is
67
+ # dated rather than content-hashed, because the thing that changes is the
68
+ # upstream database, not anything in this repository — a stale entry is
69
+ # restored by the prefix and grype updates it in place.
70
+ - name: Cache Grype vulnerability database
71
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
72
+ with:
73
+ path: ~/.cache/grype
74
+ key: grype-db-${{ runner.os }}-${{ github.run_id }}
75
+ restore-keys: |
76
+ grype-db-${{ runner.os }}-
77
+
51
78
  # One job runs the whole registry. Splitting it per check would repeat the
52
79
  # checkout and install for every one of them, and the selection of what runs
53
80
  # belongs in pr-checkmate.json (`severity`), not in copied YAML.
81
+ #
82
+ # This template is the one `init` scaffolds when pr-checkmate IS a
83
+ # dependency: `npm ci` above installs it, so npx runs that copy at the
84
+ # version your lockfile pins. A repository that does not install it gets
85
+ # the container variant instead, because bare `npx` would resolve to the
86
+ # newest release and download it on every job.
54
87
  - name: Run PR CheckMate
55
88
  env:
56
89
  # Not injected automatically: without this the run cannot authenticate,