@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.
- package/dist/cli.cjs +11 -11
- package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.d.ts +2 -0
- package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.d.ts.map +1 -0
- package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.js +354 -0
- package/dist/src/commands/android-scanner.d.ts +22 -0
- package/dist/src/commands/android-scanner.d.ts.map +1 -1
- package/dist/src/commands/android-scanner.js +424 -0
- package/dist/src/commands/index.d.ts +1 -0
- package/dist/src/commands/index.d.ts.map +1 -1
- package/dist/src/commands/index.js +3 -0
- package/dist/src/commands/ios-scanner.d.ts.map +1 -1
- package/dist/src/commands/ios-scanner.js +15 -24
- package/dist/src/commands/ios-scheme-ranking.test.js +5 -0
- package/dist/src/shared/detect-project.d.ts +32 -0
- package/dist/src/shared/detect-project.d.ts.map +1 -1
- package/dist/src/shared/detect-project.js +62 -10
- package/dist/src/shared/xcode-container.test.d.ts +2 -0
- package/dist/src/shared/xcode-container.test.d.ts.map +1 -0
- package/dist/src/shared/xcode-container.test.js +123 -0
- package/package.json +1 -1
|
@@ -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;
|
|
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":"
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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;
|
|
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"}
|