From 10a6e769cc1945c4c61730c7058bc89e197631e3 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 9 Aug 2026 12:33:17 -0700 Subject: [PATCH 1/7] feat(release): publish a linux-arm64 engine ARM Linux users had no engine at all: platformBinaryName() returned null for linux-arm64, so npm's postinstall printed "unsupported platform" and exited, and the JetBrains resolver refused too. Issue #15 came from one of the few people who worked around that by building from source. Adds the asset to both client mappings and to the release, and pins how it is built: scripts/build-linux-arm64.sh builds in a container because there is no arm64 Linux build machine, and the compatibility floor is easy to get wrong. It does not only build - it refuses to produce a binary that would narrow support: - __libc_single_threaded must land in writable memory. An immutable definition is read-only and, on aarch64, also exported, so glibc's startup write to it faults before main(). That was issue #15, and this is its regression guard. - the glibc floor must stay <= 2.30 and GLIBCXX <= 3.4.29, the floors measured from the shipped x64 asset. Ubuntu 20.04 + gcc-11 is the only combination measured to give both a working link and those floors. The alternatives failed and the reasons are recorded in the script: Debian 11 (gcc 10) cannot resolve ONNX Runtime's __throw_bad_array_new_length or __aarch64_cas8_sync; SLES 15 SP4's own gcc11 ships no outline atomics; Ubuntu 22.04 links but floors at glibc 2.34, which would have given ARM a narrower support range than x64. Verified by running the built binary: it works on SLES 15 SP4, Ubuntu 22.04+, Debian 12+, RHEL 9+ and Amazon Linux 2023, and not on Ubuntu 20.04, Debian 11, RHEL 8 or Amazon Linux 2 - identical to the x64 asset. Note the binding constraint is GLIBCXX_3.4.29, not glibc, which is why it does not run on its own build host. Also collapses three hand-kept copies of the platform list into one. PUBLISHED_BINARIES in bin/fetch-engine.js is now the source of truth, read by publish-release-assets.sh and package-npm.sh, so a sixth platform cannot be published without every client resolving it - the drift that made this change touch five files. A `while read` loop rather than mapfile: these scripts run under macOS's bash 3.2, where mapfile does not exist and would have silently yielded an empty list. The two tests asserting linux-arm64 was unsupported are inverted rather than deleted; both languages now assert it resolves to its own asset and never to the x64 one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017rVbt7rENTwXkdHt3Bpgb5 --- .../server/CodeGraphServerResolver.kt | 6 +- .../server/CodeGraphServerResolverTest.kt | 17 +- mcp-package/bin/fetch-engine.js | 34 +++- mcp-package/test/fetch-engine.test.js | 44 ++++- scripts/build-linux-arm64.sh | 157 ++++++++++++++++++ scripts/package-npm.sh | 26 ++- scripts/publish-release-assets.sh | 25 ++- 7 files changed, 277 insertions(+), 32 deletions(-) create mode 100755 scripts/build-linux-arm64.sh 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..1ea954c 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt @@ -104,7 +104,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/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/mcp-package/bin/fetch-engine.js b/mcp-package/bin/fetch-engine.js index 76b887d..21a961c 100644 --- a/mcp-package/bin/fetch-engine.js +++ b/mcp-package/bin/fetch-engine.js @@ -113,6 +113,25 @@ 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. publish-release-assets.sh uploads exactly these and + * package-npm.sh probes exactly these, both by reading this export rather than + * repeating it - three hand-kept copies of the same list is how a release ends + * up publishing a platform no client asks for, or asking for one it never + * published. `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 +141,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 +434,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..b5dc4a9 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, @@ -412,8 +413,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 +432,40 @@ 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( + platformBinaryName("linux", "arm64") === "codegraph-server-linux-arm64", + "linux-arm64 does" + ); + check( + requiredAssets("linux", "arm64").length === 1 && + requiredAssets("linux", "arm64")[0] === "codegraph-server-linux-arm64", + "linux-arm64 needs the engine and no sidecar" + ); + // Windows on ARM keeps resolving to the x64 asset: the OS emulates it, so + // refusing would leave those users with no engine at all. + check( + platformBinaryName("win32", "arm64") === "codegraph-server-win32-x64.exe", + "win32-arm64 still resolves to the emulated x64 build" + ); + // 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..2ef12c9 --- /dev/null +++ b/scripts/build-linux-arm64.sh @@ -0,0 +1,157 @@ +#!/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 Ubuntu 20.04, Debian 11, RHEL 8 or Amazon Linux 2 - not +# even on its own build host. That 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 + +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; } + +echo "=== CodeGraph linux-arm64 engine ===" +echo " version: $VERSION" +echo " target: $BUILD_DIR" +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" \ + 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" + +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). +SHIM_CLASS=$(nm "$BIN" 2>/dev/null | awk '/__libc_single_threaded/ {print $2; exit}') +echo "__libc_single_threaded section class: ${SHIM_CLASS:-}" +case "$SHIM_CLASS" in + B|D|b|d|"") ;; + *) echo "ERROR: shim is '$SHIM_CLASS' (read-only) - this binary 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 + +"$BIN" --version || { echo "ERROR: the binary does not run." >&2; exit 1; } +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" ]; then + echo + echo "Built (not staged): $BUILT" + 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. +"$REPO_ROOT/scripts/stamp-binary.sh" "$ASSET" "$VERSION" diff --git a/scripts/package-npm.sh b/scripts/package-npm.sh index dafad3b..245d5d0 100755 --- a/scripts/package-npm.sh +++ b/scripts/package-npm.sh @@ -105,13 +105,25 @@ fi # prevent. The list mirrors BINARIES + WINDOWS_SIDECAR there, which is the same # set bin/fetch-engine.js resolves against. 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..0f1fd40 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -39,12 +39,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 From 27752aa4fa4c6292595f06e933f63434100da338 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 9 Aug 2026 12:50:50 -0700 Subject: [PATCH 2/7] docs: state the platform support matrix, including both Linux floors The project published no platform requirements anywhere users could read them, while the internal build notes claimed a Linux range that was wrong. A user on RHEL 8 hitting `GLIBCXX_3.4.29 not found` had nothing to consult. Two floors matter and only one of them is obvious. glibc 2.30 is not the binding constraint: the engine embeds ONNX Runtime, built with GCC 11, so it also needs libstdc++ >= GLIBCXX_3.4.29. Checking only glibc is what produced the earlier claim that Debian 11 and Ubuntu 20.04 were supported; both ship 3.4.28 and have never been able to start the engine. The runs / does-not-run table is measured by executing the 0.20.1 binaries on each distribution, not inferred from symbol versions, and is identical for linux-x64 and linux-arm64. Also documents linux-arm64 as supported, and gives the GLIBCXX error the remedy that does not require a distro upgrade. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017rVbt7rENTwXkdHt3Bpgb5 --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) 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 From 6771c2f53dab4a65440a9ce9c794c58a1816a147 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 9 Aug 2026 13:03:30 -0700 Subject: [PATCH 3/7] no-mistakes(review): gate arm64 build provenance, harden shim check, fix stale docs --- .../server/CodeGraphServerResolver.kt | 16 ++- .../jetbrains/server/EngineDownloaderTest.kt | 9 +- mcp-package/bin/fetch-engine.js | 21 ++-- mcp-package/test/fetch-engine.test.js | 10 -- scripts/build-linux-arm64.sh | 114 ++++++++++++++++-- 5 files changed, 135 insertions(+), 35 deletions(-) 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 1ea954c..a584946 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt @@ -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() 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..a19e836 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()) diff --git a/mcp-package/bin/fetch-engine.js b/mcp-package/bin/fetch-engine.js index 21a961c..d8ba0f1 100644 --- a/mcp-package/bin/fetch-engine.js +++ b/mcp-package/bin/fetch-engine.js @@ -116,13 +116,20 @@ const VERSION_MARKER = ".engine-version"; /** * Every engine binary a release publishes. * - * This is the single list. publish-release-assets.sh uploads exactly these and - * package-npm.sh probes exactly these, both by reading this export rather than - * repeating it - three hand-kept copies of the same list is how a release ends - * up publishing a platform no client asks for, or asking for one it never - * published. `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. + * 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", diff --git a/mcp-package/test/fetch-engine.test.js b/mcp-package/test/fetch-engine.test.js index b5dc4a9..aa32115 100644 --- a/mcp-package/test/fetch-engine.test.js +++ b/mcp-package/test/fetch-engine.test.js @@ -432,21 +432,11 @@ async function run() { "darwin-arm64 does" ); check(platformBinaryName("linux", "x64") === "codegraph-server-linux-x64", "linux-x64 does"); - check( - platformBinaryName("linux", "arm64") === "codegraph-server-linux-arm64", - "linux-arm64 does" - ); check( requiredAssets("linux", "arm64").length === 1 && requiredAssets("linux", "arm64")[0] === "codegraph-server-linux-arm64", "linux-arm64 needs the engine and no sidecar" ); - // Windows on ARM keeps resolving to the x64 asset: the OS emulates it, so - // refusing would leave those users with no engine at all. - check( - platformBinaryName("win32", "arm64") === "codegraph-server-win32-x64.exe", - "win32-arm64 still resolves to the emulated x64 build" - ); // 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"); diff --git a/scripts/build-linux-arm64.sh b/scripts/build-linux-arm64.sh index 2ef12c9..f54c575 100755 --- a/scripts/build-linux-arm64.sh +++ b/scripts/build-linux-arm64.sh @@ -17,11 +17,14 @@ # 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 Ubuntu 20.04, Debian 11, RHEL 8 or Amazon Linux 2 - not -# even on its own build host. That 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. +# 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 @@ -58,9 +61,42 @@ 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 + EXPECTED_GIT_SHORT="$(git -C "$REPO_ROOT" rev-parse --short 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" @@ -72,6 +108,10 @@ docker run --rm -i --platform linux/arm64 \ -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 @@ -96,6 +136,24 @@ if ! command -v cargo >/dev/null 2>&1; then 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 @@ -108,11 +166,24 @@ 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). -SHIM_CLASS=$(nm "$BIN" 2>/dev/null | awk '/__libc_single_threaded/ {print $2; exit}') +# +# 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: shim is '$SHIM_CLASS' (read-only) - this binary will SIGSEGV at startup." >&2; exit 1 ;; + 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; } @@ -132,7 +203,32 @@ if [ -n "$GOT_GLIBCXX" ] && newer_than "$GOT_GLIBCXX" "$MAX_GLIBCXX"; then fi [ "$fail" -eq 0 ] || exit 1 -"$BIN" --version || { echo "ERROR: the binary does not run." >&2; 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" != "$EXPECTED_GIT_SHORT" ]; 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 From ef37289fb385dc7e97e3cdb1663463e076d20100 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 9 Aug 2026 13:14:32 -0700 Subject: [PATCH 4/7] no-mistakes(review): record source commit per asset and gate release on agreement --- scripts/build-linux-arm64.sh | 35 +++++++++-- scripts/publish-release-assets.sh | 96 ++++++++++++++++++++++++++++--- scripts/stamp-binary.sh | 87 ++++++++++++++++++++++++---- 3 files changed, 195 insertions(+), 23 deletions(-) diff --git a/scripts/build-linux-arm64.sh b/scripts/build-linux-arm64.sh index f54c575..a66e395 100755 --- a/scripts/build-linux-arm64.sh +++ b/scripts/build-linux-arm64.sh @@ -43,6 +43,11 @@ # 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 @@ -74,7 +79,9 @@ CHECK_PROVENANCE=1 if [ "${CODEGRAPH_ALLOW_DIRTY:-0}" = "1" ]; then CHECK_PROVENANCE=0 else - EXPECTED_GIT_SHORT="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true)" + # 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 @@ -222,7 +229,16 @@ if [ "$CHECK_PROVENANCE" = "1" ]; then echo "and cannot be traced back to a released commit." >&2 exit 1 ;; esac - if [ "$GOT_GIT" != "$EXPECTED_GIT_SHORT" ]; then + 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 @@ -235,9 +251,15 @@ CONTAINER BUILT="$BUILD_DIR/release/codegraph-server" [ -f "$BUILT" ] || { echo "ERROR: no binary at $BUILT" >&2; exit 1; } -if [ "${1:-}" = "--no-stage" ]; then +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 @@ -249,5 +271,8 @@ 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. -"$REPO_ROOT/scripts/stamp-binary.sh" "$ASSET" "$VERSION" +# 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/publish-release-assets.sh b/scripts/publish-release-assets.sh index 0f1fd40..9f641bd 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -120,14 +120,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" @@ -143,12 +171,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 @@ -168,13 +203,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. @@ -182,6 +221,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" From 1a6be12ce1bbcc20dcb78564984462b1deea5f3d Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 9 Aug 2026 13:38:02 -0700 Subject: [PATCH 5/7] no-mistakes(document): correct stale engine platform counts in client docs --- jetbrains/README.md | 4 +-- .../server/CodeGraphServerResolver.kt | 14 ++++---- .../jetbrains/server/EngineDownloader.kt | 8 ++--- .../jetbrains/server/EngineDownloaderTest.kt | 15 +++++++++ mcp-package/test/fetch-engine.test.js | 33 +++++++++++++++++++ scripts/package-npm.sh | 3 +- scripts/publish-release-assets.sh | 4 ++- 7 files changed, 65 insertions(+), 16 deletions(-) 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/server/CodeGraphServerResolver.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/CodeGraphServerResolver.kt index a584946..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) 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..c0bf1d4 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt @@ -18,10 +18,10 @@ 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 + * 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. 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. * * Downloads are verified against the checksum published beside each asset. An 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 a19e836..5aae20c 100644 --- a/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt +++ b/jetbrains/src/test/kotlin/ai/codegraph/jetbrains/server/EngineDownloaderTest.kt @@ -84,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/test/fetch-engine.test.js b/mcp-package/test/fetch-engine.test.js index aa32115..f1cfe91 100644 --- a/mcp-package/test/fetch-engine.test.js +++ b/mcp-package/test/fetch-engine.test.js @@ -110,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(); diff --git a/scripts/package-npm.sh b/scripts/package-npm.sh index 245d5d0..fa16300 100755 --- a/scripts/package-npm.sh +++ b/scripts/package-npm.sh @@ -102,8 +102,7 @@ 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)") # 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 diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index 9f641bd..ad79d81 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -13,7 +13,9 @@ # # 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). +# cross-platform build (see cross-platform-builds.md). 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 From 6b85db59195eb98156af4d3cff2af44d1278b5bf Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 9 Aug 2026 13:42:51 -0700 Subject: [PATCH 6/7] no-mistakes(document): replace dangling build-notes pointers, correct engine download sizes --- .../indexing/IndexingStartupActivity.kt | 2 +- .../jetbrains/server/EngineDownloader.kt | 8 ++++---- scripts/publish-release-assets.sh | 20 ++++++++++--------- vscode/src/engineDownload.ts | 2 +- vscode/src/extension.ts | 2 +- 5 files changed, 18 insertions(+), 16 deletions(-) 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/EngineDownloader.kt b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt index c0bf1d4..007bf5e 100644 --- a/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt +++ b/jetbrains/src/main/kotlin/ai/codegraph/jetbrains/server/EngineDownloader.kt @@ -19,10 +19,10 @@ import java.util.Locale * * The plugin does not bundle engines: the JetBrains Marketplace serves one * 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. 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. + * 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/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index ad79d81..b485ed4 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -5,17 +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). 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. +# 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 @@ -196,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 @@ -254,8 +256,8 @@ if [ "$commit_mismatch" -ne 0 ]; then Everything in one release has to come from one tree, or the tag names a build that never existed as a whole. Rebuild the assets that disagree from the commit -being released - see cross-platform-builds.md for the per-platform hosts - and -re-stamp them with ./scripts/stamp-binary.sh. +being released, each on its own build host, and re-stamp them with +./scripts/stamp-binary.sh. EOF } >&2 exit 1 diff --git a/vscode/src/engineDownload.ts b/vscode/src/engineDownload.ts index b1cc1f2..6469d2b 100644 --- a/vscode/src/engineDownload.ts +++ b/vscode/src/engineDownload.ts @@ -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.ts b/vscode/src/extension.ts index 7364700..274adae 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -330,7 +330,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()) { From 2fddd73751cdc240d9c6ebb0d6f41ae63bc0d14e Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sun, 9 Aug 2026 13:49:03 -0700 Subject: [PATCH 7/7] no-mistakes(lint): restore vscode eslint config, clear resulting lint errors --- vscode/.eslintrc.json | 68 +++++++++++++++++++++++++++ vscode/src/ai/contextProvider.test.ts | 1 - vscode/src/ai/toolManager.test.ts | 2 +- vscode/src/ai/toolManager.ts | 2 +- vscode/src/commands/index.test.ts | 1 - vscode/src/commands/index.ts | 4 +- vscode/src/engineDownload.ts | 2 +- vscode/src/extension.test.ts | 1 - vscode/src/extension.ts | 5 +- vscode/src/telemetry/reporter.ts | 2 - 10 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 vscode/.eslintrc.json 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 6469d2b..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. */ 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 274adae..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. @@ -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 {