skannr 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/cli.js +86 -0
  2. package/dist/cli.js.map +1 -1
  3. package/dist/guard/call-context.d.ts +14 -0
  4. package/dist/guard/call-context.d.ts.map +1 -0
  5. package/dist/guard/call-context.js +120 -0
  6. package/dist/guard/call-context.js.map +1 -0
  7. package/dist/guard/fix-applier.d.ts +38 -0
  8. package/dist/guard/fix-applier.d.ts.map +1 -0
  9. package/dist/guard/fix-applier.js +181 -0
  10. package/dist/guard/fix-applier.js.map +1 -0
  11. package/dist/guard/hook-installer.d.ts +19 -0
  12. package/dist/guard/hook-installer.d.ts.map +1 -0
  13. package/dist/guard/hook-installer.js +119 -0
  14. package/dist/guard/hook-installer.js.map +1 -0
  15. package/dist/guard/index.d.ts +34 -0
  16. package/dist/guard/index.d.ts.map +1 -0
  17. package/dist/guard/index.js +152 -0
  18. package/dist/guard/index.js.map +1 -0
  19. package/dist/guard/provider.d.ts +12 -0
  20. package/dist/guard/provider.d.ts.map +1 -0
  21. package/dist/guard/provider.js +211 -0
  22. package/dist/guard/provider.js.map +1 -0
  23. package/dist/guard/review-runner.d.ts +21 -0
  24. package/dist/guard/review-runner.d.ts.map +1 -0
  25. package/dist/guard/review-runner.js +85 -0
  26. package/dist/guard/review-runner.js.map +1 -0
  27. package/dist/guard/rules-loader.d.ts +10 -0
  28. package/dist/guard/rules-loader.d.ts.map +1 -0
  29. package/dist/guard/rules-loader.js +121 -0
  30. package/dist/guard/rules-loader.js.map +1 -0
  31. package/dist/guard/schema.d.ts +182 -0
  32. package/dist/guard/schema.d.ts.map +1 -0
  33. package/dist/guard/schema.js +53 -0
  34. package/dist/guard/schema.js.map +1 -0
  35. package/dist/guard/symbol-diff.d.ts +18 -0
  36. package/dist/guard/symbol-diff.d.ts.map +1 -0
  37. package/dist/guard/symbol-diff.js +243 -0
  38. package/dist/guard/symbol-diff.js.map +1 -0
  39. package/dist/guard/types.d.ts +62 -0
  40. package/dist/guard/types.d.ts.map +1 -0
  41. package/dist/guard/types.js +6 -0
  42. package/dist/guard/types.js.map +1 -0
  43. package/dist/mcp-server.d.ts.map +1 -1
  44. package/dist/mcp-server.js +44 -0
  45. package/dist/mcp-server.js.map +1 -1
  46. package/package.json +3 -2
  47. package/src/cli.ts +101 -0
  48. package/src/guard/call-context.ts +101 -0
  49. package/src/guard/fix-applier.ts +172 -0
  50. package/src/guard/hook-installer.ts +96 -0
  51. package/src/guard/index.ts +134 -0
  52. package/src/guard/provider.ts +209 -0
  53. package/src/guard/review-runner.ts +110 -0
  54. package/src/guard/rules-loader.ts +97 -0
  55. package/src/guard/schema.ts +59 -0
  56. package/src/guard/symbol-diff.ts +227 -0
  57. package/src/guard/types.ts +68 -0
  58. package/src/mcp-server.ts +49 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Review runner — orchestrates the full guard pipeline:
3
+ * build symbol diffs → enrich with call context → batch → call LLM → validate.
4
+ */
5
+
6
+ import { loadRules, loadGuardConfig } from './rules-loader';
7
+ import { buildSymbolDiffs, buildSymbolDiffsFromDiff } from './symbol-diff';
8
+ import { enrichWithCallContext } from './call-context';
9
+ import { runLlmReview } from './provider';
10
+ import type { GuardResult, ReviewResponse, SymbolDiffUnit } from './types';
11
+
12
+ /** Maximum symbols to include in a single LLM call. */
13
+ const BATCH_SIZE = 15;
14
+
15
+ export interface RunOptions {
16
+ /** Project root directory. */
17
+ root: string;
18
+ /** If provided, review this diff instead of staged files. */
19
+ diffContent?: string;
20
+ /** Skip cross-file caller/callee context (faster, cheaper). */
21
+ diffOnly?: boolean;
22
+ /** Skip symbol cache. */
23
+ noCache?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Run the full guard review pipeline.
28
+ * Returns a GuardResult with violations, or throws with exit code 2 errors.
29
+ */
30
+ export async function runGuardReview(options: RunOptions): Promise<GuardResult> {
31
+ const { root, diffContent, diffOnly = false } = options;
32
+ const started = Date.now();
33
+
34
+ // 1. Load rules
35
+ const rules = loadRules(root);
36
+
37
+ // 2. Load provider config
38
+ const config = loadGuardConfig(root);
39
+
40
+ // 3. Build symbol-level diffs
41
+ let units: SymbolDiffUnit[];
42
+ if (diffContent) {
43
+ units = buildSymbolDiffsFromDiff(root, diffContent);
44
+ } else {
45
+ units = buildSymbolDiffs(root);
46
+ }
47
+
48
+ if (units.length === 0) {
49
+ return {
50
+ response: {
51
+ violations: [],
52
+ status: 'pass',
53
+ summary: 'No staged changes with identifiable symbols to review.',
54
+ },
55
+ rulesUsed: rules,
56
+ symbolsReviewed: 0,
57
+ durationMs: Date.now() - started,
58
+ };
59
+ }
60
+
61
+ // 4. Enrich with call context (unless --diff-only)
62
+ if (!diffOnly) {
63
+ units = enrichWithCallContext(units, root);
64
+ }
65
+
66
+ // 5. Batch and review
67
+ const allViolations: ReviewResponse['violations'] = [];
68
+ const batches = batchUnits(units, BATCH_SIZE);
69
+
70
+ // Run batches concurrently (up to 3 in parallel)
71
+ const CONCURRENCY = 3;
72
+ for (let i = 0; i < batches.length; i += CONCURRENCY) {
73
+ const chunk = batches.slice(i, i + CONCURRENCY);
74
+ const results = await Promise.all(
75
+ chunk.map((batch) => runLlmReview(config, rules, batch)),
76
+ );
77
+ for (const result of results) {
78
+ allViolations.push(...result.violations);
79
+ }
80
+ }
81
+
82
+ // 6. Determine status
83
+ const hasHighConfidenceFail = allViolations.some(
84
+ (v) => v.confidence >= 0.7 && (v.severity === 'high' || v.severity === 'critical'),
85
+ );
86
+
87
+ const response: ReviewResponse = {
88
+ violations: allViolations,
89
+ status: hasHighConfidenceFail ? 'fail' : 'pass',
90
+ summary: allViolations.length === 0
91
+ ? `All ${units.length} symbol(s) pass review.`
92
+ : `${allViolations.length} violation(s) found across ${units.length} symbol(s).`,
93
+ };
94
+
95
+ return {
96
+ response,
97
+ rulesUsed: rules,
98
+ symbolsReviewed: units.length,
99
+ durationMs: Date.now() - started,
100
+ };
101
+ }
102
+
103
+ /** Split units into batches of the given size. */
104
+ function batchUnits(units: SymbolDiffUnit[], size: number): SymbolDiffUnit[][] {
105
+ const batches: SymbolDiffUnit[][] = [];
106
+ for (let i = 0; i < units.length; i += size) {
107
+ batches.push(units.slice(i, i + size));
108
+ }
109
+ return batches;
110
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Load and validate the rules file (.skannr/rules.json or .skannr/rules.yaml).
3
+ * Returns validated rules or throws with a clear error message.
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { RulesFileSchema } from './schema';
9
+ import type { GuardRule, GuardConfig } from './types';
10
+ import { GuardConfigSchema } from './schema';
11
+
12
+ /** Default locations to search for the rules file. */
13
+ const RULES_PATHS = [
14
+ '.skannr/rules.json',
15
+ '.skannr/rules.yaml',
16
+ '.skannr/rules.yml',
17
+ ];
18
+
19
+ /** Load and validate rules from the project root. */
20
+ export function loadRules(root: string): GuardRule[] {
21
+ const rulesPath = findRulesFile(root);
22
+ if (!rulesPath) {
23
+ throw new Error(
24
+ `No rules file found. Create .skannr/rules.json with your team's review rules.\n` +
25
+ `Example:\n` +
26
+ `{\n` +
27
+ ` "rules": [{\n` +
28
+ ` "id": "no-any-type",\n` +
29
+ ` "description": "Do not use the \`any\` type in TypeScript.",\n` +
30
+ ` "severity": "high",\n` +
31
+ ` "fixable": true,\n` +
32
+ ` "category": "type-safety"\n` +
33
+ ` }]\n` +
34
+ `}`,
35
+ );
36
+ }
37
+
38
+ const raw = fs.readFileSync(rulesPath, 'utf-8');
39
+ let parsed: unknown;
40
+
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ } catch {
44
+ throw new Error(`Failed to parse rules file as JSON: ${rulesPath}`);
45
+ }
46
+
47
+ const result = RulesFileSchema.safeParse(parsed);
48
+ if (!result.success) {
49
+ const errors = result.error.issues
50
+ .map((i) => ` - ${i.path.join('.')}: ${i.message}`)
51
+ .join('\n');
52
+ throw new Error(`Invalid rules file (${rulesPath}):\n${errors}`);
53
+ }
54
+
55
+ return result.data.rules;
56
+ }
57
+
58
+ /** Load guard provider configuration from .skannr/guard.json or env vars. */
59
+ export function loadGuardConfig(root: string): GuardConfig {
60
+ const configPath = path.join(root, '.skannr', 'guard.json');
61
+
62
+ let rawConfig: Record<string, unknown> = {};
63
+ if (fs.existsSync(configPath)) {
64
+ try {
65
+ rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
66
+ } catch {
67
+ // Fall through to defaults + env
68
+ }
69
+ }
70
+
71
+ // Env vars override file config
72
+ const provider = (process.env.SKANNR_GUARD_PROVIDER || rawConfig.provider || 'gemini') as string;
73
+ const model = (process.env.SKANNR_GUARD_MODEL || rawConfig.model || 'gemini-2.0-flash-exp') as string;
74
+ const apiKey = process.env.SKANNR_GUARD_API_KEY
75
+ || process.env.GEMINI_API_KEY
76
+ || process.env.OPENAI_API_KEY
77
+ || (rawConfig.apiKey as string | undefined);
78
+ const baseUrl = process.env.OPENAI_BASE_URL || (rawConfig.baseUrl as string | undefined);
79
+
80
+ const result = GuardConfigSchema.safeParse({ provider, model, apiKey, baseUrl });
81
+ if (!result.success) {
82
+ throw new Error(`Invalid guard config: ${result.error.issues.map((i) => i.message).join(', ')}`);
83
+ }
84
+
85
+ return result.data as GuardConfig;
86
+ }
87
+
88
+ /** Find the first existing rules file in the project. */
89
+ function findRulesFile(root: string): string | null {
90
+ for (const rel of RULES_PATHS) {
91
+ const full = path.join(root, rel);
92
+ if (fs.existsSync(full)) {
93
+ return full;
94
+ }
95
+ }
96
+ return null;
97
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Zod schemas for rules file validation and LLM response contract enforcement.
3
+ * These ensure we never parse free text — all LLM output is validated against
4
+ * a strict schema before being used.
5
+ */
6
+
7
+ import { z } from 'zod';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Rules file schema
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export const SeveritySchema = z.enum(['low', 'medium', 'high', 'critical']);
14
+
15
+ export const RuleSchema = z.object({
16
+ id: z.string().min(1),
17
+ description: z.string().min(1),
18
+ severity: SeveritySchema,
19
+ fixable: z.boolean(),
20
+ category: z.string().min(1),
21
+ });
22
+
23
+ export const RulesFileSchema = z.object({
24
+ rules: z.array(RuleSchema).min(1),
25
+ });
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Violation / LLM response schema
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export const ViolationSchema = z.object({
32
+ rule_id: z.string().min(1),
33
+ file: z.string().min(1),
34
+ symbol: z.string(),
35
+ line_start: z.number().int().nonnegative(),
36
+ line_end: z.number().int().nonnegative(),
37
+ severity: SeveritySchema,
38
+ confidence: z.number().min(0).max(1),
39
+ fixable: z.boolean(),
40
+ message: z.string().min(1),
41
+ suggested_fix: z.string().optional(),
42
+ });
43
+
44
+ export const ReviewResponseSchema = z.object({
45
+ violations: z.array(ViolationSchema),
46
+ status: z.enum(['pass', 'fail']),
47
+ summary: z.string().min(1),
48
+ });
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Guard config schema (optional .skannr/guard.json)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export const GuardConfigSchema = z.object({
55
+ provider: z.enum(['gemini', 'openai']).default('gemini'),
56
+ model: z.string().default('gemini-2.0-flash-exp'),
57
+ apiKey: z.string().optional(),
58
+ baseUrl: z.string().optional(),
59
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Build symbol-level diff units from staged git changes.
3
+ * Uses Skannr's existing parser to identify which symbols changed,
4
+ * instead of working with raw file diffs.
5
+ */
6
+
7
+ import * as path from 'path';
8
+ import * as fs from 'fs';
9
+ import { execSync } from 'child_process';
10
+ import { getAdapter } from '../languages/registry';
11
+ import { readFileContent } from '../scanner';
12
+ import type { Symbol } from '../languages/LanguageAdapter';
13
+ import type { SymbolDiffUnit } from './types';
14
+
15
+ /** Get the list of staged files (paths relative to root). */
16
+ export function getStagedFiles(root: string): string[] {
17
+ try {
18
+ const output = execSync('git diff --cached --name-only --diff-filter=ACMR', {
19
+ cwd: root,
20
+ encoding: 'utf-8',
21
+ });
22
+ return output.trim().split('\n').filter(Boolean);
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ /** Get the diff text for a specific staged file. */
29
+ function getStagedDiff(root: string, file: string): string {
30
+ try {
31
+ return execSync(`git diff --cached -- "${file}"`, {
32
+ cwd: root,
33
+ encoding: 'utf-8',
34
+ maxBuffer: 5 * 1024 * 1024,
35
+ });
36
+ } catch {
37
+ return '';
38
+ }
39
+ }
40
+
41
+ /** Get the old (HEAD) version of a file, or null if newly added. */
42
+ function getHeadContent(root: string, file: string): string | null {
43
+ try {
44
+ return execSync(`git show HEAD:"${file}"`, {
45
+ cwd: root,
46
+ encoding: 'utf-8',
47
+ maxBuffer: 5 * 1024 * 1024,
48
+ });
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** Extract changed line numbers from a unified diff. */
55
+ function extractChangedLines(diff: string): Set<number> {
56
+ const lines = new Set<number>();
57
+ let currentLine = 0;
58
+
59
+ for (const line of diff.split('\n')) {
60
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)/);
61
+ if (hunkMatch) {
62
+ currentLine = parseInt(hunkMatch[1], 10);
63
+ continue;
64
+ }
65
+ if (line.startsWith('+') && !line.startsWith('+++')) {
66
+ lines.add(currentLine);
67
+ currentLine++;
68
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
69
+ // Deleted lines don't advance the new-file line counter
70
+ } else {
71
+ currentLine++;
72
+ }
73
+ }
74
+
75
+ return lines;
76
+ }
77
+
78
+ /**
79
+ * Determine which symbols in a file were affected by the staged changes.
80
+ * Matches symbols whose line range overlaps any changed line.
81
+ */
82
+ function findAffectedSymbols(
83
+ symbols: Symbol[],
84
+ changedLines: Set<number>,
85
+ allSymbols: Symbol[],
86
+ ): Symbol[] {
87
+ if (changedLines.size === 0) return [];
88
+
89
+ // Build approximate end lines: each symbol extends until the next symbol starts
90
+ const sorted = [...allSymbols].sort((a, b) => a.line - b.line);
91
+ const affected: Symbol[] = [];
92
+
93
+ for (let i = 0; i < sorted.length; i++) {
94
+ const sym = sorted[i];
95
+ const startLine = sym.line;
96
+ const endLine = i + 1 < sorted.length ? sorted[i + 1].line - 1 : startLine + 50;
97
+
98
+ for (let line = startLine; line <= endLine; line++) {
99
+ if (changedLines.has(line)) {
100
+ affected.push(sym);
101
+ break;
102
+ }
103
+ }
104
+ }
105
+
106
+ return affected;
107
+ }
108
+
109
+ /**
110
+ * Build symbol-level diff units for all staged files in the project.
111
+ * This is the primary input to the review runner.
112
+ */
113
+ export function buildSymbolDiffs(root: string): SymbolDiffUnit[] {
114
+ const stagedFiles = getStagedFiles(root);
115
+ const units: SymbolDiffUnit[] = [];
116
+
117
+ for (const relFile of stagedFiles) {
118
+ const absPath = path.join(root, relFile);
119
+ if (!fs.existsSync(absPath)) continue;
120
+
121
+ const adapter = getAdapter(absPath);
122
+ const newContent = readFileContent(absPath);
123
+ if (!newContent) continue;
124
+
125
+ const newSymbols = adapter.extractSymbols(newContent);
126
+ const oldContent = getHeadContent(root, relFile);
127
+ const oldSymbols = oldContent ? adapter.extractSymbols(oldContent) : [];
128
+
129
+ const diff = getStagedDiff(root, relFile);
130
+ const changedLines = extractChangedLines(diff);
131
+
132
+ // Find symbols that overlap changed lines in the new version
133
+ const affected = findAffectedSymbols(newSymbols, changedLines, newSymbols);
134
+
135
+ // Determine change type for each affected symbol
136
+ const oldSymbolNames = new Set(oldSymbols.map((s) => s.name));
137
+
138
+ for (const sym of affected) {
139
+ if (sym.kind === 'export') continue; // skip synthetic export markers
140
+
141
+ const changeType = oldSymbolNames.has(sym.name) ? 'modified' : 'added';
142
+ const oldSym = oldSymbols.find((s) => s.name === sym.name);
143
+
144
+ units.push({
145
+ file: relFile,
146
+ symbol: sym.name,
147
+ changeType,
148
+ oldSignature: oldSym ? `${oldSym.kind} ${oldSym.name}` : undefined,
149
+ newSignature: `${sym.kind} ${sym.name}`,
150
+ callers: [], // populated by call-context module
151
+ callees: [], // populated by call-context module
152
+ diffText: diff,
153
+ });
154
+ }
155
+
156
+ // Handle deleted symbols (in old but not in new)
157
+ if (oldContent) {
158
+ const newSymbolNames = new Set(newSymbols.map((s) => s.name));
159
+ for (const oldSym of oldSymbols) {
160
+ if (oldSym.kind === 'export') continue;
161
+ if (!newSymbolNames.has(oldSym.name)) {
162
+ units.push({
163
+ file: relFile,
164
+ symbol: oldSym.name,
165
+ changeType: 'deleted',
166
+ oldSignature: `${oldSym.kind} ${oldSym.name}`,
167
+ callers: [],
168
+ callees: [],
169
+ diffText: diff,
170
+ });
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ return units;
177
+ }
178
+
179
+ /**
180
+ * Build symbol diffs from a PR diff string (for --pr-mode).
181
+ */
182
+ export function buildSymbolDiffsFromDiff(root: string, diffContent: string): SymbolDiffUnit[] {
183
+ const parseDiff = require('parse-diff');
184
+ const parsedFiles = parseDiff(diffContent);
185
+ const units: SymbolDiffUnit[] = [];
186
+
187
+ for (const file of parsedFiles) {
188
+ const raw = file.to ?? file.from;
189
+ if (!raw || raw === '/dev/null') continue;
190
+ const relFile = raw.replace(/^[ab]\//, '');
191
+ const absPath = path.join(root, relFile);
192
+ if (!fs.existsSync(absPath)) continue;
193
+
194
+ const adapter = getAdapter(absPath);
195
+ const content = readFileContent(absPath);
196
+ if (!content) continue;
197
+
198
+ const symbols = adapter.extractSymbols(content);
199
+ const changedLines = new Set<number>();
200
+
201
+ for (const chunk of file.chunks) {
202
+ for (const change of chunk.changes) {
203
+ if (change.type === 'add' || change.type === 'del') {
204
+ const ln = (change as any).ln;
205
+ if (typeof ln === 'number') changedLines.add(ln);
206
+ }
207
+ }
208
+ }
209
+
210
+ const affected = findAffectedSymbols(symbols, changedLines, symbols);
211
+
212
+ for (const sym of affected) {
213
+ if (sym.kind === 'export') continue;
214
+ units.push({
215
+ file: relFile,
216
+ symbol: sym.name,
217
+ changeType: 'modified',
218
+ newSignature: `${sym.kind} ${sym.name}`,
219
+ callers: [],
220
+ callees: [],
221
+ diffText: file.chunks.map((c: any) => c.content).join('\n'),
222
+ });
223
+ }
224
+ }
225
+
226
+ return units;
227
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared type definitions for the guard module.
3
+ */
4
+
5
+ /** Severity levels for rules and violations. */
6
+ export type Severity = 'low' | 'medium' | 'high' | 'critical';
7
+
8
+ /** A team-defined review rule. */
9
+ export interface GuardRule {
10
+ id: string;
11
+ description: string;
12
+ severity: Severity;
13
+ fixable: boolean;
14
+ category: string;
15
+ }
16
+
17
+ /** A single symbol-level diff unit sent to the LLM for review. */
18
+ export interface SymbolDiffUnit {
19
+ file: string;
20
+ symbol: string;
21
+ changeType: 'added' | 'modified' | 'deleted';
22
+ oldSignature?: string;
23
+ newSignature?: string;
24
+ callers: string[];
25
+ callees: string[];
26
+ diffText: string;
27
+ }
28
+
29
+ /** A violation found by the review. */
30
+ export interface Violation {
31
+ rule_id: string;
32
+ file: string;
33
+ symbol: string;
34
+ line_start: number;
35
+ line_end: number;
36
+ severity: Severity;
37
+ confidence: number;
38
+ fixable: boolean;
39
+ message: string;
40
+ suggested_fix?: string;
41
+ }
42
+
43
+ /** The structured response contract from the LLM. */
44
+ export interface ReviewResponse {
45
+ violations: Violation[];
46
+ status: 'pass' | 'fail';
47
+ summary: string;
48
+ }
49
+
50
+ /** Result of a full guard run. */
51
+ export interface GuardResult {
52
+ response: ReviewResponse;
53
+ rulesUsed: GuardRule[];
54
+ symbolsReviewed: number;
55
+ durationMs: number;
56
+ }
57
+
58
+ /** Guard configuration (provider, model, etc.). */
59
+ export interface GuardConfig {
60
+ /** LLM provider: 'gemini' | 'openai' */
61
+ provider: 'gemini' | 'openai';
62
+ /** Model name (e.g. 'gemini-2.0-flash-exp', 'gpt-4o'). */
63
+ model: string;
64
+ /** API key (resolved from env if not set). */
65
+ apiKey?: string;
66
+ /** OpenAI-compatible base URL (for openai provider). */
67
+ baseUrl?: string;
68
+ }
package/src/mcp-server.ts CHANGED
@@ -84,6 +84,33 @@ export async function startMcpServer(): Promise<void> {
84
84
  required: ['root'],
85
85
  },
86
86
  },
87
+ {
88
+ name: 'guard_review',
89
+ description:
90
+ 'Review staged or diff changes against team-defined rules. Returns structured violations with severity, confidence, and fixability.',
91
+ inputSchema: {
92
+ type: 'object',
93
+ properties: {
94
+ root: {
95
+ type: 'string',
96
+ description: 'Absolute path to the repo root',
97
+ },
98
+ diff: {
99
+ type: 'string',
100
+ description: 'Unified diff content (if omitted, reviews staged files)',
101
+ },
102
+ diff_only: {
103
+ type: 'boolean',
104
+ description: 'Skip cross-file context for faster review (default: false)',
105
+ },
106
+ fix: {
107
+ type: 'boolean',
108
+ description: 'Apply auto-fixes for fixable violations (default: false)',
109
+ },
110
+ },
111
+ required: ['root'],
112
+ },
113
+ },
87
114
  ],
88
115
  }));
89
116
 
@@ -143,6 +170,28 @@ export async function startMcpServer(): Promise<void> {
143
170
  };
144
171
  }
145
172
 
173
+ if (toolName === 'guard_review') {
174
+ const { runGuard, formatGuardJson } = await import('./guard/index');
175
+ const root = String(args.root ?? '');
176
+ const diffContent =
177
+ typeof args.diff === 'string' && args.diff.length > 0
178
+ ? args.diff
179
+ : undefined;
180
+ const diffOnly = args.diff_only === true;
181
+ const fix = args.fix === true;
182
+
183
+ const { result } = await runGuard({
184
+ root,
185
+ diffOnly,
186
+ fix,
187
+ ...(diffContent ? { prMode: false } : {}),
188
+ });
189
+
190
+ return {
191
+ content: [{ type: 'text', text: formatGuardJson(result) }],
192
+ };
193
+ }
194
+
146
195
  throw new Error(`Unknown tool: ${toolName}`);
147
196
  });
148
197