From 9a39117f7aaf82e644ada59e7438cea2f09d2eb9 Mon Sep 17 00:00:00 2001 From: Swathi Chippa Date: Sat, 16 May 2026 01:08:33 +0530 Subject: [PATCH 1/3] feat(analyzer): improve fallback diff summarization --- src/analyzer/fileDeduplicator.test.ts | 140 ++++++++++++++++++++++++++ src/analyzer/fileDeduplicator.ts | 134 ++++++++++++++++++++++++ src/analyzer/fileFilter.test.ts | 85 ++++++++++++++++ src/analyzer/fileFilter.ts | 99 ++++++++++++++++++ src/analyzer/fileScorer.test.ts | 93 +++++++++++++++++ src/analyzer/fileScorer.ts | 135 +++++++++++++++++++++++++ src/analyzer/summarizer.ts | 31 ++++-- src/index.ts | 37 +++++-- 8 files changed, 737 insertions(+), 17 deletions(-) create mode 100644 src/analyzer/fileDeduplicator.test.ts create mode 100644 src/analyzer/fileDeduplicator.ts create mode 100644 src/analyzer/fileFilter.test.ts create mode 100644 src/analyzer/fileFilter.ts create mode 100644 src/analyzer/fileScorer.test.ts create mode 100644 src/analyzer/fileScorer.ts diff --git a/src/analyzer/fileDeduplicator.test.ts b/src/analyzer/fileDeduplicator.test.ts new file mode 100644 index 0000000..4497730 --- /dev/null +++ b/src/analyzer/fileDeduplicator.test.ts @@ -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]]); + }); +}); diff --git a/src/analyzer/fileDeduplicator.ts b/src/analyzer/fileDeduplicator.ts new file mode 100644 index 0000000..d8adf15 --- /dev/null +++ b/src/analyzer/fileDeduplicator.ts @@ -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 { + const groups = new Map(); + + 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(); + + for (const [directory, directoryFiles] of directoryGroups) { + const byExtension = new Map(); + + 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, + }; +} diff --git a/src/analyzer/fileFilter.test.ts b/src/analyzer/fileFilter.test.ts new file mode 100644 index 0000000..121465f --- /dev/null +++ b/src/analyzer/fileFilter.test.ts @@ -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" }, + ]); + }); +}); diff --git a/src/analyzer/fileFilter.ts b/src/analyzer/fileFilter.ts new file mode 100644 index 0000000..c9981bc --- /dev/null +++ b/src/analyzer/fileFilter.ts @@ -0,0 +1,99 @@ +import * as childProcess from "node:child_process"; + +export type FileChange = { + path: string; + additions: number; + deletions: number; + status: "A" | "M" | "D"; +}; + +function normalizePath(path: string): string { + return path.replace(/\\/g, "/").toLowerCase(); +} + +export function isGeneratedFile(path: string): boolean { + const normalized = normalizePath(path); + + return ( + normalized.endsWith(".min.js") || + normalized.endsWith(".min.css") || + normalized === "package-lock.json" || + normalized === "yarn.lock" || + normalized === "pnpm-lock.yaml" || + normalized.endsWith(".pb.go") || + /(^|\/)(dist|build|\.next|out)\//.test(normalized) || + /(^|\/)generated\//.test(normalized) || + /\.generated\./.test(normalized) || + /_gen\./.test(normalized) + ); +} + +export function isSnapshotFile(path: string): boolean { + const normalized = normalizePath(path); + + return ( + /(^|\/)__snapshots__\//.test(normalized) || + normalized.endsWith(".snap") || + /\.fixture\./.test(normalized) || + /(^|\/)testdata\//.test(normalized) + ); +} + +function collectChangedLines(diffText: string, prefix: "+" | "-"): string[] { + return diffText + .split("\n") + .filter(line => line.startsWith(prefix) && !line.startsWith(prefix + prefix + prefix)) + .map(line => line.slice(1)); +} + +function normalizeDiffContent(lines: string[]): string { + return lines.map(line => line.replace(/\s+/g, "")).join(""); +} + +export function isFormattingOnlyDiff(diffText: string): boolean { + const removedLines = collectChangedLines(diffText, "-"); + const addedLines = collectChangedLines(diffText, "+"); + + if (removedLines.length === 0 && addedLines.length === 0) { + // Empty diffs, such as mode-only changes, are not formatting-only. + // Keeping them avoids filtering files when there is no textual diff to compare. + return false; + } + + return normalizeDiffContent(removedLines) === normalizeDiffContent(addedLines); +} + +function getStagedDiffForFile(path: string): string { + try { + return childProcess.execSync(`git diff --cached -U0 -- "${path}"`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + return ""; + } +} + +export function filterLowSignalFiles( + files: FileChange[], + getDiff: (path: string) => string = getStagedDiffForFile +): FileChange[] { + return files.filter(file => { + if (isGeneratedFile(file.path) || isSnapshotFile(file.path)) { + return false; + } + + if (file.additions + file.deletions === 0) { + // A zero-stat file has no meaningful textual diff to inspect, so skip the + // formatting-only check and keep it unless another low-signal rule matched. + return true; + } + + const diffText = getDiff(file.path); + if (!diffText) { + return true; + } + + return !isFormattingOnlyDiff(diffText); + }); +} diff --git a/src/analyzer/fileScorer.test.ts b/src/analyzer/fileScorer.test.ts new file mode 100644 index 0000000..b8fe6c1 --- /dev/null +++ b/src/analyzer/fileScorer.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import type { FileChange } from "./fileFilter"; +import { scoreFile, sortBySignal } from "./fileScorer"; + +describe("fileScorer", () => { + it("scores a new file higher than a modified config file", () => { + const newFile: FileChange = { + path: "src/feature.ts", + additions: 10, + deletions: 0, + status: "A", + }; + const configFile: FileChange = { + path: "tsconfig.json", + additions: 10, + deletions: 0, + status: "M", + }; + + expect(scoreFile(newFile, "")).toBeGreaterThan(scoreFile(configFile, "")); + }); + + it("scores a file with function keywords in diff higher than one without", () => { + const file: FileChange = { + path: "src/feature.ts", + additions: 10, + deletions: 2, + status: "M", + }; + + expect(scoreFile(file, "+export function buildFeature() {}")).toBeGreaterThan( + scoreFile(file, "const label = 'feature';") + ); + }); + + it("scores a test file lower than a feature file with the same diff", () => { + const testFile: FileChange = { + path: "src/__tests__/feature.test.ts", + additions: 12, + deletions: 3, + status: "M", + }; + const featureFile: FileChange = { + path: "src/feature.ts", + additions: 12, + deletions: 3, + status: "M", + }; + const diffText = "function buildFeature() { return true; }"; + + expect(scoreFile(testFile, diffText)).toBeLessThan(scoreFile(featureFile, diffText)); + }); + + it("sorts files by descending signal score", () => { + const files: FileChange[] = [ + { path: "README.md", additions: 10, deletions: 0, status: "M" }, + { path: "src/newFeature.ts", additions: 12, deletions: 0, status: "A" }, + { path: "src/config.ts", additions: 6, deletions: 0, status: "M" }, + ]; + + const diffs: Record = { + "README.md": "update installation notes", + "src/newFeature.ts": "export function createFeature() {}", + "src/config.ts": "const value = 1;", + }; + + expect(sortBySignal(files, path => diffs[path])).toEqual([ + files[1], + files[2], + files[0], + ]); + }); + + it("applies the large diff penalty for files over 200 changed lines", () => { + const largeFile: FileChange = { + path: "src/largeFeature.ts", + additions: 201, + deletions: 10, + status: "M", + }; + const mediumFile: FileChange = { + path: "src/mediumFeature.ts", + additions: 120, + deletions: 10, + status: "M", + }; + + // Files with more than 200 changed lines take a 1-point penalty compared + // with an otherwise identical file whose total changes stay below that threshold. + expect(scoreFile(largeFile, "")).toBeLessThan(scoreFile(mediumFile, "")); + }); +}); diff --git a/src/analyzer/fileScorer.ts b/src/analyzer/fileScorer.ts new file mode 100644 index 0000000..984183d --- /dev/null +++ b/src/analyzer/fileScorer.ts @@ -0,0 +1,135 @@ +import type { FileChange } from "./fileFilter"; + +export type ScoreResult = { + file: FileChange; + score: number; +}; + +function normalizePath(path: string): string { + return path.replace(/\\/g, "/").toLowerCase(); +} + +function isTestFile(path: string): boolean { + const normalized = normalizePath(path); + + return /(^|\/)__tests__\/|\.test\.|\.spec\.|\/tests?\//.test(normalized); +} + +function isDocsFile(path: string): boolean { + const normalized = normalizePath(path); + + return ( + normalized.endsWith(".md") || + normalized.endsWith(".rst") || + normalized.includes("readme") + ); +} + +function isBuildOrConfigFile(path: string): boolean { + const normalized = normalizePath(path); + + return ( + normalized.includes("package.json") || + normalized.includes("tsconfig") || + normalized.includes("vite.config") || + normalized.includes("webpack") || + normalized.includes("requirements.txt") || + normalized.includes("pyproject.toml") || + normalized.includes("pom.xml") || + normalized.includes("build.gradle") || + normalized.includes("go.mod") || + normalized.includes("cargo.toml") || + normalized.includes(".env") || + normalized.includes(".eslintrc") || + normalized.includes(".prettierrc") || + normalized.endsWith(".yml") || + normalized.endsWith(".yaml") || + normalized.includes("dockerfile") || + normalized.includes("makefile") + ); +} + +function countFunctionSignals(diffText: string): number { + const count = diffText + .split("\n") + .filter(line => line.startsWith("+") && !line.startsWith("+++")) + .reduce((total, line) => { + let lineCount = 0; + + if (line.includes("function ")) { + lineCount += 1; + } + + if (line.includes("class ")) { + lineCount += 1; + } + + if (line.includes("def ")) { + lineCount += 1; + } + + if (line.includes("fn ")) { + lineCount += 1; + } + + return total + lineCount; + }, 0); + + return Math.min(count * 3, 6); +} + +export function scoreFile(file: FileChange, diffText: string): number { + let score = 0; + + if (file.status === "A") { + score += 3; + } else if (file.status === "D") { + score += 2; + } + + score += countFunctionSignals(diffText); + + if (isTestFile(file.path)) { + score -= 2; + } + + if (isBuildOrConfigFile(file.path)) { + score -= 2; + } + + if (isDocsFile(file.path)) { + score -= 1; + } + + const changedLines = file.additions + file.deletions; + + if (changedLines > 200) { + score -= 1; + } + + if (changedLines < 5) { + score -= 1; + } + + return score; +} + +export function scoreAll( + files: FileChange[], + getDiff: (path: string) => string +): ScoreResult[] { + return files.map(file => ({ + file, + score: scoreFile(file, getDiff(file.path)), + })); +} + +export function sortBySignal( + files: FileChange[], + getDiff: (path: string) => string +): FileChange[] { + return scoreAll(files, getDiff) + .map((result, index) => ({ ...result, index })) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map(result => result.file); +} diff --git a/src/analyzer/summarizer.ts b/src/analyzer/summarizer.ts index d30edc6..2e590be 100644 --- a/src/analyzer/summarizer.ts +++ b/src/analyzer/summarizer.ts @@ -1,14 +1,23 @@ -type FileInfo = { - path: string; - additions: number; - deletions: number; - status: "A" | "M" | "D"; +import type { DeduplicatedResult } from "./fileDeduplicator"; +import type { FileChange } from "./fileFilter"; + +export function generateSummary(files: FileChange[]): string { + const summaries = files.map(file => { + return `${file.path} (+${file.additions} - ${file.deletions})`; + }); + + return summaries.join("\n"); } -export function generateSummary(files: FileInfo[]): string{ - const summaries = files.map(file => { - return `${file.path} (+${file.additions} - ${file.deletions})`; - }); +export function generateSummaryFromResult(result: DeduplicatedResult): string { + const groupSummaries = result.groups.map(group => { + const { representative } = group; + return `${group.label} (${group.count} files, +${representative.additions} - ${representative.deletions})`; + }); - return summaries.join("\n"); -} \ No newline at end of file + const singleSummaries = result.singles.map(file => { + return `${file.path} (+${file.additions} - ${file.deletions})`; + }); + + return [...groupSummaries, ...singleSummaries].join("\n"); +} diff --git a/src/index.ts b/src/index.ts index 1d75832..ceb3c8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,19 @@ import chalk from "chalk"; import ora from "ora"; +import * as childProcess from "node:child_process"; import { isGitRepo } from "./git/checkRepo"; import { getStagedFiles } from "./git/getStagedFiles"; import { getDiffStats } from "./git/getDiffStats"; import { detectScope } from "./analyzer/scopeDetector"; import { classifyCommitType } from "./analyzer/typeClassifier"; -import { generateSummary } from "./analyzer/summarizer"; +import { generateSummaryFromResult } from "./analyzer/summarizer"; +import { + filterLowSignalFiles, + type FileChange, +} from "./analyzer/fileFilter"; +import { sortBySignal } from "./analyzer/fileScorer"; +import { deduplicateFiles } from "./analyzer/fileDeduplicator"; import { generateCommitMessage } from "./generator/commitGenerator"; import { confirmCommit } from "./ui/interactive"; import { commit } from "./git/commit"; @@ -21,6 +28,17 @@ interface CliOptions { [key: string]: unknown; } +function getDiffForFile(path: string): string { + try { + return childProcess.execSync(`git diff --cached -U0 -- "${path}"`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + return ""; + } +} + export async function run(options: CliOptions) { // Ensure we are inside a Git repo const repo = await isGitRepo(); @@ -38,7 +56,7 @@ export async function run(options: CliOptions) { } // Enrich file stats - const enrichedFiles = []; + const enrichedFiles: FileChange[] = []; for (const file of stagedFiles) { const stats = await getDiffStats(file.path); @@ -50,11 +68,18 @@ export async function run(options: CliOptions) { }); } - const scope = detectScope(enrichedFiles.map(f => f.path)); - const type = await classifyCommitType(enrichedFiles); - const summary = generateSummary(enrichedFiles); + const filteredFiles = filterLowSignalFiles(enrichedFiles); + const prioritizedCandidates = sortBySignal(filteredFiles, getDiffForFile); + const prioritizedFiles = prioritizedCandidates.length > 0 + ? prioritizedCandidates + : enrichedFiles; + const deduplicatedResult = deduplicateFiles(prioritizedFiles, 2); + + const scope = detectScope(prioritizedFiles.map(f => f.path)); + const type = await classifyCommitType(prioritizedFiles); + const summary = generateSummaryFromResult(deduplicatedResult); - let commitMessage = generateCommitMessage(type, scope, enrichedFiles); + let commitMessage = generateCommitMessage(type, scope, prioritizedFiles); // Load config const config = await loadConfig(); From 2dc662e0b1e3820be23a9b41b8a4cfb0e41832a4 Mon Sep 17 00:00:00 2001 From: Swathi Chippa Date: Sat, 16 May 2026 16:54:36 +0530 Subject: [PATCH 2/3] fix(security): replace execSync with execFileSync to prevent command injection --- src/analyzer/fileFilter.ts | 4 ++-- src/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/analyzer/fileFilter.ts b/src/analyzer/fileFilter.ts index c9981bc..3e8d8ca 100644 --- a/src/analyzer/fileFilter.ts +++ b/src/analyzer/fileFilter.ts @@ -1,4 +1,4 @@ -import * as childProcess from "node:child_process"; +import { execFileSync } from "node:child_process"; export type FileChange = { path: string; @@ -65,7 +65,7 @@ export function isFormattingOnlyDiff(diffText: string): boolean { function getStagedDiffForFile(path: string): string { try { - return childProcess.execSync(`git diff --cached -U0 -- "${path}"`, { + return execFileSync("git", ["diff", "--cached", "-U0", "--", path], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); diff --git a/src/index.ts b/src/index.ts index ceb3c8a..6e4dc23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; import ora from "ora"; -import * as childProcess from "node:child_process"; +import { execFileSync } from "node:child_process"; import { isGitRepo } from "./git/checkRepo"; import { getStagedFiles } from "./git/getStagedFiles"; @@ -30,7 +30,7 @@ interface CliOptions { function getDiffForFile(path: string): string { try { - return childProcess.execSync(`git diff --cached -U0 -- "${path}"`, { + return execFileSync("git", ["diff", "--cached", "-U0", "--", path], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); From d00c4001d74b28a86e0425e26fbb81a3fb29cb00 Mon Sep 17 00:00:00 2001 From: Swathi Chippa Date: Mon, 18 May 2026 14:50:06 +0530 Subject: [PATCH 3/3] fix: pass config.format to generateCommitMessage --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index c270d0e..8d80d31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,7 +84,7 @@ const summary = generateSummaryFromResult(deduplicatedResult); // Load config const config = await loadConfig(); -let commitMessage = generateCommitMessage(type, scope, prioritizedFiles); +let commitMessage = generateCommitMessage(type, scope, prioritizedFiles, config.format); // AI enhancement (optional)