@invarn/cibuild 2.8.0 → 2.8.1

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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=a-properties-stand-in-declares-the-keys-the-build-reads.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"a-properties-stand-in-declares-the-keys-the-build-reads.test.d.ts","sourceRoot":"","sources":["../../../src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,218 @@
1
+ /**
2
+ * An empty properties file fixes "file missing", not "field missing".
3
+ *
4
+ * The Secrets Gradle plugin turns each key of the properties file it loads
5
+ * into a `BuildConfig` constant and a manifest placeholder. A checkout without
6
+ * that file gets neither, so handing the build an empty stand-in clears the
7
+ * plugin's own throw and then fails one step later, inside Gradle, at the first
8
+ * reference to a constant that was never generated.
9
+ *
10
+ * `missingPropertyKeys` is the list a stand-in has to declare for that
11
+ * reference to resolve: what the build reads, read off the build files and the
12
+ * source under the modules that apply the plugin.
13
+ */
14
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { scanAndroidProject } from "./android-scanner.js";
18
+ let root;
19
+ beforeEach(() => {
20
+ root = mkdtempSync(join(tmpdir(), "cibuild-props-"));
21
+ });
22
+ afterEach(() => {
23
+ rmSync(root, { recursive: true, force: true });
24
+ });
25
+ function write(relative, content) {
26
+ const path = join(root, relative);
27
+ mkdirSync(join(path, ".."), { recursive: true });
28
+ writeFileSync(path, content);
29
+ }
30
+ async function keys() {
31
+ return (await scanAndroidProject(root)).missingPropertyKeys;
32
+ }
33
+ /**
34
+ * The shape the whole thing is for: one module applying the plugin, no
35
+ * properties file anywhere, and the root declaring the plugin without
36
+ * applying it — which is where a version catalog alias is nearly always named.
37
+ */
38
+ function projectApplyingTheSecretsPlugin(appBuildFileExtra = "") {
39
+ write("settings.gradle.kts", 'include(":app")\n');
40
+ write("build.gradle.kts", "plugins {\n alias(libs.plugins.secrets) apply false\n}\n");
41
+ write("app/build.gradle.kts", `plugins {\n id("com.android.application")\n alias(libs.plugins.secrets)\n}\n\nandroid {\n namespace = "com.example.app"\n${appBuildFileExtra}}\n`);
42
+ write(".gitignore", "local.properties\n");
43
+ }
44
+ describe("the keys a generated properties file has to declare", () => {
45
+ test("names a BuildConfig field referenced from Kotlin source", async () => {
46
+ projectApplyingTheSecretsPlugin();
47
+ write("app/src/main/java/com/example/app/Maps.kt", "package com.example.app\n\nval key: String = BuildConfig.MAPS_API_KEY\n");
48
+ expect(await keys()).toEqual(["MAPS_API_KEY"]);
49
+ });
50
+ test("names one referenced from Java source", async () => {
51
+ projectApplyingTheSecretsPlugin();
52
+ write("app/src/main/java/com/example/app/Maps.java", "package com.example.app;\n\nclass Maps { String k = BuildConfig.MAPS_API_KEY; }\n");
53
+ expect(await keys()).toEqual(["MAPS_API_KEY"]);
54
+ });
55
+ test("names a manifestPlaceholders key — Kotlin DSL, indexed", async () => {
56
+ projectApplyingTheSecretsPlugin(' defaultConfig {\n manifestPlaceholders["mapsApiKey"] = "unused"\n }\n');
57
+ expect(await keys()).toEqual(["mapsApiKey"]);
58
+ });
59
+ test("names a manifestPlaceholders key — Kotlin DSL, whole map", async () => {
60
+ projectApplyingTheSecretsPlugin(' defaultConfig {\n manifestPlaceholders += mapOf("mapsApiKey" to "u", "adsAppId" to "u")\n }\n');
61
+ expect(await keys()).toEqual(["adsAppId", "mapsApiKey"]);
62
+ });
63
+ test("names a manifestPlaceholders key — Groovy map literal", async () => {
64
+ write("settings.gradle", "include ':app'\n");
65
+ write("build.gradle", "// root\n");
66
+ write("app/build.gradle", "plugins {\n id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'\n}\n" +
67
+ "android {\n defaultConfig {\n" +
68
+ " manifestPlaceholders = [mapsApiKey: \"unused\", 'adsAppId': \"unused\"]\n" +
69
+ " }\n}\n");
70
+ expect(await keys()).toEqual(["adsAppId", "mapsApiKey"]);
71
+ });
72
+ test("names a manifestPlaceholders key — Groovy put", async () => {
73
+ write("settings.gradle", "include ':app'\n");
74
+ write("build.gradle", "// root\n");
75
+ write("app/build.gradle", "plugins {\n id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'\n}\n" +
76
+ "android {\n defaultConfig {\n" +
77
+ " manifestPlaceholders.put('mapsApiKey', 'unused')\n" +
78
+ " }\n}\n");
79
+ expect(await keys()).toEqual(["mapsApiKey"]);
80
+ });
81
+ test("merges both sources, sorted and deduplicated", async () => {
82
+ projectApplyingTheSecretsPlugin(' defaultConfig {\n manifestPlaceholders["MAPS_API_KEY"] = "u"\n }\n');
83
+ write("app/src/main/java/com/example/app/Keys.kt", "val a = BuildConfig.MAPS_API_KEY\nval b = BuildConfig.ADS_APP_ID\nval c = BuildConfig.MAPS_API_KEY\n");
84
+ expect(await keys()).toEqual(["ADS_APP_ID", "MAPS_API_KEY"]);
85
+ });
86
+ });
87
+ describe("what it deliberately leaves out", () => {
88
+ /**
89
+ * `BuildConfig.DEBUG` is the most-referenced constant in Android source, and
90
+ * AGP generates it whether or not a properties file exists. Declaring it
91
+ * would make the Secrets plugin generate a `String DEBUG` beside AGP's
92
+ * `boolean DEBUG` — the file meant to rescue the build breaking it instead.
93
+ */
94
+ test("the fields AGP generates by itself", async () => {
95
+ projectApplyingTheSecretsPlugin();
96
+ write("app/src/main/java/com/example/app/Env.kt", [
97
+ "val d = BuildConfig.DEBUG",
98
+ "val a = BuildConfig.APPLICATION_ID",
99
+ "val t = BuildConfig.BUILD_TYPE",
100
+ "val f = BuildConfig.FLAVOR",
101
+ "val fd = BuildConfig.FLAVOR_environment",
102
+ "val vc = BuildConfig.VERSION_CODE",
103
+ "val vn = BuildConfig.VERSION_NAME",
104
+ "val lp = BuildConfig.LIBRARY_PACKAGE_NAME",
105
+ "val mine = BuildConfig.MAPS_API_KEY",
106
+ ].join("\n"));
107
+ expect(await keys()).toEqual(["MAPS_API_KEY"]);
108
+ });
109
+ test("a project that applies no Secrets plugin", async () => {
110
+ write("settings.gradle.kts", 'include(":app")\n');
111
+ write("build.gradle.kts", "// root\n");
112
+ write("app/build.gradle.kts", 'plugins {\n id("com.android.application")\n}\n');
113
+ write("app/src/main/java/com/example/app/Maps.kt", "val k = BuildConfig.MAPS_API_KEY\n");
114
+ expect(await keys()).toEqual([]);
115
+ });
116
+ /**
117
+ * `apply false` names the plugin's version for the modules that will apply
118
+ * it; the root project loads no properties file of its own. Reading it as an
119
+ * application would aim the source walk at the whole repository — every
120
+ * module's source, on the strength of a line that applies nothing.
121
+ */
122
+ test("a root that declares the plugin with apply false", async () => {
123
+ write("settings.gradle.kts", 'include(":app", ":other")\n');
124
+ write("build.gradle.kts", "plugins {\n alias(libs.plugins.secrets) apply false\n}\n");
125
+ write("app/build.gradle.kts", 'plugins {\n id("com.android.application")\n}\n');
126
+ write("other/build.gradle.kts", "// nothing\n");
127
+ write("other/src/main/java/Other.kt", "val k = BuildConfig.ELSEWHERE\n");
128
+ expect(await keys()).toEqual([]);
129
+ });
130
+ test("generated sources under build/", async () => {
131
+ projectApplyingTheSecretsPlugin();
132
+ write("app/build/generated/source/buildConfig/debug/com/example/app/BuildConfig.java", "public final class BuildConfig { public static final String STALE = BuildConfig.STALE; }\n");
133
+ write("app/src/main/java/com/example/app/Maps.kt", "val k = BuildConfig.MAPS_API_KEY\n");
134
+ expect(await keys()).toEqual(["MAPS_API_KEY"]);
135
+ });
136
+ });
137
+ describe("the module boundary", () => {
138
+ test("reads source of the module that applies the plugin, not its siblings", async () => {
139
+ write("settings.gradle.kts", 'include(":app", ":other")\n');
140
+ write("build.gradle.kts", "// root\n");
141
+ write("app/build.gradle.kts", 'plugins {\n alias(libs.plugins.secrets)\n}\n');
142
+ write("app/src/main/java/App.kt", "val k = BuildConfig.MAPS_API_KEY\n");
143
+ write("other/build.gradle.kts", "// nothing\n");
144
+ write("other/src/main/java/Other.kt", "val k = BuildConfig.ELSEWHERE\n");
145
+ expect(await keys()).toEqual(["MAPS_API_KEY"]);
146
+ });
147
+ test("a root module that really applies the plugin covers the whole tree", async () => {
148
+ write("settings.gradle.kts", 'include(":app")\n');
149
+ write("build.gradle.kts", 'plugins {\n id("com.android.application")\n alias(libs.plugins.secrets)\n}\n');
150
+ write("app/build.gradle.kts", "// nothing\n");
151
+ write("app/src/main/java/App.kt", "val k = BuildConfig.MAPS_API_KEY\n");
152
+ expect(await keys()).toEqual(["MAPS_API_KEY"]);
153
+ });
154
+ });
155
+ /**
156
+ * The warnings a customer already sees. This scan adds a field and reads more
157
+ * files; it must not add, drop or reword a single warning, so the two paths
158
+ * that emit a `secrets-plugin` warning are asserted whole.
159
+ */
160
+ describe("the Secrets-plugin warning path is unchanged", () => {
161
+ test("with no companion defaults file, the one file-absent warning", async () => {
162
+ projectApplyingTheSecretsPlugin();
163
+ write("app/src/main/java/com/example/app/Maps.kt", "val k = BuildConfig.MAPS_API_KEY\n");
164
+ const result = await scanAndroidProject(root);
165
+ expect(result.warnings.filter((w) => w.category === "secrets-plugin")).toEqual([
166
+ {
167
+ category: "secrets-plugin",
168
+ severity: "warning",
169
+ message: "Google Secrets Gradle Plugin reads 'local.properties' which is absent in CI",
170
+ hint: "Inject 'local.properties' as a file step using a secret variable",
171
+ // The root build file, which is where the version-catalog alias is
172
+ // named with `apply false`. The warning path counts that as an
173
+ // application and always has; `missingPropertyKeys` does not, which is
174
+ // why the two ask the question through different functions.
175
+ location: "build.gradle.kts",
176
+ },
177
+ ]);
178
+ // And the new field is populated in the same scan, from the same files.
179
+ expect(result.missingPropertyKeys).toEqual(["MAPS_API_KEY"]);
180
+ });
181
+ test("with a companion defaults file, one warning per expected key", async () => {
182
+ projectApplyingTheSecretsPlugin();
183
+ write("secrets.defaults.properties", "MAPS_API_KEY=\nADS_APP_ID=\n");
184
+ write("app/src/main/java/com/example/app/Maps.kt", "val k = BuildConfig.MAPS_API_KEY\n");
185
+ const result = await scanAndroidProject(root);
186
+ expect(result.warnings.filter((w) => w.category === "secrets-plugin")).toEqual([
187
+ {
188
+ category: "secrets-plugin",
189
+ severity: "warning",
190
+ message: "Secrets plugin key: MAPS_API_KEY",
191
+ hint: "Add via: ci secrets add MAPS_API_KEY",
192
+ location: "local.properties",
193
+ },
194
+ {
195
+ category: "secrets-plugin",
196
+ severity: "warning",
197
+ message: "Secrets plugin key: ADS_APP_ID",
198
+ hint: "Add via: ci secrets add ADS_APP_ID",
199
+ location: "local.properties",
200
+ },
201
+ ]);
202
+ });
203
+ test("a properties file that is present but not gitignored warns about neither", async () => {
204
+ write("settings.gradle.kts", 'include(":app")\n');
205
+ write("build.gradle.kts", "// root\n");
206
+ write("app/build.gradle.kts", "plugins {\n alias(libs.plugins.secrets)\n}\n");
207
+ write("local.properties", "sdk.dir=/opt/android\n");
208
+ write("app/src/main/java/App.kt", "val k = BuildConfig.MAPS_API_KEY\n");
209
+ const result = await scanAndroidProject(root);
210
+ expect(result.warnings.filter((w) => w.category === "secrets-plugin")).toEqual([]);
211
+ // The keys are still reported. `invarn`-style callers run this scan on a
212
+ // developer's own checkout, where the file is nearly always present and
213
+ // holds nothing but `sdk.dir` — gating the keys on its absence would
214
+ // return nothing in exactly the case they are needed for.
215
+ expect(result.missingPropertyKeys).toEqual(["MAPS_API_KEY"]);
216
+ });
217
+ });
218
+ //# sourceMappingURL=a-properties-stand-in-declares-the-keys-the-build-reads.test.js.map
@@ -25,6 +25,28 @@ export interface ScanResult {
25
25
  detectedGradleVersion?: string;
26
26
  /** Build types, product flavors, and all variant combinations detected in Gradle files. */
27
27
  buildVariants: BuildVariants;
28
+ /**
29
+ * The property names the build reads out of the properties file the Secrets
30
+ * Gradle plugin loads — sorted, deduplicated, empty when no module applies
31
+ * the plugin.
32
+ *
33
+ * **An empty properties file fixes "file missing", not "field missing".**
34
+ * The plugin turns each key of that file into a `BuildConfig` constant and a
35
+ * manifest placeholder, so a checkout without the file compiles neither.
36
+ * Supplying an empty stand-in gets the build past the plugin's own throw and
37
+ * then fails one step later, inside Gradle, at the first reference to a field
38
+ * that was never generated. These are those names: what a stand-in has to
39
+ * declare for the reference to resolve.
40
+ *
41
+ * Data, not prose — deliberately not more `warnings`. A warning is a sentence
42
+ * for a human to read; this is a list for a generator to consume, and sharing
43
+ * one channel between them means the generator has to parse English.
44
+ *
45
+ * Coarse on purpose. A name here that some other plugin turns out to
46
+ * generate costs one unused line in a properties file; a name missing from
47
+ * here costs the build.
48
+ */
49
+ missingPropertyKeys: string[];
28
50
  }
29
51
  export interface BuildVariants {
30
52
  /** Build types found, e.g. ["debug", "release", "staging"]. Always includes debug + release. */
@@ -1 +1 @@
1
- {"version":3,"file":"android-scanner.d.ts","sourceRoot":"","sources":["../../../src/commands/android-scanner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"android-scanner.d.ts","sourceRoot":"","sources":["../../../src/commands/android-scanner.ts"],"names":[],"mappings":"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"}
@@ -138,6 +138,259 @@ function extractSecretsPropertiesFile(content) {
138
138
  const m = /propertiesFileName\s*=\s*["']([^"']+)["']/.exec(block);
139
139
  return m ? m[1] : "local.properties";
140
140
  }
141
+ // ---------------------------------------------------------------------------
142
+ // Property keys the build reads out of the Secrets plugin's properties file
143
+ // ---------------------------------------------------------------------------
144
+ /**
145
+ * True when this build file *applies* the Secrets plugin, as opposed to merely
146
+ * declaring its version for other modules.
147
+ *
148
+ * A root build file almost always carries
149
+ * `alias(libs.plugins.secrets) apply false` — the plugin is named there so a
150
+ * module can apply it without repeating the version, and the root project
151
+ * itself never loads a properties file. Reading that as "the root module
152
+ * applies it" would aim the source walk below at the whole repository on the
153
+ * strength of a line that applies nothing.
154
+ *
155
+ * Separate from `detectSecretsPlugin` on purpose: that one decides which
156
+ * warnings are emitted, and this must not change them.
157
+ */
158
+ function appliesSecretsPlugin(content) {
159
+ const applied = content
160
+ .split("\n")
161
+ .filter((line) => !/\bapply\s+false\b/.test(line))
162
+ .join("\n");
163
+ return detectSecretsPlugin(applied);
164
+ }
165
+ /**
166
+ * The `BuildConfig` fields the Android Gradle Plugin generates by itself.
167
+ *
168
+ * `BuildConfig.DEBUG` is the most-referenced constant in Android source, and
169
+ * every one of these is generated whether or not a properties file exists — so
170
+ * naming them costs more than the unused line the rest of this scan tolerates.
171
+ * A property named `DEBUG` would make the Secrets plugin generate a `String
172
+ * DEBUG` beside AGP's `boolean DEBUG`, and the build that was going to compile
173
+ * stops compiling because of the file meant to rescue it.
174
+ *
175
+ * `FLAVOR_<dimension>` is generated once per flavor dimension, so it is matched
176
+ * by prefix rather than listed.
177
+ */
178
+ const AGP_GENERATED_BUILD_CONFIG_FIELDS = new Set([
179
+ "DEBUG",
180
+ "APPLICATION_ID",
181
+ "BUILD_TYPE",
182
+ "FLAVOR",
183
+ "VERSION_CODE",
184
+ "VERSION_NAME",
185
+ "LIBRARY_PACKAGE_NAME",
186
+ ]);
187
+ function isAgpGeneratedField(name) {
188
+ return AGP_GENERATED_BUILD_CONFIG_FIELDS.has(name) || name.startsWith("FLAVOR_");
189
+ }
190
+ /**
191
+ * `BuildConfig.SOME_FIELD` references in a source file.
192
+ *
193
+ * Screaming snake case only: that is the convention for a generated constant,
194
+ * and widening it to any identifier would pull in `BuildConfig.javaClass` and
195
+ * every other member access on the class itself.
196
+ */
197
+ function extractBuildConfigFieldRefs(content) {
198
+ const names = [];
199
+ const re = /\bBuildConfig\.([A-Z][A-Z0-9_]*)\b/g;
200
+ let m;
201
+ while ((m = re.exec(content)) !== null) {
202
+ if (!isAgpGeneratedField(m[1]))
203
+ names.push(m[1]);
204
+ }
205
+ return names;
206
+ }
207
+ /**
208
+ * The index of the character closing the bracket or parenthesis at `open`,
209
+ * ignoring anything inside a string literal. -1 when it is never closed.
210
+ */
211
+ function matchingClose(content, open) {
212
+ const closing = content[open] === "[" ? "]" : ")";
213
+ let depth = 0;
214
+ let quote = "";
215
+ for (let i = open; i < content.length; i++) {
216
+ const ch = content[i];
217
+ if (quote) {
218
+ if (ch === "\\")
219
+ i++;
220
+ else if (ch === quote)
221
+ quote = "";
222
+ continue;
223
+ }
224
+ if (ch === '"' || ch === "'")
225
+ quote = ch;
226
+ else if (ch === "[" || ch === "(")
227
+ depth++;
228
+ else if (ch === "]" || ch === ")") {
229
+ depth--;
230
+ if (depth === 0)
231
+ return content[i] === closing ? i : -1;
232
+ }
233
+ }
234
+ return -1;
235
+ }
236
+ /** Split a map-literal body on its top-level commas — not ones nested or quoted. */
237
+ function splitTopLevel(body) {
238
+ const parts = [];
239
+ let depth = 0;
240
+ let quote = "";
241
+ let start = 0;
242
+ for (let i = 0; i < body.length; i++) {
243
+ const ch = body[i];
244
+ if (quote) {
245
+ if (ch === "\\")
246
+ i++;
247
+ else if (ch === quote)
248
+ quote = "";
249
+ continue;
250
+ }
251
+ if (ch === '"' || ch === "'")
252
+ quote = ch;
253
+ else if (ch === "[" || ch === "(" || ch === "{")
254
+ depth++;
255
+ else if (ch === "]" || ch === ")" || ch === "}")
256
+ depth--;
257
+ else if (ch === "," && depth === 0) {
258
+ parts.push(body.slice(start, i));
259
+ start = i + 1;
260
+ }
261
+ }
262
+ parts.push(body.slice(start));
263
+ return parts;
264
+ }
265
+ /**
266
+ * The keys of a map literal, in either language's spelling:
267
+ * Groovy `[key: v, "key": v]`, Kotlin `mapOf("key" to v)`.
268
+ */
269
+ function mapLiteralKeys(body) {
270
+ const names = [];
271
+ for (const entry of splitTopLevel(body)) {
272
+ const kotlin = /^\s*["']([^"']+)["']\s+to\s/.exec(entry);
273
+ if (kotlin) {
274
+ names.push(kotlin[1]);
275
+ continue;
276
+ }
277
+ const groovy = /^\s*(?:["']([^"']+)["']|([A-Za-z_]\w*))\s*:/.exec(entry);
278
+ if (groovy)
279
+ names.push(groovy[1] ?? groovy[2]);
280
+ }
281
+ return names;
282
+ }
283
+ /**
284
+ * Keys named in a `manifestPlaceholders` map in a build file.
285
+ *
286
+ * The Secrets plugin fills a placeholder from the property of the same name,
287
+ * so a placeholder the build declares is a property the build reads. Every
288
+ * spelling the two DSLs offer for the same map:
289
+ *
290
+ * manifestPlaceholders["mapsApiKey"] = … // both
291
+ * manifestPlaceholders.put("mapsApiKey", …) // Groovy
292
+ * manifestPlaceholders = [mapsApiKey: …] // Groovy
293
+ * manifestPlaceholders += mapOf("mapsApiKey" to …) // Kotlin
294
+ */
295
+ function extractManifestPlaceholderKeys(content) {
296
+ const names = [];
297
+ for (const re of [
298
+ /manifestPlaceholders\s*\[\s*["']([^"']+)["']\s*\]/g,
299
+ /manifestPlaceholders\s*\.\s*put\s*\(\s*["']([^"']+)["']/g,
300
+ ]) {
301
+ let m;
302
+ while ((m = re.exec(content)) !== null)
303
+ names.push(m[1]);
304
+ }
305
+ // A whole map assigned, added or put at once. The opening bracket the regex
306
+ // ends on is where the literal starts; its match is where it ends.
307
+ const assigned = /manifestPlaceholders\s*(?:\.\s*putAll\s*\(\s*)?(?:\+?=\s*)?(?:mapOf\s*|mutableMapOf\s*)?[[(]/g;
308
+ let m;
309
+ while ((m = assigned.exec(content)) !== null) {
310
+ const open = m.index + m[0].length - 1;
311
+ const close = matchingClose(content, open);
312
+ if (close === -1)
313
+ continue;
314
+ names.push(...mapLiteralKeys(content.slice(open + 1, close)));
315
+ assigned.lastIndex = close;
316
+ }
317
+ return names;
318
+ }
319
+ // Bounds on the source walk, the way `findGradleFiles` bounds itself to the
320
+ // files a build actually declares. A repository is an unbounded tree and this
321
+ // runs on every scan of one.
322
+ const SOURCE_EXTENSIONS = [".kt", ".kts", ".java"];
323
+ const SOURCE_SKIP_DIRS = new Set(["build", "node_modules"]);
324
+ const SOURCE_FILE_CAP = 1500;
325
+ const SOURCE_DEPTH_CAP = 12;
326
+ /** Source files under `dir`, up to the shared file budget. */
327
+ function findSourceFiles(dir, budget, depth = 0) {
328
+ if (depth > SOURCE_DEPTH_CAP || budget.left <= 0)
329
+ return [];
330
+ let entries;
331
+ try {
332
+ entries = readdirSync(dir, { withFileTypes: true });
333
+ }
334
+ catch {
335
+ return [];
336
+ }
337
+ const files = [];
338
+ const subdirs = [];
339
+ for (const entry of entries) {
340
+ // Dot directories hold caches and VCS state, never source: `.git`,
341
+ // `.gradle`, `.idea`, `.kotlin`.
342
+ if (entry.name.startsWith("."))
343
+ continue;
344
+ if (entry.isDirectory()) {
345
+ if (!SOURCE_SKIP_DIRS.has(entry.name))
346
+ subdirs.push(join(dir, entry.name));
347
+ }
348
+ else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
349
+ if (budget.left <= 0)
350
+ break;
351
+ budget.left--;
352
+ files.push(join(dir, entry.name));
353
+ }
354
+ }
355
+ for (const sub of subdirs)
356
+ files.push(...findSourceFiles(sub, budget, depth + 1));
357
+ return files;
358
+ }
359
+ /** The directories of `dirs` that no other member of `dirs` contains. */
360
+ function outermost(dirs) {
361
+ return dirs.filter((dir) => !dirs.some((other) => other !== dir && dir.startsWith(other + "/")));
362
+ }
363
+ /**
364
+ * The property names the build reads out of the Secrets plugin's properties
365
+ * file — see `ScanResult.missingPropertyKeys`.
366
+ *
367
+ * Bounded to the modules that apply the plugin, because that is where its
368
+ * generated fields are visible and where the references to them live.
369
+ */
370
+ function collectPropertyKeys(gradleFiles) {
371
+ // A module with both a `build.gradle` and a `build.gradle.kts` yields its
372
+ // directory twice, and walking it twice would spend the file budget twice.
373
+ const moduleDirs = new Set();
374
+ const keys = new Set();
375
+ for (const filePath of gradleFiles) {
376
+ const content = safeRead(filePath);
377
+ if (!appliesSecretsPlugin(content))
378
+ continue;
379
+ moduleDirs.add(dirname(filePath));
380
+ for (const key of extractManifestPlaceholderKeys(content))
381
+ keys.add(key);
382
+ }
383
+ if (moduleDirs.size === 0)
384
+ return [];
385
+ const budget = { left: SOURCE_FILE_CAP };
386
+ for (const dir of outermost([...moduleDirs])) {
387
+ for (const file of findSourceFiles(dir, budget)) {
388
+ for (const name of extractBuildConfigFieldRefs(safeRead(file)))
389
+ keys.add(name);
390
+ }
391
+ }
392
+ return [...keys].sort();
393
+ }
141
394
  /** Parse property keys from a standard .properties file, skipping comments and blank lines. */
142
395
  function parsePropertyKeys(propertiesContent) {
143
396
  return propertiesContent
@@ -1070,6 +1323,16 @@ export async function scanAndroidProject(projectRoot) {
1070
1323
  }
1071
1324
  }
1072
1325
  // ------------------------------------------------------------------
1326
+ // 4b. Which keys that properties file has to declare
1327
+ // ------------------------------------------------------------------
1328
+ //
1329
+ // Section 4 says the file is absent; this says what is in it. The two are
1330
+ // separate because they answer different questions for different readers —
1331
+ // the warnings above tell a person a file is missing, and this tells a
1332
+ // generator what a stand-in for it has to contain.
1333
+ // ------------------------------------------------------------------
1334
+ const missingPropertyKeys = collectPropertyKeys(gradleFiles);
1335
+ // ------------------------------------------------------------------
1073
1336
  // 5. Firebase / GMS — check google-services.json after scanning all files
1074
1337
  // ------------------------------------------------------------------
1075
1338
  if (gmsDetected) {
@@ -1121,6 +1384,7 @@ export async function scanAndroidProject(projectRoot) {
1121
1384
  detectedJavaVersionSource,
1122
1385
  detectedGradleVersion,
1123
1386
  buildVariants,
1387
+ missingPropertyKeys,
1124
1388
  };
1125
1389
  }
1126
1390
  // ---------------------------------------------------------------------------
@@ -23,6 +23,7 @@ export { handleBuildCommand } from "./build.js";
23
23
  export { handleResetCommand } from "./reset.js";
24
24
  export { detectMobileProjectRoot } from "../shared/detect-project.js";
25
25
  export type { MobileProjectType } from "../shared/detect-project.js";
26
+ export { isXcodeContainer, xcodeContainersIn } from "../shared/detect-project.js";
26
27
  export { scanIosProject, formatIosScanResult } from "./ios-scanner.js";
27
28
  export type { IosScanResult, IosWarning, IosWarningCategory, IosWarningSeverity, } from "./ios-scanner.js";
28
29
  export { scanAndroidProject, formatScanResult, detectBuildVariants, } from "./android-scanner.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAE7C,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AACtD,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAC5C,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C,OAAO,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,2BAA2B,EAAE,MAAM,sBAAsB,CAAC;AACnE,YAAY,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAElE,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAGhD,OAAO,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AACtE,YAAY,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAOrE,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACvE,YAAY,EACV,aAAa,EACb,UAAU,EACV,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,UAAU,IAAI,iBAAiB,EAC/B,aAAa,EACb,YAAY,EACZ,eAAe,EACf,eAAe,GAChB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AACjF,YAAY,EACV,gBAAgB,EAChB,eAAe,EACf,aAAa,GACd,MAAM,4BAA4B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAE7C,OAAO,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AACtD,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD,OAAO,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAC5C,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C,OAAO,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,2BAA2B,EAAE,MAAM,sBAAsB,CAAC;AACnE,YAAY,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAElE,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAGhD,OAAO,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AACtE,YAAY,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAGrE,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAOlF,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACvE,YAAY,EACV,aAAa,EACb,UAAU,EACV,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,UAAU,IAAI,iBAAiB,EAC/B,aAAa,EACb,YAAY,EACZ,eAAe,EACf,eAAe,GAChB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AACjF,YAAY,EACV,gBAAgB,EAChB,eAAe,EACf,aAAa,GACd,MAAM,4BAA4B,CAAC"}
@@ -19,6 +19,9 @@ export { handleBuildCommand } from "./build.js";
19
19
  export { handleResetCommand } from "./reset.js";
20
20
  // Helpers useful for callers that implement their own variations.
21
21
  export { detectMobileProjectRoot } from "../shared/detect-project.js";
22
+ // The one answer to "is this `.xcodeproj` a project", exported so the Invarn
23
+ // CLI's own scans cannot drift from cibuild's.
24
+ export { isXcodeContainer, xcodeContainersIn } from "../shared/detect-project.js";
22
25
  // Project scanners — pure, toolchain-free file-content detectors. Exposed
23
26
  // so external callers (the Invarn CLI) can detect build variables and
24
27
  // secret-bearing files from a checkout and push them to the backend,
@@ -1 +1 @@
1
- {"version":3,"file":"ios-scanner.d.ts","sourceRoot":"","sources":["../../../src/commands/ios-scanner.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,kBAAkB,GAAG,cAAc,GAAG,SAAS,GAAG,gBAAgB,GAAG,WAAW,CAAC;AAE7F,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,MAAM,CAAC;AAEpD,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB;;;OAGG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oDAAoD;IACpD,YAAY,EAAE,OAAO,CAAC;IACtB,0DAA0D;IAC1D,MAAM,EAAE,OAAO,CAAC;IAChB,+DAA+D;IAC/D,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iFAAiF;IACjF,eAAe,EAAE,MAAM,CAAC;CACzB;AAuFD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EAAE,GAChB,MAAM,EAAE,CAEV;AAED,iEAAiE;AACjE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EAAE,GAChB;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CA8CxC;AAmLD,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAgGhF;AAaD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAkDjE"}
1
+ {"version":3,"file":"ios-scanner.d.ts","sourceRoot":"","sources":["../../../src/commands/ios-scanner.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,kBAAkB,GAAG,cAAc,GAAG,SAAS,GAAG,gBAAgB,GAAG,WAAW,CAAC;AAE7F,MAAM,MAAM,kBAAkB,GAAG,SAAS,GAAG,MAAM,CAAC;AAEpD,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB;;;OAGG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oDAAoD;IACpD,YAAY,EAAE,OAAO,CAAC;IACtB,0DAA0D;IAC1D,MAAM,EAAE,OAAO,CAAC;IAChB,+DAA+D;IAC/D,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iFAAiF;IACjF,eAAe,EAAE,MAAM,CAAC;CACzB;AAiFD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EAAE,GAChB,MAAM,EAAE,CAEV;AAED,iEAAiE;AACjE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EAAE,GAChB;IAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CA8CxC;AAkLD,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAgGhF;AAaD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAkDjE"}
@@ -1,5 +1,6 @@
1
1
  import { resolve, relative } from "node:path";
2
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { xcodeContainersIn } from "../shared/detect-project.js";
3
4
  // ---------------------------------------------------------------------------
4
5
  // File discovery
5
6
  // ---------------------------------------------------------------------------
@@ -18,17 +19,16 @@ function relPath(root, filePath) {
18
19
  * Finds the primary Xcode project path.
19
20
  * Prefers .xcworkspace over .xcodeproj (CocoaPods projects use workspace).
20
21
  * Returns the relative path from root, or empty string if not found.
22
+ *
23
+ * A candidate must be a real container — `isXcodeContainer` — and not merely
24
+ * a directory with the right suffix. `Uwi0/Oakane` ships `iosApp.xcodeproj`
25
+ * with no `project.pbxproj` beside the `oakane.xcodeproj` that has one, and
26
+ * this function returned the husk because it was first.
21
27
  */
22
28
  function findXcodeProjectPath(root) {
23
- let entries;
24
- try {
25
- entries = readdirSync(root);
26
- }
27
- catch {
28
- return "";
29
- }
30
- const workspaces = entries.filter((e) => e.endsWith(".xcworkspace"));
31
- const projects = entries.filter((e) => e.endsWith(".xcodeproj"));
29
+ const containers = xcodeContainersIn(root);
30
+ const workspaces = containers.filter((e) => e.endsWith(".xcworkspace"));
31
+ const projects = containers.filter((e) => e.endsWith(".xcodeproj"));
32
32
  // Prefer workspace (CocoaPods / multi-package setups use these)
33
33
  if (workspaces.length > 0)
34
34
  return workspaces[0];
@@ -43,14 +43,7 @@ function findXcodeProjectPath(root) {
43
43
  */
44
44
  const COMPANION_SCHEME = /(extension|widget|clip|intents?|notification|tvos|macos|watchos|visionos|tests?|screenshots?|staging|prototype|codegen)/i;
45
45
  function readSchemeFacts(root, name) {
46
- let entries;
47
- try {
48
- entries = readdirSync(root);
49
- }
50
- catch {
51
- return undefined;
52
- }
53
- for (const entry of entries) {
46
+ for (const entry of xcodeContainersIn(root)) {
54
47
  if (!entry.endsWith(".xcodeproj"))
55
48
  continue;
56
49
  const path = resolve(root, entry, "xcshareddata", "xcschemes", `${name}.xcscheme`);
@@ -194,13 +187,11 @@ export function rankSchemesWithReason(root, projectPath, schemes) {
194
187
  */
195
188
  function detectSchemes(root) {
196
189
  const schemes = new Set();
197
- let entries;
198
- try {
199
- entries = readdirSync(root);
200
- }
201
- catch {
202
- return [];
203
- }
190
+ // Real projects only. The fallback below invents a scheme from the
191
+ // directory's name, so a husk with no shared schemes contributes a scheme
192
+ // named after a project that cannot be opened — which is how `Uwi0/Oakane`
193
+ // got `IOS_SCHEME: iosApp` as well as `IOS_PROJECT_PATH: iosApp.xcodeproj`.
194
+ const entries = xcodeContainersIn(root);
204
195
  for (const entry of entries) {
205
196
  if (!entry.endsWith(".xcodeproj"))
206
197
  continue;