@invarn/cibuild 2.6.6 → 2.6.8

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 (36) hide show
  1. package/dist/cli.cjs +10 -10
  2. package/dist/src/commands/android-java-version.test.js +59 -18
  3. package/dist/src/commands/android-scanner-java.test.d.ts +2 -0
  4. package/dist/src/commands/android-scanner-java.test.d.ts.map +1 -0
  5. package/dist/src/commands/android-scanner-java.test.js +130 -0
  6. package/dist/src/commands/android-scanner.d.ts +52 -11
  7. package/dist/src/commands/android-scanner.d.ts.map +1 -1
  8. package/dist/src/commands/android-scanner.js +199 -37
  9. package/dist/src/commands/ios-scanner.d.ts +42 -1
  10. package/dist/src/commands/ios-scanner.d.ts.map +1 -1
  11. package/dist/src/commands/ios-scanner.js +123 -3
  12. package/dist/src/commands/ios-scheme-ranking.test.d.ts +2 -0
  13. package/dist/src/commands/ios-scheme-ranking.test.d.ts.map +1 -0
  14. package/dist/src/commands/ios-scheme-ranking.test.js +152 -0
  15. package/dist/src/shared/detect-project.d.ts +15 -3
  16. package/dist/src/shared/detect-project.d.ts.map +1 -1
  17. package/dist/src/shared/detect-project.js +126 -15
  18. package/dist/src/shared/detect-project.test.d.ts +2 -0
  19. package/dist/src/shared/detect-project.test.d.ts.map +1 -0
  20. package/dist/src/shared/detect-project.test.js +126 -0
  21. package/dist/src/shared/gradle-settings.d.ts +32 -0
  22. package/dist/src/shared/gradle-settings.d.ts.map +1 -0
  23. package/dist/src/shared/gradle-settings.js +66 -0
  24. package/dist/src/yaml/steps/android-variant-resolution.test.d.ts +2 -0
  25. package/dist/src/yaml/steps/android-variant-resolution.test.d.ts.map +1 -0
  26. package/dist/src/yaml/steps/android-variant-resolution.test.js +117 -0
  27. package/dist/src/yaml/steps/android.d.ts +37 -0
  28. package/dist/src/yaml/steps/android.d.ts.map +1 -1
  29. package/dist/src/yaml/steps/android.js +130 -17
  30. package/dist/src/yaml/steps/base.d.ts +20 -0
  31. package/dist/src/yaml/steps/base.d.ts.map +1 -1
  32. package/dist/src/yaml/steps/base.js +24 -1
  33. package/dist/src/yaml/steps/java-home-version-spec.test.d.ts +10 -0
  34. package/dist/src/yaml/steps/java-home-version-spec.test.d.ts.map +1 -0
  35. package/dist/src/yaml/steps/java-home-version-spec.test.js +58 -0
  36. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { resolve, relative } from "node:path";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { parseSettingsModules } from "../shared/gradle-settings.js";
3
4
  // ---------------------------------------------------------------------------
4
5
  // File discovery
5
6
  // ---------------------------------------------------------------------------
@@ -23,11 +24,22 @@ function findGradleFiles(root) {
23
24
  if (!existsSync(p))
24
25
  continue;
25
26
  const content = readFileSync(p, "utf-8");
26
- // Matches: include ':module', include(":module"), include ':a', ':b'
27
- const includeRe = /include\s*[\(']?\s*['"]:([^'")\s,]+)/g;
28
- let m;
29
- while ((m = includeRe.exec(content)) !== null) {
30
- moduleDirs.add(m[1].replace(/:/g, "/"));
27
+ // One parser, shared with the detector. The regex that used to live here
28
+ // matched only the *first* argument of an `include`, so
29
+ // `include(":android", ":common", …)` — one call, eight modules in
30
+ // `SimonSchubert/LinuxCommandLibrary` — was read as one, and the module
31
+ // holding `jvmToolchain(21)` was never opened.
32
+ for (const mod of parseSettingsModules(content))
33
+ moduleDirs.add(mod);
34
+ // An included build is a Gradle project of its own, and a monorepo's
35
+ // convention plugins live in one. Its build file is where the Java level
36
+ // for every module it configures is often declared.
37
+ for (const included of parseIncludedBuilds(content)) {
38
+ for (const buildFile of ["build.gradle", "build.gradle.kts"]) {
39
+ const q = resolve(root, included, buildFile);
40
+ if (existsSync(q))
41
+ candidates.push(q);
42
+ }
31
43
  }
32
44
  }
33
45
  for (const mod of moduleDirs) {
@@ -254,11 +266,16 @@ function extractKeystorePaths(content) {
254
266
  * Handles formats: "21", "17", "1.8" (→ 8), "VERSION_21", "VERSION_1_8" (→ 8).
255
267
  */
256
268
  function parseJavaVersion(raw) {
269
+ // `JvmTarget.fromTarget("17")` names the version inside a string.
270
+ const fromTarget = /fromTarget\s*\(\s*["']([^"']+)["']/.exec(raw);
271
+ if (fromTarget)
272
+ return parseJavaVersion(fromTarget[1]);
257
273
  // JavaVersion enum: VERSION_21, VERSION_17, VERSION_1_8
258
- const enumMatch = /VERSION_1_(\d)$/.exec(raw);
274
+ // JvmTarget enum: JVM_21, JVM_17, JVM_1_8
275
+ const enumMatch = /(?:VERSION|JVM)_1_(\d)$/.exec(raw);
259
276
  if (enumMatch)
260
277
  return parseInt(enumMatch[1], 10);
261
- const enumMatch2 = /VERSION_(\d+)$/.exec(raw);
278
+ const enumMatch2 = /(?:VERSION|JVM)_(\d+)$/.exec(raw);
262
279
  if (enumMatch2)
263
280
  return parseInt(enumMatch2[1], 10);
264
281
  // String literal: "21", "17", "11", "1.8"
@@ -290,8 +307,21 @@ function detectJavaVersionInContent(content) {
290
307
  if (v)
291
308
  candidates.push(v);
292
309
  }
293
- // jvmTarget = "21" (inside kotlinOptions or compileOptions)
294
- const jvmTargetRe = /jvmTarget\s*=\s*["']([^"']+)["']/g;
310
+ // jvmTarget, in every form a Kotlin build writes it:
311
+ // jvmTarget = "21" (kotlinOptions, Groovy)
312
+ // jvmTarget.set(JvmTarget.JVM_17) (compilerOptions, the form
313
+ // LinuxCommandLibrary uses
314
+ // everywhere and we read in
315
+ // none of them)
316
+ // jvmTarget = JvmTarget.JVM_21
317
+ // jvmTarget.set(JvmTarget.fromTarget("17"))
318
+ const jvmTargetFromTargetRe = /jvmTarget\s*(?:=|\.set\s*\(\s*)\s*JvmTarget\.fromTarget\s*\(\s*["']([^"']+)["']/g;
319
+ while ((m = jvmTargetFromTargetRe.exec(content)) !== null) {
320
+ const v = parseJavaVersion(m[1]);
321
+ if (v)
322
+ candidates.push(v);
323
+ }
324
+ const jvmTargetRe = /jvmTarget\s*(?:=|\.set\s*\(\s*)\s*(?:JvmTarget\.)?["']?([A-Za-z0-9._]+)["']?/g;
295
325
  while ((m = jvmTargetRe.exec(content)) !== null) {
296
326
  const v = parseJavaVersion(m[1]);
297
327
  if (v)
@@ -309,6 +339,92 @@ function detectJavaVersionInContent(content) {
309
339
  }
310
340
  return candidates.length > 0 ? Math.max(...candidates) : undefined;
311
341
  }
342
+ /** The builds a settings file composes in, e.g. `includeBuild("build-logic")`. */
343
+ export function parseIncludedBuilds(content) {
344
+ if (!content || typeof content !== "string")
345
+ return [];
346
+ const builds = [];
347
+ for (const m of content.matchAll(/includeBuild\s*\(?\s*["']([^"']+)["']/g)) {
348
+ if (!builds.includes(m[1]))
349
+ builds.push(m[1]);
350
+ }
351
+ return builds;
352
+ }
353
+ /**
354
+ * A Java version pinned outside Gradle, and the file that pins it.
355
+ *
356
+ * `mihonapp/mihon` declares no Java level in any Gradle file and pins 21 in
357
+ * `.github/.java-version`, which its own workflows consume through
358
+ * `setup-java`'s `java-version-file:`. The project says what it needs; it
359
+ * just does not say it in Gradle, and reading only Gradle files scanned it as
360
+ * "nothing declared" and ran it on the default. Across the thirty clones,
361
+ * three repositories pin with a version file and ten name a version in a
362
+ * workflow.
363
+ *
364
+ * A pin is a **floor**, folded in with `Math.max` like every other
365
+ * declaration — not an override.
366
+ */
367
+ export function detectPinnedJavaVersion(root) {
368
+ const found = [];
369
+ const offer = (version, source) => {
370
+ if (version !== undefined && version > 0)
371
+ found.push({ version, source });
372
+ };
373
+ for (const name of [".java-version", ".github/.java-version"]) {
374
+ const content = safeRead(resolve(root, name));
375
+ if (content)
376
+ offer(parseJavaVersion(content.trim().split("\n")[0] ?? ""), name);
377
+ }
378
+ // asdf / mise: `java temurin-21.0.3+9` — the major version is what matters.
379
+ const toolVersions = safeRead(resolve(root, ".tool-versions"));
380
+ if (toolVersions) {
381
+ const m = /^\s*java\s+(\S+)/m.exec(toolVersions);
382
+ if (m)
383
+ offer(parseJavaVersion(m[1].replace(/^[A-Za-z-]+-/, "")), ".tool-versions");
384
+ }
385
+ // SDKMAN: `java=17.0.9-tem`
386
+ const sdkmanrc = safeRead(resolve(root, ".sdkmanrc"));
387
+ if (sdkmanrc) {
388
+ const m = /^\s*java\s*=\s*(\S+)/m.exec(sdkmanrc);
389
+ if (m)
390
+ offer(parseJavaVersion(m[1]), ".sdkmanrc");
391
+ }
392
+ for (const workflow of listWorkflowFiles(root)) {
393
+ const content = safeRead(workflow);
394
+ if (!content)
395
+ continue;
396
+ const name = relative(root, workflow);
397
+ // `java-version: 21`, `java-version: '21'`, and a matrix list — take the
398
+ // highest, because that is the one that has to work.
399
+ for (const m of content.matchAll(/java-version\s*:\s*(.+)/g)) {
400
+ for (const token of m[1].split(/[,[\]\s]+/)) {
401
+ const cleaned = token.replace(/["']/g, "").trim();
402
+ if (cleaned)
403
+ offer(parseJavaVersion(cleaned), name);
404
+ }
405
+ }
406
+ // `java-version-file: .github/.java-version` — read what it points at.
407
+ for (const m of content.matchAll(/java-version-file\s*:\s*["']?([^"'\s]+)/g)) {
408
+ const pinned = safeRead(resolve(root, m[1]));
409
+ if (pinned)
410
+ offer(parseJavaVersion(pinned.trim().split("\n")[0] ?? ""), m[1]);
411
+ }
412
+ }
413
+ if (found.length === 0)
414
+ return undefined;
415
+ return found.reduce((a, b) => (b.version > a.version ? b : a));
416
+ }
417
+ function listWorkflowFiles(root) {
418
+ const dir = resolve(root, ".github", "workflows");
419
+ try {
420
+ return readdirSync(dir)
421
+ .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
422
+ .map((f) => resolve(dir, f));
423
+ }
424
+ catch {
425
+ return [];
426
+ }
427
+ }
312
428
  /**
313
429
  * Returns the highest Java version requirement found across all Gradle files,
314
430
  * or undefined if none is detected.
@@ -350,20 +466,32 @@ function detectGradleWrapperVersion(projectRoot) {
350
466
  * when the version string says nothing usable.
351
467
  */
352
468
  export function gradleJavaCeiling(gradleVersion) {
353
- const major = Number(/^(\d+)\./.exec(gradleVersion ?? "")?.[1]);
469
+ const parsed = /^(\d+)(?:\.(\d+))?/.exec(gradleVersion ?? "");
470
+ const major = parsed ? Number(parsed[1]) : NaN;
354
471
  if (!Number.isInteger(major))
355
472
  return undefined;
356
- if (major <= 4)
357
- return 8;
358
- if (major === 5)
359
- return 11;
360
- if (major === 6)
361
- return 15;
362
- if (major === 7)
363
- return 17;
364
- if (major === 8)
473
+ const minor = Number(parsed?.[2] ?? 0);
474
+ const atLeast = (m, n) => major > m || (major === m && minor >= n);
475
+ // Minor-aware, because the boundaries are minor releases. While 17 was the
476
+ // preferred JDK this was harmless — the ceiling only had to be right about
477
+ // whether 17 fitted. Preferring the newest makes it load-bearing: JDK 21
478
+ // arrived in Gradle 8.5, so 8.0 through 8.4 answering "21" would hand those
479
+ // projects a JDK their own wrapper refuses to start on.
480
+ // Gradle 9.1 added JDK 25; 9.0 tops out at 24. Without this the six corpus
481
+ // repositories declaring a level above 21 would still abstain, because a
482
+ // floor of 25 against a ceiling of 24 intersects with nothing — installing
483
+ // JDK 25 buys nothing if no ceiling ever reaches it.
484
+ if (atLeast(9, 1))
485
+ return 25;
486
+ if (major >= 9)
487
+ return 24;
488
+ if (atLeast(8, 5))
365
489
  return 21;
366
- return 24;
490
+ if (atLeast(7, 3))
491
+ return 17;
492
+ if (atLeast(5, 0))
493
+ return 11;
494
+ return 8;
367
495
  }
368
496
  /** The oldest Java release a given Gradle version will start on. */
369
497
  export function gradleJavaFloor(gradleVersion) {
@@ -375,36 +503,63 @@ export function gradleJavaFloor(gradleVersion) {
375
503
  /**
376
504
  * The JDKs a build machine installs, and therefore the only values the
377
505
  * generated `set-java-version` step can satisfy.
506
+ *
507
+ * 11 joined the images on 2026-09-05, for Gradle 5.x and 6.x — which cannot
508
+ * *start* on 17, whatever the sources declare. It is a fallback, never a
509
+ * preference: the selection takes the newest JDK that fits, so 11 is reached
510
+ * only when a wrapper's ceiling leaves nothing else.
511
+ *
512
+ * 8 and 25 joined on 2026-09-07, the companion half of the "newest the
513
+ * wrapper allows" decision — the images carry every JDK we find a project
514
+ * asking for, rather than a project bending to what they carry. 8 is 11's
515
+ * argument one line further back (Gradle 4.x tops out at Java 8); 25 covers
516
+ * the six repositories in the 150-repo corpus that declare a level above 21.
517
+ * Nothing reaches 8 unless a wrapper ceiling forces it.
518
+ */
519
+ export const AVAILABLE_JAVA_VERSIONS = [8, 11, 17, 21, 25];
520
+ /**
521
+ * The newest JDK a build machine carries. Named because it is what a
522
+ * generated pipeline falls back to when no JDK can satisfy the constraints —
523
+ * a pipeline has to write something, and `javaVersionProblem` says what was
524
+ * given up.
378
525
  */
379
- export const AVAILABLE_JAVA_VERSIONS = [17, 21];
526
+ export const NEWEST_AVAILABLE_JAVA_VERSION = Math.max(...AVAILABLE_JAVA_VERSIONS);
380
527
  /**
381
- * The Java version to write into a generated pipeline: the oldest available
382
- * JDK that satisfies both what the sources declare and what the Gradle
383
- * wrapper can run on.
528
+ * The Java version to write into a generated pipeline: the **newest**
529
+ * available JDK that satisfies both what the sources declare and what the
530
+ * Gradle wrapper can run on.
384
531
  *
385
- * Source compatibility is a floor, not an exact request — a project
386
- * declaring 11 compiles fine on 17 — but it is not the only constraint.
387
- * Gradle starts before it reads a single `sourceCompatibility`, so a wrapper
388
- * pinning an old Gradle caps the JDK no matter what the sources say, and a
389
- * declaration above every installed JDK cannot be honoured at all.
532
+ * DECIDED 2026-09-06 (Alex), reversing Invarn PRD decision #15 ("17 is the
533
+ * JDK whenever nothing forces otherwise"). The thirty-app study measured the
534
+ * cost of preferring 17 in one direction and none in the other: two rows
535
+ * failed because 17 was too *low* for a build plugin's own runtime
536
+ * requirement, and no row failed because 21 was too high. A project targeting
537
+ * 17 runs fine on 21; never the reverse.
390
538
  *
391
- * Falls back to the lowest available JDK when the constraints cannot all be
392
- * met, because a pipeline has to name something; `javaVersionProblem` says
393
- * what was given up so the caller can warn.
539
+ * Source compatibility is a floor, not an exact request. Gradle starts before
540
+ * it reads a single `sourceCompatibility`, so a wrapper pinning an old Gradle
541
+ * caps the JDK no matter what the sources say — which is why the ceiling
542
+ * above had to learn minor versions before this could ship.
394
543
  */
395
544
  export function runnableJavaVersion(detectedJavaVersion, gradleVersion) {
396
- const lowest = Math.min(...AVAILABLE_JAVA_VERSIONS);
397
545
  const ceiling = gradleJavaCeiling(gradleVersion) ?? Infinity;
398
546
  const floor = Math.max(detectedJavaVersion ?? 0, gradleJavaFloor(gradleVersion) ?? 0);
399
547
  const usable = AVAILABLE_JAVA_VERSIONS.filter((v) => v >= floor && v <= ceiling).sort((a, b) => a - b);
400
- return usable.length > 0 ? usable[0] : lowest;
548
+ if (usable.length === 0)
549
+ return NEWEST_AVAILABLE_JAVA_VERSION;
550
+ return usable[usable.length - 1];
401
551
  }
402
552
  /**
403
553
  * What `runnableJavaVersion` could not honour, as a sentence, or undefined
404
554
  * when it honoured everything.
405
555
  */
406
556
  export function javaVersionProblem(detectedJavaVersion, gradleVersion) {
407
- const installed = AVAILABLE_JAVA_VERSIONS.join(" and ");
557
+ // "11, 17 and 21", not "11 and 17 and 21" — a human reads this next to a
558
+ // build that will not start.
559
+ const sorted = [...AVAILABLE_JAVA_VERSIONS].sort((a, b) => a - b);
560
+ const installed = sorted.length > 1
561
+ ? `${sorted.slice(0, -1).join(", ")} and ${sorted[sorted.length - 1]}`
562
+ : String(sorted[0]);
408
563
  const ceiling = gradleJavaCeiling(gradleVersion);
409
564
  const lowest = Math.min(...AVAILABLE_JAVA_VERSIONS);
410
565
  if (ceiling !== undefined && ceiling < lowest) {
@@ -554,7 +709,14 @@ export async function scanAndroidProject(projectRoot) {
554
709
  // 2. Detect required Java version and build variants
555
710
  // ------------------------------------------------------------------
556
711
  const gradleFiles = findGradleFiles(projectRoot);
557
- const detectedJavaVersion = detectRequiredJavaVersion(gradleFiles);
712
+ const declaredJavaVersion = detectRequiredJavaVersion(gradleFiles);
713
+ // A version file or a workflow pin is a floor like any declaration, and for
714
+ // a project that declares nothing in Gradle it is the only thing that says
715
+ // which JDK the build needs.
716
+ const pinnedJava = detectPinnedJavaVersion(projectRoot);
717
+ const detectedJavaVersion = declaredJavaVersion === undefined && pinnedJava === undefined
718
+ ? undefined
719
+ : Math.max(declaredJavaVersion ?? 0, pinnedJava?.version ?? 0);
558
720
  const detectedGradleVersion = detectGradleWrapperVersion(projectRoot);
559
721
  const buildVariants = detectBuildVariants(gradleFiles);
560
722
  // ------------------------------------------------------------------
@@ -9,8 +9,13 @@ export interface IosWarning {
9
9
  }
10
10
  export interface IosScanResult {
11
11
  warnings: IosWarning[];
12
- /** Schemes detected from .xcscheme files inside .xcodeproj. Falls back to project name. */
12
+ /**
13
+ * Schemes detected from .xcscheme files inside .xcodeproj, **best first**.
14
+ * Falls back to the project name. Callers write `[0]` into IOS_SCHEME.
15
+ */
13
16
  detectedSchemes: string[];
17
+ /** Why `detectedSchemes[0]` is first, when more than one was found. */
18
+ schemeChoiceReason?: string;
14
19
  /** True if a Podfile exists in the project root. */
15
20
  hasCocoaPods: boolean;
16
21
  /** True if a Package.swift exists in the project root. */
@@ -22,6 +27,42 @@ export interface IosScanResult {
22
27
  /** DEVELOPMENT_TEAM value from project.pbxproj, or empty string if not found. */
23
28
  developmentTeam: string;
24
29
  }
30
+ /**
31
+ * The detected schemes, best first.
32
+ *
33
+ * `detectedSchemes[0]` is what every caller writes into `IOS_SCHEME`, and it
34
+ * used to be whatever `readdirSync` returned first. A space (0x20) sorts
35
+ * before a period (0x2E), so `"<App> <Suffix>.xcscheme"` **always** lists
36
+ * before `"<App>.xcscheme"`: an app named after its own project loses to its
37
+ * extension or its tvOS companion every time. Four of the eight iOS rows that
38
+ * built in the thirty-app study were given a non-app scheme that way — Stay
39
+ * built its extension and reported "no test bundles available to test";
40
+ * Swiftfin built its tvOS scheme and could not find an iOS Simulator
41
+ * destination.
42
+ *
43
+ * The order, in the terms the scheme XML actually states:
44
+ *
45
+ * 1. named after the project or workspace — the commonest case, and
46
+ * unambiguous;
47
+ * 2. launches the product named after the project, and is not named like a
48
+ * companion. `thebaselab/codeapp` is why this outranks the next one: its
49
+ * `Code App` scheme launches `Code.app` and declares no tests at all,
50
+ * while `Code UI` has two test bundles and launches `CodeUI.app`;
51
+ * 3. launches an `.app`, declares tests (the template runs them), and is not
52
+ * named like a companion; among several, the iOS or Debug one, then the
53
+ * shortest;
54
+ * 4. launches an `.app` and is not named like a companion;
55
+ * 5. whatever order it came in.
56
+ *
57
+ * Every scheme stays in the list — this reorders, it does not filter, so a
58
+ * caller wanting the extension can still find it.
59
+ */
60
+ export declare function rankSchemes(root: string, projectPath: string, schemes: string[]): string[];
61
+ /** The ranking, plus the sentence explaining its first entry. */
62
+ export declare function rankSchemesWithReason(root: string, projectPath: string, schemes: string[]): {
63
+ schemes: string[];
64
+ reason?: string;
65
+ };
25
66
  export declare function scanIosProject(projectRoot: string): Promise<IosScanResult>;
26
67
  export declare function formatIosScanResult(result: IosScanResult): string;
27
68
  //# sourceMappingURL=ios-scanner.d.ts.map
@@ -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,2FAA2F;IAC3F,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,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;AAyND,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAqFhF;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":"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;AA8ED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;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,CA4CxC;AAmLD,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAgGhF;AAaD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAkDjE"}
@@ -36,6 +36,115 @@ function findXcodeProjectPath(root) {
36
36
  return projects[0];
37
37
  return "";
38
38
  }
39
+ /**
40
+ * Names a scheme carries when it builds something beside the app rather than
41
+ * the app: an extension, a widget, a watch or TV companion, a test or
42
+ * screenshot harness. Matched case-insensitively on the whole name.
43
+ */
44
+ const COMPANION_SCHEME = /(extension|widget|clip|intents?|notification|tvos|macos|watchos|visionos|tests?|screenshots?|staging|prototype|codegen)/i;
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) {
54
+ if (!entry.endsWith(".xcodeproj"))
55
+ continue;
56
+ const path = resolve(root, entry, "xcshareddata", "xcschemes", `${name}.xcscheme`);
57
+ if (!existsSync(path))
58
+ continue;
59
+ const xml = safeRead(path);
60
+ if (!xml)
61
+ continue;
62
+ const launch = /<LaunchAction[\s\S]*?<\/LaunchAction>/.exec(xml)?.[0] ?? "";
63
+ return {
64
+ runnable: /BuildableName\s*=\s*"([^"]+)"/.exec(launch)?.[1],
65
+ testable: /<TestableReference/.test(xml),
66
+ };
67
+ }
68
+ return undefined;
69
+ }
70
+ /**
71
+ * The detected schemes, best first.
72
+ *
73
+ * `detectedSchemes[0]` is what every caller writes into `IOS_SCHEME`, and it
74
+ * used to be whatever `readdirSync` returned first. A space (0x20) sorts
75
+ * before a period (0x2E), so `"<App> <Suffix>.xcscheme"` **always** lists
76
+ * before `"<App>.xcscheme"`: an app named after its own project loses to its
77
+ * extension or its tvOS companion every time. Four of the eight iOS rows that
78
+ * built in the thirty-app study were given a non-app scheme that way — Stay
79
+ * built its extension and reported "no test bundles available to test";
80
+ * Swiftfin built its tvOS scheme and could not find an iOS Simulator
81
+ * destination.
82
+ *
83
+ * The order, in the terms the scheme XML actually states:
84
+ *
85
+ * 1. named after the project or workspace — the commonest case, and
86
+ * unambiguous;
87
+ * 2. launches the product named after the project, and is not named like a
88
+ * companion. `thebaselab/codeapp` is why this outranks the next one: its
89
+ * `Code App` scheme launches `Code.app` and declares no tests at all,
90
+ * while `Code UI` has two test bundles and launches `CodeUI.app`;
91
+ * 3. launches an `.app`, declares tests (the template runs them), and is not
92
+ * named like a companion; among several, the iOS or Debug one, then the
93
+ * shortest;
94
+ * 4. launches an `.app` and is not named like a companion;
95
+ * 5. whatever order it came in.
96
+ *
97
+ * Every scheme stays in the list — this reorders, it does not filter, so a
98
+ * caller wanting the extension can still find it.
99
+ */
100
+ export function rankSchemes(root, projectPath, schemes) {
101
+ return rankSchemesWithReason(root, projectPath, schemes).schemes;
102
+ }
103
+ /** The ranking, plus the sentence explaining its first entry. */
104
+ export function rankSchemesWithReason(root, projectPath, schemes) {
105
+ if (schemes.length < 2)
106
+ return { schemes };
107
+ const projectName = projectPath.replace(/\.(xcodeproj|xcworkspace)$/, "").split("/").pop();
108
+ const scored = schemes.map((name, index) => {
109
+ const facts = readSchemeFacts(root, name);
110
+ const companion = COMPANION_SCHEME.test(name);
111
+ const app = facts?.runnable?.endsWith(".app") ?? false;
112
+ let tier = 4;
113
+ if (name === projectName)
114
+ tier = 0;
115
+ else if (facts?.runnable === `${projectName}.app` && !companion)
116
+ tier = 1;
117
+ else if (app && facts?.testable && !companion)
118
+ tier = 2;
119
+ else if (app && !companion)
120
+ tier = 3;
121
+ return {
122
+ name,
123
+ index,
124
+ tier,
125
+ // Within a tier: an iOS or Debug variant first, then the shorter name.
126
+ preferred: /(^|[^A-Za-z])(iOS|Debug)([^A-Za-z]|$)/i.test(name) ? 0 : 1,
127
+ length: name.length,
128
+ };
129
+ });
130
+ scored.sort((a, b) => a.tier - b.tier ||
131
+ a.preferred - b.preferred ||
132
+ a.length - b.length ||
133
+ a.index - b.index);
134
+ const best = scored[0];
135
+ const others = scored.length - 1;
136
+ const why = [
137
+ "named after the project",
138
+ `launches ${projectName}.app`,
139
+ "the app scheme that runs the tests",
140
+ "the only app scheme that is not a companion",
141
+ "first in the list, and nothing here separates them",
142
+ ][best.tier];
143
+ return {
144
+ schemes: scored.map((s) => s.name),
145
+ reason: `${best.name} — ${why}; ${others} other scheme${others === 1 ? "" : "s"} here`,
146
+ };
147
+ }
39
148
  /**
40
149
  * Detects Xcode schemes by reading *.xcscheme files from the
41
150
  * xcshareddata/xcschemes/ directory inside a .xcodeproj bundle.
@@ -209,8 +318,10 @@ export async function scanIosProject(projectRoot) {
209
318
  const warnings = [];
210
319
  // 1. Find project path
211
320
  const projectPath = findXcodeProjectPath(projectRoot);
212
- // 2. Detect schemes
213
- const detectedSchemes = detectSchemes(projectRoot);
321
+ // 2. Detect schemes, best first — `detectedSchemes[0]` is what becomes
322
+ // IOS_SCHEME, and the filesystem's order is not a ranking.
323
+ const ranked = rankSchemesWithReason(projectRoot, projectPath, detectSchemes(projectRoot));
324
+ const detectedSchemes = ranked.schemes;
214
325
  // 3. CocoaPods
215
326
  const hasCocoaPods = existsSync(resolve(projectRoot, "Podfile"));
216
327
  // 4. SPM
@@ -278,7 +389,16 @@ export async function scanIosProject(projectRoot) {
278
389
  });
279
390
  }
280
391
  }
281
- return { warnings, detectedSchemes, hasCocoaPods, hasSPM, projectPath, hasSigningConfig, developmentTeam };
392
+ return {
393
+ warnings,
394
+ detectedSchemes,
395
+ ...(ranked.reason ? { schemeChoiceReason: ranked.reason } : {}),
396
+ hasCocoaPods,
397
+ hasSPM,
398
+ projectPath,
399
+ hasSigningConfig,
400
+ developmentTeam,
401
+ };
282
402
  }
283
403
  // ---------------------------------------------------------------------------
284
404
  // Formatter
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ios-scheme-ranking.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ios-scheme-ranking.test.d.ts","sourceRoot":"","sources":["../../../src/commands/ios-scheme-ranking.test.ts"],"names":[],"mappings":""}