@j0hanz/code-review-analyst-mcp 1.6.2 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -305,21 +305,20 @@ Summarize a pull request diff and assess high-level risk using the Flash model.
305
305
 
306
306
  ### `inspect_code_quality`
307
307
 
308
- Deep-dive code review with optional file context using the Pro model with thinking (16K token budget).
308
+ Deep-dive code review using the Pro model with thinking (16K token budget).
309
309
 
310
- | Parameter | Type | Required | Description |
311
- | ------------- | ---------- | -------- | --------------------------------------------------- |
312
- | `diff` | `string` | Yes | Unified diff text. |
313
- | `repository` | `string` | Yes | Repository identifier (e.g. `org/repo`). |
314
- | `language` | `string` | No | Primary language hint. |
315
- | `focusAreas` | `string[]` | No | Areas to inspect: security, correctness, etc. |
316
- | `maxFindings` | `number` | No | Maximum findings to return (1-25). |
317
- | `files` | `object[]` | No | Full file contents (`path`, `content`) for context. |
310
+ | Parameter | Type | Required | Description |
311
+ | ------------- | ---------- | -------- | --------------------------------------------- |
312
+ | `diff` | `string` | Yes | Unified diff text. |
313
+ | `repository` | `string` | Yes | Repository identifier (e.g. `org/repo`). |
314
+ | `language` | `string` | No | Primary language hint. |
315
+ | `focusAreas` | `string[]` | No | Areas to inspect: security, correctness, etc. |
316
+ | `maxFindings` | `number` | No | Maximum findings to return (1-25). |
318
317
 
319
318
  **Returns:** `summary`, `overallRisk` (low/medium/high/critical), `findings[]` (severity, file, line, title, explanation, recommendation), `testsNeeded[]`, `contextualInsights[]`.
320
319
 
321
320
  > [!NOTE]
322
- > Enforces `MAX_CONTEXT_CHARS` (default 500,000) on combined diff + files size. Expect 60-120s latency due to deep thinking.
321
+ > Diff size bounded by `MAX_DIFF_CHARS` (default 120,000). Expect 60-120s latency due to deep thinking.
323
322
 
324
323
  ### `suggest_search_replace`
325
324
 
@@ -378,7 +377,6 @@ Create a test plan covering the changes in the diff using the Flash model with t
378
377
  | `GEMINI_MODEL` | Override default model selection | — | No |
379
378
  | `GEMINI_HARM_BLOCK_THRESHOLD` | Safety threshold (BLOCK_NONE, BLOCK_ONLY_HIGH, etc.) | `BLOCK_NONE` | No |
380
379
  | `MAX_DIFF_CHARS` | Max chars for diff input | `120000` | No |
381
- | `MAX_CONTEXT_CHARS` | Max combined context for inspection | `500000` | No |
382
380
  | `MAX_CONCURRENT_CALLS` | Max concurrent Gemini requests | `10` | No |
383
381
  | `MAX_CONCURRENT_BATCH_CALLS` | Max concurrent inline batch requests | `2` | No |
384
382
  | `MAX_CONCURRENT_CALLS_WAIT_MS` | Max wait time for a free Gemini slot | `2000` | No |
@@ -407,7 +405,7 @@ Create a test plan covering the changes in the diff using the Flash model with t
407
405
 
408
406
  ### Deep Code Inspection
409
407
 
410
- 1. Call `inspect_code_quality` with the diff and critical files in `files[]`.
408
+ 1. Call `inspect_code_quality` with the cached diff.
411
409
  2. Use `focusAreas` to target specific concerns (security, performance).
412
410
  3. Review `findings` and `contextualInsights`.
413
411
 
@@ -457,7 +455,7 @@ The pipeline runs lint, type-check, test, and build, then publishes to three tar
457
455
  | Issue | Solution |
458
456
  | ------------------------------------------ | ------------------------------------------------------------------------------------ |
459
457
  | `Missing GEMINI_API_KEY or GOOGLE_API_KEY` | Set one of the API key env vars in your MCP client config. |
460
- | `E_INPUT_TOO_LARGE` | Diff or combined context exceeds budget. Split into smaller diffs or reduce files. |
458
+ | `E_INPUT_TOO_LARGE` | Diff exceeds budget. Split into smaller diffs. |
461
459
  | `Gemini request timed out` | Pro model tasks may take 60-120s. Increase your client timeout. |
462
460
  | `Too many concurrent Gemini calls` | Reduce parallel tool calls or increase `MAX_CONCURRENT_CALLS`. |
463
461
  | No tool output visible | Ensure your MCP client is not swallowing `stderr` — the server uses stdio transport. |
package/dist/index.js CHANGED
@@ -1,9 +1,44 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { parseCommandLineArgs } from './lib/cli.js';
3
+ import { parseArgs } from 'node:util';
4
4
  import { getErrorMessage } from './lib/errors.js';
5
5
  import { createServer } from './server.js';
6
6
  const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM'];
7
+ // --- CLI Parsing ---
8
+ const ARG_OPTION_MODEL = 'model';
9
+ const ARG_OPTION_MAX_DIFF_CHARS = 'max-diff-chars';
10
+ const PROCESS_ARGS_START_INDEX = 2;
11
+ const CLI_ENV_MAPPINGS = [
12
+ { option: ARG_OPTION_MODEL, envVar: 'GEMINI_MODEL' },
13
+ { option: ARG_OPTION_MAX_DIFF_CHARS, envVar: 'MAX_DIFF_CHARS' },
14
+ ];
15
+ const CLI_OPTIONS = {
16
+ [ARG_OPTION_MODEL]: {
17
+ type: 'string',
18
+ short: 'm',
19
+ },
20
+ [ARG_OPTION_MAX_DIFF_CHARS]: {
21
+ type: 'string',
22
+ },
23
+ };
24
+ function setStringEnv(name, value) {
25
+ if (typeof value === 'string') {
26
+ process.env[name] = value;
27
+ }
28
+ }
29
+ function applyCliEnvironmentOverrides(values) {
30
+ for (const mapping of CLI_ENV_MAPPINGS) {
31
+ setStringEnv(mapping.envVar, values[mapping.option]);
32
+ }
33
+ }
34
+ function parseCommandLineArgs() {
35
+ const { values } = parseArgs({
36
+ args: process.argv.slice(PROCESS_ARGS_START_INDEX),
37
+ options: CLI_OPTIONS,
38
+ strict: false,
39
+ });
40
+ applyCliEnvironmentOverrides(values);
41
+ }
7
42
  let shuttingDown = false;
8
43
  async function shutdown(server) {
9
44
  if (shuttingDown) {
@@ -1,5 +1,5 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import type { ParsedFile } from './diff-parser.js';
2
+ import type { ParsedFile } from './diff.js';
3
3
  import { createErrorToolResponse } from './tool-response.js';
4
4
  export declare const DIFF_RESOURCE_URI = "diff://current";
5
5
  export interface DiffStats {
@@ -0,0 +1,30 @@
1
+ import type { File as ParsedFile } from 'parse-diff';
2
+ import { createErrorToolResponse } from './tool-response.js';
3
+ export type { ParsedFile };
4
+ export declare function getMaxDiffChars(): number;
5
+ export declare function resetMaxDiffCharsCacheForTesting(): void;
6
+ export declare function exceedsDiffBudget(diff: string): boolean;
7
+ export declare function getDiffBudgetError(diffLength: number, maxChars?: number): string;
8
+ export declare function validateDiffBudget(diff: string): ReturnType<typeof createErrorToolResponse> | undefined;
9
+ export declare const NOISY_EXCLUDE_PATHSPECS: readonly [":(exclude)package-lock.json", ":(exclude)yarn.lock", ":(exclude)pnpm-lock.yaml", ":(exclude)bun.lockb", ":(exclude)*.lock", ":(exclude)dist/", ":(exclude)build/", ":(exclude)out/", ":(exclude).next/", ":(exclude)coverage/", ":(exclude)*.min.js", ":(exclude)*.min.css", ":(exclude)*.map"];
10
+ export declare function cleanDiff(raw: string): string;
11
+ export declare function isEmptyDiff(diff: string): boolean;
12
+ export interface DiffStats {
13
+ files: number;
14
+ added: number;
15
+ deleted: number;
16
+ }
17
+ export declare function parseDiffFiles(diff: string): ParsedFile[];
18
+ export declare function computeDiffStatsAndSummaryFromFiles(files: readonly ParsedFile[]): Readonly<{
19
+ stats: DiffStats;
20
+ summary: string;
21
+ }>;
22
+ export declare function computeDiffStatsAndPathsFromFiles(files: readonly ParsedFile[]): Readonly<{
23
+ stats: DiffStats;
24
+ paths: string[];
25
+ }>;
26
+ export declare function extractChangedPathsFromFiles(files: readonly ParsedFile[]): string[];
27
+ export declare function extractChangedPaths(diff: string): string[];
28
+ export declare function computeDiffStatsFromFiles(files: readonly ParsedFile[]): Readonly<DiffStats>;
29
+ export declare function computeDiffStats(diff: string): Readonly<DiffStats>;
30
+ export declare function formatFileSummary(files: ParsedFile[]): string;
@@ -0,0 +1,189 @@
1
+ import parseDiff from 'parse-diff';
2
+ import { createCachedEnvInt } from './env-config.js';
3
+ import { createErrorToolResponse } from './tool-response.js';
4
+ // --- Diff Budget ---
5
+ const DEFAULT_MAX_DIFF_CHARS = 120_000;
6
+ const MAX_DIFF_CHARS_ENV_VAR = 'MAX_DIFF_CHARS';
7
+ const numberFormatter = new Intl.NumberFormat('en-US');
8
+ const diffCharsConfig = createCachedEnvInt(MAX_DIFF_CHARS_ENV_VAR, DEFAULT_MAX_DIFF_CHARS);
9
+ export function getMaxDiffChars() {
10
+ return diffCharsConfig.get();
11
+ }
12
+ export function resetMaxDiffCharsCacheForTesting() {
13
+ diffCharsConfig.reset();
14
+ }
15
+ export function exceedsDiffBudget(diff) {
16
+ return diff.length > getMaxDiffChars();
17
+ }
18
+ export function getDiffBudgetError(diffLength, maxChars = getMaxDiffChars()) {
19
+ return `diff exceeds max allowed size (${numberFormatter.format(diffLength)} chars > ${numberFormatter.format(maxChars)} chars)`;
20
+ }
21
+ const BUDGET_ERROR_META = { retryable: false, kind: 'budget' };
22
+ export function validateDiffBudget(diff) {
23
+ const providedChars = diff.length;
24
+ const maxChars = getMaxDiffChars();
25
+ if (providedChars <= maxChars) {
26
+ return undefined;
27
+ }
28
+ return createErrorToolResponse('E_INPUT_TOO_LARGE', getDiffBudgetError(providedChars, maxChars), { providedChars, maxChars }, BUDGET_ERROR_META);
29
+ }
30
+ // --- Diff Cleaner ---
31
+ export const NOISY_EXCLUDE_PATHSPECS = [
32
+ ':(exclude)package-lock.json',
33
+ ':(exclude)yarn.lock',
34
+ ':(exclude)pnpm-lock.yaml',
35
+ ':(exclude)bun.lockb',
36
+ ':(exclude)*.lock',
37
+ ':(exclude)dist/',
38
+ ':(exclude)build/',
39
+ ':(exclude)out/',
40
+ ':(exclude).next/',
41
+ ':(exclude)coverage/',
42
+ ':(exclude)*.min.js',
43
+ ':(exclude)*.min.css',
44
+ ':(exclude)*.map',
45
+ ];
46
+ const BINARY_FILE_LINE = /^Binary files .+ differ$/m;
47
+ const GIT_BINARY_PATCH = /^GIT binary patch/m;
48
+ const HAS_HUNK = /^@@/m;
49
+ const HAS_OLD_MODE = /^old mode /m;
50
+ function shouldKeepSection(section) {
51
+ if (!section.trim())
52
+ return false;
53
+ if (BINARY_FILE_LINE.test(section))
54
+ return false;
55
+ if (GIT_BINARY_PATCH.test(section))
56
+ return false;
57
+ if (HAS_OLD_MODE.test(section) && !HAS_HUNK.test(section))
58
+ return false;
59
+ return true;
60
+ }
61
+ function processSection(raw, start, end, sections) {
62
+ if (end > start) {
63
+ const section = raw.slice(start, end);
64
+ if (shouldKeepSection(section)) {
65
+ sections.push(section);
66
+ }
67
+ }
68
+ }
69
+ export function cleanDiff(raw) {
70
+ if (!raw)
71
+ return '';
72
+ const sections = [];
73
+ let lastIndex = 0;
74
+ let nextIndex = raw.startsWith('diff --git ')
75
+ ? 0
76
+ : raw.indexOf('\ndiff --git ');
77
+ if (nextIndex === -1) {
78
+ processSection(raw, 0, raw.length, sections);
79
+ return sections.join('').trim();
80
+ }
81
+ while (nextIndex !== -1) {
82
+ const matchIndex = nextIndex === 0 ? 0 : nextIndex + 1; // +1 to skip \n
83
+ processSection(raw, lastIndex, matchIndex, sections);
84
+ lastIndex = matchIndex;
85
+ nextIndex = raw.indexOf('\ndiff --git ', lastIndex);
86
+ }
87
+ processSection(raw, lastIndex, raw.length, sections);
88
+ return sections.join('').trim();
89
+ }
90
+ export function isEmptyDiff(diff) {
91
+ return diff.trim().length === 0;
92
+ }
93
+ // --- Diff Parser ---
94
+ const UNKNOWN_PATH = 'unknown';
95
+ const NO_FILES_CHANGED = 'No files changed.';
96
+ const EMPTY_PATHS = [];
97
+ const EMPTY_STATS = Object.freeze({ files: 0, added: 0, deleted: 0 });
98
+ const PATH_SORTER = (left, right) => left.localeCompare(right);
99
+ export function parseDiffFiles(diff) {
100
+ return diff ? parseDiff(diff) : [];
101
+ }
102
+ function cleanPath(path) {
103
+ if (path.startsWith('a/') || path.startsWith('b/')) {
104
+ return path.slice(2);
105
+ }
106
+ return path;
107
+ }
108
+ function resolveChangedPath(file) {
109
+ if (file.to && file.to !== '/dev/null')
110
+ return cleanPath(file.to);
111
+ if (file.from && file.from !== '/dev/null')
112
+ return cleanPath(file.from);
113
+ return undefined;
114
+ }
115
+ function sortPaths(paths) {
116
+ return Array.from(paths).sort(PATH_SORTER);
117
+ }
118
+ function calculateStats(files) {
119
+ let added = 0;
120
+ let deleted = 0;
121
+ for (const file of files) {
122
+ added += file.additions;
123
+ deleted += file.deletions;
124
+ }
125
+ return { files: files.length, added, deleted };
126
+ }
127
+ function getUniquePaths(files) {
128
+ const paths = new Set();
129
+ for (const file of files) {
130
+ const path = resolveChangedPath(file);
131
+ if (path)
132
+ paths.add(path);
133
+ }
134
+ return paths;
135
+ }
136
+ export function computeDiffStatsAndSummaryFromFiles(files) {
137
+ if (files.length === 0) {
138
+ return { stats: EMPTY_STATS, summary: NO_FILES_CHANGED };
139
+ }
140
+ let added = 0;
141
+ let deleted = 0;
142
+ const summaries = [];
143
+ const MAX_SUMMARY_FILES = 40;
144
+ let i = 0;
145
+ for (const file of files) {
146
+ added += file.additions;
147
+ deleted += file.deletions;
148
+ if (i < MAX_SUMMARY_FILES) {
149
+ const path = resolveChangedPath(file) ?? UNKNOWN_PATH;
150
+ summaries.push(`${path} (+${file.additions} -${file.deletions})`);
151
+ }
152
+ i++;
153
+ }
154
+ if (files.length > MAX_SUMMARY_FILES) {
155
+ summaries.push(`... and ${files.length - MAX_SUMMARY_FILES} more files`);
156
+ }
157
+ const stats = { files: files.length, added, deleted };
158
+ return {
159
+ stats,
160
+ summary: `${summaries.join(', ')} [${stats.files} files, +${stats.added} -${stats.deleted}]`,
161
+ };
162
+ }
163
+ export function computeDiffStatsAndPathsFromFiles(files) {
164
+ if (files.length === 0) {
165
+ return { stats: EMPTY_STATS, paths: EMPTY_PATHS };
166
+ }
167
+ const stats = calculateStats(files);
168
+ const paths = sortPaths(getUniquePaths(files));
169
+ return { stats, paths };
170
+ }
171
+ export function extractChangedPathsFromFiles(files) {
172
+ if (files.length === 0)
173
+ return EMPTY_PATHS;
174
+ return sortPaths(getUniquePaths(files));
175
+ }
176
+ export function extractChangedPaths(diff) {
177
+ return extractChangedPathsFromFiles(parseDiffFiles(diff));
178
+ }
179
+ export function computeDiffStatsFromFiles(files) {
180
+ if (files.length === 0)
181
+ return EMPTY_STATS;
182
+ return calculateStats(files);
183
+ }
184
+ export function computeDiffStats(diff) {
185
+ return computeDiffStatsFromFiles(parseDiffFiles(diff));
186
+ }
187
+ export function formatFileSummary(files) {
188
+ return computeDiffStatsAndSummaryFromFiles(files).summary;
189
+ }
@@ -522,24 +522,28 @@ async function waitForSlot(limit, getActiveCount, acquireSlot, waiters, requestS
522
522
  return;
523
523
  settled = true;
524
524
  clearTimeout(deadlineTimer);
525
- if (requestSignal) {
526
- requestSignal.removeEventListener('abort', onAbort);
527
- }
525
+ detachAbortListener();
528
526
  acquireSlot();
529
527
  resolve();
530
528
  };
531
529
  waiters.push(waiter);
532
- const deadlineTimer = setTimeout(() => {
533
- if (settled)
534
- return;
535
- settled = true;
530
+ const removeWaiter = () => {
536
531
  const idx = waiters.indexOf(waiter);
537
532
  if (idx !== -1) {
538
533
  waiters.splice(idx, 1);
539
534
  }
535
+ };
536
+ const detachAbortListener = () => {
540
537
  if (requestSignal) {
541
538
  requestSignal.removeEventListener('abort', onAbort);
542
539
  }
540
+ };
541
+ const deadlineTimer = setTimeout(() => {
542
+ if (settled)
543
+ return;
544
+ settled = true;
545
+ removeWaiter();
546
+ detachAbortListener();
543
547
  reject(new Error(formatConcurrencyLimitErrorMessage(limit, waitLimitMs)));
544
548
  }, waitLimitMs);
545
549
  deadlineTimer.unref();
@@ -547,10 +551,7 @@ async function waitForSlot(limit, getActiveCount, acquireSlot, waiters, requestS
547
551
  if (settled)
548
552
  return;
549
553
  settled = true;
550
- const idx = waiters.indexOf(waiter);
551
- if (idx !== -1) {
552
- waiters.splice(idx, 1);
553
- }
554
+ removeWaiter();
554
555
  clearTimeout(deadlineTimer);
555
556
  reject(new Error('Gemini request was cancelled.'));
556
557
  };
@@ -103,7 +103,7 @@ export declare const TOOL_CONTRACTS: readonly [{
103
103
  readonly crossToolFlow: readonly ["Use before deep review to decide whether Pro analysis is needed."];
104
104
  }, {
105
105
  readonly name: "inspect_code_quality";
106
- readonly purpose: "Deep code review over the cached diff; files are optional supplementary excerpts only.";
106
+ readonly purpose: "Deep code review over the cached diff.";
107
107
  readonly model: "gemini-3-pro-preview";
108
108
  readonly timeoutMs: 120000;
109
109
  readonly thinkingLevel: "high";
@@ -134,17 +134,11 @@ export declare const TOOL_CONTRACTS: readonly [{
134
134
  readonly required: false;
135
135
  readonly constraints: "1-25";
136
136
  readonly description: "Post-generation cap applied to findings.";
137
- }, {
138
- readonly name: "files";
139
- readonly type: "object[]";
140
- readonly required: false;
141
- readonly constraints: "1-20 files, 100K chars/file";
142
- readonly description: "Optional short excerpts for supplementary context only; avoid full files — the diff is the primary source.";
143
137
  }];
144
138
  readonly outputShape: "{summary, overallRisk, findings[], testsNeeded[], contextualInsights[], totalFindings}";
145
- readonly gotchas: readonly ["Requires generate_diff to be called first.", "Combined diff + file context is bounded by MAX_CONTEXT_CHARS.", "maxFindings caps output after generation.", "files[] is token-expensive — omit unless the diff lacks critical structural context (e.g. class hierarchy, imports). Never pass full files."];
139
+ readonly gotchas: readonly ["Requires generate_diff to be called first.", "maxFindings caps output after generation."];
146
140
  readonly crossToolFlow: readonly ["findings[].title -> suggest_search_replace.findingTitle", "findings[].explanation -> suggest_search_replace.findingDetails"];
147
- readonly constraints: readonly ["Context budget (diff + files) < 500K chars."];
141
+ readonly constraints: readonly ["Diff budget bounded by MAX_DIFF_CHARS."];
148
142
  }, {
149
143
  readonly name: "suggest_search_replace";
150
144
  readonly purpose: "Generate verbatim search/replace fix blocks for one finding.";
@@ -118,7 +118,7 @@ export const TOOL_CONTRACTS = [
118
118
  },
119
119
  {
120
120
  name: 'inspect_code_quality',
121
- purpose: 'Deep code review over the cached diff; files are optional supplementary excerpts only.',
121
+ purpose: 'Deep code review over the cached diff.',
122
122
  model: PRO_MODEL,
123
123
  timeoutMs: DEFAULT_TIMEOUT_PRO_MS,
124
124
  thinkingLevel: PRO_THINKING_LEVEL,
@@ -154,26 +154,17 @@ export const TOOL_CONTRACTS = [
154
154
  constraints: '1-25',
155
155
  description: 'Post-generation cap applied to findings.',
156
156
  },
157
- {
158
- name: 'files',
159
- type: 'object[]',
160
- required: false,
161
- constraints: '1-20 files, 100K chars/file',
162
- description: 'Optional short excerpts for supplementary context only; avoid full files — the diff is the primary source.',
163
- },
164
157
  ],
165
158
  outputShape: '{summary, overallRisk, findings[], testsNeeded[], contextualInsights[], totalFindings}',
166
159
  gotchas: [
167
160
  'Requires generate_diff to be called first.',
168
- 'Combined diff + file context is bounded by MAX_CONTEXT_CHARS.',
169
161
  'maxFindings caps output after generation.',
170
- 'files[] is token-expensive — omit unless the diff lacks critical structural context (e.g. class hierarchy, imports). Never pass full files.',
171
162
  ],
172
163
  crossToolFlow: [
173
164
  'findings[].title -> suggest_search_replace.findingTitle',
174
165
  'findings[].explanation -> suggest_search_replace.findingDetails',
175
166
  ],
176
- constraints: ['Context budget (diff + files) < 500K chars.'],
167
+ constraints: ['Diff budget bounded by MAX_DIFF_CHARS.'],
177
168
  },
178
169
  {
179
170
  name: 'suggest_search_replace',
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { DefaultOutputSchema } from '../schemas/outputs.js';
3
- import { validateDiffBudget } from './diff-budget.js';
4
3
  import { createNoDiffError, getDiff } from './diff-store.js';
4
+ import { validateDiffBudget } from './diff.js';
5
5
  import { createCachedEnvInt } from './env-config.js';
6
6
  import { getErrorMessage, RETRYABLE_UPSTREAM_ERROR_PATTERN } from './errors.js';
7
7
  import { stripJsonSchemaConstraints } from './gemini-schema.js';
@@ -28,6 +28,23 @@ const DEFAULT_SCHEMA_RETRY_ERROR_CHARS = 1_500;
28
28
  const schemaRetryErrorCharsConfig = createCachedEnvInt('MAX_SCHEMA_RETRY_ERROR_CHARS', DEFAULT_SCHEMA_RETRY_ERROR_CHARS);
29
29
  const DETERMINISTIC_JSON_RETRY_NOTE = 'Deterministic JSON mode: keep key names exactly as schema-defined and preserve stable field ordering.';
30
30
  const JSON_PARSE_ERROR_PATTERN = /model produced invalid json/i;
31
+ function buildToolAnnotations(annotations) {
32
+ if (!annotations) {
33
+ return {
34
+ readOnlyHint: true,
35
+ idempotentHint: true,
36
+ openWorldHint: true,
37
+ };
38
+ }
39
+ const annotationOverrides = { ...annotations };
40
+ delete annotationOverrides.destructiveHint;
41
+ return {
42
+ readOnlyHint: !annotations.destructiveHint,
43
+ idempotentHint: !annotations.destructiveHint,
44
+ openWorldHint: true,
45
+ ...annotationOverrides,
46
+ };
47
+ }
31
48
  function createGeminiResponseSchema(config) {
32
49
  const sourceSchema = config.geminiSchema ?? config.resultSchema;
33
50
  return stripJsonSchemaConstraints(z.toJSONSchema(sourceSchema));
@@ -515,19 +532,7 @@ export function registerStructuredToolTask(server, config) {
515
532
  description: config.description,
516
533
  inputSchema: config.inputSchema,
517
534
  outputSchema: DefaultOutputSchema,
518
- annotations: {
519
- readOnlyHint: !config.annotations?.destructiveHint,
520
- idempotentHint: !config.annotations?.destructiveHint,
521
- openWorldHint: true,
522
- ...(() => {
523
- if (!config.annotations) {
524
- return {};
525
- }
526
- const annotationOverrides = { ...config.annotations };
527
- delete annotationOverrides.destructiveHint;
528
- return annotationOverrides;
529
- })(),
530
- },
535
+ annotations: buildToolAnnotations(config.annotations),
531
536
  }, {
532
537
  createTask: async (input, extra) => {
533
538
  const task = await extra.taskStore.createTask({
@@ -1,8 +1,5 @@
1
1
  export type JsonObject = Record<string, unknown>;
2
2
  export type GeminiLogHandler = (level: string, data: unknown) => Promise<void>;
3
- export interface GeminiFunctionCallingContext {
4
- readonly modelParts: readonly unknown[];
5
- }
6
3
  export interface GeminiRequestExecutionOptions {
7
4
  maxRetries?: number;
8
5
  timeoutMs?: number;
@@ -13,7 +10,6 @@ export interface GeminiRequestExecutionOptions {
13
10
  signal?: AbortSignal;
14
11
  onLog?: GeminiLogHandler;
15
12
  responseKeyOrdering?: readonly string[];
16
- functionCallingContext?: GeminiFunctionCallingContext;
17
13
  batchMode?: 'off' | 'inline';
18
14
  }
19
15
  export interface GeminiStructuredRequestOptions extends GeminiRequestExecutionOptions {
@@ -2,7 +2,6 @@ import { createCachedEnvInt } from '../lib/env-config.js';
2
2
  import { FLASH_MODEL } from '../lib/model-config.js';
3
3
  import { getToolContracts } from '../lib/tool-contracts.js';
4
4
  const DEFAULT_MAX_DIFF_CHARS = 120_000;
5
- const DEFAULT_MAX_CONTEXT_CHARS = 500_000;
6
5
  const DEFAULT_MAX_CONCURRENT_CALLS = 10;
7
6
  const DEFAULT_CONCURRENT_WAIT_MS = 2_000;
8
7
  const DEFAULT_SAFETY_THRESHOLD = 'BLOCK_NONE';
@@ -10,7 +9,6 @@ const GEMINI_HARM_BLOCK_THRESHOLD_ENV_VAR = 'GEMINI_HARM_BLOCK_THRESHOLD';
10
9
  const GEMINI_MODEL_ENV_VAR = 'GEMINI_MODEL';
11
10
  const GEMINI_BATCH_MODE_ENV_VAR = 'GEMINI_BATCH_MODE';
12
11
  const diffCharsConfig = createCachedEnvInt('MAX_DIFF_CHARS', DEFAULT_MAX_DIFF_CHARS);
13
- const contextCharsConfig = createCachedEnvInt('MAX_CONTEXT_CHARS', DEFAULT_MAX_CONTEXT_CHARS);
14
12
  const concurrentCallsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS', DEFAULT_MAX_CONCURRENT_CALLS);
15
13
  const concurrentBatchCallsConfig = createCachedEnvInt('MAX_CONCURRENT_BATCH_CALLS', 2);
16
14
  const concurrentWaitConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS_WAIT_MS', DEFAULT_CONCURRENT_WAIT_MS);
@@ -34,7 +32,6 @@ function formatThinkingLevel(level) {
34
32
  }
35
33
  export function buildServerConfig() {
36
34
  const maxDiffChars = diffCharsConfig.get();
37
- const maxContextChars = contextCharsConfig.get();
38
35
  const maxConcurrent = concurrentCallsConfig.get();
39
36
  const maxConcurrentBatch = concurrentBatchCallsConfig.get();
40
37
  const concurrentWaitMs = concurrentWaitConfig.get();
@@ -54,7 +51,6 @@ export function buildServerConfig() {
54
51
  | Limit | Value | Env |
55
52
  |-------|-------|-----|
56
53
  | Diff limit | ${formatNumber(maxDiffChars)} chars | \`MAX_DIFF_CHARS\` |
57
- | Context limit (inspect) | ${formatNumber(maxContextChars)} chars | \`MAX_CONTEXT_CHARS\` |
58
54
  | Concurrency limit | ${maxConcurrent} | \`MAX_CONCURRENT_CALLS\` |
59
55
  | Batch concurrency limit | ${maxConcurrentBatch} | \`MAX_CONCURRENT_BATCH_CALLS\` |
60
56
  | Wait timeout | ${formatNumber(concurrentWaitMs)}ms | \`MAX_CONCURRENT_CALLS_WAIT_MS\` |
@@ -6,7 +6,6 @@ const TOOL_CATALOG_CONTENT = `# Tool Catalog Details
6
6
  - \`language\`: Primary language hint (auto-detects). All tools except \`suggest_search_replace\`.
7
7
  - \`focusAreas\`: Focus tags (security, performance, etc.). \`inspect_code_quality\` only.
8
8
  - \`maxFindings\`: Output cap (1–25). \`inspect_code_quality\` only.
9
- - \`files\`: File context (max 20 files, 100K chars/file). \`inspect_code_quality\` only.
10
9
  - \`testFramework\`: Framework hint. \`generate_test_plan\` only.
11
10
  - \`maxTestCases\`: Output cap (1–30). \`generate_test_plan\` only.
12
11
 
@@ -1,8 +1,4 @@
1
1
  import { z } from 'zod';
2
- export declare const FileContextSchema: z.ZodObject<{
3
- path: z.ZodString;
4
- content: z.ZodString;
5
- }, z.core.$strict>;
6
2
  export declare const AnalyzePrImpactInputSchema: z.ZodObject<{
7
3
  repository: z.ZodString;
8
4
  language: z.ZodOptional<z.ZodString>;
@@ -16,10 +12,6 @@ export declare const InspectCodeQualityInputSchema: z.ZodObject<{
16
12
  language: z.ZodOptional<z.ZodString>;
17
13
  focusAreas: z.ZodOptional<z.ZodArray<z.ZodString>>;
18
14
  maxFindings: z.ZodOptional<z.ZodNumber>;
19
- files: z.ZodOptional<z.ZodArray<z.ZodObject<{
20
- path: z.ZodString;
21
- content: z.ZodString;
22
- }, z.core.$strict>>>;
23
15
  }, z.core.$strict>;
24
16
  export declare const SuggestSearchReplaceInputSchema: z.ZodObject<{
25
17
  findingTitle: z.ZodString;
@@ -2,11 +2,6 @@ import { z } from 'zod';
2
2
  const INPUT_LIMITS = {
3
3
  repository: { min: 1, max: 200 },
4
4
  language: { min: 2, max: 32 },
5
- fileContext: {
6
- path: { min: 1, max: 500 },
7
- content: { min: 0, max: 100_000 },
8
- maxItems: 20,
9
- },
10
5
  focusArea: { min: 2, max: 80, maxItems: 12 },
11
6
  maxFindings: { min: 1, max: 25 },
12
7
  findingTitle: { min: 3, max: 160 },
@@ -31,10 +26,6 @@ function createRepositorySchema() {
31
26
  function createOptionalBoundedInteger(min, max, description) {
32
27
  return z.number().int().min(min).max(max).optional().describe(description);
33
28
  }
34
- export const FileContextSchema = z.strictObject({
35
- path: createBoundedString(INPUT_LIMITS.fileContext.path.min, INPUT_LIMITS.fileContext.path.max, 'Repo-relative path (e.g. src/utils.ts).'),
36
- content: createBoundedString(INPUT_LIMITS.fileContext.content.min, INPUT_LIMITS.fileContext.content.max, 'Relevant excerpt (changed lines + context). Avoid full files.'),
37
- });
38
29
  export const AnalyzePrImpactInputSchema = z.strictObject({
39
30
  repository: createRepositorySchema(),
40
31
  language: createLanguageSchema(),
@@ -53,12 +44,6 @@ export const InspectCodeQualityInputSchema = z.strictObject({
53
44
  .optional()
54
45
  .describe('Review focus areas. Tags: security, correctness, performance, regressions, tests, maintainability, concurrency.'),
55
46
  maxFindings: createOptionalBoundedInteger(INPUT_LIMITS.maxFindings.min, INPUT_LIMITS.maxFindings.max, 'Max findings (1-25). Default: 10.'),
56
- files: z
57
- .array(FileContextSchema)
58
- .min(1)
59
- .max(INPUT_LIMITS.fileContext.maxItems)
60
- .optional()
61
- .describe('File content (changed files). Omit if large. Diff is primary source.'),
62
47
  });
63
48
  export const SuggestSearchReplaceInputSchema = z.strictObject({
64
49
  findingTitle: createBoundedString(INPUT_LIMITS.findingTitle.min, INPUT_LIMITS.findingTitle.max, 'Exact finding title from inspect_code_quality.'),
@@ -108,7 +108,7 @@ const CODE_QUALITY_SHARED_FIELDS = {
108
108
  .max(30)
109
109
  .describe('Findings (severity desc).'),
110
110
  testsNeeded: createBoundedStringArray(1, 300, 0, 12, 'Test cases needed to validate this change.'),
111
- contextualInsights: createBoundedStringArray(1, 500, 0, 5, 'Cross-file insights (omit if no file context).'),
111
+ contextualInsights: createBoundedStringArray(1, 500, 0, 5, 'Cross-file insights from diff analysis.'),
112
112
  };
113
113
  export const PrImpactResultSchema = z.strictObject({
114
114
  severity: z.enum(QUALITY_RISK_LEVELS).describe('Overall severity.'),
@@ -1,4 +1,4 @@
1
- import { computeDiffStatsAndSummaryFromFiles } from '../lib/diff-parser.js';
1
+ import { computeDiffStatsAndSummaryFromFiles } from '../lib/diff.js';
2
2
  import { buildStructuredToolRuntimeOptions, requireToolContract, } from '../lib/tool-contracts.js';
3
3
  import { registerStructuredToolTask } from '../lib/tool-factory.js';
4
4
  import { AnalyzePrImpactInputSchema } from '../schemas/inputs.js';
@@ -1,9 +1,8 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { z } from 'zod';
4
- import { cleanDiff, isEmptyDiff, NOISY_EXCLUDE_PATHSPECS, } from '../lib/diff-cleaner.js';
5
- import { computeDiffStatsFromFiles, parseDiffFiles, } from '../lib/diff-parser.js';
6
4
  import { DIFF_RESOURCE_URI, storeDiff } from '../lib/diff-store.js';
5
+ import { cleanDiff, computeDiffStatsFromFiles, isEmptyDiff, NOISY_EXCLUDE_PATHSPECS, parseDiffFiles, } from '../lib/diff.js';
7
6
  import { wrapToolHandler } from '../lib/tool-factory.js';
8
7
  import { createErrorToolResponse, createToolResponse, } from '../lib/tool-response.js';
9
8
  import { DefaultOutputSchema } from '../schemas/outputs.js';
@@ -1,4 +1,4 @@
1
- import { computeDiffStatsAndPathsFromFiles } from '../lib/diff-parser.js';
1
+ import { computeDiffStatsAndPathsFromFiles } from '../lib/diff.js';
2
2
  import { buildStructuredToolRuntimeOptions, requireToolContract, } from '../lib/tool-contracts.js';
3
3
  import { registerStructuredToolTask } from '../lib/tool-factory.js';
4
4
  import { GenerateTestPlanInputSchema } from '../schemas/inputs.js';
@@ -1,4 +1,2 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- export declare function sanitizePath(path: string): string;
3
- export declare function sanitizeContent(content: string): string;
4
2
  export declare function registerInspectCodeQualityTool(server: McpServer): void;
@@ -1,61 +1,28 @@
1
- import { validateContextBudget } from '../lib/context-budget.js';
2
- import { computeDiffStatsAndSummaryFromFiles } from '../lib/diff-parser.js';
1
+ import { computeDiffStatsAndSummaryFromFiles } from '../lib/diff.js';
3
2
  import { buildStructuredToolRuntimeOptions, requireToolContract, } from '../lib/tool-contracts.js';
4
3
  import { registerStructuredToolTask } from '../lib/tool-factory.js';
5
4
  import { InspectCodeQualityInputSchema } from '../schemas/inputs.js';
6
5
  import { CodeQualityOutputSchema, CodeQualityResultSchema, } from '../schemas/outputs.js';
7
6
  const DEFAULT_FOCUS_AREAS = 'General';
8
- const FILE_CONTEXT_HEADING = '\nFull File Context:\n';
9
- const PATH_ESCAPE_REPLACEMENTS = {
10
- '"': '\\"',
11
- '\n': ' ',
12
- '\r': ' ',
13
- '<': '&lt;',
14
- '>': '&gt;',
15
- };
16
- const PATH_ESCAPE_PATTERN = /["\n\r<>]/g;
17
7
  const SYSTEM_INSTRUCTION = `
18
8
  Principal Engineer Code Review.
19
- Source: Unified diff (primary), File excerpts (supplementary context).
9
+ Source: Unified diff.
20
10
  Goal: Identify bugs, security, performance, maintainability.
21
11
  Constraint: Ignore style/formatting. Prioritize correctness/failure modes.
22
12
  Return strict JSON.
23
13
  `;
24
14
  const TOOL_CONTRACT = requireToolContract('inspect_code_quality');
25
- export function sanitizePath(path) {
26
- return path.replace(PATH_ESCAPE_PATTERN, (match) => {
27
- return PATH_ESCAPE_REPLACEMENTS[match];
28
- });
29
- }
30
- export function sanitizeContent(content) {
31
- return content
32
- .replaceAll('<<END_FILE>>', '<END_FILE_ESCAPED>')
33
- .replaceAll('<<FILE', '<FILE');
34
- }
35
15
  function formatOptionalLine(label, value) {
36
16
  return value === undefined ? '' : `\n${label}: ${value}`;
37
17
  }
38
18
  function capFindings(findings, maxFindings) {
39
19
  return findings.slice(0, maxFindings ?? findings.length);
40
20
  }
41
- function formatFileContext(files) {
42
- if (!files || files.length === 0) {
43
- return '';
44
- }
45
- return (FILE_CONTEXT_HEADING +
46
- files
47
- .map((file) => `
48
- <<FILE path="${sanitizePath(file.path)}">>
49
- ${sanitizeContent(file.content)}
50
- <<END_FILE>>
51
- `)
52
- .join('\n'));
53
- }
54
21
  export function registerInspectCodeQualityTool(server) {
55
22
  registerStructuredToolTask(server, {
56
23
  name: 'inspect_code_quality',
57
24
  title: 'Inspect Code Quality',
58
- description: 'Deep code review. Prerequisite: generate_diff. Auto-infer repo/language/focus. Operates primarily on the diff; files are optional supplementary excerpts only.',
25
+ description: 'Deep code review. Prerequisite: generate_diff. Auto-infer repo/language/focus. Operates on the cached diff.',
59
26
  inputSchema: InspectCodeQualityInputSchema,
60
27
  fullInputSchema: InspectCodeQualityInputSchema,
61
28
  resultSchema: CodeQualityOutputSchema,
@@ -65,15 +32,7 @@ export function registerInspectCodeQualityTool(server) {
65
32
  timeoutMs: TOOL_CONTRACT.timeoutMs,
66
33
  maxOutputTokens: TOOL_CONTRACT.maxOutputTokens,
67
34
  ...buildStructuredToolRuntimeOptions(TOOL_CONTRACT),
68
- progressContext: (input) => {
69
- const fileCount = input.files?.length;
70
- return fileCount ? `+${fileCount} files` : '';
71
- },
72
35
  requiresDiff: true,
73
- validateInput: (input, ctx) => {
74
- // Diff presence and budget checked by requiresDiff: true
75
- return validateContextBudget(ctx.diffSlot?.diff ?? '', input.files);
76
- },
77
36
  formatOutcome: (result) => `${result.findings.length} findings, risk: ${result.overallRisk}`,
78
37
  formatOutput: (result) => {
79
38
  const count = result.findings.length;
@@ -90,25 +49,20 @@ export function registerInspectCodeQualityTool(server) {
90
49
  const diff = ctx.diffSlot?.diff ?? '';
91
50
  const parsedFiles = ctx.diffSlot?.parsedFiles ?? [];
92
51
  const { summary: fileSummary } = computeDiffStatsAndSummaryFromFiles(parsedFiles);
93
- const fileContext = formatFileContext(input.files);
94
52
  const languageLine = formatOptionalLine('Language', input.language);
95
53
  const maxFindingsLine = formatOptionalLine('Max Findings', input.maxFindings);
96
- const noFilesNote = !input.files?.length
97
- ? '\nNote: No file excerpts provided. Review based on diff only; leave contextualInsights empty.'
98
- : '';
99
54
  return {
100
55
  systemInstruction: SYSTEM_INSTRUCTION,
101
56
  prompt: `
102
57
  Repository: ${input.repository}${languageLine}
103
- Focus Areas: ${input.focusAreas?.join(', ') ?? DEFAULT_FOCUS_AREAS}${maxFindingsLine}${noFilesNote}
58
+ Focus Areas: ${input.focusAreas?.join(', ') ?? DEFAULT_FOCUS_AREAS}${maxFindingsLine}
104
59
  Changed Files:
105
60
  ${fileSummary}
106
61
 
107
62
  Diff:
108
63
  ${diff}
109
- ${fileContext}
110
64
 
111
- Based on the diff and file context above, perform a deep code review focusing on the specified areas.
65
+ Based on the diff above, perform a deep code review focusing on the specified areas.
112
66
  `,
113
67
  };
114
68
  },
@@ -1,4 +1,4 @@
1
- import { extractChangedPathsFromFiles } from '../lib/diff-parser.js';
1
+ import { extractChangedPathsFromFiles } from '../lib/diff.js';
2
2
  import { buildStructuredToolRuntimeOptions, requireToolContract, } from '../lib/tool-contracts.js';
3
3
  import { registerStructuredToolTask } from '../lib/tool-factory.js';
4
4
  import { SuggestSearchReplaceInputSchema } from '../schemas/inputs.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/code-review-analyst-mcp",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
4
4
  "mcpName": "io.github.j0hanz/code-review-analyst",
5
5
  "description": "Gemini-powered MCP server for code review analysis.",
6
6
  "type": "module",
package/dist/lib/cli.d.ts DELETED
@@ -1 +0,0 @@
1
- export declare function parseCommandLineArgs(): void;
package/dist/lib/cli.js DELETED
@@ -1,35 +0,0 @@
1
- import { parseArgs } from 'node:util';
2
- const ARG_OPTION_MODEL = 'model';
3
- const ARG_OPTION_MAX_DIFF_CHARS = 'max-diff-chars';
4
- const PROCESS_ARGS_START_INDEX = 2;
5
- const CLI_ENV_MAPPINGS = [
6
- { option: ARG_OPTION_MODEL, envVar: 'GEMINI_MODEL' },
7
- { option: ARG_OPTION_MAX_DIFF_CHARS, envVar: 'MAX_DIFF_CHARS' },
8
- ];
9
- const CLI_OPTIONS = {
10
- [ARG_OPTION_MODEL]: {
11
- type: 'string',
12
- short: 'm',
13
- },
14
- [ARG_OPTION_MAX_DIFF_CHARS]: {
15
- type: 'string',
16
- },
17
- };
18
- function setStringEnv(name, value) {
19
- if (typeof value === 'string') {
20
- process.env[name] = value;
21
- }
22
- }
23
- function applyCliEnvironmentOverrides(values) {
24
- for (const mapping of CLI_ENV_MAPPINGS) {
25
- setStringEnv(mapping.envVar, values[mapping.option]);
26
- }
27
- }
28
- export function parseCommandLineArgs() {
29
- const { values } = parseArgs({
30
- args: process.argv.slice(PROCESS_ARGS_START_INDEX),
31
- options: CLI_OPTIONS,
32
- strict: false,
33
- });
34
- applyCliEnvironmentOverrides(values);
35
- }
@@ -1,8 +0,0 @@
1
- import { createErrorToolResponse } from './tool-response.js';
2
- interface FileContent {
3
- content: string;
4
- }
5
- export declare function resetMaxContextCharsCacheForTesting(): void;
6
- export declare function computeContextSize(diff: string, files?: readonly FileContent[]): number;
7
- export declare function validateContextBudget(diff: string, files?: readonly FileContent[]): ReturnType<typeof createErrorToolResponse> | undefined;
8
- export {};
@@ -1,36 +0,0 @@
1
- import { createCachedEnvInt } from './env-config.js';
2
- import { createErrorToolResponse } from './tool-response.js';
3
- const DEFAULT_MAX_CONTEXT_CHARS = 500_000;
4
- const MAX_CONTEXT_CHARS_ENV_VAR = 'MAX_CONTEXT_CHARS';
5
- const BUDGET_ERROR_META = { retryable: false, kind: 'budget' };
6
- const contextCharsConfig = createCachedEnvInt(MAX_CONTEXT_CHARS_ENV_VAR, DEFAULT_MAX_CONTEXT_CHARS);
7
- function computeFilesSize(files) {
8
- let total = 0;
9
- for (const file of files) {
10
- total += file.content.length;
11
- }
12
- return total;
13
- }
14
- function createContextBudgetMessage(size, max) {
15
- return `Combined context size ${size} chars exceeds limit of ${max} chars.`;
16
- }
17
- export function resetMaxContextCharsCacheForTesting() {
18
- contextCharsConfig.reset();
19
- }
20
- function getMaxContextChars() {
21
- return contextCharsConfig.get();
22
- }
23
- export function computeContextSize(diff, files) {
24
- if (!files || files.length === 0) {
25
- return diff.length;
26
- }
27
- return diff.length + computeFilesSize(files);
28
- }
29
- export function validateContextBudget(diff, files) {
30
- const size = computeContextSize(diff, files);
31
- const max = getMaxContextChars();
32
- if (size > max) {
33
- return createErrorToolResponse('E_INPUT_TOO_LARGE', createContextBudgetMessage(size, max), { providedChars: size, maxChars: max }, BUDGET_ERROR_META);
34
- }
35
- return undefined;
36
- }
@@ -1,6 +0,0 @@
1
- import { createErrorToolResponse } from './tool-response.js';
2
- export declare function getMaxDiffChars(): number;
3
- export declare function resetMaxDiffCharsCacheForTesting(): void;
4
- export declare function exceedsDiffBudget(diff: string): boolean;
5
- export declare function getDiffBudgetError(diffLength: number, maxChars?: number): string;
6
- export declare function validateDiffBudget(diff: string): ReturnType<typeof createErrorToolResponse> | undefined;
@@ -1,31 +0,0 @@
1
- import { createCachedEnvInt } from './env-config.js';
2
- import { createErrorToolResponse } from './tool-response.js';
3
- const DEFAULT_MAX_DIFF_CHARS = 120_000;
4
- const MAX_DIFF_CHARS_ENV_VAR = 'MAX_DIFF_CHARS';
5
- const numberFormatter = new Intl.NumberFormat('en-US');
6
- const diffCharsConfig = createCachedEnvInt(MAX_DIFF_CHARS_ENV_VAR, DEFAULT_MAX_DIFF_CHARS);
7
- export function getMaxDiffChars() {
8
- return diffCharsConfig.get();
9
- }
10
- export function resetMaxDiffCharsCacheForTesting() {
11
- diffCharsConfig.reset();
12
- }
13
- export function exceedsDiffBudget(diff) {
14
- const maxChars = getMaxDiffChars();
15
- return diff.length > maxChars;
16
- }
17
- function formatDiffBudgetError(diffLength, maxChars) {
18
- return `diff exceeds max allowed size (${numberFormatter.format(diffLength)} chars > ${numberFormatter.format(maxChars)} chars)`;
19
- }
20
- export function getDiffBudgetError(diffLength, maxChars = getMaxDiffChars()) {
21
- return formatDiffBudgetError(diffLength, maxChars);
22
- }
23
- const BUDGET_ERROR_META = { retryable: false, kind: 'budget' };
24
- export function validateDiffBudget(diff) {
25
- const providedChars = diff.length;
26
- const maxChars = getMaxDiffChars();
27
- if (providedChars <= maxChars) {
28
- return undefined;
29
- }
30
- return createErrorToolResponse('E_INPUT_TOO_LARGE', getDiffBudgetError(providedChars, maxChars), { providedChars, maxChars }, BUDGET_ERROR_META);
31
- }
@@ -1,12 +0,0 @@
1
- export declare const NOISY_EXCLUDE_PATHSPECS: readonly [":(exclude)package-lock.json", ":(exclude)yarn.lock", ":(exclude)pnpm-lock.yaml", ":(exclude)bun.lockb", ":(exclude)*.lock", ":(exclude)dist/", ":(exclude)build/", ":(exclude)out/", ":(exclude).next/", ":(exclude)coverage/", ":(exclude)*.min.js", ":(exclude)*.min.css", ":(exclude)*.map"];
2
- /**
3
- * Split raw unified diff into per-file sections and strip:
4
- * - Binary file sections ("Binary files a/... and b/... differ")
5
- * - GIT binary patch sections
6
- * - Mode-only sections (permission changes with no content hunks)
7
- *
8
- * Does NOT modify content lines (+ / - / space) to preserve verbatim
9
- * accuracy required by suggest_search_replace.
10
- */
11
- export declare function cleanDiff(raw: string): string;
12
- export declare function isEmptyDiff(diff: string): boolean;
@@ -1,76 +0,0 @@
1
- export const NOISY_EXCLUDE_PATHSPECS = [
2
- ':(exclude)package-lock.json',
3
- ':(exclude)yarn.lock',
4
- ':(exclude)pnpm-lock.yaml',
5
- ':(exclude)bun.lockb',
6
- ':(exclude)*.lock',
7
- ':(exclude)dist/',
8
- ':(exclude)build/',
9
- ':(exclude)out/',
10
- ':(exclude).next/',
11
- ':(exclude)coverage/',
12
- ':(exclude)*.min.js',
13
- ':(exclude)*.min.css',
14
- ':(exclude)*.map',
15
- ];
16
- // Regex patterns to identify noisy diff file sections.
17
- const BINARY_FILE_LINE = /^Binary files .+ differ$/m;
18
- const GIT_BINARY_PATCH = /^GIT binary patch/m;
19
- const HAS_HUNK = /^@@/m;
20
- const HAS_OLD_MODE = /^old mode /m;
21
- function shouldKeepSection(section) {
22
- if (!section.trim()) {
23
- return false;
24
- }
25
- if (BINARY_FILE_LINE.test(section)) {
26
- return false;
27
- }
28
- if (GIT_BINARY_PATCH.test(section)) {
29
- return false;
30
- }
31
- if (HAS_OLD_MODE.test(section) && !HAS_HUNK.test(section)) {
32
- return false;
33
- }
34
- return true;
35
- }
36
- function processSection(raw, start, end, sections) {
37
- if (end > start) {
38
- const section = raw.slice(start, end);
39
- if (shouldKeepSection(section)) {
40
- sections.push(section);
41
- }
42
- }
43
- }
44
- /**
45
- * Split raw unified diff into per-file sections and strip:
46
- * - Binary file sections ("Binary files a/... and b/... differ")
47
- * - GIT binary patch sections
48
- * - Mode-only sections (permission changes with no content hunks)
49
- *
50
- * Does NOT modify content lines (+ / - / space) to preserve verbatim
51
- * accuracy required by suggest_search_replace.
52
- */
53
- export function cleanDiff(raw) {
54
- if (!raw)
55
- return '';
56
- const sections = [];
57
- let lastIndex = 0;
58
- let nextIndex = raw.startsWith('diff --git ')
59
- ? 0
60
- : raw.indexOf('\ndiff --git ');
61
- if (nextIndex === -1) {
62
- processSection(raw, 0, raw.length, sections);
63
- return sections.join('').trim();
64
- }
65
- while (nextIndex !== -1) {
66
- const matchIndex = nextIndex === 0 ? 0 : nextIndex + 1; // +1 to skip \n
67
- processSection(raw, lastIndex, matchIndex, sections);
68
- lastIndex = matchIndex;
69
- nextIndex = raw.indexOf('\ndiff --git ', lastIndex);
70
- }
71
- processSection(raw, lastIndex, raw.length, sections);
72
- return sections.join('').trim();
73
- }
74
- export function isEmptyDiff(diff) {
75
- return diff.trim().length === 0;
76
- }
@@ -1,34 +0,0 @@
1
- import type { File as ParsedFile } from 'parse-diff';
2
- export type { ParsedFile };
3
- interface DiffStats {
4
- files: number;
5
- added: number;
6
- deleted: number;
7
- }
8
- /** Parse unified diff string into structured file list. */
9
- export declare function parseDiffFiles(diff: string): ParsedFile[];
10
- export declare function computeDiffStatsAndSummaryFromFiles(files: readonly ParsedFile[]): Readonly<{
11
- stats: DiffStats;
12
- summary: string;
13
- }>;
14
- export declare function computeDiffStatsAndPathsFromFiles(files: readonly ParsedFile[]): Readonly<{
15
- stats: DiffStats;
16
- paths: string[];
17
- }>;
18
- /** Extract all unique changed file paths (renamed: returns new path). */
19
- export declare function extractChangedPathsFromFiles(files: readonly ParsedFile[]): string[];
20
- /** Extract all unique changed file paths (renamed: returns new path). */
21
- export declare function extractChangedPaths(diff: string): string[];
22
- export declare function computeDiffStatsFromFiles(files: readonly ParsedFile[]): Readonly<{
23
- files: number;
24
- added: number;
25
- deleted: number;
26
- }>;
27
- /** Count changed files, added lines, and deleted lines. */
28
- export declare function computeDiffStats(diff: string): Readonly<{
29
- files: number;
30
- added: number;
31
- deleted: number;
32
- }>;
33
- /** Generate human-readable summary of changed files and line counts. */
34
- export declare function formatFileSummary(files: ParsedFile[]): string;
@@ -1,116 +0,0 @@
1
- import parseDiff from 'parse-diff';
2
- const UNKNOWN_PATH = 'unknown';
3
- const NO_FILES_CHANGED = 'No files changed.';
4
- const EMPTY_PATHS = [];
5
- const EMPTY_STATS = Object.freeze({ files: 0, added: 0, deleted: 0 });
6
- const PATH_SORTER = (left, right) => left.localeCompare(right);
7
- /** Parse unified diff string into structured file list. */
8
- export function parseDiffFiles(diff) {
9
- return diff ? parseDiff(diff) : [];
10
- }
11
- function cleanPath(path) {
12
- if (path.startsWith('a/') || path.startsWith('b/')) {
13
- return path.slice(2);
14
- }
15
- return path;
16
- }
17
- function resolveChangedPath(file) {
18
- if (file.to && file.to !== '/dev/null') {
19
- return cleanPath(file.to);
20
- }
21
- if (file.from && file.from !== '/dev/null') {
22
- return cleanPath(file.from);
23
- }
24
- return undefined;
25
- }
26
- function sortPaths(paths) {
27
- return Array.from(paths).sort(PATH_SORTER);
28
- }
29
- function calculateStats(files) {
30
- let added = 0;
31
- let deleted = 0;
32
- for (const file of files) {
33
- added += file.additions;
34
- deleted += file.deletions;
35
- }
36
- return { files: files.length, added, deleted };
37
- }
38
- function getUniquePaths(files) {
39
- const paths = new Set();
40
- for (const file of files) {
41
- const path = resolveChangedPath(file);
42
- if (path) {
43
- paths.add(path);
44
- }
45
- }
46
- return paths;
47
- }
48
- export function computeDiffStatsAndSummaryFromFiles(files) {
49
- if (files.length === 0) {
50
- return {
51
- stats: EMPTY_STATS,
52
- summary: NO_FILES_CHANGED,
53
- };
54
- }
55
- let added = 0;
56
- let deleted = 0;
57
- const summaries = [];
58
- const MAX_SUMMARY_FILES = 40;
59
- let i = 0;
60
- for (const file of files) {
61
- added += file.additions;
62
- deleted += file.deletions;
63
- if (i < MAX_SUMMARY_FILES) {
64
- const path = resolveChangedPath(file) ?? UNKNOWN_PATH;
65
- summaries.push(`${path} (+${file.additions} -${file.deletions})`);
66
- }
67
- i++;
68
- }
69
- if (files.length > MAX_SUMMARY_FILES) {
70
- summaries.push(`... and ${files.length - MAX_SUMMARY_FILES} more files`);
71
- }
72
- const stats = { files: files.length, added, deleted };
73
- return {
74
- stats,
75
- summary: `${summaries.join(', ')} [${stats.files} files, +${stats.added} -${stats.deleted}]`,
76
- };
77
- }
78
- export function computeDiffStatsAndPathsFromFiles(files) {
79
- if (files.length === 0) {
80
- return {
81
- stats: EMPTY_STATS,
82
- paths: EMPTY_PATHS,
83
- };
84
- }
85
- const stats = calculateStats(files);
86
- const paths = sortPaths(getUniquePaths(files));
87
- return {
88
- stats,
89
- paths,
90
- };
91
- }
92
- /** Extract all unique changed file paths (renamed: returns new path). */
93
- export function extractChangedPathsFromFiles(files) {
94
- if (files.length === 0) {
95
- return EMPTY_PATHS;
96
- }
97
- return sortPaths(getUniquePaths(files));
98
- }
99
- /** Extract all unique changed file paths (renamed: returns new path). */
100
- export function extractChangedPaths(diff) {
101
- return extractChangedPathsFromFiles(parseDiffFiles(diff));
102
- }
103
- export function computeDiffStatsFromFiles(files) {
104
- if (files.length === 0) {
105
- return EMPTY_STATS;
106
- }
107
- return calculateStats(files);
108
- }
109
- /** Count changed files, added lines, and deleted lines. */
110
- export function computeDiffStats(diff) {
111
- return computeDiffStatsFromFiles(parseDiffFiles(diff));
112
- }
113
- /** Generate human-readable summary of changed files and line counts. */
114
- export function formatFileSummary(files) {
115
- return computeDiffStatsAndSummaryFromFiles(files).summary;
116
- }