diff --git a/README.md b/README.md index 09338d4..343a3fb 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,42 @@ A single Rust binary serves both MCP and LSP protocols. --- +## Supported platforms + +The engine is a native binary, downloaded for your platform on first run. + +| Platform | Architectures | +|---|---| +| macOS | Apple Silicon (arm64) and Intel (x64) | +| Linux | x64 and arm64 | +| Windows | x64 (Windows on ARM runs the x64 build under emulation) | + +**Linux requires glibc 2.30 or newer *and* a libstdc++ from GCC 11 or newer +(`GLIBCXX_3.4.29`).** The second requirement is the binding one, and it is not +implied by the first — the engine embeds ONNX Runtime, which is built with +GCC 11. + +| Runs | Does not run | +|---|---| +| SLES 15 SP4 | Ubuntu 20.04 | +| Ubuntu 22.04 and newer | Debian 11 | +| Debian 12 and newer | RHEL / CentOS 8 | +| RHEL 9 and newer | Amazon Linux 2 | +| Amazon Linux 2023 | | + +Both Linux architectures have identical requirements. If the engine exits +immediately with a message like + +``` +version `GLIBCXX_3.4.29' not found (required by codegraph-server) +``` + +the distribution's C++ runtime is older than the engine needs; installing a +newer `libstdc++` (for example RHEL 8's `gcc-toolset-11`) resolves it without +upgrading the distribution. + +--- + ## Building from Source ```bash diff --git a/jetbrains/README.md b/jetbrains/README.md index e2a435b..3d0fd79 100644 --- a/jetbrains/README.md +++ b/jetbrains/README.md @@ -39,8 +39,8 @@ the dependency ever becomes a problem. ## Engine resolution The plugin does **not** bundle engine binaries, and neither does any other -client any more: bundling all four platforms meant a ~120 MB download for the -one binary a given user can actually run. +client any more: bundling every platform meant a download several times the size +of the one binary a given user can actually run. The engine is published once as GitHub release assets and each client fetches what its platform needs, into the shared `~/.codegraph/bin`. diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt index 81e6f57..a4c7814 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/indexing/IndexingStartupActivity.kt @@ -38,7 +38,7 @@ class IndexingStartupActivity : ProjectActivity { val resolved = CodeGraphServerResolver.resolve(project.basePath, settings.serverPath) if (resolved == null) { - // Offered rather than done automatically: this is a ~30 MB download + // Offered rather than done automatically: this is a ~120 MB download // of a native binary that will run with the user's permissions, and // starting that unasked on project open is not a decision the // plugin should make for them. diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt index 6825b12..1eb31ea 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt @@ -59,13 +59,13 @@ data class ResolverEnvironment( /** * Locates the `codegraph-server` engine binary. * - * Resolution order mirrors `vscode/src/server.ts`, with one deliberate - * difference: the JetBrains plugin does not bundle platform binaries. The VSIX - * carries four of them (100-126 MB each) because VS Code can ship per-platform - * artifacts; the JetBrains Marketplace cannot, so a bundled plugin would be a - * ~120 MB download for every user regardless of platform. Instead the binary is - * resolved from an existing install and, failing that, downloaded once into the - * managed install directory (Phase 1). + * Resolution order mirrors `vscode/src/server.ts`. No client bundles platform + * binaries any more (each engine is 100-126 MB), and the case against it is + * strongest here: VS Code could at least ship one artifact per platform, while + * the JetBrains Marketplace serves a single artifact to everyone, so a bundled + * plugin would carry every published engine to every user. Instead the binary + * is resolved from an existing install and, failing that, downloaded once into + * the managed install directory (Phase 1). * * Order: * 1. Explicit user override (settings) @@ -83,14 +83,18 @@ object CodeGraphServerResolver { /** * Binary name for this platform, or null when no engine is published for it. * - * Only macOS is built for both architectures. Windows on ARM runs the x64 - * build under the OS's own emulation layer, so it is served the x64 asset; - * Linux has no such layer, and falling back to x64 there installs ~30 MB - * that cannot execute, which surfaces as an exec-format error at first use - * instead of as the unsupported platform it is. + * macOS and Linux are both built for x64 and arm64, and are answered by + * exact platform-arch match and nothing else: falling back to x64 on an + * arm64 machine installs ~120 MB that cannot execute, which surfaces as an + * exec-format error at first use instead of as the unsupported platform it + * is. Windows on ARM is the one exception - it runs the x64 build under the + * OS's own emulation layer, so refusing it would leave those users with no + * engine at all. * * Mirrors `platformBinaryName()` in `mcp-package/bin/fetch-engine.js`, which - * is the same rule for the JavaScript channels. + * is the same rule for the JavaScript channels. The plugin cannot import + * that list, so this mapping has to be edited in lockstep with it whenever a + * platform is added or dropped. */ fun platformBinaryNameOrNull(env: ResolverEnvironment = ResolverEnvironment.fromSystem()): String? { val os = env.osName.lowercase() @@ -104,7 +108,11 @@ object CodeGraphServerResolver { else -> null } os.contains("win") -> if (isX64 || isArm64) "codegraph-server-win32-x64.exe" else null - os.contains("linux") -> if (isX64) "codegraph-server-linux-x64" else null + os.contains("linux") -> when { + isArm64 -> "codegraph-server-linux-arm64" + isX64 -> "codegraph-server-linux-x64" + else -> null + } else -> null } } diff --git a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt index 8937a3c..007bf5e 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt @@ -18,11 +18,11 @@ import java.util.Locale * Fetches the engine for this platform into the managed install directory. * * The plugin does not bundle engines: the JetBrains Marketplace serves one - * artifact to every platform, so bundling all four would mean a ~120 MB - * download for every user to obtain the ~30 MB they can run. The alternative - * for users without Node is worse - install a 498 MB npm package for one - * binary - so the engine is fetched directly from the release that - * `scripts/publish-release-assets.sh` produces. + * artifact to every platform, so bundling every published engine would mean a + * download several times the size of the one a given user can run. Sending + * users to the npm package instead is not an answer either: it needs Node, and + * it fetches the same engine from the same release. So the engine is fetched + * directly from the release that `scripts/publish-release-assets.sh` produces. * * Downloads are verified against the checksum published beside each asset. An * engine is a native binary that runs with the user's permissions; TLS says diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt index 07f1f28..e53c976 100644 --- a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolverTest.kt @@ -160,18 +160,19 @@ class CodeGraphServerResolverTest : BasePlatformTestCase() { } } - fun `test arm64 linux has no published engine but arm64 windows emulates x64`() { - // Handing the x64 asset to an arm64 Linux machine installs something - // that cannot execute, which shows up as an exec-format error rather - // than as the missing build it is. Windows on ARM is the exception: it - // runs x64 binaries under the OS's own emulation, so refusing there - // would leave those users with no engine for no reason. + fun `test arm64 linux gets its own engine and arm64 windows emulates x64`() { + // Linux arm64 has its own published build, so it must resolve to that + // and never to the x64 asset: handing x64 to an arm64 machine installs + // something that cannot execute, which shows up as an exec-format error + // rather than as the wrong build it is. Windows on ARM is the + // exception - it runs x64 under the OS's own emulation, so refusing + // there would leave those users with no engine for no reason. fun nameFor(os: String, arch: String) = CodeGraphServerResolver.platformBinaryNameOrNull( ResolverEnvironment(fakeHome, emptyList(), os, arch), ) - assertNull(nameFor("Linux", "aarch64")) - assertNull(nameFor("Linux", "arm64")) + assertEquals("codegraph-server-linux-arm64", nameFor("Linux", "aarch64")) + assertEquals("codegraph-server-linux-arm64", nameFor("Linux", "arm64")) assertEquals("codegraph-server-win32-x64.exe", nameFor("Windows 11", "aarch64")) assertEquals("codegraph-server-win32-x64.exe", nameFor("Windows 11", "arm64")) assertEquals("codegraph-server-linux-x64", nameFor("Linux", "x86_64")) diff --git a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt index 8d1bf49..5aae20c 100644 --- a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt @@ -64,9 +64,12 @@ class EngineDownloaderTest : BasePlatformTestCase() { ResolverEnvironment(homeDir = home, pathEntries = emptyList(), osName = os, osArch = arch) /** - * Windows and Linux are published for x64 only, so an arm64 environment - * there is an unsupported platform rather than a machine that downloads the - * x64 build. + * Defaults to arm64 because that is the machine these tests describe: macOS + * and Linux each publish their own arm64 engine, and Windows on ARM is + * served the x64 one it emulates. The Windows cases below pass `amd64` + * explicitly so they read as the platform they are testing rather than + * relying on that fallback. Which name each pair resolves to is + * `CodeGraphServerResolverTest`'s subject, not this file's. */ private fun downloader(os: String, arch: String = "aarch64") = EngineDownloader(env(os, arch), baseUrl()) @@ -81,6 +84,21 @@ class EngineDownloaderTest : BasePlatformTestCase() { assertTrue("the engine must be executable", path.toFile().canExecute()) } + fun `test an arm64 linux ide installs the arm64 engine`() { + // The platform this channel gained. Both Linux assets are published, so + // a mapping that fell back to x64 would install cleanly here and only + // fail when the IDE tried to start the engine - which is why the whole + // download is exercised and not just the name it resolves to. + publish("0.20.1", "codegraph-server-linux-arm64", "arm64 engine".toByteArray()) + publish("0.20.1", "codegraph-server-linux-x64", "x64 engine".toByteArray()) + + val path = downloader("Linux").download("0.20.1") + + assertEquals("codegraph-server-linux-arm64", path.fileName.toString()) + assertEquals("arm64 engine", Files.readString(path)) + assertTrue("the engine must be executable", path.toFile().canExecute()) + } + fun `test windows also installs the runtime library the engine loads`() { publish("0.19.1", "codegraph-server-win32-x64.exe", "engine".toByteArray()) publish("0.19.1", EngineDownloader.WINDOWS_SIDECAR, "onnx".toByteArray()) diff --git a/mcp-package/bin/fetch-engine.js b/mcp-package/bin/fetch-engine.js index 76b887d..d8ba0f1 100644 --- a/mcp-package/bin/fetch-engine.js +++ b/mcp-package/bin/fetch-engine.js @@ -113,6 +113,32 @@ const ARCH_MAP = { arm64: "arm64", x64: "x64", x86_64: "x64" }; */ const VERSION_MARKER = ".engine-version"; +/** + * Every engine binary a release publishes. + * + * This is the single list for everything that can reach it. publish-release- + * assets.sh uploads exactly these and package-npm.sh probes exactly these, both + * by reading this export rather than repeating it - hand-kept copies of the same + * list are how a release ends up publishing a platform no client asks for, or + * asking for one it never published. + * + * One copy cannot be removed: `CodeGraphServerResolver.platformBinaryNameOrNull` + * in the JetBrains plugin is Kotlin and cannot import this file, so it restates + * the same rule and has to be edited alongside this list. Its own test asserts + * the mapping; nothing can cross-check the two automatically. + * + * `onnxruntime.dll` is deliberately absent: it is a sidecar of the Windows + * engine, not an engine, and requiredAssets() is what decides when it is + * needed. + */ +const PUBLISHED_BINARIES = [ + "codegraph-server-darwin-arm64", + "codegraph-server-darwin-x64", + "codegraph-server-linux-arm64", + "codegraph-server-linux-x64", + "codegraph-server-win32-x64.exe", +]; + /** * Asset name for the running platform, matching the names * publish-release-assets.sh uploads. Returns null when unsupported, so callers @@ -122,16 +148,16 @@ function platformBinaryName(platform = os.platform(), arch = os.arch()) { const p = PLATFORM_MAP[platform]; const a = ARCH_MAP[arch]; if (!p || !a) return null; - // macOS is the only platform published for both architectures. - if (p === "darwin") return `codegraph-server-darwin-${a}`; // Windows on ARM runs x64 executables under the OS's own emulation layer, so // the x64 asset is the correct answer there and refusing it would leave those - // users with no engine at all. + // users with no engine at all. Linux and macOS have no such layer, so they + // are answered by exact platform-arch match and nothing else: handing an x64 + // build to an arm64 machine installs ~120 MB that cannot execute, which + // surfaces as an exec-format error at first use rather than as the + // unsupported platform it is. if (p === "win32") return "codegraph-server-win32-x64.exe"; - // Linux has no such layer. Handing the x64 build to an arm64 machine installs - // ~30 MB that cannot execute, which surfaces as an exec-format error at first - // use rather than as the unsupported platform it is. - return a === "x64" ? "codegraph-server-linux-x64" : null; + const name = `codegraph-server-${p}-${a}`; + return PUBLISHED_BINARIES.includes(name) ? name : null; } /** Numeric release components, or null when [version] is not one. */ @@ -415,6 +441,7 @@ async function ensureEngine(version, targetDir, options = {}) { module.exports = { RELEASE_BASE, ENGINE_VERSION, + PUBLISHED_BINARIES, WINDOWS_SIDECAR, VERSION_MARKER, EngineInUseError, diff --git a/mcp-package/test/fetch-engine.test.js b/mcp-package/test/fetch-engine.test.js index 7f84257..f1cfe91 100644 --- a/mcp-package/test/fetch-engine.test.js +++ b/mcp-package/test/fetch-engine.test.js @@ -27,6 +27,7 @@ const { ensureEngine, requiredAssets, platformBinaryName, + PUBLISHED_BINARIES, redirectTarget, installedVersion, compareVersions, @@ -109,6 +110,39 @@ async function run() { } } + // --- an arm64 linux machine installs the arm64 engine ---------------- + // The whole install, not just the name mapping: before linux-arm64 was + // published this threw, and the failure worth guarding against now is the + // quiet one - the x64 asset is served alongside, so a rule that fell back to + // it would install cleanly here and only fail when the user tried to run it. + { + const dir = scratch(); + const release = await startRelease({ + "codegraph-server-linux-arm64": { content: "arm64 engine" }, + "codegraph-server-linux-x64": { content: "x64 engine" }, + }); + try { + const { binary, fetched } = await ensureEngine(VERSION, dir, { + platform: "linux", + arch: "arm64", + baseUrl: release.baseUrl, + }); + check( + path.basename(binary) === "codegraph-server-linux-arm64", + "an arm64 linux install fetches the arm64 engine" + ); + check(fs.readFileSync(binary, "utf8") === "arm64 engine", "and it is the arm64 build"); + check( + fetched.length === 1 && !fs.existsSync(path.join(dir, "codegraph-server-linux-x64")), + "and nothing else, least of all the x64 build" + ); + check(installedVersion(dir) === VERSION, "and the release it came from is recorded"); + } finally { + release.server.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } + } + // --- a corrupted download installs nothing --------------------------- { const dir = scratch(); @@ -412,8 +446,12 @@ async function run() { // --- only published platform/arch pairs resolve to an asset ---------- // An x64 asset handed to an arm64 Linux machine downloads and chmods cleanly // and then fails to exec, which is far harder to read than "not published". - // Windows on ARM is the exception: it emulates x64, so the x64 build runs. - check(platformBinaryName("linux", "arm64") === null, "linux-arm64 has no published engine"); + // Linux arm64 now has its own build, so it must resolve to that one and never + // to the x64 asset. Windows on ARM stays the exception: it emulates x64. + check( + platformBinaryName("linux", "arm64") === "codegraph-server-linux-arm64", + "linux-arm64 resolves to its own engine, not the x64 one" + ); check( platformBinaryName("win32", "arm64") === "codegraph-server-win32-x64.exe", "win32-arm64 uses the x64 engine, which Windows emulates" @@ -427,7 +465,30 @@ async function run() { "darwin-arm64 does" ); check(platformBinaryName("linux", "x64") === "codegraph-server-linux-x64", "linux-x64 does"); - check(requiredAssets("linux", "arm64").length === 0, "an unpublished pair needs no assets"); + check( + requiredAssets("linux", "arm64").length === 1 && + requiredAssets("linux", "arm64")[0] === "codegraph-server-linux-arm64", + "linux-arm64 needs the engine and no sidecar" + ); + // A platform with no build must still resolve to nothing rather than to + // someone else's binary. + check(platformBinaryName("linux", "riscv64") === null, "an unbuilt arch resolves to nothing"); + check(requiredAssets("linux", "riscv64").length === 0, "an unpublished pair needs no assets"); + // Every name the mapping can return has to be a name the release publishes, + // or an install fetches a 404. + for (const [p, a] of [ + ["darwin", "arm64"], + ["darwin", "x64"], + ["linux", "arm64"], + ["linux", "x64"], + ["win32", "x64"], + ]) { + const name = platformBinaryName(p, a); + check( + PUBLISHED_BINARIES.includes(name), + `${p}-${a} resolves to a published asset (${name})` + ); + } console.log(""); console.log(`${failures} failure(s)`); diff --git a/scripts/build-linux-arm64.sh b/scripts/build-linux-arm64.sh new file mode 100755 index 0000000..a66e395 --- /dev/null +++ b/scripts/build-linux-arm64.sh @@ -0,0 +1,278 @@ +#!/bin/bash +# Copyright 2026 Andrey Vasilevsky +# SPDX-License-Identifier: Apache-2.0 +# +# Build the linux-arm64 engine, in a container, with the same compatibility +# floor as the linux-x64 asset. +# +# The other four platforms are built natively on their own machines. arm64 Linux +# has no such machine, and the floor is easy to get wrong, so it is pinned here +# instead of remembered: +# +# Ubuntu 20.04 supplies glibc 2.31, matching the SLES 15 SP4 host the x64 +# asset is built on, so both architectures land on one support +# statement instead of two. +# +# Note what the floor actually is. glibc is not the binding +# constraint - GLIBCXX_3.4.29 is, because ONNX forces GCC 11. +# Measured, this binary runs on SLES 15 SP4, Ubuntu 22.04+, +# Debian 12+, RHEL 9+ and Amazon Linux 2023, and does not run +# on stock Ubuntu 20.04, Debian 11, RHEL 8 or Amazon Linux 2 - +# not even on its own build host as shipped. The container does +# run it, and the check at the end of the recipe relies on +# that, only because the toolchain PPA below upgrades the +# container's libstdc++6 past GLIBCXX_3.4.29. That floor is +# identical to the x64 asset. SLES 15 SP4 works because SUSE +# ships an updated libstdc++6 on an old glibc, which is the +# whole reason this combination is worth pinning. +# +# gcc-11 is required, and is why the obvious choices do not work. +# ONNX Runtime's prebuilt aarch64 static library needs GCC 11's +# libstdc++ (`std::__throw_bad_array_new_length`) and newer +# libgcc outline atomics (`__aarch64_cas8_sync`). Measured: +# Debian 11 (gcc 10) - fails, both symbols +# SLES 15 SP4 BCI + gcc11 - fails, SUSE's gcc11 ships no +# outline atomics +# Ubuntu 22.04 (gcc 11) - links, but floors at glibc 2.34 +# Ubuntu 20.04 + gcc-11 is the only combination measured to give +# both a working link and a 2.30 floor. +# +# Requires Docker. On an arm64 host this runs natively; on x86_64 it runs under +# emulation, which works but is slow. +# +# Usage: +# ./scripts/build-linux-arm64.sh # build + verify + stage +# ./scripts/build-linux-arm64.sh --no-stage # build + verify only +# +# CODEGRAPH_ALLOW_DIRTY=1 ./scripts/build-linux-arm64.sh +# Build from whatever is in the tree, skipping the commit check below. +# Implies --no-stage: a binary nobody can trace must not reach the +# staging directory, where it would look exactly like a release build. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STAGE_DIR="${CODEGRAPH_BIN_DIR:-$REPO_ROOT/vscode/bin}" +ASSET="codegraph-server-linux-arm64" +BUILD_DIR="${CODEGRAPH_ARM64_TARGET:-$REPO_ROOT/target/linux-arm64}" + +# The floors the x64 asset already has. A build that exceeds either of these +# silently narrows who can run CodeGraph, so they are asserted, not printed. +MAX_GLIBC="2.30" +MAX_GLIBCXX="3.4.29" + +command -v docker >/dev/null || { echo "ERROR: docker is required." >&2; exit 1; } + +VERSION="$(grep -m1 '^version' "$REPO_ROOT/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')" +[ -n "$VERSION" ] || { echo "ERROR: no version in Cargo.toml" >&2; exit 1; } + +# Which commit the asset claims is a floor like the other two, and is checked +# like one. build.rs derives that stamp by shelling out to git from inside the +# container, and every way that can fail - a worktree whose gitdir is not +# mounted, git refusing a tree owned by another uid, no git at all - fails +# silently to the literal string "unknown". A modified tree stamps "-dirty". +# Neither is publishable, and stamp-binary.sh records only the version, so +# nothing downstream would notice. So HEAD is captured here and the built +# binary is made to agree with it. +EXPECTED_GIT_SHORT="" +CHECK_PROVENANCE=1 +if [ "${CODEGRAPH_ALLOW_DIRTY:-0}" = "1" ]; then + CHECK_PROVENANCE=0 +else + # An explicit length rather than `--short`, whose default the host user's + # core.abbrev can change while the container's git has no such setting. + EXPECTED_GIT_SHORT="$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD 2>/dev/null || true)" + if [ -z "$EXPECTED_GIT_SHORT" ]; then + echo "ERROR: $REPO_ROOT is not a git checkout, so the engine cannot say what" >&2 + echo "produced it. Build from a checkout, or set CODEGRAPH_ALLOW_DIRTY=1 for a" >&2 + echo "throwaway build that must never be published." >&2 + exit 1 + fi + if [ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null)" ]; then + echo "ERROR: the working tree is dirty, so this build would be stamped '-dirty'" >&2 + echo "and would disagree with the assets built for the same release. Commit or" >&2 + echo "stash first, or set CODEGRAPH_ALLOW_DIRTY=1 for a throwaway build." >&2 + exit 1 + fi +fi + +echo "=== CodeGraph linux-arm64 engine ===" +echo " version: $VERSION" +echo " target: $BUILD_DIR" +if [ "$CHECK_PROVENANCE" -eq 1 ]; then + echo " commit: $EXPECTED_GIT_SHORT" +else + echo " commit: unchecked (CODEGRAPH_ALLOW_DIRTY=1) - do not publish this build" +fi +echo + +mkdir -p "$BUILD_DIR" + +# -i is required: the recipe below is fed to `bash -s` on stdin, and without it +# docker attaches no stdin and the container runs an empty script successfully. +docker run --rm -i --platform linux/arm64 \ + -v "$REPO_ROOT:/src" \ + -v "$BUILD_DIR:/target" \ + -e MAX_GLIBC="$MAX_GLIBC" \ + -e MAX_GLIBCXX="$MAX_GLIBCXX" \ + -e CHECK_PROVENANCE="$CHECK_PROVENANCE" \ + -e EXPECTED_GIT_SHORT="$EXPECTED_GIT_SHORT" \ + -e HOST_UID="$(id -u)" \ + -e HOST_GID="$(id -g)" \ + ubuntu:20.04 bash -s <<'CONTAINER' +set -uo pipefail +export DEBIAN_FRONTEND=noninteractive +APT_OPTS=(-o Acquire::Retries=8 -o Acquire::http::Timeout=30) +apt_install() { + for attempt in 1 2 3; do + apt-get "${APT_OPTS[@]}" install -y -qq --fix-missing "$@" && return 0 + echo "(apt attempt $attempt failed: $*)"; sleep 5 + done + return 1 +} + +apt-get "${APT_OPTS[@]}" update -qq || { echo "APT UPDATE FAILED"; exit 1; } +apt_install software-properties-common curl git ca-certificates || exit 1 +add-apt-repository -y ppa:ubuntu-toolchain-r/test >/dev/null 2>&1 || { echo "PPA FAILED"; exit 1; } +apt-get "${APT_OPTS[@]}" update -qq +apt_install gcc-11 g++-11 clang libclang-dev cmake pkg-config libssl-dev make binutils || exit 1 + +if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain stable --profile minimal >/dev/null || exit 1 +fi +. "$HOME/.cargo/env" + +# build.rs reads git provenance from here, and git refuses a tree owned by +# another uid unless told the ownership is expected. Without this the stamp +# degrades to "unknown" with nothing on stderr. +git config --global --add safe.directory /src + +# The container is root and both mounts live in the developer's tree. On a +# Linux host there is no uid remapping, so anything written here stays +# root-owned and the host cannot rebuild or clean its own target/ afterwards. +# Runs on every exit path, including a failed build, which is when a +# half-written target/ is most annoying to be locked out of. +restore_ownership() { + chown -R "$HOST_UID:$HOST_GID" /target 2>/dev/null || true + chown "$HOST_UID:$HOST_GID" /src/Cargo.lock 2>/dev/null || true + [ -d /src/.git ] && chown -R "$HOST_UID:$HOST_GID" /src/.git 2>/dev/null + return 0 +} +trap restore_ownership EXIT + +cd /src +export CARGO_TARGET_DIR=/target +export CC=gcc-11 CXX=g++-11 +export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=gcc-11 +cargo build --release -p codegraph-server 2>&1 | tail -12 +[ "${PIPESTATUS[0]}" -eq 0 ] || { echo "BUILD FAILED"; exit 1; } + +BIN=/target/release/codegraph-server + +# The #15 regression guard. An immutable definition of this symbol lands in +# .rodata, and on aarch64 it is also exported, so glibc's startup write to it +# faults before main(). It must live in writable memory (B or D, never R). +# +# The class is the field before the symbol name, not the second field: nm omits +# the address column for undefined symbols, so a positional $2 reads the name +# itself there. An absent symbol is an error, not a pass - main.rs defines it +# unconditionally on Linux, so nothing found means nm could not tell us and the +# guard did not run. +NM_OUT=$(nm "$BIN" 2>&1) || { echo "ERROR: nm could not read $BIN, so the #15 guard cannot run:" >&2 + printf '%s\n' "$NM_OUT" >&2; exit 1; } +SHIM_CLASS=$(printf '%s\n' "$NM_OUT" | awk '$NF == "__libc_single_threaded" { print $(NF-1); exit }') +echo "__libc_single_threaded section class: ${SHIM_CLASS:-}" +case "$SHIM_CLASS" in + B|D|b|d) ;; + "") echo "ERROR: __libc_single_threaded is absent from the symbol table, so the" >&2 + echo "issue #15 startup guard could not be checked. Do not ship this binary." >&2; exit 1 ;; + U) echo "ERROR: __libc_single_threaded is undefined - the shim did not link in, so" >&2 + echo "this binary requires glibc 2.32 and will not start on the supported floor." >&2; exit 1 ;; + *) echo "ERROR: shim is in section class '$SHIM_CLASS' (not writable) - this binary" >&2 + echo "will SIGSEGV at startup." >&2; exit 1 ;; +esac + +highest() { readelf -V "$BIN" 2>/dev/null | grep -o "$1[0-9.]*" | sed "s/$1//" | sort -V | tail -1; } +GOT_GLIBC=$(highest 'GLIBC_') +GOT_GLIBCXX=$(highest 'GLIBCXX_') +echo "glibc floor: ${GOT_GLIBC:-none} (max allowed $MAX_GLIBC)" +echo "GLIBCXX floor: ${GOT_GLIBCXX:-none} (max allowed $MAX_GLIBCXX)" + +newer_than() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ] && [ "$1" != "$2" ]; } +fail=0 +if [ -n "$GOT_GLIBC" ] && newer_than "$GOT_GLIBC" "$MAX_GLIBC"; then + echo "ERROR: glibc floor $GOT_GLIBC exceeds $MAX_GLIBC - this no longer matches" >&2 + echo "the x64 asset, and drops SLES 15 SP4 among others." >&2; fail=1 +fi +if [ -n "$GOT_GLIBCXX" ] && newer_than "$GOT_GLIBCXX" "$MAX_GLIBCXX"; then + echo "ERROR: GLIBCXX floor $GOT_GLIBCXX exceeds $MAX_GLIBCXX." >&2; fail=1 +fi +[ "$fail" -eq 0 ] || exit 1 + +# --info both proves the binary starts and is the only place the provenance +# build.rs baked in is readable from outside. +INFO=$("$BIN" --info) || { echo "ERROR: the binary does not run." >&2; exit 1; } +printf '%s\n' "$INFO" + +if [ "$CHECK_PROVENANCE" = "1" ]; then + GOT_GIT=$(printf '%s\n' "$INFO" | sed -n '1s/.*(\(.*\)).*/\1/p') + case "$GOT_GIT" in + "") + echo "ERROR: the binary reports no commit at all." >&2; exit 1 ;; + unknown*) + echo "ERROR: the binary is stamped '$GOT_GIT' - git told build.rs nothing usable" >&2 + echo "inside the container. If /src is a git worktree, its gitdir is not mounted." >&2 + exit 1 ;; + *-dirty) + echo "ERROR: the binary is stamped '$GOT_GIT' - it was built from a modified tree" >&2 + echo "and cannot be traced back to a released commit." >&2 + exit 1 ;; + esac + if [ "${#GOT_GIT}" -lt 7 ]; then + echo "ERROR: the binary's stamp '$GOT_GIT' is too short to name a commit." >&2 + exit 1 + fi + # By prefix: the container's git and the host's git abbreviate independently, + # so the same commit can legitimately come back at two different lengths. + same_commit=0 + case "$EXPECTED_GIT_SHORT" in "$GOT_GIT"*) same_commit=1 ;; esac + case "$GOT_GIT" in "$EXPECTED_GIT_SHORT"*) same_commit=1 ;; esac + if [ "$same_commit" -eq 0 ]; then + echo "ERROR: the binary claims commit $GOT_GIT, but the host is at" >&2 + echo "$EXPECTED_GIT_SHORT. The release assets would disagree on their source." >&2 + exit 1 + fi + echo "provenance: $GOT_GIT, matching the host checkout" +fi +echo "BUILD OK" +CONTAINER + +BUILT="$BUILD_DIR/release/codegraph-server" +[ -f "$BUILT" ] || { echo "ERROR: no binary at $BUILT" >&2; exit 1; } + +if [ "${1:-}" = "--no-stage" ] || [ "$CHECK_PROVENANCE" -eq 0 ]; then + echo + echo "Built (not staged): $BUILT" + if [ "$CHECK_PROVENANCE" -eq 0 ]; then + echo + echo "Not staged: CODEGRAPH_ALLOW_DIRTY=1 skipped the commit check, and an" + echo "unverified binary in $STAGE_DIR would be indistinguishable" + echo "from a release build. Commit the tree and re-run to stage." + fi + exit 0 +fi + +mkdir -p "$STAGE_DIR" +cp "$BUILT" "$STAGE_DIR/$ASSET" +chmod +x "$STAGE_DIR/$ASSET" +echo +echo "Staged: $STAGE_DIR/$ASSET" + +# Provenance is recorded where it is known. publish-release-assets.sh treats an +# unstamped binary in the staging directory as stale, because vscode/bin/ is not +# cleaned between releases, and refuses a set of assets whose commits disagree. +# The commit is passed rather than read back from the binary: an x86_64 host +# cannot execute what it just cross-built, and the container already proved this +# binary agrees with this checkout. +"$REPO_ROOT/scripts/stamp-binary.sh" "$ASSET" "$VERSION" "$EXPECTED_GIT_SHORT" diff --git a/scripts/package-npm.sh b/scripts/package-npm.sh index dafad3b..fa16300 100755 --- a/scripts/package-npm.sh +++ b/scripts/package-npm.sh @@ -102,16 +102,27 @@ fi # a rate limit part-way through leaves the release with some platforms attached # and others missing - and a one-platform probe would wave that through, giving # users on the missing platforms exactly the empty install this gate exists to -# prevent. The list mirrors BINARIES + WINDOWS_SIDECAR there, which is the same -# set bin/fetch-engine.js resolves against. +# prevent. ENGINE_VERSION=$(node -e "console.log(require('$PKG_DIR/bin/fetch-engine').ENGINE_VERSION)") -ENGINE_ASSETS=( - "codegraph-server-darwin-arm64" - "codegraph-server-darwin-x64" - "codegraph-server-linux-x64" - "codegraph-server-win32-x64.exe" - "onnxruntime.dll" -) +# Read from fetch-engine.js rather than repeating it here. A copy of this list +# drifts silently: it stays green while probing a set that no longer matches +# what the clients resolve, which is the exact failure this gate exists to +# catch. The Windows sidecar is appended because it is a required asset without +# being an engine, so it is not in PUBLISHED_BINARIES. +# A `while read` loop rather than `mapfile`: this script's shebang is +# /bin/bash, which on macOS is bash 3.2, and mapfile arrived in bash 4. +ENGINE_ASSETS=() +while IFS= read -r asset; do + [ -n "$asset" ] && ENGINE_ASSETS+=("$asset") +done < <(node -e " + const f = require('$PKG_DIR/bin/fetch-engine'); + for (const a of f.PUBLISHED_BINARIES) console.log(a); + console.log(f.WINDOWS_SIDECAR); +") +if [ "${#ENGINE_ASSETS[@]}" -lt 2 ]; then + echo "ERROR: could not read the published asset list from bin/fetch-engine.js" >&2 + exit 1 +fi RELEASE_BASE="https://github.com/codegraph-ai/CodeGraph/releases/download/v${ENGINE_VERSION}" echo "" diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index 7590ad2..b485ed4 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -5,15 +5,19 @@ # Publish the per-platform engine binaries as GitHub release assets. # # No channel bundles engines any more. Shipping all four platform binaries meant -# a 118 MB VSIX and a 498 MB npm package for the ~30 MB a given user can +# a 118 MB VSIX and a 498 MB npm package for the one binary a given user can # actually run, and the JetBrains Marketplace cannot ship per-platform artifacts # at all. The binaries are published here once, and each client fetches only # what it needs - which makes this release the single source of engines, so a # missing or mistagged one leaves every channel with no engine at all. # # This does not build anything. It uploads what ./scripts/package-*.sh already -# expect to find in vscode/bin/, so it slots in after the existing -# cross-platform build (see cross-platform-builds.md). +# expect to find in vscode/bin/, so it slots in after the existing cross-platform +# build. Each platform is built natively on its own host; those hosts are +# internal machines, so they are recorded in the maintainer's private build notes +# rather than here. The one exception is linux-arm64, which has no build host of +# its own: ./scripts/build-linux-arm64.sh builds it in a container and stages and +# stamps it into vscode/bin/ itself. # # Usage: # ./scripts/publish-release-assets.sh # stage + verify only @@ -39,12 +43,25 @@ if [ -z "$VERSION" ]; then fi TAG="v${VERSION}" -BINARIES=( - "codegraph-server-darwin-arm64" - "codegraph-server-darwin-x64" - "codegraph-server-linux-x64" - "codegraph-server-win32-x64.exe" -) +# Read the platform list from bin/fetch-engine.js instead of repeating it. That +# module is what every client resolves against, so a copy here can publish a set +# the clients never ask for, or omit one they do - and the failure only shows up +# as an empty install on the platform nobody tested. A `while read` loop rather +# than `mapfile`, which needs bash 4 and is absent from macOS's bash 3.2. +FETCH_ENGINE="$REPO_ROOT/mcp-package/bin/fetch-engine.js" +BINARIES=() +while IFS= read -r asset; do + [ -n "$asset" ] && BINARIES+=("$asset") +done < <(node -e " + for (const a of require('$FETCH_ENGINE').PUBLISHED_BINARIES) console.log(a); +" 2>/dev/null) + +if [ "${#BINARIES[@]}" -eq 0 ]; then + echo "ERROR: could not read PUBLISHED_BINARIES from $FETCH_ENGINE." >&2 + echo "That list is what the clients fetch by, so publishing without it would" >&2 + echo "guess at the platform set." >&2 + exit 1 +fi # The Windows engine loads this at runtime. Shipping the exe without it gives # users a download that succeeds and then fails at startup, which is a worse @@ -107,14 +124,42 @@ echo # # Provenance is recorded where it is knowable rather than guessed here: each # binary is stamped into MANIFEST by the build that produced it, on the host -# that could actually run `--version`. Scraping version strings out of a +# that could actually run `--info`. Scraping version strings out of a # cross-platform image was tried and is not reliable - the engine does not # store its version as a standalone string on every target, so good binaries # were reported as stale. MANIFEST="$VSCODE_BIN/BUILD-MANIFEST" +# The binary name is the last field, so a line whose second field is that name +# is one written before the manifest carried a commit at all. +manifest_line() { + [ -f "$MANIFEST" ] || return 0 + awk -v b="$1" '$NF == b { print; exit }' "$MANIFEST" +} + +manifest_version() { + local line ver + line="$(manifest_line "$1")" + if [ -n "$line" ]; then + read -r ver _ <<< "$line" + printf '%s\n' "$ver" + fi +} + +manifest_commit() { + local line ver commit + line="$(manifest_line "$1")" + if [ -n "$line" ]; then + read -r ver commit _ <<< "$line" + if [ "$commit" != "$1" ]; then + printf '%s\n' "$commit" + fi + fi +} + missing=0 stale=0 +COMMITS=() for bin in "${BINARIES[@]}" "$WINDOWS_SIDECAR"; do if [ ! -f "$VSCODE_BIN/$bin" ]; then printf ' ✗ %-36s MISSING\n' "$bin" @@ -130,12 +175,19 @@ for bin in "${BINARIES[@]}" "$WINDOWS_SIDECAR"; do continue fi - if [ -f "$MANIFEST" ] && grep -qxF "$VERSION $bin" "$MANIFEST"; then - printf ' ✓ %-36s %s (%s)\n' "$bin" "$size" "$VERSION" - else - recorded="$(grep -F " $bin" "$MANIFEST" 2>/dev/null | awk '{print $1}' | tr '\n' ' ' || true)" - printf ' ✗ %-36s %s NOT STAMPED %s\n' "$bin" "$size" "${recorded:+(manifest says: $recorded)}" + recorded_version="$(manifest_version "$bin")" + recorded_commit="$(manifest_commit "$bin")" + + if [ "$recorded_version" != "$VERSION" ]; then + printf ' ✗ %-36s %s NOT STAMPED %s\n' \ + "$bin" "$size" "${recorded_version:+(manifest says: $recorded_version)}" + stale=1 + elif [ -z "$recorded_commit" ]; then + printf ' ✗ %-36s %s NO COMMIT RECORDED\n' "$bin" "$size" stale=1 + else + printf ' ✓ %-36s %s (%s @ %s)\n' "$bin" "$size" "$VERSION" "$recorded_commit" + COMMITS+=("$recorded_commit") fi done @@ -146,8 +198,8 @@ ERROR: not every platform artifact is present in vscode/bin/. A partial release is worse than none: a client that resolves its own platform and finds nothing has no way to tell "not built yet" from "never supported". -Build the missing platforms first - see cross-platform-builds.md for the -per-platform hosts - then re-run. +Build the missing platforms first, each on its own build host - except +linux-arm64, which ./scripts/build-linux-arm64.sh builds here - then re-run. EOF exit 1 fi @@ -155,13 +207,17 @@ fi if [ "$stale" -ne 0 ]; then cat >&2 < + ./scripts/stamp-binary.sh [commit] + +A binary stamped with no commit was recorded before the manifest carried one; +re-stamp it, from the host that can run it or with the commit stated, so the +set can be checked for agreement. Publishing an unverified binary would produce a release whose checksums are perfectly valid for the wrong build - the hardest kind of mistake to notice. @@ -169,6 +225,47 @@ EOF exit 1 fi +# ------------------------------------------------------ one commit, all assets +# A shared version number is not a shared source. Each platform is built on its +# own host, so five binaries can all be stamped $VERSION and still come from +# five different trees - and then a bug reported against $VERSION on Linux and +# one reported against $VERSION on macOS describe different software under the +# same name. Compared by prefix: each host's git abbreviates commits to its own +# length, so the same commit legitimately appears as 7 and 12 hex digits. +ref_commit="" +for c in "${COMMITS[@]}"; do + if [ -z "$ref_commit" ] || [ "${#c}" -lt "${#ref_commit}" ]; then + ref_commit="$c" + fi +done + +commit_mismatch=0 +for c in "${COMMITS[@]}"; do + case "$c" in "$ref_commit"*) ;; *) commit_mismatch=1 ;; esac +done + +if [ "$commit_mismatch" -ne 0 ]; then + { + echo + echo "ERROR: the staged binaries were not all built from the same commit." + echo + for bin in "${BINARIES[@]}"; do + printf ' %-36s %s\n' "$bin" "$(manifest_commit "$bin")" + done + cat <&2 + exit 1 +fi + +echo +printf ' all %d engine binaries built from %s\n' "${#COMMITS[@]}" "$ref_commit" + # ---------------------------------------------------------------- checksums # An engine binary runs on the user's machine with their permissions, so the # client verifies what it downloaded. TLS alone does not cover a mirror, a diff --git a/scripts/stamp-binary.sh b/scripts/stamp-binary.sh index e51a89e..29f8fa2 100755 --- a/scripts/stamp-binary.sh +++ b/scripts/stamp-binary.sh @@ -2,7 +2,7 @@ # Copyright 2026 Andrey Vasilevsky # SPDX-License-Identifier: Apache-2.0 # -# Record which version a staged engine binary was built from. +# Record which version and which commit a staged engine binary was built from. # # vscode/bin/ is a staging directory that is not cleaned between releases, so a # binary left over from an earlier version is indistinguishable from a fresh @@ -10,22 +10,31 @@ # reliably across targets - the engine does not store it as a standalone string # everywhere, and good binaries get reported as stale. # -# So provenance is recorded at the point where it is actually known: whoever -# stages a binary states what produced it. publish-release-assets.sh refuses to -# publish anything not stamped for the version being released. +# The version alone is not provenance. Five binaries built on five different +# hosts can all be 0.20.1 and still come from five different trees, which is a +# release whose assets disagree with each other and with the tag it was cut +# from. So the commit is recorded beside the version, and +# publish-release-assets.sh refuses a set that does not agree on one. +# +# The commit is taken from the binary itself wherever the staging host can run +# it: the engine bakes it in at build time and prints it from `--info`, which +# makes it a measurement rather than a claim. A cross-built asset cannot be +# executed on the host that stages it, so there it has to be stated. # # Usage: -# ./scripts/stamp-binary.sh codegraph-server-linux-x64 0.20.0 +# ./scripts/stamp-binary.sh codegraph-server-darwin-arm64 0.20.1 +# ./scripts/stamp-binary.sh codegraph-server-linux-x64 0.20.1 a1b2c3d4e5f6 # set -euo pipefail -if [ $# -ne 2 ]; then - echo "usage: $(basename "$0") " >&2 +if [ $# -lt 2 ] || [ $# -gt 3 ]; then + echo "usage: $(basename "$0") [commit]" >&2 exit 2 fi BINARY="$1" VERSION="$2" +COMMIT="${3:-}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BIN_DIR="${CODEGRAPH_BIN_DIR:-$REPO_ROOT/vscode/bin}" @@ -36,14 +45,72 @@ if [ ! -f "$BIN_DIR/$BINARY" ]; then exit 1 fi +# Two abbreviations of the same commit, made by different git versions on +# different hosts, differ in length rather than content. +same_commit() { + case "$1" in "$2"*) return 0 ;; esac + case "$2" in "$1"*) return 0 ;; esac + return 1 +} + +# Failure here is the ordinary cross-built case, not an error: a linux binary +# staged on macOS cannot execute, which is exactly why [commit] exists. +SELF_REPORTED="" +if [ -x "$BIN_DIR/$BINARY" ]; then + info="$("$BIN_DIR/$BINARY" --info 2>/dev/null || true)" + SELF_REPORTED="$(printf '%s\n' "$info" | sed -n '1s/.*(\(.*\)).*/\1/p')" +fi + +if [ -n "$SELF_REPORTED" ] && [ -n "$COMMIT" ] && ! same_commit "$COMMIT" "$SELF_REPORTED"; then + echo "ERROR: $BINARY reports commit $SELF_REPORTED, but $COMMIT was given." >&2 + echo "The binary is the only witness of what actually built it, so the" >&2 + echo "argument cannot override it. Stage the binary you meant to stage." >&2 + exit 1 +fi + +# They agree by here, so keep whichever abbreviation names the commit more +# precisely. +if [ "${#SELF_REPORTED}" -gt "${#COMMIT}" ]; then + COMMIT="$SELF_REPORTED" +fi + +if [ -z "$COMMIT" ]; then + echo "ERROR: no commit recorded for $BINARY." >&2 + echo "This host cannot run it, so the commit it was built from has to be" >&2 + echo "stated. From the checkout it was built on:" >&2 + echo >&2 + echo " $(basename "$0") $BINARY $VERSION \$(git rev-parse --short=12 HEAD)" >&2 + exit 1 +fi + +case "$COMMIT" in + unknown*) + echo "ERROR: $BINARY reports commit '$COMMIT' - it was built somewhere git" >&2 + echo "could not be read, so nothing can say which source produced it." >&2 + exit 1 ;; + *-dirty) + echo "ERROR: $BINARY reports commit '$COMMIT' - it was built from a modified" >&2 + echo "tree and cannot be traced back to a released commit." >&2 + exit 1 ;; + *[!0-9a-f]*) + echo "ERROR: '$COMMIT' is not a commit hash." >&2 + exit 1 ;; +esac + +if [ "${#COMMIT}" -lt 7 ]; then + echo "ERROR: commit '$COMMIT' is too short to name one commit; use at least 7" >&2 + echo "hex digits, as \`git rev-parse --short\` produces." >&2 + exit 1 +fi + touch "$MANIFEST" # One line per binary: re-stamping replaces the previous entry rather than # appending, so the manifest can never claim two versions for one file. tmp="$(mktemp)" grep -vF " $BINARY" "$MANIFEST" > "$tmp" 2>/dev/null || true -printf '%s %s\n' "$VERSION" "$BINARY" >> "$tmp" -sort -k2 "$tmp" > "$MANIFEST" +printf '%s %s %s\n' "$VERSION" "$COMMIT" "$BINARY" >> "$tmp" +sort -k3 "$tmp" > "$MANIFEST" rm -f "$tmp" -echo "Stamped $BINARY as $VERSION" +echo "Stamped $BINARY as $VERSION ($COMMIT)" cat "$MANIFEST" diff --git a/vscode/.eslintrc.json b/vscode/.eslintrc.json new file mode 100644 index 0000000..fcadeb1 --- /dev/null +++ b/vscode/.eslintrc.json @@ -0,0 +1,68 @@ +// ESLint config for the VS Code extension, scoped to this package: the npm +// package next door is plain CommonJS with its own conventions, so `root` stops +// this from reaching it. +// +// The severities below are not a style opinion - each one is set to the level +// at which the rule catches a real defect in THIS codebase, so that +// `npm run lint` failing means something is actually wrong and is worth +// blocking on. Rules that only fire on deliberate, already-idiomatic code are +// turned off with the reason recorded rather than silenced case by case. +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + "env": { + "node": true, + "es2022": true + }, + "ignorePatterns": ["out", "dist", "node_modules", "media", "webview"], + "rules": { + // The `_`-prefix convention this source already follows: a parameter + // named `_token` is one the VS Code API hands us and we deliberately do + // not read, and renaming it would lose which API slot it fills. An + // unprefixed unused binding is still an error, because that is the case + // where something was meant to be used and was not. + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + // `vscode-languageclient` declares its request types as namespaces + // (`namespace Foo { export const type = new RequestType(...) }`) and + // src/views/graphPanel.ts follows that idiom for its own requests. The + // rule is correct in general and wrong for the one pattern our + // dependency prescribes. + "@typescript-eslint/no-namespace": "off", + // Warn, not error: `any` here is concentrated in the boundary code that + // talks to the untyped language-server protocol payloads. Each site is + // a real typing debt worth seeing, but none of them is a defect that + // should stop a build, and typing them properly is a change to the + // protocol surface rather than a lint fix. + "@typescript-eslint/no-explicit-any": "warn" + }, + "overrides": [ + { + // Test doubles stand in for the `vscode` API, whose shape they must + // match exactly - including the parameters a given test never + // reads and the wide types the real API declares. Enforcing either + // rule here would push the mocks away from the API they imitate. + "files": ["**/*.test.ts"], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-function-type": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { "args": "none", "varsIgnorePattern": "^_" } + ] + } + } + ] +} diff --git a/vscode/src/ai/contextProvider.test.ts b/vscode/src/ai/contextProvider.test.ts index f90353c..f70e1e0 100644 --- a/vscode/src/ai/contextProvider.test.ts +++ b/vscode/src/ai/contextProvider.test.ts @@ -3,7 +3,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { - setMock, reset, clearAllMocks, } from '@vsforge/shim'; diff --git a/vscode/src/ai/toolManager.test.ts b/vscode/src/ai/toolManager.test.ts index 3f61271..239bd89 100644 --- a/vscode/src/ai/toolManager.test.ts +++ b/vscode/src/ai/toolManager.test.ts @@ -1,7 +1,7 @@ // Copyright 2025-2026 Andrey Vasilevsky // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { reset, clearAllMocks, diff --git a/vscode/src/ai/toolManager.ts b/vscode/src/ai/toolManager.ts index 1dd72b7..68a9b05 100644 --- a/vscode/src/ai/toolManager.ts +++ b/vscode/src/ai/toolManager.ts @@ -688,7 +688,7 @@ export class CodeGraphToolManager { try { references = await Promise.race([refPromise, timeoutPromise]); - } catch (timeoutErr) { + } catch { // Reference search timed out, continue without references console.log('[CodeGraph] Reference search timed out, returning partial results'); } diff --git a/vscode/src/commands/index.test.ts b/vscode/src/commands/index.test.ts index a7e3e80..40828eb 100644 --- a/vscode/src/commands/index.test.ts +++ b/vscode/src/commands/index.test.ts @@ -9,7 +9,6 @@ import { clearAllMocks, } from '@vsforge/shim'; import { - mockActiveTextEditor, mockConfiguration, mockQuickPickSelection, } from '@vsforge/test'; diff --git a/vscode/src/commands/index.ts b/vscode/src/commands/index.ts index 40e9a94..165caf8 100644 --- a/vscode/src/commands/index.ts +++ b/vscode/src/commands/index.ts @@ -94,7 +94,7 @@ export function registerCommands( } }; context.subscriptions.push(vscode.commands.registerCommand(commandId, wrapped)); - } catch (error) { + } catch { console.warn(`Command ${commandId} already registered, skipping`); } }; @@ -240,7 +240,7 @@ export function registerCommands( 'CodeGraph: Use @codegraph in the chat to get code context. ' + 'Try: @codegraph explain this function' ); - } catch (error) { + } catch { // Chat panel not available - show helpful message vscode.window.showInformationMessage( 'CodeGraph provides AI context via:\n' + diff --git a/vscode/src/engineDownload.ts b/vscode/src/engineDownload.ts index b1cc1f2..954ef19 100644 --- a/vscode/src/engineDownload.ts +++ b/vscode/src/engineDownload.ts @@ -22,7 +22,7 @@ import * as vscode from 'vscode'; // The canonical implementation lives with the npm package; esbuild follows the // path and inlines it into out/extension.js, so both JavaScript channels ship // the same code rather than two implementations that drift. -// eslint-disable-next-line @typescript-eslint/no-var-requires +// eslint-disable-next-line @typescript-eslint/no-require-imports const fetchEngine = require('../../mcp-package/bin/fetch-engine.js'); /** Where downloaded engines live, shared with the CLI and the JetBrains plugin. */ @@ -107,7 +107,7 @@ export async function downloadEngine( * built against a newer engine keeps talking to the old one. * * Offered rather than done, for the same reason `downloadEngine` is: this is - * ~30 MB of native binary that will run with the user's permissions. It is also + * ~120 MB of native binary that will run with the user's permissions. It is also * deliberately not awaited by the caller - the engine on disk still works, so * blocking activation behind a transfer would cost every surface the extension * provides for an update that is not urgent. diff --git a/vscode/src/extension.test.ts b/vscode/src/extension.test.ts index 7f939db..9347811 100644 --- a/vscode/src/extension.test.ts +++ b/vscode/src/extension.test.ts @@ -7,7 +7,6 @@ import { reset, getCalls, clearAllMocks, - vscode, } from '@vsforge/shim'; import { mockConfiguration } from '@vsforge/test'; diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 7364700..4828d73 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -41,7 +41,6 @@ let lastExitSignal: string | null = null; const MAX_RAPID_CRASHES = 3; const RAPID_CRASH_WINDOW_MS = 60_000; let rapidCrashTimestamps: number[] = []; -let crashLoopDetected = false; // Set true right before an intentional server stop (crash-loop give-up, // deactivate) so the onDidChangeState→Stopped that follows isn't logged // as a crash. Consume-once: the handler resets it after skipping. @@ -330,7 +329,7 @@ export async function activate(context: vscode.ExtensionContext): Promise // // Not awaited: the engine on disk still runs, and holding activation - and // with it the language client, the tree views and the lenses - behind a - // 30 MB transfer on a slow network is a far worse trade than one release of + // ~120 MB transfer on a slow network is a far worse trade than one release of // drift. The lifecycle callbacks read `client` lazily for the same reason: // by the time the user answers the prompt it has been created and started. if (serverInfo.path === managedEnginePath()) { @@ -468,7 +467,7 @@ export async function activate(context: vscode.ExtensionContext): Promise else if (lower.includes('spawn')) errorHint = 'spawn_error'; else { // Last resort: first 80 chars, strip anything that looks like a path - errorHint = errStr.substring(0, 80).replace(/[\/\\][^\s:]+/g, ''); + errorHint = errStr.substring(0, 80).replace(/[/\\][^\s:]+/g, ''); } reporter.activationServerStartResult({ @@ -518,7 +517,6 @@ export async function activate(context: vscode.ExtensionContext): Promise ); if (rapidCrashTimestamps.length >= MAX_RAPID_CRASHES) { - crashLoopDetected = true; expectedShutdown = true; client.stop().catch(() => {}); vscode.window @@ -531,7 +529,6 @@ export async function activate(context: vscode.ExtensionContext): Promise ) .then((choice) => { if (choice === 'Retry') { - crashLoopDetected = false; rapidCrashTimestamps = []; serverRestartCount = 0; client.start().catch(() => {}); diff --git a/vscode/src/telemetry/reporter.ts b/vscode/src/telemetry/reporter.ts index 5fffe9e..8853f66 100644 --- a/vscode/src/telemetry/reporter.ts +++ b/vscode/src/telemetry/reporter.ts @@ -29,7 +29,6 @@ import { PostHog } from 'posthog-node'; import { type ActivationOutcome, - type CommandId, categorizeError, type ErrorCategory, type FirstIndexCta, @@ -51,7 +50,6 @@ import { normalizeAntivirusKind, type ServerRestartReason, SETTINGS_SNAPSHOT_KEYS, - type ToolName, type TreeView, } from './allowlists'; import {