diff --git a/src/cli.ts b/src/cli.ts index 8a62e03..5956597 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,6 +48,7 @@ program .option("--no-color", "Disable colored output") .option("--output-format ", "Output format: text (default) or json", "text") .option("--verbose", "Show detailed output from each agent") + .option("--whitespace-insensitive", "Ignore whitespace differences in convergence comparison") .option("--retry", "Re-run only failed/timed-out agents from the last run") .action(async (promptArg: string | undefined, opts) => { const testTimeout = parseInt(opts.testTimeout, 10); @@ -100,6 +101,7 @@ program verbose: opts.verbose ?? false, outputFormat: opts.outputFormat, retry: true, + whitespaceInsensitive: opts.whitespaceInsensitive ?? false, }); return; } @@ -131,6 +133,7 @@ program scoring: opts.scoring, verbose: opts.verbose ?? false, outputFormat: opts.outputFormat, + whitespaceInsensitive: opts.whitespaceInsensitive ?? false, }); }); diff --git a/src/commands/run.ts b/src/commands/run.ts index 88e0ca2..7bfcade 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -202,7 +202,7 @@ export async function retry(opts: RunOptions): Promise { } // Phase 5: Convergence analysis on full merged set - const convergence = analyzeConvergence(mergedAgents, opts.threshold); + const convergence = analyzeConvergence(mergedAgents, opts.threshold, opts.whitespaceInsensitive); // Phase 6: Recommendation const { recommended: weightedRec, scores } = recommend(mergedAgents, testResults, convergence); @@ -346,7 +346,7 @@ export async function run(opts: RunOptions): Promise { } // Phase 4: Convergence analysis - const convergence = analyzeConvergence(agents, opts.threshold); + const convergence = analyzeConvergence(agents, opts.threshold, opts.whitespaceInsensitive); // Phase 5: Recommendation const { recommended: weightedRec, scores } = recommend(agents, testResults, convergence); diff --git a/src/scoring/convergence.ts b/src/scoring/convergence.ts index e7cdae9..e0c172e 100644 --- a/src/scoring/convergence.ts +++ b/src/scoring/convergence.ts @@ -9,13 +9,20 @@ import { pairwiseSimilarity } from "./diff-parser.js"; * Agents are clustered by diff similarity using single-linkage clustering * with a 0.5 similarity threshold. */ -export function analyzeConvergence(agents: AgentResult[], threshold = 0.3): ConvergenceGroup[] { +export function analyzeConvergence( + agents: AgentResult[], + threshold = 0.3, + whitespaceInsensitive = false, +): ConvergenceGroup[] { const completed = agents.filter((a) => a.status === "success" && a.diff.length > 0); if (completed.length === 0) return []; // Compute pairwise diff similarity - const similarities = pairwiseSimilarity(completed.map((a) => ({ id: a.id, diff: a.diff }))); + const similarities = pairwiseSimilarity( + completed.map((a) => ({ id: a.id, diff: a.diff })), + whitespaceInsensitive, + ); // Single-linkage clustering: merge agents with similarity >= threshold const clusters = clusterAgents( diff --git a/src/scoring/diff-parser.test.ts b/src/scoring/diff-parser.test.ts index d6028c1..7caa0ba 100644 --- a/src/scoring/diff-parser.test.ts +++ b/src/scoring/diff-parser.test.ts @@ -121,6 +121,65 @@ describe("diffSimilarity", () => { it("returns 1 for two empty diffs", () => { assert.equal(diffSimilarity("", ""), 1); }); + + it("treats reformatted code as different by default", () => { + const diffA = `diff --git a/a.ts b/a.ts +--- a/a.ts ++++ b/a.ts +@@ -1 +1 @@ ++if (x) { return true; }`; + const diffB = `diff --git a/a.ts b/a.ts +--- a/a.ts ++++ b/a.ts +@@ -1 +1 @@ ++if (x) { return true; }`; + const sim = diffSimilarity(diffA, diffB); + assert.ok(sim < 1, "default mode should see whitespace differences"); + }); + + it("whitespace-insensitive mode treats reformatted code as identical", () => { + const diffA = `diff --git a/a.ts b/a.ts +--- a/a.ts ++++ b/a.ts +@@ -1 +1 @@ ++if (x) { return true; }`; + const diffB = `diff --git a/a.ts b/a.ts +--- a/a.ts ++++ b/a.ts +@@ -1 +1 @@ ++if (x) { return true; }`; + assert.equal(diffSimilarity(diffA, diffB, true), 1); + }); + + it("whitespace-insensitive mode normalizes indentation differences", () => { + const diffA = `diff --git a/a.ts b/a.ts +--- a/a.ts ++++ b/a.ts +@@ -1 +1 @@ ++ const x = 1; ++ const y = 2;`; + const diffB = `diff --git a/a.ts b/a.ts +--- a/a.ts ++++ b/a.ts +@@ -1 +1 @@ ++ const x = 1; ++ const y = 2;`; + assert.equal(diffSimilarity(diffA, diffB, true), 1); + }); + + it("whitespace-insensitive mode still detects real code differences", () => { + const diffA = `diff --git a/a.ts b/a.ts +--- a/a.ts ++++ b/a.ts +@@ -1 +1 @@ ++const x = 1;`; + const diffB = `diff --git a/a.ts b/a.ts +--- a/a.ts ++++ b/a.ts +@@ -1 +1 @@ ++const y = 2;`; + assert.equal(diffSimilarity(diffA, diffB, true), 0); + }); }); describe("pairwiseSimilarity", () => { diff --git a/src/scoring/diff-parser.ts b/src/scoring/diff-parser.ts index 7fd83ea..2fb0d94 100644 --- a/src/scoring/diff-parser.ts +++ b/src/scoring/diff-parser.ts @@ -54,11 +54,23 @@ export function parseDiff(diff: string): DiffFile[] { return files; } +function normalizeLine(line: string, whitespaceInsensitive: boolean): string { + const trimmed = line.trim(); + return whitespaceInsensitive ? trimmed.replace(/\s+/g, " ") : trimmed; +} + /** * Compute similarity between two diffs using Jaccard similarity * on the set of added lines. Returns 0-1 where 1 = identical changes. + * + * When whitespaceInsensitive is true, interior whitespace is collapsed + * before comparison so formatting-only differences don't lower scores. */ -export function diffSimilarity(diffA: string, diffB: string): number { +export function diffSimilarity( + diffA: string, + diffB: string, + whitespaceInsensitive = false, +): number { const filesA = parseDiff(diffA); const filesB = parseDiff(diffB); @@ -68,12 +80,12 @@ export function diffSimilarity(diffA: string, diffB: string): number { for (const f of filesA) { for (const line of f.addedLines) { - setA.add(`${f.path}:${line.trim()}`); + setA.add(`${f.path}:${normalizeLine(line, whitespaceInsensitive)}`); } } for (const f of filesB) { for (const line of f.addedLines) { - setB.add(`${f.path}:${line.trim()}`); + setB.add(`${f.path}:${normalizeLine(line, whitespaceInsensitive)}`); } } @@ -95,6 +107,7 @@ export function diffSimilarity(diffA: string, diffB: string): number { */ export function pairwiseSimilarity( agents: Array<{ id: number; diff: string }>, + whitespaceInsensitive = false, ): Map { const matrix = new Map(); @@ -102,7 +115,7 @@ export function pairwiseSimilarity( for (let j = i + 1; j < agents.length; j++) { const a = agents[i]!; const b = agents[j]!; - const sim = diffSimilarity(a.diff, b.diff); + const sim = diffSimilarity(a.diff, b.diff, whitespaceInsensitive); matrix.set(`${a.id}-${b.id}`, sim); } } diff --git a/src/types.ts b/src/types.ts index a9c2f6b..ceae3ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,7 @@ export interface RunOptions { scoring: "weighted" | "copeland"; outputFormat: "text" | "json"; retry?: boolean; + whitespaceInsensitive?: boolean; } export interface AgentResult {