pumuki 6.3.121 → 6.3.123

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/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [6.3.123] - 2026-04-28
10
+
11
+ ### Fixed
12
+
13
+ - **Detectores SOLID sin contadores arbitrarios:** `SRP`, `DIP`, `OCP` e `ISP` en iOS/Android/TypeScript dejan de depender de mínimos tipo `relatedNodes.length < N`, `typedCaseCount >= N` o cortes `slice(0, N)` y pasan a usar categorías semánticas explícitas.
14
+ - **ISP iOS/Android por familias de contrato:** los protocolos/interfaces anchos se detectan por mezcla real de `query`/`command` y uso de una sola familia por consumidor, no por número de miembros.
15
+ - **Cierre de `PUMUKI-INC-116`:** se añade regresión negativa para clases/protocolos grandes pero cohesionados y auditoría textual para impedir que los detectores estructurales vuelvan a degradarse a `N señales => bloqueo`.
16
+
17
+ ## [6.3.122] - 2026-04-28
18
+
19
+ ### Fixed
20
+
21
+ - **Skills estructurales sin umbrales hardcodeados:** `skills.backend.no-god-classes` / `skills.frontend.no-god-classes` dejan de depender de `GOD_CLASS_MAX_LINES` y pasan a detectar mezcla semántica de responsabilidades por nodos AST.
22
+ - **Auditoría transversal iOS/Android/backend/frontend:** las skills estructurales `God/Massive/SRP/Clean Architecture` ya no expresan límites implícitos de líneas; una regresión dedicada falla si vuelven a aparecer umbrales `> N líneas` en las skills de las cuatro plataformas.
23
+ - **Cierre de `PUMUKI-INC-115`:** Pumuki mantiene hotspots por `max_lines` solo cuando el consumer los declara explícitamente, pero las skills hard vuelven a depender de nodos AST inteligentes o reglas declarativas.
24
+
9
25
  ## [6.3.121] - 2026-04-28
10
26
 
11
27
  ### Fixed
package/VERSION CHANGED
@@ -1 +1 @@
1
- v6.3.121
1
+ v6.3.123
@@ -254,6 +254,46 @@ class PumukiIspAndroidCanaryUseCase(
254
254
  assert.match(match.expected_fix, /interfaces pequeñas|puerto mínimo/i);
255
255
  });
256
256
 
257
+ test('detectores SOLID Android no convierten tamaño o cardinalidad en violacion', () => {
258
+ const presentationWithoutMixedResponsibilities = `
259
+ class CatalogViewModel {
260
+ fun restoreSessionSnapshot() {}
261
+ fun refreshSessionToken() {}
262
+ fun resumeSessionIfNeeded() {}
263
+ fun signOut() {}
264
+ }
265
+ `;
266
+ const dipWithPortOnly = `
267
+ interface CatalogFetching {
268
+ suspend fun fetchCatalog(): List<String>
269
+ }
270
+
271
+ class CatalogUseCase(
272
+ private val catalog: CatalogFetching,
273
+ )
274
+ `;
275
+ const cohesiveInterface = `
276
+ interface CatalogReading {
277
+ suspend fun fetchCatalog(): List<String>
278
+ suspend fun loadCachedCatalog(): List<String>
279
+ suspend fun readCatalogVersion(): String
280
+ suspend fun getFeaturedCatalog(): List<String>
281
+ }
282
+
283
+ class CatalogUseCase(
284
+ private val catalog: CatalogReading,
285
+ ) {
286
+ suspend fun execute() {
287
+ catalog.fetchCatalog()
288
+ }
289
+ }
290
+ `;
291
+
292
+ assert.equal(findKotlinPresentationSrpMatch(presentationWithoutMixedResponsibilities), undefined);
293
+ assert.equal(findKotlinConcreteDependencyDipMatch(dipWithPortOnly), undefined);
294
+ assert.equal(findKotlinInterfaceSegregationMatch(cohesiveInterface), undefined);
295
+ });
296
+
257
297
  test('findKotlinLiskovSubstitutionMatch devuelve payload semantico para LSP-Android en application', () => {
258
298
  const source = `
259
299
  interface PumukiLspAndroidCanaryDiscountPolicy {
@@ -70,6 +70,40 @@ type KotlinTypeDeclaration = {
70
70
  bodyEndLine: number;
71
71
  };
72
72
 
73
+ type KotlinResponsibilityMatch = {
74
+ key: string;
75
+ node: KotlinSemanticNodeMatch;
76
+ };
77
+
78
+ const kotlinQueryMemberNamePattern = /^(get|find|list|fetch|read|load|restore|refresh|is|has|can)/i;
79
+ const kotlinCommandMemberNamePattern =
80
+ /^(create|update|delete|remove|save|insert|upsert|set|write|persist|clear|reset|sync|store)/i;
81
+
82
+ const registerKotlinResponsibility = (
83
+ nodes: KotlinResponsibilityMatch[],
84
+ key: string,
85
+ kind: KotlinSemanticNodeMatch['kind'],
86
+ name: string,
87
+ lines: readonly number[]
88
+ ): void => {
89
+ if (lines.length === 0) {
90
+ return;
91
+ }
92
+ nodes.push({ key, node: { kind, name, lines } });
93
+ };
94
+
95
+ const hasKotlinResponsibilityKeys = (
96
+ nodes: readonly KotlinResponsibilityMatch[],
97
+ keys: readonly string[]
98
+ ): boolean => {
99
+ const observedKeys = new Set(nodes.map((node) => node.key));
100
+ return keys.every((key) => observedKeys.has(key));
101
+ };
102
+
103
+ const isKotlinQueryMemberName = (name: string): boolean => kotlinQueryMemberNamePattern.test(name);
104
+ const isKotlinCommandMemberName = (name: string): boolean =>
105
+ kotlinCommandMemberNamePattern.test(name);
106
+
73
107
  const stripKotlinLineForSemanticScan = (line: string): string => {
74
108
  return line
75
109
  .replace(/\/\/.*$/, '')
@@ -274,44 +308,46 @@ export const findKotlinPresentationSrpMatch = (
274
308
  return undefined;
275
309
  }
276
310
 
277
- const relatedNodes: KotlinSemanticNodeMatch[] = [];
311
+ const responsibilities: KotlinResponsibilityMatch[] = [];
278
312
  const registerNode = (
313
+ key: string,
279
314
  kind: KotlinSemanticNodeMatch['kind'],
280
315
  name: string,
281
316
  regex: RegExp
282
317
  ): void => {
283
- const lines = collectKotlinRegexLines(source, regex);
284
- if (lines.length === 0) {
285
- return;
286
- }
287
- relatedNodes.push({ kind, name, lines });
318
+ registerKotlinResponsibility(responsibilities, key, kind, name, collectKotlinRegexLines(source, regex));
288
319
  };
289
320
 
290
321
  registerNode(
322
+ 'session',
291
323
  'member',
292
324
  'session/auth flow',
293
325
  /\b(?:restore|bootstrap|refresh|resume|signIn|signOut|authenticate|session)\w*\s*\(/
294
326
  );
295
327
  registerNode(
328
+ 'networking',
296
329
  'call',
297
330
  'remote networking',
298
331
  /\b(?:OkHttpClient\s*\(|Retrofit\.Builder\s*\(|HttpURLConnection\b)/
299
332
  );
300
333
  registerNode(
334
+ 'persistence',
301
335
  'call',
302
336
  'local persistence',
303
337
  /\b(?:SharedPreferences\b.*\)|PreferenceDataStoreFactory\.create|preferencesDataStore|DataStore<|RoomDatabase\b)/
304
338
  );
305
339
  registerNode(
340
+ 'navigation',
306
341
  'member',
307
342
  'navigation flow',
308
343
  /\b(?:findNavController\s*\(|\.\s*navigate\s*\()/
309
344
  );
310
345
 
311
- if (relatedNodes.length < 4) {
346
+ if (!hasKotlinResponsibilityKeys(responsibilities, ['session', 'networking', 'persistence', 'navigation'])) {
312
347
  return undefined;
313
348
  }
314
349
 
350
+ const relatedNodes = responsibilities.map((entry) => entry.node);
315
351
  const allLines = sortedUniqueLines([
316
352
  ...classLines,
317
353
  ...relatedNodes.flatMap((node) => [...node.lines]),
@@ -396,7 +432,7 @@ export const findKotlinConcreteDependencyDipMatch = (
396
432
  );
397
433
  registerNode('call', 'Room.databaseBuilder', /\bRoom\.databaseBuilder\s*\(/);
398
434
 
399
- if (relatedNodes.length < 2) {
435
+ if (relatedNodes.length === 0) {
400
436
  return undefined;
401
437
  }
402
438
 
@@ -473,7 +509,8 @@ export const findKotlinOpenClosedWhenMatch = (
473
509
  }
474
510
  }
475
511
 
476
- if (branchNodes.length < 2) {
512
+ const [firstBranchNode, secondBranchNode] = branchNodes;
513
+ if (!firstBranchNode || !secondBranchNode) {
477
514
  continue;
478
515
  }
479
516
 
@@ -483,7 +520,7 @@ export const findKotlinOpenClosedWhenMatch = (
483
520
  name: `discriminator switch: ${discriminatorName}`,
484
521
  lines: [index + 1],
485
522
  },
486
- ...branchNodes.slice(0, 3),
523
+ ...branchNodes,
487
524
  ];
488
525
 
489
526
  const allLines = sortedUniqueLines([
@@ -492,7 +529,6 @@ export const findKotlinOpenClosedWhenMatch = (
492
529
  ...branchNodes.flatMap((node) => [...node.lines]),
493
530
  ]);
494
531
  const branchSummary = branchNodes
495
- .slice(0, 3)
496
532
  .map((node) => node.name.replace(/^branch /, ''))
497
533
  .join(', ');
498
534
 
@@ -541,7 +577,13 @@ export const findKotlinInterfaceSegregationMatch = (
541
577
  const sourceLines = source.split(/\r?\n/);
542
578
 
543
579
  for (const interfaceDeclaration of interfaceDeclarations) {
544
- if (interfaceDeclaration.members.length < 4) {
580
+ const queryMembers = interfaceDeclaration.members.filter((member) =>
581
+ isKotlinQueryMemberName(member.name)
582
+ );
583
+ const commandMembers = interfaceDeclaration.members.filter((member) =>
584
+ isKotlinCommandMemberName(member.name)
585
+ );
586
+ if (queryMembers.length === 0 || commandMembers.length === 0) {
545
587
  continue;
546
588
  }
547
589
 
@@ -579,14 +621,21 @@ export const findKotlinInterfaceSegregationMatch = (
579
621
  }
580
622
  });
581
623
 
582
- if (usedMembers.size === 0 || usedMembers.size > 2) {
624
+ const usedMemberNames = [...usedMembers.keys()];
625
+ if (usedMemberNames.length === 0) {
583
626
  continue;
584
627
  }
585
628
 
586
- const unusedMembers = interfaceDeclaration.members.filter(
587
- (member) => !usedMembers.has(member.name)
588
- );
589
- if (unusedMembers.length < 2) {
629
+ const usesQueryContract = usedMemberNames.some(isKotlinQueryMemberName);
630
+ const usesCommandContract = usedMemberNames.some(isKotlinCommandMemberName);
631
+ if (usesQueryContract === usesCommandContract) {
632
+ continue;
633
+ }
634
+
635
+ const oppositeFamilyMembers = usesQueryContract ? commandMembers : queryMembers;
636
+ const unusedMembers = oppositeFamilyMembers.filter((member) => !usedMembers.has(member.name));
637
+ const firstUnusedMember = unusedMembers[0];
638
+ if (!firstUnusedMember) {
590
639
  continue;
591
640
  }
592
641
 
@@ -603,7 +652,7 @@ export const findKotlinInterfaceSegregationMatch = (
603
652
  lines: [interfaceDeclaration.line],
604
653
  },
605
654
  ...usedDescriptors,
606
- ...unusedMembers.slice(0, 2).map((member) => ({
655
+ ...unusedMembers.map((member) => ({
607
656
  kind: 'member' as const,
608
657
  name: `unused contract member: ${member.name}`,
609
658
  lines: [member.line],
@@ -611,7 +660,7 @@ export const findKotlinInterfaceSegregationMatch = (
611
660
  ];
612
661
 
613
662
  const usedMemberSummary = usedDescriptors.map((member) => member.name.replace('used member: ', ''));
614
- const unusedSummary = unusedMembers.slice(0, 2).map((member) => member.name);
663
+ const unusedSummary = unusedMembers.map((member) => member.name);
615
664
  const allLines = sortedUniqueLines([
616
665
  ...typeLines,
617
666
  interfaceDeclaration.line,
@@ -807,6 +807,54 @@ final class PumukiIspIosCanaryUseCase {
807
807
  assert.match(match.expected_fix, /protocolos pequeños|puerto mínimo/i);
808
808
  });
809
809
 
810
+ test('detectores SOLID iOS no convierten tamaño o cardinalidad en violacion', () => {
811
+ const presentationWithoutMixedResponsibilities = `
812
+ final class CatalogViewModel {
813
+ func restoreSessionSnapshot() async {}
814
+ func refreshSessionToken() async {}
815
+ func resumeSessionIfNeeded() async {}
816
+ func signOut() async {}
817
+ }
818
+ `;
819
+ const dipWithPortOnly = `
820
+ protocol CatalogFetching {
821
+ func fetchCatalog() async throws -> [String]
822
+ }
823
+
824
+ final class CatalogUseCase {
825
+ private let catalog: CatalogFetching
826
+
827
+ init(catalog: CatalogFetching) {
828
+ self.catalog = catalog
829
+ }
830
+ }
831
+ `;
832
+ const cohesiveProtocol = `
833
+ protocol CatalogReading {
834
+ func fetchCatalog() async throws -> [String]
835
+ func loadCachedCatalog() async throws -> [String]
836
+ func readCatalogVersion() async throws -> String
837
+ func getFeaturedCatalog() async throws -> [String]
838
+ }
839
+
840
+ final class CatalogUseCase {
841
+ private let catalog: CatalogReading
842
+
843
+ init(catalog: CatalogReading) {
844
+ self.catalog = catalog
845
+ }
846
+
847
+ func execute() async throws {
848
+ _ = try await catalog.fetchCatalog()
849
+ }
850
+ }
851
+ `;
852
+
853
+ assert.equal(findSwiftPresentationSrpMatch(presentationWithoutMixedResponsibilities), undefined);
854
+ assert.equal(findSwiftConcreteDependencyDipMatch(dipWithPortOnly), undefined);
855
+ assert.equal(findSwiftInterfaceSegregationMatch(cohesiveProtocol), undefined);
856
+ });
857
+
810
858
  test('findSwiftLiskovSubstitutionMatch devuelve payload semantico para LSP-iOS en application', () => {
811
859
  const source = `
812
860
  protocol PumukiLspIosCanaryDiscountApplying {
@@ -144,6 +144,39 @@ type SwiftTypeDeclaration = {
144
144
  bodyEndLine: number;
145
145
  };
146
146
 
147
+ type SwiftResponsibilityMatch = {
148
+ key: string;
149
+ node: SwiftSemanticNodeMatch;
150
+ };
151
+
152
+ const swiftQueryMemberNamePattern = /^(get|find|list|fetch|read|load|restore|refresh|is|has|can)/i;
153
+ const swiftCommandMemberNamePattern =
154
+ /^(create|update|delete|remove|save|insert|upsert|set|write|persist|clear|reset|sync|store)/i;
155
+
156
+ const registerSwiftResponsibility = (
157
+ nodes: SwiftResponsibilityMatch[],
158
+ key: string,
159
+ kind: SwiftSemanticNodeMatch['kind'],
160
+ name: string,
161
+ lines: readonly number[]
162
+ ): void => {
163
+ if (lines.length === 0) {
164
+ return;
165
+ }
166
+ nodes.push({ key, node: { kind, name, lines } });
167
+ };
168
+
169
+ const hasSwiftResponsibilityKeys = (
170
+ nodes: readonly SwiftResponsibilityMatch[],
171
+ keys: readonly string[]
172
+ ): boolean => {
173
+ const observedKeys = new Set(nodes.map((node) => node.key));
174
+ return keys.every((key) => observedKeys.has(key));
175
+ };
176
+
177
+ const isSwiftQueryMemberName = (name: string): boolean => swiftQueryMemberNamePattern.test(name);
178
+ const isSwiftCommandMemberName = (name: string): boolean => swiftCommandMemberNamePattern.test(name);
179
+
147
180
  const parseSwiftProtocolDeclarations = (source: string): readonly SwiftProtocolDeclaration[] => {
148
181
  const lines = source.split(/\r?\n/);
149
182
  const declarations: SwiftProtocolDeclaration[] = [];
@@ -874,31 +907,41 @@ export const findSwiftIOSCanary001Match = (source: string): SwiftIOSCanary001Mat
874
907
  return undefined;
875
908
  }
876
909
 
877
- const registerNode = (
878
- nodes: SwiftSemanticNodeMatch[],
879
- kind: SwiftSemanticNodeMatch['kind'],
880
- name: string,
881
- regex: RegExp
882
- ): void => {
883
- const lines = collectSwiftRegexLines(source, regex);
884
- if (lines.length === 0) {
885
- return;
886
- }
887
- nodes.push({ kind, name, lines });
888
- };
889
-
890
- const explicitInfraNodes: SwiftSemanticNodeMatch[] = [];
891
- registerNode(explicitInfraNodes, 'property', 'shared singleton', /\bstatic\s+let\s+shared\b/);
892
- registerNode(explicitInfraNodes, 'call', 'URLSession.shared', /\bURLSession\.shared\b/);
893
- registerNode(explicitInfraNodes, 'call', 'FileManager.default', /\bFileManager\.default\b/);
894
- registerNode(
895
- explicitInfraNodes,
910
+ const explicitInfraResponsibilities: SwiftResponsibilityMatch[] = [];
911
+ registerSwiftResponsibility(
912
+ explicitInfraResponsibilities,
913
+ 'shared-state',
914
+ 'property',
915
+ 'shared singleton',
916
+ collectSwiftRegexLines(source, /\bstatic\s+let\s+shared\b/)
917
+ );
918
+ registerSwiftResponsibility(
919
+ explicitInfraResponsibilities,
920
+ 'networking',
921
+ 'call',
922
+ 'URLSession.shared',
923
+ collectSwiftRegexLines(source, /\bURLSession\.shared\b/)
924
+ );
925
+ registerSwiftResponsibility(
926
+ explicitInfraResponsibilities,
927
+ 'persistence',
928
+ 'call',
929
+ 'FileManager.default',
930
+ collectSwiftRegexLines(source, /\bFileManager\.default\b/)
931
+ );
932
+ registerSwiftResponsibility(
933
+ explicitInfraResponsibilities,
934
+ 'navigation',
896
935
  'member',
897
936
  'navigation flow',
898
- /\b(?:router|route|coordinator|navigationPath|navigationDestination)\b|\b(?:navigate|dismiss|present)\s*\(/i
937
+ collectSwiftRegexLines(
938
+ source,
939
+ /\b(?:router|route|coordinator|navigationPath|navigationDestination)\b|\b(?:navigate|dismiss|present)\s*\(/i
940
+ )
899
941
  );
900
942
 
901
- if (explicitInfraNodes.length >= 3) {
943
+ if (hasSwiftResponsibilityKeys(explicitInfraResponsibilities, ['networking', 'persistence', 'navigation'])) {
944
+ const explicitInfraNodes = explicitInfraResponsibilities.map((entry) => entry.node);
902
945
  const relatedNodeNames = explicitInfraNodes.map((node) => node.name).join(', ');
903
946
  const allLines = sortedUniqueLines([
904
947
  ...classLines,
@@ -921,33 +964,55 @@ export const findSwiftIOSCanary001Match = (source: string): SwiftIOSCanary001Mat
921
964
  };
922
965
  }
923
966
 
924
- const appShellNodes: SwiftSemanticNodeMatch[] = [];
925
- registerNode(
926
- appShellNodes,
967
+ const appShellResponsibilities: SwiftResponsibilityMatch[] = [];
968
+ registerSwiftResponsibility(
969
+ appShellResponsibilities,
970
+ 'session',
927
971
  'member',
928
972
  'session bootstrap/restoration',
929
- /\b(?:restorePersistedSessionIfNeeded|continueAsGuest|bootstrapAuthenticatedSession)\s*\(/
973
+ collectSwiftRegexLines(source, /\b(?:restorePersistedSessionIfNeeded|continueAsGuest|bootstrapAuthenticatedSession)\s*\(/)
930
974
  );
931
- registerNode(appShellNodes, 'member', 'store selection orchestration', /\bselectStore\s*\(/);
932
- registerNode(appShellNodes, 'member', 'shopping list synchronization', /\bsyncShoppingList\s*\(/);
933
- registerNode(
934
- appShellNodes,
975
+ registerSwiftResponsibility(
976
+ appShellResponsibilities,
977
+ 'store',
978
+ 'member',
979
+ 'store selection orchestration',
980
+ collectSwiftRegexLines(source, /\bselectStore\s*\(/)
981
+ );
982
+ registerSwiftResponsibility(
983
+ appShellResponsibilities,
984
+ 'shopping-list',
985
+ 'member',
986
+ 'shopping list synchronization',
987
+ collectSwiftRegexLines(source, /\bsyncShoppingList\s*\(/)
988
+ );
989
+ registerSwiftResponsibility(
990
+ appShellResponsibilities,
991
+ 'route',
935
992
  'member',
936
993
  'route progression',
937
- /\b(?:markNextStopCompleted|scanCheckpoint|rebuildRouteStatus)\s*\(/
994
+ collectSwiftRegexLines(source, /\b(?:markNextStopCompleted|scanCheckpoint|rebuildRouteStatus)\s*\(/)
938
995
  );
939
- registerNode(
940
- appShellNodes,
996
+ registerSwiftResponsibility(
997
+ appShellResponsibilities,
998
+ 'offline-queue',
941
999
  'member',
942
1000
  'offline queue coordination',
943
- /\b(?:flushOfflineQueue|enqueueOfflineCheckpoint)\s*\(/
1001
+ collectSwiftRegexLines(source, /\b(?:flushOfflineQueue|enqueueOfflineCheckpoint)\s*\(/)
1002
+ );
1003
+ registerSwiftResponsibility(
1004
+ appShellResponsibilities,
1005
+ 'navigation',
1006
+ 'member',
1007
+ 'deep link/navigation flow',
1008
+ collectSwiftRegexLines(source, /\bopenDeepLink\s*\(/)
944
1009
  );
945
- registerNode(appShellNodes, 'member', 'deep link/navigation flow', /\bopenDeepLink\s*\(/);
946
1010
 
947
- if (appShellNodes.length < 4) {
1011
+ if (!hasSwiftResponsibilityKeys(appShellResponsibilities, ['session', 'store', 'route', 'navigation'])) {
948
1012
  return undefined;
949
1013
  }
950
1014
 
1015
+ const appShellNodes = appShellResponsibilities.map((entry) => entry.node);
951
1016
  const relatedNodeNames = appShellNodes.map((node) => node.name).join(', ');
952
1017
  const allLines = sortedUniqueLines([
953
1018
  ...classLines,
@@ -985,44 +1050,46 @@ export const findSwiftPresentationSrpMatch = (
985
1050
  return undefined;
986
1051
  }
987
1052
 
988
- const relatedNodes: SwiftSemanticNodeMatch[] = [];
1053
+ const responsibilities: SwiftResponsibilityMatch[] = [];
989
1054
  const registerNode = (
1055
+ key: string,
990
1056
  kind: SwiftSemanticNodeMatch['kind'],
991
1057
  name: string,
992
1058
  regex: RegExp
993
1059
  ): void => {
994
- const lines = collectSwiftRegexLines(source, regex);
995
- if (lines.length === 0) {
996
- return;
997
- }
998
- relatedNodes.push({ kind, name, lines });
1060
+ registerSwiftResponsibility(responsibilities, key, kind, name, collectSwiftRegexLines(source, regex));
999
1061
  };
1000
1062
 
1001
1063
  registerNode(
1064
+ 'session',
1002
1065
  'member',
1003
1066
  'session/auth flow',
1004
1067
  /\b(?:restore|bootstrap|refresh|resume|signIn|signOut|authenticate|session)\w*\s*\(/
1005
1068
  );
1006
1069
  registerNode(
1070
+ 'networking',
1007
1071
  'call',
1008
1072
  'remote networking',
1009
1073
  /\b(?:URLSession\.shared|URLRequest\b|dataTask\s*\(|uploadTask\s*\(|downloadTask\s*\()/
1010
1074
  );
1011
1075
  registerNode(
1076
+ 'persistence',
1012
1077
  'call',
1013
1078
  'local persistence',
1014
1079
  /\b(?:UserDefaults\.standard|FileManager\.default|Keychain|NSPersistentContainer|CoreData)\b/
1015
1080
  );
1016
1081
  registerNode(
1082
+ 'navigation',
1017
1083
  'member',
1018
1084
  'navigation flow',
1019
1085
  /\b(?:navigationPath|navigationDestination)\b|(?:\.\s*(?:navigate|present|dismiss|push|open)\s*\()/
1020
1086
  );
1021
1087
 
1022
- if (relatedNodes.length < 4) {
1088
+ if (!hasSwiftResponsibilityKeys(responsibilities, ['session', 'networking', 'persistence', 'navigation'])) {
1023
1089
  return undefined;
1024
1090
  }
1025
1091
 
1092
+ const relatedNodes = responsibilities.map((entry) => entry.node);
1026
1093
  const allLines = sortedUniqueLines([
1027
1094
  ...classLines,
1028
1095
  ...relatedNodes.flatMap((node) => [...node.lines]),
@@ -1092,7 +1159,7 @@ export const findSwiftConcreteDependencyDipMatch = (
1092
1159
  );
1093
1160
  registerNode('call', 'FileManager.default', /\bFileManager\.default\b/);
1094
1161
 
1095
- if (relatedNodes.length < 2) {
1162
+ if (relatedNodes.length === 0) {
1096
1163
  return undefined;
1097
1164
  }
1098
1165
 
@@ -1178,7 +1245,8 @@ export const findSwiftOpenClosedSwitchMatch = (
1178
1245
  }
1179
1246
  }
1180
1247
 
1181
- if (caseNodes.length < 2) {
1248
+ const [firstCaseNode, secondCaseNode] = caseNodes;
1249
+ if (!firstCaseNode || !secondCaseNode) {
1182
1250
  continue;
1183
1251
  }
1184
1252
 
@@ -1188,7 +1256,7 @@ export const findSwiftOpenClosedSwitchMatch = (
1188
1256
  name: `discriminator switch: ${discriminatorName}`,
1189
1257
  lines: [index + 1],
1190
1258
  },
1191
- ...caseNodes.slice(0, 3),
1259
+ ...caseNodes,
1192
1260
  ];
1193
1261
 
1194
1262
  const allLines = sortedUniqueLines([
@@ -1197,7 +1265,6 @@ export const findSwiftOpenClosedSwitchMatch = (
1197
1265
  ...caseNodes.flatMap((node) => [...node.lines]),
1198
1266
  ]);
1199
1267
  const caseSummary = caseNodes
1200
- .slice(0, 3)
1201
1268
  .map((node) => node.name.replace(/^case\s+/, ''))
1202
1269
  .join(', ');
1203
1270
 
@@ -1246,7 +1313,13 @@ export const findSwiftInterfaceSegregationMatch = (
1246
1313
  const sourceLines = source.split(/\r?\n/);
1247
1314
 
1248
1315
  for (const protocolDeclaration of protocolDeclarations) {
1249
- if (protocolDeclaration.members.length < 4) {
1316
+ const queryMembers = protocolDeclaration.members.filter((member) =>
1317
+ isSwiftQueryMemberName(member.name)
1318
+ );
1319
+ const commandMembers = protocolDeclaration.members.filter((member) =>
1320
+ isSwiftCommandMemberName(member.name)
1321
+ );
1322
+ if (queryMembers.length === 0 || commandMembers.length === 0) {
1250
1323
  continue;
1251
1324
  }
1252
1325
 
@@ -1283,14 +1356,21 @@ export const findSwiftInterfaceSegregationMatch = (
1283
1356
  }
1284
1357
  });
1285
1358
 
1286
- if (usedMembers.size === 0 || usedMembers.size > 2) {
1359
+ const usedMemberNames = [...usedMembers.keys()];
1360
+ if (usedMemberNames.length === 0) {
1287
1361
  continue;
1288
1362
  }
1289
1363
 
1290
- const unusedMembers = protocolDeclaration.members.filter(
1291
- (member) => !usedMembers.has(member.name)
1292
- );
1293
- if (unusedMembers.length < 2) {
1364
+ const usesQueryContract = usedMemberNames.some(isSwiftQueryMemberName);
1365
+ const usesCommandContract = usedMemberNames.some(isSwiftCommandMemberName);
1366
+ if (usesQueryContract === usesCommandContract) {
1367
+ continue;
1368
+ }
1369
+
1370
+ const oppositeFamilyMembers = usesQueryContract ? commandMembers : queryMembers;
1371
+ const unusedMembers = oppositeFamilyMembers.filter((member) => !usedMembers.has(member.name));
1372
+ const firstUnusedMember = unusedMembers[0];
1373
+ if (!firstUnusedMember) {
1294
1374
  continue;
1295
1375
  }
1296
1376
 
@@ -1311,7 +1391,7 @@ export const findSwiftInterfaceSegregationMatch = (
1311
1391
  name: `used member: ${usedMemberName}`,
1312
1392
  lines: sortedUniqueLines(usedMemberLines),
1313
1393
  },
1314
- ...unusedMembers.slice(0, 2).map((member) => ({
1394
+ ...unusedMembers.map((member) => ({
1315
1395
  kind: 'member' as const,
1316
1396
  name: `unused contract member: ${member.name}`,
1317
1397
  lines: [member.line],
@@ -1322,7 +1402,7 @@ export const findSwiftInterfaceSegregationMatch = (
1322
1402
  ...typeLines,
1323
1403
  protocolDeclaration.line,
1324
1404
  ...usedMemberLines,
1325
- ...unusedMembers.slice(0, 2).map((member) => member.line),
1405
+ ...unusedMembers.map((member) => member.line),
1326
1406
  ]);
1327
1407
 
1328
1408
  return {
@@ -856,32 +856,86 @@ test('findConcreteDependencyInstantiationMatch devuelve payload semantico para D
856
856
  assert.match(match.expected_fix, /adapter|puerto|abstracci/i);
857
857
  });
858
858
 
859
- test('hasLargeClassDeclaration detecta clases con 300 lineas o mas', () => {
860
- const oversizedClassAst = {
859
+ test('hasLargeClassDeclaration detecta god class por mezcla semantica de responsabilidades', () => {
860
+ const godClassAst = {
861
861
  type: 'ClassDeclaration',
862
862
  loc: {
863
- start: { line: 10 },
864
- end: { line: 320 },
863
+ start: { line: 1 },
864
+ end: { line: 80 },
865
+ },
866
+ body: {
867
+ type: 'ClassBody',
868
+ body: [
869
+ {
870
+ type: 'ClassProperty',
871
+ key: { type: 'Identifier', name: 'client' },
872
+ value: {
873
+ type: 'NewExpression',
874
+ callee: { type: 'Identifier', name: 'PrismaClient' },
875
+ arguments: [],
876
+ },
877
+ },
878
+ {
879
+ type: 'ClassMethod',
880
+ key: { type: 'Identifier', name: 'getOrder' },
881
+ loc: { start: { line: 20 }, end: { line: 24 } },
882
+ body: { type: 'BlockStatement', body: [] },
883
+ },
884
+ {
885
+ type: 'ClassMethod',
886
+ key: { type: 'Identifier', name: 'saveOrder' },
887
+ loc: { start: { line: 30 }, end: { line: 40 } },
888
+ body: { type: 'BlockStatement', body: [] },
889
+ },
890
+ ],
865
891
  },
866
892
  };
867
- const thresholdClassAst = {
893
+ const oversizedButSingleResponsibilityAst = {
868
894
  type: 'ClassDeclaration',
869
895
  loc: {
870
- start: { line: 10 },
871
- end: { line: 309 },
896
+ start: { line: 1 },
897
+ end: { line: 1_000 },
898
+ },
899
+ body: {
900
+ type: 'ClassBody',
901
+ body: [
902
+ {
903
+ type: 'ClassMethod',
904
+ key: { type: 'Identifier', name: 'getOrder' },
905
+ loc: { start: { line: 20 }, end: { line: 24 } },
906
+ body: { type: 'BlockStatement', body: [] },
907
+ },
908
+ ],
872
909
  },
873
910
  };
874
- const compactClassAst = {
911
+ const srpOnlyAst = {
875
912
  type: 'ClassDeclaration',
876
913
  loc: {
877
- start: { line: 10 },
914
+ start: { line: 1 },
878
915
  end: { line: 80 },
879
916
  },
917
+ body: {
918
+ type: 'ClassBody',
919
+ body: [
920
+ {
921
+ type: 'ClassMethod',
922
+ key: { type: 'Identifier', name: 'getOrder' },
923
+ loc: { start: { line: 20 }, end: { line: 24 } },
924
+ body: { type: 'BlockStatement', body: [] },
925
+ },
926
+ {
927
+ type: 'ClassMethod',
928
+ key: { type: 'Identifier', name: 'saveOrder' },
929
+ loc: { start: { line: 30 }, end: { line: 40 } },
930
+ body: { type: 'BlockStatement', body: [] },
931
+ },
932
+ ],
933
+ },
880
934
  };
881
935
 
882
- assert.equal(hasLargeClassDeclaration(oversizedClassAst), true);
883
- assert.equal(hasLargeClassDeclaration(thresholdClassAst), true);
884
- assert.equal(hasLargeClassDeclaration(compactClassAst), false);
936
+ assert.equal(hasLargeClassDeclaration(godClassAst), true);
937
+ assert.equal(hasLargeClassDeclaration(oversizedButSingleResponsibilityAst), false);
938
+ assert.equal(hasLargeClassDeclaration(srpOnlyAst), false);
885
939
  });
886
940
 
887
941
  test('hasRecordStringUnknownType detecta Record<string, unknown>', () => {
@@ -19,7 +19,6 @@ const concreteDependencyNames = new Set<string>([
19
19
  'ApolloClient',
20
20
  'Axios',
21
21
  ]);
22
- const GOD_CLASS_MAX_LINES = 300;
23
22
  const networkCallCalleePattern = /^(fetch|axios|get|post|put|patch|delete|request)$/i;
24
23
  type AstNode = Record<string, string | number | boolean | bigint | symbol | null | Date | object>;
25
24
  type TypeScriptSemanticNode = {
@@ -894,14 +893,15 @@ const findTypeDiscriminatorSwitchInfo = (
894
893
  return false;
895
894
  }
896
895
 
897
- const typedCaseCount = value.cases.filter((entry) => {
896
+ const typedCases = value.cases.filter((entry) => {
898
897
  if (!isObject(entry) || entry.type !== 'SwitchCase' || !isObject(entry.test)) {
899
898
  return false;
900
899
  }
901
900
  return typeof switchCaseLabelFromNode(entry.test) === 'string';
902
- }).length;
901
+ });
903
902
 
904
- return typedCaseCount >= 2;
903
+ const [firstTypedCase, secondTypedCase] = typedCases;
904
+ return firstTypedCase !== undefined && secondTypedCase !== undefined;
905
905
  });
906
906
 
907
907
  if (!match) {
@@ -935,7 +935,8 @@ const findTypeDiscriminatorSwitchInfo = (
935
935
  })
936
936
  : [];
937
937
 
938
- if (caseNodes.length < 2) {
938
+ const [firstCaseNode, secondCaseNode] = caseNodes;
939
+ if (!firstCaseNode || !secondCaseNode) {
939
940
  return undefined;
940
941
  }
941
942
 
@@ -1658,7 +1659,7 @@ export const hasTypeDiscriminatorSwitch = (node: unknown): boolean => {
1658
1659
  return false;
1659
1660
  }
1660
1661
 
1661
- const typedCaseCount = value.cases.filter((entry) => {
1662
+ const typedCases = value.cases.filter((entry) => {
1662
1663
  if (!isObject(entry) || entry.type !== 'SwitchCase' || !isObject(entry.test)) {
1663
1664
  return false;
1664
1665
  }
@@ -1668,9 +1669,10 @@ export const hasTypeDiscriminatorSwitch = (node: unknown): boolean => {
1668
1669
  testNode.type === 'NumericLiteral' ||
1669
1670
  testNode.type === 'BooleanLiteral'
1670
1671
  );
1671
- }).length;
1672
+ });
1672
1673
 
1673
- return typedCaseCount >= 2;
1674
+ const [firstTypedCase, secondTypedCase] = typedCases;
1675
+ return firstTypedCase !== undefined && secondTypedCase !== undefined;
1674
1676
  });
1675
1677
  };
1676
1678
 
@@ -1747,16 +1749,19 @@ export const findConcreteDependencyInstantiationMatch = (
1747
1749
  return buildSolidDipMatch(node, 'concrete-instantiation');
1748
1750
  };
1749
1751
 
1750
- const nodeLineSpan = (node: unknown): number => {
1751
- if (!isObject(node) || !isObject(node.loc)) {
1752
- return 0;
1753
- }
1754
- const start = isObject(node.loc.start) ? node.loc.start.line : undefined;
1755
- const end = isObject(node.loc.end) ? node.loc.end.line : undefined;
1756
- if (typeof start !== 'number' || typeof end !== 'number') {
1757
- return 0;
1758
- }
1759
- return Math.max(0, end - start + 1);
1752
+ const hasSemanticGodClassResponsibilities = (classNode: AstNode): boolean => {
1753
+ const mixesCommandsAndQueries = typeof buildMixedCommandQueryClassMatch(classNode) !== 'undefined';
1754
+ const ownsConcreteInfrastructure = hasConcreteDependencyInstantiation(classNode);
1755
+ const ownsTypeBranching = hasTypeDiscriminatorSwitch(classNode);
1756
+ const weakensBaseContract = hasOverrideMethodThrowingNotImplemented(classNode);
1757
+
1758
+ return (
1759
+ (mixesCommandsAndQueries && ownsConcreteInfrastructure) ||
1760
+ (mixesCommandsAndQueries && ownsTypeBranching) ||
1761
+ (mixesCommandsAndQueries && weakensBaseContract) ||
1762
+ (ownsConcreteInfrastructure && ownsTypeBranching) ||
1763
+ (ownsConcreteInfrastructure && weakensBaseContract)
1764
+ );
1760
1765
  };
1761
1766
 
1762
1767
  export const hasLargeClassDeclaration = (node: unknown): boolean => {
@@ -1764,7 +1769,7 @@ export const hasLargeClassDeclaration = (node: unknown): boolean => {
1764
1769
  if (value.type !== 'ClassDeclaration' && value.type !== 'ClassExpression') {
1765
1770
  return false;
1766
1771
  }
1767
- return nodeLineSpan(value) >= GOD_CLASS_MAX_LINES;
1772
+ return hasSemanticGodClassResponsibilities(value);
1768
1773
  });
1769
1774
  };
1770
1775
 
@@ -410,7 +410,7 @@ const astDetectorRegistry: ReadonlyArray<ASTDetectorRegistryEntry> = [
410
410
  { detect: TS.hasOverrideMethodThrowingNotImplemented, ruleId: 'heuristics.ts.solid.lsp.override-not-implemented.ast', code: 'HEURISTICS_SOLID_LSP_OVERRIDE_NOT_IMPLEMENTED_AST', message: 'AST heuristic detected LSP risk: override throws not-implemented/unsupported.' },
411
411
  { detect: TS.hasFrameworkDependencyImport, ruleId: 'heuristics.ts.solid.dip.framework-import.ast', code: 'HEURISTICS_SOLID_DIP_FRAMEWORK_IMPORT_AST', message: 'AST heuristic detected DIP risk: framework dependency imported in domain/application code.', pathCheck: isTypeScriptDomainOrApplicationPath },
412
412
  { 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 },
413
- { detect: TS.hasLargeClassDeclaration, ruleId: 'heuristics.ts.god-class-large-class.ast', code: 'HEURISTICS_GOD_CLASS_LARGE_CLASS_AST', message: 'AST heuristic detected God Class candidate (>=300 lines in a single class declaration).' },
413
+ { detect: TS.hasLargeClassDeclaration, ruleId: 'heuristics.ts.god-class-large-class.ast', code: 'HEURISTICS_GOD_CLASS_LARGE_CLASS_AST', message: 'AST heuristic detected God Class candidate by mixed responsibility nodes in a single class declaration.' },
414
414
  { detect: TS.hasRecordStringUnknownType, locateLines: TS.findRecordStringUnknownTypeLines, ruleId: 'common.types.record_unknown_requires_type', code: 'COMMON_TYPES_RECORD_UNKNOWN_REQUIRES_TYPE_AST', message: 'AST heuristic detected Record<string, unknown> without explicit value union.' },
415
415
  { detect: TS.hasUnknownWithoutGuard, locateLines: TS.findUnknownWithoutGuardLines, ruleId: 'common.types.unknown_without_guard', code: 'COMMON_TYPES_UNKNOWN_WITHOUT_GUARD_AST', message: 'AST heuristic detected unknown usage without explicit guard evidence.', pathCheck: isTypeScriptDomainOrApplicationPath },
416
416
  { detect: TS.hasUndefinedInBaseTypeUnion, locateLines: TS.findUndefinedInBaseTypeUnionLines, ruleId: 'common.types.undefined_in_base_type', code: 'COMMON_TYPES_UNDEFINED_IN_BASE_TYPE_AST', message: 'AST heuristic detected undefined inside base-type unions.' },
@@ -332,7 +332,7 @@ export const typescriptRules: RuleSet = [
332
332
  },
333
333
  {
334
334
  id: 'heuristics.ts.god-class-large-class.ast',
335
- description: 'Detects God Class candidates when a class declaration exceeds 500 lines.',
335
+ description: 'Detects God Class candidates when one class mixes multiple responsibility nodes.',
336
336
  severity: 'ERROR',
337
337
  platform: 'generic',
338
338
  locked: true,
@@ -344,7 +344,7 @@ export const typescriptRules: RuleSet = [
344
344
  },
345
345
  then: {
346
346
  kind: 'Finding',
347
- message: 'AST heuristic detected God Class candidate (>500 lines in one class declaration).',
347
+ message: 'AST heuristic detected God Class candidate by mixed responsibility nodes in one class declaration.',
348
348
  code: 'HEURISTICS_GOD_CLASS_LARGE_CLASS_AST',
349
349
  },
350
350
  },
@@ -249,7 +249,7 @@ const { data } = await supabase
249
249
  ✅ **i18n en error messages** - Mensajes traducibles
250
250
 
251
251
  ### Anti-patterns a EVITAR:
252
- ❌ **God classes** - Servicios con >500 líneas
252
+ ❌ **God classes** - Servicios que mezclan responsabilidades de dominio, aplicación, infraestructura, branching de tipos o contratos en una misma clase
253
253
  ❌ **Anemic domain models** - Entidades solo con getters/setters
254
254
  ❌ **Magic numbers** - Usar constantes con nombres descriptivos
255
255
  ❌ **Callback hell** - Usar async/await
@@ -806,7 +806,7 @@ final class APIClientSpy: @unchecked Sendable, APIClientProtocol {
806
806
  - Usar `@preconcurrency` solo como medida temporal de migración
807
807
 
808
808
  ### Anti-patterns a EVITAR:
809
- ❌ **Massive View Controllers** - ViewControllers >300 líneas
809
+ ❌ **Massive View Controllers** - ViewControllers que mezclan presentación, navegación, estado, acceso a datos o coordinación de infraestructura
810
810
  ❌ **Force unwrapping (!)** - Salvo IBOutlets y casos justificados
811
811
  ❌ **Singletons** - Dificultan testing
812
812
  ❌ **Storyboards grandes** - Merge conflicts, lentitud
@@ -446,13 +446,7 @@ const normalizeKnownRuleTarget = (
446
446
  if (includes('clean architecture')) {
447
447
  return `${prefix}.enforce-clean-architecture`;
448
448
  }
449
- if (
450
- includes('god classes') ||
451
- includes('god class') ||
452
- includes('500 lineas') ||
453
- includes('500 li neas') ||
454
- includes('500 lines')
455
- ) {
449
+ if (includes('god classes') || includes('god class')) {
456
450
  return `${prefix}.no-god-classes`;
457
451
  }
458
452
  if (includes('empty catch')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pumuki",
3
- "version": "6.3.121",
3
+ "version": "6.3.123",
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": {
@@ -249,7 +249,7 @@ const { data } = await supabase
249
249
  ✅ **i18n en error messages** - Mensajes traducibles
250
250
 
251
251
  ### Anti-patterns a EVITAR:
252
- ❌ **God classes** - Servicios con >500 líneas
252
+ ❌ **God classes** - Servicios que mezclan responsabilidades de dominio, aplicación, infraestructura, branching de tipos o contratos en una misma clase
253
253
  ❌ **Anemic domain models** - Entidades solo con getters/setters
254
254
  ❌ **Magic numbers** - Usar constantes con nombres descriptivos
255
255
  ❌ **Callback hell** - Usar async/await
@@ -806,7 +806,7 @@ final class APIClientSpy: @unchecked Sendable, APIClientProtocol {
806
806
  - Usar `@preconcurrency` solo como medida temporal de migración
807
807
 
808
808
  ### Anti-patterns a EVITAR:
809
- ❌ **Massive View Controllers** - ViewControllers >300 líneas
809
+ ❌ **Massive View Controllers** - ViewControllers que mezclan presentación, navegación, estado, acceso a datos o coordinación de infraestructura
810
810
  ❌ **Force unwrapping (!)** - Salvo IBOutlets y casos justificados
811
811
  ❌ **Singletons** - Dificultan testing
812
812
  ❌ **Storyboards grandes** - Merge conflicts, lentitud