Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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> {
Comment thread
data-douser marked this conversation as resolved.
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;
}
Comment thread
data-douser marked this conversation as resolved.

/** 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...',
Comment thread
data-douser marked this conversation as resolved.
Outdated
);
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;
Comment thread
data-douser marked this conversation as resolved.
}

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