Formatter.Audit.DecisionTrace

Which layout decisions the formatter made, and whether it makes the same ones again.

This module backs the CLI's --decisions flag:

gren-format --decisions MyFile.gren

which formats a file twice and prints, as JSON, the layout decisions that differed between the two passes. It is a debugging aid for non-idempotent formatting -- output that changes when the formatter is run on its own output. It sits off to the side of the pipeline; nothing in Formatter.Logical or Formatter.Render depends on it.

Why it exists

Every other check in this repo compares output. When a format is not a fixed point, a byte diff shows where the two runs disagree but never why, so the decision behind each diff has to be reconstructed by hand, one diff at a time -- and two diffs with the same cause look no more alike than two with different ones.

This module gives them a shared vocabulary. It reads back, per top-level declaration, the layout decisions the formatter took, as name = choice pairs. A diff like

-   foo : Int -> {- c -} { a : Int, b : String }
+   foo :
+       Int
+       -> {- c -} { a : Int, b : String }

comes back as

decl 3 `foo`  AcrossOrVertical.forceVertical  False x1 -> True x1

and a decision name is something many findings can share, so a pile of diffs becomes a histogram instead of a reading list. tests/check-decision-stability.py is the driver that does exactly that over the fixture corpus; see docs/testing.md for how to run it and how to read the groups it prints.

How a trace is built and compared

traceDeclarations walks a finished LPT and returns one DeclTrace per top-level declaration: the decisions taken inside it, plus the text it renders to. diffDeclarations compares two such arrays -- one per formatting pass -- and returns the Flips, the branches whose population changed. reportToJson renders the result as the --decisions payload.

Three properties of that comparison are deliberate:

  • Decisions carry no source positions. A row is precisely what moves between two formats, so a decision keyed on one would flag every finding and explain none. A Decision records only the declaration it was taken in, the decision's name, and the branch taken.

  • Traces are compared as multisets of (decl, name, choice), not aligned node by node. A comment that lands under a different parent on the reparse shifts every structural path after it, and an alignment that reports forty spurious moves for one real flip is worse than no alignment at all. A multiset difference reports exactly the branches whose population changed.

  • Only declarations whose rendered output moved are compared. Formatting a file that is not already canonical legitimately changes many decisions -- the second pass reads the first pass's tidied rows, not the author's -- so an unrestricted comparison is mostly the formatter converging. What the restriction leaves out is counted in convergedFlips rather than dropped silently.

What may be traced: trace an input, never a composite

Two kinds of value are traced, and nothing here is recomputed from a formula written in this module:

  • Flags the LPT already carries -- forceVertical, checkContentVertical, the bracket-container constructor, each comment's CommentRole, and the blank lines VerticalSpace put above a declaration. These are the author-intent decisions, taken in the logical stage from the source rows, and they are read straight off the node.

  • Results of calling the renderer's own exported predicates with the node's own children -- commentBreaksFlowRow, literalCommentsRideFlatLine, signatureForceVertical, commentEndsItsLine, commentTextCanRide -- plus, for the shapes whose layout is decided by measuring a rendered child, whether that child's box comes back on one line (makePBox + isSingleLine, the same question Formatter.Audit.PredicateAgreement asks).

Composite decisions are deliberately left out. commentForcesBracketOpen, for example, is a genuine layout decision, but tracing it would mean copying its formula out of MakeRenderBox -- a mirror, free to drift from the original and then report confidently about code that no longer behaves that way. Its two inputs are traced instead, so a flip in it still shows up, under the name of whichever input moved.

The cost of that rule is coverage: a decision with no traced input means some byte diffs will show no flipped decision at all. That is reported rather than hidden -- a diff with an empty flip list comes back as unexplained, and the count of those is this module's own to-do list. Adding a traced decision is how it comes down; guessing at one is how the report starts lying.

type alias Decision = { decl : String, name : String, choice : String }

One decision: the declaration it was taken in, its name, and the branch.

decl is "<n> <name>" -- the declaration's index among the root's declaration children (comments and blank lines are not counted, so inserting one does not renumber anything) and the first literal token in it, which for every declaration kind is the name being declared.

choice is a short enum-ish string ("True", "LeadsLine", "alwaysVertical:curly"), never a rendering and never a number that could drift.

type alias DeclTrace =
{ decl : String
, decisions : Array Decision
, text : String
}

One declaration's decisions, beside the text it renders to.

The text is what keeps the diff useful: diffDeclarations compares decisions only in declarations whose rendered output moved, so every flip it reports is a decision that had a visible effect. Without that restriction the report is mostly the formatter converging on a file that was not canonical to begin with.

type alias Flip =
{ decl : String
, name : String
, choice : String
, before : Int
, after : Int
}

One branch whose population changed between the two formats: it was taken before times in the first format and after times in the second, and the two differ. A flip normally arrives as a pair -- False 1 -> 0 beside True 0 -> 1 -- because the node is still there and took the other branch; a lone entry means the decision stopped (or started) being asked at all, which is a structural change and worth reading differently.

traceDeclarations : LPNode -> Result String (Array DeclTrace)

The trace, grouped by declaration, each carrying the text it renders to.

Root children are attributed to a declaration: a declaration's own subtree to itself, and a leading comment or blank line to the declaration it precedes ("eof" for anything trailing the last one), because that is the code it was written against.

Attribution puts a declaration's leading comments and blank lines under its label, so the labels come out in contiguous runs and the grouping is a single pass. The text of a run is its children's rendered output joined the way renderRoot joins it, so two runs compare exactly as the two files do.

diffDeclarations :
Array DeclTrace
-> Array DeclTrace
-> { flips : Array Flip, movedDecls : Array String, convergedFlips : Int
}

What changed between two formats, with the decisions that did nothing left out.

flips covers only the declarations whose rendered output differs, so every entry is a decision that had a visible effect. convergedFlips counts the rest: decisions that differ inside a declaration whose output is byte-identical. Those are not findings -- they are what a second format legitimately decides differently once the first pass has tidied the rows those flags are read from -- but the number is reported anyway, so the size of what is being filtered out stays visible. movedDecls names the declarations that moved.

Declarations are aligned by position. Formatting neither adds nor removes one, and the index counts only real declarations (isDeclarationChild), so the alignment survives any amount of comment and blank-line movement; a pair whose labels disagree is reported as moved rather than skipped.

reportToJson :
{ bytesDiffer : Bool
, flips : Array Flip
, movedDecls : Array String
, convergedFlips : Int
, decisionCount : Int
}
-> String

Encode the result as the JSON payload --decisions prints.

The field worth knowing is unexplained: true when the two formats produced different bytes and yet no traced decision changed, meaning the branch that moved is one this module does not record. It is reported per file rather than summed across a run, because fixing it is per-file work -- find the missing decision, trace it (under the rule in the module doc), and the file drops off the list.