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,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/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import * as path from 'path';
6
- import { discoverModules, readFileContent, scanFiles, scanRocketChatFiles, scanTypeScriptFiles } from './scanner';
6
+ import { discoverModules, readFileContent, scanFiles, scanTypeScriptFiles } from './scanner';
7
7
  import { rankFiles } from './ranker';
8
8
  import { rankFilesEnhanced } from './ranker-enhanced';
9
9
  import { buildSkeletonForFile } from './skeletonizer';
@@ -194,7 +194,7 @@ function resolveExtensions(
194
194
  export { rankFiles } from './ranker';
195
195
  export { rankFilesEnhanced } from './ranker-enhanced';
196
196
  export { buildSkeletonForFile } from './skeletonizer';
197
- export { scanTypeScriptFiles, scanRocketChatFiles, scanFiles, discoverModules } from './scanner';
197
+ export { scanTypeScriptFiles, scanFiles, discoverModules } from './scanner';
198
198
  export { countTokens } from './tokenizer';
199
199
  export {
200
200
  buildSkeletonWithMapping,
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
 
package/src/scanner.ts CHANGED
@@ -145,13 +145,6 @@ export function scanTypeScriptFiles(rootDir: string): string[] {
145
145
  return scanFiles(rootDir, { extensions: ['.ts', '.tsx'] });
146
146
  }
147
147
 
148
- export function scanRocketChatFiles(rootDir: string, moduleKeys?: string[]): string[] {
149
- return scanFiles(rootDir, {
150
- extensions: ['.ts', '.tsx'],
151
- moduleKeys,
152
- });
153
- }
154
-
155
148
  export function readFileContent(filePath: string): string | null {
156
149
  try {
157
150
  return fs.readFileSync(filePath, 'utf-8');
@@ -1,26 +0,0 @@
1
- # Universal Code Analyzer
2
-
3
- You have a tool called `scan_codebase` that scans any repository and returns compressed **skeletons** — function signatures and types without the full implementation bodies.
4
-
5
- ## When to use it
6
-
7
- Use `scan_codebase` before reading source files. It tells you which files are relevant and shows their structure without burning tokens on full file contents.
8
-
9
- ## Parameters
10
-
11
- - `question` (**required**): what you want to understand, e.g. `"how are messages sent?"`
12
- - `root` (**required**): absolute path to the repository, e.g. `/home/user/my-project`
13
- - `limit`: how many files to return (default: 10)
14
- - `modules`: narrow the search to specific modules or directories
15
-
16
- ## Example
17
-
18
- ```
19
- scan_codebase({
20
- question: "How does the authentication flow work?",
21
- root: "/home/user/my-project",
22
- modules: ["auth"]
23
- })
24
- ```
25
-
26
- The response includes each file's path, relevance score, skeleton code, and token counts so you can decide what to read in full.
@@ -1,12 +0,0 @@
1
- {
2
- "name": "code-analyzer",
3
- "version": "0.1.0",
4
- "description": "Analyze any codebase and return token-efficient structural skeletons",
5
- "mcpServers": {
6
- "code-analyzer": {
7
- "command": "node",
8
- "args": ["${extensionPath}/dist/server.js"]
9
- }
10
- },
11
- "contextFileName": "GEMINI.md"
12
- }
@@ -1,14 +0,0 @@
1
- {
2
- "name": "code-analyzer-extension",
3
- "version": "0.1.0",
4
- "scripts": {
5
- "build": "tsc"
6
- },
7
- "dependencies": {
8
- "@modelcontextprotocol/sdk": "^1.0.0"
9
- },
10
- "devDependencies": {
11
- "@types/node": "^20.0.0",
12
- "typescript": "^5.3.0"
13
- }
14
- }
@@ -1,69 +0,0 @@
1
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
- import { analyzeProject } from '../../dist/index';
5
-
6
- const server = new Server(
7
- { name: 'code-analyzer', version: '0.1.0' },
8
- { capabilities: { tools: {} } }
9
- );
10
-
11
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
12
- tools: [
13
- {
14
- name: 'scan_codebase',
15
- description: 'Analyzes any codebase to answer questions about its structure and architecture. Scans files, generates structural skeletons, and ranks results using hybrid retrieval. Supports TypeScript, JavaScript, and Python. Pass the root path of any repository.',
16
- inputSchema: {
17
- type: 'object',
18
- properties: {
19
- question: {
20
- type: 'string',
21
- description: 'What you want to understand about the codebase',
22
- },
23
- root: {
24
- type: 'string',
25
- description: 'Absolute path to the repository root',
26
- },
27
- limit: {
28
- type: 'number',
29
- description: 'Max number of files to return (default: 10)',
30
- },
31
- modules: {
32
- type: 'array',
33
- items: { type: 'string' },
34
- description: 'Optional module keys (or directory names) to narrow the search scope',
35
- },
36
- },
37
- required: ['question', 'root'],
38
- },
39
- },
40
- ],
41
- }));
42
-
43
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
44
- if (request.params.name !== 'scan_codebase') {
45
- throw new Error(`Unknown tool: ${request.params.name}`);
46
- }
47
-
48
- const { question, root, limit = 10, modules } = request.params.arguments as {
49
- question: string;
50
- root: string;
51
- limit?: number;
52
- modules?: string[];
53
- };
54
-
55
- const result = await analyzeProject({
56
- question,
57
- root,
58
- limit,
59
- moduleKeys: modules,
60
- enhancedRanking: true,
61
- });
62
-
63
- return {
64
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
65
- };
66
- });
67
-
68
- const transport = new StdioServerTransport();
69
- server.connect(transport).catch(console.error);
@@ -1,14 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "commonjs",
5
- "outDir": "./dist",
6
- "rootDir": "./src",
7
- "strict": true,
8
- "esModuleInterop": true,
9
- "skipLibCheck": true,
10
- "moduleResolution": "node",
11
- "types": ["node"]
12
- },
13
- "include": ["src/**/*"]
14
- }