fix(rust): see through Box, Rc and Arc on struct field types (#3498) (#3665)

* fix(rust): see through Box, Rc and Arc on struct field types (#3498)

The struct-field normalizer in rust/range-binding.ts cut a type at its
first `<`. A `repo: Box<User>` field was typed `Box`, so `self.repo.save()`
bound nothing. It also stripped a leading `&` but not a lifetime, so a
borrowed field, which always carries one (`&'a User`), came out as
`'a User`.

Box, Rc and Arc auto-deref on a method call, so the field now takes their
first type argument: bare or path-qualified (`std::rc::Rc<User>`), and
nested (`Rc<Box<User>>`). Borrows with a lifetime or `mut` are stripped,
and `Box<dyn Trait>` reduces to the trait through the stripDynBound helper
that parameters already use (#2604).

RefCell, Cell, Mutex, RwLock, Option and Vec are deliberately left alone.
A method on the inner type needs `.borrow()`, `.get()`, `.lock()`,
`.read()`, a match or an index first, so the receiver really is the
wrapper, and a direct call through one must not bind.

Shape arm: rust `decoratedFieldType` moves INVISIBLE-GAP -> RESOLVES, with
the edge on `User::save`. The corpus has no smart-pointer or borrowed
struct field, so the count arm and every other cell are unchanged, and the
corpus edge (2838) and drop (192) sets match main exactly. Scope-capture
fingerprints are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(rust): keep opaque pointer fields wrapped

* fix(rust): refuse opaque macro expansions

* test(rust): pin pointer wrapper targets and opacity limits (#3665)

- Assert a same-file Box keeps its own save target while User.save stays
  unbound (review thread on custom same-named wrappers).
- Pin the documented workspace-wide recall limit: an unrelated unresolved
  import or macro_rules! keeps an otherwise valid pointer field wrapped.
- Drop per-case logging and group the opaque/expansion cases in describe
  blocks.
- Collapse the stacked pointer-field receipts in BASELINE.md into one
  current section listing every workspace-wide refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: preserve Rust integration notes

* test(rust): assert complete pointer targets

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-10-23 00:19:27 +03:00 • committed by GitHub
parent af5fd9665c
commit 673dc6968e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 996 additions and 16 deletions

View file

@ -1,5 +1,19 @@
# Receiver-resolution baseline
## Rust smart-pointer field types (#3498)
The struct-field normalizer in `rust/range-binding.ts` cut a type at its first
`<`, so a `repo: Box<User>` field was typed `Box` and `self.repo.save()` bound
nothing. It now unwraps proven standard `Box`, `Rc` and `Arc` pointers to a
lexically resolved, unqualified pointee (including nested standard pointers),
strips `&'a` / `&'a mut` borrows, and reduces `Box<dyn Trait>` to the trait.
Qualified pointees, projections and callable types remain wrapped. `RefCell`, `Cell`, `Mutex`,
`RwLock`, `Option` and `Vec` stay as written, because a method on the inner type
needs an explicit step. `rust.decoratedFieldType` moves from VISIBLE-GAP to
RESOLVES, and the edge lands on `User::save`. No corpus fixture has a
smart-pointer or borrowed struct field, so the count arm and every other shape
cell are unchanged.
## C# nullable field types (#3498)
A field written `User? repo;` has a `nullable_type` as its declared type.
@ -1192,6 +1206,71 @@ Main `65b101407` is merged normally. The shared caller retains Rust's provider-q
Main `65b101407` is merged normally. Origin evidence retains the qualified-type lookup policy and both pipeline passes retain workspace file paths. Package build, both pinned TypeScript configurations, 425 focused tests across 25 files, and all 16 capture checks pass. The regenerated receiver baseline preserves every shape state and main's 132 call drops, 160 total, 28 reads and zero writes. Origin labels are 30 external, 45 unknown, 55 in-program and two unclassified. The 5,424-record resolution corpus is byte-identical to main. Exact-head hosted CI remains required.
### Standard pointer ownership
Only known std/alloc Box, Rc and Arc paths, unshadowed prelude Box, and
lexically selected explicit standard imports permit pointer unwrapping. A
custom qualified wrapper, local Box, foreign Rc import, or nested custom
wrapper cannot borrow User’s methods, and a same-file `Box` keeps its own
`save` target. Any visible glob conservatively blocks prelude Box inference.
Normalization without declaration-scope evidence keeps the wrapper rather than
introducing a new dereference guess.
### Pointer dispatch precision
Flattening a pointer field loses Rust's ordered autoderef lookup. A pointer's
own `as_ref` or a local trait implementation on `Rc<User>` must take precedence
over an identically named method on `User`. The field now stays wrapped when
its pointee declares a method in the conservative Rust 1.99.1 pointer-member
inventory, or when local trait implementations make pointer ownership opaque.
This guard deliberately declines the entire field: a valid `User.save()` call
on a field whose `User` also declares `as_ref()` remains unresolved. Separate
controls retain all three pointer kinds' unconflicted `SafeUser.save()` edges.
The inventory includes conditional and blanket implementations; it is a refusal
guard, not a replacement for Rust's method selection.
Absolute paths do not bypass an `extern crate ... as std/alloc` claim. Field
bindings carried into assigned locals retain their declaration scope and
position. The safety prepass is linear in declarations; it reuses retained
trees within the existing 16 MiB source budget and adds a parse pass when trees
cannot be retained.
### Workspace-wide opacity
Impls, traits and macro expansions outside the parsed declaration model can
change which method Rust selects. Every pointer field in the analyzed
workspace therefore stays wrapped when any Rust file contains one of these:
- an impl whose self type cannot be resolved;
- a non-standard import, including a glob, that does not resolve to workspace
declarations;
- a `macro_rules!` definition, an item-position or qualified macro call,
`include!`, or a macro whose input contains `impl` or another macro call;
- an attribute outside the known compiler attributes, or a derive outside the
nine built-in derives.
One such line can suppress otherwise valid pointer-field calls in another
file. A crate with any unresolved dependency import or any `macro_rules!` gets
no pointer-field unwrapping, and expected-limit tests pin both cases.
`derive(Clone)` and a simple `println!` expression keep their positive
controls. This is a conservative field-typing guard, not Rust method lookup or
macro expansion.
### Pointer field validation
Native probes reproduced each guarded wrong edge before its fix: six ownership
negatives, eight pointer-dispatch edges (pointer methods, qualified pointees, a
crate alias and local trait impls), four opaque-dispatch cases and six
macro-expansion cases. The old main normalizer emits none of them.
After merging main `c30449394` normally, the combined tree passes a fresh
package build, both pinned TypeScript configurations, lint with zero errors,
and 351 tests in 13 Rust test files, including main's qualified-factory,
call-return and capture-golden checks. The regenerated receiver baseline keeps
main's counts (132 call drops, 160 drops of all kinds, 28 reads and no writes)
and differs from main only in `rust.decoratedFieldType`. The 5,501-record
resolution corpus is byte-identical to main `c30449394`. Exact-head hosted CI
remains required.
## PHP producer ownership boundary
Functions declared in multi-namespace files remain ambiguity evidence, but
@ -1209,3 +1288,21 @@ return-result typing; its pre-existing direct CALLS behavior is outside this fix
Main c30449394 is merged normally. Package-factory fallback declines inherited receivers whose captured bases cannot be re-established through current bindings and the canonical MRO. Global qualified names need one declared type; declaration-local facts remain available. Companions need captured proof that their header declares no supertype, and that proof survives the worker/cache carrier. Older carriers retain companion identity but supply no such proof. These conservative boundaries do not model a build-system classpath or complete companion inheritance.
The integrated build, both TypeScript configurations, 345 tests across ten files, Kotlin capture fingerprint and regenerated receiver check pass. The baseline remains 136 call drops, 166 drops across all kinds, 30 reads and zero writes, with unchanged shape states. The 5,521-record corpus is byte-identical to the candidate before the companion-header fix. These tests overlap earlier runs. Exact-head hosted CI remains required.
### Rust pointer fields on the prepared C#/PHP/Kotlin stack
The reviewed C# parent `b74fe72b8` is merged normally, retaining PHP
`6149ce156` and Kotlin `d5a7c73fa`. The Rust implementation is byte-identical
to `1da61e025`. A fresh package build, both pinned TypeScript configurations,
and 351 tests across 13 Rust files pass on this combined source. The Rust
capture fingerprint remains `19045ed3d4ee9fce`, with 207 fixture files and
3,699 groups; its measured scaling ratio is 0.989 within the unchanged budget.
The regenerated receiver check preserves the parent's count arm: 138 call
drops, 168 drops across all kinds, 30 reads and no writes. Its only shape-state
change against that parent is `rust.decoratedFieldType` from VISIBLE-GAP to
RESOLVES. The 5,559-record resolution corpus is byte-identical to the C#
parent, with no additions or removals. These fixture results do not measure
real-repository usefulness or pre-pass cost. Exact-head hosted CI remains
required.

View file

@ -147,7 +147,7 @@
"indexElement": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "RESOLVES",
"decoratedFieldType": "VISIBLE-GAP"
"decoratedFieldType": "RESOLVES"
},
"c": {
"plainChain": "N/A",

View file

@ -11,7 +11,7 @@ const ENUM_VARIANT_NAMES = new Set(['Some', 'None', 'Ok', 'Err']);
// the principal trait name. Reference/pointer sigils are stripped by the
// caller first; wrapper unwrapping (Box<T> etc.) runs before this so the
// unwrapped inner text still gets the same treatment.
function stripDynBound(t: string): string {
export function stripDynBound(t: string): string {
if (!DYN_PREFIX_RE.test(t)) return t;
t = t.replace(DYN_PREFIX_RE, '');
const plus = t.indexOf('+');

View file

@ -0,0 +1,231 @@
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { findClassBindingInScope, lookupNameClaim } from '../../scope-resolution/scope/walkers.js';
// Conservative receiver-method inventory from Rust 1.99.1, including conditional
// and blanket implementations. An overlap keeps the whole field wrapped until
// dispatch can preserve ordered autoderef; this is not a method-selection table.
// https://doc.rust-lang.org/1.99.1/std/boxed/struct.Box.html
// https://doc.rust-lang.org/1.99.1/std/rc/struct.Rc.html
// https://doc.rust-lang.org/1.99.1/std/sync/struct.Arc.html
const POINTER_MEMBER_NAMES: ReadonlySet<string> = new Set(
`
advance_back_by advance_by all allocate allocate_zeroed any array_chunks as_fd as_handle
as_mut as_raw_fd as_ref as_socket as_utf8_pattern assume_init async_call async_call_mut
async_call_once borrow borrow_mut by_ref bytes call call_mut call_once cause chain clamp
clone clone_from clone_into clone_to_uninit cloned cmp cmp_by collect collect_into
consume copied count cycle deallocate deref deref_mut description downcast
downcast_unchecked drop enumerate eq eq_by extend extend_one extend_reserve fill_buf
filter filter_map find find_map finish flat_map flatten flush fmt fold for_each fuse ge
grow grow_zeroed gt has_data_left hash inspect intersperse intersperse_with into
into_array into_async_iter into_future into_iter into_searcher is_contained_in is_empty
is_partitioned is_prefix_of is_read_vectored is_sorted is_sorted_by is_sorted_by_key
is_suffix_of is_write_vectored last le len lines lt map map_while map_windows max max_by
max_by_key min min_by min_by_key ne next next_back next_chunk next_chunk_back nth
nth_back partial_cmp partial_cmp_by partition partition_in_place peekable pin_drop poll
poll_next position product provide read read_array read_be read_buf read_buf_exact
read_exact read_le read_line read_to_end read_to_string read_until read_vectored reduce
resume rev rewind rfind rfold rposition scan seek seek_relative share shrink size_hint
skip skip_until skip_while source split step_by stream_len stream_position
strip_prefix_of strip_suffix_of sum take take_while to_owned to_string try_collect
try_find try_fold try_for_each try_into try_reduce try_rfold type_id unzip write
write_all write_all_vectored write_fmt write_i128 write_i16 write_i32 write_i64 write_i8
write_isize write_length_prefix write_str write_u128 write_u16 write_u32 write_u64
write_u8 write_usize write_vectored zip
`
.trim()
.split(/\s+/),
);
const POINTER_NAMES = new Set(['Box', 'Rc', 'Arc']);
// Compiler attributes that cannot introduce arbitrary dispatch implementations.
// cfg_attr and procedural/tool attributes deliberately remain opaque.
const COMPILER_ATTRIBUTES = new Set([
'allow',
'warn',
'deny',
'forbid',
'expect',
'cfg',
'cold',
'inline',
'must_use',
'repr',
'non_exhaustive',
'deprecated',
'doc',
'test',
'ignore',
'should_panic',
'track_caller',
'no_mangle',
'export_name',
'link',
'link_name',
'link_section',
'used',
'unsafe',
'default',
'automatically_derived',
'derive',
]);
const COMPILER_DERIVES = new Set([
'Clone',
'Copy',
'Debug',
'Default',
'PartialEq',
'Eq',
'PartialOrd',
'Ord',
'Hash',
]);
export interface RustPointerFieldSafety {
readonly externCrateAliases: ReadonlySet<string>;
readonly opaqueWrappers: ReadonlySet<string>;
readonly opaquePointees: ReadonlySet<string>;
readonly membersByType: ReadonlyMap<string, ReadonlySet<string>>;
}
/** Index declaration evidence once, before any pointer fields are flattened. */
export function buildRustPointerFieldSafety(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
rootFor: (parsed: ParsedFile) => SyntaxNode | null,
scopeFor: (parsed: ParsedFile, node: SyntaxNode) => ScopeId | undefined,
): RustPointerFieldSafety {
const externCrateAliases = new Set<string>();
const opaqueWrappers = new Set<string>();
const opaquePointees = new Set<string>();
const membersByType = new Map<string, Set<string>>();
const typeDef = (
parsed: ParsedFile,
node: SyntaxNode,
name: string,
): SymbolDefinition | undefined => {
const scope = scopeFor(parsed, node);
return scope === undefined
? undefined
: findClassBindingInScope(scope, name, indexes, undefined, {
uniqueQualifiedNameFallback: false,
purpose: 'type',
});
};
for (const parsed of parsedFiles) {
const root = rootFor(parsed);
if (root === null) continue;
// An unresolved non-standard import may bring a blanket extension trait.
// Macro definitions may emit impls absent from the parsed declaration model.
// Neither supplies enough dispatch evidence to erase a wrapper safely.
const hasUnknownImport = parsed.parsedImports.some((entry) => {
const importedRoot = entry.targetRaw.replace(/^::/, '').split('::')[0]!;
if (['std', 'core', 'alloc', 'self', 'super', 'crate'].includes(importedRoot)) return false;
if (!('localName' in entry)) return true;
const claim = lookupNameClaim(
entry.declaredAtScope ?? parsed.moduleScope,
entry.localName,
indexes,
{ purpose: 'type' },
);
return claim.imports.length === 0 || claim.imports.some((edge) => edge.targetFile === null);
});
const hasOpaqueAttribute = root
.descendantsOfType(['attribute_item', 'inner_attribute_item'])
.some((attribute) => {
const name = /^#!?\[\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(|=|\])/.exec(attribute.text)?.[1];
if (name === undefined || !COMPILER_ATTRIBUTES.has(name)) return true;
if (name !== 'derive') return false;
const args = /^#!?\[\s*derive\s*\(([\s\S]*)\)\s*\]$/.exec(attribute.text)?.[1];
return (
args === undefined ||
args
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.some((part) => !COMPILER_DERIVES.has(part))
);
});
const hasOpaqueInvocation = root.descendantsOfType('macro_invocation').some((macro) => {
const name = macro.childForFieldName('macro')?.text ?? '';
const parent = macro.parent;
const owner = parent?.type === 'expression_statement' ? parent.parent : parent;
if (
name.includes('::') ||
name === 'include' ||
owner?.type === 'source_file' ||
owner?.type === 'declaration_list'
)
return true;
const tokens = macro.namedChildren.find((child) => child.type === 'token_tree')?.text ?? '';
// Macro token trees are not parsed as declarations. Reject hidden impls
// and nested invocations rather than guessing what their expansion emits.
return /\bimpl\b|\b[A-Za-z_][A-Za-z0-9_]*\s*!/.test(tokens);
});
if (
hasUnknownImport ||
hasOpaqueAttribute ||
hasOpaqueInvocation ||
root.descendantsOfType('macro_definition').length > 0
) {
for (const pointer of POINTER_NAMES) opaqueWrappers.add(pointer);
}
for (const declaration of root.descendantsOfType('extern_crate_declaration')) {
const alias = declaration.childForFieldName('alias')?.text;
if (alias !== undefined && alias !== declaration.childForFieldName('name')?.text)
externCrateAliases.add(alias);
}
for (const node of root.descendantsOfType(['impl_item', 'trait_item'])) {
const isImpl = node.type === 'impl_item';
const written = node.childForFieldName(isImpl ? 'type' : 'name')?.text;
if (written === undefined) continue;
const base = written
.replace(/^&\s*(?:'[\w]+\s+)?(?:mut\s+)?/, '')
.split('<')[0]!
.trim();
const definition = typeDef(parsed, node, base);
if (isImpl && node.childForFieldName('trait') !== null) {
const tail = base.split('::').at(-1)!;
if (POINTER_NAMES.has(tail)) opaqueWrappers.add(tail);
else if (definition !== undefined) opaquePointees.add(definition.nodeId);
else for (const pointer of POINTER_NAMES) opaqueWrappers.add(pointer);
}
if (definition === undefined) {
// Qualified inherent impls can still attach methods downstream even
// when their self type cannot be proved here. Do not flatten through it.
if (isImpl) for (const pointer of POINTER_NAMES) opaqueWrappers.add(pointer);
continue;
}
const members = membersByType.get(definition.nodeId) ?? new Set<string>();
const body = node.childForFieldName('body');
for (const member of body?.namedChildren ?? []) {
if (member.type !== 'function_item' && member.type !== 'function_signature_item') continue;
const name = member.childForFieldName('name')?.text;
if (name !== undefined) members.add(name);
}
membersByType.set(definition.nodeId, members);
}
}
return { externCrateAliases, opaqueWrappers, opaquePointees, membersByType };
}
/** No qualified-tail guessing, projections or callable types after autoderef. */
export function canFlattenRustPointerTarget(
target: string,
scope: ScopeId,
indexes: ScopeResolutionIndexes,
safety: RustPointerFieldSafety,
): boolean {
if (target.includes('::') || /[()[\]]/.test(target) || target.startsWith('<')) return false;
const base = target.split('<')[0]!.trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(base)) return false;
const definition = findClassBindingInScope(scope, base, indexes, undefined, {
uniqueQualifiedNameFallback: false,
purpose: 'type',
});
if (definition === undefined || safety.opaquePointees.has(definition.nodeId)) return false;
for (const member of safety.membersByType.get(definition.nodeId) ?? []) {
if (POINTER_MEMBER_NAMES.has(member)) return false;
}
return true;
}

View file

@ -1,12 +1,18 @@
import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared';
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
import { lookupLexicalName } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getRustParser } from './query.js';
import { stripDynBound } from './interpret.js';
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, lookupNameClaim } from '../../scope-resolution/scope/walkers.js';
import {
buildRustPointerFieldSafety,
canFlattenRustPointerTarget,
type RustPointerFieldSafety,
} from './pointer-field-safety.js';
/**
* Populate type bindings for patterns and iterators that the tree-sitter
@ -147,6 +153,12 @@ export function populateRustRangeBindings(
}
const treeStore: Map<string, RustTree> | undefined =
totalSourceBytes <= TREE_REUSE_SOURCE_BUDGET_BYTES ? new Map() : undefined;
const pointerSafety = buildRustPointerFieldSafety(
parsedFiles,
indexes,
(parsed) => getOrParseTree(parser, parsed.filePath, ctx, treeStore)?.rootNode ?? null,
(parsed, node) => lexicalScopeForNode(node, scopeRangesByFile.get(parsed.filePath)!)?.id,
);
for (const parsed of parsedFiles) {
const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
@ -211,10 +223,25 @@ export function populateRustRangeBindings(
const fieldName = field.childForFieldName('name');
const fieldType = field.childForFieldName('type');
if (fieldName !== null && fieldType !== null) {
fields.set(fieldName.text, normalizeFieldType(fieldType.text));
fields.set(
fieldName.text,
normalizeFieldType(
fieldType.text,
parsed,
indexes,
declarationScope?.id,
pointerSafety,
),
);
if (declarationScope !== undefined) {
fieldRefs.set(fieldName.text, {
rawName: normalizeFieldType(fieldType.text),
rawName: normalizeFieldType(
fieldType.text,
parsed,
indexes,
declarationScope.id,
pointerSafety,
),
declaredSpelling: fieldType.text,
declaredAtScope: declarationScope.id,
lookupPosition: {
@ -248,7 +275,7 @@ export function populateRustRangeBindings(
// Publish per-type member bindings for the whole workspace before resolving
// assignments. Otherwise an importer processed before its defining file can
// miss a field or identity-method type solely because of file order.
processFieldTypeBindings(tree.rootNode, parsed, scopeMap);
processFieldTypeBindings(tree.rootNode, parsed, scopeMap, indexes, pointerSafety);
processIdentityMethodBindings(parsed);
}
@ -302,6 +329,8 @@ function processFieldTypeBindings(
root: SyntaxNode,
parsed: ParsedFile,
scopeMap: ReadonlyMap<string, Scope>,
indexes: ScopeResolutionIndexes,
pointerSafety: RustPointerFieldSafety,
): void {
for (const structNode of root.descendantsOfType('struct_item')) {
const nameNode = structNode.childForFieldName('name');
@ -318,8 +347,24 @@ function processFieldTypeBindings(
const fieldType = field.childForFieldName('type');
if (fieldName === null || fieldType === null) continue;
const normalizedType = normalizeFieldType(fieldType.text);
injectTypeBinding(structScope, fieldName.text, normalizedType);
const normalizedType = normalizeFieldType(
fieldType.text,
parsed,
indexes,
structScope.id,
pointerSafety,
);
injectTypeBinding(structScope, fieldName.text, {
rawName: normalizedType,
declaredSpelling: fieldType.text,
declaredAtScope: structScope.id,
lookupPosition: {
startLine: fieldType.startPosition.row + 1,
startCol: fieldType.startPosition.column,
},
lookupPurpose: 'type',
source: 'annotation',
});
}
}
}
@ -341,14 +386,159 @@ function findScopeForNode(
return null;
}
function normalizeFieldType(text: string): string {
/**
* Owning pointers that auto-deref on a method call: `self.repo.save()` on a
* `Box<User>`, `Rc<User>` or `Arc<User>` field reaches `User::save` with no
* explicit step, so the field is typed as its argument.
*
* Interior-mutability and container wrappers are left as written on purpose.
* `RefCell`, `Cell`, `Mutex` and `RwLock` need `.borrow()`, `.get()`,
* `.lock()` or `.read()` first, `Option` needs a match or an unwrap, and
* `Vec` needs an index or an iterator, so the receiver's type really is the
* wrapper. Element access into those goes through `unwrapGeneric`.
*/
const DEREF_TRANSPARENT_POINTERS: ReadonlySet<string> = new Set(['Box', 'Rc', 'Arc']);
const STANDARD_POINTER_PATHS: ReadonlySet<string> = new Set([
'std::boxed::Box',
'alloc::boxed::Box',
'std::rc::Rc',
'alloc::rc::Rc',
'std::sync::Arc',
'alloc::sync::Arc',
]);
/** A familiar terminal name alone does not establish standard-library ownership. */
function isStandardPointer(
spelling: string,
parsed: ParsedFile,
indexes: ScopeResolutionIndexes,
scopeId: ScopeId,
externCrateAliases: ReadonlySet<string>,
): boolean {
const absolute = spelling.startsWith('::');
const path = absolute ? spelling.slice(2) : spelling;
const root = path.split('::')[0];
if (externCrateAliases.has(root)) return false;
const claim = lookupNameClaim(scopeId, root, indexes, { purpose: 'type' });
if (path.includes('::')) {
return STANDARD_POINTER_PATHS.has(path) && (absolute || claim.status === 'absent');
}
if (!DEREF_TRANSPARENT_POINTERS.has(path)) return false;
if (claim.status === 'absent') {
if (path !== 'Box') return false;
// An unresolved glob can shadow the prelude without a materialized binding.
const ancestors = new Set<ScopeId>();
let current: ScopeId | null = scopeId;
while (current !== null && !ancestors.has(current)) {
ancestors.add(current);
current = indexes.scopeTree.getScope(current)?.parent ?? null;
}
return !parsed.parsedImports.some(
(entry) =>
entry.kind === 'wildcard' &&
(entry.declaredAtScope === undefined || ancestors.has(entry.declaredAtScope)),
);
}
if (claim.bindings.length > 0 || claim.imports.length === 0 || claim.scope === undefined)
return false;
const owner = claim.scope;
const imports = parsed.parsedImports.filter(
(entry) =>
'localName' in entry &&
entry.localName === path &&
(entry.declaredAtScope === owner.id ||
(entry.declaredAtScope === undefined && owner.kind === 'Module')) &&
claim.imports.some(
(edge) =>
edge.atRange !== undefined &&
entry.atRange !== undefined &&
edge.atRange.startLine === entry.atRange.startLine &&
edge.atRange.startCol === entry.atRange.startCol,
),
);
return (
imports.length > 0 &&
claim.imports.every((edge) => edge.targetFile === null) &&
imports.every((entry) => {
const importedPath = entry.targetRaw.replace(/^::/, '');
return (
STANDARD_POINTER_PATHS.has(importedPath) &&
!externCrateAliases.has(importedPath.split('::')[0]!) &&
(entry.targetRaw.startsWith('::') ||
lookupNameClaim(owner.id, importedPath.split('::')[0], indexes, { purpose: 'type' })
.status === 'absent')
);
})
);
}
/** `&T`, `&mut T`, `&'a T`, `&'a mut T`. A struct field that borrows always
* carries a lifetime, so the lifetime form is the common one. */
const BORROW_PREFIX_RE = /^&\s*(?:'[A-Za-z_][A-Za-z0-9_]*\s+)?(?:mut\s+)?/;
function stripBorrow(text: string): string {
let t = text.trim();
if (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, '');
while (t.startsWith('&')) t = t.replace(BORROW_PREFIX_RE, '').trim();
return t;
}
/** Final `::` segment: `std::rc::Rc` -> `Rc`. */
function lastPathSegment(text: string): string {
const lastColon = text.lastIndexOf('::');
return lastColon === -1 ? text : text.slice(lastColon + 2);
}
/** `Box<User>` / `std::rc::Rc<Box<User>>` -> `User`; anything else as is. */
function unwrapDerefPointers(text: string, ownsPointer: (spelling: string) => boolean): string {
let t = stripBorrow(text);
for (;;) {
const open = t.indexOf('<');
if (open === -1 || !t.endsWith('>')) return t;
if (!ownsPointer(t.slice(0, open).trim())) return t;
const args = t.slice(open + 1, -1).trim();
const comma = findTopLevelComma(args);
t = stripBorrow(comma === -1 ? args : args.slice(0, comma));
}
}
function normalizeFieldType(
text: string,
parsed?: ParsedFile,
indexes?: ScopeResolutionIndexes,
scopeId?: ScopeId,
pointerSafety?: RustPointerFieldSafety,
): string {
// `Box<dyn Repo + Send>` dispatches on the trait, the same as that spelling
// on a parameter (#2604).
let unwrapped = false;
let declined = false;
let t = stripDynBound(
unwrapDerefPointers(text, (spelling) => {
if (
parsed === undefined ||
indexes === undefined ||
scopeId === undefined ||
pointerSafety === undefined ||
!isStandardPointer(spelling, parsed, indexes, scopeId, pointerSafety.externCrateAliases)
)
return false;
if (pointerSafety.opaqueWrappers.has(lastPathSegment(spelling))) {
declined = true;
return false;
}
unwrapped = true;
return true;
}),
);
if (
declined ||
(unwrapped && !canFlattenRustPointerTarget(t, scopeId!, indexes!, pointerSafety!))
)
return stripBorrow(text);
const bracket = t.indexOf('<');
if (bracket !== -1) t = t.slice(0, bracket);
const lastColon = t.lastIndexOf('::');
if (lastColon !== -1) t = t.slice(lastColon + 2);
return t.trim();
return lastPathSegment(t.trim()).trim();
}
/** Get-or-create the inner map for `file` and record `name -> value`. */
@ -894,7 +1084,7 @@ function findFieldTypeAcrossFiles(
structName: string,
fieldName: string,
allParsedFiles: readonly ParsedFile[],
): string | null {
): TypeRef | null {
for (const pf of allParsedFiles) {
for (const scope of pf.scopes) {
if (scope.kind !== 'Class') continue;
@ -903,7 +1093,7 @@ function findFieldTypeAcrossFiles(
);
if (!hasDef) continue;
const tb = scope.typeBindings.get(fieldName);
if (tb !== undefined) return tb.rawName;
if (tb !== undefined) return tb;
}
}
return null;

View file

@ -1,8 +1,10 @@
/**
* Rust: trait implementations + ambiguous module import disambiguation
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
FIXTURES,
CROSS_FILE_FIXTURES,
@ -13,6 +15,7 @@ import {
findDanglingEdges,
edgeSet,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
type RelEdge,
} from './helpers.js';
@ -1503,6 +1506,160 @@ describe('Field type resolution (Rust)', () => {
});
});
// ---------------------------------------------------------------------------
// Smart-pointer and borrowed struct fields as call receivers (#3498)
//
// The struct-field normalizer cut everything from the first `<`, so a
// `Box<User>` field was typed `Box` and `self.repo.save()` bound nothing. It
// also stripped `&` but not a lifetime, so `&'a User` came out as `'a User`.
// Box, Rc and Arc auto-deref on a method call, so they now unwrap to their
// argument. RefCell, Cell, Mutex, RwLock, Option and Vec need `.borrow()`,
// `.get()`, `.lock()`, `.read()`, a match or an index first, so a direct call
// on them must still bind nothing. `Address` and `User` both declare `save`,
// so a by-name fallback cannot pass the positive rows.
// ---------------------------------------------------------------------------
describe('Rust smart-pointer field receivers (#3498)', () => {
let result: PipelineResult;
let root: string;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-rust-pointer-fields-'));
writeFixtureRepo(root, {
'address.rs': `pub struct Address;
impl Address {
pub fn save(&self) {}
}
`,
'user.rs': `pub struct User;
impl User {
pub fn save(&self) {}
}
`,
'saver.rs': `pub trait Saver {
fn persist(&self);
}
`,
'main.rs': `mod address;
mod saver;
mod user;
use saver::Saver;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::{Arc, Mutex, RwLock};
use user::User;
struct Holder<'a> {
boxed: Box<User>,
rc: Rc<User>,
arc: Arc<User>,
qualified_rc: std::rc::Rc<User>,
nested: Rc<Box<User>>,
borrowed: &'a User,
borrowed_mut: &'a mut User,
dyn_boxed: Box<dyn Saver>,
plain: User,
ref_cell: RefCell<User>,
cell: Cell<User>,
mutex: Mutex<User>,
rw_lock: RwLock<User>,
option: Option<User>,
vec: Vec<User>,
arc_ref_cell: Arc<RefCell<User>>,
}
impl<'a> Holder<'a> {
fn via_boxed(&self) { self.boxed.save(); }
fn via_rc(&self) { self.rc.save(); }
fn via_arc(&self) { self.arc.save(); }
fn via_qualified_rc(&self) { self.qualified_rc.save(); }
fn via_nested(&self) { self.nested.save(); }
fn via_borrowed(&self) { self.borrowed.save(); }
fn via_borrowed_mut(&self) { self.borrowed_mut.save(); }
fn via_dyn_boxed(&self) { self.dyn_boxed.persist(); }
fn via_plain(&self) { self.plain.save(); }
fn via_ref_cell(&self) { self.ref_cell.save(); }
fn via_cell(&self) { self.cell.save(); }
fn via_mutex(&self) { self.mutex.save(); }
fn via_rw_lock(&self) { self.rw_lock.save(); }
fn via_option(&self) { self.option.save(); }
fn via_vec(&self) { self.vec.save(); }
fn via_arc_ref_cell(&self) { self.arc_ref_cell.save(); }
}
fn via_boxed_param(u: Box<User>) { u.save(); }
fn main() {}
`,
});
result = await runPipelineFromRepo(root, () => {});
}, 60000);
afterAll(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
});
it.each([
{ caller: 'via_boxed', spelling: 'Box<User>', member: 'save', targets: ['Function:user.rs'] },
{ caller: 'via_rc', spelling: 'Rc<User>', member: 'save', targets: ['Function:user.rs'] },
{ caller: 'via_arc', spelling: 'Arc<User>', member: 'save', targets: ['Function:user.rs'] },
{
caller: 'via_qualified_rc',
spelling: 'std::rc::Rc<User>',
member: 'save',
targets: ['Function:user.rs'],
},
{
caller: 'via_nested',
spelling: 'Rc<Box<User>>',
member: 'save',
targets: ['Function:user.rs'],
},
{ caller: 'via_borrowed', spelling: "&'a User", member: 'save', targets: ['Function:user.rs'] },
{
caller: 'via_borrowed_mut',
spelling: "&'a mut User",
member: 'save',
targets: ['Function:user.rs'],
},
{
caller: 'via_dyn_boxed',
spelling: 'Box<dyn Saver>',
member: 'persist',
targets: ['Function:saver.rs'],
},
{
caller: 'via_plain',
spelling: 'User (control)',
member: 'save',
targets: ['Function:user.rs'],
},
{
caller: 'via_boxed_param',
spelling: 'Box<User> parameter (control)',
member: 'save',
targets: ['Function:user.rs'],
},
{ caller: 'via_ref_cell', spelling: 'RefCell<User>', member: 'save', targets: [] },
{ caller: 'via_cell', spelling: 'Cell<User>', member: 'save', targets: [] },
{ caller: 'via_mutex', spelling: 'Mutex<User>', member: 'save', targets: [] },
{ caller: 'via_rw_lock', spelling: 'RwLock<User>', member: 'save', targets: [] },
{ caller: 'via_option', spelling: 'Option<User>', member: 'save', targets: [] },
{ caller: 'via_vec', spelling: 'Vec<User>', member: 'save', targets: [] },
{ caller: 'via_arc_ref_cell', spelling: 'Arc<RefCell<User>>', member: 'save', targets: [] },
])(
'resolves $member called from $caller through $spelling to $targets',
({ caller, member, targets }) => {
const actual = getRelationships(result, 'CALLS')
.filter((edge) => edge.source === caller && edge.target === member)
.map((edge) => `${edge.targetLabel}:${edge.targetFilePath}`);
expect(actual).toEqual(targets);
},
);
});
// ---------------------------------------------------------------------------
// Phase 8B: Deep field chain resolution (3-level)
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { writeFixtureRepo, runPipelineFromRepo, getRelationships } from './resolvers/helpers.js';
const cases = [
['dependency macro', 'ext::declare_describe!();', false],
['macro-use dependency', '#[macro_use] extern crate ext; declare_describe!();', false],
['attribute macro', '#[ext::declare_describe] struct Marker;', false],
['derive macro', '#[derive(ext::Describe)] struct Marker;', false],
['include-generated items', 'include!("generated.rs");', false],
[
'nested dependency macro',
'fn seed(){ println!("{}", { ext::declare_describe!(); 0 }); }',
false,
],
// Expected limits: unrelated opaque evidence disables inference workspace-wide.
['unrelated external import', 'use serde::Serialize;', false],
['unrelated macro definition', 'macro_rules! noop { () => {}; }', false],
['standard derive', '#[derive(Clone)] struct Marker;', true],
['standard macro expression', 'fn seed(){ println!("seed"); }', true],
] as const;
describe('Rust pointer fields near macro expansion', () => {
it.each(cases)(
'%s keeps expansion-sensitive pointer ownership',
async (_name, prefix, positive) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pointer-expansion-'));
try {
writeFixtureRepo(root, {
'src/lib.rs': `use std::rc::Rc;
trait Describe { fn describe(&self); }
${prefix}
struct User; impl User { fn describe(&self) {} }
struct Other; impl Other { fn describe(&self) {} }
struct Holder { repo: Rc<User> }
fn probe(h: &Holder) { h.repo.describe(); }
`,
});
const r = await runPipelineFromRepo(root, () => {});
const targets = getRelationships(r, 'CALLS')
.filter((e) => e.source === 'probe')
.map((e) => e.rel.targetId);
expect(targets).toEqual(positive ? ['Function:src/lib.rs:User.describe#0'] : []);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
},
180000,
);
});

View file

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { writeFixtureRepo, runPipelineFromRepo, getRelationships } from './resolvers/helpers.js';
const cases = [
[
'super inherent',
'use std::rc::Rc;\nstruct User; impl User { fn save(&self) {} }\nmod ext { impl super::User { pub fn as_ref(&self) {} } }\nstruct Holder { repo: Rc<User> }\nimpl Holder { fn probe(&self) { self.repo.as_ref(); } }',
],
[
'crate inherent',
'use std::rc::Rc;\nstruct User; impl User { fn save(&self) {} }\nmod ext { impl crate::User { pub fn as_ref(&self) {} } }\nstruct Holder { repo: Rc<User> }\nimpl Holder { fn probe(&self) { self.repo.as_ref(); } }',
],
[
'declined nested chain',
'use std::rc::Rc;\nstruct User;impl User { fn as_ref(&self) {} }\nstruct Holder { repo: Rc<User> }\nstruct Outer { holder: Holder }\nfn probe(o: &Outer) { o.holder.repo.as_ref(); }',
],
[
'unresolved external trait',
'use std::rc::Rc;\nuse external::Describe;\nstruct User; impl User { fn describe(&self) {} }\nstruct Holder { repo: Rc<User> }\nimpl Holder { fn probe(&self) { self.repo.describe(); } }',
],
[
'macro blanket trait',
'use std::rc::Rc;\nmacro_rules! declare_describe { () => { trait Describe { fn describe(&self); } impl<T> Describe for T { fn describe(&self) {} } } }\ndeclare_describe!();\nstruct User; impl User { fn describe(&self) {} }\nstruct Holder { repo: Rc<User> }\nimpl Holder { fn probe(&self) { self.repo.describe(); } }',
],
] as const;
describe('Rust pointer fields with opaque dispatch evidence', () => {
it.each(cases)(
'%s does not invent pointee dispatch',
async (_name, source) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pointer-synthesis-'));
try {
writeFixtureRepo(root, {
'src/lib.rs':
source +
'\nstruct Unrelated; impl Unrelated { fn as_ref(&self) {} fn describe(&self) {} }',
});
const r = await runPipelineFromRepo(root, () => {});
const edges = getRelationships(r, 'CALLS').filter((e) => e.source === 'probe');
expect(edges.map((e) => e.rel.targetId)).toEqual([]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
},
180000,
);
});

View file

@ -0,0 +1,154 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './resolvers/helpers.js';
const roots: string[] = [];
afterAll(() => {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
});
async function analyze(files: Record<string, string>): Promise<PipelineResult> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-pointer-dispatch-'));
roots.push(root);
writeFixtureRepo(root, files);
return runPipelineFromRepo(root, () => {}, {});
}
describe('pointer and pointee member ownership', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await analyze({
'main.rs': `use std::rc::Rc;
use std::sync::Arc;
struct User;
impl User { fn as_ref(&self) {} fn save(&self) {} }
struct SafeUser;
impl SafeUser { fn save(&self) {} }
struct Holder {
boxed: Box<User>, rc: Rc<User>, arc: Arc<User>,
safe_boxed: Box<SafeUser>, safe_rc: Rc<SafeUser>, safe_arc: Arc<SafeUser>
}
fn boxed_builtin(h: &Holder) { h.boxed.as_ref(); }
fn rc_builtin(h: &Holder) { h.rc.as_ref(); }
fn arc_builtin(h: &Holder) { h.arc.as_ref(); }
fn boxed_target(h: &Holder) { h.boxed.save(); }
fn rc_target(h: &Holder) { h.rc.save(); }
fn arc_target(h: &Holder) { h.arc.save(); }
fn safe_boxed(h: &Holder) { h.safe_boxed.save(); }
fn safe_rc(h: &Holder) { h.safe_rc.save(); }
fn safe_arc(h: &Holder) { h.safe_arc.save(); }
`,
});
}, 120000);
it.each(['boxed_builtin', 'rc_builtin', 'arc_builtin'])(
'%s never substitutes User.as_ref for a pointer method',
(name) => {
expect(getRelationships(result, 'CALLS').filter((e) => e.source === name)).toEqual([]);
},
);
it.each(['boxed_target', 'rc_target', 'arc_target'])(
'%s conservatively leaves other methods unresolved on a conflicted field',
(name) => {
// save() is legal Rust. Whole-field refusal deliberately loses this edge
// until dispatch can represent ordered autoderef for individual members.
expect(getRelationships(result, 'CALLS').filter((e) => e.source === name)).toEqual([]);
},
);
it.each(['safe_boxed', 'safe_rc', 'safe_arc'])(
'%s still resolves an unconflicted pointee method',
(name) => {
expect(
getRelationships(result, 'CALLS')
.filter((e) => e.source === name && e.target === 'save')
.map((e) => e.rel.targetId),
).toEqual(['Function:main.rs:SafeUser.save#0']);
},
);
});
describe('pointer declaration ownership', () => {
it('does not replace a qualified pointee with a local namesake', async () => {
const result = await analyze({
'src/lib.rs': `mod remote;
struct User;
impl User { fn save(&self) {} }
struct Holder { repo: Box<remote::User> }
impl Holder {
fn direct(&self) { self.repo.save(); }
fn assigned(&self) { let value = self.repo; value.save(); }
}
fn destructured(h: Holder) { let Holder { repo } = h; repo.save(); }
`,
'src/remote.rs': 'pub struct User; impl User { pub fn save(&self) {} }',
});
for (const name of ['direct', 'assigned', 'destructured']) {
expect(
getRelationships(result, 'CALLS').filter(
(e) => e.source === name && e.target === 'save' && e.targetFilePath === 'src/lib.rs',
),
).toEqual([]);
}
}, 120000);
it.each([
['standard crate', '', true],
[
'crate alias',
`#![no_std]
extern crate self as std;
pub mod boxed {
pub struct Box<T> { pub value: T }
impl<T> Box<T> { pub fn save(&self) {} }
}
`,
false,
],
] as const)(
'%s respects absolute wrapper ownership',
async (_name, prefix, standard) => {
const result = await analyze({
'src/lib.rs': `${prefix}
struct User;
impl User { fn save(&self) {} }
struct Holder { repo: ::std::boxed::Box<User> }
impl Holder { fn direct(&self) { self.repo.save(); } }
`,
});
const calls = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'direct' && e.rel.targetId === 'Function:src/lib.rs:User.save#0',
);
expect(calls).toHaveLength(standard ? 1 : 0);
},
120000,
);
it.each(['Rc<User>', '&Rc<User>'])(
'does not bypass a local trait implementation on %s',
async (receiver) => {
const result = await analyze({
'main.rs': `use std::rc::Rc;
trait Save { fn save(&self); }
struct User;
impl User { fn save(&self) {} }
impl Save for ${receiver} { fn save(&self) {} }
struct Holder { repo: Rc<User> }
fn probe(h: &Holder) { h.repo.save(); }
`,
});
expect(
getRelationships(result, 'CALLS').filter(
(e) => e.source === 'probe' && e.rel.targetId === 'Function:main.rs:User.save#0',
),
).toEqual([]);
},
120000,
);
});

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './resolvers/helpers.js';
const custom = (name: string) =>
`mod custom { pub struct ${name}<T> { pub value:T } impl<T> ${name}<T> { pub fn save(&self) {} } }`;
const localBox = `struct Box<T> { value:T } impl<T> Box<T> { fn save(&self) {} }`;
describe('Rust field pointer ownership', () => {
it.each([
['custom Box', 'custom::Box<User>', custom('Box'), ['Function:src/lib.rs:custom.Box.save#0']],
['custom Rc', 'custom::Rc<User>', custom('Rc'), ['Function:src/lib.rs:custom.Rc.save#0']],
['custom Arc', 'custom::Arc<User>', custom('Arc'), ['Function:src/lib.rs:custom.Arc.save#0']],
['shadowed prelude Box', 'Box<User>', localBox, ['Function:src/lib.rs:Box.save#0']],
['foreign imported Rc', 'Rc<User>', 'use foreign::Rc;', []],
['nested custom Box', 'std::boxed::Box<custom::Box<User>>', custom('Box'), []],
['std qualified Box', 'std::boxed::Box<User>', '', ['Function:src/lib.rs:User.save#0']],
['std qualified Rc', 'std::rc::Rc<User>', '', ['Function:src/lib.rs:User.save#0']],
['std qualified Arc', 'std::sync::Arc<User>', '', ['Function:src/lib.rs:User.save#0']],
['prelude Box', 'Box<User>', '', ['Function:src/lib.rs:User.save#0']],
['imported standard Rc', 'Rc<User>', 'use std::rc::Rc;', ['Function:src/lib.rs:User.save#0']],
[
'imported standard Arc',
'Arc<User>',
'use std::sync::Arc;',
['Function:src/lib.rs:User.save#0'],
],
] as const)(
'%s',
async (_label, type, declaration, expectedTargets) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-pointer-'));
try {
writeFixtureRepo(root, {
'Cargo.toml': '[package]\nname="pointer_probe"\nversion="0.1.0"\nedition="2021"\n',
'src/lib.rs': `${declaration}
struct User; impl User { fn save(&self) {} }
struct Decoy; impl Decoy { fn save(&self) {} }
struct Host { repo: ${type} }
impl Host { fn probe(&self) { self.repo.save(); } }
`,
});
const result = await runPipelineFromRepo(root, () => {}, {});
const targets = getRelationships(result, 'CALLS')
.filter((e) => e.source === 'probe')
.map((e) => e.rel.targetId);
expect(targets).toEqual(expectedTargets);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
},
120000,
);
});