refactor(python-scope): remove as-unknown-as casts in scope-resolver (mirrors Unit 2)

Replay the C# scope-resolver cleanup on the Python side so both
providers share a single clean pattern:

  * Drop `ws as unknown as WorkspaceIndex` — `WorkspaceIndex` is
    `unknown` in the shared contract, so the narrow context assigns
    structurally without a cast.
  * Drop `{ id: scopeId } as unknown as Scope` — `pythonMergeBindings`
    never read the scope (the parameter was `_scope`), so the stub
    was a type-only ghost. Signature is now `(bindings)` and the
    LanguageProvider slot wraps with an arrow adapter.
  * Drop `allFilePaths as Set<string>` — the orchestrator hands a
    `ReadonlySet<string>`; we copy it into a `Set` at the resolver
    adapter so the legacy downstream `resolvePythonImportInternal`
    chain (typed for mutable `Set<string>`) keeps working. The copy
    is O(N) once per import, trivial cost.

Left intact on purpose: the `(callsite, def) → (def, callsite)`
arrow wrapper on `arityCompatibility`. That's a documented shape
difference between `LanguageProvider.arityCompatibility(def, callsite)`
and `ScopeResolver.arityCompatibility(callsite, def)`; both providers
(Python + C#) carry the same wrapper. Reconciling is a separate
refactor across both contracts.

No runtime behavior change.

Verified:
  - npx tsc --noEmit                              clean
  - Python + C# unit + integration suites         529/529 passing
This commit is contained in:
Gergo Magyar 2026-04-22 11:53:37 +01:00
parent 751f5b20fd
commit f9956d89e2
5 changed files with 35 additions and 32 deletions

View file

@ -98,7 +98,7 @@ export const pythonProvider = defineLanguage({
interpretTypeBinding: interpretPythonTypeBinding,
bindingScopeFor: pythonBindingScopeFor,
importOwningScope: pythonImportOwningScope,
mergeBindings: pythonMergeBindings,
mergeBindings: (_scope, bindings) => pythonMergeBindings(bindings),
receiverBinding: pythonReceiverBinding,
arityCompatibility: pythonArityCompatibility,
resolveImportTarget: resolvePythonImportTarget,

View file

@ -15,6 +15,10 @@ import { resolvePythonImportInternal } from '../../import-resolvers/python.js';
export interface PythonResolveContext {
readonly fromFile: string;
/** Mutable `Set` because the legacy `resolvePythonImportInternal`
* chain downstream is typed to accept `Set<string>`. Callers that
* only hold a `ReadonlySet` should copy via `new Set(...)` at the
* adapter boundary. */
readonly allFilePaths: Set<string>;
}
@ -22,6 +26,10 @@ export function resolvePythonImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
// WorkspaceIndex is `unknown` in the shared contract (Ring 1
// placeholder). The scope-resolution orchestrator hands us a
// PythonResolveContext-shaped object; narrow structurally rather
// than via a cast chain so unexpected shapes return null cleanly.
const ctx = workspaceIndex as PythonResolveContext | undefined;
if (
ctx === undefined ||

View file

@ -13,7 +13,7 @@
* purposes).
*/
import type { BindingRef, Scope } from 'gitnexus-shared';
import type { BindingRef } from 'gitnexus-shared';
const TIER_LOCAL = 0;
const TIER_IMPORT = 1;
@ -35,10 +35,7 @@ function tierOf(b: BindingRef): number {
}
}
export function pythonMergeBindings(
_scope: Scope,
bindings: readonly BindingRef[],
): readonly BindingRef[] {
export function pythonMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
let bestTier = Number.POSITIVE_INFINITY;

View file

@ -12,7 +12,7 @@
* the 2 booleans, and register in `scope-resolution/pipeline/registry.ts`.
*/
import type { ParsedFile, Scope, WorkspaceIndex } from 'gitnexus-shared';
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
@ -31,30 +31,30 @@ const pythonScopeResolver: ScopeResolver = {
importEdgeReason: 'python-scope: import',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
// PythonResolveContext expects a mutable Set; orchestrator hands us
// a ReadonlySet — safe to widen since the resolver only reads.
const ws: PythonResolveContext = {
fromFile,
allFilePaths: allFilePaths as Set<string>,
};
// Copy the orchestrator's `ReadonlySet` into a `Set` because the
// legacy Python resolver chain (`resolvePythonImportInternal` →
// `resolveAbsoluteFromFiles` / `hasRepoCandidate`) is typed to
// receive a mutable `Set<string>`. The copy is O(N) but called
// once per import — trivial compared to the parser work.
const ws: PythonResolveContext = { fromFile, allFilePaths: new Set(allFilePaths) };
// `WorkspaceIndex` is an opaque `unknown` placeholder in the
// shared contract, so `ws` passes structurally without a cast.
return resolvePythonImportTarget(
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
ws as unknown as WorkspaceIndex,
ws,
);
},
// Python LEGB precedence: local > import/namespace/reexport > wildcard.
mergeBindings: (existing, incoming, scopeId) => {
// pythonMergeBindings(scope, bindings) only consults BindingRef.origin
// for tier ordering, not scope.kind. A shape-stub satisfies the type
// contract without falsifying behavior. Widen the readonly result
// to a mutable BindingRef[] for the orchestrator's hook signature.
const fakeScope = { id: scopeId } as unknown as Scope;
return [...pythonMergeBindings(fakeScope, [...existing, ...incoming])];
},
// The per-scope id is unused by pythonMergeBindings (tier ordering
// is computed purely from BindingRef.origin), so we don't need to
// synthesize a Scope.
mergeBindings: (existing, incoming) => [...pythonMergeBindings([...existing, ...incoming])],
// Adapter: pythonArityCompatibility predates RegistryProviders and
// uses (def, callsite). Contract is (callsite, def).
// uses (def, callsite). ScopeResolver contract is (callsite, def).
// Wrapper kept to honor both contracts without altering the legacy
// shape that LanguageProvider.arityCompatibility consumes.
arityCompatibility: (callsite, def) => pythonArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>

View file

@ -144,48 +144,46 @@ describe('pythonReceiverBinding', () => {
// ─── mergeBindings ─────────────────────────────────────────────────────────
describe('pythonMergeBindings — LEGB precedence', () => {
const scope = fnScope();
it('local shadows imported', () => {
const local = binding('local', 'L');
const imp = binding('import', 'I');
expect(pythonMergeBindings(scope, [imp, local])).toEqual([local]);
expect(pythonMergeBindings([imp, local])).toEqual([local]);
});
it('explicit import shadows wildcard', () => {
const imp = binding('import', 'I');
const wc = binding('wildcard', 'W');
expect(pythonMergeBindings(scope, [wc, imp])).toEqual([imp]);
expect(pythonMergeBindings([wc, imp])).toEqual([imp]);
});
it('local shadows BOTH imported and wildcard', () => {
const local = binding('local', 'L');
const imp = binding('import', 'I');
const wc = binding('wildcard', 'W');
expect(pythonMergeBindings(scope, [wc, imp, local])).toEqual([local]);
expect(pythonMergeBindings([wc, imp, local])).toEqual([local]);
});
it('keeps multiple bindings within the same tier (overload-like)', () => {
const a = binding('local', 'A');
const b = binding('local', 'B');
expect(pythonMergeBindings(scope, [a, b])).toEqual([a, b]);
expect(pythonMergeBindings([a, b])).toEqual([a, b]);
});
it('dedupes by DefId — same nodeId collapses', () => {
const a = binding('local', 'A');
const a2 = binding('local', 'A');
expect(pythonMergeBindings(scope, [a, a2])).toHaveLength(1);
expect(pythonMergeBindings([a, a2])).toHaveLength(1);
});
it('returns empty when given empty', () => {
expect(pythonMergeBindings(scope, [])).toEqual([]);
expect(pythonMergeBindings([])).toEqual([]);
});
it('namespace and reexport tie with explicit import (same tier)', () => {
const ns = binding('namespace', 'N');
const re = binding('reexport', 'R');
const imp = binding('import', 'I');
expect(pythonMergeBindings(scope, [ns, re, imp])).toHaveLength(3);
expect(pythonMergeBindings([ns, re, imp])).toHaveLength(3);
});
});