ucn 5.5.2 → 5.5.3
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/references/commands.md +3 -3
- package/README.md +2 -0
- package/cli/index.js +1 -0
- package/core/bridge.js +15 -65
- package/core/client-receivers.js +189 -0
- package/core/deadcode.js +26 -1
- package/core/inprocess-clients.js +41 -15
- package/core/output/public.js +1 -0
- package/core/output/reporting.js +6 -0
- package/core/reflection.js +15 -0
- package/core/request-helpers.js +63 -7
- package/package.json +2 -2
|
@@ -62,7 +62,7 @@ Use text search (grep/ripgrep) for simple literals, error messages, configuratio
|
|
|
62
62
|
| `--depth=N` | Trace, dependency, or test traversal depth. |
|
|
63
63
|
| `--direction=<value>` | Trace (`callees`/`callers`) or dependency (`imports`/`importers`/`both`) direction. |
|
|
64
64
|
| `--all` | Lift result and formatter caps where a command supports it; the truncation notice says when it does. |
|
|
65
|
-
| `--max-chars=N` | Text budget in characters (10K targeted / 3K broad by default; 100K ceiling), including notices and trust lines. Trust lines are reserved first; a limit too small for them withholds the answer and names the budget that carries them. MCP: `max_chars`. JSON is
|
|
65
|
+
| `--max-chars=N` | Text budget in characters (10K targeted / 3K broad by default; 100K ceiling), including notices and trust lines. Trust lines are reserved first; a limit too small for them withholds the answer and names the budget that carries them. MCP: `max_chars`. CLI JSON is uncapped even with an explicit budget; `meta.maxChars` is `"not-applied"`. |
|
|
66
66
|
| `--compact` / `--no-compact` | Token-efficient or full presentation (`show`/`impact` default compact, `usages` full). |
|
|
67
67
|
| `--range=N-M` | Explicit line range with `source <file>` or `source --file=<path>`. |
|
|
68
68
|
| `--json` | Stable `{ meta, data }` envelope; `meta.contract` carries the truth boundary, decision safety, and next actions. |
|
|
@@ -177,7 +177,7 @@ In JS/TS and Python, where an import runs the imported module, cycles distinguis
|
|
|
177
177
|
|
|
178
178
|
Route tables count too: Django URLconfs (`path`/`re_path`/`include`, composed from `ROOT_URLCONF`), DRF routers, Starlette `Route`/`Mount`/`Host` lists, Flask `add_url_rule`, aiohttp `add_routes`/`router.add_*`. Methods come from the view (`require_*` decorators, class-view handlers, viewset actions); an unresolved view serves `ALL`. Tags: `[regex]` (a `re_path` shown in path form, an unconvertible part as `{?...}`), `[drf-router]`, `[static]`, and `[include]`/`[mount]` for a target that cannot be resolved (shown as `prefix/{?target}`, matched only as uncertain). Django composes as Django does: an include prefix and the included pattern are concatenated as written (`path("api", include(...))` + `"items/"` serves `/apiitems/`); a `re_path` without `^` or, for a view, without `$` matches any text there, shown as `{?*}` (matched as a catch-all); a regex group that can match `/` is written `<path:name>`.
|
|
179
179
|
|
|
180
|
-
Client receivers
|
|
180
|
+
Client receivers require HTTP import, constructor, or factory evidence; names such as `client`, `session`, or `api` alone stay possible and never create bridges. Python follows returned clients and fixture chains, including imported/class fixtures and fixtures returning factories; JavaScript/TypeScript follows imported client values and factories such as `axios.create`, `ky.create`, and `got.extend`. Request-configuration calls (`request(OpenAPI, { method, url })`, `client.post({ url })`, `session.request(method=..., url=...)`, generated OpenAPI SDKs) count as clients when the callee's definition reaches an HTTP call (fetch, axios, requests, ...). Request-shaped calls on an unrecognized receiver or an unproven helper are listed under `Possible client requests` (JSON `uncertainRequests`, with the reason), counted in `meta`, never in the inventory.
|
|
181
181
|
|
|
182
182
|
A request through an in-process test client built from an app (`TestClient(app)`, a fixture's client, `app.test_client()`, `AsyncClient(transport=ASGITransport(app=app))`) bridges only to routes of that app or mounted under it; an app declared as a function or class serves the request itself and matches no route. A supertest client (`request(app).get('/x')`, `request.agent(app)`, a client bound to a local; the name bound to the `supertest` module, whatever it is called) is one too, its app followed through imports and `app.callback()` / `http.createServer(app)`; `request(this.app)` in a mocha test or hook takes the app its suite's `before`/`beforeEach` hook stores on `this` (the nearest suite assigning it, which writes it nowhere else and registers nothing through `this.app`). When the app does not resolve, every path match stays, marked `[unscoped]` (JSON `unscoped: true`). A HEAD request matches a GET route where the framework serves HEAD with GET (Starlette, Flask, Express).
|
|
183
183
|
|
|
@@ -187,7 +187,7 @@ Route paths include router mount prefixes composed from the code: FastAPI `inclu
|
|
|
187
187
|
|
|
188
188
|
Constructor declarations are not claims of their own (`N constructor(s) not listed`, JSON `excludedConstructors`): reflection, frameworks and the initializer the compiler generates when one is deleted can depend on them without a construction call. An unused type still appears as a whole-type candidate.
|
|
189
189
|
|
|
190
|
-
`deadcode` is a candidate generator; every candidate is review-only. Computed dispatch such as `handlers[key]()` is a reported blind spot; registry members reached by a modeled computed receiver are withheld. Statically named reflection such as `getattr(obj, "run")` is positive liveness evidence, so every matching member spelling is withheld. A reflective name built from literal fragments around a runtime part (`getattr(self, "_get_%s_perms" % src)`, f-strings, `.format`, `"get" + name` in `getMethod`, `MethodByName(fmt.Sprintf(...))`, `obj["on" + evt]`) is a pattern: members it can spell on a receiver that may hold their class are withheld and counted per pattern, and `show`/`impact`/`plan` list the site as an unverified `reflection-pattern` caller. `eval(name)`/`globals()[name]` reach only their own module's top-level names; a fully dynamic one (`eval(code)`) withholds them all and is listed as a caller of none. A reflective name that is a parameter of the enclosing function, never reassigned (`registry[type](..)` in a call, `getattr(self, name)`, `getMethod(name)`), takes the string literals the function's callers pass at that position: those members are withheld and listed as `reflection-pattern` callers. Reflection with no specific literal part is counted and warned because it cannot be attributed to one member.
|
|
190
|
+
`deadcode` is a candidate generator; every candidate is review-only. Computed dispatch such as `handlers[key]()` is a reported blind spot; registry members reached by a modeled computed receiver are withheld. Statically named reflection such as `getattr(obj, "run")` is positive liveness evidence, so every matching member spelling is withheld. A reflective name built from literal fragments around a runtime part (`getattr(self, "_get_%s_perms" % src)`, f-strings, `.format`, `"get" + name` in `getMethod`, `MethodByName(fmt.Sprintf(...))`, `obj["on" + evt]`) is a pattern: members it can spell on a receiver that may hold their class are withheld and counted per pattern, and `show`/`impact`/`plan` list the site as an unverified `reflection-pattern` caller. `eval(name)`/`globals()[name]` reach only their own module's top-level names; a fully dynamic one (`eval(code)`) withholds them all and is listed as a caller of none. A reflective name that is a parameter of the enclosing function, never reassigned (`registry[type](..)` in a call, `getattr(self, name)`, `getMethod(name)`), takes the string literals the function's callers pass at that position: those members are withheld and listed as `reflection-pattern` callers. Reflection with no specific literal part is counted and warned because it cannot be attributed to one member. When a file contains dynamic reflection, `deadcode` also conservatively withholds candidates in that file whose exact spelling occurs as an AST string literal. Text and JSON (`reflection.withheldByTable`) list each name and literal location. This does not prove flow: even an unrelated literal in that file can withhold a candidate; another file without dynamic reflection is unaffected.
|
|
191
191
|
|
|
192
192
|
Members the runtime or compiler invokes by name are hidden as runtime callbacks: Python dunders and Enum hooks (`_generate_next_value_`, `_missing_`) on Enum subclasses, Java serialization callbacks and `serialVersionUID` on Serializable types (also when written `implements java.io.Serializable` or inherited) and, as possible members with the protocol's signature, on classes that derive from another out-of-project type (`extends IOException`, `extends ArrayList`; `plan` keeps their edits and lists the definition for review), C# pattern members (`GetEnumerator`, `GetAwaiter`, `Deconstruct`), C# compiler-required types the project declares for a feature it uses (`System.Runtime.CompilerServices.IsExternalInit` with `init` accessors or positional records, `RequiredMemberAttribute` with `required` members, `System.Index`/`Range` with `^i`/`a..b`; unused when the feature is absent), JS `toJSON`/`then`/`[Symbol.*]`, C++ `begin`/`end`. Unknown decorators/annotations and member-assigned event handlers are also withheld by default because they can be the registration itself. Members of a class deriving from an outside base (underscore hooks such as a stream's `_transform` included), members of an object literal declared with an outside type (`ProxyHandler` traps) and members of impls of an outside Rust trait (`impl FromIterator for A`, however many project types implement it) may be called by that outside code and are hidden as external-contract surface.
|
|
193
193
|
|
package/README.md
CHANGED
|
@@ -220,6 +220,8 @@ records carry a tab-separated reason. An empty `--lines` listing exits 1, an
|
|
|
220
220
|
error exits 2, and a successful listing exits 0. `--lines` supports `find`, `show`,
|
|
221
221
|
`usages`, `search`, and `impact`; `show --lines` lists callers by default.
|
|
222
222
|
Use `--json` when the script needs structured fields or a tree result.
|
|
223
|
+
CLI JSON is uncapped by `--max-chars`, even when explicitly supplied;
|
|
224
|
+
`meta.maxChars` reports `"not-applied"`. Use command limits to narrow the result.
|
|
223
225
|
|
|
224
226
|
Listings are deterministic for a given source tree: a fresh build, a cache
|
|
225
227
|
load, and an incremental rebuild return the same records in the same order.
|
package/cli/index.js
CHANGED
|
@@ -963,6 +963,7 @@ Common flags:
|
|
|
963
963
|
--range=N-M (source <file> --range=N-M, or --file=PATH)
|
|
964
964
|
--base=REF --staged --no-cache --clear-cache [--all] --max-files=N --workers=N
|
|
965
965
|
--max-chars=N (text characters, not UTF-8 bytes; default 10K targeted / 3K broad, ceiling 100K)
|
|
966
|
+
JSON is uncapped; meta.maxChars is "not-applied", even with an explicit budget.
|
|
966
967
|
--lines find/usages/search/show/impact: grep -n shape, one path:line:text
|
|
967
968
|
record per output line; usages combines occurrences on each source line
|
|
968
969
|
(tags after a tab: # unverified: <reason>, # import,
|
package/core/bridge.js
CHANGED
|
@@ -2279,53 +2279,6 @@ function extractNextjsRoutes(index) {
|
|
|
2279
2279
|
* Detect HTTP client requests across the project.
|
|
2280
2280
|
* Cached on `index._endpointsCache.clientRequests`.
|
|
2281
2281
|
*/
|
|
2282
|
-
// Python HTTP client types whose instances are receivers of request calls.
|
|
2283
|
-
// A receiver typed to one of these (with-binding, constructor assignment) or
|
|
2284
|
-
// bound to a pytest fixture that constructs one is a client by evidence, not
|
|
2285
|
-
// by name (fix #349: 145 of 625 real `tc.get("/api/...")` sites on one repo
|
|
2286
|
-
// were invisible because the receiver was not literally named `client`).
|
|
2287
|
-
const PY_CLIENT_TYPES = new Set([
|
|
2288
|
-
'TestClient', 'Client', 'AsyncClient', 'Session', 'FlaskClient',
|
|
2289
|
-
'ClientSession', 'AsyncSession', 'HTTPConnection', 'HTTPSConnection',
|
|
2290
|
-
]);
|
|
2291
|
-
const PY_CLIENT_FACTORIES = new Set(['test_client', 'Session', 'Client', 'AsyncClient']);
|
|
2292
|
-
const PY_REQUEST_METHODS = /^(get|post|put|delete|patch|options|head|request)$/;
|
|
2293
|
-
|
|
2294
|
-
function _pythonClientFixtures(index) {
|
|
2295
|
-
if (index._endpointsCache?.pyClientFixtures) return index._endpointsCache.pyClientFixtures;
|
|
2296
|
-
const fixtures = new Set();
|
|
2297
|
-
for (const [filePath, fileEntry] of index.files) {
|
|
2298
|
-
if (fileEntry.language !== 'python') continue;
|
|
2299
|
-
const fixtureDefs = (fileEntry.symbols || []).filter(s =>
|
|
2300
|
-
(s.decorators || []).some(d => /(^|\.)fixture$/.test(String(d))));
|
|
2301
|
-
if (fixtureDefs.length === 0) continue;
|
|
2302
|
-
const calls = getCachedCalls(index, filePath) || [];
|
|
2303
|
-
for (const def of fixtureDefs) {
|
|
2304
|
-
const constructsClient = calls.some(c =>
|
|
2305
|
-
c.enclosingFunction?.name === def.name &&
|
|
2306
|
-
c.enclosingFunction?.startLine === def.startLine &&
|
|
2307
|
-
(PY_CLIENT_TYPES.has(c.name) || (c.isMethod && PY_CLIENT_FACTORIES.has(c.name))));
|
|
2308
|
-
if (constructsClient) fixtures.add(def.name);
|
|
2309
|
-
}
|
|
2310
|
-
}
|
|
2311
|
-
if (!index._endpointsCache) index._endpointsCache = {};
|
|
2312
|
-
index._endpointsCache.pyClientFixtures = fixtures;
|
|
2313
|
-
return fixtures;
|
|
2314
|
-
}
|
|
2315
|
-
|
|
2316
|
-
function _pythonClientReceiver(index, fileEntry, call, fixtures) {
|
|
2317
|
-
if (!call.isMethod || !call.receiver || !PY_REQUEST_METHODS.test(call.name)) return null;
|
|
2318
|
-
if (call.receiverType && PY_CLIENT_TYPES.has(String(call.receiverType).split('.').pop())) {
|
|
2319
|
-
return 'python-client';
|
|
2320
|
-
}
|
|
2321
|
-
if (fixtures.size === 0 || !fixtures.has(call.receiver)) return null;
|
|
2322
|
-
const fn = call.enclosingFunction;
|
|
2323
|
-
if (!fn) return null;
|
|
2324
|
-
const sym = (fileEntry.symbols || []).find(s => s.name === fn.name && s.startLine === fn.startLine);
|
|
2325
|
-
const params = String(sym?.params || '').split(',').map(p => p.trim().split(/[:=]/)[0].trim());
|
|
2326
|
-
return params.includes(call.receiver) ? 'pytest-client-fixture' : null;
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
2282
|
// `fastify().route({...})` - a route declared on a freshly built server.
|
|
2330
2283
|
const JS_SERVER_FACTORY_CALL = /^(fastify|Fastify|express|Hono|Koa)$/;
|
|
2331
2284
|
|
|
@@ -2347,7 +2300,7 @@ function extractClientRequests(index) {
|
|
|
2347
2300
|
}
|
|
2348
2301
|
const requests = [];
|
|
2349
2302
|
const uncertain = [];
|
|
2350
|
-
const
|
|
2303
|
+
const clientProof = new (require('./client-receivers').ClientReceiverProof)(index, endpointsSession(index));
|
|
2351
2304
|
let helperProof = null;
|
|
2352
2305
|
|
|
2353
2306
|
for (const [filePath, fileEntry] of index.files) {
|
|
@@ -2431,7 +2384,9 @@ function extractClientRequests(index) {
|
|
|
2431
2384
|
continue;
|
|
2432
2385
|
}
|
|
2433
2386
|
if (!helperProof) helperProof = new RequestHelperProof(index, endpointsSession(index));
|
|
2434
|
-
const proof =
|
|
2387
|
+
const proof = call.isMethod
|
|
2388
|
+
? { proven: !!clientProof.classify(filePath, call), reason: 'receiver-unproven' }
|
|
2389
|
+
: helperProof.classify(filePath, call);
|
|
2435
2390
|
const verb = call.isMethod && /^(get|post|put|delete|patch|options|head)$/i.test(call.name)
|
|
2436
2391
|
? call.name.toUpperCase() : null;
|
|
2437
2392
|
const method = cfg.method || verb || 'ALL';
|
|
@@ -2462,7 +2417,7 @@ function extractClientRequests(index) {
|
|
|
2462
2417
|
// fix #383: Go request constructors read their method and URL
|
|
2463
2418
|
// from their own argument positions.
|
|
2464
2419
|
if (call.requestTarget && lang === 'go' && call.receiver && !call.isPathCall &&
|
|
2465
|
-
matchClientRequest({ ...call, firstStringArg: call.requestTarget.url }, lang,
|
|
2420
|
+
matchClientRequest({ ...call, firstStringArg: call.requestTarget.url }, lang, clientProof.classify(filePath, call))) {
|
|
2466
2421
|
const target = call.requestTarget;
|
|
2467
2422
|
if (isPathShaped(target.url)) {
|
|
2468
2423
|
// A request with a path but no scheme and host cannot be
|
|
@@ -2496,26 +2451,22 @@ function extractClientRequests(index) {
|
|
|
2496
2451
|
continue;
|
|
2497
2452
|
}
|
|
2498
2453
|
if (!call.firstStringArg) continue;
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
r = { method: call.name.toUpperCase() === 'REQUEST' ? 'ALL' : call.name.toUpperCase(),
|
|
2504
|
-
framework, methodInferred: call.name === 'request' };
|
|
2505
|
-
}
|
|
2506
|
-
}
|
|
2454
|
+
const conf = CLIENT_PATTERNS[lang];
|
|
2455
|
+
const candidate = conf && (conf.bareCalls.has(call.name) ||
|
|
2456
|
+
conf.receivers.some(p => p.methodPattern.test(call.name)));
|
|
2457
|
+
const r = matchClientRequest(call, lang, candidate ? clientProof.classify(filePath, call) : null);
|
|
2507
2458
|
if (!r) {
|
|
2508
2459
|
// Visible uncertainty: request-shaped call on an unrecognized
|
|
2509
2460
|
// receiver with a path-shaped literal. Listed, never counted.
|
|
2510
|
-
const conf = CLIENT_PATTERNS[lang];
|
|
2511
2461
|
const pathShaped = call.firstStringArg.startsWith('/') || call.firstStringArg.includes('://');
|
|
2512
|
-
if (conf && call.
|
|
2462
|
+
if (conf && call.receiver && pathShaped &&
|
|
2513
2463
|
conf.receivers.some(p => p.methodPattern.test(call.name))) {
|
|
2514
2464
|
uncertain.push({
|
|
2515
2465
|
receiver: call.receiver, method: call.name, path: call.firstStringArg,
|
|
2516
2466
|
file: fileEntry.relativePath || filePath, absoluteFile: filePath, line: call.line,
|
|
2517
2467
|
callerName: call.enclosingFunction?.name || '<top-level>',
|
|
2518
|
-
reason:
|
|
2468
|
+
reason: conf.receivers.some(p => p.receiverPattern.test(call.receiver))
|
|
2469
|
+
? 'receiver-name-only' : 'receiver-unrecognized',
|
|
2519
2470
|
});
|
|
2520
2471
|
}
|
|
2521
2472
|
continue;
|
|
@@ -2640,9 +2591,9 @@ function supertestProducerOf(call, calls, producers, locals) {
|
|
|
2640
2591
|
* Match a call against client request patterns.
|
|
2641
2592
|
* Returns { method, framework, methodInferred } or null.
|
|
2642
2593
|
*/
|
|
2643
|
-
function matchClientRequest(call, lang,
|
|
2594
|
+
function matchClientRequest(call, lang, origin) {
|
|
2644
2595
|
const conf = CLIENT_PATTERNS[lang];
|
|
2645
|
-
if (!conf) return null;
|
|
2596
|
+
if (!conf || !origin) return null;
|
|
2646
2597
|
|
|
2647
2598
|
// 1) Bare-call patterns: fetch('/path') or fetch('/path', { method: 'POST' })
|
|
2648
2599
|
if (!call.isMethod && conf.bareCalls.has(call.name)) {
|
|
@@ -2662,7 +2613,6 @@ function matchClientRequest(call, lang, allCallsInFile) {
|
|
|
2662
2613
|
const isMethodLike = call.isMethod || (lang === 'go' && !!call.receiver && !call.isPathCall);
|
|
2663
2614
|
if (isMethodLike && call.receiver) {
|
|
2664
2615
|
for (const p of conf.receivers) {
|
|
2665
|
-
if (!p.receiverPattern.test(call.receiver)) continue;
|
|
2666
2616
|
if (!p.methodPattern.test(call.name)) continue;
|
|
2667
2617
|
|
|
2668
2618
|
// Determine method
|
|
@@ -2697,7 +2647,7 @@ function matchClientRequest(call, lang, allCallsInFile) {
|
|
|
2697
2647
|
} else {
|
|
2698
2648
|
method = methodName.toUpperCase();
|
|
2699
2649
|
}
|
|
2700
|
-
return { method, framework:
|
|
2650
|
+
return { method, framework: origin, methodInferred: inferred };
|
|
2701
2651
|
}
|
|
2702
2652
|
}
|
|
2703
2653
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Request verbs and receiver names discover candidates; only the value's
|
|
4
|
+
// origin can confirm HTTP. Shared by positional and configuration requests.
|
|
5
|
+
const rg = require('./route-graph');
|
|
6
|
+
const { RequestHelperProof, PY_HTTP_MODULES, pyImportedFunction } = require('./request-helpers');
|
|
7
|
+
const { PythonClientResolver } = require('./inprocess-clients');
|
|
8
|
+
const { field, namedChildren, unwrap, findDecl } = rg;
|
|
9
|
+
|
|
10
|
+
const PY_CLIENT_MODULES = [...PY_HTTP_MODULES, 'fastapi.testclient',
|
|
11
|
+
'starlette.testclient', 'flask.testing', 'werkzeug.test', 'django.test'];
|
|
12
|
+
const PY_FACTORIES = new Set(['Session', 'Client', 'AsyncClient', 'TestClient',
|
|
13
|
+
'FlaskClient', 'ClientSession', 'AsyncSession', 'HTTPConnection', 'HTTPSConnection',
|
|
14
|
+
'PoolManager']);
|
|
15
|
+
const NOMINAL_CLIENTS = {
|
|
16
|
+
go: ['net/http.Client', 'net/http'],
|
|
17
|
+
rust: ['reqwest.Client', 'reqwest.blocking.Client', 'reqwest'],
|
|
18
|
+
java: ['org.springframework.web.client.RestTemplate',
|
|
19
|
+
'org.springframework.web.reactive.function.client.WebClient', 'java.net.http.HttpClient'],
|
|
20
|
+
csharp: ['System.Net.Http.HttpClient'],
|
|
21
|
+
};
|
|
22
|
+
const HTTP_FRAMEWORK = { go: 'go-http', rust: 'reqwest', java: 'spring-client', csharp: 'dotnet-httpclient' };
|
|
23
|
+
const MAX_DEPTH = 12;
|
|
24
|
+
|
|
25
|
+
class ClientReceiverProof {
|
|
26
|
+
constructor(index, sess) {
|
|
27
|
+
this.index = index;
|
|
28
|
+
this.sess = sess;
|
|
29
|
+
this.helpers = new RequestHelperProof(index, sess);
|
|
30
|
+
this.python = new PythonClientResolver(index, sess, () => new Set());
|
|
31
|
+
this.memo = new Map();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
classify(file, call) {
|
|
35
|
+
const lang = this.sess.lang(file);
|
|
36
|
+
if (['javascript', 'typescript', 'tsx'].includes(lang)) {
|
|
37
|
+
if (!this.helpers.classify(file, call).proven) return null;
|
|
38
|
+
const binding = this.helpers.jsBinding(file, call.receiver);
|
|
39
|
+
const decl = call.receiver && findDecl(this.sess, file, call.receiver,
|
|
40
|
+
this.sess.root(file)?.descendantForIndex(call.callStart, call.callStart));
|
|
41
|
+
const module = binding?.binding.module || this.helpers.requireSpecOf(decl);
|
|
42
|
+
return ['axios', 'ky', 'got'].includes(module) ? module : 'http-client';
|
|
43
|
+
}
|
|
44
|
+
if (lang === 'python') {
|
|
45
|
+
const root = this.sess.root(file);
|
|
46
|
+
const start = call.callSite?.start;
|
|
47
|
+
if (!root || start == null) return null;
|
|
48
|
+
let node = root.descendantForIndex(start, start);
|
|
49
|
+
while (node && node.type !== 'call') node = node.parent;
|
|
50
|
+
const fn = node && field(node, 'function');
|
|
51
|
+
const receiver = fn?.type === 'attribute' ? unwrap(field(fn, 'object')) : null;
|
|
52
|
+
if (!receiver) return null;
|
|
53
|
+
if (PY_HTTP_MODULES.includes(this.pyModule(file, receiver))) return 'requests';
|
|
54
|
+
const values = this.python.clientValues(file, call.line, call.receiver, call.name);
|
|
55
|
+
if (!values?.length || !values.every(v => this.pyValue(v.file, v.expr, 0))) return null;
|
|
56
|
+
return findDecl(this.sess, file, receiver.text, receiver)?.kind === 'param'
|
|
57
|
+
? 'pytest-client-fixture' : 'python-client';
|
|
58
|
+
}
|
|
59
|
+
return this.nominal(file, call, lang);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pyModule(file, expr) {
|
|
63
|
+
if (expr?.type === 'identifier' && !findDecl(this.sess, file, expr.text, expr)) {
|
|
64
|
+
const entry = this.sess.entry(file);
|
|
65
|
+
const candidates = (entry?.importBindings || []).filter(b => (b.alias || b.name) === expr.text);
|
|
66
|
+
const visible = candidates.filter(binding => {
|
|
67
|
+
if (!binding.deferred) return true;
|
|
68
|
+
const row = binding.line - 1;
|
|
69
|
+
const imports = this.sess.root(file)?.descendantsOfType(
|
|
70
|
+
['import_statement', 'import_from_statement'],
|
|
71
|
+
{ row, column: 0 }, { row, column: 1 << 20 }) || [];
|
|
72
|
+
return imports.some(node => {
|
|
73
|
+
let scope = node.parent;
|
|
74
|
+
while (scope && scope.type !== 'function_definition' && scope.type !== 'class_definition') scope = scope.parent;
|
|
75
|
+
return scope?.type === 'function_definition' && scope.startIndex <= expr.startIndex &&
|
|
76
|
+
scope.endIndex >= expr.endIndex;
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
if (!visible.length || new Set(visible.map(b => `${b.kind}:${b.module}:${b.name}`)).size !== 1) return null;
|
|
80
|
+
const binding = visible[0];
|
|
81
|
+
if (entry.moduleResolved?.[binding.module]) return null;
|
|
82
|
+
const module = binding.module;
|
|
83
|
+
return PY_CLIENT_MODULES.includes(module)
|
|
84
|
+
? (binding.kind === 'from' ? `${module}.${binding.name}` : module) : null;
|
|
85
|
+
}
|
|
86
|
+
if (expr?.type === 'attribute') {
|
|
87
|
+
const parent = this.pyModule(file, unwrap(field(expr, 'object')));
|
|
88
|
+
return parent ? `${parent}.${field(expr, 'attribute')?.text}` : null;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pyValue(file, expr, depth) {
|
|
94
|
+
const e = unwrap(expr);
|
|
95
|
+
if (!e || depth > MAX_DEPTH) return false;
|
|
96
|
+
const key = `${file}:${e.startIndex}:${e.endIndex}`;
|
|
97
|
+
if (this.memo.has(key)) return this.memo.get(key);
|
|
98
|
+
this.memo.set(key, false); // cycles cannot prove an origin
|
|
99
|
+
let proven = false;
|
|
100
|
+
if (e.type === 'identifier') {
|
|
101
|
+
let fn = e.parent;
|
|
102
|
+
while (fn && fn.type !== 'function_definition') fn = fn.parent;
|
|
103
|
+
const value = this.python.followBinding(file, fn, e, 0);
|
|
104
|
+
if (value && (value.file !== file || value.expr?.startIndex !== e.startIndex)) {
|
|
105
|
+
proven = this.pyValue(value.file, value.expr, depth + 1);
|
|
106
|
+
} else {
|
|
107
|
+
const decl = findDecl(this.sess, file, e.text, e);
|
|
108
|
+
if (decl?.count === 1 && decl.valueNode) proven = this.pyValue(file, decl.valueNode, depth + 1);
|
|
109
|
+
}
|
|
110
|
+
} else if (e.type === 'call') {
|
|
111
|
+
const callee = unwrap(field(e, 'function'));
|
|
112
|
+
const identity = this.pyModule(file, callee);
|
|
113
|
+
if (identity && PY_FACTORIES.has(identity.split('.').pop())) proven = true;
|
|
114
|
+
if (!proven) {
|
|
115
|
+
// A helper must RETURN a proven client; merely performing
|
|
116
|
+
// HTTP somewhere in its body does not type its return value.
|
|
117
|
+
const decl = callee?.type === 'identifier' && findDecl(this.sess, file, callee.text, callee);
|
|
118
|
+
let target = decl?.kind === 'function'
|
|
119
|
+
? { kind: 'function', file, node: decl.valueNode }
|
|
120
|
+
: !decl && callee?.type === 'identifier' ? pyImportedFunction(this.sess, file, callee.text, 0) : null;
|
|
121
|
+
if (decl?.kind === 'param') {
|
|
122
|
+
const fixture = this.python.fixtureDef(file, callee.text, decl.scope);
|
|
123
|
+
const provided = fixture && this.python.fixtureValue(fixture.file, fixture.fnNode, 0);
|
|
124
|
+
if (provided?.expr?.type === 'function_definition') {
|
|
125
|
+
target = { kind: 'function', file: provided.file, node: provided.expr };
|
|
126
|
+
} else if (provided?.expr?.type === 'call') {
|
|
127
|
+
const fn = field(provided.expr, 'function');
|
|
128
|
+
// functools.partial(Client) still constructs the
|
|
129
|
+
// imported client when the fixture is called.
|
|
130
|
+
const binding = fn?.type === 'attribute' &&
|
|
131
|
+
this.helpers.pyBinding(provided.file, field(fn, 'object')?.text);
|
|
132
|
+
if (binding?.module === 'functools' && field(fn, 'attribute')?.text === 'partial') {
|
|
133
|
+
const ctor = namedChildren(field(provided.expr, 'arguments'))[0];
|
|
134
|
+
const identity = this.pyModule(provided.file, ctor);
|
|
135
|
+
proven = !!identity && PY_FACTORIES.has(identity.split('.').pop());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (target?.kind === 'function') {
|
|
140
|
+
const fnNode = target.node || target.decl?.valueNode;
|
|
141
|
+
const value = fnNode && this.python.fixtureValue(target.file, fnNode, 0);
|
|
142
|
+
if (value) proven = this.pyValue(value.file, value.expr, depth + 1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this.memo.set(key, proven);
|
|
147
|
+
return proven;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
nominal(file, call, lang) {
|
|
151
|
+
const known = NOMINAL_CLIENTS[lang];
|
|
152
|
+
if (!known || call.localShadow || (call.isPathCall && call.receiverScopeBinding)) return null;
|
|
153
|
+
const entry = this.sess.entry(file);
|
|
154
|
+
const bindings = entry?.importBindings || [];
|
|
155
|
+
const qualifies = name => {
|
|
156
|
+
if (!name) return false;
|
|
157
|
+
const normalized = name.replace(/::/g, '.').replace(/^[*&]+/, '');
|
|
158
|
+
if (known.includes(normalized)) return true;
|
|
159
|
+
return bindings.some(b => {
|
|
160
|
+
const local = b.alias || b.name;
|
|
161
|
+
const module = String(b.module || '').replace(/::/g, '.');
|
|
162
|
+
return (normalized === local && (known.includes(module) || known.includes(`${module}.${b.name}`))) ||
|
|
163
|
+
(normalized.startsWith(local + '.') && known.includes(module + normalized.slice(local.length)));
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
if (!call.receiverLocalBinding && !call.isMethod && qualifies(call.receiver)) return HTTP_FRAMEWORK[lang];
|
|
167
|
+
if (call.isPathCall && qualifies(call.receiver)) return HTTP_FRAMEWORK[lang];
|
|
168
|
+
const qualifier = call.receiverTypeQualifier || call.receiverTypeNamespace;
|
|
169
|
+
const type = qualifier ? `${qualifier}.${call.receiverType}` : call.receiverType;
|
|
170
|
+
if (qualifies(type)) return HTTP_FRAMEWORK[lang];
|
|
171
|
+
// Java field receivers carry the enclosing owner rather than the
|
|
172
|
+
// field type in the call record. Read the declared field's AST type.
|
|
173
|
+
if (lang === 'java' && call.receiverRootType && call.receiverField) {
|
|
174
|
+
const root = this.sess.root(file);
|
|
175
|
+
for (const declaration of root?.descendantsOfType('field_declaration') || []) {
|
|
176
|
+
let owner = declaration.parent;
|
|
177
|
+
while (owner && owner.type !== 'class_declaration') owner = owner.parent;
|
|
178
|
+
if (field(owner, 'name')?.text !== call.receiverRootType) continue;
|
|
179
|
+
if (namedChildren(declaration).some(n => n.type === 'variable_declarator' &&
|
|
180
|
+
field(n, 'name')?.text === call.receiverField) && qualifies(field(declaration, 'type')?.text)) {
|
|
181
|
+
return HTTP_FRAMEWORK[lang];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { ClientReceiverProof };
|
package/core/deadcode.js
CHANGED
|
@@ -14,7 +14,7 @@ const { isFrameworkEntrypoint } = require('./entrypoints');
|
|
|
14
14
|
const { splitParentList } = require('./graph-build');
|
|
15
15
|
const { isOverrideMarked, codeUnitCompare, lineInRanges, maskBlockComments, escapeRegExp } = require('./shared');
|
|
16
16
|
const { projectComputedDispatch, projectReflectionSites } = require('./ast-analysis');
|
|
17
|
-
const { reflectionPatternReferences } = require('./reflection');
|
|
17
|
+
const { reflectionPatternReferences, reflectionLiteralNames } = require('./reflection');
|
|
18
18
|
const { protocolMemberOf, protocolTypeOf, typedLiteralContract, rustProjectTraits } = require('./contract-membership');
|
|
19
19
|
|
|
20
20
|
const _CLASS_KINDS = ['class', 'struct', 'interface', 'trait', 'record'];
|
|
@@ -770,6 +770,8 @@ function deadcode(index, options = {}) {
|
|
|
770
770
|
}
|
|
771
771
|
}
|
|
772
772
|
const literalReflectionNames = new Set();
|
|
773
|
+
const dynamicReflectionFiles = new Set();
|
|
774
|
+
const reflectionTables = new Map();
|
|
773
775
|
const reflectionInfo = {
|
|
774
776
|
count: 0,
|
|
775
777
|
literalCount: 0,
|
|
@@ -781,6 +783,7 @@ function deadcode(index, options = {}) {
|
|
|
781
783
|
patterns: [],
|
|
782
784
|
withheld: 0,
|
|
783
785
|
withheldByPattern: [],
|
|
786
|
+
withheldByTable: [],
|
|
784
787
|
};
|
|
785
788
|
// Reflection is liveness evidence wherever it sits (a test or script
|
|
786
789
|
// outside --in still reaches the member); the disclosure counts cover the
|
|
@@ -790,6 +793,7 @@ function deadcode(index, options = {}) {
|
|
|
790
793
|
const fe = index.files.get(reflectionFile);
|
|
791
794
|
if (!fe || sites.length === 0) continue;
|
|
792
795
|
for (const site of sites) {
|
|
796
|
+
if (site.dynamic) dynamicReflectionFiles.add(reflectionFile);
|
|
793
797
|
if (site.name) literalReflectionNames.add(site.name);
|
|
794
798
|
if (site.patterns && site.patterns.length > 0 && !site.name) {
|
|
795
799
|
// Exact alternatives of a multi-spelling site are literals.
|
|
@@ -1598,6 +1602,25 @@ function deadcode(index, options = {}) {
|
|
|
1598
1602
|
continue;
|
|
1599
1603
|
}
|
|
1600
1604
|
|
|
1605
|
+
// A table may feed a dynamic reflection site through a loop
|
|
1606
|
+
// or helper. Withhold exact same-file literal spellings,
|
|
1607
|
+
// conservatively: this is possible dispatch, not proven flow.
|
|
1608
|
+
if (dynamicReflectionFiles.has(symbol.file)) {
|
|
1609
|
+
if (!reflectionTables.has(symbol.file)) {
|
|
1610
|
+
const source = index._readFile(symbol.file);
|
|
1611
|
+
const tree = index._getParsedTree(symbol.file, source,
|
|
1612
|
+
index.files.get(symbol.file).language);
|
|
1613
|
+
reflectionTables.set(symbol.file, reflectionLiteralNames(tree));
|
|
1614
|
+
}
|
|
1615
|
+
const line = reflectionTables.get(symbol.file).get(symbol.name);
|
|
1616
|
+
if (line) {
|
|
1617
|
+
reflectionInfo.withheldByTable.push({ name: symbol.name,
|
|
1618
|
+
file: symbol.relativePath, line, startLine: symbol.startLine,
|
|
1619
|
+
reason: 'literal-name-near-dynamic-reflection' });
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1601
1624
|
results.push({
|
|
1602
1625
|
name: symbol.name,
|
|
1603
1626
|
type: symbol.type,
|
|
@@ -1635,6 +1658,8 @@ function deadcode(index, options = {}) {
|
|
|
1635
1658
|
results.excludedConstructors = excludedConstructors;
|
|
1636
1659
|
results.excludedDynamicDispatch = excludedDynamicDispatch;
|
|
1637
1660
|
results.computedDispatch = computedDispatchInfo;
|
|
1661
|
+
reflectionInfo.withheldByTable.sort((a, b) => codeUnitCompare(a.file, b.file) ||
|
|
1662
|
+
a.startLine - b.startLine || codeUnitCompare(a.name, b.name));
|
|
1638
1663
|
reflectionInfo.withheldByPattern = [...reflectionWithheldByPattern]
|
|
1639
1664
|
.map(([key, count]) => {
|
|
1640
1665
|
const [pattern, namespaceOf] = key.split('\u0000');
|
|
@@ -195,9 +195,23 @@ class PythonClientResolver {
|
|
|
195
195
|
|
|
196
196
|
/** The fixture named `name` a test in `file` requests: same module first,
|
|
197
197
|
* then conftest.py files from the test's directory up to the root. */
|
|
198
|
-
fixtureDef(file, name) {
|
|
199
|
-
|
|
198
|
+
fixtureDef(file, name, requester = null) {
|
|
199
|
+
let owner = requester?.parent;
|
|
200
|
+
while (owner && owner.type !== 'class_definition') owner = owner.parent;
|
|
201
|
+
const key = `${file}\0${name}\0${owner?.startIndex ?? ''}`;
|
|
200
202
|
if (this.fixtureMemo.has(key)) return this.fixtureMemo.get(key);
|
|
203
|
+
this.fixtureMemo.set(key, null);
|
|
204
|
+
if (owner) {
|
|
205
|
+
const matches = namedChildren(field(owner, 'body')).map(stmt =>
|
|
206
|
+
stmt.type === 'decorated_definition' ? field(stmt, 'definition') : stmt)
|
|
207
|
+
.filter(fn => fn?.type === 'function_definition' && field(fn, 'name')?.text === name);
|
|
208
|
+
if (matches.length) {
|
|
209
|
+
const found = matches.length === 1 && this.isFixture(file, matches[0])
|
|
210
|
+
? { file, fnNode: matches[0] } : null;
|
|
211
|
+
this.fixtureMemo.set(key, found);
|
|
212
|
+
return found;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
201
215
|
let found = this.moduleFixture(file, name);
|
|
202
216
|
if (!found) {
|
|
203
217
|
for (let dir = path.dirname(file); dir.startsWith(this.index.root); dir = path.dirname(dir)) {
|
|
@@ -213,7 +227,8 @@ class PythonClientResolver {
|
|
|
213
227
|
return found;
|
|
214
228
|
}
|
|
215
229
|
|
|
216
|
-
moduleFixture(file, name) {
|
|
230
|
+
moduleFixture(file, name, depth = 0) {
|
|
231
|
+
if (depth > MAX_HOPS) return null;
|
|
217
232
|
const root = this.sess.root(file);
|
|
218
233
|
if (!root) return null;
|
|
219
234
|
const hits = [];
|
|
@@ -222,7 +237,12 @@ class PythonClientResolver {
|
|
|
222
237
|
if (fn?.type === 'function_definition' && field(fn, 'name')?.text === name &&
|
|
223
238
|
this.isFixture(file, fn)) hits.push(fn);
|
|
224
239
|
}
|
|
225
|
-
return hits.length === 1 ? { file, fnNode: hits[0] } : null;
|
|
240
|
+
if (hits.length) return hits.length === 1 ? { file, fnNode: hits[0] } : null;
|
|
241
|
+
const entry = this.sess.entry(file);
|
|
242
|
+
const bindings = (entry?.importBindings || []).filter(b => (b.alias || b.name) === name && !b.deferred);
|
|
243
|
+
if (bindings.length !== 1 || bindings[0].kind !== 'from') return null;
|
|
244
|
+
const relative = entry.moduleResolved?.[bindings[0].module];
|
|
245
|
+
return relative ? this.moduleFixture(path.join(this.index.root, relative), bindings[0].name, depth + 1) : null;
|
|
226
246
|
}
|
|
227
247
|
|
|
228
248
|
/** The expression a fixture provides: its single yielded/returned value,
|
|
@@ -253,11 +273,14 @@ class PythonClientResolver {
|
|
|
253
273
|
if (!expr || expr.type !== 'identifier' || hops > MAX_HOPS) return { file, expr };
|
|
254
274
|
if (fnNode) {
|
|
255
275
|
const values = boundValues(field(fnNode, 'body'), expr.text, this.bindingMemo);
|
|
256
|
-
if (values === null)
|
|
276
|
+
if (values === null) {
|
|
277
|
+
const decl = routeGraph.findDecl(this.sess, file, expr.text, expr);
|
|
278
|
+
return decl?.kind === 'function' ? { file, expr: decl.valueNode } : null;
|
|
279
|
+
}
|
|
257
280
|
if (values.length === 1) return this.followBinding(file, fnNode, unwrap(values[0].node), hops + 1);
|
|
258
281
|
if (values.length > 1) return null;
|
|
259
282
|
if (isParameterOf(fnNode, expr.text)) {
|
|
260
|
-
const fixture = this.fixtureDef(file, expr.text);
|
|
283
|
+
const fixture = this.fixtureDef(file, expr.text, fnNode);
|
|
261
284
|
return fixture ? this.fixtureValue(fixture.file, fixture.fnNode, hops + 1) : null;
|
|
262
285
|
}
|
|
263
286
|
}
|
|
@@ -319,7 +342,7 @@ class PythonClientResolver {
|
|
|
319
342
|
if (expr.type !== 'identifier') return null;
|
|
320
343
|
const fnNode = enclosingFunctionNode(expr);
|
|
321
344
|
if (fnNode && isParameterOf(fnNode, expr.text)) {
|
|
322
|
-
const fixture = this.fixtureDef(file, expr.text);
|
|
345
|
+
const fixture = this.fixtureDef(file, expr.text, fnNode);
|
|
323
346
|
if (!fixture) return null;
|
|
324
347
|
const value = this.fixtureValue(fixture.file, fixture.fnNode, hops + 1);
|
|
325
348
|
if (!value?.expr) return null;
|
|
@@ -345,13 +368,8 @@ class PythonClientResolver {
|
|
|
345
368
|
return null;
|
|
346
369
|
}
|
|
347
370
|
|
|
348
|
-
/**
|
|
349
|
-
|
|
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) {
|
|
371
|
+
/** Values bound to a request receiver, without assuming they are clients. */
|
|
372
|
+
clientValues(file, line, receiver, name) {
|
|
355
373
|
const root = this.sess.root(file);
|
|
356
374
|
if (!root) return null;
|
|
357
375
|
const sites = requestCallNodes(root, line, receiver, name);
|
|
@@ -365,7 +383,7 @@ class PythonClientResolver {
|
|
|
365
383
|
if (values.length > 0) {
|
|
366
384
|
clients = this.bindingInEffect(values, receiverNode).map(node => ({ file, expr: unwrap(node) }));
|
|
367
385
|
} else if (isParameterOf(fnNode, receiver)) {
|
|
368
|
-
const fixture = this.fixtureDef(file, receiver);
|
|
386
|
+
const fixture = this.fixtureDef(file, receiver, fnNode);
|
|
369
387
|
const value = fixture ? this.fixtureValue(fixture.file, fixture.fnNode, 1) : null;
|
|
370
388
|
clients = value ? [value] : [];
|
|
371
389
|
}
|
|
@@ -376,6 +394,14 @@ class PythonClientResolver {
|
|
|
376
394
|
clients = this.bindingInEffect(values, receiverNode).map(node => ({ file, expr: unwrap(node) }));
|
|
377
395
|
}
|
|
378
396
|
}
|
|
397
|
+
const values = clients?.map(c => this.followBinding(c.file,
|
|
398
|
+
enclosingFunctionNode(c.expr), c.expr, 0));
|
|
399
|
+
return values?.every(Boolean) ? values : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The served app key, an unresolved in-process app, or null. */
|
|
403
|
+
resolve(file, line, receiver, name) {
|
|
404
|
+
const clients = this.clientValues(file, line, receiver, name);
|
|
379
405
|
if (!clients || clients.length === 0 || clients.some(c => c.expr?.type !== 'call')) return null;
|
|
380
406
|
const keys = clients.map(c => this.clientAppOnce(c.file, c.expr));
|
|
381
407
|
if (keys.every(key => key && key === keys[0])) {
|
package/core/output/public.js
CHANGED
|
@@ -476,6 +476,7 @@ function formatPublicJson(command, result, params = {}, execution = {}) {
|
|
|
476
476
|
const envelope = {
|
|
477
477
|
meta: {
|
|
478
478
|
command: surfaceCommand,
|
|
479
|
+
...(execution.surface === 'cli' && { maxChars: 'not-applied' }),
|
|
479
480
|
...(surfaceCommand !== command && { canonicalCommand: command }),
|
|
480
481
|
// A result that self-reports it could not run (check with a bad
|
|
481
482
|
// base ref / outside git) must be machine-distinguishable from a
|
package/core/output/reporting.js
CHANGED
|
@@ -315,6 +315,12 @@ function formatDeadcode(results, options = {}) {
|
|
|
315
315
|
if (results.reflection?.dynamicCount > 0) {
|
|
316
316
|
lines.push(`\nWARNING: ${results.reflection.dynamicCount} dynamic reflection use(s) have no static member spelling. Dead-code results remain review candidates because runtime-selected members cannot be attributed.`);
|
|
317
317
|
}
|
|
318
|
+
if (results.reflection?.withheldByTable?.length > 0) {
|
|
319
|
+
lines.push('\nConservatively withheld: exact string literals in a file with dynamic reflection (flow to the reflection call is unproven):');
|
|
320
|
+
for (const site of results.reflection.withheldByTable) {
|
|
321
|
+
lines.push(` ${site.name}: named in possible reflection table ${site.file}:${site.line}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
318
324
|
if (results.macroPaste) {
|
|
319
325
|
const m = results.macroPaste;
|
|
320
326
|
if (m.withheld > 0) {
|
package/core/reflection.js
CHANGED
|
@@ -1113,6 +1113,20 @@ function resetReflectionIndex(index) {
|
|
|
1113
1113
|
index._reflectionPatternIndex = null;
|
|
1114
1114
|
}
|
|
1115
1115
|
|
|
1116
|
+
/** Exact AST string literals, for conservative same-file dynamic dispatch.
|
|
1117
|
+
* This does not prove that a literal flows to a reflection call. */
|
|
1118
|
+
function reflectionLiteralNames(tree) {
|
|
1119
|
+
const names = new Map();
|
|
1120
|
+
if (!tree) return names;
|
|
1121
|
+
for (const node of tree.rootNode.descendantsOfType([...STRING_NODES])) {
|
|
1122
|
+
const value = classifyNameExpression(node);
|
|
1123
|
+
if (value.name && !value.dynamic && !names.has(value.name)) {
|
|
1124
|
+
names.set(value.name, node.startPosition.row + 1);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return names;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1116
1130
|
module.exports = {
|
|
1117
1131
|
WILD,
|
|
1118
1132
|
stringAlternatives,
|
|
@@ -1126,5 +1140,6 @@ module.exports = {
|
|
|
1126
1140
|
reflectionPatternIndex,
|
|
1127
1141
|
patternIsAttributable,
|
|
1128
1142
|
resetReflectionIndex,
|
|
1143
|
+
reflectionLiteralNames,
|
|
1129
1144
|
resolveKeyParameter,
|
|
1130
1145
|
};
|
package/core/request-helpers.js
CHANGED
|
@@ -43,6 +43,8 @@ const JS_HTTP_PACKAGES = [
|
|
|
43
43
|
];
|
|
44
44
|
const JS_HTTP_GLOBAL_CALLS = new Set(['fetch']);
|
|
45
45
|
const JS_HTTP_GLOBAL_CTORS = new Set(['XMLHttpRequest', 'EventSource']);
|
|
46
|
+
const JS_HTTP_CLIENT_TYPES = new Set(['AxiosInstance', 'AxiosStatic', 'KyInstance',
|
|
47
|
+
'Got', 'GotInstance', 'HttpClient', 'Dispatcher', 'Agent']);
|
|
46
48
|
const PY_HTTP_MODULES = [
|
|
47
49
|
'requests', 'httpx', 'aiohttp', 'urllib3', 'urllib.request', 'http.client', 'niquests', 'treq',
|
|
48
50
|
];
|
|
@@ -173,7 +175,7 @@ class RequestHelperProof {
|
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
/** Proof for the function expression being called. */
|
|
176
|
-
jsCallee(file, fn, depth
|
|
178
|
+
jsCallee(file, fn, depth) {
|
|
177
179
|
if (!fn || depth > MAX_RESOLVE) return unknown('depth');
|
|
178
180
|
if (fn.type === 'identifier') {
|
|
179
181
|
const decl = findDecl(this.sess, file, fn.text, fn);
|
|
@@ -201,9 +203,7 @@ class RequestHelperProof {
|
|
|
201
203
|
// the method reads as one (`client.post`, `instance.request`);
|
|
202
204
|
// a server built on an HTTP stack (`app.route(...)`) is not a
|
|
203
205
|
// client.
|
|
204
|
-
|
|
205
|
-
// client value too.
|
|
206
|
-
if (!factory && (!prop || !CLIENT_METHOD.test(prop))) return unknown('method-not-request');
|
|
206
|
+
if (!prop || !CLIENT_METHOD.test(prop)) return unknown('method-not-request');
|
|
207
207
|
return this.jsValue(file, obj, depth + 1);
|
|
208
208
|
}
|
|
209
209
|
if (fn.type === 'call_expression') return this.jsValue(file, fn, depth + 1);
|
|
@@ -296,7 +296,7 @@ class RequestHelperProof {
|
|
|
296
296
|
const spec = this.requireSpecOf({ valueNode: e });
|
|
297
297
|
return isHttpPackage(spec, JS_HTTP_PACKAGES) ? PROVEN : unknown('receiver-unproven');
|
|
298
298
|
}
|
|
299
|
-
return this.
|
|
299
|
+
return this.jsFactory(file, callee, depth + 1);
|
|
300
300
|
}
|
|
301
301
|
default:
|
|
302
302
|
return unknown('receiver-unproven');
|
|
@@ -314,6 +314,59 @@ class RequestHelperProof {
|
|
|
314
314
|
return last;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/** A request result or server is not a client just because the function
|
|
318
|
+
* that produced it uses an HTTP package. Follow factory return values. */
|
|
319
|
+
jsFactory(file, callee, depth) {
|
|
320
|
+
if (!callee || depth > MAX_RESOLVE) return unknown('receiver-unproven');
|
|
321
|
+
if (callee.type === 'member_expression') {
|
|
322
|
+
const name = field(callee, 'property')?.text;
|
|
323
|
+
if (name === 'create' || name === 'extend') {
|
|
324
|
+
return this.jsValue(file, unwrap(field(callee, 'object')), depth + 1);
|
|
325
|
+
}
|
|
326
|
+
return unknown('factory-unproven');
|
|
327
|
+
}
|
|
328
|
+
if (callee.type !== 'identifier') return unknown('factory-unproven');
|
|
329
|
+
const decl = findDecl(this.sess, file, callee.text, callee);
|
|
330
|
+
let target = decl ? { file, value: decl.count === 1 ? decl.valueNode : null } : null;
|
|
331
|
+
if (!decl) {
|
|
332
|
+
const binding = this.jsBinding(file, callee.text);
|
|
333
|
+
if (binding) {
|
|
334
|
+
if (binding.binding.module?.startsWith('@hey-api/client-') ||
|
|
335
|
+
binding.binding.module === 'openapi-fetch') return PROVEN;
|
|
336
|
+
const rec = this.jsImportRecord(file, binding.binding, binding.original);
|
|
337
|
+
if (rec) target = { file: rec.file, value: rec.decl?.valueNode || rec.valueNode };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const fn = unwrap(target?.value);
|
|
341
|
+
if (!fn || !JS_FUNCTION_TYPES.has(fn.type)) return unknown('factory-unproven');
|
|
342
|
+
const body = field(fn, 'body');
|
|
343
|
+
if (!body) return unknown('factory-unproven');
|
|
344
|
+
const values = [];
|
|
345
|
+
const visit = node => {
|
|
346
|
+
if (node.type === 'return_statement') {
|
|
347
|
+
values.push(node.namedChild(0));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
for (const child of namedChildren(node)) {
|
|
351
|
+
if (!JS_FUNCTION_TYPES.has(child.type) && child.type !== 'class_declaration') visit(child);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
if (body.type === 'statement_block') visit(body);
|
|
355
|
+
else values.push(body);
|
|
356
|
+
if (!values.length) return unknown('factory-unproven');
|
|
357
|
+
const proven = values.every(value => {
|
|
358
|
+
const v = unwrap(value);
|
|
359
|
+
if (v?.type !== 'object') return this.jsValue(target.file, v, depth + 1).proven;
|
|
360
|
+
const methods = namedChildren(v).filter(member =>
|
|
361
|
+
CLIENT_METHOD.test((field(member, 'key') || field(member, 'name'))?.text || ''));
|
|
362
|
+
return methods.length > 0 && methods.every(member => {
|
|
363
|
+
const impl = member.type === 'method_definition' ? member : unwrap(field(member, 'value'));
|
|
364
|
+
return impl && JS_FUNCTION_TYPES.has(impl.type) && this.jsFunction(target.file, impl, 0).proven;
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
return proven ? PROVEN : unknown('factory-unproven');
|
|
368
|
+
}
|
|
369
|
+
|
|
317
370
|
/** A parameter proves a client only through its declared type or default. */
|
|
318
371
|
jsParam(file, decl, depth) {
|
|
319
372
|
const holder = decl.nameNode.parent;
|
|
@@ -321,7 +374,8 @@ class RequestHelperProof {
|
|
|
321
374
|
if (typeNode) {
|
|
322
375
|
const m = typeNode.text.replace(/^:\s*/, '').match(/^([A-Za-z_$][\w$]*)/);
|
|
323
376
|
const b = m ? this.jsBinding(file, m[1]) : null;
|
|
324
|
-
if (b && isHttpPackage(b.binding.module, JS_HTTP_PACKAGES)
|
|
377
|
+
if (b && isHttpPackage(b.binding.module, JS_HTTP_PACKAGES) &&
|
|
378
|
+
JS_HTTP_CLIENT_TYPES.has(b.original)) return PROVEN;
|
|
325
379
|
}
|
|
326
380
|
const dflt = holder ? (field(holder, 'value') || field(holder, 'right')) : null;
|
|
327
381
|
if (dflt) return this.jsValue(file, dflt, depth + 1);
|
|
@@ -374,6 +428,8 @@ class RequestHelperProof {
|
|
|
374
428
|
return !!b && isHttpPackage(b.binding.module, JS_HTTP_PACKAGES);
|
|
375
429
|
}
|
|
376
430
|
if (callee.type === 'member_expression') {
|
|
431
|
+
if (CLIENT_METHOD.test(field(callee, 'property')?.text || '') &&
|
|
432
|
+
this.jsValue(file, unwrap(field(callee, 'object')), 0).proven) return true;
|
|
377
433
|
let root = unwrap(field(callee, 'object'));
|
|
378
434
|
while (root && root.type === 'member_expression') root = unwrap(field(root, 'object'));
|
|
379
435
|
if (!root || root.type !== 'identifier') return false;
|
|
@@ -558,4 +614,4 @@ function locateCall(root, start, end, type) {
|
|
|
558
614
|
return node;
|
|
559
615
|
}
|
|
560
616
|
|
|
561
|
-
module.exports = { RequestHelperProof, JS_HTTP_PACKAGES, PY_HTTP_MODULES };
|
|
617
|
+
module.exports = { RequestHelperProof, JS_HTTP_PACKAGES, PY_HTTP_MODULES, pyImportedFunction };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "5.5.
|
|
3
|
+
"version": "5.5.3",
|
|
4
4
|
"mcpName": "io.github.mleoca/ucn",
|
|
5
5
|
"description": "Auditable AST code intelligence for AI agents: 18 task-oriented commands through one MCP tool, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java, C, C++, C#, and HTML.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"dependencies": {
|
|
88
88
|
"node-addon-api": "8.9.2",
|
|
89
89
|
"node-gyp-build": "4.8.4",
|
|
90
|
-
"re2js": "2.8.
|
|
90
|
+
"re2js": "2.8.7",
|
|
91
91
|
"ret": "0.5.0",
|
|
92
92
|
"safe-regex2": "5.1.1",
|
|
93
93
|
"tree-sitter": "0.21.1",
|