pumuki 6.3.122 → 6.3.124

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,14 @@ 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
+
9
17
  ## [6.3.122] - 2026-04-28
10
18
 
11
19
  ### Fixed
package/VERSION CHANGED
@@ -1 +1 @@
1
- v6.3.122
1
+ v6.3.123
@@ -0,0 +1,33 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import test from 'node:test';
5
+
6
+ const detectorFiles = [
7
+ 'core/facts/detectors/text/ios.ts',
8
+ 'core/facts/detectors/text/android.ts',
9
+ 'core/facts/detectors/typescript/index.ts',
10
+ 'core/facts/detectors/security/securityCredentials.ts',
11
+ ];
12
+
13
+ const forbiddenDecisionThresholds = [
14
+ /typeDeclarations\.length\s*<\s*\d+/,
15
+ /conformingTypes\.length\s*<\s*\d+/,
16
+ /trim\(\)\.length\s*>=\s*\d+/,
17
+ /relatedNodes\.length\s*<\s*\d+/,
18
+ /typedCaseCount\s*>=\s*\d+/,
19
+ /caseNodes\.length\s*<\s*\d+/,
20
+ /branchNodes\.length\s*<\s*\d+/,
21
+ /slice\(0,\s*\d+\)/,
22
+ ];
23
+
24
+ test('detectores AST estructurales no usan umbrales internos para decidir skills', () => {
25
+ const violations = detectorFiles.flatMap((filePath) => {
26
+ const content = readFileSync(join(process.cwd(), filePath), 'utf8');
27
+ return forbiddenDecisionThresholds
28
+ .filter((pattern) => pattern.test(content))
29
+ .map((pattern) => `${filePath}: ${pattern.source}`);
30
+ });
31
+
32
+ assert.deepEqual(violations, []);
33
+ });
@@ -15,7 +15,7 @@ import {
15
15
  hasWeakTokenGenerationWithCryptoRandomUuid,
16
16
  } from './index';
17
17
 
18
- test('hasHardcodedSecretTokenLiteral detecta literales fuertes en identificadores sensibles', () => {
18
+ test('hasHardcodedSecretTokenLiteral detecta literales reales en identificadores sensibles', () => {
19
19
  const ast = {
20
20
  type: 'VariableDeclarator',
21
21
  id: { type: 'Identifier', name: 'apiToken' },
@@ -24,11 +24,17 @@ test('hasHardcodedSecretTokenLiteral detecta literales fuertes en identificadore
24
24
  const safeAst = {
25
25
  type: 'VariableDeclarator',
26
26
  id: { type: 'Identifier', name: 'apiToken' },
27
- init: { type: 'StringLiteral', value: 'short' },
27
+ init: { type: 'StringLiteral', value: 'example' },
28
+ };
29
+ const shortRealSecretAst = {
30
+ type: 'VariableDeclarator',
31
+ id: { type: 'Identifier', name: 'apiToken' },
32
+ init: { type: 'StringLiteral', value: 'prod' },
28
33
  };
29
34
 
30
35
  assert.equal(hasHardcodedSecretTokenLiteral(ast), true);
31
36
  assert.equal(hasHardcodedSecretTokenLiteral(safeAst), false);
37
+ assert.equal(hasHardcodedSecretTokenLiteral(shortRealSecretAst), true);
32
38
  });
33
39
 
34
40
  test('hasInsecureTokenGenerationWithMathRandom detecta Math.random en asignacion sensible', () => {
@@ -11,16 +11,21 @@ import {
11
11
  hasWeakTokenGenerationWithCryptoRandomUuid,
12
12
  } from './securityCredentials';
13
13
 
14
- test('hasHardcodedSecretTokenLiteral detecta literal largo en identificador sensible', () => {
14
+ test('hasHardcodedSecretTokenLiteral detecta literal real en identificador sensible', () => {
15
15
  const hardcodedSecretAst = {
16
16
  type: 'VariableDeclarator',
17
17
  id: { type: 'Identifier', name: 'apiKey' },
18
18
  init: { type: 'StringLiteral', value: 'super-secret-key-123' },
19
19
  };
20
- const safeLengthAst = {
20
+ const placeholderAst = {
21
21
  type: 'VariableDeclarator',
22
22
  id: { type: 'Identifier', name: 'apiKey' },
23
- init: { type: 'StringLiteral', value: 'short' },
23
+ init: { type: 'StringLiteral', value: 'replace-me' },
24
+ };
25
+ const shortRealSecretAst = {
26
+ type: 'VariableDeclarator',
27
+ id: { type: 'Identifier', name: 'apiKey' },
28
+ init: { type: 'StringLiteral', value: 'prod' },
24
29
  };
25
30
  const nonSensitiveIdentifierAst = {
26
31
  type: 'VariableDeclarator',
@@ -29,7 +34,8 @@ test('hasHardcodedSecretTokenLiteral detecta literal largo en identificador sens
29
34
  };
30
35
 
31
36
  assert.equal(hasHardcodedSecretTokenLiteral(hardcodedSecretAst), true);
32
- assert.equal(hasHardcodedSecretTokenLiteral(safeLengthAst), false);
37
+ assert.equal(hasHardcodedSecretTokenLiteral(placeholderAst), false);
38
+ assert.equal(hasHardcodedSecretTokenLiteral(shortRealSecretAst), true);
33
39
  assert.equal(hasHardcodedSecretTokenLiteral(nonSensitiveIdentifierAst), false);
34
40
  });
35
41
 
@@ -1,13 +1,15 @@
1
1
  import { collectNodeLineMatches, hasNode, isObject } from '../utils/astHelpers';
2
2
 
3
3
  const sensitiveIdentifierPattern = /(secret|token|password|api[_-]?key)/i;
4
+ const placeholderSecretLiteralPattern =
5
+ /^(?:changeme|change-me|change_me|replace-me|replace_me|todo|tbd|example|sample|dummy|test|testing|placeholder|your[_-]?(?:secret|token|password|api[_-]?key)|xxx+)$/i;
4
6
 
5
- const hasStrongLiteralValue = (value: unknown): boolean => {
7
+ const hasCredentialLiteralValue = (value: unknown): boolean => {
6
8
  if (!isObject(value)) {
7
9
  return false;
8
10
  }
9
11
  if (value.type === 'StringLiteral') {
10
- return typeof value.value === 'string' && value.value.trim().length >= 12;
12
+ return typeof value.value === 'string' && isCredentialLiteral(value.value);
11
13
  }
12
14
  if (
13
15
  value.type === 'TemplateLiteral' &&
@@ -17,11 +19,16 @@ const hasStrongLiteralValue = (value: unknown): boolean => {
17
19
  value.quasis.length === 1
18
20
  ) {
19
21
  const cooked = value.quasis[0]?.value?.cooked;
20
- return typeof cooked === 'string' && cooked.trim().length >= 12;
22
+ return typeof cooked === 'string' && isCredentialLiteral(cooked);
21
23
  }
22
24
  return false;
23
25
  };
24
26
 
27
+ const isCredentialLiteral = (value: string): boolean => {
28
+ const normalized = value.trim();
29
+ return normalized.length > 0 && !placeholderSecretLiteralPattern.test(normalized);
30
+ };
31
+
25
32
  const containsMathRandomCall = (candidate: unknown): boolean => {
26
33
  return hasNode(candidate, (value) => {
27
34
  if (value.type !== 'CallExpression') {
@@ -109,7 +116,7 @@ const isHardcodedSecretTokenLiteralNode = (value: Record<string, string | number
109
116
  if (!sensitiveIdentifierPattern.test(idNode.name as string)) {
110
117
  return false;
111
118
  }
112
- return hasStrongLiteralValue(value.init);
119
+ return hasCredentialLiteralValue(value.init);
113
120
  };
114
121
 
115
122
  const isInsecureTokenGenerationWithMathRandomNode = (value: Record<string, string | number | boolean | bigint | symbol | null | Date | object>): boolean => {
@@ -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,
@@ -650,9 +699,6 @@ export const findKotlinLiskovSubstitutionMatch = (
650
699
  }
651
700
 
652
701
  const typeDeclarations = parseKotlinTypeDeclarations(source);
653
- if (typeDeclarations.length < 2) {
654
- return undefined;
655
- }
656
702
 
657
703
  const sourceLines = source.split(/\r?\n/);
658
704
 
@@ -665,9 +711,6 @@ export const findKotlinLiskovSubstitutionMatch = (
665
711
  const conformingTypes = typeDeclarations.filter((typeDeclaration) =>
666
712
  typeDeclaration.conformances.includes(interfaceDeclaration.name)
667
713
  );
668
- if (conformingTypes.length < 2) {
669
- continue;
670
- }
671
714
 
672
715
  for (const memberName of memberNames) {
673
716
  let safeType: KotlinTypeDeclaration | undefined;
@@ -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 {
@@ -1355,9 +1435,6 @@ export const findSwiftLiskovSubstitutionMatch = (
1355
1435
  }
1356
1436
 
1357
1437
  const typeDeclarations = parseSwiftTypeDeclarations(source);
1358
- if (typeDeclarations.length < 2) {
1359
- return undefined;
1360
- }
1361
1438
 
1362
1439
  const sourceLines = source.split(/\r?\n/);
1363
1440
 
@@ -1370,9 +1447,6 @@ export const findSwiftLiskovSubstitutionMatch = (
1370
1447
  const conformingTypes = typeDeclarations.filter((typeDeclaration) =>
1371
1448
  typeDeclaration.conformances.includes(protocolDeclaration.name)
1372
1449
  );
1373
- if (conformingTypes.length < 2) {
1374
- continue;
1375
- }
1376
1450
 
1377
1451
  for (const memberName of memberNames) {
1378
1452
  let safeType: SwiftTypeDeclaration | undefined;
@@ -893,14 +893,15 @@ const findTypeDiscriminatorSwitchInfo = (
893
893
  return false;
894
894
  }
895
895
 
896
- const typedCaseCount = value.cases.filter((entry) => {
896
+ const typedCases = value.cases.filter((entry) => {
897
897
  if (!isObject(entry) || entry.type !== 'SwitchCase' || !isObject(entry.test)) {
898
898
  return false;
899
899
  }
900
900
  return typeof switchCaseLabelFromNode(entry.test) === 'string';
901
- }).length;
901
+ });
902
902
 
903
- return typedCaseCount >= 2;
903
+ const [firstTypedCase, secondTypedCase] = typedCases;
904
+ return firstTypedCase !== undefined && secondTypedCase !== undefined;
904
905
  });
905
906
 
906
907
  if (!match) {
@@ -934,7 +935,8 @@ const findTypeDiscriminatorSwitchInfo = (
934
935
  })
935
936
  : [];
936
937
 
937
- if (caseNodes.length < 2) {
938
+ const [firstCaseNode, secondCaseNode] = caseNodes;
939
+ if (!firstCaseNode || !secondCaseNode) {
938
940
  return undefined;
939
941
  }
940
942
 
@@ -1657,7 +1659,7 @@ export const hasTypeDiscriminatorSwitch = (node: unknown): boolean => {
1657
1659
  return false;
1658
1660
  }
1659
1661
 
1660
- const typedCaseCount = value.cases.filter((entry) => {
1662
+ const typedCases = value.cases.filter((entry) => {
1661
1663
  if (!isObject(entry) || entry.type !== 'SwitchCase' || !isObject(entry.test)) {
1662
1664
  return false;
1663
1665
  }
@@ -1667,9 +1669,10 @@ export const hasTypeDiscriminatorSwitch = (node: unknown): boolean => {
1667
1669
  testNode.type === 'NumericLiteral' ||
1668
1670
  testNode.type === 'BooleanLiteral'
1669
1671
  );
1670
- }).length;
1672
+ });
1671
1673
 
1672
- return typedCaseCount >= 2;
1674
+ const [firstTypedCase, secondTypedCase] = typedCases;
1675
+ return firstTypedCase !== undefined && secondTypedCase !== undefined;
1673
1676
  });
1674
1677
  };
1675
1678
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pumuki",
3
- "version": "6.3.122",
3
+ "version": "6.3.124",
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": {