Duplicate same-module type definitions (self-recursive newtype + real defn) when generating against GitHub's OpenAPI spec — 11 collisions, 493 compile errors
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 45/100
Research direction
Start with Generator::generate_tokens and the typify-impl 0.6.2 renamer, using the GitHub OpenAPI reproduction and the three reduced specs described in the issue. Compare generated out.rs and cargo check output, then trace how seen-names state behaves across the cross-cutting references. Done means the 11 duplicate definitions and resulting compilation errors no longer occur for the reproduction.
Written by the indexing model from the issue text.
Description
Summary
When running progenitor (which uses typify internally) against GitHub's published OpenAPI 3.0.3 spec (9.1 MB, 1183 operations, 5193 generated types), typify emits two top-level type definitions with the same name in the same module for 11 of the 5193 types. The first definition is invariably a self-recursive newtype that wraps Option<Self>, which is itself an unsized recursive type. Compilation fails with 493 errors in the generated lib, all traceable to these 11 duplicate-name shadowings.
pub struct AutoMerge(pub ::std::option::Option<AutoMerge>); // ← bad placeholder, self-recursive
// ...60 lines later, same module...
pub struct AutoMerge { // ← real definition
pub commit_message: ::std::string::String,
pub commit_title: ::std::string::String,
pub enabled_by: SimpleUser,
pub merge_method: AutoMergeMergeMethod,
}
The first form is impossible (would have infinite size), so the placeholder shouldn't exist at all.
Error histogram (cargo check on the generated lib)
| Errors | Code | Meaning |
|---|---|---|
| 111 | E0119 | Conflicting trait impls (Debug, Display, Serialize, Deserialize) |
| 92 | E0599 | No associated item (enum variant lookups against the shadowing struct) |
| 83 | E0592 | Duplicate definitions with name builder (each duplicate has its own builder) |
| 63 | E0560 | Struct has no field X (call-sites assume the first defn's fields) |
| 42 | E0609 | No field X on type &T (same root) |
| 19 | E0428 | Name defined multiple times |
| 4 | E0072 | Recursive type has infinite size (the struct Foo(Option<Foo>) newtype) |
| 3 | E0391 | Cycle detected when computing layout |
| 493 | Total |
The 11 duplicated types
All are defined exactly twice in mod types {} in the generated lib:
AuthorAssociation AutoMerge CodespaceMachine IssueComment
IssueField IssueType LicenseSimple Milestone
NullableSecretScanningFirstDetectedLocation
ProjectsV2StatusUpdate SimpleCommit TeamSimple
Cross-referencing against the input spec, the 11 split into two patterns:
Pattern A: schema has top-level nullable: true (4 of 11)
auto-merge, author-association, issue-field, issue-type are each declared once in components.schemas with nullable: true at the schema root, e.g.
components:
schemas:
auto-merge:
type: object
nullable: true
properties:
commit_message: { type: string }
commit_title: { type: string }
enabled_by: { $ref: '#/components/schemas/simple-user' }
merge_method: { type: string, enum: [merge, squash, rebase] }
required: [enabled_by, merge_method, commit_title, commit_message]
Pattern B: nullable-X sibling schema with identical structure (7 of 11)
The spec defines both foo and nullable-foo as separate component schemas with identical bodies. Example: codespace-machine and nullable-codespace-machine are byte-for-byte the same except for the key. Affected types in this pattern: codespace-machine, issue-comment, license-simple, milestone, projects-v2-status-update, simple-commit, team-simple, plus nullable-secret-scanning-first-detected-location which exists only in the nullable form.
Reproduction
Generator: progenitor 0.14 → typify-impl 0.6.2 via Generator::generate_tokens (no CLI feature involved).
let raw = std::fs::read_to_string("api.github.com.yaml")?;
let spec: openapiv3::OpenAPI = serde_yaml::from_str(&raw)?;
let mut s = progenitor::GenerationSettings::default();
s.with_interface(progenitor::InterfaceStyle::Builder);
let tokens = progenitor::Generator::new(&s).generate_tokens(&spec)?;
let formatted = prettyplease::unparse(&syn::parse2(tokens)?);
std::fs::write("out.rs", formatted)?;
Then:
grep -E "^ pub (struct|enum) " out.rs | sort | uniq -c | awk '$1 > 1'
returns the 11 names above.
What I tried for minimization (and failed)
I built three reduced specs covering the two patterns:
- A schema with top-level
nullable: true, referenced once. - A
foo+nullable-foosibling pair, referenced once each. - A
nullable: trueschema referenced throughallOf.
In all three, typify's renamer correctly disambiguates by suffixing the inner type (e.g. pub struct AutoMerge(Option<AutoMergeInner>); pub struct AutoMergeInner { ... }) — the bug does not reproduce on a single-spec, two-or-three-schema reduction. The trigger seems to require many cross-cutting $refs and/or the renamer's seen-names state interacting across multiple processing contexts. I'd value pointers from a maintainer on which axes to bisect; if useful I can run instrumented experiments and report back.
Discovered via
pp, an OpenAPI → installable Rust CLI generator I'm building on top of progenitor. pp does spec normalization before handing the spec to progenitor (dedups media types, drops colliding enum values / property names, strips unsupported schema types, etc.), so the spec going into progenitor is clean. Generation itself succeeds without panic; only the produced Rust source fails to compile.
Why this matters
Past the well-known "needs a downgrade for OpenAPI 3.1" hurdle, this is the most common blocker I've seen when pointing progenitor at large real-world specs that use a "nullable variant" naming convention — a pattern common in vendor-published specs.
Happy to share the full generated out.rs (43 MB), a Cargo project that reproduces, or run any diagnostic patch a maintainer wants.
- Dominant language
- Rust
- Stars
- 905
- Forks
- 116
- Avg merge
- 13h 19m
- Merged PRs (30d)
- 16
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from oxidecomputer/typify
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
oxidecomputer/typify#1077 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 55/100
oxidecomputer/typify#1075 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 50/100
oxidecomputer/typify#1060 ·
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 48/100
oxidecomputer/typify#1059 ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 65/100
oxidecomputer/typify#1022 · 1 comment ·
Maintainers usually reply within 1 day
All issues in oxidecomputer/typify
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
dani-garcia/vaultwarden#7801 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 90/100
boxlite-ai/boxlite#1814 ·
Maintainers usually reply within 1 day