@invarn/cibuild 2.8.0 → 2.8.2

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.
@@ -138,6 +138,419 @@ 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
+ /**
320
+ * Placeholder names referenced in an `AndroidManifest.xml`, other than the ones
321
+ * the Android Gradle Plugin injects itself.
322
+ *
323
+ * **This is what the Secrets plugin is mostly for.** Its own documentation, and
324
+ * every Google Maps setup guide, puts the key in the manifest and nowhere else:
325
+ *
326
+ * <meta-data android:name="com.google.android.geo.API_KEY"
327
+ * android:value="${MAPS_API_KEY}" />
328
+ *
329
+ * There is no `BuildConfig` reference and no `manifestPlaceholders` block —
330
+ * the plugin reads `MAPS_API_KEY` from the properties file and substitutes it
331
+ * during manifest merging. A scan that reads only build files and source sees
332
+ * nothing at all in such a repository.
333
+ *
334
+ * `applicationId` is excluded because AGP supplies it from the variant. A name
335
+ * a build file *declares* in `manifestPlaceholders` is not excluded: the build
336
+ * supplies that one, so naming it costs the usual unused line, and telling the
337
+ * two apart would mean resolving which module's manifest merges with which
338
+ * module's build file.
339
+ */
340
+ const AGP_INJECTED_PLACEHOLDERS = new Set(["applicationId"]);
341
+ function extractManifestXmlPlaceholders(content) {
342
+ const names = [];
343
+ const re = /\$\{([A-Za-z_][A-Za-z0-9_.]*)\}/g;
344
+ let m;
345
+ while ((m = re.exec(content)) !== null) {
346
+ if (!AGP_INJECTED_PLACEHOLDERS.has(m[1]))
347
+ names.push(m[1]);
348
+ }
349
+ return names;
350
+ }
351
+ /**
352
+ * True when this build file loads the properties file itself, rather than
353
+ * leaving it to the Secrets plugin.
354
+ *
355
+ * Every spelling seen in the wild reduces to naming the file or calling a
356
+ * helper that names it: `rootProject.file("local.properties")`, AGP's own
357
+ * `gradleLocalProperties(rootDir)`, and Compose's `localPropertiesFile`.
358
+ */
359
+ function readsPropertiesFileDirectly(content) {
360
+ return (/\blocal\.properties\b/.test(content) ||
361
+ /\bgradleLocalProperties\s*\(/.test(content) ||
362
+ /\blocalPropertiesFile\b/.test(content));
363
+ }
364
+ /**
365
+ * Keys a build file reads out of a properties file it loaded itself.
366
+ *
367
+ * `getProperty("x")` is the direct form, with or without a default. The rest is
368
+ * for the idiom that hides it: a repository defines its own one-line accessor —
369
+ *
370
+ * def getProps(String name) {
371
+ * def f = rootProject.file('local.properties')
372
+ * if (f.exists()) { def p = new Properties(); p.load(...); return p[name] }
373
+ * return ""
374
+ * }
375
+ *
376
+ * — and every key then arrives as `getProps("sign.file")`, under a name chosen
377
+ * by that repository. So a function whose *body* reads the properties file is
378
+ * treated as an accessor for it, and the string literals at its call sites are
379
+ * keys. Without that, a repository like this reads as having none.
380
+ *
381
+ * `properties["x"]` is deliberately not matched: that is the Gradle *project*
382
+ * property channel — `gradle.properties` and `-P` — which `extractPropertyRefs`
383
+ * already reports, and which is not this file.
384
+ */
385
+ function extractDirectPropertyReads(content) {
386
+ const names = [];
387
+ const direct = /\.\s*getProperty\s*\(\s*["']([^"']+)["']/g;
388
+ let m;
389
+ while ((m = direct.exec(content)) !== null)
390
+ names.push(m[1]);
391
+ for (const accessor of propertiesAccessorNames(content)) {
392
+ const call = new RegExp(`\\b${escapeForRegExp(accessor)}\\s*\\(\\s*["']([^"']+)["']`, "g");
393
+ let c;
394
+ while ((c = call.exec(content)) !== null)
395
+ names.push(c[1]);
396
+ }
397
+ return names;
398
+ }
399
+ function escapeForRegExp(value) {
400
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
401
+ }
402
+ /** Functions declared in this file whose body reads the properties file. */
403
+ function propertiesAccessorNames(content) {
404
+ const names = [];
405
+ // Groovy `def name(...) {` and Kotlin `fun name(...) {`, which is how a
406
+ // build script declares one.
407
+ const decl = /\b(?:def|fun)\s+([A-Za-z_]\w*)\s*\([^)]*\)\s*(?::[^{]*)?\{/g;
408
+ let m;
409
+ while ((m = decl.exec(content)) !== null) {
410
+ // `matchingBrace` counts from inside the block, so hand it the index after
411
+ // the opening brace the declaration regex ended on.
412
+ const body = m.index + m[0].length;
413
+ const close = matchingBrace(content, body);
414
+ if (close === -1)
415
+ continue;
416
+ if (readsPropertiesFileDirectly(content.slice(body, close)))
417
+ names.push(m[1]);
418
+ }
419
+ return names;
420
+ }
421
+ /**
422
+ * Keys that name a location on the machine running the build, not a value the
423
+ * build needs supplied.
424
+ *
425
+ * Writing one into a generated file puts a developer's own SDK path onto a
426
+ * runner, where it is wrong — and Gradle finds the SDK from `ANDROID_HOME`
427
+ * when the key is absent, which is how every build on a runner resolves it.
428
+ * The Secrets plugin ignores `sdk.dir` for the same reason.
429
+ */
430
+ const MACHINE_LOCAL_KEYS = new Set(["sdk.dir", "ndk.dir", "cmake.dir"]);
431
+ // Bounds on the source walk, the way `findGradleFiles` bounds itself to the
432
+ // files a build actually declares. A repository is an unbounded tree and this
433
+ // runs on every scan of one.
434
+ const SOURCE_EXTENSIONS = [".kt", ".kts", ".java"];
435
+ const SOURCE_SKIP_DIRS = new Set(["build", "node_modules"]);
436
+ const SOURCE_FILE_CAP = 1500;
437
+ const SOURCE_DEPTH_CAP = 12;
438
+ /** Source files under `dir`, up to the shared file budget. */
439
+ function findSourceFiles(dir, budget, depth = 0) {
440
+ if (depth > SOURCE_DEPTH_CAP || budget.left <= 0)
441
+ return [];
442
+ let entries;
443
+ try {
444
+ entries = readdirSync(dir, { withFileTypes: true });
445
+ }
446
+ catch {
447
+ return [];
448
+ }
449
+ const files = [];
450
+ const subdirs = [];
451
+ for (const entry of entries) {
452
+ // Dot directories hold caches and VCS state, never source: `.git`,
453
+ // `.gradle`, `.idea`, `.kotlin`.
454
+ if (entry.name.startsWith("."))
455
+ continue;
456
+ if (entry.isDirectory()) {
457
+ if (!SOURCE_SKIP_DIRS.has(entry.name))
458
+ subdirs.push(join(dir, entry.name));
459
+ }
460
+ else if (SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
461
+ if (budget.left <= 0)
462
+ break;
463
+ budget.left--;
464
+ files.push(join(dir, entry.name));
465
+ }
466
+ }
467
+ for (const sub of subdirs)
468
+ files.push(...findSourceFiles(sub, budget, depth + 1));
469
+ return files;
470
+ }
471
+ /** The directories of `dirs` that no other member of `dirs` contains. */
472
+ function outermost(dirs) {
473
+ return dirs.filter((dir) => !dirs.some((other) => other !== dir && dir.startsWith(other + "/")));
474
+ }
475
+ /** Manifests under `dir`, up to the shared file budget. */
476
+ function findManifests(dir, budget, depth = 0) {
477
+ if (depth > SOURCE_DEPTH_CAP || budget.left <= 0)
478
+ return [];
479
+ let entries;
480
+ try {
481
+ entries = readdirSync(dir, { withFileTypes: true });
482
+ }
483
+ catch {
484
+ return [];
485
+ }
486
+ const found = [];
487
+ for (const entry of entries) {
488
+ if (entry.name.startsWith("."))
489
+ continue;
490
+ if (entry.isDirectory()) {
491
+ if (!SOURCE_SKIP_DIRS.has(entry.name)) {
492
+ found.push(...findManifests(join(dir, entry.name), budget, depth + 1));
493
+ }
494
+ }
495
+ else if (entry.name === "AndroidManifest.xml") {
496
+ if (budget.left <= 0)
497
+ break;
498
+ budget.left--;
499
+ found.push(join(dir, entry.name));
500
+ }
501
+ }
502
+ return found;
503
+ }
504
+ /**
505
+ * The property names the build reads out of the properties file — see
506
+ * `ScanResult.missingPropertyKeys`. Four sources, because a repository may use
507
+ * any of them and most use exactly one:
508
+ *
509
+ * - `${KEY}` in an `AndroidManifest.xml`, which the Secrets plugin fills. The
510
+ * commonest of the four by a distance, and the one its documentation shows.
511
+ * - `manifestPlaceholders` declared in a build file.
512
+ * - `BuildConfig.<FIELD>` in source, for a key read as a generated constant.
513
+ * - `getProperty("key")` in a build file that loads the properties file
514
+ * itself, for a build that does not use the plugin at all.
515
+ *
516
+ * Bounded to the modules that either apply the plugin or read the file
517
+ * directly. A module that does neither cannot be reading a key out of it.
518
+ */
519
+ function collectPropertyKeys(gradleFiles) {
520
+ // A module with both a `build.gradle` and a `build.gradle.kts` yields its
521
+ // directory twice, and walking it twice would spend the file budget twice.
522
+ const moduleDirs = new Set();
523
+ const keys = new Set();
524
+ for (const filePath of gradleFiles) {
525
+ const content = safeRead(filePath);
526
+ const plugin = appliesSecretsPlugin(content);
527
+ const direct = readsPropertiesFileDirectly(content);
528
+ if (!plugin && !direct)
529
+ continue;
530
+ moduleDirs.add(dirname(filePath));
531
+ for (const key of extractManifestPlaceholderKeys(content))
532
+ keys.add(key);
533
+ if (direct)
534
+ for (const key of extractDirectPropertyReads(content))
535
+ keys.add(key);
536
+ }
537
+ if (moduleDirs.size === 0)
538
+ return [];
539
+ const budget = { left: SOURCE_FILE_CAP };
540
+ for (const dir of outermost([...moduleDirs])) {
541
+ for (const file of findSourceFiles(dir, budget)) {
542
+ for (const name of extractBuildConfigFieldRefs(safeRead(file)))
543
+ keys.add(name);
544
+ }
545
+ for (const manifest of findManifests(dir, budget)) {
546
+ for (const name of extractManifestXmlPlaceholders(safeRead(manifest)))
547
+ keys.add(name);
548
+ }
549
+ }
550
+ for (const key of MACHINE_LOCAL_KEYS)
551
+ keys.delete(key);
552
+ return [...keys].sort();
553
+ }
141
554
  /** Parse property keys from a standard .properties file, skipping comments and blank lines. */
142
555
  function parsePropertyKeys(propertiesContent) {
143
556
  return propertiesContent
@@ -1070,6 +1483,16 @@ export async function scanAndroidProject(projectRoot) {
1070
1483
  }
1071
1484
  }
1072
1485
  // ------------------------------------------------------------------
1486
+ // 4b. Which keys that properties file has to declare
1487
+ // ------------------------------------------------------------------
1488
+ //
1489
+ // Section 4 says the file is absent; this says what is in it. The two are
1490
+ // separate because they answer different questions for different readers —
1491
+ // the warnings above tell a person a file is missing, and this tells a
1492
+ // generator what a stand-in for it has to contain.
1493
+ // ------------------------------------------------------------------
1494
+ const missingPropertyKeys = collectPropertyKeys(gradleFiles);
1495
+ // ------------------------------------------------------------------
1073
1496
  // 5. Firebase / GMS — check google-services.json after scanning all files
1074
1497
  // ------------------------------------------------------------------
1075
1498
  if (gmsDetected) {
@@ -1121,6 +1544,7 @@ export async function scanAndroidProject(projectRoot) {
1121
1544
  detectedJavaVersionSource,
1122
1545
  detectedGradleVersion,
1123
1546
  buildVariants,
1547
+ missingPropertyKeys,
1124
1548
  };
1125
1549
  }
1126
1550
  // ---------------------------------------------------------------------------
@@ -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;
@@ -30,6 +30,11 @@ afterEach(() => {
30
30
  function scheme(project, name, { runnable, testable, appExtension }) {
31
31
  const dir = join(root, `${project}.xcodeproj`, "xcshareddata", "xcschemes");
32
32
  mkdirSync(dir, { recursive: true });
33
+ // A `.xcodeproj` is its `project.pbxproj`; without one Xcode refuses to open
34
+ // it and the scanners now skip it, so a fixture that omits it is describing
35
+ // a directory that cannot exist outside a test. Content is irrelevant here —
36
+ // the scheme XML is what the ranking reads — but it has to be there.
37
+ writeFileSync(join(root, `${project}.xcodeproj`, "project.pbxproj"), "// !$*UTF8*$!\n");
33
38
  writeFileSync(join(dir, `${name}.xcscheme`), `<?xml version="1.0" encoding="UTF-8"?>
34
39
  <Scheme LastUpgradeVersion = "1600"${appExtension ? `\n wasCreatedForAppExtension = "YES"` : ""} version = "1.7">
35
40
  <TestAction buildConfiguration = "Debug">
@@ -1,5 +1,37 @@
1
1
  export type MobileProjectType = "android" | "ios" | "kmm";
2
2
  declare function isDirectory(path: string): boolean;
3
+ /**
4
+ * True when a path that is *named* like an Xcode container actually is one.
5
+ *
6
+ * **The** predicate — every question of the form "is this `.xcodeproj` a
7
+ * project" goes through here, and nothing else in cibuild joins a directory
8
+ * name to "there is a project there".
9
+ *
10
+ * A `.xcodeproj` is a directory whose whole content is `project.pbxproj`;
11
+ * without it `xcodebuild` refuses to open the project at all — "missing its
12
+ * project.pbxproj file". A `.xcworkspace` is `contents.xcworkspacedata` in
13
+ * the same way. Anything else ending in those suffixes is a directory with a
14
+ * suggestive name, and offering one is offering something Xcode would reject.
15
+ *
16
+ * `Uwi0/Oakane` is the row that found it. Its `iosApp/` ships two: a tracked
17
+ * `iosApp.xcodeproj` holding nothing but `project.xcworkspace/`, and the real
18
+ * `oakane.xcodeproj` beside it with a 20 217-byte `project.pbxproj`. The husk
19
+ * carries the name the KMM wizard gives, so the conventional name outranked
20
+ * the project that exists — and it supplied BOTH `IOS_PROJECT_PATH` and, via
21
+ * the scheme scan's project-name fallback, `IOS_SCHEME`. The build reached
22
+ * step 9 and died on "Unable to read project 'iosApp.xcodeproj'".
23
+ *
24
+ * This is the iOS twin of the Gradle module scan once treating "a directory
25
+ * holding a file called build.gradle" as a module: wrong in the same
26
+ * direction, and fixed the same way — one reader, used by everyone.
27
+ *
28
+ * The `.pbxproj` is deliberately NOT parsed. Its existence and non-emptiness
29
+ * is the whole question; a shared `.xcscheme` remains the oracle for what a
30
+ * project builds.
31
+ */
32
+ export declare function isXcodeContainer(path: string): boolean;
33
+ /** The entries of `dir` that are real Xcode containers, in `readdir` order. */
34
+ export declare function xcodeContainersIn(dir: string): string[];
3
35
  /**
4
36
  * Detects whether the given directory is the root of an Android, iOS, or
5
37
  * KMM project. Returns the detected project type, or null if none match.
@@ -1 +1 @@
1
- {"version":3,"file":"detect-project.d.ts","sourceRoot":"","sources":["../../../src/shared/detect-project.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,KAAK,GAAG,KAAK,CAAC;AAoB1D,iBAAS,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAM1C;AA+ED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAiC7E;AAED,oEAAoE;AACpE,OAAO,EAAE,WAAW,EAAE,CAAC"}
1
+ {"version":3,"file":"detect-project.d.ts","sourceRoot":"","sources":["../../../src/shared/detect-project.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,KAAK,GAAG,KAAK,CAAC;AAoB1D,iBAAS,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAM1C;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAYtD;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAYvD;AAuED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CA4B7E;AAED,oEAAoE;AACpE,OAAO,EAAE,WAAW,EAAE,CAAC"}