Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
5 changes: 5 additions & 0 deletions extensions/vscode/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export const workspace = {
onDidCreateFiles: noopReturn({ dispose: noop }),
onDidSaveTextDocument: noopReturn({ dispose: noop }),
fs: { stat: noop, readFile: noop, readDirectory: noop },
asRelativePath: (pathOrUri: any) => {
const p = typeof pathOrUri === 'string' ? pathOrUri : pathOrUri?.fsPath ?? String(pathOrUri);
return p;
},
updateWorkspaceFolders: () => true,
};

export const window = {
Expand Down
2 changes: 2 additions & 0 deletions extensions/vscode/esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const testSuiteConfig = {
'test/suite/bridge.integration.test.ts',
'test/suite/copydb-e2e.integration.test.ts',
'test/suite/extension.integration.test.ts',
'test/suite/file-watcher-stability.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
4 changes: 2 additions & 2 deletions extensions/vscode/src/bridge/database-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,15 @@ export class DatabaseWatcher extends DisposableObject {
const dbRoot = ymlPath.replace(/\/codeql-database\.yml$/, '');
if (!this.knownDatabases.has(dbRoot)) {
this.knownDatabases.add(dbRoot);
this.logger.info(`Database discovered: ${dbRoot}`);
this.logger.info(`Database discovered: ${vscode.workspace.asRelativePath(dbRoot)}`);
Comment thread
data-douser marked this conversation as resolved.
this._onDidChange.fire();
}
}

private handleDatabaseRemoved(ymlPath: string): void {
const dbRoot = ymlPath.replace(/\/codeql-database\.yml$/, '');
if (this.knownDatabases.delete(dbRoot)) {
this.logger.info(`Database removed: ${dbRoot}`);
this.logger.info(`Database removed: ${vscode.workspace.asRelativePath(dbRoot)}`);
this._onDidChange.fire();
}
}
Expand Down
4 changes: 2 additions & 2 deletions extensions/vscode/src/bridge/query-results-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class QueryResultsWatcher extends DisposableObject {
const bqrsWatcher = vscode.workspace.createFileSystemWatcher('**/*.bqrs');
this.push(bqrsWatcher);
bqrsWatcher.onDidCreate((uri) => {
this.logger.info(`Query result (BQRS) created: ${uri.fsPath}`);
this.logger.info(`Query result (BQRS) created: ${vscode.workspace.asRelativePath(uri)}`);
this._onDidChange.fire();
});

Expand All @@ -36,7 +36,7 @@ export class QueryResultsWatcher extends DisposableObject {
);
this.push(sarifWatcher);
sarifWatcher.onDidCreate((uri) => {
this.logger.info(`Query result (SARIF) created: ${uri.fsPath}`);
this.logger.info(`Query result (SARIF) created: ${vscode.workspace.asRelativePath(uri)}`);
this._onDidChange.fire();
});

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
18 changes: 8 additions & 10 deletions extensions/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,14 @@ export async function activate(
const queryWatcher = new QueryResultsWatcher(storagePaths, logger);
disposables.push(dbWatcher, queryWatcher);

// When databases or query results change, rebuild the environment and
// notify the MCP provider that the server definition has changed.
dbWatcher.onDidChange(() => {
envBuilder.invalidate();
mcpProvider.fireDidChange();
});
queryWatcher.onDidChange(() => {
envBuilder.invalidate();
mcpProvider.fireDidChange();
});
// File-content changes (new databases, query results) do NOT require
// a new MCP server definition. The running server discovers files on
// its own through filesystem scanning at tool invocation time. The
// definition only needs to change when the server binary, workspace
// folder registration, or configuration changes.
//
// The watchers are still useful: they log file events for debugging
// and DatabaseWatcher tracks known databases internally.
} catch (err) {
logger.warn(
`Failed to initialize file watchers: ${err instanceof Error ? err.message : String(err)}`,
Expand Down
22 changes: 21 additions & 1 deletion extensions/vscode/src/server/mcp-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export class McpProvider
*/
private readonly _extensionVersion: string;

/**
* Handle for the pending debounced `fireDidChange()` timer.
* Used to coalesce rapid file-system events into a single notification.
*/
private _debounceTimer: ReturnType<typeof globalThis.setTimeout> | undefined;

constructor(
private readonly serverManager: ServerManager,
private readonly envBuilder: EnvironmentBuilder,
Expand All @@ -51,9 +57,18 @@ export class McpProvider
* will NOT restart the server. Use for lightweight updates (file watcher
* events, extension changes, background install completion) where the
* running server can continue with its current environment.
*
* Debounced: rapid-fire calls (e.g. from file-system watchers during
* a build) are coalesced into a single notification after a short delay.
*/
fireDidChange(): void {
this._onDidChange.fire();
if (this._debounceTimer !== undefined) {
globalThis.clearTimeout(this._debounceTimer);
}
this._debounceTimer = globalThis.setTimeout(() => {
this._debounceTimer = undefined;
this._onDidChange.fire();
}, 1_000);
}
Comment thread
data-douser marked this conversation as resolved.

/**
Expand All @@ -68,6 +83,11 @@ export class McpProvider
* Use for changes that require a server restart (configuration changes).
*/
requestRestart(): void {
// Cancel any pending debounced fireDidChange β€” the restart supersedes it.
if (this._debounceTimer !== undefined) {
globalThis.clearTimeout(this._debounceTimer);
this._debounceTimer = undefined;
}
this.envBuilder.invalidate();
this._revision++;
this.logger.info(
Expand Down
32 changes: 29 additions & 3 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 @@ -167,6 +178,10 @@ export class PackInstaller extends DisposableObject {
this.logger.info(
'Pack download 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.`,
);
}

// Default path: install dependencies for bundled packs
Expand Down Expand Up @@ -199,20 +214,25 @@ export class PackInstaller extends DisposableObject {
);

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}...`);
try {
await this.runCodeqlPackDownload(codeqlPath, packRef);
this.logger.info(`Downloaded ${packRef}.`);
successCount++;
} catch (err) {
this.logger.error(
`Failed to download ${packRef}: ${err instanceof Error ? err.message : String(err)}`,
);
allSucceeded = false;
}
}
this.logger.info(
`Pack download 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
Loading
Loading