@dailephd/my-dev-kit-lab 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +46 -269
  2. package/dist/scripts/security/validate.js +177 -70
  3. package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +5 -2
  4. package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +5 -2
  5. package/dist/src/audits/core/collectSourceFacts.js +21 -10
  6. package/dist/src/mobile/android/audit/androidFinding.js +68 -0
  7. package/dist/src/mobile/android/audit/checkResultBuilder.js +97 -0
  8. package/dist/src/mobile/android/audit/deepLinkAudit.js +183 -0
  9. package/dist/src/mobile/android/audit/exportedComponentAudit.js +183 -0
  10. package/dist/src/mobile/android/audit/intentFilterAudit.js +128 -0
  11. package/dist/src/mobile/android/audit/permissionAudit.js +142 -0
  12. package/dist/src/mobile/android/detect/androidTargetDetection.js +18 -0
  13. package/dist/src/mobile/android/detect/buildFileEvidence.js +36 -0
  14. package/dist/src/mobile/android/detect/classify.js +150 -0
  15. package/dist/src/mobile/android/detect/detectAndroidProject.js +252 -0
  16. package/dist/src/mobile/android/detect/settingsModules.js +60 -0
  17. package/dist/src/mobile/android/detect/sourceEvidence.js +92 -0
  18. package/dist/src/mobile/android/detect/traversal.js +79 -0
  19. package/dist/src/mobile/android/detect/versionCatalogEvidence.js +37 -0
  20. package/dist/src/mobile/android/detection.js +28 -0
  21. package/dist/src/mobile/android/gradle/gradleMetadataCheckResult.js +120 -0
  22. package/dist/src/mobile/android/gradle/moduleMetadataExtractor.js +137 -0
  23. package/dist/src/mobile/android/gradle/pluginVersionExtractor.js +40 -0
  24. package/dist/src/mobile/android/gradle/readAndroidGradleMetadata.js +210 -0
  25. package/dist/src/mobile/android/gradle/releaseMetadataSummary.js +64 -0
  26. package/dist/src/mobile/android/gradle/settingsMetadata.js +20 -0
  27. package/dist/src/mobile/android/gradle/types.js +1 -0
  28. package/dist/src/mobile/android/gradle/validate/executor.js +9 -0
  29. package/dist/src/mobile/android/gradle/validate/operationCheckResult.js +99 -0
  30. package/dist/src/mobile/android/gradle/validate/operations.js +68 -0
  31. package/dist/src/mobile/android/gradle/validate/planner.js +49 -0
  32. package/dist/src/mobile/android/gradle/validate/runOptionalGradleValidation.js +113 -0
  33. package/dist/src/mobile/android/gradle/validate/targetMutation.js +45 -0
  34. package/dist/src/mobile/android/gradle/validate/taskListParser.js +24 -0
  35. package/dist/src/mobile/android/gradle/versionCatalogMetadata.js +94 -0
  36. package/dist/src/mobile/android/gradle/wrapperMetadata.js +32 -0
  37. package/dist/src/mobile/android/manifest/parseAndroidManifest.js +341 -0
  38. package/dist/src/mobile/android/manifest/types.js +29 -0
  39. package/dist/src/mobile/android/manifest/xml/parseXml.js +313 -0
  40. package/dist/src/mobile/android/profile.js +29 -0
  41. package/dist/src/mobile/android/report/model.js +104 -0
  42. package/dist/src/mobile/android/report/renderAndroidReport.js +223 -0
  43. package/dist/src/mobile/android/report/writeAndroidReportFiles.js +23 -0
  44. package/dist/src/mobile/android/targetMetadata.js +27 -0
  45. package/dist/src/mobile/android/validate/androidVerdict.js +248 -0
  46. package/dist/src/mobile/android/validate/detectionAndImmutabilityCheckResults.js +177 -0
  47. package/dist/src/mobile/android/validate/playReadinessChecklist.js +145 -0
  48. package/dist/src/mobile/android/validate/validateAndroidTarget.js +172 -0
  49. package/dist/src/mobile/android/validation/checkResult.js +51 -0
  50. package/dist/src/mobile/android/validation/result.js +25 -0
  51. package/dist/src/mobile/index.js +42 -0
  52. package/dist/src/mobile/types.js +27 -0
  53. package/dist/src/securityValidation/attackScenarios/attackProfile.js +2 -0
  54. package/dist/src/securityValidation/attackScenarios/profiles/androidProfile.js +20 -0
  55. package/dist/src/securityValidation/validate/cliOptions.js +46 -2
  56. package/package.json +2 -1
  57. package/tests/fixtures/android/compose-app/app/build.gradle.kts +21 -0
  58. package/tests/fixtures/android/compose-app/app/src/main/AndroidManifest.xml +22 -0
  59. package/tests/fixtures/android/compose-app/app/src/main/java/com/example/composeapp/MainActivity.kt +11 -0
  60. package/tests/fixtures/android/compose-app/build.gradle.kts +4 -0
  61. package/tests/fixtures/android/compose-app/gradle/wrapper/gradle-wrapper.properties +4 -0
  62. package/tests/fixtures/android/compose-app/settings.gradle.kts +2 -0
  63. package/tests/fixtures/android/gradle-metadata-fixtures/catalog-only-plugin/build.gradle.kts +3 -0
  64. package/tests/fixtures/android/gradle-metadata-fixtures/catalog-only-plugin/gradle/libs.versions.toml +5 -0
  65. package/tests/fixtures/android/gradle-metadata-fixtures/comment-safety.gradle.kts +21 -0
  66. package/tests/fixtures/android/gradle-metadata-fixtures/dynamic-values.gradle.kts +17 -0
  67. package/tests/fixtures/android/gradle-metadata-fixtures/groovy-literal.gradle +23 -0
  68. package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-malformed.properties +4 -0
  69. package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-missing-url.properties +3 -0
  70. package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-with-checksum.properties +5 -0
  71. package/tests/fixtures/android/java-app/app/build.gradle +16 -0
  72. package/tests/fixtures/android/java-app/app/src/main/AndroidManifest.xml +19 -0
  73. package/tests/fixtures/android/java-app/app/src/main/java/com/example/javaapp/MainActivity.java +4 -0
  74. package/tests/fixtures/android/java-app/build.gradle +3 -0
  75. package/tests/fixtures/android/java-app/gradle/wrapper/gradle-wrapper.properties +4 -0
  76. package/tests/fixtures/android/java-app/settings.gradle +2 -0
  77. package/tests/fixtures/android/library/build.gradle.kts +3 -0
  78. package/tests/fixtures/android/library/gradle/wrapper/gradle-wrapper.properties +4 -0
  79. package/tests/fixtures/android/library/mylibrary/build.gradle.kts +13 -0
  80. package/tests/fixtures/android/library/mylibrary/src/main/AndroidManifest.xml +2 -0
  81. package/tests/fixtures/android/library/settings.gradle.kts +2 -0
  82. package/tests/fixtures/android/manifest-audit-fixtures/incomplete-intent-filter.xml +14 -0
  83. package/tests/fixtures/android/manifest-audit-fixtures/malformed-exported.xml +10 -0
  84. package/tests/fixtures/android/manifest-audit-fixtures/malformed.xml +5 -0
  85. package/tests/fixtures/android/manifest-audit-fixtures/missing-application.xml +7 -0
  86. package/tests/fixtures/android/manifest-audit-fixtures/missing-component-name.xml +11 -0
  87. package/tests/fixtures/android/manifest-audit-fixtures/permission-coverage.xml +29 -0
  88. package/tests/fixtures/android/manifest-audit-fixtures/risky-components.xml +62 -0
  89. package/tests/fixtures/android/manifest-audit-fixtures/risky-deep-links.xml +45 -0
  90. package/tests/fixtures/android/manifest-audit-fixtures/unresolved-placeholder.xml +14 -0
  91. package/tests/fixtures/android/manifest-audit-fixtures/unusual-namespace-prefix.xml +11 -0
  92. package/tests/fixtures/android/multi-module/app/build.gradle.kts +22 -0
  93. package/tests/fixtures/android/multi-module/app/src/androidTest/java/com/example/multimodule/app/ExampleInstrumentedTest.kt +3 -0
  94. package/tests/fixtures/android/multi-module/app/src/main/AndroidManifest.xml +11 -0
  95. package/tests/fixtures/android/multi-module/app/src/test/java/com/example/multimodule/app/ExampleUnitTest.kt +3 -0
  96. package/tests/fixtures/android/multi-module/build.gradle.kts +5 -0
  97. package/tests/fixtures/android/multi-module/core/build.gradle.kts +14 -0
  98. package/tests/fixtures/android/multi-module/core/src/main/AndroidManifest.xml +2 -0
  99. package/tests/fixtures/android/multi-module/feature-login/build.gradle.kts +18 -0
  100. package/tests/fixtures/android/multi-module/feature-login/src/main/AndroidManifest.xml +2 -0
  101. package/tests/fixtures/android/multi-module/gradle/libs.versions.toml +8 -0
  102. package/tests/fixtures/android/multi-module/gradle/wrapper/gradle-wrapper.properties +4 -0
  103. package/tests/fixtures/android/multi-module/settings.gradle.kts +4 -0
  104. package/tests/fixtures/android/non-android-gradle/build.gradle.kts +3 -0
  105. package/tests/fixtures/android/non-android-gradle/core/build.gradle.kts +3 -0
  106. package/tests/fixtures/android/non-android-gradle/core/src/main/java/com/example/core/Core.java +4 -0
  107. package/tests/fixtures/android/non-android-gradle/gradle/wrapper/gradle-wrapper.properties +4 -0
  108. package/tests/fixtures/android/non-android-gradle/settings.gradle.kts +2 -0
  109. package/tests/fixtures/android/partial/app/build.gradle.kts +7 -0
  110. package/tests/fixtures/android/partial/settings.gradle.kts +2 -0
  111. package/tests/fixtures/android/xml-view-app/app/build.gradle.kts +17 -0
  112. package/tests/fixtures/android/xml-view-app/app/src/main/AndroidManifest.xml +31 -0
  113. package/tests/fixtures/android/xml-view-app/app/src/main/kotlin/com/example/xmlviewapp/MainActivity.kt +7 -0
  114. package/tests/fixtures/android/xml-view-app/app/src/main/res/layout/activity_main.xml +4 -0
  115. package/tests/fixtures/android/xml-view-app/build.gradle.kts +4 -0
  116. package/tests/fixtures/android/xml-view-app/gradle/wrapper/gradle-wrapper.properties +4 -0
  117. package/tests/fixtures/android/xml-view-app/settings.gradle.kts +2 -0
  118. package/tests/fixtures/audits/mixed-language/mixed-docs-claims/README.md +16 -0
  119. package/tests/fixtures/audits/mixed-language/mixed-docs-claims/build.gradle.kts +4 -0
  120. package/tests/fixtures/audits/mixed-language/mixed-docs-claims/src/main/java/com/example/Widget.java +7 -0
  121. package/tests/fixtures/audits/mixed-language/mixed-docs-claims/src/main/kotlin/com/example/Gadget.kt +7 -0
  122. package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/README.md +10 -0
  123. package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/build/Main.class +1 -0
  124. package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/dist/index.js +1 -0
  125. package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/generated/schema.ts +1 -0
  126. package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/src/index.ts +3 -0
  127. package/tests/fixtures/audits/mixed-language/mixed-generated-vendor/vendor/lib.py +2 -0
  128. package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/README.md +10 -0
  129. package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/build.gradle.kts +8 -0
  130. package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/settings.gradle.kts +1 -0
  131. package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/src/main/java/com/example/FooService.java +7 -0
  132. package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/src/main/kotlin/com/example/BarService.kt +7 -0
  133. package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/src/test/java/com/example/FooServiceTest.java +8 -0
  134. package/tests/fixtures/audits/mixed-language/mixed-java-kotlin/src/test/kotlin/com/example/BarServiceTest.kt +8 -0
  135. package/tests/fixtures/audits/mixed-language/mixed-python-ts/README.md +8 -0
  136. package/tests/fixtures/audits/mixed-language/mixed-python-ts/src/app.py +7 -0
  137. package/tests/fixtures/audits/mixed-language/mixed-python-ts/src/index.ts +3 -0
  138. package/tests/fixtures/audits/mixed-language/mixed-python-ts/tests/test_app.py +5 -0
  139. package/tests/fixtures/audits/mixed-language/mixed-ts-js/README.md +7 -0
  140. package/tests/fixtures/audits/mixed-language/mixed-ts-js/src/helper.js +5 -0
  141. package/tests/fixtures/audits/mixed-language/mixed-ts-js/src/index.ts +3 -0
  142. package/tests/fixtures/audits/mixed-language/mixed-ts-js/tests/index.sample.ts +5 -0
  143. package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/README.md +10 -0
  144. package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/build.gradle.kts +4 -0
  145. package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/pytests/test_app.py +5 -0
  146. package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/src/app.py +2 -0
  147. package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/src/helper.js +5 -0
  148. package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/src/index.ts +3 -0
  149. package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/src/main/java/com/example/Multiplier.java +7 -0
  150. package/tests/fixtures/audits/mixed-language/mixed-ts-python-jvm/src/main/kotlin/com/example/Divider.kt +7 -0
@@ -0,0 +1,150 @@
1
+ // ---------------------------------------------------------------------------
2
+ // v0.4.0 Batch 2 — deterministic, evidence-based classification policy.
3
+ //
4
+ // Pure functions only (no filesystem access) so the confidence/classification
5
+ // policy can be unit tested independently of traversal. See agents.txt Batch
6
+ // 2 section 7.4 for the evidence-strength tiers this implements:
7
+ // - Strong: an Android plugin id, or namespace/applicationId combined with
8
+ // an Android plugin, or a manifest inside an Android-style source set.
9
+ // - Supporting: source roots, res/, androidTest, wrapper, settings files,
10
+ // catalog aliases, Compose dependencies.
11
+ // - Weak: generic Gradle/Kotlin/Java files or directory names alone.
12
+ // A project must never be confirmed Android from weak evidence alone.
13
+ // ---------------------------------------------------------------------------
14
+ const CONFIDENCE_RANK = { unknown: 0, low: 1, medium: 2, high: 3 };
15
+ export function maxConfidence(a, b) {
16
+ return CONFIDENCE_RANK[a] >= CONFIDENCE_RANK[b] ? a : b;
17
+ }
18
+ function capConfidence(confidence, cap) {
19
+ return CONFIDENCE_RANK[confidence] <= CONFIDENCE_RANK[cap] ? confidence : cap;
20
+ }
21
+ // Classifies a single Gradle module from its collected static evidence.
22
+ // Directory naming (e.g. a module literally called "app") is never used as a
23
+ // signal — only plugin declarations, manifest presence, and Android-specific
24
+ // identifiers.
25
+ export function classifyModule(input) {
26
+ const evidence = [];
27
+ if (input.hasAndroidApplicationPlugin) {
28
+ evidence.push("com.android.application plugin declared");
29
+ const strongCombo = input.hasManifest || Boolean(input.namespace) || Boolean(input.applicationId);
30
+ if (input.hasManifest)
31
+ evidence.push("AndroidManifest.xml present in module source set");
32
+ if (input.namespace)
33
+ evidence.push(`namespace = "${input.namespace}"`);
34
+ if (input.applicationId)
35
+ evidence.push(`applicationId = "${input.applicationId}"`);
36
+ return { kind: "application", confidence: strongCombo ? "high" : "medium", evidence };
37
+ }
38
+ if (input.hasAndroidLibraryPlugin) {
39
+ evidence.push("com.android.library plugin declared");
40
+ const strongCombo = input.hasManifest || Boolean(input.namespace);
41
+ if (input.hasManifest)
42
+ evidence.push("AndroidManifest.xml present in module source set");
43
+ if (input.namespace)
44
+ evidence.push(`namespace = "${input.namespace}"`);
45
+ return { kind: "library", confidence: strongCombo ? "high" : "medium", evidence };
46
+ }
47
+ if (input.hasCatalogAndroidPluginAliasEvidence) {
48
+ evidence.push("version-catalog plugin alias referencing an Android Gradle Plugin entry");
49
+ return { kind: "unknown", confidence: "medium", evidence };
50
+ }
51
+ // Generic Java/Kotlin source roots are deliberately excluded from this
52
+ // list: they exist in any JVM project and are not Android-specific, so
53
+ // they must never by themselves push a module toward Android detection
54
+ // (agents.txt Batch 2 section 12.4 — generic Gradle/Kotlin/Java evidence
55
+ // must not trigger Android detection without Android-specific evidence).
56
+ const weakSignals = [];
57
+ if (input.hasManifest)
58
+ weakSignals.push("AndroidManifest.xml present without a resolved Android plugin");
59
+ if (input.namespace)
60
+ weakSignals.push(`namespace = "${input.namespace}" without a resolved Android plugin`);
61
+ if (input.applicationId)
62
+ weakSignals.push(`applicationId = "${input.applicationId}" without a resolved Android plugin`);
63
+ if (input.hasResDir)
64
+ weakSignals.push("res/ directory present");
65
+ if (weakSignals.length > 0) {
66
+ evidence.push(...weakSignals);
67
+ return { kind: "unknown", confidence: "low", evidence };
68
+ }
69
+ if (input.hasGenericBuildFile) {
70
+ evidence.push("Gradle build file present with no Android-specific evidence");
71
+ return { kind: "non-android", confidence: "medium", evidence };
72
+ }
73
+ return { kind: "unknown", confidence: "unknown", evidence: [] };
74
+ }
75
+ // Classifies UI toolkit usage for a single module. Conservative by design:
76
+ // Kotlin usage alone never implies Compose, and res/ alone never implies
77
+ // XML/View classification beyond what's asserted here (a literal layout
78
+ // resource file, not merely a res/ directory).
79
+ export function classifyModuleUiToolkit(input) {
80
+ const evidence = [];
81
+ const strongCompose = input.composeBuildFeatureEvidence || input.composeDependencyEvidence || input.composeSourceEvidence;
82
+ const strongView = input.hasResLayout;
83
+ if (input.composeBuildFeatureEvidence)
84
+ evidence.push("buildFeatures { compose = true }");
85
+ if (input.composeDependencyEvidence)
86
+ evidence.push("Compose dependency evidence in build file");
87
+ if (input.composeSourceEvidence)
88
+ evidence.push("@Composable/androidx.compose import evidence in source");
89
+ if (input.composePluginEvidence)
90
+ evidence.push("Compose compiler plugin evidence");
91
+ if (input.hasResLayout)
92
+ evidence.push("res/layout XML resource present");
93
+ if (input.viewSourceEvidence)
94
+ evidence.push("traditional Android View usage evidence in source");
95
+ if (strongCompose && strongView) {
96
+ return { uiToolkit: "mixed", evidence };
97
+ }
98
+ if (strongCompose) {
99
+ return { uiToolkit: "compose", evidence };
100
+ }
101
+ if (strongView) {
102
+ return { uiToolkit: "xml-view", evidence };
103
+ }
104
+ return { uiToolkit: "uncertain", evidence };
105
+ }
106
+ // Combines per-module kinds into the overall project-level classification.
107
+ // Uses the widened ANDROID_PROJECT_KINDS vocabulary (see detection.ts) — a
108
+ // project with both application and library modules is "mixed"; more than
109
+ // one Android module of the same kind is "multi-module"; a single confirmed
110
+ // module keeps its own kind; Android-ish-but-unresolved evidence with no
111
+ // confirmed module is "partial"; nothing at all is "non-android".
112
+ export function combineProjectKind(modules, hasUnresolvedAndroidEvidence) {
113
+ const applicationCount = modules.filter((module) => module.kind === "application").length;
114
+ const libraryCount = modules.filter((module) => module.kind === "library").length;
115
+ const unknownCount = modules.filter((module) => module.kind === "unknown").length;
116
+ if (applicationCount >= 1 && libraryCount >= 1)
117
+ return "mixed";
118
+ if (applicationCount + libraryCount > 1)
119
+ return "multi-module";
120
+ if (applicationCount === 1)
121
+ return "application";
122
+ if (libraryCount === 1)
123
+ return "library";
124
+ if (unknownCount > 0 || hasUnresolvedAndroidEvidence)
125
+ return "partial";
126
+ return "non-android";
127
+ }
128
+ export function combineProjectUiToolkit(modules) {
129
+ const androidModules = modules.filter((module) => module.kind === "application" || module.kind === "library");
130
+ const composeCount = androidModules.filter((module) => module.uiToolkit === "compose").length;
131
+ const viewCount = androidModules.filter((module) => module.uiToolkit === "xml-view").length;
132
+ const mixedCount = androidModules.filter((module) => module.uiToolkit === "mixed").length;
133
+ if (mixedCount > 0 || (composeCount > 0 && viewCount > 0))
134
+ return "mixed";
135
+ if (composeCount > 0)
136
+ return "compose";
137
+ if (viewCount > 0)
138
+ return "xml-view";
139
+ return "uncertain";
140
+ }
141
+ export function combineProjectConfidence(projectKind, moduleConfidences, hasNonAndroidEvidenceOnly) {
142
+ if (projectKind === "non-android") {
143
+ return hasNonAndroidEvidenceOnly ? "high" : "unknown";
144
+ }
145
+ const strongest = moduleConfidences.reduce((acc, confidence) => maxConfidence(acc, confidence), "unknown");
146
+ if (projectKind === "partial") {
147
+ return capConfidence(strongest, "medium");
148
+ }
149
+ return strongest;
150
+ }
@@ -0,0 +1,252 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { extractBuildFileEvidence } from "./buildFileEvidence.js";
4
+ import { parseDeclaredModules } from "./settingsModules.js";
5
+ import { extractVersionCatalogEvidence } from "./versionCatalogEvidence.js";
6
+ import { scanSourceRootsForUiEvidence } from "./sourceEvidence.js";
7
+ import { walkAndroidCandidateFiles } from "./traversal.js";
8
+ import { classifyModule, classifyModuleUiToolkit, combineProjectConfidence, combineProjectKind, combineProjectUiToolkit, } from "./classify.js";
9
+ // ---------------------------------------------------------------------------
10
+ // v0.4.0 Batch 2 — Android project detector entry point.
11
+ //
12
+ // Static, deterministic, read-only, network-free. Does not invoke Gradle, the
13
+ // Android SDK, or a Java toolchain. Populates the Batch 1
14
+ // AndroidDetectionResult contract only — manifest content parsing, full
15
+ // Gradle metadata, and verdict calculation are later batches' scope.
16
+ // ---------------------------------------------------------------------------
17
+ function dirOf(relativeFilePath) {
18
+ const dir = path.posix.dirname(relativeFilePath.replace(/\\/g, "/"));
19
+ return dir === "." ? "" : dir;
20
+ }
21
+ function baseNameOf(relativeFilePath) {
22
+ return path.posix.basename(relativeFilePath.replace(/\\/g, "/"));
23
+ }
24
+ function joinRelative(dir, name) {
25
+ return dir === "" ? name : `${dir}/${name}`;
26
+ }
27
+ function catalogAliasToAccessorSuffix(alias) {
28
+ return alias.replace(/-/g, ".");
29
+ }
30
+ function readTextSafe(absolutePath) {
31
+ try {
32
+ return readFileSync(absolutePath, "utf8");
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ export function detectAndroidProject(targetRoot) {
39
+ const resolvedRoot = path.resolve(targetRoot);
40
+ const { relativeFilePaths, skippedSymlinks } = walkAndroidCandidateFiles(resolvedRoot);
41
+ const fileSet = new Set(relativeFilePaths);
42
+ const warnings = [];
43
+ for (const skipped of skippedSymlinks) {
44
+ warnings.push(`Skipped directory symlink (not followed): ${skipped}`);
45
+ }
46
+ // ---- Root-level evidence -------------------------------------------------
47
+ const rootBuildFiles = relativeFilePaths
48
+ .filter((p) => dirOf(p) === "" && (baseNameOf(p) === "build.gradle" || baseNameOf(p) === "build.gradle.kts"))
49
+ .sort((a, b) => a.localeCompare(b));
50
+ const gradleSettingsFiles = relativeFilePaths
51
+ .filter((p) => dirOf(p) === "" && (baseNameOf(p) === "settings.gradle" || baseNameOf(p) === "settings.gradle.kts"))
52
+ .sort((a, b) => a.localeCompare(b));
53
+ if (gradleSettingsFiles.length > 1) {
54
+ warnings.push(`Multiple root settings files found (${gradleSettingsFiles.join(", ")}); using "${gradleSettingsFiles[0]}" as the primary evidence source.`);
55
+ }
56
+ const hasGradleWrapper = fileSet.has("gradlew") || fileSet.has("gradlew.bat") || fileSet.has("gradle/wrapper/gradle-wrapper.properties");
57
+ const versionCatalogFiles = relativeFilePaths.filter((p) => p === "gradle/libs.versions.toml");
58
+ const catalogEvidence = versionCatalogFiles.length > 0
59
+ ? extractVersionCatalogEvidence(readTextSafe(path.join(resolvedRoot, versionCatalogFiles[0])) ?? "")
60
+ : { androidPluginAliasEvidence: [], kotlinAndroidPluginAliasEvidence: [], composeAliasEvidence: [] };
61
+ // ---- Module discovery ------------------------------------------------------
62
+ const structuralModuleDirs = new Set();
63
+ for (const p of relativeFilePaths) {
64
+ const name = baseNameOf(p);
65
+ if (name === "build.gradle" || name === "build.gradle.kts") {
66
+ const dir = dirOf(p);
67
+ if (dir !== "")
68
+ structuralModuleDirs.add(dir);
69
+ }
70
+ }
71
+ let declaredModulePaths = [];
72
+ let unsupportedDeclarations = [];
73
+ if (gradleSettingsFiles.length > 0) {
74
+ const settingsText = readTextSafe(path.join(resolvedRoot, gradleSettingsFiles[0])) ?? "";
75
+ const parsed = parseDeclaredModules(settingsText);
76
+ declaredModulePaths = parsed.declaredModulePaths;
77
+ unsupportedDeclarations = parsed.unsupportedDeclarations;
78
+ }
79
+ for (const declaration of unsupportedDeclarations) {
80
+ warnings.push(`Unsupported or dynamic module declaration in settings file (preserved, not evaluated): ${declaration}`);
81
+ }
82
+ const declaredOnlyDirs = declaredModulePaths.filter((dir) => !structuralModuleDirs.has(dir));
83
+ for (const dir of declaredOnlyDirs) {
84
+ warnings.push(`Module "${dir}" is declared in the settings file but no build.gradle(.kts) was found for it.`);
85
+ }
86
+ const allModuleDirs = [...new Set([...structuralModuleDirs, ...declaredModulePaths])].sort((a, b) => a.localeCompare(b));
87
+ // ---- Per-module evidence and classification --------------------------------
88
+ const modules = [];
89
+ const javaSourceRoots = [];
90
+ const kotlinSourceRoots = [];
91
+ const unitTestSourceRoots = [];
92
+ const instrumentedTestSourceRoots = [];
93
+ const manifestPaths = [];
94
+ let androidGradlePluginEvidence;
95
+ let kotlinAndroidPluginEvidence;
96
+ let namespaceEvidence;
97
+ let applicationIdEvidence;
98
+ for (const moduleDir of allModuleDirs) {
99
+ const buildFileRelPath = [`${moduleDir}/build.gradle.kts`, `${moduleDir}/build.gradle`].find((p) => fileSet.has(p));
100
+ const buildFileText = buildFileRelPath ? readTextSafe(path.join(resolvedRoot, buildFileRelPath)) ?? "" : undefined;
101
+ const buildEvidence = buildFileText !== undefined ? extractBuildFileEvidence(buildFileText) : undefined;
102
+ const hasCatalogAliasEvidence = buildFileText !== undefined &&
103
+ [...catalogEvidence.androidPluginAliasEvidence, ...catalogEvidence.kotlinAndroidPluginAliasEvidence].some((alias) => buildFileText.includes(`libs.plugins.${catalogAliasToAccessorSuffix(alias)}`));
104
+ const manifestRelPath = `${moduleDir}/src/main/AndroidManifest.xml`;
105
+ const hasManifest = fileSet.has(manifestRelPath);
106
+ const moduleManifestPaths = hasManifest ? [manifestRelPath] : [];
107
+ const javaRootPrefix = `${moduleDir}/src/main/java/`;
108
+ const kotlinRootPrefix = `${moduleDir}/src/main/kotlin/`;
109
+ const unitTestPrefix = `${moduleDir}/src/test/`;
110
+ const instrumentedTestPrefix = `${moduleDir}/src/androidTest/`;
111
+ const resLayoutPrefix = `${moduleDir}/src/main/res/layout`;
112
+ // Language is classified by file extension found under each directory,
113
+ // not by directory name alone — Kotlin sources are conventionally placed
114
+ // under src/main/java in some older project layouts, so a "java"-named
115
+ // directory containing only .kt files is a Kotlin source root, not a
116
+ // Java one (agents.txt Batch 2 section 7.9).
117
+ const javaRootHasJavaFiles = relativeFilePaths.some((p) => p.startsWith(javaRootPrefix) && p.endsWith(".java"));
118
+ const javaRootHasKotlinFiles = relativeFilePaths.some((p) => p.startsWith(javaRootPrefix) && p.endsWith(".kt"));
119
+ const kotlinRootHasKotlinFiles = relativeFilePaths.some((p) => p.startsWith(kotlinRootPrefix) && p.endsWith(".kt"));
120
+ const kotlinRootHasJavaFiles = relativeFilePaths.some((p) => p.startsWith(kotlinRootPrefix) && p.endsWith(".java"));
121
+ const hasJavaRoot = javaRootHasJavaFiles || kotlinRootHasJavaFiles;
122
+ const hasKotlinRoot = javaRootHasKotlinFiles || kotlinRootHasKotlinFiles;
123
+ const hasUnitTestRoot = relativeFilePaths.some((p) => p.startsWith(unitTestPrefix));
124
+ const hasInstrumentedTestRoot = relativeFilePaths.some((p) => p.startsWith(instrumentedTestPrefix));
125
+ const hasResLayout = relativeFilePaths.some((p) => p.startsWith(resLayoutPrefix) && p.endsWith(".xml"));
126
+ if (javaRootHasJavaFiles)
127
+ javaSourceRoots.push(`${moduleDir}/src/main/java`);
128
+ if (kotlinRootHasJavaFiles)
129
+ javaSourceRoots.push(`${moduleDir}/src/main/kotlin`);
130
+ if (kotlinRootHasKotlinFiles)
131
+ kotlinSourceRoots.push(`${moduleDir}/src/main/kotlin`);
132
+ if (javaRootHasKotlinFiles)
133
+ kotlinSourceRoots.push(`${moduleDir}/src/main/java`);
134
+ if (hasUnitTestRoot)
135
+ unitTestSourceRoots.push(`${moduleDir}/src/test`);
136
+ if (hasInstrumentedTestRoot)
137
+ instrumentedTestSourceRoots.push(`${moduleDir}/src/androidTest`);
138
+ manifestPaths.push(...moduleManifestPaths);
139
+ const sourceUiEvidence = scanSourceRootsForUiEvidence([javaRootHasJavaFiles || javaRootHasKotlinFiles ? path.join(resolvedRoot, moduleDir, "src", "main", "java") : undefined,
140
+ kotlinRootHasKotlinFiles || kotlinRootHasJavaFiles ? path.join(resolvedRoot, moduleDir, "src", "main", "kotlin") : undefined]
141
+ .filter((p) => Boolean(p)));
142
+ const classification = classifyModule({
143
+ hasAndroidApplicationPlugin: buildEvidence?.androidApplicationPlugin ?? false,
144
+ hasAndroidLibraryPlugin: buildEvidence?.androidLibraryPlugin ?? false,
145
+ hasCatalogAndroidPluginAliasEvidence: hasCatalogAliasEvidence,
146
+ namespace: buildEvidence?.namespace,
147
+ applicationId: buildEvidence?.applicationId,
148
+ hasManifest,
149
+ hasSrcRoots: hasJavaRoot || hasKotlinRoot,
150
+ hasResDir: relativeFilePaths.some((p) => p.startsWith(`${moduleDir}/src/main/res/`)),
151
+ hasGenericBuildFile: buildFileText !== undefined,
152
+ });
153
+ const uiClassification = classifyModuleUiToolkit({
154
+ composeBuildFeatureEvidence: buildEvidence?.composeBuildFeatureEvidence ?? false,
155
+ composeDependencyEvidence: buildEvidence?.composeDependencyEvidence ?? false,
156
+ composePluginEvidence: buildEvidence?.composePluginEvidence ?? false,
157
+ composeSourceEvidence: sourceUiEvidence.composeSourceEvidence,
158
+ hasResLayout,
159
+ viewSourceEvidence: sourceUiEvidence.viewSourceEvidence,
160
+ });
161
+ if (buildEvidence?.androidApplicationPlugin && !androidGradlePluginEvidence) {
162
+ androidGradlePluginEvidence = `com.android.application (${buildFileRelPath})`;
163
+ }
164
+ if (buildEvidence?.androidLibraryPlugin && !androidGradlePluginEvidence) {
165
+ androidGradlePluginEvidence = `com.android.library (${buildFileRelPath})`;
166
+ }
167
+ if (buildEvidence?.kotlinAndroidPlugin && !kotlinAndroidPluginEvidence) {
168
+ kotlinAndroidPluginEvidence = `org.jetbrains.kotlin.android (${buildFileRelPath})`;
169
+ }
170
+ if (buildEvidence?.namespace && !namespaceEvidence) {
171
+ namespaceEvidence = buildEvidence.namespace;
172
+ }
173
+ if (buildEvidence?.applicationId && !applicationIdEvidence) {
174
+ applicationIdEvidence = buildEvidence.applicationId;
175
+ }
176
+ modules.push({
177
+ path: moduleDir,
178
+ kind: classification.kind,
179
+ manifestPaths: moduleManifestPaths,
180
+ namespaceEvidence: buildEvidence?.namespace,
181
+ applicationIdEvidence: buildEvidence?.applicationId,
182
+ confidence: classification.confidence,
183
+ evidence: classification.evidence,
184
+ uiToolkit: uiClassification.uiToolkit,
185
+ });
186
+ }
187
+ // ---- Project-level aggregation ---------------------------------------------
188
+ const applicationModules = modules.filter((m) => m.kind === "application").map((m) => m.path).sort((a, b) => a.localeCompare(b));
189
+ const libraryModules = modules.filter((m) => m.kind === "library").map((m) => m.path).sort((a, b) => a.localeCompare(b));
190
+ const hasUnresolvedAndroidEvidence = declaredOnlyDirs.length > 0 || (rootBuildFiles.length > 0 && modules.some((m) => m.kind === "unknown"));
191
+ let projectKind = combineProjectKind(modules.map((m) => ({ kind: m.kind, uiToolkit: m.uiToolkit ?? "uncertain" })), hasUnresolvedAndroidEvidence);
192
+ // A single confirmed application/library module whose manifest is entirely
193
+ // missing is Android-specific evidence with an incomplete structure, not a
194
+ // confidently confirmed Android project — downgrade to "partial" rather
195
+ // than reporting a false-confident "application"/"library" classification
196
+ // (agents.txt Batch 2 section 12.3). Multi-module/mixed projects where only
197
+ // some modules lack a manifest keep their combined kind; the missing
198
+ // manifest is still surfaced via partialOrUnsupportedStructure below.
199
+ const androidModules = modules.filter((m) => m.kind === "application" || m.kind === "library");
200
+ const allAndroidModulesMissingManifest = androidModules.length > 0 && androidModules.every((m) => m.manifestPaths.length === 0);
201
+ if (allAndroidModulesMissingManifest && (projectKind === "application" || projectKind === "library")) {
202
+ projectKind = "partial";
203
+ }
204
+ const uiToolkit = combineProjectUiToolkit(modules.map((m) => ({ kind: m.kind, uiToolkit: m.uiToolkit ?? "uncertain" })));
205
+ const hasNonAndroidEvidenceOnly = rootBuildFiles.length > 0 || modules.some((m) => m.kind === "non-android");
206
+ const confidence = combineProjectConfidence(projectKind, modules.filter((m) => m.kind === "application" || m.kind === "library" || m.kind === "unknown").map((m) => m.confidence ?? "unknown"), hasNonAndroidEvidenceOnly);
207
+ const androidModulesMissingManifest = modules.filter((m) => (m.kind === "application" || m.kind === "library") && m.manifestPaths.length === 0);
208
+ for (const module of androidModulesMissingManifest) {
209
+ warnings.push(`No AndroidManifest.xml found for Android module "${module.path}".`);
210
+ }
211
+ const partialOrUnsupportedStructure = projectKind === "partial" || androidModulesMissingManifest.length > 0 || unsupportedDeclarations.length > 0 || declaredOnlyDirs.length > 0;
212
+ const evidence = [];
213
+ if (applicationModules.length > 0)
214
+ evidence.push(`${applicationModules.length} application module(s) found: ${applicationModules.join(", ")}`);
215
+ if (libraryModules.length > 0)
216
+ evidence.push(`${libraryModules.length} library module(s) found: ${libraryModules.join(", ")}`);
217
+ if (hasGradleWrapper)
218
+ evidence.push("Gradle wrapper present");
219
+ if (gradleSettingsFiles.length > 0)
220
+ evidence.push(`Gradle settings file(s): ${gradleSettingsFiles.join(", ")}`);
221
+ if (catalogEvidence.androidPluginAliasEvidence.length > 0) {
222
+ evidence.push(`Version-catalog Android plugin alias(es): ${catalogEvidence.androidPluginAliasEvidence.join(", ")}`);
223
+ }
224
+ if (catalogEvidence.composeAliasEvidence.length > 0) {
225
+ evidence.push(`Version-catalog Compose alias(es): ${catalogEvidence.composeAliasEvidence.join(", ")}`);
226
+ }
227
+ return {
228
+ detected: projectKind !== "non-android",
229
+ confidence,
230
+ evidence,
231
+ projectKind,
232
+ uiToolkit,
233
+ hasGradleWrapper,
234
+ gradleSettingsFiles,
235
+ rootBuildFiles,
236
+ versionCatalogFiles,
237
+ modules,
238
+ applicationModules,
239
+ libraryModules,
240
+ manifestPaths: [...new Set(manifestPaths)].sort((a, b) => a.localeCompare(b)),
241
+ javaSourceRoots: [...new Set(javaSourceRoots)].sort((a, b) => a.localeCompare(b)),
242
+ kotlinSourceRoots: [...new Set(kotlinSourceRoots)].sort((a, b) => a.localeCompare(b)),
243
+ unitTestSourceRoots: [...new Set(unitTestSourceRoots)].sort((a, b) => a.localeCompare(b)),
244
+ instrumentedTestSourceRoots: [...new Set(instrumentedTestSourceRoots)].sort((a, b) => a.localeCompare(b)),
245
+ androidGradlePluginEvidence,
246
+ kotlinAndroidPluginEvidence,
247
+ namespaceEvidence,
248
+ applicationIdEvidence,
249
+ partialOrUnsupportedStructure,
250
+ warnings,
251
+ };
252
+ }
@@ -0,0 +1,60 @@
1
+ // ---------------------------------------------------------------------------
2
+ // v0.4.0 Batch 2 — bounded, deterministic settings.gradle(.kts) module parser.
3
+ //
4
+ // Extracts module declarations from common static `include(...)` syntax
5
+ // (Groovy and Kotlin DSL) using lexical regex scanning. This intentionally
6
+ // does not evaluate Gradle: no variables, functions, conditionals, loops, or
7
+ // included builds are resolved. Anything that cannot be statically resolved
8
+ // is reported as an unsupported declaration rather than guessed.
9
+ // ---------------------------------------------------------------------------
10
+ // Matches `include` (Groovy bare-args or Kotlin-DSL call) but not
11
+ // `includeBuild` (composite builds are out of scope for this batch).
12
+ const INCLUDE_CALL_PATTERN = /\binclude\b(?!Build)\s*(\(([\s\S]*?)\)|([^\n;]*))/g;
13
+ const QUOTED_STRING_PATTERN = /['"]([^'"]*)['"]/g;
14
+ const DYNAMIC_EXPRESSION_PATTERN = /\$\{|\$\w|\bfor\s*\(|\bif\s*\(|\bwhile\s*\(|[+]{1}\s*['"$]/;
15
+ function gradlePathToRelativeDir(gradlePath) {
16
+ if (!gradlePath.startsWith(":")) {
17
+ return null;
18
+ }
19
+ const segments = gradlePath.split(":").filter((segment) => segment.length > 0);
20
+ if (segments.length === 0) {
21
+ return null;
22
+ }
23
+ return segments.join("/");
24
+ }
25
+ // Parses declared module paths out of a settings.gradle or settings.gradle.kts
26
+ // file's raw text. Supports common Groovy and Kotlin DSL `include(...)` forms
27
+ // (single or multiple arguments, single-line or simple multiline calls).
28
+ // Dynamic/unresolvable declarations are preserved verbatim (trimmed, bounded)
29
+ // in `unsupportedDeclarations` rather than silently dropped.
30
+ export function parseDeclaredModules(settingsText) {
31
+ const declaredModulePaths = new Set();
32
+ const unsupportedDeclarations = [];
33
+ for (const match of settingsText.matchAll(INCLUDE_CALL_PATTERN)) {
34
+ const body = match[2] ?? match[3] ?? "";
35
+ let addedAny = false;
36
+ let dynamicFound = DYNAMIC_EXPRESSION_PATTERN.test(body);
37
+ for (const quoted of body.matchAll(QUOTED_STRING_PATTERN)) {
38
+ const literal = quoted[1];
39
+ // A quoted literal containing an unresolved `$` interpolation is not a
40
+ // static module path, even though it matched the quoted-string regex —
41
+ // it must be reported as unsupported, not silently accepted.
42
+ if (literal.includes("$")) {
43
+ dynamicFound = true;
44
+ continue;
45
+ }
46
+ const relativeDir = gradlePathToRelativeDir(literal);
47
+ if (relativeDir) {
48
+ declaredModulePaths.add(relativeDir);
49
+ addedAny = true;
50
+ }
51
+ }
52
+ if (dynamicFound || (!addedAny && body.trim().length > 0)) {
53
+ unsupportedDeclarations.push(body.trim().slice(0, 200));
54
+ }
55
+ }
56
+ return {
57
+ declaredModulePaths: [...declaredModulePaths].sort((a, b) => a.localeCompare(b)),
58
+ unsupportedDeclarations,
59
+ };
60
+ }
@@ -0,0 +1,92 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ // ---------------------------------------------------------------------------
4
+ // v0.4.0 Batch 2 — bounded source inspection for Compose/View UI evidence.
5
+ //
6
+ // Scans a small, capped number of source files under given source roots for
7
+ // lexical markers (@Composable, androidx.compose imports, traditional View
8
+ // APIs). This is not semantic parsing — it is a bounded grep-style scan with
9
+ // hard file-count and file-size caps so detection stays fast and safe on
10
+ // large or adversarial repositories.
11
+ // ---------------------------------------------------------------------------
12
+ const MAX_FILES_SCANNED = 40;
13
+ const MAX_FILE_SIZE_BYTES = 200_000;
14
+ const COMPOSE_SOURCE_PATTERN = /@Composable|androidx\.compose/;
15
+ const VIEW_SOURCE_PATTERN = /\bandroid\.view\.View\b|\bandroid\.widget\.|findViewById|\bViewBinding\b/;
16
+ function collectSourceFiles(root, extensions, budget) {
17
+ const files = [];
18
+ const visit = (dir) => {
19
+ if (budget.remaining <= 0)
20
+ return;
21
+ let entries;
22
+ try {
23
+ entries = readdirSync(dir, { withFileTypes: true });
24
+ }
25
+ catch {
26
+ return;
27
+ }
28
+ for (const entry of entries) {
29
+ if (budget.remaining <= 0)
30
+ return;
31
+ const fullPath = path.join(dir, entry.name);
32
+ if (entry.isDirectory()) {
33
+ visit(fullPath);
34
+ continue;
35
+ }
36
+ if (entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext))) {
37
+ files.push(fullPath);
38
+ budget.remaining -= 1;
39
+ }
40
+ }
41
+ };
42
+ visit(root);
43
+ return files;
44
+ }
45
+ // Scans up to MAX_FILES_SCANNED Kotlin/Java files across the given source
46
+ // roots (already resolved absolute directories) for bounded Compose/View
47
+ // lexical evidence. Missing directories are ignored, not treated as errors.
48
+ export function scanSourceRootsForUiEvidence(sourceRootAbsolutePaths) {
49
+ const budget = { remaining: MAX_FILES_SCANNED };
50
+ const files = [];
51
+ for (const root of sourceRootAbsolutePaths) {
52
+ if (budget.remaining <= 0)
53
+ break;
54
+ try {
55
+ if (!statSync(root).isDirectory())
56
+ continue;
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ files.push(...collectSourceFiles(root, [".kt", ".java"], budget));
62
+ }
63
+ let composeSourceEvidence = false;
64
+ let viewSourceEvidence = false;
65
+ let filesScanned = 0;
66
+ for (const file of files) {
67
+ let stat;
68
+ try {
69
+ stat = statSync(file);
70
+ }
71
+ catch {
72
+ continue;
73
+ }
74
+ if (stat.size > MAX_FILE_SIZE_BYTES)
75
+ continue;
76
+ let content;
77
+ try {
78
+ content = readFileSync(file, "utf8");
79
+ }
80
+ catch {
81
+ continue;
82
+ }
83
+ filesScanned += 1;
84
+ if (!composeSourceEvidence && COMPOSE_SOURCE_PATTERN.test(content)) {
85
+ composeSourceEvidence = true;
86
+ }
87
+ if (!viewSourceEvidence && VIEW_SOURCE_PATTERN.test(content)) {
88
+ viewSourceEvidence = true;
89
+ }
90
+ }
91
+ return { composeSourceEvidence, viewSourceEvidence, filesScanned };
92
+ }
@@ -0,0 +1,79 @@
1
+ import { lstatSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { relativeWithinRoot } from "../../../core/pathSafety.js";
4
+ // ---------------------------------------------------------------------------
5
+ // v0.4.0 Batch 2 — bounded, deterministic filesystem traversal for Android
6
+ // detection.
7
+ //
8
+ // A dedicated walker (rather than reusing src/core/fileGlobs.ts) because
9
+ // Android detection needs a wider and different exclusion set (.gradle, out,
10
+ // reports, lab-output, .my-dev-kit, IDE caches) than the generic glob helper
11
+ // currently declares, and widening that shared helper's exclusion list would
12
+ // change behavior for every other consumer of collectFilesForGlobs. Reuses
13
+ // src/core/pathSafety.ts's relativeWithinRoot for containment/normalization
14
+ // so target-root escape guarantees stay centralized.
15
+ // ---------------------------------------------------------------------------
16
+ const EXCLUDED_DIR_NAMES = new Set([
17
+ ".git",
18
+ ".gradle",
19
+ "build",
20
+ "out",
21
+ "dist",
22
+ "node_modules",
23
+ "coverage",
24
+ "reports",
25
+ "lab-output",
26
+ ".my-dev-kit",
27
+ ".idea",
28
+ ".vscode",
29
+ ".vs",
30
+ ]);
31
+ // Walks `root`, returning sorted POSIX-style relative file paths. Directory
32
+ // symlinks are never followed (recorded in `skippedSymlinks` instead), and
33
+ // traversal never leaves `root` (relativeWithinRoot throws on escape, which
34
+ // cannot happen here since paths are built from readdir entries under root).
35
+ export function walkAndroidCandidateFiles(root) {
36
+ const resolvedRoot = path.resolve(root);
37
+ const relativeFilePaths = [];
38
+ const skippedSymlinks = [];
39
+ const visit = (dir) => {
40
+ let entries;
41
+ try {
42
+ entries = readdirSync(dir, { withFileTypes: true });
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ for (const entry of entries) {
48
+ const fullPath = path.join(dir, entry.name);
49
+ if (entry.isSymbolicLink()) {
50
+ let stat;
51
+ try {
52
+ stat = lstatSync(fullPath);
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ if (stat.isSymbolicLink()) {
58
+ skippedSymlinks.push(relativeWithinRoot(resolvedRoot, fullPath));
59
+ continue;
60
+ }
61
+ }
62
+ if (entry.isDirectory()) {
63
+ if (EXCLUDED_DIR_NAMES.has(entry.name)) {
64
+ continue;
65
+ }
66
+ visit(fullPath);
67
+ continue;
68
+ }
69
+ if (entry.isFile()) {
70
+ relativeFilePaths.push(relativeWithinRoot(resolvedRoot, fullPath));
71
+ }
72
+ }
73
+ };
74
+ visit(resolvedRoot);
75
+ return {
76
+ relativeFilePaths: relativeFilePaths.sort((a, b) => a.localeCompare(b)),
77
+ skippedSymlinks: skippedSymlinks.sort((a, b) => a.localeCompare(b)),
78
+ };
79
+ }