diff --git a/CLAUDE.md b/CLAUDE.md index f3fb6935..991180cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CodeFRAME Development Guidelines -Last updated: 2026-04-09 +Last updated: 2026-05-11 ## Product Vision @@ -36,12 +36,14 @@ If you are an agent working in this repo: **do not improvise architecture**. Fol ### Current Focus: Phase 4A +**Phase 5.1 is complete** — Settings page now ships three working tabs: Agent (#554), API Keys (#555), and PROOF9 Defaults + Workspace Config (#556). Backend: `GET/PUT /api/v2/proof/config` and `/api/v2/workspaces/config`, plus `run_proof()` now honors `enabled_gates` filtering and `strictness` (`strict` vs `warn`). Atomic JSON writes via `codeframe/ui/routers/_helpers.atomic_write_json`. The 9-gate canonical order and `proof_config.json` filename live in `codeframe/core/proof/models.py`. + **Phase 3.5C is complete** — `CaptureGlitchModal` form (description/markdown, source, scope, gate obligations, severity, expiry) reachable from the PROOF9 page and the persistent sidebar "Capture Glitch" button. REQ detail view (`/proof/[req_id]`) ships markdown description rendering, `ProofScope` metadata display, obligations table with `Latest Run` column, sortable/filterable evidence history, and empty-state CTA. Backend: `ScopeOut` model on `RequirementResponse`. Issues #568, #569. Next, in order: - **4A**: PR status tracking + PROOF9 merge gate - **4B**: Post-merge glitch capture loop -- **5.1–5.5**: Platform completeness (#554–#565) +- **5.2–5.5**: Platform completeness (#557–#565) See `docs/PRODUCT_ROADMAP.md` for full specs and issue links. diff --git a/codeframe/core/proof/models.py b/codeframe/core/proof/models.py index 4f3aa2c1..0cad0969 100644 --- a/codeframe/core/proof/models.py +++ b/codeframe/core/proof/models.py @@ -10,6 +10,11 @@ from typing import Optional +# Shared filename for the per-workspace PROOF9 config (issue #556). +# Lives under workspace.state_dir. +PROOF_CONFIG_FILENAME = "proof_config.json" + + class Gate(str, Enum): """The 9 proof gates.""" @@ -24,6 +29,10 @@ class Gate(str, Enum): MANUAL = "manual" +# Canonical PROOF9 gate order (matches the Gate enum declaration). +PROOF9_GATE_ORDER: tuple[str, ...] = tuple(g.value for g in Gate) + + class GlitchType(str, Enum): """Classification of the glitch that produced a requirement.""" diff --git a/codeframe/core/proof/runner.py b/codeframe/core/proof/runner.py index a6b8e3fd..60d7a8db 100644 --- a/codeframe/core/proof/runner.py +++ b/codeframe/core/proof/runner.py @@ -5,6 +5,7 @@ and attaches evidence artifacts. """ +import json import logging import uuid from datetime import datetime, timezone @@ -12,12 +13,57 @@ from codeframe.core.proof import ledger from codeframe.core.proof.evidence import attach_evidence -from codeframe.core.proof.models import Gate, ProofRun, ReqStatus +from codeframe.core.proof.models import ( + PROOF_CONFIG_FILENAME, + Gate, + ProofRun, + ReqStatus, +) from codeframe.core.proof.scope import get_changed_scope, intersects from codeframe.core.workspace import Workspace logger = logging.getLogger(__name__) + +def _load_proof_config(workspace: Workspace) -> tuple[Optional[set[Gate]], str]: + """Load (enabled_gates, strictness) from .codeframe/proof_config.json. + + Returns: + (enabled_gates, strictness). enabled_gates is None when no config file + exists (meaning "all gates allowed"); a set of Gate enums otherwise. + strictness defaults to 'strict' when missing or invalid. + """ + path = workspace.state_dir / PROOF_CONFIG_FILENAME + if not path.exists(): + return None, "strict" + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Invalid %s — using defaults: %s", PROOF_CONFIG_FILENAME, exc) + return None, "strict" + + enabled: Optional[set[Gate]] = None + gates_raw = data.get("enabled_gates") + if isinstance(gates_raw, list): + enabled = set() + valid_values = {g.value for g in Gate} + for name in gates_raw: + if name in valid_values: + enabled.add(Gate(name)) + else: + logger.warning( + "Unknown gate name '%s' in %s — skipped (valid: %s)", + name, + PROOF_CONFIG_FILENAME, + valid_values, + ) + + strictness = data.get("strictness", "strict") + if strictness not in ("strict", "warn"): + strictness = "strict" + return enabled, strictness + + # Map PROOF9 gates to existing core/gates.py gate names _GATE_TO_CORE: dict[Gate, str] = { Gate.UNIT: "pytest", @@ -71,6 +117,9 @@ def run_proof( started_at = datetime.now(timezone.utc) + # Load PROOF9 config (enabled gates + strictness) + enabled_gates, strictness = _load_proof_config(workspace) + # Expire any stale waivers expired = ledger.check_expired_waivers(workspace) if expired: @@ -94,6 +143,15 @@ def run_proof( ) return {} + # Warn loudly when config disables every gate — a "vacuous pass" is + # easy to overlook: nothing runs, overall_passed=True, no evidence. + if enabled_gates is not None and not enabled_gates: + logger.warning( + "Proof run %s: all 9 gates are disabled by proof_config.json — " + "no obligations will run and the run will pass vacuously", + run_id, + ) + # Get changed scope (skip if running full) changed_scope = None if not full: @@ -117,6 +175,10 @@ def run_proof( if gate_filter and obl.gate != gate_filter: continue + # Apply config-driven gate filter (None means "all allowed") + if enabled_gates is not None and obl.gate not in enabled_gates: + continue + # Run the gate passed, output = _run_gate(workspace, obl.gate) @@ -146,7 +208,15 @@ def run_proof( completed_at = datetime.now(timezone.utc) duration_ms = int((completed_at - started_at).total_seconds() * 1000) executed = [passed for gate_results in results.values() for _, passed in gate_results] - overall_passed = all(executed) if executed else True + all_passed = all(executed) if executed else True + if not all_passed and strictness == "warn": + logger.warning( + "Proof run %s had gate failures but strictness='warn' — overall_passed=True", + run_id, + ) + overall_passed = True + else: + overall_passed = all_passed ledger.save_run( workspace, ProofRun( diff --git a/codeframe/core/workspace.py b/codeframe/core/workspace.py index 7319a278..e202f9d1 100644 --- a/codeframe/core/workspace.py +++ b/codeframe/core/workspace.py @@ -24,6 +24,11 @@ def _utc_now() -> datetime: CODEFRAME_DIR = ".codeframe" STATE_DB_NAME = "state.db" +# Per-workspace config file written by the Settings page (issue #556). +# Owned by the UI layer today; kept here so a future core consumer can +# read it without importing from codeframe/ui/. +WORKSPACE_CONFIG_FILENAME = "workspace_config.json" + @dataclass class Workspace: diff --git a/codeframe/ui/routers/_helpers.py b/codeframe/ui/routers/_helpers.py new file mode 100644 index 00000000..cd02cfa5 --- /dev/null +++ b/codeframe/ui/routers/_helpers.py @@ -0,0 +1,29 @@ +"""Shared helpers for v2 routers.""" + +import json +import os +import tempfile +from pathlib import Path + + +def atomic_write_json(path: Path, payload: dict) -> None: + """Write JSON via per-call unique temp-file + os.replace. + + A unique temp name is required so concurrent writers to the same target + do not collide on a shared `.tmp` suffix. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + try: + with os.fdopen(fd, "w") as f: + f.write(json.dumps(payload, indent=2)) + os.replace(tmp_name, path) + except Exception: + # Clean up the temp file on failure so we don't leak it. + try: + os.unlink(tmp_name) + except OSError: + pass + raise diff --git a/codeframe/ui/routers/proof_v2.py b/codeframe/ui/routers/proof_v2.py index 623a14ad..f1cee091 100644 --- a/codeframe/ui/routers/proof_v2.py +++ b/codeframe/ui/routers/proof_v2.py @@ -13,14 +13,15 @@ GET /api/v2/proof/requirements/{req_id}/evidence list_evidence() """ +import json import logging import time import uuid from datetime import date -from typing import Any, Optional +from typing import Any, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError, field_validator from codeframe.core.proof.capture import capture_requirement from codeframe.core.proof.ledger import ( @@ -33,6 +34,8 @@ waive_requirement, ) from codeframe.core.proof.models import ( + PROOF9_GATE_ORDER, + PROOF_CONFIG_FILENAME, Gate, ReqStatus, Severity, @@ -44,6 +47,7 @@ from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard from codeframe.ui.dependencies import get_v2_workspace from codeframe.ui.response_models import ErrorCodes, api_error +from codeframe.ui.routers._helpers import atomic_write_json logger = logging.getLogger(__name__) @@ -413,11 +417,20 @@ async def run_proof_endpoint( for req_id, gate_results in results.items() } - passed = all( - satisfied - for gate_results in results.values() - for _, satisfied in gate_results - ) + # Use the strictness-aware overall_passed that run_proof() persisted, + # so warn-mode does not surface as failure via the cached run status. + # Fallback note: if save_run() ever silently failed, the raw `all(...)` + # below ignores strictness — accepted because that path indicates a + # deeper persistence bug we'd want to surface as a hard failure anyway. + persisted_run = get_run(workspace, run_id) + if persisted_run is not None: + passed = persisted_run.overall_passed + else: + passed = all( + satisfied + for gate_results in results.values() + for _, satisfied in gate_results + ) response = RunProofResponse( success=True, run_id=run_id, @@ -634,6 +647,80 @@ async def get_run_evidence_endpoint( ) +# ============================================================================ +# PROOF9 Config (issue #556) +# +# Persists which gates are enabled by default and the strictness setting +# (strict vs warn) to .codeframe/proof_config.json. +# ============================================================================ + + +_VALID_GATES = {g.value for g in Gate} + + +def _proof_config_path(workspace: Workspace): + return workspace.state_dir / PROOF_CONFIG_FILENAME + + +def _default_proof_config() -> dict: + return {"enabled_gates": list(PROOF9_GATE_ORDER), "strictness": "strict"} + + +class ProofConfigResponse(BaseModel): + enabled_gates: list[str] + strictness: Literal["strict", "warn"] + + +class UpdateProofConfigRequest(BaseModel): + enabled_gates: list[str] + strictness: Literal["strict", "warn"] + + @field_validator("enabled_gates") + @classmethod + def _validate_gates(cls, v: list[str]) -> list[str]: + unknown = [g for g in v if g not in _VALID_GATES] + if unknown: + raise ValueError( + f"Unknown gate(s): {unknown}. Valid: {list(PROOF9_GATE_ORDER)}" + ) + # De-dupe while preserving submission order so the stored file never + # carries the same gate twice. + return list(dict.fromkeys(v)) + + +@router.get("/config", response_model=ProofConfigResponse) +@rate_limit_standard() +async def get_proof_config( + request: Request, + workspace: Workspace = Depends(get_v2_workspace), +) -> ProofConfigResponse: + """Load PROOF9 defaults for this workspace. + + Returns the all-gates-enabled + strict defaults if no config file exists. + """ + path = _proof_config_path(workspace) + if path.exists(): + try: + data = json.loads(path.read_text()) + return ProofConfigResponse(**data) + except (OSError, json.JSONDecodeError, ValueError, ValidationError) as e: + logger.warning("Invalid proof_config.json — falling back to defaults: %s", e) + return ProofConfigResponse(**_default_proof_config()) + + +@router.put("/config", response_model=ProofConfigResponse) +@rate_limit_standard() +async def update_proof_config( + request: Request, + body: UpdateProofConfigRequest, + workspace: Workspace = Depends(get_v2_workspace), +) -> ProofConfigResponse: + """Persist PROOF9 defaults to .codeframe/proof_config.json.""" + payload = {"enabled_gates": body.enabled_gates, "strictness": body.strictness} + atomic_write_json(_proof_config_path(workspace), payload) + return ProofConfigResponse(**payload) + + @router.get("/requirements/{req_id}/evidence", response_model=list[EvidenceResponse]) @rate_limit_standard() async def list_evidence_endpoint( diff --git a/codeframe/ui/routers/workspace_v2.py b/codeframe/ui/routers/workspace_v2.py index b9499aae..bc4c6480 100644 --- a/codeframe/ui/routers/workspace_v2.py +++ b/codeframe/ui/routers/workspace_v2.py @@ -9,18 +9,20 @@ PATCH /api/v2/workspaces/current - Update workspace (e.g., tech stack) """ +import json import logging from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError from codeframe.core import workspace as ws from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.ui.dependencies import get_v2_workspace -from codeframe.core.workspace import Workspace +from codeframe.core.workspace import WORKSPACE_CONFIG_FILENAME, Workspace from codeframe.ui.response_models import api_error, ErrorCodes +from codeframe.ui.routers._helpers import atomic_write_json logger = logging.getLogger(__name__) @@ -260,6 +262,93 @@ async def update_current_workspace( ) +# ============================================================================ +# Workspace Config (issue #556) +# +# Persists per-workspace UI-configurable settings (root path display, default +# branch, tech-stack auto-detect toggle + manual override) to +# .codeframe/workspace_config.json. +# ============================================================================ + + +class WorkspaceConfigResponse(BaseModel): + workspace_root: str = Field( + ..., description="Display-only. The server resolves the active workspace from the workspace_path query parameter." + ) + default_branch: str + auto_detect_tech_stack: bool + tech_stack_override: Optional[str] = None + + +class UpdateWorkspaceConfigRequest(BaseModel): + workspace_root: str = Field( + ..., + min_length=1, + description="Display-only — editing does not relocate the workspace.", + ) + default_branch: str = Field(..., min_length=1) + auto_detect_tech_stack: bool + tech_stack_override: Optional[str] = None + + +def _workspace_config_path(workspace: Workspace) -> Path: + return workspace.state_dir / WORKSPACE_CONFIG_FILENAME + + +def _default_workspace_config(workspace: Workspace) -> dict: + return { + "workspace_root": str(workspace.repo_path), + "default_branch": "main", + "auto_detect_tech_stack": True, + "tech_stack_override": None, + } + + +@router.get("/config", response_model=WorkspaceConfigResponse) +@rate_limit_standard() +async def get_workspace_config( + request: Request, + workspace: Workspace = Depends(get_v2_workspace), +) -> WorkspaceConfigResponse: + """Load workspace configuration for this workspace. + + Returns defaults sourced from the Workspace itself if no config file exists. + """ + path = _workspace_config_path(workspace) + if path.exists(): + try: + data = json.loads(path.read_text()) + # workspace_root is display-only — always source it from the live + # workspace so a stored value can't drift from reality. + data["workspace_root"] = str(workspace.repo_path) + return WorkspaceConfigResponse(**data) + except (OSError, json.JSONDecodeError, ValueError, ValidationError) as e: + logger.warning( + "Invalid workspace_config.json — falling back to defaults: %s", e + ) + return WorkspaceConfigResponse(**_default_workspace_config(workspace)) + + +@router.put("/config", response_model=WorkspaceConfigResponse) +@rate_limit_standard() +async def update_workspace_config( + request: Request, + body: UpdateWorkspaceConfigRequest, + workspace: Workspace = Depends(get_v2_workspace), +) -> WorkspaceConfigResponse: + """Persist workspace configuration to .codeframe/workspace_config.json. + + Note: `workspace_root` is informational/display-only. The server resolves + the active workspace path from the `workspace_path` query parameter or + its default — editing this field does not relocate the workspace. The + value is replaced on write so PUT/GET stay consistent. + """ + payload = body.model_dump(exclude={"workspace_root"}) + payload["workspace_root"] = str(workspace.repo_path) + atomic_write_json(_workspace_config_path(workspace), payload) + return WorkspaceConfigResponse(**payload) + + @router.get("/exists") @rate_limit_standard() async def check_workspace_exists( diff --git a/docs/PRODUCT_ROADMAP.md b/docs/PRODUCT_ROADMAP.md index b9e36fac..c8de41d7 100644 --- a/docs/PRODUCT_ROADMAP.md +++ b/docs/PRODUCT_ROADMAP.md @@ -65,6 +65,8 @@ After a PR is created from the Review page, show its live status in the web UI. 2. PROOF9 has no open (non-waived) requirements for the changed scope - If PROOF9 has open requirements: show a gating message listing which requirements are blocking merge and linking to the PROOF9 page +**Known follow-up** (from #556): `RunProofResponse` does not distinguish "all gates passed" from "vacuous pass because all gates were disabled in `proof_config.json`". The runner already emits a server-side warning and the Settings page shows a UI banner; the merge gate in this milestone should surface that distinction (e.g., a `vacuous_pass` flag on `RunProofResponse` or `ProofRun`) so a workspace with zero enabled gates can't auto-merge. + **Why it matters for the vision**: "Merge is gated on PROOF9 pass." That sentence is in the vision doc. Without CI tracking and a merge gate in the UI, this is a CLI-only guarantee. The SHIP phase is only complete when the user can go from "PR opened" to "merged" without leaving CodeFRAME. --- @@ -192,7 +194,7 @@ These are items that were considered and excluded because they do not serve the | 3.5C | Glitch capture UI | ✅ Complete | #568, #569 | | 4A | PR status + PROOF9 merge gate | ❌ Not started | — | | 4B | Post-merge glitch capture loop | ❌ Not started | — | -| 5.1 | Settings page (skeleton + agent config) | 🟡 In progress (#554, #555 done; #556 next) | #554–556 | +| 5.1 | Settings page (skeleton + agent config + PROOF9/workspace tabs) | ✅ Complete | #554–556 | | 5.2 | Cost analytics | ❌ Not started | #557–558 | | 5.3 | Async notifications | ❌ Not started | #559–560 | | 5.4 | PRD stress-test web UI | ❌ Not started | #561–562 | diff --git a/tests/core/test_proof_runner_config.py b/tests/core/test_proof_runner_config.py new file mode 100644 index 00000000..d7da0d72 --- /dev/null +++ b/tests/core/test_proof_runner_config.py @@ -0,0 +1,158 @@ +"""Tests for proof runner config integration (issue #556). + +The runner must: +- Load `.codeframe/proof_config.json` if present +- Filter obligations by `enabled_gates` +- Respect `strictness` setting: in 'warn' mode, gate failures should not flip + overall_passed; in 'strict' mode, behavior is unchanged +""" + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +import pytest + +from codeframe.core.proof.ledger import get_run, init_proof_tables, save_requirement +from codeframe.core.proof.models import ( + PROOF_CONFIG_FILENAME, + Gate, + Obligation, + Requirement, + RequirementScope, + ReqStatus, + Severity, + Source, +) +from codeframe.core.proof.runner import run_proof +from codeframe.core.workspace import Workspace, create_or_load_workspace + +pytestmark = pytest.mark.v2 + + +@pytest.fixture +def workspace(tmp_path: Path) -> Workspace: + ws = create_or_load_workspace(tmp_path) + init_proof_tables(ws) + return ws + + +def _make_req(req_id: str, gates: list[Gate]) -> Requirement: + return Requirement( + id=req_id, + title=f"Test {req_id}", + description="test", + severity=Severity.MEDIUM, + source=Source.QA, + scope=RequirementScope(files=["x.py"]), + obligations=[Obligation(gate=g) for g in gates], + evidence_rules=[], + status=ReqStatus.OPEN, + created_at=datetime.now(timezone.utc), + ) + + +class TestRunnerEnabledGatesFilter: + """The runner must skip obligations whose gate is not in enabled_gates.""" + + def test_filters_disabled_gates(self, workspace): + """A gate disabled in proof_config.json must not run.""" + save_requirement(workspace, _make_req("REQ-0001", [Gate.UNIT, Gate.SEC])) + + (workspace.state_dir / PROOF_CONFIG_FILENAME).write_text( + json.dumps({"enabled_gates": ["unit"], "strictness": "strict"}) + ) + + # Patch _run_gate so we can see which gates were invoked + with patch( + "codeframe.core.proof.runner._run_gate", + return_value=(True, ""), + ) as mock_gate: + run_proof(workspace, full=True) + + invoked_gates = [call.args[1] for call in mock_gate.call_args_list] + assert Gate.UNIT in invoked_gates + assert Gate.SEC not in invoked_gates + + def test_all_gates_run_when_no_config(self, workspace): + """With no proof_config.json, behavior is unchanged — all gates run.""" + save_requirement(workspace, _make_req("REQ-0002", [Gate.UNIT, Gate.SEC])) + + with patch( + "codeframe.core.proof.runner._run_gate", + return_value=(True, ""), + ) as mock_gate: + run_proof(workspace, full=True) + + invoked_gates = [call.args[1] for call in mock_gate.call_args_list] + assert Gate.UNIT in invoked_gates + assert Gate.SEC in invoked_gates + + +class TestEmptyEnabledGates: + """Documents the empty-gates behavior: nothing runs, overall_passed=True, + and a warning is logged.""" + + def test_empty_enabled_gates_vacuous_pass(self, workspace, caplog): + save_requirement(workspace, _make_req("REQ-EMPTY", [Gate.UNIT, Gate.SEC])) + (workspace.state_dir / PROOF_CONFIG_FILENAME).write_text( + json.dumps({"enabled_gates": [], "strictness": "strict"}) + ) + + with caplog.at_level(logging.WARNING, logger="codeframe.core.proof.runner"), patch( + "codeframe.core.proof.runner._run_gate", + return_value=(True, ""), + ) as mock_gate: + run_proof(workspace, full=True, run_id="empty-gates") + + # Nothing executed + mock_gate.assert_not_called() + + # Run records as passing (vacuously) + run = get_run(workspace, "empty-gates") + assert run is not None + assert run.overall_passed is True + + # Warning was emitted + assert any("vacuously" in r.message for r in caplog.records) + + +class TestRunnerStrictness: + """In 'warn' mode the run's overall_passed must remain True on failure.""" + + def test_strict_mode_propagates_failure(self, workspace): + """In strict mode (default), a failing gate flips overall_passed to False.""" + save_requirement(workspace, _make_req("REQ-0003", [Gate.UNIT])) + (workspace.state_dir / PROOF_CONFIG_FILENAME).write_text( + json.dumps({"enabled_gates": ["unit"], "strictness": "strict"}) + ) + + with patch( + "codeframe.core.proof.runner._run_gate", + return_value=(False, "boom"), + ): + run_proof(workspace, full=True, run_id="strict-run") + + # Inspect the persisted run record + run = get_run(workspace, "strict-run") + assert run is not None + assert run.overall_passed is False + + def test_warn_mode_keeps_overall_passed(self, workspace): + """In warn mode, a failing gate does NOT flip overall_passed.""" + save_requirement(workspace, _make_req("REQ-0004", [Gate.UNIT])) + (workspace.state_dir / PROOF_CONFIG_FILENAME).write_text( + json.dumps({"enabled_gates": ["unit"], "strictness": "warn"}) + ) + + with patch( + "codeframe.core.proof.runner._run_gate", + return_value=(False, "boom"), + ): + run_proof(workspace, full=True, run_id="warn-run") + + run = get_run(workspace, "warn-run") + assert run is not None + assert run.overall_passed is True diff --git a/tests/ui/test_proof_config.py b/tests/ui/test_proof_config.py new file mode 100644 index 00000000..71a405ba --- /dev/null +++ b/tests/ui/test_proof_config.py @@ -0,0 +1,222 @@ +"""Tests for PROOF9 config endpoints (issue #556). + +Covers: +- GET /api/v2/proof/config returns defaults when no config file exists +- PUT /api/v2/proof/config persists settings to .codeframe/proof_config.json +- Round-trip: PUT then GET returns the saved config +- Invalid gate names are rejected with 422 +""" + +import json +import shutil +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from codeframe.core.proof.ledger import save_requirement +from codeframe.core.proof.models import ( + Gate, + Obligation, + Requirement, + RequirementScope, + ReqStatus, + Severity, + Source, +) + +pytestmark = pytest.mark.v2 + + +@pytest.fixture +def test_workspace(): + """Create a temporary workspace for testing.""" + temp_dir = Path(tempfile.mkdtemp()) + workspace_path = temp_dir / "test_workspace" + workspace_path.mkdir(parents=True, exist_ok=True) + + from codeframe.core.workspace import create_or_load_workspace + + workspace = create_or_load_workspace(workspace_path) + + yield workspace + + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture +def test_client(test_workspace): + """Create a FastAPI TestClient with proof_v2 router and workspace override.""" + from codeframe.ui.dependencies import get_v2_workspace + from codeframe.ui.routers import proof_v2 + + app = FastAPI() + app.include_router(proof_v2.router) + + def get_test_workspace(): + return test_workspace + + app.dependency_overrides[get_v2_workspace] = get_test_workspace + + client = TestClient(app) + client.workspace = test_workspace + return client + + +class TestGetProofConfig: + """Tests for GET /api/v2/proof/config.""" + + def test_returns_defaults_when_no_config(self, test_client): + """All 9 gates enabled and strict by default when no file exists.""" + response = test_client.get("/api/v2/proof/config") + assert response.status_code == 200 + data = response.json() + assert set(data["enabled_gates"]) == { + "unit", + "contract", + "e2e", + "visual", + "a11y", + "perf", + "sec", + "demo", + "manual", + } + assert data["strictness"] == "strict" + + def test_returns_existing_config(self, test_client, test_workspace): + """GET returns the persisted config.""" + config_path = test_workspace.state_dir / "proof_config.json" + config_path.write_text( + json.dumps({"enabled_gates": ["unit", "sec"], "strictness": "warn"}) + ) + response = test_client.get("/api/v2/proof/config") + assert response.status_code == 200 + data = response.json() + assert set(data["enabled_gates"]) == {"unit", "sec"} + assert data["strictness"] == "warn" + + def test_corrupted_json_falls_back_to_defaults(self, test_client, test_workspace): + """Truncated/invalid JSON should not 500 — falls back to defaults.""" + config_path = test_workspace.state_dir / "proof_config.json" + config_path.write_text("{ not valid json") + response = test_client.get("/api/v2/proof/config") + assert response.status_code == 200 + data = response.json() + assert data["strictness"] == "strict" + assert len(data["enabled_gates"]) == 9 + + def test_invalid_field_value_falls_back_to_defaults(self, test_client, test_workspace): + """Valid JSON with invalid field values (caught by Pydantic) should + also fall back to defaults rather than 500.""" + config_path = test_workspace.state_dir / "proof_config.json" + config_path.write_text( + json.dumps({"enabled_gates": ["unit"], "strictness": "bogus"}) + ) + response = test_client.get("/api/v2/proof/config") + assert response.status_code == 200 + assert response.json()["strictness"] == "strict" + + +class TestPutProofConfig: + """Tests for PUT /api/v2/proof/config.""" + + def test_put_persists_config(self, test_client, test_workspace): + body = {"enabled_gates": ["unit", "e2e", "sec"], "strictness": "warn"} + response = test_client.put("/api/v2/proof/config", json=body) + assert response.status_code == 200 + + config_path = test_workspace.state_dir / "proof_config.json" + assert config_path.exists() + saved = json.loads(config_path.read_text()) + assert saved["enabled_gates"] == ["unit", "e2e", "sec"] + assert saved["strictness"] == "warn" + + def test_put_round_trip(self, test_client): + body = {"enabled_gates": ["unit"], "strictness": "warn"} + put_resp = test_client.put("/api/v2/proof/config", json=body) + assert put_resp.status_code == 200 + + get_resp = test_client.get("/api/v2/proof/config") + assert get_resp.status_code == 200 + data = get_resp.json() + assert data["enabled_gates"] == ["unit"] + assert data["strictness"] == "warn" + + def test_put_rejects_unknown_gate(self, test_client): + body = {"enabled_gates": ["unit", "bogus_gate"], "strictness": "strict"} + response = test_client.put("/api/v2/proof/config", json=body) + assert response.status_code == 422 + + def test_put_rejects_invalid_strictness(self, test_client): + body = {"enabled_gates": ["unit"], "strictness": "lenient"} + response = test_client.put("/api/v2/proof/config", json=body) + assert response.status_code == 422 + + def test_put_allows_empty_gate_list(self, test_client): + """Disabling all gates is allowed (only strictness matters then).""" + body = {"enabled_gates": [], "strictness": "strict"} + response = test_client.put("/api/v2/proof/config", json=body) + assert response.status_code == 200 + data = response.json() + assert data["enabled_gates"] == [] + + def test_put_dedupes_gates(self, test_client, test_workspace): + """Duplicate gate names are silently collapsed, preserving order.""" + body = { + "enabled_gates": ["unit", "sec", "unit", "e2e", "sec"], + "strictness": "strict", + } + response = test_client.put("/api/v2/proof/config", json=body) + assert response.status_code == 200 + assert response.json()["enabled_gates"] == ["unit", "sec", "e2e"] + + # Persisted file also contains no duplicates + saved = json.loads((test_workspace.state_dir / "proof_config.json").read_text()) + assert saved["enabled_gates"] == ["unit", "sec", "e2e"] + + +class TestRunCachePassedSemantics: + """Regression: cached 'passed' must mirror the run's strictness-aware + overall_passed, not a raw recomputation from gate results.""" + + def test_warn_mode_run_cache_passed_is_true(self, test_client, test_workspace): + """In warn mode, cache must record passed=True even on gate failures.""" + save_requirement( + test_workspace, + Requirement( + id="REQ-CACHE-1", + title="t", + description="d", + severity=Severity.LOW, + source=Source.QA, + scope=RequirementScope(files=["x.py"]), + obligations=[Obligation(gate=Gate.UNIT)], + evidence_rules=[], + status=ReqStatus.OPEN, + created_at=datetime.now(timezone.utc), + ), + ) + # warn strictness + test_client.put( + "/api/v2/proof/config", + json={"enabled_gates": ["unit"], "strictness": "warn"}, + ) + + with patch( + "codeframe.core.proof.runner._run_gate", + return_value=(False, "boom"), + ): + run_resp = test_client.post("/api/v2/proof/run", json={"full": True}) + + assert run_resp.status_code == 200 + run_id = run_resp.json()["run_id"] + + status = test_client.get(f"/api/v2/proof/runs/{run_id}") + assert status.status_code == 200 + # In warn mode the gate failed, but overall passed must remain True. + assert status.json()["passed"] is True diff --git a/tests/ui/test_workspace_config.py b/tests/ui/test_workspace_config.py new file mode 100644 index 00000000..b25818f8 --- /dev/null +++ b/tests/ui/test_workspace_config.py @@ -0,0 +1,204 @@ +"""Tests for workspace config endpoints (issue #556). + +Covers: +- GET /api/v2/workspaces/config returns sensible defaults when no file exists +- PUT /api/v2/workspaces/config persists to .codeframe/workspace_config.json +- Round-trip +""" + +import json +import shutil +import tempfile +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +pytestmark = pytest.mark.v2 + + +@pytest.fixture +def test_workspace(): + """Create a temporary workspace for testing.""" + temp_dir = Path(tempfile.mkdtemp()) + workspace_path = temp_dir / "test_workspace" + workspace_path.mkdir(parents=True, exist_ok=True) + + from codeframe.core.workspace import create_or_load_workspace + + workspace = create_or_load_workspace(workspace_path) + + yield workspace + + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture +def test_client(test_workspace): + """FastAPI TestClient with workspace_v2 router and workspace override.""" + from codeframe.ui.dependencies import get_v2_workspace + from codeframe.ui.routers import workspace_v2 + + app = FastAPI() + app.include_router(workspace_v2.router) + + def get_test_workspace(): + return test_workspace + + app.dependency_overrides[get_v2_workspace] = get_test_workspace + + client = TestClient(app) + client.workspace = test_workspace + return client + + +class TestGetWorkspaceConfig: + """Tests for GET /api/v2/workspaces/config.""" + + def test_returns_defaults_when_no_config(self, test_client, test_workspace): + response = test_client.get("/api/v2/workspaces/config") + assert response.status_code == 200 + data = response.json() + assert data["workspace_root"] == str(test_workspace.repo_path) + assert data["default_branch"] == "main" + assert data["auto_detect_tech_stack"] is True + assert data["tech_stack_override"] is None + + def test_corrupted_json_falls_back_to_defaults(self, test_client, test_workspace): + """Truncated/invalid JSON should not 500 — falls back to defaults.""" + config_path = test_workspace.state_dir / "workspace_config.json" + config_path.write_text("{ not valid json") + response = test_client.get("/api/v2/workspaces/config") + assert response.status_code == 200 + data = response.json() + assert data["default_branch"] == "main" + assert data["auto_detect_tech_stack"] is True + + def test_invalid_field_type_falls_back_to_defaults(self, test_client, test_workspace): + """Valid JSON with the wrong field type (caught by Pydantic) should + also fall back to defaults rather than 500.""" + config_path = test_workspace.state_dir / "workspace_config.json" + config_path.write_text( + json.dumps( + { + "workspace_root": "/x", + "default_branch": 123, # wrong type + "auto_detect_tech_stack": True, + "tech_stack_override": None, + } + ) + ) + response = test_client.get("/api/v2/workspaces/config") + assert response.status_code == 200 + assert response.json()["default_branch"] == "main" + + def test_returns_persisted_config(self, test_client, test_workspace): + config_path = test_workspace.state_dir / "workspace_config.json" + config_path.write_text( + json.dumps( + { + "workspace_root": "/tmp/elsewhere", + "default_branch": "develop", + "auto_detect_tech_stack": False, + "tech_stack_override": "Python with uv, FastAPI", + } + ) + ) + response = test_client.get("/api/v2/workspaces/config") + assert response.status_code == 200 + data = response.json() + # workspace_root is display-only — server overrides any stored value + # with the live workspace path so it can never drift. + assert data["workspace_root"] == str(test_workspace.repo_path) + assert data["default_branch"] == "develop" + assert data["auto_detect_tech_stack"] is False + assert data["tech_stack_override"] == "Python with uv, FastAPI" + + def test_workspace_root_always_reflects_live_path(self, test_client, test_workspace): + """Regression: stored workspace_root must never be returned to the + client — the live workspace.repo_path always wins.""" + config_path = test_workspace.state_dir / "workspace_config.json" + config_path.write_text( + json.dumps( + { + "workspace_root": "/stale/path/that/does/not/exist", + "default_branch": "main", + "auto_detect_tech_stack": True, + "tech_stack_override": None, + } + ) + ) + data = test_client.get("/api/v2/workspaces/config").json() + assert data["workspace_root"] == str(test_workspace.repo_path) + + +class TestPutWorkspaceConfig: + """Tests for PUT /api/v2/workspaces/config.""" + + def test_put_persists_config(self, test_client, test_workspace): + body = { + "workspace_root": "/tmp/new", + "default_branch": "release", + "auto_detect_tech_stack": False, + "tech_stack_override": "Rust", + } + response = test_client.put("/api/v2/workspaces/config", json=body) + assert response.status_code == 200 + + config_path = test_workspace.state_dir / "workspace_config.json" + assert config_path.exists() + saved = json.loads(config_path.read_text()) + # workspace_root in the request is dropped; the live path is stored + assert saved["workspace_root"] == str(test_workspace.repo_path) + assert saved["default_branch"] == body["default_branch"] + assert saved["auto_detect_tech_stack"] == body["auto_detect_tech_stack"] + assert saved["tech_stack_override"] == body["tech_stack_override"] + + def test_put_round_trip(self, test_client, test_workspace): + body = { + "workspace_root": "/tmp/proj", + "default_branch": "main", + "auto_detect_tech_stack": True, + "tech_stack_override": None, + } + put_resp = test_client.put("/api/v2/workspaces/config", json=body) + assert put_resp.status_code == 200 + + get_resp = test_client.get("/api/v2/workspaces/config") + assert get_resp.status_code == 200 + data = get_resp.json() + # workspace_root is display-only: any stored value is overridden by + # the live workspace path on GET. + assert data["workspace_root"] == str(test_workspace.repo_path) + assert data["default_branch"] == body["default_branch"] + assert data["auto_detect_tech_stack"] == body["auto_detect_tech_stack"] + assert data["tech_stack_override"] == body["tech_stack_override"] + + def test_put_ignores_client_workspace_root(self, test_client, test_workspace): + """A client cannot relocate the workspace via PUT: workspace_root in + the request is dropped, and the stored value is always the live + workspace.repo_path. PUT and GET stay consistent.""" + body = { + "workspace_root": "/attacker-controlled/path", + "default_branch": "main", + "auto_detect_tech_stack": True, + "tech_stack_override": None, + } + put_resp = test_client.put("/api/v2/workspaces/config", json=body) + assert put_resp.status_code == 200 + assert put_resp.json()["workspace_root"] == str(test_workspace.repo_path) + + # Persisted file reflects the live path, not the client-sent one + saved = json.loads((test_workspace.state_dir / "workspace_config.json").read_text()) + assert saved["workspace_root"] == str(test_workspace.repo_path) + + def test_put_empty_default_branch_rejected(self, test_client): + body = { + "workspace_root": "/tmp/proj", + "default_branch": "", + "auto_detect_tech_stack": True, + "tech_stack_override": None, + } + response = test_client.put("/api/v2/workspaces/config", json=body) + assert response.status_code == 422 diff --git a/web-ui/src/__tests__/components/settings/Proof9DefaultsTab.test.tsx b/web-ui/src/__tests__/components/settings/Proof9DefaultsTab.test.tsx new file mode 100644 index 00000000..2745dc40 --- /dev/null +++ b/web-ui/src/__tests__/components/settings/Proof9DefaultsTab.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import useSWR from 'swr'; + +import { Proof9DefaultsTab } from '@/components/settings/Proof9DefaultsTab'; +import { proofConfigApi } from '@/lib/api'; +import type { ProofConfigResponse } from '@/types'; + +jest.mock('swr'); +jest.mock('@/lib/api', () => ({ + proofConfigApi: { + getConfig: jest.fn(), + updateConfig: jest.fn(), + }, +})); +jest.mock('sonner', () => ({ + toast: { + success: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +const mockUseSWR = useSWR as jest.MockedFunction; +const mockUpdate = proofConfigApi.updateConfig as jest.MockedFunction< + typeof proofConfigApi.updateConfig +>; + +const ALL_ENABLED_STRICT: ProofConfigResponse = { + enabled_gates: ['unit', 'contract', 'e2e', 'visual', 'a11y', 'perf', 'sec', 'demo', 'manual'], + strictness: 'strict', +}; + +function mockSWR(data: ProofConfigResponse | undefined, mutate = jest.fn()) { + mockUseSWR.mockReturnValue({ + data, + error: undefined, + isLoading: data === undefined, + mutate, + } as unknown as ReturnType); + return mutate; +} + +describe('Proof9DefaultsTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows a no-workspace message when path is null', () => { + mockSWR(undefined); + render(); + expect(screen.getByText(/select a workspace/i)).toBeInTheDocument(); + }); + + it('renders all 9 gate checkboxes when data loaded', () => { + mockSWR(ALL_ENABLED_STRICT); + render(); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(9); + checkboxes.forEach((cb) => { + expect(cb).toHaveAttribute('data-state', 'checked'); + }); + }); + + it('Save and Discard are disabled when not dirty', () => { + mockSWR(ALL_ENABLED_STRICT); + render(); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled(); + }); + + it('toggling a checkbox enables Save', () => { + mockSWR(ALL_ENABLED_STRICT); + render(); + + const unitCheckbox = screen.getAllByRole('checkbox')[0]; + fireEvent.click(unitCheckbox); + + expect(screen.getByRole('button', { name: /save/i })).toBeEnabled(); + }); + + it('Save calls updateConfig with the current draft', async () => { + const mutate = mockSWR(ALL_ENABLED_STRICT); + mockUpdate.mockResolvedValue({ + enabled_gates: ['contract', 'e2e', 'visual', 'a11y', 'perf', 'sec', 'demo', 'manual'], + strictness: 'strict', + }); + + render(); + const unitCheckbox = screen.getAllByRole('checkbox')[0]; + fireEvent.click(unitCheckbox); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /save/i })); + }); + + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledTimes(1); + }); + expect(mockUpdate).toHaveBeenCalledWith('/ws', expect.objectContaining({ + strictness: 'strict', + })); + const callArgs = mockUpdate.mock.calls[0][1]; + expect(callArgs.enabled_gates).not.toContain('unit'); + expect(mutate).toHaveBeenCalled(); + }); + + it('shows a warning banner when no gates are enabled', () => { + mockSWR({ enabled_gates: [], strictness: 'strict' }); + render(); + expect(screen.getByRole('alert')).toHaveTextContent(/all gates disabled/i); + }); + + it('hides the banner once at least one gate is selected', () => { + mockSWR(ALL_ENABLED_STRICT); + render(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('Discard resets the draft to fetched data', () => { + mockSWR(ALL_ENABLED_STRICT); + render(); + + const unitCheckbox = screen.getAllByRole('checkbox')[0]; + fireEvent.click(unitCheckbox); + expect(screen.getByRole('button', { name: /save/i })).toBeEnabled(); + + fireEvent.click(screen.getByRole('button', { name: /discard/i })); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + }); +}); diff --git a/web-ui/src/__tests__/components/settings/WorkspaceConfigTab.test.tsx b/web-ui/src/__tests__/components/settings/WorkspaceConfigTab.test.tsx new file mode 100644 index 00000000..f07fef5b --- /dev/null +++ b/web-ui/src/__tests__/components/settings/WorkspaceConfigTab.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import useSWR from 'swr'; + +import { WorkspaceConfigTab } from '@/components/settings/WorkspaceConfigTab'; +import { workspaceConfigApi } from '@/lib/api'; +import type { WorkspaceConfigResponse } from '@/types'; + +jest.mock('swr'); +jest.mock('@/lib/api', () => ({ + workspaceConfigApi: { + getConfig: jest.fn(), + updateConfig: jest.fn(), + }, +})); +jest.mock('sonner', () => ({ + toast: { + success: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +const mockUseSWR = useSWR as jest.MockedFunction; +const mockUpdate = workspaceConfigApi.updateConfig as jest.MockedFunction< + typeof workspaceConfigApi.updateConfig +>; + +const DEFAULT_CONFIG: WorkspaceConfigResponse = { + workspace_root: '/home/user/proj', + default_branch: 'main', + auto_detect_tech_stack: true, + tech_stack_override: null, +}; + +function mockSWR(data: WorkspaceConfigResponse | undefined, mutate = jest.fn()) { + mockUseSWR.mockReturnValue({ + data, + error: undefined, + isLoading: data === undefined, + mutate, + } as unknown as ReturnType); + return mutate; +} + +describe('WorkspaceConfigTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows a no-workspace message when path is null', () => { + mockSWR(undefined); + render(); + expect(screen.getByText(/select a workspace/i)).toBeInTheDocument(); + }); + + it('renders the four config fields when data loaded', () => { + mockSWR(DEFAULT_CONFIG); + render(); + + expect(screen.getByLabelText(/workspace root path/i)).toHaveValue('/home/user/proj'); + expect(screen.getByLabelText(/default branch/i)).toHaveValue('main'); + expect(screen.getByLabelText(/manual tech-stack override/i)).toBeDisabled(); + }); + + it('disables tech-stack override input when auto-detect is on', () => { + mockSWR(DEFAULT_CONFIG); + render(); + expect(screen.getByLabelText(/manual tech-stack override/i)).toBeDisabled(); + }); + + it('enables tech-stack override when auto-detect is off', () => { + mockSWR({ ...DEFAULT_CONFIG, auto_detect_tech_stack: false }); + render(); + expect(screen.getByLabelText(/manual tech-stack override/i)).toBeEnabled(); + }); + + it('Save and Discard disabled when not dirty', () => { + mockSWR(DEFAULT_CONFIG); + render(); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled(); + }); + + it('editing default branch enables Save and persists on click', async () => { + const mutate = mockSWR(DEFAULT_CONFIG); + mockUpdate.mockResolvedValue({ ...DEFAULT_CONFIG, default_branch: 'develop' }); + + render(); + const branchInput = screen.getByLabelText(/default branch/i); + fireEvent.change(branchInput, { target: { value: 'develop' } }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeEnabled(); + + await act(async () => { + fireEvent.click(saveButton); + }); + await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); + expect(mockUpdate).toHaveBeenCalledWith( + '/ws', + expect.objectContaining({ default_branch: 'develop' }) + ); + expect(mutate).toHaveBeenCalled(); + }); + + it('Save stays disabled when default branch is emptied', () => { + mockSWR(DEFAULT_CONFIG); + render(); + + const branchInput = screen.getByLabelText(/default branch/i); + fireEvent.change(branchInput, { target: { value: '' } }); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + }); + + it('Discard resets the form', () => { + mockSWR(DEFAULT_CONFIG); + render(); + + const branchInput = screen.getByLabelText(/default branch/i); + fireEvent.change(branchInput, { target: { value: 'develop' } }); + expect(branchInput).toHaveValue('develop'); + + fireEvent.click(screen.getByRole('button', { name: /discard/i })); + expect(screen.getByLabelText(/default branch/i)).toHaveValue('main'); + }); +}); diff --git a/web-ui/src/app/settings/page.tsx b/web-ui/src/app/settings/page.tsx index 2a6adc4e..406664ef 100644 --- a/web-ui/src/app/settings/page.tsx +++ b/web-ui/src/app/settings/page.tsx @@ -9,6 +9,8 @@ import { settingsApi } from '@/lib/api'; import { getSelectedWorkspacePath } from '@/lib/workspace-storage'; import type { AgentSettings, AgentTypeKey, ApiError } from '@/types'; import { ApiKeysTab } from '@/components/settings/ApiKeysTab'; +import { Proof9DefaultsTab } from '@/components/settings/Proof9DefaultsTab'; +import { WorkspaceConfigTab } from '@/components/settings/WorkspaceConfigTab'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -163,15 +165,21 @@ export default function SettingsPage() {
-

PROOF9

- +

PROOF9 Defaults

+

+ Gate enablement and strictness for this workspace. +

+
-

Workspace

- +

Workspace Configuration

+

+ Root path, default branch, and tech-stack auto-detection. +

+
diff --git a/web-ui/src/components/settings/Proof9DefaultsTab.tsx b/web-ui/src/components/settings/Proof9DefaultsTab.tsx new file mode 100644 index 00000000..c5727900 --- /dev/null +++ b/web-ui/src/components/settings/Proof9DefaultsTab.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { toast } from 'sonner'; + +import { proofConfigApi } from '@/lib/api'; +import { GATE_LABELS, PROOF9_GATES, type Proof9Gate } from '@/lib/proof'; +import type { + ApiError, + ProofConfigResponse, + ProofStrictness, +} from '@/types'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +interface Proof9DefaultsTabProps { + workspacePath: string | null; +} + +function isDirty(a: ProofConfigResponse, b: ProofConfigResponse): boolean { + if (a.strictness !== b.strictness) return true; + if (a.enabled_gates.length !== b.enabled_gates.length) return true; + const setA = new Set(a.enabled_gates); + return b.enabled_gates.some((g) => !setA.has(g)); +} + +export function Proof9DefaultsTab({ workspacePath }: Proof9DefaultsTabProps) { + const swrKey = workspacePath ? ['proof-config', workspacePath] : null; + const { data, error, mutate } = useSWR( + swrKey, + () => proofConfigApi.getConfig(workspacePath!) + ); + + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (data) { + setDraft({ ...data, enabled_gates: [...data.enabled_gates] }); + } + }, [data]); + + if (!workspacePath) { + return ( +

+ Select a workspace from the sidebar to manage PROOF9 defaults. +

+ ); + } + if (error) { + return ( +

+ Failed to load PROOF9 config. Check the server logs. +

+ ); + } + if (!data || !draft) { + return

Loading…

; + } + + const dirty = isDirty(data, draft); + const enabledSet = new Set(draft.enabled_gates); + + const toggleGate = (gate: Proof9Gate, checked: boolean) => { + const next = new Set(enabledSet); + if (checked) next.add(gate); + else next.delete(gate); + setDraft({ + ...draft, + enabled_gates: PROOF9_GATES.filter((g) => next.has(g)), + }); + }; + + const setStrictness = (value: ProofStrictness) => { + setDraft({ ...draft, strictness: value }); + }; + + const handleSave = async () => { + setSaving(true); + try { + const saved = await proofConfigApi.updateConfig(workspacePath, { + enabled_gates: draft.enabled_gates, + strictness: draft.strictness, + }); + await mutate(saved, { revalidate: false }); + toast.success('PROOF9 defaults saved'); + } catch (err) { + const apiError = err as ApiError; + toast.error(apiError.detail || 'Failed to save PROOF9 defaults'); + } finally { + setSaving(false); + } + }; + + const handleDiscard = () => { + setDraft({ ...data, enabled_gates: [...data.enabled_gates] }); + toast.info('Changes discarded'); + }; + + return ( +
+

+ These defaults apply to new projects and to gate runs in this workspace. +

+ +
+

Gates enabled for new projects

+ {draft.enabled_gates.length === 0 && ( +
+ All gates disabled. Proof runs will pass with no + evidence — every requirement is silently green. Re-enable at least + one gate to keep PROOF9 enforcing quality. +
+ )} +
+ {PROOF9_GATES.map((gate) => { + const id = `proof9-gate-${gate}`; + return ( + + ); + })} +
+
+ +
+

Strictness

+

+ strict — any gate failure fails the proof run.{' '} + warn — gate failures are recorded but overall_passed + stays True. +

+ +
+ +
+ + +
+
+ ); +} diff --git a/web-ui/src/components/settings/WorkspaceConfigTab.tsx b/web-ui/src/components/settings/WorkspaceConfigTab.tsx new file mode 100644 index 00000000..74e051fb --- /dev/null +++ b/web-ui/src/components/settings/WorkspaceConfigTab.tsx @@ -0,0 +1,194 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { toast } from 'sonner'; + +import { workspaceConfigApi } from '@/lib/api'; +import type { + ApiError, + WorkspaceConfigResponse, +} from '@/types'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; + +interface WorkspaceConfigTabProps { + workspacePath: string | null; +} + +function isDirty( + a: WorkspaceConfigResponse, + b: WorkspaceConfigResponse +): boolean { + return ( + a.workspace_root !== b.workspace_root || + a.default_branch !== b.default_branch || + a.auto_detect_tech_stack !== b.auto_detect_tech_stack || + (a.tech_stack_override ?? '') !== (b.tech_stack_override ?? '') + ); +} + +export function WorkspaceConfigTab({ workspacePath }: WorkspaceConfigTabProps) { + const swrKey = workspacePath ? ['workspace-config', workspacePath] : null; + const { data, error, mutate } = useSWR( + swrKey, + () => workspaceConfigApi.getConfig(workspacePath!) + ); + + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (data) { + setDraft({ ...data }); + } + }, [data]); + + if (!workspacePath) { + return ( +

+ Select a workspace from the sidebar to manage workspace configuration. +

+ ); + } + if (error) { + return ( +

+ Failed to load workspace config. Check the server logs. +

+ ); + } + if (!data || !draft) { + return

Loading…

; + } + + const dirty = isDirty(data, draft); + const canSave = dirty && !saving && draft.workspace_root.trim() !== '' && draft.default_branch.trim() !== ''; + + const handleSave = async () => { + setSaving(true); + try { + const saved = await workspaceConfigApi.updateConfig(workspacePath, { + workspace_root: draft.workspace_root, + default_branch: draft.default_branch, + auto_detect_tech_stack: draft.auto_detect_tech_stack, + tech_stack_override: draft.tech_stack_override, + }); + await mutate(saved, { revalidate: false }); + toast.success('Workspace config saved'); + } catch (err) { + const apiError = err as ApiError; + toast.error(apiError.detail || 'Failed to save workspace config'); + } finally { + setSaving(false); + } + }; + + const handleDiscard = () => { + setDraft({ ...data }); + toast.info('Changes discarded'); + }; + + return ( +
+
+ + +

+ Display only — workspace path is fixed at init time. Use{' '} + cf init to create a workspace at a different path. +

+
+ +
+ + + setDraft({ ...draft, default_branch: e.target.value }) + } + /> +
+ +
+ +

+ When on, CodeFRAME infers the tech stack from project files. +

+
+ +
+ + + setDraft({ + ...draft, + tech_stack_override: e.target.value || null, + }) + } + /> +

+ Disabled while auto-detection is on. +

+
+ +
+ + +
+
+ ); +} diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts index 6d91e0f4..3645d349 100644 --- a/web-ui/src/lib/api.ts +++ b/web-ui/src/lib/api.ts @@ -63,6 +63,10 @@ import type { KeyProvider, KeyStatusResponse, VerifyKeyResponse, + ProofConfigResponse, + UpdateProofConfigRequest, + WorkspaceConfigResponse, + UpdateWorkspaceConfigRequest, } from '@/types'; // FastAPI validation error format @@ -858,4 +862,51 @@ export const settingsApi = { }, }; +// PROOF9 config API (issue #556) +export const proofConfigApi = { + getConfig: async (workspacePath: string): Promise => { + const response = await api.get('/api/v2/proof/config', { + params: { workspace_path: workspacePath }, + }); + return response.data; + }, + + updateConfig: async ( + workspacePath: string, + body: UpdateProofConfigRequest + ): Promise => { + const response = await api.put( + '/api/v2/proof/config', + body, + { params: { workspace_path: workspacePath } } + ); + return response.data; + }, +}; + +// Workspace config API (issue #556) +export const workspaceConfigApi = { + getConfig: async ( + workspacePath: string + ): Promise => { + const response = await api.get( + '/api/v2/workspaces/config', + { params: { workspace_path: workspacePath } } + ); + return response.data; + }, + + updateConfig: async ( + workspacePath: string, + body: UpdateWorkspaceConfigRequest + ): Promise => { + const response = await api.put( + '/api/v2/workspaces/config', + body, + { params: { workspace_path: workspacePath } } + ); + return response.data; + }, +}; + export default api; diff --git a/web-ui/src/lib/proof.ts b/web-ui/src/lib/proof.ts new file mode 100644 index 00000000..4bad0ec6 --- /dev/null +++ b/web-ui/src/lib/proof.ts @@ -0,0 +1,36 @@ +/** + * PROOF9 constants shared across the web UI. + * + * The 9 gate names mirror the `Gate` enum in + * `codeframe/core/proof/models.py` and are the canonical wire values + * accepted by the backend's `enabled_gates` field. + * + * SYNC: if a gate is added/removed in `codeframe/core/proof/models.py::Gate`, + * mirror the change here AND in `GATE_LABELS` below. + */ + +export const PROOF9_GATES = [ + 'unit', + 'contract', + 'e2e', + 'visual', + 'a11y', + 'perf', + 'sec', + 'demo', + 'manual', +] as const; + +export type Proof9Gate = (typeof PROOF9_GATES)[number]; + +export const GATE_LABELS: Record = { + unit: 'Unit', + contract: 'Contract', + e2e: 'E2E', + visual: 'Visual', + a11y: 'A11y', + perf: 'Performance', + sec: 'Security', + demo: 'Demo', + manual: 'Manual', +}; diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index 7fb6888f..1f430776 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -592,3 +592,30 @@ export interface VerifyKeyResponse { valid: boolean; message: string; } + +// PROOF9 + Workspace config (issue #556) +export type ProofStrictness = 'strict' | 'warn'; + +export interface ProofConfigResponse { + enabled_gates: string[]; + strictness: ProofStrictness; +} + +export interface UpdateProofConfigRequest { + enabled_gates: string[]; + strictness: ProofStrictness; +} + +export interface WorkspaceConfigResponse { + workspace_root: string; + default_branch: string; + auto_detect_tech_stack: boolean; + tech_stack_override: string | null; +} + +export interface UpdateWorkspaceConfigRequest { + workspace_root: string; + default_branch: string; + auto_detect_tech_stack: boolean; + tech_stack_override: string | null; +}