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
Decisionrecords 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
convergedFlipsrather 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'sCommentRole, and the blank linesVerticalSpaceput 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 questionFormatter.Audit.PredicateAgreementasks).
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.
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.
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.
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.
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.
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.
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.