docs(justdummies): specify the dum scaffolding tool - #389
Merged
Conversation
Specify `dum`, the JustDummies command-line tool, completely enough to implement without further design decisions. The tool scaffolds a named, composable generator for a type from the developer's own code, then hands the file over: it is a scaffolder, not a code generator, which removes drift, a `check` verb and the source-generator question at once. The load-bearing decisions, each checked against the library's source: * the emitted type implements IAny<T> and is immutable, which re-arms the JustDummies.Usage analyzers on it and keeps it composable; * the file is NOT marked as generated code — all 27 analyzers exempt generated code, so marking it would create the one file in the test project the safety net cannot see; * no member is emitted unless it resolves in the target compilation, which covers the netstandard2.0/net8.0 asset split, the public-API baseline and version skew with one rule; * constructor guard clauses seed each generator, and an unresolved parameter is left as a compile error rather than a runtime failure; * the tool takes no dependency on the JustDummies package, resolving every symbol by metadata name as the analyzers do. The specified skeleton was written out and compiled against the library with the analyzers wired: it raises no JD diagnostic, replays under a reproducibility scope, and is accepted by Any.ListOf, Any.PairOf and .As. Two measurements back the text — the same control file raises a JD005 error without an <auto-generated/> header and nothing with it, and the guard-less factory chain threw 594 times in 10 000 draws. Naming stays fixed at the Any prefix; customization is reserved for v1.1 and its shape is specified so v1.0 does not block it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
Two changes to the `dum` specification, both aimed at letting a fresh session pick it up after JustDummies moves to its own repository. Split the tool in two (D11). `JustDummies.GenAny` is the engine — resolution, guard reading, composition, emission — and `JustDummies.Cli` is a shell over it. The reason is not that the CLI may grow verbs, which would justify no boundary; it is that the engine's plausible second consumer is a Roslyn code refactoring, which is not a CLI and cannot load a net8.0 assembly. Keeping that door open costs nothing now and a full API re-verification later, so the engine targets netstandard2.0 at the analyzer's Roslyn floor and touches neither MSBuild nor the console. Two benefits arrive regardless of any future consumer: the test plan stops mixing resolver behaviour with argument parsing, and mutation testing gets one high-value target instead of one diluted one. The engine returns a result model carrying per-parameter provenance, so the console recap becomes data the CLI renders rather than something the engine prints. Named it GenAny, not Scaffolder: the sibling engine in this repository is GenDoc, a function name rather than a pattern name, and GenAny generates the AnyX types. "Scaffolder" survives in the prose, where it describes behaviour rather than naming a product. Made the document self-contained. A new section inlines every library fact the specification rests on — entry points, constraint surfaces, the five semantic invariants, the analyzer inventory — each with the command to re-derive it, so nothing in the design sections needs the library's source to be checked. Another states every dependency on the hosting repository as a requirement rather than a path, so they can be re-established wherever the library lands. The verification section now explains how to rebuild the harness, including the control-file step that distinguishes "no diagnostics" from "analyzers never loaded" — the trap this verification fell into on its first attempt. Also recorded two library facts the emitter must respect: unsigned generators expose no Positive/Negative, so a `p <= 0` guard on a uint resolves to nothing; and the re-derivation commands were run to confirm the inlined counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
Seven decisions in the dum specification are architectural: a maintainer would question each, and each would stand if the implementation were rewritten. They now carry full decision records — Context, Decision, Rationale, Alternatives Considered, Consequences, Follow-up Actions, References — in this repository's ADR format, section for section. The records live inside the specification rather than in the ADR base, because the repository that should hold them does not exist yet. JustDummies is expected to move out of this repository before the tool is built, and these records describe a tool that will live there. Entering them into the current base would assign numbers — the stable handles the base rests on — that migration would have to abandon or rewrite, and would leave this repository's log carrying decisions about absent code. Keeping them with the specification means the decision history travels as one artefact, and following the format exactly means admission is mechanical: lift each record, number it there, keep its Proposed date, leave a link. The decision table in section 2 loses its argument subsections in the same move: with the records in the same document, keeping both would be one argument in two places, drifting. The table is now the index and holds no reasoning of its own. Writing the records surfaced a gap in the console contract. The engine resolves every member against the developer's compilation before emitting it, so a generator that exists in the library but not in the asset their project resolves silently becomes a TODO. The recap could not distinguish that from a parameter the tool simply could not infer, so the provenance column gains an `unavailable` value — the difference between a dead end and an instruction to retarget. Four decisions deliberately carry no record. Ambient-only generation, the emitted namespace and the no-OrNull rule are scope and default choices whose record would restate the implementation rather than outlive it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
The no-OrNull rule was left out of the specification's decision records as "one line of emitter behaviour". That measured the implementation rather than the decision, which is the wrong test: the question is whether the decision would survive the implementation, not how much code it takes. It survives on all three counts. It would outlive a rewrite of the emitter in any language. It is the kind of rule a maintainer would question, since emitting OrNull for a parameter declared `string?` is the faithful-looking reading of the type and would be filed as a bug fix rather than recognised as a regression. And it has a visible consequence elsewhere in the document: the explicit conversion a nullable value type needs, which reads as accidental complexity to anyone who does not know that OrNull is forbidden — and which is shorter, better-typed and entirely wrong to "simplify" back. That last point is what settles it: a record that stops one plausible cleanup from silently reintroducing intermittent failures is doing the job records exist for. The record states the case in the format's own terms, including the alternative worth taking seriously — emitting OrNull only where the constructor shows no null guard — which is rejected because the absence of a guard is as consistent with an oversight as with intent, and would make a test's stability depend on whether an unrelated guard was written. Two decisions still carry no record, and the section now says why rather than asserting it: ambient-only generation is a scope boundary already scheduled for revisiting, and the emitted namespace is a default with a per-invocation override. Corrects the counts throughout, along with a stale "four decisions … all three" left by the previous pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
A consistency pass over the whole specification. Three findings would have produced a broken tool; the rest were internal contradictions. The guard-combination rule was wrong. It grouped recognised constraints by "axis" — length, range, charset — and dropped both whenever two landed on the same one. A lower bound and an upper bound land on the same axis and are complementary, so the rule discarded the single most common bounded idiom, written as two consecutive guards. Verified against the library: GreaterThanOrEqualTo(0).LessThanOrEqualTo(100) and NonEmpty().WithMaxLength both compose and draw. The rule now turns on the bound, not the axis: complementary bounds compose, the same bound twice drops both. A recognised pattern guard was not marked exclusive. StringMatching returns a generator exposing only DifferentFrom and Except, so chaining any length or charset constraint onto it does not compile at all — confirmed as CS1061. A pattern guard now replaces the base generator and discards every other string constraint on that parameter. `p < 1` mapped to Positive() for every numeric type. That holds only for integral ones: on a decimal, Positive() admits the values between zero and one the guard rejects. Measured at one draw in five thousand unconstrained, and roughly one in five once the parameter carries another bound — the profile of a defect that survives casual testing. The row is now typed, and a matching-order rule says the more specific row wins. The rest: the worked example resolved a parameter the console recap showed as a TODO, with nothing saying the two runs differ; section 8 promised an emitted XML sentence the example did not carry; section 9 claimed a console line for invariants the tool cannot read, which nothing produced — so the recap gains an `unread guards` provenance and section 9 now states plainly that validation delegated to a helper leaves no trace at all; the engine/CLI contract could not express a failed type resolution although the pipeline performs it inside the engine; a follow-up action asked for what the specification had since specified; and two evidence rows still pointed at subsections deleted when the decision records absorbed them. Also: a backslash does not escape a backtick inside a code span, so the IAny`1 metadata name rendered wrong in both languages; section 13 was the only numbered section writing its items in bold rather than as headings, leaving eleven cross-references without anchors; and the reading map skipped section 16. Four new evidence rows record what this pass measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
A focused pass over section 5.3 of the dum specification, the section the reading map calls the only one with real design risk. It was. The regex row had to go. `!Regex.IsMatch(p, "...")` reads like the ideal guard to translate — the library has StringMatching and the pattern is right there — but the library generates from the regular subset of the pattern language only. Four of five realistic validation patterns tried against it were rejected: lookahead, word boundary, backreference, Unicode category. Those are the ordinary vocabulary of a hand-written validator. Worse, and this is what settles it, the rejection lands at construction rather than at Generate(). The emitted parameterless constructor runs the whole recipe in its initialiser, so `new AnyOrder()` would throw before any With… call could override the offending parameter — verified. The emitted type would be unusable rather than imprecise, with no call the developer could write to rescue it. And the engine cannot screen the pattern in advance: D9 keeps it from asking the library's parser, and reimplementing that check would duplicate a parser it cannot see. Reading regex guards moves to the deferred list, and the episode leaves a rule behind that would have caught it by construction: the engine never emits an expression whose validity depends on a value it cannot check. Every remaining row emits a member D4 resolves, with an argument that is a compile-time constant of the right type. Size guards were written as if every parameter were a string. A collection generator exposes NonEmpty, WithCount, WithMinCount and WithMaxCount, and no WithLength at all, so a guard on an array's Length or a list's Count resolved to nothing and D4 dropped it silently — a real constraint lost without a trace. Size guards now map to the count family for collection parameters, and NonEmpty is noted as the one member spelled the same on both sides. Two smaller gaps: the exact-size idiom (`if (code.Length != 3) throw`) had no row despite WithLength existing, and nothing said where a guard-derived constraint attaches relative to the conversion and composition hops — it belongs to the parameter's own generator, with .As always last, because that is the step that changes the type. The collision rule also claimed to arbitrate charset and pattern axes that no recognised guard can produce; it now covers only the bounds that exist. Section 14.3 no longer says the emitter relies on constraint families v1.0 does not use. Four evidence rows record what this pass measured, and the test plan gains the collection-size case and a negative case pinning the regex exclusion so it cannot be undone by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
Same treatment as section 5.3, applied to the rest of the dum specification: verify the claims against a compiler rather than re-read them. Five more defects, three of them wrong rather than merely vague. The shape rules did not survive their own degenerate case. Section 5.1 allows a type whose constructor takes no parameters, and section 4.2 mandated a public parameterless constructor and a private all-arguments one unconditionally — which, with no arguments, are the same signature. Verified: CS0111. Section 4.2 now spells out the collapsed shape, and says why the case is still worth generating: the emitted type is an IAny<T>, so it composes into ListOf and Combine, which a bare `new Thing()` does not. The shadowing warning was aimed at the wrong set. Arity is part of a type's identity in C#, so a scaffolded AnySet and the library's AnySet<T> coexist without shadowing anything — verified. Eight of the library's forty Any* names are generic and can never collide, and the examples given were three false alarms out of four. The real set is the thirty-two non-generic names, where the collision is real and silent — also verified, on AnyPattern. The check must compare arity, or it cries wolf. The count itself was one short. AnyCollection is declared `public abstract class`, so a search for sealed classes misses it: forty type names, not thirty-nine, and thirty-eight generators rather than thirty-seven. Two eligibility gaps. A constructor with a ref or out parameter cannot be called from the emitted Generate(), which passes value arguments — CS1620, verified; such a constructor is now skipped, and `in` is noted as fine since a value argument binds to it. And a nested type, which a developer would name with a dot, needs a plus in a metadata lookup: passing the dotted form through would report a real type as missing. Section 13.2 now states the version direction it depends on: the CLI carries a current Roslyn and hands a Compilation to an engine compiled against the floor. That way round is supported; the reverse is why the floor is pinned rather than floated. Five evidence rows and three test cases follow, including the requirement that the collision golden file use a non-generic name — a generic one cannot exercise what it is meant to pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
A fourth pass, run the way the third one was: compile the claims rather than read them. This one found almost nothing left to fix, which is the result worth recording. Both resolution tables now have coverage rather than assertion. Every row of the base table was compiled as a field declaration assigning the emitted expression to the parameter's own IAny<T> — forty of them, nullable on and warnings promoted to errors, clean. Every scalar row was then drawn three thousand times against what it promises: NonEmpty never empty, Guid never Empty, Enum only declared members, Uri().Web() absolute and http(s). Every guard mapping was drawn four thousand times against the guard it claims to satisfy; all seventeen are sound, including the collection count family and the two-guard compositions. The record, static-factory and odd-parameter- name shapes compile and generate. One defect, and it was in the specification's own instructions. The command section 14.7 publishes for re-deriving the Any* type names is the command that under-counted in the first place: it matches `public sealed class` and misses AnyCollection, which is declared abstract. A reader following it would get thirty-nine, contradict the text's forty, and reasonably conclude the specification was wrong. The command now matches both forms and prints each name with its arity, so it also produces the eight/thirty-two split the shadowing check of section 7 depends on — the re-derivation yields the fact the specification actually uses, not a number that happens to differ. The entry-point inventory was diffed against the real facade and matches. The other re-derivation commands were re-run and still produce what section 14 documents: one net8-only guard, no Positive on the unsigned generators, twenty-seven analyzers exempting generated code, twenty-eight diagnostic identifiers. Four evidence rows record the exhaustive runs, so a later reader can tell which parts of the specification are argued and which are measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
D7 and D8 now carry decision records, so all eleven decisions in the specification are covered by ten records. Writing them showed my reason for leaving them out was wrong in a way worth stating rather than quietly reversing: I had been reading each decision by the size of its implementation instead of by whether it outlives one. D7 is not "v1.0 skips AnyContext", which is a scope limit and would indeed deserve no record. The lasting decision underneath is that the emitted generator holds no random source, no seed and no static state, and defers entirely to the library's ambient resolution. That is what makes the reproducibility guarantee free — a recipe built outside a scope replays inside it because the ambient source resolves at draw time — and it is why the emitted type needs no lifecycle rule and why the two seeding analyzers have nothing to report on it. A maintainer would reasonably ask why the generator does not capture its own seed; the record answers that two such generators in one test would draw from independent sequences, so no single reported seed could replay the run. D8 is not merely a default with an override. It is the sole cause of the shadowing hazard the specification spends a section on: a generator in a dedicated namespace could never shadow a library type, because the developer's using would compete on equal terms instead of losing outright to an enclosing declaration. The record owns that cost and says why the trade goes this way — a rare, warned collision against an extra import in every test file that touches the generator — and why the alternative an IDE would pick needs MSBuild knowledge the engine deliberately does not carry. The section that argued these two needed no record now explains the opposite, and generalises it: each of the three records added after the decision table carries a consequence elsewhere in the document that reads as accidental until the reasoning is written down. D10 explains a conversion in the resolution table, D8 explains the shadowing section, D7 explains the absence of a lifecycle rule. None is self-explanatory where it lands, which is the argument for the record rather than against it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
Final read of the dum specification. Three findings, all of them consequences of the last change rather than fresh design faults. The French records wrote their status in English. This repository's French ADRs translate the value — Proposé, Accepté — so ten headers diverged from the convention they exist to match, which matters because these records are written to be lifted into an ADR base verbatim. Two sections still carried argument the records had since absorbed. Section 8.2 restated why an AnyContext constructor is not worth its surface, and section 5.3 restated why a guardless emission is worse than no tool; both now state the fact and the instruction and point at D7 and at D5 + D6 for the reasoning. This is the same split already applied to the decision table: a specification says what to build, a record says why, and one argument in two places is one argument that will drift. The rest of the pass found nothing to change. The re-derivation commands still produce what section 14 documents — forty type names split eight generic and thirty-two not, no Positive on the unsigned generators, twenty-seven analyzers exempting generated code, twenty-eight diagnostic identifiers. The worked example still compiles clean, still replays under a reproducibility scope, and still draws five hundred values through a guarded factory without throwing. Both languages agree on every structural count: eighteen sections, forty-one subsections, fifty-two and seventy headings, nine tables, ten records, eleven decisions, no dangling reference, no ragged table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
…ol-prespec-uxwk4y
The branch was written against a library that has since moved twenty-nine commits, several of them through the exact surfaces the specification cites: the exception types, the regex parser, the string and count specs. Every measured claim was therefore re-run rather than assumed. They all hold. Forty Any* type names split eight generic and thirty-two not, twenty-seven analyzers exempting generated code, twenty-eight diagnostic identifiers, no Positive on the unsigned generators, and AnyPattern still exposing nothing but DifferentFrom and Except. The scaffolded example still compiles, still replays under a reproducibility scope, and still draws five hundred values through a guarded factory without throwing. Four of five realistic validation regexes are still refused, still at construction, after the parser was refactored. The re-run did correct one number. The unconstrained string generator draws zero to sixteen characters — seventeen equiprobable lengths, so an empty draw is one in seventeen, not one in sixteen as the text said in six places. The two independent runs bracket it: 594 and 557 failures per ten thousand against a predicted 588. Both are now quoted, since a single measurement can be a fluke and two spanning the prediction are evidence. An evidence row records that section 14 survived a library that moved underneath it, which is the property that section exists to have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
…ol-prespec-uxwk4y
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.
Summary
Specifies
dum, the JustDummies command-line scaffolder, completely enough to implement without further design decisions. It writes one C# file containing a named, composable generator for a type from the developer's own code, then hands that file over: a scaffolder, not a code generator, which removes drift, acheckverb and the source-generator question in one move.Documentation only — no code, no project, nothing built yet.
Type of change
Changes
doc/handwritten/for-maintainers/specifications/justdummies-tool.mdand its French pair — 18 sections covering the product, the emitted file, parameter resolution, the two-project architecture, the test plan, and the decision records.Three properties are worth calling out.
Two projects, not one.
JustDummies.GenAnyis the engine — resolution, guard reading, emission — targetingnetstandard2.0at the analyzer Roslyn floor and performing no I/O;JustDummies.Cliis a shell over it. The reason is not that the CLI may grow verbs, which would justify no boundary, but that the engine's plausible second consumer is a Roslyn code refactoring, which is not a CLI and cannot load anet8.0assembly. That door costs nothing to keep open now and a full API re-verification later.Self-contained. JustDummies is expected to move to its own repository before this is built, so §14 inlines every library fact the specification rests on — entry points, constraint surfaces, semantic invariants, the analyzer inventory — each with the command to re-derive it, and §13 states every dependency on the host repository as a requirement rather than a path.
It carries its own decision records. Ten records in this repository's ADR format, section for section, held inside the specification rather than entered into the ADR base — see "Architecture decisions" below.
Testing
Not applicable: the change adds no code.
dotnet build/dotnet testwere not run, and nothing in this pull request would exercise them.What was run, repeatedly, is verification of the specification's own claims. The emitted skeleton of §4.1 was written out by hand and compiled against
JustDummies.dllwithJustDummies.Analyzerswired in. Highlights:JDdiagnostic, with a control file proving the analyzers were actually loaded;<auto-generated/>, raises none — including aJD005error. That measurement is why the scaffolded file carries no generated-code marker;§17 tabulates 30 such checks and §17.2 says how to reproduce the harness. After merging
main(29 commits, several through the exception types and the regex parser this specification cites), every measured claim was re-derived and still holds.Five review passes over the document found 30 defects, the last three passes almost entirely by compiling claims rather than re-reading them. The commit history records what each pass found.
Documentation
doc/updated.md/.fr.mdpair, and both maintainer READMEs are indexedNo user-facing behaviour changes, so
doc/handwritten/for-users/README.fr.mdneeds nothing.Architecture decisions
Proposed: see note belowEleven architectural decisions, covered by ten decision records in §15 of the specification, each in this repository's ADR format (Context / Decision / Rationale / Alternatives Considered / Consequences / Follow-up Actions / References), each
Status: Proposed.They are deliberately not in
doc/handwritten/for-maintainers/adr/, and that needs your ruling. JustDummies is expected to leave this repository before the tool is built, and these records describe a tool that will live in the new one. Entering them here would assign numbers — the stable handles the whole base rests on — that migration would have to abandon or rewrite, and would leave this repository's log carrying decisions about code it no longer holds. Keeping them with the specification means the decision history travels as one artefact, and following the format exactly means admission is mechanical: lift each record, number it there, keep itsProposed:date, replace it here with a link. §15 opens by stating this.If you would rather they entered the base now as ADR-0065 onward, say so and I will lift them out.
None conflicts with an accepted ADR. D2 reinforces ADR-0059 (the recipe-versus-value analyzers), D9 respects ADR-0011 (JustDummies as a standalone package), and the test plan is built on the lesson of ADR-0061 — that an author's rule and the snippet testing it share the same misconception, so the emitter must be run over code written for other reasons.
One library-side follow-up is raised but not required for v1.0:
Any.Fixed<T>(value), anIAny<T>returning a constant, would let the emitter drop a nested helper. That is an addition to the library's public API, so it is your call rather than a consequence of this specification.Related issues
None.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RoJhiMTZssmMvxEehPg6DA
Generated by Claude Code