diff --git a/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts index 4dd8ad527..79696f248 100644 --- a/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts @@ -82,19 +82,18 @@ export function goIsGlobalNameFallbackPlausible(ctx: { const cand = classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf); // Non-test files never see test-only declarations. if (cand.isTest && !caller.isTest) return false; - // An external test package and its tested package are different packages: - // a BARE name cannot cross that boundary in either direction. Undecidable - // (no package clause available) → allow. + // Different clauses require an explicit dot import, even in one directory. + // Fall through to the exported/import checks for external test packages. + // Missing source leaves package identity undecidable. if ( - caller.declared !== undefined && - cand.declared !== undefined && - caller.declared !== cand.declared + caller.declared === undefined || + cand.declared === undefined || + caller.declared === cand.declared ) - return false; - return true; + return true; } - // Different directory, so a different package — and a `_test.go` file's + // Different package — and a `_test.go` file's // declarations are compiled only into ITS OWN package's test binary. No other // package, test or not, can see them, exported or not. Decidable from the // path alone, so it comes before every exception below (the module-root diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index 2b68a3662..b16cb2ba1 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -1127,6 +1127,13 @@ export function emitJsScopeCaptures( fnNode, deriveDefaultExportHocName(filePath), ); + // This declaration's name is synthetic, so the later query-name + // marker cannot see it. The HOC predicate already proves the export. + grouped['@declaration.is-exported'] = syntheticCapture( + '@declaration.is-exported', + fnNode, + 'true', + ); } } diff --git a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts index c75869196..fbe1a64b9 100644 --- a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts @@ -8,21 +8,15 @@ * bare free call and never reaches this tier — it carries a qualified name and * is resolved earlier by `resolveQualifiedFreeCall`). * - * One rule therefore covers both halves the visibility question splits into: - * - * - A non-`pub` item cannot be `use`d from outside its module at all, so the - * absence of a covering `use` correctly refuses it. - * - A `pub` item is reachable, but only from a file that actually wrote the - * `use`, which is the same check. - * - * That is why this does not need to read the `pub` marker, which - * `SymbolDefinition` does not carry. It asks the decidable question — "did this - * file bring the name's module into scope?" — instead of the undecidable one. + * This hook checks import evidence, not Rust item visibility. A child module + * can use private ancestor items, and visibility restrictions such as + * `pub(crate)` require more context than `SymbolDefinition` carries. A matching + * import therefore keeps a labeled guess rather than proving accessibility. * * Module paths are matched against the candidate's FILE path (extension * stripped, and `mod`/`lib`/`main` stem dropped, since `a/b/mod.rs` IS module - * `a::b`). `use` targets are `::`-separated and `crate::`/`super::` prefixes - * contribute no segments, so suffix matching lines the two up. + * `a::b`). `crate::` names the root; `self::` and `super::` resolve relative + * to the caller's module before comparison. */ import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; @@ -39,7 +33,7 @@ const RUST_DIRECTORY_MODULE_STEMS: ReadonlySet = new Set(['mod', 'lib', const RUST_CRATE_ROOT_DIRS: ReadonlySet = new Set(['src', 'tests', 'benches', 'examples']); /** Path prefixes of a `use` that name a root rather than a module segment. */ -const RUST_USE_ROOT_PREFIXES: ReadonlySet = new Set(['crate', 'self', 'super', '$crate']); +const RUST_USE_ROOT_PREFIXES: ReadonlySet = new Set(['crate', '$crate']); /** * The module path a Rust file provides, as a `/`-joined path. @@ -57,9 +51,18 @@ function rustModulePathOf(filePath: string): string { return segments.join('/'); } -/** A `use` target with its root prefix dropped: `crate::a::b` → `a::b`. */ -function rustUsePathOf(targetRaw: string): string { +/** Resolve explicit relative prefixes against the caller's module path. */ +function rustUsePathOf(targetRaw: string, callerFilePath: string): string { const segments = targetRaw.split('::').filter((s) => s !== ''); + if (segments[0] === 'self' || segments[0] === 'super') { + const base = rustModulePathOf(callerFilePath).split('/').filter(Boolean); + if (segments[0] === 'self') segments.shift(); + while (segments[0] === 'super') { + base.pop(); + segments.shift(); + } + return [...base, ...segments].join('::'); + } while (segments.length > 0 && RUST_USE_ROOT_PREFIXES.has(segments[0]!)) segments.shift(); return segments.join('::'); } @@ -84,7 +87,7 @@ export function rustIsGlobalNameFallbackPlausible(ctx: { const candidateName = rustSimpleNameOf(ctx.candidate); for (const imp of ctx.callerParsed.parsedImports) { - const usePath = rustUsePathOf(imp.targetRaw); + const usePath = rustUsePathOf(imp.targetRaw, ctx.callerParsed.filePath); // Only a glob introduces every bare item of a module. A named import must // match both the candidate's original name and the call's local spelling. if (imp.kind === 'wildcard') { diff --git a/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts index fa8eafa4e..e35f80292 100644 --- a/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts @@ -10,8 +10,9 @@ * then only if the declaration is `public`. * * A module is approximated by its source directory, the layout every Swift - * package manifest produces: `Sources//…` and `Tests//…`. Files - * outside that layout fall back to their top-level directory. + * package manifest produces: `Sources//…` and `Tests//…`. + * `src//…` is also recognized by the package configuration loader. + * Files outside these layouts have unknown module identity. * * The `private` / `fileprivate` half of the rule is NOT implemented, because * neither marker is recoverable from the parse model this hook sees — @@ -24,21 +25,26 @@ import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import { modulePathReaches } from '../../scope-resolution/utils/name-fallback-visibility.js'; /** Directory names that hold one subdirectory PER TARGET rather than sources. */ -const SWIFT_TARGET_ROOTS: ReadonlySet = new Set(['Sources', 'Tests', 'sources', 'tests']); +const SWIFT_TARGET_ROOTS: ReadonlySet = new Set([ + 'Sources', + 'Tests', + 'sources', + 'tests', + 'src', +]); /** * The module (target) a Swift file belongs to. * - * `Sources/Core/User.swift` → `Core`. A path with no target root returns its - * first segment, so a flat repository still groups its files together instead - * of putting every file in its own module. + * `Sources/Core/User.swift` → `Core`. Arbitrary Xcode folders are not module + * boundaries; without a recognized target layout leave the answer unknown. */ function swiftModuleOf(filePath: string): string { const segments = filePath.split('/').filter((s) => s !== ''); - for (let i = 0; i < segments.length - 1; i++) { + for (let i = 0; i < segments.length - 2; i++) { if (SWIFT_TARGET_ROOTS.has(segments[i]!)) return segments[i + 1]!; } - return segments.length > 1 ? segments[0]! : ''; + return ''; } export function swiftIsGlobalNameFallbackPlausible(ctx: { diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index b0cc8fb9a..955b70a25 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -564,6 +564,13 @@ export function emitTsScopeCaptures( fnNode, deriveDefaultExportHocName(filePath), ); + // This declaration's name is synthetic, so the later query-name + // marker cannot see it. The HOC predicate already proves the export. + grouped['@declaration.is-exported'] = syntheticCapture( + '@declaration.is-exported', + fnNode, + 'true', + ); } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts b/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts index d2dd8f8a9..be2d43201 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts @@ -1,7 +1,8 @@ /** - * Per-language census of the global-name fallback: how many CALLS edges rest on - * a unique-name guess, and how many guesses each language's visibility rules - * refused. + * Per-language census of global-name fallback decisions: how many call sites + * used a unique-name guess, and how many candidates visibility rules refused. + * Sites are counted before edge coalescing: a precisely bound site can prove + * the same caller/target dependency, so this is not a count of heuristic edges. * * Both halves are needed and neither is meaningful alone. A guess count with no * refusal count cannot distinguish a language with genuinely few impossible @@ -60,7 +61,7 @@ export function countCallsByLanguage( const UNKNOWN_LANGUAGE = 'unknown'; export interface NameFallbackLanguageCounts { - /** Labeled `global-name-fallback` edges emitted for this language — CALL SITES. */ + /** Call sites resolved by a unique-name guess, before edge coalescing. */ readonly guessed: number; /** * Distinct (caller file, callee name) pairs among those sites — the unit @@ -118,9 +119,9 @@ export function summarizeNameFallback( const guessedPairsByLanguage = new Map(); const refused = new Map(); const ambiguousNames = new Set(); - // Two units, both kept. `guessed` counts call SITES — the number of emitted - // guessed edges, which is what the log line has always reported and what - // earlier persisted summaries hold. `guessedPairs` dedupes by (caller file, + // Two units, both kept. `guessed` counts call SITES, not final graph edges: + // a precise site may prove the same dependency during edge coalescing. + // Preserve the unit earlier summaries hold. `guessedPairs` dedupes by (caller file, // callee name), the unit `callsByLanguage` is counted in: ten guessed `foo()` // calls in one file are one pair against a denominator that counts `foo` // once, so the guessy RATIO uses pairs and is bounded by 1. Changing the unit @@ -194,7 +195,7 @@ export function formatNameFallbackSummary( summary.distinctGuessedPairs !== undefined ? ` (${summary.distinctGuessedPairs} distinct caller-file/name pairs)` : ''; - return `name-guessed CALLS edges: ${summary.totalGuessed} call sites${pairs}, ${summary.totalRefused} refused as impossible (guessed/refused by language: ${languages})${ambiguous}`; + return `name-fallback resolution: ${summary.totalGuessed} call sites${pairs}, ${summary.totalRefused} refused as impossible (guessed/refused by language: ${languages})${ambiguous}`; } /** diff --git a/gitnexus/src/core/ingestion/ts-js-export-marker.ts b/gitnexus/src/core/ingestion/ts-js-export-marker.ts index ceeceb97d..e27433260 100644 --- a/gitnexus/src/core/ingestion/ts-js-export-marker.ts +++ b/gitnexus/src/core/ingestion/ts-js-export-marker.ts @@ -46,10 +46,65 @@ export interface EsmExportEvidence { const CJS_EXPORT_ASSIGNMENT = /^\s*(this\.[A-Za-z_$][\w$]*\s*=)/; +/** Read binding patterns, never initializer expressions or property keys. */ +function bindsReceiver(node: SyntaxNode | null, name: string): boolean { + if (node === null) return false; + if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') { + return node.text === name; + } + if (node.type === 'variable_declarator') + return bindsReceiver(node.childForFieldName('name'), name); + if (node.type === 'assignment_pattern') + return bindsReceiver(node.childForFieldName('left'), name); + if (node.type === 'pair_pattern') return bindsReceiver(node.childForFieldName('value'), name); + if (node.type === 'required_parameter' || node.type === 'optional_parameter') { + return bindsReceiver(node.childForFieldName('pattern'), name); + } + return ( + ['formal_parameters', 'object_pattern', 'array_pattern', 'rest_pattern'].includes(node.type) && + node.namedChildren.some((child) => bindsReceiver(child, name)) + ); +} + +/** A locally bound `module`/`exports` is not Node's export receiver. */ +function isExportReceiverShadowed(node: SyntaxNode, name: string): boolean { + for (let scope = node.parent; scope !== null; scope = scope.parent) { + if ( + bindsReceiver(scope.childForFieldName('parameters'), name) || + bindsReceiver(scope.childForFieldName('parameter'), name) + ) + return true; + if (scope.type !== 'program' && scope.type !== 'statement_block') continue; + for (const statement of scope.namedChildren) { + const declaration = + statement.type === 'export_statement' + ? statement.childForFieldName('declaration') + : statement; + if (declaration === null) continue; + if ( + declaration.type === 'lexical_declaration' || + declaration.type === 'variable_declaration' + ) { + if (declaration.namedChildren.some((child) => bindsReceiver(child, name))) return true; + } else if ( + ['function_declaration', 'class_declaration'].includes(declaration.type) && + declaration.childForFieldName('name')?.text === name + ) + return true; + } + } + return false; +} + /** Static dot and bracket spellings of the same CommonJS export object. */ function isModuleExportsReference(node: SyntaxNode): boolean { const object = node.childForFieldName('object'); - if (object?.type !== 'identifier' || object.text !== 'module') return false; + if ( + object?.type !== 'identifier' || + object.text !== 'module' || + isExportReceiverShadowed(node, 'module') + ) + return false; if (node.type === 'member_expression') { return node.childForFieldName('property')?.text === 'exports'; } @@ -68,12 +123,22 @@ function hasCommonJsExportSurface(root: SyntaxNode): boolean { for (const member of root.descendantsOfType('member_expression')) { const object = member.childForFieldName('object'); if (object === null) continue; - if (object.type === 'identifier' && object.text === 'exports') return true; + if ( + object.type === 'identifier' && + object.text === 'exports' && + !isExportReceiverShadowed(member, 'exports') + ) + return true; if (isModuleExportsReference(member)) return true; } for (const sub of root.descendantsOfType('subscript_expression')) { const object = sub.childForFieldName('object'); - if (object?.type === 'identifier' && object.text === 'exports') return true; + if ( + object?.type === 'identifier' && + object.text === 'exports' && + !isExportReceiverShadowed(sub, 'exports') + ) + return true; if (isModuleExportsReference(sub)) return true; } return false; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index add86ccd8..d9c07e26f 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -3870,9 +3870,9 @@ async function runFullAnalysisInner( const resolutionOutcomes = pipelineResult.resolutionOutcomes ?? []; logUnresolvedReceiverFiles(resolutionOutcomes); - // Census of name-guessed CALLS edges (labeled `global-name-fallback`), refused - // impossibles and ambiguous `export *` names — the honesty readout for this - // run's resolution. Logged, and persisted below as `nameFallbackEdges`. + // Census of guessed call sites (before edge coalescing), refused candidates + // and ambiguous `export *` names. The legacy `nameFallbackEdges` metadata + // key stores site counts, not the final population of heuristic edges. const nameFallbackSummary = summarizeNameFallback( resolutionOutcomes, countCallsByLanguage(pipelineResult.resolvedCalleeNamesByCaller, pipelineResult.graph), diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index b42152de3..3dddc911a 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -313,10 +313,12 @@ export interface RepoMeta { */ undecidedInterfaceSatisfaction?: UndecidedSatisfactionSummary; /** - * Census of the name-guessed CALLS edges the run emitted (labeled - * `global-name-fallback`), the impossible ones it refused, and the ambiguous + * Census of name-guessed call sites before edge coalescing, the impossible + * candidates the run refused, and the ambiguous * `export *` names it declined to publish. Absent on indexes built before the - * census existed. See `scope-resolution/name-fallback-summary.ts`. + * census existed. The legacy key does not imply final edge counts: a precise + * site may prove a dependency shared with a guessed site. + * See `scope-resolution/name-fallback-summary.ts`. */ nameFallbackEdges?: NameFallbackSummary; /** diff --git a/gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts b/gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts index 6024268b2..276015b25 100644 --- a/gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts +++ b/gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts @@ -127,9 +127,10 @@ export function render() { // — so the label, not just the name, is the assertion that actually catches // a regression here. it('every arrow-const winner through the wildcard chain is the Function def, not the Variable shadow', () => { - const byTarget = new Map(callsFromMain().map((e) => [e.target, e.targetLabel])); - expect(byTarget.get('Button')).toBe('Function'); - expect(byTarget.get('LinkButton')).toBe('Function'); - expect(byTarget.get('clearButtonStyles')).toBe('Function'); + const calls = callsFromMain(); + for (const name of ['Button', 'LinkButton', 'clearButtonStyles']) { + const labels = calls.filter((edge) => edge.target === name).map((edge) => edge.targetLabel); + expect(labels, name).toEqual(['Function']); + } }); }); diff --git a/gitnexus/test/integration/resolvers/barrel-named-import-class-method.test.ts b/gitnexus/test/integration/resolvers/barrel-named-import-class-method.test.ts index 3e657323a..27da9b1c5 100644 --- a/gitnexus/test/integration/resolvers/barrel-named-import-class-method.test.ts +++ b/gitnexus/test/integration/resolvers/barrel-named-import-class-method.test.ts @@ -17,14 +17,16 @@ const memberOnly = `export function alpha(s: string) { return s; }\nexport class async function run(name: string, files: Record) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-named-member-${name}-`)); - writeFixtureRepo(dir, files); - const result = await runPipelineFromRepo(dir, () => {}); - const targets = getRelationships(result, 'CALLS') - .filter((e) => e.sourceFilePath.includes('src/main')) - .map((e) => e.target) - .sort(); - fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - return targets; + try { + writeFixtureRepo(dir, files); + const result = await runPipelineFromRepo(dir, () => {}); + return getRelationships(result, 'CALLS') + .filter((e) => e.sourceFilePath.includes('src/main')) + .map((e) => e.target) + .sort(); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } } describe.each(['ts', 'js'])( diff --git a/gitnexus/test/integration/resolvers/name-fallback-edges.test.ts b/gitnexus/test/integration/resolvers/name-fallback-edges.test.ts index a04f55be0..a8b3768f2 100644 --- a/gitnexus/test/integration/resolvers/name-fallback-edges.test.ts +++ b/gitnexus/test/integration/resolvers/name-fallback-edges.test.ts @@ -149,7 +149,7 @@ end // The specific lie this work removed. Asserted over the whole graph, not // just the one edge, so a future emitter cannot reintroduce it elsewhere. const mislabeled = getRelationships(result, 'CALLS').filter( - (c) => c.rel.confidence === 0.5 && c.rel.reason === 'import-resolved', + (c) => c.rel.reason === 'import-resolved', ); expect(mislabeled).toEqual([]); }); diff --git a/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts b/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts index 67bbe2914..1e9a3cd3c 100644 --- a/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts +++ b/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts @@ -150,6 +150,54 @@ describe('@declaration.is-exported — Opus review follow-ups', () => { }); describe('@declaration.is-exported (JavaScript emitter)', () => { + it('marks synthesized default-export HOC declarations in both emitters', () => { + for (const [emit, file] of [ + [emitJsScopeCaptures, 'Widget.jsx'], + [emitTsScopeCaptures, 'Widget.tsx'], + ] as const) { + const captures = emit('export default memo(() =>
);', file).filter( + (capture) => capture['@declaration.function'] !== undefined, + ); + expect(captures.length).toBeGreaterThan(0); + expect( + captures.every((capture) => capture['@declaration.is-exported']?.text === 'true'), + ).toBe(true); + } + }); + + it.each([ + 'function wrapper(module) { module.exports = {}; }', + "function wrapper(module) { module['exports'] = {}; }", + 'function wrapper(exports) { exports.alpha = 1; }', + "const wrapper = exports => { exports['alpha'] = 1; };", + 'function wrapper() { const module = {}; module.exports = {}; }', + 'function wrapper() { { module.exports = {}; let module; } }', + 'function wrapper({ receiver: module }) { module.exports = {}; }', + 'const module = {}; module.exports = { alpha() {} };', + ])('ignores a shadowed CommonJS receiver: %s', (source) => { + for (const [emit, file] of [ + [emitJsScopeCaptures, 'x.js'], + [emitTsScopeCaptures, 'x.ts'], + ] as const) { + const v = verdicts( + emit, + `${source}\nexport function publicApi() {}\nfunction hidden() {}`, + file, + ); + expect(v.publicApi).toBe('true'); + expect(v.hidden).toBe('false'); + } + }); + + it('retains real CommonJS writes inside an unshadowed wrapper', () => { + const v = verdicts( + emitJsScopeCaptures, + 'function wrapper() { module.exports = {}; }\nfunction hidden() {}', + 'x.js', + ); + expect(v.hidden).toBeUndefined(); + }); + it.each(["module['exports']", 'module["exports"]'])( 'recognizes %s object methods and properties as exports', (target) => { diff --git a/gitnexus/test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts b/gitnexus/test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts index 348ea002b..9db09fd35 100644 --- a/gitnexus/test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts +++ b/gitnexus/test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts @@ -31,6 +31,11 @@ import { emitFreeCallFallback } from '../../../src/core/ingestion/scope-resoluti import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js'; import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; import { GLOBAL_NAME_FALLBACK_REASON } from '../../../src/core/graph/edge-reasons.js'; +import { + formatNameFallbackSummary, + summarizeNameFallback, +} from '../../../src/core/ingestion/scope-resolution/name-fallback-summary.js'; +import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; const CALLER_FILE = 'caller.ts'; const TARGET_FILE = 'target.ts'; @@ -162,7 +167,7 @@ function run(sites: readonly ReferenceSite[]) { const graph = createKnowledgeGraph(); fnNode(graph, 'fn:main', 'main', CALLER_FILE); fnNode(graph, 'fn:helper', 'helper', TARGET_FILE); - const outcomes: { kind: string }[] = []; + const outcomes: ResolutionOutcome[] = []; emitFreeCallFallback( graph, indexes, @@ -214,4 +219,20 @@ describe('free-call dedup: the label is decided from every collapsed site, never expect(calls[0]!.confidence).toBe(0.5); expect(calls[0]!.reason).toBe(GLOBAL_NAME_FALLBACK_REASON); }); + + it('reports guessed sites without claiming they are final guessed edges', () => { + for (const sites of [ + [guessedSite(3), preciseSite(4)], + [preciseSite(3), guessedSite(4)], + ]) { + const { calls, outcomes } = run(sites); + expect(calls).toHaveLength(1); + expect(calls[0]!.reason).toBe('import-resolved'); + const summary = summarizeNameFallback(outcomes); + expect(summary?.totalGuessed).toBe(1); + const line = formatNameFallbackSummary(summary); + expect(line).toContain('name-fallback resolution: 1 call sites'); + expect(line).not.toContain('CALLS edges'); + } + }); }); diff --git a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts index d8b51a5c1..42ac189cc 100644 --- a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts +++ b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts @@ -97,6 +97,27 @@ describe('shared path arithmetic', () => { }); describe('Go: isGlobalNameFallbackPlausible', () => { + it('allows an external test package to dot-import exported production functions', () => { + const callerParsed = mkCaller('foo/caller_test.go', [ + { kind: 'wildcard', targetRaw: 'example.com/mod/foo' }, + ]); + const sourceTextOf = (file: string) => + file.endsWith('_test.go') ? 'package foo_test' : 'package foo'; + expect( + goIsGlobalNameFallbackPlausible({ + callerParsed, + sourceTextOf, + candidate: mkCandidate('foo/helper.go', 'Helper'), + }), + ).toBe(true); + expect( + goIsGlobalNameFallbackPlausible({ + callerParsed, + sourceTextOf, + candidate: mkCandidate('foo/helper.go', 'helper'), + }), + ).toBe(false); + }); it('REFUSES an unexported identifier from another package', () => { // The headline case: `a.uniqueHelperXyz` is invisible to package `b`, and no // import can make it visible, so the guess is impossible rather than weak. @@ -337,6 +358,19 @@ describe('Dart: isGlobalNameFallbackPlausible', () => { }); describe('Rust: isGlobalNameFallbackPlausible', () => { + it.each([ + ['src/a/b.rs', 'super::unique_helper_xyz', 'src/a.rs'], + ['src/a/b/c.rs', 'super::super::unique_helper_xyz', 'src/a/mod.rs'], + ['src/a/b.rs', 'self::child::unique_helper_xyz', 'src/a/b/child.rs'], + ])('resolves relative imports from %s', (caller, target, candidate) => { + expect( + rustIsGlobalNameFallbackPlausible({ + site: BARE_SITE, + callerParsed: mkCaller(caller, [namedImport(target, BARE_SITE.name)]), + candidate: mkCandidate(candidate, BARE_SITE.name), + }), + ).toBe(true); + }); it('REFUSES a cross-module item with no covering `use`', () => { expect( rustIsGlobalNameFallbackPlausible({ @@ -388,15 +422,24 @@ describe('Rust: isGlobalNameFallbackPlausible', () => { }); it('allows an item whose `use` names the ITEM rather than only its module', () => { - // `use crate::user::User` may arrive with the item name still on the path. - // Matching only the full path missed the module and refused `User::new`. + // A bare call exercises the import matcher, not the qualified-site bypass. + const candidate = mkCandidate('src/user.rs', 'build_user'); expect( rustIsGlobalNameFallbackPlausible({ - site: { name: 'new', rawQualifiedName: 'User::new' }, - callerParsed: mkCaller('src/main.rs', [namedImport('crate::user::User', 'User')]), - candidate: mkCandidate('src/user.rs', 'User.new'), + site: { name: 'build_user' }, + callerParsed: mkCaller('src/main.rs', [ + namedImport('crate::user::build_user', 'build_user'), + ]), + candidate, }), ).toBe(true); + expect( + rustIsGlobalNameFallbackPlausible({ + site: { name: 'build_user' }, + callerParsed: mkCaller('src/main.rs'), + candidate, + }), + ).toBe(false); }); it('REFUSES when the only `use` of the module names a DIFFERENT item', () => { @@ -466,6 +509,30 @@ describe('Rust: isGlobalNameFallbackPlausible', () => { }); describe('Swift: isGlobalNameFallbackPlausible', () => { + it('does not invent module boundaries between arbitrary Xcode directories', () => { + expect( + swiftIsGlobalNameFallbackPlausible({ + callerParsed: mkCaller('App/Caller.swift'), + candidate: mkCandidate('Shared/Helper.swift', 'helper'), + }), + ).toBe(true); + }); + + it('recognizes distinct src targets and requires a matching import', () => { + const candidate = mkCandidate('src/Core/Helper.swift', 'helper'); + expect( + swiftIsGlobalNameFallbackPlausible({ + callerParsed: mkCaller('src/App/Caller.swift'), + candidate, + }), + ).toBe(false); + expect( + swiftIsGlobalNameFallbackPlausible({ + callerParsed: mkCaller('src/App/Caller.swift', [namedImport('Core')]), + candidate, + }), + ).toBe(true); + }); it('allows a cross-file candidate in the same target (whole-module internal)', () => { expect( swiftIsGlobalNameFallbackPlausible({ diff --git a/gitnexus/test/unit/scope-resolution/wildcard-topLevelOnly-gate.test.ts b/gitnexus/test/unit/scope-resolution/wildcard-topLevelOnly-gate.test.ts index fcf689108..2968939e5 100644 --- a/gitnexus/test/unit/scope-resolution/wildcard-topLevelOnly-gate.test.ts +++ b/gitnexus/test/unit/scope-resolution/wildcard-topLevelOnly-gate.test.ts @@ -15,8 +15,8 @@ * `ParsedFile` input against `finalizeScopeModel` with a FAKE resolver * (`namedImportsBindTopLevelOnly` toggled directly), same technique as * `finalize-orchestrator.test.ts`. No real language parser involved; the - * fixture below is deliberately language-agnostic (Vue is the one migrated - * resolver that opts in for real — see `languages/vue/scope-resolver.ts`). + * fixture below is deliberately language-agnostic (Vue, TypeScript, and + * JavaScript all opt in through their language-specific scope resolvers). * * Fixture shape, held constant across both hook settings: * B.ts: class Foo with method `beta` — NO top-level `beta` declaration.