skannr 0.2.0 → 0.2.2

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 (73) 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 +156 -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/index.d.ts +1 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -2
  46. package/dist/index.js.map +1 -1
  47. package/dist/mcp-server.d.ts.map +1 -1
  48. package/dist/mcp-server.js +44 -0
  49. package/dist/mcp-server.js.map +1 -1
  50. package/dist/scanner.d.ts +0 -1
  51. package/dist/scanner.d.ts.map +1 -1
  52. package/dist/scanner.js +0 -7
  53. package/dist/scanner.js.map +1 -1
  54. package/package.json +3 -2
  55. package/src/cli.ts +101 -0
  56. package/src/guard/call-context.ts +101 -0
  57. package/src/guard/fix-applier.ts +172 -0
  58. package/src/guard/hook-installer.ts +96 -0
  59. package/src/guard/index.ts +134 -0
  60. package/src/guard/provider.ts +209 -0
  61. package/src/guard/review-runner.ts +110 -0
  62. package/src/guard/rules-loader.ts +131 -0
  63. package/src/guard/schema.ts +59 -0
  64. package/src/guard/symbol-diff.ts +227 -0
  65. package/src/guard/types.ts +68 -0
  66. package/src/index.ts +2 -2
  67. package/src/mcp-server.ts +49 -0
  68. package/src/scanner.ts +0 -7
  69. package/gemini-extension/GEMINI.md +0 -26
  70. package/gemini-extension/gemini-extension.json +0 -12
  71. package/gemini-extension/package.json +0 -14
  72. package/gemini-extension/src/server.ts +0 -69
  73. package/gemini-extension/tsconfig.json +0 -14
@@ -0,0 +1,209 @@
1
+ /**
2
+ * LLM provider abstraction for guard reviews.
3
+ * Supports Gemini (existing dep) and OpenAI-compatible APIs.
4
+ * Enforces structured JSON output — never parses free text.
5
+ */
6
+
7
+ import { ReviewResponseSchema } from './schema';
8
+ import type { GuardConfig, GuardRule, ReviewResponse, SymbolDiffUnit } from './types';
9
+
10
+ /** Maximum number of retry attempts when LLM returns invalid JSON. */
11
+ const MAX_RETRIES = 1;
12
+
13
+ /**
14
+ * Build the system prompt for the guard review.
15
+ */
16
+ function buildSystemPrompt(rules: GuardRule[]): string {
17
+ const rulesText = rules
18
+ .map((r) => `- [${r.id}] (${r.severity}, fixable=${r.fixable}): ${r.description}`)
19
+ .join('\n');
20
+
21
+ return [
22
+ 'You are a code review tool. Review the provided symbol-level diffs against the rules below.',
23
+ 'For each violation found, output a structured JSON object matching the exact schema.',
24
+ '',
25
+ 'Rules:',
26
+ rulesText,
27
+ '',
28
+ 'Response format (strict JSON, no text before or after):',
29
+ '{',
30
+ ' "violations": [{ "rule_id": "...", "file": "...", "symbol": "...", "line_start": N, "line_end": N, "severity": "...", "confidence": 0.0-1.0, "fixable": bool, "message": "...", "suggested_fix": "..." }],',
31
+ ' "status": "pass" | "fail",',
32
+ ' "summary": "..."',
33
+ '}',
34
+ '',
35
+ 'IMPORTANT:',
36
+ '- Only flag violations for rules listed above.',
37
+ '- The "fixable" field MUST match the rule definition — do not override it.',
38
+ '- "confidence" reflects how certain you are this is a real violation (0.0-1.0).',
39
+ '- "suggested_fix" is required only when fixable=true.',
40
+ '- If no violations found, return status "pass" with an empty violations array.',
41
+ '- Return ONLY the JSON object. No markdown, no explanation, no preamble.',
42
+ ].join('\n');
43
+ }
44
+
45
+ /**
46
+ * Build the user prompt with symbol diff units.
47
+ */
48
+ function buildUserPrompt(units: SymbolDiffUnit[]): string {
49
+ const parts: string[] = ['Review these symbol-level changes:\n'];
50
+
51
+ for (const unit of units) {
52
+ parts.push(`--- ${unit.file} :: ${unit.symbol} (${unit.changeType}) ---`);
53
+ if (unit.oldSignature) parts.push(`Old: ${unit.oldSignature}`);
54
+ if (unit.newSignature) parts.push(`New: ${unit.newSignature}`);
55
+ if (unit.callers.length > 0) parts.push(`Callers: ${unit.callers.join(', ')}`);
56
+ if (unit.callees.length > 0) parts.push(`Callees: ${unit.callees.join(', ')}`);
57
+ parts.push('');
58
+ // Include a trimmed diff (first 100 lines to avoid token explosion)
59
+ const diffLines = unit.diffText.split('\n').slice(0, 100);
60
+ parts.push(diffLines.join('\n'));
61
+ parts.push('');
62
+ }
63
+
64
+ return parts.join('\n');
65
+ }
66
+
67
+ /**
68
+ * Extract JSON from a response that might have text before/after.
69
+ * This handles the exact failure mode from gga: providers prepending
70
+ * acknowledgment text before the actual JSON response.
71
+ */
72
+ function extractJson(text: string): string {
73
+ // Try to find a JSON object in the response
74
+ const firstBrace = text.indexOf('{');
75
+ const lastBrace = text.lastIndexOf('}');
76
+ if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
77
+ throw new Error('No JSON object found in response');
78
+ }
79
+ return text.slice(firstBrace, lastBrace + 1);
80
+ }
81
+
82
+ /**
83
+ * Call Gemini API with structured output enforcement.
84
+ */
85
+ async function callGemini(
86
+ config: GuardConfig,
87
+ systemPrompt: string,
88
+ userPrompt: string,
89
+ ): Promise<string> {
90
+ const { GoogleGenerativeAI } = await import('@google/generative-ai');
91
+
92
+ if (!config.apiKey) {
93
+ throw new Error('No API key. Set GEMINI_API_KEY or SKANNR_GUARD_API_KEY.');
94
+ }
95
+
96
+ const genAI = new GoogleGenerativeAI(config.apiKey);
97
+ const model = genAI.getGenerativeModel({
98
+ model: config.model,
99
+ generationConfig: {
100
+ responseMimeType: 'application/json',
101
+ },
102
+ });
103
+
104
+ const result = await model.generateContent({
105
+ contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
106
+ systemInstruction: { role: 'system', parts: [{ text: systemPrompt }] },
107
+ });
108
+
109
+ return result.response.text();
110
+ }
111
+
112
+ /**
113
+ * Call OpenAI-compatible API with structured output enforcement.
114
+ */
115
+ async function callOpenAI(
116
+ config: GuardConfig,
117
+ systemPrompt: string,
118
+ userPrompt: string,
119
+ ): Promise<string> {
120
+ const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
121
+ const url = `${baseUrl}/chat/completions`;
122
+
123
+ if (!config.apiKey) {
124
+ throw new Error('No API key. Set OPENAI_API_KEY or SKANNR_GUARD_API_KEY.');
125
+ }
126
+
127
+ const response = await fetch(url, {
128
+ method: 'POST',
129
+ headers: {
130
+ 'Content-Type': 'application/json',
131
+ 'Authorization': `Bearer ${config.apiKey}`,
132
+ },
133
+ body: JSON.stringify({
134
+ model: config.model,
135
+ messages: [
136
+ { role: 'system', content: systemPrompt },
137
+ { role: 'user', content: userPrompt },
138
+ ],
139
+ response_format: { type: 'json_object' },
140
+ temperature: 0.1,
141
+ }),
142
+ });
143
+
144
+ if (!response.ok) {
145
+ throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
146
+ }
147
+
148
+ const data = await response.json() as any;
149
+ return data.choices?.[0]?.message?.content ?? '';
150
+ }
151
+
152
+ /**
153
+ * Run the LLM review and return a validated response.
154
+ * Retries once on validation failure with a correction prompt.
155
+ */
156
+ export async function runLlmReview(
157
+ config: GuardConfig,
158
+ rules: GuardRule[],
159
+ units: SymbolDiffUnit[],
160
+ ): Promise<ReviewResponse> {
161
+ const systemPrompt = buildSystemPrompt(rules);
162
+ const userPrompt = buildUserPrompt(units);
163
+
164
+ let lastError: string | null = null;
165
+
166
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
167
+ const prompt = attempt === 0
168
+ ? userPrompt
169
+ : `${userPrompt}\n\n[RETRY: Your last response didn't match the required schema. Error: ${lastError}. Return ONLY valid JSON matching the schema.]`;
170
+
171
+ let rawResponse: string;
172
+ try {
173
+ rawResponse = config.provider === 'openai'
174
+ ? await callOpenAI(config, systemPrompt, prompt)
175
+ : await callGemini(config, systemPrompt, prompt);
176
+ } catch (err) {
177
+ throw new Error(
178
+ `Provider call failed (${config.provider}): ${err instanceof Error ? err.message : String(err)}`,
179
+ );
180
+ }
181
+
182
+ // Extract and validate JSON
183
+ try {
184
+ const jsonStr = extractJson(rawResponse);
185
+ const parsed = JSON.parse(jsonStr);
186
+ const result = ReviewResponseSchema.safeParse(parsed);
187
+
188
+ if (result.success) {
189
+ // Enforce: fixable field must match the rule definition
190
+ for (const violation of result.data.violations) {
191
+ const rule = rules.find((r) => r.id === violation.rule_id);
192
+ if (rule) {
193
+ violation.fixable = rule.fixable;
194
+ }
195
+ }
196
+ return result.data;
197
+ }
198
+
199
+ lastError = result.error.issues
200
+ .map((i) => `${i.path.join('.')}: ${i.message}`)
201
+ .join('; ');
202
+ } catch (err) {
203
+ lastError = err instanceof Error ? err.message : String(err);
204
+ }
205
+ }
206
+
207
+ // All retries exhausted
208
+ throw new Error(`LLM response failed schema validation after ${MAX_RETRIES + 1} attempts: ${lastError}`);
209
+ }
@@ -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,131 @@
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
+ /** Built-in default rules used when no .skannr/rules.json exists. */
20
+ const DEFAULT_RULES: GuardRule[] = [
21
+ {
22
+ id: 'no-any-type',
23
+ description: 'Do not use the `any` type in TypeScript; use explicit types or `unknown`.',
24
+ severity: 'high',
25
+ fixable: true,
26
+ category: 'type-safety',
27
+ },
28
+ {
29
+ id: 'no-console-log',
30
+ description: 'Remove console.log statements before committing; use a proper logger in production code.',
31
+ severity: 'medium',
32
+ fixable: true,
33
+ category: 'code-quality',
34
+ },
35
+ {
36
+ id: 'error-handling',
37
+ description: 'Async functions that can throw should have error handling (try/catch or .catch()).',
38
+ severity: 'high',
39
+ fixable: false,
40
+ category: 'reliability',
41
+ },
42
+ {
43
+ id: 'no-hardcoded-secrets',
44
+ description: 'Do not hardcode API keys, passwords, tokens, or connection strings. Use environment variables.',
45
+ severity: 'critical',
46
+ fixable: false,
47
+ category: 'security',
48
+ },
49
+ {
50
+ id: 'function-complexity',
51
+ description: 'Functions should not exceed ~40 lines. Extract helper functions for complex logic.',
52
+ severity: 'medium',
53
+ fixable: false,
54
+ category: 'maintainability',
55
+ },
56
+ {
57
+ id: 'unused-imports',
58
+ description: 'Remove unused imports.',
59
+ severity: 'low',
60
+ fixable: true,
61
+ category: 'code-quality',
62
+ },
63
+ ];
64
+
65
+ /** Load and validate rules from the project root. Falls back to defaults if no file exists. */
66
+ export function loadRules(root: string): GuardRule[] {
67
+ const rulesPath = findRulesFile(root);
68
+ if (!rulesPath) {
69
+ return DEFAULT_RULES;
70
+ }
71
+
72
+ const raw = fs.readFileSync(rulesPath, 'utf-8');
73
+ let parsed: unknown;
74
+
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ } catch {
78
+ throw new Error(`Failed to parse rules file as JSON: ${rulesPath}`);
79
+ }
80
+
81
+ const result = RulesFileSchema.safeParse(parsed);
82
+ if (!result.success) {
83
+ const errors = result.error.issues
84
+ .map((i) => ` - ${i.path.join('.')}: ${i.message}`)
85
+ .join('\n');
86
+ throw new Error(`Invalid rules file (${rulesPath}):\n${errors}`);
87
+ }
88
+
89
+ return result.data.rules;
90
+ }
91
+
92
+ /** Load guard provider configuration from .skannr/guard.json or env vars. */
93
+ export function loadGuardConfig(root: string): GuardConfig {
94
+ const configPath = path.join(root, '.skannr', 'guard.json');
95
+
96
+ let rawConfig: Record<string, unknown> = {};
97
+ if (fs.existsSync(configPath)) {
98
+ try {
99
+ rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
100
+ } catch {
101
+ // Fall through to defaults + env
102
+ }
103
+ }
104
+
105
+ // Env vars override file config
106
+ const provider = (process.env.SKANNR_GUARD_PROVIDER || rawConfig.provider || 'gemini') as string;
107
+ const model = (process.env.SKANNR_GUARD_MODEL || rawConfig.model || 'gemini-2.0-flash-exp') as string;
108
+ const apiKey = process.env.SKANNR_GUARD_API_KEY
109
+ || process.env.GEMINI_API_KEY
110
+ || process.env.OPENAI_API_KEY
111
+ || (rawConfig.apiKey as string | undefined);
112
+ const baseUrl = process.env.OPENAI_BASE_URL || (rawConfig.baseUrl as string | undefined);
113
+
114
+ const result = GuardConfigSchema.safeParse({ provider, model, apiKey, baseUrl });
115
+ if (!result.success) {
116
+ throw new Error(`Invalid guard config: ${result.error.issues.map((i) => i.message).join(', ')}`);
117
+ }
118
+
119
+ return result.data as GuardConfig;
120
+ }
121
+
122
+ /** Find the first existing rules file in the project. */
123
+ function findRulesFile(root: string): string | null {
124
+ for (const rel of RULES_PATHS) {
125
+ const full = path.join(root, rel);
126
+ if (fs.existsSync(full)) {
127
+ return full;
128
+ }
129
+ }
130
+ return null;
131
+ }
@@ -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
+ });