@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.
Files changed (50) hide show
  1. package/dist/concept-rules/auth-drift.d.ts +29 -0
  2. package/dist/concept-rules/auth-drift.js +127 -0
  3. package/dist/concept-rules/auth-drift.js.map +1 -0
  4. package/dist/concept-rules/contract-drift.js +2 -3
  5. package/dist/concept-rules/contract-drift.js.map +1 -1
  6. package/dist/concept-rules/contract-method-drift.d.ts +22 -0
  7. package/dist/concept-rules/contract-method-drift.js +105 -0
  8. package/dist/concept-rules/contract-method-drift.js.map +1 -0
  9. package/dist/concept-rules/cross-stack-utils.d.ts +46 -0
  10. package/dist/concept-rules/cross-stack-utils.js +161 -0
  11. package/dist/concept-rules/cross-stack-utils.js.map +1 -1
  12. package/dist/concept-rules/duplicate-route.d.ts +20 -0
  13. package/dist/concept-rules/duplicate-route.js +112 -0
  14. package/dist/concept-rules/duplicate-route.js.map +1 -0
  15. package/dist/concept-rules/index.js +14 -0
  16. package/dist/concept-rules/index.js.map +1 -1
  17. package/dist/concept-rules/missing-response-model.d.ts +10 -0
  18. package/dist/concept-rules/missing-response-model.js +38 -0
  19. package/dist/concept-rules/missing-response-model.js.map +1 -0
  20. package/dist/concept-rules/orphan-route.d.ts +20 -0
  21. package/dist/concept-rules/orphan-route.js +96 -0
  22. package/dist/concept-rules/orphan-route.js.map +1 -0
  23. package/dist/concept-rules/sync-handler-does-io.d.ts +9 -0
  24. package/dist/concept-rules/sync-handler-does-io.js +56 -0
  25. package/dist/concept-rules/sync-handler-does-io.js.map +1 -0
  26. package/dist/concept-rules/tainted-across-wire.js +2 -5
  27. package/dist/concept-rules/tainted-across-wire.js.map +1 -1
  28. package/dist/concept-rules/untyped-api-response.js +8 -6
  29. package/dist/concept-rules/untyped-api-response.js.map +1 -1
  30. package/dist/concept-rules/untyped-both-ends-response.d.ts +10 -0
  31. package/dist/concept-rules/untyped-both-ends-response.js +55 -0
  32. package/dist/concept-rules/untyped-both-ends-response.js.map +1 -0
  33. package/dist/index.js +40 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/llm-bridge.d.ts +12 -0
  36. package/dist/llm-bridge.js +131 -7
  37. package/dist/llm-bridge.js.map +1 -1
  38. package/dist/mappers/ts-concepts.js +406 -13
  39. package/dist/mappers/ts-concepts.js.map +1 -1
  40. package/dist/rules/index.js +16 -0
  41. package/dist/rules/index.js.map +1 -1
  42. package/dist/rules/kern-source.js +2 -0
  43. package/dist/rules/kern-source.js.map +1 -1
  44. package/dist/rules/set-setter-collision.d.ts +21 -0
  45. package/dist/rules/set-setter-collision.js +74 -0
  46. package/dist/rules/set-setter-collision.js.map +1 -0
  47. package/dist/rules/suggest-kern-primitive.d.ts +30 -0
  48. package/dist/rules/suggest-kern-primitive.js +543 -0
  49. package/dist/rules/suggest-kern-primitive.js.map +1 -0
  50. 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
- 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
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', 'use']);
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 === 'use' ? undefined : methodName.toUpperCase(),
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.TemplateExpression ||
998
- first.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral) {
999
- 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);
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 = false;
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