Skip to content

refactor(plugin): split iOS plugin into per-concern modules + codegen/#46

Merged
shottah merged 2 commits into
developmentfrom
refactor/plugin-codegen-foundation
May 20, 2026
Merged

refactor(plugin): split iOS plugin into per-concern modules + codegen/#46
shottah merged 2 commits into
developmentfrom
refactor/plugin-codegen-foundation

Conversation

@shottah

@shottah shottah commented May 20, 2026

Copy link
Copy Markdown
Owner

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:

# Audit item Status
§1 / §5 Mod factoring — split withIOSVoiceIntents.ts (1154 lines) into per-concern files + codegen/
§3 (part) Replace pbxproj JSON string-scan with project.hasFile()
§4 (part) Standardize errors on what/why/how with a shared pluginError helper
§7 Wrap top-level plugin with createRunOncePlugin

What 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

  • `bunx jest` — 126/126 tests pass with zero test changes
  • `bun run build:plugin` clean (`tsc --project plugin/tsconfig.json`)
  • `bun run lint` clean (12 preexisting src/ warnings, zero new)
  • `bunx expo prebuild --platform ios --no-install` clean
  • `xcodebuild ... build` — `** BUILD SUCCEEDED **`
  • Clean install on iPhone 17 simulator — linkd ingests metadata cleanly, zero `Skipping phrase` errors

What's deferred to subsequent PRs

# Audit item Why deferred
§2 External-file codegen templates Declined per audit — inline stays cleaner
§3 (rest) AndroidConfig.Permissions helpers Lands with the Android mirror refactor
§4 (rest) Soft warnings via addWarningIOS/Android Touch every Android error path too
§5 (low-pri) Drop redundant root app.plugin.js Low value, separate PR
§6 Centralize validators with fail-fast aggregation Depends on this PR's structure
§8 iOS deploymentTarget + Android minSdkVersion checks DX win, separate PR
§9 Mirror iOS structure on Android + android.appActions[] Own PR per audit — Android-shaped concern
§10 E2E layer for withDangerousMod paths Test-infra investment, pairs with #15 / #24

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:

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

codecov Bot commented May 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.60894% with 73 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.02%. Comparing base (6eb3700) to head (0a7406a).

Files with missing lines Patch % Lines
plugin/src/ios/withIOSIntentExtension.ts 22.72% 34 Missing ⚠️
plugin/src/ios/codegen/types.ts 74.00% 10 Missing and 3 partials ⚠️
plugin/src/ios/withIOSInfoPlist.ts 78.72% 7 Missing and 3 partials ⚠️
plugin/src/ios/codegen/AppEntitySwift.ts 88.00% 4 Missing and 2 partials ⚠️
plugin/src/ios/withIOSAppShortcutsCodegen.ts 86.11% 4 Missing and 1 partial ⚠️
plugin/src/ios/withIOSEntitlements.ts 75.00% 4 Missing and 1 partial ⚠️

❌ 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

Impacted file tree graph

@@                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     
Flag Coverage Δ
js 79.60% <79.60%> (+9.29%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
plugin/src/ios/codegen/AppEnumSwift.ts 100.00% <100.00%> (ø)
...lugin/src/ios/codegen/AppShortcutsProviderSwift.ts 100.00% <100.00%> (ø)
plugin/src/ios/codegen/PhraseLiteral.ts 100.00% <100.00%> (ø)
plugin/src/ios/codegen/TypedIntentSwift.ts 100.00% <100.00%> (ø)
plugin/src/utils/errors.ts 100.00% <100.00%> (ø)
plugin/src/ios/withIOSAppShortcutsCodegen.ts 86.11% <86.11%> (ø)
plugin/src/ios/withIOSEntitlements.ts 75.00% <75.00%> (ø)
plugin/src/ios/codegen/AppEntitySwift.ts 88.00% <88.00%> (ø)
plugin/src/ios/withIOSInfoPlist.ts 78.72% <78.72%> (ø)
plugin/src/ios/codegen/types.ts 74.00% <74.00%> (ø)
... and 1 more

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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
@shottah shottah merged commit cd45810 into development May 20, 2026
8 checks passed
@shottah shottah deleted the refactor/plugin-codegen-foundation branch May 20, 2026 21:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant