From 96e3965855ba6e3b7ef0c510ff3e3f21149d3967 Mon Sep 17 00:00:00 2001 From: Andraxion Date: Wed, 29 Jul 2026 10:50:05 -0400 Subject: [PATCH 1/7] Add versioned independent projection contracts --- ACTIVE_SLICE.md | 25 +- DEVELOPMENT_NOTES.md | 87 +++++ Makefile | 3 + pyproject.toml | 5 +- schemas/graph-view-plan.schema.json | 321 ++++++++++++++++ schemas/manual-render-plan.schema.json | 214 +++++++++++ schemas/projection-package.schema.json | 190 ++++++++++ schemas/projection-receipt.schema.json | 89 +++++ src/docforge/graph_projection.py | 497 ++++++++++++++++++++++++ src/docforge/manual_projection.py | 191 ++++++++++ src/docforge/projection_contract.py | 502 +++++++++++++++++++++++++ src/docforge/py.typed | 1 + src/docforge/render_contract.py | 107 ++---- src/docforge/visualization.py | 46 +-- src/docforge_renderers/__init__.py | 1 + src/docforge_renderers/manual.py | 172 +++++++++ src/docforge_renderers/py.typed | 1 + tests/test_graph_projection.py | 404 ++++++++++++++++++++ tests/test_projection_contract.py | 486 ++++++++++++++++++++++++ tests/test_projection_schemas.py | 314 ++++++++++++++++ tests/test_public_contract.py | 19 + tests/test_visualization.py | 19 + 22 files changed, 3561 insertions(+), 133 deletions(-) create mode 100644 schemas/graph-view-plan.schema.json create mode 100644 schemas/manual-render-plan.schema.json create mode 100644 schemas/projection-package.schema.json create mode 100644 schemas/projection-receipt.schema.json create mode 100644 src/docforge/graph_projection.py create mode 100644 src/docforge/manual_projection.py create mode 100644 src/docforge/projection_contract.py create mode 100644 src/docforge/py.typed create mode 100644 src/docforge_renderers/__init__.py create mode 100644 src/docforge_renderers/manual.py create mode 100644 src/docforge_renderers/py.typed create mode 100644 tests/test_graph_projection.py create mode 100644 tests/test_projection_contract.py create mode 100644 tests/test_projection_schemas.py diff --git a/ACTIVE_SLICE.md b/ACTIVE_SLICE.md index 71c0e5e..59bc152 100644 --- a/ACTIVE_SLICE.md +++ b/ACTIVE_SLICE.md @@ -1,19 +1,16 @@ # Active milestone ```text -Milestone: 2 — agent retrieval and MCP experience -Goal: Let one project-bound server return compact, task-shaped, explainable context under an explicit effective policy. -In scope: Capability modes; capability-aware bootstrap; versioned retrieval plans and context capsules; task-shaped context; generation diffs; evidence-gap diagnostics; generated client configuration; doctor checks. -Out of scope: Independent render-plan packages; adapter SDK expansion; self-hosting; storage replacement; embeddings; WorldForge or ScrapeStation changes; production MCP repointing; tags and releases. -Done when: Policy and capabilities are explicit; bootstrap recommends only available actions; task context is compact, deterministic, provenance-bearing, and bounded; generation and evidence gaps are explainable; generated configuration and doctor checks are safe and tested; the complete repository gate and Milestone 2 benchmark pass. -Status: Complete. Effective policy, versioned task retrieval, latest-generation diff receipts, -logarithmic bounded page packing, deterministic client configuration, and the read-only integration -doctor are implemented and contract-tested. The complete repository gate passes with 205 tests and -120 subtests. Three independent adversarial audits found no remaining implementation blocker. The -clean 1,000-node baseline is recorded against candidate commit -`fb0df5e4a1c591c2a84788fd4814d98550f11863`, including task/generation reconstruction, -response-size behavior, zero-hidden-work counters, and isolated memory. No tag or release was -created, no production integration was repointed, and self-hosting remains out of scope. +Milestone: 3 — independent projections +Goal: Make manual output, portable graph artifacts, and the live viewer independent generation-pinned consumers of the validated graph. +In scope: Versioned ManualRenderPlan and GraphViewPlan; immutable projection packages and receipts; independent manual and graph renderers; projection policies; incremental fragments; equivalence, recovery, accessibility, response-size, performance, and memory gates. +Out of scope: Adapter SDK expansion; remote render services; shared render farms; third-party renderer ecosystems; self-hosting; storage replacement; WorldForge or ScrapeStation changes; production MCP repointing; tags and releases. +Done when: Manual and graph plans are versioned and bounded; renderers cannot crawl project state or mutate canonical facts; portable and live graph modes remain separate; policies are enforced independently; status is receipt-only; full/incremental output is equivalent; accessibility and maintained scale gates pass. +Status: Active implementation. Three independent audits were reconciled before source changes. The +versioned plan/package/receipt contracts, pure manual and graph planners, isolated manual renderer, +legacy byte-compatibility shim, packaged schemas, and pinned live-source correction are implemented +and focused-green. Durable publication, portable graph artifacts, detached workers, fragment +equivalence, independent policy enforcement, accessibility, and maintained scale gates remain. ``` -Milestones 3–5 remain directional context and are not active. +Milestones 4–5 remain directional context and are not active. diff --git a/DEVELOPMENT_NOTES.md b/DEVELOPMENT_NOTES.md index 8e15aea..f00abff 100644 --- a/DEVELOPMENT_NOTES.md +++ b/DEVELOPMENT_NOTES.md @@ -610,3 +610,90 @@ Milestone 2 is complete. Follow-up ideas stay explicitly later-scope: avoid reco task-shaped capsule for every continuation page, add authenticated continuation when the threat model requires it, verify a native Claude timeout representation, and introduce adapter-owned launcher metadata before generating configurations for custom adapters. + +## Milestone 3 — active: independent projections + +Milestone 3 began only after `main` and `dev` were aligned at the verified Milestone 2 closeout. +Three read-only audits are running before source changes: + +- Manual planning, immutable packages, renderer isolation, receipts, preview/application + integration, and full/incremental equivalence. +- Portable graph planning, static artifacts, the live viewer boundary, worker protocol, and static + plus interactive accessibility. +- Packaging, optional dependencies, public contracts, projection policies, performance, + incremental fragments, and maintained gates. + +The active design constraints are unchanged: renderers consume one validated immutable generation; +manual and graph plans remain separate; the live viewer is not retrieval authority; core remains +usable without rendering; status performs no hidden rendering; full rendering remains the recovery +and equivalence oracle; no storage rewrite is assumed. + +### Milestone 3 architecture decision + +The three audits converged on one compatibility-first boundary: + +- The existing `docforge.render_contract` names, `GenericHtmlRenderer.prepare()` signature, + `generic_html` renderer identity, and byte output remain the version-1 compatibility surface. + They become adapters over the new manual-planning path rather than being changed in place. +- New `ManualRenderPlanV1`, `GraphViewPlanV1`, `ProjectionPackageV1`, and + `ProjectionReceiptV1` contracts use strict canonical JSON, deterministic ordering, independent + item and byte bounds, exact generation and policy binding, and content-derived identities. +- Plans and packages contain selected graph facts and bounded content. They never contain a + project object, SQLite handle, absolute project or index path, arbitrary query, command, or + project-provided executable code. +- The planner owns graph selection and meaning. A renderer may transform only a validated package + into declared artifacts and cannot select nodes, invent relationships, crawl the project, choose + publication paths, or mutate canonical sources. +- Manual and portable graph renderers live behind independent import boundaries. Renderer + dependencies load lazily. Default installation behavior remains compatible during the initial + migration; optional dependency changes require their own verified packaging decision. +- Portable graph rendering is additive. It does not replace or silently change + `docforge_visualize`, `graph-browser@17`, the viewer-manager protocol, or the query-backed live + viewer. +- Effective policy version 1 remains frozen. Milestone 3 introduces a version-2 projection-policy + view for manual `auto|explicit|disabled`, portable graph `explicit|disabled`, and live viewer + `on-demand|disabled` enforcement, while retaining the version-1 projection for existing clients. +- Publication commits content-addressed artifacts first, renderer evidence second, and a bounded + generation/view manifest last. Status remains receipt-only. Failures after artifact replacement + report committed degraded success rather than an ordinary failed mutation. +- Full planning and rendering remain the recovery and equivalence oracle. Incremental fragments + are disposable, keyed from complete plan semantics, and may be reused only when byte-exact + artifact equivalence is proven. +- The live source endpoint must stop reading mutable canonical files behind a pinned graph + snapshot. Portable artifacts never inherit that path-bearing behavior. + +The first implementation slice freezes existing golden output, adds the four versioned contracts +and validators, introduces pure manual and graph planners, and makes the legacy manual renderer a +compatibility wrapper. Publication hardening, detached rendering, incremental fragments, portable +graph publication, independent policy enforcement, accessibility, and maintained performance +gates follow on top of that frozen boundary. + +### Milestone 3 contract slice + +The first slice now implements: + +- Strict Draft 2020-12 schemas and runtime canonical-hash validation for manual plans, graph plans, + projection packages, and projection receipts. +- A deterministic manual planner that owns page selection, navigation, cross-references, + backlinks, search documents, component assignments, orphan diagnostics, and cycle diagnostics. +- A deterministic graph planner with exact-root or metadata-only lexical scope, closed filters, + explicit node/edge/work bounds, deterministic omissions, path/source-body exclusion, and + no-AST Logic exclusion. +- A separate `docforge_renderers.manual` package. Its renderer accepts only a validated package and + has no project, SQLite, publication-path, or filesystem-write API. +- The frozen `GenericHtmlRenderer` compatibility shim over the new planner/package/renderer + pipeline. The alpha artifact remains exactly 2,043 bytes with output SHA-256 + `81656bb89debc7ad1fbe8bc290e9a3ba90664442b17a6d57e908d30d20c47f77` and legacy render identity + `1c0a49c28ba3b0dabf94be36e75def197dee1be3cb73ac405b09875383c8dc5f`. +- Rejection of project-template scripts, inline event handlers, `javascript:` URLs, embedded + browsing contexts, and refresh redirects. +- Wheel inclusion for both typed packages and every published JSON schema. Importing `docforge` + no longer imports `markdown_it` or the manual renderer package. +- A live-viewer correction: source evidence now comes from the pinned index generation. The + viewer no longer reopens mutable canonical files behind an older graph snapshot. + +The new repository-native contract target passes 91 tests and 120 subtests. The combined +projection, rendering, and live-viewer focus passes with byte-exact compatibility and no hidden +source/path authority. This is not Milestone 3 closeout: durable multi-artifact publication, +portable graph rendering, detached workers, fragment reuse/equivalence, policy version 2, +accessibility, and maintained scale evidence remain active work. diff --git a/Makefile b/Makefile index ab909e4..4ff34a0 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,9 @@ contract: tests/test_retrieval.py \ tests/test_generation_diff.py \ tests/test_client_integration.py \ + tests/test_projection_contract.py \ + tests/test_projection_schemas.py \ + tests/test_graph_projection.py \ tests/test_observability.py::TelemetryContractTests::test_schema_fixed_names_match_the_implementation \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_policy_rejects_logic_publication \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters \ diff --git a/pyproject.toml b/pyproject.toml index 20c1ec8..6024c3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,10 @@ docforge-mcp = "docforge.mcp_server:main" docforge-viewer-manager = "docforge.viewer_manager:main" [tool.hatch.build.targets.wheel] -packages = ["src/docforge"] +packages = ["src/docforge", "src/docforge_renderers"] + +[tool.hatch.build.targets.wheel.force-include] +schemas = "docforge/schemas" [tool.ruff] line-length = 100 diff --git a/schemas/graph-view-plan.schema.json b/schemas/graph-view-plan.schema.json new file mode 100644 index 0000000..248ab07 --- /dev/null +++ b/schemas/graph-view-plan.schema.json @@ -0,0 +1,321 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docforge.local/schema/graph-view-plan-v1.json", + "title": "DocForge immutable portable graph view plan", + "$comment": "plan_id is the SHA-256 of canonical JSON without plan_id and is verified by the runtime contract validator. The version-1 nested graph vocabulary remains planner-owned.", + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "project": { + "type": "object", + "required": [ + "project_id", + "project_root_fingerprint", + "adapter", + "revision", + "source_hash" + ], + "properties": { + "project_id": { "type": "string", "minLength": 1 }, + "project_root_fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{16}$" + }, + "adapter": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "minLength": 1 }, + "source_hash": { "$ref": "#/$defs/sha256" } + }, + "additionalProperties": false + }, + "filter": { + "type": "array", + "maxItems": 64, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "uniqueItems": true + }, + "edge": { + "type": "object", + "required": ["source_id", "relation", "target_id"], + "properties": { + "source_id": { "type": "string", "minLength": 1 }, + "relation": { "type": "string", "minLength": 1 }, + "target_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "node": { + "type": "object", + "required": [ + "node_id", + "title", + "family", + "authority", + "status", + "tags", + "summary", + "content_hash" + ], + "properties": { + "node_id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "family": { "type": "string", "minLength": 1 }, + "authority": { "type": "string", "minLength": 1 }, + "status": { "type": "string", "minLength": 1 }, + "tags": { "$ref": "#/$defs/filter" }, + "summary": { "type": "string" }, + "content_hash": { "$ref": "#/$defs/sha256" } + }, + "additionalProperties": false + }, + "omission": { + "oneOf": [ + { + "type": "object", + "required": ["code", "subject", "limit", "minimum_omitted"], + "properties": { + "code": { "const": "node_result_limit" }, + "subject": { "const": "nodes" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 1000 }, + "minimum_omitted": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["code", "subject", "limit", "minimum_omitted"], + "properties": { + "code": { "const": "edge_result_limit" }, + "subject": { "const": "edges" }, + "limit": { "type": "integer", "minimum": 0, "maximum": 4000 }, + "minimum_omitted": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "code", + "subject", + "limit", + "examined", + "minimum_omitted" + ], + "properties": { + "code": { "const": "work_limit" }, + "subject": { "const": "selection" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 1000000 }, + "examined": { "type": "integer", "minimum": 0 }, + "minimum_omitted": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["code", "subject", "minimum_omitted"], + "properties": { + "code": { "const": "logic_forbidden" }, + "subject": { "const": "logic" }, + "minimum_omitted": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } + ] + } + }, + "type": "object", + "required": [ + "schema_version", + "contract", + "plan_id", + "project", + "view", + "bounds", + "policy", + "graph", + "omissions", + "diagnostics" + ], + "properties": { + "schema_version": { "const": 1 }, + "contract": { "const": "docforge.graph-view-plan" }, + "plan_id": { "$ref": "#/$defs/sha256" }, + "project": { "$ref": "#/$defs/project" }, + "view": { + "type": "object", + "required": [ + "view_id", + "title", + "initial_mode", + "scope", + "filters", + "detail_fields" + ], + "properties": { + "view_id": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "initial_mode": { "enum": ["nodes", "flow", "web", "logic"] }, + "scope": { + "oneOf": [ + { + "type": "object", + "required": ["kind", "root_node_id", "depth"], + "properties": { + "kind": { "const": "exact_root" }, + "root_node_id": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "depth": { "type": "integer", "minimum": 1, "maximum": 32 } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["kind", "query"], + "properties": { + "kind": { "const": "lexical" }, + "query": { + "type": "string", + "minLength": 1, + "maxLength": 10000 + } + }, + "additionalProperties": false + } + ] + }, + "filters": { + "type": "object", + "required": [ + "families", + "relations", + "authorities", + "statuses", + "tags" + ], + "properties": { + "families": { "$ref": "#/$defs/filter" }, + "relations": { "$ref": "#/$defs/filter" }, + "authorities": { "$ref": "#/$defs/filter" }, + "statuses": { "$ref": "#/$defs/filter" }, + "tags": { "$ref": "#/$defs/filter" } + }, + "additionalProperties": false + }, + "detail_fields": { + "const": [ + "node_id", + "title", + "family", + "authority", + "status", + "tags", + "summary", + "content_hash" + ] + } + }, + "additionalProperties": false + }, + "bounds": { + "type": "object", + "required": ["depth", "max_nodes", "max_edges", "max_work"], + "properties": { + "depth": { "type": "integer", "minimum": 1, "maximum": 32 }, + "max_nodes": { "type": "integer", "minimum": 1, "maximum": 1000 }, + "max_edges": { "type": "integer", "minimum": 0, "maximum": 4000 }, + "max_work": { "type": "integer", "minimum": 1, "maximum": 1000000 } + }, + "additionalProperties": false + }, + "policy": { + "type": "object", + "required": [ + "visibility", + "source_paths", + "source_bodies", + "database_queries", + "executable_content", + "logic", + "logic_requested" + ], + "properties": { + "visibility": { "const": "selected_graph_only" }, + "source_paths": { "const": "excluded" }, + "source_bodies": { "const": "excluded" }, + "database_queries": { "const": "forbidden" }, + "executable_content": { "const": "forbidden" }, + "logic": { "enum": ["allowed", "forbidden"] }, + "logic_requested": { "type": "boolean" } + }, + "additionalProperties": false + }, + "graph": { + "type": "object", + "required": ["root_node_id", "nodes", "edges", "logic_projections"], + "properties": { + "root_node_id": { + "type": ["string", "null"], + "minLength": 1, + "maxLength": 1024 + }, + "nodes": { + "type": "array", + "maxItems": 1000, + "items": { "$ref": "#/$defs/node" } + }, + "edges": { + "type": "array", + "maxItems": 4000, + "items": { "$ref": "#/$defs/edge" } + }, + "logic_projections": { + "type": "array", + "maxItems": 0 + } + }, + "additionalProperties": false + }, + "omissions": { + "type": "array", + "maxItems": 4, + "items": { "$ref": "#/$defs/omission" } + }, + "diagnostics": { + "type": "object", + "required": [ + "selection", + "returned_nodes", + "returned_edges", + "examined_work_units", + "truncated", + "ordering" + ], + "properties": { + "selection": { "enum": ["exact_root", "lexical"] }, + "returned_nodes": { "type": "integer", "minimum": 0, "maximum": 1000 }, + "returned_edges": { "type": "integer", "minimum": 0, "maximum": 4000 }, + "examined_work_units": { "type": "integer", "minimum": 0 }, + "truncated": { "type": "boolean" }, + "ordering": { "const": "node_id;source_id,relation,target_id" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/schemas/manual-render-plan.schema.json b/schemas/manual-render-plan.schema.json new file mode 100644 index 0000000..307bce3 --- /dev/null +++ b/schemas/manual-render-plan.schema.json @@ -0,0 +1,214 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docforge.local/schema/manual-render-plan-v1.json", + "title": "DocForge immutable manual render plan", + "$comment": "plan_id is the SHA-256 of canonical JSON without plan_id and is verified by the runtime contract validator.", + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "project": { + "type": "object", + "required": [ + "project_id", + "project_root_fingerprint", + "adapter", + "revision", + "source_hash" + ], + "properties": { + "project_id": { "type": "string", "minLength": 1 }, + "project_root_fingerprint": { + "type": "string", + "pattern": "^[0-9a-f]{16}$" + }, + "adapter": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "minLength": 1 }, + "source_hash": { "$ref": "#/$defs/sha256" } + }, + "additionalProperties": false + }, + "edge": { + "type": "object", + "required": ["source_id", "relation", "target_id"], + "properties": { + "source_id": { "type": "string", "minLength": 1 }, + "relation": { "type": "string", "minLength": 1 }, + "target_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "page": { + "type": "object", + "required": [ + "node_id", + "title", + "family", + "authority", + "status", + "tags", + "summary", + "content", + "content_hash", + "components", + "breadcrumbs", + "cross_references", + "backlinks" + ], + "properties": { + "node_id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "family": { "type": "string", "minLength": 1 }, + "authority": { "type": "string", "minLength": 1 }, + "status": { "type": "string", "minLength": 1 }, + "tags": { + "type": "array", + "maxItems": 10000, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "summary": { "type": "string" }, + "content": { "type": "string" }, + "content_hash": { "$ref": "#/$defs/sha256" }, + "components": { + "type": "array", + "maxItems": 32, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "breadcrumbs": { + "type": "array", + "maxItems": 10000, + "items": { "type": "string", "minLength": 1 } + }, + "cross_references": { + "type": "array", + "maxItems": 10000, + "items": { "$ref": "#/$defs/edge" } + }, + "backlinks": { + "type": "array", + "maxItems": 10000, + "items": { "$ref": "#/$defs/edge" } + } + }, + "additionalProperties": false + }, + "navigation_item": { + "type": "object", + "required": ["node_id", "title"], + "properties": { + "node_id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "search_document": { + "type": "object", + "required": [ + "node_id", + "title", + "summary", + "family", + "status", + "tags" + ], + "properties": { + "node_id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "summary": { "type": "string" }, + "family": { "type": "string", "minLength": 1 }, + "status": { "type": "string", "minLength": 1 }, + "tags": { + "type": "array", + "maxItems": 10000, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + }, + "type": "object", + "required": [ + "schema_version", + "contract", + "plan_id", + "project", + "view", + "changeset_hash", + "pages", + "navigation", + "search_documents", + "diagnostics" + ], + "properties": { + "schema_version": { "const": 1 }, + "contract": { "const": "docforge.manual-render-plan" }, + "plan_id": { "$ref": "#/$defs/sha256" }, + "project": { "$ref": "#/$defs/project" }, + "view": { + "type": "object", + "required": ["view_id", "title", "families", "renderer"], + "properties": { + "view_id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "families": { + "type": "array", + "maxItems": 10000, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "renderer": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "changeset_hash": { + "oneOf": [ + { "$ref": "#/$defs/sha256" }, + { "type": "null" } + ] + }, + "pages": { + "type": "array", + "maxItems": 10000, + "items": { "$ref": "#/$defs/page" } + }, + "navigation": { + "type": "array", + "maxItems": 10000, + "items": { "$ref": "#/$defs/navigation_item" } + }, + "search_documents": { + "type": "array", + "maxItems": 10000, + "items": { "$ref": "#/$defs/search_document" } + }, + "diagnostics": { + "type": "object", + "required": ["orphans", "cycles"], + "properties": { + "orphans": { + "type": "array", + "maxItems": 10000, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "cycles": { + "type": "array", + "maxItems": 10000, + "items": { + "type": "array", + "minItems": 1, + "maxItems": 10000, + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/schemas/projection-package.schema.json b/schemas/projection-package.schema.json new file mode 100644 index 0000000..0d0f74b --- /dev/null +++ b/schemas/projection-package.schema.json @@ -0,0 +1,190 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docforge.local/schema/projection-package-v1.json", + "title": "DocForge immutable renderer projection package", + "$comment": "package_id and embedded plan identity equality are verified by the runtime contract validator.", + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "manual_plan": { + "type": "object", + "required": [ + "schema_version", + "contract", + "plan_id", + "project", + "view", + "changeset_hash", + "pages", + "navigation", + "search_documents", + "diagnostics" + ], + "properties": { + "schema_version": { "const": 1 }, + "contract": { "const": "docforge.manual-render-plan" }, + "plan_id": { "$ref": "#/$defs/sha256" }, + "project": { "type": "object" }, + "view": { "type": "object" }, + "changeset_hash": { + "oneOf": [ + { "$ref": "#/$defs/sha256" }, + { "type": "null" } + ] + }, + "pages": { "type": "array", "maxItems": 10000 }, + "navigation": { "type": "array", "maxItems": 10000 }, + "search_documents": { "type": "array", "maxItems": 10000 }, + "diagnostics": { "type": "object" } + }, + "additionalProperties": false + }, + "graph_plan": { + "type": "object", + "required": [ + "schema_version", + "contract", + "plan_id", + "project", + "view", + "bounds", + "policy", + "graph", + "omissions", + "diagnostics" + ], + "properties": { + "schema_version": { "const": 1 }, + "contract": { "const": "docforge.graph-view-plan" }, + "plan_id": { "$ref": "#/$defs/sha256" }, + "project": { "type": "object" }, + "view": { "type": "object" }, + "bounds": { "type": "object" }, + "policy": { "type": "object" }, + "graph": { "type": "object" }, + "omissions": { "type": "array", "maxItems": 10000 }, + "diagnostics": { "type": "object" } + }, + "additionalProperties": false + }, + "renderer": { + "type": "object", + "required": ["renderer_id", "renderer_version"], + "properties": { + "renderer_id": { "type": "string", "minLength": 1 }, + "renderer_version": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "component": { + "type": "object", + "required": ["component_id"], + "properties": { + "component_id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "asset": { + "type": "object", + "required": ["asset_id", "media_type", "sha256", "text"], + "properties": { + "asset_id": { + "type": "string", + "minLength": 1, + "pattern": "^[^/]+$" + }, + "media_type": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/$defs/sha256" }, + "text": { "type": "string" } + }, + "additionalProperties": false + } + }, + "type": "object", + "required": [ + "schema_version", + "contract", + "package_id", + "kind", + "plan_id", + "plan", + "renderer", + "components", + "assets", + "output_policy" + ], + "properties": { + "schema_version": { "const": 1 }, + "contract": { "const": "docforge.projection-package" }, + "package_id": { "$ref": "#/$defs/sha256" }, + "kind": { "enum": ["manual", "graph"] }, + "plan_id": { "$ref": "#/$defs/sha256" }, + "plan": { + "oneOf": [ + { "$ref": "#/$defs/manual_plan" }, + { "$ref": "#/$defs/graph_plan" } + ] + }, + "renderer": { "$ref": "#/$defs/renderer" }, + "components": { + "type": "array", + "maxItems": 32, + "items": { "$ref": "#/$defs/component" }, + "uniqueItems": true + }, + "assets": { + "type": "array", + "maxItems": 32, + "items": { "$ref": "#/$defs/asset" } + }, + "output_policy": { + "type": "object", + "required": ["artifact_ids", "max_total_bytes"], + "properties": { + "artifact_ids": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^/]+$" + }, + "uniqueItems": true + }, + "max_total_bytes": { + "type": "integer", + "minimum": 1 + } + }, + "additionalProperties": false + } + }, + "allOf": [ + { + "if": { + "properties": { "kind": { "const": "manual" } }, + "required": ["kind"] + }, + "then": { + "properties": { + "plan": { "$ref": "#/$defs/manual_plan" } + } + } + }, + { + "if": { + "properties": { "kind": { "const": "graph" } }, + "required": ["kind"] + }, + "then": { + "properties": { + "plan": { "$ref": "#/$defs/graph_plan" } + } + } + } + ], + "additionalProperties": false +} diff --git a/schemas/projection-receipt.schema.json b/schemas/projection-receipt.schema.json new file mode 100644 index 0000000..7e5d7d5 --- /dev/null +++ b/schemas/projection-receipt.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docforge.local/schema/projection-receipt-v1.json", + "title": "DocForge projection renderer receipt", + "$comment": "receipt_id is the SHA-256 of canonical JSON without receipt_id and is verified by the runtime contract validator.", + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "renderer": { + "type": "object", + "required": ["renderer_id", "renderer_version"], + "properties": { + "renderer_id": { "type": "string", "minLength": 1 }, + "renderer_version": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "artifact": { + "type": "object", + "required": ["artifact_id", "media_type", "sha256", "bytes"], + "properties": { + "artifact_id": { + "type": "string", + "minLength": 1, + "pattern": "^[^/]+$" + }, + "media_type": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/$defs/sha256" }, + "bytes": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + } + }, + "type": "object", + "required": [ + "schema_version", + "contract", + "receipt_id", + "kind", + "package_id", + "plan_id", + "renderer", + "artifacts", + "diagnostics", + "timing", + "peak_memory_bytes" + ], + "properties": { + "schema_version": { "const": 1 }, + "contract": { "const": "docforge.projection-receipt" }, + "receipt_id": { "$ref": "#/$defs/sha256" }, + "kind": { "enum": ["manual", "graph"] }, + "package_id": { "$ref": "#/$defs/sha256" }, + "plan_id": { "$ref": "#/$defs/sha256" }, + "renderer": { "$ref": "#/$defs/renderer" }, + "artifacts": { + "type": "array", + "maxItems": 32, + "items": { "$ref": "#/$defs/artifact" } + }, + "diagnostics": { + "type": "object", + "required": ["warnings"], + "properties": { + "warnings": { + "type": "array", + "maxItems": 10000, + "items": { "type": "string" } + } + }, + "additionalProperties": false + }, + "timing": { + "type": "object", + "required": ["elapsed_ns"], + "properties": { + "elapsed_ns": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "peak_memory_bytes": { + "type": ["integer", "null"], + "minimum": 0 + } + }, + "additionalProperties": false +} diff --git a/src/docforge/graph_projection.py b/src/docforge/graph_projection.py new file mode 100644 index 0000000..d4a05c3 --- /dev/null +++ b/src/docforge/graph_projection.py @@ -0,0 +1,497 @@ +"""Pure, bounded portable-graph planning over one immutable graph generation.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Literal, cast + +from .errors import DocForgeError +from .models import Edge, Node, ProjectSnapshot +from .project import project_root_fingerprint +from .projection_contract import GraphViewPlanV1 + +GraphViewMode = Literal["nodes", "flow", "web", "logic"] + +MAX_GRAPH_VIEW_DEPTH = 32 +MAX_GRAPH_VIEW_NODES = 1_000 +MAX_GRAPH_VIEW_EDGES = 4_000 +MAX_GRAPH_VIEW_WORK = 1_000_000 +MAX_GRAPH_VIEW_FILTERS = 64 +MAX_GRAPH_VIEW_STRING_CHARS = 1_024 +MAX_GRAPH_VIEW_QUERY_CHARS = 10_000 + +_QUERY_TOKEN = re.compile(r"\w+", re.UNICODE) +_DETAIL_FIELDS = ( + "node_id", + "title", + "family", + "authority", + "status", + "tags", + "summary", + "content_hash", +) + + +@dataclass(frozen=True) +class GraphViewRequestV1: + """One closed, inert graph selection request.""" + + view_id: str + title: str + root_node_id: str | None = None + query: str | None = None + initial_mode: GraphViewMode = "nodes" + depth: int = 1 + max_nodes: int = 100 + max_edges: int = 400 + max_work: int = 100_000 + families: tuple[str, ...] = () + relations: tuple[str, ...] = () + authorities: tuple[str, ...] = () + statuses: tuple[str, ...] = () + tags: tuple[str, ...] = () + include_logic: bool = False + + +@dataclass(frozen=True) +class _ValidatedRequest: + view_id: str + title: str + root_node_id: str | None + query: str | None + initial_mode: GraphViewMode + depth: int + max_nodes: int + max_edges: int + max_work: int + families: tuple[str, ...] + relations: tuple[str, ...] + authorities: tuple[str, ...] + statuses: tuple[str, ...] + tags: tuple[str, ...] + include_logic: bool + + +def _invalid(message: str, **details: object) -> DocForgeError: + return DocForgeError("invalid_graph_view_request", message, **details) + + +def _string(value: object, *, field: str, maximum: int = MAX_GRAPH_VIEW_STRING_CHARS) -> str: + if not isinstance(value, str) or not value.strip() or len(value) > maximum or "\0" in value: + raise _invalid("Graph view request string is invalid", field=field) + return value.strip() + + +def _filter_values(values: object, *, field: str) -> tuple[str, ...]: + if not isinstance(values, tuple): + raise _invalid("Graph view filter is invalid", field=field) + tuple_values = cast(tuple[object, ...], values) + if not all(isinstance(value, str) for value in tuple_values): + raise _invalid("Graph view filter is invalid", field=field) + if len(tuple_values) > MAX_GRAPH_VIEW_FILTERS: + raise _invalid("Graph view filter is invalid", field=field) + normalized = tuple(_string(value, field=field) for value in cast(tuple[str, ...], tuple_values)) + if len(normalized) != len(set(normalized)): + raise _invalid("Graph view filter contains duplicates", field=field) + return tuple(sorted(normalized)) + + +def _validated_request(request: GraphViewRequestV1) -> _ValidatedRequest: + view_id = _string(request.view_id, field="view_id") + title = _string(request.title, field="title") + if (request.root_node_id is None) == (request.query is None): + raise _invalid("Choose exactly one exact root or lexical query") + root_node_id = ( + None + if request.root_node_id is None + else _string(request.root_node_id, field="root_node_id") + ) + query = ( + None + if request.query is None + else _string( + request.query, + field="query", + maximum=MAX_GRAPH_VIEW_QUERY_CHARS, + ) + ) + if request.initial_mode not in {"nodes", "flow", "web", "logic"}: + raise _invalid("Graph view initial mode is unsupported") + if type(request.depth) is not int or not 1 <= request.depth <= MAX_GRAPH_VIEW_DEPTH: + raise _invalid( + "Graph view depth is outside the fixed boundary", + maximum=MAX_GRAPH_VIEW_DEPTH, + ) + for field, value, minimum, maximum in ( + ("max_nodes", request.max_nodes, 1, MAX_GRAPH_VIEW_NODES), + ("max_edges", request.max_edges, 0, MAX_GRAPH_VIEW_EDGES), + ("max_work", request.max_work, 1, MAX_GRAPH_VIEW_WORK), + ): + if type(value) is not int or not minimum <= value <= maximum: + raise _invalid( + "Graph view bound is outside the fixed boundary", + field=field, + minimum=minimum, + maximum=maximum, + ) + if type(request.include_logic) is not bool: + raise _invalid("Graph view Logic selection must be Boolean") + return _ValidatedRequest( + view_id=view_id, + title=title, + root_node_id=root_node_id, + query=query, + initial_mode=request.initial_mode, + depth=request.depth, + max_nodes=request.max_nodes, + max_edges=request.max_edges, + max_work=request.max_work, + families=_filter_values(request.families, field="families"), + relations=_filter_values(request.relations, field="relations"), + authorities=_filter_values(request.authorities, field="authorities"), + statuses=_filter_values(request.statuses, field="statuses"), + tags=_filter_values(request.tags, field="tags"), + include_logic=request.include_logic, + ) + + +def _validated_graph( + snapshot: ProjectSnapshot, +) -> tuple[tuple[Node, ...], tuple[Edge, ...], dict[str, Node]]: + nodes = tuple(sorted(snapshot.nodes, key=lambda node: node.node_id)) + node_by_id = {node.node_id: node for node in nodes} + if len(node_by_id) != len(nodes): + raise DocForgeError( + "invalid_projection", + "Graph view snapshot contains duplicate node identities", + ) + edges = tuple( + sorted( + snapshot.edges, + key=lambda edge: (edge.source_id, edge.relation, edge.target_id), + ) + ) + edge_keys = {(edge.source_id, edge.relation, edge.target_id) for edge in edges} + if len(edge_keys) != len(edges) or any( + edge.source_id not in node_by_id or edge.target_id not in node_by_id for edge in edges + ): + raise DocForgeError( + "invalid_projection", + "Graph view snapshot contains invalid relationships", + ) + return nodes, edges, node_by_id + + +def _eligible(node: Node, request: _ValidatedRequest) -> bool: + return ( + (not request.families or node.family in request.families) + and (not request.authorities or node.authority in request.authorities) + and (not request.statuses or node.status in request.statuses) + and (not request.tags or set(request.tags).issubset(node.tags)) + ) + + +def _relation_allowed(edge: Edge, request: _ValidatedRequest) -> bool: + return not request.relations or edge.relation in request.relations + + +def _lexical_text(node: Node) -> str: + return " ".join( + ( + node.node_id, + node.title, + node.summary, + node.family, + node.authority, + node.status, + *node.tags, + ) + ).casefold() + + +def _lexical_nodes( + nodes: tuple[Node, ...], + request: _ValidatedRequest, + omissions: list[dict[str, object]], +) -> tuple[set[str], int, bool]: + query = request.query + maximum_nodes = request.max_nodes + maximum_work = request.max_work + assert query is not None + terms = tuple(dict.fromkeys(_QUERY_TOKEN.findall(query.casefold()))) + if not terms: + raise _invalid("Lexical graph scope contains no searchable text") + selected: set[str] = set() + work = 0 + work_limited = False + for node in nodes: + if work >= maximum_work: + work_limited = True + break + work += 1 + if not _eligible(node, request) or not all(term in _lexical_text(node) for term in terms): + continue + if len(selected) >= maximum_nodes: + omissions.append( + { + "code": "node_result_limit", + "subject": "nodes", + "limit": maximum_nodes, + "minimum_omitted": 1, + } + ) + break + selected.add(node.node_id) + return selected, work, work_limited + + +def _root_nodes( + edges: tuple[Edge, ...], + node_by_id: dict[str, Node], + request: _ValidatedRequest, + omissions: list[dict[str, object]], +) -> tuple[set[str], int, bool]: + root_node_id = request.root_node_id + maximum_nodes = request.max_nodes + maximum_work = request.max_work + depth = request.depth + assert root_node_id is not None + root = node_by_id.get(root_node_id) + if root is None: + raise DocForgeError( + "missing_node", + "No node has the requested stable ID", + node_id=root_node_id, + ) + if not _eligible(root, request): + raise _invalid("Exact graph root is excluded by the closed node filters") + + selected = {root_node_id} + frontier = {root_node_id} + work = 0 + work_limited = False + node_limited = False + for _ in range(depth): + if not frontier: + break + next_frontier: set[str] = set() + for edge in edges: + if work >= maximum_work: + work_limited = True + break + work += 1 + if not _relation_allowed(edge, request): + continue + candidate: str | None = None + if edge.source_id in frontier: + candidate = edge.target_id + elif edge.target_id in frontier: + candidate = edge.source_id + if candidate is None or candidate in selected: + continue + node = node_by_id[candidate] + if not _eligible(node, request): + continue + if len(selected) >= maximum_nodes: + node_limited = True + continue + selected.add(candidate) + next_frontier.add(candidate) + if work_limited: + break + frontier = next_frontier + if node_limited: + omissions.append( + { + "code": "node_result_limit", + "subject": "nodes", + "limit": maximum_nodes, + "minimum_omitted": 1, + } + ) + return selected, work, work_limited + + +def _selected_edges( + edges: tuple[Edge, ...], + selected_ids: set[str], + request: _ValidatedRequest, + *, + initial_work: int, + omissions: list[dict[str, object]], +) -> tuple[list[Edge], int, bool]: + maximum_edges = request.max_edges + maximum_work = request.max_work + selected: list[Edge] = [] + work = initial_work + work_limited = False + edge_limited = False + for edge in edges: + if work >= maximum_work: + work_limited = True + break + work += 1 + if ( + edge.source_id not in selected_ids + or edge.target_id not in selected_ids + or not _relation_allowed(edge, request) + ): + continue + if len(selected) >= maximum_edges: + edge_limited = True + break + selected.append(edge) + if edge_limited: + omissions.append( + { + "code": "edge_result_limit", + "subject": "edges", + "limit": maximum_edges, + "minimum_omitted": 1, + } + ) + return selected, work, work_limited + + +def _node_payload(node: Node) -> dict[str, object]: + return { + "node_id": node.node_id, + "title": node.title, + "family": node.family, + "authority": node.authority, + "status": node.status, + "tags": sorted(node.tags), + "summary": node.summary, + "content_hash": node.content_hash, + } + + +def _edge_payload(edge: Edge) -> dict[str, str]: + return { + "source_id": edge.source_id, + "relation": edge.relation, + "target_id": edge.target_id, + } + + +def build_graph_view_plan( + snapshot: ProjectSnapshot, + request: GraphViewRequestV1, + allow_logic: bool, +) -> GraphViewPlanV1: + """Build one deterministic, path-free graph plan without rendering or storage access.""" + + if type(allow_logic) is not bool: + raise _invalid("Graph view Logic policy must be Boolean") + normalized = _validated_request(request) + nodes, edges, node_by_id = _validated_graph(snapshot) + omissions: list[dict[str, object]] = [] + if normalized.root_node_id is not None: + selected_ids, work, work_limited = _root_nodes( + edges, + node_by_id, + normalized, + omissions, + ) + scope: dict[str, object] = { + "kind": "exact_root", + "root_node_id": normalized.root_node_id, + "depth": normalized.depth, + } + else: + selected_ids, work, work_limited = _lexical_nodes( + nodes, + normalized, + omissions, + ) + scope = { + "kind": "lexical", + "query": normalized.query, + } + selected_edges, work, edge_work_limited = _selected_edges( + edges, + selected_ids, + normalized, + initial_work=work, + omissions=omissions, + ) + work_limited = work_limited or edge_work_limited + if work_limited: + omissions.append( + { + "code": "work_limit", + "subject": "selection", + "limit": normalized.max_work, + "examined": work, + "minimum_omitted": 1, + } + ) + logic_requested = normalized.include_logic + if logic_requested and not allow_logic: + omissions.append( + { + "code": "logic_forbidden", + "subject": "logic", + "minimum_omitted": 1, + } + ) + omissions.sort(key=lambda item: (str(item["code"]), str(item["subject"]))) + selected_nodes = [node_by_id[node_id] for node_id in sorted(selected_ids)] + filters: dict[str, list[str]] = { + "families": list(normalized.families), + "relations": list(normalized.relations), + "authorities": list(normalized.authorities), + "statuses": list(normalized.statuses), + "tags": list(normalized.tags), + } + return GraphViewPlanV1.create( + { + "project": { + "project_id": snapshot.descriptor.project_id, + "project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root), + "adapter": snapshot.descriptor.adapter, + "revision": snapshot.revision, + "source_hash": snapshot.source_hash, + }, + "view": { + "view_id": normalized.view_id, + "title": normalized.title, + "initial_mode": normalized.initial_mode, + "scope": scope, + "filters": filters, + "detail_fields": list(_DETAIL_FIELDS), + }, + "bounds": { + "depth": normalized.depth, + "max_nodes": normalized.max_nodes, + "max_edges": normalized.max_edges, + "max_work": normalized.max_work, + }, + "policy": { + "visibility": "selected_graph_only", + "source_paths": "excluded", + "source_bodies": "excluded", + "database_queries": "forbidden", + "executable_content": "forbidden", + "logic": "allowed" if allow_logic else "forbidden", + "logic_requested": logic_requested, + }, + "graph": { + "root_node_id": normalized.root_node_id, + "nodes": [_node_payload(node) for node in selected_nodes], + "edges": [_edge_payload(edge) for edge in selected_edges], + "logic_projections": [], + }, + "omissions": omissions, + "diagnostics": { + "selection": scope["kind"], + "returned_nodes": len(selected_nodes), + "returned_edges": len(selected_edges), + "examined_work_units": work, + "truncated": bool(omissions), + "ordering": "node_id;source_id,relation,target_id", + }, + } + ) diff --git a/src/docforge/manual_projection.py b/src/docforge/manual_projection.py new file mode 100644 index 0000000..3cee4d6 --- /dev/null +++ b/src/docforge/manual_projection.py @@ -0,0 +1,191 @@ +"""Pure manual planning over one immutable validated graph generation.""" + +from __future__ import annotations + +import hashlib +from collections import defaultdict + +from .errors import DocForgeError +from .models import Edge, ProjectSnapshot, RenderView +from .project import project_root_fingerprint +from .projection_contract import ManualRenderPlanV1, ProjectionPackageV1 + + +def _edge_dict(edge: Edge) -> dict[str, str]: + return { + "source_id": edge.source_id, + "relation": edge.relation, + "target_id": edge.target_id, + } + + +def _cycles(node_ids: tuple[str, ...], edges: tuple[Edge, ...]) -> list[list[str]]: + """Return deterministic strongly connected components that represent cycles.""" + + adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_ids} + for edge in edges: + adjacency[edge.source_id].append(edge.target_id) + for targets in adjacency.values(): + targets.sort() + + index = 0 + indexes: dict[str, int] = {} + lowlinks: dict[str, int] = {} + stack: list[str] = [] + on_stack: set[str] = set() + components: list[list[str]] = [] + + def visit(node_id: str) -> None: + nonlocal index + indexes[node_id] = index + lowlinks[node_id] = index + index += 1 + stack.append(node_id) + on_stack.add(node_id) + for target_id in adjacency[node_id]: + if target_id not in indexes: + visit(target_id) + lowlinks[node_id] = min(lowlinks[node_id], lowlinks[target_id]) + elif target_id in on_stack: + lowlinks[node_id] = min(lowlinks[node_id], indexes[target_id]) + if lowlinks[node_id] != indexes[node_id]: + return + component: list[str] = [] + while stack: + member = stack.pop() + on_stack.remove(member) + component.append(member) + if member == node_id: + break + component.sort() + if len(component) > 1 or component[0] in adjacency[component[0]]: + components.append(component) + + for node_id in node_ids: + if node_id not in indexes: + visit(node_id) + return sorted(components) + + +def build_manual_render_plan( + snapshot: ProjectSnapshot, + view: RenderView, + *, + changeset_hash: str | None, +) -> ManualRenderPlanV1: + """Select and describe a complete manual without rendering markup.""" + + selected = tuple( + node for node in snapshot.nodes if not view.families or node.family in view.families + ) + selected_ids = {node.node_id for node in selected} + edges = tuple( + edge + for edge in snapshot.edges + if edge.source_id in selected_ids and edge.target_id in selected_ids + ) + outgoing: dict[str, list[Edge]] = defaultdict(list) + incoming: dict[str, list[Edge]] = defaultdict(list) + for edge in edges: + outgoing[edge.source_id].append(edge) + incoming[edge.target_id].append(edge) + for values in (*outgoing.values(), *incoming.values()): + values.sort(key=lambda edge: (edge.source_id, edge.relation, edge.target_id)) + + pages = [ + { + "node_id": node.node_id, + "title": node.title, + "family": node.family, + "authority": node.authority, + "status": node.status, + "tags": list(node.tags), + "summary": node.summary, + "content": node.content, + "content_hash": node.content_hash, + "components": [ + "manual.node-metadata@1", + "manual.summary@1", + "manual.commonmark@1", + "manual.relationships@1", + ], + "breadcrumbs": [], + "cross_references": [_edge_dict(edge) for edge in outgoing[node.node_id]], + "backlinks": [_edge_dict(edge) for edge in incoming[node.node_id]], + } + for node in selected + ] + node_ids = tuple(node.node_id for node in selected) + connected = {endpoint for edge in edges for endpoint in (edge.source_id, edge.target_id)} + return ManualRenderPlanV1.create( + { + "project": { + "project_id": snapshot.descriptor.project_id, + "project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root), + "adapter": snapshot.descriptor.adapter, + "revision": snapshot.revision, + "source_hash": snapshot.source_hash, + }, + "view": { + "view_id": view.view_id, + "title": view.title, + "families": list(view.families), + "renderer": view.renderer, + }, + "changeset_hash": changeset_hash, + "pages": pages, + "navigation": [{"node_id": node.node_id, "title": node.title} for node in selected], + "search_documents": [ + { + "node_id": node.node_id, + "title": node.title, + "summary": node.summary, + "family": node.family, + "status": node.status, + "tags": list(node.tags), + } + for node in selected + ], + "diagnostics": { + "orphans": [node_id for node_id in node_ids if node_id not in connected], + "cycles": _cycles(node_ids, edges), + }, + } + ) + + +def build_manual_projection_package( + plan: ManualRenderPlanV1, + template_bytes: bytes, + *, + renderer_id: str, + renderer_version: str, + max_output_bytes: int, +) -> ProjectionPackageV1: + """Bind one plan and inert template asset for a path-free manual renderer.""" + + try: + template = template_bytes.decode("utf-8") + except UnicodeDecodeError as error: + raise DocForgeError("invalid_template", "Render template is not valid UTF-8") from error + return ProjectionPackageV1.create( + kind="manual", + plan=plan, + renderer={"renderer_id": renderer_id, "renderer_version": renderer_version}, + components=[ + {"component_id": "manual.document@1"}, + {"component_id": "manual.commonmark@1"}, + ], + assets=[ + { + "asset_id": "manual.template", + "media_type": "text/html; charset=utf-8", + "sha256": hashlib.sha256(template_bytes).hexdigest(), + "text": template, + } + ], + output_policy={ + "artifact_ids": ["manual.html"], + "max_total_bytes": max_output_bytes, + }, + ) diff --git a/src/docforge/projection_contract.py b/src/docforge/projection_contract.py new file mode 100644 index 0000000..6d06cb0 --- /dev/null +++ b/src/docforge/projection_contract.py @@ -0,0 +1,502 @@ +"""Versioned, canonical contracts shared by independent projection renderers.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Literal, cast + +from .errors import DocForgeError + +MANUAL_RENDER_PLAN_CONTRACT = "docforge.manual-render-plan" +GRAPH_VIEW_PLAN_CONTRACT = "docforge.graph-view-plan" +PROJECTION_PACKAGE_CONTRACT = "docforge.projection-package" +PROJECTION_RECEIPT_CONTRACT = "docforge.projection-receipt" + +PROJECTION_SCHEMA_VERSION = 1 +MAX_PLAN_BYTES = 16_000_000 +MAX_PACKAGE_BYTES = 24_000_000 +MAX_RECEIPT_BYTES = 128_000 +MAX_PROJECTION_ARTIFACTS = 32 + +ProjectionKind = Literal["manual", "graph"] + + +def canonical_projection_bytes(value: object) -> bytes: + """Return the one canonical UTF-8 representation used for projection identities.""" + + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + + +def projection_hash(value: object) -> str: + return hashlib.sha256(canonical_projection_bytes(value)).hexdigest() + + +def _is_hash(value: object) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + +def _bounded(document: dict[str, object], maximum: int, *, kind: str) -> None: + size = len(canonical_projection_bytes(document)) + if size > maximum: + raise DocForgeError( + "projection_too_large", + f"{kind} exceeds its fixed serialized-size limit", + maximum_bytes=maximum, + actual_bytes=size, + ) + + +def _validated_identity( + document: dict[str, object], + *, + identity_field: str, + maximum: int, + kind: str, +) -> dict[str, object]: + _bounded(document, maximum, kind=kind) + identity = document.get(identity_field) + if not _is_hash(identity): + raise DocForgeError("invalid_projection", f"{kind} identity is invalid") + body = dict(document) + body.pop(identity_field) + if projection_hash(body) != identity: + raise DocForgeError("invalid_projection", f"{kind} identity does not match its content") + return document + + +def _reject_runtime_authority(value: object) -> None: + """Reject structural capabilities while treating selected content as inert data.""" + + forbidden_keys = { + "command", + "database", + "database_path", + "index_path", + "project_root", + "project_path", + "sql", + } + if isinstance(value, dict): + payload = cast(dict[object, object], value) + for key, item in payload.items(): + if isinstance(key, str) and key in forbidden_keys: + raise DocForgeError( + "invalid_projection", + "Projection package contains forbidden runtime authority", + field=key, + ) + if ( + isinstance(key, str) + and (key == "path" or key.endswith("_path")) + and isinstance(item, str) + and item.startswith("/") + ): + raise DocForgeError( + "invalid_projection", + "Projection package contains an absolute runtime path", + field=key, + ) + _reject_runtime_authority(item) + elif isinstance(value, list): + for item in cast(list[object], value): + _reject_runtime_authority(item) + + +@dataclass(frozen=True) +class ManualRenderPlanV1: + """One immutable, bounded manual plan prepared from a validated graph generation.""" + + document: dict[str, object] + + @property + def plan_id(self) -> str: + return cast(str, self.document["plan_id"]) + + def as_dict(self) -> dict[str, object]: + return dict(self.document) + + @classmethod + def create(cls, payload: dict[str, object]) -> ManualRenderPlanV1: + body = { + "schema_version": PROJECTION_SCHEMA_VERSION, + "contract": MANUAL_RENDER_PLAN_CONTRACT, + **payload, + } + document = {**body, "plan_id": projection_hash(body)} + return cls(validate_manual_render_plan(document)) + + @classmethod + def from_dict(cls, document: dict[str, object]) -> ManualRenderPlanV1: + return cls(validate_manual_render_plan(dict(document))) + + +@dataclass(frozen=True) +class GraphViewPlanV1: + """One immutable, bounded portable-graph plan.""" + + document: dict[str, object] + + @property + def plan_id(self) -> str: + return cast(str, self.document["plan_id"]) + + def as_dict(self) -> dict[str, object]: + return dict(self.document) + + @classmethod + def create(cls, payload: dict[str, object]) -> GraphViewPlanV1: + body = { + "schema_version": PROJECTION_SCHEMA_VERSION, + "contract": GRAPH_VIEW_PLAN_CONTRACT, + **payload, + } + document = {**body, "plan_id": projection_hash(body)} + return cls(validate_graph_view_plan(document)) + + @classmethod + def from_dict(cls, document: dict[str, object]) -> GraphViewPlanV1: + return cls(validate_graph_view_plan(dict(document))) + + +@dataclass(frozen=True) +class ProjectionPackageV1: + """Path-free package supplied to one capability-isolated renderer.""" + + document: dict[str, object] + + @property + def package_id(self) -> str: + return cast(str, self.document["package_id"]) + + @property + def kind(self) -> ProjectionKind: + return cast(ProjectionKind, self.document["kind"]) + + def as_dict(self) -> dict[str, object]: + return dict(self.document) + + @classmethod + def create( + cls, + *, + kind: ProjectionKind, + plan: ManualRenderPlanV1 | GraphViewPlanV1, + renderer: dict[str, object], + components: list[dict[str, object]], + assets: list[dict[str, object]], + output_policy: dict[str, object], + ) -> ProjectionPackageV1: + body: dict[str, object] = { + "schema_version": PROJECTION_SCHEMA_VERSION, + "contract": PROJECTION_PACKAGE_CONTRACT, + "kind": kind, + "plan_id": plan.plan_id, + "plan": plan.as_dict(), + "renderer": renderer, + "components": components, + "assets": assets, + "output_policy": output_policy, + } + document = {**body, "package_id": projection_hash(body)} + return cls(validate_projection_package(document)) + + @classmethod + def from_dict(cls, document: dict[str, object]) -> ProjectionPackageV1: + return cls(validate_projection_package(dict(document))) + + +@dataclass(frozen=True) +class ProjectionReceiptV1: + """Renderer evidence that contains identities and sizes, never artifact bytes.""" + + document: dict[str, object] + + @property + def receipt_id(self) -> str: + return cast(str, self.document["receipt_id"]) + + def as_dict(self) -> dict[str, object]: + return dict(self.document) + + @classmethod + def create( + cls, + *, + kind: ProjectionKind, + package_id: str, + plan_id: str, + renderer: dict[str, object], + artifacts: list[dict[str, object]], + diagnostics: dict[str, object], + timing: dict[str, object], + peak_memory_bytes: int | None, + ) -> ProjectionReceiptV1: + body: dict[str, object] = { + "schema_version": PROJECTION_SCHEMA_VERSION, + "contract": PROJECTION_RECEIPT_CONTRACT, + "kind": kind, + "package_id": package_id, + "plan_id": plan_id, + "renderer": renderer, + "artifacts": artifacts, + "diagnostics": diagnostics, + "timing": timing, + "peak_memory_bytes": peak_memory_bytes, + } + document = {**body, "receipt_id": projection_hash(body)} + return cls(validate_projection_receipt(document)) + + @classmethod + def from_dict(cls, document: dict[str, object]) -> ProjectionReceiptV1: + return cls(validate_projection_receipt(dict(document))) + + +@dataclass(frozen=True) +class ProjectionArtifact: + """One renderer-produced artifact addressed by a logical identifier.""" + + artifact_id: str + media_type: str + content: bytes + + def evidence(self) -> dict[str, object]: + return { + "artifact_id": self.artifact_id, + "media_type": self.media_type, + "sha256": hashlib.sha256(self.content).hexdigest(), + "bytes": len(self.content), + } + + +@dataclass(frozen=True) +class ProjectionRenderResult: + """Artifact bytes plus the bounded renderer receipt that attests them.""" + + artifacts: tuple[ProjectionArtifact, ...] + receipt: ProjectionReceiptV1 + + +def _validate_project_identity(value: object) -> None: + if not isinstance(value, dict): + raise DocForgeError("invalid_projection", "Projection project identity is invalid") + project = cast(dict[str, object], value) + if set(project) != { + "project_id", + "project_root_fingerprint", + "adapter", + "revision", + "source_hash", + }: + raise DocForgeError("invalid_projection", "Projection project identity is invalid") + if not all( + isinstance(project.get(key), str) and bool(project[key]) + for key in ("project_id", "project_root_fingerprint", "adapter", "revision") + ) or not _is_hash(project.get("source_hash")): + raise DocForgeError("invalid_projection", "Projection project identity is invalid") + + +def validate_manual_render_plan(document: dict[str, object]) -> dict[str, object]: + required = { + "schema_version", + "contract", + "plan_id", + "project", + "view", + "changeset_hash", + "pages", + "navigation", + "search_documents", + "diagnostics", + } + if set(document) != required: + raise DocForgeError("invalid_projection", "Manual render plan fields are invalid") + if ( + document.get("schema_version") != PROJECTION_SCHEMA_VERSION + or document.get("contract") != MANUAL_RENDER_PLAN_CONTRACT + ): + raise DocForgeError("invalid_projection", "Manual render plan version is unsupported") + _validate_project_identity(document.get("project")) + if not all( + isinstance(document.get(key), expected) + for key, expected in ( + ("view", dict), + ("pages", list), + ("navigation", list), + ("search_documents", list), + ("diagnostics", dict), + ) + ): + raise DocForgeError("invalid_projection", "Manual render plan structure is invalid") + changeset_hash = document.get("changeset_hash") + if changeset_hash is not None and not _is_hash(changeset_hash): + raise DocForgeError("invalid_projection", "Manual render plan changeset hash is invalid") + return _validated_identity( + document, + identity_field="plan_id", + maximum=MAX_PLAN_BYTES, + kind="Manual render plan", + ) + + +def validate_graph_view_plan(document: dict[str, object]) -> dict[str, object]: + required = { + "schema_version", + "contract", + "plan_id", + "project", + "view", + "bounds", + "policy", + "graph", + "omissions", + "diagnostics", + } + if set(document) != required: + raise DocForgeError("invalid_projection", "Graph view plan fields are invalid") + if ( + document.get("schema_version") != PROJECTION_SCHEMA_VERSION + or document.get("contract") != GRAPH_VIEW_PLAN_CONTRACT + ): + raise DocForgeError("invalid_projection", "Graph view plan version is unsupported") + _validate_project_identity(document.get("project")) + if not all( + isinstance(document.get(key), expected) + for key, expected in ( + ("view", dict), + ("bounds", dict), + ("policy", dict), + ("graph", dict), + ("omissions", list), + ("diagnostics", dict), + ) + ): + raise DocForgeError("invalid_projection", "Graph view plan structure is invalid") + return _validated_identity( + document, + identity_field="plan_id", + maximum=MAX_PLAN_BYTES, + kind="Graph view plan", + ) + + +def validate_projection_package(document: dict[str, object]) -> dict[str, object]: + required = { + "schema_version", + "contract", + "package_id", + "kind", + "plan_id", + "plan", + "renderer", + "components", + "assets", + "output_policy", + } + if set(document) != required: + raise DocForgeError("invalid_projection", "Projection package fields are invalid") + kind = document.get("kind") + if ( + document.get("schema_version") != PROJECTION_SCHEMA_VERSION + or document.get("contract") != PROJECTION_PACKAGE_CONTRACT + or kind not in {"manual", "graph"} + ): + raise DocForgeError("invalid_projection", "Projection package version or kind is invalid") + plan = document.get("plan") + if not isinstance(plan, dict): + raise DocForgeError("invalid_projection", "Projection package plan is invalid") + validated_plan = ( + validate_manual_render_plan(cast(dict[str, object], plan)) + if kind == "manual" + else validate_graph_view_plan(cast(dict[str, object], plan)) + ) + if document.get("plan_id") != validated_plan.get("plan_id"): + raise DocForgeError("invalid_projection", "Projection package plan identity is invalid") + if not all( + isinstance(document.get(key), expected) + for key, expected in ( + ("renderer", dict), + ("components", list), + ("assets", list), + ("output_policy", dict), + ) + ): + raise DocForgeError("invalid_projection", "Projection package structure is invalid") + if len(cast(list[object], document["assets"])) > MAX_PROJECTION_ARTIFACTS: + raise DocForgeError("projection_too_large", "Projection package has too many assets") + _reject_runtime_authority(document) + return _validated_identity( + document, + identity_field="package_id", + maximum=MAX_PACKAGE_BYTES, + kind="Projection package", + ) + + +def validate_projection_receipt(document: dict[str, object]) -> dict[str, object]: + required = { + "schema_version", + "contract", + "receipt_id", + "kind", + "package_id", + "plan_id", + "renderer", + "artifacts", + "diagnostics", + "timing", + "peak_memory_bytes", + } + if set(document) != required: + raise DocForgeError("invalid_projection", "Projection receipt fields are invalid") + if ( + document.get("schema_version") != PROJECTION_SCHEMA_VERSION + or document.get("contract") != PROJECTION_RECEIPT_CONTRACT + or document.get("kind") not in {"manual", "graph"} + or not _is_hash(document.get("package_id")) + or not _is_hash(document.get("plan_id")) + ): + raise DocForgeError("invalid_projection", "Projection receipt identity is invalid") + artifacts_value = document.get("artifacts") + if not isinstance(artifacts_value, list): + raise DocForgeError("invalid_projection", "Projection receipt structure is invalid") + artifacts = cast(list[object], artifacts_value) + if ( + len(artifacts) > MAX_PROJECTION_ARTIFACTS + or not isinstance(document.get("renderer"), dict) + or not isinstance(document.get("diagnostics"), dict) + or not isinstance(document.get("timing"), dict) + ): + raise DocForgeError("invalid_projection", "Projection receipt structure is invalid") + peak = document.get("peak_memory_bytes") + if peak is not None and (type(peak) is not int or peak < 0): + raise DocForgeError("invalid_projection", "Projection receipt memory value is invalid") + for artifact in artifacts: + if not isinstance(artifact, dict): + raise DocForgeError("invalid_projection", "Projection receipt artifact is invalid") + item = cast(dict[str, object], artifact) + if ( + set(item) != {"artifact_id", "media_type", "sha256", "bytes"} + or not isinstance(item.get("artifact_id"), str) + or not item["artifact_id"] + or "/" in cast(str, item["artifact_id"]) + or not isinstance(item.get("media_type"), str) + or not item["media_type"] + or not _is_hash(item.get("sha256")) + or type(item.get("bytes")) is not int + or cast(int, item["bytes"]) < 0 + ): + raise DocForgeError("invalid_projection", "Projection receipt artifact is invalid") + return _validated_identity( + document, + identity_field="receipt_id", + maximum=MAX_RECEIPT_BYTES, + kind="Projection receipt", + ) diff --git a/src/docforge/py.typed b/src/docforge/py.typed new file mode 100644 index 0000000..e4798ff --- /dev/null +++ b/src/docforge/py.typed @@ -0,0 +1 @@ +# PEP 561 marker for the typed DocForge public package. diff --git a/src/docforge/render_contract.py b/src/docforge/render_contract.py index f24ee9b..aaecf9a 100644 --- a/src/docforge/render_contract.py +++ b/src/docforge/render_contract.py @@ -1,31 +1,17 @@ -"""Deterministic built-in renderer contract and safe template primitives.""" +"""Compatibility shim over the versioned manual projection boundary.""" from __future__ import annotations import hashlib -import html import json -import re from dataclasses import dataclass from importlib.metadata import version from pathlib import Path from typing import Protocol -from markdown_it import MarkdownIt - from .errors import DocForgeError -from .models import Edge, Node, ProjectSnapshot, RenderView - -_TEMPLATE_TOKEN = re.compile(r"{{\s*([a-z_][a-z0-9_]*)\s*}}") -_ALLOWED_TOKENS = frozenset( - { - "docforge_content", - "docforge_project_id", - "docforge_render_identity", - "docforge_title", - "docforge_view_id", - } -) +from .manual_projection import build_manual_projection_package, build_manual_render_plan +from .models import ProjectSnapshot, RenderView @dataclass(frozen=True) @@ -55,13 +41,12 @@ class Renderer(Protocol): class GenericHtmlRenderer: - """Render validated nodes through escaped CommonMark and a strict token template.""" + """Preserve the public v1 renderer API over the plan-only manual renderer.""" renderer_id = "generic_html" contract_version = "1" def __init__(self) -> None: - self.markdown = MarkdownIt("commonmark", {"html": False, "typographer": False}) self.renderer_version = ( f"{self.contract_version}+markdown-it-py-{version('markdown-it-py')}" ) @@ -74,22 +59,6 @@ class GenericHtmlRenderer: *, changeset_hash: str | None, ) -> PreparedRender: - try: - template = template_bytes.decode("utf-8") - except UnicodeDecodeError as error: - raise DocForgeError("invalid_template", "Render template is not valid UTF-8") from error - tokens = _TEMPLATE_TOKEN.findall(template) - unknown = sorted(set(tokens) - _ALLOWED_TOKENS) - remainder = _TEMPLATE_TOKEN.sub("", template) - if unknown or "{{" in remainder or "}}" in remainder: - raise DocForgeError( - "invalid_template", "Render template contains unsupported tokens", tokens=unknown - ) - if tokens.count("docforge_content") != 1: - raise DocForgeError( - "invalid_template", "Render template must contain docforge_content exactly once" - ) - selected = tuple( node for node in snapshot.nodes if not view.families or node.family in view.families ) @@ -128,16 +97,26 @@ class GenericHtmlRenderer: render_identity = hashlib.sha256( json.dumps(identity_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() - content = self._content(selected, selected_edges) - replacements = { - "docforge_content": content, - "docforge_project_id": html.escape(snapshot.descriptor.project_id, quote=True), - "docforge_render_identity": render_identity, - "docforge_title": html.escape(view.title, quote=True), - "docforge_view_id": html.escape(view.view_id, quote=True), - } - rendered = _TEMPLATE_TOKEN.sub(lambda match: replacements[match.group(1)], template) - output = rendered.rstrip().encode("utf-8") + b"\n" + plan = build_manual_render_plan(snapshot, view, changeset_hash=changeset_hash) + package = build_manual_projection_package( + plan, + template_bytes, + renderer_id=self.renderer_id, + renderer_version=self.renderer_version, + max_output_bytes=snapshot.descriptor.limits.max_render_bytes, + ) + from docforge_renderers.manual import ManualHtmlRenderer + + result = ManualHtmlRenderer(self.renderer_version).render( + package, + render_identity=render_identity, + ) + if len(result.artifacts) != 1: + raise DocForgeError( + "invalid_projection", + "Manual renderer returned an unsupported artifact set", + ) + output = result.artifacts[0].content return PreparedRender( render_identity=render_identity, output_hash=hashlib.sha256(output).hexdigest(), @@ -147,44 +126,6 @@ class GenericHtmlRenderer: template_hash=template_hash, ) - def _content(self, nodes: tuple[Node, ...], edges: tuple[Edge, ...]) -> str: - navigation = ['") - sections = [*navigation] - edge_map: dict[str, list[Edge]] = {} - for edge in edges: - edge_map.setdefault(edge.source_id, []).append(edge) - for node in nodes: - sections.extend( - [ - f'
', - f"

{html.escape(node.title)}

", - '
', - f"
ID
{html.escape(node.node_id)}
", - f"
Family
{html.escape(node.family)}
", - f"
Status
{html.escape(node.status)}
", - f"
Authority
{html.escape(node.authority)}
", - "
", - f'

{html.escape(node.summary)}

', - self.markdown.render(node.content).rstrip(), - ] - ) - relationships = edge_map.get(node.node_id, []) - if relationships: - sections.append('") - sections.append("
") - return "\n".join(sections) - _RENDERERS: dict[str, type[GenericHtmlRenderer]] = { GenericHtmlRenderer.renderer_id: GenericHtmlRenderer diff --git a/src/docforge/visualization.py b/src/docforge/visualization.py index 621cb83..9dfcb7e 100644 --- a/src/docforge/visualization.py +++ b/src/docforge/visualization.py @@ -506,11 +506,11 @@ class VisualizationIndexSnapshot: ) def source(self, node_id: str) -> dict[str, object]: - """Return one node's bounded, project-confined UTF-8 source file.""" + """Return bounded source evidence stored in the pinned index generation.""" with self._connection() as connection: row = connection.execute( - "SELECT node_id, source_path, source_anchor FROM nodes WHERE node_id = ?", + "SELECT node_id, source_path, source_anchor, content FROM nodes WHERE node_id = ?", (node_id,), ).fetchone() if row is None: @@ -518,7 +518,8 @@ class VisualizationIndexSnapshot: """ SELECT logic.logic_id AS node_id, owner.source_path AS source_path, - logic.source_anchor AS source_anchor + logic.source_anchor AS source_anchor, + owner.content AS content FROM logic_nodes AS logic JOIN nodes AS owner ON owner.node_id = logic.owner_node_id WHERE logic.logic_id = ? @@ -533,51 +534,26 @@ class VisualizationIndexSnapshot: "No node has the requested stable ID", node_id=node_id, ) - relative = Path(row["source_path"]) - if relative.is_absolute() or ".." in relative.parts or not relative.parts: - raise DocForgeError("path_escape", "Node source path is unsafe", node_id=node_id) - source = self.project_root / relative - try: - resolved = source.resolve(strict=True) - except OSError as error: + content = row["content"] + if not isinstance(content, str): raise DocForgeError( - "missing_source", - "Node source file is unavailable", - node_id=node_id, - ) from error - if ( - source.is_symlink() - or resolved != source - or not source.is_relative_to(self.project_root) - or not source.is_file() - ): - raise DocForgeError("path_escape", "Node source file is unsafe", node_id=node_id) - if source.stat().st_size > self.max_source_bytes: - raise DocForgeError( - "source_too_large", - "Node source exceeds the configured source limit", + "invalid_index", + "Pinned source evidence is invalid", node_id=node_id, ) - raw = source.read_bytes() + raw = content.encode("utf-8") if len(raw) > self.max_source_bytes: raise DocForgeError( "source_too_large", - "Node source exceeds the configured source limit", + "Pinned source evidence exceeds the configured source limit", node_id=node_id, ) - try: - content = raw.decode("utf-8") - except UnicodeDecodeError as error: - raise DocForgeError( - "invalid_source", - "Node source is not UTF-8", - node_id=node_id, - ) from error return self._result( node_id=node_id, source_path=row["source_path"], source_anchor=row["source_anchor"], content=content, + source_provenance="index_snapshot", snapshot=True, ) diff --git a/src/docforge_renderers/__init__.py b/src/docforge_renderers/__init__.py new file mode 100644 index 0000000..94c4706 --- /dev/null +++ b/src/docforge_renderers/__init__.py @@ -0,0 +1 @@ +"""Capability-isolated renderer implementations for DocForge projection packages.""" diff --git a/src/docforge_renderers/manual.py b/src/docforge_renderers/manual.py new file mode 100644 index 0000000..d54be0f --- /dev/null +++ b/src/docforge_renderers/manual.py @@ -0,0 +1,172 @@ +"""Plan-only renderer for the built-in DocForge manual artifact.""" + +from __future__ import annotations + +import hashlib +import html +import re +from time import perf_counter_ns +from typing import cast + +from markdown_it import MarkdownIt + +from docforge.errors import DocForgeError +from docforge.projection_contract import ( + ProjectionArtifact, + ProjectionPackageV1, + ProjectionReceiptV1, + ProjectionRenderResult, +) + +_TEMPLATE_TOKEN = re.compile(r"{{\s*([a-z_][a-z0-9_]*)\s*}}") +_ALLOWED_TOKENS = frozenset( + { + "docforge_content", + "docforge_project_id", + "docforge_render_identity", + "docforge_title", + "docforge_view_id", + } +) +_ACTIVE_TEMPLATE_CONTENT = re.compile( + r"<\s*(?:script|iframe|object|embed)\b" + r"|\son[a-z0-9_-]+\s*=" + r"|javascript\s*:" + r"|<\s*meta\b[^>]*\bhttp-equiv\s*=\s*[\"']?\s*refresh\b", + re.IGNORECASE, +) + + +class ManualHtmlRenderer: + """Transform one validated path-free package without graph-selection authority.""" + + renderer_id = "generic_html" + + def __init__(self, renderer_version: str) -> None: + self.renderer_version = renderer_version + self.markdown = MarkdownIt("commonmark", {"html": False, "typographer": False}) + + def render( + self, + package: ProjectionPackageV1, + *, + render_identity: str | None = None, + ) -> ProjectionRenderResult: + started = perf_counter_ns() + package = ProjectionPackageV1.from_dict(package.as_dict()) + document = package.document + if package.kind != "manual": + raise DocForgeError("invalid_projection", "Manual renderer requires a manual package") + renderer = cast(dict[str, object], document["renderer"]) + if renderer != { + "renderer_id": self.renderer_id, + "renderer_version": self.renderer_version, + }: + raise DocForgeError("unsupported_renderer", "Manual renderer identity is incompatible") + plan = cast(dict[str, object], document["plan"]) + assets = cast(list[object], document["assets"]) + if len(assets) != 1 or not isinstance(assets[0], dict): + raise DocForgeError("invalid_projection", "Manual template asset is invalid") + asset = cast(dict[str, object], assets[0]) + if ( + set(asset) != {"asset_id", "media_type", "sha256", "text"} + or asset.get("asset_id") != "manual.template" + or asset.get("media_type") != "text/html; charset=utf-8" + or not isinstance(asset.get("text"), str) + ): + raise DocForgeError("invalid_projection", "Manual template asset is invalid") + template = cast(str, asset["text"]) + template_bytes = template.encode("utf-8") + if hashlib.sha256(template_bytes).hexdigest() != asset.get("sha256"): + raise DocForgeError("invalid_projection", "Manual template asset hash is invalid") + if _ACTIVE_TEMPLATE_CONTENT.search(template): + raise DocForgeError( + "invalid_template", + "Render template contains active or executable content", + ) + tokens = _TEMPLATE_TOKEN.findall(template) + unknown = sorted(set(tokens) - _ALLOWED_TOKENS) + remainder = _TEMPLATE_TOKEN.sub("", template) + if unknown or "{{" in remainder or "}}" in remainder: + raise DocForgeError( + "invalid_template", + "Render template contains unsupported tokens", + tokens=unknown, + ) + if tokens.count("docforge_content") != 1: + raise DocForgeError( + "invalid_template", + "Render template must contain docforge_content exactly once", + ) + identity = render_identity or cast(str, plan["plan_id"]) + project = cast(dict[str, object], plan["project"]) + view = cast(dict[str, object], plan["view"]) + replacements = { + "docforge_content": self._content(plan), + "docforge_project_id": html.escape(cast(str, project["project_id"]), quote=True), + "docforge_render_identity": identity, + "docforge_title": html.escape(cast(str, view["title"]), quote=True), + "docforge_view_id": html.escape(cast(str, view["view_id"]), quote=True), + } + rendered = _TEMPLATE_TOKEN.sub(lambda match: replacements[match.group(1)], template) + output = rendered.rstrip().encode("utf-8") + b"\n" + policy = cast(dict[str, object], document["output_policy"]) + maximum = policy.get("max_total_bytes") + if type(maximum) is not int or maximum < 1 or len(output) > maximum: + raise DocForgeError("render_too_large", "Rendered output exceeds the configured limit") + artifact = ProjectionArtifact( + artifact_id="manual.html", + media_type="text/html; charset=utf-8", + content=output, + ) + receipt = ProjectionReceiptV1.create( + kind="manual", + package_id=package.package_id, + plan_id=cast(str, document["plan_id"]), + renderer=dict(renderer), + artifacts=[artifact.evidence()], + diagnostics={"warnings": []}, + timing={"elapsed_ns": perf_counter_ns() - started}, + peak_memory_bytes=None, + ) + return ProjectionRenderResult((artifact,), receipt) + + def _content(self, plan: dict[str, object]) -> str: + navigation = ['") + sections = [*navigation] + for value in cast(list[object], plan["pages"]): + page = cast(dict[str, object], value) + node_id = cast(str, page["node_id"]) + sections.extend( + [ + f'
', + f"

{html.escape(cast(str, page['title']))}

", + '
', + f"
ID
{html.escape(node_id)}
", + f"
Family
{html.escape(cast(str, page['family']))}
", + f"
Status
{html.escape(cast(str, page['status']))}
", + f"
Authority
{html.escape(cast(str, page['authority']))}
", + "
", + f'

{html.escape(cast(str, page["summary"]))}

', + self.markdown.render(cast(str, page["content"])).rstrip(), + ] + ) + relationships = cast(list[object], page["cross_references"]) + if relationships: + sections.append('") + sections.append("
") + return "\n".join(sections) diff --git a/src/docforge_renderers/py.typed b/src/docforge_renderers/py.typed new file mode 100644 index 0000000..e75b43f --- /dev/null +++ b/src/docforge_renderers/py.typed @@ -0,0 +1 @@ +# PEP 561 marker for the typed DocForge renderer package. diff --git a/tests/test_graph_projection.py b/tests/test_graph_projection.py new file mode 100644 index 0000000..d0b516a --- /dev/null +++ b/tests/test_graph_projection.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path +from typing import Any, cast + +from docforge.errors import DocForgeError +from docforge.graph_projection import ( + GraphViewRequestV1, + build_graph_view_plan, +) +from docforge.models import Edge, Limits, Node, ProjectDescriptor, ProjectSnapshot +from docforge.projection_contract import GraphViewPlanV1 + + +def _document(plan: GraphViewPlanV1) -> dict[str, Any]: + return cast(dict[str, Any], plan.as_dict()) + + +def _node( + node_id: str, + *, + title: str | None = None, + family: str = "code", + authority: str = "derived", + status: str = "active", + tags: tuple[str, ...] = (), +) -> Node: + return Node( + node_id=node_id, + title=title or node_id, + family=family, + authority=authority, + status=status, + tags=tags, + summary=f"Summary for {node_id}", + content=f"SECRET SOURCE BODY {node_id}", + source_path=f"/private/source/{node_id}.py", + source_anchor=f"line-{len(node_id)}", + content_hash=(node_id.encode("utf-8").hex() + "0" * 64)[:64], + ) + + +def _snapshot( + root: Path, + nodes: tuple[Node, ...], + edges: tuple[Edge, ...], +) -> ProjectSnapshot: + descriptor = ProjectDescriptor( + schema_version=1, + project_id="graph-project", + title="Graph project", + adapter="generic", + root=root, + descriptor_path=root / ".docforge" / "project.toml", + descriptor_hash="d" * 64, + content_roots=(root / "docs",), + authority_files=(), + cache_root=root / ".docforge" / "cache", + index_path=root / ".docforge" / "cache" / "index.sqlite3", + changeset_root=root / ".docforge" / "changesets", + proposal_writers=(), + render=None, + allowed_relations=tuple(sorted({edge.relation for edge in edges})), + profiles=(), + limits=Limits(), + ) + return ProjectSnapshot( + descriptor=descriptor, + nodes=nodes, + edges=edges, + revision="revision-1", + source_hash="a" * 64, + ) + + +class GraphProjectionTests(unittest.TestCase): + def test_exact_root_plan_is_deterministic_sorted_and_path_free(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + nodes = ( + _node("c", family="docs", tags=("python",)), + _node("a", tags=("python", "callable")), + _node("b", tags=("python",)), + _node("unrelated"), + ) + edges = ( + Edge("b", "calls", "c"), + Edge("a", "calls", "b"), + Edge("unrelated", "calls", "c"), + ) + request = GraphViewRequestV1( + view_id="architecture", + title="Architecture", + root_node_id="a", + depth=2, + max_nodes=10, + max_edges=10, + max_work=100, + ) + first = build_graph_view_plan(_snapshot(root, nodes, edges), request, True) + second = build_graph_view_plan( + _snapshot(root, tuple(reversed(nodes)), tuple(reversed(edges))), + request, + True, + ) + self.assertEqual(first.as_dict(), second.as_dict()) + GraphViewPlanV1.from_dict(first.as_dict()) + document = _document(first) + self.assertEqual( + ["a", "b", "c"], [node["node_id"] for node in document["graph"]["nodes"]] + ) + self.assertEqual( + [ + {"source_id": "a", "relation": "calls", "target_id": "b"}, + {"source_id": "b", "relation": "calls", "target_id": "c"}, + ], + document["graph"]["edges"], + ) + encoded = json.dumps(document, sort_keys=True) + self.assertNotIn(str(root), encoded) + self.assertNotIn("SECRET SOURCE BODY", encoded) + self.assertNotIn("/private/source/", encoded) + self.assertNotIn("line-1", encoded) + self.assertEqual("excluded", document["policy"]["source_paths"]) + self.assertEqual("excluded", document["policy"]["source_bodies"]) + self.assertEqual("allowed", document["policy"]["logic"]) + self.assertEqual("exact_root", document["view"]["scope"]["kind"]) + + def test_lexical_scope_uses_metadata_only_and_closed_filters(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + nodes = ( + _node( + "api.handler", + title="Request handler", + family="code", + authority="derived", + tags=("python", "route"), + ), + _node( + "api.test", + title="Handler proof", + family="test", + authority="approved_plan", + tags=("python", "test"), + ), + replace( + _node("hidden.body", family="code", tags=("python",)), + content="request handler appears only in the forbidden source body", + ), + ) + edges = ( + Edge("api.handler", "tested_by", "api.test"), + Edge("hidden.body", "relates_to", "api.handler"), + ) + request = GraphViewRequestV1( + view_id="routes", + title="Routes", + query="request handler", + families=("code",), + authorities=("derived",), + tags=("python", "route"), + relations=("tested_by",), + max_nodes=10, + max_edges=10, + max_work=100, + ) + plan = build_graph_view_plan(_snapshot(root, nodes, edges), request, False) + document = _document(plan) + self.assertEqual( + ["api.handler"], + [node["node_id"] for node in document["graph"]["nodes"]], + ) + self.assertEqual([], document["graph"]["edges"]) + self.assertEqual( + { + "families": ["code"], + "relations": ["tested_by"], + "authorities": ["derived"], + "statuses": [], + "tags": ["python", "route"], + }, + document["view"]["filters"], + ) + self.assertEqual("lexical", document["view"]["scope"]["kind"]) + + def test_result_and_work_limits_emit_explicit_omissions(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + nodes = tuple(_node(value) for value in ("a", "b", "c", "d")) + edges = ( + Edge("a", "calls", "b"), + Edge("a", "calls", "c"), + Edge("a", "calls", "d"), + Edge("b", "calls", "c"), + ) + result_limited = build_graph_view_plan( + _snapshot(root, nodes, edges), + GraphViewRequestV1( + view_id="limited", + title="Limited", + root_node_id="a", + max_nodes=2, + max_edges=0, + max_work=100, + ), + False, + ) + result_limited = _document(result_limited) + self.assertEqual( + ["a", "b"], [node["node_id"] for node in result_limited["graph"]["nodes"]] + ) + self.assertEqual([], result_limited["graph"]["edges"]) + self.assertEqual( + ["edge_result_limit", "node_result_limit"], + [item["code"] for item in result_limited["omissions"]], + ) + + work_limited = build_graph_view_plan( + _snapshot(root, nodes, edges), + GraphViewRequestV1( + view_id="work", + title="Work", + root_node_id="a", + max_nodes=10, + max_edges=10, + max_work=1, + ), + False, + ) + work_limited = _document(work_limited) + self.assertIn( + "work_limit", + [item["code"] for item in work_limited["omissions"]], + ) + self.assertEqual( + 1, + work_limited["diagnostics"]["examined_work_units"], + ) + self.assertTrue(work_limited["diagnostics"]["truncated"]) + + def test_no_ast_policy_excludes_requested_logic(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + snapshot = _snapshot(root, (_node("a"),), ()) + request = GraphViewRequestV1( + view_id="logic", + title="Logic", + root_node_id="a", + initial_mode="logic", + include_logic=True, + ) + blocked = _document(build_graph_view_plan(snapshot, request, False)) + self.assertEqual("forbidden", blocked["policy"]["logic"]) + self.assertEqual([], blocked["graph"]["logic_projections"]) + self.assertIn( + "logic_forbidden", + [item["code"] for item in blocked["omissions"]], + ) + allowed = _document(build_graph_view_plan(snapshot, request, True)) + self.assertEqual("allowed", allowed["policy"]["logic"]) + self.assertNotIn( + "logic_forbidden", + [item["code"] for item in allowed["omissions"]], + ) + + def test_edge_and_node_filters_constrain_exact_root_bfs(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + nodes = ( + _node("root", family="code", status="active"), + _node("code-child", family="code", status="active"), + _node("doc-child", family="docs", status="active"), + _node("old-child", family="code", status="historical"), + ) + edges = ( + Edge("root", "calls", "code-child"), + Edge("root", "documents", "doc-child"), + Edge("root", "calls", "old-child"), + ) + plan = build_graph_view_plan( + _snapshot(root, nodes, edges), + GraphViewRequestV1( + view_id="filtered", + title="Filtered", + root_node_id="root", + families=("code",), + statuses=("active",), + relations=("calls",), + max_work=100, + ), + False, + ) + plan = _document(plan) + self.assertEqual( + ["code-child", "root"], [node["node_id"] for node in plan["graph"]["nodes"]] + ) + self.assertEqual( + [{"source_id": "root", "relation": "calls", "target_id": "code-child"}], + plan["graph"]["edges"], + ) + + def test_invalid_requests_and_graphs_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + snapshot = _snapshot(root, (_node("a"),), ()) + invalid_requests = ( + GraphViewRequestV1(view_id="v", title="V"), + GraphViewRequestV1( + view_id="v", + title="V", + root_node_id="a", + query="a", + ), + GraphViewRequestV1( + view_id="v", + title="V", + root_node_id="a", + max_nodes=0, + ), + GraphViewRequestV1( + view_id="v", + title="V", + query="***", + ), + GraphViewRequestV1( + view_id="v", + title="V", + root_node_id="a", + families=("code", "code"), + ), + ) + for request in invalid_requests: + with self.subTest(request=request), self.assertRaises(DocForgeError) as error: + build_graph_view_plan(snapshot, request, False) + self.assertEqual("invalid_graph_view_request", error.exception.code) + + with self.assertRaises(DocForgeError) as missing: + build_graph_view_plan( + snapshot, + GraphViewRequestV1( + view_id="v", + title="V", + root_node_id="missing", + ), + False, + ) + self.assertEqual("missing_node", missing.exception.code) + + duplicate = _snapshot(root, (_node("a"), _node("a")), ()) + with self.assertRaises(DocForgeError) as invalid: + build_graph_view_plan( + duplicate, + GraphViewRequestV1( + view_id="v", + title="V", + root_node_id="a", + ), + False, + ) + self.assertEqual("invalid_projection", invalid.exception.code) + + def test_plan_identity_changes_with_generation_request_and_policy(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + snapshot = _snapshot(root, (_node("a"),), ()) + request = GraphViewRequestV1( + view_id="v", + title="V", + root_node_id="a", + ) + base = build_graph_view_plan(snapshot, request, False) + self.assertEqual( + base.plan_id, + build_graph_view_plan(snapshot, request, False).plan_id, + ) + self.assertNotEqual( + base.plan_id, + build_graph_view_plan( + replace(snapshot, source_hash="b" * 64), + request, + False, + ).plan_id, + ) + self.assertNotEqual( + base.plan_id, + build_graph_view_plan( + snapshot, + replace(request, title="Other"), + False, + ).plan_id, + ) + self.assertNotEqual( + base.plan_id, + build_graph_view_plan(snapshot, request, True).plan_id, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_projection_contract.py b/tests/test_projection_contract.py new file mode 100644 index 0000000..882527a --- /dev/null +++ b/tests/test_projection_contract.py @@ -0,0 +1,486 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import os +import shutil +import sqlite3 +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path +from unittest import mock + +from docforge.errors import DocForgeError +from docforge.manual_projection import ( + build_manual_projection_package, + build_manual_render_plan, +) +from docforge.models import Edge +from docforge.project import Project +from docforge.projection_contract import ( + MANUAL_RENDER_PLAN_CONTRACT, + PROJECTION_PACKAGE_CONTRACT, + PROJECTION_RECEIPT_CONTRACT, + ManualRenderPlanV1, + ProjectionArtifact, + ProjectionPackageV1, + ProjectionReceiptV1, + canonical_projection_bytes, + projection_hash, +) +from docforge.render_contract import GenericHtmlRenderer +from docforge_renderers.manual import ManualHtmlRenderer + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests" / "fixtures" + +ALPHA_RENDERER_VERSION = "1+markdown-it-py-4.2.0" +ALPHA_RENDER_IDENTITY = "1c0a49c28ba3b0dabf94be36e75def197dee1be3cb73ac405b09875383c8dc5f" +ALPHA_OUTPUT_HASH = "81656bb89debc7ad1fbe8bc290e9a3ba90664442b17a6d57e908d30d20c47f77" +ALPHA_OUTPUT_BYTES = 2043 + + +class ProjectionContractTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) / "alpha" + shutil.copytree(FIXTURES / "alpha", self.root) + self.project = Project.open(self.root) + self.snapshot = self.project.load() + assert self.snapshot.descriptor.render is not None + self.view = self.snapshot.descriptor.render.views[0] + self.template = self.view.template_path.read_bytes() + self.plan = build_manual_render_plan( + self.snapshot, + self.view, + changeset_hash=None, + ) + self.package = build_manual_projection_package( + self.plan, + self.template, + renderer_id="generic_html", + renderer_version=ALPHA_RENDERER_VERSION, + max_output_bytes=self.snapshot.descriptor.limits.max_render_bytes, + ) + + def test_canonical_identity_is_stable_and_tampering_is_rejected(self) -> None: + self.assertEqual( + b'{"a":"\xc3\xa9","b":1}', + canonical_projection_bytes({"b": 1, "a": "\N{LATIN SMALL LETTER E WITH ACUTE}"}), + ) + self.assertEqual( + projection_hash( + {key: value for key, value in self.plan.as_dict().items() if key != "plan_id"} + ), + self.plan.plan_id, + ) + self.assertEqual( + self.plan.plan_id, + build_manual_render_plan( + self.snapshot, + self.view, + changeset_hash=None, + ).plan_id, + ) + self.assertEqual( + self.package.package_id, + ProjectionPackageV1.from_dict( + json.loads(json.dumps(self.package.as_dict())) + ).package_id, + ) + + tampered_plan = copy.deepcopy(self.plan.as_dict()) + pages = tampered_plan["pages"] + assert isinstance(pages, list) + assert isinstance(pages[0], dict) + pages[0]["title"] = "Tampered title" + with self.assertRaises(DocForgeError) as plan_error: + ManualRenderPlanV1.from_dict(tampered_plan) + self.assertEqual("invalid_projection", plan_error.exception.code) + + tampered_package = copy.deepcopy(self.package.as_dict()) + assets = tampered_package["assets"] + assert isinstance(assets, list) + assert isinstance(assets[0], dict) + assets[0]["text"] = f"{assets[0]['text']}\nTampered" + with self.assertRaises(DocForgeError) as package_error: + ProjectionPackageV1.from_dict(tampered_package) + self.assertEqual("invalid_projection", package_error.exception.code) + + def test_contract_documents_reject_unknown_or_malformed_fields(self) -> None: + plan_with_extra = copy.deepcopy(self.plan.as_dict()) + plan_with_extra["unexpected"] = True + with self.assertRaises(DocForgeError) as extra_plan: + ManualRenderPlanV1.from_dict(plan_with_extra) + self.assertEqual("invalid_projection", extra_plan.exception.code) + + plan_with_foreign_project = copy.deepcopy(self.plan.as_dict()) + project = plan_with_foreign_project["project"] + assert isinstance(project, dict) + project["absolute_root"] = str(self.root) + with self.assertRaises(DocForgeError) as foreign_project: + ManualRenderPlanV1.from_dict(plan_with_foreign_project) + self.assertEqual("invalid_projection", foreign_project.exception.code) + + package_with_extra = copy.deepcopy(self.package.as_dict()) + package_with_extra["unexpected"] = [] + with self.assertRaises(DocForgeError) as extra_package: + ProjectionPackageV1.from_dict(package_with_extra) + self.assertEqual("invalid_projection", extra_package.exception.code) + + result = ManualHtmlRenderer(ALPHA_RENDERER_VERSION).render(self.package) + receipt_with_extra = copy.deepcopy(result.receipt.as_dict()) + receipt_with_extra["artifact_bytes"] = "forbidden" + with self.assertRaises(DocForgeError) as extra_receipt: + ProjectionReceiptV1.from_dict(receipt_with_extra) + self.assertEqual("invalid_projection", extra_receipt.exception.code) + + with self.assertRaises(DocForgeError) as path_artifact: + ProjectionReceiptV1.create( + kind="manual", + package_id=self.package.package_id, + plan_id=self.plan.plan_id, + renderer={ + "renderer_id": "generic_html", + "renderer_version": ALPHA_RENDERER_VERSION, + }, + artifacts=[ + { + "artifact_id": "../manual.html", + "media_type": "text/html", + "sha256": "0" * 64, + "bytes": 1, + } + ], + diagnostics={}, + timing={"elapsed_ns": 0}, + peak_memory_bytes=None, + ) + self.assertEqual("invalid_projection", path_artifact.exception.code) + + def test_projection_package_is_path_free_and_rejects_runtime_references(self) -> None: + serialized = canonical_projection_bytes(self.package.as_dict()) + self.assertNotIn(str(self.root).encode("utf-8"), serialized) + self.assertNotIn(b"source_path", serialized) + self.assertNotIn(b"sqlite", serialized.lower()) + + with self.assertRaises(DocForgeError) as absolute_path: + ProjectionPackageV1.create( + kind="manual", + plan=self.plan, + renderer={ + "renderer_id": "generic_html", + "renderer_version": ALPHA_RENDERER_VERSION, + }, + components=[], + assets=[], + output_policy={ + "artifact_ids": ["manual.html"], + "max_total_bytes": 1000, + "template_path": "/home/example/private-template.html", + }, + ) + self.assertEqual("invalid_projection", absolute_path.exception.code) + + with self.assertRaises(DocForgeError) as database_reference: + ProjectionPackageV1.create( + kind="manual", + plan=self.plan, + renderer={ + "renderer_id": "generic_html", + "renderer_version": ALPHA_RENDERER_VERSION, + }, + components=[], + assets=[], + output_policy={ + "artifact_ids": ["manual.html"], + "max_total_bytes": 1000, + "database": "index.sqlite3", + }, + ) + self.assertEqual("invalid_projection", database_reference.exception.code) + + def test_receipt_attests_artifacts_without_embedding_content(self) -> None: + result = ManualHtmlRenderer(ALPHA_RENDERER_VERSION).render( + self.package, + render_identity=ALPHA_RENDER_IDENTITY, + ) + self.assertEqual(1, len(result.artifacts)) + artifact = result.artifacts[0] + evidence = artifact.evidence() + receipt = result.receipt.as_dict() + + self.assertEqual(self.package.package_id, receipt["package_id"]) + self.assertEqual(self.plan.plan_id, receipt["plan_id"]) + self.assertEqual([evidence], receipt["artifacts"]) + self.assertEqual(PROJECTION_RECEIPT_CONTRACT, receipt["contract"]) + self.assertEqual( + { + "renderer_id": "generic_html", + "renderer_version": ALPHA_RENDERER_VERSION, + }, + receipt["renderer"], + ) + self.assertEqual({"warnings": []}, receipt["diagnostics"]) + self.assertIsNone(receipt["peak_memory_bytes"]) + timing = receipt["timing"] + assert isinstance(timing, dict) + self.assertGreaterEqual(timing["elapsed_ns"], 0) + self.assertNotIn("content", evidence) + self.assertNotIn(artifact.content, canonical_projection_bytes(receipt)) + self.assertEqual( + receipt["receipt_id"], + ProjectionReceiptV1.from_dict(copy.deepcopy(receipt)).receipt_id, + ) + + tampered_receipt = copy.deepcopy(receipt) + artifacts = tampered_receipt["artifacts"] + assert isinstance(artifacts, list) + assert isinstance(artifacts[0], dict) + artifacts[0]["bytes"] = int(artifacts[0]["bytes"]) + 1 + with self.assertRaises(DocForgeError) as tampered: + ProjectionReceiptV1.from_dict(tampered_receipt) + self.assertEqual("invalid_projection", tampered.exception.code) + + def test_manual_plan_is_deterministic_and_preserves_alpha_semantics(self) -> None: + document = self.plan.as_dict() + self.assertEqual(MANUAL_RENDER_PLAN_CONTRACT, document["contract"]) + self.assertIsNone(document["changeset_hash"]) + pages = document["pages"] + navigation = document["navigation"] + search_documents = document["search_documents"] + diagnostics = document["diagnostics"] + assert isinstance(pages, list) + assert isinstance(navigation, list) + assert isinstance(search_documents, list) + assert isinstance(diagnostics, dict) + + self.assertEqual( + ["guide.foundation", "guide.workflow", "proof.validation"], + [page["node_id"] for page in pages], + ) + self.assertEqual( + ["guide.foundation", "guide.workflow", "proof.validation"], + [item["node_id"] for item in navigation], + ) + self.assertEqual( + ["guide.foundation", "guide.workflow", "proof.validation"], + [item["node_id"] for item in search_documents], + ) + self.assertEqual([], diagnostics["orphans"]) + self.assertEqual([], diagnostics["cycles"]) + + page_by_id = {page["node_id"]: page for page in pages} + self.assertEqual( + [ + { + "source_id": "guide.workflow", + "relation": "depends_on", + "target_id": "guide.foundation", + } + ], + page_by_id["guide.foundation"]["backlinks"], + ) + self.assertEqual( + [ + { + "source_id": "guide.workflow", + "relation": "depends_on", + "target_id": "guide.foundation", + } + ], + page_by_id["guide.workflow"]["cross_references"], + ) + self.assertEqual( + [ + { + "source_id": "proof.validation", + "relation": "proves", + "target_id": "guide.workflow", + } + ], + page_by_id["guide.workflow"]["backlinks"], + ) + self.assertEqual( + [ + { + "source_id": "proof.validation", + "relation": "proves", + "target_id": "guide.workflow", + } + ], + page_by_id["proof.validation"]["cross_references"], + ) + self.assertTrue( + all( + page["components"] + == [ + "manual.node-metadata@1", + "manual.summary@1", + "manual.commonmark@1", + "manual.relationships@1", + ] + for page in pages + ) + ) + + proposed = build_manual_render_plan( + self.snapshot, + self.view, + changeset_hash="a" * 64, + ) + self.assertNotEqual(self.plan.plan_id, proposed.plan_id) + self.assertEqual("a" * 64, proposed.as_dict()["changeset_hash"]) + + def test_cycle_orphan_backlink_and_cross_reference_planning(self) -> None: + edges = ( + Edge("guide.foundation", "relates_to", "guide.workflow"), + Edge("guide.workflow", "returns_to", "guide.foundation"), + ) + snapshot = replace(self.snapshot, edges=edges) + first = build_manual_render_plan(snapshot, self.view, changeset_hash=None) + second = build_manual_render_plan(snapshot, self.view, changeset_hash=None) + self.assertEqual(first.plan_id, second.plan_id) + + document = first.as_dict() + diagnostics = document["diagnostics"] + pages = document["pages"] + assert isinstance(diagnostics, dict) + assert isinstance(pages, list) + self.assertEqual(["proof.validation"], diagnostics["orphans"]) + self.assertEqual( + [["guide.foundation", "guide.workflow"]], + diagnostics["cycles"], + ) + + page_by_id = {page["node_id"]: page for page in pages} + foundation = page_by_id["guide.foundation"] + workflow = page_by_id["guide.workflow"] + self.assertEqual( + [ + { + "source_id": "guide.foundation", + "relation": "relates_to", + "target_id": "guide.workflow", + } + ], + foundation["cross_references"], + ) + self.assertEqual( + [ + { + "source_id": "guide.workflow", + "relation": "returns_to", + "target_id": "guide.foundation", + } + ], + foundation["backlinks"], + ) + self.assertEqual( + foundation["cross_references"], + workflow["backlinks"], + ) + self.assertEqual( + foundation["backlinks"], + workflow["cross_references"], + ) + + def test_alpha_compatibility_shim_preserves_legacy_identity_and_bytes(self) -> None: + renderer = GenericHtmlRenderer() + self.assertEqual(ALPHA_RENDERER_VERSION, renderer.renderer_version) + prepared = renderer.prepare( + self.snapshot, + self.view, + self.template, + changeset_hash=None, + ) + + self.assertEqual(ALPHA_RENDER_IDENTITY, prepared.render_identity) + self.assertEqual(ALPHA_OUTPUT_HASH, prepared.output_hash) + self.assertEqual(ALPHA_OUTPUT_BYTES, len(prepared.output)) + self.assertEqual( + ALPHA_OUTPUT_HASH, + hashlib.sha256(prepared.output).hexdigest(), + ) + self.assertEqual(b"", prepared.output.splitlines()[0]) + self.assertTrue(prepared.output.endswith(b"\n")) + self.assertIn( + f'content="{ALPHA_RENDER_IDENTITY}"'.encode(), + prepared.output, + ) + + def test_manual_renderer_rejects_project_provided_active_content(self) -> None: + for active in ( + "{{ docforge_content }}", + '
{{ docforge_content }}
', + '{{ docforge_content }}', + '{{ docforge_content }}', + '{{ docforge_content }}', + ): + with self.subTest(active=active): + package = build_manual_projection_package( + self.plan, + active.encode("utf-8"), + renderer_id="generic_html", + renderer_version=ALPHA_RENDERER_VERSION, + max_output_bytes=self.snapshot.descriptor.limits.max_render_bytes, + ) + with self.assertRaises(DocForgeError) as rejected: + ManualHtmlRenderer(ALPHA_RENDERER_VERSION).render(package) + self.assertEqual("invalid_template", rejected.exception.code) + + def test_manual_renderer_has_no_project_sqlite_or_path_write_capability(self) -> None: + renderer = ManualHtmlRenderer(ALPHA_RENDERER_VERSION) + forbidden = AssertionError("manual renderer crossed its capability boundary") + with ( + mock.patch.object(Project, "open", side_effect=forbidden), + mock.patch.object(Project, "load", side_effect=forbidden), + mock.patch.object(sqlite3, "connect", side_effect=forbidden), + mock.patch.object(Path, "write_bytes", side_effect=forbidden), + mock.patch.object(Path, "write_text", side_effect=forbidden), + mock.patch.object(Path, "mkdir", side_effect=forbidden), + mock.patch.object(Path, "touch", side_effect=forbidden), + mock.patch.object(Path, "unlink", side_effect=forbidden), + mock.patch.object(Path, "rename", side_effect=forbidden), + mock.patch.object(Path, "replace", side_effect=forbidden), + mock.patch.object(os, "mkdir", side_effect=forbidden), + mock.patch.object(os, "makedirs", side_effect=forbidden), + mock.patch.object(os, "rename", side_effect=forbidden), + mock.patch.object(os, "replace", side_effect=forbidden), + mock.patch.object(os, "unlink", side_effect=forbidden), + ): + result = renderer.render( + self.package, + render_identity=ALPHA_RENDER_IDENTITY, + ) + + self.assertEqual(1, len(result.artifacts)) + self.assertEqual("manual.html", result.artifacts[0].artifact_id) + self.assertEqual(ALPHA_OUTPUT_HASH, result.artifacts[0].evidence()["sha256"]) + + def test_projection_artifact_evidence_is_canonical_and_content_free(self) -> None: + artifact = ProjectionArtifact( + artifact_id="manual.html", + media_type="text/html; charset=utf-8", + content=b"manual bytes", + ) + self.assertEqual( + { + "artifact_id": "manual.html", + "media_type": "text/html; charset=utf-8", + "sha256": hashlib.sha256(b"manual bytes").hexdigest(), + "bytes": len(b"manual bytes"), + }, + artifact.evidence(), + ) + self.assertEqual( + PROJECTION_PACKAGE_CONTRACT, + self.package.as_dict()["contract"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_projection_schemas.py b/tests/test_projection_schemas.py new file mode 100644 index 0000000..823ad48 --- /dev/null +++ b/tests/test_projection_schemas.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import copy +import json +import unittest +from pathlib import Path + +from jsonschema import Draft202012Validator + +from docforge.errors import DocForgeError +from docforge.projection_contract import ( + GraphViewPlanV1, + ManualRenderPlanV1, + ProjectionPackageV1, + ProjectionReceiptV1, +) + +ROOT = Path(__file__).resolve().parents[1] +SCHEMAS = ROOT / "schemas" + + +class ProjectionSchemaTests(unittest.TestCase): + @staticmethod + def schema(name: str) -> dict[str, object]: + return json.loads((SCHEMAS / name).read_text(encoding="utf-8")) + + @classmethod + def validator(cls, name: str) -> Draft202012Validator: + return Draft202012Validator(cls.schema(name)) + + @staticmethod + def project_identity() -> dict[str, object]: + return { + "project_id": "schema-fixture", + "project_root_fingerprint": "a" * 16, + "adapter": "generic", + "revision": "fixture-revision", + "source_hash": "b" * 64, + } + + @classmethod + def manual_plan(cls) -> ManualRenderPlanV1: + return ManualRenderPlanV1.create( + { + "project": cls.project_identity(), + "view": { + "view_id": "manual", + "title": "Schema Manual", + "families": ["guide"], + "renderer": "generic_html", + }, + "changeset_hash": None, + "pages": [ + { + "node_id": "guide.schema", + "title": "Projection schema", + "family": "guide", + "authority": "authoritative", + "status": "approved", + "tags": ["schema"], + "summary": "Defines the projection schema fixture.", + "content": "The schema fixture is deterministic.", + "content_hash": "c" * 64, + "components": ["manual.commonmark@1"], + "breadcrumbs": [], + "cross_references": [], + "backlinks": [], + } + ], + "navigation": [ + { + "node_id": "guide.schema", + "title": "Projection schema", + } + ], + "search_documents": [ + { + "node_id": "guide.schema", + "title": "Projection schema", + "summary": "Defines the projection schema fixture.", + "family": "guide", + "status": "approved", + "tags": ["schema"], + } + ], + "diagnostics": {"orphans": ["guide.schema"], "cycles": []}, + } + ) + + @classmethod + def graph_plan(cls) -> GraphViewPlanV1: + return GraphViewPlanV1.create( + { + "project": cls.project_identity(), + "view": { + "view_id": "portable", + "title": "Portable graph", + "initial_mode": "nodes", + "scope": { + "kind": "exact_root", + "root_node_id": "guide.schema", + "depth": 2, + }, + "filters": { + "families": [], + "relations": [], + "authorities": [], + "statuses": [], + "tags": [], + }, + "detail_fields": [ + "node_id", + "title", + "family", + "authority", + "status", + "tags", + "summary", + "content_hash", + ], + }, + "bounds": { + "depth": 2, + "max_nodes": 100, + "max_edges": 400, + "max_work": 100000, + }, + "policy": { + "visibility": "selected_graph_only", + "source_paths": "excluded", + "source_bodies": "excluded", + "database_queries": "forbidden", + "executable_content": "forbidden", + "logic": "forbidden", + "logic_requested": False, + }, + "graph": { + "root_node_id": "guide.schema", + "nodes": [], + "edges": [], + "logic_projections": [], + }, + "omissions": [], + "diagnostics": { + "selection": "exact_root", + "returned_nodes": 0, + "returned_edges": 0, + "examined_work_units": 0, + "truncated": False, + "ordering": "node_id;source_id,relation,target_id", + }, + } + ) + + @classmethod + def package( + cls, + plan: ManualRenderPlanV1 | GraphViewPlanV1 | None = None, + ) -> ProjectionPackageV1: + selected = plan or cls.manual_plan() + kind = "manual" if isinstance(selected, ManualRenderPlanV1) else "graph" + return ProjectionPackageV1.create( + kind=kind, + plan=selected, + renderer={ + "renderer_id": "generic_html", + "renderer_version": "1", + }, + components=[{"component_id": "projection.document@1"}], + assets=[ + { + "asset_id": "projection.template", + "media_type": "text/plain; charset=utf-8", + "sha256": "d" * 64, + "text": "fixture", + } + ], + output_policy={ + "artifact_ids": ["projection.html"], + "max_total_bytes": 1000000, + }, + ) + + @classmethod + def receipt(cls) -> ProjectionReceiptV1: + package = cls.package() + return ProjectionReceiptV1.create( + kind="manual", + package_id=package.package_id, + plan_id=package.document["plan_id"], # type: ignore[arg-type] + renderer={ + "renderer_id": "generic_html", + "renderer_version": "1", + }, + artifacts=[ + { + "artifact_id": "manual.html", + "media_type": "text/html; charset=utf-8", + "sha256": "e" * 64, + "bytes": 123, + } + ], + diagnostics={"warnings": []}, + timing={"elapsed_ns": 123456}, + peak_memory_bytes=None, + ) + + def test_schemas_are_valid_and_accept_current_documents(self) -> None: + documents = { + "manual-render-plan.schema.json": self.manual_plan().as_dict(), + "graph-view-plan.schema.json": self.graph_plan().as_dict(), + "projection-package.schema.json": self.package().as_dict(), + "projection-receipt.schema.json": self.receipt().as_dict(), + } + for name, document in documents.items(): + with self.subTest(schema=name): + schema = self.schema(name) + Draft202012Validator.check_schema(schema) + Draft202012Validator(schema).validate(document) + + graph_package = self.package(self.graph_plan()).as_dict() + self.validator("projection-package.schema.json").validate(graph_package) + + def test_unknown_fields_are_rejected_at_contract_boundaries(self) -> None: + cases = ( + ( + "manual-render-plan.schema.json", + self.manual_plan().as_dict(), + ), + ( + "graph-view-plan.schema.json", + self.graph_plan().as_dict(), + ), + ( + "projection-package.schema.json", + self.package().as_dict(), + ), + ( + "projection-receipt.schema.json", + self.receipt().as_dict(), + ), + ) + for name, document in cases: + with self.subTest(schema=name): + document["unexpected"] = True + self.assertFalse(self.validator(name).is_valid(document)) + + manual = self.manual_plan().as_dict() + manual["pages"][0]["unexpected"] = True # type: ignore[index] + self.assertFalse(self.validator("manual-render-plan.schema.json").is_valid(manual)) + + package = self.package().as_dict() + package["assets"][0]["path"] = "/tmp/escape" # type: ignore[index] + self.assertFalse(self.validator("projection-package.schema.json").is_valid(package)) + + receipt = self.receipt().as_dict() + receipt["artifacts"][0]["content"] = "not receipt evidence" # type: ignore[index] + self.assertFalse(self.validator("projection-receipt.schema.json").is_valid(receipt)) + + def test_obviously_malformed_identities_and_structures_are_rejected(self) -> None: + manual = self.manual_plan().as_dict() + manual["plan_id"] = "not-a-sha256" + self.assertFalse(self.validator("manual-render-plan.schema.json").is_valid(manual)) + + graph = self.graph_plan().as_dict() + graph["project"]["project_root_fingerprint"] = "wrong" # type: ignore[index] + self.assertFalse(self.validator("graph-view-plan.schema.json").is_valid(graph)) + graph = self.graph_plan().as_dict() + graph["bounds"] = -1 + self.assertFalse(self.validator("graph-view-plan.schema.json").is_valid(graph)) + + package = self.package().as_dict() + package["assets"][0].pop("sha256") # type: ignore[index] + self.assertFalse(self.validator("projection-package.schema.json").is_valid(package)) + package = self.package().as_dict() + package["output_policy"]["max_total_bytes"] = 0 # type: ignore[index] + self.assertFalse(self.validator("projection-package.schema.json").is_valid(package)) + package = self.package().as_dict() + package["kind"] = "graph" + self.assertFalse(self.validator("projection-package.schema.json").is_valid(package)) + + receipt = self.receipt().as_dict() + receipt["artifacts"][0]["artifact_id"] = "../manual.html" # type: ignore[index] + self.assertFalse(self.validator("projection-receipt.schema.json").is_valid(receipt)) + receipt = self.receipt().as_dict() + receipt["artifacts"][0]["bytes"] = -1 # type: ignore[index] + self.assertFalse(self.validator("projection-receipt.schema.json").is_valid(receipt)) + receipt = self.receipt().as_dict() + receipt["peak_memory_bytes"] = True + self.assertFalse(self.validator("projection-receipt.schema.json").is_valid(receipt)) + + def test_assets_and_artifacts_enforce_fixed_collection_bounds(self) -> None: + package = self.package().as_dict() + package["assets"] = [copy.deepcopy(package["assets"][0]) for _ in range(33)] # type: ignore[index] + self.assertFalse(self.validator("projection-package.schema.json").is_valid(package)) + + receipt = self.receipt().as_dict() + receipt["artifacts"] = [ + copy.deepcopy(receipt["artifacts"][0]) + for _ in range(33) # type: ignore[index] + ] + self.assertFalse(self.validator("projection-receipt.schema.json").is_valid(receipt)) + + def test_canonical_identity_equality_remains_a_runtime_check(self) -> None: + document = self.manual_plan().as_dict() + document["plan_id"] = "f" * 64 + self.validator("manual-render-plan.schema.json").validate(document) + with self.assertRaises(DocForgeError) as raised: + ManualRenderPlanV1.from_dict(document) + self.assertEqual("invalid_projection", raised.exception.code) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index e153b2c..3e3a426 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -64,6 +64,14 @@ PUBLIC_IMPORTS = { "docforge.client_config": ("generate_client_configuration",), "docforge.doctor": ("run_doctor",), "docforge.index": ("ProjectIndex",), + "docforge.graph_projection": ( + "GraphViewRequestV1", + "build_graph_view_plan", + ), + "docforge.manual_projection": ( + "build_manual_projection_package", + "build_manual_render_plan", + ), "docforge.mcp_server": ( "create_project_server", "create_read_only_server", @@ -84,6 +92,16 @@ PUBLIC_IMPORTS = { "capability_mode", "compose_effective_policy", ), + "docforge.projection_contract": ( + "GraphViewPlanV1", + "ManualRenderPlanV1", + "ProjectionArtifact", + "ProjectionPackageV1", + "ProjectionReceiptV1", + "ProjectionRenderResult", + "canonical_projection_bytes", + "projection_hash", + ), "docforge.retrieval": ( "ContextCapsuleV1", "RetrievalPlanV1", @@ -97,6 +115,7 @@ PUBLIC_IMPORTS = { "Renderer", "renderer_for", ), + "docforge_renderers.manual": ("ManualHtmlRenderer",), } EXPECTED_ENTRY_POINTS = { diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 0c2aeb9..269dbe4 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -386,6 +386,25 @@ class VisualizationTests(unittest.TestCase): with self.assertRaisesRegex(DocForgeError, "category is unsupported"): snapshot.filter_nodes(category="relation", value="depends_on", limit=2) + def test_snapshot_source_never_mixes_pinned_graph_with_newer_canonical_text(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture("alpha", Path(directory)) + index = ProjectIndex(Project.open(root)) + index.build() + snapshot = VisualizationIndexSnapshot(index, index.check()) + before = snapshot.source("guide.workflow") + + source = root / "docs/content/workflow.md" + source.write_text( + source.read_text(encoding="utf-8") + "\nNewer unindexed source text.\n", + encoding="utf-8", + ) + after = snapshot.source("guide.workflow") + + self.assertEqual(before["content"], after["content"]) + self.assertNotIn("Newer unindexed source text", after["content"]) + self.assertEqual("index_snapshot", after["source_provenance"]) + def test_flow_reverses_imports_into_a_complete_structural_path(self) -> None: with tempfile.TemporaryDirectory() as directory: root = self.copy_fixture("alpha", Path(directory)) From 1134c2d375b57eb798cb057cf00e126e421148eb Mon Sep 17 00:00:00 2001 From: Andraxion Date: Wed, 29 Jul 2026 11:37:33 -0400 Subject: [PATCH 2/7] Add durable portable graph publication --- Makefile | 2 + schemas/project.schema.json | 76 +++ schemas/result.schema.json | 3 + src/docforge/_fs_safety.py | 177 +++++++ src/docforge/cli.py | 13 + src/docforge/graph_projection.py | 46 +- src/docforge/graph_render_config.py | 285 ++++++++++ src/docforge/graph_rendering.py | 793 ++++++++++++++++++++++++++++ src/docforge/models.py | 30 +- src/docforge/project.py | 15 + src/docforge/projection_contract.py | 40 +- src/docforge/telemetry.py | 3 + src/docforge_renderers/graph.py | 321 +++++++++++ tests/test_cli.py | 43 ++ tests/test_graph_publication.py | 344 ++++++++++++ tests/test_graph_rendering.py | 284 ++++++++++ tests/test_projection_schemas.py | 10 + tests/test_public_contract.py | 6 + tools/check_web_assets.py | 64 +++ 19 files changed, 2542 insertions(+), 13 deletions(-) create mode 100644 src/docforge/graph_render_config.py create mode 100644 src/docforge/graph_rendering.py create mode 100644 src/docforge_renderers/graph.py create mode 100644 tests/test_graph_publication.py create mode 100644 tests/test_graph_rendering.py diff --git a/Makefile b/Makefile index 4ff34a0..a3334df 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,8 @@ contract: tests/test_projection_contract.py \ tests/test_projection_schemas.py \ tests/test_graph_projection.py \ + tests/test_graph_rendering.py \ + tests/test_graph_publication.py \ tests/test_observability.py::TelemetryContractTests::test_schema_fixed_names_match_the_implementation \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_policy_rejects_logic_publication \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters \ diff --git a/schemas/project.schema.json b/schemas/project.schema.json index 4d7f046..7d9a64f 100644 --- a/schemas/project.schema.json +++ b/schemas/project.schema.json @@ -74,6 +74,82 @@ }, "additionalProperties": false }, + "graph_render": { + "type": "object", + "required": ["output_root", "views"], + "properties": { + "output_root": { "$ref": "#/$defs/relativePath" }, + "views": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "renderer", "output", "title"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{1,127}$" }, + "renderer": { "const": "portable_graph_html" }, + "output": { + "allOf": [ + { "$ref": "#/$defs/relativePath" }, + { "pattern": "\\.html$" } + ] + }, + "title": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "root": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "query": { "type": "string", "minLength": 1, "maxLength": 10000 }, + "initial_mode": { "enum": ["nodes", "flow", "web"] }, + "depth": { "type": "integer", "minimum": 1, "maximum": 32 }, + "max_nodes": { "type": "integer", "minimum": 1, "maximum": 1000 }, + "max_edges": { "type": "integer", "minimum": 0, "maximum": 4000 }, + "max_work": { "type": "integer", "minimum": 1, "maximum": 1000000 }, + "families": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 1024 } + }, + "relations": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 1024 } + }, + "authorities": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 1024 } + }, + "statuses": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 1024 } + }, + "tags": { + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 1024 } + }, + "include_logic": { "const": false } + }, + "oneOf": [ + { + "required": ["root"], + "not": { "required": ["query"] } + }, + { + "required": ["query"], + "not": { "required": ["root"] } + } + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, "graph": { "type": "object", "required": ["allowed_relations"], diff --git a/schemas/result.schema.json b/schemas/result.schema.json index aac219b..ae0ea04 100644 --- a/schemas/result.schema.json +++ b/schemas/result.schema.json @@ -58,6 +58,9 @@ "cli.impact", "cli.context", "cli.generation-diff", + "cli.graph-plan", + "cli.graph-render", + "cli.graph-render-status", "cli.configure", "cli.doctor", "cli.render", diff --git a/src/docforge/_fs_safety.py b/src/docforge/_fs_safety.py index e2c8c94..6795a77 100644 --- a/src/docforge/_fs_safety.py +++ b/src/docforge/_fs_safety.py @@ -3,7 +3,10 @@ from __future__ import annotations import os +import secrets import stat +from collections.abc import Callable +from contextlib import suppress from pathlib import Path from .errors import DocForgeError @@ -69,3 +72,177 @@ def require_bound_directory(path: Path, directory_fd: int) -> None: "path_escape", "Derived cache root disappeared during publication", ) from error + + +def open_confined_directory(root: Path, path: Path, *, create: bool) -> int: + """Open a descendant directory through stable no-follow directory descriptors.""" + + try: + unsafe = ( + root.is_symlink() + or root.resolve(strict=True) != root + or not path.is_relative_to(root) + or path == root + ) + except OSError as error: + raise DocForgeError("path_escape", "Project root cannot be resolved safely") from error + if unsafe: + raise DocForgeError("path_escape", "Derived output directory is not confined") + relative = path.relative_to(root) + try: + descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + except OSError as error: + raise DocForgeError("path_escape", "Project root cannot be opened safely") from error + try: + for part in relative.parts: + if part in {"", ".", ".."}: + raise DocForgeError("path_escape", "Derived output directory is not confined") + if create: + try: + os.mkdir(part, mode=0o700, dir_fd=descriptor) + except FileExistsError: + pass + except OSError as error: + raise DocForgeError( + "publication_failure", + "Derived output directory could not be created", + ) from error + try: + next_descriptor = os.open( + part, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=descriptor, + ) + except OSError as error: + raise DocForgeError( + "path_escape", + "Derived output directory is missing or unsafe", + ) from error + os.close(descriptor) + descriptor = next_descriptor + require_bound_directory(path, descriptor) + return descriptor + except Exception: + os.close(descriptor) + raise + + +def safe_file_identity_at( + directory: Path, + directory_fd: int, + name: str, +) -> dict[str, object] | None: + """Return one no-follow regular-file identity relative to a bound directory.""" + + del directory + if not name or "/" in name or name in {".", ".."}: + raise DocForgeError("path_escape", "Derived artifact name is unsafe") + try: + current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except FileNotFoundError: + return None + except OSError as error: + raise DocForgeError("path_escape", "Derived artifact cannot be inspected") from error + if not stat.S_ISREG(current.st_mode): + raise DocForgeError("path_escape", "Derived artifact is not a safe regular file") + return { + "path": name, + "device": current.st_dev, + "inode": current.st_ino, + "mode": current.st_mode, + "size": current.st_size, + "mtime_ns": current.st_mtime_ns, + "ctime_ns": current.st_ctime_ns, + } + + +def read_bounded_file_at( + directory_fd: int, + name: str, + maximum_bytes: int, +) -> bytes | None: + """Read one regular file through a bound directory without following links.""" + + try: + descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory_fd) + except FileNotFoundError: + return None + except OSError as error: + raise DocForgeError("path_escape", "Derived artifact cannot be opened safely") from error + with os.fdopen(descriptor, "rb") as handle: + current = os.fstat(handle.fileno()) + if not stat.S_ISREG(current.st_mode) or current.st_size > maximum_bytes: + raise DocForgeError("invalid_projection", "Derived artifact is invalid or oversized") + content = handle.read(maximum_bytes + 1) + if len(content) > maximum_bytes: + raise DocForgeError("invalid_projection", "Derived artifact is oversized") + return content + + +def atomic_replace_bytes_at( + path: Path, + directory_fd: int, + name: str, + content: bytes, + *, + verify: Callable[[], None], +) -> dict[str, object]: + """Durably replace one file inside an already bound directory.""" + + if not name or "/" in name or name in {".", ".."}: + raise DocForgeError("path_escape", "Derived artifact name is unsafe") + existing = safe_file_identity_at(path, directory_fd, name) + del existing + temporary = f".docforge-projection-{secrets.token_hex(12)}" + descriptor: int | None = None + committed = False + try: + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + dir_fd=directory_fd, + ) + with os.fdopen(descriptor, "wb") as handle: + descriptor = None + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + verify() + require_bound_directory(path, directory_fd) + os.replace( + temporary, + name, + src_dir_fd=directory_fd, + dst_dir_fd=directory_fd, + ) + committed = True + os.fsync(directory_fd) + identity = safe_file_identity_at(path, directory_fd, name) + if identity is None: + raise DocForgeError( + "publication_failure", + "Derived artifact disappeared after publication", + mutation_committed=True, + ) + return identity + except DocForgeError as error: + if committed: + raise DocForgeError( + "publication_failure", + "Derived artifact was replaced but final publication verification failed", + mutation_committed=True, + cause=error.code, + ) from error + raise + except OSError as error: + raise DocForgeError( + "publication_failure", + "Derived artifact publication failed", + mutation_committed=committed, + ) from error + finally: + if descriptor is not None: + os.close(descriptor) + with suppress(OSError): + os.unlink(temporary, dir_fd=directory_fd) diff --git a/src/docforge/cli.py b/src/docforge/cli.py index 35b55d2..dde293e 100644 --- a/src/docforge/cli.py +++ b/src/docforge/cli.py @@ -13,6 +13,7 @@ from .client_config import CLIENT_NAMES, generate_client_configuration from .context import compile_context from .doctor import run_doctor from .errors import DocForgeError +from .graph_rendering import GraphRenderService from .index import ProjectIndex from .onboarding import assess_project, scaffold_project from .project import Project, project_root_fingerprint @@ -95,6 +96,12 @@ def _parser() -> argparse.ArgumentParser: render_status = commands.add_parser("render-status") render_status.add_argument("view_id", nargs="?") render_status.add_argument("--deep", action="store_true") + graph_plan = commands.add_parser("graph-plan") + graph_plan.add_argument("view_id") + graph_render = commands.add_parser("graph-render") + graph_render.add_argument("view_id") + graph_render_status = commands.add_parser("graph-render-status") + graph_render_status.add_argument("view_id", nargs="?") preview = commands.add_parser("preview") preview.add_argument("changeset_id") preview.add_argument("view_id") @@ -258,6 +265,12 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]: if arguments.deep else rendering.status(arguments.view_id) ) + if arguments.command == "graph-plan": + return GraphRenderService(project).plan(arguments.view_id) + if arguments.command == "graph-render": + return GraphRenderService(project).render(arguments.view_id) + if arguments.command == "graph-render-status": + return GraphRenderService(project).status(arguments.view_id) if arguments.command == "preview": return RenderService(project).preview(arguments.changeset_id, arguments.view_id) if arguments.command == "apply": diff --git a/src/docforge/graph_projection.py b/src/docforge/graph_projection.py index d4a05c3..d716389 100644 --- a/src/docforge/graph_projection.py +++ b/src/docforge/graph_projection.py @@ -9,18 +9,20 @@ from typing import Literal, cast from .errors import DocForgeError from .models import Edge, Node, ProjectSnapshot from .project import project_root_fingerprint -from .projection_contract import GraphViewPlanV1 +from .projection_contract import ( + MAX_GRAPH_VIEW_DEPTH, + MAX_GRAPH_VIEW_EDGES, + MAX_GRAPH_VIEW_FILTERS, + MAX_GRAPH_VIEW_NODES, + MAX_GRAPH_VIEW_QUERY_CHARS, + MAX_GRAPH_VIEW_STRING_CHARS, + MAX_GRAPH_VIEW_WORK, + GraphViewPlanV1, + ProjectionPackageV1, +) GraphViewMode = Literal["nodes", "flow", "web", "logic"] -MAX_GRAPH_VIEW_DEPTH = 32 -MAX_GRAPH_VIEW_NODES = 1_000 -MAX_GRAPH_VIEW_EDGES = 4_000 -MAX_GRAPH_VIEW_WORK = 1_000_000 -MAX_GRAPH_VIEW_FILTERS = 64 -MAX_GRAPH_VIEW_STRING_CHARS = 1_024 -MAX_GRAPH_VIEW_QUERY_CHARS = 10_000 - _QUERY_TOKEN = re.compile(r"\w+", re.UNICODE) _DETAIL_FIELDS = ( "node_id", @@ -495,3 +497,29 @@ def build_graph_view_plan( }, } ) + + +def build_graph_projection_package( + plan: GraphViewPlanV1, + *, + renderer_id: str, + renderer_version: str, + max_output_bytes: int, +) -> ProjectionPackageV1: + """Bind a graph plan to the fixed portable renderer without adding runtime authority.""" + + return ProjectionPackageV1.create( + kind="graph", + plan=plan, + renderer={"renderer_id": renderer_id, "renderer_version": renderer_version}, + components=[ + {"component_id": "graph.portable-document@1"}, + {"component_id": "graph.accessible-list@1"}, + {"component_id": "graph.relationship-table@1"}, + ], + assets=[], + output_policy={ + "artifact_ids": ["portable-graph.html"], + "max_total_bytes": max_output_bytes, + }, + ) diff --git a/src/docforge/graph_render_config.py b/src/docforge/graph_render_config.py new file mode 100644 index 0000000..dfd3ade --- /dev/null +++ b/src/docforge/graph_render_config.py @@ -0,0 +1,285 @@ +"""Strict parsing and confinement for optional portable graph artifacts.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal, cast + +from .config_validation import ( + ID_PATTERN, + confined_path, + positive_int, + require_string, + string_list, +) +from .errors import DocForgeError +from .models import GraphRenderConfig, GraphRenderView, Limits, RenderConfig +from .projection_contract import ( + MAX_GRAPH_VIEW_DEPTH, + MAX_GRAPH_VIEW_EDGES, + MAX_GRAPH_VIEW_FILTERS, + MAX_GRAPH_VIEW_NODES, + MAX_GRAPH_VIEW_QUERY_CHARS, + MAX_GRAPH_VIEW_STRING_CHARS, + MAX_GRAPH_VIEW_WORK, +) + +_CONFIG_KEYS = frozenset({"output_root", "views"}) +_VIEW_KEYS = frozenset( + { + "id", + "renderer", + "output", + "title", + "root", + "query", + "initial_mode", + "depth", + "max_nodes", + "max_edges", + "max_work", + "families", + "relations", + "authorities", + "statuses", + "tags", + "include_logic", + } +) +_MODES = frozenset({"nodes", "flow", "web"}) + + +def _overlaps(first: Path, second: Path) -> bool: + return first == second or first.is_relative_to(second) or second.is_relative_to(first) + + +def _optional_string(document: dict[str, object], key: str, source: Path) -> str | None: + if key not in document: + return None + return require_string(document, key, source) + + +def _bounded_string( + document: dict[str, object], + key: str, + source: Path, + *, + maximum: int, +) -> str: + value = require_string(document, key, source) + if len(value) > maximum: + raise DocForgeError("invalid_config", f"{key} exceeds its fixed character limit") + return value + + +def _bounded_strings( + value: object, + *, + key: str, + source: Path, +) -> tuple[str, ...]: + values = string_list(value, key=key, source=source) + if len(values) > MAX_GRAPH_VIEW_FILTERS or any( + len(item) > MAX_GRAPH_VIEW_STRING_CHARS for item in values + ): + raise DocForgeError("invalid_config", f"{key} exceeds its fixed bounds") + return values + + +def load_graph_render_config( + root: Path, + document: object, + *, + descriptor_path: Path, + content_roots: tuple[Path, ...], + authority_files: tuple[Path, ...], + cache_root: Path, + index_path: Path, + changeset_root: Path, + manual_render: RenderConfig | None, + limits: Limits, +) -> GraphRenderConfig | None: + if document is None: + return None + if not isinstance(document, dict): + raise DocForgeError("invalid_config", "graph_render must be a table") + document = cast(dict[str, object], document) + unknown = sorted(set(document) - _CONFIG_KEYS) + if unknown: + raise DocForgeError("invalid_config", "graph_render has unknown fields", fields=unknown) + output_root = confined_path( + root, + document.get("output_root"), + field="graph_render.output_root", + must_exist=False, + ) + protected = [*content_roots, cache_root, changeset_root] + if manual_render is not None: + protected.extend((manual_render.template_root, manual_render.preview_root)) + protected.extend(view.output_path for view in manual_render.views) + if any(_overlaps(output_root, path) for path in protected): + raise DocForgeError( + "invalid_config", + "Portable graph output must not overlap canonical or other derived roots", + ) + protected_files = (descriptor_path, index_path, *authority_files) + if any(path == output_root or path.is_relative_to(output_root) for path in protected_files): + raise DocForgeError( + "invalid_config", + "Portable graph output overlaps a protected project path", + ) + view_values_value = document.get("views") + if not isinstance(view_values_value, list) or not view_values_value: + raise DocForgeError( + "invalid_config", + "graph_render.views must contain at least one view", + ) + view_values = cast(list[object], view_values_value) + if len(view_values) > limits.max_render_views: + raise DocForgeError("invalid_config", "graph_render.views exceeds the configured limit") + views: list[GraphRenderView] = [] + view_ids: set[str] = set() + outputs: set[Path] = set() + for value in view_values: + if not isinstance(value, dict): + raise DocForgeError("invalid_config", "Each portable graph view must be a table") + view = cast(dict[str, object], value) + unknown_view = sorted(set(view) - _VIEW_KEYS) + if unknown_view: + raise DocForgeError( + "invalid_config", + "Portable graph view has unknown fields", + fields=unknown_view, + ) + view_id = require_string(view, "id", descriptor_path) + if ID_PATTERN.fullmatch(view_id) is None or view_id in view_ids: + raise DocForgeError( + "invalid_config", + "Portable graph view ID is invalid or duplicated", + id=view_id, + ) + view_ids.add(view_id) + renderer = require_string(view, "renderer", descriptor_path) + if renderer != "portable_graph_html": + raise DocForgeError( + "unsupported_renderer", + "Portable graph view names an unsupported built-in renderer", + renderer=renderer, + ) + output = confined_path( + output_root, + view.get("output"), + field="graph_render.view.output", + must_exist=False, + ) + if output.suffix != ".html" or output in outputs: + raise DocForgeError( + "invalid_config", + "Portable graph outputs must be unique HTML files", + ) + outputs.add(output) + root_node_id = _optional_string(view, "root", descriptor_path) + query = _optional_string(view, "query", descriptor_path) + if (root_node_id is None) == (query is None): + raise DocForgeError( + "invalid_config", + "Portable graph view requires exactly one root or query", + ) + if root_node_id is not None and len(root_node_id) > MAX_GRAPH_VIEW_STRING_CHARS: + raise DocForgeError("invalid_config", "Portable graph root exceeds its fixed limit") + if query is not None and len(query) > MAX_GRAPH_VIEW_QUERY_CHARS: + raise DocForgeError("invalid_config", "Portable graph query exceeds its fixed limit") + initial_mode_value = view.get("initial_mode", "nodes") + if not isinstance(initial_mode_value, str): + raise DocForgeError( + "invalid_config", + "Portable graph initial mode is unsupported", + ) + initial_mode = cast( + Literal["nodes", "flow", "web", "logic"], + initial_mode_value, + ) + if initial_mode not in _MODES: + raise DocForgeError( + "invalid_config", + "Portable graph initial mode is unsupported", + ) + depth = positive_int(view.get("depth", 1), "graph_render.view.depth") + max_nodes = positive_int(view.get("max_nodes", 100), "graph_render.view.max_nodes") + max_edges = positive_int( + view.get("max_edges", 400), + "graph_render.view.max_edges", + allow_zero=True, + ) + max_work = positive_int(view.get("max_work", 100_000), "graph_render.view.max_work") + if ( + depth > min(limits.max_traversal_depth, MAX_GRAPH_VIEW_DEPTH) + or max_nodes > min(limits.max_nodes, MAX_GRAPH_VIEW_NODES) + or max_edges > MAX_GRAPH_VIEW_EDGES + or max_work > MAX_GRAPH_VIEW_WORK + ): + raise DocForgeError( + "invalid_config", + "Portable graph view exceeds project or fixed safety limits", + ) + include_logic = view.get("include_logic", False) + if type(include_logic) is not bool: + raise DocForgeError( + "invalid_config", + "Portable graph include_logic must be Boolean", + ) + if include_logic: + raise DocForgeError( + "unsupported_renderer", + "Portable graph renderer version 1 does not support Logic projections", + ) + views.append( + GraphRenderView( + view_id=view_id, + renderer=renderer, + output_path=output, + title=_bounded_string( + view, + "title", + descriptor_path, + maximum=MAX_GRAPH_VIEW_STRING_CHARS, + ), + root_node_id=root_node_id, + query=query, + initial_mode=initial_mode, + depth=depth, + max_nodes=max_nodes, + max_edges=max_edges, + max_work=max_work, + families=_bounded_strings( + view.get("families", []), + key="graph_render.view.families", + source=descriptor_path, + ), + relations=_bounded_strings( + view.get("relations", []), + key="graph_render.view.relations", + source=descriptor_path, + ), + authorities=_bounded_strings( + view.get("authorities", []), + key="graph_render.view.authorities", + source=descriptor_path, + ), + statuses=_bounded_strings( + view.get("statuses", []), + key="graph_render.view.statuses", + source=descriptor_path, + ), + tags=_bounded_strings( + view.get("tags", []), + key="graph_render.view.tags", + source=descriptor_path, + ), + include_logic=include_logic, + ) + ) + return GraphRenderConfig( + output_root=output_root, + views=tuple(sorted(views, key=lambda item: item.view_id)), + ) diff --git a/src/docforge/graph_rendering.py b/src/docforge/graph_rendering.py new file mode 100644 index 0000000..55621bd --- /dev/null +++ b/src/docforge/graph_rendering.py @@ -0,0 +1,793 @@ +"""Declared portable graph planning, publication, and receipt-only status.""" + +from __future__ import annotations + +import fcntl +import json +import os +from collections.abc import Callable, Generator +from contextlib import contextmanager +from pathlib import Path +from typing import cast + +from ._fs_safety import ( + atomic_replace_bytes_at, + open_confined_directory, + read_bounded_file_at, + require_bound_directory, + safe_file_identity_at, +) +from .errors import DocForgeError +from .graph_projection import ( + GraphViewRequestV1, + build_graph_projection_package, + build_graph_view_plan, +) +from .models import ( + GenerationRecordingProject, + GraphRenderConfig, + GraphRenderView, + IncrementalStateProject, + ProjectService, + ProjectSnapshot, + ProjectState, +) +from .project import project_root_fingerprint +from .projection_contract import GraphViewPlanV1, ProjectionReceiptV1, projection_hash + +GRAPH_RENDERER_ID = "portable_graph_html" +GRAPH_RENDERER_VERSION = "1" +GRAPH_PUBLICATION_MANIFEST_VERSION = 1 +GRAPH_PUBLICATION_CONTRACT = "docforge.graph-publication" +MAX_GRAPH_PUBLICATION_BYTES = 256_000 + + +class GraphRenderService: + """Publish one declared artifact while keeping planning and rendering independent.""" + + def __init__(self, project: ProjectService, *, allow_logic: bool = False) -> None: + self.project = project + self.allow_logic = allow_logic + + def plan(self, view_id: str) -> dict[str, object]: + snapshot = self.project.load() + view = self._view(self._config(snapshot), view_id) + plan = self._plan(snapshot, view) + return { + "status": "ok", + **self._identity(snapshot), + "view_id": view.view_id, + "plan": plan.as_dict(), + } + + def status(self, view_id: str | None = None) -> dict[str, object]: + config = self.project.descriptor.graph_render + current = self._current_state() + if config is None: + return self._status_result( + current, + configured=False, + state="not_configured", + outputs=[], + ) + views = config.views if view_id is None else (self._view(config, view_id),) + first_outputs = [self._manifest_status(view, current) for view in views] + outputs = [self._manifest_status(view, current) for view in views] + if outputs != first_outputs: + for output in outputs: + if output["state"] == "current": + output["state"] = "stale" + output["reason"] = "publication_changed_during_status" + final = self._current_state() + if final != current: + for output in outputs: + if output["state"] == "current": + output["state"] = "stale" + output["reason"] = "source_changed_during_status" + identity = final if final is not None else current + return self._status_result( + identity, + configured=True, + state="current" if all(item["state"] == "current" for item in outputs) else "stale", + outputs=outputs, + ) + + def render(self, view_id: str) -> dict[str, object]: + with self._lock(): + current_status = self.status(view_id) + current_outputs = cast(list[dict[str, object]], current_status["outputs"]) + if current_status["state"] == "current" and current_outputs: + return { + **current_status, + "publication": "unchanged", + "output": current_outputs[0], + } + snapshot = self.project.load() + view = self._view(self._config(snapshot), view_id) + plan = self._plan(snapshot, view) + package = build_graph_projection_package( + plan, + renderer_id=GRAPH_RENDERER_ID, + renderer_version=GRAPH_RENDERER_VERSION, + max_output_bytes=snapshot.descriptor.limits.max_render_bytes, + ) + from docforge_renderers.graph import PortableGraphHtmlRenderer + + result = PortableGraphHtmlRenderer().render(package) + if len(result.artifacts) != 1: + raise DocForgeError( + "invalid_projection", + "Portable graph renderer returned an unsupported artifact set", + ) + artifact = result.artifacts[0] + + def verify() -> None: + current = self.project.load() + if ( + current.revision != snapshot.revision + or current.source_hash != snapshot.source_hash + ): + raise DocForgeError( + "render_input_changed", + "Canonical input changed during portable graph rendering", + ) + + verify() + if isinstance(self.project, GenerationRecordingProject): + self.project.record_generation(snapshot) + artifact_evidence = artifact.evidence() + try: + store_identity = self._publish_artifact( + snapshot, + artifact_evidence["sha256"], + artifact.content, + verify=verify, + ) + except DocForgeError as error: + if self._mutation_committed(error): + return self._degraded_publication( + snapshot, + view, + plan, + package.package_id, + result.receipt.as_dict(), + artifact_evidence, + stage="artifact_store", + error=error, + output_published=False, + ) + raise + try: + output_identity = self._publish_output( + snapshot, + view, + artifact.content, + verify=verify, + ) + except DocForgeError as error: + if self._mutation_committed(error): + return self._degraded_publication( + snapshot, + view, + plan, + package.package_id, + result.receipt.as_dict(), + artifact_evidence, + stage="output", + error=error, + output_published=True, + ) + raise + manifest = self._manifest( + snapshot, + view, + plan, + package.package_id, + result.receipt.as_dict(), + artifact_evidence, + store_identity, + output_identity, + ) + try: + self._publish_manifest(snapshot, view, manifest, verify=verify) + except DocForgeError as error: + return self._degraded_publication( + snapshot, + view, + plan, + package.package_id, + result.receipt.as_dict(), + artifact_evidence, + stage="manifest", + error=error, + output_published=True, + ) + return { + "status": "ok", + **self._identity(snapshot), + "view_id": view.view_id, + "state": "current", + "publication": "published", + "plan_id": plan.plan_id, + "package_id": package.package_id, + "output": { + **artifact.evidence(), + "path": view.output_path.relative_to(snapshot.descriptor.root).as_posix(), + }, + "receipt": result.receipt.as_dict(), + "manifest": { + "state": "current", + "publication_id": manifest["publication_id"], + }, + } + + @staticmethod + def _mutation_committed(error: DocForgeError) -> bool: + return error.details.get("mutation_committed") is True + + def _degraded_publication( + self, + snapshot: ProjectSnapshot, + view: GraphRenderView, + plan: GraphViewPlanV1, + package_id: str, + receipt: dict[str, object], + artifact: dict[str, object], + *, + stage: str, + error: DocForgeError, + output_published: bool, + ) -> dict[str, object]: + return { + "status": "ok", + **self._identity(snapshot), + "view_id": view.view_id, + "state": "degraded", + "publication": "published" if output_published else "partial", + "committed_stage": stage, + "plan_id": plan.plan_id, + "package_id": package_id, + "artifact": artifact, + "output": { + **artifact, + "path": view.output_path.relative_to(snapshot.descriptor.root).as_posix(), + "state": "unverified" if output_published else "not_published", + }, + "receipt": receipt, + "manifest": { + "state": "failed", + "error": error.as_dict(), + }, + } + + def _manifest_status( + self, + view: GraphRenderView, + current: ProjectState | None, + ) -> dict[str, object]: + manifest = self._read_manifest(view) + base = { + "view_id": view.view_id, + "renderer": GRAPH_RENDERER_ID, + "renderer_version": GRAPH_RENDERER_VERSION, + "path": view.output_path.relative_to(self.project.descriptor.root).as_posix(), + "verification": "manifest", + } + if manifest is None: + return {**base, "state": "missing", "reason": "manifest_missing"} + if not self._valid_manifest(view, manifest): + return {**base, "state": "unverified", "reason": "manifest_invalid"} + if current is None: + reason = ( + "source_generation_changed" + if isinstance(self.project, GenerationRecordingProject) + else "source_generation_unavailable" + ) + return { + **base, + "state": ( + "stale" + if isinstance(self.project, GenerationRecordingProject) + else "unverified" + ), + "reason": reason, + "plan_id": manifest.get("plan_id"), + "package_id": manifest.get("package_id"), + } + project = cast(dict[str, object], manifest["project"]) + if project["revision"] != current.revision or project["source_hash"] != current.source_hash: + return { + **base, + "state": "stale", + "reason": "source_generation_changed", + "plan_id": manifest["plan_id"], + "package_id": manifest["package_id"], + } + artifact = cast(dict[str, object], manifest["artifact"]) + store = cast(dict[str, object], manifest["store"]) + artifact_root = self.project.descriptor.cache_root / "projection-artifacts" + if not artifact_root.exists(): + return { + **base, + "state": "stale", + "reason": "artifact_store_missing", + "plan_id": manifest["plan_id"], + "package_id": manifest["package_id"], + } + if artifact_root.is_symlink() or not artifact_root.is_dir(): + return { + **base, + "state": "unsafe", + "reason": "artifact_store_unsafe", + "plan_id": manifest["plan_id"], + "package_id": manifest["package_id"], + } + artifact_directory: int | None = None + try: + artifact_directory = open_confined_directory( + self.project.descriptor.root, + artifact_root, + create=False, + ) + artifact_identity = safe_file_identity_at( + artifact_root, + artifact_directory, + f"{artifact['sha256']}.html", + ) + except DocForgeError: + return { + **base, + "state": "unsafe", + "reason": "artifact_store_unsafe", + "plan_id": manifest["plan_id"], + "package_id": manifest["package_id"], + } + finally: + if artifact_directory is not None: + os.close(artifact_directory) + if artifact_identity is None: + return { + **base, + "state": "stale", + "reason": "artifact_store_missing", + "plan_id": manifest["plan_id"], + "package_id": manifest["package_id"], + } + if artifact_identity != store: + return { + **base, + "state": "stale", + "reason": "artifact_store_changed", + "plan_id": manifest["plan_id"], + "package_id": manifest["package_id"], + } + try: + directory = open_confined_directory( + self.project.descriptor.root, + view.output_path.parent, + create=False, + ) + except DocForgeError: + return {**base, "state": "unsafe", "reason": "output_root_unsafe"} + try: + identity = safe_file_identity_at( + view.output_path.parent, directory, view.output_path.name + ) + except DocForgeError: + return {**base, "state": "unsafe", "reason": "output_unsafe"} + finally: + os.close(directory) + expected = cast(dict[str, object], manifest["output"]) + if identity != expected: + return { + **base, + "state": "stale", + "reason": "output_changed", + "plan_id": manifest["plan_id"], + "package_id": manifest["package_id"], + } + return { + **base, + "state": "current", + "reason": None, + "plan_id": manifest["plan_id"], + "package_id": manifest["package_id"], + "publication_id": manifest["publication_id"], + "artifact": manifest["artifact"], + } + + def _read_manifest(self, view: GraphRenderView) -> dict[str, object] | None: + root = self._manifest_root() + if not root.is_dir() or root.is_symlink(): + return None + try: + descriptor = open_confined_directory( + self.project.descriptor.root, + root, + create=False, + ) + except DocForgeError: + return None + try: + raw = read_bounded_file_at( + descriptor, + f"{view.view_id}.json", + MAX_GRAPH_PUBLICATION_BYTES, + ) + except DocForgeError: + return None + finally: + os.close(descriptor) + if raw is None: + return None + try: + value: object = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + return cast(dict[str, object], value) if isinstance(value, dict) else None + + def _valid_manifest(self, view: GraphRenderView, manifest: dict[str, object]) -> bool: + required = { + "schema_version", + "contract", + "publication_id", + "project", + "view_id", + "view_config_hash", + "plan_id", + "package_id", + "renderer", + "receipt", + "artifact", + "store", + "output", + } + try: + if ( + set(manifest) != required + or manifest.get("schema_version") != GRAPH_PUBLICATION_MANIFEST_VERSION + or manifest.get("contract") != GRAPH_PUBLICATION_CONTRACT + or manifest.get("view_id") != view.view_id + or manifest.get("view_config_hash") != self._view_hash(view) + or not self._hash(manifest.get("plan_id")) + or not self._hash(manifest.get("package_id")) + ): + return False + project = manifest.get("project") + descriptor = self.project.descriptor + if not isinstance(project, dict): + return False + project_document = cast(dict[str, object], project) + if ( + set(project_document) + != { + "project_id", + "project_root_fingerprint", + "adapter", + "revision", + "source_hash", + } + or project_document.get("project_id") != descriptor.project_id + or project_document.get("project_root_fingerprint") + != project_root_fingerprint(descriptor.root) + or project_document.get("adapter") != descriptor.adapter + or not isinstance(project_document.get("revision"), str) + or not project_document["revision"] + or not self._hash(project_document.get("source_hash")) + ): + return False + renderer = manifest.get("renderer") + if renderer != { + "renderer_id": GRAPH_RENDERER_ID, + "renderer_version": GRAPH_RENDERER_VERSION, + }: + return False + receipt_value = manifest.get("receipt") + if not isinstance(receipt_value, dict): + return False + receipt = ProjectionReceiptV1.from_dict( + dict(cast(dict[str, object], receipt_value)) + ).as_dict() + artifacts = receipt.get("artifacts") + if not isinstance(artifacts, list): + return False + artifact_values = cast(list[object], artifacts) + if ( + receipt.get("kind") != "graph" + or receipt.get("plan_id") != manifest["plan_id"] + or receipt.get("package_id") != manifest["package_id"] + or receipt.get("renderer") != renderer + or len(artifact_values) != 1 + or manifest.get("artifact") != artifact_values[0] + ): + return False + artifact = artifact_values[0] + if not isinstance(artifact, dict): + return False + artifact_document = cast(dict[str, object], artifact) + if ( + artifact_document.get("artifact_id") != "portable-graph.html" + or artifact_document.get("media_type") != "text/html; charset=utf-8" + ): + return False + artifact_hash = artifact_document.get("sha256") + artifact_bytes = artifact_document.get("bytes") + store = manifest.get("store") + output = manifest.get("output") + if ( + not self._file_identity(store, expected_name=f"{artifact_hash}.html") + or not self._file_identity(output, expected_name=view.output_path.name) + or type(artifact_bytes) is not int + or cast(dict[str, object], store)["size"] != artifact_bytes + or cast(dict[str, object], output)["size"] != artifact_bytes + ): + return False + body = dict(manifest) + publication_id = body.pop("publication_id", None) + return self._hash(publication_id) and publication_id == projection_hash(body) + except (DocForgeError, KeyError, TypeError, ValueError): + return False + + @staticmethod + def _hash(value: object) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + + @staticmethod + def _file_identity(value: object, *, expected_name: str) -> bool: + if not isinstance(value, dict): + return False + document = cast(dict[str, object], value) + required = {"path", "device", "inode", "mode", "size", "mtime_ns", "ctime_ns"} + return ( + set(document) == required + and document.get("path") == expected_name + and all( + type(document.get(field)) is int and cast(int, document[field]) >= 0 + for field in required - {"path"} + ) + ) + + def _manifest( + self, + snapshot: ProjectSnapshot, + view: GraphRenderView, + plan: GraphViewPlanV1, + package_id: str, + receipt: dict[str, object], + artifact: dict[str, object], + store: dict[str, object], + output: dict[str, object], + ) -> dict[str, object]: + body: dict[str, object] = { + "schema_version": GRAPH_PUBLICATION_MANIFEST_VERSION, + "contract": GRAPH_PUBLICATION_CONTRACT, + "project": self._identity(snapshot), + "view_id": view.view_id, + "view_config_hash": self._view_hash(view), + "plan_id": plan.plan_id, + "package_id": package_id, + "renderer": { + "renderer_id": GRAPH_RENDERER_ID, + "renderer_version": GRAPH_RENDERER_VERSION, + }, + "receipt": receipt, + "artifact": artifact, + "store": store, + "output": output, + } + return {**body, "publication_id": projection_hash(body)} + + def _publish_artifact( + self, + snapshot: ProjectSnapshot, + artifact_hash: object, + content: bytes, + *, + verify: Callable[[], None], + ) -> dict[str, object]: + if not isinstance(artifact_hash, str): + raise DocForgeError("invalid_projection", "Artifact hash is invalid") + root = snapshot.descriptor.cache_root / "projection-artifacts" + descriptor = open_confined_directory(snapshot.descriptor.root, root, create=True) + name = f"{artifact_hash}.html" + try: + try: + existing = read_bounded_file_at(descriptor, name, len(content)) + except DocForgeError as error: + if error.code != "invalid_projection": + raise + existing = None + if existing == content: + identity = safe_file_identity_at(root, descriptor, name) + assert identity is not None + return identity + return atomic_replace_bytes_at(root, descriptor, name, content, verify=verify) + finally: + os.close(descriptor) + + def _publish_output( + self, + snapshot: ProjectSnapshot, + view: GraphRenderView, + content: bytes, + *, + verify: Callable[[], None], + ) -> dict[str, object]: + root = view.output_path.parent + descriptor = open_confined_directory(snapshot.descriptor.root, root, create=True) + try: + try: + existing = read_bounded_file_at( + descriptor, + view.output_path.name, + len(content), + ) + except DocForgeError as error: + if error.code != "invalid_projection": + raise + existing = None + if existing == content: + identity = safe_file_identity_at(root, descriptor, view.output_path.name) + assert identity is not None + return identity + return atomic_replace_bytes_at( + root, + descriptor, + view.output_path.name, + content, + verify=verify, + ) + finally: + os.close(descriptor) + + def _publish_manifest( + self, + snapshot: ProjectSnapshot, + view: GraphRenderView, + manifest: dict[str, object], + *, + verify: Callable[[], None], + ) -> None: + raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n" + if len(raw) > MAX_GRAPH_PUBLICATION_BYTES: + raise DocForgeError( + "projection_too_large", + "Portable graph publication manifest exceeds its fixed limit", + ) + root = self._manifest_root() + descriptor = open_confined_directory(snapshot.descriptor.root, root, create=True) + try: + atomic_replace_bytes_at( + root, + descriptor, + f"{view.view_id}.json", + raw, + verify=verify, + ) + finally: + os.close(descriptor) + + @contextmanager + def _lock(self) -> Generator[None]: + root = self.project.descriptor.cache_root + descriptor = open_confined_directory(self.project.descriptor.root, root, create=True) + lock_descriptor: int | None = None + try: + lock_descriptor = os.open( + "graph-render.lock", + os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, + 0o600, + dir_fd=descriptor, + ) + fcntl.flock(lock_descriptor, fcntl.LOCK_EX) + require_bound_directory(root, descriptor) + yield + except OSError as error: + raise DocForgeError( + "publication_failure", + "Portable graph render lock is unavailable", + ) from error + finally: + if lock_descriptor is not None: + os.close(lock_descriptor) + os.close(descriptor) + + def _plan(self, snapshot: ProjectSnapshot, view: GraphRenderView) -> GraphViewPlanV1: + return build_graph_view_plan( + snapshot, + GraphViewRequestV1( + view_id=view.view_id, + title=view.title, + root_node_id=view.root_node_id, + query=view.query, + initial_mode=view.initial_mode, + depth=view.depth, + max_nodes=view.max_nodes, + max_edges=view.max_edges, + max_work=view.max_work, + families=view.families, + relations=view.relations, + authorities=view.authorities, + statuses=view.statuses, + tags=view.tags, + include_logic=view.include_logic, + ), + self.allow_logic, + ) + + def _current_state(self) -> ProjectState | None: + if isinstance(self.project, IncrementalStateProject): + return self.project.incremental_state() + return None + + def _config(self, snapshot: ProjectSnapshot) -> GraphRenderConfig: + config = snapshot.descriptor.graph_render + if config is None: + raise DocForgeError( + "graph_render_not_configured", + "Project has no portable graph render configuration", + ) + return config + + @staticmethod + def _view(config: GraphRenderConfig, view_id: str) -> GraphRenderView: + for view in config.views: + if view.view_id == view_id: + return view + raise DocForgeError( + "unknown_graph_render_view", + "Portable graph view is not declared", + view_id=view_id, + ) + + def _manifest_root(self) -> Path: + return self.project.descriptor.cache_root / "projection-publications" / "graph" + + @staticmethod + def _view_hash(view: GraphRenderView) -> str: + return projection_hash( + { + "view_id": view.view_id, + "renderer": view.renderer, + "title": view.title, + "root_node_id": view.root_node_id, + "query": view.query, + "initial_mode": view.initial_mode, + "depth": view.depth, + "max_nodes": view.max_nodes, + "max_edges": view.max_edges, + "max_work": view.max_work, + "families": list(view.families), + "relations": list(view.relations), + "authorities": list(view.authorities), + "statuses": list(view.statuses), + "tags": list(view.tags), + "include_logic": view.include_logic, + } + ) + + def _status_result(self, current: ProjectState | None, **payload: object) -> dict[str, object]: + descriptor = self.project.descriptor + return { + "status": "ok", + "project_id": descriptor.project_id, + "project_root_fingerprint": project_root_fingerprint(descriptor.root), + "adapter": descriptor.adapter, + "revision": current.revision if current is not None else "unknown", + "source_hash": current.source_hash if current is not None else None, + **payload, + } + + @staticmethod + def _identity(snapshot: ProjectSnapshot) -> dict[str, object]: + return { + "project_id": snapshot.descriptor.project_id, + "project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root), + "adapter": snapshot.descriptor.adapter, + "revision": snapshot.revision, + "source_hash": snapshot.source_hash, + } diff --git a/src/docforge/models.py b/src/docforge/models.py index 47d5339..efcd71f 100644 --- a/src/docforge/models.py +++ b/src/docforge/models.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import asdict, dataclass from pathlib import Path -from typing import Protocol, runtime_checkable +from typing import Literal, Protocol, runtime_checkable @dataclass(frozen=True) @@ -49,6 +49,33 @@ class RenderConfig: views: tuple[RenderView, ...] +@dataclass(frozen=True) +class GraphRenderView: + view_id: str + renderer: str + output_path: Path + title: str + root_node_id: str | None + query: str | None + initial_mode: Literal["nodes", "flow", "web", "logic"] + depth: int + max_nodes: int + max_edges: int + max_work: int + families: tuple[str, ...] + relations: tuple[str, ...] + authorities: tuple[str, ...] + statuses: tuple[str, ...] + tags: tuple[str, ...] + include_logic: bool + + +@dataclass(frozen=True) +class GraphRenderConfig: + output_root: Path + views: tuple[GraphRenderView, ...] + + @dataclass(frozen=True) class ContextProfile: profile_id: str @@ -78,6 +105,7 @@ class ProjectDescriptor: allowed_relations: tuple[str, ...] profiles: tuple[ContextProfile, ...] limits: Limits + graph_render: GraphRenderConfig | None = None @dataclass(frozen=True) diff --git a/src/docforge/project.py b/src/docforge/project.py index 3d689bd..04dfebb 100644 --- a/src/docforge/project.py +++ b/src/docforge/project.py @@ -25,6 +25,7 @@ from .config_validation import ( string_list, ) from .errors import DocForgeError +from .graph_render_config import load_graph_render_config from .models import ( ContextProfile, Edge, @@ -66,6 +67,7 @@ _DESCRIPTOR_KEYS = frozenset( "derived", "changesets", "render", + "graph_render", "graph", "limits", "profiles", @@ -560,6 +562,18 @@ def _load_descriptor(root: Path) -> ProjectDescriptor: changeset_root=changeset_root, limits=limits, ) + graph_render = load_graph_render_config( + root, + document.get("graph_render"), + descriptor_path=descriptor_path, + content_roots=content_roots, + authority_files=authority_files, + cache_root=cache_root, + index_path=index_path, + changeset_root=changeset_root, + manual_render=render, + limits=limits, + ) profile_documents = document.get("profiles", []) if not isinstance(profile_documents, list): @@ -623,6 +637,7 @@ def _load_descriptor(root: Path) -> ProjectDescriptor: changeset_root=changeset_root, proposal_writers=tuple(sorted(proposal_writers, key=lambda writer: writer.writer_id)), render=render, + graph_render=graph_render, allowed_relations=allowed_relations, profiles=tuple(profiles), limits=limits, diff --git a/src/docforge/projection_contract.py b/src/docforge/projection_contract.py index 6d06cb0..937d84d 100644 --- a/src/docforge/projection_contract.py +++ b/src/docforge/projection_contract.py @@ -19,6 +19,13 @@ MAX_PLAN_BYTES = 16_000_000 MAX_PACKAGE_BYTES = 24_000_000 MAX_RECEIPT_BYTES = 128_000 MAX_PROJECTION_ARTIFACTS = 32 +MAX_GRAPH_VIEW_DEPTH = 32 +MAX_GRAPH_VIEW_NODES = 1_000 +MAX_GRAPH_VIEW_EDGES = 4_000 +MAX_GRAPH_VIEW_WORK = 1_000_000 +MAX_GRAPH_VIEW_FILTERS = 64 +MAX_GRAPH_VIEW_STRING_CHARS = 1_024 +MAX_GRAPH_VIEW_QUERY_CHARS = 10_000 ProjectionKind = Literal["manual", "graph"] @@ -468,16 +475,39 @@ def validate_projection_receipt(document: dict[str, object]) -> dict[str, object if not isinstance(artifacts_value, list): raise DocForgeError("invalid_projection", "Projection receipt structure is invalid") artifacts = cast(list[object], artifacts_value) + renderer = document.get("renderer") + diagnostics = document.get("diagnostics") + timing = document.get("timing") + if ( + not isinstance(renderer, dict) + or not isinstance(diagnostics, dict) + or not isinstance(timing, dict) + ): + raise DocForgeError("invalid_projection", "Projection receipt structure is invalid") + renderer_document = cast(dict[str, object], renderer) + diagnostics_document = cast(dict[str, object], diagnostics) + timing_document = cast(dict[str, object], timing) + warnings = diagnostics_document.get("warnings") if ( len(artifacts) > MAX_PROJECTION_ARTIFACTS - or not isinstance(document.get("renderer"), dict) - or not isinstance(document.get("diagnostics"), dict) - or not isinstance(document.get("timing"), dict) + or set(renderer_document) != {"renderer_id", "renderer_version"} + or not all( + isinstance(renderer_document.get(field), str) and renderer_document[field] + for field in ("renderer_id", "renderer_version") + ) + or set(diagnostics_document) != {"warnings"} + or not isinstance(warnings, list) + or len(cast(list[object], warnings)) > 10_000 + or not all(isinstance(item, str) for item in cast(list[object], warnings)) + or set(timing_document) != {"elapsed_ns"} + or type(timing_document.get("elapsed_ns")) is not int + or cast(int, timing_document["elapsed_ns"]) < 0 ): raise DocForgeError("invalid_projection", "Projection receipt structure is invalid") peak = document.get("peak_memory_bytes") if peak is not None and (type(peak) is not int or peak < 0): raise DocForgeError("invalid_projection", "Projection receipt memory value is invalid") + artifact_ids: set[str] = set() for artifact in artifacts: if not isinstance(artifact, dict): raise DocForgeError("invalid_projection", "Projection receipt artifact is invalid") @@ -494,6 +524,10 @@ def validate_projection_receipt(document: dict[str, object]) -> dict[str, object or cast(int, item["bytes"]) < 0 ): raise DocForgeError("invalid_projection", "Projection receipt artifact is invalid") + artifact_id = cast(str, item["artifact_id"]) + if artifact_id in artifact_ids: + raise DocForgeError("invalid_projection", "Projection receipt artifacts are duplicated") + artifact_ids.add(artifact_id) return _validated_identity( document, identity_field="receipt_id", diff --git a/src/docforge/telemetry.py b/src/docforge/telemetry.py index 4c9c6d8..1367273 100644 --- a/src/docforge/telemetry.py +++ b/src/docforge/telemetry.py @@ -117,6 +117,9 @@ OPERATION_NAMES = frozenset( "cli.impact", "cli.context", "cli.generation-diff", + "cli.graph-plan", + "cli.graph-render", + "cli.graph-render-status", "cli.configure", "cli.doctor", "cli.render", diff --git a/src/docforge_renderers/graph.py b/src/docforge_renderers/graph.py new file mode 100644 index 0000000..a95e322 --- /dev/null +++ b/src/docforge_renderers/graph.py @@ -0,0 +1,321 @@ +"""Deterministic self-contained renderer for one portable graph package.""" + +from __future__ import annotations + +import base64 +import hashlib +import html +import json +from time import perf_counter_ns +from typing import cast + +from docforge.errors import DocForgeError +from docforge.projection_contract import ( + ProjectionArtifact, + ProjectionPackageV1, + ProjectionReceiptV1, + ProjectionRenderResult, +) + +PORTABLE_GRAPH_CSS = """ +:root { color-scheme: light dark; font-family: system-ui, sans-serif; } +* { box-sizing: border-box; } +body { margin: 0; background: Canvas; color: CanvasText; } +.skip { position: absolute; left: -10000px; top: auto; } +.skip:focus { left: 1rem; top: 1rem; z-index: 2; padding: .5rem; background: Canvas; } +header, main { width: min(96%, 1100px); margin: 0 auto; } +header { padding: 1rem 0; } +.controls { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; } +label { display: grid; gap: .25rem; font-weight: 600; } +input, select, button { font: inherit; min-height: 2.75rem; padding: .45rem .65rem; } +button { cursor: pointer; } +button:focus-visible, input:focus-visible, select:focus-visible { outline: .2rem solid Highlight; } +.summary { margin: 1rem 0; } +.layout { display: grid; grid-template-columns: minmax(16rem, 1fr) minmax(20rem, 2fr); gap: 1rem; } +.panel { border: 1px solid GrayText; border-radius: .5rem; padding: 1rem; overflow: auto; } +html[data-enhanced="true"] main[data-mode="nodes"] .layout, +html[data-enhanced="true"] main[data-mode="flow"] .layout { grid-template-columns: 1fr; } +html[data-enhanced="true"] main[data-mode="nodes"] [data-panel="relationships"] { display: none; } +html[data-enhanced="true"] main[data-mode="flow"] [data-panel="nodes"] { display: none; } +.node-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .5rem; } +.node-list button { + width: 100%; text-align: left; border: 1px solid GrayText; border-radius: .35rem; +} +.node-list button[aria-current="true"] { border-width: .2rem; } +table { border-collapse: collapse; width: 100%; } +th, td { text-align: left; border-bottom: 1px solid GrayText; padding: .5rem; vertical-align: top; } +caption { text-align: left; font-weight: 700; margin-bottom: .5rem; } +.muted { color: GrayText; } +dialog { + max-width: min(42rem, calc(100% - 2rem)); + border: 1px solid GrayText; border-radius: .5rem; +} +dialog::backdrop { background: rgb(0 0 0 / 55%); } +@media (max-width: 48rem) { .layout { grid-template-columns: 1fr; } } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; } +} +@media (forced-colors: active) { + .panel, .node-list button, dialog { border: 2px solid CanvasText; } +} +""".strip() + +PORTABLE_GRAPH_JAVASCRIPT = r""" +(() => { + "use strict"; + const plan = JSON.parse(document.getElementById("docforge-graph-plan").textContent); + const nodes = plan.graph.nodes; + const edges = plan.graph.edges; + const list = document.getElementById("node-list"); + const rows = document.getElementById("edge-rows"); + const filter = document.getElementById("filter"); + const mode = document.getElementById("mode"); + const main = document.getElementById("main"); + const status = document.getElementById("status"); + const dialog = document.getElementById("node-dialog"); + const detail = document.getElementById("node-detail"); + const close = document.getElementById("close-dialog"); + let opener = null; + document.documentElement.dataset.enhanced = "true"; + + const matches = (node) => { + const query = filter.value.trim().toLocaleLowerCase(); + const fields = [ + node.node_id, node.title, node.summary, node.family, node.status, ...node.tags + ]; + return !query || fields + .join(" ").toLocaleLowerCase().includes(query); + }; + const selectedIds = () => new Set(nodes.filter(matches).map((node) => node.node_id)); + const render = () => { + main.dataset.mode = mode.value; + const visible = nodes.filter(matches); + const ids = selectedIds(); + list.replaceChildren(...visible.map((node) => { + const item = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + button.textContent = `${node.title} (${node.node_id})`; + button.dataset.nodeId = node.node_id; + button.addEventListener("click", () => inspect(node, button)); + item.append(button); + return item; + })); + const visibleEdges = edges.filter((edge) => ids.has(edge.source_id) && ids.has(edge.target_id)); + rows.replaceChildren(...visibleEdges.map((edge) => { + const row = document.createElement("tr"); + [edge.source_id, edge.relation, edge.target_id].forEach((value) => { + const cell = document.createElement("td"); + cell.textContent = value; + row.append(cell); + }); + return row; + })); + status.textContent = `${visible.length} nodes and ${visibleEdges.length} relationships ` + + `shown in ${mode.value} mode.`; + }; + const inspect = (node, button) => { + opener = button; + detail.replaceChildren(); + const heading = document.createElement("h2"); + heading.id = "node-dialog-title"; + heading.textContent = node.title; + const identity = document.createElement("p"); + identity.textContent = `${node.node_id} · ${node.family} · ${node.status}`; + const summary = document.createElement("p"); + summary.textContent = node.summary; + detail.append(heading, identity, summary); + dialog.showModal(); + close.focus(); + }; + close.addEventListener("click", () => dialog.close()); + dialog.addEventListener("close", () => opener?.focus()); + filter.addEventListener("input", render); + mode.addEventListener("change", render); + render(); +})(); +""".strip() + + +def _csp_hash(content: str) -> str: + digest = hashlib.sha256(content.encode("utf-8")).digest() + return base64.b64encode(digest).decode("ascii") + + +def _embedded_json(value: object) -> str: + return ( + json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + .replace("&", "\\u0026") + .replace("<", "\\u003c") + .replace(">", "\\u003e") + ) + + +def _static_node_markup(plan: dict[str, object]) -> str: + graph = cast(dict[str, object], plan["graph"]) + nodes = cast(list[dict[str, object]], graph["nodes"]) + parts: list[str] = [] + for node in nodes: + node_id = html.escape(cast(str, node["node_id"])) + attribute_node_id = html.escape(cast(str, node["node_id"]), quote=True) + title = html.escape(cast(str, node["title"])) + family = html.escape(cast(str, node["family"])) + status = html.escape(cast(str, node["status"])) + summary = html.escape(cast(str, node["summary"])) + parts.append( + "
  • " + f'" + f'

    {family} · {status}

    ' + f"

    {summary}

    " + "
  • " + ) + return "".join(parts) + + +def _static_edge_markup(plan: dict[str, object]) -> str: + graph = cast(dict[str, object], plan["graph"]) + edges = cast(list[dict[str, object]], graph["edges"]) + return "".join( + "" + f"{html.escape(cast(str, edge['source_id']))}" + f"{html.escape(cast(str, edge['relation']))}" + f"{html.escape(cast(str, edge['target_id']))}" + "" + for edge in edges + ) + + +class PortableGraphHtmlRenderer: + """Render a validated graph package without querying or publishing project state.""" + + renderer_id = "portable_graph_html" + renderer_version = "1" + + def render(self, package: ProjectionPackageV1) -> ProjectionRenderResult: + started = perf_counter_ns() + package = ProjectionPackageV1.from_dict(package.as_dict()) + document = package.document + if package.kind != "graph": + raise DocForgeError("invalid_projection", "Graph renderer requires a graph package") + renderer = cast(dict[str, object], document["renderer"]) + if renderer != { + "renderer_id": self.renderer_id, + "renderer_version": self.renderer_version, + }: + raise DocForgeError("unsupported_renderer", "Graph renderer identity is incompatible") + if document["components"] != [ + {"component_id": "graph.portable-document@1"}, + {"component_id": "graph.accessible-list@1"}, + {"component_id": "graph.relationship-table@1"}, + ]: + raise DocForgeError( + "invalid_projection", + "Portable graph renderer component declarations are incompatible", + ) + if document["assets"] != []: + raise DocForgeError( + "invalid_projection", + "Portable graph renderer does not accept project-provided assets", + ) + plan = cast(dict[str, object], document["plan"]) + view = cast(dict[str, object], plan["view"]) + initial_mode = view.get("initial_mode") + policy = cast(dict[str, object], plan["policy"]) + if ( + initial_mode not in {"nodes", "flow", "web"} + or policy.get("logic_requested") is not False + ): + raise DocForgeError( + "unsupported_renderer", + "Portable graph renderer version 1 does not render Logic projections", + ) + title = html.escape(cast(str, view["title"])) + project = cast(dict[str, object], plan["project"]) + embedded = _embedded_json(plan) + static_nodes = _static_node_markup(plan) + static_edges = _static_edge_markup(plan) + csp = ( + "default-src 'none'; " + f"style-src 'sha256-{_csp_hash(PORTABLE_GRAPH_CSS)}'; " + f"script-src 'sha256-{_csp_hash(PORTABLE_GRAPH_JAVASCRIPT)}'; " + "img-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; " + "form-action 'none'; frame-ancestors 'none'" + ) + output = ( + "\n" + '\n' + "\n" + '\n' + '\n' + '\n' + f"{title} · DocForge graph\n" + f"\n" + "\n" + "\n" + '\n' + "
    \n" + f"

    {title}

    \n" + f'

    Generation {html.escape(cast(str, project["source_hash"]))}

    \n' + '
    \n' + '\n' + '\n" + "
    \n" + '

    \n' + "
    \n" + f'
    \n' + '
    \n' + '
    ' + '

    Nodes

    ' + f'
      {static_nodes}
    \n' + '
    ' + '

    Relationships

    ' + "" + '' + f'{static_edges}' + "
    Selected graph facts
    SourceRelationTarget
    " + "
    \n" + "
    \n" + "
    \n" + '' + '

    Node details

    ' + '
    \n' + f'\n' + f"\n" + "\n" + "\n" + ).encode() + policy = cast(dict[str, object], document["output_policy"]) + maximum = policy.get("max_total_bytes") + if set(policy) != {"artifact_ids", "max_total_bytes"} or policy.get("artifact_ids") != [ + "portable-graph.html" + ]: + raise DocForgeError( + "invalid_projection", + "Portable graph output policy is incompatible", + ) + if type(maximum) is not int or maximum < 1 or len(output) > maximum: + raise DocForgeError("render_too_large", "Rendered output exceeds the configured limit") + artifact = ProjectionArtifact( + artifact_id="portable-graph.html", + media_type="text/html; charset=utf-8", + content=output, + ) + receipt = ProjectionReceiptV1.create( + kind="graph", + package_id=package.package_id, + plan_id=cast(str, document["plan_id"]), + renderer=dict(renderer), + artifacts=[artifact.evidence()], + diagnostics={ + "warnings": [], + }, + timing={"elapsed_ns": perf_counter_ns() - started}, + peak_memory_bytes=None, + ) + return ProjectionRenderResult((artifact,), receipt) diff --git a/tests/test_cli.py b/tests/test_cli.py index 92e3cda..bd5a039 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,6 +17,25 @@ from docforge.viewer_manager import ViewerManager ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "tests" / "fixtures" +GRAPH_CONFIG = """ + +[graph_render] +output_root = ".docforge/portable-graph" + +[[graph_render.views]] +id = "architecture" +renderer = "portable_graph_html" +output = "architecture.html" +title = "Alpha architecture" +root = "guide.workflow" +initial_mode = "nodes" +depth = 2 +max_nodes = 20 +max_edges = 40 +max_work = 1000 +include_logic = false +""" + class DocForgeCliTests(unittest.TestCase): def copy_fixture(self, destination: Path) -> Path: @@ -134,6 +153,30 @@ class DocForgeCliTests(unittest.TestCase): else: os.environ["DOCFORGE_VIEWER_MANAGER_STATE"] = previous + def test_portable_graph_plan_render_and_status_are_self_service(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = self.copy_fixture(Path(directory)) + descriptor = root / ".docforge/project.toml" + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG, + encoding="utf-8", + ) + parser = _parser() + planned = _run( + parser.parse_args(["--project-root", str(root), "graph-plan", "architecture"]) + ) + self.assertEqual("architecture", planned["view_id"]) + rendered = _run( + parser.parse_args(["--project-root", str(root), "graph-render", "architecture"]) + ) + self.assertEqual("current", rendered["state"]) + status = _run( + parser.parse_args( + ["--project-root", str(root), "graph-render-status", "architecture"] + ) + ) + self.assertEqual("current", status["state"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_graph_publication.py b/tests/test_graph_publication.py new file mode 100644 index 0000000..1d6d110 --- /dev/null +++ b/tests/test_graph_publication.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import json +import shutil +import tempfile +import tomllib +import unittest +from pathlib import Path +from unittest import mock + +from jsonschema import Draft202012Validator + +from docforge.errors import DocForgeError +from docforge.graph_rendering import GraphRenderService +from docforge.models import ProjectState +from docforge.project import Project +from docforge.projection_contract import projection_hash + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests" / "fixtures" +PROJECT_SCHEMA = json.loads((ROOT / "schemas/project.schema.json").read_text(encoding="utf-8")) + +GRAPH_CONFIG = """ + +[graph_render] +output_root = ".docforge/portable-graph" + +[[graph_render.views]] +id = "architecture" +renderer = "portable_graph_html" +output = "architecture.html" +title = "Alpha architecture" +root = "guide.workflow" +initial_mode = "nodes" +depth = 2 +max_nodes = 20 +max_edges = 40 +max_work = 1000 +families = ["guide", "proof"] +relations = ["depends_on", "proves"] +authorities = [] +statuses = [] +tags = [] +include_logic = false +""" + + +class GraphPublicationTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) / "alpha" + shutil.copytree(FIXTURES / "alpha", self.root) + descriptor = self.root / ".docforge/project.toml" + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG, + encoding="utf-8", + ) + self.project = Project.open(self.root) + self.service = GraphRenderService(self.project) + + def test_descriptor_schema_and_runtime_accept_the_separate_graph_view(self) -> None: + document = tomllib.loads((self.root / ".docforge/project.toml").read_text(encoding="utf-8")) + Draft202012Validator(PROJECT_SCHEMA).validate(document) + config = self.project.descriptor.graph_render + assert config is not None + self.assertEqual(self.root / ".docforge/portable-graph", config.output_root) + self.assertEqual("architecture", config.views[0].view_id) + self.assertEqual("guide.workflow", config.views[0].root_node_id) + self.assertIsNone(config.views[0].query) + + def test_render_publication_is_deterministic_durable_and_unchanged_on_reuse(self) -> None: + missing = self.service.status("architecture") + self.assertEqual("stale", missing["state"]) + self.assertEqual("missing", missing["outputs"][0]["state"]) + + first = self.service.render("architecture") + output = self.root / ".docforge/portable-graph/architecture.html" + manifest = self.root / ".docforge/cache/projection-publications/graph/architecture.json" + artifact_root = self.root / ".docforge/cache/projection-artifacts" + self.assertEqual("current", first["state"]) + self.assertEqual("published", first["publication"]) + self.assertTrue(output.is_file()) + self.assertTrue(manifest.is_file()) + self.assertEqual(1, len(tuple(artifact_root.glob("*.html")))) + before = output.stat() + before_bytes = output.read_bytes() + + current = self.service.status("architecture") + self.assertEqual("current", current["state"]) + self.assertEqual("manifest", current["outputs"][0]["verification"]) + second = self.service.render("architecture") + after = output.stat() + self.assertEqual("unchanged", second["publication"]) + self.assertEqual(before_bytes, output.read_bytes()) + self.assertEqual((before.st_dev, before.st_ino), (after.st_dev, after.st_ino)) + + def test_status_is_manifest_only_and_detects_source_output_and_manifest_changes(self) -> None: + self.service.render("architecture") + output = self.root / ".docforge/portable-graph/architecture.html" + manifest = self.root / ".docforge/cache/projection-publications/graph/architecture.json" + with mock.patch.object( + self.project, + "load", + side_effect=AssertionError("status must not load or plan"), + ): + self.assertEqual("current", self.service.status("architecture")["state"]) + + output.write_bytes(output.read_bytes() + b"\n") + changed_output = self.service.status("architecture") + self.assertEqual("stale", changed_output["state"]) + self.assertEqual("output_changed", changed_output["outputs"][0]["reason"]) + + self.service.render("architecture") + source = self.root / "docs/content/workflow.md" + source.write_text( + source.read_text(encoding="utf-8") + "\nChanged after publication.\n", + encoding="utf-8", + ) + changed_source = self.service.status("architecture") + self.assertEqual("stale", changed_source["state"]) + self.assertEqual("stale", changed_source["outputs"][0]["state"]) + self.assertEqual( + "source_generation_changed", + changed_source["outputs"][0]["reason"], + ) + + self.service.render("architecture") + manifest.write_text("{bad-json", encoding="utf-8") + corrupt = self.service.status("architecture") + self.assertEqual("missing", corrupt["outputs"][0]["state"]) + + def test_status_validates_nested_manifest_and_artifact_store_evidence(self) -> None: + self.service.render("architecture") + manifest_path = ( + self.root / ".docforge/cache/projection-publications/graph/architecture.json" + ) + original = json.loads(manifest_path.read_text(encoding="utf-8")) + mutations = { + "bad_project": lambda value: value.__setitem__("project", "bad"), + "forged_artifact": lambda value: value.__setitem__( + "artifact", + { + "artifact_id": "portable-graph.html", + "media_type": "text/html; charset=utf-8", + "sha256": "0" * 64, + "bytes": 1, + }, + ), + "bad_store": lambda value: value["store"].__setitem__("size", -1), + } + for name, mutate in mutations.items(): + with self.subTest(name=name): + value = json.loads(json.dumps(original)) + mutate(value) + value.pop("publication_id") + value["publication_id"] = projection_hash(value) + manifest_path.write_text( + json.dumps(value, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + status = self.service.status("architecture") + self.assertEqual("unverified", status["outputs"][0]["state"]) + self.assertEqual("manifest_invalid", status["outputs"][0]["reason"]) + manifest_path.write_text( + json.dumps(original, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + + artifact = next((self.root / ".docforge/cache/projection-artifacts").glob("*.html")) + artifact.unlink() + missing = self.service.status("architecture") + self.assertEqual("stale", missing["outputs"][0]["state"]) + self.assertEqual("artifact_store_missing", missing["outputs"][0]["reason"]) + repaired = self.service.render("architecture") + self.assertEqual("current", repaired["state"]) + self.assertTrue(artifact.is_file()) + + def test_status_detects_source_and_publication_races(self) -> None: + self.service.render("architecture") + current = self.project.incremental_state() + assert current is not None + changed = ProjectState(source_hash="0" * 64, revision="changed") + with mock.patch.object( + self.project, + "incremental_state", + side_effect=(current, changed), + ): + raced_source = self.service.status("architecture") + self.assertEqual("stale", raced_source["state"]) + self.assertEqual( + "source_changed_during_status", + raced_source["outputs"][0]["reason"], + ) + + baseline = self.service._manifest_status( + self.project.descriptor.graph_render.views[0], # type: ignore[union-attr] + current, + ) + replaced = dict(baseline) + replaced["publication_id"] = "f" * 64 + with mock.patch.object( + self.service, + "_manifest_status", + side_effect=(baseline, replaced), + ): + raced_publication = self.service.status("architecture") + self.assertEqual("stale", raced_publication["state"]) + self.assertEqual( + "publication_changed_during_status", + raced_publication["outputs"][0]["reason"], + ) + + def test_manifest_failure_after_output_is_degraded_success(self) -> None: + with mock.patch.object( + self.service, + "_publish_manifest", + side_effect=DocForgeError( + "publication_failure", + "Synthetic manifest failure", + ), + ): + result = self.service.render("architecture") + self.assertEqual("degraded", result["state"]) + self.assertEqual("published", result["publication"]) + self.assertEqual("manifest", result["committed_stage"]) + self.assertTrue((self.root / ".docforge/portable-graph/architecture.html").is_file()) + self.assertEqual("failed", result["manifest"]["state"]) + + def test_post_commit_stage_failures_are_degraded_and_precommit_failures_raise(self) -> None: + committed = DocForgeError( + "publication_failure", + "Synthetic committed failure", + mutation_committed=True, + ) + cases = ( + ("_publish_artifact", "artifact_store", "partial", False), + ("_publish_output", "output", "published", None), + ) + for method, stage, publication, output_exists in cases: + with self.subTest(method=method): + root = Path(self.temporary.name) / method + shutil.copytree(FIXTURES / "alpha", root) + descriptor = root / ".docforge/project.toml" + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG, + encoding="utf-8", + ) + service = GraphRenderService(Project.open(root)) + with mock.patch.object(service, method, side_effect=committed): + result = service.render("architecture") + self.assertEqual("degraded", result["state"]) + self.assertEqual(stage, result["committed_stage"]) + self.assertEqual(publication, result["publication"]) + if output_exists is not None: + self.assertEqual( + output_exists, + (root / ".docforge/portable-graph/architecture.html").exists(), + ) + + uncommitted = DocForgeError( + "publication_failure", + "Synthetic precommit failure", + mutation_committed=False, + ) + with ( + mock.patch.object( + self.service, + "_publish_output", + side_effect=uncommitted, + ), + self.assertRaises(DocForgeError), + ): + self.service.render("architecture") + + def test_configuration_rejects_unsafe_ambiguous_and_overlapping_views(self) -> None: + cases = { + "both_scope": GRAPH_CONFIG.replace( + 'root = "guide.workflow"', + 'root = "guide.workflow"\nquery = "workflow"', + ), + "output_overlap": GRAPH_CONFIG.replace( + 'output_root = ".docforge/portable-graph"', + 'output_root = "docs/content"', + ), + "active_renderer": GRAPH_CONFIG.replace( + 'renderer = "portable_graph_html"', + 'renderer = "shell"', + ), + "oversized": GRAPH_CONFIG.replace("max_nodes = 20", "max_nodes = 100000"), + "logic_mode": GRAPH_CONFIG.replace('initial_mode = "nodes"', 'initial_mode = "logic"'), + "logic_projection": GRAPH_CONFIG.replace( + "include_logic = false", + "include_logic = true", + ), + "long_title": GRAPH_CONFIG.replace( + 'title = "Alpha architecture"', + f'title = "{"x" * 1025}"', + ), + "long_query": GRAPH_CONFIG.replace( + 'root = "guide.workflow"', + f'query = "{"x" * 10001}"', + ), + "too_many_filters": GRAPH_CONFIG.replace( + 'families = ["guide", "proof"]', + "families = [" + ", ".join(f'"family-{index}"' for index in range(65)) + "]", + ), + } + for name, graph_config in cases.items(): + with self.subTest(name=name): + root = Path(self.temporary.name) / name + shutil.copytree(FIXTURES / "alpha", root) + descriptor = root / ".docforge/project.toml" + descriptor.write_text( + descriptor.read_text(encoding="utf-8") + graph_config, + encoding="utf-8", + ) + parsed = tomllib.loads(descriptor.read_text(encoding="utf-8")) + if name != "output_overlap": + self.assertFalse(Draft202012Validator(PROJECT_SCHEMA).is_valid(parsed)) + with self.assertRaises(DocForgeError): + Project.open(root) + + relocated = Path(self.temporary.name) / "descriptor-overlap" + shutil.copytree(FIXTURES / "alpha", relocated) + descriptor = relocated / ".docforge/project.toml" + base = descriptor.read_text(encoding="utf-8").replace( + 'cache_root = ".docforge/cache"\nindex = ".docforge/cache/index.sqlite3"', + 'cache_root = "var/cache"\nindex = "var/cache/index.sqlite3"', + ) + descriptor.write_text( + base + + GRAPH_CONFIG.replace( + 'output_root = ".docforge/portable-graph"', + 'output_root = ".docforge"', + ), + encoding="utf-8", + ) + with self.assertRaises(DocForgeError): + Project.open(relocated) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_graph_rendering.py b/tests/test_graph_rendering.py new file mode 100644 index 0000000..bdb4831 --- /dev/null +++ b/tests/test_graph_rendering.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import os +import shutil +import sqlite3 +import tempfile +import unittest +from pathlib import Path +from typing import cast +from unittest import mock + +from docforge.errors import DocForgeError +from docforge.graph_projection import ( + GraphViewRequestV1, + build_graph_projection_package, + build_graph_view_plan, +) +from docforge.project import Project +from docforge.projection_contract import ProjectionPackageV1 +from docforge_renderers.graph import PortableGraphHtmlRenderer + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests" / "fixtures" + + +class PortableGraphRenderingTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) / "alpha" + shutil.copytree(FIXTURES / "alpha", self.root) + self.project = Project.open(self.root) + self.snapshot = self.project.load() + self.request = GraphViewRequestV1( + view_id="architecture", + title="Alpha architecture", + root_node_id="guide.workflow", + depth=2, + max_nodes=20, + max_edges=40, + max_work=1_000, + ) + self.plan = build_graph_view_plan(self.snapshot, self.request, False) + self.package = build_graph_projection_package( + self.plan, + renderer_id=PortableGraphHtmlRenderer.renderer_id, + renderer_version=PortableGraphHtmlRenderer.renderer_version, + max_output_bytes=1_000_000, + ) + + def test_portable_artifact_is_deterministic_self_contained_and_generation_bound(self) -> None: + renderer = PortableGraphHtmlRenderer() + first = renderer.render(self.package) + second = renderer.render(self.package) + self.assertEqual(first.artifacts, second.artifacts) + self.assertEqual(1, len(first.artifacts)) + artifact = first.artifacts[0] + self.assertEqual("portable-graph.html", artifact.artifact_id) + self.assertEqual("text/html; charset=utf-8", artifact.media_type) + receipt = first.receipt.as_dict() + evidence = cast(list[dict[str, object]], receipt["artifacts"]) + self.assertEqual( + hashlib.sha256(artifact.content).hexdigest(), + evidence[0]["sha256"], + ) + rendered = artifact.content.decode("utf-8") + self.assertIn("Content-Security-Policy", rendered) + self.assertIn("default-src 'none'", rendered) + self.assertIn('type="application/json"', rendered) + self.assertNotIn(str(self.root), rendered) + self.assertNotIn("docs/content/", rendered) + self.assertNotIn("Editors change canonical nodes", rendered) + self.assertNotIn("fetch(", rendered) + self.assertNotIn("XMLHttpRequest", rendered) + self.assertNotIn("WebSocket", rendered) + self.assertIn(self.snapshot.source_hash, rendered) + + def test_embedded_plan_is_exact_and_script_breakout_is_inert(self) -> None: + plan = copy.deepcopy(self.plan.as_dict()) + view = plan["view"] + assert isinstance(view, dict) + view["title"] = '' + plan.pop("plan_id") + from docforge.projection_contract import GraphViewPlanV1 + + malicious = GraphViewPlanV1.create(plan) + package = build_graph_projection_package( + malicious, + renderer_id=PortableGraphHtmlRenderer.renderer_id, + renderer_version=PortableGraphHtmlRenderer.renderer_version, + max_output_bytes=1_000_000, + ) + rendered = PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8") + self.assertNotIn('", 1)[0] + self.assertEqual(malicious.as_dict(), json.loads(embedded)) + + def test_renderer_has_no_project_database_or_filesystem_write_capability(self) -> None: + forbidden = AssertionError("portable graph renderer crossed its capability boundary") + with ( + mock.patch.object(Project, "open", side_effect=forbidden), + mock.patch.object(Project, "load", side_effect=forbidden), + mock.patch.object(sqlite3, "connect", side_effect=forbidden), + mock.patch.object(Path, "write_bytes", side_effect=forbidden), + mock.patch.object(Path, "write_text", side_effect=forbidden), + mock.patch.object(Path, "mkdir", side_effect=forbidden), + mock.patch.object(os, "replace", side_effect=forbidden), + mock.patch.object(os, "rename", side_effect=forbidden), + mock.patch.object(os, "unlink", side_effect=forbidden), + ): + result = PortableGraphHtmlRenderer().render(self.package) + self.assertEqual(1, len(result.artifacts)) + + def test_renderer_rejects_wrong_kind_identity_assets_and_size(self) -> None: + wrong = ProjectionPackageV1.create( + kind="graph", + plan=self.plan, + renderer={ + "renderer_id": PortableGraphHtmlRenderer.renderer_id, + "renderer_version": "other", + }, + components=[], + assets=[], + output_policy={ + "artifact_ids": ["portable-graph.html"], + "max_total_bytes": 1_000_000, + }, + ) + with self.assertRaises(DocForgeError) as unsupported: + PortableGraphHtmlRenderer().render(wrong) + self.assertEqual("unsupported_renderer", unsupported.exception.code) + + with_asset = ProjectionPackageV1.create( + kind="graph", + plan=self.plan, + renderer={ + "renderer_id": PortableGraphHtmlRenderer.renderer_id, + "renderer_version": PortableGraphHtmlRenderer.renderer_version, + }, + components=[], + assets=[ + { + "asset_id": "project-script", + "media_type": "text/javascript", + "sha256": "0" * 64, + "text": "alert(1)", + } + ], + output_policy={"artifact_ids": ["portable-graph.html"], "max_total_bytes": 1_000_000}, + ) + with self.assertRaises(DocForgeError) as assets: + PortableGraphHtmlRenderer().render(with_asset) + self.assertEqual("invalid_projection", assets.exception.code) + + wrong_components = ProjectionPackageV1.create( + kind="graph", + plan=self.plan, + renderer={ + "renderer_id": PortableGraphHtmlRenderer.renderer_id, + "renderer_version": PortableGraphHtmlRenderer.renderer_version, + }, + components=[], + assets=[], + output_policy={ + "artifact_ids": ["portable-graph.html"], + "max_total_bytes": 1_000_000, + }, + ) + with self.assertRaises(DocForgeError) as components: + PortableGraphHtmlRenderer().render(wrong_components) + self.assertEqual("invalid_projection", components.exception.code) + + wrong_artifact = ProjectionPackageV1.create( + kind="graph", + plan=self.plan, + renderer={ + "renderer_id": PortableGraphHtmlRenderer.renderer_id, + "renderer_version": PortableGraphHtmlRenderer.renderer_version, + }, + components=self.package.document["components"], # type: ignore[arg-type] + assets=[], + output_policy={ + "artifact_ids": ["unexpected.html"], + "max_total_bytes": 1_000_000, + }, + ) + with self.assertRaises(DocForgeError) as artifact: + PortableGraphHtmlRenderer().render(wrong_artifact) + self.assertEqual("invalid_projection", artifact.exception.code) + + tiny = build_graph_projection_package( + self.plan, + renderer_id=PortableGraphHtmlRenderer.renderer_id, + renderer_version=PortableGraphHtmlRenderer.renderer_version, + max_output_bytes=1, + ) + with self.assertRaises(DocForgeError) as too_large: + PortableGraphHtmlRenderer().render(tiny) + self.assertEqual("render_too_large", too_large.exception.code) + + def test_initial_mode_is_honored_and_logic_is_rejected(self) -> None: + for mode in ("nodes", "flow", "web"): + with self.subTest(mode=mode): + plan = build_graph_view_plan( + self.snapshot, + GraphViewRequestV1( + view_id=f"{mode}-view", + title=f"{mode.title()} view", + root_node_id="guide.workflow", + initial_mode=mode, # type: ignore[arg-type] + depth=2, + max_nodes=20, + max_edges=40, + max_work=1_000, + ), + False, + ) + package = build_graph_projection_package( + plan, + renderer_id=PortableGraphHtmlRenderer.renderer_id, + renderer_version=PortableGraphHtmlRenderer.renderer_version, + max_output_bytes=1_000_000, + ) + rendered = ( + PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8") + ) + self.assertIn(f'data-mode="{mode}"', rendered) + self.assertIn(f'