Skip to content

Show attribute-level changes in the plan diff - #292

Merged
refeed merged 3 commits into
feat/plan-diff-in-commentfrom
feat/plan-attribute-diff
Sep 4, 2026
Merged

refeed merged 3 commits into
feat/plan-diff-in-commentfrom
feat/plan-attribute-diff

Conversation

@refeed

@refeed refeed commented Sep 3, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #291, stacked on it so this diff is only the attribute work. #291 gives one line per
resource — address and action. This says what about it.

! terraform_data.replaced                          replace (destroy first)
    ~ id = "93222216-07f1-b735-3476-5b93ed7d6115" -> (known after apply)
    ~ triggers_replace = ["v1"] -> ["v2"]   # forces replacement
! terraform_data.updated                           update in place
    ~ input = "before" -> "after"

Still rendered from the masked document. before, after, after_unknown and the
*_sensitive trees carry everything needed — including replace_paths, which names the one
attribute whose change is costing a destroy and recreate. That line is the most consequential thing
in a plan review, and no amount of resource-level summary conveys it.

What each action shows, and why they differ

action shows why
create the values the author chose; computed ones counted a wall of (known after apply) is not information
update only what changed that is the question being asked
replace the same, plus # forces replacement names the attribute that cost the recreate
destroy nothing the resource is going; its former values neither inform the decision nor belong in a public comment

Two things this had to get right, both found by writing it wrong first

Attribute values are a new attack surface, and my first draft was vulnerable. Values are as
author-controlled as addresses — more so, being the literal text of the author's terraform. The
first version interpolated them raw, and a value of three backticks closed the block: five fence
terminators where there should be two
, with a forged ## Tirith — all policies passed heading
rendering as markdown after it. Exactly the hole #291's address guard was written to close, reopened
one layer in. Keys and values now both go through _fence_safe; the guard belongs on both or it
protects neither.

Sensitivity is a tree, not a boolean. terraform reports {"triggers_replace": [false]} — the
list is structure, the false is the answer. A truthy test on that node says the opposite, and duly
printed (sensitive value) over a value that was never secret, hiding information for no reason.
_contains_true walks the tree and treats any true anywhere as marked. Deliberately
conservative: for sensitivity it over-hides rather than leaks, and for unknown-ness it prefers "we
cannot show this" to printing half a value as if it were whole.

Both are pinned by tests named for the failure rather than the feature:
test_an_attribute_value_cannot_escape_the_fence and
test_a_shape_shaped_sensitivity_tree_is_not_read_as_true.

Bounds

Attributes are capped per resource, and the block cap is now counted in lines rather than
resources
— a resource brings its attributes with it, and it is the line count that decides
whether the comment is readable. PLAN_ROW_LIMIT becomes PLAN_LINE_LIMIT for that reason, which
is why one test in #291's file is renamed here.

Checks

  • 11 new tests in tests/platform/test_report_plan_attributes.py.
  • Full suite 12 failed, 711 passed, 10 skipped. I diffed the failure list against this branch's
    baseline: zero new failures. The 12 are the pre-existing ones on main — 11 fixture
    FileNotFoundErrors and the README-drift test.
  • Verified against a real hostile plan produced by tofu, not just fixtures: a for_each key and an
    attribute value both carrying fence terminators render inert, with 2 fence terminators and no
    backticks inside the block.

Not in scope

Nested attribute trees. A map or list renders compacted on one line rather than as a nested diff —
enough to answer "did this change and roughly to what". A real nested diff is a larger feature and
would need its own thinking about depth limits.


Follow-up in this branch: always expanded, and detail gives way first

Two changes to how the block behaves once it gets large.

It is never collapsed. The block used to hide behind <details><summary>Show plan</summary>
above twelve resources. The plan is the thing this block exists to show, and putting it behind a
click makes the common case a reviewer who never sees it.

Detail is given up before resources. When the plan exceeds the line cap, every attribute row
is dropped so that every resource stays named; only a bare list that is still too long is cut off
the end. A reviewer can act on "this is being destroyed" without knowing which field changed, but
not the reverse.

Detail is dropped wholesale rather than from the cut-off point onward. Trimming at the boundary
would annotate the first handful of resources and leave the rest bare — which reads as though the
later ones had nothing to say, and the bare-looking ones are exactly where a reviewer stops looking.

Also adds module coverage. Modules needed no code change: terraform flattens module resources
into the same flat resource_changes list, so nesting shows up only as a longer address. The tests
pin that down, and cover the one genuinely new attack surface — a module for_each key lands
before the resource part of the address, so a guard written around resource names rather than
whole addresses would miss it.

Verified end to end

Three real plans in tirith-plan-diff-test#1,
all runs green:

comment plan result
tag=default (platform) 15 resources, 42 lines expanded, 28 attribute rows intact
tag=local (no credentials) same byte-identical resource rows to platform
tag=oversized 70 creates 60 resource rows, 0 attribute rows, both notes

The oversized comment degrades in the designed order:

+ terraform_data.bulk[59]                          create

… attribute detail omitted: 140 more line(s) than a comment can carry

… and 10 more resource(s), truncated

Module addresses on a real plan, with state seeded so they plan updates and a replace rather than
only creates:

! module.cache.terraform_data.inner                replace (destroy first)
    ~ triggers_replace = ["v1"] -> ["v2"]   # forces replacement
- module.retired.terraform_data.inner              destroy
! module.outer.module.inner.terraform_data.inner   update in place

Fence integrity holds across all three: exactly two line-initial fences and zero backticks
inside the fence body, including the run carrying a hostile module for_each key.

Full suite: 12 failed, 781 passed. Diffed against main's baseline — the failure sets are
identical, so zero new failures.

The resource list said what was changing. This says what about it.

Rendered from the masked plan document, as before -- `before`, `after`,
`after_unknown` and the `*_sensitive` trees carry everything needed, including
`replace_paths`, which names the one attribute whose change is costing a destroy
and recreate. That line is the most consequential thing in a plan review and no
amount of resource-level summary conveys it.

What each action shows differs on purpose. A create names the values the author
chose and counts the computed ones, because a wall of "(known after apply)" is
not information. An update and a replace show only what changed. A destroy shows
nothing: the resource is going away, its former values do not inform the
decision, and they do not belong in a public comment.

Two things this had to get right, both found by writing it wrong first.

Attribute values are as author-controlled as addresses -- more so, being the
literal text of their terraform. A first draft interpolated them raw and a value
of three backticks closed the block: five fence terminators where there should be
two, with a forged verdict heading rendering as markdown after it. Keys and
values now both go through _fence_safe. The guard belongs on both or it protects
neither.

Sensitivity is reported as a tree mirroring the value, not a boolean:
{"triggers_replace": [false]} means the element is NOT sensitive. A truthy test
on that list says the opposite, and duly printed "(sensitive value)" over a value
that was never secret. _contains_true walks the tree and treats any true anywhere
as marked -- conservative on purpose, since for sensitivity it over-hides rather
than leaks.

The cap is now counted in lines rather than resources, because a resource brings
its attributes with it and it is the line count that decides readability.
@codecov

codecov Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.40659% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/tirith/plan_actions.py 88.67% 3 Missing and 3 partials ⚠️
Files with missing lines Coverage Δ
src/tirith/platform/report.py 89.09% <100.00%> (+1.13%) ⬆️
src/tirith/plan_actions.py 92.04% <88.67%> (+0.61%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Modules need no code change -- terraform flattens them into the same resource_changes list, so
nesting shows up only as a longer address. These tests pin that down so it stays true, and add the
one genuinely new attack surface: a module for_each key lands before the resource part of the
address, so a guard written around resource names rather than whole addresses would miss it.
Two changes to how an oversized plan is handled.

The block is no longer collapsed behind a <details> above twelve resources. The plan is the thing
this block was added to show, and putting it behind a click makes the common case a reviewer who
never sees it.

And when the plan does not fit, detail gives way before resources: every attribute row is dropped
so that every resource stays named, and only a bare list that is still too long is cut. A reviewer
can act on "this is being destroyed" without knowing which field changed, but not the reverse.
Detail is dropped wholesale rather than at the cut-off point -- trimming there would annotate the
first few resources and leave the rest bare, which reads as though the later ones had nothing to
say.
@sonarqubecloud

sonarqubecloud Bot commented Sep 4, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@refeed
refeed merged commit e4544d0 into feat/plan-diff-in-comment Sep 4, 2026
15 of 18 checks passed
@refeed
refeed deleted the feat/plan-attribute-diff branch September 4, 2026 15:44
refeed added a commit that referenced this pull request Sep 4, 2026
* feat: add plan diff information in the comment

The comment said whether policies passed and never what was changing. To learn
that a pull request creates one bucket, a reviewer had to leave the review and
open the job log or the run.

It now carries the planned changes and terraform's summary line, rendered from
the *masked* plan document rather than from `terraform show` output. That is a
security choice, not a convenience one: masking is the only thing keeping a
sensitive value out of a public comment, and captured text would not carry it.

no-op resources are counted, not listed. A plan against applied infrastructure
carries one for every resource in state -- on the demo repository five of six
rows -- and listing them buries the one that changed.

Replacements are counted separately rather than folded into add and destroy,
which is where terraform puts them. "1 to replace" is the number that should
make a reviewer look twice, and it disappears when spread across two columns.

Markers are +, - and ! only. terraform's ~ means nothing to the diff
highlighting, so an update row would render plain and the fence would buy
nothing.

The block is dropped first when the comment is too long, ahead of any finding.
A comment that keeps the diff and loses the violation has failed at its job.

`action_summary` moves out of the TUI into a shared module so both surfaces
answer the replacement question identically.

* fix: the unchanged count is plain text, not <sub>

Every other <sub> in the reporter wraps a whole line -- the cost line, the
context line, the footer. This one wrapped a fragment mid-line, gluing an HTML
tag onto a line that otherwise reads as terraform output.

The count itself stays: it is what explains why the list is shorter than the
plan, so a reviewer who knows there are seven resources does not wonder where
six went. It just does not need to shout.

Also strengthens the newline guard test to use a real newline rather than an
escaped one. Terraform escapes newlines in a for_each key into an inert literal,
so the original test was checking the harmless case; a hand-written state or a
different tool can still carry a real one, and one real newline in an address is
one forged row in the diff.

* Show attribute-level changes in the plan diff (#292)

* feat: show attribute-level changes in the plan block

The resource list said what was changing. This says what about it.

Rendered from the masked plan document, as before -- `before`, `after`,
`after_unknown` and the `*_sensitive` trees carry everything needed, including
`replace_paths`, which names the one attribute whose change is costing a destroy
and recreate. That line is the most consequential thing in a plan review and no
amount of resource-level summary conveys it.

What each action shows differs on purpose. A create names the values the author
chose and counts the computed ones, because a wall of "(known after apply)" is
not information. An update and a replace show only what changed. A destroy shows
nothing: the resource is going away, its former values do not inform the
decision, and they do not belong in a public comment.

Two things this had to get right, both found by writing it wrong first.

Attribute values are as author-controlled as addresses -- more so, being the
literal text of their terraform. A first draft interpolated them raw and a value
of three backticks closed the block: five fence terminators where there should be
two, with a forged verdict heading rendering as markdown after it. Keys and
values now both go through _fence_safe. The guard belongs on both or it protects
neither.

Sensitivity is reported as a tree mirroring the value, not a boolean:
{"triggers_replace": [false]} means the element is NOT sensitive. A truthy test
on that list says the opposite, and duly printed "(sensitive value)" over a value
that was never secret. _contains_true walks the tree and treats any true anywhere
as marked -- conservative on purpose, since for sensitivity it over-hides rather
than leaks.

The cap is now counted in lines rather than resources, because a resource brings
its attributes with it and it is the line count that decides readability.

* test: cover module addresses and the module for_each injection surface

Modules need no code change -- terraform flattens them into the same resource_changes list, so
nesting shows up only as a longer address. These tests pin that down so it stays true, and add the
one genuinely new attack surface: a module for_each key lands before the resource part of the
address, so a guard written around resource names rather than whole addresses would miss it.

* feat: always expand the plan block, and drop detail before resources

Two changes to how an oversized plan is handled.

The block is no longer collapsed behind a <details> above twelve resources. The plan is the thing
this block was added to show, and putting it behind a click makes the common case a reviewer who
never sees it.

And when the plan does not fit, detail gives way before resources: every attribute row is dropped
so that every resource stays named, and only a bare list that is still too long is cut. A reviewer
can act on "this is being destroyed" without knowing which field changed, but not the reverse.
Detail is dropped wholesale rather than at the cut-off point -- trimming there would annotate the
first few resources and leave the rest bare, which reads as though the later ones had nothing to
say.
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.

1 participant