pr-checkmate 2.1.2 → 3.0.1

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 (119) hide show
  1. package/README.md +63 -268
  2. package/cspell.json +470 -447
  3. package/dist/cli.js +1 -1
  4. package/dist/config/constants.d.ts +6 -1
  5. package/dist/config/constants.js +9 -7
  6. package/dist/config/index.d.ts +23 -11
  7. package/dist/config/index.js +27 -33
  8. package/dist/core/checks/dependencies/banned-imports.js +4 -1
  9. package/dist/core/checks/dependencies/circular-deps.js +14 -5
  10. package/dist/core/checks/dependencies/dependency-check.js +1 -1
  11. package/dist/core/checks/dependencies/grype-scan.js +2 -1
  12. package/dist/core/checks/dependencies/vuln-scan.js +2 -1
  13. package/dist/core/checks/findings.d.ts +46 -2
  14. package/dist/core/checks/findings.js +140 -2
  15. package/dist/core/checks/git/custom-rules.js +2 -0
  16. package/dist/core/checks/git/diff-smell.js +6 -1
  17. package/dist/core/checks/git/leftover-debug.js +8 -2
  18. package/dist/core/checks/git/lockfile-drift.js +6 -1
  19. package/dist/core/checks/git/merge-conflict.js +1 -1
  20. package/dist/core/checks/git/missing-tests.js +1 -1
  21. package/dist/core/checks/git/todo-fixme.js +10 -2
  22. package/dist/core/checks/languages/cpp-format.js +19 -42
  23. package/dist/core/checks/languages/external-checks.d.ts +77 -0
  24. package/dist/core/checks/languages/external-checks.js +200 -0
  25. package/dist/core/checks/languages/external-runner.d.ts +119 -0
  26. package/dist/core/checks/languages/external-runner.js +143 -0
  27. package/dist/core/checks/languages/go-format.d.ts +4 -4
  28. package/dist/core/checks/languages/go-format.js +151 -30
  29. package/dist/core/checks/languages/lint.js +19 -19
  30. package/dist/core/checks/languages/prettier.js +2 -7
  31. package/dist/core/checks/languages/python-typecheck.d.ts +10 -5
  32. package/dist/core/checks/languages/python-typecheck.js +161 -37
  33. package/dist/core/checks/languages/ruff-shared.d.ts +3 -3
  34. package/dist/core/checks/languages/ruff-shared.js +27 -4
  35. package/dist/core/checks/languages/typecheck.js +10 -8
  36. package/dist/core/checks/pr/commitlint.js +7 -1
  37. package/dist/core/checks/quality/case-collision.js +6 -4
  38. package/dist/core/checks/quality/config-validation.d.ts +0 -7
  39. package/dist/core/checks/quality/config-validation.js +30 -0
  40. package/dist/core/checks/quality/coverage.js +6 -1
  41. package/dist/core/checks/quality/dead-code.js +12 -3
  42. package/dist/core/checks/quality/duplicate-check.js +54 -27
  43. package/dist/core/checks/quality/license-header.js +7 -4
  44. package/dist/core/checks/quality/markdown-lint.js +1 -6
  45. package/dist/core/checks/quality/package-size.d.ts +15 -0
  46. package/dist/core/checks/quality/package-size.js +130 -0
  47. package/dist/core/checks/quality/spellcheck.js +34 -5
  48. package/dist/core/checks/quality/symlinks.js +6 -4
  49. package/dist/core/checks/security/diff-security.js +26 -18
  50. package/dist/core/checks/security/dockerfile-security.js +7 -1
  51. package/dist/core/checks/security/migration-safety.js +32 -4
  52. package/dist/core/checks/security/security.d.ts +56 -0
  53. package/dist/core/checks/security/security.js +135 -11
  54. package/dist/core/checks/security/sensitive-file-guard.js +40 -6
  55. package/dist/core/checks/security/workflow-security.js +7 -1
  56. package/dist/core/checks/tool-install.d.ts +102 -0
  57. package/dist/core/checks/tool-install.js +69 -0
  58. package/dist/core/commands.js +1 -0
  59. package/dist/core/context.js +9 -1
  60. package/dist/core/install/cache.d.ts +20 -1
  61. package/dist/core/install/cache.js +65 -12
  62. package/dist/core/install/doctor.js +49 -15
  63. package/dist/core/install/ensure.d.ts +31 -14
  64. package/dist/core/install/ensure.js +165 -61
  65. package/dist/core/install/lock.d.ts +2 -1
  66. package/dist/core/install/merge.d.ts +47 -0
  67. package/dist/core/install/merge.js +91 -0
  68. package/dist/core/install/prepare.d.ts +76 -0
  69. package/dist/core/install/prepare.js +147 -0
  70. package/dist/core/install/profiles.d.ts +17 -3
  71. package/dist/core/install/profiles.js +27 -0
  72. package/dist/core/install/reuse.d.ts +41 -0
  73. package/dist/core/install/reuse.js +107 -0
  74. package/dist/core/registry.js +12 -18
  75. package/dist/core/run-checks.js +74 -3
  76. package/dist/core/targets.d.ts +116 -5
  77. package/dist/core/targets.js +101 -16
  78. package/dist/core/types.d.ts +8 -2
  79. package/dist/init.js +21 -1
  80. package/dist/interfaces/index.d.ts +6 -0
  81. package/dist/utils/resolve-bin.d.ts +56 -6
  82. package/dist/utils/resolve-bin.js +136 -26
  83. package/gitleaks.toml +14 -0
  84. package/install-manifests/commitlint/package-lock.json +128 -250
  85. package/install-manifests/commitlint/package.json +2 -2
  86. package/install-manifests/core/package-lock.json +76 -76
  87. package/install-manifests/core/package.json +1 -1
  88. package/install-manifests/duplicate/package-lock.json +45 -45
  89. package/install-manifests/duplicate/package.json +1 -1
  90. package/install-manifests/go/package-lock.json +24 -0
  91. package/install-manifests/go/package.json +8 -0
  92. package/install-manifests/js/package-lock.json +248 -283
  93. package/install-manifests/js/package.json +9 -9
  94. package/install-manifests/python/package-lock.json +4 -4
  95. package/install-manifests/python/package.json +1 -1
  96. package/install-manifests/pytypes/package-lock.json +45 -0
  97. package/install-manifests/pytypes/package.json +8 -0
  98. package/package.json +21 -19
  99. package/pr-checkmate.schema.json +41 -13
  100. package/src/config/cspell.json +470 -447
  101. package/src/config/pr-checkmate.schema.json +41 -13
  102. package/dist/core/checks/languages/csharp-format.d.ts +0 -13
  103. package/dist/core/checks/languages/csharp-format.js +0 -52
  104. package/dist/core/checks/languages/go-lint.d.ts +0 -8
  105. package/dist/core/checks/languages/go-lint.js +0 -48
  106. package/dist/core/checks/languages/kotlin-lint.d.ts +0 -2
  107. package/dist/core/checks/languages/kotlin-lint.js +0 -42
  108. package/dist/core/checks/languages/php-format.d.ts +0 -9
  109. package/dist/core/checks/languages/php-format.js +0 -49
  110. package/dist/core/checks/languages/ruby-lint.d.ts +0 -8
  111. package/dist/core/checks/languages/ruby-lint.js +0 -50
  112. package/dist/core/checks/languages/rust-format.d.ts +0 -9
  113. package/dist/core/checks/languages/rust-format.js +0 -45
  114. package/dist/core/checks/languages/rust-lint.d.ts +0 -8
  115. package/dist/core/checks/languages/rust-lint.js +0 -50
  116. package/dist/core/checks/languages/shellcheck.d.ts +0 -8
  117. package/dist/core/checks/languages/shellcheck.js +0 -55
  118. package/dist/core/checks/languages/swift-lint.d.ts +0 -2
  119. package/dist/core/checks/languages/swift-lint.js +0 -46
@@ -1,27 +1,154 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.pythonTypecheckCheck = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
4
9
  const utils_1 = require("../../../utils");
5
- const execa_1 = require("../../../utils/execa");
6
10
  const targets_1 = require("../../targets");
7
11
  const types_1 = require("../../types");
12
+ const findings_1 = require("../findings");
13
+ const tool_install_1 = require("../tool-install");
14
+ const external_runner_1 = require("./external-runner");
8
15
  const ruff_shared_1 = require("./ruff-shared");
9
- /** Run a type checker over the target files, or return null if the binary is absent. */
10
- async function runChecker(ctx, bin, targets) {
11
- const result = await (0, execa_1.execa)(bin, targets, { cwd: ctx.cwd, reject: false });
12
- if (result.code === 'ENOENT')
16
+ /** How many diagnostics reach the log before the rest are counted instead. */
17
+ const MAX_LOGGED = 10;
18
+ /** The check's display name — the log prefix, and the row in the report. */
19
+ const NAME = 'Python Types';
20
+ /**
21
+ * Diagnostics that mean pyright could not see the repository's dependencies.
22
+ *
23
+ * Their presence is a verdict about the *setup*, not about the code, and the
24
+ * first attempt at this got it backwards: it dropped these two rules and
25
+ * reported everything else. That removes the cause and keeps the consequences,
26
+ * which is worse than either extreme — on a real client repository (ticket 076)
27
+ * it produced fifteen confident "type errors" of which nine were one cascade:
28
+ *
29
+ * ```python
30
+ * @click.group()
31
+ * def main() -> None: ...
32
+ *
33
+ * @main.command("list-jobs") # nine of these
34
+ * def list_jobs() -> None: ...
35
+ * ```
36
+ *
37
+ * With `click` absent, pyright sees `main` as a plain `FunctionType` and every
38
+ * `@main.command(...)` becomes an error. Pyright is answering correctly; the
39
+ * question is wrong. The same shape follows from typer, flask, celery and pytest
40
+ * fixtures — most Python projects, in any CI job that does not install first.
41
+ *
42
+ * So an unresolved import means we cannot type-check at all, and the honest
43
+ * answer is a skip that says so. That is exactly what the TypeScript check does
44
+ * with a missing `node_modules`, in the same words.
45
+ */
46
+ const UNRESOLVED_IMPORT_RULES = new Set(['reportMissingImports', 'reportMissingModuleSource']);
47
+ /** How many unresolved module names the skip message names before stopping. */
48
+ const NAMED_IMPORTS = 3;
49
+ /** The module a `reportMissingImports` diagnostic is about, for the skip message. */
50
+ function unresolvedModule(message) {
51
+ return /"([^"]+)"/.exec(message)?.[1] ?? null;
52
+ }
53
+ /** Whether the repository configures pyright itself. */
54
+ function hasOwnPyrightConfig(cwd) {
55
+ if (node_fs_1.default.existsSync(node_path_1.default.join(cwd, 'pyrightconfig.json')))
56
+ return true;
57
+ try {
58
+ return node_fs_1.default.readFileSync(node_path_1.default.join(cwd, 'pyproject.toml'), 'utf-8').includes('[tool.pyright]');
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /** Where our copy of pyright lives — bundled, or in a fetched profile. */
65
+ function resolvePyright() {
66
+ try {
67
+ return (0, utils_1.resolvePackageBin)('pyright', 'index.js');
68
+ }
69
+ catch {
13
70
  return null;
14
- return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
71
+ }
72
+ }
73
+ /** Read mypy's text output: one diagnostic per line, with its own final count. */
74
+ function reportMypy(output) {
75
+ const lines = output.split('\n').filter(line => /\berror\b|\bwarning\b/i.test(line));
76
+ if (lines.length === 0)
77
+ return (0, types_1.pass)();
78
+ // "Found 3 errors in 1 file (checked 12 source files)" — mypy's own count says
79
+ // more than a tally of lines that merely contained the word.
80
+ const summary = lines.find(line => /found\s+\d+\s+error/i.test(line))?.trim();
81
+ (0, findings_1.logSample)(NAME, 'type issues (mypy):', lines);
82
+ return (0, types_1.warn)(summary ?? `${lines.length} type issue(s)`);
15
83
  }
16
84
  /**
17
- * Type-checks changed Python files — the Python analogue of the TypeScript
18
- * `tsc --noEmit` check. Runner-dependency (needs mypy or pyright on the runner),
19
- * gracefully skipping when neither is present. Informational by design (like
20
- * Ruff lint) so teams can adopt gradually; force a checker with
21
- * `python.typeChecker`, else mypy is tried first, then pyright.
85
+ * Type-check with our own pyright, and report only what the client can act on.
86
+ *
87
+ * Pyright is a Node bundle, so it runs through `process.execPath` rather than as
88
+ * a binary on the path — the same reason clang-format does.
89
+ */
90
+ function reportPyright(ctx, stdout) {
91
+ let report;
92
+ try {
93
+ report = JSON.parse(stdout);
94
+ }
95
+ catch {
96
+ // Pyright exits non-zero for a bad invocation too, and calling that a type
97
+ // error would put a finding in the report that is really our own bug.
98
+ utils_1.logger.warn(`[${NAME}]: ⚠️ pyright produced no readable report`);
99
+ return (0, types_1.skip)('pyright produced no readable report');
100
+ }
101
+ const diagnostics = (report.generalDiagnostics ?? []).filter(diagnostic => diagnostic.severity !== 'information');
102
+ // Nothing downstream of an unresolved import can be trusted, so this is
103
+ // checked before anything is reported — and skipped rather than filtered.
104
+ //
105
+ // Not applied to a repository that configures pyright itself: having written
106
+ // `pyrightconfig.json` (or a `[tool.pyright]` section) is having an opinion
107
+ // about import resolution — a `venvPath`, or these rules downgraded on
108
+ // purpose — and overriding it with a skip would ignore the one client who
109
+ // said what they wanted.
110
+ const unresolved = diagnostics.filter(diagnostic => UNRESOLVED_IMPORT_RULES.has(diagnostic.rule ?? ''));
111
+ if (unresolved.length > 0 && !hasOwnPyrightConfig(ctx.cwd)) {
112
+ const modules = [
113
+ ...new Set(unresolved
114
+ .map(diagnostic => unresolvedModule(diagnostic.message))
115
+ .filter((name) => Boolean(name))),
116
+ ];
117
+ const named = modules.slice(0, NAMED_IMPORTS).join(', ');
118
+ const rest = modules.length > NAMED_IMPORTS ? ` and ${modules.length - NAMED_IMPORTS} more` : '';
119
+ utils_1.logger.warn(`[${NAME}]: ⚠️ ${unresolved.length} import(s) could not be resolved — not type-checking`);
120
+ return (0, types_1.skip)(`project dependencies not installed (${named || `${unresolved.length} import(s)`}${rest}) ` +
121
+ '— run `pip install` before typecheck');
122
+ }
123
+ if (diagnostics.length === 0) {
124
+ utils_1.logger.info(`[${NAME}]: ✅ No type errors (pyright)`);
125
+ return (0, types_1.pass)();
126
+ }
127
+ const errors = diagnostics.filter(diagnostic => diagnostic.severity === 'error').length;
128
+ const summary = `${errors} error(s), ${diagnostics.length - errors} warning(s)`;
129
+ (0, findings_1.logSample)(NAME, `type issues (pyright): ${summary}`, diagnostics.slice(0, MAX_LOGGED).map(diagnostic => {
130
+ const rel = node_path_1.default.relative(ctx.cwd, diagnostic.file) || diagnostic.file;
131
+ // Pyright counts lines from zero; every other tool in the report counts
132
+ // from one, and a line number that is off by one is worse than none.
133
+ const line = (diagnostic.range?.start?.line ?? 0) + 1;
134
+ return `${rel}:${line} ${diagnostic.severity}: ${diagnostic.message.split('\n')[0]}`;
135
+ }));
136
+ return (0, types_1.warn)(summary);
137
+ }
138
+ /**
139
+ * Type-checks changed Python files — the Python analogue of `tsc --noEmit`.
140
+ *
141
+ * Two checkers, and the order is the policy. **mypy first**, because a repository
142
+ * that installed mypy configured it too (`mypy.ini`, `[tool.mypy]`), and its
143
+ * settled opinion about its own code beats ours. **Our bundled pyright
144
+ * otherwise**, which is what stopped `⏭️ mypy/pyright not installed` from being
145
+ * the answer on every runner: pyright is a Node bundle carrying its own typeshed,
146
+ * so it needs neither Python nor pip. Force either with `python.typeChecker`.
147
+ *
148
+ * Informational by design, like Ruff lint, so teams can adopt gradually.
22
149
  */
23
150
  exports.pythonTypecheckCheck = {
24
- name: 'Python Types',
151
+ name: NAME,
25
152
  phase: 'informational',
26
153
  applies(ctx) {
27
154
  return (ctx.config.python?.enabled !== false &&
@@ -35,32 +162,29 @@ exports.pythonTypecheckCheck = {
35
162
  if (targets.length === 0)
36
163
  return (0, types_1.pass)('no Python files changed');
37
164
  const forced = ctx.config.python?.typeChecker;
38
- const order = forced ? [forced] : ['mypy', 'pyright'];
39
- let result = null;
40
- let used = '';
41
- for (const bin of order) {
42
- result = await runChecker(ctx, bin, targets);
43
- if (result) {
44
- used = bin;
45
- break;
165
+ if (forced !== 'pyright') {
166
+ const mypy = await (0, external_runner_1.runTool)(ctx, 'mypy', targets);
167
+ if (!mypy.missing) {
168
+ if (mypy.exitCode === 0) {
169
+ utils_1.logger.info(`[${NAME}]: ✅ No type errors (mypy)`);
170
+ return (0, types_1.pass)();
171
+ }
172
+ return reportMypy([mypy.stdout, mypy.stderr].filter(Boolean).join('\n'));
46
173
  }
174
+ // Asking for mypy by name and not having it is a configuration error the
175
+ // client meant to make visible; falling back to pyright would hide it.
176
+ if (forced === 'mypy')
177
+ return (0, tool_install_1.notInstalled)('mypy', 'mypy');
47
178
  }
48
- if (!result)
49
- return (0, types_1.skip)(`${forced ?? 'mypy/pyright'} not installed`);
50
- if (result.exitCode === 0) {
51
- utils_1.logger.info(`[Python Types]: ✅ No type errors (${used})`);
52
- return (0, types_1.pass)();
53
- }
54
- const lines = [result.stdout, result.stderr]
55
- .filter(Boolean)
56
- .join('\n')
57
- .split('\n')
58
- .filter(l => /\berror\b|\bwarning\b/i.test(l));
59
- utils_1.logger.warn(`[Python Types]: ⚠️ type issues (${used}):`);
60
- lines.slice(0, 10).forEach(l => utils_1.logger.warn(` ${l}`));
61
- if (lines.length > 10)
62
- utils_1.logger.warn(` ... and ${lines.length - 10} more`);
63
- const summary = lines.find(l => /found\s+\d+\s+error/i.test(l)) ?? `${lines.length} type issue(s)`;
64
- return (0, types_1.warn)(summary.trim());
179
+ const entry = resolvePyright();
180
+ if (!entry)
181
+ return (0, tool_install_1.notInstalled)('pyright', 'pyright');
182
+ const result = await (0, external_runner_1.runTool)(ctx, process.execPath, [entry, '--outputjson', ...targets]);
183
+ // The binary here is the Node running this process, so `missing` is not a
184
+ // reachable state — it is a case of the type, folded in rather than asserted
185
+ // away.
186
+ if (result.missing)
187
+ return (0, tool_install_1.notInstalled)('pyright', 'pyright');
188
+ return reportPyright(ctx, result.stdout);
65
189
  },
66
190
  };
@@ -2,9 +2,9 @@ type RuffModule = typeof import('@astral-sh/ruff-wasm-nodejs');
2
2
  type RuffWorkspace = InstanceType<RuffModule['Workspace']>;
3
3
  export declare const PY_GLOBS: string[];
4
4
  /**
5
- * Instantiate a Ruff WASM workspace with default settings. Shared by the Ruff
6
- * lint and format checks so their setup stays identical, and kept as a function
7
- * (not a module-level singleton) so the WASM module loads only when a check runs.
5
+ * Instantiate a Ruff WASM workspace. Shared by the Ruff lint and format checks so
6
+ * their setup stays identical, and kept as a function (not a module-level
7
+ * singleton) so the WASM module loads only when a check runs.
8
8
  */
9
9
  export declare function createRuffWorkspace(): RuffWorkspace;
10
10
  export {};
@@ -4,11 +4,34 @@ exports.PY_GLOBS = void 0;
4
4
  exports.createRuffWorkspace = createRuffWorkspace;
5
5
  exports.PY_GLOBS = ['*.py'];
6
6
  /**
7
- * Instantiate a Ruff WASM workspace with default settings. Shared by the Ruff
8
- * lint and format checks so their setup stays identical, and kept as a function
9
- * (not a module-level singleton) so the WASM module loads only when a check runs.
7
+ * The rules we lint Python with — pinned here rather than left to Ruff's defaults.
8
+ *
9
+ * `E4`/`E7`/`E9` (import placement, statement style, syntax) plus `F` (pyflakes:
10
+ * undefined names, unused imports, unreachable code) — the set Ruff itself
11
+ * shipped as its default through 0.15.
12
+ *
13
+ * Written down because leaving it implicit made our verdict change under us.
14
+ * Ruff 0.16 added the isort rules (`I`) to its defaults, and a routine dependency
15
+ * bump turned that into a 5- to 48-fold increase in findings across seventeen
16
+ * Python repositories — every one of them `I001`, "import block is un-sorted".
17
+ * Two repositories went from a clean report to a warning on code nobody had
18
+ * touched. Import order is a matter of taste, and a check that fires on every
19
+ * file in a repository is how people learn to ignore the whole report.
20
+ *
21
+ * Adding a rule to this list is a decision with a measurement behind it, which
22
+ * is the point: it is ours to make, not our dependency's to make for us.
23
+ */
24
+ const SELECTED_RULES = ['E4', 'E7', 'E9', 'F'];
25
+ /**
26
+ * Instantiate a Ruff WASM workspace. Shared by the Ruff lint and format checks so
27
+ * their setup stays identical, and kept as a function (not a module-level
28
+ * singleton) so the WASM module loads only when a check runs.
10
29
  */
11
30
  function createRuffWorkspace() {
12
31
  const { Workspace, PositionEncoding } = require('@astral-sh/ruff-wasm-nodejs');
13
- return new Workspace(Workspace.defaultSettings(), PositionEncoding.Utf16);
32
+ const settings = {
33
+ ...Workspace.defaultSettings(),
34
+ lint: { select: SELECTED_RULES },
35
+ };
36
+ return new Workspace(settings, PositionEncoding.Utf16);
14
37
  }
@@ -8,18 +8,20 @@ const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const utils_1 = require("../../../utils");
10
10
  const execa_1 = require("../../../utils/execa");
11
+ const reuse_1 = require("../../install/reuse");
11
12
  const types_1 = require("../../types");
12
13
  /**
13
14
  * Prefer the consumer project's own TypeScript so its tsconfig is compiled by the
14
15
  * version it was written for; fall back to the compiler bundled with pr-checkmate.
15
16
  */
16
- function resolveTsc(cwd) {
17
- try {
18
- return (0, utils_1.resolvePackageBin)('typescript', 'bin/tsc', { paths: [cwd] });
19
- }
20
- catch {
21
- return (0, utils_1.resolvePackageBin)('typescript', 'bin/tsc');
22
- }
17
+ function resolveTsc(ctx) {
18
+ // `clientOwnsConfig` unconditionally: this check does not apply without a
19
+ // `tsconfig.json` in the repository, so the compiler their config was written
20
+ // for is always theirs. `install.reuseLocal: "off"` still overrides, for a
21
+ // client who wants our pinned compiler for reproducibility.
22
+ return (0, utils_1.resolvePackageBin)('typescript', 'bin/tsc', {
23
+ paths: (0, reuse_1.clientToolPaths)('typescript', ctx, { clientOwnsConfig: true }),
24
+ });
23
25
  }
24
26
  exports.typecheckCheck = {
25
27
  name: 'TypeScript',
@@ -42,7 +44,7 @@ exports.typecheckCheck = {
42
44
  if (!node_fs_1.default.existsSync(node_path_1.default.join(ctx.cwd, 'node_modules'))) {
43
45
  return (0, types_1.skip)('project dependencies not installed (no node_modules) — run `npm ci` before typecheck');
44
46
  }
45
- const tscBin = resolveTsc(ctx.cwd);
47
+ const tscBin = resolveTsc(ctx);
46
48
  const result = await (0, execa_1.execa)(process.execPath, [tscBin, '--noEmit'], {
47
49
  cwd: ctx.cwd,
48
50
  reject: false,
@@ -60,8 +60,14 @@ exports.commitlintCheck = {
60
60
  return ctx.config.commitlint?.enabled !== false;
61
61
  },
62
62
  async run(ctx) {
63
+ // This check needs a commit to lint. Without a repository `--last` finds
64
+ // none, commitlint prints nothing and exits 0, and we reported `pass` — a
65
+ // verdict on a commit message that does not exist. Ticket 072: a check that
66
+ // examined nothing must not read as a check that found nothing.
67
+ if (!(await (0, utils_1.resolveGitRef)(ctx.cwd, 'HEAD')))
68
+ return (0, types_1.skip)('git unavailable');
63
69
  const commitlintBin = (0, utils_1.resolvePackageBin)('@commitlint/cli', 'cli.js');
64
- const hasLocal = (0, config_1.hasLocalConfig)(LOCAL_CONFIG_FILES);
70
+ const hasLocal = (0, config_1.hasLocalConfig)(LOCAL_CONFIG_FILES, ctx.cwd);
65
71
  let tempConfigFile = null;
66
72
  // With no diff range (a full-repo review, or `--full`) the check still has a
67
73
  // meaningful subject: the newest commit message. `HEAD~1` cannot be assumed to
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.caseCollisionCheck = void 0;
4
- const config_1 = require("../../../config");
5
4
  const utils_1 = require("../../../utils");
6
5
  const targets_1 = require("../../targets");
7
6
  const types_1 = require("../../types");
@@ -18,9 +17,12 @@ exports.caseCollisionCheck = {
18
17
  return ctx.config.caseCollision?.enabled !== false;
19
18
  },
20
19
  async run(ctx) {
21
- const files = await (0, targets_1.listTrackedFiles)(ctx, ['*'], (0, config_1.getSourcePaths)());
22
- if (files.length === 0)
23
- return (0, types_1.skip)('no tracked files');
20
+ const files = await (0, targets_1.resolveTargets)(ctx, ['*'], {
21
+ wholeTree: true,
22
+ onEmpty: { skip: 'no tracked files' },
23
+ });
24
+ if (!Array.isArray(files))
25
+ return files;
24
26
  const byLower = new Map();
25
27
  for (const f of files) {
26
28
  const key = f.toLowerCase();
@@ -8,11 +8,4 @@ import { Check } from '../../types';
8
8
  * slip or two.
9
9
  */
10
10
  export declare function suggestName(input: string, candidates: string[]): string | null;
11
- /**
12
- * Validates pr-checkmate.json itself: unknown top-level keys (typos like
13
- * `mergeConfict` that silently do nothing), invalid values in `severity` and
14
- * `failOn`, and names in `severity` that match no check. Reads the raw file so it
15
- * checks what the user actually wrote, not the merged defaults. `//`-comment keys
16
- * and `$schema` are ignored.
17
- */
18
11
  export declare const configValidationCheck: Check;
@@ -112,6 +112,35 @@ function validateFailOn(json) {
112
112
  * checks what the user actually wrote, not the merged defaults. `//`-comment keys
113
113
  * and `$schema` are ignored.
114
114
  */
115
+ /**
116
+ * Report a `sourcePath` that points nowhere.
117
+ *
118
+ * Every scoped check restricts itself to these directories, so a path that does
119
+ * not exist silences them — and silences them as a PASS. Measured on a fixture
120
+ * with `"sourcePath": "src"` and the code in `app/`: a real conflict marker in
121
+ * `app/conflict.py` went unreported while Merge Conflict, a blocking check,
122
+ * printed ✅. Nothing else in the run mentioned that most of the repository had
123
+ * not been looked at.
124
+ *
125
+ * `init` defaults to `.` and recommends it, so this is not the common case — it
126
+ * is what happens when someone picks `src` at setup, or moves the code later, and
127
+ * that is precisely when nobody is looking for it.
128
+ */
129
+ function validateSourcePath(json, cwd) {
130
+ const raw = json.sourcePath;
131
+ if (raw === undefined)
132
+ return [];
133
+ const paths = (Array.isArray(raw) ? raw : [raw]).filter((p) => typeof p === 'string' && p.trim().length > 0);
134
+ return paths
135
+ .filter(p => {
136
+ const normalised = p.replace(/^\.\/?/, '').replace(/\/+$/, '');
137
+ // `.` and `./` mean the whole repository and always exist.
138
+ if (normalised === '')
139
+ return false;
140
+ return !node_fs_1.default.existsSync(node_path_1.default.join(cwd, normalised));
141
+ })
142
+ .map(p => `sourcePath "${p}" does not exist — every scoped check silently reviews nothing, and reports a pass`);
143
+ }
115
144
  exports.configValidationCheck = {
116
145
  name: 'Config Validation',
117
146
  phase: 'informational',
@@ -153,6 +182,7 @@ exports.configValidationCheck = {
153
182
  issues.push(...validateSeverity(json, checkNames));
154
183
  }
155
184
  issues.push(...validateFailOn(json));
185
+ issues.push(...validateSourcePath(json, ctx.cwd));
156
186
  if (issues.length === 0)
157
187
  return (0, types_1.pass)();
158
188
  // Advisory by default: a config problem is worth knowing about, but a repo
@@ -48,7 +48,12 @@ exports.coverageCheck = {
48
48
  : DEFAULT_REPORTS;
49
49
  const found = candidates.find(rel => {
50
50
  try {
51
- return node_fs_1.default.statSync(node_path_1.default.join(ctx.cwd, rel)).size > 0;
51
+ // `isFile()` as well as the size: `statSync` reports a non-zero size for
52
+ // a directory too, so a path that happens to be one would read as a
53
+ // report. Non-empty matters because a zero-byte report is what a test
54
+ // run that died halfway leaves behind — the case this check exists for.
55
+ const stat = node_fs_1.default.statSync(node_path_1.default.join(ctx.cwd, rel));
56
+ return stat.isFile() && stat.size > 0;
52
57
  }
53
58
  catch {
54
59
  return false;
@@ -44,7 +44,7 @@ exports.deadCodeCheck = {
44
44
  const ignore = compileIgnorePatterns(ctx.config.deadCode?.ignoreFiles ?? constants_1.DEAD_CODE_IGNORE_DEFAULTS);
45
45
  // ts-unused-exports prints one `path: sym1, sym2` line per file, preceded
46
46
  // by a `N modules with unused exports` summary line we skip.
47
- const entries = output
47
+ const parsed = output
48
48
  .split('\n')
49
49
  .map(line => line.trim().match(/^(.+\.[cm]?tsx?): (.+)$/))
50
50
  .filter((match) => match !== null)
@@ -54,8 +54,17 @@ exports.deadCodeCheck = {
54
54
  .split(',')
55
55
  .map(sym => sym.trim())
56
56
  .filter(Boolean),
57
- }))
58
- .filter(entry => !ignore.some(re => re.test(entry.file)));
57
+ }));
58
+ // The tool said something and none of it was a report. That is not a clean
59
+ // repository — it is an answer this check cannot read, and calling it a
60
+ // pass is how Circular Deps stayed green on every repository for its whole
61
+ // existence while grepping for console wording that had changed. Same
62
+ // resolution as Grype Scan: say so rather than invent a verdict.
63
+ if (parsed.length === 0) {
64
+ utils_1.logger.warn(`[Dead Code]: ⚠️ ts-unused-exports produced no parseable output:\n${output}`);
65
+ return (0, types_1.warn)('ts-unused-exports produced no parseable output');
66
+ }
67
+ const entries = parsed.filter(entry => !ignore.some(re => re.test(entry.file)));
59
68
  if (entries.length === 0) {
60
69
  utils_1.logger.info('[Dead Code]: ✅ No unused exports found');
61
70
  return (0, types_1.pass)();
@@ -1,12 +1,19 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.duplicateCheck = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_os_1 = __importDefault(require("node:os"));
9
+ const node_path_1 = __importDefault(require("node:path"));
4
10
  const config_1 = require("../../../config");
5
11
  const constants_1 = require("../../../config/constants");
6
12
  const utils_1 = require("../../../utils");
7
13
  const execa_1 = require("../../../utils/execa");
8
14
  const targets_1 = require("../../targets");
9
15
  const types_1 = require("../../types");
16
+ const findings_1 = require("../findings");
10
17
  /**
11
18
  * The files to hand jscpd, per language.
12
19
  *
@@ -43,22 +50,16 @@ function globsFor(languages) {
43
50
  // not logic, and are commonly shipped in two formats (e.g. .eslintrc + flat
44
51
  // config), so their repetition is expected rather than a code smell.
45
52
  const BASE_IGNORE = ['node_modules/**', 'dist/**', '.git/**', ...constants_1.CONFIG_FILE_PATTERNS];
46
- // jscpd v5 appends promotional/funding lines after its report. Strip them so
47
- // they never reach the client's terminal when running our package.
48
- const JSCPD_PROMO_MARKERS = [
49
- 'Auto-refactor with AI',
50
- 'github.com/kucherenko/jscpd',
51
- 'Gangsta Agents',
52
- 'gangsta.page',
53
- 'Support jscpd project',
54
- 'opencollective.com/jscpd',
55
- ];
56
- function stripJscpdPromo(text) {
57
- return text
58
- .split('\n')
59
- .filter(line => !JSCPD_PROMO_MARKERS.some(marker => line.includes(marker)))
60
- .join('\n')
61
- .trimEnd();
53
+ /** `<path>:<start>-<end>`, repo-relative, for one end of a clone. */
54
+ function locate(cwd, at) {
55
+ const name = at?.name ?? '';
56
+ const rel = name ? node_path_1.default.relative(cwd, name) || node_path_1.default.basename(name) : '<unknown file>';
57
+ return `${rel}:${at?.start ?? '?'}-${at?.end ?? '?'}`;
58
+ }
59
+ /** One reportable line per clone: both ends, and how big it is. */
60
+ function describe(cwd, clone) {
61
+ const size = clone.lines === undefined ? '' : ` (${clone.lines} lines)`;
62
+ return `${locate(cwd, clone.firstFile)} ↔ ${locate(cwd, clone.secondFile)}${size}`;
62
63
  }
63
64
  exports.duplicateCheck = {
64
65
  name: 'Duplicate Code',
@@ -72,7 +73,7 @@ exports.duplicateCheck = {
72
73
  async run(ctx) {
73
74
  const minLines = ctx.config.duplicate?.minLines ?? 5;
74
75
  const minTokens = ctx.config.duplicate?.minTokens ?? 50;
75
- const targetPaths = await (0, targets_1.resolveScopedTargetFiles)(ctx, globsFor(ctx.languages), (0, config_1.getSourcePaths)());
76
+ const targetPaths = await (0, targets_1.resolveScopedTargetFiles)(ctx, globsFor(ctx.languages), (0, config_1.getSourcePaths)(ctx.cwd));
76
77
  if (targetPaths === null)
77
78
  return (0, types_1.skip)('git unavailable');
78
79
  if (targetPaths.length === 0)
@@ -86,7 +87,16 @@ exports.duplicateCheck = {
86
87
  // defaulting to source-code formats so data files (yaml/json/markdown) are
87
88
  // not scanned. Clients can add them back explicitly.
88
89
  const formats = (ctx.config.duplicate?.formats ?? constants_1.DUPLICATE_CODE_FORMATS).join(',');
90
+ // The JSON reporter writes a file, so it needs somewhere to write it. Outside
91
+ // the repository deliberately: with no `--output`, jscpd creates `report/`
92
+ // in the client's working tree, and a check that leaves a directory behind is
93
+ // a check that shows up in the next commit.
94
+ const reportDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pr-checkmate-jscpd-'));
89
95
  try {
96
+ // `--silent` keeps jscpd's own console output away from the client entirely.
97
+ // That is what retired `stripJscpdPromo` — six string markers matching the
98
+ // funding lines jscpd v5 prints after its report, each good until the next
99
+ // release of somebody else's package.
90
100
  const result = await (0, execa_1.execa)(process.execPath, [
91
101
  jscpdBin,
92
102
  '--min-lines',
@@ -94,27 +104,44 @@ exports.duplicateCheck = {
94
104
  '--min-tokens',
95
105
  String(minTokens),
96
106
  '--reporters',
97
- 'console',
107
+ 'json',
108
+ '--output',
109
+ reportDir,
110
+ '--absolute',
111
+ '--silent',
98
112
  '--format',
99
113
  formats,
100
114
  '--ignore',
101
115
  ignore,
102
116
  ...targetPaths,
103
117
  ], { cwd: ctx.cwd, reject: false });
104
- const output = stripJscpdPromo([result.stdout, result.stderr].filter(Boolean).join('\n'));
105
- const clonesMatch = output.match(/(\d+)\s+clone/i);
106
- const cloneCount = clonesMatch ? parseInt(clonesMatch[1], 10) : 0;
107
- if (cloneCount > 0) {
108
- utils_1.logger.warn(`[Duplicate Code]: ⚠️ Found ${cloneCount} duplicate block(s)`);
109
- if (output)
110
- utils_1.logger.warn(output);
111
- return (0, types_1.warn)(`${cloneCount} duplicate block(s)`);
118
+ const reportPath = node_path_1.default.join(reportDir, 'jscpd-report.json');
119
+ if (!node_fs_1.default.existsSync(reportPath)) {
120
+ // No report and nothing to read it from: jscpd did not get as far as
121
+ // writing one. Its stderr is the only thing that can say why, and a skip
122
+ // is the honest outcome — an empty report would read as "no duplication".
123
+ utils_1.logger.warn(`[Duplicate Code]: ⚠️ jscpd wrote no report (exit ${result.exitCode ?? 'killed'})` +
124
+ `${result.stderr ? `\n${result.stderr}` : ''}`);
125
+ return (0, types_1.skip)('jscpd produced no report');
112
126
  }
113
- return (0, types_1.pass)();
127
+ const report = JSON.parse(node_fs_1.default.readFileSync(reportPath, 'utf-8'));
128
+ const clones = report.duplicates ?? [];
129
+ // jscpd's own count, not a regex over its prose. `output.match(/(\d+)\s+clone/i)`
130
+ // read the number out of an English sentence, so a reworded summary upstream
131
+ // would have reported zero duplication on a repository full of it — and
132
+ // reported it as a pass.
133
+ const cloneCount = report.statistics?.total?.clones ?? clones.length;
134
+ if (cloneCount === 0)
135
+ return (0, types_1.pass)();
136
+ (0, findings_1.logSample)('Duplicate Code', `${cloneCount} duplicate block(s):`, clones.map(clone => describe(ctx.cwd, clone)));
137
+ return (0, types_1.warn)(`${cloneCount} duplicate block(s)`);
114
138
  }
115
139
  catch (err) {
116
140
  utils_1.logger.warn(`[Duplicate Code]: ⚠️ Could not run duplicate check\n${err}`);
117
141
  return (0, types_1.skip)('jscpd unavailable');
118
142
  }
143
+ finally {
144
+ node_fs_1.default.rmSync(reportDir, { recursive: true, force: true });
145
+ }
119
146
  },
120
147
  };
@@ -8,6 +8,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const utils_1 = require("../../../utils");
10
10
  const execa_1 = require("../../../utils/execa");
11
+ const targets_1 = require("../../targets");
11
12
  const types_1 = require("../../types");
12
13
  /** Source extensions a license header is expected on. */
13
14
  const SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|kt|java|go|rs|cs|rb|php|swift|c|cc|cpp|h|hpp)$/i;
@@ -49,10 +50,12 @@ exports.licenseHeaderCheck = {
49
50
  ? ['diff', '--name-only', '--diff-filter=A', ctx.baseSha, ctx.headSha ?? 'HEAD']
50
51
  : ['ls-files'];
51
52
  const { stdout } = await (0, execa_1.execa)('git', args, { cwd: ctx.cwd });
52
- added = stdout
53
- .split('\n')
54
- .filter(Boolean)
55
- .filter(f => SOURCE_EXT.test(f));
53
+ // Its own git call rather than `resolveTargets`, and for one reason:
54
+ // `--diff-filter=A`. This check is about files a PR ADDS, and the shared
55
+ // resolver asks for ACMR — routing through it would start demanding a
56
+ // header on every modified file too, which is a different check. So the
57
+ // client's `excludePaths` is applied here explicitly instead (ticket 080).
58
+ added = (0, targets_1.excludeClientPaths)(stdout.split('\n').filter(Boolean), ctx.cwd).filter(f => SOURCE_EXT.test(f));
56
59
  }
57
60
  catch (err) {
58
61
  utils_1.logger.warn(`[License Header]: ⚠️ Could not analyze diff\n${err}`);
@@ -47,12 +47,7 @@ exports.markdownLintCheck = {
47
47
  const userRules = userConfig.rules ?? {};
48
48
  let tempConfigFile = null;
49
49
  let configArg;
50
- if ((0, config_1.hasLocalConfig)([
51
- '.markdownlint.json',
52
- '.markdownlint.jsonc',
53
- '.markdownlint.yaml',
54
- '.markdownlint.yml',
55
- ])) {
50
+ if ((0, config_1.hasLocalConfig)(['.markdownlint.json', '.markdownlint.jsonc', '.markdownlint.yaml', '.markdownlint.yml'], ctx.cwd)) {
56
51
  configArg = [];
57
52
  }
58
53
  else {