gren-format-lib

The LLM attic

This document is written to be read by an LLM. It is the attic: things worth keeping that have no other home. A rule reference says what the code does; a doc comment says why the rule that won is right. Neither records the approach that was tried and lost, the work that was deliberately deferred and what measurement settled it, or the design that was specified and never built. Those are the things a model working on this repo will otherwise re-derive — usually by proposing something that has already been rejected, because the rejected thing is the locally obvious one and nothing in the source signals it was attempted.

Read this before proposing a change to comment placement, glue, or the test gates. Treat a match in Rejected approaches as evidence against the proposal, not as a draft to refine. Where an entry says what something cost, that cost is measured — the fixture and cell counts came from running the gates, not from estimating.

Assembled for 1.0.0 from two documents that were retired: the archived long-form CLAUDE.md, and commentRunTesting.md. Everything else in those was either duplicated by testing.md / commentAlgorithm.md or superseded — in particular commentAlgorithm.md §7 (the run rules R1–R5), §8 (why one comment working implies n working, argued from the code rather than from a suite) and §10 (what each gate varies) now say what commentRunTesting.md set out to plan.

Fixture names are under tests/testfiles/; counts are what the gates read on the day.


Contents


Rejected approaches

Each of these was tried against the whole corpus and backed out — all but the last reverted before shipping, the last deleted after months in the formatter. The cost line is the point of the entry.

freezeTabs inside addSuffixBox, to fix a Box.prefix mis-measurement

The bug. prefix padded continuation lines using lineLength 0 pref — the prefix’s width if it began at column 0 — while the line renders wherever it lands. A prefix of Row[Tab, Tab, "q + one", Space] sitting at column 6 measures 4+4+7+1 = 16 at column 0 but renders 2+4+7+1 = 14 there, because a Tab snaps to the next multiple of 4 from where it stands, and a record literal’s { is what puts the line at a non-multiple-of-4 column.

What was tried. freezeTabs on the prefix. It does fix the width — but it converts Tabs to the spaces they render to standing alone, which changes the emitted line at the same time. 2 fixtures regressed (a when-in-parens header, a KitchenComments binop chain), both of which rely on those Tabs re-snapping once the box is embedded.

What won instead. Box.blankLike — pad with a blanked copy of the prefix line rather than a count of spaces. A copy keeps every element, Tab included, at the same offset within the padding as within the prefix, so both snap identically at any column and no absolute column has to be known. A Tab-free prefix renders exactly as the old space run did, which is why the corpus did not move.

The distinction to keep: copying pads without touching the emitted line; freezing changes both.


Adding Binop to shapeKeepsTrailingCommentOutside

It converges the ownership half of the probe that motivated it, and breaks 7 fixturesBinopChainCommentChain, TrailingLineCommentBinopOperand, BinopParenEmptyBracketTrailingComment, the """…""" backward-pipe pair, and others. In every one of them a trailing comment belongs inside the binop.

Tried, reverted, measured. The list in Comments.gren is deliberately short; Binop is not a missing entry.


Relaxing gluedExposingBox’s single-line test

The diagnosis is correct and the one-line fix works. gluedExposingBox refuses a multi-line header, but only its inline branch needs one line — the vertical branch merely stacks. So a comment in the module header forced the fallback to the generic flow, which glues the list’s first item onto the header’s last row; for a one-item list that erases the only evidence a reparse has that the list was vertical (MakeLogical.exposedStartsBelowHeader), the derived ) collapses onto that row, and the comment pinned above it escapes to column 1.

It cost 4 existing fixtures, and the repair chain is the point. The hoist branch applies the same single-line test on purpose — its own code comment says so — and the two agreed only by both falling back to the same generic flow. Relaxing one alone made a header comment alternate between the two layouts forever (SortingCommentZoo, ModuleExposingInlineAndHoistedComment, ModuleExposingSortCommentToFront). Relaxing both fixed those. Then headerHasOwnLineComment turned out to mean “a trailing own-line run”, not “an own-line comment anywhere”, because a -- inside the header puts a later token on its own row. Then a third finding appeared in EffectModuleHeaderInlineComment: an effect header’s } exposing tail is position-less, so a {- c -} rendered there reads as own-line on reparse and moves — headerTailGlue’s territory.

Three expanding changes to comment classification in one sitting, none of them gated over the whole corpus, is how a session ships a regression. The attempt was reverted whole.

If you pick it up: start at headerTailGlue’s row range for the effect-module tail, and only then relax gluedExposingBox. The renderer change is the easy half and it is not the half that is wrong.


Adding SoftIndentedBlock beside IndentedBlock on blockTailKeepsCommentOutside

The idea was that a lambda body written on the -> row and the same body written below it should treat a trailing comment alike. It converges the probe’s first difference, and it cannot be motivated: in a correctly-parsed lambda the body block is the node’s last child, so hasNoFollowingSibling vetoes the arm, and the only tree that reaches it is a misparsed one (compiler-common#14). Its whole effect was to turn that probe’s non-idempotency into an AST-mismatch refusal.


Detaching a trailing comment run to column 1

Reading a probe as “the run’s tail renders below the declaration, so detach it to column 1” produced a patch that failed MultilineCommentTrailedByComment — a fixture written for this exact shape, whose own description says detaching there would “oscillate col 4 ↔ col 0”.

The gates cost ten minutes and the fixture named the answer. The general form is worth more than the case: when a shape is unstable in one container, look for the container that already agrees before designing a rule.

Note also that neither matrix could have found this family — --comments injects exactly one comment per cell, and this needs two.


A “silent flip” check in the decision-stability gate

Dropped as vacuous. Over the corpus the input already is the output, so the two traces come from identical text and nothing can differ — exactly the “if the two formats agree the comparison collapses into the idempotency check we already have” trap. The value of that gate is the reason a probe moved, which only the formatter holds; a check that compares two identical texts holds nothing.


Stripping a call argument’s redundant parens

Unlike the rest of this section, this one shipped — it was live formatter behaviour for months — and was then deleted by decision (598f55a, 2026-07-15). exprIsAtomicAsArg / insertCallArgAsItem / folderInsertCallArgAsItem in InsertExpressions.gren stripped one layer of parens off an atomic call argument; call arguments now fold through the same plain path as every other position.

Why a model needs this entry. It is the locally obvious proposal — a positional call-argument slot can never make parens load-bearing, so stripping there is provably meaning-preserving, which is exactly what elm-format relies on. That reasoning is correct and it is not the reason the code was removed. Keeping every paren in every position is a settled decision; consistency across positions is the rule, and being more explicit than elm-format is divergence #10, not a gap to close.

What it actually failed at was consistency, not correctness. fn (a) last came out as fn a last, but fn ((a)) last was left untouched — a second layer switched the stripping off entirely rather than peeling one, so the two got opposite treatment. That was registered as 6 doubleParen/callArg* BUG cells in tests/matrix-parity-baseline.json; deleting the stripper reclassified them from BUG to plain #10 (registered divergences 245 → 251, known bugs 10 → 4).

Nothing in the docs signals this any more, which is why it is here. The comparison table used to carry ⚠️ rows marking the one-layer-only strip; the table now lives under divergence #10 with no exceptions in it, and the docs/redundantParens.md that held the warning was folded into settledDecisions.md and elmFormatComparison.md (2026-08-14). A reader of the current docs sees a rule with no exceptions and no trace that an exception was ever tried.

Binop as a NestCarrying soft-glue item, to stop a header crash

The bug. softGlueAlignment’s table listed IfCondition, WhenFlow and a raw Binop as UnclassifiedCarrying — “cannot occupy a non-first soft-glue slot” — on the argument that a multi-line if/when/chain can only ever follow an OPERATOR, where it arrives wrapped in an OpAndRhs. A LEADING COMMENT needs no operator, and all three were reachable: [ 0 {- c -}, if ⏎ cond ⏎ then … ], a + {- c -} when y is …, and if {- c -} a ⏎ + b then. Each refused the file with “unreachable: multi-line non-paren unclassified soft-glue item”.

What was tried. All three added to the table as NestCarrying, which is what each box’s shape says they are. The first two are right and shipped (2026-08-21). The third turned a crash into an OSCILLATION: the only shapes that reach the slot with a raw Binop are a header’s own condition/subject (if {- c -} a ⏎ + b then), and gluing the comment onto the chain’s first line leaves it holding the condition’s row — so the enclosing header stacks it, the comment becomes line-leading on reparse, its role changes, and the second format moves it. Measured, not reasoned: --show reported the two passes differing.

What was done instead. The two headers now render vertical when their content does not fit on the header row (condBoxFrom in makeIfConditionBox, headerFrom in makeWhenFlowBox), which puts the comment on its own row where the output reparses. With that in place a bare multi-line Binop really is unreachable in the slot, so the table entry was never needed — the third name stays UnclassifiedCarrying and the table’s docstring says why.

The cost line. A crash and an oscillation are not interchangeable. Silencing the crash was one line and looked like progress; it moved the failure to a gate that ran later and would have read as a regression from an unrelated change.


Deferred, with the measurement

Not rejected — decided against for now, on evidence. The evidence is the part worth keeping: without it the next reader re-opens the question from scratch.

The run-classification refactor, sub-steps 2 and 3

The plan was to make two of the comment rules hold by construction rather than by testing: identify a gap’s comment run once, decide one role for the whole run (C1), and let each member contribute only its own commentTextCanRide to a fold (C3). Sub-step 1 — identify the run once — was done: spanTrailingOwnLine now lives in LogicalPrintingTree beside CommentRole and roleGlues, and Comments.spanTrailingOwnLineNodes, its mirror, is deleted.

Sub-steps 2 and 3 were deferred on 2026-08-06, by measuring rather than by reading. Three findings, in the order that settles it:

So the refactor buys structure — C1 and C3 holding by construction — and, on this evidence, no bugs. If you pick it up: 2 and 3 are one change, not two. The per-member role is the chaining mechanism (prevLineGlueRow / prevBlockGlueRow / bracketItemRow all key a comment by its LAST row on purpose, a5d948c), so the second member of a run comes back TrailsPrevious / RidesInline against the first, which is what tells the renderer to glue it onto the first’s line. Decide one role for the run without the fold in place and that information is gone; MultilineCommentTrailedByComment is the fixture that says so.

One unification that looks available and is not. spanTrailingComments (every trailing comment) and peelTrailingCommentNodes (only inline-gluable, at most one --) are not the same rule despite the names. chainedRefRow and SortSymbols.takeSameRowTrailingFrom genuinely are two formulations of one idea, but they differ at the margin (backward fold, firstRow <= acc, returns a row; versus forward walk, firstRow == prevLast, returns the nodes), so unifying them picks one comparison over the other and changes behaviour — it needs its own measurement rather than riding along with another change.


Designed, never built

The deletion-invariance oracle

The problem it answers: a comment run’s test space grows as 3^n, and the thing a run gets wrong is usually layout, which has no local truth — a run can be stable, AST-preserving, idempotent and comment-preserving while sitting in visibly the wrong place. Enumerating is impractical and judging the output is not mechanical.

The oracle is rule C4 (“a comment changes where the lines fall, and nothing else”) applied to the k-th comment of a run:

In a run of two or more comments where at least two members share a ride-class, deleting one of those members must change the output by exactly that comment’s own rendering — nothing else moves.

You never judge the n-comment output. You check that it differs from the (n−1)-comment output by exactly the comment removed, and (n−1) against (n−2), down to n=1 — which the existing gates and the elm-format oracle already cover. n-comment correctness follows from 1-comment correctness by induction, and no human reads a three-comment layout.

The “two share a class” guard is the load-bearing part, and is not a fudge. Plain “deleting a comment moves nothing else” is false, deliberately: two shipped rules make a comment change the surrounding layout — literalCommentsRideFlatLine (deleting the only non-ridable member lets the container collapse back to one line, which is C3 working) and NodeClassify.commentBreaksFlowRow (a comment that ends a row is folded into the force-vertical decision, so deleting it can re-flow a call or a binop chain). Both are any/all folds over the run’s classes. So if the run still contains another member of the same class after the deletion, every such fold returns what it returned before by construction — no verdict can flip, and the invariance holds unconditionally. No baseline, no exception list to keep in step with the code. It costs one extra comment in the probe.

Build it in gen-random.py, not in the gap fuzzer. The generator emits from a tree, so the n and n−1 variants come from the same tree deterministically — the mechanism the sort-order oracle already uses to emit two author orders. Splicing text to delete a comment from a corpus file cannot guarantee nothing else moved. The comparison is a code skeleton: blank every comment span in both outputs, drop lines that were wholly comment, require byte-equality of the rest. That is C4 stated directly, with no span-boundary judgement in it.


Known coverage gaps

Stated so they are not mistaken for coverage. commentAlgorithm.md §10 is the full what-each-gate-varies map; these are the holes in it.

Rules of thumb that cost something to learn