pumuki 6.3.293 → 6.3.294

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/VERSION CHANGED
@@ -1 +1 @@
1
- 6.3.291
1
+ 6.3.294
@@ -98,9 +98,11 @@ import {
98
98
  hasSwiftNonIBOutletImplicitlyUnwrappedOptionalUsage,
99
99
  hasSwiftObservableObjectUsage,
100
100
  hasSwiftOnChangeTaskUsage,
101
+ collectSwiftOnChangeTaskLines,
101
102
  collectSwiftOnChangeReadonlyVarLines,
102
103
  hasSwiftOnChangeReadonlyVarUsage,
103
104
  hasSwiftOnAppearTaskUsage,
105
+ collectSwiftOnAppearTaskLines,
104
106
  hasSwiftOnTapGestureUsage,
105
107
  hasSwiftOperationQueueUsage,
106
108
  hasSwiftContainsUserFilterUsage,
@@ -766,7 +768,9 @@ struct FeedView: View {
766
768
  `;
767
769
 
768
770
  assert.equal(hasSwiftOnAppearTaskUsage(source), true);
771
+ assert.deepEqual(collectSwiftOnAppearTaskLines(source), [8]);
769
772
  assert.equal(hasSwiftOnAppearTaskUsage(safe), false);
773
+ assert.deepEqual(collectSwiftOnAppearTaskLines(safe), []);
770
774
  });
771
775
 
772
776
  test('hasSwiftOnChangeTaskUsage detecta Task dentro de onChange y preserva task id', () => {
@@ -803,7 +807,9 @@ struct SearchView: View {
803
807
  `;
804
808
 
805
809
  assert.equal(hasSwiftOnChangeTaskUsage(source), true);
810
+ assert.deepEqual(collectSwiftOnChangeTaskLines(source), [8]);
806
811
  assert.equal(hasSwiftOnChangeTaskUsage(safe), false);
812
+ assert.deepEqual(collectSwiftOnChangeTaskLines(safe), []);
807
813
  });
808
814
 
809
815
  test('hasSwiftOnChangeReadonlyVarUsage detecta var local dentro de onChange y preserva let', () => {
@@ -699,17 +699,52 @@ export const hasSwiftEmptyCatchUsage = (source: string): boolean => {
699
699
  };
700
700
 
701
701
  export const hasSwiftOnAppearTaskUsage = (source: string): boolean => {
702
- const sanitized = sanitizeSwiftSourceForMultilineRegex(source);
703
- return /\.onAppear\s*\{[\s\S]{0,500}?\bTask\s*(?:\([^)]*\))?\s*\{/.test(sanitized);
702
+ return collectSwiftOnAppearTaskLines(source).length > 0;
704
703
  };
705
704
 
706
705
  export const hasSwiftOnChangeTaskUsage = (source: string): boolean => {
707
- const sanitized = sanitizeSwiftSourceForMultilineRegex(source);
708
- return /\.onChange\s*\([^)]*\)\s*\{[\s\S]{0,500}?\bTask\s*(?:\([^)]*\))?\s*\{/.test(
709
- sanitized
710
- );
706
+ return collectSwiftOnChangeTaskLines(source).length > 0;
711
707
  };
712
708
 
709
+ const collectSwiftLifecycleTaskLines = (
710
+ source: string,
711
+ lifecyclePattern: RegExp
712
+ ): readonly number[] => {
713
+ const lines: number[] = [];
714
+ let insideLifecycle = false;
715
+ let braceDepth = 0;
716
+
717
+ source.split(/\r?\n/).forEach((rawLine, index) => {
718
+ const line = stripSwiftLineForSemanticScan(rawLine);
719
+
720
+ if (!insideLifecycle && lifecyclePattern.test(line)) {
721
+ insideLifecycle = true;
722
+ braceDepth = 0;
723
+ }
724
+
725
+ if (insideLifecycle && /\bTask\s*(?:\([^)]*\))?\s*\{/.test(line)) {
726
+ lines.push(index + 1);
727
+ }
728
+
729
+ if (insideLifecycle) {
730
+ const opened = (line.match(/\{/g) ?? []).length;
731
+ const closed = (line.match(/\}/g) ?? []).length;
732
+ braceDepth += opened - closed;
733
+ if (braceDepth <= 0) {
734
+ insideLifecycle = false;
735
+ }
736
+ }
737
+ });
738
+
739
+ return sortedUniqueLines(lines);
740
+ };
741
+
742
+ export const collectSwiftOnAppearTaskLines = (source: string): readonly number[] =>
743
+ collectSwiftLifecycleTaskLines(source, /\.onAppear\s*\{/);
744
+
745
+ export const collectSwiftOnChangeTaskLines = (source: string): readonly number[] =>
746
+ collectSwiftLifecycleTaskLines(source, /\.onChange\s*\([^)]*\)\s*\{/);
747
+
713
748
  export const collectSwiftOnChangeReadonlyVarLines = (source: string): readonly number[] => {
714
749
  const lines: number[] = [];
715
750
  let insideOnChange = false;
@@ -6,6 +6,7 @@ import {
6
6
  findDtoPropertyWithoutValidationMatch,
7
7
  findDtoNestedPropertyWithoutNestedValidationMatch,
8
8
  findBackendControllerRouteWithoutGuardLines,
9
+ findBackendControllerRouteWithoutRolesLines,
9
10
  findBackendPermissiveCorsConfigurationLines,
10
11
  findBackendStringLiteralUnionEnumCandidateLines,
11
12
  findBackendHardDeleteWithoutSoftDeleteLines,
@@ -13,7 +14,11 @@ import {
13
14
  findBackendInconsistentErrorResponseLines,
14
15
  findBackendErrorPayloadWithSuccessStatusLines,
15
16
  findBackendControllerEntityResponseLines,
17
+ findBackendControllerBusinessLogicMatch,
18
+ findBackendRepositoryBusinessLogicMatch,
16
19
  findBackendRawThrowExpressionLines,
20
+ findBackendMissingGlobalValidationPipeLines,
21
+ findBackendMissingHelmetSecurityHeadersLines,
17
22
  findFrameworkDependencyImportMatch,
18
23
  findMixedCommandQueryClassMatch,
19
24
  findMixedCommandQueryInterfaceMatch,
@@ -37,13 +42,18 @@ import {
37
42
  hasDtoPropertyWithoutValidation,
38
43
  hasDtoNestedPropertyWithoutNestedValidation,
39
44
  hasBackendControllerRouteWithoutGuard,
45
+ hasBackendControllerRouteWithoutRoles,
40
46
  hasBackendPermissiveCorsConfiguration,
41
47
  hasBackendStringLiteralUnionEnumCandidate,
42
48
  hasBackendHardDeleteWithoutSoftDelete,
43
49
  hasBackendInconsistentErrorResponse,
44
50
  hasBackendErrorPayloadWithSuccessStatus,
45
51
  hasBackendControllerEntityResponse,
52
+ hasBackendControllerBusinessLogic,
53
+ hasBackendRepositoryBusinessLogic,
46
54
  hasBackendRawThrowExpression,
55
+ hasBackendMissingGlobalValidationPipe,
56
+ hasBackendMissingHelmetSecurityHeaders,
47
57
  hasEmptyCatchClause,
48
58
  hasEvalCall,
49
59
  hasExplicitAnyType,
@@ -715,6 +725,199 @@ test('hasBackendControllerEntityResponse detecta entidades expuestas por control
715
725
  assert.equal(hasBackendControllerEntityResponse(dtoReturnTypeAst), false);
716
726
  });
717
727
 
728
+ test('hasBackendControllerBusinessLogic detecta logica dentro de rutas controller NestJS', () => {
729
+ const controllerWithLogicAst = {
730
+ type: 'Program',
731
+ body: [
732
+ {
733
+ type: 'ClassDeclaration',
734
+ decorators: [
735
+ {
736
+ type: 'Decorator',
737
+ expression: {
738
+ type: 'CallExpression',
739
+ callee: { type: 'Identifier', name: 'Controller' },
740
+ arguments: [],
741
+ },
742
+ },
743
+ ],
744
+ body: {
745
+ type: 'ClassBody',
746
+ body: [
747
+ {
748
+ type: 'ClassMethod',
749
+ key: { type: 'Identifier', name: 'createOrder' },
750
+ loc: { start: { line: 18 }, end: { line: 30 } },
751
+ decorators: [
752
+ {
753
+ type: 'Decorator',
754
+ expression: {
755
+ type: 'CallExpression',
756
+ callee: { type: 'Identifier', name: 'Post' },
757
+ arguments: [],
758
+ },
759
+ },
760
+ ],
761
+ body: {
762
+ type: 'BlockStatement',
763
+ body: [
764
+ {
765
+ type: 'IfStatement',
766
+ test: { type: 'Identifier', name: 'invalid' },
767
+ consequent: { type: 'BlockStatement', body: [] },
768
+ },
769
+ ],
770
+ },
771
+ },
772
+ ],
773
+ },
774
+ },
775
+ ],
776
+ };
777
+ const thinControllerAst = {
778
+ type: 'Program',
779
+ body: [
780
+ {
781
+ type: 'ClassDeclaration',
782
+ decorators: [
783
+ {
784
+ type: 'Decorator',
785
+ expression: {
786
+ type: 'CallExpression',
787
+ callee: { type: 'Identifier', name: 'Controller' },
788
+ arguments: [],
789
+ },
790
+ },
791
+ ],
792
+ body: {
793
+ type: 'ClassBody',
794
+ body: [
795
+ {
796
+ type: 'ClassMethod',
797
+ key: { type: 'Identifier', name: 'createOrder' },
798
+ decorators: [
799
+ {
800
+ type: 'Decorator',
801
+ expression: {
802
+ type: 'CallExpression',
803
+ callee: { type: 'Identifier', name: 'Post' },
804
+ arguments: [],
805
+ },
806
+ },
807
+ ],
808
+ body: {
809
+ type: 'BlockStatement',
810
+ body: [
811
+ {
812
+ type: 'ReturnStatement',
813
+ argument: {
814
+ type: 'CallExpression',
815
+ callee: {
816
+ type: 'MemberExpression',
817
+ object: {
818
+ type: 'MemberExpression',
819
+ object: { type: 'ThisExpression' },
820
+ property: { type: 'Identifier', name: 'ordersService' },
821
+ },
822
+ property: { type: 'Identifier', name: 'create' },
823
+ },
824
+ arguments: [],
825
+ },
826
+ },
827
+ ],
828
+ },
829
+ },
830
+ ],
831
+ },
832
+ },
833
+ ],
834
+ };
835
+
836
+ assert.equal(hasBackendControllerBusinessLogic(controllerWithLogicAst), true);
837
+ assert.equal(hasBackendControllerBusinessLogic(thinControllerAst), false);
838
+
839
+ const match = findBackendControllerBusinessLogicMatch(controllerWithLogicAst);
840
+ assert.ok(match);
841
+ assert.deepEqual(match.lines, [18]);
842
+ assert.equal(match.primary_node, 'createOrder');
843
+ });
844
+
845
+ test('hasBackendRepositoryBusinessLogic detecta decisiones de negocio dentro de repositories', () => {
846
+ const repositoryWithLogicAst = {
847
+ type: 'Program',
848
+ body: [
849
+ {
850
+ type: 'ClassDeclaration',
851
+ id: { type: 'Identifier', name: 'OrdersRepository' },
852
+ body: {
853
+ type: 'ClassBody',
854
+ body: [
855
+ {
856
+ type: 'ClassMethod',
857
+ key: { type: 'Identifier', name: 'saveOrder' },
858
+ loc: { start: { line: 27 }, end: { line: 35 } },
859
+ body: {
860
+ type: 'BlockStatement',
861
+ body: [
862
+ {
863
+ type: 'IfStatement',
864
+ test: { type: 'Identifier', name: 'isPremium' },
865
+ consequent: { type: 'BlockStatement', body: [] },
866
+ },
867
+ ],
868
+ },
869
+ },
870
+ ],
871
+ },
872
+ },
873
+ ],
874
+ };
875
+ const thinRepositoryAst = {
876
+ type: 'Program',
877
+ body: [
878
+ {
879
+ type: 'ClassDeclaration',
880
+ id: { type: 'Identifier', name: 'OrdersRepository' },
881
+ body: {
882
+ type: 'ClassBody',
883
+ body: [
884
+ {
885
+ type: 'ClassMethod',
886
+ key: { type: 'Identifier', name: 'findActiveOrdersByUserId' },
887
+ body: {
888
+ type: 'BlockStatement',
889
+ body: [
890
+ {
891
+ type: 'ReturnStatement',
892
+ argument: {
893
+ type: 'CallExpression',
894
+ callee: {
895
+ type: 'MemberExpression',
896
+ object: { type: 'Identifier', name: 'queryBuilder' },
897
+ property: { type: 'Identifier', name: 'where' },
898
+ },
899
+ arguments: [],
900
+ },
901
+ },
902
+ ],
903
+ },
904
+ },
905
+ ],
906
+ },
907
+ },
908
+ ],
909
+ };
910
+
911
+ assert.equal(hasBackendRepositoryBusinessLogic(repositoryWithLogicAst), true);
912
+ assert.equal(hasBackendRepositoryBusinessLogic(thinRepositoryAst), false);
913
+
914
+ const match = findBackendRepositoryBusinessLogicMatch(repositoryWithLogicAst);
915
+ assert.ok(match);
916
+ assert.deepEqual(match.lines, [27]);
917
+ assert.equal(match.primary_node, 'OrdersRepository');
918
+ assert.deepEqual(match.related_nodes, ['saveOrder']);
919
+ });
920
+
718
921
  test('hasBackendAnemicDomainModel detecta entidades de dominio anemicas', () => {
719
922
  const anemicEntityAst = {
720
923
  type: 'ClassDeclaration',
@@ -2758,6 +2961,164 @@ test('hasDtoNestedPropertyWithoutNestedValidation detecta DTOs anidados sin Vali
2758
2961
  assert.deepEqual(match.related_nodes, ['address']);
2759
2962
  });
2760
2963
 
2964
+ test('hasBackendMissingGlobalValidationPipe detecta bootstrap NestJS sin ValidationPipe estricto', () => {
2965
+ const missingPipeAst = {
2966
+ type: 'Program',
2967
+ body: [
2968
+ {
2969
+ type: 'ExpressionStatement',
2970
+ expression: {
2971
+ type: 'CallExpression',
2972
+ loc: { start: { line: 7 }, end: { line: 7 } },
2973
+ callee: {
2974
+ type: 'MemberExpression',
2975
+ object: { type: 'Identifier', name: 'NestFactory' },
2976
+ property: { type: 'Identifier', name: 'create' },
2977
+ },
2978
+ arguments: [],
2979
+ },
2980
+ },
2981
+ ],
2982
+ };
2983
+ const unsafePipeAst = {
2984
+ type: 'Program',
2985
+ body: [
2986
+ {
2987
+ type: 'ExpressionStatement',
2988
+ expression: {
2989
+ type: 'CallExpression',
2990
+ loc: { start: { line: 11 }, end: { line: 11 } },
2991
+ callee: {
2992
+ type: 'MemberExpression',
2993
+ object: { type: 'Identifier', name: 'app' },
2994
+ property: { type: 'Identifier', name: 'useGlobalPipes' },
2995
+ },
2996
+ arguments: [
2997
+ {
2998
+ type: 'NewExpression',
2999
+ callee: { type: 'Identifier', name: 'ValidationPipe' },
3000
+ arguments: [],
3001
+ },
3002
+ ],
3003
+ },
3004
+ },
3005
+ ],
3006
+ };
3007
+ const strictPipeAst = {
3008
+ type: 'Program',
3009
+ body: [
3010
+ {
3011
+ type: 'ExpressionStatement',
3012
+ expression: {
3013
+ type: 'CallExpression',
3014
+ callee: {
3015
+ type: 'MemberExpression',
3016
+ object: { type: 'Identifier', name: 'NestFactory' },
3017
+ property: { type: 'Identifier', name: 'create' },
3018
+ },
3019
+ arguments: [],
3020
+ },
3021
+ },
3022
+ {
3023
+ type: 'ExpressionStatement',
3024
+ expression: {
3025
+ type: 'CallExpression',
3026
+ callee: {
3027
+ type: 'MemberExpression',
3028
+ object: { type: 'Identifier', name: 'app' },
3029
+ property: { type: 'Identifier', name: 'useGlobalPipes' },
3030
+ },
3031
+ arguments: [
3032
+ {
3033
+ type: 'NewExpression',
3034
+ callee: { type: 'Identifier', name: 'ValidationPipe' },
3035
+ arguments: [
3036
+ {
3037
+ type: 'ObjectExpression',
3038
+ properties: [
3039
+ {
3040
+ type: 'ObjectProperty',
3041
+ key: { type: 'Identifier', name: 'whitelist' },
3042
+ value: { type: 'BooleanLiteral', value: true },
3043
+ },
3044
+ ],
3045
+ },
3046
+ ],
3047
+ },
3048
+ ],
3049
+ },
3050
+ },
3051
+ ],
3052
+ };
3053
+
3054
+ assert.equal(hasBackendMissingGlobalValidationPipe(missingPipeAst), true);
3055
+ assert.deepEqual(findBackendMissingGlobalValidationPipeLines(missingPipeAst), [7]);
3056
+ assert.equal(hasBackendMissingGlobalValidationPipe(unsafePipeAst), true);
3057
+ assert.deepEqual(findBackendMissingGlobalValidationPipeLines(unsafePipeAst), [11]);
3058
+ assert.equal(hasBackendMissingGlobalValidationPipe(strictPipeAst), false);
3059
+ });
3060
+
3061
+ test('hasBackendMissingHelmetSecurityHeaders detecta bootstrap NestJS sin Helmet', () => {
3062
+ const missingHelmetAst = {
3063
+ type: 'Program',
3064
+ body: [
3065
+ {
3066
+ type: 'ExpressionStatement',
3067
+ expression: {
3068
+ type: 'CallExpression',
3069
+ loc: { start: { line: 9 }, end: { line: 9 } },
3070
+ callee: {
3071
+ type: 'MemberExpression',
3072
+ object: { type: 'Identifier', name: 'NestFactory' },
3073
+ property: { type: 'Identifier', name: 'create' },
3074
+ },
3075
+ arguments: [],
3076
+ },
3077
+ },
3078
+ ],
3079
+ };
3080
+ const helmetAst = {
3081
+ type: 'Program',
3082
+ body: [
3083
+ {
3084
+ type: 'ExpressionStatement',
3085
+ expression: {
3086
+ type: 'CallExpression',
3087
+ loc: { start: { line: 11 }, end: { line: 11 } },
3088
+ callee: {
3089
+ type: 'MemberExpression',
3090
+ object: { type: 'Identifier', name: 'NestFactory' },
3091
+ property: { type: 'Identifier', name: 'create' },
3092
+ },
3093
+ arguments: [],
3094
+ },
3095
+ },
3096
+ {
3097
+ type: 'ExpressionStatement',
3098
+ expression: {
3099
+ type: 'CallExpression',
3100
+ callee: {
3101
+ type: 'MemberExpression',
3102
+ object: { type: 'Identifier', name: 'app' },
3103
+ property: { type: 'Identifier', name: 'use' },
3104
+ },
3105
+ arguments: [
3106
+ {
3107
+ type: 'CallExpression',
3108
+ callee: { type: 'Identifier', name: 'helmet' },
3109
+ arguments: [],
3110
+ },
3111
+ ],
3112
+ },
3113
+ },
3114
+ ],
3115
+ };
3116
+
3117
+ assert.equal(hasBackendMissingHelmetSecurityHeaders(missingHelmetAst), true);
3118
+ assert.deepEqual(findBackendMissingHelmetSecurityHeadersLines(missingHelmetAst), [9]);
3119
+ assert.equal(hasBackendMissingHelmetSecurityHeaders(helmetAst), false);
3120
+ });
3121
+
2761
3122
  test('hasBackendControllerRouteWithoutGuard detecta rutas NestJS sin UseGuards', () => {
2762
3123
  const unguardedRouteAst = {
2763
3124
  type: 'Program',
@@ -2828,3 +3189,94 @@ test('hasBackendControllerRouteWithoutGuard detecta rutas NestJS sin UseGuards',
2828
3189
  assert.equal(hasBackendControllerRouteWithoutGuard(classGuardedRouteAst), false);
2829
3190
  assert.deepEqual(findBackendControllerRouteWithoutGuardLines(unguardedRouteAst), [12]);
2830
3191
  });
3192
+
3193
+ test('hasBackendControllerRouteWithoutRoles detecta rutas NestJS protegidas sin RBAC explicito', () => {
3194
+ const guardedWithoutRolesAst = {
3195
+ type: 'Program',
3196
+ body: [
3197
+ {
3198
+ type: 'ClassDeclaration',
3199
+ decorators: [
3200
+ {
3201
+ type: 'Decorator',
3202
+ expression: { type: 'CallExpression', callee: { type: 'Identifier', name: 'Controller' }, arguments: [] },
3203
+ },
3204
+ {
3205
+ type: 'Decorator',
3206
+ expression: { type: 'CallExpression', callee: { type: 'Identifier', name: 'UseGuards' }, arguments: [] },
3207
+ },
3208
+ ],
3209
+ body: {
3210
+ type: 'ClassBody',
3211
+ body: [
3212
+ {
3213
+ type: 'ClassMethod',
3214
+ key: { type: 'Identifier', name: 'deleteOrder' },
3215
+ loc: { start: { line: 31 }, end: { line: 33 } },
3216
+ decorators: [
3217
+ {
3218
+ type: 'Decorator',
3219
+ expression: { type: 'CallExpression', callee: { type: 'Identifier', name: 'Delete' }, arguments: [] },
3220
+ },
3221
+ ],
3222
+ },
3223
+ ],
3224
+ },
3225
+ },
3226
+ ],
3227
+ };
3228
+ const methodRolesAst = {
3229
+ type: 'Program',
3230
+ body: [
3231
+ {
3232
+ type: 'ClassDeclaration',
3233
+ decorators: [
3234
+ {
3235
+ type: 'Decorator',
3236
+ expression: { type: 'CallExpression', callee: { type: 'Identifier', name: 'Controller' }, arguments: [] },
3237
+ },
3238
+ {
3239
+ type: 'Decorator',
3240
+ expression: { type: 'CallExpression', callee: { type: 'Identifier', name: 'UseGuards' }, arguments: [] },
3241
+ },
3242
+ ],
3243
+ body: {
3244
+ type: 'ClassBody',
3245
+ body: [
3246
+ {
3247
+ type: 'ClassMethod',
3248
+ key: { type: 'Identifier', name: 'deleteOrder' },
3249
+ loc: { start: { line: 42 }, end: { line: 44 } },
3250
+ decorators: [
3251
+ {
3252
+ type: 'Decorator',
3253
+ expression: { type: 'CallExpression', callee: { type: 'Identifier', name: 'Roles' }, arguments: [] },
3254
+ },
3255
+ {
3256
+ type: 'Decorator',
3257
+ expression: { type: 'CallExpression', callee: { type: 'Identifier', name: 'Delete' }, arguments: [] },
3258
+ },
3259
+ ],
3260
+ },
3261
+ ],
3262
+ },
3263
+ },
3264
+ ],
3265
+ };
3266
+ const unguardedRouteAst = {
3267
+ type: 'ClassMethod',
3268
+ key: { type: 'Identifier', name: 'publicHealth' },
3269
+ loc: { start: { line: 51 }, end: { line: 53 } },
3270
+ decorators: [
3271
+ {
3272
+ type: 'Decorator',
3273
+ expression: { type: 'CallExpression', callee: { type: 'Identifier', name: 'Get' }, arguments: [] },
3274
+ },
3275
+ ],
3276
+ };
3277
+
3278
+ assert.equal(hasBackendControllerRouteWithoutRoles(guardedWithoutRolesAst), true);
3279
+ assert.deepEqual(findBackendControllerRouteWithoutRolesLines(guardedWithoutRolesAst), [31]);
3280
+ assert.equal(hasBackendControllerRouteWithoutRoles(methodRolesAst), false);
3281
+ assert.equal(hasBackendControllerRouteWithoutRoles(unguardedRouteAst), false);
3282
+ });
@@ -2155,7 +2155,7 @@ const isCorsPermissiveValue = (node: unknown): boolean => {
2155
2155
  );
2156
2156
  };
2157
2157
 
2158
- const objectPropertyName = (node: unknown): string | undefined => {
2158
+ const validationPipeObjectPropertyName = (node: unknown): string | undefined => {
2159
2159
  if (!isObject(node) || (node.type !== 'ObjectProperty' && node.type !== 'Property')) {
2160
2160
  return undefined;
2161
2161
  }
@@ -2766,6 +2766,7 @@ const nestJsClassDecoratorNames = new Set([
2766
2766
  ]);
2767
2767
  const nestJsRouteDecoratorNames = new Set(['Delete', 'Get', 'Head', 'Options', 'Patch', 'Post', 'Put']);
2768
2768
  const nestJsUseGuardsDecoratorNames = new Set(['UseGuards']);
2769
+ const nestJsRolesDecoratorNames = new Set(['Roles']);
2769
2770
 
2770
2771
  const decoratorExpressionName = (node: unknown): string | undefined => {
2771
2772
  if (!isObject(node)) {
@@ -2801,12 +2802,42 @@ const hasDecoratorFrom = (node: AstNode, allowedNames: ReadonlySet<string>): boo
2801
2802
  });
2802
2803
  };
2803
2804
 
2805
+ const decoratorCallArguments = (decorator: AstNode): readonly unknown[] => {
2806
+ const expression = decorator.expression;
2807
+ if (!isObject(expression) || expression.type !== 'CallExpression') {
2808
+ return [];
2809
+ }
2810
+ return Array.isArray(expression.arguments) ? expression.arguments : [];
2811
+ };
2812
+
2804
2813
  const hasNestJsRouteDecorator = (node: AstNode): boolean =>
2805
2814
  hasDecoratorFrom(node, nestJsRouteDecoratorNames);
2806
2815
 
2807
2816
  const hasNestJsUseGuardsDecorator = (node: AstNode): boolean =>
2808
2817
  hasDecoratorFrom(node, nestJsUseGuardsDecoratorNames);
2809
2818
 
2819
+ const isRolesSetMetadataDecorator = (decorator: AstNode): boolean => {
2820
+ const name = decoratorExpressionName(decorator.expression);
2821
+ if (name !== 'SetMetadata') {
2822
+ return false;
2823
+ }
2824
+ const [metadataKey] = decoratorCallArguments(decorator);
2825
+ return isObject(metadataKey) && metadataKey.type === 'StringLiteral' && metadataKey.value === 'roles';
2826
+ };
2827
+
2828
+ const hasNestJsRolesDecorator = (node: AstNode): boolean => {
2829
+ if (!Array.isArray(node.decorators)) {
2830
+ return false;
2831
+ }
2832
+ return node.decorators.some((decorator) => {
2833
+ if (!isObject(decorator)) {
2834
+ return false;
2835
+ }
2836
+ const name = decoratorExpressionName(decorator.expression);
2837
+ return (name !== undefined && nestJsRolesDecoratorNames.has(name)) || isRolesSetMetadataDecorator(decorator);
2838
+ });
2839
+ };
2840
+
2810
2841
  const isClassLikeNode = (node: unknown): node is AstNode =>
2811
2842
  isObject(node) && (node.type === 'ClassDeclaration' || node.type === 'ClassExpression');
2812
2843
 
@@ -2926,6 +2957,23 @@ export const findBackendControllerRouteWithoutGuardLines = (ast: unknown): reado
2926
2957
  export const hasBackendControllerRouteWithoutGuard = (ast: unknown): boolean =>
2927
2958
  findBackendControllerRouteWithoutGuardLines(ast).length > 0;
2928
2959
 
2960
+ export const findBackendControllerRouteWithoutRolesLines = (ast: unknown): readonly number[] => {
2961
+ return collectLineMatchesWithAncestors(ast, (node, ancestors) => {
2962
+ if (!isClassMethodLikeNode(node) || !hasNestJsRouteDecorator(node)) {
2963
+ return false;
2964
+ }
2965
+ const ownerClass = [...ancestors].reverse().find(isClassLikeNode);
2966
+ const hasGuard = hasNestJsUseGuardsDecorator(node) || (ownerClass !== undefined && hasNestJsUseGuardsDecorator(ownerClass));
2967
+ if (!hasGuard) {
2968
+ return false;
2969
+ }
2970
+ return !hasNestJsRolesDecorator(node) && (ownerClass === undefined || !hasNestJsRolesDecorator(ownerClass));
2971
+ });
2972
+ };
2973
+
2974
+ export const hasBackendControllerRouteWithoutRoles = (ast: unknown): boolean =>
2975
+ findBackendControllerRouteWithoutRolesLines(ast).length > 0;
2976
+
2929
2977
  const persistenceMutationNames = new Set([
2930
2978
  'create',
2931
2979
  'delete',
@@ -3290,6 +3338,298 @@ export const findDtoNestedPropertyWithoutNestedValidationMatch = (
3290
3338
  export const hasDtoNestedPropertyWithoutNestedValidation = (ast: unknown): boolean =>
3291
3339
  findDtoNestedPropertyWithoutNestedValidationMatch(ast) !== undefined;
3292
3340
 
3341
+ const isValidationPipeCallee = (node: unknown): boolean => {
3342
+ return isObject(node) && node.type === 'Identifier' && node.name === 'ValidationPipe';
3343
+ };
3344
+
3345
+ const objectPropertyName = (node: unknown): string | undefined => {
3346
+ if (!isObject(node)) {
3347
+ return undefined;
3348
+ }
3349
+ if (isObject(node.key)) {
3350
+ return methodNameFromNode(node.key);
3351
+ }
3352
+ return undefined;
3353
+ };
3354
+
3355
+ const isBooleanTrueLiteral = (node: unknown): boolean => {
3356
+ return isObject(node) && node.type === 'BooleanLiteral' && node.value === true;
3357
+ };
3358
+
3359
+ const validationPipeOptionsEnableWhitelist = (node: unknown): boolean => {
3360
+ if (!isObject(node) || node.type !== 'ObjectExpression' || !Array.isArray(node.properties)) {
3361
+ return false;
3362
+ }
3363
+ return node.properties.some((property) => {
3364
+ return (
3365
+ isObject(property) &&
3366
+ validationPipeObjectPropertyName(property) === 'whitelist' &&
3367
+ isBooleanTrueLiteral(property.value)
3368
+ );
3369
+ });
3370
+ };
3371
+
3372
+ const isStrictValidationPipeExpression = (node: unknown): boolean => {
3373
+ if (!isObject(node)) {
3374
+ return false;
3375
+ }
3376
+ if (node.type !== 'NewExpression' && node.type !== 'CallExpression') {
3377
+ return false;
3378
+ }
3379
+ if (!isValidationPipeCallee(node.callee)) {
3380
+ return false;
3381
+ }
3382
+ const args = Array.isArray(node.arguments) ? node.arguments : [];
3383
+ return args.some(validationPipeOptionsEnableWhitelist);
3384
+ };
3385
+
3386
+ const isUseGlobalPipesCall = (node: AstNode): boolean => {
3387
+ return node.type === 'CallExpression' && callExpressionMemberName(node) === 'useGlobalPipes';
3388
+ };
3389
+
3390
+ const isStrictUseGlobalPipesCall = (node: AstNode): boolean => {
3391
+ if (!isUseGlobalPipesCall(node)) {
3392
+ return false;
3393
+ }
3394
+ const args = Array.isArray(node.arguments) ? node.arguments : [];
3395
+ return args.some(isStrictValidationPipeExpression);
3396
+ };
3397
+
3398
+ const isBackendValidationNestFactoryCreateCall = (node: AstNode): boolean => {
3399
+ if (node.type !== 'CallExpression') {
3400
+ return false;
3401
+ }
3402
+ const memberName = callExpressionMemberName(node);
3403
+ const objectName = callExpressionObjectName(node);
3404
+ return memberName === 'create' && objectName === 'NestFactory';
3405
+ };
3406
+
3407
+ export const findBackendMissingGlobalValidationPipeLines = (ast: unknown): readonly number[] => {
3408
+ const unsafeUseGlobalPipesLines = collectLineMatchesWithAncestors(
3409
+ ast,
3410
+ (node) => isUseGlobalPipesCall(node) && !isStrictUseGlobalPipesCall(node)
3411
+ );
3412
+ if (unsafeUseGlobalPipesLines.length > 0) {
3413
+ return unsafeUseGlobalPipesLines;
3414
+ }
3415
+
3416
+ if (!hasNode(ast, isBackendValidationNestFactoryCreateCall)) {
3417
+ return [];
3418
+ }
3419
+ if (hasNode(ast, isStrictUseGlobalPipesCall)) {
3420
+ return [];
3421
+ }
3422
+ return collectLineMatchesWithAncestors(ast, isBackendValidationNestFactoryCreateCall, { max: 1 });
3423
+ };
3424
+
3425
+ export const hasBackendMissingGlobalValidationPipe = (ast: unknown): boolean =>
3426
+ findBackendMissingGlobalValidationPipeLines(ast).length > 0;
3427
+
3428
+ const isHelmetCallee = (node: unknown): boolean => {
3429
+ return isObject(node) && node.type === 'Identifier' && node.name === 'helmet';
3430
+ };
3431
+
3432
+ const isHelmetCallExpression = (node: unknown): boolean => {
3433
+ return isObject(node) && node.type === 'CallExpression' && isHelmetCallee(node.callee);
3434
+ };
3435
+
3436
+ const isAppUseHelmetCall = (node: AstNode): boolean => {
3437
+ if (node.type !== 'CallExpression' || callExpressionMemberName(node) !== 'use') {
3438
+ return false;
3439
+ }
3440
+ const args = Array.isArray(node.arguments) ? node.arguments : [];
3441
+ return args.some(isHelmetCallExpression);
3442
+ };
3443
+
3444
+ export const findBackendMissingHelmetSecurityHeadersLines = (ast: unknown): readonly number[] => {
3445
+ if (!hasNode(ast, isBackendValidationNestFactoryCreateCall)) {
3446
+ return [];
3447
+ }
3448
+ if (hasNode(ast, isAppUseHelmetCall)) {
3449
+ return [];
3450
+ }
3451
+ return collectLineMatchesWithAncestors(ast, isBackendValidationNestFactoryCreateCall, { max: 1 });
3452
+ };
3453
+
3454
+ export const hasBackendMissingHelmetSecurityHeaders = (ast: unknown): boolean =>
3455
+ findBackendMissingHelmetSecurityHeadersLines(ast).length > 0;
3456
+
3457
+ const backendRouteDecoratorNames = new Set([
3458
+ 'All',
3459
+ 'Delete',
3460
+ 'Get',
3461
+ 'Head',
3462
+ 'Options',
3463
+ 'Patch',
3464
+ 'Post',
3465
+ 'Put',
3466
+ ]);
3467
+
3468
+ const backendControllerBusinessLogicNodeTypes = new Set([
3469
+ 'DoWhileStatement',
3470
+ 'ForInStatement',
3471
+ 'ForOfStatement',
3472
+ 'ForStatement',
3473
+ 'IfStatement',
3474
+ 'SwitchStatement',
3475
+ 'WhileStatement',
3476
+ ]);
3477
+
3478
+ const hasBackendRouteDecorator = (node: AstNode): boolean => {
3479
+ if (!Array.isArray(node.decorators)) {
3480
+ return false;
3481
+ }
3482
+ return node.decorators.some((decorator) => {
3483
+ if (!isObject(decorator)) {
3484
+ return false;
3485
+ }
3486
+ const name = decoratorExpressionName(decorator.expression);
3487
+ return name !== undefined && backendRouteDecoratorNames.has(name);
3488
+ });
3489
+ };
3490
+
3491
+ const isBackendControllerRouteMethod = (node: AstNode): boolean => {
3492
+ return (
3493
+ (node.type === 'ClassMethod' ||
3494
+ node.type === 'ClassPrivateMethod' ||
3495
+ node.type === 'ObjectMethod') &&
3496
+ hasBackendRouteDecorator(node)
3497
+ );
3498
+ };
3499
+
3500
+ const isBackendControllerBusinessLogicNode = (node: AstNode): boolean => {
3501
+ if (typeof node.type === 'string' && backendControllerBusinessLogicNodeTypes.has(node.type)) {
3502
+ return true;
3503
+ }
3504
+ if (isPersistenceMutationCall(node)) {
3505
+ return true;
3506
+ }
3507
+ const objectName = callExpressionObjectName(node);
3508
+ const rootObjectName =
3509
+ node.type === 'CallExpression' && isObject(node.callee)
3510
+ ? memberExpressionRootObjectName(node.callee)
3511
+ : undefined;
3512
+ return (
3513
+ (objectName !== undefined && /(?:repository|repo|prisma|supabase|model|collection)$/i.test(objectName)) ||
3514
+ (rootObjectName !== undefined && /(?:repository|repo|prisma|supabase|model|collection)$/i.test(rootObjectName))
3515
+ );
3516
+ };
3517
+
3518
+ export type TypeScriptBackendControllerBusinessLogicMatch = {
3519
+ lines: readonly number[];
3520
+ primary_node: string;
3521
+ related_nodes: readonly string[];
3522
+ why: string;
3523
+ impact: string;
3524
+ expected_fix: string;
3525
+ };
3526
+
3527
+ export const findBackendControllerBusinessLogicMatch = (
3528
+ ast: unknown
3529
+ ): TypeScriptBackendControllerBusinessLogicMatch | undefined => {
3530
+ const match = findFirstNodeWithAncestors(ast, (node) => {
3531
+ if (!isBackendControllerRouteMethod(node)) {
3532
+ return false;
3533
+ }
3534
+ return hasDescendantNode(
3535
+ node,
3536
+ (nested) => nested !== node && isBackendControllerBusinessLogicNode(nested)
3537
+ );
3538
+ });
3539
+
3540
+ if (match === undefined) {
3541
+ return undefined;
3542
+ }
3543
+
3544
+ const methodName = methodNameFromNode(match.node.key) ?? 'anonymous controller route';
3545
+ const line = toPositiveLine(match.node);
3546
+
3547
+ return {
3548
+ lines: line === null ? [] : sortedUniqueLines([line]),
3549
+ primary_node: methodName,
3550
+ related_nodes: ['NestJS route handler', 'business logic in controller'],
3551
+ why: `El handler ${methodName} contiene logica de negocio o acceso a persistencia dentro del controller.`,
3552
+ impact:
3553
+ 'El controller deja de ser una frontera HTTP delgada, mezcla routing con dominio/aplicacion y dificulta testabilidad y reutilizacion.',
3554
+ expected_fix:
3555
+ 'Mueve la logica a un service/use case inyectado y deja el controller solo con routing, validacion de entrada y delegacion.',
3556
+ };
3557
+ };
3558
+
3559
+ export const findBackendControllerBusinessLogicLines = (ast: unknown): readonly number[] =>
3560
+ findBackendControllerBusinessLogicMatch(ast)?.lines ?? [];
3561
+
3562
+ export const hasBackendControllerBusinessLogic = (ast: unknown): boolean =>
3563
+ findBackendControllerBusinessLogicMatch(ast) !== undefined;
3564
+
3565
+ const isBackendRepositoryClassName = (name: string | undefined): boolean =>
3566
+ name !== undefined && /(?:Repository|Repo)$/i.test(name);
3567
+
3568
+ const isBackendRepositoryBusinessLogicMethod = (node: AstNode): boolean => {
3569
+ if (
3570
+ node.type !== 'ClassMethod' &&
3571
+ node.type !== 'ClassPrivateMethod' &&
3572
+ node.type !== 'ObjectMethod'
3573
+ ) {
3574
+ return false;
3575
+ }
3576
+ return hasDescendantNode(node, (nested) => {
3577
+ return (
3578
+ nested !== node &&
3579
+ typeof nested.type === 'string' &&
3580
+ backendControllerBusinessLogicNodeTypes.has(nested.type)
3581
+ );
3582
+ });
3583
+ };
3584
+
3585
+ export type TypeScriptBackendRepositoryBusinessLogicMatch = {
3586
+ lines: readonly number[];
3587
+ primary_node: string;
3588
+ related_nodes: readonly string[];
3589
+ why: string;
3590
+ impact: string;
3591
+ expected_fix: string;
3592
+ };
3593
+
3594
+ export const findBackendRepositoryBusinessLogicMatch = (
3595
+ ast: unknown
3596
+ ): TypeScriptBackendRepositoryBusinessLogicMatch | undefined => {
3597
+ const match = findFirstNodeWithAncestors(ast, (node) => {
3598
+ if (node.type !== 'ClassDeclaration' || !isBackendRepositoryClassName(classNameFromNode(node))) {
3599
+ return false;
3600
+ }
3601
+ const body = isObject(node.body) && Array.isArray(node.body.body) ? node.body.body : [];
3602
+ return body.some((member) => isObject(member) && isBackendRepositoryBusinessLogicMethod(member));
3603
+ });
3604
+
3605
+ if (match === undefined) {
3606
+ return undefined;
3607
+ }
3608
+
3609
+ const className = classNameFromNode(match.node) ?? 'Repository';
3610
+ const body = isObject(match.node.body) && Array.isArray(match.node.body.body) ? match.node.body.body : [];
3611
+ const method = body.find((member) => isObject(member) && isBackendRepositoryBusinessLogicMethod(member));
3612
+ const methodName = isObject(method) ? methodNameFromNode(method.key) : undefined;
3613
+ const line = isObject(method) ? toPositiveLine(method) : toPositiveLine(match.node);
3614
+
3615
+ return {
3616
+ lines: line === null ? [] : sortedUniqueLines([line]),
3617
+ primary_node: className,
3618
+ related_nodes: methodName === undefined ? ['repository method'] : [methodName],
3619
+ why: `El repositorio ${className} contiene ramas de decision dentro de un metodo de persistencia.`,
3620
+ impact:
3621
+ 'El repositorio mezcla reglas de negocio con acceso a datos, rompiendo Clean Architecture y dificultando pruebas de casos de uso.',
3622
+ expected_fix:
3623
+ 'Mueve la decision de negocio a un use case/service y deja el repositorio limitado a CRUD, queries expresivas y mapeo de datos.',
3624
+ };
3625
+ };
3626
+
3627
+ export const findBackendRepositoryBusinessLogicLines = (ast: unknown): readonly number[] =>
3628
+ findBackendRepositoryBusinessLogicMatch(ast)?.lines ?? [];
3629
+
3630
+ export const hasBackendRepositoryBusinessLogic = (ast: unknown): boolean =>
3631
+ findBackendRepositoryBusinessLogicMatch(ast) !== undefined;
3632
+
3293
3633
  const isRecordTypeName = (node: unknown): boolean => {
3294
3634
  return (
3295
3635
  isObject(node) &&
@@ -499,8 +499,12 @@ const astDetectorRegistry: ReadonlyArray<ASTDetectorRegistryEntry> = [
499
499
  { detect: TS.hasBackendInconsistentErrorResponse, locateLines: TS.findBackendInconsistentErrorResponseLines, ruleId: 'heuristics.ts.backend.inconsistent-error-response.ast', code: 'HEURISTICS_BACKEND_INCONSISTENT_ERROR_RESPONSE_AST', message: 'AST heuristic detected backend error response without statusCode, message, timestamp and path.', pathCheck: isBackendTypeScriptPath },
500
500
  { detect: TS.hasBackendErrorPayloadWithSuccessStatus, locateLines: TS.findBackendErrorPayloadWithSuccessStatusLines, ruleId: 'heuristics.ts.backend.error-payload-success-status.ast', code: 'HEURISTICS_BACKEND_ERROR_PAYLOAD_SUCCESS_STATUS_AST', message: 'AST heuristic detected backend error payload sent with a success HTTP status.', pathCheck: isBackendTypeScriptPath },
501
501
  { detect: TS.hasBackendControllerEntityResponse, locateLines: TS.findBackendControllerEntityResponseLines, ruleId: 'heuristics.ts.backend.controller-entity-response.ast', code: 'HEURISTICS_BACKEND_CONTROLLER_ENTITY_RESPONSE_AST', message: 'AST heuristic detected backend controller exposing domain entities directly; return DTOs instead.', pathCheck: isBackendControllerTypeScriptPath },
502
+ { detect: TS.hasBackendControllerBusinessLogic, locateLines: TS.findBackendControllerBusinessLogicLines, ruleId: 'heuristics.ts.backend.controller-business-logic.ast', code: 'HEURISTICS_BACKEND_CONTROLLER_BUSINESS_LOGIC_AST', message: 'AST heuristic detected business logic inside a NestJS controller route handler.', pathCheck: isBackendControllerTypeScriptPath },
503
+ { detect: TS.hasBackendRepositoryBusinessLogic, locateLines: TS.findBackendRepositoryBusinessLogicLines, ruleId: 'heuristics.ts.backend.repository-business-logic.ast', code: 'HEURISTICS_BACKEND_REPOSITORY_BUSINESS_LOGIC_AST', message: 'AST heuristic detected business decision logic inside a backend repository.', pathCheck: isBackendTypeScriptPath },
502
504
  { detect: TS.hasBackendAnemicDomainModel, ruleId: 'heuristics.ts.backend.anemic-domain-model.ast', code: 'HEURISTICS_BACKEND_ANEMIC_DOMAIN_MODEL_AST', message: 'AST heuristic detected an anemic backend domain entity without business behavior.', pathCheck: isBackendDomainEntityPath },
503
505
  { detect: TS.hasBackendPermissiveCorsConfiguration, locateLines: TS.findBackendPermissiveCorsConfigurationLines, ruleId: 'heuristics.ts.backend.permissive-cors.ast', code: 'HEURISTICS_BACKEND_PERMISSIVE_CORS_AST', message: 'AST heuristic detected permissive backend CORS configuration; restrict allowed origins explicitly.', pathCheck: isBackendTypeScriptPath },
506
+ { detect: TS.hasBackendMissingHelmetSecurityHeaders, locateLines: TS.findBackendMissingHelmetSecurityHeadersLines, ruleId: 'heuristics.ts.backend.missing-helmet-security-headers.ast', code: 'HEURISTICS_BACKEND_MISSING_HELMET_SECURITY_HEADERS_AST', message: 'AST heuristic detected NestJS bootstrap without Helmet security headers middleware.', pathCheck: isBackendTypeScriptPath },
507
+ { detect: TS.hasBackendMissingGlobalValidationPipe, locateLines: TS.findBackendMissingGlobalValidationPipeLines, ruleId: 'heuristics.ts.backend.missing-global-validation-pipe.ast', code: 'HEURISTICS_BACKEND_MISSING_GLOBAL_VALIDATION_PIPE_AST', message: 'AST heuristic detected NestJS bootstrap without global ValidationPipe whitelist.', pathCheck: isBackendTypeScriptPath },
504
508
  { detect: TS.hasBackendStringLiteralUnionEnumCandidate, locateLines: TS.findBackendStringLiteralUnionEnumCandidateLines, ruleId: 'heuristics.ts.backend.string-literal-union-enum.ast', code: 'HEURISTICS_BACKEND_STRING_LITERAL_UNION_ENUM_AST', message: 'AST heuristic detected fixed backend string literal union; use an enum or centralized typed constants.', pathCheck: isBackendTypeScriptPath },
505
509
  { detect: TS.hasEvalCall, ruleId: 'heuristics.ts.eval.ast', code: 'HEURISTICS_EVAL_AST', message: 'AST heuristic detected eval usage.' },
506
510
  { detect: TS.hasFunctionConstructorUsage, ruleId: 'heuristics.ts.function-constructor.ast', code: 'HEURISTICS_FUNCTION_CONSTRUCTOR_AST', message: 'AST heuristic detected Function constructor usage.' },
@@ -527,6 +531,7 @@ const astDetectorRegistry: ReadonlyArray<ASTDetectorRegistryEntry> = [
527
531
  { detect: TS.hasConcreteDependencyInstantiation, ruleId: 'heuristics.ts.solid.dip.concrete-instantiation.ast', code: 'HEURISTICS_SOLID_DIP_CONCRETE_INSTANTIATION_AST', message: 'AST heuristic detected DIP risk: direct instantiation of concrete framework dependency.', pathCheck: isTypeScriptDomainOrApplicationPath },
528
532
  { detect: TS.hasNestJsConstructorDependencyWithoutDecorator, ruleId: 'heuristics.ts.nestjs.constructor-di-without-decorator.ast', code: 'HEURISTICS_NESTJS_CONSTRUCTOR_DI_WITHOUT_DECORATOR_AST', message: 'AST heuristic detected NestJS constructor dependency injection without an explicit class decorator.' },
529
533
  { detect: TS.hasBackendControllerRouteWithoutGuard, locateLines: TS.findBackendControllerRouteWithoutGuardLines, ruleId: 'heuristics.ts.backend.controller-route-without-guard.ast', code: 'HEURISTICS_BACKEND_CONTROLLER_ROUTE_WITHOUT_GUARD_AST', message: 'AST heuristic detected NestJS controller route without UseGuards at method or class level.', pathCheck: isBackendTypeScriptPath },
534
+ { detect: TS.hasBackendControllerRouteWithoutRoles, locateLines: TS.findBackendControllerRouteWithoutRolesLines, ruleId: 'heuristics.ts.backend.controller-route-without-roles.ast', code: 'HEURISTICS_BACKEND_CONTROLLER_ROUTE_WITHOUT_ROLES_AST', message: 'AST heuristic detected guarded NestJS controller route without Roles/SetMetadata roles authorization.', pathCheck: isBackendTypeScriptPath },
530
535
  { detect: TS.hasPersistenceMutationWithoutAuditEvent, ruleId: 'heuristics.ts.backend.persistence-mutation-without-audit-event.ast', code: 'HEURISTICS_BACKEND_PERSISTENCE_MUTATION_WITHOUT_AUDIT_EVENT_AST', message: 'AST heuristic detected backend persistence mutation without audit log or domain event.' },
531
536
  { detect: TS.hasBackendHardDeleteWithoutSoftDelete, locateLines: TS.findBackendHardDeleteWithoutSoftDeleteLines, ruleId: 'heuristics.ts.backend.hard-delete-without-soft-delete.ast', code: 'HEURISTICS_BACKEND_HARD_DELETE_WITHOUT_SOFT_DELETE_AST', message: 'AST heuristic detected backend physical delete; use soft delete with deletedAt/deleted_at.', pathCheck: isBackendTypeScriptPath },
532
537
  { detect: TS.hasDtoPropertyWithoutValidation, ruleId: 'heuristics.ts.backend.dto-property-without-validation.ast', code: 'HEURISTICS_BACKEND_DTO_PROPERTY_WITHOUT_VALIDATION_AST', message: 'AST heuristic detected DTO property without class-validator/class-transformer decorator.' },
@@ -733,8 +738,8 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
733
738
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftTaskDetachedUsage, ruleId: 'heuristics.ios.task-detached.ast', code: 'HEURISTICS_IOS_TASK_DETACHED_AST', message: 'AST heuristic detected Task.detached usage.' },
734
739
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftAsyncWithoutAwaitUsage, ruleId: 'heuristics.ios.concurrency.async-without-await.ast', code: 'HEURISTICS_IOS_CONCURRENCY_ASYNC_WITHOUT_AWAIT_AST', message: 'AST heuristic detected a private async function without await; remove async unless a protocol/override boundary requires it.' },
735
740
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftEmptyCatchUsage, ruleId: 'heuristics.ios.error.empty-catch.ast', code: 'HEURISTICS_IOS_ERROR_EMPTY_CATCH_AST', message: 'AST heuristic detected an empty Swift catch block; handle, log, or propagate the error.' },
736
- { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftOnAppearTaskUsage, ruleId: 'heuristics.ios.swiftui.onappear-task.ast', code: 'HEURISTICS_IOS_SWIFTUI_ONAPPEAR_TASK_AST', message: 'AST heuristic detected Task launched from SwiftUI onAppear; .task/.task(id:) provides lifecycle-aware cancellation.' },
737
- { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftOnChangeTaskUsage, ruleId: 'heuristics.ios.swiftui.onchange-task.ast', code: 'HEURISTICS_IOS_SWIFTUI_ONCHANGE_TASK_AST', message: 'AST heuristic detected Task launched from SwiftUI onChange; .task(id:) provides lifecycle-aware cancellation for value-dependent async work.' },
741
+ { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftOnAppearTaskUsage, locateLines: TextIOS.collectSwiftOnAppearTaskLines, primaryNode: (lines) => ({ kind: 'call', name: 'Task launched inside SwiftUI onAppear', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: .task { ... } on the view', lines }], why: 'A Task launched from onAppear is not owned by the SwiftUI view lifecycle in the same way as .task.', impact: 'Async work can outlive view disappearance or require manual cancellation, and the gate must point to the exact Task line instead of blocking the whole file.', expected_fix: 'Move the async work from .onAppear { Task { ... } } into .task { ... } so SwiftUI owns automatic cancellation. Keep onAppear only for synchronous side effects such as analytics.', ruleId: 'heuristics.ios.swiftui.onappear-task.ast', code: 'HEURISTICS_IOS_SWIFTUI_ONAPPEAR_TASK_AST', message: 'AST heuristic detected Task launched from SwiftUI onAppear; .task/.task(id:) provides lifecycle-aware cancellation.' },
742
+ { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftOnChangeTaskUsage, locateLines: TextIOS.collectSwiftOnChangeTaskLines, primaryNode: (lines) => ({ kind: 'call', name: 'Task launched inside SwiftUI onChange', lines }), relatedNodes: (lines) => [{ kind: 'call', name: 'replacement: .task(id:) for value-dependent async work', lines }], why: 'A Task launched from onChange makes value-dependent async work manual instead of tying cancellation to the changing value.', impact: 'Search, load or refresh work can race after state changes because cancellation is not expressed through SwiftUI .task(id:).', expected_fix: 'Move value-dependent async work from .onChange { Task { ... } } into .task(id: value) { ... }. Keep onChange only for synchronous derivations or analytics.', ruleId: 'heuristics.ios.swiftui.onchange-task.ast', code: 'HEURISTICS_IOS_SWIFTUI_ONCHANGE_TASK_AST', message: 'AST heuristic detected Task launched from SwiftUI onChange; .task(id:) provides lifecycle-aware cancellation for value-dependent async work.' },
738
743
  { platform: 'ios', pathCheck: isIOSPresentationPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftOnChangeReadonlyVarUsage, locateLines: TextIOS.collectSwiftOnChangeReadonlyVarLines, primaryNode: (lines) => ({ kind: 'property', name: 'var declared inside SwiftUI onChange closure', lines }), relatedNodes: (lines) => [{ kind: 'property', name: 'replacement: let for read-only derived value inside onChange', lines }], why: 'A local var inside onChange hides whether the closure is deriving a read-only value or mutating state as part of a reactive update.', impact: 'Reactive closures become harder to audit because unnecessary mutability can mask accidental state changes and value-dependent side effects.', expected_fix: 'Use let for read-only derived values inside onChange. Keep var only when the local value is intentionally mutated and extract complex mutation out of the view closure.', ruleId: 'heuristics.ios.swiftui.onchange-readonly-var.ast', code: 'HEURISTICS_IOS_SWIFTUI_ONCHANGE_READONLY_VAR_AST', message: 'AST heuristic detected local var inside SwiftUI onChange; prefer let for read-only derived values.' },
739
744
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftStrongDelegateReferenceUsage, ruleId: 'heuristics.ios.memory.strong-delegate.ast', code: 'HEURISTICS_IOS_MEMORY_STRONG_DELEGATE_AST', message: 'AST heuristic detected a strong delegate/dataSource reference; weak delegates remain the preferred baseline to avoid retain cycles.' },
740
745
  { platform: 'ios', pathCheck: isIOSSwiftPath, excludePaths: [isSwiftTestPath], detect: TextIOS.hasSwiftStrongSelfEscapingClosureUsage, ruleId: 'heuristics.ios.memory.strong-self-escaping-closure.ast', code: 'HEURISTICS_IOS_MEMORY_STRONG_SELF_ESCAPING_CLOSURE_AST', message: 'AST heuristic detected strong self capture in an escaping iOS closure; weak or unowned captures remain the preferred baseline when ownership is not explicit.' },
@@ -3,7 +3,7 @@ import test from 'node:test';
3
3
  import { typescriptRules } from './typescript';
4
4
 
5
5
  test('typescriptRules define reglas heurísticas locked para plataforma generic', () => {
6
- assert.equal(typescriptRules.length, 47);
6
+ assert.equal(typescriptRules.length, 52);
7
7
 
8
8
  const ids = typescriptRules.map((rule) => rule.id);
9
9
  assert.deepEqual(ids, [
@@ -49,10 +49,15 @@ test('typescriptRules define reglas heurísticas locked para plataforma generic'
49
49
  'heuristics.ts.solid.dip.concrete-instantiation.ast',
50
50
  'heuristics.ts.nestjs.constructor-di-without-decorator.ast',
51
51
  'heuristics.ts.backend.controller-route-without-guard.ast',
52
+ 'heuristics.ts.backend.controller-route-without-roles.ast',
52
53
  'heuristics.ts.backend.persistence-mutation-without-audit-event.ast',
53
54
  'heuristics.ts.backend.hard-delete-without-soft-delete.ast',
54
55
  'heuristics.ts.backend.dto-property-without-validation.ast',
55
56
  'heuristics.ts.backend.dto-nested-property-without-nested-validation.ast',
57
+ 'heuristics.ts.backend.missing-global-validation-pipe.ast',
58
+ 'heuristics.ts.backend.missing-helmet-security-headers.ast',
59
+ 'heuristics.ts.backend.controller-business-logic.ast',
60
+ 'heuristics.ts.backend.repository-business-logic.ast',
56
61
  'heuristics.ts.god-class-large-class.ast',
57
62
  ]);
58
63
 
@@ -169,6 +174,10 @@ test('typescriptRules define reglas heurísticas locked para plataforma generic'
169
174
  byId.get('heuristics.ts.backend.controller-route-without-guard.ast')?.then.code,
170
175
  'HEURISTICS_BACKEND_CONTROLLER_ROUTE_WITHOUT_GUARD_AST'
171
176
  );
177
+ assert.equal(
178
+ byId.get('heuristics.ts.backend.controller-route-without-roles.ast')?.then.code,
179
+ 'HEURISTICS_BACKEND_CONTROLLER_ROUTE_WITHOUT_ROLES_AST'
180
+ );
172
181
  assert.equal(
173
182
  byId.get('heuristics.ts.backend.persistence-mutation-without-audit-event.ast')?.then.code,
174
183
  'HEURISTICS_BACKEND_PERSISTENCE_MUTATION_WITHOUT_AUDIT_EVENT_AST'
@@ -185,6 +194,10 @@ test('typescriptRules define reglas heurísticas locked para plataforma generic'
185
194
  byId.get('heuristics.ts.backend.dto-nested-property-without-nested-validation.ast')?.then.code,
186
195
  'HEURISTICS_BACKEND_DTO_NESTED_PROPERTY_WITHOUT_NESTED_VALIDATION_AST'
187
196
  );
197
+ assert.equal(
198
+ byId.get('heuristics.ts.backend.missing-helmet-security-headers.ast')?.then.code,
199
+ 'HEURISTICS_BACKEND_MISSING_HELMET_SECURITY_HEADERS_AST'
200
+ );
188
201
  assert.equal(
189
202
  byId.get('heuristics.ts.god-class-large-class.ast')?.then.code,
190
203
  'HEURISTICS_GOD_CLASS_LARGE_CLASS_AST'
@@ -195,6 +208,7 @@ test('typescriptRules define reglas heurísticas locked para plataforma generic'
195
208
  if (
196
209
  rule.id === 'heuristics.ts.god-class-large-class.ast' ||
197
210
  rule.id === 'heuristics.ts.backend.controller-route-without-guard.ast' ||
211
+ rule.id === 'heuristics.ts.backend.controller-route-without-roles.ast' ||
198
212
  rule.id === 'heuristics.ts.backend.persistence-mutation-without-audit-event.ast' ||
199
213
  rule.id === 'heuristics.ts.backend.hard-delete-without-soft-delete.ast' ||
200
214
  rule.id === 'heuristics.ts.backend.dto-property-without-validation.ast' ||
@@ -211,6 +225,10 @@ test('typescriptRules define reglas heurísticas locked para plataforma generic'
211
225
  rule.id === 'heuristics.ts.backend.controller-entity-response.ast' ||
212
226
  rule.id === 'heuristics.ts.backend.anemic-domain-model.ast' ||
213
227
  rule.id === 'heuristics.ts.backend.permissive-cors.ast' ||
228
+ rule.id === 'heuristics.ts.backend.missing-global-validation-pipe.ast' ||
229
+ rule.id === 'heuristics.ts.backend.missing-helmet-security-headers.ast' ||
230
+ rule.id === 'heuristics.ts.backend.controller-business-logic.ast' ||
231
+ rule.id === 'heuristics.ts.backend.repository-business-logic.ast' ||
214
232
  rule.id === 'heuristics.ts.backend.string-literal-union-enum.ast' ||
215
233
  rule.id === 'heuristics.ts.sensitive-token-in-url.ast'
216
234
  ) {
@@ -764,6 +764,25 @@ export const typescriptRules: RuleSet = [
764
764
  code: 'HEURISTICS_BACKEND_CONTROLLER_ROUTE_WITHOUT_GUARD_AST',
765
765
  },
766
766
  },
767
+ {
768
+ id: 'heuristics.ts.backend.controller-route-without-roles.ast',
769
+ description:
770
+ 'Detects guarded NestJS controller route handlers without an explicit Roles decorator or roles metadata.',
771
+ severity: 'ERROR',
772
+ platform: 'generic',
773
+ locked: true,
774
+ when: {
775
+ kind: 'Heuristic',
776
+ where: {
777
+ ruleId: 'heuristics.ts.backend.controller-route-without-roles.ast',
778
+ },
779
+ },
780
+ then: {
781
+ kind: 'Finding',
782
+ message: 'AST heuristic detected guarded NestJS controller route without role metadata.',
783
+ code: 'HEURISTICS_BACKEND_CONTROLLER_ROUTE_WITHOUT_ROLES_AST',
784
+ },
785
+ },
767
786
  {
768
787
  id: 'heuristics.ts.backend.persistence-mutation-without-audit-event.ast',
769
788
  description:
@@ -840,6 +859,86 @@ export const typescriptRules: RuleSet = [
840
859
  code: 'HEURISTICS_BACKEND_DTO_NESTED_PROPERTY_WITHOUT_NESTED_VALIDATION_AST',
841
860
  },
842
861
  },
862
+ {
863
+ id: 'heuristics.ts.backend.missing-global-validation-pipe.ast',
864
+ description:
865
+ 'Detects NestJS bootstrap code without a strict global ValidationPipe configured with whitelist=true.',
866
+ severity: 'ERROR',
867
+ platform: 'generic',
868
+ locked: true,
869
+ when: {
870
+ kind: 'Heuristic',
871
+ where: {
872
+ ruleId: 'heuristics.ts.backend.missing-global-validation-pipe.ast',
873
+ },
874
+ },
875
+ then: {
876
+ kind: 'Finding',
877
+ message:
878
+ 'AST heuristic detected NestJS bootstrap without global ValidationPipe whitelist.',
879
+ code: 'HEURISTICS_BACKEND_MISSING_GLOBAL_VALIDATION_PIPE_AST',
880
+ },
881
+ },
882
+ {
883
+ id: 'heuristics.ts.backend.missing-helmet-security-headers.ast',
884
+ description:
885
+ 'Detects NestJS bootstrap code without Helmet middleware for security headers.',
886
+ severity: 'ERROR',
887
+ platform: 'generic',
888
+ locked: true,
889
+ when: {
890
+ kind: 'Heuristic',
891
+ where: {
892
+ ruleId: 'heuristics.ts.backend.missing-helmet-security-headers.ast',
893
+ },
894
+ },
895
+ then: {
896
+ kind: 'Finding',
897
+ message:
898
+ 'AST heuristic detected NestJS bootstrap without Helmet security headers middleware.',
899
+ code: 'HEURISTICS_BACKEND_MISSING_HELMET_SECURITY_HEADERS_AST',
900
+ },
901
+ },
902
+ {
903
+ id: 'heuristics.ts.backend.controller-business-logic.ast',
904
+ description:
905
+ 'Detects NestJS controller route handlers that contain business logic or direct persistence access instead of delegating to services/use cases.',
906
+ severity: 'ERROR',
907
+ platform: 'generic',
908
+ locked: true,
909
+ when: {
910
+ kind: 'Heuristic',
911
+ where: {
912
+ ruleId: 'heuristics.ts.backend.controller-business-logic.ast',
913
+ },
914
+ },
915
+ then: {
916
+ kind: 'Finding',
917
+ message:
918
+ 'AST heuristic detected business logic inside a NestJS controller route handler.',
919
+ code: 'HEURISTICS_BACKEND_CONTROLLER_BUSINESS_LOGIC_AST',
920
+ },
921
+ },
922
+ {
923
+ id: 'heuristics.ts.backend.repository-business-logic.ast',
924
+ description:
925
+ 'Detects backend repository methods that contain business decision branches instead of staying limited to CRUD, queries and data mapping.',
926
+ severity: 'ERROR',
927
+ platform: 'generic',
928
+ locked: true,
929
+ when: {
930
+ kind: 'Heuristic',
931
+ where: {
932
+ ruleId: 'heuristics.ts.backend.repository-business-logic.ast',
933
+ },
934
+ },
935
+ then: {
936
+ kind: 'Finding',
937
+ message:
938
+ 'AST heuristic detected business decision logic inside a backend repository.',
939
+ code: 'HEURISTICS_BACKEND_REPOSITORY_BUSINESS_LOGIC_AST',
940
+ },
941
+ },
843
942
  {
844
943
  id: 'heuristics.ts.god-class-large-class.ast',
845
944
  description: 'Detects God Class candidates when one class mixes multiple responsibility nodes.',
@@ -813,6 +813,18 @@ const registryByRuleId: Record<string, SkillsDetectorBinding> = {
813
813
  heuristicDetector('typescript.backend.dto-nested-property-without-nested-validation', [
814
814
  'heuristics.ts.backend.dto-nested-property-without-nested-validation.ast',
815
815
  ]),
816
+ 'skills.backend.guideline.backend.pipes-para-validacio-n-global-validationpipe-en-main-ts':
817
+ heuristicDetector('typescript.backend.missing-global-validation-pipe', [
818
+ 'heuristics.ts.backend.missing-global-validation-pipe.ast',
819
+ ]),
820
+ 'skills.backend.guideline.backend.validationpipe-global-en-main-ts-con-whitelist-true':
821
+ heuristicDetector('typescript.backend.missing-global-validation-pipe', [
822
+ 'heuristics.ts.backend.missing-global-validation-pipe.ast',
823
+ ]),
824
+ 'skills.backend.guideline.backend.helmet-security-headers':
825
+ heuristicDetector('typescript.backend.missing-helmet-security-headers', [
826
+ 'heuristics.ts.backend.missing-helmet-security-headers.ast',
827
+ ]),
816
828
  'skills.backend.guideline.backend.guards-en-todas-las-rutas-protegidas-useguards-jwtauthguard':
817
829
  heuristicDetector('typescript.backend.controller-route-without-guard', [
818
830
  'heuristics.ts.backend.controller-route-without-guard.ast',
@@ -821,6 +833,10 @@ const registryByRuleId: Record<string, SkillsDetectorBinding> = {
821
833
  heuristicDetector('typescript.backend.controller-route-without-guard', [
822
834
  'heuristics.ts.backend.controller-route-without-guard.ast',
823
835
  ]),
836
+ 'skills.backend.guideline.backend.role-based-access-control-roles-admin-user':
837
+ heuristicDetector('typescript.backend.controller-route-without-roles', [
838
+ 'heuristics.ts.backend.controller-route-without-roles.ast',
839
+ ]),
824
840
  'skills.backend.guideline.backend.callback-hell-usar-async-await': heuristicDetector(
825
841
  'typescript.callback-hell',
826
842
  ['heuristics.ts.callback-hell.ast']
@@ -898,6 +914,22 @@ const registryByRuleId: Record<string, SkillsDetectorBinding> = {
898
914
  heuristicDetector('typescript.backend.controller-entity-response', [
899
915
  'heuristics.ts.backend.controller-entity-response.ast',
900
916
  ]),
917
+ 'skills.backend.guideline.backend.controllers-delgados-solo-routing-y-validacio-n-lo-gica-en-servicios':
918
+ heuristicDetector('typescript.backend.controller-business-logic', [
919
+ 'heuristics.ts.backend.controller-business-logic.ast',
920
+ ]),
921
+ 'skills.backend.guideline.backend.lo-gica-en-controllers-mover-a-servicios-use-cases':
922
+ heuristicDetector('typescript.backend.controller-business-logic', [
923
+ 'heuristics.ts.backend.controller-business-logic.ast',
924
+ ]),
925
+ 'skills.backend.guideline.backend.no-lo-gica-de-negocio-en-repositorios-solo-crud-y-queries':
926
+ heuristicDetector('typescript.backend.repository-business-logic', [
927
+ 'heuristics.ts.backend.repository-business-logic.ast',
928
+ ]),
929
+ 'skills.backend.guideline.backend.repositories-para-datos-abstraer-acceso-a-bd-con-interfaces':
930
+ heuristicDetector('typescript.backend.repository-business-logic', [
931
+ 'heuristics.ts.backend.repository-business-logic.ast',
932
+ ]),
901
933
  'skills.backend.guideline.backend.anemic-domain-models-entidades-solo-con-getters-setters':
902
934
  heuristicDetector('typescript.backend.anemic-domain-model', [
903
935
  'heuristics.ts.backend.anemic-domain-model.ast',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pumuki",
3
- "version": "6.3.293",
3
+ "version": "6.3.294",
4
4
  "description": "Enterprise-grade AST Intelligence System with multi-platform support (iOS, Android, Backend, Frontend) and Feature-First + DDD + Clean Architecture enforcement. Includes dynamic violations API for intelligent querying.",
5
5
  "main": "index.js",
6
6
  "bin": {