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.
- package/README.md +63 -268
- package/cspell.json +155 -133
- package/dist/config/constants.d.ts +6 -1
- package/dist/config/constants.js +8 -7
- package/dist/config/index.d.ts +23 -11
- package/dist/config/index.js +27 -33
- package/dist/core/checks/dependencies/banned-imports.js +4 -1
- package/dist/core/checks/dependencies/circular-deps.js +14 -5
- package/dist/core/checks/dependencies/dependency-check.js +1 -1
- package/dist/core/checks/dependencies/grype-scan.js +2 -1
- package/dist/core/checks/dependencies/vuln-scan.js +2 -1
- package/dist/core/checks/findings.d.ts +46 -2
- package/dist/core/checks/findings.js +140 -2
- package/dist/core/checks/git/custom-rules.js +2 -0
- package/dist/core/checks/git/diff-smell.js +6 -1
- package/dist/core/checks/git/leftover-debug.js +8 -2
- package/dist/core/checks/git/lockfile-drift.js +6 -1
- package/dist/core/checks/git/merge-conflict.js +1 -1
- package/dist/core/checks/git/missing-tests.js +1 -1
- package/dist/core/checks/git/todo-fixme.js +10 -2
- package/dist/core/checks/languages/cpp-format.js +19 -42
- package/dist/core/checks/languages/external-checks.d.ts +77 -0
- package/dist/core/checks/languages/external-checks.js +200 -0
- package/dist/core/checks/languages/external-runner.d.ts +119 -0
- package/dist/core/checks/languages/external-runner.js +143 -0
- package/dist/core/checks/languages/go-format.d.ts +4 -4
- package/dist/core/checks/languages/go-format.js +151 -30
- package/dist/core/checks/languages/lint.js +19 -19
- package/dist/core/checks/languages/prettier.js +2 -7
- package/dist/core/checks/languages/python-typecheck.d.ts +10 -5
- package/dist/core/checks/languages/python-typecheck.js +161 -37
- package/dist/core/checks/languages/typecheck.js +10 -8
- package/dist/core/checks/pr/commitlint.js +7 -1
- package/dist/core/checks/quality/case-collision.js +6 -4
- package/dist/core/checks/quality/config-validation.d.ts +0 -7
- package/dist/core/checks/quality/config-validation.js +30 -0
- package/dist/core/checks/quality/duplicate-check.js +54 -27
- package/dist/core/checks/quality/license-header.js +7 -4
- package/dist/core/checks/quality/markdown-lint.js +1 -6
- package/dist/core/checks/quality/spellcheck.js +34 -5
- package/dist/core/checks/quality/symlinks.js +6 -4
- package/dist/core/checks/security/diff-security.js +26 -18
- package/dist/core/checks/security/dockerfile-security.js +7 -1
- package/dist/core/checks/security/migration-safety.js +32 -4
- package/dist/core/checks/security/security.d.ts +56 -0
- package/dist/core/checks/security/security.js +135 -11
- package/dist/core/checks/security/sensitive-file-guard.js +40 -6
- package/dist/core/checks/security/workflow-security.js +7 -1
- package/dist/core/checks/tool-install.d.ts +102 -0
- package/dist/core/checks/tool-install.js +69 -0
- package/dist/core/context.js +9 -1
- package/dist/core/install/cache.js +42 -11
- package/dist/core/install/doctor.js +9 -0
- package/dist/core/install/ensure.js +22 -3
- package/dist/core/install/lock.d.ts +2 -1
- package/dist/core/install/prepare.d.ts +75 -0
- package/dist/core/install/prepare.js +151 -0
- package/dist/core/install/profiles.d.ts +17 -3
- package/dist/core/install/profiles.js +27 -0
- package/dist/core/install/reuse.d.ts +41 -0
- package/dist/core/install/reuse.js +107 -0
- package/dist/core/registry.js +10 -18
- package/dist/core/run-checks.js +74 -3
- package/dist/core/targets.d.ts +116 -5
- package/dist/core/targets.js +101 -16
- package/dist/core/types.d.ts +8 -2
- package/dist/init.js +21 -1
- package/dist/interfaces/index.d.ts +1 -0
- package/dist/utils/resolve-bin.d.ts +56 -6
- package/dist/utils/resolve-bin.js +136 -26
- package/gitleaks.toml +14 -0
- package/install-manifests/go/package-lock.json +24 -0
- package/install-manifests/go/package.json +8 -0
- package/install-manifests/pytypes/package-lock.json +45 -0
- package/install-manifests/pytypes/package.json +8 -0
- package/package.json +4 -2
- package/pr-checkmate.schema.json +8 -2
- package/src/config/cspell.json +155 -133
- package/src/config/pr-checkmate.schema.json +8 -2
- package/dist/core/checks/languages/csharp-format.d.ts +0 -13
- package/dist/core/checks/languages/csharp-format.js +0 -52
- package/dist/core/checks/languages/go-lint.d.ts +0 -8
- package/dist/core/checks/languages/go-lint.js +0 -48
- package/dist/core/checks/languages/kotlin-lint.d.ts +0 -2
- package/dist/core/checks/languages/kotlin-lint.js +0 -42
- package/dist/core/checks/languages/php-format.d.ts +0 -9
- package/dist/core/checks/languages/php-format.js +0 -49
- package/dist/core/checks/languages/ruby-lint.d.ts +0 -8
- package/dist/core/checks/languages/ruby-lint.js +0 -50
- package/dist/core/checks/languages/rust-format.d.ts +0 -9
- package/dist/core/checks/languages/rust-format.js +0 -45
- package/dist/core/checks/languages/rust-lint.d.ts +0 -8
- package/dist/core/checks/languages/rust-lint.js +0 -50
- package/dist/core/checks/languages/shellcheck.d.ts +0 -8
- package/dist/core/checks/languages/shellcheck.js +0 -55
- package/dist/core/checks/languages/swift-lint.d.ts +0 -2
- 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
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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-
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
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:
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
.
|
|
57
|
-
|
|
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(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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.
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
'
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
'
|
|
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
|
-
'
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return (0, types_1.
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
198
|
-
const
|
|
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
|
-
|
|
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.
|
|
46
|
-
|
|
47
|
-
|
|
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) {
|