| name | audit-xcodeproj |
| description | Audit an Xcode project file (pbxproj) for common post-merge issues.
Use when asked to "audit project file", "check pbxproj", "fix project file",
or after a merge/rebase that touched the project file.
|
| allowed-tools | Bash(ruby *), Bash(/tmp/*), Read, Glob, Grep, Edit |
Audit Xcode Project File
Detect and optionally fix common .pbxproj issues caused by merge conflicts or bad rebases.
Input
$ARGUMENTS โ optional path to a .xcodeproj directory or .pbxproj file.
If empty, find the project file automatically:
Glob for *.xcodeproj in the working directory (not recursive โ skip vendor/, DerivedData/, etc.).
If multiple matches, list them and ask which one to audit.
Check dependency chain
Checks are ordered by severity.
Later checks depend on earlier ones passing โ stop at the first failure tier.
Tier 0: Balanced delimiters ({}, (), ;) โ pure text, no gem needed
โ If this fails, STOP. Fix before proceeding.
โ
Tier 1: Gem parsing + unknown UUID warnings โ xcodeproj gem
โ If parsing fails, STOP. Fix before proceeding.
โ Unknown UUID warnings (stderr) are collected here.
โ
Tier 2: All semantic checks (independent, run in parallel)
โโ Duplicate group children (same UUID twice)
โโ Duplicate file names in groups (different UUIDs, same name)
โโ Duplicate files in build phases
โโ Orphaned build files (nil file_ref)
โโ "Recovered References" group
Tier 0: Balanced delimiters
This is the most common post-merge issue.
Run a simple Ruby script before attempting gem parsing.
The script reads the raw .pbxproj file and checks:
- Every
{ has a matching }
- Every
( has a matching )
- Report the line number where the mismatch occurs
Important: .pbxproj files use old-style plist format with // comments.
plutil -lint does NOT work on them. Use a custom script.
#!/usr/bin/env ruby
file = ARGV[0]
lines = File.readlines(file)
issues = []
braces = 0
parens = 0
lines.each_with_index do |line, i|
stripped = line.strip
next if stripped.start_with?('//')
next if stripped.start_with?('/*') && stripped.end_with?('*/')
line.each_char do |c|
case c
when '{' then braces += 1
when '}'
braces -= 1
issues << "Line #{i + 1}: unexpected '}' (braces went negative)" if braces < 0
when '(' then parens += 1
when ')'
parens -= 1
issues << "Line #{i + 1}: unexpected ')' (parens went negative)" if parens < 0
end
end
end
issues << "Unmatched '{' โ #{braces} still open at end of file" if braces > 0
issues << "Unmatched '(' โ #{parens} still open at end of file" if parens > 0
if issues.empty?
puts "Tier 0 OK โ delimiters balanced"
else
puts "Tier 0 FAIL โ #{issues.size} issue(s):"
issues.each { |i| puts " #{i}" }
exit 1
end
If Tier 0 fails, report the issues and STOP.
Use the line numbers to find and fix the truncated entries (usually a missing );, sourceTree, }; sequence).
After fixing, re-run Tier 0.
Tier 1: Gem parsing
Use the xcodeproj Ruby gem to parse the project.
Capture both stdout and stderr โ the gem prints "unknown UUID" warnings to stderr that are valuable diagnostics.
#!/usr/bin/env ruby
require 'xcodeproj'
project_path = ARGV[0]
begin
project = Xcodeproj::Project.open(project_path)
puts "Tier 1 OK โ project parsed successfully"
rescue => e
$stderr.puts "Tier 1 FAIL โ #{e.message}"
exit 1
end
Run with 2>&1 to capture unknown UUID warnings.
These warnings indicate dangling references (build phase entries or target attributes pointing to non-existent objects) โ a direct sign of merge damage.
If parsing fails, report the error and STOP.
If unknown UUID warnings appear, report them as issues to fix.
Tier 2: Semantic checks
All of these are independent and run in a single script after Tier 1 passes.
Important: Skip PBXAggregateTarget when checking build phases โ it doesn't have source_build_phase etc.
Filter with target.is_a?(Xcodeproj::Project::Object::PBXNativeTarget).
Combined script template
#!/usr/bin/env ruby
require 'xcodeproj'
project_path = ARGV[0]
project = Xcodeproj::Project.open(project_path)
issues = []
def check_groups(group, issues)
uuids = group.children.map(&:uuid)
dupes = uuids.select { |u| uuids.count(u) > 1 }.uniq
dupes.each do |uuid|
obj = group.children.find { |c| c.uuid == uuid }
issues << "[DUP_CHILD] Group '#{group.display_name}': #{obj.display_name} (#{uuid})"
end
names = group.children.map(&:display_name)
name_dupes = names.select { |n| names.count(n) > 1 }.uniq
name_dupes.each do |name|
refs = group.children.select { |c| c.display_name == name }
next if refs.map(&:uuid).uniq.size == 1
issues << "[DUP_NAME] Group '#{group.display_name}': '#{name}' (#{refs.map(&:uuid).join(', ')})"
end
group.groups.each { |g| check_groups(g, issues) }
end
check_groups(project.main_group, issues)
project.targets
.select { |t| t.is_a?(Xcodeproj::Project::Object::PBXNativeTarget) }
.each do |target|
[target.source_build_phase, target.resources_build_phase, target.frameworks_build_phase].compact.each do |phase|
refs = phase.files.map { |f| f.file_ref&.uuid }.compact
dupes = refs.select { |r| refs.count(r) > 1 }.uniq
dupes.each do |uuid|
bf = phase.files.find { |f| f.file_ref&.uuid == uuid }
issues << "[DUP_BUILD] #{target.name}/#{phase.display_name}: #{bf.file_ref.display_name} (#{uuid})"
end
end
end
project.targets.each do |target|
target.build_phases.each do |phase|
phase.files.each do |bf|
if bf.file_ref.nil?
issues << "[ORPHAN] #{target.name}/#{phase.display_name}: build file #{bf.uuid}"
end
end
end
end
recovered = project.main_group.children.find { |c| c.display_name == 'Recovered References' }
if recovered
issues << "[RECOVERED] Found 'Recovered References' group with #{recovered.children.count} children"
end
counts = {
'DUP_CHILD' => 0, 'DUP_NAME' => 0, 'DUP_BUILD' => 0,
'ORPHAN' => 0, 'RECOVERED' => 0
}
issues.each { |i| tag = i[/\[(\w+)\]/, 1]; counts[tag] += 1 if counts.key?(tag) }
puts ""
puts "=== Tier 2 Summary ==="
counts.each { |k, v| puts " #{k}: #{v > 0 ? "#{v} found" : 'OK'}" }
if issues.empty?
puts "\nNo issues found."
else
puts "\nDetails:"
issues.each { |i| puts " #{i}" }
exit 1
end
Reporting
Present results as a summary table:
| Tier | Check | Status | Details |
|---|
| 0 | Balanced delimiters | OK/FAIL | line numbers |
| 1 | Gem parsing | OK/FAIL | error message |
| 1 | Unknown UUID refs | OK/N found | list |
| 2 | Duplicate group children | OK/N found | โ |
| 2 | Duplicate file names | OK/N found | โ |
| 2 | Duplicate build phase files | OK/N found | โ |
| 2 | Orphaned build files | OK/N found | โ |
| 2 | Recovered References | OK/Found | โ |
Fixing issues
If issues were found, ask the user if they want to apply fixes.
- Tier 0 (truncated entries): Use
Edit on the raw .pbxproj file.
Look at the line number, read context, and restore missing );, sourceTree = "<group>";, }; sequences.
Cross-reference with the default branch or the introducing commit (git log -S <UUID>) to find correct content.
- Tier 1 (unknown UUIDs): Remove the dangling UUID lines from the raw file with
Edit.
These are lines in files = ( or fileSystemSynchronizedGroups = ( arrays referencing non-existent objects.
- Tier 2: Write a second Ruby script using
xcodeproj to programmatically remove duplicates/orphans, then project.save.
After fixing, re-run the full audit to confirm all issues are resolved.