@hyodotdev/openiap 0.0.0-bootstrap.0 → 0.1.0

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/src/checks.mjs ADDED
@@ -0,0 +1,610 @@
1
+ import {
2
+ BUNDLED_FILES,
3
+ clientFiles,
4
+ dependencies,
5
+ envValue,
6
+ hasUnreadablePath,
7
+ isDirectory,
8
+ isEnvFile,
9
+ listDir,
10
+ parseEnv,
11
+ parseProperties,
12
+ read,
13
+ readFirst,
14
+ codeLines,
15
+ isGeneratedDir,
16
+ pubspecAssets,
17
+ quoted,
18
+ walkFiles,
19
+ } from "./project.mjs";
20
+ import { finding } from "./findings.mjs";
21
+
22
+ function withoutXmlComments(text) {
23
+ return text.replace(/<!--[\s\S]*?-->/g, (comment) =>
24
+ comment.replace(/[^\r\n]/g, " "),
25
+ );
26
+ }
27
+
28
+ /** A value read from a project file is data, never a line of this report. */
29
+ function oneLine(value) {
30
+ return (
31
+ value
32
+ // Escapes and bidi overrides can repaint a line they did not write.
33
+ .replace(
34
+ /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g,
35
+ "",
36
+ )
37
+ .replace(/\s+/g, " ")
38
+ .trim()
39
+ .slice(0, 60)
40
+ );
41
+ }
42
+
43
+ /** A plist can name anything, and that name is spliced into a RegExp. */
44
+ function escapeRegExp(value) {
45
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ }
47
+
48
+ function lineAt(text, index) {
49
+ return text.slice(0, index).split("\n").length;
50
+ }
51
+
52
+ /**
53
+ * The store a build links. The second argument is a literal in a generated
54
+ * project and a variable in a hand-written one, and a variable names a store
55
+ * only Gradle can resolve.
56
+ */
57
+ const PLATFORM_STRATEGY =
58
+ /^[ \t]*missingDimensionStrategy[\s(]{0,4}["']platform["'][\s,]{0,8}(?:["'](\w+)["']|(\w+))/;
59
+
60
+ const APP_BUILD_FILES = [
61
+ "android/app/build.gradle",
62
+ "android/app/build.gradle.kts",
63
+ ];
64
+
65
+ /**
66
+ * A generated Android project is written in one pass, so its store flag and
67
+ * its flavor literal always agree when they are fresh. A disagreement means a
68
+ * half-finished regeneration, and the app links a store the device may not run.
69
+ */
70
+ export function androidStoreChecks(root) {
71
+ const propertiesText = read(root, "android/gradle.properties");
72
+ const app = readFirst(root, APP_BUILD_FILES);
73
+ // Either file alone still proves which store the build links.
74
+ if (propertiesText === null && !app) return [];
75
+
76
+ const properties = propertiesText ? parseProperties(propertiesText) : null;
77
+ // OpenIAP Gradle scripts read these properties with Groovy toBoolean().
78
+ const enabled = (name) =>
79
+ ["true", "1", "y"].includes(
80
+ properties?.get(name)?.value.trim().toLowerCase(),
81
+ );
82
+ const horizon = enabled("horizonEnabled");
83
+ const fireOs = enabled("fireOsEnabled");
84
+ const findings = [];
85
+
86
+ if (horizon && fireOs) {
87
+ findings.push(
88
+ finding(
89
+ "android-store-flavor-conflict",
90
+ "error",
91
+ "android/gradle.properties",
92
+ "horizonEnabled and fireOsEnabled are both true.",
93
+ "Leave one store enabled and regenerate the Android project.",
94
+ { line: properties.get("horizonEnabled")?.line },
95
+ ),
96
+ );
97
+ }
98
+
99
+ // Gradle comments hold disabled configuration; reading them reports fiction.
100
+ const strategies = app
101
+ ? codeLines(app.text)
102
+ .map((one) => ({ ...one, match: PLATFORM_STRATEGY.exec(one.line) }))
103
+ .filter((one) => one.match)
104
+ : [];
105
+ const stores = [
106
+ ...new Set(strategies.map((one) => one.match[1]).filter(Boolean)),
107
+ ];
108
+ // A variable second argument names a store this cannot resolve.
109
+ const computed = strategies.some((one) => !one.match[1]);
110
+ // Play billing works if any flavor links Play, so only a build that links no
111
+ // Play flavor at all is worth reporting.
112
+ const linksNonPlay = stores.includes("play")
113
+ ? undefined
114
+ : stores.find((one) => one !== "play");
115
+ // Build types may legitimately link different stores, so a mismatch is not
116
+ // about how many are declared: it is that none of them is the one the flags
117
+ // selected, which only a half-finished regeneration produces.
118
+ const hasStoreFlags =
119
+ properties?.has("fireOsEnabled") || properties?.has("horizonEnabled");
120
+ const selects = hasStoreFlags
121
+ ? fireOs
122
+ ? "amazon"
123
+ : horizon
124
+ ? "horizon"
125
+ : "play"
126
+ : null;
127
+ // A computed flavor may well resolve to the selected store, so a mismatch is
128
+ // only provable when every strategy names a store and none of them is it.
129
+ const missing =
130
+ selects !== null &&
131
+ !computed &&
132
+ stores.length > 0 &&
133
+ !stores.includes(selects);
134
+ const declared = stores.length === 1 && !computed ? stores[0] : null;
135
+ const line = strategies[0]?.number;
136
+
137
+ // With both flags true `selected` is this tool's own tiebreak, not something
138
+ // gradle.properties states, and the conflict finding already covers it.
139
+ if (missing && !(horizon && fireOs)) {
140
+ findings.push(
141
+ finding(
142
+ "android-store-flavor-mismatch",
143
+ "error",
144
+ app.file,
145
+ `The project links ${stores.join(" and ")} while gradle.properties selects ${selects}.`,
146
+ "Regenerate the Android project so both come from one run.",
147
+ { line, expected: selects, actual: stores.join(",") },
148
+ ),
149
+ );
150
+ }
151
+
152
+ const store = linksNonPlay ?? declared ?? selects;
153
+ if (store && store !== "play") {
154
+ const evidence =
155
+ declared || linksNonPlay ? app.file : "android/gradle.properties";
156
+ const enabledFlag = fireOs ? "fireOsEnabled" : "horizonEnabled";
157
+ findings.push(
158
+ finding(
159
+ "android-store-not-play",
160
+ "warning",
161
+ evidence,
162
+ computed && !linksNonPlay
163
+ ? `gradle.properties selects the ${store} store, and the build computes its flavor from it.`
164
+ : `This Android project is built for the ${store} store.`,
165
+ `Google Play billing will not connect from this build. Regenerate without the ${store} flags before testing on a Play device.`,
166
+ {
167
+ line: linksNonPlay
168
+ ? strategies.find((one) => one.match[1] === linksNonPlay)?.number
169
+ : properties?.get(enabledFlag)?.line,
170
+ actual: store,
171
+ },
172
+ ),
173
+ );
174
+ }
175
+
176
+ if (store === "horizon") findings.push(...horizonAppIdCheck(root));
177
+ return findings;
178
+ }
179
+
180
+ /**
181
+ * Every flavor's manifest, not just `main`: an app can declare the id in the
182
+ * Horizon source set alone. Gradle can also inject it through a placeholder,
183
+ * which no manifest records -- so this reports a suspicion, never a verdict.
184
+ */
185
+ function horizonAppIdCheck(root) {
186
+ // A test source set merges into the test APK, never the app, so an id
187
+ // declared only there proves nothing about what ships.
188
+ const shipped = listDir(root, "android/app/src").filter(
189
+ (one) => !/^(androidTest|test)/.test(one),
190
+ );
191
+ const manifests = shipped
192
+ .map((one) => `android/app/src/${one}/AndroidManifest.xml`)
193
+ .map((file) => ({ file, text: read(root, file) }))
194
+ .filter(({ text }) => text !== null);
195
+ if (manifests.length === 0) return [];
196
+ const declares = ({ text }) =>
197
+ // An XML comment holds disabled configuration, the same as a Gradle one.
198
+ withoutXmlComments(text).includes(
199
+ "com.meta.horizon.platform.HORIZON_APP_ID",
200
+ );
201
+ if (manifests.some(declares)) return [];
202
+ // Point at the manifest the id belongs in, not whichever the disk listed.
203
+ const preferred =
204
+ manifests.find(({ file }) => file.includes("/horizon/")) ??
205
+ manifests.find(({ file }) => file.includes("/main/")) ??
206
+ manifests[0];
207
+ return [
208
+ finding(
209
+ "android-horizon-app-id-missing",
210
+ "warning",
211
+ preferred.file,
212
+ "The Horizon store is selected but no shipped manifest declares HORIZON_APP_ID.",
213
+ "Set the Horizon app id in your OpenIAP plugin configuration and regenerate, unless Gradle injects it as a manifest placeholder.",
214
+ ),
215
+ ];
216
+ }
217
+
218
+ /** A long tail is what separates a real key from prose naming the prefix. */
219
+ const SECRET_KEY = /openiap-kit_sk_[A-Za-z0-9]{16,}/;
220
+
221
+ /**
222
+ * Whether a name assigned in an env file reaches the bundle: a dotenv
223
+ * transform inlines the bare name, Expo's bundler inlines `EXPO_PUBLIC_`, and a
224
+ * Flutter app shipping `.env` as an asset copies the file verbatim.
225
+ */
226
+ function reachesBundle(root, framework, name, file) {
227
+ const deps = dependencies(root);
228
+ if (DOTENV_PACKAGES.some((one) => deps[one])) return true;
229
+ // Only Expo's bundler inlines this prefix; elsewhere the name is read by
230
+ // nothing, which is what `iapkit-env-unexpected-expo-prefix` reports.
231
+ if (framework === "expo" && name.startsWith("EXPO_PUBLIC_")) return true;
232
+ // Flutter copies the asset paths pubspec declares, and only those.
233
+ return pubspecAssets(root).includes(file);
234
+ }
235
+
236
+ /** Distinguish bundled secrets from values used only during configuration. */
237
+ export function secretKeyChecks(root, framework) {
238
+ const findings = [];
239
+ for (const file of clientFiles(root)) {
240
+ const text = read(root, file);
241
+ if (!text) continue;
242
+
243
+ if (isEnvFile(file) || file === "eas.json") {
244
+ let entries;
245
+ if (file === "eas.json") {
246
+ try {
247
+ const config = JSON.parse(text);
248
+ entries = Object.values(config.build ?? {}).flatMap((profile) =>
249
+ ["ios", "android"].flatMap((platform) =>
250
+ Object.entries({
251
+ ...profile?.env,
252
+ ...profile?.[platform]?.env,
253
+ }).map(([name, value]) => ({ name, value })),
254
+ ),
255
+ );
256
+ entries = [
257
+ ...new Map(
258
+ entries.map((entry) => [JSON.stringify(entry), entry]),
259
+ ).values(),
260
+ ];
261
+ } catch {
262
+ continue;
263
+ }
264
+ } else {
265
+ entries = parseEnv(text);
266
+ if (!pubspecAssets(root).includes(file)) {
267
+ entries = entries.filter(
268
+ (entry) => envValue(entries, entry.name) === entry,
269
+ );
270
+ }
271
+ }
272
+ // Parsed, so a key inside a comment is not reported as shipping.
273
+ for (const entry of entries) {
274
+ if (typeof entry.value !== "string" || !SECRET_KEY.test(entry.value))
275
+ continue;
276
+ // Expo inlines only EXPO_PUBLIC_ names, so a bare name can be the
277
+ // server-side key an API route reads. Anywhere the bundle is not
278
+ // proven, say so rather than demanding a rotation.
279
+ const proven = reachesBundle(root, framework, entry.name, file);
280
+ findings.push(
281
+ proven
282
+ ? finding(
283
+ "iapkit-secret-key-in-client",
284
+ "error",
285
+ file,
286
+ `An IAPKit secret key is assigned to ${entry.name}, which reaches the app bundle.`,
287
+ "Move it to your server and use a publishable openiap-kit_pk_ key here. Rotate the exposed key.",
288
+ { line: entry.line, actual: entry.name },
289
+ )
290
+ : finding(
291
+ "iapkit-secret-key-in-env",
292
+ "warning",
293
+ file,
294
+ `An IAPKit secret key is assigned to ${entry.name}. Nothing here proves it reaches the bundle.`,
295
+ "Keep it off every EXPO_PUBLIC_ name and out of exported app configuration.",
296
+ { line: entry.line, actual: entry.name },
297
+ ),
298
+ );
299
+ }
300
+ continue;
301
+ }
302
+
303
+ const found = codeLines(text).find((one) => SECRET_KEY.test(one.line));
304
+ if (!found) continue;
305
+ const dynamicConfig =
306
+ BUNDLED_FILES.includes(file) && !file.endsWith(".json");
307
+ if (dynamicConfig && !pubspecAssets(root).includes(file)) {
308
+ findings.push(
309
+ finding(
310
+ "iapkit-secret-key-in-config",
311
+ "warning",
312
+ file,
313
+ "An IAPKit secret key appears in executable app configuration. Nothing here proves it reaches the bundle.",
314
+ "Keep build-time secrets out of exported app configuration.",
315
+ { line: found.number },
316
+ ),
317
+ );
318
+ continue;
319
+ }
320
+ findings.push(
321
+ finding(
322
+ "iapkit-secret-key-in-client",
323
+ "error",
324
+ file,
325
+ "An IAPKit secret key is in a file the app bundle can read.",
326
+ "Move it to your server and use a publishable openiap-kit_pk_ key here. Rotate the exposed key.",
327
+ { line: found.number },
328
+ ),
329
+ );
330
+ }
331
+ return findings;
332
+ }
333
+
334
+ const IAPKIT_NAMES = [
335
+ "IAPKIT_API_KEY",
336
+ "IAPKIT_BASE_URL",
337
+ "IAPKIT_PUBLISHABLE_KEY",
338
+ ];
339
+
340
+ /**
341
+ * Transforms that inline a bare name into the bundle. Plain `dotenv` is not
342
+ * one: it is a Node library, and the app.config lane it serves is covered by
343
+ * `readAtConfigTime` instead.
344
+ */
345
+ export const DOTENV_PACKAGES = ["react-native-dotenv", "react-native-config"];
346
+
347
+ /** The ways an app.config can name an env variable, as one pattern each. */
348
+ function envReadPatterns(name) {
349
+ // A dotenv name may hold `.` and `-`, which are regex syntax.
350
+ const safe = escapeRegExp(name);
351
+ return {
352
+ // `delete` removes the value rather than reading it.
353
+ dotted: new RegExp(`(?<!delete\\s{1,8})process\\.env\\??\\.${safe}\\b`),
354
+ indexed: new RegExp(`process\\.env\\??\\[\\s*["'\`]${safe}["'\`]\\s*\\]`),
355
+ // Prettier wraps a destructure over several lines once it is long enough.
356
+ destructured: new RegExp(
357
+ `\\{[^{}]*\\b${safe}\\b[^{}]*\\}\\s*=\\s*process\\.env\\b`,
358
+ "s",
359
+ ),
360
+ };
361
+ }
362
+
363
+ /** Whether any bundled file names the variable at all. */
364
+ function readAtConfigTime(root, name) {
365
+ const reads = envReadPatterns(name);
366
+ return BUNDLED_FILES.some((file) => {
367
+ const text = read(root, file);
368
+ if (text === null) return false;
369
+ const lines = codeLines(text);
370
+ return (
371
+ reads.destructured.test(lines.map((one) => one.line).join("\n")) ||
372
+ lines.some((one) => {
373
+ const match =
374
+ reads.dotted.exec(one.line) ?? reads.indexed.exec(one.line);
375
+ // A name inside a string on this line is prose, not a read.
376
+ return Boolean(match) && !quoted(one.line, match.index);
377
+ })
378
+ );
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Expo inlines only `EXPO_PUBLIC_`-prefixed variables into the bundle, so the
384
+ * unprefixed name reads as undefined at runtime -- unless something else
385
+ * supplies it, in which case the bare name is correct and demanding the prefix
386
+ * would be wrong advice.
387
+ */
388
+ export function envNameChecks(root, framework) {
389
+ const deps = dependencies(root);
390
+ if (DOTENV_PACKAGES.some((one) => deps[one])) return [];
391
+
392
+ const findings = [];
393
+ for (const file of clientFiles(root)) {
394
+ if (!isEnvFile(file)) continue;
395
+ const text = read(root, file);
396
+ if (!text) continue;
397
+ const entries = parseEnv(text);
398
+
399
+ // A prefix on another variable says nothing about this one.
400
+ for (const name of IAPKIT_NAMES) {
401
+ const bare = envValue(entries, name);
402
+ const prefixed = envValue(entries, `EXPO_PUBLIC_${name}`);
403
+ if (framework === "expo" && bare && !prefixed) {
404
+ if (SECRET_KEY.test(bare.value)) continue; // secretKeyChecks owns it
405
+ if (readAtConfigTime(root, name)) continue;
406
+ findings.push(
407
+ finding(
408
+ "iapkit-env-missing-expo-prefix",
409
+ "warning",
410
+ file,
411
+ `Expo reads only EXPO_PUBLIC_-prefixed variables in app code, so ${name} is undefined at runtime.`,
412
+ `Rename ${name} to EXPO_PUBLIC_${name}, or read it in app.config where Node resolves it.`,
413
+ { line: bare.line, actual: name },
414
+ ),
415
+ );
416
+ }
417
+ if (framework !== "expo" && framework !== "unknown" && prefixed) {
418
+ findings.push(
419
+ finding(
420
+ "iapkit-env-unexpected-expo-prefix",
421
+ "warning",
422
+ file,
423
+ `EXPO_PUBLIC_${name} is set in a ${framework} project, where nothing inlines that prefix.`,
424
+ `Rename it to ${name}, and check how this project reads env files.`,
425
+ { line: prefixed.line, actual: `EXPO_PUBLIC_${name}` },
426
+ ),
427
+ );
428
+ }
429
+ }
430
+ }
431
+ return findings;
432
+ }
433
+
434
+ /**
435
+ * The base URL is an origin. IAPKit appends the verification path itself, so a
436
+ * URL that already carries one resolves to a route that does not exist.
437
+ */
438
+ export function baseUrlChecks(root, framework) {
439
+ const findings = [];
440
+ for (const file of clientFiles(root)) {
441
+ if (!isEnvFile(file)) continue;
442
+ const text = read(root, file);
443
+ if (!text) continue;
444
+ const entries = parseEnv(text);
445
+ for (const name of ["IAPKIT_BASE_URL", "EXPO_PUBLIC_IAPKIT_BASE_URL"]) {
446
+ const entry = envValue(entries, name);
447
+ if (!entry) continue;
448
+ // A value on a name this project never inlines cannot break anything at
449
+ // runtime, so its shape is a suspicion rather than a proven fault.
450
+ const level = reachesBundle(root, framework, name, file)
451
+ ? "error"
452
+ : "warning";
453
+ // An empty or interpolated value is filled at build time; nothing here
454
+ // proves it wrong.
455
+ if (entry.value === "" || /^\$|\$\{|\$\(/.test(entry.value)) continue;
456
+ let url;
457
+ try {
458
+ url = new URL(entry.value);
459
+ } catch {
460
+ findings.push(
461
+ finding(
462
+ "iapkit-base-url-invalid",
463
+ level,
464
+ file,
465
+ `${name} is not a URL.`,
466
+ "Use a bare origin such as https://kit.openiap.dev.",
467
+ // The value can be anything, including a credential, so the
468
+ // finding says where it is and never what it is.
469
+ { line: entry.line },
470
+ ),
471
+ );
472
+ continue;
473
+ }
474
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
475
+ findings.push(
476
+ finding(
477
+ "iapkit-base-url-scheme",
478
+ level,
479
+ file,
480
+ `${name} uses ${url.protocol} instead of http or https.`,
481
+ "Use a bare http(s) origin.",
482
+ { line: entry.line, actual: url.protocol },
483
+ ),
484
+ );
485
+ continue;
486
+ }
487
+ // Every verification lane requires a bare origin and throws a developer
488
+ // error otherwise: `OpenIapModule.swift`, `PurchaseVerificationValidator`
489
+ // and both Vega adapters reject a path, userinfo, a query or a fragment.
490
+ // A trailing slash is trimmed first, so `/` is an origin.
491
+ const path = url.pathname !== "" && url.pathname !== "/";
492
+ if (path || url.username || url.password || url.search || url.hash) {
493
+ findings.push(
494
+ finding(
495
+ "iapkit-base-url-has-path",
496
+ level,
497
+ file,
498
+ `${name} is not a bare origin, which is all IAPKit accepts.`,
499
+ "Give the scheme and host only; IAPKit appends /v1/purchase/verify itself.",
500
+ { line: entry.line },
501
+ ),
502
+ );
503
+ }
504
+ }
505
+ }
506
+ return findings;
507
+ }
508
+
509
+ const IOS_SOURCE = /\.(swift|m|mm|h)$/;
510
+
511
+ /**
512
+ * When the Info.plist names a scene delegate, the class has to exist in the
513
+ * target. Without it UIKit attaches nothing, React Native never starts, and
514
+ * the app shows a black screen with no crash to explain it.
515
+ */
516
+ export function iosSceneChecks(root, framework) {
517
+ if (framework !== "expo" && framework !== "react-native") return [];
518
+ const findings = [];
519
+ // Sources live wherever the target groups them, so read the whole ios/ tree
520
+ // rather than only the directory holding the plist.
521
+ const sources = walkFiles(root, "ios", IOS_SOURCE).map((one) =>
522
+ read(root, one),
523
+ );
524
+ const unreadableSource =
525
+ sources.some((one) => one === null) || hasUnreadablePath("ios");
526
+ const declarations = sources
527
+ .filter(Boolean)
528
+ .flatMap((one) => codeLines(one).map((line) => line.line));
529
+
530
+ // The app directory is named after the app, so read whatever ios/ holds
531
+ // rather than assuming the name of this repository's own examples.
532
+ const plists = [
533
+ ...(read(root, "ios/Info.plist") === null ? [] : ["ios"]),
534
+ ...listDir(root, "ios")
535
+ // Exactly what `walkFiles` skips: a plist in a generated directory is
536
+ // not the app's, and its sources are never searched.
537
+ .filter((one) => !isGeneratedDir(one) && isDirectory(root, `ios/${one}`))
538
+ .map((one) => `ios/${one}`),
539
+ ];
540
+ for (const dir of plists) {
541
+ const plist = read(root, `${dir}/Info.plist`);
542
+ if (!plist) continue;
543
+
544
+ // A plist declares one delegate per scene role, and the roles after the
545
+ // first were never examined.
546
+ const activePlist = withoutXmlComments(plist);
547
+ for (const declared of activePlist.matchAll(
548
+ /<key>UISceneDelegateClassName<\/key>\s*(?:<string>([^<]*)<\/string>|<string\s*\/>)/g,
549
+ )) {
550
+ // Only `$(PRODUCT_MODULE_NAME).X` names the app's own module. A dotted
551
+ // name like `RNScreens.SceneDelegate` comes from a pod, which lives in
552
+ // a directory this deliberately does not read.
553
+ const value = (declared[1] ?? "").trim();
554
+ const qualified = /^\$[({]PRODUCT_MODULE_NAME[)}]\./.test(value);
555
+ // Any other module is a framework whose sources are not in this tree.
556
+ if (!qualified && value.includes(".")) continue;
557
+ // Match on the whole name; `oneLine` is for the report.
558
+ const name = value.split(".").pop().trim();
559
+ const shown = oneLine(name);
560
+ const line = lineAt(plist, declared.index);
561
+
562
+ if (!name) {
563
+ findings.push(
564
+ finding(
565
+ "ios-scene-delegate-missing",
566
+ "error",
567
+ `${dir}/Info.plist`,
568
+ "UISceneDelegateClassName is set to an empty class name.",
569
+ "Regenerate the iOS project, or remove UISceneDelegateClassName.",
570
+ { line },
571
+ ),
572
+ );
573
+ continue;
574
+ }
575
+
576
+ const escaped = escapeRegExp(name);
577
+ // `@objc(Name)` renames a Swift class for UIKit, so the declaration line
578
+ // need not carry the name at all.
579
+ const defined = new RegExp(
580
+ `(?:\\bclass|@interface|@implementation)\\s+${escaped}\\b` +
581
+ `|@objc\\(\\s*${escaped}\\s*\\)`,
582
+ );
583
+ // Counting quotes to reject a name inside a string breaks on an ObjC
584
+ // char literal, and missing a real declaration fails a correct project.
585
+ // Reading a mentioned name as declared only under-reports.
586
+ if (declarations.some((one) => defined.test(one))) continue;
587
+ // A source this run could not open cannot be said to lack the class.
588
+ if (unreadableSource) continue;
589
+
590
+ // `$(PRODUCT_MODULE_NAME).X` says the class is in the app's own module,
591
+ // so its absence is proof. A bare name can also come from a linked
592
+ // framework, which no file in the project records.
593
+ findings.push(
594
+ finding(
595
+ "ios-scene-delegate-missing",
596
+ qualified ? "error" : "warning",
597
+ `${dir}/Info.plist`,
598
+ qualified
599
+ ? `The Info.plist names ${shown} in the app's own module, which no source under ios/ declares.`
600
+ : `The Info.plist names the scene delegate ${shown}, which no source under ios/ declares.`,
601
+ qualified
602
+ ? "Regenerate the iOS project, or remove UISceneDelegateClassName."
603
+ : "Check that a linked framework supplies it; otherwise regenerate the iOS project or remove UISceneDelegateClassName.",
604
+ { line, actual: shown },
605
+ ),
606
+ );
607
+ }
608
+ }
609
+ return findings;
610
+ }