mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
fix(workflows): address Copilot review findings in merge engine
- Apply inserts before winning replace to prevent anchor-not-found errors when replace changes step ID (r3594064604) - Track attribution recursively for nested steps in composite inserts/replaces so workflow resolve attributes all child steps correctly (r3594064638) - Add regression tests for both fixes Refs: PR #3557 review discussion Assisted-by: GitHub Copilot (model: qwen3.7-plus, autonomous)
This commit is contained in:
@@ -102,6 +102,61 @@ def _init_sources_recursively(
|
|||||||
_init_sources_recursively(case_steps, sources)
|
_init_sources_recursively(case_steps, sources)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_sources_recursively(
|
||||||
|
step: dict[str, Any],
|
||||||
|
source: str,
|
||||||
|
sources: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""Record *source* for a step and all its nested child steps.
|
||||||
|
|
||||||
|
Traverses ``then``, ``else``, ``steps``, ``default``, and ``cases.*``
|
||||||
|
so that ``workflow resolve`` attributes every step inside a composite
|
||||||
|
insert or replacement to the correct overlay layer.
|
||||||
|
"""
|
||||||
|
step_id = step.get("id")
|
||||||
|
if isinstance(step_id, str):
|
||||||
|
sources[step_id] = source
|
||||||
|
for key in _NESTED_LIST_KEYS:
|
||||||
|
nested = step.get(key)
|
||||||
|
if isinstance(nested, list):
|
||||||
|
for child in nested:
|
||||||
|
if isinstance(child, dict):
|
||||||
|
_record_sources_recursively(child, source, sources)
|
||||||
|
cases = step.get("cases")
|
||||||
|
if isinstance(cases, dict):
|
||||||
|
for case_steps in cases.values():
|
||||||
|
if isinstance(case_steps, list):
|
||||||
|
for child in case_steps:
|
||||||
|
if isinstance(child, dict):
|
||||||
|
_record_sources_recursively(child, source, sources)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_sources_recursively(
|
||||||
|
step: dict[str, Any],
|
||||||
|
sources: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
"""Remove source entries for a step and all its nested child steps.
|
||||||
|
|
||||||
|
Traverses the same nesting keys as ``_record_sources_recursively``.
|
||||||
|
"""
|
||||||
|
step_id = step.get("id")
|
||||||
|
if isinstance(step_id, str):
|
||||||
|
sources.pop(step_id, None)
|
||||||
|
for key in _NESTED_LIST_KEYS:
|
||||||
|
nested = step.get(key)
|
||||||
|
if isinstance(nested, list):
|
||||||
|
for child in nested:
|
||||||
|
if isinstance(child, dict):
|
||||||
|
_remove_sources_recursively(child, sources)
|
||||||
|
cases = step.get("cases")
|
||||||
|
if isinstance(cases, dict):
|
||||||
|
for case_steps in cases.values():
|
||||||
|
if isinstance(case_steps, list):
|
||||||
|
for child in case_steps:
|
||||||
|
if isinstance(child, dict):
|
||||||
|
_remove_sources_recursively(child, sources)
|
||||||
|
|
||||||
|
|
||||||
def apply_edit(
|
def apply_edit(
|
||||||
steps: list[dict[str, Any]],
|
steps: list[dict[str, Any]],
|
||||||
edit: OverlayEdit,
|
edit: OverlayEdit,
|
||||||
@@ -207,30 +262,33 @@ def merge_steps(
|
|||||||
if location is not None:
|
if location is not None:
|
||||||
parent_list, index = location
|
parent_list, index = location
|
||||||
removed_step = parent_list[index]
|
removed_step = parent_list[index]
|
||||||
removed_id = removed_step.get("id") if isinstance(removed_step, dict) else None
|
|
||||||
del parent_list[index]
|
del parent_list[index]
|
||||||
if isinstance(removed_id, str):
|
if isinstance(removed_step, dict):
|
||||||
sources.pop(removed_id, None)
|
_remove_sources_recursively(removed_step, sources)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# For replace/insert_*, the anchor survives. Only the highest-priority
|
# For replace/insert_*, the anchor survives. Only the highest-priority
|
||||||
# replace is applied; lower-priority replaces on the same anchor are
|
# replace is applied; lower-priority replaces on the same anchor are
|
||||||
# skipped. Inserts are applied in merge order.
|
# skipped. Inserts are applied in merge order.
|
||||||
if winning_edit.operation == "replace":
|
#
|
||||||
winning_layer, _ = edits[-1]
|
# Inserts must be applied *before* the winning replace: if the replace
|
||||||
steps, composed, replaced_id = apply_edit(steps, winning_edit, winning_layer.source)
|
# changes the step ID, ``find_step`` can no longer locate the original
|
||||||
if isinstance(replaced_id, str):
|
# anchor and the inserts would raise.
|
||||||
sources.pop(replaced_id, None)
|
|
||||||
if composed is not None:
|
|
||||||
sources[composed.step_id] = composed.source
|
|
||||||
|
|
||||||
for layer, edit in edits:
|
for layer, edit in edits:
|
||||||
if edit.operation in ("insert_after", "insert_before"):
|
if edit.operation in ("insert_after", "insert_before"):
|
||||||
steps, composed, replaced_id = apply_edit(steps, edit, layer.source)
|
steps, composed, replaced_id = apply_edit(steps, edit, layer.source)
|
||||||
if replaced_id is not None:
|
if replaced_id is not None:
|
||||||
sources.pop(replaced_id, None)
|
sources.pop(replaced_id, None)
|
||||||
if composed is not None:
|
if composed is not None:
|
||||||
sources[composed.step_id] = composed.source
|
_record_sources_recursively(edit.step, composed.source, sources)
|
||||||
|
|
||||||
|
if winning_edit.operation == "replace":
|
||||||
|
winning_layer, _ = edits[-1]
|
||||||
|
steps, composed, replaced_id = apply_edit(steps, winning_edit, winning_layer.source)
|
||||||
|
if isinstance(replaced_id, str):
|
||||||
|
sources.pop(replaced_id, None)
|
||||||
|
if composed is not None:
|
||||||
|
_record_sources_recursively(winning_edit.step, composed.source, sources)
|
||||||
|
|
||||||
attribution = _build_attribution(steps, sources)
|
attribution = _build_attribution(steps, sources)
|
||||||
return steps, attribution
|
return steps, attribution
|
||||||
|
|||||||
@@ -364,6 +364,60 @@ class TestMergeSteps:
|
|||||||
for composed in attribution
|
for composed in attribution
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_merge_steps_insert_after_then_replace_same_anchor_id_change(self):
|
||||||
|
"""Inserts must be applied before the winning replace so the anchor still exists.
|
||||||
|
|
||||||
|
Regression: when a replace changes the step ID, applying it before inserts
|
||||||
|
causes ``find_step`` to fail on the now-gone original anchor.
|
||||||
|
"""
|
||||||
|
base = [_step("build")]
|
||||||
|
low = Overlay(
|
||||||
|
id="low",
|
||||||
|
extends="wf",
|
||||||
|
priority=5,
|
||||||
|
edits=[OverlayEdit("insert_after", "build", _step("test"))],
|
||||||
|
)
|
||||||
|
high = Overlay(
|
||||||
|
id="high",
|
||||||
|
extends="wf",
|
||||||
|
priority=10,
|
||||||
|
edits=[OverlayEdit("replace", "build", _step("compile"))],
|
||||||
|
)
|
||||||
|
steps, attribution = merge_steps(
|
||||||
|
base, [_layer(low, "project:low"), _layer(high, "project:high")]
|
||||||
|
)
|
||||||
|
# The insert should land after the original anchor position, then the
|
||||||
|
# anchor is replaced. Final order: ["compile", "test"].
|
||||||
|
assert [s["id"] for s in steps] == ["compile", "test"]
|
||||||
|
assert attribution == [
|
||||||
|
ComposedStep("compile", "project:high"),
|
||||||
|
ComposedStep("test", "project:low"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_merge_steps_insert_before_then_replace_same_anchor_id_change(self):
|
||||||
|
"""Same as above but with insert_before — anchor must still be findable."""
|
||||||
|
base = [_step("build")]
|
||||||
|
low = Overlay(
|
||||||
|
id="low",
|
||||||
|
extends="wf",
|
||||||
|
priority=5,
|
||||||
|
edits=[OverlayEdit("insert_before", "build", _step("lint"))],
|
||||||
|
)
|
||||||
|
high = Overlay(
|
||||||
|
id="high",
|
||||||
|
extends="wf",
|
||||||
|
priority=10,
|
||||||
|
edits=[OverlayEdit("replace", "build", _step("compile"))],
|
||||||
|
)
|
||||||
|
steps, attribution = merge_steps(
|
||||||
|
base, [_layer(low, "project:low"), _layer(high, "project:high")]
|
||||||
|
)
|
||||||
|
assert [s["id"] for s in steps] == ["lint", "compile"]
|
||||||
|
assert attribution == [
|
||||||
|
ComposedStep("lint", "project:low"),
|
||||||
|
ComposedStep("compile", "project:high"),
|
||||||
|
]
|
||||||
|
|
||||||
def test_merge_steps_unknown_anchor_still_raises(self):
|
def test_merge_steps_unknown_anchor_still_raises(self):
|
||||||
base = [_step("a")]
|
base = [_step("a")]
|
||||||
overlay = Overlay(
|
overlay = Overlay(
|
||||||
@@ -375,6 +429,130 @@ class TestMergeSteps:
|
|||||||
with pytest.raises(ValueError, match="Anchor 'missing' not found"):
|
with pytest.raises(ValueError, match="Anchor 'missing' not found"):
|
||||||
merge_steps(base, [_layer(overlay, "project:ov")])
|
merge_steps(base, [_layer(overlay, "project:ov")])
|
||||||
|
|
||||||
|
# ── composite step attribution ───────────────────────────────────────
|
||||||
|
|
||||||
|
def test_merge_insert_composite_if_attribution(self):
|
||||||
|
"""Nested then/else children of an inserted 'if' step get the overlay source."""
|
||||||
|
base = [_step("a")]
|
||||||
|
composite = {
|
||||||
|
"id": "if-1",
|
||||||
|
"type": "if",
|
||||||
|
"condition": "true",
|
||||||
|
"then": [_step("then-a")],
|
||||||
|
"else": [_step("else-b")],
|
||||||
|
}
|
||||||
|
overlay = Overlay(
|
||||||
|
id="ov", extends="wf", priority=10,
|
||||||
|
edits=[OverlayEdit("insert_after", "a", composite)],
|
||||||
|
)
|
||||||
|
_steps, attribution = merge_steps(
|
||||||
|
base, [_layer(overlay, "project:ov")]
|
||||||
|
)
|
||||||
|
assert attribution == [
|
||||||
|
ComposedStep("a", "base"),
|
||||||
|
ComposedStep("if-1", "project:ov"),
|
||||||
|
ComposedStep("then-a", "project:ov"),
|
||||||
|
ComposedStep("else-b", "project:ov"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_merge_insert_composite_switch_attribution(self):
|
||||||
|
"""Nested cases/default children of an inserted 'switch' step get the overlay source."""
|
||||||
|
base = [_step("a")]
|
||||||
|
composite = {
|
||||||
|
"id": "switch-1",
|
||||||
|
"type": "switch",
|
||||||
|
"expression": "{{inputs.x}}",
|
||||||
|
"cases": {"one": [_step("case-one")], "two": [_step("case-two")]},
|
||||||
|
"default": [_step("default-z")],
|
||||||
|
}
|
||||||
|
overlay = Overlay(
|
||||||
|
id="ov", extends="wf", priority=10,
|
||||||
|
edits=[OverlayEdit("insert_before", "a", composite)],
|
||||||
|
)
|
||||||
|
_steps, attribution = merge_steps(
|
||||||
|
base, [_layer(overlay, "project:ov")]
|
||||||
|
)
|
||||||
|
assert attribution == [
|
||||||
|
ComposedStep("switch-1", "project:ov"),
|
||||||
|
ComposedStep("default-z", "project:ov"),
|
||||||
|
ComposedStep("case-one", "project:ov"),
|
||||||
|
ComposedStep("case-two", "project:ov"),
|
||||||
|
ComposedStep("a", "base"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_merge_replace_flat_with_composite_attribution(self):
|
||||||
|
"""Replacing a flat step with a composite step attributes all nested children."""
|
||||||
|
base = [_step("a")]
|
||||||
|
composite = {
|
||||||
|
"id": "if-1",
|
||||||
|
"type": "if",
|
||||||
|
"condition": "true",
|
||||||
|
"then": [_step("inner-x"), _step("inner-y")],
|
||||||
|
}
|
||||||
|
overlay = Overlay(
|
||||||
|
id="ov", extends="wf", priority=10,
|
||||||
|
edits=[OverlayEdit("replace", "a", composite)],
|
||||||
|
)
|
||||||
|
_steps, attribution = merge_steps(
|
||||||
|
base, [_layer(overlay, "project:ov")]
|
||||||
|
)
|
||||||
|
assert attribution == [
|
||||||
|
ComposedStep("if-1", "project:ov"),
|
||||||
|
ComposedStep("inner-x", "project:ov"),
|
||||||
|
ComposedStep("inner-y", "project:ov"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_merge_remove_composite_step_cleans_nested_sources(self):
|
||||||
|
"""Removing a composite step also cleans its nested children from sources."""
|
||||||
|
base = [
|
||||||
|
{
|
||||||
|
"id": "if-1",
|
||||||
|
"type": "if",
|
||||||
|
"condition": "true",
|
||||||
|
"then": [_step("then-a")],
|
||||||
|
"else": [_step("else-b")],
|
||||||
|
},
|
||||||
|
_step("a"),
|
||||||
|
]
|
||||||
|
overlay = Overlay(
|
||||||
|
id="ov", extends="wf", priority=10,
|
||||||
|
edits=[OverlayEdit("remove", "if-1")],
|
||||||
|
)
|
||||||
|
steps, attribution = merge_steps(
|
||||||
|
base, [_layer(overlay, "project:ov")]
|
||||||
|
)
|
||||||
|
assert [s["id"] for s in steps] == ["a"]
|
||||||
|
assert attribution == [ComposedStep("a", "base")]
|
||||||
|
|
||||||
|
def test_merge_insert_deeply_nested_composite_attribution(self):
|
||||||
|
"""Deep nesting (if inside while) gets the overlay source at every level."""
|
||||||
|
base = [_step("a")]
|
||||||
|
inner_if = {
|
||||||
|
"id": "inner-if",
|
||||||
|
"type": "if",
|
||||||
|
"condition": "true",
|
||||||
|
"then": [_step("deep-x")],
|
||||||
|
}
|
||||||
|
composite = {
|
||||||
|
"id": "while-1",
|
||||||
|
"type": "while",
|
||||||
|
"condition": "true",
|
||||||
|
"steps": [inner_if],
|
||||||
|
}
|
||||||
|
overlay = Overlay(
|
||||||
|
id="ov", extends="wf", priority=10,
|
||||||
|
edits=[OverlayEdit("insert_after", "a", composite)],
|
||||||
|
)
|
||||||
|
_steps, attribution = merge_steps(
|
||||||
|
base, [_layer(overlay, "project:ov")]
|
||||||
|
)
|
||||||
|
assert attribution == [
|
||||||
|
ComposedStep("a", "base"),
|
||||||
|
ComposedStep("while-1", "project:ov"),
|
||||||
|
ComposedStep("inner-if", "project:ov"),
|
||||||
|
ComposedStep("deep-x", "project:ov"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestValidateEdits:
|
class TestValidateEdits:
|
||||||
"""Edit validation against known base step IDs."""
|
"""Edit validation against known base step IDs."""
|
||||||
|
|||||||
@@ -532,6 +532,46 @@ class TestWorkflowResolver:
|
|||||||
with pytest.raises(ValueError, match="Symlinked overlay directories are not allowed"):
|
with pytest.raises(ValueError, match="Symlinked overlay directories are not allowed"):
|
||||||
resolver.resolve("wf")
|
resolver.resolve("wf")
|
||||||
|
|
||||||
|
def test_resolve_attribution_for_inserted_composite_step(self, project_dir):
|
||||||
|
"""Inserted composite steps must attribute nested children to the overlay source."""
|
||||||
|
data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||||
|
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||||
|
}
|
||||||
|
_write_workflow(project_dir, "wf", data)
|
||||||
|
_write_overlay(
|
||||||
|
project_dir,
|
||||||
|
"wf",
|
||||||
|
"ov1",
|
||||||
|
{
|
||||||
|
"id": "ov1",
|
||||||
|
"extends": "wf",
|
||||||
|
"priority": 10,
|
||||||
|
"edits": [
|
||||||
|
{
|
||||||
|
"operation": "insert_after",
|
||||||
|
"anchor": "a",
|
||||||
|
"step": {
|
||||||
|
"id": "if-1",
|
||||||
|
"type": "if",
|
||||||
|
"condition": "true",
|
||||||
|
"then": [{"id": "then-x", "type": "command", "command": "echo"}],
|
||||||
|
"else": [{"id": "else-y", "type": "command", "command": "echo"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
resolver = WorkflowResolver(project_dir)
|
||||||
|
_definition, _layers, attribution = resolver.resolve_with_layers("wf")
|
||||||
|
sources = {c.step_id: c.source for c in attribution}
|
||||||
|
assert sources["a"] == "base"
|
||||||
|
assert sources["if-1"] == "project:ov1"
|
||||||
|
assert sources["then-x"] == "project:ov1"
|
||||||
|
assert sources["else-y"] == "project:ov1"
|
||||||
|
|
||||||
def test_engine_load_workflow_uses_resolver(self, project_dir):
|
def test_engine_load_workflow_uses_resolver(self, project_dir):
|
||||||
from specify_cli.workflows.engine import WorkflowEngine
|
from specify_cli.workflows.engine import WorkflowEngine
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user