pumuki 6.3.129 → 6.3.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +16 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +14 -10
  4. package/VERSION +1 -1
  5. package/core/facts/detectors/text/android.test.ts +2583 -24
  6. package/core/facts/detectors/text/android.ts +4633 -34
  7. package/core/facts/detectors/typescript/index.test.ts +3639 -73
  8. package/core/facts/detectors/typescript/index.ts +4819 -270
  9. package/core/facts/extractHeuristicFacts.ts +271 -6
  10. package/core/rules/presets/heuristics/android.test.ts +314 -1
  11. package/core/rules/presets/heuristics/android.ts +1220 -50
  12. package/core/rules/presets/heuristics/typescript.test.ts +158 -2
  13. package/core/rules/presets/heuristics/typescript.ts +508 -0
  14. package/docs/README.md +3 -3
  15. package/docs/operations/RELEASE_NOTES.md +7 -1
  16. package/docs/operations/framework-menu-consumer-walkthrough.md +18 -15
  17. package/docs/product/API_REFERENCE.md +1 -1
  18. package/docs/product/USAGE.md +1 -1
  19. package/docs/validation/README.md +3 -1
  20. package/integrations/config/skillsCompilerTemplates.test.ts +131 -0
  21. package/integrations/config/skillsCompilerTemplates.ts +953 -7
  22. package/integrations/config/skillsDetectorRegistry.ts +451 -8
  23. package/integrations/config/skillsMarkdownRules.ts +884 -2
  24. package/integrations/evidence/buildEvidence.ts +29 -1
  25. package/integrations/evidence/platformSummary.test.ts +73 -9
  26. package/integrations/evidence/platformSummary.ts +165 -7
  27. package/integrations/evidence/repoState.ts +3 -0
  28. package/integrations/evidence/schema.ts +18 -0
  29. package/integrations/evidence/trackingContract.ts +17 -0
  30. package/integrations/evidence/writeEvidence.test.ts +3 -0
  31. package/integrations/evidence/writeEvidence.ts +29 -1
  32. package/integrations/gate/evaluateAiGate.ts +251 -8
  33. package/integrations/gate/governanceActionCatalog.ts +275 -0
  34. package/integrations/gate/remediationCatalog.ts +8 -0
  35. package/integrations/git/runPlatformGate.ts +9 -1
  36. package/integrations/lifecycle/adapter.ts +24 -0
  37. package/integrations/lifecycle/bootstrapManifest.ts +248 -0
  38. package/integrations/lifecycle/cliGovernanceConsole.ts +69 -0
  39. package/integrations/lifecycle/governanceNextAction.ts +171 -0
  40. package/integrations/lifecycle/governanceObservationSnapshot.ts +369 -0
  41. package/integrations/lifecycle/packageInfo.ts +118 -1
  42. package/integrations/lifecycle/state.ts +8 -1
  43. package/integrations/lifecycle/trackingState.ts +403 -0
  44. package/integrations/lifecycle/watch.ts +1 -1
  45. package/integrations/mcp/aiGateCheck.ts +194 -10
  46. package/integrations/mcp/alignedPlatformGate.ts +232 -0
  47. package/integrations/mcp/enterpriseServer.ts +19 -3
  48. package/integrations/mcp/preFlightCheck.ts +66 -3
  49. package/integrations/mcp/readMcpPrePushStdin.ts +7 -0
  50. package/package.json +1 -1
  51. package/scripts/build-ruralgo-s1-evidence-pack.ts +85 -0
  52. package/scripts/check-tracking-single-active.sh +1 -1
  53. package/scripts/framework-menu-advanced-view-lib.ts +49 -0
  54. package/scripts/framework-menu-consumer-actions-lib.ts +32 -32
  55. package/scripts/framework-menu-consumer-preflight-render.ts +10 -0
  56. package/scripts/framework-menu-consumer-preflight-run.ts +23 -0
  57. package/scripts/framework-menu-consumer-preflight-types.ts +12 -0
  58. package/scripts/framework-menu-consumer-runtime-actions.ts +11 -5
  59. package/scripts/framework-menu-consumer-runtime-audit.ts +0 -28
  60. package/scripts/framework-menu-consumer-runtime-evidence-classic.ts +118 -42
  61. package/scripts/framework-menu-consumer-runtime-lib.ts +38 -0
  62. package/scripts/framework-menu-consumer-runtime-menu.ts +55 -15
  63. package/scripts/framework-menu-consumer-runtime-types.ts +4 -0
  64. package/scripts/framework-menu-evidence-summary-read.ts +17 -1
  65. package/scripts/framework-menu-evidence-summary-types.ts +3 -0
  66. package/scripts/framework-menu-layout-data.ts +3 -23
  67. package/scripts/framework-menu-system-notifications-macos-applescript-dialog.ts +1 -1
  68. package/scripts/framework-menu-system-notifications-macos-dialog-payload.ts +14 -2
  69. package/scripts/framework-menu-system-notifications-macos-swift-source.ts +1 -1
  70. package/scripts/framework-menu-system-notifications-payloads-blocked.ts +128 -4
  71. package/scripts/framework-menu-system-notifications-payloads.ts +8 -1
  72. package/scripts/framework-menu-system-notifications-text.ts +7 -1
  73. package/scripts/framework-menu.ts +37 -2
  74. package/scripts/package-install-smoke-consumer-git-repo-lib.ts +10 -1
  75. package/scripts/package-install-smoke-consumer-npm-lib.ts +46 -9
  76. package/scripts/ruralgo-s1-evidence-pack-lib.ts +200 -0
  77. package/skills.lock.json +613 -698
@@ -51,6 +51,287 @@ export type KotlinLiskovSubstitutionMatch = {
51
51
  lines: readonly number[];
52
52
  };
53
53
 
54
+ export type KotlinAndroidComposeShellMatch = {
55
+ primary_node: KotlinSemanticNodeMatch;
56
+ related_nodes: readonly KotlinSemanticNodeMatch[];
57
+ why: string;
58
+ impact: string;
59
+ expected_fix: string;
60
+ lines: readonly number[];
61
+ };
62
+
63
+ export type KotlinAndroidNavigationArgumentsMatch = KotlinAndroidComposeShellMatch;
64
+
65
+ export type KotlinAndroidCallbackAsyncMatch = {
66
+ primary_node: KotlinSemanticNodeMatch;
67
+ related_nodes: readonly KotlinSemanticNodeMatch[];
68
+ why: string;
69
+ impact: string;
70
+ expected_fix: string;
71
+ lines: readonly number[];
72
+ };
73
+
74
+ export type KotlinAndroidStateFlowMatch = {
75
+ primary_node: KotlinSemanticNodeMatch;
76
+ related_nodes: readonly KotlinSemanticNodeMatch[];
77
+ why: string;
78
+ impact: string;
79
+ expected_fix: string;
80
+ lines: readonly number[];
81
+ };
82
+
83
+ export type KotlinAndroidSingleSourceOfTruthMatch = {
84
+ primary_node: KotlinSemanticNodeMatch;
85
+ related_nodes: readonly KotlinSemanticNodeMatch[];
86
+ why: string;
87
+ impact: string;
88
+ expected_fix: string;
89
+ lines: readonly number[];
90
+ };
91
+
92
+ export type KotlinAndroidFlowBuilderMatch = {
93
+ primary_node: KotlinSemanticNodeMatch;
94
+ related_nodes: readonly KotlinSemanticNodeMatch[];
95
+ why: string;
96
+ impact: string;
97
+ expected_fix: string;
98
+ lines: readonly number[];
99
+ };
100
+
101
+ export type KotlinAndroidSharedFlowMatch = {
102
+ primary_node: KotlinSemanticNodeMatch;
103
+ related_nodes: readonly KotlinSemanticNodeMatch[];
104
+ why: string;
105
+ impact: string;
106
+ expected_fix: string;
107
+ lines: readonly number[];
108
+ };
109
+
110
+ export type KotlinAndroidFlowCollectMatch = {
111
+ primary_node: KotlinSemanticNodeMatch;
112
+ related_nodes: readonly KotlinSemanticNodeMatch[];
113
+ why: string;
114
+ impact: string;
115
+ expected_fix: string;
116
+ lines: readonly number[];
117
+ };
118
+
119
+ export type KotlinAndroidCollectAsStateMatch = {
120
+ primary_node: KotlinSemanticNodeMatch;
121
+ related_nodes: readonly KotlinSemanticNodeMatch[];
122
+ why: string;
123
+ impact: string;
124
+ expected_fix: string;
125
+ lines: readonly number[];
126
+ };
127
+
128
+ export type KotlinAndroidRememberMatch = {
129
+ primary_node: KotlinSemanticNodeMatch;
130
+ related_nodes: readonly KotlinSemanticNodeMatch[];
131
+ why: string;
132
+ impact: string;
133
+ expected_fix: string;
134
+ lines: readonly number[];
135
+ };
136
+
137
+ export type KotlinAndroidDerivedStateOfMatch = {
138
+ primary_node: KotlinSemanticNodeMatch;
139
+ related_nodes: readonly KotlinSemanticNodeMatch[];
140
+ why: string;
141
+ impact: string;
142
+ expected_fix: string;
143
+ lines: readonly number[];
144
+ };
145
+
146
+ export type KotlinAndroidLaunchedEffectMatch = {
147
+ primary_node: KotlinSemanticNodeMatch;
148
+ related_nodes: readonly KotlinSemanticNodeMatch[];
149
+ why: string;
150
+ impact: string;
151
+ expected_fix: string;
152
+ lines: readonly number[];
153
+ };
154
+
155
+ export type KotlinAndroidLaunchedEffectKeysMatch = {
156
+ primary_node: KotlinSemanticNodeMatch;
157
+ related_nodes: readonly KotlinSemanticNodeMatch[];
158
+ why: string;
159
+ impact: string;
160
+ expected_fix: string;
161
+ lines: readonly number[];
162
+ };
163
+
164
+ export type KotlinAndroidDisposableEffectMatch = {
165
+ primary_node: KotlinSemanticNodeMatch;
166
+ related_nodes: readonly KotlinSemanticNodeMatch[];
167
+ why: string;
168
+ impact: string;
169
+ expected_fix: string;
170
+ lines: readonly number[];
171
+ };
172
+
173
+ export type KotlinAndroidPreviewMatch = {
174
+ primary_node: KotlinSemanticNodeMatch;
175
+ related_nodes: readonly KotlinSemanticNodeMatch[];
176
+ why: string;
177
+ impact: string;
178
+ expected_fix: string;
179
+ lines: readonly number[];
180
+ };
181
+
182
+ export type KotlinAndroidAdaptiveLayoutsMatch = {
183
+ primary_node: KotlinSemanticNodeMatch;
184
+ related_nodes: readonly KotlinSemanticNodeMatch[];
185
+ why: string;
186
+ impact: string;
187
+ expected_fix: string;
188
+ lines: readonly number[];
189
+ };
190
+
191
+ export type KotlinAndroidExistingStructureMatch = {
192
+ primary_node: KotlinSemanticNodeMatch;
193
+ related_nodes: readonly KotlinSemanticNodeMatch[];
194
+ why: string;
195
+ impact: string;
196
+ expected_fix: string;
197
+ lines: readonly number[];
198
+ };
199
+
200
+ export type KotlinAndroidThemeMatch = {
201
+ primary_node: KotlinSemanticNodeMatch;
202
+ related_nodes: readonly KotlinSemanticNodeMatch[];
203
+ why: string;
204
+ impact: string;
205
+ expected_fix: string;
206
+ lines: readonly number[];
207
+ };
208
+
209
+ export type KotlinAndroidRecompositionMatch = {
210
+ primary_node: KotlinSemanticNodeMatch;
211
+ related_nodes: readonly KotlinSemanticNodeMatch[];
212
+ why: string;
213
+ impact: string;
214
+ expected_fix: string;
215
+ lines: readonly number[];
216
+ };
217
+
218
+ export type KotlinAndroidSkipRecompositionMatch = {
219
+ primary_node: KotlinSemanticNodeMatch;
220
+ related_nodes: readonly KotlinSemanticNodeMatch[];
221
+ why: string;
222
+ impact: string;
223
+ expected_fix: string;
224
+ lines: readonly number[];
225
+ };
226
+
227
+ export type KotlinAndroidStabilityMatch = {
228
+ primary_node: KotlinSemanticNodeMatch;
229
+ related_nodes: readonly KotlinSemanticNodeMatch[];
230
+ why: string;
231
+ impact: string;
232
+ expected_fix: string;
233
+ lines: readonly number[];
234
+ };
235
+
236
+ export type KotlinAndroidUiStateMatch = {
237
+ primary_node: KotlinSemanticNodeMatch;
238
+ related_nodes: readonly KotlinSemanticNodeMatch[];
239
+ why: string;
240
+ impact: string;
241
+ expected_fix: string;
242
+ lines: readonly number[];
243
+ };
244
+
245
+ export type KotlinAndroidUseCaseMatch = {
246
+ primary_node: KotlinSemanticNodeMatch;
247
+ related_nodes: readonly KotlinSemanticNodeMatch[];
248
+ why: string;
249
+ impact: string;
250
+ expected_fix: string;
251
+ lines: readonly number[];
252
+ };
253
+
254
+ export type KotlinAndroidRepositoryPatternMatch = {
255
+ primary_node: KotlinSemanticNodeMatch;
256
+ related_nodes: readonly KotlinSemanticNodeMatch[];
257
+ why: string;
258
+ impact: string;
259
+ expected_fix: string;
260
+ lines: readonly number[];
261
+ };
262
+
263
+ export type KotlinAndroidStateHoistingMatch = {
264
+ primary_node: KotlinSemanticNodeMatch;
265
+ related_nodes: readonly KotlinSemanticNodeMatch[];
266
+ why: string;
267
+ impact: string;
268
+ expected_fix: string;
269
+ lines: readonly number[];
270
+ };
271
+
272
+ export type KotlinAndroidViewModelMatch = {
273
+ primary_node: KotlinSemanticNodeMatch;
274
+ related_nodes: readonly KotlinSemanticNodeMatch[];
275
+ why: string;
276
+ impact: string;
277
+ expected_fix: string;
278
+ lines: readonly number[];
279
+ };
280
+
281
+ export type KotlinAndroidViewModelScopeMatch = {
282
+ primary_node: KotlinSemanticNodeMatch;
283
+ related_nodes: readonly KotlinSemanticNodeMatch[];
284
+ why: string;
285
+ impact: string;
286
+ expected_fix: string;
287
+ lines: readonly number[];
288
+ };
289
+
290
+ export type KotlinAndroidSupervisorScopeMatch = {
291
+ primary_node: KotlinSemanticNodeMatch;
292
+ related_nodes: readonly KotlinSemanticNodeMatch[];
293
+ why: string;
294
+ impact: string;
295
+ expected_fix: string;
296
+ lines: readonly number[];
297
+ };
298
+
299
+ export type KotlinAndroidAppStartupMatch = {
300
+ primary_node: KotlinSemanticNodeMatch;
301
+ related_nodes: readonly KotlinSemanticNodeMatch[];
302
+ why: string;
303
+ impact: string;
304
+ expected_fix: string;
305
+ lines: readonly number[];
306
+ };
307
+
308
+ export type KotlinAndroidAnalyticsMatch = {
309
+ primary_node: KotlinSemanticNodeMatch;
310
+ related_nodes: readonly KotlinSemanticNodeMatch[];
311
+ why: string;
312
+ impact: string;
313
+ expected_fix: string;
314
+ lines: readonly number[];
315
+ };
316
+
317
+ export type KotlinAndroidProfilerMatch = {
318
+ primary_node: KotlinSemanticNodeMatch;
319
+ related_nodes: readonly KotlinSemanticNodeMatch[];
320
+ why: string;
321
+ impact: string;
322
+ expected_fix: string;
323
+ lines: readonly number[];
324
+ };
325
+
326
+ export type KotlinAndroidBaselineProfilesMatch = {
327
+ primary_node: KotlinSemanticNodeMatch;
328
+ related_nodes: readonly KotlinSemanticNodeMatch[];
329
+ why: string;
330
+ impact: string;
331
+ expected_fix: string;
332
+ lines: readonly number[];
333
+ };
334
+
54
335
  type KotlinInterfaceMemberDeclaration = {
55
336
  name: string;
56
337
  line: number;
@@ -59,6 +340,8 @@ type KotlinInterfaceMemberDeclaration = {
59
340
  type KotlinInterfaceDeclaration = {
60
341
  name: string;
61
342
  line: number;
343
+ bodyStartLine: number;
344
+ bodyEndLine: number;
62
345
  members: readonly KotlinInterfaceMemberDeclaration[];
63
346
  };
64
347
 
@@ -110,6 +393,12 @@ const stripKotlinLineForSemanticScan = (line: string): string => {
110
393
  .replace(/"(?:\\.|[^"\\])*"/g, '""');
111
394
  };
112
395
 
396
+ const stripTomlLineForSemanticScan = (line: string): string => {
397
+ return line
398
+ .replace(/#.*$/, '')
399
+ .replace(/"(?:\\.|[^"\\])*"/g, '""');
400
+ };
401
+
113
402
  const collectKotlinRegexLines = (source: string, regex: RegExp): readonly number[] => {
114
403
  const matches: number[] = [];
115
404
  source.split(/\r?\n/).forEach((line, index) => {
@@ -125,7 +414,188 @@ const collectKotlinRegexLines = (source: string, regex: RegExp): readonly number
125
414
  return matches;
126
415
  };
127
416
 
128
- const sortedUniqueLines = (lines: ReadonlyArray<number>): readonly number[] => {
417
+ const collectKotlinRegexLinesInRange = (
418
+ source: string,
419
+ regex: RegExp,
420
+ startLine: number,
421
+ endLine: number
422
+ ): readonly number[] => {
423
+ const lines = source.split(/\r?\n/);
424
+ const matches: number[] = [];
425
+ const normalizedStart = Math.max(1, Math.trunc(startLine));
426
+ const normalizedEnd = Math.max(normalizedStart, Math.min(lines.length, Math.trunc(endLine)));
427
+
428
+ for (let index = normalizedStart - 1; index < normalizedEnd; index += 1) {
429
+ const sanitized = stripKotlinLineForSemanticScan(lines[index] ?? '');
430
+ if (sanitized.trimStart().startsWith('import ')) {
431
+ continue;
432
+ }
433
+ regex.lastIndex = 0;
434
+ if (regex.test(sanitized)) {
435
+ matches.push(index + 1);
436
+ }
437
+ }
438
+
439
+ return sortedUniqueLines(matches);
440
+ };
441
+
442
+ const collectAndroidTestMarkerLines = (source: string): readonly number[] => {
443
+ const markers = [
444
+ /AndroidJUnit4/,
445
+ /InstrumentationRegistry/,
446
+ /ActivityScenario/,
447
+ /Espresso/,
448
+ /onView/,
449
+ /createAndroidComposeRule/,
450
+ /createComposeRule/,
451
+ /androidx\.test/,
452
+ ];
453
+
454
+ const matches: number[] = [];
455
+ source.split(/\r?\n/).forEach((line, index) => {
456
+ const sanitized = stripKotlinLineForSemanticScan(line);
457
+ if (markers.some((marker) => marker.test(sanitized))) {
458
+ matches.push(index + 1);
459
+ }
460
+ });
461
+
462
+ return sortedUniqueLines(matches);
463
+ };
464
+
465
+ const collectAndroidTestFunctionDeclarations = (
466
+ source: string
467
+ ): readonly KotlinSemanticNodeMatch[] => {
468
+ const lines = source.split(/\r?\n/);
469
+ const declarations: KotlinSemanticNodeMatch[] = [];
470
+ const functionPattern =
471
+ /^\s*(?:public\s+|private\s+|internal\s+|protected\s+)?(?:inline\s+)?(?:suspend\s+)?fun\s+([A-Za-z_][A-Za-z0-9_]*)\b/;
472
+
473
+ for (let index = 0; index < lines.length; index += 1) {
474
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? '');
475
+ if (!/@Test\b/.test(sanitizedLine)) {
476
+ continue;
477
+ }
478
+
479
+ const sanitizedFunctionLine = sanitizedLine.replace(/^(?:@\w+(?:\([^)]*\))?\s*)+/, '');
480
+ const sameLineMatch = sanitizedFunctionLine.match(functionPattern);
481
+ if (sameLineMatch?.[1]) {
482
+ declarations.push({
483
+ kind: 'member',
484
+ name: `@Test fun ${sameLineMatch[1]}`,
485
+ lines: [index + 1],
486
+ });
487
+ continue;
488
+ }
489
+
490
+ for (let cursor = index + 1; cursor < Math.min(lines.length, index + 4); cursor += 1) {
491
+ const candidateLine = stripKotlinLineForSemanticScan(lines[cursor] ?? '');
492
+ const candidateFunctionLine = candidateLine.replace(/^(?:@\w+(?:\([^)]*\))?\s*)+/, '');
493
+ const functionMatch = candidateFunctionLine.match(functionPattern);
494
+ if (functionMatch?.[1]) {
495
+ declarations.push({
496
+ kind: 'member',
497
+ name: `@Test fun ${functionMatch[1]}`,
498
+ lines: [cursor + 1],
499
+ });
500
+ break;
501
+ }
502
+ if (candidateLine.trim().length > 0 && !candidateLine.trim().startsWith('@')) {
503
+ break;
504
+ }
505
+ }
506
+ }
507
+
508
+ return declarations;
509
+ };
510
+
511
+ const collectAndroidTestStructureMarkerLines = (
512
+ source: string,
513
+ markerPatterns: ReadonlyArray<RegExp>
514
+ ): readonly number[] => {
515
+ const matches: number[] = [];
516
+
517
+ source.split(/\r?\n/).forEach((line, index) => {
518
+ const trimmed = line.trim();
519
+ if (markerPatterns.some((marker) => marker.test(trimmed))) {
520
+ matches.push(index + 1);
521
+ }
522
+ });
523
+
524
+ return sortedUniqueLines(matches);
525
+ };
526
+
527
+ const buildAndroidTestStructureMatch = (params: {
528
+ source: string;
529
+ markerPatterns: ReadonlyArray<RegExp>;
530
+ relatedLabel: string;
531
+ why: string;
532
+ impact: string;
533
+ expectedFix: string;
534
+ }): KotlinAndroidTestStructureMatch | undefined => {
535
+ const testFunctions = collectAndroidTestFunctionDeclarations(params.source);
536
+ const markerLines = collectAndroidTestStructureMarkerLines(params.source, params.markerPatterns);
537
+
538
+ if (testFunctions.length === 0 || markerLines.length === 0) {
539
+ return undefined;
540
+ }
541
+
542
+ const [primaryNode, ...relatedFunctions] = testFunctions;
543
+ if (!primaryNode) {
544
+ return undefined;
545
+ }
546
+
547
+ return {
548
+ primary_node: {
549
+ kind: 'member',
550
+ name: primaryNode.name,
551
+ lines: primaryNode.lines,
552
+ },
553
+ related_nodes: [
554
+ ...relatedFunctions,
555
+ ...markerLines.map((line) => ({
556
+ kind: 'member' as const,
557
+ name: params.relatedLabel,
558
+ lines: [line],
559
+ })),
560
+ ],
561
+ why: params.why,
562
+ impact: params.impact,
563
+ expected_fix: params.expectedFix,
564
+ lines: sortedUniqueLines([
565
+ ...testFunctions.flatMap((node) => [...node.lines]),
566
+ ...markerLines,
567
+ ]),
568
+ };
569
+ };
570
+
571
+ const buildAndroidJvmUnitTestMatch = (
572
+ source: string
573
+ ): KotlinAndroidTestStructureMatch | undefined => {
574
+ const testFunctions = collectAndroidTestFunctionDeclarations(source);
575
+ const [primaryNode, ...relatedNodes] = testFunctions;
576
+
577
+ if (!primaryNode) {
578
+ return undefined;
579
+ }
580
+
581
+ return {
582
+ primary_node: {
583
+ kind: 'member',
584
+ name: primaryNode.name,
585
+ lines: primaryNode.lines,
586
+ },
587
+ related_nodes: relatedNodes,
588
+ why:
589
+ 'test/ agrupa unit tests JVM que deben ejecutarse rápido en la máquina local y mantenerse separadas de las pruebas instrumentadas.',
590
+ impact:
591
+ 'Las pruebas JVM quedan aisladas de androidTest, son más rápidas de ejecutar y validan la lógica local sin depender de emulador o dispositivo.',
592
+ expected_fix:
593
+ 'Mantén las pruebas de unidad en src/test con JUnit y mueve las comprobaciones de UI o framework Android a androidTest.',
594
+ lines: sortedUniqueLines(testFunctions.flatMap((node) => [...node.lines])),
595
+ };
596
+ };
597
+
598
+ const sortedUniqueLines = (lines: ReadonlyArray<number>): readonly number[] => {
129
599
  return Array.from(new Set(lines.filter((line) => Number.isFinite(line)).map((line) => Math.trunc(line))))
130
600
  .sort((left, right) => left - right);
131
601
  };
@@ -140,6 +610,10 @@ const normalizeKotlinWhenBranchName = (rawLabel: string): string => {
140
610
  return withoutGuard.match(/([A-Za-z_][A-Za-z0-9_]*)$/)?.[1] ?? withoutGuard;
141
611
  };
142
612
 
613
+ const escapeRegex = (value: string): string => {
614
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
615
+ };
616
+
143
617
  const parseKotlinInterfaceDeclarations = (source: string): readonly KotlinInterfaceDeclaration[] => {
144
618
  const lines = source.split(/\r?\n/);
145
619
  const declarations: KotlinInterfaceDeclaration[] = [];
@@ -163,6 +637,8 @@ const parseKotlinInterfaceDeclarations = (source: string): readonly KotlinInterf
163
637
  const members: KotlinInterfaceMemberDeclaration[] = [];
164
638
  let braceDepth =
165
639
  countTokenOccurrences(sanitizedLine, '{') - countTokenOccurrences(sanitizedLine, '}');
640
+ const bodyStartLine = index + 1;
641
+ let bodyEndLine = index + 1;
166
642
 
167
643
  for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
168
644
  const candidateLine = stripKotlinLineForSemanticScan(lines[cursor] ?? '');
@@ -185,6 +661,7 @@ const parseKotlinInterfaceDeclarations = (source: string): readonly KotlinInterf
185
661
 
186
662
  braceDepth += countTokenOccurrences(candidateLine, '{');
187
663
  braceDepth -= countTokenOccurrences(candidateLine, '}');
664
+ bodyEndLine = cursor + 1;
188
665
  if (braceDepth <= 0) {
189
666
  break;
190
667
  }
@@ -193,6 +670,8 @@ const parseKotlinInterfaceDeclarations = (source: string): readonly KotlinInterf
193
670
  declarations.push({
194
671
  name: interfaceName,
195
672
  line: index + 1,
673
+ bodyStartLine,
674
+ bodyEndLine,
196
675
  members,
197
676
  });
198
677
  }
@@ -255,51 +734,3591 @@ const parseKotlinTypeDeclarations = (source: string): readonly KotlinTypeDeclara
255
734
  return declarations;
256
735
  };
257
736
 
258
- export const hasKotlinThreadSleepCall = (source: string): boolean => {
259
- return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => {
260
- if (current !== 'T') {
261
- return false;
262
- }
737
+ type KotlinRepositoryDeclaration = {
738
+ name: string;
739
+ line: number;
740
+ bodyStartLine: number;
741
+ bodyEndLine: number;
742
+ conformances: readonly string[];
743
+ members: readonly KotlinInterfaceMemberDeclaration[];
744
+ };
263
745
 
264
- return (
265
- hasIdentifierAt(kotlinSource, index, 'Thread') &&
266
- kotlinSource.startsWith('.sleep', index + 'Thread'.length)
746
+ const repositoryDeclarationNamePattern = /(?:Repository(?:Impl)?|OrdersRep)$/;
747
+
748
+ const parseAndroidRepositoryDeclarations = (
749
+ source: string
750
+ ): readonly KotlinRepositoryDeclaration[] => {
751
+ const classDeclarations = parseKotlinTypeDeclarations(source)
752
+ .filter((declaration) => repositoryDeclarationNamePattern.test(declaration.name))
753
+ .map(
754
+ (declaration): KotlinRepositoryDeclaration => ({
755
+ name: declaration.name,
756
+ line: declaration.line,
757
+ bodyStartLine: declaration.bodyStartLine,
758
+ bodyEndLine: declaration.bodyEndLine,
759
+ conformances: declaration.conformances,
760
+ members: [],
761
+ })
267
762
  );
268
- });
763
+
764
+ const interfaceDeclarations = parseKotlinInterfaceDeclarations(source)
765
+ .filter((declaration) => repositoryDeclarationNamePattern.test(declaration.name))
766
+ .map(
767
+ (declaration): KotlinRepositoryDeclaration => ({
768
+ name: declaration.name,
769
+ line: declaration.line,
770
+ bodyStartLine: declaration.bodyStartLine,
771
+ bodyEndLine: declaration.bodyEndLine,
772
+ conformances: [],
773
+ members: declaration.members,
774
+ })
775
+ );
776
+
777
+ return [...classDeclarations, ...interfaceDeclarations].sort(
778
+ (left, right) => left.line - right.line || left.name.localeCompare(right.name)
779
+ );
269
780
  };
270
781
 
271
- export const hasKotlinGlobalScopeUsage = (source: string): boolean => {
272
- return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => {
273
- if (current !== 'G' || !hasIdentifierAt(kotlinSource, index, 'GlobalScope')) {
274
- return false;
275
- }
782
+ const androidActivityConformancePattern =
783
+ /^(?:Activity|ComponentActivity|AppCompatActivity|FragmentActivity)$/;
276
784
 
277
- const start = index + 'GlobalScope'.length;
278
- const tail = kotlinSource.slice(start, start + 32);
279
- return /^\s*\.(launch|async|produce|actor)\b/.test(tail);
280
- });
785
+ const isAndroidActivityTypeConformance = (conformance: string): boolean => {
786
+ return androidActivityConformancePattern.test(conformance);
281
787
  };
282
788
 
283
- export const hasKotlinRunBlockingUsage = (source: string): boolean => {
284
- return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => {
285
- if (current !== 'r' || !hasIdentifierAt(kotlinSource, index, 'runBlocking')) {
286
- return false;
789
+ const parseAndroidActivityDeclarations = (source: string): readonly KotlinTypeDeclaration[] => {
790
+ return parseKotlinTypeDeclarations(source).filter((declaration) =>
791
+ declaration.conformances.some(isAndroidActivityTypeConformance)
792
+ );
793
+ };
794
+
795
+ const collectAndroidComposableFunctionDeclarations = (
796
+ source: string
797
+ ): readonly KotlinSemanticNodeMatch[] => {
798
+ const lines = source.split(/\r?\n/);
799
+ const declarations: KotlinSemanticNodeMatch[] = [];
800
+ const functionPattern =
801
+ /^\s*(?:public\s+|private\s+|internal\s+|protected\s+)?(?:inline\s+)?(?:suspend\s+)?fun\s+([A-Za-z_][A-Za-z0-9_]*)\b/;
802
+
803
+ for (let index = 0; index < lines.length; index += 1) {
804
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? '');
805
+ if (!/@Composable\b/.test(sanitizedLine)) {
806
+ continue;
287
807
  }
288
808
 
289
- const start = index + 'runBlocking'.length;
290
- const tail = kotlinSource.slice(start, start + 48);
291
- return /^\s*(<[^>\n]+>\s*)?(\(|\{)/.test(tail);
292
- });
809
+ const sanitizedFunctionLine = sanitizedLine.replace(
810
+ /^(?:@\w+(?:\([^)]*\))?\s*)+/,
811
+ ''
812
+ );
813
+ const sameLineMatch = sanitizedFunctionLine.match(functionPattern);
814
+ if (sameLineMatch?.[1]) {
815
+ declarations.push({
816
+ kind: 'member',
817
+ name: `@Composable fun ${sameLineMatch[1]}`,
818
+ lines: [index + 1],
819
+ });
820
+ continue;
821
+ }
822
+
823
+ for (let cursor = index + 1; cursor < Math.min(lines.length, index + 4); cursor += 1) {
824
+ const candidateLine = stripKotlinLineForSemanticScan(lines[cursor] ?? '');
825
+ const candidateFunctionLine = candidateLine.replace(
826
+ /^(?:@\w+(?:\([^)]*\))?\s*)+/,
827
+ ''
828
+ );
829
+ const functionMatch = candidateFunctionLine.match(functionPattern);
830
+ if (functionMatch?.[1]) {
831
+ declarations.push({
832
+ kind: 'member',
833
+ name: `@Composable fun ${functionMatch[1]}`,
834
+ lines: [cursor + 1],
835
+ });
836
+ break;
837
+ }
838
+ if (candidateLine.trim().length > 0 && !candidateLine.trim().startsWith('@')) {
839
+ break;
840
+ }
841
+ }
842
+ }
843
+
844
+ return declarations;
293
845
  };
294
846
 
295
- export const hasKotlinForceUnwrapUsage = (source: string): boolean => {
296
- return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => {
297
- if (current !== '!' || kotlinSource[index + 1] !== '!') {
298
- return false;
847
+ const collectAndroidActivityShellNodes = (
848
+ source: string,
849
+ bodyStartLine: number,
850
+ bodyEndLine: number
851
+ ): readonly KotlinSemanticNodeMatch[] => {
852
+ const nodes: KotlinSemanticNodeMatch[] = [];
853
+ const patterns: ReadonlyArray<{ name: string; regex: RegExp }> = [
854
+ { name: 'setContent', regex: /\bsetContent\s*\{/ },
855
+ { name: 'rememberNavController', regex: /\brememberNavController\b/ },
856
+ { name: 'NavHost', regex: /\bNavHost\b/ },
857
+ ];
858
+
859
+ for (const pattern of patterns) {
860
+ const lines = collectKotlinRegexLinesInRange(source, pattern.regex, bodyStartLine, bodyEndLine);
861
+ if (lines.length === 0) {
862
+ continue;
299
863
  }
864
+ nodes.push({
865
+ kind: 'call',
866
+ name: pattern.name,
867
+ lines,
868
+ });
869
+ }
870
+
871
+ return nodes;
872
+ };
873
+
874
+ const collectAndroidActivityComposableNodes = (
875
+ source: string,
876
+ _bodyStartLine: number,
877
+ _bodyEndLine: number
878
+ ): readonly KotlinSemanticNodeMatch[] => {
879
+ return collectAndroidComposableFunctionDeclarations(source);
880
+ };
881
+
882
+ const findAndroidActivityDeclaration = (
883
+ source: string
884
+ ): KotlinTypeDeclaration | undefined => {
885
+ return parseAndroidActivityDeclarations(source)[0];
886
+ };
887
+
888
+ const buildAndroidActivityComposeShellMatch = (
889
+ source: string
890
+ ): KotlinAndroidComposeShellMatch | undefined => {
891
+ const activity = findAndroidActivityDeclaration(source);
892
+ if (!activity) {
893
+ return undefined;
894
+ }
895
+
896
+ const relatedNodes = collectAndroidActivityShellNodes(
897
+ source,
898
+ activity.bodyStartLine,
899
+ activity.bodyEndLine
900
+ );
901
+ if (relatedNodes.length === 0) {
902
+ return undefined;
903
+ }
904
+
905
+ const composableNodes = collectAndroidActivityComposableNodes(
906
+ source,
907
+ activity.bodyStartLine,
908
+ activity.bodyEndLine
909
+ );
910
+ if (composableNodes.length > 0) {
911
+ return undefined;
912
+ }
913
+
914
+ return {
915
+ primary_node: {
916
+ kind: 'class',
917
+ name: activity.name,
918
+ lines: [activity.line],
919
+ },
920
+ related_nodes: relatedNodes,
921
+ why: `${activity.name} funciona como Activity shell de Compose con navegación explícita en lugar de distribuir la UI entre múltiples Activities.`,
922
+ impact:
923
+ 'La pantalla principal queda centralizada en una sola Activity y la navegación Compose mantiene la entrada de la app más simple y predecible.',
924
+ expected_fix:
925
+ 'Mantén una única Activity contenedora y delega la UI en composables y navegación Compose en vez de crear Activities adicionales.',
926
+ lines: sortedUniqueLines([
927
+ activity.line,
928
+ ...relatedNodes.flatMap((node) => [...node.lines]),
929
+ ]),
930
+ };
931
+ };
932
+
933
+ const buildAndroidGodActivityMatch = (
934
+ source: string
935
+ ): KotlinAndroidComposeShellMatch | undefined => {
936
+ const activity = findAndroidActivityDeclaration(source);
937
+ if (!activity) {
938
+ return undefined;
939
+ }
940
+
941
+ const shellNodes = collectAndroidActivityShellNodes(source, activity.bodyStartLine, activity.bodyEndLine);
942
+ const composableNodes = collectAndroidActivityComposableNodes(
943
+ source,
944
+ activity.bodyStartLine,
945
+ activity.bodyEndLine
946
+ );
947
+
948
+ if (shellNodes.length === 0 || composableNodes.length === 0) {
949
+ return undefined;
950
+ }
951
+
952
+ return {
953
+ primary_node: {
954
+ kind: 'class',
955
+ name: activity.name,
956
+ lines: [activity.line],
957
+ },
958
+ related_nodes: [...shellNodes, ...composableNodes],
959
+ why: `${activity.name} mezcla la Activity shell con declaraciones @Composable en el mismo archivo, concentrando demasiadas responsabilidades en una sola clase.`,
960
+ impact:
961
+ 'La Activity deja de ser un contenedor fino, el archivo crece como punto único de cambio y la UI Compose queda demasiado acoplada a la orquestación de pantalla.',
962
+ expected_fix:
963
+ 'Extrae los composables a archivos dedicados y conserva la Activity como una shell mínima de navegación y arranque.',
964
+ lines: sortedUniqueLines([
965
+ activity.line,
966
+ ...shellNodes.flatMap((node) => [...node.lines]),
967
+ ...composableNodes.flatMap((node) => [...node.lines]),
968
+ ]),
969
+ };
970
+ };
971
+
972
+ export const findAndroidComposableFunctionMatch = (
973
+ source: string
974
+ ): KotlinAndroidComposeShellMatch | undefined => {
975
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
976
+ const [primaryNode, ...relatedNodes] = composableNodes;
977
+ if (!primaryNode) {
978
+ return undefined;
979
+ }
980
+
981
+ return {
982
+ primary_node: primaryNode,
983
+ related_nodes: relatedNodes,
984
+ why: `${primaryNode.name} está declarada como @Composable para UI declarativa en Android.`,
985
+ impact:
986
+ 'La interfaz se expresa como funciones composables, lo que favorece la composición y la reutilización en la capa de presentación.',
987
+ expected_fix:
988
+ 'Mantén la UI en funciones composables pequeñas y declarativas, elevando el estado y dejando la lógica de negocio fuera.',
989
+ lines: sortedUniqueLines(composableNodes.flatMap((node) => [...node.lines])),
990
+ };
991
+ };
992
+
993
+ export const hasAndroidComposableFunctionUsage = (source: string): boolean => {
994
+ return findAndroidComposableFunctionMatch(source) !== undefined;
995
+ };
996
+
997
+ export const findAndroidArgumentsMatch = (
998
+ source: string
999
+ ): KotlinAndroidNavigationArgumentsMatch | undefined => {
1000
+ const lines = source.split(/\r?\n/);
1001
+ const classPattern =
1002
+ /^\s*(?:internal\s+|private\s+|public\s+)?(?:abstract\s+|open\s+|sealed\s+|data\s+|final\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)\b/;
1003
+ const classLines = collectKotlinRegexLines(source, classPattern);
1004
+ const navHostLines = collectKotlinRegexLines(source, /\bNavHost\b/);
1005
+ const composableRouteLines = collectKotlinRegexLines(source, /\bcomposable\s*\(/);
1006
+ const navArgumentLines = collectKotlinRegexLines(source, /\bnavArgument\s*\(/);
1007
+ const savedStateHandleLines = collectKotlinRegexLines(source, /\bSavedStateHandle\b/);
1008
+ const navigateCallLines = collectKotlinRegexLines(source, /\bnavigate\s*\(/);
1009
+
1010
+ if (
1011
+ (navArgumentLines.length === 0 && savedStateHandleLines.length === 0) ||
1012
+ navigateCallLines.length === 0 ||
1013
+ (navHostLines.length === 0 &&
1014
+ composableRouteLines.length === 0 &&
1015
+ savedStateHandleLines.length === 0)
1016
+ ) {
1017
+ return undefined;
1018
+ }
1019
+
1020
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
1021
+ const classLine = classLines[0] ? lines[classLines[0] - 1] ?? '' : '';
1022
+ const className = classLine.match(classPattern)?.[1];
1023
+ const primaryNode =
1024
+ composableNodes[0] ??
1025
+ (classLines[0] && className
1026
+ ? {
1027
+ kind: 'class' as const,
1028
+ name: className,
1029
+ lines: [classLines[0]],
1030
+ }
1031
+ : undefined);
1032
+
1033
+ if (!primaryNode) {
1034
+ return undefined;
1035
+ }
1036
+
1037
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
1038
+ {
1039
+ kind: 'call',
1040
+ name: 'NavHost',
1041
+ lines: navHostLines,
1042
+ },
1043
+ {
1044
+ kind: 'call',
1045
+ name: 'composable',
1046
+ lines: composableRouteLines,
1047
+ },
1048
+ ...(navArgumentLines.length > 0
1049
+ ? [
1050
+ {
1051
+ kind: 'call' as const,
1052
+ name: 'navArgument',
1053
+ lines: navArgumentLines,
1054
+ },
1055
+ ]
1056
+ : []),
1057
+ ...(savedStateHandleLines.length > 0
1058
+ ? [
1059
+ {
1060
+ kind: 'property' as const,
1061
+ name: 'SavedStateHandle',
1062
+ lines: savedStateHandleLines,
1063
+ },
1064
+ ]
1065
+ : []),
1066
+ {
1067
+ kind: 'call',
1068
+ name: 'navigate with arguments',
1069
+ lines: navigateCallLines,
1070
+ },
1071
+ ];
1072
+
1073
+ const evidenceLines = sortedUniqueLines([
1074
+ ...primaryNode.lines,
1075
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1076
+ ]);
1077
+
1078
+ return {
1079
+ primary_node: primaryNode,
1080
+ related_nodes: relatedNodes,
1081
+ why:
1082
+ 'La navegación entre pantallas pasa argumentos de ruta y los recupera de forma explícita en el destino, evitando dependencias implícitas o estados globales.',
1083
+ impact:
1084
+ 'El contrato entre pantallas queda tipado y visible: la fuente de navegación, el argumento y la lectura del destino se mantienen en la misma frontera de UI.',
1085
+ expected_fix:
1086
+ 'Pasa los datos por la ruta o SavedStateHandle con argumentos explícitos y deja la navegación sin estado oculto ni singletons compartidos.',
1087
+ lines: evidenceLines,
1088
+ };
1089
+ };
1090
+
1091
+ export const hasAndroidArgumentsUsage = (source: string): boolean => {
1092
+ return findAndroidArgumentsMatch(source) !== undefined;
1093
+ };
1094
+
1095
+ export const findAndroidSingleActivityComposeShellMatch = (
1096
+ source: string
1097
+ ): KotlinAndroidComposeShellMatch | undefined => {
1098
+ return buildAndroidActivityComposeShellMatch(source);
1099
+ };
1100
+
1101
+ export const hasAndroidSingleActivityComposeShellUsage = (source: string): boolean => {
1102
+ return findAndroidSingleActivityComposeShellMatch(source) !== undefined;
1103
+ };
1104
+
1105
+ export const findAndroidGodActivityMatch = (
1106
+ source: string
1107
+ ): KotlinAndroidComposeShellMatch | undefined => {
1108
+ return buildAndroidGodActivityMatch(source);
1109
+ };
1110
+
1111
+ export const hasAndroidGodActivityUsage = (source: string): boolean => {
1112
+ return findAndroidGodActivityMatch(source) !== undefined;
1113
+ };
1114
+
1115
+ export const hasKotlinThreadSleepCall = (source: string): boolean => {
1116
+ return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => {
1117
+ if (current !== 'T') {
1118
+ return false;
1119
+ }
1120
+
1121
+ return (
1122
+ hasIdentifierAt(kotlinSource, index, 'Thread') &&
1123
+ kotlinSource.startsWith('.sleep', index + 'Thread'.length)
1124
+ );
1125
+ });
1126
+ };
1127
+
1128
+ export const hasKotlinGlobalScopeUsage = (source: string): boolean => {
1129
+ return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => {
1130
+ if (current !== 'G' || !hasIdentifierAt(kotlinSource, index, 'GlobalScope')) {
1131
+ return false;
1132
+ }
1133
+
1134
+ const start = index + 'GlobalScope'.length;
1135
+ const tail = kotlinSource.slice(start, start + 32);
1136
+ return /^\s*\.(launch|async|produce|actor)\b/.test(tail);
1137
+ });
1138
+ };
1139
+
1140
+ export const hasKotlinRunBlockingUsage = (source: string): boolean => {
1141
+ return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => {
1142
+ if (current !== 'r' || !hasIdentifierAt(kotlinSource, index, 'runBlocking')) {
1143
+ return false;
1144
+ }
1145
+
1146
+ const start = index + 'runBlocking'.length;
1147
+ const tail = kotlinSource.slice(start, start + 48);
1148
+ return /^\s*(<[^>\n]+>\s*)?(\(|\{)/.test(tail);
1149
+ });
1150
+ };
1151
+
1152
+ export const hasKotlinForceUnwrapUsage = (source: string): boolean => {
1153
+ return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => {
1154
+ if (current !== '!' || kotlinSource[index + 1] !== '!') {
1155
+ return false;
1156
+ }
1157
+
1158
+ return kotlinSource[index + 2] !== '=';
1159
+ });
1160
+ };
1161
+
1162
+ export const findAndroidStateFlowMatch = (
1163
+ source: string
1164
+ ): KotlinAndroidStateFlowMatch | undefined => {
1165
+ const declarations = parseKotlinTypeDeclarations(source).filter((declaration) =>
1166
+ declaration.conformances.some((conformance) => conformance === 'ViewModel')
1167
+ );
1168
+
1169
+ for (const declaration of declarations) {
1170
+ const stateFlowLines = collectKotlinRegexLinesInRange(
1171
+ source,
1172
+ /\b(?:MutableStateFlow|StateFlow|asStateFlow)\b/,
1173
+ declaration.bodyStartLine,
1174
+ declaration.bodyEndLine
1175
+ );
1176
+
1177
+ if (stateFlowLines.length === 0) {
1178
+ continue;
1179
+ }
1180
+
1181
+ const lines = source.split(/\r?\n/);
1182
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
1183
+
1184
+ for (const lineNumber of stateFlowLines) {
1185
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[lineNumber - 1] ?? '');
1186
+ if (/\bMutableStateFlow\b/.test(sanitizedLine)) {
1187
+ relatedNodes.push({
1188
+ kind: 'property',
1189
+ name: 'MutableStateFlow',
1190
+ lines: [lineNumber],
1191
+ });
1192
+ }
1193
+ if (/\bStateFlow\b/.test(sanitizedLine)) {
1194
+ relatedNodes.push({
1195
+ kind: 'property',
1196
+ name: 'StateFlow',
1197
+ lines: [lineNumber],
1198
+ });
1199
+ }
1200
+ if (/\basStateFlow\b/.test(sanitizedLine)) {
1201
+ relatedNodes.push({
1202
+ kind: 'call',
1203
+ name: 'asStateFlow',
1204
+ lines: [lineNumber],
1205
+ });
1206
+ }
1207
+ }
1208
+
1209
+ return {
1210
+ primary_node: {
1211
+ kind: 'class',
1212
+ name: declaration.name,
1213
+ lines: [declaration.line],
1214
+ },
1215
+ related_nodes: relatedNodes,
1216
+ why: `${declaration.name} expone estado observable con StateFlow en lugar de mantenerlo como estado opaco dentro del ViewModel.`,
1217
+ impact:
1218
+ 'El estado de UI deja de depender de observadores ad-hoc y pasa a un flujo observable y predecible que Compose puede consumir de forma segura.',
1219
+ expected_fix:
1220
+ 'Mantén un MutableStateFlow privado, expón StateFlow inmutable para la UI y actualiza el estado desde el ViewModel con una sola fuente de verdad.',
1221
+ lines: sortedUniqueLines([
1222
+ declaration.line,
1223
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1224
+ ]),
1225
+ };
1226
+ }
1227
+
1228
+ return undefined;
1229
+ };
1230
+
1231
+ export const hasAndroidStateFlowUsage = (source: string): boolean => {
1232
+ return findAndroidStateFlowMatch(source) !== undefined;
1233
+ };
1234
+
1235
+ export const findAndroidSingleSourceOfTruthMatch = (
1236
+ source: string
1237
+ ): KotlinAndroidSingleSourceOfTruthMatch | undefined => {
1238
+ const declarations = parseKotlinTypeDeclarations(source).filter((declaration) =>
1239
+ declaration.conformances.some((conformance) => conformance === 'ViewModel')
1240
+ );
1241
+
1242
+ for (const declaration of declarations) {
1243
+ const stateFlowLines = collectKotlinRegexLinesInRange(
1244
+ source,
1245
+ /\b(?:MutableStateFlow|StateFlow|asStateFlow)\b/,
1246
+ declaration.bodyStartLine,
1247
+ declaration.bodyEndLine
1248
+ );
1249
+
1250
+ if (stateFlowLines.length === 0) {
1251
+ continue;
1252
+ }
1253
+
1254
+ const lines = source.split(/\r?\n/);
1255
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
1256
+ let hasMutableState = false;
1257
+ let hasExposedState = false;
1258
+
1259
+ for (const lineNumber of stateFlowLines) {
1260
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[lineNumber - 1] ?? '');
1261
+ if (/\bMutableStateFlow\b/.test(sanitizedLine)) {
1262
+ relatedNodes.push({
1263
+ kind: 'property',
1264
+ name: 'MutableStateFlow',
1265
+ lines: [lineNumber],
1266
+ });
1267
+ hasMutableState = true;
1268
+ }
1269
+ if (/\bStateFlow\b/.test(sanitizedLine)) {
1270
+ relatedNodes.push({
1271
+ kind: 'property',
1272
+ name: 'StateFlow',
1273
+ lines: [lineNumber],
1274
+ });
1275
+ hasExposedState = true;
1276
+ }
1277
+ if (/\basStateFlow\b/.test(sanitizedLine)) {
1278
+ relatedNodes.push({
1279
+ kind: 'call',
1280
+ name: 'asStateFlow',
1281
+ lines: [lineNumber],
1282
+ });
1283
+ hasExposedState = true;
1284
+ }
1285
+ }
1286
+
1287
+ if (!hasMutableState || !hasExposedState) {
1288
+ continue;
1289
+ }
1290
+
1291
+ return {
1292
+ primary_node: {
1293
+ kind: 'class',
1294
+ name: declaration.name,
1295
+ lines: [declaration.line],
1296
+ },
1297
+ related_nodes: relatedNodes,
1298
+ why: `${declaration.name} mantiene una sola fuente de verdad en el ViewModel y expone el estado mutable como StateFlow inmutable para la UI.`,
1299
+ impact:
1300
+ 'La UI observa un estado único, estable y predecible en lugar de duplicar estado o mezclar orígenes distintos de verdad.',
1301
+ expected_fix:
1302
+ 'Mantén un MutableStateFlow privado, expón StateFlow inmutable y actualiza el estado únicamente desde el ViewModel.',
1303
+ lines: sortedUniqueLines([
1304
+ declaration.line,
1305
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1306
+ ]),
1307
+ };
1308
+ }
1309
+
1310
+ return undefined;
1311
+ };
1312
+
1313
+ export const hasAndroidSingleSourceOfTruthUsage = (source: string): boolean => {
1314
+ return findAndroidSingleSourceOfTruthMatch(source) !== undefined;
1315
+ };
1316
+
1317
+ export const findAndroidSkipRecompositionMatch = (
1318
+ source: string
1319
+ ): KotlinAndroidSkipRecompositionMatch | undefined => {
1320
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
1321
+ const stableMarkerPattern =
1322
+ /(?:@Stable\b|@Immutable\b|\bImmutable(?:List|Set|Map)\b|\bPersistent(?:List|Set|Map)\b|\bkotlinx\.collections\.immutable\b)/;
1323
+ const lines = source.split(/\r?\n/);
1324
+
1325
+ for (const composableNode of composableNodes) {
1326
+ const startLine = composableNode.lines[0] ?? 1;
1327
+ const endLine = Math.min(lines.length, startLine + 5);
1328
+ const markerLines = collectKotlinRegexLinesInRange(source, stableMarkerPattern, startLine, endLine);
1329
+
1330
+ if (markerLines.length === 0) {
1331
+ continue;
1332
+ }
1333
+
1334
+ const relatedNodes = markerLines.map((lineNumber) => {
1335
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[lineNumber - 1] ?? '');
1336
+ const markerName =
1337
+ sanitizedLine.match(/@Stable\b/)?.[0] ??
1338
+ sanitizedLine.match(/@Immutable\b/)?.[0] ??
1339
+ sanitizedLine.match(/\bImmutable(?:List|Set|Map)\b/)?.[0] ??
1340
+ sanitizedLine.match(/\bPersistent(?:List|Set|Map)\b/)?.[0] ??
1341
+ sanitizedLine.match(/\bkotlinx\.collections\.immutable\b/)?.[0] ??
1342
+ 'stable';
1343
+
1344
+ return {
1345
+ kind: 'member' as const,
1346
+ name: markerName,
1347
+ lines: [lineNumber],
1348
+ };
1349
+ });
1350
+
1351
+ return {
1352
+ primary_node: composableNode,
1353
+ related_nodes: relatedNodes,
1354
+ why:
1355
+ `${composableNode.name} usa parámetros inmutables o estables para permitir que Compose salte recomposiciones innecesarias.`,
1356
+ impact:
1357
+ 'La UI evita trabajo repetido cuando los parámetros son estables y Compose puede reutilizar el árbol sin recalcular todo el composable.',
1358
+ expected_fix:
1359
+ 'Define parámetros estables o inmutables para los composables, usa colecciones inmutables y marca modelos con @Stable o @Immutable cuando corresponda.',
1360
+ lines: sortedUniqueLines([
1361
+ ...composableNode.lines,
1362
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1363
+ ]),
1364
+ };
1365
+ }
1366
+
1367
+ return undefined;
1368
+ };
1369
+
1370
+ export const hasAndroidSkipRecompositionUsage = (source: string): boolean => {
1371
+ return findAndroidSkipRecompositionMatch(source) !== undefined;
1372
+ };
1373
+
1374
+ type KotlinAndroidStabilityTypeDeclaration = {
1375
+ name: string;
1376
+ line: number;
1377
+ };
1378
+
1379
+ const collectAndroidStabilityTypeDeclarations = (
1380
+ source: string
1381
+ ): readonly KotlinAndroidStabilityTypeDeclaration[] => {
1382
+ const lines = source.split(/\r?\n/);
1383
+ const declarations: KotlinAndroidStabilityTypeDeclaration[] = [];
1384
+ const seen = new Set<string>();
1385
+ const annotationPattern = /@(Stable|Immutable)\b/;
1386
+ const typePattern =
1387
+ /^\s*(?:internal\s+|private\s+|public\s+)?(?:data\s+|sealed\s+|open\s+|abstract\s+)?(?:class|object)\s+([A-Za-z_][A-Za-z0-9_]*)\b/;
1388
+
1389
+ for (let index = 0; index < lines.length; index += 1) {
1390
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? '');
1391
+ if (!annotationPattern.test(sanitizedLine)) {
1392
+ continue;
1393
+ }
1394
+
1395
+ for (let cursor = index; cursor < Math.min(lines.length, index + 5); cursor += 1) {
1396
+ const candidateLine = stripKotlinLineForSemanticScan(lines[cursor] ?? '');
1397
+ const candidateTypeLine = candidateLine.replace(/^(?:@\w+(?:\([^)]*\))?\s*)+/, '');
1398
+ const typeMatch = candidateTypeLine.match(typePattern);
1399
+ if (!typeMatch?.[1]) {
1400
+ continue;
1401
+ }
1402
+
1403
+ const key = `${typeMatch[1]}:${cursor + 1}`;
1404
+ if (seen.has(key)) {
1405
+ break;
1406
+ }
1407
+
1408
+ seen.add(key);
1409
+ declarations.push({
1410
+ name: typeMatch[1],
1411
+ line: cursor + 1,
1412
+ });
1413
+ break;
1414
+ }
1415
+ }
1416
+
1417
+ return declarations;
1418
+ };
1419
+
1420
+ export const findAndroidStabilityMatch = (
1421
+ source: string
1422
+ ): KotlinAndroidStabilityMatch | undefined => {
1423
+ const stableTypeDeclarations = collectAndroidStabilityTypeDeclarations(source);
1424
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
1425
+ const lines = source.split(/\r?\n/);
1426
+
1427
+ for (const composableNode of composableNodes) {
1428
+ const startLine = composableNode.lines[0] ?? 1;
1429
+ const endLine = Math.min(lines.length, startLine + 5);
1430
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
1431
+
1432
+ for (const stableTypeDeclaration of stableTypeDeclarations) {
1433
+ const stableTypeLines = collectKotlinRegexLinesInRange(
1434
+ source,
1435
+ new RegExp(`\\b${escapeRegex(stableTypeDeclaration.name)}\\b`),
1436
+ startLine,
1437
+ endLine
1438
+ );
1439
+
1440
+ if (stableTypeLines.length === 0) {
1441
+ continue;
1442
+ }
1443
+
1444
+ relatedNodes.push({
1445
+ kind: 'class',
1446
+ name: stableTypeDeclaration.name,
1447
+ lines: [stableTypeDeclaration.line],
1448
+ });
1449
+ }
1450
+
1451
+ if (relatedNodes.length === 0) {
1452
+ continue;
1453
+ }
1454
+
1455
+ return {
1456
+ primary_node: composableNode,
1457
+ related_nodes: relatedNodes,
1458
+ why:
1459
+ `${composableNode.name} recibe tipos marcados como estables o inmutables y Compose puede tratar sus parámetros como una entrada más estable para recomponer menos.`,
1460
+ impact:
1461
+ 'La UI reduce recomposiciones evitables cuando los modelos de entrada ya comunican estabilidad a Compose.',
1462
+ expected_fix:
1463
+ 'Marca los modelos de UI como @Stable o @Immutable cuando su semántica lo permita y pásalos como parámetros del composable.',
1464
+ lines: sortedUniqueLines([
1465
+ ...composableNode.lines,
1466
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1467
+ ]),
1468
+ };
1469
+ }
1470
+
1471
+ return undefined;
1472
+ };
1473
+
1474
+ export const hasAndroidStabilityUsage = (source: string): boolean => {
1475
+ return findAndroidStabilityMatch(source) !== undefined;
1476
+ };
1477
+
1478
+ const collectAndroidSharedFlowNodes = (
1479
+ source: string
1480
+ ): readonly KotlinSemanticNodeMatch[] => {
1481
+ const sharedFlowPatterns: ReadonlyArray<{ name: string; regex: RegExp }> = [
1482
+ { name: 'MutableSharedFlow', regex: /\bMutableSharedFlow\b/ },
1483
+ { name: 'SharedFlow', regex: /\bSharedFlow\b/ },
1484
+ { name: 'asSharedFlow', regex: /\basSharedFlow\b/ },
1485
+ { name: 'tryEmit', regex: /\btryEmit\b/ },
1486
+ ];
1487
+
1488
+ const nodes: KotlinSemanticNodeMatch[] = [];
1489
+ for (const sharedFlowPattern of sharedFlowPatterns) {
1490
+ const lines = collectKotlinRegexLines(source, sharedFlowPattern.regex);
1491
+ if (lines.length === 0) {
1492
+ continue;
1493
+ }
1494
+ nodes.push({
1495
+ kind: 'call',
1496
+ name: sharedFlowPattern.name,
1497
+ lines: [lines[0] ?? 1],
1498
+ });
1499
+ }
1500
+
1501
+ return nodes;
1502
+ };
1503
+
1504
+ export const findAndroidSharedFlowMatch = (
1505
+ source: string
1506
+ ): KotlinAndroidSharedFlowMatch | undefined => {
1507
+ const relatedNodes = collectAndroidSharedFlowNodes(source);
1508
+ const [primaryNode, ...restNodes] = relatedNodes;
1509
+
1510
+ if (!primaryNode) {
1511
+ return undefined;
1512
+ }
1513
+
1514
+ return {
1515
+ primary_node: primaryNode,
1516
+ related_nodes: restNodes,
1517
+ why:
1518
+ `${primaryNode.name} modela un SharedFlow para eventos en lugar de reemitir estado estable dentro de la UI.`,
1519
+ impact:
1520
+ 'Los eventos quedan como un stream explícito que puede observarse desde ViewModel o Compose sin depender de callbacks sueltos.',
1521
+ expected_fix:
1522
+ 'Usa MutableSharedFlow/SharedFlow para eventos efímeros y expón el stream de forma inmutable cuando corresponda.',
1523
+ lines: sortedUniqueLines(relatedNodes.flatMap((node) => [...node.lines])),
1524
+ };
1525
+ };
1526
+
1527
+ export const hasAndroidSharedFlowUsage = (source: string): boolean => {
1528
+ return findAndroidSharedFlowMatch(source) !== undefined;
1529
+ };
1530
+
1531
+ const collectAndroidFlowBuilderNodes = (
1532
+ source: string
1533
+ ): readonly KotlinSemanticNodeMatch[] => {
1534
+ const builderPatterns: ReadonlyArray<{ name: string; regex: RegExp }> = [
1535
+ { name: 'flow { emit() }', regex: /\bflow\s*\{/ },
1536
+ { name: 'flowOf', regex: /\bflowOf\s*\(/ },
1537
+ { name: 'asFlow', regex: /\basFlow\s*\(/ },
1538
+ ];
1539
+
1540
+ const nodes: KotlinSemanticNodeMatch[] = [];
1541
+ for (const builderPattern of builderPatterns) {
1542
+ const lines = collectKotlinRegexLines(source, builderPattern.regex);
1543
+ if (lines.length === 0) {
1544
+ continue;
1545
+ }
1546
+ nodes.push({
1547
+ kind: 'call',
1548
+ name: builderPattern.name,
1549
+ lines: [lines[0] ?? 1],
1550
+ });
1551
+ }
1552
+
1553
+ return nodes;
1554
+ };
1555
+
1556
+ export const findAndroidFlowBuilderMatch = (
1557
+ source: string
1558
+ ): KotlinAndroidFlowBuilderMatch | undefined => {
1559
+ const relatedNodes = collectAndroidFlowBuilderNodes(source);
1560
+ const [primaryNode, ...restNodes] = relatedNodes;
1561
+
1562
+ if (!primaryNode) {
1563
+ return undefined;
1564
+ }
1565
+
1566
+ return {
1567
+ primary_node: primaryNode,
1568
+ related_nodes: restNodes,
1569
+ why:
1570
+ `${primaryNode.name} construye un Flow en lugar de materializar datos de forma imperativa dentro de la capa Android.`,
1571
+ impact:
1572
+ 'El flujo queda modelado de forma declarativa, más fácil de componer, testear y observar desde la UI o el ViewModel.',
1573
+ expected_fix:
1574
+ 'Usa builders de Flow como flow { emit(...) }, flowOf(...) o asFlow() para exponer streams explícitos y testeables.',
1575
+ lines: sortedUniqueLines(relatedNodes.flatMap((node) => [...node.lines])),
1576
+ };
1577
+ };
1578
+
1579
+ export const hasAndroidFlowBuilderUsage = (source: string): boolean => {
1580
+ return findAndroidFlowBuilderMatch(source) !== undefined;
1581
+ };
1582
+
1583
+ const collectAndroidFlowCollectNodes = (
1584
+ source: string
1585
+ ): readonly KotlinSemanticNodeMatch[] => {
1586
+ const collectPatterns: ReadonlyArray<{ name: string; regex: RegExp }> = [
1587
+ { name: 'collect', regex: /\bcollect\s*(?:<[^>\n]+>\s*)?\{/ },
1588
+ { name: 'collectLatest', regex: /\bcollectLatest\s*(?:<[^>\n]+>\s*)?\{/ },
1589
+ { name: 'launchIn', regex: /\blaunchIn\s*\(/ },
1590
+ ];
1591
+
1592
+ const sourceLines = source.split(/\r?\n/);
1593
+ const nodes: KotlinSemanticNodeMatch[] = [];
1594
+
1595
+ for (const collectPattern of collectPatterns) {
1596
+ const lines = collectKotlinRegexLines(source, collectPattern.regex);
1597
+ if (lines.length === 0) {
1598
+ continue;
1599
+ }
1600
+
1601
+ const matchedLine = lines.find((lineNumber) => {
1602
+ const sanitizedLine = stripKotlinLineForSemanticScan(sourceLines[lineNumber - 1] ?? '');
1603
+ return collectPattern.regex.test(sanitizedLine);
1604
+ });
1605
+
1606
+ if (!matchedLine) {
1607
+ continue;
1608
+ }
1609
+
1610
+ nodes.push({
1611
+ kind: 'call',
1612
+ name: collectPattern.name,
1613
+ lines: [matchedLine],
1614
+ });
1615
+ }
1616
+
1617
+ return nodes;
1618
+ };
1619
+
1620
+ export const findAndroidFlowCollectMatch = (
1621
+ source: string
1622
+ ): KotlinAndroidFlowCollectMatch | undefined => {
1623
+ const relatedNodes = collectAndroidFlowCollectNodes(source);
1624
+ const [primaryNode, ...restNodes] = relatedNodes;
1625
+
1626
+ if (!primaryNode) {
1627
+ return undefined;
1628
+ }
1629
+
1630
+ return {
1631
+ primary_node: primaryNode,
1632
+ related_nodes: restNodes,
1633
+ why:
1634
+ `${primaryNode.name} consume un Flow en lugar de dejarlo como stream no observado dentro de la capa Android.`,
1635
+ impact:
1636
+ 'El flujo pasa a tener un consumidor terminal explícito, lo que evita streams huérfanos y hace visible el punto de observación.',
1637
+ expected_fix:
1638
+ 'Consume el Flow con collect, collectLatest o launchIn desde la capa adecuada y mantén el terminal operator cerca del owner correcto.',
1639
+ lines: sortedUniqueLines(relatedNodes.flatMap((node) => [...node.lines])),
1640
+ };
1641
+ };
1642
+
1643
+ export const hasAndroidFlowCollectUsage = (source: string): boolean => {
1644
+ return findAndroidFlowCollectMatch(source) !== undefined;
1645
+ };
1646
+
1647
+ const collectAndroidCollectAsStateNodes = (
1648
+ source: string
1649
+ ): readonly KotlinSemanticNodeMatch[] => {
1650
+ const collectPatterns: ReadonlyArray<{ name: string; regex: RegExp }> = [
1651
+ { name: 'collectAsState', regex: /\bcollectAsState\s*\(/ },
1652
+ { name: 'collectAsStateWithLifecycle', regex: /\bcollectAsStateWithLifecycle\s*\(/ },
1653
+ ];
1654
+
1655
+ const nodes: KotlinSemanticNodeMatch[] = [];
1656
+ for (const collectPattern of collectPatterns) {
1657
+ const lines = collectKotlinRegexLines(source, collectPattern.regex);
1658
+ if (lines.length === 0) {
1659
+ continue;
1660
+ }
1661
+ nodes.push({
1662
+ kind: 'call',
1663
+ name: collectPattern.name,
1664
+ lines: [lines[0] ?? 1],
1665
+ });
1666
+ }
1667
+
1668
+ return nodes;
1669
+ };
1670
+
1671
+ export const findAndroidCollectAsStateMatch = (
1672
+ source: string
1673
+ ): KotlinAndroidCollectAsStateMatch | undefined => {
1674
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
1675
+ const lines = source.split(/\r?\n/);
1676
+
1677
+ for (const composableNode of composableNodes) {
1678
+ const startLine = composableNode.lines[0] ?? 1;
1679
+ const endLine = Math.min(lines.length, startLine + 80);
1680
+ const relatedNodes = collectAndroidCollectAsStateNodes(source).filter((node) =>
1681
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
1682
+ );
1683
+
1684
+ if (relatedNodes.length === 0) {
1685
+ continue;
1686
+ }
1687
+
1688
+ return {
1689
+ primary_node: composableNode,
1690
+ related_nodes: relatedNodes,
1691
+ why: `${composableNode.name} consume un Flow con collectAsState dentro de Compose para mantener la UI observando estado reactivo explícito.`,
1692
+ impact:
1693
+ 'La UI Compose obtiene una representación estable del Flow y el estado deja de depender de callbacks o de observación manual fuera del composable.',
1694
+ expected_fix:
1695
+ 'Usa collectAsState o collectAsStateWithLifecycle dentro del composable adecuado para convertir el Flow en estado observable de UI.',
1696
+ lines: sortedUniqueLines([
1697
+ ...(composableNode.lines ?? []),
1698
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1699
+ ]),
1700
+ };
1701
+ }
1702
+
1703
+ return undefined;
1704
+ };
1705
+
1706
+ export const hasAndroidCollectAsStateUsage = (source: string): boolean => {
1707
+ return findAndroidCollectAsStateMatch(source) !== undefined;
1708
+ };
1709
+
1710
+ const collectAndroidRememberNodes = (
1711
+ source: string
1712
+ ): readonly KotlinSemanticNodeMatch[] => {
1713
+ const lines = collectKotlinRegexLines(source, /\bremember\s*(?:\(|\{)/);
1714
+
1715
+ return lines.map((lineNumber) => ({
1716
+ kind: 'call',
1717
+ name: 'remember',
1718
+ lines: [lineNumber],
1719
+ }));
1720
+ };
1721
+
1722
+ export const findAndroidRememberMatch = (
1723
+ source: string
1724
+ ): KotlinAndroidRememberMatch | undefined => {
1725
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
1726
+ const lines = source.split(/\r?\n/);
1727
+
1728
+ for (const composableNode of composableNodes) {
1729
+ const startLine = composableNode.lines[0] ?? 1;
1730
+ const endLine = Math.min(lines.length, startLine + 80);
1731
+ const relatedNodes = collectAndroidRememberNodes(source).filter((node) =>
1732
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
1733
+ );
1734
+
1735
+ if (relatedNodes.length === 0) {
1736
+ continue;
1737
+ }
1738
+
1739
+ return {
1740
+ primary_node: composableNode,
1741
+ related_nodes: relatedNodes,
1742
+ why: `${composableNode.name} usa remember para evitar recrear objetos o valores en cada recomposición de Compose.`,
1743
+ impact:
1744
+ 'La UI mantiene instancias estables y reduce trabajo repetido al recomponer la pantalla.',
1745
+ expected_fix:
1746
+ 'Usa remember para memoizar objetos o valores costosos dentro del composable y evita reconstruirlos en cada recomposición.',
1747
+ lines: sortedUniqueLines([
1748
+ ...(composableNode.lines ?? []),
1749
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1750
+ ]),
1751
+ };
1752
+ }
1753
+
1754
+ return undefined;
1755
+ };
1756
+
1757
+ export const hasAndroidRememberUsage = (source: string): boolean => {
1758
+ return findAndroidRememberMatch(source) !== undefined;
1759
+ };
1760
+
1761
+ const collectAndroidDerivedStateOfNodes = (
1762
+ source: string
1763
+ ): readonly KotlinSemanticNodeMatch[] => {
1764
+ const lines = collectKotlinRegexLines(source, /\bderivedStateOf\s*(?:\(|\{)/);
1765
+
1766
+ return lines.map((lineNumber) => ({
1767
+ kind: 'call',
1768
+ name: 'derivedStateOf',
1769
+ lines: [lineNumber],
1770
+ }));
1771
+ };
1772
+
1773
+ export const findAndroidDerivedStateOfMatch = (
1774
+ source: string
1775
+ ): KotlinAndroidDerivedStateOfMatch | undefined => {
1776
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
1777
+ const lines = source.split(/\r?\n/);
1778
+
1779
+ for (const composableNode of composableNodes) {
1780
+ const startLine = composableNode.lines[0] ?? 1;
1781
+ const endLine = Math.min(lines.length, startLine + 80);
1782
+ const relatedNodes = collectAndroidDerivedStateOfNodes(source).filter((node) =>
1783
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
1784
+ );
1785
+
1786
+ if (relatedNodes.length === 0) {
1787
+ continue;
1788
+ }
1789
+
1790
+ return {
1791
+ primary_node: composableNode,
1792
+ related_nodes: relatedNodes,
1793
+ why: `${composableNode.name} usa derivedStateOf para recalcular un valor caro solo cuando cambia la entrada observada.`,
1794
+ impact:
1795
+ 'La UI Compose evita recomputaciones innecesarias y mantiene el cálculo derivado sincronizado con el estado de origen.',
1796
+ expected_fix:
1797
+ 'Usa derivedStateOf dentro del composable adecuado para derivar estados costosos a partir de la entrada que realmente cambia.',
1798
+ lines: sortedUniqueLines([
1799
+ ...(composableNode.lines ?? []),
1800
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1801
+ ]),
1802
+ };
1803
+ }
1804
+
1805
+ return undefined;
1806
+ };
1807
+
1808
+ export const hasAndroidDerivedStateOfUsage = (source: string): boolean => {
1809
+ return findAndroidDerivedStateOfMatch(source) !== undefined;
1810
+ };
1811
+
1812
+ const collectAndroidLaunchedEffectNodes = (
1813
+ source: string
1814
+ ): readonly KotlinSemanticNodeMatch[] => {
1815
+ const lines = collectKotlinRegexLines(source, /\bLaunchedEffect\s*\([^,\n)]*\)/);
1816
+
1817
+ return lines.map((lineNumber) => ({
1818
+ kind: 'call',
1819
+ name: 'LaunchedEffect',
1820
+ lines: [lineNumber],
1821
+ }));
1822
+ };
1823
+
1824
+ export const findAndroidLaunchedEffectMatch = (
1825
+ source: string
1826
+ ): KotlinAndroidLaunchedEffectMatch | undefined => {
1827
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
1828
+ const lines = source.split(/\r?\n/);
1829
+
1830
+ for (const composableNode of composableNodes) {
1831
+ const startLine = composableNode.lines[0] ?? 1;
1832
+ const endLine = Math.min(lines.length, startLine + 80);
1833
+ const relatedNodes = collectAndroidLaunchedEffectNodes(source).filter((node) =>
1834
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
1835
+ );
1836
+
1837
+ if (relatedNodes.length === 0) {
1838
+ continue;
1839
+ }
1840
+
1841
+ return {
1842
+ primary_node: composableNode,
1843
+ related_nodes: relatedNodes,
1844
+ why: `${composableNode.name} usa LaunchedEffect para ejecutar side effects acotados al lifecycle de Compose con claves explícitas.`,
1845
+ impact:
1846
+ 'El efecto queda cancelado y relanzado por Compose de forma controlada, evitando trabajo de UI fuera del árbol de composición.',
1847
+ expected_fix:
1848
+ 'Usa LaunchedEffect dentro del composable adecuado para ejecutar side effects ligados al lifecycle y a claves estables.',
1849
+ lines: sortedUniqueLines([
1850
+ ...(composableNode.lines ?? []),
1851
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1852
+ ]),
1853
+ };
1854
+ }
1855
+
1856
+ return undefined;
1857
+ };
1858
+
1859
+ export const hasAndroidLaunchedEffectUsage = (source: string): boolean => {
1860
+ return findAndroidLaunchedEffectMatch(source) !== undefined;
1861
+ };
1862
+
1863
+ const collectAndroidLaunchedEffectKeysNodes = (
1864
+ source: string
1865
+ ): readonly KotlinSemanticNodeMatch[] => {
1866
+ const lines = collectKotlinRegexLines(source, /\bLaunchedEffect\s*\([^)\n]*,[^)\n]*\)/);
1867
+
1868
+ return lines.map((lineNumber) => ({
1869
+ kind: 'call',
1870
+ name: 'LaunchedEffect keys',
1871
+ lines: [lineNumber],
1872
+ }));
1873
+ };
1874
+
1875
+ export const findAndroidLaunchedEffectKeysMatch = (
1876
+ source: string
1877
+ ): KotlinAndroidLaunchedEffectKeysMatch | undefined => {
1878
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
1879
+ const lines = source.split(/\r?\n/);
1880
+
1881
+ for (const composableNode of composableNodes) {
1882
+ const startLine = composableNode.lines[0] ?? 1;
1883
+ const endLine = Math.min(lines.length, startLine + 80);
1884
+ const relatedNodes = collectAndroidLaunchedEffectKeysNodes(source).filter((node) =>
1885
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
1886
+ );
1887
+
1888
+ if (relatedNodes.length === 0) {
1889
+ continue;
1890
+ }
1891
+
1892
+ return {
1893
+ primary_node: composableNode,
1894
+ related_nodes: relatedNodes,
1895
+ why: `${composableNode.name} usa LaunchedEffect con keys para controlar cuándo se relanza el efecto en Compose.`,
1896
+ impact:
1897
+ 'Las keys determinan el ciclo de relanzado del efecto y evitan ejecuciones inesperadas cuando cambia el input correcto.',
1898
+ expected_fix:
1899
+ 'Usa claves estables y explícitas en LaunchedEffect para relanzar el side effect solo cuando cambie la entrada esperada.',
1900
+ lines: sortedUniqueLines([
1901
+ ...(composableNode.lines ?? []),
1902
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1903
+ ]),
1904
+ };
1905
+ }
1906
+
1907
+ return undefined;
1908
+ };
1909
+
1910
+ export const hasAndroidLaunchedEffectKeysUsage = (source: string): boolean => {
1911
+ return findAndroidLaunchedEffectKeysMatch(source) !== undefined;
1912
+ };
1913
+
1914
+ const collectAndroidDisposableEffectNodes = (
1915
+ source: string
1916
+ ): readonly KotlinSemanticNodeMatch[] => {
1917
+ const lines = collectKotlinRegexLines(source, /\bDisposableEffect\s*\([^)\n]*\)/);
1918
+
1919
+ return lines.map((lineNumber) => ({
1920
+ kind: 'call',
1921
+ name: 'DisposableEffect',
1922
+ lines: [lineNumber],
1923
+ }));
1924
+ };
1925
+
1926
+ export const findAndroidDisposableEffectMatch = (
1927
+ source: string
1928
+ ): KotlinAndroidDisposableEffectMatch | undefined => {
1929
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
1930
+ const lines = source.split(/\r?\n/);
1931
+
1932
+ for (const composableNode of composableNodes) {
1933
+ const startLine = composableNode.lines[0] ?? 1;
1934
+ const endLine = Math.min(lines.length, startLine + 80);
1935
+ const relatedNodes = collectAndroidDisposableEffectNodes(source).filter((node) =>
1936
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
1937
+ );
1938
+
1939
+ if (relatedNodes.length === 0) {
1940
+ continue;
1941
+ }
1942
+
1943
+ return {
1944
+ primary_node: composableNode,
1945
+ related_nodes: relatedNodes,
1946
+ why: `${composableNode.name} usa DisposableEffect para limpiar recursos y observadores cuando sale de composición.`,
1947
+ impact:
1948
+ 'El efecto libera sus recursos al abandonar el árbol de Compose y evita fugas de suscripciones o listeners.',
1949
+ expected_fix:
1950
+ 'Usa DisposableEffect dentro del composable adecuado para registrar y limpiar recursos ligados al lifecycle de Compose.',
1951
+ lines: sortedUniqueLines([
1952
+ ...(composableNode.lines ?? []),
1953
+ ...relatedNodes.flatMap((node) => [...node.lines]),
1954
+ ]),
1955
+ };
1956
+ }
1957
+
1958
+ return undefined;
1959
+ };
1960
+
1961
+ export const hasAndroidDisposableEffectUsage = (source: string): boolean => {
1962
+ return findAndroidDisposableEffectMatch(source) !== undefined;
1963
+ };
1964
+
1965
+ const collectAndroidPreviewNodes = (
1966
+ source: string,
1967
+ startLine: number,
1968
+ endLine: number
1969
+ ): readonly KotlinSemanticNodeMatch[] => {
1970
+ const lines = collectKotlinRegexLinesInRange(source, /@Preview\b/, startLine, endLine);
1971
+
1972
+ return lines.map((lineNumber) => ({
1973
+ kind: 'call',
1974
+ name: '@Preview',
1975
+ lines: [lineNumber],
1976
+ }));
1977
+ };
1978
+
1979
+ export const findAndroidPreviewMatch = (
1980
+ source: string
1981
+ ): KotlinAndroidPreviewMatch | undefined => {
1982
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
1983
+ const lines = source.split(/\r?\n/);
1984
+
1985
+ for (const composableNode of composableNodes) {
1986
+ const startLine = composableNode.lines[0] ?? 1;
1987
+ const windowStart = Math.max(1, startLine - 3);
1988
+ const endLine = Math.min(lines.length, startLine + 80);
1989
+ const relatedNodes = collectAndroidPreviewNodes(source, windowStart, endLine).filter((node) =>
1990
+ node.lines.some((lineNumber) => lineNumber >= windowStart && lineNumber <= endLine)
1991
+ );
1992
+
1993
+ if (relatedNodes.length === 0) {
1994
+ continue;
1995
+ }
1996
+
1997
+ return {
1998
+ primary_node: composableNode,
1999
+ related_nodes: relatedNodes,
2000
+ why: `${composableNode.name} expone una vista previa con @Preview para inspeccionar la UI sin ejecutar la app.`,
2001
+ impact:
2002
+ 'La pantalla puede renderizarse en Android Studio y validar el layout de Compose sin levantar el runtime completo.',
2003
+ expected_fix:
2004
+ 'Anota un composable representativo con @Preview para revisar la UI en el editor o extrae un wrapper preview dedicado.',
2005
+ lines: sortedUniqueLines([
2006
+ ...(composableNode.lines ?? []),
2007
+ ...relatedNodes.flatMap((node) => [...node.lines]),
2008
+ ]),
2009
+ };
2010
+ }
2011
+
2012
+ return undefined;
2013
+ };
2014
+
2015
+ export const hasAndroidPreviewUsage = (source: string): boolean => {
2016
+ return findAndroidPreviewMatch(source) !== undefined;
2017
+ };
2018
+
2019
+ const collectAndroidAdaptiveLayoutNodes = (
2020
+ source: string
2021
+ ): readonly KotlinSemanticNodeMatch[] => {
2022
+ const patterns: ReadonlyArray<{ kind: KotlinSemanticNodeMatch['kind']; name: string; regex: RegExp }> = [
2023
+ { kind: 'call', name: 'calculateWindowSizeClass', regex: /\bcalculateWindowSizeClass\s*\(/ },
2024
+ { kind: 'member', name: 'WindowSizeClass', regex: /\bWindowSizeClass\b/ },
2025
+ { kind: 'member', name: 'WindowWidthSizeClass', regex: /\bWindowWidthSizeClass\b/ },
2026
+ { kind: 'member', name: 'WindowHeightSizeClass', regex: /\bWindowHeightSizeClass\b/ },
2027
+ ];
2028
+
2029
+ const nodes: KotlinSemanticNodeMatch[] = [];
2030
+
2031
+ for (const pattern of patterns) {
2032
+ const lines = collectKotlinRegexLines(source, pattern.regex);
2033
+ if (lines.length === 0) {
2034
+ continue;
2035
+ }
2036
+
2037
+ nodes.push({
2038
+ kind: pattern.kind,
2039
+ name: pattern.name,
2040
+ lines,
2041
+ });
2042
+ }
2043
+
2044
+ return nodes;
2045
+ };
2046
+
2047
+ export const findAndroidAdaptiveLayoutsMatch = (
2048
+ source: string
2049
+ ): KotlinAndroidAdaptiveLayoutsMatch | undefined => {
2050
+ const adaptiveLayoutNodes = collectAndroidAdaptiveLayoutNodes(source);
2051
+ const hasWindowSizeClassContext = adaptiveLayoutNodes.some((node) =>
2052
+ node.name === 'calculateWindowSizeClass' || node.name === 'WindowSizeClass'
2053
+ );
2054
+ const hasResponsiveBranches = adaptiveLayoutNodes.some(
2055
+ (node) => node.name === 'WindowWidthSizeClass' || node.name === 'WindowHeightSizeClass'
2056
+ );
2057
+
2058
+ if (!hasWindowSizeClassContext || !hasResponsiveBranches) {
2059
+ return undefined;
2060
+ }
2061
+
2062
+ const [primaryNode, ...relatedNodes] = adaptiveLayoutNodes;
2063
+ if (!primaryNode) {
2064
+ return undefined;
2065
+ }
2066
+
2067
+ return {
2068
+ primary_node: primaryNode,
2069
+ related_nodes: relatedNodes,
2070
+ why:
2071
+ 'La UI usa WindowSizeClass para adaptar el layout a tamaños compact, medium y expanded en lugar de fijar una sola composición.',
2072
+ impact:
2073
+ 'La interfaz responde al tamaño disponible y evita layouts rígidos que se rompen en pantallas grandes o pequeñas.',
2074
+ expected_fix:
2075
+ 'Usa calculateWindowSizeClass junto con WindowWidthSizeClass o WindowHeightSizeClass para adaptar la composición al tamaño disponible.',
2076
+ lines: sortedUniqueLines(adaptiveLayoutNodes.flatMap((node) => [...node.lines])),
2077
+ };
2078
+ };
2079
+
2080
+ export const hasAndroidAdaptiveLayoutsUsage = (source: string): boolean => {
2081
+ return findAndroidAdaptiveLayoutsMatch(source) !== undefined;
2082
+ };
2083
+
2084
+ const collectAndroidGradleDependencyLines = (source: string): readonly number[] => {
2085
+ const lines = source.split(/\r?\n/);
2086
+ const matches: number[] = [];
2087
+ let insideDependenciesBlock = false;
2088
+ let braceDepth = 0;
2089
+
2090
+ for (let index = 0; index < lines.length; index += 1) {
2091
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? '').trim();
2092
+ if (sanitizedLine.length === 0) {
2093
+ continue;
2094
+ }
2095
+
2096
+ if (!insideDependenciesBlock && /\bdependencies\s*\{/.test(sanitizedLine)) {
2097
+ insideDependenciesBlock = true;
2098
+ braceDepth = countTokenOccurrences(sanitizedLine, '{') - countTokenOccurrences(sanitizedLine, '}');
2099
+ matches.push(index + 1);
2100
+ continue;
2101
+ }
2102
+
2103
+ if (!insideDependenciesBlock) {
2104
+ continue;
2105
+ }
2106
+
2107
+ if (
2108
+ /^(?:implementation|api|kapt|compileOnly|runtimeOnly|debugImplementation|releaseImplementation|testImplementation|androidTestImplementation)\b/.test(
2109
+ sanitizedLine
2110
+ )
2111
+ ) {
2112
+ matches.push(index + 1);
2113
+ }
2114
+
2115
+ braceDepth += countTokenOccurrences(sanitizedLine, '{');
2116
+ braceDepth -= countTokenOccurrences(sanitizedLine, '}');
2117
+ if (braceDepth <= 0) {
2118
+ break;
2119
+ }
2120
+ }
2121
+
2122
+ return sortedUniqueLines(matches);
2123
+ };
2124
+
2125
+ export const findAndroidExistingStructureMatch = (
2126
+ source: string
2127
+ ): KotlinAndroidExistingStructureMatch | undefined => {
2128
+ const gradleDependencyLines = collectAndroidGradleDependencyLines(source);
2129
+ if (gradleDependencyLines.length > 0) {
2130
+ const [primaryLine, ...dependencyLines] = gradleDependencyLines;
2131
+ if (!primaryLine) {
2132
+ return undefined;
2133
+ }
2134
+
2135
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
2136
+ {
2137
+ kind: 'member',
2138
+ name: 'dependencies block',
2139
+ lines: [primaryLine],
2140
+ },
2141
+ ...dependencyLines.map((line) => ({
2142
+ kind: 'member' as const,
2143
+ name: 'dependency declaration',
2144
+ lines: [line],
2145
+ })),
2146
+ ];
2147
+
2148
+ return {
2149
+ primary_node: relatedNodes[0] ?? {
2150
+ kind: 'member',
2151
+ name: 'dependencies block',
2152
+ lines: [primaryLine],
2153
+ },
2154
+ related_nodes: relatedNodes.slice(1),
2155
+ why:
2156
+ 'El build Gradle expone la estructura real de dependencias del proyecto Android y conviene revisarla antes de introducir cambios arquitectónicos.',
2157
+ impact:
2158
+ 'Si se ignora la estructura de dependencias, se duplican configuraciones, se rompen boundaries y se vuelve más difícil alinear los módulos existentes.',
2159
+ expected_fix:
2160
+ 'Revisa primero el bloque dependencies y el catálogo Gradle para entender qué módulos y dependencias ya existen antes de añadir nuevas piezas.',
2161
+ lines: gradleDependencyLines,
2162
+ };
2163
+ }
2164
+
2165
+ const interfaceDeclarations = parseKotlinInterfaceDeclarations(source);
2166
+ const moduleAnnotationLines = collectKotlinRegexLines(source, /@(Module|InstallIn)\b/);
2167
+ if (interfaceDeclarations.length === 0 || moduleAnnotationLines.length === 0) {
2168
+ return undefined;
2169
+ }
2170
+
2171
+ const [primaryDeclaration, ...otherDeclarations] = interfaceDeclarations;
2172
+ if (!primaryDeclaration) {
2173
+ return undefined;
2174
+ }
2175
+
2176
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
2177
+ {
2178
+ kind: 'member',
2179
+ name: `interface declaration: ${primaryDeclaration.name}`,
2180
+ lines: [primaryDeclaration.line],
2181
+ },
2182
+ ...otherDeclarations.map((declaration) => ({
2183
+ kind: 'member' as const,
2184
+ name: `interface declaration: ${declaration.name}`,
2185
+ lines: [declaration.line],
2186
+ })),
2187
+ ...moduleAnnotationLines.map((line) => ({
2188
+ kind: 'member' as const,
2189
+ name: 'module annotation',
2190
+ lines: [line],
2191
+ })),
2192
+ ];
2193
+
2194
+ return {
2195
+ primary_node: relatedNodes[0] ?? {
2196
+ kind: 'member',
2197
+ name: `interface declaration: ${primaryDeclaration.name}`,
2198
+ lines: [primaryDeclaration.line],
2199
+ },
2200
+ related_nodes: relatedNodes.slice(1),
2201
+ why:
2202
+ 'El código Android combina contratos de interfaz, módulos Hilt y dependencias explícitas, así que conviene inspeccionar la estructura existente antes de cambiarla.',
2203
+ impact:
2204
+ 'Si no se revisan módulos, interfaces y dependencias existentes, es fácil duplicar contratos, acoplar capas o introducir cambios que rompan Gradle o DI.',
2205
+ expected_fix:
2206
+ 'Antes de añadir código nuevo, revisa los módulos, interfaces y dependencias Gradle ya presentes y alinea el cambio con la estructura actual del proyecto.',
2207
+ lines: sortedUniqueLines([
2208
+ primaryDeclaration.line,
2209
+ ...otherDeclarations.map((declaration) => declaration.line),
2210
+ ...moduleAnnotationLines,
2211
+ ]),
2212
+ };
2213
+ };
2214
+
2215
+ export const hasAndroidExistingStructureUsage = (source: string): boolean => {
2216
+ return findAndroidExistingStructureMatch(source) !== undefined;
2217
+ };
2218
+
2219
+ export const findAndroidThemeMatch = (
2220
+ source: string
2221
+ ): KotlinAndroidThemeMatch | undefined => {
2222
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
2223
+ const lines = source.split(/\r?\n/);
2224
+
2225
+ for (const composableNode of composableNodes) {
2226
+ const startLine = composableNode.lines[0] ?? 1;
2227
+ const endLine = Math.min(lines.length, startLine + 80);
2228
+ const materialThemeLines = collectKotlinRegexLinesInRange(
2229
+ source,
2230
+ /\bMaterialTheme\s*\(/,
2231
+ startLine,
2232
+ endLine
2233
+ );
2234
+ if (materialThemeLines.length === 0) {
2235
+ continue;
2236
+ }
2237
+
2238
+ const scanStartLine = materialThemeLines[0] ?? startLine;
2239
+ const colorSchemeLines = collectKotlinRegexLinesInRange(
2240
+ source,
2241
+ /\bcolorScheme\b/,
2242
+ scanStartLine,
2243
+ Math.min(lines.length, scanStartLine + 20)
2244
+ );
2245
+ const typographyLines = collectKotlinRegexLinesInRange(
2246
+ source,
2247
+ /\btypography\b/,
2248
+ scanStartLine,
2249
+ Math.min(lines.length, scanStartLine + 20)
2250
+ );
2251
+ const shapesLines = collectKotlinRegexLinesInRange(
2252
+ source,
2253
+ /\bshapes\b/,
2254
+ scanStartLine,
2255
+ Math.min(lines.length, scanStartLine + 20)
2256
+ );
2257
+
2258
+ if (colorSchemeLines.length === 0 || typographyLines.length === 0 || shapesLines.length === 0) {
2259
+ continue;
2260
+ }
2261
+
2262
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
2263
+ {
2264
+ kind: 'call',
2265
+ name: 'MaterialTheme',
2266
+ lines: materialThemeLines,
2267
+ },
2268
+ {
2269
+ kind: 'property',
2270
+ name: 'colorScheme',
2271
+ lines: colorSchemeLines,
2272
+ },
2273
+ {
2274
+ kind: 'property',
2275
+ name: 'typography',
2276
+ lines: typographyLines,
2277
+ },
2278
+ {
2279
+ kind: 'property',
2280
+ name: 'shapes',
2281
+ lines: shapesLines,
2282
+ },
2283
+ ];
2284
+
2285
+ return {
2286
+ primary_node: composableNode,
2287
+ related_nodes: relatedNodes,
2288
+ why: `${composableNode.name} materializa el tema visual con colorScheme, typography y shapes explícitos en Compose.`,
2289
+ impact:
2290
+ 'El esquema visual queda implícito o repartido y la UI pierde un contrato claro de coherencia y escalabilidad.',
2291
+ expected_fix:
2292
+ 'Centraliza la configuración del tema en un composable dedicado y pasa colorScheme, typography y shapes de forma explícita.',
2293
+ lines: sortedUniqueLines([
2294
+ ...(composableNode.lines ?? []),
2295
+ ...relatedNodes.flatMap((node) => [...node.lines]),
2296
+ ]),
2297
+ };
2298
+ }
2299
+
2300
+ return undefined;
2301
+ };
2302
+
2303
+ export const hasAndroidThemeUsage = (source: string): boolean => {
2304
+ return findAndroidThemeMatch(source) !== undefined;
2305
+ };
2306
+
2307
+ export type KotlinAndroidDarkThemeMatch = {
2308
+ primary_node: KotlinSemanticNodeMatch;
2309
+ related_nodes: readonly KotlinSemanticNodeMatch[];
2310
+ why: string;
2311
+ impact: string;
2312
+ expected_fix: string;
2313
+ lines: readonly number[];
2314
+ };
2315
+
2316
+ export type KotlinAndroidAccessibilityMatch = {
2317
+ primary_node: KotlinSemanticNodeMatch;
2318
+ related_nodes: readonly KotlinSemanticNodeMatch[];
2319
+ why: string;
2320
+ impact: string;
2321
+ expected_fix: string;
2322
+ lines: readonly number[];
2323
+ };
2324
+
2325
+ export type KotlinAndroidContentDescriptionMatch = {
2326
+ primary_node: KotlinSemanticNodeMatch;
2327
+ related_nodes: readonly KotlinSemanticNodeMatch[];
2328
+ why: string;
2329
+ impact: string;
2330
+ expected_fix: string;
2331
+ lines: readonly number[];
2332
+ };
2333
+
2334
+ export const findAndroidDarkThemeMatch = (
2335
+ source: string
2336
+ ): KotlinAndroidDarkThemeMatch | undefined => {
2337
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
2338
+ const lines = source.split(/\r?\n/);
2339
+
2340
+ for (const composableNode of composableNodes) {
2341
+ const startLine = composableNode.lines[0] ?? 1;
2342
+ const endLine = Math.min(lines.length, startLine + 80);
2343
+ const darkThemeLines = collectKotlinRegexLinesInRange(
2344
+ source,
2345
+ /\bisSystemInDarkTheme\s*\(/,
2346
+ startLine,
2347
+ endLine
2348
+ );
2349
+ if (darkThemeLines.length === 0) {
2350
+ continue;
2351
+ }
2352
+
2353
+ const scanStartLine = darkThemeLines[0] ?? startLine;
2354
+ const darkColorSchemeLines = collectKotlinRegexLinesInRange(
2355
+ source,
2356
+ /\bdarkColorScheme\s*\(/,
2357
+ scanStartLine,
2358
+ Math.min(lines.length, scanStartLine + 20)
2359
+ );
2360
+ const lightColorSchemeLines = collectKotlinRegexLinesInRange(
2361
+ source,
2362
+ /\blightColorScheme\s*\(/,
2363
+ scanStartLine,
2364
+ Math.min(lines.length, scanStartLine + 20)
2365
+ );
2366
+ const materialThemeLines = collectKotlinRegexLinesInRange(
2367
+ source,
2368
+ /\bMaterialTheme\s*\(/,
2369
+ scanStartLine,
2370
+ Math.min(lines.length, scanStartLine + 20)
2371
+ );
2372
+
2373
+ if (
2374
+ darkColorSchemeLines.length === 0 ||
2375
+ lightColorSchemeLines.length === 0 ||
2376
+ materialThemeLines.length === 0
2377
+ ) {
2378
+ continue;
2379
+ }
2380
+
2381
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
2382
+ { kind: 'call', name: 'isSystemInDarkTheme', lines: darkThemeLines },
2383
+ { kind: 'call', name: 'darkColorScheme', lines: darkColorSchemeLines },
2384
+ { kind: 'call', name: 'lightColorScheme', lines: lightColorSchemeLines },
2385
+ { kind: 'call', name: 'MaterialTheme', lines: materialThemeLines },
2386
+ ];
2387
+
2388
+ return {
2389
+ primary_node: composableNode,
2390
+ related_nodes: relatedNodes,
2391
+ why: `${composableNode.name} soporta tema oscuro desde el primer día al bifurcar color schemes según isSystemInDarkTheme().`,
2392
+ impact:
2393
+ 'La UI se adapta al sistema desde el primer día y evita una experiencia rota para usuarios con tema oscuro activo.',
2394
+ expected_fix:
2395
+ 'Usa isSystemInDarkTheme() para elegir darkColorScheme() o lightColorScheme() dentro de un composable de tema dedicado.',
2396
+ lines: sortedUniqueLines([
2397
+ ...(composableNode.lines ?? []),
2398
+ ...relatedNodes.flatMap((node) => [...node.lines]),
2399
+ ]),
2400
+ };
2401
+ }
2402
+
2403
+ return undefined;
2404
+ };
2405
+
2406
+ export const hasAndroidDarkThemeUsage = (source: string): boolean => {
2407
+ return findAndroidDarkThemeMatch(source) !== undefined;
2408
+ };
2409
+
2410
+ const collectAndroidAccessibilityNodes = (
2411
+ source: string
2412
+ ): readonly KotlinSemanticNodeMatch[] => {
2413
+ const patterns: ReadonlyArray<{ name: string; regex: RegExp }> = [
2414
+ { name: 'contentDescription', regex: /\bcontentDescription\s*=/ },
2415
+ { name: 'semantics', regex: /\b(?:Modifier\s*\.\s*)?semantics\s*(?:\(|\{)/ },
2416
+ ];
2417
+
2418
+ const sourceLines = source.split(/\r?\n/);
2419
+ const nodes: KotlinSemanticNodeMatch[] = [];
2420
+
2421
+ for (const pattern of patterns) {
2422
+ const lines = collectKotlinRegexLines(source, pattern.regex);
2423
+ if (lines.length === 0) {
2424
+ continue;
2425
+ }
2426
+
2427
+ const matchedLine = lines.find((lineNumber) => {
2428
+ const sanitizedLine = stripKotlinLineForSemanticScan(sourceLines[lineNumber - 1] ?? '');
2429
+ return pattern.regex.test(sanitizedLine);
2430
+ });
2431
+
2432
+ if (!matchedLine) {
2433
+ continue;
2434
+ }
2435
+
2436
+ nodes.push({
2437
+ kind: pattern.name === 'contentDescription' ? 'property' : 'call',
2438
+ name: pattern.name,
2439
+ lines: [matchedLine],
2440
+ });
2441
+ }
2442
+
2443
+ return nodes;
2444
+ };
2445
+
2446
+ export const findAndroidAccessibilityMatch = (
2447
+ source: string
2448
+ ): KotlinAndroidAccessibilityMatch | undefined => {
2449
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
2450
+ const lines = source.split(/\r?\n/);
2451
+
2452
+ for (const composableNode of composableNodes) {
2453
+ const startLine = composableNode.lines[0] ?? 1;
2454
+ const endLine = Math.min(lines.length, startLine + 80);
2455
+ const relatedNodes = collectAndroidAccessibilityNodes(source).filter((node) =>
2456
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
2457
+ );
2458
+
2459
+ if (relatedNodes.length === 0) {
2460
+ continue;
2461
+ }
2462
+
2463
+ return {
2464
+ primary_node: composableNode,
2465
+ related_nodes: relatedNodes,
2466
+ why: `${composableNode.name} expone accesibilidad explícita con contentDescription o semantics en Compose.`,
2467
+ impact:
2468
+ 'La UI proporciona una experiencia accesible para lectores de pantalla y tecnologías de asistencia sin depender de defaults implícitos.',
2469
+ expected_fix:
2470
+ 'Usa contentDescription y/o semantics dentro del composable adecuado para describir la interfaz de forma accesible.',
2471
+ lines: sortedUniqueLines([
2472
+ ...(composableNode.lines ?? []),
2473
+ ...relatedNodes.flatMap((node) => [...node.lines]),
2474
+ ]),
2475
+ };
2476
+ }
2477
+
2478
+ return undefined;
2479
+ };
2480
+
2481
+ export const hasAndroidAccessibilityUsage = (source: string): boolean => {
2482
+ return findAndroidAccessibilityMatch(source) !== undefined;
2483
+ };
2484
+
2485
+ export type KotlinAndroidTalkBackMatch = KotlinAndroidAccessibilityMatch;
2486
+
2487
+ export const findAndroidTalkBackMatch = (
2488
+ source: string
2489
+ ): KotlinAndroidTalkBackMatch | undefined => {
2490
+ return findAndroidAccessibilityMatch(source);
2491
+ };
2492
+
2493
+ export const hasAndroidTalkBackUsage = (source: string): boolean => {
2494
+ return findAndroidTalkBackMatch(source) !== undefined;
2495
+ };
2496
+
2497
+ export type KotlinAndroidTextScalingMatch = KotlinAndroidAccessibilityMatch;
2498
+
2499
+ const collectAndroidTextScalingNodes = (
2500
+ source: string
2501
+ ): readonly KotlinSemanticNodeMatch[] => {
2502
+ const patterns: ReadonlyArray<{ name: string; kind: KotlinSemanticNodeMatch['kind']; regex: RegExp }> = [
2503
+ { name: 'fontScale', kind: 'property', regex: /\bfontScale\b/ },
2504
+ {
2505
+ name: 'fontSize',
2506
+ kind: 'property',
2507
+ regex: /\bfontSize\s*=\s*[^,\n]*\.sp\b/,
2508
+ },
2509
+ { name: 'TextUnit.Sp', kind: 'property', regex: /\bTextUnit\.Sp\b/ },
2510
+ ];
2511
+
2512
+ const sourceLines = source.split(/\r?\n/);
2513
+ const nodes: KotlinSemanticNodeMatch[] = [];
2514
+
2515
+ for (const pattern of patterns) {
2516
+ const lines = collectKotlinRegexLines(source, pattern.regex);
2517
+ if (lines.length === 0) {
2518
+ continue;
2519
+ }
2520
+
2521
+ const matchedLine = lines.find((lineNumber) => {
2522
+ const sanitizedLine = stripKotlinLineForSemanticScan(sourceLines[lineNumber - 1] ?? '');
2523
+ return pattern.regex.test(sanitizedLine);
2524
+ });
2525
+
2526
+ if (!matchedLine) {
2527
+ continue;
2528
+ }
2529
+
2530
+ nodes.push({
2531
+ kind: pattern.kind,
2532
+ name: pattern.name,
2533
+ lines: [matchedLine],
2534
+ });
2535
+ }
2536
+
2537
+ return nodes;
2538
+ };
2539
+
2540
+ export const findAndroidTextScalingMatch = (
2541
+ source: string
2542
+ ): KotlinAndroidTextScalingMatch | undefined => {
2543
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
2544
+ const lines = source.split(/\r?\n/);
2545
+
2546
+ for (const composableNode of composableNodes) {
2547
+ const startLine = composableNode.lines[0] ?? 1;
2548
+ const endLine = Math.min(lines.length, startLine + 80);
2549
+ const relatedNodes = collectAndroidTextScalingNodes(source).filter((node) =>
2550
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
2551
+ );
2552
+
2553
+ if (relatedNodes.length === 0) {
2554
+ continue;
2555
+ }
2556
+
2557
+ const hasFontScale = relatedNodes.some((node) => node.name === 'fontScale');
2558
+ const hasFontSizeSp = relatedNodes.some(
2559
+ (node) => node.name === 'fontSize' || node.name === 'TextUnit.Sp'
2560
+ );
2561
+
2562
+ if (!hasFontScale || !hasFontSizeSp) {
2563
+ continue;
2564
+ }
2565
+
2566
+ return {
2567
+ primary_node: composableNode,
2568
+ related_nodes: relatedNodes,
2569
+ why: `${composableNode.name} respeta el font scaling del sistema al leer fontScale y dimensionar el texto con sp en Compose.`,
2570
+ impact:
2571
+ 'La UI conserva legibilidad con tamaños de fuente accesibles y no rompe la experiencia de usuarios que aumentan el texto del sistema.',
2572
+ expected_fix:
2573
+ 'Lee LocalDensity.current.fontScale cuando necesites ajustar la tipografía y usa sp o TextUnit.Sp para que Compose respete el escalado del sistema.',
2574
+ lines: sortedUniqueLines([
2575
+ ...(composableNode.lines ?? []),
2576
+ ...relatedNodes.flatMap((node) => [...node.lines]),
2577
+ ]),
2578
+ };
2579
+ }
2580
+
2581
+ return undefined;
2582
+ };
2583
+
2584
+ export const hasAndroidTextScalingUsage = (source: string): boolean => {
2585
+ return findAndroidTextScalingMatch(source) !== undefined;
2586
+ };
2587
+
2588
+ export type KotlinAndroidTouchTargetsMatch = KotlinAndroidAccessibilityMatch;
2589
+
2590
+ const collectAndroidTouchTargetNodes = (
2591
+ source: string
2592
+ ): readonly KotlinSemanticNodeMatch[] => {
2593
+ const patterns: ReadonlyArray<{ name: string; kind: KotlinSemanticNodeMatch['kind']; regex: RegExp }> = [
2594
+ {
2595
+ name: 'minimumInteractiveComponentSize',
2596
+ kind: 'call',
2597
+ regex: /\bminimumInteractiveComponentSize\s*\(/,
2598
+ },
2599
+ {
2600
+ name: 'minimumTouchTargetSize',
2601
+ kind: 'call',
2602
+ regex: /\bminimumTouchTargetSize\s*\(/,
2603
+ },
2604
+ {
2605
+ name: 'sizeIn48dp',
2606
+ kind: 'call',
2607
+ regex: /\b(?:sizeIn|requiredSizeIn)\s*\([^)\n]*(?:minWidth|minHeight)\s*=\s*48\.dp/,
2608
+ },
2609
+ ];
2610
+
2611
+ const sourceLines = source.split(/\r?\n/);
2612
+ const nodes: KotlinSemanticNodeMatch[] = [];
2613
+
2614
+ for (const pattern of patterns) {
2615
+ const lines = collectKotlinRegexLines(source, pattern.regex);
2616
+ if (lines.length === 0) {
2617
+ continue;
2618
+ }
2619
+
2620
+ const matchedLine = lines.find((lineNumber) => {
2621
+ const sanitizedLine = stripKotlinLineForSemanticScan(sourceLines[lineNumber - 1] ?? '');
2622
+ return pattern.regex.test(sanitizedLine);
2623
+ });
2624
+
2625
+ if (!matchedLine) {
2626
+ continue;
2627
+ }
2628
+
2629
+ nodes.push({
2630
+ kind: pattern.kind,
2631
+ name: pattern.name,
2632
+ lines: [matchedLine],
2633
+ });
2634
+ }
2635
+
2636
+ return nodes;
2637
+ };
2638
+
2639
+ export const findAndroidTouchTargetsMatch = (
2640
+ source: string
2641
+ ): KotlinAndroidTouchTargetsMatch | undefined => {
2642
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
2643
+ const lines = source.split(/\r?\n/);
2644
+
2645
+ for (const composableNode of composableNodes) {
2646
+ const startLine = composableNode.lines[0] ?? 1;
2647
+ const endLine = Math.min(lines.length, startLine + 80);
2648
+ const relatedNodes = collectAndroidTouchTargetNodes(source).filter((node) =>
2649
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
2650
+ );
2651
+
2652
+ if (relatedNodes.length === 0) {
2653
+ continue;
2654
+ }
2655
+
2656
+ return {
2657
+ primary_node: composableNode,
2658
+ related_nodes: relatedNodes,
2659
+ why: `${composableNode.name} asegura touch targets de al menos 48dp en Compose para controles interactivos.`,
2660
+ impact:
2661
+ 'Los elementos interactivos mantienen un área de toque suficiente y evitan interacciones demasiado pequeñas o imprecisas.',
2662
+ expected_fix:
2663
+ 'Usa Modifier.minimumInteractiveComponentSize() o sizeIn(minWidth = 48.dp, minHeight = 48.dp) para garantizar un touch target accesible.',
2664
+ lines: sortedUniqueLines([
2665
+ ...(composableNode.lines ?? []),
2666
+ ...relatedNodes.flatMap((node) => [...node.lines]),
2667
+ ]),
2668
+ };
2669
+ }
2670
+
2671
+ return undefined;
2672
+ };
2673
+
2674
+ export const hasAndroidTouchTargetsUsage = (source: string): boolean => {
2675
+ return findAndroidTouchTargetsMatch(source) !== undefined;
2676
+ };
2677
+
2678
+ export type KotlinAndroidStringsXmlMatch = KotlinAndroidAccessibilityMatch;
2679
+ export type KotlinAndroidPluralsXmlMatch = KotlinAndroidAccessibilityMatch;
2680
+ export type KotlinAndroidStringFormattingXmlMatch = KotlinAndroidAccessibilityMatch;
2681
+
2682
+ const stripAndroidXmlLineForSemanticScan = (line: string): string => {
2683
+ return line.replace(/<!--.*?-->/g, '');
2684
+ };
2685
+
2686
+ const collectAndroidStringsXmlNodes = (
2687
+ source: string
2688
+ ): readonly KotlinSemanticNodeMatch[] => {
2689
+ const patterns: ReadonlyArray<{ name: string; regex: RegExp }> = [
2690
+ { name: 'string', regex: /<string\b/ },
2691
+ { name: 'plurals', regex: /<plurals\b/ },
2692
+ { name: 'string-array', regex: /<string-array\b/ },
2693
+ ];
2694
+
2695
+ const sourceLines = source.split(/\r?\n/);
2696
+ const nodes: KotlinSemanticNodeMatch[] = [];
2697
+
2698
+ for (const pattern of patterns) {
2699
+ const matchedLine = sourceLines.findIndex((line) =>
2700
+ pattern.regex.test(stripAndroidXmlLineForSemanticScan(line))
2701
+ );
2702
+
2703
+ if (matchedLine === -1) {
2704
+ continue;
2705
+ }
2706
+
2707
+ nodes.push({
2708
+ kind: 'property',
2709
+ name: pattern.name,
2710
+ lines: [matchedLine + 1],
2711
+ });
2712
+ }
2713
+
2714
+ return nodes;
2715
+ };
2716
+
2717
+ const collectAndroidStringFormattingXmlNodes = (
2718
+ source: string
2719
+ ): readonly KotlinSemanticNodeMatch[] => {
2720
+ const sourceLines = source.split(/\r?\n/);
2721
+ const formattedStringPattern = /<string\b[^>]*>[^<]*%\d+\$[sdif][^<]*<\/string>/;
2722
+ const nodes: KotlinSemanticNodeMatch[] = [];
2723
+
2724
+ sourceLines.forEach((line, index) => {
2725
+ if (!formattedStringPattern.test(stripAndroidXmlLineForSemanticScan(line))) {
2726
+ return;
2727
+ }
2728
+
2729
+ nodes.push({
2730
+ kind: 'property',
2731
+ name: 'formatted string',
2732
+ lines: [index + 1],
2733
+ });
2734
+ });
2735
+
2736
+ return nodes;
2737
+ };
2738
+
2739
+ export const findAndroidStringsXmlMatch = (
2740
+ source: string
2741
+ ): KotlinAndroidStringsXmlMatch | undefined => {
2742
+ const relatedNodes = collectAndroidStringsXmlNodes(source);
2743
+ if (relatedNodes.length === 0) {
2744
+ return undefined;
2745
+ }
2746
+
2747
+ const lines = source.split(/\r?\n/);
2748
+ const firstNodeLine = relatedNodes[0]?.lines[0] ?? 1;
2749
+ const resourcesLine = lines.findIndex((line) =>
2750
+ /<resources\b/.test(stripAndroidXmlLineForSemanticScan(line))
2751
+ );
2752
+ const primaryLine = resourcesLine >= 0 ? resourcesLine + 1 : firstNodeLine;
2753
+
2754
+ return {
2755
+ primary_node: {
2756
+ kind: 'member',
2757
+ name: 'strings.xml',
2758
+ lines: [primaryLine],
2759
+ },
2760
+ related_nodes: relatedNodes,
2761
+ why: 'strings.xml organiza recursos localizados por idioma para que la UI use textos externos al código en Compose y el resto de Android.',
2762
+ impact:
2763
+ 'La aplicación mantiene traducciones y recursos de texto separados del código, facilitando internacionalización y mantenimiento.',
2764
+ expected_fix:
2765
+ 'Define los textos en values-*/strings.xml y referencia los recursos desde el código con R.string en lugar de literals embebidos.',
2766
+ lines: sortedUniqueLines([primaryLine, ...relatedNodes.flatMap((node) => [...node.lines])]),
2767
+ };
2768
+ };
2769
+
2770
+ export const hasAndroidStringsXmlUsage = (source: string): boolean => {
2771
+ return findAndroidStringsXmlMatch(source) !== undefined;
2772
+ };
2773
+
2774
+ export const findAndroidStringFormattingMatch = (
2775
+ source: string
2776
+ ): KotlinAndroidStringFormattingXmlMatch | undefined => {
2777
+ const relatedNodes = collectAndroidStringFormattingXmlNodes(source);
2778
+ if (relatedNodes.length === 0) {
2779
+ return undefined;
2780
+ }
2781
+
2782
+ const lines = source.split(/\r?\n/);
2783
+ const firstNodeLine = relatedNodes[0]?.lines[0] ?? 1;
2784
+ const resourcesLine = lines.findIndex((line) =>
2785
+ /<resources\b/.test(stripAndroidXmlLineForSemanticScan(line))
2786
+ );
2787
+ const primaryLine = resourcesLine >= 0 ? resourcesLine + 1 : firstNodeLine;
2788
+
2789
+ return {
2790
+ primary_node: {
2791
+ kind: 'member',
2792
+ name: 'strings.xml',
2793
+ lines: [primaryLine],
2794
+ },
2795
+ related_nodes: relatedNodes,
2796
+ why:
2797
+ 'strings.xml usa placeholders posicionales para mantener el orden de argumentos estable entre idiomas y evitar concatenaciones frágiles.',
2798
+ impact:
2799
+ 'La UI conserva el mapeo correcto entre argumentos y traducciones aunque el orden de las palabras cambie según el locale.',
2800
+ expected_fix:
2801
+ 'Usa placeholders posicionales como %1$s y %2$d en strings.xml y pasa los argumentos en el mismo orden desde el código.',
2802
+ lines: sortedUniqueLines([primaryLine, ...relatedNodes.flatMap((node) => [...node.lines])]),
2803
+ };
2804
+ };
2805
+
2806
+ export const hasAndroidStringFormattingUsage = (source: string): boolean => {
2807
+ return findAndroidStringFormattingMatch(source) !== undefined;
2808
+ };
2809
+
2810
+ const collectAndroidPluralsXmlNodes = (
2811
+ source: string
2812
+ ): readonly KotlinSemanticNodeMatch[] => {
2813
+ const patterns: ReadonlyArray<{ name: string; regex: RegExp }> = [
2814
+ { name: 'plurals', regex: /<plurals\b/ },
2815
+ { name: 'plural item', regex: /<item\b[^>]*\bquantity\s*=/ },
2816
+ ];
2817
+
2818
+ const sourceLines = source.split(/\r?\n/);
2819
+ const nodes: KotlinSemanticNodeMatch[] = [];
2820
+
2821
+ for (const pattern of patterns) {
2822
+ const matchedLine = sourceLines.findIndex((line) =>
2823
+ pattern.regex.test(stripAndroidXmlLineForSemanticScan(line))
2824
+ );
2825
+
2826
+ if (matchedLine === -1) {
2827
+ continue;
2828
+ }
2829
+
2830
+ nodes.push({
2831
+ kind: 'property',
2832
+ name: pattern.name,
2833
+ lines: [matchedLine + 1],
2834
+ });
2835
+ }
2836
+
2837
+ return nodes;
2838
+ };
2839
+
2840
+ export const findAndroidPluralsXmlMatch = (
2841
+ source: string
2842
+ ): KotlinAndroidPluralsXmlMatch | undefined => {
2843
+ const relatedNodes = collectAndroidPluralsXmlNodes(source);
2844
+ if (relatedNodes.length < 2) {
2845
+ return undefined;
2846
+ }
2847
+
2848
+ const lines = source.split(/\r?\n/);
2849
+ const firstNodeLine = relatedNodes[0]?.lines[0] ?? 1;
2850
+ const resourcesLine = lines.findIndex((line) =>
2851
+ /<resources\b/.test(stripAndroidXmlLineForSemanticScan(line))
2852
+ );
2853
+ const primaryLine = resourcesLine >= 0 ? resourcesLine + 1 : firstNodeLine;
2854
+
2855
+ return {
2856
+ primary_node: {
2857
+ kind: 'member',
2858
+ name: 'plurals.xml',
2859
+ lines: [primaryLine],
2860
+ },
2861
+ related_nodes: relatedNodes,
2862
+ why: 'plurals.xml centraliza variantes gramaticales por cantidad para que Android seleccione el texto correcto según el quantity.',
2863
+ impact:
2864
+ 'La UI mantiene pluralización correcta por idioma y evita lógica de cantidades hardcodeada en el código.',
2865
+ expected_fix:
2866
+ 'Define <plurals> con item quantity en values-*/plurals.xml y consume el recurso desde R.plurals en lugar de concatenar textos en código.',
2867
+ lines: sortedUniqueLines([primaryLine, ...relatedNodes.flatMap((node) => [...node.lines])]),
2868
+ };
2869
+ };
2870
+
2871
+ export const hasAndroidPluralsXmlUsage = (source: string): boolean => {
2872
+ return findAndroidPluralsXmlMatch(source) !== undefined;
2873
+ };
2874
+
2875
+ const collectAndroidContentDescriptionNodes = (
2876
+ source: string
2877
+ ): readonly KotlinSemanticNodeMatch[] => {
2878
+ return collectAndroidAccessibilityNodes(source).filter((node) => node.name === 'contentDescription');
2879
+ };
2880
+
2881
+ export const findAndroidContentDescriptionMatch = (
2882
+ source: string
2883
+ ): KotlinAndroidContentDescriptionMatch | undefined => {
2884
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
2885
+ const lines = source.split(/\r?\n/);
2886
+
2887
+ for (const composableNode of composableNodes) {
2888
+ const startLine = composableNode.lines[0] ?? 1;
2889
+ const endLine = Math.min(lines.length, startLine + 80);
2890
+ const relatedNodes = collectAndroidContentDescriptionNodes(source).filter((node) =>
2891
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
2892
+ );
2893
+
2894
+ if (relatedNodes.length === 0) {
2895
+ continue;
2896
+ }
2897
+
2898
+ return {
2899
+ primary_node: composableNode,
2900
+ related_nodes: relatedNodes,
2901
+ why: `${composableNode.name} define contentDescription explícito para imágenes o botones en Compose.`,
2902
+ impact:
2903
+ 'Los elementos interactivos o gráficos quedan descritos para lectores de pantalla y tecnologías de asistencia.',
2904
+ expected_fix:
2905
+ 'Usa contentDescription dentro del composable adecuado para describir imágenes, iconos y botones de forma accesible.',
2906
+ lines: sortedUniqueLines([
2907
+ ...(composableNode.lines ?? []),
2908
+ ...relatedNodes.flatMap((node) => [...node.lines]),
2909
+ ]),
2910
+ };
2911
+ }
2912
+
2913
+ return undefined;
2914
+ };
2915
+
2916
+ export const hasAndroidContentDescriptionUsage = (source: string): boolean => {
2917
+ return findAndroidContentDescriptionMatch(source) !== undefined;
2918
+ };
2919
+
2920
+ const collectAndroidRecompositionNodes = (
2921
+ source: string
2922
+ ): readonly KotlinSemanticNodeMatch[] => {
2923
+ const patterns: ReadonlyArray<[string, RegExp]> = [
2924
+ ['println', /\bprintln\s*\(/],
2925
+ ['log', /\bLog\.[A-Z]+\s*\(/],
2926
+ ['toast', /\bToast\.makeText\b/],
2927
+ ['state mutation', /\b[A-Za-z_][A-Za-z0-9_]*\.value\s*=/],
2928
+ ['mutable increment', /\+\+|--/],
2929
+ ['side effect launch', /\b(?:launch|async|withContext)\s*\(/],
2930
+ ];
2931
+
2932
+ const nodes: KotlinSemanticNodeMatch[] = [];
2933
+ for (const [name, regex] of patterns) {
2934
+ const lines = collectKotlinRegexLines(source, regex);
2935
+ for (const lineNumber of lines) {
2936
+ nodes.push({
2937
+ kind: 'call',
2938
+ name,
2939
+ lines: [lineNumber],
2940
+ });
2941
+ }
2942
+ }
2943
+
2944
+ return nodes;
2945
+ };
2946
+
2947
+ export const findAndroidRecompositionMatch = (
2948
+ source: string
2949
+ ): KotlinAndroidRecompositionMatch | undefined => {
2950
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
2951
+ const lines = source.split(/\r?\n/);
2952
+
2953
+ for (const composableNode of composableNodes) {
2954
+ const startLine = composableNode.lines[0] ?? 1;
2955
+ const endLine = Math.min(lines.length, startLine + 80);
2956
+ const relatedNodes = collectAndroidRecompositionNodes(source).filter((node) =>
2957
+ node.lines.some((lineNumber) => lineNumber >= startLine && lineNumber <= endLine)
2958
+ );
2959
+
2960
+ if (relatedNodes.length === 0) {
2961
+ continue;
2962
+ }
2963
+
2964
+ return {
2965
+ primary_node: composableNode,
2966
+ related_nodes: relatedNodes,
2967
+ why: `${composableNode.name} ejecuta efectos o mutaciones directas que rompen la idempotencia durante la recomposición de Compose.`,
2968
+ impact:
2969
+ 'La UI puede repetir efectos secundarios o mutaciones cada vez que Compose recomputa el composable.',
2970
+ expected_fix:
2971
+ 'Mantén el cuerpo del composable libre de mutaciones y side effects, y mueve la lógica imperativa a efectos o al ViewModel.',
2972
+ lines: sortedUniqueLines([
2973
+ ...(composableNode.lines ?? []),
2974
+ ...relatedNodes.flatMap((node) => [...node.lines]),
2975
+ ]),
2976
+ };
2977
+ }
2978
+
2979
+ return undefined;
2980
+ };
2981
+
2982
+ export const hasAndroidRecompositionUsage = (source: string): boolean => {
2983
+ return findAndroidRecompositionMatch(source) !== undefined;
2984
+ };
2985
+
2986
+ const collectAndroidUiStateVariantNodes = (
2987
+ source: string,
2988
+ bodyStartLine: number,
2989
+ bodyEndLine: number
2990
+ ): readonly KotlinSemanticNodeMatch[] => {
2991
+ const variantPatterns: ReadonlyArray<{ name: string; regex: RegExp }> = [
2992
+ { name: 'Loading', regex: /\b(?:data\s+object|object|data\s+class|class)\s+Loading\b/ },
2993
+ { name: 'Success', regex: /\b(?:data\s+object|object|data\s+class|class)\s+Success\b/ },
2994
+ { name: 'Error', regex: /\b(?:data\s+object|object|data\s+class|class)\s+Error\b/ },
2995
+ ];
2996
+
2997
+ const nodes: KotlinSemanticNodeMatch[] = [];
2998
+ for (const variantPattern of variantPatterns) {
2999
+ const lines = collectKotlinRegexLinesInRange(
3000
+ source,
3001
+ variantPattern.regex,
3002
+ bodyStartLine,
3003
+ bodyEndLine
3004
+ );
3005
+ if (lines.length === 0) {
3006
+ return [];
3007
+ }
3008
+ nodes.push({
3009
+ kind: 'member',
3010
+ name: variantPattern.name,
3011
+ lines: [lines[0] ?? bodyStartLine],
3012
+ });
3013
+ }
3014
+
3015
+ return nodes;
3016
+ };
3017
+
3018
+ const buildAndroidUiStateMatch = (
3019
+ source: string
3020
+ ): KotlinAndroidUiStateMatch | undefined => {
3021
+ const lines = source.split(/\r?\n/);
3022
+ const declarations = parseKotlinTypeDeclarations(source).filter((declaration) =>
3023
+ declaration.name.endsWith('UiState')
3024
+ );
3025
+
3026
+ for (const declaration of declarations) {
3027
+ const declarationLine = stripKotlinLineForSemanticScan(lines[declaration.line - 1] ?? '');
3028
+ if (!/\bsealed\s+class\b/.test(declarationLine)) {
3029
+ continue;
3030
+ }
3031
+
3032
+ const relatedNodes = collectAndroidUiStateVariantNodes(
3033
+ source,
3034
+ declaration.bodyStartLine,
3035
+ declaration.bodyEndLine
3036
+ );
3037
+ if (relatedNodes.length === 0) {
3038
+ continue;
3039
+ }
3040
+
3041
+ return {
3042
+ primary_node: {
3043
+ kind: 'class',
3044
+ name: declaration.name,
3045
+ lines: [declaration.line],
3046
+ },
3047
+ related_nodes: relatedNodes,
3048
+ why: `${declaration.name} modela Loading, Success y Error como estados cerrados de UI en Android.`,
3049
+ impact:
3050
+ 'La UI trabaja con un estado tipado y cerrado, lo que evita banderas sueltas y branches dispersos para cada render.',
3051
+ expected_fix:
3052
+ 'Mantén un sealed class UiState con Loading, Success y Error y expónlo desde la capa de presentación o el ViewModel.',
3053
+ lines: sortedUniqueLines([
3054
+ declaration.line,
3055
+ ...relatedNodes.flatMap((node) => [...node.lines]),
3056
+ ]),
3057
+ };
3058
+ }
3059
+
3060
+ return undefined;
3061
+ };
3062
+
3063
+ export const findAndroidUiStateMatch = (
3064
+ source: string
3065
+ ): KotlinAndroidUiStateMatch | undefined => {
3066
+ return buildAndroidUiStateMatch(source);
3067
+ };
3068
+
3069
+ export const hasAndroidUiStateUsage = (source: string): boolean => {
3070
+ return findAndroidUiStateMatch(source) !== undefined;
3071
+ };
3072
+
3073
+ const buildAndroidUseCaseMatch = (
3074
+ source: string
3075
+ ): KotlinAndroidUseCaseMatch | undefined => {
3076
+ const sourceLines = source.split(/\r?\n/);
3077
+ const declarations = parseKotlinTypeDeclarations(source).filter((declaration) =>
3078
+ declaration.name.endsWith('UseCase')
3079
+ );
3080
+
3081
+ for (let declarationIndex = 0; declarationIndex < declarations.length; declarationIndex += 1) {
3082
+ const declaration = declarations[declarationIndex];
3083
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
3084
+ const nextDeclarationLine =
3085
+ declarations[declarationIndex + 1]?.line ?? sourceLines.length + 1;
3086
+
3087
+ const dependencyLines = collectKotlinRegexLines(
3088
+ source,
3089
+ /\b(?:private|internal|public)?\s*val\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*[A-Za-z_][A-Za-z0-9_]*(?:Repository|Port|Gateway|Service|DataSource|Fetcher)\b/,
3090
+ ).filter((line) => line >= declaration.line && line < nextDeclarationLine);
3091
+ if (dependencyLines.length > 0) {
3092
+ relatedNodes.push({
3093
+ kind: 'property',
3094
+ name: 'use case dependency',
3095
+ lines: dependencyLines,
3096
+ });
3097
+ }
3098
+
3099
+ const executeLines: number[] = [];
3100
+ for (let lineIndex = declaration.line - 1; lineIndex < nextDeclarationLine - 1; lineIndex += 1) {
3101
+ const sanitizedLine = stripKotlinLineForSemanticScan(sourceLines[lineIndex] ?? '');
3102
+ if (/\b(?:suspend\s+)?(?:operator\s+)?fun\s+(invoke|execute)\b/.test(sanitizedLine)) {
3103
+ executeLines.push(lineIndex + 1);
3104
+ }
3105
+ }
3106
+ const executeLinesInScope = executeLines.filter(
3107
+ (line) => line >= declaration.line && line < nextDeclarationLine
3108
+ );
3109
+ if (executeLinesInScope.length > 0) {
3110
+ relatedNodes.push({
3111
+ kind: 'member',
3112
+ name: 'use case operation',
3113
+ lines: executeLinesInScope,
3114
+ });
3115
+ }
3116
+
3117
+ if (executeLinesInScope.length === 0) {
3118
+ continue;
3119
+ }
3120
+
3121
+ return {
3122
+ primary_node: {
3123
+ kind: 'class',
3124
+ name: declaration.name,
3125
+ lines: [declaration.line],
3126
+ },
3127
+ related_nodes: relatedNodes,
3128
+ why: `${declaration.name} encapsula lógica de negocio en una única operación de aplicación en lugar de dispersarla por presentation.`,
3129
+ impact:
3130
+ 'La lógica queda concentrada en un punto de entrada estable, más fácil de testear y de reutilizar desde ViewModel o coordinadores.',
3131
+ expected_fix:
3132
+ 'Mantén un UseCase pequeño con una operación pública clara, dependencias inyectadas y sin mezclar orquestación de UI.',
3133
+ lines: sortedUniqueLines([
3134
+ declaration.line,
3135
+ ...relatedNodes.flatMap((node) => [...node.lines]),
3136
+ ]),
3137
+ };
3138
+ }
3139
+
3140
+ return undefined;
3141
+ };
3142
+
3143
+ export const findAndroidUseCaseMatch = (
3144
+ source: string
3145
+ ): KotlinAndroidUseCaseMatch | undefined => {
3146
+ return buildAndroidUseCaseMatch(source);
3147
+ };
3148
+
3149
+ export const hasAndroidUseCaseUsage = (source: string): boolean => {
3150
+ return findAndroidUseCaseMatch(source) !== undefined;
3151
+ };
3152
+
3153
+ const collectAndroidRepositoryRelatedNodes = (
3154
+ source: string,
3155
+ startLine: number,
3156
+ endLine: number
3157
+ ): readonly KotlinSemanticNodeMatch[] => {
3158
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
3159
+ const dependencyLines = collectKotlinRegexLinesInRange(
3160
+ source,
3161
+ /\b(?:private|internal|public)?\s*val\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*[A-Za-z_][A-Za-z0-9_]*(?:Api|Dao|DataSource|Service|Client|Remote|Local|Store)\b/,
3162
+ startLine,
3163
+ endLine
3164
+ );
3165
+ if (dependencyLines.length > 0) {
3166
+ relatedNodes.push({
3167
+ kind: 'property',
3168
+ name: 'repository dependency',
3169
+ lines: dependencyLines,
3170
+ });
3171
+ }
3172
+
3173
+ const operationLines = collectKotlinRegexLinesInRange(
3174
+ source,
3175
+ /\b(?:override\s+)?(?:suspend\s+)?fun\s+(?:load|fetch|get|save|observe|sync|refresh|update|delete|insert|find|create|clear|add|remove)[A-Za-z0-9_]*\b/,
3176
+ startLine,
3177
+ endLine
3178
+ );
3179
+ if (operationLines.length > 0) {
3180
+ relatedNodes.push({
3181
+ kind: 'member',
3182
+ name: 'repository operation',
3183
+ lines: operationLines,
3184
+ });
3185
+ }
3186
+
3187
+ const contractLines = collectKotlinRegexLinesInRange(
3188
+ source,
3189
+ /:\s*[A-Za-z_][A-Za-z0-9_]*(?:Repository(?:Impl)?|OrdersRep)\b/,
3190
+ startLine,
3191
+ endLine
3192
+ );
3193
+ if (contractLines.length > 0) {
3194
+ relatedNodes.push({
3195
+ kind: 'member',
3196
+ name: 'repository contract',
3197
+ lines: contractLines,
3198
+ });
3199
+ }
3200
+
3201
+ return relatedNodes;
3202
+ };
3203
+
3204
+ const buildAndroidRepositoryPatternMatch = (
3205
+ source: string,
3206
+ namePredicate: (name: string) => boolean,
3207
+ why: string,
3208
+ impact: string,
3209
+ expectedFix: string
3210
+ ): KotlinAndroidRepositoryPatternMatch | undefined => {
3211
+ const repositoryDeclarations = parseAndroidRepositoryDeclarations(source).filter((declaration) =>
3212
+ namePredicate(declaration.name)
3213
+ );
3214
+
3215
+ for (const declaration of repositoryDeclarations) {
3216
+ const relatedNodes = [...declaration.members.map(
3217
+ (member): KotlinSemanticNodeMatch => ({
3218
+ kind: 'member',
3219
+ name: member.name,
3220
+ lines: [member.line],
3221
+ })
3222
+ ), ...collectAndroidRepositoryRelatedNodes(
3223
+ source,
3224
+ declaration.line,
3225
+ source.split(/\r?\n/).length
3226
+ )];
3227
+
3228
+ const uniqueRelatedNodes: KotlinSemanticNodeMatch[] = [];
3229
+ const seenRelatedNodes = new Set<string>();
3230
+ for (const relatedNode of relatedNodes) {
3231
+ const signature = `${relatedNode.kind}|${relatedNode.name}|${relatedNode.lines.join(',')}`;
3232
+ if (seenRelatedNodes.has(signature)) {
3233
+ continue;
3234
+ }
3235
+ seenRelatedNodes.add(signature);
3236
+ uniqueRelatedNodes.push(relatedNode);
3237
+ }
3238
+
3239
+ if (uniqueRelatedNodes.length === 0) {
3240
+ continue;
3241
+ }
3242
+
3243
+ return {
3244
+ primary_node: {
3245
+ kind: 'class',
3246
+ name: declaration.name,
3247
+ lines: [declaration.line],
3248
+ },
3249
+ related_nodes: uniqueRelatedNodes,
3250
+ why,
3251
+ impact,
3252
+ expected_fix: expectedFix,
3253
+ lines: sortedUniqueLines([
3254
+ declaration.line,
3255
+ ...uniqueRelatedNodes.flatMap((node) => [...node.lines]),
3256
+ ]),
3257
+ };
3258
+ }
3259
+
3260
+ return undefined;
3261
+ };
3262
+
3263
+ export const findAndroidRepositoryPatternMatch = (
3264
+ source: string
3265
+ ): KotlinAndroidRepositoryPatternMatch | undefined => {
3266
+ return buildAndroidRepositoryPatternMatch(
3267
+ source,
3268
+ (name) => name.endsWith('Repository') || name.endsWith('RepositoryImpl') || name === 'OrdersRep',
3269
+ 'El repository abstrae el acceso a datos detrás de una frontera estable en lugar de mezclar APIs, DAOs o DataSources con la capa de presentación.',
3270
+ 'La capa consumidora depende de un contrato de datos estable y no de los detalles de red o persistencia.',
3271
+ 'Mantén el Repository como una abstracción pequeña, inyectada y delegando a fuentes de datos concretas desde la capa data.'
3272
+ );
3273
+ };
3274
+
3275
+ export const hasAndroidRepositoryPatternUsage = (source: string): boolean => {
3276
+ return findAndroidRepositoryPatternMatch(source) !== undefined;
3277
+ };
3278
+
3279
+ export const findAndroidOrdersRepMatch = (
3280
+ source: string
3281
+ ): KotlinAndroidRepositoryPatternMatch | undefined => {
3282
+ return buildAndroidRepositoryPatternMatch(
3283
+ source,
3284
+ (name) => name === 'OrdersRep',
3285
+ 'OrdersRep formaliza el acceso a datos del flujo de pedidos detrás de una abstracción estable.',
3286
+ 'La UI y el dominio consumen un contrato de pedidos uniforme en lugar de dispersar llamadas a fuentes locales o remotas.',
3287
+ 'Mantén OrdersRep como fachada de acceso a datos de pedidos y delega la persistencia o red a dependencias inyectadas.'
3288
+ );
3289
+ };
3290
+
3291
+ export const hasAndroidOrdersRepUsage = (source: string): boolean => {
3292
+ return findAndroidOrdersRepMatch(source) !== undefined;
3293
+ };
3294
+
3295
+ export const findAndroidStateHoistingMatch = (
3296
+ source: string
3297
+ ): KotlinAndroidStateHoistingMatch | undefined => {
3298
+ const composableNodes = collectAndroidComposableFunctionDeclarations(source);
3299
+ const primaryNode = composableNodes[0];
3300
+ if (!primaryNode) {
3301
+ return undefined;
3302
+ }
3303
+
3304
+ const stateLines = collectKotlinRegexLinesInRange(
3305
+ source,
3306
+ /\b(?:rememberSaveable|mutableStateOf)\b/,
3307
+ primaryNode.lines[0] ?? 1,
3308
+ (primaryNode.lines[0] ?? 1) + 80
3309
+ );
3310
+ if (stateLines.length === 0) {
3311
+ return undefined;
3312
+ }
3313
+
3314
+ const lines = source.split(/\r?\n/);
3315
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
3316
+
3317
+ for (const lineNumber of stateLines) {
3318
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[lineNumber - 1] ?? '');
3319
+ if (/\brememberSaveable\b/.test(sanitizedLine)) {
3320
+ relatedNodes.push({
3321
+ kind: 'call',
3322
+ name: 'rememberSaveable',
3323
+ lines: [lineNumber],
3324
+ });
3325
+ }
3326
+ if (/\bmutableStateOf\b/.test(sanitizedLine)) {
3327
+ relatedNodes.push({
3328
+ kind: 'call',
3329
+ name: 'mutableStateOf',
3330
+ lines: [lineNumber],
3331
+ });
3332
+ }
3333
+ }
3334
+
3335
+ return {
3336
+ primary_node: primaryNode,
3337
+ related_nodes: relatedNodes,
3338
+ why: `${primaryNode.name} concentra estado de UI local en lugar de elevarlo al nivel apropiado para compartirlo con la pantalla o el ViewModel.`,
3339
+ impact:
3340
+ 'El estado queda acoplado al composable concreto y la jerarquía de UI pierde una única fuente clara para observar y reutilizar ese valor.',
3341
+ expected_fix:
3342
+ 'Eleva el estado al padre o al ViewModel y deja el composable como una función que recibe valor y callbacks explícitos.',
3343
+ lines: sortedUniqueLines([
3344
+ ...(primaryNode.lines ?? []),
3345
+ ...relatedNodes.flatMap((node) => [...node.lines]),
3346
+ ]),
3347
+ };
3348
+ };
3349
+
3350
+ export const hasAndroidStateHoistingUsage = (source: string): boolean => {
3351
+ return findAndroidStateHoistingMatch(source) !== undefined;
3352
+ };
3353
+
3354
+ export const findAndroidViewModelMatch = (
3355
+ source: string
3356
+ ): KotlinAndroidViewModelMatch | undefined => {
3357
+ const declarations = parseKotlinTypeDeclarations(source).filter((declaration) =>
3358
+ declaration.conformances.some((conformance) => conformance === 'ViewModel')
3359
+ );
3360
+
3361
+ for (const declaration of declarations) {
3362
+ const lines = source.split(/\r?\n/);
3363
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
3364
+ {
3365
+ kind: 'member',
3366
+ name: 'androidx.lifecycle.ViewModel',
3367
+ lines: [declaration.line],
3368
+ },
3369
+ ];
3370
+
3371
+ const savedStateHandleLines = collectKotlinRegexLinesInRange(
3372
+ source,
3373
+ /\bSavedStateHandle\b/,
3374
+ declaration.bodyStartLine,
3375
+ declaration.bodyEndLine
3376
+ );
3377
+ if (savedStateHandleLines.length > 0) {
3378
+ relatedNodes.push({
3379
+ kind: 'property',
3380
+ name: 'SavedStateHandle',
3381
+ lines: savedStateHandleLines,
3382
+ });
3383
+ }
3384
+
3385
+ const stateFlowLines = collectKotlinRegexLinesInRange(
3386
+ source,
3387
+ /\b(?:MutableStateFlow|StateFlow|asStateFlow)\b/,
3388
+ declaration.bodyStartLine,
3389
+ declaration.bodyEndLine
3390
+ );
3391
+ if (stateFlowLines.length > 0) {
3392
+ relatedNodes.push({
3393
+ kind: 'property',
3394
+ name: 'StateFlow',
3395
+ lines: stateFlowLines,
3396
+ });
3397
+ }
3398
+
3399
+ return {
3400
+ primary_node: {
3401
+ kind: 'class',
3402
+ name: declaration.name,
3403
+ lines: [declaration.line],
3404
+ },
3405
+ related_nodes: relatedNodes,
3406
+ why: `${declaration.name} usa AndroidX ViewModel para encapsular estado y lógica que deben sobrevivir a cambios de configuración.`,
3407
+ impact:
3408
+ 'El estado deja de vivir en la Activity o Fragment y pasa a una capa explícita que Compose y el resto de la UI pueden observar sin recrearlo en cada rotación.',
3409
+ expected_fix:
3410
+ 'Mantén el estado observable y la coordinación de la pantalla dentro de un ViewModel de AndroidX, no en la Activity ni en objetos efímeros.',
3411
+ lines: sortedUniqueLines([
3412
+ declaration.line,
3413
+ ...relatedNodes.flatMap((node) => [...node.lines]),
3414
+ ]),
3415
+ };
3416
+ }
3417
+
3418
+ return undefined;
3419
+ };
3420
+
3421
+ export const hasAndroidViewModelUsage = (source: string): boolean => {
3422
+ return findAndroidViewModelMatch(source) !== undefined;
3423
+ };
3424
+
3425
+ export const findAndroidViewModelScopeMatch = (
3426
+ source: string
3427
+ ): KotlinAndroidViewModelScopeMatch | undefined => {
3428
+ const scopeLines = collectKotlinRegexLines(source, /\bviewModelScope\b/);
3429
+ if (scopeLines.length === 0) {
3430
+ return undefined;
3431
+ }
3432
+
3433
+ const lines = source.split(/\r?\n/);
3434
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
3435
+ const coroutineLaunchLines = scopeLines.filter((lineNumber) => {
3436
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[lineNumber - 1] ?? '');
3437
+ return /\bviewModelScope\s*\.\s*(launch|async)\b/.test(sanitizedLine);
3438
+ });
3439
+
3440
+ if (coroutineLaunchLines.length > 0) {
3441
+ relatedNodes.push({
3442
+ kind: 'call',
3443
+ name: 'launch/async',
3444
+ lines: coroutineLaunchLines,
3445
+ });
3446
+ }
3447
+
3448
+ return {
3449
+ primary_node: {
3450
+ kind: 'member',
3451
+ name: 'viewModelScope',
3452
+ lines: scopeLines,
3453
+ },
3454
+ related_nodes: relatedNodes,
3455
+ why: 'viewModelScope crea un scope ligado al ViewModel que se cancela automáticamente cuando el ViewModel se destruye.',
3456
+ impact:
3457
+ 'La coordinación asíncrona queda atada al ciclo de vida correcto y no necesita limpieza manual en Activity o Fragment.',
3458
+ expected_fix:
3459
+ 'Usa viewModelScope para lanzar trabajo asincrónico de pantalla y delega la lógica al ViewModel en lugar de mantener scopes manuales.',
3460
+ lines: sortedUniqueLines([
3461
+ ...scopeLines,
3462
+ ...relatedNodes.flatMap((node) => [...node.lines]),
3463
+ ]),
3464
+ };
3465
+ };
3466
+
3467
+ export const hasAndroidViewModelScopeUsage = (source: string): boolean => {
3468
+ return findAndroidViewModelScopeMatch(source) !== undefined;
3469
+ };
3470
+
3471
+ export const findAndroidAppStartupMatch = (
3472
+ source: string
3473
+ ): KotlinAndroidAppStartupMatch | undefined => {
3474
+ const initializerPattern =
3475
+ /\b(?:class|object)\s+([A-Za-z_][A-Za-z0-9_]*)\b[^\n]*\bInitializer\s*</;
3476
+ const initializerLines = collectKotlinRegexLines(source, initializerPattern);
3477
+ if (initializerLines.length === 0) {
3478
+ return undefined;
3479
+ }
3480
+
3481
+ const classLine = source.split(/\r?\n/)[initializerLines[0] - 1] ?? '';
3482
+ const className = classLine.match(initializerPattern)?.[1];
3483
+ if (!className) {
3484
+ return undefined;
3485
+ }
3486
+
3487
+ return {
3488
+ primary_node: {
3489
+ kind: 'class',
3490
+ name: className,
3491
+ lines: initializerLines,
3492
+ },
3493
+ related_nodes: [
3494
+ {
3495
+ kind: 'member',
3496
+ name: 'androidx.startup.Initializer',
3497
+ lines: initializerLines,
3498
+ },
3499
+ ],
3500
+ why: `${className} usa androidx.startup.Initializer para resolver dependencias de arranque de forma perezosa y explícita.`,
3501
+ impact:
3502
+ 'La inicialización de dependencias pasa a un contrato de arranque controlado en lugar de ejecutarse de forma ad hoc en Application o en singletons manuales.',
3503
+ expected_fix:
3504
+ 'Mantén la inicialización de arranque en androidx.startup y limita el trabajo eager para que la app cargue dependencias solo cuando haga falta.',
3505
+ lines: sortedUniqueLines([...initializerLines]),
3506
+ };
3507
+ };
3508
+
3509
+ export const hasAndroidAppStartupUsage = (source: string): boolean => {
3510
+ return findAndroidAppStartupMatch(source) !== undefined;
3511
+ };
3512
+
3513
+ export const findAndroidAnalyticsMatch = (
3514
+ source: string
3515
+ ): KotlinAndroidAnalyticsMatch | undefined => {
3516
+ const declarations = parseKotlinTypeDeclarations(source);
3517
+ const analyticsCallPattern = /\b(?:logEvent|trackEvent|logScreenView|setUserProperty)\s*\(/;
3518
+ const analyticsMarkerPattern = /\b(?:FirebaseAnalytics|logEvent|trackEvent|logScreenView|setUserProperty)\b/;
3519
+
3520
+ for (const declaration of declarations) {
3521
+ const analyticsLines = collectKotlinRegexLinesInRange(
3522
+ source,
3523
+ analyticsMarkerPattern,
3524
+ declaration.bodyStartLine,
3525
+ declaration.bodyEndLine
3526
+ );
3527
+ if (analyticsLines.length === 0) {
3528
+ continue;
3529
+ }
3530
+
3531
+ const firebaseLines = collectKotlinRegexLinesInRange(
3532
+ source,
3533
+ /\bFirebaseAnalytics\b/,
3534
+ declaration.bodyStartLine,
3535
+ declaration.bodyEndLine
3536
+ );
3537
+ const analyticsCallLines = collectKotlinRegexLinesInRange(
3538
+ source,
3539
+ analyticsCallPattern,
3540
+ declaration.bodyStartLine,
3541
+ declaration.bodyEndLine
3542
+ );
3543
+
3544
+ if (firebaseLines.length === 0 && analyticsCallLines.length === 0) {
3545
+ continue;
3546
+ }
3547
+
3548
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
3549
+ if (firebaseLines.length > 0) {
3550
+ relatedNodes.push({
3551
+ kind: 'property',
3552
+ name: 'FirebaseAnalytics',
3553
+ lines: firebaseLines,
3554
+ });
3555
+ }
3556
+ if (analyticsCallLines.length > 0) {
3557
+ relatedNodes.push({
3558
+ kind: 'call',
3559
+ name: 'analytics event',
3560
+ lines: analyticsCallLines,
3561
+ });
3562
+ }
3563
+
3564
+ return {
3565
+ primary_node: {
3566
+ kind: 'class',
3567
+ name: declaration.name,
3568
+ lines: [declaration.line],
3569
+ },
3570
+ related_nodes: relatedNodes,
3571
+ why: `${declaration.name} centraliza instrumentation de analytics en un punto estable en lugar de dispersar eventos por la app.`,
3572
+ impact:
3573
+ 'Las mediciones de comportamiento quedan encapsuladas detrás de un contrato claro y no se confunden con lógica de UI o dominio.',
3574
+ expected_fix:
3575
+ 'Mantén el tracking de analytics en una clase dedicada o wrapper del SDK y delega desde la UI solo eventos explícitos.',
3576
+ lines: sortedUniqueLines([
3577
+ declaration.line,
3578
+ ...relatedNodes.flatMap((node) => [...node.lines]),
3579
+ ]),
3580
+ };
3581
+ }
3582
+
3583
+ return undefined;
3584
+ };
3585
+
3586
+ export const hasAndroidAnalyticsUsage = (source: string): boolean => {
3587
+ return findAndroidAnalyticsMatch(source) !== undefined;
3588
+ };
3589
+
3590
+ export const findAndroidProfilerMatch = (
3591
+ source: string
3592
+ ): KotlinAndroidProfilerMatch | undefined => {
3593
+ const declarations = parseKotlinTypeDeclarations(source);
3594
+ const profilerCallPattern =
3595
+ /\b(?:Trace\.beginSection|Trace\.beginAsyncSection|Trace\.setCounter|TraceKt\.trace|Debug\.startMethodTracing(?:Sampling)?|Debug\.stopMethodTracing|Debug\.startAllocCounting|Debug\.stopAllocCounting|Debug\.startNativeTracing|Debug\.stopNativeTracing|Debug\.getMemoryInfo|Debug\.threadCpuTimeNanos)\s*\(/;
3596
+ const profilerMarkerPattern =
3597
+ /\b(?:androidx\.tracing\.Trace|android\.os\.Trace|android\.os\.Debug|Debug\.MemoryInfo|TraceKt\.trace|startMethodTracing|stopMethodTracing|beginSection|beginAsyncSection|setCounter|startAllocCounting|stopAllocCounting|startNativeTracing|stopNativeTracing|getMemoryInfo|threadCpuTimeNanos)\b/;
3598
+
3599
+ for (const declaration of declarations) {
3600
+ const profilerLines = collectKotlinRegexLinesInRange(
3601
+ source,
3602
+ profilerMarkerPattern,
3603
+ declaration.bodyStartLine,
3604
+ declaration.bodyEndLine
3605
+ );
3606
+ if (profilerLines.length === 0) {
3607
+ continue;
3608
+ }
3609
+
3610
+ const traceLines = collectKotlinRegexLinesInRange(
3611
+ source,
3612
+ /\b(?:Trace\.beginSection|Trace\.beginAsyncSection|Trace\.setCounter|TraceKt\.trace)\b/,
3613
+ declaration.bodyStartLine,
3614
+ declaration.bodyEndLine
3615
+ );
3616
+ const debugTracingLines = collectKotlinRegexLinesInRange(
3617
+ source,
3618
+ /\b(?:Debug\.startMethodTracing(?:Sampling)?|Debug\.stopMethodTracing|Debug\.startAllocCounting|Debug\.stopAllocCounting|Debug\.startNativeTracing|Debug\.stopNativeTracing|Debug\.getMemoryInfo|Debug\.threadCpuTimeNanos)\b/,
3619
+ declaration.bodyStartLine,
3620
+ declaration.bodyEndLine
3621
+ );
3622
+
3623
+ if (traceLines.length === 0 && debugTracingLines.length === 0) {
3624
+ continue;
3625
+ }
3626
+
3627
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
3628
+ if (traceLines.length > 0) {
3629
+ relatedNodes.push({
3630
+ kind: 'call',
3631
+ name: 'Trace',
3632
+ lines: traceLines,
3633
+ });
3634
+ }
3635
+ if (debugTracingLines.length > 0) {
3636
+ relatedNodes.push({
3637
+ kind: 'call',
3638
+ name: 'Debug tracing',
3639
+ lines: debugTracingLines,
3640
+ });
3641
+ }
3642
+
3643
+ return {
3644
+ primary_node: {
3645
+ kind: 'class',
3646
+ name: declaration.name,
3647
+ lines: [declaration.line],
3648
+ },
3649
+ related_nodes: relatedNodes,
3650
+ why: `${declaration.name} centraliza instrumentación de profiling en un punto estable en lugar de dispersar trazas y mediciones por la app.`,
3651
+ impact:
3652
+ 'La captura de CPU, memoria o trazas de ejecución queda explícita y no se mezcla con lógica de dominio o presentación.',
3653
+ expected_fix:
3654
+ 'Mantén el profiling en una clase dedicada o helper de observabilidad y usa Trace o Debug de forma controlada.',
3655
+ lines: sortedUniqueLines([
3656
+ declaration.line,
3657
+ ...relatedNodes.flatMap((node) => [...node.lines]),
3658
+ ]),
3659
+ };
3660
+ }
3661
+
3662
+ return undefined;
3663
+ };
3664
+
3665
+ export const hasAndroidProfilerUsage = (source: string): boolean => {
3666
+ return findAndroidProfilerMatch(source) !== undefined;
3667
+ };
3668
+
3669
+ export const findAndroidBaselineProfilesMatch = (
3670
+ source: string
3671
+ ): KotlinAndroidBaselineProfilesMatch | undefined => {
3672
+ const ruleLines = collectKotlinRegexLines(source, /\bBaselineProfileRule\b/);
3673
+ if (ruleLines.length === 0) {
3674
+ return undefined;
3675
+ }
3676
+
3677
+ const collectLines = collectKotlinRegexLines(source, /\bcollect\s*\(/);
3678
+ const packageNameLines = collectKotlinRegexLines(source, /\bpackageName\s*=/);
3679
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
3680
+
3681
+ if (collectLines.length > 0) {
3682
+ relatedNodes.push({
3683
+ kind: 'call',
3684
+ name: 'collect',
3685
+ lines: collectLines,
3686
+ });
3687
+ }
3688
+
3689
+ if (packageNameLines.length > 0) {
3690
+ relatedNodes.push({
3691
+ kind: 'member',
3692
+ name: 'packageName',
3693
+ lines: packageNameLines,
3694
+ });
3695
+ }
3696
+
3697
+ return {
3698
+ primary_node: {
3699
+ kind: 'member',
3700
+ name: 'BaselineProfileRule',
3701
+ lines: ruleLines,
3702
+ },
3703
+ related_nodes: relatedNodes,
3704
+ why:
3705
+ 'BaselineProfileRule captura perfiles de compilación para que el arranque de Android use un baseline profile explícito y medible.',
3706
+ impact:
3707
+ 'La app puede precalentar rutas críticas de arranque sin hacer eager work manual en producción.',
3708
+ expected_fix:
3709
+ 'Mantén los baseline profiles en pruebas de benchmark/instrumented tests y registra rutas críticas con BaselineProfileRule.collect.',
3710
+ lines: sortedUniqueLines([...ruleLines, ...collectLines, ...packageNameLines]),
3711
+ };
3712
+ };
3713
+
3714
+ export const hasAndroidBaselineProfilesUsage = (source: string): boolean => {
3715
+ return findAndroidBaselineProfilesMatch(source) !== undefined;
3716
+ };
3717
+
3718
+ const workManagerWorkerConformancePattern = /(?:Worker|CoroutineWorker|ListenableWorker)$/;
3719
+
3720
+ const collectWorkManagerWorkerDeclarations = (
3721
+ lines: readonly string[]
3722
+ ): readonly KotlinTypeDeclaration[] => {
3723
+ const declarations: KotlinTypeDeclaration[] = [];
3724
+ const classPattern =
3725
+ /(?:internal\s+|private\s+|public\s+)?(?:abstract\s+|open\s+|sealed\s+|data\s+|final\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)\b/;
3726
+
3727
+ for (let index = 0; index < lines.length; index += 1) {
3728
+ const declarationWindow = lines
3729
+ .slice(index, Math.min(lines.length, index + 6))
3730
+ .map((line) => stripKotlinLineForSemanticScan(line ?? ''))
3731
+ .join(' ');
3732
+ const classMatch = declarationWindow.match(classPattern);
3733
+ if (!classMatch) {
3734
+ continue;
3735
+ }
3736
+
3737
+ const typeName = classMatch[1];
3738
+ const conformanceName =
3739
+ declarationWindow.match(/\)\s*:\s*(?:[A-Za-z_][A-Za-z0-9_.]*\.)?([A-Za-z_][A-Za-z0-9_]*)\b/)?.[1] ??
3740
+ declarationWindow.match(
3741
+ /class\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*(?:[A-Za-z_][A-Za-z0-9_.]*\.)?([A-Za-z_][A-Za-z0-9_]*)\b/
3742
+ )?.[1];
3743
+ if (!conformanceName || !workManagerWorkerConformancePattern.test(conformanceName)) {
3744
+ continue;
3745
+ }
3746
+
3747
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? '');
3748
+
3749
+ let braceDepth =
3750
+ countTokenOccurrences(sanitizedLine, '{') - countTokenOccurrences(sanitizedLine, '}');
3751
+ const bodyStartLine = index + 1;
3752
+ let bodyEndLine = index + 1;
3753
+
3754
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
3755
+ const candidateLine = stripKotlinLineForSemanticScan(lines[cursor] ?? '');
3756
+ braceDepth += countTokenOccurrences(candidateLine, '{');
3757
+ braceDepth -= countTokenOccurrences(candidateLine, '}');
3758
+ bodyEndLine = cursor + 1;
3759
+ if (braceDepth <= 0) {
3760
+ break;
3761
+ }
3762
+ }
3763
+
3764
+ declarations.push({
3765
+ name: typeName,
3766
+ line: index + 1,
3767
+ conformances: [conformanceName],
3768
+ bodyStartLine,
3769
+ bodyEndLine,
3770
+ });
3771
+ }
3772
+
3773
+ return declarations;
3774
+ };
3775
+
3776
+ export const findAndroidWorkManagerBackgroundTaskMatch = (
3777
+ source: string
3778
+ ): KotlinAndroidWorkManagerBackgroundTaskMatch | undefined => {
3779
+ const lines = source.split(/\r?\n/);
3780
+ for (let index = 0; index < lines.length; index += 1) {
3781
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? '');
3782
+ if (!/\bclass\b/.test(sanitizedLine)) {
3783
+ continue;
3784
+ }
3785
+
3786
+ const declarationWindow = lines.slice(index, Math.min(lines.length, index + 8)).join('\n');
3787
+ const classMatch = declarationWindow.match(
3788
+ /class\s+([A-Za-z_][A-Za-z0-9_]*)[\s\S]{0,240}?\)\s*:\s*(?:[A-Za-z_][A-Za-z0-9_.]*\.)?([A-Za-z_][A-Za-z0-9_]*)\b/
3789
+ );
3790
+ const conformanceMatch = classMatch?.[2] ?? '';
3791
+ if (!classMatch || !workManagerWorkerConformancePattern.test(conformanceMatch)) {
3792
+ continue;
3793
+ }
3794
+
3795
+ const doWorkMatch = declarationWindow.match(/\boverride\s+(?:suspend\s+)?fun\s+doWork\s*\(/);
3796
+ if (!doWorkMatch || typeof doWorkMatch.index !== 'number') {
3797
+ continue;
3798
+ }
3799
+
3800
+ const doWorkLine = index + declarationWindow.slice(0, doWorkMatch.index).split(/\r?\n/).length;
3801
+ return {
3802
+ primary_node: {
3803
+ kind: 'class',
3804
+ name: classMatch[1],
3805
+ lines: [index + 1],
3806
+ },
3807
+ related_nodes: [
3808
+ {
3809
+ kind: 'member',
3810
+ name: conformanceMatch,
3811
+ lines: [index + 1],
3812
+ },
3813
+ {
3814
+ kind: 'call',
3815
+ name: 'doWork',
3816
+ lines: [doWorkLine],
3817
+ },
3818
+ ],
3819
+ why: `${classMatch[1]} implementa una tarea de WorkManager en segundo plano en lugar de ejecutar trabajo largo desde el hilo UI.`,
3820
+ impact:
3821
+ 'El trabajo diferido queda encapsulado en un Worker explícito que WorkManager puede reintentar y orquestar según las restricciones del sistema.',
3822
+ expected_fix:
3823
+ 'Usa Worker, CoroutineWorker o ListenableWorker con doWork() explícito para expresar el trabajo en background.',
3824
+ lines: sortedUniqueLines([index + 1, doWorkLine]),
3825
+ };
3826
+ }
3827
+
3828
+ return undefined;
3829
+ };
3830
+
3831
+ export const hasAndroidWorkManagerBackgroundTaskUsage = (source: string): boolean => {
3832
+ return findAndroidWorkManagerBackgroundTaskMatch(source) !== undefined;
3833
+ };
3834
+
3835
+ export type KotlinAndroidSuspendApiServiceMatch = {
3836
+ primary_node: KotlinSemanticNodeMatch;
3837
+ related_nodes: readonly KotlinSemanticNodeMatch[];
3838
+ why: string;
3839
+ impact: string;
3840
+ expected_fix: string;
3841
+ lines: readonly number[];
3842
+ };
3843
+
3844
+ export type KotlinAndroidSuspendFunctionsAsyncMatch = {
3845
+ primary_node: KotlinSemanticNodeMatch;
3846
+ related_nodes: readonly KotlinSemanticNodeMatch[];
3847
+ why: string;
3848
+ impact: string;
3849
+ expected_fix: string;
3850
+ lines: readonly number[];
3851
+ };
3852
+
3853
+ export type KotlinAndroidAsyncAwaitParallelismMatch = {
3854
+ primary_node: KotlinSemanticNodeMatch;
3855
+ related_nodes: readonly KotlinSemanticNodeMatch[];
3856
+ why: string;
3857
+ impact: string;
3858
+ expected_fix: string;
3859
+ lines: readonly number[];
3860
+ };
3861
+
3862
+ export type KotlinAndroidVersionCatalogMatch = {
3863
+ primary_node: KotlinSemanticNodeMatch;
3864
+ related_nodes: readonly KotlinSemanticNodeMatch[];
3865
+ why: string;
3866
+ impact: string;
3867
+ expected_fix: string;
3868
+ lines: readonly number[];
3869
+ };
3870
+
3871
+ export type KotlinAndroidTransactionMatch = {
3872
+ primary_node: KotlinSemanticNodeMatch;
3873
+ related_nodes: readonly KotlinSemanticNodeMatch[];
3874
+ why: string;
3875
+ impact: string;
3876
+ expected_fix: string;
3877
+ lines: readonly number[];
3878
+ };
3879
+
3880
+ export type KotlinAndroidWorkManagerDependencyMatch = {
3881
+ primary_node: KotlinSemanticNodeMatch;
3882
+ related_nodes: readonly KotlinSemanticNodeMatch[];
3883
+ why: string;
3884
+ impact: string;
3885
+ expected_fix: string;
3886
+ lines: readonly number[];
3887
+ };
3888
+
3889
+ export type KotlinAndroidWorkManagerBackgroundTaskMatch = {
3890
+ primary_node: KotlinSemanticNodeMatch;
3891
+ related_nodes: readonly KotlinSemanticNodeMatch[];
3892
+ why: string;
3893
+ impact: string;
3894
+ expected_fix: string;
3895
+ lines: readonly number[];
3896
+ };
3897
+
3898
+ export type KotlinAndroidInstrumentedTestMatch = {
3899
+ primary_node: KotlinSemanticNodeMatch;
3900
+ related_nodes: readonly KotlinSemanticNodeMatch[];
3901
+ why: string;
3902
+ impact: string;
3903
+ expected_fix: string;
3904
+ lines: readonly number[];
3905
+ };
3906
+
3907
+ export type KotlinAndroidTestStructureMatch = {
3908
+ primary_node: KotlinSemanticNodeMatch;
3909
+ related_nodes: readonly KotlinSemanticNodeMatch[];
3910
+ why: string;
3911
+ impact: string;
3912
+ expected_fix: string;
3913
+ lines: readonly number[];
3914
+ };
3915
+
3916
+ export const findAndroidSuspendFunctionsApiServiceMatch = (
3917
+ source: string
3918
+ ): KotlinAndroidSuspendApiServiceMatch | undefined => {
3919
+ const declarations = parseKotlinInterfaceDeclarations(source).filter((declaration) =>
3920
+ /(?:Api)?Service$/.test(declaration.name)
3921
+ );
3922
+
3923
+ for (const declaration of declarations) {
3924
+ const suspendFunctionLines = collectKotlinRegexLinesInRange(
3925
+ source,
3926
+ /\bsuspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/,
3927
+ declaration.bodyStartLine,
3928
+ declaration.bodyEndLine
3929
+ );
3930
+ if (suspendFunctionLines.length === 0) {
3931
+ continue;
3932
+ }
3933
+
3934
+ const lines = source.split(/\r?\n/);
3935
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
3936
+ {
3937
+ kind: 'member',
3938
+ name: `API service: ${declaration.name}`,
3939
+ lines: [declaration.line],
3940
+ },
3941
+ ];
3942
+
3943
+ for (const lineNumber of suspendFunctionLines) {
3944
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[lineNumber - 1] ?? '');
3945
+ const functionName = sanitizedLine.match(/\bsuspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/)?.[1];
3946
+ if (!functionName) {
3947
+ continue;
3948
+ }
3949
+ relatedNodes.push({
3950
+ kind: 'member',
3951
+ name: `suspend fun ${functionName}`,
3952
+ lines: [lineNumber],
3953
+ });
3954
+ }
3955
+
3956
+ const annotationLines = collectKotlinRegexLinesInRange(
3957
+ source,
3958
+ /\b(?:@GET|@POST|@PUT|@DELETE|@PATCH|@HEAD|@HTTP)\b/,
3959
+ declaration.bodyStartLine,
3960
+ declaration.bodyEndLine
3961
+ );
3962
+ if (annotationLines.length > 0) {
3963
+ relatedNodes.push({
3964
+ kind: 'member',
3965
+ name: 'Retrofit HTTP annotations',
3966
+ lines: annotationLines,
3967
+ });
3968
+ }
3969
+
3970
+ return {
3971
+ primary_node: {
3972
+ kind: 'class',
3973
+ name: declaration.name,
3974
+ lines: [declaration.line],
3975
+ },
3976
+ related_nodes: relatedNodes,
3977
+ why: `${declaration.name} define un contrato de API con suspend functions para operaciones remotas en lugar de callbacks o bloqueos.`,
3978
+ impact:
3979
+ 'La capa de red expone una API lineal, más fácil de orquestar desde coroutines y más simple de testear sin anidar callbacks.',
3980
+ expected_fix:
3981
+ 'Mantén los métodos del API service como suspend functions y resuelve la coordinación asíncrona en el caso de uso o ViewModel.',
3982
+ lines: sortedUniqueLines([
3983
+ declaration.line,
3984
+ ...relatedNodes.flatMap((node) => [...node.lines]),
3985
+ ]),
3986
+ };
3987
+ }
3988
+
3989
+ return undefined;
3990
+ };
3991
+
3992
+ export const hasAndroidSuspendFunctionsApiServiceUsage = (source: string): boolean => {
3993
+ return findAndroidSuspendFunctionsApiServiceMatch(source) !== undefined;
3994
+ };
3995
+
3996
+ export type KotlinAndroidDaoSuspendFunctionsMatch = {
3997
+ primary_node: KotlinSemanticNodeMatch;
3998
+ related_nodes: readonly KotlinSemanticNodeMatch[];
3999
+ why: string;
4000
+ impact: string;
4001
+ expected_fix: string;
4002
+ lines: readonly number[];
4003
+ };
4004
+
4005
+ const findKotlinDaoAnnotationLine = (sourceLines: readonly string[], declarationLine: number): number => {
4006
+ const start = Math.max(0, declarationLine - 4);
4007
+ for (let index = declarationLine - 2; index >= start; index -= 1) {
4008
+ const sanitizedLine = stripKotlinLineForSemanticScan(sourceLines[index] ?? '');
4009
+ if (/@Dao\b/.test(sanitizedLine)) {
4010
+ return index + 1;
4011
+ }
4012
+ }
4013
+ return declarationLine;
4014
+ };
4015
+
4016
+ const findKotlinTransactionAnnotationLine = (
4017
+ sourceLines: readonly string[],
4018
+ declarationLine: number,
4019
+ memberLine: number
4020
+ ): number | undefined => {
4021
+ const start = Math.max(0, memberLine - 4);
4022
+ for (let index = memberLine - 2; index >= start; index -= 1) {
4023
+ const sanitizedLine = stripKotlinLineForSemanticScan(sourceLines[index] ?? '');
4024
+ if (/@Transaction\b/.test(sanitizedLine)) {
4025
+ return index + 1;
4026
+ }
4027
+ }
4028
+
4029
+ return undefined;
4030
+ };
4031
+
4032
+ export const findAndroidTransactionMatch = (
4033
+ source: string
4034
+ ): KotlinAndroidTransactionMatch | undefined => {
4035
+ const declarations = parseKotlinInterfaceDeclarations(source).filter((declaration) =>
4036
+ /Dao$/.test(declaration.name)
4037
+ );
4038
+
4039
+ const sourceLines = source.split(/\r?\n/);
4040
+ for (const declaration of declarations) {
4041
+ const daoAnnotationLine = findKotlinDaoAnnotationLine(sourceLines, declaration.line);
4042
+ const transactionFunctions: KotlinSemanticNodeMatch[] = [];
4043
+
4044
+ for (const member of declaration.members) {
4045
+ const transactionAnnotationLine = findKotlinTransactionAnnotationLine(
4046
+ sourceLines,
4047
+ declaration.line,
4048
+ member.line
4049
+ );
4050
+ if (!transactionAnnotationLine) {
4051
+ continue;
4052
+ }
4053
+
4054
+ const memberLine = sourceLines[member.line - 1] ?? '';
4055
+ if (!/\bfun\b/.test(stripKotlinLineForSemanticScan(memberLine))) {
4056
+ continue;
4057
+ }
4058
+
4059
+ transactionFunctions.push({
4060
+ kind: 'member',
4061
+ name: `@Transaction fun ${member.name}`,
4062
+ lines: [transactionAnnotationLine, member.line],
4063
+ });
4064
+ }
4065
+
4066
+ if (transactionFunctions.length === 0) {
4067
+ continue;
4068
+ }
4069
+
4070
+ const [primaryFunction, ...otherFunctions] = transactionFunctions;
4071
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
4072
+ {
4073
+ kind: 'member',
4074
+ name: '@Dao',
4075
+ lines: [daoAnnotationLine],
4076
+ },
4077
+ primaryFunction,
4078
+ ...otherFunctions,
4079
+ ];
4080
+
4081
+ return {
4082
+ primary_node: {
4083
+ kind: 'class',
4084
+ name: declaration.name,
4085
+ lines: [declaration.line],
4086
+ },
4087
+ related_nodes: relatedNodes,
4088
+ why: `${declaration.name} marca operaciones multi-query con @Transaction para que Room ejecute la secuencia de lectura/escritura de forma atómica.`,
4089
+ impact:
4090
+ 'Las consultas múltiples quedan dentro de una transacción explícita y evitan estados intermedios inconsistentes o parciales durante la persistencia.',
4091
+ expected_fix:
4092
+ 'Anota con @Transaction los métodos DAO que coordinan varias queries o escrituras relacionadas para que Room las trate como una unidad atómica.',
4093
+ lines: sortedUniqueLines([
4094
+ daoAnnotationLine,
4095
+ declaration.line,
4096
+ ...relatedNodes.flatMap((node) => [...node.lines]),
4097
+ ]),
4098
+ };
4099
+ }
4100
+
4101
+ return undefined;
4102
+ };
4103
+
4104
+ export const findAndroidDaoSuspendFunctionsMatch = (
4105
+ source: string
4106
+ ): KotlinAndroidDaoSuspendFunctionsMatch | undefined => {
4107
+ const declarations = parseKotlinInterfaceDeclarations(source).filter((declaration) =>
4108
+ /Dao$/.test(declaration.name)
4109
+ );
4110
+
4111
+ const sourceLines = source.split(/\r?\n/);
4112
+ for (const declaration of declarations) {
4113
+ const suspendFunctionLines = collectKotlinRegexLinesInRange(
4114
+ source,
4115
+ /\bsuspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/,
4116
+ declaration.bodyStartLine,
4117
+ declaration.bodyEndLine
4118
+ );
4119
+
4120
+ if (suspendFunctionLines.length === 0) {
4121
+ continue;
4122
+ }
4123
+
4124
+ const daoAnnotationLine = findKotlinDaoAnnotationLine(sourceLines, declaration.line);
4125
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
4126
+ {
4127
+ kind: 'member',
4128
+ name: '@Dao',
4129
+ lines: [daoAnnotationLine],
4130
+ },
4131
+ ];
4132
+
4133
+ for (const lineNumber of suspendFunctionLines) {
4134
+ const sanitizedLine = stripKotlinLineForSemanticScan(sourceLines[lineNumber - 1] ?? '');
4135
+ const functionName = sanitizedLine.match(/\bsuspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/)?.[1];
4136
+ if (!functionName) {
4137
+ continue;
4138
+ }
4139
+ relatedNodes.push({
4140
+ kind: 'member',
4141
+ name: `suspend fun ${functionName}`,
4142
+ lines: [lineNumber],
4143
+ });
4144
+ }
4145
+
4146
+ if (relatedNodes.length <= 1) {
4147
+ continue;
4148
+ }
4149
+
4150
+ return {
4151
+ primary_node: {
4152
+ kind: 'class',
4153
+ name: declaration.name,
4154
+ lines: [declaration.line],
4155
+ },
4156
+ related_nodes: relatedNodes,
4157
+ why: `${declaration.name} concentra consultas y mutaciones de Room con suspend functions en la capa DAO en lugar de delegar a APIs lineales o contratos asíncronos más explícitos.`,
4158
+ impact:
4159
+ 'La capa de persistencia expone un contrato de acceso a datos asincrónico que resulta más fácil de componer desde coroutines y más claro de testear.',
4160
+ expected_fix:
4161
+ 'Mantén los métodos del DAO como suspend functions y deja la coordinación de flujo en repositorios o casos de uso.',
4162
+ lines: sortedUniqueLines([
4163
+ daoAnnotationLine,
4164
+ declaration.line,
4165
+ ...relatedNodes.flatMap((node) => [...node.lines]),
4166
+ ]),
4167
+ };
4168
+ }
4169
+
4170
+ return undefined;
4171
+ };
4172
+
4173
+ export const hasAndroidDaoSuspendFunctionsUsage = (source: string): boolean => {
4174
+ return findAndroidDaoSuspendFunctionsMatch(source) !== undefined;
4175
+ };
4176
+
4177
+ export const findAndroidSuspendFunctionsAsyncMatch = (
4178
+ source: string
4179
+ ): KotlinAndroidSuspendFunctionsAsyncMatch | undefined => {
4180
+ const declarations = [
4181
+ ...parseKotlinTypeDeclarations(source),
4182
+ ...parseKotlinInterfaceDeclarations(source),
4183
+ ].filter((declaration) => !/(?:Api)?Service$/.test(declaration.name) && !/Dao$/.test(declaration.name));
4184
+
4185
+ for (const declaration of declarations) {
4186
+ const suspendFunctionLines = collectKotlinRegexLinesInRange(
4187
+ source,
4188
+ /\bsuspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/,
4189
+ declaration.bodyStartLine,
4190
+ declaration.bodyEndLine
4191
+ );
4192
+ if (suspendFunctionLines.length === 0) {
4193
+ continue;
4194
+ }
4195
+
4196
+ const lines = source.split(/\r?\n/);
4197
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
4198
+ {
4199
+ kind: 'member',
4200
+ name: declaration.name,
4201
+ lines: [declaration.line],
4202
+ },
4203
+ ];
4204
+
4205
+ for (const lineNumber of suspendFunctionLines) {
4206
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[lineNumber - 1] ?? '');
4207
+ const functionName = sanitizedLine.match(/\bsuspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/)?.[1];
4208
+ if (!functionName) {
4209
+ continue;
4210
+ }
4211
+ relatedNodes.push({
4212
+ kind: 'member',
4213
+ name: `suspend fun ${functionName}`,
4214
+ lines: [lineNumber],
4215
+ });
4216
+ }
4217
+
4218
+ if (relatedNodes.length <= 1) {
4219
+ continue;
4220
+ }
4221
+
4222
+ return {
4223
+ primary_node: {
4224
+ kind: 'class',
4225
+ name: declaration.name,
4226
+ lines: [declaration.line],
4227
+ },
4228
+ related_nodes: relatedNodes,
4229
+ why: `${declaration.name} encapsula operaciones async con suspend functions en una frontera Kotlin explicita en lugar de dispersar callbacks o bloqueos.`,
4230
+ impact:
4231
+ 'La coordinación asíncrona queda expresada como API lineal, más fácil de componer desde coroutines y más simple de testear.',
4232
+ expected_fix:
4233
+ 'Mantén suspend functions para operaciones async en clases o interfaces de dominio, repositorio o coordinación y deja los callbacks fuera de la frontera Kotlin.',
4234
+ lines: sortedUniqueLines([
4235
+ declaration.line,
4236
+ ...relatedNodes.flatMap((node) => [...node.lines]),
4237
+ ]),
4238
+ };
4239
+ }
4240
+
4241
+ return undefined;
4242
+ };
4243
+
4244
+ export const hasAndroidSuspendFunctionsAsyncUsage = (source: string): boolean => {
4245
+ return findAndroidSuspendFunctionsAsyncMatch(source) !== undefined;
4246
+ };
4247
+
4248
+ export const findAndroidAsyncAwaitParallelismMatch = (
4249
+ source: string
4250
+ ): KotlinAndroidAsyncAwaitParallelismMatch | undefined => {
4251
+ const declarations = parseKotlinTypeDeclarations(source).filter((declaration) => {
4252
+ return !/(?:Api)?Service$/.test(declaration.name) && !/Dao$/.test(declaration.name);
4253
+ });
4254
+
4255
+ for (const declaration of declarations) {
4256
+ const asyncCallLines = collectKotlinRegexLinesInRange(
4257
+ source,
4258
+ /\basync\b.*\{/,
4259
+ declaration.bodyStartLine,
4260
+ declaration.bodyEndLine
4261
+ );
4262
+ const awaitLines = collectKotlinRegexLinesInRange(
4263
+ source,
4264
+ /\bawait(?:All)?\s*\(/,
4265
+ declaration.bodyStartLine,
4266
+ declaration.bodyEndLine
4267
+ );
4268
+
4269
+ if (asyncCallLines.length === 0 || awaitLines.length === 0) {
4270
+ continue;
4271
+ }
4272
+
4273
+ const lines = source.split(/\r?\n/);
4274
+ const relatedNodes: KotlinSemanticNodeMatch[] = [
4275
+ {
4276
+ kind: 'member',
4277
+ name: declaration.name,
4278
+ lines: [declaration.line],
4279
+ },
4280
+ {
4281
+ kind: 'call',
4282
+ name: 'async',
4283
+ lines: asyncCallLines,
4284
+ },
4285
+ {
4286
+ kind: 'call',
4287
+ name: awaitLines.some((lineNumber) => /\bawaitAll\s*\(/.test(lines[lineNumber - 1] ?? ''))
4288
+ ? 'awaitAll'
4289
+ : 'await',
4290
+ lines: awaitLines,
4291
+ },
4292
+ ];
4293
+
4294
+ return {
4295
+ primary_node: {
4296
+ kind: 'class',
4297
+ name: declaration.name,
4298
+ lines: [declaration.line],
4299
+ },
4300
+ related_nodes: relatedNodes,
4301
+ why: `${declaration.name} usa async/await para ejecutar trabajo paralelo en coroutines en lugar de secuenciar operaciones independientes.`,
4302
+ impact:
4303
+ 'Las tareas independientes pueden completarse en paralelo y luego coordinarse con await/awaitAll sin bloquear el hilo de llamada.',
4304
+ expected_fix:
4305
+ 'Usa async/await dentro de una coroutineScope cuando varias tareas independientes puedan ejecutarse en paralelo y luego combinarse.',
4306
+ lines: sortedUniqueLines([
4307
+ declaration.line,
4308
+ ...relatedNodes.flatMap((node) => [...node.lines]),
4309
+ ]),
4310
+ };
4311
+ }
4312
+
4313
+ return undefined;
4314
+ };
300
4315
 
301
- return kotlinSource[index + 2] !== '=';
302
- });
4316
+ export const hasAndroidAsyncAwaitParallelismUsage = (source: string): boolean => {
4317
+ return findAndroidAsyncAwaitParallelismMatch(source) !== undefined;
4318
+ };
4319
+
4320
+ export const hasAndroidTransactionUsage = (source: string): boolean => {
4321
+ return findAndroidTransactionMatch(source) !== undefined;
303
4322
  };
304
4323
 
305
4324
  export const hasAndroidJavaSourceCode = (source: string): boolean => {
@@ -444,6 +4463,111 @@ export const hasAndroidCoroutineTryCatchUsage = (source: string): boolean => {
444
4463
  });
445
4464
  };
446
4465
 
4466
+ export const findAndroidSupervisorScopeMatch = (
4467
+ source: string
4468
+ ): KotlinAndroidSupervisorScopeMatch | undefined => {
4469
+ const lines = source.split(/\r?\n/);
4470
+ const scopeLines = collectKotlinRegexLines(source, /\bsupervisorScope\b/).filter((lineNumber) => {
4471
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[lineNumber - 1] ?? '');
4472
+ return /\bsupervisorScope\b\s*(?:<[^>\n]+>\s*)?\{/.test(sanitizedLine);
4473
+ });
4474
+
4475
+ if (scopeLines.length === 0) {
4476
+ return undefined;
4477
+ }
4478
+
4479
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
4480
+ const childCoroutineLines = collectKotlinRegexLines(source, /\b(?:launch|async)\b/).filter(
4481
+ (lineNumber) => lineNumber > scopeLines[0] && lineNumber <= scopeLines[scopeLines.length - 1] + 12
4482
+ );
4483
+
4484
+ if (childCoroutineLines.length > 0) {
4485
+ relatedNodes.push({
4486
+ kind: 'call',
4487
+ name: 'launch/async',
4488
+ lines: childCoroutineLines,
4489
+ });
4490
+ }
4491
+
4492
+ return {
4493
+ primary_node: {
4494
+ kind: 'member',
4495
+ name: 'supervisorScope',
4496
+ lines: scopeLines,
4497
+ },
4498
+ related_nodes: relatedNodes,
4499
+ why:
4500
+ 'supervisorScope crea un ámbito supervisor donde el fallo de un job no cancela a los jobs hermanos.',
4501
+ impact:
4502
+ 'La coordinación concurrente mantiene aislada la propagación de errores y evita que una tarea secundaria tumbe el trabajo paralelo.',
4503
+ expected_fix:
4504
+ 'Usa supervisorScope cuando varios jobs deban progresar de forma independiente y reserva try/catch para el manejo explícito del error de cada rama.',
4505
+ lines: sortedUniqueLines([
4506
+ ...scopeLines,
4507
+ ...relatedNodes.flatMap((node) => [...node.lines]),
4508
+ ]),
4509
+ };
4510
+ };
4511
+
4512
+ export const hasAndroidSupervisorScopeUsage = (source: string): boolean => {
4513
+ return findAndroidSupervisorScopeMatch(source) !== undefined;
4514
+ };
4515
+
4516
+ const hasAndroidCallbackAsyncCallInLine = (line: string): boolean => {
4517
+ return /\b(?:enqueue|addOnSuccessListener|addOnFailureListener|addOnCompleteListener|addOnCanceledListener|setCallback|setCompletionListener)\s*(?:\(|\{)/.test(
4518
+ line
4519
+ );
4520
+ };
4521
+
4522
+ export const findAndroidCoroutineCallbackMatch = (
4523
+ source: string
4524
+ ): KotlinAndroidCallbackAsyncMatch | undefined => {
4525
+ const lines = source.split(/\r?\n/);
4526
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
4527
+
4528
+ for (let index = 0; index < lines.length; index += 1) {
4529
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? '');
4530
+ if (sanitizedLine.trimStart().startsWith('import ')) {
4531
+ continue;
4532
+ }
4533
+ if (!hasAndroidCallbackAsyncCallInLine(sanitizedLine)) {
4534
+ continue;
4535
+ }
4536
+
4537
+ const callMatch =
4538
+ sanitizedLine.match(/\b(enqueue|addOnSuccessListener|addOnFailureListener|addOnCompleteListener|addOnCanceledListener|setCallback|setCompletionListener)\s*(?:\(|\{)/) ??
4539
+ sanitizedLine.match(/\b(enqueue|addOnSuccessListener|addOnFailureListener|addOnCompleteListener|addOnCanceledListener|setCallback|setCompletionListener)\b/);
4540
+ const callName = callMatch?.[1] ?? 'callback async API';
4541
+ relatedNodes.push({
4542
+ kind: 'call',
4543
+ name: callName,
4544
+ lines: [index + 1],
4545
+ });
4546
+ }
4547
+
4548
+ const [primaryNode, ...restNodes] = relatedNodes;
4549
+ if (!primaryNode) {
4550
+ return undefined;
4551
+ }
4552
+
4553
+ return {
4554
+ primary_node: primaryNode,
4555
+ related_nodes: restNodes,
4556
+ why:
4557
+ `${primaryNode.name} sigue un patrón callback-based para trabajo asíncrono en Android en lugar de ` +
4558
+ 'usar coroutines/Flow con un flujo lineal de control.',
4559
+ impact:
4560
+ 'El flujo asíncrono queda dividido en callbacks dispersos, aumenta la complejidad de manejo de errores y hace más difícil razonar sobre cancelación y composición.',
4561
+ expected_fix:
4562
+ 'Reemplaza callbacks por suspend functions, coroutines o Flow y encapsula la coordinación asíncrona detrás de APIs lineales y testeables.',
4563
+ lines: sortedUniqueLines(relatedNodes.flatMap((node) => [...node.lines])),
4564
+ };
4565
+ };
4566
+
4567
+ export const hasAndroidCoroutineCallbackUsage = (source: string): boolean => {
4568
+ return findAndroidCoroutineCallbackMatch(source) !== undefined;
4569
+ };
4570
+
447
4571
  const isAndroidDebugLogGuardWindow = (lines: readonly string[], lineIndex: number): boolean => {
448
4572
  const guardPattern = /\bif\s*\(\s*BuildConfig\.DEBUG\s*\)/;
449
4573
  const windowStart = Math.max(0, lineIndex - 2);
@@ -455,7 +4579,11 @@ const isAndroidDebugLogGuardWindow = (lines: readonly string[], lineIndex: numbe
455
4579
  };
456
4580
 
457
4581
  const hasAndroidLogCallInLine = (line: string): boolean => {
458
- return /\b(?:Timber|Log)\s*\.\s*(?:v|d|i|w|e)\s*\(/.test(line);
4582
+ return /\bLog\s*\.\s*(?:v|d|i|w|e)\s*\(/.test(line);
4583
+ };
4584
+
4585
+ const hasAndroidTimberCallInLine = (line: string): boolean => {
4586
+ return /\bTimber\s*\.\s*(?:v|d|i|w|e)\s*\(/.test(line);
459
4587
  };
460
4588
 
461
4589
  export const hasAndroidNoConsoleLogUsage = (source: string): boolean => {
@@ -477,6 +4605,42 @@ export const hasAndroidNoConsoleLogUsage = (source: string): boolean => {
477
4605
  return false;
478
4606
  };
479
4607
 
4608
+ export const hasAndroidTimberUsage = (source: string): boolean => {
4609
+ const lines = source.split(/\r?\n/);
4610
+ for (let index = 0; index < lines.length; index += 1) {
4611
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? '');
4612
+ if (sanitizedLine.trimStart().startsWith('import ')) {
4613
+ continue;
4614
+ }
4615
+ if (!hasAndroidTimberCallInLine(sanitizedLine)) {
4616
+ continue;
4617
+ }
4618
+ if (isAndroidDebugLogGuardWindow(lines, index)) {
4619
+ continue;
4620
+ }
4621
+ return true;
4622
+ }
4623
+
4624
+ return false;
4625
+ };
4626
+
4627
+ export const hasAndroidBuildConfigConstantUsage = (source: string): boolean => {
4628
+ const lines = source.split(/\r?\n/);
4629
+ const buildConfigPattern = /\bBuildConfig\s*\.\s*(?!DEBUG\b)[A-Z_][A-Z0-9_]*/;
4630
+
4631
+ for (const line of lines) {
4632
+ const sanitizedLine = stripKotlinLineForSemanticScan(line);
4633
+ if (sanitizedLine.trimStart().startsWith('import ')) {
4634
+ continue;
4635
+ }
4636
+ if (buildConfigPattern.test(sanitizedLine)) {
4637
+ return true;
4638
+ }
4639
+ }
4640
+
4641
+ return false;
4642
+ };
4643
+
480
4644
  export const hasAndroidHardcodedStringUsage = (source: string): boolean => {
481
4645
  let inLineComment = false;
482
4646
  let inBlockComment = 0;
@@ -584,6 +4748,371 @@ const hasAndroidSingletonCompanionPattern = (block: string): boolean => {
584
4748
  );
585
4749
  };
586
4750
 
4751
+ const hasAndroidAnnotationUsage = (source: string, annotation: string): boolean => {
4752
+ return scanCodeLikeSource(source, ({ source: androidSource, index, current }) => {
4753
+ if (current !== '@') {
4754
+ return false;
4755
+ }
4756
+
4757
+ return androidSource.startsWith(annotation, index + 1);
4758
+ });
4759
+ };
4760
+
4761
+ const hasAndroidHiltDependencyLine = (source: string): boolean => {
4762
+ return /(?:com\.google\.dagger:hilt-android|com\.google\.dagger\.hilt\.android|dagger\.hilt\.android\.plugin)\b/.test(
4763
+ source
4764
+ );
4765
+ };
4766
+
4767
+ export const hasAndroidHiltFrameworkUsage = (source: string): boolean => {
4768
+ return scanCodeLikeSource(source, ({ source: androidSource, index, current }) => {
4769
+ if (current === '@') {
4770
+ return (
4771
+ androidSource.startsWith('HiltAndroidApp', index + 1) ||
4772
+ androidSource.startsWith('AndroidEntryPoint', index + 1) ||
4773
+ androidSource.startsWith('HiltViewModel', index + 1) ||
4774
+ androidSource.startsWith('Module', index + 1) ||
4775
+ androidSource.startsWith('InstallIn', index + 1) ||
4776
+ androidSource.startsWith('ViewModelScoped', index + 1)
4777
+ );
4778
+ }
4779
+
4780
+ if (current === 'j' && androidSource.startsWith('javax.inject.Inject', index)) {
4781
+ return true;
4782
+ }
4783
+
4784
+ return current === 'd' && androidSource.startsWith('dagger.hilt', index);
4785
+ });
4786
+ };
4787
+
4788
+ export const hasAndroidHiltDependencyUsage = (source: string): boolean => {
4789
+ return source.split(/\r?\n/).some((line) => {
4790
+ const sanitizedLine = (line ?? '').replace(/\/\/.*$/, '').replace(/#.*$/, '').trim();
4791
+ return hasAndroidHiltDependencyLine(sanitizedLine);
4792
+ });
4793
+ };
4794
+
4795
+ const hasAndroidWorkManagerDependencyLine = (line: string): boolean => {
4796
+ return /(?:androidx\.work:work-runtime-ktx\b|androidx\.work:work-runtime\b|work-runtime-ktx\b)/.test(
4797
+ line
4798
+ );
4799
+ };
4800
+
4801
+ export const findAndroidWorkManagerDependencyMatch = (
4802
+ source: string
4803
+ ): KotlinAndroidWorkManagerDependencyMatch | undefined => {
4804
+ const lines = source.split(/\r?\n/);
4805
+
4806
+ for (let index = 0; index < lines.length; index += 1) {
4807
+ const sanitizedLine = (lines[index] ?? '').replace(/\/\/.*$/, '').replace(/#.*$/, '').trim();
4808
+ if (!hasAndroidWorkManagerDependencyLine(sanitizedLine)) {
4809
+ continue;
4810
+ }
4811
+
4812
+ return {
4813
+ primary_node: {
4814
+ kind: 'member',
4815
+ name: 'WorkManager dependency',
4816
+ lines: [index + 1],
4817
+ },
4818
+ related_nodes: [
4819
+ {
4820
+ kind: 'member',
4821
+ name: 'androidx.work:work-runtime-ktx',
4822
+ lines: [index + 1],
4823
+ },
4824
+ ],
4825
+ why:
4826
+ 'El build declara WorkManager como dependencia Jetpack para programar tareas diferidas y persistentes en Android.',
4827
+ impact:
4828
+ 'La app puede orquestar trabajo en background con una API soportada en lugar de depender de jobs o callbacks manuales.',
4829
+ expected_fix:
4830
+ 'Mantén androidx.work:work-runtime-ktx en el catálogo o build script para expresar el contrato de WorkManager de forma explícita.',
4831
+ lines: [index + 1],
4832
+ };
4833
+ }
4834
+
4835
+ return undefined;
4836
+ };
4837
+
4838
+ export const hasAndroidWorkManagerDependencyUsage = (source: string): boolean => {
4839
+ return findAndroidWorkManagerDependencyMatch(source) !== undefined;
4840
+ };
4841
+
4842
+ export const findAndroidVersionCatalogMatch = (
4843
+ source: string
4844
+ ): KotlinAndroidVersionCatalogMatch | undefined => {
4845
+ const lines = source.split(/\r?\n/);
4846
+ const relatedNodes: KotlinSemanticNodeMatch[] = [];
4847
+ let currentSection = '';
4848
+ let versionSectionLine = 0;
4849
+ let librariesSectionLine = 0;
4850
+ let versionAliasLine = 0;
4851
+ let libraryAliasLine = 0;
4852
+
4853
+ for (let index = 0; index < lines.length; index += 1) {
4854
+ const sanitizedLine = stripTomlLineForSemanticScan(lines[index] ?? '').trim();
4855
+ if (sanitizedLine.length === 0) {
4856
+ continue;
4857
+ }
4858
+
4859
+ const sectionMatch = sanitizedLine.match(/^\[([A-Za-z0-9_.-]+)\]$/);
4860
+ if (sectionMatch?.[1]) {
4861
+ currentSection = sectionMatch[1].trim().toLowerCase();
4862
+ if (currentSection === 'versions' && versionSectionLine === 0) {
4863
+ versionSectionLine = index + 1;
4864
+ relatedNodes.push({
4865
+ kind: 'member',
4866
+ name: 'versions section',
4867
+ lines: [versionSectionLine],
4868
+ });
4869
+ }
4870
+ if (currentSection === 'libraries' && librariesSectionLine === 0) {
4871
+ librariesSectionLine = index + 1;
4872
+ relatedNodes.push({
4873
+ kind: 'member',
4874
+ name: 'libraries section',
4875
+ lines: [librariesSectionLine],
4876
+ });
4877
+ }
4878
+ continue;
4879
+ }
4880
+
4881
+ if (currentSection === 'versions') {
4882
+ const versionMatch = sanitizedLine.match(/^([A-Za-z0-9_.-]+)\s*=\s*.+$/);
4883
+ if (versionMatch?.[1]) {
4884
+ versionAliasLine = versionAliasLine || index + 1;
4885
+ relatedNodes.push({
4886
+ kind: 'member',
4887
+ name: `version alias: ${versionMatch[1]}`,
4888
+ lines: [index + 1],
4889
+ });
4890
+ }
4891
+ continue;
4892
+ }
4893
+
4894
+ if (currentSection !== 'libraries') {
4895
+ continue;
4896
+ }
4897
+
4898
+ const inlineLibraryMatch = sanitizedLine.match(/^([A-Za-z0-9_.-]+)\s*=\s*\{(.+)\}$/);
4899
+ if (inlineLibraryMatch?.[1]) {
4900
+ const inlineBody = inlineLibraryMatch[2] ?? '';
4901
+ if (/\b(?:module|group)\s*=/.test(inlineBody) && /\bversion(?:\.ref)?\s*=/.test(inlineBody)) {
4902
+ libraryAliasLine = libraryAliasLine || index + 1;
4903
+ relatedNodes.push({
4904
+ kind: 'member',
4905
+ name: `library alias: ${inlineLibraryMatch[1]}`,
4906
+ lines: [index + 1],
4907
+ });
4908
+ }
4909
+ continue;
4910
+ }
4911
+
4912
+ if (
4913
+ /^(?:[A-Za-z0-9_.-]+)\s*=\s*\{$/.test(sanitizedLine) ||
4914
+ /^\s*(?:[A-Za-z0-9_.-]+)\s*=\s*\{$/.test(sanitizedLine)
4915
+ ) {
4916
+ const aliasName = sanitizedLine.match(/^([A-Za-z0-9_.-]+)\s*=\s*\{$/)?.[1];
4917
+ if (aliasName) {
4918
+ const blockLines = [index + 1];
4919
+ let braceDepth = 1;
4920
+ let hasModuleOrGroup = false;
4921
+ let hasVersionReference = false;
4922
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
4923
+ const candidateLine = stripTomlLineForSemanticScan(lines[cursor] ?? '').trim();
4924
+ blockLines.push(cursor + 1);
4925
+ braceDepth += (candidateLine.match(/\{/g) ?? []).length;
4926
+ braceDepth -= (candidateLine.match(/\}/g) ?? []).length;
4927
+ if (/^(?:module|group)\s*=/.test(candidateLine)) {
4928
+ hasModuleOrGroup = true;
4929
+ }
4930
+ if (/^version(?:\.ref)?\s*=/.test(candidateLine)) {
4931
+ hasVersionReference = true;
4932
+ }
4933
+ if (braceDepth <= 0) {
4934
+ if (hasModuleOrGroup && hasVersionReference) {
4935
+ libraryAliasLine = libraryAliasLine || index + 1;
4936
+ relatedNodes.push({
4937
+ kind: 'member',
4938
+ name: `library alias: ${aliasName}`,
4939
+ lines: [index + 1],
4940
+ });
4941
+ relatedNodes.push({
4942
+ kind: 'member',
4943
+ name: 'library block',
4944
+ lines: blockLines,
4945
+ });
4946
+ }
4947
+ break;
4948
+ }
4949
+ }
4950
+ }
4951
+ }
4952
+ }
4953
+
4954
+ if (versionSectionLine === 0 || librariesSectionLine === 0 || versionAliasLine === 0) {
4955
+ return undefined;
4956
+ }
4957
+
4958
+ if (libraryAliasLine === 0) {
4959
+ return undefined;
4960
+ }
4961
+
4962
+ return {
4963
+ primary_node: {
4964
+ kind: 'member',
4965
+ name: 'libs.versions.toml',
4966
+ lines: [versionSectionLine],
4967
+ },
4968
+ related_nodes: relatedNodes,
4969
+ why:
4970
+ 'libs.versions.toml centraliza versiones y aliases de dependencias Android en un catálogo tipado en lugar de dispersarlos por build scripts.',
4971
+ impact:
4972
+ 'Las versiones y aliases quedan centralizados, más fáciles de revisar y consumibles desde Gradle mediante accessors consistentes.',
4973
+ expected_fix:
4974
+ 'Mantén versiones, bibliotecas y plugins en libs.versions.toml y consume las dependencias desde los accessors del catálogo.',
4975
+ lines: sortedUniqueLines([
4976
+ versionSectionLine,
4977
+ librariesSectionLine,
4978
+ versionAliasLine,
4979
+ libraryAliasLine,
4980
+ ...relatedNodes.flatMap((node) => [...node.lines]),
4981
+ ]),
4982
+ };
4983
+ };
4984
+
4985
+ export const hasAndroidVersionCatalogUsage = (source: string): boolean => {
4986
+ return findAndroidVersionCatalogMatch(source) !== undefined;
4987
+ };
4988
+
4989
+ export const findAndroidInstrumentedTestMatch = (
4990
+ source: string
4991
+ ): KotlinAndroidInstrumentedTestMatch | undefined => {
4992
+ const markerLines = collectAndroidTestMarkerLines(source);
4993
+ if (markerLines.length === 0) {
4994
+ return undefined;
4995
+ }
4996
+
4997
+ return {
4998
+ primary_node: {
4999
+ kind: 'member',
5000
+ name: 'androidTest/',
5001
+ lines: [markerLines[0]],
5002
+ },
5003
+ related_nodes: markerLines.map((line) => ({
5004
+ kind: 'member',
5005
+ name: 'androidTest marker',
5006
+ lines: [line],
5007
+ })),
5008
+ why:
5009
+ 'androidTest agrupa pruebas instrumentadas Android que deben ejecutarse en dispositivo o emulador con soporte del framework Android.',
5010
+ impact:
5011
+ 'Las pruebas instrumentadas quedan aisladas de los unit tests JVM y pueden validar UI, ActivityScenario y APIs Android reales.',
5012
+ expected_fix:
5013
+ 'Mantén las pruebas instrumentadas en androidTest con AndroidJUnit4, ActivityScenario, Espresso o Compose testing sobre dispositivo o emulador.',
5014
+ lines: markerLines,
5015
+ };
5016
+ };
5017
+
5018
+ export const hasAndroidInstrumentedTestUsage = (source: string): boolean => {
5019
+ return findAndroidInstrumentedTestMatch(source) !== undefined;
5020
+ };
5021
+
5022
+ export const findAndroidAaaPatternMatch = (
5023
+ source: string
5024
+ ): KotlinAndroidTestStructureMatch | undefined => {
5025
+ return buildAndroidTestStructureMatch({
5026
+ source,
5027
+ markerPatterns: [
5028
+ /^\s*(?:\/\/|\/\*|\*)?\s*Arrange\b/i,
5029
+ /^\s*(?:\/\/|\/\*|\*)?\s*Act\b/i,
5030
+ /^\s*(?:\/\/|\/\*|\*)?\s*Assert\b/i,
5031
+ ],
5032
+ relatedLabel: 'AAA marker',
5033
+ why:
5034
+ 'La estructura AAA mantiene cada test Kotlin dividido en Arrange, Act y Assert para que el escenario sea fácil de leer y revisar.',
5035
+ impact:
5036
+ 'Las pruebas ganan intención explícita, menos ambigüedad y una separación clara entre preparación, acción y verificación.',
5037
+ expectedFix:
5038
+ 'Mantén los tests de Android con bloques Arrange, Act y Assert bien separados y evita mezclar preparación, acción y aserciones en una sola masa de código.',
5039
+ });
5040
+ };
5041
+
5042
+ export const hasAndroidAaaPatternUsage = (source: string): boolean => {
5043
+ return findAndroidAaaPatternMatch(source) !== undefined;
5044
+ };
5045
+
5046
+ export const findAndroidGivenWhenThenMatch = (
5047
+ source: string
5048
+ ): KotlinAndroidTestStructureMatch | undefined => {
5049
+ return buildAndroidTestStructureMatch({
5050
+ source,
5051
+ markerPatterns: [
5052
+ /^\s*(?:\/\/|\/\*|\*)?\s*Given\b/i,
5053
+ /^\s*(?:\/\/|\/\*|\*)?\s*When\b/i,
5054
+ /^\s*(?:\/\/|\/\*|\*)?\s*Then\b/i,
5055
+ ],
5056
+ relatedLabel: 'BDD marker',
5057
+ why:
5058
+ 'La estructura Given-When-Then mantiene cada test Kotlin expresado como una historia de comportamiento legible para quien revisa la suite.',
5059
+ impact:
5060
+ 'Los escenarios de prueba quedan más cercanos al lenguaje de producto y más fáciles de mantener cuando cambia el comportamiento.',
5061
+ expectedFix:
5062
+ 'Mantén los tests de Android escritos con Given, When y Then bien separados para que cada escenario se lea como una especificación de comportamiento.',
5063
+ });
5064
+ };
5065
+
5066
+ export const hasAndroidGivenWhenThenUsage = (source: string): boolean => {
5067
+ return findAndroidGivenWhenThenMatch(source) !== undefined;
5068
+ };
5069
+
5070
+ export const findAndroidJvmUnitTestMatch = (
5071
+ source: string
5072
+ ): KotlinAndroidTestStructureMatch | undefined => {
5073
+ return buildAndroidJvmUnitTestMatch(source);
5074
+ };
5075
+
5076
+ export const hasAndroidJvmUnitTestUsage = (source: string): boolean => {
5077
+ return findAndroidJvmUnitTestMatch(source) !== undefined;
5078
+ };
5079
+
5080
+ export const hasAndroidHiltAndroidAppUsage = (source: string): boolean => {
5081
+ return hasAndroidAnnotationUsage(source, 'HiltAndroidApp');
5082
+ };
5083
+
5084
+ export const hasAndroidAndroidEntryPointUsage = (source: string): boolean => {
5085
+ return hasAndroidAnnotationUsage(source, 'AndroidEntryPoint');
5086
+ };
5087
+
5088
+ export const hasAndroidInjectConstructorUsage = (source: string): boolean => {
5089
+ return source.split(/\r?\n/).some((line) => {
5090
+ const sanitizedLine = stripKotlinLineForSemanticScan(line ?? '');
5091
+ if (!/@Inject\b/.test(sanitizedLine)) {
5092
+ return false;
5093
+ }
5094
+
5095
+ const tail = sanitizedLine.slice(sanitizedLine.indexOf('@Inject') + '@Inject'.length);
5096
+ return /^\s*(?:<[^>\n]+>\s*)?constructor\b/.test(tail);
5097
+ });
5098
+ };
5099
+
5100
+ export const hasAndroidModuleInstallInUsage = (source: string): boolean => {
5101
+ return hasAndroidAnnotationUsage(source, 'Module') && hasAndroidAnnotationUsage(source, 'InstallIn');
5102
+ };
5103
+
5104
+ export const hasAndroidBindsUsage = (source: string): boolean => {
5105
+ return hasAndroidAnnotationUsage(source, 'Binds') && hasAndroidModuleInstallInUsage(source);
5106
+ };
5107
+
5108
+ export const hasAndroidProvidesUsage = (source: string): boolean => {
5109
+ return hasAndroidAnnotationUsage(source, 'Provides') && hasAndroidModuleInstallInUsage(source);
5110
+ };
5111
+
5112
+ export const hasAndroidViewModelScopedUsage = (source: string): boolean => {
5113
+ return hasAndroidAnnotationUsage(source, 'ViewModelScoped');
5114
+ };
5115
+
587
5116
  export const hasAndroidSingletonUsage = (source: string): boolean => {
588
5117
  const lines = source.split(/\r?\n/);
589
5118
 
@@ -1182,5 +5711,75 @@ export const findKotlinLiskovSubstitutionMatch = (
1182
5711
  }
1183
5712
  }
1184
5713
 
5714
+ const daoAnnotationIndex = sourceLines.findIndex((line) =>
5715
+ /@Dao\b/.test(stripKotlinLineForSemanticScan(line ?? ''))
5716
+ );
5717
+ if (daoAnnotationIndex >= 0) {
5718
+ let declarationLine = -1;
5719
+ let declarationName = '';
5720
+ for (let cursor = daoAnnotationIndex + 1; cursor < Math.min(sourceLines.length, daoAnnotationIndex + 6); cursor += 1) {
5721
+ const candidateLine = stripKotlinLineForSemanticScan(sourceLines[cursor] ?? '');
5722
+ const declarationMatch = candidateLine.match(
5723
+ /^\s*(?:abstract\s+)?(?:interface|class)\s+([A-Za-z_][A-Za-z0-9_]*)\b/
5724
+ );
5725
+ if (!declarationMatch?.[1]) {
5726
+ continue;
5727
+ }
5728
+ declarationLine = cursor + 1;
5729
+ declarationName = declarationMatch[1];
5730
+ break;
5731
+ }
5732
+
5733
+ if (declarationLine >= 0 && declarationName.length > 0) {
5734
+ const suspendFunctionLines = sourceLines
5735
+ .map((line, index) => ({ line, index }))
5736
+ .filter(({ index }) => index + 1 > declarationLine)
5737
+ .filter(({ line }) =>
5738
+ /\bsuspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/.test(
5739
+ stripKotlinLineForSemanticScan(line ?? '')
5740
+ )
5741
+ )
5742
+ .map(({ index, line }) => ({
5743
+ lineNumber: index + 1,
5744
+ name:
5745
+ stripKotlinLineForSemanticScan(line ?? '')
5746
+ .match(/\bsuspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/)?.[1] ?? '',
5747
+ }))
5748
+ .filter((entry) => entry.name.length > 0);
5749
+
5750
+ if (suspendFunctionLines.length > 0) {
5751
+ return {
5752
+ primary_node: {
5753
+ kind: 'class',
5754
+ name: declarationName,
5755
+ lines: [declarationLine],
5756
+ },
5757
+ related_nodes: [
5758
+ {
5759
+ kind: 'member',
5760
+ name: '@Dao',
5761
+ lines: [daoAnnotationIndex + 1],
5762
+ },
5763
+ ...suspendFunctionLines.map((entry) => ({
5764
+ kind: 'member' as const,
5765
+ name: `suspend fun ${entry.name}`,
5766
+ lines: [entry.lineNumber],
5767
+ })),
5768
+ ],
5769
+ why: `${declarationName} concentra consultas y mutaciones de Room con suspend functions en la capa DAO en lugar de delegar a APIs lineales o contratos asíncronos más explícitos.`,
5770
+ impact:
5771
+ 'La capa de persistencia expone un contrato de acceso a datos asincrónico que resulta más fácil de componer desde coroutines y más claro de testear.',
5772
+ expected_fix:
5773
+ 'Mantén los métodos del DAO como suspend functions y deja la coordinación de flujo en repositorios o casos de uso.',
5774
+ lines: sortedUniqueLines([
5775
+ daoAnnotationIndex + 1,
5776
+ declarationLine,
5777
+ ...suspendFunctionLines.flatMap((entry) => [entry.lineNumber]),
5778
+ ]),
5779
+ };
5780
+ }
5781
+ }
5782
+ }
5783
+
1185
5784
  return undefined;
1186
5785
  };