@kernlang/review 3.3.8 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/cache.js +1 -1
  2. package/dist/call-graph.d.ts +10 -0
  3. package/dist/call-graph.js +138 -9
  4. package/dist/call-graph.js.map +1 -1
  5. package/dist/concept-rules/auth-drift.js +2 -0
  6. package/dist/concept-rules/auth-drift.js.map +1 -1
  7. package/dist/concept-rules/auth-propagation-drift.d.ts +10 -0
  8. package/dist/concept-rules/auth-propagation-drift.js +85 -0
  9. package/dist/concept-rules/auth-propagation-drift.js.map +1 -0
  10. package/dist/concept-rules/body-shape-drift.d.ts +32 -0
  11. package/dist/concept-rules/body-shape-drift.js +98 -0
  12. package/dist/concept-rules/body-shape-drift.js.map +1 -0
  13. package/dist/concept-rules/contract-drift.js +3 -1
  14. package/dist/concept-rules/contract-drift.js.map +1 -1
  15. package/dist/concept-rules/contract-method-drift.js +2 -0
  16. package/dist/concept-rules/contract-method-drift.js.map +1 -1
  17. package/dist/concept-rules/cross-stack-utils.d.ts +24 -0
  18. package/dist/concept-rules/cross-stack-utils.js +123 -29
  19. package/dist/concept-rules/cross-stack-utils.js.map +1 -1
  20. package/dist/concept-rules/index.d.ts +4 -2
  21. package/dist/concept-rules/index.js +22 -3
  22. package/dist/concept-rules/index.js.map +1 -1
  23. package/dist/concept-rules/mutation-without-idempotency.d.ts +10 -0
  24. package/dist/concept-rules/mutation-without-idempotency.js +47 -0
  25. package/dist/concept-rules/mutation-without-idempotency.js.map +1 -0
  26. package/dist/concept-rules/request-validation-drift.d.ts +11 -0
  27. package/dist/concept-rules/request-validation-drift.js +99 -0
  28. package/dist/concept-rules/request-validation-drift.js.map +1 -0
  29. package/dist/concept-rules/root-cause.d.ts +4 -0
  30. package/dist/concept-rules/root-cause.js +31 -0
  31. package/dist/concept-rules/root-cause.js.map +1 -0
  32. package/dist/concept-rules/unbounded-collection-query.d.ts +10 -0
  33. package/dist/concept-rules/unbounded-collection-query.js +58 -0
  34. package/dist/concept-rules/unbounded-collection-query.js.map +1 -0
  35. package/dist/concept-rules/unhandled-api-error-shape.d.ts +10 -0
  36. package/dist/concept-rules/unhandled-api-error-shape.js +59 -0
  37. package/dist/concept-rules/unhandled-api-error-shape.js.map +1 -0
  38. package/dist/default-export.d.ts +41 -0
  39. package/dist/default-export.js +76 -0
  40. package/dist/default-export.js.map +1 -0
  41. package/dist/eval.d.ts +67 -0
  42. package/dist/eval.js +177 -0
  43. package/dist/eval.js.map +1 -0
  44. package/dist/external-tools.js +52 -3
  45. package/dist/external-tools.js.map +1 -1
  46. package/dist/file-context.js +32 -13
  47. package/dist/file-context.js.map +1 -1
  48. package/dist/file-role.d.ts +6 -0
  49. package/dist/file-role.js +27 -0
  50. package/dist/file-role.js.map +1 -1
  51. package/dist/framework-seeds.d.ts +46 -0
  52. package/dist/framework-seeds.js +245 -0
  53. package/dist/framework-seeds.js.map +1 -0
  54. package/dist/git-env.d.ts +1 -0
  55. package/dist/git-env.js +25 -0
  56. package/dist/git-env.js.map +1 -0
  57. package/dist/graph.js +246 -21
  58. package/dist/graph.js.map +1 -1
  59. package/dist/index.d.ts +12 -3
  60. package/dist/index.js +314 -96
  61. package/dist/index.js.map +1 -1
  62. package/dist/mappers/ts-concepts.js +730 -1
  63. package/dist/mappers/ts-concepts.js.map +1 -1
  64. package/dist/path-canonical.d.ts +34 -0
  65. package/dist/path-canonical.js +85 -0
  66. package/dist/path-canonical.js.map +1 -0
  67. package/dist/policy.d.ts +22 -0
  68. package/dist/policy.js +47 -0
  69. package/dist/policy.js.map +1 -0
  70. package/dist/project-context.d.ts +135 -0
  71. package/dist/project-context.js +563 -0
  72. package/dist/project-context.js.map +1 -0
  73. package/dist/public-api.d.ts +21 -0
  74. package/dist/public-api.js +17 -2
  75. package/dist/public-api.js.map +1 -1
  76. package/dist/python-fallback.d.ts +2 -0
  77. package/dist/python-fallback.js +506 -0
  78. package/dist/python-fallback.js.map +1 -0
  79. package/dist/reporter.js +106 -1
  80. package/dist/reporter.js.map +1 -1
  81. package/dist/rule-quality.d.ts +58 -0
  82. package/dist/rule-quality.js +357 -0
  83. package/dist/rule-quality.js.map +1 -0
  84. package/dist/rules/base.js +21 -3
  85. package/dist/rules/base.js.map +1 -1
  86. package/dist/rules/dead-code.d.ts +2 -2
  87. package/dist/rules/dead-code.js +88 -4
  88. package/dist/rules/dead-code.js.map +1 -1
  89. package/dist/rules/index.d.ts +22 -0
  90. package/dist/rules/index.js +72 -0
  91. package/dist/rules/index.js.map +1 -1
  92. package/dist/rules/kern-source.d.ts +4 -0
  93. package/dist/rules/kern-source.js +184 -0
  94. package/dist/rules/kern-source.js.map +1 -1
  95. package/dist/rules/react.js +52 -3
  96. package/dist/rules/react.js.map +1 -1
  97. package/dist/rules/suggest-kern-primitive.js +0 -1
  98. package/dist/rules/suggest-kern-primitive.js.map +1 -1
  99. package/dist/semantic-diff.js +2 -0
  100. package/dist/semantic-diff.js.map +1 -1
  101. package/dist/suppression/apply-suppression.js +2 -0
  102. package/dist/suppression/apply-suppression.js.map +1 -1
  103. package/dist/suppression/parse-directives.d.ts +13 -5
  104. package/dist/suppression/parse-directives.js +62 -8
  105. package/dist/suppression/parse-directives.js.map +1 -1
  106. package/dist/suppression/types.d.ts +9 -0
  107. package/dist/suppression/types.js +6 -1
  108. package/dist/suppression/types.js.map +1 -1
  109. package/dist/taint-crossfile.js +15 -8
  110. package/dist/taint-crossfile.js.map +1 -1
  111. package/dist/telemetry.d.ts +126 -0
  112. package/dist/telemetry.js +303 -0
  113. package/dist/telemetry.js.map +1 -0
  114. package/dist/types.d.ts +172 -2
  115. package/dist/types.js.map +1 -1
  116. package/package.json +4 -3
@@ -49,6 +49,32 @@ const DB_CALLS = new Set([
49
49
  'findOne',
50
50
  'countDocuments',
51
51
  ]);
52
+ const DB_COLLECTION_READ_CALLS = new Set([
53
+ 'findMany',
54
+ 'find',
55
+ 'select',
56
+ 'query',
57
+ 'aggregate',
58
+ 'toArray',
59
+ 'all',
60
+ 'fetchAll',
61
+ ]);
62
+ const DB_WRITE_CALLS = new Set([
63
+ 'create',
64
+ 'createMany',
65
+ 'insert',
66
+ 'insertOne',
67
+ 'insertMany',
68
+ 'update',
69
+ 'updateMany',
70
+ 'updateOne',
71
+ 'delete',
72
+ 'deleteMany',
73
+ 'deleteOne',
74
+ 'remove',
75
+ 'save',
76
+ 'upsert',
77
+ ]);
52
78
  const FS_CALLS = new Set([
53
79
  'readFile',
54
80
  'readFileSync',
@@ -274,6 +300,9 @@ function extractEffects(sf, filePath, nodes) {
274
300
  const isWrappedClientCall = CLIENT_HTTP_METHODS.has(funcName) && clientIdents.has(objName);
275
301
  if (isDirectNetwork || isKnownLibraryMethod || isWrappedClientCall) {
276
302
  const isAsync = isInAsyncContext(call);
303
+ const sentFieldsInfo = extractSentFields(call, funcName);
304
+ const queryParamsInfo = extractQueryParams(call);
305
+ const hasAuthHeader = extractHasAuthHeader(call, funcName);
277
306
  nodes.push({
278
307
  id: conceptId(filePath, 'effect', call.getStart()),
279
308
  kind: 'effect',
@@ -290,7 +319,13 @@ function extractEffects(sf, filePath, nodes) {
290
319
  responseAsserted: isResponseAsserted(call, isWrappedClientCall),
291
320
  bodyKind: extractBodyKind(call, funcName),
292
321
  method: extractHttpMethod(call, funcName, isDirectNetwork, isKnownLibraryMethod, isWrappedClientCall),
293
- hasAuthHeader: extractHasAuthHeader(call, funcName),
322
+ hasAuthHeader,
323
+ sentFields: sentFieldsInfo.fields,
324
+ sentFieldsResolved: sentFieldsInfo.resolved,
325
+ handlesApiErrors: extractHandlesApiErrors(call, isWrappedClientCall),
326
+ authPropagation: extractAuthPropagation(call, funcName, objName, isWrappedClientCall, hasAuthHeader),
327
+ queryParams: queryParamsInfo.params,
328
+ queryParamsResolved: queryParamsInfo.resolved,
294
329
  },
295
330
  });
296
331
  continue;
@@ -353,6 +388,23 @@ function extractEntrypoints(sf, filePath, nodes) {
353
388
  if (args[0].getKind() === SyntaxKind.StringLiteral) {
354
389
  routePath = args[0].getLiteralValue();
355
390
  }
391
+ // Inline, same-file named, or statically imported handler. The id we
392
+ // compute for same-file handlers must match the function_declaration
393
+ // concept emitted for the same function, so downstream rules can
394
+ // dereference `handlerConceptId`. Imported handlers are still analyzed
395
+ // when TypeScript can resolve their body, but are not linked into this
396
+ // file's concept map.
397
+ const resolvedHandler = resolveExpressRouteHandler(args, filePath);
398
+ const handlerFn = resolvedHandler?.fn;
399
+ const handlerConceptId = resolvedHandler && isSameConceptSourceFile(resolvedHandler.conceptFilePath, filePath)
400
+ ? conceptId(filePath, 'function_declaration', resolvedHandler.conceptStart)
401
+ : undefined;
402
+ const bodyFieldsInfo = handlerFn
403
+ ? extractHandlerBodyFields(handlerFn)
404
+ : { fields: undefined, resolved: false };
405
+ const routeAnalysis = handlerFn
406
+ ? analyzeExpressRouteHandler(handlerFn, args, methodName.toUpperCase(), routePath)
407
+ : EMPTY_ROUTE_ANALYSIS;
356
408
  nodes.push({
357
409
  id: conceptId(filePath, 'entrypoint', call.getStart()),
358
410
  kind: 'entrypoint',
@@ -366,6 +418,16 @@ function extractEntrypoints(sf, filePath, nodes) {
366
418
  subtype: 'route',
367
419
  name: routePath || methodName,
368
420
  httpMethod: methodName.toUpperCase(),
421
+ handlerConceptId,
422
+ bodyFields: bodyFieldsInfo.fields,
423
+ bodyFieldsResolved: bodyFieldsInfo.resolved,
424
+ errorStatusCodes: routeAnalysis.errorStatusCodes,
425
+ hasUnboundedCollectionQuery: routeAnalysis.hasUnboundedCollectionQuery,
426
+ hasDbWrite: routeAnalysis.hasDbWrite,
427
+ hasIdempotencyProtection: routeAnalysis.hasIdempotencyProtection,
428
+ hasBodyValidation: routeAnalysis.hasBodyValidation,
429
+ validatedBodyFields: routeAnalysis.validatedBodyFields,
430
+ bodyValidationResolved: routeAnalysis.bodyValidationResolved,
369
431
  },
370
432
  });
371
433
  }
@@ -400,6 +462,235 @@ function extractEntrypoints(sf, filePath, nodes) {
400
462
  }
401
463
  }
402
464
  }
465
+ const EMPTY_ROUTE_ANALYSIS = {};
466
+ const API_ERROR_STATUS_CODES = new Set([401, 403, 404, 422, 500]);
467
+ const PAGINATION_RE = /\b(limit|take|offset|skip|cursor|page|pageSize|perPage)\b|\.limit\s*\(|\.take\s*\(/i;
468
+ const IDEMPOTENCY_RE = /\b(idempotency|Idempotency-Key|transaction|\$transaction|unique|upsert|findUnique|findOne|on\s+conflict|getOrCreate|createOrGet)\b/i;
469
+ function analyzeExpressRouteHandler(handlerFn, routeArgs, method, routePath) {
470
+ const text = handlerFn.getText();
471
+ const errorStatusCodes = extractExpressErrorStatusCodes(handlerFn);
472
+ const validation = extractExpressValidation(handlerFn, routeArgs);
473
+ return {
474
+ errorStatusCodes,
475
+ hasUnboundedCollectionQuery: hasUnboundedExpressCollectionQuery(handlerFn, method, routePath),
476
+ hasDbWrite: handlerHasDbWrite(handlerFn),
477
+ hasIdempotencyProtection: IDEMPOTENCY_RE.test(text),
478
+ hasBodyValidation: validation.has,
479
+ validatedBodyFields: validation.fields,
480
+ bodyValidationResolved: validation.resolved,
481
+ };
482
+ }
483
+ function resolveExpressRouteHandler(routeArgs, filePath) {
484
+ for (let i = routeArgs.length - 1; i >= 1; i--) {
485
+ const arg = routeArgs[i];
486
+ const kind = arg.getKind();
487
+ if (kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.FunctionExpression) {
488
+ return {
489
+ fn: arg,
490
+ conceptStart: arg.getStart(),
491
+ conceptFilePath: filePath,
492
+ };
493
+ }
494
+ if (kind !== SyntaxKind.Identifier)
495
+ continue;
496
+ const resolved = resolveHandlerIdentifier(arg);
497
+ if (resolved)
498
+ return resolved;
499
+ }
500
+ return undefined;
501
+ }
502
+ function resolveHandlerIdentifier(ident) {
503
+ const symbol = ident.getSymbol();
504
+ if (!symbol)
505
+ return undefined;
506
+ for (const candidate of expandIdentifierSymbols(symbol)) {
507
+ for (const decl of candidate.getDeclarations()) {
508
+ const conceptFilePath = decl.getSourceFile().getFilePath();
509
+ if (isExternalSourcePath(conceptFilePath))
510
+ continue;
511
+ if (decl.getKind() === SyntaxKind.FunctionDeclaration) {
512
+ const fn = decl;
513
+ return { fn, conceptStart: fn.getStart(), conceptFilePath };
514
+ }
515
+ if (decl.getKind() !== SyntaxKind.VariableDeclaration)
516
+ continue;
517
+ const varDecl = decl;
518
+ const init = varDecl.getInitializer();
519
+ if (!init)
520
+ continue;
521
+ const initKind = init.getKind();
522
+ if (initKind !== SyntaxKind.ArrowFunction && initKind !== SyntaxKind.FunctionExpression)
523
+ continue;
524
+ return {
525
+ fn: init,
526
+ conceptStart: varDecl.getStart(),
527
+ conceptFilePath,
528
+ };
529
+ }
530
+ }
531
+ return undefined;
532
+ }
533
+ function expandIdentifierSymbols(symbol) {
534
+ const aliased = symbol.getAliasedSymbol();
535
+ return aliased ? [aliased, symbol] : [symbol];
536
+ }
537
+ function isSameConceptSourceFile(actualFilePath, conceptFilePath) {
538
+ const actual = actualFilePath.replace(/\\/g, '/').replace(/^\/+/, '');
539
+ const expected = conceptFilePath.replace(/\\/g, '/').replace(/^\/+/, '');
540
+ return actual === expected || actual.endsWith(`/${expected}`) || expected.endsWith(`/${actual}`);
541
+ }
542
+ function isExternalSourcePath(filePath) {
543
+ const normalized = filePath.replace(/\\/g, '/');
544
+ return normalized.includes('/node_modules/') || normalized.includes('/.pnpm/');
545
+ }
546
+ function extractExpressErrorStatusCodes(handlerFn) {
547
+ const codes = new Set();
548
+ for (const call of handlerFn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
549
+ const callee = call.getExpression();
550
+ if (callee.getKind() === SyntaxKind.Identifier && callee.getText() === 'next' && call.getArguments().length > 0) {
551
+ codes.add(500);
552
+ continue;
553
+ }
554
+ if (callee.getKind() !== SyntaxKind.PropertyAccessExpression)
555
+ continue;
556
+ const pa = callee;
557
+ const name = pa.getName();
558
+ if (name !== 'status' && name !== 'sendStatus')
559
+ continue;
560
+ const receiver = pa.getExpression().getText();
561
+ if (!/\b(res|reply|response)\b/i.test(receiver))
562
+ continue;
563
+ const code = numericLiteralValue(call.getArguments()[0]);
564
+ if (code !== undefined && API_ERROR_STATUS_CODES.has(code))
565
+ codes.add(code);
566
+ }
567
+ for (const throwStmt of handlerFn.getDescendantsOfKind(SyntaxKind.ThrowStatement)) {
568
+ if (throwStmt.getExpression())
569
+ codes.add(500);
570
+ }
571
+ return codes.size > 0 ? Array.from(codes).sort((a, b) => a - b) : undefined;
572
+ }
573
+ function numericLiteralValue(node) {
574
+ if (!node || node.getKind() !== SyntaxKind.NumericLiteral)
575
+ return undefined;
576
+ const value = Number(node.getText());
577
+ return Number.isFinite(value) ? value : undefined;
578
+ }
579
+ function hasUnboundedExpressCollectionQuery(handlerFn, method, routePath) {
580
+ if (method !== 'GET')
581
+ return false;
582
+ if (routePath && /[:{]/.test(routePath))
583
+ return false;
584
+ const text = handlerFn.getText();
585
+ if (PAGINATION_RE.test(text) || /\b(req|request)\.query\b/.test(text))
586
+ return false;
587
+ if (!handlerHasDbCollectionRead(handlerFn))
588
+ return false;
589
+ if (!/\.json\s*\(|send\s*\(/.test(text))
590
+ return false;
591
+ const lastSegment = routePath?.split('/').filter(Boolean).pop();
592
+ return Boolean(lastSegment?.endsWith('s')) || /\bfindMany\b|\.find\s*\(|\.toArray\s*\(/.test(text);
593
+ }
594
+ function handlerHasDbCollectionRead(handlerFn) {
595
+ return handlerFn.getDescendantsOfKind(SyntaxKind.CallExpression).some((call) => {
596
+ const callee = call.getExpression();
597
+ if (callee.getKind() !== SyntaxKind.PropertyAccessExpression)
598
+ return false;
599
+ const pa = callee;
600
+ if (!DB_COLLECTION_READ_CALLS.has(pa.getName()))
601
+ return false;
602
+ return isDbLikeReceiver(pa.getExpression().getText());
603
+ });
604
+ }
605
+ function handlerHasDbWrite(handlerFn) {
606
+ return handlerFn.getDescendantsOfKind(SyntaxKind.CallExpression).some((call) => {
607
+ const callee = call.getExpression();
608
+ if (callee.getKind() !== SyntaxKind.PropertyAccessExpression)
609
+ return false;
610
+ const pa = callee;
611
+ if (!DB_WRITE_CALLS.has(pa.getName()))
612
+ return false;
613
+ return isDbLikeReceiver(pa.getExpression().getText());
614
+ });
615
+ }
616
+ function isDbLikeReceiver(receiver) {
617
+ return (/\b(db|prisma|mongo|collection|repo|repository|model|client|knex|sequelize|typeorm|pool)\b/i.test(receiver) ||
618
+ /^[A-Z][A-Za-z0-9_]*(Model)?$/.test(receiver));
619
+ }
620
+ function extractExpressValidation(handlerFn, routeArgs) {
621
+ const fields = new Set();
622
+ let hasValidation = false;
623
+ let resolved = false;
624
+ for (const arg of routeArgs) {
625
+ if (arg === handlerFn)
626
+ continue;
627
+ if (arg.getStart() > handlerFn.getStart())
628
+ continue;
629
+ if (/\b(validate|validator|schema|zod|joi)\b/i.test(arg.getText()))
630
+ hasValidation = true;
631
+ for (const field of extractExpressValidatorFields(arg)) {
632
+ fields.add(field);
633
+ hasValidation = true;
634
+ resolved = true;
635
+ }
636
+ }
637
+ const handlerText = handlerFn.getText();
638
+ if (/\.(parse|safeParse|validate)\s*\(\s*(req|request)\.body\b/.test(handlerText))
639
+ hasValidation = true;
640
+ for (const call of handlerFn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
641
+ if (!isSchemaObjectCall(call))
642
+ continue;
643
+ if (!schemaCallValidatesRequestBody(call))
644
+ continue;
645
+ const arg = call.getArguments()[0];
646
+ if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression)
647
+ continue;
648
+ const extracted = extractLiteralObjectFields(arg);
649
+ if (!extracted.resolved || !extracted.fields)
650
+ continue;
651
+ for (const field of extracted.fields)
652
+ fields.add(field);
653
+ hasValidation = true;
654
+ resolved = true;
655
+ }
656
+ return {
657
+ has: hasValidation,
658
+ fields: fields.size > 0 ? Array.from(fields).sort() : undefined,
659
+ resolved,
660
+ };
661
+ }
662
+ function extractExpressValidatorFields(node) {
663
+ const fields = [];
664
+ const calls = node.getKind() === SyntaxKind.CallExpression
665
+ ? [node, ...node.getDescendantsOfKind(SyntaxKind.CallExpression)]
666
+ : node.getDescendantsOfKind(SyntaxKind.CallExpression);
667
+ for (const call of calls) {
668
+ const callee = call.getExpression().getText();
669
+ if (!/^(body|check|param|query)$/.test(callee) && !/\.(body|check|param|query)$/.test(callee))
670
+ continue;
671
+ const first = call.getArguments()[0];
672
+ if (!first || first.getKind() !== SyntaxKind.StringLiteral)
673
+ continue;
674
+ fields.push(first.getLiteralValue());
675
+ }
676
+ return fields;
677
+ }
678
+ function isSchemaObjectCall(call) {
679
+ const callee = call.getExpression().getText();
680
+ return callee === 'z.object' || callee === 'Joi.object' || callee.endsWith('.object');
681
+ }
682
+ function schemaCallValidatesRequestBody(call) {
683
+ let cursor = call;
684
+ for (let depth = 0; depth < 5; depth++) {
685
+ cursor = cursor.getParent();
686
+ if (!cursor)
687
+ return false;
688
+ const text = cursor.getText();
689
+ if (/\.(parse|safeParse|validate)\s*\(\s*(req|request)\.body\b/.test(text))
690
+ return true;
691
+ }
692
+ return false;
693
+ }
403
694
  // Express `app.use('/api/prefix', ...middlewares, subRouter)`: emit a
404
695
  // route-mount concept so `collectRoutesAcrossGraph` can join the sub-router's
405
696
  // bare paths (`router.get('/foo')`) with the mount prefix (`/api/prefix/foo`).
@@ -489,6 +780,81 @@ function pathSuffixBetween(mountFile, targetFile) {
489
780
  const tail = parts.slice(commonLen).join('/');
490
781
  return tail || parts.slice(-2).join('/');
491
782
  }
783
+ // Walk an Express handler body and collect the REQUIRED body field names it
784
+ // reads. Combines two evidence sources:
785
+ // 1. Destructuring: `const { name, email } = req.body;`
786
+ // 2. Property access: `req.body.name`, `req.body['email']`
787
+ //
788
+ // Default-assignments in destructuring (`{ status = 'active' }`) mark the
789
+ // field as optional and are excluded from the required set. A rest element
790
+ // (`{ ...rest }`) or a dynamic key (`req.body[var]`) poisons the resolution
791
+ // because the handler may need arbitrary fields we can't see.
792
+ function extractHandlerBodyFields(fn) {
793
+ const body = fn.getBody();
794
+ if (!body)
795
+ return { fields: undefined, resolved: false };
796
+ const required = new Set();
797
+ let poisoned = false;
798
+ // 1. Destructuring: walk VariableDeclarations whose initializer is `req.body`
799
+ // or a reference that aliases it. V1 matches only direct `req.body`.
800
+ for (const decl of body.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
801
+ const init = decl.getInitializer();
802
+ if (!init || init.getText() !== 'req.body')
803
+ continue;
804
+ const name = decl.getNameNode();
805
+ if (name.getKind() !== SyntaxKind.ObjectBindingPattern) {
806
+ // `const body = req.body` whole-body alias — handler may read anything
807
+ // off `body.X` downstream. Poison the resolution for safety.
808
+ poisoned = true;
809
+ continue;
810
+ }
811
+ for (const el of name.getElements()) {
812
+ if (el.getDotDotDotToken()) {
813
+ // `{ ...rest } = req.body` — unknowable set of fields.
814
+ poisoned = true;
815
+ continue;
816
+ }
817
+ if (el.getInitializer()) {
818
+ // `{ status = 'active' }` — optional, skip (don't add to required).
819
+ continue;
820
+ }
821
+ const elName = el.getNameNode();
822
+ if (elName.getKind() === SyntaxKind.Identifier) {
823
+ // `{ name }` or `{ name: alias }` — the property name lives on
824
+ // `propertyNameNode` when aliased, `nameNode` otherwise.
825
+ const propName = el.getPropertyNameNode()?.getText() ?? elName.getText();
826
+ required.add(propName);
827
+ }
828
+ else {
829
+ // Nested destructuring or other exotic shape — give up for v1.
830
+ poisoned = true;
831
+ }
832
+ }
833
+ }
834
+ // 2. Property access: `req.body.name` / `req.body['name']`.
835
+ for (const pa of body.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
836
+ if (pa.getExpression().getText() !== 'req.body')
837
+ continue;
838
+ required.add(pa.getName());
839
+ }
840
+ for (const el of body.getDescendantsOfKind(SyntaxKind.ElementAccessExpression)) {
841
+ if (el.getExpression().getText() !== 'req.body')
842
+ continue;
843
+ const arg = el.getArgumentExpression();
844
+ if (arg && arg.getKind() === SyntaxKind.StringLiteral) {
845
+ required.add(arg.getLiteralValue());
846
+ }
847
+ else {
848
+ // `req.body[key]` dynamic — unknowable.
849
+ poisoned = true;
850
+ }
851
+ }
852
+ if (poisoned)
853
+ return { fields: undefined, resolved: false };
854
+ if (required.size === 0)
855
+ return { fields: undefined, resolved: false };
856
+ return { fields: Array.from(required), resolved: true };
857
+ }
492
858
  function guessResolvedSuffix(specifier, mountFile) {
493
859
  if (!specifier.startsWith('.'))
494
860
  return undefined;
@@ -1128,6 +1494,140 @@ function isInAsyncContext(node) {
1128
1494
  }
1129
1495
  return false;
1130
1496
  }
1497
+ // Extract the field names a network call sends on the wire. High-confidence
1498
+ // sources:
1499
+ // - literal objects: `JSON.stringify({ a, b })`, `axios.post(url, { a, b })`
1500
+ // - typed payload variables: `JSON.stringify(input)` where `input: CreateUser`
1501
+ // Everything else returns `{ fields: undefined, resolved: false }` so
1502
+ // body-shape-drift stays silent on opaque shapes rather than guessing.
1503
+ function extractSentFields(call, funcName) {
1504
+ const payload = extractNetworkPayloadExpression(call, funcName);
1505
+ if (!payload)
1506
+ return { fields: undefined, resolved: false };
1507
+ return extractPayloadFields(payload);
1508
+ }
1509
+ function extractNetworkPayloadExpression(call, funcName) {
1510
+ const args = call.getArguments();
1511
+ if (args.length < 2)
1512
+ return undefined;
1513
+ if (AXIOS_STYLE_METHODS.has(funcName)) {
1514
+ return args[1];
1515
+ }
1516
+ if (funcName !== 'fetch')
1517
+ return undefined;
1518
+ const opts = args[1];
1519
+ if (opts.getKind() !== SyntaxKind.ObjectLiteralExpression)
1520
+ return undefined;
1521
+ const optsObj = opts;
1522
+ const bodyProp = optsObj.getProperty('body');
1523
+ if (!bodyProp)
1524
+ return undefined;
1525
+ if (bodyProp.getKind() !== SyntaxKind.PropertyAssignment) {
1526
+ return undefined;
1527
+ }
1528
+ const init = bodyProp.getInitializer();
1529
+ return init;
1530
+ }
1531
+ function extractPayloadFields(node) {
1532
+ const payload = unwrapPayloadExpression(node);
1533
+ if (payload.getKind() === SyntaxKind.CallExpression) {
1534
+ const bodyCall = payload;
1535
+ if (bodyCall.getExpression().getText() !== 'JSON.stringify')
1536
+ return { fields: undefined, resolved: false };
1537
+ const stringifyArg = bodyCall.getArguments()[0];
1538
+ return stringifyArg ? extractPayloadFields(stringifyArg) : { fields: undefined, resolved: false };
1539
+ }
1540
+ if (payload.getKind() === SyntaxKind.ObjectLiteralExpression) {
1541
+ return extractLiteralObjectFields(payload);
1542
+ }
1543
+ if (payload.getKind() === SyntaxKind.Identifier || payload.getKind() === SyntaxKind.PropertyAccessExpression) {
1544
+ return extractObjectFieldsFromType(payload);
1545
+ }
1546
+ return { fields: undefined, resolved: false };
1547
+ }
1548
+ function unwrapPayloadExpression(node) {
1549
+ let current = node;
1550
+ while (true) {
1551
+ const kind = current.getKind();
1552
+ if (kind === SyntaxKind.ParenthesizedExpression) {
1553
+ current = current.getExpression();
1554
+ }
1555
+ else if (kind === SyntaxKind.AsExpression) {
1556
+ current = current.getExpression();
1557
+ }
1558
+ else if (kind === SyntaxKind.TypeAssertionExpression) {
1559
+ current = current.getExpression();
1560
+ }
1561
+ else if (kind === SyntaxKind.NonNullExpression) {
1562
+ current = current.getExpression();
1563
+ }
1564
+ else if (kind === SyntaxKind.SatisfiesExpression) {
1565
+ current = current.getExpression();
1566
+ }
1567
+ else {
1568
+ return current;
1569
+ }
1570
+ }
1571
+ }
1572
+ function extractObjectFieldsFromType(node) {
1573
+ const type = node.getType();
1574
+ if (type.isAny() || type.isUnknown() || type.isUnion())
1575
+ return { fields: undefined, resolved: false };
1576
+ if (type.getStringIndexType() || type.getNumberIndexType())
1577
+ return { fields: undefined, resolved: false };
1578
+ const fields = new Set();
1579
+ for (const prop of type.getProperties()) {
1580
+ const declarations = prop.getDeclarations();
1581
+ if (declarations.length === 0)
1582
+ return { fields: undefined, resolved: false };
1583
+ if (declarations.some((decl) => isExternalSourcePath(decl.getSourceFile().getFilePath()))) {
1584
+ return { fields: undefined, resolved: false };
1585
+ }
1586
+ if (declarations.some((decl) => declarationIsOptional(decl)))
1587
+ continue;
1588
+ const name = prop.getName();
1589
+ if (!/^[A-Za-z_$][\w$-]*$/.test(name))
1590
+ return { fields: undefined, resolved: false };
1591
+ fields.add(name);
1592
+ }
1593
+ return fields.size > 0 ? { fields: Array.from(fields).sort(), resolved: true } : { fields: [], resolved: true };
1594
+ }
1595
+ function declarationIsOptional(decl) {
1596
+ const maybeOptional = decl;
1597
+ return maybeOptional.hasQuestionToken?.() === true;
1598
+ }
1599
+ // Walk an object literal and return its identifier-keyed property names.
1600
+ // Spread (`...x`) or computed keys (`[x]: ...`) poison the resolution —
1601
+ // we mark unresolved rather than return a partial field list that would
1602
+ // produce false positives downstream.
1603
+ function extractLiteralObjectFields(obj) {
1604
+ const fields = [];
1605
+ for (const prop of obj.getProperties()) {
1606
+ const kind = prop.getKind();
1607
+ if (kind === SyntaxKind.SpreadAssignment)
1608
+ return { fields: undefined, resolved: false };
1609
+ if (kind === SyntaxKind.PropertyAssignment) {
1610
+ const name = prop.getNameNode();
1611
+ if (name.getKind() === SyntaxKind.ComputedPropertyName)
1612
+ return { fields: undefined, resolved: false };
1613
+ if (name.getKind() === SyntaxKind.Identifier || name.getKind() === SyntaxKind.StringLiteral) {
1614
+ fields.push(name.getText().replace(/['"]/g, ''));
1615
+ }
1616
+ else {
1617
+ return { fields: undefined, resolved: false };
1618
+ }
1619
+ }
1620
+ else if (kind === SyntaxKind.ShorthandPropertyAssignment) {
1621
+ fields.push(prop.getName());
1622
+ }
1623
+ else {
1624
+ // Method definitions, getters, setters — unusual in a fetch body,
1625
+ // treat as unresolved.
1626
+ return { fields: undefined, resolved: false };
1627
+ }
1628
+ }
1629
+ return { fields, resolved: true };
1630
+ }
1131
1631
  function extractTarget(call) {
1132
1632
  const args = call.getArguments();
1133
1633
  if (args.length === 0)
@@ -1144,6 +1644,29 @@ function extractTarget(call) {
1144
1644
  }
1145
1645
  return undefined;
1146
1646
  }
1647
+ function extractQueryParams(call) {
1648
+ const target = extractTarget(call);
1649
+ if (!target)
1650
+ return { params: undefined, resolved: false };
1651
+ const q = target.indexOf('?');
1652
+ if (q === -1)
1653
+ return { params: [], resolved: true };
1654
+ const query = target.slice(q + 1).split('#')[0] ?? '';
1655
+ if (query.length === 0)
1656
+ return { params: [], resolved: true };
1657
+ const params = [];
1658
+ for (const part of query.split('&')) {
1659
+ if (!part)
1660
+ continue;
1661
+ const [rawName] = part.split('=');
1662
+ if (!rawName)
1663
+ continue;
1664
+ const name = rawName.replace(/^:+/, '');
1665
+ if (/^[A-Za-z_][\w-]*$/.test(name))
1666
+ params.push(name);
1667
+ }
1668
+ return { params, resolved: true };
1669
+ }
1147
1670
  // Convert a template literal like `${API_BASE}/api/review/${slug}` into a
1148
1671
  // server-route-shaped path like `/api/review/:slug` so cross-stack rules can
1149
1672
  // correlate it against backend routes. Without this every `fetch(` \`${BASE}/…\` `)`
@@ -1441,6 +1964,212 @@ function extractHasAuthHeader(call, funcName) {
1441
1964
  }
1442
1965
  return false;
1443
1966
  }
1967
+ function extractHandlesApiErrors(call, isWrappedClientCall) {
1968
+ if (isWrappedClientCall)
1969
+ return undefined;
1970
+ if (isInsideTryWithCatch(call))
1971
+ return true;
1972
+ if (hasCatchInPromiseChain(call))
1973
+ return true;
1974
+ if (hasInlineStatusCheck(call))
1975
+ return true;
1976
+ if (hasAssignedResponseStatusCheck(call))
1977
+ return true;
1978
+ if (isPassedToApiResponseHelper(call))
1979
+ return true;
1980
+ if (hasNearbyErrorUiPath(call))
1981
+ return true;
1982
+ return false;
1983
+ }
1984
+ function isInsideTryWithCatch(node) {
1985
+ let parent = node.getParent();
1986
+ while (parent) {
1987
+ if (parent.getKind() === SyntaxKind.TryStatement) {
1988
+ const tryStmt = parent;
1989
+ return Boolean(tryStmt.getCatchClause());
1990
+ }
1991
+ parent = parent.getParent();
1992
+ }
1993
+ return false;
1994
+ }
1995
+ function hasCatchInPromiseChain(node) {
1996
+ let cursor = node;
1997
+ for (let depth = 0; depth < 8; depth++) {
1998
+ const parent = cursor.getParent();
1999
+ if (!parent)
2000
+ return false;
2001
+ if (parent.getKind() === SyntaxKind.PropertyAccessExpression) {
2002
+ const pa = parent;
2003
+ if (pa.getName() === 'catch')
2004
+ return true;
2005
+ }
2006
+ cursor = parent;
2007
+ }
2008
+ return false;
2009
+ }
2010
+ function hasInlineStatusCheck(call) {
2011
+ let cursor = call;
2012
+ for (let depth = 0; depth < 8; depth++) {
2013
+ const parent = cursor.getParent();
2014
+ if (!parent)
2015
+ return false;
2016
+ const text = parent.getText();
2017
+ if (/\b\w+\.(ok|status|statusCode)\b/.test(text) &&
2018
+ /\b(if|throw|reject|setError|toast\.error|Alert\.alert)\b/.test(text)) {
2019
+ return true;
2020
+ }
2021
+ if (parent.getKind() === SyntaxKind.ExpressionStatement || parent.getKind() === SyntaxKind.VariableDeclaration) {
2022
+ return false;
2023
+ }
2024
+ cursor = parent;
2025
+ }
2026
+ return false;
2027
+ }
2028
+ function hasAssignedResponseStatusCheck(call) {
2029
+ const decl = enclosingVariableDeclaration(call);
2030
+ if (!decl)
2031
+ return false;
2032
+ const name = decl.getName();
2033
+ if (!/^[A-Za-z_$][\w$]*$/.test(name))
2034
+ return false;
2035
+ const block = nearestBlock(decl);
2036
+ if (!block)
2037
+ return false;
2038
+ const escaped = escapeRegExp(name);
2039
+ const text = block.getText();
2040
+ return new RegExp(`\\b${escaped}\\.(ok|status|statusCode)\\b`).test(text);
2041
+ }
2042
+ function isPassedToApiResponseHelper(call) {
2043
+ let cursor = call;
2044
+ for (let depth = 0; depth < 4; depth++) {
2045
+ const parent = cursor.getParent();
2046
+ if (!parent)
2047
+ return false;
2048
+ const kind = parent.getKind();
2049
+ if (kind === SyntaxKind.AwaitExpression || kind === SyntaxKind.ParenthesizedExpression) {
2050
+ cursor = parent;
2051
+ continue;
2052
+ }
2053
+ if (kind !== SyntaxKind.CallExpression)
2054
+ return false;
2055
+ const helperName = parent.getExpression().getText();
2056
+ return /^(parse|handle|check|ensure|assert|unwrap)[A-Za-z0-9_$]*(Api|Response|Result)$/i.test(helperName);
2057
+ }
2058
+ return false;
2059
+ }
2060
+ function hasNearbyErrorUiPath(call) {
2061
+ const container = nearestFunctionLike(call);
2062
+ if (!container)
2063
+ return false;
2064
+ const text = container.getText();
2065
+ return /\b(setError|setErrorMessage|showError|toast\.error|Alert\.alert|notifyError)\s*\(/.test(text);
2066
+ }
2067
+ function enclosingVariableDeclaration(node) {
2068
+ let cursor = node;
2069
+ for (let depth = 0; depth < 6; depth++) {
2070
+ const parent = cursor.getParent();
2071
+ if (!parent)
2072
+ return undefined;
2073
+ if (parent.getKind() === SyntaxKind.VariableDeclaration)
2074
+ return parent;
2075
+ cursor = parent;
2076
+ }
2077
+ return undefined;
2078
+ }
2079
+ function nearestBlock(node) {
2080
+ let parent = node.getParent();
2081
+ while (parent) {
2082
+ if (parent.getKind() === SyntaxKind.Block)
2083
+ return parent;
2084
+ parent = parent.getParent();
2085
+ }
2086
+ return undefined;
2087
+ }
2088
+ function nearestFunctionLike(node) {
2089
+ let parent = node.getParent();
2090
+ while (parent) {
2091
+ const kind = parent.getKind();
2092
+ if (kind === SyntaxKind.FunctionDeclaration ||
2093
+ kind === SyntaxKind.MethodDeclaration ||
2094
+ kind === SyntaxKind.ArrowFunction ||
2095
+ kind === SyntaxKind.FunctionExpression) {
2096
+ return parent;
2097
+ }
2098
+ parent = parent.getParent();
2099
+ }
2100
+ return undefined;
2101
+ }
2102
+ function extractAuthPropagation(call, funcName, objName, isWrappedClientCall, hasAuthHeader) {
2103
+ if (hasAuthHeader === true)
2104
+ return 'present';
2105
+ if (hasCookieOrSessionEvidence(call, funcName))
2106
+ return 'present';
2107
+ if (isWrappedClientCall)
2108
+ return /auth|session|private|secure/i.test(objName) ? 'present' : 'unknown';
2109
+ if (funcName === 'fetch')
2110
+ return hasAuthHeader === false ? 'absent' : 'unknown';
2111
+ const config = networkConfigArgument(call, funcName);
2112
+ if (!config)
2113
+ return 'absent';
2114
+ if (config.getKind() !== SyntaxKind.ObjectLiteralExpression)
2115
+ return 'unknown';
2116
+ const obj = config;
2117
+ if (obj.getProperties().some((p) => p.getKind() === SyntaxKind.SpreadAssignment))
2118
+ return 'unknown';
2119
+ return configObjectHasAuthEvidence(obj) ? 'present' : 'absent';
2120
+ }
2121
+ function hasCookieOrSessionEvidence(call, funcName) {
2122
+ const config = funcName === 'fetch' ? call.getArguments()[1] : networkConfigArgument(call, funcName);
2123
+ if (!config || config.getKind() !== SyntaxKind.ObjectLiteralExpression)
2124
+ return false;
2125
+ return configObjectHasAuthEvidence(config);
2126
+ }
2127
+ function networkConfigArgument(call, funcName) {
2128
+ const args = call.getArguments();
2129
+ if (funcName === 'fetch')
2130
+ return args[1];
2131
+ if (AXIOS_STYLE_METHODS.has(funcName))
2132
+ return args[2];
2133
+ return args[1];
2134
+ }
2135
+ function configObjectHasAuthEvidence(obj) {
2136
+ const credentialsProp = obj.getProperty('credentials');
2137
+ if (credentialsProp?.getKind() === SyntaxKind.PropertyAssignment) {
2138
+ const init = credentialsProp.getInitializer();
2139
+ if (init &&
2140
+ (init.getKind() === SyntaxKind.StringLiteral || init.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral)) {
2141
+ const value = init.getLiteralValue();
2142
+ if (value === 'include' || value === 'same-origin')
2143
+ return true;
2144
+ }
2145
+ }
2146
+ const withCredentialsProp = obj.getProperty('withCredentials');
2147
+ if (withCredentialsProp?.getKind() === SyntaxKind.PropertyAssignment) {
2148
+ const init = withCredentialsProp.getInitializer();
2149
+ if (init?.getKind() === SyntaxKind.TrueKeyword)
2150
+ return true;
2151
+ }
2152
+ const headersProp = obj.getProperty('headers');
2153
+ if (headersProp?.getKind() !== SyntaxKind.PropertyAssignment)
2154
+ return false;
2155
+ const headersInit = headersProp.getInitializer();
2156
+ if (!headersInit || headersInit.getKind() !== SyntaxKind.ObjectLiteralExpression)
2157
+ return false;
2158
+ const headersObj = headersInit;
2159
+ if (headersObj.getProperties().some((p) => p.getKind() === SyntaxKind.SpreadAssignment))
2160
+ return false;
2161
+ for (const prop of headersObj.getProperties()) {
2162
+ if (prop.getKind() !== SyntaxKind.PropertyAssignment)
2163
+ continue;
2164
+ const name = prop.getName().replace(/['"]/g, '').toLowerCase();
2165
+ if (name === 'authorization' || name === 'cookie' || name === 'x-session' || name === 'x-csrf-token')
2166
+ return true;
2167
+ }
2168
+ return false;
2169
+ }
2170
+ function escapeRegExp(value) {
2171
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2172
+ }
1444
2173
  function classifyBodyExpression(expr) {
1445
2174
  if (!expr)
1446
2175
  return undefined;