diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts index a46c71497..604383b27 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts @@ -140,6 +140,7 @@ export interface UniqueNamePropertyStats { */ function indexPropertyNodesByName( graph: KnowledgeGraph, + ownFilePaths: ReadonlySet, ): ReadonlyMap { const byName = new Map(); for (const node of graph.iterNodes()) { @@ -147,12 +148,21 @@ function indexPropertyNodesByName( const name = node.properties.name; if (typeof name !== 'string' || name.length === 0) continue; const filePath = node.properties.filePath; + // SAME LANGUAGE ONLY. The graph is shared across every language in the + // repo, and `fieldFallbackOnMethodLookup` only decides whether this pass + // RUNS for a language — it never restricted which nodes could be TARGETS. + // So a Java backend declaring `private int loyaltyPoints` was the unique + // carrier of that name, and a JS frontend writing `cfg.loyaltyPoints` on an + // untyped parameter got an edge to it: no owner, file, or language evidence, + // and inference across a language boundary that has no call path at all. + // The confidence tier does not save it, since `minConfidence` defaults to 0. + // + // `parsedFiles` is exactly this language's file set, so matching on it is a + // precise restriction rather than a heuristic — no node property needed. + if (typeof filePath !== 'string' || !ownFilePaths.has(filePath)) continue; const existing = byName.get(name); if (existing === OVERSATURATED) continue; - const candidate: PropertyCandidate = { - id: node.id, - filePath: typeof filePath === 'string' ? filePath : '', - }; + const candidate: PropertyCandidate = { id: node.id, filePath }; if (existing === undefined) { byName.set(name, [candidate]); continue; @@ -245,7 +255,7 @@ export function emitUniqueNamePropertyAccesses( /** Finalized import graph; narrows a name carried by several definitions. */ finalized?: FinalizedImportView, ): UniqueNamePropertyStats { - const byName = indexPropertyNodesByName(graph); + const byName = indexPropertyNodesByName(graph, new Set(parsedFiles.map((p) => p.filePath))); if (byName.size === 0) { return { emitted: 0, ambiguous: 0, narrowed: 0, ambiguousNames: [] }; } diff --git a/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/Loyalty.java b/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/Loyalty.java new file mode 100644 index 000000000..16d731460 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/Loyalty.java @@ -0,0 +1,11 @@ +// RV-5: the ONLY declaration of `loyaltyPointsBalance` in the workspace, and it +// is Java. Nothing in the JS file below can call into it. +package shop; + +public class Loyalty { + private int loyaltyPointsBalance; + + public int read() { + return loyaltyPointsBalance; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/settings.js b/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/settings.js new file mode 100644 index 000000000..f6e16684d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/settings.js @@ -0,0 +1,16 @@ +// A JS read of the same name through an untyped receiver. Workspace-wide the +// name is unique, so unique-name inference resolved it — to a Java private +// field, across a language boundary with no call path. +export function renderLoyalty(cfg) { + return cfg.loyaltyPointsBalance; +} + +// CONTROL: a same-language target the pass SHOULD still reach, so the fix is +// shown to restrict by language rather than to disable the pass. +export const jsConfig = { + jsOnlyThreshold: 10, +}; + +export function readsJsOnly(bag) { + return bag.jsOnlyThreshold; +} diff --git a/gitnexus/test/integration/resolvers/polyglot-property-isolation.test.ts b/gitnexus/test/integration/resolvers/polyglot-property-isolation.test.ts new file mode 100644 index 000000000..0d1ecd66e --- /dev/null +++ b/gitnexus/test/integration/resolvers/polyglot-property-isolation.test.ts @@ -0,0 +1,43 @@ +/** + * RV-5 — unique-name property inference must not cross a language boundary. + * + * The pass indexed `Property` nodes from the whole shared graph. Per-language + * gating (`fieldFallbackOnMethodLookup`) decides whether the pass RUNS for a + * language; it never restricted which nodes could be TARGETS. So the only + * carrier of a name might be in another language entirely, and a read here + * resolved to it on name uniqueness alone — no owner, no file, no call path. + * + * Confidence does not mitigate it: `minConfidence` defaults to 0, so a consumer + * gets the edge unless it opts out explicitly. + * + * Every other fixture is single-language, so this could not be caught by + * construction anywhere in the suite. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('cross-language property inference (RV-5)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'polyglot-property-isolation'), + () => {}, + ); + }, 60000); + + const readersOf = (field: string): string[] => + getRelationships(result, 'ACCESSES') + .filter((e) => e.target === field) + .map((e) => e.source); + + it('does not link a JS read to a Java field of the same name', () => { + expect(readersOf('loyaltyPointsBalance')).not.toContain('renderLoyalty'); + }); + + // The other half: restricting by language must not disable the pass. + it('still resolves a same-language unique name', () => { + expect(readersOf('jsOnlyThreshold')).toContain('readsJsOnly'); + }); +});