@invarn/cibuild 2.6.7 → 2.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +10 -10
- package/dist/src/commands/android-java-version.test.js +55 -24
- package/dist/src/commands/android-scanner-java.test.d.ts +2 -0
- package/dist/src/commands/android-scanner-java.test.d.ts.map +1 -0
- package/dist/src/commands/android-scanner-java.test.js +130 -0
- package/dist/src/commands/android-scanner.d.ts +47 -22
- package/dist/src/commands/android-scanner.d.ts.map +1 -1
- package/dist/src/commands/android-scanner.js +187 -52
- package/dist/src/commands/ios-scanner.d.ts +42 -1
- package/dist/src/commands/ios-scanner.d.ts.map +1 -1
- package/dist/src/commands/ios-scanner.js +123 -3
- package/dist/src/commands/ios-scheme-ranking.test.d.ts +2 -0
- package/dist/src/commands/ios-scheme-ranking.test.d.ts.map +1 -0
- package/dist/src/commands/ios-scheme-ranking.test.js +152 -0
- package/dist/src/shared/detect-project.d.ts +15 -3
- package/dist/src/shared/detect-project.d.ts.map +1 -1
- package/dist/src/shared/detect-project.js +126 -15
- package/dist/src/shared/detect-project.test.d.ts +2 -0
- package/dist/src/shared/detect-project.test.d.ts.map +1 -0
- package/dist/src/shared/detect-project.test.js +126 -0
- package/dist/src/shared/gradle-settings.d.ts +32 -0
- package/dist/src/shared/gradle-settings.d.ts.map +1 -0
- package/dist/src/shared/gradle-settings.js +66 -0
- package/dist/src/yaml/step-validator-self-assigned.test.d.ts +23 -0
- package/dist/src/yaml/step-validator-self-assigned.test.d.ts.map +1 -0
- package/dist/src/yaml/step-validator-self-assigned.test.js +129 -0
- package/dist/src/yaml/step-validator.d.ts +32 -0
- package/dist/src/yaml/step-validator.d.ts.map +1 -1
- package/dist/src/yaml/step-validator.js +72 -0
- package/dist/src/yaml/steps/android-variant-resolution.test.d.ts +2 -0
- package/dist/src/yaml/steps/android-variant-resolution.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/android-variant-resolution.test.js +117 -0
- package/dist/src/yaml/steps/android.d.ts +37 -0
- package/dist/src/yaml/steps/android.d.ts.map +1 -1
- package/dist/src/yaml/steps/android.js +130 -17
- package/dist/src/yaml/steps/base.d.ts +20 -0
- package/dist/src/yaml/steps/base.d.ts.map +1 -1
- package/dist/src/yaml/steps/base.js +24 -1
- package/dist/src/yaml/steps/java-home-version-spec.test.d.ts +10 -0
- package/dist/src/yaml/steps/java-home-version-spec.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/java-home-version-spec.test.js +58 -0
- 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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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 = /
|
|
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
|
|
294
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -378,48 +506,48 @@ export function gradleJavaFloor(gradleVersion) {
|
|
|
378
506
|
*
|
|
379
507
|
* 11 joined the images on 2026-09-05, for Gradle 5.x and 6.x — which cannot
|
|
380
508
|
* *start* on 17, whatever the sources declare. It is a fallback, never a
|
|
381
|
-
* preference
|
|
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.
|
|
382
518
|
*/
|
|
383
|
-
export const AVAILABLE_JAVA_VERSIONS = [11, 17, 21];
|
|
519
|
+
export const AVAILABLE_JAVA_VERSIONS = [8, 11, 17, 21, 25];
|
|
384
520
|
/**
|
|
385
|
-
* The JDK
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
* installed that rule would write `JAVA_VERSION: 11` for every project whose
|
|
390
|
-
* `sourceCompatibility` is 11 — and AGP 8.x will not run on JDK 11, so
|
|
391
|
-
* pipelines that build today would stop building.
|
|
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.
|
|
392
525
|
*/
|
|
393
|
-
export const
|
|
526
|
+
export const NEWEST_AVAILABLE_JAVA_VERSION = Math.max(...AVAILABLE_JAVA_VERSIONS);
|
|
394
527
|
/**
|
|
395
|
-
* The Java version to write into a generated pipeline:
|
|
396
|
-
*
|
|
397
|
-
* 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.
|
|
398
531
|
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
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.
|
|
404
538
|
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
* 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.
|
|
410
543
|
*/
|
|
411
544
|
export function runnableJavaVersion(detectedJavaVersion, gradleVersion) {
|
|
412
545
|
const ceiling = gradleJavaCeiling(gradleVersion) ?? Infinity;
|
|
413
546
|
const floor = Math.max(detectedJavaVersion ?? 0, gradleJavaFloor(gradleVersion) ?? 0);
|
|
414
547
|
const usable = AVAILABLE_JAVA_VERSIONS.filter((v) => v >= floor && v <= ceiling).sort((a, b) => a - b);
|
|
415
548
|
if (usable.length === 0)
|
|
416
|
-
return
|
|
417
|
-
|
|
418
|
-
return PREFERRED_JAVA_VERSION;
|
|
419
|
-
// Forced off the default: below it take the newest that fits under the
|
|
420
|
-
// ceiling, above it the oldest that clears the floor.
|
|
421
|
-
const below = usable.filter((v) => v < PREFERRED_JAVA_VERSION);
|
|
422
|
-
return below.length > 0 ? below[below.length - 1] : usable[0];
|
|
549
|
+
return NEWEST_AVAILABLE_JAVA_VERSION;
|
|
550
|
+
return usable[usable.length - 1];
|
|
423
551
|
}
|
|
424
552
|
/**
|
|
425
553
|
* What `runnableJavaVersion` could not honour, as a sentence, or undefined
|
|
@@ -581,7 +709,14 @@ export async function scanAndroidProject(projectRoot) {
|
|
|
581
709
|
// 2. Detect required Java version and build variants
|
|
582
710
|
// ------------------------------------------------------------------
|
|
583
711
|
const gradleFiles = findGradleFiles(projectRoot);
|
|
584
|
-
const
|
|
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);
|
|
585
720
|
const detectedGradleVersion = detectGradleWrapperVersion(projectRoot);
|
|
586
721
|
const buildVariants = detectBuildVariants(gradleFiles);
|
|
587
722
|
// ------------------------------------------------------------------
|
|
@@ -9,8 +9,13 @@ export interface IosWarning {
|
|
|
9
9
|
}
|
|
10
10
|
export interface IosScanResult {
|
|
11
11
|
warnings: IosWarning[];
|
|
12
|
-
/**
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"ios-scheme-ranking.test.d.ts","sourceRoot":"","sources":["../../../src/commands/ios-scheme-ranking.test.ts"],"names":[],"mappings":""}
|