pr-checkmate 1.23.20 → 1.23.22

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.
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.detectLanguages = detectLanguages;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
+ const constants_1 = require("../config/constants");
9
10
  const execa_1 = require("./execa");
10
11
  const glob_1 = require("./glob");
11
12
  const logger_1 = require("./logger");
@@ -70,7 +71,7 @@ function hasMarker(root, lang) {
70
71
  * when git is unavailable or the directory is not a repo — callers then fall
71
72
  * back to marker files alone.
72
73
  */
73
- async function countTrackedFiles(root) {
74
+ async function countTrackedFiles(root, ignoreDirs) {
74
75
  const counts = {
75
76
  typescript: 0,
76
77
  javascript: 0,
@@ -87,9 +88,18 @@ async function countTrackedFiles(root) {
87
88
  try {
88
89
  const { stdout } = await (0, execa_1.execa)('git', ['ls-files'], { cwd: root });
89
90
  const files = stdout.split('\n').map(f => f.trim());
91
+ const ignored = new Set(ignoreDirs);
90
92
  for (const file of files) {
91
93
  if (!file)
92
94
  continue;
95
+ // A repository that commits its dependencies (`node_modules`, `vendor`,
96
+ // `third_party`) would otherwise be detected as a Python/Go/PHP project off
97
+ // the back of files nobody in it wrote, switching on whole toolchains for
98
+ // languages absent from the source. Detection has to describe the repo's own
99
+ // code, since it decides which checks run at all — the same exclusion every
100
+ // check's file list already applies via `excludeIgnoredDirs`.
101
+ if ((0, glob_1.isUnderIgnoredDir)(file, ignored))
102
+ continue;
93
103
  const ext = node_path_1.default.extname(file).toLowerCase();
94
104
  for (const lang of Object.keys(EXTENSIONS)) {
95
105
  if (EXTENSIONS[lang].includes(ext)) {
@@ -109,11 +119,16 @@ async function countTrackedFiles(root) {
109
119
  * with tracked source-file counts (breadth). A language is reported when it has a
110
120
  * marker file OR at least one tracked source file.
111
121
  *
122
+ * @param root Repository root.
123
+ * @param ignoreDirs Directory names whose contents do not count as the repo's own
124
+ * code. Defaults to the curated list; `buildContext` passes the client's
125
+ * `ignoreDirs` so a repo that vendors dependencies is described by its own
126
+ * sources. Marker files are unaffected — they are only ever read from `root`.
112
127
  * @returns languages ordered by tracked file count (most-used first). `.h` headers
113
128
  * are intentionally excluded from the C++ count since they are ambiguous with C.
114
129
  */
115
- async function detectLanguages(root = process.cwd()) {
116
- const counts = await countTrackedFiles(root);
130
+ async function detectLanguages(root = process.cwd(), ignoreDirs = constants_1.IGNORED_DIRS) {
131
+ const counts = await countTrackedFiles(root, ignoreDirs);
117
132
  const detected = Object.keys(EXTENSIONS).filter(lang => hasMarker(root, lang) || counts[lang] > 0);
118
133
  detected.sort((a, b) => counts[b] - counts[a]);
119
134
  const countStr = Object.keys(counts).map(l => `${l}=${counts[l]}`).join(', ');
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Resolve a git ref to a concrete commit SHA, or `undefined` when this clone
3
+ * cannot resolve it.
4
+ *
5
+ * `--verify --quiet` emits nothing (rather than echoing the literal ref) and exits
6
+ * non-zero for a missing ref, so the caller gets a clear "not here" instead of a
7
+ * string that looks like a ref but resolves to nothing. Refs go missing more often
8
+ * than it seems: `HEAD^` on a repository's first commit, and any ancestor in a
9
+ * shallow CI clone — `actions/checkout` fetches depth 1 by default.
10
+ *
11
+ * Every caller must degrade rather than fail when this returns `undefined`: a
12
+ * missing base means "review everything", never "review nothing".
13
+ */
14
+ export declare function resolveGitRef(cwd: string, ref: string): Promise<string | undefined>;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveGitRef = resolveGitRef;
4
+ const execa_1 = require("./execa");
5
+ /**
6
+ * Resolve a git ref to a concrete commit SHA, or `undefined` when this clone
7
+ * cannot resolve it.
8
+ *
9
+ * `--verify --quiet` emits nothing (rather than echoing the literal ref) and exits
10
+ * non-zero for a missing ref, so the caller gets a clear "not here" instead of a
11
+ * string that looks like a ref but resolves to nothing. Refs go missing more often
12
+ * than it seems: `HEAD^` on a repository's first commit, and any ancestor in a
13
+ * shallow CI clone — `actions/checkout` fetches depth 1 by default.
14
+ *
15
+ * Every caller must degrade rather than fail when this returns `undefined`: a
16
+ * missing base means "review everything", never "review nothing".
17
+ */
18
+ async function resolveGitRef(cwd, ref) {
19
+ const { stdout } = await (0, execa_1.execa)('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
20
+ cwd,
21
+ reject: false,
22
+ });
23
+ return stdout.trim() || undefined;
24
+ }
@@ -8,3 +8,17 @@
8
8
  export declare function globToRegExp(glob: string): RegExp;
9
9
  /** True when `file` matches any of the glob `patterns`. */
10
10
  export declare function matchesAnyGlob(file: string, patterns: string[]): boolean;
11
+ /**
12
+ * True when any segment of a repo-relative path names an excluded directory —
13
+ * `node_modules/x/y.py` for `node_modules`, at any nesting depth.
14
+ *
15
+ * Shared so the two consumers cannot drift: the file list every check operates on
16
+ * (`excludeIgnoredDirs` in core/targets.ts) and language detection, which counts
17
+ * tracked source files. They disagreed once — detection counted a repository that
18
+ * commits its dependencies as a Python/Go/PHP project and switched on the checks
19
+ * for languages nobody had written a line of.
20
+ *
21
+ * @param file Repo-relative path, forward slashes.
22
+ * @param ignoredDirs Directory NAMES (not globs) to exclude.
23
+ */
24
+ export declare function isUnderIgnoredDir(file: string, ignoredDirs: Iterable<string>): boolean;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.globToRegExp = globToRegExp;
4
4
  exports.matchesAnyGlob = matchesAnyGlob;
5
+ exports.isUnderIgnoredDir = isUnderIgnoredDir;
5
6
  /**
6
7
  * Minimal glob → RegExp for path matching against config patterns. Supports `*`
7
8
  * (matches within a path segment), `**` (across segments), and a leading `** /`
@@ -23,3 +24,22 @@ function globToRegExp(glob) {
23
24
  function matchesAnyGlob(file, patterns) {
24
25
  return patterns.some(p => globToRegExp(p).test(file));
25
26
  }
27
+ /**
28
+ * True when any segment of a repo-relative path names an excluded directory —
29
+ * `node_modules/x/y.py` for `node_modules`, at any nesting depth.
30
+ *
31
+ * Shared so the two consumers cannot drift: the file list every check operates on
32
+ * (`excludeIgnoredDirs` in core/targets.ts) and language detection, which counts
33
+ * tracked source files. They disagreed once — detection counted a repository that
34
+ * commits its dependencies as a Python/Go/PHP project and switched on the checks
35
+ * for languages nobody had written a line of.
36
+ *
37
+ * @param file Repo-relative path, forward slashes.
38
+ * @param ignoredDirs Directory NAMES (not globs) to exclude.
39
+ */
40
+ function isUnderIgnoredDir(file, ignoredDirs) {
41
+ const ignored = ignoredDirs instanceof Set ? ignoredDirs : new Set(ignoredDirs);
42
+ if (ignored.size === 0)
43
+ return false;
44
+ return file.split('/').some(segment => ignored.has(segment));
45
+ }
@@ -6,6 +6,8 @@ export * from './direct-deps';
6
6
  export * from './detect-language';
7
7
  export * from './glob';
8
8
  export * from './update-notifier';
9
+ export * from './ci';
10
+ export * from './git';
9
11
  /**
10
12
  *
11
13
  */
@@ -28,6 +28,8 @@ __exportStar(require("./direct-deps"), exports);
28
28
  __exportStar(require("./detect-language"), exports);
29
29
  __exportStar(require("./glob"), exports);
30
30
  __exportStar(require("./update-notifier"), exports);
31
+ __exportStar(require("./ci"), exports);
32
+ __exportStar(require("./git"), exports);
31
33
  /**
32
34
  *
33
35
  */
@@ -13,13 +13,13 @@
13
13
  "space-before-function-paren": "off",
14
14
  "operator-linebreak": "off",
15
15
  "max-len": ["warn", { "code": 100 }],
16
- "semi": ["error", "always"],
17
- "quotes": ["error", "single", { "avoidEscape": true }],
18
- "object-curly-spacing": ["error", "always"],
19
- "space-infix-ops": "error",
20
- "keyword-spacing": ["error", { "before": true, "after": true }],
16
+ "semi": ["warn", "always"],
17
+ "quotes": ["warn", "single", { "avoidEscape": true }],
18
+ "object-curly-spacing": ["warn", "always"],
19
+ "space-infix-ops": "warn",
20
+ "keyword-spacing": ["warn", { "before": true, "after": true }],
21
21
  "padding-line-between-statements": [
22
- "error",
22
+ "warn",
23
23
  { "blankLine": "always", "prev": "block", "next": "*" },
24
24
  { "blankLine": "always", "prev": "*", "next": "return" }
25
25
  ],
@@ -50,7 +50,7 @@
50
50
  "jsdoc/check-tag-names": "warn",
51
51
  "jsdoc/check-types": "warn",
52
52
  "prettier/prettier": [
53
- "error",
53
+ "warn",
54
54
  {
55
55
  "semi": true,
56
56
  "singleQuote": true,
package/eslint.config.mjs CHANGED
@@ -12,6 +12,22 @@ import jsdocPlugin from 'eslint-plugin-jsdoc';
12
12
  * Default ESLint rules — single source of truth, shared with init.ts so the
13
13
  * pr-checkmate.json it generates lists exactly the rules applied here. Edit the
14
14
  * rules in `eslint-default-rules.json` (shipped alongside this file).
15
+ *
16
+ * SEVERITY POLICY: `error` is reserved for defects a human has to resolve —
17
+ * `no-debugger`, `eqeqeq`, `prefer-const`, `@typescript-eslint/no-unused-vars`,
18
+ * `@typescript-eslint/no-floating-promises`, `import/no-duplicates`. Everything
19
+ * that is purely formatting is `warn`, even
20
+ * though it is auto-fixable, precisely BECAUSE it is auto-fixable: ESLint exits 1
21
+ * on errors only, and the ESLint check maps exit 1 to a blocking failure, so an
22
+ * `error`-level whitespace rule turns `npx prettier --write` worth of work into a
23
+ * red gate on someone's PR. Formatting still gets reported — as a warning here and
24
+ * by the dedicated Prettier check, which has always been advisory. A repo that
25
+ * wants formatting to gate promotes it back with
26
+ * `eslint.rules: { "prettier/prettier": "error" }` or
27
+ * `severity: { "Prettier": "error" }` in pr-checkmate.json.
28
+ *
29
+ * A JSON file cannot carry this note, hence it lives here; `src/config/.eslintrc.js`
30
+ * holds the same rule set for the legacy ESLint <9 path and must stay in sync.
15
31
  */
16
32
  const require = createRequire(import.meta.url);
17
33
  export const defaultRules = require('./eslint-default-rules.json');
@@ -34,7 +50,7 @@ const defaultIgnores = [
34
50
  function loadUserConfig() {
35
51
  let currentDir = process.cwd();
36
52
  let configPath = path.join(currentDir, 'pr-checkmate.json');
37
-
53
+
38
54
  // If running from node_modules, find project root
39
55
  if (currentDir.includes('node_modules')) {
40
56
  const parts = currentDir.split(path.sep);
@@ -44,7 +60,7 @@ function loadUserConfig() {
44
60
  configPath = path.join(currentDir, 'pr-checkmate.json');
45
61
  }
46
62
  }
47
-
63
+
48
64
  if (fs.existsSync(configPath)) {
49
65
  try {
50
66
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
@@ -56,7 +72,7 @@ function loadUserConfig() {
56
72
  } else {
57
73
  console.error(`[loadUserConfig]: No pr-checkmate.json found, using defaults`);
58
74
  }
59
-
75
+
60
76
  return { sourcePath: 'src', eslint: {} };
61
77
  }
62
78
 
@@ -84,6 +100,24 @@ const customIgnoresCount = (userConfig.eslint?.ignorePatterns || []).length;
84
100
  if (customRulesCount > 0) {
85
101
  console.error(`Applied ${customRulesCount} custom ESLint rules`);
86
102
  }
103
+
104
+ // A `pr-checkmate.json` written by an older `init` contains a VERBATIM COPY of the
105
+ // bundled rule set — all of it, severities included. Client rules merge on top of
106
+ // the defaults, so that copy silently pins every severity forever: a repo in this
107
+ // state keeps failing on `prettier/prettier: error` long after the bundled default
108
+ // became `warn`, and nothing in the output says why. Detected by rule NAMES rather
109
+ // than values (nobody hand-writes exactly this list), and reported where the person
110
+ // reading a failed lint run will actually see it.
111
+ const defaultRuleNames = Object.keys(defaultRules);
112
+ const userRuleNames = Object.keys(userConfig.eslint?.rules || {});
113
+ if (defaultRuleNames.every(name => userRuleNames.includes(name))) {
114
+ console.error(
115
+ `⚠️ eslint.rules in pr-checkmate.json lists all ${defaultRuleNames.length} built-in rules — ` +
116
+ 'a copy written by an older `init`. It overrides the bundled defaults, so updates to them ' +
117
+ '(formatting is advisory since 1.23.21) never reach this repo. Delete the block to track ' +
118
+ 'the defaults again, or keep it to pin these severities deliberately.',
119
+ );
120
+ }
87
121
  if (customIgnoresCount > 0) {
88
122
  console.error(`🚫 Applied ${customIgnoresCount} custom ignore patterns`);
89
123
  }
@@ -117,4 +151,4 @@ export default [
117
151
 
118
152
  rules: mergedRules,
119
153
  },
120
- ];
154
+ ];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-checkmate",
3
- "version": "1.23.20",
4
- "description": "Automated PR quality checks: linting, formatting, dependency analysis, and spellcheck",
3
+ "version": "1.23.22",
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",
7
7
  "pull-request",
@@ -51,6 +51,7 @@
51
51
  "docs": "typedoc",
52
52
  "scan:secrets": "gitleaks-secret-scanner --diff-mode ci --html-report gitleaks-report.html",
53
53
  "test": "node --require ts-node/register --test \"src/**/*.test.ts\"",
54
+ "verify:tarball": "bash scripts/verify-tarball.sh",
54
55
  "security-scan": "npx pr-checkmate security",
55
56
  "clean": "rm -rf dist",
56
57
  "release": "standard-version"
@@ -108,7 +108,12 @@
108
108
  "type": "object",
109
109
  "properties": {
110
110
  "enabled": { "type": "boolean" },
111
- "version": { "type": "string" }
111
+ "version": { "type": "string" },
112
+ "depth": {
113
+ "description": "Caps the whole-repository history scan used when there is no diff range (a local run or --full) to the last N commits. Ignored in a PR run, which scans the diff range.",
114
+ "type": "integer",
115
+ "minimum": 1
116
+ }
112
117
  }
113
118
  },
114
119
  "npm-audit": {
@@ -70,13 +70,15 @@ module.exports = {
70
70
  'space-before-function-paren': 'off',
71
71
  'operator-linebreak': 'off',
72
72
  'max-len': ['warn', { code: 100 }],
73
- semi: ['error', 'always'],
74
- quotes: ['error', 'single', { avoidEscape: true }],
75
- 'object-curly-spacing': ['error', 'always'],
76
- 'space-infix-ops': 'error',
77
- 'keyword-spacing': ['error', { before: true, after: true }],
73
+ // Formatting is advisory — see the note above `rules` in eslint-flat-config.mjs.
74
+ // Keep these in sync with src/config/eslint-default-rules.json.
75
+ semi: ['warn', 'always'],
76
+ quotes: ['warn', 'single', { avoidEscape: true }],
77
+ 'object-curly-spacing': ['warn', 'always'],
78
+ 'space-infix-ops': 'warn',
79
+ 'keyword-spacing': ['warn', { before: true, after: true }],
78
80
  'padding-line-between-statements': [
79
- 'error',
81
+ 'warn',
80
82
  { blankLine: 'always', prev: 'block', next: '*' },
81
83
  { blankLine: 'always', prev: '*', next: 'return' },
82
84
  ],
@@ -108,7 +110,7 @@ module.exports = {
108
110
  'jsdoc/check-tag-names': 'warn',
109
111
  'jsdoc/check-types': 'warn',
110
112
  'prettier/prettier': [
111
- 'error',
113
+ 'warn',
112
114
  {
113
115
  semi: true,
114
116
  singleQuote: true,