1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Add versioned independent projection contracts

This commit is contained in:
Andraxion 2026-07-29 10:50:05 -04:00
parent 4c5773c865
commit 96e3965855
22 changed files with 3561 additions and 133 deletions

View file

@ -1,19 +1,16 @@
# Active milestone # Active milestone
```text ```text
Milestone: 2 — agent retrieval and MCP experience Milestone: 3 — independent projections
Goal: Let one project-bound server return compact, task-shaped, explainable context under an explicit effective policy. Goal: Make manual output, portable graph artifacts, and the live viewer independent generation-pinned consumers of the validated graph.
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. 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: Independent render-plan packages; adapter SDK expansion; self-hosting; storage replacement; embeddings; WorldForge or ScrapeStation changes; production MCP repointing; tags and releases. 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: 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. 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: Complete. Effective policy, versioned task retrieval, latest-generation diff receipts, Status: Active implementation. Three independent audits were reconciled before source changes. The
logarithmic bounded page packing, deterministic client configuration, and the read-only integration versioned plan/package/receipt contracts, pure manual and graph planners, isolated manual renderer,
doctor are implemented and contract-tested. The complete repository gate passes with 205 tests and legacy byte-compatibility shim, packaged schemas, and pinned live-source correction are implemented
120 subtests. Three independent adversarial audits found no remaining implementation blocker. The and focused-green. Durable publication, portable graph artifacts, detached workers, fragment
clean 1,000-node baseline is recorded against candidate commit equivalence, independent policy enforcement, accessibility, and maintained scale gates remain.
`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.
``` ```
Milestones 35 remain directional context and are not active. Milestones 45 remain directional context and are not active.

View file

@ -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 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 model requires it, verify a native Claude timeout representation, and introduce adapter-owned
launcher metadata before generating configurations for custom adapters. 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.

View file

@ -28,6 +28,9 @@ contract:
tests/test_retrieval.py \ tests/test_retrieval.py \
tests/test_generation_diff.py \ tests/test_generation_diff.py \
tests/test_client_integration.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_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_policy_rejects_logic_publication \
tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters \ tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters \

View file

@ -31,7 +31,10 @@ docforge-mcp = "docforge.mcp_server:main"
docforge-viewer-manager = "docforge.viewer_manager:main" docforge-viewer-manager = "docforge.viewer_manager:main"
[tool.hatch.build.targets.wheel] [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] [tool.ruff]
line-length = 100 line-length = 100

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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",
},
}
)

View file

@ -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,
},
)

View file

@ -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",
)

1
src/docforge/py.typed Normal file
View file

@ -0,0 +1 @@
# PEP 561 marker for the typed DocForge public package.

View file

@ -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 from __future__ import annotations
import hashlib import hashlib
import html
import json import json
import re
from dataclasses import dataclass from dataclasses import dataclass
from importlib.metadata import version from importlib.metadata import version
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Protocol
from markdown_it import MarkdownIt
from .errors import DocForgeError from .errors import DocForgeError
from .models import Edge, Node, ProjectSnapshot, RenderView from .manual_projection import build_manual_projection_package, build_manual_render_plan
from .models import 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",
}
)
@dataclass(frozen=True) @dataclass(frozen=True)
@ -55,13 +41,12 @@ class Renderer(Protocol):
class GenericHtmlRenderer: 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" renderer_id = "generic_html"
contract_version = "1" contract_version = "1"
def __init__(self) -> None: def __init__(self) -> None:
self.markdown = MarkdownIt("commonmark", {"html": False, "typographer": False})
self.renderer_version = ( self.renderer_version = (
f"{self.contract_version}+markdown-it-py-{version('markdown-it-py')}" f"{self.contract_version}+markdown-it-py-{version('markdown-it-py')}"
) )
@ -74,22 +59,6 @@ class GenericHtmlRenderer:
*, *,
changeset_hash: str | None, changeset_hash: str | None,
) -> PreparedRender: ) -> 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( selected = tuple(
node for node in snapshot.nodes if not view.families or node.family in view.families 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( render_identity = hashlib.sha256(
json.dumps(identity_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") json.dumps(identity_payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest() ).hexdigest()
content = self._content(selected, selected_edges) plan = build_manual_render_plan(snapshot, view, changeset_hash=changeset_hash)
replacements = { package = build_manual_projection_package(
"docforge_content": content, plan,
"docforge_project_id": html.escape(snapshot.descriptor.project_id, quote=True), template_bytes,
"docforge_render_identity": render_identity, renderer_id=self.renderer_id,
"docforge_title": html.escape(view.title, quote=True), renderer_version=self.renderer_version,
"docforge_view_id": html.escape(view.view_id, quote=True), max_output_bytes=snapshot.descriptor.limits.max_render_bytes,
} )
rendered = _TEMPLATE_TOKEN.sub(lambda match: replacements[match.group(1)], template) from docforge_renderers.manual import ManualHtmlRenderer
output = rendered.rstrip().encode("utf-8") + b"\n"
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( return PreparedRender(
render_identity=render_identity, render_identity=render_identity,
output_hash=hashlib.sha256(output).hexdigest(), output_hash=hashlib.sha256(output).hexdigest(),
@ -147,44 +126,6 @@ class GenericHtmlRenderer:
template_hash=template_hash, template_hash=template_hash,
) )
def _content(self, nodes: tuple[Node, ...], edges: tuple[Edge, ...]) -> str:
navigation = ['<nav aria-label="Documentation"><ul>']
for node in nodes:
navigation.append(
f'<li><a href="#node-{html.escape(node.node_id, quote=True)}">'
f"{html.escape(node.title)}</a></li>"
)
navigation.append("</ul></nav>")
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'<section id="node-{html.escape(node.node_id, quote=True)}">',
f"<h2>{html.escape(node.title)}</h2>",
'<dl class="docforge-node-meta">',
f"<dt>ID</dt><dd>{html.escape(node.node_id)}</dd>",
f"<dt>Family</dt><dd>{html.escape(node.family)}</dd>",
f"<dt>Status</dt><dd>{html.escape(node.status)}</dd>",
f"<dt>Authority</dt><dd>{html.escape(node.authority)}</dd>",
"</dl>",
f'<p class="docforge-summary">{html.escape(node.summary)}</p>',
self.markdown.render(node.content).rstrip(),
]
)
relationships = edge_map.get(node.node_id, [])
if relationships:
sections.append('<ul class="docforge-relationships">')
for edge in relationships:
sections.append(
f"<li>{html.escape(edge.relation)}: {html.escape(edge.target_id)}</li>"
)
sections.append("</ul>")
sections.append("</section>")
return "\n".join(sections)
_RENDERERS: dict[str, type[GenericHtmlRenderer]] = { _RENDERERS: dict[str, type[GenericHtmlRenderer]] = {
GenericHtmlRenderer.renderer_id: GenericHtmlRenderer GenericHtmlRenderer.renderer_id: GenericHtmlRenderer

View file

@ -506,11 +506,11 @@ class VisualizationIndexSnapshot:
) )
def source(self, node_id: str) -> dict[str, object]: 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: with self._connection() as connection:
row = connection.execute( 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,), (node_id,),
).fetchone() ).fetchone()
if row is None: if row is None:
@ -518,7 +518,8 @@ class VisualizationIndexSnapshot:
""" """
SELECT logic.logic_id AS node_id, SELECT logic.logic_id AS node_id,
owner.source_path AS source_path, 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 FROM logic_nodes AS logic
JOIN nodes AS owner ON owner.node_id = logic.owner_node_id JOIN nodes AS owner ON owner.node_id = logic.owner_node_id
WHERE logic.logic_id = ? WHERE logic.logic_id = ?
@ -533,51 +534,26 @@ class VisualizationIndexSnapshot:
"No node has the requested stable ID", "No node has the requested stable ID",
node_id=node_id, node_id=node_id,
) )
relative = Path(row["source_path"]) content = row["content"]
if relative.is_absolute() or ".." in relative.parts or not relative.parts: if not isinstance(content, str):
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:
raise DocForgeError( raise DocForgeError(
"missing_source", "invalid_index",
"Node source file is unavailable", "Pinned source evidence is invalid",
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",
node_id=node_id, node_id=node_id,
) )
raw = source.read_bytes() raw = content.encode("utf-8")
if len(raw) > self.max_source_bytes: if len(raw) > self.max_source_bytes:
raise DocForgeError( raise DocForgeError(
"source_too_large", "source_too_large",
"Node source exceeds the configured source limit", "Pinned source evidence exceeds the configured source limit",
node_id=node_id, 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( return self._result(
node_id=node_id, node_id=node_id,
source_path=row["source_path"], source_path=row["source_path"],
source_anchor=row["source_anchor"], source_anchor=row["source_anchor"],
content=content, content=content,
source_provenance="index_snapshot",
snapshot=True, snapshot=True,
) )

View file

@ -0,0 +1 @@
"""Capability-isolated renderer implementations for DocForge projection packages."""

View file

@ -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 = ['<nav aria-label="Documentation"><ul>']
for value in cast(list[object], plan["navigation"]):
item = cast(dict[str, object], value)
navigation.append(
f'<li><a href="#node-{html.escape(cast(str, item["node_id"]), quote=True)}">'
f"{html.escape(cast(str, item['title']))}</a></li>"
)
navigation.append("</ul></nav>")
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'<section id="node-{html.escape(node_id, quote=True)}">',
f"<h2>{html.escape(cast(str, page['title']))}</h2>",
'<dl class="docforge-node-meta">',
f"<dt>ID</dt><dd>{html.escape(node_id)}</dd>",
f"<dt>Family</dt><dd>{html.escape(cast(str, page['family']))}</dd>",
f"<dt>Status</dt><dd>{html.escape(cast(str, page['status']))}</dd>",
f"<dt>Authority</dt><dd>{html.escape(cast(str, page['authority']))}</dd>",
"</dl>",
f'<p class="docforge-summary">{html.escape(cast(str, page["summary"]))}</p>',
self.markdown.render(cast(str, page["content"])).rstrip(),
]
)
relationships = cast(list[object], page["cross_references"])
if relationships:
sections.append('<ul class="docforge-relationships">')
for relationship_value in relationships:
relationship = cast(dict[str, object], relationship_value)
sections.append(
f"<li>{html.escape(cast(str, relationship['relation']))}: "
f"{html.escape(cast(str, relationship['target_id']))}</li>"
)
sections.append("</ul>")
sections.append("</section>")
return "\n".join(sections)

View file

@ -0,0 +1 @@
# PEP 561 marker for the typed DocForge renderer package.

View file

@ -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()

View file

@ -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"<!DOCTYPE html>", prepared.output.splitlines()[0])
self.assertTrue(prepared.output.endswith(b"</html>\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 (
"<script>alert(1)</script>{{ docforge_content }}",
'<main onclick="alert(1)">{{ docforge_content }}</main>',
'<a href="javascript:alert(1)">{{ docforge_content }}</a>',
'<iframe src="https://example.invalid"></iframe>{{ docforge_content }}',
'<meta http-equiv="refresh" content="0">{{ 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()

View file

@ -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()

View file

@ -64,6 +64,14 @@ PUBLIC_IMPORTS = {
"docforge.client_config": ("generate_client_configuration",), "docforge.client_config": ("generate_client_configuration",),
"docforge.doctor": ("run_doctor",), "docforge.doctor": ("run_doctor",),
"docforge.index": ("ProjectIndex",), "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": ( "docforge.mcp_server": (
"create_project_server", "create_project_server",
"create_read_only_server", "create_read_only_server",
@ -84,6 +92,16 @@ PUBLIC_IMPORTS = {
"capability_mode", "capability_mode",
"compose_effective_policy", "compose_effective_policy",
), ),
"docforge.projection_contract": (
"GraphViewPlanV1",
"ManualRenderPlanV1",
"ProjectionArtifact",
"ProjectionPackageV1",
"ProjectionReceiptV1",
"ProjectionRenderResult",
"canonical_projection_bytes",
"projection_hash",
),
"docforge.retrieval": ( "docforge.retrieval": (
"ContextCapsuleV1", "ContextCapsuleV1",
"RetrievalPlanV1", "RetrievalPlanV1",
@ -97,6 +115,7 @@ PUBLIC_IMPORTS = {
"Renderer", "Renderer",
"renderer_for", "renderer_for",
), ),
"docforge_renderers.manual": ("ManualHtmlRenderer",),
} }
EXPECTED_ENTRY_POINTS = { EXPECTED_ENTRY_POINTS = {

View file

@ -386,6 +386,25 @@ class VisualizationTests(unittest.TestCase):
with self.assertRaisesRegex(DocForgeError, "category is unsupported"): with self.assertRaisesRegex(DocForgeError, "category is unsupported"):
snapshot.filter_nodes(category="relation", value="depends_on", limit=2) 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: def test_flow_reverses_imports_into_a_complete_structural_path(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory)) root = self.copy_fixture("alpha", Path(directory))