Merge branch 'main' into main

This commit is contained in:
Gergő Magyar 2026-07-23 15:04:24 +01:00 committed by GitHub
commit bd9889cdec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 979 additions and 93 deletions

View file

@ -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: >-

View file

@ -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",

View file

@ -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<ReturnType<typeof getRustParser>['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<typeof getRustParser>,
filePath: string,
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
},
store: Map<string, RustTree> | 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<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
@ -26,45 +72,45 @@ export function populateRustRangeBindings(
): void {
const parser = getRustParser();
const allReturnTypes = new Map<string, string>();
const ambiguousReturnTypes = new Set<string>();
const allFieldTypes = new Map<string, Map<string, string>>();
const ambiguousFieldTypes = new Set<string>();
// 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<Repo>` element type survives (#2514
// follow-up: import-disambiguated duplicates resolve like the compiler).
const returnTypeByFile = new Map<string, Map<string, string>>();
const fieldTypeByFile = new Map<string, Map<string, Map<string, string>>>();
// 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<string, RustTree> | 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<typeof parser.parse>
| undefined;
let tree: ReturnType<typeof parser.parse>;
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<typeof parser.parse>
| undefined;
let tree: ReturnType<typeof parser.parse>;
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<V>(
byFile: Map<string, Map<string, V>>,
file: string,
name: string,
value: V,
): void {
let inner = byFile.get(file);
if (inner === undefined) {
inner = new Map<string, V>();
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<string>();
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<V>(
name: string,
moduleScope: Scope,
indexes: ScopeResolutionIndexes,
byFile: ReadonlyMap<string, ReadonlyMap<string, V>>,
): 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<string, Scope>,
moduleScope: Scope,
allReturnTypes: ReadonlyMap<string, string>,
indexes: ScopeResolutionIndexes,
returnTypeByFile: ReadonlyMap<string, Map<string, string>>,
): 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<string, Scope>,
moduleScope: Scope,
allFieldTypes?: ReadonlyMap<string, Map<string, string>>,
allFieldTypes: ReadonlyMap<string, Map<string, string>>,
indexes: ScopeResolutionIndexes,
fieldTypeByFile: ReadonlyMap<string, ReadonlyMap<string, Map<string, string>>>,
): 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<string, Scope>,
moduleScope: Scope,
allReturnTypes?: ReadonlyMap<string, string>,
allReturnTypes: ReadonlyMap<string, string>,
indexes: ScopeResolutionIndexes,
returnTypeByFile: ReadonlyMap<string, ReadonlyMap<string, string>>,
): 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<Repo>` 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);
}

View file

@ -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).

View file

@ -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>): 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 {

View file

@ -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;

View file

@ -0,0 +1,3 @@
pub struct Config { pub db: DbA }
pub struct DbA;
impl DbA { pub fn run(&self) {} }

View file

@ -0,0 +1,3 @@
pub struct Config { pub db: DbB }
pub struct DbB;
impl DbB { pub fn run(&self) {} }

View file

@ -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() {}

View file

@ -0,0 +1,3 @@
pub struct Config { pub db: DbA }
pub struct DbA;
impl DbA { pub fn run(&self) {} }

View file

@ -0,0 +1,3 @@
pub struct Config { pub db: DbB }
pub struct DbB;
impl DbB { pub fn run(&self) {} }

View file

@ -0,0 +1,3 @@
pub struct Config { pub db: DbC }
pub struct DbC;
impl DbC { pub fn run(&self) {} }

View file

@ -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() {}

View file

@ -0,0 +1,8 @@
mod t_a;
mod t_b;
fn drive() {
for item in make() {
item.save();
}
}
fn main() {}

View file

@ -0,0 +1,3 @@
pub struct User { pub name: String }
impl User { pub fn save(&self) {} }
pub fn make() -> Vec<User> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Repo { pub name: String }
impl Repo { pub fn save(&self) {} }
pub fn make() -> Vec<Repo> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Task { pub name: String }
impl Task { pub fn save(&self) {} }
pub fn make() -> Vec<Task> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Repo { pub name: String }
impl Repo { pub fn save(&self) {} }
pub fn make() -> Vec<Repo> { vec![] }

View file

@ -0,0 +1,9 @@
mod z_user;
mod m_repo;
mod a_task;
fn drive() {
for item in make() {
item.save();
}
}
fn main() {}

View file

@ -0,0 +1,3 @@
pub struct User { pub name: String }
impl User { pub fn save(&self) {} }
pub fn make() -> Vec<User> { vec![] }

View file

@ -0,0 +1,9 @@
mod t_a;
mod t_b;
mod t_c;
fn drive() {
for item in make() {
item.save();
}
}
fn main() {}

View file

@ -0,0 +1,3 @@
pub struct User { pub name: String }
impl User { pub fn save(&self) {} }
pub fn make() -> Vec<User> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Repo { pub name: String }
impl Repo { pub fn save(&self) {} }
pub fn make() -> Vec<Repo> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Task { pub name: String }
impl Task { pub fn save(&self) {} }
pub fn make() -> Vec<Task> { vec![] }

View file

@ -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() {}

View file

@ -0,0 +1,3 @@
pub struct User { pub name: String }
impl User { pub fn save(&self) {} }
pub fn make() -> Vec<User> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Repo { pub name: String }
impl Repo { pub fn save(&self) {} }
pub fn make() -> Vec<Repo> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Task { pub name: String }
impl Task { pub fn save(&self) {} }
pub fn make() -> Vec<Task> { vec![] }

View file

@ -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() {}

View file

@ -0,0 +1,3 @@
pub struct Config { pub db: DbA }
pub struct DbA;
impl DbA { pub fn run(&self) {} }

View file

@ -0,0 +1,3 @@
pub struct Config { pub db: DbB }
pub struct DbB;
impl DbB { pub fn run(&self) {} }

View file

@ -0,0 +1,3 @@
pub struct Config { pub db: DbC }
pub struct DbC;
impl DbC { pub fn run(&self) {} }

View file

@ -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() {}

View file

@ -0,0 +1,3 @@
pub struct User { pub name: String }
impl User { pub fn save(&self) {} }
pub fn make() -> Vec<User> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Repo { pub name: String }
impl Repo { pub fn save(&self) {} }
pub fn make() -> Vec<Repo> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Task { pub name: String }
impl Task { pub fn save(&self) {} }
pub fn make() -> Vec<Task> { vec![] }

View file

@ -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() {}

View file

@ -0,0 +1,3 @@
pub struct User { pub name: String }
impl User { pub fn save(&self) {} }
pub fn make() -> Vec<User> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Repo { pub name: String }
impl Repo { pub fn save(&self) {} }
pub fn make() -> Vec<Repo> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Task { pub name: String }
impl Task { pub fn save(&self) {} }
pub fn make() -> Vec<Task> { vec![] }

View file

@ -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<Local> { vec![] }
fn drive() {
for item in make() {
item.save();
}
}
fn main() {}

View file

@ -0,0 +1,3 @@
pub struct User { pub name: String }
impl User { pub fn save(&self) {} }
pub fn make() -> Vec<User> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Repo { pub name: String }
impl Repo { pub fn save(&self) {} }
pub fn make() -> Vec<Repo> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Task { pub name: String }
impl Task { pub fn save(&self) {} }
pub fn make() -> Vec<Task> { vec![] }

View file

@ -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() {}

View file

@ -0,0 +1,3 @@
pub struct User { pub name: String }
impl User { pub fn save(&self) {} }
pub fn make() -> Vec<User> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Repo { pub name: String }
impl Repo { pub fn save(&self) {} }
pub fn make() -> Vec<Repo> { vec![] }

View file

@ -0,0 +1,3 @@
pub struct Task { pub name: String }
impl Task { pub fn save(&self) {} }
pub fn make() -> Vec<Task> { vec![] }

View file

@ -0,0 +1,7 @@
mod t_a;
fn drive() {
for item in make() {
item.save();
}
}
fn main() {}

View file

@ -0,0 +1,3 @@
pub struct User { pub name: String }
impl User { pub fn save(&self) {} }
pub fn make() -> Vec<User> { vec![] }

View file

@ -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"

View file

@ -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');
});
});
});

View file

@ -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);
});
});

View file

@ -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<unknown>) => {
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;
}
});
});

View file

@ -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 });
}
});
});