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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
/**
* Configuration Loader Tests
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { loadConfig, loadConfigOrDefault } from '../../../src/codeScan/config/loader';
import { CodeScanSeverity, ConfigLoadError } from '../../../src/types/codeScan';
describe('Configuration Loader', () => {
let tempDir: string;
beforeEach(() => {
// Create a temporary directory for test files
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-scan-test-'));
});
afterEach(() => {
// Clean up temporary directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe('loadConfig', () => {
it('should load valid configuration file', () => {
const configPath = path.join(tempDir, 'config.yaml');
fs.writeFileSync(configPath, 'minimumSeverity: high\ndiffsOnly: false');
const config = loadConfig(configPath);
expect(config).toEqual({
minimumSeverity: CodeScanSeverity.HIGH,
diffsOnly: false,
});
});
it('should apply defaults for missing optional fields', () => {
const configPath = path.join(tempDir, 'config.yaml');
fs.writeFileSync(configPath, '{}'); // Empty object
const config = loadConfig(configPath);
expect(config).toEqual({
minimumSeverity: CodeScanSeverity.MEDIUM,
diffsOnly: false,
});
});
it('should accept all valid severity levels', () => {
const testCases: Array<[CodeScanSeverity, string]> = [
[CodeScanSeverity.LOW, 'low'],
[CodeScanSeverity.MEDIUM, 'medium'],
[CodeScanSeverity.HIGH, 'high'],
[CodeScanSeverity.CRITICAL, 'critical'],
];
for (const [expectedLevel, levelString] of testCases) {
const configPath = path.join(tempDir, `config-${levelString}.yaml`);
fs.writeFileSync(configPath, `minimumSeverity: ${levelString}\ndiffsOnly: true`);
const config = loadConfig(configPath);
expect(config.minimumSeverity).toBe(expectedLevel);
expect(config.diffsOnly).toBe(true);
}
});
it('should throw ConfigLoadError if file does not exist', () => {
const nonExistentPath = path.join(tempDir, 'does-not-exist.yaml');
expect(() => loadConfig(nonExistentPath)).toThrow(ConfigLoadError);
expect(() => loadConfig(nonExistentPath)).toThrow('Configuration file not found');
});
it('should throw ConfigLoadError if file cannot be parsed', () => {
const configPath = path.join(tempDir, 'invalid.yaml');
fs.writeFileSync(configPath, '{ invalid yaml content [[[');
expect(() => loadConfig(configPath)).toThrow(ConfigLoadError);
expect(() => loadConfig(configPath)).toThrow('Failed to parse YAML');
});
it('should throw ConfigLoadError if validation fails', () => {
const configPath = path.join(tempDir, 'invalid-schema.yaml');
fs.writeFileSync(configPath, 'minimumSeverity: invalid-level\ndiffsOnly: not-a-boolean');
expect(() => loadConfig(configPath)).toThrow(ConfigLoadError);
expect(() => loadConfig(configPath)).toThrow('Invalid configuration');
});
it('should handle boolean diffsOnly values', () => {
const configPath = path.join(tempDir, 'config.yaml');
fs.writeFileSync(configPath, 'minimumSeverity: medium\ndiffsOnly: true');
const config = loadConfig(configPath);
expect(config.diffsOnly).toBe(true);
});
it('should accept minSeverity as an alias for minimumSeverity', () => {
const configPath = path.join(tempDir, 'config.yaml');
fs.writeFileSync(configPath, 'minSeverity: critical\ndiffsOnly: true');
const config = loadConfig(configPath);
expect(config.minimumSeverity).toBe(CodeScanSeverity.CRITICAL);
expect(config.diffsOnly).toBe(true);
});
it('should prefer minSeverity over minimumSeverity when both provided', () => {
const configPath = path.join(tempDir, 'config.yaml');
fs.writeFileSync(configPath, 'minSeverity: low\nminimumSeverity: high\ndiffsOnly: false');
const config = loadConfig(configPath);
expect(config.minimumSeverity).toBe(CodeScanSeverity.LOW);
expect(config.diffsOnly).toBe(false);
});
it('should accept all valid severity levels with minSeverity alias', () => {
const testCases: Array<[CodeScanSeverity, string]> = [
[CodeScanSeverity.LOW, 'low'],
[CodeScanSeverity.MEDIUM, 'medium'],
[CodeScanSeverity.HIGH, 'high'],
[CodeScanSeverity.CRITICAL, 'critical'],
];
for (const [expectedLevel, levelString] of testCases) {
const configPath = path.join(tempDir, `config-min-${levelString}.yaml`);
fs.writeFileSync(configPath, `minSeverity: ${levelString}\ndiffsOnly: true`);
const config = loadConfig(configPath);
expect(config.minimumSeverity).toBe(expectedLevel);
expect(config.diffsOnly).toBe(true);
}
});
it('should accept inline guidance text', () => {
const configPath = path.join(tempDir, 'config.yaml');
fs.writeFileSync(
configPath,
'minimumSeverity: high\nguidance: "Focus on authentication vulnerabilities"',
);
const config = loadConfig(configPath);
expect(config.guidance).toBe('Focus on authentication vulnerabilities');
});
it('should read guidanceFile and populate guidance field', () => {
const guidanceFilePath = path.join(tempDir, 'guidance.txt');
fs.writeFileSync(guidanceFilePath, 'Focus on authentication vulnerabilities');
const configPath = path.join(tempDir, 'config.yaml');
fs.writeFileSync(configPath, `minimumSeverity: high\nguidanceFile: ${guidanceFilePath}`);
const config = loadConfig(configPath);
expect(config.guidance).toBe('Focus on authentication vulnerabilities');
});
it('should throw ConfigLoadError when guidanceFile does not exist', () => {
const configPath = path.join(tempDir, 'config.yaml');
fs.writeFileSync(configPath, 'minimumSeverity: high\nguidanceFile: ./nonexistent.txt');
expect(() => loadConfig(configPath)).toThrow(ConfigLoadError);
expect(() => loadConfig(configPath)).toThrow(/Failed to read guidance file/);
});
it('should throw ConfigLoadError when both guidance and guidanceFile are specified', () => {
const guidanceFilePath = path.join(tempDir, 'guidance.txt');
fs.writeFileSync(guidanceFilePath, 'File guidance');
const configPath = path.join(tempDir, 'config.yaml');
fs.writeFileSync(
configPath,
`minimumSeverity: high\nguidance: "Inline guidance"\nguidanceFile: ${guidanceFilePath}`,
);
expect(() => loadConfig(configPath)).toThrow(ConfigLoadError);
expect(() => loadConfig(configPath)).toThrow(/Cannot specify both guidance and guidanceFile/);
});
});
describe('loadConfigOrDefault', () => {
it('should return default config when no path provided', () => {
const config = loadConfigOrDefault();
expect(config).toEqual({
minimumSeverity: CodeScanSeverity.MEDIUM,
diffsOnly: false,
});
});
it('should load config when path provided', () => {
const configPath = path.join(tempDir, 'config.yaml');
fs.writeFileSync(configPath, 'minimumSeverity: critical\ndiffsOnly: true');
const config = loadConfigOrDefault(configPath);
expect(config).toEqual({
minimumSeverity: CodeScanSeverity.CRITICAL,
diffsOnly: true,
});
});
it('should return default config when empty string provided', () => {
const config = loadConfigOrDefault('');
expect(config).toEqual({
minimumSeverity: CodeScanSeverity.MEDIUM,
diffsOnly: false,
});
});
});
});
+180
View File
@@ -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');
});
});
});
+340
View File
@@ -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
`);
});
});
});
+224
View File
@@ -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');
});
});
});
+240
View File
@@ -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');
});
});
});
+136
View File
@@ -0,0 +1,136 @@
import { EventEmitter } from 'events';
import type { ChildProcess } from 'child_process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mockProcessEnv } from '../../util/utils';
const mocks = vi.hoisted(() => ({
spawn: vi.fn(),
}));
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: mocks.spawn,
};
});
import {
startFilesystemMcpServer,
waitForFilesystemMcpServerReady,
} from '../../../src/codeScan/mcp/filesystem';
class FakeChildProcess extends EventEmitter {
exitCode: number | null = null;
killed = false;
kill = vi.fn();
pid = 1234;
signalCode: NodeJS.Signals | null = null;
stderr = new EventEmitter();
}
function createFakeProcess(): ChildProcess & { stderr: EventEmitter } {
return new FakeChildProcess() as unknown as ChildProcess & { stderr: EventEmitter };
}
describe('filesystem MCP server management', () => {
const originalEnv = { ...process.env };
let restoreEnv: () => void;
beforeEach(() => {
vi.useFakeTimers();
vi.resetAllMocks();
restoreEnv = mockProcessEnv(originalEnv, { clear: true });
});
afterEach(() => {
restoreEnv();
vi.useRealTimers();
vi.clearAllMocks();
});
it('strips npm before config when spawning the filesystem MCP server', () => {
const restoreNpmConfig = mockProcessEnv({
NPM_CONFIG_BEFORE: '2026-03-29T00:00:00.000Z',
npm_config_before: '2026-03-29T00:00:00.000Z',
});
try {
mocks.spawn.mockReturnValue(createFakeProcess());
startFilesystemMcpServer(process.cwd());
const spawnOptions = mocks.spawn.mock.calls[0]?.[2];
expect(spawnOptions?.env?.NPM_CONFIG_BEFORE).toBeUndefined();
expect(spawnOptions?.env?.npm_config_before).toBeUndefined();
} finally {
restoreNpmConfig();
}
});
it('resolves when the filesystem MCP server prints its ready marker', async () => {
const mcpProcess = createFakeProcess();
const ready = waitForFilesystemMcpServerReady(mcpProcess);
mcpProcess.stderr.emit('data', Buffer.from('Secure MCP Filesystem Server running on stdio\n'));
await expect(ready).resolves.toBeUndefined();
});
it('resolves when the ready marker is split across stderr chunks', async () => {
const mcpProcess = createFakeProcess();
const ready = waitForFilesystemMcpServerReady(mcpProcess);
mcpProcess.stderr.emit('data', Buffer.from('Secure MCP Filesystem Server '));
mcpProcess.stderr.emit('data', Buffer.from('running on stdio\n'));
await expect(ready).resolves.toBeUndefined();
});
it('rejects when the filesystem MCP server exits before it is ready', async () => {
const mcpProcess = createFakeProcess();
const ready = waitForFilesystemMcpServerReady(mcpProcess);
mcpProcess.emit('exit', 1, null);
await expect(ready).rejects.toThrow('Filesystem MCP server exited before ready: code 1');
});
it('rejects immediately when the process has already exited', async () => {
const mcpProcess = createFakeProcess();
Object.defineProperty(mcpProcess, 'exitCode', { value: 1 });
await expect(waitForFilesystemMcpServerReady(mcpProcess)).rejects.toThrow(
'Filesystem MCP server exited before ready: code 1',
);
});
it('rejects immediately when the process was already killed', async () => {
const mcpProcess = createFakeProcess();
Object.defineProperty(mcpProcess, 'killed', { value: true });
await expect(waitForFilesystemMcpServerReady(mcpProcess)).rejects.toThrow(
'Filesystem MCP server exited before ready: unknown reason',
);
});
it('rejects when stderr is unavailable', async () => {
const mcpProcess = createFakeProcess();
Object.defineProperty(mcpProcess, 'stderr', { value: null });
await expect(waitForFilesystemMcpServerReady(mcpProcess)).rejects.toThrow(
'Filesystem MCP server stderr pipe unavailable',
);
});
it('rejects when the filesystem MCP server readiness times out', async () => {
const mcpProcess = createFakeProcess();
const ready = waitForFilesystemMcpServerReady(mcpProcess, 1000);
const expectation = expect(ready).rejects.toThrow(
'Timed out waiting for filesystem MCP server to be ready after 1000ms',
);
await vi.advanceTimersByTimeAsync(1000);
await expectation;
});
});
+249
View File
@@ -0,0 +1,249 @@
/**
* Scanner No Files Test
*
* Tests that processDiff handles cases where no files are found to scan
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CodeScanOutputFormat, type FileRecord } from '../../src/types/codeScan';
describe('Scanner - No Files to Scan', () => {
it('should filter out files with skipReason when determining includedFiles', () => {
// Simulate the result from processDiff with all files skipped
const files: FileRecord[] = [
{
path: 'package-lock.json',
status: 'M',
skipReason: 'denylist',
shaA: 'abc123',
shaB: 'def456',
linesAdded: 10,
linesRemoved: 5,
},
{
path: 'large-file.bin',
status: 'M',
skipReason: 'blob too large',
shaA: 'abc124',
shaB: 'def457',
linesAdded: 100,
linesRemoved: 50,
},
];
// This is the same logic used in scanner/index.ts
const includedFiles = files.filter((f) => !f.skipReason && f.patch);
expect(includedFiles).toHaveLength(0);
});
it('should correctly identify included files when some have no skipReason', () => {
const files: FileRecord[] = [
{
path: 'package-lock.json',
status: 'M',
skipReason: 'denylist',
shaA: 'abc123',
shaB: 'def456',
linesAdded: 10,
linesRemoved: 5,
},
{
path: 'src/index.ts',
status: 'M',
shaA: 'abc124',
shaB: 'def457',
linesAdded: 5,
linesRemoved: 2,
patch: '@@ -1,3 +1,4 @@\n+const test = true;\n const existing = true;',
},
];
const includedFiles = files.filter((f) => !f.skipReason && f.patch);
expect(includedFiles).toHaveLength(1);
expect(includedFiles[0].path).toBe('src/index.ts');
});
it('should handle empty file array', () => {
const files: FileRecord[] = [];
const includedFiles = files.filter((f) => !f.skipReason && f.patch);
expect(includedFiles).toHaveLength(0);
});
});
describe('Scanner machine-readable output', () => {
function mockScanner({
initialLogLevel = 'info',
loadConfigError,
processDiffError,
}: {
initialLogLevel?: string;
loadConfigError?: Error;
processDiffError?: Error;
} = {}) {
let currentLogLevel = initialLogLevel;
const disconnect = vi.fn();
vi.doMock('../../src/codeScan/git/diffProcessor', () => ({
processDiff: processDiffError
? vi.fn().mockRejectedValue(processDiffError)
: vi.fn().mockResolvedValue([]),
}));
vi.doMock('../../src/codeScan/git/diff', () => ({
validateOnBranch: vi.fn().mockResolvedValue('main'),
}));
vi.doMock('../../src/codeScan/config/loader', () => ({
loadConfigOrDefault: loadConfigError
? vi.fn().mockImplementation(() => {
throw loadConfigError;
})
: vi.fn().mockReturnValue({
minimumSeverity: 'medium',
diffsOnly: true,
}),
mergeConfigWithOptions: vi.fn().mockImplementation((config, options) => ({
...config,
diffsOnly: options.diffsOnly ?? config.diffsOnly,
})),
resolveGuidance: vi.fn().mockReturnValue(undefined),
resolveApiHost: vi.fn().mockReturnValue('https://api.example.com'),
}));
vi.doMock('simple-git', () => ({
default: vi.fn(() => ({
branch: vi.fn().mockResolvedValue({ current: 'main', all: ['main'] }),
revparse: vi.fn().mockResolvedValue('abc123'),
})),
}));
vi.doMock('../../src/util/agent/agentClient', () => ({
createAgentClient: vi.fn().mockResolvedValue({
sessionId: 'test-session-id',
start: vi.fn(),
cancel: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
on: vi.fn(),
emit: vi.fn(),
disconnect,
socket: { emit: vi.fn(), on: vi.fn(), off: vi.fn(), disconnect: vi.fn() },
}),
}));
vi.doMock('../../src/codeScan/util/auth', () => ({
resolveAuthCredentials: vi.fn().mockResolvedValue({ apiKey: 'test-key' }),
}));
vi.doMock('../../src/cliState', () => ({
default: { postActionCallback: null },
}));
vi.doMock('../../src/logger', () => ({
default: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
getLogLevel: vi.fn().mockImplementation(() => currentLogLevel),
setLogLevel: vi.fn().mockImplementation((level: string) => {
currentLogLevel = level;
}),
}));
vi.doMock('../../src/codeScan/scanner/cleanup', () => ({
registerCleanupHandlers: vi.fn(),
}));
vi.doMock('../../src/codeScan/scanner/output', () => ({
createSpinner: vi.fn().mockReturnValue(undefined),
displayScanResults: vi.fn(),
}));
return { disconnect };
}
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
['--json', { json: true, diffsOnly: true }, CodeScanOutputFormat.JSON],
[
'--format sarif',
{ format: CodeScanOutputFormat.SARIF, diffsOnly: true },
CodeScanOutputFormat.SARIF,
],
])('emits an empty machine-readable response when no files are available with %s', async (_label, options, expectedFormat) => {
mockScanner();
const { executeScan } = await import('../../src/codeScan/scanner/index');
const { displayScanResults } = await import('../../src/codeScan/scanner/output');
const { getLogLevel, setLogLevel } = await import('../../src/logger');
const setLogLevelMock = vi.mocked(setLogLevel);
await executeScan('/test/repo', options);
expect(displayScanResults).toHaveBeenCalledWith(
{ success: true, comments: [], review: 'No files to scan' },
expect.any(Number),
{ format: expectedFormat, githubPr: undefined },
);
expect(setLogLevelMock).toHaveBeenCalledWith('error');
expect(setLogLevelMock).toHaveBeenLastCalledWith('info');
expect(getLogLevel()).toBe('info');
});
it('restores the original log level when structured config loading fails early', async () => {
mockScanner({ loadConfigError: new Error('missing config') });
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { executeScan } = await import('../../src/codeScan/scanner/index');
const logger = (await import('../../src/logger')).default;
const { getLogLevel, setLogLevel } = await import('../../src/logger');
const setLogLevelMock = vi.mocked(setLogLevel);
await executeScan('/test/repo', { json: true, config: '/missing.yaml' });
expect(consoleErrorSpy).toHaveBeenCalledWith('Scan failed: missing config');
expect(logger.error).not.toHaveBeenCalled();
expect(setLogLevelMock).toHaveBeenNthCalledWith(1, 'error');
expect(setLogLevelMock).toHaveBeenLastCalledWith('info');
expect(getLogLevel()).toBe('info');
});
it('keeps invalid structured output flag combinations off stdout', async () => {
mockScanner();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { executeScan } = await import('../../src/codeScan/scanner/index');
const logger = (await import('../../src/logger')).default;
const { getLogLevel, setLogLevel } = await import('../../src/logger');
const setLogLevelMock = vi.mocked(setLogLevel);
await executeScan('/test/repo', { json: true, format: CodeScanOutputFormat.SARIF });
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Scan failed: Cannot combine --json with --format sarif',
);
expect(logger.error).not.toHaveBeenCalled();
expect(setLogLevelMock).not.toHaveBeenCalled();
expect(getLogLevel()).toBe('info');
});
it('keeps structured-output suppression active until cleanup finishes', async () => {
const { disconnect } = mockScanner({
initialLogLevel: 'debug',
processDiffError: new Error('diff failed'),
});
const { executeScan } = await import('../../src/codeScan/scanner/index');
const { getLogLevel, setLogLevel } = await import('../../src/logger');
const setLogLevelMock = vi.mocked(setLogLevel);
await executeScan('/test/repo', { json: true });
expect(disconnect).toHaveBeenCalled();
expect(setLogLevelMock.mock.calls).toEqual([['error'], ['debug']]);
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(
setLogLevelMock.mock.invocationCallOrder[1] ?? Number.POSITIVE_INFINITY,
);
expect(getLogLevel()).toBe('debug');
});
});
+190
View File
@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('Scanner fork PR auth rejection', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = 0;
});
function mockForkPrAuthScanner() {
vi.doMock('../../../src/codeScan/git/diffProcessor', () => ({
processDiff: vi.fn().mockResolvedValue([
{
path: 'src/index.ts',
status: 'M',
shaA: 'abc123',
shaB: 'def456',
linesAdded: 5,
linesRemoved: 2,
patch: '@@ -1,3 +1,4 @@\n+const test = true;\n const existing = true;',
},
]),
}));
vi.doMock('../../../src/codeScan/git/diff', () => ({
validateOnBranch: vi.fn().mockResolvedValue('main'),
}));
vi.doMock('../../../src/codeScan/git/metadata', () => ({
extractMetadata: vi.fn().mockResolvedValue({
branch: 'main',
baseBranch: 'main',
baseRef: 'main',
baseSha: 'base123',
compareRef: 'HEAD',
compareSha: 'compare123',
commitMessages: ['test commit'],
author: 'Minh Vu',
timestamp: '2026-05-23T00:00:00.000Z',
}),
}));
vi.doMock('../../../src/codeScan/config/loader', () => ({
loadConfigOrDefault: vi.fn().mockReturnValue({
minimumSeverity: 'medium',
diffsOnly: true,
}),
mergeConfigWithOptions: vi.fn().mockImplementation((config, options) => ({
...config,
diffsOnly: options.diffsOnly ?? config.diffsOnly,
})),
resolveGuidance: vi.fn().mockReturnValue(undefined),
resolveApiHost: vi.fn().mockReturnValue('https://api.example.com'),
}));
vi.doMock('simple-git', () => ({
default: vi.fn(() => ({
branch: vi.fn().mockResolvedValue({ current: 'main', all: ['main'] }),
revparse: vi.fn().mockResolvedValue('abc123'),
})),
}));
vi.doMock('../../../src/util/agent/agentClient', () => ({
createAgentClient: vi.fn().mockResolvedValue({
sessionId: 'test-session-id',
disconnect: vi.fn(),
socket: { io: { off: vi.fn() } },
}),
}));
vi.doMock('../../../src/codeScan/util/auth', () => ({
resolveAuthCredentials: vi.fn().mockReturnValue({ apiKey: 'test-key' }),
}));
vi.doMock('../../../src/cliState', () => ({
default: { postActionCallback: null },
}));
vi.doMock('../../../src/logger', () => ({
default: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
getLogLevel: vi.fn().mockReturnValue('info'),
setLogLevel: vi.fn(),
}));
vi.doMock('../../../src/codeScan/scanner/cleanup', () => ({
registerCleanupHandlers: vi.fn(),
}));
vi.doMock('../../../src/codeScan/scanner/output', () => ({
createSpinner: vi.fn().mockReturnValue(undefined),
displayScanResults: vi.fn(),
}));
vi.doMock('../../../src/codeScan/scanner/request', () => ({
buildScanRequest: vi.fn().mockReturnValue({ request: 'test' }),
executeScanRequestWithRetry: vi
.fn()
.mockRejectedValue(new Error('Fork PR scanning not authorized')),
}));
}
const SKIP_MESSAGE = 'Fork PR scanning requires maintainer approval. See PR comment for options.';
it('emits a structured skip response with skipReason in json mode', async () => {
mockForkPrAuthScanner();
const { executeScan } = await import('../../../src/codeScan/scanner/index');
const { displayScanResults } = await import('../../../src/codeScan/scanner/output');
const { setLogLevel } = await import('../../../src/logger');
const cliState = (await import('../../../src/cliState')).default;
const { executeScanRequestWithRetry } = await import('../../../src/codeScan/scanner/request');
const { processDiff } = await import('../../../src/codeScan/git/diffProcessor');
await executeScan('/test/repo', {
format: 'json',
diffsOnly: true,
githubPr: 'test-owner/test-repo#123',
});
expect(processDiff).toHaveBeenCalled();
expect(executeScanRequestWithRetry).toHaveBeenCalled();
expect(displayScanResults).toHaveBeenCalledWith(
{
success: true,
comments: [],
skipReason: SKIP_MESSAGE,
},
expect.any(Number),
{
format: 'json',
githubPr: 'test-owner/test-repo#123',
},
);
expect(setLogLevel).toHaveBeenCalledWith('error');
expect(typeof cliState.postActionCallback).toBe('function');
await cliState.postActionCallback?.();
expect(process.exitCode).toBe(0);
});
it('does not emit empty SARIF when fork authorization skips the scan', async () => {
mockForkPrAuthScanner();
vi.doUnmock('../../../src/codeScan/scanner/output');
const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { executeScan } = await import('../../../src/codeScan/scanner/index');
const cliState = (await import('../../../src/cliState')).default;
const logger = (await import('../../../src/logger')).default;
await executeScan('/test/repo', {
format: 'sarif',
diffsOnly: true,
githubPr: 'test-owner/test-repo#123',
});
await cliState.postActionCallback?.();
expect(stdoutSpy).not.toHaveBeenCalled();
expect(stderrSpy).toHaveBeenCalledWith(
`Scan skipped: ${SKIP_MESSAGE} SARIF output was not generated because the scan did not complete.`,
);
expect(logger.error).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it('emits stdout JSON that the action can round-trip parse', async () => {
mockForkPrAuthScanner();
// Use the real output module so we exercise the actual stdout serialization.
vi.doUnmock('../../../src/codeScan/scanner/output');
const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { executeScan } = await import('../../../src/codeScan/scanner/index');
const cliState = (await import('../../../src/cliState')).default;
await executeScan('/test/repo', {
json: true,
diffsOnly: true,
githubPr: 'test-owner/test-repo#123',
});
const stdout = stdoutSpy.mock.calls.map((args) => args.join('')).join('');
const parsed = JSON.parse(stdout);
expect(parsed).toMatchObject({
success: true,
comments: [],
skipReason: SKIP_MESSAGE,
});
expect(parsed).not.toHaveProperty('commentsPosted');
await cliState.postActionCallback?.();
expect(process.exitCode).toBe(0);
});
});
+216
View File
@@ -0,0 +1,216 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { executeScanRequestWithRetry } from '../../../src/codeScan/scanner/request';
import { sleepWithAbort } from '../../../src/util/time';
import type { ScanRequest, ScanResponse } from '../../../src/types/codeScan';
import type { AgentClient } from '../../../src/util/agent/agentClient';
vi.mock('../../../src/util/time', () => ({
sleepWithAbort: vi.fn().mockResolvedValue(undefined),
}));
type ScanOutcome =
| { type: 'complete'; response: ScanResponse }
| { type: 'error'; message: string };
function createMockAgentClient(outcomes: ScanOutcome[]) {
let completeHandler: ((response: ScanResponse) => void) | undefined;
let errorHandler: ((error: { type: string; message: string }) => void) | undefined;
let cancelledHandler: (() => void) | undefined;
const client = {
sessionId: 'test-session',
start: vi.fn(() => {
const outcome = outcomes.shift();
queueMicrotask(() => {
if (!outcome) {
errorHandler?.({ type: 'test_error', message: 'No test outcome configured' });
return;
}
if (outcome.type === 'complete') {
completeHandler?.(outcome.response);
} else {
errorHandler?.({ type: 'agent_error', message: outcome.message });
}
});
}),
cancel: vi.fn(),
onComplete: vi.fn((handler: (response: ScanResponse) => void) => {
completeHandler = handler;
}),
onError: vi.fn((handler: (error: { type: string; message: string }) => void) => {
errorHandler = handler;
}),
onCancelled: vi.fn((handler: () => void) => {
cancelledHandler = handler;
}),
on: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
socket: {
io: {
once: vi.fn(),
off: vi.fn(),
},
},
} as unknown as AgentClient;
return {
client,
cancelledHandler: () => cancelledHandler,
};
}
const scanRequest = {
sessionId: 'test-session',
} as ScanRequest;
const scanResponse: ScanResponse = {
success: true,
comments: [],
review: 'all clear',
};
function createExecutionOptions() {
return {
showSpinner: false,
abortController: new AbortController(),
};
}
describe('executeScanRequestWithRetry', () => {
beforeEach(() => {
vi.mocked(sleepWithAbort).mockReset();
vi.mocked(sleepWithAbort).mockResolvedValue(undefined);
});
afterEach(() => {
vi.mocked(sleepWithAbort).mockReset();
});
it('retries once when the remote scanner times out waiting for MCP repository access', async () => {
const { client } = createMockAgentClient([
{ type: 'error', message: 'Internal server error: MCP error -32001: Request timed out' },
{ type: 'complete', response: scanResponse },
]);
await expect(
executeScanRequestWithRetry(client, scanRequest, createExecutionOptions()),
).resolves.toEqual(scanResponse);
expect(client.start).toHaveBeenCalledTimes(2);
expect(sleepWithAbort).toHaveBeenCalledTimes(1);
});
it('stops after one retry for repeated MCP repository access timeouts', async () => {
const { client } = createMockAgentClient([
{ type: 'error', message: 'Internal server error: MCP error -32001: Request timed out' },
{ type: 'error', message: 'Internal server error: MCP error -32001: Request timed out' },
{ type: 'complete', response: scanResponse },
]);
await expect(
executeScanRequestWithRetry(client, scanRequest, createExecutionOptions()),
).rejects.toThrow('Internal server error: MCP error -32001: Request timed out');
expect(client.start).toHaveBeenCalledTimes(2);
expect(sleepWithAbort).toHaveBeenCalledTimes(1);
});
it('continues to use the longer retry budget for server capacity errors', async () => {
const { client } = createMockAgentClient([
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'complete', response: scanResponse },
]);
await expect(
executeScanRequestWithRetry(client, scanRequest, createExecutionOptions()),
).resolves.toEqual(scanResponse);
expect(client.start).toHaveBeenCalledTimes(3);
expect(sleepWithAbort).toHaveBeenCalledTimes(2);
});
it('succeeds on first attempt without retrying', async () => {
const { client } = createMockAgentClient([{ type: 'complete', response: scanResponse }]);
await expect(
executeScanRequestWithRetry(client, scanRequest, createExecutionOptions()),
).resolves.toEqual(scanResponse);
expect(client.start).toHaveBeenCalledTimes(1);
expect(sleepWithAbort).not.toHaveBeenCalled();
});
it('enforces MCP timeout budget independently when preceded by capacity errors', async () => {
const { client } = createMockAgentClient([
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'error', message: 'Internal server error: MCP error -32001: Request timed out' },
{ type: 'error', message: 'Internal server error: MCP error -32001: Request timed out' },
{ type: 'complete', response: scanResponse },
]);
// Two capacity retries succeed, then the MCP timeout budget (2) is exhausted
await expect(
executeScanRequestWithRetry(client, scanRequest, createExecutionOptions()),
).rejects.toThrow('Internal server error: MCP error -32001: Request timed out');
// 2 capacity + 2 MCP timeout = 4 total starts
expect(client.start).toHaveBeenCalledTimes(4);
expect(sleepWithAbort).toHaveBeenCalledTimes(3);
});
it('enforces capacity budget independently when preceded by MCP timeout', async () => {
const { client } = createMockAgentClient([
{ type: 'error', message: 'Internal server error: MCP error -32001: Request timed out' },
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'complete', response: scanResponse },
]);
// MCP timeout uses 1 of its 2 attempts, then capacity error uses 1 of its 7 → succeeds
await expect(
executeScanRequestWithRetry(client, scanRequest, createExecutionOptions()),
).resolves.toEqual(scanResponse);
expect(client.start).toHaveBeenCalledTimes(3);
expect(sleepWithAbort).toHaveBeenCalledTimes(2);
});
it('can succeed after mixed transient failures exceed one policy total', async () => {
const { client } = createMockAgentClient([
{ type: 'error', message: 'Internal server error: MCP error -32001: Request timed out' },
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'error', message: 'Server at capacity. Please retry.' },
{ type: 'complete', response: scanResponse },
]);
await expect(
executeScanRequestWithRetry(client, scanRequest, createExecutionOptions()),
).resolves.toEqual(scanResponse);
expect(client.start).toHaveBeenCalledTimes(8);
expect(sleepWithAbort).toHaveBeenCalledTimes(7);
});
it('does not retry non-transient scanner errors', async () => {
const { client } = createMockAgentClient([
{ type: 'error', message: 'Invalid scan request' },
{ type: 'complete', response: scanResponse },
]);
await expect(
executeScanRequestWithRetry(client, scanRequest, createExecutionOptions()),
).rejects.toThrow('Invalid scan request');
expect(client.start).toHaveBeenCalledTimes(1);
expect(sleepWithAbort).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { resolveOutputFormat } from '../../../src/codeScan/scanner/index';
import { CodeScanOutputFormat } from '../../../src/types/codeScan';
describe('resolveOutputFormat', () => {
it('defaults to TEXT when neither --json nor --format is given', () => {
expect(resolveOutputFormat({})).toBe(CodeScanOutputFormat.TEXT);
});
it('returns the requested --format value', () => {
expect(resolveOutputFormat({ format: 'text' })).toBe(CodeScanOutputFormat.TEXT);
expect(resolveOutputFormat({ format: 'json' })).toBe(CodeScanOutputFormat.JSON);
expect(resolveOutputFormat({ format: 'sarif' })).toBe(CodeScanOutputFormat.SARIF);
});
it('promotes --json to JSON regardless of the default --format', () => {
expect(resolveOutputFormat({ json: true })).toBe(CodeScanOutputFormat.JSON);
});
it('lets --json win over --format text (back-compat with the old --json-only flag)', () => {
expect(resolveOutputFormat({ json: true, format: 'text' })).toBe(CodeScanOutputFormat.JSON);
});
it('treats --json + --format json as JSON without complaint', () => {
expect(resolveOutputFormat({ json: true, format: 'json' })).toBe(CodeScanOutputFormat.JSON);
});
it('rejects --json + --format sarif as ambiguous', () => {
expect(() => resolveOutputFormat({ json: true, format: 'sarif' })).toThrow(
/Cannot combine --json with --format sarif/,
);
});
it('rejects unknown --format values with a discoverable message', () => {
expect(() => resolveOutputFormat({ format: 'xml' })).toThrow(
/Invalid output format "xml".*text, json, sarif/,
);
});
});
+194
View File
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest';
import {
clampCommentLines,
extractValidLineRanges,
isLineInDiff,
} from '../../../src/codeScan/util/diffLineRanges';
describe('extractValidLineRanges', () => {
it('should handle empty diff', () => {
expect(extractValidLineRanges('')).toEqual(new Map());
});
it('should extract ranges from single file with single hunk', () => {
const diff = `diff --git a/src/foo.ts b/src/foo.ts
--- a/src/foo.ts
+++ b/src/foo.ts
@@ -10,7 +10,8 @@
context line 1
context line 2
- removed line
+ added line 1
+ added line 2
context line 3
context line 4`;
const ranges = extractValidLineRanges(diff);
expect(ranges.get('src/foo.ts')).toEqual([{ start: 10, end: 15 }]);
});
it('should extract ranges from single file with multiple hunks (gap between)', () => {
const diff = `diff --git a/src/foo.ts b/src/foo.ts
--- a/src/foo.ts
+++ b/src/foo.ts
@@ -10,5 +10,5 @@
context
- old
+ new
context
context
@@ -50,4 +50,5 @@
more context
+ added
even more
end`;
const ranges = extractValidLineRanges(diff);
const fileRanges = ranges.get('src/foo.ts');
expect(fileRanges).toHaveLength(2);
expect(fileRanges![0]).toEqual({ start: 10, end: 13 });
expect(fileRanges![1]).toEqual({ start: 50, end: 53 });
});
it('should extract ranges from multiple files', () => {
const diff = `diff --git a/src/a.ts b/src/a.ts
--- a/src/a.ts
+++ b/src/a.ts
@@ -1,3 +1,4 @@
line 1
+added
line 2
line 3
diff --git a/src/b.ts b/src/b.ts
--- a/src/b.ts
+++ b/src/b.ts
@@ -5,2 +5,3 @@
line 5
+added
line 6`;
const ranges = extractValidLineRanges(diff);
expect(ranges.get('src/a.ts')).toEqual([{ start: 1, end: 4 }]);
expect(ranges.get('src/b.ts')).toEqual([{ start: 5, end: 7 }]);
});
it('should handle new file (all additions)', () => {
const diff = `diff --git a/src/new.ts b/src/new.ts
new file mode 100644
--- /dev/null
+++ b/src/new.ts
@@ -0,0 +1,5 @@
+line 1
+line 2
+line 3
+line 4
+line 5`;
const ranges = extractValidLineRanges(diff);
expect(ranges.get('src/new.ts')).toEqual([{ start: 1, end: 5 }]);
});
it('should handle deleted file (no valid ranges)', () => {
const diff = `diff --git a/src/deleted.ts b/src/deleted.ts
deleted file mode 100644
--- a/src/deleted.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-line 1
-line 2
-line 3`;
const ranges = extractValidLineRanges(diff);
expect(ranges.has('src/deleted.ts')).toBe(false);
});
});
describe('clampCommentLines', () => {
const ranges = new Map([
[
'src/foo.ts',
[
{ start: 10, end: 20 },
{ start: 50, end: 60 },
],
],
]);
it('should return line unchanged if valid (single-line)', () => {
expect(clampCommentLines('src/foo.ts', null, 15, ranges)).toEqual({
startLine: null,
line: 15,
});
});
it('should clamp single-line comment in gap to end of previous hunk', () => {
expect(clampCommentLines('src/foo.ts', null, 30, ranges)).toEqual({
startLine: null,
line: 20,
});
});
it('should return both lines unchanged if valid (multi-line)', () => {
expect(clampCommentLines('src/foo.ts', 12, 18, ranges)).toEqual({
startLine: 12,
line: 18,
});
});
it('should clamp end line if it extends into gap', () => {
// Start at 15 (valid), end at 25 (in gap) -> clamp end to 20
expect(clampCommentLines('src/foo.ts', 15, 25, ranges)).toEqual({
startLine: 15,
line: 20,
});
});
it('should return null for unknown file', () => {
expect(clampCommentLines('src/unknown.ts', 10, 20, ranges)).toBeNull();
});
it('should return null for null endLine', () => {
expect(clampCommentLines('src/foo.ts', 10, null, ranges)).toBeNull();
});
});
describe('integration: ENG-1309 scenario', () => {
it('should clamp comment that extends into gap between hunks', () => {
// Based on the actual PR that triggered ENG-1309
const diff = `diff --git a/example-app/src/tools/index.ts b/example-app/src/tools/index.ts
--- a/example-app/src/tools/index.ts
+++ b/example-app/src/tools/index.ts
@@ -53,7 +53,7 @@ export function executeTool(toolCall: ToolCall, userContext: UserContext): ToolR
// Secure level: always use authenticated user's role as user_id
userId = userContext.role;
} else {
- // Insecure level: allow any user_id (no access control)
+ // Insecure/Medium level: allow any user_id (no access control)
const providedUserId = args.user_id as string | undefined;
userId = providedUserId === 'current' || providedUserId === 'me' || !providedUserId
? userContext.role
@@ -68,7 +68,7 @@ export function executeTool(toolCall: ToolCall, userContext: UserContext): ToolR
// Secure level: always use authenticated user's role as user_id
userId = userContext.role;
} else {
- // Insecure level: allow any user_id (no access control)
+ // Insecure/Medium level: allow any user_id (no access control)
const providedUserId = args.user_id as string | undefined;`;
const ranges = extractValidLineRanges(diff);
// Agent tried to comment on lines 56-62, but line 62 is in the gap (59-68)
const result = clampCommentLines('example-app/src/tools/index.ts', 56, 62, ranges);
expect(result).toEqual({
startLine: 56,
line: 59, // clamped from 62 to end of first hunk
});
// Verify the gap detection
expect(isLineInDiff('example-app/src/tools/index.ts', 59, ranges)).toBe(true);
expect(isLineInDiff('example-app/src/tools/index.ts', 62, ranges)).toBe(false);
});
});
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import {
ALL_CLEAR_MESSAGE,
hasPrPostableFindings,
prepareComments,
} from '../../../src/codeScan/util/github';
import { CodeScanSeverity, type Comment } from '../../../src/types/codeScan';
describe('prepareComments', () => {
it('should return empty arrays and empty review body when no comments', () => {
const result = prepareComments([], undefined, undefined);
expect(result.lineComments).toEqual([]);
expect(result.generalComments).toEqual([]);
expect(result.reviewBody).toBe('');
});
it('should prepend "All Clear" when only severity=none comments exist', () => {
const comments: Comment[] = [
{
file: null,
line: null,
severity: CodeScanSeverity.NONE,
finding: 'No vulnerabilities found',
},
];
const review = 'Scanned 10 files. No issues detected.';
const result = prepareComments(comments, review, 'medium');
expect(result.reviewBody).toContain(ALL_CLEAR_MESSAGE);
expect(result.reviewBody).toContain('Scanned 10 files');
});
it('should separate line-specific from general comments', () => {
const comments: Comment[] = [
{
file: 'src/test.ts',
line: 42,
severity: CodeScanSeverity.HIGH,
finding: 'SQL injection vulnerability',
},
{
file: null,
line: null,
severity: CodeScanSeverity.MEDIUM,
finding: 'General security issue',
},
{
file: 'src/file-only.ts',
line: null,
severity: CodeScanSeverity.LOW,
finding: 'File-level security issue',
},
];
const result = prepareComments(comments, 'Review text', 'low');
expect(result.lineComments).toHaveLength(1);
expect(result.lineComments[0].file).toBe('src/test.ts');
expect(result.generalComments).toHaveLength(2);
expect(result.generalComments.map((comment) => comment.file)).toEqual([
null,
'src/file-only.ts',
]);
});
it.each([
null,
0,
-1,
1.5,
])('should route findings with a non-inlineable line (%s) to general comments', (line) => {
const comment: Comment = {
file: 'src/example.ts',
line,
severity: CodeScanSeverity.HIGH,
finding: 'File-scoped issue',
};
const result = prepareComments([comment], 'Review', 'low');
expect(result.lineComments).toEqual([]);
expect(result.generalComments).toEqual([comment]);
});
it('should filter out severity=none from general comments', () => {
const comments: Comment[] = [
{
file: null,
line: null,
severity: CodeScanSeverity.NONE,
finding: 'All clear message',
},
{
file: null,
line: null,
severity: CodeScanSeverity.LOW,
finding: 'Actual issue',
},
];
const result = prepareComments(comments, 'Review', 'low');
expect(result.generalComments).toHaveLength(1);
expect(result.generalComments[0].severity).toBe(CodeScanSeverity.LOW);
});
it('should recognize fileless findings as PR-postable but reject severity=none comments', () => {
expect(
hasPrPostableFindings([
{
file: null,
line: null,
severity: CodeScanSeverity.HIGH,
finding: 'General security issue',
},
]),
).toBe(true);
expect(
hasPrPostableFindings([
{
file: 'src/test.ts',
line: 42,
severity: CodeScanSeverity.NONE,
finding: 'No issue found',
},
]),
).toBe(false);
});
it('should append severity threshold', () => {
const result = prepareComments([], 'Review text', 'medium');
expect(result.reviewBody).toContain('Minimum severity threshold');
expect(result.reviewBody).toContain('Medium');
});
it('should sort comments by severity descending', () => {
const comments: Comment[] = [
{ severity: CodeScanSeverity.LOW, finding: 'Low issue', file: 'a.ts', line: 1 },
{ severity: CodeScanSeverity.CRITICAL, finding: 'Critical issue', file: 'b.ts', line: 2 },
{ severity: CodeScanSeverity.MEDIUM, finding: 'Medium issue', file: 'c.ts', line: 3 },
];
const result = prepareComments(comments, 'Review', undefined);
expect(result.lineComments[0].severity).toBe(CodeScanSeverity.CRITICAL);
expect(result.lineComments[1].severity).toBe(CodeScanSeverity.MEDIUM);
expect(result.lineComments[2].severity).toBe(CodeScanSeverity.LOW);
});
it('should not prepend "All Clear" when vulnerabilities exist', () => {
const comments: Comment[] = [
{
severity: CodeScanSeverity.HIGH,
finding: 'Real vulnerability',
file: 'test.ts',
line: 10,
},
];
const result = prepareComments(comments, 'Found issues', 'low');
expect(result.reviewBody).not.toContain(ALL_CLEAR_MESSAGE);
expect(result.reviewBody).toContain('Found issues');
});
});
+359
View File
@@ -0,0 +1,359 @@
import { describe, expect, it } from 'vitest';
import { hasSarifReportableFindings, scanResponseToSarif } from '../../../src/codeScan/util/sarif';
import { CodeScanSeverity, type Comment, type ScanResponse } from '../../../src/types/codeScan';
function makeFindingResponse(overrides: Partial<Comment> = {}): ScanResponse {
return {
success: true,
comments: [
{
file: 'src/handler.ts',
startLine: 10,
line: 12,
finding: 'User input reaches the model prompt without sanitization.',
severity: CodeScanSeverity.CRITICAL,
...overrides,
},
],
};
}
function fingerprintOf(response: ScanResponse): string {
return scanResponseToSarif(response).runs[0].results[0].partialFingerprints.promptfooFindingHash;
}
describe('scanResponseToSarif', () => {
it('maps reportable findings into GitHub-compatible SARIF results', () => {
const response: ScanResponse = {
success: true,
comments: [
{
file: 'src/chat/handler.ts',
startLine: 40,
line: 42,
finding: 'User input reaches the model prompt without sanitization.',
fix: 'Sanitize user input before composing the prompt.',
severity: CodeScanSeverity.CRITICAL,
aiAgentPrompt: 'Add prompt input validation.',
},
{
file: 'src/chat/render output.ts',
line: 87,
finding: 'Model output is rendered as raw HTML.',
severity: CodeScanSeverity.MEDIUM,
},
],
};
const sarif = scanResponseToSarif(response);
expect(sarif).toMatchObject({
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
version: '2.1.0',
runs: [
{
tool: {
driver: {
name: 'Promptfoo Code Scan',
rules: [
{
id: 'promptfoo/code-scan-finding',
},
],
},
},
results: [
{
ruleId: 'promptfoo/code-scan-finding',
level: 'error',
message: {
text: 'User input reaches the model prompt without sanitization.',
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: 'src/chat/handler.ts',
},
region: {
startLine: 40,
endLine: 42,
},
},
},
],
properties: {
severity: CodeScanSeverity.CRITICAL,
fix: 'Sanitize user input before composing the prompt.',
aiAgentPrompt: 'Add prompt input validation.',
},
},
{
ruleId: 'promptfoo/code-scan-finding',
level: 'warning',
message: {
text: 'Model output is rendered as raw HTML.',
},
locations: [
{
physicalLocation: {
artifactLocation: {
uri: 'src/chat/render%20output.ts',
},
region: {
startLine: 87,
},
},
},
],
},
],
},
],
});
expect(sarif.runs[0].results[0].partialFingerprints.promptfooFindingHash).toMatch(
/^[a-f0-9]{64}$/,
);
});
it('omits endLine when the comment range is inverted (startLine > line)', () => {
const response: ScanResponse = {
success: true,
comments: [
{
file: 'src/inverted.ts',
startLine: 100,
line: 42,
finding: 'Inverted region from upstream data.',
severity: CodeScanSeverity.HIGH,
},
],
};
const region =
scanResponseToSarif(response).runs[0].results[0].locations?.[0].physicalLocation.region;
expect(region).toEqual({ startLine: 100 });
});
it('keeps fingerprints stable when line numbers shift or wording is rephrased slightly', () => {
const baseHash = fingerprintOf(makeFindingResponse());
const shifted = fingerprintOf(makeFindingResponse({ startLine: 88, line: 90 }));
const reworded = fingerprintOf(
makeFindingResponse({
finding: ' User input reaches the model prompt without sanitization.\n',
}),
);
expect(shifted).toBe(baseHash);
expect(reworded).toBe(baseHash);
});
it('produces distinct fingerprints for different files or severities', () => {
const response: ScanResponse = {
success: true,
comments: [
{
file: 'src/a.ts',
line: 5,
finding: 'Same finding text.',
severity: CodeScanSeverity.HIGH,
},
{
file: 'src/b.ts',
line: 5,
finding: 'Same finding text.',
severity: CodeScanSeverity.HIGH,
},
{
file: 'src/a.ts',
line: 5,
finding: 'Same finding text.',
severity: CodeScanSeverity.LOW,
},
],
};
const hashes = scanResponseToSarif(response).runs[0].results.map(
(result) => result.partialFingerprints.promptfooFindingHash,
);
expect(new Set(hashes).size).toBe(3);
});
it('normalizes Windows-style backslash separators in artifact URIs', () => {
const response: ScanResponse = {
success: true,
comments: [
{
file: 'src\\windows path\\file.ts',
line: 7,
finding: 'Windows path leak.',
severity: CodeScanSeverity.LOW,
},
],
};
expect(
scanResponseToSarif(response).runs[0].results[0].locations?.[0].physicalLocation
.artifactLocation.uri,
).toBe('src/windows%20path/file.ts');
});
it.each([
[CodeScanSeverity.CRITICAL, 'error'],
[CodeScanSeverity.HIGH, 'error'],
[CodeScanSeverity.MEDIUM, 'warning'],
[CodeScanSeverity.LOW, 'note'],
])('maps severity %s to SARIF level %s', (severity, expected) => {
const sarif = scanResponseToSarif(makeFindingResponse({ severity }));
expect(sarif.runs[0].results[0].level).toBe(expected);
});
it('drops findings with severity NONE even when they have a file and line', () => {
const sarif = scanResponseToSarif(makeFindingResponse({ severity: CodeScanSeverity.NONE }));
expect(sarif.runs[0].results).toEqual([]);
});
it('emits findings with missing severity as note level (CommentSchema allows it)', () => {
const sarif = scanResponseToSarif(makeFindingResponse({ severity: undefined }));
expect(sarif.runs[0].results).toHaveLength(1);
expect(sarif.runs[0].results[0].level).toBe('note');
expect(sarif.runs[0].results[0].properties.severity).toBeUndefined();
});
it('exposes the docs URL via tool.driver.informationUri', () => {
const sarif = scanResponseToSarif(makeFindingResponse());
expect(sarif.runs[0].tool.driver.informationUri).toBe(
'https://www.promptfoo.dev/docs/code-scanning/cli/',
);
});
it('emits a file-only finding (no line) with an artifactLocation but no region', () => {
const sarif = scanResponseToSarif(
makeFindingResponse({ file: 'src/x.ts', line: null, startLine: undefined }),
);
expect(sarif.runs[0].results).toHaveLength(1);
const location = sarif.runs[0].results[0].locations?.[0].physicalLocation;
expect(location?.artifactLocation.uri).toBe('src/x.ts');
expect(location?.region).toBeUndefined();
});
it('omits the rule descriptor when there are no SARIF results', () => {
const sarif = scanResponseToSarif({ success: true, comments: [] });
expect(sarif.runs[0].tool.driver.rules).toEqual([]);
expect(sarif.runs[0].results).toEqual([]);
});
it('preserves the scan review summary under properties.promptfoo', () => {
const sarif = scanResponseToSarif({
success: true,
comments: [],
review: 'No issues found in this PR.',
});
expect(sarif.runs[0].properties?.promptfoo?.review).toBe('No issues found in this PR.');
});
it('omits run.properties when there is no review summary to carry', () => {
const sarif = scanResponseToSarif({ success: true, comments: [] });
expect(sarif.runs[0].properties).toBeUndefined();
});
it('truncates fingerprint input at the configured max so very long findings collide stably', () => {
// Two findings whose first 160 chars match but tails differ → identical fingerprint
// (intended: tail variation shouldn't churn the alert id across re-scans).
const longHead =
'This is a long LLM finding that describes a potential issue with user input. '.repeat(3);
const tailA = ` First tail variation A — ${'a'.repeat(50)}`;
const tailB = ` Second tail variation B — ${'b'.repeat(50)}`;
const hashA = fingerprintOf(makeFindingResponse({ finding: longHead + tailA }));
const hashB = fingerprintOf(makeFindingResponse({ finding: longHead + tailB }));
expect(hashA).toBe(hashB);
// Sanity: a divergence inside the first 160 chars does change the hash.
const earlyDiverge = fingerprintOf(
makeFindingResponse({ finding: 'Different opening text — ' + longHead + tailA }),
);
expect(earlyDiverge).not.toBe(hashA);
});
it.each([
['src/path with spaces/file.ts', 'src/path%20with%20spaces/file.ts'],
['src/anchor#fragment.ts', 'src/anchor%23fragment.ts'],
['src/café/résumé.ts', 'src/caf%C3%A9/r%C3%A9sum%C3%A9.ts'],
['src\\mixed/style/file.ts', 'src/mixed/style/file.ts'],
])('encodes artifactLocation.uri "%s" as "%s"', (input, expected) => {
const sarif = scanResponseToSarif(
makeFindingResponse({ file: input, line: 1, startLine: undefined }),
);
expect(sarif.runs[0].results[0].locations?.[0].physicalLocation.artifactLocation.uri).toBe(
expected,
);
});
it('omits non-finding comments and findings without displayable locations', () => {
const response: ScanResponse = {
success: true,
comments: [
{
file: null,
line: null,
finding: 'No issues found.',
severity: CodeScanSeverity.NONE,
},
{
file: null,
line: null,
finding: 'Potential unsafe agent behavior.',
severity: CodeScanSeverity.HIGH,
},
],
};
const sarif = scanResponseToSarif(response);
expect(sarif.runs[0].results).toEqual([]);
});
});
describe('hasSarifReportableFindings', () => {
it('returns true when a comment would serialize to a SARIF result', () => {
expect(hasSarifReportableFindings(makeFindingResponse())).toBe(true);
});
it('returns true for file-only findings supported by SARIF', () => {
expect(
hasSarifReportableFindings(
makeFindingResponse({ file: 'src/handler.ts', line: null, startLine: undefined }),
),
).toBe(true);
});
it('returns false for an empty comments array', () => {
expect(hasSarifReportableFindings({ success: true, comments: [] })).toBe(false);
});
it('returns false when every comment is filtered by scanResponseToSarif', () => {
const response: ScanResponse = {
success: true,
comments: [
{
file: 'src/handler.ts',
line: 12,
finding: 'No issue found on this line.',
severity: CodeScanSeverity.NONE,
},
{
file: null,
line: null,
finding: 'Advisory not pinned to a file.',
severity: CodeScanSeverity.HIGH,
},
],
};
expect(hasSarifReportableFindings(response)).toBe(false);
// Stays in lockstep with the serializer it guards.
expect(scanResponseToSarif(response).runs[0].results).toEqual([]);
});
});
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import { requestsStructuredCodeScanOutput } from '../../../src/codeScan/util/structuredOutputDetect';
const node = 'node';
const cli = '/path/to/main.js';
function argv(...rest: string[]): string[] {
return [node, cli, ...rest];
}
describe('requestsStructuredCodeScanOutput', () => {
it('returns false when the subcommand is not code-scans run', () => {
expect(requestsStructuredCodeScanOutput(argv())).toBe(false);
expect(requestsStructuredCodeScanOutput(argv('eval', '--json'))).toBe(false);
expect(requestsStructuredCodeScanOutput(argv('code-scans'))).toBe(false);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'list', '--json'))).toBe(false);
});
it('detects --json on code-scans run', () => {
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '.', '--json'))).toBe(true);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '--json', '.'))).toBe(true);
});
it('detects --format sarif and --format json (space-separated)', () => {
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '--format', 'sarif'))).toBe(
true,
);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '--format', 'json'))).toBe(
true,
);
});
it('detects -f sarif and -f json (short alias, space-separated)', () => {
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '-f', 'sarif'))).toBe(true);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '-f', 'json'))).toBe(true);
});
it('detects --format=value and -f=value (equals form)', () => {
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '--format=sarif'))).toBe(
true,
);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '--format=json'))).toBe(true);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '-f=sarif'))).toBe(true);
});
it('detects -fsarif and -fjson (Commander combined short form)', () => {
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '-fsarif'))).toBe(true);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '-fjson'))).toBe(true);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '-ftext'))).toBe(false);
});
it('returns false for --format text (the default)', () => {
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '--format', 'text'))).toBe(
false,
);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '--format=text'))).toBe(
false,
);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '-f', 'text'))).toBe(false);
});
it('still detects --json after a non-structured --format text', () => {
// Regression: an earlier implementation returned eagerly on --format and missed
// a later --json. Matters because Commander allows redundant flags.
expect(
requestsStructuredCodeScanOutput(argv('code-scans', 'run', '--format', 'text', '--json')),
).toBe(true);
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '-f=text', '--json'))).toBe(
true,
);
});
it('detects the latest format when multiple --format flags appear', () => {
expect(
requestsStructuredCodeScanOutput(
argv('code-scans', 'run', '--format', 'text', '--format', 'sarif'),
),
).toBe(true);
expect(
requestsStructuredCodeScanOutput(
argv('code-scans', 'run', '--format=sarif', '--format=text'),
),
).toBe(false);
});
it("does not misinterpret a value-eating flag's argument as a structured-output flag", () => {
// `--base --json` — the literal `--json` is the value of `--base`, not a real flag.
expect(
requestsStructuredCodeScanOutput(argv('code-scans', 'run', '.', '--base', '--json')),
).toBe(false);
expect(
requestsStructuredCodeScanOutput(argv('code-scans', 'run', '.', '--config', '--format')),
).toBe(false);
});
it('stops scanning at the conventional `--` positional separator', () => {
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '.', '--', '--json'))).toBe(
false,
);
});
it('returns false for a bare --format with no value', () => {
expect(requestsStructuredCodeScanOutput(argv('code-scans', 'run', '--format'))).toBe(false);
});
});