Skip to content
Merged
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: 140 additions & 0 deletions src/analyzer/fileDeduplicator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";

import type { FileChange } from "./fileFilter";
import { deduplicateFiles } from "./fileDeduplicator";

describe("fileDeduplicator", () => {
it("collapses tsx files in the same directory into one group", () => {
const files: FileChange[] = [
{ path: "src/components/Button.tsx", additions: 3, deletions: 1, status: "M" },
{ path: "src/components/Card.tsx", additions: 4, deletions: 2, status: "M" },
{ path: "src/components/Modal.tsx", additions: 2, deletions: 1, status: "M" },
];

const result = deduplicateFiles(files, 2);

expect(result.groups).toEqual([
{
representative: files[1],
count: 3,
label: "src/components/*.tsx",
},
]);
expect(result.singles).toEqual([]);
});

it("collapses mixed extensions in the same directory into a wildcard group", () => {
const files: FileChange[] = [
{ path: "src/components/Button.tsx", additions: 3, deletions: 1, status: "M" },
{ path: "src/components/styles.css", additions: 4, deletions: 1, status: "M" },
{ path: "src/components/index.ts", additions: 2, deletions: 2, status: "M" },
{ path: "src/components/schema.json", additions: 1, deletions: 1, status: "M" },
];

// Each file has a unique extension, so no same-extension group forms here.
// This exercises only the 4-file mixed-extension fallback path.
const result = deduplicateFiles(files, 2);

expect(result.groups).toEqual([
{
representative: files[1],
count: 4,
label: "src/components/*",
},
]);
expect(result.singles).toEqual([]);
});

it("keeps a lone file as a single", () => {
const files: FileChange[] = [
{ path: "src/utils/format.ts", additions: 5, deletions: 0, status: "M" },
];

const result = deduplicateFiles(files, 2);

expect(result.groups).toEqual([]);
expect(result.singles).toEqual(files);
});

it("chooses the file with the highest change count as representative", () => {
const files: FileChange[] = [
{ path: "src/components/Button.tsx", additions: 1, deletions: 1, status: "M" },
{ path: "src/components/Card.tsx", additions: 10, deletions: 3, status: "M" },
{ path: "src/components/Modal.tsx", additions: 2, deletions: 0, status: "M" },
];

const result = deduplicateFiles(files, 2);

expect(result.groups[0]?.representative).toEqual(files[1]);
});

it("disables grouping when maxPerGroup is 1", () => {
const files: FileChange[] = [
{ path: "src/components/Button.tsx", additions: 3, deletions: 1, status: "M" },
{ path: "src/components/Card.tsx", additions: 4, deletions: 2, status: "M" },
{ path: "src/components/Modal.tsx", additions: 2, deletions: 1, status: "M" },
];

const result = deduplicateFiles(files, 1);

expect(result.groups).toEqual([]);
expect(result.singles).toEqual(files);
});

it("keeps odd files out as singles when only one extension bucket groups", () => {
const files: FileChange[] = [
{ path: "src/components/Button.tsx", additions: 3, deletions: 1, status: "M" },
{ path: "src/components/Card.tsx", additions: 4, deletions: 2, status: "M" },
{ path: "src/components/index.ts", additions: 2, deletions: 1, status: "M" },
];

const result = deduplicateFiles(files, 2);

expect(result.groups).toEqual([
{
representative: files[1],
count: 2,
label: "src/components/*.tsx",
},
]);
expect(result.singles).toEqual([files[2]]);
});

it("uses root-relative labels for root-level grouped files", () => {
const files: FileChange[] = [
{ path: "index.ts", additions: 3, deletions: 1, status: "M" },
{ path: "main.ts", additions: 4, deletions: 2, status: "M" },
];

const result = deduplicateFiles(files, 2);

expect(result.groups).toEqual([
{
representative: files[1],
count: 2,
label: "*.ts",
},
]);
expect(result.singles).toEqual([]);
});

it("handles a mix of grouped and ungrouped files", () => {
const files: FileChange[] = [
{ path: "src/components/Button.tsx", additions: 2, deletions: 1, status: "M" },
{ path: "src/components/Card.tsx", additions: 7, deletions: 1, status: "M" },
{ path: "src/docs/guide.md", additions: 3, deletions: 0, status: "M" },
{ path: "src/pages/index.ts", additions: 1, deletions: 1, status: "M" },
];

const result = deduplicateFiles(files, 2);

expect(result.groups).toEqual([
{
representative: files[1],
count: 2,
label: "src/components/*.tsx",
},
]);
expect(result.singles).toEqual([files[2], files[3]]);
});
});
134 changes: 134 additions & 0 deletions src/analyzer/fileDeduplicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { FileChange } from "./fileFilter";

export type FileGroup = {
representative: FileChange;
count: number;
label: string;
};

export type DeduplicatedResult = {
groups: FileGroup[];
singles: FileChange[];
};

function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}

function getDirectory(path: string): string {
const normalized = normalizePath(path);
const lastSlash = normalized.lastIndexOf("/");

return lastSlash === -1 ? "" : normalized.slice(0, lastSlash);
}

function getGroupRepresentative(files: FileChange[]): FileChange {
return files.reduce((best, current) => {
const bestSize = best.additions + best.deletions;
const currentSize = current.additions + current.deletions;

return currentSize > bestSize ? current : best;
});
}

function buildGroupLabel(directory: string, extension: string): string {
if (!directory) {
return extension ? `*${extension}` : "*";
}

return extension ? `${directory}/*${extension}` : `${directory}/*`;
}

export function getExtension(path: string): string {
const normalized = normalizePath(path);
const fileName = normalized.split("/").pop() || "";
const lastDot = fileName.lastIndexOf(".");

return lastDot <= 0 ? "" : fileName.slice(lastDot).toLowerCase();
}

export function groupByDirectory(files: FileChange[]): Map<string, FileChange[]> {
const groups = new Map<string, FileChange[]>();

for (const file of files) {
const directory = getDirectory(file.path);
const existing = groups.get(directory) || [];
existing.push(file);
groups.set(directory, existing);
}

return groups;
}

export function deduplicateFiles(
files: FileChange[],
maxPerGroup: number
): DeduplicatedResult {
if (maxPerGroup <= 1) {
return {
groups: [],
singles: [...files],
};
}

const directoryGroups = groupByDirectory(files);
const groups: FileGroup[] = [];
const singles: FileChange[] = [];
const groupedPaths = new Set<string>();

for (const [directory, directoryFiles] of directoryGroups) {
const byExtension = new Map<string, FileChange[]>();

for (const file of directoryFiles) {
const extension = getExtension(file.path);
const existing = byExtension.get(extension) || [];
existing.push(file);
byExtension.set(extension, existing);
}

let groupedInDirectory = false;

for (const [extension, extensionFiles] of byExtension) {
if (extensionFiles.length >= maxPerGroup) {
groups.push({
representative: getGroupRepresentative(extensionFiles),
count: extensionFiles.length,
label: buildGroupLabel(directory, extension),
});

for (const file of extensionFiles) {
groupedPaths.add(file.path);
}

groupedInDirectory = true;
}
}

if (groupedInDirectory) {
continue;
}

if (directoryFiles.length >= 4 && byExtension.size > 1) {
groups.push({
representative: getGroupRepresentative(directoryFiles),
count: directoryFiles.length,
label: buildGroupLabel(directory, ""),
});

for (const file of directoryFiles) {
groupedPaths.add(file.path);
}
}
}

for (const file of files) {
if (!groupedPaths.has(file.path)) {
singles.push(file);
}
}

return {
groups,
singles,
};
}
85 changes: 85 additions & 0 deletions src/analyzer/fileFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";

import {
filterLowSignalFiles,
isFormattingOnlyDiff,
isGeneratedFile,
isSnapshotFile,
type FileChange,
} from "./fileFilter";

describe("fileFilter", () => {
it("identifies generated file paths", () => {
expect(isGeneratedFile("dist/assets/app.js")).toBe(true);
expect(isGeneratedFile("dist\\app.min.js")).toBe(true);
expect(isGeneratedFile("package-lock.json")).toBe(true);
expect(isGeneratedFile("src/app.ts")).toBe(false);
});

it("identifies snapshot and fixture paths", () => {
expect(isSnapshotFile("src/__snapshots__/button.test.ts.snap")).toBe(true);
expect(isSnapshotFile("testdata/request.json")).toBe(true);
expect(isSnapshotFile("src/components/button.tsx")).toBe(false);
});

it("classifies whitespace-only diffs as formatting-only", () => {
const diffText = `diff --git a/src/example.ts b/src/example.ts
index 1111111..2222222 100644
--- a/src/example.ts
+++ b/src/example.ts
@@ -1 +1 @@
-const value = { foo: "bar" };
+const value={foo:"bar"};`;

expect(isFormattingOnlyDiff(diffText)).toBe(true);
});

it("does not classify real code changes as formatting-only", () => {
const diffText = `diff --git a/src/example.ts b/src/example.ts
index 1111111..2222222 100644
--- a/src/example.ts
+++ b/src/example.ts
@@ -1 +1 @@
-return total;
+return total + tax;`;

expect(isFormattingOnlyDiff(diffText)).toBe(false);
});

it("removes low-signal files from a mixed file list", () => {
const files: FileChange[] = [
{ path: "dist/app.min.js", additions: 20, deletions: 10, status: "M" },
{ path: "src/__snapshots__/button.test.ts.snap", additions: 2, deletions: 2, status: "M" },
{ path: "src/feature.ts", additions: 1, deletions: 1, status: "M" },
{ path: "src/logic.ts", additions: 3, deletions: 1, status: "M" },
];

const fakeDiff = (path: string): string => {
if (path === "src/feature.ts") {
return [
"diff --git a/src/feature.ts b/src/feature.ts",
"--- a/src/feature.ts",
"+++ b/src/feature.ts",
"@@ -1 +1 @@",
"-const x = 1;",
"+const x=1;",
].join("\n");
}
if (path === "src/logic.ts") {
return [
"diff --git a/src/logic.ts b/src/logic.ts",
"--- a/src/logic.ts",
"+++ b/src/logic.ts",
"@@ -1 +1 @@",
"-return value;",
"+return value + 1;",
].join("\n");
}
return "";
};

expect(filterLowSignalFiles(files, fakeDiff)).toEqual([
{ path: "src/logic.ts", additions: 3, deletions: 1, status: "M" },
]);
});
});
Loading
Loading