@j0hanz/code-review-analyst-mcp 1.5.3 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,43 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { parseArgs } from 'node:util';
3
+ import { parseCommandLineArgs } from './lib/cli.js';
4
4
  import { getErrorMessage } from './lib/errors.js';
5
5
  import { createServer } from './server.js';
6
6
  const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM'];
7
- const ARG_OPTION_MODEL = 'model';
8
- const ARG_OPTION_MAX_DIFF_CHARS = 'max-diff-chars';
9
- const PROCESS_ARGS_START_INDEX = 2;
10
- const CLI_ENV_MAPPINGS = [
11
- { option: ARG_OPTION_MODEL, envVar: 'GEMINI_MODEL' },
12
- { option: ARG_OPTION_MAX_DIFF_CHARS, envVar: 'MAX_DIFF_CHARS' },
13
- ];
14
- const CLI_OPTIONS = {
15
- [ARG_OPTION_MODEL]: {
16
- type: 'string',
17
- short: 'm',
18
- },
19
- [ARG_OPTION_MAX_DIFF_CHARS]: {
20
- type: 'string',
21
- },
22
- };
23
- function setStringEnv(name, value) {
24
- if (typeof value === 'string') {
25
- process.env[name] = value;
26
- }
27
- }
28
- function applyCliEnvironmentOverrides(values) {
29
- for (const mapping of CLI_ENV_MAPPINGS) {
30
- setStringEnv(mapping.envVar, values[mapping.option]);
31
- }
32
- }
33
- function parseCommandLineArgs() {
34
- const { values } = parseArgs({
35
- args: process.argv.slice(PROCESS_ARGS_START_INDEX),
36
- options: CLI_OPTIONS,
37
- strict: false,
38
- });
39
- applyCliEnvironmentOverrides(values);
40
- }
41
7
  let shuttingDown = false;
42
8
  async function shutdown(server) {
43
9
  if (shuttingDown) {
@@ -0,0 +1 @@
1
+ export declare function parseCommandLineArgs(): void;
@@ -0,0 +1,35 @@
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
+ }
@@ -5,11 +5,11 @@ const MAX_CONTEXT_CHARS_ENV_VAR = 'MAX_CONTEXT_CHARS';
5
5
  const BUDGET_ERROR_META = { retryable: false, kind: 'budget' };
6
6
  const contextCharsConfig = createCachedEnvInt(MAX_CONTEXT_CHARS_ENV_VAR, DEFAULT_MAX_CONTEXT_CHARS);
7
7
  function computeFilesSize(files) {
8
- let fileSize = 0;
8
+ let total = 0;
9
9
  for (const file of files) {
10
- fileSize += file.content.length;
10
+ total += file.content.length;
11
11
  }
12
- return fileSize;
12
+ return total;
13
13
  }
14
14
  function createContextBudgetMessage(size, max) {
15
15
  return `Combined context size ${size} chars exceeds limit of ${max} chars.`;
@@ -12,7 +12,7 @@ export function resetMaxDiffCharsCacheForTesting() {
12
12
  }
13
13
  export function exceedsDiffBudget(diff) {
14
14
  const maxChars = getMaxDiffChars();
15
- return getDiffLength(diff) > maxChars;
15
+ return diff.length > maxChars;
16
16
  }
17
17
  function formatDiffBudgetError(diffLength, maxChars) {
18
18
  return `diff exceeds max allowed size (${numberFormatter.format(diffLength)} chars > ${numberFormatter.format(maxChars)} chars)`;
@@ -21,14 +21,11 @@ export function getDiffBudgetError(diffLength, maxChars = getMaxDiffChars()) {
21
21
  return formatDiffBudgetError(diffLength, maxChars);
22
22
  }
23
23
  const BUDGET_ERROR_META = { retryable: false, kind: 'budget' };
24
- function getDiffLength(diff) {
25
- return diff.length;
26
- }
27
24
  export function validateDiffBudget(diff) {
28
- const providedChars = getDiffLength(diff);
25
+ const providedChars = diff.length;
29
26
  const maxChars = getMaxDiffChars();
30
27
  if (providedChars <= maxChars) {
31
28
  return undefined;
32
29
  }
33
- return createErrorToolResponse('E_INPUT_TOO_LARGE', formatDiffBudgetError(providedChars, maxChars), { providedChars, maxChars }, BUDGET_ERROR_META);
30
+ return createErrorToolResponse('E_INPUT_TOO_LARGE', getDiffBudgetError(providedChars, maxChars), { providedChars, maxChars }, BUDGET_ERROR_META);
34
31
  }
@@ -18,6 +18,29 @@ const BINARY_FILE_LINE = /^Binary files .+ differ$/m;
18
18
  const GIT_BINARY_PATCH = /^GIT binary patch/m;
19
19
  const HAS_HUNK = /^@@/m;
20
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
+ }
21
44
  /**
22
45
  * Split raw unified diff into per-file sections and strip:
23
46
  * - Binary file sections ("Binary files a/... and b/... differ")
@@ -30,21 +53,23 @@ const HAS_OLD_MODE = /^old mode /m;
30
53
  export function cleanDiff(raw) {
31
54
  if (!raw)
32
55
  return '';
33
- // Split on the start of each "diff --git" header, keeping the header.
34
- const sections = raw.split(/(?=^diff --git )/m);
35
- const cleaned = sections.filter((section) => {
36
- if (!section.trim())
37
- return false;
38
- if (BINARY_FILE_LINE.test(section))
39
- return false;
40
- if (GIT_BINARY_PATCH.test(section))
41
- return false;
42
- // Drop mode-only sections that have no actual content hunks.
43
- if (HAS_OLD_MODE.test(section) && !HAS_HUNK.test(section))
44
- return false;
45
- return true;
46
- });
47
- return cleaned.join('').trim();
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();
48
73
  }
49
74
  export function isEmptyDiff(diff) {
50
75
  return diff.trim().length === 0;
@@ -6,10 +6,7 @@ const EMPTY_STATS = Object.freeze({ files: 0, added: 0, deleted: 0 });
6
6
  const PATH_SORTER = (left, right) => left.localeCompare(right);
7
7
  /** Parse unified diff string into structured file list. */
8
8
  export function parseDiffFiles(diff) {
9
- if (!diff) {
10
- return [];
11
- }
12
- return parseDiff(diff);
9
+ return diff ? parseDiff(diff) : [];
13
10
  }
14
11
  function cleanPath(path) {
15
12
  if (path.startsWith('a/') || path.startsWith('b/')) {
@@ -27,43 +24,26 @@ function resolveChangedPath(file) {
27
24
  return undefined;
28
25
  }
29
26
  function sortPaths(paths) {
30
- if (paths.size === 0) {
31
- return EMPTY_PATHS;
32
- }
33
27
  return Array.from(paths).sort(PATH_SORTER);
34
28
  }
35
- function buildDiffComputation(files, options) {
29
+ function calculateStats(files) {
36
30
  let added = 0;
37
31
  let deleted = 0;
38
- const paths = options.needPaths ? new Set() : undefined;
39
- const summaries = options.needSummaries
40
- ? new Array(files.length)
41
- : undefined;
42
- let index = 0;
43
32
  for (const file of files) {
44
33
  added += file.additions;
45
34
  deleted += file.deletions;
46
- if (options.needPaths || options.needSummaries) {
47
- const path = resolveChangedPath(file);
48
- if (paths && path) {
49
- paths.add(path);
50
- }
51
- if (summaries) {
52
- summaries[index] =
53
- `${path ?? UNKNOWN_PATH} (+${file.additions} -${file.deletions})`;
54
- }
55
- }
56
- index += 1;
57
35
  }
58
- return {
59
- added,
60
- deleted,
61
- paths: paths ?? new Set(),
62
- summaries: summaries ?? [],
63
- };
36
+ return { files: files.length, added, deleted };
64
37
  }
65
- function buildStats(filesCount, added, deleted) {
66
- return { files: filesCount, added, deleted };
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;
67
47
  }
68
48
  export function computeDiffStatsAndSummaryFromFiles(files) {
69
49
  if (files.length === 0) {
@@ -72,14 +52,27 @@ export function computeDiffStatsAndSummaryFromFiles(files) {
72
52
  summary: NO_FILES_CHANGED,
73
53
  };
74
54
  }
75
- const computed = buildDiffComputation(files, {
76
- needPaths: false,
77
- needSummaries: true,
78
- });
79
- const stats = buildStats(files.length, computed.added, computed.deleted);
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 };
80
73
  return {
81
74
  stats,
82
- summary: `${computed.summaries.join(', ')} [${stats.files} files, +${stats.added} -${stats.deleted}]`,
75
+ summary: `${summaries.join(', ')} [${stats.files} files, +${stats.added} -${stats.deleted}]`,
83
76
  };
84
77
  }
85
78
  export function computeDiffStatsAndPathsFromFiles(files) {
@@ -89,13 +82,11 @@ export function computeDiffStatsAndPathsFromFiles(files) {
89
82
  paths: EMPTY_PATHS,
90
83
  };
91
84
  }
92
- const computed = buildDiffComputation(files, {
93
- needPaths: true,
94
- needSummaries: false,
95
- });
85
+ const stats = calculateStats(files);
86
+ const paths = sortPaths(getUniquePaths(files));
96
87
  return {
97
- stats: buildStats(files.length, computed.added, computed.deleted),
98
- paths: sortPaths(computed.paths),
88
+ stats,
89
+ paths,
99
90
  };
100
91
  }
101
92
  /** Extract all unique changed file paths (renamed: returns new path). */
@@ -103,7 +94,7 @@ export function extractChangedPathsFromFiles(files) {
103
94
  if (files.length === 0) {
104
95
  return EMPTY_PATHS;
105
96
  }
106
- return sortPaths(buildDiffComputation(files, { needPaths: true, needSummaries: false }).paths);
97
+ return sortPaths(getUniquePaths(files));
107
98
  }
108
99
  /** Extract all unique changed file paths (renamed: returns new path). */
109
100
  export function extractChangedPaths(diff) {
@@ -113,11 +104,7 @@ export function computeDiffStatsFromFiles(files) {
113
104
  if (files.length === 0) {
114
105
  return EMPTY_STATS;
115
106
  }
116
- const computed = buildDiffComputation(files, {
117
- needPaths: false,
118
- needSummaries: false,
119
- });
120
- return buildStats(files.length, computed.added, computed.deleted);
107
+ return calculateStats(files);
121
108
  }
122
109
  /** Count changed files, added lines, and deleted lines. */
123
110
  export function computeDiffStats(diff) {
@@ -1,32 +1,54 @@
1
+ import { createCachedEnvInt } from './env-config.js';
1
2
  import { createErrorToolResponse } from './tool-response.js';
2
3
  export const DIFF_RESOURCE_URI = 'diff://current';
4
+ const diffCacheTtlMs = createCachedEnvInt('DIFF_CACHE_TTL_MS', 60 * 60 * 1_000 // 1 hour default
5
+ );
3
6
  const diffSlots = new Map();
4
7
  let sendResourceUpdated;
8
+ function setDiffSlot(key, data) {
9
+ if (data) {
10
+ diffSlots.set(key, data);
11
+ return;
12
+ }
13
+ diffSlots.delete(key);
14
+ }
15
+ function notifyDiffUpdated() {
16
+ void sendResourceUpdated?.({ uri: DIFF_RESOURCE_URI }).catch(() => {
17
+ // Ignore errors sending resource-updated, which can happen if the server is not fully initialized yet.
18
+ });
19
+ }
5
20
  /** Call once during server setup so the store can emit resource-updated notifications. */
6
21
  export function initDiffStore(server) {
7
22
  const inner = server.server;
8
- sendResourceUpdated = inner.sendResourceUpdated.bind(inner);
23
+ if (typeof inner?.sendResourceUpdated === 'function') {
24
+ sendResourceUpdated = inner.sendResourceUpdated.bind(inner);
25
+ }
26
+ else {
27
+ console.error('[diff-store] sendResourceUpdated not available — diff resource notifications disabled.');
28
+ }
9
29
  }
10
30
  export function storeDiff(data, key = process.cwd()) {
11
- diffSlots.set(key, data);
12
- void sendResourceUpdated?.({ uri: DIFF_RESOURCE_URI }).catch(() => {
13
- // Ignore errors sending resource-updated, which can happen if the server is not fully initialized yet.
14
- });
31
+ setDiffSlot(key, data);
32
+ notifyDiffUpdated();
15
33
  }
16
34
  export function getDiff(key = process.cwd()) {
17
- return diffSlots.get(key);
35
+ const slot = diffSlots.get(key);
36
+ if (!slot) {
37
+ return undefined;
38
+ }
39
+ const age = Date.now() - new Date(slot.generatedAt).getTime();
40
+ if (age > diffCacheTtlMs.get()) {
41
+ diffSlots.delete(key);
42
+ return undefined;
43
+ }
44
+ return slot;
18
45
  }
19
46
  export function hasDiff(key = process.cwd()) {
20
47
  return diffSlots.has(key);
21
48
  }
22
49
  /** Test-only: directly set or clear the diff slot without emitting resource-updated. */
23
50
  export function setDiffForTesting(data, key = process.cwd()) {
24
- if (data) {
25
- diffSlots.set(key, data);
26
- }
27
- else {
28
- diffSlots.delete(key);
29
- }
51
+ setDiffSlot(key, data);
30
52
  }
31
53
  export function createNoDiffError() {
32
54
  return createErrorToolResponse('E_NO_DIFF', 'No diff cached. You must call the generate_diff tool before using any review tool. Run generate_diff with mode="unstaged" or mode="staged" to capture the current branch changes, then retry this tool.', undefined, { retryable: false, kind: 'validation' });
@@ -1,9 +1,10 @@
1
1
  function parsePositiveInteger(value) {
2
- if (value.length === 0) {
2
+ const normalized = value.trim();
3
+ if (normalized.length === 0) {
3
4
  return undefined;
4
5
  }
5
- const parsed = Number.parseInt(value, 10);
6
- if (Number.isNaN(parsed) || parsed <= 0) {
6
+ const parsed = Number.parseInt(normalized, 10);
7
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
7
8
  return undefined;
8
9
  }
9
10
  return parsed;
@@ -4,16 +4,18 @@ export const RETRYABLE_UPSTREAM_ERROR_PATTERN = /(429|500|502|503|504|rate.?limi
4
4
  function isObjectRecord(value) {
5
5
  return typeof value === 'object' && value !== null;
6
6
  }
7
- function hasStringProperty(value, key) {
7
+ function getStringProperty(value, key) {
8
8
  if (!isObjectRecord(value) || !(key in value)) {
9
- return false;
9
+ return undefined;
10
10
  }
11
11
  const record = value;
12
- return typeof record[key] === 'string';
12
+ const property = record[key];
13
+ return typeof property === 'string' ? property : undefined;
13
14
  }
14
15
  export function getErrorMessage(error) {
15
- if (hasStringProperty(error, 'message')) {
16
- return error.message;
16
+ const message = getStringProperty(error, 'message');
17
+ if (message !== undefined) {
18
+ return message;
17
19
  }
18
20
  if (typeof error === 'string') {
19
21
  return error;
@@ -8,6 +8,8 @@ const CONSTRAINT_KEYS = new Set([
8
8
  'minItems',
9
9
  'maxItems',
10
10
  'multipleOf',
11
+ 'pattern',
12
+ 'format',
11
13
  ]);
12
14
  const INTEGER_JSON_TYPE = 'integer';
13
15
  const NUMBER_JSON_TYPE = 'number';