diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6736ae9c6..90b711f6f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -828,7 +828,7 @@ jobs: fi - name: Create GitHub Release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v2 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v2 with: tag_name: ${{ steps.vtag-gate.outputs.vtag }} name: >- diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 81e8b92ec..f55fae7cd 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -46,13 +46,14 @@ "_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11)." }, "rust": { - "fingerprint": "f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846", + "fingerprint": "655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db", "scaling_budget": 1.5, "_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.", "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", - "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f." + "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.", + "_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5." }, "php": { "fingerprint": "4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd", diff --git a/gitnexus/src/core/ingestion/languages/rust/range-binding.ts b/gitnexus/src/core/ingestion/languages/rust/range-binding.ts index 4c7224333..593716cf9 100644 --- a/gitnexus/src/core/ingestion/languages/rust/range-binding.ts +++ b/gitnexus/src/core/ingestion/languages/rust/range-binding.ts @@ -5,6 +5,7 @@ import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; import { logger } from '../../../logger.js'; +import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js'; /** * Populate type bindings for patterns and iterators that the tree-sitter @@ -16,9 +17,54 @@ import { logger } from '../../../logger.js'; * Runs in Phase 2 (after propagateImportedReturnTypes) so all cross-file * type bindings are available for lookup. */ +type RustTree = ReturnType['parse']>; + +/** + * Hold parsed trees for reuse across both prepass loops only when the whole + * Rust source fits this budget. Trees are much larger than their source, so a + * modest source cap keeps peak held-tree memory bounded; larger repos fall + * back to re-parsing per loop (unchanged RSS). + */ +const TREE_REUSE_SOURCE_BUDGET_BYTES = 16 * 1024 * 1024; + +/** + * Parse `filePath`'s source once, honoring the caller's `treeCache` and, when + * provided, an in-function `store` so the two prepass loops share a single + * parse instead of re-parsing every file. Returns null when the source is + * missing or parsing times out. + */ +function getOrParseTree( + parser: ReturnType, + filePath: string, + ctx: { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; + }, + store: Map | undefined, +): RustTree | null { + const cached = (ctx.treeCache?.get(filePath) ?? store?.get(filePath)) as RustTree | undefined; + if (cached !== undefined) return cached; + const sourceText = ctx.fileContents.get(filePath); + if (sourceText === undefined) return null; + let tree: RustTree; + try { + tree = parseSourceSafe(parser, sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + } catch (err) { + if (err instanceof ParseTimeoutError) { + logger.warn({ file: filePath }, 'rust range-binding: parse timed out, skipping file'); + return null; + } + throw err; + } + store?.set(filePath, tree); + return tree; +} + export function populateRustRangeBindings( parsedFiles: readonly ParsedFile[], - _indexes: ScopeResolutionIndexes, + indexes: ScopeResolutionIndexes, ctx: { readonly fileContents: ReadonlyMap; readonly treeCache?: { get(filePath: string): unknown }; @@ -26,45 +72,45 @@ export function populateRustRangeBindings( ): void { const parser = getRustParser(); const allReturnTypes = new Map(); + const ambiguousReturnTypes = new Set(); const allFieldTypes = new Map>(); + const ambiguousFieldTypes = new Set(); + // Per-defining-file, un-collapsed, FULL-generic return/field types. When a + // bare name is ambiguous (#2514) but the call site's `use` import pins a + // single definition, we resolve that definition's file here and read its + // untruncated type so a generic `Vec` element type survives (#2514 + // follow-up: import-disambiguated duplicates resolve like the compiler). + const returnTypeByFile = new Map>(); + const fieldTypeByFile = new Map>>(); + // Parse each file once and reuse across both loops when the workspace fits + // the byte budget; otherwise re-parse per loop to bound RSS (see helper). + let totalSourceBytes = 0; + for (const parsed of parsedFiles) { + totalSourceBytes += ctx.fileContents.get(parsed.filePath)?.length ?? 0; + } + const treeStore: Map | undefined = + totalSourceBytes <= TREE_REUSE_SOURCE_BUDGET_BYTES ? new Map() : undefined; for (const parsed of parsedFiles) { - const sourceText = ctx.fileContents.get(parsed.filePath); - if (sourceText === undefined) continue; - - const cachedTree = ctx.treeCache?.get(parsed.filePath) as - | ReturnType - | undefined; - let tree: ReturnType; - if (cachedTree !== undefined) { - tree = cachedTree; - } else { - try { - tree = parseSourceSafe(parser, sourceText, undefined, { - bufferSize: getTreeSitterBufferSize(sourceText), - }); - } catch (err) { - if (err instanceof ParseTimeoutError) { - logger.warn( - { file: parsed.filePath }, - 'rust range-binding: parse timed out, skipping file', - ); - continue; - } - throw err; - } - } + const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore); + if (tree === null) continue; for (const fn of tree.rootNode.descendantsOfType('function_item')) { const nameNode = fn.childForFieldName('name'); const retType = fn.childForFieldName('return_type'); if (nameNode !== null && retType !== null) { const name = nameNode.text; + // Ambiguity is a latch, not a toggle: once a name has two or more + // workspace definitions it stays ambiguous for the rest of the + // prepass, regardless of duplicate count or file order (#2514). if (allReturnTypes.has(name)) { allReturnTypes.delete(name); - } else { + ambiguousReturnTypes.add(name); + } else if (!ambiguousReturnTypes.has(name)) { allReturnTypes.set(name, retType.text); } + // Full-generic record per defining file for import-disambiguated lookup. + recordByFile(returnTypeByFile, parsed.filePath, name, retType.text); } } @@ -82,11 +128,16 @@ export function populateRustRangeBindings( } if (fields.size > 0) { const name = nameNode.text; + // Same ambiguity latch as return types (#2514): a third same-named + // struct must not restore a resolvable global field map. if (allFieldTypes.has(name)) { allFieldTypes.delete(name); - } else { + ambiguousFieldTypes.add(name); + } else if (!ambiguousFieldTypes.has(name)) { allFieldTypes.set(name, fields); } + // Full-generic record per defining file for import-disambiguated lookup. + recordByFile(fieldTypeByFile, parsed.filePath, name, fields); } } @@ -99,39 +150,32 @@ export function populateRustRangeBindings( } for (const parsed of parsedFiles) { - const sourceText = ctx.fileContents.get(parsed.filePath); - if (sourceText === undefined) continue; - - const cachedTree = ctx.treeCache?.get(parsed.filePath) as - | ReturnType - | undefined; - let tree: ReturnType; - if (cachedTree !== undefined) { - tree = cachedTree; - } else { - try { - tree = parseSourceSafe(parser, sourceText, undefined, { - bufferSize: getTreeSitterBufferSize(sourceText), - }); - } catch (err) { - if (err instanceof ParseTimeoutError) { - logger.warn( - { file: parsed.filePath }, - 'rust range-binding: parse timed out, skipping file', - ); - continue; - } - throw err; - } - } + const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore); + if (tree === null) continue; const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s])); const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); if (moduleScope === undefined) continue; - processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes); + processForLoops( + tree.rootNode, + parsed, + scopeMap, + moduleScope, + allReturnTypes, + indexes, + returnTypeByFile, + ); processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope); - processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes); + processStructDestructuring( + tree.rootNode, + parsed, + scopeMap, + moduleScope, + allFieldTypes, + indexes, + fieldTypeByFile, + ); processPendingAssignments( tree.rootNode, parsed, @@ -196,12 +240,88 @@ function normalizeFieldType(text: string): string { return t.trim(); } +/** Get-or-create the inner map for `file` and record `name -> value`. */ +function recordByFile( + byFile: Map>, + file: string, + name: string, + value: V, +): void { + let inner = byFile.get(file); + if (inner === undefined) { + inner = new Map(); + byFile.set(file, inner); + } + inner.set(name, value); +} + +/** Final segment of a dot-joined qualified name (`a.make` -> `make`), or the + * bare name when the def carries no qualifier. */ +function simpleName(qualifiedName: string | undefined, bareName: string): string { + if (qualifiedName === undefined) return bareName; + const dot = qualifiedName.lastIndexOf('.'); + return dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1); +} + +/** Distinct `(file, name)` definitions, in first-seen order. */ +function uniqueDefs( + defs: readonly { file: string; name: string }[], +): { file: string; name: string }[] { + const seen = new Set(); + const out: { file: string; name: string }[] = []; + for (const d of defs) { + const key = `${d.file} ${d.name}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(d); + } + return out; +} + +/** + * Resolve `name` at `moduleScope` to the value recorded in `byFile` for the one + * definition visible here, or null when zero or several are visible (which + * keeps the #2514 ambiguity latch). Mirrors Rust name resolution: explicit + * `use`/re-export imports and local defs shadow `use x::*` globs, so a glob is + * consulted only when no explicit binding names `name`, and even then only when + * exactly one glob-target file actually defines it. + */ +function resolveImportedDef( + name: string, + moduleScope: Scope, + indexes: ScopeResolutionIndexes, + byFile: ReadonlyMap>, +): V | null { + const explicit = uniqueDefs( + lookupBindingsAt(moduleScope.id, name, indexes) + .filter((r) => r.origin === 'import' || r.origin === 'reexport' || r.origin === 'local') + .map((r) => ({ file: r.def.filePath, name: simpleName(r.def.qualifiedName, name) })), + ); + const defs = + explicit.length > 0 + ? explicit + : uniqueDefs( + (indexes.imports.get(moduleScope.id) ?? []) + .filter( + (e) => + e.kind === 'wildcard-expanded' && + e.targetFile !== null && + byFile.get(e.targetFile)?.has(name) === true, + ) + .map((e) => ({ file: e.targetFile as string, name })), + ); + if (defs.length !== 1) return null; + return byFile.get(defs[0].file)?.get(defs[0].name) ?? null; +} + function processForLoops( root: SyntaxNode, parsed: ParsedFile, scopeMap: ReadonlyMap, moduleScope: Scope, allReturnTypes: ReadonlyMap, + indexes: ScopeResolutionIndexes, + returnTypeByFile: ReadonlyMap>, ): void { for (const forNode of root.descendantsOfType('for_expression')) { const patternNode = forNode.childForFieldName('pattern'); @@ -217,6 +337,8 @@ function processForLoops( scopeMap, moduleScope, allReturnTypes, + indexes, + returnTypeByFile, ); if (elementType === null) continue; @@ -331,7 +453,9 @@ function processStructDestructuring( parsed: ParsedFile, scopeMap: ReadonlyMap, moduleScope: Scope, - allFieldTypes?: ReadonlyMap>, + allFieldTypes: ReadonlyMap>, + indexes: ScopeResolutionIndexes, + fieldTypeByFile: ReadonlyMap>>, ): void { for (const letNode of root.descendantsOfType('let_declaration')) { const patternNode = letNode.childForFieldName('pattern'); @@ -356,7 +480,13 @@ function processStructDestructuring( let fieldType = lookupFieldType(typeName, fieldName, parsed, scopeMap, moduleScope); if (fieldType === null) { - fieldType = allFieldTypes?.get(typeName)?.get(fieldName) ?? null; + fieldType = allFieldTypes.get(typeName)?.get(fieldName) ?? null; + } + if (fieldType === null) { + // Import-disambiguated duplicate struct (#2514 follow-up): the global + // field map is ambiguous, but a `use` import pins one definition. + const fields = resolveImportedDef(typeName, moduleScope, indexes, fieldTypeByFile); + fieldType = fields?.get(fieldName) ?? null; } if (fieldType !== null) { injectTypeBinding(targetScope, fieldName, fieldType); @@ -481,7 +611,9 @@ function resolveIterableElementType( parsed: ParsedFile, scopeMap: ReadonlyMap, moduleScope: Scope, - allReturnTypes?: ReadonlyMap, + allReturnTypes: ReadonlyMap, + indexes: ScopeResolutionIndexes, + returnTypeByFile: ReadonlyMap>, ): string | null { let iterableNode = valueNode; if (iterableNode.type === 'reference_expression') { @@ -506,10 +638,16 @@ function resolveIterableElementType( } if (func.type === 'identifier') { - const crossFileReturn = allReturnTypes?.get(func.text); + const crossFileReturn = allReturnTypes.get(func.text); if (crossFileReturn !== undefined) return unwrapGeneric(crossFileReturn); const rawReturn = lookupRawFunctionReturnType(func.text, valueNode); if (rawReturn !== null) return unwrapGeneric(rawReturn); + // Import-disambiguated duplicate: the bare-name map is ambiguous (#2514) + // but a `use` import pins one definition. Read its FULL return type + // here, BEFORE the scope-binding lookup below, because that binding is + // generic-truncated (`Vec` becomes `Vec`), losing the element. + const importedReturn = resolveImportedDef(func.text, moduleScope, indexes, returnTypeByFile); + if (importedReturn !== null) return unwrapGeneric(importedReturn); const returnType = lookupReturnTypeInScopes(func.text, parsed, scopeMap, moduleScope); if (returnType !== null) return unwrapGeneric(returnType); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index e47498ebb..59553b75d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -711,6 +711,7 @@ export function runScopeResolution( propagateImportedReturnTypes(parsedFiles, indexes, workspaceIndex); } + const tRangeBindStart = PROF ? process.hrtime.bigint() : 0n; if (provider.populateRangeBindings !== undefined) { provider.populateRangeBindings(parsedFiles, indexes, { fileContents: getFileContents(), @@ -1309,6 +1310,7 @@ export function runScopeResolution( `[scope-resolution prof] extract=${ns(tStart, tExtract).toFixed(0)}ms` + ` finalize=${ns(tExtract, tFinalize).toFixed(0)}ms` + ` propagate=${ns(tFinalize, tPropagate).toFixed(0)}ms` + + ` rangeBind=${ns(tRangeBindStart, tPropagate).toFixed(1)}ms` + ` resolve=${ns(tPropagate, tResolve).toFixed(0)}ms` + ` emit=${ns(tResolve, tEnd).toFixed(0)}ms` + // pdg ⊆ emit: the M2 reaching-defs share of the emit bucket (#2082 U4). diff --git a/gitnexus/src/core/lbug/native-check.ts b/gitnexus/src/core/lbug/native-check.ts index a9971a5e9..c874ed98e 100644 --- a/gitnexus/src/core/lbug/native-check.ts +++ b/gitnexus/src/core/lbug/native-check.ts @@ -1,6 +1,11 @@ import fs from 'fs'; import path from 'path'; import { createRequire } from 'node:module'; +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; + +/** Cap the out-of-process native load probe so a hung filesystem cannot wedge a + * CLI startup gate (same bounding rationale as the extension probe below). */ +const NATIVE_LOAD_PROBE_TIMEOUT_MS = 15_000; export interface NativeCheckResult { ok: boolean; @@ -59,35 +64,73 @@ export function checkLbugNative(overridePkgDir?: string): NativeCheckResult { }; } - try { - const _require = createRequire(import.meta.url); - _require(binaryPath); - } catch (err: unknown) { - const nativeError = err instanceof Error ? err.message : String(err); - return { - ok: false, - binaryPath, - message: [ - 'LadybugDB native binary (lbugjs.node) exists but failed to load:', - ` ${nativeError}`, - '', - 'This can happen with a truncated file, ABI mismatch, or wrong-platform binary.', - '', - 'To repair:', - ` node ${path.join(pkgDir, 'install.js')}`, - '', - 'If install scripts were skipped (pnpm dlx / pnpx / ignore-scripts):', - ' pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter \\', - ' dlx gitnexus@latest serve', - ' pnpm add -g --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter gitnexus', - '', - 'If using bun, add to package.json and reinstall:', - ' "trustedDependencies": ["@ladybugdb/core"]', - ].join('\n'), - }; + // Validate loadability in a THROWAWAY CHILD PROCESS, not in-process. A merely + // truncated or corrupted .node (valid header, missing pages) does not throw a + // catchable error — it SIGBUSes the dynamic loader mid-dlopen, which would take + // the whole CLI down with a raw exit 135 and no guidance (#2441). Loading it in + // a child lets us observe that crash (a non-zero exit or a kill signal) and turn + // it into the same actionable failure as a clean load error. The child requires + // the binary by absolute path, exactly as the former in-process load did. + const probe = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', binaryPath], { + encoding: 'utf8', + timeout: NATIVE_LOAD_PROBE_TIMEOUT_MS, + stdio: ['ignore', 'ignore', 'pipe'], + // Run as Node even if process.execPath is an Electron/embedder binary. + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + }); + + // Only a child that actually RAN and failed proves the binary is bad. If the + // probe could not run at all — a spawn error or a timeout, e.g. a sandbox that + // forbids subprocesses or a non-Node execPath — we could not test the binary, + // so we stay out of the way and let the command's own load be the authority + // rather than condemn a healthy binary. (#2441 still holds: a genuinely broken + // binary loaded in-process later still exits non-zero.) + if (probe.error || probe.status === 0) { + return { ok: true, binaryPath }; } - return { ok: true, binaryPath }; + return { + ok: false, + binaryPath, + message: [ + 'LadybugDB native binary (lbugjs.node) exists but failed to load:', + ` ${describeNativeLoadFailure(probe)}`, + '', + 'This can happen with a truncated file, ABI mismatch, or wrong-platform binary.', + '', + 'To repair:', + ` node ${path.join(pkgDir, 'install.js')}`, + '', + 'If install scripts were skipped (pnpm dlx / pnpx / ignore-scripts):', + ' pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter \\', + ' dlx gitnexus@latest serve', + ' pnpm add -g --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter gitnexus', + '', + 'If using bun, add to package.json and reinstall:', + ' "trustedDependencies": ["@ladybugdb/core"]', + ].join('\n'), + }; +} + +/** + * Describe a child-observed native load failure. Reached only after a probe that + * actually ran and failed: a fatal signal (SIGBUS/SIGSEGV ⇒ truncated/corrupt + * binary), otherwise the child's own load error lifted from its stderr. + */ +function describeNativeLoadFailure(probe: SpawnSyncReturns): string { + if (probe.signal) { + return `crashed while loading (signal ${probe.signal}) — the binary is likely truncated or corrupted`; + } + const lines = (probe.stderr ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const errorLine = lines.find((line) => /^\w*Error: /.test(line)); + return ( + errorLine?.replace(/^\w*Error:\s*/, '') ?? + lines.at(-1) ?? + `exited with code ${probe.status ?? 'unknown'}` + ); } export interface FtsProbeResult { diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 9852e8ba4..bef8a23ce 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -444,8 +444,21 @@ export interface RepoMeta { * incremental write set only covers changed files, so a top-up against a * pre-v11 index would keep silently missing these CALLS edges for every * unchanged Rust trait file; force a full re-analyze instead. + * v12: Rust range-binding stopped restoring ambiguous duplicate type names + * (#2514): a function/struct name defined three or more times used to + * re-resolve to the last-scanned file (a presence toggle), so odd duplicate + * counts emitted a wrong cross-file CALLS edge. Same v7/v11 contract: the + * incremental write set only covers changed files, so a top-up against a + * pre-v12 index would keep these spurious CALLS edges on every unchanged Rust + * file. v12 also changes edges in the other direction: range-binding now + * RESOLVES import-disambiguated duplicate names (`for item in make()` / + * `let Struct { f } = ..` where a `use` or `use x::*` import pins one of several + * same-named definitions) to the imported definition's type. Both the removed + * spurious edges and these new resolved edges are cross-file, so a pre-v12 + * top-up would leave unchanged Rust files stale either way; force a full + * re-analyze instead. */ -export const INCREMENTAL_SCHEMA_VERSION = 11; +export const INCREMENTAL_SCHEMA_VERSION = 12; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-2/src/c_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-2/src/c_a.rs new file mode 100644 index 000000000..97e1a626c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-2/src/c_a.rs @@ -0,0 +1,3 @@ +pub struct Config { pub db: DbA } +pub struct DbA; +impl DbA { pub fn run(&self) {} } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-2/src/c_b.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-2/src/c_b.rs new file mode 100644 index 000000000..6eba40993 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-2/src/c_b.rs @@ -0,0 +1,3 @@ +pub struct Config { pub db: DbB } +pub struct DbB; +impl DbB { pub fn run(&self) {} } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-2/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-2/src/main.rs new file mode 100644 index 000000000..787aa4e42 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-2/src/main.rs @@ -0,0 +1,8 @@ +mod c_a; +mod c_b; +pub fn load() -> u8 { 0 } +fn use_it() { + let Config { db } = load(); + db.run(); +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/c_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/c_a.rs new file mode 100644 index 000000000..97e1a626c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/c_a.rs @@ -0,0 +1,3 @@ +pub struct Config { pub db: DbA } +pub struct DbA; +impl DbA { pub fn run(&self) {} } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/c_b.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/c_b.rs new file mode 100644 index 000000000..6eba40993 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/c_b.rs @@ -0,0 +1,3 @@ +pub struct Config { pub db: DbB } +pub struct DbB; +impl DbB { pub fn run(&self) {} } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/c_c.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/c_c.rs new file mode 100644 index 000000000..e90cb83a2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/c_c.rs @@ -0,0 +1,3 @@ +pub struct Config { pub db: DbC } +pub struct DbC; +impl DbC { pub fn run(&self) {} } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/main.rs new file mode 100644 index 000000000..92fbad3d4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-fields-3/src/main.rs @@ -0,0 +1,9 @@ +mod c_a; +mod c_b; +mod c_c; +pub fn load() -> u8 { 0 } +fn use_it() { + let Config { db } = load(); + db.run(); +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-2/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-2/src/main.rs new file mode 100644 index 000000000..9d224de54 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-2/src/main.rs @@ -0,0 +1,8 @@ +mod t_a; +mod t_b; +fn drive() { + for item in make() { + item.save(); + } +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-2/src/t_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-2/src/t_a.rs new file mode 100644 index 000000000..b62a1679d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-2/src/t_a.rs @@ -0,0 +1,3 @@ +pub struct User { pub name: String } +impl User { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-2/src/t_b.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-2/src/t_b.rs new file mode 100644 index 000000000..80c240632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-2/src/t_b.rs @@ -0,0 +1,3 @@ +pub struct Repo { pub name: String } +impl Repo { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/a_task.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/a_task.rs new file mode 100644 index 000000000..ac105f126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/a_task.rs @@ -0,0 +1,3 @@ +pub struct Task { pub name: String } +impl Task { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/m_repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/m_repo.rs new file mode 100644 index 000000000..80c240632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/m_repo.rs @@ -0,0 +1,3 @@ +pub struct Repo { pub name: String } +impl Repo { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/main.rs new file mode 100644 index 000000000..764062061 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/main.rs @@ -0,0 +1,9 @@ +mod z_user; +mod m_repo; +mod a_task; +fn drive() { + for item in make() { + item.save(); + } +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/z_user.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/z_user.rs new file mode 100644 index 000000000..b62a1679d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3-reordered/src/z_user.rs @@ -0,0 +1,3 @@ +pub struct User { pub name: String } +impl User { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/main.rs new file mode 100644 index 000000000..7c2c54f02 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/main.rs @@ -0,0 +1,9 @@ +mod t_a; +mod t_b; +mod t_c; +fn drive() { + for item in make() { + item.save(); + } +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/t_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/t_a.rs new file mode 100644 index 000000000..b62a1679d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/t_a.rs @@ -0,0 +1,3 @@ +pub struct User { pub name: String } +impl User { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/t_b.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/t_b.rs new file mode 100644 index 000000000..80c240632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/t_b.rs @@ -0,0 +1,3 @@ +pub struct Repo { pub name: String } +impl Repo { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/t_c.rs b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/t_c.rs new file mode 100644 index 000000000..ac105f126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dup-return-3/src/t_c.rs @@ -0,0 +1,3 @@ +pub struct Task { pub name: String } +impl Task { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/main.rs new file mode 100644 index 000000000..d5efc716d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/main.rs @@ -0,0 +1,10 @@ +mod t_a; +mod t_b; +mod t_c; +use crate::t_b::make as mk; +fn drive() { + for item in mk() { + item.save(); + } +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/t_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/t_a.rs new file mode 100644 index 000000000..b62a1679d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/t_a.rs @@ -0,0 +1,3 @@ +pub struct User { pub name: String } +impl User { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/t_b.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/t_b.rs new file mode 100644 index 000000000..80c240632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/t_b.rs @@ -0,0 +1,3 @@ +pub struct Repo { pub name: String } +impl Repo { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/t_c.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/t_c.rs new file mode 100644 index 000000000..ac105f126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-alias-return/src/t_c.rs @@ -0,0 +1,3 @@ +pub struct Task { pub name: String } +impl Task { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/main.rs new file mode 100644 index 000000000..f099c1c96 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/main.rs @@ -0,0 +1,10 @@ +mod t_a; +mod t_b; +mod t_c; +use crate::t_b::Config; +pub fn load() -> u8 { 0 } +fn use_it() { + let Config { db } = load(); + db.run(); +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/t_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/t_a.rs new file mode 100644 index 000000000..97e1a626c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/t_a.rs @@ -0,0 +1,3 @@ +pub struct Config { pub db: DbA } +pub struct DbA; +impl DbA { pub fn run(&self) {} } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/t_b.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/t_b.rs new file mode 100644 index 000000000..6eba40993 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/t_b.rs @@ -0,0 +1,3 @@ +pub struct Config { pub db: DbB } +pub struct DbB; +impl DbB { pub fn run(&self) {} } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/t_c.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/t_c.rs new file mode 100644 index 000000000..e90cb83a2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-fields/src/t_c.rs @@ -0,0 +1,3 @@ +pub struct Config { pub db: DbC } +pub struct DbC; +impl DbC { pub fn run(&self) {} } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/main.rs new file mode 100644 index 000000000..6645a70ca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/main.rs @@ -0,0 +1,10 @@ +mod t_a; +mod t_b; +mod t_c; +use crate::t_b::make; +fn drive() { + for item in make() { + item.save(); + } +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/t_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/t_a.rs new file mode 100644 index 000000000..b62a1679d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/t_a.rs @@ -0,0 +1,3 @@ +pub struct User { pub name: String } +impl User { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/t_b.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/t_b.rs new file mode 100644 index 000000000..80c240632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/t_b.rs @@ -0,0 +1,3 @@ +pub struct Repo { pub name: String } +impl Repo { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/t_c.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/t_c.rs new file mode 100644 index 000000000..ac105f126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-dup-return/src/t_c.rs @@ -0,0 +1,3 @@ +pub struct Task { pub name: String } +impl Task { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/main.rs new file mode 100644 index 000000000..72d2374e4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/main.rs @@ -0,0 +1,11 @@ +mod t_a; +mod t_b; +mod t_c; +use crate::t_b::*; +use crate::t_c::*; +fn drive() { + for item in make() { + item.save(); + } +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/t_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/t_a.rs new file mode 100644 index 000000000..b62a1679d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/t_a.rs @@ -0,0 +1,3 @@ +pub struct User { pub name: String } +impl User { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/t_b.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/t_b.rs new file mode 100644 index 000000000..80c240632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/t_b.rs @@ -0,0 +1,3 @@ +pub struct Repo { pub name: String } +impl Repo { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/t_c.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/t_c.rs new file mode 100644 index 000000000..ac105f126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-ambiguous/src/t_c.rs @@ -0,0 +1,3 @@ +pub struct Task { pub name: String } +impl Task { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/main.rs new file mode 100644 index 000000000..d83ef15a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/main.rs @@ -0,0 +1,15 @@ +mod t_a; +mod t_b; +mod t_c; +use crate::t_b::*; +pub struct Local; +impl Local { + pub fn save(&self) {} +} +pub fn make() -> Vec { vec![] } +fn drive() { + for item in make() { + item.save(); + } +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/t_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/t_a.rs new file mode 100644 index 000000000..b62a1679d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/t_a.rs @@ -0,0 +1,3 @@ +pub struct User { pub name: String } +impl User { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/t_b.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/t_b.rs new file mode 100644 index 000000000..80c240632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/t_b.rs @@ -0,0 +1,3 @@ +pub struct Repo { pub name: String } +impl Repo { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/t_c.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/t_c.rs new file mode 100644 index 000000000..ac105f126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-local-shadows/src/t_c.rs @@ -0,0 +1,3 @@ +pub struct Task { pub name: String } +impl Task { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/main.rs new file mode 100644 index 000000000..add8f4b35 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/main.rs @@ -0,0 +1,10 @@ +mod t_a; +mod t_b; +mod t_c; +use crate::t_b::*; +fn drive() { + for item in make() { + item.save(); + } +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/t_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/t_a.rs new file mode 100644 index 000000000..b62a1679d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/t_a.rs @@ -0,0 +1,3 @@ +pub struct User { pub name: String } +impl User { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/t_b.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/t_b.rs new file mode 100644 index 000000000..80c240632 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/t_b.rs @@ -0,0 +1,3 @@ +pub struct Repo { pub name: String } +impl Repo { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/t_c.rs b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/t_c.rs new file mode 100644 index 000000000..ac105f126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-import-glob-return/src/t_c.rs @@ -0,0 +1,3 @@ +pub struct Task { pub name: String } +impl Task { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-unique-return/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-unique-return/src/main.rs new file mode 100644 index 000000000..cedb46e85 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-unique-return/src/main.rs @@ -0,0 +1,7 @@ +mod t_a; +fn drive() { + for item in make() { + item.save(); + } +} +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-unique-return/src/t_a.rs b/gitnexus/test/fixtures/lang-resolution/rust-unique-return/src/t_a.rs new file mode 100644 index 000000000..b62a1679d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-unique-return/src/t_a.rs @@ -0,0 +1,3 @@ +pub struct User { pub name: String } +impl User { pub fn save(&self) {} } +pub fn make() -> Vec { vec![] } diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index f632f78aa..755630759 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -171,6 +171,78 @@ "captureGroups": 22, "digest": "c53db401a81fde2ffd5665393acb9cd605a62ec51c015c3aafb3f41c0897471f" }, + "rust-dup-fields-2/src/c_a.rs": { + "captureGroups": 11, + "digest": "4c7844b039d3b2c618e5e1978e0bed50a2d8de0a7a92a87fef4791c91fd2d0d2" + }, + "rust-dup-fields-2/src/c_b.rs": { + "captureGroups": 11, + "digest": "cd02dd0f2b74d8e9495634f3a33477c2b20d5f2877612733b66402eae6fe8426" + }, + "rust-dup-fields-2/src/main.rs": { + "captureGroups": 16, + "digest": "e96013ac801f874a1ad902c7bd2be277202b38fbf12030c4ebbb46edd8c0fe79" + }, + "rust-dup-fields-3/src/c_a.rs": { + "captureGroups": 11, + "digest": "4c7844b039d3b2c618e5e1978e0bed50a2d8de0a7a92a87fef4791c91fd2d0d2" + }, + "rust-dup-fields-3/src/c_b.rs": { + "captureGroups": 11, + "digest": "cd02dd0f2b74d8e9495634f3a33477c2b20d5f2877612733b66402eae6fe8426" + }, + "rust-dup-fields-3/src/c_c.rs": { + "captureGroups": 11, + "digest": "a5263afa5bc9cb6b499d3c5394cc8b0a942d9a1fe388a2366a3baca963ead634" + }, + "rust-dup-fields-3/src/main.rs": { + "captureGroups": 17, + "digest": "fad21b046b2f34b8a6fd98ffc3719446f176f7207864b3fdd9ba2d9bb07d607f" + }, + "rust-dup-return-2/src/main.rs": { + "captureGroups": 14, + "digest": "a4d637dc57a09e56dce75102c70ca6a45998f498990289855d4953bf4ed5461f" + }, + "rust-dup-return-2/src/t_a.rs": { + "captureGroups": 14, + "digest": "d531e0aec84d7da0e2c6818e445735b4cb1ff15e08d14586093069e5eeab41c9" + }, + "rust-dup-return-2/src/t_b.rs": { + "captureGroups": 14, + "digest": "cb5f21ac23b71efdf24b121268f783d5b224e7d498ffa7a54555c97ab0f904bf" + }, + "rust-dup-return-3-reordered/src/a_task.rs": { + "captureGroups": 14, + "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" + }, + "rust-dup-return-3-reordered/src/m_repo.rs": { + "captureGroups": 14, + "digest": "cb5f21ac23b71efdf24b121268f783d5b224e7d498ffa7a54555c97ab0f904bf" + }, + "rust-dup-return-3-reordered/src/main.rs": { + "captureGroups": 15, + "digest": "0f68357dbffb22af2c36ca5a935c3eea4025c119afa1a7c1d258ab9a74fe96e8" + }, + "rust-dup-return-3-reordered/src/z_user.rs": { + "captureGroups": 14, + "digest": "d531e0aec84d7da0e2c6818e445735b4cb1ff15e08d14586093069e5eeab41c9" + }, + "rust-dup-return-3/src/main.rs": { + "captureGroups": 15, + "digest": "a93ca0874eeaedd21dba987143fa389281d8b738612ca49c314ab91dd73e4065" + }, + "rust-dup-return-3/src/t_a.rs": { + "captureGroups": 14, + "digest": "d531e0aec84d7da0e2c6818e445735b4cb1ff15e08d14586093069e5eeab41c9" + }, + "rust-dup-return-3/src/t_b.rs": { + "captureGroups": 14, + "digest": "cb5f21ac23b71efdf24b121268f783d5b224e7d498ffa7a54555c97ab0f904bf" + }, + "rust-dup-return-3/src/t_c.rs": { + "captureGroups": 14, + "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" + }, "rust-dyn-trait-object/src/lib.rs": { "captureGroups": 23, "digest": "720618dff6a43ab8e5b59aa354c0c448b9057dd6f2f7b3b22b13b82d53745943" @@ -267,6 +339,102 @@ "captureGroups": 20, "digest": "d8c1eb57431b915dd5c9055d8451054a454e340c4842628e38e4d46f69471abd" }, + "rust-import-alias-return/src/main.rs": { + "captureGroups": 16, + "digest": "c14fd2abf932f09fe19821951e73afdf03839bdb761a7d0ad347c9926a0d5542" + }, + "rust-import-alias-return/src/t_a.rs": { + "captureGroups": 14, + "digest": "d531e0aec84d7da0e2c6818e445735b4cb1ff15e08d14586093069e5eeab41c9" + }, + "rust-import-alias-return/src/t_b.rs": { + "captureGroups": 14, + "digest": "cb5f21ac23b71efdf24b121268f783d5b224e7d498ffa7a54555c97ab0f904bf" + }, + "rust-import-alias-return/src/t_c.rs": { + "captureGroups": 14, + "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" + }, + "rust-import-dup-fields/src/main.rs": { + "captureGroups": 18, + "digest": "82b48d00fe2e4b5a2f52185d5d2501fdef408faf148b509fdc5f428171b9a784" + }, + "rust-import-dup-fields/src/t_a.rs": { + "captureGroups": 11, + "digest": "4c7844b039d3b2c618e5e1978e0bed50a2d8de0a7a92a87fef4791c91fd2d0d2" + }, + "rust-import-dup-fields/src/t_b.rs": { + "captureGroups": 11, + "digest": "cd02dd0f2b74d8e9495634f3a33477c2b20d5f2877612733b66402eae6fe8426" + }, + "rust-import-dup-fields/src/t_c.rs": { + "captureGroups": 11, + "digest": "a5263afa5bc9cb6b499d3c5394cc8b0a942d9a1fe388a2366a3baca963ead634" + }, + "rust-import-dup-return/src/main.rs": { + "captureGroups": 16, + "digest": "e70780bcc2ccbfca9fe2099887e30187f70c48b68a11948592778997d1e2bd13" + }, + "rust-import-dup-return/src/t_a.rs": { + "captureGroups": 14, + "digest": "d531e0aec84d7da0e2c6818e445735b4cb1ff15e08d14586093069e5eeab41c9" + }, + "rust-import-dup-return/src/t_b.rs": { + "captureGroups": 14, + "digest": "cb5f21ac23b71efdf24b121268f783d5b224e7d498ffa7a54555c97ab0f904bf" + }, + "rust-import-dup-return/src/t_c.rs": { + "captureGroups": 14, + "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" + }, + "rust-import-glob-ambiguous/src/main.rs": { + "captureGroups": 17, + "digest": "31f683937b9d9208e7f2c6bd2d1092c59bcf6ab16a6a6923b48bba6fcab2a1a6" + }, + "rust-import-glob-ambiguous/src/t_a.rs": { + "captureGroups": 14, + "digest": "d531e0aec84d7da0e2c6818e445735b4cb1ff15e08d14586093069e5eeab41c9" + }, + "rust-import-glob-ambiguous/src/t_b.rs": { + "captureGroups": 14, + "digest": "cb5f21ac23b71efdf24b121268f783d5b224e7d498ffa7a54555c97ab0f904bf" + }, + "rust-import-glob-ambiguous/src/t_c.rs": { + "captureGroups": 14, + "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" + }, + "rust-import-glob-local-shadows/src/main.rs": { + "captureGroups": 28, + "digest": "5242d2ee3c9bdb8c679ddc55726e423fc48997db2bcfc4f79dca3645289d1149" + }, + "rust-import-glob-local-shadows/src/t_a.rs": { + "captureGroups": 14, + "digest": "d531e0aec84d7da0e2c6818e445735b4cb1ff15e08d14586093069e5eeab41c9" + }, + "rust-import-glob-local-shadows/src/t_b.rs": { + "captureGroups": 14, + "digest": "cb5f21ac23b71efdf24b121268f783d5b224e7d498ffa7a54555c97ab0f904bf" + }, + "rust-import-glob-local-shadows/src/t_c.rs": { + "captureGroups": 14, + "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" + }, + "rust-import-glob-return/src/main.rs": { + "captureGroups": 16, + "digest": "a157af0c6d7b8f9861712c1822bb5bab8e7fadbcdd58c08d4d034343117053cb" + }, + "rust-import-glob-return/src/t_a.rs": { + "captureGroups": 14, + "digest": "d531e0aec84d7da0e2c6818e445735b4cb1ff15e08d14586093069e5eeab41c9" + }, + "rust-import-glob-return/src/t_b.rs": { + "captureGroups": 14, + "digest": "cb5f21ac23b71efdf24b121268f783d5b224e7d498ffa7a54555c97ab0f904bf" + }, + "rust-import-glob-return/src/t_c.rs": { + "captureGroups": 14, + "digest": "67e8de06340dd3b5c5c4bcc88836d1ef89b47f4a740697664dfa26cd36cf5ac5" + }, "rust-iter-for-loop/src/main.rs": { "captureGroups": 30, "digest": "529fda7f9f188814ce6044e2c99d6b005ec4a9b98835fe9609530897998b9f32" @@ -507,6 +675,14 @@ "captureGroups": 10, "digest": "e2a6fb9eab259b8c7104f1530b96b8c1f42ab32fe1d71d6bdca04d68263507f2" }, + "rust-unique-return/src/main.rs": { + "captureGroups": 13, + "digest": "72cc5728b40f51fae75359c2443986ce73f5d5450ebf8a367a2d860c87c84ed0" + }, + "rust-unique-return/src/t_a.rs": { + "captureGroups": 14, + "digest": "d531e0aec84d7da0e2c6818e445735b4cb1ff15e08d14586093069e5eeab41c9" + }, "rust-write-access/models.rs": { "captureGroups": 9, "digest": "660f755fd70cd1796f9da02ad7d65f599dea8029665ee45ecd18cd27919741f3" diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index fe970b777..2e4a400c4 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -13,6 +13,7 @@ import { edgeSet, runPipelineFromRepo, type PipelineResult, + type RelEdge, } from './helpers.js'; // --------------------------------------------------------------------------- @@ -2350,3 +2351,211 @@ describe('Rust macro resolution (issue #1934 F72)', () => { expect(calls.every((e) => e.targetLabel !== 'Macro')).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// #2514: duplicate type names must stay ambiguous regardless of duplicate +// count or file order. The range-binding prepass used Map presence as an +// ambiguity toggle (has→delete / else→set), so a 3rd same-named definition +// re-inserted a resolvable — and wrong — cross-file type (the last-scanned +// file's). The fix latches ambiguity in a separate Set: once a name has two +// definitions it never resolves again. +// +// Observable: for-loop `for item in make() { item.save(); }` where each +// `make()` (or each `Config` field) lives in its own file with no `use` +// import, so the receiver type can only come from the global range-binding +// map. A cross-file `save`/`run` CALLS edge means the name resolved. +// --------------------------------------------------------------------------- + +describe('Rust duplicate-name ambiguity latch (#2514)', () => { + // Cross-file receiver-method CALLS edges emitted from the fixture driver fn. + const receiverCalls = (result: PipelineResult, source: string, method: string): RelEdge[] => + getRelationships(result, 'CALLS').filter((c) => c.source === source && c.target === method); + + // --- return-type registry (allReturnTypes) --- + + describe('two same-named fns with different return types', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-dup-return-2'), () => {}); + }, 60000); + + it('suppresses cross-file return-type inference — item.save() does not resolve', () => { + expect(receiverCalls(result, 'drive', 'save')).toEqual([]); + }); + }); + + describe('three same-named fns with different return types', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-dup-return-3'), () => {}); + }, 60000); + + it('still suppresses inference — the 3rd duplicate does not restore a binding', () => { + expect(receiverCalls(result, 'drive', 'save')).toEqual([]); + }); + }); + + describe('three same-named fns, permuted input file order', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-dup-return-3-reordered'), + () => {}, + ); + }, 60000); + + it('resolution is independent of file order — still no edge', () => { + expect(receiverCalls(result, 'drive', 'save')).toEqual([]); + }); + }); + + describe('unique fn still infers normally (over-suppression guard)', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-unique-return'), () => {}); + }, 60000); + + it('resolves item.save() to User#save via cross-file return type', () => { + const edges = receiverCalls(result, 'drive', 'save'); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ source: 'drive', target: 'save', targetLabel: 'Function' }); + expect(edges[0].targetFilePath).toContain('t_a.rs'); + }); + }); + + // --- field-type registry (allFieldTypes) via struct destructuring --- + + describe('two same-named structs with conflicting field types', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-dup-fields-2'), () => {}); + }, 60000); + + it('suppresses global field-type inference — db.run() does not resolve', () => { + expect(receiverCalls(result, 'use_it', 'run')).toEqual([]); + }); + }); + + describe('three same-named structs with conflicting field types', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-dup-fields-3'), () => {}); + }, 60000); + + it('still suppresses field inference — the 3rd duplicate does not restore', () => { + expect(receiverCalls(result, 'use_it', 'run')).toEqual([]); + }); + }); +}); + +// --------------------------------------------------------------------------- +// #2514 follow-up: when a `use` import disambiguates one of several same-named +// definitions, the type must resolve to THAT definition (like the compiler), +// not stay ambiguous. The bare-name map is ambiguous, but the call site's +// import pins a single defining file, so range-binding reads that definition's +// FULL return/field type — recovering generic element types the bare-name map +// would have lost. Genuinely-ambiguous (no-import) duplicates still stay +// unresolved (covered by the #2514 block above). +// --------------------------------------------------------------------------- + +describe('Rust import-disambiguated duplicate resolution (#2514 follow-up)', () => { + describe('for-loop over an imported generic-returning duplicate fn', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-import-dup-return'), () => {}); + }, 60000); + + it('resolves item.save() to the imported definition in t_b (Repo), not ambiguous', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'drive' && c.target === 'save', + ); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ source: 'drive', target: 'save', targetLabel: 'Function' }); + expect(edges[0].targetFilePath).toContain('t_b.rs'); + }); + }); + + describe('struct destructuring of an imported duplicate struct', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-import-dup-fields'), () => {}); + }, 60000); + + it('resolves db.run() to the imported definition in t_b (DbB) via its field type', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'use_it' && c.target === 'run', + ); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ source: 'use_it', target: 'run', targetLabel: 'Function' }); + expect(edges[0].targetFilePath).toContain('t_b.rs'); + }); + }); + + describe('aliased import (`use t_b::make as mk`) still resolves the definition', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-import-alias-return'), () => {}); + }, 60000); + + it('keys on the definition name, not the alias — item.save() resolves to t_b (Repo)', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'drive' && c.target === 'save', + ); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ source: 'drive', target: 'save', targetLabel: 'Function' }); + expect(edges[0].targetFilePath).toContain('t_b.rs'); + }); + }); + + describe('single glob import (`use t_b::*`) resolves the one globbed definition', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-import-glob-return'), () => {}); + }, 60000); + + it('resolves item.save() to t_b (Repo) via the one glob-target that defines it', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'drive' && c.target === 'save', + ); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ source: 'drive', target: 'save', targetLabel: 'Function' }); + expect(edges[0].targetFilePath).toContain('t_b.rs'); + }); + }); + + describe('two glob imports that both export the name stay ambiguous', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-import-glob-ambiguous'), + () => {}, + ); + }, 60000); + + it('leaves item.save() unresolved when two `use x::*` both define make', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'drive' && c.target === 'save', + ); + expect(edges).toEqual([]); + }); + }); + + describe('a local definition shadows a glob import', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-import-glob-local-shadows'), + () => {}, + ); + }, 60000); + + it('resolves item.save() to the local make in main.rs, not the glob target', () => { + const edges = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'drive' && c.target === 'save', + ); + expect(edges.length).toBe(1); + expect(edges[0]).toMatchObject({ source: 'drive', target: 'save', targetLabel: 'Function' }); + expect(edges[0].targetFilePath).toContain('main.rs'); + }); + }); +}); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 2047754c4..04a53f6e1 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,8 +73,8 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 11 (Rust dyn-trait-object dispatch re-index window, #2604)', () => { - expect(INCREMENTAL_SCHEMA_VERSION).toBe(11); + it('INCREMENTAL_SCHEMA_VERSION is bumped to 12 (Rust range-binding ambiguity latch + import-disambiguated resolution, #2514)', () => { + expect(INCREMENTAL_SCHEMA_VERSION).toBe(12); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -116,7 +116,12 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // (#2604) — abstract trait methods would keep being uncaptured (no // ownerId/CALLS resolution) on unchanged Rust trait files → must NOT reuse. expect(passesReuseGate(10)).toBe(false); + // A pre-v12 (v11) index predates the #2514 Rust range-binding fix — the + // ambiguity latch removes spurious cross-file CALLS edges and the + // import-disambiguated resolution adds new ones on unchanged Rust files, + // neither of which reach an incremental write set → must NOT reuse. + expect(passesReuseGate(11)).toBe(false); // A current-version stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(11)).toBe(true); + expect(passesReuseGate(12)).toBe(true); }); }); diff --git a/gitnexus/test/unit/lazy-action.test.ts b/gitnexus/test/unit/lazy-action.test.ts index 9afe7bc27..973d728ca 100644 --- a/gitnexus/test/unit/lazy-action.test.ts +++ b/gitnexus/test/unit/lazy-action.test.ts @@ -90,4 +90,46 @@ describe('createAnalyzerLbugLazyAction', () => { expect(events).toEqual(['identity-module', 'receipt-captured', 'analyzer-module']); expect(run).toHaveBeenCalledWith(receipt, 'repo', { force: true }); }); + + it('sets exit code 1 and skips the analyzer import when native load fails', async () => { + // Regression guard for #2441: a LadybugDB native-load failure must fail + // closed — no analyzer import, no index write, non-zero exit — not the + // pre-fix "print help then exit 0" silent success. Mirrors the + // createLbugLazyAction failure test above for the analyze-only wrapper. + checkLbugNativeMock.mockReturnValueOnce({ + ok: false, + message: + 'LadybugDB native binary (lbugjs.node) exists but failed to load:\n' + ' dlopen failed', + }); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + process.exitCode = undefined; + const run = vi.fn(async () => undefined); + const analyzerLoader = vi.fn(async () => ({ run })); + const identityLoader = vi.fn(async () => ({ + captureAnalyzerIdentityBeforeLoad: async (_url: string, loader: () => Promise) => { + const loaded = await loader(); + return { runnerIdentity: { schemaVersion: 4 }, loaded }; + }, + })); + const action = createAnalyzerLbugLazyAction( + identityLoader as never, + analyzerLoader, + 'run', + 'file:///fixture/dist/cli/index.js', + ); + + try { + await expect(action('repo', { force: true })).resolves.toBeUndefined(); + + expect(analyzerLoader).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('LadybugDB native binary (lbugjs.node) exists but failed to load:'), + ); + } finally { + stderrSpy.mockRestore(); + process.exitCode = undefined; + } + }); }); diff --git a/gitnexus/test/unit/lbug-native-check.test.ts b/gitnexus/test/unit/lbug-native-check.test.ts index c54b1635b..20883a77e 100644 --- a/gitnexus/test/unit/lbug-native-check.test.ts +++ b/gitnexus/test/unit/lbug-native-check.test.ts @@ -49,4 +49,49 @@ describe('checkLbugNative', () => { await fs.rm(tmpDir, { recursive: true, force: true }); } }); + + it('returns ok:false when lbugjs.node is truncated (loader crashes with a signal)', async () => { + // A partially written .node (valid header, missing pages) SIGBUSes dlopen — a + // signal, not a catchable throw. The out-of-process probe must observe the + // crash and report it, instead of the whole process dying with exit 135 (#2441). + const realPath = checkLbugNative().binaryPath; + expect(realPath).toBeDefined(); + const truncated = (await fs.readFile(realPath!)).subarray(0, 300_000); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-')); + try { + await fs.writeFile(path.join(tmpDir, 'install.js'), ''); + await fs.writeFile(path.join(tmpDir, 'lbugjs.node'), truncated); + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(false); + expect(result.message).toContain('failed to load'); + expect(result.message).toContain('install.js'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns ok:true when the load probe cannot be spawned (inconclusive, not a broken binary)', async () => { + // The binary is present, but the child probe cannot launch — a sandbox that + // forbids subprocesses, or a non-Node execPath. We could not test the binary, + // so a healthy one must not be condemned; the command's own load stays + // authoritative. (Binary content is irrelevant here — the probe never runs.) + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-')); + const originalExecPath = process.execPath; + try { + await fs.writeFile(path.join(tmpDir, 'lbugjs.node'), Buffer.from('content-irrelevant')); + await fs.writeFile(path.join(tmpDir, 'install.js'), ''); + process.execPath = path.join(tmpDir, 'definitely-not-node'); + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(true); + expect(result.message).toBeUndefined(); + } finally { + process.execPath = originalExecPath; + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); });