fix(resolution): keep unknown Python import members unresolved (#2040)

Bisect identifies 5eaa6fed (#2034, #1820) as the first bad commit: its Python named-import fallback resolves task.delay() to the task function itself, hiding the queue effect in API steps. Require member resolution before returning a named Python import's target for attribute access.

Keep direct calls, callbacks, constructors, and known members working. Add absolute/relative aliased-import regression coverage without changing the existing steps assertions.

Validation: git bisect with preserved steps test, tsc and copy-assets at every step, CODEGRAPH_KERNEL=0 throughout bisect. 338 tests pass across ui-steps-api-servers, function-ref, rebuilt kernel-tsjs-parity, resolution, and ui-effects. Issue #1820 repro and tsc --noEmit pass.
This commit is contained in:
Colby Mchenry
2026-09-27 14:02:38 +00:00
committed by GitHub
parent 68085f3997
commit 9be3f1a8a2
2 changed files with 57 additions and 0 deletions
+50
View File
@@ -1085,6 +1085,56 @@ def static(pool):
} finally { cg.close(); }
});
it.each(['tasks', '.tasks'])('Python imported members do not fall back to their receiver (%s)', async (module) => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-import-members-'));
fs.writeFileSync(path.join(tmpDir, 'tasks.py'), `from celery import shared_task
@shared_task
def send_welcome(item_id):
return item_id
class Store:
@staticmethod
def fetch():
return 1
settings = None
`);
fs.writeFileSync(path.join(tmpDir, 'main.py'), `from ${module} import send_welcome as welcome, Store as Actual, settings
def direct():
welcome(1)
def callback(pool):
pool.submit(welcome, 1)
def enqueue():
welcome.delay(1)
welcome.apply_async(args=[1])
def member_values(pool):
pool.submit(welcome.delay, 1)
cb = welcome.custom_attribute
return [welcome.apply_async]
def missing_members():
Actual.missing()
settings.missing()
def known_member():
Actual.fetch()
def known_callback(pool):
pool.submit(Actual.fetch)
def construct():
return Actual()
`);
const cg = CodeGraph.initSync(tmpDir);
try {
await cg.indexAll();
const task = cg.getNodesByName('send_welcome').find(n => n.kind === 'function')!;
expect(sourceNames(cg, cg.getIncomingEdges(task.id).filter(e => e.kind === 'calls'))).toEqual(['direct']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'send_welcome'))).toEqual(['callback']);
const missing = cg.getNodesByName('missing_members')[0]!;
expect(cg.getOutgoingEdges(missing.id).filter(e => e.kind === 'calls' || e.kind === 'instantiates')).toEqual([]);
const fetch = cg.getNodesByName('fetch').find(n => n.kind === 'method')!;
expect(sourceNames(cg, cg.getIncomingEdges(fetch.id).filter(e => e.kind === 'calls'))).toEqual(['known_member']);
expect(sourceNames(cg, fnRefEdgesInto(cg, 'fetch'))).toEqual(['known_callback']);
const store = cg.getNodesByName('Store').find(n => n.kind === 'class')!;
expect(sourceNames(cg, cg.getIncomingEdges(store.id).filter(e => e.kind === 'instantiates'))).toEqual(['construct']);
} finally { cg.close(); }
});
it('#1820: same-file ambiguity and noncallable receivers stay unlinked', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-ambiguity-'));
fs.writeFileSync(path.join(tmpDir, 'main.py'), `class A:
+7
View File
@@ -1725,6 +1725,13 @@ export function resolveViaImport(
// constant edge below rather than fabricating a wrong one.
const instanceMember = resolveImportedInstanceMember(targetNode, ref, imp.localName, context);
if (instanceMember) return instanceMember;
// Finding a named Python import proves the receiver exists, not
// its requested attribute. In particular, task.delay() enqueues
// work; it does not call the imported task function directly.
// Keep unknown members unresolved (including callback values)
// instead of falling back to the receiver as their target.
if (ref.language === 'python') return null;
}
return {