chore: import upstream snapshot with attribution
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Git Diff Tests
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
import { extractDiff, validateOnBranch } from '../../../src/codeScan/git/diff';
|
||||
import { GitError } from '../../../src/types/codeScan';
|
||||
import type { SimpleGit, StatusResult } from 'simple-git';
|
||||
|
||||
// Define a partial mock type for the SimpleGit methods we use
|
||||
type MockSimpleGit = Pick<SimpleGit, 'status' | 'branch' | 'diff' | 'log'> & {
|
||||
status: Mock;
|
||||
branch: Mock;
|
||||
diff: Mock;
|
||||
log: Mock;
|
||||
};
|
||||
|
||||
// Mock simple-git with a factory that returns a mock instance
|
||||
const mockGit: MockSimpleGit = {
|
||||
status: vi.fn(),
|
||||
branch: vi.fn(),
|
||||
diff: vi.fn(),
|
||||
log: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
default: vi.fn(() => mockGit),
|
||||
}));
|
||||
|
||||
describe('Git Diff', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('validateOnBranch', () => {
|
||||
it('should return current branch name when on a branch', async () => {
|
||||
mockGit.status.mockResolvedValue({
|
||||
current: 'feature/test-branch',
|
||||
detached: false,
|
||||
} as StatusResult);
|
||||
|
||||
const branchName = await validateOnBranch(mockGit as unknown as SimpleGit);
|
||||
|
||||
expect(branchName).toBe('feature/test-branch');
|
||||
});
|
||||
|
||||
it('should throw GitError when in detached HEAD state', async () => {
|
||||
mockGit.status.mockResolvedValue({
|
||||
current: null,
|
||||
detached: true,
|
||||
} as StatusResult);
|
||||
|
||||
await expect(validateOnBranch(mockGit as unknown as SimpleGit)).rejects.toThrow(GitError);
|
||||
await expect(validateOnBranch(mockGit as unknown as SimpleGit)).rejects.toThrow(
|
||||
'Not on a branch',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractDiff', () => {
|
||||
it('should extract diff comparing against main branch', async () => {
|
||||
const mockDiff = `diff --git a/file.ts b/file.ts
|
||||
index 123..456 100644
|
||||
--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
+const newLine = 'test';
|
||||
const existingLine = 'existing';`;
|
||||
|
||||
// Mock status (for validateOnBranch)
|
||||
mockGit.status.mockResolvedValue({
|
||||
current: 'feature/test',
|
||||
detached: false,
|
||||
} as StatusResult);
|
||||
|
||||
// Mock main branch exists
|
||||
mockGit.branch.mockResolvedValue({
|
||||
all: ['main', 'feature/test'],
|
||||
branches: {},
|
||||
current: 'feature/test',
|
||||
detached: false,
|
||||
} as any);
|
||||
mockGit.diff.mockResolvedValue(mockDiff);
|
||||
|
||||
const result = await extractDiff('/fake/repo');
|
||||
|
||||
expect(result).toEqual({
|
||||
diff: mockDiff,
|
||||
baseBranch: 'main',
|
||||
});
|
||||
expect(mockGit.diff).toHaveBeenCalledWith(['main...HEAD']);
|
||||
});
|
||||
|
||||
it('should fall back to master branch if main does not exist', async () => {
|
||||
const mockDiff = 'diff content';
|
||||
|
||||
// Mock status (for validateOnBranch)
|
||||
mockGit.status.mockResolvedValue({
|
||||
current: 'feature/test',
|
||||
detached: false,
|
||||
} as StatusResult);
|
||||
|
||||
// Mock main branch does not exist, master does
|
||||
mockGit.branch.mockResolvedValue({
|
||||
all: ['master', 'feature/test'],
|
||||
branches: {},
|
||||
current: 'feature/test',
|
||||
detached: false,
|
||||
} as any);
|
||||
mockGit.diff.mockResolvedValue(mockDiff);
|
||||
|
||||
const result = await extractDiff('/fake/repo');
|
||||
|
||||
expect(result).toEqual({
|
||||
diff: mockDiff,
|
||||
baseBranch: 'master',
|
||||
});
|
||||
expect(mockGit.diff).toHaveBeenCalledWith(['master...HEAD']);
|
||||
});
|
||||
|
||||
it('should throw GitError when neither main nor master exists', async () => {
|
||||
// Mock status (for validateOnBranch)
|
||||
mockGit.status.mockResolvedValue({
|
||||
current: 'feature/test',
|
||||
detached: false,
|
||||
} as StatusResult);
|
||||
|
||||
// Mock neither branch exists - should throw error
|
||||
mockGit.branch.mockResolvedValue({
|
||||
all: ['feature/test'],
|
||||
branches: {},
|
||||
current: 'feature/test',
|
||||
detached: false,
|
||||
} as any);
|
||||
|
||||
await expect(extractDiff('/fake/repo')).rejects.toThrow(GitError);
|
||||
});
|
||||
|
||||
it('should throw GitError if no changes detected', async () => {
|
||||
// Mock status (for validateOnBranch)
|
||||
mockGit.status.mockResolvedValue({
|
||||
current: 'feature/test',
|
||||
detached: false,
|
||||
} as StatusResult);
|
||||
|
||||
mockGit.branch.mockResolvedValue({
|
||||
all: ['main', 'feature/test'],
|
||||
branches: {},
|
||||
current: 'feature/test',
|
||||
detached: false,
|
||||
} as any);
|
||||
|
||||
// Mock empty diff
|
||||
mockGit.diff.mockResolvedValue('');
|
||||
|
||||
await expect(extractDiff('/fake/repo')).rejects.toThrow(GitError);
|
||||
await expect(extractDiff('/fake/repo')).rejects.toThrow('No changes detected');
|
||||
});
|
||||
|
||||
it('should throw GitError if diff extraction fails', async () => {
|
||||
// Mock status (for validateOnBranch)
|
||||
mockGit.status.mockResolvedValue({
|
||||
current: 'feature/test',
|
||||
detached: false,
|
||||
} as StatusResult);
|
||||
|
||||
mockGit.branch.mockResolvedValue({
|
||||
all: ['main', 'feature/test'],
|
||||
branches: {},
|
||||
current: 'feature/test',
|
||||
detached: false,
|
||||
} as any);
|
||||
|
||||
mockGit.diff.mockRejectedValue(new Error('git diff failed'));
|
||||
|
||||
await expect(extractDiff('/fake/repo')).rejects.toThrow(GitError);
|
||||
await expect(extractDiff('/fake/repo')).rejects.toThrow('Failed to extract diff');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { annotateSingleFileDiffWithLineNumbers } from '../../../src/codeScan/git/diffAnnotator';
|
||||
|
||||
describe('annotateDiffWithLineNumbers', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('should handle empty patch', () => {
|
||||
expect(annotateSingleFileDiffWithLineNumbers('')).toBe('');
|
||||
expect(annotateSingleFileDiffWithLineNumbers(' ')).toBe(' ');
|
||||
});
|
||||
|
||||
it('should preserve file headers without annotation', () => {
|
||||
const patch = `diff --git a/test.ts b/test.ts
|
||||
index abc123..def456 100644
|
||||
--- a/test.ts
|
||||
+++ b/test.ts`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(patch);
|
||||
});
|
||||
|
||||
it('should annotate simple hunk with additions', () => {
|
||||
const patch = `@@ -1,3 +1,4 @@
|
||||
line 1
|
||||
line 2
|
||||
+new line 3
|
||||
line 4`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(`@@ -1,3 +1,4 @@
|
||||
L1: line 1
|
||||
L2: line 2
|
||||
L3: +new line 3
|
||||
L4: line 4`);
|
||||
});
|
||||
|
||||
it('should annotate simple hunk with deletions', () => {
|
||||
const patch = `@@ -1,4 +1,3 @@
|
||||
line 1
|
||||
-deleted line
|
||||
line 2
|
||||
line 3`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(`@@ -1,4 +1,3 @@
|
||||
L1: line 1
|
||||
-deleted line
|
||||
L2: line 2
|
||||
L3: line 3`);
|
||||
});
|
||||
|
||||
it('should annotate hunk with mixed changes', () => {
|
||||
const patch = `@@ -5,5 +5,6 @@
|
||||
context before
|
||||
-old line
|
||||
+new line 1
|
||||
+new line 2
|
||||
context after`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(`@@ -5,5 +5,6 @@
|
||||
L5: context before
|
||||
-old line
|
||||
L6: +new line 1
|
||||
L7: +new line 2
|
||||
L8: context after`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple hunks', () => {
|
||||
it('should handle multiple hunks with correct line numbering', () => {
|
||||
const patch = `@@ -1,3 +1,3 @@
|
||||
line 1
|
||||
-old line 2
|
||||
+new line 2
|
||||
line 3
|
||||
@@ -10,2 +10,3 @@
|
||||
line 10
|
||||
+inserted line
|
||||
line 11`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(`@@ -1,3 +1,3 @@
|
||||
L1: line 1
|
||||
-old line 2
|
||||
L2: +new line 2
|
||||
L3: line 3
|
||||
@@ -10,2 +10,3 @@
|
||||
L10: line 10
|
||||
L11: +inserted line
|
||||
L12: line 11`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('full patch with headers', () => {
|
||||
it('should annotate complete patch including file headers', () => {
|
||||
const patch = `diff --git a/src/test.ts b/src/test.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/test.ts
|
||||
+++ b/src/test.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
import { foo } from 'bar';
|
||||
+import { baz } from 'qux';
|
||||
|
||||
function test() {`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(`diff --git a/src/test.ts b/src/test.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/test.ts
|
||||
+++ b/src/test.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
L1: import { foo } from 'bar';
|
||||
L2: +import { baz } from 'qux';
|
||||
L3:
|
||||
L4: function test() {`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world example from PR #5', () => {
|
||||
it('should correctly annotate the mcp tool definition hunk', () => {
|
||||
// This is a simplified excerpt from the actual PR #5
|
||||
const patch = `@@ -18,20 +20,64 @@ const getRetentionOffers = tool({
|
||||
customer_id: z.string(),
|
||||
account_type: z.string(),
|
||||
current_plan: z.string(),
|
||||
- tenure_months: z.integer(),
|
||||
+ tenure_months: z.number().int(),
|
||||
recent_complaints: z.boolean(),
|
||||
}),
|
||||
execute: async (input: {
|
||||
customer_id: string;
|
||||
account_type: string;
|
||||
current_plan: string;
|
||||
- tenure_months: integer;
|
||||
+ tenure_months: number;
|
||||
recent_complaints: boolean;
|
||||
}) => {
|
||||
- // TODO: Unimplemented
|
||||
+ // Mock implementation - returns sample retention offers
|
||||
+ return {
|
||||
+ offers: [
|
||||
+ {
|
||||
+ type: "discount",
|
||||
+ description: "20% off for 12 months",
|
||||
+ monthly_savings: 15,
|
||||
+ },
|
||||
+ {
|
||||
+ type: "upgrade",
|
||||
+ description: "Free upgrade to premium plan for 3 months",
|
||||
+ value: 45,
|
||||
+ },
|
||||
+ ],
|
||||
+ };
|
||||
},
|
||||
});
|
||||
|
||||
+const mcp = hostedMcpTool({
|
||||
+ serverLabel: "dropbox",
|
||||
+ connectorId: "connector_dropbox",
|
||||
+ serverDescription: "Return Policy Knowledge",
|
||||
+ allowedTools: [
|
||||
+ "fetch",
|
||||
+ "fetch_file",
|
||||
+ "get_profile",
|
||||
+ "list_recent_files",
|
||||
+ "search",
|
||||
+ "search_files",
|
||||
+ ],
|
||||
+ requireApproval: "never",
|
||||
+});`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
|
||||
// Verify key line numbers
|
||||
expect(result).toContain('L51: +const mcp = hostedMcpTool({');
|
||||
expect(result).toContain('L63: + requireApproval: "never",');
|
||||
expect(result).toContain('L64: +});');
|
||||
|
||||
// Verify that context lines are also numbered
|
||||
expect(result).toContain('L20: customer_id: z.string(),');
|
||||
expect(result).toContain('L21: account_type: z.string(),');
|
||||
|
||||
// Verify that removed lines have no line numbers
|
||||
const lines = result.split('\n');
|
||||
const removedLine = lines.find((l) => l.includes('tenure_months: z.integer()'));
|
||||
expect(removedLine).toBeDefined();
|
||||
expect(removedLine).toMatch(/^-/);
|
||||
});
|
||||
|
||||
it('should verify line numbering is absolute, not relative to hunk', () => {
|
||||
// This test explicitly verifies the bug we're fixing
|
||||
// The hunk starts at line 20, so lines should be numbered starting from 20
|
||||
const patch = `@@ -18,20 +20,10 @@
|
||||
customer_id: z.string(),
|
||||
account_type: z.string(),
|
||||
current_plan: z.string(),
|
||||
}),
|
||||
execute: async (input: {
|
||||
customer_id: string;
|
||||
+const mcp = hostedMcpTool({
|
||||
+ serverLabel: "dropbox",`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
|
||||
// Should start numbering at 20 (the hunk start), not at 1
|
||||
expect(result).toContain('L20: customer_id: z.string(),');
|
||||
expect(result).toContain('L21: account_type: z.string(),');
|
||||
|
||||
// The mcp tool should be at L26 (20 + 6 lines)
|
||||
expect(result).toContain('L26: +const mcp = hostedMcpTool({');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle hunk at start of file (line 1)', () => {
|
||||
const patch = `@@ -0,0 +1,3 @@
|
||||
+new file line 1
|
||||
+new file line 2
|
||||
+new file line 3`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(`@@ -0,0 +1,3 @@
|
||||
L1: +new file line 1
|
||||
L2: +new file line 2
|
||||
L3: +new file line 3`);
|
||||
});
|
||||
|
||||
it('should handle "No newline at end of file" marker', () => {
|
||||
const patch = `@@ -1,2 +1,2 @@
|
||||
line 1
|
||||
-line 2
|
||||
\\ No newline at end of file
|
||||
+line 2 with newline`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
|
||||
// The marker line should be preserved
|
||||
expect(result).toContain('\\ No newline at end of file');
|
||||
|
||||
// Verify the marker line itself doesn't have a line number
|
||||
const lines = result.split('\n');
|
||||
const markerLine = lines.find((l) => l.includes('No newline at end of file'));
|
||||
expect(markerLine).toBe('\\ No newline at end of file');
|
||||
});
|
||||
|
||||
it('should handle empty lines (context)', () => {
|
||||
const patch = `@@ -1,4 +1,4 @@
|
||||
line 1
|
||||
|
||||
line 3
|
||||
+line 4`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(`@@ -1,4 +1,4 @@
|
||||
L1: line 1
|
||||
L2:
|
||||
L3: line 3
|
||||
L4: +line 4`);
|
||||
});
|
||||
|
||||
it('should handle consecutive additions', () => {
|
||||
const patch = `@@ -1,1 +1,5 @@
|
||||
existing line
|
||||
+added line 1
|
||||
+added line 2
|
||||
+added line 3
|
||||
+added line 4`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(`@@ -1,1 +1,5 @@
|
||||
L1: existing line
|
||||
L2: +added line 1
|
||||
L3: +added line 2
|
||||
L4: +added line 3
|
||||
L5: +added line 4`);
|
||||
});
|
||||
|
||||
it('should handle consecutive deletions', () => {
|
||||
const patch = `@@ -1,5 +1,1 @@
|
||||
kept line
|
||||
-deleted 1
|
||||
-deleted 2
|
||||
-deleted 3
|
||||
-deleted 4`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(`@@ -1,5 +1,1 @@
|
||||
L1: kept line
|
||||
-deleted 1
|
||||
-deleted 2
|
||||
-deleted 3
|
||||
-deleted 4`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hunk header variations', () => {
|
||||
it('should handle hunk header without line count', () => {
|
||||
const patch = `@@ -1 +1,2 @@
|
||||
line 1
|
||||
+line 2`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toBe(`@@ -1 +1,2 @@
|
||||
L1: line 1
|
||||
L2: +line 2`);
|
||||
});
|
||||
|
||||
it('should handle hunk header with function context', () => {
|
||||
const patch = `@@ -10,3 +10,4 @@ function myFunction() {
|
||||
const x = 1;
|
||||
const y = 2;
|
||||
+ const z = 3;
|
||||
return x + y;`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
expect(result).toContain('L10: const x = 1;');
|
||||
expect(result).toContain('L11: const y = 2;');
|
||||
expect(result).toContain('L12: + const z = 3;');
|
||||
expect(result).toContain('L13: return x + y;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trailing content', () => {
|
||||
it('should handle trailing blank line without annotation', () => {
|
||||
// Git diffs often have trailing blank lines
|
||||
const patch = `@@ -1,2 +1,2 @@
|
||||
line 1
|
||||
+added line
|
||||
`;
|
||||
|
||||
const result = annotateSingleFileDiffWithLineNumbers(patch);
|
||||
|
||||
// The trailing blank should not be annotated
|
||||
expect(result).toBe(`@@ -1,2 +1,2 @@
|
||||
L1: line 1
|
||||
L2: +added line
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Git Metadata Tests
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
import { extractMetadata } from '../../../src/codeScan/git/metadata';
|
||||
import { GitMetadataError } from '../../../src/types/codeScan';
|
||||
import type { SimpleGit } from 'simple-git';
|
||||
|
||||
// Define a partial mock type for the SimpleGit methods we use
|
||||
type MockSimpleGit = Pick<SimpleGit, 'log' | 'revparse'> & {
|
||||
log: Mock;
|
||||
revparse: Mock;
|
||||
};
|
||||
|
||||
// Mock simple-git with a factory that returns a mock instance
|
||||
const mockGit: MockSimpleGit = {
|
||||
log: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
default: vi.fn(() => mockGit),
|
||||
}));
|
||||
|
||||
describe('Git Metadata', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('extractMetadata', () => {
|
||||
it('should extract metadata from git log', async () => {
|
||||
const mockLog = {
|
||||
all: [
|
||||
{
|
||||
hash: 'abc1234',
|
||||
date: '2025-01-15T10:30:00Z',
|
||||
message: 'Add LLM integration',
|
||||
author_name: 'Test User',
|
||||
author_email: 'test@example.com',
|
||||
},
|
||||
{
|
||||
hash: 'def5678',
|
||||
date: '2025-01-14T15:20:00Z',
|
||||
message: 'Fix prompt handling',
|
||||
author_name: 'Test User',
|
||||
author_email: 'test@example.com',
|
||||
},
|
||||
],
|
||||
latest: {
|
||||
hash: 'abc1234',
|
||||
date: '2025-01-15T10:30:00Z',
|
||||
message: 'Add LLM integration',
|
||||
author_name: 'Test User',
|
||||
author_email: 'test@example.com',
|
||||
},
|
||||
total: 2,
|
||||
};
|
||||
|
||||
mockGit.log.mockResolvedValue(mockLog as any);
|
||||
(mockGit.revparse as any).mockImplementation(async (options: any) => {
|
||||
const ref = Array.isArray(options) ? options[0] : options;
|
||||
return ref === 'main' ? 'base-sha-123456' : 'compare-sha-789012';
|
||||
});
|
||||
|
||||
const metadata = await extractMetadata('/fake/repo', 'main', 'feature/test');
|
||||
|
||||
expect(metadata).toEqual({
|
||||
branch: 'feature/test',
|
||||
baseBranch: 'main',
|
||||
baseRef: 'main',
|
||||
baseSha: 'base-sha-123456',
|
||||
compareRef: 'feature/test',
|
||||
compareSha: 'compare-sha-789012',
|
||||
commitMessages: ['abc1234: Add LLM integration', 'def5678: Fix prompt handling'],
|
||||
author: 'Test User',
|
||||
timestamp: '2025-01-15T10:30:00Z',
|
||||
});
|
||||
|
||||
expect(mockGit.log).toHaveBeenCalledWith({
|
||||
from: 'main',
|
||||
to: 'feature/test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single commit', async () => {
|
||||
const mockLog = {
|
||||
all: [
|
||||
{
|
||||
hash: 'abc1234567',
|
||||
date: '2025-01-15T10:30:00Z',
|
||||
message: 'Initial commit',
|
||||
author_name: 'Test User',
|
||||
author_email: 'test@example.com',
|
||||
},
|
||||
],
|
||||
latest: {
|
||||
hash: 'abc1234567',
|
||||
date: '2025-01-15T10:30:00Z',
|
||||
message: 'Initial commit',
|
||||
author_name: 'Test User',
|
||||
author_email: 'test@example.com',
|
||||
},
|
||||
total: 1,
|
||||
};
|
||||
|
||||
mockGit.log.mockResolvedValue(mockLog as any);
|
||||
(mockGit.revparse as any).mockImplementation(async (options: any) => {
|
||||
const ref = Array.isArray(options) ? options[0] : options;
|
||||
return ref === 'main' ? 'base-sha-123456' : 'compare-sha-789012';
|
||||
});
|
||||
|
||||
const metadata = await extractMetadata('/fake/repo', 'main', 'feature/test');
|
||||
|
||||
expect(metadata.commitMessages).toEqual(['abc1234: Initial commit']);
|
||||
expect(metadata.author).toBe('Test User');
|
||||
});
|
||||
|
||||
it('should handle no commits with default values', async () => {
|
||||
const mockLog = {
|
||||
all: [],
|
||||
latest: null,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
mockGit.log.mockResolvedValue(mockLog as any);
|
||||
(mockGit.revparse as any).mockImplementation(async (options: any) => {
|
||||
const ref = Array.isArray(options) ? options[0] : options;
|
||||
return ref === 'main' ? 'base-sha-123456' : 'compare-sha-789012';
|
||||
});
|
||||
|
||||
const metadata = await extractMetadata('/fake/repo', 'main', 'feature/test');
|
||||
|
||||
expect(metadata.commitMessages).toEqual([]);
|
||||
expect(metadata.author).toBe('Unknown');
|
||||
expect(metadata.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); // ISO date format
|
||||
});
|
||||
|
||||
it('should throw GitMetadataError if log extraction fails', async () => {
|
||||
mockGit.log.mockRejectedValue(new Error('git log failed'));
|
||||
|
||||
await expect(extractMetadata('/fake/repo', 'main', 'feature/test')).rejects.toThrow(
|
||||
GitMetadataError,
|
||||
);
|
||||
await expect(extractMetadata('/fake/repo', 'main', 'feature/test')).rejects.toThrow(
|
||||
'Failed to extract git metadata',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle commits with multiline messages', async () => {
|
||||
const mockLog = {
|
||||
all: [
|
||||
{
|
||||
hash: 'abc123def',
|
||||
date: '2025-01-15T10:30:00Z',
|
||||
message: 'Add feature\n\nDetailed description here',
|
||||
author_name: 'Test User',
|
||||
author_email: 'test@example.com',
|
||||
},
|
||||
],
|
||||
latest: {
|
||||
hash: 'abc123def',
|
||||
date: '2025-01-15T10:30:00Z',
|
||||
message: 'Add feature\n\nDetailed description here',
|
||||
author_name: 'Test User',
|
||||
author_email: 'test@example.com',
|
||||
},
|
||||
total: 1,
|
||||
};
|
||||
|
||||
mockGit.log.mockResolvedValue(mockLog as any);
|
||||
(mockGit.revparse as any).mockImplementation(async (options: any) => {
|
||||
const ref = Array.isArray(options) ? options[0] : options;
|
||||
return ref === 'main' ? 'base-sha-123456' : 'compare-sha-789012';
|
||||
});
|
||||
|
||||
const metadata = await extractMetadata('/fake/repo', 'main', 'feature/test');
|
||||
|
||||
expect(metadata.commitMessages).toEqual([
|
||||
'abc123d: Add feature\n\nDetailed description here',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use latest commit for author and timestamp', async () => {
|
||||
const mockLog = {
|
||||
all: [
|
||||
{
|
||||
hash: 'abc1234',
|
||||
date: '2025-01-15T10:30:00Z',
|
||||
message: 'Latest commit',
|
||||
author_name: 'Latest Author',
|
||||
author_email: 'latest@example.com',
|
||||
},
|
||||
{
|
||||
hash: 'def5678',
|
||||
date: '2025-01-14T15:20:00Z',
|
||||
message: 'Older commit',
|
||||
author_name: 'Old Author',
|
||||
author_email: 'old@example.com',
|
||||
},
|
||||
],
|
||||
latest: {
|
||||
hash: 'abc1234',
|
||||
date: '2025-01-15T10:30:00Z',
|
||||
message: 'Latest commit',
|
||||
author_name: 'Latest Author',
|
||||
author_email: 'latest@example.com',
|
||||
},
|
||||
total: 2,
|
||||
};
|
||||
|
||||
mockGit.log.mockResolvedValue(mockLog as any);
|
||||
(mockGit.revparse as any).mockImplementation(async (options: any) => {
|
||||
const ref = Array.isArray(options) ? options[0] : options;
|
||||
return ref === 'main' ? 'base-sha-123456' : 'compare-sha-789012';
|
||||
});
|
||||
|
||||
const metadata = await extractMetadata('/fake/repo', 'main', 'feature/test');
|
||||
|
||||
expect(metadata.author).toBe('Latest Author');
|
||||
expect(metadata.timestamp).toBe('2025-01-15T10:30:00Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Tests for git diff --raw -z parser
|
||||
*
|
||||
* These tests verify correct handling of renamed and copied files
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseRawDiff } from '../../../src/codeScan/git/rawDiffParser';
|
||||
|
||||
describe('parseRawDiff', () => {
|
||||
describe('basic file operations', () => {
|
||||
it('should parse modified files correctly', () => {
|
||||
const rawOutput = ':100644 100644 abc123 def456 M\0src/file.ts\0';
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
path: 'src/file.ts',
|
||||
status: 'M',
|
||||
shaA: 'abc123',
|
||||
shaB: 'def456',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse added files correctly', () => {
|
||||
const rawOutput =
|
||||
':000000 100644 0000000000000000000000000000000000000000 abc123 A\0src/new.ts\0';
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
path: 'src/new.ts',
|
||||
status: 'A',
|
||||
shaA: null,
|
||||
shaB: 'abc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse deleted files correctly', () => {
|
||||
const rawOutput =
|
||||
':100644 000000 abc123 0000000000000000000000000000000000000000 D\0src/old.ts\0';
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
path: 'src/old.ts',
|
||||
status: 'D',
|
||||
shaA: 'abc123',
|
||||
shaB: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse multiple regular files correctly', () => {
|
||||
const rawOutput = [
|
||||
':100644 100644 abc123 def456 M',
|
||||
'file1.ts',
|
||||
':100644 100644 111111 222222 M',
|
||||
'file2.ts',
|
||||
'',
|
||||
].join('\0');
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].path).toBe('file1.ts');
|
||||
expect(result[0].status).toBe('M');
|
||||
expect(result[1].path).toBe('file2.ts');
|
||||
expect(result[1].status).toBe('M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renamed files', () => {
|
||||
it('should parse renamed file and use NEW path', () => {
|
||||
// Git diff --raw -z output for a renamed file has TWO paths
|
||||
// Format: :oldmode newmode oldsha newsha status\0oldpath\0newpath\0
|
||||
const rawOutput = [
|
||||
':100644 100644 abc123 def456 R100',
|
||||
'old/path/file.ts',
|
||||
'new/path/file.ts',
|
||||
'',
|
||||
].join('\0');
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('new/path/file.ts'); // Should use NEW path
|
||||
expect(result[0].oldPath).toBe('old/path/file.ts'); // Should preserve old path
|
||||
expect(result[0].status).toBe('R100');
|
||||
expect(result[0].shaA).toBe('abc123');
|
||||
expect(result[0].shaB).toBe('def456');
|
||||
});
|
||||
|
||||
it('should parse renamed file with similarity score', () => {
|
||||
const rawOutput = [':100644 100644 aaa111 aaa222 R90', 'old.ts', 'new.ts', ''].join('\0');
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('new.ts'); // NEW path
|
||||
expect(result[0].oldPath).toBe('old.ts'); // OLD path preserved
|
||||
expect(result[0].status).toBe('R90');
|
||||
});
|
||||
|
||||
it('should handle rename followed by other files', () => {
|
||||
// Multiple files: rename followed by a modify
|
||||
const rawOutput = [
|
||||
':100644 100644 aaa111 aaa222 R90',
|
||||
'old.ts',
|
||||
'new.ts',
|
||||
':100644 100644 bbb111 bbb222 M',
|
||||
'modified.ts',
|
||||
'',
|
||||
].join('\0');
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
// Should get both files correctly
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
// First file: renamed
|
||||
expect(result[0].path).toBe('new.ts'); // NEW path
|
||||
expect(result[0].oldPath).toBe('old.ts'); // OLD path preserved
|
||||
expect(result[0].status).toBe('R90');
|
||||
|
||||
// Second file: modified (parser should not be misaligned)
|
||||
expect(result[1].path).toBe('modified.ts');
|
||||
expect(result[1].status).toBe('M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copied files', () => {
|
||||
it('should parse copied file and use destination path', () => {
|
||||
// Git diff --raw -z output for a copied file also has TWO paths
|
||||
// Format: :oldmode newmode oldsha newsha status\0sourcepath\0destpath\0
|
||||
const rawOutput = [
|
||||
':100644 100644 abc123 abc123 C100',
|
||||
'src/original.ts',
|
||||
'src/copy.ts',
|
||||
'',
|
||||
].join('\0');
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('src/copy.ts'); // Should use DESTINATION path
|
||||
expect(result[0].oldPath).toBe('src/original.ts'); // Should preserve source path
|
||||
expect(result[0].status).toBe('C100');
|
||||
expect(result[0].shaA).toBe('abc123');
|
||||
expect(result[0].shaB).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should handle copy followed by other files', () => {
|
||||
const rawOutput = [
|
||||
':100644 100644 abc123 abc123 C100',
|
||||
'original.ts',
|
||||
'copy.ts',
|
||||
':100644 100644 def456 ghi789 M',
|
||||
'other.ts',
|
||||
'',
|
||||
].join('\0');
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
// First file: copied
|
||||
expect(result[0].path).toBe('copy.ts'); // DESTINATION
|
||||
expect(result[0].oldPath).toBe('original.ts'); // SOURCE
|
||||
expect(result[0].status).toBe('C100');
|
||||
|
||||
// Second file: modified
|
||||
expect(result[1].path).toBe('other.ts');
|
||||
expect(result[1].status).toBe('M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed operations', () => {
|
||||
it('should handle mix of rename, add, modify, delete', () => {
|
||||
const rawOutput = [
|
||||
':100644 100644 aaa111 aaa222 R90',
|
||||
'old.ts',
|
||||
'new.ts',
|
||||
':000000 100644 000000 bbb111 A',
|
||||
'added.ts',
|
||||
':100644 100644 ccc111 ccc222 M',
|
||||
'modified.ts',
|
||||
':100644 000000 ddd111 000000 D',
|
||||
'deleted.ts',
|
||||
'',
|
||||
].join('\0');
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
|
||||
// Renamed file
|
||||
expect(result[0].path).toBe('new.ts');
|
||||
expect(result[0].oldPath).toBe('old.ts');
|
||||
expect(result[0].status).toBe('R90');
|
||||
|
||||
// Added file
|
||||
expect(result[1].path).toBe('added.ts');
|
||||
expect(result[1].status).toBe('A');
|
||||
|
||||
// Modified file
|
||||
expect(result[2].path).toBe('modified.ts');
|
||||
expect(result[2].status).toBe('M');
|
||||
|
||||
// Deleted file
|
||||
expect(result[3].path).toBe('deleted.ts');
|
||||
expect(result[3].status).toBe('D');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty input', () => {
|
||||
const result = parseRawDiff('');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip malformed entries', () => {
|
||||
const rawOutput = [
|
||||
':100644 100644 abc123 def456 M',
|
||||
'valid.ts',
|
||||
':invalid',
|
||||
'missing-metadata.ts',
|
||||
'',
|
||||
].join('\0');
|
||||
|
||||
const result = parseRawDiff(rawOutput);
|
||||
|
||||
// Should only get the valid entry
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('valid.ts');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user