Skip to content

fix: resolve the references project.xcproj left undefined - #1184

Merged
pepicrft merged 1 commit into
mainfrom
fix/xcproj-unresolvable-references
Sep 18, 2026
Merged

pepicrft merged 1 commit into
mainfrom
fix/xcproj-unresolvable-references

Conversation

@pepicrft

Copy link
Copy Markdown
Contributor

Four ways a project.xcproj could come out referring to something the file never defines, so Xcode refused to open it. I hit these while checking #1179, #1180, #1181 and #1182 against a local Xcode 27.2, by writing projects with the library and opening each one with xcodebuild -list.

None of them are caught by what we have today. Apple's schema validator accepts every one of these files, and our own round trip resolves a reference against the same graph that produced it, so neither ever looks at whether the reference resolves within the file. Only Xcode does.

What was wrong

1. A name path through an ambiguous group. indexNamePaths already notices that two sibling groups spelled the same way cannot be told apart, and gives them identifiers. It then kept walking and used that same ambiguous name as the prefix for their children, so a child came out as "Products/App.app" and resolved to nothing:

Invalid reference: "Products/App.app"

A name path is only as good as every component in it, so the ambiguity now travels down and everything below an ambiguous group falls back to an identifier too. This is not exotic: a project with several subproject references has one group named Products per subproject, which is exactly the shape Xcode16ProjectReferenceOrder/Wrong.xcodeproj has.

2. A products group that is the main group. productRefGroup = mainGroup is how a project without a separate products group spells it, and Fixtures/iOS/ProjectWithoutProductsGroup.xcodeproj is one. We wrote "products-group": "id:<mainGroup>", but the main group is the root of the tree rather than an entry in it, so it never carries an identifier and nothing resolved:

A reference to an object with identifier "E4C06A7B1FC1CA8500A9AB51" couldn't be resolved

I checked what Xcode itself writes by converting the fixture with xcodebuild -convert-project "Xcode Project", and it writes "products-group": []. The empty name path is how the root group is addressed, so that is what we write now.

3. An element no group in the tree holds. A subproject's file reference is named by projectReferences and by a dependency's container portal, but nothing requires a group to list it as a child, and in Xcode16ProjectReferenceOrder/{Test,Wrong}.xcodeproj none does. We referred to it by identifier and then never wrote the element, so again nothing resolved.

Xcode's own converter collects these into a group called Recovered References and addresses them by name through it. I went with the same thing rather than inventing our own spelling, and our output for that group is now byte identical to Xcode's. Worth a second opinion on this one, since synthesizing a group does add structure the project.pbxproj never had. The alternative I considered was throwing instead of writing a file we know Xcode will reject, but that trades a bad file for a hard failure on projects that Xcode is perfectly happy to open once the reference is recovered.

Because that group is synthesized rather than held by the tree, the sibling collision count never sees it, so a project that already has a top level group of that name would have hit problem 1. Both sides now fall back to identifiers in that case.

4. The decoder could not resolve the empty name path either. lookUp(namePath:) started with no current element, so a path with no components returned nil and productsGroup was quietly dropped on the way back in. It now starts at the root group.

That one mattered more than it looks. Before this change, a preserveAll round trip of ProjectWithoutProductsGroup did not just produce a file Xcode would reject, it threw:

The groups and files tree has no element matching 'id:E4C06A7B1FC1CA8500A9AB51'

So that shape was broken in both directions and we could not read back our own output.

How it was verified

Everything below is against Xcode 27.2, opening real generated projects with xcodebuild -list, since that is the only thing that catches any of this.

  • Every fixture in Fixtures/ that Xcode can read now opens. ProjectWithoutProductsGroup, Xcode16ProjectReferenceOrder/Test and Xcode16ProjectReferenceOrder/Wrong were rejected before and open now. The four ProjectWith*SwiftPackage* fixtures still stop at package resolution, which is a missing sibling directory in my scratch copy and not a project read.
  • Hand built projects for each shape: same named sibling groups, an orphaned project reference, and an orphaned project reference in a project that already has a Recovered References group. All three were rejected before and open now.
  • The new tests include a general invariant, that every id: reference in the encoded file is defined somewhere in the same file. It fails on all three fixtures without the source change and would have caught all four of these.
  • I confirmed each new test fails for the right reason with the source change reverted, rather than passing for some unrelated one.
  • ProjectWithoutProductsGroup is added to the preserveAll identifier round trip, where it used to throw.
  • 383 XCTest and 95 Swift Testing tests pass, and mise run lint passes.

One thing I deliberately left alone: Wrong.xcodeproj loses App-Info.plist on a round trip. It sits in an InfoPlists group that nothing references, and Xcode's own conversion drops it too, so an unreachable element simply has no place in a format that stores the reachable tree. That is why that fixture is not in the strict identifier round trip.

馃 Generated with Claude Code

Four ways an encoded project could refer to something the file never
defines, each of which makes Xcode refuse to open it:

- A name path built through a group that shares its name with a sibling.
  The ambiguity now travels down, so descendants fall back to identifiers
  the same way their ancestor does.
- A products group that is the main group. The root carries no identifier,
  so it is addressed by the empty name path, which is what Xcode writes.
- An element no group in the tree holds, such as a subproject's file
  reference. These are collected into a "Recovered References" group, the
  same as Xcode's own converter does.
- The decoder could not resolve the empty name path either, so the products
  group was dropped on the way back in.

Apple's schema validator and the round trip both resolve a reference
against the graph that produced it, so neither noticed. Verified by opening
the generated projects with Xcode 27.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pepicrft
pepicrft merged commit 6e93151 into main Sep 18, 2026
12 checks passed
@pepicrft
pepicrft deleted the fix/xcproj-unresolvable-references branch September 18, 2026 09:46
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