Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 34 additions & 21 deletions extensions/vscode/src/codeql/cli-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFile } from 'child_process';
import { access, readdir, readFile } from 'fs/promises';
import { constants } from 'fs';
import { join } from 'path';
import { dirname, join } from 'path';
import { DisposableObject } from '../common/disposable';
import type { Logger } from '../common/logger';

Expand Down Expand Up @@ -122,33 +122,48 @@ export class CliResolver extends DisposableObject {
private async resolveFromVsCodeDistribution(): Promise<string | undefined> {
if (!this.vsCodeCodeqlStoragePath) return undefined;

try {
// Fast path: read distribution.json for the exact folder index
const hintPath = await this.resolveFromDistributionJson();
if (hintPath) return hintPath;
} catch {
this.logger.debug('distribution.json hint unavailable, falling back to directory scan');
}
const parent = dirname(this.vsCodeCodeqlStoragePath);
// VS Code stores the extension directory as either 'GitHub.vscode-codeql'
// (original publisher casing) or 'github.vscode-codeql' (lowercased by VS Code
// on some platforms/versions). Probe both to ensure discovery works on
// case-sensitive filesystems.
const candidatePaths = [
...new Set([
this.vsCodeCodeqlStoragePath,
join(parent, 'github.vscode-codeql'),
join(parent, 'GitHub.vscode-codeql'),
]),
];

for (const storagePath of candidatePaths) {
try {
// Fast path: read distribution.json for the exact folder index
const hintPath = await this.resolveFromDistributionJson(storagePath);
if (hintPath) return hintPath;
} catch {
this.logger.debug('distribution.json hint unavailable, falling back to directory scan');
}

// Fallback: scan for distribution* directories
return this.resolveFromDistributionScan();
// Fallback: scan for distribution* directories
const scanPath = await this.resolveFromDistributionScan(storagePath);
if (scanPath) return scanPath;
}
return undefined;
}

/**
* Read `distribution.json` to get the current `folderIndex` and validate
* the binary at the corresponding path.
*/
private async resolveFromDistributionJson(): Promise<string | undefined> {
if (!this.vsCodeCodeqlStoragePath) return undefined;

const jsonPath = join(this.vsCodeCodeqlStoragePath, 'distribution.json');
private async resolveFromDistributionJson(storagePath: string): Promise<string | undefined> {
const jsonPath = join(storagePath, 'distribution.json');
const content = await readFile(jsonPath, 'utf-8');
const data = JSON.parse(content) as { folderIndex?: number };

if (typeof data.folderIndex !== 'number') return undefined;

const binaryPath = join(
this.vsCodeCodeqlStoragePath,
storagePath,
`distribution${data.folderIndex}`,
'codeql',
CODEQL_BINARY_NAME,
Expand All @@ -165,11 +180,9 @@ export class CliResolver extends DisposableObject {
* Scan for `distribution*` directories sorted by numeric suffix (highest
* first) and return the first one containing a valid `codeql` binary.
*/
private async resolveFromDistributionScan(): Promise<string | undefined> {
if (!this.vsCodeCodeqlStoragePath) return undefined;

private async resolveFromDistributionScan(storagePath: string): Promise<string | undefined> {
try {
const entries = await readdir(this.vsCodeCodeqlStoragePath, { withFileTypes: true });
const entries = await readdir(storagePath, { withFileTypes: true });

const distDirs = entries
.filter(e => e.isDirectory() && /^distribution\d*$/.test(e.name))
Expand All @@ -181,7 +194,7 @@ export class CliResolver extends DisposableObject {

for (const dir of distDirs) {
const binaryPath = join(
this.vsCodeCodeqlStoragePath,
storagePath,
dir.name,
'codeql',
CODEQL_BINARY_NAME,
Expand All @@ -194,7 +207,7 @@ export class CliResolver extends DisposableObject {
}
} catch {
this.logger.debug(
`Could not scan vscode-codeql distribution directory: ${this.vsCodeCodeqlStoragePath}`,
`Could not scan vscode-codeql distribution directory: ${storagePath}`,
);
}
return undefined;
Expand Down
25 changes: 25 additions & 0 deletions extensions/vscode/test/codeql/cli-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,31 @@ describe('CliResolver', () => {
expect(result).toBe(expectedPath);
});

it('should discover CLI from alternate casing of storage path', async () => {
// Simulate the caller providing 'GitHub.vscode-codeql' (StoragePaths publisher casing)
// but the actual directory on disk is 'github.vscode-codeql' (lowercased by VS Code).
const lowercaseExtStorage = '/mock/globalStorage/github.vscode-codeql';
const publisherCaseExtStorage = '/mock/globalStorage/GitHub.vscode-codeql';
resolver = new CliResolver(logger, publisherCaseExtStorage);

// distribution.json at the publisher-cased path throws; the lowercase path returns valid JSON
vi.mocked(readFile).mockImplementation((path: any) => {
if (String(path).startsWith(publisherCaseExtStorage)) {
return Promise.reject(new Error('ENOENT'));
}
return Promise.resolve(JSON.stringify({ folderIndex: 1 }) as any);
});

const expectedPath = `${lowercaseExtStorage}/distribution1/codeql/${binaryName}`;
vi.mocked(access).mockImplementation((path: any) => {
if (String(path) === expectedPath) return Promise.resolve(undefined as any);
return Promise.reject(new Error('ENOENT'));
});

const result = await resolver.resolve();
expect(result).toBe(expectedPath);
});

it('should handle distribution.json without folderIndex property', async () => {
resolver = new CliResolver(logger, storagePath);

Expand Down
4 changes: 2 additions & 2 deletions server/dist/codeql-development-mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -38209,8 +38209,8 @@ function getVsCodeGlobalStorageCandidates() {
}
return candidates;
}
function discoverVsCodeCodeQLDistribution() {
const globalStorageCandidates = getVsCodeGlobalStorageCandidates();
function discoverVsCodeCodeQLDistribution(candidateStorageRoots) {
const globalStorageCandidates = candidateStorageRoots ?? getVsCodeGlobalStorageCandidates();
for (const gsRoot of globalStorageCandidates) {
for (const dirName of VSCODE_CODEQL_STORAGE_DIR_NAMES) {
const codeqlStorage = join4(gsRoot, dirName);
Expand Down
4 changes: 2 additions & 2 deletions server/dist/codeql-development-mcp-server.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions server/src/lib/cli-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ export function getVsCodeGlobalStorageCandidates(): string[] {
*
* @returns Absolute path to the `codeql` binary, or `undefined` if not found.
*/
export function discoverVsCodeCodeQLDistribution(): string | undefined {
const globalStorageCandidates = getVsCodeGlobalStorageCandidates();
export function discoverVsCodeCodeQLDistribution(candidateStorageRoots?: string[]): string | undefined {
const globalStorageCandidates = candidateStorageRoots ?? getVsCodeGlobalStorageCandidates();

for (const gsRoot of globalStorageCandidates) {
// Check both casings: VS Code may lowercase the extension ID on disk,
Expand Down
127 changes: 67 additions & 60 deletions server/test/src/lib/cli-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { writeFileSync, rmSync, chmodSync, mkdirSync, existsSync } from 'fs';
import { writeFileSync, rmSync, chmodSync, mkdirSync } from 'fs';
import { execFileSync } from 'child_process';
import { isAbsolute, join } from 'path';
import { createProjectTempDir } from '../../../src/utils/temp-dir';
Expand Down Expand Up @@ -587,20 +587,12 @@ describe('resolveCodeQLBinary', () => {
delete process.env.CODEQL_PATH;
const result = resolveCodeQLBinary();
expect(result).toBe('codeql');
// getResolvedCodeQLDir() may be non-null if the vscode-codeql
// distribution is installed on this machine (auto-discovery)
const dir = getResolvedCodeQLDir();
expect(dir === null || typeof dir === 'string').toBe(true);
});

it('should default to "codeql" when CODEQL_PATH is empty', () => {
process.env.CODEQL_PATH = '';
const result = resolveCodeQLBinary();
expect(result).toBe('codeql');
// getResolvedCodeQLDir() may be non-null if the vscode-codeql
// distribution is installed on this machine (auto-discovery)
const dir = getResolvedCodeQLDir();
expect(dir === null || typeof dir === 'string').toBe(true);
});

it('should return bare codeql command and set dir to parent directory', () => {
Expand Down Expand Up @@ -690,10 +682,6 @@ describe('CODEQL_PATH - PATH prepend integration', () => {
process.env.CODEQL_PATH = '/some/changed/path/codeql';
const second = resolveCodeQLBinary();
expect(second).toBe('codeql');
// getResolvedCodeQLDir() may be non-null if vscode-codeql
// distribution was auto-discovered on the first resolve()
const dir = getResolvedCodeQLDir();
expect(dir === null || typeof dir === 'string').toBe(true);
});

it.skipIf(process.platform === 'win32')('should prepend CODEQL_PATH directory to child process PATH', async () => {
Expand Down Expand Up @@ -823,66 +811,89 @@ describe('getVsCodeGlobalStorageCandidates', () => {
});

describe('discoverVsCodeCodeQLDistribution', () => {
const binaryName = process.platform === 'win32' ? 'codeql.exe' : 'codeql';

it('should return undefined when no vscode-codeql storage exists', () => {
// In the test environment, the vscode-codeql storage is unlikely to exist
// at the standard location, so discovery should return undefined.
// This is effectively a no-op test that ensures the function handles
// missing directories gracefully.
const result = discoverVsCodeCodeQLDistribution();
// Result depends on whether vscode-codeql is installed β€” just verify it doesn't throw
expect(result === undefined || typeof result === 'string').toBe(true);
const tmpDir = createProjectTempDir('vscode-codeql-no-storage-');
try {
// No extension storage dirs created inside tmpDir
const result = discoverVsCodeCodeQLDistribution([tmpDir]);
expect(result).toBeUndefined();
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});

it('should discover CLI from a simulated distribution directory', () => {
const tmpDir = createProjectTempDir('vscode-codeql-discovery-test-');
it('should discover CLI from distribution.json fast path', () => {
const tmpDir = createProjectTempDir('vscode-codeql-json-fastpath-');
const codeqlStorage = join(tmpDir, 'github.vscode-codeql');
const distDir = join(codeqlStorage, 'distribution3', 'codeql');

// Create the distribution directory structure with a fake binary
mkdirSync(distDir, { recursive: true });
const binaryPath = join(distDir, process.platform === 'win32' ? 'codeql.exe' : 'codeql');
const binaryPath = join(distDir, binaryName);
writeFileSync(binaryPath, '#!/bin/sh\necho test', { mode: 0o755 });

// Also create distribution.json
writeFileSync(
join(codeqlStorage, 'distribution.json'),
JSON.stringify({ folderIndex: 3 }),
);

writeFileSync(join(codeqlStorage, 'distribution.json'), JSON.stringify({ folderIndex: 3 }));
try {
// We can't easily test discoverVsCodeCodeQLDistribution directly because
// it uses hardcoded platform-specific paths. Instead, test the behavior
// by creating the structure and verifying the file was created correctly.
expect(existsSync(binaryPath)).toBe(true);
const result = discoverVsCodeCodeQLDistribution([tmpDir]);
expect(result).toBe(binaryPath);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});

it('should prefer distribution.json folderIndex when available', () => {
const tmpDir = createProjectTempDir('vscode-codeql-json-test-');
it('should fall back to directory scan when distribution.json is missing', () => {
const tmpDir = createProjectTempDir('vscode-codeql-scan-fallback-');
const codeqlStorage = join(tmpDir, 'github.vscode-codeql');
const dist1 = join(codeqlStorage, 'distribution1', 'codeql');
const dist3 = join(codeqlStorage, 'distribution3', 'codeql');
mkdirSync(dist1, { recursive: true });
mkdirSync(dist3, { recursive: true });
writeFileSync(join(dist1, binaryName), '#!/bin/sh\necho v1', { mode: 0o755 });
writeFileSync(join(dist3, binaryName), '#!/bin/sh\necho v3', { mode: 0o755 });
// No distribution.json β€” scan should pick the highest-numbered directory
try {
const result = discoverVsCodeCodeQLDistribution([tmpDir]);
expect(result).toBe(join(dist3, binaryName));
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});

// Create two distribution directories
const dist2Dir = join(codeqlStorage, 'distribution2', 'codeql');
const dist3Dir = join(codeqlStorage, 'distribution3', 'codeql');
mkdirSync(dist2Dir, { recursive: true });
mkdirSync(dist3Dir, { recursive: true });

const binaryName = process.platform === 'win32' ? 'codeql.exe' : 'codeql';
writeFileSync(join(dist2Dir, binaryName), '#!/bin/sh\necho v2', { mode: 0o755 });
writeFileSync(join(dist3Dir, binaryName), '#!/bin/sh\necho v3', { mode: 0o755 });

// distribution.json points to distribution3
writeFileSync(
join(codeqlStorage, 'distribution.json'),
JSON.stringify({ folderIndex: 3 }),
);
it('should prefer distribution.json folderIndex over highest-numbered directory', () => {
const tmpDir = createProjectTempDir('vscode-codeql-json-precedence-');
const codeqlStorage = join(tmpDir, 'github.vscode-codeql');
const dist2 = join(codeqlStorage, 'distribution2', 'codeql');
const dist3 = join(codeqlStorage, 'distribution3', 'codeql');
mkdirSync(dist2, { recursive: true });
mkdirSync(dist3, { recursive: true });
writeFileSync(join(dist2, binaryName), '#!/bin/sh\necho v2', { mode: 0o755 });
writeFileSync(join(dist3, binaryName), '#!/bin/sh\necho v3', { mode: 0o755 });
// distribution.json points to distribution2 (lower index than distribution3)
writeFileSync(join(codeqlStorage, 'distribution.json'), JSON.stringify({ folderIndex: 2 }));
try {
const result = discoverVsCodeCodeQLDistribution([tmpDir]);
// Should pick distribution2 per distribution.json, not distribution3
expect(result).toBe(join(dist2, binaryName));
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});

it('should scan directories sorted by descending numeric suffix', () => {
const tmpDir = createProjectTempDir('vscode-codeql-scan-order-');
const codeqlStorage = join(tmpDir, 'github.vscode-codeql');
const dist1 = join(codeqlStorage, 'distribution1', 'codeql');
const dist2 = join(codeqlStorage, 'distribution2', 'codeql');
const dist10 = join(codeqlStorage, 'distribution10', 'codeql');
mkdirSync(dist1, { recursive: true });
mkdirSync(dist2, { recursive: true });
mkdirSync(dist10, { recursive: true });
writeFileSync(join(dist1, binaryName), '#!/bin/sh\necho v1', { mode: 0o755 });
writeFileSync(join(dist2, binaryName), '#!/bin/sh\necho v2', { mode: 0o755 });
writeFileSync(join(dist10, binaryName), '#!/bin/sh\necho v10', { mode: 0o755 });
try {
// Verify both files exist
expect(existsSync(join(dist3Dir, binaryName))).toBe(true);
expect(existsSync(join(dist2Dir, binaryName))).toBe(true);
const result = discoverVsCodeCodeQLDistribution([tmpDir]);
// distribution10 > distribution2 > distribution1 by numeric sort
expect(result).toBe(join(dist10, binaryName));
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
Expand All @@ -906,10 +917,6 @@ describe('resolveCodeQLBinary - vscode-codeql auto-discovery', () => {
// This will either find a vscode-codeql distribution or fall back to bare 'codeql'
const result = resolveCodeQLBinary();
expect(result).toBe('codeql');
// If a distribution was found, resolvedCodeQLDir will be set
// If not, it will be null β€” either is acceptable
const dir = getResolvedCodeQLDir();
expect(dir === null || typeof dir === 'string').toBe(true);
});
});

Expand Down