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,561 @@
1
+ /**
2
+ * core/request-helpers.js - structural proof that a call performs an HTTP
3
+ * request (fix #366).
4
+ *
5
+ * Generated API clients and hand-written wrappers pass the request as
6
+ * configuration: `__request(OpenAPI, { method: 'POST', url: '/api/v1/x' })`,
7
+ * `(options.client ?? client).post({ url: '/api/v1/x' })`,
8
+ * `session.request(method="GET", url="/x")`. The parser records the
9
+ * configuration (`requestConfig`); this module decides whether the CALLEE is
10
+ * an HTTP request helper from its definition, never from its name:
11
+ *
12
+ * - the callee (or the receiver value it is called on) is bound to an HTTP
13
+ * client package import (axios, ky, got, undici, requests, httpx, ...),
14
+ * or is the platform `fetch`;
15
+ * - the callee is a project function whose body - following project
16
+ * function calls a few hops - performs such a call;
17
+ * - the receiver value was produced by such a factory
18
+ * (`export const client = createClient(...)` where createClient calls
19
+ * `axios.create`), possibly through `a ?? b` / `a || b` / ternaries.
20
+ *
21
+ * Anything else is unproven and stays in the "possible client requests"
22
+ * band with a reason.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const path = require('path');
28
+ const rg = require('./route-graph');
29
+
30
+ const { field, namedChildren, unwrap, findDecl } = rg;
31
+
32
+ const MAX_HOPS = 4; // project function bodies followed from a helper
33
+ const MAX_RESOLVE = 16; // value/alias resolution steps
34
+ const MAX_NODES_PER_FUNCTION = 20000;
35
+
36
+ // HTTP client packages: calling them, or methods on values they produce,
37
+ // performs HTTP. Library identities, not project names.
38
+ const JS_HTTP_PACKAGES = [
39
+ 'axios', 'ky', 'got', 'node-fetch', 'cross-fetch', 'isomorphic-fetch', 'undici',
40
+ 'superagent', 'ofetch', 'redaxios', 'wretch', 'http', 'https', 'node:http',
41
+ 'node:https', '@angular/common/http', '@hey-api/client-fetch', '@hey-api/client-axios',
42
+ '@hey-api/client-next', '@hey-api/client-nuxt', 'openapi-fetch',
43
+ ];
44
+ const JS_HTTP_GLOBAL_CALLS = new Set(['fetch']);
45
+ const JS_HTTP_GLOBAL_CTORS = new Set(['XMLHttpRequest', 'EventSource']);
46
+ const PY_HTTP_MODULES = [
47
+ 'requests', 'httpx', 'aiohttp', 'urllib3', 'urllib.request', 'http.client', 'niquests', 'treq',
48
+ ];
49
+
50
+ function isHttpPackage(spec, list) {
51
+ if (!spec) return false;
52
+ const s = String(spec);
53
+ return list.some(p => s === p || s.startsWith(p + '/') || s.startsWith(p + '.'));
54
+ }
55
+
56
+ const JS_LANGS = new Set(['javascript', 'typescript', 'tsx']);
57
+ const JS_FUNCTION_TYPES = new Set([
58
+ 'function_declaration', 'function_expression', 'function', 'arrow_function',
59
+ 'method_definition', 'generator_function_declaration', 'generator_function',
60
+ ]);
61
+
62
+ const PROVEN = Object.freeze({ proven: true });
63
+ const PY_CLIENT_TYPE = /^(TestClient|Client|AsyncClient|Session|FlaskClient|ClientSession|AsyncSession)$/;
64
+ const CLIENT_METHOD = /^(get|post|put|delete|patch|options|head|fetch|send)$|request/i;
65
+
66
+ function unknown(reason) {
67
+ return { proven: false, reason };
68
+ }
69
+
70
+ class RequestHelperProof {
71
+ constructor(index, sess) {
72
+ this.index = index;
73
+ this.sess = sess;
74
+ this.fnMemo = new Map();
75
+ }
76
+
77
+ /** Classify one call record carrying `requestConfig`. */
78
+ classify(file, call) {
79
+ const lang = this.sess.lang(file);
80
+ if (JS_LANGS.has(lang)) {
81
+ // A method call proves HTTP only as a client-shaped method on a
82
+ // client value, or through a module namespace (`api.fn(...)`):
83
+ // refuse the rest from the record, before any parse.
84
+ if (call.isMethod && !CLIENT_METHOD.test(call.name) && !call.receiverIsModule &&
85
+ !this.jsNamespaceReceiver(file, call.receiver)) {
86
+ return unknown('method-not-request');
87
+ }
88
+ const root = this.sess.root(file);
89
+ if (!root) return unknown('unparsed');
90
+ const node = locateCall(root, call.callStart, call.callEnd, 'call_expression');
91
+ if (!node) return unknown('call-not-located');
92
+ return this.jsCallee(file, unwrap(field(node, 'function')), 0);
93
+ }
94
+ if (lang === 'python') {
95
+ // Cheap refusals from the call record before any parse: a dotted
96
+ // receiver (`Model.objects.create`, `self.client.x`) is never a
97
+ // module or module-level client binding, and a bare name that
98
+ // is neither a local function nor an import is a class/builtin.
99
+ if (call.isMethod) {
100
+ const typed = call.receiverType && PY_CLIENT_TYPE.test(String(call.receiverType).split('.').pop()) &&
101
+ call.receiverTypeSource !== 'guess';
102
+ if (typed) return PROVEN;
103
+ if (!call.receiver || !/^[A-Za-z_]\w*$/.test(call.receiver) ||
104
+ call.receiver === 'self' || call.receiver === 'cls') {
105
+ return unknown('receiver-unproven');
106
+ }
107
+ // A class receiver (`RedirectView.as_view(url=...)`) is not a
108
+ // client value or module.
109
+ const recvDefs = this.index.symbols.get(call.receiver) || [];
110
+ if (recvDefs.length > 0 && recvDefs.every(d => d.type === 'class')) {
111
+ return unknown('receiver-unproven');
112
+ }
113
+ } else {
114
+ const entry = this.sess.entry(file);
115
+ const local = (entry?.symbols || []).some(sym => sym.name === call.name &&
116
+ (sym.type === 'function' || sym.type === 'method'));
117
+ const imported = (entry?.importBindings || []).some(b => (b.alias || b.name) === call.name) ||
118
+ (entry?.importAliases || []).some(a => a.local === call.name);
119
+ if (!local && !imported) return unknown('callee-unresolved');
120
+ // A project class constructor is not a request helper.
121
+ const defs = this.index.symbols.get(call.name) || [];
122
+ if (!local && defs.length > 0 && defs.every(d => d.type === 'class')) {
123
+ return unknown('constructor');
124
+ }
125
+ }
126
+ const root = this.sess.root(file);
127
+ if (!root) return unknown('unparsed');
128
+ const start = call.callSite?.start;
129
+ const node = start != null ? locateCall(root, start, start + 1, 'call') : null;
130
+ if (!node) return unknown('call-not-located');
131
+ return this.pyCallee(file, unwrap(field(node, 'function')), call, 0);
132
+ }
133
+ return unknown('language');
134
+ }
135
+
136
+ // ------------------------------------------------------------ JS/TS
137
+
138
+ /** Receiver bound by an import to a project module namespace. */
139
+ jsNamespaceReceiver(file, receiver) {
140
+ if (!receiver) return false;
141
+ const b = this.jsBinding(file, receiver);
142
+ return !!b && b.binding.kind === 'namespace';
143
+ }
144
+
145
+ jsBinding(file, name) {
146
+ const entry = this.sess.entry(file);
147
+ if (!entry) return null;
148
+ const bindings = entry.importBindings || [];
149
+ let binding = bindings.find(b => (b.alias || b.name) === name);
150
+ let original = binding ? binding.name : name;
151
+ if (!binding) {
152
+ const alias = (entry.importAliases || []).find(a => a.local === name);
153
+ if (alias) {
154
+ original = alias.original;
155
+ binding = bindings.find(b => b.name === original);
156
+ }
157
+ }
158
+ return binding ? { binding, original } : null;
159
+ }
160
+
161
+ jsImportRecord(file, binding, original) {
162
+ const entry = this.sess.entry(file);
163
+ const rel = entry?.moduleResolved?.[binding.module];
164
+ if (!rel) return null;
165
+ const target = path.join(this.index.root, rel);
166
+ if (binding.kind === 'namespace') return { file: target, namespace: true };
167
+ if (binding.kind === 'default' || original === 'default' ||
168
+ (binding.kind === 'require' && binding.defaultLike)) {
169
+ return rg.resolveJsDefaultExport(this.sess, target, 1);
170
+ }
171
+ return rg.resolveJsExport(this.sess, target, original, 1) ||
172
+ (binding.kind === 'named' ? rg.resolveJsDefaultExport(this.sess, target, 1) : null);
173
+ }
174
+
175
+ /** Proof for the function expression being called. */
176
+ jsCallee(file, fn, depth, factory = false) {
177
+ if (!fn || depth > MAX_RESOLVE) return unknown('depth');
178
+ if (fn.type === 'identifier') {
179
+ const decl = findDecl(this.sess, file, fn.text, fn);
180
+ if (decl) return this.jsDeclCallable(file, decl, depth);
181
+ const b = this.jsBinding(file, fn.text);
182
+ if (b) return this.jsImportedCallable(file, b, depth);
183
+ if (JS_HTTP_GLOBAL_CALLS.has(fn.text)) return PROVEN;
184
+ return unknown('callee-unresolved');
185
+ }
186
+ if (fn.type === 'member_expression') {
187
+ const obj = unwrap(field(fn, 'object'));
188
+ const prop = field(fn, 'property')?.text;
189
+ // Namespace import of a project module: `api.request({...})`.
190
+ if (obj && obj.type === 'identifier' && prop && !findDecl(this.sess, file, obj.text, obj)) {
191
+ const b = this.jsBinding(file, obj.text);
192
+ if (b && isHttpPackage(b.binding.module, JS_HTTP_PACKAGES)) return PROVEN;
193
+ const rec = b && this.jsImportRecord(file, b.binding, b.original);
194
+ if (rec && rec.namespace) {
195
+ const exported = rg.resolveJsExport(this.sess, rec.file, prop, 1);
196
+ return exported ? this.jsRecordCallable(exported, depth + 1)
197
+ : unknown('callee-unresolved');
198
+ }
199
+ }
200
+ // A method on an HTTP client VALUE performs a request only when
201
+ // the method reads as one (`client.post`, `instance.request`);
202
+ // a server built on an HTTP stack (`app.route(...)`) is not a
203
+ // client.
204
+ // A factory call on a client value (`axios.create()`) yields a
205
+ // client value too.
206
+ if (!factory && (!prop || !CLIENT_METHOD.test(prop))) return unknown('method-not-request');
207
+ return this.jsValue(file, obj, depth + 1);
208
+ }
209
+ if (fn.type === 'call_expression') return this.jsValue(file, fn, depth + 1);
210
+ return unknown('callee-unresolved');
211
+ }
212
+
213
+ jsDeclCallable(file, decl, depth) {
214
+ if (decl.kind === 'function') return this.jsFunction(file, decl.valueNode, 0);
215
+ if (decl.kind === 'param') return this.jsParam(file, decl, depth + 1);
216
+ if ((decl.kind === 'const' || decl.kind === 'var') && decl.valueNode && decl.count === 1) {
217
+ const v = unwrap(decl.valueNode);
218
+ if (JS_FUNCTION_TYPES.has(v.type)) return this.jsFunction(file, v, 0);
219
+ if (v.type === 'identifier' || v.type === 'member_expression') return this.jsCallee(file, v, depth + 1);
220
+ return this.jsValue(file, v, depth + 1);
221
+ }
222
+ return unknown('callee-unresolved');
223
+ }
224
+
225
+ jsImportedCallable(file, b, depth) {
226
+ if (isHttpPackage(b.binding.module, JS_HTTP_PACKAGES)) return PROVEN;
227
+ const rec = this.jsImportRecord(file, b.binding, b.original);
228
+ if (!rec) return unknown('external-helper');
229
+ return this.jsRecordCallable(rec, depth + 1);
230
+ }
231
+
232
+ jsRecordCallable(rec, depth) {
233
+ if (!rec || rec.namespace) return unknown('callee-unresolved');
234
+ if (rec.decl) return this.jsDeclCallable(rec.file, rec.decl, depth + 1);
235
+ if (rec.valueNode) {
236
+ const v = unwrap(rec.valueNode);
237
+ if (JS_FUNCTION_TYPES.has(v.type)) return this.jsFunction(rec.file, v, 0);
238
+ return this.jsValue(rec.file, v, depth + 1);
239
+ }
240
+ if (rec.importedName) {
241
+ const b = this.jsBinding(rec.file, rec.importedName);
242
+ return b ? this.jsImportedCallable(rec.file, b, depth + 1) : unknown('callee-unresolved');
243
+ }
244
+ return unknown('callee-unresolved');
245
+ }
246
+
247
+ /** `const x = require('<spec>')` - the required module specifier. */
248
+ requireSpecOf(decl) {
249
+ const v = decl && decl.valueNode ? unwrap(decl.valueNode) : null;
250
+ if (!v || v.type !== 'call_expression') return null;
251
+ const f = field(v, 'function');
252
+ if (!f || f.type !== 'identifier' || f.text !== 'require') return null;
253
+ const arg = namedChildren(field(v, 'arguments'))[0];
254
+ return arg && arg.type === 'string' ? rg.readLiteral(arg, () => '') : null;
255
+ }
256
+
257
+ /** Does this VALUE denote an HTTP client (so methods on it perform HTTP)? */
258
+ jsValue(file, expr, depth) {
259
+ const e = unwrap(expr);
260
+ if (!e || depth > MAX_RESOLVE) return unknown('depth');
261
+ switch (e.type) {
262
+ case 'identifier': {
263
+ const decl = findDecl(this.sess, file, e.text, e);
264
+ if (decl) {
265
+ if (decl.kind === 'param') return this.jsParam(file, decl, depth + 1);
266
+ if ((decl.kind === 'const' || decl.kind === 'var') && decl.valueNode && decl.count === 1) {
267
+ return this.jsValue(file, decl.valueNode, depth + 1);
268
+ }
269
+ return unknown('receiver-unproven');
270
+ }
271
+ const b = this.jsBinding(file, e.text);
272
+ if (!b) return unknown('receiver-unproven');
273
+ if (isHttpPackage(b.binding.module, JS_HTTP_PACKAGES)) return PROVEN;
274
+ const rec = this.jsImportRecord(file, b.binding, b.original);
275
+ if (!rec) return unknown('external-helper');
276
+ if (rec.decl) {
277
+ return rec.decl.valueNode && rec.decl.count === 1
278
+ ? this.jsValue(rec.file, rec.decl.valueNode, depth + 1)
279
+ : unknown('receiver-unproven');
280
+ }
281
+ return rec.valueNode ? this.jsValue(rec.file, rec.valueNode, depth + 1)
282
+ : unknown('receiver-unproven');
283
+ }
284
+ case 'binary_expression': {
285
+ const op = e.child(1)?.type;
286
+ if (op !== '??' && op !== '||') return unknown('receiver-unproven');
287
+ return this.anyProven([field(e, 'left'), field(e, 'right')], n => this.jsValue(file, n, depth + 1));
288
+ }
289
+ case 'ternary_expression':
290
+ return this.anyProven([field(e, 'consequence'), field(e, 'alternative')],
291
+ n => this.jsValue(file, n, depth + 1));
292
+ case 'call_expression': {
293
+ // A factory result: `createClient(...)`, `axios.create(...)`.
294
+ const callee = unwrap(field(e, 'function'));
295
+ if (callee && callee.type === 'identifier' && callee.text === 'require') {
296
+ const spec = this.requireSpecOf({ valueNode: e });
297
+ return isHttpPackage(spec, JS_HTTP_PACKAGES) ? PROVEN : unknown('receiver-unproven');
298
+ }
299
+ return this.jsCallee(file, callee, depth + 1, true);
300
+ }
301
+ default:
302
+ return unknown('receiver-unproven');
303
+ }
304
+ }
305
+
306
+ anyProven(nodes, proof) {
307
+ let last = unknown('receiver-unproven');
308
+ for (const n of nodes) {
309
+ if (!n) continue;
310
+ const r = proof(n);
311
+ if (r.proven) return r;
312
+ last = r;
313
+ }
314
+ return last;
315
+ }
316
+
317
+ /** A parameter proves a client only through its declared type or default. */
318
+ jsParam(file, decl, depth) {
319
+ const holder = decl.nameNode.parent;
320
+ const typeNode = decl.typeNode || (holder ? field(holder, 'type') : null);
321
+ if (typeNode) {
322
+ const m = typeNode.text.replace(/^:\s*/, '').match(/^([A-Za-z_$][\w$]*)/);
323
+ const b = m ? this.jsBinding(file, m[1]) : null;
324
+ if (b && isHttpPackage(b.binding.module, JS_HTTP_PACKAGES)) return PROVEN;
325
+ }
326
+ const dflt = holder ? (field(holder, 'value') || field(holder, 'right')) : null;
327
+ if (dflt) return this.jsValue(file, dflt, depth + 1);
328
+ return unknown('receiver-unproven');
329
+ }
330
+
331
+ /** A project function performs HTTP when its body (closures included)
332
+ * calls an HTTP primitive, directly or through project functions. */
333
+ jsFunction(file, fnNode, hops) {
334
+ if (!fnNode) return unknown('callee-unresolved');
335
+ const key = `${file}#${fnNode.startIndex}`;
336
+ if (this.fnMemo.has(key)) return this.fnMemo.get(key) || unknown('recursion');
337
+ this.fnMemo.set(key, null);
338
+ let result = unknown('helper-performs-no-http');
339
+ {
340
+ const stack = [fnNode];
341
+ let visited = 0;
342
+ const followUps = [];
343
+ while (stack.length > 0 && visited++ < MAX_NODES_PER_FUNCTION) {
344
+ const node = stack.pop();
345
+ if (node.type === 'new_expression') {
346
+ const ctor = unwrap(field(node, 'constructor'));
347
+ if (ctor && ctor.type === 'identifier' && JS_HTTP_GLOBAL_CTORS.has(ctor.text) &&
348
+ !findDecl(this.sess, file, ctor.text, ctor)) { result = PROVEN; break; }
349
+ } else if (node.type === 'call_expression') {
350
+ const callee = unwrap(field(node, 'function'));
351
+ if (callee && this.jsPrimitive(file, callee)) { result = PROVEN; break; }
352
+ if (callee && callee.type === 'identifier') followUps.push(callee);
353
+ }
354
+ for (let i = node.namedChildCount - 1; i >= 0; i--) stack.push(node.namedChild(i));
355
+ }
356
+ if (!result.proven && hops < MAX_HOPS) {
357
+ for (const callee of followUps) {
358
+ const r = this.jsLocalCallee(file, callee, hops + 1);
359
+ if (r.proven) { result = r; break; }
360
+ }
361
+ }
362
+ }
363
+ this.fnMemo.set(key, result);
364
+ return result;
365
+ }
366
+
367
+ /** fetch / HTTP-package call inside a function body. */
368
+ jsPrimitive(file, callee) {
369
+ if (callee.type === 'identifier') {
370
+ const decl = findDecl(this.sess, file, callee.text, callee);
371
+ if (decl) return isHttpPackage(this.requireSpecOf(decl), JS_HTTP_PACKAGES);
372
+ if (JS_HTTP_GLOBAL_CALLS.has(callee.text) && !this.jsBinding(file, callee.text)) return true;
373
+ const b = this.jsBinding(file, callee.text);
374
+ return !!b && isHttpPackage(b.binding.module, JS_HTTP_PACKAGES);
375
+ }
376
+ if (callee.type === 'member_expression') {
377
+ let root = unwrap(field(callee, 'object'));
378
+ while (root && root.type === 'member_expression') root = unwrap(field(root, 'object'));
379
+ if (!root || root.type !== 'identifier') return false;
380
+ const decl = findDecl(this.sess, file, root.text, root);
381
+ if (decl) return isHttpPackage(this.requireSpecOf(decl), JS_HTTP_PACKAGES);
382
+ const b = this.jsBinding(file, root.text);
383
+ return !!b && isHttpPackage(b.binding.module, JS_HTTP_PACKAGES);
384
+ }
385
+ return false;
386
+ }
387
+
388
+ /** A bare call inside a helper body: follow it into project functions. */
389
+ jsLocalCallee(file, callee, depth) {
390
+ const decl = findDecl(this.sess, file, callee.text, callee);
391
+ if (decl) {
392
+ if (decl.kind === 'function') return this.jsFunction(file, decl.valueNode, depth);
393
+ if ((decl.kind === 'const' || decl.kind === 'var') && decl.valueNode && decl.count === 1) {
394
+ const v = unwrap(decl.valueNode);
395
+ if (JS_FUNCTION_TYPES.has(v.type)) return this.jsFunction(file, v, depth);
396
+ }
397
+ return unknown('callee-unresolved');
398
+ }
399
+ const b = this.jsBinding(file, callee.text);
400
+ if (!b) return unknown('callee-unresolved');
401
+ const rec = this.jsImportRecord(file, b.binding, b.original);
402
+ if (!rec || rec.namespace) return unknown('callee-unresolved');
403
+ if (rec.decl && rec.decl.kind === 'function') return this.jsFunction(rec.file, rec.decl.valueNode, depth);
404
+ const v = rec.decl?.valueNode || rec.valueNode;
405
+ const u = v ? unwrap(v) : null;
406
+ return u && JS_FUNCTION_TYPES.has(u.type) ? this.jsFunction(rec.file, u, depth)
407
+ : unknown('callee-unresolved');
408
+ }
409
+
410
+ // ------------------------------------------------------------ Python
411
+
412
+ pyBinding(file, name) {
413
+ const entry = this.sess.entry(file);
414
+ if (!entry) return null;
415
+ const bindings = entry.importBindings || [];
416
+ let binding = bindings.find(b => (b.alias || b.name) === name);
417
+ if (!binding) {
418
+ const alias = (entry.importAliases || []).find(a => a.local === name);
419
+ if (alias) binding = bindings.find(b => b.name === alias.original);
420
+ }
421
+ return binding || null;
422
+ }
423
+
424
+ pyBindingIsHttp(binding) {
425
+ if (!binding) return false;
426
+ const mod = String(binding.module || '');
427
+ return isHttpPackage(mod, PY_HTTP_MODULES) ||
428
+ (binding.kind !== 'from' && isHttpPackage(binding.name, PY_HTTP_MODULES));
429
+ }
430
+
431
+ pyCallee(file, fn, call, depth) {
432
+ if (!fn || depth > MAX_RESOLVE) return unknown('depth');
433
+ if (fn.type === 'identifier') {
434
+ const decl = findDecl(this.sess, file, fn.text, fn);
435
+ if (decl && decl.kind === 'function') return this.pyFunction(file, decl.valueNode, 0);
436
+ if (decl) return unknown('callee-unresolved');
437
+ const b = this.pyBinding(file, fn.text);
438
+ if (this.pyBindingIsHttp(b)) return PROVEN;
439
+ return this.pyValueCallable(pyImportedFunction(this.sess, file, fn.text, 0));
440
+ }
441
+ if (fn.type === 'attribute') {
442
+ if (call && call.receiverType && PY_CLIENT_TYPE.test(String(call.receiverType).split('.').pop()) &&
443
+ call.receiverTypeSource !== 'guess') {
444
+ return PROVEN;
445
+ }
446
+ const obj = unwrap(field(fn, 'object'));
447
+ const attr = field(fn, 'attribute')?.text;
448
+ if (obj && obj.type === 'identifier') {
449
+ const decl = findDecl(this.sess, file, obj.text, obj);
450
+ if (!decl) {
451
+ const b = this.pyBinding(file, obj.text);
452
+ if (this.pyBindingIsHttp(b)) return PROVEN;
453
+ const v = rg.lookupValue(this.sess, file, obj, 1);
454
+ if (v && v.kind === 'module' && attr) {
455
+ return this.pyValueCallable(topLevelFunction(this.sess, v.file, attr) ||
456
+ pyImportedFunction(this.sess, v.file, attr, 0));
457
+ }
458
+ return unknown('receiver-unproven');
459
+ }
460
+ if (decl.valueNode && decl.count === 1) return this.pyValue(file, decl.valueNode, depth + 1);
461
+ }
462
+ return unknown('receiver-unproven');
463
+ }
464
+ return unknown('callee-unresolved');
465
+ }
466
+
467
+ /** A bare call inside a helper body: follow it into project functions. */
468
+ pyFollow(file, callee, hops) {
469
+ const decl = findDecl(this.sess, file, callee.text, callee);
470
+ const v = decl ? (decl.kind === 'function' ? { kind: 'function', file, node: decl.valueNode } : null)
471
+ : pyImportedFunction(this.sess, file, callee.text, 0);
472
+ return v ? this.pyFunction(v.file, v.node, hops) : unknown('callee-unresolved');
473
+ }
474
+
475
+ pyValueCallable(v) {
476
+ if (!v) return unknown('external-helper');
477
+ if (v.kind === 'function') return this.pyFunction(v.file, v.node, 0);
478
+ return unknown('callee-unresolved');
479
+ }
480
+
481
+ /** `s = requests.Session()` / `client = httpx.Client()` */
482
+ pyValue(file, expr, depth) {
483
+ const e = unwrap(expr);
484
+ if (!e || e.type !== 'call' || depth > MAX_RESOLVE) return unknown('receiver-unproven');
485
+ const callee = unwrap(field(e, 'function'));
486
+ if (!callee) return unknown('receiver-unproven');
487
+ let root = callee;
488
+ while (root && root.type === 'attribute') root = unwrap(field(root, 'object'));
489
+ if (root && root.type === 'identifier' && !findDecl(this.sess, file, root.text, root) &&
490
+ this.pyBindingIsHttp(this.pyBinding(file, root.text))) return PROVEN;
491
+ return unknown('receiver-unproven');
492
+ }
493
+
494
+ pyFunction(file, fnNode, hops) {
495
+ if (!fnNode) return unknown('callee-unresolved');
496
+ const key = `${file}#${fnNode.startIndex}`;
497
+ if (this.fnMemo.has(key)) return this.fnMemo.get(key) || unknown('recursion');
498
+ this.fnMemo.set(key, null);
499
+ let result = unknown('helper-performs-no-http');
500
+ const followUps = [];
501
+ const stack = [fnNode];
502
+ let visited = 0;
503
+ while (stack.length > 0 && visited++ < MAX_NODES_PER_FUNCTION) {
504
+ const node = stack.pop();
505
+ if (node.type === 'call') {
506
+ const callee = unwrap(field(node, 'function'));
507
+ let root = callee;
508
+ while (root && root.type === 'attribute') root = unwrap(field(root, 'object'));
509
+ if (root && root.type === 'identifier' && !findDecl(this.sess, file, root.text, root) &&
510
+ this.pyBindingIsHttp(this.pyBinding(file, root.text))) { result = PROVEN; break; }
511
+ if (callee && callee.type === 'identifier') followUps.push(callee);
512
+ }
513
+ for (let i = node.namedChildCount - 1; i >= 0; i--) stack.push(node.namedChild(i));
514
+ }
515
+ if (!result.proven && hops < MAX_HOPS) {
516
+ for (const callee of followUps) {
517
+ const r = this.pyFollow(file, callee, hops + 1);
518
+ if (r.proven) { result = r; break; }
519
+ }
520
+ }
521
+ this.fnMemo.set(key, result);
522
+ return result;
523
+ }
524
+ }
525
+
526
+ function topLevelFunction(sess, file, name) {
527
+ const root = sess.root(file);
528
+ if (!root) return null;
529
+ const decl = rg.scopeDecls(sess, file, sess.lang(file), root).get(name);
530
+ return decl && decl.kind === 'function' ? { kind: 'function', file, node: decl.valueNode } : null;
531
+ }
532
+
533
+ /** A function a Python name imports (`from pkg.mod import helper`),
534
+ * chasing re-exports. */
535
+ function pyImportedFunction(sess, file, name, depth) {
536
+ if (depth > MAX_HOPS) return null;
537
+ const entry = sess.entry(file);
538
+ const bindings = entry?.importBindings || [];
539
+ let binding = bindings.find(b => (b.alias || b.name) === name);
540
+ if (!binding) {
541
+ const alias = (entry?.importAliases || []).find(a => a.local === name);
542
+ if (alias) binding = bindings.find(b => b.name === alias.original);
543
+ }
544
+ if (!binding || binding.kind !== 'from') return null;
545
+ const rel = entry.moduleResolved?.[String(binding.module || '')];
546
+ if (!rel) return null;
547
+ const target = path.join(sess.index.root, rel);
548
+ return topLevelFunction(sess, target, binding.name) ||
549
+ pyImportedFunction(sess, target, binding.name, depth + 1);
550
+ }
551
+
552
+ /** The call node of `type` starting at `start` (JS callStart / Python name). */
553
+ function locateCall(root, start, end, type) {
554
+ let node = root.descendantForIndex(start, Math.max(start, end - 1));
555
+ while (node && !(node.type === type && node.startIndex <= start && node.endIndex >= end)) {
556
+ node = node.parent;
557
+ }
558
+ return node;
559
+ }
560
+
561
+ module.exports = { RequestHelperProof, JS_HTTP_PACKAGES, PY_HTTP_MODULES };