5 Python Lints I Wrote After Shipping 4 iOS Apps in 75 Days — And the Bugs They Caught
TL;DR: I shipped 4 production iOS apps in 75 days from a Windows machine. Each app submitted ate at least one Apple rejection. Each rejection had a specific root cause that I retrospectively realized was staticly detectable. So I wrote 5 small Python lints that catch those root causes before I push the tag.
The 5 lints, in order of how much pain they would have saved past-me:
-
swift_modular_lint.py— flagstruncatingRemainder+%on signed accumulators -
test_bundle_audit.py— flagsBundle(for:)inside test targets / @testable import contexts -
match_audit.py— flags missing bundles infastlane/Matchfilevsproject.ymltargets -
asc_capability_consistency.py— flags code-implied capabilities not registered on Apple side -
asc_health_check_one_shot.py— flags submission-readiness blockers (missing build / locale gaps / IAP state)
All MIT, ~200-250 lines each, no deps beyond Python stdlib + PyJWT + PyYAML. I'll drop links to each at the bottom.
Why Lints Beat Rejection Lessons
When I started I had a smart "I'll learn from each reject" attitude. By rejection #5 on AutoChoice, that attitude was clearly delusional — I was re-encountering lessons I had already learned, because:
- The lesson was buried in a Substack draft I hadn't published
- I had implemented the fix on app A but not propagated to apps B/C/D
- The rejection email's paraphrasing was misleading me to the wrong fix
- I was reading commit messages instead of running the test myself
Lints solve all four. The script runs in 5-15 seconds. It can't paraphrase. It runs on every app the same way. It tells you the specific file + line.
Lint #1: swift_modular_lint.py
Bug it catches: Swift's truncatingRemainder(dividingBy:) returns a negative residual when the dividend is negative. (-2310).truncatingRemainder(dividingBy: 360) == -150, not 210.
Real cost: AutoChoice v1.0.1 → v1.0.5, 5 Apple rejections, 15 calendar days of confused debugging. The reviewer was tapping Spin enough times to make currentRotation accumulate to a negative value. Each subsequent spin landed the pointer on the wrong slice.
How the lint detects it:
TRUNC_PATTERN = re.compile(r"(\w+)\s*\.truncatingRemainder\s*\(\s*dividingBy\s*:")
def likely_signed(varname: str) -> bool:
low = varname.lower()
if any(hint in low for hint in SAFE_VAR_HINTS): # count, length, size
return False
return any(hint in low for hint in SIGNED_VAR_HINTS) or True
# SIGNED_VAR_HINTS = rotation, angle, offset, delta, index, cursor, ...
Plus integer % checks for the same trap on Int, with false-positive filters for String(format:) / .enumerated() / 0..<N ranges.
Real findings on my 4 apps:
ModelTests.swift:153: warn: `a.truncatingRemainder(...)` ← intentional, test of the trap
WheelView.swift:55: info: `idx % palette.count` ← false positive (enumerated context)
DaysUntilWidget.swift:338: info: `d % 50` ← real warning (`d` is days, can be negative)
IconGenerator.swift:99: info: `i % 2` ← false positive (loop index)
1 true positive + 1 real warning worth reviewing.
Lint #2: test_bundle_audit.py
Bug it catches: Bundle(for: ClassName.self) inside a file that has @testable import. The @testable recompiles the imported type into the test bundle, so Bundle(for:) returns the test bundle (no .lproj resources), not the host app bundle.
Real cost: AutoChoice CI v1.0.11 → v1.0.13, 4 days lost, 3 false-claim commit messages. 56 XCTAssertNotNil failures for "Missing key 'Wheel' in zh-Hans" that wasn't actually missing — the path lookup itself was returning nil.
How the lint detects it:
BUNDLE_FOR_PATTERN = re.compile(r"Bundle\s*\(\s*for\s*:\s*([\w\.]+)\.self\s*\)")
def is_test_file(path: Path) -> bool:
return any(p.lower() in ("tests", "test") for p in path.parts) \
or path.stem.lower().endswith(("tests", "test", "spec", "specs"))
def has_testable_import(content: str) -> bool:
return bool(re.search(r"@testable\s+import", content))
# Flag if Bundle(for:) is in a test file OR in any file with @testable import.
Real findings on my 4 apps: 0 (all already migrated to the correct pattern). The lint exists to prevent regression — if a new test file is added with Bundle(for:), it'll catch immediately.
Lint #3: match_audit.py
Bug it catches: fastlane/Matchfile lists app_identifier: [...] only for the main bundle, but the project has additional targets (widget, app extension, watch app) with their own bundle IDs. fastlane match doesn't auto-discover targets — it only manages profiles for bundles you explicitly list. Build_app fails with "Provisioning profile doesn't support the App Group" pointing at the main bundle's profile (misleading).
Real cost: DaysUntil v1.0.8 TestFlight CI failed for 3 days. I tried:
- Adding App Group capability via ASC API (still failed)
- Running init_signing.yml with force=true (still failed)
- Manually checking the bundle ID was registered (it was)
The actual fix was 5 lines across Matchfile + Fastfile. The bundle for com.jiejuefuyou.daysuntil.widget was missing from match.
How the lint detects it:
def project_yml_bundles(path: Path) -> set[str]:
data = yaml.safe_load(path.read_text())
bundles = set()
for target_name, target_def in (data.get("targets") or {}).items():
bid = (target_def.get("settings") or {}).get("base", {}).get("PRODUCT_BUNDLE_IDENTIFIER")
if bid and not is_test_bundle(bid): # Skip .tests / .uitests targets
bundles.add(bid)
return bundles
def matchfile_bundles(path: Path) -> set[str]:
text = path.read_text()
m = re.search(r'app_identifier\s*\(\s*\[(.*?)\]\s*\)', text, re.DOTALL)
if not m: return set()
cleaned = re.sub(r'ENV\[[^\]]+\]', '', m.group(1))
return set(re.findall(r'"(com\.[\w\.-]+)"', cleaned))
# Diff: bundles in project.yml but not in Matchfile → blocker
Real findings on my 5 repos:
autoapp-days-until: missing from Matchfile: ['com.jiejuefuyou.daysuntil.widget']
autoapp-prompt-vault: missing from Matchfile: ['com.jiejuefuyou.promptvault.ActionExtension']
(3 other repos: OK)
2 real bugs found in repos that were currently failing CI. This single lint output unblocked 2 broken CI pipelines that I had been firefighting separately.
Lint #4: asc_capability_consistency.py
Bug it catches: Your Swift code uses HealthKit / iCloud / App Groups, but the corresponding capability is not registered on the bundle ID on Apple's side. Build may succeed (Xcode auto-adds entitlements) but runtime capability silently fails — or build fails cryptically.
Real cost: AltitudeNow ITMS-90683 (HealthKit needs both Info.plist keys, even if you only write). I figured it out after a few hours of digging, but it would have been instant if I'd had this lint.
How the lint detects it:
def code_implied_capabilities(repo: Path) -> set[str]:
implied = set()
for f in repo.rglob("*"):
content = f.read_text(errors="replace")
if re.search(r'group\.com\.\w', content): implied.add("APP_GROUPS")
if "import HealthKit" in content or "NSHealth*UsageDescription" in content:
implied.add("HEALTHKIT")
if "iCloud.com." in content or "import CloudKit" in content: implied.add("ICLOUD")
if "import StoreKit" in content: implied.add("IN_APP_PURCHASE")
return implied
def asc_capabilities_for(bundle: str, token: str) -> list[str]:
# Query /v1/bundleIds?filter[identifier]={bundle} for resource id
# Then /v1/bundleIds/{id}/bundleIdCapabilities
...
# Diff: code implies capability X but ASC doesn't have it → blocker
Real findings on my 4 apps: 0 (all consistent — I've been keeping these in sync manually). The lint is a safety net for "I add HealthKit to a new app and forget to register it on Apple side."
Lint #5: asc_health_check_one_shot.py
Bug it catches: Submission-readiness blockers that you only discover after pushing your release tag. Specifically:
- v1.0.x exists in ASC but no binary attached
- Localizations < 8 (incomplete i18n)
- supportUrl missing in any locale (1.5 Safety reject)
- IAP state ≠ APPROVED (2.1(b) reject)
- reviewSubmission drafted but items=0
Real cost: DaysUntil v1.0.2 spent 3 days in PREPARE_FOR_SUBMISSION because the submission was drafted but the version wasn't attached as an item. I didn't realize until I tried to PATCH state=submitted and got a 409.
How the lint detects it:
def probe_app(slug, app_id, token):
# Get latest version
versions = asc_get(f"/v1/apps/{app_id}/appStoreVersions?limit=10")
latest = sorted(versions["data"], key=lambda v: v["attributes"]["createdDate"], reverse=True)[0]
# Check build attached
build = latest["relationships"].get("build", {}).get("data")
if not build: blockers.append("NO_BUILD_ATTACHED")
# Check 8 lang localizations + supportUrl
locs = asc_get(f"/v1/appStoreVersions/{latest['id']}/appStoreVersionLocalizations")
if len(locs["data"]) < 8: blockers.append("MISSING_LOCALES")
if any(not lc["attributes"]["supportUrl"] for lc in locs["data"]):
blockers.append("SUPPORTURL_MISSING")
# Check submission items
subs = asc_get(f"/v1/apps/{app_id}/reviewSubmissions?limit=5")
latest_sub = sorted_by_state(subs)
items = asc_get(f"/v1/reviewSubmissions/{latest_sub['id']}/items")
if not items["data"]: blockers.append("SUBMISSION_DRAFTED_NO_ITEMS")
Real findings on my 4 apps (live):
[OK] AutoChoice v1.0.14 WAITING_FOR_REVIEW build=+ locs=8/8 supURL=8/8 IAP=APPROVED
[OK] AltitudeNow v1.0.3 WAITING_FOR_REVIEW build=+ locs=8/8 supURL=8/8 IAP=WAITING
[!!] DaysUntil v1.0.2 PREPARE_FOR_SUBMISSION build=X locs=8/8 supURL=8/8 IAP=APPROVED
- blocker: NO_BUILD_ATTACHED_TO_v1.0.2
[OK] PromptVault v1.0.2 READY_FOR_SALE
Catches the DaysUntil blocker in 5 seconds. I run this before pushing any new tag.
The Master Orchestrator
I also wrote ios_preflight_master.py that runs all 5 in sequence with a single command:
python orchestrator/lib/ios_preflight_master.py repos/autoapp-days-until
Output:
============================================================================
iOS Preflight Master — 2026-05-16 21:44:12
Target: C:\Users\sh199\Desktop\autoapp\repos\autoapp-days-until
============================================================================
[OK] swift_modular_lint exit= 0 duration= 0.2s
[OK] test_bundle_audit exit= 0 duration= 0.2s
[!!] match_audit exit= 1 duration= 0.2s
head: bundle-missing-matchfile: ['com.jiejuefuyou.daysuntil.widget']
[OK] asc_capability_consistency exit= 0 duration= 6.0s
[!!] asc_health_check_one_shot exit= 1 duration= 12.9s
head: NO_BUILD_ATTACHED_TO_v1.0.2
============================================================================
Passed: 3/5
Failed: ['match_audit', 'asc_health_check_one_shot']
Re-run failing lints individually for full output:
python orchestrator/lib/match_audit.py repos/autoapp-days-until
python orchestrator/lib/asc_health_check_one_shot.py
Total runtime: ~20 seconds. Catches every category of bug that has rejected my apps so far.
What This Replaces
Pre-lint workflow:
- Code → commit → push tag → wait 10 min CI → fail → re-read CI log (10 min) → fix → repeat
- Submit to Apple → wait 3-15 days → reject → reread email → guess → fix → resubmit
Post-lint workflow:
- Code → run
ios_preflight_master.py(20 sec) → fix any findings → commit → push tag → CI passes → submit - Apple reviews → less likely to reject for any of these 5 categories
For AutoChoice alone (which ate 5 rejections), this would have cut shipping time from 15 days to ~3 days. For 4 apps total, ~3 weeks of calendar time recoverable.
Repo Links
All 5 lints + the master orchestrator are MIT in my autoapp repo:
- swift_modular_lint.py
- test_bundle_audit.py
- match_audit.py
- asc_capability_consistency.py
- asc_health_check_one_shot.py
- ios_preflight_master.py
(Repo currently private; will go public after my 5th app or Substack hits 1k subs.)
If you want any of them now, hit reply or DM and I'll send you the file directly.
Bottom Line
Five small Python lints catch the categories of bugs that cost me 8 Apple rejections + 3 days of CI debugging. Each lint is < 250 lines. The total maintenance burden is ~1 hour to update when a new failure mode appears (which has happened twice so far).
The meta-lesson: every rejection has a static check that could have caught it. If a human can write down the rejection's root cause as a paragraph, a regex can find that pattern in your codebase before you push.
Lints aren't a substitute for understanding the bugs. But they're a 5-second cost on every commit that catches the bugs you already understand from re-occurring.
Cover image prompt (1280×720):
"A Python script terminal output with five lint results: 3 [OK] in green, 2 [!!] in red. Beside it, a small chart showing rejection count dropping from 5 (app #1) to 0 (apps #2-4) — connected by arrows. Editorial illustration style, dark mode terminal, monospaced font, slight glow on the green checkmarks."
Tags: python, swift, ios, lint, tooling, pre-commit
Internal links:
- Previous: dev.to 96 (truncatingRemainder), dev.to 97 (Matchfile widget)
- Series: "Indie iOS Lessons from 4 Apps in 75 Days"
Top comments (0)