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
@@ -1,6 +1,48 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import {
4
+ hasAndroidAndroidEntryPointUsage,
5
+ hasAndroidComposableFunctionUsage,
6
+ hasAndroidArgumentsUsage,
7
+ hasAndroidCoroutineCallbackUsage,
8
+ hasAndroidGodActivityUsage,
9
+ hasAndroidStateFlowUsage,
10
+ hasAndroidSingleSourceOfTruthUsage,
11
+ hasAndroidSharedFlowUsage,
12
+ hasAndroidFlowBuilderUsage,
13
+ hasAndroidFlowCollectUsage,
14
+ hasAndroidCollectAsStateUsage,
15
+ hasAndroidRememberUsage,
16
+ hasAndroidDerivedStateOfUsage,
17
+ hasAndroidLaunchedEffectUsage,
18
+ hasAndroidLaunchedEffectKeysUsage,
19
+ hasAndroidDisposableEffectUsage,
20
+ hasAndroidPreviewUsage,
21
+ hasAndroidAdaptiveLayoutsUsage,
22
+ hasAndroidExistingStructureUsage,
23
+ hasAndroidThemeUsage,
24
+ hasAndroidDarkThemeUsage,
25
+ hasAndroidAccessibilityUsage,
26
+ hasAndroidContentDescriptionUsage,
27
+ hasAndroidTalkBackUsage,
28
+ hasAndroidTextScalingUsage,
29
+ hasAndroidTouchTargetsUsage,
30
+ hasAndroidRecompositionUsage,
31
+ hasAndroidUiStateUsage,
32
+ hasAndroidStateHoistingUsage,
33
+ hasAndroidRepositoryPatternUsage,
34
+ hasAndroidOrdersRepUsage,
35
+ hasAndroidUseCaseUsage,
36
+ hasAndroidViewModelUsage,
37
+ hasAndroidViewModelScopeUsage,
38
+ hasAndroidSupervisorScopeUsage,
39
+ hasAndroidAppStartupUsage,
40
+ hasAndroidAnalyticsUsage,
41
+ findAndroidProfilerMatch,
42
+ hasAndroidProfilerUsage,
43
+ hasAndroidBaselineProfilesUsage,
44
+ hasAndroidSkipRecompositionUsage,
45
+ hasAndroidStabilityUsage,
4
46
  findKotlinConcreteDependencyDipMatch,
5
47
  findKotlinInterfaceSegregationMatch,
6
48
  findKotlinLiskovSubstitutionMatch,
@@ -8,14 +50,95 @@ import {
8
50
  findKotlinPresentationSrpMatch,
9
51
  hasAndroidAsyncTaskUsage,
10
52
  hasAndroidFindViewByIdUsage,
53
+ hasAndroidHiltAndroidAppUsage,
54
+ hasAndroidHiltDependencyUsage,
55
+ hasAndroidWorkManagerDependencyUsage,
56
+ hasAndroidWorkManagerBackgroundTaskUsage,
57
+ hasAndroidHiltFrameworkUsage,
58
+ hasAndroidInjectConstructorUsage,
11
59
  hasAndroidJavaSourceCode,
12
60
  hasAndroidDispatcherUsage,
13
61
  hasAndroidCoroutineTryCatchUsage,
14
62
  hasAndroidHardcodedStringUsage,
63
+ hasAndroidBuildConfigConstantUsage,
64
+ findAndroidStringsXmlMatch,
65
+ findAndroidStringFormattingMatch,
66
+ hasAndroidStringsXmlUsage,
67
+ hasAndroidStringFormattingUsage,
68
+ findAndroidPluralsXmlMatch,
69
+ hasAndroidPluralsXmlUsage,
15
70
  hasAndroidNoConsoleLogUsage,
71
+ hasAndroidTimberUsage,
72
+ hasAndroidModuleInstallInUsage,
73
+ hasAndroidBindsUsage,
74
+ hasAndroidProvidesUsage,
16
75
  hasAndroidRxJavaUsage,
17
76
  hasAndroidSingletonUsage,
77
+ hasAndroidInstrumentedTestUsage,
78
+ hasAndroidAaaPatternUsage,
79
+ hasAndroidGivenWhenThenUsage,
80
+ hasAndroidJvmUnitTestUsage,
81
+ hasAndroidVersionCatalogUsage,
82
+ hasAndroidDaoSuspendFunctionsUsage,
83
+ hasAndroidTransactionUsage,
84
+ hasAndroidSuspendFunctionsApiServiceUsage,
85
+ hasAndroidSuspendFunctionsAsyncUsage,
86
+ hasAndroidAsyncAwaitParallelismUsage,
87
+ hasAndroidSingleActivityComposeShellUsage,
18
88
  hasAndroidWithContextUsage,
89
+ hasAndroidViewModelScopedUsage,
90
+ findAndroidComposableFunctionMatch,
91
+ findAndroidArgumentsMatch,
92
+ findAndroidCoroutineCallbackMatch,
93
+ findAndroidGodActivityMatch,
94
+ findAndroidDaoSuspendFunctionsMatch,
95
+ findAndroidSuspendFunctionsApiServiceMatch,
96
+ findAndroidSuspendFunctionsAsyncMatch,
97
+ findAndroidAsyncAwaitParallelismMatch,
98
+ findAndroidStateFlowMatch,
99
+ findAndroidSingleSourceOfTruthMatch,
100
+ findAndroidSharedFlowMatch,
101
+ findAndroidFlowBuilderMatch,
102
+ findAndroidFlowCollectMatch,
103
+ findAndroidCollectAsStateMatch,
104
+ findAndroidRememberMatch,
105
+ findAndroidDerivedStateOfMatch,
106
+ findAndroidLaunchedEffectMatch,
107
+ findAndroidLaunchedEffectKeysMatch,
108
+ findAndroidDisposableEffectMatch,
109
+ findAndroidPreviewMatch,
110
+ findAndroidAdaptiveLayoutsMatch,
111
+ findAndroidExistingStructureMatch,
112
+ findAndroidThemeMatch,
113
+ findAndroidDarkThemeMatch,
114
+ findAndroidAccessibilityMatch,
115
+ findAndroidContentDescriptionMatch,
116
+ findAndroidTalkBackMatch,
117
+ findAndroidTextScalingMatch,
118
+ findAndroidTouchTargetsMatch,
119
+ findAndroidRecompositionMatch,
120
+ findAndroidUiStateMatch,
121
+ findAndroidStateHoistingMatch,
122
+ findAndroidRepositoryPatternMatch,
123
+ findAndroidOrdersRepMatch,
124
+ findAndroidUseCaseMatch,
125
+ findAndroidViewModelMatch,
126
+ findAndroidViewModelScopeMatch,
127
+ findAndroidSupervisorScopeMatch,
128
+ findAndroidAppStartupMatch,
129
+ findAndroidAnalyticsMatch,
130
+ findAndroidBaselineProfilesMatch,
131
+ findAndroidSkipRecompositionMatch,
132
+ findAndroidStabilityMatch,
133
+ findAndroidInstrumentedTestMatch,
134
+ findAndroidAaaPatternMatch,
135
+ findAndroidGivenWhenThenMatch,
136
+ findAndroidJvmUnitTestMatch,
137
+ findAndroidVersionCatalogMatch,
138
+ findAndroidWorkManagerDependencyMatch,
139
+ findAndroidWorkManagerBackgroundTaskMatch,
140
+ findAndroidSingleActivityComposeShellMatch,
141
+ findAndroidTransactionMatch,
19
142
  hasKotlinForceUnwrapUsage,
20
143
  hasKotlinGlobalScopeUsage,
21
144
  hasKotlinRunBlockingUsage,
@@ -102,41 +225,2289 @@ fun main() {
102
225
  assert.equal(hasKotlinRunBlockingUsage(partialSource), false);
103
226
  });
104
227
 
228
+ test('findAndroidCoroutineCallbackMatch devuelve payload semantico para callbacks Android', () => {
229
+ const source = `
230
+ fun loadRemoteData() {
231
+ service.enqueue()
232
+ task.addOnSuccessListener { }
233
+ task.addOnCompleteListener { }
234
+ }
235
+ `;
236
+
237
+ const match = findAndroidCoroutineCallbackMatch(source);
238
+
239
+ assert.ok(match);
240
+ assert.deepEqual(match.primary_node, {
241
+ kind: 'call',
242
+ name: 'enqueue',
243
+ lines: [3],
244
+ });
245
+ assert.deepEqual(match.related_nodes, [
246
+ { kind: 'call', name: 'addOnSuccessListener', lines: [4] },
247
+ { kind: 'call', name: 'addOnCompleteListener', lines: [5] },
248
+ ]);
249
+ assert.match(match.why, /callback/i);
250
+ assert.match(match.impact, /callback|error|cancel/i);
251
+ assert.match(match.expected_fix, /coroutines|Flow|suspend/i);
252
+ });
253
+
254
+ test('hasAndroidCoroutineCallbackUsage ignora comentarios, strings y llamadas no callback', () => {
255
+ const source = `
256
+ // service.enqueue()
257
+ val sample = "addOnSuccessListener()"
258
+ fun loadRemoteData() {
259
+ loadCoroutine()
260
+ }
261
+ `;
262
+
263
+ assert.equal(hasAndroidCoroutineCallbackUsage(source), false);
264
+ });
265
+
266
+ test('findAndroidStateFlowMatch devuelve payload semantico para StateFlow en ViewModel', () => {
267
+ const source = `
268
+ class CatalogViewModel : ViewModel() {
269
+ private val _uiState = MutableStateFlow(CatalogUiState())
270
+ val uiState: StateFlow<CatalogUiState> = _uiState.asStateFlow()
271
+ }
272
+ `;
273
+
274
+ const match = findAndroidStateFlowMatch(source);
275
+
276
+ assert.ok(match);
277
+ assert.deepEqual(match.primary_node, {
278
+ kind: 'class',
279
+ name: 'CatalogViewModel',
280
+ lines: [2],
281
+ });
282
+ assert.deepEqual(match.related_nodes, [
283
+ { kind: 'property', name: 'MutableStateFlow', lines: [3] },
284
+ { kind: 'property', name: 'StateFlow', lines: [4] },
285
+ { kind: 'call', name: 'asStateFlow', lines: [4] },
286
+ ]);
287
+ assert.match(match.why, /StateFlow/i);
288
+ assert.match(match.impact, /estado|UI|Compose/i);
289
+ assert.match(match.expected_fix, /MutableStateFlow|StateFlow|ViewModel/i);
290
+ });
291
+
292
+ test('findAndroidSingleSourceOfTruthMatch devuelve payload semantico para Single source of truth en ViewModel', () => {
293
+ const source = `
294
+ class CatalogViewModel : ViewModel() {
295
+ private val _uiState = MutableStateFlow(CatalogUiState())
296
+ val uiState: StateFlow<CatalogUiState> = _uiState.asStateFlow()
297
+ }
298
+ `;
299
+
300
+ const match = findAndroidSingleSourceOfTruthMatch(source);
301
+
302
+ assert.ok(match);
303
+ assert.deepEqual(match.primary_node, {
304
+ kind: 'class',
305
+ name: 'CatalogViewModel',
306
+ lines: [2],
307
+ });
308
+ assert.deepEqual(match.related_nodes, [
309
+ { kind: 'property', name: 'MutableStateFlow', lines: [3] },
310
+ { kind: 'property', name: 'StateFlow', lines: [4] },
311
+ { kind: 'call', name: 'asStateFlow', lines: [4] },
312
+ ]);
313
+ assert.match(match.why, /fuente de verdad|single source/i);
314
+ assert.match(match.impact, /estado|UI|ViewModel/i);
315
+ assert.match(match.expected_fix, /MutableStateFlow|StateFlow|ViewModel/i);
316
+ });
317
+
318
+ test('findAndroidSharedFlowMatch devuelve payload semantico para SharedFlow de eventos', () => {
319
+ const source = `
320
+ class CatalogViewModel : ViewModel() {
321
+ private val _events = MutableSharedFlow<UiEvent>()
322
+ val events: SharedFlow<UiEvent> = _events.asSharedFlow()
323
+
324
+ fun onRetry() {
325
+ _events.tryEmit(UiEvent.Retry)
326
+ }
327
+ }
328
+ `;
329
+
330
+ const match = findAndroidSharedFlowMatch(source);
331
+
332
+ assert.ok(match);
333
+ assert.deepEqual(match.primary_node, {
334
+ kind: 'call',
335
+ name: 'MutableSharedFlow',
336
+ lines: [3],
337
+ });
338
+ assert.deepEqual(match.related_nodes, [
339
+ { kind: 'call', name: 'SharedFlow', lines: [4] },
340
+ { kind: 'call', name: 'asSharedFlow', lines: [4] },
341
+ { kind: 'call', name: 'tryEmit', lines: [7] },
342
+ ]);
343
+ assert.match(match.why, /SharedFlow/i);
344
+ assert.match(match.impact, /evento|stream|callback/i);
345
+ assert.match(match.expected_fix, /MutableSharedFlow|SharedFlow|tryEmit/i);
346
+ });
347
+
348
+ test('findAndroidFlowBuilderMatch devuelve payload semantico para builders de Flow', () => {
349
+ const source = `
350
+ fun observeCatalog(): Flow<List<Int>> = flow {
351
+ emit(listOf(1))
352
+ }
353
+
354
+ val flowValues = flowOf(1, 2, 3)
355
+ val stream = listOf(4, 5).asFlow()
356
+ `;
357
+
358
+ const match = findAndroidFlowBuilderMatch(source);
359
+
360
+ assert.ok(match);
361
+ assert.deepEqual(match.primary_node, {
362
+ kind: 'call',
363
+ name: 'flow { emit() }',
364
+ lines: [2],
365
+ });
366
+ assert.deepEqual(match.related_nodes, [
367
+ { kind: 'call', name: 'flowOf', lines: [6] },
368
+ { kind: 'call', name: 'asFlow', lines: [7] },
369
+ ]);
370
+ assert.match(match.why, /Flow/i);
371
+ assert.match(match.impact, /stream|declarative|test/i);
372
+ assert.match(match.expected_fix, /flow \{ emit|flowOf|asFlow/i);
373
+ });
374
+
375
+ test('findAndroidFlowCollectMatch devuelve payload semantico para terminal operator collect', () => {
376
+ const source = `
377
+ fun observeCatalog(scope: CoroutineScope, flow: Flow<List<Int>>) {
378
+ flow.collect { items -> render(items) }
379
+ flow.collectLatest { items -> renderLatest(items) }
380
+ flow.launchIn(scope)
381
+ }
382
+ `;
383
+
384
+ const match = findAndroidFlowCollectMatch(source);
385
+
386
+ assert.ok(match);
387
+ assert.deepEqual(match.primary_node, {
388
+ kind: 'call',
389
+ name: 'collect',
390
+ lines: [3],
391
+ });
392
+ assert.deepEqual(match.related_nodes, [
393
+ { kind: 'call', name: 'collectLatest', lines: [4] },
394
+ { kind: 'call', name: 'launchIn', lines: [5] },
395
+ ]);
396
+ assert.match(match.why, /consume|Flow/i);
397
+ assert.match(match.impact, /consumidor|terminal|observa/i);
398
+ assert.match(match.expected_fix, /collect|collectLatest|launchIn/i);
399
+ });
400
+
401
+ test('findAndroidCollectAsStateMatch devuelve payload semantico para Compose collectAsState', () => {
402
+ const source = `
403
+ @Composable fun CatalogScreen(viewModel: CatalogViewModel) {
404
+ val state by viewModel.uiState.collectAsState()
405
+ val lifecycleState by viewModel.uiState.collectAsStateWithLifecycle()
406
+ }
407
+ `;
408
+
409
+ const match = findAndroidCollectAsStateMatch(source);
410
+
411
+ assert.ok(match);
412
+ assert.deepEqual(match.primary_node, {
413
+ kind: 'member',
414
+ name: '@Composable fun CatalogScreen',
415
+ lines: [2],
416
+ });
417
+ assert.deepEqual(match.related_nodes, [
418
+ { kind: 'call', name: 'collectAsState', lines: [3] },
419
+ { kind: 'call', name: 'collectAsStateWithLifecycle', lines: [4] },
420
+ ]);
421
+ assert.match(match.why, /collectAsState|Compose/i);
422
+ assert.match(match.impact, /UI|estado|Flow/i);
423
+ assert.match(match.expected_fix, /collectAsState|collectAsStateWithLifecycle/i);
424
+ });
425
+
426
+ test('findAndroidUiStateMatch devuelve payload semantico para sealed class UiState', () => {
427
+ const source = `
428
+ sealed class CatalogUiState {
429
+ data object Loading : CatalogUiState()
430
+ data class Success(val items: List<String>) : CatalogUiState()
431
+ data class Error(val message: String) : CatalogUiState()
432
+ }
433
+ `;
434
+
435
+ const match = findAndroidUiStateMatch(source);
436
+
437
+ assert.ok(match);
438
+ assert.deepEqual(match.primary_node, {
439
+ kind: 'class',
440
+ name: 'CatalogUiState',
441
+ lines: [2],
442
+ });
443
+ assert.deepEqual(match.related_nodes, [
444
+ { kind: 'member', name: 'Loading', lines: [3] },
445
+ { kind: 'member', name: 'Success', lines: [4] },
446
+ { kind: 'member', name: 'Error', lines: [5] },
447
+ ]);
448
+ assert.match(match.why, /UiState|Loading|Success|Error/i);
449
+ assert.match(match.impact, /estado|UI|tipado|cerrado/i);
450
+ assert.match(match.expected_fix, /sealed class|Loading|Success|Error/i);
451
+ });
452
+
453
+ test('findAndroidStateHoistingMatch devuelve payload semantico para composable con estado local', () => {
454
+ const source = `
455
+ @Composable fun CounterScreen() {
456
+ var count by rememberSaveable { mutableStateOf(0) }
457
+ }
458
+ `;
459
+
460
+ const match = findAndroidStateHoistingMatch(source);
461
+
462
+ assert.ok(match);
463
+ assert.deepEqual(match.primary_node, {
464
+ kind: 'member',
465
+ name: '@Composable fun CounterScreen',
466
+ lines: [2],
467
+ });
468
+ assert.deepEqual(match.related_nodes, [
469
+ { kind: 'call', name: 'rememberSaveable', lines: [3] },
470
+ { kind: 'call', name: 'mutableStateOf', lines: [3] },
471
+ ]);
472
+ assert.match(match.why, /estado/i);
473
+ assert.match(match.impact, /fuente|composable|UI/i);
474
+ assert.match(match.expected_fix, /eleva|ViewModel|callbacks/i);
475
+ });
476
+
477
+ test('hasAndroidUiStateUsage detecta UiState sealed class completa y descarta estados incompletos', () => {
478
+ const source = `
479
+ sealed class CatalogUiState {
480
+ data object Loading : CatalogUiState()
481
+ data class Success(val items: List<String>) : CatalogUiState()
482
+ data class Error(val message: String) : CatalogUiState()
483
+ }
484
+ `;
485
+
486
+ assert.equal(hasAndroidUiStateUsage(source), true);
487
+ assert.equal(
488
+ hasAndroidUiStateUsage(`
489
+ sealed class CatalogUiState {
490
+ data object Loading : CatalogUiState()
491
+ data class Success(val items: List<String>) : CatalogUiState()
492
+ }
493
+ `),
494
+ false
495
+ );
496
+ });
497
+
498
+ test('findAndroidUseCaseMatch devuelve payload semantico para UseCase Android', () => {
499
+ const source = `
500
+ class CatalogUseCase(
501
+ private val catalogRepository: CatalogRepository,
502
+ ) {
503
+ suspend operator fun invoke(): List<String> {
504
+ return catalogRepository.loadCatalog()
505
+ }
506
+ }
507
+ `;
508
+
509
+ const match = findAndroidUseCaseMatch(source);
510
+
511
+ assert.ok(match);
512
+ assert.deepEqual(match.primary_node, {
513
+ kind: 'class',
514
+ name: 'CatalogUseCase',
515
+ lines: [2],
516
+ });
517
+ assert.deepEqual(match.related_nodes, [
518
+ { kind: 'property', name: 'use case dependency', lines: [3] },
519
+ { kind: 'member', name: 'use case operation', lines: [5] },
520
+ ]);
521
+ assert.match(match.why, /UseCase|l[oó]gica de negocio/i);
522
+ assert.match(match.impact, /testear|reutilizar|ViewModel/i);
523
+ assert.match(match.expected_fix, /UseCase|operaci[oó]n|dependencias/i);
524
+ });
525
+
526
+ test('findAndroidRepositoryPatternMatch devuelve payload semantico para Repository Android', () => {
527
+ const source = `
528
+ class CatalogRepository @Inject constructor(
529
+ private val api: CatalogApi,
530
+ private val catalogDataSource: CatalogDataSource,
531
+ ) {
532
+ suspend fun loadCatalog(): List<String> {
533
+ return api.loadCatalog()
534
+ }
535
+
536
+ fun saveCatalog(items: List<String>) {
537
+ catalogDataSource.save(items)
538
+ }
539
+ }
540
+ `;
541
+
542
+ const match = findAndroidRepositoryPatternMatch(source);
543
+
544
+ assert.ok(match);
545
+ assert.deepEqual(match.primary_node, {
546
+ kind: 'class',
547
+ name: 'CatalogRepository',
548
+ lines: [2],
549
+ });
550
+ assert.deepEqual(match.related_nodes, [
551
+ { kind: 'property', name: 'repository dependency', lines: [3, 4] },
552
+ { kind: 'member', name: 'repository operation', lines: [6, 10] },
553
+ ]);
554
+ assert.match(match.why, /repository|acceso a datos/i);
555
+ assert.match(match.impact, /contrato|persistencia|red/i);
556
+ assert.match(match.expected_fix, /Repository|fachada|dependencias/i);
557
+ assert.equal(hasAndroidRepositoryPatternUsage(source), true);
558
+ });
559
+
560
+ test('findAndroidOrdersRepMatch devuelve payload semantico para OrdersRep Android', () => {
561
+ const source = `
562
+ class OrdersRep @Inject constructor(
563
+ private val remoteDataSource: OrdersRemoteDataSource,
564
+ ) {
565
+ suspend fun loadOrders(): List<String> {
566
+ return remoteDataSource.loadOrders()
567
+ }
568
+ }
569
+ `;
570
+
571
+ const match = findAndroidOrdersRepMatch(source);
572
+
573
+ assert.ok(match);
574
+ assert.deepEqual(match.primary_node, {
575
+ kind: 'class',
576
+ name: 'OrdersRep',
577
+ lines: [2],
578
+ });
579
+ assert.deepEqual(match.related_nodes, [
580
+ { kind: 'property', name: 'repository dependency', lines: [3] },
581
+ { kind: 'member', name: 'repository operation', lines: [5] },
582
+ ]);
583
+ assert.match(match.why, /OrdersRep|pedidos|acceso a datos/i);
584
+ assert.match(match.impact, /pedidos|contrato|red/i);
585
+ assert.match(match.expected_fix, /OrdersRep|fachada|dependencias/i);
586
+ assert.equal(hasAndroidOrdersRepUsage(source), true);
587
+ });
588
+
589
+ test('hasAndroidUseCaseUsage detecta UseCase real y descarta clases sin operacion publica', () => {
590
+ const source = `
591
+ class CatalogUseCase(
592
+ private val catalogRepository: CatalogRepository,
593
+ ) {
594
+ private fun helper() {}
595
+ }
596
+ `;
597
+
598
+ assert.equal(hasAndroidUseCaseUsage(source), false);
599
+ assert.equal(
600
+ hasAndroidUseCaseUsage(`
601
+ class CatalogUseCase(
602
+ private val catalogRepository: CatalogRepository,
603
+ ) {
604
+ suspend operator fun invoke(): List<String> {
605
+ return catalogRepository.loadCatalog()
606
+ }
607
+ }
608
+ `),
609
+ true
610
+ );
611
+ });
612
+
613
+ test('hasAndroidRepositoryPatternUsage detecta Repository real y descarta helpers sin acceso a datos', () => {
614
+ const source = `
615
+ class CatalogHelper {
616
+ fun build() {}
617
+ }
618
+ `;
619
+
620
+ assert.equal(hasAndroidRepositoryPatternUsage(source), false);
621
+ assert.equal(
622
+ hasAndroidRepositoryPatternUsage(`
623
+ interface CatalogRepository {
624
+ suspend fun loadCatalog(): List<String>
625
+ }
626
+ `),
627
+ true
628
+ );
629
+ });
630
+
631
+ test('hasAndroidOrdersRepUsage detecta OrdersRep real y descarta helpers sin repositorio', () => {
632
+ const source = `
633
+ class OrdersRepHelper {
634
+ fun build() {}
635
+ }
636
+ `;
637
+
638
+ assert.equal(hasAndroidOrdersRepUsage(source), false);
639
+ assert.equal(
640
+ hasAndroidOrdersRepUsage(`
641
+ class OrdersRep @Inject constructor(
642
+ private val remoteDataSource: OrdersRemoteDataSource,
643
+ ) {
644
+ suspend fun loadOrders(): List<String> = remoteDataSource.loadOrders()
645
+ }
646
+ `),
647
+ true
648
+ );
649
+ });
650
+
651
+ test('findAndroidViewModelMatch devuelve payload semantico para ViewModel AndroidX', () => {
652
+ const source = `
653
+ class CatalogViewModel : ViewModel()
654
+ `;
655
+
656
+ const match = findAndroidViewModelMatch(source);
657
+
658
+ assert.ok(match);
659
+ assert.deepEqual(match.primary_node, {
660
+ kind: 'class',
661
+ name: 'CatalogViewModel',
662
+ lines: [2],
663
+ });
664
+ assert.deepEqual(match.related_nodes, [
665
+ { kind: 'member', name: 'androidx.lifecycle.ViewModel', lines: [2] },
666
+ ]);
667
+ assert.match(match.why, /ViewModel/i);
668
+ assert.match(match.impact, /configuraci[oó]n|estado|ViewModel/i);
669
+ assert.match(match.expected_fix, /ViewModel|estado|configuraci[oó]n/i);
670
+ });
671
+
672
+ test('hasAndroidStateFlowUsage ignora comentarios, strings y ViewModels sin estado observable', () => {
673
+ const source = `
674
+ // MutableStateFlow(value)
675
+ val sample = "StateFlow"
676
+ class CatalogViewModel : ViewModel() {
677
+ fun load() {}
678
+ }
679
+ `;
680
+
681
+ assert.equal(hasAndroidStateFlowUsage(source), false);
682
+ });
683
+
684
+ test('hasAndroidSingleSourceOfTruthUsage detecta ViewModel con estado de fuente unica y descarta helpers sin estado', () => {
685
+ const source = `
686
+ class CatalogHelper {
687
+ fun load() {}
688
+ }
689
+ `;
690
+
691
+ assert.equal(hasAndroidSingleSourceOfTruthUsage(source), false);
692
+ assert.equal(
693
+ hasAndroidSingleSourceOfTruthUsage(`
694
+ class CatalogViewModel : ViewModel() {
695
+ private val _uiState = MutableStateFlow(CatalogUiState())
696
+ val uiState: StateFlow<CatalogUiState> = _uiState.asStateFlow()
697
+ }
698
+ `),
699
+ true
700
+ );
701
+ });
702
+
703
+ test('hasAndroidSharedFlowUsage ignora comentarios, strings y ViewModels sin eventos', () => {
704
+ const source = `
705
+ // MutableSharedFlow(value)
706
+ val sample = "SharedFlow"
707
+ class CatalogViewModel : ViewModel() {
708
+ fun load() {}
709
+ }
710
+ `;
711
+
712
+ assert.equal(hasAndroidSharedFlowUsage(source), false);
713
+ });
714
+
715
+ test('hasAndroidFlowBuilderUsage ignora comentarios, strings y archivos sin builders de Flow', () => {
716
+ const source = `
717
+ // flow { emit(1) }
718
+ val sample = "flowOf(1, 2, 3)"
719
+ fun load() {
720
+ val value = listOf(1, 2, 3)
721
+ }
722
+ `;
723
+
724
+ assert.equal(hasAndroidFlowBuilderUsage(source), false);
725
+ });
726
+
727
+ test('hasAndroidFlowCollectUsage ignora comentarios, strings y archivos sin terminal operators de Flow', () => {
728
+ const source = `
729
+ // flow.collect { }
730
+ val sample = "collectLatest"
731
+ fun render() {
732
+ println("no flow collection here")
733
+ }
734
+ `;
735
+
736
+ assert.equal(hasAndroidFlowCollectUsage(source), false);
737
+ });
738
+
739
+ test('hasAndroidCollectAsStateUsage ignora comentarios, strings y archivos sin collectAsState', () => {
740
+ const source = `
741
+ // collectAsState()
742
+ val sample = "collectAsStateWithLifecycle"
743
+ fun render() {
744
+ println("no compose state collection here")
745
+ }
746
+ `;
747
+
748
+ assert.equal(hasAndroidCollectAsStateUsage(source), false);
749
+ });
750
+
751
+ test('findAndroidRememberMatch devuelve payload semantico para remember en Compose', () => {
752
+ const source = `
753
+ @Composable fun ChartScreen() {
754
+ val formatter = remember { java.text.DecimalFormat("#.##") }
755
+ }
756
+ `;
757
+
758
+ const match = findAndroidRememberMatch(source);
759
+
760
+ assert.ok(match);
761
+ assert.deepEqual(match.primary_node, {
762
+ kind: 'member',
763
+ name: '@Composable fun ChartScreen',
764
+ lines: [2],
765
+ });
766
+ assert.deepEqual(match.related_nodes, [{ kind: 'call', name: 'remember', lines: [3] }]);
767
+ assert.match(match.why, /remember|recrear/i);
768
+ assert.match(match.impact, /estables|recomponer/i);
769
+ assert.match(match.expected_fix, /remember|memoizar/i);
770
+ });
771
+
772
+ test('hasAndroidRememberUsage ignora comentarios, strings y rememberSaveable', () => {
773
+ const source = `
774
+ // remember { }
775
+ val sample = "remember { }"
776
+ @Composable fun Sample() {
777
+ val state = rememberSaveable { 1 }
778
+ }
779
+ `;
780
+
781
+ assert.equal(hasAndroidRememberUsage(source), false);
782
+ });
783
+
784
+ test('findAndroidDerivedStateOfMatch devuelve payload semantico para derivedStateOf en Compose', () => {
785
+ const source = `
786
+ @Composable fun SearchScreen(query: String) {
787
+ val hasQuery by derivedStateOf { query.isNotBlank() }
788
+ }
789
+ `;
790
+
791
+ const match = findAndroidDerivedStateOfMatch(source);
792
+
793
+ assert.ok(match);
794
+ assert.deepEqual(match.primary_node, {
795
+ kind: 'member',
796
+ name: '@Composable fun SearchScreen',
797
+ lines: [2],
798
+ });
799
+ assert.deepEqual(match.related_nodes, [{ kind: 'call', name: 'derivedStateOf', lines: [3] }]);
800
+ assert.match(match.why, /derivedStateOf|caro/i);
801
+ assert.match(match.impact, /recomput|estado/i);
802
+ assert.match(match.expected_fix, /derivedStateOf|derivar/i);
803
+ });
804
+
805
+ test('hasAndroidDerivedStateOfUsage ignora comentarios y strings', () => {
806
+ assert.equal(
807
+ hasAndroidDerivedStateOfUsage(`
808
+ // derivedStateOf { }
809
+ val sample = "derivedStateOf"
810
+ @Composable fun Sample() {
811
+ Text("ok")
812
+ }
813
+ `),
814
+ false
815
+ );
816
+ });
817
+
818
+ test('findAndroidLaunchedEffectMatch devuelve payload semantico para LaunchedEffect en Compose', () => {
819
+ const source = `
820
+ @Composable fun Screen(viewModel: ScreenViewModel) {
821
+ LaunchedEffect(viewModel.userId) {
822
+ viewModel.refresh()
823
+ }
824
+ }
825
+ `;
826
+
827
+ const match = findAndroidLaunchedEffectMatch(source);
828
+
829
+ assert.ok(match);
830
+ assert.deepEqual(match.primary_node, {
831
+ kind: 'member',
832
+ name: '@Composable fun Screen',
833
+ lines: [2],
834
+ });
835
+ assert.deepEqual(match.related_nodes, [{ kind: 'call', name: 'LaunchedEffect', lines: [3] }]);
836
+ assert.match(match.why, /LaunchedEffect|lifecycle/i);
837
+ assert.match(match.impact, /cancelado|relanzado|composición/i);
838
+ assert.match(match.expected_fix, /LaunchedEffect|lifecycle|claves/i);
839
+ });
840
+
841
+ test('hasAndroidLaunchedEffectUsage ignora comentarios y strings', () => {
842
+ assert.equal(
843
+ hasAndroidLaunchedEffectUsage(`\n// LaunchedEffect(Unit)\nval sample = "LaunchedEffect(Unit)"\n@Composable fun Sample() {\n Text("ok")\n}\n`),
844
+ false
845
+ );
846
+ });
847
+
848
+ test('findAndroidLaunchedEffectKeysMatch devuelve payload semantico para LaunchedEffect keys en Compose', () => {
849
+ const source = `
850
+ @Composable fun Screen(viewModel: ScreenViewModel) {
851
+ LaunchedEffect(viewModel.userId, viewModel.refreshTrigger) {
852
+ viewModel.refresh()
853
+ }
854
+ }
855
+ `;
856
+
857
+ const match = findAndroidLaunchedEffectKeysMatch(source);
858
+
859
+ assert.ok(match);
860
+ assert.deepEqual(match.primary_node, {
861
+ kind: 'member',
862
+ name: '@Composable fun Screen',
863
+ lines: [2],
864
+ });
865
+ assert.deepEqual(match.related_nodes, [{ kind: 'call', name: 'LaunchedEffect keys', lines: [3] }]);
866
+ assert.match(match.why, /LaunchedEffect|keys|relanza/i);
867
+ assert.match(match.impact, /keys|relanzado|input/i);
868
+ assert.match(match.expected_fix, /claves|LaunchedEffect|relanzar/i);
869
+ });
870
+
871
+ test('hasAndroidLaunchedEffectKeysUsage ignora comentarios y strings', () => {
872
+ assert.equal(
873
+ hasAndroidLaunchedEffectKeysUsage(`\n// LaunchedEffect(viewModel.userId)\nval sample = "LaunchedEffect(viewModel.userId)"\n@Composable fun Sample() {\n Text("ok")\n}\n`),
874
+ false
875
+ );
876
+ });
877
+
878
+ test('findAndroidDisposableEffectMatch devuelve payload semantico para DisposableEffect en Compose', () => {
879
+ const source = `
880
+ @Composable fun Screen() {
881
+ DisposableEffect(Unit) {
882
+ onDispose { }
883
+ }
884
+ }
885
+ `;
886
+
887
+ const match = findAndroidDisposableEffectMatch(source);
888
+
889
+ assert.ok(match);
890
+ assert.deepEqual(match.primary_node, {
891
+ kind: 'member',
892
+ name: '@Composable fun Screen',
893
+ lines: [2],
894
+ });
895
+ assert.deepEqual(match.related_nodes, [{ kind: 'call', name: 'DisposableEffect', lines: [3] }]);
896
+ assert.match(match.why, /DisposableEffect|limpiar|composición/i);
897
+ assert.match(match.impact, /libera|recursos|Compose/i);
898
+ assert.match(match.expected_fix, /DisposableEffect|limpiar|lifecycle/i);
899
+ });
900
+
901
+ test('hasAndroidDisposableEffectUsage ignora comentarios y strings', () => {
902
+ assert.equal(
903
+ hasAndroidDisposableEffectUsage(`\n// DisposableEffect(Unit)\nval sample = "DisposableEffect(Unit)"\n@Composable fun Sample() {\n Text("ok")\n}\n`),
904
+ false
905
+ );
906
+ });
907
+
908
+ test('findAndroidPreviewMatch devuelve payload semantico para @Preview en Compose', () => {
909
+ const source = `
910
+ data class PreviewUiState(val label: String)
911
+ @Preview(showBackground = true)
912
+ @Composable fun PreviewCounter(state: PreviewUiState) {
913
+ Text(text = state.label)
914
+ }
915
+ `;
916
+
917
+ const match = findAndroidPreviewMatch(source);
918
+
919
+ assert.ok(match);
920
+ assert.deepEqual(match.primary_node, {
921
+ kind: 'member',
922
+ name: '@Composable fun PreviewCounter',
923
+ lines: [4],
924
+ });
925
+ assert.deepEqual(match.related_nodes, [{ kind: 'call', name: '@Preview', lines: [3] }]);
926
+ assert.match(match.why, /Preview|UI|app/i);
927
+ assert.match(match.impact, /renderizar|Android Studio|Compose/i);
928
+ assert.match(match.expected_fix, /Preview|composable|UI/i);
929
+ });
930
+
931
+ test('hasAndroidPreviewUsage ignora comentarios y strings', () => {
932
+ assert.equal(
933
+ hasAndroidPreviewUsage(`\n// @Preview(showBackground = true)\nval sample = "@Preview(showBackground = true)"\n@Composable fun Sample() {\n Text("ok")\n}\n`),
934
+ false
935
+ );
936
+ });
937
+
938
+ test('findAndroidAdaptiveLayoutsMatch devuelve payload semantico para WindowSizeClass en Compose', () => {
939
+ const source = `
940
+ fun ResponsiveScreen(activity: Activity) {
941
+ val windowSizeClass = calculateWindowSizeClass(activity)
942
+ when (windowSizeClass.widthSizeClass) {
943
+ WindowWidthSizeClass.Compact -> Unit
944
+ WindowWidthSizeClass.Medium -> Unit
945
+ WindowWidthSizeClass.Expanded -> Unit
946
+ }
947
+ }
948
+ `;
949
+
950
+ const match = findAndroidAdaptiveLayoutsMatch(source);
951
+
952
+ assert.ok(match);
953
+ assert.deepEqual(match.primary_node, {
954
+ kind: 'call',
955
+ name: 'calculateWindowSizeClass',
956
+ lines: [3],
957
+ });
958
+ assert.deepEqual(match.related_nodes, [
959
+ { kind: 'member', name: 'WindowWidthSizeClass', lines: [5, 6, 7] },
960
+ ]);
961
+ assert.match(match.why, /WindowSizeClass|adaptive|responsive/i);
962
+ assert.match(match.impact, /layout|pantallas|responsive/i);
963
+ assert.match(match.expected_fix, /calculateWindowSizeClass|WindowWidthSizeClass/i);
964
+ });
965
+
966
+ test('hasAndroidAdaptiveLayoutsUsage ignora comentarios y strings', () => {
967
+ assert.equal(
968
+ hasAndroidAdaptiveLayoutsUsage(`\n// calculateWindowSizeClass(activity)\nval sample = "WindowWidthSizeClass.Compact"\nfun render() {\n Text("ok")\n}\n`),
969
+ false
970
+ );
971
+ });
972
+
973
+ test('findAndroidExistingStructureMatch devuelve payload semantico para interfaces y modules Android', () => {
974
+ const source = `
975
+ interface SessionContract {
976
+ fun load(): String
977
+ }
978
+
979
+ @Module
980
+ @InstallIn(SingletonComponent::class)
981
+ object SessionModule {
982
+ @Provides
983
+ fun provideSessionContract(): SessionContract = RealSessionContract()
984
+ }
985
+ `;
986
+
987
+ const match = findAndroidExistingStructureMatch(source);
988
+
989
+ assert.ok(match);
990
+ assert.deepEqual(match.primary_node, {
991
+ kind: 'member',
992
+ name: 'interface declaration: SessionContract',
993
+ lines: [2],
994
+ });
995
+ assert.deepEqual(match.related_nodes, [
996
+ { kind: 'member', name: 'module annotation', lines: [6] },
997
+ { kind: 'member', name: 'module annotation', lines: [7] },
998
+ ]);
999
+ assert.match(match.why, /estructura existente|interfaces|módulos/i);
1000
+ assert.match(match.impact, /dependenc|gradle|di/i);
1001
+ assert.match(match.expected_fix, /módulos|interfaces|Gradle/i);
1002
+ });
1003
+
1004
+ test('findAndroidExistingStructureMatch devuelve payload semantico para dependencies Gradle', () => {
1005
+ const source = `
1006
+ plugins {
1007
+ id("com.android.application")
1008
+ kotlin("android")
1009
+ }
1010
+
1011
+ dependencies {
1012
+ implementation(libs.androidx.core.ktx)
1013
+ api(libs.core.domain)
1014
+ }
1015
+ `;
1016
+
1017
+ const match = findAndroidExistingStructureMatch(source);
1018
+
1019
+ assert.ok(match);
1020
+ assert.deepEqual(match.primary_node, {
1021
+ kind: 'member',
1022
+ name: 'dependencies block',
1023
+ lines: [7],
1024
+ });
1025
+ assert.deepEqual(match.related_nodes, [
1026
+ { kind: 'member', name: 'dependency declaration', lines: [8] },
1027
+ { kind: 'member', name: 'dependency declaration', lines: [9] },
1028
+ ]);
1029
+ assert.match(match.why, /Gradle|dependenc/i);
1030
+ assert.match(match.impact, /módulos|dependencias|Gradle/i);
1031
+ assert.match(match.expected_fix, /dependencies|catálogo|Gradle/i);
1032
+ });
1033
+
1034
+ test('hasAndroidExistingStructureUsage ignora comentarios y strings', () => {
1035
+ assert.equal(
1036
+ hasAndroidExistingStructureUsage(`\n// interface SessionRepository\nval sample = "dependencies { implementation(\\"foo\\") }"\n`),
1037
+ false
1038
+ );
1039
+ });
1040
+
1041
+ test('findAndroidThemeMatch devuelve payload semantico para theme Material 3 en Compose', () => {
1042
+ const source = `
1043
+ @Composable
1044
+ fun AppTheme(content: @Composable () -> Unit) {
1045
+ MaterialTheme(
1046
+ colorScheme = darkColorScheme(),
1047
+ typography = AppTypography,
1048
+ shapes = AppShapes,
1049
+ content = content
1050
+ )
1051
+ }
1052
+ `;
1053
+
1054
+ const match = findAndroidThemeMatch(source);
1055
+
1056
+ assert.ok(match);
1057
+ assert.deepEqual(match.primary_node, {
1058
+ kind: 'member',
1059
+ name: '@Composable fun AppTheme',
1060
+ lines: [3],
1061
+ });
1062
+ assert.deepEqual(match.related_nodes, [
1063
+ { kind: 'call', name: 'MaterialTheme', lines: [4] },
1064
+ { kind: 'property', name: 'colorScheme', lines: [5] },
1065
+ { kind: 'property', name: 'typography', lines: [6] },
1066
+ { kind: 'property', name: 'shapes', lines: [7] },
1067
+ ]);
1068
+ assert.match(match.why, /tema|MaterialTheme|colorScheme/i);
1069
+ assert.match(match.impact, /coherencia|escalabilidad|UI/i);
1070
+ assert.match(match.expected_fix, /tema|colorScheme|typography|shapes/i);
1071
+ });
1072
+
1073
+ test('hasAndroidThemeUsage ignora comentarios y strings', () => {
1074
+ assert.equal(
1075
+ hasAndroidThemeUsage(`\n// MaterialTheme(colorScheme = darkColorScheme())\nval sample = "MaterialTheme(colorScheme = darkColorScheme())"\n@Composable fun Sample() {\n Text("ok")\n}\n`),
1076
+ false
1077
+ );
1078
+ });
1079
+
1080
+ test('findAndroidDarkThemeMatch devuelve payload semantico para soporte de tema oscuro', () => {
1081
+ const source = `
1082
+ @Composable
1083
+ fun AppTheme(content: @Composable () -> Unit) {
1084
+ val darkTheme = isSystemInDarkTheme()
1085
+ val colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme()
1086
+ MaterialTheme(
1087
+ colorScheme = colorScheme,
1088
+ typography = AppTypography,
1089
+ shapes = AppShapes,
1090
+ content = content
1091
+ )
1092
+ }
1093
+ `;
1094
+
1095
+ const match = findAndroidDarkThemeMatch(source);
1096
+
1097
+ assert.ok(match);
1098
+ assert.deepEqual(match.primary_node, {
1099
+ kind: 'member',
1100
+ name: '@Composable fun AppTheme',
1101
+ lines: [3],
1102
+ });
1103
+ assert.deepEqual(match.related_nodes, [
1104
+ { kind: 'call', name: 'isSystemInDarkTheme', lines: [4] },
1105
+ { kind: 'call', name: 'darkColorScheme', lines: [5] },
1106
+ { kind: 'call', name: 'lightColorScheme', lines: [5] },
1107
+ { kind: 'call', name: 'MaterialTheme', lines: [6] },
1108
+ ]);
1109
+ assert.match(match.why, /tema oscuro|isSystemInDarkTheme/i);
1110
+ assert.match(match.impact, /tema oscuro|UI|sistema/i);
1111
+ assert.match(match.expected_fix, /isSystemInDarkTheme|darkColorScheme|lightColorScheme/i);
1112
+ });
1113
+
1114
+ test('hasAndroidDarkThemeUsage ignora comentarios y strings', () => {
1115
+ assert.equal(
1116
+ hasAndroidDarkThemeUsage(`\n// isSystemInDarkTheme()\nval sample = "darkColorScheme()"\n@Composable fun Sample() {\n Text("ok")\n}\n`),
1117
+ false
1118
+ );
1119
+ });
1120
+
1121
+ test('findAndroidAccessibilityMatch devuelve payload semantico para accesibilidad Compose', () => {
1122
+ const source = `
1123
+ @Composable fun AccessibleIconScreen() {
1124
+ Icon(
1125
+ imageVector = Icons.Default.Settings,
1126
+ contentDescription = "Ajustes"
1127
+ )
1128
+ Box(modifier = Modifier.semantics { contentDescription = "Pantalla de ajustes" })
1129
+ }
1130
+ `;
1131
+
1132
+ const match = findAndroidAccessibilityMatch(source);
1133
+
1134
+ assert.ok(match);
1135
+ assert.deepEqual(match.primary_node, {
1136
+ kind: 'member',
1137
+ name: '@Composable fun AccessibleIconScreen',
1138
+ lines: [2],
1139
+ });
1140
+ assert.deepEqual(match.related_nodes, [
1141
+ { kind: 'property', name: 'contentDescription', lines: [5] },
1142
+ { kind: 'call', name: 'semantics', lines: [7] },
1143
+ ]);
1144
+ assert.match(match.why, /contentDescription|semantics|accesibilidad/i);
1145
+ assert.match(match.impact, /accesible|lectores de pantalla/i);
1146
+ assert.match(match.expected_fix, /contentDescription|semantics/i);
1147
+ });
1148
+
1149
+ test('hasAndroidAccessibilityUsage ignora comentarios, strings y accesibilidad implícita', () => {
1150
+ const source = `
1151
+ // contentDescription = "debug"
1152
+ val sample = "Modifier.semantics { }"
1153
+ @Composable fun Sample() {
1154
+ Text("ok")
1155
+ }
1156
+ `;
1157
+
1158
+ assert.equal(hasAndroidAccessibilityUsage(source), false);
1159
+ });
1160
+
1161
+ test('findAndroidTalkBackMatch devuelve payload semantico para TalkBack en Compose', () => {
1162
+ const source = `
1163
+ @Composable fun AccessibleIconScreen() {
1164
+ Box(modifier = Modifier.semantics { })
1165
+ }
1166
+ `;
1167
+
1168
+ const match = findAndroidTalkBackMatch(source);
1169
+
1170
+ assert.ok(match);
1171
+ assert.deepEqual(match.primary_node, {
1172
+ kind: 'member',
1173
+ name: '@Composable fun AccessibleIconScreen',
1174
+ lines: [2],
1175
+ });
1176
+ assert.deepEqual(match.related_nodes, [
1177
+ { kind: 'call', name: 'semantics', lines: [3] },
1178
+ ]);
1179
+ assert.match(match.why, /TalkBack|accesibilidad|screen reader/i);
1180
+ assert.match(match.impact, /lectores de pantalla|tecnolog/i);
1181
+ assert.match(match.expected_fix, /TalkBack|contentDescription|semantics/i);
1182
+ });
1183
+
1184
+ test('hasAndroidTalkBackUsage ignora comentarios y strings', () => {
1185
+ assert.equal(
1186
+ hasAndroidTalkBackUsage(`\n// TalkBack\nval sample = "contentDescription = ignored"\n@Composable fun Sample() {\n Text("ok")\n}\n`),
1187
+ false
1188
+ );
1189
+ });
1190
+
1191
+ test('findAndroidTextScalingMatch devuelve payload semantico para text scaling en Compose', () => {
1192
+ const source = `
1193
+ @Composable fun ScaledTextScreen() {
1194
+ val fontScale = LocalDensity.current.fontScale
1195
+ Text(
1196
+ text = "Hola",
1197
+ fontSize = 16.sp
1198
+ )
1199
+ }
1200
+ `;
1201
+
1202
+ const match = findAndroidTextScalingMatch(source);
1203
+
1204
+ assert.ok(match);
1205
+ assert.deepEqual(match.primary_node, {
1206
+ kind: 'member',
1207
+ name: '@Composable fun ScaledTextScreen',
1208
+ lines: [2],
1209
+ });
1210
+ assert.deepEqual(match.related_nodes, [
1211
+ { kind: 'property', name: 'fontScale', lines: [3] },
1212
+ { kind: 'property', name: 'fontSize', lines: [6] },
1213
+ ]);
1214
+ assert.match(match.why, /font scaling|fontScale|sp/i);
1215
+ assert.match(match.impact, /legibilidad|texto|acces/i);
1216
+ assert.match(match.expected_fix, /fontScale|sp|TextUnit\.Sp/i);
1217
+ });
1218
+
1219
+ test('hasAndroidTextScalingUsage ignora comentarios y strings', () => {
1220
+ assert.equal(
1221
+ hasAndroidTextScalingUsage(`\n// fontScale\nval sample = "fontSize = 16.sp"\n@Composable fun Sample() {\n Text("ok")\n}\n`),
1222
+ false
1223
+ );
1224
+ });
1225
+
1226
+ test('findAndroidTouchTargetsMatch devuelve payload semantico para touch targets en Compose', () => {
1227
+ const source = `
1228
+ @Composable fun TouchTargetButton() {
1229
+ IconButton(
1230
+ onClick = {},
1231
+ modifier = Modifier.sizeIn(minWidth = 48.dp, minHeight = 48.dp)
1232
+ ) {
1233
+ Icon(imageVector = Icons.Default.Settings, contentDescription = "Ajustes")
1234
+ }
1235
+ }
1236
+ `;
1237
+
1238
+ const match = findAndroidTouchTargetsMatch(source);
1239
+
1240
+ assert.ok(match);
1241
+ assert.deepEqual(match.primary_node, {
1242
+ kind: 'member',
1243
+ name: '@Composable fun TouchTargetButton',
1244
+ lines: [2],
1245
+ });
1246
+ assert.deepEqual(match.related_nodes, [
1247
+ { kind: 'call', name: 'sizeIn48dp', lines: [5] },
1248
+ ]);
1249
+ assert.match(match.why, /touch targets|48dp|interact/i);
1250
+ assert.match(match.impact, /toque|interactiv|precisi/i);
1251
+ assert.match(match.expected_fix, /minimumInteractiveComponentSize|sizeIn|minWidth|minHeight/i);
1252
+ });
1253
+
1254
+ test('hasAndroidTouchTargetsUsage ignora comentarios, strings y tamanos inferiores a 48dp', () => {
1255
+ const source = `
1256
+ // sizeIn(minWidth = 48.dp, minHeight = 48.dp)
1257
+ val sample = "minimumInteractiveComponentSize()"
1258
+ @Composable fun Sample() {
1259
+ IconButton(
1260
+ onClick = {},
1261
+ modifier = Modifier.sizeIn(minWidth = 24.dp, minHeight = 24.dp)
1262
+ ) {
1263
+ Icon(imageVector = Icons.Default.Settings, contentDescription = "Ajustes")
1264
+ }
1265
+ }
1266
+ `;
1267
+
1268
+ assert.equal(hasAndroidTouchTargetsUsage(source), false);
1269
+ });
1270
+
1271
+ test('findAndroidContentDescriptionMatch devuelve payload semantico para contentDescription en Compose', () => {
1272
+ const source = `
1273
+ @Composable fun SettingsButton() {
1274
+ Icon(
1275
+ imageVector = Icons.Default.Settings,
1276
+ contentDescription = "Ajustes"
1277
+ )
1278
+ }
1279
+ `;
1280
+
1281
+ const match = findAndroidContentDescriptionMatch(source);
1282
+
1283
+ assert.ok(match);
1284
+ assert.deepEqual(match.primary_node, {
1285
+ kind: 'member',
1286
+ name: '@Composable fun SettingsButton',
1287
+ lines: [2],
1288
+ });
1289
+ assert.deepEqual(match.related_nodes, [
1290
+ { kind: 'property', name: 'contentDescription', lines: [5] },
1291
+ ]);
1292
+ assert.match(match.why, /contentDescription|imágenes|botones/i);
1293
+ assert.match(match.impact, /lectores de pantalla|tecnolog/i);
1294
+ assert.match(match.expected_fix, /contentDescription|accesible/i);
1295
+ });
1296
+
1297
+ test('hasAndroidContentDescriptionUsage ignora comentarios y strings', () => {
1298
+ assert.equal(
1299
+ hasAndroidContentDescriptionUsage(`\n// contentDescription = "debug"\nval sample = "contentDescription = ignored"\n@Composable fun Sample() {\n Text("ok")\n}\n`),
1300
+ false
1301
+ );
1302
+ });
1303
+
1304
+ test('findAndroidRecompositionMatch devuelve payload semantico para recomposition idempotente', () => {
1305
+ const source = `
1306
+ @Composable fun CounterScreen(viewModel: CounterViewModel) {
1307
+ println("recompose")
1308
+ viewModel.counter.value = viewModel.counter.value + 1
1309
+ }
1310
+ `;
1311
+
1312
+ const match = findAndroidRecompositionMatch(source);
1313
+
1314
+ assert.ok(match);
1315
+ assert.deepEqual(match.primary_node, {
1316
+ kind: 'member',
1317
+ name: '@Composable fun CounterScreen',
1318
+ lines: [2],
1319
+ });
1320
+ assert.deepEqual(match.related_nodes, [
1321
+ { kind: 'call', name: 'println', lines: [3] },
1322
+ { kind: 'call', name: 'state mutation', lines: [4] },
1323
+ ]);
1324
+ assert.match(match.why, /idempotencia|recompos/i);
1325
+ assert.match(match.impact, /repetir|mutaciones|side effects/i);
1326
+ assert.match(match.expected_fix, /idempotencia|side effects|ViewModel/i);
1327
+ });
1328
+
1329
+ test('hasAndroidRecompositionUsage ignora comentarios y strings', () => {
1330
+ assert.equal(
1331
+ hasAndroidRecompositionUsage(`\n// println("recompose")\nval sample = "Log.d(\\\"x\\\")"\n@Composable fun Sample() {\n Text("ok")\n}\n`),
1332
+ false
1333
+ );
1334
+ });
1335
+
1336
+ test('hasAndroidStateHoistingUsage detecta composables con estado local y descarta files sin estado', () => {
1337
+ const source = `
1338
+ @Composable fun CounterScreen() {
1339
+ var count by rememberSaveable { mutableStateOf(0) }
1340
+ }
1341
+
1342
+ @Composable fun StaticScreen() {
1343
+ Text("hello")
1344
+ }
1345
+ `;
1346
+
1347
+ assert.equal(hasAndroidStateHoistingUsage(source), true);
1348
+ assert.equal(
1349
+ hasAndroidStateHoistingUsage(`
1350
+ @Composable fun StaticScreen() {
1351
+ Text("hello")
1352
+ }
1353
+ `),
1354
+ false
1355
+ );
1356
+ });
1357
+
1358
+ test('hasAndroidViewModelUsage detecta ViewModel real y descarta clases no relacionadas', () => {
1359
+ const source = `
1360
+ class CatalogViewModel : ViewModel()
1361
+ class CatalogPresenter
1362
+ `;
1363
+
1364
+ assert.equal(hasAndroidViewModelUsage(source), true);
1365
+ });
1366
+
1367
+ test('findAndroidSuspendFunctionsApiServiceMatch devuelve payload semantico para API service con suspend fun', () => {
1368
+ const source = `
1369
+ interface CatalogApiService {
1370
+ suspend fun fetchCatalog(): List<String>
1371
+
1372
+ suspend fun fetchFeaturedCatalog(): List<String>
1373
+ }
1374
+ `;
1375
+
1376
+ const match = findAndroidSuspendFunctionsApiServiceMatch(source);
1377
+
1378
+ assert.ok(match);
1379
+ assert.deepEqual(match.primary_node, {
1380
+ kind: 'class',
1381
+ name: 'CatalogApiService',
1382
+ lines: [2],
1383
+ });
1384
+ assert.deepEqual(match.related_nodes, [
1385
+ { kind: 'member', name: 'API service: CatalogApiService', lines: [2] },
1386
+ { kind: 'member', name: 'suspend fun fetchCatalog', lines: [3] },
1387
+ { kind: 'member', name: 'suspend fun fetchFeaturedCatalog', lines: [5] },
1388
+ ]);
1389
+ assert.match(match.why, /API/i);
1390
+ assert.match(match.impact, /API lineal|callbacks/i);
1391
+ assert.match(match.expected_fix, /suspend functions|coroutine/i);
1392
+ });
1393
+
1394
+ test('hasAndroidSuspendFunctionsApiServiceUsage ignora comentarios y servicios no API', () => {
1395
+ const source = `
1396
+ // suspend fun fake()
1397
+ val sample = "CatalogApiService"
1398
+
1399
+ interface CatalogRepository {
1400
+ fun fetchCatalog()
1401
+ }
1402
+ `;
1403
+
1404
+ assert.equal(hasAndroidSuspendFunctionsApiServiceUsage(source), false);
1405
+ });
1406
+
1407
+ test('findAndroidSuspendFunctionsAsyncMatch devuelve payload semantico para suspend fun async en clases generales', () => {
1408
+ const source = `
1409
+ class DashboardRepository {
1410
+ suspend fun loadDashboard(): String = "ok"
1411
+
1412
+ suspend fun refreshDashboard(): String = "ok"
1413
+ }
1414
+ `;
1415
+
1416
+ const match = findAndroidSuspendFunctionsAsyncMatch(source);
1417
+
1418
+ assert.ok(match);
1419
+ assert.deepEqual(match.primary_node, {
1420
+ kind: 'class',
1421
+ name: 'DashboardRepository',
1422
+ lines: [2],
1423
+ });
1424
+ assert.deepEqual(match.related_nodes, [
1425
+ { kind: 'member', name: 'DashboardRepository', lines: [2] },
1426
+ { kind: 'member', name: 'suspend fun loadDashboard', lines: [3] },
1427
+ { kind: 'member', name: 'suspend fun refreshDashboard', lines: [5] },
1428
+ ]);
1429
+ assert.match(match.why, /suspend functions/i);
1430
+ assert.match(match.impact, /lineal|componer/i);
1431
+ assert.match(match.expected_fix, /async|suspend functions/i);
1432
+ });
1433
+
1434
+ test('hasAndroidSuspendFunctionsAsyncUsage ignora comentarios y excluye services y daos', () => {
1435
+ const source = `
1436
+ // suspend fun fake()
1437
+ val sample = "suspend fun load"
1438
+
1439
+ interface CatalogApiService {
1440
+ suspend fun fetchCatalog(): List<String>
1441
+ }
1442
+
1443
+ @Dao
1444
+ interface CatalogDao {
1445
+ @Query("SELECT * FROM catalog")
1446
+ suspend fun loadCatalog(): List<String>
1447
+ }
1448
+
1449
+ class DashboardRepository {
1450
+ suspend fun loadDashboard(): String = "ok"
1451
+ }
1452
+ `;
1453
+
1454
+ assert.equal(hasAndroidSuspendFunctionsAsyncUsage(source), true);
1455
+ assert.equal(
1456
+ hasAndroidSuspendFunctionsAsyncUsage(`
1457
+ // suspend fun fake()
1458
+ val sample = "suspend fun load"
1459
+
1460
+ interface CatalogApiService {
1461
+ suspend fun fetchCatalog(): List<String>
1462
+ }
1463
+
1464
+ @Dao
1465
+ interface CatalogDao {
1466
+ @Query("SELECT * FROM catalog")
1467
+ suspend fun loadCatalog(): List<String>
1468
+ }
1469
+ `),
1470
+ false
1471
+ );
1472
+ });
1473
+
1474
+ test('findAndroidAsyncAwaitParallelismMatch devuelve payload semantico para async/await en paralelismo', () => {
1475
+ const source = `
1476
+ class ReportCoordinator {
1477
+ suspend fun buildReport(): String = coroutineScope {
1478
+ val summary = async { "summary" }
1479
+ val details = async(Dispatchers.IO) { "details" }
1480
+ summary.await()
1481
+ details.await()
1482
+ "report"
1483
+ }
1484
+ }
1485
+ `;
1486
+
1487
+ const match = findAndroidAsyncAwaitParallelismMatch(source);
1488
+
1489
+ assert.ok(match);
1490
+ assert.deepEqual(match.primary_node, {
1491
+ kind: 'class',
1492
+ name: 'ReportCoordinator',
1493
+ lines: [2],
1494
+ });
1495
+ assert.deepEqual(match.related_nodes, [
1496
+ { kind: 'member', name: 'ReportCoordinator', lines: [2] },
1497
+ { kind: 'call', name: 'async', lines: [4, 5] },
1498
+ { kind: 'call', name: 'await', lines: [6, 7] },
1499
+ ]);
1500
+ assert.match(match.why, /async\/await/i);
1501
+ assert.match(match.impact, /paralelo|parallel|await/i);
1502
+ assert.match(match.expected_fix, /coroutineScope|parallel/i);
1503
+ });
1504
+
1505
+ test('hasAndroidAsyncAwaitParallelismUsage ignora comentarios y excluye services y daos', () => {
1506
+ const source = `
1507
+ // async { fake() }
1508
+ val sample = "await()"
1509
+
1510
+ interface CatalogApiService {
1511
+ suspend fun fetchCatalog(): List<String>
1512
+ }
1513
+
1514
+ @Dao
1515
+ interface CatalogDao {
1516
+ @Query("SELECT * FROM catalog")
1517
+ suspend fun loadCatalog(): List<String>
1518
+ }
1519
+
1520
+ class ReportCoordinator {
1521
+ suspend fun buildReport(): String = coroutineScope {
1522
+ val summary = async { "summary" }
1523
+ summary.await()
1524
+ "report"
1525
+ }
1526
+ }
1527
+ `;
1528
+
1529
+ assert.equal(hasAndroidAsyncAwaitParallelismUsage(source), true);
1530
+ assert.equal(
1531
+ hasAndroidAsyncAwaitParallelismUsage(`
1532
+ // async { fake() }
1533
+ val sample = "await()"
1534
+
1535
+ interface CatalogApiService {
1536
+ suspend fun fetchCatalog(): List<String>
1537
+ }
1538
+
1539
+ @Dao
1540
+ interface CatalogDao {
1541
+ @Query("SELECT * FROM catalog")
1542
+ suspend fun loadCatalog(): List<String>
1543
+ }
1544
+ `),
1545
+ false
1546
+ );
1547
+ });
1548
+
1549
+ test('findAndroidDaoSuspendFunctionsMatch devuelve payload semantico para DAO con suspend fun', () => {
1550
+ const source = `
1551
+ @Dao
1552
+ interface CatalogDao {
1553
+ @Query("SELECT * FROM catalog")
1554
+ suspend fun loadCatalog(): List<String>
1555
+
1556
+ @Insert
1557
+ suspend fun saveCatalog(items: List<String>)
1558
+ }
1559
+ `;
1560
+
1561
+ const match = findAndroidDaoSuspendFunctionsMatch(source);
1562
+
1563
+ assert.ok(match);
1564
+ assert.deepEqual(match.primary_node, {
1565
+ kind: 'class',
1566
+ name: 'CatalogDao',
1567
+ lines: [3],
1568
+ });
1569
+ assert.deepEqual(match.related_nodes, [
1570
+ { kind: 'member', name: '@Dao', lines: [2] },
1571
+ { kind: 'member', name: 'suspend fun loadCatalog', lines: [5] },
1572
+ { kind: 'member', name: 'suspend fun saveCatalog', lines: [8] },
1573
+ ]);
1574
+ assert.match(match.why, /DAO/i);
1575
+ assert.match(match.impact, /persistencia|coroutines|Room/i);
1576
+ assert.match(match.expected_fix, /DAO|suspend functions|repositor/i);
1577
+ });
1578
+
1579
+ test('findAndroidTransactionMatch devuelve payload semantico para DAO con @Transaction', () => {
1580
+ const source = `
1581
+ @Dao
1582
+ interface OrderDao {
1583
+ @Transaction
1584
+ fun loadOrderGraph(): Order
1585
+
1586
+ @Transaction
1587
+ fun saveOrder()
1588
+ }
1589
+ `;
1590
+
1591
+ const match = findAndroidTransactionMatch(source);
1592
+
1593
+ assert.ok(match);
1594
+ assert.deepEqual(match?.primary_node, {
1595
+ kind: 'class',
1596
+ name: 'OrderDao',
1597
+ lines: [3],
1598
+ });
1599
+ assert.deepEqual(match?.related_nodes, [
1600
+ { kind: 'member', name: '@Dao', lines: [2] },
1601
+ { kind: 'member', name: '@Transaction fun loadOrderGraph', lines: [4, 5] },
1602
+ { kind: 'member', name: '@Transaction fun saveOrder', lines: [7, 8] },
1603
+ ]);
1604
+ assert.deepEqual(match?.lines, [2, 3, 4, 5, 7, 8]);
1605
+ });
1606
+
1607
+ test('hasAndroidDaoSuspendFunctionsUsage ignora comentarios y DAOs sin suspend fun', () => {
1608
+ const source = `
1609
+ // @Dao
1610
+ interface CatalogDao {
1611
+ fun loadCatalog()
1612
+ }
1613
+ `;
1614
+
1615
+ assert.equal(hasAndroidDaoSuspendFunctionsUsage(source), false);
1616
+ });
1617
+
1618
+ test('hasAndroidTransactionUsage detecta transacciones en DAO y descarta comentarios', () => {
1619
+ const source = `
1620
+ // @Transaction
1621
+ @Dao
1622
+ interface OrderDao {
1623
+ @Transaction
1624
+ fun loadOrderGraph(): Order
1625
+ }
1626
+ `;
1627
+
1628
+ assert.equal(hasAndroidTransactionUsage(source), true);
1629
+ assert.equal(
1630
+ hasAndroidTransactionUsage(`
1631
+ // @Transaction
1632
+ // fun shouldNotTrigger()
1633
+ `),
1634
+ false
1635
+ );
1636
+ });
1637
+
105
1638
  test('hasKotlinForceUnwrapUsage detecta operador !! en codigo Kotlin real', () => {
106
1639
  const source = `
107
- fun renderName(user: User?) {
108
- val name = user!!.name
109
- println(name)
1640
+ fun renderName(user: User?) {
1641
+ val name = user!!.name
1642
+ println(name)
1643
+ }
1644
+ `;
1645
+ assert.equal(hasKotlinForceUnwrapUsage(source), true);
1646
+ });
1647
+
1648
+ test('hasKotlinForceUnwrapUsage ignora comentarios, strings y operador !=', () => {
1649
+ const source = `
1650
+ // val name = user!!.name
1651
+ val debug = "user!!.name"
1652
+ fun isDifferent(left: String?, right: String?) = left != right
1653
+ `;
1654
+ assert.equal(hasKotlinForceUnwrapUsage(source), false);
1655
+ });
1656
+
1657
+ test('hasAndroidJavaSourceCode detecta codigo Java real', () => {
1658
+ const source = `
1659
+ package com.acme.orders;
1660
+
1661
+ public class OrdersActivity {
1662
+ }
1663
+ `;
1664
+ assert.equal(hasAndroidJavaSourceCode(source), true);
1665
+ });
1666
+
1667
+ test('hasAndroidJavaSourceCode ignora menciones Java en comentarios y strings', () => {
1668
+ const source = `
1669
+ // public class OrdersActivity {}
1670
+ val sample = "class OrdersActivity"
1671
+ `;
1672
+ assert.equal(hasAndroidJavaSourceCode(source), false);
1673
+ });
1674
+
1675
+ test('hasAndroidHiltDependencyUsage detecta dependencia Hilt real en Gradle', () => {
1676
+ const source = `
1677
+ dependencies {
1678
+ implementation("com.google.dagger:hilt-android:2.51.1")
1679
+ kapt("com.google.dagger:hilt-compiler:2.51.1")
1680
+ }
1681
+ `;
1682
+ assert.equal(hasAndroidHiltDependencyUsage(source), true);
1683
+ });
1684
+
1685
+ test('hasAndroidHiltDependencyUsage ignora comentarios y dependencias no Hilt', () => {
1686
+ const source = `
1687
+ // implementation("com.google.dagger:hilt-android:2.51.1")
1688
+ implementation("com.squareup.retrofit2:retrofit:2.11.0")
1689
+ `;
1690
+ assert.equal(hasAndroidHiltDependencyUsage(source), false);
1691
+ });
1692
+
1693
+ test('findAndroidWorkManagerDependencyMatch devuelve payload semantico para WorkManager en Gradle', () => {
1694
+ const source = `
1695
+ dependencies {
1696
+ implementation("androidx.work:work-runtime-ktx:2.9.1")
1697
+ }
1698
+ `;
1699
+
1700
+ const match = findAndroidWorkManagerDependencyMatch(source);
1701
+
1702
+ assert.ok(match);
1703
+ assert.deepEqual(match.primary_node, {
1704
+ kind: 'member',
1705
+ name: 'WorkManager dependency',
1706
+ lines: [3],
1707
+ });
1708
+ assert.deepEqual(match.related_nodes, [
1709
+ { kind: 'member', name: 'androidx.work:work-runtime-ktx', lines: [3] },
1710
+ ]);
1711
+ assert.match(match.why, /WorkManager/i);
1712
+ assert.match(match.impact, /background|background tasks|background/i);
1713
+ assert.match(match.expected_fix, /work-runtime-ktx|WorkManager/i);
1714
+ });
1715
+
1716
+ test('hasAndroidWorkManagerDependencyUsage detecta dependencia WorkManager real en Gradle y descarta dependencias no relacionadas', () => {
1717
+ assert.equal(
1718
+ hasAndroidWorkManagerDependencyUsage(`
1719
+ dependencies {
1720
+ implementation("androidx.work:work-runtime-ktx:2.9.1")
1721
+ }
1722
+ `),
1723
+ true
1724
+ );
1725
+ assert.equal(
1726
+ hasAndroidWorkManagerDependencyUsage(`
1727
+ dependencies {
1728
+ implementation("androidx.room:room-ktx:2.6.1")
1729
+ }
1730
+ `),
1731
+ false
1732
+ );
1733
+ });
1734
+
1735
+ test('findAndroidVersionCatalogMatch devuelve payload semantico para libs.versions.toml', () => {
1736
+ const source = `
1737
+ [versions]
1738
+ kotlin = "1.9.24"
1739
+ androidx-core = "1.13.1"
1740
+
1741
+ [libraries]
1742
+ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
1743
+ androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-core" }
1744
+ `;
1745
+
1746
+ const match = findAndroidVersionCatalogMatch(source);
1747
+
1748
+ assert.ok(match);
1749
+ assert.deepEqual(match.primary_node, {
1750
+ kind: 'member',
1751
+ name: 'libs.versions.toml',
1752
+ lines: [2],
1753
+ });
1754
+ assert.deepEqual(match.related_nodes, [
1755
+ { kind: 'member', name: 'versions section', lines: [2] },
1756
+ { kind: 'member', name: 'version alias: kotlin', lines: [3] },
1757
+ { kind: 'member', name: 'version alias: androidx-core', lines: [4] },
1758
+ { kind: 'member', name: 'libraries section', lines: [6] },
1759
+ { kind: 'member', name: 'library alias: androidx-core-ktx', lines: [7] },
1760
+ {
1761
+ kind: 'member',
1762
+ name: 'library alias: androidx-lifecycle-runtime-ktx',
1763
+ lines: [8],
1764
+ },
1765
+ ]);
1766
+ assert.match(match.why, /version catalog|libs\.versions\.toml/i);
1767
+ assert.match(match.impact, /centralizados|accessors/i);
1768
+ assert.match(match.expected_fix, /libs\.versions\.toml|catalogo/i);
1769
+ });
1770
+
1771
+ test('hasAndroidVersionCatalogUsage detecta version catalog real y descarta toml incompleto', () => {
1772
+ const source = `
1773
+ [versions]
1774
+ kotlin = "1.9.24"
1775
+
1776
+ [libraries]
1777
+ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "kotlin" }
1778
+ `;
1779
+
1780
+ assert.equal(hasAndroidVersionCatalogUsage(source), true);
1781
+ assert.equal(
1782
+ hasAndroidVersionCatalogUsage(`
1783
+ [versions]
1784
+ kotlin = "1.9.24"
1785
+ `),
1786
+ false
1787
+ );
1788
+ });
1789
+
1790
+ test('findAndroidAaaPatternMatch devuelve payload semantico para AAA en tests Android', () => {
1791
+ const source = `
1792
+ class OrderTest {
1793
+ @Test
1794
+ fun savesOrder() {
1795
+ // Arrange
1796
+ val repository = FakeRepository()
1797
+ // Act
1798
+ val result = repository.save()
1799
+ // Assert
1800
+ assertTrue(result)
1801
+ }
1802
+ }
1803
+ `;
1804
+
1805
+ const match = findAndroidAaaPatternMatch(source);
1806
+
1807
+ assert.ok(match);
1808
+ assert.deepEqual(match.primary_node, {
1809
+ kind: 'member',
1810
+ name: '@Test fun savesOrder',
1811
+ lines: [4],
1812
+ });
1813
+ assert.deepEqual(match.related_nodes, [
1814
+ { kind: 'member', name: 'AAA marker', lines: [5] },
1815
+ { kind: 'member', name: 'AAA marker', lines: [7] },
1816
+ { kind: 'member', name: 'AAA marker', lines: [9] },
1817
+ ]);
1818
+ assert.match(match.why, /AAA|Arrange|Act|Assert/i);
1819
+ assert.match(match.impact, /intenci[oó]n|separaci[oó]n/i);
1820
+ assert.match(match.expected_fix, /Arrange|Act|Assert/i);
1821
+ });
1822
+
1823
+ test('hasAndroidAaaPatternUsage detecta AAA real y descarta tests sin estructura', () => {
1824
+ assert.equal(
1825
+ hasAndroidAaaPatternUsage(`
1826
+ class OrderTest {
1827
+ @Test
1828
+ fun savesOrder() {
1829
+ // Arrange
1830
+ val repository = FakeRepository()
1831
+ // Act
1832
+ val result = repository.save()
1833
+ // Assert
1834
+ assertTrue(result)
1835
+ }
1836
+ }
1837
+ `),
1838
+ true
1839
+ );
1840
+ assert.equal(
1841
+ hasAndroidAaaPatternUsage(`
1842
+ class OrderTest {
1843
+ @Test
1844
+ fun savesOrder() {
1845
+ val repository = FakeRepository()
1846
+ val result = repository.save()
1847
+ assertTrue(result)
1848
+ }
1849
+ }
1850
+ `),
1851
+ false
1852
+ );
1853
+ });
1854
+
1855
+ test('findAndroidGivenWhenThenMatch devuelve payload semantico para BDD en tests Android', () => {
1856
+ const source = `
1857
+ class OrderTest {
1858
+ @Test
1859
+ fun savesOrder() {
1860
+ // Given
1861
+ val repository = FakeRepository()
1862
+ // When
1863
+ val result = repository.save()
1864
+ // Then
1865
+ assertTrue(result)
1866
+ }
1867
+ }
1868
+ `;
1869
+
1870
+ const match = findAndroidGivenWhenThenMatch(source);
1871
+
1872
+ assert.ok(match);
1873
+ assert.deepEqual(match.primary_node, {
1874
+ kind: 'member',
1875
+ name: '@Test fun savesOrder',
1876
+ lines: [4],
1877
+ });
1878
+ assert.deepEqual(match.related_nodes, [
1879
+ { kind: 'member', name: 'BDD marker', lines: [5] },
1880
+ { kind: 'member', name: 'BDD marker', lines: [7] },
1881
+ { kind: 'member', name: 'BDD marker', lines: [9] },
1882
+ ]);
1883
+ assert.match(match.why, /Given-When-Then|BDD/i);
1884
+ assert.match(match.impact, /comportamiento|lenguaje de producto/i);
1885
+ assert.match(match.expected_fix, /Given|When|Then/i);
1886
+ });
1887
+
1888
+ test('hasAndroidGivenWhenThenUsage detecta BDD real y descarta tests sin estructura', () => {
1889
+ assert.equal(
1890
+ hasAndroidGivenWhenThenUsage(`
1891
+ class OrderTest {
1892
+ @Test
1893
+ fun savesOrder() {
1894
+ // Given
1895
+ val repository = FakeRepository()
1896
+ // When
1897
+ val result = repository.save()
1898
+ // Then
1899
+ assertTrue(result)
1900
+ }
1901
+ }
1902
+ `),
1903
+ true
1904
+ );
1905
+ assert.equal(
1906
+ hasAndroidGivenWhenThenUsage(`
1907
+ class OrderTest {
1908
+ @Test
1909
+ fun savesOrder() {
1910
+ val repository = FakeRepository()
1911
+ val result = repository.save()
1912
+ assertTrue(result)
1913
+ }
1914
+ }
1915
+ `),
1916
+ false
1917
+ );
1918
+ });
1919
+
1920
+ test('findAndroidJvmUnitTestMatch devuelve payload semantico para unit tests JVM en test/', () => {
1921
+ const source = `
1922
+ class CatalogRepositoryTest {
1923
+ @Test
1924
+ fun loadsCatalog() {
1925
+ assertTrue(true)
1926
+ }
1927
+ }
1928
+ `;
1929
+
1930
+ const match = findAndroidJvmUnitTestMatch(source);
1931
+
1932
+ assert.ok(match);
1933
+ assert.deepEqual(match.primary_node, {
1934
+ kind: 'member',
1935
+ name: '@Test fun loadsCatalog',
1936
+ lines: [4],
1937
+ });
1938
+ assert.deepEqual(match.related_nodes, []);
1939
+ assert.match(match.why, /test\/|JVM/i);
1940
+ assert.match(match.impact, /rápidas|androidTest/i);
1941
+ assert.match(match.expected_fix, /src\/test|androidTest/i);
1942
+ });
1943
+
1944
+ test('hasAndroidJvmUnitTestUsage detecta unit tests JVM reales y descarta fuentes no test', () => {
1945
+ assert.equal(
1946
+ hasAndroidJvmUnitTestUsage(`
1947
+ class CatalogRepositoryTest {
1948
+ @Test
1949
+ fun loadsCatalog() {
1950
+ assertTrue(true)
1951
+ }
1952
+ }
1953
+ `),
1954
+ true
1955
+ );
1956
+ assert.equal(
1957
+ hasAndroidJvmUnitTestUsage(`
1958
+ class CatalogRepository {
1959
+ fun loadsCatalog() {
1960
+ assertTrue(true)
1961
+ }
1962
+ }
1963
+ `),
1964
+ false
1965
+ );
1966
+ });
1967
+
1968
+ test('findAndroidWorkManagerBackgroundTaskMatch devuelve payload semantico para Worker de WorkManager', () => {
1969
+ const source = `
1970
+ class SyncWorker(
1971
+ appContext: Context,
1972
+ workerParams: WorkerParameters,
1973
+ ) : CoroutineWorker(appContext, workerParams) {
1974
+ override suspend fun doWork(): Result {
1975
+ return Result.success()
1976
+ }
1977
+ }
1978
+ `;
1979
+
1980
+ const match = findAndroidWorkManagerBackgroundTaskMatch(source);
1981
+
1982
+ assert.ok(match);
1983
+ assert.deepEqual(match.primary_node, {
1984
+ kind: 'class',
1985
+ name: 'SyncWorker',
1986
+ lines: [2],
1987
+ });
1988
+ assert.deepEqual(match.related_nodes, [
1989
+ { kind: 'member', name: 'CoroutineWorker', lines: [2] },
1990
+ { kind: 'call', name: 'doWork', lines: [6] },
1991
+ ]);
1992
+ assert.match(match.why, /WorkManager|WorkManager/i);
1993
+ assert.match(match.impact, /background|Worker/i);
1994
+ assert.match(match.expected_fix, /Worker|CoroutineWorker|ListenableWorker/i);
1995
+ });
1996
+
1997
+ test('hasAndroidWorkManagerBackgroundTaskUsage detecta Worker real y descarta clases no relacionadas', () => {
1998
+ assert.equal(
1999
+ hasAndroidWorkManagerBackgroundTaskUsage(`
2000
+ class SyncWorker(
2001
+ appContext: Context,
2002
+ workerParams: WorkerParameters,
2003
+ ) : CoroutineWorker(appContext, workerParams) {
2004
+ override suspend fun doWork(): Result {
2005
+ return Result.success()
2006
+ }
2007
+ }
2008
+ `),
2009
+ true
2010
+ );
2011
+ assert.equal(
2012
+ hasAndroidWorkManagerBackgroundTaskUsage(`
2013
+ class SyncManager(
2014
+ appContext: Context,
2015
+ workerParams: WorkerParameters,
2016
+ ) {
2017
+ fun doWork(): Result = Result.success()
2018
+ }
2019
+ `),
2020
+ false
2021
+ );
2022
+ });
2023
+
2024
+ test('findAndroidInstrumentedTestMatch devuelve payload semantico para androidTest instrumentado', () => {
2025
+ const source = `
2026
+ @RunWith(AndroidJUnit4::class)
2027
+ class CatalogInstrumentedTest {
2028
+ @Test fun launchesActivity() {
2029
+ ActivityScenario.launch(MainActivity::class.java)
2030
+ InstrumentationRegistry.getInstrumentation()
2031
+ onView(withId(R.id.title)).check(matches(isDisplayed()))
2032
+ }
2033
+ }
2034
+ `;
2035
+
2036
+ const match = findAndroidInstrumentedTestMatch(source);
2037
+
2038
+ assert.ok(match);
2039
+ assert.deepEqual(match.primary_node, {
2040
+ kind: 'member',
2041
+ name: 'androidTest/',
2042
+ lines: [2],
2043
+ });
2044
+ assert.deepEqual(match.related_nodes, [
2045
+ { kind: 'member', name: 'androidTest marker', lines: [2] },
2046
+ { kind: 'member', name: 'androidTest marker', lines: [5] },
2047
+ { kind: 'member', name: 'androidTest marker', lines: [6] },
2048
+ { kind: 'member', name: 'androidTest marker', lines: [7] },
2049
+ ]);
2050
+ assert.match(match.why, /androidTest|instrumentad/i);
2051
+ assert.match(match.impact, /dispositivo|emulador|UI/i);
2052
+ assert.match(match.expected_fix, /AndroidJUnit4|ActivityScenario|Espresso/i);
2053
+ });
2054
+
2055
+ test('hasAndroidInstrumentedTestUsage detecta androidTest real y descarta fuentes no instrumentadas', () => {
2056
+ assert.equal(
2057
+ hasAndroidInstrumentedTestUsage(`
2058
+ @RunWith(AndroidJUnit4::class)
2059
+ class CatalogInstrumentedTest {
2060
+ @Test fun launchesActivity() {
2061
+ ActivityScenario.launch(MainActivity::class.java)
2062
+ }
2063
+ }
2064
+ `),
2065
+ true
2066
+ );
2067
+ assert.equal(
2068
+ hasAndroidInstrumentedTestUsage(`
2069
+ class CatalogUnitTest {
2070
+ @Test fun launchesActivity() {
2071
+ assertTrue(true)
2072
+ }
2073
+ }
2074
+ `),
2075
+ false
2076
+ );
2077
+ });
2078
+
2079
+ test('hasAndroidHiltFrameworkUsage detecta anotaciones y referencias Hilt reales', () => {
2080
+ const source = `
2081
+ import dagger.hilt.android.HiltAndroidApp
2082
+ import javax.inject.Inject
2083
+
2084
+ @HiltAndroidApp
2085
+ class App : Application()
2086
+ `;
2087
+ assert.equal(hasAndroidHiltFrameworkUsage(source), true);
2088
+ });
2089
+
2090
+ test('hasAndroidHiltFrameworkUsage ignora comentarios y strings', () => {
2091
+ const source = `
2092
+ // @HiltAndroidApp
2093
+ val sample = "dagger.hilt.android"
2094
+ `;
2095
+ assert.equal(hasAndroidHiltFrameworkUsage(source), false);
2096
+ });
2097
+
2098
+ test('hasAndroidHiltAndroidAppUsage detecta Application Hilt real', () => {
2099
+ const source = `
2100
+ @HiltAndroidApp
2101
+ class App : Application()
2102
+ `;
2103
+ assert.equal(hasAndroidHiltAndroidAppUsage(source), true);
2104
+ });
2105
+
2106
+ test('hasAndroidAndroidEntryPointUsage detecta Activity o Fragment EntryPoint real', () => {
2107
+ const source = `
2108
+ @AndroidEntryPoint
2109
+ class HomeActivity : AppCompatActivity()
2110
+ `;
2111
+ assert.equal(hasAndroidAndroidEntryPointUsage(source), true);
2112
+ });
2113
+
2114
+ test('hasAndroidInjectConstructorUsage detecta constructor injection real', () => {
2115
+ const source = `
2116
+ class HomeViewModel @Inject constructor(
2117
+ private val repository: HomeRepository
2118
+ )
2119
+ `;
2120
+ assert.equal(hasAndroidInjectConstructorUsage(source), true);
2121
+ });
2122
+
2123
+ test('hasAndroidModuleInstallInUsage detecta Module + InstallIn reales', () => {
2124
+ const source = `
2125
+ @Module
2126
+ @InstallIn(SingletonComponent::class)
2127
+ object NetworkModule
2128
+ `;
2129
+ assert.equal(hasAndroidModuleInstallInUsage(source), true);
2130
+ });
2131
+
2132
+ test('hasAndroidBindsUsage detecta Binds real con Module + InstallIn', () => {
2133
+ const source = `
2134
+ @Module
2135
+ @InstallIn(SingletonComponent::class)
2136
+ abstract class NetworkModule {
2137
+ @Binds
2138
+ abstract fun bindRepository(impl: RepositoryImpl): Repository
2139
+ }
2140
+ `;
2141
+ assert.equal(hasAndroidBindsUsage(source), true);
2142
+ });
2143
+
2144
+ test('hasAndroidBindsUsage ignora comentarios y fuentes sin Module + InstallIn', () => {
2145
+ const source = `
2146
+ // @Binds abstract fun bindRepository(impl: RepositoryImpl): Repository
2147
+ @Provides
2148
+ fun provideRepository(): Repository = RepositoryImpl()
2149
+ `;
2150
+ assert.equal(hasAndroidBindsUsage(source), false);
2151
+ });
2152
+
2153
+ test('hasAndroidProvidesUsage detecta Provides real con Module + InstallIn', () => {
2154
+ const source = `
2155
+ @Module
2156
+ @InstallIn(SingletonComponent::class)
2157
+ abstract class NetworkModule {
2158
+ @Provides
2159
+ fun provideRepository(): Repository = RepositoryImpl()
2160
+ }
2161
+ `;
2162
+ assert.equal(hasAndroidProvidesUsage(source), true);
2163
+ });
2164
+
2165
+ test('hasAndroidProvidesUsage ignora comentarios y fuentes sin Module + InstallIn', () => {
2166
+ const source = `
2167
+ // @Provides fun provideRepository(): Repository = RepositoryImpl()
2168
+ fun provideRepository(): Repository = RepositoryImpl()
2169
+ `;
2170
+ assert.equal(hasAndroidProvidesUsage(source), false);
2171
+ });
2172
+
2173
+ test('hasAndroidViewModelScopedUsage detecta ViewModelScoped real', () => {
2174
+ const source = `
2175
+ @ViewModelScoped
2176
+ class HomeRepository @Inject constructor()
2177
+ `;
2178
+ assert.equal(hasAndroidViewModelScopedUsage(source), true);
2179
+ });
2180
+
2181
+ test('findAndroidViewModelScopeMatch detecta viewModelScope real en codigo Android', () => {
2182
+ const source = `
2183
+ class HomeViewModel : ViewModel() {
2184
+ fun load() {
2185
+ viewModelScope.launch {
2186
+ refresh()
2187
+ }
2188
+ }
110
2189
  }
111
2190
  `;
112
- assert.equal(hasKotlinForceUnwrapUsage(source), true);
2191
+ const match = findAndroidViewModelScopeMatch(source);
2192
+ assert.ok(match);
2193
+ assert.equal(match?.primary_node.name, 'viewModelScope');
2194
+ assert.equal(hasAndroidViewModelScopeUsage(source), true);
113
2195
  });
114
2196
 
115
- test('hasKotlinForceUnwrapUsage ignora comentarios, strings y operador !=', () => {
2197
+ test('findAndroidViewModelScopeMatch ignora comentarios, strings y nombres parciales', () => {
116
2198
  const source = `
117
- // val name = user!!.name
118
- val debug = "user!!.name"
119
- fun isDifferent(left: String?, right: String?) = left != right
2199
+ // viewModelScope.launch { }
2200
+ val sample = "viewModelScope"
2201
+ val viewModelScoped = false
120
2202
  `;
121
- assert.equal(hasKotlinForceUnwrapUsage(source), false);
2203
+ assert.equal(findAndroidViewModelScopeMatch(source), undefined);
2204
+ assert.equal(hasAndroidViewModelScopeUsage(source), false);
122
2205
  });
123
2206
 
124
- test('hasAndroidJavaSourceCode detecta codigo Java real', () => {
2207
+ test('findAndroidAppStartupMatch detecta Initializer real en codigo Android', () => {
125
2208
  const source = `
126
- package com.acme.orders;
2209
+ class FeatureInitializer : Initializer<Unit> {
2210
+ override fun create(context: Context) {
2211
+ return Unit
2212
+ }
127
2213
 
128
- public class OrdersActivity {
2214
+ override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
129
2215
  }
130
2216
  `;
131
- assert.equal(hasAndroidJavaSourceCode(source), true);
2217
+ const match = findAndroidAppStartupMatch(source);
2218
+ assert.ok(match);
2219
+ assert.equal(match?.primary_node.name, 'FeatureInitializer');
2220
+ assert.equal(hasAndroidAppStartupUsage(source), true);
132
2221
  });
133
2222
 
134
- test('hasAndroidJavaSourceCode ignora menciones Java en comentarios y strings', () => {
2223
+ test('findAndroidAppStartupMatch ignora comentarios, strings y clases sin Initializer', () => {
135
2224
  const source = `
136
- // public class OrdersActivity {}
137
- val sample = "class OrdersActivity"
2225
+ // class FeatureInitializer : Initializer<Unit> {}
2226
+ val sample = "Initializer<Unit>"
2227
+ class FeatureInitializerHelper
138
2228
  `;
139
- assert.equal(hasAndroidJavaSourceCode(source), false);
2229
+ assert.equal(findAndroidAppStartupMatch(source), undefined);
2230
+ assert.equal(hasAndroidAppStartupUsage(source), false);
2231
+ });
2232
+
2233
+ test('findAndroidAnalyticsMatch detecta tracking real de analytics en codigo Android', () => {
2234
+ const source = `
2235
+ import com.google.firebase.analytics.FirebaseAnalytics
2236
+
2237
+ class PurchaseAnalytics(private val firebaseAnalytics: FirebaseAnalytics) {
2238
+ fun trackPurchase(eventName: String) {
2239
+ firebaseAnalytics.trackEvent(eventName)
2240
+ }
2241
+ }
2242
+ `;
2243
+ const match = findAndroidAnalyticsMatch(source);
2244
+ assert.ok(match);
2245
+ assert.equal(match?.primary_node.name, 'PurchaseAnalytics');
2246
+ assert.equal(hasAndroidAnalyticsUsage(source), true);
2247
+ });
2248
+
2249
+ test('findAndroidAnalyticsMatch ignora comentarios, strings y clases sin tracking', () => {
2250
+ const source = `
2251
+ // firebaseAnalytics.logEvent("purchase")
2252
+ val sample = "trackEvent(\"purchase\")"
2253
+ class PurchaseLogger {
2254
+ fun log() = Unit
2255
+ }
2256
+ `;
2257
+ assert.equal(findAndroidAnalyticsMatch(source), undefined);
2258
+ assert.equal(hasAndroidAnalyticsUsage(source), false);
2259
+ });
2260
+
2261
+ test('findAndroidProfilerMatch detecta profiling real de Android en codigo de produccion', () => {
2262
+ const source = `
2263
+ import android.os.Debug
2264
+
2265
+ class CheckoutProfiler {
2266
+ fun traceStartup() {
2267
+ Debug.startMethodTracing()
2268
+ Debug.stopMethodTracing()
2269
+ }
2270
+ }
2271
+ `;
2272
+ const match = findAndroidProfilerMatch(source);
2273
+ assert.ok(match);
2274
+ assert.equal(match?.primary_node.name, 'CheckoutProfiler');
2275
+ assert.equal(hasAndroidProfilerUsage(source), true);
2276
+ });
2277
+
2278
+ test('findAndroidProfilerMatch ignora comentarios, strings y clases sin profiling', () => {
2279
+ const source = `
2280
+ // Trace.beginSection("startup")
2281
+ val sample = "startMethodTracing"
2282
+ class CheckoutLogger {
2283
+ fun log() = Unit
2284
+ }
2285
+ `;
2286
+ assert.equal(findAndroidProfilerMatch(source), undefined);
2287
+ assert.equal(hasAndroidProfilerUsage(source), false);
2288
+ });
2289
+
2290
+ test('findAndroidBaselineProfilesMatch detecta BaselineProfileRule real en androidTest', () => {
2291
+ const source = `
2292
+ import androidx.benchmark.macro.junit4.BaselineProfileRule
2293
+
2294
+ class StartupBaselineProfileTest {
2295
+ @get:Rule
2296
+ val baselineProfileRule = BaselineProfileRule()
2297
+
2298
+ @Test
2299
+ fun generateBaselineProfile() {
2300
+ baselineProfileRule.collect(
2301
+ packageName = "com.acme.app"
2302
+ ) {
2303
+ startActivityAndWait()
2304
+ }
2305
+ }
2306
+ }
2307
+ `;
2308
+ const match = findAndroidBaselineProfilesMatch(source);
2309
+ assert.ok(match);
2310
+ assert.equal(match?.primary_node.name, 'BaselineProfileRule');
2311
+ assert.equal(hasAndroidBaselineProfilesUsage(source), true);
2312
+ });
2313
+
2314
+ test('findAndroidBaselineProfilesMatch ignora comentarios, strings y referencias parciales', () => {
2315
+ const source = `
2316
+ // BaselineProfileRule()
2317
+ val sample = "BaselineProfileRule.collect(packageName = \\"com.acme.app\\")"
2318
+ fun render() {
2319
+ val collect = "collect()"
2320
+ }
2321
+ `;
2322
+ assert.equal(findAndroidBaselineProfilesMatch(source), undefined);
2323
+ assert.equal(hasAndroidBaselineProfilesUsage(source), false);
2324
+ });
2325
+
2326
+ test('findAndroidSkipRecompositionMatch detecta composables con parametros estables o inmutables', () => {
2327
+ const source = `
2328
+ import androidx.compose.runtime.Composable
2329
+ import kotlinx.collections.immutable.ImmutableList
2330
+
2331
+ @Composable
2332
+ fun Feed(items: ImmutableList<FeedItem>, state: FeedUiState) {
2333
+ Text(text = state.title)
2334
+ }
2335
+ `;
2336
+ const match = findAndroidSkipRecompositionMatch(source);
2337
+ assert.ok(match);
2338
+ assert.equal(match?.primary_node.name, '@Composable fun Feed');
2339
+ assert.equal(hasAndroidSkipRecompositionUsage(source), true);
2340
+ });
2341
+
2342
+ test('findAndroidSkipRecompositionMatch ignora composables sin parametros estables o inmutables', () => {
2343
+ const source = `
2344
+ @Composable
2345
+ fun Feed(items: List<FeedItem>, state: FeedUiState) {
2346
+ Text(text = state.title)
2347
+ }
2348
+ `;
2349
+ assert.equal(findAndroidSkipRecompositionMatch(source), undefined);
2350
+ assert.equal(hasAndroidSkipRecompositionUsage(source), false);
2351
+ });
2352
+
2353
+ test('findAndroidStabilityMatch detecta composables con tipos @Stable o @Immutable', () => {
2354
+ const source = `
2355
+ import androidx.compose.runtime.Composable
2356
+ import androidx.compose.runtime.Immutable
2357
+ import androidx.compose.runtime.Stable
2358
+
2359
+ @Stable
2360
+ class PlaybackState(val isPlaying: Boolean)
2361
+
2362
+ @Immutable
2363
+ data class FeedUiState(val title: String)
2364
+
2365
+ @Composable
2366
+ fun Feed(state: FeedUiState, playback: PlaybackState) {
2367
+ Text(text = state.title)
2368
+ }
2369
+ `;
2370
+ const match = findAndroidStabilityMatch(source);
2371
+ assert.ok(match);
2372
+ assert.equal(match?.primary_node.name, '@Composable fun Feed');
2373
+ assert.equal(hasAndroidStabilityUsage(source), true);
2374
+ });
2375
+
2376
+ test('findAndroidStabilityMatch ignora composables sin tipos estables o inmutables', () => {
2377
+ const source = `
2378
+ @Composable
2379
+ fun Feed(state: FeedUiState, playback: PlaybackState) {
2380
+ Text(text = state.title)
2381
+ }
2382
+
2383
+ data class FeedUiState(val title: String)
2384
+ class PlaybackState(val isPlaying: Boolean)
2385
+ `;
2386
+ assert.equal(findAndroidStabilityMatch(source), undefined);
2387
+ assert.equal(hasAndroidStabilityUsage(source), false);
2388
+ });
2389
+
2390
+ test('findAndroidComposableFunctionMatch detecta composable real en codigo Android', () => {
2391
+ const source = `
2392
+ @Composable
2393
+ fun HomeScreen() {
2394
+ Box(modifier = Modifier)
2395
+ }
2396
+ `;
2397
+ const match = findAndroidComposableFunctionMatch(source);
2398
+ assert.ok(match);
2399
+ assert.equal(match?.primary_node.name, '@Composable fun HomeScreen');
2400
+ assert.deepEqual(match?.lines, [3]);
2401
+ assert.equal(hasAndroidComposableFunctionUsage(source), true);
2402
+ });
2403
+
2404
+ test('findAndroidComposableFunctionMatch ignora comentarios y strings', () => {
2405
+ const source = `
2406
+ // @Composable fun HomeScreen() {}
2407
+ val sample = "@Composable fun HomeScreen()"
2408
+ `;
2409
+ assert.equal(findAndroidComposableFunctionMatch(source), undefined);
2410
+ assert.equal(hasAndroidComposableFunctionUsage(source), false);
2411
+ });
2412
+
2413
+ test('findAndroidArgumentsMatch devuelve payload semantico para argumentos entre pantallas', () => {
2414
+ const source = `
2415
+ import androidx.lifecycle.SavedStateHandle
2416
+
2417
+ class OrderDetailViewModel(
2418
+ private val navController: NavController,
2419
+ savedStateHandle: SavedStateHandle,
2420
+ ) : ViewModel() {
2421
+ val orderId = checkNotNull(savedStateHandle["orderId"])
2422
+
2423
+ fun openOtherScreen(route: String) {
2424
+ navController.navigate(route)
2425
+ }
2426
+ }
2427
+ `;
2428
+ const match = findAndroidArgumentsMatch(source);
2429
+ assert.ok(match);
2430
+ assert.equal(match?.primary_node.name, 'OrderDetailViewModel');
2431
+ assert.equal(hasAndroidArgumentsUsage(source), true);
2432
+ });
2433
+
2434
+ test('findAndroidArgumentsMatch ignora navigate sin argumentos y comentarios', () => {
2435
+ const source = `
2436
+ // NavHost(navController = navController, startDestination = "orders") {}
2437
+ class OrderDetailViewModel : ViewModel() {
2438
+ fun openDetails(navController: NavController) {
2439
+ navController.navigate("orders")
2440
+ }
2441
+ }
2442
+ `;
2443
+ assert.equal(findAndroidArgumentsMatch(source), undefined);
2444
+ assert.equal(hasAndroidArgumentsUsage(source), false);
2445
+ });
2446
+
2447
+ test('findAndroidSingleActivityComposeShellMatch detecta Activity shell con Compose', () => {
2448
+ const source = `
2449
+ class MainActivity : ComponentActivity() {
2450
+ override fun onCreate(savedInstanceState: Bundle?) {
2451
+ super.onCreate(savedInstanceState)
2452
+ setContent {
2453
+ NavHost(navController = rememberNavController(), startDestination = homeRoute()) { }
2454
+ }
2455
+ }
2456
+ }
2457
+ `;
2458
+ const match = findAndroidSingleActivityComposeShellMatch(source);
2459
+ assert.ok(match);
2460
+ assert.equal(match?.primary_node.name, 'MainActivity');
2461
+ assert.equal(hasAndroidSingleActivityComposeShellUsage(source), true);
2462
+ });
2463
+
2464
+ test('findAndroidSingleActivityComposeShellMatch ignora Activities sin Compose shell', () => {
2465
+ const source = `
2466
+ class MainActivity : ComponentActivity() {
2467
+ override fun onCreate(savedInstanceState: Bundle?) {
2468
+ super.onCreate(savedInstanceState)
2469
+ }
2470
+ }
2471
+ `;
2472
+ assert.equal(findAndroidSingleActivityComposeShellMatch(source), undefined);
2473
+ assert.equal(hasAndroidSingleActivityComposeShellUsage(source), false);
2474
+ });
2475
+
2476
+ test('findAndroidGodActivityMatch detecta God Activity que mezcla shell Compose y composables', () => {
2477
+ const source = `
2478
+ class MainActivity : ComponentActivity() {
2479
+ override fun onCreate(savedInstanceState: Bundle?) {
2480
+ super.onCreate(savedInstanceState)
2481
+ setContent {
2482
+ HomeScreen()
2483
+ }
2484
+ }
2485
+ }
2486
+
2487
+ @Composable
2488
+ fun HomeScreen() {
2489
+ Box(modifier = Modifier)
2490
+ }
2491
+ `;
2492
+ const match = findAndroidGodActivityMatch(source);
2493
+ assert.ok(match);
2494
+ assert.equal(match?.primary_node.name, 'MainActivity');
2495
+ assert.equal(hasAndroidGodActivityUsage(source), true);
2496
+ });
2497
+
2498
+ test('findAndroidGodActivityMatch ignora Activity shell pura', () => {
2499
+ const source = `
2500
+ class MainActivity : ComponentActivity() {
2501
+ override fun onCreate(savedInstanceState: Bundle?) {
2502
+ super.onCreate(savedInstanceState)
2503
+ setContent {
2504
+ NavHost(navController = rememberNavController(), startDestination = homeRoute()) { }
2505
+ }
2506
+ }
2507
+ }
2508
+ `;
2509
+ assert.equal(findAndroidGodActivityMatch(source), undefined);
2510
+ assert.equal(hasAndroidGodActivityUsage(source), false);
140
2511
  });
141
2512
 
142
2513
  test('hasAndroidAsyncTaskUsage detecta AsyncTask real en codigo Android', () => {
@@ -257,6 +2628,40 @@ suspend fun load() {
257
2628
  assert.equal(hasAndroidCoroutineTryCatchUsage(source), true);
258
2629
  });
259
2630
 
2631
+ test('findAndroidSupervisorScopeMatch detecta supervisorScope real en codigo coroutine Android', () => {
2632
+ const source = `
2633
+ import kotlinx.coroutines.async
2634
+ import kotlinx.coroutines.launch
2635
+
2636
+ suspend fun load() = supervisorScope {
2637
+ val summary = async { loadSummary() }
2638
+ launch { refreshCache() }
2639
+ summary.await()
2640
+ }
2641
+ `;
2642
+
2643
+ const match = findAndroidSupervisorScopeMatch(source);
2644
+
2645
+ assert.ok(match);
2646
+ assert.deepEqual(match?.primary_node, {
2647
+ kind: 'member',
2648
+ name: 'supervisorScope',
2649
+ lines: [5],
2650
+ });
2651
+ assert.equal(hasAndroidSupervisorScopeUsage(source), true);
2652
+ });
2653
+
2654
+ test('findAndroidSupervisorScopeMatch ignora comentarios, strings y nombres parciales', () => {
2655
+ const source = `
2656
+ // supervisorScope { launch { } }
2657
+ val sample = "supervisorScope"
2658
+ class SupervisorScopeHelper
2659
+ `;
2660
+
2661
+ assert.equal(findAndroidSupervisorScopeMatch(source), undefined);
2662
+ assert.equal(hasAndroidSupervisorScopeUsage(source), false);
2663
+ });
2664
+
260
2665
  test('hasAndroidCoroutineTryCatchUsage ignora comentarios, strings y codigo no coroutine', () => {
261
2666
  const source = `
262
2667
  // try { withContext(Dispatchers.IO) } catch (e: Exception) {}
@@ -274,11 +2679,9 @@ fun notCoroutine() {
274
2679
 
275
2680
  test('hasAndroidNoConsoleLogUsage detecta logs reales en codigo Android y permite debug guards', () => {
276
2681
  const source = `
277
- import timber.log.Timber
278
-
279
2682
  fun render() {
280
- Timber.d("visible in production")
281
- if (BuildConfig.DEBUG) Timber.d("visible in debug only")
2683
+ Log.d("visible in production")
2684
+ if (BuildConfig.DEBUG) Log.d("visible in debug only")
282
2685
  if (BuildConfig.DEBUG) {
283
2686
  Log.d("Tag", "debug only")
284
2687
  }
@@ -289,18 +2692,63 @@ fun render() {
289
2692
 
290
2693
  test('hasAndroidNoConsoleLogUsage ignora comentarios, strings y logs protegidos por BuildConfig.DEBUG', () => {
291
2694
  const source = `
292
- // Timber.d("debug")
2695
+ // Log.d("debug")
293
2696
  val sample = "Log.e(\\"Tag\\", \\"debug\\")"
294
2697
  fun render() {
295
- if (BuildConfig.DEBUG) Timber.d("debug only")
2698
+ if (BuildConfig.DEBUG) Log.d("debug only")
296
2699
  if (BuildConfig.DEBUG) {
297
2700
  Log.e("Tag", "debug only")
298
2701
  }
299
2702
  }
300
- `;
2703
+ `;
301
2704
  assert.equal(hasAndroidNoConsoleLogUsage(source), false);
302
2705
  });
303
2706
 
2707
+ test('hasAndroidTimberUsage detecta Timber real en codigo Android y permite debug guards', () => {
2708
+ const source = `
2709
+ import timber.log.Timber
2710
+
2711
+ fun render() {
2712
+ Timber.d("visible in production")
2713
+ if (BuildConfig.DEBUG) Timber.d("visible in debug only")
2714
+ }
2715
+ `;
2716
+ assert.equal(hasAndroidTimberUsage(source), true);
2717
+ });
2718
+
2719
+ test('hasAndroidTimberUsage ignora comentarios, strings y logs protegidos por BuildConfig.DEBUG', () => {
2720
+ const source = `
2721
+ // Timber.d("debug")
2722
+ val sample = "Timber.e(\\"Tag\\", \\"debug\\")"
2723
+ fun render() {
2724
+ if (BuildConfig.DEBUG) Timber.d("debug only")
2725
+ }
2726
+ `;
2727
+ assert.equal(hasAndroidTimberUsage(source), false);
2728
+ });
2729
+
2730
+ test('hasAndroidBuildConfigConstantUsage detecta constantes BuildConfig reales en codigo Android', () => {
2731
+ const source = `
2732
+ fun versionName(): String {
2733
+ return BuildConfig.VERSION_NAME
2734
+ }
2735
+ `;
2736
+ assert.equal(hasAndroidBuildConfigConstantUsage(source), true);
2737
+ });
2738
+
2739
+ test('hasAndroidBuildConfigConstantUsage ignora comentarios, strings y BuildConfig.DEBUG', () => {
2740
+ const source = `
2741
+ // BuildConfig.VERSION_NAME
2742
+ val sample = "BuildConfig.BUILD_TYPE"
2743
+ fun render() {
2744
+ if (BuildConfig.DEBUG) {
2745
+ Timber.d("debug only")
2746
+ }
2747
+ }
2748
+ `;
2749
+ assert.equal(hasAndroidBuildConfigConstantUsage(source), false);
2750
+ });
2751
+
304
2752
  test('hasAndroidHardcodedStringUsage detecta strings hardcodeadas en codigo Android', () => {
305
2753
  const source = `
306
2754
  fun render() {
@@ -320,6 +2768,117 @@ fun render() {
320
2768
  assert.equal(hasAndroidHardcodedStringUsage(source), false);
321
2769
  });
322
2770
 
2771
+ test('findAndroidStringsXmlMatch devuelve payload semantico para strings.xml localizado', () => {
2772
+ const source = `
2773
+ <resources>
2774
+ <string name="app_name">Mi App</string>
2775
+ <string-array name="welcome_steps">
2776
+ <item>Bienvenido</item>
2777
+ </string-array>
2778
+ </resources>
2779
+ `;
2780
+
2781
+ const match = findAndroidStringsXmlMatch(source);
2782
+
2783
+ assert.ok(match);
2784
+ assert.deepEqual(match.primary_node, {
2785
+ kind: 'member',
2786
+ name: 'strings.xml',
2787
+ lines: [2],
2788
+ });
2789
+ assert.deepEqual(match.related_nodes, [
2790
+ { kind: 'property', name: 'string', lines: [3] },
2791
+ { kind: 'property', name: 'string-array', lines: [4] },
2792
+ ]);
2793
+ assert.match(match.why, /strings\.xml|localiz/i);
2794
+ assert.match(match.impact, /internacionalizaci[oó]n|mantenimiento/i);
2795
+ assert.match(match.expected_fix, /values-\\*\/strings\.xml|R\.string/i);
2796
+ });
2797
+
2798
+ test('hasAndroidStringsXmlUsage ignora comentarios y XML incompleto', () => {
2799
+ const source = `
2800
+ <!-- <string name="debug">Hola</string> -->
2801
+ <resources>
2802
+ <!-- strings xml placeholder -->
2803
+ </resources>
2804
+ `;
2805
+
2806
+ assert.equal(hasAndroidStringsXmlUsage(source), false);
2807
+ });
2808
+
2809
+ test('findAndroidStringFormattingMatch devuelve payload semantico para strings.xml con placeholders posicionales', () => {
2810
+ const source = `
2811
+ <resources>
2812
+ <string name="order_summary">Hola %1$s, total %2$d</string>
2813
+ <string name="app_name">Mi App</string>
2814
+ </resources>
2815
+ `;
2816
+
2817
+ const match = findAndroidStringFormattingMatch(source);
2818
+
2819
+ assert.ok(match);
2820
+ assert.deepEqual(match.primary_node, {
2821
+ kind: 'member',
2822
+ name: 'strings.xml',
2823
+ lines: [2],
2824
+ });
2825
+ assert.deepEqual(match.related_nodes, [
2826
+ { kind: 'property', name: 'formatted string', lines: [3] },
2827
+ ]);
2828
+ assert.match(match.why, /placeholders|argumentos|idiomas/i);
2829
+ assert.match(match.impact, /locale|traducci[oó]n|argumentos/i);
2830
+ assert.match(match.expected_fix, /%1\$s|%2\$d|strings\.xml/i);
2831
+ });
2832
+
2833
+ test('findAndroidStringFormattingMatch ignora strings sin placeholders posicionales', () => {
2834
+ const source = `
2835
+ <resources>
2836
+ <string name="order_summary">Hola mundo</string>
2837
+ </resources>
2838
+ `;
2839
+
2840
+ assert.equal(findAndroidStringFormattingMatch(source), undefined);
2841
+ assert.equal(hasAndroidStringFormattingUsage(source), false);
2842
+ });
2843
+
2844
+ test('findAndroidPluralsXmlMatch devuelve payload semantico para plurals.xml localizado', () => {
2845
+ const source = `
2846
+ <resources>
2847
+ <plurals name="notification_count">
2848
+ <item quantity="one">1 notificación</item>
2849
+ <item quantity="other">%d notificaciones</item>
2850
+ </plurals>
2851
+ </resources>
2852
+ `;
2853
+
2854
+ const match = findAndroidPluralsXmlMatch(source);
2855
+
2856
+ assert.ok(match);
2857
+ assert.deepEqual(match.primary_node, {
2858
+ kind: 'member',
2859
+ name: 'plurals.xml',
2860
+ lines: [2],
2861
+ });
2862
+ assert.deepEqual(match.related_nodes, [
2863
+ { kind: 'property', name: 'plurals', lines: [3] },
2864
+ { kind: 'property', name: 'plural item', lines: [4] },
2865
+ ]);
2866
+ assert.match(match.why, /plurals\.xml|plural/i);
2867
+ assert.match(match.impact, /plural|idioma|cantidad/i);
2868
+ assert.match(match.expected_fix, /plurals|quantity|R\.plurals/i);
2869
+ });
2870
+
2871
+ test('hasAndroidPluralsXmlUsage ignora comentarios y XML incompleto', () => {
2872
+ const source = `
2873
+ <!-- <plurals name="debug_count"> -->
2874
+ <resources>
2875
+ <item quantity="one">1 notificación</item>
2876
+ </resources>
2877
+ `;
2878
+
2879
+ assert.equal(hasAndroidPluralsXmlUsage(source), false);
2880
+ });
2881
+
323
2882
  test('hasAndroidSingletonUsage detecta object declarations y companion singleton holders', () => {
324
2883
  const source = `
325
2884
  object SessionManager {