Skip to content

docs(justdummies): specify the dum scaffolding tool - #389

Merged
Reefact merged 13 commits into
mainfrom
claude/justdummies-tool-prespec-uxwk4y
Jul 30, 2026
Merged

docs(justdummies): specify the dum scaffolding tool#389
Reefact merged 13 commits into
mainfrom
claude/justdummies-tool-prespec-uxwk4y

Conversation

@Reefact

@Reefact Reefact commented Jul 30, 2026

Copy link
Copy Markdown
Owner

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, a check verb and the source-generator question in one move.

Documentation only — no code, no project, nothing built yet.

Type of change

  • Documentation

Changes

  • Adds doc/handwritten/for-maintainers/specifications/justdummies-tool.md and its French pair — 18 sections covering the product, the emitted file, parameter resolution, the two-project architecture, the test plan, and the decision records.
  • Indexes both in the maintainer documentation README (English and French).

Three properties are worth calling out.

Two projects, not one. JustDummies.GenAny is the engine — resolution, guard reading, emission — targeting netstandard2.0 at the analyzer Roslyn floor and performing no I/O; JustDummies.Cli is 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 a net8.0 assembly. 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 test were 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.dll with JustDummies.Analyzers wired in. Highlights:

  • every row of the resolution table compiles (40 declarations, nullable on, warnings-as-errors) and keeps its promise over 3 000 draws each;
  • all 17 guard mappings are sound over 4 000 draws each — every value drawn is one the original guard would accept;
  • the scaffolded output raises no JD diagnostic, with a control file proving the analyzers were actually loaded;
  • the same control file, marked <auto-generated/>, raises none — including a JD005 error. That measurement is why the scaffolded file carries no generated-code marker;
  • the emitted generator replays under a reproducibility scope even when built outside it.

§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

  • README / doc/ updated
  • French translation updated — the specification ships as an .md / .fr.md pair, and both maintainer READMEs are indexed

No user-facing behaviour changes, so doc/handwritten/for-users/README.fr.md needs nothing.

Architecture decisions

  • New decision recorded — ADR drafted as Proposed: see note below

Eleven 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 its Proposed: 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), an IAny<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

claude added 13 commits July 30, 2026 15:52
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
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
@Reefact
Reefact merged commit 0f8cfba into main Jul 30, 2026
29 checks passed
@Reefact
Reefact deleted the claude/justdummies-tool-prespec-uxwk4y branch July 30, 2026 21:47
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.

2 participants