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.
Files changed (34) hide show
  1. package/README.md +6 -0
  2. package/cspell.json +1 -0
  3. package/dist/cjs/agent/hook.d.ts +2 -1
  4. package/dist/cjs/agent/setup.d.ts +4 -3
  5. package/dist/cjs/core/checks/git/diff-smell.js +3 -1
  6. package/dist/cjs/core/checks/languages/lint.js +27 -0
  7. package/dist/cjs/core/checks/languages/locate.d.ts +7 -0
  8. package/dist/cjs/core/checks/languages/locate.js +68 -0
  9. package/dist/cjs/core/checks/languages/prettier.js +15 -2
  10. package/dist/cjs/core/checks/languages/typecheck.js +4 -1
  11. package/dist/cjs/core/sarif.d.ts +3 -1
  12. package/dist/cjs/mcp/dependency.d.ts +2 -1
  13. package/dist/cjs/mcp/dependency.js +28 -22
  14. package/dist/cjs/mcp/protocol.d.ts +3 -9
  15. package/dist/cjs/mcp/review.d.ts +3 -2
  16. package/dist/cjs/mcp/review.js +1 -1
  17. package/dist/cjs/mcp/tools.js +43 -58
  18. package/dist/esm/agent/hook.d.ts +2 -1
  19. package/dist/esm/agent/setup.d.ts +4 -3
  20. package/dist/esm/core/checks/git/diff-smell.js +3 -1
  21. package/dist/esm/core/checks/languages/lint.js +28 -1
  22. package/dist/esm/core/checks/languages/locate.d.ts +7 -0
  23. package/dist/esm/core/checks/languages/locate.js +60 -0
  24. package/dist/esm/core/checks/languages/prettier.js +15 -2
  25. package/dist/esm/core/checks/languages/typecheck.js +4 -1
  26. package/dist/esm/core/sarif.d.ts +3 -1
  27. package/dist/esm/mcp/dependency.d.ts +2 -1
  28. package/dist/esm/mcp/dependency.js +28 -22
  29. package/dist/esm/mcp/protocol.d.ts +3 -9
  30. package/dist/esm/mcp/review.d.ts +3 -2
  31. package/dist/esm/mcp/review.js +1 -1
  32. package/dist/esm/mcp/tools.js +43 -58
  33. package/package.json +2 -2
  34. 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
@@ -1377,6 +1377,7 @@
1377
1377
  "samd",
1378
1378
  "samplerate",
1379
1379
  "sandboxing",
1380
+ "sarif",
1380
1381
  "satoshis",
1381
1382
  "savgol",
1382
1383
  "sbom",
@@ -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
- export interface HookVerdict {
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
- export type SetupStatus = 'created' | 'updated' | 'unchanged' | 'skipped';
6
- export interface SetupAction {
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
- export interface SetupOptions {
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
- pattern: /^\+(?!\+).*\bdebugger\b/,
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
- const mode = ctx.write ? '--write' : '--check';
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
  };
@@ -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. Passes and skips are not results.
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
- export type Verdict = 'avoid' | 'review' | 'ok' | 'unknown';
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
- async function lookupNpm(name, requested, get) {
172
- const out = { reasons: [], facts: {}, fatal: false, incomplete: false };
173
- const res = await get(`https://registry.npmjs.org/${name.replace('/', '%2f')}`);
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('not on the npm registry — a name that sounds right but does not exist is how hallucinated packages get squatted; do not add it');
178
- return out;
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 the npm registry (${res.error ?? res.status})`);
183
- return out;
185
+ out.reasons.push(`could not read ${registry} (${res.error ?? res.status})`);
186
+ return true;
184
187
  }
185
- const doc = res.body;
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 = { reasons: [], facts: {}, fatal: false, incomplete: false };
237
- const res = await get(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`);
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
- if (!res.ok || !isRecord(res.body)) {
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
- export type JsonRpcId = string | number | null;
17
- export interface JsonRpcRequest {
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 {};
@@ -35,7 +35,7 @@ export declare function snapshotWorktree(cwd: string): Promise<{
35
35
  baseSha: string;
36
36
  headSha: string;
37
37
  } | undefined>;
38
- export interface ReviewOptions {
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
- export interface ReviewOutcome {
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 {};
@@ -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)('sha1').update(node_fs_1.default.readFileSync(abs)).digest('hex') : '');
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
  }
@@ -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 scope = args.scope ?? 'worktree';
319
- if (!review_js_1.REVIEW_SCOPES.includes(scope)) {
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
- const cwd = await repoFor(args);
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
- type: 'object',
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
- type: 'object',
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'];
@@ -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
- export interface HookVerdict {
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
- export type SetupStatus = 'created' | 'updated' | 'unchanged' | 'skipped';
6
- export interface SetupAction {
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
- export interface SetupOptions {
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
- pattern: /^\+(?!\+).*\bdebugger\b/,
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
- const mode = ctx.write ? '--write' : '--check';
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
  };
@@ -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. Passes and skips are not results.
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
- export type Verdict = 'avoid' | 'review' | 'ok' | 'unknown';
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
- async function lookupNpm(name, requested, get) {
167
- const out = { reasons: [], facts: {}, fatal: false, incomplete: false };
168
- const res = await get(`https://registry.npmjs.org/${name.replace('/', '%2f')}`);
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('not on the npm registry — a name that sounds right but does not exist is how hallucinated packages get squatted; do not add it');
173
- return out;
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 the npm registry (${res.error ?? res.status})`);
178
- return out;
180
+ out.reasons.push(`could not read ${registry} (${res.error ?? res.status})`);
181
+ return true;
179
182
  }
180
- const doc = res.body;
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 = { reasons: [], facts: {}, fatal: false, incomplete: false };
232
- const res = await get(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`);
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
- if (!res.ok || !isRecord(res.body)) {
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
- export type JsonRpcId = string | number | null;
17
- export interface JsonRpcRequest {
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 {};
@@ -35,7 +35,7 @@ export declare function snapshotWorktree(cwd: string): Promise<{
35
35
  baseSha: string;
36
36
  headSha: string;
37
37
  } | undefined>;
38
- export interface ReviewOptions {
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
- export interface ReviewOutcome {
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 {};
@@ -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('sha1').update(fs.readFileSync(abs)).digest('hex') : '');
136
+ files.set(name, fs.existsSync(abs) ? createHash('sha256').update(fs.readFileSync(abs)).digest('hex') : '');
137
137
  }
138
138
  return files;
139
139
  }
@@ -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 scope = args.scope ?? 'worktree';
312
- if (!REVIEW_SCOPES.includes(scope)) {
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
- const cwd = await repoFor(args);
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
- type: 'object',
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
- type: 'object',
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.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": "SEE LICENSE IN 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",
@@ -1377,6 +1377,7 @@
1377
1377
  "samd",
1378
1378
  "samplerate",
1379
1379
  "sandboxing",
1380
+ "sarif",
1380
1381
  "satoshis",
1381
1382
  "savgol",
1382
1383
  "sbom",