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.
- package/dist/cli.js +86 -0
- package/dist/cli.js.map +1 -1
- package/dist/guard/call-context.d.ts +14 -0
- package/dist/guard/call-context.d.ts.map +1 -0
- package/dist/guard/call-context.js +120 -0
- package/dist/guard/call-context.js.map +1 -0
- package/dist/guard/fix-applier.d.ts +38 -0
- package/dist/guard/fix-applier.d.ts.map +1 -0
- package/dist/guard/fix-applier.js +181 -0
- package/dist/guard/fix-applier.js.map +1 -0
- package/dist/guard/hook-installer.d.ts +19 -0
- package/dist/guard/hook-installer.d.ts.map +1 -0
- package/dist/guard/hook-installer.js +119 -0
- package/dist/guard/hook-installer.js.map +1 -0
- package/dist/guard/index.d.ts +34 -0
- package/dist/guard/index.d.ts.map +1 -0
- package/dist/guard/index.js +152 -0
- package/dist/guard/index.js.map +1 -0
- package/dist/guard/provider.d.ts +12 -0
- package/dist/guard/provider.d.ts.map +1 -0
- package/dist/guard/provider.js +211 -0
- package/dist/guard/provider.js.map +1 -0
- package/dist/guard/review-runner.d.ts +21 -0
- package/dist/guard/review-runner.d.ts.map +1 -0
- package/dist/guard/review-runner.js +85 -0
- package/dist/guard/review-runner.js.map +1 -0
- package/dist/guard/rules-loader.d.ts +10 -0
- package/dist/guard/rules-loader.d.ts.map +1 -0
- package/dist/guard/rules-loader.js +156 -0
- package/dist/guard/rules-loader.js.map +1 -0
- package/dist/guard/schema.d.ts +182 -0
- package/dist/guard/schema.d.ts.map +1 -0
- package/dist/guard/schema.js +53 -0
- package/dist/guard/schema.js.map +1 -0
- package/dist/guard/symbol-diff.d.ts +18 -0
- package/dist/guard/symbol-diff.d.ts.map +1 -0
- package/dist/guard/symbol-diff.js +243 -0
- package/dist/guard/symbol-diff.js.map +1 -0
- package/dist/guard/types.d.ts +62 -0
- package/dist/guard/types.d.ts.map +1 -0
- package/dist/guard/types.js +6 -0
- package/dist/guard/types.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +44 -0
- package/dist/mcp-server.js.map +1 -1
- package/dist/scanner.d.ts +0 -1
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +0 -7
- package/dist/scanner.js.map +1 -1
- package/package.json +3 -2
- package/src/cli.ts +101 -0
- package/src/guard/call-context.ts +101 -0
- package/src/guard/fix-applier.ts +172 -0
- package/src/guard/hook-installer.ts +96 -0
- package/src/guard/index.ts +134 -0
- package/src/guard/provider.ts +209 -0
- package/src/guard/review-runner.ts +110 -0
- package/src/guard/rules-loader.ts +131 -0
- package/src/guard/schema.ts +59 -0
- package/src/guard/symbol-diff.ts +227 -0
- package/src/guard/types.ts +68 -0
- package/src/index.ts +2 -2
- package/src/mcp-server.ts +49 -0
- package/src/scanner.ts +0 -7
- package/gemini-extension/GEMINI.md +0 -26
- package/gemini-extension/gemini-extension.json +0 -12
- package/gemini-extension/package.json +0 -14
- package/gemini-extension/src/server.ts +0 -69
- 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';
|