refactor(plugin): split iOS plugin into per-concern modules + codegen/#46
Merged
Merged
Conversation
Implements audit follow-ups #1, #2, #4, #5 from .plan/07-plugin-audit.md (4 of 10 items — the foundation slice). Mod factoring (§5 — keystone) plugin/src/withIOSVoiceIntents.ts (1154 lines) → seven focused files: plugin/src/ios/ index.ts composer (40 lines) withIOSInfoPlist.ts Info.plist mod + setInfoPlist withIOSEntitlements.ts entitlements + setEntitlements withIOSAppShortcutsCodegen.ts withXcodeProject + pbxproj reg withIOSIntentExtension.ts legacy Intent Extension (gated) codegen/ types.ts swiftTypeFor / marshalExpr / intentStructName / isEnumType / isEntityType / capitalize / escapeSwift PhraseLiteral.ts buildPhraseLiteral + validation AppEnumSwift.ts generateAppEnumStruct AppEntitySwift.ts generateAppEntityStructs + helpers TypedIntentSwift.ts generateTypedIntentStruct AppShortcutsProviderSwift.ts renderAppShortcutsProviderFile + buildAppleRefBlock (top-level file assembly) Each mod owns ONE concern. Each codegen module is pure functions — no fs, no mod plumbing, no side effects. Mirrors expo-splash-screen's plugin/src/ shape (audit §1). Longest single file: 240 lines (AppEntitySwift — the most complex generator). Was 1154. Idempotency (§3 — partial: hasFile only this PR) withIOSAppShortcutsCodegen.ts uses project.hasFile(relativePath) before calling IOSConfig.XcodeUtils.addBuildSourceFileToGroup, replacing the JSON.stringify().includes() string-scan. Matches the pattern in expo-notifications/plugin/src/withNotificationsIOS.ts:102-108. AndroidConfig helpers (the rest of §3) deferred to the Android-side refactor PR. Errors (§4 — partial: structure standardized this PR) New plugin/src/utils/errors.ts exposes pluginError({what, why, how}) for consistent multi-line error messages with the [expo-assistant] prefix the test harness keys off (test:116-118). Every throw in the new codegen modules uses it. Existing message substrings preserved byte-for-byte so the 126 existing tests pass unchanged. createRunOncePlugin (§7) Top-level withExpoAssistant now wrapped in createRunOncePlugin(name, version). Universal first-party convention (expo-tracking- transparency, -camera, -notifications, -dev-client, -asset all do this). Accidentally composing the plugin twice is now a no-op instead of doubling every side effect. What's deferred to subsequent PRs §2 External-file codegen templates — declined per audit; inline stays cleaner for our use case §3 AndroidConfig.Permissions.withPermissions + Manifest helpers — lands with the Android mirror refactor (#9) §6 Mirror iOS structure on Android + android.appActions[] — own PR §7 Snapshot tests via memfs — depends on this PR's structure §8 iOS deploymentTarget + Android minSdkVersion checks §9 Centralized validators with fail-fast aggregation §10 E2E layer for withDangerousMod paths Verification ✓ bunx jest — 126/126 tests pass with zero changes to test code ✓ bun run build:plugin clean ✓ bun run lint clean (12 preexisting warnings in src/, zero new) ✓ bunx expo prebuild --platform ios generates Swift identical in shape to pre-refactor ✓ xcodebuild SUCCEEDED on iPhone 17 simulator ✓ Clean install: linkd ingested metadata cleanly, zero Skipping phrase errors
Codecov Report❌ Patch coverage is ❌ Your patch check has failed because the patch coverage (79.60%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## development #46 +/- ##
=================================================
+ Coverage 66.78% 71.02% +4.23%
Complexity 39 39
=================================================
Files 9 13 +4
Lines 837 528 -309
Branches 207 141 -66
=================================================
- Hits 559 375 -184
+ Misses 241 120 -121
+ Partials 37 33 -4
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Adds plugin/AGENTS.md as the single source of truth for "how to add a new mod / codegen module / feature to the iOS plugin without regressing the audit's decisions." Covers: - File layout map with the two-layer rule (mods are side-effect, codegen/ is pure) - Adding a new declarative slice (the common case for #30 / #32 / #33 / #34) — step-by-step where to put what - When to add a new with*.ts mod vs extending an existing one - When to add a new codegen/ file vs extending types.ts - Conventions: pluginError({what,why,how}), SWIFT_IDENT validation, project.hasFile() idempotency, createRunOncePlugin, escapeSwift (and when not to use it) - Test conventions: the harness, direct unit-tests of codegen, matching pluginError prefixes in rejection patterns - First-party precedents we follow, with file:line citations (expo-widgets is the closest analog to our generators — withWidgetSourceFiles.ts:176-260 mirrors our TypedIntentSwift + AppEnumSwift pattern almost 1:1) - Things NOT to do (the file-split must stay split, no plain Error() throws, no JSON.stringify-based pbxproj scans, etc.) - What to do when something the audit didn't anticipate comes up Also: - plugin/src/ios/index.ts header docstring now points at AGENTS.md as the first thing readers should read before adding features - plugin/src/ios/codegen/types.ts header points at AGENTS.md before the audit (since AGENTS.md is the actionable doc) - Repo-root AGENTS.md "Repo-internal references" section bumped with plugin/AGENTS.md and the new plugin/ file layout
This was referenced May 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First-pass execution of the plugin codegen audit (
.plan/07-plugin-audit.md, gitignored). Ships four of ten audit follow-ups — the foundation slice:withIOSVoiceIntents.ts(1154 lines) into per-concern files +codegen/project.hasFile()pluginErrorhelpercreateRunOncePluginWhat landed
```
plugin/src/
index.ts composer (with createRunOncePlugin)
types.ts unchanged
utils/
errors.ts pluginError({what, why, how}) helper
ios/
index.ts thin composer
withIOSInfoPlist.ts Info.plist mod
withIOSEntitlements.ts entitlements mod
withIOSAppShortcutsCodegen.ts withXcodeProject mod (now uses hasFile())
withIOSIntentExtension.ts legacy IntentExtension (gated)
codegen/
types.ts swiftTypeFor, marshalExpr, intentStructName, isEnum/EntityType, escapeSwift, capitalize
PhraseLiteral.ts buildPhraseLiteral + validation
AppEnumSwift.ts generateAppEnumStruct
AppEntitySwift.ts generateAppEntityStructs + helpers
TypedIntentSwift.ts generateTypedIntentStruct
AppShortcutsProviderSwift.ts renderAppShortcutsProviderFile + buildAppleRefBlock
withAndroidVoiceIntents.ts unchanged (Android refactor is its own PR)
```
The 1154-line monolith is now seven mod files (longest 205, IntentExtension's inline Swift template) + six codegen modules (longest 240, AppEntitySwift — the most complex generator). Each mod owns ONE concern. Codegen modules are pure functions — no fs, no mod plumbing, directly unit-testable in isolation. Mirrors
expo-splash-screen's plugin/src/ shape.Test invariance
The refactor is byte-equivalent at the test level — all 126 existing tests pass with zero changes to test code. The generated Swift output is shape-equivalent to pre-refactor (verified via xcodebuild + simulator linkd ingestion: zero `Skipping phrase` errors, metadata extracted correctly).
This was a structural refactor of WHERE the code lives, not WHAT it does. The contract is preserved.
Verification
What's deferred to subsequent PRs
This PR lands the keystone (§5 file split) that subsequent refactors will build on. Audit doc remains at `.plan/07-plugin-audit.md` (local-only, gitignored — see issue #42 for the comment that points at it).
Architecture rationale
§1 + §5 of the audit captures the principle: one mod per file, one concern per mod. Composition happens in a thin top-level orchestrator. Pure functions produce content separately from mods that wrap them.
Why this matters going forward: every iOS feature in the roadmap (#30 AssistantSchemas, #31 result presentation, #32 AudioPlayback, #33 widgets, #34 controls) adds more Swift codegen. Each new intent type now lands as its own `codegen/XYZSwift.ts` + a one-line addition to the provider assembly — instead of growing the monolith further.
References: