ucn 5.4.2 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +18 -12
  6. package/cli/index.js +40 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +30 -0
  9. package/core/analysis.js +508 -264
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +522 -31
  16. package/core/callers.js +12053 -1047
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis.js +18 -2
  38. package/core/output/brief.js +3 -0
  39. package/core/output/doctor.js +4 -0
  40. package/core/output/endpoints.js +15 -6
  41. package/core/output/find.js +7 -2
  42. package/core/output/refactoring.js +121 -14
  43. package/core/output/reporting.js +31 -3
  44. package/core/output/shared.js +14 -0
  45. package/core/output-budget.js +305 -122
  46. package/core/parallel-build.js +124 -47
  47. package/core/parser.js +3 -2
  48. package/core/project.js +457 -60
  49. package/core/provenance-facts.js +156 -12
  50. package/core/provenance-overload.js +30 -3
  51. package/core/provenance.js +104 -30
  52. package/core/pytest-fixtures.js +434 -0
  53. package/core/python-fixture-flow.js +17 -3
  54. package/core/python-modules.js +173 -0
  55. package/core/reflection.js +877 -0
  56. package/core/registry.js +1 -1
  57. package/core/reporting.js +234 -52
  58. package/core/request-helpers.js +561 -0
  59. package/core/route-graph.js +1922 -0
  60. package/core/route-lists.js +2414 -0
  61. package/core/rust-macro-expansion.js +2113 -0
  62. package/core/rust-modules.js +185 -0
  63. package/core/search.js +121 -29
  64. package/core/shared.js +19 -1
  65. package/core/test-name-bindings.js +256 -0
  66. package/core/type-denotation.js +1161 -0
  67. package/core/type-references.js +3229 -0
  68. package/core/verify.js +3360 -672
  69. package/languages/adapter.js +31 -5
  70. package/languages/c-family.js +4061 -212
  71. package/languages/c-macro-invocations.js +380 -0
  72. package/languages/c-preprocessor.js +254 -0
  73. package/languages/csharp.js +1406 -49
  74. package/languages/go.js +1243 -70
  75. package/languages/index.js +728 -6
  76. package/languages/java.js +473 -61
  77. package/languages/javascript.js +1270 -197
  78. package/languages/lexical-scope.js +1280 -0
  79. package/languages/python.js +1557 -72
  80. package/languages/rust-macro-rules.js +1020 -0
  81. package/languages/rust-value-flow.js +416 -0
  82. package/languages/rust.js +2237 -115
  83. package/languages/utils.js +298 -4
  84. package/mcp/server.js +22 -8
  85. package/package.json +1 -1
@@ -0,0 +1,434 @@
1
+ /**
2
+ * core/pytest-fixtures.js - The parameters a pytest fixture is injected
3
+ * into, by pytest's own name resolution (fix #402).
4
+ *
5
+ * pytest passes a fixture to every test function or fixture that names it
6
+ * as a parameter. Which fixture a parameter `name` receives is decided where
7
+ * the requester is declared: a fixture method of its class, then a fixture
8
+ * of its module (a fixture requesting its own name skips itself), then the
9
+ * conftest.py files from the module's directory up to the project root;
10
+ * plugins come after all of them. Renaming a fixture function renames the
11
+ * fixture, so every parameter that receives it is renamed with it (with the
12
+ * uses that bind that parameter), and no other parameter is.
13
+ *
14
+ * What the model cannot decide becomes a review item, never a silent skip
15
+ * or edit: a parameter in a module pytest does not collect by default, a
16
+ * class or module binding of the name that is not a resolvable fixture
17
+ * definition (assignments, star imports, outside or unresolved imports), a
18
+ * class with bases whose fixtures are not visible, and a parametrized
19
+ * argument of the same name. A fixture defined outside conftest.py and test
20
+ * modules reaches its users through plugin registration: its requesters are
21
+ * listed for review.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const path = require('path');
27
+ const { sameNode } = require('../languages/utils');
28
+
29
+ const PY_COMPOUNDS = new Set(['block', 'if_statement', 'elif_clause', 'else_clause', 'try_statement',
30
+ 'except_clause', 'except_group_clause', 'finally_clause', 'with_statement', 'for_statement',
31
+ 'while_statement', 'match_statement', 'case_clause']);
32
+
33
+ const isConftest = file => path.basename(file) === 'conftest.py';
34
+ // pytest's default `python_files` patterns.
35
+ const isTestModule = file => /^test_.*\.py$|^.*_test\.py$/.test(path.basename(file));
36
+ const collected = file => isConftest(file) || isTestModule(file);
37
+
38
+ function pathOf(node) {
39
+ if (node?.type === 'identifier') return [node.text];
40
+ if (node?.type !== 'attribute') return null;
41
+ const root = pathOf(node.childForFieldName('object'));
42
+ const member = node.childForFieldName('attribute');
43
+ return root && member ? [...root, member.text] : null;
44
+ }
45
+
46
+ /** The qualified name a dotted path denotes through the file's imports of an
47
+ * outside module, or null (a project module, a local binding, no import). */
48
+ function outsideQualified(index, file, parts) {
49
+ const entry = index.files.get(file);
50
+ if (!entry || !parts?.length) return null;
51
+ if ((entry.moduleAssignedNames || []).includes(parts[0]) ||
52
+ (index.symbols.get(parts[0]) || []).some(d => d.file === file)) return null;
53
+ const bindings = (entry.importBindings || []).filter(b => (b.alias || b.name) === parts[0]);
54
+ if (bindings.length !== 1) return null;
55
+ const binding = bindings[0];
56
+ if (!binding.module || entry.moduleResolved?.[binding.module]) return null;
57
+ const fromImport = binding.kind === 'from' || binding.kind === 'from-import';
58
+ return fromImport
59
+ ? [binding.module, binding.name, ...parts.slice(1)].join('.')
60
+ : [binding.module, ...parts.slice(1)].join('.');
61
+ }
62
+
63
+ /** pytest decorator kind of one decorator node: fixture | parametrize | other. */
64
+ function decoratorOf(index, file, decorator) {
65
+ const expression = decorator.namedChild(0);
66
+ const callable = expression?.type === 'call' ? expression.childForFieldName('function') : expression;
67
+ const qualified = outsideQualified(index, file, pathOf(callable));
68
+ const args = expression?.type === 'call' ? expression.childForFieldName('arguments') : null;
69
+ if (qualified === 'pytest.fixture') {
70
+ let alias = null;
71
+ let dynamicName = false;
72
+ for (const argument of args?.namedChildren || []) {
73
+ if (argument.type !== 'keyword_argument' || argument.childForFieldName('name')?.text !== 'name') continue;
74
+ const value = argument.childForFieldName('value');
75
+ const content = value?.type === 'string' ? value.namedChildren.find(c => c.type === 'string_content') : null;
76
+ if (content && value.namedChildren.every(c => ['string_start', 'string_content', 'string_end'].includes(c.type))) {
77
+ alias = content.text;
78
+ } else dynamicName = true;
79
+ }
80
+ return { kind: 'fixture', alias, dynamicName };
81
+ }
82
+ if (qualified === 'pytest.mark.parametrize') {
83
+ // argnames: "a, b" / "a,b" / ("a", "b") / ["a", "b"]; anything else
84
+ // is unknown and treated as naming every parameter.
85
+ const first = args?.namedChildren.find(c => c.type !== 'comment' && c.type !== 'keyword_argument') ||
86
+ args?.namedChildren.find(c => c.type === 'keyword_argument' &&
87
+ c.childForFieldName('name')?.text === 'argnames')?.childForFieldName('value');
88
+ const strings = first?.type === 'string' ? [first]
89
+ : ['tuple', 'list'].includes(first?.type) ? first.namedChildren : null;
90
+ if (!strings || strings.some(s => s.type !== 'string')) return { kind: 'parametrize', names: null };
91
+ const names = [];
92
+ for (const s of strings) {
93
+ const content = s.namedChildren.find(c => c.type === 'string_content');
94
+ for (const part of (content?.text || '').split(',')) if (part.trim()) names.push(part.trim());
95
+ }
96
+ return { kind: 'parametrize', names };
97
+ }
98
+ return { kind: qualified ? 'outside' : 'other' };
99
+ }
100
+
101
+ function paramsOf(fn, isMethod) {
102
+ const out = [];
103
+ const params = fn.childForFieldName('parameters');
104
+ let index = 0;
105
+ for (const param of params?.namedChildren || []) {
106
+ const position = index++;
107
+ if (param.type === 'comment') { index--; continue; }
108
+ let nameNode = null;
109
+ let hasDefault = false;
110
+ if (param.type === 'identifier') nameNode = param;
111
+ else if (param.type === 'typed_parameter') nameNode = param.namedChildren.find(c => c.type === 'identifier') || null;
112
+ else if (param.type === 'default_parameter' || param.type === 'typed_default_parameter') {
113
+ nameNode = param.childForFieldName('name');
114
+ hasDefault = true;
115
+ }
116
+ if (!nameNode || nameNode.type !== 'identifier') continue;
117
+ if (isMethod && position === 0) continue; // self / cls
118
+ out.push({ name: nameNode.text, node: nameNode, hasDefault });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Functions a module declares where pytest looks for tests and fixtures:
125
+ * module scope and class bodies (through compound statements), never
126
+ * nested functions. Memoized per file entry.
127
+ */
128
+ const moduleMemo = new WeakMap();
129
+ function moduleFunctions(index, file) {
130
+ const entry = index.files.get(file);
131
+ if (!entry || entry.language !== 'python') return null;
132
+ let memo = moduleMemo.get(entry);
133
+ if (memo) return memo;
134
+ let tree;
135
+ try { tree = index._getParsedTree(file, index._readFile(file), 'python'); } catch { tree = null; }
136
+ if (!tree) return null;
137
+ const functions = [];
138
+ const classes = [];
139
+ // Module-scope bindings of each name that are not plain defs.
140
+ const otherBindings = new Map();
141
+ let starImport = false;
142
+ const noteOther = (name, node, kind) => {
143
+ if (!otherBindings.has(name)) otherBindings.set(name, []);
144
+ otherBindings.get(name).push({ node, kind });
145
+ };
146
+ const addTargets = (target, node) => {
147
+ if (!target) return;
148
+ if (target.type === 'identifier') { noteOther(target.text, node, 'assignment'); return; }
149
+ for (const child of target.namedChildren || []) addTargets(child, node);
150
+ };
151
+ const visit = (container, owner) => {
152
+ for (const child of container.namedChildren) {
153
+ const definition = child.type === 'decorated_definition' ? child.childForFieldName('definition') : child;
154
+ const decorators = child.type === 'decorated_definition'
155
+ ? child.namedChildren.filter(c => c.type === 'decorator') : [];
156
+ if (definition?.type === 'function_definition') {
157
+ const kinds = decorators.map(d => decoratorOf(index, file, d));
158
+ const fixture = kinds.find(k => k.kind === 'fixture');
159
+ functions.push({
160
+ node: definition, outer: child, name: definition.childForFieldName('name')?.text,
161
+ owner, kinds, fixture: fixture || null,
162
+ fixtureName: fixture ? (fixture.dynamicName ? null : fixture.alias || definition.childForFieldName('name')?.text) : null,
163
+ params: paramsOf(definition, !!owner),
164
+ });
165
+ continue;
166
+ }
167
+ if (definition?.type === 'class_definition') {
168
+ const record = {
169
+ node: definition, name: definition.childForFieldName('name')?.text, owner,
170
+ bases: (definition.childForFieldName('superclasses')?.namedChildren || [])
171
+ .filter(c => c.type !== 'keyword_argument' && c.type !== 'comment'),
172
+ kinds: decorators.map(d => decoratorOf(index, file, d)),
173
+ };
174
+ classes.push(record);
175
+ if (!owner) noteOther(record.name, definition, 'class');
176
+ const body = definition.childForFieldName('body');
177
+ if (body) visit(body, record);
178
+ continue;
179
+ }
180
+ if (!owner) {
181
+ if (child.type === 'import_from_statement' || child.type === 'import_statement') {
182
+ if (child.namedChildren.some(c => c.type === 'wildcard_import')) starImport = true;
183
+ const moduleName = child.childForFieldName('module_name');
184
+ for (const part of child.namedChildren) {
185
+ if (moduleName && sameNode(part, moduleName)) continue;
186
+ const local = part.type === 'aliased_import' ? part.childForFieldName('alias')
187
+ : part.type === 'dotted_name' ? part.namedChild(child.type === 'import_statement' ? 0 : part.namedChildCount - 1) : null;
188
+ if (local) noteOther(local.text, child, 'import');
189
+ }
190
+ continue;
191
+ }
192
+ if (child.type === 'expression_statement') {
193
+ for (const expression of child.namedChildren) {
194
+ if (expression.type === 'assignment' || expression.type === 'augmented_assignment') {
195
+ addTargets(expression.childForFieldName('left'), child);
196
+ }
197
+ }
198
+ continue;
199
+ }
200
+ } else if (child.type === 'expression_statement') {
201
+ for (const expression of child.namedChildren) {
202
+ if (expression.type === 'assignment' || expression.type === 'augmented_assignment') {
203
+ const target = expression.childForFieldName('left');
204
+ if (target?.type === 'identifier') {
205
+ (owner.assigned || (owner.assigned = new Set())).add(target.text);
206
+ }
207
+ }
208
+ }
209
+ continue;
210
+ }
211
+ if (PY_COMPOUNDS.has(child.type)) visit(child, owner);
212
+ }
213
+ };
214
+ visit(tree.rootNode, null);
215
+ memo = { tree, functions, classes, otherBindings, starImport };
216
+ moduleMemo.set(entry, memo);
217
+ return memo;
218
+ }
219
+
220
+ /** Whether `fn` asks pytest for fixtures: a fixture, or a test pytest collects. */
221
+ function requestsFixtures(file, fn) {
222
+ if (fn.fixture) return true;
223
+ if (!isTestModule(file) || !fn.name?.startsWith('test')) return false;
224
+ for (let owner = fn.owner; owner; owner = owner.owner) {
225
+ if (!owner.name?.startsWith('Test')) return false;
226
+ }
227
+ return true;
228
+ }
229
+
230
+ /**
231
+ * Which fixture definition a request for `name` from `fn` (declared in
232
+ * `file`) receives: { def: {file, node} } for a resolved fixture function,
233
+ * { none: true } when no project fixture of that name is in scope (plugins,
234
+ * built-ins), or { unknown: reason }.
235
+ */
236
+ function resolveRequest(index, file, fn, name, pinned) {
237
+ const self = fn.fixture && fn.fixtureName === name;
238
+ // Class fixtures: the requester's enclosing classes, innermost first.
239
+ for (let owner = fn.owner; owner; owner = owner.owner) {
240
+ const own = moduleFunctions(index, file).functions.filter(other => other.owner === owner &&
241
+ other.fixtureName === name && !(self && sameNode(other.node, fn.node)));
242
+ if (own.length > 0) return own.length === 1 ? { def: { file, node: own[0].node } } : { unknown: 'class-fixtures' };
243
+ if (owner.assigned?.has(name)) return { unknown: 'class-binding' };
244
+ if (owner.bases.some(base => base.text !== 'object')) return { unknown: 'class-bases' };
245
+ }
246
+ return resolveModuleLevel(index, file, name, self ? fn : null, pinned);
247
+ }
248
+
249
+ function resolveModuleLevel(index, file, name, skipFn, pinned) {
250
+ const root = index.root;
251
+ // The module itself, then conftest.py upward from its directory (from the
252
+ // parent directory for a conftest's own requests).
253
+ const chain = [file];
254
+ let dir = path.dirname(file);
255
+ if (isConftest(file)) dir = dir === root ? null : path.dirname(dir);
256
+ for (; dir && (dir === root || dir.startsWith(root + path.sep)); dir = dir === root ? null : path.dirname(dir)) {
257
+ const conftest = path.join(dir, 'conftest.py');
258
+ if (conftest !== file && index.files.has(conftest)) chain.push(conftest);
259
+ }
260
+ for (const current of chain) {
261
+ const module = moduleFunctions(index, current);
262
+ if (!module) return { unknown: 'unreadable' };
263
+ const defs = module.functions.filter(fn => !fn.owner && fn.fixtureName === name &&
264
+ !(skipFn && current === file && sameNode(fn.node, skipFn.node)));
265
+ const dynamic = module.functions.some(fn => !fn.owner && fn.fixture?.dynamicName);
266
+ const others = module.otherBindings.get(name) || [];
267
+ if (defs.length === 0 && others.length === 0) {
268
+ if (module.starImport || dynamic) return { unknown: 'module-namespace' };
269
+ continue;
270
+ }
271
+ if (defs.length > 1 || others.some(other => other.kind !== 'import')) return { unknown: 'module-binding' };
272
+ if (defs.length === 1 && others.length === 0) return { def: { file: current, node: defs[0].node } };
273
+ // An import of the name: the fixture it binds, when it is the pinned
274
+ // definition itself (renamed with it); anything else is undecided.
275
+ if (defs.length === 0 && others.length === 1 && importReachesPinned(index, current, name, pinned)) {
276
+ return { def: { file: pinned.file, node: null, viaImport: current } };
277
+ }
278
+ return { unknown: 'module-import' };
279
+ }
280
+ return { none: true };
281
+ }
282
+
283
+ /** A `from m import name` binding of a module that resolves to the pinned def. */
284
+ function importReachesPinned(index, file, name, pinned) {
285
+ const entry = index.files.get(file);
286
+ const bindings = (entry?.importBindings || []).filter(b => (b.alias || b.name) === name);
287
+ if (bindings.length !== 1 || bindings[0].alias || bindings[0].name !== name) return false;
288
+ const relative = entry.moduleResolved?.[bindings[0].module];
289
+ return !!relative && path.resolve(index.root, relative) === pinned.file;
290
+ }
291
+
292
+ /** Whether an identifier is a parameter name (not a default value). */
293
+ function isParameterName(node) {
294
+ let current = node;
295
+ let parent = current.parent;
296
+ if (parent?.type === 'list_splat_pattern' || parent?.type === 'dictionary_splat_pattern') {
297
+ current = parent;
298
+ parent = current.parent;
299
+ }
300
+ if (!parent) return false;
301
+ if (parent.type === 'parameters' || parent.type === 'lambda_parameters') return true;
302
+ if (parent.type === 'typed_parameter') return sameNode(parent.namedChild(0), current);
303
+ if (parent.type === 'default_parameter' || parent.type === 'typed_default_parameter') {
304
+ return sameNode(parent.childForFieldName('name'), current);
305
+ }
306
+ return false;
307
+ }
308
+
309
+ /** Identifier tokens in `fn` that bind its parameter `name` (scope-exact). */
310
+ function parameterTokens(fn, param, name) {
311
+ const { referenceScopeNode } = require('../languages/lexical-scope');
312
+ const tokens = [param.node];
313
+ const body = fn.node.childForFieldName('body');
314
+ const stack = body ? [body] : [];
315
+ const memo = new Map();
316
+ while (stack.length > 0) {
317
+ const node = stack.pop();
318
+ if (node.type === 'identifier' && node.text === name) {
319
+ // A nested function's or lambda's own parameter binds there.
320
+ if (isParameterName(node)) continue;
321
+ const scope = referenceScopeNode(node, name, memo);
322
+ if (scope && sameNode(scope, fn.node)) tokens.push(node);
323
+ continue;
324
+ }
325
+ for (let i = node.namedChildCount - 1; i >= 0; i--) stack.push(node.namedChild(i));
326
+ }
327
+ return tokens;
328
+ }
329
+
330
+ /**
331
+ * The parameter sites of a fixture rename. `def` is the pinned fixture
332
+ * function. Returns null when `def` is not a pytest fixture named by its
333
+ * function name; otherwise { edits: [{file, line, byteColumns}], reviews:
334
+ * [{file, line, reason}], plugin: bool }.
335
+ */
336
+ function fixtureParameterSites(index, def) {
337
+ const entry = index.files.get(def.file);
338
+ if (entry?.language !== 'python') return null;
339
+ const pinnedModule = moduleFunctions(index, def.file);
340
+ const pinnedFn = pinnedModule?.functions.find(fn => fn.name === def.name &&
341
+ fn.node.childForFieldName('name')?.startPosition.row + 1 === (def.nameLine || def.startLine));
342
+ if (!pinnedFn?.fixture) return null;
343
+ if (pinnedFn.fixture.dynamicName) return { dynamic: true, edits: [], reviews: [] };
344
+ // `@pytest.fixture(name="other")`: the function name is not the fixture's.
345
+ if (pinnedFn.fixtureName !== def.name) return { edits: [], reviews: [], aliased: true };
346
+ const name = def.name;
347
+ const pinned = { file: def.file, node: pinnedFn.node };
348
+ const isPinned = resolved => resolved.def && resolved.def.file === pinned.file &&
349
+ (resolved.def.viaImport || (resolved.def.node && sameNode(resolved.def.node, pinned.node)));
350
+ // Files whose requests may reach the fixture: the defining module, every
351
+ // module under a conftest providing it, and modules importing it.
352
+ const scopes = [];
353
+ if (pinnedFn.owner) scopes.push({ files: [def.file] });
354
+ else if (isConftest(def.file)) scopes.push({ dir: path.dirname(def.file) });
355
+ else scopes.push({ files: [def.file] });
356
+ const plugin = !pinnedFn.owner && !collected(def.file);
357
+ for (const [file, other] of index.files) {
358
+ if (other.language !== 'python' || file === def.file) continue;
359
+ if (!importReachesPinned(index, file, name, pinned)) continue;
360
+ scopes.push(isConftest(file) ? { dir: path.dirname(file) } : { files: [file] });
361
+ }
362
+ // A fixture outside conftest.py and test modules is registered as a
363
+ // plugin: any request pytest resolves past every project fixture of the
364
+ // name may receive it.
365
+ if (plugin) scopes.push({ dir: index.root });
366
+ const candidates = new Set();
367
+ for (const scope of scopes) {
368
+ if (scope.files) for (const file of scope.files) candidates.add(file);
369
+ else {
370
+ const prefix = scope.dir === index.root ? '' : scope.dir + path.sep;
371
+ for (const [file, other] of index.files) {
372
+ if (other.language === 'python' && (!prefix || file.startsWith(prefix))) candidates.add(file);
373
+ }
374
+ }
375
+ }
376
+ const edits = [];
377
+ const reviews = [];
378
+ for (const file of [...candidates].sort()) {
379
+ let content;
380
+ try { content = index._readFile(file); } catch { continue; }
381
+ if (!content.includes(name) || !declaresParameter(index.files.get(file), name)) continue;
382
+ const module = moduleFunctions(index, file);
383
+ if (!module) continue;
384
+ for (const fn of module.functions) {
385
+ const param = fn.params.find(p => p.name === name);
386
+ if (!param || param.hasDefault) continue;
387
+ if (!requestsFixtures(file, fn)) {
388
+ // A test-named function or fixture-shaped request pytest does
389
+ // not collect by default may still be collected by
390
+ // configuration or imported into a test module.
391
+ if (!collected(file) && fn.name?.startsWith('test')) {
392
+ reviews.push({ file, line: param.node.startPosition.row + 1, reason: 'not-collected' });
393
+ }
394
+ continue;
395
+ }
396
+ const parametrized = [fn, ...ownersOf(fn)].some(item => (item.kinds || []).some(k =>
397
+ k.kind === 'parametrize' && (!k.names || k.names.includes(name))));
398
+ const resolved = resolveRequest(index, file, fn, name, pinned);
399
+ if (resolved.none && plugin) {
400
+ reviews.push({ file, line: param.node.startPosition.row + 1, reason: 'plugin-fixture' });
401
+ continue;
402
+ }
403
+ if (resolved.none || (resolved.def && !isPinned(resolved))) continue;
404
+ if (resolved.unknown || parametrized) {
405
+ reviews.push({ file, line: param.node.startPosition.row + 1,
406
+ reason: parametrized ? 'parametrized' : resolved.unknown });
407
+ continue;
408
+ }
409
+ const byLine = new Map();
410
+ for (const token of parameterTokens(fn, param, name)) {
411
+ const line = token.startPosition.row + 1;
412
+ if (!byLine.has(line)) byLine.set(line, []);
413
+ byLine.get(line).push(token.startPosition.column);
414
+ }
415
+ for (const [line, byteColumns] of byLine) edits.push({ file, line, byteColumns });
416
+ }
417
+ }
418
+ return { edits, reviews, plugin };
419
+ }
420
+
421
+ /** Whether an indexed function of the module may take a parameter `name` (skips the parse). */
422
+ function declaresParameter(entry, name) {
423
+ return (entry?.symbols || []).some(symbol => Array.isArray(symbol.paramsStructured)
424
+ ? symbol.paramsStructured.some(param => param.name === name)
425
+ : typeof symbol.params === 'string' && symbol.params.includes(name));
426
+ }
427
+
428
+ function ownersOf(fn) {
429
+ const out = [];
430
+ for (let owner = fn.owner; owner; owner = owner.owner) out.push(owner);
431
+ return out;
432
+ }
433
+
434
+ module.exports = { fixtureParameterSites };
@@ -12,7 +12,9 @@ function moduleEvidence(index, file) {
12
12
  if (entry?.language !== 'python') return null;
13
13
  const cached = moduleMetadata.get(entry);
14
14
  if (cached?.hash === entry.hash) return cached.value;
15
- const value = getLanguageAdapter('python').findPythonModuleEvidence(index._readFile(file), getParser('python'));
15
+ const content = index._readFile(file);
16
+ const value = getLanguageAdapter('python').findPythonModuleEvidence(content, getParser('python'),
17
+ index._getParsedTree(file, content, 'python'));
16
18
  moduleMetadata.set(entry, { hash: entry.hash, value });
17
19
  return value;
18
20
  }
@@ -59,7 +61,9 @@ function functionsIn(index, file) {
59
61
  if (entry?.language !== 'python') return [];
60
62
  const cached = metadata.get(entry);
61
63
  if (cached?.hash === entry.hash) return cached.functions;
62
- const functions = getLanguageAdapter('python').findPytestFunctions(index._readFile(file), getParser('python'));
64
+ const content = index._readFile(file);
65
+ const functions = getLanguageAdapter('python').findPytestFunctions(content, getParser('python'),
66
+ index._getParsedTree(file, content, 'python'));
63
67
  metadata.set(entry, { hash: entry.hash, functions });
64
68
  return functions;
65
69
  }
@@ -87,6 +91,16 @@ function pythonFixtureReceiver(index, file, call, options) {
87
91
  const receiver = call.receiver || call.receiverRoot;
88
92
  if (!receiver || call.receiverType || call.receiverRootType ||
89
93
  call.receiverFlowInvalidated || !/^test_.*\.py$|^.*_test\.py$/.test(path.basename(file))) return null;
94
+ // Only a module test parameter can satisfy the proof below. Use the
95
+ // indexed declaration to reject other receivers before parsing every
96
+ // fixture and test body in this file.
97
+ const enclosing = call.enclosingFunction;
98
+ if (!enclosing?.name?.startsWith('test_') ||
99
+ !(index.symbols.get(enclosing.name) || []).some(definition =>
100
+ definition.file === file && !definition.className && !definition.isNested &&
101
+ definition.startLine === enclosing.startLine &&
102
+ (!Array.isArray(definition.paramsStructured) ||
103
+ definition.paramsStructured.some(parameter => parameter.name === receiver)))) return null;
90
104
  const test = functionsIn(index, file).find(fn => fn.name.startsWith('test_') &&
91
105
  fn.startLine <= call.line && fn.endLine >= call.line && !fn.async && !fn.decorators.length);
92
106
  const parameter = test?.parameters.find(p => p.name === receiver && p.unchanged);
@@ -213,4 +227,4 @@ function validatePythonFixtureBinding(proof, facts) {
213
227
  sameDeclaration(previous, facts.receiverTypeDeclaration || facts.receiverResolvedIn);
214
228
  }
215
229
 
216
- module.exports = { pythonFixtureReceiver, pythonFixtureType, validatePythonFixtureBinding };
230
+ module.exports = { moduleEvidence, pythonFixtureReceiver, pythonFixtureType, validatePythonFixtureBinding };
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Resolve Python module objects through imported names and package attributes.
3
+ * A module import, a from-imported submodule and an ordinary package value
4
+ * have different bindings. Unknown or conflicting bindings never pin a file.
5
+ * The caller owns the memo and discards it when the index changes.
6
+ */
7
+ 'use strict';
8
+
9
+ const path = require('path');
10
+
11
+ function moduleFile(index, entry, name) {
12
+ const rel = entry.moduleResolved?.[name];
13
+ return rel ? path.isAbsolute(rel) ? rel : path.join(index.root, rel) : null;
14
+ }
15
+
16
+ function pythonModulePath(index, file, receiver, memo = new Map(), seen = new Set()) {
17
+ memo ||= new Map();
18
+ const key = `py-module\0${file}\0${receiver}`;
19
+ if (memo.has(key)) return memo.get(key);
20
+ if (seen.has(key) || seen.size >= 16) return { files: [], unknown: true };
21
+ const next = new Set(seen).add(key);
22
+ const entry = index.files.get(file);
23
+ if (!entry) return { files: [], unknown: true };
24
+ const [head, ...tail] = receiver.split('.');
25
+ const named = (entry.importBindings || []).filter(b => (b.alias || b.name) === head);
26
+ const bindings = named.filter(b =>
27
+ !(entry.symbols || []).some(s => s.startLine <= b.line && s.endLine >= b.line));
28
+ if (bindings.length === 0) return named.length ? { files: [], unknown: true } : null;
29
+ const files = new Set();
30
+ let unknown = (entry.moduleAssignedNames || []).includes(head) ||
31
+ (entry.symbols || []).some(s => s.name === head && !s.className &&
32
+ s.lexicalScopeStartLine == null);
33
+ let bound = false;
34
+ let external = false;
35
+ for (const b of bindings) {
36
+ const module = String(b.module || '');
37
+ if (b.kind !== 'import') {
38
+ const parent = moduleFile(index, entry, module);
39
+ const parentEntry = index.files.get(parent);
40
+ if ((parentEntry?.moduleAssignedNames || []).includes(b.name) ||
41
+ (parentEntry?.symbols || []).some(s => s.name === b.name && !s.className &&
42
+ s.lexicalScopeStartLine == null)) {
43
+ // A from-import reads the package's attribute before
44
+ // loading a same-named child module.
45
+ bound = true;
46
+ unknown = true;
47
+ continue;
48
+ }
49
+ }
50
+ const spec = b.kind === 'import' ? module
51
+ : module.endsWith('.') ? module + b.name : `${module}.${b.name}`;
52
+ const start = moduleFile(index, entry, spec);
53
+ if (start) {
54
+ bound = true;
55
+ files.add(start);
56
+ } else if (b.kind === 'import') {
57
+ bound = true;
58
+ const { bindingIsExternal } = require('./type-denotation');
59
+ if (bindingIsExternal(index, entry, file, b) !== true) unknown = true;
60
+ else external = true;
61
+ } else {
62
+ // A package can re-export a module imported under another
63
+ // name. Chase that binding, never a same-spelled disk path.
64
+ const parent = moduleFile(index, entry, module);
65
+ const imported = parent && pythonModulePath(index, parent, b.name, memo, next);
66
+ if (imported) {
67
+ bound = true;
68
+ unknown ||= imported.unknown;
69
+ external ||= imported.files.length === 0 && !imported.unknown;
70
+ for (const target of imported.files) files.add(target);
71
+ } else if (bindings.length > 1) unknown = true;
72
+ }
73
+ }
74
+ if (!bound) return null;
75
+ unknown ||= external && files.size > 0;
76
+ let current = [...files];
77
+ for (const part of tail) {
78
+ const children = new Set();
79
+ let externalChild = false;
80
+ for (const parent of current) {
81
+ const imported = pythonModulePath(index, parent, part, memo, next);
82
+ if (imported) {
83
+ unknown ||= imported.unknown;
84
+ externalChild ||= imported.files.length === 0 && !imported.unknown;
85
+ for (const target of imported.files) children.add(target);
86
+ continue;
87
+ }
88
+ const parentEntry = index.files.get(parent);
89
+ const rebound = (parentEntry?.moduleAssignedNames || []).includes(part) ||
90
+ (parentEntry?.symbols || []).some(s => s.name === part) ||
91
+ (parentEntry?.importBindings || []).some(b => (b.alias || b.name) === part);
92
+ // `import pkg.sub` loads sub onto the package object. Its
93
+ // indexed child is usable unless the package binds that
94
+ // attribute to a different (or unknown) value.
95
+ const child = path.basename(parent) === '__init__.py' && !rebound
96
+ ? [path.join(path.dirname(parent), part + '.py'),
97
+ path.join(path.dirname(parent), part, '__init__.py')]
98
+ .find(candidate => index.files.has(candidate)) : null;
99
+ if (child) children.add(child);
100
+ else unknown = true;
101
+ }
102
+ unknown ||= externalChild && children.size > 0;
103
+ current = [...children];
104
+ }
105
+ const result = { files: current, unknown };
106
+ memo.set(key, result);
107
+ return result;
108
+ }
109
+
110
+ /** Whether a module root is read from global scope at its written origin.
111
+ * An annotation's origin is its annotation node, not a later call in the
112
+ * function body; a class's bases are evaluated outside its own body. */
113
+ function pythonModuleScope(index, file, receiver, site, memo = new Map()) {
114
+ if (!site) return true;
115
+ memo ||= new Map();
116
+ const head = receiver.split('.')[0];
117
+ const key = `py-module-scope\0${file}\0${head}\0${site.start ?? ''}\0${site.end ?? ''}\0${site.startLine ?? ''}`;
118
+ if (memo.has(key)) return memo.get(key);
119
+ let ownedTree;
120
+ let result = false;
121
+ try {
122
+ const content = index._readFile(file);
123
+ const { getParser, safeParse } = require('../languages');
124
+ const tree = index._getParsedTree(file, content, 'python') ||
125
+ (ownedTree = safeParse(getParser('python'), content));
126
+ let node;
127
+ if (Number.isInteger(site.start) && Number.isInteger(site.end)) {
128
+ node = tree?.rootNode.descendantForIndex(site.start, Math.max(site.start, site.end - 1));
129
+ } else if (site.startLine != null && site.endLine != null) {
130
+ const stack = tree ? [tree.rootNode] : [];
131
+ while (stack.length) {
132
+ const current = stack.pop();
133
+ if (current.endPosition.row + 1 < site.startLine ||
134
+ current.startPosition.row + 1 > site.endLine) continue;
135
+ if (current.type === 'class_definition' &&
136
+ current.childForFieldName('name')?.text === site.name &&
137
+ current.endPosition.row + 1 === site.endLine) {
138
+ node = current.childForFieldName('superclasses');
139
+ break;
140
+ }
141
+ for (const child of current.namedChildren) stack.push(child);
142
+ }
143
+ }
144
+ if (node) {
145
+ const { referenceScope } = require('../languages/lexical-scope');
146
+ const scopesKey = 'py-module-scopes';
147
+ let trees = memo.get(scopesKey);
148
+ if (!trees) { trees = new WeakMap(); memo.set(scopesKey, trees); }
149
+ let scopes = trees.get(tree);
150
+ if (!scopes) { scopes = new Map(); trees.set(tree, scopes); }
151
+ result = referenceScope(node, 'python', scopes, head) === 'module';
152
+ }
153
+ } finally {
154
+ ownedTree?.delete?.();
155
+ }
156
+ memo.set(key, result);
157
+ return result;
158
+ }
159
+
160
+ /** Definitions a resolved module exposes under this exact type name.
161
+ * Preserve the file-closure witness for unmodeled wildcard exports. A
162
+ * definitive different binding never takes that fallback. */
163
+ function pythonModuleTypes(index, file, name, definitions) {
164
+ const { _nameBindingReaches, _importReaches } = require('./callers');
165
+ const verdicts = definitions.map(def => ({ def, verdict: _nameBindingReaches(
166
+ index, file, name, new Set([def.file]), 8, { exactName: true }) }));
167
+ const named = verdicts.filter(row => row.verdict === 'yes').map(row => row.def);
168
+ if (named.length) return named;
169
+ return verdicts.filter(row => row.verdict === 'unknown' &&
170
+ _importReaches(index, file, new Set([row.def.file]))).map(row => row.def);
171
+ }
172
+
173
+ module.exports = { pythonModulePath, pythonModuleScope, pythonModuleTypes };