pr-checkmate 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) 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/checks/quality/yaml-lint.d.ts +15 -0
  12. package/dist/cjs/core/checks/quality/yaml-lint.js +33 -1
  13. package/dist/cjs/core/sarif.d.ts +3 -1
  14. package/dist/cjs/mcp/dependency.d.ts +2 -1
  15. package/dist/cjs/mcp/dependency.js +28 -22
  16. package/dist/cjs/mcp/protocol.d.ts +3 -9
  17. package/dist/cjs/mcp/review.d.ts +3 -2
  18. package/dist/cjs/mcp/review.js +1 -1
  19. package/dist/cjs/mcp/tools.js +43 -58
  20. package/dist/esm/agent/hook.d.ts +2 -1
  21. package/dist/esm/agent/setup.d.ts +4 -3
  22. package/dist/esm/core/checks/git/diff-smell.js +3 -1
  23. package/dist/esm/core/checks/languages/lint.js +28 -1
  24. package/dist/esm/core/checks/languages/locate.d.ts +7 -0
  25. package/dist/esm/core/checks/languages/locate.js +60 -0
  26. package/dist/esm/core/checks/languages/prettier.js +15 -2
  27. package/dist/esm/core/checks/languages/typecheck.js +4 -1
  28. package/dist/esm/core/checks/quality/yaml-lint.d.ts +15 -0
  29. package/dist/esm/core/checks/quality/yaml-lint.js +33 -2
  30. package/dist/esm/core/sarif.d.ts +3 -1
  31. package/dist/esm/mcp/dependency.d.ts +2 -1
  32. package/dist/esm/mcp/dependency.js +28 -22
  33. package/dist/esm/mcp/protocol.d.ts +3 -9
  34. package/dist/esm/mcp/review.d.ts +3 -2
  35. package/dist/esm/mcp/review.js +1 -1
  36. package/dist/esm/mcp/tools.js +43 -58
  37. package/package.json +2 -2
  38. package/src/config/cspell.json +1 -0
@@ -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
  };
@@ -1,3 +1,4 @@
1
+ import { Document, LineCounter } from 'yaml';
1
2
  import { Check } from '../../types.js';
2
3
  /**
3
4
  * True when a parser problem describes something actually wrong with the file —
@@ -6,6 +7,20 @@ import { Check } from '../../types.js';
6
7
  export declare function isFileDefect(problem: {
7
8
  code?: string;
8
9
  }): boolean;
10
+ /**
11
+ * Aliases (`*name`) that point at an anchor which does not exist before them.
12
+ *
13
+ * The parser does not treat this as a parse error: it builds the alias node and only
14
+ * throws when the document is converted to a JS value, which is exactly what a CI
15
+ * runner does next. So `doc.errors` stays empty for a pipeline that reuses a step via
16
+ * `*update-knowledge` after its `&update-knowledge` anchor was deleted, and the check
17
+ * used to pass a file Bitbucket rejects. Found by walking the alias nodes.
18
+ */
19
+ export declare function unresolvedAliases(doc: Document.Parsed, lineCounter: LineCounter): {
20
+ name: string;
21
+ line: number;
22
+ col: number;
23
+ }[];
9
24
  /**
10
25
  * Bundled YAML linter (the `yaml` package — no runner dependency, always
11
26
  * available like Markdown/Spellcheck). Parses every changed `.yml`/`.yaml` file
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { parseAllDocuments } from 'yaml';
3
+ import { LineCounter, parseAllDocuments, visit } from 'yaml';
4
4
  import { logger, matchesAnyGlob } from '../../../utils/index.js';
5
5
  import { resolveTargetFiles } from '../../targets.js';
6
6
  import { pass, skip } from '../../types.js';
@@ -31,6 +31,27 @@ const NON_DEFECT_CODES = new Set(['TAG_RESOLVE_FAILED']);
31
31
  export function isFileDefect(problem) {
32
32
  return problem.code === undefined || !NON_DEFECT_CODES.has(problem.code);
33
33
  }
34
+ /**
35
+ * Aliases (`*name`) that point at an anchor which does not exist before them.
36
+ *
37
+ * The parser does not treat this as a parse error: it builds the alias node and only
38
+ * throws when the document is converted to a JS value, which is exactly what a CI
39
+ * runner does next. So `doc.errors` stays empty for a pipeline that reuses a step via
40
+ * `*update-knowledge` after its `&update-knowledge` anchor was deleted, and the check
41
+ * used to pass a file Bitbucket rejects. Found by walking the alias nodes.
42
+ */
43
+ export function unresolvedAliases(doc, lineCounter) {
44
+ const found = [];
45
+ visit(doc, {
46
+ Alias(_key, node) {
47
+ if (node.resolve(doc) !== undefined)
48
+ return;
49
+ const { line, col } = lineCounter.linePos(node.range?.[0] ?? 0);
50
+ found.push({ name: node.source, line, col });
51
+ },
52
+ });
53
+ return found;
54
+ }
34
55
  /**
35
56
  * Bundled YAML linter (the `yaml` package — no runner dependency, always
36
57
  * available like Markdown/Spellcheck). Parses every changed `.yml`/`.yaml` file
@@ -74,8 +95,18 @@ export const yamlLintCheck = {
74
95
  }
75
96
  // parseAllDocuments never throws — it collects problems on each document,
76
97
  // so a single malformed file cannot abort the whole check.
77
- const docs = parseAllDocuments(source, { prettyErrors: true });
98
+ const lineCounter = new LineCounter();
99
+ const docs = parseAllDocuments(source, { prettyErrors: true, lineCounter });
78
100
  for (const doc of docs) {
101
+ for (const alias of unresolvedAliases(doc, lineCounter)) {
102
+ issues.push({
103
+ file,
104
+ blocking: true,
105
+ line: alias.line,
106
+ col: alias.col,
107
+ message: `Unresolved alias "*${alias.name}" (the anchor must be declared before the alias)`,
108
+ });
109
+ }
79
110
  const problems = [
80
111
  ...doc.errors.map(p => ({ problem: p, blocking: true })),
81
112
  ...doc.warnings.map(p => ({ problem: p, blocking: false })),
@@ -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.1.0",
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",