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