Skip to content
Draft
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
1 change: 1 addition & 0 deletions extensions/vscode/esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const testSuiteConfig = {
'test/suite/bridge.integration.test.ts',
'test/suite/copydb-e2e.integration.test.ts',
'test/suite/extension.integration.test.ts',
'test/suite/mcp-completion-e2e.integration.test.ts',
'test/suite/mcp-prompt-e2e.integration.test.ts',
'test/suite/mcp-resource-e2e.integration.test.ts',
'test/suite/mcp-server.integration.test.ts',
Expand Down
27 changes: 24 additions & 3 deletions extensions/vscode/src/codeql/cli-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const KNOWN_LOCATIONS = [
export class CliResolver extends DisposableObject {
private cachedPath: string | undefined | null = null; // null = not yet resolved
private cachedVersion: string | undefined;
private resolvePromise: Promise<string | undefined> | null = null;

constructor(
private readonly logger: Logger,
Expand All @@ -57,6 +58,21 @@ export class CliResolver extends DisposableObject {
return this.cachedPath;
}

// Return the in-flight promise if a resolution is already in progress
if (this.resolvePromise) {
return this.resolvePromise;
}

this.resolvePromise = this.doResolve();
try {
return await this.resolvePromise;
} finally {
this.resolvePromise = null;
}
}

/** Internal resolution logic. Called at most once per cache cycle. */
private async doResolve(): Promise<string | undefined> {
this.logger.debug('Resolving CodeQL CLI path...');

// Strategy 1: CODEQL_PATH env var
Expand All @@ -74,9 +90,13 @@ export class CliResolver extends DisposableObject {
// Strategy 2: which/command -v
const whichPath = await this.resolveFromPath();
if (whichPath) {
this.logger.info(`CodeQL CLI found on PATH: ${whichPath}`);
this.cachedPath = whichPath;
return whichPath;
const validated = await this.validateBinary(whichPath);
if (validated) {
this.logger.info(`CodeQL CLI found on PATH: ${whichPath}`);
this.cachedPath = whichPath;
return whichPath;
}
this.logger.warn(`Found 'codeql' on PATH at '${whichPath}' but it failed validation.`);
}

// Strategy 3: vscode-codeql managed distribution
Expand Down Expand Up @@ -106,6 +126,7 @@ export class CliResolver extends DisposableObject {
invalidateCache(): void {
this.cachedPath = null;
this.cachedVersion = undefined;
this.resolvePromise = null;
}

/** Check if a path exists and responds to `--version`. */
Expand Down
42 changes: 34 additions & 8 deletions extensions/vscode/src/server/pack-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,21 @@ export class PackInstaller extends DisposableObject {
const actualCliVersion = this.cliResolver.getCliVersion();
const targetCliVersion = this.getTargetCliVersion();

if (actualCliVersion) {
this.logger.info(
`Detected CodeQL CLI version: ${actualCliVersion}, target: ${targetCliVersion}.`,
);
} else {
this.logger.info(
`CodeQL CLI version could not be determined. Target: ${targetCliVersion}. ` +
'Using bundled pack install.',
);
}

if (downloadEnabled && actualCliVersion && actualCliVersion !== targetCliVersion) {
this.logger.info(
`CodeQL CLI version ${actualCliVersion} differs from VSIX target ${targetCliVersion}. ` +
'Attempting to download compatible tool query packs...',
'Attempting to install compatible tool query packs...',
);
const downloaded = await this.downloadPacksForCliVersion(
codeqlPath, actualCliVersion, languages,
Expand All @@ -165,7 +176,11 @@ export class PackInstaller extends DisposableObject {
return;
}
this.logger.info(
'Pack download did not succeed for all languages β€” falling back to bundled pack install.',
'Pack install did not succeed for all languages β€” falling back to bundled pack install.',
);
} else if (actualCliVersion && actualCliVersion === targetCliVersion) {
this.logger.info(
`CLI and target versions match (${actualCliVersion}). Using bundled pack install.`,
);
}

Expand Down Expand Up @@ -195,24 +210,29 @@ export class PackInstaller extends DisposableObject {
}

this.logger.info(
`Downloading ql-mcp tool query packs v${packVersion} for CodeQL CLI ${cliVersion}...`,
`Installing ql-mcp tool query packs v${packVersion} for CodeQL CLI ${cliVersion}...`,
);

let allSucceeded = true;
let successCount = 0;
for (const lang of languages) {
const packRef =
`${PackInstaller.PACK_SCOPE}/ql-mcp-${lang}-tools-src@${packVersion}`;
this.logger.info(`Downloading ${packRef}...`);
this.logger.info(`Installing ${packRef}...`);
try {
await this.runCodeqlPackDownload(codeqlPath, packRef);
this.logger.info(`Downloaded ${packRef}.`);
this.logger.info(`Installed ${packRef}.`);
successCount++;
} catch (err) {
this.logger.error(
`Failed to download ${packRef}: ${err instanceof Error ? err.message : String(err)}`,
`Failed to install ${packRef}: ${err instanceof Error ? err.message : String(err)}`,
);
allSucceeded = false;
}
}
this.logger.info(
`Pack install complete: ${successCount}/${languages.length} languages succeeded.`,
);
return allSucceeded;
}

Expand All @@ -226,9 +246,11 @@ export class PackInstaller extends DisposableObject {
languages: string[],
): Promise<void> {
const qlRoot = this.getQlpackRoot();
let successCount = 0;

for (const lang of languages) {
const packDir = join(qlRoot, 'ql', lang, 'tools', 'src');
const packName = `${PackInstaller.PACK_SCOPE}/ql-mcp-${lang}-tools-src`;

// Check if the pack directory exists
try {
Expand All @@ -238,16 +260,20 @@ export class PackInstaller extends DisposableObject {
continue;
}

this.logger.info(`Installing CodeQL pack dependencies for ${lang}...`);
this.logger.info(`Installing CodeQL pack dependencies for ${packName} (${lang})...`);
try {
await this.runCodeqlPackInstall(codeqlPath, packDir);
this.logger.info(`Pack dependencies installed for ${lang}.`);
this.logger.info(`Pack dependencies installed for ${packName} (${lang}).`);
successCount++;
} catch (err) {
this.logger.error(
`Failed to install pack dependencies for ${lang}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
this.logger.info(
`Bundled pack install complete: ${successCount}/${languages.length} languages succeeded.`,
);
}

/** Run `codeql pack install` for a single pack directory. */
Expand Down
3 changes: 1 addition & 2 deletions extensions/vscode/src/server/server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,7 @@ export class ServerManager extends DisposableObject {
// VSIX bundle or monorepo server is present β€” no npm install required.
if (this.getBundledQlRoot()) {
this.logger.info(
`Using bundled server (v${this.getExtensionVersion()}). ` +
'No npm install required.',
`Bundled server ready (v${this.getExtensionVersion()}).`,
);
return false;
}
Expand Down
117 changes: 117 additions & 0 deletions extensions/vscode/test/codeql/cli-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,4 +434,121 @@ describe('CliResolver', () => {
expect(result).toBe(expectedPath);
});
});

describe('PATH resolution version detection', () => {
let originalEnv: string | undefined;

beforeEach(() => {
originalEnv = process.env.CODEQL_PATH;
delete process.env.CODEQL_PATH;
});

afterEach(() => {
if (originalEnv === undefined) {
delete process.env.CODEQL_PATH;
} else {
process.env.CODEQL_PATH = originalEnv;
}
});

it('should detect CLI version when resolved via PATH', async () => {
// `which codeql` succeeds, then `codeql --version` returns version
vi.mocked(execFile).mockImplementation(
(_cmd: any, _args: any, callback: any) => {
const cmd = String(_cmd);
const args = Array.isArray(_args) ? _args : [];
if (cmd === 'which' || cmd === 'where') {
callback(null, '/usr/local/bin/codeql\n', '');
} else if (args.includes('--version')) {
callback(null, 'CodeQL CLI 2.24.1\n', '');
}
return {} as any;
},
);

vi.mocked(access).mockResolvedValue(undefined as any);

const result = await resolver.resolve();
expect(result).toBe('/usr/local/bin/codeql');
expect(resolver.getCliVersion()).toBe('2.24.1');
});

it('should fall through when PATH binary fails validation', async () => {
// `which codeql` returns a path, but --version fails
vi.mocked(execFile).mockImplementation(
(_cmd: any, _args: any, callback: any) => {
const cmd = String(_cmd);
if (cmd === 'which' || cmd === 'where') {
callback(null, '/broken/codeql\n', '');
} else {
callback(new Error('not a valid binary'), '', '');
}
return {} as any;
},
);

// access fails for everything (including known locations)
vi.mocked(access).mockRejectedValue(new Error('ENOENT'));

const result = await resolver.resolve();
expect(result).toBeUndefined();
expect(resolver.getCliVersion()).toBeUndefined();
});
});

describe('concurrent resolution', () => {
let originalEnv: string | undefined;

beforeEach(() => {
originalEnv = process.env.CODEQL_PATH;
process.env.CODEQL_PATH = '/usr/local/bin/codeql';
});

afterEach(() => {
if (originalEnv === undefined) {
delete process.env.CODEQL_PATH;
} else {
process.env.CODEQL_PATH = originalEnv;
}
});

it('should not duplicate resolution work for concurrent calls', async () => {
vi.mocked(access).mockResolvedValue(undefined as any);
vi.mocked(execFile).mockImplementation(
(_cmd: any, _args: any, callback: any) => {
callback(null, 'CodeQL CLI 2.25.1\n', '');
return {} as any;
},
);

// Fire two concurrent resolve() calls
const [result1, result2] = await Promise.all([
resolver.resolve(),
resolver.resolve(),
]);

expect(result1).toBe('/usr/local/bin/codeql');
expect(result2).toBe('/usr/local/bin/codeql');
// Should only validate once, not twice
expect(access).toHaveBeenCalledTimes(1);
});

it('should allow re-resolution after invalidateCache', async () => {
vi.mocked(access).mockResolvedValue(undefined as any);
vi.mocked(execFile).mockImplementation(
(_cmd: any, _args: any, callback: any) => {
callback(null, 'CodeQL CLI 2.25.1\n', '');
return {} as any;
},
);

await resolver.resolve();
resolver.invalidateCache();

const result = await resolver.resolve();
expect(result).toBe('/usr/local/bin/codeql');
// access called twice: once before invalidation, once after
expect(access).toHaveBeenCalledTimes(2);
});
});
});
Loading
Loading