/**
 * Python: relative imports + class inheritance + ambiguous module disambiguation
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
  FIXTURES,
  CROSS_FILE_FIXTURES,
  getRelationships,
  getNodesByLabel,
  getNodesByLabelFull,
  edgeSet,
  runPipelineFromRepo,
  type PipelineResult,
} from './helpers.js';

function writeFixtureRepo(root: string, files: Record<string, string>): void {
  for (const [relPath, content] of Object.entries(files)) {
    const fullPath = path.join(root, relPath);
    fs.mkdirSync(path.dirname(fullPath), { recursive: true });
    fs.writeFileSync(fullPath, content, 'utf8');
  }
}

// ---------------------------------------------------------------------------
// Heritage: relative imports + class inheritance
// ---------------------------------------------------------------------------

describe('Python relative import & heritage resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-pkg'), () => {});
  }, 60000);

  it('classifies top-level functions separately from class methods', () => {
    expect(getNodesByLabel(result, 'Class')).toEqual(['AuthService', 'BaseModel', 'User']);
    expect(getNodesByLabel(result, 'Function')).toEqual(['process_model']);
    expect(getNodesByLabel(result, 'Method')).toEqual([
      'authenticate',
      'get_name',
      'save',
      'validate',
    ]);
  });

  it('emits exactly 1 EXTENDS edge: User → BaseModel', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    expect(extends_.length).toBe(1);
    expect(extends_[0].source).toBe('User');
    expect(extends_[0].target).toBe('BaseModel');
  });

  it('resolves all 3 relative imports', () => {
    const imports = getRelationships(result, 'IMPORTS');
    expect(imports.length).toBe(3);
    expect(edgeSet(imports)).toEqual([
      'auth.py → user.py',
      'helpers.py → base.py',
      'user.py → base.py',
    ]);
  });

  it('emits exactly 3 CALLS edges', () => {
    const calls = getRelationships(result, 'CALLS');
    expect(calls.length).toBe(3);
    expect(edgeSet(calls)).toEqual([
      'authenticate → validate',
      'process_model → save',
      'process_model → validate',
    ]);
  });

  it('no OVERRIDES edges target Property nodes', () => {
    const overrides = getRelationships(result, 'METHOD_OVERRIDES');
    for (const edge of overrides) {
      const target = result.graph.getNode(edge.rel.targetId);
      expect(target).toBeDefined();
      expect(target!.label).not.toBe('Property');
    }
  });
});

// ---------------------------------------------------------------------------
// Qualified / generic bases (#1951). An earlier synth DROPPED these shapes —
// only bare `identifier` bases emitted, so production silently omitted their
// inheritance edges. service.py exercises the three now-handled shapes plus a
// bare control, each base defined in a sibling module:
//   - Service: `base_mod.Model`   (attribute base, trailing id -> Model)
//   - Nested:  `a.b.Base`         (nested attribute base, recurse -> Base)
//   - Gen:     `Container[str]`   (subscript base, value: field -> Container)
//   - Plain:   `Container`        (bare control, byte-identical capture)
// Scope-resolution (the single path since #942) owns these edges; the synth's
// bare-name text is asserted to match the documented per-shape reduction.
// ---------------------------------------------------------------------------

describe('Python qualified-base heritage resolution (#1951)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-qualified-base'), () => {});
  }, 60000);

  it('emits EXTENDS edges for attribute / nested-attribute / subscript / bare bases', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    expect(edgeSet(extends_)).toEqual([
      'Gen → Container',
      'Nested → Base',
      'Plain → Container',
      'Service → Model',
    ]);
  });

  it('emits no IMPLEMENTS edges (Python has no interfaces)', () => {
    const implements_ = getRelationships(result, 'IMPLEMENTS');
    expect(implements_.length).toBe(0);
  });

  it('all heritage edges point to real graph nodes', () => {
    for (const edge of getRelationships(result, 'EXTENDS')) {
      const target = result.graph.getNode(edge.rel.targetId);
      expect(target).toBeDefined();
      expect(target!.properties.name).toBe(edge.target);
    }
  });
});

// ---------------------------------------------------------------------------
// Ambiguous: Handler in two packages, relative import disambiguates
// ---------------------------------------------------------------------------

describe('Python ambiguous symbol resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-ambiguous'), () => {});
  }, 60000);

  it('detects 2 Handler classes', () => {
    const classes = getNodesByLabel(result, 'Class');
    expect(classes.filter((n) => n === 'Handler').length).toBe(2);
    expect(classes).toContain('UserHandler');
  });

  it('resolves EXTENDS to models/handler.py (not other/handler.py)', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    expect(extends_.length).toBe(1);
    expect(extends_[0].source).toBe('UserHandler');
    expect(extends_[0].target).toBe('Handler');
    expect(extends_[0].targetFilePath).toBe('models/handler.py');
  });

  it('import edge points to models/ not other/', () => {
    const imports = getRelationships(result, 'IMPORTS');
    expect(imports.length).toBe(1);
    expect(imports[0].targetFilePath).toBe('models/handler.py');
  });

  it('all heritage edges point to real graph nodes', () => {
    for (const edge of getRelationships(result, 'EXTENDS')) {
      const target = result.graph.getNode(edge.rel.targetId);
      expect(target).toBeDefined();
    }
  });
});

describe('Python call resolution with arity filtering', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-calls'), () => {});
  }, 60000);

  it('resolves run → write_audit to one.py via arity narrowing', () => {
    const calls = getRelationships(result, 'CALLS');
    expect(calls.length).toBe(1);
    expect(calls[0].source).toBe('run');
    expect(calls[0].target).toBe('write_audit');
    expect(calls[0].targetFilePath).toBe('one.py');
    expect(calls[0].rel.reason).toBe('import-resolved');
  });
});

// ---------------------------------------------------------------------------
// Member-call resolution: obj.method() resolves through pipeline
// ---------------------------------------------------------------------------

describe('Python member-call resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-member-calls'), () => {});
  }, 60000);

  it('resolves process_user → save as a member call on User', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save');
    expect(saveCall).toBeDefined();
    expect(saveCall!.source).toBe('process_user');
    expect(saveCall!.targetFilePath).toBe('user.py');
  });

  it('classifies regular and dunder class-body functions as Method nodes', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Method')).toEqual(
      expect.arrayContaining(['save', '__getitem__']),
    );
    expect(getNodesByLabel(result, 'Function')).not.toContain('save');
    expect(getNodesByLabel(result, 'Function')).not.toContain('__getitem__');
  });
});

// ---------------------------------------------------------------------------
// Receiver-constrained resolution: typed variables disambiguate same-named methods
// ---------------------------------------------------------------------------

describe('Python receiver-constrained resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-receiver-resolution'), () => {});
  }, 60000);

  it('detects User and Repo classes, both with save methods', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('Repo');
    const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
    expect(saveFns.length).toBe(2);
  });

  it('resolves user.save() to User.save and repo.save() to Repo.save via receiver typing', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save');
    expect(saveCalls.length).toBe(2);

    const userSave = saveCalls.find((c) => c.targetFilePath === 'user.py');
    const repoSave = saveCalls.find((c) => c.targetFilePath === 'repo.py');

    expect(userSave).toBeDefined();
    expect(repoSave).toBeDefined();
    expect(userSave!.source).toBe('process_entities');
    expect(repoSave!.source).toBe('process_entities');
  });
});

// ---------------------------------------------------------------------------
// Named import disambiguation: two modules export same name, from-import resolves
// ---------------------------------------------------------------------------

describe('Python named import disambiguation', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-named-imports'), () => {});
  }, 60000);

  it('resolves process_input → format_data to format_upper.py via from-import', () => {
    const calls = getRelationships(result, 'CALLS');
    const formatCall = calls.find((c) => c.target === 'format_data');
    expect(formatCall).toBeDefined();
    expect(formatCall!.source).toBe('process_input');
    expect(formatCall!.targetFilePath).toBe('format_upper.py');
  });

  it('emits IMPORTS edge to format_upper.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const appImport = imports.find((e) => e.source === 'app.py');
    expect(appImport).toBeDefined();
    expect(appImport!.targetFilePath).toBe('format_upper.py');
  });
});

// ---------------------------------------------------------------------------
// Variadic resolution: *args don't get filtered by arity
// ---------------------------------------------------------------------------

describe('Python variadic call resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-variadic-resolution'), () => {});
  }, 60000);

  it('resolves process_input → log_entry to logger.py despite 3 args vs *args', () => {
    const calls = getRelationships(result, 'CALLS');
    const logCall = calls.find((c) => c.target === 'log_entry');
    expect(logCall).toBeDefined();
    expect(logCall!.source).toBe('process_input');
    expect(logCall!.targetFilePath).toBe('logger.py');
  });
});

// ---------------------------------------------------------------------------
// Alias import resolution: from x import User as U resolves U → User
// ---------------------------------------------------------------------------

describe('Python alias import resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-alias-imports'), () => {});
  }, 60000);

  it('detects User and Repo classes', () => {
    expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
  });

  it('resolves u.save() to models.py and r.persist() to models.py via alias', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save');
    const persistCall = calls.find((c) => c.target === 'persist');

    expect(saveCall).toBeDefined();
    expect(saveCall!.source).toBe('main');
    expect(saveCall!.targetFilePath).toBe('models.py');

    expect(persistCall).toBeDefined();
    expect(persistCall!.source).toBe('main');
    expect(persistCall!.targetFilePath).toBe('models.py');
  });

  it('emits exactly 1 IMPORTS edge: app.py → models.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    expect(imports.length).toBe(1);
    expect(imports[0].sourceFilePath).toBe('app.py');
    expect(imports[0].targetFilePath).toBe('models.py');
  });
});

// ---------------------------------------------------------------------------
// Plain import alias: import models as m → m.User() resolves to models.py
// ---------------------------------------------------------------------------

describe('Python plain import alias resolution (import X as Y)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-plain-import-alias'), () => {});
  }, 60000);

  it('detects User classes in both models.py and auth.py', () => {
    const classes = getNodesByLabel(result, 'Class');
    expect(classes).toContain('User');
    expect(classes).toContain('Repo');
  });

  it('emits IMPORTS edges: app.py → models.py and app.py → auth.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const importFiles = imports
      .filter((i) => i.sourceFilePath === 'app.py')
      .map((i) => i.targetFilePath)
      .sort();
    expect(importFiles).toEqual(['auth.py', 'models.py']);
  });

  it('resolves m.User() and u.save() to models.py via alias', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save' && c.source === 'main');
    expect(saveCall).toBeDefined();
    expect(saveCall!.targetFilePath).toBe('models.py');
  });

  it('resolves m.Repo() and r.persist() to models.py via alias', () => {
    const calls = getRelationships(result, 'CALLS');
    const persistCall = calls.find((c) => c.target === 'persist' && c.source === 'main');
    expect(persistCall).toBeDefined();
    expect(persistCall!.targetFilePath).toBe('models.py');
  });

  it('resolves a.User() and v.login() to auth.py via alias (disambiguation)', () => {
    const calls = getRelationships(result, 'CALLS');
    const loginCall = calls.find((c) => c.target === 'login' && c.source === 'main');
    expect(loginCall).toBeDefined();
    expect(loginCall!.targetFilePath).toBe('auth.py');
  });
});

// ---------------------------------------------------------------------------
// Same-name collision: import X as alias; alias.func() where caller is also named func
// Issue #417 — module-alias disambiguation must override same-file tier
// ---------------------------------------------------------------------------

describe('Python same-name collision via module alias (Issue #417)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-same-name-collision'), () => {});
  }, 60000);

  it('resolves app_metrics.get_metrics() to metrics.py, not self (same-name collision)', () => {
    const calls = getRelationships(result, 'CALLS');
    const getMetricsCall = calls.find(
      (c) => c.source === 'get_metrics' && c.target === 'get_metrics',
    );
    expect(getMetricsCall).toBeDefined();
    // Must resolve to metrics.py, NOT router.py (self-call)
    expect(getMetricsCall!.sourceFilePath).toBe('router.py');
    expect(getMetricsCall!.targetFilePath).toBe('metrics.py');
  });

  it('emits IMPORTS edge: router.py → metrics.py (module alias registered)', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const metricsImport = imports.find(
      (i) => i.sourceFilePath === 'router.py' && i.targetFilePath === 'metrics.py',
    );
    expect(metricsImport).toBeDefined();
  });
});

// ---------------------------------------------------------------------------
// Ancestor directory import: Python single-segment import resolved via ancestor walk
// Issue #417 — prevents cross-language misresolution when suffix matching picks .ts over .py
// ---------------------------------------------------------------------------

describe('Python ancestor directory import resolution (Issue #417)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-ancestor-import'), () => {});
  }, 60000);

  it('resolves from middleware import to backend/middleware.py, not frontend/middleware.ts', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const middlewareImport = imports.find(
      (i) =>
        i.sourceFilePath === 'backend/services/auth.py' && i.targetFilePath.includes('middleware'),
    );
    expect(middlewareImport).toBeDefined();
    expect(middlewareImport!.targetFilePath).toBe('backend/middleware.py');
  });

  it('resolves _canonical() call to middleware.py:get_remaining_slots via alias', () => {
    const calls = getRelationships(result, 'CALLS');
    const canonicalCall = calls.find(
      (c) => c.source === 'get_remaining_slots' && c.sourceFilePath === 'backend/services/auth.py',
    );
    expect(canonicalCall).toBeDefined();
    expect(canonicalCall!.target).toBe('get_remaining_slots');
    expect(canonicalCall!.targetFilePath).toBe('backend/middleware.py');
  });

  it('resolves depth-2 ancestor import: a/b/c/deep.py → a/utils.py (not suffix match)', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const utilsImport = imports.find(
      (i) => i.sourceFilePath === 'a/b/c/deep.py' && i.targetFilePath.includes('utils'),
    );
    expect(utilsImport).toBeDefined();
    expect(utilsImport!.targetFilePath).toBe('a/utils.py');
  });

  it('resolves format_currency() call across depth-2 ancestor import', () => {
    const calls = getRelationships(result, 'CALLS');
    const fmtCall = calls.find(
      (c) => c.source === 'render_price' && c.target === 'format_currency',
    );
    expect(fmtCall).toBeDefined();
    expect(fmtCall!.targetFilePath).toBe('a/utils.py');
  });
});

// ---------------------------------------------------------------------------
// Multi-segment ancestor walk: `from services.sync import X` style imports
// from a sibling sub-package nested under a shared root directory.
//
// Before this fix, single-segment ancestor walks worked (`from middleware
// import X` from `backend/services/auth.py` → `backend/middleware.py`) but
// multi-segment dotted imports were only resolved against the workspace
// root. In a `backend/`-prefixed repo, `from services.sync import X` from
// `backend/routers/cron.py` would silently drop because `services/sync.py`
// does not exist at the workspace root — only `backend/services/sync.py`
// does. The fix mirrors the single-segment ancestor walk for multi-segment
// paths.
// ---------------------------------------------------------------------------

describe('Python multi-segment ancestor directory import resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-multi-segment-ancestor-import'),
      () => {},
    );
  }, 60000);

  it('resolves from services.sync import to backend/services/sync.py via ancestor walk', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const syncImport = imports.find(
      (i) =>
        i.sourceFilePath === 'backend/routers/cron.py' &&
        i.targetFilePath === 'backend/services/sync.py',
    );
    expect(syncImport).toBeDefined();
  });

  it('resolves from services.alerts import to backend/services/alerts.py via ancestor walk', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const alertsImport = imports.find(
      (i) =>
        i.sourceFilePath === 'backend/routers/cron.py' &&
        i.targetFilePath === 'backend/services/alerts.py',
    );
    expect(alertsImport).toBeDefined();
  });

  it('resolves from routers.alerts import to backend/routers/alerts.py (sibling sub-package)', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const routerImport = imports.find(
      (i) =>
        i.sourceFilePath === 'backend/routers/cron.py' &&
        i.targetFilePath === 'backend/routers/alerts.py',
    );
    expect(routerImport).toBeDefined();
  });

  it('emits CALLS edges for every multi-segment-imported callee', () => {
    const calls = getRelationships(result, 'CALLS').filter(
      (c) => c.sourceFilePath === 'backend/routers/cron.py',
    );

    const startCronRunCalls = calls.filter((c) => c.target === '_start_cron_run');
    expect(startCronRunCalls.length).toBe(3);
    expect(startCronRunCalls.every((c) => c.targetFilePath === 'backend/services/sync.py')).toBe(
      true,
    );

    const completeCronRunCalls = calls.filter((c) => c.target === '_complete_cron_run');
    expect(completeCronRunCalls.length).toBe(1);
    expect(completeCronRunCalls[0].targetFilePath).toBe('backend/services/sync.py');

    const opsAlertCalls = calls.filter((c) => c.target === '_create_ops_alert');
    expect(opsAlertCalls.length).toBe(2);
    expect(opsAlertCalls.every((c) => c.targetFilePath === 'backend/services/alerts.py')).toBe(
      true,
    );

    const sendDailyCalls = calls.filter((c) => c.target === 'send_daily_alerts');
    expect(sendDailyCalls.length).toBe(2);
    expect(sendDailyCalls.every((c) => c.targetFilePath === 'backend/routers/alerts.py')).toBe(
      true,
    );
  });

  it('preserves single-segment ancestor walk (regression check for from auth_utils import X)', () => {
    const calls = getRelationships(result, 'CALLS').filter(
      (c) => c.sourceFilePath === 'backend/routers/cron.py',
    );

    const verifyCalls = calls.filter((c) => c.target === 'verify_cron_secret');
    expect(verifyCalls.length).toBe(1);
    expect(verifyCalls[0].targetFilePath).toBe('backend/auth_utils.py');

    const orgCalls = calls.filter((c) => c.target === 'get_org_id_from_header');
    expect(orgCalls.length).toBe(1);
    expect(orgCalls[0].targetFilePath).toBe('backend/auth_utils.py');
  });
});

// ---------------------------------------------------------------------------
// Negative case for `hasRepoCandidate` widening: a vendored copy of an
// external package (e.g. `vendor/django/urls.py`) must not cause an external
// import like `from django.urls import path` issued from an unrelated file
// (`app/main.py`) to be treated as a local candidate. The ancestor-bounded
// nested check rejects vendored matches that don't sit on the importer's
// own ancestor path.
// ---------------------------------------------------------------------------

describe('Python multi-segment widening: vendored external package false-positive guard', () => {
  let repoDir: string;
  let result: PipelineResult;

  beforeAll(async () => {
    repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-vendored-django-'));
    writeFixtureRepo(repoDir, {
      'app/main.py': `from django.urls import path

def boot():
    path("/")
`,
      'vendor/django/__init__.py': '',
      'vendor/django/urls.py': `def path(p):
    return p
`,
    });
    result = await runPipelineFromRepo(repoDir, () => {});
  }, 60000);

  afterAll(() => {
    if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
  });

  it('does not resolve from django.urls to vendor/django/urls.py from an unrelated importer', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const stray = imports.find(
      (i) => i.sourceFilePath === 'app/main.py' && i.targetFilePath === 'vendor/django/urls.py',
    );
    expect(stray).toBeUndefined();
  });

  it('does not emit a CALLS edge from app/main.py:boot to vendor/django/urls.py:path', () => {
    const calls = getRelationships(result, 'CALLS').filter(
      (c) => c.sourceFilePath === 'app/main.py',
    );
    const stray = calls.find(
      (c) => c.target === 'path' && c.targetFilePath === 'vendor/django/urls.py',
    );
    expect(stray).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// Workspace-root precedence: when both `services/sync.py` (root) and
// `backend/services/sync.py` (ancestor) exist, an importer at
// `backend/routers/cron.py` doing `from services.sync import X` resolves to
// the root file. Mirrors Python's `sys.path` semantics where the project
// root is searched before package-local namespaces.
// ---------------------------------------------------------------------------

describe('Python multi-segment resolution: workspace root wins over ancestor', () => {
  let repoDir: string;
  let result: PipelineResult;

  beforeAll(async () => {
    repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-root-precedence-'));
    writeFixtureRepo(repoDir, {
      'services/__init__.py': '',
      'services/sync.py': `def root_marker():
    return "root"
`,
      'backend/__init__.py': '',
      'backend/services/__init__.py': '',
      'backend/services/sync.py': `def ancestor_marker():
    return "ancestor"
`,
      'backend/routers/__init__.py': '',
      'backend/routers/cron.py': `from services.sync import root_marker

def handler():
    return root_marker()
`,
    });
    result = await runPipelineFromRepo(repoDir, () => {});
  }, 60000);

  afterAll(() => {
    if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
  });

  it('resolves the import edge to the root services/sync.py, not backend/services/sync.py', () => {
    const imports = getRelationships(result, 'IMPORTS').filter(
      (i) => i.sourceFilePath === 'backend/routers/cron.py',
    );
    const rootEdge = imports.find((i) => i.targetFilePath === 'services/sync.py');
    expect(rootEdge).toBeDefined();

    const ancestorEdge = imports.find((i) => i.targetFilePath === 'backend/services/sync.py');
    expect(ancestorEdge).toBeUndefined();
  });

  it('binds the imported name to the root file, not the ancestor copy', () => {
    const calls = getRelationships(result, 'CALLS').filter(
      (c) => c.sourceFilePath === 'backend/routers/cron.py' && c.target === 'root_marker',
    );
    expect(calls.length).toBe(1);
    expect(calls[0].targetFilePath).toBe('services/sync.py');
  });
});

// ---------------------------------------------------------------------------
// Suffix-fallback determinism: when both root + ancestor walk miss but the
// suffix scan finds multiple candidates in unrelated trees, the resolver
// must pick the same file regardless of file-set insertion order. The
// previous implementation returned the first match in `Set` iteration
// order, which depended on file ingestion order and produced flapping
// edges across runs in multi-directory collision repos.
//
// Tie-break order: fewest path segments, then lexicographic.
// ---------------------------------------------------------------------------

describe('Python multi-segment resolution: suffix fallback determinism', () => {
  let repoDir: string;
  let result: PipelineResult;

  beforeAll(async () => {
    repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-determinism-'));
    writeFixtureRepo(repoDir, {
      // Importer's package. The `app/services/marker.py` file makes the
      // `services` segment gate-pass under the ancestor-bounded
      // `hasRepoCandidate` check, but `app/services/sync.py` is
      // intentionally absent so the ancestor walk misses and the suffix
      // fallback fires.
      'app/services/marker.py': `def _marker(): return True
`,
      'app/main.py': `from services.sync import handler

def boot():
    return handler()
`,
      // Two suffix candidates outside the importer's ancestor tree.
      // `lib/services/sync.py` has 3 path segments, the alternative has
      // 4 — the deterministic pick is `lib/services/sync.py`.
      'lib/services/sync.py': `def handler():
    return "lib"
`,
      'tooling/extras/services/sync.py': `def handler():
    return "tooling"
`,
    });
    result = await runPipelineFromRepo(repoDir, () => {});
  }, 60000);

  afterAll(() => {
    if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
  });

  it('picks the shortest-path candidate (lib/services/sync.py) and only that one', () => {
    const imports = getRelationships(result, 'IMPORTS').filter(
      (i) => i.sourceFilePath === 'app/main.py',
    );

    const libEdge = imports.find((i) => i.targetFilePath === 'lib/services/sync.py');
    expect(libEdge).toBeDefined();

    const toolingEdge = imports.find((i) => i.targetFilePath === 'tooling/extras/services/sync.py');
    expect(toolingEdge).toBeUndefined();
  });

  it('binds the call to the deterministic pick, not the alternate copy', () => {
    const calls = getRelationships(result, 'CALLS').filter(
      (c) => c.sourceFilePath === 'app/main.py' && c.target === 'handler',
    );
    expect(calls.length).toBe(1);
    expect(calls[0].targetFilePath).toBe('lib/services/sync.py');
  });
});

// ---------------------------------------------------------------------------
// Lexicographic tiebreak: when two suffix candidates have the same
// directory depth, the lexicographically smaller path wins. Without this,
// equal-depth collisions would still depend on file-set insertion order.
// ---------------------------------------------------------------------------

describe('Python multi-segment resolution: suffix fallback lexicographic tiebreak', () => {
  let repoDir: string;
  let result: PipelineResult;

  beforeAll(async () => {
    repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-lex-tiebreak-'));
    writeFixtureRepo(repoDir, {
      // Same gate-passing pattern as the determinism test — non-init
      // marker file makes the `services` segment satisfy
      // `hasRepoCandidate` for an importer at `app/main.py`.
      'app/services/marker.py': `def _marker(): return True
`,
      'app/main.py': `from services.sync import handler

def boot():
    return handler()
`,
      // Both candidates have depth 3, so directory-depth alone cannot
      // disambiguate. Lexicographic order picks `alpha/...` over
      // `omega/...` regardless of which file was ingested first.
      'alpha/services/sync.py': `def handler():
    return "alpha"
`,
      'omega/services/sync.py': `def handler():
    return "omega"
`,
    });
    result = await runPipelineFromRepo(repoDir, () => {});
  }, 60000);

  afterAll(() => {
    if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
  });

  it('picks the lexicographically smaller path on equal-depth ties', () => {
    const imports = getRelationships(result, 'IMPORTS').filter(
      (i) => i.sourceFilePath === 'app/main.py',
    );

    const alphaEdge = imports.find((i) => i.targetFilePath === 'alpha/services/sync.py');
    expect(alphaEdge).toBeDefined();

    const omegaEdge = imports.find((i) => i.targetFilePath === 'omega/services/sync.py');
    expect(omegaEdge).toBeUndefined();
  });

  it('binds the call to alpha/services/sync.py, not omega', () => {
    const calls = getRelationships(result, 'CALLS').filter(
      (c) => c.sourceFilePath === 'app/main.py' && c.target === 'handler',
    );
    expect(calls.length).toBe(1);
    expect(calls[0].targetFilePath).toBe('alpha/services/sync.py');
  });
});

// ---------------------------------------------------------------------------
// Insertion-order independence: re-runs the depth and lexicographic
// scenarios with the candidate files written in reverse order. The
// deterministic sort in `resolveAbsoluteFromFiles` should pick the same
// winner regardless. If a future refactor accidentally drops the sort
// and falls back to `Set` insertion order, these tests pin the
// regression directly.
// ---------------------------------------------------------------------------

describe('Python multi-segment resolution: suffix fallback insertion-order independence', () => {
  let depthRepoDir: string;
  let lexRepoDir: string;
  let depthResult: PipelineResult;
  let lexResult: PipelineResult;

  beforeAll(async () => {
    // Depth scenario, files written in reverse order: tooling first, lib
    // second. `writeFixtureRepo` iterates in object-property insertion
    // order, and the pipeline scanner's directory traversal is also
    // affected by mtime/inode order on most filesystems. The expected
    // winner is still `lib/services/sync.py` (depth 3 < depth 4).
    depthRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-determinism-rev-'));
    writeFixtureRepo(depthRepoDir, {
      'tooling/extras/services/sync.py': `def handler():
    return "tooling"
`,
      'lib/services/sync.py': `def handler():
    return "lib"
`,
      'app/services/marker.py': `def _marker(): return True
`,
      'app/main.py': `from services.sync import handler

def boot():
    return handler()
`,
    });
    depthResult = await runPipelineFromRepo(depthRepoDir, () => {});

    // Lexicographic scenario, files written in reverse order: omega first.
    // Expected winner is still `alpha/services/sync.py`.
    lexRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-suffix-lex-rev-'));
    writeFixtureRepo(lexRepoDir, {
      'omega/services/sync.py': `def handler():
    return "omega"
`,
      'alpha/services/sync.py': `def handler():
    return "alpha"
`,
      'app/services/marker.py': `def _marker(): return True
`,
      'app/main.py': `from services.sync import handler

def boot():
    return handler()
`,
    });
    lexResult = await runPipelineFromRepo(lexRepoDir, () => {});
  }, 120000);

  afterAll(() => {
    if (depthRepoDir !== undefined) fs.rmSync(depthRepoDir, { recursive: true, force: true });
    if (lexRepoDir !== undefined) fs.rmSync(lexRepoDir, { recursive: true, force: true });
  });

  it('depth tiebreak still picks lib/services/sync.py with reversed file-write order', () => {
    const imports = getRelationships(depthResult, 'IMPORTS').filter(
      (i) => i.sourceFilePath === 'app/main.py',
    );

    const libEdge = imports.find((i) => i.targetFilePath === 'lib/services/sync.py');
    expect(libEdge).toBeDefined();

    const toolingEdge = imports.find((i) => i.targetFilePath === 'tooling/extras/services/sync.py');
    expect(toolingEdge).toBeUndefined();
  });

  it('lex tiebreak still picks alpha/services/sync.py with reversed file-write order', () => {
    const imports = getRelationships(lexResult, 'IMPORTS').filter(
      (i) => i.sourceFilePath === 'app/main.py',
    );

    const alphaEdge = imports.find((i) => i.targetFilePath === 'alpha/services/sync.py');
    expect(alphaEdge).toBeDefined();

    const omegaEdge = imports.find((i) => i.targetFilePath === 'omega/services/sync.py');
    expect(omegaEdge).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// Re-export chain: from .base import X barrel pattern via __init__.py
// ---------------------------------------------------------------------------

describe('Python re-export chain resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-reexport-chain'), () => {});
  }, 60000);

  it('resolves user.save() through __init__.py barrel to models/base.py', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save');
    expect(saveCall).toBeDefined();
    expect(saveCall!.source).toBe('main');
    expect(saveCall!.targetFilePath).toBe('models/base.py');
  });

  it('resolves repo.persist() through __init__.py barrel to models/base.py', () => {
    const calls = getRelationships(result, 'CALLS');
    const persistCall = calls.find((c) => c.target === 'persist');
    expect(persistCall).toBeDefined();
    expect(persistCall!.source).toBe('main');
    expect(persistCall!.targetFilePath).toBe('models/base.py');
  });
});

// ---------------------------------------------------------------------------
// Local shadow: same-file definition takes priority over imported name
// ---------------------------------------------------------------------------

describe('Python local definition shadows import', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-local-shadow'), () => {});
  }, 60000);

  it('resolves save("test") to local save in app.py, not utils.py', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save' && c.source === 'main');
    expect(saveCall).toBeDefined();
    expect(saveCall!.targetFilePath).toBe('app.py');
  });
});

// ---------------------------------------------------------------------------
// Bare import: `import user` from services/auth.py resolves to services/user.py
// not models/user.py, even though models/ is indexed first (proximity wins)
// ---------------------------------------------------------------------------

describe('Python bare import resolution (proximity over index order)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-bare-import'), () => {});
  }, 60000);

  it('detects User in models/ and UserService in services/', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('UserService');
  });

  it('resolves `import user` from services/auth.py to services/user.py, not models/user.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const imp = imports.find((e) => e.sourceFilePath === 'services/auth.py');
    expect(imp).toBeDefined();
    expect(imp!.targetFilePath).toBe('services/user.py');
    expect(imp!.targetFilePath).not.toBe('models/user.py');
  });

  it('resolves svc.execute() CALLS edge to UserService#execute in services/user.py', () => {
    // End-to-end: correct IMPORTS resolution must propagate through type inference
    // so that user.UserService() binds svc → UserService, and svc.execute() resolves
    const calls = getRelationships(result, 'CALLS');
    const executeCall = calls.find(
      (c) => c.target === 'execute' && c.targetFilePath === 'services/user.py',
    );
    expect(executeCall).toBeDefined();
    expect(executeCall!.source).toBe('authenticate');
  });
});

// ---------------------------------------------------------------------------
// Constructor-inferred type resolution: user = User(); user.save() → User.save
// Cross-file SymbolTable verification (no explicit type annotations)
// ---------------------------------------------------------------------------

describe('Python constructor-inferred type resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-constructor-type-inference'),
      () => {},
    );
  }, 60000);

  it('detects User and Repo classes, both with save methods', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('Repo');
    const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
    expect(saveFns.length).toBe(2);
  });

  it('resolves user.save() to models/user.py via constructor-inferred type', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) => c.target === 'save' && c.targetFilePath === 'models/user.py',
    );
    expect(userSave).toBeDefined();
    expect(userSave!.source).toBe('process_entities');
  });

  it('resolves repo.save() to models/repo.py via constructor-inferred type', () => {
    const calls = getRelationships(result, 'CALLS');
    const repoSave = calls.find(
      (c) => c.target === 'save' && c.targetFilePath === 'models/repo.py',
    );
    expect(repoSave).toBeDefined();
    expect(repoSave!.source).toBe('process_entities');
  });

  it('emits exactly 2 save() CALLS edges (one per receiver type)', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save');
    expect(saveCalls.length).toBe(2);
  });
});

// ---------------------------------------------------------------------------
// Constructor-call resolution: User("alice") resolves to User class
// ---------------------------------------------------------------------------

describe('Python constructor-call resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-constructor-calls'), () => {});
  }, 60000);

  it('detects User class with __init__ and save methods', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Method')).toEqual(expect.arrayContaining(['__init__', 'save']));
    expect(getNodesByLabel(result, 'Function')).toContain('process');
  });

  it('resolves import from app.py to models.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const imp = imports.find((e) => e.source === 'app.py' && e.targetFilePath === 'models.py');
    expect(imp).toBeDefined();
  });

  it('emits HAS_METHOD from User class to __init__ and save', () => {
    const hasMethod = getRelationships(result, 'HAS_METHOD');
    const initEdge = hasMethod.find((e) => e.source === 'User' && e.target === '__init__');
    const saveEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
    expect(initEdge).toBeDefined();
    expect(saveEdge).toBeDefined();
  });

  it('resolves user.save() as a method call to models.py', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save');
    expect(saveCall).toBeDefined();
    expect(saveCall!.source).toBe('process');
    expect(saveCall!.targetFilePath).toBe('models.py');
  });
});

// ---------------------------------------------------------------------------
// self.save() resolves to enclosing class's own save method
// ---------------------------------------------------------------------------

describe('Python self resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-self-this-resolution'),
      () => {},
    );
  }, 60000);

  it('detects User and Repo classes, each with a save method', () => {
    expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
    const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
    expect(saveFns.length).toBe(2);
  });

  it('resolves self.save() inside User.process to User.save, not Repo.save', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process');
    expect(saveCall).toBeDefined();
    expect(saveCall!.targetFilePath).toBe('models/user.py');
  });
});

// ---------------------------------------------------------------------------
// Parent class resolution: EXTENDS edge
// ---------------------------------------------------------------------------

describe('Python parent resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-parent-resolution'), () => {});
  }, 60000);

  it('detects BaseModel and User classes', () => {
    expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']);
  });

  it('emits EXTENDS edge: User → BaseModel', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    expect(extends_.length).toBe(1);
    expect(extends_[0].source).toBe('User');
    expect(extends_[0].target).toBe('BaseModel');
  });

  it('EXTENDS edge points to real graph node in base.py', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    const target = result.graph.getNode(extends_[0].rel.targetId);
    expect(target).toBeDefined();
    expect(target!.properties.filePath).toBe('models/base.py');
  });
});

// ---------------------------------------------------------------------------
// super().save() resolves to parent class's save method
// ---------------------------------------------------------------------------

describe('Python super resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-super-resolution'), () => {});
  }, 60000);

  it('detects BaseModel, User, and Repo classes', () => {
    expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']);
  });

  it('resolves super().save() inside User to BaseModel.save, not Repo.save', () => {
    const calls = getRelationships(result, 'CALLS');
    const superSave = calls.find(
      (c) => c.source === 'save' && c.target === 'save' && c.targetFilePath === 'models/base.py',
    );
    expect(superSave).toBeDefined();
    // NOTE: no `rel.reason` assertion here. The legacy DAG classifies
    // Python `super()` as `'import-resolved'` (the ancestor arrives via
    // `from base import BaseModel`), while the scope-resolution super-
    // branch emits the canonical `'global'` (super resolves via MRO,
    // not through an import directive). That legacy-path asymmetry is
    // pre-existing (the scope-resolution path previously emitted the
    // non-standard `'scope-resolution: super-receiver'`) and closing it
    // requires realigning the legacy tier classifier, which is out of
    // scope here. The C# `csharp-super-resolution` + `csharp-generic-
    // parent` suites pin `'global'` because C# legacy also emits
    // `'global'` for `base` calls, giving us a same-graph guarantee
    // on at least one migrated language.
    const repoSave = calls.find(
      (c) => c.target === 'save' && c.targetFilePath === 'models/repo.py',
    );
    expect(repoSave).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// Python qualified constructor: user = models.User("alice"); user.save()
// ---------------------------------------------------------------------------

describe('Python qualified constructor inference', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-qualified-constructor'),
      () => {},
    );
  }, 60000);

  it('resolves user.save() via qualified constructor (models.User)', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save' && c.targetFilePath === 'models.py');
    expect(saveCall).toBeDefined();
    expect(saveCall!.source).toBe('main');
  });

  it('resolves user.greet() via qualified constructor (models.User)', () => {
    const calls = getRelationships(result, 'CALLS');
    const greetCall = calls.find((c) => c.target === 'greet' && c.targetFilePath === 'models.py');
    expect(greetCall).toBeDefined();
    expect(greetCall!.source).toBe('main');
  });
});

// ---------------------------------------------------------------------------
// Walrus operator: if (user := User("alice")): user.save()
// ---------------------------------------------------------------------------

describe('Python walrus operator type inference', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-walrus-operator'), () => {});
  }, 60000);

  it('detects User class with save and greet methods', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Method')).toEqual(expect.arrayContaining(['save', 'greet']));
  });

  it('resolves user.save() via walrus operator constructor inference', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save' && c.targetFilePath === 'models.py');
    expect(saveCall).toBeDefined();
    expect(saveCall!.source).toBe('process');
  });
});

// ---------------------------------------------------------------------------
// Class-level annotations: file-scope `user: User` disambiguates method calls
// ---------------------------------------------------------------------------

describe('Python class-level annotation resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-class-annotations'), () => {});
  }, 60000);

  it('detects User and Repo classes, both with save methods', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('Repo');
    const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
    expect(saveFns.length).toBe(2);
  });

  it('resolves active_user.save() to User.save via file-level annotation', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'user.py');
    expect(userSave).toBeDefined();
    expect(userSave!.source).toBe('process');
  });

  it('resolves active_repo.save() to Repo.save via file-level annotation', () => {
    const calls = getRelationships(result, 'CALLS');
    const repoSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'repo.py');
    expect(repoSave).toBeDefined();
    expect(repoSave!.source).toBe('process');
  });

  it('emits exactly 2 save() CALLS edges (one per receiver type)', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save');
    expect(saveCalls.length).toBe(2);
  });
});

// ---------------------------------------------------------------------------
// Return type inference: user = get_user('alice'); user.save()
// Python's scanner captures ALL call assignments, enabling return type inference.
// ---------------------------------------------------------------------------

describe('Python return type inference', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-return-type-inference'),
      () => {},
    );
  }, 60000);

  it('detects User class', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
  });

  it('detects get_user and save symbols', () => {
    // Python methods inside classes may be labeled Method or Function depending on nesting
    const allSymbols = [
      ...getNodesByLabel(result, 'Function'),
      ...getNodesByLabel(result, 'Method'),
    ];
    expect(allSymbols).toContain('get_user');
    expect(allSymbols).toContain('save');
  });

  it('resolves user.save() to User#save via return type inference from get_user() -> User', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process_user');
    expect(saveCall).toBeDefined();
    expect(saveCall!.targetFilePath).toContain('models.py');
  });
});

// ---------------------------------------------------------------------------
// Issue #289: static/classmethod classes must have HAS_METHOD edges
// ---------------------------------------------------------------------------

describe('Python static/classmethod class resolution (issue #289)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-static-class-methods'),
      () => {},
    );
  }, 60000);

  it('detects UserService and AdminService classes', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('UserService');
    expect(getNodesByLabel(result, 'Class')).toContain('AdminService');
  });

  it('detects all static/class methods as symbols', () => {
    const allSymbols = [
      ...getNodesByLabel(result, 'Function'),
      ...getNodesByLabel(result, 'Method'),
    ];
    expect(allSymbols).toContain('find_user');
    expect(allSymbols).toContain('create_user');
    expect(allSymbols).toContain('from_config');
    expect(allSymbols).toContain('delete_user');
  });

  it('emits HAS_METHOD edges linking static methods to their enclosing class', () => {
    // This is the core of issue #289: without HAS_METHOD, context() and impact()
    // return empty for classes whose methods are all @staticmethod/@classmethod
    const hasMethod = getRelationships(result, 'HAS_METHOD');

    const userServiceMethods = hasMethod.filter((e) => e.source === 'UserService');
    expect(userServiceMethods.length).toBe(3); // find_user, create_user, from_config

    const adminServiceMethods = hasMethod.filter((e) => e.source === 'AdminService');
    expect(adminServiceMethods.length).toBe(2); // find_user, delete_user
  });

  it('resolves unique static method calls (create_user, delete_user, from_config)', () => {
    const calls = getRelationships(result, 'CALLS');
    // delete_user is unique to AdminService — should resolve
    const deleteCall = calls.find(
      (c) =>
        c.target === 'delete_user' &&
        c.source === 'process' &&
        c.targetFilePath.includes('service.py'),
    );
    expect(deleteCall).toBeDefined();

    // create_user is unique to UserService — should resolve
    const createCall = calls.find(
      (c) =>
        c.target === 'create_user' &&
        c.source === 'process' &&
        c.targetFilePath.includes('service.py'),
    );
    expect(createCall).toBeDefined();
  });

  it('resolves find_user() via class-as-receiver for static method calls', () => {
    // With qualified IDs, UserService.find_user and AdminService.find_user are distinct
    // nodes — so both CALLS edges are correctly emitted (no ID collision).
    const calls = getRelationships(result, 'CALLS');
    const findCalls = calls.filter((c) => c.target === 'find_user' && c.source === 'process');
    expect(findCalls.length).toBe(2);
    expect(findCalls.every((c) => c.targetFilePath.includes('service.py'))).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// Nullable receiver: user: User | None = find_user(); user.save()
// Python 3.10+ union syntax — stripNullable unwraps `User | None` → `User`
// ---------------------------------------------------------------------------

describe('Python nullable receiver resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-nullable-receiver'), () => {});
  }, 60000);

  it('detects User and Repo classes, both with save methods', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('Repo');
    const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
    expect(saveFns.length).toBe(2);
  });

  it('resolves user.save() to User.save via nullable receiver typing', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'user.py');
    expect(userSave).toBeDefined();
    expect(userSave!.source).toBe('process_entities');
  });

  it('resolves repo.save() to Repo.save via nullable receiver typing', () => {
    const calls = getRelationships(result, 'CALLS');
    const repoSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'repo.py');
    expect(repoSave).toBeDefined();
    expect(repoSave!.source).toBe('process_entities');
  });

  it('user.save() does NOT resolve to Repo.save (negative disambiguation)', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save' && c.source === 'process_entities');
    // Each save() call should resolve to exactly one target file
    const userSaveToRepo = saveCalls.filter((c) => c.targetFilePath === 'repo.py');
    const repoSaveToUser = saveCalls.filter((c) => c.targetFilePath === 'user.py');
    // Exactly 1 edge to each file (not 2 to either)
    expect(userSaveToRepo.length).toBe(1);
    expect(repoSaveToUser.length).toBe(1);
  });

  it('emits exactly 2 save() CALLS edges (one per receiver type)', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save');
    expect(saveCalls.length).toBe(2);
  });
});

// ---------------------------------------------------------------------------
// Assignment chain propagation (Phase 4.3)
// ---------------------------------------------------------------------------

describe('Python assignment chain propagation', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-assignment-chain'), () => {});
  }, 60000);

  it('detects User and Repo classes each with a save method', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('Repo');
    const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
    expect(saveFns.length).toBe(2);
  });

  it('resolves alias.save() to User#save via assignment chain', () => {
    const calls = getRelationships(result, 'CALLS');
    // Positive: alias.save() must resolve to User#save
    const userSave = calls.find(
      (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user.py'),
    );
    expect(userSave).toBeDefined();
  });

  it('alias.save() does NOT resolve to Repo#save', () => {
    const calls = getRelationships(result, 'CALLS');
    // Negative: only one save call from process to User#save
    const wrongCall = calls.filter(
      (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user.py'),
    );
    expect(wrongCall.length).toBe(1);
  });

  it('resolves r_alias.save() to Repo#save via assignment chain', () => {
    const calls = getRelationships(result, 'CALLS');
    // Positive: r_alias.save() must resolve to Repo#save
    const repoSave = calls.find(
      (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('repo.py'),
    );
    expect(repoSave).toBeDefined();
  });

  it('each alias resolves to its own class, not the other', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user.py'),
    );
    const repoSave = calls.find(
      (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('repo.py'),
    );
    expect(userSave).toBeDefined();
    expect(repoSave).toBeDefined();
    expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath);
  });
});

// ---------------------------------------------------------------------------
// Python nullable (User | None) + assignment chain combined.
// Python 3.10+ union syntax is parsed as binary_operator by tree-sitter,
// stored as raw text "User | None" in TypeEnv. stripNullable's
// NULLABLE_KEYWORDS.has() path must resolve it at lookup time.
// ---------------------------------------------------------------------------

describe('Python nullable (User | None) + assignment chain combined', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-nullable-chain'), () => {});
  }, 60000);

  it('detects User and Repo classes each with a save method', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('Repo');
    const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
    expect(saveFns.length).toBe(2);
  });

  it('resolves alias.save() to User#save when source is User | None', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'nullable_chain_user' &&
        c.targetFilePath?.includes('user.py'),
    );
    expect(userSave).toBeDefined();
  });

  it('alias.save() from User | None does NOT resolve to Repo#save (negative)', () => {
    const calls = getRelationships(result, 'CALLS');
    const wrongCall = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'nullable_chain_user' &&
        c.targetFilePath?.includes('repo.py'),
    );
    expect(wrongCall).toBeUndefined();
  });

  it('resolves alias.save() to Repo#save when source is Repo | None', () => {
    const calls = getRelationships(result, 'CALLS');
    const repoSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'nullable_chain_repo' &&
        c.targetFilePath?.includes('repo.py'),
    );
    expect(repoSave).toBeDefined();
  });

  it('alias.save() from Repo | None does NOT resolve to User#save (negative)', () => {
    const calls = getRelationships(result, 'CALLS');
    const wrongCall = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'nullable_chain_repo' &&
        c.targetFilePath?.includes('user.py'),
    );
    expect(wrongCall).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// Python walrus operator (:=) assignment chain.
// Tests that extractPendingAssignment handles named_expression nodes
// in addition to regular assignment nodes.
// ---------------------------------------------------------------------------

describe('Python walrus operator (:=) assignment chain', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-walrus-chain'), () => {});
  }, 60000);

  it('detects User and Repo classes each with a save method', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('Repo');
    const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
    expect(saveFns.length).toBe(2);
  });

  it('resolves alias.save() to User#save via regular + walrus chains', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'walrus_chain_user' &&
        c.targetFilePath?.includes('user.py'),
    );
    expect(userSave).toBeDefined();
  });

  it('save() in walrus_chain_user does NOT resolve to Repo#save (negative)', () => {
    const calls = getRelationships(result, 'CALLS');
    const wrongCall = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'walrus_chain_user' &&
        c.targetFilePath?.includes('repo.py'),
    );
    expect(wrongCall).toBeUndefined();
  });

  it('resolves alias.save() to Repo#save via regular + walrus chains', () => {
    const calls = getRelationships(result, 'CALLS');
    const repoSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'walrus_chain_repo' &&
        c.targetFilePath?.includes('repo.py'),
    );
    expect(repoSave).toBeDefined();
  });

  it('save() in walrus_chain_repo does NOT resolve to User#save (negative)', () => {
    const calls = getRelationships(result, 'CALLS');
    const wrongCall = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'walrus_chain_repo' &&
        c.targetFilePath?.includes('user.py'),
    );
    expect(wrongCall).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// Python match/case as-pattern binding: `case User() as u: u.save()`
// Tests Phase 6 extractPatternBinding for Python's match statement.
// ---------------------------------------------------------------------------

describe('Python match/case as-pattern type binding', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-match-case'), () => {});
  }, 60000);

  it('detects User and Repo classes each with a save method', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('Repo');
    const saveFns = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
    expect(saveFns.length).toBe(2);
  });

  it('resolves u.save() to User#save via match/case as-pattern binding', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.py'),
    );
    expect(userSave).toBeDefined();
  });

  it('does NOT resolve u.save() to Repo#save (negative disambiguation)', () => {
    const calls = getRelationships(result, 'CALLS');
    const wrongSave = calls.find(
      (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.py'),
    );
    expect(wrongSave).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// Chained method calls: svc.get_user().save()
// Tests that Python's scanner correctly handles method-call chains where
// the intermediate receiver type is inferred from the return type annotation.
// ---------------------------------------------------------------------------

describe('Python chained method call resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-chain-call'), () => {});
  }, 60000);

  it('detects User, Repo, and UserService classes', () => {
    const classes = getNodesByLabel(result, 'Class');
    expect(classes).toContain('User');
    expect(classes).toContain('Repo');
    expect(classes).toContain('UserService');
  });

  it('detects get_user and save functions', () => {
    const allSymbols = [
      ...getNodesByLabel(result, 'Function'),
      ...getNodesByLabel(result, 'Method'),
    ];
    expect(allSymbols).toContain('get_user');
    expect(allSymbols).toContain('save');
  });

  it('resolves svc.get_user().save() to User#save via chain resolution', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) =>
        c.target === 'save' && c.source === 'process_user' && c.targetFilePath?.includes('user.py'),
    );
    expect(userSave).toBeDefined();
  });

  it('does NOT resolve svc.get_user().save() to Repo#save', () => {
    const calls = getRelationships(result, 'CALLS');
    const repoSave = calls.find(
      (c) =>
        c.target === 'save' && c.source === 'process_user' && c.targetFilePath?.includes('repo.py'),
    );
    expect(repoSave).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// for key, user in data.items() — dict.items() call iterable + tuple unpacking
// ---------------------------------------------------------------------------

describe('Python dict.items() for-loop resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-dict-items-loop'), () => {});
  }, 60000);

  it('detects User class with save method', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
  });

  it('resolves user.save() via dict.items() loop to User#save', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.py'),
    );
    expect(userSave).toBeDefined();
  });

  it('does NOT resolve user.save() to Repo#save (negative)', () => {
    const calls = getRelationships(result, 'CALLS');
    const wrongSave = calls.find(
      (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.py'),
    );
    expect(wrongSave).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// self.users member access iterable: for user in self.users
// ---------------------------------------------------------------------------

describe('Python member access iterable for-loop', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-member-access-for-loop'),
      () => {},
    );
  }, 60000);

  it('detects User and Repo classes with save methods', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('Repo');
    expect(getNodesByLabel(result, 'Method')).toContain('save');
  });

  it('resolves user.save() via self.users to User#save', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'process_users' &&
        c.targetFilePath?.includes('user.py'),
    );
    expect(userSave).toBeDefined();
  });

  it('does NOT cross-resolve user.save() to Repo#save', () => {
    const calls = getRelationships(result, 'CALLS');
    const wrong = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'process_users' &&
        c.targetFilePath?.includes('repo.py'),
    );
    expect(wrong).toBeUndefined();
  });

  it('resolves repo.save() via self.repos to Repo#save', () => {
    const calls = getRelationships(result, 'CALLS');
    const repoSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'process_repos' &&
        c.targetFilePath?.includes('repo.py'),
    );
    expect(repoSave).toBeDefined();
  });
});

// ---------------------------------------------------------------------------
// Python for-loop with call_expression iterable: for user in get_users()
// Phase 7.3: call_expression iterable resolution via ReturnTypeLookup
// ---------------------------------------------------------------------------

describe('Python for-loop call_expression iterable resolution (Phase 7.3)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-for-call-expr'), () => {});
  }, 60000);

  it('detects User and Repo classes with competing save methods', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Class')).toContain('Repo');
  });

  it('resolves user.save() in for-loop over get_users() to User#save', () => {
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'process_users' &&
        c.targetFilePath?.includes('models.py'),
    );
    expect(userSave).toBeDefined();
  });

  it('resolves repo.save() in for-loop over get_repos() to Repo#save', () => {
    const calls = getRelationships(result, 'CALLS');
    const repoSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'process_repos' &&
        c.targetFilePath?.includes('models.py'),
    );
    expect(repoSave).toBeDefined();
  });

  it('process_users resolves exactly one save call (no cross-binding)', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save' && c.source === 'process_users');
    expect(saveCalls.length).toBe(1);
  });

  it('process_repos resolves exactly one save call (no cross-binding)', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save' && c.source === 'process_repos');
    expect(saveCalls.length).toBe(1);
  });
});

// ---------------------------------------------------------------------------
// enumerate() for-loop: for i, k, v in enumerate(d.items())
// ---------------------------------------------------------------------------

describe('Python enumerate() for-loop resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-enumerate-loop'), () => {});
  }, 60000);

  it('detects User class with save method', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
  });

  it('resolves v.save() in enumerate(users.items()) loop to User#save', () => {
    // for i, k, v in enumerate(users.items()): v.save()
    // v must bind to User (value type of dict[str, User]).
    // Without enumerate() support, v is unbound → resolver emits 0 CALLS.
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'process_users' &&
        c.targetFilePath?.includes('user.py'),
    );
    expect(userSave).toBeDefined();
  });

  it('does NOT resolve v.save() to a non-User target', () => {
    // i is the int index from enumerate — must not produce a spurious CALLS edge
    const calls = getRelationships(result, 'CALLS');
    const wrongSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'process_users' &&
        !c.targetFilePath?.includes('user.py'),
    );
    expect(wrongSave).toBeUndefined();
  });

  it('resolves nested tuple pattern: for i, (k, v) in enumerate(d.items())', () => {
    // Nested tuple_pattern inside pattern_list — must descend to find v
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'process_nested_tuple' &&
        c.targetFilePath?.includes('user.py'),
    );
    expect(userSave).toBeDefined();
  });

  it('resolves parenthesized tuple: for (i, u) in enumerate(users)', () => {
    // tuple_pattern as top-level left node (not pattern_list)
    const calls = getRelationships(result, 'CALLS');
    const userSave = calls.find(
      (c) =>
        c.target === 'save' &&
        c.source === 'process_parenthesized_tuple' &&
        c.targetFilePath?.includes('user.py'),
    );
    expect(userSave).toBeDefined();
  });
});

// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution — annotated attribute capture
// ---------------------------------------------------------------------------

describe('Field type resolution (Python)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-field-types'), () => {});
  }, 60000);

  it('detects classes: Address, User', () => {
    expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
  });

  it('detects Property nodes for Python annotated attributes', () => {
    const properties = getNodesByLabel(result, 'Property');
    expect(properties).toContain('address');
    expect(properties).toContain('name');
    expect(properties).toContain('city');
  });

  it('emits HAS_PROPERTY edges linking attributes to classes', () => {
    const propEdges = getRelationships(result, 'HAS_PROPERTY');
    expect(propEdges.length).toBe(3);
    expect(edgeSet(propEdges)).toContain('User → address');
    expect(edgeSet(propEdges)).toContain('User → name');
    expect(edgeSet(propEdges)).toContain('Address → city');
  });

  it('resolves user.address.save() → Address#save via field type', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((e) => e.target === 'save');
    const addressSave = saveCalls.find(
      (e) => e.source === 'process_user' && e.targetFilePath.includes('models'),
    );
    expect(addressSave).toBeDefined();
  });

  it('populates field metadata (visibility, isStatic, isReadonly) on Property nodes', () => {
    const properties = getNodesByLabelFull(result, 'Property');

    const city = properties.find((p) => p.name === 'city');
    expect(city).toBeDefined();
    expect(city!.properties.visibility).toBe('public');
    expect(city!.properties.isStatic).toBe(false);
    expect(city!.properties.isReadonly).toBe(false);
    expect(city!.properties.declaredType).toBe('str');

    const addr = properties.find((p) => p.name === 'address');
    expect(addr).toBeDefined();
    expect(addr!.properties.visibility).toBe('public');
    expect(addr!.properties.isStatic).toBe(false);
    expect(addr!.properties.declaredType).toBe('Address');
  });
});

// ---------------------------------------------------------------------------
// Phase 8: Field type disambiguation — both User and Address have save()
// ---------------------------------------------------------------------------

describe('Field type disambiguation (Python)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-field-type-disambig'), () => {});
  }, 60000);

  it('detects both User#save and Address#save', () => {
    const methods = getNodesByLabel(result, 'Method');
    const saveMethods = methods.filter((m) => m === 'save');
    expect(saveMethods.length).toBe(2);
  });

  it('resolves user.address.save() → Address#save (not User#save)', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'process_user');
    expect(saveCalls.length).toBe(1);
    expect(saveCalls[0].targetFilePath).toContain('address');
    expect(saveCalls[0].targetFilePath).not.toContain('user');
  });
});

// ---------------------------------------------------------------------------
// ACCESSES write edges from assignment expressions
// ---------------------------------------------------------------------------

describe('Write access tracking (Python)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-write-access'), () => {});
  }, 60000);

  it('emits ACCESSES write edges for attribute assignments', () => {
    const accesses = getRelationships(result, 'ACCESSES');
    const writes = accesses.filter((e) => e.rel.reason === 'write');
    expect(writes.length).toBe(2);
    const nameWrite = writes.find((e) => e.target === 'name');
    const addressWrite = writes.find((e) => e.target === 'address');
    expect(nameWrite).toBeDefined();
    expect(nameWrite!.source).toBe('update_user');
    expect(addressWrite).toBeDefined();
    expect(addressWrite!.source).toBe('update_user');
  });
});

// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): user = get_user(); user.save()
// ---------------------------------------------------------------------------

describe('Python call-result variable binding (Tier 2b)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-call-result-binding'), () => {});
  }, 60000);

  it('resolves user.save() to User#save via call-result binding', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find(
      (c) =>
        c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models'),
    );
    expect(saveCall).toBeDefined();
  });
});

// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): get_user() → .get_city() → .save()
// ---------------------------------------------------------------------------

describe('Python method chain binding via unified fixpoint (Phase 9C)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-method-chain-binding'),
      () => {},
    );
  }, 60000);

  it('resolves city.save() to City#save via method chain', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find(
      (c) =>
        c.target === 'save' && c.source === 'process_chain' && c.targetFilePath.includes('models'),
    );
    expect(saveCall).toBeDefined();
  });
});

// ---------------------------------------------------------------------------
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
// greet() is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
// ---------------------------------------------------------------------------

describe('Python grandparent method resolution via MRO (Phase B)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-grandparent-resolution'),
      () => {},
    );
  }, 60000);

  it('detects A, B, C, Greeting classes', () => {
    const classes = getNodesByLabel(result, 'Class');
    expect(classes).toContain('A');
    expect(classes).toContain('B');
    expect(classes).toContain('C');
    expect(classes).toContain('Greeting');
  });

  it('emits EXTENDS edges: B→A, C→B', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    expect(edgeSet(extends_)).toContain('B → A');
    expect(edgeSet(extends_)).toContain('C → B');
  });

  it('resolves c.greet().save() to Greeting#save via depth-2 MRO lookup', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find(
      (c) => c.target === 'save' && c.targetFilePath.includes('greeting'),
    );
    expect(saveCall).toBeDefined();
  });

  it('resolves c.greet() to A#greet (method found via MRO walk)', () => {
    const calls = getRelationships(result, 'CALLS');
    const greetCall = calls.find((c) => c.target === 'greet' && c.targetFilePath.includes('a.py'));
    expect(greetCall).toBeDefined();
  });
});

// ── Phase P: Default Parameter Arity Resolution ──────────────────────────

describe('Python default parameter arity resolution', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-default-params'), () => {});
  }, 60000);

  it('resolves greet("alice") with 1 arg to greet with 2 params (1 default)', () => {
    const calls = getRelationships(result, 'CALLS');
    const greetCalls = calls.filter((c) => c.source === 'process' && c.target === 'greet');
    expect(greetCalls.length).toBe(1);
  });

  it('resolves search("test") with 1 arg to search with 2 params (1 default)', () => {
    const calls = getRelationships(result, 'CALLS');
    const searchCalls = calls.filter((c) => c.source === 'process' && c.target === 'search');
    expect(searchCalls.length).toBe(1);
  });
});

// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation
// models.py exports get_user() -> User
// app.py imports get_user, calls u = get_user(); u.save(); u.get_name()
// → u is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------

describe('Python cross-file binding propagation', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'py-cross-file'), () => {});
  }, 60000);

  it('detects User class with save and get_name methods', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('User');
    expect(getNodesByLabel(result, 'Method')).toEqual(expect.arrayContaining(['save', 'get_name']));
  });

  it('detects get_user and run functions', () => {
    expect(getNodesByLabel(result, 'Function')).toContain('get_user');
    expect(getNodesByLabel(result, 'Function')).toContain('run');
  });

  it('emits IMPORTS edge from app.py to models.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const edge = imports.find(
      (e) => e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'),
    );
    expect(edge).toBeDefined();
  });

  it('resolves u.save() in run() to User#save via cross-file return type propagation', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find(
      (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('models'),
    );
    expect(saveCall).toBeDefined();
  });

  it('resolves u.get_name() in run() to User#get_name via cross-file return type propagation', () => {
    const calls = getRelationships(result, 'CALLS');
    const getNameCall = calls.find(
      (c) => c.target === 'get_name' && c.source === 'run' && c.targetFilePath.includes('models'),
    );
    expect(getNameCall).toBeDefined();
  });

  it('emits HAS_METHOD edges linking save and get_name to User', () => {
    const hasMethod = getRelationships(result, 'HAS_METHOD');
    const saveEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
    const getNameEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'get_name');
    expect(saveEdge).toBeDefined();
    expect(getNameEdge).toBeDefined();
  });
});

// ---------------------------------------------------------------------------
// Module import: `import models; models.User()` should produce CALLS edges
// even when multiple imported modules export a class with the same name.
// Python's `import models` is a namespace import — moduleAliasMap maps the
// module alias to its source file, enabling scope-resolution to disambiguate
// `models.User()` from `auth.User()` when both modules export `User`.
// ---------------------------------------------------------------------------

describe('Python module import CALLS resolution (Issue #337)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-module-import'), () => {});
  }, 60000);

  // ── Node detection ──────────────────────────────────────────────────

  it('detects exactly 3 Class nodes: User (×2) and Admin (×1)', () => {
    const classes = getNodesByLabel(result, 'Class');
    expect(classes.length).toBe(3);
    expect(classes.filter((c) => c === 'User').length).toBe(2);
    expect(classes.filter((c) => c === 'Admin').length).toBe(1);
  });

  it('detects exactly 3 Method nodes: save, verify, login', () => {
    const methods = getNodesByLabel(result, 'Method');
    expect(methods.length).toBe(3);
    expect(methods).toContain('save');
    expect(methods).toContain('verify');
    expect(methods).toContain('login');
  });

  // ── IMPORTS edges ───────────────────────────────────────────────────

  it('emits exactly 2 IMPORTS edges from app.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const appImports = imports.filter((e) => e.sourceFilePath === 'app.py');
    expect(appImports.length).toBe(2);
  });

  it('resolves `import models` IMPORTS edge: app.py → models.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const toModels = imports.find(
      (e) => e.sourceFilePath === 'app.py' && e.targetFilePath === 'models.py',
    );
    expect(toModels).toBeDefined();
  });

  it('resolves `import auth` IMPORTS edge: app.py → auth.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const toAuth = imports.find(
      (e) => e.sourceFilePath === 'app.py' && e.targetFilePath === 'auth.py',
    );
    expect(toAuth).toBeDefined();
  });

  it('no IMPORTS edge from models.py or auth.py (they import nothing)', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const fromModels = imports.filter((e) => e.sourceFilePath === 'models.py');
    const fromAuth = imports.filter((e) => e.sourceFilePath === 'auth.py');
    expect(fromModels.length).toBe(0);
    expect(fromAuth.length).toBe(0);
  });

  // ── CALLS edges: key regression test (Issue #337) ───────────────────

  it('resolves models.User() CALLS edge from app.py to models.py:User', () => {
    const calls = getRelationships(result, 'CALLS');
    const userCall = calls.find(
      (c) =>
        c.target === 'User' && c.targetFilePath === 'models.py' && c.sourceFilePath === 'app.py',
    );
    expect(userCall).toBeDefined();
  });

  it('resolves auth.Admin() CALLS edge from app.py to auth.py:Admin', () => {
    const calls = getRelationships(result, 'CALLS');
    const adminCall = calls.find(
      (c) =>
        c.target === 'Admin' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py',
    );
    expect(adminCall).toBeDefined();
  });

  it('resolves u.save() method call from app.py to models.py:save', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find(
      (c) =>
        c.target === 'save' && c.targetFilePath === 'models.py' && c.sourceFilePath === 'app.py',
    );
    expect(saveCall).toBeDefined();
  });

  it('resolves a.login() method call from app.py to auth.py:login', () => {
    const calls = getRelationships(result, 'CALLS');
    const loginCall = calls.find(
      (c) =>
        c.target === 'login' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py',
    );
    expect(loginCall).toBeDefined();
  });

  // ── Negative tests ──────────────────────────────────────────────────

  it('no CALLS edges originate from models.py or auth.py (they have no callers)', () => {
    const calls = getRelationships(result, 'CALLS');
    const fromModels = calls.filter((c) => c.sourceFilePath === 'models.py');
    const fromAuth = calls.filter((c) => c.sourceFilePath === 'auth.py');
    expect(fromModels.length).toBe(0);
    expect(fromAuth.length).toBe(0);
  });

  it('Admin() does NOT resolve to models.py (Admin only exists in auth.py)', () => {
    const calls = getRelationships(result, 'CALLS');
    const wrongAdmin = calls.find((c) => c.target === 'Admin' && c.targetFilePath === 'models.py');
    expect(wrongAdmin).toBeUndefined();
  });

  it('no EXTENDS edges (no inheritance in this fixture)', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    expect(extends_.length).toBe(0);
  });

  // ── Same-name cross-module disambiguation ───────────────────────────

  it('resolves auth.User() CALLS edge to auth.py:User (not models.py:User)', () => {
    // Both models.py and auth.py export User. moduleAliasMap maps
    // receiverName='auth' → auth.py for correct disambiguation.
    const calls = getRelationships(result, 'CALLS');
    const authUserCall = calls.find(
      (c) => c.target === 'User' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py',
    );
    expect(authUserCall).toBeDefined();
  });

  it('models.User() and auth.User() resolve to DIFFERENT files', () => {
    const calls = getRelationships(result, 'CALLS');
    const userCalls = calls.filter((c) => c.target === 'User' && c.sourceFilePath === 'app.py');
    expect(userCalls.length).toBe(2);
    const targetFiles = new Set(userCalls.map((c) => c.targetFilePath));
    expect(targetFiles.size).toBe(2);
    expect(targetFiles).toContain('models.py');
    expect(targetFiles).toContain('auth.py');
  });

  it('v.verify() resolves to auth.py:verify (via auth.User() constructor inference)', () => {
    const calls = getRelationships(result, 'CALLS');
    const verifyCall = calls.find(
      (c) =>
        c.target === 'verify' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py',
    );
    expect(verifyCall).toBeDefined();
  });

  // ── HAS_METHOD edges ────────────────────────────────────────────────

  it('emits HAS_METHOD edges linking methods to their classes', () => {
    const hasMethod = getRelationships(result, 'HAS_METHOD');
    // models.py: User → save
    const modelsUserSave = hasMethod.find(
      (e) => e.source === 'User' && e.target === 'save' && e.sourceFilePath === 'models.py',
    );
    expect(modelsUserSave).toBeDefined();
    // auth.py: User → verify, Admin → login
    const authUserVerify = hasMethod.find(
      (e) => e.source === 'User' && e.target === 'verify' && e.sourceFilePath === 'auth.py',
    );
    const authAdminLogin = hasMethod.find(
      (e) => e.source === 'Admin' && e.target === 'login' && e.sourceFilePath === 'auth.py',
    );
    expect(authUserVerify).toBeDefined();
    expect(authAdminLogin).toBeDefined();
  });
});

// ---------------------------------------------------------------------------
// External dotted imports: framework modules like django.apps must not resolve
// to unrelated local basename matches such as accounts/apps.py or config/urls.py.
// ---------------------------------------------------------------------------

describe('Python external dotted imports do not self-resolve to local files', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-django-app-imports'), () => {});
  }, 60000);

  it('keeps the real local cross-app import: billing/models.py -> accounts/models.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const localImport = imports.find(
      (e) => e.sourceFilePath === 'billing/models.py' && e.targetFilePath === 'accounts/models.py',
    );
    expect(localImport).toBeDefined();
  });

  it('does not resolve django.apps in app configs to local apps.py files', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const appConfigImports = imports.filter((e) => e.sourceFilePath.endsWith('/apps.py'));
    expect(appConfigImports.length).toBe(0);
  });

  it('does not resolve django.urls in config/urls.py to config/urls.py', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const urlsImport = imports.find(
      (e) => e.sourceFilePath === 'config/urls.py' && e.targetFilePath === 'config/urls.py',
    );
    expect(urlsImport).toBeUndefined();
  });

  it('does not resolve django.core.asgi or django.core.wsgi to local config modules', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const asgiImport = imports.find(
      (e) => e.sourceFilePath === 'config/asgi.py' && e.targetFilePath === 'config/asgi.py',
    );
    const wsgiImport = imports.find(
      (e) => e.sourceFilePath === 'config/wsgi.py' && e.targetFilePath === 'config/wsgi.py',
    );

    expect(asgiImport).toBeUndefined();
    expect(wsgiImport).toBeUndefined();
  });

  it('does not resolve other django.* imports to local same-basename files', () => {
    const imports = getRelationships(result, 'IMPORTS');
    const wrongTargets = new Set(['config/asgi.py', 'config/wsgi.py', 'config/urls.py']);
    const misresolvedFrameworkImports = imports.filter((e) => wrongTargets.has(e.targetFilePath));
    expect(misresolvedFrameworkImports.length).toBe(0);
  });
});

// ---------------------------------------------------------------------------
// Phase 16: Method enrichment (isAbstract, parameterTypes, static methods)
// models.py: Animal(ABC) with @abstractmethod speak, @staticmethod classify, breathe
// Dog(Animal) overrides speak
// app.py: dog.speak(), Dog.classify("dog")
// ---------------------------------------------------------------------------

describe('Python method enrichment', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-method-enrichment'), () => {});
  }, 60000);

  it('detects Animal and Dog classes', () => {
    const classes = getNodesByLabel(result, 'Class');
    expect(classes).toContain('Animal');
    expect(classes).toContain('Dog');
  });

  it('emits HAS_METHOD edges for Animal methods', () => {
    const hasMethod = getRelationships(result, 'HAS_METHOD');
    const animalMethods = hasMethod
      .filter((e) => e.source === 'Animal')
      .map((e) => e.target)
      .sort();
    expect(animalMethods).toContain('speak');
    expect(animalMethods).toContain('classify');
    expect(animalMethods).toContain('breathe');
  });

  it('emits HAS_METHOD edge for Dog.speak', () => {
    const hasMethod = getRelationships(result, 'HAS_METHOD');
    const dogSpeak = hasMethod.find((e) => e.source === 'Dog' && e.target === 'speak');
    expect(dogSpeak).toBeDefined();
  });

  it('emits EXTENDS edge Dog -> Animal', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    const dogExtends = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal');
    expect(dogExtends).toBeDefined();
  });

  it('marks @abstractmethod speak as isAbstract (conditional)', () => {
    const methods = getNodesByLabelFull(result, 'Function');
    const speak = methods.find((n) => n.name === 'speak' && n.properties.filePath === 'models.py');
    if (speak?.properties.isAbstract !== undefined) {
      expect(speak.properties.isAbstract).toBe(true);
    }
  });

  it('marks breathe as NOT isAbstract (conditional)', () => {
    const methods = getNodesByLabelFull(result, 'Function');
    const breathe = methods.find((n) => n.name === 'breathe');
    if (breathe?.properties.isAbstract !== undefined) {
      expect(breathe.properties.isAbstract).toBe(false);
    }
  });

  it('populates parameterTypes for classify (conditional)', () => {
    const methods = getNodesByLabelFull(result, 'Function');
    const classify = methods.find((n) => n.name === 'classify');
    if (classify?.properties.parameterTypes !== undefined) {
      const params = classify.properties.parameterTypes;
      expect(params).toContain('str');
    }
  });

  it('resolves dog.speak() CALLS edge', () => {
    const calls = getRelationships(result, 'CALLS');
    const speakCall = calls.find((c) => c.target === 'speak' && c.sourceFilePath === 'app.py');
    expect(speakCall).toBeDefined();
  });

  it('resolves Dog.classify("dog") static CALLS edge', () => {
    const calls = getRelationships(result, 'CALLS');
    const classifyCall = calls.find(
      (c) => c.target === 'classify' && c.sourceFilePath === 'app.py',
    );
    expect(classifyCall).toBeDefined();
  });
});

// ---------------------------------------------------------------------------
// Phase 17: Overload dispatch (similarly-named methods/functions)
// service.py: Formatter.format, Formatter.format_with_prefix,
//             format_text, format_text_with_width
// app.py: calls all four
// ---------------------------------------------------------------------------

describe('Python overload dispatch', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-overload-dispatch'), () => {});
  }, 60000);

  it('detects Formatter class', () => {
    expect(getNodesByLabel(result, 'Class')).toContain('Formatter');
  });

  it('classifies overload methods separately from free functions', () => {
    const fns = getNodesByLabel(result, 'Function');
    expect(fns).toContain('format_text');
    expect(fns).toContain('format_text_with_width');
    expect(fns).toContain('run');
    expect(getNodesByLabel(result, 'Method')).toEqual(
      expect.arrayContaining(['format', 'format_with_prefix']),
    );
  });

  it('emits HAS_METHOD for Formatter.format and Formatter.format_with_prefix', () => {
    const hasMethod = getRelationships(result, 'HAS_METHOD');
    const fmtFormat = hasMethod.find((e) => e.source === 'Formatter' && e.target === 'format');
    const fmtPrefix = hasMethod.find(
      (e) => e.source === 'Formatter' && e.target === 'format_with_prefix',
    );
    expect(fmtFormat).toBeDefined();
    expect(fmtPrefix).toBeDefined();
  });

  it('resolves f.format("hello") to Formatter.format', () => {
    const calls = getRelationships(result, 'CALLS');
    const formatCall = calls.find((c) => c.target === 'format' && c.sourceFilePath === 'app.py');
    expect(formatCall).toBeDefined();
  });

  it('resolves f.format_with_prefix("hello",">>") to Formatter.format_with_prefix', () => {
    const calls = getRelationships(result, 'CALLS');
    const prefixCall = calls.find(
      (c) => c.target === 'format_with_prefix' && c.sourceFilePath === 'app.py',
    );
    expect(prefixCall).toBeDefined();
  });

  it('resolves format_text() top-level call', () => {
    const calls = getRelationships(result, 'CALLS');
    const textCall = calls.find((c) => c.target === 'format_text' && c.sourceFilePath === 'app.py');
    expect(textCall).toBeDefined();
  });

  it('resolves format_text_with_width() top-level call', () => {
    const calls = getRelationships(result, 'CALLS');
    const widthCall = calls.find(
      (c) => c.target === 'format_text_with_width' && c.sourceFilePath === 'app.py',
    );
    expect(widthCall).toBeDefined();
  });

  it('populates parameterTypes for format_with_prefix (conditional)', () => {
    const methods = getNodesByLabelFull(result, 'Function');
    const fwp = methods.find((n) => n.name === 'format_with_prefix');
    if (fwp?.properties.parameterTypes !== undefined) {
      const params = fwp.properties.parameterTypes;
      expect(params).toContain('str');
    }
  });

  it('populates parameterTypes for format_text_with_width (conditional)', () => {
    const fns = getNodesByLabelFull(result, 'Function');
    const ftw = fns.find((n) => n.name === 'format_text_with_width');
    if (ftw?.properties.parameterTypes !== undefined) {
      const params = ftw.properties.parameterTypes;
      expect(params).toContain('str');
      expect(params).toContain('int');
    }
  });
});

// ---------------------------------------------------------------------------
// Phase 18: Abstract dispatch (ABC base + concrete impl + receiver resolution)
// base.py: Repository(ABC) with @abstractmethod find, save
// impl.py: SqlRepository(Repository) implements find, save
// app.py: repo = SqlRepository(); repo.find(42); repo.save(user)
// ---------------------------------------------------------------------------

describe('Python abstract dispatch', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-abstract-dispatch'), () => {});
  }, 60000);

  it('detects Repository and SqlRepository classes', () => {
    const classes = getNodesByLabel(result, 'Class');
    expect(classes).toContain('Repository');
    expect(classes).toContain('SqlRepository');
  });

  it('emits EXTENDS edge SqlRepository -> Repository', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    const edge = extends_.find((e) => e.source === 'SqlRepository' && e.target === 'Repository');
    expect(edge).toBeDefined();
  });

  it('emits HAS_METHOD edges for Repository.find and Repository.save', () => {
    const hasMethod = getRelationships(result, 'HAS_METHOD');
    const repoFind = hasMethod.find((e) => e.source === 'Repository' && e.target === 'find');
    const repoSave = hasMethod.find((e) => e.source === 'Repository' && e.target === 'save');
    expect(repoFind).toBeDefined();
    expect(repoSave).toBeDefined();
  });

  it('emits HAS_METHOD edges for SqlRepository.find and SqlRepository.save', () => {
    const hasMethod = getRelationships(result, 'HAS_METHOD');
    const sqlFind = hasMethod.find((e) => e.source === 'SqlRepository' && e.target === 'find');
    const sqlSave = hasMethod.find((e) => e.source === 'SqlRepository' && e.target === 'save');
    expect(sqlFind).toBeDefined();
    expect(sqlSave).toBeDefined();
  });

  it('marks base Repository.find as isAbstract (conditional)', () => {
    const methods = getNodesByLabelFull(result, 'Function');
    const baseFind = methods.find((n) => n.name === 'find' && n.properties.filePath === 'base.py');
    if (baseFind?.properties.isAbstract !== undefined) {
      expect(baseFind.properties.isAbstract).toBe(true);
    }
  });

  it('marks base Repository.save as isAbstract (conditional)', () => {
    const methods = getNodesByLabelFull(result, 'Function');
    const baseSave = methods.find((n) => n.name === 'save' && n.properties.filePath === 'base.py');
    if (baseSave?.properties.isAbstract !== undefined) {
      expect(baseSave.properties.isAbstract).toBe(true);
    }
  });

  it('marks concrete SqlRepository.find as NOT isAbstract (conditional)', () => {
    const methods = getNodesByLabelFull(result, 'Function');
    const sqlFind = methods.find((n) => n.name === 'find' && n.properties.filePath === 'impl.py');
    if (sqlFind?.properties.isAbstract !== undefined) {
      expect(sqlFind.properties.isAbstract).toBe(false);
    }
  });

  it('resolves repo.find(42) CALLS edge', () => {
    const calls = getRelationships(result, 'CALLS');
    const findCall = calls.find((c) => c.target === 'find' && c.sourceFilePath === 'app.py');
    expect(findCall).toBeDefined();
  });

  it('resolves repo.save(user) CALLS edge', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.target === 'save' && c.sourceFilePath === 'app.py');
    expect(saveCall).toBeDefined();
  });

  it('populates parameterTypes for Repository.find (conditional)', () => {
    const methods = getNodesByLabelFull(result, 'Function');
    const baseFind = methods.find((n) => n.name === 'find' && n.properties.filePath === 'base.py');
    if (baseFind?.properties.parameterTypes !== undefined) {
      const params = baseFind.properties.parameterTypes;
      expect(params).toContain('int');
    }
  });

  it('does not emit METHOD_IMPLEMENTS for abstract-class inheritance (only interface/trait parents)', () => {
    // Python ABC is modelled as a Class with EXTENDS (not Interface with IMPLEMENTS),
    // so the MRO processor does not emit METHOD_IMPLEMENTS edges here.
    const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
    const edges = mi.filter(
      (e) => e.sourceFilePath.includes('impl.py') && e.targetFilePath.includes('base.py'),
    );
    expect(edges.length).toBe(0);
  });
});

// ---------------------------------------------------------------------------
// SM-9: inherited method resolution — child.parent_method() via C3 parent walk
// ---------------------------------------------------------------------------

describe('Python Child extends Parent — inherited method resolution (SM-9)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-child-extends-parent'),
      () => {},
    );
  }, 60000);

  it('detects Parent and Child classes', () => {
    const classes = getNodesByLabel(result, 'Class');
    expect(classes).toContain('Parent');
    expect(classes).toContain('Child');
  });

  it('emits EXTENDS edge: Child → Parent', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    expect(edgeSet(extends_)).toContain('Child → Parent');
  });

  it('resolves c.parent_method() to Parent.parent_method via C3 MRO walk', () => {
    const calls = getRelationships(result, 'CALLS');
    const parentMethodCall = calls.find(
      (c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.py'),
    );
    expect(parentMethodCall).toBeDefined();
    expect(parentMethodCall!.source).toBe('run');
  });
});

describe('Python Grandchild→Child→Parent — 3-level C3 MRO walk (SM-11)', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(path.join(FIXTURES, 'python-multi-level-mro'), () => {});
  }, 60000);

  it('detects Grandparent, Parent, and Child classes', () => {
    const classes = getNodesByLabel(result, 'Class');
    expect(classes).toContain('Grandparent');
    expect(classes).toContain('Parent');
    expect(classes).toContain('Child');
  });

  it('emits EXTENDS chain: Child → Parent, Parent → Grandparent', () => {
    const extends_ = getRelationships(result, 'EXTENDS');
    expect(edgeSet(extends_)).toContain('Child → Parent');
    expect(edgeSet(extends_)).toContain('Parent → Grandparent');
  });

  it('resolves c.gp_method() to Grandparent.gp_method via 3-level C3 walk', () => {
    const calls = getRelationships(result, 'CALLS');
    const gpCall = calls.find(
      (c) => c.target === 'gp_method' && c.targetFilePath.includes('grandparent.py'),
    );
    expect(gpCall).toBeDefined();
    expect(gpCall!.source).toBe('run');
  });
});

// ---------------------------------------------------------------------------
// Same-file method-name collision across classes
// PR #980 review feedback — without a qualified-name key in the node lookup,
// User.save and Document.save share the bucket `models.py::save`, so every
// d.save() CALLS edge silently resolves to the first save() seen.
// ---------------------------------------------------------------------------

describe('Python same-file method-name collision across classes', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-same-file-method-collision'),
      () => {},
    );
  }, 60000);

  it('u.save() resolves to User.save, not Document.save', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save');
    const fromUseUser = saveCalls.find((c) => c.source === 'use_user');
    expect(fromUseUser).toBeDefined();
    // targetId encodes qualifier: Method:models.py:User.save#0
    expect(fromUseUser!.rel.targetId).toContain('User.save');
    expect(fromUseUser!.rel.targetId).not.toContain('Document.save');
  });

  it('d.save() resolves to Document.save, not User.save', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save');
    const fromUseDoc = saveCalls.find((c) => c.source === 'use_document');
    expect(fromUseDoc).toBeDefined();
    expect(fromUseDoc!.rel.targetId).toContain('Document.save');
    expect(fromUseDoc!.rel.targetId).not.toContain('User.save');
  });

  it('exactly two CALLS edges to save() — one per class, no duplication to wrong target', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save');
    expect(saveCalls).toHaveLength(2);
    const targets = saveCalls.map((c) => c.rel.targetId).sort();
    expect(targets[0]).toContain('Document.save');
    expect(targets[1]).toContain('User.save');
  });
});

// ---------------------------------------------------------------------------
// Module export vs class method collision within the same file
// Codex review on PR #980 flagged: buildWorkspaceResolutionIndex feeds
// defsByFileAndName and callablesBySimpleName from parsed.localDefs (every
// def in the file, flat). A class method declared before a top-level
// function with the same simple name wins the file-level export lookup,
// so `mod.save(x)` silently binds to `User.save`.
// ---------------------------------------------------------------------------

describe('Python module export vs method-name collision in same file', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-module-export-vs-method-collision'),
      () => {},
    );
  }, 60000);

  it('mod.save(x) resolves to the module-level Function, not User.save', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save');
    const fromModuleExport = saveCalls.find((c) => c.source === 'use_module_export');
    expect(fromModuleExport).toBeDefined();
    // Target must be the top-level Function save, not the User.save Method.
    // Node id format: `Function:mod.py:save` vs `Method:mod.py:User.save#0`.
    expect(fromModuleExport!.rel.targetId).toContain('Function:');
    expect(fromModuleExport!.rel.targetId).toContain('mod.py:save');
    expect(fromModuleExport!.rel.targetId).not.toContain('User.save');
  });

  it('u.save() resolves to User.save Method via typed receiver', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save');
    const fromMethod = saveCalls.find((c) => c.source === 'use_method');
    expect(fromMethod).toBeDefined();
    expect(fromMethod!.rel.targetId).toContain('User.save');
  });

  it('exactly two CALLS edges to save — one to the free function, one to the method', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCalls = calls.filter((c) => c.target === 'save');
    expect(saveCalls).toHaveLength(2);
    const targetIds = saveCalls.map((c) => c.rel.targetId).sort();
    // One Function target, one Method target. Exact shape pins the fix.
    const hasFunctionTarget = targetIds.some(
      (id) => id.startsWith('Function:') && !id.includes('User.save'),
    );
    const hasMethodTarget = targetIds.some((id) => id.includes('User.save'));
    expect(hasFunctionTarget).toBe(true);
    expect(hasMethodTarget).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// Class-body attribute leak into module export index
// Codex round-2 review on PR #980: defsByFileAndName indexes ALL defs
// owned by every child scope of the module, including class-body defs
// (e.g. `User.MAX_USERS`). `mod.MAX_USERS` / `from mod import MAX_USERS`
// can silently bind to a class attribute that's not a module export.
// ---------------------------------------------------------------------------

describe('Python class-body attribute does NOT leak into module export index', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-class-attr-export-leak'),
      () => {},
    );
  }, 60000);

  it('mod.MAX_USERS does not resolve to User.MAX_USERS as a module export', () => {
    // Any edge sourced from `use_class_attr` must NOT target a node
    // that represents `User.MAX_USERS`. Under the bug, CALLS/USES/
    // ACCESSES could silently bind to the class attribute.
    const edges = [
      ...getRelationships(result, 'CALLS'),
      ...getRelationships(result, 'USES'),
      ...getRelationships(result, 'ACCESSES'),
    ];
    const fromConsumer = edges.filter((e) => e.source === 'use_class_attr');
    for (const edge of fromConsumer) {
      expect(edge.rel.targetId).not.toContain('User.MAX_USERS');
    }
  });

  it('mod.helper() still resolves to the top-level Function (happy-path guard)', () => {
    // Regression guard: the narrowing fix must not drop legitimate
    // top-level function exports. Without this, the fix would over-
    // narrow and break normal `mod.helper()` calls.
    const calls = getRelationships(result, 'CALLS');
    const helperCall = calls.find((c) => c.source === 'use_helper' && c.target === 'helper');
    expect(helperCall).toBeDefined();
    expect(helperCall!.rel.targetId).toContain('mod.py:helper');
  });
});

// ---------------------------------------------------------------------------
// Function-local import + cross-file return-type propagation
// Codex round-2 flagged this as potentially broken, but empirically the
// finalize-algorithm hoists the `from svc import get_user` binding to
// the app.py module scope (observed via indexes.bindings dump), so
// `propagateImportedReturnTypes`'s module-scope pass already handles
// it. These assertions pin that working behavior as a regression
// guard against any future change to binding-scope routing.
// ---------------------------------------------------------------------------

describe('Python function-local import feeds chained receiver-bound call', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-function-local-import-chain'),
      () => {},
    );
  }, 60000);

  it('emits CALLS edge do_work -> get_user (free call, baseline sanity)', () => {
    const calls = getRelationships(result, 'CALLS');
    const getUserCall = calls.find((c) => c.source === 'do_work' && c.target === 'get_user');
    expect(getUserCall).toBeDefined();
    expect(getUserCall!.rel.targetId).toContain('svc.py:get_user');
  });

  it('emits CALLS edge do_work -> User.save via function-local-scoped import return-type', () => {
    const calls = getRelationships(result, 'CALLS');
    const saveCall = calls.find((c) => c.source === 'do_work' && c.target === 'save');
    expect(saveCall).toBeDefined();
    // Target must be the User.save Method in svc.py.
    expect(saveCall!.rel.targetId).toContain('User.save');
  });
});

// ---------------------------------------------------------------------------
// Function-local namespace import: `def f(): import svc as s; s.call()`
// Codex round-3 flagged this pattern as potentially broken because
// collectNamespaceTargets reads only module-scope imports. Empirically
// the edge IS emitted (finalize hoists ImportEdges onto the module
// scope), so these assertions pin the working behavior. If finalize
// routing ever changes to match pythonImportOwningScope's per-scope
// contract, this block will flip red and signal the need to make
// collectNamespaceTargets scope-chain-aware.
// ---------------------------------------------------------------------------

describe('Python function-local namespace import feeds receiver-bound call', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-function-local-namespace-import'),
      () => {},
    );
  }, 60000);

  it('emits CALLS edge outer -> svc.call via function-local `import svc as s`', () => {
    const calls = getRelationships(result, 'CALLS');
    const callEdge = calls.find((c) => c.source === 'outer' && c.target === 'call');
    expect(callEdge).toBeDefined();
    expect(callEdge!.rel.targetId).toContain('svc.py:call');
  });

  it('sanity: unrelated function without local import is still parsed as a Function node', () => {
    const fns = result.graph.nodes.filter(
      (n) => n.label === 'Function' && n.properties.name === 'sanity',
    );
    expect(fns).toHaveLength(1);
  });
});

// ---------------------------------------------------------------------------
// Class-body namespace import: `class A: import mod; def use(): mod.helper()`
// Same theoretical concern as the function-local case above, same
// empirical outcome — finalize hoists the ImportEdge to the module
// scope so the namespace-receiver path finds it from inside A.use.
// These assertions pin that working behavior.
// ---------------------------------------------------------------------------

describe('Python class-body namespace import feeds method receiver-bound call', () => {
  let result: PipelineResult;

  beforeAll(async () => {
    result = await runPipelineFromRepo(
      path.join(FIXTURES, 'python-class-body-namespace-import'),
      () => {},
    );
  }, 60000);

  it('emits CALLS edge A.use -> mod.helper via class-body `import mod`', () => {
    const calls = getRelationships(result, 'CALLS');
    const callEdge = calls.find((c) => c.source === 'use' && c.target === 'helper');
    expect(callEdge).toBeDefined();
    expect(callEdge!.rel.targetId).toContain('mod.py:helper');
  });
});

// ---------------------------------------------------------------------------
// Issue #1066 sibling regression for Python: force worker-mode extraction so
// scope-resolution reparses on cache miss, then assert large ASCII and
// UTF-8-heavy source files still produce trailing call edges.
// ---------------------------------------------------------------------------

describe('Python large-file cache-miss parser buffer regression', () => {
  let repoDir: string;
  let result: PipelineResult;

  beforeAll(async () => {
    repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-python-large-cache-'));
    writeFixtureRepo(repoDir, {
      'models.py': `
class User:
    def save(self):
        return True
`,
      'ascii_app.py': `from models import User

# ${'x'.repeat(120 * 1024)}
def create_ascii_user():
    user = User()
    user.save()
`,
      'utf8_app.py': `from models import User

# ${'漢'.repeat(120_000)}
def create_utf8_user():
    user = User()
    user.save()
`,
    });
    result = await runPipelineFromRepo(repoDir, () => {}, {});
  }, 120000);

  afterAll(() => {
    if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
  });

  it('extracts trailing functions after large ASCII and UTF-8 padding', () => {
    expect(getNodesByLabel(result, 'Function')).toEqual(
      expect.arrayContaining(['create_ascii_user', 'create_utf8_user']),
    );
  });

  it('resolves calls from both padded files to User.save', () => {
    const calls = getRelationships(result, 'CALLS');
    for (const source of ['create_ascii_user', 'create_utf8_user']) {
      const save = calls.find((c) => c.source === source && c.target === 'save');
      expect(save).toBeDefined();
      expect(save!.targetFilePath).toBe('models.py');
    }
  });
});
