Преглед изворни кода

方向测试:覆盖目标树、Host 投影与旧载荷拒绝

增加 30 个目标节点、一层父子关系、成功标准、约束偏好和确定性 Markdown 投影测试。

同步更新 Phase 1 scripted runner、恢复边界和仓储测试,并证明旧 Direction 字段无法被当前水合逻辑接受。
SamLee пре 12 часа
родитељ
комит
bc90bd7efb

+ 114 - 0
script_build_host/tests/test_direction_artifact.py

@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import pytest
+
+from script_build_host.domain.artifacts import (
+    ArtifactKind,
+    DirectionArtifact,
+    DirectionConstraint,
+    DirectionGoal,
+    DirectionPreference,
+)
+from script_build_host.domain.errors import ProtocolViolation
+from script_build_host.repositories.sqlalchemy import _hydrate_artifact
+from script_build_host.tools.registry import _save_direction_candidate_schema
+
+EVIDENCE_REF = "script-build://artifact-versions/1"
+
+
+def _goal(index: int, *, parent: str | None = None) -> DirectionGoal:
+    return DirectionGoal(
+        goal_id=f"goal-{index}",
+        parent_goal_id=parent,
+        statement=f"goal statement {index}",
+        rationale=f"goal rationale {index}",
+        success_criteria=(f"goal criterion {index}",),
+    )
+
+
+def test_direction_supports_hierarchy_rules_and_deterministic_projection() -> None:
+    direction = DirectionArtifact(
+        goals=(_goal(1), _goal(2, parent="goal-1"), _goal(3)),
+        constraints=(
+            DirectionConstraint("constraint-1", "facts remain traceable", "hard boundary"),
+        ),
+        preferences=(DirectionPreference("preference-1", "prefer a concise opening", priority=1),),
+        evidence_refs=(EVIDENCE_REF,),
+    )
+
+    payload = direction.content_payload()
+
+    assert payload["schema_version"] == "script-direction/v1"
+    assert payload["goals"][1]["parent_goal_id"] == "goal-1"
+    assert payload["constraints"][0]["constraint_id"] == "constraint-1"
+    assert payload["preferences"][0]["priority"] == 1
+    assert "### goal-1: goal statement 1" in direction.legacy_markdown
+    assert "#### goal-2: goal statement 2" in direction.legacy_markdown
+    assert "## Constraints" in direction.legacy_markdown
+    assert "## Preferences" in direction.legacy_markdown
+    assert EVIDENCE_REF in direction.legacy_markdown
+
+
+def test_direction_accepts_thirty_goal_nodes_and_rejects_thirty_one() -> None:
+    DirectionArtifact(
+        goals=tuple(_goal(index) for index in range(1, 31)), evidence_refs=(EVIDENCE_REF,)
+    )
+
+    with pytest.raises(ValueError, match="between one and thirty"):
+        DirectionArtifact(
+            goals=tuple(_goal(index) for index in range(1, 32)),
+            evidence_refs=(EVIDENCE_REF,),
+        )
+
+
+@pytest.mark.parametrize(
+    ("goals", "message"),
+    [
+        ((_goal(1, parent="missing"),), "parent must exist"),
+        (
+            (
+                _goal(1),
+                _goal(2, parent="goal-1"),
+                _goal(3, parent="goal-2"),
+            ),
+            "only two levels",
+        ),
+        (
+            (DirectionGoal("goal-1", "statement", "rationale", success_criteria=()),),
+            "requires success criteria",
+        ),
+        (
+            (DirectionGoal("goal-1", "statement", "", success_criteria=("check",)),),
+            "requires a rationale",
+        ),
+    ],
+)
+def test_direction_rejects_invalid_goal_hierarchy(
+    goals: tuple[DirectionGoal, ...], message: str
+) -> None:
+    with pytest.raises(ValueError, match=message):
+        DirectionArtifact(goals=goals, evidence_refs=(EVIDENCE_REF,))
+
+
+def test_direction_tool_contract_keeps_schema_name_and_removes_model_markdown() -> None:
+    parameters = _save_direction_candidate_schema()["function"]["parameters"]
+
+    assert parameters["properties"]["goals"]["maxItems"] == 30
+    assert "constraints" in parameters["required"]
+    assert "preferences" in parameters["required"]
+    assert "legacy_markdown" not in parameters["properties"]
+    assert "criteria" not in parameters["properties"]
+
+
+def test_direction_hydration_rejects_historical_structure_without_compatibility() -> None:
+    historical_payload = {
+        "schema_version": "script-direction/v1",
+        "goals": [{"goal_id": "goal-1", "statement": "old goal", "rationale": "old rationale"}],
+        "criteria": [{"criterion_id": "criterion-1", "description": "old criterion"}],
+        "domain_criteria": [],
+        "evidence_refs": [EVIDENCE_REF],
+        "legacy_markdown": "# old projection",
+    }
+
+    with pytest.raises(ProtocolViolation, match="current structured contract"):
+        _hydrate_artifact(ArtifactKind.DIRECTION, historical_payload, "sha256:" + "a" * 64)

+ 20 - 13
script_build_host/tests/test_mission_service_boundaries.py

@@ -21,8 +21,8 @@ from script_build_host.application.mission_service import (
 from script_build_host.domain.artifacts import (
     ArtifactKind,
     ArtifactState,
+    DirectionArtifact,
     DirectionGoal,
-    ScriptDirectionArtifactV1,
 )
 from script_build_host.domain.errors import (
     DirectionProjectionConflict,
@@ -181,16 +181,16 @@ async def test_stop_is_idempotent_and_publication_is_gated_by_durable_status() -
 
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
-    ("publication_state", "projected", "active"),
+    ("publication_state", "has_projection", "active"),
     [
-        (PublicationState.PENDING, "# direction", None),
-        (PublicationState.FAILED, "# direction", 3),
-        (PublicationState.PUBLISHED, "# direction", 3),
+        (PublicationState.PENDING, True, None),
+        (PublicationState.FAILED, True, 3),
+        (PublicationState.PUBLISHED, True, 3),
     ],
 )
 async def test_direction_reconciler_reenters_each_publication_crash_point(
     publication_state: PublicationState,
-    projected: str | None,
+    has_projection: bool,
     active: int | None,
 ) -> None:
     ref = ArtifactRef(
@@ -199,11 +199,15 @@ async def test_direction_reconciler_reenters_each_publication_crash_point(
         "3",
         "sha256:" + "a" * 64,
     )
-    direction = ScriptDirectionArtifactV1(
-        goals=(DirectionGoal("goal", "statement"),),
+    direction = DirectionArtifact(
+        goals=(
+            DirectionGoal(
+                "goal", "statement", "the statement is testable", success_criteria=("done",)
+            ),
+        ),
         evidence_refs=("script-build://artifact-versions/2",),
-        legacy_markdown="# direction",
     )
+    projected = direction.legacy_markdown if has_projection else None
     version = SimpleNamespace(
         artifact_version_id=3,
         canonical_sha256=ref.digest,
@@ -291,7 +295,7 @@ async def test_direction_reconciler_reenters_each_publication_crash_point(
     )
 
     assert await reconciler.reconcile(7, "root") == 3
-    assert state.direction == "# direction"
+    assert state.direction == direction.legacy_markdown
     assert bindings.current == 3
     assert publications.state is PublicationState.PUBLISHED
     assert publications.published_calls == (
@@ -307,10 +311,13 @@ async def test_direction_reconciler_fails_closed_on_projection_conflict() -> Non
         "3",
         "sha256:" + "a" * 64,
     )
-    direction = ScriptDirectionArtifactV1(
-        goals=(DirectionGoal("goal", "statement"),),
+    direction = DirectionArtifact(
+        goals=(
+            DirectionGoal(
+                "goal", "statement", "the statement is testable", success_criteria=("done",)
+            ),
+        ),
         evidence_refs=("script-build://artifact-versions/2",),
-        legacy_markdown="# accepted direction",
     )
     task = SimpleNamespace(
         task_id="direction-task",

+ 10 - 5
script_build_host/tests/test_phase_one_e2e.py

@@ -106,6 +106,7 @@ def _contract(
             "max_external_queries": 40,
             "max_no_improvement": 3,
         },
+        "goal_ids": [],
         "supersedes_decision_ids": [],
         "candidate_closure_decision_refs": [],
         "adopted_decision_ids": [],
@@ -308,15 +309,17 @@ class _ScriptedLLM:
                             "goal_id": "g1",
                             "statement": "make one grounded direction",
                             "rationale": "accepted evidence",
+                            "success_criteria": ["the direction remains grounded"],
                         }
                     ],
-                    "criteria": [
+                    "constraints": [
                         {
-                            "criterion_id": "c1",
-                            "description": "grounded",
+                            "constraint_id": "c1",
+                            "statement": "remain grounded",
+                            "rationale": "accepted evidence is mandatory",
                         }
                     ],
-                    "legacy_markdown": "# accepted direction",
+                    "preferences": [],
                     "evidence_refs": [evidence_uri],
                 },
             )
@@ -581,7 +584,9 @@ async def test_real_runner_phase_one_fail_revise_pass_direction_accept_then_root
     assert len(ledger.operations) == 3
     assert llm.validated_artifact_kinds == ["evidence", "evidence", "direction"]
     assert legacy.status == BuildStatus.PARTIAL
-    assert legacy.direction == "# accepted direction"
+    assert legacy.direction.startswith("# Direction\n\n## Goals")
+    assert "### g1: make one grounded direction" in legacy.direction
+    assert "## Constraints" in legacy.direction
     assert publications.state == PublicationState.PUBLISHED
     assert bindings.active is not None
     assert not hasattr(bindings.binding, "branch_id")

+ 10 - 4
script_build_host/tests/test_repositories.py

@@ -11,9 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
 from script_build_host.agents.validation import ScriptBuildArtifactEvidenceReader
 from script_build_host.domain.artifacts import (
     ArtifactState,
+    DirectionArtifact,
     DirectionGoal,
     EvidenceRecordV1,
-    ScriptDirectionArtifactV1,
 )
 from script_build_host.domain.errors import (
     ArtifactAlreadyFrozen,
@@ -244,10 +244,16 @@ async def test_direction_and_publication_are_idempotent_and_error_is_redacted(
         task_id="direction-task",
         attempt_id="direction-attempt",
         spec_version=1,
-        artifact=ScriptDirectionArtifactV1(
-            goals=(DirectionGoal("goal-1", "make the topic clear", "source-backed"),),
+        artifact=DirectionArtifact(
+            goals=(
+                DirectionGoal(
+                    "goal-1",
+                    "make the topic clear",
+                    "source-backed",
+                    success_criteria=("the topic is explicit",),
+                ),
+            ),
             evidence_refs=("script-build://artifact-versions/1",),
-            legacy_markdown="# Direction",
         ),
     )
     publications = SqlAlchemyPublicationRepository(sessions)