ucn 5.4.2 → 5.5.1
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 +27 -16
- package/cli/index.js +45 -14
- package/core/accessors.js +118 -3
- package/core/account.js +62 -11
- package/core/analysis.js +517 -266
- 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 +529 -31
- package/core/callers.js +12228 -921
- 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 +472 -66
- 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 +3026 -0
- package/core/rust-modules.js +203 -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 +1185 -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 +2265 -112
- package/languages/utils.js +298 -4
- package/mcp/server.js +22 -8
- package/package.json +1 -1
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/inprocess-clients.js - The app an in-process test client serves
|
|
3
|
+
* (fix #392).
|
|
4
|
+
*
|
|
5
|
+
* A test client built from an app object (`TestClient(app)`,
|
|
6
|
+
* `test_client_factory(app)`, `AsyncClient(transport=ASGITransport(app=app))`,
|
|
7
|
+
* `app.test_client()`) sends its requests to that app in process: only the
|
|
8
|
+
* routes registered on it, or mounted under it, can serve them. A request
|
|
9
|
+
* whose receiver is such a client carries the app's route-container key
|
|
10
|
+
* (the key scheme of core/route-lists.js: `file:name` for a module-level
|
|
11
|
+
* binding, `file@scope:name` for a function-local one, the defining module's
|
|
12
|
+
* key for an imported one).
|
|
13
|
+
*
|
|
14
|
+
* The client is found where the receiver is bound: an assignment or
|
|
15
|
+
* `with ... as name` in the enclosing function, a module-level assignment,
|
|
16
|
+
* or a pytest fixture parameter (the fixture's yielded/returned value, found
|
|
17
|
+
* in the test's module or a conftest.py up the directory chain). The app is
|
|
18
|
+
* the constructor's `app=` argument (also inside a `transport=` argument),
|
|
19
|
+
* else its first positional argument, or the receiver of `.test_client()`.
|
|
20
|
+
* An app that is itself a fixture parameter resolves through that fixture's
|
|
21
|
+
* returned name. Anything else is unresolved (null).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const routeGraph = require('./route-graph');
|
|
28
|
+
const { pythonContainerKey } = require('./route-lists');
|
|
29
|
+
|
|
30
|
+
const { field, namedChildren, unwrap } = routeGraph;
|
|
31
|
+
const { sameNode } = require('../languages/utils');
|
|
32
|
+
|
|
33
|
+
const MAX_HOPS = 3;
|
|
34
|
+
|
|
35
|
+
function enclosingFunctionNode(node) {
|
|
36
|
+
for (let n = node?.parent; n; n = n.parent) {
|
|
37
|
+
if (n.type === 'function_definition') return n;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Call nodes on a 1-based line: `receiver.name(...)`. */
|
|
43
|
+
function requestCallNodes(root, line, receiver, name) {
|
|
44
|
+
const row = line - 1;
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const call of root.descendantsOfType('call', { row, column: 0 }, { row, column: 1 << 20 })) {
|
|
47
|
+
const fn = field(call, 'function');
|
|
48
|
+
if (fn?.type !== 'attribute') continue;
|
|
49
|
+
const attr = field(fn, 'attribute');
|
|
50
|
+
const obj = unwrap(field(fn, 'object'));
|
|
51
|
+
if (attr?.text === name && attr.startPosition.row === row && obj?.type === 'identifier' &&
|
|
52
|
+
obj.text === receiver) out.push({ call, receiverNode: obj });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const BINDING_TYPES = ['assignment', 'augmented_assignment', 'for_statement', 'with_item',
|
|
58
|
+
'function_definition', 'class_definition'];
|
|
59
|
+
const SCOPE_TYPES = new Set(['function_definition', 'class_definition', 'lambda']);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Values bound to `name` directly in a function body or module (nested
|
|
63
|
+
* function/class bodies excluded): `name = V` and `with V as name`, each
|
|
64
|
+
* with `top`, the container's statement holding it. null when the name is
|
|
65
|
+
* bound any other way (loop target, augmented assignment, tuple unpacking, a
|
|
66
|
+
* def or class of the name).
|
|
67
|
+
*/
|
|
68
|
+
function boundValues(container, name, memoByTree = null) {
|
|
69
|
+
let memo = null;
|
|
70
|
+
if (memoByTree && container.tree) {
|
|
71
|
+
memo = memoByTree.get(container.tree);
|
|
72
|
+
if (!memo) { memo = new Map(); memoByTree.set(container.tree, memo); }
|
|
73
|
+
}
|
|
74
|
+
const key = memo ? `${container.startIndex}:${container.endIndex}:${name}` : null;
|
|
75
|
+
if (key && memo.has(key)) return memo.get(key);
|
|
76
|
+
const values = [];
|
|
77
|
+
let other = false;
|
|
78
|
+
for (const node of container.descendantsOfType(BINDING_TYPES)) {
|
|
79
|
+
if (!node.text.includes(name)) continue;
|
|
80
|
+
// The statement of the container holding it, unless a nested
|
|
81
|
+
// function or class scope lies in between.
|
|
82
|
+
let top = null;
|
|
83
|
+
let nested = false;
|
|
84
|
+
for (let n = node; n; n = n.parent) {
|
|
85
|
+
if (!sameNode(n, node) && SCOPE_TYPES.has(n.type)) { nested = true; break; }
|
|
86
|
+
if (n.parent && sameNode(n.parent, container)) { top = n; break; }
|
|
87
|
+
}
|
|
88
|
+
if (nested || !top) continue;
|
|
89
|
+
switch (node.type) {
|
|
90
|
+
case 'function_definition': case 'class_definition':
|
|
91
|
+
if (field(node, 'name')?.text === name) other = true;
|
|
92
|
+
break;
|
|
93
|
+
case 'assignment': {
|
|
94
|
+
const left = field(node, 'left');
|
|
95
|
+
if (left?.type === 'identifier' && left.text === name) {
|
|
96
|
+
const right = field(node, 'right');
|
|
97
|
+
if (right) values.push({ node: right, top });
|
|
98
|
+
else other = true;
|
|
99
|
+
} else if (left && left.type !== 'identifier' && left.type !== 'attribute' &&
|
|
100
|
+
left.type !== 'subscript' && left.descendantsOfType('identifier').some(n => n.text === name)) {
|
|
101
|
+
other = true;
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'augmented_assignment':
|
|
106
|
+
if (field(node, 'left')?.text === name) other = true;
|
|
107
|
+
break;
|
|
108
|
+
case 'for_statement': {
|
|
109
|
+
const left = field(node, 'left');
|
|
110
|
+
if (left && (left.text === name || left.descendantsOfType('identifier').some(n => n.text === name))) {
|
|
111
|
+
other = true;
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case 'with_item': {
|
|
116
|
+
const value = field(node, 'value');
|
|
117
|
+
if (value?.type === 'as_pattern') {
|
|
118
|
+
const alias = field(value, 'alias');
|
|
119
|
+
const target = alias && alias.namedChild(0);
|
|
120
|
+
if (target?.type === 'identifier' && target.text === name) {
|
|
121
|
+
const expr = value.namedChild(0);
|
|
122
|
+
if (expr) values.push({ node: expr, top });
|
|
123
|
+
} else if (alias && alias.descendantsOfType('identifier').some(n => n.text === name)) {
|
|
124
|
+
other = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const result = other ? null : values;
|
|
134
|
+
if (key) memo.set(key, result);
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isParameterOf(fnNode, name) {
|
|
139
|
+
for (const param of namedChildren(field(fnNode, 'parameters'))) {
|
|
140
|
+
const nameNode = param.type === 'identifier' ? param
|
|
141
|
+
: field(param, 'name') || (param.type === 'typed_parameter' ? param.namedChild(0) : null);
|
|
142
|
+
if (nameNode?.type === 'identifier' && nameNode.text === name) return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The app a wrapping constructor wraps: `app=` or its first positional. */
|
|
148
|
+
function wrappedApp(callNode) {
|
|
149
|
+
const args = namedChildren(field(callNode, 'arguments'));
|
|
150
|
+
for (const arg of args) {
|
|
151
|
+
if (arg.type === 'keyword_argument' && field(arg, 'name')?.text === 'app') return field(arg, 'value');
|
|
152
|
+
}
|
|
153
|
+
const first = args.find(arg => arg.type !== 'keyword_argument' && arg.type !== 'list_splat' &&
|
|
154
|
+
arg.type !== 'dictionary_splat');
|
|
155
|
+
const value = unwrap(first);
|
|
156
|
+
return value && (value.type === 'identifier' || value.type === 'call') ? value : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// An app served by its own callable (a function or class), never by routes.
|
|
160
|
+
const NO_ROUTES = '\0no-routes';
|
|
161
|
+
|
|
162
|
+
class PythonClientResolver {
|
|
163
|
+
/** `served()`: the container keys some route is served from. */
|
|
164
|
+
constructor(index, sess, served) {
|
|
165
|
+
this.index = index;
|
|
166
|
+
this.sess = sess;
|
|
167
|
+
this.served = served;
|
|
168
|
+
this.fixtureMemo = new Map();
|
|
169
|
+
this.bindingMemo = new WeakMap();
|
|
170
|
+
this.clientMemo = new Map();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Is `decorated` a pytest fixture (`@pytest.fixture`, `@fixture(...)`)? */
|
|
174
|
+
isFixture(file, fnNode) {
|
|
175
|
+
const decorated = fnNode.parent?.type === 'decorated_definition' ? fnNode.parent : null;
|
|
176
|
+
if (!decorated) return false;
|
|
177
|
+
const entry = this.index.files.get(file);
|
|
178
|
+
const bindings = entry?.importBindings || [];
|
|
179
|
+
for (const deco of namedChildren(decorated)) {
|
|
180
|
+
if (deco.type !== 'decorator') continue;
|
|
181
|
+
let expr = deco.namedChild(0);
|
|
182
|
+
if (expr?.type === 'call') expr = field(expr, 'function');
|
|
183
|
+
if (!expr) continue;
|
|
184
|
+
if (expr.type === 'attribute' && field(expr, 'attribute')?.text === 'fixture') {
|
|
185
|
+
const head = field(expr, 'object');
|
|
186
|
+
if (head?.type === 'identifier' && bindings.some(b =>
|
|
187
|
+
(b.alias || b.name) === head.text && b.kind === 'import' && b.module === 'pytest')) return true;
|
|
188
|
+
}
|
|
189
|
+
if (expr.type === 'identifier' && bindings.some(b =>
|
|
190
|
+
(b.alias || b.name) === expr.text && b.kind === 'from' && b.module === 'pytest' &&
|
|
191
|
+
b.name === 'fixture')) return true;
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The fixture named `name` a test in `file` requests: same module first,
|
|
197
|
+
* then conftest.py files from the test's directory up to the root. */
|
|
198
|
+
fixtureDef(file, name) {
|
|
199
|
+
const key = `${file}\0${name}`;
|
|
200
|
+
if (this.fixtureMemo.has(key)) return this.fixtureMemo.get(key);
|
|
201
|
+
let found = this.moduleFixture(file, name);
|
|
202
|
+
if (!found) {
|
|
203
|
+
for (let dir = path.dirname(file); dir.startsWith(this.index.root); dir = path.dirname(dir)) {
|
|
204
|
+
const conftest = path.join(dir, 'conftest.py');
|
|
205
|
+
if (this.index.files.has(conftest)) {
|
|
206
|
+
found = this.moduleFixture(conftest, name);
|
|
207
|
+
if (found) break;
|
|
208
|
+
}
|
|
209
|
+
if (dir === this.index.root || path.dirname(dir) === dir) break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
this.fixtureMemo.set(key, found);
|
|
213
|
+
return found;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
moduleFixture(file, name) {
|
|
217
|
+
const root = this.sess.root(file);
|
|
218
|
+
if (!root) return null;
|
|
219
|
+
const hits = [];
|
|
220
|
+
for (const stmt of namedChildren(root)) {
|
|
221
|
+
const fn = stmt.type === 'decorated_definition' ? field(stmt, 'definition') : stmt;
|
|
222
|
+
if (fn?.type === 'function_definition' && field(fn, 'name')?.text === name &&
|
|
223
|
+
this.isFixture(file, fn)) hits.push(fn);
|
|
224
|
+
}
|
|
225
|
+
return hits.length === 1 ? { file, fnNode: hits[0] } : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The expression a fixture provides: its single yielded/returned value,
|
|
229
|
+
* followed through a local binding. */
|
|
230
|
+
fixtureValue(file, fnNode, hops) {
|
|
231
|
+
const body = field(fnNode, 'body');
|
|
232
|
+
if (!body) return null;
|
|
233
|
+
const provided = [];
|
|
234
|
+
const visit = (node) => {
|
|
235
|
+
for (const child of namedChildren(node)) {
|
|
236
|
+
if (child.type === 'function_definition' || child.type === 'class_definition' ||
|
|
237
|
+
child.type === 'decorated_definition' || child.type === 'lambda') continue;
|
|
238
|
+
if (child.type === 'return_statement' || child.type === 'yield') {
|
|
239
|
+
const value = child.namedChild(0);
|
|
240
|
+
if (value) provided.push(value);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
visit(child);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
visit(body);
|
|
247
|
+
if (provided.length !== 1) return null;
|
|
248
|
+
return this.followBinding(file, fnNode, unwrap(provided[0]), hops);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A name follows to its single bound value in the function or module. */
|
|
252
|
+
followBinding(file, fnNode, expr, hops) {
|
|
253
|
+
if (!expr || expr.type !== 'identifier' || hops > MAX_HOPS) return { file, expr };
|
|
254
|
+
if (fnNode) {
|
|
255
|
+
const values = boundValues(field(fnNode, 'body'), expr.text, this.bindingMemo);
|
|
256
|
+
if (values === null) return null;
|
|
257
|
+
if (values.length === 1) return this.followBinding(file, fnNode, unwrap(values[0].node), hops + 1);
|
|
258
|
+
if (values.length > 1) return null;
|
|
259
|
+
if (isParameterOf(fnNode, expr.text)) {
|
|
260
|
+
const fixture = this.fixtureDef(file, expr.text);
|
|
261
|
+
return fixture ? this.fixtureValue(fixture.file, fixture.fnNode, hops + 1) : null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { file, expr };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** clientApp, once per client expression (requests share clients). */
|
|
268
|
+
clientAppOnce(file, expr) {
|
|
269
|
+
const key = `${file}\0${expr.startIndex}:${expr.endIndex}`;
|
|
270
|
+
if (this.clientMemo.has(key)) return this.clientMemo.get(key);
|
|
271
|
+
const value = this.clientApp(file, expr, 1);
|
|
272
|
+
this.clientMemo.set(key, value);
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The app a client-constructing expression is built from. */
|
|
277
|
+
clientApp(file, clientExpr, hops) {
|
|
278
|
+
const expr = unwrap(clientExpr);
|
|
279
|
+
if (!expr || expr.type !== 'call') return null;
|
|
280
|
+
const fn = field(expr, 'function');
|
|
281
|
+
// Flask/werkzeug: `app.test_client()`.
|
|
282
|
+
if (fn?.type === 'attribute' && field(fn, 'attribute')?.text === 'test_client') {
|
|
283
|
+
return this.appKey(file, field(fn, 'object'), hops);
|
|
284
|
+
}
|
|
285
|
+
const args = field(expr, 'arguments');
|
|
286
|
+
const keyword = (call, name) => {
|
|
287
|
+
for (const arg of namedChildren(field(call, 'arguments'))) {
|
|
288
|
+
if (arg.type === 'keyword_argument' && field(arg, 'name')?.text === name) return field(arg, 'value');
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
};
|
|
292
|
+
let app = keyword(expr, 'app');
|
|
293
|
+
const transport = !app ? unwrap(keyword(expr, 'transport')) : null;
|
|
294
|
+
if (transport?.type === 'call') {
|
|
295
|
+
app = keyword(transport, 'app') ||
|
|
296
|
+
namedChildren(field(transport, 'arguments')).find(a => a.type !== 'keyword_argument');
|
|
297
|
+
}
|
|
298
|
+
if (!app && !transport) app = namedChildren(args).find(a => a.type !== 'keyword_argument' &&
|
|
299
|
+
a.type !== 'list_splat' && a.type !== 'dictionary_splat');
|
|
300
|
+
return app ? this.appKey(file, app, hops) : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Container key of an app expression: a name (fixtures followed) or an
|
|
305
|
+
* inline constructor call. An app that holds no routes of its own but
|
|
306
|
+
* wraps another (`Middleware(inner)`, `app=inner`) is served by the inner
|
|
307
|
+
* one; an app declared as a function or class serves every request
|
|
308
|
+
* itself (NO_ROUTES). null: unresolved.
|
|
309
|
+
*/
|
|
310
|
+
appKey(file, appExpr, hops) {
|
|
311
|
+
const expr = unwrap(appExpr);
|
|
312
|
+
if (!expr || hops > MAX_HOPS) return null;
|
|
313
|
+
if (expr.type === 'call') {
|
|
314
|
+
const inline = `${file}#${expr.startIndex}`;
|
|
315
|
+
if (this.served().has(inline)) return inline;
|
|
316
|
+
const inner = wrappedApp(expr);
|
|
317
|
+
return inner ? this.appKey(file, inner, hops + 1) : null;
|
|
318
|
+
}
|
|
319
|
+
if (expr.type !== 'identifier') return null;
|
|
320
|
+
const fnNode = enclosingFunctionNode(expr);
|
|
321
|
+
if (fnNode && isParameterOf(fnNode, expr.text)) {
|
|
322
|
+
const fixture = this.fixtureDef(file, expr.text);
|
|
323
|
+
if (!fixture) return null;
|
|
324
|
+
const value = this.fixtureValue(fixture.file, fixture.fnNode, hops + 1);
|
|
325
|
+
if (!value?.expr) return null;
|
|
326
|
+
return this.appKey(value.file, value.expr, hops + 1);
|
|
327
|
+
}
|
|
328
|
+
const key = pythonContainerKey(this.index, this.sess, file, expr);
|
|
329
|
+
if (key && this.served().has(key)) return key;
|
|
330
|
+
const decl = routeGraph.findDecl(this.sess, file, expr.text, expr);
|
|
331
|
+
if (decl && (decl.kind === 'function' || decl.kind === 'class')) return NO_ROUTES;
|
|
332
|
+
if (decl?.kind === 'var') {
|
|
333
|
+
// The binding in effect at the reference (sequential rebinding
|
|
334
|
+
// in one body picks the last one before it).
|
|
335
|
+
const container = decl.scope.type === 'function_definition' ? field(decl.scope, 'body') : decl.scope;
|
|
336
|
+
const values = container ? boundValues(container, expr.text, this.bindingMemo) : null;
|
|
337
|
+
if (!values || values.length === 0) return null;
|
|
338
|
+
const results = this.bindingInEffect(values, expr).map(node => {
|
|
339
|
+
const value = unwrap(node);
|
|
340
|
+
const inner = value?.type === 'call' ? wrappedApp(value) : null;
|
|
341
|
+
return inner ? this.appKey(file, inner, hops + 1) : null;
|
|
342
|
+
});
|
|
343
|
+
return results.every(r => r && r === results[0]) ? results[0] : null;
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* { appKey } for a request `receiver.verb(path)` at `line` of `file` made
|
|
350
|
+
* through an in-process client, { inProcess: true } when the client is
|
|
351
|
+
* built from an app that did not resolve, else null (not a client built
|
|
352
|
+
* from an app).
|
|
353
|
+
*/
|
|
354
|
+
resolve(file, line, receiver, name) {
|
|
355
|
+
const root = this.sess.root(file);
|
|
356
|
+
if (!root) return null;
|
|
357
|
+
const sites = requestCallNodes(root, line, receiver, name);
|
|
358
|
+
if (sites.length !== 1) return null;
|
|
359
|
+
const { receiverNode } = sites[0];
|
|
360
|
+
const fnNode = enclosingFunctionNode(receiverNode);
|
|
361
|
+
let clients = null;
|
|
362
|
+
if (fnNode) {
|
|
363
|
+
const values = boundValues(field(fnNode, 'body'), receiver, this.bindingMemo);
|
|
364
|
+
if (values === null) return null;
|
|
365
|
+
if (values.length > 0) {
|
|
366
|
+
clients = this.bindingInEffect(values, receiverNode).map(node => ({ file, expr: unwrap(node) }));
|
|
367
|
+
} else if (isParameterOf(fnNode, receiver)) {
|
|
368
|
+
const fixture = this.fixtureDef(file, receiver);
|
|
369
|
+
const value = fixture ? this.fixtureValue(fixture.file, fixture.fnNode, 1) : null;
|
|
370
|
+
clients = value ? [value] : [];
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (!clients) {
|
|
374
|
+
const values = boundValues(root, receiver, this.bindingMemo);
|
|
375
|
+
if (values && values.length > 0) {
|
|
376
|
+
clients = this.bindingInEffect(values, receiverNode).map(node => ({ file, expr: unwrap(node) }));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!clients || clients.length === 0 || clients.some(c => c.expr?.type !== 'call')) return null;
|
|
380
|
+
const keys = clients.map(c => this.clientAppOnce(c.file, c.expr));
|
|
381
|
+
if (keys.every(key => key && key === keys[0])) {
|
|
382
|
+
return keys[0] === NO_ROUTES ? { noRoutes: true } : { appKey: keys[0] };
|
|
383
|
+
}
|
|
384
|
+
return clients.some(c => this.looksBuiltFromApp(c.expr)) ? { inProcess: true } : null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The bindings that can be in effect at the request: all of them, unless
|
|
389
|
+
* every binding runs in sequence in the same body (then the last one
|
|
390
|
+
* before the request).
|
|
391
|
+
*/
|
|
392
|
+
bindingInEffect(values, refNode) {
|
|
393
|
+
if (values.length === 1 || values.some(v => !v.top)) return values.map(v => v.node);
|
|
394
|
+
const before = values.filter(v => v.node.endIndex <= refNode.startIndex &&
|
|
395
|
+
v.top.endIndex <= refNode.startIndex);
|
|
396
|
+
const containing = values.find(v => v.top.startIndex <= refNode.startIndex &&
|
|
397
|
+
v.top.endIndex >= refNode.endIndex);
|
|
398
|
+
if (containing) return [containing.node]; // `with X as c:` around the request
|
|
399
|
+
return before.length > 0 ? [before[before.length - 1].node] : values.map(v => v.node);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** A client call built from an app-shaped argument that did not
|
|
403
|
+
* resolve: `.test_client()`, `app=`/`transport=`, or a first positional
|
|
404
|
+
* argument that is a name, attribute or call (never a literal URL). */
|
|
405
|
+
looksBuiltFromApp(callExpr) {
|
|
406
|
+
const fn = field(callExpr, 'function');
|
|
407
|
+
if (fn?.type === 'attribute' && field(fn, 'attribute')?.text === 'test_client') return true;
|
|
408
|
+
const args = namedChildren(field(callExpr, 'arguments'));
|
|
409
|
+
if (args.some(arg => arg.type === 'keyword_argument' &&
|
|
410
|
+
['app', 'transport'].includes(field(arg, 'name')?.text))) return true;
|
|
411
|
+
const first = args.find(arg => arg.type !== 'keyword_argument');
|
|
412
|
+
return !!first && ['identifier', 'attribute', 'call'].includes(unwrap(first)?.type);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The app a JS in-process client (supertest `request(app)`,
|
|
418
|
+
* `request.agent(app)`) is built from (fix #397): the router value key of the
|
|
419
|
+
* client call's first argument (the route graph's key scheme: the binding the
|
|
420
|
+
* app is declared by, followed through imports), through `app.callback()`,
|
|
421
|
+
* `app.listen(..)` and `http.createServer(app)` wrappers.
|
|
422
|
+
*/
|
|
423
|
+
// Mocha-style suites share one context object: `this` in a test or hook
|
|
424
|
+
// callback is its suite's context, and a nested suite's context inherits
|
|
425
|
+
// from its parent's (fix #402).
|
|
426
|
+
const SUITE_CALLS = new Set(['describe', 'context', 'suite']);
|
|
427
|
+
const RUNNABLE_CALLS = new Set(['it', 'specify', 'test', 'before', 'beforeEach', 'after', 'afterEach']);
|
|
428
|
+
const SETUP_HOOKS = new Set(['before', 'beforeEach']);
|
|
429
|
+
|
|
430
|
+
/** `describe` / `it.only` / `before`: the framework name a call is spelled with. */
|
|
431
|
+
function frameworkCallName(call) {
|
|
432
|
+
const fn = unwrap(field(call, 'function'));
|
|
433
|
+
if (fn?.type === 'identifier') return fn.text;
|
|
434
|
+
if (fn?.type === 'member_expression') {
|
|
435
|
+
const object = unwrap(field(fn, 'object'));
|
|
436
|
+
const property = field(fn, 'property')?.text;
|
|
437
|
+
if (object?.type === 'identifier' && (property === 'only' || property === 'skip')) return object.text;
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The call a function expression is a direct argument of, with that call's framework name. */
|
|
443
|
+
function callbackCall(fn) {
|
|
444
|
+
const args = fn?.parent;
|
|
445
|
+
const call = args?.type === 'arguments' ? args.parent : null;
|
|
446
|
+
return call?.type === 'call_expression' ? { call, name: frameworkCallName(call) } : null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const isContextMember = (node, name) => node?.type === 'member_expression' &&
|
|
450
|
+
unwrap(field(node, 'object'))?.type === 'this' && field(node, 'property')?.text === name;
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* `this.<name> = value` assignments whose `this` is `fn`'s own; `configured`
|
|
454
|
+
* when `fn` calls a method of `this.<name>` (`this.app.get('/', ..)`):
|
|
455
|
+
* routes registered through the context are not in the route inventory.
|
|
456
|
+
*/
|
|
457
|
+
function contextWrites(fn, name) {
|
|
458
|
+
const out = [];
|
|
459
|
+
const body = field(fn, 'body');
|
|
460
|
+
const stack = body ? [body] : [];
|
|
461
|
+
while (stack.length > 0) {
|
|
462
|
+
const node = stack.pop();
|
|
463
|
+
if (node.type === 'function_expression' || node.type === 'function' ||
|
|
464
|
+
node.type === 'function_declaration' || node.type === 'method_definition' ||
|
|
465
|
+
node.type === 'class_declaration' || node.type === 'class') continue;
|
|
466
|
+
if (node.type === 'assignment_expression' || node.type === 'augmented_assignment_expression') {
|
|
467
|
+
if (isContextMember(unwrap(field(node, 'left')), name)) out.push(node);
|
|
468
|
+
} else if (node.type === 'call_expression') {
|
|
469
|
+
const callee = unwrap(field(node, 'function'));
|
|
470
|
+
if (callee?.type === 'member_expression' && isContextMember(unwrap(field(callee, 'object')), name)) {
|
|
471
|
+
out.configured = true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
for (const child of namedChildren(node)) stack.push(child);
|
|
475
|
+
}
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The value `this.<name>` holds in a mocha test or hook callback: the one
|
|
481
|
+
* `this.<name> = V` of the nearest enclosing suite whose `before` /
|
|
482
|
+
* `beforeEach` hooks assign it (ancestor suites are reached through context
|
|
483
|
+
* inheritance). Null when the read is not in such a callback, when no suite
|
|
484
|
+
* assigns it, when the deciding suite assigns it more than once or anywhere
|
|
485
|
+
* but its setup hooks (a test or teardown hook writes the shared context).
|
|
486
|
+
*/
|
|
487
|
+
function mochaContextValue(node) {
|
|
488
|
+
const read = unwrap(node);
|
|
489
|
+
if (read?.type !== 'member_expression' || unwrap(field(read, 'object'))?.type !== 'this') return null;
|
|
490
|
+
const name = field(read, 'property')?.type === 'property_identifier' ? field(read, 'property').text : null;
|
|
491
|
+
if (!name) return null;
|
|
492
|
+
let fn = read.parent;
|
|
493
|
+
while (fn && fn.type !== 'function_expression' && fn.type !== 'function') {
|
|
494
|
+
if (fn.type === 'function_declaration' || fn.type === 'method_definition' || fn.type === 'class_body') return null;
|
|
495
|
+
fn = fn.parent;
|
|
496
|
+
}
|
|
497
|
+
const runnable = callbackCall(fn);
|
|
498
|
+
if (!runnable || !RUNNABLE_CALLS.has(runnable.name)) return null;
|
|
499
|
+
// Suites from the innermost outward.
|
|
500
|
+
let statement = runnable.call.parent;
|
|
501
|
+
for (let depth = 0; depth < 16; depth++) {
|
|
502
|
+
const block = statement?.type === 'expression_statement' ? statement.parent : null;
|
|
503
|
+
const suiteFn = block?.type === 'statement_block' ? block.parent : null;
|
|
504
|
+
const suite = suiteFn && (suiteFn.type === 'function_expression' || suiteFn.type === 'function' ||
|
|
505
|
+
suiteFn.type === 'arrow_function') ? callbackCall(suiteFn) : null;
|
|
506
|
+
if (!suite || !SUITE_CALLS.has(suite.name)) return null;
|
|
507
|
+
const hookWrites = [];
|
|
508
|
+
let writes = 0;
|
|
509
|
+
let configured = false;
|
|
510
|
+
for (const child of namedChildren(block)) {
|
|
511
|
+
const call = child.type === 'expression_statement' ? unwrap(namedChildren(child)[0]) : null;
|
|
512
|
+
const callName = call?.type === 'call_expression' ? frameworkCallName(call) : null;
|
|
513
|
+
if (!RUNNABLE_CALLS.has(callName)) continue;
|
|
514
|
+
for (const arg of namedChildren(field(call, 'arguments'))) {
|
|
515
|
+
if (arg.type !== 'function_expression' && arg.type !== 'function') continue;
|
|
516
|
+
const found = contextWrites(arg, name);
|
|
517
|
+
writes += found.length;
|
|
518
|
+
if (found.configured) configured = true;
|
|
519
|
+
if (SETUP_HOOKS.has(callName)) hookWrites.push(...found);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// Every write in the suite's own runnables must be its one setup
|
|
523
|
+
// hook write (a test or teardown hook may replace the value), and
|
|
524
|
+
// the app must not be configured through the context.
|
|
525
|
+
if (writes > 0) {
|
|
526
|
+
if (configured || hookWrites.length !== 1 || writes !== 1 ||
|
|
527
|
+
hookWrites[0].type !== 'assignment_expression') return null;
|
|
528
|
+
return field(hookWrites[0], 'right');
|
|
529
|
+
}
|
|
530
|
+
if (configured) return null;
|
|
531
|
+
statement = suite.call.parent;
|
|
532
|
+
}
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
class JsClientResolver {
|
|
537
|
+
constructor(index, sess, graph) {
|
|
538
|
+
this.index = index;
|
|
539
|
+
this.sess = sess;
|
|
540
|
+
this.graph = graph;
|
|
541
|
+
this.keyMemo = new Map();
|
|
542
|
+
this.routeKeys = new Map();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** { jsAppKey } for the client built at [start, end) of `file`, { inProcess: true } when unresolved. */
|
|
546
|
+
resolve(file, start, end) {
|
|
547
|
+
if (!this.graph) return { inProcess: true };
|
|
548
|
+
const root = this.sess.root(file);
|
|
549
|
+
const call = root ? routeGraph.locateSpan(root, start, end, 'call_expression') : null;
|
|
550
|
+
let app = call ? namedChildren(field(call, 'arguments')).filter(n => !n.type.endsWith('comment'))[0] : null;
|
|
551
|
+
// `request(this.app)` in a mocha test: the app a setup hook of its
|
|
552
|
+
// suite stores on the shared context (fix #402).
|
|
553
|
+
app = (app && mochaContextValue(app)) || app;
|
|
554
|
+
for (let hops = 0; app && hops < MAX_HOPS; hops++) {
|
|
555
|
+
const value = unwrap(app);
|
|
556
|
+
if (value?.type !== 'call_expression') { app = value; break; }
|
|
557
|
+
const fn = unwrap(field(value, 'function'));
|
|
558
|
+
const method = fn?.type === 'member_expression' ? field(fn, 'property')?.text : null;
|
|
559
|
+
const args = namedChildren(field(value, 'arguments')).filter(n => !n.type.endsWith('comment'));
|
|
560
|
+
if (method === 'callback' || method === 'listen') app = field(fn, 'object');
|
|
561
|
+
else if ((method === 'createServer' || fn?.text === 'createServer') && args.length >= 1) {
|
|
562
|
+
app = args[args.length - 1];
|
|
563
|
+
} else { app = value; break; }
|
|
564
|
+
}
|
|
565
|
+
const key = app ? this.graph.jsRouterKey(file, app, 0) : null;
|
|
566
|
+
return key ? { jsAppKey: key } : { inProcess: true };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Whether the route graph proved the key a router value. */
|
|
570
|
+
isRouterValue(key) {
|
|
571
|
+
return !!this.graph?.evidence?.has(key);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The router key a JS route registers on, from the graph or its call's receiver; null when unknown. */
|
|
575
|
+
routeKey(route) {
|
|
576
|
+
const site = route.jsRouterSite;
|
|
577
|
+
if (!site || !this.graph) return null;
|
|
578
|
+
if (site.key) return site.key;
|
|
579
|
+
if (this.routeKeys.has(route)) return this.routeKeys.get(route);
|
|
580
|
+
let key = null;
|
|
581
|
+
const root = this.sess.root(route.absoluteFile);
|
|
582
|
+
const call = root ? routeGraph.locateSpan(root, site.start, site.end, 'call_expression') : null;
|
|
583
|
+
const fn = call ? unwrap(field(call, 'function')) : null;
|
|
584
|
+
const obj = fn?.type === 'member_expression' ? field(fn, 'object') : null;
|
|
585
|
+
if (obj) key = this.graph.jsRouterKey(route.absoluteFile, obj, 0);
|
|
586
|
+
this.routeKeys.set(route, key);
|
|
587
|
+
return key;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* true when the route's router is the app or is mounted under it, false
|
|
592
|
+
* when it provably is not, null when the route cannot be keyed. Mount
|
|
593
|
+
* edges come from the route graph; the files declaring the app and the
|
|
594
|
+
* route are walked on demand when the keys differ.
|
|
595
|
+
*/
|
|
596
|
+
routeServedBy(route, appKey) {
|
|
597
|
+
const key = this.routeKey(route);
|
|
598
|
+
if (!key) return null;
|
|
599
|
+
if (key === appKey) return true;
|
|
600
|
+
for (const file of [keyFile(appKey), keyFile(key), route.absoluteFile]) {
|
|
601
|
+
if (file && !this.graph.walked.has(file)) {
|
|
602
|
+
this.graph.enqueue(file);
|
|
603
|
+
this.keyMemo.clear();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (this.graph.queue.length > 0) this.graph.run();
|
|
607
|
+
return this.servingKeys(key).has(appKey);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The router key and every router that mounts it (transitively). */
|
|
611
|
+
servingKeys(key) {
|
|
612
|
+
let keys = this.keyMemo.get(key);
|
|
613
|
+
if (keys) return keys;
|
|
614
|
+
keys = new Set([key]);
|
|
615
|
+
const queue = [key];
|
|
616
|
+
while (queue.length > 0 && keys.size < 256) {
|
|
617
|
+
const current = queue.shift();
|
|
618
|
+
for (const edge of this.graph.edges.get(current) || []) {
|
|
619
|
+
if (edge.mounterKey && !keys.has(edge.mounterKey)) {
|
|
620
|
+
keys.add(edge.mounterKey);
|
|
621
|
+
queue.push(edge.mounterKey);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
this.keyMemo.set(key, keys);
|
|
626
|
+
return keys;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// The file a route-graph key names (`file#offset`).
|
|
631
|
+
function keyFile(key) {
|
|
632
|
+
const at = typeof key === 'string' ? key.lastIndexOf('#') : -1;
|
|
633
|
+
return at > 0 ? key.slice(0, at) : null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
module.exports = { PythonClientResolver, JsClientResolver };
|