diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5f7f7..e22e075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ coverage *completeness* remains a documented roadmap item, not a 1.0 gate.) bounded evidence), `intent orphans`, `intent trace`, `intent corpus`. - **Authoring surface** — `goal`, `req` (draft/approve/supersede/deprecate), `bind sei`, `catalog record`, `trace`, `criterion`, `verify`, `baseline`, `actor`. +- **Local store & verification reads** — `init` (create a `.plainweave/` store), + `status` (requirement verification status), `dossier` (per-requirement dossier). - **Cross-member seams** — Loomweave catalog adapter (consumes SEIs opaquely, never mints), Legis preflight advisory cell, peer-ready entity-intent-context API. - **MCP server** (`plainweave-mcp`) — read-only mirror of the intent reads; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8855f4..c891bc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,12 @@ # Contributing to Plainweave -Plainweave is a local-first requirements and verification authority for the Weft -suite. It is early-stage; keep changes small, tested, and aligned with the -authority boundary in `docs/concept.md`. +Plainweave is the Weft federation member that holds the team's code-grounded +intent — the local-first traceability graph binding code entities (Loomweave +SEIs) to requirements and strategic goals. As of 1.0.0 it is stable +(Production/Stable on PyPI, versioned JSON envelopes, a green CI gate). Keep +changes small, tested, and aligned with the authority boundary in the canonical +design, +[`docs/design/2026-06-18-plainweave-permission-to-exist.md`](docs/design/2026-06-18-plainweave-permission-to-exist.md). ## Development setup @@ -22,8 +26,9 @@ This installs Plainweave in a uv-managed virtual environment with `ruff`, `mypy` - Linter and formatter: `ruff` with line length 120. - Type checker: `mypy` in strict mode. - Tests: `pytest`. -- Runtime base: keep required dependencies empty unless the approved design says - otherwise. +- Runtime base: the only approved runtime dependency is `mcp>=1.2.0` (needed by + `plainweave-mcp`). Do not add further runtime dependencies without an explicit + design decision. Before committing: diff --git a/README.md b/README.md index 3ed3799..ea73322 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ agent use, not canned reports): the artifact a curator reads to spot *"these three are the same."* Consolidation is **agent-driven**; Plainweave serves the substrate, not an automated verdict. +Over these three sits **`coverage()`** — the self-computed *intent-coverage* +north-star: the fraction of public surfaces that answer *"why does this exist?"* +It is honestly qualified in-band (namespace scoping, `denominator_complete`, +`present_plugins`, bounded evidence) and never reports a silent clean when the +denominator is partial. + **Boundary** — coverage facts ride out at the git/CI boundary through Legis (*"this change adds N public entities bound to no requirement"*). **Advisory by default;** any repo wanting teeth dials it up through Legis's policy cells. @@ -110,12 +116,41 @@ blast-radius map + dated counterpart tickets. ## Documentation -- [`docs/design/`](docs/design/) — the canonical design ("permission for code to - exist"). **Start here.** +- [`docs/design/2026-06-18-plainweave-permission-to-exist.md`](docs/design/2026-06-18-plainweave-permission-to-exist.md) + — the canonical design ("permission for code to exist"). **Start here.** - [`docs/MODULE-MAP.md`](docs/MODULE-MAP.md) — what the precursor core carries forward vs. what the reframe reshapes. - [`docs/README.md`](docs/README.md) — index of canon vs. precursor-era docs. +## Installation + +```bash +pip install plainweave +``` + +Or with [uv](https://docs.astral.sh/uv/): + +```bash +uv pip install plainweave # add to an environment +uvx plainweave --help # or run it without installing +``` + +Plainweave requires Python ≥ 3.12. Installing exposes two console commands: + +- `plainweave` — the CLI: `init`, `intent` (`coverage` / `orphans` / `trace` / + `corpus`), `req`, `goal`, `bind`, `catalog`, `criterion`, `verify`, `status`, + `dossier`, `baseline`, `actor`, and `doctor`. +- `plainweave-mcp` — the read-only MCP server that mirrors the intent reads for + agents (`mutates:false`, `local_only:true`). + +Quick start: + +```bash +plainweave init # create a local store under .plainweave/ +plainweave intent coverage # the north-star: how much public surface is justified +plainweave doctor # check store, Loomweave binding, and MCP surface +``` + ## Development Plainweave uses [uv](https://docs.astral.sh/uv/), `hatchling`, `ruff`, `mypy`, @@ -128,10 +163,30 @@ make ci # lint + typecheck + test (coverage-gated) The runtime package depends on the official Python MCP SDK for `plainweave-mcp`. -### What works today (carried forward from the precursor) +### Web UI (optional) + +Install the extra and launch the operator console: + + pip install 'plainweave[web]' + plainweave web --actor human: + +Browse the corpus, author requirements, and ratify agent-proposed drafts and +trace links. Local-first, single-operator; advisory only (no release verdicts). + +#### Accessibility (AT gate — manual) + +Before shipping the review surface, run an NVDA (Windows) or VoiceOver (macOS) +pass over the `/review` queue. Each approve / accept / reject action must: + +1. **Announce the outcome** via the `#sr-status` live region + (`role="status" aria-live="polite"`), e.g. "Approved: Requirement title". +2. **Move focus** to the next action button in the queue (or to the "All caught + up" heading when the queue empties). +3. **On the last item**, announce "Queue is now empty" and place focus on the + "All caught up" heading. -The precursor's local requirements/verification core is intact and green — a -foundation to reshape, not the reframed feature set. See the MODULE MAP for the -current → target audit. The code-up read primitives (`orphans`/`trace`/`corpus`), -the SEI-keyed ADR-029 bindings, and the authoring-time write path are **stubbed -with backlog markers**, not yet implemented. +Structural contracts (live region presence, skip-link, labelled search input, +per-item `aria-label` on Approve buttons) are locked by +`tests/web/test_a11y_contracts.py`. The focus-management and announcement +behaviour above require a live AT session and cannot be automated in the +current test harness. diff --git a/pyproject.toml b/pyproject.toml index eb54599..4346af5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ [dependency-groups] dev = [ "coverage[toml]>=7.0", + "jinja2>=3.1.6", "mypy>=1.13.0", "pytest>=8.0", "pytest-cov>=5.0", @@ -42,6 +43,14 @@ plainweave-mcp = "plainweave.mcp_server:main" Homepage = "https://github.com/foundryside-dev/plainweave" Repository = "https://github.com/foundryside-dev/plainweave" Issues = "https://github.com/foundryside-dev/plainweave/issues" +Changelog = "https://github.com/foundryside-dev/plainweave/blob/main/CHANGELOG.md" + +[project.optional-dependencies] +web = [ + "starlette>=0.37", + "uvicorn>=0.30", + "jinja2>=3.1", +] [tool.hatch.version] path = "src/plainweave/_version.py" @@ -49,6 +58,10 @@ path = "src/plainweave/_version.py" [tool.hatch.build.targets.wheel] packages = ["src/plainweave"] +[tool.hatch.build.targets.wheel.force-include] +"src/plainweave/web/templates" = "plainweave/web/templates" +"src/plainweave/web/static" = "plainweave/web/static" + [tool.ruff] line-length = 120 target-version = "py312" @@ -67,6 +80,21 @@ explicit_package_bases = true [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra -q" +filterwarnings = [ + # Starlette TestClient emits a StarletteDeprecationWarning (a subclass of + # DeprecationWarning) at import time when the legacy httpx package is installed + # instead of httpx2. This is a third-party warning outside our control; suppress + # it until we can upgrade to httpx2. + "ignore:Using `httpx` with `starlette.testclient` is deprecated.*", + # pytest-cov's coverage tracer triggers Python's ResourceWarning for SQLite + # connections that are closed lazily by the GC rather than explicitly. These + # are pre-existing store-layer connections surfaced only under --cov; they do + # not indicate a real data-loss risk. Track the underlying leak separately. + "ignore:unclosed database.*:ResourceWarning", +] +markers = [ + "sei_drift: opt-in release-gate recheck that the vendored SEI conformance oracle has not drifted from the upstream Loomweave checkout (run with -m sei_drift)", +] [tool.coverage.run] source = ["plainweave"] diff --git a/src/plainweave/cli.py b/src/plainweave/cli.py index 1a30a95..66dbc9b 100644 --- a/src/plainweave/cli.py +++ b/src/plainweave/cli.py @@ -16,6 +16,9 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="store_true", help="Print the Plainweave version and exit.") subparsers = parser.add_subparsers(dest="command") register_commands(subparsers) + from plainweave.web.server import add_web_subcommand # local import keeps web optional + + add_web_subcommand(subparsers) return parser diff --git a/src/plainweave/loomweave_adapter.py b/src/plainweave/loomweave_adapter.py index 584f007..b873068 100644 --- a/src/plainweave/loomweave_adapter.py +++ b/src/plainweave/loomweave_adapter.py @@ -253,7 +253,39 @@ def snapshot_error(self, error: LoomweaveIdentityError) -> JsonObject: code = code_by_reason.get(error.reason, "identity_degraded") return {"code": code, "message": error.message} + def _probe_sei_capability(self) -> None: + """Probe the remote Loomweave's ``GET /api/v1/_capabilities`` and gate the + HTTP identity resolve on ``sei.supported``. + + Loomweave is the SEI authority; a consumer learns whether an instance serves + SEI from the wire capability, NOT from the local SQLite schema. Without this + probe the adapter would POST ``/api/v1/identity/resolve`` against a pre-SEI + Loomweave and surface the response (or its connection error) as + ``not_found`` / ``unreachable`` — conflating "this instance has no SEI + capability" with "this instance is down". That fails §8 ``capability_absent``, + which requires an HONEST degrade. + + Two outcomes are kept ORTHOGONAL, mirroring the existing reason vocabulary: + * ``_http_json`` raises (connection refused / timeout / non-object body) → + the error propagates with ``reason="unreachable"`` — the remote is down, + unchanged from before. + * ``_http_json`` returns a 2xx body whose ``sei.supported`` is not exactly + ``True`` (absent / false / malformed) → raise ``reason="unsupported"`` — + the remote is reachable but serves no SEI. This is the ONLY new behaviour; + it is the honest "identity unavailable" the conformance oracle demands. + """ + body = self._http_json("GET", "/api/v1/_capabilities") + sei = body.get("sei") + supported = isinstance(sei, dict) and sei.get("supported") is True + if not supported: + raise LoomweaveIdentityError( + "unsupported", + "Loomweave instance does not advertise SEI support (/api/v1/_capabilities).", + [self._degraded("sei_support_missing", "Loomweave SEI capability is absent on the remote.")], + ) + def _resolve_identity_http(self, value: str) -> LoomweaveCatalogEntity: + self._probe_sei_capability() if value.startswith(LOOMWEAVE_SEI_PREFIX): quoted = urllib.parse.quote(value, safe="") body = self._http_json("GET", f"/api/v1/identity/sei/{quoted}") diff --git a/src/plainweave/service.py b/src/plainweave/service.py index d56e1bd..a4f4688 100644 --- a/src/plainweave/service.py +++ b/src/plainweave/service.py @@ -1051,6 +1051,11 @@ def create_goal(self, title: str, statement: str, *, actor: str) -> IntentGoal: connection.commit() return IntentGoal(goal_id, display_id, stable_id, title, statement, "active", actor, now) + def list_goals(self) -> list[IntentGoal]: + with connect(self.db_path) as connection: + rows = connection.execute("select * from intent_goals order by display_id").fetchall() + return [self._goal_from_row(row) for row in rows] + def link_goal_to_requirement(self, goal_id: str, requirement_id: str, *, actor: str) -> IntentEdge: self._require_actor(actor) now = self._now() diff --git a/src/plainweave/web/__init__.py b/src/plainweave/web/__init__.py new file mode 100644 index 0000000..82037cb --- /dev/null +++ b/src/plainweave/web/__init__.py @@ -0,0 +1 @@ +"""Optional operator-facing web tier (the plainweave[web] extra).""" diff --git a/src/plainweave/web/app.py b/src/plainweave/web/app.py new file mode 100644 index 0000000..4197378 --- /dev/null +++ b/src/plainweave/web/app.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from pathlib import Path +from urllib.parse import parse_qsl + +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import PlainTextResponse, Response +from starlette.routing import Mount, Route +from starlette.staticfiles import StaticFiles +from starlette.templating import Jinja2Templates + +from plainweave.errors import PlainweaveError +from plainweave.web.context import RequestContext, csrf_ok, new_csrf_token +from plainweave.web.errors import error_to_status + +_HERE = Path(__file__).parent +_CSRF_COOKIE = "pw_csrf" + + +def create_app(*, actor: str | None, root: Path | None) -> Starlette: + templates = Jinja2Templates(directory=str(_HERE / "templates")) + + def ctx_factory() -> RequestContext: + return RequestContext.from_root(root, actor=actor) + + async def healthz(request: Request) -> Response: + return PlainTextResponse("ok") + + async def on_error(request: Request, exc: Exception) -> Response: + if isinstance(exc, PlainweaveError): + status = error_to_status(exc.code) + return templates.TemplateResponse( + request, + "_partials/error.html", + {"code": exc.code.value, "message": exc.message, "hint": exc.hint}, + status_code=status, + ) + raise exc + + async def csrf_mw(request: Request, call_next: RequestResponseEndpoint) -> Response: + cookie_token = request.cookies.get(_CSRF_COOKIE) + if request.method in {"POST", "PUT", "PATCH", "DELETE"}: + # Read via .body() so Starlette's _CachedRequest can replay the raw + # bytes to downstream handlers — calling .form() here would consume + # the body stream, leaving downstream request.form() empty. + body = await request.body() + fields = dict(parse_qsl(body.decode("utf-8"))) + if not csrf_ok(cookie_token, fields.get("_csrf")): + return PlainTextResponse("CSRF check failed", status_code=403) + # Mint the token for THIS render before calling the handler so the template + # can embed a real token even on the very first (cold) request, when there + # is no cookie yet. scope["state"] is shared into the handler via + # BaseHTTPMiddleware, making request.state.csrf_token visible there. + token = cookie_token or new_csrf_token() + request.state.csrf_token = token + response = await call_next(request) + if cookie_token is None: + response.set_cookie(_CSRF_COOKIE, token, httponly=True, samesite="strict") + return response + + routes = [ + Route("/healthz", healthz), + Mount("/static", app=StaticFiles(directory=str(_HERE / "static")), name="static"), + ] + app = Starlette( + routes=routes, + middleware=[Middleware(BaseHTTPMiddleware, dispatch=csrf_mw)], + exception_handlers={PlainweaveError: on_error}, + ) + app.state.templates = templates + app.state.ctx_factory = ctx_factory + app.state.csrf_cookie = _CSRF_COOKIE + + from plainweave.web.routes import register_all + + register_all(app) + return app diff --git a/src/plainweave/web/context.py b/src/plainweave/web/context.py new file mode 100644 index 0000000..93d6074 --- /dev/null +++ b/src/plainweave/web/context.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from pathlib import Path + +from plainweave.errors import ErrorCode, PlainweaveError +from plainweave.paths import plainweave_db_path +from plainweave.service import PlainweaveService + +DEFAULT_OPERATOR_ID = "human:operator" + + +@dataclass(frozen=True) +class OperatorIdentity: + actor_id: str + display_name: str + kind: str + + +class RequestContext: + def __init__(self, service: PlainweaveService, operator: OperatorIdentity) -> None: + self.service = service + self.operator = operator + + @classmethod + def from_root(cls, root: Path | None, *, actor: str | None) -> RequestContext: + service = PlainweaveService(plainweave_db_path(root), root=root) + actor_id = actor or DEFAULT_OPERATOR_ID + display = actor_id.split(":", 1)[-1] or actor_id + operator = cls._ensure_operator(service, actor_id, display) + return cls(service, operator) + + @staticmethod + def _ensure_operator(service: PlainweaveService, actor_id: str, display: str) -> OperatorIdentity: + # Register the operator as a human actor. At genesis (no attester yet) this + # self-registration is permitted; once an attester exists, only an existing + # attester may (re)register a human — surface that clearly rather than crashing. + try: + service.register_actor(actor_id, kind="human", display_name=display, actor=actor_id) + except PlainweaveError as exc: + if exc.code is ErrorCode.POLICY_REQUIRED: + raise PlainweaveError( + ErrorCode.POLICY_REQUIRED, + f"operator actor {actor_id!r} is not a registered human and cannot self-register " + "(an attester already exists). Register it via the CLI before launching the web UI.", + recoverable=False, + hint="plainweave actor register --id --kind human --actor ", + ) from exc + raise + return OperatorIdentity(actor_id=actor_id, display_name=display, kind="human") + + +def new_csrf_token() -> str: + return secrets.token_urlsafe(32) + + +def csrf_ok(cookie_token: str | None, form_token: str | None) -> bool: + if not cookie_token or not form_token: + return False + return secrets.compare_digest(cookie_token, form_token) diff --git a/src/plainweave/web/errors.py b/src/plainweave/web/errors.py new file mode 100644 index 0000000..f8d7f8e --- /dev/null +++ b/src/plainweave/web/errors.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from plainweave.errors import ErrorCode + +_STATUS: dict[ErrorCode, int] = { + ErrorCode.VALIDATION: 400, + ErrorCode.NOT_FOUND: 404, + ErrorCode.CONFLICT: 409, + ErrorCode.POLICY_REQUIRED: 409, + ErrorCode.LOCKED: 409, + ErrorCode.PEER_ABSENT: 503, + ErrorCode.PEER_STALE: 503, + ErrorCode.PEER_CONTRACT: 502, + ErrorCode.UNSUPPORTED: 400, + ErrorCode.INTERNAL: 500, +} + + +def error_to_status(code: ErrorCode) -> int: + return _STATUS.get(code, 500) diff --git a/src/plainweave/web/routes/__init__.py b/src/plainweave/web/routes/__init__.py new file mode 100644 index 0000000..9075af3 --- /dev/null +++ b/src/plainweave/web/routes/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from starlette.applications import Starlette + + +def register_all(app: Starlette) -> None: + # Each route module appends its routes; populated as tasks land. + from plainweave.web.routes import goals, intent, requirements, review + + requirements.register(app) + intent.register(app) + review.register(app) + goals.register(app) diff --git a/src/plainweave/web/routes/goals.py b/src/plainweave/web/routes/goals.py new file mode 100644 index 0000000..01e928e --- /dev/null +++ b/src/plainweave/web/routes/goals.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response +from starlette.routing import Route +from starlette.templating import Jinja2Templates + +from plainweave.intent_graph import IntentLevel + + +async def goals_page(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + goals = ctx.service.list_goals() + orphan_goal_ids = {n.node_id for n in ctx.service.intent_orphans(IntentLevel.GOAL)} + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "goals.html", + {"goals": goals, "orphan_goal_ids": orphan_goal_ids, "operator": ctx.operator, "active_page": "goals"}, + ) + + +async def goals_new(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + form = await request.form() + ctx.service.create_goal(str(form["title"]), str(form["statement"]), actor=ctx.operator.actor_id) + return RedirectResponse("/goals", status_code=303) + + +async def req_ladder(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id = request.path_params["req_id"] + form = await request.form() + ctx.service.link_goal_to_requirement(str(form["goal_id"]), req_id, actor=ctx.operator.actor_id) + return RedirectResponse(f"/req/{req_id}", status_code=303) + + +def register(app: Starlette) -> None: + app.router.routes.append(Route("/goals", goals_page, name="goals")) + app.router.routes.append(Route("/goals/new", goals_new, methods=["POST"])) + app.router.routes.append(Route("/req/{req_id}/ladder", req_ladder, methods=["POST"])) diff --git a/src/plainweave/web/routes/intent.py b/src/plainweave/web/routes/intent.py new file mode 100644 index 0000000..d0f5e68 --- /dev/null +++ b/src/plainweave/web/routes/intent.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route +from starlette.templating import Jinja2Templates + +from plainweave.intent_graph import IntentLevel +from plainweave.web import views + + +async def intent_dashboard(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + cov = ctx.service.intent_coverage() + orphans = { + level.value: ctx.service.intent_orphans(level) + for level in (IntentLevel.CODE, IntentLevel.REQUIREMENT, IntentLevel.GOAL) + } + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "intent.html", + { + "cov": cov, + "banner": views.coverage_banner(cov), + "orphans": orphans, + "operator": ctx.operator, + "active_page": "intent", + }, + ) + + +def register(app: Starlette) -> None: + app.router.routes.append(Route("/intent", intent_dashboard, name="intent")) diff --git a/src/plainweave/web/routes/requirements.py b/src/plainweave/web/routes/requirements.py new file mode 100644 index 0000000..40673f9 --- /dev/null +++ b/src/plainweave/web/routes/requirements.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from starlette.applications import Starlette +from starlette.datastructures import FormData +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse, Response +from starlette.routing import Route +from starlette.templating import Jinja2Templates + +from plainweave.errors import ErrorCode, PlainweaveError +from plainweave.models import RequirementRecord +from plainweave.service import PlainweaveService +from plainweave.web import views + + +def _require_str(form: FormData, field: str) -> str: + """Return form[field] as a non-empty str, or raise a 400-mapped PlainweaveError.""" + raw = form.get(field) + if raw is None: + raise PlainweaveError( + ErrorCode.VALIDATION, + f"missing required field: {field!r}", + recoverable=True, + hint=f"provide a value for {field!r}", + ) + return str(raw) + + +def _optional_int(form: FormData, field: str, default: int = 0) -> int: + """Return form[field] as int (default if absent), or raise a 400-mapped PlainweaveError.""" + raw = form.get(field) + if raw is None: + return default + try: + return int(str(raw)) + except ValueError as exc: + raise PlainweaveError( + ErrorCode.VALIDATION, + f"field {field!r} must be an integer, got {raw!r}", + recoverable=True, + hint=f"provide a valid integer value for {field!r}", + ) from exc + + +def _resolve_titles(svc: PlainweaveService, records: list[RequirementRecord]) -> dict[str, str]: + """Resolve a display title for each requirement. + + For approved requirements the approved-version title is used. For draft-only + requirements the active draft title is sourced via ``requirement_dossier`` so + the displayed title matches what the author typed, not just the display-id + fallback. Falls back to the display-id when neither exists. + """ + titles: dict[str, str] = {} + for rec in records: + if rec.current_version_record is not None: + titles[rec.requirement_id] = rec.current_version_record.title + else: + # Draft-only: fetch the active draft title from the dossier. + dossier = svc.requirement_dossier(rec.requirement_id) + draft = dossier.requirement.active_draft + titles[rec.requirement_id] = draft.title if draft is not None else rec.id + return titles + + +async def corpus(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + q = request.query_params.get("q", "") + status = request.query_params.get("status", "") + orphan = request.query_params.get("orphan", "") + records = ctx.service.search_requirements() + titles = _resolve_titles(ctx.service, records) + rows = views.build_corpus_rows(ctx.service.intent_corpus(), records, titles) + rows = views.filter_rows(rows, q=q, status=status, orphan=orphan) + template = "_partials/corpus_rows.html" if request.headers.get("HX-Request") else "corpus.html" + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + template, + { + "rows": rows, + "filters": {"q": q, "status": status, "orphan": orphan}, + "operator": ctx.operator, + "active_page": "corpus", + }, + ) + + +async def req_inline(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id = request.path_params["req_id"] + dossier = ctx.service.requirement_dossier(req_id) + section = dossier.requirement + statement = ( + section.active_draft.statement + if section.active_draft + else (section.current_version.statement if section.current_version else "") + ) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/req_inline.html", + {"req_id": req_id, "statement": statement, "status": dossier.requirement.record.status}, + ) + + +async def req_inline_collapsed(request: Request) -> Response: + return HTMLResponse("") + + +async def req_detail(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id = request.path_params["req_id"] + dossier = ctx.service.requirement_dossier(req_id) + goals = ctx.service.list_goals() + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "requirement_detail.html", + {"dossier": dossier, "req_id": req_id, "operator": ctx.operator, "active_page": "corpus", "goals": goals}, + ) + + +async def req_new_get(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "requirement_form.html", + { + "req_id": None, + "title": "", + "statement": "", + "expected_draft_revision": None, + "operator": ctx.operator, + "active_page": "corpus", + }, + ) + + +async def req_new_post(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + form = await request.form() + title = _require_str(form, "title") + statement = _require_str(form, "statement") + ctx.service.create_requirement(title, statement, actor=ctx.operator.actor_id) + return RedirectResponse("/", status_code=303) + + +async def req_edit_get(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id = request.path_params["req_id"] + draft = ctx.service.requirement_dossier(req_id).requirement.active_draft + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "requirement_form.html", + { + "req_id": req_id, + "title": draft.title if draft is not None else "", + "statement": draft.statement if draft is not None else "", + "expected_draft_revision": draft.draft_revision if draft is not None else None, + "operator": ctx.operator, + "active_page": "corpus", + }, + ) + + +async def req_edit_post(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id = request.path_params["req_id"] + form = await request.form() + title = _require_str(form, "title") + statement = _require_str(form, "statement") + expected = _optional_int(form, "expected_draft_revision", default=0) + try: + ctx.service.update_draft( + req_id, + actor=ctx.operator.actor_id, + title=title, + statement=statement, + expected_draft_revision=expected, + ) + return RedirectResponse(f"/req/{req_id}", status_code=303) + except PlainweaveError as exc: + if exc.code is not ErrorCode.CONFLICT: + raise # falls through to the global handler + # Local catch: HTMX only swaps 2xx; return 200 with both texts preserved. + draft = ctx.service.requirement_dossier(req_id).requirement.active_draft + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/edit_conflict.html", + { + "req_id": req_id, + "submitted_title": title, + "submitted_statement": statement, + "current_title": draft.title if draft is not None else "", + "current_statement": draft.statement if draft is not None else "", + "fresh_revision": draft.draft_revision if draft is not None else 0, + }, + status_code=200, + ) + + +def register(app: Starlette) -> None: + app.router.routes.append(Route("/", corpus, name="corpus")) + # /req/new MUST precede /req/{req_id} — Starlette matches in registration order; + # a literal "new" segment would otherwise be captured as req_id. + app.router.routes.append(Route("/req/new", req_new_get, name="req_new")) + app.router.routes.append(Route("/req/new", req_new_post, methods=["POST"])) + app.router.routes.append(Route("/req/{req_id}", req_detail, name="req_detail")) + app.router.routes.append(Route("/req/{req_id}/inline", req_inline, name="req_inline")) + app.router.routes.append(Route("/req/{req_id}/inline/collapsed", req_inline_collapsed, name="req_inline_collapsed")) + app.router.routes.append(Route("/req/{req_id}/edit", req_edit_get, name="req_edit")) + app.router.routes.append(Route("/req/{req_id}/edit", req_edit_post, methods=["POST"])) diff --git a/src/plainweave/web/routes/review.py b/src/plainweave/web/routes/review.py new file mode 100644 index 0000000..20281ff --- /dev/null +++ b/src/plainweave/web/routes/review.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from starlette.applications import Starlette +from starlette.datastructures import FormData +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route +from starlette.templating import Jinja2Templates + +from plainweave.errors import ErrorCode, PlainweaveError +from plainweave.models import RequirementDraft, RequirementRecord +from plainweave.web import views + +if TYPE_CHECKING: + from plainweave.service import PlainweaveService + + +def _require_int(form: FormData, field: str) -> int: + """Return form[field] as int, or raise a 400-mapped PlainweaveError.""" + raw = form.get(field) + if raw is None: + raise PlainweaveError( + ErrorCode.VALIDATION, + f"missing required field: {field!r}", + recoverable=True, + hint=f"provide a valid integer value for {field!r}", + ) + try: + return int(str(raw)) + except ValueError as exc: + raise PlainweaveError( + ErrorCode.VALIDATION, + f"field {field!r} must be an integer, got {raw!r}", + recoverable=True, + hint=f"provide a valid integer value for {field!r}", + ) from exc + + +def _pending_count(service: PlainweaveService) -> int: + return len(views.pending_items(service)) + + +def _draft_ctx(service: PlainweaveService, req_id: str) -> tuple[RequirementRecord, RequirementDraft]: + rec = service.get_requirement(req_id) + draft = service.requirement_dossier(req_id).requirement.active_draft + if draft is None: + raise PlainweaveError( + ErrorCode.POLICY_REQUIRED, + f"requirement {req_id!r} has no active draft", + recoverable=False, + hint="ensure the requirement has an active draft before approving", + ) + return rec, draft + + +async def review(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + items = views.pending_items(ctx.service) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "review.html", + { + "items": items, + "pending_count": len(items), + "operator": ctx.operator, + "active_page": "review", + }, + ) + + +async def approve_confirm(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id: str = request.path_params["req_id"] + rec, draft = _draft_ctx(ctx.service, req_id) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/draft_approve_confirm.html", + { + "req_id": req_id, + "title": draft.title, + "current_version": rec.current_version, + "next_version": rec.current_version + 1, + "error": None, + }, + ) + + +async def approve_post(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id: str = request.path_params["req_id"] + form = await request.form() + expected = _require_int(form, "expected_version") + rec, draft = _draft_ctx(ctx.service, req_id) + templates: Jinja2Templates = request.app.state.templates + try: + ctx.service.approve_requirement(req_id, actor=ctx.operator.actor_id, expected_version=expected) + except PlainweaveError as exc: + if exc.code is not ErrorCode.CONFLICT: + raise + return templates.TemplateResponse( + request, + "_partials/draft_approve_confirm.html", + { + "req_id": req_id, + "title": draft.title, + "current_version": rec.current_version, + "next_version": rec.current_version + 1, + "error": "Draft changed since you loaded this. Reopen to see the latest.", + }, + status_code=200, + ) + remaining = _pending_count(ctx.service) + return templates.TemplateResponse( + request, + "_partials/queue_action_result.html", + { + "action_label": "Approved", + "item_desc": draft.title, + "remaining_count": remaining, + }, + ) + + +async def draft_card(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id: str = request.path_params["req_id"] + rec, draft = _draft_ctx(ctx.service, req_id) + item = views.DraftItem( + kind="draft", + req_id=req_id, + display_id=rec.id, + title=draft.title, + statement=draft.statement, + current_version=rec.current_version, + ) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/queue_item_draft.html", + {"item": item}, + ) + + +def _link_item(service: PlainweaveService, link_id: str) -> views.LinkItem: + for link in service.trace_for(state="proposed"): + if link.id == link_id: + return views.LinkItem( + "link", + link.id, + link.from_ref.id, + link.relation, + link.to_ref.id, + link.created_by, + link.confidence, + link.freshness != "current", + ) + raise PlainweaveError( + ErrorCode.NOT_FOUND, + f"proposed link {link_id!r} not found", + recoverable=False, + hint="It may have already been accepted or rejected.", + ) + + +async def reject_form(request: Request) -> Response: + link_id: str = request.path_params["link_id"] + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/link_reject_form.html", + {"link_id": link_id, "submitted_reason": "", "error": None}, + ) + + +async def reject_post(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + link_id: str = request.path_params["link_id"] + form = await request.form() + reason = str(form.get("reason", "")).strip() + templates: Jinja2Templates = request.app.state.templates + if not reason: + return templates.TemplateResponse( + request, + "_partials/link_reject_form.html", + { + "link_id": link_id, + "submitted_reason": "", + "error": "Reason is required — explain why this link should be rejected.", + }, + status_code=200, + ) + item = _link_item(ctx.service, link_id) + ctx.service.reject_trace_link(link_id, actor=ctx.operator.actor_id, reason=reason) + remaining = _pending_count(ctx.service) + return templates.TemplateResponse( + request, + "_partials/queue_action_result.html", + { + "action_label": "Rejected", + "item_desc": f"{item.from_label} {item.relation} {item.to_label}", + "remaining_count": remaining, + }, + ) + + +async def accept_post(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + link_id: str = request.path_params["link_id"] + item = _link_item(ctx.service, link_id) + ctx.service.accept_trace_link(link_id, actor=ctx.operator.actor_id) + remaining = _pending_count(ctx.service) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/queue_action_result.html", + { + "action_label": "Accepted", + "item_desc": f"{item.from_label} {item.relation} {item.to_label}", + "remaining_count": remaining, + }, + ) + + +async def link_card(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + item = _link_item(ctx.service, request.path_params["link_id"]) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/queue_item_link.html", + {"item": item}, + ) + + +async def accept_drifted_confirm(request: Request) -> Response: + link_id: str = request.path_params["link_id"] + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/link_accept_drifted_confirm.html", + {"link_id": link_id}, + ) + + +def register(app: Starlette) -> None: + app.router.routes.append(Route("/review", review, name="review")) + app.router.routes.append(Route("/req/{req_id}/approve-confirm", approve_confirm)) + app.router.routes.append(Route("/req/{req_id}/approve", approve_post, methods=["POST"])) + app.router.routes.append(Route("/req/{req_id}/draft-card", draft_card)) + app.router.routes.append(Route("/trace/{link_id}/accept", accept_post, methods=["POST"])) + app.router.routes.append(Route("/trace/{link_id}/reject-form", reject_form)) + app.router.routes.append(Route("/trace/{link_id}/reject", reject_post, methods=["POST"])) + app.router.routes.append(Route("/trace/{link_id}/card", link_card)) + app.router.routes.append(Route("/trace/{link_id}/accept-drifted-confirm", accept_drifted_confirm)) diff --git a/src/plainweave/web/server.py b/src/plainweave/web/server.py new file mode 100644 index 0000000..7d2de6b --- /dev/null +++ b/src/plainweave/web/server.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +WEB_EXTRA_HINT = ( + "The web UI needs the optional 'web' extra. Install it with:\n" + " pip install plainweave[web]\n" + "(or: uv pip install 'plainweave[web]')" +) + + +def add_web_subcommand(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + parser = subparsers.add_parser("web", help="Run the operator-facing web UI (needs plainweave[web]).") + parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1).") + parser.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765).") + parser.add_argument("--actor", default=None, help="Operator actor id (default: from config / first-run).") + parser.add_argument( + "--no-open", + dest="open_browser", + action="store_false", + help="Do not open a browser on start.", + ) + parser.set_defaults(open_browser=True, handler=_handle) + + +def _handle(args: argparse.Namespace) -> int: + return run_web(host=args.host, port=args.port, actor=args.actor, open_browser=args.open_browser) + + +def run_web(*, host: str, port: int, actor: str | None, open_browser: bool, root: Path | None = None) -> int: + try: + return _serve(host=host, port=port, actor=actor, open_browser=open_browser, root=root) + except ModuleNotFoundError: + print(WEB_EXTRA_HINT) + return 1 + + +def _serve( # pragma: no cover + *, host: str, port: int, actor: str | None, open_browser: bool, root: Path | None = None +) -> int: + # Lazy import: only touches starlette/uvicorn when the extra is installed. + import uvicorn # noqa: PLC0415, I001 + + from plainweave.web.app import create_app # noqa: PLC0415, I001 + + app = create_app(actor=actor, root=root) + if open_browser: + _open_browser_later(host, port) + uvicorn.run(app, host=host, port=port, log_level="info") + return 0 + + +def _open_browser_later(host: str, port: int) -> None: # pragma: no cover + import threading + import webbrowser + + url = f"http://{host}:{port}/" + threading.Timer(0.8, lambda: webbrowser.open(url)).start() diff --git a/src/plainweave/web/static/.gitkeep b/src/plainweave/web/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/plainweave/web/static/app.css b/src/plainweave/web/static/app.css new file mode 100644 index 0000000..da03f0a --- /dev/null +++ b/src/plainweave/web/static/app.css @@ -0,0 +1,19 @@ +:root { --amber: #c47b1a; --warn-bg: #fdf3e3; --line: #d9d9d9; } +body { font-family: system-ui, sans-serif; margin: 0; color: #1c1c1c; font-size: 16px; } +.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.skip-link { position: absolute; left: -999px; } +.skip-link:focus { left: 1rem; top: 0.5rem; background: #fff; padding: 0.5rem; } +.topnav { display: flex; gap: 1rem; align-items: center; padding: 0.6rem 1rem; border-bottom: 1px solid var(--line); } +.topnav a[aria-current="page"] { font-weight: 700; text-decoration: underline; } +.nav-badge:not(:empty) { background: #b00; color: #fff; border-radius: 8px; padding: 0 6px; font-size: 0.75rem; } +.operator { margin-left: auto; opacity: 0.7; font-size: 0.85rem; } +main { padding: 1rem; } +table { border-collapse: collapse; width: 100%; font-size: 14px; } +th, td { text-align: left; padding: 6px 8px; border-top: 1px solid var(--line); } +.htmx-indicator { opacity: 0; transition: opacity 0.1s; } +.htmx-request .htmx-indicator, .htmx-indicator.htmx-request { opacity: 1; } +.queue-item--drifted { border: 1px solid var(--amber); border-left-width: 4px; background: var(--warn-bg); padding: 0.6rem; } +.drift-badge { display: inline-block; background: var(--amber); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 1px 7px; border-radius: 4px; } +.toggle-btn { border: 1px solid var(--line); border-radius: 4px; padding: 3px 9px; font-size: 0.8rem; } +.toggle-btn--active { border-width: 2px; font-weight: 700; } +.toggle-btn--active::before { content: "✓ "; } diff --git a/src/plainweave/web/static/htmx.min.js b/src/plainweave/web/static/htmx.min.js new file mode 100644 index 0000000..de5f0f1 --- /dev/null +++ b/src/plainweave/web/static/htmx.min.js @@ -0,0 +1 @@ +(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s(""+n+"
",1);case"col":return s(""+n+"
",2);case"tr":return s(""+n+"
",2);case"td":case"th":return s(""+n+"
",3);case"script":case"style":return s("
"+n+"
",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;if(!t){return false}for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var B=p.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}p=(B[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); \ No newline at end of file diff --git a/src/plainweave/web/templates/.gitkeep b/src/plainweave/web/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/plainweave/web/templates/_partials/corpus_filter.html b/src/plainweave/web/templates/_partials/corpus_filter.html new file mode 100644 index 0000000..bbdd4c9 --- /dev/null +++ b/src/plainweave/web/templates/_partials/corpus_filter.html @@ -0,0 +1,27 @@ + +
+ + + +
+ Status + {% for value, label in [("", "All"), ("approved", "Approved"), ("draft", "Draft")] %} + + {% endfor %} +
+ +
+ Orphans + {% for value, label in [("", "Any"), ("no-goal", "No goal"), ("no-code", "No code"), ("both", "Both")] %} + + {% endfor %} +
+
+
diff --git a/src/plainweave/web/templates/_partials/corpus_rows.html b/src/plainweave/web/templates/_partials/corpus_rows.html new file mode 100644 index 0000000..22e69d2 --- /dev/null +++ b/src/plainweave/web/templates/_partials/corpus_rows.html @@ -0,0 +1,11 @@ +{% for row in rows %} + + {{ row.title }} {{ row.display_id }} + {{ row.status }} + {% if row.goal_count %}{{ row.goal_count }}{% else %}none{% endif %} + {% if row.code_count %}{{ row.code_count }}{% else %}none{% endif %} + +
+{% else %} +No requirements match the current filters. +{% endfor %} diff --git a/src/plainweave/web/templates/_partials/csrf.html b/src/plainweave/web/templates/_partials/csrf.html new file mode 100644 index 0000000..83b81a1 --- /dev/null +++ b/src/plainweave/web/templates/_partials/csrf.html @@ -0,0 +1 @@ + diff --git a/src/plainweave/web/templates/_partials/draft_approve_confirm.html b/src/plainweave/web/templates/_partials/draft_approve_confirm.html new file mode 100644 index 0000000..cbd5609 --- /dev/null +++ b/src/plainweave/web/templates/_partials/draft_approve_confirm.html @@ -0,0 +1,10 @@ +
+ {% if error %}{% endif %} +

Approve {{ title }} as version {{ next_version }}? This cannot be undone — there is no un-approve.

+
+ {% include "_partials/csrf.html" %} + + + +
+
diff --git a/src/plainweave/web/templates/_partials/edit_conflict.html b/src/plainweave/web/templates/_partials/edit_conflict.html new file mode 100644 index 0000000..fcb8774 --- /dev/null +++ b/src/plainweave/web/templates/_partials/edit_conflict.html @@ -0,0 +1,15 @@ +
+ +
+
+ {% include "_partials/csrf.html" %} + +

Your edits (not saved)

+ + + +
+

Current draft

{{ current_title }}

{{ current_statement }}

+ Discard mine — start from current
+
+
diff --git a/src/plainweave/web/templates/_partials/error.html b/src/plainweave/web/templates/_partials/error.html new file mode 100644 index 0000000..cb359ca --- /dev/null +++ b/src/plainweave/web/templates/_partials/error.html @@ -0,0 +1,7 @@ +
+

Something went wrong

+

{{ code }}

+

{{ message }}

+ {% if hint %}

{{ hint }}

{% endif %} +

Back to corpus

+
diff --git a/src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html b/src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html new file mode 100644 index 0000000..bec8ea0 --- /dev/null +++ b/src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html @@ -0,0 +1,10 @@ + diff --git a/src/plainweave/web/templates/_partials/link_reject_form.html b/src/plainweave/web/templates/_partials/link_reject_form.html new file mode 100644 index 0000000..ed98b85 --- /dev/null +++ b/src/plainweave/web/templates/_partials/link_reject_form.html @@ -0,0 +1,10 @@ + diff --git a/src/plainweave/web/templates/_partials/queue_action_result.html b/src/plainweave/web/templates/_partials/queue_action_result.html new file mode 100644 index 0000000..e344036 --- /dev/null +++ b/src/plainweave/web/templates/_partials/queue_action_result.html @@ -0,0 +1,10 @@ +{# Primary target (the acted card) is replaced by NOTHING via outerHTML → card removed. #} +
+ {{ action_label }}: {{ item_desc }}. + {% if remaining_count > 0 %}{{ remaining_count }} item{{ 's' if remaining_count != 1 }} remaining in queue. + {% else %}Queue is now empty.{% endif %} +
+{% if remaining_count > 0 %}{{ remaining_count }}{% endif %} +{% if remaining_count == 0 %} +
{% include "_partials/queue_empty.html" %}
+{% endif %} diff --git a/src/plainweave/web/templates/_partials/queue_empty.html b/src/plainweave/web/templates/_partials/queue_empty.html new file mode 100644 index 0000000..e77764a --- /dev/null +++ b/src/plainweave/web/templates/_partials/queue_empty.html @@ -0,0 +1,4 @@ +
+

All caught up

+

No pending drafts or trace links to review.

+
diff --git a/src/plainweave/web/templates/_partials/queue_item_draft.html b/src/plainweave/web/templates/_partials/queue_item_draft.html new file mode 100644 index 0000000..37d43c3 --- /dev/null +++ b/src/plainweave/web/templates/_partials/queue_item_draft.html @@ -0,0 +1,11 @@ +
+
DRAFT +

{{ item.title }} {{ item.display_id }}

+

{{ item.statement | truncate(200) }}

+
+ View full draft → + +
+
diff --git a/src/plainweave/web/templates/_partials/queue_item_link.html b/src/plainweave/web/templates/_partials/queue_item_link.html new file mode 100644 index 0000000..9f07e49 --- /dev/null +++ b/src/plainweave/web/templates/_partials/queue_item_link.html @@ -0,0 +1,22 @@ + diff --git a/src/plainweave/web/templates/_partials/req_inline.html b/src/plainweave/web/templates/_partials/req_inline.html new file mode 100644 index 0000000..65c4605 --- /dev/null +++ b/src/plainweave/web/templates/_partials/req_inline.html @@ -0,0 +1,7 @@ +
+

{{ statement }}

+
+ Full dossier → + +
+
diff --git a/src/plainweave/web/templates/base.html b/src/plainweave/web/templates/base.html new file mode 100644 index 0000000..8277c71 --- /dev/null +++ b/src/plainweave/web/templates/base.html @@ -0,0 +1,33 @@ + + + + + + {% block title %}Plainweave{% endblock %} + + + + + + + + + {# Permanent SR status live region — NEVER replaced via outerHTML; innerHTML-OOB only. #} +
+ {# Decorative global loader; status comes from #sr-status, so this is aria-hidden. #} + + +
+ {% block main %}{% endblock %} +
+ + diff --git a/src/plainweave/web/templates/corpus.html b/src/plainweave/web/templates/corpus.html new file mode 100644 index 0000000..c2619bc --- /dev/null +++ b/src/plainweave/web/templates/corpus.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% set active_page = "corpus" %} +{% block title %}Corpus · Plainweave{% endblock %} +{% block main %} +

Corpus

+{% include "_partials/corpus_filter.html" %} + + + + + + {% include "_partials/corpus_rows.html" %} + +
RequirementStatusGoalCode links
+{% endblock %} diff --git a/src/plainweave/web/templates/goals.html b/src/plainweave/web/templates/goals.html new file mode 100644 index 0000000..2fcb2f3 --- /dev/null +++ b/src/plainweave/web/templates/goals.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% set active_page = "goals" %} +{% block title %}Goals · Plainweave{% endblock %} +{% block main %} +

Goals

+
+ {% include "_partials/csrf.html" %} + + + +
+
    +{% for g in goals %} +
  • {{ g.title }} {{ g.id }}{% if g.goal_id in orphan_goal_ids %} — no requirements ladder here{% endif %}
  • +{% else %} +
  • No goals yet.
  • +{% endfor %} +
+{% endblock %} diff --git a/src/plainweave/web/templates/intent.html b/src/plainweave/web/templates/intent.html new file mode 100644 index 0000000..402c0ea --- /dev/null +++ b/src/plainweave/web/templates/intent.html @@ -0,0 +1,17 @@ +{# src/plainweave/web/templates/intent.html #} +{% extends "base.html" %} +{% set active_page = "intent" %} +{% block title %}Intent · Plainweave{% endblock %} +{% block main %} +

Intent coverage

+{% if banner %}{% endif %} +

+ {% if cov.ratio is not none %}{{ "%.0f%%"|format(cov.ratio * 100) }}{% else %}—{% endif %} + {{ cov.numerator }}/{{ cov.denominator }} public surfaces answer "why does this exist?" +

+{% for level, nodes in orphans.items() %} +

Orphans — {{ level }} ({{ nodes|length }})

+
    {% for n in nodes %}
  • {{ n.node_id }}
  • {% endfor %}
+
+{% endfor %} +{% endblock %} diff --git a/src/plainweave/web/templates/requirement_detail.html b/src/plainweave/web/templates/requirement_detail.html new file mode 100644 index 0000000..746ff54 --- /dev/null +++ b/src/plainweave/web/templates/requirement_detail.html @@ -0,0 +1,39 @@ +{# src/plainweave/web/templates/requirement_detail.html #} +{% extends "base.html" %} +{% set active_page = "corpus" %} +{% set section = dossier.requirement %} +{% block title %}{{ section.record.id }} · Plainweave{% endblock %} +{% block main %} +

{% if section.current_version %}{{ section.current_version.title }}{% elif section.active_draft %}{{ section.active_draft.title }}{% endif %} + {{ section.record.id }} · {{ section.record.status }}

+ +{% if section.current_version %} +

Current approved — v{{ section.current_version.version }}

+

{{ section.current_version.statement }}

+{% endif %} +{% if section.active_draft %} +

Draft{% if section.current_version %} (proposed changes){% else %} (new — no approved version yet){% endif %}

+

{{ section.active_draft.statement }}

+

Edit draft

+
+ +
+
+{% endif %} +{% if goals %} +
+

Ladder to a goal

+
+ {% include "_partials/csrf.html" %} + + +
+
+{% endif %} +{% endblock %} diff --git a/src/plainweave/web/templates/requirement_form.html b/src/plainweave/web/templates/requirement_form.html new file mode 100644 index 0000000..63eb23e --- /dev/null +++ b/src/plainweave/web/templates/requirement_form.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% set active_page = "corpus" %} +{% block main %} +

{% if req_id %}Edit draft{% else %}New requirement{% endif %}

+
+ {% include "_partials/csrf.html" %} + {% if expected_draft_revision is not none %}{% endif %} + + + +
+{% endblock %} diff --git a/src/plainweave/web/templates/review.html b/src/plainweave/web/templates/review.html new file mode 100644 index 0000000..8a78b0c --- /dev/null +++ b/src/plainweave/web/templates/review.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% set active_page = "review" %} +{% block title %}Review · Plainweave{% endblock %} +{% block main %} +

Review queue

+
+ {% if items %} + {% for item in items %} + {% if item.kind == 'draft' %}{% include "_partials/queue_item_draft.html" %} + {% else %}{% include "_partials/queue_item_link.html" %}{% endif %} + {% endfor %} + {% else %}{% include "_partials/queue_empty.html" %}{% endif %} +
+ +{% endblock %} diff --git a/src/plainweave/web/views.py b/src/plainweave/web/views.py new file mode 100644 index 0000000..7b1b721 --- /dev/null +++ b/src/plainweave/web/views.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from plainweave.intent_graph import CorpusEntry +from plainweave.models import RequirementRecord + +if TYPE_CHECKING: + from plainweave.service import PlainweaveService + + +@dataclass(frozen=True) +class CorpusRow: + req_id: str + display_id: str + title: str + status: str + goal_count: int + code_count: int + + +def build_corpus_rows( + corpus: list[CorpusEntry], + records: list[RequirementRecord], + titles: dict[str, str], +) -> list[CorpusRow]: + """Build corpus rows from pre-fetched corpus entries, records, and resolved titles. + + ``titles`` maps requirement_id -> resolved title (caller resolves draft vs version title + before calling here, keeping this function pure and unit-testable). + """ + by_id = {r.requirement_id: r for r in records} + rows: list[CorpusRow] = [] + for entry in corpus: + rid = entry.requirement.node_id + rec = by_id.get(rid) + if rec is None: + continue + title = titles.get(rid, rec.id) + rows.append( + CorpusRow( + req_id=rid, + display_id=rec.id, + title=title, + status=rec.status, + goal_count=len(entry.goals), + code_count=len(entry.code), + ) + ) + return rows + + +def coverage_banner(cov: object) -> str | None: + if getattr(cov, "denominator_complete", True) and not getattr(cov, "adapter_degraded", ()): + return None + return "Coverage denominator is incomplete — the Loomweave catalog is absent or stale. This number is partial." + + +@dataclass(frozen=True) +class DraftItem: + kind: str # "draft" + req_id: str + display_id: str + title: str + statement: str + current_version: int + + +@dataclass(frozen=True) +class LinkItem: + kind: str # "link" + link_id: str + from_label: str + relation: str + to_label: str + proposing_actor: str + confidence: float | None + drifted: bool + + +def pending_items(service: PlainweaveService) -> list[DraftItem | LinkItem]: + """Return unified review queue: pending drafts + proposed trace links.""" + items: list[DraftItem | LinkItem] = [] + for rec in service.search_requirements(): + if rec.active_draft_id is None: + continue + d = service.requirement_dossier(rec.requirement_id).requirement.active_draft + if d is None: + continue + items.append( + DraftItem( + kind="draft", + req_id=rec.requirement_id, + display_id=rec.id, + title=d.title, + statement=d.statement, + current_version=rec.current_version, + ) + ) + for link in service.trace_for(state="proposed"): + items.append( + LinkItem( + kind="link", + link_id=link.id, + from_label=link.from_ref.id, + relation=link.relation, + to_label=link.to_ref.id, + proposing_actor=link.created_by, + confidence=link.confidence, + drifted=link.freshness != "current", + ) + ) + return items + + +def filter_rows(rows: list[CorpusRow], *, q: str, status: str, orphan: str) -> list[CorpusRow]: + out = rows + if q: + needle = q.lower() + out = [r for r in out if needle in r.title.lower() or needle in r.display_id.lower()] + if status: + out = [r for r in out if r.status == status] + if orphan == "no-goal": + out = [r for r in out if r.goal_count == 0] + elif orphan == "no-code": + out = [r for r in out if r.code_count == 0] + elif orphan == "both": + out = [r for r in out if r.goal_count == 0 and r.code_count == 0] + return out diff --git a/tests/conformance/fixtures/PROVENANCE.md b/tests/conformance/fixtures/PROVENANCE.md new file mode 100644 index 0000000..a4880fa --- /dev/null +++ b/tests/conformance/fixtures/PROVENANCE.md @@ -0,0 +1,40 @@ +# Vendored SEI conformance oracle — provenance + +`sei-conformance-oracle.json` in this directory is a **byte-verbatim** copy of +Loomweave's authoritative fixture: + + /home/john/loomweave/docs/federation/fixtures/sei-conformance-oracle.json + (repo path: docs/federation/fixtures/sei-conformance-oracle.json) + +Loomweave is the **producer / authority** for the six-scenario Weft SEI §8 +conformance oracle (cargo gate `sei_conformance_oracle`). Plainweave is a +**consumer** and vendors the fixture so its conformance suite runs offline, +without a live Loomweave. + +## Invariants + +- **Never hand-edit** the vendored copy. Loomweave's oracle is the only author. +- The Layer-1 byte-pin (`UPSTREAM_BLOB_SHA` in + `tests/conformance/test_sei_oracle.py`) reds the default suite on any byte + change, so a tamper or an accidental edit is caught immediately. +- The Layer-2 drift recheck (`pytest -m sei_drift`) byte-compares this copy + against the upstream sibling checkout (`LOOMWEAVE_REPO`, default + `/home/john/loomweave`) — the release-gate drift alarm. + +## Re-vendor procedure + +1. Copy `$LOOMWEAVE_REPO/docs/federation/fixtures/sei-conformance-oracle.json` + byte-verbatim over this file (`cmp` to confirm). +2. Recompute the git blob SHA and update `UPSTREAM_BLOB_SHA` in + `tests/conformance/test_sei_oracle.py` **in the same commit**: + + python -c "import hashlib,sys; d=open(sys.argv[1],'rb').read(); \ + print(hashlib.sha1(b'blob %d\0'%len(d)+d).hexdigest())" \ + tests/conformance/fixtures/sei-conformance-oracle.json + +3. Re-run conformance and conform the consumer + (`src/plainweave/loomweave_adapter.py`) until green; never weaken the + assertions. + +Current vendored blob SHA: `0ea577025d94c028a0f682b7d29765079455718c` +(fixture_version 1, upstream `updated: 2026-06-02`). diff --git a/tests/conformance/fixtures/sei-conformance-oracle.json b/tests/conformance/fixtures/sei-conformance-oracle.json new file mode 100644 index 0000000..0ea5770 --- /dev/null +++ b/tests/conformance/fixtures/sei-conformance-oracle.json @@ -0,0 +1,85 @@ +{ + "_meta": { + "contract": "weft-sei-conformance-oracle", + "standard": "Weft Stable Entity Identity (SEI) conformance standard §8", + "authority": "Loomweave ADR-038 (token/signature/persistence/reserved-namespace); SEI standard (suite-wide)", + "fixture_version": 1, + "stability": "normative", + "token_format_agnostic": true, + "verification": "cargo test -p loomweave-storage --test sei_conformance_oracle", + "updated": "2026-06-02", + "description": "The six shared SEI conformance scenarios every Weft tool runs against a reference Loomweave. Asserts BEHAVIOUR and OPACITY, never the SEI's internal form. A subsystem is SEI-conformant only when it passes all six (no grandfathering)." + }, + "invariants": [ + "SEI is opaque: a consumer never parses it. It carries the reserved `loomweave:eid:` prefix and is NOT a locator.", + "Fail-closed: when sameness cannot be PROVEN, mint a new SEI and orphan the old one — never silently re-point.", + "Lineage is append-only: born / locator_changed / moved / orphaned / superseded.", + "Identity is carried (never re-minted) for an unchanged locator; SEI values are not part of the byte-identical-run guarantee, but carry/mint decisions are deterministic given the same bindings + source." + ], + "scenarios": [ + { + "id": "identity_round_trip_and_opacity", + "given": "A function entity is analyzed for the first time.", + "when": "Mint an SEI; resolve(locator) → sei; resolve_sei(sei) → locator.", + "expect": { + "resolve_locator": { "sei": "", "current_locator": "", "content_hash": "", "alive": true }, + "resolve_sei": { "current_locator": "", "alive": true }, + "opacity": "the returned `sei` begins with `loomweave:eid:` and is treated as an opaque string by the consumer (never parsed); it is not equal to the locator" + } + }, + { + "id": "rename", + "given": "An entity with an alive SEI; its file/module is renamed so the locator prefix changes; the body is byte-identical; a git-rename signal maps old_locator → new_locator.", + "when": "Re-index.", + "expect": { + "carry": true, + "sei": "unchanged (same token as before)", + "current_locator": "the new locator", + "lineage_appends": "locator_changed", + "resolve_locator(old)": { "alive": false }, + "resolve_locator(new)": { "alive": true, "sei": "" } + } + }, + { + "id": "move", + "given": "An entity with an alive SEI is moved to a new module; body hash AND signature are identical at the new locator; exactly one vanished candidate matches; no git signal required.", + "when": "Re-index.", + "expect": { + "carry": true, + "sei": "unchanged", + "lineage_appends": "moved" + } + }, + { + "id": "ambiguous", + "given": "An entity is renamed WITH a body edit (the body hash changes), even if a git-rename signal is present.", + "when": "Re-index.", + "expect": { + "carry": false, + "new_entity": "minted a fresh SEI (born)", + "old_binding": "orphaned (resolve_sei → alive:false with an `orphaned` lineage event)", + "rationale": "the matcher cannot PROVE sameness → fail closed; a governance attestation on the old SEI is never silently carried across an unproven match" + } + }, + { + "id": "delete", + "given": "An entity present in a prior run is absent from the current run and was not rematched by a rename/move.", + "when": "Re-index.", + "expect": { + "old_binding": "orphaned", + "resolve_locator(old)": { "alive": false }, + "resolve_sei(old_sei)": { "alive": false, "lineage": "includes an `orphaned` event" } + } + }, + { + "id": "capability_absent", + "given": "A Loomweave instance that has not populated SEI (pre-SEI DB, or `_capabilities.sei.supported` false / absent).", + "when": "A consumer probes `_capabilities` and/or resolves.", + "expect": { + "consumer": "detects the absent capability and DEGRADES gracefully — keeps working on locators, no crash, honest 'identity unavailable'", + "resolve_locator(any)": { "alive": false }, + "resolve_sei(unknown)": { "alive": false, "lineage": [] } + } + } + ] +} diff --git a/tests/conformance/test_sei_oracle.py b/tests/conformance/test_sei_oracle.py new file mode 100644 index 0000000..6676f4a --- /dev/null +++ b/tests/conformance/test_sei_oracle.py @@ -0,0 +1,366 @@ +"""Weft SEI §8 conformance oracle — Plainweave as consumer. + +Plainweave consumes Loomweave's Stable Entity Identity (SEI) via +:class:`plainweave.loomweave_adapter.LoomweaveAdapter`. This module proves +Plainweave is a §8 SEI CONFORMER: each of the six shared scenarios is driven +through the REAL adapter HTTP resolve path (``resolve_identity`` → +``_resolve_identity_http``) with a fake ``_http_json`` injected at the same seam +the existing ``test_identity_resolution_over_http_*`` tests use. The assertions +check the consumer's own verdict — alive / orphaned / unsupported / opacity — +NOT a re-implementation of the oracle (NON-CIRCULAR). + +The scenario list is loaded from the vendored ``sei-conformance-oracle.json`` +fixture, copied BYTE-VERBATIM from Loomweave's authoritative fixture. Loomweave +is the PRODUCER/authority for the six-scenario §8 oracle; Plainweave is the +CONSUMER. Two layers protect the vendored bytes: + + * Layer 1 (default suite): ``UPSTREAM_BLOB_SHA`` git-blob byte-pin — any edit + to the vendored copy reds the default PR suite. + * Layer 2 (opt-in, ``-m sei_drift``): byte-compare against the sibling + Loomweave checkout — the release-gate drift alarm; skips clean when the + sibling is absent. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from collections.abc import Callable +from pathlib import Path +from typing import Any, cast + +import pytest +from tests.loomweave_test_utils import seed_loomweave_catalog + +from plainweave.loomweave_adapter import LoomweaveAdapter, LoomweaveIdentityError + +ORACLE_PATH = Path(__file__).parent / "fixtures" / "sei-conformance-oracle.json" + +# The git blob hash of the vendored SEI conformance oracle as authored upstream by +# Loomweave (docs/federation/fixtures/sei-conformance-oracle.json). Loomweave is the +# PRODUCER/authority for the six-scenario §8 oracle; Plainweave is the CONSUMER and +# VENDORS the fixture byte-verbatim. This Layer-1 byte-pin runs in the DEFAULT suite, +# so ANY byte change to the vendored copy fails loudly — re-vendors are deliberate and +# update this constant in the SAME commit as the new bytes. +# +# RE-VENDOR PROCEDURE (run ``pytest -m sei_drift -v`` before every release; on drift, or +# on a deliberate upstream oracle bump): +# 1. Copy ``$LOOMWEAVE_REPO/docs/federation/fixtures/sei-conformance-oracle.json`` +# byte-verbatim over the vendored copy (``cmp`` to confirm). NEVER hand-edit the +# vendored fixture; Loomweave's oracle (cargo gate ``sei_conformance_oracle``) is the +# only author. +# 2. Update ``UPSTREAM_BLOB_SHA`` to ``git hash-object`` of the vendored file +# (equivalently ``hashlib.sha1(b"blob %d\0" % len(data) + data)``) — same commit. +# 3. Re-run conformance and CONFORM the consumer (``plainweave.loomweave_adapter``) +# until green; never weaken the assertions. +UPSTREAM_BLOB_SHA = "0ea577025d94c028a0f682b7d29765079455718c" + +CAPABILITIES_PATH = "/api/v1/_capabilities" +SEI_SUPPORTED_CAPS: dict[str, Any] = {"sei": {"supported": True, "version": 1}} + + +def _load_oracle() -> dict[str, Any]: + return cast("dict[str, Any]", json.loads(ORACLE_PATH.read_text(encoding="utf-8"))) + + +def _scenario(scenario_id: str) -> dict[str, Any]: + for item in _load_oracle()["scenarios"]: + if item["id"] == scenario_id: + return cast("dict[str, Any]", item) + raise AssertionError(f"missing SEI oracle scenario {scenario_id!r}") + + +def _loomweave_oracle_source() -> Path | None: + # Env takes EXCLUSIVE precedence: if ``LOOMWEAVE_REPO`` is set, resolve the sibling + # ONLY from it and skip clean if the oracle is absent under it. Otherwise fall back + # to the documented local-dev convenience checkout at ``/home/john/loomweave``. A + # CI runner (env unset, no convenience sibling) skips clean — the documented basis + # for the clean skip is the sibling's ABSENCE, not a guarantee independent of layout. + subpath = ("docs", "federation", "fixtures", "sei-conformance-oracle.json") + env = os.environ.get("LOOMWEAVE_REPO") + if env: + path = Path(env).joinpath(*subpath) + return path if path.exists() else None + path = Path("/home/john/loomweave").joinpath(*subpath) + return path if path.exists() else None + + +COVERED_SCENARIOS = { + "identity_round_trip_and_opacity", + "rename", + "move", + "ambiguous", + "delete", + "capability_absent", +} + + +def _fake_http_json( + *, + caps: dict[str, Any] | None, + identity: dict[str, Any] | None, + calls: list[str], +) -> Callable[..., dict[str, object]]: + """Build a fake ``_http_json`` that records every wire path and routes the + ``_capabilities`` probe to ``caps`` and any identity resolve to ``identity``.""" + + def fake(method: str, path: str, payload: object | None = None) -> dict[str, object]: + calls.append(path) + if path == CAPABILITIES_PATH: + if caps is None: + # Model a remote that 2xx-returns a body with no SEI capability. + return {} + return cast("dict[str, object]", caps) + assert identity is not None, f"unexpected identity wire call: {path}" + return cast("dict[str, object]", identity) + + return fake + + +# --------------------------------------------------------------------------- # +# Fixture integrity (Layer 1 + Layer 2) # +# --------------------------------------------------------------------------- # + + +def test_vendored_oracle_matches_upstream_blob_pin() -> None: + """Layer 1 (default suite): the vendored SEI oracle byte-pins to the upstream + git blob hash. ANY edit to the vendored fixture without a matching re-pin reds + the default suite — the fail-closed protection that lets the Layer-2 drift + recheck skip clean when the sibling checkout is absent.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = ORACLE_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored SEI oracle changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, update UPSTREAM_BLOB_SHA in the same commit and " + "re-run conformance; if not, someone edited the vendored copy (forbidden — Loomweave's " + "oracle is the only author; see the RE-VENDOR PROCEDURE at the top of this module)" + ) + + +@pytest.mark.sei_drift +def test_vendored_oracle_matches_loomweave_source() -> None: + """Layer 2 (opt-in, ``-m sei_drift``): the sibling Loomweave checkout's + authoritative oracle must be BYTE-IDENTICAL to the vendored copy — the + release-gate drift alarm. Absent checkout (CI/default suite) skips clean; + divergence FAILS. + + FAIL-CLOSED ARMING: a release gate sets ``PLAINWEAVE_SEI_DRIFT_REQUIRED`` to + turn the skip into a HARD FAILURE when the sibling oracle is missing — so an + armed drift gate cannot silently no-op (e.g. a runner that forgot to provide + ``LOOMWEAVE_REPO``). Unset (the default) keeps the skip-clean behaviour. + + Byte-exact (not JSON-semantic) by design: the RE-VENDOR PROCEDURE mandates a + byte-verbatim copy and the Layer-1 ``UPSTREAM_BLOB_SHA`` pins the git blob, so + a copy that is reordered/reformatted (JSON-equal but byte-different) would leave + the blob-pin silently stale yet pass a parsed-dict compare. Comparing raw bytes + enforces the same byte-verbatim invariant Layer-1 assumes.""" + source = _loomweave_oracle_source() + if source is None: + if os.environ.get("PLAINWEAVE_SEI_DRIFT_REQUIRED"): + pytest.fail( + "SEI drift check ARMED (PLAINWEAVE_SEI_DRIFT_REQUIRED) but no Loomweave sibling " + "oracle was found — point LOOMWEAVE_REPO at a checkout that carries " + "docs/federation/fixtures/sei-conformance-oracle.json so drift can be proven" + ) + pytest.skip("Loomweave repo not found; set LOOMWEAVE_REPO to enable the drift check") + if ORACLE_PATH.read_bytes() != source.read_bytes(): + pytest.fail( + f"upstream {source} has drifted from the vendored " + "tests/conformance/fixtures/sei-conformance-oracle.json — re-vendor + conform: follow " + "the RE-VENDOR PROCEDURE at the top of this module (byte-verbatim copy, bump " + "UPSTREAM_BLOB_SHA in the same commit, re-run conformance)" + ) + + +def test_every_oracle_scenario_is_covered() -> None: + fixture_ids = {item["id"] for item in _load_oracle()["scenarios"]} + assert fixture_ids == COVERED_SCENARIOS + + +# --------------------------------------------------------------------------- # +# The six §8 scenarios, driven through the REAL adapter HTTP resolve path # +# --------------------------------------------------------------------------- # + + +def test_identity_round_trip_and_opacity(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """round_trip: resolve(locator) → sei; resolve(sei) → locator; the SEI is opaque + (carries the reserved prefix, is never equal to the locator, never parsed).""" + scenario = _scenario("identity_round_trip_and_opacity") + seed = seed_loomweave_catalog(tmp_path) + locator = seed["public_locator"] + sei = seed["public_sei"] + monkeypatch.setenv("WEFT_LOOMWEAVE_URL", "http://loomweave.test") + adapter = LoomweaveAdapter(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( + adapter, + "_http_json", + _fake_http_json( + caps=SEI_SUPPORTED_CAPS, + identity={"alive": True, "current_locator": locator, "sei": sei, "content_hash": "hash-public-v1"}, + calls=calls, + ), + ) + + by_locator = adapter.resolve_identity(locator) + by_sei = adapter.resolve_identity(sei) + + assert scenario["expect"]["resolve_locator"]["alive"] is True + # resolve(locator) → sei + assert by_locator.locator == locator + assert by_locator.sei == sei + assert by_locator.lineage_status == "alive" + # resolve(sei) → locator + assert by_sei.locator == locator + assert by_sei.sei == sei + # opacity: reserved prefix, not equal to the locator (the consumer never parses it). + assert by_locator.sei is not None and by_locator.sei.startswith("loomweave:eid:") + assert by_locator.sei != locator + assert CAPABILITIES_PATH in calls + + +@pytest.mark.parametrize("scenario_id", ["rename", "move"]) +def test_carried_sei_remains_alive_for_rename_and_move( + scenario_id: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """rename / move: identity is CARRIED — the same SEI resolves alive (at the new + locator), so the consumer's verdict stays ALIVE across the re-index.""" + scenario = _scenario(scenario_id) + seed = seed_loomweave_catalog(tmp_path) + new_locator = seed["public_locator"] + carried_sei = seed["public_sei"] + monkeypatch.setenv("WEFT_LOOMWEAVE_URL", "http://loomweave.test") + adapter = LoomweaveAdapter(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( + adapter, + "_http_json", + _fake_http_json( + caps=SEI_SUPPORTED_CAPS, + identity={ + "alive": True, + "current_locator": new_locator, + "sei": carried_sei, + "content_hash": "hash-public-v1", + }, + calls=calls, + ), + ) + + resolved = adapter.resolve_identity(carried_sei) + + assert scenario["expect"]["carry"] is True + assert resolved.sei == carried_sei # carried verbatim — unchanged token + assert resolved.locator == new_locator + assert resolved.lineage_status == "alive" + + +@pytest.mark.parametrize("scenario_id", ["ambiguous", "delete"]) +def test_orphaned_sei_surfaces_as_orphaned_for_ambiguous_and_delete( + scenario_id: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ambiguous / delete: the old binding is ORPHANED — resolve_sei returns + ``alive:false`` with an ``orphaned`` lineage event, so the consumer raises the + ``orphaned`` verdict (fail-closed: never silently re-pointed).""" + scenario = _scenario(scenario_id) + seed = seed_loomweave_catalog(tmp_path) + orphaned_sei = seed["public_sei"] + monkeypatch.setenv("WEFT_LOOMWEAVE_URL", "http://loomweave.test") + adapter = LoomweaveAdapter(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( + adapter, + "_http_json", + _fake_http_json( + caps=SEI_SUPPORTED_CAPS, + identity={"alive": False, "lineage": [{"event": "orphaned"}]}, + calls=calls, + ), + ) + + assert "orphaned" in json.dumps(scenario["expect"]) + with pytest.raises(LoomweaveIdentityError) as exc_info: + adapter.resolve_identity(orphaned_sei) + + assert exc_info.value.reason == "orphaned" + + +@pytest.mark.parametrize( + "caps", + [ + pytest.param({"sei": {"supported": False}}, id="supported_false"), + pytest.param({}, id="sei_key_absent"), + pytest.param({"sei": {"version": 1}}, id="supported_key_absent"), + ], +) +def test_capability_absent_degrades_honestly( + caps: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """capability_absent: a REACHABLE remote whose ``_capabilities.sei.supported`` is + false OR ABSENT (the §8 scenario names both) → the consumer detects the absent + capability and degrades HONESTLY to ``unsupported`` (identity unavailable) — NOT + ``unreachable`` (down), and NEVER a fabricated alive identity. The identity resolve + route is never even called.""" + scenario = _scenario("capability_absent") + seed = seed_loomweave_catalog(tmp_path) + locator = seed["public_locator"] + monkeypatch.setenv("WEFT_LOOMWEAVE_URL", "http://loomweave.test") + adapter = LoomweaveAdapter(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( + adapter, + "_http_json", + # Reachable remote (the probe 2xx-returns), but SEI is not supported / not advertised. + _fake_http_json(caps=caps, identity=None, calls=calls), + ) + + assert "DEGRADES gracefully" in json.dumps(scenario["expect"]) + with pytest.raises(LoomweaveIdentityError) as exc_info: + adapter.resolve_identity(locator) + + # Honest degrade: capability-absent, NOT a down/unreachable verdict. + assert exc_info.value.reason == "unsupported" + assert exc_info.value.reason != "unreachable" + # The consumer probed _capabilities and STOPPED — it never resolved against a + # remote it knows serves no SEI (no fabricated identity). + assert calls == [CAPABILITIES_PATH] + + +def test_capability_absent_distinguished_from_unreachable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The split that makes capability_absent honest: a DOWN remote (the probe itself + raises) still surfaces ``unreachable``, while a REACHABLE pre-SEI remote surfaces + ``unsupported``. Conflating the two is exactly the §8 capability_absent failure.""" + seed = seed_loomweave_catalog(tmp_path) + locator = seed["public_locator"] + monkeypatch.setenv("WEFT_LOOMWEAVE_URL", "http://loomweave.test") + + # (a) Remote DOWN: the probe raises before any capability is read → unreachable. + down = LoomweaveAdapter(tmp_path) + + def probe_raises(method: str, path: str, payload: object | None = None) -> dict[str, object]: + raise LoomweaveIdentityError( + "unreachable", + "down", + [{"code": "identity_unreachable", "message": "down"}], + ) + + monkeypatch.setattr(down, "_http_json", probe_raises) + with pytest.raises(LoomweaveIdentityError) as down_exc: + down.resolve_identity(locator) + assert down_exc.value.reason == "unreachable" + + # (b) Remote REACHABLE but pre-SEI → unsupported (the honest capability_absent). + absent = LoomweaveAdapter(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( + absent, + "_http_json", + _fake_http_json(caps={"sei": {"supported": False}}, identity=None, calls=calls), + ) + with pytest.raises(LoomweaveIdentityError) as absent_exc: + absent.resolve_identity(locator) + assert absent_exc.value.reason == "unsupported" diff --git a/tests/contracts/test_preflight_facts_wire_golden.py b/tests/contracts/test_preflight_facts_wire_golden.py new file mode 100644 index 0000000..711208a --- /dev/null +++ b/tests/contracts/test_preflight_facts_wire_golden.py @@ -0,0 +1,227 @@ +"""Plainweave-authored ``weft.plainweave.preflight_facts.v1`` envelope frozen to a +vendored byte golden (ADR-006), with a non-circular producer-source recheck. + +``tests/fixtures/contracts/legis/preflight-facts.json`` is the preflight-facts +``schema + data`` payload plainweave emits from +``PlainweaveMcpSurface.plainweave_preflight_facts_get`` — the producer named in +ADR-006 (Status: Accepted). Legis is the intended CONSUMER of this envelope, but +the consumer side does NOT exist yet and legis is ringfenced, so this row is +PRODUCER-SIDE ONLY: it freezes plainweave's own produced bytes and ties them to +the live producer. There is no consumer oracle and no cross-repo drift check. + +PLAINWEAVE IS THE AUTHORITY for this seam — it OWNS the preflight-facts shape via +``PlainweaveMcpSurface.plainweave_preflight_facts_get``. The protection is a +two-layer affair (mirroring wardline's vocabulary-descriptor wire golden): + +* Layer-1 (``test_golden_matches_blob_pin``): a git-blob byte-pin on the vendored + golden, so any silent edit to the envelope wire reds the default suite. On its + OWN this is CIRCULAR — plainweave pins plainweave's own bytes. +* Producer-source recheck (``test_golden_matches_live_producer``): the + non-circular break. It re-invokes the REAL producer + (``PlainweaveMcpSurface.plainweave_preflight_facts_get``) over a fixed, + deterministically seeded tmp project and asserts the regenerated ``schema + + data`` payload EQUALS the frozen golden. The frozen bytes are tied to the live + producer, so if the envelope shape drifts from the golden — a fact kind + added/removed, a message/severity/provenance changed, a section added — it reds + even though the byte-pin still passes. + +NON-DETERMINISTIC / RELEASE-COUPLED FIELDS (honest caveat). The producer embeds +exactly two fields that are not byte-stable across runs/releases: + +* ``data.generated_at`` — ``datetime.now(UTC).isoformat()``; changes every call. +* ``data.producer.version`` — ``plainweave.__version__``; bumps every release with + no contract change. + +The golden freezes these to realistic, representative values +(``generated_at`` = ``2026-06-04T10:00:00+00:00``, ``producer.version`` = +``1.0.0``) so it reads as a real envelope and the byte-pin is stable across the +clock and releases. The recheck keeps them bound to the LIVE producer +NON-CIRCULARLY: BEFORE normalizing it asserts the regenerated ``generated_at`` +parses as an aware ISO-8601 UTC instant and that the regenerated +``producer.version`` EQUALS the live ``plainweave.__version__`` — these +pre-normalization asserts ARE the non-circularity for the two normalized fields. +ONLY THEN does it copy the golden's frozen values over those two fields and +assert deep dict-equality. A producer that dropped ``generated_at`` or emitted a +garbage version would red on the asserts, not be hidden by the normalization. + +RE-VENDOR PROCEDURE: if you deliberately change the preflight-facts shape (a new +fact kind, a changed message, an added section), regenerate the golden from the +real producer over the seeding below, freeze ``generated_at`` / +``producer.version`` back to the representative values, recompute the blob SHA +(``git hash-object tests/fixtures/contracts/legis/preflight-facts.json``) and +update ``UPSTREAM_BLOB_SHA`` in the SAME commit — the recheck will otherwise red. +The independent structural check in +``tests/contracts/test_contract_fixtures.py::test_preflight_facts_fixture_contract`` +validates the same golden through ``validate_preflight_facts`` as a free +cross-check. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from datetime import datetime +from pathlib import Path +from typing import Any, cast + +from tests.loomweave_test_utils import seed_loomweave_catalog + +from plainweave import __version__ +from plainweave.mcp_surface import PlainweaveMcpSurface +from plainweave.models import TraceRef +from plainweave.service import PlainweaveService +from plainweave.store import migrate + +GOLDEN_PATH = Path(__file__).parents[1] / "fixtures" / "contracts" / "legis" / "preflight-facts.json" + +# Layer-1 byte-pin: the git-blob SHA-1 of legis/preflight-facts.json. Recomputed +# below as hashlib.sha1(b"blob %d\0" % len(data) + data) (== `git hash-object`). +# Any edit to the vendored golden without a matching re-pin reds the default suite. +UPSTREAM_BLOB_SHA = "10506f0359317da614237df3694f038bc141009e" + +# The two fields the producer cannot emit deterministically; the golden freezes +# them to representative values and the recheck re-binds them to the live producer. +_FROZEN_GENERATED_AT = "2026-06-04T10:00:00+00:00" +_FROZEN_VERSION = "1.0.0" + + +def _seed_preflight_project(root: Path) -> dict[str, Any]: + """Deterministically seed a tmp project that exercises every preflight fact kind. + + Mirrors the seeding in + ``tests/test_mcp_read_surface.py::test_mcp_preflight_facts_returns_scoped_advisory_facts_without_verdicts`` + so the regenerated envelope reproduces the frozen golden byte-for-byte (modulo + the two normalized fields). All inputs (IDs, SEIs, content hashes, dates) are + fixed, so the producer's output is stable across runs. + """ + db_path = root / ".plainweave" / "plainweave.db" + migrate(db_path, project_key="AUTH") + service = PlainweaveService(db_path, root=root) + seed = seed_loomweave_catalog(root) + + def approve(*, title: str, statement: str, criterion: str, key: str) -> str: + draft = service.create_requirement(title, statement, "human:john") + service.add_acceptance_criterion(draft.id, criterion, actor="human:john") + service.approve_requirement(draft.id, actor="human:john", expected_version=0, idempotency_key=key) + return draft.id + + stale = approve( + title="Rotate signing keys", + statement="The API shall rotate signing keys.", + criterion="Rotated keys are accepted.", + key="approve-stale", + ) + method = service.add_verification_method( + stale, method="test", target="tests/test_keys.py::test_rotation", actor="human:john" + ) + service.record_verification_evidence( + method.id, + status="passing", + evidence_ref="pytest:tests/test_keys.py::test_rotation", + actor="agent:codex", + ) + service.create_trace_link( + TraceRef("loomweave_entity", seed["public_locator"]), + "satisfies", + TraceRef("requirement_version", f"{stale}@1"), + actor="human:john", + authority="accepted", + ) + baseline = service.create_baseline("Release 1.0", actor="human:john") + service.supersede_requirement( + stale, + title="Rotate signing keys promptly", + statement="The API shall rotate signing keys within the configured window.", + actor="human:john", + expected_version=1, + idempotency_key="supersede-stale", + ) + missing = approve( + title="Audit password resets", + statement="The API shall audit password resets.", + criterion="Password resets are audited.", + key="approve-missing", + ) + return { + "root": root, + "stale": stale, + "missing": missing, + "baseline_id": baseline.id, + "public_sei": seed["public_sei"], + } + + +def _produce_schema_plus_data(root: Path) -> dict[str, Any]: + """Re-invoke the REAL producer and return the ``schema + data`` payload shape + the golden vendors (``{"schema": ..., **data}``, NOT the full envelope).""" + seeded = _seed_preflight_project(root) + surface = PlainweaveMcpSurface(root) + envelope = surface.plainweave_preflight_facts_get( + scope_kind="pending_diff", + base="main", + head="WORKTREE", + requirement_ids=[seeded["stale"], seeded["missing"]], + entity_refs=[seeded["public_sei"], "loomweave:eid:untraced"], + baseline_id=seeded["baseline_id"], + ) + assert envelope["ok"] is True + return {"schema": envelope["schema"], **cast(dict[str, Any], envelope["data"])} + + +def test_golden_matches_blob_pin() -> None: + """Layer-1 (default suite): the plainweave-authored preflight-facts golden + byte-pins to its git blob hash. ANY edit without a matching re-pin reds the + default suite. On its OWN this pin is plainweave-pins-plainweave (circular); + the non-circular protection is ``test_golden_matches_live_producer`` below, + which regenerates the payload from the LIVE producer.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = GOLDEN_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored preflight-facts golden changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, regenerate it from the real producer, freeze generated_at / " + "producer.version back to the representative values, update UPSTREAM_BLOB_SHA in the same commit " + "(see the RE-VENDOR PROCEDURE at the top of this module); if not, revert the edit." + ) + + +def test_golden_matches_live_producer(tmp_path: Path) -> None: + """PRODUCER-SOURCE recheck (non-circular): regenerate the preflight-facts + payload from the REAL ``plainweave_preflight_facts_get`` producer over a fixed + seeded project and assert it EQUALS the frozen golden. This ties the + byte-pinned golden to the live producer, so an envelope-shape drift — a fact + kind added/removed, a message/severity/provenance changed, a section added — + without a re-vendor reds even though the byte-pin still passes. + + The two non-deterministic / release-coupled fields (``generated_at``, + ``producer.version``) are asserted LIVE *before* being normalized to the + golden's frozen values; those pre-normalization asserts are what keep drift + coverage on the exact fields the normalization clobbers.""" + golden = cast(dict[str, Any], json.loads(GOLDEN_PATH.read_text("utf-8"))) + regenerated = _produce_schema_plus_data(tmp_path) + + # --- non-circular core: assert the LIVE values BEFORE clobbering them --- + generated_at = regenerated["generated_at"] + parsed = datetime.fromisoformat(generated_at) # valid ISO-8601… + assert parsed.utcoffset() is not None and parsed.utcoffset().total_seconds() == 0, ( # type: ignore[union-attr] + f"generated_at must be an aware UTC instant, got {generated_at!r}" + ) + assert regenerated["producer"]["version"] == __version__, ( + f"producer.version must equal the live plainweave.__version__ ({__version__!r}), " + f"got {regenerated['producer']['version']!r}" + ) + + # --- normalize the two fields to the golden's frozen values, then deep-compare --- + assert golden["generated_at"] == _FROZEN_GENERATED_AT + assert golden["producer"]["version"] == _FROZEN_VERSION + normalized = copy.deepcopy(regenerated) + normalized["generated_at"] = golden["generated_at"] + normalized["producer"]["version"] = golden["producer"]["version"] + + assert normalized == golden, ( + "the live preflight-facts producer drifted from the vendored golden — see the " + "RE-VENDOR PROCEDURE at the top of this module." + ) diff --git a/tests/fixtures/contracts/legis/preflight-facts.json b/tests/fixtures/contracts/legis/preflight-facts.json index e187b46..10506f0 100644 --- a/tests/fixtures/contracts/legis/preflight-facts.json +++ b/tests/fixtures/contracts/legis/preflight-facts.json @@ -2,7 +2,7 @@ "schema": "weft.plainweave.preflight_facts.v1", "producer": { "tool": "plainweave", - "version": "0.0.1", + "version": "1.0.0", "project": "AUTH" }, "scope": { diff --git a/tests/test_loomweave_adapter.py b/tests/test_loomweave_adapter.py index 2bd1f63..272bab4 100644 --- a/tests/test_loomweave_adapter.py +++ b/tests/test_loomweave_adapter.py @@ -224,6 +224,8 @@ def test_identity_resolution_over_http_returns_alive_snapshot( } def fake_http_json(method: str, path: str, payload: object | None = None) -> dict[str, object]: + if path == "/api/v1/_capabilities": + return {"sei": {"supported": True, "version": 1}} return alive_body monkeypatch.setattr(adapter, "_http_json", fake_http_json) @@ -249,6 +251,8 @@ def test_identity_resolution_over_http_matches_the_pinned_contract_fixture( resolve_body = cast("dict[str, object]", _identity_fixture("identity-resolve.json")["response"]) def fake_http_json(method: str, path: str, payload: object | None = None) -> dict[str, object]: + if path == "/api/v1/_capabilities": + return {"sei": {"supported": True, "version": 1}} return sei_body if path.startswith("/api/v1/identity/sei/") else resolve_body monkeypatch.setattr(adapter, "_http_json", fake_http_json) @@ -272,6 +276,8 @@ def test_identity_resolution_over_http_reports_orphaned_when_not_alive( adapter = LoomweaveAdapter(tmp_path) def fake_http_json(method: str, path: str, payload: object | None = None) -> dict[str, object]: + if path == "/api/v1/_capabilities": + return {"sei": {"supported": True, "version": 1}} return {"alive": False, "lineage": [{"event": "renamed"}]} monkeypatch.setattr(adapter, "_http_json", fake_http_json) diff --git a/tests/web/__init__.py b/tests/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web/conftest.py b/tests/web/conftest.py new file mode 100644 index 0000000..0c3c74a --- /dev/null +++ b/tests/web/conftest.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from plainweave.paths import default_project_key, plainweave_db_path +from plainweave.store import migrate + + +@pytest.fixture +def project_root(tmp_path: Path) -> Path: + # Initialize a fresh local store under a temp root. + migrate(plainweave_db_path(tmp_path), project_key=default_project_key(tmp_path)) + return tmp_path diff --git a/tests/web/test_a11y_contracts.py b/tests/web/test_a11y_contracts.py new file mode 100644 index 0000000..df7c5b8 --- /dev/null +++ b/tests/web/test_a11y_contracts.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from plainweave.web.app import create_app +from plainweave.web.context import RequestContext + + +@pytest.fixture +def client(project_root: Path) -> TestClient: + return TestClient(create_app(actor="human:alice", root=project_root)) + + +def test_base_has_live_region_and_skip_link(client: TestClient) -> None: + """§4.1 / §12: base.html must carry the SR status live region and skip-link.""" + html = client.get("/").text + assert 'id="sr-status"' in html + assert 'role="status"' in html + assert 'aria-live="polite"' in html + assert 'class="skip-link"' in html + + +def test_search_has_visible_label(client: TestClient) -> None: + """§4.1: corpus filter must have a visible