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.
- package/.claude/skills/ucn/SKILL.md +58 -236
- package/.claude/skills/ucn/references/commands.md +144 -68
- package/.claude/skills/ucn/references/resolution-rules.md +171 -0
- package/.claude/skills/ucn/references/trust-contract.md +27 -33
- package/README.md +18 -12
- package/cli/index.js +40 -14
- package/core/accessors.js +118 -3
- package/core/account.js +30 -0
- package/core/analysis.js +508 -264
- package/core/ast-analysis.js +76 -117
- package/core/async-producers.js +1729 -0
- package/core/bridge.js +1877 -268
- package/core/brief.js +6 -0
- package/core/build-worker.js +78 -14
- package/core/cache.js +522 -31
- package/core/callers.js +12053 -1047
- package/core/check.js +30 -38
- package/core/class-identity.js +1028 -0
- package/core/command-contracts.js +6 -6
- package/core/configuration-items.js +86 -0
- package/core/contract-membership.js +2262 -0
- package/core/cpp-scope.js +532 -0
- package/core/deadcode.js +171 -49
- package/core/decorator-binding.js +260 -0
- package/core/discovery.js +281 -164
- package/core/errors.js +52 -0
- package/core/execute.js +21 -9
- package/core/external-macros.js +326 -0
- package/core/graph-build.js +277 -8
- package/core/graph.js +35 -2
- package/core/ignore-rules.js +472 -0
- package/core/imports.js +504 -42
- package/core/index-ir.js +94 -11
- package/core/inprocess-clients.js +636 -0
- package/core/ir.js +87 -8
- package/core/macro-expansion.js +1416 -0
- package/core/output/analysis.js +18 -2
- package/core/output/brief.js +3 -0
- package/core/output/doctor.js +4 -0
- package/core/output/endpoints.js +15 -6
- package/core/output/find.js +7 -2
- package/core/output/refactoring.js +121 -14
- package/core/output/reporting.js +31 -3
- package/core/output/shared.js +14 -0
- package/core/output-budget.js +305 -122
- package/core/parallel-build.js +124 -47
- package/core/parser.js +3 -2
- package/core/project.js +457 -60
- package/core/provenance-facts.js +156 -12
- package/core/provenance-overload.js +30 -3
- package/core/provenance.js +104 -30
- package/core/pytest-fixtures.js +434 -0
- package/core/python-fixture-flow.js +17 -3
- package/core/python-modules.js +173 -0
- package/core/reflection.js +877 -0
- package/core/registry.js +1 -1
- package/core/reporting.js +234 -52
- package/core/request-helpers.js +561 -0
- package/core/route-graph.js +1922 -0
- package/core/route-lists.js +2414 -0
- package/core/rust-macro-expansion.js +2113 -0
- package/core/rust-modules.js +185 -0
- package/core/search.js +121 -29
- package/core/shared.js +19 -1
- package/core/test-name-bindings.js +256 -0
- package/core/type-denotation.js +1161 -0
- package/core/type-references.js +3229 -0
- package/core/verify.js +3360 -672
- package/languages/adapter.js +31 -5
- package/languages/c-family.js +4061 -212
- package/languages/c-macro-invocations.js +380 -0
- package/languages/c-preprocessor.js +254 -0
- package/languages/csharp.js +1406 -49
- package/languages/go.js +1243 -70
- package/languages/index.js +728 -6
- package/languages/java.js +473 -61
- package/languages/javascript.js +1270 -197
- package/languages/lexical-scope.js +1280 -0
- package/languages/python.js +1557 -72
- package/languages/rust-macro-rules.js +1020 -0
- package/languages/rust-value-flow.js +416 -0
- package/languages/rust.js +2237 -115
- package/languages/utils.js +298 -4
- package/mcp/server.js +22 -8
- 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
|
|
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
|
|
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 };
|