pr-checkmate 4.0.0 → 4.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.
- package/README.md +6 -0
- package/cspell.json +1 -0
- package/dist/cjs/agent/hook.d.ts +2 -1
- package/dist/cjs/agent/setup.d.ts +4 -3
- package/dist/cjs/core/checks/git/diff-smell.js +3 -1
- package/dist/cjs/core/checks/languages/lint.js +27 -0
- package/dist/cjs/core/checks/languages/locate.d.ts +7 -0
- package/dist/cjs/core/checks/languages/locate.js +68 -0
- package/dist/cjs/core/checks/languages/prettier.js +15 -2
- package/dist/cjs/core/checks/languages/typecheck.js +4 -1
- package/dist/cjs/core/sarif.d.ts +3 -1
- package/dist/cjs/mcp/dependency.d.ts +2 -1
- package/dist/cjs/mcp/dependency.js +28 -22
- package/dist/cjs/mcp/protocol.d.ts +3 -9
- package/dist/cjs/mcp/review.d.ts +3 -2
- package/dist/cjs/mcp/review.js +1 -1
- package/dist/cjs/mcp/tools.js +43 -58
- package/dist/esm/agent/hook.d.ts +2 -1
- package/dist/esm/agent/setup.d.ts +4 -3
- package/dist/esm/core/checks/git/diff-smell.js +3 -1
- package/dist/esm/core/checks/languages/lint.js +28 -1
- package/dist/esm/core/checks/languages/locate.d.ts +7 -0
- package/dist/esm/core/checks/languages/locate.js +60 -0
- package/dist/esm/core/checks/languages/prettier.js +15 -2
- package/dist/esm/core/checks/languages/typecheck.js +4 -1
- package/dist/esm/core/sarif.d.ts +3 -1
- package/dist/esm/mcp/dependency.d.ts +2 -1
- package/dist/esm/mcp/dependency.js +28 -22
- package/dist/esm/mcp/protocol.d.ts +3 -9
- package/dist/esm/mcp/review.d.ts +3 -2
- package/dist/esm/mcp/review.js +1 -1
- package/dist/esm/mcp/tools.js +43 -58
- package/package.json +2 -2
- package/src/config/cspell.json +1 -0
package/README.md
CHANGED
|
@@ -78,6 +78,12 @@ claude mcp add --scope user pr-checkmate -- pr-checkmate mcp # once, for every
|
|
|
78
78
|
npx pr-checkmate agent-setup # per repository, below
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
Then check it: `claude mcp list` shows `pr-checkmate ✔ Connected`; sessions that were already open
|
|
82
|
+
need a restart. `claude` comes from the Claude Code CLI (an editor extension keeps its own copy off
|
|
83
|
+
your `PATH`). To stop Claude Code asking before every call, allow `mcp__pr-checkmate` in
|
|
84
|
+
`permissions.allow`
|
|
85
|
+
([details](https://github.com/Mybono/pr_checkmate_public/blob/main/documentation/ai-agents.md#after-installing)).
|
|
86
|
+
|
|
81
87
|
| Tool | The agent calls it |
|
|
82
88
|
|---|---|
|
|
83
89
|
| `get_rules` | before writing — what blocks a PR, what only warns, your custom rules and banned imports |
|
package/cspell.json
CHANGED
package/dist/cjs/agent/hook.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare const HOOK_COMMAND = "pr-checkmate hook";
|
|
|
10
10
|
* @returns the scope to review, or `undefined` when the line commits nothing.
|
|
11
11
|
*/
|
|
12
12
|
export declare function commitScopeFor(command: string): ReviewScope | undefined;
|
|
13
|
-
|
|
13
|
+
interface HookVerdict {
|
|
14
14
|
/** 2 blocks the tool call, 0 lets it through. */
|
|
15
15
|
exitCode: 0 | 2;
|
|
16
16
|
/** Shown to the model when blocked. */
|
|
@@ -35,3 +35,4 @@ export declare const EDIT_CHECKS: readonly string[];
|
|
|
35
35
|
* @param fallbackCwd Used when the payload carries no directory.
|
|
36
36
|
*/
|
|
37
37
|
export declare function evaluateHook(stdin: string, fallbackCwd: string): Promise<HookVerdict>;
|
|
38
|
+
export {};
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
export declare function isOnPath(name: string, env?: NodeJS.ProcessEnv): boolean;
|
|
3
3
|
export declare const BLOCK_START = "<!-- pr-checkmate:agent-rules:start -->";
|
|
4
4
|
export declare const BLOCK_END = "<!-- pr-checkmate:agent-rules:end -->";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
type SetupStatus = 'created' | 'updated' | 'unchanged' | 'skipped';
|
|
6
|
+
interface SetupAction {
|
|
7
7
|
file: string;
|
|
8
8
|
status: SetupStatus;
|
|
9
9
|
detail: string;
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
interface SetupOptions {
|
|
12
12
|
/** Install the commit-gate hook. Default true. */
|
|
13
13
|
hook?: boolean;
|
|
14
14
|
/** Whether the CLI is on PATH. Detected when omitted; injectable for tests. */
|
|
@@ -18,3 +18,4 @@ export interface SetupOptions {
|
|
|
18
18
|
export declare function setupAgent(cwd: string, opts?: SetupOptions): SetupAction[];
|
|
19
19
|
/** Print what changed and the one step the repository cannot do for the user. */
|
|
20
20
|
export declare function reportSetup(actions: SetupAction[]): void;
|
|
21
|
+
export {};
|
|
@@ -22,7 +22,9 @@ exports.CODE_SMELLS = [
|
|
|
22
22
|
view: 'code',
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
|
-
|
|
25
|
+
// A statement, not a mention: `debugger` ends the line or follows a `;`, `{`, `}` or `)`.
|
|
26
|
+
// A regex literal (`/debugger/`) or an object key (`{ debugger: 1 }`) is not one.
|
|
27
|
+
pattern: /^\+(?!\+)(?:.*[;{})]|\s*)\s*debugger\b\s*;?\s*$/,
|
|
26
28
|
label: 'debugger statement',
|
|
27
29
|
severity: 'warn',
|
|
28
30
|
view: 'code',
|
|
@@ -4,13 +4,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.lintCheck = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
7
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
8
10
|
const index_js_1 = require("../../../config/index.js");
|
|
9
11
|
const execa_js_1 = require("../../../utils/execa.js");
|
|
10
12
|
const index_js_2 = require("../../../utils/index.js");
|
|
13
|
+
const finding_store_js_1 = require("../../finding-store.js");
|
|
11
14
|
const reuse_js_1 = require("../../install/reuse.js");
|
|
12
15
|
const targets_js_1 = require("../../targets.js");
|
|
13
16
|
const types_js_1 = require("../../types.js");
|
|
17
|
+
const locate_js_1 = require("./locate.js");
|
|
18
|
+
const MAX_LOGGED_PROBLEMS = 50;
|
|
14
19
|
/**
|
|
15
20
|
* Resolve the ESLint binary under the client's `install.reuseLocal` policy.
|
|
16
21
|
*
|
|
@@ -36,6 +41,17 @@ async function getESLintMajorVersion(eslintBin) {
|
|
|
36
41
|
return 8;
|
|
37
42
|
}
|
|
38
43
|
}
|
|
44
|
+
function readProblems(reportFile, cwd) {
|
|
45
|
+
try {
|
|
46
|
+
return (0, locate_js_1.parseEslintJson)(node_fs_1.default.readFileSync(reportFile, 'utf-8'), cwd);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
(0, index_js_2.cleanupTempFile)(reportFile);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
39
55
|
exports.lintCheck = {
|
|
40
56
|
name: 'ESLint',
|
|
41
57
|
phase: 'blocking',
|
|
@@ -87,12 +103,23 @@ exports.lintCheck = {
|
|
|
87
103
|
if (!hasLocal && version < 9 && toolRoots.length > 0) {
|
|
88
104
|
args.push('--resolve-plugins-relative-to', toolRoots[0]);
|
|
89
105
|
}
|
|
106
|
+
// JSON to a file, not stylish text to the terminal: the file is what lets a
|
|
107
|
+
// finding name its file, line and rule instead of saying "lint errors found".
|
|
108
|
+
// The same problems are logged below in a compact form, so nothing is lost.
|
|
109
|
+
const reportFile = node_path_1.default.join(node_os_1.default.tmpdir(), `pr-checkmate-eslint-${process.pid}-${Date.now()}.json`);
|
|
110
|
+
args.push('--format', 'json', '--output-file', reportFile);
|
|
90
111
|
const result = await (0, execa_js_1.execa)(process.execPath, [eslintBin, ...args], {
|
|
91
112
|
cwd: ctx.cwd,
|
|
92
113
|
stdio: 'inherit',
|
|
93
114
|
reject: false,
|
|
94
115
|
env: { PR_CHECKMATE_TOOL_ROOTS: toolRoots.join(node_path_1.default.delimiter) },
|
|
95
116
|
});
|
|
117
|
+
const problems = readProblems(reportFile, ctx.cwd);
|
|
118
|
+
problems.slice(0, MAX_LOGGED_PROBLEMS).forEach(f => {
|
|
119
|
+
index_js_2.logger.warn(`[ESLint]: ${f.file}:${f.line ?? 0} ${f.rule} ${f.snippet ?? ''}`);
|
|
120
|
+
});
|
|
121
|
+
if (result.exitCode === 1)
|
|
122
|
+
problems.forEach(finding_store_js_1.addFinding);
|
|
96
123
|
if (result.exitCode === 0)
|
|
97
124
|
return (0, types_js_1.pass)();
|
|
98
125
|
// ESLint exits 1 for lint errors and 2 for a fatal error — a config it cannot
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from '../../types.js';
|
|
2
|
+
/** `src/a.ts(3,5): error TS2322: Type …` lines of `tsc --pretty false`. */
|
|
3
|
+
export declare function parseTscDiagnostics(output: string): Finding[];
|
|
4
|
+
/** The array `eslint --format json` writes. Anything else yields no findings. */
|
|
5
|
+
export declare function parseEslintJson(raw: string, cwd: string): Finding[];
|
|
6
|
+
/** One path per line, as `prettier --list-different` prints them. */
|
|
7
|
+
export declare function parseListDifferent(stdout: string): Finding[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseTscDiagnostics = parseTscDiagnostics;
|
|
7
|
+
exports.parseEslintJson = parseEslintJson;
|
|
8
|
+
exports.parseListDifferent = parseListDifferent;
|
|
9
|
+
/**
|
|
10
|
+
* Turn what the language tools print into located findings.
|
|
11
|
+
*
|
|
12
|
+
* The three tools disagree on how to say where a problem is, so each gets a small
|
|
13
|
+
* pure parser here and the checks only decide what to do with the result. Without
|
|
14
|
+
* these an agent was told "lint errors found" and nothing else.
|
|
15
|
+
*/
|
|
16
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
17
|
+
const MAX_SNIPPET = 160;
|
|
18
|
+
const TSC_DIAGNOSTIC = /^(.+?)\((\d+),\d+\): error (TS\d+): (.*)$/;
|
|
19
|
+
const clip = (text) => text.length > MAX_SNIPPET ? `${text.slice(0, MAX_SNIPPET - 1)}…` : text;
|
|
20
|
+
/** `src/a.ts(3,5): error TS2322: Type …` lines of `tsc --pretty false`. */
|
|
21
|
+
function parseTscDiagnostics(output) {
|
|
22
|
+
const found = [];
|
|
23
|
+
for (const line of output.split('\n')) {
|
|
24
|
+
const m = TSC_DIAGNOSTIC.exec(line.trim());
|
|
25
|
+
if (m) {
|
|
26
|
+
found.push({
|
|
27
|
+
file: m[1].replace(/\\/g, '/'),
|
|
28
|
+
line: Number(m[2]),
|
|
29
|
+
rule: m[3],
|
|
30
|
+
snippet: clip(m[4]),
|
|
31
|
+
severity: 'error',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
/** The array `eslint --format json` writes. Anything else yields no findings. */
|
|
38
|
+
function parseEslintJson(raw, cwd) {
|
|
39
|
+
let report;
|
|
40
|
+
try {
|
|
41
|
+
report = JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(report))
|
|
47
|
+
return [];
|
|
48
|
+
return report.flatMap(file => (file.messages ?? []).map(m => ({
|
|
49
|
+
file: node_path_1.default.relative(cwd, file.filePath).replace(/\\/g, '/'),
|
|
50
|
+
...(m.line !== undefined && { line: m.line }),
|
|
51
|
+
rule: m.ruleId ?? 'parse-error',
|
|
52
|
+
snippet: clip(m.message),
|
|
53
|
+
severity: m.severity === 2 ? 'error' : 'warn',
|
|
54
|
+
})));
|
|
55
|
+
}
|
|
56
|
+
/** One path per line, as `prettier --list-different` prints them. */
|
|
57
|
+
function parseListDifferent(stdout) {
|
|
58
|
+
return stdout
|
|
59
|
+
.split('\n')
|
|
60
|
+
.map(l => l.trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.map(file => ({
|
|
63
|
+
file: file.replace(/\\/g, '/'),
|
|
64
|
+
rule: 'prettier',
|
|
65
|
+
snippet: 'not formatted',
|
|
66
|
+
severity: 'warn',
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
@@ -9,8 +9,10 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
9
9
|
const index_js_1 = require("../../../config/index.js");
|
|
10
10
|
const execa_js_1 = require("../../../utils/execa.js");
|
|
11
11
|
const index_js_2 = require("../../../utils/index.js");
|
|
12
|
+
const finding_store_js_1 = require("../../finding-store.js");
|
|
12
13
|
const targets_js_1 = require("../../targets.js");
|
|
13
14
|
const types_js_1 = require("../../types.js");
|
|
15
|
+
const locate_js_1 = require("./locate.js");
|
|
14
16
|
function loadUserPrettierConfig(cwd) {
|
|
15
17
|
const configPath = node_path_1.default.join(cwd, 'pr-checkmate.json');
|
|
16
18
|
if (!node_fs_1.default.existsSync(configPath))
|
|
@@ -59,7 +61,9 @@ exports.prettierCheck = {
|
|
|
59
61
|
ignorePath = tempIgnore = createTempIgnoreFile(ctx.cwd);
|
|
60
62
|
const hasLocal = (0, index_js_1.hasLocalConfig)(['.prettierrc', '.prettierrc.json', '.prettierrc.js', 'prettier.config.js'], ctx.cwd);
|
|
61
63
|
// write=true → format in place; write=false → report-only (`--check`, never mutates files).
|
|
62
|
-
|
|
64
|
+
// `--list-different` names the unformatted files on stdout, which is what lets a
|
|
65
|
+
// finding point at them; `--check` only says how many there are.
|
|
66
|
+
const mode = ctx.write ? '--write' : '--list-different';
|
|
63
67
|
let tempConfig = null;
|
|
64
68
|
let args;
|
|
65
69
|
if (hasLocal) {
|
|
@@ -73,11 +77,20 @@ exports.prettierCheck = {
|
|
|
73
77
|
try {
|
|
74
78
|
const result = await (0, execa_js_1.execa)(process.execPath, [prettierBin, ...args], {
|
|
75
79
|
cwd: ctx.cwd,
|
|
76
|
-
stdio: 'inherit',
|
|
80
|
+
...(ctx.write && { stdio: 'inherit' }),
|
|
77
81
|
reject: false,
|
|
78
82
|
});
|
|
79
83
|
if (result.exitCode === 0)
|
|
80
84
|
return (0, types_js_1.pass)();
|
|
85
|
+
if (!ctx.write) {
|
|
86
|
+
const unformatted = (0, locate_js_1.parseListDifferent)(String(result.stdout ?? ''));
|
|
87
|
+
unformatted.forEach(f => {
|
|
88
|
+
index_js_2.logger.warn(`[Prettier]: not formatted: ${f.file}`);
|
|
89
|
+
(0, finding_store_js_1.addFinding)(f);
|
|
90
|
+
});
|
|
91
|
+
if (result.stderr)
|
|
92
|
+
index_js_2.logger.warn(String(result.stderr));
|
|
93
|
+
}
|
|
81
94
|
// In --check mode a non-zero exit means "some files are not formatted".
|
|
82
95
|
return (0, types_js_1.warn)(ctx.write ? 'prettier failed' : 'unformatted files (run with write to fix)');
|
|
83
96
|
}
|
|
@@ -8,8 +8,10 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
8
8
|
const node_path_1 = __importDefault(require("node:path"));
|
|
9
9
|
const execa_js_1 = require("../../../utils/execa.js");
|
|
10
10
|
const index_js_1 = require("../../../utils/index.js");
|
|
11
|
+
const finding_store_js_1 = require("../../finding-store.js");
|
|
11
12
|
const reuse_js_1 = require("../../install/reuse.js");
|
|
12
13
|
const types_js_1 = require("../../types.js");
|
|
14
|
+
const locate_js_1 = require("./locate.js");
|
|
13
15
|
/**
|
|
14
16
|
* Prefer the consumer project's own TypeScript so its tsconfig is compiled by the
|
|
15
17
|
* version it was written for; fall back to the compiler bundled with pr-checkmate.
|
|
@@ -45,7 +47,7 @@ exports.typecheckCheck = {
|
|
|
45
47
|
return (0, types_js_1.skip)('project dependencies not installed (no node_modules) — run `npm ci` before typecheck');
|
|
46
48
|
}
|
|
47
49
|
const tscBin = resolveTsc(ctx);
|
|
48
|
-
const result = await (0, execa_js_1.execa)(process.execPath, [tscBin, '--noEmit'], {
|
|
50
|
+
const result = await (0, execa_js_1.execa)(process.execPath, [tscBin, '--noEmit', '--pretty', 'false'], {
|
|
49
51
|
cwd: ctx.cwd,
|
|
50
52
|
reject: false,
|
|
51
53
|
});
|
|
@@ -62,6 +64,7 @@ exports.typecheckCheck = {
|
|
|
62
64
|
index_js_1.logger.warn(`[TypeScript]: ⚠️ tsc exited ${result.exitCode ?? 'abnormally'} without reporting a type error — treating as a tooling failure, not a type error`);
|
|
63
65
|
return (0, types_js_1.warn)(`tsc failed to run (exit ${result.exitCode ?? 'killed'})`);
|
|
64
66
|
}
|
|
67
|
+
(0, locate_js_1.parseTscDiagnostics)(output).forEach(finding_store_js_1.addFinding);
|
|
65
68
|
return (0, types_js_1.fail)(`${errorCount} type error(s)`);
|
|
66
69
|
},
|
|
67
70
|
};
|
package/dist/cjs/core/sarif.d.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* Only what a consumer can use is written. A failed or warned check that located
|
|
7
7
|
* its problems yields one result per location; one that could not yields a single
|
|
8
8
|
* result with no location, because a finding with nowhere to point is still a
|
|
9
|
-
* finding.
|
|
9
|
+
* finding. GitHub code scanning displays only results that have a location, so
|
|
10
|
+
* those one-line summaries reach editors and other viewers, not its alerts.
|
|
11
|
+
* Passes and skips are not results.
|
|
10
12
|
*/
|
|
11
13
|
import type { CheckReport } from './types.js';
|
|
12
14
|
/** Convert a report to a SARIF log object, ready for `JSON.stringify`. */
|
|
@@ -5,7 +5,7 @@ export type Ecosystem = 'npm' | 'pypi';
|
|
|
5
5
|
* `ok` — nothing found
|
|
6
6
|
* `unknown` — a lookup failed, so nothing can be said
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
type Verdict = 'avoid' | 'review' | 'ok' | 'unknown';
|
|
9
9
|
export interface DependencyReport {
|
|
10
10
|
name: string;
|
|
11
11
|
ecosystem: Ecosystem;
|
|
@@ -31,3 +31,4 @@ export declare function checkDependency(name: string, opts?: {
|
|
|
31
31
|
}): Promise<DependencyReport>;
|
|
32
32
|
/** The report as the text an agent reads. */
|
|
33
33
|
export declare function formatDependency(r: DependencyReport): string;
|
|
34
|
+
export {};
|
|
@@ -168,21 +168,37 @@ const daysSince = (iso) => {
|
|
|
168
168
|
const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
|
|
169
169
|
return Number.isNaN(t) ? undefined : Math.floor((Date.now() - t) / DAY_MS);
|
|
170
170
|
};
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Record why a registry answer cannot be used: "no such package" is fatal, an answer
|
|
173
|
+
* that is missing or unreadable only leaves the report incomplete. Returns `true`
|
|
174
|
+
* when the lookup should stop; `false` means `res.body` is a JSON object.
|
|
175
|
+
*/
|
|
176
|
+
function rejectRegistryAnswer(out, res, registry) {
|
|
174
177
|
if (res.status === 404) {
|
|
175
178
|
out.fatal = true;
|
|
176
179
|
out.facts.exists = false;
|
|
177
|
-
out.reasons.push(
|
|
178
|
-
return
|
|
180
|
+
out.reasons.push(`not on ${registry} — a name that sounds right but does not exist is how hallucinated packages get squatted; do not add it`);
|
|
181
|
+
return true;
|
|
179
182
|
}
|
|
180
183
|
if (!res.ok || !isRecord(res.body)) {
|
|
181
184
|
out.incomplete = true;
|
|
182
|
-
out.reasons.push(`could not read
|
|
183
|
-
return
|
|
185
|
+
out.reasons.push(`could not read ${registry} (${res.error ?? res.status})`);
|
|
186
|
+
return true;
|
|
184
187
|
}
|
|
185
|
-
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
/** Fetch a registry document: a fresh `Collected` plus the JSON object, absent when it cannot be used. */
|
|
191
|
+
async function openRegistry(url, get, registry) {
|
|
192
|
+
const out = { reasons: [], facts: {}, fatal: false, incomplete: false };
|
|
193
|
+
const res = await get(url);
|
|
194
|
+
if (rejectRegistryAnswer(out, res, registry))
|
|
195
|
+
return { out };
|
|
196
|
+
return { out, doc: res.body };
|
|
197
|
+
}
|
|
198
|
+
async function lookupNpm(name, requested, get) {
|
|
199
|
+
const { out, doc } = await openRegistry(`https://registry.npmjs.org/${name.replace('/', '%2f')}`, get, 'the npm registry');
|
|
200
|
+
if (!doc)
|
|
201
|
+
return out;
|
|
186
202
|
const tags = isRecord(doc['dist-tags']) ? doc['dist-tags'] : {};
|
|
187
203
|
const versions = isRecord(doc.versions) ? doc.versions : {};
|
|
188
204
|
const time = isRecord(doc.time) ? doc.time : {};
|
|
@@ -233,21 +249,11 @@ async function lookupNpm(name, requested, get) {
|
|
|
233
249
|
return out;
|
|
234
250
|
}
|
|
235
251
|
async function lookupPypi(name, requested, get) {
|
|
236
|
-
const out
|
|
237
|
-
|
|
238
|
-
if (res.status === 404) {
|
|
239
|
-
out.fatal = true;
|
|
240
|
-
out.facts.exists = false;
|
|
241
|
-
out.reasons.push('not on PyPI — a name that sounds right but does not exist is how hallucinated packages get squatted; do not add it');
|
|
252
|
+
const { out, doc } = await openRegistry(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, get, 'PyPI');
|
|
253
|
+
if (!doc)
|
|
242
254
|
return out;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
out.incomplete = true;
|
|
246
|
-
out.reasons.push(`could not read PyPI (${res.error ?? res.status})`);
|
|
247
|
-
return out;
|
|
248
|
-
}
|
|
249
|
-
const info = isRecord(res.body.info) ? res.body.info : {};
|
|
250
|
-
const releases = isRecord(res.body.releases) ? res.body.releases : {};
|
|
255
|
+
const info = isRecord(doc.info) ? doc.info : {};
|
|
256
|
+
const releases = isRecord(doc.releases) ? doc.releases : {};
|
|
251
257
|
out.facts.exists = true;
|
|
252
258
|
if (requested && !(requested in releases)) {
|
|
253
259
|
out.fatal = true;
|
|
@@ -13,15 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
/** Revisions we can speak, newest first. A client asking for another gets the newest. */
|
|
15
15
|
export declare const SUPPORTED_PROTOCOLS: readonly string[];
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
jsonrpc: '2.0';
|
|
19
|
-
/** Absent on notifications, which never get an answer. */
|
|
20
|
-
id?: JsonRpcId;
|
|
21
|
-
method: string;
|
|
22
|
-
params?: Record<string, unknown>;
|
|
23
|
-
}
|
|
24
|
-
export interface JsonRpcResponse {
|
|
16
|
+
type JsonRpcId = string | number | null;
|
|
17
|
+
interface JsonRpcResponse {
|
|
25
18
|
jsonrpc: '2.0';
|
|
26
19
|
id: JsonRpcId;
|
|
27
20
|
result?: unknown;
|
|
@@ -95,3 +88,4 @@ export declare function negotiateProtocol(requested: unknown): string;
|
|
|
95
88
|
export declare function handleMessage(server: ServerDefinition, message: unknown): Promise<JsonRpcResponse | null>;
|
|
96
89
|
/** Decode one stdio line. A line that is not JSON gets a parse error, never a crash. */
|
|
97
90
|
export declare function handleLine(server: ServerDefinition, line: string): Promise<JsonRpcResponse | null>;
|
|
91
|
+
export {};
|
package/dist/cjs/mcp/review.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export declare function snapshotWorktree(cwd: string): Promise<{
|
|
|
35
35
|
baseSha: string;
|
|
36
36
|
headSha: string;
|
|
37
37
|
} | undefined>;
|
|
38
|
-
|
|
38
|
+
interface ReviewOptions {
|
|
39
39
|
/** Directory inside the repository to review. */
|
|
40
40
|
cwd: string;
|
|
41
41
|
scope?: ReviewScope;
|
|
@@ -49,7 +49,7 @@ export interface ReviewOptions {
|
|
|
49
49
|
*/
|
|
50
50
|
write?: boolean;
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
interface ReviewOutcome {
|
|
53
53
|
report: CheckReport;
|
|
54
54
|
scope: ReviewScope;
|
|
55
55
|
/** Files the diff touches; absent for `full`, which has no diff. */
|
|
@@ -61,3 +61,4 @@ export interface ReviewOutcome {
|
|
|
61
61
|
export declare function review(opts: ReviewOptions): Promise<ReviewOutcome>;
|
|
62
62
|
/** Files in the working-tree diff, with a content hash, so a later call can tell what a fixer rewrote. */
|
|
63
63
|
export declare function fingerprintChanges(cwd: string): Promise<Map<string, string>>;
|
|
64
|
+
export {};
|
package/dist/cjs/mcp/review.js
CHANGED
|
@@ -144,7 +144,7 @@ async function fingerprintChanges(cwd) {
|
|
|
144
144
|
});
|
|
145
145
|
for (const name of stdout.split('\n').filter(Boolean)) {
|
|
146
146
|
const abs = node_path_1.default.join(cwd, name);
|
|
147
|
-
files.set(name, node_fs_1.default.existsSync(abs) ? (0, node_crypto_1.createHash)('
|
|
147
|
+
files.set(name, node_fs_1.default.existsSync(abs) ? (0, node_crypto_1.createHash)('sha256').update(node_fs_1.default.readFileSync(abs)).digest('hex') : '');
|
|
148
148
|
}
|
|
149
149
|
return files;
|
|
150
150
|
}
|
package/dist/cjs/mcp/tools.js
CHANGED
|
@@ -288,6 +288,41 @@ const getRules = {
|
|
|
288
288
|
return serialized(async () => ({ text: await describeRules(cwd) }));
|
|
289
289
|
},
|
|
290
290
|
};
|
|
291
|
+
const SAME_SCOPE_PROPERTY = {
|
|
292
|
+
type: 'string',
|
|
293
|
+
enum: [...review_js_1.REVIEW_SCOPES],
|
|
294
|
+
description: 'Same meaning as in check_changes. Defaults to "worktree".',
|
|
295
|
+
};
|
|
296
|
+
/** The scope a call asks for, or the error to hand back when it is not one we know. */
|
|
297
|
+
function readScope(args) {
|
|
298
|
+
const scope = args.scope ?? 'worktree';
|
|
299
|
+
if (review_js_1.REVIEW_SCOPES.includes(scope))
|
|
300
|
+
return { scope: scope };
|
|
301
|
+
return {
|
|
302
|
+
error: {
|
|
303
|
+
text: `Unknown scope "${String(scope)}". Use one of: ${review_js_1.REVIEW_SCOPES.join(', ')}.`,
|
|
304
|
+
isError: true,
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const SCOPE_ONLY_SCHEMA = {
|
|
309
|
+
type: 'object',
|
|
310
|
+
properties: { scope: SAME_SCOPE_PROPERTY, ...CWD_PROPERTY },
|
|
311
|
+
additionalProperties: false,
|
|
312
|
+
};
|
|
313
|
+
/** Review the repository a call points at and format the outcome for the agent. */
|
|
314
|
+
async function reviewFor(args, scope, extras) {
|
|
315
|
+
const cwd = await repoFor(args);
|
|
316
|
+
const outcome = await serialized(() => inDirectory(cwd, () => (0, review_js_1.review)({ cwd, scope, ...extras })));
|
|
317
|
+
return formatReport(outcome.report, outcome);
|
|
318
|
+
}
|
|
319
|
+
/** A review whose only argument is the scope: `scan_secrets` and `verify`. */
|
|
320
|
+
async function reviewWithScope(args, extras) {
|
|
321
|
+
const asked = readScope(args);
|
|
322
|
+
if ('error' in asked)
|
|
323
|
+
return asked.error;
|
|
324
|
+
return reviewFor(args, asked.scope, extras);
|
|
325
|
+
}
|
|
291
326
|
const checkChanges = {
|
|
292
327
|
name: 'check_changes',
|
|
293
328
|
description: "Run the repository's pull-request gate on the code you have changed — uncommitted files included, nothing needs to be staged — and return the findings. " +
|
|
@@ -315,13 +350,9 @@ const checkChanges = {
|
|
|
315
350
|
additionalProperties: false,
|
|
316
351
|
},
|
|
317
352
|
async run(args) {
|
|
318
|
-
const
|
|
319
|
-
if (
|
|
320
|
-
return
|
|
321
|
-
text: `Unknown scope "${String(scope)}". Use one of: ${review_js_1.REVIEW_SCOPES.join(', ')}.`,
|
|
322
|
-
isError: true,
|
|
323
|
-
};
|
|
324
|
-
}
|
|
353
|
+
const asked = readScope(args);
|
|
354
|
+
if ('error' in asked)
|
|
355
|
+
return asked.error;
|
|
325
356
|
const requested = Array.isArray(args.checks) ? args.checks.map(String) : [];
|
|
326
357
|
const known = new Set(registry_js_1.REGISTRY.map(c => c.name));
|
|
327
358
|
const only = requested.map(name => commands_js_1.COMMAND_TO_CHECK[name] ?? name);
|
|
@@ -334,68 +365,22 @@ const checkChanges = {
|
|
|
334
365
|
isError: true,
|
|
335
366
|
};
|
|
336
367
|
}
|
|
337
|
-
|
|
338
|
-
const outcome = await serialized(() => inDirectory(cwd, () => (0, review_js_1.review)({ cwd, scope: scope, only, deep: args.deep === true })));
|
|
339
|
-
return formatReport(outcome.report, outcome);
|
|
368
|
+
return reviewFor(args, asked.scope, { only, deep: args.deep === true });
|
|
340
369
|
},
|
|
341
370
|
};
|
|
342
371
|
const scanSecrets = {
|
|
343
372
|
name: 'scan_secrets',
|
|
344
373
|
description: 'Scan the code you have changed for committed credentials, tokens, private keys and risky additions. Run it before committing configuration, environment or credential files, and whenever a value looks like a secret.',
|
|
345
|
-
inputSchema:
|
|
346
|
-
|
|
347
|
-
properties: {
|
|
348
|
-
scope: {
|
|
349
|
-
type: 'string',
|
|
350
|
-
enum: [...review_js_1.REVIEW_SCOPES],
|
|
351
|
-
description: 'Same meaning as in check_changes. Defaults to "worktree".',
|
|
352
|
-
},
|
|
353
|
-
...CWD_PROPERTY,
|
|
354
|
-
},
|
|
355
|
-
additionalProperties: false,
|
|
356
|
-
},
|
|
357
|
-
async run(args) {
|
|
358
|
-
const scope = args.scope ?? 'worktree';
|
|
359
|
-
if (!review_js_1.REVIEW_SCOPES.includes(scope)) {
|
|
360
|
-
return {
|
|
361
|
-
text: `Unknown scope "${String(scope)}". Use one of: ${review_js_1.REVIEW_SCOPES.join(', ')}.`,
|
|
362
|
-
isError: true,
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
const cwd = await repoFor(args);
|
|
366
|
-
const outcome = await serialized(() => inDirectory(cwd, () => (0, review_js_1.review)({ cwd, scope: scope, only: [...SECRET_CHECKS] })));
|
|
367
|
-
return formatReport(outcome.report, outcome);
|
|
368
|
-
},
|
|
374
|
+
inputSchema: SCOPE_ONLY_SCHEMA,
|
|
375
|
+
run: args => reviewWithScope(args, { only: [...SECRET_CHECKS] }),
|
|
369
376
|
};
|
|
370
377
|
const verify = {
|
|
371
378
|
name: 'verify',
|
|
372
379
|
description: 'The final gate. Runs every check the pull-request gate runs, the slow ones included (typecheck, dependency audit, spellcheck, dead code), on the code you have changed. ' +
|
|
373
380
|
'Call it once when you believe the work is done and before you tell the user it is finished. ' +
|
|
374
381
|
'check_changes is the quick loop while you work; a clean verify is the closest you can get to knowing CI will agree.',
|
|
375
|
-
inputSchema:
|
|
376
|
-
|
|
377
|
-
properties: {
|
|
378
|
-
scope: {
|
|
379
|
-
type: 'string',
|
|
380
|
-
enum: [...review_js_1.REVIEW_SCOPES],
|
|
381
|
-
description: 'Same meaning as in check_changes. Defaults to "worktree".',
|
|
382
|
-
},
|
|
383
|
-
...CWD_PROPERTY,
|
|
384
|
-
},
|
|
385
|
-
additionalProperties: false,
|
|
386
|
-
},
|
|
387
|
-
async run(args) {
|
|
388
|
-
const scope = args.scope ?? 'worktree';
|
|
389
|
-
if (!review_js_1.REVIEW_SCOPES.includes(scope)) {
|
|
390
|
-
return {
|
|
391
|
-
text: `Unknown scope "${String(scope)}". Use one of: ${review_js_1.REVIEW_SCOPES.join(', ')}.`,
|
|
392
|
-
isError: true,
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
const cwd = await repoFor(args);
|
|
396
|
-
const outcome = await serialized(() => inDirectory(cwd, () => (0, review_js_1.review)({ cwd, scope: scope, deep: true })));
|
|
397
|
-
return formatReport(outcome.report, outcome);
|
|
398
|
-
},
|
|
382
|
+
inputSchema: SCOPE_ONLY_SCHEMA,
|
|
383
|
+
run: args => reviewWithScope(args, { deep: true }),
|
|
399
384
|
};
|
|
400
385
|
/** The only checks that can rewrite code safely: formatting and lint autofixes. */
|
|
401
386
|
const FIXERS = ['Prettier', 'ESLint'];
|
package/dist/esm/agent/hook.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare const HOOK_COMMAND = "pr-checkmate hook";
|
|
|
10
10
|
* @returns the scope to review, or `undefined` when the line commits nothing.
|
|
11
11
|
*/
|
|
12
12
|
export declare function commitScopeFor(command: string): ReviewScope | undefined;
|
|
13
|
-
|
|
13
|
+
interface HookVerdict {
|
|
14
14
|
/** 2 blocks the tool call, 0 lets it through. */
|
|
15
15
|
exitCode: 0 | 2;
|
|
16
16
|
/** Shown to the model when blocked. */
|
|
@@ -35,3 +35,4 @@ export declare const EDIT_CHECKS: readonly string[];
|
|
|
35
35
|
* @param fallbackCwd Used when the payload carries no directory.
|
|
36
36
|
*/
|
|
37
37
|
export declare function evaluateHook(stdin: string, fallbackCwd: string): Promise<HookVerdict>;
|
|
38
|
+
export {};
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
export declare function isOnPath(name: string, env?: NodeJS.ProcessEnv): boolean;
|
|
3
3
|
export declare const BLOCK_START = "<!-- pr-checkmate:agent-rules:start -->";
|
|
4
4
|
export declare const BLOCK_END = "<!-- pr-checkmate:agent-rules:end -->";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
type SetupStatus = 'created' | 'updated' | 'unchanged' | 'skipped';
|
|
6
|
+
interface SetupAction {
|
|
7
7
|
file: string;
|
|
8
8
|
status: SetupStatus;
|
|
9
9
|
detail: string;
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
interface SetupOptions {
|
|
12
12
|
/** Install the commit-gate hook. Default true. */
|
|
13
13
|
hook?: boolean;
|
|
14
14
|
/** Whether the CLI is on PATH. Detected when omitted; injectable for tests. */
|
|
@@ -18,3 +18,4 @@ export interface SetupOptions {
|
|
|
18
18
|
export declare function setupAgent(cwd: string, opts?: SetupOptions): SetupAction[];
|
|
19
19
|
/** Print what changed and the one step the repository cannot do for the user. */
|
|
20
20
|
export declare function reportSetup(actions: SetupAction[]): void;
|
|
21
|
+
export {};
|
|
@@ -19,7 +19,9 @@ export const CODE_SMELLS = [
|
|
|
19
19
|
view: 'code',
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
|
-
|
|
22
|
+
// A statement, not a mention: `debugger` ends the line or follows a `;`, `{`, `}` or `)`.
|
|
23
|
+
// A regex literal (`/debugger/`) or an object key (`{ debugger: 1 }`) is not one.
|
|
24
|
+
pattern: /^\+(?!\+)(?:.*[;{})]|\s*)\s*debugger\b\s*;?\s*$/,
|
|
23
25
|
label: 'debugger statement',
|
|
24
26
|
severity: 'warn',
|
|
25
27
|
view: 'code',
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
1
3
|
import path from 'node:path';
|
|
2
4
|
import { getPackageConfigPath, getSourcePaths, hasLocalConfig, IGNORE_PATTERNS, } from '../../../config/index.js';
|
|
3
5
|
import { execa } from '../../../utils/execa.js';
|
|
4
|
-
import { logger, registeredToolPaths, resolvePackageBin } from '../../../utils/index.js';
|
|
6
|
+
import { cleanupTempFile, logger, registeredToolPaths, resolvePackageBin, } from '../../../utils/index.js';
|
|
7
|
+
import { addFinding } from '../../finding-store.js';
|
|
5
8
|
import { clientToolPaths } from '../../install/reuse.js';
|
|
6
9
|
import { resolveScopedTargetFiles } from '../../targets.js';
|
|
7
10
|
import { fail, pass, skip, warn } from '../../types.js';
|
|
11
|
+
import { parseEslintJson } from './locate.js';
|
|
12
|
+
const MAX_LOGGED_PROBLEMS = 50;
|
|
8
13
|
/**
|
|
9
14
|
* Resolve the ESLint binary under the client's `install.reuseLocal` policy.
|
|
10
15
|
*
|
|
@@ -30,6 +35,17 @@ async function getESLintMajorVersion(eslintBin) {
|
|
|
30
35
|
return 8;
|
|
31
36
|
}
|
|
32
37
|
}
|
|
38
|
+
function readProblems(reportFile, cwd) {
|
|
39
|
+
try {
|
|
40
|
+
return parseEslintJson(fs.readFileSync(reportFile, 'utf-8'), cwd);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
cleanupTempFile(reportFile);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
33
49
|
export const lintCheck = {
|
|
34
50
|
name: 'ESLint',
|
|
35
51
|
phase: 'blocking',
|
|
@@ -81,12 +97,23 @@ export const lintCheck = {
|
|
|
81
97
|
if (!hasLocal && version < 9 && toolRoots.length > 0) {
|
|
82
98
|
args.push('--resolve-plugins-relative-to', toolRoots[0]);
|
|
83
99
|
}
|
|
100
|
+
// JSON to a file, not stylish text to the terminal: the file is what lets a
|
|
101
|
+
// finding name its file, line and rule instead of saying "lint errors found".
|
|
102
|
+
// The same problems are logged below in a compact form, so nothing is lost.
|
|
103
|
+
const reportFile = path.join(os.tmpdir(), `pr-checkmate-eslint-${process.pid}-${Date.now()}.json`);
|
|
104
|
+
args.push('--format', 'json', '--output-file', reportFile);
|
|
84
105
|
const result = await execa(process.execPath, [eslintBin, ...args], {
|
|
85
106
|
cwd: ctx.cwd,
|
|
86
107
|
stdio: 'inherit',
|
|
87
108
|
reject: false,
|
|
88
109
|
env: { PR_CHECKMATE_TOOL_ROOTS: toolRoots.join(path.delimiter) },
|
|
89
110
|
});
|
|
111
|
+
const problems = readProblems(reportFile, ctx.cwd);
|
|
112
|
+
problems.slice(0, MAX_LOGGED_PROBLEMS).forEach(f => {
|
|
113
|
+
logger.warn(`[ESLint]: ${f.file}:${f.line ?? 0} ${f.rule} ${f.snippet ?? ''}`);
|
|
114
|
+
});
|
|
115
|
+
if (result.exitCode === 1)
|
|
116
|
+
problems.forEach(addFinding);
|
|
90
117
|
if (result.exitCode === 0)
|
|
91
118
|
return pass();
|
|
92
119
|
// ESLint exits 1 for lint errors and 2 for a fatal error — a config it cannot
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Finding } from '../../types.js';
|
|
2
|
+
/** `src/a.ts(3,5): error TS2322: Type …` lines of `tsc --pretty false`. */
|
|
3
|
+
export declare function parseTscDiagnostics(output: string): Finding[];
|
|
4
|
+
/** The array `eslint --format json` writes. Anything else yields no findings. */
|
|
5
|
+
export declare function parseEslintJson(raw: string, cwd: string): Finding[];
|
|
6
|
+
/** One path per line, as `prettier --list-different` prints them. */
|
|
7
|
+
export declare function parseListDifferent(stdout: string): Finding[];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn what the language tools print into located findings.
|
|
3
|
+
*
|
|
4
|
+
* The three tools disagree on how to say where a problem is, so each gets a small
|
|
5
|
+
* pure parser here and the checks only decide what to do with the result. Without
|
|
6
|
+
* these an agent was told "lint errors found" and nothing else.
|
|
7
|
+
*/
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
const MAX_SNIPPET = 160;
|
|
10
|
+
const TSC_DIAGNOSTIC = /^(.+?)\((\d+),\d+\): error (TS\d+): (.*)$/;
|
|
11
|
+
const clip = (text) => text.length > MAX_SNIPPET ? `${text.slice(0, MAX_SNIPPET - 1)}…` : text;
|
|
12
|
+
/** `src/a.ts(3,5): error TS2322: Type …` lines of `tsc --pretty false`. */
|
|
13
|
+
export function parseTscDiagnostics(output) {
|
|
14
|
+
const found = [];
|
|
15
|
+
for (const line of output.split('\n')) {
|
|
16
|
+
const m = TSC_DIAGNOSTIC.exec(line.trim());
|
|
17
|
+
if (m) {
|
|
18
|
+
found.push({
|
|
19
|
+
file: m[1].replace(/\\/g, '/'),
|
|
20
|
+
line: Number(m[2]),
|
|
21
|
+
rule: m[3],
|
|
22
|
+
snippet: clip(m[4]),
|
|
23
|
+
severity: 'error',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return found;
|
|
28
|
+
}
|
|
29
|
+
/** The array `eslint --format json` writes. Anything else yields no findings. */
|
|
30
|
+
export function parseEslintJson(raw, cwd) {
|
|
31
|
+
let report;
|
|
32
|
+
try {
|
|
33
|
+
report = JSON.parse(raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(report))
|
|
39
|
+
return [];
|
|
40
|
+
return report.flatMap(file => (file.messages ?? []).map(m => ({
|
|
41
|
+
file: path.relative(cwd, file.filePath).replace(/\\/g, '/'),
|
|
42
|
+
...(m.line !== undefined && { line: m.line }),
|
|
43
|
+
rule: m.ruleId ?? 'parse-error',
|
|
44
|
+
snippet: clip(m.message),
|
|
45
|
+
severity: m.severity === 2 ? 'error' : 'warn',
|
|
46
|
+
})));
|
|
47
|
+
}
|
|
48
|
+
/** One path per line, as `prettier --list-different` prints them. */
|
|
49
|
+
export function parseListDifferent(stdout) {
|
|
50
|
+
return stdout
|
|
51
|
+
.split('\n')
|
|
52
|
+
.map(l => l.trim())
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.map(file => ({
|
|
55
|
+
file: file.replace(/\\/g, '/'),
|
|
56
|
+
rule: 'prettier',
|
|
57
|
+
snippet: 'not formatted',
|
|
58
|
+
severity: 'warn',
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
@@ -3,8 +3,10 @@ import path from 'node:path';
|
|
|
3
3
|
import { getPackageConfigPath, getSourcePaths, hasLocalConfig, IGNORE_PATTERNS, } from '../../../config/index.js';
|
|
4
4
|
import { execa } from '../../../utils/execa.js';
|
|
5
5
|
import { cleanupTempFile, logger, resolvePackageBin } from '../../../utils/index.js';
|
|
6
|
+
import { addFinding } from '../../finding-store.js';
|
|
6
7
|
import { resolveScopedTargetFiles } from '../../targets.js';
|
|
7
8
|
import { pass, skip, warn } from '../../types.js';
|
|
9
|
+
import { parseListDifferent } from './locate.js';
|
|
8
10
|
function loadUserPrettierConfig(cwd) {
|
|
9
11
|
const configPath = path.join(cwd, 'pr-checkmate.json');
|
|
10
12
|
if (!fs.existsSync(configPath))
|
|
@@ -53,7 +55,9 @@ export const prettierCheck = {
|
|
|
53
55
|
ignorePath = tempIgnore = createTempIgnoreFile(ctx.cwd);
|
|
54
56
|
const hasLocal = hasLocalConfig(['.prettierrc', '.prettierrc.json', '.prettierrc.js', 'prettier.config.js'], ctx.cwd);
|
|
55
57
|
// write=true → format in place; write=false → report-only (`--check`, never mutates files).
|
|
56
|
-
|
|
58
|
+
// `--list-different` names the unformatted files on stdout, which is what lets a
|
|
59
|
+
// finding point at them; `--check` only says how many there are.
|
|
60
|
+
const mode = ctx.write ? '--write' : '--list-different';
|
|
57
61
|
let tempConfig = null;
|
|
58
62
|
let args;
|
|
59
63
|
if (hasLocal) {
|
|
@@ -67,11 +71,20 @@ export const prettierCheck = {
|
|
|
67
71
|
try {
|
|
68
72
|
const result = await execa(process.execPath, [prettierBin, ...args], {
|
|
69
73
|
cwd: ctx.cwd,
|
|
70
|
-
stdio: 'inherit',
|
|
74
|
+
...(ctx.write && { stdio: 'inherit' }),
|
|
71
75
|
reject: false,
|
|
72
76
|
});
|
|
73
77
|
if (result.exitCode === 0)
|
|
74
78
|
return pass();
|
|
79
|
+
if (!ctx.write) {
|
|
80
|
+
const unformatted = parseListDifferent(String(result.stdout ?? ''));
|
|
81
|
+
unformatted.forEach(f => {
|
|
82
|
+
logger.warn(`[Prettier]: not formatted: ${f.file}`);
|
|
83
|
+
addFinding(f);
|
|
84
|
+
});
|
|
85
|
+
if (result.stderr)
|
|
86
|
+
logger.warn(String(result.stderr));
|
|
87
|
+
}
|
|
75
88
|
// In --check mode a non-zero exit means "some files are not formatted".
|
|
76
89
|
return warn(ctx.write ? 'prettier failed' : 'unformatted files (run with write to fix)');
|
|
77
90
|
}
|
|
@@ -2,8 +2,10 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { execa } from '../../../utils/execa.js';
|
|
4
4
|
import { logger, resolvePackageBin } from '../../../utils/index.js';
|
|
5
|
+
import { addFinding } from '../../finding-store.js';
|
|
5
6
|
import { clientToolPaths } from '../../install/reuse.js';
|
|
6
7
|
import { fail, pass, skip, warn } from '../../types.js';
|
|
8
|
+
import { parseTscDiagnostics } from './locate.js';
|
|
7
9
|
/**
|
|
8
10
|
* Prefer the consumer project's own TypeScript so its tsconfig is compiled by the
|
|
9
11
|
* version it was written for; fall back to the compiler bundled with pr-checkmate.
|
|
@@ -39,7 +41,7 @@ export const typecheckCheck = {
|
|
|
39
41
|
return skip('project dependencies not installed (no node_modules) — run `npm ci` before typecheck');
|
|
40
42
|
}
|
|
41
43
|
const tscBin = resolveTsc(ctx);
|
|
42
|
-
const result = await execa(process.execPath, [tscBin, '--noEmit'], {
|
|
44
|
+
const result = await execa(process.execPath, [tscBin, '--noEmit', '--pretty', 'false'], {
|
|
43
45
|
cwd: ctx.cwd,
|
|
44
46
|
reject: false,
|
|
45
47
|
});
|
|
@@ -56,6 +58,7 @@ export const typecheckCheck = {
|
|
|
56
58
|
logger.warn(`[TypeScript]: ⚠️ tsc exited ${result.exitCode ?? 'abnormally'} without reporting a type error — treating as a tooling failure, not a type error`);
|
|
57
59
|
return warn(`tsc failed to run (exit ${result.exitCode ?? 'killed'})`);
|
|
58
60
|
}
|
|
61
|
+
parseTscDiagnostics(output).forEach(addFinding);
|
|
59
62
|
return fail(`${errorCount} type error(s)`);
|
|
60
63
|
},
|
|
61
64
|
};
|
package/dist/esm/core/sarif.d.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* Only what a consumer can use is written. A failed or warned check that located
|
|
7
7
|
* its problems yields one result per location; one that could not yields a single
|
|
8
8
|
* result with no location, because a finding with nowhere to point is still a
|
|
9
|
-
* finding.
|
|
9
|
+
* finding. GitHub code scanning displays only results that have a location, so
|
|
10
|
+
* those one-line summaries reach editors and other viewers, not its alerts.
|
|
11
|
+
* Passes and skips are not results.
|
|
10
12
|
*/
|
|
11
13
|
import type { CheckReport } from './types.js';
|
|
12
14
|
/** Convert a report to a SARIF log object, ready for `JSON.stringify`. */
|
|
@@ -5,7 +5,7 @@ export type Ecosystem = 'npm' | 'pypi';
|
|
|
5
5
|
* `ok` — nothing found
|
|
6
6
|
* `unknown` — a lookup failed, so nothing can be said
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
type Verdict = 'avoid' | 'review' | 'ok' | 'unknown';
|
|
9
9
|
export interface DependencyReport {
|
|
10
10
|
name: string;
|
|
11
11
|
ecosystem: Ecosystem;
|
|
@@ -31,3 +31,4 @@ export declare function checkDependency(name: string, opts?: {
|
|
|
31
31
|
}): Promise<DependencyReport>;
|
|
32
32
|
/** The report as the text an agent reads. */
|
|
33
33
|
export declare function formatDependency(r: DependencyReport): string;
|
|
34
|
+
export {};
|
|
@@ -163,21 +163,37 @@ const daysSince = (iso) => {
|
|
|
163
163
|
const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
|
|
164
164
|
return Number.isNaN(t) ? undefined : Math.floor((Date.now() - t) / DAY_MS);
|
|
165
165
|
};
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Record why a registry answer cannot be used: "no such package" is fatal, an answer
|
|
168
|
+
* that is missing or unreadable only leaves the report incomplete. Returns `true`
|
|
169
|
+
* when the lookup should stop; `false` means `res.body` is a JSON object.
|
|
170
|
+
*/
|
|
171
|
+
function rejectRegistryAnswer(out, res, registry) {
|
|
169
172
|
if (res.status === 404) {
|
|
170
173
|
out.fatal = true;
|
|
171
174
|
out.facts.exists = false;
|
|
172
|
-
out.reasons.push(
|
|
173
|
-
return
|
|
175
|
+
out.reasons.push(`not on ${registry} — a name that sounds right but does not exist is how hallucinated packages get squatted; do not add it`);
|
|
176
|
+
return true;
|
|
174
177
|
}
|
|
175
178
|
if (!res.ok || !isRecord(res.body)) {
|
|
176
179
|
out.incomplete = true;
|
|
177
|
-
out.reasons.push(`could not read
|
|
178
|
-
return
|
|
180
|
+
out.reasons.push(`could not read ${registry} (${res.error ?? res.status})`);
|
|
181
|
+
return true;
|
|
179
182
|
}
|
|
180
|
-
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
/** Fetch a registry document: a fresh `Collected` plus the JSON object, absent when it cannot be used. */
|
|
186
|
+
async function openRegistry(url, get, registry) {
|
|
187
|
+
const out = { reasons: [], facts: {}, fatal: false, incomplete: false };
|
|
188
|
+
const res = await get(url);
|
|
189
|
+
if (rejectRegistryAnswer(out, res, registry))
|
|
190
|
+
return { out };
|
|
191
|
+
return { out, doc: res.body };
|
|
192
|
+
}
|
|
193
|
+
async function lookupNpm(name, requested, get) {
|
|
194
|
+
const { out, doc } = await openRegistry(`https://registry.npmjs.org/${name.replace('/', '%2f')}`, get, 'the npm registry');
|
|
195
|
+
if (!doc)
|
|
196
|
+
return out;
|
|
181
197
|
const tags = isRecord(doc['dist-tags']) ? doc['dist-tags'] : {};
|
|
182
198
|
const versions = isRecord(doc.versions) ? doc.versions : {};
|
|
183
199
|
const time = isRecord(doc.time) ? doc.time : {};
|
|
@@ -228,21 +244,11 @@ async function lookupNpm(name, requested, get) {
|
|
|
228
244
|
return out;
|
|
229
245
|
}
|
|
230
246
|
async function lookupPypi(name, requested, get) {
|
|
231
|
-
const out
|
|
232
|
-
|
|
233
|
-
if (res.status === 404) {
|
|
234
|
-
out.fatal = true;
|
|
235
|
-
out.facts.exists = false;
|
|
236
|
-
out.reasons.push('not on PyPI — a name that sounds right but does not exist is how hallucinated packages get squatted; do not add it');
|
|
247
|
+
const { out, doc } = await openRegistry(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, get, 'PyPI');
|
|
248
|
+
if (!doc)
|
|
237
249
|
return out;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
out.incomplete = true;
|
|
241
|
-
out.reasons.push(`could not read PyPI (${res.error ?? res.status})`);
|
|
242
|
-
return out;
|
|
243
|
-
}
|
|
244
|
-
const info = isRecord(res.body.info) ? res.body.info : {};
|
|
245
|
-
const releases = isRecord(res.body.releases) ? res.body.releases : {};
|
|
250
|
+
const info = isRecord(doc.info) ? doc.info : {};
|
|
251
|
+
const releases = isRecord(doc.releases) ? doc.releases : {};
|
|
246
252
|
out.facts.exists = true;
|
|
247
253
|
if (requested && !(requested in releases)) {
|
|
248
254
|
out.fatal = true;
|
|
@@ -13,15 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
/** Revisions we can speak, newest first. A client asking for another gets the newest. */
|
|
15
15
|
export declare const SUPPORTED_PROTOCOLS: readonly string[];
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
jsonrpc: '2.0';
|
|
19
|
-
/** Absent on notifications, which never get an answer. */
|
|
20
|
-
id?: JsonRpcId;
|
|
21
|
-
method: string;
|
|
22
|
-
params?: Record<string, unknown>;
|
|
23
|
-
}
|
|
24
|
-
export interface JsonRpcResponse {
|
|
16
|
+
type JsonRpcId = string | number | null;
|
|
17
|
+
interface JsonRpcResponse {
|
|
25
18
|
jsonrpc: '2.0';
|
|
26
19
|
id: JsonRpcId;
|
|
27
20
|
result?: unknown;
|
|
@@ -95,3 +88,4 @@ export declare function negotiateProtocol(requested: unknown): string;
|
|
|
95
88
|
export declare function handleMessage(server: ServerDefinition, message: unknown): Promise<JsonRpcResponse | null>;
|
|
96
89
|
/** Decode one stdio line. A line that is not JSON gets a parse error, never a crash. */
|
|
97
90
|
export declare function handleLine(server: ServerDefinition, line: string): Promise<JsonRpcResponse | null>;
|
|
91
|
+
export {};
|
package/dist/esm/mcp/review.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export declare function snapshotWorktree(cwd: string): Promise<{
|
|
|
35
35
|
baseSha: string;
|
|
36
36
|
headSha: string;
|
|
37
37
|
} | undefined>;
|
|
38
|
-
|
|
38
|
+
interface ReviewOptions {
|
|
39
39
|
/** Directory inside the repository to review. */
|
|
40
40
|
cwd: string;
|
|
41
41
|
scope?: ReviewScope;
|
|
@@ -49,7 +49,7 @@ export interface ReviewOptions {
|
|
|
49
49
|
*/
|
|
50
50
|
write?: boolean;
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
interface ReviewOutcome {
|
|
53
53
|
report: CheckReport;
|
|
54
54
|
scope: ReviewScope;
|
|
55
55
|
/** Files the diff touches; absent for `full`, which has no diff. */
|
|
@@ -61,3 +61,4 @@ export interface ReviewOutcome {
|
|
|
61
61
|
export declare function review(opts: ReviewOptions): Promise<ReviewOutcome>;
|
|
62
62
|
/** Files in the working-tree diff, with a content hash, so a later call can tell what a fixer rewrote. */
|
|
63
63
|
export declare function fingerprintChanges(cwd: string): Promise<Map<string, string>>;
|
|
64
|
+
export {};
|
package/dist/esm/mcp/review.js
CHANGED
|
@@ -133,7 +133,7 @@ export async function fingerprintChanges(cwd) {
|
|
|
133
133
|
});
|
|
134
134
|
for (const name of stdout.split('\n').filter(Boolean)) {
|
|
135
135
|
const abs = path.join(cwd, name);
|
|
136
|
-
files.set(name, fs.existsSync(abs) ? createHash('
|
|
136
|
+
files.set(name, fs.existsSync(abs) ? createHash('sha256').update(fs.readFileSync(abs)).digest('hex') : '');
|
|
137
137
|
}
|
|
138
138
|
return files;
|
|
139
139
|
}
|
package/dist/esm/mcp/tools.js
CHANGED
|
@@ -281,6 +281,41 @@ const getRules = {
|
|
|
281
281
|
return serialized(async () => ({ text: await describeRules(cwd) }));
|
|
282
282
|
},
|
|
283
283
|
};
|
|
284
|
+
const SAME_SCOPE_PROPERTY = {
|
|
285
|
+
type: 'string',
|
|
286
|
+
enum: [...REVIEW_SCOPES],
|
|
287
|
+
description: 'Same meaning as in check_changes. Defaults to "worktree".',
|
|
288
|
+
};
|
|
289
|
+
/** The scope a call asks for, or the error to hand back when it is not one we know. */
|
|
290
|
+
function readScope(args) {
|
|
291
|
+
const scope = args.scope ?? 'worktree';
|
|
292
|
+
if (REVIEW_SCOPES.includes(scope))
|
|
293
|
+
return { scope: scope };
|
|
294
|
+
return {
|
|
295
|
+
error: {
|
|
296
|
+
text: `Unknown scope "${String(scope)}". Use one of: ${REVIEW_SCOPES.join(', ')}.`,
|
|
297
|
+
isError: true,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const SCOPE_ONLY_SCHEMA = {
|
|
302
|
+
type: 'object',
|
|
303
|
+
properties: { scope: SAME_SCOPE_PROPERTY, ...CWD_PROPERTY },
|
|
304
|
+
additionalProperties: false,
|
|
305
|
+
};
|
|
306
|
+
/** Review the repository a call points at and format the outcome for the agent. */
|
|
307
|
+
async function reviewFor(args, scope, extras) {
|
|
308
|
+
const cwd = await repoFor(args);
|
|
309
|
+
const outcome = await serialized(() => inDirectory(cwd, () => review({ cwd, scope, ...extras })));
|
|
310
|
+
return formatReport(outcome.report, outcome);
|
|
311
|
+
}
|
|
312
|
+
/** A review whose only argument is the scope: `scan_secrets` and `verify`. */
|
|
313
|
+
async function reviewWithScope(args, extras) {
|
|
314
|
+
const asked = readScope(args);
|
|
315
|
+
if ('error' in asked)
|
|
316
|
+
return asked.error;
|
|
317
|
+
return reviewFor(args, asked.scope, extras);
|
|
318
|
+
}
|
|
284
319
|
const checkChanges = {
|
|
285
320
|
name: 'check_changes',
|
|
286
321
|
description: "Run the repository's pull-request gate on the code you have changed — uncommitted files included, nothing needs to be staged — and return the findings. " +
|
|
@@ -308,13 +343,9 @@ const checkChanges = {
|
|
|
308
343
|
additionalProperties: false,
|
|
309
344
|
},
|
|
310
345
|
async run(args) {
|
|
311
|
-
const
|
|
312
|
-
if (
|
|
313
|
-
return
|
|
314
|
-
text: `Unknown scope "${String(scope)}". Use one of: ${REVIEW_SCOPES.join(', ')}.`,
|
|
315
|
-
isError: true,
|
|
316
|
-
};
|
|
317
|
-
}
|
|
346
|
+
const asked = readScope(args);
|
|
347
|
+
if ('error' in asked)
|
|
348
|
+
return asked.error;
|
|
318
349
|
const requested = Array.isArray(args.checks) ? args.checks.map(String) : [];
|
|
319
350
|
const known = new Set(REGISTRY.map(c => c.name));
|
|
320
351
|
const only = requested.map(name => COMMAND_TO_CHECK[name] ?? name);
|
|
@@ -327,68 +358,22 @@ const checkChanges = {
|
|
|
327
358
|
isError: true,
|
|
328
359
|
};
|
|
329
360
|
}
|
|
330
|
-
|
|
331
|
-
const outcome = await serialized(() => inDirectory(cwd, () => review({ cwd, scope: scope, only, deep: args.deep === true })));
|
|
332
|
-
return formatReport(outcome.report, outcome);
|
|
361
|
+
return reviewFor(args, asked.scope, { only, deep: args.deep === true });
|
|
333
362
|
},
|
|
334
363
|
};
|
|
335
364
|
const scanSecrets = {
|
|
336
365
|
name: 'scan_secrets',
|
|
337
366
|
description: 'Scan the code you have changed for committed credentials, tokens, private keys and risky additions. Run it before committing configuration, environment or credential files, and whenever a value looks like a secret.',
|
|
338
|
-
inputSchema:
|
|
339
|
-
|
|
340
|
-
properties: {
|
|
341
|
-
scope: {
|
|
342
|
-
type: 'string',
|
|
343
|
-
enum: [...REVIEW_SCOPES],
|
|
344
|
-
description: 'Same meaning as in check_changes. Defaults to "worktree".',
|
|
345
|
-
},
|
|
346
|
-
...CWD_PROPERTY,
|
|
347
|
-
},
|
|
348
|
-
additionalProperties: false,
|
|
349
|
-
},
|
|
350
|
-
async run(args) {
|
|
351
|
-
const scope = args.scope ?? 'worktree';
|
|
352
|
-
if (!REVIEW_SCOPES.includes(scope)) {
|
|
353
|
-
return {
|
|
354
|
-
text: `Unknown scope "${String(scope)}". Use one of: ${REVIEW_SCOPES.join(', ')}.`,
|
|
355
|
-
isError: true,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
const cwd = await repoFor(args);
|
|
359
|
-
const outcome = await serialized(() => inDirectory(cwd, () => review({ cwd, scope: scope, only: [...SECRET_CHECKS] })));
|
|
360
|
-
return formatReport(outcome.report, outcome);
|
|
361
|
-
},
|
|
367
|
+
inputSchema: SCOPE_ONLY_SCHEMA,
|
|
368
|
+
run: args => reviewWithScope(args, { only: [...SECRET_CHECKS] }),
|
|
362
369
|
};
|
|
363
370
|
const verify = {
|
|
364
371
|
name: 'verify',
|
|
365
372
|
description: 'The final gate. Runs every check the pull-request gate runs, the slow ones included (typecheck, dependency audit, spellcheck, dead code), on the code you have changed. ' +
|
|
366
373
|
'Call it once when you believe the work is done and before you tell the user it is finished. ' +
|
|
367
374
|
'check_changes is the quick loop while you work; a clean verify is the closest you can get to knowing CI will agree.',
|
|
368
|
-
inputSchema:
|
|
369
|
-
|
|
370
|
-
properties: {
|
|
371
|
-
scope: {
|
|
372
|
-
type: 'string',
|
|
373
|
-
enum: [...REVIEW_SCOPES],
|
|
374
|
-
description: 'Same meaning as in check_changes. Defaults to "worktree".',
|
|
375
|
-
},
|
|
376
|
-
...CWD_PROPERTY,
|
|
377
|
-
},
|
|
378
|
-
additionalProperties: false,
|
|
379
|
-
},
|
|
380
|
-
async run(args) {
|
|
381
|
-
const scope = args.scope ?? 'worktree';
|
|
382
|
-
if (!REVIEW_SCOPES.includes(scope)) {
|
|
383
|
-
return {
|
|
384
|
-
text: `Unknown scope "${String(scope)}". Use one of: ${REVIEW_SCOPES.join(', ')}.`,
|
|
385
|
-
isError: true,
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
const cwd = await repoFor(args);
|
|
389
|
-
const outcome = await serialized(() => inDirectory(cwd, () => review({ cwd, scope: scope, deep: true })));
|
|
390
|
-
return formatReport(outcome.report, outcome);
|
|
391
|
-
},
|
|
375
|
+
inputSchema: SCOPE_ONLY_SCHEMA,
|
|
376
|
+
run: args => reviewWithScope(args, { deep: true }),
|
|
392
377
|
};
|
|
393
378
|
/** The only checks that can rewrite code safely: formatting and lint autofixes. */
|
|
394
379
|
const FIXERS = ['Prettier', 'ESLint'];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-checkmate",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "Security-first PR gate for CI: secret scanning, grype dependency scanning, SBOM — plus linting and formatting across 11 languages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github-actions",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
},
|
|
81
81
|
"homepage": "https://github.com/Mybono/pr_checkmate_public#readme",
|
|
82
82
|
"author": "Artur Polishchuk",
|
|
83
|
-
"license": "
|
|
83
|
+
"license": "LicenseRef-PR-Checkmate-Proprietary",
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"execa": "^9.6.0",
|
|
86
86
|
"gitleaks-secret-scanner-fixed": "3.0.4",
|