@kernlang/review 3.3.4 → 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.
Files changed (62) hide show
  1. package/dist/cache.js +1 -1
  2. package/dist/concept-rules/auth-drift.d.ts +29 -0
  3. package/dist/concept-rules/auth-drift.js +127 -0
  4. package/dist/concept-rules/auth-drift.js.map +1 -0
  5. package/dist/concept-rules/contract-drift.d.ts +21 -0
  6. package/dist/concept-rules/contract-drift.js +65 -0
  7. package/dist/concept-rules/contract-drift.js.map +1 -0
  8. package/dist/concept-rules/contract-method-drift.d.ts +22 -0
  9. package/dist/concept-rules/contract-method-drift.js +105 -0
  10. package/dist/concept-rules/contract-method-drift.js.map +1 -0
  11. package/dist/concept-rules/cross-stack-utils.d.ts +96 -0
  12. package/dist/concept-rules/cross-stack-utils.js +259 -0
  13. package/dist/concept-rules/cross-stack-utils.js.map +1 -0
  14. package/dist/concept-rules/duplicate-route.d.ts +20 -0
  15. package/dist/concept-rules/duplicate-route.js +112 -0
  16. package/dist/concept-rules/duplicate-route.js.map +1 -0
  17. package/dist/concept-rules/index.js +26 -1
  18. package/dist/concept-rules/index.js.map +1 -1
  19. package/dist/concept-rules/missing-response-model.d.ts +10 -0
  20. package/dist/concept-rules/missing-response-model.js +38 -0
  21. package/dist/concept-rules/missing-response-model.js.map +1 -0
  22. package/dist/concept-rules/orphan-route.d.ts +20 -0
  23. package/dist/concept-rules/orphan-route.js +96 -0
  24. package/dist/concept-rules/orphan-route.js.map +1 -0
  25. package/dist/concept-rules/sync-handler-does-io.d.ts +9 -0
  26. package/dist/concept-rules/sync-handler-does-io.js +56 -0
  27. package/dist/concept-rules/sync-handler-does-io.js.map +1 -0
  28. package/dist/concept-rules/tainted-across-wire.d.ts +33 -0
  29. package/dist/concept-rules/tainted-across-wire.js +95 -0
  30. package/dist/concept-rules/tainted-across-wire.js.map +1 -0
  31. package/dist/concept-rules/untyped-api-response.d.ts +30 -0
  32. package/dist/concept-rules/untyped-api-response.js +73 -0
  33. package/dist/concept-rules/untyped-api-response.js.map +1 -0
  34. package/dist/concept-rules/untyped-both-ends-response.d.ts +10 -0
  35. package/dist/concept-rules/untyped-both-ends-response.js +55 -0
  36. package/dist/concept-rules/untyped-both-ends-response.js.map +1 -0
  37. package/dist/external-tools.d.ts +17 -4
  38. package/dist/external-tools.js +12 -1
  39. package/dist/external-tools.js.map +1 -1
  40. package/dist/index.d.ts +2 -1
  41. package/dist/index.js +115 -9
  42. package/dist/index.js.map +1 -1
  43. package/dist/llm-bridge.d.ts +38 -1
  44. package/dist/llm-bridge.js +172 -12
  45. package/dist/llm-bridge.js.map +1 -1
  46. package/dist/llm-review.js +29 -11
  47. package/dist/llm-review.js.map +1 -1
  48. package/dist/mappers/ts-concepts.js +650 -11
  49. package/dist/mappers/ts-concepts.js.map +1 -1
  50. package/dist/rules/index.js +17 -1
  51. package/dist/rules/index.js.map +1 -1
  52. package/dist/rules/kern-source.js +37 -5
  53. package/dist/rules/kern-source.js.map +1 -1
  54. package/dist/rules/set-setter-collision.d.ts +21 -0
  55. package/dist/rules/set-setter-collision.js +74 -0
  56. package/dist/rules/set-setter-collision.js.map +1 -0
  57. package/dist/rules/suggest-kern-primitive.d.ts +30 -0
  58. package/dist/rules/suggest-kern-primitive.js +543 -0
  59. package/dist/rules/suggest-kern-primitive.js.map +1 -0
  60. package/dist/types.d.ts +2 -0
  61. package/dist/types.js.map +1 -1
  62. 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,18 +269,29 @@ function extractEffects(sf, filePath, nodes) {
250
269
  objName = pa.getExpression().getText();
251
270
  }
252
271
  // Network effects
253
- if (NETWORK_CALLS.has(funcName) ||
254
- (NETWORK_METHODS.has(funcName) && /axios|got|ky|http|request|superagent/i.test(objName))) {
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: NETWORK_CALLS.has(funcName) ? 1.0 : 0.8,
282
+ confidence: isDirectNetwork ? 1.0 : isWrappedClientCall ? 0.75 : 0.8,
262
283
  language: 'ts',
263
284
  containerId: getContainerId(call, filePath),
264
- payload: { kind: 'effect', subtype: 'network', async: isAsync, target: extractTarget(call) },
285
+ payload: {
286
+ kind: 'effect',
287
+ subtype: 'network',
288
+ async: isAsync,
289
+ target: extractTarget(call),
290
+ responseAsserted: isResponseAsserted(call, isWrappedClientCall),
291
+ bodyKind: extractBodyKind(call, funcName),
292
+ method: extractHttpMethod(call, funcName, isDirectNetwork, isKnownLibraryMethod, isWrappedClientCall),
293
+ hasAuthHeader: extractHasAuthHeader(call, funcName),
294
+ },
265
295
  });
266
296
  continue;
267
297
  }
@@ -295,7 +325,7 @@ function extractEffects(sf, filePath, nodes) {
295
325
  }
296
326
  }
297
327
  // ── entrypoint ───────────────────────────────────────────────────────────
298
- const ROUTE_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'all', 'use']);
328
+ const ROUTE_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'all']);
299
329
  function extractEntrypoints(sf, filePath, nodes) {
300
330
  // Express/Fastify route handlers: app.get('/path', handler)
301
331
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
@@ -304,14 +334,20 @@ function extractEntrypoints(sf, filePath, nodes) {
304
334
  continue;
305
335
  const pa = callee;
306
336
  const methodName = pa.getName();
307
- if (!ROUTE_METHODS.has(methodName))
308
- continue;
309
337
  const objText = pa.getExpression().getText();
310
338
  if (!/app|router|server/i.test(objText))
311
339
  continue;
312
340
  const args = call.getArguments();
313
341
  if (args.length < 2)
314
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;
315
351
  // First arg is the route path
316
352
  let routePath;
317
353
  if (args[0].getKind() === SyntaxKind.StringLiteral) {
@@ -329,7 +365,7 @@ function extractEntrypoints(sf, filePath, nodes) {
329
365
  kind: 'entrypoint',
330
366
  subtype: 'route',
331
367
  name: routePath || methodName,
332
- httpMethod: methodName === 'use' ? undefined : methodName.toUpperCase(),
368
+ httpMethod: methodName.toUpperCase(),
333
369
  },
334
370
  });
335
371
  }
@@ -364,6 +400,119 @@ function extractEntrypoints(sf, filePath, nodes) {
364
400
  }
365
401
  }
366
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
+ }
367
516
  // ── Next.js handlers & server actions ────────────────────────────────────
368
517
  const NEXTJS_HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']);
369
518
  function hasUseServerDirective(sf) {
@@ -987,10 +1136,500 @@ function extractTarget(call) {
987
1136
  if (first.getKind() === SyntaxKind.StringLiteral) {
988
1137
  return first.getLiteralValue();
989
1138
  }
990
- if (first.getKind() === SyntaxKind.TemplateExpression ||
991
- first.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral) {
992
- return first.getText().substring(0, 80);
1139
+ if (first.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral) {
1140
+ return first.getLiteralValue();
1141
+ }
1142
+ if (first.getKind() === SyntaxKind.TemplateExpression) {
1143
+ return extractTemplateUrl(first);
1144
+ }
1145
+ return undefined;
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
+ }
1175
+ /**
1176
+ * Given a network call (fetch/axios/…), decide whether the eventual JSON
1177
+ * payload is consumed with a type annotation, `as T` cast, or `satisfies T`
1178
+ * clause. Returns:
1179
+ * - `true` — the call-site is typed; the consumer enforces a shape.
1180
+ * - `false` — the call-site is awaited/.then()'d but no assertion appears.
1181
+ * - `undefined` — no `.json()` consumption in scope, or the pattern is
1182
+ * too complex to analyze.
1183
+ *
1184
+ * Powers the `untyped-api-response` cross-stack rule (the frontend treats
1185
+ * the server's declared response shape as `any`). Kept intentionally
1186
+ * conservative — false positives here poison the pitch.
1187
+ */
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;
1196
+ // Walk outward from the network call looking for JSON consumption. Only
1197
+ // after we've seen `.json()` (or a `.then(r => r.json())` callback) does
1198
+ // it make sense to rule the *payload* typed vs untyped — a raw Response
1199
+ // assigned to a variable like `const res = await fetch(...)` tells us
1200
+ // nothing about how the caller will later parse it. Codex review on
1201
+ // 550a57ec caught the false positive for the split pattern
1202
+ // `const res = await fetch(url); const data: User[] = await res.json();`
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.
1210
+ let cursor = call;
1211
+ let sawJsonConsumption = isWrappedClientCall;
1212
+ for (let depth = 0; depth < 8; depth++) {
1213
+ const parent = cursor.getParent();
1214
+ if (!parent)
1215
+ return undefined;
1216
+ if (parent.getKind() === SyntaxKind.AwaitExpression || parent.getKind() === SyntaxKind.ParenthesizedExpression) {
1217
+ cursor = parent;
1218
+ continue;
1219
+ }
1220
+ if (parent.getKind() === SyntaxKind.PropertyAccessExpression) {
1221
+ const pa = parent;
1222
+ const parentCall = pa.getParent();
1223
+ if (pa.getName() === 'then' && parentCall?.getKind() === SyntaxKind.CallExpression) {
1224
+ // Count this as JSON consumption only if the `.then` callback
1225
+ // clearly parses JSON. A `.then(r => r.status)` chain doesn't.
1226
+ if (callbackCallsJson(parentCall)) {
1227
+ sawJsonConsumption = true;
1228
+ }
1229
+ cursor = parentCall;
1230
+ continue;
1231
+ }
1232
+ if (pa.getName() === 'json' && parentCall?.getKind() === SyntaxKind.CallExpression) {
1233
+ // `(…).json()` — we've now reached the JSON payload.
1234
+ sawJsonConsumption = true;
1235
+ cursor = parentCall;
1236
+ continue;
1237
+ }
1238
+ return undefined;
1239
+ }
1240
+ // From here on, every branch is about classifying the payload.
1241
+ // If we haven't actually reached the payload yet, we're looking at a
1242
+ // raw Response and can't honestly say whether it's typed — bail with
1243
+ // `undefined` instead of misclassifying it as untyped.
1244
+ if (!sawJsonConsumption)
1245
+ return undefined;
1246
+ if (parent.getKind() === SyntaxKind.VariableDeclaration) {
1247
+ const decl = parent;
1248
+ if (decl.getTypeNode())
1249
+ return true;
1250
+ return containsAssertion(decl.getInitializer());
1251
+ }
1252
+ if (parent.getKind() === SyntaxKind.ReturnStatement ||
1253
+ parent.getKind() === SyntaxKind.ArrowFunction ||
1254
+ parent.getKind() === SyntaxKind.BinaryExpression) {
1255
+ return containsAssertion(cursor);
1256
+ }
1257
+ if (parent.getKind() === SyntaxKind.AsExpression ||
1258
+ parent.getKind() === SyntaxKind.TypeAssertionExpression ||
1259
+ parent.getKind() === SyntaxKind.SatisfiesExpression) {
1260
+ return true;
1261
+ }
1262
+ return undefined;
1263
+ }
1264
+ return undefined;
1265
+ }
1266
+ /**
1267
+ * Peek into a `.then(...)` call's first argument and return true when the
1268
+ * callback body contains a `.json()` call. Used to tell whether a
1269
+ * `.then(r => r.json())` chain actually resolves to JSON — as opposed to
1270
+ * `.then(r => r.status)` which resolves to a number and should not be
1271
+ * treated as JSON consumption.
1272
+ */
1273
+ /**
1274
+ * Classify the body payload of a network call (fetch/axios/…):
1275
+ * - `'none'` — no options arg, or no body property found.
1276
+ * - `'static'` — body is a literal (string/object/array) with no dynamic
1277
+ * interpolation.
1278
+ * - `'dynamic'` — body reads from a variable, uses a template literal
1279
+ * containing `${…}`, or calls `JSON.stringify(x)` on a non-literal `x`.
1280
+ *
1281
+ * Feeds the `tainted-across-wire` cross-stack rule. Conservative by design:
1282
+ * when unsure we return `undefined` (fallthrough) so the rule stays silent
1283
+ * instead of over-reporting.
1284
+ */
1285
+ /**
1286
+ * Network libraries split into two call-site conventions:
1287
+ * fetch-style — `fetch(url, options)` where `options.body` carries the
1288
+ * payload. `ky` and generic `request()` follow this shape.
1289
+ * axios-style — `axios.post(url, data, config)` where the second arg IS
1290
+ * the payload directly. `got.post`, `superagent.post`, and most axios
1291
+ * method calls (post/put/patch) match this.
1292
+ * Gemini review on d8f95d49 caught that the flat "object literal at arg1?
1293
+ * look for .body" logic returned `none` for legitimate axios payloads like
1294
+ * `axios.post('/api', { name: 'foo' })` — the whole object IS the body.
1295
+ */
1296
+ const AXIOS_STYLE_METHODS = new Set(['post', 'put', 'patch']);
1297
+ function extractBodyKind(call, funcName) {
1298
+ const args = call.getArguments();
1299
+ if (args.length < 2)
1300
+ return 'none';
1301
+ const arg = args[1];
1302
+ // Axios-style: the 2nd arg *is* the body.
1303
+ if (AXIOS_STYLE_METHODS.has(funcName)) {
1304
+ return classifyBodyExpression(arg);
1305
+ }
1306
+ // Fetch-style: the 2nd arg is an options object; body lives in `.body`.
1307
+ if (arg.getKind() === SyntaxKind.ObjectLiteralExpression) {
1308
+ const obj = arg;
1309
+ const bodyProp = obj.getProperty('body');
1310
+ if (!bodyProp)
1311
+ return 'none';
1312
+ // Shorthand `{ method: 'POST', body }` — the body is a variable reference
1313
+ // by definition, so it's dynamic. Codex flagged the earlier branch that
1314
+ // returned `undefined` here as a false negative: a shorthand in a
1315
+ // fetch options object is a common RequestInit pattern, not an
1316
+ // unclassifiable construct.
1317
+ if (bodyProp.getKind() === SyntaxKind.ShorthandPropertyAssignment)
1318
+ return 'dynamic';
1319
+ if (bodyProp.getKind() !== SyntaxKind.PropertyAssignment)
1320
+ return undefined;
1321
+ const initializer = bodyProp.getInitializer();
1322
+ return classifyBodyExpression(initializer);
1323
+ }
1324
+ // Options is passed as a variable (common pattern: `fetch(url, opts)`) —
1325
+ // we don't have enough info to tell without type checker dataflow.
1326
+ return undefined;
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;
993
1378
  }
994
1379
  return undefined;
995
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
+ }
1444
+ function classifyBodyExpression(expr) {
1445
+ if (!expr)
1446
+ return undefined;
1447
+ const k = expr.getKind();
1448
+ if (k === SyntaxKind.StringLiteral || k === SyntaxKind.NoSubstitutionTemplateLiteral)
1449
+ return 'static';
1450
+ if (k === SyntaxKind.NumericLiteral || k === SyntaxKind.TrueKeyword || k === SyntaxKind.FalseKeyword)
1451
+ return 'static';
1452
+ if (k === SyntaxKind.NullKeyword || k === SyntaxKind.UndefinedKeyword)
1453
+ return 'none';
1454
+ // Template literal with ${…} → dynamic. Template without substitutions is
1455
+ // handled above as NoSubstitutionTemplateLiteral.
1456
+ if (k === SyntaxKind.TemplateExpression)
1457
+ return 'dynamic';
1458
+ // Plain object/array literal — dynamic iff any value inside is non-literal.
1459
+ if (k === SyntaxKind.ObjectLiteralExpression || k === SyntaxKind.ArrayLiteralExpression) {
1460
+ return objectOrArrayIsDynamic(expr) ? 'dynamic' : 'static';
1461
+ }
1462
+ // `JSON.stringify(x)` — dynamic when x is non-literal, static otherwise.
1463
+ if (k === SyntaxKind.CallExpression) {
1464
+ const call = expr;
1465
+ const calleeText = call.getExpression().getText();
1466
+ if (calleeText === 'JSON.stringify') {
1467
+ const arg = call.getArguments()[0];
1468
+ return classifyBodyExpression(arg);
1469
+ }
1470
+ return 'dynamic';
1471
+ }
1472
+ // Identifier, property access, element access, binary expression, etc. —
1473
+ // something that reads a variable or constructs a value at runtime.
1474
+ if (k === SyntaxKind.Identifier ||
1475
+ k === SyntaxKind.PropertyAccessExpression ||
1476
+ k === SyntaxKind.ElementAccessExpression ||
1477
+ k === SyntaxKind.BinaryExpression ||
1478
+ k === SyntaxKind.ConditionalExpression ||
1479
+ k === SyntaxKind.SpreadElement) {
1480
+ return 'dynamic';
1481
+ }
1482
+ return undefined;
1483
+ }
1484
+ function objectOrArrayIsDynamic(expr) {
1485
+ // Walk top-level values; recurse into nested objects/arrays.
1486
+ if (expr.getKind() === SyntaxKind.ObjectLiteralExpression) {
1487
+ const obj = expr;
1488
+ for (const prop of obj.getProperties()) {
1489
+ if (prop.getKind() === SyntaxKind.ShorthandPropertyAssignment)
1490
+ return true;
1491
+ if (prop.getKind() === SyntaxKind.SpreadAssignment)
1492
+ return true;
1493
+ if (prop.getKind() !== SyntaxKind.PropertyAssignment)
1494
+ return true;
1495
+ const init = prop.getInitializer();
1496
+ const kind = classifyBodyExpression(init);
1497
+ if (kind !== 'static' && kind !== 'none')
1498
+ return true;
1499
+ }
1500
+ return false;
1501
+ }
1502
+ if (expr.getKind() === SyntaxKind.ArrayLiteralExpression) {
1503
+ const arr = expr;
1504
+ for (const el of arr.getElements()) {
1505
+ const kind = classifyBodyExpression(el);
1506
+ if (kind !== 'static' && kind !== 'none')
1507
+ return true;
1508
+ }
1509
+ return false;
1510
+ }
1511
+ return true;
1512
+ }
1513
+ function callbackCallsJson(thenCall) {
1514
+ const callback = thenCall.getArguments()[0];
1515
+ if (!callback)
1516
+ return false;
1517
+ const k = callback.getKind();
1518
+ if (k !== SyntaxKind.ArrowFunction && k !== SyntaxKind.FunctionExpression)
1519
+ return false;
1520
+ const callbackText = callback.getText();
1521
+ // Text-based check is sufficient here: the callback bodies we care about
1522
+ // are short and the helper is advisory. The rule treats `undefined` as
1523
+ // "unknown" and stays silent, so a miss here is never a false positive.
1524
+ return /\.json\s*\(\s*\)/.test(callbackText);
1525
+ }
1526
+ function containsAssertion(node) {
1527
+ if (!node)
1528
+ return false;
1529
+ const k = node.getKind();
1530
+ if (k === SyntaxKind.AsExpression ||
1531
+ k === SyntaxKind.TypeAssertionExpression ||
1532
+ k === SyntaxKind.SatisfiesExpression) {
1533
+ return true;
1534
+ }
1535
+ // A single-level unwrap of `await` / `(...)` is enough for the common
1536
+ // `const x = (await fetch(...).then(r => r.json())) as User` shape.
1537
+ if (k === SyntaxKind.AwaitExpression || k === SyntaxKind.ParenthesizedExpression) {
1538
+ const child = node.getExpression();
1539
+ return containsAssertion(child);
1540
+ }
1541
+ return false;
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
+ }
996
1635
  //# sourceMappingURL=ts-concepts.js.map