pumuki 6.3.127 → 6.3.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,14 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [6.3.129] - 2026-04-29
10
+
11
+ ### Fixed
12
+
13
+ - **Nueva slice Android de singletons cerrada:** `skills.android.no-singleton-usar-inyeccio-n-de-dependencias-hilt-dagger` pasa a detector AST real y deja de depender de normalización genérica.
14
+ - **Exclusión correcta de módulos DI:** `@Module`, `@InstallIn` y `@EntryPoint` ya no disparan el detector de singleton cuando el `object` es un módulo de inyección legítimo.
15
+ - **Cobertura de regresión y lock recompilado:** la suite Android dirigida vuelve a verde y `skills.lock.json` se regenera con el binding canónico de la nueva skill.
16
+
9
17
  ## [6.3.127] - 2026-04-28
10
18
 
11
19
  ### Fixed
package/VERSION CHANGED
@@ -1 +1 @@
1
- v6.3.127
1
+ v6.3.128
@@ -6,6 +6,17 @@ import {
6
6
  findKotlinLiskovSubstitutionMatch,
7
7
  findKotlinOpenClosedWhenMatch,
8
8
  findKotlinPresentationSrpMatch,
9
+ hasAndroidAsyncTaskUsage,
10
+ hasAndroidFindViewByIdUsage,
11
+ hasAndroidJavaSourceCode,
12
+ hasAndroidDispatcherUsage,
13
+ hasAndroidCoroutineTryCatchUsage,
14
+ hasAndroidHardcodedStringUsage,
15
+ hasAndroidNoConsoleLogUsage,
16
+ hasAndroidRxJavaUsage,
17
+ hasAndroidSingletonUsage,
18
+ hasAndroidWithContextUsage,
19
+ hasKotlinForceUnwrapUsage,
9
20
  hasKotlinGlobalScopeUsage,
10
21
  hasKotlinRunBlockingUsage,
11
22
  hasKotlinThreadSleepCall,
@@ -91,6 +102,263 @@ fun main() {
91
102
  assert.equal(hasKotlinRunBlockingUsage(partialSource), false);
92
103
  });
93
104
 
105
+ test('hasKotlinForceUnwrapUsage detecta operador !! en codigo Kotlin real', () => {
106
+ const source = `
107
+ fun renderName(user: User?) {
108
+ val name = user!!.name
109
+ println(name)
110
+ }
111
+ `;
112
+ assert.equal(hasKotlinForceUnwrapUsage(source), true);
113
+ });
114
+
115
+ test('hasKotlinForceUnwrapUsage ignora comentarios, strings y operador !=', () => {
116
+ const source = `
117
+ // val name = user!!.name
118
+ val debug = "user!!.name"
119
+ fun isDifferent(left: String?, right: String?) = left != right
120
+ `;
121
+ assert.equal(hasKotlinForceUnwrapUsage(source), false);
122
+ });
123
+
124
+ test('hasAndroidJavaSourceCode detecta codigo Java real', () => {
125
+ const source = `
126
+ package com.acme.orders;
127
+
128
+ public class OrdersActivity {
129
+ }
130
+ `;
131
+ assert.equal(hasAndroidJavaSourceCode(source), true);
132
+ });
133
+
134
+ test('hasAndroidJavaSourceCode ignora menciones Java en comentarios y strings', () => {
135
+ const source = `
136
+ // public class OrdersActivity {}
137
+ val sample = "class OrdersActivity"
138
+ `;
139
+ assert.equal(hasAndroidJavaSourceCode(source), false);
140
+ });
141
+
142
+ test('hasAndroidAsyncTaskUsage detecta AsyncTask real en codigo Android', () => {
143
+ const source = `
144
+ import android.os.AsyncTask
145
+
146
+ class LoadDataTask : AsyncTask<Unit, Unit, String>() {
147
+ override fun doInBackground(vararg params: Unit): String = "ok"
148
+ }
149
+ `;
150
+ assert.equal(hasAndroidAsyncTaskUsage(source), true);
151
+ });
152
+
153
+ test('hasAndroidAsyncTaskUsage ignora comentarios, strings y nombres parciales', () => {
154
+ const source = `
155
+ // AsyncTask should be removed
156
+ val sample = "AsyncTask"
157
+ class AsyncTaskRunner
158
+ `;
159
+ assert.equal(hasAndroidAsyncTaskUsage(source), false);
160
+ });
161
+
162
+ test('hasAndroidFindViewByIdUsage detecta findViewById real en codigo Android', () => {
163
+ const source = `
164
+ class HomeActivity : AppCompatActivity() {
165
+ fun render() {
166
+ val title = findViewById<TextView>(R.id.title)
167
+ }
168
+ }
169
+ `;
170
+ assert.equal(hasAndroidFindViewByIdUsage(source), true);
171
+ });
172
+
173
+ test('hasAndroidFindViewByIdUsage ignora comentarios, strings y nombres parciales', () => {
174
+ const source = `
175
+ // findViewById<TextView>(R.id.title)
176
+ val sample = "findViewById"
177
+ class FindViewByIdHelper
178
+ `;
179
+ assert.equal(hasAndroidFindViewByIdUsage(source), false);
180
+ });
181
+
182
+ test('hasAndroidRxJavaUsage detecta RxJava real en codigo Android', () => {
183
+ const source = `
184
+ import io.reactivex.rxjava3.core.Observable
185
+
186
+ class HomeRepository {
187
+ fun load() = Observable.just("ok")
188
+ }
189
+ `;
190
+ assert.equal(hasAndroidRxJavaUsage(source), true);
191
+ });
192
+
193
+ test('hasAndroidRxJavaUsage ignora comentarios, strings y nombres parciales', () => {
194
+ const source = `
195
+ // Observable.just("ok")
196
+ val sample = "RxJava"
197
+ class ObservableHelper
198
+ `;
199
+ assert.equal(hasAndroidRxJavaUsage(source), false);
200
+ });
201
+
202
+ test('hasAndroidDispatcherUsage detecta Dispatchers reales en codigo Android', () => {
203
+ const source = `
204
+ import kotlinx.coroutines.Dispatchers
205
+ import kotlinx.coroutines.withContext
206
+
207
+ suspend fun load() = withContext(Dispatchers.IO) {
208
+ "ok"
209
+ }
210
+ `;
211
+ assert.equal(hasAndroidDispatcherUsage(source), true);
212
+ });
213
+
214
+ test('hasAndroidDispatcherUsage ignora comentarios, strings y nombres parciales', () => {
215
+ const source = `
216
+ // withContext(Dispatchers.IO)
217
+ val sample = "Dispatchers.Main"
218
+ class DispatchersHelper
219
+ `;
220
+ assert.equal(hasAndroidDispatcherUsage(source), false);
221
+ });
222
+
223
+ test('hasAndroidWithContextUsage detecta withContext real en codigo Android', () => {
224
+ const source = `
225
+ import kotlinx.coroutines.Dispatchers
226
+ import kotlinx.coroutines.withContext
227
+
228
+ suspend fun load() = withContext(Dispatchers.IO) {
229
+ "ok"
230
+ }
231
+ `;
232
+ assert.equal(hasAndroidWithContextUsage(source), true);
233
+ });
234
+
235
+ test('hasAndroidWithContextUsage ignora comentarios, strings y nombres parciales', () => {
236
+ const source = `
237
+ // withContext(Dispatchers.IO)
238
+ val sample = "withContext"
239
+ class WithContextHelper
240
+ `;
241
+ assert.equal(hasAndroidWithContextUsage(source), false);
242
+ });
243
+
244
+ test('hasAndroidCoroutineTryCatchUsage detecta try-catch real en codigo coroutine Android', () => {
245
+ const source = `
246
+ import kotlinx.coroutines.Dispatchers
247
+ import kotlinx.coroutines.withContext
248
+
249
+ suspend fun load() {
250
+ try {
251
+ withContext(Dispatchers.IO) { }
252
+ } catch (e: Exception) {
253
+ throw e
254
+ }
255
+ }
256
+ `;
257
+ assert.equal(hasAndroidCoroutineTryCatchUsage(source), true);
258
+ });
259
+
260
+ test('hasAndroidCoroutineTryCatchUsage ignora comentarios, strings y codigo no coroutine', () => {
261
+ const source = `
262
+ // try { withContext(Dispatchers.IO) } catch (e: Exception) {}
263
+ val sample = "try catch"
264
+ fun notCoroutine() {
265
+ try {
266
+ println("ok")
267
+ } catch (e: Exception) {
268
+ println(e)
269
+ }
270
+ }
271
+ `;
272
+ assert.equal(hasAndroidCoroutineTryCatchUsage(source), false);
273
+ });
274
+
275
+ test('hasAndroidNoConsoleLogUsage detecta logs reales en codigo Android y permite debug guards', () => {
276
+ const source = `
277
+ import timber.log.Timber
278
+
279
+ fun render() {
280
+ Timber.d("visible in production")
281
+ if (BuildConfig.DEBUG) Timber.d("visible in debug only")
282
+ if (BuildConfig.DEBUG) {
283
+ Log.d("Tag", "debug only")
284
+ }
285
+ }
286
+ `;
287
+ assert.equal(hasAndroidNoConsoleLogUsage(source), true);
288
+ });
289
+
290
+ test('hasAndroidNoConsoleLogUsage ignora comentarios, strings y logs protegidos por BuildConfig.DEBUG', () => {
291
+ const source = `
292
+ // Timber.d("debug")
293
+ val sample = "Log.e(\\"Tag\\", \\"debug\\")"
294
+ fun render() {
295
+ if (BuildConfig.DEBUG) Timber.d("debug only")
296
+ if (BuildConfig.DEBUG) {
297
+ Log.e("Tag", "debug only")
298
+ }
299
+ }
300
+ `;
301
+ assert.equal(hasAndroidNoConsoleLogUsage(source), false);
302
+ });
303
+
304
+ test('hasAndroidHardcodedStringUsage detecta strings hardcodeadas en codigo Android', () => {
305
+ const source = `
306
+ fun render() {
307
+ val title = "Hola mundo"
308
+ }
309
+ `;
310
+ assert.equal(hasAndroidHardcodedStringUsage(source), true);
311
+ });
312
+
313
+ test('hasAndroidHardcodedStringUsage ignora comentarios y referencias a resources', () => {
314
+ const source = `
315
+ // "Hola mundo"
316
+ fun render() {
317
+ val title = R.string.app_name
318
+ }
319
+ `;
320
+ assert.equal(hasAndroidHardcodedStringUsage(source), false);
321
+ });
322
+
323
+ test('hasAndroidSingletonUsage detecta object declarations y companion singleton holders', () => {
324
+ const source = `
325
+ object SessionManager {
326
+ fun refresh() {}
327
+ }
328
+
329
+ class HomeRepository private constructor() {
330
+ companion object {
331
+ @Volatile private var INSTANCE: HomeRepository? = null
332
+
333
+ fun getInstance(): HomeRepository {
334
+ return INSTANCE ?: HomeRepository()
335
+ }
336
+ }
337
+ }
338
+ `;
339
+ assert.equal(hasAndroidSingletonUsage(source), true);
340
+ });
341
+
342
+ test('hasAndroidSingletonUsage ignora anonymous objects y companion objects inocuos', () => {
343
+ const source = `
344
+ val listener = object : Runnable {
345
+ override fun run() {}
346
+ }
347
+
348
+ @Module
349
+ object NetworkModule {
350
+ fun provideClient(): String = "ok"
351
+ }
352
+
353
+ class Themes {
354
+ companion object {
355
+ const val DEFAULT = "dark"
356
+ }
357
+ }
358
+ `;
359
+ assert.equal(hasAndroidSingletonUsage(source), false);
360
+ });
361
+
94
362
  test('findKotlinPresentationSrpMatch devuelve payload semantico para SRP-Android en presentation', () => {
95
363
  const source = `
96
364
  import android.content.SharedPreferences
@@ -292,6 +292,346 @@ export const hasKotlinRunBlockingUsage = (source: string): boolean => {
292
292
  });
293
293
  };
294
294
 
295
+ export const hasKotlinForceUnwrapUsage = (source: string): boolean => {
296
+ return scanCodeLikeSource(source, ({ source: kotlinSource, index, current }) => {
297
+ if (current !== '!' || kotlinSource[index + 1] !== '!') {
298
+ return false;
299
+ }
300
+
301
+ return kotlinSource[index + 2] !== '=';
302
+ });
303
+ };
304
+
305
+ export const hasAndroidJavaSourceCode = (source: string): boolean => {
306
+ return scanCodeLikeSource(source, ({ source: javaSource, index, current }) => {
307
+ if (!/[a-zA-Z_]/.test(current)) {
308
+ return false;
309
+ }
310
+
311
+ const tail = javaSource.slice(index, index + 32);
312
+ return /^(package|import|class|interface|enum|record)\b/.test(tail);
313
+ });
314
+ };
315
+
316
+ export const hasAndroidAsyncTaskUsage = (source: string): boolean => {
317
+ return scanCodeLikeSource(source, ({ source: androidSource, index, current }) => {
318
+ if (current !== 'A') {
319
+ return false;
320
+ }
321
+
322
+ return hasIdentifierAt(androidSource, index, 'AsyncTask');
323
+ });
324
+ };
325
+
326
+ export const hasAndroidFindViewByIdUsage = (source: string): boolean => {
327
+ return scanCodeLikeSource(source, ({ source: androidSource, index, current }) => {
328
+ if (current !== 'f') {
329
+ return false;
330
+ }
331
+
332
+ return hasIdentifierAt(androidSource, index, 'findViewById');
333
+ });
334
+ };
335
+
336
+ export const hasAndroidRxJavaUsage = (source: string): boolean => {
337
+ return scanCodeLikeSource(source, ({ source: androidSource, index, current }) => {
338
+ if (current === 'i' && androidSource.startsWith('io.reactivex', index)) {
339
+ return true;
340
+ }
341
+
342
+ if (current === 'r' && androidSource.startsWith('rx.', index)) {
343
+ return true;
344
+ }
345
+
346
+ const rxJavaIdentifiers = [
347
+ 'Observable',
348
+ 'Flowable',
349
+ 'Single',
350
+ 'Maybe',
351
+ 'Completable',
352
+ 'Disposable',
353
+ 'CompositeDisposable',
354
+ 'Subject',
355
+ 'PublishSubject',
356
+ 'BehaviorSubject',
357
+ 'ReplaySubject',
358
+ 'ConnectableObservable',
359
+ ];
360
+
361
+ return rxJavaIdentifiers.some((identifier) => {
362
+ return current === identifier[0] && hasIdentifierAt(androidSource, index, identifier);
363
+ });
364
+ });
365
+ };
366
+
367
+ export const hasAndroidDispatcherUsage = (source: string): boolean => {
368
+ return scanCodeLikeSource(source, ({ source: androidSource, index, current }) => {
369
+ if (current !== 'D' || !hasIdentifierAt(androidSource, index, 'Dispatchers')) {
370
+ return false;
371
+ }
372
+
373
+ const start = index + 'Dispatchers'.length;
374
+ const tail = androidSource.slice(start, start + 32);
375
+ return /^\s*\.(Main|IO|Default)\b/.test(tail);
376
+ });
377
+ };
378
+
379
+ export const hasAndroidWithContextUsage = (source: string): boolean => {
380
+ return scanCodeLikeSource(source, ({ source: androidSource, index, current }) => {
381
+ if (current !== 'w' || !hasIdentifierAt(androidSource, index, 'withContext')) {
382
+ return false;
383
+ }
384
+
385
+ const start = index + 'withContext'.length;
386
+ const tail = androidSource.slice(start, start + 48);
387
+ return /^\s*(<[^>\n]+>\s*)?\(/.test(tail);
388
+ });
389
+ };
390
+
391
+ export const hasAndroidCoroutineTryCatchUsage = (source: string): boolean => {
392
+ const hasCoroutineContextHint = scanCodeLikeSource(source, ({ source: androidSource, index, current }) => {
393
+ if (current === 'w' && hasIdentifierAt(androidSource, index, 'withContext')) {
394
+ return true;
395
+ }
396
+
397
+ if (current === 'D' && hasIdentifierAt(androidSource, index, 'Dispatchers')) {
398
+ return true;
399
+ }
400
+
401
+ if (current === 'v' && hasIdentifierAt(androidSource, index, 'viewModelScope')) {
402
+ return true;
403
+ }
404
+
405
+ if (current === 'l' && hasIdentifierAt(androidSource, index, 'lifecycleScope')) {
406
+ return true;
407
+ }
408
+
409
+ if (current === 'c' && hasIdentifierAt(androidSource, index, 'coroutineScope')) {
410
+ return true;
411
+ }
412
+
413
+ if (current === 's' && hasIdentifierAt(androidSource, index, 'supervisorScope')) {
414
+ return true;
415
+ }
416
+
417
+ if (current === 'l' && hasIdentifierAt(androidSource, index, 'launch')) {
418
+ return true;
419
+ }
420
+
421
+ if (current === 'a' && hasIdentifierAt(androidSource, index, 'async')) {
422
+ return true;
423
+ }
424
+
425
+ if (current === 's' && hasIdentifierAt(androidSource, index, 'suspend')) {
426
+ const tail = androidSource.slice(index + 'suspend'.length, index + 'suspend'.length + 16);
427
+ return /^\s+fun\b/.test(tail);
428
+ }
429
+
430
+ return false;
431
+ });
432
+
433
+ if (!hasCoroutineContextHint) {
434
+ return false;
435
+ }
436
+
437
+ return scanCodeLikeSource(source, ({ source: androidSource, index, current }) => {
438
+ if (current !== 't' || !hasIdentifierAt(androidSource, index, 'try')) {
439
+ return false;
440
+ }
441
+
442
+ const tail = androidSource.slice(index + 'try'.length, index + 'try'.length + 192);
443
+ return /\bcatch\b/.test(tail);
444
+ });
445
+ };
446
+
447
+ const isAndroidDebugLogGuardWindow = (lines: readonly string[], lineIndex: number): boolean => {
448
+ const guardPattern = /\bif\s*\(\s*BuildConfig\.DEBUG\s*\)/;
449
+ const windowStart = Math.max(0, lineIndex - 2);
450
+ const window = lines
451
+ .slice(windowStart, lineIndex + 1)
452
+ .map((line) => stripKotlinLineForSemanticScan(line ?? ''))
453
+ .join(' ');
454
+ return guardPattern.test(window);
455
+ };
456
+
457
+ const hasAndroidLogCallInLine = (line: string): boolean => {
458
+ return /\b(?:Timber|Log)\s*\.\s*(?:v|d|i|w|e)\s*\(/.test(line);
459
+ };
460
+
461
+ export const hasAndroidNoConsoleLogUsage = (source: string): boolean => {
462
+ const lines = source.split(/\r?\n/);
463
+ for (let index = 0; index < lines.length; index += 1) {
464
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? '');
465
+ if (sanitizedLine.trimStart().startsWith('import ')) {
466
+ continue;
467
+ }
468
+ if (!hasAndroidLogCallInLine(sanitizedLine)) {
469
+ continue;
470
+ }
471
+ if (isAndroidDebugLogGuardWindow(lines, index)) {
472
+ continue;
473
+ }
474
+ return true;
475
+ }
476
+
477
+ return false;
478
+ };
479
+
480
+ export const hasAndroidHardcodedStringUsage = (source: string): boolean => {
481
+ let inLineComment = false;
482
+ let inBlockComment = 0;
483
+ let inString = false;
484
+ let inMultilineString = false;
485
+ let stringStartIndex = -1;
486
+ let lastNonWhitespace = '';
487
+
488
+ for (let index = 0; index < source.length; index += 1) {
489
+ const current = source[index];
490
+ const next = source[index + 1];
491
+ const nextTwo = source[index + 2];
492
+
493
+ if (inLineComment) {
494
+ if (current === '\n') {
495
+ inLineComment = false;
496
+ }
497
+ continue;
498
+ }
499
+
500
+ if (inBlockComment > 0) {
501
+ if (current === '/' && next === '*') {
502
+ inBlockComment += 1;
503
+ index += 1;
504
+ continue;
505
+ }
506
+ if (current === '*' && next === '/') {
507
+ inBlockComment -= 1;
508
+ index += 1;
509
+ continue;
510
+ }
511
+ continue;
512
+ }
513
+
514
+ if (inMultilineString) {
515
+ if (current === '"' && next === '"' && nextTwo === '"') {
516
+ return true;
517
+ }
518
+ continue;
519
+ }
520
+
521
+ if (inString) {
522
+ if (current === '\\') {
523
+ index += 1;
524
+ continue;
525
+ }
526
+ if (current === '"') {
527
+ const previousChar = lastNonWhitespace;
528
+ const isInterpolationOrResource = previousChar === '.' || previousChar === ')';
529
+ if (!isInterpolationOrResource) {
530
+ return true;
531
+ }
532
+ inString = false;
533
+ stringStartIndex = -1;
534
+ }
535
+ continue;
536
+ }
537
+
538
+ if (current === '/' && next === '/') {
539
+ inLineComment = true;
540
+ index += 1;
541
+ continue;
542
+ }
543
+
544
+ if (current === '/' && next === '*') {
545
+ inBlockComment = 1;
546
+ index += 1;
547
+ continue;
548
+ }
549
+
550
+ if (current === '"' && next === '"' && nextTwo === '"') {
551
+ inMultilineString = true;
552
+ stringStartIndex = index;
553
+ return true;
554
+ }
555
+
556
+ if (current === '"') {
557
+ const tail = source.slice(Math.max(0, index - 24), index + 1);
558
+ if (/\bR\.(?:string|plurals|array)\s*\.\s*[A-Za-z_]/.test(tail)) {
559
+ inString = false;
560
+ stringStartIndex = -1;
561
+ continue;
562
+ }
563
+ inString = true;
564
+ stringStartIndex = index;
565
+ }
566
+
567
+ if (!/\s/.test(current)) {
568
+ lastNonWhitespace = current;
569
+ }
570
+ }
571
+
572
+ return false;
573
+ };
574
+
575
+ const hasAndroidSingletonModuleAnnotation = (window: string): boolean => {
576
+ return /@(Module|InstallIn|EntryPoint)\b/.test(window);
577
+ };
578
+
579
+ const hasAndroidSingletonCompanionPattern = (block: string): boolean => {
580
+ return (
581
+ /\b(?:getInstance|INSTANCE|instance)\b/.test(block) ||
582
+ /@Volatile\b/.test(block) ||
583
+ /\bprivate\s+(?:val|var)\s+(?:INSTANCE|instance)\b/.test(block)
584
+ );
585
+ };
586
+
587
+ export const hasAndroidSingletonUsage = (source: string): boolean => {
588
+ const lines = source.split(/\r?\n/);
589
+
590
+ for (let index = 0; index < lines.length; index += 1) {
591
+ const sanitizedLine = stripKotlinLineForSemanticScan(lines[index] ?? '');
592
+ if (sanitizedLine.trimStart().startsWith('import ')) {
593
+ continue;
594
+ }
595
+
596
+ const namedObjectMatch = sanitizedLine.match(
597
+ /^\s*(?:internal\s+|private\s+|public\s+)?(?:data\s+)?object\s+([A-Za-z_][A-Za-z0-9_]*)\b/
598
+ );
599
+ if (namedObjectMatch?.[1]) {
600
+ const annotationWindow = lines
601
+ .slice(Math.max(0, index - 3), index + 1)
602
+ .map((line) => stripKotlinLineForSemanticScan(line ?? ''))
603
+ .join(' ');
604
+ if (!hasAndroidSingletonModuleAnnotation(annotationWindow)) {
605
+ return true;
606
+ }
607
+ }
608
+
609
+ if (!/\bcompanion\s+object\b/.test(sanitizedLine)) {
610
+ continue;
611
+ }
612
+
613
+ let braceDepth =
614
+ countTokenOccurrences(sanitizedLine, '{') - countTokenOccurrences(sanitizedLine, '}');
615
+ const blockLines = [sanitizedLine];
616
+
617
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
618
+ const candidateLine = stripKotlinLineForSemanticScan(lines[cursor] ?? '');
619
+ blockLines.push(candidateLine);
620
+ braceDepth += countTokenOccurrences(candidateLine, '{');
621
+ braceDepth -= countTokenOccurrences(candidateLine, '}');
622
+ if (braceDepth <= 0) {
623
+ break;
624
+ }
625
+ }
626
+
627
+ if (hasAndroidSingletonCompanionPattern(blockLines.join(' '))) {
628
+ return true;
629
+ }
630
+ }
631
+
632
+ return false;
633
+ };
634
+
295
635
  export const findKotlinPresentationSrpMatch = (
296
636
  source: string
297
637
  ): KotlinPresentationSrpMatch | undefined => {
@@ -91,6 +91,14 @@ const isAndroidKotlinPath = (path: string): boolean => {
91
91
  return (path.endsWith('.kt') || path.endsWith('.kts')) && path.startsWith('apps/android/');
92
92
  };
93
93
 
94
+ const isAndroidJavaPath = (path: string): boolean => {
95
+ return path.endsWith('.java') && path.startsWith('apps/android/');
96
+ };
97
+
98
+ const isAndroidSourcePath = (path: string): boolean => {
99
+ return isAndroidKotlinPath(path) || isAndroidJavaPath(path);
100
+ };
101
+
94
102
  const isAndroidPresentationPath = (path: string): boolean => {
95
103
  return isAndroidKotlinPath(path) && path.includes('/presentation/');
96
104
  };
@@ -146,6 +154,16 @@ const isKotlinTestPath = (path: string): boolean => {
146
154
  );
147
155
  };
148
156
 
157
+ const isJavaTestPath = (path: string): boolean => {
158
+ const normalized = path.toLowerCase();
159
+ return (
160
+ normalized.includes('/test/') ||
161
+ normalized.includes('/androidtest/') ||
162
+ normalized.endsWith('test.java') ||
163
+ normalized.endsWith('tests.java')
164
+ );
165
+ };
166
+
149
167
  const isExcludedProjectScanPath = (path: string): boolean => {
150
168
  const normalized = path.replace(/\\/g, '/').toLowerCase();
151
169
  return (
@@ -636,6 +654,17 @@ const textDetectorRegistry: ReadonlyArray<TextDetectorRegistryEntry> = [
636
654
  { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinThreadSleepCall, ruleId: 'heuristics.android.thread-sleep.ast', code: 'HEURISTICS_ANDROID_THREAD_SLEEP_AST', message: 'AST heuristic detected Thread.sleep usage in production Kotlin code.' },
637
655
  { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinGlobalScopeUsage, ruleId: 'heuristics.android.globalscope.ast', code: 'HEURISTICS_ANDROID_GLOBAL_SCOPE_AST', message: 'AST heuristic detected GlobalScope coroutine usage in production Kotlin code.' },
638
656
  { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinRunBlockingUsage, ruleId: 'heuristics.android.run-blocking.ast', code: 'HEURISTICS_ANDROID_RUN_BLOCKING_AST', message: 'AST heuristic detected runBlocking usage in production Kotlin code.' },
657
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasKotlinForceUnwrapUsage, ruleId: 'heuristics.android.force-unwrap.ast', code: 'HEURISTICS_ANDROID_FORCE_UNWRAP_AST', message: 'AST heuristic detected Kotlin force unwrap (!!) usage in production code.' },
658
+ { platform: 'android', pathCheck: isAndroidJavaPath, excludePaths: [isJavaTestPath], detect: TextAndroid.hasAndroidJavaSourceCode, ruleId: 'heuristics.android.java-source.ast', code: 'HEURISTICS_ANDROID_JAVA_SOURCE_AST', message: 'AST heuristic detected Java source in Android production code where Kotlin is required for new code.' },
659
+ { platform: 'android', pathCheck: isAndroidSourcePath, excludePaths: [isKotlinTestPath, isJavaTestPath], detect: TextAndroid.hasAndroidAsyncTaskUsage, ruleId: 'heuristics.android.asynctask-deprecated.ast', code: 'HEURISTICS_ANDROID_ASYNCTASK_DEPRECATED_AST', message: 'AST heuristic detected AsyncTask usage in Android production code where Coroutines are required.' },
660
+ { platform: 'android', pathCheck: isAndroidSourcePath, excludePaths: [isKotlinTestPath, isJavaTestPath], detect: TextAndroid.hasAndroidFindViewByIdUsage, ruleId: 'heuristics.android.findviewbyid.ast', code: 'HEURISTICS_ANDROID_FINDVIEWBYID_AST', message: 'AST heuristic detected findViewById usage in Android production code where View Binding or Compose is required.' },
661
+ { platform: 'android', pathCheck: isAndroidSourcePath, excludePaths: [isKotlinTestPath, isJavaTestPath], detect: TextAndroid.hasAndroidRxJavaUsage, ruleId: 'heuristics.android.rxjava-new-code.ast', code: 'HEURISTICS_ANDROID_RXJAVA_NEW_CODE_AST', message: 'AST heuristic detected RxJava usage in Android production code where Flow is required for new code.' },
662
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasAndroidDispatcherUsage, ruleId: 'heuristics.android.dispatchers-main-ui-io-network-disk-default-cpu.ast', code: 'HEURISTICS_ANDROID_DISPATCHERS_MAIN_UI_IO_NETWORK_DISK_DEFAULT_CPU_AST', message: 'AST heuristic detected explicit Dispatchers.Main/IO/Default usage in Android production code where dispatcher selection must remain intentional.' },
663
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasAndroidWithContextUsage, ruleId: 'heuristics.android.withcontext-change-dispatcher.ast', code: 'HEURISTICS_ANDROID_WITHCONTEXT_CHANGE_DISPATCHER_AST', message: 'AST heuristic detected withContext usage in Android production code where dispatcher switching is intentional.' },
664
+ { platform: 'android', pathCheck: isAndroidSourcePath, excludePaths: [isKotlinTestPath, isJavaTestPath], detect: TextAndroid.hasAndroidNoConsoleLogUsage, ruleId: 'heuristics.android.no-console-log.ast', code: 'HEURISTICS_ANDROID_NO_CONSOLE_LOG_AST', message: 'AST heuristic detected Android logging usage in production code without a debug-only guard.' },
665
+ { platform: 'android', pathCheck: isAndroidSourcePath, excludePaths: [isKotlinTestPath, isJavaTestPath], detect: TextAndroid.hasAndroidHardcodedStringUsage, ruleId: 'heuristics.android.hardcoded-strings.ast', code: 'HEURISTICS_ANDROID_HARDCODED_STRINGS_AST', message: 'AST heuristic detected hardcoded string literal usage in Android production code where strings.xml should be used.' },
666
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasAndroidSingletonUsage, ruleId: 'heuristics.android.no-singleton.ast', code: 'HEURISTICS_ANDROID_NO_SINGLETON_AST', message: 'AST heuristic detected Kotlin singleton object or companion singleton holder usage in Android production code where Hilt or Dagger DI should be used.' },
667
+ { platform: 'android', pathCheck: isAndroidKotlinPath, excludePaths: [isKotlinTestPath], detect: TextAndroid.hasAndroidCoroutineTryCatchUsage, ruleId: 'heuristics.android.try-catch-manejo-de-errores-en-coroutines.ast', code: 'HEURISTICS_ANDROID_TRY_CATCH_MANEJO_DE_ERRORES_EN_COROUTINES_AST', message: 'AST heuristic detected try/catch usage in Android coroutine code where error handling must remain explicit.' },
639
668
  ];
640
669
 
641
670
  const extractWorkflowHeuristicFacts = (