From 677b56053d49fce4a05cb9b643dce95da1281987 Mon Sep 17 00:00:00 2001 From: Abhinav Pandey Date: Sun, 6 Sep 2026 09:41:16 +0530 Subject: [PATCH 1/2] fix(exports): recognize imported and expression-local receivers --- .../src/core/ingestion/ts-js-export-marker.ts | 26 +++++++++++++++++-- .../esm-export-marker.test.ts | 13 ++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/gitnexus/src/core/ingestion/ts-js-export-marker.ts b/gitnexus/src/core/ingestion/ts-js-export-marker.ts index e27433260..94781657d 100644 --- a/gitnexus/src/core/ingestion/ts-js-export-marker.ts +++ b/gitnexus/src/core/ingestion/ts-js-export-marker.ts @@ -57,18 +57,33 @@ function bindsReceiver(node: SyntaxNode | null, name: string): boolean { 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 === 'import_specifier') { + return bindsReceiver(node.childForFieldName('alias') ?? node.childForFieldName('name'), 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)) + [ + 'formal_parameters', + 'object_pattern', + 'array_pattern', + 'rest_pattern', + 'import_clause', + 'named_imports', + 'namespace_import', + ].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 ( + ['function_expression', 'generator_function', 'class'].includes(scope.type) && + scope.childForFieldName('name')?.text === name + ) + return true; if ( bindsReceiver(scope.childForFieldName('parameters'), name) || bindsReceiver(scope.childForFieldName('parameter'), name) @@ -81,6 +96,13 @@ function isExportReceiverShadowed(node: SyntaxNode, name: string): boolean { ? statement.childForFieldName('declaration') : statement; if (declaration === null) continue; + if ( + declaration.type === 'import_statement' && + declaration.namedChildren.some( + (child) => child.type === 'import_clause' && bindsReceiver(child, name), + ) + ) + return true; if ( declaration.type === 'lexical_declaration' || declaration.type === 'variable_declaration' 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 1e9a3cd3c..876bf2bcc 100644 --- a/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts +++ b/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts @@ -174,6 +174,10 @@ describe('@declaration.is-exported (JavaScript emitter)', () => { 'function wrapper() { { module.exports = {}; let module; } }', 'function wrapper({ receiver: module }) { module.exports = {}; }', 'const module = {}; module.exports = { alpha() {} };', + "import module from './other'; module.exports = {};", + "import { receiver as exports } from './other'; exports.alpha = 1;", + "import * as module from './other'; module.exports = {};", + 'const wrapper = function module() { module.exports = {}; };', ])('ignores a shadowed CommonJS receiver: %s', (source) => { for (const [emit, file] of [ [emitJsScopeCaptures, 'x.js'], @@ -198,6 +202,15 @@ describe('@declaration.is-exported (JavaScript emitter)', () => { expect(v.hidden).toBeUndefined(); }); + it('an imported source name does not shadow a different local alias', () => { + const v = verdicts( + emitJsScopeCaptures, + "import { module as other } from './other'; module.exports = {}; function hidden() {}", + 'x.js', + ); + expect(v.hidden).toBeUndefined(); + }); + it.each(["module['exports']", 'module["exports"]'])( 'recognizes %s object methods and properties as exports', (target) => { From 5813018b0813b805656fbd20643522401b384be9 Mon Sep 17 00:00:00 2001 From: Abhinav Pandey Date: Sun, 6 Sep 2026 09:45:14 +0530 Subject: [PATCH 2/2] fix(resolution): align export and target evidence with language scope --- .../swift/name-fallback-visibility.ts | 12 ++---- .../scope-resolution/name-fallback-summary.ts | 3 +- .../src/core/ingestion/ts-js-export-marker.ts | 22 +++++++++-- .../resolvers/name-fallback-edges.test.ts | 7 ++-- .../esm-export-marker.test.ts | 26 +++++++++++++ .../free-call-fallback-guess-taint.test.ts | 2 +- .../name-fallback-summary.test.ts | 8 +++- .../name-fallback-visibility.test.ts | 11 ++++++ .../resolved-callee-names.test.ts | 37 +++++++++++++++++-- 9 files changed, 105 insertions(+), 23 deletions(-) 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 e35f80292..f6472cd0b 100644 --- a/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts @@ -10,7 +10,7 @@ * 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//…`. + * package configuration discovers: `Sources//…` (also under `Package/`). * `src//…` is also recognized by the package configuration loader. * Files outside these layouts have unknown module identity. * @@ -24,14 +24,8 @@ 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', - 'src', -]); +/** Keep aligned with loadSwiftPackageConfig: other layouts are unconfigured. */ +const SWIFT_TARGET_ROOTS: ReadonlySet = new Set(['Sources', 'src']); /** * The module (target) a Swift file belongs to. 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 be2d43201..e96d9d5c7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts @@ -195,7 +195,8 @@ export function formatNameFallbackSummary( summary.distinctGuessedPairs !== undefined ? ` (${summary.distinctGuessedPairs} distinct caller-file/name pairs)` : ''; - return `name-fallback resolution: ${summary.totalGuessed} call sites${pairs}, ${summary.totalRefused} refused as impossible (guessed/refused by language: ${languages})${ambiguous}`; + const siteUnit = summary.totalGuessed === 1 ? 'call site' : 'call sites'; + return `name-fallback resolution: ${summary.totalGuessed} ${siteUnit}${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 94781657d..2d8a66206 100644 --- a/gitnexus/src/core/ingestion/ts-js-export-marker.ts +++ b/gitnexus/src/core/ingestion/ts-js-export-marker.ts @@ -44,8 +44,6 @@ export interface EsmExportEvidence { readonly commonJs: boolean; } -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; @@ -219,8 +217,16 @@ export function collectEsmExportEvidence( namedLocals.add(child.text); } } - } else if (stmt.type === 'expression_statement' && CJS_EXPORT_ASSIGNMENT.test(stmt.text)) { - commonJs = true; + } else if (stmt.type === 'expression_statement') { + const assignment = stmt.namedChildren[0]; + const left = + assignment?.type === 'assignment_expression' ? assignment.childForFieldName('left') : null; + if ( + left !== null && + (left.type === 'member_expression' || left.type === 'subscript_expression') && + left.childForFieldName('object')?.type === 'this' + ) + commonJs = true; } } return { namedLocals, commonJs }; @@ -257,7 +263,9 @@ export function esmExportVerdict( // program without crossing a nesting boundary — one inside a namespace or // ambient-module body is that container's export (see NESTING_BOUNDARIES). let underExport = false; + let functionScopedVariable = false; while (current !== null && current.type !== 'program') { + if (current.type === 'variable_declaration') functionScopedVariable = true; if (current.type === 'export_statement') { underExport = true; current = current.parent; @@ -270,6 +278,12 @@ export function esmExportVerdict( return evidence.commonJs ? undefined : false; } if (NESTING_BOUNDARIES.has(current.type) && current.id !== nameNode.parent?.id) { + // `var` crosses blocks but never a function/namespace boundary. A + // module-scoped `var` can therefore be exported by a later clause. + if (current.type === 'statement_block' && functionScopedVariable) { + current = current.parent; + continue; + } return evidence.commonJs ? undefined : false; } current = current.parent; diff --git a/gitnexus/test/integration/resolvers/name-fallback-edges.test.ts b/gitnexus/test/integration/resolvers/name-fallback-edges.test.ts index a8b3768f2..5b5ded1af 100644 --- a/gitnexus/test/integration/resolvers/name-fallback-edges.test.ts +++ b/gitnexus/test/integration/resolvers/name-fallback-edges.test.ts @@ -11,8 +11,8 @@ * import produced, so no consumer could discount it. * * These tests pin both. The Go arm proves the impossible edge is now REFUSED, - * with the same-package call kept as the control that shows the refusal is - * targeted rather than a blanket disabling of the tier. The Ruby arm proves a + * with a same-file call confirming ordinary local resolution is preserved. + * That control does not exercise the global-name tier. The Ruby arm proves a * surviving guess is LABELED, since Ruby deliberately keeps the tier for * autoload. The last test is the regression that matters most: no edge from * this tier may ever again carry `import-resolved`. @@ -71,10 +71,11 @@ func CallItRemotely() int { afterAll(() => rmRepo(repoDir)); - it('keeps the same-package call (control: the tier still works)', () => { + it('keeps ordinary same-file resolution as a control', () => { const calls = getRelationships(result, 'CALLS'); const local = calls.find((c) => c.source === 'UseItLocally' && c.target === 'uniqueHelperXyz'); expect(local).toBeDefined(); + expect(local!.rel.reason).not.toBe(GLOBAL_NAME_FALLBACK_REASON); }); it('emits NO caller edge from the other package', () => { 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 876bf2bcc..31e6a9cf9 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,32 @@ describe('@declaration.is-exported — Opus review follow-ups', () => { }); describe('@declaration.is-exported (JavaScript emitter)', () => { + it('keeps bracket-form top-level this exports undecided like dot-form exports', () => { + for (const [emit, file] of [ + [emitJsScopeCaptures, 'x.js'], + [emitTsScopeCaptures, 'x.ts'], + ] as const) { + const v = verdicts(emit, "function api() {} this['api'] = api;", file); + expect(v.api).toBeUndefined(); + } + }); + + it('recognizes exported module-scoped var inside a block, but not block let or function-local var', () => { + for (const [emit, file] of [ + [emitJsScopeCaptures, 'x.js'], + [emitTsScopeCaptures, 'x.ts'], + ] as const) { + const v = verdicts( + emit, + 'if (ok) { var visible = 1; let hidden = 2; } function wrapper() { var nested = 3; } export { visible, hidden, nested };', + file, + ); + expect(v.visible).toBe('true'); + expect(v.hidden).toBe('false'); + expect(v.nested).toBe('false'); + } + }); + it('marks synthesized default-export HOC declarations in both emitters', () => { for (const [emit, file] of [ [emitJsScopeCaptures, 'Widget.jsx'], 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 9db09fd35..78c814cec 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 @@ -231,7 +231,7 @@ describe('free-call dedup: the label is decided from every collapsed site, never const summary = summarizeNameFallback(outcomes); expect(summary?.totalGuessed).toBe(1); const line = formatNameFallbackSummary(summary); - expect(line).toContain('name-fallback resolution: 1 call sites'); + expect(line).toContain('name-fallback resolution: 1 call site ('); expect(line).not.toContain('CALLS edges'); } }); diff --git a/gitnexus/test/unit/scope-resolution/name-fallback-summary.test.ts b/gitnexus/test/unit/scope-resolution/name-fallback-summary.test.ts index e40fb8298..c013aa76e 100644 --- a/gitnexus/test/unit/scope-resolution/name-fallback-summary.test.ts +++ b/gitnexus/test/unit/scope-resolution/name-fallback-summary.test.ts @@ -224,9 +224,15 @@ describe('formatNameFallbackSummary', () => { const line = formatNameFallbackSummary( summarizeNameFallback([guessed('ruby'), refused('go'), refused('go'), refused('go')]), ); - expect(line).toContain('1 call sites (1 distinct caller-file/name pairs)'); + expect(line).toContain('1 call site (1 distinct caller-file/name pairs)'); expect(line).toContain('3 refused as impossible'); // `go` has more total activity, so it leads. expect(line).toMatch(/go 0\/3.*ruby 1\/0/); }); + + it('uses plural call sites for multiple guesses', () => { + expect( + formatNameFallbackSummary(summarizeNameFallback([guessed('go'), guessed('go')])), + ).toContain('2 call sites ('); + }); }); 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 42ac189cc..434e6b80a 100644 --- a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts +++ b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts @@ -509,6 +509,17 @@ describe('Rust: isGlobalNameFallbackPlausible', () => { }); describe('Swift: isGlobalNameFallbackPlausible', () => { + it.each(['sources', 'tests', 'Tests'])( + 'does not invent target modules under unconfigured %s folders', + (root) => { + expect( + swiftIsGlobalNameFallbackPlausible({ + callerParsed: mkCaller(`${root}/App/Caller.swift`), + candidate: mkCandidate(`${root}/Core/Helper.swift`, 'helper'), + }), + ).toBe(true); + }, + ); it('does not invent module boundaries between arbitrary Xcode directories', () => { expect( swiftIsGlobalNameFallbackPlausible({ diff --git a/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts b/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts index c3ff05dde..5e7116bb6 100644 --- a/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts +++ b/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts @@ -20,17 +20,46 @@ describe('collectResolvedCalleeNames', () => { fn('b', 'b', 'src/b.go'); fn('c', 'c', 'src/c.ts'); g.addNode({ id: 'file', label: 'File' as NodeLabel, properties: { filePath: 'src/a.go' } }); - g.addRelationship({ id: 'r1', sourceId: 'a', targetId: 'b', type: 'CALLS', confidence: 0.85 }); - g.addRelationship({ id: 'r2', sourceId: 'a', targetId: 'c', type: 'CALLS', confidence: 0.5 }); - g.addRelationship({ id: 'r3', sourceId: 'c', targetId: 'b', type: 'CALLS', confidence: 0.85 }); + g.addRelationship({ + id: 'r1', + sourceId: 'a', + targetId: 'b', + type: 'CALLS', + confidence: 0.85, + reason: '', + }); + g.addRelationship({ + id: 'r2', + sourceId: 'a', + targetId: 'c', + type: 'CALLS', + confidence: 0.5, + reason: '', + }); + g.addRelationship({ + id: 'r3', + sourceId: 'c', + targetId: 'b', + type: 'CALLS', + confidence: 0.85, + reason: '', + }); g.addRelationship({ id: 'r4', sourceId: 'file', targetId: 'a', type: 'DEFINES', confidence: 1, + reason: '', + }); + g.addRelationship({ + id: 'r5', + sourceId: 'a', + targetId: 'file', + type: 'CALLS', + confidence: 1, + reason: '', }); - g.addRelationship({ id: 'r5', sourceId: 'a', targetId: 'file', type: 'CALLS', confidence: 1 }); const index = collectResolvedCalleeNames(g, g); expect([...index.keys()].sort()).toEqual(['a', 'c']);