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
@@ -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
  };
@@ -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
@@ -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 {
@@ -188,19 +188,48 @@ function dropMissingLanguages(language, missing) {
188
188
  // to its own default, which is not necessarily what was configured here.
189
189
  return kept.length > 0 ? kept.join(',') : 'en';
190
190
  }
191
+ /**
192
+ * Resolve a package id from the repository under review first, then from our own
193
+ * tree.
194
+ *
195
+ * The default `require.resolve` searches from THIS module, which is only the
196
+ * client's tree by accident. Installed as a dependency we sit in their
197
+ * `node_modules` and npm hoists `@cspell/dict-*` above us, so walking up finds
198
+ * it. Run through `npx` we sit in the npx cache and their `node_modules` is not
199
+ * on the path at all — so `npm i -D @cspell/dict-ru_ru`, which this file's own
200
+ * docstring promises is all it takes, did nothing and said nothing (ticket 081).
201
+ *
202
+ * Same policy as tool resolution for binaries: the client's copy first, ours as
203
+ * the fallback.
204
+ */
205
+ function resolveFrom(cwd) {
206
+ return id => {
207
+ try {
208
+ return require.resolve(id, { paths: [cwd] });
209
+ }
210
+ catch {
211
+ return require.resolve(id);
212
+ }
213
+ };
214
+ }
191
215
  function createTempCspellConfig(cwd) {
192
216
  const packageConfigPath = (0, config_1.getPackageConfigPath)('cspell.json');
193
217
  const packageConfigDir = node_path_1.default.dirname(packageConfigPath);
194
218
  const defaultConfig = JSON.parse(node_fs_1.default.readFileSync(packageConfigPath, 'utf-8'));
195
219
  const userConfig = loadUserCspellConfig(cwd);
196
220
  const merged = mergeCspellConfig(defaultConfig, userConfig);
197
- const { resolved, missing } = resolveImports(merged.import ?? [], packageConfigDir);
198
- const available = availableDictionaries();
221
+ const resolve = resolveFrom(cwd);
222
+ const { resolved, missing } = resolveImports(merged.import ?? [], packageConfigDir, resolve);
223
+ const available = availableDictionaries(resolve);
199
224
  for (const { pkg } of missing) {
200
225
  utils_1.logger.info(`[Spellcheck]: dictionary ${pkg} is not installed and was skipped. Add it with \`npm i -D ${pkg}\`.`);
201
226
  }
202
227
  if (available.absent.length > 0) {
203
- utils_1.logger.debug(`[Spellcheck]: optional dictionaries not installed: ${available.absent.join(', ')}. ` +
228
+ // `info`, not `debug`. A client who installed a dictionary and saw no change
229
+ // had nothing to read: the one line that would have explained it was behind a
230
+ // flag. Which dictionaries are in play decides which findings appear, so it
231
+ // belongs in the ordinary log (ticket 081).
232
+ utils_1.logger.info(`[Spellcheck]: optional dictionaries not installed: ${available.absent.join(', ')}. ` +
204
233
  `Install any of them to spellcheck that language (\`npm i -D ${available.absent[0]}\`).`);
205
234
  }
206
235
  // A language the client asked for whose dictionary is absent is dropped rather
@@ -230,7 +259,7 @@ exports.spellcheckCheck = {
230
259
  return ctx.config.cspell?.enabled !== false && ctx.config.spellcheck?.enabled !== false;
231
260
  },
232
261
  async run(ctx) {
233
- const targets = await (0, targets_1.resolveScopedTargetFiles)(ctx, ['*.ts', '*.tsx', '*.js', '*.jsx', '*.md', '*.mdx', '*.json'], (0, config_1.getSourcePaths)());
262
+ const targets = await (0, targets_1.resolveScopedTargetFiles)(ctx, ['*.ts', '*.tsx', '*.js', '*.jsx', '*.md', '*.mdx', '*.json'], (0, config_1.getSourcePaths)(ctx.cwd));
234
263
  if (targets === null)
235
264
  return (0, types_1.skip)('git unavailable');
236
265
  // Committed build output and test reports are dropped here, in JS, against
@@ -246,7 +275,7 @@ exports.spellcheckCheck = {
246
275
  const files = targets.filter(f => !(0, utils_1.matchesAnyGlob)(f, config_1.IGNORE_PATTERNS));
247
276
  if (files.length === 0)
248
277
  return (0, types_1.pass)('no files to spellcheck');
249
- const hasLocal = (0, config_1.hasLocalConfig)(['cspell.json', '.cspell.json', 'cspell.config.json']);
278
+ const hasLocal = (0, config_1.hasLocalConfig)(['cspell.json', '.cspell.json', 'cspell.config.json'], ctx.cwd);
250
279
  let tempConfigPath = null;
251
280
  const cspellBin = (0, utils_1.resolvePackageBin)('cspell', 'bin.mjs');
252
281
  // `--no-must-find-files`: cspell exits 1 when it checked nothing, which the
@@ -7,7 +7,6 @@ exports.symlinksCheck = void 0;
7
7
  exports.isBrokenSymlink = isBrokenSymlink;
8
8
  const node_fs_1 = __importDefault(require("node:fs"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
- const config_1 = require("../../../config");
11
10
  const utils_1 = require("../../../utils");
12
11
  const targets_1 = require("../../targets");
13
12
  const types_1 = require("../../types");
@@ -42,9 +41,12 @@ exports.symlinksCheck = {
42
41
  return ctx.config.symlinks?.enabled !== false;
43
42
  },
44
43
  async run(ctx) {
45
- const files = await (0, targets_1.listTrackedFiles)(ctx, ['*'], (0, config_1.getSourcePaths)());
46
- if (files.length === 0)
47
- return (0, types_1.skip)('no tracked files');
44
+ const files = await (0, targets_1.resolveTargets)(ctx, ['*'], {
45
+ wholeTree: true,
46
+ onEmpty: { skip: 'no tracked files' },
47
+ });
48
+ if (!Array.isArray(files))
49
+ return files;
48
50
  const ignore = ctx.config.symlinks?.ignore ?? [];
49
51
  const broken = [];
50
52
  for (const file of files) {