diff --git a/.typos.toml b/.typos.toml
index 6d0ccf5997..bf2ec9759c 100644
--- a/.typos.toml
+++ b/.typos.toml
@@ -78,4 +78,6 @@ ALS = "ALS" # AsyncLocalStorage abbreviation (used in CLI perf-tracking spec)
UDID = "UDID" # Apple's "Unique Device IDentifier" — distinct from UUID, used in Developer Portal walkthroughs.
unparseable = "unparseable" # Valid English synonym of "unparsable".
CIPS = "CIPS" # real App Store Connect session role value (ASC key helper)
+ITMS = "ITMS" # App Store Connect upload error-code prefix (ITMS-90704, ITMS-90474, ...) cited in iOS prescan findings
+loca = "loca" # localtunnel host suffix *.loca.lt detected by the iOS capacitor-server-url check
# Add more project-specific terms as needed
diff --git a/cli/src/build/mobileprovision-parser.ts b/cli/src/build/mobileprovision-parser.ts
index b1167c5147..8436fc4bb4 100644
--- a/cli/src/build/mobileprovision-parser.ts
+++ b/cli/src/build/mobileprovision-parser.ts
@@ -27,8 +27,22 @@ export interface MobileprovisionDetail extends MobileprovisionInfo {
profileType: 'app_store' | 'ad_hoc' | 'development' | 'enterprise' | 'unknown'
/** SHA1 (40-char lowercase hex) of each DeveloperCertificate embedded in the profile */
certificateSha1s: string[]
+ /**
+ * The capability-bearing keys parsed from the profile's `Entitlements`
+ * dict, keyed by entitlement name. String/bool entitlements map to their value;
+ * array entitlements (application-groups, associated-domains, keychain-access-groups,
+ * iCloud container ids, etc.) map to the list of `` members. Only keys
+ * actually present in the profile are included, so a caller can both test
+ * presence (`key in profileEntitlements`) and read the granted value/members.
+ * No credential material is included — these are capability KEY names + their
+ * declared identifiers, the same data the entitlement-coverage checks surface.
+ */
+ profileEntitlements: ProfileEntitlements
}
+export type ProfileEntitlementValue = string | string[] | boolean
+export type ProfileEntitlements = Record
+
export function parseMobileprovision(filePath: string): MobileprovisionInfo {
const data = readFileSync(filePath)
return parseMobileprovisionBuffer(data, filePath)
@@ -103,6 +117,7 @@ export function parseMobileprovisionBufferDetailed(data: Buffer, source = '` children of `...` block text. */
+function arrayStringMembers(arrayXml: string): string[] {
+ return Array.from(arrayXml.matchAll(/([\s\S]*?)<\/string>/g), m => m[1].trim())
+}
+
+/**
+ * Parse the capability keys from the profile's first `Entitlements`
+ * dict (one-level capture, mirroring extractNestedPlistValue). EVERY key present
+ * in the dict is read generically by its sibling value tag (string / bool / array),
+ * so the profile side is symmetric with the app side — a granted capability outside
+ * any fixed allowlist is recorded, not silently treated as "missing" (which would
+ * false-positive in the entitlements-vs-profile coverage check). Auto-managed keys
+ * (application-identifier, *.team-identifier) are skipped. Only keys actually present
+ * are added, so a missing capability is absent (not a false value). Never throws —
+ * returns {} when there is no Entitlements dict.
+ */
+function extractProfileEntitlements(xml: string): ProfileEntitlements {
+ const dict = xml.match(/Entitlements<\/key>\s*([\s\S]*?)<\/dict>/)?.[1]
+ if (dict === undefined)
+ return {}
+ const out: ProfileEntitlements = {}
+ // Each K followed by its value: a self-closing /, an
+ // ... (non-greedy, first close wins), or a scalar V.
+ const re = /([\s\S]*?)<\/key>\s*(?:<(true|false)\s*\/>|([\s\S]*?)<\/array>|<([a-z]+)>([\s\S]*?)<\/\4>)/g
+ for (const m of dict.matchAll(re)) {
+ const key = m[1].trim()
+ if (key in out || isAutoManagedEntitlementKey(key))
+ continue
+ if (m[2] !== undefined)
+ out[key] = m[2] === 'true'
+ else if (m[3] !== undefined)
+ out[key] = arrayStringMembers(m[3])
+ else if (m[4] === 'string')
+ out[key] = m[5].trim()
+ // Other scalar kinds (integer/real/date/data) are not capability-bearing and
+ // are intentionally not surfaced; their presence is still implied by the key.
+ else
+ out[key] = m[5].trim()
+ }
+ return out
+}
+
function extractPlistValue(xml: string, key: string, valueTag: string = 'string'): string | null {
const tag = escapeRegex(valueTag)
const regex = new RegExp(`${escapeRegex(key)}\\s*<${tag}>([^<]*)${tag}>`)
diff --git a/cli/src/build/onboarding/ios/flow.ts b/cli/src/build/onboarding/ios/flow.ts
index 627bde2770..e6c35aef8b 100644
--- a/cli/src/build/onboarding/ios/flow.ts
+++ b/cli/src/build/onboarding/ios/flow.ts
@@ -772,6 +772,9 @@ function synthesizeProfileFromAscSummary(
? 'ad_hoc'
: 'unknown') as DiscoveredProfile['profileType'],
certificateSha1s: [identity.sha1],
+ // No entitlements dict in an ASC profile summary — the field is required on
+ // MobileprovisionDetail, so populate it with an empty map.
+ profileEntitlements: {},
profileBase64: summary.profileContent,
} as DiscoveredProfile & { profileBase64: string }
}
@@ -3086,6 +3089,7 @@ export async function runIosEffect(
expirationDate: detail.expirationDate,
profileType: detail.profileType as DiscoveredProfile['profileType'],
certificateSha1s: detail.certificateSha1s,
+ profileEntitlements: detail.profileEntitlements,
}
const matches = deps.carried?.importMatches ?? []
const injectedMatches = matches.map(m => m.identity.sha1 === chosenIdentity.sha1
diff --git a/cli/src/build/prescan/capacitor-version.ts b/cli/src/build/prescan/capacitor-version.ts
new file mode 100644
index 0000000000..bdc494dc41
--- /dev/null
+++ b/cli/src/build/prescan/capacitor-version.ts
@@ -0,0 +1,31 @@
+// src/build/prescan/capacitor-version.ts
+import { join } from 'node:path'
+import { readTextIfExists } from './gradle'
+
+/**
+ * Major version of the Capacitor runtime declared in package.json, or null.
+ *
+ * Checks `@capacitor/core` first (the platform-agnostic runtime), then the
+ * platform packages `@capacitor/ios` and `@capacitor/android`, so both the iOS
+ * and Android prescan checks resolve the same major from one shared place.
+ * Reads dev+prod dependencies, returns null on a missing/malformed package.json
+ * (never throws).
+ */
+export function capacitorMajor(projectDir: string): number | null {
+ const raw = readTextIfExists(join(projectDir, 'package.json'))
+ if (raw === null)
+ return null
+ let deps: Record
+ try {
+ const pkg = JSON.parse(raw) as { dependencies?: Record, devDependencies?: Record }
+ deps = { ...pkg.devDependencies, ...pkg.dependencies }
+ }
+ catch {
+ return null
+ }
+ const range = deps['@capacitor/core'] ?? deps['@capacitor/ios'] ?? deps['@capacitor/android']
+ if (!range)
+ return null
+ const m = range.match(/(\d+)/)
+ return m ? Number(m[1]) : null
+}
diff --git a/cli/src/build/prescan/checks/android-project.ts b/cli/src/build/prescan/checks/android-project.ts
index 470a8993b9..1a9ee31815 100644
--- a/cli/src/build/prescan/checks/android-project.ts
+++ b/cli/src/build/prescan/checks/android-project.ts
@@ -11,6 +11,7 @@ import {
stripGradleComments,
} from '../gradle'
import { readAndroidManifest, stripXmlComments } from '../manifest'
+import { capacitorMajor } from '../capacitor-version'
import { willUploadToPlay } from '../upload-intent'
function hasCordovaPlugins(ctx: ScanContext): boolean {
@@ -535,26 +536,6 @@ export const targetSdkPlay: PrescanCheck = {
const CAP_MINSDK_FLOORS: Record = { 6: 22, 7: 23, 8: 24 }
const CAP_MINSDK_DEFAULT = 24
-/** Major version of @capacitor/core or @capacitor/android from package.json, or null. */
-function capacitorMajor(projectDir: string): number | null {
- const raw = readTextIfExists(join(projectDir, 'package.json'))
- if (raw === null)
- return null
- let deps: Record
- try {
- const pkg = JSON.parse(raw) as { dependencies?: Record, devDependencies?: Record }
- deps = { ...pkg.devDependencies, ...pkg.dependencies }
- }
- catch {
- return null
- }
- const range = deps['@capacitor/core'] ?? deps['@capacitor/android']
- if (!range)
- return null
- const m = range.match(/(\d+)/)
- return m ? Number(m[1]) : null
-}
-
function capacitorMinSdkFloor(major: number): number {
return CAP_MINSDK_FLOORS[major] ?? CAP_MINSDK_DEFAULT
}
diff --git a/cli/src/build/prescan/checks/ios-capacitor-config.ts b/cli/src/build/prescan/checks/ios-capacitor-config.ts
new file mode 100644
index 0000000000..bd8a6a9ff6
--- /dev/null
+++ b/cli/src/build/prescan/checks/ios-capacitor-config.ts
@@ -0,0 +1,118 @@
+// src/build/prescan/checks/ios-capacitor-config.ts
+//
+// §2.D — Capacitor config checks. These read only ctx.config (a passthrough
+// zod object, so server.* is reached via safe optional access — no parser
+// needed). Upload intent escalates severity for the server.url check. All
+// findings name only config values, never credential material.
+import type { Finding, PrescanCheck, ScanContext } from '../types'
+import { willUploadToAppStore } from '../upload-intent'
+
+// ctx.config is `.passthrough()`, so the server block is not in the static type.
+// Read it through a narrow shape rather than re-parsing the config.
+interface CapServer {
+ url?: unknown
+ cleartext?: unknown
+ allowNavigation?: unknown
+}
+function serverOf(ctx: ScanContext): CapServer | undefined {
+ return (ctx.config as { server?: CapServer } | undefined)?.server
+}
+function serverUrlOf(ctx: ScanContext): string | null {
+ const url = serverOf(ctx)?.url
+ return typeof url === 'string' && url !== '' ? url : null
+}
+
+// Dev-only markers that make a shipped server.url unambiguously a live-reload
+// leftover rather than a deliberate production host.
+const RFC1918_RE = /^https?:\/\/(?:10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.)/i
+const LOCALHOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?:[:/]|$)/i
+const TUNNEL_RE = /^https?:\/\/[^/]*\.(?:ngrok\.io|ngrok-free\.app|trycloudflare\.com|loca\.lt)(?:[:/]|$)/i
+
+/** A human-readable reason this url looks like a dev/live-reload target, or null. */
+function devTargetReason(url: string): string | null {
+ if (LOCALHOST_RE.test(url))
+ return 'points at localhost / 127.0.0.1'
+ if (RFC1918_RE.test(url))
+ return 'points at a private LAN IP (live-reload)'
+ if (TUNNEL_RE.test(url))
+ return 'points at a dev tunnel host (ngrok / cloudflare / localtunnel)'
+ if (/^http:\/\//i.test(url))
+ return 'uses cleartext http://'
+ return null
+}
+
+export const serverUrlShipped: PrescanCheck = {
+ id: 'ios/capacitor-server-url-shipped',
+ platforms: ['ios'],
+ appliesTo: ctx => serverUrlOf(ctx) !== null,
+ async run(ctx): Promise {
+ const url = serverUrlOf(ctx)
+ if (url === null)
+ return []
+ const uploading = willUploadToAppStore(ctx)
+ const reason = devTargetReason(url)
+ const detail = reason
+ ? `server.url=${url} (${reason}) — this ships a live-reload/remote endpoint into the build`
+ : `server.url=${url} — this ships a remote endpoint into the build instead of the bundled web assets`
+ return [{
+ id: 'ios/capacitor-server-url-shipped',
+ severity: uploading ? 'error' : 'warning',
+ title: 'capacitor.config server.url is set — the build will load a remote URL instead of bundled assets',
+ detail,
+ fix: 'Remove the server.url live-reload block before a production build; build web assets and run `npx cap sync`',
+ }]
+ },
+}
+
+export const serverCleartext: PrescanCheck = {
+ id: 'ios/capacitor-server-cleartext',
+ platforms: ['ios'],
+ appliesTo: ctx => serverOf(ctx)?.cleartext === true,
+ async run(ctx): Promise {
+ if (serverOf(ctx)?.cleartext !== true)
+ return []
+ const url = serverUrlOf(ctx)
+ const httpUrl = url !== null && /^http:\/\//i.test(url)
+ return [{
+ id: 'ios/capacitor-server-cleartext',
+ severity: httpUrl ? 'error' : 'warning',
+ title: 'capacitor.config server.cleartext is enabled — arbitrary cleartext HTTP traffic is allowed',
+ detail: httpUrl ? `paired with a cleartext server.url (${url})` : undefined,
+ fix: 'Remove server.cleartext (or set it false) for production; use https or a scoped ATS exception',
+ }]
+ },
+}
+
+/** A blanket `*` or public-suffix wildcard (`*.com`, `*.io`) with no specific host. */
+function isPublicWildcard(entry: string): boolean {
+ if (entry === '*')
+ return true
+ // `*.` such as *.com / *.io — a wildcard with no concrete host.
+ // `*.example.com` (two+ labels after the dot) is a specific subdomain wildcard
+ // and is NOT flagged.
+ return /^\*\.[a-z0-9-]+$/i.test(entry)
+}
+function navWildcards(ctx: ScanContext): string[] {
+ const list = serverOf(ctx)?.allowNavigation
+ if (!Array.isArray(list))
+ return []
+ return list.filter((e): e is string => typeof e === 'string' && isPublicWildcard(e))
+}
+
+export const allowNavigationWildcard: PrescanCheck = {
+ id: 'ios/capacitor-allow-navigation-wildcard',
+ platforms: ['ios'],
+ appliesTo: ctx => navWildcards(ctx).length > 0,
+ async run(ctx): Promise {
+ const offenders = navWildcards(ctx)
+ if (offenders.length === 0)
+ return []
+ return [{
+ id: 'ios/capacitor-allow-navigation-wildcard',
+ severity: 'warning',
+ title: 'capacitor.config server.allowNavigation contains a blanket wildcard',
+ detail: `wildcard entr(y/ies): ${offenders.join(', ')}`,
+ fix: 'Restrict allowNavigation to specific hosts; remove blanket "*" / "*." entries',
+ }]
+ },
+}
diff --git a/cli/src/build/prescan/checks/ios-entitlements-checks.ts b/cli/src/build/prescan/checks/ios-entitlements-checks.ts
new file mode 100644
index 0000000000..0e1e413d90
--- /dev/null
+++ b/cli/src/build/prescan/checks/ios-entitlements-checks.ts
@@ -0,0 +1,312 @@
+// src/build/prescan/checks/ios-entitlements-checks.ts
+//
+// §2.C — Entitlements / capabilities checks. These compare the app's own
+// entitlements file (ios/App/App/App.entitlements) against the mapped
+// provisioning profiles' entitlements, plus pure format checks for
+// associated-domains and app-groups. All readers are pure and never throw;
+// findings surface only capability KEY names + declared identifiers (no
+// credential material — see types.ts Finding doc).
+import type { ProfileEntitlements } from '../../mobileprovision-parser'
+import type { Finding, PrescanCheck, ScanContext } from '../types'
+import { existsSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { parseMobileprovisionDetailedFromBase64 } from '../../mobileprovision-parser'
+import { entArray, entString, readAppEntitlements } from '../ios-entitlements'
+import { plistArrayStrings } from './ios-plist-read'
+import { parseProvisioningMap } from './ios-profiles'
+
+const hasMap = (ctx: ScanContext): boolean => parseProvisioningMap(ctx).length > 0
+const hasAppEntitlements = (ctx: ScanContext): boolean => readAppEntitlements(ctx.projectDir) !== null
+
+/**
+ * Independent evidence the app actually uses push: the Info.plist declares the
+ * `remote-notification` background mode. Used to distinguish a genuine
+ * development-vs-app_store push mismatch from the benign default Capacitor
+ * `aps-environment=development` leftover that nearly every push-free app carries.
+ * Reads only the project Info.plist; never throws.
+ */
+function appUsesRemoteNotifications(projectDir: string): boolean {
+ const p = join(projectDir, 'ios', 'App', 'App', 'Info.plist')
+ if (!existsSync(p))
+ return false
+ try {
+ return plistArrayStrings(readFileSync(p, 'utf8'), 'UIBackgroundModes').includes('remote-notification')
+ }
+ catch {
+ return false
+ }
+}
+
+/** Parse a mapped profile's entitlements; {} when the blob is not a valid profile. */
+function profileEntitlementsOf(base64: string): ProfileEntitlements {
+ try {
+ return parseMobileprovisionDetailedFromBase64(base64).profileEntitlements
+ }
+ catch {
+ return {}
+ }
+}
+
+// Auto-managed keys the profile always carries / the build pipeline injects.
+// aps-environment has its own check (entitlements-aps-environment-vs-mode).
+const EXCLUDED_KEYS = new Set([
+ 'aps-environment',
+ 'get-task-allow',
+ 'application-identifier',
+])
+function isExcludedKey(key: string): boolean {
+ return EXCLUDED_KEYS.has(key) || key.endsWith('.team-identifier')
+}
+
+// A profile array member that grants every requested member. Profiles store the
+// wildcard either as the bare `*`, the unresolved `$(VAR)*` template form, or the
+// RESOLVED 10-char-team-prefixed form `.*` (e.g. `ABCDE12345.*`) — which is
+// what a signed wildcard-App-ID profile actually carries, never the $() variable.
+function isWildcardMember(member: string): boolean {
+ return member === '*'
+ || member === '$(AppIdentifierPrefix)*'
+ || /^\$\(\w+\)\*$/.test(member)
+ || /^[A-Z0-9]{10}\.\*$/.test(member)
+}
+
+// App entitlement array members carry the unresolved Xcode prefix variable
+// ($(AppIdentifierPrefix) / $(TeamIdentifierPrefix)), while the profile carries the
+// resolved 10-char team prefix. Strip both so the suffixes compare like-for-like.
+const APP_PREFIX_VAR_RE = /^\$\((?:AppIdentifierPrefix|TeamIdentifierPrefix)\)/
+const RESOLVED_TEAM_PREFIX_RE = /^[A-Z0-9]{10}\./
+function entitlementMemberSuffix(member: string): string {
+ if (APP_PREFIX_VAR_RE.test(member))
+ return member.replace(APP_PREFIX_VAR_RE, '')
+ if (RESOLVED_TEAM_PREFIX_RE.test(member))
+ return member.replace(RESOLVED_TEAM_PREFIX_RE, '')
+ return member
+}
+
+/**
+ * TOP-LEVEL entitlement keys present in the app entitlements ``, each
+ * tagged with the kind of its sibling value (array vs scalar). When a key's
+ * value is a container (`` / ``), the scan skips past that
+ * container's MATCHING close so keys nested inside it are NOT collected —
+ * otherwise a nested key would leak into the capability set and feed a false
+ * positive into the ERROR-severity entitlements-vs-profile-capability check.
+ * Self-closing empty containers (`` / ``) carry no inner keys
+ * and need no skip. Pure; never throws.
+ */
+function appEntitlementKeys(raw: string): { key: string, isArray: boolean }[] {
+ const out: { key: string, isArray: boolean }[] = []
+ // A key element followed by its value's opening tag (or a self-closing one).
+ const keyRe = /([\s\S]*?)<\/key>\s*<(array|string|true|false|dict|integer|real|data|date)(\/)?\s*>/g
+ let m = keyRe.exec(raw)
+ while (m !== null) {
+ const key = m[1].trim()
+ const valueTag = m[2]
+ const selfClosing = m[3] === '/'
+ out.push({ key, isArray: valueTag === 'array' })
+ // For a non-empty container value, jump the cursor past its matching close
+ // so the next iteration resumes at the following TOP-LEVEL sibling key.
+ if (!selfClosing && (valueTag === 'dict' || valueTag === 'array')) {
+ const skipTo = matchingClose(raw, valueTag, m.index + m[0].length)
+ if (skipTo > keyRe.lastIndex)
+ keyRe.lastIndex = skipTo
+ }
+ m = keyRe.exec(raw)
+ }
+ return out
+}
+
+/**
+ * Index just past the `` that balances the container opened immediately
+ * before `from` (one level already open). Falls back to the input length when
+ * the container is never closed (malformed), which safely halts the outer scan.
+ */
+function matchingClose(raw: string, tag: 'dict' | 'array', from: number): number {
+ const tagRe = new RegExp(`<${tag}>|${tag}>`, 'g')
+ tagRe.lastIndex = from
+ let depth = 1
+ for (let t = tagRe.exec(raw); t !== null; t = tagRe.exec(raw)) {
+ if (t[0] === `<${tag}>`)
+ depth++
+ else if (--depth === 0)
+ return tagRe.lastIndex
+ }
+ return raw.length
+}
+
+export const entitlementsVsProfileCapability: PrescanCheck = {
+ id: 'ios/entitlements-vs-profile-capability',
+ platforms: ['ios'],
+ appliesTo: ctx => hasMap(ctx) && hasAppEntitlements(ctx),
+ async run(ctx): Promise {
+ const app = readAppEntitlements(ctx.projectDir)
+ if (!app)
+ return []
+ const appKeys = appEntitlementKeys(app.raw).filter(k => !isExcludedKey(k.key))
+ if (appKeys.length === 0)
+ return []
+
+ const findings: Finding[] = []
+ for (const { bundleId, base64 } of parseProvisioningMap(ctx)) {
+ const profileEnt = profileEntitlementsOf(base64)
+ for (const { key, isArray } of appKeys) {
+ if (isArray) {
+ const appMembers = entArray(app.raw, key)
+ const profileValue = profileEnt[key]
+ const profileMembers = Array.isArray(profileValue) ? profileValue : []
+ if (profileMembers.some(isWildcardMember))
+ continue
+ // Compare on the prefix-normalized suffix: app members carry the
+ // $(AppIdentifierPrefix) variable, profile members the resolved team prefix.
+ const profileSuffixes = new Set(profileMembers.map(entitlementMemberSuffix))
+ const uncovered = appMembers.filter(member => !profileSuffixes.has(entitlementMemberSuffix(member)))
+ if (uncovered.length > 0) {
+ findings.push({
+ id: 'ios/entitlements-vs-profile-capability',
+ severity: 'error',
+ title: `Entitlement "${key}" is not fully covered by the provisioning profile for "${bundleId}"`,
+ detail: `uncovered ${key} value(s): ${uncovered.join(', ')}`,
+ fix: 'Enable the capability for this App ID in the Apple Developer portal, regenerate the profile, and re-save credentials (or remove the unused entitlement)',
+ })
+ }
+ }
+ else if (!(key in profileEnt)) {
+ findings.push({
+ id: 'ios/entitlements-vs-profile-capability',
+ severity: 'error',
+ title: `Entitlement "${key}" is declared by the app but not granted by the provisioning profile for "${bundleId}"`,
+ detail: `missing capability: ${key}`,
+ fix: 'Enable the capability for this App ID in the Apple Developer portal, regenerate the profile, and re-save credentials (or remove the unused entitlement)',
+ })
+ }
+ }
+ }
+ return findings
+ },
+}
+
+export const apsEnvironmentVsMode: PrescanCheck = {
+ id: 'ios/entitlements-aps-environment-vs-mode',
+ platforms: ['ios'],
+ appliesTo: (ctx) => {
+ const app = readAppEntitlements(ctx.projectDir)
+ return app !== null && entString(app.raw, 'aps-environment') !== null && Boolean(ctx.distributionMode)
+ },
+ async run(ctx): Promise {
+ const app = readAppEntitlements(ctx.projectDir)
+ if (!app)
+ return []
+ const value = entString(app.raw, 'aps-environment')
+ if (value === null)
+ return []
+
+ const findings: Finding[] = []
+ if (ctx.distributionMode === 'app_store' && value === 'development') {
+ // The default Capacitor App.entitlements ships aps-environment=development.
+ // On a push-free app this is a benign leftover the cloud builder neither
+ // rewrites nor fails the archive on — so it must NOT hard-block an App Store
+ // build. Only escalate to a blocking error when there is independent evidence
+ // the app genuinely uses push (a remote-notification background mode); the
+ // mapped-profile production mismatch is caught separately below as an error.
+ const usesPush = appUsesRemoteNotifications(ctx.projectDir)
+ findings.push({
+ id: 'ios/entitlements-aps-environment-vs-mode',
+ severity: usesPush ? 'error' : 'warning',
+ title: 'aps-environment is "development" but the build distributes to the App Store / TestFlight',
+ detail: usesPush
+ ? 'The app declares the remote-notification background mode, so it needs a production push environment for App Store / TestFlight'
+ : 'App Store and TestFlight push needs a production environment; this is the default Capacitor leftover and is harmless unless the app uses push notifications',
+ fix: 'Set aps-environment=production in App.entitlements and use a production push profile (or remove aps-environment if the app does not use push)',
+ })
+ }
+ else if (ctx.distributionMode === 'ad_hoc' && value === 'production') {
+ findings.push({
+ id: 'ios/entitlements-aps-environment-vs-mode',
+ severity: 'warning',
+ title: 'aps-environment is "production" for an ad_hoc build',
+ detail: 'ad_hoc builds usually use the development push environment (production is valid but uncommon)',
+ fix: 'Use aps-environment=development for ad_hoc unless you intend production APNs',
+ })
+ }
+
+ // When a provisioning map is present, the profile's aps-environment must agree
+ // with the app's declared value.
+ if (hasMap(ctx)) {
+ for (const { bundleId, base64 } of parseProvisioningMap(ctx)) {
+ const profileEnt = profileEntitlementsOf(base64)
+ const profileValue = profileEnt['aps-environment']
+ if (typeof profileValue === 'string' && profileValue !== value) {
+ findings.push({
+ id: 'ios/entitlements-aps-environment-vs-mode',
+ severity: 'error',
+ title: `aps-environment differs between App.entitlements and the provisioning profile for "${bundleId}"`,
+ detail: `app: ${value} — profile: ${profileValue}`,
+ fix: 'Align aps-environment in App.entitlements with the push profile (or regenerate the profile)',
+ })
+ }
+ }
+ }
+ return findings
+ },
+}
+
+// applinks/webcredentials/activitycontinuation/appclips:[?mode=developer|managed]
+const ASSOCIATED_DOMAIN_RE = /^(?:applinks|webcredentials|activitycontinuation|appclips):[a-z0-9.-]+(?:\?mode=(?:developer|managed))?$/i
+const ASSOCIATED_DOMAIN_KEY = 'com.apple.developer.associated-domains'
+
+export const associatedDomainsFormat: PrescanCheck = {
+ id: 'ios/entitlements-associated-domains-format',
+ platforms: ['ios'],
+ appliesTo: (ctx) => {
+ const app = readAppEntitlements(ctx.projectDir)
+ return app !== null && entArray(app.raw, ASSOCIATED_DOMAIN_KEY).length > 0
+ },
+ async run(ctx): Promise {
+ const app = readAppEntitlements(ctx.projectDir)
+ if (!app)
+ return []
+ const bad: string[] = []
+ for (const entry of entArray(app.raw, ASSOCIATED_DOMAIN_KEY)) {
+ // The managed-wildcard form `service:*` (e.g. applinks:*) is valid.
+ if (/^(?:applinks|webcredentials|activitycontinuation|appclips):\*$/i.test(entry))
+ continue
+ if (!ASSOCIATED_DOMAIN_RE.test(entry))
+ bad.push(entry)
+ }
+ if (bad.length === 0)
+ return []
+ return [{
+ id: 'ios/entitlements-associated-domains-format',
+ severity: 'warning',
+ title: `${bad.length} associated-domains entr(y/ies) have an invalid format`,
+ detail: `invalid: ${bad.join(', ')}`,
+ fix: 'Use the "service:domain" form (e.g. applinks:example.com) — no scheme, path, or trailing slash',
+ }]
+ },
+}
+
+const APP_GROUP_KEY = 'com.apple.security.application-groups'
+// group.: lowercase letters, digits, dots, hyphens; no uppercase/whitespace.
+const APP_GROUP_RE = /^group\.[a-z0-9.-]+$/
+
+export const appGroupsFormat: PrescanCheck = {
+ id: 'ios/entitlements-app-groups-format',
+ platforms: ['ios'],
+ appliesTo: (ctx) => {
+ const app = readAppEntitlements(ctx.projectDir)
+ return app !== null && entArray(app.raw, APP_GROUP_KEY).length > 0
+ },
+ async run(ctx): Promise {
+ const app = readAppEntitlements(ctx.projectDir)
+ if (!app)
+ return []
+ const bad = entArray(app.raw, APP_GROUP_KEY).filter(g => !APP_GROUP_RE.test(g))
+ if (bad.length === 0)
+ return []
+ return [{
+ id: 'ios/entitlements-app-groups-format',
+ severity: 'warning',
+ title: `${bad.length} app group identifier(s) have an invalid format`,
+ detail: `invalid: ${bad.join(', ')}`,
+ fix: 'Name app groups "group." (lowercase, no whitespace) and register them in the portal',
+ }]
+ },
+}
diff --git a/cli/src/build/prescan/checks/ios-plist-checks.ts b/cli/src/build/prescan/checks/ios-plist-checks.ts
new file mode 100644
index 0000000000..00e486700c
--- /dev/null
+++ b/cli/src/build/prescan/checks/ios-plist-checks.ts
@@ -0,0 +1,409 @@
+// src/build/prescan/checks/ios-plist-checks.ts
+//
+// §2.A Info.plist / App Store checks. All local (read only project files), all
+// gated on the Capacitor Info.plist existing at ios/App/App/Info.plist. Every
+// value-format check pipes the plist value through resolvePlistValue first, so a
+// `$(VAR)` build-variable reference is substituted from the pbxproj (the single
+// biggest false-positive guard) — and a still-unresolved `$()` is treated as
+// "skip / cannot judge", never a finding.
+//
+// Finding.id is always the check id (findings never invent per-finding ids).
+// detail/title/fix are printed and serialized to --json: these checks read only
+// non-credential project files, so no credential material can leak.
+import type { Finding, PrescanCheck, ScanContext } from '../types'
+import { existsSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { readPbxproj } from '../../pbxproj-parser'
+import { readBuildSetting, resolvePlistValue } from '../ios-pbxsettings'
+import { willUploadToAppStore } from '../upload-intent'
+import {
+ plistArrayStrings,
+ plistBool,
+ plistDictBlock,
+ plistHasKey,
+ plistString,
+} from './ios-plist-read'
+
+const INFO_PLIST_REL = ['ios', 'App', 'App', 'Info.plist']
+
+/** The Capacitor Info.plist path, or null when it is absent (non-standard layout). */
+function infoPlistPath(projectDir: string): string | null {
+ const p = join(projectDir, ...INFO_PLIST_REL)
+ return existsSync(p) ? p : null
+}
+
+/** Read the Info.plist text, or null when absent / unreadable (never throws). */
+function readInfoPlist(projectDir: string): string | null {
+ const p = infoPlistPath(projectDir)
+ if (!p)
+ return null
+ try {
+ return readFileSync(p, 'utf8')
+ }
+ catch {
+ return null
+ }
+}
+
+/** pbxproj text for the project, or '' when none — '' makes every $() unresolvable (=> skip). */
+function pbx(projectDir: string): string {
+ return readPbxproj(projectDir) ?? ''
+}
+
+const hasInfoPlist = (ctx: ScanContext) => infoPlistPath(ctx.projectDir) !== null
+
+/** A clean `$(VAR)` / `${VAR}` reference that resolvePlistValue could not resolve. */
+function isUnresolvedRef(value: string): boolean {
+ return value.startsWith('$(') || value.startsWith('${')
+}
+
+/**
+ * `ctx.config.server` is reachable through the schema's `.passthrough()` but is
+ * typed `unknown`, so read its dev-server signals defensively. Returns whether a
+ * live dev server (cleartext flag or a configured url) is still wired in.
+ */
+function hasDevServerConfig(ctx: ScanContext): boolean {
+ const server = (ctx.config as { server?: unknown } | undefined)?.server
+ if (server === null || typeof server !== 'object')
+ return false
+ const s = server as { url?: unknown, cleartext?: unknown }
+ return s.cleartext === true || (typeof s.url === 'string' && s.url.length > 0)
+}
+
+const VERSION_RE = /^\d+(?:\.\d+){0,2}$/
+// Reverse-DNS, >=2 segments, no space/underscore/wildcard.
+const BUNDLE_ID_RE = /^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+$/i
+
+const ORIENTATIONS = [
+ 'UIInterfaceOrientationPortrait',
+ 'UIInterfaceOrientationPortraitUpsideDown',
+ 'UIInterfaceOrientationLandscapeLeft',
+ 'UIInterfaceOrientationLandscapeRight',
+]
+const ORIENTATION_SET = new Set(ORIENTATIONS)
+
+const BACKGROUND_MODES = new Set([
+ 'audio',
+ 'location',
+ 'voip',
+ 'fetch',
+ 'remote-notification',
+ 'processing',
+ 'bluetooth-central',
+ 'bluetooth-peripheral',
+ 'external-accessory',
+ 'newsstand-content',
+])
+
+export const plistBundleIdFormat: PrescanCheck = {
+ id: 'ios/plist-bundle-id-format',
+ platforms: ['ios'],
+ appliesTo: hasInfoPlist,
+ async run(ctx): Promise {
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return []
+ const v = plistString(raw, 'CFBundleIdentifier')
+ if (v === null) {
+ return [{
+ id: 'ios/plist-bundle-id-format',
+ severity: 'error',
+ title: 'Info.plist has no CFBundleIdentifier',
+ fix: 'Set a valid reverse-DNS PRODUCT_BUNDLE_IDENTIFIER (no spaces, underscores, or wildcards).',
+ }]
+ }
+ const r = resolvePlistValue(v, pbx(ctx.projectDir))
+ if (isUnresolvedRef(r))
+ return [] // build-variable with no pbxproj match — cannot judge
+ if (!BUNDLE_ID_RE.test(r)) {
+ return [{
+ id: 'ios/plist-bundle-id-format',
+ severity: 'error',
+ title: `Invalid bundle identifier "${r}"`,
+ detail: 'Bundle ids must be reverse-DNS (≥2 dot-separated segments) with no spaces, underscores, or wildcards.',
+ fix: 'Set a valid reverse-DNS PRODUCT_BUNDLE_IDENTIFIER (no spaces, underscores, or wildcards).',
+ }]
+ }
+ return []
+ },
+}
+
+export const plistVersionShortFormat: PrescanCheck = {
+ id: 'ios/plist-version-short-format',
+ platforms: ['ios'],
+ appliesTo: hasInfoPlist,
+ async run(ctx): Promise {
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return []
+ const v = plistString(raw, 'CFBundleShortVersionString')
+ if (v === null)
+ return [] // presence owned by ios/infoplist-sanity (see spec §5)
+ const r = resolvePlistValue(v, pbx(ctx.projectDir))
+ if (isUnresolvedRef(r))
+ return []
+ if (!VERSION_RE.test(r)) {
+ return [{
+ id: 'ios/plist-version-short-format',
+ severity: 'error',
+ title: `CFBundleShortVersionString "${r}" is not a valid version (ITMS-90060)`,
+ detail: 'The marketing version must be ≤3 dot-separated integers (e.g. 1.4.2) — no letters or pre-release suffixes.',
+ fix: 'Set MARKETING_VERSION to ≤3 dot-separated integers (e.g. 1.4.2).',
+ }]
+ }
+ return []
+ },
+}
+
+export const plistVersionBuildFormat: PrescanCheck = {
+ id: 'ios/plist-version-build-format',
+ platforms: ['ios'],
+ appliesTo: hasInfoPlist,
+ async run(ctx): Promise {
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return []
+ const v = plistString(raw, 'CFBundleVersion')
+ if (v === null)
+ return []
+ const r = resolvePlistValue(v, pbx(ctx.projectDir))
+ if (isUnresolvedRef(r))
+ return []
+ if (!VERSION_RE.test(r)) {
+ return [{
+ id: 'ios/plist-version-build-format',
+ severity: 'error',
+ title: `CFBundleVersion "${r}" is not a valid build number`,
+ detail: 'The build number must be numeric, ≤3 dot-separated integers (e.g. 42 or 1.4.42).',
+ fix: 'Set CURRENT_PROJECT_VERSION numeric, ≤3 integers (e.g. 42 or 1.4.42).',
+ }]
+ }
+ return []
+ },
+}
+
+export const plistEncryptionCompliance: PrescanCheck = {
+ id: 'ios/plist-encryption-compliance',
+ platforms: ['ios'],
+ appliesTo: ctx => hasInfoPlist(ctx) && willUploadToAppStore(ctx),
+ async run(ctx): Promise {
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return []
+ if (plistHasKey(raw, 'ITSAppUsesNonExemptEncryption'))
+ return [] // present (either value) — do NOT assert which value is correct
+ return [{
+ id: 'ios/plist-encryption-compliance',
+ severity: 'warning',
+ title: 'Info.plist is missing ITSAppUsesNonExemptEncryption',
+ detail: 'Without this key, App Store Connect shows a "Missing Compliance" prompt on every upload.',
+ fix: 'Add ITSAppUsesNonExemptEncryption= (correct for most Capacitor apps) to stop the per-upload Missing Compliance prompt.',
+ }]
+ },
+}
+
+export const plistAtsArbitraryLoads: PrescanCheck = {
+ id: 'ios/plist-ats-arbitrary-loads',
+ platforms: ['ios'],
+ appliesTo: hasInfoPlist,
+ async run(ctx): Promise {
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return []
+ const dict = plistDictBlock(raw, 'NSAppTransportSecurity')
+ if (dict === null)
+ return []
+ if (plistBool(dict, 'NSAllowsArbitraryLoads') !== true)
+ return []
+ // Escalate to error only when an upload is intended AND a dev server config
+ // is still wired in (cleartext or a live server.url) — that combination ships
+ // arbitrary-loads to production.
+ const escalate = willUploadToAppStore(ctx) && hasDevServerConfig(ctx)
+ return [{
+ id: 'ios/plist-ats-arbitrary-loads',
+ severity: escalate ? 'error' : 'warning',
+ title: 'NSAllowsArbitraryLoads is enabled (App Transport Security disabled)',
+ detail: escalate
+ ? 'Uploading with arbitrary loads enabled and a dev server (cleartext / server.url) still configured ships an insecure release.'
+ : 'NSAllowsArbitraryLoads disables App Transport Security for all hosts.',
+ fix: 'Remove NSAllowsArbitraryLoads (or set ); use scoped NSExceptionDomains; remove server.url/cleartext before release.',
+ }]
+ },
+}
+
+export const plistLaunchStoryboard: PrescanCheck = {
+ id: 'ios/plist-launch-storyboard',
+ platforms: ['ios'],
+ appliesTo: hasInfoPlist,
+ async run(ctx): Promise {
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return []
+ const ok = plistHasKey(raw, 'UILaunchStoryboardName') || plistHasKey(raw, 'UILaunchScreen')
+ if (ok)
+ return []
+ return [{
+ id: 'ios/plist-launch-storyboard',
+ severity: 'error',
+ title: 'Info.plist declares no launch screen (ITMS-90475/90096)',
+ detail: 'App Store upload requires a launch storyboard or a UILaunchScreen dictionary.',
+ fix: 'Add UILaunchStoryboardName=LaunchScreen (Capacitor default) or a UILaunchScreen dict.',
+ }]
+ },
+}
+
+export const plistOrientationsMultitasking: PrescanCheck = {
+ id: 'ios/plist-orientations-multitasking',
+ platforms: ['ios'],
+ appliesTo(ctx) {
+ if (!hasInfoPlist(ctx))
+ return false
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return false
+ // Full-screen-only iPad apps are exempt from the multitasking orientation rule.
+ if (plistBool(raw, 'UIRequiresFullScreen') === true)
+ return false
+ const family = readBuildSetting(pbx(ctx.projectDir), 'TARGETED_DEVICE_FAMILY')
+ if (family === null)
+ return false
+ // family is a comma list like "1,2"; iPad multitasking only matters when "2" is present.
+ return family.split(',').map(s => s.trim()).includes('2')
+ },
+ async run(ctx): Promise {
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return []
+ // Scope STRICTLY to the ~ipad array (the iPhone array is intentionally allowed
+ // to omit PortraitUpsideDown and would false-positive). Fall back to the
+ // non-suffixed array only when the ~ipad key is entirely absent.
+ let ipad = plistArrayStrings(raw, 'UISupportedInterfaceOrientations~ipad')
+ if (!plistHasKey(raw, 'UISupportedInterfaceOrientations~ipad'))
+ ipad = plistArrayStrings(raw, 'UISupportedInterfaceOrientations')
+ const present = new Set(ipad)
+ const missing = ORIENTATIONS.filter(o => !present.has(o))
+ if (missing.length === 0)
+ return []
+ return [{
+ id: 'ios/plist-orientations-multitasking',
+ severity: 'warning',
+ title: `iPad multitasking requires all four orientations — missing ${missing.join(', ')} (ITMS-90474)`,
+ detail: 'An iPad-capable app that supports multitasking must declare all four interface orientations for iPad.',
+ fix: 'Add all four orientations to UISupportedInterfaceOrientations~ipad, or add UIRequiresFullScreen=.',
+ }]
+ },
+}
+
+export const plistOrientationsPresent: PrescanCheck = {
+ id: 'ios/plist-orientations-present',
+ platforms: ['ios'],
+ appliesTo: hasInfoPlist,
+ async run(ctx): Promise {
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return []
+ const arr = plistArrayStrings(raw, 'UISupportedInterfaceOrientations')
+ if (arr.length === 0) {
+ return [{
+ id: 'ios/plist-orientations-present',
+ severity: 'warning',
+ title: 'Info.plist declares no UISupportedInterfaceOrientations',
+ detail: 'Declare at least one supported interface orientation for iPhone.',
+ fix: 'Declare ≥1 valid UIInterfaceOrientation* value.',
+ }]
+ }
+ const bad = arr.find(o => !ORIENTATION_SET.has(o))
+ if (bad !== undefined) {
+ return [{
+ id: 'ios/plist-orientations-present',
+ severity: 'warning',
+ title: `Invalid interface orientation "${bad}"`,
+ detail: 'Each entry must be one of UIInterfaceOrientationPortrait, PortraitUpsideDown, LandscapeLeft, LandscapeRight.',
+ fix: 'Declare ≥1 valid UIInterfaceOrientation* value.',
+ }]
+ }
+ return []
+ },
+}
+
+export const plistDisplayName: PrescanCheck = {
+ id: 'ios/plist-display-name',
+ platforms: ['ios'],
+ appliesTo: hasInfoPlist,
+ async run(ctx): Promise {
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return []
+ const pbxContent = pbx(ctx.projectDir)
+ // A resolved value is "good" only when it is a non-empty literal (not a
+ // still-unresolved $() reference).
+ const resolved = (key: string): string | null => {
+ const v = plistString(raw, key)
+ if (v === null)
+ return null
+ const r = resolvePlistValue(v, pbxContent)
+ if (isUnresolvedRef(r) || r.trim() === '')
+ return null
+ return r
+ }
+ if (resolved('CFBundleDisplayName') !== null || resolved('CFBundleName') !== null)
+ return []
+ return [{
+ id: 'ios/plist-display-name',
+ severity: 'warning',
+ title: 'Info.plist has no resolvable app display name',
+ detail: 'Neither CFBundleDisplayName nor CFBundleName resolves to a non-empty value.',
+ fix: 'Set CFBundleDisplayName or ensure PRODUCT_NAME resolves.',
+ }]
+ },
+}
+
+const LOCATION_USAGE_KEYS = [
+ 'NSLocationWhenInUseUsageDescription',
+ 'NSLocationAlwaysAndWhenInUseUsageDescription',
+ 'NSLocationAlwaysUsageDescription',
+ 'NSLocationUsageDescription',
+]
+
+export const plistBackgroundModesSanity: PrescanCheck = {
+ id: 'ios/plist-background-modes-sanity',
+ platforms: ['ios'],
+ appliesTo(ctx) {
+ if (!hasInfoPlist(ctx))
+ return false
+ const raw = readInfoPlist(ctx.projectDir)
+ return raw !== null && plistHasKey(raw, 'UIBackgroundModes')
+ },
+ async run(ctx): Promise {
+ const raw = readInfoPlist(ctx.projectDir)
+ if (raw === null)
+ return []
+ const modes = plistArrayStrings(raw, 'UIBackgroundModes')
+ const findings: Finding[] = []
+ // (a) unknown tokens — structural sanity, never a hard error.
+ const invalid = modes.filter(m => !BACKGROUND_MODES.has(m))
+ if (invalid.length > 0) {
+ findings.push({
+ id: 'ios/plist-background-modes-sanity',
+ severity: 'warning',
+ title: `Unknown UIBackgroundModes value(s): ${invalid.join(', ')}`,
+ detail: 'Background modes must be one of Apple\'s documented UIBackgroundModes tokens.',
+ fix: 'Remove unused background modes; add matching usage strings/capabilities.',
+ })
+ }
+ // (b) location mode without a usage string — Guideline 2.5.4. Gate to upload.
+ if (modes.includes('location') && willUploadToAppStore(ctx)) {
+ const hasUsage = LOCATION_USAGE_KEYS.some(k => plistHasKey(raw, k))
+ if (!hasUsage) {
+ findings.push({
+ id: 'ios/plist-background-modes-sanity',
+ severity: 'warning',
+ title: 'UIBackgroundModes declares "location" without a location usage description (Guideline 2.5.4)',
+ detail: 'App Review rejects background location without an NSLocation*UsageDescription string.',
+ fix: 'Add the matching NSLocation*UsageDescription, or remove the location background mode.',
+ })
+ }
+ }
+ return findings
+ },
+}
diff --git a/cli/src/build/prescan/checks/ios-plist-read.ts b/cli/src/build/prescan/checks/ios-plist-read.ts
new file mode 100644
index 0000000000..80ba8c2e9b
--- /dev/null
+++ b/cli/src/build/prescan/checks/ios-plist-read.ts
@@ -0,0 +1,98 @@
+// src/build/prescan/checks/ios-plist-read.ts
+//
+// Shared, value-typed readers for iOS Info.plist / entitlements XML. Targeted
+// regexes are deliberate: plist sibling order is lost in object-mode XML
+// parsers, so full-tree parsing buys nothing for the shallow keys the prescan
+// checks inspect, and avoids a parser dependency entirely.
+//
+// Every reader is pure and NEVER throws — it returns null/[]/false on a missing
+// key or malformed input. Key regexes are built with escapeRegex so a key that
+// contains regex metacharacters (e.g. dotted reverse-DNS keys) can never make
+// the pattern invalid.
+
+function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+/**
+ * Top-level `KV` value, or null when absent.
+ * V is returned verbatim, including an unresolved `$(VAR)` build-variable
+ * reference — callers run it through resolvePlistValue before validating.
+ */
+export function plistString(raw: string, key: string): string | null {
+ const re = new RegExp(`${escapeRegex(key)}\\s*([\\s\\S]*?)`)
+ return raw.match(re)?.[1] ?? null
+}
+
+/**
+ * Boolean for `K\s*|`. Returns null when the key is
+ * absent OR maps to a non-bool value — null is meaningfully distinct from false
+ * (e.g. UIRequiresFullScreen absent vs. explicitly false).
+ */
+export function plistBool(raw: string, key: string): boolean | null {
+ const re = new RegExp(`${escapeRegex(key)}\\s*<(true|false)\\s*/>`)
+ const m = raw.match(re)
+ if (!m)
+ return null
+ return m[1] === 'true'
+}
+
+/** Presence-only check for a `K` element. */
+export function plistHasKey(raw: string, key: string): boolean {
+ return raw.includes(`${key}`)
+}
+
+/**
+ * The `` children of `K\s*...`, or [] when the
+ * key is absent / not an array. Non-greedy so the first `` wins. Note
+ * that the key match is anchored on ``, so a suffixed key such as
+ * `K~ipad` is NOT matched by a request for the plain `K` (and vice-versa).
+ */
+export function plistArrayStrings(raw: string, key: string): string[] {
+ const re = new RegExp(`${escapeRegex(key)}\\s*([\\s\\S]*?)`)
+ const block = raw.match(re)?.[1]
+ if (block === undefined)
+ return []
+ const out: string[] = []
+ for (const m of block.matchAll(/([\s\S]*?)<\/string>/g))
+ out.push(m[1].trim())
+ return out
+}
+
+/**
+ * Inner text of `K\s*...`, or null when the key is
+ * absent. Returns the FULL balanced dict: it scans `` / `` depth
+ * from the opening dict to its matching close, so arbitrarily nested dicts are
+ * captured intact. This matters for keys like NSAppTransportSecurity, where a
+ * nested NSExceptionDomains dict appearing BEFORE a sibling such as
+ * NSAllowsArbitraryLoads would otherwise truncate the block at the first
+ * `` and hide the sibling. Used to scope reads such as
+ * NSAppTransportSecurity. Pure; returns null on a missing key or an unbalanced
+ * (malformed) dict.
+ */
+export function plistDictBlock(raw: string, key: string): string | null {
+ // Locate the opening `` that immediately follows the key.
+ const openRe = new RegExp(`${escapeRegex(key)}\\s*`)
+ const openMatch = raw.match(openRe)
+ if (openMatch?.index === undefined)
+ return null
+ const innerStart = openMatch.index + openMatch[0].length
+
+ // Walk forward tracking nesting depth so the matching `` wins, not the
+ // first one. depth starts at 1 for the opening dict already consumed.
+ const tagRe = /|<\/dict>/g
+ tagRe.lastIndex = innerStart
+ let depth = 1
+ for (let m = tagRe.exec(raw); m !== null; m = tagRe.exec(raw)) {
+ if (m[0] === '') {
+ depth++
+ }
+ else {
+ depth--
+ if (depth === 0)
+ return raw.slice(innerStart, m.index)
+ }
+ }
+ // Unbalanced — no matching close. Mirror the other readers' "skip" contract.
+ return null
+}
diff --git a/cli/src/build/prescan/checks/ios-plist.ts b/cli/src/build/prescan/checks/ios-plist.ts
index 6c3ca62915..cb104f941c 100644
--- a/cli/src/build/prescan/checks/ios-plist.ts
+++ b/cli/src/build/prescan/checks/ios-plist.ts
@@ -2,17 +2,7 @@
import type { Finding, PrescanCheck } from '../types'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
-
-/**
- * Extract a top-level value for a key without relying on sibling ordering.
- * Targeted regexes are deliberate here: plist sibling order is lost in
- * fast-xml-parser's object mode, so full-tree parsing buys nothing for the
- * shallow string keys this check inspects.
- */
-function plistStringValue(raw: string, key: string): string | null {
- const re = new RegExp(`${key}\\s*([\\s\\S]*?)`)
- return raw.match(re)?.[1] ?? null
-}
+import { plistString } from './ios-plist-read'
// RFC 3986/1738 scheme grammar - no underscores. Exported so the Android
// deep-link check (manifest.ts re-exports this) validates schemes with the
@@ -71,7 +61,7 @@ export const infoplistSanity: PrescanCheck = {
for (const key of PURPOSE_KEYS) {
if (!raw.includes(`${key}`))
continue
- const value = plistStringValue(raw, key)
+ const value = plistString(raw, key)
if (value === null || PLACEHOLDERS.has(value.trim().toLowerCase()) || value.trim().length < 8) {
findings.push({
id: 'ios/infoplist-sanity',
diff --git a/cli/src/build/prescan/checks/ios-pods-assets.ts b/cli/src/build/prescan/checks/ios-pods-assets.ts
new file mode 100644
index 0000000000..4b44d80b8f
--- /dev/null
+++ b/cli/src/build/prescan/checks/ios-pods-assets.ts
@@ -0,0 +1,392 @@
+// src/build/prescan/checks/ios-pods-assets.ts
+//
+// §2.E (Pods / SPM) + §2.F (App icons / assets) of the iOS prescan expansion.
+// All checks are LOCAL (read only project files) and never throw - the parsers
+// (readContentsJson / readTextIfExists / readPbxproj) all return null/[] on
+// missing or malformed input, so a degraded project produces no findings rather
+// than a crash. Findings surface only file paths / icon filenames / bundle-id-free
+// structure, never credential material.
+//
+// ── Builder-grounded severity decisions ──────────────────────────────────────
+// Verified against the real cloud build:
+// - capgo_builder_new/.github/workflows/github-ios-build.yml (runner shell)
+// - capgo_builder_new/src/fastlaneTemplateIos.ts (the injected Fastfile)
+//
+// What the cloud build actually does:
+// 1. CocoaPods: the `install_pods` lane runs `cocoapods(repo_update: true)`
+// whenever a Podfile is found - it REGENERATES `Pods/` and the `.xcworkspace`.
+// => pods-not-installed and pods-capacitor-missing are NOT hard build-breakers
+// server-side, so both are DOWNGRADED error -> warning.
+// 2. SPM: `build_app` (gym -> xcodebuild) RESOLVES Swift Package dependencies
+// during the build; a missing Package.resolved is regenerated on the runner.
+// => spm-package-resolved-missing is DOWNGRADED error -> warning. The builder
+// never runs `npx cap sync`, so Package.swift is not regenerated, but the
+// `/capacitor-swift-pm/` + `.product(Capacitor)` regex is high-FP, so
+// spm-capacitor-dependency-missing is also DOWNGRADED error -> warning.
+// 3. AppIcon: `xcodebuild`/`actool` TOLERATE an empty AppIcon.appiconset for
+// ad_hoc/dev exports - an empty set only fails App Store *upload* validation
+// (ITMS-90704). => appicon-empty-or-placeholder is DOWNGRADED error -> warning
+// and UPLOAD-GATED back to error via willUploadToAppStore. A referenced PNG
+// that is MISSING from disk genuinely fails `actool` server-side, so
+// appicon-referenced-file-missing KEEPS error. appicon-marketing-missing stays
+// error but remains upload-gated (its appliesTo is willUploadToAppStore).
+
+import type { Finding, PrescanCheck, ScanContext } from '../types'
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { appIconSetDir, hasMarketingIcon, readContentsJson } from '../ios-appicon'
+import { readBuildSetting } from '../ios-pbxsettings'
+import { readPbxproj } from '../../pbxproj-parser'
+import { readTextIfExists } from '../gradle'
+
+// ── Layout discriminators (spec §2.E) ────────────────────────────────────────
+// CocoaPods = ios/App/Podfile present. SPM = ios/App/CapApp-SPM/Package.swift present.
+function podfilePath(projectDir: string): string {
+ return join(projectDir, 'ios', 'App', 'Podfile')
+}
+
+function packageSwiftPath(projectDir: string): string {
+ return join(projectDir, 'ios', 'App', 'CapApp-SPM', 'Package.swift')
+}
+
+function hasPodfile(ctx: ScanContext): boolean {
+ return existsSync(podfilePath(ctx.projectDir))
+}
+
+function hasPackageSwift(ctx: ScanContext): boolean {
+ return existsSync(packageSwiftPath(ctx.projectDir))
+}
+
+function pbxContent(ctx: ScanContext): string | null {
+ return readPbxproj(ctx.projectDir)
+}
+
+// willUploadToAppStore is re-implemented locally to avoid a circular import path
+// concern; mirror upload-intent.ts exactly (iOS + app_store mode default + full
+// ASC triplet). Kept tiny and in-sync with upload-intent.willUploadToAppStore.
+function uploading(ctx: ScanContext): boolean {
+ if (ctx.platform !== 'ios')
+ return false
+ const mode = ctx.distributionMode ?? 'app_store'
+ if (mode !== 'app_store')
+ return false
+ const c = ctx.credentials
+ return Boolean(c?.APPLE_KEY_ID && c?.APPLE_ISSUER_ID && c?.APPLE_KEY_CONTENT)
+}
+
+// ── §2.E Pods ─────────────────────────────────────────────────────────────────
+
+/**
+ * Podfile present but `Pods/` or `App.xcworkspace` missing. The cloud builder
+ * runs `pod install` (regenerating both), so this is a WARNING, not an error -
+ * it slows the first build and breaks local Xcode opens, but the server recovers.
+ */
+export const podsNotInstalled: PrescanCheck = {
+ id: 'ios/pods-not-installed',
+ platforms: ['ios'],
+ appliesTo: hasPodfile,
+ async run(ctx): Promise {
+ const appDir = join(ctx.projectDir, 'ios', 'App')
+ const podsDir = existsSync(join(appDir, 'Pods'))
+ const workspace = existsSync(join(appDir, 'App.xcworkspace'))
+ if (podsDir && workspace)
+ return []
+
+ const missing: string[] = []
+ if (!podsDir)
+ missing.push('ios/App/Pods')
+ if (!workspace)
+ missing.push('ios/App/App.xcworkspace')
+
+ return [{
+ id: 'ios/pods-not-installed',
+ severity: 'warning',
+ title: 'CocoaPods is not installed (missing Pods/ or App.xcworkspace)',
+ detail: `A Podfile exists but ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} missing. The cloud build runs pod install and recovers, but local Xcode builds will fail.`,
+ fix: 'Run `npx cap sync ios` (or `pod install`) and commit Pods/ + App.xcworkspace.',
+ }]
+ },
+}
+
+/** Podfile present but Podfile.lock absent - non-reproducible pod resolution. */
+export const podsLockMissing: PrescanCheck = {
+ id: 'ios/pods-lock-missing',
+ platforms: ['ios'],
+ appliesTo: hasPodfile,
+ async run(ctx): Promise {
+ if (readTextIfExists(join(ctx.projectDir, 'ios', 'App', 'Podfile.lock')) !== null)
+ return []
+ return [{
+ id: 'ios/pods-lock-missing',
+ severity: 'warning',
+ title: 'Podfile.lock is missing - pod versions are not pinned',
+ detail: 'Without Podfile.lock the cloud build resolves pod versions fresh, which can drift from your local versions.',
+ fix: 'Run `pod install` and commit Podfile.lock.',
+ }]
+ },
+}
+
+/**
+ * Podfile does not wire the Capacitor pod. `pod install` cannot inject a missing
+ * declaration, but the standard Capacitor Podfile uses the `capacitor_pods`
+ * helper / `require_relative` rather than a literal `pod 'Capacitor'`, so the
+ * literal-match heuristic is high false-positive. WARNING, not error.
+ */
+export const podsCapacitorMissing: PrescanCheck = {
+ id: 'ios/pods-capacitor-missing',
+ platforms: ['ios'],
+ appliesTo: hasPodfile,
+ async run(ctx): Promise {
+ const raw = readTextIfExists(podfilePath(ctx.projectDir))
+ if (raw === null)
+ return []
+ // Match a literal `pod 'Capacitor'` / `pod "Capacitor"` OR the standard
+ // capacitor_pods helper (require_relative .../@capacitor/ios or capacitor_pods).
+ const wiresCapacitor
+ = /pod\s+['"]Capacitor['"]/.test(raw)
+ || /capacitor_pods/.test(raw)
+ || /@capacitor\/ios/.test(raw)
+ if (wiresCapacitor)
+ return []
+ return [{
+ id: 'ios/pods-capacitor-missing',
+ severity: 'warning',
+ title: 'Podfile does not appear to wire the Capacitor pod',
+ detail: 'No `pod \'Capacitor\'`, `capacitor_pods` helper, or `@capacitor/ios` reference was found. If this is intentional (custom pod names) you can ignore this.',
+ fix: 'Run `npx cap sync ios` then `pod install`.',
+ }]
+ },
+}
+
+// ── §2.E SPM ────────────────────────────────────────────────────────────────
+
+/**
+ * SPM project with no Package.resolved in either known location. `build_app`
+ * (xcodebuild) resolves packages during the build, so this is a WARNING - a
+ * committed Package.resolved just makes resolution reproducible and faster.
+ */
+export const spmPackageResolvedMissing: PrescanCheck = {
+ id: 'ios/spm-package-resolved-missing',
+ platforms: ['ios'],
+ appliesTo: hasPackageSwift,
+ async run(ctx): Promise {
+ const xcodeprojResolved = join(
+ ctx.projectDir,
+ 'ios',
+ 'App',
+ 'App.xcodeproj',
+ 'project.xcworkspace',
+ 'xcshareddata',
+ 'swiftpm',
+ 'Package.resolved',
+ )
+ const capAppResolved = join(ctx.projectDir, 'ios', 'App', 'CapApp-SPM', 'Package.resolved')
+ if (existsSync(xcodeprojResolved) || existsSync(capAppResolved))
+ return []
+ return [{
+ id: 'ios/spm-package-resolved-missing',
+ severity: 'warning',
+ title: 'Swift Package Manager Package.resolved is missing',
+ detail: 'No Package.resolved found under the xcodeproj or CapApp-SPM. The cloud build resolves packages during build_app, but committing Package.resolved pins versions for reproducible builds.',
+ fix: 'Run `xcodebuild -resolvePackageDependencies` (or open in Xcode) and commit Package.resolved.',
+ }]
+ },
+}
+
+/**
+ * Package.swift does not declare the capacitor-swift-pm dependency / the
+ * Capacitor product. The builder never runs `cap sync`, but Package.swift is
+ * CLI-managed ("DO NOT MODIFY") and the regex is high false-positive on valid
+ * variants, so this is a WARNING, not an error.
+ */
+export const spmCapacitorDependencyMissing: PrescanCheck = {
+ id: 'ios/spm-capacitor-dependency-missing',
+ platforms: ['ios'],
+ appliesTo: hasPackageSwift,
+ async run(ctx): Promise {
+ const raw = readTextIfExists(packageSwiftPath(ctx.projectDir))
+ if (raw === null)
+ return []
+ const hasPackage = /capacitor-swift-pm/.test(raw)
+ const hasProduct = /\.product\(\s*name:\s*['"]Capacitor['"]/.test(raw)
+ if (hasPackage && hasProduct)
+ return []
+ return [{
+ id: 'ios/spm-capacitor-dependency-missing',
+ severity: 'warning',
+ title: 'Package.swift does not declare the Capacitor SPM dependency',
+ detail: 'Expected a capacitor-swift-pm package dependency and a `.product(name: "Capacitor", ...)` target dependency. If your project uses a non-standard layout you can ignore this.',
+ fix: 'Run `npx cap sync ios` to regenerate Package.swift.',
+ }]
+ },
+}
+
+// ── §2.F App icons / assets ───────────────────────────────────────────────────
+
+/**
+ * AppIcon.appiconset directory missing, Contents.json missing/malformed, or no
+ * icon images declared. The directory-missing and malformed-Contents cases are
+ * always errors (the asset catalog is structurally broken). The "no images"
+ * case is a WARNING by default (actool tolerates it for ad_hoc/dev) and
+ * ESCALATES to error when uploading to the App Store (ITMS-90704 blocks upload).
+ */
+export const appiconEmptyOrPlaceholder: PrescanCheck = {
+ id: 'ios/appicon-empty-or-placeholder',
+ platforms: ['ios'],
+ async run(ctx): Promise {
+ const dir = appIconSetDir(ctx.projectDir, pbxContent(ctx) ?? undefined)
+ if (!existsSync(dir)) {
+ return [{
+ id: 'ios/appicon-empty-or-placeholder',
+ severity: 'error',
+ title: 'AppIcon.appiconset is missing',
+ detail: `Expected an app-icon asset set at ${dir.replace(ctx.projectDir, '.')} but the directory does not exist.`,
+ fix: 'Run `npx @capacitor/assets generate` (or add the icon set in Xcode).',
+ }]
+ }
+
+ const c = readContentsJson(join(dir, 'Contents.json'))
+ if (c === null) {
+ return [{
+ id: 'ios/appicon-empty-or-placeholder',
+ severity: 'error',
+ title: 'AppIcon Contents.json is missing or malformed',
+ detail: 'The app-icon Contents.json could not be parsed as JSON.',
+ fix: 'Regenerate the icon set (`npx @capacitor/assets generate`) or fix the malformed Contents.json.',
+ }]
+ }
+
+ const hasImage = (c.images ?? []).some(i => i.filename?.trim())
+ if (hasImage)
+ return []
+
+ // No icon images: warning normally, error when it would block an upload.
+ return [{
+ id: 'ios/appicon-empty-or-placeholder',
+ severity: uploading(ctx) ? 'error' : 'warning',
+ title: 'AppIcon.appiconset declares no icon images',
+ detail: uploading(ctx)
+ ? 'The icon set has no images - App Store upload will reject it (ITMS-90704).'
+ : 'The icon set has no images. The build tolerates this for ad_hoc/development, but App Store upload would reject it.',
+ fix: 'Run `npx @capacitor/assets generate` so the set has at least the 1024x1024 icon.',
+ }]
+ },
+}
+
+/**
+ * A Contents.json image entry references a filename that is missing from disk.
+ * `actool` FAILS server-side in this case, so this KEEPS error severity.
+ */
+export const appiconReferencedFileMissing: PrescanCheck = {
+ id: 'ios/appicon-referenced-file-missing',
+ platforms: ['ios'],
+ appliesTo(ctx): boolean {
+ const dir = appIconSetDir(ctx.projectDir, readPbxproj(ctx.projectDir) ?? undefined)
+ return readContentsJson(join(dir, 'Contents.json')) !== null
+ },
+ async run(ctx): Promise {
+ const dir = appIconSetDir(ctx.projectDir, pbxContent(ctx) ?? undefined)
+ const c = readContentsJson(join(dir, 'Contents.json'))
+ if (c === null)
+ return []
+
+ const missing: string[] = []
+ for (const image of c.images ?? []) {
+ const filename = image.filename?.trim()
+ if (!filename)
+ continue
+ if (!existsSync(join(dir, filename)))
+ missing.push(filename)
+ }
+ if (missing.length === 0)
+ return []
+
+ return [{
+ id: 'ios/appicon-referenced-file-missing',
+ severity: 'error',
+ title: 'AppIcon Contents.json references icon file(s) missing from disk',
+ detail: `actool will fail the build: missing ${missing.join(', ')}.`,
+ fix: 'Regenerate the icons (`npx @capacitor/assets generate`) or commit the referenced PNG(s).',
+ }]
+ },
+}
+
+/**
+ * App Store marketing icon (1024x1024 / role=marketing) missing. Upload-gated:
+ * ad_hoc/dev builds do not need it, but App Store upload requires it
+ * (ITMS-90704). KEEPS error severity.
+ */
+export const appiconMarketingMissing: PrescanCheck = {
+ id: 'ios/appicon-marketing-missing',
+ platforms: ['ios'],
+ appliesTo: uploading,
+ async run(ctx): Promise {
+ const dir = appIconSetDir(ctx.projectDir, pbxContent(ctx) ?? undefined)
+ const c = readContentsJson(join(dir, 'Contents.json'))
+ // A missing/empty set is owned by appicon-empty-or-placeholder; this check
+ // only fires when a set exists but lacks the marketing icon specifically.
+ if (c === null)
+ return []
+ if (hasMarketingIcon(c))
+ return []
+ return [{
+ id: 'ios/appicon-marketing-missing',
+ severity: 'error',
+ title: 'App Store marketing icon (1024x1024) is missing',
+ detail: 'App Store upload requires a 1024x1024 marketing icon in the AppIcon set (ITMS-90704).',
+ fix: 'Add a 1024x1024 PNG (no alpha) marketing icon and reference it in Contents.json.',
+ }]
+ },
+}
+
+// ── §2.F SPM deployment-target consistency ─────────────────────────────────────
+
+const SPM_MIN_RE = /\.iOS\(\.v(\d+)\)/
+
+/**
+ * SPM project where the pbxproj IPHONEOS_DEPLOYMENT_TARGET is LOWER than the
+ * Package.swift platform minimum (`.iOS(.vN)`) - the dangerous direction: the
+ * package requires a newer floor than the app builds against. Warning only;
+ * skipped entirely when IPHONEOS_DEPLOYMENT_TARGET is absent (inherited/unknown).
+ */
+export const spmDeploymentTargetConsistency: PrescanCheck = {
+ id: 'ios/spm-deployment-target-consistency',
+ platforms: ['ios'],
+ appliesTo(ctx): boolean {
+ if (!hasPackageSwift(ctx))
+ return false
+ const pbx = readPbxproj(ctx.projectDir)
+ if (pbx === null)
+ return false
+ return readBuildSetting(pbx, 'IPHONEOS_DEPLOYMENT_TARGET') !== null
+ },
+ async run(ctx): Promise {
+ const pbx = pbxContent(ctx)
+ if (pbx === null)
+ return []
+ const raw = readBuildSetting(pbx, 'IPHONEOS_DEPLOYMENT_TARGET')
+ if (raw === null)
+ return []
+ const pbxTarget = Number.parseFloat(raw)
+ if (Number.isNaN(pbxTarget))
+ return []
+
+ const packageSwift = readTextIfExists(packageSwiftPath(ctx.projectDir))
+ if (packageSwift === null)
+ return []
+ const m = packageSwift.match(SPM_MIN_RE)
+ if (!m)
+ return []
+ const spmMin = Number.parseFloat(m[1])
+ if (Number.isNaN(spmMin) || pbxTarget >= spmMin)
+ return []
+
+ return [{
+ id: 'ios/spm-deployment-target-consistency',
+ severity: 'warning',
+ title: 'IPHONEOS_DEPLOYMENT_TARGET is below the Package.swift iOS minimum',
+ detail: `The Xcode app target builds for iOS ${pbxTarget} but Package.swift requires iOS ${spmMin} (.iOS(.v${spmMin})).`,
+ fix: `Raise IPHONEOS_DEPLOYMENT_TARGET to >= ${spmMin}, or run \`npx cap sync ios\`.`,
+ }]
+ },
+}
diff --git a/cli/src/build/prescan/checks/ios-xcode.ts b/cli/src/build/prescan/checks/ios-xcode.ts
new file mode 100644
index 0000000000..88be13d495
--- /dev/null
+++ b/cli/src/build/prescan/checks/ios-xcode.ts
@@ -0,0 +1,324 @@
+// src/build/prescan/checks/ios-xcode.ts
+//
+// §2.B "Xcode project / build settings" prescan checks. All local (read only
+// the project's pbxproj). They build on the scalar build-setting readers in
+// ios-pbxsettings (readBuildSetting / readBuildConfigs / readTargetConfigs),
+// the signable-target walk in pbxproj-parser, the shared capacitorMajor, and
+// willUploadToAppStore for upload-aware severity.
+//
+// FP guards baked in (see the spec §0 ground-truth facts):
+// - signing-team is SUPPRESSED entirely when a provisioning map is present
+// (the cloud builder injects the team + switches to managed-manual signing).
+// - every "value below floor / bad value" check only flags a PRESENT scalar;
+// an ABSENT key means "inherited/unknown" and is skipped (no finding),
+// because build-setting inheritance / xcconfig is not resolved here.
+import type { PbxTarget } from '../../pbxproj-parser'
+import type { Finding, PrescanCheck, ScanContext } from '../types'
+import { findSignableTargets, readPbxproj } from '../../pbxproj-parser'
+import { capacitorMajor } from '../capacitor-version'
+import { readBuildConfigs, readBuildSetting, readTargetConfigs } from '../ios-pbxsettings'
+import { willUploadToAppStore } from '../upload-intent'
+import { parseProvisioningMap } from './ios-profiles'
+
+const APPLICATION_PRODUCT_TYPE = 'com.apple.product-type.application'
+
+/** pbxproj content for the project, or null when no Xcode project is found. */
+function pbxOf(ctx: ScanContext): string | null {
+ return readPbxproj(ctx.projectDir)
+}
+
+/** Application-product-type targets only (extensions/watch apps excluded). */
+function appTargets(pbxContent: string): PbxTarget[] {
+ return findSignableTargets(pbxContent).filter(t => t.productType === APPLICATION_PRODUCT_TYPE)
+}
+
+// ── ios/xcode-deployment-target-capacitor ────────────────────────────────────
+
+// Build-breaking deployment-target floor per Capacitor major. Mirrors the
+// android-style Record + default pattern. Capacitor 5/6 require
+// iOS 13, 7/8 require iOS 14; unknown/newer majors default to 14.
+const CAP_IOS_DEPLOYMENT_FLOORS: Record = { 5: 13, 6: 13, 7: 14, 8: 14 }
+const CAP_IOS_DEPLOYMENT_DEFAULT = 14
+
+function capacitorDeploymentFloor(major: number): number {
+ return CAP_IOS_DEPLOYMENT_FLOORS[major] ?? CAP_IOS_DEPLOYMENT_DEFAULT
+}
+
+/**
+ * Lowest PRESENT IPHONEOS_DEPLOYMENT_TARGET across the project-level config and
+ * each app target (Release-preferred via readBuildSetting), parsed as a float;
+ * null when the key is absent everywhere (inherited — cannot judge).
+ */
+function presentDeploymentTarget(pbxContent: string): number | null {
+ const raw = readBuildSetting(pbxContent, 'IPHONEOS_DEPLOYMENT_TARGET')
+ if (raw === null)
+ return null
+ const n = Number.parseFloat(raw)
+ return Number.isNaN(n) ? null : n
+}
+
+export const deploymentTargetCapacitor: PrescanCheck = {
+ id: 'ios/xcode-deployment-target-capacitor',
+ platforms: ['ios'],
+ appliesTo: (ctx) => {
+ if (capacitorMajor(ctx.projectDir) === null)
+ return false
+ const pbx = pbxOf(ctx)
+ return pbx !== null && presentDeploymentTarget(pbx) !== null
+ },
+ async run(ctx): Promise {
+ const pbx = pbxOf(ctx)
+ if (!pbx)
+ return []
+ const major = capacitorMajor(ctx.projectDir)
+ const target = presentDeploymentTarget(pbx)
+ if (major === null || target === null)
+ return []
+ const floor = capacitorDeploymentFloor(major)
+ if (target >= floor)
+ return []
+ return [{
+ id: 'ios/xcode-deployment-target-capacitor',
+ severity: 'error',
+ title: `IPHONEOS_DEPLOYMENT_TARGET ${target} is below the Capacitor ${major} floor (${floor})`,
+ detail: `Capacitor ${major} requires iOS deployment target >= ${floor}; the build will fail otherwise.`,
+ fix: `Raise IPHONEOS_DEPLOYMENT_TARGET to >= ${floor} (project + app target) and the Podfile platform line if present.`,
+ }]
+ },
+}
+
+// ── ios/xcode-signing-team ───────────────────────────────────────────────────
+
+const hasProvisioningMap = (ctx: ScanContext): boolean => parseProvisioningMap(ctx).length > 0
+
+/**
+ * Release-preferred (fallback Debug) lookup of a scalar within a single target's
+ * configs. Mirrors the Release-vs-fallback walk readBuildSetting uses, but
+ * scoped to one target's config set.
+ */
+function targetSetting(configs: { name: string, settings: Record }[], key: string): string | undefined {
+ const release = configs.find(c => c.name === 'Release')?.settings[key]
+ if (release !== undefined)
+ return release
+ const debug = configs.find(c => c.name === 'Debug')?.settings[key]
+ if (debug !== undefined)
+ return debug
+ for (const c of configs) {
+ if (c.settings[key] !== undefined)
+ return c.settings[key]
+ }
+ return undefined
+}
+
+/** Signable targets that set CODE_SIGN_STYLE but have no (non-empty) DEVELOPMENT_TEAM. */
+function targetsMissingTeam(pbxContent: string): string[] {
+ const out: string[] = []
+ for (const { target, configs } of readTargetConfigs(pbxContent)) {
+ const style = targetSetting(configs, 'CODE_SIGN_STYLE')
+ if (style === undefined)
+ continue // signing style inherited — nothing to judge
+ const team = targetSetting(configs, 'DEVELOPMENT_TEAM')
+ if (team === undefined || team.trim() === '')
+ out.push(target.name)
+ }
+ return out
+}
+
+export const signingTeam: PrescanCheck = {
+ id: 'ios/xcode-signing-team',
+ platforms: ['ios'],
+ appliesTo: (ctx) => {
+ // Suppress entirely when a provisioning map is present: the builder injects
+ // the team and switches to managed-manual signing, so a locally-absent
+ // DEVELOPMENT_TEAM is not load-bearing.
+ if (hasProvisioningMap(ctx))
+ return false
+ const pbx = pbxOf(ctx)
+ return pbx !== null && targetsMissingTeam(pbx).length > 0
+ },
+ async run(ctx): Promise {
+ if (hasProvisioningMap(ctx))
+ return []
+ const pbx = pbxOf(ctx)
+ if (!pbx)
+ return []
+ const missing = targetsMissingTeam(pbx)
+ if (missing.length === 0)
+ return []
+ const uploading = willUploadToAppStore(ctx)
+ return [{
+ id: 'ios/xcode-signing-team',
+ severity: uploading ? 'error' : 'warning',
+ title: `${missing.length} signable target(s) set a code-signing style but no DEVELOPMENT_TEAM`,
+ detail: `targets without a team: ${missing.join(', ')}`,
+ fix: 'Set DEVELOPMENT_TEAM in Xcode (Signing & Capabilities), or supply signing creds/profiles to the cloud build so it switches to managed-manual signing.',
+ }]
+ },
+}
+
+// ── ios/xcode-bundle-id-mismatch-across-configs ──────────────────────────────
+
+/** Signable targets that PRESENT >= 2 distinct PRODUCT_BUNDLE_IDENTIFIER values across configs. */
+function targetsWithBundleIdConflict(pbxContent: string): { name: string, values: string[] }[] {
+ const out: { name: string, values: string[] }[] = []
+ for (const { target, configs } of readTargetConfigs(pbxContent)) {
+ const present = configs
+ .map(c => c.settings.PRODUCT_BUNDLE_IDENTIFIER)
+ .filter((v): v is string => v !== undefined)
+ if (present.length < 2)
+ continue // single-config or all-inherited — nothing to compare
+ const distinct = Array.from(new Set(present))
+ if (distinct.length >= 2)
+ out.push({ name: target.name, values: distinct })
+ }
+ return out
+}
+
+export const bundleIdMismatchAcrossConfigs: PrescanCheck = {
+ id: 'ios/xcode-bundle-id-mismatch-across-configs',
+ platforms: ['ios'],
+ appliesTo: (ctx) => {
+ const pbx = pbxOf(ctx)
+ return pbx !== null && readTargetConfigs(pbx).some((t) => {
+ const present = t.configs.map(c => c.settings.PRODUCT_BUNDLE_IDENTIFIER).filter(v => v !== undefined)
+ return present.length >= 2
+ })
+ },
+ async run(ctx): Promise {
+ const pbx = pbxOf(ctx)
+ if (!pbx)
+ return []
+ const conflicts = targetsWithBundleIdConflict(pbx)
+ if (conflicts.length === 0)
+ return []
+ return [{
+ id: 'ios/xcode-bundle-id-mismatch-across-configs',
+ severity: 'warning',
+ title: `PRODUCT_BUNDLE_IDENTIFIER differs across build configs in ${conflicts.length} target(s)`,
+ detail: conflicts.map(c => `${c.name}: ${c.values.join(' vs ')}`).join('; '),
+ fix: 'Align PRODUCT_BUNDLE_IDENTIFIER across Debug/Release (or ignore if a Debug suffix is intentional).',
+ }]
+ },
+}
+
+// ── ios/xcode-enable-bitcode-leftover ────────────────────────────────────────
+
+/** Config names (project + target) that PRESENT ENABLE_BITCODE == YES. */
+function bitcodeYesConfigs(pbxContent: string): string[] {
+ return readBuildConfigs(pbxContent)
+ .filter(c => c.settings.ENABLE_BITCODE === 'YES')
+ .map(c => (c.isProjectLevel ? `project ${c.name}` : c.name))
+}
+
+export const enableBitcodeLeftover: PrescanCheck = {
+ id: 'ios/xcode-enable-bitcode-leftover',
+ platforms: ['ios'],
+ appliesTo: (ctx) => {
+ const pbx = pbxOf(ctx)
+ return pbx !== null && bitcodeYesConfigs(pbx).length > 0
+ },
+ async run(ctx): Promise {
+ const pbx = pbxOf(ctx)
+ if (!pbx)
+ return []
+ const configs = bitcodeYesConfigs(pbx)
+ if (configs.length === 0)
+ return []
+ return [{
+ id: 'ios/xcode-enable-bitcode-leftover',
+ severity: 'warning',
+ title: 'ENABLE_BITCODE = YES is set (deprecated since Xcode 14)',
+ detail: `present in: ${configs.join(', ')}`,
+ fix: 'Set ENABLE_BITCODE = NO or delete it — Xcode 14+ ignores bitcode and Apple no longer accepts it.',
+ }]
+ },
+}
+
+// ── ios/xcode-swift-version-sanity ───────────────────────────────────────────
+
+/** Signable targets whose PRESENT SWIFT_VERSION is < 5 or not a leading number. */
+function targetsWithBadSwiftVersion(pbxContent: string): { name: string, value: string }[] {
+ const out: { name: string, value: string }[] = []
+ for (const { target, configs } of readTargetConfigs(pbxContent)) {
+ const value = targetSetting(configs, 'SWIFT_VERSION')
+ if (value === undefined)
+ continue // absent — Obj-C-only target, skip
+ const m = value.match(/^\s*(\d+(?:\.\d+)?)/)
+ const major = m ? Number.parseFloat(m[1]) : Number.NaN
+ if (Number.isNaN(major) || major < 5)
+ out.push({ name: target.name, value })
+ }
+ return out
+}
+
+export const swiftVersionSanity: PrescanCheck = {
+ id: 'ios/xcode-swift-version-sanity',
+ platforms: ['ios'],
+ appliesTo: (ctx) => {
+ const pbx = pbxOf(ctx)
+ return pbx !== null && readTargetConfigs(pbx).some(t => targetSetting(t.configs, 'SWIFT_VERSION') !== undefined)
+ },
+ async run(ctx): Promise {
+ const pbx = pbxOf(ctx)
+ if (!pbx)
+ return []
+ const bad = targetsWithBadSwiftVersion(pbx)
+ if (bad.length === 0)
+ return []
+ return [{
+ id: 'ios/xcode-swift-version-sanity',
+ severity: 'warning',
+ title: `SWIFT_VERSION is below 5 (or unparseable) in ${bad.length} target(s)`,
+ detail: bad.map(b => `${b.name}: ${b.value}`).join('; '),
+ fix: 'Set SWIFT_VERSION = 5.0 (or your intended Swift >= 5) for the affected target(s).',
+ }]
+ },
+}
+
+// ── ios/xcode-no-app-target ──────────────────────────────────────────────────
+
+export const noAppTarget: PrescanCheck = {
+ id: 'ios/xcode-no-app-target',
+ platforms: ['ios'],
+ appliesTo: ctx => pbxOf(ctx) !== null,
+ async run(ctx): Promise {
+ const pbx = pbxOf(ctx)
+ if (!pbx)
+ return []
+ if (appTargets(pbx).length > 0)
+ return []
+ return [{
+ id: 'ios/xcode-no-app-target',
+ severity: 'error',
+ title: 'No application target found in the Xcode project',
+ detail: 'The pbxproj parsed but contains no com.apple.product-type.application target — the build has nothing to archive.',
+ fix: 'Run `npx cap sync ios`, or restore the application target in Xcode.',
+ }]
+ },
+}
+
+// ── ios/xcode-multiple-app-targets ───────────────────────────────────────────
+
+export const multipleAppTargets: PrescanCheck = {
+ id: 'ios/xcode-multiple-app-targets',
+ platforms: ['ios'],
+ appliesTo: (ctx) => {
+ const pbx = pbxOf(ctx)
+ return pbx !== null && appTargets(pbx).length > 1
+ },
+ async run(ctx): Promise {
+ const pbx = pbxOf(ctx)
+ if (!pbx)
+ return []
+ const apps = appTargets(pbx)
+ if (apps.length <= 1)
+ return []
+ return [{
+ id: 'ios/xcode-multiple-app-targets',
+ severity: 'warning',
+ title: `${apps.length} application targets found — the build may sign/archive the wrong one`,
+ detail: `app targets: ${apps.map(t => `${t.name} (${t.bundleId})`).join(', ')}`,
+ fix: 'Keep a single application target, remove the duplicate, or pass the intended scheme to the build.',
+ }]
+ },
+}
diff --git a/cli/src/build/prescan/ios-appicon.ts b/cli/src/build/prescan/ios-appicon.ts
new file mode 100644
index 0000000000..82251f357b
--- /dev/null
+++ b/cli/src/build/prescan/ios-appicon.ts
@@ -0,0 +1,59 @@
+// src/build/prescan/ios-appicon.ts
+//
+// Reader for the AppIcon asset-catalog Contents.json. Contents.json is real
+// JSON, so JSON.parse (in try/catch) is the parse-safety net — readContentsJson
+// NEVER throws (null on missing/malformed). The other helpers are pure.
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { readBuildSetting } from './ios-pbxsettings'
+
+export interface AppIconImage {
+ idiom?: string
+ size?: string
+ scale?: string
+ filename?: string
+ platform?: string
+ role?: string
+}
+
+export interface AssetContents {
+ images?: AppIconImage[]
+ info?: { version?: number, author?: string }
+}
+
+/**
+ * Parse an asset-catalog Contents.json. Returns null on a missing file OR a
+ * parse error — the asset catalog is the one place an invalid hand-edit can
+ * crash a naive reader, so this is the shared safety net for the appicon checks.
+ * Never throws.
+ */
+export function readContentsJson(path: string): AssetContents | null {
+ try {
+ return JSON.parse(readFileSync(path, 'utf8')) as AssetContents
+ }
+ catch {
+ return null
+ }
+}
+
+/**
+ * Absolute path to the project's `.appiconset` directory. The icon
+ * name comes from the pbxproj ASSETCATALOG_COMPILER_APPICON_NAME build setting
+ * (Release-preferred) and defaults to `AppIcon` for standard Capacitor layouts.
+ */
+export function appIconSetDir(projectDir: string, pbxContent?: string): string {
+ const iconName = (pbxContent ? readBuildSetting(pbxContent, 'ASSETCATALOG_COMPILER_APPICON_NAME') : null) ?? 'AppIcon'
+ return join(projectDir, 'ios', 'App', 'App', 'Assets.xcassets', `${iconName}.appiconset`)
+}
+
+function normalizeSize(size: string | undefined): string {
+ return (size ?? '').trim()
+}
+
+/**
+ * Whether the icon set declares the App Store marketing icon: a `1024x1024`
+ * size (whitespace-trimmed) or an image whose `role` is `marketing`.
+ */
+export function hasMarketingIcon(c: AssetContents | null): boolean {
+ return (c?.images ?? []).some(i => normalizeSize(i.size) === '1024x1024' || i.role === 'marketing')
+}
diff --git a/cli/src/build/prescan/ios-entitlements.ts b/cli/src/build/prescan/ios-entitlements.ts
new file mode 100644
index 0000000000..3d8e80b459
--- /dev/null
+++ b/cli/src/build/prescan/ios-entitlements.ts
@@ -0,0 +1,41 @@
+// src/build/prescan/ios-entitlements.ts
+//
+// Reader for the app's own entitlements file. The profile-side entitlements are
+// parsed by mobileprovision-parser (MobileprovisionDetail.profileEntitlements);
+// the entitlement checks compare the two. All readers are pure and never throw.
+import { existsSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { plistArrayStrings, plistBool, plistString } from './checks/ios-plist-read'
+
+/**
+ * Read the app entitlements plist at the Capacitor-convention path
+ * `ios/App/App/App.entitlements`. Returns `{ raw }` or null when the file is
+ * absent (or unreadable). Future enhancement: resolve the path via the pbxproj
+ * CODE_SIGN_ENTITLEMENTS setting; the default path is reliable for Capacitor.
+ */
+export function readAppEntitlements(projectDir: string): { raw: string } | null {
+ const path = join(projectDir, 'ios', 'App', 'App', 'App.entitlements')
+ if (!existsSync(path))
+ return null
+ try {
+ return { raw: readFileSync(path, 'utf8') }
+ }
+ catch {
+ return null
+ }
+}
+
+/** String entitlement value (e.g. aps-environment), or null when absent. */
+export function entString(raw: string, key: string): string | null {
+ return plistString(raw, key)
+}
+
+/** Array entitlement members (e.g. application-groups), or [] when absent. */
+export function entArray(raw: string, key: string): string[] {
+ return plistArrayStrings(raw, key)
+}
+
+/** Boolean entitlement (e.g. get-task-allow), or null when absent. */
+export function entBool(raw: string, key: string): boolean | null {
+ return plistBool(raw, key)
+}
diff --git a/cli/src/build/prescan/ios-pbxsettings.ts b/cli/src/build/prescan/ios-pbxsettings.ts
new file mode 100644
index 0000000000..d43050144f
--- /dev/null
+++ b/cli/src/build/prescan/ios-pbxsettings.ts
@@ -0,0 +1,187 @@
+// src/build/prescan/ios-pbxsettings.ts
+//
+// pbxproj scalar build-setting readers + the Info.plist `$(VAR)` resolver.
+// Implemented standalone (not by mutating pbxproj-parser) so the existing
+// signing pipeline stays untouched; reuses findSignableTargets/PbxTarget for the
+// target shape.
+//
+// CRITICAL parser caveat: the block capture allows ONE level of nested braces
+// (the `buildSettings = { ... }` dict), mirroring resolveBundleId. Array-valued
+// settings (`LD_RUNPATH_SEARCH_PATHS = ( ... )`, `GCC_PREPROCESSOR_DEFINITIONS`)
+// span parentheses/lines and are deliberately NOT captured — only single-line
+// SCALAR keys are returned. Build-setting inheritance / xcconfig is not
+// resolved: an ABSENT scalar means "unknown/inherited", which callers treat as
+// "skip". Every function is pure and returns null/[]/{} on malformed input.
+
+import type { PbxTarget } from '../pbxproj-parser'
+import { findSignableTargets } from '../pbxproj-parser'
+
+export interface BuildConfig {
+ name: string
+ settings: Record
+ isProjectLevel: boolean
+}
+
+export interface TargetConfigs {
+ target: PbxTarget
+ configs: { name: string, settings: Record }[]
+}
+
+function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+/**
+ * Pull the inner text of a single `buildSettings = { ... }` dict (one nested
+ * level). The outer block's name line lives outside this, captured separately.
+ */
+function buildSettingsBlock(configBlock: string): string {
+ const m = configBlock.match(/buildSettings\s*=\s*\{([\s\S]*?)\n\s*\};/)
+ return m?.[1] ?? ''
+}
+
+/** Parse single-line `KEY = "?VALUE"?;` scalar pairs; skip array/paren values. */
+function parseScalarSettings(settingsText: string): Record {
+ const out: Record = {}
+ for (const line of settingsText.split('\n')) {
+ const m = line.match(/^\s*([A-Z0-9_]+(?:\[[^\]]*\])?)\s*=\s*(.+?);\s*$/i)
+ if (!m)
+ continue
+ const key = m[1]
+ let value = m[2].trim()
+ // Skip array-valued settings — `( ... )` opens a multi-line list whose
+ // closing paren is not on this line; never treat it as a scalar.
+ if (value.startsWith('('))
+ continue
+ // Strip a single pair of surrounding double quotes.
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"'))
+ value = value.slice(1, -1)
+ out[key] = value
+ }
+ return out
+}
+
+/** Each XCBuildConfiguration block's id, name, and scalar settings dict. */
+function eachConfigBlock(pbxContent: string): { id: string, name: string, settings: Record }[] {
+ if (!pbxContent)
+ return []
+ const blockRe = /(\w+)\s*\/\*[^*]*\*\/\s*=\s*\{[^{}]*isa\s*=\s*XCBuildConfiguration;(?:[^{}]*\{[^}]*\})*[^}]*\}/g
+ const out: { id: string, name: string, settings: Record }[] = []
+ for (const m of pbxContent.matchAll(blockRe)) {
+ const block = m[0]
+ const id = m[1]
+ const nameMatch = block.match(/\bname\s*=\s*("[^"]*"|[^;\s]+)\s*;/)
+ const name = nameMatch ? nameMatch[1].replace(/^"|"$/g, '') : ''
+ out.push({ id, name, settings: parseScalarSettings(buildSettingsBlock(block)) })
+ }
+ return out
+}
+
+/** IDs of the configs referenced by the PBXProject's buildConfigurationList. */
+/** IDs of the configs referenced by the PBXProject's buildConfigurationList. */
+function projectLevelConfigIds(pbxContent: string): Set {
+ // The PBXProject block nests `attributes = { TargetAttributes = { ... } }`,
+ // so a generic non-greedy block capture would stop early. Anchor on the
+ // `isa = PBXProject;` marker and read the first buildConfigurationList that
+ // follows it (the project-level one).
+ const listId = pbxContent.match(/isa\s*=\s*PBXProject;[\s\S]*?buildConfigurationList\s*=\s*(\w+)/)?.[1]
+ if (!listId)
+ return new Set()
+ return configListIds(pbxContent, listId)
+}
+
+/** The build-config IDs listed inside a named XCConfigurationList block. */
+function configListIds(pbxContent: string, listId: string): Set {
+ const listRe = new RegExp(`${escapeRegex(listId)}\\s*\\/\\*[^*]*\\*\\/\\s*=\\s*\\{[^}]*isa\\s*=\\s*XCConfigurationList;[^}]*\\}`)
+ const listBlock = pbxContent.match(listRe)?.[0]
+ if (!listBlock)
+ return new Set()
+ const section = listBlock.match(/buildConfigurations\s*=\s*\(([^)]*)\)/)
+ if (!section)
+ return new Set()
+ return new Set(Array.from(section[1].matchAll(/(\w+)/g), m => m[1]))
+}
+
+/**
+ * Per-config build settings (Debug/Release/...) plus an isProjectLevel flag for
+ * the configs referenced by the PBXProject configuration list.
+ */
+export function readBuildConfigs(pbxContent: string): BuildConfig[] {
+ const projectIds = projectLevelConfigIds(pbxContent)
+ return eachConfigBlock(pbxContent).map(c => ({
+ name: c.name,
+ settings: c.settings,
+ isProjectLevel: projectIds.has(c.id),
+ }))
+}
+
+/**
+ * Release-preferred scalar lookup across every build config. Returns the value
+ * from a config named Release if one carries the key, else the first config
+ * that carries it (fallback), else null. SCALAR keys only.
+ */
+export function readBuildSetting(pbxContent: string, name: string): string | null {
+ let fallback: string | null = null
+ for (const c of eachConfigBlock(pbxContent)) {
+ const v = c.settings[name]
+ if (v === undefined)
+ continue
+ if (c.name === 'Release')
+ return v
+ if (fallback === null)
+ fallback = v
+ }
+ return fallback
+}
+
+/**
+ * If rawValue is EXACTLY one `$(VAR)` or `${VAR}` reference, substitute it via
+ * readBuildSetting(VAR); otherwise return rawValue unchanged. When the variable
+ * has no pbxproj match the raw `$()` string is returned so callers can treat a
+ * still-unresolved reference as "skip / cannot judge". A value that merely
+ * contains a `$()` among other text is left untouched (not a clean reference).
+ */
+export function resolvePlistValue(rawValue: string, pbxContent: string): string {
+ const m = rawValue.match(/^\$[({]([A-Z0-9_]+)[)}]$/i)
+ if (!m)
+ return rawValue
+ return readBuildSetting(pbxContent, m[1]) ?? rawValue
+}
+
+/**
+ * Per signable target, the full scalar settings dict of each of its configs.
+ * Reuses findSignableTargets for the target identity and re-walks the native
+ * target blocks to associate each target name with its configuration list.
+ */
+export function readTargetConfigs(pbxContent: string): TargetConfigs[] {
+ if (!pbxContent)
+ return []
+ const targets = findSignableTargets(pbxContent)
+ if (targets.length === 0)
+ return []
+
+ // name -> configurationList id, from the PBXNativeTarget blocks.
+ const targetConfigListByName = new Map()
+ const targetRe = /\w+\s*\/\*[^*]*\*\/\s*=\s*\{[^}]*isa\s*=\s*PBXNativeTarget;[^}]*\}/g
+ for (const tm of pbxContent.matchAll(targetRe)) {
+ const block = tm[0]
+ const nameMatch = block.match(/\bname\s*=\s*("[^"]*"|[^;\s]+)\s*;/)
+ const listMatch = block.match(/buildConfigurationList\s*=\s*(\w+)/)
+ if (!nameMatch || !listMatch)
+ continue
+ targetConfigListByName.set(nameMatch[1].replace(/^"|"$/g, ''), listMatch[1])
+ }
+
+ const allBlocks = eachConfigBlock(pbxContent)
+ const blockById = new Map(allBlocks.map(b => [b.id, b]))
+
+ return targets.map((target) => {
+ const listId = targetConfigListByName.get(target.name)
+ const ids = listId ? configListIds(pbxContent, listId) : new Set()
+ const configs = Array.from(ids)
+ .map(id => blockById.get(id))
+ .filter((b): b is NonNullable => b !== undefined)
+ .map(b => ({ name: b.name, settings: b.settings }))
+ return { target, configs }
+ })
+}
diff --git a/cli/src/build/prescan/registry.ts b/cli/src/build/prescan/registry.ts
index 5b9f974325..0f3f96c964 100644
--- a/cli/src/build/prescan/registry.ts
+++ b/cli/src/build/prescan/registry.ts
@@ -37,6 +37,49 @@ import { credentialsSaved } from './checks/credentials'
import { ascKeyValid, p12Expiry, p12Opens } from './checks/ios-certs'
import { infoplistSanity } from './checks/ios-plist'
import { certProfilePairing, profileBundleMatch, profileExpiry, profileTypeVsMode, targetsCovered } from './checks/ios-profiles'
+import {
+ allowNavigationWildcard,
+ serverCleartext,
+ serverUrlShipped,
+} from './checks/ios-capacitor-config'
+import {
+ appGroupsFormat,
+ apsEnvironmentVsMode,
+ associatedDomainsFormat,
+ entitlementsVsProfileCapability,
+} from './checks/ios-entitlements-checks'
+import {
+ plistAtsArbitraryLoads,
+ plistBackgroundModesSanity,
+ plistBundleIdFormat,
+ plistDisplayName,
+ plistEncryptionCompliance,
+ plistLaunchStoryboard,
+ plistOrientationsMultitasking,
+ plistOrientationsPresent,
+ plistVersionBuildFormat,
+ plistVersionShortFormat,
+} from './checks/ios-plist-checks'
+import {
+ appiconEmptyOrPlaceholder,
+ appiconMarketingMissing,
+ appiconReferencedFileMissing,
+ podsCapacitorMissing,
+ podsLockMissing,
+ podsNotInstalled,
+ spmCapacitorDependencyMissing,
+ spmDeploymentTargetConsistency,
+ spmPackageResolvedMissing,
+} from './checks/ios-pods-assets'
+import {
+ bundleIdMismatchAcrossConfigs,
+ deploymentTargetCapacitor,
+ enableBitcodeLeftover,
+ multipleAppTargets,
+ noAppTarget,
+ signingTeam,
+ swiftVersionSanity,
+} from './checks/ios-xcode'
import { bundleIdConsistency, capSyncStale, nodeLinkerLayout } from './checks/shared'
import { apikeyPermission, appExists } from './checks/shared-remote'
import { ascKeyAccess, playSaAccess } from './checks/store-access'
@@ -63,4 +106,22 @@ export const ALL_CHECKS: PrescanCheck[] = [
sdkFloors, targetSdkPlay, minSdkCapacitor, versionFields,
// store-access (remote)
playSaAccess, ascKeyAccess,
+ // ios plist (Info.plist / App Store)
+ plistBundleIdFormat, plistVersionShortFormat, plistVersionBuildFormat,
+ plistEncryptionCompliance, plistAtsArbitraryLoads, plistLaunchStoryboard,
+ plistOrientationsMultitasking, plistOrientationsPresent, plistDisplayName,
+ plistBackgroundModesSanity,
+ // ios xcode (project / build settings)
+ deploymentTargetCapacitor, signingTeam, bundleIdMismatchAcrossConfigs,
+ enableBitcodeLeftover, swiftVersionSanity, noAppTarget,
+ multipleAppTargets,
+ // ios entitlements / capabilities
+ entitlementsVsProfileCapability, apsEnvironmentVsMode,
+ associatedDomainsFormat, appGroupsFormat,
+ // ios capacitor config
+ serverUrlShipped, serverCleartext, allowNavigationWildcard,
+ // ios pods / spm / app icons
+ podsNotInstalled, podsLockMissing, podsCapacitorMissing,
+ spmPackageResolvedMissing, spmCapacitorDependencyMissing, appiconEmptyOrPlaceholder,
+ appiconReferencedFileMissing, appiconMarketingMissing, spmDeploymentTargetConsistency,
]
diff --git a/cli/test/prescan/capacitor-version.test.ts b/cli/test/prescan/capacitor-version.test.ts
new file mode 100644
index 0000000000..e0f3999768
--- /dev/null
+++ b/cli/test/prescan/capacitor-version.test.ts
@@ -0,0 +1,68 @@
+// test/prescan/capacitor-version.test.ts
+import { describe, expect, it } from 'bun:test'
+import { capacitorMajor } from '../../src/build/prescan/capacitor-version'
+import { makeProject } from './helpers'
+
+function pkg(deps: Record, devDeps: Record = {}) {
+ return JSON.stringify({ dependencies: deps, devDependencies: devDeps })
+}
+
+describe('capacitor-version: capacitorMajor', () => {
+ it('reads the major from @capacitor/core', () => {
+ const dir = makeProject({ 'package.json': pkg({ '@capacitor/core': '^8.3.1' }) })
+ expect(capacitorMajor(dir)).toBe(8)
+ })
+
+ it('falls back to @capacitor/ios when core is absent', () => {
+ const dir = makeProject({ 'package.json': pkg({ '@capacitor/ios': '~7.2.0' }) })
+ expect(capacitorMajor(dir)).toBe(7)
+ })
+
+ it('falls back to @capacitor/android when core and ios are absent', () => {
+ const dir = makeProject({ 'package.json': pkg({ '@capacitor/android': '6.1.2' }) })
+ expect(capacitorMajor(dir)).toBe(6)
+ })
+
+ it('prefers @capacitor/core over ios/android', () => {
+ const dir = makeProject({ 'package.json': pkg({
+ '@capacitor/core': '8.0.0',
+ '@capacitor/ios': '7.0.0',
+ '@capacitor/android': '6.0.0',
+ }) })
+ expect(capacitorMajor(dir)).toBe(8)
+ })
+
+ it('reads from devDependencies too', () => {
+ const dir = makeProject({ 'package.json': pkg({}, { '@capacitor/core': '^8.3.1' }) })
+ expect(capacitorMajor(dir)).toBe(8)
+ })
+
+ it('returns null when package.json is absent', () => {
+ const dir = makeProject({})
+ expect(capacitorMajor(dir)).toBeNull()
+ })
+
+ it('returns null when package.json is malformed (never throws)', () => {
+ const dir = makeProject({ 'package.json': '{ not json' })
+ expect(capacitorMajor(dir)).toBeNull()
+ })
+
+ it('returns null when no capacitor dependency is present', () => {
+ const dir = makeProject({ 'package.json': pkg({ react: '^19.0.0' }) })
+ expect(capacitorMajor(dir)).toBeNull()
+ })
+
+ it('parses the leading integer from a complex range', () => {
+ const dir = makeProject({ 'package.json': pkg({ '@capacitor/core': '>=8.3.1 <9.0.0' }) })
+ expect(capacitorMajor(dir)).toBe(8)
+ })
+
+ it('grounds clean against a real-shaped Capacitor-8 project (inline fixture)', () => {
+ // Self-contained inline fixture mirroring the real tutorial-app package.json
+ // Capacitor-8 dependency, so the grounding is REAL on CI (the external
+ // tutorial-app checkout does not exist there; reading it returned null and
+ // failed this assertion, not a meaningful grounding).
+ const dir = makeProject({ 'package.json': pkg({ '@capacitor/core': '^8.0.0', '@capacitor/ios': '^8.0.0' }) })
+ expect(capacitorMajor(dir)).toBe(8)
+ })
+})
diff --git a/cli/test/prescan/checks-ios-entitlements-config.test.ts b/cli/test/prescan/checks-ios-entitlements-config.test.ts
new file mode 100644
index 0000000000..7b48ccd18d
--- /dev/null
+++ b/cli/test/prescan/checks-ios-entitlements-config.test.ts
@@ -0,0 +1,467 @@
+// test/prescan/checks-ios-entitlements-config.test.ts
+import type { ScanContext } from '../../src/build/prescan/types'
+import { Buffer } from 'node:buffer'
+import { describe, expect, it } from 'bun:test'
+import {
+ allowNavigationWildcard,
+ serverCleartext,
+ serverUrlShipped,
+} from '../../src/build/prescan/checks/ios-capacitor-config'
+import {
+ appGroupsFormat,
+ apsEnvironmentVsMode,
+ associatedDomainsFormat,
+ entitlementsVsProfileCapability,
+} from '../../src/build/prescan/checks/ios-entitlements-checks'
+import { makeCtx, makeProject } from './helpers'
+
+const b64 = (s: string) => Buffer.from(s).toString('base64')
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const PLIST_HEAD = `
+
+`
+
+/** App.entitlements file body (inside ...). */
+function entitlementsFile(body: string): string {
+ return `${PLIST_HEAD}\n${body}\n`
+}
+
+/** The exact grounding-project entitlements (aps-environment=development only). */
+const GROUNDING_ENTITLEMENTS = entitlementsFile(`
+ aps-environmentdevelopment`)
+
+/**
+ * Provisioning-profile XML the mobileprovision parser accepts, with an
+ * Entitlements dict whose body the caller supplies. Mirrors the structure the
+ * parser scans (, application-identifier, Entitlements dict).
+ */
+function profileXml(entitlementsBody: string, bundleId = 'com.demo.app', teamId = 'TEAM123456'): string {
+ return `${PLIST_HEAD}
+NameTest Profile
+UUID11111111-2222-3333-4444-555555555555
+TeamIdentifier${teamId}
+ExpirationDate2099-01-01T00:00:00Z
+Entitlements
+ application-identifier${teamId}.${bundleId}
+ ${entitlementsBody}
+
+DeveloperCertificates
+`
+}
+
+/** Serialized CAPGO_IOS_PROVISIONING_MAP: { [bundleId]: { profile, name } }. */
+function mapWith(xml: string, bundleId = 'com.demo.app'): string {
+ return JSON.stringify({ [bundleId]: { profile: b64(xml), name: 'Test Profile' } })
+}
+
+/** ScanContext with an App.entitlements file written to a temp project dir. */
+function ctxWithEntitlements(entitlementsBody: string, extra: Partial = {}): ScanContext {
+ const dir = makeProject({ 'ios/App/App/App.entitlements': entitlementsFile(entitlementsBody) })
+ return makeCtx({ projectDir: dir, platform: 'ios', ...extra })
+}
+
+function ctxWithEntitlementsRaw(raw: string, extra: Partial = {}): ScanContext {
+ const dir = makeProject({ 'ios/App/App/App.entitlements': raw })
+ return makeCtx({ projectDir: dir, platform: 'ios', ...extra })
+}
+
+// ASC triplet that makes willUploadToAppStore(ctx) true.
+const UPLOAD_CREDS = { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' }
+
+// ===========================================================================
+// §2.C ios/entitlements-vs-profile-capability
+// ===========================================================================
+
+describe('ios/entitlements-vs-profile-capability', () => {
+ it('does not apply without a provisioning map', () => {
+ const ctx = ctxWithEntitlements('aps-environmentdevelopment')
+ expect(entitlementsVsProfileCapability.appliesTo?.(ctx)).toBe(false)
+ })
+
+ it('does not apply when App.entitlements is absent', () => {
+ const ctx = makeCtx({
+ projectDir: makeProject({}),
+ platform: 'ios',
+ credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('')) },
+ })
+ expect(entitlementsVsProfileCapability.appliesTo?.(ctx)).toBe(false)
+ })
+
+ it('errors when the app declares a capability the profile does not grant', async () => {
+ const ctx = ctxWithEntitlements(
+ 'com.apple.developer.healthkit',
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('')) } },
+ )
+ const f = await entitlementsVsProfileCapability.run(ctx)
+ expect(f[0]?.severity).toBe('error')
+ expect(f[0]?.detail ?? f[0]?.title).toContain('com.apple.developer.healthkit')
+ })
+
+ it('passes when the profile grants the same bool/string capability', async () => {
+ const ctx = ctxWithEntitlements(
+ 'com.apple.developer.healthkit',
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('com.apple.developer.healthkit')) } },
+ )
+ expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([])
+ })
+
+ it('errors when an app app-group member is not covered by the profile list', async () => {
+ const ctx = ctxWithEntitlements(
+ 'com.apple.security.application-groupsgroup.com.demo.appgroup.com.demo.extra',
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('com.apple.security.application-groupsgroup.com.demo.app')) } },
+ )
+ const f = await entitlementsVsProfileCapability.run(ctx)
+ expect(f[0]?.severity).toBe('error')
+ expect(f[0]?.detail ?? f[0]?.title).toContain('com.apple.security.application-groups')
+ })
+
+ it('passes when every app app-group member is in the profile list', async () => {
+ const ctx = ctxWithEntitlements(
+ 'com.apple.security.application-groupsgroup.com.demo.app',
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('com.apple.security.application-groupsgroup.com.demo.appgroup.com.demo.extra')) } },
+ )
+ expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([])
+ })
+
+ it('treats a profile wildcard member (*) as covering all app members', async () => {
+ const ctx = ctxWithEntitlements(
+ 'keychain-access-groupsTEAM123456.com.demo.app',
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('keychain-access-groups*')) } },
+ )
+ expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([])
+ })
+
+ it('treats a $(AppIdentifierPrefix)* profile member as covering all app members', async () => {
+ const ctx = ctxWithEntitlements(
+ 'keychain-access-groupsTEAM123456.com.demo.app',
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('keychain-access-groups$(AppIdentifierPrefix)*')) } },
+ )
+ expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([])
+ })
+
+ it('treats a resolved-team wildcard (TEAMID.*) profile member as covering all app members', async () => {
+ // Real profiles store the RESOLVED 10-char team prefix, e.g. `TEAM123456.*`,
+ // never the $(AppIdentifierPrefix) build variable. This must be recognized as a
+ // wildcard or it false-positives on every keychain-sharing app with a wildcard App ID.
+ const ctx = ctxWithEntitlements(
+ 'keychain-access-groups$(AppIdentifierPrefix)app.capgo.plugin.TutorialBuild',
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('keychain-access-groupsTEAM123456.*')) } },
+ )
+ expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([])
+ })
+
+ it('matches a $(AppIdentifierPrefix)-prefixed app member against the profile resolved-team grant', async () => {
+ // App entitlements carry the unresolved $(AppIdentifierPrefix) prefix; the profile
+ // carries the resolved . prefix. A literal (non-wildcard) grant of the same
+ // suffix must be treated as covered.
+ const ctx = ctxWithEntitlements(
+ 'keychain-access-groups$(AppIdentifierPrefix)app.capgo.plugin.TutorialBuild',
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('keychain-access-groupsTEAM123456.app.capgo.plugin.TutorialBuild')) } },
+ )
+ expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([])
+ })
+
+ it('passes when the profile grants a non-allowlisted scalar/bool capability (App Attest, Siri)', async () => {
+ // Capability keys outside the legacy profile-parser allowlist must NOT be reported
+ // as ungranted when the profile actually grants them.
+ const ctx = ctxWithEntitlements(
+ `com.apple.developer.devicecheck.appattest-environmentproduction
+ com.apple.developer.siri`,
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml(`com.apple.developer.devicecheck.appattest-environmentproduction
+ com.apple.developer.siri`)) } },
+ )
+ expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([])
+ })
+
+ it('passes when the profile grants a non-allowlisted array capability (Sign in with Apple)', async () => {
+ const ctx = ctxWithEntitlements(
+ 'com.apple.developer.applesigninDefault',
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('com.apple.developer.applesigninDefault')) } },
+ )
+ expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([])
+ })
+
+ it('still errors when a non-allowlisted capability is declared but NOT granted', async () => {
+ // The downgrade must not silence genuine missing-capability errors.
+ const ctx = ctxWithEntitlements(
+ 'com.apple.developer.siri',
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('')) } },
+ )
+ const f = await entitlementsVsProfileCapability.run(ctx)
+ expect(f[0]?.severity).toBe('error')
+ expect(f[0]?.detail ?? f[0]?.title).toContain('com.apple.developer.siri')
+ })
+
+ it('does NOT treat keys nested inside a dict-valued entitlement as capabilities', async () => {
+ // Regression: appEntitlementKeys must collect only TOP-LEVEL keys. The inner
+ // keys of a dict-valued entitlement must NOT leak into the capability set and
+ // get checked against the profile — that would be a false-positive blocking
+ // error. The profile grants only an UNRELATED capability, so the only thing
+ // that could surface the nested key names is the leak this fix prevents.
+ const ctx = ctxWithEntitlements(
+ `com.apple.developer.networking.HotspotConfiguration`
+ + `com.apple.private.nested.flag`
+ + `com.apple.private.nested.listx`
+ + ``,
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('com.apple.developer.healthkit')) } },
+ )
+ const f = await entitlementsVsProfileCapability.run(ctx)
+ const text = f.map(x => `${x.title} ${x.detail ?? ''}`).join(' ')
+ // The nested keys must NEVER appear as their own capability findings.
+ expect(text).not.toContain('com.apple.private.nested.flag')
+ expect(text).not.toContain('com.apple.private.nested.list')
+ })
+
+ it('excludes auto-managed keys (aps-environment, get-task-allow, application-identifier, team-identifier)', async () => {
+ // App declares only auto-managed keys; profile grants none of them -> no finding.
+ const ctx = ctxWithEntitlements(
+ `aps-environmentdevelopment
+ get-task-allow
+ application-identifierTEAM123456.com.demo.app
+ com.apple.developer.team-identifierTEAM123456`,
+ { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('')) } },
+ )
+ expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([])
+ })
+
+ it('grounding entitlements (aps-environment only) scan clean against a matching profile', async () => {
+ const ctx = ctxWithEntitlementsRaw(GROUNDING_ENTITLEMENTS, {
+ credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('aps-environmentdevelopment')) },
+ })
+ expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([])
+ })
+})
+
+// ===========================================================================
+// §2.C ios/entitlements-aps-environment-vs-mode
+// ===========================================================================
+
+describe('ios/entitlements-aps-environment-vs-mode', () => {
+ it('does not apply without aps-environment in App.entitlements', () => {
+ const ctx = ctxWithEntitlements('com.apple.developer.healthkit', { distributionMode: 'app_store' })
+ expect(apsEnvironmentVsMode.appliesTo?.(ctx)).toBe(false)
+ })
+
+ it('does not apply without a distributionMode', () => {
+ const ctx = ctxWithEntitlements('aps-environmentdevelopment')
+ expect(apsEnvironmentVsMode.appliesTo?.(ctx)).toBe(false)
+ })
+
+ it('warns (does NOT error) when aps-environment=development but mode is app_store with no push evidence', async () => {
+ // The default Capacitor App.entitlements ships aps-environment=development. On a
+ // push-free app this is a benign leftover the builder neither rewrites nor fails
+ // on, so it must NOT hard-block an App Store build — it is a warning, not an error.
+ const ctx = ctxWithEntitlements('aps-environmentdevelopment', { distributionMode: 'app_store' })
+ const f = await apsEnvironmentVsMode.run(ctx)
+ expect(f[0]?.severity).toBe('warning')
+ expect(f.some(x => x.severity === 'error')).toBe(false)
+ })
+
+ it('errors on development+app_store when a mapped profile grants production push (real mismatch)', async () => {
+ // A mapped profile granting aps-environment=production is independent evidence the
+ // app genuinely uses push, so a development app entitlement IS a real signing mismatch.
+ const ctx = ctxWithEntitlements('aps-environmentdevelopment', {
+ distributionMode: 'app_store',
+ credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('aps-environmentproduction')) },
+ })
+ const f = await apsEnvironmentVsMode.run(ctx)
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+
+ it('errors on development+app_store when UIBackgroundModes declares remote-notification', async () => {
+ // remote-notification background mode is independent evidence the app uses push.
+ const dir = makeProject({
+ 'ios/App/App/App.entitlements': entitlementsFile('aps-environmentdevelopment'),
+ 'ios/App/App/Info.plist': `${PLIST_HEAD}\nUIBackgroundModesremote-notification\n`,
+ })
+ const ctx = makeCtx({ projectDir: dir, platform: 'ios', distributionMode: 'app_store' })
+ const f = await apsEnvironmentVsMode.run(ctx)
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+
+ it('warns when aps-environment=production but mode is ad_hoc', async () => {
+ const ctx = ctxWithEntitlements('aps-environmentproduction', { distributionMode: 'ad_hoc' })
+ const f = await apsEnvironmentVsMode.run(ctx)
+ expect(f[0]?.severity).toBe('warning')
+ })
+
+ it('passes when aps-environment=production and mode is app_store', async () => {
+ const ctx = ctxWithEntitlements('aps-environmentproduction', { distributionMode: 'app_store' })
+ expect(await apsEnvironmentVsMode.run(ctx)).toEqual([])
+ })
+
+ it('errors when the app aps-environment differs from the mapped profile aps-environment', async () => {
+ const ctx = ctxWithEntitlements('aps-environmentproduction', {
+ distributionMode: 'app_store',
+ credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('aps-environmentdevelopment')) },
+ })
+ const f = await apsEnvironmentVsMode.run(ctx)
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+
+ it('grounding (development, push-free) warns under app_store — never hard-blocks the default Capacitor entitlement', async () => {
+ // Restores the spec acceptance baseline: the grounding project (aps-environment=
+ // development, no push) must NOT produce a build-blocking error on the default
+ // app_store path. It surfaces a warning so the leftover is still visible.
+ const noMode = ctxWithEntitlementsRaw(GROUNDING_ENTITLEMENTS)
+ expect(apsEnvironmentVsMode.appliesTo?.(noMode)).toBe(false)
+ const withMode = ctxWithEntitlementsRaw(GROUNDING_ENTITLEMENTS, { distributionMode: 'app_store' })
+ const f = await apsEnvironmentVsMode.run(withMode)
+ expect(f[0]?.severity).toBe('warning')
+ expect(f.some(x => x.severity === 'error')).toBe(false)
+ })
+})
+
+// ===========================================================================
+// §2.C ios/entitlements-associated-domains-format
+// ===========================================================================
+
+describe('ios/entitlements-associated-domains-format', () => {
+ it('does not apply without associated-domains', () => {
+ const ctx = ctxWithEntitlements('aps-environmentdevelopment')
+ expect(associatedDomainsFormat.appliesTo?.(ctx)).toBe(false)
+ })
+
+ it('warns on an entry with a scheme/url (applinks:https://...)', async () => {
+ const ctx = ctxWithEntitlements('com.apple.developer.associated-domainsapplinks:https://example.com')
+ const f = await associatedDomainsFormat.run(ctx)
+ expect(f[0]?.severity).toBe('warning')
+ })
+
+ it('warns on an entry with a trailing path', async () => {
+ const ctx = ctxWithEntitlements('com.apple.developer.associated-domainsapplinks:example.com/path')
+ expect((await associatedDomainsFormat.run(ctx))[0]?.severity).toBe('warning')
+ })
+
+ it('warns on an unknown service prefix', async () => {
+ const ctx = ctxWithEntitlements('com.apple.developer.associated-domainsbogus:example.com')
+ expect((await associatedDomainsFormat.run(ctx))[0]?.severity).toBe('warning')
+ })
+
+ it('passes valid applinks/webcredentials entries', async () => {
+ const ctx = ctxWithEntitlements('com.apple.developer.associated-domainsapplinks:example.comwebcredentials:example.comapplinks:example.com?mode=developer')
+ expect(await associatedDomainsFormat.run(ctx)).toEqual([])
+ })
+
+ it('does not flag the service:* managed wildcard form', async () => {
+ const ctx = ctxWithEntitlements('com.apple.developer.associated-domainsapplinks:*')
+ expect(await associatedDomainsFormat.run(ctx)).toEqual([])
+ })
+})
+
+// ===========================================================================
+// §2.C ios/entitlements-app-groups-format
+// ===========================================================================
+
+describe('ios/entitlements-app-groups-format', () => {
+ it('does not apply without application-groups', () => {
+ const ctx = ctxWithEntitlements('aps-environmentdevelopment')
+ expect(appGroupsFormat.appliesTo?.(ctx)).toBe(false)
+ })
+
+ it('warns when an entry does not start with group.', async () => {
+ const ctx = ctxWithEntitlements('com.apple.security.application-groupscom.demo.app')
+ expect((await appGroupsFormat.run(ctx))[0]?.severity).toBe('warning')
+ })
+
+ it('warns on uppercase / whitespace in the group id', async () => {
+ const ctx = ctxWithEntitlements('com.apple.security.application-groupsgroup.Com.Demo.App')
+ expect((await appGroupsFormat.run(ctx))[0]?.severity).toBe('warning')
+ })
+
+ it('passes a well-formed group identifier', async () => {
+ const ctx = ctxWithEntitlements('com.apple.security.application-groupsgroup.com.demo.app')
+ expect(await appGroupsFormat.run(ctx)).toEqual([])
+ })
+})
+
+// ===========================================================================
+// §2.D ios/capacitor-server-url-shipped
+// ===========================================================================
+
+function ctxConfig(server: Record | undefined, extra: Partial = {}): ScanContext {
+ const config = { appId: 'com.demo.app', appName: 'demo', webDir: 'dist', ...(server ? { server } : {}) } as ScanContext['config']
+ return makeCtx({ projectDir: '/tmp', platform: 'ios', config, ...extra })
+}
+
+describe('ios/capacitor-server-url-shipped', () => {
+ it('does not apply when server.url is absent or empty', () => {
+ expect(serverUrlShipped.appliesTo?.(ctxConfig(undefined))).toBe(false)
+ expect(serverUrlShipped.appliesTo?.(ctxConfig({ url: '' }))).toBe(false)
+ })
+
+ it('warns when a server.url is set without upload creds', async () => {
+ const f = await serverUrlShipped.run(ctxConfig({ url: 'http://192.168.1.5:3000' }))
+ expect(f[0]?.severity).toBe('warning')
+ })
+
+ it('errors when uploading to the App Store with a server.url set', async () => {
+ const ctx = ctxConfig({ url: 'http://192.168.1.5:3000' }, { credentials: UPLOAD_CREDS })
+ const f = await serverUrlShipped.run(ctx)
+ expect(f[0]?.severity).toBe('error')
+ })
+
+ it('detail flags a dev target (RFC1918 / localhost / tunnel host)', async () => {
+ const tunnel = await serverUrlShipped.run(ctxConfig({ url: 'https://abcd.ngrok.io' }))
+ expect(tunnel[0]?.detail).toBeTruthy()
+ const local = await serverUrlShipped.run(ctxConfig({ url: 'http://localhost:3000' }))
+ expect(local[0]?.detail).toBeTruthy()
+ })
+
+ it('grounding config (no server) scans clean', async () => {
+ expect(await serverUrlShipped.run(ctxConfig(undefined))).toEqual([])
+ })
+})
+
+// ===========================================================================
+// §2.D ios/capacitor-server-cleartext
+// ===========================================================================
+
+describe('ios/capacitor-server-cleartext', () => {
+ it('does not apply when cleartext is not true', () => {
+ expect(serverCleartext.appliesTo?.(ctxConfig({ cleartext: false }))).toBe(false)
+ expect(serverCleartext.appliesTo?.(ctxConfig(undefined))).toBe(false)
+ })
+
+ it('warns when cleartext is true', async () => {
+ expect((await serverCleartext.run(ctxConfig({ cleartext: true })))[0]?.severity).toBe('warning')
+ })
+
+ it('escalates to error when a http:// server.url is also present', async () => {
+ expect((await serverCleartext.run(ctxConfig({ cleartext: true, url: 'http://example.com' })))[0]?.severity).toBe('error')
+ })
+})
+
+// ===========================================================================
+// §2.D ios/capacitor-allow-navigation-wildcard
+// ===========================================================================
+
+describe('ios/capacitor-allow-navigation-wildcard', () => {
+ it('does not apply when allowNavigation is not an array', () => {
+ expect(allowNavigationWildcard.appliesTo?.(ctxConfig(undefined))).toBe(false)
+ expect(allowNavigationWildcard.appliesTo?.(ctxConfig({ allowNavigation: 'nope' }))).toBe(false)
+ })
+
+ it('does not apply when there are no wildcard-only entries', () => {
+ expect(allowNavigationWildcard.appliesTo?.(ctxConfig({ allowNavigation: ['*.example.com', 'api.example.com'] }))).toBe(false)
+ })
+
+ it('warns on a blanket * entry', async () => {
+ const f = await allowNavigationWildcard.run(ctxConfig({ allowNavigation: ['*'] }))
+ expect(f[0]?.severity).toBe('warning')
+ })
+
+ it('warns on a public-suffix wildcard (*.com)', async () => {
+ const f = await allowNavigationWildcard.run(ctxConfig({ allowNavigation: ['*.com'] }))
+ expect(f[0]?.severity).toBe('warning')
+ expect(f[0]?.detail ?? f[0]?.title).toContain('*.com')
+ })
+
+ it('does not flag a specific subdomain wildcard (*.example.com)', async () => {
+ expect(await allowNavigationWildcard.run(ctxConfig({ allowNavigation: ['*.example.com'] }))).toEqual([])
+ })
+})
diff --git a/cli/test/prescan/checks-ios-plist-checks.test.ts b/cli/test/prescan/checks-ios-plist-checks.test.ts
new file mode 100644
index 0000000000..349d47aa37
--- /dev/null
+++ b/cli/test/prescan/checks-ios-plist-checks.test.ts
@@ -0,0 +1,358 @@
+// test/prescan/checks-ios-plist-checks.test.ts
+//
+// §2.A Info.plist / App Store checks. The primary regression fixture is the real
+// Capacitor-8 SPM tutorial project, which MUST scan clean (zero findings) across
+// every check. Per-check failing fixtures are minimal mutations of a healthy
+// in-memory plist; the unresolved-$() fixture proves the resolvePlistValue guard.
+import { describe, expect, it } from 'bun:test'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import {
+ plistAtsArbitraryLoads,
+ plistBackgroundModesSanity,
+ plistBundleIdFormat,
+ plistDisplayName,
+ plistEncryptionCompliance,
+ plistLaunchStoryboard,
+ plistOrientationsMultitasking,
+ plistOrientationsPresent,
+ plistVersionBuildFormat,
+ plistVersionShortFormat,
+} from '../../src/build/prescan/checks/ios-plist-checks'
+import { makeCtx, makeProject } from './helpers'
+
+const plist = (body: string) => `
+
+${body}`
+
+// A pbxproj whose Release config resolves every $(VAR) the Capacitor Info.plist
+// references to a healthy literal.
+const PBX = `
+ AAAA /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ PRODUCT_BUNDLE_IDENTIFIER = app.capgo.plugin.TutorialBuild;
+ MARKETING_VERSION = 1.0;
+ CURRENT_PROJECT_VERSION = 1;
+ PRODUCT_NAME = "Tutorial Build example app";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+`
+
+// Minimal healthy plist body (build-variable refs, like a real Capacitor app).
+const HEALTHY_BODY = `CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)
+CFBundleShortVersionString$(MARKETING_VERSION)
+CFBundleVersion$(CURRENT_PROJECT_VERSION)
+CFBundleDisplayNameTutorial Build example app
+CFBundleName$(PRODUCT_NAME)
+LSRequiresIPhoneOS
+UILaunchStoryboardNameLaunchScreen
+UISupportedInterfaceOrientations
+UIInterfaceOrientationPortrait
+UIInterfaceOrientationLandscapeLeft
+UIInterfaceOrientationLandscapeRight
+
+UISupportedInterfaceOrientations~ipad
+UIInterfaceOrientationPortrait
+UIInterfaceOrientationPortraitUpsideDown
+UIInterfaceOrientationLandscapeLeft
+UIInterfaceOrientationLandscapeRight
+`
+
+/** Build a ctx whose Info.plist + pbxproj are written at the Capacitor paths. */
+function ctxFor(plistBody: string, opts: { pbx?: string, partial?: Parameters[0] extends never ? never : Record } = {}) {
+ const files: Record = { 'ios/App/App/Info.plist': plist(plistBody) }
+ files['ios/App/App.xcodeproj/project.pbxproj'] = opts.pbx ?? PBX
+ const dir = makeProject(files)
+ return makeCtx({ projectDir: dir, platform: 'ios', ...(opts.partial as object) })
+}
+
+const ALL_CHECKS = [
+ plistBundleIdFormat,
+ plistVersionShortFormat,
+ plistVersionBuildFormat,
+ plistEncryptionCompliance,
+ plistAtsArbitraryLoads,
+ plistLaunchStoryboard,
+ plistOrientationsMultitasking,
+ plistOrientationsPresent,
+ plistDisplayName,
+ plistBackgroundModesSanity,
+]
+
+// Run a check honoring its appliesTo gate (returns [] when the gate is false),
+// mirroring how the engine applies appliesTo before run().
+async function runGated(check: typeof ALL_CHECKS[number], ctx: ReturnType) {
+ if (check.appliesTo && !check.appliesTo(ctx))
+ return []
+ return check.run(ctx)
+}
+
+describe('§2.A regression baseline — real-shaped Capacitor-8 project scans clean', () => {
+ // Self-contained inline fixture: HEALTHY_BODY + PBX mirror the real Capacitor-8
+ // SPM tutorial project's Info.plist + pbxproj shape ($()-ref'd bundle id /
+ // versions resolved by the Release config, all four ~ipad orientations, literal
+ // display name). Written to a temp project dir so the grounding assertions are
+ // REAL on CI — the previous external absolute path made every check early-return
+ // [] vacuously (the plist reader returns null on a missing file).
+ const groundedCtx = ctxFor(HEALTHY_BODY)
+
+ for (const check of ALL_CHECKS) {
+ it(`${check.id} produces no finding`, async () => {
+ expect(await runGated(check, groundedCtx)).toEqual([])
+ })
+ }
+
+ it('sanity: the inline fixture files are written and readable', () => {
+ const infoPlist = join(groundedCtx.projectDir, 'ios', 'App', 'App', 'Info.plist')
+ const pbxproj = join(groundedCtx.projectDir, 'ios', 'App', 'App.xcodeproj', 'project.pbxproj')
+ expect(readFileSync(infoPlist, 'utf8')).toContain('CFBundleIdentifier')
+ expect(readFileSync(pbxproj, 'utf8')).toContain('PRODUCT_BUNDLE_IDENTIFIER')
+ expect(groundedCtx.platform).toBe('ios')
+ })
+})
+
+describe('synthetic healthy plist scans clean', () => {
+ for (const check of ALL_CHECKS) {
+ it(`${check.id} produces no finding on the healthy in-memory plist`, async () => {
+ expect(await runGated(check, ctxFor(HEALTHY_BODY))).toEqual([])
+ })
+ }
+})
+
+describe('ios/plist-bundle-id-format', () => {
+ it('errors when CFBundleIdentifier is absent', async () => {
+ const f = await plistBundleIdFormat.run(ctxFor(`CFBundleVersion1`))
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+ it('errors on an underscore / space bundle id', async () => {
+ const f = await plistBundleIdFormat.run(ctxFor(`CFBundleIdentifiercom.my_app.bad id`))
+ expect(f.some(x => x.severity === 'error' && x.title.includes('com.my_app.bad id'))).toBe(true)
+ })
+ it('errors on a single-segment (non reverse-DNS) id', async () => {
+ const f = await plistBundleIdFormat.run(ctxFor(`CFBundleIdentifierjustone`))
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+ it('skips an unresolved $() reference (no matching pbxproj setting)', async () => {
+ const f = await plistBundleIdFormat.run(ctxFor(`CFBundleIdentifier$(UNRESOLVED_ID)`, { pbx: '' }))
+ expect(f).toEqual([])
+ })
+ it('passes a literal reverse-DNS id', async () => {
+ const f = await plistBundleIdFormat.run(ctxFor(`CFBundleIdentifierapp.capgo.plugin.TutorialBuild`))
+ expect(f).toEqual([])
+ })
+})
+
+describe('ios/plist-version-short-format', () => {
+ it('errors on a non-numeric marketing version (ITMS-90060)', async () => {
+ const f = await plistVersionShortFormat.run(ctxFor(`CFBundleShortVersionString1.0-beta`))
+ expect(f.some(x => x.severity === 'error' && x.title.includes('1.0-beta'))).toBe(true)
+ })
+ it('errors on a 4-segment version', async () => {
+ const f = await plistVersionShortFormat.run(ctxFor(`CFBundleShortVersionString1.2.3.4`))
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+ it('SKIPS when the key is absent (presence owned by infoplist-sanity)', async () => {
+ const f = await plistVersionShortFormat.run(ctxFor(`CFBundleVersion1`))
+ expect(f).toEqual([])
+ })
+ it('skips an unresolved $() reference', async () => {
+ const f = await plistVersionShortFormat.run(ctxFor(`CFBundleShortVersionString$(MARKETING_VERSION)`, { pbx: '' }))
+ expect(f).toEqual([])
+ })
+ it('passes 1.4.2', async () => {
+ const f = await plistVersionShortFormat.run(ctxFor(`CFBundleShortVersionString1.4.2`))
+ expect(f).toEqual([])
+ })
+})
+
+describe('ios/plist-version-build-format', () => {
+ it('errors on a non-numeric build version', async () => {
+ const f = await plistVersionBuildFormat.run(ctxFor(`CFBundleVersionbuild-42`))
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+ it('SKIPS when the key is absent', async () => {
+ const f = await plistVersionBuildFormat.run(ctxFor(`CFBundleShortVersionString1.0`))
+ expect(f).toEqual([])
+ })
+ it('passes a numeric build (42)', async () => {
+ const f = await plistVersionBuildFormat.run(ctxFor(`CFBundleVersion42`))
+ expect(f).toEqual([])
+ })
+})
+
+describe('ios/plist-encryption-compliance (upload-gated)', () => {
+ const uploadCreds = { APPLE_KEY_ID: 'ABCDE12345', APPLE_ISSUER_ID: '12345678-1234-1234-1234-123456789012', APPLE_KEY_CONTENT: 'x' }
+ it('does not apply when not uploading', () => {
+ const ctx = ctxFor(HEALTHY_BODY)
+ expect(plistEncryptionCompliance.appliesTo?.(ctx)).toBe(false)
+ })
+ it('warns when uploading and the key is missing', async () => {
+ const ctx = ctxFor(HEALTHY_BODY, { partial: { credentials: uploadCreds, distributionMode: 'app_store' } })
+ expect(plistEncryptionCompliance.appliesTo?.(ctx)).toBe(true)
+ const f = await plistEncryptionCompliance.run(ctx)
+ expect(f.some(x => x.severity === 'warning')).toBe(true)
+ })
+ it('passes when uploading and the key is present', async () => {
+ const body = `${HEALTHY_BODY}ITSAppUsesNonExemptEncryption`
+ const ctx = ctxFor(body, { partial: { credentials: uploadCreds, distributionMode: 'app_store' } })
+ expect(await plistEncryptionCompliance.run(ctx)).toEqual([])
+ })
+})
+
+describe('ios/plist-ats-arbitrary-loads', () => {
+ it('warns when NSAllowsArbitraryLoads is true (no upload)', async () => {
+ const body = `${HEALTHY_BODY}NSAppTransportSecurityNSAllowsArbitraryLoads`
+ const f = await plistAtsArbitraryLoads.run(ctxFor(body))
+ expect(f.length).toBe(1)
+ expect(f[0].severity).toBe('warning')
+ })
+ it('escalates to error on upload + server.url dev config', async () => {
+ const body = `${HEALTHY_BODY}NSAppTransportSecurityNSAllowsArbitraryLoads`
+ const ctx = ctxFor(body, { partial: {
+ credentials: { APPLE_KEY_ID: 'ABCDE12345', APPLE_ISSUER_ID: '12345678-1234-1234-1234-123456789012', APPLE_KEY_CONTENT: 'x' },
+ distributionMode: 'app_store',
+ config: { appId: 'x', appName: 'x', webDir: 'd', server: { url: 'http://10.0.0.2:3000' } } as never,
+ } })
+ const f = await plistAtsArbitraryLoads.run(ctx)
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+ it('no finding when NSAppTransportSecurity is absent', async () => {
+ expect(await plistAtsArbitraryLoads.run(ctxFor(HEALTHY_BODY))).toEqual([])
+ })
+ it('no finding when NSAllowsArbitraryLoads is false', async () => {
+ const body = `${HEALTHY_BODY}NSAppTransportSecurityNSAllowsArbitraryLoads`
+ expect(await plistAtsArbitraryLoads.run(ctxFor(body))).toEqual([])
+ })
+ it('still detects NSAllowsArbitraryLoads=true when a nested NSExceptionDomains dict precedes it', async () => {
+ // The nested NSExceptionDomains dict comes BEFORE NSAllowsArbitraryLoads —
+ // a first-`` capture would truncate the ATS block and miss the flag.
+ const ats = `NSAppTransportSecurity`
+ + `NSExceptionDomainsexample.comNSIncludesSubdomains`
+ + `NSAllowsArbitraryLoads`
+ + ``
+ const f = await plistAtsArbitraryLoads.run(ctxFor(`${HEALTHY_BODY}${ats}`))
+ expect(f.length).toBe(1)
+ expect(f[0].severity).toBe('warning')
+ })
+})
+
+describe('ios/plist-launch-storyboard', () => {
+ it('errors when neither UILaunchStoryboardName nor UILaunchScreen is present', async () => {
+ const f = await plistLaunchStoryboard.run(ctxFor(`CFBundleVersion1`))
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+ it('passes with UILaunchScreen dict', async () => {
+ const f = await plistLaunchStoryboard.run(ctxFor(`UILaunchScreen`))
+ expect(f).toEqual([])
+ })
+})
+
+describe('ios/plist-orientations-multitasking', () => {
+ it('does not apply when device family lacks iPad (no "2")', () => {
+ const pbx = PBX.replace('TARGETED_DEVICE_FAMILY = "1,2";', 'TARGETED_DEVICE_FAMILY = "1";')
+ const ctx = ctxFor(HEALTHY_BODY, { pbx })
+ expect(plistOrientationsMultitasking.appliesTo?.(ctx)).toBe(false)
+ })
+ it('does not apply when UIRequiresFullScreen is true', () => {
+ const body = `${HEALTHY_BODY}UIRequiresFullScreen`
+ const ctx = ctxFor(body)
+ expect(plistOrientationsMultitasking.appliesTo?.(ctx)).toBe(false)
+ })
+ it('warns when the ~ipad array is missing an orientation', async () => {
+ const body = HEALTHY_BODY.replace('UIInterfaceOrientationPortraitUpsideDown\n', '')
+ const ctx = ctxFor(body)
+ expect(plistOrientationsMultitasking.appliesTo?.(ctx)).toBe(true)
+ const f = await plistOrientationsMultitasking.run(ctx)
+ expect(f.some(x => x.severity === 'warning' && x.title.includes('PortraitUpsideDown'))).toBe(true)
+ })
+ it('passes when ~ipad has all four (grounding)', async () => {
+ const ctx = ctxFor(HEALTHY_BODY)
+ expect(await plistOrientationsMultitasking.run(ctx)).toEqual([])
+ })
+})
+
+describe('ios/plist-orientations-present', () => {
+ it('warns when the key is absent', async () => {
+ const f = await plistOrientationsPresent.run(ctxFor(`CFBundleVersion1`))
+ expect(f.some(x => x.severity === 'warning')).toBe(true)
+ })
+ it('warns when present but empty', async () => {
+ const f = await plistOrientationsPresent.run(ctxFor(`UISupportedInterfaceOrientations`))
+ expect(f.some(x => x.severity === 'warning')).toBe(true)
+ })
+ it('warns naming an invalid token', async () => {
+ const f = await plistOrientationsPresent.run(ctxFor(`UISupportedInterfaceOrientationsUIInterfaceOrientationSideways`))
+ expect(f.some(x => x.severity === 'warning' && x.title.includes('UIInterfaceOrientationSideways'))).toBe(true)
+ })
+ it('passes the grounding three-orientation iPhone array', async () => {
+ expect(await plistOrientationsPresent.run(ctxFor(HEALTHY_BODY))).toEqual([])
+ })
+})
+
+describe('ios/plist-display-name', () => {
+ it('warns when both display name and name are missing/empty', async () => {
+ const f = await plistDisplayName.run(ctxFor(`CFBundleVersion1`))
+ expect(f.some(x => x.severity === 'warning')).toBe(true)
+ })
+ it('warns when both still unresolved $() with no pbx match', async () => {
+ const body = `CFBundleDisplayName$(NO_SUCH)CFBundleName$(PRODUCT_NAME)`
+ const f = await plistDisplayName.run(ctxFor(body, { pbx: '' }))
+ expect(f.some(x => x.severity === 'warning')).toBe(true)
+ })
+ it('passes a literal display name (grounding)', async () => {
+ expect(await plistDisplayName.run(ctxFor(HEALTHY_BODY))).toEqual([])
+ })
+ it('passes when only CFBundleName resolves via pbx', async () => {
+ const body = `CFBundleName$(PRODUCT_NAME)`
+ expect(await plistDisplayName.run(ctxFor(body))).toEqual([])
+ })
+})
+
+describe('ios/plist-background-modes-sanity', () => {
+ it('does not apply without UIBackgroundModes', () => {
+ expect(plistBackgroundModesSanity.appliesTo?.(ctxFor(HEALTHY_BODY))).toBe(false)
+ })
+ it('warns on an invalid background mode token', async () => {
+ const body = `${HEALTHY_BODY}UIBackgroundModesteleport`
+ const ctx = ctxFor(body)
+ expect(plistBackgroundModesSanity.appliesTo?.(ctx)).toBe(true)
+ const f = await plistBackgroundModesSanity.run(ctx)
+ expect(f.some(x => x.severity === 'warning' && x.title.includes('teleport'))).toBe(true)
+ })
+ it('passes a valid mode (audio) when not uploading', async () => {
+ const body = `${HEALTHY_BODY}UIBackgroundModesaudio`
+ expect(await plistBackgroundModesSanity.run(ctxFor(body))).toEqual([])
+ })
+ it('warns on location without a usage string ONLY when uploading', async () => {
+ const body = `${HEALTHY_BODY}UIBackgroundModeslocation`
+ // not uploading -> no 2.5.4 sub-check finding
+ expect(await plistBackgroundModesSanity.run(ctxFor(body))).toEqual([])
+ // uploading -> warns
+ const ctx = ctxFor(body, { partial: {
+ credentials: { APPLE_KEY_ID: 'ABCDE12345', APPLE_ISSUER_ID: '12345678-1234-1234-1234-123456789012', APPLE_KEY_CONTENT: 'x' },
+ distributionMode: 'app_store',
+ } })
+ const f = await plistBackgroundModesSanity.run(ctx)
+ expect(f.some(x => x.severity === 'warning' && /location/i.test(`${x.title} ${x.detail ?? ''}`))).toBe(true)
+ })
+ it('passes location WITH a usage string when uploading', async () => {
+ const body = `${HEALTHY_BODY}UIBackgroundModeslocationNSLocationWhenInUseUsageDescriptionTo show nearby places`
+ const ctx = ctxFor(body, { partial: {
+ credentials: { APPLE_KEY_ID: 'ABCDE12345', APPLE_ISSUER_ID: '12345678-1234-1234-1234-123456789012', APPLE_KEY_CONTENT: 'x' },
+ distributionMode: 'app_store',
+ } })
+ expect(await plistBackgroundModesSanity.run(ctx)).toEqual([])
+ })
+})
+
+describe('partial / non-Capacitor project — all checks early-return []', () => {
+ it('no Info.plist -> [] (no crash)', async () => {
+ const dir = makeProject({})
+ const ctx = makeCtx({ projectDir: dir, platform: 'ios' })
+ for (const check of ALL_CHECKS)
+ expect(await runGated(check, ctx)).toEqual([])
+ })
+})
diff --git a/cli/test/prescan/checks-ios-pods-assets.test.ts b/cli/test/prescan/checks-ios-pods-assets.test.ts
new file mode 100644
index 0000000000..8e7c27cc32
--- /dev/null
+++ b/cli/test/prescan/checks-ios-pods-assets.test.ts
@@ -0,0 +1,479 @@
+// test/prescan/checks-ios-pods-assets.test.ts
+//
+// §2.E (Pods / SPM) + §2.F (App icons / assets) of the iOS prescan expansion.
+//
+// Two fixture shapes: a CocoaPods-shaped project (Podfile + Pods/ + App.xcworkspace)
+// and an SPM-shaped project (CapApp-SPM/Package.swift + Package.resolved). Both clean
+// shapes scan with ZERO findings — the SPM clean case mirrors the real grounding
+// tutorial-app exactly (single 1024 AppIcon, Package.resolved under the xcodeproj).
+import { describe, expect, it } from 'bun:test'
+import {
+ appiconEmptyOrPlaceholder,
+ appiconMarketingMissing,
+ appiconReferencedFileMissing,
+ podsCapacitorMissing,
+ podsLockMissing,
+ podsNotInstalled,
+ spmCapacitorDependencyMissing,
+ spmDeploymentTargetConsistency,
+ spmPackageResolvedMissing,
+} from '../../src/build/prescan/checks/ios-pods-assets'
+import { makeCtx, makeProject } from './helpers'
+
+// ---------------------------------------------------------------------------
+// Fixture builders
+// ---------------------------------------------------------------------------
+
+// Standard Capacitor Podfile (uses the capacitor_pods helper, NOT a literal pod 'Capacitor').
+const CAPACITOR_PODFILE = `require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
+
+platform :ios, '14.0'
+use_frameworks!
+
+install! 'cocoapods', :disable_input_output_paths => true
+
+def capacitor_pods
+ pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
+ pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
+end
+
+target 'App' do
+ capacitor_pods
+end`
+
+// Standard CLI-managed SPM manifest (depends on capacitor-swift-pm + .product Capacitor).
+const SPM_PACKAGE_SWIFT = `// swift-tools-version: 5.9
+import PackageDescription
+
+// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
+let package = Package(
+ name: "CapApp-SPM",
+ platforms: [.iOS(.v15)],
+ products: [
+ .library(
+ name: "CapApp-SPM",
+ targets: ["CapApp-SPM"])
+ ],
+ dependencies: [
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.1")
+ ],
+ targets: [
+ .target(
+ name: "CapApp-SPM",
+ dependencies: [
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
+ .product(name: "Cordova", package: "capacitor-swift-pm")
+ ]
+ )
+ ]
+)`
+
+const PACKAGE_RESOLVED = `{
+ "originHash" : "abc123",
+ "pins" : [],
+ "version" : 3
+}`
+
+// pbxproj with a Release IPHONEOS_DEPLOYMENT_TARGET=15.0 (matches grounding).
+function pbxproj(deploymentTarget = '15.0', iconName = 'AppIcon'): string {
+ return `// !$*UTF8*$!
+{
+ ABCD0001 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget};
+ ASSETCATALOG_COMPILER_APPICON_NAME = ${iconName};
+ PRODUCT_BUNDLE_IDENTIFIER = app.capgo.plugin.TutorialBuild;
+ };
+ name = Release;
+ };
+}`
+}
+
+const APPICON_CONTENTS_1024 = `{
+ "images": [
+ {
+ "idiom": "universal",
+ "size": "1024x1024",
+ "filename": "AppIcon-512@2x.png",
+ "platform": "ios"
+ }
+ ],
+ "info": { "author": "xcode", "version": 1 }
+}`
+
+// A healthy SPM project mirroring the grounding tutorial-app layout.
+function cleanSpmFiles(extra: Record = {}): Record {
+ return {
+ 'ios/App/CapApp-SPM/Package.swift': SPM_PACKAGE_SWIFT,
+ 'ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved': PACKAGE_RESOLVED,
+ 'ios/App/App.xcodeproj/project.pbxproj': pbxproj(),
+ 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': APPICON_CONTENTS_1024,
+ 'ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png': 'PNGDATA',
+ ...extra,
+ }
+}
+
+// A healthy CocoaPods project: Podfile + Podfile.lock + Pods/ + App.xcworkspace + appicon.
+function cleanPodsFiles(extra: Record = {}): Record {
+ return {
+ 'ios/App/Podfile': CAPACITOR_PODFILE,
+ 'ios/App/Podfile.lock': 'PODFILE CHECKSUM: abc',
+ 'ios/App/Pods/Manifest.lock': 'PODFILE CHECKSUM: abc',
+ 'ios/App/App.xcworkspace/contents.xcworkspacedata': '',
+ 'ios/App/App.xcodeproj/project.pbxproj': pbxproj(),
+ 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': APPICON_CONTENTS_1024,
+ 'ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png': 'PNGDATA',
+ ...extra,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// §2.E Pods checks
+// ---------------------------------------------------------------------------
+
+describe('ios/pods-not-installed', () => {
+ it('does NOT apply to an SPM project (no Podfile)', () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) })
+ expect(podsNotInstalled.appliesTo?.(ctx) ?? true).toBe(false)
+ })
+
+ it('is clean when Pods/ and App.xcworkspace both exist', async () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) })
+ expect(await podsNotInstalled.run(ctx)).toEqual([])
+ })
+
+ it('warns (not errors — builder runs pod install) when Pods/ is missing', async () => {
+ const files = cleanPodsFiles()
+ delete files['ios/App/Pods/Manifest.lock']
+ const ctx = makeCtx({ projectDir: makeProject(files) })
+ const f = await podsNotInstalled.run(ctx)
+ expect(f.length).toBeGreaterThan(0)
+ expect(f.every(x => x.severity === 'warning')).toBe(true)
+ expect(f.every(x => x.id === 'ios/pods-not-installed')).toBe(true)
+ })
+
+ it('warns when App.xcworkspace is missing', async () => {
+ const files = cleanPodsFiles()
+ delete files['ios/App/App.xcworkspace/contents.xcworkspacedata']
+ const ctx = makeCtx({ projectDir: makeProject(files) })
+ const f = await podsNotInstalled.run(ctx)
+ expect(f.some(x => x.severity === 'warning')).toBe(true)
+ })
+})
+
+describe('ios/pods-lock-missing', () => {
+ it('is clean when Podfile.lock exists', async () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) })
+ expect(await podsLockMissing.run(ctx)).toEqual([])
+ })
+
+ it('warns when Podfile.lock is absent', async () => {
+ const files = cleanPodsFiles()
+ delete files['ios/App/Podfile.lock']
+ const ctx = makeCtx({ projectDir: makeProject(files) })
+ const f = await podsLockMissing.run(ctx)
+ expect(f.some(x => x.severity === 'warning' && x.id === 'ios/pods-lock-missing')).toBe(true)
+ })
+
+ it('does NOT apply to an SPM project', () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) })
+ expect(podsLockMissing.appliesTo?.(ctx) ?? true).toBe(false)
+ })
+})
+
+describe('ios/pods-capacitor-missing', () => {
+ it('is clean when the Podfile wires Capacitor (capacitor_pods helper)', async () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) })
+ expect(await podsCapacitorMissing.run(ctx)).toEqual([])
+ })
+
+ it('warns (not errors — high FP, builder builds from Podfile) when no Capacitor pod', async () => {
+ const ctx = makeCtx({
+ projectDir: makeProject(cleanPodsFiles({
+ 'ios/App/Podfile': `platform :ios, '14.0'\ntarget 'App' do\n pod 'SomethingElse'\nend`,
+ })),
+ })
+ const f = await podsCapacitorMissing.run(ctx)
+ expect(f.length).toBeGreaterThan(0)
+ expect(f.every(x => x.severity === 'warning' && x.id === 'ios/pods-capacitor-missing')).toBe(true)
+ })
+
+ it('does NOT apply to an SPM project', () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) })
+ expect(podsCapacitorMissing.appliesTo?.(ctx) ?? true).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// §2.E SPM checks
+// ---------------------------------------------------------------------------
+
+describe('ios/spm-package-resolved-missing', () => {
+ it('is clean when Package.resolved exists under the xcodeproj (grounding layout)', async () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) })
+ expect(await spmPackageResolvedMissing.run(ctx)).toEqual([])
+ })
+
+ it('is clean when Package.resolved lives next to Package.swift instead', async () => {
+ const files = cleanSpmFiles()
+ delete files['ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved']
+ files['ios/App/CapApp-SPM/Package.resolved'] = PACKAGE_RESOLVED
+ const ctx = makeCtx({ projectDir: makeProject(files) })
+ expect(await spmPackageResolvedMissing.run(ctx)).toEqual([])
+ })
+
+ it('warns (not errors — builder resolves SPM during build_app) when neither Package.resolved exists', async () => {
+ const files = cleanSpmFiles()
+ delete files['ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved']
+ const ctx = makeCtx({ projectDir: makeProject(files) })
+ const f = await spmPackageResolvedMissing.run(ctx)
+ expect(f.length).toBeGreaterThan(0)
+ expect(f.every(x => x.severity === 'warning' && x.id === 'ios/spm-package-resolved-missing')).toBe(true)
+ })
+
+ it('does NOT apply to a Pods project (no Package.swift)', () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) })
+ expect(spmPackageResolvedMissing.appliesTo?.(ctx) ?? true).toBe(false)
+ })
+})
+
+describe('ios/spm-capacitor-dependency-missing', () => {
+ it('is clean when Package.swift declares capacitor-swift-pm + .product Capacitor', async () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) })
+ expect(await spmCapacitorDependencyMissing.run(ctx)).toEqual([])
+ })
+
+ it('warns (not errors — builder does not cap-sync but high FP) when capacitor dep absent', async () => {
+ const ctx = makeCtx({
+ projectDir: makeProject(cleanSpmFiles({
+ 'ios/App/CapApp-SPM/Package.swift': `// swift-tools-version: 5.9
+import PackageDescription
+let package = Package(name: "CapApp-SPM", platforms: [.iOS(.v15)], dependencies: [], targets: [])`,
+ })),
+ })
+ const f = await spmCapacitorDependencyMissing.run(ctx)
+ expect(f.length).toBeGreaterThan(0)
+ expect(f.every(x => x.severity === 'warning' && x.id === 'ios/spm-capacitor-dependency-missing')).toBe(true)
+ })
+
+ it('does NOT apply to a Pods project', () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) })
+ expect(spmCapacitorDependencyMissing.appliesTo?.(ctx) ?? true).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// §2.F App icon checks
+// ---------------------------------------------------------------------------
+
+describe('ios/appicon-empty-or-placeholder', () => {
+ it('is clean when the appiconset has a real icon image (grounding)', async () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) })
+ expect(await appiconEmptyOrPlaceholder.run(ctx)).toEqual([])
+ })
+
+ it('errors when the AppIcon.appiconset directory is missing', async () => {
+ const files = cleanSpmFiles()
+ delete files['ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json']
+ delete files['ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png']
+ const ctx = makeCtx({ projectDir: makeProject(files) })
+ const f = await appiconEmptyOrPlaceholder.run(ctx)
+ expect(f.some(x => x.id === 'ios/appicon-empty-or-placeholder')).toBe(true)
+ })
+
+ it('warns (not errors — actool tolerates empty for ad_hoc) when no icon images and NOT uploading', async () => {
+ const ctx = makeCtx({
+ projectDir: makeProject(cleanSpmFiles({
+ 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': `{ "images": [], "info": { "version": 1 } }`,
+ })),
+ distributionMode: 'ad_hoc',
+ })
+ const f = await appiconEmptyOrPlaceholder.run(ctx)
+ expect(f.length).toBeGreaterThan(0)
+ expect(f.every(x => x.severity === 'warning')).toBe(true)
+ })
+
+ it('escalates to error when no icon images AND uploading to App Store (ITMS-90704 will block)', async () => {
+ const ctx = makeCtx({
+ projectDir: makeProject(cleanSpmFiles({
+ 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': `{ "images": [], "info": { "version": 1 } }`,
+ })),
+ distributionMode: 'app_store',
+ credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' },
+ })
+ const f = await appiconEmptyOrPlaceholder.run(ctx)
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+
+ it('errors when Contents.json is malformed', async () => {
+ const ctx = makeCtx({
+ projectDir: makeProject(cleanSpmFiles({
+ 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': `{ not valid json`,
+ })),
+ distributionMode: 'app_store',
+ credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' },
+ })
+ const f = await appiconEmptyOrPlaceholder.run(ctx)
+ expect(f.some(x => x.severity === 'error')).toBe(true)
+ })
+})
+
+describe('ios/appicon-referenced-file-missing', () => {
+ it('is clean when every referenced filename exists on disk (grounding)', async () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) })
+ expect(await appiconReferencedFileMissing.run(ctx)).toEqual([])
+ })
+
+ it('does NOT apply when Contents.json is absent', () => {
+ const files = cleanSpmFiles()
+ delete files['ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json']
+ const ctx = makeCtx({ projectDir: makeProject(files) })
+ expect(appiconReferencedFileMissing.appliesTo?.(ctx) ?? true).toBe(false)
+ })
+
+ it('ERRORS (actool fails server-side) when a referenced PNG is missing from disk', async () => {
+ const files = cleanSpmFiles()
+ delete files['ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png']
+ const ctx = makeCtx({ projectDir: makeProject(files) })
+ const f = await appiconReferencedFileMissing.run(ctx)
+ expect(f.length).toBeGreaterThan(0)
+ expect(f.every(x => x.severity === 'error' && x.id === 'ios/appicon-referenced-file-missing')).toBe(true)
+ // Filename is surfaced; no credential material ever appears in findings.
+ expect(f.some(x => (x.detail ?? '').includes('AppIcon-512@2x.png'))).toBe(true)
+ })
+})
+
+describe('ios/appicon-marketing-missing', () => {
+ it('does NOT apply when not uploading to App Store (ad_hoc)', () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()), distributionMode: 'ad_hoc' })
+ expect(appiconMarketingMissing.appliesTo?.(ctx) ?? true).toBe(false)
+ })
+
+ it('is clean (1024 present) when uploading to App Store', async () => {
+ const ctx = makeCtx({
+ projectDir: makeProject(cleanSpmFiles()),
+ distributionMode: 'app_store',
+ credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' },
+ })
+ expect(await appiconMarketingMissing.run(ctx)).toEqual([])
+ })
+
+ it('errors (upload-gated, ITMS-90704) when the 1024 marketing icon is absent', async () => {
+ const ctx = makeCtx({
+ projectDir: makeProject(cleanSpmFiles({
+ 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': `{
+ "images": [{ "idiom": "iphone", "size": "60x60", "scale": "3x", "filename": "icon-60.png" }],
+ "info": { "version": 1 }
+ }`,
+ })),
+ distributionMode: 'app_store',
+ credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' },
+ })
+ const f = await appiconMarketingMissing.run(ctx)
+ expect(f.some(x => x.severity === 'error' && x.id === 'ios/appicon-marketing-missing')).toBe(true)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// §2.F SPM deployment-target consistency
+// ---------------------------------------------------------------------------
+
+describe('ios/spm-deployment-target-consistency', () => {
+ it('is clean when pbxproj target >= Package.swift min (grounding 15.0 vs .v15)', async () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) })
+ expect(await spmDeploymentTargetConsistency.run(ctx)).toEqual([])
+ })
+
+ it('warns when pbxproj target < Package.swift min (the dangerous direction)', async () => {
+ const ctx = makeCtx({
+ projectDir: makeProject(cleanSpmFiles({
+ 'ios/App/App.xcodeproj/project.pbxproj': pbxproj('14.0'),
+ })),
+ })
+ const f = await spmDeploymentTargetConsistency.run(ctx)
+ expect(f.some(x => x.severity === 'warning' && x.id === 'ios/spm-deployment-target-consistency')).toBe(true)
+ })
+
+ it('is clean when pbxproj target > Package.swift min', async () => {
+ const ctx = makeCtx({
+ projectDir: makeProject(cleanSpmFiles({
+ 'ios/App/App.xcodeproj/project.pbxproj': pbxproj('16.0'),
+ })),
+ })
+ expect(await spmDeploymentTargetConsistency.run(ctx)).toEqual([])
+ })
+
+ it('does NOT apply when IPHONEOS_DEPLOYMENT_TARGET is absent (inherited/unknown)', () => {
+ const files = cleanSpmFiles()
+ files['ios/App/App.xcodeproj/project.pbxproj'] = `// !$*UTF8*$!
+{
+ ABCD0001 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = app.capgo.plugin.TutorialBuild; };
+ name = Release;
+ };
+}`
+ const ctx = makeCtx({ projectDir: makeProject(files) })
+ expect(spmDeploymentTargetConsistency.appliesTo?.(ctx) ?? true).toBe(false)
+ })
+
+ it('does NOT apply to a Pods project', () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) })
+ expect(spmDeploymentTargetConsistency.appliesTo?.(ctx) ?? true).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Degradation / no-crash on partial projects
+// ---------------------------------------------------------------------------
+
+describe('ios-pods-assets - partial / non-Capacitor projects never crash', () => {
+ const ALL = [
+ podsNotInstalled,
+ podsLockMissing,
+ podsCapacitorMissing,
+ spmPackageResolvedMissing,
+ spmCapacitorDependencyMissing,
+ appiconEmptyOrPlaceholder,
+ appiconReferencedFileMissing,
+ appiconMarketingMissing,
+ spmDeploymentTargetConsistency,
+ ]
+
+ it('every check early-returns [] on an empty project dir - except appicon-empty (always reports a missing set)', async () => {
+ const ctx = makeCtx({ projectDir: makeProject({}) })
+ for (const check of ALL) {
+ if (check.appliesTo && !check.appliesTo(ctx))
+ continue
+ // appicon-empty-or-placeholder is "always (iOS, degrade in run)" per §3: a
+ // project with no AppIcon set must surface that, not stay silent.
+ if (check.id === 'ios/appicon-empty-or-placeholder') {
+ const f = await check.run(ctx)
+ expect(f.length).toBe(1)
+ expect(f[0].id).toBe('ios/appicon-empty-or-placeholder')
+ expect(f[0].title).toContain('AppIcon.appiconset is missing')
+ continue
+ }
+ expect(await check.run(ctx)).toEqual([])
+ }
+ })
+
+ it('full clean SPM project (grounding mirror) yields ZERO findings across all checks', async () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()), distributionMode: 'app_store', credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' } })
+ for (const check of ALL) {
+ if (check.appliesTo && !check.appliesTo(ctx))
+ continue
+ expect(await check.run(ctx)).toEqual([])
+ }
+ })
+
+ it('full clean CocoaPods project yields ZERO findings across all checks', async () => {
+ const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()), distributionMode: 'app_store', credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' } })
+ for (const check of ALL) {
+ if (check.appliesTo && !check.appliesTo(ctx))
+ continue
+ expect(await check.run(ctx)).toEqual([])
+ }
+ })
+})
diff --git a/cli/test/prescan/checks-ios-xcode.test.ts b/cli/test/prescan/checks-ios-xcode.test.ts
new file mode 100644
index 0000000000..d78f517290
--- /dev/null
+++ b/cli/test/prescan/checks-ios-xcode.test.ts
@@ -0,0 +1,453 @@
+// test/prescan/checks-ios-xcode.test.ts
+//
+// Tests for the §2.B "Xcode project / build settings" check pack
+// (checks/ios-xcode.ts). The clean fixture mirrors the real Capacitor-8 SPM
+// tutorial project (Debug==Release bundle id, DEVELOPMENT_TEAM present, no
+// ENABLE_BITCODE, SWIFT_VERSION 5.0, IPHONEOS_DEPLOYMENT_TARGET 15.0, a single
+// application target) and MUST scan clean (zero findings) — it is the
+// regression baseline. Each failing case is a minimal mutation of that fixture.
+import type { ScanContext } from '../../src/build/prescan/types'
+import { describe, expect, it } from 'bun:test'
+import {
+ bundleIdMismatchAcrossConfigs,
+ deploymentTargetCapacitor,
+ enableBitcodeLeftover,
+ multipleAppTargets,
+ noAppTarget,
+ signingTeam,
+ swiftVersionSanity,
+} from '../../src/build/prescan/checks/ios-xcode'
+import { makeProject } from './helpers'
+
+const CAP8_PKG = JSON.stringify({ dependencies: { '@capacitor/core': '^8.0.0', '@capacitor/ios': '^8.0.0' } })
+
+/**
+ * Build a pbxproj from per-config settings dicts, modelled on the Capacitor SPM
+ * layout: one project-level Debug/Release pair carrying only deployment target,
+ * and one app-target Debug/Release pair carrying the signing/version settings.
+ * `extraTargets` lets a test add extra targets (for the multi-target case);
+ * includeAppTarget:false drops the application target entirely.
+ */
+function makePbx(opts: {
+ projectSettings?: Record
+ targetDebug?: Record
+ targetRelease?: Record
+ extraTargets?: { name: string, productType: string, settings: Record }[]
+ includeAppTarget?: boolean
+} = {}): string {
+ const renderSettings = (s: Record): string =>
+ Object.entries(s)
+ .map(([k, v]) => `\t\t\t\t${k} = ${/[\s,]/.test(v) ? `"${v}"` : v};`)
+ .join('\n')
+
+ const projectSettings = opts.projectSettings ?? { IPHONEOS_DEPLOYMENT_TARGET: '15.0', SDKROOT: 'iphoneos' }
+ const includeApp = opts.includeAppTarget !== false
+ const targetDebug = opts.targetDebug ?? {
+ CODE_SIGN_STYLE: 'Automatic',
+ CURRENT_PROJECT_VERSION: '1',
+ DEVELOPMENT_TEAM: 'UVTJ336J2D',
+ IPHONEOS_DEPLOYMENT_TARGET: '15.0',
+ MARKETING_VERSION: '1.0',
+ PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild',
+ PRODUCT_NAME: 'Tutorial Build example app',
+ SWIFT_VERSION: '5.0',
+ TARGETED_DEVICE_FAMILY: '1,2',
+ }
+ const targetRelease = opts.targetRelease ?? { ...targetDebug }
+
+ const targetBlocks: string[] = []
+ const configBlocks: string[] = []
+ const listBlocks: string[] = []
+ const projectTargetRefs: string[] = []
+
+ if (includeApp) {
+ targetBlocks.push(`\t\tAAA1 /* App */ = {
+\t\t\tisa = PBXNativeTarget;
+\t\t\tbuildConfigurationList = LIST_APP /* Build configuration list for PBXNativeTarget "App" */;
+\t\t\tname = App;
+\t\t\tproductType = "com.apple.product-type.application";
+\t\t};`)
+ configBlocks.push(`\t\tCFG_APP_DEBUG /* Debug */ = {
+\t\t\tisa = XCBuildConfiguration;
+\t\t\tbuildSettings = {
+${renderSettings(targetDebug)}
+\t\t\t};
+\t\t\tname = Debug;
+\t\t};`)
+ configBlocks.push(`\t\tCFG_APP_RELEASE /* Release */ = {
+\t\t\tisa = XCBuildConfiguration;
+\t\t\tbuildSettings = {
+${renderSettings(targetRelease)}
+\t\t\t};
+\t\t\tname = Release;
+\t\t};`)
+ listBlocks.push(`\t\tLIST_APP /* Build configuration list for PBXNativeTarget "App" */ = {
+\t\t\tisa = XCConfigurationList;
+\t\t\tbuildConfigurations = (
+\t\t\t\tCFG_APP_DEBUG /* Debug */,
+\t\t\t\tCFG_APP_RELEASE /* Release */,
+\t\t\t);
+\t\t\tdefaultConfigurationName = Release;
+\t\t};`)
+ projectTargetRefs.push('\t\t\t\tAAA1 /* App */,')
+ }
+
+ let i = 0
+ for (const t of opts.extraTargets ?? []) {
+ i++
+ const id = `EXTRA${i}`
+ targetBlocks.push(`\t\t${id} /* ${t.name} */ = {
+\t\t\tisa = PBXNativeTarget;
+\t\t\tbuildConfigurationList = LIST_${id} /* Build configuration list for PBXNativeTarget "${t.name}" */;
+\t\t\tname = ${t.name};
+\t\t\tproductType = "${t.productType}";
+\t\t};`)
+ configBlocks.push(`\t\tCFG_${id}_RELEASE /* Release */ = {
+\t\t\tisa = XCBuildConfiguration;
+\t\t\tbuildSettings = {
+${renderSettings(t.settings)}
+\t\t\t};
+\t\t\tname = Release;
+\t\t};`)
+ listBlocks.push(`\t\tLIST_${id} /* Build configuration list for PBXNativeTarget "${t.name}" */ = {
+\t\t\tisa = XCConfigurationList;
+\t\t\tbuildConfigurations = (
+\t\t\t\tCFG_${id}_RELEASE /* Release */,
+\t\t\t);
+\t\t\tdefaultConfigurationName = Release;
+\t\t};`)
+ projectTargetRefs.push(`\t\t\t\t${id} /* ${t.name} */,`)
+ }
+
+ return `// !$*UTF8*$!
+{
+\tobjects = {
+/* Begin PBXNativeTarget section */
+${targetBlocks.join('\n')}
+/* End PBXNativeTarget section */
+/* Begin PBXProject section */
+\t\tPROJ /* Project object */ = {
+\t\t\tisa = PBXProject;
+\t\t\tattributes = {
+\t\t\t\tTargetAttributes = {
+\t\t\t\t\tAAA1 = { ProvisioningStyle = Automatic; };
+\t\t\t\t};
+\t\t\t};
+\t\t\tbuildConfigurationList = LIST_PROJECT /* Build configuration list for PBXProject "App" */;
+\t\t\ttargets = (
+${projectTargetRefs.join('\n')}
+\t\t\t);
+\t\t};
+/* End PBXProject section */
+/* Begin XCBuildConfiguration section */
+\t\tCFG_PROJ_DEBUG /* Debug */ = {
+\t\t\tisa = XCBuildConfiguration;
+\t\t\tbuildSettings = {
+${renderSettings(projectSettings)}
+\t\t\t};
+\t\t\tname = Debug;
+\t\t};
+\t\tCFG_PROJ_RELEASE /* Release */ = {
+\t\t\tisa = XCBuildConfiguration;
+\t\t\tbuildSettings = {
+${renderSettings(projectSettings)}
+\t\t\t};
+\t\t\tname = Release;
+\t\t};
+${configBlocks.join('\n')}
+/* End XCBuildConfiguration section */
+/* Begin XCConfigurationList section */
+\t\tLIST_PROJECT /* Build configuration list for PBXProject "App" */ = {
+\t\t\tisa = XCConfigurationList;
+\t\t\tbuildConfigurations = (
+\t\t\t\tCFG_PROJ_DEBUG /* Debug */,
+\t\t\t\tCFG_PROJ_RELEASE /* Release */,
+\t\t\t);
+\t\t\tdefaultConfigurationName = Release;
+\t\t};
+${listBlocks.join('\n')}
+/* End XCConfigurationList section */
+\t};
+}
+`
+}
+
+/** Write a pbxproj into the Capacitor `ios/App/App.xcodeproj` location. */
+function projectWith(pbx: string, extraFiles: Record = {}): string {
+ return makeProject({
+ 'package.json': CAP8_PKG,
+ 'ios/App/App.xcodeproj/project.pbxproj': pbx,
+ ...extraFiles,
+ })
+}
+
+function ctx(projectDir: string, partial: Partial = {}): ScanContext {
+ return { appId: 'app.capgo.plugin.TutorialBuild', platform: 'ios', projectDir, ...partial }
+}
+
+// ── Clean grounding baseline ────────────────────────────────────────────────
+
+describe('ios-xcode: clean Capacitor-8 SPM fixture scans clean (regression baseline)', () => {
+ const dir = projectWith(makePbx())
+ const checks = [
+ deploymentTargetCapacitor,
+ signingTeam,
+ bundleIdMismatchAcrossConfigs,
+ enableBitcodeLeftover,
+ swiftVersionSanity,
+ noAppTarget,
+ multipleAppTargets,
+ ]
+ for (const check of checks) {
+ it(`${check.id} returns [] on the clean fixture`, async () => {
+ const c = ctx(dir)
+ const applies = check.appliesTo ? check.appliesTo(c) : true
+ const findings = applies ? await check.run(c) : []
+ expect(findings).toEqual([])
+ })
+ }
+})
+
+describe('ios-xcode: grounding against a real-shaped pbxproj fixture (every check clean)', () => {
+ // Self-contained inline fixture mirroring the real Capacitor-8 SPM tutorial
+ // project's distinctive build settings (real bundle id, team, AppIcon name,
+ // version pair) so the grounding assertions are REAL on CI, where the external
+ // tutorial-app checkout does not exist. Previously this read an absolute path
+ // outside the repo, which made the grounding pass vacuously / crash on CI.
+ const realShapedTarget = {
+ ASSETCATALOG_COMPILER_APPICON_NAME: 'AppIcon',
+ CODE_SIGN_STYLE: 'Automatic',
+ CURRENT_PROJECT_VERSION: '1',
+ DEVELOPMENT_TEAM: 'UVTJ336J2D',
+ IPHONEOS_DEPLOYMENT_TARGET: '15.0',
+ MARKETING_VERSION: '1.0',
+ PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild',
+ PRODUCT_NAME: 'Tutorial Build example app',
+ SWIFT_VERSION: '5.0',
+ TARGETED_DEVICE_FAMILY: '1,2',
+ }
+ const realDir = projectWith(makePbx({
+ projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '15.0', SDKROOT: 'iphoneos' },
+ targetDebug: { ...realShapedTarget },
+ targetRelease: { ...realShapedTarget },
+ }))
+ const checks = [
+ deploymentTargetCapacitor,
+ signingTeam,
+ bundleIdMismatchAcrossConfigs,
+ enableBitcodeLeftover,
+ swiftVersionSanity,
+ noAppTarget,
+ multipleAppTargets,
+ ]
+ for (const check of checks) {
+ it(`${check.id} returns [] against the real-shaped fixture`, async () => {
+ const c = ctx(realDir)
+ const applies = check.appliesTo ? check.appliesTo(c) : true
+ const findings = applies ? await check.run(c) : []
+ expect(findings).toEqual([])
+ })
+ }
+})
+
+// ── ios/xcode-deployment-target-capacitor ───────────────────────────────────
+
+describe('ios/xcode-deployment-target-capacitor', () => {
+ it('errors when IPHONEOS_DEPLOYMENT_TARGET is below the Capacitor 8 floor (14)', async () => {
+ const dir = projectWith(makePbx({ projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '12.0', SDKROOT: 'iphoneos' } }))
+ expect(deploymentTargetCapacitor.appliesTo!(ctx(dir))).toBe(true)
+ const findings = await deploymentTargetCapacitor.run(ctx(dir))
+ expect(findings.length).toBe(1)
+ expect(findings[0].severity).toBe('error')
+ expect(findings[0].id).toBe('ios/xcode-deployment-target-capacitor')
+ expect(findings[0].title).toContain('12')
+ })
+ it('passes at exactly the floor (14.0 on Cap8)', async () => {
+ const dir = projectWith(makePbx({ projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '14.0', SDKROOT: 'iphoneos' } }))
+ expect(await deploymentTargetCapacitor.run(ctx(dir))).toEqual([])
+ })
+ it('reads the app-target value too (target below floor errors even if project-level is fine)', async () => {
+ const low = { IPHONEOS_DEPLOYMENT_TARGET: '11.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', DEVELOPMENT_TEAM: 'UVTJ336J2D', CODE_SIGN_STYLE: 'Automatic', SWIFT_VERSION: '5.0' }
+ const dir = projectWith(makePbx({ targetDebug: { ...low }, targetRelease: { ...low }, projectSettings: { SDKROOT: 'iphoneos' } }))
+ const findings = await deploymentTargetCapacitor.run(ctx(dir))
+ expect(findings.length).toBe(1)
+ expect(findings[0].severity).toBe('error')
+ })
+ it('appliesTo false when the key is absent everywhere (inherited)', async () => {
+ const noTarget = { PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', DEVELOPMENT_TEAM: 'UVTJ336J2D', CODE_SIGN_STYLE: 'Automatic', SWIFT_VERSION: '5.0' }
+ const dir = projectWith(makePbx({ projectSettings: { SDKROOT: 'iphoneos' }, targetDebug: { ...noTarget }, targetRelease: { ...noTarget } }))
+ expect(deploymentTargetCapacitor.appliesTo!(ctx(dir))).toBe(false)
+ })
+ it('appliesTo false when capacitorMajor is null (no package.json)', async () => {
+ const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': makePbx() })
+ expect(deploymentTargetCapacitor.appliesTo!(ctx(dir))).toBe(false)
+ })
+ it('does not throw on a missing project (returns [])', async () => {
+ const dir = makeProject({ 'package.json': CAP8_PKG })
+ expect(await deploymentTargetCapacitor.run(ctx(dir))).toEqual([])
+ })
+})
+
+// ── ios/xcode-signing-team ───────────────────────────────────────────────────
+
+describe('ios/xcode-signing-team', () => {
+ const noTeam = {
+ CODE_SIGN_STYLE: 'Automatic',
+ IPHONEOS_DEPLOYMENT_TARGET: '15.0',
+ PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild',
+ SWIFT_VERSION: '5.0',
+ }
+ it('warns (no upload) when CODE_SIGN_STYLE present but DEVELOPMENT_TEAM absent', async () => {
+ const dir = projectWith(makePbx({ targetDebug: { ...noTeam }, targetRelease: { ...noTeam } }))
+ expect(signingTeam.appliesTo!(ctx(dir))).toBe(true)
+ const findings = await signingTeam.run(ctx(dir))
+ expect(findings.length).toBe(1)
+ expect(findings[0].severity).toBe('warning')
+ expect(findings[0].id).toBe('ios/xcode-signing-team')
+ })
+ it('escalates to error when uploading to the App Store', async () => {
+ const dir = projectWith(makePbx({ targetDebug: { ...noTeam }, targetRelease: { ...noTeam } }))
+ const c = ctx(dir, { distributionMode: 'app_store', credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' } })
+ const findings = await signingTeam.run(c)
+ expect(findings.length).toBe(1)
+ expect(findings[0].severity).toBe('error')
+ })
+ it('treats an empty-string DEVELOPMENT_TEAM as missing', async () => {
+ const dir = projectWith(makePbx({ targetDebug: { ...noTeam, DEVELOPMENT_TEAM: '' }, targetRelease: { ...noTeam, DEVELOPMENT_TEAM: '' } }))
+ expect((await signingTeam.run(ctx(dir))).length).toBe(1)
+ })
+ it('also fires for Manual signing without a team', async () => {
+ const manual = { ...noTeam, CODE_SIGN_STYLE: 'Manual' }
+ const dir = projectWith(makePbx({ targetDebug: { ...manual }, targetRelease: { ...manual } }))
+ expect((await signingTeam.run(ctx(dir))).length).toBe(1)
+ })
+ it('suppressed (appliesTo false) when a provisioning map is present', async () => {
+ const dir = projectWith(makePbx({ targetDebug: { ...noTeam }, targetRelease: { ...noTeam } }))
+ const c = ctx(dir, { credentials: { CAPGO_IOS_PROVISIONING_MAP: JSON.stringify({ 'app.capgo.plugin.TutorialBuild': { profile: 'AAAA', name: 'p' } }) } })
+ expect(signingTeam.appliesTo!(c)).toBe(false)
+ })
+ it('appliesTo false when CODE_SIGN_STYLE is absent (nothing to judge)', async () => {
+ const noStyle = { IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', SWIFT_VERSION: '5.0' }
+ const dir = projectWith(makePbx({ targetDebug: { ...noStyle }, targetRelease: { ...noStyle } }))
+ expect(signingTeam.appliesTo!(ctx(dir))).toBe(false)
+ })
+})
+
+// ── ios/xcode-bundle-id-mismatch-across-configs ──────────────────────────────
+
+describe('ios/xcode-bundle-id-mismatch-across-configs', () => {
+ it('warns when Debug and Release bundle ids differ', async () => {
+ const dir = projectWith(makePbx({
+ targetDebug: { CODE_SIGN_STYLE: 'Automatic', DEVELOPMENT_TEAM: 'UVTJ336J2D', SWIFT_VERSION: '5.0', IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild.debug' },
+ targetRelease: { CODE_SIGN_STYLE: 'Automatic', DEVELOPMENT_TEAM: 'UVTJ336J2D', SWIFT_VERSION: '5.0', IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild' },
+ }))
+ expect(bundleIdMismatchAcrossConfigs.appliesTo!(ctx(dir))).toBe(true)
+ const findings = await bundleIdMismatchAcrossConfigs.run(ctx(dir))
+ expect(findings.length).toBe(1)
+ expect(findings[0].severity).toBe('warning')
+ expect(findings[0].id).toBe('ios/xcode-bundle-id-mismatch-across-configs')
+ expect(findings[0].detail).toContain('app.capgo.plugin.TutorialBuild.debug')
+ })
+ it('passes when Debug == Release (the clean case)', async () => {
+ const dir = projectWith(makePbx())
+ expect(await bundleIdMismatchAcrossConfigs.run(ctx(dir))).toEqual([])
+ })
+})
+
+// ── ios/xcode-enable-bitcode-leftover ────────────────────────────────────────
+
+describe('ios/xcode-enable-bitcode-leftover', () => {
+ it('warns when ENABLE_BITCODE = YES anywhere', async () => {
+ const dir = projectWith(makePbx({ projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '15.0', ENABLE_BITCODE: 'YES' } }))
+ expect(enableBitcodeLeftover.appliesTo!(ctx(dir))).toBe(true)
+ const findings = await enableBitcodeLeftover.run(ctx(dir))
+ expect(findings.length).toBe(1)
+ expect(findings[0].severity).toBe('warning')
+ expect(findings[0].id).toBe('ios/xcode-enable-bitcode-leftover')
+ })
+ it('appliesTo false when ENABLE_BITCODE = NO', async () => {
+ const dir = projectWith(makePbx({ projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '15.0', ENABLE_BITCODE: 'NO' } }))
+ expect(enableBitcodeLeftover.appliesTo!(ctx(dir))).toBe(false)
+ })
+ it('appliesTo false when absent (the clean case)', async () => {
+ const dir = projectWith(makePbx())
+ expect(enableBitcodeLeftover.appliesTo!(ctx(dir))).toBe(false)
+ })
+})
+
+// ── ios/xcode-swift-version-sanity ───────────────────────────────────────────
+
+describe('ios/xcode-swift-version-sanity', () => {
+ it('warns when SWIFT_VERSION < 5', async () => {
+ const s = { CODE_SIGN_STYLE: 'Automatic', DEVELOPMENT_TEAM: 'UVTJ336J2D', IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', SWIFT_VERSION: '4.2' }
+ const dir = projectWith(makePbx({ targetDebug: { ...s }, targetRelease: { ...s } }))
+ expect(swiftVersionSanity.appliesTo!(ctx(dir))).toBe(true)
+ const findings = await swiftVersionSanity.run(ctx(dir))
+ expect(findings.length).toBe(1)
+ expect(findings[0].severity).toBe('warning')
+ expect(findings[0].id).toBe('ios/xcode-swift-version-sanity')
+ })
+ it('warns when SWIFT_VERSION is non-numeric', async () => {
+ const s = { CODE_SIGN_STYLE: 'Automatic', DEVELOPMENT_TEAM: 'UVTJ336J2D', IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', SWIFT_VERSION: 'swift5' }
+ const dir = projectWith(makePbx({ targetDebug: { ...s }, targetRelease: { ...s } }))
+ expect((await swiftVersionSanity.run(ctx(dir))).length).toBe(1)
+ })
+ it('passes at 5.0 (clean)', async () => {
+ const dir = projectWith(makePbx())
+ expect(await swiftVersionSanity.run(ctx(dir))).toEqual([])
+ })
+ it('appliesTo false when SWIFT_VERSION is absent (Obj-C-only target)', async () => {
+ const s = { CODE_SIGN_STYLE: 'Automatic', DEVELOPMENT_TEAM: 'UVTJ336J2D', IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild' }
+ const dir = projectWith(makePbx({ targetDebug: { ...s }, targetRelease: { ...s } }))
+ expect(swiftVersionSanity.appliesTo!(ctx(dir))).toBe(false)
+ })
+})
+
+// ── ios/xcode-no-app-target ──────────────────────────────────────────────────
+
+describe('ios/xcode-no-app-target', () => {
+ it('errors when the pbxproj parses but has zero application targets', async () => {
+ const dir = projectWith(makePbx({
+ includeAppTarget: false,
+ extraTargets: [{ name: 'Ext', productType: 'com.apple.product-type.app-extension', settings: { PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild.ext', IPHONEOS_DEPLOYMENT_TARGET: '15.0' } }],
+ }))
+ expect(noAppTarget.appliesTo!(ctx(dir))).toBe(true)
+ const findings = await noAppTarget.run(ctx(dir))
+ expect(findings.length).toBe(1)
+ expect(findings[0].severity).toBe('error')
+ expect(findings[0].id).toBe('ios/xcode-no-app-target')
+ })
+ it('passes with exactly one application target (clean)', async () => {
+ const dir = projectWith(makePbx())
+ expect(await noAppTarget.run(ctx(dir))).toEqual([])
+ })
+ it('appliesTo false (skip) when readPbxproj is null', async () => {
+ const dir = makeProject({ 'package.json': CAP8_PKG })
+ expect(noAppTarget.appliesTo!(ctx(dir))).toBe(false)
+ })
+})
+
+// ── ios/xcode-multiple-app-targets ───────────────────────────────────────────
+
+describe('ios/xcode-multiple-app-targets', () => {
+ it('warns when two application targets exist', async () => {
+ const dir = projectWith(makePbx({
+ extraTargets: [{ name: 'App2', productType: 'com.apple.product-type.application', settings: { PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild2', IPHONEOS_DEPLOYMENT_TARGET: '15.0' } }],
+ }))
+ expect(multipleAppTargets.appliesTo!(ctx(dir))).toBe(true)
+ const findings = await multipleAppTargets.run(ctx(dir))
+ expect(findings.length).toBe(1)
+ expect(findings[0].severity).toBe('warning')
+ expect(findings[0].id).toBe('ios/xcode-multiple-app-targets')
+ })
+ it('does not count extensions (single app target + extension is clean)', async () => {
+ const dir = projectWith(makePbx({
+ extraTargets: [{ name: 'Ext', productType: 'com.apple.product-type.app-extension', settings: { PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild.ext', IPHONEOS_DEPLOYMENT_TARGET: '15.0' } }],
+ }))
+ expect(multipleAppTargets.appliesTo!(ctx(dir))).toBe(false)
+ })
+ it('appliesTo false with a single app target (clean)', async () => {
+ const dir = projectWith(makePbx())
+ expect(multipleAppTargets.appliesTo!(ctx(dir))).toBe(false)
+ })
+})
diff --git a/cli/test/prescan/engine.test.ts b/cli/test/prescan/engine.test.ts
index 23be17ce1e..1e4199ffb8 100644
--- a/cli/test/prescan/engine.test.ts
+++ b/cli/test/prescan/engine.test.ts
@@ -82,16 +82,34 @@ describe('fixture helpers', () => {
})
describe('registry', () => {
- it('contains all 47 checks with unique ids', () => {
+ it('contains all 80 checks with unique ids', () => {
const ids = ALL_CHECKS.map(c => c.id)
expect(new Set(ids).size).toBe(ids.length)
- expect(ids.length).toBe(47)
+ expect(ids.length).toBe(80)
for (const expected of [
'shared/apikey-permission', 'shared/app-exists', 'shared/credentials-saved',
'shared/cap-sync-stale', 'shared/node-linker-layout', 'shared/bundle-id-consistency',
'ios/p12-opens', 'ios/p12-expiry', 'ios/profile-expiry', 'ios/profile-bundle-match',
'ios/profile-type-vs-mode', 'ios/cert-profile-pairing', 'ios/targets-covered',
'ios/infoplist-sanity', 'ios/asc-key-valid',
+ // 10 ios plist checks
+ 'ios/plist-bundle-id-format', 'ios/plist-version-short-format', 'ios/plist-version-build-format',
+ 'ios/plist-encryption-compliance', 'ios/plist-ats-arbitrary-loads', 'ios/plist-launch-storyboard',
+ 'ios/plist-orientations-multitasking', 'ios/plist-orientations-present', 'ios/plist-display-name',
+ 'ios/plist-background-modes-sanity',
+ // 7 ios xcode checks
+ 'ios/xcode-deployment-target-capacitor', 'ios/xcode-signing-team', 'ios/xcode-bundle-id-mismatch-across-configs',
+ 'ios/xcode-enable-bitcode-leftover', 'ios/xcode-swift-version-sanity', 'ios/xcode-no-app-target',
+ 'ios/xcode-multiple-app-targets',
+ // 4 ios entitlements checks
+ 'ios/entitlements-vs-profile-capability', 'ios/entitlements-aps-environment-vs-mode',
+ 'ios/entitlements-associated-domains-format', 'ios/entitlements-app-groups-format',
+ // 3 ios capacitor config checks
+ 'ios/capacitor-server-url-shipped', 'ios/capacitor-server-cleartext', 'ios/capacitor-allow-navigation-wildcard',
+ // 9 ios pods/spm/appicon checks
+ 'ios/pods-not-installed', 'ios/pods-lock-missing', 'ios/pods-capacitor-missing',
+ 'ios/spm-package-resolved-missing', 'ios/spm-capacitor-dependency-missing', 'ios/appicon-empty-or-placeholder',
+ 'ios/appicon-referenced-file-missing', 'ios/appicon-marketing-missing', 'ios/spm-deployment-target-consistency',
'android/keystore-opens', 'android/keystore-expiry', 'android/cordova-vars-present',
'android/gradle-props-heuristics', 'android/play-sa-json', 'android/flavor-exists',
'android/agp8-package-attr',
diff --git a/cli/test/prescan/ios-parsers.test.ts b/cli/test/prescan/ios-parsers.test.ts
new file mode 100644
index 0000000000..9d71fc97e2
--- /dev/null
+++ b/cli/test/prescan/ios-parsers.test.ts
@@ -0,0 +1,741 @@
+// test/prescan/ios-parsers.test.ts
+// Unit tests for the shared iOS prescan parsing helpers. Each pure parser must
+// NEVER throw — it returns null/[]/false on missing or malformed input — and is
+// grounded against the real Capacitor-8 SPM tutorial project (which must stay
+// false-positive clean).
+import { describe, expect, it } from 'bun:test'
+import {
+ plistArrayStrings,
+ plistBool,
+ plistDictBlock,
+ plistHasKey,
+ plistString,
+} from '../../src/build/prescan/checks/ios-plist-read'
+import {
+ readBuildConfigs,
+ readBuildSetting,
+ readTargetConfigs,
+ resolvePlistValue,
+} from '../../src/build/prescan/ios-pbxsettings'
+import {
+ entArray,
+ entBool,
+ entString,
+ readAppEntitlements,
+} from '../../src/build/prescan/ios-entitlements'
+import { parseMobileprovisionDetailedFromBase64 } from '../../src/build/mobileprovision-parser'
+import {
+ appIconSetDir,
+ hasMarketingIcon,
+ readContentsJson,
+} from '../../src/build/prescan/ios-appicon'
+import { Buffer } from 'node:buffer'
+import { join } from 'node:path'
+import { makeProfileXml, makeProject } from './helpers'
+
+// Self-contained inline fixtures mirroring the real Capacitor-8 SPM tutorial
+// project's Info.plist and App.entitlements byte-for-byte (key set, $()-refs,
+// orientation arrays). These keep the grounding assertions REAL on CI, where the
+// external tutorial-app checkout does not exist — previously the grounding read
+// absolute paths outside the repo (vacuous pass / module-load crash on CI).
+const REAL_SHAPED_INFO_PLIST = `
+
+
+
+ CAPACITOR_DEBUG
+ $(CAPACITOR_DEBUG)
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ Tutorial Build example app
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UIRequiredDeviceCapabilities
+
+ armv7
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIViewControllerBasedStatusBarAppearance
+
+
+`
+
+// Real-shaped App.entitlements (aps-environment=development only, like the
+// stock Capacitor default).
+const REAL_SHAPED_ENTITLEMENTS = `
+
+
+
+ aps-environment
+ development
+
+`
+
+// Real-shaped AppIcon Contents.json (single universal 1024 marketing icon).
+const REAL_SHAPED_APPICON_CONTENTS = JSON.stringify({
+ images: [{ idiom: 'universal', size: '1024x1024', filename: 'AppIcon-512@2x.png', platform: 'ios' }],
+ info: { author: 'xcode', version: 1 },
+}, null, 2)
+const plist = (body: string) => `
+
+${body}`
+
+describe('ios-plist-read: plistString', () => {
+ it('reads a top-level string value', () => {
+ expect(plistString(plist('CFBundleNameMyApp'), 'CFBundleName')).toBe('MyApp')
+ })
+ it('returns null when the key is absent', () => {
+ expect(plistString(plist('Otherx'), 'CFBundleName')).toBeNull()
+ })
+ it('reads an unresolved $() build-variable reference verbatim', () => {
+ expect(plistString(plist('CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)'), 'CFBundleIdentifier'))
+ .toBe('$(PRODUCT_BUNDLE_IDENTIFIER)')
+ })
+ it('escapes regex metacharacters in the key (never throws)', () => {
+ expect(plistString(plist('a.b*cv'), 'a.b*c')).toBe('v')
+ })
+ it('returns null on empty/garbage input', () => {
+ expect(plistString('', 'CFBundleName')).toBeNull()
+ expect(plistString('<<<', 'CFBundleName')).toBeNull()
+ })
+})
+
+describe('ios-plist-read: plistBool', () => {
+ it('reads ', () => {
+ expect(plistBool(plist('LSRequiresIPhoneOS'), 'LSRequiresIPhoneOS')).toBe(true)
+ })
+ it('reads ', () => {
+ expect(plistBool(plist('UIRequiresFullScreen'), 'UIRequiresFullScreen')).toBe(false)
+ })
+ it('returns null when the key is absent (distinct from false)', () => {
+ expect(plistBool(plist('Other'), 'UIRequiresFullScreen')).toBeNull()
+ })
+ it('returns null when the key maps to a non-bool value', () => {
+ expect(plistBool(plist('CFBundleNamex'), 'CFBundleName')).toBeNull()
+ })
+})
+
+describe('ios-plist-read: plistHasKey', () => {
+ it('detects presence', () => {
+ expect(plistHasKey(plist('CFBundleVersion$(X)'), 'CFBundleVersion')).toBe(true)
+ })
+ it('returns false when absent', () => {
+ expect(plistHasKey(plist('CFBundleVersion1'), 'CFBundleShortVersionString')).toBe(false)
+ })
+})
+
+describe('ios-plist-read: plistArrayStrings', () => {
+ it('collects the children of an array key', () => {
+ const body = `UISupportedInterfaceOrientations
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ `
+ expect(plistArrayStrings(plist(body), 'UISupportedInterfaceOrientations')).toEqual([
+ 'UIInterfaceOrientationPortrait',
+ 'UIInterfaceOrientationLandscapeLeft',
+ ])
+ })
+ it('distinguishes the ~ipad suffixed key from the base key', () => {
+ const body = `UISupportedInterfaceOrientationsA
+ UISupportedInterfaceOrientations~ipadBC`
+ expect(plistArrayStrings(plist(body), 'UISupportedInterfaceOrientations~ipad')).toEqual(['B', 'C'])
+ expect(plistArrayStrings(plist(body), 'UISupportedInterfaceOrientations')).toEqual(['A'])
+ })
+ it('stops at the first closing tag (non-greedy)', () => {
+ const body = `AoneBtwo`
+ expect(plistArrayStrings(plist(body), 'A')).toEqual(['one'])
+ })
+ it('returns [] when the key is absent or not an array', () => {
+ expect(plistArrayStrings(plist('Xs'), 'X')).toEqual([])
+ expect(plistArrayStrings('', 'X')).toEqual([])
+ })
+})
+
+describe('ios-plist-read: plistDictBlock', () => {
+ it('returns the inner text of a one-level dict', () => {
+ const body = `NSAppTransportSecurityNSAllowsArbitraryLoads`
+ const inner = plistDictBlock(plist(body), 'NSAppTransportSecurity')
+ expect(inner).toContain('NSAllowsArbitraryLoads')
+ expect(inner).toContain('')
+ })
+ it('returns null when the key is absent', () => {
+ expect(plistDictBlock(plist('Xs'), 'NSAppTransportSecurity')).toBeNull()
+ })
+ it('does not throw on malformed input', () => {
+ expect(plistDictBlock('<<<', 'X')).toBeNull()
+ })
+ it('returns the FULL balanced dict when a nested dict precedes a sibling key', () => {
+ // NSExceptionDomains (a nested dict) appears BEFORE NSAllowsArbitraryLoads.
+ // A lazy first-`` capture would truncate after NSExceptionDomains and
+ // hide NSAllowsArbitraryLoads; the balanced scan must keep the sibling.
+ const body = `NSAppTransportSecurity`
+ + `NSExceptionDomainsexample.comNSIncludesSubdomains`
+ + `NSAllowsArbitraryLoads`
+ + ``
+ const inner = plistDictBlock(plist(body), 'NSAppTransportSecurity')
+ expect(inner).not.toBeNull()
+ expect(inner).toContain('NSExceptionDomains')
+ expect(inner).toContain('NSAllowsArbitraryLoads')
+ // The matching close wins, so the trailing sibling bool is inside the block.
+ expect(plistBool(inner!, 'NSAllowsArbitraryLoads')).toBe(true)
+ })
+ it('returns null on an unbalanced (never-closed) dict', () => {
+ expect(plistDictBlock(plist('NSAppTransportSecurityA'), 'NSAppTransportSecurity')).toBeNull()
+ })
+})
+
+describe('ios-plist-read: grounding against the real-shaped Info.plist', () => {
+ const raw = REAL_SHAPED_INFO_PLIST
+ it('reads the literal CFBundleDisplayName', () => {
+ expect(plistString(raw, 'CFBundleDisplayName')).toBe('Tutorial Build example app')
+ })
+ it('reads the $() build-var refs verbatim', () => {
+ expect(plistString(raw, 'CFBundleIdentifier')).toBe('$(PRODUCT_BUNDLE_IDENTIFIER)')
+ expect(plistString(raw, 'CFBundleShortVersionString')).toBe('$(MARKETING_VERSION)')
+ expect(plistString(raw, 'CFBundleVersion')).toBe('$(CURRENT_PROJECT_VERSION)')
+ expect(plistString(raw, 'CFBundleName')).toBe('$(PRODUCT_NAME)')
+ })
+ it('reads LSRequiresIPhoneOS as true', () => {
+ expect(plistBool(raw, 'LSRequiresIPhoneOS')).toBe(true)
+ })
+ it('reads UIRequiresFullScreen as null (key absent)', () => {
+ expect(plistBool(raw, 'UIRequiresFullScreen')).toBeNull()
+ })
+ it('reads the ~ipad orientations (all four) separately from iPhone (three)', () => {
+ expect(plistArrayStrings(raw, 'UISupportedInterfaceOrientations~ipad')).toEqual([
+ 'UIInterfaceOrientationPortrait',
+ 'UIInterfaceOrientationPortraitUpsideDown',
+ 'UIInterfaceOrientationLandscapeLeft',
+ 'UIInterfaceOrientationLandscapeRight',
+ ])
+ expect(plistArrayStrings(raw, 'UISupportedInterfaceOrientations')).toEqual([
+ 'UIInterfaceOrientationPortrait',
+ 'UIInterfaceOrientationLandscapeLeft',
+ 'UIInterfaceOrientationLandscapeRight',
+ ])
+ })
+})
+
+// Minimal pbxproj fixture: one project-level config pair + one app-target
+// config pair, modelled on the Capacitor SPM layout. Release and Debug differ
+// in PRODUCT_BUNDLE_IDENTIFIER so the Release-preferred lookup is observable.
+const FIXTURE_PBX = `// !$*UTF8*$!
+{
+ objects = {
+/* Begin PBXNativeTarget section */
+ AAA1 /* App */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = LIST_TARGET /* Build configuration list for PBXNativeTarget "App" */;
+ name = App;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+/* Begin PBXProject section */
+ PROJ /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ TargetAttributes = {
+ AAA1 = { ProvisioningStyle = Automatic; };
+ };
+ };
+ buildConfigurationList = LIST_PROJECT /* Build configuration list for PBXProject "App" */;
+ targets = (
+ AAA1 /* App */,
+ );
+ };
+/* End PBXProject section */
+/* Begin XCBuildConfiguration section */
+ CFG_PROJ_DEBUG /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ SDKROOT = iphoneos;
+ };
+ name = Debug;
+ };
+ CFG_PROJ_RELEASE /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ SDKROOT = iphoneos;
+ };
+ name = Release;
+ };
+ CFG_TGT_DEBUG /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.demo.app.debug;
+ PRODUCT_NAME = "Tutorial Build example app";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ CFG_TGT_RELEASE /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.demo.app;
+ PRODUCT_NAME = "Tutorial Build example app";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+/* Begin XCConfigurationList section */
+ LIST_PROJECT /* Build configuration list for PBXProject "App" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ CFG_PROJ_DEBUG /* Debug */,
+ CFG_PROJ_RELEASE /* Release */,
+ );
+ defaultConfigurationName = Release;
+ };
+ LIST_TARGET /* Build configuration list for PBXNativeTarget "App" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ CFG_TGT_DEBUG /* Debug */,
+ CFG_TGT_RELEASE /* Release */,
+ );
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+}
+`
+
+describe('ios-pbxsettings: readBuildSetting (Release-preferred scalar)', () => {
+ it('prefers the Release value when configs disagree', () => {
+ expect(readBuildSetting(FIXTURE_PBX, 'PRODUCT_BUNDLE_IDENTIFIER')).toBe('com.demo.app')
+ })
+ it('strips surrounding quotes from a quoted value', () => {
+ expect(readBuildSetting(FIXTURE_PBX, 'PRODUCT_NAME')).toBe('Tutorial Build example app')
+ expect(readBuildSetting(FIXTURE_PBX, 'TARGETED_DEVICE_FAMILY')).toBe('1,2')
+ })
+ it('reads an unquoted scalar present in every config', () => {
+ expect(readBuildSetting(FIXTURE_PBX, 'IPHONEOS_DEPLOYMENT_TARGET')).toBe('15.0')
+ })
+ it('returns null for an absent / inherited key', () => {
+ expect(readBuildSetting(FIXTURE_PBX, 'ENABLE_BITCODE')).toBeNull()
+ })
+ it('does not throw on empty input', () => {
+ expect(readBuildSetting('', 'IPHONEOS_DEPLOYMENT_TARGET')).toBeNull()
+ })
+})
+
+describe('ios-pbxsettings: resolvePlistValue ($()->pbxproj substitution)', () => {
+ it('substitutes a $(VAR) reference from the pbxproj', () => {
+ expect(resolvePlistValue('$(PRODUCT_BUNDLE_IDENTIFIER)', FIXTURE_PBX)).toBe('com.demo.app')
+ })
+ it('substitutes the ${VAR} brace form too', () => {
+ expect(resolvePlistValue('${MARKETING_VERSION}', FIXTURE_PBX)).toBe('1.0')
+ })
+ it('returns a literal value unchanged', () => {
+ expect(resolvePlistValue('1.4.2', FIXTURE_PBX)).toBe('1.4.2')
+ expect(resolvePlistValue('Tutorial Build example app', FIXTURE_PBX)).toBe('Tutorial Build example app')
+ })
+ it('returns the raw $() string when the var has no pbxproj match (caller treats as skip)', () => {
+ expect(resolvePlistValue('$(CAPACITOR_DEBUG)', FIXTURE_PBX)).toBe('$(CAPACITOR_DEBUG)')
+ })
+ it('does not substitute a value that merely contains a $() among other text', () => {
+ expect(resolvePlistValue('prefix-$(MARKETING_VERSION)', FIXTURE_PBX)).toBe('prefix-$(MARKETING_VERSION)')
+ })
+})
+
+describe('ios-pbxsettings: readBuildConfigs', () => {
+ it('returns all four configs with project-level flagged', () => {
+ const configs = readBuildConfigs(FIXTURE_PBX)
+ expect(configs.length).toBe(4)
+ const projectLevel = configs.filter(c => c.isProjectLevel)
+ expect(projectLevel.length).toBe(2)
+ expect(projectLevel.every(c => c.settings.IPHONEOS_DEPLOYMENT_TARGET === '15.0')).toBe(true)
+ const targetLevel = configs.filter(c => !c.isProjectLevel)
+ expect(targetLevel.some(c => c.name === 'Release' && c.settings.PRODUCT_BUNDLE_IDENTIFIER === 'com.demo.app')).toBe(true)
+ })
+ it('captures scalar settings only and strips quotes', () => {
+ const release = readBuildConfigs(FIXTURE_PBX).find(c => !c.isProjectLevel && c.name === 'Release')!
+ expect(release.settings.PRODUCT_NAME).toBe('Tutorial Build example app')
+ expect(release.settings.CODE_SIGN_STYLE).toBe('Automatic')
+ })
+ it('returns [] on empty input', () => {
+ expect(readBuildConfigs('')).toEqual([])
+ })
+})
+
+describe('ios-pbxsettings: readTargetConfigs', () => {
+ it('returns per-target per-config settings for the signable App target', () => {
+ const targets = readTargetConfigs(FIXTURE_PBX)
+ expect(targets.length).toBe(1)
+ const app = targets[0]
+ expect(app.target.name).toBe('App')
+ const names = app.configs.map(c => c.name).sort()
+ expect(names).toEqual(['Debug', 'Release'])
+ const rel = app.configs.find(c => c.name === 'Release')!
+ expect(rel.settings.PRODUCT_BUNDLE_IDENTIFIER).toBe('com.demo.app')
+ const dbg = app.configs.find(c => c.name === 'Debug')!
+ expect(dbg.settings.PRODUCT_BUNDLE_IDENTIFIER).toBe('com.demo.app.debug')
+ })
+ it('returns [] on empty input', () => {
+ expect(readTargetConfigs('')).toEqual([])
+ })
+})
+
+// Real-shaped pbxproj mirroring the tutorial-app's distinctive build settings
+// (real bundle id, AppIcon name, version pair, Cap8 deployment target) so the
+// grounding assertions below are self-contained and REAL on CI.
+const REAL_SHAPED_PBX = `// !$*UTF8*$!
+{
+\tobjects = {
+/* Begin PBXNativeTarget section */
+\t\tAAA1 /* App */ = {
+\t\t\tisa = PBXNativeTarget;
+\t\t\tbuildConfigurationList = LIST_TARGET /* Build configuration list for PBXNativeTarget "App" */;
+\t\t\tname = App;
+\t\t\tproductType = "com.apple.product-type.application";
+\t\t};
+/* End PBXNativeTarget section */
+/* Begin PBXProject section */
+\t\tPROJ /* Project object */ = {
+\t\t\tisa = PBXProject;
+\t\t\tattributes = {
+\t\t\t\tTargetAttributes = {
+\t\t\t\t\tAAA1 = { ProvisioningStyle = Automatic; };
+\t\t\t\t};
+\t\t\t};
+\t\t\tbuildConfigurationList = LIST_PROJECT /* Build configuration list for PBXProject "App" */;
+\t\t\ttargets = (
+\t\t\t\tAAA1 /* App */,
+\t\t\t);
+\t\t};
+/* End PBXProject section */
+/* Begin XCBuildConfiguration section */
+\t\tCFG_PROJ_DEBUG /* Debug */ = {
+\t\t\tisa = XCBuildConfiguration;
+\t\t\tbuildSettings = {
+\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.0;
+\t\t\t\tSDKROOT = iphoneos;
+\t\t\t};
+\t\t\tname = Debug;
+\t\t};
+\t\tCFG_PROJ_RELEASE /* Release */ = {
+\t\t\tisa = XCBuildConfiguration;
+\t\t\tbuildSettings = {
+\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.0;
+\t\t\t\tSDKROOT = iphoneos;
+\t\t\t};
+\t\t\tname = Release;
+\t\t};
+\t\tCFG_TGT_DEBUG /* Debug */ = {
+\t\t\tisa = XCBuildConfiguration;
+\t\t\tbuildSettings = {
+\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+\t\t\t\tCODE_SIGN_STYLE = Automatic;
+\t\t\t\tCURRENT_PROJECT_VERSION = 1;
+\t\t\t\tMARKETING_VERSION = 1.0;
+\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = app.capgo.plugin.TutorialBuild;
+\t\t\t\tPRODUCT_NAME = "Tutorial Build example app";
+\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
+\t\t\t};
+\t\t\tname = Debug;
+\t\t};
+\t\tCFG_TGT_RELEASE /* Release */ = {
+\t\t\tisa = XCBuildConfiguration;
+\t\t\tbuildSettings = {
+\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+\t\t\t\tCODE_SIGN_STYLE = Automatic;
+\t\t\t\tCURRENT_PROJECT_VERSION = 1;
+\t\t\t\tMARKETING_VERSION = 1.0;
+\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = app.capgo.plugin.TutorialBuild;
+\t\t\t\tPRODUCT_NAME = "Tutorial Build example app";
+\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
+\t\t\t};
+\t\t\tname = Release;
+\t\t};
+/* End XCBuildConfiguration section */
+/* Begin XCConfigurationList section */
+\t\tLIST_PROJECT /* Build configuration list for PBXProject "App" */ = {
+\t\t\tisa = XCConfigurationList;
+\t\t\tbuildConfigurations = (
+\t\t\t\tCFG_PROJ_DEBUG /* Debug */,
+\t\t\t\tCFG_PROJ_RELEASE /* Release */,
+\t\t\t);
+\t\t\tdefaultConfigurationName = Release;
+\t\t};
+\t\tLIST_TARGET /* Build configuration list for PBXNativeTarget "App" */ = {
+\t\t\tisa = XCConfigurationList;
+\t\t\tbuildConfigurations = (
+\t\t\t\tCFG_TGT_DEBUG /* Debug */,
+\t\t\t\tCFG_TGT_RELEASE /* Release */,
+\t\t\t);
+\t\t\tdefaultConfigurationName = Release;
+\t\t};
+/* End XCConfigurationList section */
+\t};
+}
+`
+
+describe('ios-pbxsettings: grounding against the real-shaped pbxproj', () => {
+ const pbx = REAL_SHAPED_PBX
+ it('reads the grounding scalar build settings (Release-preferred)', () => {
+ expect(readBuildSetting(pbx, 'IPHONEOS_DEPLOYMENT_TARGET')).toBe('15.0')
+ expect(readBuildSetting(pbx, 'PRODUCT_BUNDLE_IDENTIFIER')).toBe('app.capgo.plugin.TutorialBuild')
+ expect(readBuildSetting(pbx, 'ASSETCATALOG_COMPILER_APPICON_NAME')).toBe('AppIcon')
+ expect(readBuildSetting(pbx, 'MARKETING_VERSION')).toBe('1.0')
+ expect(readBuildSetting(pbx, 'CURRENT_PROJECT_VERSION')).toBe('1')
+ expect(readBuildSetting(pbx, 'TARGETED_DEVICE_FAMILY')).toBe('1,2')
+ })
+ it('returns null for ENABLE_BITCODE (absent in the grounding project)', () => {
+ expect(readBuildSetting(pbx, 'ENABLE_BITCODE')).toBeNull()
+ })
+ it('resolves the real Info.plist $() refs against the real pbxproj', () => {
+ const info = REAL_SHAPED_INFO_PLIST
+ expect(resolvePlistValue(plistString(info, 'CFBundleIdentifier')!, pbx)).toBe('app.capgo.plugin.TutorialBuild')
+ expect(resolvePlistValue(plistString(info, 'CFBundleShortVersionString')!, pbx)).toBe('1.0')
+ expect(resolvePlistValue(plistString(info, 'CFBundleVersion')!, pbx)).toBe('1')
+ expect(resolvePlistValue(plistString(info, 'CFBundleName')!, pbx)).toBe('Tutorial Build example app')
+ })
+ it('leaves CAPACITOR_DEBUG unresolved (no pbxproj setting -> skip)', () => {
+ const info = REAL_SHAPED_INFO_PLIST
+ expect(resolvePlistValue(plistString(info, 'CAPACITOR_DEBUG')!, pbx)).toBe('$(CAPACITOR_DEBUG)')
+ })
+ it('finds the single signable App target with Debug+Release configs', () => {
+ const targets = readTargetConfigs(pbx)
+ expect(targets.length).toBe(1)
+ expect(targets[0].target.name).toBe('App')
+ expect(targets[0].configs.map(c => c.name).sort()).toEqual(['Debug', 'Release'])
+ })
+})
+
+// A temp project dir populated with the real-shaped fixtures, standing in for
+// the external tutorial-app checkout so the on-disk grounding tests (entitlements
+// reader, appicon Contents.json) are self-contained and REAL on CI.
+const REAL_PROJECT_DIR = makeProject({
+ 'ios/App/App/App.entitlements': REAL_SHAPED_ENTITLEMENTS,
+ 'ios/App/App/Info.plist': REAL_SHAPED_INFO_PLIST,
+ 'ios/App/App.xcodeproj/project.pbxproj': REAL_SHAPED_PBX,
+ 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': REAL_SHAPED_APPICON_CONTENTS,
+})
+
+const entitlements = (body: string) => `
+
+${body}`
+
+describe('ios-entitlements: readAppEntitlements', () => {
+ it('reads ios/App/App/App.entitlements when present', () => {
+ const dir = makeProject({ 'ios/App/App/App.entitlements': entitlements('aps-environmentdevelopment') })
+ const ent = readAppEntitlements(dir)
+ expect(ent).not.toBeNull()
+ expect(ent!.raw).toContain('aps-environment')
+ })
+ it('returns null when the entitlements file is absent', () => {
+ expect(readAppEntitlements(makeProject({}))).toBeNull()
+ })
+ it('reads the typed accessors off the raw text', () => {
+ const raw = entitlements(`
+ aps-environmentdevelopment
+ com.apple.security.application-groupsgroup.com.demo.app
+ com.apple.developer.healthkit`)
+ expect(entString(raw, 'aps-environment')).toBe('development')
+ expect(entArray(raw, 'com.apple.security.application-groups')).toEqual(['group.com.demo.app'])
+ expect(entBool(raw, 'com.apple.developer.healthkit')).toBe(true)
+ expect(entBool(raw, 'get-task-allow')).toBeNull()
+ expect(entArray(raw, 'keychain-access-groups')).toEqual([])
+ })
+ it('grounds clean against the real App.entitlements (aps-environment=development only)', () => {
+ const ent = readAppEntitlements(REAL_PROJECT_DIR)
+ expect(ent).not.toBeNull()
+ expect(entString(ent!.raw, 'aps-environment')).toBe('development')
+ expect(entArray(ent!.raw, 'com.apple.security.application-groups')).toEqual([])
+ })
+})
+
+describe('mobileprovision-parser: profileEntitlements', () => {
+ function detailFromXml(xml: string) {
+ const buf = Buffer.concat([Buffer.from([0x30, 0x82, 0x00, 0x00]), Buffer.from(xml, 'utf8')])
+ return parseMobileprovisionDetailedFromBase64(buf.toString('base64'))
+ }
+
+ it('parses string, array and bool capability keys (not application-identifier)', () => {
+ const xml = `
+
+NameTest
+UUIDu
+TeamIdentifierTEAM123456
+Entitlements
+ application-identifierTEAM123456.com.demo.app
+ aps-environmentproduction
+ com.apple.security.application-groupsgroup.com.demo.appgroup.com.demo.shared
+ com.apple.developer.associated-domainsapplinks:demo.com
+ com.apple.developer.healthkit
+ get-task-allow
+
+`
+ const ent = detailFromXml(xml).profileEntitlements
+ expect(ent['aps-environment']).toBe('production')
+ expect(ent['com.apple.security.application-groups']).toEqual(['group.com.demo.app', 'group.com.demo.shared'])
+ expect(ent['com.apple.developer.associated-domains']).toEqual(['applinks:demo.com'])
+ expect(ent['com.apple.developer.healthkit']).toBe(true)
+ expect(ent['get-task-allow']).toBe(false)
+ // application-identifier is auto-managed and intentionally NOT surfaced.
+ expect('application-identifier' in ent).toBe(false)
+ })
+
+ it('surfaces capability keys outside the legacy allowlist (App Attest, Sign in with Apple, Siri)', () => {
+ // Regression: the profile side must scan ALL entitlement keys generically, the
+ // same way the app side does. Previously only a fixed ~10-key allowlist was read,
+ // so any granted-but-non-allowlisted capability looked "missing" and produced a
+ // false-positive blocking error in entitlements-vs-profile-capability.
+ const xml = `
+
+NameTest
+UUIDu
+TeamIdentifierTEAM123456
+Entitlements
+ application-identifierTEAM123456.com.demo.app
+ com.apple.developer.devicecheck.appattest-environmentproduction
+ com.apple.developer.siri
+ com.apple.developer.applesigninDefault
+
+`
+ const ent = detailFromXml(xml).profileEntitlements
+ expect(ent['com.apple.developer.devicecheck.appattest-environment']).toBe('production')
+ expect(ent['com.apple.developer.siri']).toBe(true)
+ expect(ent['com.apple.developer.applesignin']).toEqual(['Default'])
+ // auto-managed keys stay hidden even with generic scanning.
+ expect('application-identifier' in ent).toBe(false)
+ })
+
+ it('omits capability keys that are absent (missing != false)', () => {
+ const ent = detailFromXml(makeProfileXml({ type: 'app_store' })).profileEntitlements
+ expect('com.apple.security.application-groups' in ent).toBe(false)
+ expect('aps-environment' in ent).toBe(false)
+ expect('get-task-allow' in ent).toBe(false)
+ })
+
+ it('returns {} when there is no Entitlements dict (never throws)', () => {
+ const xml = `
+
+NameTest
+UUIDu
+`
+ expect(detailFromXml(xml).profileEntitlements).toEqual({})
+ })
+
+ it('keeps the existing detail fields intact (no regression)', () => {
+ const detail = detailFromXml(makeProfileXml({ type: 'app_store', teamId: 'TEAM999999', bundleId: 'com.demo.app' }))
+ expect(detail.teamId).toBe('TEAM999999')
+ expect(detail.bundleId).toBe('com.demo.app')
+ expect(detail.profileType).toBe('app_store')
+ expect(detail.profileEntitlements).toEqual({})
+ })
+})
+
+const APPICON_REL = 'ios/App/App/Assets.xcassets/AppIcon.appiconset'
+const CONTENTS_REL = `${APPICON_REL}/Contents.json`
+
+describe('ios-appicon: readContentsJson', () => {
+ it('parses a valid Contents.json', () => {
+ const dir = makeProject({ [CONTENTS_REL]: JSON.stringify({ images: [{ size: '1024x1024', filename: 'icon.png' }] }) })
+ const c = readContentsJson(join(dir, CONTENTS_REL))
+ expect(c?.images?.length).toBe(1)
+ expect(c?.images?.[0].filename).toBe('icon.png')
+ })
+ it('returns null on a missing file (never throws)', () => {
+ expect(readContentsJson('/no/such/Contents.json')).toBeNull()
+ })
+ it('returns null on malformed JSON (never throws)', () => {
+ const dir = makeProject({ [CONTENTS_REL]: '{ not: json,,, ' })
+ expect(readContentsJson(join(dir, CONTENTS_REL))).toBeNull()
+ })
+})
+
+describe('ios-appicon: appIconSetDir', () => {
+ it('defaults to AppIcon when no pbxproj icon name is given', () => {
+ const dir = makeProject({})
+ expect(appIconSetDir(dir)).toBe(join(dir, 'ios', 'App', 'App', 'Assets.xcassets', 'AppIcon.appiconset'))
+ })
+ it('honours ASSETCATALOG_COMPILER_APPICON_NAME from the pbxproj', () => {
+ const dir = makeProject({})
+ const pbx = [
+ 'CFG /* Release */ = {',
+ '\tisa = XCBuildConfiguration;',
+ '\tbuildSettings = {',
+ '\t\tASSETCATALOG_COMPILER_APPICON_NAME = BrandIcon;',
+ '\t};',
+ '\tname = Release;',
+ '};',
+ ].join('\n')
+ expect(appIconSetDir(dir, pbx)).toBe(join(dir, 'ios', 'App', 'App', 'Assets.xcassets', 'BrandIcon.appiconset'))
+ })
+})
+
+describe('ios-appicon: hasMarketingIcon', () => {
+ it('true when a 1024x1024 image is present', () => {
+ expect(hasMarketingIcon({ images: [{ size: '1024x1024', filename: 'icon.png' }] })).toBe(true)
+ })
+ it('trims whitespace around the size before comparing', () => {
+ expect(hasMarketingIcon({ images: [{ size: ' 1024x1024 ', filename: 'icon.png' }] })).toBe(true)
+ })
+ it('true when an image has role marketing', () => {
+ expect(hasMarketingIcon({ images: [{ role: 'marketing', filename: 'icon.png' }] })).toBe(true)
+ })
+ it('false when only smaller icons are present', () => {
+ expect(hasMarketingIcon({ images: [{ size: '60x60', scale: '2x', filename: 'i.png' }] })).toBe(false)
+ })
+ it('false on null/empty contents (never throws)', () => {
+ expect(hasMarketingIcon(null)).toBe(false)
+ expect(hasMarketingIcon({})).toBe(false)
+ expect(hasMarketingIcon({ images: [] })).toBe(false)
+ })
+})
+
+describe('ios-appicon: grounding against the real AppIcon.appiconset', () => {
+ const realContents = join(REAL_PROJECT_DIR, CONTENTS_REL)
+ it('parses the real single-universal-1024 Contents.json', () => {
+ const c = readContentsJson(realContents)
+ expect(c?.images?.length).toBe(1)
+ expect(c?.images?.[0].size).toBe('1024x1024')
+ expect(c?.images?.[0].filename).toBe('AppIcon-512@2x.png')
+ })
+ it('has the marketing (1024) icon', () => {
+ expect(hasMarketingIcon(readContentsJson(realContents))).toBe(true)
+ })
+ it('resolves the real appicon dir from the pbxproj AppIcon name', () => {
+ const pbx = REAL_SHAPED_PBX
+ expect(appIconSetDir(REAL_PROJECT_DIR, pbx)).toBe(join(REAL_PROJECT_DIR, 'ios', 'App', 'App', 'Assets.xcassets', 'AppIcon.appiconset'))
+ })
+})
diff --git a/docs/superpowers/specs/2026-06-22-prescan-ios-expansion-design.md b/docs/superpowers/specs/2026-06-22-prescan-ios-expansion-design.md
new file mode 100644
index 0000000000..8fa0ae44c0
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-22-prescan-ios-expansion-design.md
@@ -0,0 +1,414 @@
+# Prescan iOS Expansion — Implementation-Ready Design
+
+Status: design (no code in this phase)
+Branch: `wolny/prescan-ios-expansion`
+Date: 2026-06-22
+Author: lead designer (merge of 4 research outputs)
+
+## 0. Scope & ground truth
+
+This spec merges four iOS prescan research outputs (Info.plist/App Store, Xcode
+project/build settings, Entitlements/Capacitor config, Pods/SPM/App icons) into
+ONE deduplicated, TDD-ready check pack. It is grounded against a real Capacitor 8
+SPM project at
+`/Users/michaltremblay/Developer/capgo-saas/capgo_builder/tutorial-app/ios`.
+
+### Verified ground-truth facts (drive the FP guards)
+
+- Info.plist stores **build-variable references**, not literals:
+ `CFBundleIdentifier=$(PRODUCT_BUNDLE_IDENTIFIER)`, `CFBundleShortVersionString=$(MARKETING_VERSION)`,
+ `CFBundleVersion=$(CURRENT_PROJECT_VERSION)`, `CFBundleName=$(PRODUCT_NAME)`.
+ CFBundleDisplayName is a **literal** (`Tutorial Build example app`).
+ → `resolvePlistValue()` substitution is the single biggest false-positive guard.
+- `LSRequiresIPhoneOS=`, `UILaunchStoryboardName=LaunchScreen` present.
+- `UISupportedInterfaceOrientations` (iPhone) is MISSING `PortraitUpsideDown`;
+ `UISupportedInterfaceOrientations~ipad` has all four. → multitasking check MUST
+ read the `~ipad` array only.
+- `App.entitlements` = `{ aps-environment: development }` only. → the universal
+ Capacitor default leftover; on this push-free app the aps-vs-mode check surfaces it
+ as a **warning** (not a build-blocking error) under app_store, so the grounding
+ project still scans clean of errors; no app-groups/iCloud/associated-domains.
+- pbxproj target (Debug+Release identical): `CODE_SIGN_STYLE=Automatic`,
+ `DEVELOPMENT_TEAM=UVTJ336J2D`, `IPHONEOS_DEPLOYMENT_TARGET=15.0`,
+ `SWIFT_VERSION=5.0`, `TARGETED_DEVICE_FAMILY="1,2"` (iPad-capable),
+ `PRODUCT_BUNDLE_IDENTIFIER=app.capgo.plugin.TutorialBuild` (same in both configs),
+ `ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon`, `MARKETING_VERSION=1.0`,
+ `CURRENT_PROJECT_VERSION=1`. `ENABLE_BITCODE` ABSENT. Project-level config blocks
+ only carry `IPHONEOS_DEPLOYMENT_TARGET`.
+- SPM-only layout: NO `Podfile`, NO `Pods/`, NO `App.xcworkspace`.
+ `Package.swift` = `platforms: [.iOS(.v15)]`, depends on `capacitor-swift-pm` exact `8.3.1`,
+ `.product(name: "Capacitor", ...)` + `.product(name: "Cordova", ...)`.
+ `Package.resolved` at `ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`.
+- `AppIcon.appiconset/Contents.json`: single universal `1024x1024` entry,
+ `filename=AppIcon-512@2x.png`, file present.
+
+**Every check below produces NO finding against this healthy project** (the
+acceptance baseline for TDD: the grounding project must scan clean).
+
+### Engine facts that constrain check authoring
+
+- `Finding.id` is the check id; existing checks reuse the same id across all
+ findings they emit (mirror this — do NOT invent per-finding ids).
+- Each `run()` is crash- and timeout-isolated (10s) and degrades to an `info`
+ notice; `appliesTo` is crash-isolated too. So a `run()` that throws is safe,
+ but checks should still early-return `[]` on missing files for clean output.
+- `Finding.detail/title/fix` are printed and serialized to `--json` (CI logs):
+ NEVER embed credential material. All checks here read only project files, so
+ this is naturally satisfied (the one credential-adjacent input is the
+ provisioning map, and we only surface entitlement KEY names + bundle ids).
+- `ctx.config` is a Capacitor config with `.passthrough()` — `ctx.config?.server?.url`
+ / `.cleartext` / `.allowNavigation` are reachable with safe optional access; no
+ parser needed.
+- `willUploadToAppStore(ctx)` already exists (upload-intent.ts): iOS + mode
+ `app_store` (default when undefined) + full ASC triplet
+ (`APPLE_KEY_ID`+`APPLE_ISSUER_ID`+`APPLE_KEY_CONTENT`).
+- `parseProvisioningMap(ctx)` + `hasMap` live in ios-profiles.ts; `bundleMatches()`
+ (wildcard-aware) is local to that file — extract/share for entitlement subset checks.
+
+---
+
+## 1. Parsing infrastructure (new files + reuse)
+
+NO new npm deps. NO fast-xml-parser (deliberately removed). Targeted regex for
+plist/pbxproj/Podfile/Package.swift (XML/pbx text), `JSON.parse` for Contents.json
+and package.json (already JSON). All readers use `existsSync`/`readFileSync`
+(node:fs) + `join` (node:path) and return null/[]/false on absence — never throw.
+
+### 1.1 `cli/src/build/prescan/checks/ios-plist-read.ts` (NEW — shared plist value reader)
+
+Promotes the private `plistStringValue` from ios-plist.ts and adds the value-typed
+readers the new checks need. Keep `SCHEME_RE` where it is (ios-plist.ts) or re-export.
+
+```ts
+plistString(raw, key): string | null // top-level KV (= existing plistStringValue)
+plistBool(raw, key): boolean | null // K\s*<(true|false)/> ; null when key absent
+plistHasKey(raw, key): boolean // raw.includes(`${key}`) (presence only)
+plistArrayStrings(raw, key): string[] // K\s*... -> its children
+plistDictBlock(raw, key): string | null // inner text of K\s*..., ONE level non-greedy
+```
+
+- `plistArrayStrings` generalizes the existing CFBundleURLSchemes block+children
+ pattern (ios-plist.ts L55-58). Needed for orientations + UIBackgroundModes.
+- `plistDictBlock` matches `extractNestedPlistValue`'s dict-capture regex
+ (mobileprovision-parser.ts L198). ONE-level nesting only — document the limit
+ like `resolveBundleId`. Needed to scope NSAppTransportSecurity reads.
+- Regex notes: build all `key` regexes with the existing `escapeRegex` pattern;
+ arrays/dicts use `[\s\S]*?` (non-greedy) so the first closing tag wins.
+
+### 1.2 `cli/src/build/prescan/ios-buildsettings.ts` (NEW — pbxproj scalar + plist-var resolver)
+
+Two cross-file resolvers. Implemented standalone here (rather than mutating
+pbxproj-parser) to keep the existing signing pipeline untouched; both reuse
+`readPbxproj` from pbxproj-parser.ts.
+
+```ts
+readBuildSetting(pbxContent, name): string | null
+// Release-preferred scalar lookup, mirroring resolveBundleId's Release-vs-fallback walk.
+// Generalize pbxproj-parser L82-103: per build-config block, match `NAME = "?VALUE"?;`
+// (strip quotes). Return the Release value if any config is named Release; else the
+// first non-null value found (fallback). SCALAR keys only — never array-valued settings.
+
+resolvePlistValue(rawValue, pbxContent): string
+// If rawValue is exactly one $(VAR) or ${VAR} reference, substitute via readBuildSetting(VAR).
+// Else return rawValue unchanged. EVERY value-format check pipes the plist value through
+// this before validating. If the var has no pbxproj match, return the raw $() string
+// (callers treat a still-unresolved $() as "skip / cannot judge").
+```
+
+### 1.3 `cli/src/build/prescan/ios-pbxsettings.ts` helper OR extend ios-buildsettings (NEW — per-config + per-target)
+
+For signing / per-config / swift checks we need ALL configs, not just Release-preferred:
+
+```ts
+interface BuildConfig { name: string; settings: Record; isProjectLevel: boolean }
+readBuildConfigs(pbxContent): BuildConfig[]
+// Generalize the buildConfig block walk (pbxproj-parser L82-96): for every
+// XCBuildConfiguration block capture name (L95 regex) + all `KEY = "?VALUE"?;` scalar pairs.
+// isProjectLevel = referenced by the PBXProject's buildConfigurationList (find the
+// `isa = PBXProject` block, read its buildConfigurationList id, mark those configs).
+
+interface TargetConfigs { target: PbxTarget; configs: { name: string; settings: Record }[] }
+readTargetConfigs(pbxContent): TargetConfigs[]
+// For each signable target (reuse findSignableTargets + its configList walk), capture
+// the FULL settings dict per config (Debug/Release) in the same pass resolveBundleId uses.
+```
+
+CRITICAL parser caveat (drives "skip absent key" rule everywhere): the one-level
+nested-brace cap means **array-valued settings** (`LD_RUNPATH_SEARCH_PATHS = ( ... )`,
+`GCC_PREPROCESSOR_DEFINITIONS`) may be captured raw/truncated. Checks MUST only rely
+on **scalar** keys (`IPHONEOS_DEPLOYMENT_TARGET`, `CODE_SIGN_STYLE`, `DEVELOPMENT_TEAM`,
+`SWIFT_VERSION`, `ENABLE_BITCODE`, `PRODUCT_BUNDLE_IDENTIFIER`, `TARGETED_DEVICE_FAMILY`,
+`ASSETCATALOG_COMPILER_APPICON_NAME`, `MARKETING_VERSION`, `CURRENT_PROJECT_VERSION`,
+`PRODUCT_NAME`). Build-setting inheritance/xcconfig is NOT resolved: an ABSENT scalar
+key == "unknown/inherited" → SKIP (no finding). Only flag a PRESENT bad value.
+
+### 1.4 `cli/src/build/prescan/ios-entitlements.ts` (NEW — app + profile entitlements)
+
+```ts
+// App's own entitlements file. Capacitor convention: ios/App/App/App.entitlements
+// (future enhancement: resolve via pbxproj CODE_SIGN_ENTITLEMENTS; default path is reliable).
+readAppEntitlements(projectDir): { raw: string } | null // null when file absent
+entString(raw, key): string | null // reuse plistString
+entArray(raw, key): string[] // reuse plistArrayStrings
+entBool(raw, key): boolean | null // reuse plistBool
+```
+
+Profile entitlements: extend `MobileprovisionDetail` (mobileprovision-parser.ts) with a
+`profileEntitlements` accessor. Reuse the existing `extractNestedPlistValue` dict-capture
+for the `Entitlements` block, plus an array-aware sibling (the
+DeveloperCertificates array regex L167 is the template) to read these keys from inside
+that dict: `aps-environment` (string), `com.apple.security.application-groups` (array),
+`com.apple.developer.associated-domains` (array), `com.apple.developer.icloud-container-identifiers`
+(array), `com.apple.developer.icloud-services` (array), `com.apple.developer.ubiquity-kvstore-identifier`
+(string), `com.apple.developer.healthkit` (bool), `keychain-access-groups` (array),
+`com.apple.developer.in-app-payments` (array), `com.apple.developer.networking.*` (array/bool),
+`get-task-allow` (bool). ~30-50 lines, no new deps. Expose as a structured getter so
+checks read `profileEntitlements[key]` rather than re-parsing.
+
+### 1.5 `cli/src/build/prescan/ios-appicon.ts` (NEW — AppIcon Contents.json reader)
+
+```ts
+interface AppIconImage { idiom?: string; size?: string; scale?: string; filename?: string; platform?: string; role?: string }
+interface AssetContents { images?: AppIconImage[]; info?: { version?: number; author?: string } }
+readContentsJson(path): AssetContents | null // existsSync + readFileSync + JSON.parse in try/catch; null on missing/parse error (NEVER throws)
+appIconSetDir(projectDir, pbxContent?): string // join(projectDir,'ios','App','App','Assets.xcassets', `${iconName}.appiconset`)
+ // iconName = readBuildSetting(pbx,'ASSETCATALOG_COMPILER_APPICON_NAME') ?? 'AppIcon'
+hasMarketingIcon(c): boolean // images.some(i => normalizeSize(i.size)==='1024x1024' || i.role==='marketing'); normalizeSize trims whitespace
+```
+
+### 1.6 `cli/src/build/prescan/capacitor-version.ts` (NEW — extract shared `capacitorMajor`)
+
+Move the private `capacitorMajor(projectDir)` out of android-project.ts (L539-556) into
+this shared module so the iOS deployment-target check reuses the exact
+`@capacitor/core` ?? `@capacitor/ios` major detection. (Android currently reads
+`@capacitor/core` ?? `@capacitor/android`; generalize to check core, ios, android.)
+Re-import it back into android-project.ts to avoid duplication.
+
+### 1.7 Reuse (no change)
+
+`readTextIfExists` (gradle.ts L5) for Podfile/Podfile.lock/Package.swift/Package.resolved/
+package.json text. `existsSync`/`readdirSync` for Pods/ & workspace presence.
+`findSignableTargets`/`readPbxproj` (pbxproj-parser.ts). `capacitorPluginDeps` heuristic
+(shared.ts L8-21) for optional plugin-pod cross-check. `parseProvisioningMap`/`hasMap`/
+`bundleMatches` (ios-profiles.ts — export `bundleMatches`).
+
+---
+
+## 2. Final NEW iOS checks
+
+Local = reads only project files. None of the new checks are `remote`. `appliesTo`
+predicates are exact. Severity given as `error`/`warning`; "error-on-upload" means
+`willUploadToAppStore(ctx) ? error : warning`.
+
+### 2.A Info.plist / App Store (file: `checks/ios-plist-store.ts`)
+
+| id | sev | local | appliesTo (exact) | detection (algorithm/regex) | fix |
+|----|-----|-------|-------------------|------------------------------|-----|
+| `ios/plist-bundle-id-format` | error | local | iOS; Info.plist exists | `v=plistString(raw,'CFBundleIdentifier')`. null→error "missing". `r=resolvePlistValue(v,pbx)`. If `r` still starts `'$('`→skip. Validate `r` against `/^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9-]+)+$/` (reverse-DNS ≥2 segments, no space/_/`*`). Fail→error naming `r`. | Set valid reverse-DNS PRODUCT_BUNDLE_IDENTIFIER (no spaces/underscores/wildcards). |
+| `ios/plist-version-short-format` | error | local | iOS; Info.plist exists | `v=resolvePlistValue(plistString(raw,'CFBundleShortVersionString'),pbx)`. null→(SKIP — presence owned by infoplist-sanity, see §5). `'$('`→skip. Else validate `/^\d+(\.\d+){0,2}$/`. Fail→error with value (ITMS-90060). | Set MARKETING_VERSION to ≤3 dot-separated integers (e.g. 1.4.2). |
+| `ios/plist-version-build-format` | error | local | iOS; Info.plist exists | `v=resolvePlistValue(plistString(raw,'CFBundleVersion'),pbx)`. null→SKIP. `'$('`→skip. Else `/^\d+(\.\d+){0,2}$/`. Fail→error. Do NOT require monotonic/ordering (needs ASC history). | Set CURRENT_PROJECT_VERSION numeric, ≤3 integers (e.g. 42 or 1.4.42). |
+| `ios/plist-encryption-compliance` | warning | local | `willUploadToAppStore(ctx)` | `plistHasKey(raw,'ITSAppUsesNonExemptEncryption')===false` → warning. Do NOT assert which value is correct. | Add `ITSAppUsesNonExemptEncryption=` (most Capacitor apps) to stop the per-upload Missing Compliance prompt. |
+| `ios/plist-ats-arbitrary-loads` | warning (→error on upload+dev-config) | local | iOS; Info.plist exists | `d=plistDictBlock(raw,'NSAppTransportSecurity')`; null→[]. If `plistBool(d,'NSAllowsArbitraryLoads')===true`→finding. Escalate to **error** when `willUploadToAppStore(ctx) && (ctx.config?.server?.cleartext===true || ctx.config?.server?.url)`. | Remove NSAllowsArbitraryLoads (or ``); use scoped NSExceptionDomains; remove server.url/cleartext before release. |
+| `ios/plist-launch-storyboard` | error | local | iOS; Info.plist exists | `ok = plistHasKey(raw,'UILaunchStoryboardName') || plistHasKey(raw,'UILaunchScreen')`. !ok→error (ITMS-90475/90096). (Drop the optional storyboard-file-existence sub-check — higher FP, low value.) | Add `UILaunchStoryboardName=LaunchScreen` (Capacitor default) or a UILaunchScreen dict. |
+| `ios/plist-orientations-multitasking` | warning | local | iOS; Info.plist exists; `TARGETED_DEVICE_FAMILY` (readBuildSetting) contains `2`; NOT `plistBool(raw,'UIRequiresFullScreen')===true` | `ipad = plistArrayStrings(raw,'UISupportedInterfaceOrientations~ipad')` (fallback to non-suffixed only if `~ipad` key entirely absent). Missing any of the four `UIInterfaceOrientation{Portrait,PortraitUpsideDown,LandscapeLeft,LandscapeRight}`→warning listing missing. SCOPE STRICTLY to `~ipad` (grounding iPhone array is missing PortraitUpsideDown → would FP). (ITMS-90474). | Add all four to `~ipad`, or add `UIRequiresFullScreen=`. |
+| `ios/plist-orientations-present` | warning | local | iOS; Info.plist exists | `arr=plistArrayStrings(raw,'UISupportedInterfaceOrientations')`. Key absent OR zero entries→warning. Present-but-invalid token (not one of the four constants)→warning naming the bad token. | Declare ≥1 valid UIInterfaceOrientation* value. |
+| `ios/plist-display-name` | warning | local | iOS; Info.plist exists | `disp=resolvePlistValue(plistString(raw,'CFBundleDisplayName'),pbx)`; `name=resolvePlistValue(plistString(raw,'CFBundleName'),pbx)`. Warning only if BOTH null/empty after resolution OR both still `'$('` with no pbx match. Grounding passes (literal CFBundleDisplayName). | Set CFBundleDisplayName or ensure PRODUCT_NAME resolves. |
+| `ios/plist-background-modes-sanity` | warning | local | iOS; Info.plist has `UIBackgroundModes` | `modes=plistArrayStrings(raw,'UIBackgroundModes')`; empty→[]. (a) any token ∉ {audio,location,voip,fetch,remote-notification,processing,bluetooth-central,bluetooth-peripheral,external-accessory,newsstand-content}→warning (invalid mode). (b) `location` present but no `NSLocation*UsageDescription` key in plist→warning (2.5.4). Gate (b) to `willUploadToAppStore(ctx)`. Do NOT hard-error on audio/location presence. | Remove unused background modes; add matching usage strings/capabilities. |
+| `ios/plist-iphoneos-required` | warning | local | iOS; Info.plist exists | `b=plistBool(raw,'LSRequiresIPhoneOS')`. Key absent→warning "missing". `b===false`→warning. Grounding ``→pass. | Add `LSRequiresIPhoneOS=`. |
+
+Notes:
+- `ios/plist-deployment-target` from research #1 is CUT (see §5): no real ASC
+ deployment-target floor exists; the build-breaking floor is Capacitor's and is
+ covered by `ios/xcode-deployment-target-capacitor` in §2.B.
+- `ios/plist-bundle-id-vs-appid` from research #1 is CUT/folded (see §5): duplicates
+ `shared/bundle-id-consistency`.
+- `ios/plist-app-icon` from research #1 is CUT: superseded by the §2.F appicon checks
+ (asset-catalog based, cleaner split).
+
+### 2.B Xcode project / build settings (file: `checks/ios-xcode.ts`)
+
+| id | sev | local | appliesTo (exact) | detection | fix |
+|----|-----|-------|-------------------|-----------|-----|
+| `ios/xcode-deployment-target-capacitor` | error | local | iOS; `capacitorMajor(projectDir)!==null`; `IPHONEOS_DEPLOYMENT_TARGET` PRESENT in app-target or project-level config | floor map `{5:13,6:13,7:14,8:14, default:14}` (extend android-style). Read target value via `readBuildSetting`/`readTargetConfigs` app (application product-type) target Release-pref + project-level. `parseFloat`. Flag PRESENT value `< floor`. Absent key OR null major→[]. Grounding 15.0/Cap8(floor14)→pass. | Raise IPHONEOS_DEPLOYMENT_TARGET to ≥floor (project + app target) and the Podfile platform line if present. |
+| `ios/xcode-signing-team` | error-on-upload | local | iOS; some signable target has `CODE_SIGN_STYLE` PRESENT (Automatic or Manual) with `DEVELOPMENT_TEAM` absent/empty; AND `parseProvisioningMap(ctx).length===0` | **MERGED** Automatic+Manual no-team checks (research recommended). Per signable target Release-pref (fallback Debug): if CODE_SIGN_STYLE present (Automatic or Manual) AND DEVELOPMENT_TEAM absent/empty/`""`→finding. Suppress entirely when a provisioning map IS present (builder injects team+manual signing). Severity `error` when `willUploadToAppStore(ctx)` else `warning`. Grounding DEVELOPMENT_TEAM=UVTJ336J2D→pass. | Set DEVELOPMENT_TEAM in Xcode, or supply signing creds/profiles to the cloud build (map switches to managed-manual). |
+| `ios/xcode-bundle-id-mismatch-across-configs` | warning | local | iOS; ≥1 signable target has `PRODUCT_BUNDLE_IDENTIFIER` PRESENT in ≥2 configs | Per signable target collect PRODUCT_BUNDLE_IDENTIFIER from every config in its configList (`readTargetConfigs`). If ≥2 PRESENT values differ→warning naming target + per-config values. Ignore absent (inherited). Single-config→skip. Grounding Debug==Release→pass. Distinct from shared/bundle-id-consistency (appId vs resolved) and targets-covered (profile coverage). | Align PRODUCT_BUNDLE_IDENTIFIER across Debug/Release (or ignore if Debug suffix is intentional). |
+| `ios/xcode-enable-bitcode-leftover` | warning | local | iOS; `ENABLE_BITCODE` PRESENT with value `YES` anywhere (`readBuildConfigs`) | Read ENABLE_BITCODE from project + target configs. Flag any PRESENT `==YES` (aggregate which configs). Absent (modern default)→[]. Grounding absent→pass. | Set ENABLE_BITCODE=NO or delete it (deprecated since Xcode 14). |
+| `ios/xcode-swift-version-sanity` | warning | local | iOS; signable target has `SWIFT_VERSION` PRESENT | Per signable target parse leading numeric of SWIFT_VERSION. Flag PRESENT and (`<5` OR not a number). Absent→skip (Obj-C-only target). Grounding 5.0→pass. | Set SWIFT_VERSION=5.0 (or intended ≥5). |
+| `ios/xcode-no-app-target` | error | local | iOS; `readPbxproj(projectDir)!==null` | `findSignableTargets`; count `productType==='com.apple.product-type.application'`. If pbxproj non-null but count===0→error. readPbxproj null→skip (project-missing owned elsewhere). Grounding 1 app target→pass. | `npx cap sync ios` or restore the application target. |
+| `ios/xcode-multiple-app-targets` | warning | local | iOS; `findSignableTargets` yields >1 application-product-type target | Filter `productType==='com.apple.product-type.application'`; `length>1`→warning listing names+bundle ids. Extensions don't count. Grounding 1→pass. | Keep a single app target; remove the duplicate or pass the intended scheme. |
+
+Notes:
+- `ios/xcode-automatic-signing-no-team` + `ios/xcode-manual-signing-no-team`
+ (research #2) MERGED into `ios/xcode-signing-team`.
+
+### 2.C Entitlements / capabilities (file: `checks/ios-entitlements-checks.ts`)
+
+| id | sev | local | appliesTo (exact) | detection | fix |
+|----|-----|-------|-------------------|-----------|-----|
+| `ios/entitlements-vs-profile-capability` | error | local | `hasMap(ctx)` AND `readAppEntitlements(projectDir)!==null` | For each mapped profile parse `profileEntitlements` (parsed GENERICALLY — every key in the profile Entitlements dict, by sibling value tag string/bool/array; the old fixed ~10-key allowlist was an app-vs-profile asymmetry that false-positived granted-but-non-allowlisted capabilities like App Attest / Sign in with Apple / Siri) + app entitlements. Build app capability-key set EXCLUDING `aps-environment` (own check), `get-task-allow`, `application-identifier`, `*.team-identifier` (auto-managed). For each app key, profile must grant it: bool/string→same key present; array keys (application-groups, associated-domains, keychain-access-groups, icloud-container-identifiers) → every app member ⊆ profile list, BUT a profile wildcard member (`*`, `$(VAR)*`, OR the RESOLVED-team form `.*`) covers all. App members carry the `$(AppIdentifierPrefix)`/`$(TeamIdentifierPrefix)` variable while the profile carries the resolved 10-char team prefix — both are stripped before the subset compare so suffixes match. One error per missing/under-covered key naming key + bundle id. (Covers iCloud — see §5, iCloud-specific check folded in.) | Enable the capability for this App ID in the portal, regenerate the profile, re-save creds; or remove the unused entitlement. |
+| `ios/entitlements-aps-environment-vs-mode` | warning/**error** | local | `readAppEntitlements` has `aps-environment` AND `ctx.distributionMode` set | `v=entString(raw,'aps-environment')`. `distributionMode==='app_store' && v==='development'`→**warning** by default (the default Capacitor leftover is benign on a push-free app and the cloud builder neither rewrites entitlements nor fails the archive on it), escalating to **error** only with independent push evidence (Info.plist `UIBackgroundModes` contains `remote-notification`). `distributionMode==='ad_hoc' && v==='production'`→**warning** (ad_hoc may use prod APNs). If `hasMap`: cross-check profile `aps-environment`; app≠profile→**error** (a mapped profile granting `production` while the app declares `development` is a real signing mismatch). Grounding (development, no mode→appliesTo false unless mode set; with app_store→**warning**, NOT a build-blocking error — restores the clean-scan baseline). | Set aps-environment=production for App Store/TestFlight push, or remove aps-environment if the app does not use push. |
+| `ios/entitlements-associated-domains-format` | warning | local | `readAppEntitlements` has non-empty `com.apple.developer.associated-domains` | Per ``: require `/^(applinks|webcredentials|activitycontinuation|appclips):[a-z0-9.-]+(\?mode=(developer|managed))?$/i`. Flag entries containing `://`, starting `http`, containing `/`, containing spaces, or unknown service prefix. Don't flag the `service:*` managed-wildcard form. | Use `service:domain` (e.g. `applinks:example.com`) — no scheme/path/trailing slash. |
+| `ios/entitlements-app-groups-format` | warning | local | `readAppEntitlements` has non-empty `com.apple.security.application-groups` | Per ``: warn if it does not start with `group.`, or contains uppercase/whitespace/illegal chars. Pure format (subset coverage handled by entitlements-vs-profile). | Rename to `group.` and register in the portal. |
+
+### 2.D Capacitor config (file: `checks/ios-capacitor-config.ts`)
+
+| id | sev | local | appliesTo (exact) | detection | fix |
+|----|-----|-------|-------------------|-----------|-----|
+| `ios/capacitor-server-url-shipped` | error (→warning if not uploading) | local | iOS; `typeof ctx.config?.server?.url === 'string' && server.url !== ''` | Non-empty server.url→finding. Detail escalates when dev target: `http://` (non-https), RFC1918 IP (`10.`/`172.(1[6-9]|2\d|3[01]).`/`192.168.`), `localhost`/`127.0.0.1`, or tunnel host (`*.ngrok.io`/`*.trycloudflare.com`/`*.loca.lt`). Severity `error` when `willUploadToAppStore(ctx)` else `warning` (research note: optionally always-error; we gate hard error to upload to limit dev-scan noise). | Remove server.url live-reload block before production; build web assets + `npx cap sync`. |
+| `ios/capacitor-server-cleartext` | warning (→error w/ http url) | local | iOS; `ctx.config?.server?.cleartext === true` | `cleartext===true`→warning. Escalate to **error** when `server.url` present AND starts `http://`. | Remove/`false` server.cleartext for production; use https or a scoped ATS exception. |
+| `ios/capacitor-allow-navigation-wildcard` | warning | local | iOS; `Array.isArray(ctx.config?.server?.allowNavigation)` and it contains a wildcard-only/public-suffix-wildcard entry | For each entry flag `*` and `*.` (e.g. `*.com`,`*.io`) with no specific host. Do NOT flag `*.example.com` or concrete hosts. One finding listing offenders. | Restrict allowNavigation to specific hosts; remove blanket `*`. |
+
+### 2.E Pods / SPM (file: `checks/ios-deps.ts`)
+
+Layout discriminator up front: CocoaPods = `readTextIfExists(ios/App/Podfile)!==null`;
+SPM = `readTextIfExists(ios/App/CapApp-SPM/Package.swift)!==null`. Pods checks
+early-return when no Podfile; SPM checks early-return when no Package.swift.
+Grounding is SPM-only → all Pods checks early-return.
+
+| id | sev | local | appliesTo (exact) | detection | fix |
+|----|-----|-------|-------------------|-----------|-----|
+| `ios/pods-not-installed` | error | local | iOS; `ios/App/Podfile` exists | `podsDir=existsSync(ios/App/Pods)`; `ws=existsSync(ios/App/App.xcworkspace)`. `!podsDir || !ws`→error (distinguish the two sub-cases in detail). Do NOT fire when Podfile absent (SPM; cap-sync-stale owns missing-Podfile). | `npx cap sync ios` (or `pod install`); commit Pods/ + App.xcworkspace. |
+| `ios/pods-lock-missing` | warning | local | iOS; `ios/App/Podfile` exists | `readTextIfExists(ios/App/Podfile.lock)===null`→warning. Separate from pods-not-installed. | `pod install`; commit Podfile.lock. |
+| `ios/pods-capacitor-missing` | error | local | iOS; `ios/App/Podfile` exists | Podfile text NOT matching `/pod\s+['"]Capacitor['"]/`→error (core not wired). Optional lower-confidence detail: plugins from `capacitorPluginDeps` whose PascalCase pod (`@capacitor/camera`→`CapacitorCamera`) is absent from the Podfile — keep as DETAIL only (community plugins have non-standard pod names). | `npx cap sync ios` then `pod install`. |
+| `ios/spm-package-resolved-missing` | error | local | iOS; `ios/App/CapApp-SPM/Package.swift` exists | Neither `ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` NOR `ios/App/CapApp-SPM/Package.resolved` exists→error. Grounding has the xcodeproj one→pass. | `xcodebuild -resolvePackageDependencies` or open in Xcode; commit Package.resolved. |
+| `ios/spm-capacitor-dependency-missing` | error | local | iOS; `ios/App/CapApp-SPM/Package.swift` exists | Package.swift text NOT matching `/capacitor-swift-pm/` OR NOT matching `/\.product\(name:\s*['"]Capacitor['"]/`→error. (File header "DO NOT MODIFY ... managed by Capacitor CLI" → stable to regex.) Grounding matches both→pass. | `npx cap sync ios` to regenerate Package.swift. |
+
+### 2.F App icons / assets (file: `checks/ios-appicon-checks.ts`)
+
+Merges research #1 `ios/plist-app-icon` and research #4's three appicon checks into a
+clean 3-way split. `readContentsJson` (§1.5) is the shared parse-safety net.
+
+| id | sev | local | appliesTo (exact) | detection | fix |
+|----|-----|-------|-------------------|-----------|-----|
+| `ios/appicon-empty-or-placeholder` | error | local | iOS (always) | `dir=appIconSetDir(...)`. `!existsSync(dir)`→error "AppIcon.appiconset missing". Else `c=readContentsJson(dir/Contents.json)`: null→error "missing or malformed". Else `(c.images??[]).filter(i=>i.filename?.trim()).length===0`→error "no icon images". Distinct from marketing-missing (fires regardless of upload). Grounding 1 image→pass. | `npx @capacitor/assets generate` (or Xcode) so the set has ≥ the 1024 icon. |
+| `ios/appicon-referenced-file-missing` | error | local | iOS; AppIcon.appiconset/Contents.json exists & parses | For every image entry with non-empty `filename`: `existsSync(join(dir,filename))`. Any missing→error listing missing filenames. Grounding file present→pass. | Regenerate icons or commit the referenced PNG(s). |
+| `ios/appicon-marketing-missing` | error | local | `willUploadToAppStore(ctx)` | `c=readContentsJson(...)`; if `!hasMarketingIcon(c)` (no `1024x1024` size and no `role==='marketing'`)→error (ITMS-90704). Upload-gated (ad_hoc/dev don't need the store icon). Grounding has 1024→pass. | Add a 1024x1024 PNG (no alpha) marketing icon; reference it in Contents.json. |
+
+Note: research #4 `ios/deployment-target-consistency` (SPM `.iOS(.vN)` vs pbxproj)
+is included as ONE additional check (below) — it is SPM-specific and distinct from
+the Capacitor-floor check in §2.B.
+
+| id | sev | local | appliesTo (exact) | detection | fix |
+|----|-----|-------|-------------------|-----------|-----|
+| `ios/spm-deployment-target-consistency` | warning | local | iOS; `ios/App/CapApp-SPM/Package.swift` exists AND `IPHONEOS_DEPLOYMENT_TARGET` PRESENT | `pbxTarget`=parseFloat(readBuildSetting IPHONEOS_DEPLOYMENT_TARGET, app-target Release-pref). `spmMin`=`/\.iOS\(\.v(\d+)\)/` from Package.swift. Warn when `pbxTarget < spmMin` (the dangerous direction — Package requires higher than the app builds against). Numeric major.minor compare. Grounding 15.0 vs .v15→pass. | Raise IPHONEOS_DEPLOYMENT_TARGET to ≥ Package.swift min, or `npx cap sync ios`. |
+
+---
+
+## 3. appliesTo gating summary
+
+| Gate | Checks |
+|------|--------|
+| `willUploadToAppStore(ctx)` (upload-gated) | `ios/plist-encryption-compliance`, `ios/appicon-marketing-missing`, `ios/plist-background-modes-sanity` (location-2.5.4 sub-check only) |
+| upload escalates severity (warn→error) | `ios/xcode-signing-team`, `ios/capacitor-server-url-shipped`, `ios/plist-ats-arbitrary-loads` |
+| `ctx.distributionMode` required | `ios/entitlements-aps-environment-vs-mode` |
+| `hasMap(ctx)` (provisioning map present) | `ios/entitlements-vs-profile-capability`; suppresses `ios/xcode-signing-team` |
+| File-presence | All Info.plist checks (Info.plist exists); Pods checks (Podfile exists); SPM checks (Package.swift exists); entitlements checks (App.entitlements exists); appicon empty/referenced (always; degrade in run); `ios/xcode-no-app-target` (readPbxproj non-null) |
+| Build-setting presence (skip if absent/inherited) | deployment-target, signing-team, bitcode, swift-version, bundle-id-across-configs, spm-deployment-consistency |
+| Always (iOS, degrade in run) | `ios/appicon-empty-or-placeholder` |
+
+---
+
+## 4. File / registry plan
+
+New files under `cli/src/build/prescan/`:
+
+Parsing helpers:
+- `checks/ios-plist-read.ts` — plistString/Bool/HasKey/ArrayStrings/DictBlock
+- `ios-buildsettings.ts` — readBuildSetting + resolvePlistValue
+- `ios-pbxsettings.ts` — readBuildConfigs + readTargetConfigs (or fold into ios-buildsettings)
+- `ios-entitlements.ts` — readAppEntitlements + ent* readers; + MobileprovisionDetail.profileEntitlements extension (edit mobileprovision-parser.ts)
+- `ios-appicon.ts` — readContentsJson + appIconSetDir + hasMarketingIcon
+- `capacitor-version.ts` — shared capacitorMajor (extracted from android-project.ts)
+
+Check modules:
+- `checks/ios-plist-store.ts` — 11 checks (§2.A)
+- `checks/ios-xcode.ts` — 7 checks (§2.B)
+- `checks/ios-entitlements-checks.ts` — 4 checks (§2.C)
+- `checks/ios-capacitor-config.ts` — 3 checks (§2.D)
+- `checks/ios-deps.ts` — 5 checks (§2.E)
+- `checks/ios-appicon-checks.ts` — 4 checks (§2.F, incl. spm-deployment-consistency)
+
+`registry.ts` changes: import each exported check and append to `ALL_CHECKS` under
+new grouping comments (`// ios info.plist / app store`, `// ios xcode project`,
+`// ios entitlements / capabilities`, `// ios capacitor config`, `// ios pods/spm`,
+`// ios app icons`). Also re-point android-project.ts to import `capacitorMajor`
+from `../capacitor-version`.
+
+### Check count
+
+- Current iOS checks: 10 (p12-opens, p12-expiry, asc-key-valid, asc-key-access,
+ profile-expiry, profile-bundle-match, profile-type-vs-mode, cert-profile-pairing,
+ targets-covered, infoplist-sanity). Plus shared/* that also run on iOS.
+- NEW iOS checks: **34**
+ - Info.plist / App Store: 11
+ - Xcode project: 7
+ - Entitlements/capabilities: 4
+ - Capacitor config: 3
+ - Pods/SPM: 5
+ - App icons/assets: 4 (incl. spm-deployment-consistency)
+- **Total iOS after expansion: 44.**
+
+(34 exceeds the 18-28 target; if trimming is desired, the lowest-value
+high-FP/cosmetic 6-8 are flagged in §6 as "optional trims" — cutting those lands
+at ~26-28. Recommended baseline ships all 34: each is grounded, low/medium FP, and
+the grounding project scans clean.)
+
+---
+
+## 5. Cut list (researched → rejected, with reason)
+
+| Rejected check (source) | Reason |
+|--------------------------|--------|
+| `ios/plist-bundle-id-vs-appid` (#1) | Duplicates `shared/bundle-id-consistency` (appId vs resolved bundle id). Instead: EXTEND shared/bundle-id-consistency to escalate to error when `willUploadToAppStore(ctx)` (ASC record match becomes load-bearing). No new check. |
+| `ios/plist-deployment-target` (#1) | No real App-Store deployment-target floor exists (Apple's mandate is the build SDK, controlled by the cloud builder). The build-breaking floor is Capacitor's → covered by `ios/xcode-deployment-target-capacitor`. |
+| `ios/plist-app-icon` (#1) | Superseded by the §2.F asset-catalog appicon checks (cleaner 3-way split: empty / referenced-missing / marketing-missing). |
+| `ios/xcode-automatic-signing-no-team` + `ios/xcode-manual-signing-no-team` (#2) | MERGED into one `ios/xcode-signing-team` (fires for either style when DEVELOPMENT_TEAM missing + no map). Two near-identical checks collapse. |
+| `ios/entitlements-icloud-container-coverage` (#3) | Folded into `ios/entitlements-vs-profile-capability` (iCloud keys are just more capability keys; the array-subset logic is identical). The general check's message names the specific key, so the sharper-message rationale is met without a second check. |
+| `shared/cap-sync-stale` "Podfile missing" overlap | Existing `shared/cap-sync-stale` already flags missing Podfile on iOS. `ios/pods-not-installed` is scoped to *Podfile-present-but-Pods/-or-workspace-missing* and explicitly does NOT fire when Podfile is absent — no overlap. |
+| Any check requiring the built IPA / actool output / binary inspection | Out of static-scan scope (prescan runs pre-build on source). |
+| Any "is the app actually using background audio/location" runtime judgement | Out of scope — only structural/invalid-token + missing-usage-string heuristics kept (in `ios/plist-background-modes-sanity`). |
+| CFBundleIconName plist-key check | Injected at build time, correctly absent from Capacitor Info.plist → would FP on every healthy app. Asset-catalog detection used instead. |
+| Monotonic/increasing CFBundleVersion check | Needs ASC build history (remote/stateful) — out of static scope; format-only kept. |
+
+### Existing checks to lightly extend (not new modules)
+
+- `shared/bundle-id-consistency`: add upload-aware severity escalation (warn→error
+ when `willUploadToAppStore(ctx)`), absorbing the intent of `ios/plist-bundle-id-vs-appid`.
+- `ios/infoplist-sanity`: have the presence-only CFBundleShortVersionString /
+ CFBundleVersion warnings DEFER to the new format checks (or drop the presence
+ warnings there) so the missing case is reported once, by the stronger check. See
+ §2.A "null→SKIP" notes — the new format checks intentionally skip the null case to
+ avoid double-reporting; if infoplist-sanity's presence warnings are removed instead,
+ flip the format checks to own the null→warning case. Pick ONE owner; spec default:
+ infoplist-sanity keeps presence, format checks skip null.
+
+---
+
+## 6. Optional trims (to land at 18-28 if mandated)
+
+Lowest value / highest FP, in trim order (cut top-down):
+1. `ios/plist-background-modes-sanity` (high FP, research-flagged).
+2. `ios/plist-iphoneos-required` (cosmetic structural sanity; Capacitor always sets it).
+3. `ios/plist-display-name` (cosmetic; grounding passes via literal).
+4. `ios/xcode-multiple-app-targets` (rare; bad-merge only).
+5. `ios/entitlements-app-groups-format` (format-only; rare in Capacitor apps).
+6. `ios/capacitor-allow-navigation-wildcard` (security smell, not build-breaking).
+7. `ios/spm-deployment-target-consistency` (warning-only; pbxproj≥spm usually fine).
+8. `ios/plist-orientations-present` (Apple lenient on iPhone orientations).
+
+Cutting 1-8 → 26 iOS checks (within target). Recommended: ship all 34 (each grounded,
+clean against the real project).
+
+---
+
+## 7. TDD acceptance fixtures
+
+1. **Healthy SPM project** (the grounding tutorial-app): every new check returns `[]`.
+ This is the primary regression fixture — copy the real files into a test fixture dir.
+2. **Per-check failing fixture**: minimal mutation of the healthy fixture (e.g.
+ `CFBundleShortVersionString` literal `1.0-beta`; `aps-environment` + `distributionMode=app_store`;
+ delete `AppIcon-512@2x.png`; `server.url` set; `IPHONEOS_DEPLOYMENT_TARGET=12.0` for Cap8).
+3. **Unresolved-var fixture**: Info.plist `$()` refs with NO matching pbxproj setting →
+ value-format checks must SKIP (no finding), proving the resolvePlistValue guard.
+4. **Non-Capacitor / partial project**: missing Info.plist/pbxproj/appiconset → all
+ checks early-return `[]` (no crashes).