From cfc1b4c299f412adfc684ba915f3b8af8a35106d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Mon, 31 Aug 2026 11:07:31 +0800 Subject: [PATCH 1/4] fix(workspace): preserve YAML boundaries when adding members --- scripts/work-bundle/control_plane.py | 22 ++++++++-- tests/test_multi_repository_member.py | 60 ++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/scripts/work-bundle/control_plane.py b/scripts/work-bundle/control_plane.py index 9a9a7f0..d976477 100644 --- a/scripts/work-bundle/control_plane.py +++ b/scripts/work-bundle/control_plane.py @@ -1599,10 +1599,16 @@ def _append_member_metadata(text: str, member: dict[str, str]) -> str: text = re.sub(r"^(\s{2}mode: )single-repository\s*$", r"\1composite", text, count=1, flags=re.MULTILINE) block = _render_member_metadata_block(member, multi=_workspace_value(text, "mode") == "multi-repository") lines = text.splitlines(keepends=True) - start = next(i for i, line in enumerate(lines) if line.rstrip() == "source_repositories:") - end = next((i for i in range(start + 1, len(lines)) if re.match(r"^[A-Za-z_][\w-]*:", lines[i])), len(lines)) + start = next((i for i, line in enumerate(lines) + if re.fullmatch(r"source_repositories:\s*(?:#.*)?", line.rstrip())), None) + if start is None: + raise ControlPlaneError("WB_CONTROL_PLANE_METADATA_INVALID") + # Any non-comment root content ends the list, including quoted keys and + # document markers. Preserve its bytes instead of assuming bare key names. + end = next((i for i in range(start + 1, len(lines)) + if re.match(r"^[^\s#]", lines[i])), len(lines)) prefix = "".join(lines[:end]) - return prefix.rstrip("\n") + "\n" + block + "".join(lines[end:]) + return prefix + ("" if prefix.endswith("\n") else "\n") + block + "".join(lines[end:]) def _require_observed_branch(path: Path, expected: str, repository_id: str) -> str: @@ -1686,6 +1692,16 @@ def _require_add_workspace_member_request(member: dict[str, str]) -> None: def _require_add_workspace_member_target(text: str, member: dict[str, str], classification: str) -> None: _require_add_workspace_member_request(member) rendered = text if classification == "match" else _append_member_metadata(text, member) + # Validate the complete target before a proposal or transaction can publish + # it. The dependency-free loader needs the list-header comment removed only + # in its parsing view; the document written to disk remains byte-preserving. + parsing_view = re.sub(r"^source_repositories:[ \t]*#.*$", "source_repositories:", rendered, flags=re.MULTILINE) + try: + document = _load_yaml(parsing_view) + except Exception: + raise ControlPlaneError("WB_CONTROL_PLANE_METADATA_INVALID") from None + if not isinstance(document, dict) or not isinstance(document.get("source_repositories"), list): + raise ControlPlaneError("WB_CONTROL_PLANE_METADATA_INVALID") failures = _portable_failures(rendered) if failures: raise ControlPlaneError(failures[0]) diff --git a/tests/test_multi_repository_member.py b/tests/test_multi_repository_member.py index d698c73..25c9ca1 100644 --- a/tests/test_multi_repository_member.py +++ b/tests/test_multi_repository_member.py @@ -8,7 +8,7 @@ import yaml from test_control_plane_v4 import ( - add_workspace_member_args, config_root, git, make_remote, run_wb, + add_workspace_member_args, config_root, git, init_single_v4, make_remote, run_wb, ) @@ -199,3 +199,61 @@ def test_multi_member_add_only_and_collisions(multi, tmp_path): assert result.returncode == 1 assert before == metadata.read_bytes() assert len(yaml.safe_load(before)["source_repositories"]) == 3 + + +@pytest.mark.parametrize("mode", ["single", "multi"]) +@pytest.mark.parametrize("shape", ["commented-header", "quoted-key"]) +@pytest.mark.parametrize("dependency_free", [False, True]) +def test_member_add_preserves_yaml_section_boundaries(multi, tmp_path, monkeypatch, mode, shape, dependency_free): + config, workspace, remote = multi + if mode == "single": + config, workspace, _, _ = init_single_v4(tmp_path / "single") + metadata = workspace / ".work-bundle/project.yaml" + text = metadata.read_text() + if shape == "commented-header": + text = text.replace("source_repositories:", "source_repositories: # managed members") + else: + text = text.replace("prefer_subagent:", "'custom_owner_field': retained\nprefer_subagent:") + metadata.write_text(text) + original = yaml.safe_load(text) + if dependency_free: + # Exercise the actual CLI without its optional YAML dependency, while + # retaining PyYAML in the test process as an independent output oracle. + no_yaml = tmp_path / "no-yaml" + no_yaml.mkdir() + (no_yaml / "yaml.py").write_text("raise ImportError('dependency-free regression')\n") + monkeypatch.setenv("PYTHONPATH", str(no_yaml)) + proposal = propose(config, workspace, remote) + assert metadata.read_text() == text + result = apply(config, workspace, remote, proposal) + assert result.returncode == 0, result.stdout + result.stderr + document = yaml.safe_load(metadata.read_text()) + assert len(document["source_repositories"]) == 2 + assert document["source_repositories"][0] == original["source_repositories"][0] + if shape == "quoted-key": + assert document["custom_owner_field"] == "retained" + else: + assert "source_repositories: # managed members" in metadata.read_text() + replay = apply(config, workspace, remote, propose(config, workspace, remote)) + assert replay.returncode == 0, replay.stdout + replay.stderr + assert json.loads(replay.stdout)["changed_files"] == [] + + +@pytest.mark.parametrize("dependency_free", [False, True]) +def test_member_add_rejects_invalid_complete_document_without_mutation(multi, tmp_path, monkeypatch, dependency_free): + config, workspace, remote = multi + if dependency_free: + no_yaml = tmp_path / "no-yaml" + no_yaml.mkdir() + (no_yaml / "yaml.py").write_text("raise ImportError('dependency-free regression')\n") + monkeypatch.setenv("PYTHONPATH", str(no_yaml)) + metadata = workspace / ".work-bundle/project.yaml" + registry = config / "registry/projects.yaml" + metadata.write_text(metadata.read_text() + "'owner_field': retained\n invalid_child: value\n") + before = metadata.read_bytes(), registry.read_bytes() + result = run_wb(config, *add_workspace_member_args(workspace, remote), "--dry-run") + assert result.returncode == 1 + assert json.loads(result.stdout)["failure_code"] == "WB_CONTROL_PLANE_METADATA_INVALID" + assert "Traceback" not in result.stderr + assert before == (metadata.read_bytes(), registry.read_bytes()) + assert not (workspace / "execution-flow").exists() From 926c74ffdbbe8a578e4ff136292546979faddeb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Mon, 31 Aug 2026 11:12:44 +0800 Subject: [PATCH 2/4] fix(workspace): preserve dependency-free owner metadata --- scripts/work-bundle/control_plane.py | 19 ++++++------ tests/test_multi_repository_member.py | 44 ++++++++++++++++++--------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/scripts/work-bundle/control_plane.py b/scripts/work-bundle/control_plane.py index d976477..06c10cc 100644 --- a/scripts/work-bundle/control_plane.py +++ b/scripts/work-bundle/control_plane.py @@ -1692,19 +1692,18 @@ def _require_add_workspace_member_request(member: dict[str, str]) -> None: def _require_add_workspace_member_target(text: str, member: dict[str, str], classification: str) -> None: _require_add_workspace_member_request(member) rendered = text if classification == "match" else _append_member_metadata(text, member) - # Validate the complete target before a proposal or transaction can publish - # it. The dependency-free loader needs the list-header comment removed only - # in its parsing view; the document written to disk remains byte-preserving. - parsing_view = re.sub(r"^source_repositories:[ \t]*#.*$", "source_repositories:", rendered, flags=re.MULTILINE) - try: - document = _load_yaml(parsing_view) - except Exception: - raise ControlPlaneError("WB_CONTROL_PLANE_METADATA_INVALID") from None - if not isinstance(document, dict) or not isinstance(document.get("source_repositories"), list): - raise ControlPlaneError("WB_CONTROL_PLANE_METADATA_INVALID") failures = _portable_failures(rendered) if failures: raise ControlPlaneError(failures[0]) + # Verify the intended change through the portable metadata reader. A block + # outside the source list must never pass just because older sources remain + # valid. Unmodified owner fields must not inherit the script-index YAML + # loader's restricted grammar or acquire an optional dependency requirement. + repositories = _v4_repositories(rendered) + if _classify_workspace_member(repositories, member) != "match": + raise ControlPlaneError("WB_CONTROL_PLANE_METADATA_INVALID") + if classification != "match" and repositories[:-1] != _v4_repositories(text): + raise ControlPlaneError("WB_CONTROL_PLANE_METADATA_INVALID") def _require_add_workspace_member_replay_state( diff --git a/tests/test_multi_repository_member.py b/tests/test_multi_repository_member.py index 25c9ca1..bac5846 100644 --- a/tests/test_multi_repository_member.py +++ b/tests/test_multi_repository_member.py @@ -202,7 +202,7 @@ def test_multi_member_add_only_and_collisions(multi, tmp_path): @pytest.mark.parametrize("mode", ["single", "multi"]) -@pytest.mark.parametrize("shape", ["commented-header", "quoted-key"]) +@pytest.mark.parametrize("shape", ["commented-header", "quoted-key", "commented-control", "owner-flow", "owner-comment"]) @pytest.mark.parametrize("dependency_free", [False, True]) def test_member_add_preserves_yaml_section_boundaries(multi, tmp_path, monkeypatch, mode, shape, dependency_free): config, workspace, remote = multi @@ -212,8 +212,14 @@ def test_member_add_preserves_yaml_section_boundaries(multi, tmp_path, monkeypat text = metadata.read_text() if shape == "commented-header": text = text.replace("source_repositories:", "source_repositories: # managed members") - else: + elif shape == "quoted-key": text = text.replace("prefer_subagent:", "'custom_owner_field': retained\nprefer_subagent:") + elif shape == "commented-control": + text = text.replace("control_plane:", "control_plane: # portable settings") + elif shape == "owner-flow": + text += "owner_settings: {}\n" + else: + text += "owner_settings: # retained\n team: engineering\n" metadata.write_text(text) original = yaml.safe_load(text) if dependency_free: @@ -230,30 +236,38 @@ def test_member_add_preserves_yaml_section_boundaries(multi, tmp_path, monkeypat document = yaml.safe_load(metadata.read_text()) assert len(document["source_repositories"]) == 2 assert document["source_repositories"][0] == original["source_repositories"][0] + for key, value in original.items(): + if key not in {"source_repositories", "workspace"}: + assert document[key] == value if shape == "quoted-key": assert document["custom_owner_field"] == "retained" - else: + elif shape == "commented-header": assert "source_repositories: # managed members" in metadata.read_text() + elif shape == "commented-control": + assert "control_plane: # portable settings" in metadata.read_text() + elif shape == "owner-comment": + assert "owner_settings: # retained\n team: engineering\n" in metadata.read_text() replay = apply(config, workspace, remote, propose(config, workspace, remote)) assert replay.returncode == 0, replay.stdout + replay.stderr assert json.loads(replay.stdout)["changed_files"] == [] -@pytest.mark.parametrize("dependency_free", [False, True]) -def test_member_add_rejects_invalid_complete_document_without_mutation(multi, tmp_path, monkeypatch, dependency_free): +def test_member_add_rejects_misplaced_rendered_block_without_mutation(multi, monkeypatch, capsys): + import control_plane + config, workspace, remote = multi - if dependency_free: - no_yaml = tmp_path / "no-yaml" - no_yaml.mkdir() - (no_yaml / "yaml.py").write_text("raise ImportError('dependency-free regression')\n") - monkeypatch.setenv("PYTHONPATH", str(no_yaml)) + monkeypatch.setenv("WB_CONFIG_ROOT", str(config)) + # Inject the publication defect reported in review: the generated member + # appears after a root scalar instead of inside source_repositories. + monkeypatch.setattr(control_plane, "_append_member_metadata", lambda text, member: + text + control_plane._render_member_metadata_block(member, multi=True)) metadata = workspace / ".work-bundle/project.yaml" registry = config / "registry/projects.yaml" - metadata.write_text(metadata.read_text() + "'owner_field': retained\n invalid_child: value\n") before = metadata.read_bytes(), registry.read_bytes() - result = run_wb(config, *add_workspace_member_args(workspace, remote), "--dry-run") - assert result.returncode == 1 - assert json.loads(result.stdout)["failure_code"] == "WB_CONTROL_PLANE_METADATA_INVALID" - assert "Traceback" not in result.stderr + result = control_plane.cmd_add_workspace_member(add_workspace_member_args(workspace, remote)[1:] + ["--dry-run"]) + assert result == 1 + output = capsys.readouterr() + assert json.loads(output.out)["failure_code"] == "WB_CONTROL_PLANE_METADATA_INVALID" + assert "Traceback" not in output.err assert before == (metadata.read_bytes(), registry.read_bytes()) assert not (workspace / "execution-flow").exists() From 8d0aced834f225a4a2fc7b98af8e46b9cb8b0d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Mon, 31 Aug 2026 11:17:24 +0800 Subject: [PATCH 3/4] fix(workspace): share source reader and writer boundaries --- scripts/work-bundle/control_plane.py | 26 ++++++++++++++++++-------- tests/test_multi_repository_member.py | 4 +++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/scripts/work-bundle/control_plane.py b/scripts/work-bundle/control_plane.py index 06c10cc..b8be492 100644 --- a/scripts/work-bundle/control_plane.py +++ b/scripts/work-bundle/control_plane.py @@ -409,8 +409,22 @@ def _v3_repositories(text: str) -> list[dict[str, object]]: return repositories +def _source_repository_bounds(lines: list[str]) -> tuple[int, int] | None: + start = next((i for i, line in enumerate(lines) + if re.fullmatch(r"source_repositories:\s*(?:#.*)?", line.rstrip())), None) + if start is None: + return None + # Any non-comment root content ends the list, including quoted keys and + # document markers. Readers and writers must agree on this boundary. + end = next((i for i in range(start + 1, len(lines)) + if re.match(r"^[^\s#]", lines[i])), len(lines)) + return start, end + + def _v4_repositories(text: str) -> list[dict[str, object]]: - repositories = _parse_list_items(_block(text, "source_repositories")) + lines = text.splitlines(keepends=True) + bounds = _source_repository_bounds(lines) + repositories = _parse_list_items("".join(lines[bounds[0]:bounds[1]])) if bounds else [] # The generic parser retains remote as a mapping. Normalize its canonical field. for repository in repositories: remote = repository.get("remote") @@ -1599,14 +1613,10 @@ def _append_member_metadata(text: str, member: dict[str, str]) -> str: text = re.sub(r"^(\s{2}mode: )single-repository\s*$", r"\1composite", text, count=1, flags=re.MULTILINE) block = _render_member_metadata_block(member, multi=_workspace_value(text, "mode") == "multi-repository") lines = text.splitlines(keepends=True) - start = next((i for i, line in enumerate(lines) - if re.fullmatch(r"source_repositories:\s*(?:#.*)?", line.rstrip())), None) - if start is None: + bounds = _source_repository_bounds(lines) + if bounds is None: raise ControlPlaneError("WB_CONTROL_PLANE_METADATA_INVALID") - # Any non-comment root content ends the list, including quoted keys and - # document markers. Preserve its bytes instead of assuming bare key names. - end = next((i for i in range(start + 1, len(lines)) - if re.match(r"^[^\s#]", lines[i])), len(lines)) + _, end = bounds prefix = "".join(lines[:end]) return prefix + ("" if prefix.endswith("\n") else "\n") + block + "".join(lines[end:]) diff --git a/tests/test_multi_repository_member.py b/tests/test_multi_repository_member.py index bac5846..4bf69cb 100644 --- a/tests/test_multi_repository_member.py +++ b/tests/test_multi_repository_member.py @@ -202,7 +202,7 @@ def test_multi_member_add_only_and_collisions(multi, tmp_path): @pytest.mark.parametrize("mode", ["single", "multi"]) -@pytest.mark.parametrize("shape", ["commented-header", "quoted-key", "commented-control", "owner-flow", "owner-comment"]) +@pytest.mark.parametrize("shape", ["commented-header", "quoted-key", "quoted-nested", "commented-control", "owner-flow", "owner-comment"]) @pytest.mark.parametrize("dependency_free", [False, True]) def test_member_add_preserves_yaml_section_boundaries(multi, tmp_path, monkeypatch, mode, shape, dependency_free): config, workspace, remote = multi @@ -214,6 +214,8 @@ def test_member_add_preserves_yaml_section_boundaries(multi, tmp_path, monkeypat text = text.replace("source_repositories:", "source_repositories: # managed members") elif shape == "quoted-key": text = text.replace("prefer_subagent:", "'custom_owner_field': retained\nprefer_subagent:") + elif shape == "quoted-nested": + text = text.replace("prefer_subagent:", "'owner_settings':\n contact:\n team: engineering\nprefer_subagent:") elif shape == "commented-control": text = text.replace("control_plane:", "control_plane: # portable settings") elif shape == "owner-flow": From ca5dd1774b78c421dc49fff06be1a08b5e8ef9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=8D=87=E9=BE=99?= Date: Mon, 31 Aug 2026 11:20:43 +0800 Subject: [PATCH 4/4] fix(workspace): retain anchored source header compatibility --- scripts/work-bundle/control_plane.py | 2 +- tests/test_multi_repository_member.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/work-bundle/control_plane.py b/scripts/work-bundle/control_plane.py index b8be492..adec5e2 100644 --- a/scripts/work-bundle/control_plane.py +++ b/scripts/work-bundle/control_plane.py @@ -411,7 +411,7 @@ def _v3_repositories(text: str) -> list[dict[str, object]]: def _source_repository_bounds(lines: list[str]) -> tuple[int, int] | None: start = next((i for i, line in enumerate(lines) - if re.fullmatch(r"source_repositories:\s*(?:#.*)?", line.rstrip())), None) + if re.match(r"^source_repositories:(?:\s|$)", line)), None) if start is None: return None # Any non-comment root content ends the list, including quoted keys and diff --git a/tests/test_multi_repository_member.py b/tests/test_multi_repository_member.py index 4bf69cb..4a4417f 100644 --- a/tests/test_multi_repository_member.py +++ b/tests/test_multi_repository_member.py @@ -202,7 +202,7 @@ def test_multi_member_add_only_and_collisions(multi, tmp_path): @pytest.mark.parametrize("mode", ["single", "multi"]) -@pytest.mark.parametrize("shape", ["commented-header", "quoted-key", "quoted-nested", "commented-control", "owner-flow", "owner-comment"]) +@pytest.mark.parametrize("shape", ["commented-header", "anchored-header", "quoted-key", "quoted-nested", "commented-control", "owner-flow", "owner-comment"]) @pytest.mark.parametrize("dependency_free", [False, True]) def test_member_add_preserves_yaml_section_boundaries(multi, tmp_path, monkeypatch, mode, shape, dependency_free): config, workspace, remote = multi @@ -212,6 +212,8 @@ def test_member_add_preserves_yaml_section_boundaries(multi, tmp_path, monkeypat text = metadata.read_text() if shape == "commented-header": text = text.replace("source_repositories:", "source_repositories: # managed members") + elif shape == "anchored-header": + text = text.replace("source_repositories:", "source_repositories: &sources") elif shape == "quoted-key": text = text.replace("prefer_subagent:", "'custom_owner_field': retained\nprefer_subagent:") elif shape == "quoted-nested": @@ -245,6 +247,8 @@ def test_member_add_preserves_yaml_section_boundaries(multi, tmp_path, monkeypat assert document["custom_owner_field"] == "retained" elif shape == "commented-header": assert "source_repositories: # managed members" in metadata.read_text() + elif shape == "anchored-header": + assert "source_repositories: &sources" in metadata.read_text() elif shape == "commented-control": assert "control_plane: # portable settings" in metadata.read_text() elif shape == "owner-comment":