pr-checkmate 2.1.2 → 3.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 (97) hide show
  1. package/README.md +63 -268
  2. package/cspell.json +155 -133
  3. package/dist/config/constants.d.ts +6 -1
  4. package/dist/config/constants.js +8 -7
  5. package/dist/config/index.d.ts +23 -11
  6. package/dist/config/index.js +27 -33
  7. package/dist/core/checks/dependencies/banned-imports.js +4 -1
  8. package/dist/core/checks/dependencies/circular-deps.js +14 -5
  9. package/dist/core/checks/dependencies/dependency-check.js +1 -1
  10. package/dist/core/checks/dependencies/grype-scan.js +2 -1
  11. package/dist/core/checks/dependencies/vuln-scan.js +2 -1
  12. package/dist/core/checks/findings.d.ts +46 -2
  13. package/dist/core/checks/findings.js +140 -2
  14. package/dist/core/checks/git/custom-rules.js +2 -0
  15. package/dist/core/checks/git/diff-smell.js +6 -1
  16. package/dist/core/checks/git/leftover-debug.js +8 -2
  17. package/dist/core/checks/git/lockfile-drift.js +6 -1
  18. package/dist/core/checks/git/merge-conflict.js +1 -1
  19. package/dist/core/checks/git/missing-tests.js +1 -1
  20. package/dist/core/checks/git/todo-fixme.js +10 -2
  21. package/dist/core/checks/languages/cpp-format.js +19 -42
  22. package/dist/core/checks/languages/external-checks.d.ts +77 -0
  23. package/dist/core/checks/languages/external-checks.js +200 -0
  24. package/dist/core/checks/languages/external-runner.d.ts +119 -0
  25. package/dist/core/checks/languages/external-runner.js +143 -0
  26. package/dist/core/checks/languages/go-format.d.ts +4 -4
  27. package/dist/core/checks/languages/go-format.js +151 -30
  28. package/dist/core/checks/languages/lint.js +19 -19
  29. package/dist/core/checks/languages/prettier.js +2 -7
  30. package/dist/core/checks/languages/python-typecheck.d.ts +10 -5
  31. package/dist/core/checks/languages/python-typecheck.js +161 -37
  32. package/dist/core/checks/languages/typecheck.js +10 -8
  33. package/dist/core/checks/pr/commitlint.js +7 -1
  34. package/dist/core/checks/quality/case-collision.js +6 -4
  35. package/dist/core/checks/quality/config-validation.d.ts +0 -7
  36. package/dist/core/checks/quality/config-validation.js +30 -0
  37. package/dist/core/checks/quality/duplicate-check.js +54 -27
  38. package/dist/core/checks/quality/license-header.js +7 -4
  39. package/dist/core/checks/quality/markdown-lint.js +1 -6
  40. package/dist/core/checks/quality/spellcheck.js +34 -5
  41. package/dist/core/checks/quality/symlinks.js +6 -4
  42. package/dist/core/checks/security/diff-security.js +26 -18
  43. package/dist/core/checks/security/dockerfile-security.js +7 -1
  44. package/dist/core/checks/security/migration-safety.js +32 -4
  45. package/dist/core/checks/security/security.d.ts +56 -0
  46. package/dist/core/checks/security/security.js +135 -11
  47. package/dist/core/checks/security/sensitive-file-guard.js +40 -6
  48. package/dist/core/checks/security/workflow-security.js +7 -1
  49. package/dist/core/checks/tool-install.d.ts +102 -0
  50. package/dist/core/checks/tool-install.js +69 -0
  51. package/dist/core/context.js +9 -1
  52. package/dist/core/install/cache.js +42 -11
  53. package/dist/core/install/doctor.js +9 -0
  54. package/dist/core/install/ensure.js +22 -3
  55. package/dist/core/install/lock.d.ts +2 -1
  56. package/dist/core/install/prepare.d.ts +75 -0
  57. package/dist/core/install/prepare.js +151 -0
  58. package/dist/core/install/profiles.d.ts +17 -3
  59. package/dist/core/install/profiles.js +27 -0
  60. package/dist/core/install/reuse.d.ts +41 -0
  61. package/dist/core/install/reuse.js +107 -0
  62. package/dist/core/registry.js +10 -18
  63. package/dist/core/run-checks.js +74 -3
  64. package/dist/core/targets.d.ts +116 -5
  65. package/dist/core/targets.js +101 -16
  66. package/dist/core/types.d.ts +8 -2
  67. package/dist/init.js +21 -1
  68. package/dist/interfaces/index.d.ts +1 -0
  69. package/dist/utils/resolve-bin.d.ts +56 -6
  70. package/dist/utils/resolve-bin.js +136 -26
  71. package/gitleaks.toml +14 -0
  72. package/install-manifests/go/package-lock.json +24 -0
  73. package/install-manifests/go/package.json +8 -0
  74. package/install-manifests/pytypes/package-lock.json +45 -0
  75. package/install-manifests/pytypes/package.json +8 -0
  76. package/package.json +4 -2
  77. package/pr-checkmate.schema.json +8 -2
  78. package/src/config/cspell.json +155 -133
  79. package/src/config/pr-checkmate.schema.json +8 -2
  80. package/dist/core/checks/languages/csharp-format.d.ts +0 -13
  81. package/dist/core/checks/languages/csharp-format.js +0 -52
  82. package/dist/core/checks/languages/go-lint.d.ts +0 -8
  83. package/dist/core/checks/languages/go-lint.js +0 -48
  84. package/dist/core/checks/languages/kotlin-lint.d.ts +0 -2
  85. package/dist/core/checks/languages/kotlin-lint.js +0 -42
  86. package/dist/core/checks/languages/php-format.d.ts +0 -9
  87. package/dist/core/checks/languages/php-format.js +0 -49
  88. package/dist/core/checks/languages/ruby-lint.d.ts +0 -8
  89. package/dist/core/checks/languages/ruby-lint.js +0 -50
  90. package/dist/core/checks/languages/rust-format.d.ts +0 -9
  91. package/dist/core/checks/languages/rust-format.js +0 -45
  92. package/dist/core/checks/languages/rust-lint.d.ts +0 -8
  93. package/dist/core/checks/languages/rust-lint.js +0 -50
  94. package/dist/core/checks/languages/shellcheck.d.ts +0 -8
  95. package/dist/core/checks/languages/shellcheck.js +0 -55
  96. package/dist/core/checks/languages/swift-lint.d.ts +0 -2
  97. package/dist/core/checks/languages/swift-lint.js +0 -46
@@ -18,21 +18,13 @@ const merge_conflict_1 = require("./checks/git/merge-conflict");
18
18
  const missing_tests_1 = require("./checks/git/missing-tests");
19
19
  const todo_fixme_1 = require("./checks/git/todo-fixme");
20
20
  const cpp_format_1 = require("./checks/languages/cpp-format");
21
- const csharp_format_1 = require("./checks/languages/csharp-format");
21
+ const external_checks_1 = require("./checks/languages/external-checks");
22
22
  const go_format_1 = require("./checks/languages/go-format");
23
- const go_lint_1 = require("./checks/languages/go-lint");
24
- const kotlin_lint_1 = require("./checks/languages/kotlin-lint");
25
23
  const lint_1 = require("./checks/languages/lint");
26
- const php_format_1 = require("./checks/languages/php-format");
27
24
  const prettier_1 = require("./checks/languages/prettier");
28
25
  const python_format_1 = require("./checks/languages/python-format");
29
26
  const python_lint_1 = require("./checks/languages/python-lint");
30
27
  const python_typecheck_1 = require("./checks/languages/python-typecheck");
31
- const ruby_lint_1 = require("./checks/languages/ruby-lint");
32
- const rust_format_1 = require("./checks/languages/rust-format");
33
- const rust_lint_1 = require("./checks/languages/rust-lint");
34
- const shellcheck_1 = require("./checks/languages/shellcheck");
35
- const swift_lint_1 = require("./checks/languages/swift-lint");
36
28
  const typecheck_1 = require("./checks/languages/typecheck");
37
29
  const commitlint_1 = require("./checks/pr/commitlint");
38
30
  const pr_body_1 = require("./checks/pr/pr-body");
@@ -101,18 +93,18 @@ exports.REGISTRY = [
101
93
  python_lint_1.pythonLintCheck,
102
94
  python_typecheck_1.pythonTypecheckCheck,
103
95
  dead_code_1.deadCodeCheck,
104
- swift_lint_1.swiftLintCheck,
105
- kotlin_lint_1.kotlinLintCheck,
106
- go_lint_1.goLintCheck,
107
- rust_lint_1.rustLintCheck,
108
- ruby_lint_1.rubyLintCheck,
109
- shellcheck_1.shellcheckCheck,
96
+ external_checks_1.swiftLintCheck,
97
+ external_checks_1.kotlinLintCheck,
98
+ external_checks_1.goLintCheck,
99
+ external_checks_1.rustLintCheck,
100
+ external_checks_1.rubyLintCheck,
101
+ external_checks_1.shellcheckCheck,
110
102
  // format
111
103
  prettier_1.prettierCheck,
112
104
  cpp_format_1.cppFormatCheck,
113
105
  python_format_1.pythonFormatCheck,
114
106
  go_format_1.goFormatCheck,
115
- rust_format_1.rustFormatCheck,
116
- csharp_format_1.csharpFormatCheck,
117
- php_format_1.phpFormatCheck,
107
+ external_checks_1.rustFormatCheck,
108
+ external_checks_1.csharpFormatCheck,
109
+ external_checks_1.phpFormatCheck,
118
110
  ];
@@ -4,9 +4,12 @@ exports.applySeverity = applySeverity;
4
4
  exports.shouldReportDiffSkip = shouldReportDiffSkip;
5
5
  exports.runChecks = runChecks;
6
6
  const utils_1 = require("../utils");
7
+ const package_version_1 = require("../utils/package-version");
7
8
  const pool_1 = require("../utils/pool");
9
+ const resolve_bin_1 = require("../utils/resolve-bin");
8
10
  const result_collector_1 = require("../utils/result-collector");
9
11
  const context_1 = require("./context");
12
+ const prepare_1 = require("./install/prepare");
10
13
  const registry_1 = require("./registry");
11
14
  const reporters_1 = require("./reporters");
12
15
  const types_1 = require("./types");
@@ -107,13 +110,27 @@ function noDiffReason(ctx) {
107
110
  return `no diff range (${scope.reason}) — set fetch-depth: 0`;
108
111
  return 'full scan — this check reviews a diff';
109
112
  }
113
+ /**
114
+ * Whether a skip came from the missing diff range, as opposed to any other reason
115
+ * a check can decline to run.
116
+ *
117
+ * `reportDegradedScope` counts these to say how much coverage a fallback to a full
118
+ * scan cost. It used to count every skip in the report, which already included
119
+ * unrelated ones ("mypy not installed"), and tooling skips would have added more —
120
+ * turning a number that means "checks you lost to a shallow clone" into "skips,
121
+ * roughly". Matched on the phrasing {@link noDiffReason} produces, which is the
122
+ * only thing that distinguishes them once a skip is just a status and a string.
123
+ */
124
+ function isDiffSkip(details) {
125
+ return Boolean(details && /^no diff range|^full scan — this check reviews a diff/.test(details));
126
+ }
110
127
  /**
111
128
  * Run one check: gate on `applies`, execute, translate throws into `fail`.
112
129
  *
113
130
  * `heavy` admits only a couple of expensive checks at a time, and the per-check
114
131
  * timeout means one that never returns costs a warning rather than the run.
115
132
  */
116
- async function runOne(check, ctx, collector, heavy) {
133
+ async function runOne(check, ctx, collector, heavy, tooling) {
117
134
  try {
118
135
  // A diff-only check without a range is usually a hole in coverage rather
119
136
  // than an irrelevance, so it is reported as a skip and the gap stays visible
@@ -141,6 +158,20 @@ async function runOne(check, ctx, collector, heavy) {
141
158
  // from run() (e.g. "no relevant files changed") are still reported.
142
159
  return;
143
160
  }
161
+ // Tooling is checked AFTER `applies`, so the row only appears for a check
162
+ // that would otherwise have run. A Ruff row in a TypeScript repository would
163
+ // be noise; a Ruff row in a Python repository whose toolchain never arrived
164
+ // is the whole point — ticket 057, applied to tooling instead of diffs.
165
+ const missing = tooling.unavailable.get(check.name);
166
+ if (missing) {
167
+ // Severity still has the last word, as it does for every other outcome: a
168
+ // client who made this check advisory does not get a hard failure out of
169
+ // `onMissingTool: "error"`, and one who promoted it does.
170
+ const status = applySeverity(check.name, tooling.fatal ? 'fail' : 'skip', ctx);
171
+ utils_1.logger.warn(`[runChecks]: ${status === 'fail' ? '❌' : '⏭️'} ${check.name}: ${missing}`);
172
+ collector.register(check.name, status, `tooling unavailable — ${missing}`);
173
+ return;
174
+ }
144
175
  const started = Date.now();
145
176
  const timeoutMs = ctx.config.run?.checkTimeoutMs ?? 300000;
146
177
  const execute = () => (0, pool_1.withTimeout)(check.run(ctx), timeoutMs, () => {
@@ -180,12 +211,16 @@ async function runChecks(opts = {}) {
180
211
  // the list from here rather than importing the registry, which would make
181
212
  // registry → check → registry a cycle.
182
213
  ctx.checkNames = resolveChecks(opts).map(c => c.name);
214
+ // Before any check starts: make sure the tools they need are reachable, and
215
+ // learn which ones are not. Doing this per-check would fetch the same profile
216
+ // from several checks at once and leave each of them guessing on failure.
217
+ const tooling = await prepareToolingFor(ctx, selected);
183
218
  const limit = (0, pool_1.resolveConcurrency)(ctx.config.run?.concurrency);
184
219
  const heavyLimit = Math.max(1, ctx.config.run?.heavyConcurrency ?? 1);
185
220
  const heavy = new pool_1.Semaphore(heavyLimit);
186
221
  for (const phase of types_1.PHASES) {
187
222
  const inPhase = selected.filter(c => c.phase === phase);
188
- await (0, pool_1.runPool)(inPhase.map(c => () => runOne(c, ctx, collector, heavy)), limit);
223
+ await (0, pool_1.runPool)(inPhase.map(c => () => runOne(c, ctx, collector, heavy, tooling)), limit);
189
224
  }
190
225
  reportDegradedScope(ctx, collector);
191
226
  const build = () => {
@@ -204,6 +239,40 @@ async function runChecks(opts = {}) {
204
239
  }
205
240
  return result;
206
241
  }
242
+ /**
243
+ * Prepare the tooling the selected checks need, or explain what is missing.
244
+ *
245
+ * Separate from `prepareTooling` itself so the failure of the *preparation* is
246
+ * never the failure of the *run*: fetching tooling is best-effort by nature —
247
+ * offline runners, read-only caches, a registry having a bad day — and a throw
248
+ * here would take down a run that could still have done most of its job. The
249
+ * checks that needed the tooling report it themselves, one row each.
250
+ *
251
+ * `install.strategy: "manual"` and a caller that supplied its own checks both
252
+ * arrive here; neither is special-cased, because a profile whose packages ship
253
+ * with us needs no preparation either way and the loop exits having done nothing.
254
+ */
255
+ async function prepareToolingFor(ctx, selected) {
256
+ try {
257
+ return await (0, prepare_1.prepareTooling)({
258
+ cwd: ctx.cwd,
259
+ config: ctx.config,
260
+ // A hand-built context (the SDK, a test) carries no counts. Treating its
261
+ // languages as real rather than marker-only is the honest reading: a caller
262
+ // that names a language is asserting it, not guessing.
263
+ usage: ctx.usage ?? ctx.languages.map(lang => ({ lang, files: 1, marker: true })),
264
+ selected: selected.map(c => c.name),
265
+ version: (0, package_version_1.packageVersion)(),
266
+ });
267
+ }
268
+ catch (err) {
269
+ utils_1.logger.warn(`[install]: ⚠️ could not prepare tooling: ${errMessage(err)}`);
270
+ // Preparation may have registered roots before it threw. Leaving them behind
271
+ // would point resolution at a half-installed directory for the whole run.
272
+ (0, resolve_bin_1.clearToolPaths)();
273
+ return (0, prepare_1.noTooling)();
274
+ }
275
+ }
207
276
  /**
208
277
  * Surface a scope that silently narrowed the run.
209
278
  *
@@ -220,7 +289,9 @@ function reportDegradedScope(ctx, collector) {
220
289
  const mode = ctx.config.run?.onDegradedScope ?? 'warn';
221
290
  if (mode === 'off' || !ctx.scope?.degraded || !(0, utils_1.isCiEnvironment)())
222
291
  return;
223
- const dropped = collector.getResults().filter(r => r.status === 'skip').length;
292
+ const dropped = collector
293
+ .getResults()
294
+ .filter(r => r.status === 'skip' && isDiffSkip(r.details)).length;
224
295
  const detail = `${ctx.scope.reason}; ${dropped} diff-only check(s) did not run — set fetch-depth: 0`;
225
296
  utils_1.logger.error(`[runChecks]: ${mode === 'error' ? '❌' : '⚠️'} Degraded scope: ${detail}`);
226
297
  collector.register('Scan Scope', mode === 'error' ? 'fail' : 'warn', detail);
@@ -1,4 +1,31 @@
1
- import type { CheckContext } from './types';
1
+ import { type CheckContext, type CheckOutcome } from './types';
2
+ /**
3
+ * Drop files that live under any excluded directory (client `ignoreDirs` or the
4
+ * curated default), matched on any path segment so nesting depth doesn't matter.
5
+ * Applied to every check's file list so build/tooling/report dirs are never
6
+ * scanned, even when committed.
7
+ *
8
+ * `keepDirs` un-ignores specific segments for a single check — e.g. YAML lint
9
+ * keeps the CI dirs (`.github`, `.gitlab`, `.circleci`), which are ignored
10
+ * globally but hold the workflow files a YAML linter most needs to see.
11
+ */
12
+ /**
13
+ * Drop files the client excluded by path.
14
+ *
15
+ * Separate from `ignoreDirs`, which matches a directory *name* on any segment:
16
+ * that cannot express "this one file" and cannot express "this one path" —
17
+ * `ignoreDirs: ["generated"]` silences every `generated/` in the repository, not
18
+ * the one that is actually generated. Globs answer both, and because this sits
19
+ * in the one function every file-scanning check calls, a client writes the rule
20
+ * once instead of hunting for the right key on each check.
21
+ *
22
+ * Exported because three checks build their file list themselves and cannot go
23
+ * through the resolvers above — and `excludePaths` is documented as "the only
24
+ * exclusion that reaches every check", which was not true of them (ticket 080).
25
+ * Each has a real reason to skip `sourcePath` and `ignoreDirs`; none has one to
26
+ * ignore a client saying "do not look here".
27
+ */
28
+ export declare function excludeClientPaths(files: string[], cwd: string): string[];
2
29
  /**
3
30
  * Lists the files a language check should operate on, matched by pathspec `globs`
4
31
  * (e.g. `['*.py']`). Uses the PR diff range when available (delta mode), else
@@ -20,10 +47,94 @@ export declare function resolveTargetFiles(ctx: CheckContext, globs: string[], o
20
47
  * fallback while gaining delta support on every platform via `ctx.baseSha`.
21
48
  */
22
49
  export declare function resolveScopedTargetFiles(ctx: CheckContext, globs: string[], sourcePaths: string[]): Promise<string[] | null>;
50
+ /**
51
+ * The files to work on, or the outcome to return instead of working.
52
+ *
53
+ * One value rather than two, because the two-value shape is what kept going
54
+ * wrong. Every file-scanning check ends up writing the same triple —
55
+ *
56
+ * ```typescript
57
+ * const targets = await resolveTargetFiles(ctx, globs);
58
+ * if (targets === null) return skip('git unavailable');
59
+ * if (targets.length === 0) return pass('no Kotlin files changed');
60
+ * ```
61
+ *
62
+ * — and it was written out seventeen times, with nineteen exact copies of the
63
+ * middle line and seven copies of the comment explaining why the two cases are
64
+ * not the same (ticket 079). The point of collapsing it is not the four lines. It
65
+ * is that `null` means "git could not tell us" and `[]` means "git did and there
66
+ * is nothing", they are opposite answers, and a check that conflates them reports
67
+ * a clean pass over a tree it never opened — which is ticket 072, and before that
68
+ * ticket 067, and both were found in security checks, where it cost the most.
69
+ *
70
+ * Written this way round — an array or an outcome, not a nullable array — so the
71
+ * caller's one line is `if (!Array.isArray(targets)) return targets;` and there
72
+ * is no second case left to forget.
73
+ *
74
+ * Not exported: it is the return type of {@link resolveTargets} and every caller
75
+ * gets it by inference, so exporting it would only widen the public surface and
76
+ * show up as an unused export in our own Dead Code check.
77
+ */
78
+ type TargetsOrOutcome = string[] | CheckOutcome;
79
+ /** How a check wants an empty file list phrased, and at which status. */
80
+ type EmptyResult = {
81
+ pass: string;
82
+ } | {
83
+ skip: string;
84
+ };
85
+ /** Options for {@link resolveTargets} beyond the globs themselves. */
86
+ interface ResolveOptions {
87
+ /**
88
+ * What no matching files means to this check, and in what words.
89
+ *
90
+ * Not defaulted, and that is deliberate: the two halves of the corpus disagree.
91
+ * A language check has genuinely nothing to do when its language is not in the
92
+ * diff, so `{ pass: 'no Kotlin files changed' }`. A whole-tree check that finds
93
+ * no files at all has not reviewed the repository, so `{ skip: 'no tracked
94
+ * files' }`. Picking one for the caller would make half of them wrong.
95
+ */
96
+ onEmpty: EmptyResult;
97
+ /** Walk every tracked file instead of the PR diff range. */
98
+ wholeTree?: boolean;
99
+ /** Restrict the result to the configured `sourcePath`(s). */
100
+ scoped?: boolean;
101
+ /** Un-ignore specific directory segments, as in {@link resolveTargetFiles}. */
102
+ keepDirs?: string[];
103
+ }
104
+ /**
105
+ * Resolve a check's file list, or the outcome it should report instead.
106
+ *
107
+ * The one entry point for checks that operate on whole files. It wraps
108
+ * {@link resolveTargetFiles} and {@link listTrackedFiles} rather than replacing
109
+ * them — a check that needs the raw list (because it distinguishes the two cases
110
+ * itself, or scans in several passes) still calls those directly.
111
+ */
112
+ export declare function resolveTargets(ctx: CheckContext, globs: string[], opts: ResolveOptions): Promise<TargetsOrOutcome>;
23
113
  /**
24
114
  * List ALL tracked files matching `globs` (never delta), scoped to
25
- * `sourcePath`(s). For whole-repo analyses like circular-dependency detection
26
- * that need the full graph, not just the PR diff. Using `git ls-files` means
27
- * untracked `node_modules` is excluded by construction.
115
+ * `sourcePath`(s).
116
+ *
117
+ * For whole-repo analyses like circular-dependency detection that need the full
118
+ * graph, not just the PR diff. Using `git ls-files` means untracked
119
+ * `node_modules` is excluded by construction.
120
+ *
121
+ * `keepDirs` un-ignores segments for one caller, exactly as in
122
+ * {@link resolveTargetFiles}. It was missing here, and that made Workflow
123
+ * Security blind on the whole-tree path: `.github` is in the default ignore list,
124
+ * so `git ls-files .github/workflows/*.yml` returned the workflow and this
125
+ * function then dropped it. A blocking security check reported a clean pass over
126
+ * files it had never been handed — the surviving half of ticket 067, found by
127
+ * level 1 of the release regression plan.
128
+ *
129
+ * `null` when git could not answer, `[]` when it answered "nothing". The two are
130
+ * opposites to a caller and used to be the same value: `reject: false` keeps a
131
+ * failed git out of the catch below, so a directory git cannot read returned an
132
+ * empty list, and every caller read that as "nothing to review" and passed. On a
133
+ * directory holding real code that produced three green rows — Diff Security,
134
+ * Workflow Security, Diff Smells — over files none of them had opened
135
+ * (ticket 072).
28
136
  */
29
- export declare function listTrackedFiles(ctx: CheckContext, globs: string[], sourcePaths: string[]): Promise<string[]>;
137
+ export declare function listTrackedFiles(ctx: CheckContext, globs: string[], sourcePaths: string[], opts?: {
138
+ keepDirs?: string[];
139
+ }): Promise<string[] | null>;
140
+ export {};
@@ -3,8 +3,10 @@ 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.excludeClientPaths = excludeClientPaths;
6
7
  exports.resolveTargetFiles = resolveTargetFiles;
7
8
  exports.resolveScopedTargetFiles = resolveScopedTargetFiles;
9
+ exports.resolveTargets = resolveTargets;
8
10
  exports.listTrackedFiles = listTrackedFiles;
9
11
  const node_fs_1 = __importDefault(require("node:fs"));
10
12
  const node_path_1 = __importDefault(require("node:path"));
@@ -12,6 +14,7 @@ const config_1 = require("../config");
12
14
  const loader_1 = require("../config/loader");
13
15
  const utils_1 = require("../utils");
14
16
  const execa_1 = require("../utils/execa");
17
+ const types_1 = require("./types");
15
18
  /**
16
19
  * Drop files that live under any excluded directory (client `ignoreDirs` or the
17
20
  * curated default), matched on any path segment so nesting depth doesn't matter.
@@ -31,11 +34,17 @@ const execa_1 = require("../utils/execa");
31
34
  * the one that is actually generated. Globs answer both, and because this sits
32
35
  * in the one function every file-scanning check calls, a client writes the rule
33
36
  * once instead of hunting for the right key on each check.
37
+ *
38
+ * Exported because three checks build their file list themselves and cannot go
39
+ * through the resolvers above — and `excludePaths` is documented as "the only
40
+ * exclusion that reaches every check", which was not true of them (ticket 080).
41
+ * Each has a real reason to skip `sourcePath` and `ignoreDirs`; none has one to
42
+ * ignore a client saying "do not look here".
34
43
  */
35
- function excludeConfiguredPaths(files) {
44
+ function excludeClientPaths(files, cwd) {
36
45
  let patterns = [];
37
46
  try {
38
- patterns = (0, loader_1.loadProjectConfig)().excludePaths ?? [];
47
+ patterns = (0, loader_1.loadProjectConfig)(cwd).excludePaths ?? [];
39
48
  }
40
49
  catch {
41
50
  // A config that cannot be read is the loader's problem to report, not a
@@ -43,8 +52,8 @@ function excludeConfiguredPaths(files) {
43
52
  }
44
53
  return patterns.length > 0 ? files.filter(f => !(0, utils_1.matchesAnyGlob)(f, patterns)) : files;
45
54
  }
46
- function excludeIgnoredDirs(files, keepDirs = []) {
47
- const ignored = new Set((0, config_1.getIgnoredDirs)());
55
+ function excludeIgnoredDirs(files, cwd, keepDirs = []) {
56
+ const ignored = new Set((0, config_1.getIgnoredDirs)(cwd));
48
57
  for (const dir of keepDirs)
49
58
  ignored.delete(dir);
50
59
  if (ignored.size === 0)
@@ -78,15 +87,45 @@ async function resolveTargetFiles(ctx, globs, opts = {}) {
78
87
  : ['ls-files', ...globs];
79
88
  const cacheKey = `t:${args.join('')}`;
80
89
  let stdout = ctx.cache?.get(cacheKey);
90
+ const fromCache = stdout !== undefined;
81
91
  if (stdout === undefined) {
82
- stdout = (await (0, execa_1.execa)('git', args, { cwd: ctx.cwd, reject: false })).stdout;
92
+ // `reject: false` keeps a failed git out of the catch below, so the exit
93
+ // code is the only thing separating "git could not answer" from "nothing
94
+ // matched". They are opposites to the caller: every caller turns `null`
95
+ // into a skip with a reason and `[]` into a clean pass. Without this
96
+ // branch a directory git cannot read produced the second — seven checks
97
+ // reporting ✅ over code none of them had looked at, while the docstring
98
+ // above had promised `null` all along. Found by ticket 062's corpus.
99
+ //
100
+ // A pathspec that matches nothing still exits 0, so the ordinary empty
101
+ // result cannot be swallowed here.
102
+ const result = await (0, execa_1.execa)('git', args, { cwd: ctx.cwd, reject: false });
103
+ if (result.exitCode !== 0) {
104
+ utils_1.logger.warn(`[targets]: ⚠️ git could not list files (exit ${result.exitCode})`);
105
+ return null;
106
+ }
107
+ stdout = result.stdout;
83
108
  ctx.cache?.set(cacheKey, stdout);
84
109
  }
85
- const files = excludeConfiguredPaths(excludeIgnoredDirs(stdout
110
+ const files = excludeClientPaths(excludeIgnoredDirs(stdout
86
111
  .split('\n')
87
112
  .map(f => f.trim())
88
- .filter(Boolean), opts.keepDirs)).filter(f => node_fs_1.default.existsSync(node_path_1.default.join(ctx.cwd, f)));
89
- utils_1.logger.info(`[targets]: ${ctx.baseSha ? '📂 Delta' : 'Full scan'}: ${files.length} file(s) for ${globs.join(', ')}`);
113
+ .filter(Boolean), ctx.cwd, opts.keepDirs), ctx.cwd).filter(f => node_fs_1.default.existsSync(node_path_1.default.join(ctx.cwd, f)));
114
+ // One line per actual git call, not per consumer. Several checks ask for the
115
+ // same globs in a run and were each answered from the cache, but the line was
116
+ // printed after the cache branch — so four checks sharing one lookup printed
117
+ // four identical `Delta: 1 file(s) for *` lines and the log implied four walks
118
+ // of the tree.
119
+ //
120
+ // Demoted rather than dropped on a cache hit: the count is computed AFTER
121
+ // filtering, and two callers with the same globs but different `keepDirs` get
122
+ // different lists from the same cached output. Losing the second line would
123
+ // hide a real difference, so it moves to `debug` instead of disappearing.
124
+ const line = `[targets]: ${ctx.baseSha ? '📂 Delta' : 'Full scan'}: ${files.length} file(s) for ${globs.join(', ')}`;
125
+ if (fromCache)
126
+ utils_1.logger.debug(`${line} (cached)`);
127
+ else
128
+ utils_1.logger.info(line);
90
129
  return files;
91
130
  }
92
131
  catch (err) {
@@ -116,23 +155,69 @@ async function resolveScopedTargetFiles(ctx, globs, sourcePaths) {
116
155
  return null;
117
156
  return scopeToSourcePaths(files, sourcePaths);
118
157
  }
158
+ /**
159
+ * Resolve a check's file list, or the outcome it should report instead.
160
+ *
161
+ * The one entry point for checks that operate on whole files. It wraps
162
+ * {@link resolveTargetFiles} and {@link listTrackedFiles} rather than replacing
163
+ * them — a check that needs the raw list (because it distinguishes the two cases
164
+ * itself, or scans in several passes) still calls those directly.
165
+ */
166
+ async function resolveTargets(ctx, globs, opts) {
167
+ const listOpts = opts.keepDirs ? { keepDirs: opts.keepDirs } : {};
168
+ // Read once, and only when something will use it. A whole-tree walk is always
169
+ // scoped — that is what `listTrackedFiles` promises its callers — while a delta
170
+ // walk is scoped only on request.
171
+ const sourcePaths = opts.wholeTree || opts.scoped ? (0, config_1.getSourcePaths)(ctx.cwd) : [];
172
+ const files = opts.wholeTree
173
+ ? await listTrackedFiles(ctx, globs, sourcePaths, listOpts)
174
+ : await resolveTargetFiles(ctx, globs, listOpts);
175
+ if (files === null)
176
+ return (0, types_1.skip)('git unavailable');
177
+ const scoped = opts.wholeTree ? files : scopeToSourcePaths(files, sourcePaths);
178
+ if (scoped.length > 0)
179
+ return scoped;
180
+ return 'pass' in opts.onEmpty ? (0, types_1.pass)(opts.onEmpty.pass) : (0, types_1.skip)(opts.onEmpty.skip);
181
+ }
119
182
  /**
120
183
  * List ALL tracked files matching `globs` (never delta), scoped to
121
- * `sourcePath`(s). For whole-repo analyses like circular-dependency detection
122
- * that need the full graph, not just the PR diff. Using `git ls-files` means
123
- * untracked `node_modules` is excluded by construction.
184
+ * `sourcePath`(s).
185
+ *
186
+ * For whole-repo analyses like circular-dependency detection that need the full
187
+ * graph, not just the PR diff. Using `git ls-files` means untracked
188
+ * `node_modules` is excluded by construction.
189
+ *
190
+ * `keepDirs` un-ignores segments for one caller, exactly as in
191
+ * {@link resolveTargetFiles}. It was missing here, and that made Workflow
192
+ * Security blind on the whole-tree path: `.github` is in the default ignore list,
193
+ * so `git ls-files .github/workflows/*.yml` returned the workflow and this
194
+ * function then dropped it. A blocking security check reported a clean pass over
195
+ * files it had never been handed — the surviving half of ticket 067, found by
196
+ * level 1 of the release regression plan.
197
+ *
198
+ * `null` when git could not answer, `[]` when it answered "nothing". The two are
199
+ * opposites to a caller and used to be the same value: `reject: false` keeps a
200
+ * failed git out of the catch below, so a directory git cannot read returned an
201
+ * empty list, and every caller read that as "nothing to review" and passed. On a
202
+ * directory holding real code that produced three green rows — Diff Security,
203
+ * Workflow Security, Diff Smells — over files none of them had opened
204
+ * (ticket 072).
124
205
  */
125
- async function listTrackedFiles(ctx, globs, sourcePaths) {
206
+ async function listTrackedFiles(ctx, globs, sourcePaths, opts = {}) {
126
207
  try {
127
- const { stdout } = await (0, execa_1.execa)('git', ['ls-files', ...globs], { cwd: ctx.cwd, reject: false });
128
- const files = excludeConfiguredPaths(excludeIgnoredDirs(stdout
208
+ const result = await (0, execa_1.execa)('git', ['ls-files', ...globs], { cwd: ctx.cwd, reject: false });
209
+ if (result.exitCode !== 0) {
210
+ utils_1.logger.warn(`[targets]: ⚠️ git could not list tracked files (exit ${result.exitCode})`);
211
+ return null;
212
+ }
213
+ const files = excludeClientPaths(excludeIgnoredDirs(result.stdout
129
214
  .split('\n')
130
215
  .map(f => f.trim())
131
- .filter(Boolean)));
216
+ .filter(Boolean), ctx.cwd, opts.keepDirs), ctx.cwd);
132
217
  return scopeToSourcePaths(files, sourcePaths);
133
218
  }
134
219
  catch (err) {
135
220
  utils_1.logger.warn(`[targets]: ⚠️ Could not list tracked files\n${err}`);
136
- return [];
221
+ return null;
137
222
  }
138
223
  }
@@ -1,7 +1,7 @@
1
1
  import type { PRCheckMateConfig, ReporterConfig } from '../interfaces';
2
- import type { Language } from '../utils/detect-language';
2
+ import type { Language, LanguageUsage } from '../utils/detect-language';
3
3
  import type { CheckResult, CheckStatus } from '../utils/result-collector';
4
- export type { CheckResult, CheckStatus, Language };
4
+ export type { CheckResult, CheckStatus, Language, LanguageUsage };
5
5
  /**
6
6
  * Execution phases, run strictly in order. Within a phase checks run in parallel.
7
7
  * `format` runs last because formatters (prettier) may write files.
@@ -32,6 +32,12 @@ export interface CheckContext {
32
32
  cwd: string;
33
33
  /** Languages detected (or supplied) for this repo — drives `applies` gating. */
34
34
  languages: Language[];
35
+ /**
36
+ * The same detection with its evidence — file counts per language. Read by the
37
+ * install layer, which must not confuse "this language is here" with "this
38
+ * language is worth a toolchain download". Absent on hand-built contexts.
39
+ */
40
+ usage?: LanguageUsage[];
35
41
  /** Merged pr-checkmate.json + defaults. */
36
42
  config: PRCheckMateConfig;
37
43
  /** When false, checks that would modify files (prettier, --fix) run read-only or skip the write. */
package/dist/init.js CHANGED
@@ -11,6 +11,7 @@ const fs_1 = __importDefault(require("fs"));
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const inquirer_1 = __importDefault(require("inquirer"));
13
13
  const config_1 = require("./config");
14
+ const findings_1 = require("./core/checks/findings");
14
15
  const utils_1 = require("./utils");
15
16
  const CONFIG_FILE = path_1.default.resolve(process.cwd(), 'pr-checkmate.json');
16
17
  /**
@@ -319,7 +320,26 @@ function buildInitialConfig(sourcePath, languages) {
319
320
  // Delete an entry to have that check start reporting on tests again.
320
321
  duplicate: { ignore: [...config_1.DUPLICATE_IGNORE_DEFAULTS] },
321
322
  deadCode: { ignoreFiles: [...config_1.DEAD_CODE_IGNORE_DEFAULTS] },
322
- diffSecurity: { ignorePaths: [...config_1.DIFF_SECURITY_IGNORE_DEFAULTS] },
323
+ // `dataflowWindow` is written out at its default for the same reason as
324
+ // `max-len`: it is a number people will want to change, and a number nobody
325
+ // can see is a number nobody can change. It is also the one finding in this
326
+ // check produced by a heuristic rather than a pattern, so the caveat belongs
327
+ // next to the value — including the part that surprises people, which is what
328
+ // promoting the whole check to `error` does to it.
329
+ '// diffSecurity': [
330
+ '"ignorePaths" REPLACES the test-path defaults below — they are there because a security',
331
+ "test's job is to contain the exact constructs this check hunts for.",
332
+ '"dataflowWindow" is how many added lines a value stays tainted for, so an interpolated',
333
+ 'string assigned on one line and passed to a query on the next are still connected.',
334
+ 'It is a heuristic — no scopes, no reassignment tracking — so it only ever warns.',
335
+ 'Set it to 0 to switch it off. Note that "severity": { "Diff Security": "error" } promotes',
336
+ 'this warning to a failure along with the rest, so a repository that makes this check a',
337
+ 'gate and does not want the heuristic blocking should set dataflowWindow to 0.',
338
+ ].join(' '),
339
+ diffSecurity: {
340
+ dataflowWindow: findings_1.DEFAULT_DATAFLOW_WINDOW,
341
+ ignorePaths: [...config_1.DIFF_SECURITY_IGNORE_DEFAULTS],
342
+ },
323
343
  documentation: config_1.documentation,
324
344
  languages,
325
345
  };
@@ -299,6 +299,7 @@ export interface PRCheckMateConfig {
299
299
  flags?: string;
300
300
  }>;
301
301
  commentHandling?: 'language' | 'legacy' | 'off';
302
+ dataflowWindow?: number;
302
303
  };
303
304
  mergeConflict?: {
304
305
  enabled?: boolean;
@@ -1,16 +1,66 @@
1
+ /** Where a tool was found, in the order the candidates are tried. */
2
+ export type ToolSource = 'client' | 'profile' | 'bundled';
3
+ /** A resolved tool binary, and which copy of it answered. */
4
+ export interface ResolvedTool {
5
+ bin: string;
6
+ source: ToolSource;
7
+ /** The search root that produced it; `undefined` for module-relative resolution. */
8
+ root?: string;
9
+ }
10
+ /**
11
+ * Point tool resolution at the profile directories fetched for this run.
12
+ *
13
+ * Called by the install layer once the cache is known. Passing an empty list —
14
+ * or never calling it at all — leaves resolution exactly as it was before
15
+ * profiles existed, which is what keeps a run that needs no fetching identical
16
+ * to a run from before there was anything to fetch.
17
+ */
18
+ export declare function registerToolPaths(dirs: string[]): void;
19
+ /** Drop every registered profile root. For tests, and for a second run in-process. */
20
+ export declare function clearToolPaths(): void;
21
+ /** The root of the installed pr-checkmate package — where our own tooling lives. */
22
+ export declare function ownPackageRoot(): string;
23
+ /**
24
+ * Whether a package is physically present in our own dependency tree.
25
+ *
26
+ * Checked by walking `node_modules` upwards rather than by `require.resolve`,
27
+ * because a package that restricts its `exports` map resolves for neither its
28
+ * main entry nor its `package.json` while being perfectly well installed —
29
+ * `cspell` is one. The question here is only "is it there", and the directory
30
+ * answers that without an opinion about what it chooses to export.
31
+ *
32
+ * Walking up matters as much as the check itself: installed as a dependency, our
33
+ * own packages are hoisted to the client's `node_modules`, one level above ours.
34
+ */
35
+ export declare function packagePresent(pkgName: string, from?: string): boolean;
36
+ /**
37
+ * Resolve a tool's binary and say which copy answered.
38
+ *
39
+ * Use this over {@link resolvePackageBin} when the answer is worth logging — the
40
+ * source is the difference between "the client's ESLint 8 ran" and "ours did",
41
+ * and that is the first thing anyone asks when a lint result looks wrong.
42
+ *
43
+ * @param pkgName Package whose binary to resolve (e.g. `eslint`).
44
+ * @param binRelPath Bin path relative to the package root (e.g. `bin/eslint.js`).
45
+ * @param options Resolution options.
46
+ * @param options.paths Roots to prefer ahead of everything else. Use
47
+ * `[process.cwd()]` to prefer the consumer project's copy.
48
+ */
49
+ export declare function resolveTool(pkgName: string, binRelPath: string, options?: {
50
+ paths?: string[];
51
+ }): ResolvedTool | null;
1
52
  /**
2
53
  * Resolves a binary path for a bundled package, bypassing exports map restrictions.
3
54
  *
4
- * Strategy (tries each in order):
5
- * 1. Direct subpath — fast path for packages that explicitly export their bin
6
- * 2. Via package.json — for packages that export ./package.json but not the bin subpath (e.g. eslint)
7
- * 3. Walk up from main entry — for packages that export neither (e.g. cspell)
55
+ * Throws when no copy can be found anywhere, which is the honest outcome: a check
56
+ * that cannot find its tool has nothing to report, and the install layer turns
57
+ * that into a skip with a reason rather than letting it surface as a stack trace.
8
58
  *
9
59
  * @param pkgName - The package whose binary to resolve (e.g. `eslint`).
10
60
  * @param binRelPath - The bin path relative to the package root (e.g. `bin/eslint.js`).
11
61
  * @param options - Resolution options.
12
- * @param options.paths - Directories to resolve from (passed to `require.resolve`).
13
- * Use `[process.cwd()]` to prefer the consumer project's copy of the package.
62
+ * @param options.paths - Directories to resolve from, preferred over everything
63
+ * else. Use `[process.cwd()]` to prefer the consumer project's copy.
14
64
  */
15
65
  export declare function resolvePackageBin(pkgName: string, binRelPath: string, options?: {
16
66
  paths?: string[];