Skip to content

Commit 1aaa2fc

Browse files
jawwad-aliclaude
andauthored
fix(workflows): keep an overlay's replace when the same overlay also inserts on that anchor (#4140)
* fix(workflows): keep an overlay's replace when it also inserts on that anchor `_traverse_and_apply` decided an anchor's fate with `edits[-1]`, which treats declaration order *inside a single overlay file* as a precedence signal. Priority is a per-overlay property, so two edits from one overlay have no priority relation to break — yet a trailing `insert_after` reverted the anchor to the base step and silently discarded that same overlay's `replace`. Measured through the real resolver, one overlay declaring both edits: replace-then-insert (main): implement run='make build' <-- LOST attribution: ('implement', 'base') insert-then-replace (main): implement run='make build-hardened' either order (fixed): implement run='make build-hardened' attribution: ('implement', 'project:my-overlay') So `specify workflow run demo` executed `make build` instead of `make build-hardened`, with no error, and `workflow resolve` attributed the untouched step to "base". Scoped to `replace` only. A replace leaves the anchor in place so both edits can be honoured; `remove` destroys it, so an insert relative to it cannot also apply and choosing between them is a separate question — that combination keeps its existing behaviour, pinned by a test. The ancestor-conflict map uses the same fate rule so the guard cannot drift, while still listing every anchor: `_check_anchor_conflicts` reads its key set to find descendant anchors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(workflows): scope the fate rescue to unambiguous replace-plus-insert Addresses review feedback on the same-overlay fate rescue. 1. `_winning_fate_edit` also rescued the `replace` when the winning layer declared `replace`, `remove` AND a trailing insert on one anchor. That changed behaviour for a combination this PR deliberately scoped out. The rescue now bails out when the winning layer has a `remove` on the anchor, so such layers stay byte-identical to their pre-rescue outcome: one overlay's edits upstream/main before now replace, remove, insert base kept replaced base kept remove, replace, insert base kept replaced base kept replace, insert (target) base kept replaced replaced remove, insert base kept base kept base kept Only the intended case now differs from main. 2. `_traverse_and_apply`'s docstring still said the winning edit "is `edits[-1]`", which stopped being true on this path. It now points at `_winning_fate_edit` so future changes do not bypass it. 3. The test class docstring said an overlay's "replace/remove" must survive a trailing insert; only `replace` is rescued. Limited to `replace` and made the `remove` exclusion explicit. New parametrized regression test pins the ambiguous layer in both declaration orders, so the rescue cannot start honouring whichever of replace/remove happens to come first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(workflows): cover insert_before and the conflict-guard use of the fate rule Addresses two review comments, both correct. 1. `_winning_fate_edit` treats `insert_after` and `insert_before` alike, but every same-overlay fate test used only `insert_after` -- the pre-existing `insert_before` tests use separate layers and never reach this branch. `test_replace_then_insert_same_overlay_keeps_replacement` is now parametrized over both, asserting the resulting order in each case (`[implement, lint, tail]` vs `[lint, implement, tail]`). 2. The conflict guard's use of `_winning_fate_edit` had no regression test. Every existing conflict test ends the parent's edits with `replace` or `remove`, so all of them pass under either rule. New parametrized `test_replace_parent_with_trailing_insert_still_conflicts` gives the parent a `replace` plus a trailing insert alongside a descendant edit: under `anchor_edits[-1]` the parent's apparent fate becomes the insert, which `_check_anchor_conflicts` deliberately skips, so the conflict would go undetected and the subtree would be replaced out from under the descendant. Mutation-verified: reverting the guard to `anchor_edits[-1]` fails exactly the two new tests and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 61c26fe commit 1aaa2fc

2 files changed

Lines changed: 263 additions & 7 deletions

File tree

‎src/specify_cli/workflows/overlays/merge.py‎

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,51 @@ def _build_attribution(
230230
return result
231231

232232

233+
def _winning_fate_edit(
234+
edits: list[tuple[OverlayLayer, OverlayEdit]],
235+
) -> tuple[OverlayLayer, OverlayEdit] | None:
236+
"""Return the edit that decides an anchor's fate.
237+
238+
Normally that is simply the last edit in merge order. The exception this
239+
helper exists for: when the last edit is an ``insert_*``, the same overlay
240+
may *also* have declared a ``replace`` on the anchor earlier in the file.
241+
Declaration order inside one overlay is not a precedence signal -- priority
242+
is a per-overlay property -- so the trailing insert must not cancel that
243+
overlay's own replacement, which previously reverted the anchor to the base
244+
step and discarded the replacement silently.
245+
246+
A ``replace`` leaves the anchor in place, so both edits can be honoured.
247+
``remove`` is deliberately NOT rescued here: it destroys the anchor, so an
248+
insert relative to it cannot also apply, and choosing between them is a
249+
separate question. That combination keeps its existing behaviour.
250+
251+
For the same reason the rescue applies only to an *unambiguous*
252+
replace-plus-insert layer. If the winning layer also declared a ``remove``
253+
on this anchor, the layer is asking for two incompatible fates and picking
254+
one is that same separate question -- so the previous trailing-insert
255+
outcome is preserved and such layers stay byte-identical to their
256+
pre-rescue behaviour.
257+
258+
Returns ``None`` only when there are no edits.
259+
"""
260+
if not edits:
261+
return None
262+
winning_layer, last_edit = edits[-1]
263+
if last_edit.operation not in ("insert_after", "insert_before"):
264+
return edits[-1]
265+
replacement: tuple[OverlayLayer, OverlayEdit] | None = None
266+
for layer, edit in edits:
267+
if layer is not winning_layer:
268+
continue
269+
if edit.operation == "remove":
270+
# Ambiguous layer (replace *and* remove on one anchor): leave the
271+
# pre-existing trailing-insert fate untouched.
272+
return edits[-1]
273+
if edit.operation == "replace":
274+
replacement = (layer, edit)
275+
return replacement if replacement is not None else edits[-1]
276+
277+
233278
def _traverse_and_apply(
234279
steps: list[dict[str, Any]],
235280
edits_by_anchor: dict[str, list[tuple[OverlayLayer, OverlayEdit]]],
@@ -244,7 +289,10 @@ def _traverse_and_apply(
244289
steps).
245290
246291
*edits* are expected to be in merge order (lowest priority first, highest
247-
priority last); the winning edit for each anchor is ``edits[-1]``.
292+
priority last). The winning edit for each anchor is chosen by
293+
``_winning_fate_edit`` -- normally ``edits[-1]``, except that a trailing
294+
``insert_*`` does not cancel a ``replace`` declared earlier by that same
295+
overlay. Go through that helper rather than reading ``edits[-1]`` directly.
248296
"""
249297
result: list[dict[str, Any]] = []
250298

@@ -255,7 +303,8 @@ def _traverse_and_apply(
255303

256304
step_id = step.get("id")
257305
edits = edits_by_anchor.get(step_id, []) if isinstance(step_id, str) else []
258-
winning_edit = edits[-1][1] if edits else None
306+
fate = _winning_fate_edit(edits)
307+
winning_edit = fate[1] if fate is not None else None
259308

260309
if winning_edit is not None and winning_edit.operation == "remove":
261310
# Winning edit removes this step; ignore all other edits on this anchor.
@@ -274,7 +323,7 @@ def _traverse_and_apply(
274323
result.append(new_step)
275324

276325
if winning_edit is not None and winning_edit.operation == "replace":
277-
winning_layer = edits[-1][0]
326+
winning_layer = fate[0]
278327
new_step = copy.deepcopy(winning_edit.step)
279328
_remove_sources_recursively(step, sources)
280329
_record_sources_recursively(new_step, winning_layer.source, sources)
@@ -352,10 +401,20 @@ def merge_steps(
352401
# the ancestor edit replaces or removes its subtree — those produce
353402
# order-dependent results. Pure insert edits on an ancestor are safe because
354403
# the ancestor step (and its descendants) remain intact.
355-
anchor_winning_ops = {
356-
anchor: anchor_edits[-1][1].operation
357-
for anchor, anchor_edits in edits_by_anchor.items()
358-
}
404+
# Must agree with ``_traverse_and_apply``: use the same fate rule, or the
405+
# conflict guard stops firing for a subtree that is in fact replaced.
406+
# Every anchor stays in the mapping even when its fate is a pure insert --
407+
# ``_check_anchor_conflicts`` reads the key set to find *descendant*
408+
# anchors, so dropping insert-only anchors would stop conflicts being
409+
# detected against them.
410+
anchor_winning_ops = {}
411+
for anchor, anchor_edits in edits_by_anchor.items():
412+
anchor_fate = _winning_fate_edit(anchor_edits)
413+
anchor_winning_ops[anchor] = (
414+
anchor_fate[1].operation
415+
if anchor_fate is not None
416+
else anchor_edits[-1][1].operation
417+
)
359418
anchor_conflicts = _check_anchor_conflicts(anchor_winning_ops, base_steps)
360419
if anchor_conflicts:
361420
raise ValueError(

‎tests/workflows/test_overlay_merge.py‎

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,40 @@ def test_replace_parent_and_remove_child_raises(self):
591591
with pytest.raises(ValueError, match="ancestor"):
592592
merge_steps(base, [_layer(overlay, "project:ov")])
593593

594+
@pytest.mark.parametrize(
595+
"insert_op", ["insert_after", "insert_before"], ids=["after", "before"]
596+
)
597+
def test_replace_parent_with_trailing_insert_still_conflicts(self, insert_op):
598+
"""A rescued parent `replace` must still be seen by the conflict guard.
599+
600+
The guard reads the anchor's fate through `_winning_fate_edit`, the same
601+
rule `_traverse_and_apply` uses. Were it to read `anchor_edits[-1]`
602+
instead, the trailing insert would be the parent's apparent fate --
603+
and `_check_anchor_conflicts` skips inserts, because they leave the
604+
ancestor intact -- so this replace-plus-descendant conflict would go
605+
undetected and the subtree would be replaced out from under the
606+
descendant edit.
607+
608+
Every other conflict test ends the parent's edits with `replace` or
609+
`remove`, so they pass under either rule; only a trailing insert tells
610+
the two apart.
611+
"""
612+
parent_id = "if-step"
613+
child_id = "then-child"
614+
base = [self._if_step(parent_id, child_id)]
615+
overlay = Overlay(
616+
id="ov",
617+
extends="wf",
618+
priority=10,
619+
edits=[
620+
OverlayEdit("replace", parent_id, _step("new-parent")),
621+
OverlayEdit(insert_op, parent_id, _step("beside-parent")),
622+
OverlayEdit("remove", child_id),
623+
],
624+
)
625+
with pytest.raises(ValueError, match="ancestor"):
626+
merge_steps(base, [_layer(overlay, "project:ov")])
627+
594628
def test_conflict_across_multiple_overlays_raises(self):
595629
"""Conflict is detected even when conflicting anchors come from different overlays."""
596630
parent_id = "if-step"
@@ -733,3 +767,166 @@ def test_replace_with_reused_id_does_not_affect_original(self):
733767
assert sources.get("b") == "project:ov", (
734768
f"expected 'project:ov' but got {sources.get('b')!r}"
735769
)
770+
771+
772+
class TestMergeStepsSameOverlayFateEdits:
773+
"""An overlay's `replace` must survive its own trailing insert.
774+
775+
`_traverse_and_apply` decided an anchor's fate with `edits[-1]`, which
776+
treats declaration order *inside one overlay file* as a precedence signal.
777+
Priority is a per-overlay property, so two edits from the same overlay have
778+
no priority relation to break — yet a trailing `insert_after` reverted the
779+
anchor to the base step and discarded that overlay's own `replace`.
780+
781+
`remove` is explicitly out of scope: it destroys the anchor, so an insert
782+
relative to it cannot also apply. Layers combining `remove` with a trailing
783+
insert — with or without a `replace` alongside — keep their pre-existing
784+
behaviour, as `test_remove_then_insert_after_same_overlay_is_unchanged` and
785+
`test_replace_and_remove_with_trailing_insert_is_unchanged` pin.
786+
"""
787+
788+
@pytest.mark.parametrize(
789+
"insert_op,expected_ids",
790+
[
791+
("insert_after", ["implement", "lint", "tail"]),
792+
("insert_before", ["lint", "implement", "tail"]),
793+
],
794+
ids=["after", "before"],
795+
)
796+
def test_replace_then_insert_same_overlay_keeps_replacement(
797+
self, insert_op, expected_ids
798+
):
799+
"""The rescue covers both insert operations, not just `insert_after`.
800+
801+
`_winning_fate_edit` treats `insert_after` and `insert_before` alike, so
802+
both are pinned here; the pre-existing `insert_before` tests use
803+
separate layers and never reach this same-overlay branch.
804+
"""
805+
base = [_step("implement"), _step("tail")]
806+
overlay = Overlay(
807+
id="ov",
808+
extends="wf",
809+
priority=10,
810+
edits=[
811+
OverlayEdit(
812+
"replace", "implement",
813+
{**_step("implement"), "command": "custom.impl"},
814+
),
815+
OverlayEdit(insert_op, "implement", _step("lint")),
816+
],
817+
)
818+
819+
steps, _ = merge_steps(base, [_layer(overlay, "project:ov")])
820+
821+
assert [s["id"] for s in steps] == expected_ids
822+
by_id = {s["id"]: s for s in steps}
823+
assert by_id["implement"]["command"] == "custom.impl"
824+
825+
def test_replace_and_insert_order_inside_one_overlay_is_irrelevant(self):
826+
"""Both declaration orders must produce the same result."""
827+
base = [_step("implement"), _step("tail")]
828+
replace_edit = OverlayEdit(
829+
"replace", "implement", {**_step("implement"), "command": "custom.impl"}
830+
)
831+
insert_edit = OverlayEdit("insert_after", "implement", _step("lint"))
832+
833+
first, _ = merge_steps(
834+
base,
835+
[_layer(Overlay(id="ov", extends="wf", priority=10, edits=[replace_edit, insert_edit]), "project:ov")],
836+
)
837+
second, _ = merge_steps(
838+
base,
839+
[_layer(Overlay(id="ov", extends="wf", priority=10, edits=[insert_edit, replace_edit]), "project:ov")],
840+
)
841+
842+
assert [(s["id"], s.get("command")) for s in first] == [
843+
(s["id"], s.get("command")) for s in second
844+
]
845+
846+
def test_remove_then_insert_after_same_overlay_is_unchanged(self):
847+
"""`remove` is deliberately not rescued: it destroys the anchor, so an
848+
insert relative to it cannot also apply. That combination keeps its
849+
existing behaviour; only `replace` is rescued."""
850+
base = [_step("implement"), _step("tail")]
851+
overlay = Overlay(
852+
id="ov",
853+
extends="wf",
854+
priority=10,
855+
edits=[
856+
OverlayEdit("remove", "implement"),
857+
OverlayEdit("insert_after", "implement", _step("lint")),
858+
],
859+
)
860+
861+
steps, _ = merge_steps(base, [_layer(overlay, "project:ov")])
862+
863+
assert [s["id"] for s in steps] == ["implement", "lint", "tail"]
864+
865+
@pytest.mark.parametrize(
866+
"order",
867+
["replace_first", "remove_first"],
868+
)
869+
def test_replace_and_remove_with_trailing_insert_is_unchanged(self, order):
870+
"""A layer asking for two incompatible fates keeps the old outcome.
871+
872+
The rescue applies only to an unambiguous replace-plus-insert layer. If
873+
the winning layer also declared a `remove` on the anchor it is asking
874+
for two incompatible fates, and choosing one is the separate question
875+
this change deliberately does not answer — so the trailing-insert
876+
outcome is preserved and the base step survives, exactly as it did
877+
before the rescue existed. Pinned in both declaration orders so the
878+
rescue cannot start honouring whichever of the two happens to come
879+
first.
880+
"""
881+
base = [_step("implement"), _step("tail")]
882+
replace_edit = OverlayEdit(
883+
"replace", "implement", {**_step("implement"), "command": "custom.impl"}
884+
)
885+
remove_edit = OverlayEdit("remove", "implement")
886+
fate_edits = (
887+
[replace_edit, remove_edit]
888+
if order == "replace_first"
889+
else [remove_edit, replace_edit]
890+
)
891+
overlay = Overlay(
892+
id="ov",
893+
extends="wf",
894+
priority=10,
895+
edits=[
896+
*fate_edits,
897+
OverlayEdit("insert_after", "implement", _step("lint")),
898+
],
899+
)
900+
901+
steps, _ = merge_steps(base, [_layer(overlay, "project:ov")])
902+
903+
assert [s["id"] for s in steps] == ["implement", "lint", "tail"]
904+
# The base step, not the replacement: the ambiguous layer is left alone.
905+
by_id = {s["id"]: s for s in steps}
906+
assert by_id["implement"].get("command") != "custom.impl"
907+
908+
def test_higher_priority_insert_only_overlay_keeps_base_step(self):
909+
"""A later layer that only inserts must NOT resurrect a lower layer's
910+
replace — the fate still comes from the winning layer."""
911+
base = [_step("implement")]
912+
replacer = Overlay(
913+
id="low", extends="wf", priority=5,
914+
edits=[OverlayEdit(
915+
"replace", "implement",
916+
{**_step("implement"), "command": "low.impl"},
917+
)],
918+
)
919+
inserter = Overlay(
920+
id="high", extends="wf", priority=10,
921+
edits=[OverlayEdit("insert_after", "implement", _step("lint"))],
922+
)
923+
924+
steps, _ = merge_steps(
925+
base,
926+
[_layer(replacer, "project:low"), _layer(inserter, "project:high")],
927+
)
928+
929+
by_id = {s["id"]: s for s in steps}
930+
# The insert-only layer wins the anchor, so the base step survives.
931+
assert by_id["implement"]["command"] == "speckit.specify"
932+
assert "lint" in by_id

0 commit comments

Comments
 (0)