From d9e51362b87b90a5cffa018e2a7e6efa7a897bc2 Mon Sep 17 00:00:00 2001 From: gezerui0124 Date: Thu, 5 Nov 2026 10:54:18 +0800 Subject: [PATCH 1/4] fix(codelldb): read raw VSIX version metadata Signed-off-by: gezerui0124 --- changelog.d/1066.fixed.md | 1 + .../codelldb-common/src/codelldb-resolver.ts | 3 + .../tests/codelldb-resolver.test.ts | 90 ++++++++++++++++++- .../cli/doctor-codelldb-version.test.ts | 79 ++++++++++++++++ 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1066.fixed.md create mode 100644 tests/integration/cli/doctor-codelldb-version.test.ts diff --git a/changelog.d/1066.fixed.md b/changelog.d/1066.fixed.md new file mode 100644 index 00000000..cfc9b249 --- /dev/null +++ b/changelog.d/1066.fixed.md @@ -0,0 +1 @@ +**Doctor reads raw VSIX CodeLLDB versions** — fall back to the selected engine's `package.json` before metadata from other installations (#1066). diff --git a/packages/codelldb-common/src/codelldb-resolver.ts b/packages/codelldb-common/src/codelldb-resolver.ts index 44e68e44..fe8371a1 100644 --- a/packages/codelldb-common/src/codelldb-resolver.ts +++ b/packages/codelldb-common/src/codelldb-resolver.ts @@ -300,8 +300,11 @@ export async function getCodeLLDBVersion(options?: { // beside it (/adapter/codelldb -> /version.json) — probe that // first so the reported version cannot be misattributed to a different // install (e.g. a stale vendor tree under the cwd fallback). + // Raw VSIX extractions store their version in package.json at the same + // root. Read that before metadata from another install. const versionFileCandidates = [ path.resolve(path.dirname(codelldbPath), '..', 'version.json'), + path.resolve(path.dirname(codelldbPath), '..', 'package.json'), ...buildVendorCandidatePaths(defaultPackageRoot(), platformDir, 'version.json') ]; diff --git a/packages/codelldb-common/tests/codelldb-resolver.test.ts b/packages/codelldb-common/tests/codelldb-resolver.test.ts index cc3babbe..a51a1c9f 100644 --- a/packages/codelldb-common/tests/codelldb-resolver.test.ts +++ b/packages/codelldb-common/tests/codelldb-resolver.test.ts @@ -243,7 +243,91 @@ describe('codelldb-resolver', () => { expect(readFileMock).toHaveBeenCalledTimes(2); }); - it('falls back to DEFAULT_CODELLDB_VERSION when no version.json is readable', async () => { + it.each([ + { + name: 'reads the raw VSIX manifest before unrelated metadata', + versionJson: undefined, + packageJson: '{"version":"1.11.8"}', + otherVersion: '9.9.9', + expected: '1.11.8' + }, + { + name: 'keeps version.json ahead of the VSIX manifest', + versionJson: '{"version":"1.11.7"}', + packageJson: '{"version":"1.11.8"}', + otherVersion: '9.9.9', + expected: '1.11.7' + }, + { + name: 'preserves the empty version.json fallback', + versionJson: '{}', + packageJson: '{"version":"1.11.8"}', + otherVersion: '9.9.9', + expected: DEFAULT_CODELLDB_VERSION + }, + { + name: 'tries the manifest after malformed version.json', + versionJson: '{invalid', + packageJson: '{"version":"1.11.8"}', + otherVersion: '9.9.9', + expected: '1.11.8' + }, + { + name: 'continues after a malformed manifest', + versionJson: undefined, + packageJson: '{invalid', + otherVersion: '9.9.9', + expected: '9.9.9' + }, + { + name: 'continues when the manifest is absent', + versionJson: undefined, + packageJson: undefined, + otherVersion: '9.9.9', + expected: '9.9.9' + }, + { + name: 'uses the default when all metadata is absent', + versionJson: undefined, + packageJson: undefined, + otherVersion: undefined, + expected: DEFAULT_CODELLDB_VERSION + }, + { + name: 'uses the default when the manifest has no version', + versionJson: undefined, + packageJson: '{}', + otherVersion: undefined, + expected: DEFAULT_CODELLDB_VERSION + } + ])('$name', async ({ versionJson, packageJson, otherVersion, expected }) => { + stubPlatform('linux', 'x64'); + const extensionRoot = path.resolve('/raw-vsix/extension'); + const executable = path.join(extensionRoot, 'adapter', 'codelldb'); + vi.stubEnv('CODELLDB_PATH', executable); + accessMock.mockImplementation(async (candidate: string) => { + if (candidate !== executable) throw new Error('ENOENT'); + }); + const adjacentMetadata = new Map([ + [path.join(extensionRoot, 'version.json'), versionJson], + [path.join(extensionRoot, 'package.json'), packageJson] + ]); + readFileMock.mockImplementation(async (candidate: string) => { + if (adjacentMetadata.has(candidate)) { + const content = adjacentMetadata.get(candidate); + if (content === undefined) throw new Error('ENOENT'); + return content; + } + if (otherVersion !== undefined) return JSON.stringify({ version: otherVersion }); + throw new Error('ENOENT'); + }); + + await expect( + getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null }) + ).resolves.toBe(expected); + }); + + it('falls back to DEFAULT_CODELLDB_VERSION when no version metadata is readable', async () => { stubPlatform('win32'); accessMock.mockResolvedValue(undefined); readFileMock.mockRejectedValue(new Error('ENOENT')); @@ -251,8 +335,8 @@ describe('codelldb-resolver', () => { await expect( getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null }) ).resolves.toBe(DEFAULT_CODELLDB_VERSION); - // Sibling-of-resolved-binary candidate + the 4 vendor candidates - expect(readFileMock).toHaveBeenCalledTimes(5); + // Both sibling metadata files + the 4 vendor candidates + expect(readFileMock).toHaveBeenCalledTimes(6); }); it('falls back to DEFAULT_CODELLDB_VERSION when version.json lacks a version field', async () => { diff --git a/tests/integration/cli/doctor-codelldb-version.test.ts b/tests/integration/cli/doctor-codelldb-version.test.ts new file mode 100644 index 00000000..0708e137 --- /dev/null +++ b/tests/integration/cli/doctor-codelldb-version.test.ts @@ -0,0 +1,79 @@ +/** Exercise the shipped doctor command with the documented raw VSIX layout (#1066). */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { DebugLanguage } from '@debugmcp/shared'; +import { DEFAULT_CODELLDB_VERSION } from '@debugmcp/codelldb-common'; +import type { DoctorReport } from '../../../src/cli/commands/doctor/diagnose.js'; +import { scrubbedEnvironment } from '../../test-utils/helpers/mcp-stdio-client.js'; +import { requireCliBundle } from '../../test-utils/helpers/cli-bundle.js'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const run = promisify(execFile); +let fixtureRoot: string; +let extensionRoot: string; +let executable: string; + +beforeEach(() => { + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-codelldb-')); + // An isolated install and cwd prevent the checkout's vendored engine from winning. + fs.copyFileSync(requireCliBundle(root), path.join(fixtureRoot, 'cli.mjs')); + extensionRoot = path.join(fixtureRoot, 'extension'); + fs.mkdirSync(path.join(extensionRoot, 'adapter'), { recursive: true }); + executable = path.join(extensionRoot, 'adapter', process.platform === 'win32' ? 'codelldb.exe' : 'codelldb'); + // Version discovery checks existence and reads metadata without executing CodeLLDB. + fs.writeFileSync(executable, ''); +}); + +afterEach(() => { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); +}); + +describe('doctor CodeLLDB version', () => { + it.each([ + { + name: 'reads the raw VSIX manifest', + versionJson: undefined, + packageJson: '{"version":"1.11.8"}', + expected: '1.11.8' + }, + { + name: 'prefers version.json', + versionJson: '{"version":"1.11.7"}', + packageJson: '{"version":"1.11.8"}', + expected: '1.11.7' + }, + { + name: 'preserves an empty version.json fallback', + versionJson: '{}', + packageJson: '{"version":"1.11.8"}', + expected: DEFAULT_CODELLDB_VERSION + } + ])('$name', async ({ versionJson, packageJson, expected }) => { + if (versionJson !== undefined) fs.writeFileSync(path.join(extensionRoot, 'version.json'), versionJson); + fs.writeFileSync(path.join(extensionRoot, 'package.json'), packageJson); + const { stdout, stderr } = await run(process.execPath, [path.join(fixtureRoot, 'cli.mjs'), 'doctor', 'rust', '--json'], { + cwd: fixtureRoot, + env: { + ...scrubbedEnvironment(), + CODELLDB_PATH: executable, + // Keep this metadata test independent of other installed language toolchains. + DEBUG_MCP_DISABLE_LANGUAGES: Object.values(DebugLanguage).filter(language => language !== DebugLanguage.RUST).join(',') + }, + timeout: 15_000, + windowsHide: true + }); + + expect(stderr).toBe(''); + const report = JSON.parse(stdout) as DoctorReport; + expect(report.exitCode).toBe(0); + expect(report.requested).toEqual(['rust']); + expect(report.languages.find(language => language.language === 'rust')?.backend).toEqual({ + label: 'CodeLLDB', path: executable, version: expected, source: 'env:CODELLDB_PATH' + }); + }); +}); From d59febdd71cfe445c7d64be5c5bdab5ad92e7154 Mon Sep 17 00:00:00 2001 From: JF Date: Thu, 5 Nov 2026 09:15:55 -0500 Subject: [PATCH 2/4] fix(codelldb): the engine version comes from the resolved binary's own root, never another install's (#1066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on d9e51362 (the raw VSIX package.json candidate). getCodeLLDBVersion still walked every install's metadata — the four vendor candidates (cwd fallback included) and the platform package — before the compiled default, so a CODELLDB_PATH binary with no metadata of its own was reported with some other copy's version, and the new package.json candidate accepted any manifest two levels above the binary (a project's own under a hand-placed or symlinked binary) and let a version.json without a `version` short-circuit to the constant. Now: realpath the resolved binary, read /version.json, then /package.json only when its name is vscode-lldb (the upstream manifest), and stop. A vendored copy with no readable metadata keeps the pin the vendor script fetched; any other source reports no version (null) instead of a guess. `codelldbVersion` is only rendered by the three doctor rows, so null drops the cell. Unit cases cover the vendored, CODELLDB_PATH (symlink included) and platform-package sources, each asserting no read outside the resolved root. docs/diagnostics.md says where the version is read from; the fragment credits the contributor. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1066.fixed.md | 2 +- docs/diagnostics.md | 2 +- .../codelldb-common/src/codelldb-resolver.ts | 82 ++--- .../tests/codelldb-resolver.test.ts | 279 ++++++++++++------ 4 files changed, 233 insertions(+), 132 deletions(-) diff --git a/changelog.d/1066.fixed.md b/changelog.d/1066.fixed.md index cfc9b249..a0254c2e 100644 --- a/changelog.d/1066.fixed.md +++ b/changelog.d/1066.fixed.md @@ -1 +1 @@ -**Doctor reads raw VSIX CodeLLDB versions** — fall back to the selected engine's `package.json` before metadata from other installations (#1066). +**`doctor` reports the CodeLLDB that actually resolved, not the CLI's compiled default** — under a `CODELLDB_PATH` pointing into a raw VSIX extraction (the documented fallback for `--omit=optional` installs, and what the canary's own leg provisions) `doctor rust`, `cpp` and `cobol` named the version the CLI was built with, because the version lookup knew only the `version.json` the vendor script writes and then walked other installs' metadata before settling on the constant. The version now comes from the resolved binary's own root and nowhere else: its `version.json` (vendored copies, the `@debugmcp/codelldb-*` packages), else the VSIX's own `package.json` when it is CodeLLDB's (`name: vscode-lldb`); a symlinked `CODELLDB_PATH` is followed to where it points; a vendored copy with no readable metadata keeps the pin, and any other binary with none reports no version rather than a guess. The canary asserts the engine version on its `CODELLDB_PATH` leg for releases after 0.26.0. Thanks @gezerui888 for the fix and the regression tests (#1066) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 91a7bc21..08173990 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -65,7 +65,7 @@ Usage notes: | dart | Dart SDK 3.x (`dart`), or Flutter (bundles its own Dart) | `dart debug_adapter` / `flutter debug-adapter` — the SDK's own DAP servers, behind a TCP-to-stdio bridge; nothing vendored | [dart.dev/get-dart](https://dart.dev/get-dart) (Windows `winget install Google.DartSDK`, macOS `brew install dart`, Linux the `dart` apt package) or Flutter; run `flutter --version` once so the Flutter tool cache is built | `DART_SDK` (alias `DART_PATH`), `FLUTTER_ROOT` (alias `FLUTTER_PATH`); a project's fvm pin (`.fvmrc`) is honoured | | mock | — | — | nothing (testing adapter) | — | -CodeLLDB resolution order (rust, cpp and cobol): **vendored copy → `CODELLDB_PATH` → `@debugmcp/codelldb-` package** (npm installs exactly the one matching your platform as an optional dependency). Doctor's backend column shows which source won. If you installed with `--omit=optional`, set `CODELLDB_PATH` to a [CodeLLDB release](https://github.com/vadimcn/codelldb/releases) binary. +CodeLLDB resolution order (rust, cpp and cobol): **vendored copy → `CODELLDB_PATH` → `@debugmcp/codelldb-` package** (npm installs exactly the one matching your platform as an optional dependency). Doctor's backend column shows which source won, and the engine version read from that copy's own root: its `version.json` (vendored copies, the platform packages), else a raw VSIX extraction's `package.json`; a bare binary with neither shows no version. If you installed with `--omit=optional`, set `CODELLDB_PATH` to a [CodeLLDB release](https://github.com/vadimcn/codelldb/releases) binary (extract the `.vsix` and point at `extension/adapter/codelldb`). ## Failure signatures diff --git a/packages/codelldb-common/src/codelldb-resolver.ts b/packages/codelldb-common/src/codelldb-resolver.ts index fe8371a1..4ccd4ed6 100644 --- a/packages/codelldb-common/src/codelldb-resolver.ts +++ b/packages/codelldb-common/src/codelldb-resolver.ts @@ -277,51 +277,65 @@ export async function resolveCodeLLDBExecutable(options?: { return resolved?.path ?? null; } +/** The `name` of the upstream VSIX manifest (vadimcn/codelldb's package.json). */ +const CODELLDB_VSIX_MANIFEST_NAME = 'vscode-lldb'; + +/** A parsed JSON object file, or null when it is unreadable, not JSON, or not an object. */ +async function readJsonObject(file: string): Promise | null> { + try { + const parsed: unknown = JSON.parse(await fs.readFile(file, 'utf-8')); + return parsed !== null && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return null; + } +} + +/** The object's `version` when it is a non-empty string. */ +function versionField(data: Record | null): string | undefined { + const version = data?.version; + return typeof version === 'string' && version.length > 0 ? version : undefined; +} + /** - * Check if CodeLLDB is installed and get version + * The version of the CodeLLDB that resolves, read from that binary's own root + * and nowhere else (/adapter/codelldb → ): `version.json` — the + * vendor script writes it, the platform packages ship it — then the upstream + * VSIX manifest `package.json` when it is CodeLLDB's own, which is what a raw + * extraction under CODELLDB_PATH carries (issue #1066). Another install's + * metadata is never consulted, so the reported version cannot be + * misattributed: not a stale vendor tree under the cwd fallback, not the + * platform package's version.json when CODELLDB_PATH won, not a project's + * package.json above a hand-placed binary. A vendored copy with no readable metadata keeps the + * pin the vendor script fetched; any other source reports no version (null) + * rather than the compiled default. */ export async function getCodeLLDBVersion(options?: { resolvePlatformPackageRoot?: (platformDir: string) => string | null; }): Promise { - const resolvePlatformPackageRoot = - options?.resolvePlatformPackageRoot ?? resolveCodeLLDBPlatformPackageRoot; - const codelldbPath = await resolveCodeLLDBExecutable({ resolvePlatformPackageRoot }); - - if (!codelldbPath) { + const resolved = await resolveCodeLLDBExecutableWithSource(options); + if (!resolved) { return null; } - const platformDir = getCodeLLDBPlatformDir(process.platform, process.arch); - if (!platformDir) { - return DEFAULT_CODELLDB_VERSION; + // A symlinked CODELLDB_PATH (~/.local/bin/codelldb → the extraction) keeps + // its metadata where it points. + let binary = resolved.path; + try { + binary = await fs.realpath(resolved.path); + } catch { + // Read beside the path as given } + const root = path.resolve(path.dirname(binary), '..'); - // The version.json that describes the binary we actually resolved sits - // beside it (/adapter/codelldb -> /version.json) — probe that - // first so the reported version cannot be misattributed to a different - // install (e.g. a stale vendor tree under the cwd fallback). - // Raw VSIX extractions store their version in package.json at the same - // root. Read that before metadata from another install. - const versionFileCandidates = [ - path.resolve(path.dirname(codelldbPath), '..', 'version.json'), - path.resolve(path.dirname(codelldbPath), '..', 'package.json'), - ...buildVendorCandidatePaths(defaultPackageRoot(), platformDir, 'version.json') - ]; - - const platformPackageRoot = resolvePlatformPackageRoot(platformDir); - if (platformPackageRoot) { - versionFileCandidates.push(path.join(platformPackageRoot, 'version.json')); + const fromVersionJson = versionField(await readJsonObject(path.join(root, 'version.json'))); + if (fromVersionJson) { + return fromVersionJson; } - - for (const versionFile of versionFileCandidates) { - try { - const versionData = await fs.readFile(versionFile, 'utf-8'); - const parsed = JSON.parse(versionData); - return parsed.version || DEFAULT_CODELLDB_VERSION; - } catch { - // Continue to next candidate - } + const manifest = await readJsonObject(path.join(root, 'package.json')); + const fromManifest = manifest?.name === CODELLDB_VSIX_MANIFEST_NAME ? versionField(manifest) : undefined; + if (fromManifest) { + return fromManifest; } - return DEFAULT_CODELLDB_VERSION; // Default version fallback + return resolved.source === 'vendored' ? DEFAULT_CODELLDB_VERSION : null; } diff --git a/packages/codelldb-common/tests/codelldb-resolver.test.ts b/packages/codelldb-common/tests/codelldb-resolver.test.ts index a51a1c9f..c92d0488 100644 --- a/packages/codelldb-common/tests/codelldb-resolver.test.ts +++ b/packages/codelldb-common/tests/codelldb-resolver.test.ts @@ -10,10 +10,12 @@ import { readFileSync } from 'node:fs'; const accessMock: Mock = vi.fn(); const readFileMock: Mock = vi.fn(); +const realpathMock: Mock = vi.fn(); vi.mock('fs/promises', () => ({ access: (...args: unknown[]) => accessMock(...args), - readFile: (...args: unknown[]) => readFileMock(...args) + readFile: (...args: unknown[]) => readFileMock(...args), + realpath: (...args: unknown[]) => realpathMock(...args) })); import { @@ -48,6 +50,9 @@ describe('codelldb-resolver', () => { beforeEach(() => { accessMock.mockReset(); readFileMock.mockReset(); + // No symlinks unless a test says so + realpathMock.mockReset(); + realpathMock.mockImplementation(async (p: string) => p); // Hermetic: never let the host machine's CODELLDB_PATH leak into tests vi.stubEnv('CODELLDB_PATH', ''); }); @@ -213,6 +218,23 @@ describe('codelldb-resolver', () => { }); describe('getCodeLLDBVersion', () => { + // The version comes from the resolved binary's own root (/adapter/codelldb): + // /version.json (the vendor script's, the platform package's), then the + // upstream VSIX manifest /package.json when it is CodeLLDB's own + // (issue #1066). Another install's metadata is never read: a vendored copy with + // no readable metadata keeps the pin, any other source reports no version. + const vsixManifest = (version: string): string => + JSON.stringify({ name: 'vscode-lldb', publisher: 'vadimcn', version }); + + /** Serve `files` (path -> content; undefined = ENOENT) and ENOENT for any other path. */ + function serveFiles(files: ReadonlyMap): void { + readFileMock.mockImplementation(async (candidate: string) => { + const content = files.get(candidate); + if (content === undefined) throw new Error('ENOENT'); + return content; + }); + } + it('returns null when the executable cannot be resolved', async () => { stubPlatform('win32'); accessMock.mockRejectedValue(new Error('ENOENT')); @@ -228,106 +250,37 @@ describe('codelldb-resolver', () => { await expect(getCodeLLDBVersion()).resolves.toBe('1.12.3'); + expect(readFileMock).toHaveBeenCalledTimes(1); const versionFile = readFileMock.mock.calls[0][0] as string; expect(versionFile.endsWith(path.join('linux-x64', 'version.json'))).toBe(true); }); - it('skips a malformed version.json and reads the next candidate', async () => { + it('a vendored copy with a malformed version.json and no manifest reports the pin, reading nothing of another install', async () => { stubPlatform('linux', 'x64'); accessMock.mockResolvedValue(undefined); - readFileMock - .mockResolvedValueOnce('not-json{') - .mockResolvedValueOnce(JSON.stringify({ version: '9.9.9' })); + readFileMock.mockResolvedValueOnce('not-json{').mockRejectedValue(new Error('ENOENT')); - await expect(getCodeLLDBVersion()).resolves.toBe('9.9.9'); - expect(readFileMock).toHaveBeenCalledTimes(2); - }); + await expect(getCodeLLDBVersion()).resolves.toBe(DEFAULT_CODELLDB_VERSION); - it.each([ - { - name: 'reads the raw VSIX manifest before unrelated metadata', - versionJson: undefined, - packageJson: '{"version":"1.11.8"}', - otherVersion: '9.9.9', - expected: '1.11.8' - }, - { - name: 'keeps version.json ahead of the VSIX manifest', - versionJson: '{"version":"1.11.7"}', - packageJson: '{"version":"1.11.8"}', - otherVersion: '9.9.9', - expected: '1.11.7' - }, - { - name: 'preserves the empty version.json fallback', - versionJson: '{}', - packageJson: '{"version":"1.11.8"}', - otherVersion: '9.9.9', - expected: DEFAULT_CODELLDB_VERSION - }, - { - name: 'tries the manifest after malformed version.json', - versionJson: '{invalid', - packageJson: '{"version":"1.11.8"}', - otherVersion: '9.9.9', - expected: '1.11.8' - }, - { - name: 'continues after a malformed manifest', - versionJson: undefined, - packageJson: '{invalid', - otherVersion: '9.9.9', - expected: '9.9.9' - }, - { - name: 'continues when the manifest is absent', - versionJson: undefined, - packageJson: undefined, - otherVersion: '9.9.9', - expected: '9.9.9' - }, - { - name: 'uses the default when all metadata is absent', - versionJson: undefined, - packageJson: undefined, - otherVersion: undefined, - expected: DEFAULT_CODELLDB_VERSION - }, - { - name: 'uses the default when the manifest has no version', - versionJson: undefined, - packageJson: '{}', - otherVersion: undefined, - expected: DEFAULT_CODELLDB_VERSION - } - ])('$name', async ({ versionJson, packageJson, otherVersion, expected }) => { - stubPlatform('linux', 'x64'); - const extensionRoot = path.resolve('/raw-vsix/extension'); - const executable = path.join(extensionRoot, 'adapter', 'codelldb'); - vi.stubEnv('CODELLDB_PATH', executable); - accessMock.mockImplementation(async (candidate: string) => { - if (candidate !== executable) throw new Error('ENOENT'); - }); - const adjacentMetadata = new Map([ - [path.join(extensionRoot, 'version.json'), versionJson], - [path.join(extensionRoot, 'package.json'), packageJson] + // version.json, then package.json, both beside the resolved binary — not the + // other vendor candidates, not the platform package + const vendorDir = path.dirname(path.dirname(accessMock.mock.calls[0][0] as string)); + expect(readFileMock.mock.calls.map((call) => call[0])).toEqual([ + path.join(vendorDir, 'version.json'), + path.join(vendorDir, 'package.json') ]); - readFileMock.mockImplementation(async (candidate: string) => { - if (adjacentMetadata.has(candidate)) { - const content = adjacentMetadata.get(candidate); - if (content === undefined) throw new Error('ENOENT'); - return content; - } - if (otherVersion !== undefined) return JSON.stringify({ version: otherVersion }); - throw new Error('ENOENT'); - }); + }); - await expect( - getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null }) - ).resolves.toBe(expected); + it('a vendored version.json without a version field falls through to the VSIX manifest beside it', async () => { + stubPlatform('darwin', 'arm64'); + accessMock.mockResolvedValue(undefined); + readFileMock.mockResolvedValueOnce('{}').mockResolvedValueOnce(vsixManifest('1.12.3')); + + await expect(getCodeLLDBVersion()).resolves.toBe('1.12.3'); + expect(readFileMock).toHaveBeenCalledTimes(2); }); - it('falls back to DEFAULT_CODELLDB_VERSION when no version metadata is readable', async () => { + it('falls back to DEFAULT_CODELLDB_VERSION for a vendored copy with no readable metadata', async () => { stubPlatform('win32'); accessMock.mockResolvedValue(undefined); readFileMock.mockRejectedValue(new Error('ENOENT')); @@ -335,16 +288,150 @@ describe('codelldb-resolver', () => { await expect( getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null }) ).resolves.toBe(DEFAULT_CODELLDB_VERSION); - // Both sibling metadata files + the 4 vendor candidates - expect(readFileMock).toHaveBeenCalledTimes(6); + // The two files beside the binary and nothing else + expect(readFileMock).toHaveBeenCalledTimes(2); }); - it('falls back to DEFAULT_CODELLDB_VERSION when version.json lacks a version field', async () => { - stubPlatform('darwin', 'arm64'); - accessMock.mockResolvedValue(undefined); - readFileMock.mockResolvedValue('{}'); + describe('under CODELLDB_PATH (a raw VSIX extraction, issue #1066)', () => { + const extensionRoot = path.resolve('/raw-vsix/extension'); + const executable = path.join(extensionRoot, 'adapter', 'codelldb'); - await expect(getCodeLLDBVersion()).resolves.toBe(DEFAULT_CODELLDB_VERSION); + /** CODELLDB_PATH names the extraction's binary; the vendor candidates do not exist. */ + function stubExtraction(versionJson: string | undefined, packageJson: string | undefined): void { + stubPlatform('linux', 'x64'); + vi.stubEnv('CODELLDB_PATH', executable); + accessMock.mockImplementation(async (candidate: string) => { + if (candidate !== executable) throw new Error('ENOENT'); + }); + serveFiles(new Map([ + [path.join(extensionRoot, 'version.json'), versionJson], + [path.join(extensionRoot, 'package.json'), packageJson] + ])); + } + + it.each([ + { + name: 'reads the VSIX manifest when there is no version.json', + versionJson: undefined, + packageJson: vsixManifest('1.11.8'), + expected: '1.11.8' + }, + { + name: 'keeps version.json ahead of the manifest', + versionJson: '{"version":"1.11.7"}', + packageJson: vsixManifest('1.11.8'), + expected: '1.11.7' + }, + { + name: 'falls through to the manifest when version.json has no version field', + versionJson: '{}', + packageJson: vsixManifest('1.11.8'), + expected: '1.11.8' + }, + { + name: 'falls through to the manifest when version.json is malformed', + versionJson: '{invalid', + packageJson: vsixManifest('1.11.8'), + expected: '1.11.8' + }, + { + name: 'reports no version when the manifest is malformed', + versionJson: undefined, + packageJson: '{invalid', + expected: null + }, + { + name: 'reports no version when there is no metadata at all', + versionJson: undefined, + packageJson: undefined, + expected: null + }, + { + name: 'reports no version when the manifest has no version', + versionJson: undefined, + packageJson: vsixManifest(''), + expected: null + }, + { + name: "reports no version when the package.json beside the binary is not CodeLLDB's", + versionJson: undefined, + packageJson: '{"name":"my-app","version":"0.4.2"}', + expected: null + } + ])('$name', async ({ versionJson, packageJson, expected }) => { + stubExtraction(versionJson, packageJson); + + await expect( + getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null }) + ).resolves.toBe(expected); + + // Never the compiled default, never another install's metadata: every read + // is beside the resolved binary. + expect(readFileMock.mock.calls.length).toBeGreaterThan(0); + for (const call of readFileMock.mock.calls) { + expect(path.dirname(call[0] as string)).toBe(extensionRoot); + } + }); + + it('follows a symlinked CODELLDB_PATH to the extraction it points into', async () => { + const link = path.resolve('/usr/local/bin/codelldb'); + stubExtraction(undefined, vsixManifest('1.11.8')); + vi.stubEnv('CODELLDB_PATH', link); + accessMock.mockImplementation(async (candidate: string) => { + if (candidate !== link) throw new Error('ENOENT'); + }); + realpathMock.mockImplementation(async (candidate: string) => (candidate === link ? executable : candidate)); + + await expect( + getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null }) + ).resolves.toBe('1.11.8'); + expect(readFileMock.mock.calls.map((call) => call[0])).toEqual([ + path.join(extensionRoot, 'version.json'), + path.join(extensionRoot, 'package.json') + ]); + }); + + it('reads beside the path as given when it cannot be resolved', async () => { + stubExtraction(undefined, vsixManifest('1.11.8')); + realpathMock.mockRejectedValue(new Error('EACCES')); + + await expect( + getCodeLLDBVersion({ resolvePlatformPackageRoot: () => null }) + ).resolves.toBe('1.11.8'); + }); + }); + + describe('under the @debugmcp/codelldb- package', () => { + const packageRoot = path.resolve('/pkg/node_modules/@debugmcp/codelldb-linux-x64'); + const executable = path.join(packageRoot, 'adapter', 'codelldb'); + + /** Only the platform package's binary exists; its npm manifest is not CodeLLDB's. */ + function stubPlatformPackage(versionJson: string | undefined): void { + stubPlatform('linux', 'x64'); + accessMock.mockImplementation(async (candidate: string) => { + if (candidate !== executable) throw new Error('ENOENT'); + }); + serveFiles(new Map([ + [path.join(packageRoot, 'version.json'), versionJson], + [path.join(packageRoot, 'package.json'), '{"name":"@debugmcp/codelldb-linux-x64","version":"1.12.4"}'] + ])); + } + + it('reads the version.json the package ships', async () => { + stubPlatformPackage('{"version":"1.12.3"}'); + + await expect( + getCodeLLDBVersion({ resolvePlatformPackageRoot: () => packageRoot }) + ).resolves.toBe('1.12.3'); + }); + + it("reports no version without it — the npm manifest's version is the package's, not the engine's", async () => { + stubPlatformPackage(undefined); + + await expect( + getCodeLLDBVersion({ resolvePlatformPackageRoot: () => packageRoot }) + ).resolves.toBeNull(); + }); }); }); From b3d0ca1ff7b0fcc1aac84315b51bc17c6af6029c Mon Sep 17 00:00:00 2001 From: JF Date: Thu, 5 Nov 2026 09:15:56 -0500 Subject: [PATCH 3/4] test(cli): the doctor VSIX-version proof runs on CI-sized budgets and proves the bare-binary case (#1066) doctor is invoked with --timeout 60000, the CLI kill is 90 s and each case has 120 s: the rust probe runs `cargo -vV`, which the canary has seen take over 30 s on a cold Windows runner, and the doctor's default 10 s probe budget would have timed the row out and lost the backend cell. The bundle is copied once per file (each case gets its own extraction dir), the empty-stderr assertion goes (a Node warning from the bundle is not a resolver fault), and a fourth case proves a binary whose only neighbour is someone else's package.json reports no version key at all. Co-Authored-By: Claude Fable 5.1 --- .../cli/doctor-codelldb-version.test.ts | 127 +++++++++++++----- 1 file changed, 90 insertions(+), 37 deletions(-) diff --git a/tests/integration/cli/doctor-codelldb-version.test.ts b/tests/integration/cli/doctor-codelldb-version.test.ts index 0708e137..6589f789 100644 --- a/tests/integration/cli/doctor-codelldb-version.test.ts +++ b/tests/integration/cli/doctor-codelldb-version.test.ts @@ -1,5 +1,16 @@ -/** Exercise the shipped doctor command with the documented raw VSIX layout (#1066). */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +/** + * The shipped doctor command reads the CodeLLDB version from the resolved + * engine's own root (issue #1066). Under a CODELLDB_PATH pointing into the + * documented raw VSIX layout (/extension/adapter/codelldb) that is the + * extraction's `version.json` when there is one, else the VSIX's own + * `package.json`; a binary with neither reports no version at all rather than + * the CLI's compiled default. + * + * Runs the built CLI bundle from an isolated directory, so the checkout's + * vendored engine cannot win the resolution, with every language but rust + * disabled, so only the rust probe runs. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execFile } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; @@ -7,73 +18,115 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { DebugLanguage } from '@debugmcp/shared'; -import { DEFAULT_CODELLDB_VERSION } from '@debugmcp/codelldb-common'; import type { DoctorReport } from '../../../src/cli/commands/doctor/diagnose.js'; import { scrubbedEnvironment } from '../../test-utils/helpers/mcp-stdio-client.js'; import { requireCliBundle } from '../../test-utils/helpers/cli-bundle.js'; const root = fileURLToPath(new URL('../../../', import.meta.url)); const run = promisify(execFile); +const vsixManifest = (version: string): string => + JSON.stringify({ name: 'vscode-lldb', publisher: 'vadimcn', version }); + +/** + * The rust probe runs `cargo -vV`, which the canary has seen take over 30 s on + * a cold Windows runner (its doctor step budgets 120 s): the doctor's default + * 10 s probe budget would time the row out and lose the backend cell. + */ +const DOCTOR_TIMEOUT_MS = 60_000; +const CLI_KILL_MS = 90_000; +const CASE_TIMEOUT_MS = 120_000; + let fixtureRoot: string; -let extensionRoot: string; -let executable: string; +let cli: string; -beforeEach(() => { +beforeAll(() => { fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-codelldb-')); - // An isolated install and cwd prevent the checkout's vendored engine from winning. - fs.copyFileSync(requireCliBundle(root), path.join(fixtureRoot, 'cli.mjs')); - extensionRoot = path.join(fixtureRoot, 'extension'); - fs.mkdirSync(path.join(extensionRoot, 'adapter'), { recursive: true }); - executable = path.join(extensionRoot, 'adapter', process.platform === 'win32' ? 'codelldb.exe' : 'codelldb'); - // Version discovery checks existence and reads metadata without executing CodeLLDB. - fs.writeFileSync(executable, ''); + // One copy of the bundle: its own directory and cwd keep the checkout's + // vendor tree out of the candidate walk. + cli = path.join(fixtureRoot, 'cli.mjs'); + fs.copyFileSync(requireCliBundle(root), cli); }); -afterEach(() => { +afterAll(() => { fs.rmSync(fixtureRoot, { recursive: true, force: true }); }); -describe('doctor CodeLLDB version', () => { +/** A raw VSIX extraction: /extension/adapter/codelldb plus the metadata the case wants. */ +function extraction(versionJson: string | undefined, packageJson: string | undefined): string { + const extensionRoot = path.join(fs.mkdtempSync(path.join(fixtureRoot, 'vsix-')), 'extension'); + fs.mkdirSync(path.join(extensionRoot, 'adapter'), { recursive: true }); + const executable = path.join(extensionRoot, 'adapter', process.platform === 'win32' ? 'codelldb.exe' : 'codelldb'); + // Version discovery checks existence and reads metadata; nothing executes the engine. + fs.writeFileSync(executable, ''); + if (versionJson !== undefined) fs.writeFileSync(path.join(extensionRoot, 'version.json'), versionJson); + if (packageJson !== undefined) fs.writeFileSync(path.join(extensionRoot, 'package.json'), packageJson); + return executable; +} + +async function doctorRust(executable: string): Promise { + const { stdout } = await run( + process.execPath, + [cli, 'doctor', 'rust', '--json', '--timeout', String(DOCTOR_TIMEOUT_MS)], + { + cwd: fixtureRoot, + env: { + ...scrubbedEnvironment(), + CODELLDB_PATH: executable, + // Only the rust probe runs: the other toolchains are neither needed nor waited on. + DEBUG_MCP_DISABLE_LANGUAGES: Object.values(DebugLanguage) + .filter((language) => language !== DebugLanguage.RUST) + .join(',') + }, + timeout: CLI_KILL_MS, + windowsHide: true + } + ); + return JSON.parse(stdout) as DoctorReport; +} + +function rustBackend(report: DoctorReport): unknown { + return report.languages.find((language) => language.language === 'rust')?.backend; +} + +describe('doctor CodeLLDB version under CODELLDB_PATH (issue #1066)', () => { it.each([ { name: 'reads the raw VSIX manifest', versionJson: undefined, - packageJson: '{"version":"1.11.8"}', + packageJson: vsixManifest('1.11.8'), expected: '1.11.8' }, { name: 'prefers version.json', versionJson: '{"version":"1.11.7"}', - packageJson: '{"version":"1.11.8"}', + packageJson: vsixManifest('1.11.8'), expected: '1.11.7' }, { - name: 'preserves an empty version.json fallback', + name: 'falls through to the manifest when version.json has no version field', versionJson: '{}', - packageJson: '{"version":"1.11.8"}', - expected: DEFAULT_CODELLDB_VERSION + packageJson: vsixManifest('1.11.8'), + expected: '1.11.8' } ])('$name', async ({ versionJson, packageJson, expected }) => { - if (versionJson !== undefined) fs.writeFileSync(path.join(extensionRoot, 'version.json'), versionJson); - fs.writeFileSync(path.join(extensionRoot, 'package.json'), packageJson); - const { stdout, stderr } = await run(process.execPath, [path.join(fixtureRoot, 'cli.mjs'), 'doctor', 'rust', '--json'], { - cwd: fixtureRoot, - env: { - ...scrubbedEnvironment(), - CODELLDB_PATH: executable, - // Keep this metadata test independent of other installed language toolchains. - DEBUG_MCP_DISABLE_LANGUAGES: Object.values(DebugLanguage).filter(language => language !== DebugLanguage.RUST).join(',') - }, - timeout: 15_000, - windowsHide: true - }); + const executable = extraction(versionJson, packageJson); + const report = await doctorRust(executable); - expect(stderr).toBe(''); - const report = JSON.parse(stdout) as DoctorReport; expect(report.exitCode).toBe(0); expect(report.requested).toEqual(['rust']); - expect(report.languages.find(language => language.language === 'rust')?.backend).toEqual({ + expect(rustBackend(report)).toEqual({ label: 'CodeLLDB', path: executable, version: expected, source: 'env:CODELLDB_PATH' }); - }); + }, CASE_TIMEOUT_MS); + + it("reports no version for a binary whose only neighbour is someone else's package.json", async () => { + const executable = extraction(undefined, '{"name":"my-app","version":"0.4.2"}'); + const report = await doctorRust(executable); + + expect(report.exitCode).toBe(0); + // No version key at all — not the compiled default, not my-app's 0.4.2. + expect(rustBackend(report)).toEqual({ + label: 'CodeLLDB', path: executable, source: 'env:CODELLDB_PATH' + }); + }, CASE_TIMEOUT_MS); }); From 0f77e2e71b05ca35da70cb094861b12da3c5a04d Mon Sep 17 00:00:00 2001 From: JF Date: Thu, 5 Nov 2026 09:15:56 -0500 Subject: [PATCH 4/4] ci(canary): assert the engine version on the CODELLDB_PATH leg for releases after 0.26.0 (#1066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The omit-optional leg provisions the VSIX at the checkout's pin and doctor now reads that extraction's package.json, so backend.version must equal the pin — the assertion #1066 asked for. Releases up to 0.26.0 reported the CLI's compiled default, so the check is gated on the published CLI's version being greater than 0.26.0 (dotted core compared numerically, prerelease suffix ignored) and skipped with a notice otherwise. Dry-run locally against 0.26.0, 0.26.1 and 0.27.0-beta.1 with matching and mismatching engine versions. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/canary.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index b47502bf..eb48bea6 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -210,6 +210,23 @@ jobs: SOURCE="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).languages.find(l=>l.language==='rust').backend.source" "$TRANSCRIPTS/doctor.json")" echo "backend.source=$SOURCE (expected $EXPECT)" [ "$SOURCE" = "$EXPECT" ] + if [ "$LEG" = "omit-optional" ] && [ "$SLIM" = "true" ]; then + # The engine on this leg is the VSIX provisioned above at the + # checkout's pin, and doctor reads the version from that + # extraction's own package.json (#1066) — so it must name the pin. + # Releases up to 0.26.0 reported the CLI's compiled default instead; + # the assertion applies to everything published after. + CLI_VERSION="$(node -p "require(process.argv[1]).version" "$(npm root -g)/@debugmcp/mcp-debugger/package.json")" + FIXED="$(node -e 'const core=v=>v.split("-")[0].split(".").map(Number);const [a,b]=[core(process.argv[1]),core("0.26.0")];console.log(a[0]>b[0]||(a[0]===b[0]&&(a[1]>b[1]||(a[1]===b[1]&&a[2]>b[2]))))' "$CLI_VERSION")" + if [ "$FIXED" = "true" ]; then + PIN="$(node -p "require('./packages/codelldb-common/vendor-manifest.json').codelldb.version")" + ENGINE="$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).languages.find(l=>l.language==='rust').backend.version" "$TRANSCRIPTS/doctor.json")" + echo "backend.version=$ENGINE (expected $PIN)" + [ "$ENGINE" = "$PIN" ] + else + echo "::notice::$CLI_VERSION predates the engine-version fix (#1066) — backend.version assertion skipped" + fi + fi - name: Smoke cycle (mock) run: |