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
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
  };
@@ -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
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.yamlLintCheck = void 0;
7
7
  exports.isFileDefect = isFileDefect;
8
+ exports.unresolvedAliases = unresolvedAliases;
8
9
  const node_fs_1 = __importDefault(require("node:fs"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
10
11
  const yaml_1 = require("yaml");
@@ -38,6 +39,27 @@ const NON_DEFECT_CODES = new Set(['TAG_RESOLVE_FAILED']);
38
39
  function isFileDefect(problem) {
39
40
  return problem.code === undefined || !NON_DEFECT_CODES.has(problem.code);
40
41
  }
42
+ /**
43
+ * Aliases (`*name`) that point at an anchor which does not exist before them.
44
+ *
45
+ * The parser does not treat this as a parse error: it builds the alias node and only
46
+ * throws when the document is converted to a JS value, which is exactly what a CI
47
+ * runner does next. So `doc.errors` stays empty for a pipeline that reuses a step via
48
+ * `*update-knowledge` after its `&update-knowledge` anchor was deleted, and the check
49
+ * used to pass a file Bitbucket rejects. Found by walking the alias nodes.
50
+ */
51
+ function unresolvedAliases(doc, lineCounter) {
52
+ const found = [];
53
+ (0, yaml_1.visit)(doc, {
54
+ Alias(_key, node) {
55
+ if (node.resolve(doc) !== undefined)
56
+ return;
57
+ const { line, col } = lineCounter.linePos(node.range?.[0] ?? 0);
58
+ found.push({ name: node.source, line, col });
59
+ },
60
+ });
61
+ return found;
62
+ }
41
63
  /**
42
64
  * Bundled YAML linter (the `yaml` package — no runner dependency, always
43
65
  * available like Markdown/Spellcheck). Parses every changed `.yml`/`.yaml` file
@@ -81,8 +103,18 @@ exports.yamlLintCheck = {
81
103
  }
82
104
  // parseAllDocuments never throws — it collects problems on each document,
83
105
  // so a single malformed file cannot abort the whole check.
84
- const docs = (0, yaml_1.parseAllDocuments)(source, { prettyErrors: true });
106
+ const lineCounter = new yaml_1.LineCounter();
107
+ const docs = (0, yaml_1.parseAllDocuments)(source, { prettyErrors: true, lineCounter });
85
108
  for (const doc of docs) {
109
+ for (const alias of unresolvedAliases(doc, lineCounter)) {
110
+ issues.push({
111
+ file,
112
+ blocking: true,
113
+ line: alias.line,
114
+ col: alias.col,
115
+ message: `Unresolved alias "*${alias.name}" (the anchor must be declared before the alias)`,
116
+ });
117
+ }
86
118
  const problems = [
87
119
  ...doc.errors.map(p => ({ problem: p, blocking: true })),
88
120
  ...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 {};
@@ -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'];