fix(resolution): account for writes to a Python module global from other files (#2074)

The module-global receiver type was read from the global's own module only,
so `settings.conn = Decoy()` in another module, or `globals()["conn"] = X`,
left a stale type and a wrong edge.

- A production write `<module>.<name> = Cls(...)` from another file joins
  the type set (resolved in the writing file); any other production write
  (another value, a tuple target, `setattr`/`patch.object`) makes the type
  unknown. The module is matched through each file's imports, including
  aliases and relative imports.
- Test files install doubles (`settings.conn = MagicMock()`,
  `monkeypatch.setattr(settings, "conn", ...)`): they do not change the
  production type, but a ref inside such a test resolves nothing.
- `globals()[...] = ...` with the global's name or a computed key, and
  `globals().update(...)`, in the global's module make the type unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CQubRS1bXvaoApgPyLf66
This commit is contained in:
Josh66
2026-09-28 09:15:07 +00:00
parent 0aab32a18b
commit 2055711226
2 changed files with 129 additions and 11 deletions
+26
View File
@@ -1397,6 +1397,32 @@ def init():
'def cb(pool):\n pool.submit(conn.fetch)\n' }, []],
annotation_over_factory: [{ 'm.py': 'from store import Store\nconn: Store = make_store()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n' },
['cb -> Store::fetch']],
external_production_write: [{ 'settings.py': header,
'reset.py': 'import settings\nfrom decoy import Decoy\n\ndef reset():\n settings.conn = Decoy()\n',
'c.py': 'import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, []],
external_aliased_write: [{ 'pkg/__init__.py': '', 'pkg/settings.py': header,
'reset.py': 'import pkg.settings as cfg\n\ndef reset():\n cfg.conn = make_conn()\n',
'c.py': 'from pkg import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, []],
external_relative_write: [{ 'pkg/__init__.py': '', 'pkg/settings.py': header,
'pkg/reset.py': 'from . import settings\nfrom decoy import Decoy\n\ndef reset():\n settings.conn = Decoy()\n',
'c.py': 'from pkg import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, []],
external_setattr: [{ 'settings.py': header,
'reset.py': 'import settings\nfrom decoy import Decoy\n\ndef reset():\n setattr(settings, "conn", Decoy())\n',
'c.py': 'import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, []],
external_subclass_write: [{ 'settings.py': header,
'sub.py': 'from store import Store\nclass SubStore(Store):\n def fetch(self, ids):\n return ids\n',
'reset.py': 'import settings\nfrom sub import SubStore\n\ndef reset():\n settings.conn = SubStore()\n',
'c.py': 'import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, ['cb -> Store::fetch']],
test_double_write: [{ 'settings.py': header,
'tests/test_c.py': 'import settings\nfrom unittest.mock import MagicMock\n\ndef test_cb(pool):\n settings.conn = MagicMock()\n pool.submit(settings.conn.fetch)\n',
'c.py': 'import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, ['cb -> Store::fetch']],
test_monkeypatch: [{ 'settings.py': header,
'tests/test_c.py': 'import settings\n\ndef test_cb(monkeypatch, pool):\n monkeypatch.setattr(settings, "conn", object())\n pool.submit(settings.conn.fetch)\n' }, []],
globals_literal_write: [{ 'm.py': header + 'def reset():\n globals()["conn"] = Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n' }, []],
globals_dynamic_write: [{ 'm.py': header + 'for name, obj in [("conn", Decoy())]:\n globals()[name] = obj\n\ndef cb(pool):\n pool.submit(conn.fetch)\n' }, []],
unrelated_attribute_write: [{ 'settings.py': header,
'other.py': 'from decoy import Decoy\n\nclass Holder:\n def __init__(self):\n self.conn = Decoy()\n',
'c.py': 'import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, ['cb -> Store::fetch']],
dotted_import_collision: [{ 'alpha/__init__.py': '', 'beta/__init__.py': '',
'alpha/foo.py': 'class Client:\n def fetch(self):\n return 1\n', 'beta/foo.py': 'class Client:\n def fetch(self):\n return 2\n',
'm.py': 'import beta.foo\nimport alpha.foo\nconn = None\n\ndef init():\n global conn\n conn = alpha.foo.Client()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n' }, []],
+103 -11
View File
@@ -9,6 +9,7 @@ import { Language, Node } from '../types';
import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types';
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins';
import { isTestFile } from '../search/query-utils';
/**
* Ceiling on how many same-named definitions a FUZZY name-match strategy will
* score. A name defined more times than this is "ubiquitous" — a method/symbol
@@ -607,45 +608,136 @@ const PYTHON_GLOBAL_CLASSES = new WeakMap<ResolutionContext, Map<string, Node[]
* rebound by `global conn; conn = Backend()`), so its type is the set of
* classes its module assigns to it: at module scope, or in a function that
* declares it `global`. Any other binding of it there (an opaque value, a
* tuple target, `for`/`with`/import, a star import) makes the type unknown
* (null). Not seen: rebinding from ANOTHER module (`settings.conn = X`) and
* dynamic writes (`globals()["conn"] = X`).
* tuple target, `for`/`with`/import, a star import, a `globals()` write)
* makes the type unknown (null). Production writes from other modules
* (`settings.conn = X`) join the set; see pythonExternalWrites.
*/
function pythonGlobalClasses(global: Node, ref: UnresolvedRef, context: ResolutionContext): Node[] | null {
let memo = PYTHON_GLOBAL_CLASSES.get(context);
if (!memo) { memo = new Map(); PYTHON_GLOBAL_CLASSES.set(context, memo); }
if (memo.has(global.id)) return memo.get(global.id)!;
const file = global.filePath;
const types = new Set<string>();
let classes: Node[] | null = [];
for (const b of pythonGlobalBindings(global.name, file, context)) {
const external = pythonExternalWrites(global, context);
// Each type is resolved in the file that wrote it (its imports name the class).
const writes: Array<{ type: string; file: string }> = [...external.writes];
let classes: Node[] | null = external.unknown || pythonDynamicGlobalWrite(global.name, file, context) ? null : [];
for (const b of classes ? pythonGlobalBindings(global.name, file, context) : []) {
if (b.kind !== 'assign') { classes = null; break; }
const constructor = b.value && b.value !== 'None' ? pythonConstructorCall(b.value) : null;
if (b.type) {
// `conn: Base = make()` trusts the annotation; `conn: A = B()` contradicts it.
if (constructor && constructor.split('.').pop() !== b.type.split('.').pop()) { classes = null; break; }
types.add(b.type);
writes.push({ type: b.type, file });
continue;
}
if (b.value === 'None') continue;
if (!constructor) { classes = null; break; }
types.add(constructor);
writes.push({ type: constructor, file });
}
for (const type of classes ? types : []) {
const cls = pythonRefClass(type, { ...ref, filePath: file }, context);
const seen = new Set<string>();
for (const write of classes ? writes : []) {
const cls = pythonRefClass(write.type, { ...ref, filePath: write.file }, context);
if (!cls) { classes = null; break; }
classes!.push(cls);
if (!seen.has(cls.id)) { seen.add(cls.id); classes!.push(cls); }
}
memo.set(global.id, classes);
return classes;
}
/** `a/b/c.py` → `a.b.c`; a package's `__init__.py` → the package. */
function pythonModulePath(filePath: string): string {
return filePath.replace(/\\/g, '/').replace(/\.pyi?$/, '').replace(/\/__init__$/, '').split('/').join('.');
}
/** Names in `filePath` that bind module `module` (dotted; matched as a suffix, so source roots don't matter). */
function pythonModuleAliases(filePath: string, module: string, context: ResolutionContext): string[] {
const names = (dotted: string) => dotted === module || module.endsWith(`.${dotted}`);
const aliases = new Set<string>();
for (const m of context.getImportMappings(filePath, 'python')) {
let source = m.source;
const dots = source.match(/^\.+/)?.[0].length ?? 0;
if (dots) {
// `from . import x` in `pkg/mod.py` → `pkg.x`; one more dot per level up.
const here = pythonModulePath(filePath).split('.');
const pkg = /\/__init__\.pyi?$/.test(filePath.replace(/\\/g, '/')) ? here : here.slice(0, -1);
source = [...pkg.slice(0, pkg.length - (dots - 1)), ...source.slice(dots).split('.').filter(Boolean)].join('.');
}
if (m.isNamespace) {
if (!names(source)) continue;
aliases.add(m.localName);
aliases.add(source); // `import a.b` binds `a`, so the module is spelled `a.b`
} else if (names(source ? `${source}.${m.exportedName}` : m.exportedName)) {
aliases.add(m.localName);
}
}
return [...aliases];
}
/**
* Writes to module global `global` from OTHER files. A production write
* `<module>.<name> = Cls(...)` adds a type; any other production write
* (another value, a tuple target, `setattr`) makes the type unknown. Test
* files install doubles (`settings.conn = MagicMock()`, `monkeypatch.setattr`)
* that do not define the production type; they are recorded as `writers`, and
* a ref inside a writer resolves nothing through the global.
*/
function pythonExternalWrites(global: Node, context: ResolutionContext): { writes: Array<{ type: string; file: string }>; unknown: boolean; writers: Set<string> } {
return pythonNameScan(context, `external\0${global.id}`, () => {
const out = { writes: [] as Array<{ type: string; file: string }>, unknown: false, writers: new Set<string>() };
const module = pythonModulePath(global.filePath);
const name = global.name;
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
for (const file of context.getAllFiles()) {
if (file === global.filePath || !/\.pyi?$/.test(file) || !context.readFile(file)?.includes(name)) continue;
const test = isTestFile(file);
const aliases = pythonModuleAliases(file, module, context);
if (!test && !aliases.length) continue;
const receiver = test ? '[\\w.]+' : `(?:${aliases.map(escape).join('|')})`;
const target = new RegExp(`(?<![\\w.])${receiver}\\.${name}\\b(?!\\s*[.\\[(])`);
const exact = new RegExp(`^${receiver}\\.${name}$`);
const dynamic = new RegExp(`\\b(?:setattr|patch\\.object)\\s*\\(\\s*${receiver}\\s*,\\s*['"]${name}['"]`);
const lines = pythonMemberLines(file, context);
const statements = pythonStatementStarts(file, context);
for (let i = 0; i < lines.length; i++) {
if (!statements[i]) continue;
let statement = lines[i]!;
for (let j = i + 1; j < lines.length && !statements[j]; j++) statement += '\n' + lines[j];
if (!statement.includes(name)) continue;
const assignment = pythonAssignment(statement);
const assigned = assignment?.targets.some(t => target.test(t.trim())) ?? false;
if (!assigned && !dynamic.test(statement)) continue;
if (test) { out.writers.add(file); continue; }
const single = assignment && assignment.targets.length === 1 && !assignment.augmented && exact.test(assignment.targets[0]!.trim());
const value = single ? assignment!.value.trim() : '';
if (value === 'None') continue;
const constructor = value ? pythonConstructorCall(value) : null;
if (!constructor) { out.unknown = true; return out; }
out.writes.push({ type: constructor, file });
}
}
return out;
});
}
/** `globals()[...] = ...` naming the global (or with a computed key), or `globals().update(...)`, in its own module. */
function pythonDynamicGlobalWrite(name: string, filePath: string, context: ResolutionContext): boolean {
return pythonMemberLines(filePath, context).some(line => {
if (/\bglobals\(\)\s*\.\s*update\s*\(/.test(line)) return true;
const key = line.match(/\bglobals\(\)\s*\[\s*([^\]]+?)\s*\]\s*=(?!=)/)?.[1];
if (!key) return false;
const literal = key.match(/^(['"])(\w+)\1$/);
return !literal || literal[2] === name;
});
}
/**
* The method a module global's value can dispatch to: one class resolves to
* its own method; several bind to the nearest declaration they all inherit,
* as a base-typed receiver does. Otherwise, no edge.
*/
function pythonGlobalMembers(global: Node, member: string, ref: UnresolvedRef, context: ResolutionContext): Node[] {
// A test that installs its own double sees the double, not the production type.
if (pythonExternalWrites(global, context).writers.has(ref.filePath)) return [];
const classes = pythonGlobalClasses(global, ref, context);
if (!classes) return [];
const targets = [...new Map(classes.flatMap(cls => pythonMembers(cls, member, ref, context)).map(n => [n.id, n])).values()];