Skip to content

typeDiagram front end, one canonical model template, and upstream's names for union cases - #4

Merged
MelbourneDeveloper merged 4 commits into
mainfrom
typeDiagram
Aug 21, 2026
Merged

MelbourneDeveloper merged 4 commits into
mainfrom
typeDiagram

Conversation

@MelbourneDeveloper

Copy link
Copy Markdown
Collaborator

TLDR

Adds the typeDiagram front end — a .td definition or a .dmx.md document generates whole Dart files — with one canonical model template that turns every record and union case into an immutable value whose JSON lives on an extension beside it, under the names typeDiagram's own emitters use.

Details

A second way in. dmx already generated into an annotated Dart file. It now also generates whole files from a typeDiagram definition: models/shipping.td on its own, or a *.dmx.md document with the definition and its templates in fenced blocks. Both front ends build one binding::Group, resolve one model, and dispatch through the same macro registry an @dmx('model') annotation goes through — parsing, resolution, context, render, hygiene, validation and safe emission are dmx's own. typeDiagram's CLI, library and language emitters are never invoked; scripts/typediagram-oracle.mjs is a development tool that captures upstream's model JSON so typediagram_model.rs can prove the two parsers agree.

One model template. A definition with nothing beside it used to generate nothing, so every project wrote and maintained its own Mustache — the golden corpus and the storefront each carried a near-copy of the same one, and neither produced a value. src/dmx/templates/diagram_model.mustache is now compiled into the binary and is what a definition renders through; <name>.mustache beside it takes its place, <name>.<suffix>.mustache is an extra output.

Generated classes are values. ==, hashCode, toString, copyWith — built by the same Rust that builds them for @dmx('model') (macros::model::{codec, comparison, hash_component, copy_param, copy_arg}, parameterised by one types::Runtime), so the annotated path and the diagram path cannot say different things about the same type.

JSON is never a class member. toJson/fromJson go on extension <Name>Json on <Name>, and nested decodes name the extension. The runtime import is prefixed — import 'package:dmx/dmx.dart' as dmx; — because the parity corpus declares its own Result, Ok and Err, and a local declaration hides an imported name. A declaration dmx cannot build a codec for (type parameter, generic declaration, untagged union, Unit, non-string map key) keeps its class and value semantics, gets no extension, and says why: DMX8009 plus jsonRefusals in dmx explain.

Union cases carry the names typeDiagram gives them. final class Circle extends Shape, not ShapeCircle — a diagram is a source of truth two tools generate from, and tdbin interop needs them to agree. A case takes its union's name as a prefix only where Dart's single namespace forces it: Ok belongs to two unions in the parity corpus, and String is Dart's own, so those become ErrorCodeOk / ResultOk / RequestIdString while the other eleven match upstream exactly. A shared name qualifies on every side, so nothing is renamed for being declared second; a case with neither name available is DMX8010 rather than two classes under one name.

Duplication gate. It was breached at 7.6% against a 7.0% budget on main before this branch, and this branch's generated Dart took it to 11.0%. Now 6.6%: the typeDiagram goldens join the generated-output list already in .deslop.toml beside src/dmx/tests/golden/** and every examples/*/lib/**, the two typeDiagram suites share one Workspace fixture (tests/support/workspace.rs), the two watcher-repair tests share one parameterised helper, and several test bodies that differed only in their data are table-driven. Budget ratchets 7.0 → 6.8; deslop 0.5.1 measures 6.36–6.60% on an unchanged tree, so the headroom is deliberate and documented.

Also: dmx explain FILE, src/dmx/src/hygiene.rs (a CST check that user-authored templates cannot emit throw, as or !), watch support for definitions and documents, the VS Code extension watching both, and website/src/docs/models-from-a-diagram.md. Rust coverage floor 90 → 93 (measured 94.1%).

How Do The Automated Tests Prove It Works?

  • typediagram_golden::every_corpus_fixture_generates_its_golden_dart regenerates all five parity fixtures through the real binary and compares them byte for byte with the committed goldens; make corpus then puts those same files through dart analyze --fatal-infos (No issues found!), which is the half a byte comparison cannot do.
  • typediagram_golden::the_goldens_cover_the_shapes_the_corpus_exists_for pins the constructs no other suite generates — tuple variants under Dart-legal names, generic and multi-parameter union cases, explicit discriminants including 1_000, the untagged union — and the naming rule from both sides: final class Circle extends Shape { / Left / Number are bare, ErrorCodeOk / ResultOk<T, E> / RequestIdString are qualified, and ShapeCircle appears nowhere.
  • typediagram_golden::every_generated_class_is_a_value_with_its_json_beside_it reads each class body out of the generated source and asserts no Json or toJson inside it, while extension ShapeJson on Shape and dmx.Result<Circle, dmx.DecodeError> exist outside it.
  • typediagram::naming unit tests cover the rule directly: a case keeps its own name; a name two unions share qualifies on both sides; a case shadowing a record or a Dart name qualifies while its siblings do not; a declaration another target owns claims nothing; and a case with no name left is refused.
  • typediagram_standalone::a_definition_alone_generates_a_model_class drives dmx build models lib over a .td with nothing beside it and proves the whole canonical shape (operator ==, Object.hash(, copyWith, extension ParcelJson), then idempotence (0 of 2 file(s) updated), then --check clean, then that a parcel.mustache replaces the canonical template and removing it hands the file back.
  • typediagram_standalone::every_refusal_is_coded_and_names_a_file walks every diagnostic — DMX8001, DMX8003, DMX8004, DMX8005, DMX8010, DMX4003 — asserting each names the file to open, never mentions "fence", and writes no output.
  • typediagram_cli does the same black-box work for documents, including every_refusal_is_coded_and_located (eight refusals, each located at a document line) and prose-outside-the-group surviving untouched.
  • typediagram_model diffs dmx's resolved model against the JSON upstream's own parser produced for each fixture, so language drift is a failing test rather than a surprise.
  • The storefront regenerates from models/shipping.td and its 196 Dart tests pass, including groups for value semantics and for JSON living beside the class; dart analyze --fatal-infos is clean.
  • make vsix-e2e drives the packaged extension in a real VS Code over a real workspace: 6 passing, including "generates from a standalone .td definition, and answers an edit to either file".

For AI

Pipeline is unchanged and singular: parse → context → render → validate → emit. typediagram::run synthesises one Invocation per group and dispatches through REGISTRY, so there is no second rendering path. Language leakage is confined to typediagram::target::Target (one row, dart), which now also carries codec_text (alias-following, refusing what has no codec) and canonical (include_str! of the shipped template).

types::Runtime is the seam that lets one set of expression builders serve both backends: Runtime::IN_CLASS (no prefix, decoders on the type) for inline emission into a file somebody else owns, Runtime::PREFIXED (dmx., decoders on the extension) for whole-file generation. typediagram::semantics::place finishes every per-member equalsExpr / hashExpr / copyParam / copyArg / toStringExpr and per-class otherParam / hashCombiner / canCopy / usesRuntime, and codec_names prepares every runtime-qualified name so no template author decides what prefix reaches the runtime. void is excluded from equality, hash and toString, and suppresses copyWith entirely, because a void expression cannot be passed on.

typediagram::naming::Names decides class names once per model before any declaration is built: seed taken with the visible declarations plus the Dart names generated code writes itself, count bare case names, qualify a case whose name is taken or shared, and bail!(DMX8010) when the qualified name is taken too. Context builders read it via Names::case(union, variant); nothing downstream re-derives a name.

ramhorns cannot resolve partials from compiled-in templates, so model.mustache and diagram_model.mustache place the same prepared expressions in two layouts — the expressions are shared in Rust, the layout is written twice. That is recorded as an open item in docs/plans/typediagram-integration.md, along with tdbin interop, whose name-alignment prerequisite this PR satisfies.

…ases

A definition with nothing beside it used to generate nothing, and every project
that wanted model classes wrote and maintained its own Mustache. There is now
one model template, shipped in the binary, used wherever a diagram generates
model classes.

Every record and every union case comes out of it as an immutable value — ==,
hashCode, toString, copyWith — built by the same Rust that builds them for
@dmx('model'), so the annotated path and the diagram path cannot say different
things about the same type. JSON is never a member of a generated class: it
goes on an `extension <Name>Json`, and nested decodes name the extension. The
runtime import is prefixed, so a diagram that declares its own Result, Ok or Err
cannot hide the one the codec means.

Union cases are named the way typeDiagram's own emitters name them — the case's
own name — and take their union's name as a prefix only where Dart's single
namespace forces it: `Ok` belongs to two unions in the parity corpus, and
`String` is Dart's own. A case with neither name available is refused (DMX8010)
rather than generated as two classes under one name. That alignment is what
tdbin interop needs.

Also brings the duplication gate back under its budget. It was breached at
7.6% before this branch: the typeDiagram goldens join the generated-output list
already in .deslop.toml, the two typeDiagram suites share one Workspace fixture,
and several test bodies that differed only in their data are table-driven. The
budget ratchets 7.0 -> 6.8.
@MelbourneDeveloper
MelbourneDeveloper enabled auto-merge (squash) August 21, 2026 12:30
@MelbourneDeveloper
MelbourneDeveloper merged commit 65f4223 into main Aug 21, 2026
7 checks passed
@MelbourneDeveloper
MelbourneDeveloper deleted the typeDiagram branch August 21, 2026 12:36
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