@kernlang/review 3.3.8 → 3.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cache.js +1 -1
- package/dist/concept-rules/auth-propagation-drift.d.ts +10 -0
- package/dist/concept-rules/auth-propagation-drift.js +83 -0
- package/dist/concept-rules/auth-propagation-drift.js.map +1 -0
- package/dist/concept-rules/body-shape-drift.d.ts +32 -0
- package/dist/concept-rules/body-shape-drift.js +96 -0
- package/dist/concept-rules/body-shape-drift.js.map +1 -0
- package/dist/concept-rules/cross-stack-utils.d.ts +24 -0
- package/dist/concept-rules/cross-stack-utils.js +123 -29
- package/dist/concept-rules/cross-stack-utils.js.map +1 -1
- package/dist/concept-rules/index.d.ts +4 -2
- package/dist/concept-rules/index.js +53 -3
- package/dist/concept-rules/index.js.map +1 -1
- package/dist/concept-rules/mutation-without-idempotency.d.ts +10 -0
- package/dist/concept-rules/mutation-without-idempotency.js +47 -0
- package/dist/concept-rules/mutation-without-idempotency.js.map +1 -0
- package/dist/concept-rules/request-validation-drift.d.ts +11 -0
- package/dist/concept-rules/request-validation-drift.js +96 -0
- package/dist/concept-rules/request-validation-drift.js.map +1 -0
- package/dist/concept-rules/unbounded-collection-query.d.ts +10 -0
- package/dist/concept-rules/unbounded-collection-query.js +56 -0
- package/dist/concept-rules/unbounded-collection-query.js.map +1 -0
- package/dist/concept-rules/unhandled-api-error-shape.d.ts +10 -0
- package/dist/concept-rules/unhandled-api-error-shape.js +57 -0
- package/dist/concept-rules/unhandled-api-error-shape.js.map +1 -0
- package/dist/external-tools.js +52 -3
- package/dist/external-tools.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +136 -62
- package/dist/index.js.map +1 -1
- package/dist/mappers/ts-concepts.js +663 -1
- package/dist/mappers/ts-concepts.js.map +1 -1
- package/dist/python-fallback.d.ts +2 -0
- package/dist/python-fallback.js +506 -0
- package/dist/python-fallback.js.map +1 -0
- package/dist/reporter.js +84 -1
- package/dist/reporter.js.map +1 -1
- package/dist/rules/base.js +21 -3
- package/dist/rules/base.js.map +1 -1
- package/dist/rules/index.js +40 -0
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/kern-source.js +1 -0
- package/dist/rules/kern-source.js.map +1 -1
- package/dist/types.d.ts +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
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,73 @@ function isInAsyncContext(node) {
|
|
|
1128
1494
|
}
|
|
1129
1495
|
return false;
|
|
1130
1496
|
}
|
|
1497
|
+
// Extract the field names a `fetch(url, { body: JSON.stringify({ a, b }) })`
|
|
1498
|
+
// sends on the wire. V1 scope: raw `fetch` only, body must be
|
|
1499
|
+
// `JSON.stringify({ ... })` with a literal object that has only identifier
|
|
1500
|
+
// keys and no spread. Everything else returns `{ fields: undefined, resolved: false }`
|
|
1501
|
+
// so body-shape-drift stays silent on opaque shapes rather than guessing.
|
|
1502
|
+
function extractSentFields(call, funcName) {
|
|
1503
|
+
if (funcName !== 'fetch')
|
|
1504
|
+
return { fields: undefined, resolved: false };
|
|
1505
|
+
const args = call.getArguments();
|
|
1506
|
+
if (args.length < 2)
|
|
1507
|
+
return { fields: undefined, resolved: false };
|
|
1508
|
+
const opts = args[1];
|
|
1509
|
+
if (opts.getKind() !== SyntaxKind.ObjectLiteralExpression)
|
|
1510
|
+
return { fields: undefined, resolved: false };
|
|
1511
|
+
const optsObj = opts;
|
|
1512
|
+
const bodyProp = optsObj.getProperty('body');
|
|
1513
|
+
if (!bodyProp)
|
|
1514
|
+
return { fields: undefined, resolved: false };
|
|
1515
|
+
if (bodyProp.getKind() !== SyntaxKind.PropertyAssignment) {
|
|
1516
|
+
return { fields: undefined, resolved: false };
|
|
1517
|
+
}
|
|
1518
|
+
const init = bodyProp.getInitializer();
|
|
1519
|
+
if (!init || init.getKind() !== SyntaxKind.CallExpression)
|
|
1520
|
+
return { fields: undefined, resolved: false };
|
|
1521
|
+
const bodyCall = init;
|
|
1522
|
+
if (bodyCall.getExpression().getText() !== 'JSON.stringify')
|
|
1523
|
+
return { fields: undefined, resolved: false };
|
|
1524
|
+
const stringifyArgs = bodyCall.getArguments();
|
|
1525
|
+
if (stringifyArgs.length === 0)
|
|
1526
|
+
return { fields: undefined, resolved: false };
|
|
1527
|
+
const payload = stringifyArgs[0];
|
|
1528
|
+
if (payload.getKind() !== SyntaxKind.ObjectLiteralExpression)
|
|
1529
|
+
return { fields: undefined, resolved: false };
|
|
1530
|
+
return extractLiteralObjectFields(payload);
|
|
1531
|
+
}
|
|
1532
|
+
// Walk an object literal and return its identifier-keyed property names.
|
|
1533
|
+
// Spread (`...x`) or computed keys (`[x]: ...`) poison the resolution —
|
|
1534
|
+
// we mark unresolved rather than return a partial field list that would
|
|
1535
|
+
// produce false positives downstream.
|
|
1536
|
+
function extractLiteralObjectFields(obj) {
|
|
1537
|
+
const fields = [];
|
|
1538
|
+
for (const prop of obj.getProperties()) {
|
|
1539
|
+
const kind = prop.getKind();
|
|
1540
|
+
if (kind === SyntaxKind.SpreadAssignment)
|
|
1541
|
+
return { fields: undefined, resolved: false };
|
|
1542
|
+
if (kind === SyntaxKind.PropertyAssignment) {
|
|
1543
|
+
const name = prop.getNameNode();
|
|
1544
|
+
if (name.getKind() === SyntaxKind.ComputedPropertyName)
|
|
1545
|
+
return { fields: undefined, resolved: false };
|
|
1546
|
+
if (name.getKind() === SyntaxKind.Identifier || name.getKind() === SyntaxKind.StringLiteral) {
|
|
1547
|
+
fields.push(name.getText().replace(/['"]/g, ''));
|
|
1548
|
+
}
|
|
1549
|
+
else {
|
|
1550
|
+
return { fields: undefined, resolved: false };
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
else if (kind === SyntaxKind.ShorthandPropertyAssignment) {
|
|
1554
|
+
fields.push(prop.getName());
|
|
1555
|
+
}
|
|
1556
|
+
else {
|
|
1557
|
+
// Method definitions, getters, setters — unusual in a fetch body,
|
|
1558
|
+
// treat as unresolved.
|
|
1559
|
+
return { fields: undefined, resolved: false };
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
return { fields, resolved: true };
|
|
1563
|
+
}
|
|
1131
1564
|
function extractTarget(call) {
|
|
1132
1565
|
const args = call.getArguments();
|
|
1133
1566
|
if (args.length === 0)
|
|
@@ -1144,6 +1577,29 @@ function extractTarget(call) {
|
|
|
1144
1577
|
}
|
|
1145
1578
|
return undefined;
|
|
1146
1579
|
}
|
|
1580
|
+
function extractQueryParams(call) {
|
|
1581
|
+
const target = extractTarget(call);
|
|
1582
|
+
if (!target)
|
|
1583
|
+
return { params: undefined, resolved: false };
|
|
1584
|
+
const q = target.indexOf('?');
|
|
1585
|
+
if (q === -1)
|
|
1586
|
+
return { params: [], resolved: true };
|
|
1587
|
+
const query = target.slice(q + 1).split('#')[0] ?? '';
|
|
1588
|
+
if (query.length === 0)
|
|
1589
|
+
return { params: [], resolved: true };
|
|
1590
|
+
const params = [];
|
|
1591
|
+
for (const part of query.split('&')) {
|
|
1592
|
+
if (!part)
|
|
1593
|
+
continue;
|
|
1594
|
+
const [rawName] = part.split('=');
|
|
1595
|
+
if (!rawName)
|
|
1596
|
+
continue;
|
|
1597
|
+
const name = rawName.replace(/^:+/, '');
|
|
1598
|
+
if (/^[A-Za-z_][\w-]*$/.test(name))
|
|
1599
|
+
params.push(name);
|
|
1600
|
+
}
|
|
1601
|
+
return { params, resolved: true };
|
|
1602
|
+
}
|
|
1147
1603
|
// Convert a template literal like `${API_BASE}/api/review/${slug}` into a
|
|
1148
1604
|
// server-route-shaped path like `/api/review/:slug` so cross-stack rules can
|
|
1149
1605
|
// correlate it against backend routes. Without this every `fetch(` \`${BASE}/…\` `)`
|
|
@@ -1441,6 +1897,212 @@ function extractHasAuthHeader(call, funcName) {
|
|
|
1441
1897
|
}
|
|
1442
1898
|
return false;
|
|
1443
1899
|
}
|
|
1900
|
+
function extractHandlesApiErrors(call, isWrappedClientCall) {
|
|
1901
|
+
if (isWrappedClientCall)
|
|
1902
|
+
return undefined;
|
|
1903
|
+
if (isInsideTryWithCatch(call))
|
|
1904
|
+
return true;
|
|
1905
|
+
if (hasCatchInPromiseChain(call))
|
|
1906
|
+
return true;
|
|
1907
|
+
if (hasInlineStatusCheck(call))
|
|
1908
|
+
return true;
|
|
1909
|
+
if (hasAssignedResponseStatusCheck(call))
|
|
1910
|
+
return true;
|
|
1911
|
+
if (isPassedToApiResponseHelper(call))
|
|
1912
|
+
return true;
|
|
1913
|
+
if (hasNearbyErrorUiPath(call))
|
|
1914
|
+
return true;
|
|
1915
|
+
return false;
|
|
1916
|
+
}
|
|
1917
|
+
function isInsideTryWithCatch(node) {
|
|
1918
|
+
let parent = node.getParent();
|
|
1919
|
+
while (parent) {
|
|
1920
|
+
if (parent.getKind() === SyntaxKind.TryStatement) {
|
|
1921
|
+
const tryStmt = parent;
|
|
1922
|
+
return Boolean(tryStmt.getCatchClause());
|
|
1923
|
+
}
|
|
1924
|
+
parent = parent.getParent();
|
|
1925
|
+
}
|
|
1926
|
+
return false;
|
|
1927
|
+
}
|
|
1928
|
+
function hasCatchInPromiseChain(node) {
|
|
1929
|
+
let cursor = node;
|
|
1930
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
1931
|
+
const parent = cursor.getParent();
|
|
1932
|
+
if (!parent)
|
|
1933
|
+
return false;
|
|
1934
|
+
if (parent.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
1935
|
+
const pa = parent;
|
|
1936
|
+
if (pa.getName() === 'catch')
|
|
1937
|
+
return true;
|
|
1938
|
+
}
|
|
1939
|
+
cursor = parent;
|
|
1940
|
+
}
|
|
1941
|
+
return false;
|
|
1942
|
+
}
|
|
1943
|
+
function hasInlineStatusCheck(call) {
|
|
1944
|
+
let cursor = call;
|
|
1945
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
1946
|
+
const parent = cursor.getParent();
|
|
1947
|
+
if (!parent)
|
|
1948
|
+
return false;
|
|
1949
|
+
const text = parent.getText();
|
|
1950
|
+
if (/\b\w+\.(ok|status|statusCode)\b/.test(text) &&
|
|
1951
|
+
/\b(if|throw|reject|setError|toast\.error|Alert\.alert)\b/.test(text)) {
|
|
1952
|
+
return true;
|
|
1953
|
+
}
|
|
1954
|
+
if (parent.getKind() === SyntaxKind.ExpressionStatement || parent.getKind() === SyntaxKind.VariableDeclaration) {
|
|
1955
|
+
return false;
|
|
1956
|
+
}
|
|
1957
|
+
cursor = parent;
|
|
1958
|
+
}
|
|
1959
|
+
return false;
|
|
1960
|
+
}
|
|
1961
|
+
function hasAssignedResponseStatusCheck(call) {
|
|
1962
|
+
const decl = enclosingVariableDeclaration(call);
|
|
1963
|
+
if (!decl)
|
|
1964
|
+
return false;
|
|
1965
|
+
const name = decl.getName();
|
|
1966
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(name))
|
|
1967
|
+
return false;
|
|
1968
|
+
const block = nearestBlock(decl);
|
|
1969
|
+
if (!block)
|
|
1970
|
+
return false;
|
|
1971
|
+
const escaped = escapeRegExp(name);
|
|
1972
|
+
const text = block.getText();
|
|
1973
|
+
return new RegExp(`\\b${escaped}\\.(ok|status|statusCode)\\b`).test(text);
|
|
1974
|
+
}
|
|
1975
|
+
function isPassedToApiResponseHelper(call) {
|
|
1976
|
+
let cursor = call;
|
|
1977
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
1978
|
+
const parent = cursor.getParent();
|
|
1979
|
+
if (!parent)
|
|
1980
|
+
return false;
|
|
1981
|
+
const kind = parent.getKind();
|
|
1982
|
+
if (kind === SyntaxKind.AwaitExpression || kind === SyntaxKind.ParenthesizedExpression) {
|
|
1983
|
+
cursor = parent;
|
|
1984
|
+
continue;
|
|
1985
|
+
}
|
|
1986
|
+
if (kind !== SyntaxKind.CallExpression)
|
|
1987
|
+
return false;
|
|
1988
|
+
const helperName = parent.getExpression().getText();
|
|
1989
|
+
return /^(parse|handle|check|ensure|assert|unwrap)[A-Za-z0-9_$]*(Api|Response|Result)$/i.test(helperName);
|
|
1990
|
+
}
|
|
1991
|
+
return false;
|
|
1992
|
+
}
|
|
1993
|
+
function hasNearbyErrorUiPath(call) {
|
|
1994
|
+
const container = nearestFunctionLike(call);
|
|
1995
|
+
if (!container)
|
|
1996
|
+
return false;
|
|
1997
|
+
const text = container.getText();
|
|
1998
|
+
return /\b(setError|setErrorMessage|showError|toast\.error|Alert\.alert|notifyError)\s*\(/.test(text);
|
|
1999
|
+
}
|
|
2000
|
+
function enclosingVariableDeclaration(node) {
|
|
2001
|
+
let cursor = node;
|
|
2002
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
2003
|
+
const parent = cursor.getParent();
|
|
2004
|
+
if (!parent)
|
|
2005
|
+
return undefined;
|
|
2006
|
+
if (parent.getKind() === SyntaxKind.VariableDeclaration)
|
|
2007
|
+
return parent;
|
|
2008
|
+
cursor = parent;
|
|
2009
|
+
}
|
|
2010
|
+
return undefined;
|
|
2011
|
+
}
|
|
2012
|
+
function nearestBlock(node) {
|
|
2013
|
+
let parent = node.getParent();
|
|
2014
|
+
while (parent) {
|
|
2015
|
+
if (parent.getKind() === SyntaxKind.Block)
|
|
2016
|
+
return parent;
|
|
2017
|
+
parent = parent.getParent();
|
|
2018
|
+
}
|
|
2019
|
+
return undefined;
|
|
2020
|
+
}
|
|
2021
|
+
function nearestFunctionLike(node) {
|
|
2022
|
+
let parent = node.getParent();
|
|
2023
|
+
while (parent) {
|
|
2024
|
+
const kind = parent.getKind();
|
|
2025
|
+
if (kind === SyntaxKind.FunctionDeclaration ||
|
|
2026
|
+
kind === SyntaxKind.MethodDeclaration ||
|
|
2027
|
+
kind === SyntaxKind.ArrowFunction ||
|
|
2028
|
+
kind === SyntaxKind.FunctionExpression) {
|
|
2029
|
+
return parent;
|
|
2030
|
+
}
|
|
2031
|
+
parent = parent.getParent();
|
|
2032
|
+
}
|
|
2033
|
+
return undefined;
|
|
2034
|
+
}
|
|
2035
|
+
function extractAuthPropagation(call, funcName, objName, isWrappedClientCall, hasAuthHeader) {
|
|
2036
|
+
if (hasAuthHeader === true)
|
|
2037
|
+
return 'present';
|
|
2038
|
+
if (hasCookieOrSessionEvidence(call, funcName))
|
|
2039
|
+
return 'present';
|
|
2040
|
+
if (isWrappedClientCall)
|
|
2041
|
+
return /auth|session|private|secure/i.test(objName) ? 'present' : 'unknown';
|
|
2042
|
+
if (funcName === 'fetch')
|
|
2043
|
+
return hasAuthHeader === false ? 'absent' : 'unknown';
|
|
2044
|
+
const config = networkConfigArgument(call, funcName);
|
|
2045
|
+
if (!config)
|
|
2046
|
+
return 'absent';
|
|
2047
|
+
if (config.getKind() !== SyntaxKind.ObjectLiteralExpression)
|
|
2048
|
+
return 'unknown';
|
|
2049
|
+
const obj = config;
|
|
2050
|
+
if (obj.getProperties().some((p) => p.getKind() === SyntaxKind.SpreadAssignment))
|
|
2051
|
+
return 'unknown';
|
|
2052
|
+
return configObjectHasAuthEvidence(obj) ? 'present' : 'absent';
|
|
2053
|
+
}
|
|
2054
|
+
function hasCookieOrSessionEvidence(call, funcName) {
|
|
2055
|
+
const config = funcName === 'fetch' ? call.getArguments()[1] : networkConfigArgument(call, funcName);
|
|
2056
|
+
if (!config || config.getKind() !== SyntaxKind.ObjectLiteralExpression)
|
|
2057
|
+
return false;
|
|
2058
|
+
return configObjectHasAuthEvidence(config);
|
|
2059
|
+
}
|
|
2060
|
+
function networkConfigArgument(call, funcName) {
|
|
2061
|
+
const args = call.getArguments();
|
|
2062
|
+
if (funcName === 'fetch')
|
|
2063
|
+
return args[1];
|
|
2064
|
+
if (AXIOS_STYLE_METHODS.has(funcName))
|
|
2065
|
+
return args[2];
|
|
2066
|
+
return args[1];
|
|
2067
|
+
}
|
|
2068
|
+
function configObjectHasAuthEvidence(obj) {
|
|
2069
|
+
const credentialsProp = obj.getProperty('credentials');
|
|
2070
|
+
if (credentialsProp?.getKind() === SyntaxKind.PropertyAssignment) {
|
|
2071
|
+
const init = credentialsProp.getInitializer();
|
|
2072
|
+
if (init &&
|
|
2073
|
+
(init.getKind() === SyntaxKind.StringLiteral || init.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral)) {
|
|
2074
|
+
const value = init.getLiteralValue();
|
|
2075
|
+
if (value === 'include' || value === 'same-origin')
|
|
2076
|
+
return true;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
const withCredentialsProp = obj.getProperty('withCredentials');
|
|
2080
|
+
if (withCredentialsProp?.getKind() === SyntaxKind.PropertyAssignment) {
|
|
2081
|
+
const init = withCredentialsProp.getInitializer();
|
|
2082
|
+
if (init?.getKind() === SyntaxKind.TrueKeyword)
|
|
2083
|
+
return true;
|
|
2084
|
+
}
|
|
2085
|
+
const headersProp = obj.getProperty('headers');
|
|
2086
|
+
if (headersProp?.getKind() !== SyntaxKind.PropertyAssignment)
|
|
2087
|
+
return false;
|
|
2088
|
+
const headersInit = headersProp.getInitializer();
|
|
2089
|
+
if (!headersInit || headersInit.getKind() !== SyntaxKind.ObjectLiteralExpression)
|
|
2090
|
+
return false;
|
|
2091
|
+
const headersObj = headersInit;
|
|
2092
|
+
if (headersObj.getProperties().some((p) => p.getKind() === SyntaxKind.SpreadAssignment))
|
|
2093
|
+
return false;
|
|
2094
|
+
for (const prop of headersObj.getProperties()) {
|
|
2095
|
+
if (prop.getKind() !== SyntaxKind.PropertyAssignment)
|
|
2096
|
+
continue;
|
|
2097
|
+
const name = prop.getName().replace(/['"]/g, '').toLowerCase();
|
|
2098
|
+
if (name === 'authorization' || name === 'cookie' || name === 'x-session' || name === 'x-csrf-token')
|
|
2099
|
+
return true;
|
|
2100
|
+
}
|
|
2101
|
+
return false;
|
|
2102
|
+
}
|
|
2103
|
+
function escapeRegExp(value) {
|
|
2104
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2105
|
+
}
|
|
1444
2106
|
function classifyBodyExpression(expr) {
|
|
1445
2107
|
if (!expr)
|
|
1446
2108
|
return undefined;
|