diff --git a/tests/test_idea_to_spec_clarification_requests.py b/tests/test_idea_to_spec_clarification_requests.py index 35a23e48..31711d92 100644 --- a/tests/test_idea_to_spec_clarification_requests.py +++ b/tests/test_idea_to_spec_clarification_requests.py @@ -137,6 +137,29 @@ def test_clarification_requests_marks_ready_session_not_required() -> None: assert report["workspace_id"] == "support-triage-log" +def test_closed_clarification_rows_are_not_required() -> None: + clarification_module = load_module( + TOOL_PATH, + "clarification_requests_closed_rows", + ) + report = clarification_module.build_idea_to_spec_clarification_requests( + repair_loop={ + "artifact_kind": "candidate_repair_loop_report", + "contract_ref": "specgraph.idea-to-spec.candidate-repair-loop.v0.1", + "repair_actions": [ + { + "id": "repair.preview-applied", + "kind": "graph_repair", + "status": "preview_applied", + "target_ref": "candidate-spec.preview", + } + ], + } + ) + assert report["clarification_outcome"] == "clarification_not_required" + assert report["clarification_requests"] + + def test_clarification_requests_project_repair_context_actions() -> None: repair_module = load_module(REPAIR_TOOL_PATH, "repair_loop_for_clarifications") clarification_module = load_module(TOOL_PATH, "clarification_requests_for_repair_loop") diff --git a/tests/test_real_idea_answer_authoring.py b/tests/test_real_idea_answer_authoring.py index 71ef047a..ada3d828 100644 --- a/tests/test_real_idea_answer_authoring.py +++ b/tests/test_real_idea_answer_authoring.py @@ -178,6 +178,19 @@ def test_intake_answer_template_marks_clarification_not_required(tmp_path: Path) assert template["readiness"]["next_artifact"] == "user_idea_intake_source.json" assert template["answer_targets"] == [] assert template["findings"] == [] + report = module._authoring_report( + operation="template", + status=template["summary"]["status"], + stage="intake", + run_dir=tmp_path, + requests_path=requests_path, + answers_path=None, + answer_set=None, + validated_answers=None, + findings=[], + outputs={"next_artifact": template["readiness"]["next_artifact"]}, + ) + assert report["readiness"]["ready"] is True def test_intake_answer_template_blocks_unsupported_mandatory_request(tmp_path: Path) -> None: @@ -198,7 +211,7 @@ def test_intake_answer_template_blocks_unsupported_mandatory_request(tmp_path: P "status": "open", "question": "Provide an unsupported executable answer.", "target_ref": "unsupported.target", - "suggested_actions": ["execute_custom_resolver"], + "suggested_actions": ["answer_question"], "suggested_answer_shape": "custom_binary_payload", } ], @@ -218,6 +231,76 @@ def test_intake_answer_template_blocks_unsupported_mandatory_request(tmp_path: P assert "clarification_request_not_browser_answerable" in finding_ids(template) +def test_answer_validation_rejects_stale_template_requests_digest(tmp_path: Path) -> None: + module = load_module() + requests = load_json(REPAIR_REQUESTS) + template = module.build_template( + clarification_requests=requests, + requests_path=REPAIR_REQUESTS, + stage="repair", + run_dir=tmp_path, + ) + template["operator_answers"] = [ + { + **answer, + "status": "accepted_for_candidate", + "value": {"answer": "operator answer"}, + } + for answer in template["operator_answers"] + ] + stale_requests = json.loads(json.dumps(requests)) + stale_requests["clarification_requests"][0]["question"] = "Changed question" + + _answer_set, _validated, report = module.build_validation( + clarification_requests=stale_requests, + requests_path=REPAIR_REQUESTS, + answer_input=template, + answers_path=tmp_path / "stale_template.json", + stage="repair", + run_dir=tmp_path, + ) + + assert report["readiness"]["ready"] is False + assert "answer_template_requests_digest_mismatch" in finding_ids(report) + + +def test_answer_template_ignores_closed_requests_for_not_required_outcome( + tmp_path: Path, +) -> None: + module = load_module() + requests = { + "artifact_kind": "idea_to_spec_clarification_requests", + "contract_ref": "specgraph.idea-to-spec.clarification-requests.v0.1", + "clarification_outcome": "clarification_not_required", + "clarification_requests": [ + { + "id": "clarification.closed", + "kind": "graph_repair", + "severity": "advisory", + "status": "preview_applied", + "question": "Already applied", + "target_ref": "candidate-spec.preview", + "suggested_actions": ["defer"], + "suggested_answer_shape": "plain text", + } + ], + "readiness": {"ready": True}, + } + requests_path = tmp_path / "closed_requests.json" + write_json(requests_path, requests) + + template = module.build_template( + clarification_requests=requests, + requests_path=requests_path, + stage="repair", + run_dir=tmp_path, + ) + + assert template["clarification_outcome"] == "clarification_not_required" + assert template["answer_targets"] == [] + assert template["readiness"]["ready"] is True + + def test_answer_authoring_validate_accepts_filled_template(tmp_path: Path) -> None: module = load_module() template_path = filled_repair_template(tmp_path) @@ -786,6 +869,16 @@ def test_verify_no_clarification_requires_matching_workspace(tmp_path: Path) -> run_dir_ref = Path(".pytest_cache") / "no_clarification_verify" / tmp_path.name run_dir = ROOT / run_dir_ref template_path = run_dir / "real_idea_answer_template.json" + session_path = run_dir / "user_idea_intake_session.json" + session = { + "artifact_kind": "user_idea_intake_session", + "workspace": { + "candidate_id": "complete-idea", + "display_name": "Complete Idea", + "public_route": "/complete-idea", + }, + "readiness": {"ready": True}, + } requests = { "artifact_kind": "idea_to_spec_clarification_requests", "contract_ref": "specgraph.idea-to-spec.clarification-requests.v0.1", @@ -795,6 +888,13 @@ def test_verify_no_clarification_requires_matching_workspace(tmp_path: Path) -> "clarification_outcome": "clarification_not_required", "clarification_requests": [], "readiness": {"ready": True, "review_state": "clarification_clear"}, + "source_artifacts": [ + { + "name": "user_idea_intake_session", + "path": run_dir_ref.joinpath("user_idea_intake_session.json").as_posix(), + "source_digest": module._source_digest(session), + } + ], } try: template = module.build_template( @@ -803,6 +903,7 @@ def test_verify_no_clarification_requires_matching_workspace(tmp_path: Path) -> stage="intake", run_dir=run_dir, ) + write_json(session_path, session) write_json(run_dir / "idea_intake_clarification_requests.json", requests) write_json(template_path, template) success = subprocess.run( @@ -844,6 +945,30 @@ def test_verify_no_clarification_requires_matching_workspace(tmp_path: Path) -> assert mismatch.returncode == 1 assert "workspace_id" in mismatch.stdout + session["workspace"]["display_name"] = "Changed after template" + write_json(session_path, session) + stale_session = subprocess.run( + [ + sys.executable, + str(TOOL_PATH), + "verify-no-clarification", + "--run-dir", + str(run_dir_ref), + "--template", + str(template_path), + "--workspace-id", + "complete-idea", + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + assert stale_session.returncode == 1 + assert "intake_session.source_digest" in stale_session.stdout + session["workspace"]["display_name"] = "Complete Idea" + write_json(session_path, session) + requests["clarification_outcome"] = "answers_required" write_json(run_dir / "idea_intake_clarification_requests.json", requests) stale_source = subprocess.run( diff --git a/tests/test_specspace_real_idea_answer_handoff.py b/tests/test_specspace_real_idea_answer_handoff.py index 18892e32..0f3cea2b 100644 --- a/tests/test_specspace_real_idea_answer_handoff.py +++ b/tests/test_specspace_real_idea_answer_handoff.py @@ -315,6 +315,32 @@ def test_specspace_real_idea_answer_import_preview_blocks_template_mismatch() -> assert "/Users/" not in dumped +def test_specspace_real_idea_answer_import_preview_blocks_stale_template_digest() -> None: + module = load_module(TOOL_PATH, "specspace_real_idea_answer_handoff_digest_test") + run_dir = ROOT / ".pytest_cache" / "specspace_real_idea_answer_handoff" / "digest" + paths = prepare_run_dir(run_dir) + state_path = run_dir / "idea_to_spec_intake_clarification_answers.json" + write_json(state_path, specspace_state(paths)) + requests = load_json(paths["requests"]) + requests["clarification_requests"][0]["question"] = "Changed after template generation" + + preview = module.build_import_preview( + specspace_answer_state=load_json(state_path), + state_path=state_path, + template=load_json(paths["template"]), + template_path=paths["template"], + clarification_requests=requests, + requests_path=paths["requests"], + intake_session=load_json(paths["session"]), + intake_session_path=paths["session"], + run_dir=run_dir, + stage="intake", + ) + + assert preview["readiness"]["ready"] is False + assert "answer_template_requests_digest_mismatch" in finding_ids(preview) + + def test_specspace_real_idea_answer_import_preview_requires_identity_and_source_refs() -> None: module = load_module(TOOL_PATH, "specspace_real_idea_answer_handoff_identity_test") run_dir = ROOT / ".pytest_cache" / "specspace_real_idea_answer_handoff" / "identity" diff --git a/tools/real_idea_answer_authoring.py b/tools/real_idea_answer_authoring.py index 716caf2c..5f10b71d 100644 --- a/tools/real_idea_answer_authoring.py +++ b/tools/real_idea_answer_authoring.py @@ -99,6 +99,18 @@ "value.relations[]", "value.terms[]", } +SUPPORTED_BROWSER_ANSWER_SHAPE_TOKENS = { + "active_frame_ref[]", + "context_ref[]", + "domain_ref[]", + "event_storming_entry[]", + "event_storming_relation[]", + "model_applicability_ref[]", + "ontology_layer_ref[]", + "ontology_ref[]", + "plain text", + "structured context", +} def _now_iso() -> str: @@ -195,7 +207,8 @@ def _digest(payload: dict[str, Any]) -> str: def _source_digest(payload: dict[str, Any]) -> str: - return _digest({key: value for key, value in payload.items() if key != "generated_at"}) + safe = _dict(_public_safe(payload)) + return _digest({key: value for key, value in safe.items() if key != "generated_at"}) def _finding( @@ -294,6 +307,13 @@ def _browser_answerability_findings( missing.append("supported_action") if fields and not fields.issubset(SUPPORTED_BROWSER_VALUE_FIELDS): missing.append("supported_value_shape") + shape = _text(request.get("suggested_answer_shape")) + if ( + "answer_question" in supported_actions + and shape + and not any(token in shape for token in SUPPORTED_BROWSER_ANSWER_SHAPE_TOKENS) + ): + missing.append("supported_answer_shape") if not missing: return [] return [ @@ -315,6 +335,50 @@ def _browser_answerability_findings( ] +def template_request_binding_findings( + *, + template: dict[str, Any], + clarification_requests: dict[str, Any], + requests_path: Path, +) -> list[dict[str, Any]]: + source = _dict(_dict(template.get("source_artifacts")).get("clarification_requests")) + findings: list[dict[str, Any]] = [] + expected_ref = _relative_ref(requests_path) + if source.get("source_ref") != expected_ref: + findings.append( + _finding( + finding_id="answer_template_requests_ref_mismatch", + severity="blocking", + message="Answer template does not reference the selected clarification requests.", + evidence={"expected": expected_ref, "actual": source.get("source_ref")}, + ) + ) + if source.get("source_digest") != _source_digest(clarification_requests): + findings.append( + _finding( + finding_id="answer_template_requests_digest_mismatch", + severity="blocking", + message="Answer template was generated from stale clarification requests.", + evidence={"source_ref": expected_ref}, + ) + ) + template_workspace = _workspace_identity(template) + requests_workspace = _workspace_identity(clarification_requests) + if template_workspace.get("workspace_id") != requests_workspace.get("workspace_id"): + findings.append( + _finding( + finding_id="answer_template_requests_workspace_mismatch", + severity="blocking", + message="Answer template workspace does not match clarification requests.", + evidence={ + "template_workspace_id": template_workspace.get("workspace_id"), + "requests_workspace_id": requests_workspace.get("workspace_id"), + }, + ) + ) + return findings + + def _stage_request_path(stage: str, run_dir: Path) -> Path: if stage == "intake": return run_dir / "idea_intake_clarification_requests.json" @@ -514,7 +578,12 @@ def build_template( stage: str, run_dir: Path, ) -> dict[str, Any]: - requests = [_dict(item) for item in _list(clarification_requests.get("clarification_requests"))] + all_requests = [ + _dict(item) for item in _list(clarification_requests.get("clarification_requests")) + ] + requests = [ + request for request in all_requests if _text(request.get("status"), "open") == "open" + ] targets = [ _answer_target(request, stage=stage, index=index) for index, request in enumerate(requests) ] @@ -600,7 +669,7 @@ def build_template( "contract_ref": clarification_requests.get("contract_ref"), "source_ref": _relative_ref(requests_path), "source_digest": _source_digest(clarification_requests), - "request_count": len(requests), + "request_count": len(all_requests), } }, "answer_targets": targets, @@ -825,6 +894,15 @@ def build_validation( ) findings = [ *_scan_authority_and_raw(answer_input), + *( + template_request_binding_findings( + template=answer_input, + clarification_requests=clarification_requests, + requests_path=requests_path, + ) + if answer_input.get("artifact_kind") == "real_idea_answer_template" + else [] + ), *_required_value_findings( answer_set=answer_set, clarification_requests=clarification_requests, @@ -863,6 +941,8 @@ def _authoring_report( answer_count = len(_list(_dict(answer_set or {}).get("answers"))) validated_summary = _dict(_dict(validated_answers or {}).get("summary")) ready = status.endswith("ready") or status in { + "answers_required", + "clarification_not_required", "answers_ready_for_materialization", "answers_materialized", } @@ -1291,6 +1371,34 @@ def _verify_no_clarification(args: argparse.Namespace) -> int: request_workspace = _workspace_identity(requests) if request_workspace.get("workspace_id") != workspace.get("workspace_id"): findings.append("clarification_requests.workspace_id") + session_source = next( + ( + _dict(item) + for item in _list(requests.get("source_artifacts")) + if _text(_dict(item).get("name")) == "user_idea_intake_session" + ), + {}, + ) + session_ref = _text(session_source.get("path")) + if not session_ref: + findings.append("clarification_requests.source_artifacts.intake_session") + else: + session_path = (ROOT / session_ref).resolve() + try: + session_path.relative_to(run_dir.resolve()) + except ValueError: + findings.append("clarification_requests.intake_session.outside_run_dir") + else: + if not session_path.is_file(): + findings.append("clarification_requests.intake_session.missing") + else: + session = load_json(session_path) + if session_source.get("source_digest") != _source_digest(session): + findings.append("clarification_requests.intake_session.source_digest") + if _workspace_identity(session).get("workspace_id") != workspace.get( + "workspace_id" + ): + findings.append("clarification_requests.intake_session.workspace_id") if findings: print( "clarification_not_required verification failed: " + ", ".join(findings), diff --git a/tools/specspace_real_idea_answer_handoff.py b/tools/specspace_real_idea_answer_handoff.py index 6f5686a6..1c9aa73a 100644 --- a/tools/specspace_real_idea_answer_handoff.py +++ b/tools/specspace_real_idea_answer_handoff.py @@ -685,6 +685,13 @@ def build_import_preview( evidence={"readiness": _dict(template.get("readiness"))}, ) ) + findings.extend( + real_idea_answer_authoring.template_request_binding_findings( + template=template, + clarification_requests=clarification_requests, + requests_path=requests_path, + ) + ) if not answer_count: findings.append( _finding(