From 2220f4d8513b023d907f50ff4d1dc015b13a906a Mon Sep 17 00:00:00 2001 From: Abhinav Pandey Date: Thu, 10 Sep 2026 03:38:19 -0700 Subject: [PATCH] fix(resolution): label fallback guesses and preserve export visibility (#3190) * fix(resolution): distinguish name guesses and preserve export visibility * test(go): keep method enrichment fixture in one package * fix(resolution): address split review edge cases and evidence reporting * fix(exports): recognize imported and expression-local receivers * fix(resolution): align export and target evidence with language scope * fix(ingestion): preserve lexical import provenance through resolution * test(ci): rebalance Windows shards from measured slow suites * Address PR review feedback (#3190) - Label constructor unique-name guesses as global-name-fallback and run language vetoes - Tighten Go qualified, Rust crate::, and Ruby class-reopen fallback guards - Ignore for-loop shadowed CommonJS receivers and exclude guesses from the resolved-call census - Refresh FinalizeOutput and hook docs for lexical binding scopes Co-authored-by: Cursor * Tighten review-feedback leftovers on fallback visibility. Qualified Go calls still respect export and test-package rules, nested Rust src/ stays a module segment, and top-level conditional this.x is treated as CommonJS. Co-authored-by: Cursor * chore(autofix): apply prettier + eslint fixes via /autofix command * Address PR review feedback (#3190) - Distinguish Swift package prefixes when comparing target modules - Document lexical import binding and handledSites refusal marking - Drop the stale ci-scope-parity workflow claim and prototype-safe export verdicts Co-authored-by: Cursor * Address PR review feedback (#3190) Supply caller source on Ruby visibility cases so they exercise the named allow branches instead of the missing-text bypass. Co-authored-by: Cursor * fix(resolution): label unique constructor types as name guesses A workspace-unique class hit in findClassBindingInScope was treated as an in-scope bind, so Go/JS constructor-form sites skipped the guess label and the Go unexported veto. Co-authored-by: Cursor * fix(resolution): keep qualified constructors precise after unique-name split Bare constructor unique-name hits stay guesses so Go can veto an unexported type. A written qualifier is now carried as rawQualifiedName so `new pkg.Foo()` and `models.Box[T]{}` can still recover the unique class without that veto. Co-authored-by: Cursor * test(bench): rebaseline Go/Java scope-capture fingerprints for constructor qualifiers Generic Go composite literals and qualified Java `new pkg.Foo()` now carry @reference.qualified-name on existing constructor matches. Co-authored-by: Cursor * fix(resolution): treat import-reached unique constructors as precise C++ #include and Rust re-exports do not mint a lexical class binding. A unique type in an imported file (or imported directory) is therefore a real bind, not a name guess, so those CALLS edges stay import-resolved. Co-authored-by: Cursor * fix(resolution): require named or resolved imports for constructor precision Bare Go Box[T]{} is not package-qualified, and a sibling-file import of a different name is not constructor visibility. Co-authored-by: Cursor --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- gitnexus-shared/src/index.ts | 1 + .../scope-resolution/finalize-algorithm.ts | 494 +++++++++- .../src/scope-resolution/symbol-definition.ts | 12 + gitnexus-shared/src/scope-resolution/types.ts | 26 +- gitnexus/bench/scope-capture/baselines.json | 13 +- gitnexus/scripts/cross-platform-shard.ts | 22 +- gitnexus/src/core/graph/edge-reasons.ts | 38 + gitnexus/src/core/graph/graph.ts | 12 +- gitnexus/src/core/graph/types.ts | 16 +- .../core/ingestion/finalize-orchestrator.ts | 3 + .../dart/name-fallback-visibility.ts | 77 ++ .../languages/dart/scope-resolver.ts | 2 + .../core/ingestion/languages/go/captures.ts | 23 +- .../languages/go/name-fallback-visibility.ts | 126 +++ .../ingestion/languages/go/scope-resolver.ts | 2 + .../core/ingestion/languages/java/captures.ts | 20 + .../languages/javascript/captures.ts | 24 + .../languages/javascript/scope-resolver.ts | 6 + .../ruby/name-fallback-visibility.ts | 196 ++++ .../languages/ruby/scope-resolver.ts | 2 + .../rust/name-fallback-visibility.ts | 183 ++++ .../languages/rust/scope-resolver.ts | 3 + .../swift/name-fallback-visibility.ts | 67 ++ .../languages/swift/scope-resolver.ts | 2 + .../languages/typescript/captures.ts | 24 + .../languages/typescript/scope-resolver.ts | 9 + .../ingestion/languages/vue/scope-resolver.ts | 3 + gitnexus/src/core/ingestion/pipeline.ts | 34 + .../src/core/ingestion/scope-extractor.ts | 28 +- .../contract/scope-resolver.ts | 118 ++- .../scope-resolution/graph-bridge/ids.ts | 16 + .../scope-resolution/name-fallback-summary.ts | 212 ++++ .../passes/free-call-fallback.ts | 206 +++- .../passes/imported-return-types.ts | 82 +- .../scope-resolution/pipeline/run.ts | 21 + .../scope-resolution/resolution-outcome.ts | 54 + .../scope-resolution/scope/walkers.ts | 100 +- .../utils/name-fallback-visibility.ts | 97 ++ .../src/core/ingestion/ts-js-export-marker.ts | 345 +++++++ gitnexus/src/core/lbug/graph-emit-sink.ts | 48 +- gitnexus/src/core/lbug/pdg-emit-sink.ts | 8 +- gitnexus/src/core/run-analyze.ts | 14 + gitnexus/src/storage/parse-cache.ts | 13 +- gitnexus/src/storage/repo-meta.ts | 10 + gitnexus/src/types/pipeline.ts | 8 + .../go-captures-golden/expected-captures.json | 6 +- .../go-method-enrichment/app.go | 7 +- .../barrel-dir-index-wildcard.test.ts | 136 +++ .../barrel-named-import-class-method.test.ts | 107 ++ .../barrel-named-over-star-precedence.test.ts | 56 ++ .../barrel-review-3182-repros.test.ts | 141 +++ .../barrel-wildcard-arrow-const.test.ts | 94 ++ .../resolvers/name-fallback-edges.test.ts | 207 ++++ .../resolvers/rust-import-scope.test.ts | 73 ++ .../test/unit/cross-platform-shard.test.ts | 29 +- .../test/unit/incremental-parse-cache.test.ts | 13 +- .../esm-export-marker.test.ts | 325 ++++++ .../free-call-fallback-guess-taint.test.ts | 469 +++++++++ .../go/go-captures-smoke.test.ts | 30 + .../import-scope-provenance.test.ts | 159 +++ .../java/java-captures.test.ts | 14 +- .../name-fallback-summary.test.ts | 238 +++++ .../name-fallback-visibility.test.ts | 928 ++++++++++++++++++ .../python/python-fixtures.test.ts | 127 ++- .../resolved-callee-names.test.ts | 82 ++ .../scope-resolution/scope-extractor.test.ts | 22 +- ...wildcard-collision-export-evidence.test.ts | 228 +++++ .../wildcard-topLevelOnly-gate.test.ts | 152 +++ 68 files changed, 6215 insertions(+), 248 deletions(-) create mode 100644 gitnexus/src/core/graph/edge-reasons.ts create mode 100644 gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts create mode 100644 gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts create mode 100644 gitnexus/src/core/ingestion/ts-js-export-marker.ts create mode 100644 gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts create mode 100644 gitnexus/test/integration/resolvers/barrel-named-import-class-method.test.ts create mode 100644 gitnexus/test/integration/resolvers/barrel-named-over-star-precedence.test.ts create mode 100644 gitnexus/test/integration/resolvers/barrel-review-3182-repros.test.ts create mode 100644 gitnexus/test/integration/resolvers/barrel-wildcard-arrow-const.test.ts create mode 100644 gitnexus/test/integration/resolvers/name-fallback-edges.test.ts create mode 100644 gitnexus/test/integration/resolvers/rust-import-scope.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/import-scope-provenance.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/name-fallback-summary.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/wildcard-collision-export-evidence.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/wildcard-topLevelOnly-gate.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 9857a60cc..7958cfa9d 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -137,6 +137,7 @@ export type { FinalizeOutput, FinalizedScc, FinalizeStats, + AmbiguousWildcardExport, } from './scope-resolution/finalize-algorithm.js'; // Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917) diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index f6038b3f0..2dc14b398 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -5,13 +5,14 @@ * Pure logic that takes per-file parse output (`ParsedImport[]` + * `SymbolDefinition[]`) and returns: * - * - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and - * `targetDefId` filled where resolvable; edges that could not be - * resolved within the hard fixpoint cap are marked + * - Linked `ImportEdge[]` keyed by binding scope (`fromScope`; module + * scope unless `importsBindAtLexicalScope` is on), with + * `targetModuleScope` and `targetDefId` filled where resolvable; edges + * that could not be resolved within the hard fixpoint cap are marked * `linkStatus: 'unresolved'`. - * - Materialized `bindings` per module scope — local defs merged with - * imported / wildcard-expanded / re-exported names via the provider's - * `mergeBindings` precedence. + * - Materialized `bindings` keyed by the same scopes — local defs merged + * with imported / wildcard-expanded / re-exported names via the + * provider's `mergeBindings` precedence. * - The SCC condensation of the import graph, exposed so disjoint SCCs * can be processed in parallel by callers that want that. * @@ -39,7 +40,7 @@ import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } fr /** Per-file input for the finalize pass. */ export interface FinalizeFile { readonly filePath: string; - /** The module scope id for this file; owns the finalized imports + bindings. */ + /** Default binding scope for imports without lexical provenance or opt-in. */ readonly moduleScope: ScopeId; readonly parsedImports: readonly ParsedImport[]; /** @@ -84,6 +85,10 @@ export interface FinalizeInput { * expects pure answers. */ export interface FinalizeHooks { + /** Bind imports at their extracted lexical scope. Missing provenance retains + * the legacy module-scope behavior. Opt-in: lexical position and language + * import-binding semantics are distinct facts. */ + readonly importsBindAtLexicalScope?: boolean; /** * Resolve a raw import target to the concrete file path that owns it. * Return `null` when no target file is resolvable (e.g., `np.foo` when @@ -114,6 +119,34 @@ export interface FinalizeHooks { */ expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[]; + /** + * Does this language make two `wildcard` re-exports that both DECLARE the + * same name AMBIGUOUS (no winner), rather than overloads or redeclarations + * of one entity? + * + * True for ECMAScript modules: `export * from './a'; export * from './b'` + * with `collide` declared in both excludes the name from the module's + * exports, so binding either source is a guess. False (the default) for + * languages whose wildcard import is `#include`, `require`, or a package + * fan-out, where the same name declared in two files is an overload set + * (C++ `write_audit(int)` / `write_audit(int, int)` across two headers), a + * redeclaration of one function, or a per-file `init` — legal, and resolved + * downstream by arity or by definition. Only a language that opts in has + * its collisions refused and reported via `ambiguousWildcardExports`. + */ + readonly wildcardCollisionIsAmbiguous?: boolean; + + /** + * A named import / named re-export binds only to MODULE-LEVEL declarations + * of the target file. Opt-in for languages whose `import { x }` can never + * reach a class member: without it, a class method sharing a name with a + * top-level value — or standing alone — wins the callable preference in + * `findExportByName` and the import binds to a symbol the module cannot + * export (a confident wrong edge). Languages that bind module-level members + * by bare name (static members, module functions) leave it off. + */ + readonly namedImportsBindTopLevelOnly?: boolean; + /** * Merge `incoming` bindings into `existing` for a given name. Called * once per name at each scope. Typical rules: @@ -164,12 +197,32 @@ export interface FinalizeStats { readonly unresolvedEdges: number; readonly sccCount: number; readonly largestSccSize: number; + /** + * Names a file re-exported through two or more `export *` sources that each + * DECLARE the name, so the language names no winner. The finalize pass + * refuses to bind them (they are absent from the file's re-export closure + * AND from its wildcard-expanded module-scope bindings) instead of taking the + * first-listed source and publishing the guess as `import-resolved`. + * Reported so the caller can record the refusal — an importer of that name + * stays unresolved, and the reason must be auditable rather than silent. + */ + readonly ambiguousWildcardExports: readonly AmbiguousWildcardExport[]; +} + +/** One refused `export *` collision — see `FinalizeStats.ambiguousWildcardExports`. */ +export interface AmbiguousWildcardExport { + readonly filePath: string; + readonly name: string; + /** `nodeId`s of the colliding declarations, in `export *` declaration order. */ + readonly candidateDefIds: readonly string[]; } export interface FinalizeOutput { - /** Linked `ImportEdge[]` per module scope, in original input order. */ + /** Linked `ImportEdge[]` keyed by binding scope (`fromScope`). Module scope + * for languages that bind file-wide; nested lexical scopes when + * `importsBindAtLexicalScope` is on. */ readonly imports: ReadonlyMap; - /** Materialized bindings per module scope. */ + /** Materialized bindings keyed by the same scopes as `imports`. */ readonly bindings: ReadonlyMap>; /** SCCs in reverse-topological order (leaves first). */ readonly sccs: readonly FinalizedScc[]; @@ -223,7 +276,33 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu // SCC-condensed). Eliminates the recursive crawl that the per-edge // `tryFinalize` call site used to do; lookups are O(1) afterwards. // See `buildReexportClosures` for the algorithm. - const reexportClosures = buildReexportClosures(input.files, byFilePath, edgeIndex); + // A local import is a dependency of its file, not a re-export of that file. + const moduleEdgeIndex = new Map(); + for (const file of input.files) { + moduleEdgeIndex.set( + file.filePath, + (edgeIndex.get(file.filePath) ?? []).filter((d) => d.fromScope === file.moduleScope), + ); + } + const ambiguityByFile = collectAmbiguityByFile( + input.files, + byFilePath, + moduleEdgeIndex, + hooks.wildcardCollisionIsAmbiguous === true, + hooks.namedImportsBindTopLevelOnly === true, + ); + const ambiguousByFile = new Map>(); + for (const [filePath, byName] of ambiguityByFile) { + ambiguousByFile.set(filePath, new Set(byName.keys())); + } + const topLevelOnly = hooks.namedImportsBindTopLevelOnly === true; + const reexportClosures = buildReexportClosures( + input.files, + byFilePath, + moduleEdgeIndex, + ambiguousByFile, + topLevelOnly, + ); // ── Phase 3: process SCCs in reverse-topological order (leaves first). // Within each SCC, run a bounded fixpoint that resolves intra-SCC edges. @@ -231,6 +310,19 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu // already finalized); edges inside the SCC may need multiple passes. const linkedByScope = new Map(); let linkedEdges = 0; + // Every refused wildcard name, reported from the ambiguity map rather than from + // the edges phase 4 happens to drop: a language whose `expandsWildcardTo` + // returns nothing (TypeScript — `export *` never binds names locally) drops + // no expanded edge, yet its importers were refused through the closure just + // the same, and that refusal must still be visible. Named-vs-named conflicts + // remain refused above but are not export-star collisions in this audit. + const ambiguousWildcardExports: AmbiguousWildcardExport[] = []; + for (const [filePath, byName] of ambiguityByFile) { + for (const [name, ambiguity] of byName) { + if (ambiguity.kind !== 'wildcard') continue; + ambiguousWildcardExports.push({ filePath, name, candidateDefIds: ambiguity.candidateDefIds }); + } + } for (const scc of sccs) { const sccFiles = new Set(scc.files); @@ -249,7 +341,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu if (drafts === undefined) continue; for (const draft of drafts) { if (draft.finalized !== null) continue; - const finalized = tryFinalize(draft, byFilePath, reexportClosures); + const finalized = tryFinalize(draft, byFilePath, reexportClosures, topLevelOnly); if (finalized !== null) { draft.finalized = finalized; progressed = true; @@ -272,13 +364,25 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu } } - // ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving + // ── Phase 4: collect finalized `ImportEdge[]` per binding scope, preserving // input order within each file, and wildcard-expand where applicable. for (const file of input.files) { const drafts = edgeIndex.get(file.filePath); if (drafts === undefined) continue; - const finalized: ImportEdge[] = []; + const finalizedByScope = new Map([[file.moduleScope, []]]); + // Names this file's `export *` sources collide on (see + // `collectAmbiguousWildcards`). Their expanded edges are dropped here, so + // the file's own module scope does not bind an arbitrary winner either — + // suppressing them only in the closure would leave this binding standing, + // and it was this binding, not the closure, that produced the published + // `import-resolved` guess. + const ambiguousHere = ambiguousByFile.get(file.filePath) ?? EMPTY_NAME_SET; for (const d of drafts) { + let finalized = finalizedByScope.get(d.fromScope); + if (finalized === undefined) { + finalized = []; + finalizedByScope.set(d.fromScope, finalized); + } const edge = d.finalized; if (edge === null) { throw new Error(`Invariant violated: import edge was not finalized for ${file.filePath}`); @@ -286,16 +390,25 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') { // Produce one `wildcard-expanded` ImportEdge per exported name. const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex); - for (const e of expanded) finalized.push(e); + for (const e of expanded) { + if ( + d.fromScope === file.moduleScope && + e.kind === 'wildcard-expanded' && + ambiguousHere.has(e.localName) + ) + continue; + finalized.push(e); + } } else { finalized.push(edge); } if (edge.linkStatus !== 'unresolved') linkedEdges++; } - linkedByScope.set(file.moduleScope, Object.freeze(finalized)); + for (const [scopeId, edges] of finalizedByScope) + linkedByScope.set(scopeId, Object.freeze(edges)); } - // ── Phase 5: materialize module-scope bindings (local + imports + wildcards), + // ── Phase 5: materialize bindings (local + scoped imports + wildcards), // delegating precedence to `provider.mergeBindings`. const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks); @@ -312,6 +425,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu unresolvedEdges: totalEdges - linkedEdges, sccCount, largestSccSize, + ambiguousWildcardExports: Object.freeze(ambiguousWildcardExports), }; return Object.freeze({ @@ -339,6 +453,10 @@ function makeEdgeDrafts( hooks: FinalizeHooks, workspace: WorkspaceIndex, ): ImportEdgeDraft[] { + const fromScope = + hooks.importsBindAtLexicalScope === true + ? (parsed.declaredAtScope ?? file.moduleScope) + : file.moduleScope; // Dynamic-unresolved passes through — no `BindingRef`, no target file. if (parsed.kind === 'dynamic-unresolved') { const base: ImportEdge = { @@ -351,7 +469,7 @@ function makeEdgeDrafts( { source: parsed, fromFile: file.filePath, - fromScope: file.moduleScope, + fromScope, targetFile: null, base, finalized: base, // already fully finalized @@ -381,7 +499,7 @@ function makeEdgeDrafts( { source: parsed, fromFile: file.filePath, - fromScope: file.moduleScope, + fromScope, targetFile: null, base, finalized: base, @@ -417,7 +535,7 @@ function makeEdgeDrafts( return { source: parsed, fromFile: file.filePath, - fromScope: file.moduleScope, + fromScope, targetFile: tf, base, finalized: isFileLevelTerminal ? base : null, @@ -529,6 +647,7 @@ function tryFinalize( draft: ImportEdgeDraft, byFilePath: Map, reexportClosures: ReadonlyMap, + topLevelOnly: boolean, ): ImportEdge | null { const targetFile = draft.targetFile; if (targetFile === null) return draft.base; // already terminal @@ -552,7 +671,11 @@ function tryFinalize( // so consumers can reach the module as a symbol — but its absence is not // a failure. if (draft.base.kind === 'namespace') { - const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source)); + const moduleDef = findExportByName( + targetModule.localDefs, + extractExportedName(draft.source), + topLevelOnly, + ); return { ...draft.base, targetModuleScope: targetModule.moduleScope, @@ -564,7 +687,7 @@ function tryFinalize( // local defs. Multi-hop re-export chains settle iteratively — each hop // resolves once its prior hop is finalized. const importedName = extractExportedName(draft.source); - const exported = findExportByName(targetModule.localDefs, importedName); + const exported = findExportByName(targetModule.localDefs, importedName, topLevelOnly); if (exported !== undefined) { const transitiveVia = @@ -691,15 +814,18 @@ function buildReexportClosures( files: readonly FinalizeFile[], byFilePath: ReadonlyMap, edgeIndex: ReadonlyMap, + ambiguous: ReadonlyMap>, + topLevelOnly: boolean, ): ReadonlyMap { const closures = new Map>(); for (const file of files) closures.set(file.filePath, new Map()); // ── Step 1: build the re-export sub-graph (only resolvable wildcard / - // reexport / flagged-named targets contribute edges), and collect the - // per-file ambiguous names in the same walk. + // reexport / flagged-named targets contribute edges). The per-file + // ambiguous-name sets arrive precomputed (`collectAmbiguityByFile`) because + // phase 4 consults the same sets when it expands wildcards into module + // scope — one source of truth for "this name has no winner". const subGraph = new Map>(); - const ambiguous = new Map>(); for (const file of files) { const targets = new Set(); const drafts = edgeIndex.get(file.filePath); @@ -710,7 +836,6 @@ function buildReexportClosures( if (!byFilePath.has(d.targetFile)) continue; targets.add(d.targetFile); } - ambiguous.set(file.filePath, collectAmbiguousReexports(drafts, byFilePath)); } subGraph.set(file.filePath, targets); } @@ -726,7 +851,7 @@ function buildReexportClosures( if (!scc.isCycle) { const filePath = scc.files[0]; if (filePath !== undefined) { - populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous); + populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly); } continue; } @@ -740,7 +865,9 @@ function buildReexportClosures( progressed = false; iter++; for (const filePath of scc.files) { - if (populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous)) { + if ( + populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly) + ) { progressed = true; } } @@ -820,6 +947,220 @@ function isNamedReexport(draft: ImportEdgeDraft): draft is ImportEdgeDraft & { * are still filling in, so detecting them needs a set that grows during the * fixpoint — the thing this pre-pass exists to avoid. */ +/** + * Per-file set of re-exported names that have NO decidable winner, from both + * detectors: `collectAmbiguousReexports` (flagged-named vs flagged-named) and + * `collectAmbiguousWildcards` (`export *` vs `export *`, direct declarations). + * Fixed for the whole run; consulted by the closure fixpoint AND by phase 4's + * wildcard expansion, so a refused name is absent from BOTH the exports an + * importer can reach and the module-scope bindings the file itself sees. + */ +interface ReexportAmbiguity { + readonly kind: 'named' | 'wildcard'; + readonly candidateDefIds: readonly string[]; +} + +function collectAmbiguityByFile( + files: readonly FinalizeFile[], + byFilePath: ReadonlyMap, + edgeIndex: ReadonlyMap, + wildcardCollisionIsAmbiguous: boolean, + topLevelOnly: boolean, +): ReadonlyMap> { + const out = new Map>(); + for (const file of files) { + const drafts = edgeIndex.get(file.filePath); + if (drafts === undefined) continue; + const byName = new Map(); + for (const name of collectAmbiguousReexports(drafts, byFilePath)) { + byName.set(name, { + kind: 'named', + candidateDefIds: namedReexportCandidates(drafts, byFilePath, name, topLevelOnly), + }); + } + // Wildcard-vs-wildcard is a language rule (`FinalizeHooks. + // wildcardCollisionIsAmbiguous`): ECMAScript excludes the name, C++ + // overloads it. Without the opt-in this half stays first-wins. + if (wildcardCollisionIsAmbiguous) { + for (const [name, ids] of collectAmbiguousWildcards(file, drafts, byFilePath)) { + if (!byName.has(name)) byName.set(name, { kind: 'wildcard', candidateDefIds: ids }); + } + } + if (byName.size === 0) continue; + out.set(file.filePath, byName); + } + return out; +} + +/** The declarations a flagged-named collision on `localName` points at. */ +function namedReexportCandidates( + drafts: readonly ImportEdgeDraft[], + byFilePath: ReadonlyMap, + localName: string, + topLevelOnly: boolean, +): readonly string[] { + const ids: string[] = []; + for (const draft of drafts) { + if (!isNamedReexport(draft) || draft.source.localName !== localName) continue; + const targetFile = draft.targetFile; + if (targetFile === null) continue; + const target = byFilePath.get(targetFile); + if (target === undefined) continue; + const def = findExportByName(target.localDefs, draft.source.importedName, topLevelOnly); + if (def !== undefined && !ids.includes(def.nodeId)) ids.push(def.nodeId); + } + return Object.freeze(ids); +} + +/** + * `export * from './a'; export * from './b'` where BOTH `a` and `b` declare + * `collide`: the language names no winner (ECMAScript excludes the name from + * the module's exports entirely; a direct `import { collide }` of it is a + * SyntaxError-class ambiguity). First-wins here published the `a` binding as + * `import-resolved` at full confidence — a definite target for a call that has + * none, which is the incorrect-context-over-missing-context failure in its + * purest form. The name is refused instead and reported. + * + * Decidable in this pre-pass because it reads only the targets' own + * `localDefs` — nothing that fills in during the closure fixpoint. Collisions + * that arrive TRANSITIVELY (two wildcards whose targets each re-export the + * name from somewhere else) are still first-wins; detecting them needs a set + * that grows mid-fixpoint, the thing this pre-pass exists to avoid. + * + * A name the file DECLARES itself, or re-exports by NAME, is excluded: an + * explicit export shadows every `export *`, so those collisions are legal and + * resolved by precedence, not ambiguous. + * + * Only MODULE-LEVEL, EXPORT-SHAPED declarations can collide. `localDefs` also + * carries class members, properties and parameters (a `Property:value` on two + * unrelated classes, an interface field named `move`), which no `export *` + * publishes. Counting those produced thousands of phantom collisions on a real + * monorepo (2,640 on grafana) and — the dangerous half — would have refused a + * genuinely exported `move()` because some class elsewhere had a `move` + * property. The wildcard closure loop tolerates the wider set because nobody + * imports a property by name; a refusal cannot afford the same tolerance. + * + * Export evidence, when the language supplies it (`SymbolDefinition.isExported`, + * tri-state), settles the rest: a def marked `false` is module-private and is + * neither a provider here nor published by the closure + * (`indexTopLevelExportsByName`), so a private `function foo` beside an exported + * one no longer refuses the export — and, the half that matters more, cannot be + * the first-listed winner the closure binds either. A def marked `true` counts + * whatever its label, `Variable` included: the closure publishes a `Variable`, + * so two sources each exporting `const alpha` are a real collision and must be + * refused rather than first-wins. + * + * Without evidence (`isExported` undefined — most languages) `Variable` is + * excluded from the COLLISION set only: the typical top-level `const` in a + * barrel's sources is module-private (`const category = ['Axis']` in fourteen + * option-builder files), so counting it would refuse a real exported constant + * of the same name for nothing. Residual risk, accepted, for that evidence-free + * case: a non-exported `function`/`class` sharing its name with an exported one + * behind the same barrel is counted as a collision and the export is refused — + * a missing edge, never a wrong one. `ownerId` is only set for class members, so + * a callable nested in an object literal (`showIf: (cfg) => …` across fourteen + * option-builder files) still counts as a provider when unmarked. Measured + * before the export marker existed: grafana@871af0720 refuses 52 names (from + * 2,640 before the member exclusion), discourse@3f71fa15c 5. + */ + +/** Labels that are never a module export, whatever their owner. */ +const NON_EXPORTABLE_MEMBER_LABELS: readonly string[] = [ + 'Property', + 'Method', + 'Constructor', + 'Parameter', + 'Field', +]; +/** Labels excluded from the collision set when no export evidence is present. */ +const UNMARKED_NON_COLLIDING_LABELS: ReadonlySet = new Set([ + ...NON_EXPORTABLE_MEMBER_LABELS, + 'Variable', +]); +/** + * Labels a module can never export by name: class/interface members and + * parameters. Filtered by LABEL, not `ownerId` — `ownerId` is populated in a + * later pass and is not reliable while the closure is built. + */ +const MEMBER_LABELS: ReadonlySet = new Set(NON_EXPORTABLE_MEMBER_LABELS); + +/** + * A declaration `export *` could publish, for COLLISION purposes: top-level, of + * an exportable kind, and not marked module-private. With export evidence the + * label rule yields to the marker (an exported `Variable` collides; a private + * `function` does not); without it `Variable` is left out — see the header. + */ +function isWildcardPublishable(def: SymbolDefinition): boolean { + // Explicit evidence wins over the label: a CommonJS `module.exports = { + // alpha() {} }` member is labeled Method and IS the module's export. + if (def.isExported === true) return true; + if (def.isExported === false) return false; + if (def.ownerId !== undefined) return false; + return !UNMARKED_NON_COLLIDING_LABELS.has(def.type); +} + +/** + * Can a declaration of the barrel's OWN shadow a name its `export *` sources + * collide on? Only a module-level binding can — ECMAScript's explicit-export + * precedence is about the module's own exports. A class MEMBER named `clash` + * (`export class Unrelated { clash() {} }`) is not such a binding and must not + * switch the collision check off; it did, and a confident edge to one source's + * `clash` was emitted where the import should have been refused. + */ +function canShadowWildcard(def: SymbolDefinition): boolean { + if (def.isExported === true) return true; + if (def.isExported === false) return false; + if (def.ownerId !== undefined) return false; + return !MEMBER_LABELS.has(def.type); +} +function collectAmbiguousWildcards( + file: FinalizeFile, + drafts: readonly ImportEdgeDraft[], + byFilePath: ReadonlyMap, +): ReadonlyMap { + const shadowed = new Set(); + for (const def of file.localDefs) { + if (!canShadowWildcard(def)) continue; + const name = deriveSimpleName(def); + if (name !== null) shadowed.add(name); + } + for (const draft of drafts) { + if (isNamedReexport(draft)) shadowed.add(draft.source.localName); + } + + // name → (target file → declaring def ids), in declaration order. + const providers = new Map>(); + for (const draft of drafts) { + if (draft.source.kind !== 'wildcard') continue; + const targetFile = draft.targetFile; + if (targetFile === null) continue; + const target = byFilePath.get(targetFile); + if (target === undefined) continue; + for (const def of target.localDefs) { + if (!isWildcardPublishable(def)) continue; + const name = deriveSimpleName(def); + if (name === null || shadowed.has(name)) continue; + let byTarget = providers.get(name); + if (byTarget === undefined) { + byTarget = new Map(); + providers.set(name, byTarget); + } + const ids = byTarget.get(targetFile); + if (ids === undefined) byTarget.set(targetFile, [def.nodeId]); + else ids.push(def.nodeId); + } + } + const conflicting = new Map(); + for (const [name, byTarget] of providers) { + // Two DIFFERENT source files declaring the name. The same file declaring + // it twice (overloads, a declaration merged with its namespace) is one + // provider and not a collision. + if (byTarget.size < 2) continue; + conflicting.set(name, Object.freeze([...byTarget.values()].flat())); + } + return conflicting; +} + function collectAmbiguousReexports( drafts: readonly ImportEdgeDraft[], byFilePath: ReadonlyMap, @@ -859,6 +1200,7 @@ function populateFileClosure( edgeIndex: ReadonlyMap, closures: Map>, ambiguousByFile: ReadonlyMap>, + topLevelOnly: boolean, ): boolean { const myClosure = closures.get(filePath); if (myClosure === undefined) return false; @@ -883,7 +1225,7 @@ function populateFileClosure( if (ambiguous.has(localName) || myClosure.has(localName)) continue; const importedName = draft.source.importedName; - const direct = findExportByName(targetModule.localDefs, importedName); + const direct = findExportByName(targetModule.localDefs, importedName, topLevelOnly); if (direct !== undefined) { myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) }); continue; @@ -909,9 +1251,27 @@ function populateFileClosure( const targetModule = byFilePath.get(targetFile); if (targetModule === undefined) continue; - for (const def of targetModule.localDefs) { - const name = deriveSimpleName(def); - if (name === null || ambiguous.has(name) || myClosure.has(name)) continue; + // Fan out the WINNER per name, not every def. `export const alpha = () => + // {}` emits both a `Variable` (the lexical declaration) and a `Function` + // (the arrow) under the same simple name; iterating `localDefs` raw let + // whichever came first — the `Variable` — claim the closure slot, and a + // call bound to a value shadow emits no CALLS edge. Named re-exports + // already go through `findExportByName`'s callable-preferred index; the + // wildcard hop is the same lookup and must apply the same preference. + // Measured: grafana `Button`/`clearButtonStyles` (arrow consts behind + // `export *`) resolved 8 of 475 ledger entries before this. + // Over TOP-LEVEL declarations only. `localDefs` also carries class members; + // `Foo.render` (label `Method`, callable) outranked the file's real + // `const render` in the callable-preferred index and `import { render }` + // bound to a symbol `export *` can never publish — a confident wrong edge + // where the value shadow used to yield none. Gated by the same hook as the + // named-import path: only a language that opted in (ECMAScript, where + // `export *` cannot publish a class member) narrows; every other language's + // wildcard keeps the wide index, whose members are legitimately reachable. + for (const [name, def] of (topLevelOnly ? indexTopLevelExportsByName : indexExportsByName)( + targetModule.localDefs, + )) { + if (ambiguous.has(name) || myClosure.has(name)) continue; myClosure.set(name, { def, via: Object.freeze([targetFile]) }); } const targetClosure = closures.get(targetFile); @@ -993,6 +1353,13 @@ function deriveSimpleName(def: SymbolDefinition): string | null { function findExportByName( defs: readonly SymbolDefinition[], name: string, + /** + * `true` (a `namedImportsBindTopLevelOnly` language): consult only + * module-level declarations, so a class member can neither outrank a + * top-level value nor bind on its own. Phase-4 wildcard expansion keeps + * the wide index — that is the path languages use to bind members. + */ + topLevelOnly: boolean = false, ): SymbolDefinition | undefined { // GENERIC RULE (applies to every language using this finalize // algorithm): when MULTIPLE `SymbolDefinition`s share the same simple @@ -1018,7 +1385,7 @@ function findExportByName( // // See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts` // for the cross-file regression this rule prevents. - return indexExportsByName(defs).get(name); + return (topLevelOnly ? indexTopLevelExportsByName(defs) : indexExportsByName(defs)).get(name); } /** @@ -1062,6 +1429,47 @@ function indexExportsByName( return index; } +/** + * `indexExportsByName` restricted to declarations a module publishes by name: + * members (by LABEL — `ownerId` is stamped by a later reconcile pass and is not + * reliable while the closure is built) are skipped unless the language marked + * them exported (a CommonJS `module.exports = { alpha() {} }` member), and so + * is any def the language marked module-private (`isExported === false`) — a + * function nested inside another function carries the Function label and used + * to displace the real exported value of the same name here; a barrel cannot + * republish what its source never exported, and binding it would put a private + * `function foo` in front of the exported one another source provides. + * `Variable` stays, since a barrel legitimately republishes a `const`. Same + * memoization contract. + */ +const TOP_LEVEL_EXPORTS_BY_NAME = new WeakMap< + readonly SymbolDefinition[], + ReadonlyMap +>(); + +function indexTopLevelExportsByName( + defs: readonly SymbolDefinition[], +): ReadonlyMap { + const cached = TOP_LEVEL_EXPORTS_BY_NAME.get(defs); + if (cached !== undefined) return cached; + const index = new Map(); + for (const d of defs) { + // Evidence over label, both ways: a marked-private def (a function nested + // in another function carries the Function label too) is skipped, and a + // marked-exported member (`module.exports = { alpha() {} }`) is admitted. + if (d.isExported === false) continue; + if (d.isExported !== true && MEMBER_LABELS.has(d.type)) continue; + const name = deriveSimpleName(d); + if (name === null) continue; + const existing = index.get(name); + if (existing === undefined) index.set(name, d); + else if (!isCallableOrTypeLike(existing.type) && isCallableOrTypeLike(d.type)) + index.set(name, d); + } + TOP_LEVEL_EXPORTS_BY_NAME.set(defs, index); + return index; +} + const EMPTY_NAME_SET: ReadonlySet = new Set(); const CALLABLE_OR_TYPE_LIKE: ReadonlySet = new Set([ @@ -1179,7 +1587,7 @@ function materializeBindings( linkedByScope: ReadonlyMap, hooks: FinalizeHooks, ): ReadonlyMap> { - const out = new Map>(); + const buckets = new Map>(); // Build a `nodeId → SymbolDefinition` index once across all files // (O(N_files × D_defs)) so the per-edge lookup below is O(1) instead @@ -1204,8 +1612,17 @@ function materializeBindings( scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); } - // Layer in finalized imports. - const imports = linkedByScope.get(file.moduleScope) ?? []; + buckets.set(file.moduleScope, scopeBindings); + } + + // Layer imports into their binding scope; lexical locals already live in + // the scope tree and must not be copied into unrelated scope buckets. + for (const [scopeId, imports] of linkedByScope) { + let scopeBindings = buckets.get(scopeId); + if (scopeBindings === undefined) { + scopeBindings = new Map(); + buckets.set(scopeId, scopeBindings); + } for (const edge of imports) { if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue; const def = defById.get(edge.targetDefId); @@ -1224,15 +1641,18 @@ function materializeBindings( if (name === null) continue; const incoming: BindingRef[] = [{ def, origin, via: edge }]; const existing = scopeBindings.get(name) ?? []; - scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); + scopeBindings.set(name, hooks.mergeBindings(existing, incoming, scopeId)); } + } + const out = new Map>(); + for (const [scopeId, scopeBindings] of buckets) { // Freeze nested buckets for immutability. const frozen = new Map(); for (const [name, refs] of scopeBindings) { frozen.set(name, Object.freeze(refs.slice())); } - out.set(file.moduleScope, frozen); + out.set(scopeId, frozen); } return out; diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index e90b0be85..4334d1534 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -111,6 +111,18 @@ export interface SymbolDefinition { * source (for example an anonymous class). Consumers may use this only as a * conservative priority hint; it does not change graph-node identity. */ isSynthetic?: boolean; + /** + * Whether the producing language saw EXPORT EVIDENCE on this declaration — + * an `export` modifier, a later `export { name }` specifier, an `export + * default name`. TRI-STATE, and the absence is load-bearing: `undefined` + * means the language emitted no verdict (most languages, and any ECMAScript + * file whose export surface is a CommonJS assignment the marker cannot read), + * which readers MUST treat as "unknown" and fall back to their prior + * behavior. Only `false` says "this module does not publish the name": a + * `false` keeps a module-private `function foo` from being counted as a + * wildcard provider or bound through a barrel's `export *` closure. + */ + isExported?: boolean; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; /** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`) diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 94a085daa..50a968146 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -107,7 +107,14 @@ export type CaptureMatch = Readonly>; * produced when `expandsWildcardTo` materializes a wildcard against target * exports — a provider must never emit it at parse time. */ -export type ParsedImport = +export type ParsedImport = ParsedImportSyntax & { + /** Lexical location retained by extraction, independently of execution timing. + * Absent for legacy or synthesized imports. Binding semantics remain opt-in + * through FinalizeHooks.importsBindAtLexicalScope. */ + readonly declaredAtScope?: ScopeId; +}; + +type ParsedImportSyntax = /** * Per-name import without rename. * @@ -183,14 +190,15 @@ export type ParsedImport = * field exists.** The natural place to decide it looks like the graph * bridge, by walking the scope the finalized edges hang off; that is * exactly what `graph-bridge/imports-to-edges.ts` once attempted, and it - * is dead code by construction. `finalize-algorithm.ts:295` publishes - * every file's finalized edges as - * `linkedByScope.set(file.moduleScope, …)`, so the map the bridge - * receives is keyed by the file's **Module** scope and by nothing else: - * the walk starts at a `Module` every time and answers `false` for every - * import in the tree. Finalize cannot recover the position either — - * `FinalizeFile.parsedImports` is a flat per-file `ParsedImport[]` with - * no scope attached. The extractor is the last stage that still knows + * is dead code by construction. Default finalize still publishes each + * file's edges under `file.moduleScope`, so a bridge walk that starts + * there answers `false` for every import. Resolvers that opt into + * `importsBindAtLexicalScope` instead key the edge by + * `parsed.declaredAtScope` when extraction placed the statement (Rust + * `use` in a function body is the example). Even then the *execution* + * question — does this import run at module init? — is not the same as + * the lexical key, and `FinalizeFile.parsedImports` is still a flat + * per-file list. The extractor is the last stage that still knows * where the statement sat (`scope-extractor.ts`, Pass 3), so it marks the * fact here and it rides the edge from there — see * {@link ImportEdge.runsOnlyWhenCalled}. diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 272d081c0..94fc262f6 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,7 +1,9 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).", "go": { - "fingerprint": "9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e", + "fingerprint": "2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f", + "_rebaselined_3190_ctor_qualified_name": "#3190: generic composite-literal constructors now carry @reference.qualified-name with the written pkg.Box[T] spelling so constructor fallback can tell a qualifier from a bare unique-name guess. DIGEST DRIFT ONLY: the tag is added to existing constructor matches (captureGroups unchanged on go-constructor-type-inference/cmd/main.go). Prior 990e4921a7ef0e6aa4d7da92b671de91fa2621cb27918adb27742740dc6ad902 -> 2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f; capture_groups_fp 2579, fixture_count 122.", + "_rebaselined_3190": "Corrected go-method-enrichment/app.go to share package animal with its declarations; its capture count is now 16 rather than 15. No Go emitter change. Scaling budget unchanged.", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.", @@ -137,8 +139,9 @@ "_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0." }, "java": { - "fingerprint": "2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff", + "fingerprint": "e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f", "scaling_budget": 1.5, + "_rebaselined_3190_ctor_qualified_name": "#3190: qualified constructors (`new pkg.Foo()`, `new pkg.Box()`) now copy @reference.call.constructor.qualified onto @reference.qualified-name so constructor fallback treats the written qualifier as precise. DIGEST DRIFT ONLY: the tag is added to existing F35 matches. Prior 2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff -> e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f; capture_groups_fp 3586, fixture_count 209.", "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.", "_rebaselined_2917_record_component_accessors": "#2917: every implicit Java record-component accessor now emits a component-bounded @scope.function plus @declaration.method/name/zero-arity/return-type metadata. The scope boundary prevents subsequent record-body references from being attributed to the accessor. Java was the only general language fingerprint to move; capture groups scale by exactly two per generated record component (small 5755 -> 6255, large 18405 -> 20005). Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 901a66c7dc0f071eeef9e4864b2519e5b58a1a141a1f9a7817ea42f7ff70eafb; scaling 0.961 < 1.5. Re-measured after merging origin/main, which carries #2935's is-synthetic sidecar on top of the same corpus: 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a -> 79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a; scaling 1.085 < 1.5, capture groups 6255/20005, capture_groups_fp 3560, fixture_count 206 (unchanged by the merge).", "_rebaselined_2900_record_heritage": "#2900 review follow-up: the Java scale unit now includes a record implementing Marker, so the record-declaration @reference.inherits path is fingerprinted and exercised at scale. Prior b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663 -> 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5; scaling 1.042 < 1.5.", @@ -171,7 +174,8 @@ "capture_groups_fp": 680 }, "typescript": { - "fingerprint": "fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff", + "fingerprint": "60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0", + "_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.", "scaling_budget": 1.5, "_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.", @@ -197,7 +201,8 @@ "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff." }, "javascript": { - "fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3", + "fingerprint": "b916c7072b30d09b4949803810604830ea1aca1cfdda0d9d9312f7cb22f7a10a", + "_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.", diff --git a/gitnexus/scripts/cross-platform-shard.ts b/gitnexus/scripts/cross-platform-shard.ts index 2c52dd86b..0cd9fe8d9 100644 --- a/gitnexus/scripts/cross-platform-shard.ts +++ b/gitnexus/scripts/cross-platform-shard.ts @@ -36,9 +36,9 @@ * * Only files heavy enough to matter are listed; everything else is carried by * {@link PER_FILE_OVERHEAD_SEC} alone. These are load-balancing hints, NOT - * assertions — no - * test asserts a runtime, and drift only makes the split slightly less even, so - * a stale entry is harmless and refreshing them is optional. Deliberately not + * assertions — no test measures elapsed time against this table. Missing or + * stale heavy entries can still overload a shard; refresh them from failed + * CI logs and replay that profile in the partition tests. Deliberately not * auto-generated: a committed table is reviewable and works offline, and the * alternative (timing files at CI runtime to decide the split) would make the * partition depend on the very machine load it is trying to protect against. @@ -49,13 +49,15 @@ export const WINDOWS_WEIGHTS_SEC: Readonly> = { 'test/integration/cli-e2e.test.ts': 621, 'test/integration/worker-pool.test.ts': 222, 'test/unit/incremental-vector-extension-ordering.test.ts': 87, - // ESTIMATE, not a measurement (#2841): this suite drives more full - // `runFullAnalysis` cycles than the VECTOR sibling above, so the 8 s - // PER_FILE_OVERHEAD floor would badly under-charge it and skew the Windows - // split — the failure mode that produced the job timeouts this table exists - // to prevent. Scaled from the sibling's measured 87 s by analyze-run count. - // Replace with a real figure after the first green Windows matrix run. - 'test/unit/incremental-index-extension-dml-gate.test.ts': 180, + // Measured on Windows in run 34014266125 (#3190, 2026-09-06). These DB + // suites landed together on shard 3: the old 180s estimate and missing + // entries made a ~27-minute recorded load look like an ~12-minute shard. + // Upstream speedups may reduce these figures; retaining conservative weights + // keeps the expensive suites distributed without changing the watchdog. + 'test/unit/incremental-index-extension-dml-gate.test.ts': 414, + 'test/integration/skills-e2e.test.ts': 444, + 'test/integration/fts-extension-e2e.test.ts': 146, + 'test/integration/analyze-wal-checkpoint-failure.test.ts': 86, 'test/integration/cli-limit-e2e.test.ts': 75, 'test/unit/hooks.test.ts': 26, 'test/integration/analyze-heap-oom-e2e.test.ts': 23, diff --git a/gitnexus/src/core/graph/edge-reasons.ts b/gitnexus/src/core/graph/edge-reasons.ts new file mode 100644 index 000000000..ad31db850 --- /dev/null +++ b/gitnexus/src/core/graph/edge-reasons.ts @@ -0,0 +1,38 @@ +/** + * Edge `reason` values that mark a CALLS edge as a HEURISTIC GUESS rather than + * a resolution. + * + * The distinction exists because an edge's `confidence` number cannot carry it. + * `GLOBAL_NAME_FALLBACK_REASON` edges are emitted at exactly 0.5 — the same + * number as `process-processor`'s `MIN_TRACE_CONFIDENCE` and + * `community-processor`'s `MIN_CONFIDENCE_LARGE` — so a `confidence < 0.5` + * gate does NOT exclude them. Anything that must exclude guesses has to read + * the reason, which is why `KnowledgeGraph.forEachRelationshipFields` passes it + * and why `GraphEmitSink` retains a reason column. + */ + +/** + * The target was chosen because its SIMPLE NAME is unique in the workspace — + * not because any import, scope chain, or type binding led to it. + * + * Emitted by the unique-simple-name tiers of the free-call fallback + * (`pickUniqueGlobalCallable` and constructor-form `pickUniqueGlobalClass`), + * and only for the languages that opt into `allowGlobalFreeCallFallback`. It + * is a name collision away from being wrong and must never be presented as + * an import-resolved edge: a reader who cannot tell the two apart has no way + * to discount the guess. + */ +export const GLOBAL_NAME_FALLBACK_REASON = 'global-name-fallback'; + +/** + * Reasons excluded from process tracing and large-graph community detection. + * + * Both walks exist to describe how the program actually flows. Seeding a flow + * from a unique-name guess produces a confident-looking trace through code that + * may never call each other, which is worse than a shorter honest trace. + */ +const HEURISTIC_EDGE_REASONS: ReadonlySet = new Set([GLOBAL_NAME_FALLBACK_REASON]); + +/** True when this edge's target was guessed by name rather than resolved. */ +export const isHeuristicEdgeReason = (reason: string): boolean => + HEURISTIC_EDGE_REASONS.has(reason); diff --git a/gitnexus/src/core/graph/graph.ts b/gitnexus/src/core/graph/graph.ts index 1c708a4af..84323bf8e 100644 --- a/gitnexus/src/core/graph/graph.ts +++ b/gitnexus/src/core/graph/graph.ts @@ -163,9 +163,17 @@ export const createKnowledgeGraph = (): KnowledgeGraph => { relationshipMap.forEach(fn); }, forEachRelationshipFields( - fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void, + fn: ( + sourceId: string, + targetId: string, + type: RelationshipType, + confidence: number, + reason: string, + ) => void, ) { - relationshipMap.forEach((rel) => fn(rel.sourceId, rel.targetId, rel.type, rel.confidence)); + relationshipMap.forEach((rel) => + fn(rel.sourceId, rel.targetId, rel.type, rel.confidence, rel.reason), + ); }, getNode: (id: string) => nodeMap.get(id), diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index 9d9caf12b..a7a7e8a04 100644 --- a/gitnexus/src/core/graph/types.ts +++ b/gitnexus/src/core/graph/types.ts @@ -31,14 +31,26 @@ export interface KnowledgeGraph { * Zero-allocation relationship scan: fields, not objects (#2680). * * The whole-graph scans (the local-symbol pruner, community detection, - * process extraction) read only these four fields, and materializing a + * process extraction) read only these five fields, and materializing a * `GraphRelationship` per edge just to read them dominates iteration cost once * relationships are held columnar — measured at ~90 ms per analyze on a * million-edge graph. Prefer this over `forEachRelationship` in any pass that * walks every edge and needs no other field. + * + * `reason` is passed because confidence alone cannot separate a heuristic + * name guess from a resolved edge that happens to sit at the same number: + * the global-name fallback emits at exactly the process/community threshold + * (0.5), so the walks that must exclude it have to read the reason. See + * `GraphEmitSink`'s reason column, added for this consumer. */ forEachRelationshipFields: ( - fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void, + fn: ( + sourceId: string, + targetId: string, + type: RelationshipType, + confidence: number, + reason: string, + ) => void, ) => void; getNode: (id: string) => GraphNode | undefined; nodeCount: number; diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts index 3146aff26..4a0e99f6b 100644 --- a/gitnexus/src/core/ingestion/finalize-orchestrator.ts +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -200,8 +200,11 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) { */ function withDefaultHooks(partial: Partial): FinalizeHooks { return { + importsBindAtLexicalScope: partial.importsBindAtLexicalScope === true, resolveImportTarget: partial.resolveImportTarget ?? (() => null), isNamespaceImport: partial.isNamespaceImport, + wildcardCollisionIsAmbiguous: partial.wildcardCollisionIsAmbiguous === true, + namedImportsBindTopLevelOnly: partial.namedImportsBindTopLevelOnly === true, expandsWildcardTo: partial.expandsWildcardTo ?? (() => []), mergeBindings: partial.mergeBindings ?? diff --git a/gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts new file mode 100644 index 000000000..562311bb8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/dart/name-fallback-visibility.ts @@ -0,0 +1,77 @@ +/** + * Dart's veto on the global-name fallback — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. + * + * Dart's privacy is LIBRARY-scoped and marked in the identifier itself: a name + * beginning with `_` is visible only inside its own library and cannot be + * imported by any spelling. So a `_`-prefixed candidate in another file is an + * impossible call, not an unlikely one. + * + * The exact boundary is `part` / `part of` — one library spanning several + * files, with `_` names shared between them — and the extractor does not + * surface `part` directives yet (the Dart query captures only `library_import`; + * nothing in `languages/dart/` reads `part`). Without them a cross-file `_` + * candidate is UNDECIDABLE, not impossible: refusing on "different file" would + * delete real edges on Flutter's dominant generated-code idiom (`factory + * Foo.fromJson(j) => _$FooFromJson(j)` calls into `foo.g.dart`, a `part` beside + * it), and refusing on "different directory" is wrong too — a `part` URI is a + * relative URI and legally traverses directories (`part '../shared/gen.dart';`). + * An earlier version refused the cross-directory case as "no `part` layout can + * make this legal"; that claim was false, so the hook now REFUSES NOTHING and + * every cross-file `_` candidate stays a LABELED edge (0.5 / + * `global-name-fallback`), which is the honest answer until `part` is + * extracted. A caller that names the candidate's file in a directive is + * recognized already, for the day the extractor surfaces `part` as an import + * target; at that point "not the same library" becomes decidable and the + * refusal can return. + * + * Public names are left to the labeled-edge path. Dart does require an import + * for a cross-library public name, but the fallback exists partly to recover + * edges where the import chain was not reconstructed, and refusing every + * cross-file public call would delete real edges to buy a rule the `_` marker + * already gives for free. + */ + +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { + modulePathReaches, + stripExtension, +} from '../../scope-resolution/utils/name-fallback-visibility.js'; + +/** Dart privacy marker: a leading underscore on the declared identifier. Read + * from the last `qualifiedName` segment, so `_Foo.bar` is public `bar` on a + * private class and `Foo._bar` is the private member. */ +function isPrivateDartName(candidate: SymbolDefinition): boolean { + const qualified = candidate.qualifiedName ?? ''; + const dot = qualified.lastIndexOf('.'); + const simple = dot === -1 ? qualified : qualified.slice(dot + 1); + return simple.startsWith('_'); +} + +/** + * Are these two files parts of one library? True when the caller names the + * candidate's file in a `part` / `part of` directive, which the extractor + * surfaces as an ordinary import target. + */ +function sharesLibrary(callerParsed: ParsedFile, candidateFilePath: string): boolean { + const candidateModule = stripExtension(candidateFilePath); + for (const imp of callerParsed.parsedImports) { + if (modulePathReaches(stripExtension(imp.targetRaw), candidateModule)) return true; + } + return false; +} + +export function dartIsGlobalNameFallbackPlausible(ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; +}): boolean { + if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; + if (!isPrivateDartName(ctx.candidate)) return true; + // A directive naming the candidate's file is positive evidence of one library. + if (sharesLibrary(ctx.callerParsed, ctx.candidate.filePath)) return true; + // Any other file may be a `part` of the caller's library — a sibling or, via + // a relative `part` URI, a file in another directory. Undecidable without + // `part` extraction (see the header), so allowed as a labeled guess, never + // refused. + return true; +} diff --git a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts index 76bec5d74..bc46ad452 100644 --- a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts @@ -41,6 +41,7 @@ import { decodeMarker } from '../../utils/heritage-marker.js'; import { typeApplicationArguments } from '../../utils/template-arguments.js'; import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import { expandDartWildcardNames } from './expand-wildcards.js'; +import { dartIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; interface ClassDefRef { readonly graphId: string; @@ -233,5 +234,6 @@ export const dartScopeResolver: ScopeResolver = { // No `new`: bare `Foo()` resolves to the type; with cross-file imports the // callee is reachable workspace-wide. allowGlobalFreeCallFallback: true, + isGlobalNameFallbackPlausible: dartIsGlobalNameFallbackPlausible, constructorCallTargetsClass: true, }; diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts index c1a8cf23c..97fe71d83 100644 --- a/gitnexus/src/core/ingestion/languages/go/captures.ts +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -508,11 +508,24 @@ function normalizeGenericConstructorCapture( if (referenceNode !== undefined && referenceNode.type === 'generic_type') { const base = referenceNode.childForFieldName('type'); if (base !== null) { - grouped['@reference.name'] = syntheticCapture( - '@reference.name', - base, - extractSimpleTypeNameText(base), - ); + const simple = extractSimpleTypeNameText(base); + // Keep the written `pkg.Box[T]` spelling so constructor resolution + // can tell a package-qualified type from a bare unique-name guess. + // `Box[T]` is also `text !== simple` (`Box[T]` vs `Box`) — that is + // not a qualifier. Only a `qualified_type` base (`pkg.Box`) is. + if ( + grouped['@reference.qualified-name'] === undefined && + base.type === 'qualified_type' && + referenceNode.text.length > 0 && + referenceNode.text !== simple + ) { + grouped['@reference.qualified-name'] = syntheticCapture( + '@reference.qualified-name', + referenceNode, + referenceNode.text, + ); + } + grouped['@reference.name'] = syntheticCapture('@reference.name', base, simple); } } } diff --git a/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts new file mode 100644 index 000000000..02b968e7b --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/name-fallback-visibility.ts @@ -0,0 +1,126 @@ +/** + * Go's veto on the global-name fallback — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. + * + * Go's visibility rules are unusually decidable from a file path and an + * identifier, which is why this is the language the guard is most complete for: + * + * 1. A package IS a directory. Two files in the same directory see each + * other's identifiers with no import and no qualification, so a same- + * directory candidate is always plausible. + * 2. An identifier is exported iff it begins with an upper-case letter. An + * UNEXPORTED identifier is invisible outside its own package — no import + * makes it reachable, so a cross-directory candidate with a lower-case + * initial is not unlikely, it is IMPOSSIBLE. + * 3. A bare exported identifier from another package requires a DOT import. + * Ordinary, alias, and blank imports never introduce a bare callable. + * + * Rule 2 is the one that matters most in practice: `uniqueHelperXyz` defined + * once in package `a` used to acquire a caller in package `b` purely because + * the name was unique in the repo, and the resulting edge was published as + * `import-resolved` — a caller Go itself would reject. + * + * Methods require a receiver and never qualify for this bare-name tier. + */ + +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { + modulePathReaches, + directoryOf, +} from '../../scope-resolution/utils/name-fallback-visibility.js'; +import { inferGoPackageName } from './package-clause.js'; + +/** + * Compare actual package clauses, not a guessed `_test` suffix convention: + * `foo_test` can itself be a production package name. Missing source remains + * undecidable, while the test-file boundary is always available from the path. + */ +function classifyGoFile( + filePath: string, + sourceTextOf: ((p: string) => string | undefined) | undefined, +): { isTest: boolean; declared: string | undefined } { + const isTest = filePath.endsWith('_test.go'); + const text = sourceTextOf?.(filePath); + return { + isTest, + declared: text === undefined ? undefined : (inferGoPackageName(text) ?? undefined), + }; +} + +/** Go export rule: an upper-case initial, by Unicode letter case. */ +function isExportedGoName(name: string): boolean { + return /^\p{Lu}/u.test(name); +} + +/** + * The simple identifier a Go declaration contributes to its package scope: the + * last segment of `qualifiedName`, which is `Type.method` for a method and the + * bare identifier for a function. Either way the LAST segment is the identifier + * whose case decides export. + */ +function goSimpleName(candidate: SymbolDefinition): string { + const qualified = candidate.qualifiedName ?? ''; + const dot = qualified.lastIndexOf('.'); + return dot === -1 ? qualified : qualified.slice(dot + 1); +} + +export function goIsGlobalNameFallbackPlausible(ctx: { + readonly sourceTextOf?: (filePath: string) => string | undefined; + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + readonly site?: { readonly rawQualifiedName?: string }; +}): boolean { + // A PATH-QUALIFIED call (`pkg.Export()`) reached this tier because the + // qualifier could not be followed. Qualification removes the bare-name + // "only a dot import introduces this identifier" rule — not export or + // test-package visibility. `other.private()` is still impossible. + const isPackageQualified = ctx.site?.rawQualifiedName !== undefined; + // Methods require a receiver, even within their own package or a dot import. + if (ctx.candidate.type === 'Method' || ctx.candidate.qualifiedName?.includes('.')) return false; + const callerDir = directoryOf(ctx.callerParsed.filePath); + const candidateDir = directoryOf(ctx.candidate.filePath); + // Same directory is NOT the same package (rule 1, refined): a `_test.go` + // file may declare `foo_test`, and test-only declarations are invisible to + // non-test files. Apply the split `package-siblings.ts` uses for the + // confident tier, so the heuristic tier cannot reopen what it closed. + if (callerDir === candidateDir) { + const caller = classifyGoFile(ctx.callerParsed.filePath, ctx.sourceTextOf); + const cand = classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf); + // Non-test files never see test-only declarations. + if (cand.isTest && !caller.isTest) return false; + // 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 + ) + return true; + } + + // 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 + // exception in particular used to accept a root `helper_test.go` export). + if (classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf).isTest) return false; + + const simpleName = goSimpleName(ctx.candidate); + // No identifier to read the case of — an unanswered question, not a refusal. + if (simpleName === '') return true; + // Unexported across a package boundary (rule 2): no import can reach it. + if (!isExportedGoName(simpleName)) return false; + if (isPackageQualified) return true; + + // Only a dot import introduces a bare name. A candidate in the module ROOT package has an empty + // directory, which `modulePathReaches` cannot align against any import path + // (the root package is imported by the module path alone, which the repo- + // relative layout does not carry). That is an unanswered question, not a + // refusal — a dot import is plausible even if its path cannot be aligned. + return ctx.callerParsed.parsedImports.some( + (imp) => + imp.kind === 'wildcard' && + (candidateDir === '' || modulePathReaches(imp.targetRaw, candidateDir)), + ); +} diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts index ff891d079..7c03e10f6 100644 --- a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts @@ -16,6 +16,7 @@ import { detectGoInterfaceImplementations } from './interface-impls.js'; import { populateGoRangeBindings } from './range-binding.js'; import { expandGoWildcardNames } from './expand-wildcards.js'; import { goMapValueType } from './interpret.js'; +import { goIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; /** Slice `[]T` and array `[N]T` / `[...]T` → the element spelling. Hoisted — * a literal inside the hook would mint a fresh RegExp per folded subscript. */ @@ -85,6 +86,7 @@ export const goScopeResolver: ScopeResolver = { hoistTypeBindingsToModule: true, propagatesReturnTypesAcrossImports: true, allowGlobalFreeCallFallback: true, + isGlobalNameFallbackPlausible: goIsGlobalNameFallbackPlausible, populateNamespaceSiblings: populateGoPackageSiblings, mirrorNamespaceTypeBindings: mirrorGoNamespaceTypeBindings, diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 5d31aed4a..b227d549c 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -357,6 +357,26 @@ export function emitJavaScopeCaptures( } } + // Qualified constructor `new pkg.Foo()` already binds the simple tail as + // `@reference.name` (F35). Copy the scoped spelling onto + // `@reference.qualified-name` so constructor resolution can treat it as a + // written qualifier instead of a bare unique-name guess (C# does the same). + const ctorQualifiedNode = nodeMap['@reference.call.constructor.qualified']; + const ctorQualifiedText = grouped['@reference.call.constructor.qualified']?.text; + if ( + ctorQualifiedNode !== undefined && + ctorQualifiedText !== undefined && + ctorQualifiedText.length > 0 && + ctorQualifiedText !== grouped['@reference.name']?.text && + grouped['@reference.qualified-name'] === undefined + ) { + grouped['@reference.qualified-name'] = syntheticCapture( + '@reference.qualified-name', + ctorQualifiedNode, + ctorQualifiedText, + ); + } + // Synthesize `@reference.arity` on every callsite. const callTag = ( ['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index d3ba94791..b16cb2ba1 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -36,6 +36,7 @@ import { syntheticCapture, type SyntaxNode, } from '../../utils/ast-helpers.js'; +import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js'; import { splitImportStatement } from '../typescript/import-decomposer.js'; import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js'; import { computeTsArityMetadata } from '../typescript/arity-metadata.js'; @@ -983,6 +984,8 @@ export function emitJsScopeCaptures( } const rawMatches = getJsScopeQuery(filePath).matches(tree.rootNode); + // Export evidence, read once per file (see `ts-js-export-marker.ts`). + const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath); const out: CaptureMatch[] = []; for (const m of rawMatches) { @@ -1124,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', + ); } } @@ -1207,6 +1217,20 @@ export function emitJsScopeCaptures( // non-call match, an absent receiver, or a chain with no nameable base // all leave `grouped` untouched. synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']); + // `@declaration.is-exported`: a verdict for every declaration the file's + // export surface can decide (see `ts-js-export-marker.ts`); nothing where + // it cannot, because absence is the honest answer there. + const declNameNode = groupedNodes['@declaration.name']; + if (exportEvidence !== undefined && declNameNode !== undefined) { + const verdict = esmExportVerdict(declNameNode, exportEvidence); + if (verdict !== undefined) { + grouped['@declaration.is-exported'] = syntheticCapture( + '@declaration.is-exported', + declNameNode, + verdict ? 'true' : 'false', + ); + } + } out.push(grouped); // Synthesize `this` receiver type-bindings on class member functions. diff --git a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts index 8d74798ee..280fd9929 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts @@ -97,6 +97,12 @@ const javascriptScopeResolver: ScopeResolver = { // explicit imports at the call site. Workspace-wide unique-name fallback // recovers these edges. allowGlobalFreeCallFallback: true, + + // Same ECMAScript `export *` exclusivity as TypeScript: a name declared by + // two wildcard sources is refused, not guessed. + exclusiveWildcardReexports: true, + // Same ECMAScript rule as TypeScript: named imports bind module-level declarations only. + namedImportsBindTopLevelOnly: true, }; export { javascriptScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts new file mode 100644 index 000000000..4b225c96f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/name-fallback-visibility.ts @@ -0,0 +1,196 @@ +/** + * Ruby's veto on the global-name fallback — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. + * + * Ruby keeps the labeled fallback deliberately: with autoload (Rails' zeitwerk, + * `ActiveSupport::Dependencies`) a file genuinely can call a method whose + * defining file it never requires, so "no require" is NOT evidence of + * impossibility the way it is in Go or Rust. + * + * What IS decidable is namespacing — but only for CLASS bodies. A method + * defined inside a `class` is not callable as a bare `helper()` from another + * file unless that file names the class somehow (a receiver `Ns.helper`, a + * subclass declaration, an `include`, a `require`). A method defined inside a + * `module` body is different in kind: modules exist to be mixed in, and Rails + * mixes them in for you — every `*Helper` module is included into views and + * controllers by the framework, and concerns arrive through `included do` + * hooks — so a bare `format_money()` in a view legitimately reaches + * `module ApplicationHelper` with no `include`, `require`, or constant + * anywhere in the caller. Refusing that would delete real edges on exactly the + * codebase shape Ruby's fallback exists to serve. So module-owned methods stay + * a LABELED guess, and the refusal targets CLASS-owned methods whose class the + * caller never names. A top-level method (`ownerId === undefined`) is left + * alone, since that is the shape autoload actually delivers. When the owner + * cannot be found or typed (no `parsedFileOf`, owner outside the file set), + * the question is unanswered and the edge is allowed. + * + * "Never names" is read from the caller's own text-visible signals — its + * `require`/`include`/`extend` targets, and any reference site spelling the + * namespace's constant. If any of them mentions the namespace, the call is + * plausible and the labeled edge stands. `require` paths are snake_case + * (`billing/invoice_service`) while constants are CamelCase + * (`Billing::InvoiceService`), so the comparison normalizes both sides — + * without that the `require` branch never matched anything. + * + * One more thing a caller file can do without naming the class: INHERIT its + * way to it. `class UsersController < AdminController` reaches every method + * `ApplicationController` defines while naming only `AdminController`, and an + * `include Concern` reaches whatever that concern includes in turn. Neither + * chain is decidable from one file, so a caller with ANY inheritance or mixin + * surface (an `inherits` site, an `include`/`extend`/`prepend` marker) is + * treated as plausible. So is a caller file that DEFINES a module: a module's + * methods run against whatever class includes the module, and call that class's + * methods bare — `module PostGuardian; def can_see?; is_staff? ...` reaches + * `class Guardian#is_staff?` because Guardian includes PostGuardian, a fact the + * module file never states. What remains refused is the shape Ruby itself + * rejects: a bare call to a class's method from a file that inherits nothing, + * mixes in nothing, defines no module, and never spells the class. Measured on + * discourse@3f71fa15c that is ~1000 refusals, and the sample is what the rule + * promises: RSpec `before`/`after`/`subject` guessed to serializer methods of + * the same name, `Gemfile`'s `gem` to `Plugin::Instance#gem`, `routes.rb`'s + * `get` to `Draft#get` — fabricated callers, every one. + */ + +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { moduleSegments } from '../../scope-resolution/utils/name-fallback-visibility.js'; +import { HERITAGE_MARKER_PREFIX } from '../../utils/heritage-marker.js'; + +/** + * The namespace segments a candidate's qualified name declares, minus the + * method itself. `Billing::Invoice#total` / `Billing.Invoice.total` → the + * `Billing`, `Invoice` constants a caller would have to name. + */ +function namespaceConstantsOf(candidate: SymbolDefinition): readonly string[] { + const qualified = candidate.qualifiedName; + if (qualified === undefined || qualified === '') return []; + const segments = qualified + .split(/::|\.|#/) + .map((s) => s.trim()) + .filter((s) => s !== ''); + // Drop the trailing method name; what remains is the namespace chain. + const namespace = segments.slice(0, -1); + // Only CONSTANTS name a Ruby namespace (upper-case initial). A lower-case + // segment is a receiver expression, not a namespace a caller can mention. + return namespace.filter((s) => /^[A-Z]/.test(s)); +} + +/** + * `billing/invoice_service` vs `InvoiceService`: a path segment names a + * constant when, with underscores removed, the two are equal case-insensitively. + * Zeitwerk's own inflection rule, minus acronym overrides — over-matching here + * only loses a refusal (the edge stays, labeled), which is the safe direction. + */ +function pathSegmentNamesConstant(segment: string, constant: string): boolean { + return segment.replace(/_/g, '').toLowerCase() === constant.toLowerCase(); +} + +function requireReachesConstant(targetRaw: string, constant: string): boolean { + for (const segment of moduleSegments(targetRaw)) { + if (pathSegmentNamesConstant(segment, constant)) return true; + } + return false; +} + +/** + * The declared kind of the candidate's owner, read from the candidate's own + * file. Ruby labels `class` bodies `Class` and `module` bodies `Trait` + * (`query.ts`: "module (labeled Trait for class-like registry lookup)"). + * `undefined` when the owner cannot be found — an unanswered question. + */ +function ownerLabelOf( + candidate: SymbolDefinition, + parsedFileOf: ((filePath: string) => ParsedFile | undefined) | undefined, +): string | undefined { + const ownerId = candidate.ownerId; + if (ownerId === undefined || parsedFileOf === undefined) return undefined; + const owner = parsedFileOf(candidate.filePath)?.localDefs.find((d) => d.nodeId === ownerId); + return owner?.type; +} + +/** + * Calls that rebind `self` for the duration of a block: inside + * `service.instance_eval do … end` a bare `helper()` is dispatched on + * `service`, so it legitimately reaches a class-owned method the caller file + * never names. Detected on the caller's SOURCE TEXT, not the call site — the + * site does not know which block encloses it — so any file that uses one of + * these forms keeps its class-owned guesses LABELED rather than refused. Coarse + * in the safe direction: it loses refusals in that file, never an edge. + */ +const SELF_REBINDING_CALL = + /\b(?:instance_eval|instance_exec|class_eval|class_exec|module_eval|module_exec)\b/; + +export function rubyIsGlobalNameFallbackPlausible(ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined; + readonly sourceTextOf?: (filePath: string) => string | undefined; +}): boolean { + if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; + // Top-level method — the autoload shape the fallback exists for. + if (ctx.candidate.ownerId === undefined) return true; + // A self-rebinding block anywhere in the caller makes "the class is never + // named here" no proof of impossibility (see `SELF_REBINDING_CALL`). The + // pipeline always supplies the source; a missing text is an unanswered + // question and keeps the labeled edge as well. + const text = ctx.sourceTextOf?.(ctx.callerParsed.filePath); + if (text === undefined || SELF_REBINDING_CALL.test(text)) return true; + // Only a CLASS body makes a bare cross-file call impossible without naming + // it (see the header). A module owner, or an owner we cannot type, is not a + // refusal. + if (ownerLabelOf(ctx.candidate, ctx.parsedFileOf) !== 'Class') return true; + + const constants = namespaceConstantsOf(ctx.candidate); + // Owned but with no nameable namespace (an anonymous or lower-cased owner): + // nothing to check, so do not refuse on an unanswered question. + if (constants.length === 0) return true; + + // Any inheritance or mixin surface in the caller can reach the class + // transitively (see the header) — not decidable here, so not refused. + for (const site of ctx.callerParsed.referenceSites) { + if (site.kind === 'inherits') return true; + } + for (const imp of ctx.callerParsed.parsedImports) { + if (imp.targetRaw.startsWith(HERITAGE_MARKER_PREFIX)) return true; + } + // A file that defines a module is a mixin whose methods run inside some + // including class (see the header). Ruby labels `module` bodies `Trait`. + for (const def of ctx.callerParsed.localDefs) { + if (def.type === 'Trait') return true; + // A file that REOPENS `class Invoice` names the owner even without an + // inherits site or a reference capture — the declaration itself is the + // mention. + if (def.type === 'Class') { + const declared = (def.qualifiedName ?? '') + .split(/::|\.|#/) + .map((s) => s.trim()) + .filter((s) => s !== '') + .at(-1); + if (declared !== undefined && constants.includes(declared)) return true; + } + } + + for (const imp of ctx.callerParsed.parsedImports) { + for (const constant of constants) { + if (requireReachesConstant(imp.targetRaw, constant)) return true; + // `include Billing::Invoice` arrives as an import whose LOCAL name is the + // constant rather than a path. Not every variant carries one. + if ('localName' in imp && imp.localName === constant) return true; + } + } + // A bare mention of the constant anywhere in the caller (`Billing::Invoice`, + // `Invoice.new`) is enough to make the namespace present in this file. The + // qualified spelling is matched SEGMENT-wise on `::` / `.`: `InvoiceService` + // is not a mention of `Invoice`, and a substring test made it one. + for (const site of ctx.callerParsed.referenceSites) { + for (const constant of constants) { + if (site.name === constant) return true; + if ( + site.rawQualifiedName !== undefined && + site.rawQualifiedName.split(/::|\./).some((segment) => segment === constant) + ) { + return true; + } + } + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts index 160bec94e..088651a81 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -10,6 +10,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; import { decodeMarker } from '../../utils/heritage-marker.js'; +import { rubyIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; /** * #1991: resolve a BARE mixin reference (`include Loggable`) to a nested module by @@ -287,4 +288,5 @@ export const rubyScopeResolver: ScopeResolver = { fieldFallbackOnMethodLookup: true, propagatesReturnTypesAcrossImports: true, allowGlobalFreeCallFallback: true, + isGlobalNameFallbackPlausible: rubyIsGlobalNameFallbackPlausible, }; diff --git a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts new file mode 100644 index 000000000..1afdb900f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts @@ -0,0 +1,183 @@ +/** + * Rust's veto on the global-name fallback — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. + * + * Rust has NO ambient namespace. A bare `helper()` resolves only against names + * in scope, and for an item declared in another module the only way in is a + * `use` path (or a fully-qualified `crate::a::b::helper()` call, which is not a + * bare free call and never reaches this tier — it carries a qualified name and + * is resolved earlier by `resolveQualifiedFreeCall`). + * + * 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`). `crate::` names the root; `self::` and `super::` resolve relative + * to the caller's module before comparison. + */ + +import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { + modulePathReaches, + stripExtension, +} from '../../scope-resolution/utils/name-fallback-visibility.js'; + +/** File-stems that name their PARENT directory as the module, not themselves. */ +const RUST_DIRECTORY_MODULE_STEMS: ReadonlySet = new Set(['mod', 'lib', 'main']); + +/** Directories that hold a crate's root and contribute no module segment, so + * `src/net/http.rs` is module `net::http` and not `src::net::http`. */ +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', '$crate']); + +// One scope lookup per immutable parsed-file snapshot, not per fallback site. +// Weak keys release both the snapshot and its index at the end of ingestion. +const scopeLookupByFile = new WeakMap>(); + +/** + * The module path a Rust file provides, as a `/`-joined path. + * + * Two normalizations, both needed for a file path and a `use` path to line up + * on their trailing segments: the `mod`/`lib`/`main` stem names its parent + * directory, and a leading crate-root directory (`src/`) is not a module. + */ +function rustModulePathOf(filePath: string): string { + const withoutExtension = stripExtension(filePath); + const segments = withoutExtension.split('/').filter((s) => s !== ''); + const stem = segments[segments.length - 1]; + if (stem !== undefined && RUST_DIRECTORY_MODULE_STEMS.has(stem)) segments.pop(); + // Only the crate-root folder is non-semantic. A nested `src/` is module + // `src` (`src/src/helper.rs` → `crate::src::helper`), not another root. + if (segments.length > 0 && RUST_CRATE_ROOT_DIRS.has(segments[0]!)) segments.shift(); + return segments.join('/'); +} + +/** + * Directory that owns the crate-root folder (`src`/`tests`/…). Empty when the + * file sits at the analyzed root (`src/lib.rs`) so a single-crate tree is not + * refused on a missing workspace prefix. + */ +function rustCrateRootOf(filePath: string): string { + const segments = stripExtension(filePath) + .split('/') + .filter((s) => s !== ''); + const stem = segments[segments.length - 1]; + if (stem !== undefined && RUST_DIRECTORY_MODULE_STEMS.has(stem)) segments.pop(); + const rootIdx = segments.findIndex((s) => RUST_CRATE_ROOT_DIRS.has(s)); + if (rootIdx <= 0) return ''; + return segments.slice(0, rootIdx).join('/'); +} + +/** 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('::'); +} + +export function rustIsGlobalNameFallbackPlausible(ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + readonly site: { + readonly name: string; + readonly rawQualifiedName?: string; + readonly inScope?: ScopeId; + }; +}): boolean { + if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; + // A PATH-QUALIFIED call (`User::new(...)`, `crate::a::helper()`) reaches this + // tier when the qualifier could not be followed, carrying only its tail name. + // It is not a bare-name guess: the source named the path, so the module rule + // below would refuse an edge the code spells out. + if (ctx.site.rawQualifiedName !== undefined) return true; + + const candidateModule = rustModulePathOf(ctx.candidate.filePath); + // A candidate whose file maps to no module path (a crate root reduced to '') + // is not something this rule can speak about; allow the labeled edge rather + // than refuse on an unanswered question. + if (candidateModule === '') return true; + + const candidateName = rustSimpleNameOf(ctx.candidate); + // Imports are lexical evidence, not a file-wide allowlist. Legacy/synthetic + // imports without a scope receipt retain the previous conservative behavior. + let visibleScopes: Set | undefined; + if (ctx.site.inScope !== undefined) { + let scopes = scopeLookupByFile.get(ctx.callerParsed); + if (scopes === undefined) { + scopes = new Map(ctx.callerParsed.scopes.map((scope) => [scope.id, scope])); + scopeLookupByFile.set(ctx.callerParsed, scopes); + } + visibleScopes = new Set(); + let current: ScopeId | null = ctx.site.inScope; + while (current !== null && !visibleScopes.has(current)) { + visibleScopes.add(current); + const scope = scopes.get(current); + if (scope === undefined || scope.kind === 'Namespace') break; + current = scope.parent; + } + } + for (const imp of ctx.callerParsed.parsedImports) { + if ( + imp.declaredAtScope !== undefined && + visibleScopes !== undefined && + !visibleScopes.has(imp.declaredAtScope) + ) + continue; + // `crate::` is the caller's crate. A same trailing module in another + // workspace crate is a different item and cannot authorize the guess. + if (imp.targetRaw === 'crate' || imp.targetRaw.startsWith('crate::')) { + const callerRoot = rustCrateRootOf(ctx.callerParsed.filePath); + const candidateRoot = rustCrateRootOf(ctx.candidate.filePath); + if (callerRoot !== '' && candidateRoot !== '' && callerRoot !== candidateRoot) continue; + } + 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') { + if (modulePathReaches(usePath, candidateModule)) return true; + continue; + } + if (!('localName' in imp) || imp.localName !== ctx.site.name) continue; + // Otherwise the path names ONE item inside a module (`use crate::a::other`). + // Its PARENT is the candidate's module only if that item IS the candidate: + // importing `other` says nothing about a `helper` in `a`, and the bare + // parent-path match used to accept every item of `a` on its strength. + // An alias authorizes only the local spelling checked above. + if (importedNameOf(imp) !== candidateName) continue; + if (modulePathReaches(usePath, candidateModule)) return true; + const parent = usePath.slice(0, Math.max(0, usePath.lastIndexOf('::'))); + if (parent !== '' && modulePathReaches(parent, candidateModule)) return true; + } + return false; +} + +/** The identifier a `use` binds, as written at its source (`importedName`). */ +function importedNameOf(imp: ParsedFile['parsedImports'][number]): string | undefined { + return 'importedName' in imp ? imp.importedName : undefined; +} + +/** + * The identifier a Rust declaration contributes to its module: the FIRST + * segment of `qualifiedName` after any module prefix — `User` for `User.new` + * (an associated function is reached through its type, so it is the type the + * `use` must name), the bare name for a free function. + */ +function rustSimpleNameOf(candidate: SymbolDefinition): string { + const qualified = candidate.qualifiedName ?? ''; + const segments = qualified.split(/::|\./).filter((s) => s !== ''); + return segments[0] ?? ''; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index bea53d9fd..71fb697db 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -19,6 +19,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; +import { rustIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; /** * Emit Rust `S IMPLEMENTS T` edges from `impl T for S` trait implementations. @@ -192,4 +193,6 @@ export const rustScopeResolver: ScopeResolver = { hoistTypeBindingsToModule: true, propagatesReturnTypesAcrossImports: true, allowGlobalFreeCallFallback: true, + importsBindAtLexicalScope: true, + isGlobalNameFallbackPlausible: rustIsGlobalNameFallbackPlausible, }; diff --git a/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts new file mode 100644 index 000000000..916465d94 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/name-fallback-visibility.ts @@ -0,0 +1,67 @@ +/** + * Swift's veto on the global-name fallback — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. + * + * Swift's default access level is `internal`: visible throughout the MODULE and + * nowhere else. Swift needs no per-file import inside a module, which is why + * the global fallback is enabled for it at all — but that whole-module + * visibility stops hard at the module boundary. A candidate in a DIFFERENT + * module is reachable only if the caller wrote `import `, and even + * then only if the declaration is `public`. + * + * A module is approximated by its source directory, the layout every Swift + * package configuration discovers: `Sources//…` (also under `Package/`). + * `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 — + * `SymbolDefinition` carries no access level and `ParsedFile` no modifiers. + * Those candidates keep the labeled low-confidence edge, which is the + * "cannot decide, so do not refuse" direction the hook contract asks for. + */ + +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { modulePathReaches } from '../../scope-resolution/utils/name-fallback-visibility.js'; + +/** 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. + * + * `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 - 2; i++) { + if (SWIFT_TARGET_ROOTS.has(segments[i]!)) { + // Keep the path through the target so `Sources/Core` and + // `Package/Sources/Core` stay distinct modules. `import Core` still + // reaches either via trailing-segment containment. + return segments.slice(0, i + 2).join('/'); + } + } + return ''; +} + +export function swiftIsGlobalNameFallbackPlausible(ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; +}): boolean { + if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true; + + const callerModule = swiftModuleOf(ctx.callerParsed.filePath); + const candidateModule = swiftModuleOf(ctx.candidate.filePath); + // Same module: whole-module `internal` visibility, no import needed. + if (callerModule === candidateModule) return true; + // A file the layout heuristic cannot place is not something this rule can + // speak about — allow rather than refuse on an unanswered question. + if (callerModule === '' || candidateModule === '') return true; + + for (const imp of ctx.callerParsed.parsedImports) { + if (modulePathReaches(imp.targetRaw, candidateModule)) return true; + } + return false; +} diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index 6d71117e4..83a8e6ce7 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -66,6 +66,7 @@ import { mirrorSwiftSiblingTypeBindings, type SwiftResolveContext, } from './index.js'; +import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; @@ -136,6 +137,7 @@ const swiftScopeResolver: ScopeResolver = { // global free-call fallback (as Python/Go/Ruby/COBOL do for the same // no-`new` constructor + cross-file free-call shape). allowGlobalFreeCallFallback: true, + isGlobalNameFallbackPlausible: swiftIsGlobalNameFallbackPlausible, // Swift's call graph models `Type(...)` as a reference to the type // itself, not its `init` — both the legacy DAG and this test suite link diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 2e3a76290..955b70a25 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -32,6 +32,7 @@ import { syntheticCapture, type SyntaxNode, } from '../../utils/ast-helpers.js'; +import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js'; import { splitImportStatement } from './import-decomposer.js'; import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; @@ -388,6 +389,8 @@ export function emitTsScopeCaptures( } const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode); + // Export evidence, read once per file (see `ts-js-export-marker.ts`). + const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath); const out: CaptureMatch[] = []; for (const m of rawMatches) { @@ -561,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', + ); } } @@ -660,6 +670,20 @@ export function emitTsScopeCaptures( // instead of re-parsing the receiver's source text. Self-gating: a // non-call match, an absent receiver, or a chain with no nameable base // all leave `grouped` untouched. + // `@declaration.is-exported`: a verdict for every declaration the file's + // export surface can decide (see `ts-js-export-marker.ts`); nothing where + // it cannot, because absence is the honest answer there. + const declNameNode = groupedNodes['@declaration.name']; + if (exportEvidence !== undefined && declNameNode !== undefined) { + const verdict = esmExportVerdict(declNameNode, exportEvidence); + if (verdict !== undefined) { + grouped['@declaration.is-exported'] = syntheticCapture( + '@declaration.is-exported', + declNameNode, + verdict ? 'true' : 'false', + ); + } + } synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']); out.push(grouped); diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index bc2af4bcb..b0d12a3e8 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -132,6 +132,15 @@ const typescriptScopeResolver: ScopeResolver = { fieldFallbackOnMethodLookup: false, propagatesReturnTypesAcrossImports: true, + // ECMAScript: `export * from './a'; export * from './b'` with a name declared + // in both exports NEITHER — the finalize pass refuses the binding instead of + // taking the first-listed source (see `exclusiveWildcardReexports`). + exclusiveWildcardReexports: true, + // `import { x }` never reaches a class member: named imports and named + // re-exports bind to module-level declarations only (a class method sharing + // a name with a top-level value must not win the callable preference). + namedImportsBindTopLevelOnly: true, + // TypeScript uses `.values()` / `.keys()` method-call syntax for collection // views -- no property-style accessors like C#'s `Dictionary.Values` -- // so `elementTypeOf` answers only the `index` route and lets the regular diff --git a/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts index d2f37781f..d0b0cfaed 100644 --- a/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts @@ -117,6 +117,9 @@ const vueScopeResolver: ScopeResolver = { // Vue uses explicit imports for all external symbols; no global free- // call fallback needed (would produce spurious edges for built-ins). allowGlobalFreeCallFallback: false, + // Vue SFC scripts are TypeScript/JavaScript: a named import binds a module-level + // declaration, never a class member (see the TS resolver). + namedImportsBindTopLevelOnly: true, /** * Expand the scope-resolution file universe for Vue by performing a diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 8ee94e265..d81b7116e 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -16,6 +16,8 @@ */ import { createKnowledgeGraph } from '../graph/graph.js'; +import type { KnowledgeGraph } from '../graph/types.js'; +import { GLOBAL_NAME_FALLBACK_REASON } from '../graph/edge-reasons.js'; import { GraphEmitSink, type GraphEmitManifest } from '../lbug/graph-emit-sink.js'; import { type PipelineProgress } from 'gitnexus-shared'; import { PipelineResult } from '../../types/pipeline.js'; @@ -410,6 +412,11 @@ export const runPipelineFromRepo = async ( graphEmitSink?.close(); } + // Resolved-call index for the name-fallback census: read through the SINK, + // whose field-wise scan includes every streamed edge, not through `graph`, + // which under streaming holds none of them. + const resolvedCalleeNamesByCaller = collectResolvedCalleeNames(graphEmitSink ?? graph, graph); + // Extract final results for the PipelineResult contract const { totalFiles, @@ -473,6 +480,7 @@ export const runPipelineFromRepo = async ( communityResult, processResult, resolutionOutcomes, + resolvedCalleeNamesByCaller, undecidedSatisfaction, usedWorkerPool, reparsedFileCount, @@ -518,3 +526,29 @@ export const runPipelineFromRepo = async ( return result; }; + +/** + * Caller node id → the simple names of every callee it has a CALLS edge to. + * + * `edges` may be the streaming sink or the raw graph; `nodes` is always the raw + * graph, which holds every node in both modes (only relationships stream). One + * O(E) field-wise pass, allocation-free per edge except for the per-caller set. + */ +export function collectResolvedCalleeNames( + edges: Pick, + nodes: Pick, +): ReadonlyMap> { + const out = new Map>(); + edges.forEachRelationshipFields((sourceId, targetId, type, _confidence, reason) => { + if (type !== 'CALLS' || reason === GLOBAL_NAME_FALLBACK_REASON) return; + const name = nodes.getNode(targetId)?.properties.name; + if (typeof name !== 'string' || name === '') return; + let names = out.get(sourceId); + if (names === undefined) { + names = new Set(); + out.set(sourceId, names); + } + names.add(name); + }); + return out; +} diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index d8dbd42dd..898282b5e 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -707,6 +707,10 @@ function buildDefFromDeclarationMatch( const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']); const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']); const isSynthetic = parseBooleanCapture(match['@declaration.is-synthetic']); + // Tri-state on purpose: only a producer that saw the file's export surface + // emits the marker, and both `true` and `false` are verdicts (see + // `SymbolDefinition.isExported`). Absent stays absent. + const isExported = parseBooleanCapture(match['@declaration.is-exported']); return { nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), @@ -725,6 +729,7 @@ function buildDefFromDeclarationMatch( ...(isExplicit === true ? { isExplicit: true } : {}), ...(isDeleted === true ? { isDeleted: true } : {}), ...(isSynthetic === true ? { isSynthetic: true } : {}), + ...(isExported !== undefined ? { isExported } : {}), }; } @@ -1037,8 +1042,7 @@ function pass3CollectImports( if (provider.interpretImport === undefined) return; // Hoisted: the capability is a property of the language, identical for every // match in the file. A provider that declares its imports do not execute - // where they are written (C/C++ `#include`, Rust `use`, COBOL `COPY`) skips - // the position walk entirely — position cannot defer something that never + // where they are written skips the execution-deferral walk — position cannot defer something that never // runs, and marking one deferred would hide a real cycle. Absent reads as // `true`, so an undeclared provider is unchanged. See // `LanguageProvider.importsExecuteWhereWritten`. @@ -1049,14 +1053,22 @@ function pass3CollectImports( const parsed = provider.interpretImport(match); if (parsed === null) continue; // The statement's own position, resolved to the innermost scope holding - // it. An unlocatable anchor leaves the import unmarked, which reads as + // it. Provenance is retained independently of execution timing. An + // unlocatable anchor leaves the import unmarked, which reads as // "runs at initialization" — the fail-safe direction, since it can only // make `check --cycles` over-report. - const inScopeId = positionCanDefer - ? positionIndex.atPosition(filePath, anchor.range.startLine, anchor.range.startCol) - : undefined; - const deferred = inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId); - parsedImports.push(deferred ? { ...parsed, runsOnlyWhenCalled: true } : parsed); + const inScopeId = positionIndex.atPosition( + filePath, + anchor.range.startLine, + anchor.range.startCol, + ); + const deferred = + positionCanDefer && inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId); + parsedImports.push({ + ...parsed, + ...(inScopeId !== undefined ? { declaredAtScope: inScopeId } : {}), + ...(deferred ? { runsOnlyWhenCalled: true } : {}), + }); } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index e364b0227..d6da6d4d9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -82,13 +82,14 @@ * `handledSites` IFF a `tryEmitEdge` call returned `true` for it. * Sites a pass touched but couldn't resolve do NOT get marked — * they still get a chance from the shared resolver. Exception: - * the free-call fallback marks the site unconditionally after - * attempting emission (even on dedup-collapse), because the - * per-(caller, target) collapse semantics require multiple call - * sites in the same caller body not produce multiple edges. - * `preEmitInheritanceEdges` also pre-marks every `inherits` site so - * the generic bridge cannot remap class heritage into method-owned - * EXTENDS edges via `resolveCallerGraphId`. + * the free-call fallback marks the site after it decides the site, + * including when it emits no edge — dedup-collapse, a visibility + * veto (`fallback-refused`), or a selected callable that is deleted + * / invisible. The later generic pass must not recreate an edge + * that pass just refused. `preEmitInheritanceEdges` also pre-marks + * every `inherits` site so the generic bridge cannot remap class + * heritage into method-owned EXTENDS edges via + * `resolveCallerGraphId`. * * - **I3 — `propagateImportedReturnTypes` mutation timing + ordering.** * The pass mutates `Scope.typeBindings` (a plain `new Map(...)` from @@ -248,8 +249,8 @@ * ## Semantic-model source of truth * * `ParsedFile` (from `gitnexus-shared/src/scope-resolution/parsed-file.ts`) - * is the single semantic model consumed by both the legacy DAG and the - * scope-resolution pipeline. Scope-resolution passes MUST NOT build a + * is the single semantic model consumed by the scope-resolution pipeline. + * Scope-resolution passes MUST NOT build a * parallel parse representation; if a pass needs AST-level facts that * `ParsedFile` doesn't expose, it should reuse the orchestrator's * `treeCache` (see `RunScopeResolutionInput.treeCache`) rather than @@ -257,23 +258,20 @@ * * ## Same-graph guarantee * - * Edges emitted by `runScopeResolution` and edges emitted by the legacy - * DAG are indistinguishable to downstream consumers: + * All language resolvers emit edges through `runScopeResolution` using the + * shared graph contract: * - Node identity: same `generateId(...)` helper, same qualified-name * keyspace, same File/Folder/Method/Class node labels. * - Edge vocabulary: `'import-resolved' | 'global' | 'local-call' | - * 'same-file' | 'interface-dispatch' | 'read' | 'write'` — both - * paths emit the same reasons (see - * `gitnexus/src/core/ingestion/call-processor.ts` for the legacy - * emitter and `passes/receiver-bound-calls.ts` / + * 'same-file' | 'interface-dispatch' | 'read' | 'write' | + * 'global-name-fallback'` (see `passes/receiver-bound-calls.ts` / * `passes/free-call-fallback.ts` for the scope-resolution emitters). - * - Overload disambiguation: both paths use + * - Overload disambiguation: resolvers use * `generateId('Method', ...)` suffixed with `parameterTypes` when a * method has overloads — see `graph-bridge/ids.ts`. * - * The CI parity workflow (`.github/workflows/ci-scope-parity.yml`) - * runs both paths on every migrated language's fixture corpus and - * fails if the graph outputs diverge. + * Resolver fixture coverage lives in `ci-tests.yml` (the separate + * `ci-scope-parity.yml` gate was removed in RING4-1 #942). * * Plan that introduced most of these invariants: * `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`. @@ -865,6 +863,88 @@ export interface ScopeResolver { */ readonly allowGlobalFreeCallFallback?: boolean; + /** Opt in to binding imports at their extracted lexical scope, rather than + * publishing every file's imports at module scope. */ + readonly importsBindAtLexicalScope?: boolean; + + /** + * Two `wildcard` re-exports that both DECLARE a name make it AMBIGUOUS in + * this language — ECMAScript `export *` semantics, where the module simply + * does not export the name and any binding is a guess. Opt-in: for a + * language whose wildcard import is `#include`, `require` or a package + * fan-out, the same name in two files is an overload set or a redeclaration, + * and refusing it would delete real edges (C++ arity-narrowed overloads + * across two headers). Forwarded to `FinalizeHooks.wildcardCollisionIsAmbiguous`. + */ + readonly exclusiveWildcardReexports?: boolean; + + /** + * A named import or named re-export can only bind to a MODULE-LEVEL + * declaration of the target file — ECMAScript semantics, where + * `import { x }` never reaches a class member. Opt-in: languages that bind + * module-level members by bare name (static members, module functions) + * leave it off. Forwarded to `FinalizeHooks.namedImportsBindTopLevelOnly`. + */ + readonly namedImportsBindTopLevelOnly?: boolean; + + /** + * Veto for a single `allowGlobalFreeCallFallback` guess. + * + * The fallback picks a callable because its SIMPLE NAME is unique in the + * workspace — it consults no import and no scope chain. For most languages a + * large share of those guesses are not merely unlikely but IMPOSSIBLE: Go + * cannot call an unexported identifier from another package, ESM cannot see a + * name it did not import, Rust cannot reach an item with no `use` path. This + * hook is where a language states those rules, so the shared pass can drop + * the edge instead of publishing a guess that the language forbids. + * + * Return `false` to REFUSE (no edge, recorded as `fallback-refused`). Return + * `true`, or leave the hook undefined, to emit the labeled + * `global-name-fallback` edge. **Only answer `false` when the call is + * impossible, not when it is merely unproven** — a wrongly-refused candidate + * is a lost real edge, whereas a wrongly-allowed one is at least labeled and + * excluded from flows. + * + * Deliberately NOT folded into `isCallableVisibleFromCaller`: that hook also + * gates precise dispatch paths (implicit-this, member calls), so a rule + * written for the name-guess tier would silently suppress resolved edges too. + * + * `parsedFileOf` reaches the CANDIDATE's parse result — a language whose rule + * depends on declaration-side facts not carried by `SymbolDefinition` (for + * example Rust's `pub` form) needs it. `isExported` is the tri-state export + * marker when the language supplies one; this hook still needs the parse for + * visibility facts that marker does not represent. It returns `undefined` + * for a path outside this pass's file set; treat that as "cannot decide" + * and allow. + */ + readonly isGlobalNameFallbackPlausible?: (ctx: { + readonly callerParsed: ParsedFile; + readonly candidate: SymbolDefinition; + readonly parsedFileOf: (filePath: string) => ParsedFile | undefined; + /** + * Raw source of any parsed file, for languages whose visibility rule needs + * a declaration the parse does not carry (Go's package clause: a test file's + * `package foo_test` is a different package from its directory's `foo`). + * Absent when the pipeline has no contents at hand; hooks must then answer + * from paths alone and, when undecidable, allow. + */ + readonly sourceTextOf?: (filePath: string) => string | undefined; + /** + * The call site, so a language can tell a BARE name from a PATH-QUALIFIED + * one. Both reach this tier — a qualified call whose qualifier the resolver + * could not follow falls through to the bare-name search with only its tail + * name — but they are not the same claim. Rust's `User::new(...)` named its + * type in source, so refusing it for lacking a `use` of the module would + * delete an edge the source spells out. + */ + readonly site: { + readonly name: string; + readonly rawQualifiedName?: string; + /** Lexical call-site scope; absent only for legacy/synthetic hook callers. */ + readonly inScope?: ScopeId; + }; + }) => boolean; + /** * In this language every `Method` belongs to a class instance, so a * FREE (receiver-less) call may resolve to a `Method` only when the diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 453c0b406..48673f027 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -505,9 +505,25 @@ function resolveDefGraphIdUncached( if (qualifiedHit !== undefined) return qualifiedHit; } const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1); + // FAIL CLOSED before the label-agnostic simple key when a FUNCTION-LOCAL + // callable of this name exists in the file. The guards above cover a def + // that is itself a callable; a NON-callable def (`export const selected = + // factory()`, a `Variable` with no graph node of its own) used to fall + // through here and alias onto `wrapper.selected`, the function-local one + // (#3182 review). Whatever the def's label, a bare name matching a local + // callable is the aliasing this key cannot tell apart, and a missing edge + // is the correct failure direction. + for (const localLabel of LOCAL_CALLABLE_LABELS) { + if (nodeLookup.get(localNameKey(filePath, localLabel, simpleName)) !== undefined) { + return undefined; + } + } return nodeLookup.get(simpleKey(filePath, simpleName)); } +/** Labels the structure phase registers function-local declarations under. */ +const LOCAL_CALLABLE_LABELS: readonly NodeLabel[] = ['Function', 'Method']; + /** Derive the simple (unqualified) name of a def from its `qualifiedName`. */ export function simpleQualifiedName(def: SymbolDefinition): string | undefined { const q = def.qualifiedName; diff --git a/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts b/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts new file mode 100644 index 000000000..e96d9d5c7 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/name-fallback-summary.ts @@ -0,0 +1,212 @@ +/** + * 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 + * candidates from one whose hook is missing; a refusal count with no guess count + * cannot distinguish a working guard from one that rejects everything. The pair + * is what makes the guard auditable on a real repository, which is the whole + * point of recording it — before this, guessed edges were emitted with the same + * reason and confidence as import-resolved ones and the number was unknowable. + * + * Structural sibling of `unresolved-receivers.ts`'s summary, and persisted the + * same way (`RepoMeta.nameFallbackEdges`). + */ + +import { getLanguageFromFilename } from 'gitnexus-shared'; +import { logger } from '../../logger.js'; +import type { ResolutionOutcome } from './resolution-outcome.js'; + +/** + * Distinct caller-file/callee-name pairs per language, from the caller→callee-name index the pipeline + * builds while its streaming sink is live (so it is complete under `--force`), + * bucketed by the CALLER's file language. Gives `NameFallbackSummary.byLanguage` + * its denominator: a guess count is only readable as a share of the calls. + */ +export function countCallsByLanguage( + index: ReadonlyMap> | undefined, + nodes: { getNode(id: string): { properties: Record } | undefined } | undefined, +): Readonly> | undefined { + if (index === undefined || nodes === undefined) return undefined; + const counts = new Map(); + const namesByFile = new Map>(); + for (const [callerId, callees] of index) { + const filePath = nodes.getNode(callerId)?.properties?.filePath; + if (typeof filePath !== 'string') continue; + let names = namesByFile.get(filePath); + if (names === undefined) { + names = new Set(); + namesByFile.set(filePath, names); + } + for (const name of callees) names.add(name); + } + for (const [filePath, names] of namesByFile) { + const language = getLanguageFromFilename(filePath) ?? 'unknown'; + counts.set(language, (counts.get(language) ?? 0) + names.size); + } + if (counts.size === 0) return undefined; + const out: Record = {}; + for (const [language, count] of [...counts.entries()].sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + )) { + out[language] = count; + } + return out; +} + +/** Unattributed bucket for a pass that recorded no language. */ +const UNKNOWN_LANGUAGE = 'unknown'; + +export interface NameFallbackLanguageCounts { + /** 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 + * `callsByLanguage` counts in, so `guessedPairs / callsByLanguage[lang]` is a + * ratio bounded by 1. Absent on a summary persisted before this field existed. + */ + readonly guessedPairs?: number; + /** Candidates this language's `isGlobalNameFallbackPlausible` hook refused. */ + readonly refused: number; +} + +export interface NameFallbackSummary { + /** Language → guessed/refused counts. Languages with neither are absent. */ + readonly byLanguage: Readonly>; + /** Guessed call sites, repo-wide. */ + readonly totalGuessed: number; + /** Distinct (caller file, callee name) pairs among the guessed sites. */ + readonly distinctGuessedPairs?: number; + readonly totalRefused: number; + /** + * Barrel names refused because two `export *` sources both declared them + * (`reexport-ambiguous`). Not a guess and not per-language — a name the + * finalize pass declined to bind at all — but it belongs in the same census: + * it is the other place the resolver used to publish an arbitrary winner as + * `import-resolved`. + */ + readonly totalAmbiguousReexports: number; + /** + * The refused barrel names themselves (`file:name`), sorted, capped at + * `MAX_AMBIGUOUS_NAMES`. A count alone cannot say whether a refusal landed on + * a name anyone calls; the list can be joined against the ledger's `byName`. + */ + readonly ambiguousReexportNames?: readonly string[]; + /** + * Distinct caller-file/callee-name pairs per language (through any path), so `guessedPairs` can be + * read as a SHARE of a language's call graph rather than a bare count. Absent + * when the caller did not supply the totals. + */ + readonly callsByLanguage?: Readonly>; +} + +/** Bound on the persisted ambiguous-name list; the total beside it stays exact. */ +export const MAX_AMBIGUOUS_NAMES = 200; + +/** + * Build the summary, or `undefined` when the run neither guessed nor refused — + * a repository with no opt-in language stores no key at all, so the artifact + * stays absent rather than recording a row of zeroes. + */ +export function summarizeNameFallback( + outcomes: readonly ResolutionOutcome[], + callsByLanguage?: Readonly>, +): NameFallbackSummary | undefined { + const guessed = new Map(); + const guessedPairsByLanguage = new Map(); + const refused = new Map(); + const ambiguousNames = new Set(); + // 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 + // of `guessed` itself silently read as a large improvement across engines. + const guessedPairs = new Set(); + let totalGuessed = 0; + let totalRefused = 0; + let totalAmbiguousReexports = 0; + + for (const outcome of outcomes) { + if (outcome.kind === 'fallback-guessed') { + const language = outcome.language ?? UNKNOWN_LANGUAGE; + guessed.set(language, (guessed.get(language) ?? 0) + 1); + totalGuessed++; + const pair = `${outcome.filePath}\u0000${outcome.name}`; + if (!guessedPairs.has(pair)) { + guessedPairs.add(pair); + guessedPairsByLanguage.set(language, (guessedPairsByLanguage.get(language) ?? 0) + 1); + } + } else if (outcome.kind === 'fallback-refused') { + const language = outcome.language ?? UNKNOWN_LANGUAGE; + refused.set(language, (refused.get(language) ?? 0) + 1); + totalRefused++; + } else if (outcome.kind === 'reexport-ambiguous') { + totalAmbiguousReexports++; + ambiguousNames.add(`${outcome.filePath}:${outcome.name}`); + } + } + if (totalGuessed === 0 && totalRefused === 0 && totalAmbiguousReexports === 0) return undefined; + + const byLanguage: Record = {}; + for (const language of new Set([...guessed.keys(), ...refused.keys()])) { + byLanguage[language] = { + guessed: guessed.get(language) ?? 0, + guessedPairs: guessedPairsByLanguage.get(language) ?? 0, + refused: refused.get(language) ?? 0, + }; + } + const sortedNames = [...ambiguousNames].sort(); + return { + byLanguage, + totalGuessed, + distinctGuessedPairs: guessedPairs.size, + totalRefused, + totalAmbiguousReexports, + ...(sortedNames.length > 0 + ? { ambiguousReexportNames: sortedNames.slice(0, MAX_AMBIGUOUS_NAMES) } + : {}), + ...(callsByLanguage !== undefined ? { callsByLanguage } : {}), + }; +} + +/** + * One-line readout for the analyze summary. `undefined` when there is nothing to + * report, so a run on a repository with no opt-in language prints no line. + */ +export function formatNameFallbackSummary( + summary: NameFallbackSummary | undefined, +): string | undefined { + if (summary === undefined) return undefined; + const perLanguage = Object.entries(summary.byLanguage) + .sort(([, a], [, b]) => b.guessed + b.refused - (a.guessed + a.refused)) + .map(([language, counts]) => `${language} ${counts.guessed}/${counts.refused}`) + .join(', '); + const ambiguous = + summary.totalAmbiguousReexports > 0 + ? `; ${summary.totalAmbiguousReexports} barrel name(s) refused as ambiguous \`export *\`` + : ''; + const languages = perLanguage === '' ? 'none' : perLanguage; + const pairs = + summary.distinctGuessedPairs !== undefined + ? ` (${summary.distinctGuessedPairs} distinct caller-file/name pairs)` + : ''; + 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}`; +} + +/** + * Print the readout as part of the analyze summary. Unconditional (not behind a + * debug env var, unlike the receiver-drop diagnostic): a reader deciding how far + * to trust this index's call graph needs to know how much of it is guessed, and + * a number nobody sees is the state this work exists to end. + */ +export function logNameFallbackSummary(summary: NameFallbackSummary | undefined): void { + const line = formatNameFallbackSummary(summary); + if (line === undefined) return; + logger.info(line); +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 0457767b2..7dcc792f3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -36,6 +36,7 @@ import type { ResolutionOutcomeRecorder, ResolutionSuppressionReason, } from '../resolution-outcome.js'; +import { GLOBAL_NAME_FALLBACK_REASON } from '../../../graph/edge-reasons.js'; import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; import { @@ -43,7 +44,9 @@ import { findAllCallableBindingsInScope, findCallableBindingInScope, findCallableBindingsAndAdlBlocker, + findClassBindingInScope, findEnclosingClassDef, + isClassFileImportGrounded, resolveInheritanceBaseInScope, type CallableBindingCandidate, } from '../scope/walkers.js'; @@ -64,6 +67,15 @@ export function emitFreeCallFallback( workspaceIndex: WorkspaceResolutionIndex, options: { readonly allowGlobalFallback?: boolean; + /** Language whose pass this is, carried onto the fallback outcome records + * so the analyze summary can report guesses/refusals PER LANGUAGE. A + * repo-wide total hides which language's rules are the loose ones. */ + readonly language?: string; + /** Per-language veto on a name guess — see + * `ScopeResolver.isGlobalNameFallbackPlausible`. */ + readonly isGlobalNameFallbackPlausible?: ScopeResolver['isGlobalNameFallbackPlausible']; + /** Raw source lookup handed to `isGlobalNameFallbackPlausible` (optional). */ + readonly sourceTextOf?: (filePath: string) => string | undefined; /** When true, `Type(...)` constructor calls link to the Class def * itself rather than its explicit Constructor. Swift opts in. */ readonly constructorCallTargetsClass?: boolean; @@ -138,6 +150,14 @@ export function emitFreeCallFallback( let allFilePathsMemo: ReadonlySet | undefined; const allFilePaths = (): ReadonlySet => (allFilePathsMemo ??= new Set(parsedFiles.map((p) => p.filePath))); + // Candidate-side parse lookup for `isGlobalNameFallbackPlausible`. Built + // lazily and once, on the same terms as `allFilePaths` above: a language + // without the hook never pays for the index. + let parsedByPathMemo: ReadonlyMap | undefined; + const parsedFileByPath = (): ((filePath: string) => ParsedFile | undefined) => { + parsedByPathMemo ??= new Map(parsedFiles.map((p) => [p.filePath, p])); + return (filePath) => parsedByPathMemo!.get(filePath); + }; // Per-pass memo of pickUniqueGlobalCallable's post-filter candidate list, // keyed (simpleName, callerFilePath). Only created when no per-caller // visibility filter applies (the list is then a pure function of name+file — @@ -183,7 +203,17 @@ export function emitFreeCallFallback( }; for (const parsed of parsedFiles) { - type PendingRel = { rel: Parameters[0]; gatedAll: boolean }; + type PendingRel = { + rel: Parameters[0]; + gatedAll: boolean; + /** + * The confidence/reason a PRECISELY resolved site (a real binding, not a + * unique-name guess) proved for this edge; `undefined` while every site + * collapsed into it so far was a guess. Decided at flush, not by the + * first site the walk met. + */ + precise: { confidence: number; reason: string } | undefined; + }; const pending = new Map(); const bindingCandidatesByScope = options.freeCallsRequireInstanceOwnership === true @@ -199,13 +229,48 @@ export function emitFreeCallFallback( // to the Class node itself (implicit constructor). Legacy emits // the same two targets; see test expectations. let fnDef: SymbolDefinition | undefined; + // Guess flag starts here so the constructor unique-class pick can mark + // the same class of guess as `pickUniqueGlobalCallable` below. The + // veto/label path keys off this flag; declaring it after that pick left + // constructor-form unique-name hits labeled `import-resolved` at 0.85. + let fnDefFromGlobalNameFallback = false; + // Language visibility vetoes the constructed TYPE (Class/Struct), not a + // Constructor child — Go refuses any `qualifiedName` containing `.`. + let globalFallbackVetoTarget: SymbolDefinition | undefined; if (site.callForm === 'constructor') { - const classDef = resolveInheritanceBaseInScope( + // Lexical / written-qualifier first. A bare unique type with no + // import/#include evidence is a guess (JS `new UniqueWidget()`, + // Go unexported `uniqueWidget{}`). A unique type whose file is + // imported, whose import resolved to this def, or whose name + // was imported is precise — C++ `#include "user.h"` and Rust + // `use`/`pub use` often mint no lexical class binding. An + // imported sibling file of a different name is not evidence. + let classDef = resolveInheritanceBaseInScope( site.inScope, site.name, scopes, site.rawQualifiedName, + undefined, + { uniqueQualifiedNameFallback: site.rawQualifiedName !== undefined }, ); + if (classDef === undefined) { + classDef = + findClassBindingInScope(site.inScope, site.name, scopes, undefined, { + uniqueQualifiedNameFallback: true, + }) ?? + (options.allowGlobalFallback === true + ? pickUniqueGlobalClass(site.name, globalClassesBySimpleName) + : undefined); + if ( + classDef !== undefined && + classDef.type !== 'Interface' && + site.rawQualifiedName === undefined && + !isClassFileImportGrounded(site.inScope, classDef, scopes, site.name) + ) { + fnDefFromGlobalNameFallback = true; + globalFallbackVetoTarget = classDef; + } + } if (classDef !== undefined && classDef.type !== 'Interface') { // Most languages link `Type(...)` to the explicit Constructor def // when one exists (else the Class). Languages that model the call @@ -215,21 +280,6 @@ export function emitFreeCallFallback( options.constructorCallTargetsClass === true ? classDef : pickConstructorOrClass(classDef, workspaceIndex, scopes, site.arity); - } else if (options.allowGlobalFallback === true) { - // The constructed type may live in a sibling/imported file that is - // not in the call-site's lexical scope-chain bindings. Fall back to - // a unique workspace-wide Class def by simple name (gated on the - // same global-fallback opt-in as free calls). Then target the - // Class or its Constructor per the language's preference. - const globalClass = pickUniqueGlobalClass(site.name, globalClassesBySimpleName); - if (globalClass !== undefined) { - fnDef = - globalClass.type === 'Interface' - ? undefined - : options.constructorCallTargetsClass === true - ? globalClass - : pickConstructorOrClass(globalClass, workspaceIndex, scopes, site.arity); - } } } // Module-qualified free call (`mod::fn()`): the source named the module @@ -543,10 +593,17 @@ export function emitFreeCallFallback( } } } - // V1: pickUniqueGlobalCallable ignores import context — resolves to any - // globally-unique callable. False cross-package edges are possible when - // the caller does not import the target package. Same-package calls are - // usually caught by nearest-scope lookup before reaching here. + // Name-guess tier: pickUniqueGlobalCallable consults no import context — + // it resolves to any globally-unique callable. Same-package calls are + // usually caught by nearest-scope lookup before reaching here, so what + // lands in this tier is disproportionately cross-module, and a + // cross-module name match is a guess. + // + // Two things make that honest rather than a lie. The language's + // `isGlobalNameFallbackPlausible` hook refuses candidates its own + // visibility rules forbid (below), and every edge that survives is + // emitted with `GLOBAL_NAME_FALLBACK_REASON` at 0.5 rather than + // masquerading as `import-resolved` at 0.85 (see the emit site). if (fnDef === undefined && options.allowGlobalFallback === true) { fnDef = pickUniqueGlobalCallable( site.name, @@ -570,6 +627,53 @@ export function emitFreeCallFallback( scopeDefsCache, options.conversionOnlyArgTypePrefixes, ); + fnDefFromGlobalNameFallback = fnDef !== undefined; + if (fnDef !== undefined) globalFallbackVetoTarget = fnDef; + } + if (fnDefFromGlobalNameFallback && fnDef !== undefined) { + // An explicit named import cannot bind a declaration proven private + // to another module. In particular, a rejected named import must not + // reappear as a name guess to a class member or nested function. + // Unknown export evidence (e.g. dynamic module exports) keeps the + // existing fallback behavior. + const vetoCandidate = globalFallbackVetoTarget ?? fnDef; + const importsPrivateDeclaration = + vetoCandidate.filePath !== parsed.filePath && + vetoCandidate.isExported === false && + parsed.parsedImports.some( + (imported) => + (imported.kind === 'named' || imported.kind === 'alias') && + imported.localName === site.name, + ); + if ( + importsPrivateDeclaration || + options.isGlobalNameFallbackPlausible?.({ + callerParsed: parsed, + candidate: vetoCandidate, + parsedFileOf: parsedFileByPath(), + sourceTextOf: options.sourceTextOf, + site: { + name: site.name, + rawQualifiedName: site.rawQualifiedName, + inScope: site.inScope, + }, + }) === false + ) { + // The language proved this call impossible. Mark the site handled so + // `emit-references` does not substitute its own looser guess for the + // edge we just refused — the point is no edge, not a different one. + options.recordResolutionOutcome?.({ + kind: 'fallback-refused', + candidateId: fnDef.nodeId, + language: options.language, + phase: 'free-call-fallback', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + }); + handledSites.add(siteKey(parsed.filePath, site)); + continue; + } } if (fnDef === undefined) continue; if (fnDef.isDeleted === true) { @@ -617,40 +721,78 @@ export function emitFreeCallFallback( site.atRange.startCol, tgtGraphId, ); + if (fnDefFromGlobalNameFallback) { + options.recordResolutionOutcome?.({ + kind: 'fallback-guessed', + targetId: fnDef.nodeId, + language: options.language, + phase: 'free-call-fallback', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + }); + } const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`; // One edge per (caller, callee): `staticGated` is the AND over every site // that collapses into it, so a callee reached from one live site and one // dead site stays live whichever site the walk meets first. Emission is // deferred to the end of this file's sites for that reason. + const preciseHere = fnDefFromGlobalNameFallback + ? undefined + : { + confidence: 0.85, + // Match legacy DAG's reason convention so consumers that + // assert `reason === 'import-resolved'` keep working. The + // construction-site marker is opt-in for the same reason. + reason: constructionSiteReason( + fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call', + site, + options.markConstructionSites, + ), + }; const pendingRel = pending.get(relId); if (pendingRel !== undefined) { if (site.staticGated !== true) pendingRel.gatedAll = false; + // The edge's label is decided at flush time from EVERY site that + // collapsed into it, not from whichever the walk met first. One site + // resolved through a real binding PROVES the dependency; a guessed + // site for the same pair is then redundant evidence, not a taint. + if (pendingRel.precise === undefined) pendingRel.precise = preciseHere; continue; } if (seen.has(relId)) continue; seen.add(relId); pending.set(relId, { gatedAll: site.staticGated === true, + precise: preciseHere, rel: { id: relId, sourceId: callerGraphId, targetId: tgtGraphId, type: 'CALLS', - confidence: 0.85, - // Match legacy DAG's reason convention so consumers that - // assert `reason === 'import-resolved'` keep working. The - // construction-site marker is opt-in for the same reason. - reason: constructionSiteReason( - fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call', - site, - options.markConstructionSites, - ), + // Guess values as placeholders; decided at flush from `precise`. + confidence: 0.5, + reason: GLOBAL_NAME_FALLBACK_REASON, }, }); emitted++; } - for (const { rel, gatedAll } of pending.values()) { - graph.addRelationship(gatedAll ? { ...rel, staticGated: true } : rel); + for (const { rel, gatedAll, precise } of pending.values()) { + // A name guess is not an import resolution and must not be spelled like + // one. It used to be emitted at 0.85 / `'import-resolved'`, which made + // it indistinguishable from an edge a real import produced — so every + // consumer that wanted to discount guesses had no field to do it with. + // 0.5 is the deliberate "coin flip" value, and the reason is what the + // process/community walks and the MCP tools actually key on, because + // 0.5 sits exactly ON their thresholds (see graph/edge-reasons.ts). + // An edge is a guess only when EVERY site that collapsed into it was one; + // a single precisely resolved site proves it, whatever order the walk + // met the sites in. Independent of `gatedAll`. + const labeled = + precise !== undefined + ? { ...rel, confidence: precise.confidence, reason: precise.reason } + : rel; + graph.addRelationship(gatedAll ? { ...labeled, staticGated: true } : labeled); } } return emitted; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts index 157348c5b..0736bb53c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts @@ -120,19 +120,10 @@ export function followChainPostFinalize( * typeBindings — the in-extractor pass-4 ran before propagation and * missed any chain whose terminal lived in a foreign file. * - * Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope` - * documents that function-local `from x import y` binds `y` to the - * inner function scope, which would make a module-only write miss - * non-module importers. In practice `finalize-algorithm` hoists those - * bindings into `indexes.bindings[moduleScope]` regardless of where - * the `import` statement appears — the integration fixture - * `python-function-local-import-chain` exercises a chained - * receiver-bound call `u = get_user(); u.save()` inside a function - * body and emits the expected `do_work → User.save` edge. The - * module-scope write is sufficient today. If finalize routing ever - * changes to honor the hook's per-scope contract, this pass must - * iterate `indexes.bindings` over every scope and mirror into the - * binding-owning scope's `typeBindings`, not just the module's. + * Finalize can retain lexical import ownership. Mirror into each binding's + * owning scope, never hoist local import-derived types into the file scope. + * Module ordering remains SCC-based; non-module chains are followed after + * all import bindings have been mirrored. */ export function propagateImportedReturnTypes( parsedFiles: readonly ParsedFile[], @@ -140,6 +131,7 @@ export function propagateImportedReturnTypes( index: WorkspaceResolutionIndex, ): void { const moduleScopeByFile = index.moduleScopeByFile; + const scopesByFile = new Map(parsedFiles.map((file) => [file.filePath, file.scopes])); // Walk SCCs in reverse-topological order (`indexes.sccs` is leaves- // first per `tarjanSccs`). For each file we mirror import bindings @@ -167,42 +159,44 @@ export function propagateImportedReturnTypes( // import-derived typeBinding mirror. Both helpers fast-path when // no augmentations exist for the scope, so the common case is // allocation-free. See I8. - for (const localName of namesAtScope(importerModule.id, indexes)) { - // Skip if importer already has a typeBinding for this name — - // an explicit local annotation must win over import-derived. - if (importerModule.typeBindings.has(localName)) continue; + for (const importerScope of scopesByFile.get(filePath) ?? [importerModule]) { + for (const localName of namesAtScope(importerScope.id, indexes)) { + // Skip if importer already has a typeBinding for this name — + // an explicit local annotation must win over import-derived. + if (importerScope.typeBindings.has(localName)) continue; - const refs = lookupBindingsAt(importerModule.id, localName, indexes); - for (const ref of refs) { - if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard') - continue; - const sourceModule = moduleScopeByFile.get(ref.def.filePath); - if (sourceModule === undefined) continue; + const refs = lookupBindingsAt(importerScope.id, localName, indexes); + for (const ref of refs) { + if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard') + continue; + const sourceModule = moduleScopeByFile.get(ref.def.filePath); + if (sourceModule === undefined) continue; - // The source file's typeBinding is keyed by the def's simple - // name (e.g. `get_user`), not the importer's local alias. - const qn = ref.def.qualifiedName; - if (qn === undefined) continue; - const dot = qn.lastIndexOf('.'); - const sourceName = dot === -1 ? qn : qn.slice(dot + 1); + // The source file's typeBinding is keyed by the def's simple + // name (e.g. `get_user`), not the importer's local alias. + const qn = ref.def.qualifiedName; + if (qn === undefined) continue; + const dot = qn.lastIndexOf('.'); + const sourceName = dot === -1 ? qn : qn.slice(dot + 1); - const sourceTypeRef = sourceModule.typeBindings.get(sourceName); - if (sourceTypeRef === undefined) continue; + const sourceTypeRef = sourceModule.typeBindings.get(sourceName); + if (sourceTypeRef === undefined) continue; - // Chain-follow inside the source module so we mirror the - // terminal type, not an intermediate intra-source reference. - const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes); + // Chain-follow inside the source module so we mirror the + // terminal type, not an intermediate intra-source reference. + const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes); - // Mutating typeBindings is safe because draftToScope - // produced a non-frozen Map (Contract Invariant I3/I8). - (importerModule.typeBindings as Map).set(localName, terminal); - // First-write-wins for the local alias: if the same - // `localName` was registered multiple times via - // `mergeBindings` (rare; happens with conflicting - // re-exports), only the first ref with a usable - // typeBinding source is mirrored. Conflict resolution - // among multiple sources is the merger's job, not ours. - break; + // Mutating typeBindings is safe because draftToScope + // produced a non-frozen Map (Contract Invariant I3/I8). + (importerScope.typeBindings as Map).set(localName, terminal); + // First-write-wins for the local alias: if the same + // `localName` was registered multiple times via + // `mergeBindings` (rare; happens with conflicting + // re-exports), only the first ref with a usable + // typeBinding source is mirrored. Conflict resolution + // among multiple sources is the merger's job, not ours. + break; + } } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 15976130f..7cc96e829 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -726,6 +726,7 @@ export function runScopeResolution( const resolutionConfig = input.resolutionConfig; const finalized = finalizeScopeModel(parsedFiles, { hooks: { + importsBindAtLexicalScope: provider.importsBindAtLexicalScope === true, resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) => provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, { parsedFiles, @@ -737,9 +738,23 @@ export function runScopeResolution( provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [], mergeBindings: (existing, incoming, scopeId) => provider.mergeBindings(existing, incoming, scopeId), + wildcardCollisionIsAmbiguous: provider.exclusiveWildcardReexports === true, + namedImportsBindTopLevelOnly: provider.namedImportsBindTopLevelOnly === true, }, }); logHeapProbe('sr-post-finalize', `lang=${provider.language}`); + // `export *` collisions the shared finalize refused to bind (WS1 C2). Recorded + // as outcomes so the refusal is auditable next to the name-fallback census — + // a silently unresolved importer is indistinguishable from a resolver gap. + for (const refused of finalized.stats.ambiguousWildcardExports) { + recordResolutionOutcome({ + kind: 'reexport-ambiguous', + candidateIds: refused.candidateDefIds, + phase: 'finalize', + filePath: refused.filePath, + name: refused.name, + }); + } // One store and ONE writer rule for heritage instantiations (#2912), shared by // the pre-pass below and by the language hook further down — a heritage shape // the pre-pass cannot express (Rust `impl T for S`, Dart `implements`) records @@ -1085,6 +1100,12 @@ export function runScopeResolution( workspaceIndex, { allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, + language: provider.language, + isGlobalNameFallbackPlausible: provider.isGlobalNameFallbackPlausible, + sourceTextOf: + provider.isGlobalNameFallbackPlausible !== undefined + ? (filePath: string) => getFileContents().get(filePath) + : undefined, constructorCallTargetsClass: provider.constructorCallTargetsClass === true, markConstructionSites: provider.markConstructionSites === true, isFileLocalDef: provider.isFileLocalDef, diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index a0e5e0919..8a14460a6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -108,6 +108,60 @@ export type ResolutionOutcome = * every real codebase and taught readers to ignore it. */ readonly receiverOrigin?: ReceiverOrigin; + } + /** + * The global-name fallback fired: a callable was chosen because its SIMPLE + * NAME is unique in the workspace, with no import or scope chain leading to + * it. A labeled low-confidence edge WAS emitted. + * + * Separate from `resolved` because it is not a resolution, and separate from + * `suppressed` because an edge exists. Counting it is the only way a reader + * can tell how much of a language's call graph rests on name uniqueness — the + * number that was previously invisible because these edges were emitted with + * the same reason and confidence as import-resolved ones. + */ + | { + readonly kind: 'fallback-guessed'; + readonly targetId: string; + readonly language?: string; + readonly phase: string; + readonly filePath: string; + readonly name: string; + readonly range: Range; + } + /** + * A global-name-fallback candidate was REFUSED by the language's plausibility + * hook: the language's own visibility rules make that call impossible, so no + * edge was emitted. + * + * The counterpart of `fallback-guessed`, and the pair is what makes the + * refusal auditable — a refusal count with no guess count cannot distinguish + * "the guard works" from "the guard rejects everything". + */ + | { + readonly kind: 'fallback-refused'; + readonly candidateId: string; + readonly language?: string; + readonly phase: string; + readonly filePath: string; + readonly name: string; + readonly range: Range; + } + /** + * A barrel re-exported `name` through two or more `export *` sources that + * each declare it, so the language names no winner. The shared finalize pass + * REFUSED the binding (see `FinalizeStats.ambiguousWildcardExports`) instead + * of publishing the first-listed source as `import-resolved`; every importer + * of `name` through `filePath` stays unresolved. No `range`: the collision + * belongs to the file's export surface, not to one statement. + */ + | { + readonly kind: 'reexport-ambiguous'; + readonly candidateIds: readonly string[]; + readonly phase: string; + /** The barrel file whose `export *` sources collide. */ + readonly filePath: string; + readonly name: string; }; /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 835f9a7a8..54daf1c92 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -782,6 +782,20 @@ function soleBoundBaseName(bound: string): string | undefined { return base.length === 0 ? undefined : base; } +export type ClassBindingLookup = { + /** + * When false, skip the workspace-unique QualifiedNameIndex hit (and the + * dotted-tail variant). Default true — Go inheritance still needs that + * hit because namespace-style imports often create no scope binding. + * + * Constructor-form free calls pass false so a unique type name is not + * treated as a precise in-scope binding. Those sites belong to + * `pickUniqueGlobalClass`, which labels the edge as a guess and runs + * `isGlobalNameFallbackPlausible`. + */ + readonly uniqueQualifiedNameFallback?: boolean; +}; + export function findClassBindingInScope( startScope: ScopeId, receiverName: string, @@ -798,6 +812,7 @@ export function findClassBindingInScope( * selection. Only receiver-chain base and step resolution passes this. */ stripDecoration?: DecorationStripper, + lookup?: ClassBindingLookup, ): SymbolDefinition | undefined { // A TYPE PARAMETER is not a class, and it is checked before every route below // rather than inside one of them because each route would otherwise reach a @@ -806,7 +821,13 @@ export function findClassBindingInScope( // fallback after stripping. The declaration that introduced the parameter is // the only thing that knows, and it knows for all three. if (bindsTypeParameter(startScope, receiverName, scopes)) { - return resolveThroughTypeParameterBound(startScope, receiverName, scopes, stripDecoration); + return resolveThroughTypeParameterBound( + startScope, + receiverName, + scopes, + stripDecoration, + lookup, + ); } const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type)); @@ -815,21 +836,25 @@ export function findClassBindingInScope( // Fallback for languages (Go) where namespace-style imports don't // create scope bindings: resolve via QualifiedNameIndex. Only fires // when the scope-chain walk found nothing; single-match wins. - const qnames = scopes.qualifiedNames.get(receiverName); - if (qnames.length === 1) { - const def = scopes.defs.get(qnames[0]!); - if (def !== undefined && isClassLike(def.type)) return def; - } - // Second fallback: dotted names like "models.User" — try the simple - // name (tail after last dot) for languages where defs are indexed by - // simple name (Go). Only when the dotted lookup fails. - if (receiverName.includes('.')) { - const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1); - if (simple.length > 0 && simple !== receiverName) { - const simpleIds = scopes.qualifiedNames.get(simple); - if (simpleIds.length === 1) { - const def = scopes.defs.get(simpleIds[0]!); - if (def !== undefined && isClassLike(def.type)) return def; + // Constructor-form free calls opt out — a unique type name is a guess, + // not proof the type is in scope (see `ClassBindingLookup`). + if (lookup?.uniqueQualifiedNameFallback !== false) { + const qnames = scopes.qualifiedNames.get(receiverName); + if (qnames.length === 1) { + const def = scopes.defs.get(qnames[0]!); + if (def !== undefined && isClassLike(def.type)) return def; + } + // Second fallback: dotted names like "models.User" — try the simple + // name (tail after last dot) for languages where defs are indexed by + // simple name (Go). Only when the dotted lookup fails. + if (receiverName.includes('.')) { + const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1); + if (simple.length > 0 && simple !== receiverName) { + const simpleIds = scopes.qualifiedNames.get(simple); + if (simpleIds.length === 1) { + const def = scopes.defs.get(simpleIds[0]!); + if (def !== undefined && isClassLike(def.type)) return def; + } } } } @@ -875,6 +900,7 @@ function resolveThroughTypeParameterBound( parameterName: string, scopes: ScopeResolutionIndexes, stripDecoration?: DecorationStripper, + lookup?: ClassBindingLookup, ): SymbolDefinition | undefined { const bound = typeParameterAt(startScope, parameterName, scopes)?.bound; if (bound === undefined) return undefined; @@ -882,7 +908,7 @@ function resolveThroughTypeParameterBound( if (baseName === undefined || baseName === parameterName) return undefined; // A bound naming another parameter terminates here rather than recursing. if (bindsTypeParameter(startScope, baseName, scopes)) return undefined; - return findClassBindingInScope(startScope, baseName, scopes, stripDecoration); + return findClassBindingInScope(startScope, baseName, scopes, stripDecoration, lookup); } function normalizeTemplateArgToken(value: string): string { @@ -1283,6 +1309,7 @@ export function resolveInheritanceBaseInScope( scopes: ScopeResolutionIndexes, rawQualifiedName?: string, enclosingClassDef?: SymbolDefinition, + lookup?: ClassBindingLookup, ): SymbolDefinition | undefined { // #1982: when the source wrote a qualified base (`Other::Inner`), resolve it // against the full-path QualifiedNameIndex FIRST, so a same-tail nested base @@ -1302,7 +1329,7 @@ export function resolveInheritanceBaseInScope( if (qualified !== undefined) return qualified; } return ( - findClassBindingInScope(startScope, baseName, scopes) ?? + findClassBindingInScope(startScope, baseName, scopes, undefined, lookup) ?? resolveAmbiguousInheritanceBaseViaImports(startScope, baseName, scopes) ); } @@ -1528,6 +1555,43 @@ function dirnameOf(filePath: string): string { return idx === -1 ? '' : filePath.slice(0, idx); } +/** + * True when the caller can see `classDef` through import evidence, not + * merely because the class name is workspace-unique. + * + * Exact imported file (`#include "user.h"`) and a finalize-resolved + * `targetDefId` (named `use` / `pub use` followed to the def) are + * precise. An import that *names* this class (`targetExportedName`) + * is also precise — a barrel's `targetFile` is the re-exporting module, + * not the defining file. A sibling-file import of a *different* name + * is not: `use crate::a::bar` does not make `Foo` in `a/foo.rs` visible. + * + * Directory-only matching is intentionally omitted. The same-directory + * tier on `resolveAmbiguousInheritanceBaseViaImports` is for picking + * among *ambiguous* names (C# namespace `using`); it is not proof a + * unique constructor type is in scope. + */ +export function isClassFileImportGrounded( + startScope: ScopeId, + classDef: Pick, + scopes: ScopeResolutionIndexes, + importedName: string, +): boolean { + const callerModule = moduleScopeIdOf(startScope, scopes); + const callerFile = scopes.scopeTree.getScope(startScope)?.filePath; + if (callerFile !== undefined && classDef.filePath === callerFile) return true; + if (callerModule === null) return false; + const importEdges = scopes.imports.get(callerModule); + if (importEdges === undefined || importEdges.length === 0) return false; + for (const edge of importEdges) { + if (edge.targetDefId !== undefined && edge.targetDefId === classDef.nodeId) return true; + if (edge.targetFile === null) continue; + if (edge.targetFile === classDef.filePath) return true; + if (importedName.length > 0 && edge.targetExportedName === importedName) return true; + } + return false; +} + /** * Predicate for value-receiver bridge: the labels for which * `reconcileOwnership` registers methods/fields under the def's diff --git a/gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts new file mode 100644 index 000000000..2c66f1c05 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/utils/name-fallback-visibility.ts @@ -0,0 +1,97 @@ +/** + * Language-agnostic primitives for `ScopeResolver.isGlobalNameFallbackPlausible` + * implementations. + * + * The hook itself is per-language — the RULES here are not. What every + * implementation needs is the same small set of path arithmetic: which + * directory a file sits in, and whether a module path a caller wrote can name + * a given file or directory. Those questions are about paths, not about any + * language, so they live in shared code (see AGENTS.md: shared ingestion must + * not name languages) and the language files supply only the semantics. + */ + +import type { ParsedFile } from 'gitnexus-shared'; + +/** POSIX-style parent directory. `''` for a file at the repo root. */ +export function directoryOf(filePath: string): string { + const slash = filePath.lastIndexOf('/'); + return slash === -1 ? '' : filePath.slice(0, slash); +} + +/** + * Split a module path into segments, accepting the three separators languages + * spell module nesting with: `/` (Go, Node), `::` (Rust, C++) and `.` (JVM, + * Python). Empty segments and `.` are dropped, so a leading `./` contributes + * nothing. Named root prefixes are NOT stripped here — `crate::a` yields + * `['crate', 'a']`; a language whose paths carry one (Rust's `crate::` / + * `super::`) removes it before calling, see `rustUsePathOf`. + * + * `.` is only treated as a separator when the path contains no `/`: a Node + * specifier like `./util/parse.js` must not split on the extension dot. + */ +export function moduleSegments(modulePath: string): readonly string[] { + const bySlashOrColon = modulePath.split(/\/|::/).filter((s) => s !== '' && s !== '.'); + if (modulePath.includes('/')) return bySlashOrColon; + return bySlashOrColon.flatMap((s) => s.split('.').filter((p) => p !== '')); +} + +/** + * Does a module path a caller wrote reach `targetPath`? + * + * True when either side's segments are a SUFFIX of the other's. Both directions + * are needed and neither alone is sufficient: + * + * - The written path is usually longer than the repo-relative one, because it + * carries a module/package prefix that is not a directory + * (`github.com/org/svc/internal/models` → `internal/models`). + * - The repo-relative path is longer when the manifest that defines the module + * root sits in a subdirectory of the analyzed tree (`svc/go.mod`, so + * `svc/internal/models` is written `mod/internal/models`). + * + * So the match is on ALIGNED TRAILING SEGMENTS, and it succeeds when either the + * shorter side is fully contained in the longer, or at least two segments align. + * Both conditions are needed: full containment covers a one-segment package + * (`mod/models` reaching `models`), while the two-segment floor covers the case + * where neither side contains the other because each carries a different root + * (`mod/internal/models` vs `svc/internal/models`). A SINGLE aligned segment + * with neither side contained is not enough — `handlers` appearing at the end of + * two unrelated trees says nothing. + * + * Tolerance is the SAFE direction here: this predicate is consulted to decide + * whether to REFUSE an edge, so over-matching loses a refusal (the edge stays, + * labeled and excluded from flows) while under-matching loses a real edge. + */ +export function modulePathReaches(writtenPath: string, targetPath: string): boolean { + const written = moduleSegments(writtenPath); + const target = moduleSegments(targetPath); + if (written.length === 0 || target.length === 0) return false; + const shorter = Math.min(written.length, target.length); + let aligned = 0; + while ( + aligned < shorter && + written[written.length - 1 - aligned] === target[target.length - 1 - aligned] + ) { + aligned++; + } + return aligned === shorter || aligned >= 2; +} + +/** Every module path this file's import statements named, in source order. */ +function importedModulePaths(parsed: ParsedFile): readonly string[] { + return parsed.parsedImports.map((imp) => imp.targetRaw); +} + +/** True when any of the caller's imports reaches `targetPath`. */ +export function anyImportReaches(parsed: ParsedFile, targetPath: string): boolean { + for (const written of importedModulePaths(parsed)) { + if (modulePathReaches(written, targetPath)) return true; + } + return false; +} + +/** Strip a trailing file extension. `a/b.rs` → `a/b`; `a/b` → `a/b`. */ +export function stripExtension(filePath: string): string { + const slash = filePath.lastIndexOf('/'); + const dot = filePath.lastIndexOf('.'); + return dot > slash ? filePath.slice(0, dot) : filePath; +} diff --git a/gitnexus/src/core/ingestion/ts-js-export-marker.ts b/gitnexus/src/core/ingestion/ts-js-export-marker.ts new file mode 100644 index 000000000..b01440433 --- /dev/null +++ b/gitnexus/src/core/ingestion/ts-js-export-marker.ts @@ -0,0 +1,345 @@ +/** + * Export evidence for ECMAScript declarations — the `@declaration.is-exported` + * marker both the TypeScript and the JavaScript capture emitters synthesize. + * + * `SymbolDefinition.isExported` is tri-state, and this is where the three + * states are decided for TS/JS: + * + * - `true` — the declaration sits under an `export_statement` (`export + * function f`, `export const x`, `export default class`), or the + * file names it in an `export { f }` / `export { f as g }` clause + * or an `export default f` / `export = f` statement. + * - `false` — an ESM-shaped file (no CommonJS export assignment) that does + * neither. The declaration is module-private: `export *` cannot + * republish it and it must not be counted as a wildcard provider. + * - no verdict — the file exports through CommonJS (`module.exports = …`, + * `exports.x = …`, top-level `this.x = …`) or is an ambient + * `.d.ts`, where "not under `export`" says nothing about what the + * module publishes. Nothing is emitted, and the reader keeps its + * prior behavior. One CommonJS shape IS decidable and gets `true`: + * a method or property declared directly in the object literal + * assigned to `module.exports` (`module.exports = { alpha() {} }`) + * is that module's export of `alpha`. + * + * Only a declaration reached from the top level through DECLARATION nodes can + * be a module export. The walk therefore stops with `false` at the first + * nesting boundary — a class body, an interface/enum body, a function body, an + * object literal that is not the `module.exports` value: `export class C { + * m() {} }` exports `C`, not `m`; `function w() { function s() {} }` exports + * nothing even when the file has `export { s }` for a different `s`. + * + * The ancestor walk here is deliberately NOT `tsExportChecker` + * (`export-detection.ts`): that checker's text fallback (`text.startsWith('export ')`) + * fires on the `program` node of any file whose first token is `export`, which + * would mark every declaration in such a file exported. + */ + +import type { SyntaxNode } from './utils/ast-helpers.js'; + +export interface EsmExportEvidence { + /** Local names published by `export { … }`, `export default `, `export = `. */ + readonly namedLocals: ReadonlySet; + /** The file exports through a CommonJS assignment: a plain "not under + * `export`" is no verdict there. */ + readonly commonJs: boolean; +} + +/** 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 === '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', + '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) + ) + return true; + if (scope.type === 'for_in_statement' || scope.type === 'for_of_statement') { + if (bindsReceiver(scope.childForFieldName('left'), name)) return true; + } + if (scope.type === 'for_statement') { + const initializer = scope.childForFieldName('initializer'); + if ( + initializer !== null && + (initializer.type === 'lexical_declaration' || + initializer.type === 'variable_declaration') && + initializer.namedChildren.some((child) => bindsReceiver(child, 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 === 'import_statement' && + declaration.namedChildren.some( + (child) => child.type === 'import_clause' && bindsReceiver(child, name), + ) + ) + return true; + 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' || + isExportReceiverShadowed(node, 'module') + ) + return false; + if (node.type === 'member_expression') { + return node.childForFieldName('property')?.text === 'exports'; + } + if (node.type !== 'subscript_expression') return false; + const index = node.childForFieldName('index'); + return index?.type === 'string' && (index.text === "'exports'" || index.text === '"exports"'); +} + +/** + * Does the file touch a CommonJS export object anywhere — `module.exports` or + * `exports.x` / `exports[x]` — as an actual expression? Read from AST nodes, + * not source text, so a comment or string mentioning `module.exports` does not + * disable the file's verdicts. + */ +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' && + !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' && + !isExportReceiverShadowed(sub, 'exports') + ) + return true; + if (isModuleExportsReference(sub)) return true; + } + return false; +} + +/** + * Top-level control-flow wrappers around `this.x =` / `this['x'] =`. + * Recurse through these only — a function or class body is a different `this`. + */ +const TOP_LEVEL_THIS_WALK: ReadonlySet = new Set([ + 'if_statement', + 'else_clause', + 'for_statement', + 'for_in_statement', + 'for_of_statement', + 'while_statement', + 'do_statement', + 'switch_statement', + 'switch_body', + 'switch_case', + 'switch_default', + 'try_statement', + 'catch_clause', + 'finally_clause', + 'labeled_statement', + 'statement_block', +]); + +/** Module-wrapper `this.x =` / `this['x'] =` as a statement. */ +function isThisExportAssignment(stmt: SyntaxNode): boolean { + if (stmt.type !== 'expression_statement') return false; + const assignment = stmt.namedChildren[0]; + const left = + assignment?.type === 'assignment_expression' ? assignment.childForFieldName('left') : null; + return ( + left !== null && + (left.type === 'member_expression' || left.type === 'subscript_expression') && + left.childForFieldName('object')?.type === 'this' + ); +} + +/** + * A top-level `this` export, including `if (enabled) this.api = api` and the + * braced form. Does not walk into function or class bodies. + */ +function hasTopLevelThisExportAssignment(stmt: SyntaxNode): boolean { + if (isThisExportAssignment(stmt)) return true; + if (!TOP_LEVEL_THIS_WALK.has(stmt.type)) return false; + return stmt.namedChildren.some(hasTopLevelThisExportAssignment); +} + +/** Node types below which a declaration is nested, not module-level. */ +const NESTING_BOUNDARIES: ReadonlySet = new Set([ + 'class_body', + 'interface_body', + 'enum_body', + 'object_type', + 'statement_block', + 'arrow_function', + 'function_expression', + 'function_declaration', + 'generator_function', + 'generator_function_declaration', + 'method_definition', + // `export namespace NS { export function f() {} }` / `declare module 'x' { + // export function q(): void }`: an `export` inside these bodies is an export + // of the namespace/ambient module, not of the file. + 'internal_module', + 'module', + 'ambient_declaration', +]); + +/** + * Scan a file's top level once. `undefined` means the file's export surface + * cannot be read at all (ambient `.d.ts`), so no marker should be emitted. + */ +export function collectEsmExportEvidence( + root: SyntaxNode, + filePath: string, +): EsmExportEvidence | undefined { + if (filePath.endsWith('.d.ts')) return undefined; + const namedLocals = new Set(); + // Any CommonJS export surface anywhere in the file — a direct `module.exports + // = …`, an alias (`const m = module.exports; m.x = …`), an `exports.x` — means + // "not under `export`" says nothing. + let commonJs = hasCommonJsExportSurface(root); + for (const stmt of root.namedChildren) { + if (stmt.type === 'export_statement') { + // `export { a } from './x'` / `export type { T } from './t'` re-export + // ANOTHER module's names: they say nothing about a local `a`, and adding + // them here marked a private local of the same name exported. + if (stmt.childForFieldName('source') !== null) continue; + for (const child of stmt.namedChildren) { + if (child.type === 'export_clause') { + for (const spec of child.namedChildren) { + if (spec.type !== 'export_specifier') continue; + const name = spec.childForFieldName('name')?.text; + if (name !== undefined && name !== '') namedLocals.add(name); + } + } else if (child.type === 'identifier') { + // `export default f;` and TS `export = f;`. + namedLocals.add(child.text); + } + } + } else if (hasTopLevelThisExportAssignment(stmt)) { + commonJs = true; + } + } + return { namedLocals, commonJs }; +} + +/** Is `object` the value of a top-level `module.exports = { … }` assignment? */ +function isModuleExportsObject(object: SyntaxNode): boolean { + const assignment = object.parent; + if (assignment === null || assignment.type !== 'assignment_expression') return false; + if (assignment.childForFieldName('right')?.id !== object.id) return false; + const left = assignment.childForFieldName('left'); + if (left === null || !isModuleExportsReference(left)) return false; + return ( + assignment.parent?.type === 'expression_statement' && + assignment.parent.parent?.type === 'program' + ); +} + +/** + * The export verdict for a declaration whose NAME node is `nameNode`: + * `true` / `false` as documented in the header, `undefined` when this file's + * export surface cannot decide it (CommonJS, other than the `module.exports` + * object literal itself). + */ +export function esmExportVerdict( + nameNode: SyntaxNode, + evidence: EsmExportEvidence, +): boolean | undefined { + // The name node's own declaration node is where the walk starts; the + // declaration itself (a `method_definition`, a `function_declaration`) must + // not count as its own nesting boundary. + let current: SyntaxNode | null = nameNode.parent; + // An `export` keyword is only a FILE-level export when the walk reaches the + // 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; + continue; + } + if (current.type === 'object') { + // `module.exports = { alpha() {} }`: the literal's own members are the + // module's exports. Any other object literal is a nesting boundary. + if (isModuleExportsObject(current) && nameNode.parent?.parent?.id === current.id) return true; + 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; + } + if (underExport) return true; + if (evidence.commonJs) return undefined; + return evidence.namedLocals.has(nameNode.text); +} diff --git a/gitnexus/src/core/lbug/graph-emit-sink.ts b/gitnexus/src/core/lbug/graph-emit-sink.ts index 6b263701f..d13ab47c7 100644 --- a/gitnexus/src/core/lbug/graph-emit-sink.ts +++ b/gitnexus/src/core/lbug/graph-emit-sink.ts @@ -70,7 +70,7 @@ * replaced, so the obvious rewrite is not the one that shipped. * 3. The remaining ~90 ms was object allocation itself, irreducible while the * read API returns objects — so the five whole-graph scans moved to - * `forEachRelationshipFields`, which passes the four fields they actually + * `forEachRelationshipFields`, which passes the five fields they actually * read as primitives and allocates nothing. See * {@link GraphEmitSink.forEachRelationshipFields}. * @@ -282,14 +282,19 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { * safe because `buildRelRow` never persists `rel.id` and no consumer keys on * it (audited). * - * The dropped `reason`/`step` are safe too, but for a different reason worth - * stating: the PERSISTED row keeps their true values, because `buildRelRow` is - * handed the original relationship on the way through. Only in-memory reads - * see the `'streamed'` placeholder, and the in-pipeline consumers of streamed - * edges read neither field. So e.g. the `ACCESSES reason: 'read'|'write'` - * distinction that MCP queries rely on survives in the database. A future - * in-pipeline consumer needing `reason` or `step` on a streamed edge must add - * the column, not trust the placeholder. + * `reason` IS now retained, as an interned index — the in-pipeline consumer + * this JSDoc anticipated arrived. Process tracing and large-graph community + * detection must exclude global-name-fallback edges, which are emitted at + * exactly their confidence threshold (0.5) and so cannot be separated by + * confidence alone. Interning keeps the cost at one small integer per edge + * (the reason vocabulary is a fixed set of literals), not one string. + * + * `id` and `step` remain dropped. The PERSISTED row keeps `step`'s true value, + * because `buildRelRow` is handed the original relationship on the way + * through; only in-memory OBJECT reads see the `'streamed'`-era placeholder, + * and no in-pipeline consumer of streamed edges reads `step`. A future + * in-pipeline consumer needing `step` must add the column, not trust the + * placeholder. * * Node ids are interned; the strings are shared by reference with the node * map's, so interning adds bookkeeping, not new text. @@ -300,6 +305,12 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { private readonly tgtIx: number[] = []; private readonly relTypes: RelationshipType[] = []; private readonly confidences: number[] = []; + /** Interned reason strings, and the per-edge index into them. The vocabulary + * is a fixed set of emitter literals, so this is O(vocabulary) text plus one + * small integer per edge. */ + private readonly reasonIds = new Map(); + private readonly reasonByIx: string[] = []; + private readonly reasonIx: number[] = []; private finalized = false; /** * Streaming is OFF until {@link beginStreaming} is called by `parse`. @@ -472,6 +483,16 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { this.tgtIx.push(tgtIx); this.relTypes.push(relationship.type); this.confidences.push(relationship.confidence); + this.reasonIx.push(this.internReason(relationship.reason)); + } + + private internReason(reason: string): number { + const existing = this.reasonIds.get(reason); + if (existing !== undefined) return existing; + const ix = this.reasonByIx.length; + this.reasonByIx.push(reason); + this.reasonIds.set(reason, ix); + return ix; } /** Flush + close every writer and return the COPY manifest. Every fd is @@ -599,7 +620,13 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { * with the object-based graph despite holding relationships columnar. */ forEachRelationshipFields( - fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void, + fn: ( + sourceId: string, + targetId: string, + type: RelationshipType, + confidence: number, + reason: string, + ) => void, ): void { this.real.forEachRelationshipFields(fn); for (let ix = 0; ix < this.srcIx.length; ix++) { @@ -608,6 +635,7 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { this.nodeIdByIx[this.tgtIx[ix]], this.relTypes[ix], this.confidences[ix], + this.reasonByIx[this.reasonIx[ix]], ); } } diff --git a/gitnexus/src/core/lbug/pdg-emit-sink.ts b/gitnexus/src/core/lbug/pdg-emit-sink.ts index 12db53ac4..fafcd2c53 100644 --- a/gitnexus/src/core/lbug/pdg-emit-sink.ts +++ b/gitnexus/src/core/lbug/pdg-emit-sink.ts @@ -299,7 +299,13 @@ export class PdgEmitSink implements KnowledgeGraph { this.real.forEachRelationship(fn); } forEachRelationshipFields( - fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void, + fn: ( + sourceId: string, + targetId: string, + type: RelationshipType, + confidence: number, + reason: string, + ) => void, ): void { this.real.forEachRelationshipFields(fn); } diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 8b70ef10e..b7b9685b8 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -17,6 +17,11 @@ import { constants as fsConstants } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { retryRename } from '../storage/fs-atomic.js'; import { acquireIndexLock } from '../storage/index-lock.js'; +import { + logNameFallbackSummary, + summarizeNameFallback, + countCallsByLanguage, +} from './ingestion/scope-resolution/name-fallback-summary.js'; import { runPipelineFromRepo } from './ingestion/pipeline.js'; import { logUnresolvedReceiverFiles, @@ -3861,6 +3866,14 @@ async function runFullAnalysisInner( const resolutionOutcomes = pipelineResult.resolutionOutcomes ?? []; logUnresolvedReceiverFiles(resolutionOutcomes); + // 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), + ); + logNameFallbackSummary(nameFallbackSummary); // Annotated so the capabilities stamp below is compile-checked against // RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated @@ -3977,6 +3990,7 @@ async function runFullAnalysisInner( // Git-only: non-git repos never take the incremental path. schemaFingerprint: hasGitDir(repoPath) ? SCHEMA_FINGERPRINT : undefined, unresolvedReceiverMembers: summarizeUnresolvedReceivers(resolutionOutcomes), + nameFallbackEdges: nameFallbackSummary, scopeExtractionFailures: summarizeScopeExtractionFailures( pipelineResult.scopeExtractionFailures, ), diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index a06f646df..0ce2b7dab 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -759,14 +759,11 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // USES edge, `callableValueReferenceBoundaries` measures a real zero, and // `impact` on a registered accessor republishes `epistemic: "exact"` — the exact // #3399 defect this change exists to close, silently un-fixed. -// -// 94-97 went to #3179 (Objective-C), which has since merged; 98 is the next -// value above it. Re-checked against origin/main and every open PR touching -// this file at merge time, which is the rule the paragraphs above were written -// by PRs that each checked only once: main is at 97, and the remaining open -// claims (#3190 at 94, #2840 at 71, #1616 at 2) all sit below it and must -// re-bump themselves. -const SCHEMA_BUMP = 98; +// v99: ParsedImport retains declaredAtScope and export evidence changes in +// #3190. Old durable ParsedFiles lack the facts needed for scoped binding; +// invalidate both stores so warm indexing actually applies the correction. +// origin/main took 98 for #3219; 99 is the next free value. +const SCHEMA_BUMP = 99; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index f65fc8ec1..3dddc911a 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -28,6 +28,7 @@ import fs from 'fs/promises'; import path from 'path'; import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js'; +import type { NameFallbackSummary } from '../core/ingestion/scope-resolution/name-fallback-summary.js'; import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js'; import type { ScopeExtractionFailureSummary } from '../core/ingestion/scope-resolution/scope-extraction-failures.js'; @@ -311,6 +312,15 @@ export interface RepoMeta { * reads as absent, and both correctly mean "no hedge available from here". */ undecidedInterfaceSatisfaction?: UndecidedSatisfactionSummary; + /** + * 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. 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; /** * SHA-256 of every file's content at the time of the last successful * indexing run. The next run computes current hashes and diffs against diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts index d665a7acf..4136cca42 100644 --- a/gitnexus/src/types/pipeline.ts +++ b/gitnexus/src/types/pipeline.ts @@ -33,6 +33,14 @@ export interface PipelineResult { * produced; graph edge semantics are unchanged. */ resolutionOutcomes: readonly ResolutionOutcome[]; + /** + * Caller node id → simple names of every callee it has a CALLS edge to, read + * through the streaming sink when one was active (the raw graph holds no + * streamed edge). Denominator for the name-fallback census + * (`countCallsByLanguage`), so a guess count can be read as a share of the + * call graph. Absent only when scope resolution did not run. + */ + resolvedCalleeNamesByCaller?: ReadonlyMap>; /** * Interfaces whose structural-satisfaction check could not be completed * (#2873). Empty for languages with no structural detection. diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index c8c5d6efe..0035a60c4 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -85,7 +85,7 @@ }, "go-constructor-type-inference/cmd/main.go": { "captureGroups": 19, - "digest": "4f2e8bb6dbca59bcb82fc2efd43899b7a92816af28811cfc1858f0ce7ba9f6fe" + "digest": "8dff73176d5890d7ede1d8b9cd9f8555518a1dcc11fbf02190beb474c0f9e47e" }, "go-constructor-type-inference/models/repo.go": { "captureGroups": 8, @@ -252,8 +252,8 @@ "digest": "ad4cfabcd53397ab664ba4bb05df4e1a803455b5c255a19e2ed283c6523a1660" }, "go-method-enrichment/app.go": { - "captureGroups": 15, - "digest": "35929a31f6921feb074f50385aa91879e93f25b88d7f50774fddcfbb7b4e8539" + "captureGroups": 16, + "digest": "b2ab0936ab28e22d0333119838c929ec6e3d460cc0be8d27aa4d178b9f6452d3" }, "go-mixed-chain/cmd/main.go": { "captureGroups": 22, diff --git a/gitnexus/test/fixtures/lang-resolution/go-method-enrichment/app.go b/gitnexus/test/fixtures/lang-resolution/go-method-enrichment/app.go index 9de68c3b0..c2734574a 100644 --- a/gitnexus/test/fixtures/lang-resolution/go-method-enrichment/app.go +++ b/gitnexus/test/fixtures/lang-resolution/go-method-enrichment/app.go @@ -1,9 +1,10 @@ -package main - -import "animal" +// This fixture exercises method enrichment and a valid same-package free call. +// A different package with a normal import cannot refer to Dog or Classify bare. +package animal func main() { dog := Dog{} sound := dog.Speak() category := Classify("dog") + _, _ = sound, category } diff --git a/gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts b/gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts new file mode 100644 index 000000000..276015b25 --- /dev/null +++ b/gitnexus/test/integration/resolvers/barrel-dir-index-wildcard.test.ts @@ -0,0 +1,136 @@ +/** + * C2 — the grafana `Button` shape: a workspace barrel `export *`s a components + * index, which re-exports NAMED bindings (with inline `type` modifiers) from a + * DIRECTORY index, which `export *`s the real file, whose `Button` is a + * `React.forwardRef` const. Measured on grafana@871af0720: `Button` resolved 8 + * of 475 ledger entries while siblings through plain hops resolved at scale. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + getResolutionOutcomes, + runPipelineFromRepo, + writeFixtureRepo, + type PipelineResult, +} from './helpers.js'; + +describe('named re-export through a directory index that wildcards (grafana Button shape)', () => { + let result: PipelineResult; + let repoDir: string | undefined; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-c2-dir-index-')); + writeFixtureRepo(repoDir, { + 'package.json': '{ "name": "root", "private": true, "workspaces": ["packages/*"] }\n', + 'packages/ui/package.json': + '{ "name": "@x/ui", "version": "1.0.0", "main": "src/index.ts" }\n', + 'packages/ui/src/index.ts': `export * from './components';\nexport * from './themes';\n`, + // Inline `type` modifiers on the same statement as value re-exports. + 'packages/ui/src/components/index.ts': `export { Stack } from './Layout/Stack'; +export { Button, LinkButton, type ButtonVariant, ButtonGroup, type ButtonProps, clearButtonStyles } from './Button'; +`, + // Directory index: wildcard + one named re-export. + 'packages/ui/src/components/Button/index.ts': `export * from './Button';\nexport { ButtonGroup } from './ButtonGroup';\n`, + 'packages/ui/src/components/Button/Button.tsx': `import React from 'react'; +export type ButtonVariant = 'primary' | 'secondary'; +export interface ButtonProps { variant?: ButtonVariant; label: string } +export const Button = React.forwardRef((props, ref) => { + return null; +}); +export const LinkButton = React.forwardRef((props, ref) => { + return null; +}); +export const clearButtonStyles = (theme: string) => { + return theme; +}; +`, + 'packages/ui/src/components/Button/ButtonGroup.tsx': `export function ButtonGroup(children: string) { + return children; +} +`, + 'packages/ui/src/components/Layout/Stack.tsx': `export function Stack(children: string) { + return children; +} +`, + 'packages/ui/src/themes/index.ts': `export * from './hooks';\n`, + 'packages/ui/src/themes/hooks/index.ts': `export * from './useStyles2';\n`, + 'packages/ui/src/themes/hooks/useStyles2.ts': `export function useStyles2(fn: (t: string) => string) { + return fn('theme'); +} +`, + 'packages/app/package.json': + '{ "name": "@x/app", "version": "1.0.0", "main": "src/main.tsx", "dependencies": { "@x/ui": "1.0.0" } }\n', + 'packages/app/tsconfig.json': `{ "compilerOptions": { "jsx": "react-jsx" } }\n`, + 'packages/app/src/main.tsx': `import { Button, LinkButton, ButtonGroup, clearButtonStyles, Stack, useStyles2 } from '@x/ui'; + +export function render() { + const styles = useStyles2((t) => t); + clearButtonStyles(styles); + ButtonGroup('x'); + Stack('y'); + const a =