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,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user