@kernlang/review 3.3.5 → 3.3.6
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/dist/concept-rules/auth-drift.d.ts +29 -0
- package/dist/concept-rules/auth-drift.js +127 -0
- package/dist/concept-rules/auth-drift.js.map +1 -0
- package/dist/concept-rules/contract-drift.js +2 -3
- package/dist/concept-rules/contract-drift.js.map +1 -1
- package/dist/concept-rules/contract-method-drift.d.ts +22 -0
- package/dist/concept-rules/contract-method-drift.js +105 -0
- package/dist/concept-rules/contract-method-drift.js.map +1 -0
- package/dist/concept-rules/cross-stack-utils.d.ts +46 -0
- package/dist/concept-rules/cross-stack-utils.js +161 -0
- package/dist/concept-rules/cross-stack-utils.js.map +1 -1
- package/dist/concept-rules/duplicate-route.d.ts +20 -0
- package/dist/concept-rules/duplicate-route.js +112 -0
- package/dist/concept-rules/duplicate-route.js.map +1 -0
- package/dist/concept-rules/index.js +14 -0
- package/dist/concept-rules/index.js.map +1 -1
- package/dist/concept-rules/missing-response-model.d.ts +10 -0
- package/dist/concept-rules/missing-response-model.js +38 -0
- package/dist/concept-rules/missing-response-model.js.map +1 -0
- package/dist/concept-rules/orphan-route.d.ts +20 -0
- package/dist/concept-rules/orphan-route.js +96 -0
- package/dist/concept-rules/orphan-route.js.map +1 -0
- package/dist/concept-rules/sync-handler-does-io.d.ts +9 -0
- package/dist/concept-rules/sync-handler-does-io.js +56 -0
- package/dist/concept-rules/sync-handler-does-io.js.map +1 -0
- package/dist/concept-rules/tainted-across-wire.js +2 -5
- package/dist/concept-rules/tainted-across-wire.js.map +1 -1
- package/dist/concept-rules/untyped-api-response.js +8 -6
- package/dist/concept-rules/untyped-api-response.js.map +1 -1
- package/dist/concept-rules/untyped-both-ends-response.d.ts +10 -0
- package/dist/concept-rules/untyped-both-ends-response.js +55 -0
- package/dist/concept-rules/untyped-both-ends-response.js.map +1 -0
- package/dist/index.js +40 -3
- package/dist/index.js.map +1 -1
- package/dist/llm-bridge.d.ts +12 -0
- package/dist/llm-bridge.js +131 -7
- package/dist/llm-bridge.js.map +1 -1
- package/dist/mappers/ts-concepts.js +406 -13
- package/dist/mappers/ts-concepts.js.map +1 -1
- package/dist/rules/index.js +16 -0
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/kern-source.js +2 -0
- package/dist/rules/kern-source.js.map +1 -1
- package/dist/rules/set-setter-collision.d.ts +21 -0
- package/dist/rules/set-setter-collision.js +74 -0
- package/dist/rules/set-setter-collision.js.map +1 -0
- package/dist/rules/suggest-kern-primitive.d.ts +30 -0
- package/dist/rules/suggest-kern-primitive.js +543 -0
- package/dist/rules/suggest-kern-primitive.js.map +1 -0
- package/package.json +2 -2
|
@@ -10,6 +10,24 @@ const EXTRACTOR_VERSION = '1.0.0';
|
|
|
10
10
|
// ── Network effect signatures ────────────────────────────────────────────
|
|
11
11
|
const NETWORK_CALLS = new Set(['fetch', 'axios', 'got', 'request', 'superagent', 'ky']);
|
|
12
12
|
const NETWORK_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'request']);
|
|
13
|
+
// ── Wrapped HTTP-client detection ────────────────────────────────────────
|
|
14
|
+
// ~75% of production React/Next/Expo apps route through a wrapper (custom
|
|
15
|
+
// ApiClient class, axios.create instance, tRPC-generated client). The fixed
|
|
16
|
+
// NETWORK_CALLS set misses those, so the cross-stack wedge rules
|
|
17
|
+
// (contract-drift, untyped-api-response, tainted-across-wire) silently
|
|
18
|
+
// find nothing on real repos. collectClientIdentifiers() scans the file
|
|
19
|
+
// for wrapper patterns and returns the local identifiers that behave like
|
|
20
|
+
// HTTP clients; extractEffects() then treats `<name>.get/post/…` calls on
|
|
21
|
+
// those identifiers as network effects.
|
|
22
|
+
const CLIENT_HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
|
23
|
+
// Names considered client-shaped for imported identifiers. Kept narrow on
|
|
24
|
+
// purpose — false positives here cascade into the wedge rules and poison
|
|
25
|
+
// the pitch. Matches: api, http, client, apiClient, httpClient, fetcher,
|
|
26
|
+
// requester, ApiClient, HttpClient, MyApiClient, etc.
|
|
27
|
+
const CLIENT_NAME_PATTERN = /^(api|http|client|apiClient|httpClient|fetcher|requester)$|Client$/;
|
|
28
|
+
// Wrapper factories — if a variable is initialized with one of these calls,
|
|
29
|
+
// the variable is a client instance (axios.create, ky.create, got.extend).
|
|
30
|
+
const CLIENT_FACTORY_CALLS = new Set(['axios.create', 'ky.create', 'ky.extend', 'got.extend']);
|
|
13
31
|
const DB_CALLS = new Set([
|
|
14
32
|
'query',
|
|
15
33
|
'execute',
|
|
@@ -237,6 +255,7 @@ function hasIntentComment(text) {
|
|
|
237
255
|
}
|
|
238
256
|
// ── effect ───────────────────────────────────────────────────────────────
|
|
239
257
|
function extractEffects(sf, filePath, nodes) {
|
|
258
|
+
const clientIdents = collectClientIdentifiers(sf);
|
|
240
259
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
241
260
|
const callee = call.getExpression();
|
|
242
261
|
let funcName = '';
|
|
@@ -250,15 +269,17 @@ function extractEffects(sf, filePath, nodes) {
|
|
|
250
269
|
objName = pa.getExpression().getText();
|
|
251
270
|
}
|
|
252
271
|
// Network effects
|
|
253
|
-
|
|
254
|
-
|
|
272
|
+
const isDirectNetwork = NETWORK_CALLS.has(funcName);
|
|
273
|
+
const isKnownLibraryMethod = NETWORK_METHODS.has(funcName) && /axios|got|ky|http|request|superagent/i.test(objName);
|
|
274
|
+
const isWrappedClientCall = CLIENT_HTTP_METHODS.has(funcName) && clientIdents.has(objName);
|
|
275
|
+
if (isDirectNetwork || isKnownLibraryMethod || isWrappedClientCall) {
|
|
255
276
|
const isAsync = isInAsyncContext(call);
|
|
256
277
|
nodes.push({
|
|
257
278
|
id: conceptId(filePath, 'effect', call.getStart()),
|
|
258
279
|
kind: 'effect',
|
|
259
280
|
primarySpan: span(filePath, call),
|
|
260
281
|
evidence: call.getText().substring(0, 120),
|
|
261
|
-
confidence:
|
|
282
|
+
confidence: isDirectNetwork ? 1.0 : isWrappedClientCall ? 0.75 : 0.8,
|
|
262
283
|
language: 'ts',
|
|
263
284
|
containerId: getContainerId(call, filePath),
|
|
264
285
|
payload: {
|
|
@@ -266,8 +287,10 @@ function extractEffects(sf, filePath, nodes) {
|
|
|
266
287
|
subtype: 'network',
|
|
267
288
|
async: isAsync,
|
|
268
289
|
target: extractTarget(call),
|
|
269
|
-
responseAsserted: isResponseAsserted(call),
|
|
290
|
+
responseAsserted: isResponseAsserted(call, isWrappedClientCall),
|
|
270
291
|
bodyKind: extractBodyKind(call, funcName),
|
|
292
|
+
method: extractHttpMethod(call, funcName, isDirectNetwork, isKnownLibraryMethod, isWrappedClientCall),
|
|
293
|
+
hasAuthHeader: extractHasAuthHeader(call, funcName),
|
|
271
294
|
},
|
|
272
295
|
});
|
|
273
296
|
continue;
|
|
@@ -302,7 +325,7 @@ function extractEffects(sf, filePath, nodes) {
|
|
|
302
325
|
}
|
|
303
326
|
}
|
|
304
327
|
// ── entrypoint ───────────────────────────────────────────────────────────
|
|
305
|
-
const ROUTE_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'all'
|
|
328
|
+
const ROUTE_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'all']);
|
|
306
329
|
function extractEntrypoints(sf, filePath, nodes) {
|
|
307
330
|
// Express/Fastify route handlers: app.get('/path', handler)
|
|
308
331
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
@@ -311,14 +334,20 @@ function extractEntrypoints(sf, filePath, nodes) {
|
|
|
311
334
|
continue;
|
|
312
335
|
const pa = callee;
|
|
313
336
|
const methodName = pa.getName();
|
|
314
|
-
if (!ROUTE_METHODS.has(methodName))
|
|
315
|
-
continue;
|
|
316
337
|
const objText = pa.getExpression().getText();
|
|
317
338
|
if (!/app|router|server/i.test(objText))
|
|
318
339
|
continue;
|
|
319
340
|
const args = call.getArguments();
|
|
320
341
|
if (args.length < 2)
|
|
321
342
|
continue;
|
|
343
|
+
if (methodName === 'use') {
|
|
344
|
+
const mount = extractRouteMount(call, filePath);
|
|
345
|
+
if (mount)
|
|
346
|
+
nodes.push(mount);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (!ROUTE_METHODS.has(methodName))
|
|
350
|
+
continue;
|
|
322
351
|
// First arg is the route path
|
|
323
352
|
let routePath;
|
|
324
353
|
if (args[0].getKind() === SyntaxKind.StringLiteral) {
|
|
@@ -336,7 +365,7 @@ function extractEntrypoints(sf, filePath, nodes) {
|
|
|
336
365
|
kind: 'entrypoint',
|
|
337
366
|
subtype: 'route',
|
|
338
367
|
name: routePath || methodName,
|
|
339
|
-
httpMethod: methodName
|
|
368
|
+
httpMethod: methodName.toUpperCase(),
|
|
340
369
|
},
|
|
341
370
|
});
|
|
342
371
|
}
|
|
@@ -371,6 +400,119 @@ function extractEntrypoints(sf, filePath, nodes) {
|
|
|
371
400
|
}
|
|
372
401
|
}
|
|
373
402
|
}
|
|
403
|
+
// Express `app.use('/api/prefix', ...middlewares, subRouter)`: emit a
|
|
404
|
+
// route-mount concept so `collectRoutesAcrossGraph` can join the sub-router's
|
|
405
|
+
// bare paths (`router.get('/foo')`) with the mount prefix (`/api/prefix/foo`).
|
|
406
|
+
// Mirrors the FastAPI `app.include_router()` emission in review-python.
|
|
407
|
+
// Caller must have already filtered to `.use()` calls whose object matches
|
|
408
|
+
// `app|router|server`.
|
|
409
|
+
function extractRouteMount(call, mountFile) {
|
|
410
|
+
const args = call.getArguments();
|
|
411
|
+
if (args.length < 2)
|
|
412
|
+
return undefined;
|
|
413
|
+
if (args[0].getKind() !== SyntaxKind.StringLiteral)
|
|
414
|
+
return undefined;
|
|
415
|
+
const prefix = args[0].getLiteralValue();
|
|
416
|
+
if (!prefix.startsWith('/'))
|
|
417
|
+
return undefined;
|
|
418
|
+
// The sub-router is the LAST arg; intermediate args are middlewares.
|
|
419
|
+
const last = args[args.length - 1];
|
|
420
|
+
if (last.getKind() !== SyntaxKind.Identifier)
|
|
421
|
+
return undefined;
|
|
422
|
+
const ident = last;
|
|
423
|
+
const routerName = ident.getText();
|
|
424
|
+
const sourceModule = resolveImportedFileSuffix(ident, mountFile);
|
|
425
|
+
// Emit even when resolution fails — the same-file fallback in
|
|
426
|
+
// resolveMountPrefix can still match if the router is declared locally.
|
|
427
|
+
return {
|
|
428
|
+
id: conceptId(mountFile, 'entrypoint', call.getStart()),
|
|
429
|
+
kind: 'entrypoint',
|
|
430
|
+
primarySpan: span(mountFile, call),
|
|
431
|
+
evidence: call.getText().substring(0, 120),
|
|
432
|
+
confidence: 0.9,
|
|
433
|
+
language: 'ts',
|
|
434
|
+
containerId: getContainerId(call, mountFile),
|
|
435
|
+
payload: {
|
|
436
|
+
kind: 'entrypoint',
|
|
437
|
+
subtype: 'route-mount',
|
|
438
|
+
name: prefix,
|
|
439
|
+
routerName,
|
|
440
|
+
sourceModule,
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
// Given an identifier used in a mount call, resolve it back to the file it
|
|
445
|
+
// was imported from, and return a trailing path suffix (relative to the mount
|
|
446
|
+
// file's directory, with extension) usable by `cross-stack-utils`'s
|
|
447
|
+
// `routeFile.endsWith('/' + sourceModule)` matcher.
|
|
448
|
+
//
|
|
449
|
+
// Returns `undefined` when the identifier is declared locally (no import),
|
|
450
|
+
// when the import cannot be resolved to a source file, or when the import
|
|
451
|
+
// is external (node_modules).
|
|
452
|
+
function resolveImportedFileSuffix(ident, mountFile) {
|
|
453
|
+
const symbol = ident.getSymbol();
|
|
454
|
+
if (!symbol)
|
|
455
|
+
return undefined;
|
|
456
|
+
for (const decl of symbol.getDeclarations()) {
|
|
457
|
+
const kind = decl.getKind();
|
|
458
|
+
if (kind !== SyntaxKind.ImportClause && kind !== SyntaxKind.ImportSpecifier)
|
|
459
|
+
continue;
|
|
460
|
+
const importDecl = decl.getFirstAncestorByKind(SyntaxKind.ImportDeclaration);
|
|
461
|
+
if (!importDecl)
|
|
462
|
+
continue;
|
|
463
|
+
const resolved = importDecl.getModuleSpecifierSourceFile();
|
|
464
|
+
if (resolved) {
|
|
465
|
+
return pathSuffixBetween(mountFile, resolved.getFilePath());
|
|
466
|
+
}
|
|
467
|
+
// ts-morph couldn't resolve (no project config) — fall back to parsing the
|
|
468
|
+
// specifier string and swapping common JS→TS extensions.
|
|
469
|
+
const specifier = importDecl.getModuleSpecifierValue();
|
|
470
|
+
const guess = guessResolvedSuffix(specifier, mountFile);
|
|
471
|
+
if (guess)
|
|
472
|
+
return guess;
|
|
473
|
+
}
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
function pathSuffixBetween(mountFile, targetFile) {
|
|
477
|
+
// Return targetFile relative to the mount file's directory when possible.
|
|
478
|
+
// Fallback: the last 2-3 path components of the target. This just needs to
|
|
479
|
+
// be a unique trailing suffix for `routeFile.endsWith('/' + suffix)` to match.
|
|
480
|
+
const parts = targetFile.split('/');
|
|
481
|
+
const mountParts = mountFile.split('/');
|
|
482
|
+
// Find longest common prefix
|
|
483
|
+
let commonLen = 0;
|
|
484
|
+
while (commonLen < parts.length - 1 &&
|
|
485
|
+
commonLen < mountParts.length - 1 &&
|
|
486
|
+
parts[commonLen] === mountParts[commonLen]) {
|
|
487
|
+
commonLen++;
|
|
488
|
+
}
|
|
489
|
+
const tail = parts.slice(commonLen).join('/');
|
|
490
|
+
return tail || parts.slice(-2).join('/');
|
|
491
|
+
}
|
|
492
|
+
function guessResolvedSuffix(specifier, mountFile) {
|
|
493
|
+
if (!specifier.startsWith('.'))
|
|
494
|
+
return undefined;
|
|
495
|
+
const mountDir = mountFile.split('/').slice(0, -1).join('/');
|
|
496
|
+
const segments = [];
|
|
497
|
+
for (const seg of specifier.split('/')) {
|
|
498
|
+
if (seg === '' || seg === '.')
|
|
499
|
+
continue;
|
|
500
|
+
if (seg === '..') {
|
|
501
|
+
if (mountDir.length === 0)
|
|
502
|
+
continue;
|
|
503
|
+
segments.pop();
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
segments.push(seg);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (segments.length === 0)
|
|
510
|
+
return undefined;
|
|
511
|
+
const base = segments[segments.length - 1];
|
|
512
|
+
const swapped = base.replace(/\.(js|mjs|cjs)$/i, '.ts');
|
|
513
|
+
segments[segments.length - 1] = /\.(ts|tsx)$/i.test(swapped) ? swapped : `${swapped}.ts`;
|
|
514
|
+
return segments.join('/');
|
|
515
|
+
}
|
|
374
516
|
// ── Next.js handlers & server actions ────────────────────────────────────
|
|
375
517
|
const NEXTJS_HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']);
|
|
376
518
|
function hasUseServerDirective(sf) {
|
|
@@ -994,12 +1136,42 @@ function extractTarget(call) {
|
|
|
994
1136
|
if (first.getKind() === SyntaxKind.StringLiteral) {
|
|
995
1137
|
return first.getLiteralValue();
|
|
996
1138
|
}
|
|
997
|
-
if (first.getKind() === SyntaxKind.
|
|
998
|
-
first.
|
|
999
|
-
|
|
1139
|
+
if (first.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
1140
|
+
return first.getLiteralValue();
|
|
1141
|
+
}
|
|
1142
|
+
if (first.getKind() === SyntaxKind.TemplateExpression) {
|
|
1143
|
+
return extractTemplateUrl(first);
|
|
1000
1144
|
}
|
|
1001
1145
|
return undefined;
|
|
1002
1146
|
}
|
|
1147
|
+
// Convert a template literal like `${API_BASE}/api/review/${slug}` into a
|
|
1148
|
+
// server-route-shaped path like `/api/review/:slug` so cross-stack rules can
|
|
1149
|
+
// correlate it against backend routes. Without this every `fetch(` \`${BASE}/…\` `)`
|
|
1150
|
+
// call is silently dropped by `normalizeClientUrl` (which rejects targets that
|
|
1151
|
+
// start with \`$ instead of \`/).
|
|
1152
|
+
function extractTemplateUrl(tmpl) {
|
|
1153
|
+
const head = tmpl.getHead();
|
|
1154
|
+
let out = head.getLiteralText();
|
|
1155
|
+
const spans = tmpl.getTemplateSpans();
|
|
1156
|
+
for (let i = 0; i < spans.length; i++) {
|
|
1157
|
+
const span = spans[i];
|
|
1158
|
+
const expr = span.getExpression();
|
|
1159
|
+
const exprText = expr.getText();
|
|
1160
|
+
const isBareIdent = expr.getKind() === SyntaxKind.Identifier;
|
|
1161
|
+
if (i === 0 && out === '' && isBareIdent && looksLikeBaseUrlName(exprText)) {
|
|
1162
|
+
// Drop a leading `${BASE_URL}` interpolation so the path starts with `/`.
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
out += `:${isBareIdent ? exprText : 'param'}`;
|
|
1166
|
+
}
|
|
1167
|
+
out += span.getLiteral().getLiteralText();
|
|
1168
|
+
}
|
|
1169
|
+
return out.length > 0 ? out : undefined;
|
|
1170
|
+
}
|
|
1171
|
+
function looksLikeBaseUrlName(name) {
|
|
1172
|
+
return (/(^|_)(base|url|host|origin|endpoint|api|server)(_|$)/i.test(name) ||
|
|
1173
|
+
/(Base|Url|Host|Origin|Endpoint|Api|Server)$/.test(name));
|
|
1174
|
+
}
|
|
1003
1175
|
/**
|
|
1004
1176
|
* Given a network call (fetch/axios/…), decide whether the eventual JSON
|
|
1005
1177
|
* payload is consumed with a type annotation, `as T` cast, or `satisfies T`
|
|
@@ -1013,7 +1185,14 @@ function extractTarget(call) {
|
|
|
1013
1185
|
* the server's declared response shape as `any`). Kept intentionally
|
|
1014
1186
|
* conservative — false positives here poison the pitch.
|
|
1015
1187
|
*/
|
|
1016
|
-
function isResponseAsserted(call) {
|
|
1188
|
+
function isResponseAsserted(call, isWrappedClientCall = false) {
|
|
1189
|
+
// Generic type argument on the call itself, e.g. `apiClient.get<User>(url)`.
|
|
1190
|
+
// Wrapped clients (~75% of prod apps) almost always rely on this — the
|
|
1191
|
+
// wrapper pre-parses JSON and returns a typed payload, so the caller never
|
|
1192
|
+
// sees a `.json()` call. Treating the generic as the assertion is the only
|
|
1193
|
+
// way the `untyped-api-response` rule can fire on wrapped-client codebases.
|
|
1194
|
+
if (call.getTypeArguments().length > 0)
|
|
1195
|
+
return true;
|
|
1017
1196
|
// Walk outward from the network call looking for JSON consumption. Only
|
|
1018
1197
|
// after we've seen `.json()` (or a `.then(r => r.json())` callback) does
|
|
1019
1198
|
// it make sense to rule the *payload* typed vs untyped — a raw Response
|
|
@@ -1022,8 +1201,14 @@ function isResponseAsserted(call) {
|
|
|
1022
1201
|
// 550a57ec caught the false positive for the split pattern
|
|
1023
1202
|
// `const res = await fetch(url); const data: User[] = await res.json();`
|
|
1024
1203
|
// where the raw Response variable has no annotation.
|
|
1204
|
+
//
|
|
1205
|
+
// Wrapped clients pre-parse JSON inside the wrapper, so there's no
|
|
1206
|
+
// `.json()` call to observe at the call-site. For those we treat the
|
|
1207
|
+
// wrapped call itself as if JSON consumption had already happened — the
|
|
1208
|
+
// payload is whatever `apiClient.get(...)` returns, and we classify the
|
|
1209
|
+
// enclosing binding's type annotation directly.
|
|
1025
1210
|
let cursor = call;
|
|
1026
|
-
let sawJsonConsumption =
|
|
1211
|
+
let sawJsonConsumption = isWrappedClientCall;
|
|
1027
1212
|
for (let depth = 0; depth < 8; depth++) {
|
|
1028
1213
|
const parent = cursor.getParent();
|
|
1029
1214
|
if (!parent)
|
|
@@ -1140,6 +1325,122 @@ function extractBodyKind(call, funcName) {
|
|
|
1140
1325
|
// we don't have enough info to tell without type checker dataflow.
|
|
1141
1326
|
return undefined;
|
|
1142
1327
|
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Derive the HTTP method of a network call. Returns uppercase method string
|
|
1330
|
+
* (`GET`, `POST`, …) when confident, `undefined` when the method lives in a
|
|
1331
|
+
* runtime variable we can't read statically (e.g. `axios({ method: verb })`).
|
|
1332
|
+
* Feeds the `contract-method-drift` cross-stack rule.
|
|
1333
|
+
*
|
|
1334
|
+
* Resolution rules (in order):
|
|
1335
|
+
* 1. Wrapped client (`apiClient.post(…)`) or library method (`axios.get(…)`)
|
|
1336
|
+
* → `funcName.toUpperCase()`. `funcName === 'request'` is intentionally
|
|
1337
|
+
* skipped — for `axios.request({ method })` we'd need to read the config.
|
|
1338
|
+
* 2. Raw `fetch(url, { method: 'POST' })` — read the string literal. Any
|
|
1339
|
+
* spread (`{ method: 'GET', ...opts }`) downgrades to `undefined` since
|
|
1340
|
+
* we can't tell if opts overrides method at runtime.
|
|
1341
|
+
* 3. Raw `fetch(url)` / `axios(url)` with no options arg → `GET` (WHATWG +
|
|
1342
|
+
* axios spec default).
|
|
1343
|
+
* 4. Raw call with variable options arg → `undefined` (requires dataflow).
|
|
1344
|
+
*/
|
|
1345
|
+
function extractHttpMethod(call, funcName, isDirectNetwork, isKnownLibraryMethod, isWrappedClientCall) {
|
|
1346
|
+
if (isKnownLibraryMethod || isWrappedClientCall) {
|
|
1347
|
+
if (funcName === 'request')
|
|
1348
|
+
return undefined;
|
|
1349
|
+
return funcName.toUpperCase();
|
|
1350
|
+
}
|
|
1351
|
+
if (!isDirectNetwork)
|
|
1352
|
+
return undefined;
|
|
1353
|
+
const args = call.getArguments();
|
|
1354
|
+
if (args.length < 2)
|
|
1355
|
+
return 'GET';
|
|
1356
|
+
const opts = args[1];
|
|
1357
|
+
if (opts.getKind() === SyntaxKind.ObjectLiteralExpression) {
|
|
1358
|
+
const obj = opts;
|
|
1359
|
+
const hasSpread = obj.getProperties().some((p) => p.getKind() === SyntaxKind.SpreadAssignment);
|
|
1360
|
+
if (hasSpread)
|
|
1361
|
+
return undefined;
|
|
1362
|
+
const methodProp = obj.getProperty('method');
|
|
1363
|
+
if (!methodProp)
|
|
1364
|
+
return 'GET';
|
|
1365
|
+
if (methodProp.getKind() !== SyntaxKind.PropertyAssignment)
|
|
1366
|
+
return undefined;
|
|
1367
|
+
const initializer = methodProp.getInitializer();
|
|
1368
|
+
if (!initializer)
|
|
1369
|
+
return undefined;
|
|
1370
|
+
const iKind = initializer.getKind();
|
|
1371
|
+
if (iKind === SyntaxKind.StringLiteral) {
|
|
1372
|
+
return initializer.getLiteralValue().toUpperCase();
|
|
1373
|
+
}
|
|
1374
|
+
if (iKind === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
1375
|
+
return initializer.getLiteralValue().toUpperCase();
|
|
1376
|
+
}
|
|
1377
|
+
return undefined;
|
|
1378
|
+
}
|
|
1379
|
+
return undefined;
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Detect whether a network call's options literal carries an `Authorization`
|
|
1383
|
+
* header. Returns `true`/`false` when the options object is inspectable,
|
|
1384
|
+
* `undefined` when it's a variable, spread, or missing. Only looks at raw
|
|
1385
|
+
* `fetch(…)` — wrapped clients typically inject auth inside the wrapper.
|
|
1386
|
+
* Feeds the `auth-drift` rule, which only fires when the mapper is confident.
|
|
1387
|
+
*/
|
|
1388
|
+
function extractHasAuthHeader(call, funcName) {
|
|
1389
|
+
if (funcName !== 'fetch')
|
|
1390
|
+
return undefined;
|
|
1391
|
+
const args = call.getArguments();
|
|
1392
|
+
if (args.length < 2)
|
|
1393
|
+
return false;
|
|
1394
|
+
const opts = args[1];
|
|
1395
|
+
if (opts.getKind() !== SyntaxKind.ObjectLiteralExpression)
|
|
1396
|
+
return undefined;
|
|
1397
|
+
const obj = opts;
|
|
1398
|
+
if (obj.getProperties().some((p) => p.getKind() === SyntaxKind.SpreadAssignment))
|
|
1399
|
+
return undefined;
|
|
1400
|
+
// Cookie / session auth: `fetch(url, { credentials: 'include' })` or
|
|
1401
|
+
// `'same-origin'` sends session cookies without an Authorization header.
|
|
1402
|
+
// We can't tell from the call alone whether the server accepts that auth
|
|
1403
|
+
// channel, so downgrade to `undefined` and let auth-drift stay silent.
|
|
1404
|
+
// Codex review: without this, cookie-based apps fire auth-drift on every
|
|
1405
|
+
// protected route even though they're authenticated.
|
|
1406
|
+
const credentialsProp = obj.getProperty('credentials');
|
|
1407
|
+
if (credentialsProp && credentialsProp.getKind() === SyntaxKind.PropertyAssignment) {
|
|
1408
|
+
const init = credentialsProp.getInitializer();
|
|
1409
|
+
if (init) {
|
|
1410
|
+
const k = init.getKind();
|
|
1411
|
+
if (k === SyntaxKind.StringLiteral || k === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
1412
|
+
const v = init.getLiteralValue();
|
|
1413
|
+
if (v === 'include' || v === 'same-origin')
|
|
1414
|
+
return undefined;
|
|
1415
|
+
}
|
|
1416
|
+
else {
|
|
1417
|
+
// Runtime-computed credentials flag — can't tell.
|
|
1418
|
+
return undefined;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const headersProp = obj.getProperty('headers');
|
|
1423
|
+
if (!headersProp)
|
|
1424
|
+
return false;
|
|
1425
|
+
if (headersProp.getKind() !== SyntaxKind.PropertyAssignment)
|
|
1426
|
+
return undefined;
|
|
1427
|
+
const headersInit = headersProp.getInitializer();
|
|
1428
|
+
if (!headersInit)
|
|
1429
|
+
return undefined;
|
|
1430
|
+
if (headersInit.getKind() !== SyntaxKind.ObjectLiteralExpression)
|
|
1431
|
+
return undefined;
|
|
1432
|
+
const headersObj = headersInit;
|
|
1433
|
+
if (headersObj.getProperties().some((p) => p.getKind() === SyntaxKind.SpreadAssignment))
|
|
1434
|
+
return undefined;
|
|
1435
|
+
for (const prop of headersObj.getProperties()) {
|
|
1436
|
+
if (prop.getKind() === SyntaxKind.PropertyAssignment) {
|
|
1437
|
+
const pa = prop;
|
|
1438
|
+
if (/^['"]?authorization['"]?$/i.test(pa.getName()))
|
|
1439
|
+
return true;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return false;
|
|
1443
|
+
}
|
|
1143
1444
|
function classifyBodyExpression(expr) {
|
|
1144
1445
|
if (!expr)
|
|
1145
1446
|
return undefined;
|
|
@@ -1239,4 +1540,96 @@ function containsAssertion(node) {
|
|
|
1239
1540
|
}
|
|
1240
1541
|
return false;
|
|
1241
1542
|
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Scan `sf` for identifiers that behave like HTTP client wrappers, so that
|
|
1545
|
+
* `extractEffects` can emit `effect.network` for `<name>.get/post/…` calls
|
|
1546
|
+
* that would otherwise slip through the fixed NETWORK_CALLS/library-method
|
|
1547
|
+
* filter.
|
|
1548
|
+
*
|
|
1549
|
+
* Three evidence sources (all single-file, no cross-file graph resolution):
|
|
1550
|
+
* 1. Local class declarations whose bodies call a known network primitive
|
|
1551
|
+
* somewhere — that class IS a client wrapper. The class name is used
|
|
1552
|
+
* in pass 2 to mark `new <ClassName>()` instances.
|
|
1553
|
+
* 2. Local variable initializers that match a client factory
|
|
1554
|
+
* (`axios.create(...)`, `ky.create(...)`, `got.extend(...)`) or
|
|
1555
|
+
* `new <ClientClass>(...)` where <ClientClass> was found in pass 1.
|
|
1556
|
+
* 3. Imported identifiers from a relative / alias path whose local name
|
|
1557
|
+
* matches CLIENT_NAME_PATTERN. Third-party imports are skipped —
|
|
1558
|
+
* library HTTP clients are already covered by NETWORK_CALLS.
|
|
1559
|
+
*
|
|
1560
|
+
* False-positive surface is narrow on purpose: a match only translates into
|
|
1561
|
+
* a network effect when the identifier is later called with `.get/post/put/
|
|
1562
|
+
* patch/delete`, so a name match alone never produces a finding.
|
|
1563
|
+
*/
|
|
1564
|
+
function collectClientIdentifiers(sf) {
|
|
1565
|
+
const clients = new Set();
|
|
1566
|
+
// Pass 1 — wrapper classes defined in this file.
|
|
1567
|
+
const clientClassNames = new Set();
|
|
1568
|
+
for (const cls of sf.getDescendantsOfKind(SyntaxKind.ClassDeclaration)) {
|
|
1569
|
+
if (classCallsNetwork(cls)) {
|
|
1570
|
+
const name = cls.getName();
|
|
1571
|
+
if (name)
|
|
1572
|
+
clientClassNames.add(name);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
// Pass 2 — local instances: `const api = axios.create(...)`, `new ApiClient()`.
|
|
1576
|
+
for (const decl of sf.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
|
|
1577
|
+
const nameNode = decl.getNameNode();
|
|
1578
|
+
if (nameNode.getKind() !== SyntaxKind.Identifier)
|
|
1579
|
+
continue;
|
|
1580
|
+
const init = decl.getInitializer();
|
|
1581
|
+
if (!init)
|
|
1582
|
+
continue;
|
|
1583
|
+
const identName = nameNode.getText();
|
|
1584
|
+
if (init.getKind() === SyntaxKind.NewExpression) {
|
|
1585
|
+
const className = init.getExpression().getText();
|
|
1586
|
+
if (clientClassNames.has(className))
|
|
1587
|
+
clients.add(identName);
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
if (init.getKind() === SyntaxKind.CallExpression) {
|
|
1591
|
+
const calleeText = init.getExpression().getText();
|
|
1592
|
+
if (CLIENT_FACTORY_CALLS.has(calleeText))
|
|
1593
|
+
clients.add(identName);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
// Pass 3 — imported clients with client-shaped names from local paths.
|
|
1597
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
1598
|
+
const spec = imp.getModuleSpecifierValue();
|
|
1599
|
+
if (!spec)
|
|
1600
|
+
continue;
|
|
1601
|
+
const isLocal = spec.startsWith('.') || spec.startsWith('@/') || spec.startsWith('~/') || spec.startsWith('@shared/');
|
|
1602
|
+
if (!isLocal)
|
|
1603
|
+
continue;
|
|
1604
|
+
for (const named of imp.getNamedImports()) {
|
|
1605
|
+
const local = named.getAliasNode()?.getText() ?? named.getNameNode().getText();
|
|
1606
|
+
if (CLIENT_NAME_PATTERN.test(local))
|
|
1607
|
+
clients.add(local);
|
|
1608
|
+
}
|
|
1609
|
+
const def = imp.getDefaultImport();
|
|
1610
|
+
if (def && CLIENT_NAME_PATTERN.test(def.getText()))
|
|
1611
|
+
clients.add(def.getText());
|
|
1612
|
+
}
|
|
1613
|
+
return clients;
|
|
1614
|
+
}
|
|
1615
|
+
function classCallsNetwork(cls) {
|
|
1616
|
+
for (const call of cls.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1617
|
+
const callee = call.getExpression();
|
|
1618
|
+
const k = callee.getKind();
|
|
1619
|
+
if (k === SyntaxKind.Identifier) {
|
|
1620
|
+
if (NETWORK_CALLS.has(callee.getText()))
|
|
1621
|
+
return true;
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
if (k === SyntaxKind.PropertyAccessExpression) {
|
|
1625
|
+
const pa = callee;
|
|
1626
|
+
const methodName = pa.getName();
|
|
1627
|
+
const objText = pa.getExpression().getText();
|
|
1628
|
+
if (NETWORK_METHODS.has(methodName) && /^(axios|got|ky|superagent|request|http)$/.test(objText)) {
|
|
1629
|
+
return true;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
return false;
|
|
1634
|
+
}
|
|
1242
1635
|
//# sourceMappingURL=ts-concepts.js.map
|