Skip to content

fix(core): stop heuristically merging distinct named top-level classes#3055

Open
schani wants to merge 6 commits into
masterfrom
agent/fix-issue-1630
Open

fix(core): stop heuristically merging distinct named top-level classes#3055
schani wants to merge 6 commits into
masterfrom
agent/fix-issue-1630

Conversation

@schani

@schani schani commented Jul 20, 2026

Copy link
Copy Markdown
Member

Bug

When quicktype was run on a directory of JSON samples where two named top-level types were structurally similar (one's properties are a subset of another's), the type-graph rewriting logic could incorrectly merge them into a single class. Reported against C# output in #1630 using the Plaid API's AccountsGet.Request.json, AuthGet.Request.json, and TransactionsGet.Request.json samples:

  • AccountsGetRequest has client_id, secret, and access_token.
  • AuthGetRequest has the same properties plus options.
  • quicktype generated an empty AccountsGetRequest helper whose FromJson returned AuthGetRequest.
  • AuthGetRequest.Options was misnamed AccountsGetRequestOptions.

Root cause

CombineClasses' similarity pass considered distinct named top-level classes as merge candidates. Its 3/4 property-overlap heuristic correctly combines structurally similar inferred classes, but it must not erase the distinction between top-level names supplied by separate input files.

Fix

Prevent a similarity clique from containing more than one top-level class. A top-level class can still combine with inferred nested classes, preserving existing recursive-type inference, and exact duplicate top levels remain deduplicated earlier during inference.

Fixture coverage

Added the three issue inputs under test/inputs/json/priority/issue-1630/ and a focused csharp-multiple-json-top-levels fixture. It passes the directory as one JSON input—the same multi-source path used by the CLI—and its C# driver:

  • requires AccountsGetRequest.FromJson to return AccountsGetRequest;
  • requires AuthGetRequest.Options to use AuthGetRequestOptions;
  • requires TransactionsGetRequest.Options to retain TransactionsGetRequestOptions;
  • compiles and round-trips all three JSON files.

The fixture participates in C#/System.Text.Json CI and generated-output snapshots, so the intended generated-code change is visible in the output-difference report.

Verification

  • npm run build
  • npm run test:unit (218 tests)
  • npm run lint -- --diagnostic-level=error
  • focused generated-output fixture: OUTPUT_SNAPSHOT_DIR=/tmp/pr3055-head-snapshot FIXTURE=csharp-multiple-json-top-levels CPUs=1 npm run test:fixtures

The local environment does not have dotnet; the fixture's compile/round-trip path runs in C# CI.

Fixes #1630

#1630)

When multiple named top-level JSON inputs were structurally similar (one a
property subset of another), CombineClasses' similarity-clique heuristic
would merge them into a single class, silently dropping one of the named
top-level types and misnaming shared descendant types after the wrong
survivor.

Exclude named top-level classes from the heuristic similarity-merge
candidate pool in findSimilarityCliques; exact-duplicate top-levels are
still deduplicated during inference as before, and heuristic merging of
non-top-level (inferred/nested) classes is unaffected.

Added a regression test (test/unit/multiple-json-top-levels.test.ts) using
the Plaid API request JSON samples from the issue, asserting AccountsGetRequest
stays distinct from AuthGetRequest and that AuthGetRequest's nested options
class is named AuthGetRequestOptions rather than AccountsGetRequestOptions.

Fixes #1630

Co-Authored-By: gpt-5.6-sol via pi <noreply@openai.com>
@schani

schani commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

I’m not sure this is actually a bug. I’ll have to read up on what the semantics of using a JSON directory as input actually is.

@schani

schani commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

On the directory semantics: samplesFromDirectory in src/index.ts turns each
file in the directory into its own TypeSource, with the top-level name taken
from the filename (typeNameFromFilename). So a directory is not "many samples
of one type" — it is one named top-level per distinct filename. Files that share
a name have their samples merged into a single top-level; files with different
names are meant to produce different named types. That is the only knob the user
has to say "these are the same type" vs. "these are separate types," and here
they gave three separate filenames.

The collapse comes from combineClasses (an inference-only pass, on by default),
whose findSimilarityCliques heuristic previously treated every named class —
including these explicit top-levels — as a merge candidate. With
REQUIRED_OVERLAP = 3/4, AccountsGetRequest (client_id, secret, access_token)
is a subset of AuthGetRequest (those three plus options), so the two got
unified.

The reason I'm confident this is a real bug and not just a debatable heuristic is
the output it produces. Before the change, generating C#/System.Text.Json for the
three issue samples gives:

public partial class AccountsGetRequest       // emitted, but empty
public static AuthGetRequest FromJson(...) => JsonSerializer.Deserialize<AuthGetRequest>(...)  // under AccountsGetRequest
public partial class AccountsGetRequestOptions   // nested options class attached to AuthGetRequest, misnamed

AccountsGetRequest is emitted as a named type, but its FromJson deserializes
into AuthGetRequest, and the options object hanging off AuthGetRequest is
named AccountsGetRequestOptions. A named factory that returns a different type,
and a child type named after the wrong parent, are broken regardless of how one
feels about the merge heuristic.

The fix is narrow: it only removes graph.topLevels from the similarity-clique
candidate pool. Exact-duplicate top-level dedup during inference is untouched, and
heuristic merging of inferred/nested classes (array elements, repeated nested
objects — the case the heuristic exists for) is unchanged. After the change,
AccountsGetRequest.FromJson returns AccountsGetRequest and the nested class is
AuthGetRequestOptions. Reverting just the two-line candidate filter makes the
regression test fail again, so the behavior is load-bearing on that one change.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Generated-output differences

1 files differ — 0 modified, 1 new, 0 deleted
256 changed lines — +256 / −0

Open the generated-output report →

@github-actions

Copy link
Copy Markdown

Generated-output differences

1 files differ — 0 modified, 1 new, 0 deleted
256 changed lines — +256 / −0

Open the generated-output report →

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.

Missing generated classes

2 participants