Add versioned independent projection contracts
This commit is contained in:
parent
4c5773c865
commit
96e3965855
22 changed files with 3561 additions and 133 deletions
|
|
@ -1,19 +1,16 @@
|
|||
# Active milestone
|
||||
|
||||
```text
|
||||
Milestone: 2 — agent retrieval and MCP experience
|
||||
Goal: Let one project-bound server return compact, task-shaped, explainable context under an explicit effective policy.
|
||||
In scope: Capability modes; capability-aware bootstrap; versioned retrieval plans and context capsules; task-shaped context; generation diffs; evidence-gap diagnostics; generated client configuration; doctor checks.
|
||||
Out of scope: Independent render-plan packages; adapter SDK expansion; self-hosting; storage replacement; embeddings; WorldForge or ScrapeStation changes; production MCP repointing; tags and releases.
|
||||
Done when: Policy and capabilities are explicit; bootstrap recommends only available actions; task context is compact, deterministic, provenance-bearing, and bounded; generation and evidence gaps are explainable; generated configuration and doctor checks are safe and tested; the complete repository gate and Milestone 2 benchmark pass.
|
||||
Status: Complete. Effective policy, versioned task retrieval, latest-generation diff receipts,
|
||||
logarithmic bounded page packing, deterministic client configuration, and the read-only integration
|
||||
doctor are implemented and contract-tested. The complete repository gate passes with 205 tests and
|
||||
120 subtests. Three independent adversarial audits found no remaining implementation blocker. The
|
||||
clean 1,000-node baseline is recorded against candidate commit
|
||||
`fb0df5e4a1c591c2a84788fd4814d98550f11863`, including task/generation reconstruction,
|
||||
response-size behavior, zero-hidden-work counters, and isolated memory. No tag or release was
|
||||
created, no production integration was repointed, and self-hosting remains out of scope.
|
||||
Milestone: 3 — independent projections
|
||||
Goal: Make manual output, portable graph artifacts, and the live viewer independent generation-pinned consumers of the validated graph.
|
||||
In scope: Versioned ManualRenderPlan and GraphViewPlan; immutable projection packages and receipts; independent manual and graph renderers; projection policies; incremental fragments; equivalence, recovery, accessibility, response-size, performance, and memory gates.
|
||||
Out of scope: Adapter SDK expansion; remote render services; shared render farms; third-party renderer ecosystems; self-hosting; storage replacement; WorldForge or ScrapeStation changes; production MCP repointing; tags and releases.
|
||||
Done when: Manual and graph plans are versioned and bounded; renderers cannot crawl project state or mutate canonical facts; portable and live graph modes remain separate; policies are enforced independently; status is receipt-only; full/incremental output is equivalent; accessibility and maintained scale gates pass.
|
||||
Status: Active implementation. Three independent audits were reconciled before source changes. The
|
||||
versioned plan/package/receipt contracts, pure manual and graph planners, isolated manual renderer,
|
||||
legacy byte-compatibility shim, packaged schemas, and pinned live-source correction are implemented
|
||||
and focused-green. Durable publication, portable graph artifacts, detached workers, fragment
|
||||
equivalence, independent policy enforcement, accessibility, and maintained scale gates remain.
|
||||
```
|
||||
|
||||
Milestones 3–5 remain directional context and are not active.
|
||||
Milestones 4–5 remain directional context and are not active.
|
||||
|
|
|
|||
|
|
@ -610,3 +610,90 @@ Milestone 2 is complete. Follow-up ideas stay explicitly later-scope: avoid reco
|
|||
task-shaped capsule for every continuation page, add authenticated continuation when the threat
|
||||
model requires it, verify a native Claude timeout representation, and introduce adapter-owned
|
||||
launcher metadata before generating configurations for custom adapters.
|
||||
|
||||
## Milestone 3 — active: independent projections
|
||||
|
||||
Milestone 3 began only after `main` and `dev` were aligned at the verified Milestone 2 closeout.
|
||||
Three read-only audits are running before source changes:
|
||||
|
||||
- Manual planning, immutable packages, renderer isolation, receipts, preview/application
|
||||
integration, and full/incremental equivalence.
|
||||
- Portable graph planning, static artifacts, the live viewer boundary, worker protocol, and static
|
||||
plus interactive accessibility.
|
||||
- Packaging, optional dependencies, public contracts, projection policies, performance,
|
||||
incremental fragments, and maintained gates.
|
||||
|
||||
The active design constraints are unchanged: renderers consume one validated immutable generation;
|
||||
manual and graph plans remain separate; the live viewer is not retrieval authority; core remains
|
||||
usable without rendering; status performs no hidden rendering; full rendering remains the recovery
|
||||
and equivalence oracle; no storage rewrite is assumed.
|
||||
|
||||
### Milestone 3 architecture decision
|
||||
|
||||
The three audits converged on one compatibility-first boundary:
|
||||
|
||||
- The existing `docforge.render_contract` names, `GenericHtmlRenderer.prepare()` signature,
|
||||
`generic_html` renderer identity, and byte output remain the version-1 compatibility surface.
|
||||
They become adapters over the new manual-planning path rather than being changed in place.
|
||||
- New `ManualRenderPlanV1`, `GraphViewPlanV1`, `ProjectionPackageV1`, and
|
||||
`ProjectionReceiptV1` contracts use strict canonical JSON, deterministic ordering, independent
|
||||
item and byte bounds, exact generation and policy binding, and content-derived identities.
|
||||
- Plans and packages contain selected graph facts and bounded content. They never contain a
|
||||
project object, SQLite handle, absolute project or index path, arbitrary query, command, or
|
||||
project-provided executable code.
|
||||
- The planner owns graph selection and meaning. A renderer may transform only a validated package
|
||||
into declared artifacts and cannot select nodes, invent relationships, crawl the project, choose
|
||||
publication paths, or mutate canonical sources.
|
||||
- Manual and portable graph renderers live behind independent import boundaries. Renderer
|
||||
dependencies load lazily. Default installation behavior remains compatible during the initial
|
||||
migration; optional dependency changes require their own verified packaging decision.
|
||||
- Portable graph rendering is additive. It does not replace or silently change
|
||||
`docforge_visualize`, `graph-browser@17`, the viewer-manager protocol, or the query-backed live
|
||||
viewer.
|
||||
- Effective policy version 1 remains frozen. Milestone 3 introduces a version-2 projection-policy
|
||||
view for manual `auto|explicit|disabled`, portable graph `explicit|disabled`, and live viewer
|
||||
`on-demand|disabled` enforcement, while retaining the version-1 projection for existing clients.
|
||||
- Publication commits content-addressed artifacts first, renderer evidence second, and a bounded
|
||||
generation/view manifest last. Status remains receipt-only. Failures after artifact replacement
|
||||
report committed degraded success rather than an ordinary failed mutation.
|
||||
- Full planning and rendering remain the recovery and equivalence oracle. Incremental fragments
|
||||
are disposable, keyed from complete plan semantics, and may be reused only when byte-exact
|
||||
artifact equivalence is proven.
|
||||
- The live source endpoint must stop reading mutable canonical files behind a pinned graph
|
||||
snapshot. Portable artifacts never inherit that path-bearing behavior.
|
||||
|
||||
The first implementation slice freezes existing golden output, adds the four versioned contracts
|
||||
and validators, introduces pure manual and graph planners, and makes the legacy manual renderer a
|
||||
compatibility wrapper. Publication hardening, detached rendering, incremental fragments, portable
|
||||
graph publication, independent policy enforcement, accessibility, and maintained performance
|
||||
gates follow on top of that frozen boundary.
|
||||
|
||||
### Milestone 3 contract slice
|
||||
|
||||
The first slice now implements:
|
||||
|
||||
- Strict Draft 2020-12 schemas and runtime canonical-hash validation for manual plans, graph plans,
|
||||
projection packages, and projection receipts.
|
||||
- A deterministic manual planner that owns page selection, navigation, cross-references,
|
||||
backlinks, search documents, component assignments, orphan diagnostics, and cycle diagnostics.
|
||||
- A deterministic graph planner with exact-root or metadata-only lexical scope, closed filters,
|
||||
explicit node/edge/work bounds, deterministic omissions, path/source-body exclusion, and
|
||||
no-AST Logic exclusion.
|
||||
- A separate `docforge_renderers.manual` package. Its renderer accepts only a validated package and
|
||||
has no project, SQLite, publication-path, or filesystem-write API.
|
||||
- The frozen `GenericHtmlRenderer` compatibility shim over the new planner/package/renderer
|
||||
pipeline. The alpha artifact remains exactly 2,043 bytes with output SHA-256
|
||||
`81656bb89debc7ad1fbe8bc290e9a3ba90664442b17a6d57e908d30d20c47f77` and legacy render identity
|
||||
`1c0a49c28ba3b0dabf94be36e75def197dee1be3cb73ac405b09875383c8dc5f`.
|
||||
- Rejection of project-template scripts, inline event handlers, `javascript:` URLs, embedded
|
||||
browsing contexts, and refresh redirects.
|
||||
- Wheel inclusion for both typed packages and every published JSON schema. Importing `docforge`
|
||||
no longer imports `markdown_it` or the manual renderer package.
|
||||
- A live-viewer correction: source evidence now comes from the pinned index generation. The
|
||||
viewer no longer reopens mutable canonical files behind an older graph snapshot.
|
||||
|
||||
The new repository-native contract target passes 91 tests and 120 subtests. The combined
|
||||
projection, rendering, and live-viewer focus passes with byte-exact compatibility and no hidden
|
||||
source/path authority. This is not Milestone 3 closeout: durable multi-artifact publication,
|
||||
portable graph rendering, detached workers, fragment reuse/equivalence, policy version 2,
|
||||
accessibility, and maintained scale evidence remain active work.
|
||||
|
|
|
|||
3
Makefile
3
Makefile
|
|
@ -28,6 +28,9 @@ contract:
|
|||
tests/test_retrieval.py \
|
||||
tests/test_generation_diff.py \
|
||||
tests/test_client_integration.py \
|
||||
tests/test_projection_contract.py \
|
||||
tests/test_projection_schemas.py \
|
||||
tests/test_graph_projection.py \
|
||||
tests/test_observability.py::TelemetryContractTests::test_schema_fixed_names_match_the_implementation \
|
||||
tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_policy_rejects_logic_publication \
|
||||
tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters \
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ docforge-mcp = "docforge.mcp_server:main"
|
|||
docforge-viewer-manager = "docforge.viewer_manager:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/docforge"]
|
||||
packages = ["src/docforge", "src/docforge_renderers"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
schemas = "docforge/schemas"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
|
|
|||
321
schemas/graph-view-plan.schema.json
Normal file
321
schemas/graph-view-plan.schema.json
Normal 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
|
||||
}
|
||||
214
schemas/manual-render-plan.schema.json
Normal file
214
schemas/manual-render-plan.schema.json
Normal 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
|
||||
}
|
||||
190
schemas/projection-package.schema.json
Normal file
190
schemas/projection-package.schema.json
Normal 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
|
||||
}
|
||||
89
schemas/projection-receipt.schema.json
Normal file
89
schemas/projection-receipt.schema.json
Normal 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
|
||||
}
|
||||
497
src/docforge/graph_projection.py
Normal file
497
src/docforge/graph_projection.py
Normal 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",
|
||||
},
|
||||
}
|
||||
)
|
||||
191
src/docforge/manual_projection.py
Normal file
191
src/docforge/manual_projection.py
Normal 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,
|
||||
},
|
||||
)
|
||||
502
src/docforge/projection_contract.py
Normal file
502
src/docforge/projection_contract.py
Normal 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
1
src/docforge/py.typed
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PEP 561 marker for the typed DocForge public package.
|
||||
|
|
@ -1,31 +1,17 @@
|
|||
"""Deterministic built-in renderer contract and safe template primitives."""
|
||||
"""Compatibility shim over the versioned manual projection boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
|
||||
from .errors import DocForgeError
|
||||
from .models import Edge, Node, ProjectSnapshot, RenderView
|
||||
|
||||
_TEMPLATE_TOKEN = re.compile(r"{{\s*([a-z_][a-z0-9_]*)\s*}}")
|
||||
_ALLOWED_TOKENS = frozenset(
|
||||
{
|
||||
"docforge_content",
|
||||
"docforge_project_id",
|
||||
"docforge_render_identity",
|
||||
"docforge_title",
|
||||
"docforge_view_id",
|
||||
}
|
||||
)
|
||||
from .manual_projection import build_manual_projection_package, build_manual_render_plan
|
||||
from .models import ProjectSnapshot, RenderView
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -55,13 +41,12 @@ class Renderer(Protocol):
|
|||
|
||||
|
||||
class GenericHtmlRenderer:
|
||||
"""Render validated nodes through escaped CommonMark and a strict token template."""
|
||||
"""Preserve the public v1 renderer API over the plan-only manual renderer."""
|
||||
|
||||
renderer_id = "generic_html"
|
||||
contract_version = "1"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.markdown = MarkdownIt("commonmark", {"html": False, "typographer": False})
|
||||
self.renderer_version = (
|
||||
f"{self.contract_version}+markdown-it-py-{version('markdown-it-py')}"
|
||||
)
|
||||
|
|
@ -74,22 +59,6 @@ class GenericHtmlRenderer:
|
|||
*,
|
||||
changeset_hash: str | None,
|
||||
) -> PreparedRender:
|
||||
try:
|
||||
template = template_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise DocForgeError("invalid_template", "Render template is not valid UTF-8") from error
|
||||
tokens = _TEMPLATE_TOKEN.findall(template)
|
||||
unknown = sorted(set(tokens) - _ALLOWED_TOKENS)
|
||||
remainder = _TEMPLATE_TOKEN.sub("", template)
|
||||
if unknown or "{{" in remainder or "}}" in remainder:
|
||||
raise DocForgeError(
|
||||
"invalid_template", "Render template contains unsupported tokens", tokens=unknown
|
||||
)
|
||||
if tokens.count("docforge_content") != 1:
|
||||
raise DocForgeError(
|
||||
"invalid_template", "Render template must contain docforge_content exactly once"
|
||||
)
|
||||
|
||||
selected = tuple(
|
||||
node for node in snapshot.nodes if not view.families or node.family in view.families
|
||||
)
|
||||
|
|
@ -128,16 +97,26 @@ class GenericHtmlRenderer:
|
|||
render_identity = hashlib.sha256(
|
||||
json.dumps(identity_payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
content = self._content(selected, selected_edges)
|
||||
replacements = {
|
||||
"docforge_content": content,
|
||||
"docforge_project_id": html.escape(snapshot.descriptor.project_id, quote=True),
|
||||
"docforge_render_identity": render_identity,
|
||||
"docforge_title": html.escape(view.title, quote=True),
|
||||
"docforge_view_id": html.escape(view.view_id, quote=True),
|
||||
}
|
||||
rendered = _TEMPLATE_TOKEN.sub(lambda match: replacements[match.group(1)], template)
|
||||
output = rendered.rstrip().encode("utf-8") + b"\n"
|
||||
plan = build_manual_render_plan(snapshot, view, changeset_hash=changeset_hash)
|
||||
package = build_manual_projection_package(
|
||||
plan,
|
||||
template_bytes,
|
||||
renderer_id=self.renderer_id,
|
||||
renderer_version=self.renderer_version,
|
||||
max_output_bytes=snapshot.descriptor.limits.max_render_bytes,
|
||||
)
|
||||
from docforge_renderers.manual import ManualHtmlRenderer
|
||||
|
||||
result = ManualHtmlRenderer(self.renderer_version).render(
|
||||
package,
|
||||
render_identity=render_identity,
|
||||
)
|
||||
if len(result.artifacts) != 1:
|
||||
raise DocForgeError(
|
||||
"invalid_projection",
|
||||
"Manual renderer returned an unsupported artifact set",
|
||||
)
|
||||
output = result.artifacts[0].content
|
||||
return PreparedRender(
|
||||
render_identity=render_identity,
|
||||
output_hash=hashlib.sha256(output).hexdigest(),
|
||||
|
|
@ -147,44 +126,6 @@ class GenericHtmlRenderer:
|
|||
template_hash=template_hash,
|
||||
)
|
||||
|
||||
def _content(self, nodes: tuple[Node, ...], edges: tuple[Edge, ...]) -> str:
|
||||
navigation = ['<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]] = {
|
||||
GenericHtmlRenderer.renderer_id: GenericHtmlRenderer
|
||||
|
|
|
|||
|
|
@ -506,11 +506,11 @@ class VisualizationIndexSnapshot:
|
|||
)
|
||||
|
||||
def source(self, node_id: str) -> dict[str, object]:
|
||||
"""Return one node's bounded, project-confined UTF-8 source file."""
|
||||
"""Return bounded source evidence stored in the pinned index generation."""
|
||||
|
||||
with self._connection() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT node_id, source_path, source_anchor FROM nodes WHERE node_id = ?",
|
||||
"SELECT node_id, source_path, source_anchor, content FROM nodes WHERE node_id = ?",
|
||||
(node_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
|
|
@ -518,7 +518,8 @@ class VisualizationIndexSnapshot:
|
|||
"""
|
||||
SELECT logic.logic_id AS node_id,
|
||||
owner.source_path AS source_path,
|
||||
logic.source_anchor AS source_anchor
|
||||
logic.source_anchor AS source_anchor,
|
||||
owner.content AS content
|
||||
FROM logic_nodes AS logic
|
||||
JOIN nodes AS owner ON owner.node_id = logic.owner_node_id
|
||||
WHERE logic.logic_id = ?
|
||||
|
|
@ -533,51 +534,26 @@ class VisualizationIndexSnapshot:
|
|||
"No node has the requested stable ID",
|
||||
node_id=node_id,
|
||||
)
|
||||
relative = Path(row["source_path"])
|
||||
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
|
||||
raise DocForgeError("path_escape", "Node source path is unsafe", node_id=node_id)
|
||||
source = self.project_root / relative
|
||||
try:
|
||||
resolved = source.resolve(strict=True)
|
||||
except OSError as error:
|
||||
content = row["content"]
|
||||
if not isinstance(content, str):
|
||||
raise DocForgeError(
|
||||
"missing_source",
|
||||
"Node source file is unavailable",
|
||||
node_id=node_id,
|
||||
) from error
|
||||
if (
|
||||
source.is_symlink()
|
||||
or resolved != source
|
||||
or not source.is_relative_to(self.project_root)
|
||||
or not source.is_file()
|
||||
):
|
||||
raise DocForgeError("path_escape", "Node source file is unsafe", node_id=node_id)
|
||||
if source.stat().st_size > self.max_source_bytes:
|
||||
raise DocForgeError(
|
||||
"source_too_large",
|
||||
"Node source exceeds the configured source limit",
|
||||
"invalid_index",
|
||||
"Pinned source evidence is invalid",
|
||||
node_id=node_id,
|
||||
)
|
||||
raw = source.read_bytes()
|
||||
raw = content.encode("utf-8")
|
||||
if len(raw) > self.max_source_bytes:
|
||||
raise DocForgeError(
|
||||
"source_too_large",
|
||||
"Node source exceeds the configured source limit",
|
||||
"Pinned source evidence exceeds the configured source limit",
|
||||
node_id=node_id,
|
||||
)
|
||||
try:
|
||||
content = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise DocForgeError(
|
||||
"invalid_source",
|
||||
"Node source is not UTF-8",
|
||||
node_id=node_id,
|
||||
) from error
|
||||
return self._result(
|
||||
node_id=node_id,
|
||||
source_path=row["source_path"],
|
||||
source_anchor=row["source_anchor"],
|
||||
content=content,
|
||||
source_provenance="index_snapshot",
|
||||
snapshot=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
1
src/docforge_renderers/__init__.py
Normal file
1
src/docforge_renderers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Capability-isolated renderer implementations for DocForge projection packages."""
|
||||
172
src/docforge_renderers/manual.py
Normal file
172
src/docforge_renderers/manual.py
Normal 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)
|
||||
1
src/docforge_renderers/py.typed
Normal file
1
src/docforge_renderers/py.typed
Normal file
|
|
@ -0,0 +1 @@
|
|||
# PEP 561 marker for the typed DocForge renderer package.
|
||||
404
tests/test_graph_projection.py
Normal file
404
tests/test_graph_projection.py
Normal 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()
|
||||
486
tests/test_projection_contract.py
Normal file
486
tests/test_projection_contract.py
Normal 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()
|
||||
314
tests/test_projection_schemas.py
Normal file
314
tests/test_projection_schemas.py
Normal 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()
|
||||
|
|
@ -64,6 +64,14 @@ PUBLIC_IMPORTS = {
|
|||
"docforge.client_config": ("generate_client_configuration",),
|
||||
"docforge.doctor": ("run_doctor",),
|
||||
"docforge.index": ("ProjectIndex",),
|
||||
"docforge.graph_projection": (
|
||||
"GraphViewRequestV1",
|
||||
"build_graph_view_plan",
|
||||
),
|
||||
"docforge.manual_projection": (
|
||||
"build_manual_projection_package",
|
||||
"build_manual_render_plan",
|
||||
),
|
||||
"docforge.mcp_server": (
|
||||
"create_project_server",
|
||||
"create_read_only_server",
|
||||
|
|
@ -84,6 +92,16 @@ PUBLIC_IMPORTS = {
|
|||
"capability_mode",
|
||||
"compose_effective_policy",
|
||||
),
|
||||
"docforge.projection_contract": (
|
||||
"GraphViewPlanV1",
|
||||
"ManualRenderPlanV1",
|
||||
"ProjectionArtifact",
|
||||
"ProjectionPackageV1",
|
||||
"ProjectionReceiptV1",
|
||||
"ProjectionRenderResult",
|
||||
"canonical_projection_bytes",
|
||||
"projection_hash",
|
||||
),
|
||||
"docforge.retrieval": (
|
||||
"ContextCapsuleV1",
|
||||
"RetrievalPlanV1",
|
||||
|
|
@ -97,6 +115,7 @@ PUBLIC_IMPORTS = {
|
|||
"Renderer",
|
||||
"renderer_for",
|
||||
),
|
||||
"docforge_renderers.manual": ("ManualHtmlRenderer",),
|
||||
}
|
||||
|
||||
EXPECTED_ENTRY_POINTS = {
|
||||
|
|
|
|||
|
|
@ -386,6 +386,25 @@ class VisualizationTests(unittest.TestCase):
|
|||
with self.assertRaisesRegex(DocForgeError, "category is unsupported"):
|
||||
snapshot.filter_nodes(category="relation", value="depends_on", limit=2)
|
||||
|
||||
def test_snapshot_source_never_mixes_pinned_graph_with_newer_canonical_text(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
index = ProjectIndex(Project.open(root))
|
||||
index.build()
|
||||
snapshot = VisualizationIndexSnapshot(index, index.check())
|
||||
before = snapshot.source("guide.workflow")
|
||||
|
||||
source = root / "docs/content/workflow.md"
|
||||
source.write_text(
|
||||
source.read_text(encoding="utf-8") + "\nNewer unindexed source text.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
after = snapshot.source("guide.workflow")
|
||||
|
||||
self.assertEqual(before["content"], after["content"])
|
||||
self.assertNotIn("Newer unindexed source text", after["content"])
|
||||
self.assertEqual("index_snapshot", after["source_provenance"])
|
||||
|
||||
def test_flow_reverses_imports_into_a_complete_structural_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue