diff --git a/packages/opencode/src/altimate/native/dispatcher.ts b/packages/opencode/src/altimate/native/dispatcher.ts index 944647630..f564c265f 100644 --- a/packages/opencode/src/altimate/native/dispatcher.ts +++ b/packages/opencode/src/altimate/native/dispatcher.ts @@ -35,12 +35,35 @@ export async function call( method: M, params: (typeof BridgeMethods)[M] extends { params: infer P } ? P : never, ): Promise<(typeof BridgeMethods)[M] extends { result: infer R } ? R : never> { - // Lazy registration: load all handler modules on first call + // altimate_change start — graceful degradation when native binding unavailable + // Lazy registration: load all handler modules on first call. + // If the native binding fails to load (e.g. GLIBC mismatch on older Linux), + // log a warning and continue — tools that don't need native will still work. if (_ensureRegistered) { const fn = _ensureRegistered _ensureRegistered = null - await fn() + try { + await fn() + } catch (e: any) { + const msg = String(e?.message || e) + if (msg.includes("native binding") || msg.includes("GLIBC") || msg.includes("ERR_DLOPEN_FAILED")) { + console.error( + `\n⚠ Native module (@altimateai/altimate-core) failed to load.\n` + + ` SQL analysis tools (validate, lint, transpile, lineage, etc.) will be unavailable.\n` + + ` Other features (warehouse connections, schema indexing, dbt) still work.\n` + + ` Cause: ${msg.slice(0, 200)}\n` + + `\n` + + ` To fix this, upgrade to a system with GLIBC >= 2.35:\n` + + ` • Ubuntu 22.04+ / Debian 12+ / Fedora 36+ / RHEL 9+\n` + + ` • Or run inside a Docker container with a newer base image\n` + + ` • Check your version: ldd --version\n`, + ) + } else { + throw e + } + } } + // altimate_change end const native = nativeHandlers.get(method as string) diff --git a/packages/opencode/src/altimate/native/index.ts b/packages/opencode/src/altimate/native/index.ts index c95a49bf7..cf59c21b7 100644 --- a/packages/opencode/src/altimate/native/index.ts +++ b/packages/opencode/src/altimate/native/index.ts @@ -5,12 +5,48 @@ export * as Dispatcher from "./dispatcher" // Lazy handler registration — modules are loaded on first Dispatcher.call(), // not at import time. This prevents @altimateai/altimate-core napi binary // from loading in test environments where it's not needed. +// altimate_change start — graceful degradation when native binding unavailable +function isNativeBindingError(e: any): boolean { + const msg = String(e?.message || e) + return msg.includes("native binding") || msg.includes("GLIBC") || msg.includes("ERR_DLOPEN_FAILED") +} + setRegistrationHook(async () => { - await import("./altimate-core") - await import("./sql/register") + // altimate-core napi-rs binding may fail on systems with older GLIBC. + // Load it separately so other handlers still register. + try { + await import("./altimate-core") + } catch (e: any) { + if (isNativeBindingError(e)) { + // Swallowed here — dispatcher.ts logs the user-facing warning + } else { + throw e + } + } + + // These modules transitively import @altimateai/altimate-core (via pii-detector, + // lineage, test-local, or directly). Wrap each so a native binding failure in one + // doesn't prevent the others from registering. + const coreDependent = [ + () => import("./sql/register"), + () => import("./schema/register"), + () => import("./dbt/register"), + () => import("./local/register"), + ] + for (const load of coreDependent) { + try { + await load() + } catch (e: any) { + if (isNativeBindingError(e)) { + // Core-dependent module failed — skip silently, main warning already logged + } else { + throw e + } + } + } + + // These modules don't depend on altimate-core and should always load. await import("./connections/register") - await import("./schema/register") await import("./finops/register") - await import("./dbt/register") - await import("./local/register") }) +// altimate_change end diff --git a/packages/opencode/src/altimate/tools/sql-classify.ts b/packages/opencode/src/altimate/tools/sql-classify.ts index 9127e86a1..2ab708e74 100644 --- a/packages/opencode/src/altimate/tools/sql-classify.ts +++ b/packages/opencode/src/altimate/tools/sql-classify.ts @@ -1,10 +1,24 @@ -// altimate_change - SQL query classifier for write detection +// altimate_change start — SQL query classifier for write detection // // Uses altimate-core's AST-based getStatementTypes() for accurate classification. // Handles CTEs, string literals, procedural blocks, all dialects correctly. +// Lazy-loads altimate-core on first use to avoid crashing at import time +// when the native binary is unavailable (e.g. GLIBC mismatch). // eslint-disable-next-line @typescript-eslint/no-explicit-any -const core: any = require("@altimateai/altimate-core") +let _core: any = null + +function getCore(): any { + if (!_core) { + try { + _core = require("@altimateai/altimate-core") + } catch { + // Native binding unavailable — return null so callers can degrade gracefully + return null + } + } + return _core +} // Categories from altimate-core that indicate write operations const WRITE_CATEGORIES = new Set(["dml", "ddl", "dcl", "tcl"]) @@ -17,8 +31,11 @@ const HARD_DENY_TYPES = new Set(["DROP DATABASE", "DROP SCHEMA", "TRUNCATE", "TR /** * Classify a SQL string as "read" or "write" using AST parsing. * If ANY statement is a write, returns "write". + * Falls back to "write" (safe default) if native binding is unavailable. */ export function classify(sql: string): "read" | "write" { + const core = getCore() + if (!core) return "write" // fail-safe: treat as write when native unavailable const result = core.getStatementTypes(sql) if (!result?.categories?.length) return "read" // Treat unknown categories (not in WRITE or READ sets) as write to fail safe @@ -36,8 +53,11 @@ export function classifyMulti(sql: string): "read" | "write" { /** * Single-pass: classify and check for hard-denied statement types. * Returns both the overall query type and whether a hard-deny pattern was found. + * Falls back to write + not-blocked when native binding is unavailable. */ export function classifyAndCheck(sql: string): { queryType: "read" | "write"; blocked: boolean } { + const core = getCore() + if (!core) return { queryType: "write", blocked: false } const result = core.getStatementTypes(sql) if (!result?.statements?.length) return { queryType: "read", blocked: false } @@ -50,3 +70,4 @@ export function classifyAndCheck(sql: string): { queryType: "read" | "write"; bl const queryType = categories.some((c: string) => !READ_CATEGORIES.has(c)) ? "write" : "read" return { queryType: queryType as "read" | "write", blocked } } +// altimate_change end diff --git a/packages/opencode/test/branding/build-integrity.test.ts b/packages/opencode/test/branding/build-integrity.test.ts index e4977c21c..36c12d009 100644 --- a/packages/opencode/test/branding/build-integrity.test.ts +++ b/packages/opencode/test/branding/build-integrity.test.ts @@ -338,7 +338,50 @@ describe("Bundle Completeness", () => { }) // --------------------------------------------------------------------------- -// 8. Install Script +// 8. Graceful Native Binding Degradation +// --------------------------------------------------------------------------- + +// altimate_change start — CI guard: core-dependent modules must be try/catch wrapped +describe("Graceful Native Binding Degradation", () => { + const nativeIndex = readFileSync( + join(repoRoot, "packages/opencode/src/altimate/native/index.ts"), + "utf-8", + ) + + test("native/index.ts has isNativeBindingError helper", () => { + expect(nativeIndex).toContain("isNativeBindingError") + }) + + test("altimate-core import is wrapped in try/catch", () => { + // The altimate-core import must be inside a try block + expect(nativeIndex).toMatch(/try\s*\{[^}]*import\(["']\.\/altimate-core["']\)/) + }) + + const coreDepModules = ["sql/register", "schema/register", "dbt/register", "local/register"] + + for (const mod of coreDepModules) { + test(`${mod} import is wrapped in try/catch (not bare await)`, () => { + // Each core-dependent module should appear inside the coreDependent array or + // a try/catch, NOT as a bare `await import("./module")`. + const barePattern = new RegExp(`^\\s*await import\\(["']\\.\\/${mod}["']\\)`, "m") + expect(nativeIndex).not.toMatch(barePattern) + // Must still be referenced somewhere in the file + expect(nativeIndex).toContain(mod) + }) + } + + const safeMods = ["connections/register", "finops/register"] + + for (const mod of safeMods) { + test(`${mod} is imported (does not depend on altimate-core)`, () => { + expect(nativeIndex).toContain(mod) + }) + } +}) +// altimate_change end + +// --------------------------------------------------------------------------- +// 9. Install Script // --------------------------------------------------------------------------- describe("Install Script", () => {