feat(cli): expand iOS pre-scan checks (plist, Xcode, entitlements, Capacitor config, pods/SPM, app icons)#2565
feat(cli): expand iOS pre-scan checks (plist, Xcode, entitlements, Capacitor config, pods/SPM, app icons)#2565WcaleNieWolny wants to merge 7 commits into
Conversation
…nts/appicon) + shared capacitorMajor
Shared, never-throwing parsing infrastructure the new iOS prescan checks
depend on (spec parsing-infrastructure section):
- checks/ios-plist-read.ts: plistString/Bool/HasKey/ArrayStrings/DictBlock
(promotes the private plistStringValue out of ios-plist.ts, which now imports it)
- ios-pbxsettings.ts: readBuildSetting (Release-preferred scalar), resolvePlistValue
($()->pbxproj substitution, the Info.plist false-positive guard), readBuildConfigs,
readTargetConfigs (scalar keys only; absent == inherited == skip)
- ios-entitlements.ts: readAppEntitlements + entString/entArray/entBool, plus
MobileprovisionDetail.profileEntitlements (capability keys from the profile's
Entitlements dict) in mobileprovision-parser.ts
- ios-appicon.ts: readContentsJson (JSON.parse, never throws) / appIconSetDir /
hasMarketingIcon
- capacitor-version.ts: shared capacitorMajor (extracted from android-project.ts,
generalized to check core/ios/android; android-project re-imports it)
All pure parsers return null/[]/{} on malformed input. Grounded against the real
Capacitor-8 SPM tutorial project (scans clean). flow.ts synthesis sites carry the
new required profileEntitlements field.
Claude-Session: https://claude.ai/code/session_01KwFbH9dxYYFCGR554WRp4A
…tor config, pods/spm, app icons) Wire 33 new iOS prescan checks into ALL_CHECKS (47 -> 80 total): - 10 plist (Info.plist / App Store): bundle-id/version formats, encryption compliance, ATS arbitrary loads, launch storyboard, orientations, display name, background modes. - 7 xcode (project / build settings): deployment-target floor vs Capacitor major, signing team (suppressed when a provisioning map is present), bundle-id mismatch across configs, leftover ENABLE_BITCODE, Swift version, no/multiple app targets. - 4 entitlements / capabilities: app entitlements vs profile, aps-environment vs distribution mode, associated-domains + app-groups format. - 3 capacitor config: shipped server.url, server.cleartext, allowNavigation wildcard. - 9 pods/spm/app icons: pods not installed / lock missing / capacitor missing, SPM Package.resolved missing / capacitor dependency missing, app icon empty / referenced-file-missing / marketing-missing, SPM deployment-target consistency. Builder-grounded severity (spec deviation 2, verified against capgo_builder_new github-ios-build.yml + fastlaneTemplateIos.ts): the cloud build runs pod install and resolves SPM during build_app, and actool tolerates an empty AppIcon for non-App-Store exports, so pods/spm not-installed/resolved and appicon-empty are warnings (appicon-empty + appicon-marketing upload-gated back to error). Spec deviation 1 (cut ios/plist-iphoneos-required) honored. Regression baseline: the real Capacitor-8 SPM tutorial-app scans clean of all new checks in its real ad_hoc distribution (zero new-check findings); the only app_store-mode signal is a correct true-positive (committed aps-environment=development). Claude-Session: https://claude.ai/code/session_01KwFbH9dxYYFCGR554WRp4A
- aps-environment-vs-mode: downgrade the standalone development+app_store branch from a hard error to a warning (the default Capacitor leftover is benign on a push-free app; the cloud builder neither rewrites entitlements nor fails the archive). Escalate to error only with independent push evidence (Info.plist UIBackgroundModes contains remote-notification); the mapped-profile production mismatch stays an error. Restores the spec clean-scan baseline. - entitlements-vs-profile-capability: recognize the resolved-team wildcard form <teamid>.* (signed wildcard-App-ID profiles store this, never $(AppIdentifierPrefix)*), and normalize $(AppIdentifierPrefix)/$(TeamIdentifierPrefix) app prefixes vs the resolved team prefix before the subset compare. - mobileprovision-parser: parse profileEntitlements GENERICALLY (every key in the Entitlements dict by sibling value tag) instead of a fixed ~10-key allowlist, removing the app-vs-profile asymmetry that false-positived granted-but-non-allowlisted capabilities (App Attest, Sign in with Apple, Siri, ...). Claude-Session: https://claude.ai/code/session_01KwFbH9dxYYFCGR554WRp4A
Grounded against the real Capacitor 8 SPM tutorial-app, documents the new plist/Xcode/entitlements/Capacitor-config/pods-SPM/app-icon checks, the parsing helpers, and the false-positive guards. Claude-Session: https://claude.ai/code/session_01KwFbH9dxYYFCGR554WRp4A
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
🔗 Linked repositories identifiedCodeRabbit considers these linked repositories for cross-repo context during reviews:
📝 WalkthroughWalkthroughExpands the iOS prescan engine from 47 to 80 checks by adding five new iOS check modules (Xcode build settings, Info.plist validation, entitlements/capabilities, Capacitor config security, Pods/SPM/AppIcon assets), backed by new shared parsing primitives for plist, pbxproj, entitlements, and app icon reading. Extracts ChangesiOS Prescan Expansion
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
Merging this PR will improve performance by 97.45%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | /updates manifest response with metadata |
228.2 µs | 115.6 µs | +97.45% |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing wolny/prescan-ios-expansion (727c621) with main (1c01553)
Footnotes
-
2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
🧪 Builder onboarding TUI preview — ❌ failed▶ Open the interactive HTML report (zoomable journey tree + cast playback) Commit: 727c621 · Job summary with the result table |
- plistDictBlock: balanced <dict> depth scan instead of lazy first-</dict> capture, so a nested NSExceptionDomains dict preceding NSAllowsArbitraryLoads no longer truncates the ATS block (fixes ATS false negative). - appEntitlementKeys: collect only TOP-LEVEL entitlement keys; skip past a dict/array value's matching close so nested keys do not leak into the capability set feeding the ERROR-severity profile-coverage check. - Replace external machine-specific tutorial-app absolute paths in the prescan grounding tests with self-contained inline real-shaped fixtures so the grounding assertions are real on CI (plist/xcode/parsers/entitlements/cap-ver). Claude-Session: https://claude.ai/code/session_01KwFbH9dxYYFCGR554WRp4A
There was a problem hiding this comment.
Stale comment
Risk: medium. Not approving: this large iOS prescan expansion (~5k lines, 33 new checks) exceeds the low-risk approval threshold. Cursor Bugbot did not run on this PR (signal skipped). Requesting human review from riderx and Dalanir.
Sent by Cursor Approval Agent: Pull Request Approver External
There was a problem hiding this comment.
Actionable comments posted: 9
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cli/src/build/prescan/capacitor-version.ts`:
- Around line 26-30: The code assumes `range` is always a string before calling
`.match()` on line 29, but package.json can contain non-string dependency values
(numbers, objects, etc.) which will cause a runtime error. Add a type guard to
check if `range` is a string before calling the `.match()` method. If `range` is
not a string, return null to maintain the contract that this function never
throws. This guard should be added after the initial null check on line 27 but
before the `.match()` call on line 29.
In `@cli/src/build/prescan/checks/ios-entitlements-checks.ts`:
- Around line 148-180: The for loop iterating through parseProvisioningMap(ctx)
is checking the same app entitlements against all provisioning profiles in the
map, including those for extensions and watch apps. This causes false errors
when the app entitlements don't match unrelated bundle ID profiles. Scope the
capability comparison to only the primary app profile by filtering
parseProvisioningMap(ctx) to include only the profile that corresponds to the
main app being checked, not all bundle IDs in the map.
In `@cli/src/build/prescan/checks/ios-pods-assets.ts`:
- Around line 355-381: The check is using readBuildSetting to retrieve the
IPHONEOS_DEPLOYMENT_TARGET value in both the appliesTo method and the run
method, but this approach can return a higher project/other-target Release value
first, missing the lower app-target value that actually determines this check's
behavior. Replace the readBuildSetting calls for IPHONEOS_DEPLOYMENT_TARGET with
a method that properly aggregates or retrieves the app-target deployment target
instead of just the first value found. This will ensure the check accurately
identifies the actual app-target deployment target that drives the validation
logic.
In `@cli/src/build/prescan/checks/ios-xcode.ts`:
- Around line 52-58: The presentDeploymentTarget function currently relies on
readBuildSetting which returns only the first Release configuration value
encountered, rather than checking all relevant configurations across project and
app targets. Modify the function to retrieve deployment target values from all
applicable configurations (both project and app targets) and return the lowest
value found among them, instead of just taking the first result from
readBuildSetting. This ensures you catch the actual minimum deployment target
requirement that could block builds.
In `@cli/src/build/prescan/ios-pbxsettings.ts`:
- Around line 145-149: The regex pattern in the match call within the
resolvePlistValue function currently allows mismatched delimiters like $(VAR} by
using independent character classes for opening and closing brackets. Update the
regex to enforce matching delimiters by using alternation to match either
$(VAR_NAME) or ${VAR_NAME} patterns exclusively, ensuring the opener and closer
are paired correctly rather than allowing any combination of ( with ) or { with
}.
In `@cli/test/prescan/checks-ios-pods-assets.test.ts`:
- Around line 382-396: Add a new test case to the
ios/spm-deployment-target-consistency describe block that creates a regression
test for mixed deployment target configurations. The test should use it() to
create a fixture via cleanSpmFiles() where the project-level
IPHONEOS_DEPLOYMENT_TARGET is higher than the app target
IPHONEOS_DEPLOYMENT_TARGET, and the app target is lower than the minimum
specified in Package.swift. Then call spmDeploymentTargetConsistency.run(ctx)
and verify that it correctly warns about the inconsistency, ensuring the check
does not depend on configuration traversal order.
In `@cli/test/prescan/checks-ios-xcode.test.ts`:
- Around line 269-275: The test titled "reads the app-target value too (target
below floor errors even if project-level is fine)" claims project-level settings
are fine but doesn't actually set a project-level IPHONEOS_DEPLOYMENT_TARGET in
the projectSettings object. Update the makePbx call to include
IPHONEOS_DEPLOYMENT_TARGET in the projectSettings parameter (set to a value
above the floor like '13.0') while keeping the targetDebug and targetRelease
IPHONEOS_DEPLOYMENT_TARGET values at '11.0'. This ensures the test properly
validates the "lowest applicable value wins" behavior where the app target's
lower deployment target takes precedence over the project-level setting.
In `@docs/superpowers/specs/2026-06-22-prescan-ios-expansion-design.md`:
- Around line 324-330: Update the iOS checks module listing in the spec to match
the actual implementation: replace the module name `ios-plist-store.ts` with
`ios-plist-checks.ts`, replace `ios-deps.ts` with `ios-pods-assets.ts`, and
adjust the total check count from 34 to 33 checks to align with the implemented
registry. Additionally, review and update any other sections referenced at lines
342-349 that list module names or check totals to ensure consistency with the
actual check implementation throughout the document.
- Around line 211-212: Escape all unescaped pipe characters in the Markdown
table cells by replacing `|` with `\|` when they appear as literal text (such as
in field names, regex patterns, or code examples) rather than as column
delimiters. This applies to the rows with check IDs
ios/plist-ats-arbitrary-loads and ios/plist-launch-storyboard as well as the
rows at lines 250, 257, and 270. Alternatively, if the content is too complex to
fit cleanly in table cells, move detailed regex/technical specifications below
the table as separate reference sections. Ensure the Markdown table parses
correctly without MD056 errors after making these changes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0a73e420-16a5-4102-9ded-32e2e503b195
📒 Files selected for processing (23)
cli/src/build/mobileprovision-parser.tscli/src/build/onboarding/ios/flow.tscli/src/build/prescan/capacitor-version.tscli/src/build/prescan/checks/android-project.tscli/src/build/prescan/checks/ios-capacitor-config.tscli/src/build/prescan/checks/ios-entitlements-checks.tscli/src/build/prescan/checks/ios-plist-checks.tscli/src/build/prescan/checks/ios-plist-read.tscli/src/build/prescan/checks/ios-plist.tscli/src/build/prescan/checks/ios-pods-assets.tscli/src/build/prescan/checks/ios-xcode.tscli/src/build/prescan/ios-appicon.tscli/src/build/prescan/ios-entitlements.tscli/src/build/prescan/ios-pbxsettings.tscli/src/build/prescan/registry.tscli/test/prescan/capacitor-version.test.tscli/test/prescan/checks-ios-entitlements-config.test.tscli/test/prescan/checks-ios-plist-checks.test.tscli/test/prescan/checks-ios-pods-assets.test.tscli/test/prescan/checks-ios-xcode.test.tscli/test/prescan/engine.test.tscli/test/prescan/ios-parsers.test.tsdocs/superpowers/specs/2026-06-22-prescan-ios-expansion-design.md
🔗 Linked repositories identified
CodeRabbit considers these linked repositories for cross-repo context during reviews:
Cap-go/capacitor-updater(manual)
| 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 |
There was a problem hiding this comment.
Guard non-string dependency ranges before calling .match().
On Line 29, range can be a non-string when package.json is syntactically valid but malformed (for example, numeric/object dependency values), which throws at runtime and breaks the documented "never throws" contract.
Proposed fix
- const range = deps['`@capacitor/core`'] ?? deps['`@capacitor/ios`'] ?? deps['`@capacitor/android`']
- if (!range)
+ const range = deps['`@capacitor/core`'] ?? deps['`@capacitor/ios`'] ?? deps['`@capacitor/android`']
+ if (typeof range !== 'string' || range.length === 0)
return null
const m = range.match(/(\d+)/)
return m ? Number(m[1]) : null📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 | |
| const range = deps['`@capacitor/core`'] ?? deps['`@capacitor/ios`'] ?? deps['`@capacitor/android`'] | |
| if (typeof range !== 'string' || range.length === 0) | |
| return null | |
| const m = range.match(/(\d+)/) | |
| return m ? Number(m[1]) : null |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/src/build/prescan/capacitor-version.ts` around lines 26 - 30, The code
assumes `range` is always a string before calling `.match()` on line 29, but
package.json can contain non-string dependency values (numbers, objects, etc.)
which will cause a runtime error. Add a type guard to check if `range` is a
string before calling the `.match()` method. If `range` is not a string, return
null to maintain the contract that this function never throws. This guard should
be added after the initial null check on line 27 but before the `.match()` call
on line 29.
| 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)', | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Scope capability matching to the primary app profile, not every mapped profile.
Line 148 loops over all provisioning-map entries, but the check compares a single app entitlements file. If CAPGO_IOS_PROVISIONING_MAP contains extension/watch bundle IDs, this can emit false blocking errors for unrelated profiles.
Proposed fix direction
- for (const { bundleId, base64 } of parseProvisioningMap(ctx)) {
+ const profiles = parseProvisioningMap(ctx)
+ const appBundleId = resolvePrimaryAppBundleId(ctx, app.raw) // derive from app target context
+ const relevantProfiles = appBundleId
+ ? profiles.filter(p => p.bundleId === appBundleId)
+ : profiles
+ for (const { bundleId, base64 } of relevantProfiles) {
const profileEnt = profileEntitlementsOf(base64)
...🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/src/build/prescan/checks/ios-entitlements-checks.ts` around lines 148 -
180, The for loop iterating through parseProvisioningMap(ctx) is checking the
same app entitlements against all provisioning profiles in the map, including
those for extensions and watch apps. This causes false errors when the app
entitlements don't match unrelated bundle ID profiles. Scope the capability
comparison to only the primary app profile by filtering
parseProvisioningMap(ctx) to include only the profile that corresponds to the
main app being checked, not all bundle IDs in the map.
| 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<Finding[]> { | ||
| 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) |
There was a problem hiding this comment.
Use app-target deployment target aggregation instead of a single readBuildSetting value.
On Line 361 and Line 367, readBuildSetting can return a higher project/other-target Release value first. That can miss the lower app-target value that actually drives this check, causing false clean results.
Suggested patch
-import { readBuildSetting } from '../ios-pbxsettings'
+import { readBuildSetting, readTargetConfigs } from '../ios-pbxsettings'
@@
const SPM_MIN_RE = /\.iOS\(\.v(\d+)\)/
+const APPLICATION_PRODUCT_TYPE = 'com.apple.product-type.application'
+
+function lowestAppDeploymentTarget(pbx: string): number | null {
+ const values = readTargetConfigs(pbx)
+ .filter(t => t.target.productType === APPLICATION_PRODUCT_TYPE)
+ .flatMap(t => t.configs.map(c => c.settings.IPHONEOS_DEPLOYMENT_TARGET))
+ .filter((v): v is string => v !== undefined)
+ .map(v => Number.parseFloat(v))
+ .filter(v => !Number.isNaN(v))
+ return values.length > 0 ? Math.min(...values) : null
+}
@@
appliesTo(ctx): boolean {
@@
- return readBuildSetting(pbx, 'IPHONEOS_DEPLOYMENT_TARGET') !== null
+ return lowestAppDeploymentTarget(pbx) !== null
},
async run(ctx): Promise<Finding[]> {
@@
- const raw = readBuildSetting(pbx, 'IPHONEOS_DEPLOYMENT_TARGET')
- if (raw === null)
- return []
- const pbxTarget = Number.parseFloat(raw)
- if (Number.isNaN(pbxTarget))
+ const pbxTarget = lowestAppDeploymentTarget(pbx)
+ if (pbxTarget === null)
return []📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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<Finding[]> { | |
| 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) | |
| import { readBuildSetting, readTargetConfigs } from '../ios-pbxsettings' | |
| const SPM_MIN_RE = /\.iOS\(\.v(\d+)\)/ | |
| const APPLICATION_PRODUCT_TYPE = 'com.apple.product-type.application' | |
| function lowestAppDeploymentTarget(pbx: string): number | null { | |
| const values = readTargetConfigs(pbx) | |
| .filter(t => t.target.productType === APPLICATION_PRODUCT_TYPE) | |
| .flatMap(t => t.configs.map(c => c.settings.IPHONEOS_DEPLOYMENT_TARGET)) | |
| .filter((v): v is string => v !== undefined) | |
| .map(v => Number.parseFloat(v)) | |
| .filter(v => !Number.isNaN(v)) | |
| return values.length > 0 ? Math.min(...values) : null | |
| } | |
| appliesTo(ctx): boolean { | |
| if (!hasPackageSwift(ctx)) | |
| return false | |
| const pbx = readPbxproj(ctx.projectDir) | |
| if (pbx === null) | |
| return false | |
| return lowestAppDeploymentTarget(pbx) !== null | |
| }, | |
| async run(ctx): Promise<Finding[]> { | |
| const pbx = pbxContent(ctx) | |
| if (pbx === null) | |
| return [] | |
| const pbxTarget = lowestAppDeploymentTarget(pbx) | |
| if (pbxTarget === null) | |
| 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) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/src/build/prescan/checks/ios-pods-assets.ts` around lines 355 - 381, The
check is using readBuildSetting to retrieve the IPHONEOS_DEPLOYMENT_TARGET value
in both the appliesTo method and the run method, but this approach can return a
higher project/other-target Release value first, missing the lower app-target
value that actually determines this check's behavior. Replace the
readBuildSetting calls for IPHONEOS_DEPLOYMENT_TARGET with a method that
properly aggregates or retrieves the app-target deployment target instead of
just the first value found. This will ensure the check accurately identifies the
actual app-target deployment target that drives the validation logic.
| 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 | ||
| } |
There was a problem hiding this comment.
Compute deployment target from all relevant configs, not readBuildSetting’s first Release hit.
On Line 53, presentDeploymentTarget calls readBuildSetting, which returns the first Release value it sees rather than the lowest value across project + app targets. This can hide a lower app-target deployment target and skip a real build-blocking finding.
Suggested patch
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
+ const values: number[] = []
+
+ for (const cfg of readBuildConfigs(pbxContent)) {
+ if (!cfg.isProjectLevel)
+ continue
+ const raw = cfg.settings.IPHONEOS_DEPLOYMENT_TARGET
+ if (raw === undefined)
+ continue
+ const n = Number.parseFloat(raw)
+ if (!Number.isNaN(n))
+ values.push(n)
+ }
+
+ for (const { target, configs } of readTargetConfigs(pbxContent)) {
+ if (target.productType !== APPLICATION_PRODUCT_TYPE)
+ continue
+ for (const cfg of configs) {
+ const raw = cfg.settings.IPHONEOS_DEPLOYMENT_TARGET
+ if (raw === undefined)
+ continue
+ const n = Number.parseFloat(raw)
+ if (!Number.isNaN(n))
+ values.push(n)
+ }
+ }
+
+ return values.length > 0 ? Math.min(...values) : null
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/src/build/prescan/checks/ios-xcode.ts` around lines 52 - 58, The
presentDeploymentTarget function currently relies on readBuildSetting which
returns only the first Release configuration value encountered, rather than
checking all relevant configurations across project and app targets. Modify the
function to retrieve deployment target values from all applicable configurations
(both project and app targets) and return the lowest value found among them,
instead of just taking the first result from readBuildSetting. This ensures you
catch the actual minimum deployment target requirement that could block builds.
| const m = rawValue.match(/^\$[({]([A-Z0-9_]+)[)}]$/i) | ||
| if (!m) | ||
| return rawValue | ||
| return readBuildSetting(pbxContent, m[1]) ?? rawValue | ||
| } |
There was a problem hiding this comment.
Require matching $(...) / ${...} delimiters in resolvePlistValue.
Line 145 currently accepts malformed refs like $(VAR} because opener/closer are matched independently, which can produce false resolutions.
Proposed fix
export function resolvePlistValue(rawValue: string, pbxContent: string): string {
- const m = rawValue.match(/^\$[({]([A-Z0-9_]+)[)}]$/i)
- if (!m)
+ const paren = rawValue.match(/^\$\(([A-Z0-9_]+)\)$/i)
+ const brace = rawValue.match(/^\$\{([A-Z0-9_]+)\}$/i)
+ const varName = paren?.[1] ?? brace?.[1]
+ if (!varName)
return rawValue
- return readBuildSetting(pbxContent, m[1]) ?? rawValue
+ return readBuildSetting(pbxContent, varName) ?? rawValue
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const m = rawValue.match(/^\$[({]([A-Z0-9_]+)[)}]$/i) | |
| if (!m) | |
| return rawValue | |
| return readBuildSetting(pbxContent, m[1]) ?? rawValue | |
| } | |
| const paren = rawValue.match(/^\$\(([A-Z0-9_]+)\)$/i) | |
| const brace = rawValue.match(/^\$\{([A-Z0-9_]+)\}$/i) | |
| const varName = paren?.[1] ?? brace?.[1] | |
| if (!varName) | |
| return rawValue | |
| return readBuildSetting(pbxContent, varName) ?? rawValue | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/src/build/prescan/ios-pbxsettings.ts` around lines 145 - 149, The regex
pattern in the match call within the resolvePlistValue function currently allows
mismatched delimiters like $(VAR} by using independent character classes for
opening and closing brackets. Update the regex to enforce matching delimiters by
using alternation to match either $(VAR_NAME) or ${VAR_NAME} patterns
exclusively, ensuring the opener and closer are paired correctly rather than
allowing any combination of ( with ) or { with }.
| 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) | ||
| }) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Add a mixed-config regression case for deployment-target consistency.
Current tests around Line 388 only validate a single IPHONEOS_DEPLOYMENT_TARGET value. Add a fixture where project-level is higher but app target is lower than .iOS(.vN) to ensure this check does not depend on config traversal order.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/test/prescan/checks-ios-pods-assets.test.ts` around lines 382 - 396, Add
a new test case to the ios/spm-deployment-target-consistency describe block that
creates a regression test for mixed deployment target configurations. The test
should use it() to create a fixture via cleanSpmFiles() where the project-level
IPHONEOS_DEPLOYMENT_TARGET is higher than the app target
IPHONEOS_DEPLOYMENT_TARGET, and the app target is lower than the minimum
specified in Package.swift. Then call spmDeploymentTargetConsistency.run(ctx)
and verify that it correctly warns about the inconsistency, ensuring the check
does not depend on configuration traversal order.
| 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') | ||
| }) |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Add a regression case where both project and app targets set deployment target.
On Line 269, the test title says “project-level is fine,” but this fixture omits project-level IPHONEOS_DEPLOYMENT_TARGET. Add a case with project-level above floor and app target below floor to lock in the intended “lowest applicable value wins” behavior.
Suggested test addition
describe('ios/xcode-deployment-target-capacitor', () => {
+ it('errors when app target is below floor even if project-level is above floor', 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({
+ projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '15.0', SDKROOT: 'iphoneos' },
+ targetDebug: { ...low },
+ targetRelease: { ...low },
+ }))
+ const findings = await deploymentTargetCapacitor.run(ctx(dir))
+ expect(findings.length).toBe(1)
+ expect(findings[0].id).toBe('ios/xcode-deployment-target-capacitor')
+ })📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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('errors when app target is below floor even if project-level is above floor', 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({ | |
| projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '15.0', SDKROOT: 'iphoneos' }, | |
| targetDebug: { ...low }, | |
| targetRelease: { ...low }, | |
| })) | |
| const findings = await deploymentTargetCapacitor.run(ctx(dir)) | |
| expect(findings.length).toBe(1) | |
| expect(findings[0].id).toBe('ios/xcode-deployment-target-capacitor') | |
| }) | |
| 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') | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cli/test/prescan/checks-ios-xcode.test.ts` around lines 269 - 275, The test
titled "reads the app-target value too (target below floor errors even if
project-level is fine)" claims project-level settings are fine but doesn't
actually set a project-level IPHONEOS_DEPLOYMENT_TARGET in the projectSettings
object. Update the makePbx call to include IPHONEOS_DEPLOYMENT_TARGET in the
projectSettings parameter (set to a value above the floor like '13.0') while
keeping the targetDebug and targetRelease IPHONEOS_DEPLOYMENT_TARGET values at
'11.0'. This ensures the test properly validates the "lowest applicable value
wins" behavior where the app target's lower deployment target takes precedence
over the project-level setting.
| | `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 `<false/>`); 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. | |
There was a problem hiding this comment.
Escape pipe characters in table cells to fix broken Markdown table parsing.
Several rows contain unescaped | inside cell text/regex, which breaks column parsing (MD056). Escape literal pipes (\|) or move complex regex/details below the table to keep the spec renderable.
Also applies to: 250-250, 257-257, 270-270
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 211-211: Table column count
Expected: 6; Actual: 8; Too many cells, extra data will be missing
(MD056, table-column-count)
[warning] 212-212: Table column count
Expected: 6; Actual: 8; Too many cells, extra data will be missing
(MD056, table-column-count)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-06-22-prescan-ios-expansion-design.md` around
lines 211 - 212, Escape all unescaped pipe characters in the Markdown table
cells by replacing `|` with `\|` when they appear as literal text (such as in
field names, regex patterns, or code examples) rather than as column delimiters.
This applies to the rows with check IDs ios/plist-ats-arbitrary-loads and
ios/plist-launch-storyboard as well as the rows at lines 250, 257, and 270.
Alternatively, if the content is too complex to fit cleanly in table cells, move
detailed regex/technical specifications below the table as separate reference
sections. Ensure the Markdown table parses correctly without MD056 errors after
making these changes.
Source: Linters/SAST tools
| - `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) | ||
|
|
There was a problem hiding this comment.
Sync spec module names and new-check totals with the implemented registry.
This spec section still documents 34 new iOS checks and module names (ios-plist-store.ts, ios-deps.ts, ios-appicon-checks.ts) that do not match the implemented registry/test wiring in this PR cohort (33 new checks, ios-plist-checks.ts, ios-pods-assets.ts). Please align these to avoid future implementation drift.
Also applies to: 342-349
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-06-22-prescan-ios-expansion-design.md` around
lines 324 - 330, Update the iOS checks module listing in the spec to match the
actual implementation: replace the module name `ios-plist-store.ts` with
`ios-plist-checks.ts`, replace `ios-deps.ts` with `ios-pods-assets.ts`, and
adjust the total check count from 34 to 33 checks to align with the implemented
registry. Additionally, review and update any other sections referenced at lines
342-349 that list module names or check totals to ensure consistency with the
actual check implementation throughout the document.
….lt host in iOS prescan)
There was a problem hiding this comment.
Risk: medium. Not approving: this large CLI iOS prescan expansion (~5k lines, 33 new checks) exceeds the low-risk approval threshold. Cursor Bugbot was not present on this PR (signal skipped). Human review already requested from riderx and Dalanir; no additional reviewers assigned.
Sent by Cursor Approval Agent: Pull Request Approver External
There was a problem hiding this comment.
Risk: medium. Approved: Cursor Bugbot was not present on this PR (signal skipped), no applicable approval-policy blockers, and this CLI-only iOS prescan expansion is within the medium-risk threshold with substantial test coverage. Reviewers Dalanir and riderx are already assigned.
Sent by Cursor Approval Agent: Pull Request Approver
|





Follow-up to the build pre-scan feature. Expands the iOS pre-scan from credential/profile-only coverage to the full local iOS project surface, so a misconfigured Xcode/Capacitor project is caught before anything is uploaded to a cloud build.
New iOS checks by group
Info.plist / App Store (
ios/plist-*)plist-bundle-id-format,plist-version-short-format,plist-version-build-formatplist-encryption-compliance,plist-ats-arbitrary-loads,plist-launch-storyboardplist-orientations-present,plist-orientations-multitasking,plist-display-name,plist-background-modes-sanityXcode project / build settings (
ios/xcode-*)xcode-deployment-target-capacitor,xcode-signing-team,xcode-bundle-id-mismatch-across-configsxcode-enable-bitcode-leftover,xcode-swift-version-sanity,xcode-no-app-target,xcode-multiple-app-targetsEntitlements / capabilities (
ios/entitlements-*)entitlements-vs-profile-capability,entitlements-aps-environment-vs-modeentitlements-associated-domains-format,entitlements-app-groups-formatCapacitor config (
ios/capacitor-*)capacitor-server-url-shipped,capacitor-server-cleartext,capacitor-allow-navigation-wildcardPods / SPM (
ios/pods-*,ios/spm-*)pods-not-installed,pods-lock-missing,pods-capacitor-missingspm-package-resolved-missing,spm-capacitor-dependency-missing,spm-deployment-target-consistencyApp icons (
ios/appicon-*)appicon-empty-or-placeholder,appicon-referenced-file-missing,appicon-marketing-missingNet +33 registered iOS checks (ALL_CHECKS 46 -> 80).
Parsing helpers
Shared, FP-resistant parsers backing the checks:
$(PRODUCT_BUNDLE_IDENTIFIER),$(MARKETING_VERSION), etc.) so build-variable Info.plist values are not flagged as literalsAppIcon.appiconsetreadercapacitorMajorhelper for Capacitor-version-aware checksSeverity calibration (builder-verified)
Severities were tuned against the real Capacitor 8 builder project to avoid false build-blocks. Notably the Capacitor-default
aps-environment: developmententitlement on a push-free app surfaces as a warning (not an error) underapp_store, so a healthy project still scans clean of errors. Other Capacitor-default-leftover and cosmetic conditions were downgraded from error to warning/info after confirming the builder tolerates them.Upload-gated checks
Checks whose answer depends on the target distribution mode / upload destination (e.g. APS environment vs
app_store/ad_hoc, provisioning capability pairing) are gated on the distribution mode and only run when the relevant context is present, rather than firing unconditionally.Verified clean on the real project
Scanned the real Capacitor 8 SPM project (
capgo_builder/tutorial-app) with--platform ios: 0 errors and 0 warnings from any of the new iOS structural checks. The only findings are pre-existing shared checks (remote apikey/credentials, and one bundle-id-consistency warning) — none from the new plist/Xcode/entitlements/Capacitor/pods-SPM/app-icon pack.Tests
bun test test/prescan/: 617 pass / 0 fail across 25 files.https://claude.ai/code/session_01KwFbH9dxYYFCGR554WRp4A
Summary by CodeRabbit
Release Notes
New Features
Tests
Documentation