pr-checkmate 1.23.24 → 2.0.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.
Files changed (98) hide show
  1. package/README.md +61 -11
  2. package/cspell.json +3 -8
  3. package/dist/cli.js +37 -64
  4. package/dist/config/constants.d.ts +21 -3
  5. package/dist/config/constants.js +33 -6
  6. package/dist/config/pr-checkmate-workflow.yml +33 -327
  7. package/dist/core/checks/dependencies/banned-imports.js +23 -24
  8. package/dist/core/checks/dependencies/circular-deps.js +1 -0
  9. package/dist/core/checks/dependencies/dependency-check.js +1 -0
  10. package/dist/core/checks/dependencies/grype-scan.js +1 -0
  11. package/dist/core/checks/dependencies/npm-audit.js +6 -1
  12. package/dist/core/checks/dependencies/vuln-scan.js +1 -0
  13. package/dist/core/checks/findings.d.ts +198 -19
  14. package/dist/core/checks/findings.js +474 -22
  15. package/dist/core/checks/git/custom-rules.js +21 -16
  16. package/dist/core/checks/git/diff-smell.d.ts +17 -0
  17. package/dist/core/checks/git/diff-smell.js +43 -24
  18. package/dist/core/checks/git/large-files.js +14 -2
  19. package/dist/core/checks/git/leftover-debug.js +23 -14
  20. package/dist/core/checks/git/lockfile-drift.js +2 -1
  21. package/dist/core/checks/git/merge-conflict.js +19 -8
  22. package/dist/core/checks/git/missing-tests.js +22 -6
  23. package/dist/core/checks/git/todo-fixme.js +10 -9
  24. package/dist/core/checks/languages/shellcheck.d.ts +1 -1
  25. package/dist/core/checks/languages/shellcheck.js +1 -1
  26. package/dist/core/checks/languages/typecheck.js +1 -0
  27. package/dist/core/checks/pr/pr-size-check.js +2 -1
  28. package/dist/core/checks/quality/config-validation.d.ts +14 -4
  29. package/dist/core/checks/quality/config-validation.js +119 -17
  30. package/dist/core/checks/quality/dead-code.js +1 -0
  31. package/dist/core/checks/quality/duplicate-check.js +1 -0
  32. package/dist/core/checks/quality/license-header.js +12 -2
  33. package/dist/core/checks/quality/spellcheck.d.ts +50 -0
  34. package/dist/core/checks/quality/spellcheck.js +141 -13
  35. package/dist/core/checks/quality/yaml-lint.js +22 -9
  36. package/dist/core/checks/security/diff-security.js +111 -28
  37. package/dist/core/checks/security/dockerfile-security.js +23 -20
  38. package/dist/core/checks/security/migration-safety.js +19 -17
  39. package/dist/core/checks/security/sensitive-file-guard.js +37 -5
  40. package/dist/core/checks/security/workflow-security.d.ts +10 -0
  41. package/dist/core/checks/security/workflow-security.js +48 -26
  42. package/dist/core/commands.d.ts +27 -0
  43. package/dist/core/commands.js +91 -0
  44. package/dist/core/context.d.ts +12 -3
  45. package/dist/core/context.js +118 -16
  46. package/dist/core/install/cache.d.ts +39 -0
  47. package/dist/core/install/cache.js +99 -0
  48. package/dist/core/install/doctor.d.ts +35 -0
  49. package/dist/core/install/doctor.js +145 -0
  50. package/dist/core/install/ensure.d.ts +60 -0
  51. package/dist/core/install/ensure.js +169 -0
  52. package/dist/core/install/lock.d.ts +33 -0
  53. package/dist/core/install/lock.js +99 -0
  54. package/dist/core/install/profiles.d.ts +64 -0
  55. package/dist/core/install/profiles.js +170 -0
  56. package/dist/core/reporters.d.ts +13 -2
  57. package/dist/core/reporters.js +145 -104
  58. package/dist/core/run-checks.js +91 -14
  59. package/dist/core/targets.js +26 -4
  60. package/dist/core/types.d.ts +49 -1
  61. package/dist/init.d.ts +29 -14
  62. package/dist/init.js +204 -262
  63. package/dist/interfaces/index.d.ts +120 -0
  64. package/dist/scripts/generate-install-manifests.d.ts +39 -0
  65. package/dist/scripts/generate-install-manifests.js +121 -0
  66. package/dist/scripts/publish-github.js +18 -4
  67. package/dist/utils/detect-language.d.ts +37 -4
  68. package/dist/utils/detect-language.js +113 -19
  69. package/dist/utils/http.d.ts +59 -0
  70. package/dist/utils/http.js +119 -0
  71. package/dist/utils/logger.d.ts +52 -6
  72. package/dist/utils/logger.js +125 -33
  73. package/dist/utils/pool.d.ts +49 -0
  74. package/dist/utils/pool.js +124 -0
  75. package/dist/utils/update-notifier.d.ts +9 -0
  76. package/dist/utils/update-notifier.js +57 -3
  77. package/eslint.config.mjs +14 -2
  78. package/install-manifests/commitlint/package-lock.json +1028 -0
  79. package/install-manifests/commitlint/package.json +9 -0
  80. package/install-manifests/core/package-lock.json +1335 -0
  81. package/install-manifests/core/package.json +11 -0
  82. package/install-manifests/cpp/package-lock.json +29 -0
  83. package/install-manifests/cpp/package.json +8 -0
  84. package/install-manifests/duplicate/package-lock.json +113 -0
  85. package/install-manifests/duplicate/package.json +8 -0
  86. package/install-manifests/js/package-lock.json +5099 -0
  87. package/install-manifests/js/package.json +26 -0
  88. package/install-manifests/markdown/package-lock.json +1281 -0
  89. package/install-manifests/markdown/package.json +8 -0
  90. package/install-manifests/python/package-lock.json +21 -0
  91. package/install-manifests/python/package.json +8 -0
  92. package/install-manifests/spellcheck/package-lock.json +1047 -0
  93. package/install-manifests/spellcheck/package.json +8 -0
  94. package/package.json +13 -28
  95. package/pr-checkmate.schema.json +1062 -104
  96. package/src/config/cspell.json +3 -8
  97. package/src/config/pr-checkmate-workflow.yml +33 -327
  98. package/src/config/pr-checkmate.schema.json +1062 -104
@@ -72,21 +72,53 @@ function categorizeFiles(changedFiles) {
72
72
  exports.sensitiveFileGuardCheck = {
73
73
  name: 'Sensitive Files',
74
74
  phase: 'informational',
75
+ // No `requiresDiff`: a committed `.env` or private key is committed whether
76
+ // this PR touched it or not (ticket 067). In a full scan the question becomes
77
+ // "what sensitive files does this repository contain", which is the more
78
+ // useful one when auditing a repo rather than reviewing a change.
75
79
  applies(ctx) {
76
- return ctx.config.sensitiveFileGuard?.enabled !== false && Boolean(ctx.baseSha);
80
+ return ctx.config.sensitiveFileGuard?.enabled !== false;
77
81
  },
78
82
  async run(ctx) {
79
83
  try {
80
- const { stdout } = await (0, execa_1.execa)('git', ['diff', '--name-only', '--diff-filter=ACMRD', ctx.baseSha, 'HEAD'], { cwd: ctx.cwd });
81
- const changedFiles = stdout.split('\n').filter(Boolean);
84
+ const args = ctx.baseSha
85
+ ? ['diff', '--name-only', '--diff-filter=ACMRD', ctx.baseSha, 'HEAD']
86
+ : ['ls-files'];
87
+ if (!ctx.baseSha && ctx.config.run?.fullScanBehaviour === 'skip')
88
+ return (0, types_1.pass)();
89
+ // `reject: false` so a directory that is not a repository is an ordinary
90
+ // outcome rather than a thrown error whose message carries git's raw
91
+ // `fatal: not a git repository` into the log. The check still skips — but
92
+ // a skip with a reason reads as a decision, while a bare `fatal:` reads as
93
+ // a crash, and someone has to spend time proving it was not one.
94
+ const result = await (0, execa_1.execa)('git', args, { cwd: ctx.cwd, reject: false });
95
+ if (result.exitCode !== 0)
96
+ return (0, types_1.skip)('git unavailable');
97
+ const cfg = ctx.config.sensitiveFileGuard ?? {};
98
+ // Path exclusions are applied before categorising, so one false positive —
99
+ // a `.pem` test fixture, a committed `.env.example` — is excused by name.
100
+ // Without this the only exclusion a client has is `ignore`, which suppresses
101
+ // the whole CATEGORY: muting "Private keys and certificates" to excuse one
102
+ // fixture would also excuse the next real key someone commits.
103
+ const changedFiles = result.stdout
104
+ .split('\n')
105
+ .filter(Boolean)
106
+ .filter(file => !(0, utils_1.matchesAnyGlob)(file, cfg.ignorePaths ?? []));
82
107
  if (changedFiles.length === 0)
83
108
  return (0, types_1.pass)();
84
109
  const { errors, warnings } = categorizeFiles(changedFiles);
85
110
  return (0, findings_1.reportSeverityBuckets)(errors, warnings, {
86
111
  prefix: 'Sensitive Files',
87
- errorSummary: n => `${n} sensitive file(s) changed`,
88
- warningSummary: n => `${n} notable file(s) changed`,
112
+ // "changed" is only true of a diff; a full scan is answering a different
113
+ // question — what the repository contains — and saying otherwise would
114
+ // send someone looking for a change that never happened.
115
+ errorSummary: n => `${n} sensitive file(s) ${ctx.baseSha ? 'changed' : 'in the repo'}`,
116
+ warningSummary: n => `${n} notable file(s) ${ctx.baseSha ? 'changed' : 'in the repo'}`,
89
117
  showCount: false,
118
+ ...(0, findings_1.clientFindingOptions)(ctx, cfg),
119
+ // Every sensitive path is listed rather than sampled: knowing that eight
120
+ // files are in play but only which three is not an answer anyone can act on.
121
+ sampleLimit: undefined,
90
122
  });
91
123
  }
92
124
  catch (err) {
@@ -1,2 +1,12 @@
1
1
  import { Check } from '../../types';
2
+ import { DiffPattern } from '../findings';
3
+ /**
4
+ * GitHub Actions workflow risks, matched on added diff lines of workflow files.
5
+ * These are the high-frequency supply-chain / injection footguns.
6
+ *
7
+ * Exported for the corpus in `workflow-security.test.ts`: these rules turn on a
8
+ * context distinction subtle enough that only worked examples can hold it in
9
+ * place.
10
+ */
11
+ export declare const WORKFLOW_PATTERNS: DiffPattern[];
2
12
  export declare const workflowSecurityCheck: Check;
@@ -1,23 +1,46 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.workflowSecurityCheck = void 0;
3
+ exports.workflowSecurityCheck = exports.WORKFLOW_PATTERNS = void 0;
4
4
  const utils_1 = require("../../../utils");
5
+ const targets_1 = require("../../targets");
5
6
  const types_1 = require("../../types");
6
7
  const findings_1 = require("../findings");
8
+ /**
9
+ * A YAML key binding — `HEAD_REF: ${{ … }}`, `ref: ${{ … }}` — rather than an
10
+ * interpolation inside a script body.
11
+ *
12
+ * This is the distinction the rules below turn on. Binding an untrusted value to
13
+ * an `env:` entry or an action input is *the recommended fix*: the value reaches
14
+ * the shell as data, expanded after parsing. Flagging it made the check punish
15
+ * the exact remediation it recommends, which is worse than not checking at all —
16
+ * a warning on correct code teaches people to ignore the warnings.
17
+ *
18
+ * The risky shape is the same expression inside a `run:` block, where GitHub
19
+ * substitutes it into the script *before* the shell parses it.
20
+ */
21
+ const YAML_KEY_BINDING = String.raw `(?!\s*[\w.-]+:\s*\$\{\{)`;
7
22
  /**
8
23
  * GitHub Actions workflow risks, matched on added diff lines of workflow files.
9
24
  * These are the high-frequency supply-chain / injection footguns.
25
+ *
26
+ * Exported for the corpus in `workflow-security.test.ts`: these rules turn on a
27
+ * context distinction subtle enough that only worked examples can hold it in
28
+ * place.
10
29
  */
11
- const WORKFLOW_PATTERNS = [
30
+ exports.WORKFLOW_PATTERNS = [
12
31
  {
13
32
  // Untrusted PR-controlled input interpolated into an expression — the classic
14
33
  // `run:` script-injection vector when it reaches a shell.
15
- pattern: /^\+(?!\+).*\$\{\{\s*github\.event\.(?:issue|pull_request|comment|review|discussion|head_commit|commits)[^}]*\.(?:title|body|head_ref|ref|label|name|email|message|url)/,
34
+ pattern: new RegExp([
35
+ String.raw `^\+(?!\+)${YAML_KEY_BINDING}.*\$\{\{\s*github\.event\.`,
36
+ String.raw `(?:issue|pull_request|comment|review|discussion|head_commit|commits)[^}]*`,
37
+ String.raw `\.(?:title|body|head_ref|ref|label|name|email|message|url)`,
38
+ ].join('')),
16
39
  label: 'untrusted github.event input in expression (script injection risk)',
17
40
  severity: 'error',
18
41
  },
19
42
  {
20
- pattern: /^\+(?!\+).*\$\{\{\s*github\.head_ref\s*\}\}/,
43
+ pattern: new RegExp(String.raw `^\+(?!\+)${YAML_KEY_BINDING}.*\$\{\{\s*github\.head_ref\s*\}\}`),
21
44
  label: 'github.head_ref interpolation (injection risk — use an env var)',
22
45
  severity: 'warn',
23
46
  },
@@ -33,38 +56,37 @@ const WORKFLOW_PATTERNS = [
33
56
  severity: 'warn',
34
57
  },
35
58
  ];
36
- function scanDiff(stdout) {
37
- const errors = {};
38
- const warnings = {};
39
- for (const line of stdout.split('\n')) {
40
- for (const { pattern, label, severity } of WORKFLOW_PATTERNS) {
41
- if (pattern.test(line)) {
42
- const bucket = severity === 'error' ? errors : warnings;
43
- (bucket[label] ?? (bucket[label] = [])).push(line.slice(1).trim().substring(0, 120));
44
- }
45
- }
46
- }
47
- return { errors, warnings };
48
- }
49
59
  exports.workflowSecurityCheck = {
50
60
  name: 'Workflow Security',
51
61
  phase: 'informational',
62
+ // No `requiresDiff`: an injectable workflow is injectable whether the line
63
+ // arrived in this PR or long before it (ticket 067). Ours had lived there for
64
+ // months when ticket 060 found it — by hand, because no mode looked.
52
65
  applies(ctx) {
53
- return ctx.config.workflowSecurity?.enabled !== false && Boolean(ctx.baseSha);
66
+ return ctx.config.workflowSecurity?.enabled !== false;
54
67
  },
55
68
  async run(ctx) {
56
69
  try {
57
- const stdout = await (0, findings_1.diffAddedLines)(ctx, [
58
- '.github/workflows/*.yml',
59
- '.github/workflows/*.yaml',
60
- ]);
61
- const { errors, warnings } = scanDiff(stdout);
62
- return (0, findings_1.reportSeverityBuckets)(errors, warnings, {
70
+ const globs = ['.github/workflows/*.yml', '.github/workflows/*.yaml'];
71
+ const cfg = ctx.config.workflowSecurity ?? {};
72
+ const found = (0, findings_1.emptyBuckets)();
73
+ const scanInto = (stdout) => {
74
+ // `diffAddedLines` already dropped `pr-checkmate-ignore` lines, and
75
+ // `scanTrackedFiles` does the same for the whole-file path.
76
+ (0, findings_1.mergeSeverityBuckets)(found, (0, findings_1.scanDiffPatterns)(stdout, exports.WORKFLOW_PATTERNS, { ignorePaths: cfg.ignorePaths }));
77
+ };
78
+ if (ctx.baseSha) {
79
+ scanInto(await (0, findings_1.diffAddedLines)(ctx, globs));
80
+ }
81
+ else if (ctx.config.run?.fullScanBehaviour !== 'skip') {
82
+ const files = await (0, targets_1.listTrackedFiles)(ctx, globs, ['.']);
83
+ await (0, findings_1.scanTrackedFiles)(ctx, files, scanInto);
84
+ }
85
+ return (0, findings_1.reportSeverityBuckets)(found.errors, found.warnings, {
63
86
  prefix: 'Workflow Security',
64
87
  errorSummary: n => `${n} issue(s)`,
65
88
  warningSummary: n => `${n} warning(s)`,
66
- sampleLimit: 3,
67
- ignore: ctx.config.workflowSecurity?.ignore,
89
+ ...(0, findings_1.clientFindingOptions)(ctx, cfg),
68
90
  });
69
91
  }
70
92
  catch (err) {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * CLI vocabulary, kept out of `cli.ts` so it can be imported without side
3
+ * effects: that module is an executable entry point — it parses `process.argv`
4
+ * and calls `process.exit` at import time, so anything importing it to read
5
+ * these tables would run the CLI instead.
6
+ *
7
+ * Separating them is what lets `commands.test.ts` assert the invariant that
8
+ * matters here: every command must resolve to a check that actually exists.
9
+ * A command pointing at a name no longer in the registry does not error — the
10
+ * `only` filter simply matches nothing, every check is filtered out, and the run
11
+ * reports success having verified nothing at all.
12
+ */
13
+ /** Maps short CLI command names to their registry check names. */
14
+ export declare const COMMAND_TO_CHECK: Record<string, string>;
15
+ /**
16
+ * Commands that run the whole pipeline (or no pipeline at all) and take no siblings.
17
+ *
18
+ * The three install commands are here rather than in {@link COMMAND_TO_CHECK}
19
+ * because they run no checks at all: they manage the tooling the checks need.
20
+ * `doctor` in particular is the answer to "why did my typecheck not run" once
21
+ * tooling is fetched on demand — a question that must have a command behind it
22
+ * and not a reading of our source.
23
+ */
24
+ export declare const TOOLING_COMMANDS: readonly string[];
25
+ export declare const STANDALONE_COMMANDS: readonly string[];
26
+ /** Flags accepted anywhere on the command line, with what each one means. */
27
+ export declare const FLAGS: Record<string, string>;
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ /**
3
+ * CLI vocabulary, kept out of `cli.ts` so it can be imported without side
4
+ * effects: that module is an executable entry point — it parses `process.argv`
5
+ * and calls `process.exit` at import time, so anything importing it to read
6
+ * these tables would run the CLI instead.
7
+ *
8
+ * Separating them is what lets `commands.test.ts` assert the invariant that
9
+ * matters here: every command must resolve to a check that actually exists.
10
+ * A command pointing at a name no longer in the registry does not error — the
11
+ * `only` filter simply matches nothing, every check is filtered out, and the run
12
+ * reports success having verified nothing at all.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.FLAGS = exports.STANDALONE_COMMANDS = exports.TOOLING_COMMANDS = exports.COMMAND_TO_CHECK = void 0;
16
+ /** Maps short CLI command names to their registry check names. */
17
+ exports.COMMAND_TO_CHECK = {
18
+ // Every check that can fail a run is reachable on its own, so "why does this
19
+ // block me" is answered by running that one check instead of the whole suite.
20
+ 'merge-conflict': 'Merge Conflict',
21
+ lint: 'ESLint',
22
+ deps: 'Dependencies',
23
+ dependencies: 'Dependencies',
24
+ 'npm-audit': 'NPM Audit',
25
+ prettier: 'Prettier',
26
+ spellcheck: 'Spellcheck',
27
+ security: 'Security Scan',
28
+ circular: 'Circular Deps',
29
+ license: 'License Check',
30
+ 'pr-size': 'PR Size',
31
+ 'pr-title': 'PR Title',
32
+ outdated: 'Outdated Deps',
33
+ markdown: 'Markdown',
34
+ 'yaml-lint': 'YAML Lint',
35
+ symlinks: 'Broken Symlinks',
36
+ shellcheck: 'ShellCheck',
37
+ typecheck: 'TypeScript',
38
+ 'diff-smell': 'Diff Smells',
39
+ 'diff-security': 'Diff Security',
40
+ 'workflow-security': 'Workflow Security',
41
+ 'custom-rules': 'Custom Rules',
42
+ 'banned-imports': 'Banned Imports',
43
+ 'license-header': 'License Header',
44
+ 'config-validation': 'Config Validation',
45
+ 'dockerfile-security': 'Dockerfile Security',
46
+ 'case-collision': 'Case Collision',
47
+ 'vuln-scan': 'Vuln Scan',
48
+ 'leftover-debug': 'Leftover Debug',
49
+ 'lockfile-drift': 'Lockfile Drift',
50
+ 'large-files': 'Large Files',
51
+ 'todo-fixme': 'TODO/FIXME',
52
+ 'missing-tests': 'Missing Tests',
53
+ coverage: 'Coverage',
54
+ 'migration-safety': 'Migration Safety',
55
+ 'sensitive-files': 'Sensitive Files',
56
+ 'pr-body': 'PR Body',
57
+ duplicate: 'Duplicate Code',
58
+ commitlint: 'Commitlint',
59
+ 'cpp-format': 'C++ Format',
60
+ 'python-lint': 'Ruff Lint',
61
+ 'python-format': 'Ruff Format',
62
+ 'python-typecheck': 'Python Types',
63
+ 'go-lint': 'Go Vet',
64
+ 'go-format': 'Go Format',
65
+ 'rust-lint': 'Clippy',
66
+ 'rust-format': 'Rustfmt',
67
+ 'csharp-format': 'C# Format',
68
+ 'ruby-lint': 'RuboCop',
69
+ 'php-format': 'PHP CS Fixer',
70
+ };
71
+ /**
72
+ * Commands that run the whole pipeline (or no pipeline at all) and take no siblings.
73
+ *
74
+ * The three install commands are here rather than in {@link COMMAND_TO_CHECK}
75
+ * because they run no checks at all: they manage the tooling the checks need.
76
+ * `doctor` in particular is the answer to "why did my typecheck not run" once
77
+ * tooling is fetched on demand — a question that must have a command behind it
78
+ * and not a reading of our source.
79
+ */
80
+ exports.TOOLING_COMMANDS = ['install', 'doctor', 'cache-prune'];
81
+ exports.STANDALONE_COMMANDS = [
82
+ 'init',
83
+ 'all',
84
+ 'precommit',
85
+ ...exports.TOOLING_COMMANDS,
86
+ ];
87
+ /** Flags accepted anywhere on the command line, with what each one means. */
88
+ exports.FLAGS = {
89
+ '--full': 'review every tracked file instead of the diff (the default outside CI)',
90
+ '--all': 'with "install": fetch every profile, not only the ones this repository needs',
91
+ };
@@ -22,6 +22,8 @@ type RangePlan = {
22
22
  headSha?: string;
23
23
  } | {
24
24
  kind: 'github-event';
25
+ } | {
26
+ kind: 'branch';
25
27
  } | {
26
28
  kind: 'ref';
27
29
  ref: string;
@@ -37,9 +39,16 @@ type RangePlan = {
37
39
  * branch) — a gate exists to judge the diff.
38
40
  * 4. A CI build with no PR context still diffs `HEAD^`: it is a push/branch build,
39
41
  * and delta is why CI runs are fast.
40
- * 5. Anything else is a human at a terminal → full scan. `HEAD^` used to be the
41
- * fallback here, which meant a local `pr-checkmate all` judged only the last
42
- * commit and could report a clean project with a `debugger` committed earlier.
42
+ * 5. Anything else is a human at a terminal, working on a branch → review what the
43
+ * branch changed, i.e. everything since it diverged from the default branch.
44
+ * That is the same set of changes a pull request would show, which is what
45
+ * someone running the tool before opening one is asking about.
46
+ *
47
+ * `HEAD^` was tried here once and was wrong for a different reason: it judged
48
+ * only the last commit, so a `debugger` committed earlier on the branch passed.
49
+ * The merge base has neither problem — it covers every commit on the branch —
50
+ * and on the default branch itself, where there is nothing to diverge from,
51
+ * {@link resolveDiffRange} falls back to a full scan.
43
52
  *
44
53
  * @param opts Run options as passed to `runChecks`.
45
54
  * @param env Environment to read CI markers from. Defaults to the current process.
@@ -20,9 +20,16 @@ const execa_1 = require("../utils/execa");
20
20
  * branch) — a gate exists to judge the diff.
21
21
  * 4. A CI build with no PR context still diffs `HEAD^`: it is a push/branch build,
22
22
  * and delta is why CI runs are fast.
23
- * 5. Anything else is a human at a terminal → full scan. `HEAD^` used to be the
24
- * fallback here, which meant a local `pr-checkmate all` judged only the last
25
- * commit and could report a clean project with a `debugger` committed earlier.
23
+ * 5. Anything else is a human at a terminal, working on a branch → review what the
24
+ * branch changed, i.e. everything since it diverged from the default branch.
25
+ * That is the same set of changes a pull request would show, which is what
26
+ * someone running the tool before opening one is asking about.
27
+ *
28
+ * `HEAD^` was tried here once and was wrong for a different reason: it judged
29
+ * only the last commit, so a `debugger` committed earlier on the branch passed.
30
+ * The merge base has neither problem — it covers every commit on the branch —
31
+ * and on the default branch itself, where there is nothing to diverge from,
32
+ * {@link resolveDiffRange} falls back to a full scan.
26
33
  *
27
34
  * @param opts Run options as passed to `runChecks`.
28
35
  * @param env Environment to read CI markers from. Defaults to the current process.
@@ -47,20 +54,30 @@ function planDiffRange(opts, env = process.env) {
47
54
  }
48
55
  if ((0, utils_1.isCiEnvironment)(env))
49
56
  return { kind: 'ref', ref: 'HEAD^', reason: 'CI build, no PR context' };
50
- return { kind: 'full', reason: 'no CI detected' };
57
+ return { kind: 'branch' };
51
58
  }
59
+ /** A full scan the caller or the environment asked for, not one we fell back to. */
60
+ const intended = (reason) => ({ kind: 'full', reason, degraded: false });
61
+ /**
62
+ * A full scan we ended up with because a diff range could not be built. The
63
+ * distinction matters: the delta-only checks drop out either way, but here that
64
+ * is a symptom (shallow clone, missing base) rather than the intent — and it is
65
+ * the case worth telling the client about, because it silently removes most of
66
+ * the security surface.
67
+ */
68
+ const degraded = (reason) => ({ kind: 'full', reason, degraded: true });
52
69
  /**
53
70
  * Carry out a {@link RangePlan}. Every path that cannot produce a usable base
54
71
  * commit returns no SHAs, which the checks read as "scan everything" — degrading
55
72
  * to a full scan is always safer than diffing against a ref that isn't there and
56
73
  * silently reviewing nothing.
57
74
  *
58
- * @returns The resolved range, plus a label for the log line.
75
+ * @returns The resolved range, plus the scope that produced it.
59
76
  */
60
77
  async function resolveDiffRange(cwd, opts) {
61
78
  const plan = planDiffRange(opts, process.env);
62
79
  if (plan.kind === 'full')
63
- return { scope: `full (${plan.reason})` };
80
+ return { scope: intended(plan.reason) };
64
81
  // Pre-commit mode: diff HEAD against the current index. `git write-tree`
65
82
  // materialises the staged index as a real tree object, so every check that
66
83
  // diffs baseSha..headSha sees exactly the staged changes — no per-check code.
@@ -68,14 +85,18 @@ async function resolveDiffRange(cwd, opts) {
68
85
  try {
69
86
  const head = (await (0, execa_1.execa)('git', ['rev-parse', 'HEAD'], { cwd })).stdout.trim();
70
87
  const tree = (await (0, execa_1.execa)('git', ['write-tree'], { cwd })).stdout.trim();
71
- return { baseSha: head, headSha: tree, scope: 'staged' };
88
+ return { baseSha: head, headSha: tree, scope: { kind: 'staged', degraded: false } };
72
89
  }
73
90
  catch {
74
- return { scope: 'full (no staged range)' };
91
+ return { scope: degraded('no staged range') };
75
92
  }
76
93
  }
77
94
  if (plan.kind === 'explicit') {
78
- return { baseSha: plan.baseSha, headSha: plan.headSha, scope: 'delta (explicit)' };
95
+ return {
96
+ baseSha: plan.baseSha,
97
+ headSha: plan.headSha,
98
+ scope: { kind: 'delta', reason: 'explicit', degraded: false },
99
+ };
79
100
  }
80
101
  let headSha;
81
102
  try {
@@ -83,7 +104,7 @@ async function resolveDiffRange(cwd, opts) {
83
104
  headSha = stdout.trim();
84
105
  }
85
106
  catch {
86
- return { scope: 'full (no HEAD)' };
107
+ return { scope: degraded('no HEAD') };
87
108
  }
88
109
  // GitHub Actions: the PR event payload carries the base branch SHA. Read it
89
110
  // directly so delta works without relying on any client-set variable
@@ -95,19 +116,69 @@ async function resolveDiffRange(cwd, opts) {
95
116
  // Only use it if the commit is actually present in this clone; otherwise
96
117
  // fall through so a shallow clone degrades to a full scan, not 0 files.
97
118
  const baseSha = eventBase ? await (0, utils_1.resolveGitRef)(cwd, eventBase) : undefined;
98
- if (baseSha)
99
- return { baseSha, headSha, scope: 'delta (PR event)' };
119
+ if (baseSha) {
120
+ return { baseSha, headSha, scope: { kind: 'delta', reason: 'PR event', degraded: false } };
121
+ }
100
122
  }
101
123
  catch {
102
124
  // Malformed/unreadable event — fall through to a full scan below.
103
125
  }
104
- return { headSha, scope: 'full (PR base unavailable)' };
126
+ return { headSha, scope: degraded('PR base unavailable') };
105
127
  }
128
+ if (plan.kind === 'branch')
129
+ return resolveBranchRange(cwd, headSha);
106
130
  const baseSha = await (0, utils_1.resolveGitRef)(cwd, plan.ref);
131
+ if (!baseSha)
132
+ return { headSha, scope: degraded(`${plan.ref} unresolvable`) };
133
+ return {
134
+ baseSha,
135
+ headSha,
136
+ scope: { kind: 'delta', reason: `${plan.ref}, ${plan.reason}`, degraded: false },
137
+ };
138
+ }
139
+ /** Branches a repository's trunk is normally called, most likely first. */
140
+ const DEFAULT_BRANCHES = ['main', 'master', 'develop'];
141
+ /**
142
+ * Where this branch diverged from the trunk, or nothing when it has not.
143
+ *
144
+ * Prefers `origin/HEAD` — the remote's own answer to "what is the default
145
+ * branch" — and falls back to the usual names, local or remote. Returns
146
+ * `undefined` when no trunk is found or when HEAD is already on it, which the
147
+ * caller turns into a full scan: on the trunk there is no branch to review.
148
+ */
149
+ async function findMergeBase(cwd, headSha) {
150
+ const candidates = ['origin/HEAD'];
151
+ for (const name of DEFAULT_BRANCHES)
152
+ candidates.push(`origin/${name}`, name);
153
+ for (const candidate of candidates) {
154
+ const ref = await (0, utils_1.resolveGitRef)(cwd, candidate);
155
+ // A candidate that resolves to HEAD is the branch we are on, not a base.
156
+ if (!ref || ref === headSha)
157
+ continue;
158
+ const { stdout } = await (0, execa_1.execa)('git', ['merge-base', headSha, ref], { cwd, reject: false });
159
+ const base = stdout.trim();
160
+ // An unrelated history has no merge base, and a base equal to HEAD means the
161
+ // branch is behind rather than ahead — neither describes local changes.
162
+ if (base && base !== headSha)
163
+ return base;
164
+ }
165
+ return undefined;
166
+ }
167
+ /**
168
+ * Local run on a branch: review what the branch changed.
169
+ *
170
+ * On the trunk itself there is nothing to diff against, so this degrades to a
171
+ * full scan — deliberately not marked `degraded`, because it is the expected
172
+ * answer there rather than a configuration problem worth warning about.
173
+ */
174
+ async function resolveBranchRange(cwd, headSha) {
175
+ const baseSha = await findMergeBase(cwd, headSha);
176
+ if (!baseSha)
177
+ return { headSha, scope: intended('no branch base — on the default branch?') };
107
178
  return {
108
179
  baseSha,
109
180
  headSha,
110
- scope: baseSha ? `delta (${plan.ref}, ${plan.reason})` : `full (${plan.ref} unresolvable)`,
181
+ scope: { kind: 'delta', reason: 'branch changes', degraded: false },
111
182
  };
112
183
  }
113
184
  /**
@@ -120,12 +191,13 @@ async function buildContext(opts = {}) {
120
191
  // Detection reads the client's `ignoreDirs` from the config already loaded here,
121
192
  // rather than calling getIgnoredDirs() — that one resolves against process.cwd(),
122
193
  // which is not necessarily the `cwd` under review.
123
- const languages = opts.languages ?? (await (0, detect_language_1.detectLanguages)(cwd, config.ignoreDirs));
194
+ applyLogConfig(config);
195
+ const languages = opts.languages ?? (await (0, detect_language_1.detectLanguages)(cwd, config.ignoreDirs, config.detect));
124
196
  const { baseSha, headSha, scope } = await resolveDiffRange(cwd, opts);
125
197
  // `scope` is logged because it decides what the whole run looks at, and getting
126
198
  // it wrong is invisible in the results: a run that reviewed one commit and one
127
199
  // that reviewed the repository both just print passes.
128
- utils_1.logger.info(`[buildContext]: cwd=${cwd} languages=[${languages.join(', ') || 'none'}] write=${opts.write ?? false} scope=${scope}`);
200
+ utils_1.logger.info(`[buildContext]: cwd=${cwd} languages=[${languages.join(', ') || 'none'}] write=${opts.write ?? false} scope=${describeScope(scope)}`);
129
201
  return {
130
202
  cwd,
131
203
  languages,
@@ -133,6 +205,36 @@ async function buildContext(opts = {}) {
133
205
  write: opts.write ?? false,
134
206
  baseSha,
135
207
  headSha,
208
+ scope,
136
209
  cache: new Map(),
137
210
  };
138
211
  }
212
+ /**
213
+ * Human-readable one-liner for a scope, e.g. `full (PR base unavailable)`.
214
+ *
215
+ * Deliberately not exported: `buildContext` is the only caller, and the scope it
216
+ * describes already travels on the context for anyone who wants to phrase it
217
+ * differently. An `export` here would only be API surface Dead Code reports.
218
+ */
219
+ function describeScope(scope) {
220
+ return scope.reason ? `${scope.kind} (${scope.reason})` : scope.kind;
221
+ }
222
+ /**
223
+ * Apply the client's `log` block, with environment variables taking precedence.
224
+ *
225
+ * Env wins because the config file is committed and the environment is not: a
226
+ * one-off `PR_CHECKMATE_LOG_LEVEL=debug` to diagnose a CI run should not require
227
+ * editing — and later reverting — a tracked file.
228
+ */
229
+ function applyLogConfig(config) {
230
+ const log = config.log ?? {};
231
+ const level = process.env.PR_CHECKMATE_LOG_LEVEL ?? log.level;
232
+ const format = process.env.PR_CHECKMATE_LOG_FORMAT ?? log.format;
233
+ utils_1.logger.configure({
234
+ ...(level ? { level } : {}),
235
+ ...(format ? { format } : {}),
236
+ ...(log.color ? { color: log.color } : {}),
237
+ ...(log.timestamps !== undefined ? { timestamps: log.timestamps } : {}),
238
+ ...(log.stderrLevels ? { stderrLevels: log.stderrLevels } : {}),
239
+ });
240
+ }
@@ -0,0 +1,39 @@
1
+ /** Where fetched tooling lives, and why it ended up there. */
2
+ export interface CacheLocation {
3
+ dir: string;
4
+ /** `false` when the configured location was unusable and this is a fallback. */
5
+ preferred: boolean;
6
+ reason?: string;
7
+ }
8
+ /**
9
+ * Expand a leading `~`.
10
+ *
11
+ * Config files are written by hand, `~/.cache/pr-checkmate` is what a person
12
+ * writes, and nothing in Node expands it — the directory would be created
13
+ * literally, as a folder named `~`, in whatever the working directory happened
14
+ * to be.
15
+ */
16
+ export declare function expandHome(dir: string, home?: string): string;
17
+ /**
18
+ * Resolve the cache directory, falling back rather than failing.
19
+ *
20
+ * Every candidate below is a real environment: containers without `HOME`, CI
21
+ * images with a read-only home, and runners where only the temp directory is
22
+ * writable. A tool that refuses to work in any of them is a tool that fails on
23
+ * CI for reasons the client cannot see.
24
+ *
25
+ * @param version The pr-checkmate version, kept in the path so two projects on
26
+ * different versions never share fetched tooling.
27
+ * @param configured `install.cacheDir`, if the client set one.
28
+ */
29
+ export declare function resolveCacheDir(version: string, configured?: string, env?: NodeJS.ProcessEnv): CacheLocation | null;
30
+ /** Where a single profile's tooling is installed. */
31
+ export declare function profileDir(cacheDir: string, profile: string): string;
32
+ /**
33
+ * Profile directories left behind by other versions of pr-checkmate.
34
+ *
35
+ * Nothing removes these automatically: an upgrade must not delete tooling a
36
+ * concurrently running older version is in the middle of using. `cache prune`
37
+ * is the deliberate, separate act.
38
+ */
39
+ export declare function stalePaths(version: string, configured?: string): string[];