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
package/src/cli.ts CHANGED
@@ -483,6 +483,102 @@ cacheCommand
483
483
  console.log(` Dir: ${cacheManager.getCacheDir()}\n`);
484
484
  });
485
485
 
486
+ // ---------------------------------------------------------------------------
487
+ // Subcommand: guard
488
+ // ---------------------------------------------------------------------------
489
+ const guardCommand = program
490
+ .command('guard')
491
+ .description('Review staged changes against team-defined rules')
492
+ .option('--root <path>', 'project root directory', process.cwd())
493
+ .option('--fix', 'apply auto-fixes for fixable violations')
494
+ .option('--dry-run', 'show what --fix would change without writing')
495
+ .option('--pr-mode', 'review full PR diff vs base branch')
496
+ .option('--diff-only', 'skip cross-file context (faster, cheaper)')
497
+ .option('--no-cache', 'ignore symbol cache')
498
+ .option('--json', 'output as JSON')
499
+ .action(async (cmdOpts: {
500
+ root: string;
501
+ fix?: boolean;
502
+ dryRun?: boolean;
503
+ prMode?: boolean;
504
+ diffOnly?: boolean;
505
+ noCache?: boolean;
506
+ json?: boolean;
507
+ }) => {
508
+ try {
509
+ const { runGuard, formatGuardText, formatGuardJson } = await import('./guard/index');
510
+ const { result, exitCode } = await runGuard({
511
+ root: cmdOpts.root,
512
+ fix: cmdOpts.fix,
513
+ dryRun: cmdOpts.dryRun,
514
+ prMode: cmdOpts.prMode,
515
+ diffOnly: cmdOpts.diffOnly,
516
+ noCache: cmdOpts.noCache,
517
+ json: cmdOpts.json,
518
+ });
519
+
520
+ const output = cmdOpts.json
521
+ ? formatGuardJson(result)
522
+ : formatGuardText(result);
523
+
524
+ process.stdout.write(output + (output.endsWith('\n') ? '' : '\n'));
525
+ process.exit(exitCode);
526
+ } catch (error) {
527
+ console.error(
528
+ 'Error running guard:',
529
+ error instanceof Error ? error.message : error,
530
+ );
531
+ process.exit(2);
532
+ }
533
+ });
534
+
535
+ guardCommand
536
+ .command('install')
537
+ .description('Install git pre-commit hook')
538
+ .option('--root <path>', 'project root directory', process.cwd())
539
+ .action(async (cmdOpts: { root: string }) => {
540
+ const { installHook } = await import('./guard/index');
541
+ const { installed, message } = installHook(path.resolve(cmdOpts.root));
542
+ console.log(message);
543
+ process.exit(installed ? 0 : 1);
544
+ });
545
+
546
+ guardCommand
547
+ .command('uninstall')
548
+ .description('Remove git pre-commit hook')
549
+ .option('--root <path>', 'project root directory', process.cwd())
550
+ .action(async (cmdOpts: { root: string }) => {
551
+ const { uninstallHook } = await import('./guard/index');
552
+ const { removed, message } = uninstallHook(path.resolve(cmdOpts.root));
553
+ console.log(message);
554
+ process.exit(removed ? 0 : 1);
555
+ });
556
+
557
+ guardCommand
558
+ .command('config')
559
+ .description('Show loaded rules and provider config')
560
+ .option('--root <path>', 'project root directory', process.cwd())
561
+ .action(async (cmdOpts: { root: string }) => {
562
+ const { loadRules, loadGuardConfig } = await import('./guard/index');
563
+ const root = path.resolve(cmdOpts.root);
564
+ try {
565
+ const rules = loadRules(root);
566
+ const config = loadGuardConfig(root);
567
+ console.log('\n Guard Config:');
568
+ console.log(` Provider: ${config.provider}`);
569
+ console.log(` Model: ${config.model}`);
570
+ console.log(` API Key: ${config.apiKey ? '***' + config.apiKey.slice(-4) : 'not set'}`);
571
+ console.log(`\n Rules (${rules.length}):`);
572
+ for (const r of rules) {
573
+ console.log(` [${r.id}] ${r.severity} | fixable=${r.fixable} | ${r.description}`);
574
+ }
575
+ console.log('');
576
+ } catch (error) {
577
+ console.error(error instanceof Error ? error.message : error);
578
+ process.exit(2);
579
+ }
580
+ });
581
+
486
582
  // ---------------------------------------------------------------------------
487
583
  // Help
488
584
  // ---------------------------------------------------------------------------
@@ -499,6 +595,11 @@ Examples:
499
595
  skannr risk --diff feature.patch Impact of a patch file
500
596
  skannr risk -n 3 --json 3 hops, JSON for CI
501
597
 
598
+ skannr guard Review staged changes against rules
599
+ skannr guard --fix Auto-fix fixable violations
600
+ skannr guard --pr-mode --json Review PR diff, JSON output
601
+ skannr guard install Install pre-commit hook
602
+
502
603
  skannr report Repo health summary
503
604
  skannr agent Interactive mode
504
605
  skannr cache stats Cache hit rate
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Pull caller/callee context from the existing dependency graph.
3
+ * Enriches SymbolDiffUnit[] with cross-file relationship information
4
+ * so the LLM can detect breakages beyond the changed file.
5
+ */
6
+
7
+ import * as path from 'path';
8
+ import { scanFiles, readFileContent } from '../scanner';
9
+ import { analyzeDependencyGraph } from '../ranker-enhanced';
10
+ import { getAdapter } from '../languages/registry';
11
+ import { buildReverseGraph } from '../blast-radius';
12
+ import { loadConfig } from '../config';
13
+ import type { SymbolDiffUnit } from './types';
14
+
15
+ /**
16
+ * Enrich symbol diff units with caller and callee context.
17
+ * Uses the project's dependency graph to find:
18
+ * - callers: files that import the changed file (reverse deps)
19
+ * - callees: files that the changed file imports (forward deps)
20
+ */
21
+ export function enrichWithCallContext(
22
+ units: SymbolDiffUnit[],
23
+ root: string,
24
+ ): SymbolDiffUnit[] {
25
+ if (units.length === 0) return units;
26
+
27
+ // Scan project files and build dependency graph
28
+ const config = loadConfig(root);
29
+ const allFiles = scanFiles(root, {
30
+ extensions: config.extensions,
31
+ exclude: config.exclude,
32
+ });
33
+
34
+ const forwardGraph = analyzeDependencyGraph(allFiles, root);
35
+ const reverseGraph = buildReverseGraph(forwardGraph);
36
+
37
+ // Group units by file
38
+ const fileSet = new Set(units.map((u) => u.file));
39
+
40
+ for (const unit of units) {
41
+ const fileRel = unit.file;
42
+ const fileNoExt = fileRel.replace(/\.[^.]+$/, '');
43
+ const fileBase = path.basename(fileRel, path.extname(fileRel));
44
+
45
+ // Find callers (files that import this file)
46
+ const callers: string[] = [];
47
+ for (const [key, importers] of reverseGraph.entries()) {
48
+ const normalizedKey = key.split(path.sep).join('/');
49
+ if (
50
+ normalizedKey === fileRel ||
51
+ normalizedKey === fileNoExt ||
52
+ normalizedKey.endsWith('/' + fileBase)
53
+ ) {
54
+ for (const importer of importers) {
55
+ callers.push(importer);
56
+ }
57
+ }
58
+ }
59
+
60
+ // Find callees (files this file imports)
61
+ const callees: string[] = [];
62
+ const normalizedFileRel = fileRel.split(path.sep).join('/');
63
+ for (const [key, imports] of forwardGraph.entries()) {
64
+ const normalizedKey = key.split(path.sep).join('/');
65
+ if (normalizedKey === normalizedFileRel) {
66
+ callees.push(...imports);
67
+ break;
68
+ }
69
+ }
70
+
71
+ // Format as "file:symbol" where possible
72
+ unit.callers = [...new Set(callers)].slice(0, 10).map((c) => formatCallerRef(c, root));
73
+ unit.callees = [...new Set(callees)].slice(0, 10);
74
+ }
75
+
76
+ return units;
77
+ }
78
+
79
+ /**
80
+ * Format a caller file reference, attempting to identify which symbols
81
+ * in that file actually reference the changed file.
82
+ */
83
+ function formatCallerRef(callerFile: string, root: string): string {
84
+ const absPath = path.join(root, callerFile);
85
+ const content = readFileContent(absPath);
86
+ if (!content) return callerFile;
87
+
88
+ const adapter = getAdapter(absPath);
89
+ const symbols = adapter.extractSymbols(content);
90
+
91
+ // Return the file with its top exported symbols for context
92
+ const topSymbols = symbols
93
+ .filter((s) => s.kind === 'function' || s.kind === 'class')
94
+ .slice(0, 3)
95
+ .map((s) => s.name);
96
+
97
+ if (topSymbols.length > 0) {
98
+ return `${callerFile}:${topSymbols.join(',')}`;
99
+ }
100
+ return callerFile;
101
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Fix applier — applies suggested fixes ONLY for fixable:true violations
3
+ * from the current review run. Never expands scope beyond what was reviewed.
4
+ *
5
+ * Two-phase consent:
6
+ * 1. Default: show a dry-run diff of what would change
7
+ * 2. Only write when explicitly confirmed (--fix flag on same invocation)
8
+ */
9
+
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import type { Violation } from './types';
13
+
14
+ export interface FixResult {
15
+ file: string;
16
+ symbol: string;
17
+ rule_id: string;
18
+ applied: boolean;
19
+ diff: string;
20
+ }
21
+
22
+ /**
23
+ * Filter violations to only fixable ones with a suggested fix.
24
+ */
25
+ export function getFixableViolations(violations: Violation[]): Violation[] {
26
+ return violations.filter((v) => v.fixable && v.suggested_fix);
27
+ }
28
+
29
+ /**
30
+ * Generate a dry-run preview of what fixes would be applied.
31
+ * Does NOT write to disk.
32
+ */
33
+ export function previewFixes(violations: Violation[], root: string): FixResult[] {
34
+ const fixable = getFixableViolations(violations);
35
+ const results: FixResult[] = [];
36
+
37
+ for (const v of fixable) {
38
+ const absPath = path.join(root, v.file);
39
+ if (!fs.existsSync(absPath)) {
40
+ results.push({
41
+ file: v.file,
42
+ symbol: v.symbol,
43
+ rule_id: v.rule_id,
44
+ applied: false,
45
+ diff: `[file not found: ${v.file}]`,
46
+ });
47
+ continue;
48
+ }
49
+
50
+ const content = fs.readFileSync(absPath, 'utf-8');
51
+ const lines = content.split('\n');
52
+ const startIdx = Math.max(0, v.line_start - 1);
53
+ const endIdx = Math.min(lines.length, v.line_end);
54
+ const originalLines = lines.slice(startIdx, endIdx);
55
+
56
+ results.push({
57
+ file: v.file,
58
+ symbol: v.symbol,
59
+ rule_id: v.rule_id,
60
+ applied: false,
61
+ diff: [
62
+ ` ${v.file}:${v.line_start}-${v.line_end} (${v.rule_id})`,
63
+ ` - ${originalLines.join('\n - ')}`,
64
+ ` + ${v.suggested_fix}`,
65
+ ].join('\n'),
66
+ });
67
+ }
68
+
69
+ return results;
70
+ }
71
+
72
+ /**
73
+ * Apply fixes to disk. Only touches fixable:true violations with a suggested_fix.
74
+ * Returns a summary of what was applied.
75
+ *
76
+ * IMPORTANT: This only applies fixes from violations passed in — never does a
77
+ * fresh analysis pass. The caller is responsible for scoping.
78
+ */
79
+ export function applyFixes(violations: Violation[], root: string): FixResult[] {
80
+ const fixable = getFixableViolations(violations);
81
+ const results: FixResult[] = [];
82
+
83
+ // Group by file to minimize reads/writes
84
+ const byFile = new Map<string, Violation[]>();
85
+ for (const v of fixable) {
86
+ const existing = byFile.get(v.file) ?? [];
87
+ existing.push(v);
88
+ byFile.set(v.file, existing);
89
+ }
90
+
91
+ for (const [file, fileViolations] of byFile.entries()) {
92
+ const absPath = path.join(root, file);
93
+ if (!fs.existsSync(absPath)) {
94
+ for (const v of fileViolations) {
95
+ results.push({
96
+ file: v.file,
97
+ symbol: v.symbol,
98
+ rule_id: v.rule_id,
99
+ applied: false,
100
+ diff: `[file not found: ${v.file}]`,
101
+ });
102
+ }
103
+ continue;
104
+ }
105
+
106
+ let content = fs.readFileSync(absPath, 'utf-8');
107
+ const lines = content.split('\n');
108
+
109
+ // Apply fixes in reverse line order to avoid offset drift
110
+ const sorted = [...fileViolations].sort((a, b) => b.line_start - a.line_start);
111
+
112
+ for (const v of sorted) {
113
+ if (!v.suggested_fix) continue;
114
+
115
+ const startIdx = Math.max(0, v.line_start - 1);
116
+ const endIdx = Math.min(lines.length, v.line_end);
117
+ const originalLines = lines.slice(startIdx, endIdx);
118
+
119
+ // Replace the affected lines with the suggested fix
120
+ lines.splice(startIdx, endIdx - startIdx, v.suggested_fix);
121
+
122
+ results.push({
123
+ file: v.file,
124
+ symbol: v.symbol,
125
+ rule_id: v.rule_id,
126
+ applied: true,
127
+ diff: [
128
+ ` ${v.file}:${v.line_start}-${v.line_end} (${v.rule_id})`,
129
+ ` - ${originalLines.join('\n - ')}`,
130
+ ` + ${v.suggested_fix}`,
131
+ ].join('\n'),
132
+ });
133
+ }
134
+
135
+ // Write the modified content back
136
+ content = lines.join('\n');
137
+ fs.writeFileSync(absPath, content, 'utf-8');
138
+ }
139
+
140
+ return results;
141
+ }
142
+
143
+ /**
144
+ * Format fix results for terminal output.
145
+ */
146
+ export function formatFixResults(results: FixResult[], dryRun: boolean): string {
147
+ if (results.length === 0) {
148
+ return ' No fixable violations to apply.';
149
+ }
150
+
151
+ const lines: string[] = [];
152
+ const prefix = dryRun ? ' [DRY RUN] Would apply:' : ' Applied fixes:';
153
+ lines.push(prefix);
154
+ lines.push('');
155
+
156
+ for (const r of results) {
157
+ lines.push(r.diff);
158
+ lines.push('');
159
+ }
160
+
161
+ if (!dryRun) {
162
+ const files = [...new Set(results.filter((r) => r.applied).map((r) => r.file))];
163
+ if (files.length > 0) {
164
+ lines.push(' Changes are unstaged. To discard:');
165
+ for (const f of files) {
166
+ lines.push(` git checkout -- ${f}`);
167
+ }
168
+ }
169
+ }
170
+
171
+ return lines.join('\n');
172
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Git hook installer for skannr guard.
3
+ * Appends to existing hooks using markers — never overwrites.
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+
9
+ const HOOK_START_MARKER = '# >>> skannr-guard start >>>';
10
+ const HOOK_END_MARKER = '# <<< skannr-guard end <<<';
11
+
12
+ const HOOK_CONTENT = `
13
+ ${HOOK_START_MARKER}
14
+ # Run skannr guard on staged files before committing.
15
+ # Exit code 1 blocks the commit; exit code 2 is a tool error (non-blocking).
16
+ npx skannr guard
17
+ GUARD_EXIT=$?
18
+ if [ $GUARD_EXIT -eq 1 ]; then
19
+ echo ""
20
+ echo " Commit blocked by skannr guard. Fix violations or use --no-verify to skip."
21
+ echo ""
22
+ exit 1
23
+ fi
24
+ ${HOOK_END_MARKER}
25
+ `;
26
+
27
+ /**
28
+ * Install the pre-commit hook (append-safe).
29
+ */
30
+ export function installHook(root: string): { installed: boolean; message: string } {
31
+ const hooksDir = path.join(root, '.git', 'hooks');
32
+ if (!fs.existsSync(path.join(root, '.git'))) {
33
+ return { installed: false, message: 'Not a git repository (no .git directory found).' };
34
+ }
35
+
36
+ if (!fs.existsSync(hooksDir)) {
37
+ fs.mkdirSync(hooksDir, { recursive: true });
38
+ }
39
+
40
+ const hookPath = path.join(hooksDir, 'pre-commit');
41
+
42
+ if (fs.existsSync(hookPath)) {
43
+ const existing = fs.readFileSync(hookPath, 'utf-8');
44
+
45
+ // Already installed?
46
+ if (existing.includes(HOOK_START_MARKER)) {
47
+ return { installed: true, message: 'Hook already installed.' };
48
+ }
49
+
50
+ // Append to existing hook
51
+ const updated = existing.trimEnd() + '\n\n' + HOOK_CONTENT.trim() + '\n';
52
+ fs.writeFileSync(hookPath, updated, { mode: 0o755 });
53
+ } else {
54
+ // Create new hook
55
+ const content = '#!/bin/sh\n' + HOOK_CONTENT;
56
+ fs.writeFileSync(hookPath, content, { mode: 0o755 });
57
+ }
58
+
59
+ return { installed: true, message: `Hook installed at ${hookPath}` };
60
+ }
61
+
62
+ /**
63
+ * Uninstall the pre-commit hook (remove only our markers).
64
+ */
65
+ export function uninstallHook(root: string): { removed: boolean; message: string } {
66
+ const hookPath = path.join(root, '.git', 'hooks', 'pre-commit');
67
+
68
+ if (!fs.existsSync(hookPath)) {
69
+ return { removed: false, message: 'No pre-commit hook found.' };
70
+ }
71
+
72
+ const content = fs.readFileSync(hookPath, 'utf-8');
73
+ if (!content.includes(HOOK_START_MARKER)) {
74
+ return { removed: false, message: 'Skannr guard hook not found in pre-commit.' };
75
+ }
76
+
77
+ // Remove everything between (and including) the markers
78
+ const startIdx = content.indexOf(HOOK_START_MARKER);
79
+ const endIdx = content.indexOf(HOOK_END_MARKER);
80
+ if (startIdx === -1 || endIdx === -1) {
81
+ return { removed: false, message: 'Malformed hook markers.' };
82
+ }
83
+
84
+ const before = content.slice(0, startIdx).trimEnd();
85
+ const after = content.slice(endIdx + HOOK_END_MARKER.length).trimStart();
86
+ const updated = (before + '\n' + after).trim();
87
+
88
+ if (updated === '#!/bin/sh' || updated === '') {
89
+ // Hook is now empty — remove the file
90
+ fs.unlinkSync(hookPath);
91
+ } else {
92
+ fs.writeFileSync(hookPath, updated + '\n', { mode: 0o755 });
93
+ }
94
+
95
+ return { removed: true, message: 'Skannr guard hook removed.' };
96
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Skannr Guard — entry point.
3
+ * Reviews staged changes against team-defined rules using symbol-level analysis.
4
+ */
5
+
6
+ import * as path from 'path';
7
+ import { runGuardReview } from './review-runner';
8
+ import { previewFixes, applyFixes, formatFixResults } from './fix-applier';
9
+ import { installHook, uninstallHook } from './hook-installer';
10
+ import { loadRules, loadGuardConfig } from './rules-loader';
11
+ import type { GuardResult, Violation } from './types';
12
+
13
+ export interface GuardCliOptions {
14
+ root: string;
15
+ fix?: boolean;
16
+ dryRun?: boolean;
17
+ prMode?: boolean;
18
+ diffOnly?: boolean;
19
+ noCache?: boolean;
20
+ json?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Run the guard review and return the result + exit code.
25
+ * Exit codes: 0 = pass, 1 = fail (violations), 2 = tool error.
26
+ */
27
+ export async function runGuard(options: GuardCliOptions): Promise<{ result: GuardResult; exitCode: number }> {
28
+ const { root, fix = false, dryRun = false, prMode = false, diffOnly = false, noCache = false } = options;
29
+ const absoluteRoot = path.resolve(root);
30
+
31
+ // Get PR diff if in --pr-mode
32
+ let diffContent: string | undefined;
33
+ if (prMode) {
34
+ const { execSync } = await import('child_process');
35
+ try {
36
+ diffContent = execSync('git diff origin/main...HEAD', {
37
+ cwd: absoluteRoot,
38
+ encoding: 'utf-8',
39
+ maxBuffer: 10 * 1024 * 1024,
40
+ });
41
+ } catch {
42
+ diffContent = execSync('git diff main...HEAD', {
43
+ cwd: absoluteRoot,
44
+ encoding: 'utf-8',
45
+ maxBuffer: 10 * 1024 * 1024,
46
+ });
47
+ }
48
+ }
49
+
50
+ const result = await runGuardReview({
51
+ root: absoluteRoot,
52
+ diffContent,
53
+ diffOnly,
54
+ noCache,
55
+ });
56
+
57
+ // Handle --fix
58
+ if (fix && result.response.violations.length > 0) {
59
+ if (dryRun) {
60
+ const previews = previewFixes(result.response.violations, absoluteRoot);
61
+ const output = formatFixResults(previews, true);
62
+ process.stdout.write(output + '\n');
63
+ } else {
64
+ const applied = applyFixes(result.response.violations, absoluteRoot);
65
+ const output = formatFixResults(applied, false);
66
+ process.stdout.write(output + '\n');
67
+ }
68
+ }
69
+
70
+ const exitCode = result.response.status === 'fail' ? 1 : 0;
71
+ return { result, exitCode };
72
+ }
73
+
74
+ /**
75
+ * Format the guard result for terminal output.
76
+ */
77
+ export function formatGuardText(result: GuardResult): string {
78
+ const lines: string[] = [];
79
+ const { response } = result;
80
+
81
+ lines.push('');
82
+ lines.push(' Skannr Guard');
83
+ lines.push(' ' + '─'.repeat(50));
84
+ lines.push('');
85
+ lines.push(` Status: ${response.status.toUpperCase()}`);
86
+ lines.push(` ${response.summary}`);
87
+ lines.push(` Reviewed ${result.symbolsReviewed} symbol(s) against ${result.rulesUsed.length} rule(s) in ${result.durationMs}ms`);
88
+ lines.push('');
89
+
90
+ if (response.violations.length > 0) {
91
+ lines.push(' Violations:');
92
+ lines.push('');
93
+
94
+ for (const v of response.violations) {
95
+ const fixTag = v.fixable ? ' [FIXABLE]' : '';
96
+ const confTag = `(${Math.round(v.confidence * 100)}% confidence)`;
97
+ lines.push(` ${v.severity.toUpperCase()} ${v.file}:${v.line_start} ${confTag}${fixTag}`);
98
+ lines.push(` Rule: ${v.rule_id}`);
99
+ lines.push(` Symbol: ${v.symbol}`);
100
+ lines.push(` ${v.message}`);
101
+ if (v.suggested_fix) {
102
+ lines.push(` Fix: ${v.suggested_fix}`);
103
+ }
104
+ lines.push('');
105
+ }
106
+
107
+ const fixableCount = response.violations.filter((v) => v.fixable).length;
108
+ if (fixableCount > 0) {
109
+ lines.push(` ${fixableCount} violation(s) are auto-fixable. Run: skannr guard --fix`);
110
+ lines.push('');
111
+ }
112
+ }
113
+
114
+ return lines.join('\n');
115
+ }
116
+
117
+ /**
118
+ * Format the guard result as JSON.
119
+ */
120
+ export function formatGuardJson(result: GuardResult): string {
121
+ return JSON.stringify({
122
+ status: result.response.status,
123
+ summary: result.response.summary,
124
+ violations: result.response.violations,
125
+ symbolsReviewed: result.symbolsReviewed,
126
+ rulesUsed: result.rulesUsed.length,
127
+ durationMs: result.durationMs,
128
+ }, null, 2);
129
+ }
130
+
131
+ // Re-export for CLI/MCP wiring
132
+ export { installHook, uninstallHook } from './hook-installer';
133
+ export { loadRules, loadGuardConfig } from './rules-loader';
134
+ export type { GuardResult, Violation } from './types';