typeDiagram front end, one canonical model template, and upstream's names for union cases - #4
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Adds the
typeDiagramfront end — a.tddefinition or a.dmx.mddocument 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.tdon its own, or a*.dmx.mddocument with the definition and its templates in fenced blocks. Both front ends build onebinding::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.mjsis a development tool that captures upstream's model JSON sotypediagram_model.rscan 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.mustacheis now compiled into the binary and is what a definition renders through;<name>.mustachebeside it takes its place,<name>.<suffix>.mustacheis 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 onetypes::Runtime), so the annotated path and the diagram path cannot say different things about the same type.JSON is never a class member.
toJson/fromJsongo onextension <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 ownResult,OkandErr, 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:DMX8009plusjsonRefusalsindmx explain.Union cases carry the names typeDiagram gives them.
final class Circle extends Shape, notShapeCircle— 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:Okbelongs to two unions in the parity corpus, andStringis Dart's own, so those becomeErrorCodeOk/ResultOk/RequestIdStringwhile 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 isDMX8010rather than two classes under one name.Duplication gate. It was breached at 7.6% against a 7.0% budget on
mainbefore 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.tomlbesidesrc/dmx/tests/golden/**and everyexamples/*/lib/**, the two typeDiagram suites share oneWorkspacefixture (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 emitthrow,asor!), watch support for definitions and documents, the VS Code extension watching both, andwebsite/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_dartregenerates all five parity fixtures through the real binary and compares them byte for byte with the committed goldens;make corpusthen puts those same files throughdart 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_forpins the constructs no other suite generates — tuple variants under Dart-legal names, generic and multi-parameter union cases, explicit discriminants including1_000, the untagged union — and the naming rule from both sides:final class Circle extends Shape {/Left/Numberare bare,ErrorCodeOk/ResultOk<T, E>/RequestIdStringare qualified, andShapeCircleappears nowhere.typediagram_golden::every_generated_class_is_a_value_with_its_json_beside_itreads each class body out of the generated source and asserts noJsonortoJsoninside it, whileextension ShapeJson on Shapeanddmx.Result<Circle, dmx.DecodeError>exist outside it.typediagram::namingunit 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_classdrivesdmx build models libover a.tdwith nothing beside it and proves the whole canonical shape (operator ==,Object.hash(,copyWith,extension ParcelJson), then idempotence (0 of 2 file(s) updated), then--checkclean, then that aparcel.mustachereplaces the canonical template and removing it hands the file back.typediagram_standalone::every_refusal_is_coded_and_names_a_filewalks every diagnostic —DMX8001,DMX8003,DMX8004,DMX8005,DMX8010,DMX4003— asserting each names the file to open, never mentions "fence", and writes no output.typediagram_clidoes the same black-box work for documents, includingevery_refusal_is_coded_and_located(eight refusals, each located at a document line) and prose-outside-the-group surviving untouched.typediagram_modeldiffs 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.models/shipping.tdand its 196 Dart tests pass, including groups for value semantics and for JSON living beside the class;dart analyze --fatal-infosis clean.make vsix-e2edrives 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::runsynthesises oneInvocationper group and dispatches throughREGISTRY, so there is no second rendering path. Language leakage is confined totypediagram::target::Target(one row,dart), which now also carriescodec_text(alias-following, refusing what has no codec) andcanonical(include_str!of the shipped template).types::Runtimeis 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::placefinishes every per-memberequalsExpr/hashExpr/copyParam/copyArg/toStringExprand per-classotherParam/hashCombiner/canCopy/usesRuntime, andcodec_namesprepares every runtime-qualified name so no template author decides what prefix reaches the runtime.voidis excluded from equality, hash andtoString, and suppressescopyWithentirely, because avoidexpression cannot be passed on.typediagram::naming::Namesdecides class names once per model before any declaration is built: seedtakenwith the visible declarations plus the Dart names generated code writes itself, count bare case names, qualify a case whose name is taken or shared, andbail!(DMX8010)when the qualified name is taken too. Context builders read it viaNames::case(union, variant); nothing downstream re-derives a name.ramhornscannot resolve partials from compiled-in templates, somodel.mustacheanddiagram_model.mustacheplace 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 indocs/plans/typediagram-integration.md, along with tdbin interop, whose name-alignment prerequisite this PR satisfies.