From de29268b8e110164aff299efe814ac72f5793ae8 Mon Sep 17 00:00:00 2001 From: Frankie-Xu <92643488+Frankie-Xu@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:04:58 +0800 Subject: [PATCH] fix(build): fall back to WASM grammars when native tree-sitter is unavailable (#119) linux/arm64 has no tree-sitter@0.21.1 prebuild, so require() crashed every CLI command. Catch the native load, extract depth languages via the existing WASM path, and warn about reduced binding fidelity. Darwin/linux-x64 native behavior is unchanged. Co-authored-by: Cursor --- CHANGELOG.md | 15 ++++ src/cli.ts | 1 + src/graph/build.ts | 22 +++++- src/graph/check.ts | 14 +++- src/graph/extract.ts | 118 ++++++++++++++++++++++++------- src/graph/generic.ts | 25 ++++++- src/graph/queries/go.scm | 25 +++++++ src/graph/queries/python.scm | 14 ++++ src/graph/queries/r.scm | 17 +++++ src/graph/queries/tsx.scm | 38 ++++++++++ src/graph/queries/typescript.scm | 39 ++++++++++ src/graph/workspace-cli.ts | 1 + test/native-fallback.test.ts | 111 +++++++++++++++++++++++++++++ 13 files changed, 409 insertions(+), 31 deletions(-) create mode 100644 src/graph/queries/go.scm create mode 100644 src/graph/queries/python.scm create mode 100644 src/graph/queries/r.scm create mode 100644 src/graph/queries/tsx.scm create mode 100644 src/graph/queries/typescript.scm create mode 100644 test/native-fallback.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bd845ee87..f8af09b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +### Fixed + +- **`graft init` / `graft build` no longer crash on linux/arm64** when + `tree-sitter@0.21.1` has no native prebuild (`No native build was found for + platform=linux arch=arm64`). Native parsers still load when they exist + (darwin, linux-x64). If `require("tree-sitter")` fails, depth languages + (TypeScript/JavaScript, Python, Go, Java, Kotlin, Swift, PHP, R) fall back to the + existing WASM breadth grammars. Fidelity is reduced: no bindings / + receiver-type resolution; call edges come from `tags.scm` (symbols only if + a query is missing). The build prints + `native parser unavailable — falling back to WASM (reduced binding fidelity)` + and `graft check` / the statusline do not treat it as an error. (#119) + ## 0.18.0 ### Added diff --git a/src/cli.ts b/src/cli.ts index 66be92fdf..d7a8082b9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -547,6 +547,7 @@ program { repo: buildRoot }, ); for (const e of g.errors) console.error(`✗ ${e}`); + for (const w of g.warnings) console.error(`⚠ ${w}`); const rel = relative(process.cwd(), g.contextDir) || "graft"; if (process.env.GRAFT_NO_GITIGNORE) { diff --git a/src/graph/build.ts b/src/graph/build.ts index b3094051d..e44f8aee7 100644 --- a/src/graph/build.ts +++ b/src/graph/build.ts @@ -17,7 +17,7 @@ import { readFileSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; import { walkDir } from "../ingest/fs.js"; import { contextDirFor, ensureGitignored, ensureSearchable } from "../context/node-file.js"; -import { extractFile, languageLabelOf, languageOf, type RawEdge } from "./extract.js"; +import { extractFile, languageLabelOf, languageOf, nativeFallbackWarning, wasmFallbackLangs, type RawEdge } from "./extract.js"; import { extractGeneric, genericLangOf, warmGenericGrammars } from "./generic.js"; import { containerLangOf, extractContainer, warmContainerGrammars } from "./container.js"; import { contentHash } from "../util/id.js"; @@ -121,6 +121,8 @@ export interface GraphBuildResult { languages: string[]; meaning: EnrichStats; errors: string[]; + /** Non-fatal notices (native parser WASM fallback, etc.). Not check-failures. */ + warnings: string[]; } /** Every Go module in the repo: each `go.mod`'s declared `module` path and the repo @@ -192,9 +194,22 @@ export async function buildGraph( // Breadth tier: WASM grammars load asynchronously, so warm the ones this repo // needs ONCE here (buildGraph is async) before the synchronous parse loop below - // can call extractGeneric. Depth-tier (native) grammars need no warmup. + // can call extractGeneric. Depth-tier (native) grammars need no warmup — unless + // they failed to load, in which case the same WASM path covers them (#119). + const fallback = new Set(wasmFallbackLangs()); await warmGenericGrammars( - new Set(files.map((f) => genericLangOf(f.abs)?.name).filter((n): n is string => !!n)), + new Set( + files.flatMap((f) => { + const names: string[] = []; + const generic = genericLangOf(f.abs)?.name; + if (generic) names.push(generic); + const lang = languageOf(f.abs); + if (lang && fallback.has(lang)) names.push(lang); + const container = containerLangOf(f.abs); + if (container && fallback.has(container.inner)) names.push(container.inner); + return names; + }), + ), ); // Container tier (.vue and friends) loads its wrapper grammars the same way, // for the same reason: extractContainer runs inside the sync loop below. @@ -406,5 +421,6 @@ export async function buildGraph( languages: [...langs].sort(), meaning, errors, + warnings: [nativeFallbackWarning()].filter((w): w is string => w !== null), }; } diff --git a/src/graph/check.ts b/src/graph/check.ts index d36f2bd27..39c19d3da 100644 --- a/src/graph/check.ts +++ b/src/graph/check.ts @@ -20,7 +20,7 @@ import { resolve } from "node:path"; import { relPosix } from "../util/paths.js"; import { contextDirFor } from "../context/node-file.js"; -import { extractFile, languageOf } from "./extract.js"; +import { extractFile, languageOf, wasmFallbackLangs } from "./extract.js"; import { extractGeneric, genericLangOf, warmGenericGrammars } from "./generic.js"; import { containerLangOf, extractContainer, warmContainerGrammars } from "./container.js"; import { listSourceFiles } from "./build.js"; @@ -87,8 +87,18 @@ export async function checkGraph( const fpOnlyDirs = readFingerprint(outDir)?.onlyDirs; const onlyDirs = fpOnlyDirs && fpOnlyDirs.length > 0 ? new Set(fpOnlyDirs) : undefined; const sourceFiles = listSourceFiles(root, outDir, undefined, onlyDirs); + const fallback = new Set(wasmFallbackLangs()); await warmGenericGrammars( - new Set(sourceFiles.map((f) => genericLangOf(f)?.name).filter((n): n is string => !!n)), + new Set( + sourceFiles.flatMap((f) => { + const names: string[] = []; + const generic = genericLangOf(f)?.name; + if (generic) names.push(generic); + const lang = languageOf(f); + if (lang && fallback.has(lang)) names.push(lang); + return names; + }), + ), ); // Container-tier grammars need the same warmup as the generic ones, for the same // reason: extraction below is synchronous. Missing this is what made `graft diff --git a/src/graph/extract.ts b/src/graph/extract.ts index d0f9dff19..41e834917 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -5,21 +5,22 @@ * definition (file, class, function, method, interface, type, enum, and TS * arrow-function consts) plus unresolved edge intents. Edge *targets* are * resolved against the whole-repo node index later, in build.ts. + * + * Native tree-sitter addons load via `require` inside a try (issue #119). When + * they are missing — linux/arm64 has no prebuild in tree-sitter@0.21.1 — + * `extractFile` delegates to the WASM breadth extractor instead of throwing + * at module load. Darwin / linux-x64 keep the hand-written walk unchanged. */ -import Parser from "tree-sitter"; -import TypeScript from "tree-sitter-typescript"; -import Python from "tree-sitter-python"; -import Go from "tree-sitter-go"; -import R from "tree-sitter-r"; -import Java from "tree-sitter-java"; -import Kotlin from "tree-sitter-kotlin"; -import Swift from "tree-sitter-swift"; -import PHP from "tree-sitter-php"; +import type Parser from "tree-sitter"; +import { createRequire } from "node:module"; import { basename } from "node:path"; import { contentHash } from "../util/id.js"; import { collectBindings, goReceiverVarOf, resolveRecvType, type FileBindings } from "./bindings.js"; +import { extractGeneric } from "./generic.js"; import type { Kind, NodeV1, Relation } from "./types.js"; +const require = createRequire(import.meta.url); + export type Language = "typescript" | "tsx" | "python" | "go" | "java" | "kotlin" | "swift" | "php" | "r"; /** @@ -83,6 +84,85 @@ export function languageLabelOf(path: string): string | null { return entryFor(path)?.label ?? null; } +export const NATIVE_FALLBACK_WARNING = + "native parser unavailable — falling back to WASM (reduced binding fidelity)"; + +const DEPTH_LANGS: Language[] = ["typescript", "tsx", "python", "go", "java", "kotlin", "swift", "php", "r"]; + +function asParserCtor(mod: unknown): (new () => Parser) | null { + if (typeof mod === "function") return mod as new () => Parser; + if (mod && typeof mod === "object" && "default" in mod) { + const d = (mod as { default: unknown }).default; + if (typeof d === "function") return d as new () => Parser; + } + return null; +} + +export interface NativeLoadResult { + Parser: (new () => Parser) | null; + grammars: Partial>; + error: string | null; +} + +/** Load native grammars via `require`. Injectable so tests can simulate a missing + * prebuild (`No native build was found for platform=linux arch=arm64`) without + * unloading this process's real addons. Native available → same grammars as + * the old static imports; a throw or a missing Parser ctor → empty grammars. */ +export function loadNativeGrammars(req: (id: string) => unknown = require): NativeLoadResult { + try { + const ParserCtor = asParserCtor(req("tree-sitter")); + if (!ParserCtor) { + return { Parser: null, grammars: {}, error: "tree-sitter did not export a Parser constructor" }; + } + const grammars: Partial> = {}; + const one = (id: string): unknown | null => { + try { + return req(id); + } catch { + return null; + } + }; + const ts = one("tree-sitter-typescript") as { typescript?: unknown; tsx?: unknown } | null; + if (ts?.typescript) grammars.typescript = ts.typescript; + if (ts?.tsx) grammars.tsx = ts.tsx; + const py = one("tree-sitter-python"); + if (py) grammars.python = py; + const go = one("tree-sitter-go"); + if (go) grammars.go = go; + const r = one("tree-sitter-r"); + if (r) grammars.r = r; + const java = one("tree-sitter-java"); + if (java) grammars.java = java; + const kotlin = one("tree-sitter-kotlin"); + if (kotlin) grammars.kotlin = kotlin; + const swift = one("tree-sitter-swift"); + if (swift) grammars.swift = swift; + const php = one("tree-sitter-php") as { php?: unknown } | null; + if (php?.php) grammars.php = php.php; + return { Parser: ParserCtor, grammars, error: null }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { Parser: null, grammars: {}, error: message }; + } +} + +const native = loadNativeGrammars(); +const parser: Parser | null = native.Parser ? new native.Parser() : null; +const GRAMMARS: Partial> = native.grammars; + +/** Depth-tier languages whose native grammar did not load — warm these as WASM. */ +export function wasmFallbackLangs(): Language[] { + if (!parser) return [...DEPTH_LANGS]; + return DEPTH_LANGS.filter((l) => GRAMMARS[l] === undefined); +} + +/** One-line banner when the native runtime itself failed to load (issue #119). + * Missing *individual* grammars still WASM-fall back, but that is not the + * linux/arm64 crash and must not warn on darwin where tree-sitter loaded. */ +export function nativeFallbackWarning(): string | null { + return parser ? null : NATIVE_FALLBACK_WARNING; +} + /** * An edge whose target isn't resolved yet. build.ts turns these into EdgeV1 by * matching `name`/`specifier` against the repo-wide node index. @@ -310,19 +390,6 @@ const FUNCTION_VALUE_TYPES = new Set([ const EMPTY_SET: ReadonlySet = new Set(); -const parser = new Parser(); -const GRAMMARS: Record = { - typescript: TypeScript.typescript, - tsx: TypeScript.tsx, - python: Python, - go: Go, - r: R, - java: Java, - kotlin: Kotlin, - swift: Swift, - php: PHP.php, -}; - export interface WalkCtx { rel: string; source: string; @@ -381,10 +448,13 @@ interface DefDescriptor { * the source in <32 KB slices. Code-unit indexing matches `String.slice`. */ const PARSE_CHUNK = 16384; function parseSource(source: string): Parser.SyntaxNode { - return parser.parse((index: number) => source.slice(index, index + PARSE_CHUNK)).rootNode; + return parser!.parse((index: number) => source.slice(index, index + PARSE_CHUNK)).rootNode; } -export function extractFile(rel: string, source: string, lang: Language): ExtractResult { +export function extractFile(rel: string, source: string, lang: Language, opts?: { wasm?: boolean }): ExtractResult { + if (opts?.wasm || !parser || GRAMMARS[lang] === undefined) { + return extractGeneric(rel, source, lang); + } parser.setLanguage(GRAMMARS[lang] as never); const root = parseSource(source); const bindings = collectBindings(root, lang); diff --git a/src/graph/generic.ts b/src/graph/generic.ts index b599deb77..2aa49ecd2 100644 --- a/src/graph/generic.ts +++ b/src/graph/generic.ts @@ -60,6 +60,27 @@ export const GENERIC_LANGS: readonly GenericLang[] = [ { name: "lua", exts: [".lua"], wasm: "lua" }, ]; +/** + * WASM rows for depth-tier languages. Used only when the native grammar failed + * to load (`extract.ts` wasmFallbackLangs). `genericLangOf` does NOT consult + * this table — depth extensions stay owned by `languageOf` so the native path + * is unchanged when addons exist. java is already in GENERIC_LANGS. + */ +export const DEPTH_WASM_LANGS: readonly GenericLang[] = [ + { name: "typescript", exts: [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"], wasm: "typescript" }, + { name: "tsx", exts: [".tsx", ".jsx"], wasm: "tsx" }, + { name: "python", exts: [".py", ".pyi"], wasm: "python" }, + { name: "go", exts: [".go"], wasm: "go" }, + { name: "kotlin", exts: [".kt", ".kts"], wasm: "kotlin" }, + { name: "swift", exts: [".swift"], wasm: "swift" }, + { name: "php", exts: [".php"], wasm: "php" }, + { name: "r", exts: [".r"], wasm: "r" }, +]; + +function wasmRow(name: string): GenericLang | undefined { + return GENERIC_LANGS.find((l) => l.name === name) ?? DEPTH_WASM_LANGS.find((l) => l.name === name); +} + const byExt = new Map(); for (const l of GENERIC_LANGS) for (const e of l.exts) byExt.set(e, l); @@ -122,7 +143,7 @@ function loadQuery(name: string): string | null { * grammars are silently skipped (their files then extract as file-only). */ export async function warmGenericGrammars(langNames: Iterable): Promise { const want = new Set(langNames); - const need = [...want].filter((n) => !loaded.has(n) && GENERIC_LANGS.some((l) => l.name === n)); + const need = [...want].filter((n) => !loaded.has(n) && wasmRow(n)); if (need.length === 0) return; if (!tsMod) { tsMod = await import("web-tree-sitter"); @@ -131,7 +152,7 @@ export async function warmGenericGrammars(langNames: Iterable): Promise< await initPromise; const { Language, Query } = tsMod; for (const name of need) { - const row = GENERIC_LANGS.find((l) => l.name === name)!; + const row = wasmRow(name)!; const bytes = requireWasm(row.wasm); if (!bytes) continue; try { diff --git a/src/graph/queries/go.scm b/src/graph/queries/go.scm new file mode 100644 index 000000000..2dc1f7808 --- /dev/null +++ b/src/graph/queries/go.scm @@ -0,0 +1,25 @@ +; Depth-tier WASM fallback (#119). + +(function_declaration + name: (identifier) @name) @definition.function + +(method_declaration + name: (field_identifier) @name) @definition.method + +(type_spec + name: (type_identifier) @name + type: (struct_type)) @definition.struct + +(type_spec + name: (type_identifier) @name + type: (interface_type)) @definition.interface + +(type_spec + name: (type_identifier) @name) @definition.type + +(call_expression + function: [ + (identifier) @name + (selector_expression + field: (field_identifier) @name) + ]) @reference.call diff --git a/src/graph/queries/python.scm b/src/graph/queries/python.scm new file mode 100644 index 000000000..d3a5e85e8 --- /dev/null +++ b/src/graph/queries/python.scm @@ -0,0 +1,14 @@ +; Depth-tier WASM fallback (#119). + +(function_definition + name: (identifier) @name) @definition.function + +(class_definition + name: (identifier) @name) @definition.class + +(call + function: [ + (identifier) @name + (attribute + attribute: (identifier) @name) + ]) @reference.call diff --git a/src/graph/queries/r.scm b/src/graph/queries/r.scm new file mode 100644 index 000000000..b1bf00d1c --- /dev/null +++ b/src/graph/queries/r.scm @@ -0,0 +1,17 @@ +; Depth-tier WASM fallback (#119). + +(binary_operator + lhs: (identifier) @name + operator: "<-" + rhs: (function_definition) +) @definition.function + +(binary_operator + lhs: (identifier) @name + operator: "=" + rhs: (function_definition) +) @definition.function + +(call + function: (identifier) @name +) @reference.call diff --git a/src/graph/queries/tsx.scm b/src/graph/queries/tsx.scm new file mode 100644 index 000000000..f05b17d49 --- /dev/null +++ b/src/graph/queries/tsx.scm @@ -0,0 +1,38 @@ +; Depth-tier WASM fallback (#119). TSX grammar is TypeScript + JSX; reuse the +; TypeScript definition/call captures. Walker covers anything this misses. + +(function_declaration + name: (identifier) @name) @definition.function + +(generator_function_declaration + name: (identifier) @name) @definition.function + +(class_declaration + name: [(type_identifier) (identifier)] @name) @definition.class + +(abstract_class_declaration + name: [(type_identifier) (identifier)] @name) @definition.class + +(interface_declaration + name: (type_identifier) @name) @definition.interface + +(type_alias_declaration + name: (type_identifier) @name) @definition.type + +(enum_declaration + name: [(identifier) (type_identifier)] @name) @definition.enum + +(method_definition + name: (property_identifier) @name) @definition.method + +(lexical_declaration + (variable_declarator + name: (identifier) @name + value: [(arrow_function) (function_expression) (generator_function)])) @definition.function + +(call_expression + function: [ + (identifier) @name + (member_expression + property: (property_identifier) @name) + ]) @reference.call diff --git a/src/graph/queries/typescript.scm b/src/graph/queries/typescript.scm new file mode 100644 index 000000000..4542c6297 --- /dev/null +++ b/src/graph/queries/typescript.scm @@ -0,0 +1,39 @@ +; Depth-tier WASM fallback (#119). Same tags convention as the breadth queries. +; If this fails to compile against the wasm grammar, generic.ts drops it and +; the walker still emits symbols (no call edges). + +(function_declaration + name: (identifier) @name) @definition.function + +(generator_function_declaration + name: (identifier) @name) @definition.function + +(class_declaration + name: [(type_identifier) (identifier)] @name) @definition.class + +(abstract_class_declaration + name: [(type_identifier) (identifier)] @name) @definition.class + +(interface_declaration + name: (type_identifier) @name) @definition.interface + +(type_alias_declaration + name: (type_identifier) @name) @definition.type + +(enum_declaration + name: [(identifier) (type_identifier)] @name) @definition.enum + +(method_definition + name: (property_identifier) @name) @definition.method + +(lexical_declaration + (variable_declarator + name: (identifier) @name + value: [(arrow_function) (function_expression) (generator_function)])) @definition.function + +(call_expression + function: [ + (identifier) @name + (member_expression + property: (property_identifier) @name) + ]) @reference.call diff --git a/src/graph/workspace-cli.ts b/src/graph/workspace-cli.ts index f6dc57e87..587347a27 100644 --- a/src/graph/workspace-cli.ts +++ b/src/graph/workspace-cli.ts @@ -67,6 +67,7 @@ export async function runWorkspaceBuild(root: string, opts: WorkspaceBuildOption const g = await engine.graph(childDir, { llm: opts.deep, concurrency: opts.concurrency }); console.log(`✓ ${childName}/: ${g.nodes} nodes, ${g.edges} edges, ${g.cards} cards [${g.languages.join(", ")}]`); for (const e of g.errors) console.error(`✗ ${childName}/: ${e}`); + for (const w of g.warnings) console.error(`⚠ ${childName}/: ${w}`); }; const { children } = await splitWorkspace( diff --git a/test/native-fallback.test.ts b/test/native-fallback.test.ts new file mode 100644 index 000000000..11d7f355f --- /dev/null +++ b/test/native-fallback.test.ts @@ -0,0 +1,111 @@ +/** + * #119 — when native tree-sitter addons are missing (linux/arm64 has no + * prebuild for tree-sitter@0.21.1), depth-tier files still extract via WASM. + * + * Native-available (this darwin CI) is the control: extractFile without + * `{ wasm: true }` must keep origin "ast". The fallback is exercised by + * injecting a throwing require and by extractFile(..., { wasm: true }). + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + extractFile, + loadNativeGrammars, + NATIVE_FALLBACK_WARNING, + nativeFallbackWarning, +} from "../src/graph/extract.js"; +import { genericLangOf, isWarm, warmGenericGrammars } from "../src/graph/generic.js"; +import { buildGraph } from "../src/graph/build.js"; +import { checkGraph } from "../src/graph/check.js"; + +const TS = `export function hello() { + return world(); +} +function world() { + return 1; +} +`; + +const PY = `def hello(): + return world() +def world(): + return 1 +`; + +test("loadNativeGrammars: a missing linux/arm64 prebuild yields empty grammars", () => { + const boom = (id: string): unknown => { + throw new Error( + `No native build was found for platform=linux arch=arm64 runtime=node abi=127 uv=1 libc=glibc (${id})`, + ); + }; + const loaded = loadNativeGrammars(boom); + assert.equal(loaded.Parser, null); + assert.equal(Object.keys(loaded.grammars).length, 0); + assert.match(loaded.error ?? "", /linux/); + assert.match(loaded.error ?? "", /arm64/); + assert.equal( + NATIVE_FALLBACK_WARNING, + "native parser unavailable — falling back to WASM (reduced binding fidelity)", + ); +}); + +test("genericLangOf still does not claim depth extensions", () => { + assert.equal(genericLangOf("src/app.ts"), null); + assert.equal(genericLangOf("src/main.py"), null); + assert.equal(genericLangOf("cmd/main.go"), null); +}); + +test("extractFile wasm fallback still emits nodes for a TS function", async () => { + await warmGenericGrammars(["typescript"]); + assert.ok(isWarm("typescript"), "typescript wasm grammar should warm"); + const { nodes, rawEdges } = extractFile("a.ts", TS, "typescript", { wasm: true }); + const named = nodes.filter((n) => n.kind !== "file"); + assert.ok( + named.some((n) => n.name === "hello"), + `expected hello, got ${named.map((n) => n.name).join(",")}`, + ); + assert.ok( + named.every((n) => n.origin === "generic"), + "WASM path is the breadth extractor (no bindings/recvType)", + ); + const calls = rawEdges.filter((e) => e.relation === "calls"); + assert.ok( + calls.some((e) => e.source.endsWith("#hello") && e.name === "world"), + `expected hello→world call edge, got ${JSON.stringify(calls)}`, + ); +}); + +test("extractFile wasm fallback still emits nodes for a Python function", async () => { + await warmGenericGrammars(["python"]); + assert.ok(isWarm("python"), "python wasm grammar should warm"); + const { nodes } = extractFile("a.py", PY, "python", { wasm: true }); + const named = nodes.filter((n) => n.kind !== "file"); + assert.ok( + named.some((n) => n.name === "hello"), + `expected hello, got ${named.map((n) => n.name).join(",")}`, + ); + assert.ok(named.every((n) => n.origin === "generic")); +}); + +test("extractFile default path on this platform stays native (origin ast)", () => { + const { nodes, rawEdges } = extractFile("a.ts", TS, "typescript"); + const hello = nodes.find((n) => n.name === "hello"); + assert.ok(hello); + assert.equal(hello?.origin, "ast"); + assert.ok(rawEdges.some((e) => e.relation === "calls" && e.name === "world")); + assert.equal(nativeFallbackWarning(), null, "darwin/linux-x64 native path must not warn"); +}); + +test("native build + check stay in sync and emit no fallback warning", async () => { + const d = mkdtempSync(join(tmpdir(), "graft-119-")); + writeFileSync(join(d, "a.ts"), TS); + const g = await buildGraph(d, { reuse: false }); + assert.equal(g.errors.length, 0); + assert.deepEqual(g.warnings, []); + const check = await checkGraph(d); + assert.equal(check.missing, false); + assert.equal(check.ok, true, `check drift: added=${check.added} removed=${check.removed} changed=${check.changed}`); +});