Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 130 additions & 10 deletions HookQA/HookQA/Resources/hookqa-hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bun
// hookqa-hook v1.0.0
// hookqa-hook v1.2.0

const startTime = Date.now();

Expand Down Expand Up @@ -293,11 +293,70 @@ async function collectDiff(maxLines: number): Promise<string> {
return lines.slice(0, maxLines).join("\n") + `\n... (truncated at ${maxLines} lines)`;
}

function countDiffLines(diff: string): number {
// --- Diff Filtering ---

export function isCommentLine(s: string): boolean {
if (s.startsWith("//") || s.startsWith("/*") || s.startsWith("*/")) return true;
if (s.startsWith("<!--") || s.startsWith("-->")) return true;
// Block-comment continuation ("* foo"), but not "*ptr" style code
if (/^\*(\s|$)/.test(s)) return true;
// Shell/Python/Ruby comments ("# foo"), but not "#include", "#define", "#!"
if (/^#(\s|$)/.test(s)) return true;
return false;
}

export function isImportLine(s: string): boolean {
const patterns: RegExp[] = [
/^import[\s({"']/, // JS/TS/Swift/Python/Java/Go
/^from\s+\S+\s+import\b/, // Python
/^export\s+(\*|\{[^}]*\})\s+from\b/, // JS re-export
/^(const|let|var)\s+.*=\s*require\s*\(/, // CommonJS
/^require(_relative)?\s*[("']/, // Ruby
/^#(include|import)\b/, // C/ObjC
/^using\s+[\w.]+;?\s*$/, // C#/C++ using
/^use\s+[\w:\\]/, // Rust/PHP
/^@testable\s+import\b/, // Swift
];
return patterns.some(p => p.test(s));
}

export function countMeaningfulDiffLines(diff: string): number {
if (!diff) return 0;
return diff.split("\n").filter(line =>
!line.startsWith("=== ") && line.trim() !== ""
).length;

let count = 0;
let removed = new Map<string, number>();
let added = new Map<string, number>();

// Cancel matching removed/added pairs (reindented or moved lines), count the rest
function flushHunk(): void {
const keys = new Set([...removed.keys(), ...added.keys()]);
for (const key of keys) {
count += Math.abs((removed.get(key) ?? 0) - (added.get(key) ?? 0));
}
removed = new Map();
added = new Map();
}

for (const line of diff.split("\n")) {
if (line.startsWith("@@") || line.startsWith("diff --git") || line.startsWith("=== ")) {
flushHunk();
continue;
}
if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;

const isAdded = line.startsWith("+");
const isRemoved = line.startsWith("-");
if (!isAdded && !isRemoved) continue;

const normalized = line.slice(1).trim().replace(/\s+/g, " ");
if (!normalized || isCommentLine(normalized) || isImportLine(normalized)) continue;

const bucket = isAdded ? added : removed;
bucket.set(normalized, (bucket.get(normalized) ?? 0) + 1);
}

flushHunk();
return count;
}

// --- Project Name ---
Expand Down Expand Up @@ -352,6 +411,45 @@ async function deleteRetryFile(sessionId: string): Promise<void> {
}
}

// --- Pass Cache ---

export function computeReviewFingerprint(diff: string, config: ResolvedConfig): string {
const payload = JSON.stringify({
diff,
model: config.model,
temperature: config.temperature,
weights: config.weights,
customInstructions: config.customInstructions,
blockOnWarnings: config.blockOnWarnings,
});
return new Bun.CryptoHasher("sha256").update(payload).digest("hex");
}

export function getPassCacheFilePath(cwd: string): string {
const key = new Bun.CryptoHasher("sha256").update(cwd).digest("hex").slice(0, 16);
return `/tmp/hookqa-${key}-lastpass`;
}

async function readPassCache(cwd: string): Promise<string | null> {
try {
const file = Bun.file(getPassCacheFilePath(cwd));
const exists = await file.exists();
if (!exists) return null;
const text = await file.text();
return text.trim();
} catch {
return null;
}
}

async function writePassCache(cwd: string, fingerprint: string): Promise<void> {
try {
await Bun.write(getPassCacheFilePath(cwd), fingerprint);
} catch {
// never fail on cache errors
}
}

// --- Logging ---

async function appendLog(config: ResolvedConfig, entry: LogEntry): Promise<void> {
Expand Down Expand Up @@ -512,7 +610,7 @@ async function main(): Promise<void> {

// Collect diff
const diff = await collectDiff(config.maxDiffLines);
const diffLineCount = countDiffLines(diff);
const diffLineCount = countMeaningfulDiffLines(diff);

if (diffLineCount < config.minDiffLines) {
// Not enough diff to review — skip
Expand All @@ -525,7 +623,28 @@ async function main(): Promise<void> {
findings: 0,
criticals: 0,
warnings: 0,
summary: `Diff too small (${diffLineCount} lines, min ${config.minDiffLines})`,
summary: `Diff too small (${diffLineCount} meaningful lines, min ${config.minDiffLines})`,
durationMs: 0,
});
await deleteRetryFile(sessionId);
process.exit(0);
}

// Check pass cache — skip if this exact diff already passed review
const fingerprint = computeReviewFingerprint(diff, config);
const cachedFingerprint = await readPassCache(process.cwd());

if (cachedFingerprint === fingerprint) {
const project = await getProjectName();
await appendLog(config, {
timestamp: new Date().toISOString(),
project,
model: config.model,
verdict: "SKIPPED",
findings: 0,
criticals: 0,
warnings: 0,
summary: "Unchanged diff already reviewed — skipping",
durationMs: 0,
});
await deleteRetryFile(sessionId);
Expand Down Expand Up @@ -571,6 +690,7 @@ async function main(): Promise<void> {
});

if (!shouldBlock) {
await writePassCache(process.cwd(), fingerprint);
await deleteRetryFile(sessionId);
process.exit(0);
}
Expand All @@ -587,7 +707,7 @@ async function main(): Promise<void> {
process.exit(2);
}

main().catch(() => {
if (import.meta.main) {
// Never crash Claude Code
process.exit(0);
});
main().catch(() => process.exit(0));
}
2 changes: 1 addition & 1 deletion HookQA/HookQA/Views/BehaviourTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ struct BehaviourTab: View {
// MARK: Min Diff Lines
LabeledSliderRow(
label: "Min Diff Lines",
description: "Skip QA for trivial changes below this threshold.",
description: "Skip QA below this many meaningful changed lines (imports, comments, and whitespace don't count).",
value: Binding(
get: { Double(settings.config.behaviour.minDiffLines) },
set: { settings.config.behaviour.minDiffLines = Int($0); settings.scheduleSave() }
Expand Down
Loading