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

Compare commits

..

No commits in common. "d6d9f47672c513c4927c5c42ad97a175ed651a74" and "4c5773c8652aedfeab82cf87ee7da1b7bc01eddc" have entirely different histories.

72 changed files with 193 additions and 12118 deletions

View file

@ -1,13 +1,19 @@
# Milestone state
# Active milestone
```text
Last completed milestone: 3 — independent projections
Outcome: Manual output, portable graph artifacts, and the live viewer are independent generation-pinned consumers of the validated graph.
Evidence: Clean candidate f5dccb5e1c312121f1af63780162f593d9363b98; 281 tests and 272 subtests; 3 accessibility flows; clean 1,000-node ten-sample benchmark; isolated wheel proof; no secret-scan findings.
Active milestone: None.
Next directional milestone: 4 — adapter SDK and product documentation.
Status: Milestone 3 is closed. Milestone 4 has not started.
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.
```
Milestones 45 remain directional context. Do not begin Milestone 4 without a new active-slice
contract.
Milestones 35 remain directional context and are not active.

View file

@ -345,7 +345,7 @@ These are notes, not commitments:
than bounded positions, they will need a different versioned security contract and persisted key
lifecycle.
## Milestone 2 — complete: agent retrieval and MCP experience
## Milestone 2 — active: agent retrieval and MCP experience
### Audit reconciliation
@ -610,177 +610,3 @@ 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 — complete: independent projections
Milestone 3 began only after `main` and `dev` were aligned at the verified Milestone 2 closeout.
Three read-only audits ran 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 passed 91 tests and 120 subtests at the slice boundary.
The combined projection, rendering, and live-viewer focus passed with byte-exact compatibility and
no hidden source/path authority.
### Durable portable graph publication
The portable graph path now has its own declared `graph_render` views, pure plans, fixed
`portable_graph_html` renderer, content-addressed artifact store, renderer receipts, and one bounded
generation/view manifest as the publication commit. It supports Nodes, Flow, and Web without
including Logic. Static HTML contains the complete pre-rendered graph and treats JavaScript as
progressive enhancement.
Publication revalidates source, view, artifact, receipt, and output identities across replacement.
Status reads only bounded manifest and receipt evidence. It never plans or renders. Repair may
restore a declared output from its content-addressed artifact. A post-artifact failure that cannot
be rolled back returns explicit degraded committed evidence rather than reporting an ordinary
failed mutation.
### Detached workers and incremental fragments
Manual and portable graph packages execute through one fixed one-request child protocol. The
parent launches isolated Python from a trusted working directory with a sanitized environment,
spools stdout to disk, reads one bounded canonical response, and validates the complete artifact
and receipt identity. The worker accepts only the two built-in renderer identities. Requests are
bounded by the 24,000,000-byte package contract, actual artifact transfer by 20,000,000 bytes, and
execution by a 30-second timeout.
Manual fragment records are semantic, versioned, canonical, hash-bound, and stored below a
dedicated confined cache. The worker independently recomputes the expected page fragment before
using a record. Corrupt, forged, oversized, stale, or aggregate-oversized records fall back to the
full detached render. Cold fragment creation is compared byte-for-byte with that full oracle before
cache publication. The cache retains only the current inventory and is capped at 10,000 entries
and 64,000,000 bytes.
### Independent policies and accessibility
Projection policy version 2 independently composes manual `auto|explicit|disabled`, portable graph
`explicit|disabled`, and live viewer `on-demand|disabled`. CLI, MCP, generated client
configuration, doctor, render services, canonical application, onboarding, and viewer-manager
entry points enforce their relevant policy. Status remains available when an active operation is
disabled.
Generated client evidence binds the projection policy, its hash, projection availability, and the
current descriptor hash into the configuration hash. Validation cross-checks omitted default
selectors against the bound descriptor so coordinated policy and availability drift fails closed.
The version-1 effective-policy payload remains unchanged for existing clients.
Pinned Playwright 1.62.0 and axe-core 4.12.1 gates exercise the frozen manual, portable graph, and
live viewer with selected WCAG A/AA axe tags and keyboard interaction flows. Portable and live
graph presentation received only the minimal contrast and nested-role corrections needed by those
gates.
### Scale and runtime hardening
The first 1,000-node full benchmark exposed recursive strongly connected-component traversal in
manual planning. Cycle detection now uses an iterative two-pass traversal. A regression covers the
descriptor maximum of 10,000 nodes as both a deep acyclic chain and one strongly connected
component.
The isolated wheel proof also exposed a Python `runpy` warning when the worker module was imported
during package initialization before `-m` execution. A private fixed module entrypoint now owns
child startup. Malformed child input returns code 2 with empty stdout and stderr.
Configured render ceilings above 20,000,000 bytes remain accepted for compatibility, and small
actual artifacts render normally. The detached protocol still rejects an actual transfer beyond
its fixed 20,000,000-byte boundary.
### Milestone 3 closeout
Candidate `f5dccb5e1c312121f1af63780162f593d9363b98` passed the complete repository gate: formatting,
Python and web lint, strict types, compilation, 281 tests and 272 subtests, three accessibility
flows, lock and dependency checks, package builds, and all milestone smoke benchmarks. The
maintained projection contract subset passed 142 tests and 236 subtests.
The clean ten-sample 1,000-node benchmark passed every latency, memory, response-size, no-work, and
equivalence gate. Manual full rendering measured 810.490 ms p95, portable graph full rendering
323.690 ms p95, and receipt-only status 111.381 ms and 59.331 ms p95 respectively. Direct detached
worker peaks were 88,580,096 and 89,583,616 bytes. The separately gated production manual worker
peak was 104,771,584 bytes. Production cold, warm, forced-full, add, change, delete, and reorder
outputs were byte-identical.
Production warm fragment rendering measured 2,206.540 ms p95 versus 978.870 ms for forced full.
Milestone 3 therefore closes the fragment isolation, invalidation, equivalence, and recovery
contract without claiming a throughput win. Later optimization must begin from that evidence.
The exact method and measurements are recorded in `docs/MILESTONE_3_BASELINE.md` and
`benchmarks/milestone3-2026-07-29.json`. The candidate passed an isolated wheel CLI/MCP/worker
proof. Gitleaks 8.30.1 found no findings across the six Milestone 3 commits or candidate tree.
Milestone 3 is complete. No tag, release, production integration repointing, WorldForge change,
ScrapeStation change, storage rewrite, or self-hosting dependency was introduced. Milestone 4
remains directional and has not started.

View file

@ -5,10 +5,7 @@ NPM := npm
PYTHONPYCACHEPREFIX := /tmp/docforge-quality-pycache
PYTEST_BASETEMP := /tmp/docforge-quality-pytest
.PHONY: accessibility benchmark benchmark-m1 benchmark-m1-smoke benchmark-m2 benchmark-m2-smoke benchmark-m3 benchmark-m3-full benchmark-m3-smoke benchmark-smoke build compile contract dependencies format-check gate lint lock test type
accessibility:
$(NPM) run test:accessibility
.PHONY: benchmark benchmark-m1 benchmark-m1-smoke benchmark-m2 benchmark-m2-smoke benchmark-smoke build compile contract dependencies format-check gate lint lock test type
format-check:
$(PYTHON) -m ruff format --check src tests tools
@ -28,18 +25,9 @@ contract:
-p no:cacheprovider --basetemp=$(PYTEST_BASETEMP) \
tests/test_public_contract.py \
tests/test_policy.py \
tests/test_projection_policy.py \
tests/test_projection_policy_integration.py \
tests/test_projection_worker.py \
tests/test_projection_fragments.py \
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_graph_rendering.py \
tests/test_graph_publication.py \
tests/test_observability.py::TelemetryContractTests::test_schema_fixed_names_match_the_implementation \
tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_policy_rejects_logic_publication \
tests/test_adapter_contract.py::AdapterContractTests::test_no_ast_index_accepts_legacy_and_non_logic_incremental_adapters \
@ -80,13 +68,4 @@ benchmark-m2-smoke:
benchmark-m2:
$(PYTHON) tools/milestone2_benchmark.py --nodes 1000 --samples 10
benchmark-m3-smoke:
$(PYTHON) tools/milestone3_benchmark.py --mode smoke \
--output /tmp/docforge-milestone3-smoke.json > /dev/null
benchmark-m3:
$(PYTHON) tools/milestone3_benchmark.py --mode full
benchmark-m3-full: benchmark-m3
gate: format-check lint type compile contract test accessibility lock dependencies build benchmark-smoke benchmark-m1-smoke benchmark-m2-smoke benchmark-m3-smoke
gate: format-check lint type compile contract test lock dependencies build benchmark-smoke benchmark-m1-smoke benchmark-m2-smoke

View file

@ -26,12 +26,6 @@ declared manuals, visualizes project structure, and manages reviewable documenta
- Detects project-local adapter implementation and configuration changes and requires a fresh
project-bound process before any further MCP work.
- Keeps function-scoped control-flow projections separate from the primary architecture graph.
- Compiles manuals and portable graph artifacts from separate versioned, generation-pinned plans
and immutable packages.
- Runs built-in manual and graph renderers in fixed detached workers with validated receipts,
bounded transfer, and no project-path authority.
- Enforces independent manual, portable-graph, and live-viewer policy while keeping status
receipt-only.
- Runs a managed loopback graph browser with neighborhood, semantic Flow, convergence Web,
function-scoped Logic, source inspection, and branch-aware node hiding.
- Supports generic documentation projects and project-owned source adapters.
@ -79,63 +73,6 @@ details, and explicit truncation. Paged results use one top-level cursor and a v
`receipt_header`; `stored_receipt_hash` identifies the complete persisted receipt. The read never
exposes Logic details, loads canonical source, repairs derived state, or invents history.
## Independent projections
Manual compilation, portable graph rendering, and the live viewer consume the same validated graph
generation through separate boundaries:
```text
validated generation
├── ManualRenderPlanV1 → immutable package → detached manual renderer
├── GraphViewPlanV1 → immutable package → detached portable graph renderer
└── pinned index → managed read-only live viewer
```
Plans, packages, and receipts are canonical, versioned, hash-identified, bounded, and contain no
project object, SQLite handle, absolute project path, command, or caller-selected renderer module.
Renderers cannot select graph facts, crawl canonical sources, choose publication paths, or mutate
project state.
Declare portable graph output separately from manual views:
```toml
[graph_render]
output_root = ".docforge/portable-graph"
[[graph_render.views]]
id = "architecture"
renderer = "portable_graph_html"
output = "architecture.html"
title = "Architecture"
root = "architecture.overview"
initial_mode = "web"
depth = 3
max_nodes = 250
max_edges = 1000
max_work = 100000
include_logic = false
```
Plan, publish, and inspect it explicitly:
```bash
.venv/bin/docforge --project-root "$PROJECT" graph-plan architecture
.venv/bin/docforge --project-root "$PROJECT" graph-render architecture
.venv/bin/docforge --project-root "$PROJECT" graph-render-status architecture
```
The version-2 projection policy independently selects manual
`auto|explicit|disabled`, portable graph `explicit|disabled`, and live viewer
`on-demand|disabled`. Use `--manual-render-policy`, `--portable-graph-policy`, and
`--live-viewer-policy` on CLI/MCP startup or generated client configuration. Status remains
available when the corresponding active operation is disabled.
Manual fragment reuse is disposable. Cold record publication is guarded by byte-exact comparison
with a full detached render; warm records are independently recomputed and validated inside the
worker. Full rendering remains the recovery and equivalence oracle. Portable artifacts commit
content-addressed output and renderer evidence before one bounded generation/view manifest; status
does not plan or render.
## Graph views
The browser presents the primary architecture graph through three complementary views and loads a
@ -233,18 +170,10 @@ DocForge describes them as a source graph.
performance, memory, rendering and response sizes, bottlenecks, and missing coverage.
- [Milestone 0 closeout](docs/MILESTONE_0_CLOSEOUT.md) — lineage, migration, security scan,
repository state, and fresh-clone proof.
- [Milestone 1 baseline](docs/MILESTONE_1_BASELINE.md) — warm operation latency, structured work,
status, retrieval, and memory measurements.
- [Milestone 1 closeout](docs/MILESTONE_1_CLOSEOUT.md) — fast-core contracts, adversarial
validation, compatibility boundaries, and exact candidate evidence.
- [Milestone 2 baseline](docs/MILESTONE_2_BASELINE.md) — task context, generation diff, client
configuration, doctor, response-size, counter, and memory measurements.
- [Milestone 2 closeout](docs/MILESTONE_2_CLOSEOUT.md) — implemented contracts, adversarial
validation, exclusions, and exact candidate evidence.
- [Milestone 3 baseline](docs/MILESTONE_3_BASELINE.md) — manual, fragment, worker, portable graph,
status, equivalence, response-size, and memory measurements.
- [Milestone 3 closeout](docs/MILESTONE_3_CLOSEOUT.md) — independent projection contracts,
adversarial validation, compatibility boundaries, and exact candidate evidence.
- [MCP contract](docs/MCP_CONTRACT.md) — exact tool and process boundary.
- [Viewer manager](docs/VIEWER_MANAGER.md) — native service setup and lifecycle.
- [Adapter decision](docs/APPLICATION_DECISION.md) — why custom adapters own canonical
@ -268,8 +197,6 @@ make gate
Focused entry points are available as `make contract`, `make test`, `make type`,
`make benchmark-smoke`, `make benchmark`, `make benchmark-m1-smoke`, and
`make benchmark-m1`. Milestone 2 adds `make benchmark-m2-smoke` and `make benchmark-m2`.
Milestone 3 adds `make accessibility`, `make benchmark-m3-smoke`, `make benchmark-m3`, and
`make benchmark-m3-full`.
The committed 1,000-node baseline and its measurement method are under `benchmarks/`.

View file

@ -1,51 +1,5 @@
# Completed slices
## DocForge2 Milestone 3 independent projections
### Changed
- Added strict version-1 manual plan, graph plan, projection package, and projection receipt
contracts with canonical identities and packaged schemas.
- Kept the legacy manual API and exact alpha bytes as a compatibility wrapper over a pure planner,
immutable package, and independent renderer.
- Added generation-pinned portable graph planning, detached rendering, content-addressed durable
publication, repair, and receipt-only status.
- Added a fixed isolated worker protocol with bounded request, response, artifact, timeout,
environment, and renderer inventory.
- Added semantic manual fragments with independent worker validation, corruption recovery,
full-render equivalence, and bounded cache retention.
- Added version-2 independent manual, portable-graph, and live-viewer policy with descriptor-bound
generated-client evidence while retaining effective policy version 1.
- Corrected live source reads to use the pinned index generation.
- Added static and interactive accessibility gates for the manual, portable graph, and live viewer.
- Replaced recursive cycle planning with an iterative traversal proven at 10,000 nodes.
### Verification
- The complete gate passed 281 tests, 272 subtests, three accessibility flows, dependency checks,
builds, and all milestone smoke benchmarks.
- The maintained projection contract subset passed 142 tests and 236 subtests.
- The clean ten-sample 1,000-node benchmark passed every latency, memory, response-size, no-work,
deterministic-output, and full/incremental equivalence threshold.
- An isolated wheel passed CLI, MCP, detached manual rendering, and malformed worker startup.
- Three adversarial review tracks were reconciled, and Gitleaks found no findings in the six
milestone commits or candidate tree.
### Limits
- Production fragment reuse is correct and recoverable but is slower than forced full rendering at
the maintained 1,000-page fixture. No speedup is claimed.
- Actual detached artifact transfer is capped at 20,000,000 bytes.
- Portable graph publication is CLI-owned. MCP exposes read-only plan and status tools.
- Remote render services, render farms, third-party renderer ecosystems, storage replacement,
self-hosting, adapter SDK expansion, and production integration changes remain out of scope.
- No tag or release was created.
### Next gate
Milestone 4 remains directional and is not active. Create a new active-slice contract before
starting adapter SDK or product-documentation implementation.
## DocForge2 Milestone 0 successor foundation
### Changed

View file

@ -34,14 +34,6 @@ make benchmark-m2-smoke
make benchmark-m2
```
Run the Milestone 3 independent-projection smoke and maintained full gates:
```bash
make benchmark-m3-smoke
make benchmark-m3
make benchmark-m3-full
```
The benchmark creates canonical sources, derived state, changesets, rendered output, and caches
only in a disposable temporary directory. It does not read another project, self-host DocForge, or
mutate repository content.
@ -64,16 +56,9 @@ records whether diagnostics were dropped for response budget, checks all hidden-
and measures isolated-process peak RSS. Its interpretation is in
[`docs/MILESTONE_2_BASELINE.md`](../docs/MILESTONE_2_BASELINE.md).
`milestone3-2026-07-29.json` is the clean-tree independent-projection baseline captured from commit
`f5dccb5e1c312121f1af63780162f593d9363b98`. It measures versioned manual and graph planning,
in-process and detached rendering, production cold/warm/forced-full behavior, fragment cache
sweeps, add/change/delete/reorder equivalence, portable publication, receipt-only status, traced
memory, detached worker peak memory, and response size. Its interpretation is in
[`docs/MILESTONE_3_BASELINE.md`](../docs/MILESTONE_3_BASELINE.md).
The generic fixtures expose whole-source and projection scaling. They do not replace incremental
adapter equivalence tests. Milestone 3's portable fixture contains 1,000 Nodes/Flow/Web nodes and
999 edges; portable version 1 deliberately excludes Logic.
The generic fixture exposes whole-source scaling. It does not replace the incremental adapter
equivalence tests and does not claim to measure a portable graph renderer, because Milestone 0 has
no portable graph-planning or graph-rendering contract.
The Milestone 1 harness treats wall time and structured work counters as separate gates. Warm
operations fail if they load a complete project, parse source files, reconstruct an adapter
@ -88,5 +73,4 @@ status. The 1,000-node run records bounded semantic summaries for exact errors,
backlinks, both traversal directions, paged context, render receipt states, and visualization
freshness. The reported p95 uses the nearest-rank method; with ten samples it is the maximum.
`process_peak_rss_kib` is the cumulative main-process `RUSAGE_SELF` high-water mark and excludes the
detached viewer worker. It is diagnostic and not operation-local. Milestone 3 memory gates use
per-operation `tracemalloc` peaks and detached worker receipt peaks instead.
detached viewer worker.

View file

@ -1,442 +0,0 @@
{
"benchmark": "docforge2_milestone3",
"environment": {
"implementation": "CPython",
"machine": "x86_64",
"platform": "Linux-7.1.3-200.nobara.fc44.x86_64-x86_64-with-glibc2.43",
"python": "3.14.6"
},
"equivalence": {
"manual_full_vs_fragment_assisted": true,
"manual_in_process_vs_detached": true,
"manual_production_cold_warm_full": true,
"manual_production_variants": {
"add": true,
"change": true,
"delete": true,
"reorder": true
},
"portable_graph_in_process_vs_detached": true
},
"fixture": {
"edge_count": 999,
"full_coverage": true,
"kind": "synthetic_generic_projection",
"manual_page_count": 1000,
"node_count": 1000,
"portable_graph_edge_count": 999,
"portable_graph_node_count": 1000
},
"memory": {
"manual_production_worker_peak_bytes": 104771584,
"manual_worker_peak_bytes": 88580096,
"portable_graph_worker_peak_bytes": 89583616,
"process_peak_rss_kib": 102692
},
"method": {
"clock": "time.perf_counter_ns",
"detached_peak_memory": "worker receipt resource peak RSS",
"determinism": "stable semantic summaries must match across samples; report JSON uses sorted keys",
"full_mode_node_requirement": 1000,
"in_process_peak_memory": "tracemalloc per measured invocation",
"maximum_worker_artifact_bytes": 20000000,
"process_peak_memory": "resource.getrusage(RUSAGE_SELF).ru_maxrss",
"response_size": "UTF-8 bytes of canonical compact sorted JSON",
"samples": 10
},
"mode": "full",
"operations": {
"fragment_assisted_equivalence": {
"max_ms": 666.119,
"maximum_response_bytes": 664,
"maximum_traced_peak_bytes": 5332792,
"median_ms": 638.999,
"min_ms": 633.157,
"p95_limit_ms": 15000,
"p95_ms": 666.119,
"response_limit_bytes": 128000,
"samples": 10,
"stable_result": {
"artifact_bytes": 583149,
"artifact_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
"package_bytes": 2364302,
"package_id": "ecc21e58106c420a9f78ffbba997778ca98b61586ecfdb23a15b08f431a52c42",
"plan_bytes": 1006393,
"plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de"
},
"traced_peak_limit_bytes": 268435456
},
"fragment_cache_hit_sweep": {
"max_ms": 514.324,
"maximum_response_bytes": 145,
"maximum_traced_peak_bytes": 6941174,
"median_ms": 498.911,
"min_ms": 495.447,
"p95_limit_ms": 5000,
"p95_ms": 514.324,
"response_limit_bytes": 32768,
"samples": 10,
"stable_result": {
"aggregate_content_bytes": 516921,
"fragment_count": 1000,
"ordered_record_hash": "99a95d575504238233a6367c884fa1d50231855f2f2ffe3dae90fa18c4136f72"
},
"traced_peak_limit_bytes": 268435456
},
"fragment_cache_miss_sweep": {
"max_ms": 106.888,
"maximum_response_bytes": 145,
"maximum_traced_peak_bytes": 146245,
"median_ms": 106.203,
"min_ms": 105.383,
"p95_limit_ms": 5000,
"p95_ms": 106.888,
"response_limit_bytes": 32768,
"samples": 10,
"stable_result": {
"aggregate_content_bytes": 516921,
"fragment_count": 1000,
"ordered_record_hash": "99a95d575504238233a6367c884fa1d50231855f2f2ffe3dae90fa18c4136f72"
},
"traced_peak_limit_bytes": 268435456
},
"fragment_cache_put_sweep": {
"max_ms": 539.212,
"maximum_response_bytes": 145,
"maximum_traced_peak_bytes": 509548,
"median_ms": 539.212,
"min_ms": 539.212,
"p95_limit_ms": 10000,
"p95_ms": 539.212,
"response_limit_bytes": 32768,
"samples": 1,
"stable_result": {
"aggregate_content_bytes": 516921,
"fragment_count": 1000,
"ordered_record_hash": "99a95d575504238233a6367c884fa1d50231855f2f2ffe3dae90fa18c4136f72"
},
"traced_peak_limit_bytes": 268435456
},
"manual_detached_worker": {
"child_peak_limit_bytes": 268435456,
"max_ms": 599.394,
"maximum_child_peak_bytes": 88580096,
"maximum_response_bytes": 667,
"maximum_traced_peak_bytes": 28203708,
"median_ms": 591.317,
"min_ms": 585.123,
"p95_limit_ms": 20000,
"p95_ms": 599.394,
"response_limit_bytes": 128000,
"samples": 10,
"stable_result": {
"artifact_bytes": 583149,
"artifact_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
"package_bytes": 1007297,
"package_id": "5fec20ace40b6db7e89083704af371245a5b5b73247f71edb6eab0cb2bd5e2c1",
"plan_bytes": 1006393,
"plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de"
},
"traced_peak_limit_bytes": 268435456
},
"manual_forced_full": {
"child_peak_limit_bytes": 268435456,
"max_ms": 978.87,
"maximum_child_peak_bytes": 104771584,
"maximum_response_bytes": 668,
"maximum_traced_peak_bytes": 30425714,
"median_ms": 944.135,
"min_ms": 934.773,
"p95_limit_ms": 20000,
"p95_ms": 978.87,
"response_limit_bytes": 128000,
"samples": 10,
"stable_result": {
"output_bytes": 583149,
"output_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
"render_identity": "73f9a175afd7bfea5af70d4c7df902c982f042c413d533817dc2bade9e54d4b0"
},
"traced_peak_limit_bytes": 268435456
},
"manual_full_render": {
"max_ms": 810.49,
"maximum_response_bytes": 664,
"maximum_traced_peak_bytes": 4412754,
"median_ms": 801.948,
"min_ms": 773.7,
"p95_limit_ms": 15000,
"p95_ms": 810.49,
"response_limit_bytes": 128000,
"samples": 10,
"stable_result": {
"artifact_bytes": 583149,
"artifact_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
"package_bytes": 1007297,
"package_id": "5fec20ace40b6db7e89083704af371245a5b5b73247f71edb6eab0cb2bd5e2c1",
"plan_bytes": 1006393,
"plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de"
},
"traced_peak_limit_bytes": 268435456
},
"manual_incremental_cold": {
"child_peak_limit_bytes": 268435456,
"max_ms": 2827.152,
"maximum_child_peak_bytes": 99454976,
"maximum_response_bytes": 668,
"maximum_traced_peak_bytes": 35160716,
"median_ms": 2827.152,
"min_ms": 2827.152,
"p95_limit_ms": 20000,
"p95_ms": 2827.152,
"response_limit_bytes": 128000,
"samples": 1,
"stable_result": {
"output_bytes": 583149,
"output_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
"render_identity": "73f9a175afd7bfea5af70d4c7df902c982f042c413d533817dc2bade9e54d4b0"
},
"traced_peak_limit_bytes": 268435456
},
"manual_incremental_warm": {
"child_peak_limit_bytes": 268435456,
"max_ms": 2206.54,
"maximum_child_peak_bytes": 104767488,
"maximum_response_bytes": 669,
"maximum_traced_peak_bytes": 34676043,
"median_ms": 2143.388,
"min_ms": 2125.466,
"p95_limit_ms": 20000,
"p95_ms": 2206.54,
"response_limit_bytes": 128000,
"samples": 10,
"stable_result": {
"output_bytes": 583149,
"output_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
"render_identity": "73f9a175afd7bfea5af70d4c7df902c982f042c413d533817dc2bade9e54d4b0"
},
"traced_peak_limit_bytes": 268435456
},
"manual_status_no_work": {
"max_ms": 111.381,
"maximum_response_bytes": 1542,
"maximum_traced_peak_bytes": 1792025,
"median_ms": 62.881,
"min_ms": 60.111,
"p95_limit_ms": 500,
"p95_ms": 111.381,
"response_limit_bytes": 256000,
"samples": 10,
"stable_result": {
"counters": {
"adapter_projection_loads": 0,
"adapter_source_extractions": 0,
"index_builds": 0,
"index_checks": 0,
"index_synchronizations": 0,
"project_loads": 0,
"render_output_bytes_built": 0,
"render_output_bytes_hashed": 0,
"render_prepare_calls": 0,
"source_bytes_parsed": 0,
"source_files_parsed": 0,
"source_generation_checks": 2,
"viewer_manager_requests": 0
},
"response": {
"adapter": "generic",
"configured": true,
"outputs": [
{
"actual_output_hash": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
"expected_output_hash": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
"path": ".docforge/rendered/manual.html",
"projection_receipt": {
"artifacts": [
{
"artifact_id": "manual.html",
"bytes": 583149,
"media_type": "text/html; charset=utf-8",
"sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c"
}
],
"contract": "docforge.projection-receipt",
"diagnostics": {
"warnings": []
},
"kind": "manual",
"package_id": "b30c65390570537a16ce03afe6a992599c6ca0c4ef5ea2f06d14b33e248bc9aa",
"peak_memory_bytes": 104771584,
"plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de",
"receipt_id": "ead4c25b34ada8df674903a592e25dd6bbf9817df2b04e5ebc66cde9e41c1ed1",
"renderer": {
"renderer_id": "generic_html",
"renderer_version": "1+markdown-it-py-4.2.0"
},
"schema_version": 1,
"timing": {
"elapsed_ns": 114544865
}
},
"reason": null,
"receipt_schema_version": 1,
"render_identity": "73f9a175afd7bfea5af70d4c7df902c982f042c413d533817dc2bade9e54d4b0",
"renderer": "generic_html",
"renderer_version": "1+markdown-it-py-4.2.0",
"state": "current",
"template_hash": "ae0ebb3eadeb530e9d033c8fbd21321d406e29d04c0ae7d07a9a0f929f0ffa9f",
"verification": "receipt",
"view_id": "manual"
}
],
"project_id": "synthetic-1000",
"project_root_fingerprint": "0747acfd975703c0",
"revision": "unversioned",
"source_hash": "a314da4ffa8fcf291ef7a7b0fc87737caeaa89fa3c558ea442afeb0e5ae49c2d",
"state": "current",
"status": "ok",
"verification": "receipt"
}
},
"traced_peak_limit_bytes": 268435456
},
"portable_graph_detached_worker": {
"child_peak_limit_bytes": 268435456,
"max_ms": 268.428,
"maximum_child_peak_bytes": 89583616,
"maximum_response_bytes": 660,
"maximum_traced_peak_bytes": 27587227,
"median_ms": 265.448,
"min_ms": 263.761,
"p95_limit_ms": 20000,
"p95_ms": 268.428,
"response_limit_bytes": 128000,
"samples": 10,
"stable_result": {
"artifact_bytes": 718383,
"artifact_sha256": "91dd5bb31679225e5af40a1f94a9e75f72921e36416e3d8b00c45f46a37a3a30",
"package_bytes": 398715,
"package_id": "72f96d7ab92c1d6b59f4f32539558232ecf35deda099fc4887c77890da1d4309",
"plan_bytes": 398158,
"plan_id": "6dac35937d855f72c6670f87aace3c0cb1dbff67bb842c55370429584064f76b"
},
"traced_peak_limit_bytes": 268435456
},
"portable_graph_full_render": {
"max_ms": 323.69,
"maximum_response_bytes": 657,
"maximum_traced_peak_bytes": 3490976,
"median_ms": 303.736,
"min_ms": 301.215,
"p95_limit_ms": 10000,
"p95_ms": 323.69,
"response_limit_bytes": 128000,
"samples": 10,
"stable_result": {
"artifact_bytes": 718383,
"artifact_sha256": "91dd5bb31679225e5af40a1f94a9e75f72921e36416e3d8b00c45f46a37a3a30",
"package_bytes": 398715,
"package_id": "72f96d7ab92c1d6b59f4f32539558232ecf35deda099fc4887c77890da1d4309",
"plan_bytes": 398158,
"plan_id": "6dac35937d855f72c6670f87aace3c0cb1dbff67bb842c55370429584064f76b"
},
"traced_peak_limit_bytes": 268435456
},
"portable_graph_status_no_work": {
"max_ms": 59.331,
"maximum_response_bytes": 879,
"maximum_traced_peak_bytes": 685012,
"median_ms": 58.467,
"min_ms": 58.052,
"p95_limit_ms": 500,
"p95_ms": 59.331,
"response_limit_bytes": 256000,
"samples": 10,
"stable_result": {
"counters": {
"adapter_projection_loads": 0,
"adapter_source_extractions": 0,
"index_builds": 0,
"index_checks": 0,
"index_synchronizations": 0,
"project_loads": 0,
"render_output_bytes_built": 0,
"render_output_bytes_hashed": 0,
"render_prepare_calls": 0,
"source_bytes_parsed": 0,
"source_files_parsed": 0,
"source_generation_checks": 2,
"viewer_manager_requests": 0
},
"response": {
"adapter": "generic",
"configured": true,
"outputs": [
{
"artifact": {
"artifact_id": "portable-graph.html",
"bytes": 718383,
"media_type": "text/html; charset=utf-8",
"sha256": "91dd5bb31679225e5af40a1f94a9e75f72921e36416e3d8b00c45f46a37a3a30"
},
"package_id": "72f96d7ab92c1d6b59f4f32539558232ecf35deda099fc4887c77890da1d4309",
"path": ".docforge/portable-graph/architecture.html",
"plan_id": "6dac35937d855f72c6670f87aace3c0cb1dbff67bb842c55370429584064f76b",
"publication_id": "2620ce106593d5499931b145a42fc1eccd31f5e356b19bf1d76687f8d45bcad2",
"reason": null,
"renderer": "portable_graph_html",
"renderer_version": "1",
"state": "current",
"verification": "manifest",
"view_id": "architecture"
}
],
"project_id": "synthetic-1000",
"project_root_fingerprint": "0747acfd975703c0",
"revision": "unversioned",
"source_hash": "a314da4ffa8fcf291ef7a7b0fc87737caeaa89fa3c558ea442afeb0e5ae49c2d",
"state": "current",
"status": "ok"
}
},
"traced_peak_limit_bytes": 268435456
}
},
"schema_version": 1,
"sizes": {
"fragment_assisted_manual": {
"aggregate_fragment_content_bytes": 516921,
"artifact_bytes": 583149,
"artifact_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
"fragment_count": 1000,
"package_bytes": 2364302,
"package_id": "ecc21e58106c420a9f78ffbba997778ca98b61586ecfdb23a15b08f431a52c42",
"plan_bytes": 1006393,
"plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de",
"receipt_bytes": 664
},
"manual": {
"artifact_bytes": 583149,
"artifact_sha256": "278ff8cd6b092e7d8114527dba88051917aa827b22d4aed61e57acf004e5870c",
"package_bytes": 1007297,
"package_id": "5fec20ace40b6db7e89083704af371245a5b5b73247f71edb6eab0cb2bd5e2c1",
"plan_bytes": 1006393,
"plan_id": "47008f5177230e3ad5abbfee8bccaff0498963106cb33ed7cdd6f8bbc3c495de",
"receipt_bytes": 664
},
"manual_status_response_bytes": 1542,
"portable_graph": {
"artifact_bytes": 718383,
"artifact_sha256": "91dd5bb31679225e5af40a1f94a9e75f72921e36416e3d8b00c45f46a37a3a30",
"package_bytes": 398715,
"package_id": "72f96d7ab92c1d6b59f4f32539558232ecf35deda099fc4887c77890da1d4309",
"plan_bytes": 398158,
"plan_id": "6dac35937d855f72c6670f87aace3c0cb1dbff67bb842c55370429584064f76b",
"receipt_bytes": 657
},
"portable_graph_status_response_bytes": 879
},
"source": {
"dirty": false,
"revision": "f5dccb5e1c312121f1af63780162f593d9363b98"
}
}

View file

@ -71,19 +71,6 @@ Existing hand-written client configurations remain valid and are never rewritten
Doctor is inspection-only and does not become a hidden bootstrap, synchronization, or migration
path.
The following Milestone 3 CLI additions are also additive:
- `docforge graph-plan VIEW_ID`
- `docforge graph-render VIEW_ID`
- `docforge graph-render-status [VIEW_ID]`
- `--manual-render-policy auto|explicit|disabled`
- `--portable-graph-policy explicit|disabled`
- `--live-viewer-policy on-demand|disabled`
MCP adds the read-only `docforge_graph_plan` and `docforge_graph_render_status` tools. Portable
graph publication remains an explicit local CLI integration action. Existing manual render,
preview, visualization, and status names remain supported.
## Versioned data contracts
Milestone 0 preserves:
@ -109,11 +96,6 @@ Milestone 0 preserves:
- Latest-generation-diff page schema version 1. Pages use one top-level pagination object and a
nested `receipt_header`. `stored_receipt_hash` names the complete stored receipt. Opaque cursors
may be restarted after a server or receipt change and are not durable public identifiers.
- Manual render-plan schema version 1.
- Graph view-plan schema version 1.
- Projection-package schema version 1.
- Projection-receipt schema version 1.
- Independent projection-policy schema version 2. Effective policy version 1 remains frozen.
Indexes, attestations, extraction caches, previews, and rendered artifacts are disposable. A schema
change may rebuild them. Canonical project content and stored proposals may not be silently
@ -179,24 +161,12 @@ The following guarantees remain stable:
## Rendering and visualization
The `generic_html` renderer remains the supported version-1 manual projection. Its public
`GenericHtmlRenderer.prepare()` signature, renderer identity, frozen alpha bytes, confined paths,
raw-HTML suppression, fixed template tokens, deterministic identities, atomic replacement, and
side-effect-free status remain compatible. It now delegates through a versioned manual plan,
immutable package, and independent renderer.
The `portable_graph_html` renderer and `graph_render` descriptor table are additive. Manual and
portable graph declarations, plans, policies, publication receipts, and status remain separate.
The portable renderer does not replace the existing live viewer or `docforge_visualize`.
Existing project descriptors may retain any positive `max_render_bytes` accepted by schema version
1. A value above 20,000,000 bytes does not make the descriptor invalid, and a smaller actual
artifact still renders. Actual detached worker transfer is a separate fixed 20,000,000-byte
runtime boundary.
The `generic_html` renderer remains the supported version-1 manual projection. It retains confined
paths, raw-HTML suppression, fixed template tokens, deterministic identities, atomic replacement,
and side-effect-free status.
The live graph viewer remains a read-only consumer of a generation-pinned validated index. It does
not become project authority or MCP retrieval authority. Source reads use the pinned index
generation instead of reopening mutable canonical files behind that generation.
not become project authority or MCP retrieval authority.
## Task-context compatibility
@ -229,9 +199,8 @@ The exact version-1 relation aliases are frozen by the MCP contract and reposito
Changing an alias category requires a new planner version; it is not a silent implementation
detail.
Milestone 3 adds `ManualRenderPlanV1`, `GraphViewPlanV1`, projection package and receipt version 1,
and projection policy version 2. These are additive submodule and schema contracts. They do not
change the legacy task-context, adapter, changeset, or effective-policy contracts described above.
`ManualRenderPlan`, `GraphViewPlan`, a portable graph renderer, and independently packaged
renderers are later-milestone direction. Milestone 0 does not claim that those contracts exist.
## Safety boundary
@ -252,8 +221,6 @@ Milestone 0 records rather than redesigns these areas:
callers use targeted retrieval for that node.
- One individually oversized changeset diff is transported as reconstructable canonical-JSON
chunks. Cursors are corruption-detecting read tokens, not authenticated authorization tokens.
- Production fragment validation is currently slower than forced-full rendering at the maintained
1,000-page fixture. Full rendering remains the equivalence and recovery oracle.
- Remote render services, render farms, third-party renderer ecosystems, and a separate render MCP
remain deferred.
- Manual planning is not separated from rendering.
- There is no portable graph-planning or graph-rendering contract.
- DocForge2 does not self-host its bootstrap documentation.

View file

@ -24,11 +24,6 @@ commit when Git is available; it cannot change repository state.
- Latest generation-diff page: `schemas/generation-diff-page.schema.json`, version 1.
- Generated client configuration: `schemas/client-configuration.schema.json`, version 1.
- Client doctor result: `schemas/doctor-result.schema.json`, version 1.
- Manual render plan: `schemas/manual-render-plan.schema.json`, version 1.
- Graph view plan: `schemas/graph-view-plan.schema.json`, version 1.
- Projection package: `schemas/projection-package.schema.json`, version 1.
- Projection receipt: `schemas/projection-receipt.schema.json`, version 1.
- Independent projection policy: `schemas/projection-policy.schema.json`, version 2.
- Index schema: version 3, disposable and reproducible.
- Index attestation: schema version 1, disposable and reproducible.
- Core, CLI, and MCP server: version 1.3.0.dev0.
@ -87,9 +82,7 @@ the header alone. One top-level pagination object carries the only continuation
Generated Codex, Claude, and OpenClaw fragments are machine-local projections. They are not
canonical project content. Version 1 binds the selected project, exact isolated Python
interpreter, canonical argument layout, effective policy, no-AST projection, render policy,
timeouts, artifact bytes, and configuration hash. Milestone 3 adds the version-2 projection policy,
its hash, projection availability, and the exact descriptor hash to that attested configuration
evidence. Omitted default selectors are recomposed against the bound descriptor.
timeouts, artifact bytes, and configuration hash.
Preview is side-effect free. Explicit publication creates only one new private standalone
fragment in an existing real directory. It never merges or replaces different content. Descriptor,
@ -142,12 +135,10 @@ preview root, and one or more stable view IDs. Each view names a built-in render
derived output file, title, and optional family filter. Paths are resolved under the project root
and may not overlap canonical content, authority files, changesets, templates, or previews.
The `generic_html` renderer uses pinned CommonMark parsing with raw HTML disabled. Templates are
UTF-8 files with a fixed token vocabulary; they cannot name commands, modules, or executable
The initial `generic_html` renderer uses pinned CommonMark parsing with raw HTML disabled. Templates
are UTF-8 files with a fixed token vocabulary; they cannot name commands, modules, or executable
renderers. Render identity covers the canonical source hash, optional changeset hash, selected node
and edge identities, view configuration, template hash, renderer contract, and exact parser
version. The frozen version-1 API and alpha bytes are preserved by a compatibility wrapper over the
manual plan/package/renderer path.
and edge identities, view configuration, template hash, renderer contract, and exact parser version.
An explicit CLI render atomically replaces one declared derived output. MCP can render a validated
changeset only to its isolated preview path. Normal status verifies bounded source, configuration,
@ -155,56 +146,6 @@ template, output, renderer, and publication-receipt identities without reconstru
Explicit deep status remains the side-effect-free full-render oracle. Input changes detected before
atomic replacement fail without publishing a current receipt for stale output.
## Independent projection boundary
Manual and portable graph plans are separate version-1 contracts over one immutable validated
generation. They use canonical JSON, deterministic ordering, fixed structural and serialized-size
bounds, and content-derived identities. Plans contain selected graph facts and bounded content.
They contain no live project object, database handle, absolute project or index path, arbitrary
query, command, executable path, or caller-selected module.
Projection packages bind one plan to inert assets, a closed built-in renderer identity, declared
component versions, and an artifact inventory with a byte allowance. Receipts bind the exact
package, plan, renderer, artifact hashes and sizes, diagnostics, timing, and detached peak memory.
Manual and graph renderer modules accept only validated packages. They cannot select nodes, invent
relationships, read project state, choose publication paths, or write canonical files.
Detached execution uses one fixed private Python module, isolated mode, a trusted working
directory, a sanitized environment, exactly one canonical newline-terminated JSON request and
response, a closed renderer allowlist, a 30-second timeout, disk-spooled stdout, and bounded reads. The package
contract is capped at 24,000,000 bytes and actual detached artifact transfer at 20,000,000 bytes.
Project descriptors may retain a larger `max_render_bytes` compatibility allowance, but an actual
detached transfer above the fixed worker boundary fails closed.
Portable graph configuration is independent of manual render configuration. One view selects
either an exact root or a bounded metadata-only lexical query plus closed filters and node, edge,
depth, and work limits. Logic is excluded. The renderer emits a complete static Nodes, Flow, or Web
artifact and uses JavaScript only as progressive enhancement.
Portable publication commits a content-addressed artifact, renderer receipt, and one bounded
generation/view manifest in that order. The manifest is the publication commit. Status reads only
bounded manifest and receipt evidence and never plans or renders. Repair restores declared output
only from validated content-addressed evidence. A failure after a replacement that cannot be
proven rolled back returns explicit degraded committed evidence.
Manual fragment records are disposable semantic cache entries. Their keys bind the renderer,
component version, and complete page semantics. The detached renderer recomputes the expected page
fragment before accepting cached bytes. Cold creation is compared with a full detached render
before cache publication. Invalid, corrupt, forged, stale, individually oversized, or
aggregate-oversized records fall back to the full oracle. The dedicated cache retains only current
keys and is capped at 10,000 entries and 64,000,000 bytes.
Projection policy version 2 composes manual `auto|explicit|disabled`, portable graph
`explicit|disabled`, and live viewer `on-demand|disabled` independently. Active plan, render,
application, onboarding, and viewer-start operations enforce the relevant policy before hidden
work. Receipt-only status and explicit viewer stop remain available. Effective policy version 1
and its legacy projections remain unchanged.
The live viewer remains separate from portable graph publication. It consumes one
generation-pinned validated index through the viewer manager. Source reads come from that pinned
generation and do not reopen mutable canonical files behind an older snapshot. Neither live nor
portable visualization is retrieval or canonical authority.
Normal MCP access does not expose canonical application. An explicitly configured canonical
applier registers one hash-bound application tool. No MCP mode exposes arbitrary renderer
execution, arbitrary file writes, shell commands, Git mutation, build commands, deployment, or

View file

@ -1,90 +0,0 @@
# Milestone 3 baseline
## Scope and method
This baseline records the independent-projection behavior completed in Milestone 3. It was
captured on 2026-07-29 from clean candidate commit
`f5dccb5e1c312121f1af63780162f593d9363b98`.
The maintained command was:
```bash
.venv/bin/python tools/milestone3_benchmark.py \
--mode full \
--nodes 1000 \
--samples 10 \
--output benchmarks/milestone3-2026-07-29.json
```
The synthetic generic fixture contains 1,000 manual pages, 1,000 portable-graph nodes, and 999
edges. Durations use `time.perf_counter_ns()` and nearest-rank p95. In-process peak memory uses
`tracemalloc`; detached worker peak memory comes from the worker receipt and `RUSAGE_SELF`.
Every measured result is checked for deterministic semantic identity and bounded response size.
Environment:
- Linux 7.1.3-200.nobara.fc44.x86_64.
- CPython 3.14.6.
- x86_64.
- Ten samples except the one-time production cold render and fragment-cache population.
- In-process and detached-worker memory ceiling: 268,435,456 bytes.
- Detached artifact-transfer ceiling: 20,000,000 bytes.
The complete machine-readable result is
[`benchmarks/milestone3-2026-07-29.json`](../benchmarks/milestone3-2026-07-29.json).
## Results
| Operation | Median | p95 | Limit | Maximum response |
|---|---:|---:|---:|---:|
| Manual full plan/package/render | 801.948 ms | 810.490 ms | 15,000 ms | 664 B |
| Manual detached worker | 591.317 ms | 599.394 ms | 20,000 ms | 667 B |
| Fragment-assisted equivalence | 638.999 ms | 666.119 ms | 15,000 ms | 664 B |
| Production incremental cold | 2,827.152 ms | 2,827.152 ms | 20,000 ms | 668 B |
| Production incremental warm | 2,143.388 ms | 2,206.540 ms | 20,000 ms | 669 B |
| Production forced full | 944.135 ms | 978.870 ms | 20,000 ms | 668 B |
| Fragment cache miss sweep | 106.203 ms | 106.888 ms | 5,000 ms | 145 B |
| Fragment cache hit sweep | 498.911 ms | 514.324 ms | 5,000 ms | 145 B |
| Portable graph full plan/package/render | 303.736 ms | 323.690 ms | 10,000 ms | 657 B |
| Portable graph detached worker | 265.448 ms | 268.428 ms | 20,000 ms | 660 B |
| Manual receipt-only status | 62.881 ms | 111.381 ms | 500 ms | 1,542 B |
| Portable graph receipt-only status | 58.467 ms | 59.331 ms | 500 ms | 879 B |
The largest traced in-process peak was 35,160,716 bytes. The direct manual worker track peaked at
88,580,096 bytes and the portable graph worker at 89,583,616 bytes. The production manual paths,
including cold, warm, forced-full, and mutation variants, peaked at 104,771,584 bytes. Every child
peak was validated from its projection receipt against the 268,435,456-byte gate.
The manual artifact was 583,149 bytes. The portable graph artifact was 718,383 bytes. The manual
plan was 1,006,393 bytes and its ordinary package was 1,007,297 bytes. The graph plan was 398,158
bytes and its package was 398,715 bytes.
## Equivalence and no-work gates
The benchmark proved exact output equivalence for:
- Manual in-process and detached rendering.
- Manual full and fragment-assisted rendering.
- Production cold, warm, and forced-full rendering.
- Production add, change, delete, and reorder variants.
- Portable graph in-process and detached rendering.
Manual and portable-graph status each performed zero project loads, source parses, adapter
projection loads, adapter extraction, index checks, synchronization, index builds, render
preparation, output construction, output hashing, and viewer-manager requests. Each status path
performed only two cheap source-generation checks and verified committed receipt or manifest
evidence.
## Measured limits and future notes
- Fragment reuse is a correctness, isolation, and recovery boundary in this milestone. At 1,000
pages, production warm fragment validation is slower than the forced-full path. Later
optimization must start from this measurement and preserve byte equivalence.
- Full rendering remains the oracle and recovery path. Invalid, corrupt, oversized, stale, or
mismatched fragment records fall back without changing canonical facts.
- The benchmark main-process `ru_maxrss` value was 102,692 KiB. It is cumulative across all
main-process operations and is recorded only as diagnostic context. Detached child peaks are
measured separately. Per-operation traced peaks and every detached receipt peak own the memory
gates.
- The results do not justify a storage rewrite, render farm, remote renderer, or separate render
MCP.

View file

@ -1,89 +0,0 @@
# Milestone 3 closeout
## Outcome
Milestone 3 is complete. Manual compilation, portable graph rendering, and the live viewer are
separate generation-pinned consumers of the validated graph. They cannot become canonical or
retrieval authority.
Implemented contracts:
- Version-1 `ManualRenderPlan`, `GraphViewPlan`, projection package, and projection receipt.
- Strict canonical JSON identities and packaged Draft 2020-12 schemas.
- Independent manual and portable-graph renderer import boundaries.
- One isolated, fixed, one-request detached worker protocol with bounded request, response,
artifact, timeout, environment, and renderer inventory.
- Content-addressed portable graph artifacts, renderer receipts, generation/view manifests,
receipt-only status, repair, and degraded committed-publication evidence.
- Disposable semantic fragment records with bounded cache inventory, corruption recovery, and
full-render equivalence.
- Version-2 independent projection policy while preserving version-1 effective-policy behavior.
- Generation-pinned live source reads and a separate read-only viewer-manager lifecycle.
- Automated axe-tag and keyboard gates for the manual, portable graph, and live viewer.
- Repository-native contract, smoke, scale, response-size, memory, and equivalence gates.
## Candidate evidence
The frozen implementation candidate is
`f5dccb5e1c312121f1af63780162f593d9363b98`.
The complete repository gate passed:
- Ruff formatting and lint.
- HTML, rendered-manual HTML, portable-graph HTML, CSS, and JavaScript checks.
- Pyright with zero diagnostics.
- Warning-strict compilation and tests.
- 281 tests and 272 subtests.
- Three Playwright and axe accessibility flows. The alpha manual is checked with WCAG 2.0/2.1
A/AA axe tags; portable and live graph flows add WCAG 2.2 A/AA tags and keyboard interaction.
- Lock and npm dependency-tree checks.
- Wheel and source-distribution builds.
- Milestone 0, 1, 2, and 3 smoke benchmarks.
The maintained projection contract subset passed 142 tests and 236 subtests. A 10,000-node deep
chain and one 10,000-node strongly connected component prove that manual cycle planning has no
recursion-depth failure.
An isolated wheel installation passed CLI and MCP startup, a real detached manual render, and the
closed malformed-worker-request contract. Six Milestone 3 commits and the complete candidate tree
passed Gitleaks 8.30.1 with no findings.
Three independent adversarial review tracks covered manual isolation and fragment integrity,
portable publication and policy binding, and worker/accessibility/benchmark gates. Reproduced
project import, hostile environment, unbounded stdout, fragment forgery, cache growth, aggregate
overflow, coordinated policy drift, render-limit compatibility, deep-graph, and module-startup
defects were fixed and regression-tested before closeout.
The clean ten-sample 1,000-node benchmark passed every threshold. Exact measurements, equivalence
results, memory peaks, and response sizes are recorded in
[`MILESTONE_3_BASELINE.md`](MILESTONE_3_BASELINE.md) and
[`benchmarks/milestone3-2026-07-29.json`](../benchmarks/milestone3-2026-07-29.json).
## Preserved boundaries
- The `docforge` distribution, package, CLI, MCP executable, and existing tool names remain.
- The frozen alpha manual remains exactly 2,043 bytes with its legacy output hash and render
identity.
- Legacy one-method `load_projection()` adapters remain supported.
- Effective policy version 1, no-AST behavior, and existing client bindings remain compatible.
- Project descriptor schema version 1 and SQLite index schema version 3 remain unchanged.
- Configured `max_render_bytes` values above the detached transfer ceiling still load; a small
actual artifact renders normally. Actual detached transfer remains capped at 20,000,000 bytes.
- No storage replacement or self-hosting dependency was introduced.
- WorldForge and ScrapeStation were not touched.
- No production MCP integration was repointed.
- The legacy Forgejo repository and `legacy` remote were not changed.
- No tag, release, release announcement, or visibility change was created.
## Known follow-up work
Milestone 4 remains directional and is not active. Its adapter SDK and product-documentation work
must not silently absorb these separate future ideas:
- Optimize production fragment reuse only from measured profiles while preserving the forced-full
oracle.
- Add authenticated cursors only if a stronger threat model requires them.
- Verify Claude's native timeout representation.
- Add versioned adapter-owned launcher metadata before generating custom-adapter configurations.
- Keep remote render services, shared render farms, third-party renderers, storage replacement,
and self-hosting deferred until their own evidence justifies them.

View file

@ -27,10 +27,6 @@ incremental methods while retaining the full loader as a fallback.
- Opt-in incremental adapter extraction with reverse-dependency invalidation.
- Lazy function-scoped logic projections that do not densify the primary graph.
- Declared HTML render views. Arbitrary templates, render commands, and output paths are rejected.
- Separate versioned manual and portable graph plans, immutable packages, detached built-in
renderers, and validated receipts.
- Content-addressed portable Nodes/Flow/Web artifacts with receipt-only status and repair.
- Independent manual, portable-graph, and live-viewer policy.
- A loopback-only graph browser with Nodes, semantic Flow, and convergence Web views,
relationship keys, source inspection, branch-aware node hiding, panel resizing, zooming, and
managed idle shutdown.
@ -51,11 +47,7 @@ Canonical files own facts:
canonical Markdown/TOML or adapter sources
↓ validate
disposable SQLite graph
├── query / compile context
├── ManualRenderPlanV1 → detached manual renderer → declared manual
├── GraphViewPlanV1 → detached graph renderer → portable Nodes/Flow/Web artifact
└── pinned index → managed live Nodes/Flow/Web/Logic viewer
↓ query / visualize / compile context
people and agents
↓ propose
isolated changeset + preview
@ -85,8 +77,8 @@ source format.
Clone and verify DocForge:
```bash
git clone forgejo@repo.andraxion.net:administrator/DocForge2.git /absolute/path/DocForge2
cd /absolute/path/DocForge2
git clone forgejo@repo.andraxion.net:administrator/DocForge.git /absolute/path/DocForge
cd /absolute/path/DocForge
uv sync --group dev
npm ci
@ -167,27 +159,6 @@ output = "Docs/Rendered/Manual.html"
title = "My Project Manual"
families = ["architecture", "system", "operations", "roadmap"]
[graph_render]
output_root = ".docforge/portable-graph"
[[graph_render.views]]
id = "architecture"
renderer = "portable_graph_html"
output = "architecture.html"
title = "Architecture"
root = "architecture.overview"
initial_mode = "web"
depth = 3
max_nodes = 250
max_edges = 1000
max_work = 100000
families = ["architecture", "system"]
relations = ["depends_on", "owns", "calls", "reads", "writes", "tested_by", "relates_to"]
authorities = []
statuses = ["current", "active", "verified"]
tags = []
include_logic = false
[graph]
allowed_relations = ["depends_on", "owns", "calls", "reads", "writes", "tested_by", "relates_to"]
@ -521,25 +492,13 @@ builds or repairs the index.
```text
render-status [VIEW_ID] [--deep]
render VIEW_ID
graph-plan VIEW_ID
graph-render VIEW_ID
graph-render-status [VIEW_ID]
preview CHANGESET_ID VIEW_ID
apply CHANGESET_ID --changeset-hash SHA256 --applier WRITER_ID
```
`graph-plan` validates and returns one declared `GraphViewPlanV1` without publishing. A portable
view must select exactly one stable `root` or metadata-only lexical `query`. It may use only Nodes,
Flow, or Web as `initial_mode`; portable version 1 excludes function-scoped Logic.
`graph-render` explicitly publishes the declared static artifact, content-addressed renderer
evidence, and generation/view manifest. `graph-render-status` verifies only bounded committed
evidence and never plans or renders. Portable publication is a local CLI action.
The CLI apply command supports the generic adapter. It verifies that the configured writer owns the
changeset, applies the exact reviewed hash, rebuilds the index, checks it, and regenerates every
declared manual render when manual policy is `auto`. It does not publish portable graphs, commit,
or push the result.
declared render. It does not commit or push the result.
### Viewer commands
@ -590,20 +549,6 @@ declares the named writer, and application requires the same writer/applier iden
`--no-ast` to preserve the no-AST binding. Generic CLI generation refuses project-owned adapters
because it cannot safely reconstruct their composition.
Select projection behavior independently:
```bash
docforge configure codex \
--project /absolute/path/MyProject \
--manual-render-policy explicit \
--portable-graph-policy disabled \
--live-viewer-policy on-demand
```
The generated version-1 configuration result carries an additive version-2 `projection_policy`,
its hash, projection availability, and the exact descriptor hash. Omitted default selectors are
validated against that descriptor rather than trusted as self-reported output.
Inspect one configured client binding:
```bash
@ -624,54 +569,6 @@ starts MCP, executes the configured command, synchronizes, builds, renders, star
writes configuration. Claude timeout representation and client filtering that cannot be proved
locally remain explicit warnings.
## Independent projection behavior
Manual and portable graph renderers consume immutable, path-free packages. A package binds one
generation-pinned plan, inert assets, fixed component versions, a built-in renderer identity, and
an exact artifact inventory. The detached child cannot select nodes, open the project or index,
choose a publication path, execute project code, or mutate canonical facts.
Child startup is fixed to isolated Python, a private module entrypoint, a trusted working
directory, and a sanitized environment. One request and response use canonical newline-terminated
JSON. The request, response, receipt, execution time, and disk-spooled stdout are bounded. Actual
artifact transfer is capped at 20,000,000 bytes even when the descriptor retains a larger
`max_render_bytes` compatibility value.
Manual fragment records are disposable semantic cache entries. On a cold miss, DocForge performs a
trusted full detached render, extracts candidate page fragments, and compares fragment-assisted
output byte-for-byte before publishing records. On a warm hit, the worker recomputes each expected
page fragment before accepting cached bytes. Corrupt, forged, stale, individually oversized, or
aggregate-oversized records fall back to the full oracle. Fragment reuse is currently a correctness
and recovery boundary, not a promised speedup.
Projection policy version 2 is:
```text
manual: auto | explicit | disabled
portable_graph: explicit | disabled
live_viewer: on-demand | disabled
```
For ordinary CLI commands, place the corresponding global flag before the subcommand:
```bash
docforge --project-root "$PROJECT" --manual-render-policy disabled render manual
docforge --project-root "$PROJECT" --portable-graph-policy disabled graph-plan architecture
docforge --project-root "$PROJECT" --live-viewer-policy disabled visualize
```
An active operation blocked by policy returns `projection_policy_forbids_operation` before hidden
work. Manual and portable receipt-only status remain available. Viewer status and explicit stop
remain available when viewer start is disabled.
A non-disabled projection also requires its declared configuration or runtime. Manual `explicit`
requires manual render configuration. Manual `auto` additionally requires canonical application in
the current operation or server capability. Portable graph `explicit` requires portable graph
render configuration, and live viewer `on-demand` requires its runtime. An unavailable selection
returns `projection_policy_unavailable` before work begins. In particular, ordinary CLI `render`
operations cannot select manual `auto`; use `explicit`, or let a configured canonical `apply`
operation own automatic regeneration.
## MCP usage
Run one MCP server per project with absolute paths:
@ -689,10 +586,7 @@ Select the session's declared surface explicitly when useful:
```bash
docforge-mcp \
--project-root /absolute/path/MyProject \
--capability-mode read \
--manual-render-policy explicit \
--portable-graph-policy explicit \
--live-viewer-policy on-demand
--capability-mode read
```
Supported modes are `read`, `proposal`, `application`, and `operator`. Existing startup defaults
@ -719,9 +613,8 @@ identity, not a command. The changeset creator, configured writer, and canonical
Call `docforge_bootstrap` first. Its version-1 `session_contract` contains the fixed binding,
current graph generation, effective policy, actual capabilities, render policies, prohibitions,
and a recommended first operation. The result also carries the independently composed version-2
`projection_policy` and hash. Workflow guidance does not recommend registration or application
when those startup capabilities are unavailable.
and a recommended first operation. Workflow guidance does not recommend registration or
application when those startup capabilities are unavailable.
Example MCP client configuration:
@ -760,16 +653,11 @@ Example MCP client configuration:
- `docforge_get_task_context`
- `docforge_validate_project`
- `docforge_render_status`
- `docforge_graph_plan`
- `docforge_graph_render_status`
- `docforge_visualize`
- `docforge_visualization_status`
- `docforge_stop_visualization`
- `docforge_get_generation_diff`
MCP graph plan and status are read-only. MCP does not expose portable graph publication; use the
explicit local `graph-render` CLI command.
### Proposal tools
- `docforge_create_changeset`
@ -1088,29 +976,8 @@ docforge --project-root "$PROJECT" render-status
docforge --project-root "$PROJECT" render VIEW_ID
```
Successful canonical apply regenerates declared manual views only when manual policy is `auto`.
A manual canonical edit requires reindexing and explicit rendering. Portable graph publication
always remains a separate explicit CLI action.
Portable graph publication has separate status and policy:
```bash
docforge --project-root "$PROJECT" graph-render-status
docforge --project-root "$PROJECT" graph-render architecture
```
### `projection_policy_forbids_operation`
The process was deliberately started with the relevant manual, portable-graph, or live-viewer
operation disabled. Restart with an allowed selector after confirming that the integration should
receive that capability. Status and explicit stop operations remain available as described above.
### `projection_policy_unavailable`
The selected non-disabled projection has no matching project configuration or runtime. Add the
declared manual or portable graph render configuration, or make the live viewer runtime available,
before selecting that mode. Manual `auto` also requires an operation or MCP server with canonical
application enabled. Use manual `explicit` for a standalone CLI render.
Successful canonical apply regenerates all declared views automatically. A manual canonical edit
requires reindexing and rendering.
### Descriptor changed after startup
@ -1125,11 +992,8 @@ Run the complete release gate from the DocForge repository:
make gate
```
Use `make benchmark` for the historical Milestone 0 baseline, `make benchmark-m1` for the
counter-gated warm-operation benchmark, `make benchmark-m2` for agent workflow gates, and
`make benchmark-m3-full` for the ten-sample 1,000-node projection, worker, fragment, status,
equivalence, response-size, and memory gates. `make accessibility` runs the generated manual,
portable graph, and live viewer axe and keyboard flows.
Use `make benchmark` for the historical Milestone 0 baseline and `make benchmark-m1` for the
counter-gated 1,000-node warm-operation benchmark.
Project-specific vocabulary, extraction rules, and serialization belong in the project adapter.
Generic core behavior must remain deterministic, project-bound, and recoverable.

View file

@ -21,26 +21,4 @@ export default [
"prefer-const": "error",
},
},
{
files: ["playwright.accessibility.config.mjs", "tests/accessibility.spec.mjs"],
...js.configs.recommended,
languageOptions: {
ecmaVersion: 2024,
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
},
linterOptions: {
reportUnusedDisableDirectives: "error",
},
rules: {
...js.configs.recommended.rules,
eqeqeq: "error",
"no-implicit-coercion": "error",
"no-var": "error",
"prefer-const": "error",
},
},
];

88
package-lock.json generated
View file

@ -8,9 +8,7 @@
"name": "docforge-web-quality",
"version": "0.0.0",
"devDependencies": {
"@axe-core/playwright": "4.12.1",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.0",
"eslint": "10.8.0",
"globals": "17.7.0",
"html-validate": "11.5.6",
@ -20,19 +18,6 @@
"stylelint-csstree-validator": "4.0.0"
}
},
"node_modules/@axe-core/playwright": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz",
"integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.12.1"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@ -530,22 +515,6 @@
"node": ">= 8"
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@ -666,16 +635,6 @@
"node": ">=8"
}
},
"node_modules/axe-core": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
"integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -1984,53 +1943,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",

View file

@ -4,14 +4,10 @@
"private": true,
"packageManager": "npm@10.9.7",
"scripts": {
"install:accessibility-browser": "playwright install chromium",
"lint:web": "uv run python tools/check_web_assets.py && eslint --max-warnings=0 playwright.accessibility.config.mjs tests/accessibility.spec.mjs",
"test:accessibility": "npm run install:accessibility-browser && playwright test --config=playwright.accessibility.config.mjs"
"lint:web": "uv run python tools/check_web_assets.py"
},
"devDependencies": {
"@axe-core/playwright": "4.12.1",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.0",
"eslint": "10.8.0",
"globals": "17.7.0",
"html-validate": "11.5.6",

View file

@ -1,24 +0,0 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
testMatch: "accessibility.spec.mjs",
fullyParallel: false,
workers: 1,
retries: 0,
reporter: "line",
outputDir: "/tmp/docforge-playwright-accessibility",
timeout: 30_000,
expect: {
timeout: 5_000,
},
use: {
browserName: "chromium",
bypassCSP: true,
headless: true,
viewport: {
width: 1440,
height: 1000,
},
},
});

View file

@ -31,10 +31,7 @@ docforge-mcp = "docforge.mcp_server:main"
docforge-viewer-manager = "docforge.viewer_manager:main"
[tool.hatch.build.targets.wheel]
packages = ["src/docforge", "src/docforge_renderers"]
[tool.hatch.build.targets.wheel.force-include]
schemas = "docforge/schemas"
packages = ["src/docforge"]
[tool.ruff]
line-length = 100

View file

@ -127,17 +127,6 @@
},
"additionalProperties": false
},
"projection_policy": {
"type": "object",
"required": ["schema_version", "manual", "portable_graph", "live_viewer"],
"properties": {
"schema_version": { "const": 2 },
"manual": { "enum": ["auto", "explicit", "disabled"] },
"portable_graph": { "enum": ["explicit", "disabled"] },
"live_viewer": { "enum": ["on-demand", "disabled"] }
},
"additionalProperties": false
},
"diagnostics": {
"type": "object",
"required": [
@ -222,9 +211,6 @@
"project",
"binding",
"effective_policy",
"projection_policy",
"projection_policy_hash",
"projection_availability",
"artifact",
"configuration_hash",
"warnings"
@ -245,8 +231,7 @@
"project_id",
"project_root",
"project_root_fingerprint",
"adapter",
"descriptor_hash"
"adapter"
],
"properties": {
"project_id": { "type": "string", "minLength": 1 },
@ -255,8 +240,7 @@
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"adapter": { "type": "string", "minLength": 1 },
"descriptor_hash": { "$ref": "#/$defs/sha256" }
"adapter": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
@ -333,24 +317,6 @@
"additionalProperties": false
},
"effective_policy": { "$ref": "#/$defs/effective_policy" },
"projection_policy": { "$ref": "#/$defs/projection_policy" },
"projection_policy_hash": { "$ref": "#/$defs/sha256" },
"projection_availability": {
"type": "object",
"required": [
"manual_configured",
"portable_graph_configured",
"application_enabled",
"live_viewer_available"
],
"properties": {
"manual_configured": { "type": "boolean" },
"portable_graph_configured": { "type": "boolean" },
"application_enabled": { "type": "boolean" },
"live_viewer_available": { "const": true }
},
"additionalProperties": false
},
"artifact": {
"type": "object",
"required": [

View file

@ -1,321 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://docforge.local/schema/graph-view-plan-v1.json",
"title": "DocForge immutable portable graph view plan",
"$comment": "plan_id is the SHA-256 of canonical JSON without plan_id and is verified by the runtime contract validator. The version-1 nested graph vocabulary remains planner-owned.",
"$defs": {
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"project": {
"type": "object",
"required": [
"project_id",
"project_root_fingerprint",
"adapter",
"revision",
"source_hash"
],
"properties": {
"project_id": { "type": "string", "minLength": 1 },
"project_root_fingerprint": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"adapter": { "type": "string", "minLength": 1 },
"revision": { "type": "string", "minLength": 1 },
"source_hash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"filter": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"uniqueItems": true
},
"edge": {
"type": "object",
"required": ["source_id", "relation", "target_id"],
"properties": {
"source_id": { "type": "string", "minLength": 1 },
"relation": { "type": "string", "minLength": 1 },
"target_id": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"node": {
"type": "object",
"required": [
"node_id",
"title",
"family",
"authority",
"status",
"tags",
"summary",
"content_hash"
],
"properties": {
"node_id": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"family": { "type": "string", "minLength": 1 },
"authority": { "type": "string", "minLength": 1 },
"status": { "type": "string", "minLength": 1 },
"tags": { "$ref": "#/$defs/filter" },
"summary": { "type": "string" },
"content_hash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"omission": {
"oneOf": [
{
"type": "object",
"required": ["code", "subject", "limit", "minimum_omitted"],
"properties": {
"code": { "const": "node_result_limit" },
"subject": { "const": "nodes" },
"limit": { "type": "integer", "minimum": 1, "maximum": 1000 },
"minimum_omitted": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["code", "subject", "limit", "minimum_omitted"],
"properties": {
"code": { "const": "edge_result_limit" },
"subject": { "const": "edges" },
"limit": { "type": "integer", "minimum": 0, "maximum": 4000 },
"minimum_omitted": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"code",
"subject",
"limit",
"examined",
"minimum_omitted"
],
"properties": {
"code": { "const": "work_limit" },
"subject": { "const": "selection" },
"limit": { "type": "integer", "minimum": 1, "maximum": 1000000 },
"examined": { "type": "integer", "minimum": 0 },
"minimum_omitted": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["code", "subject", "minimum_omitted"],
"properties": {
"code": { "const": "logic_forbidden" },
"subject": { "const": "logic" },
"minimum_omitted": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
}
]
}
},
"type": "object",
"required": [
"schema_version",
"contract",
"plan_id",
"project",
"view",
"bounds",
"policy",
"graph",
"omissions",
"diagnostics"
],
"properties": {
"schema_version": { "const": 1 },
"contract": { "const": "docforge.graph-view-plan" },
"plan_id": { "$ref": "#/$defs/sha256" },
"project": { "$ref": "#/$defs/project" },
"view": {
"type": "object",
"required": [
"view_id",
"title",
"initial_mode",
"scope",
"filters",
"detail_fields"
],
"properties": {
"view_id": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"initial_mode": { "enum": ["nodes", "flow", "web", "logic"] },
"scope": {
"oneOf": [
{
"type": "object",
"required": ["kind", "root_node_id", "depth"],
"properties": {
"kind": { "const": "exact_root" },
"root_node_id": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"depth": { "type": "integer", "minimum": 1, "maximum": 32 }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["kind", "query"],
"properties": {
"kind": { "const": "lexical" },
"query": {
"type": "string",
"minLength": 1,
"maxLength": 10000
}
},
"additionalProperties": false
}
]
},
"filters": {
"type": "object",
"required": [
"families",
"relations",
"authorities",
"statuses",
"tags"
],
"properties": {
"families": { "$ref": "#/$defs/filter" },
"relations": { "$ref": "#/$defs/filter" },
"authorities": { "$ref": "#/$defs/filter" },
"statuses": { "$ref": "#/$defs/filter" },
"tags": { "$ref": "#/$defs/filter" }
},
"additionalProperties": false
},
"detail_fields": {
"const": [
"node_id",
"title",
"family",
"authority",
"status",
"tags",
"summary",
"content_hash"
]
}
},
"additionalProperties": false
},
"bounds": {
"type": "object",
"required": ["depth", "max_nodes", "max_edges", "max_work"],
"properties": {
"depth": { "type": "integer", "minimum": 1, "maximum": 32 },
"max_nodes": { "type": "integer", "minimum": 1, "maximum": 1000 },
"max_edges": { "type": "integer", "minimum": 0, "maximum": 4000 },
"max_work": { "type": "integer", "minimum": 1, "maximum": 1000000 }
},
"additionalProperties": false
},
"policy": {
"type": "object",
"required": [
"visibility",
"source_paths",
"source_bodies",
"database_queries",
"executable_content",
"logic",
"logic_requested"
],
"properties": {
"visibility": { "const": "selected_graph_only" },
"source_paths": { "const": "excluded" },
"source_bodies": { "const": "excluded" },
"database_queries": { "const": "forbidden" },
"executable_content": { "const": "forbidden" },
"logic": { "enum": ["allowed", "forbidden"] },
"logic_requested": { "type": "boolean" }
},
"additionalProperties": false
},
"graph": {
"type": "object",
"required": ["root_node_id", "nodes", "edges", "logic_projections"],
"properties": {
"root_node_id": {
"type": ["string", "null"],
"minLength": 1,
"maxLength": 1024
},
"nodes": {
"type": "array",
"maxItems": 1000,
"items": { "$ref": "#/$defs/node" }
},
"edges": {
"type": "array",
"maxItems": 4000,
"items": { "$ref": "#/$defs/edge" }
},
"logic_projections": {
"type": "array",
"maxItems": 0
}
},
"additionalProperties": false
},
"omissions": {
"type": "array",
"maxItems": 4,
"items": { "$ref": "#/$defs/omission" }
},
"diagnostics": {
"type": "object",
"required": [
"selection",
"returned_nodes",
"returned_edges",
"examined_work_units",
"truncated",
"ordering"
],
"properties": {
"selection": { "enum": ["exact_root", "lexical"] },
"returned_nodes": { "type": "integer", "minimum": 0, "maximum": 1000 },
"returned_edges": { "type": "integer", "minimum": 0, "maximum": 4000 },
"examined_work_units": { "type": "integer", "minimum": 0 },
"truncated": { "type": "boolean" },
"ordering": { "const": "node_id;source_id,relation,target_id" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}

View file

@ -1,214 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://docforge.local/schema/manual-render-plan-v1.json",
"title": "DocForge immutable manual render plan",
"$comment": "plan_id is the SHA-256 of canonical JSON without plan_id and is verified by the runtime contract validator.",
"$defs": {
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"project": {
"type": "object",
"required": [
"project_id",
"project_root_fingerprint",
"adapter",
"revision",
"source_hash"
],
"properties": {
"project_id": { "type": "string", "minLength": 1 },
"project_root_fingerprint": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"adapter": { "type": "string", "minLength": 1 },
"revision": { "type": "string", "minLength": 1 },
"source_hash": { "$ref": "#/$defs/sha256" }
},
"additionalProperties": false
},
"edge": {
"type": "object",
"required": ["source_id", "relation", "target_id"],
"properties": {
"source_id": { "type": "string", "minLength": 1 },
"relation": { "type": "string", "minLength": 1 },
"target_id": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"page": {
"type": "object",
"required": [
"node_id",
"title",
"family",
"authority",
"status",
"tags",
"summary",
"content",
"content_hash",
"components",
"breadcrumbs",
"cross_references",
"backlinks"
],
"properties": {
"node_id": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"family": { "type": "string", "minLength": 1 },
"authority": { "type": "string", "minLength": 1 },
"status": { "type": "string", "minLength": 1 },
"tags": {
"type": "array",
"maxItems": 10000,
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"summary": { "type": "string" },
"content": { "type": "string" },
"content_hash": { "$ref": "#/$defs/sha256" },
"components": {
"type": "array",
"maxItems": 32,
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"breadcrumbs": {
"type": "array",
"maxItems": 10000,
"items": { "type": "string", "minLength": 1 }
},
"cross_references": {
"type": "array",
"maxItems": 10000,
"items": { "$ref": "#/$defs/edge" }
},
"backlinks": {
"type": "array",
"maxItems": 10000,
"items": { "$ref": "#/$defs/edge" }
}
},
"additionalProperties": false
},
"navigation_item": {
"type": "object",
"required": ["node_id", "title"],
"properties": {
"node_id": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"search_document": {
"type": "object",
"required": [
"node_id",
"title",
"summary",
"family",
"status",
"tags"
],
"properties": {
"node_id": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"summary": { "type": "string" },
"family": { "type": "string", "minLength": 1 },
"status": { "type": "string", "minLength": 1 },
"tags": {
"type": "array",
"maxItems": 10000,
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
},
"additionalProperties": false
}
},
"type": "object",
"required": [
"schema_version",
"contract",
"plan_id",
"project",
"view",
"changeset_hash",
"pages",
"navigation",
"search_documents",
"diagnostics"
],
"properties": {
"schema_version": { "const": 1 },
"contract": { "const": "docforge.manual-render-plan" },
"plan_id": { "$ref": "#/$defs/sha256" },
"project": { "$ref": "#/$defs/project" },
"view": {
"type": "object",
"required": ["view_id", "title", "families", "renderer"],
"properties": {
"view_id": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"families": {
"type": "array",
"maxItems": 10000,
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"renderer": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"changeset_hash": {
"oneOf": [
{ "$ref": "#/$defs/sha256" },
{ "type": "null" }
]
},
"pages": {
"type": "array",
"maxItems": 10000,
"items": { "$ref": "#/$defs/page" }
},
"navigation": {
"type": "array",
"maxItems": 10000,
"items": { "$ref": "#/$defs/navigation_item" }
},
"search_documents": {
"type": "array",
"maxItems": 10000,
"items": { "$ref": "#/$defs/search_document" }
},
"diagnostics": {
"type": "object",
"required": ["orphans", "cycles"],
"properties": {
"orphans": {
"type": "array",
"maxItems": 10000,
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"cycles": {
"type": "array",
"maxItems": 10000,
"items": {
"type": "array",
"minItems": 1,
"maxItems": 10000,
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}

View file

@ -74,82 +74,6 @@
},
"additionalProperties": false
},
"graph_render": {
"type": "object",
"required": ["output_root", "views"],
"properties": {
"output_root": { "$ref": "#/$defs/relativePath" },
"views": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["id", "renderer", "output", "title"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{1,127}$" },
"renderer": { "const": "portable_graph_html" },
"output": {
"allOf": [
{ "$ref": "#/$defs/relativePath" },
{ "pattern": "\\.html$" }
]
},
"title": { "type": "string", "minLength": 1, "maxLength": 1024 },
"root": { "type": "string", "minLength": 1, "maxLength": 1024 },
"query": { "type": "string", "minLength": 1, "maxLength": 10000 },
"initial_mode": { "enum": ["nodes", "flow", "web"] },
"depth": { "type": "integer", "minimum": 1, "maximum": 32 },
"max_nodes": { "type": "integer", "minimum": 1, "maximum": 1000 },
"max_edges": { "type": "integer", "minimum": 0, "maximum": 4000 },
"max_work": { "type": "integer", "minimum": 1, "maximum": 1000000 },
"families": {
"type": "array",
"maxItems": 64,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1, "maxLength": 1024 }
},
"relations": {
"type": "array",
"maxItems": 64,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1, "maxLength": 1024 }
},
"authorities": {
"type": "array",
"maxItems": 64,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1, "maxLength": 1024 }
},
"statuses": {
"type": "array",
"maxItems": 64,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1, "maxLength": 1024 }
},
"tags": {
"type": "array",
"maxItems": 64,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1, "maxLength": 1024 }
},
"include_logic": { "const": false }
},
"oneOf": [
{
"required": ["root"],
"not": { "required": ["query"] }
},
{
"required": ["query"],
"not": { "required": ["root"] }
}
],
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"graph": {
"type": "object",
"required": ["allowed_relations"],

View file

@ -1,190 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://docforge.local/schema/projection-package-v1.json",
"title": "DocForge immutable renderer projection package",
"$comment": "package_id and embedded plan identity equality are verified by the runtime contract validator.",
"$defs": {
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"manual_plan": {
"type": "object",
"required": [
"schema_version",
"contract",
"plan_id",
"project",
"view",
"changeset_hash",
"pages",
"navigation",
"search_documents",
"diagnostics"
],
"properties": {
"schema_version": { "const": 1 },
"contract": { "const": "docforge.manual-render-plan" },
"plan_id": { "$ref": "#/$defs/sha256" },
"project": { "type": "object" },
"view": { "type": "object" },
"changeset_hash": {
"oneOf": [
{ "$ref": "#/$defs/sha256" },
{ "type": "null" }
]
},
"pages": { "type": "array", "maxItems": 10000 },
"navigation": { "type": "array", "maxItems": 10000 },
"search_documents": { "type": "array", "maxItems": 10000 },
"diagnostics": { "type": "object" }
},
"additionalProperties": false
},
"graph_plan": {
"type": "object",
"required": [
"schema_version",
"contract",
"plan_id",
"project",
"view",
"bounds",
"policy",
"graph",
"omissions",
"diagnostics"
],
"properties": {
"schema_version": { "const": 1 },
"contract": { "const": "docforge.graph-view-plan" },
"plan_id": { "$ref": "#/$defs/sha256" },
"project": { "type": "object" },
"view": { "type": "object" },
"bounds": { "type": "object" },
"policy": { "type": "object" },
"graph": { "type": "object" },
"omissions": { "type": "array", "maxItems": 10000 },
"diagnostics": { "type": "object" }
},
"additionalProperties": false
},
"renderer": {
"type": "object",
"required": ["renderer_id", "renderer_version"],
"properties": {
"renderer_id": { "type": "string", "minLength": 1 },
"renderer_version": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"component": {
"type": "object",
"required": ["component_id"],
"properties": {
"component_id": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"asset": {
"type": "object",
"required": ["asset_id", "media_type", "sha256", "text"],
"properties": {
"asset_id": {
"type": "string",
"minLength": 1,
"pattern": "^[^/]+$"
},
"media_type": { "type": "string", "minLength": 1 },
"sha256": { "$ref": "#/$defs/sha256" },
"text": { "type": "string" }
},
"additionalProperties": false
}
},
"type": "object",
"required": [
"schema_version",
"contract",
"package_id",
"kind",
"plan_id",
"plan",
"renderer",
"components",
"assets",
"output_policy"
],
"properties": {
"schema_version": { "const": 1 },
"contract": { "const": "docforge.projection-package" },
"package_id": { "$ref": "#/$defs/sha256" },
"kind": { "enum": ["manual", "graph"] },
"plan_id": { "$ref": "#/$defs/sha256" },
"plan": {
"oneOf": [
{ "$ref": "#/$defs/manual_plan" },
{ "$ref": "#/$defs/graph_plan" }
]
},
"renderer": { "$ref": "#/$defs/renderer" },
"components": {
"type": "array",
"maxItems": 32,
"items": { "$ref": "#/$defs/component" },
"uniqueItems": true
},
"assets": {
"type": "array",
"maxItems": 32,
"items": { "$ref": "#/$defs/asset" }
},
"output_policy": {
"type": "object",
"required": ["artifact_ids", "max_total_bytes"],
"properties": {
"artifact_ids": {
"type": "array",
"minItems": 1,
"maxItems": 32,
"items": {
"type": "string",
"minLength": 1,
"pattern": "^[^/]+$"
},
"uniqueItems": true
},
"max_total_bytes": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false
}
},
"allOf": [
{
"if": {
"properties": { "kind": { "const": "manual" } },
"required": ["kind"]
},
"then": {
"properties": {
"plan": { "$ref": "#/$defs/manual_plan" }
}
}
},
{
"if": {
"properties": { "kind": { "const": "graph" } },
"required": ["kind"]
},
"then": {
"properties": {
"plan": { "$ref": "#/$defs/graph_plan" }
}
}
}
],
"additionalProperties": false
}

View file

@ -1,19 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://docforge.local/schema/projection-policy-v2.json",
"title": "DocForge independent projection policy",
"type": "object",
"required": [
"schema_version",
"manual",
"portable_graph",
"live_viewer"
],
"properties": {
"schema_version": { "const": 2 },
"manual": { "enum": ["auto", "explicit", "disabled"] },
"portable_graph": { "enum": ["explicit", "disabled"] },
"live_viewer": { "enum": ["on-demand", "disabled"] }
},
"additionalProperties": false
}

View file

@ -1,89 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://docforge.local/schema/projection-receipt-v1.json",
"title": "DocForge projection renderer receipt",
"$comment": "receipt_id is the SHA-256 of canonical JSON without receipt_id and is verified by the runtime contract validator.",
"$defs": {
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"renderer": {
"type": "object",
"required": ["renderer_id", "renderer_version"],
"properties": {
"renderer_id": { "type": "string", "minLength": 1 },
"renderer_version": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"artifact": {
"type": "object",
"required": ["artifact_id", "media_type", "sha256", "bytes"],
"properties": {
"artifact_id": {
"type": "string",
"minLength": 1,
"pattern": "^[^/]+$"
},
"media_type": { "type": "string", "minLength": 1 },
"sha256": { "$ref": "#/$defs/sha256" },
"bytes": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
}
},
"type": "object",
"required": [
"schema_version",
"contract",
"receipt_id",
"kind",
"package_id",
"plan_id",
"renderer",
"artifacts",
"diagnostics",
"timing",
"peak_memory_bytes"
],
"properties": {
"schema_version": { "const": 1 },
"contract": { "const": "docforge.projection-receipt" },
"receipt_id": { "$ref": "#/$defs/sha256" },
"kind": { "enum": ["manual", "graph"] },
"package_id": { "$ref": "#/$defs/sha256" },
"plan_id": { "$ref": "#/$defs/sha256" },
"renderer": { "$ref": "#/$defs/renderer" },
"artifacts": {
"type": "array",
"maxItems": 32,
"items": { "$ref": "#/$defs/artifact" }
},
"diagnostics": {
"type": "object",
"required": ["warnings"],
"properties": {
"warnings": {
"type": "array",
"maxItems": 10000,
"items": { "type": "string" }
}
},
"additionalProperties": false
},
"timing": {
"type": "object",
"required": ["elapsed_ns"],
"properties": {
"elapsed_ns": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
},
"peak_memory_bytes": {
"type": ["integer", "null"],
"minimum": 0
}
},
"additionalProperties": false
}

View file

@ -20,7 +20,6 @@
"test",
"benchmark.m1",
"benchmark.m2",
"benchmark.m3",
"mcp.invoke",
"mcp.bootstrap",
"mcp.sync",
@ -38,8 +37,6 @@
"mcp.generation_diff",
"mcp.validate_project",
"mcp.render_status",
"mcp.graph_plan",
"mcp.graph_render_status",
"mcp.visualize",
"mcp.visualization_status",
"mcp.stop_visualization",
@ -61,9 +58,6 @@
"cli.impact",
"cli.context",
"cli.generation-diff",
"cli.graph-plan",
"cli.graph-render",
"cli.graph-render-status",
"cli.configure",
"cli.doctor",
"cli.render",

View file

@ -3,10 +3,7 @@
from __future__ import annotations
import os
import secrets
import stat
from collections.abc import Callable
from contextlib import suppress
from pathlib import Path
from .errors import DocForgeError
@ -72,177 +69,3 @@ def require_bound_directory(path: Path, directory_fd: int) -> None:
"path_escape",
"Derived cache root disappeared during publication",
) from error
def open_confined_directory(root: Path, path: Path, *, create: bool) -> int:
"""Open a descendant directory through stable no-follow directory descriptors."""
try:
unsafe = (
root.is_symlink()
or root.resolve(strict=True) != root
or not path.is_relative_to(root)
or path == root
)
except OSError as error:
raise DocForgeError("path_escape", "Project root cannot be resolved safely") from error
if unsafe:
raise DocForgeError("path_escape", "Derived output directory is not confined")
relative = path.relative_to(root)
try:
descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
except OSError as error:
raise DocForgeError("path_escape", "Project root cannot be opened safely") from error
try:
for part in relative.parts:
if part in {"", ".", ".."}:
raise DocForgeError("path_escape", "Derived output directory is not confined")
if create:
try:
os.mkdir(part, mode=0o700, dir_fd=descriptor)
except FileExistsError:
pass
except OSError as error:
raise DocForgeError(
"publication_failure",
"Derived output directory could not be created",
) from error
try:
next_descriptor = os.open(
part,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=descriptor,
)
except OSError as error:
raise DocForgeError(
"path_escape",
"Derived output directory is missing or unsafe",
) from error
os.close(descriptor)
descriptor = next_descriptor
require_bound_directory(path, descriptor)
return descriptor
except Exception:
os.close(descriptor)
raise
def safe_file_identity_at(
directory: Path,
directory_fd: int,
name: str,
) -> dict[str, object] | None:
"""Return one no-follow regular-file identity relative to a bound directory."""
del directory
if not name or "/" in name or name in {".", ".."}:
raise DocForgeError("path_escape", "Derived artifact name is unsafe")
try:
current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
except FileNotFoundError:
return None
except OSError as error:
raise DocForgeError("path_escape", "Derived artifact cannot be inspected") from error
if not stat.S_ISREG(current.st_mode):
raise DocForgeError("path_escape", "Derived artifact is not a safe regular file")
return {
"path": name,
"device": current.st_dev,
"inode": current.st_ino,
"mode": current.st_mode,
"size": current.st_size,
"mtime_ns": current.st_mtime_ns,
"ctime_ns": current.st_ctime_ns,
}
def read_bounded_file_at(
directory_fd: int,
name: str,
maximum_bytes: int,
) -> bytes | None:
"""Read one regular file through a bound directory without following links."""
try:
descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory_fd)
except FileNotFoundError:
return None
except OSError as error:
raise DocForgeError("path_escape", "Derived artifact cannot be opened safely") from error
with os.fdopen(descriptor, "rb") as handle:
current = os.fstat(handle.fileno())
if not stat.S_ISREG(current.st_mode) or current.st_size > maximum_bytes:
raise DocForgeError("invalid_projection", "Derived artifact is invalid or oversized")
content = handle.read(maximum_bytes + 1)
if len(content) > maximum_bytes:
raise DocForgeError("invalid_projection", "Derived artifact is oversized")
return content
def atomic_replace_bytes_at(
path: Path,
directory_fd: int,
name: str,
content: bytes,
*,
verify: Callable[[], None],
) -> dict[str, object]:
"""Durably replace one file inside an already bound directory."""
if not name or "/" in name or name in {".", ".."}:
raise DocForgeError("path_escape", "Derived artifact name is unsafe")
existing = safe_file_identity_at(path, directory_fd, name)
del existing
temporary = f".docforge-projection-{secrets.token_hex(12)}"
descriptor: int | None = None
committed = False
try:
descriptor = os.open(
temporary,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600,
dir_fd=directory_fd,
)
with os.fdopen(descriptor, "wb") as handle:
descriptor = None
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
verify()
require_bound_directory(path, directory_fd)
os.replace(
temporary,
name,
src_dir_fd=directory_fd,
dst_dir_fd=directory_fd,
)
committed = True
os.fsync(directory_fd)
identity = safe_file_identity_at(path, directory_fd, name)
if identity is None:
raise DocForgeError(
"publication_failure",
"Derived artifact disappeared after publication",
mutation_committed=True,
)
return identity
except DocForgeError as error:
if committed:
raise DocForgeError(
"publication_failure",
"Derived artifact was replaced but final publication verification failed",
mutation_committed=True,
cause=error.code,
) from error
raise
except OSError as error:
raise DocForgeError(
"publication_failure",
"Derived artifact publication failed",
mutation_committed=committed,
) from error
finally:
if descriptor is not None:
os.close(descriptor)
with suppress(OSError):
os.unlink(temporary, dir_fd=directory_fd)

View file

@ -1,8 +0,0 @@
"""Private module entry point for the detached projection worker."""
from __future__ import annotations
from .projection_worker import main
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -15,7 +15,6 @@ from .changesets import ChangesetStore
from .errors import DocForgeError
from .index import ProjectIndex
from .models import Node, ProjectService, ProjectSnapshot
from .projection_policy import ManualProjectionMode, validate_manual_projection_mode
from .rendering import RenderService
@ -354,19 +353,13 @@ class CanonicalApplicationService:
applier_id: str | None,
applier: CanonicalApplier | None,
index: ProjectIndex | None = None,
manual_policy: ManualProjectionMode = "auto",
) -> None:
self.project = project
self.applier_id = applier_id
self.applier = applier
self.changesets = ChangesetStore(project, applier_id)
self.index = index or ProjectIndex(project)
self.manual_policy = validate_manual_projection_mode(manual_policy)
self.rendering = RenderService(
project,
self.changesets,
manual_policy=self.manual_policy,
)
self.rendering = RenderService(project, self.changesets)
@property
def enabled(self) -> bool:
@ -409,9 +402,7 @@ class CanonicalApplicationService:
)
renders: list[dict[str, object]] = []
config = self.project.descriptor.render
render_action = "not_configured"
if config is not None and self.manual_policy == "auto":
render_action = "rendered"
if config is not None:
for view in config.views:
try:
rendered = self.rendering.render(view.view_id)
@ -444,10 +435,6 @@ class CanonicalApplicationService:
"error": error.as_dict(),
}
)
elif config is not None:
render_action = (
"skipped_explicit" if self.manual_policy == "explicit" else "skipped_disabled"
)
return {
**applied,
"derived_refresh": {
@ -455,10 +442,6 @@ class CanonicalApplicationService:
"index": index_result,
"check": index_check,
"renders": renders,
"render_policy": {
"mode": self.manual_policy,
"action": render_action,
},
"errors": refresh_errors,
},
}

View file

@ -95,7 +95,7 @@
aria-label="Visible relationship color and symbol key"></ul>
</details>
<svg id="graph" viewBox="-600 -410 1200 820"
role="group" aria-label="Interactive node neighborhood"></svg>
role="img" aria-label="Node neighborhood"></svg>
<div class="empty" id="empty">Search for a node to inspect its neighborhood.</div>
<div class="connection-state" id="connection-state" role="alert" hidden>
<strong>Visualization disconnected</strong>

View file

@ -13,11 +13,9 @@ from .client_config import CLIENT_NAMES, generate_client_configuration
from .context import compile_context
from .doctor import run_doctor
from .errors import DocForgeError
from .graph_rendering import GraphRenderService
from .index import ProjectIndex
from .onboarding import assess_project, scaffold_project
from .project import Project, project_root_fingerprint
from .projection_policy import compose_projection_policy
from .rendering import RenderService
from .telemetry import request
from .viewer_manager import ViewerManagerClient
@ -31,18 +29,6 @@ def _parser() -> argparse.ArgumentParser:
action="store_true",
help="Attach bounded request-local stage timings and counters",
)
parser.add_argument(
"--manual-render-policy",
choices=("auto", "explicit", "disabled"),
)
parser.add_argument(
"--portable-graph-policy",
choices=("explicit", "disabled"),
)
parser.add_argument(
"--live-viewer-policy",
choices=("on-demand", "disabled"),
)
commands = parser.add_subparsers(dest="command", required=True)
configure = commands.add_parser("configure")
configure.add_argument("client", choices=CLIENT_NAMES)
@ -56,21 +42,6 @@ def _parser() -> argparse.ArgumentParser:
configure.add_argument("--proposal-writer")
configure.add_argument("--canonical-applier")
configure.add_argument("--no-ast", action="store_true")
configure.add_argument(
"--manual-render-policy",
choices=("auto", "explicit", "disabled"),
default=argparse.SUPPRESS,
)
configure.add_argument(
"--portable-graph-policy",
choices=("explicit", "disabled"),
default=argparse.SUPPRESS,
)
configure.add_argument(
"--live-viewer-policy",
choices=("on-demand", "disabled"),
default=argparse.SUPPRESS,
)
configure.add_argument("--startup-timeout", type=int, default=30)
configure.add_argument("--tool-timeout", type=int, default=300)
configure.add_argument("--output", type=Path)
@ -124,12 +95,6 @@ def _parser() -> argparse.ArgumentParser:
render_status = commands.add_parser("render-status")
render_status.add_argument("view_id", nargs="?")
render_status.add_argument("--deep", action="store_true")
graph_plan = commands.add_parser("graph-plan")
graph_plan.add_argument("view_id")
graph_render = commands.add_parser("graph-render")
graph_render.add_argument("view_id")
graph_render_status = commands.add_parser("graph-render-status")
graph_render_status.add_argument("view_id", nargs="?")
preview = commands.add_parser("preview")
preview.add_argument("changeset_id")
preview.add_argument("view_id")
@ -159,9 +124,6 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
proposal_writer=arguments.proposal_writer,
canonical_applier=arguments.canonical_applier,
no_ast=arguments.no_ast,
manual_render_policy=arguments.manual_render_policy,
portable_graph_policy=arguments.portable_graph_policy,
live_viewer_policy=arguments.live_viewer_policy,
startup_timeout=arguments.startup_timeout,
tool_timeout=arguments.tool_timeout,
output=arguments.output,
@ -190,39 +152,12 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
content_root=arguments.content_root,
)
project = Project.open(arguments.project_root)
projection_policy = compose_projection_policy(
manual=arguments.manual_render_policy,
portable_graph=arguments.portable_graph_policy,
live_viewer=arguments.live_viewer_policy,
manual_configured=project.descriptor.render is not None,
portable_graph_configured=project.descriptor.graph_render is not None,
application_enabled=False,
)
build = ProjectIndex(project).build()
render = (
{
"status": "ok",
"state": "skipped",
"reason": "projection_policy_disabled",
}
if projection_policy.manual == "disabled"
else RenderService(
project,
manual_policy=projection_policy.manual,
).render("manual")
)
render = RenderService(project).render("manual")
return {**scaffold, "build": build, "render": render}
return assess_project(arguments.project_root, requested_languages=languages)
project = Project.open(arguments.project_root)
index = ProjectIndex(project)
projection_policy = compose_projection_policy(
manual=arguments.manual_render_policy,
portable_graph=arguments.portable_graph_policy,
live_viewer=arguments.live_viewer_policy,
manual_configured=project.descriptor.render is not None,
portable_graph_configured=project.descriptor.graph_render is not None,
application_enabled=arguments.command == "apply",
)
if arguments.command == "info":
snapshot = project.load()
return {
@ -315,52 +250,24 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
cursor=arguments.cursor,
)
if arguments.command == "render":
return RenderService(
project,
manual_policy=projection_policy.manual,
).render(arguments.view_id)
return RenderService(project).render(arguments.view_id)
if arguments.command == "render-status":
rendering = RenderService(
project,
manual_policy=projection_policy.manual,
)
rendering = RenderService(project)
return (
rendering.deep_status(arguments.view_id)
if arguments.deep
else rendering.status(arguments.view_id)
)
if arguments.command == "graph-plan":
return GraphRenderService(
project,
portable_graph_policy=projection_policy.portable_graph,
).plan(arguments.view_id)
if arguments.command == "graph-render":
return GraphRenderService(
project,
portable_graph_policy=projection_policy.portable_graph,
).render(arguments.view_id)
if arguments.command == "graph-render-status":
return GraphRenderService(
project,
portable_graph_policy=projection_policy.portable_graph,
).status(arguments.view_id)
if arguments.command == "preview":
return RenderService(
project,
manual_policy=projection_policy.manual,
).preview(arguments.changeset_id, arguments.view_id)
return RenderService(project).preview(arguments.changeset_id, arguments.view_id)
if arguments.command == "apply":
return CanonicalApplicationService(
project,
applier_id=arguments.applier,
applier=GenericCanonicalApplier(project),
manual_policy=projection_policy.manual,
).apply(arguments.changeset_id, arguments.changeset_hash)
if arguments.command == "visualize":
visualization = ViewerManagerClient(
index,
live_viewer_policy=projection_policy.live_viewer,
).start(
visualization = ViewerManagerClient(index).start(
node_id=arguments.node,
query=arguments.query,
depth=arguments.depth,
@ -377,15 +284,9 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
"visualization": visualization,
}
if arguments.command == "visualization-status":
return ViewerManagerClient(
index,
live_viewer_policy=projection_policy.live_viewer,
).status()
return ViewerManagerClient(index).status()
if arguments.command == "visualization-stop":
return ViewerManagerClient(
index,
live_viewer_policy=projection_policy.live_viewer,
).stop()
return ViewerManagerClient(index).stop()
raise DocForgeError("invalid_command", "Unknown command")

View file

@ -18,10 +18,9 @@ from typing import Literal, cast
from .changeset_contract import document_hash
from .errors import DocForgeError
from .models import ProjectDescriptor, ProjectService
from .models import ProjectService
from .policy import CapabilityMode, compose_effective_policy
from .project import Project, project_root_fingerprint, validate_descriptor_binding
from .projection_policy import compose_projection_policy
from .project import project_root_fingerprint, validate_descriptor_binding
ClientName = Literal["codex", "claude", "openclaw"]
CLIENT_NAMES: tuple[ClientName, ...] = ("codex", "claude", "openclaw")
@ -606,37 +605,11 @@ def _atomic_write(
os.close(directory_fd)
def _validate_configuration_result(
result: dict[str, object],
*,
trusted_descriptor: ProjectDescriptor | None = None,
) -> None:
def _validate_configuration_result(result: dict[str, object]) -> None:
artifact = cast(dict[str, object], result["artifact"])
binding = cast(dict[str, object], result["binding"])
policy = cast(dict[str, object], result["effective_policy"])
projection_policy = cast(dict[str, object], result["projection_policy"])
projection_availability = cast(
dict[str, object],
result["projection_availability"],
)
project = cast(dict[str, object], result["project"])
if trusted_descriptor is None:
try:
bound_descriptor = Project.open(cast(str, project["project_root"])).descriptor
except (DocForgeError, KeyError, TypeError) as error:
raise AssertionError(
"Generated client project binding cannot be independently validated"
) from error
else:
bound_descriptor = trusted_descriptor
if (
project["project_id"] != bound_descriptor.project_id
or project["project_root"] != str(bound_descriptor.root)
or project["project_root_fingerprint"] != project_root_fingerprint(bound_descriptor.root)
or project["adapter"] != bound_descriptor.adapter
or project["descriptor_hash"] != bound_descriptor.descriptor_hash
):
raise AssertionError("Generated client project binding drifted")
content = cast(str, artifact["content"])
if artifact["content_sha256"] != hashlib.sha256(content.encode("utf-8")).hexdigest():
raise AssertionError("Generated client content hash drifted")
@ -671,29 +644,6 @@ def _validate_configuration_result(
if remaining[-1:] != ["--no-ast"] or arguments.count("--no-ast") != 1:
raise AssertionError("Generated no-AST argument layout drifted")
remaining = remaining[:-1]
projection_arguments: dict[str, str] = {}
authority_arguments: list[str] = []
position = 0
projection_options = {
"--manual-render-policy": "manual",
"--portable-graph-policy": "portable_graph",
"--live-viewer-policy": "live_viewer",
}
while position < len(remaining):
option = remaining[position]
field = projection_options.get(option)
if field is None:
authority_arguments.append(option)
position += 1
continue
if position + 1 >= len(remaining) or option in projection_arguments:
raise AssertionError("Generated projection policy argument layout drifted")
value = remaining[position + 1]
projection_arguments[option] = value
if projection_policy[field] != value:
raise AssertionError("Generated projection policy argument drifted")
position += 2
remaining = authority_arguments
mode = binding["capability_mode"]
if (
(mode == "read" and remaining)
@ -713,34 +663,6 @@ def _validate_configuration_result(
)
):
raise AssertionError("Generated authority argument layout drifted")
expected_projection_policy = compose_projection_policy(
manual=projection_arguments.get("--manual-render-policy"),
portable_graph=projection_arguments.get("--portable-graph-policy"),
live_viewer=projection_arguments.get("--live-viewer-policy"),
manual_configured=cast(bool, projection_availability["manual_configured"]),
portable_graph_configured=cast(
bool,
projection_availability["portable_graph_configured"],
),
application_enabled=cast(
bool,
projection_availability["application_enabled"],
),
live_viewer_available=cast(
bool,
projection_availability["live_viewer_available"],
),
)
if (
projection_policy != expected_projection_policy.as_dict()
or projection_availability["manual_configured"] != (render_policy["manual"] != "disabled")
or projection_availability["manual_configured"] != (bound_descriptor.render is not None)
or projection_availability["portable_graph_configured"]
!= (bound_descriptor.graph_render is not None)
or projection_availability["application_enabled"] != (mode == "application")
or projection_availability["live_viewer_available"] is not True
):
raise AssertionError("Generated projection policy drifted from its availability")
composed_policy = compose_effective_policy(
selected_mode=cast(CapabilityMode, mode),
capability_source="explicit",
@ -776,18 +698,6 @@ def _validate_configuration_result(
)
):
raise AssertionError("Generated client policy drifted from its binding")
if (
result["projection_policy_hash"]
!= hashlib.sha256(
json.dumps(
projection_policy,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
).hexdigest()
):
raise AssertionError("Generated projection policy hash drifted")
expected_hash = document_hash(
{
"schema_version": 1,
@ -796,9 +706,6 @@ def _validate_configuration_result(
"project": project,
"binding": binding,
"effective_policy": policy,
"projection_policy": projection_policy,
"projection_policy_hash": result["projection_policy_hash"],
"projection_availability": projection_availability,
"artifact_format": artifact["format"],
"artifact_content_sha256": artifact["content_sha256"],
}
@ -816,9 +723,6 @@ def generate_client_configuration(
proposal_writer: str | None = None,
canonical_applier: str | None = None,
no_ast: bool = False,
manual_render_policy: str | None = None,
portable_graph_policy: str | None = None,
live_viewer_policy: str | None = None,
startup_timeout: int = 30,
tool_timeout: int = 300,
output: Path | None = None,
@ -935,6 +839,9 @@ def generate_client_configuration(
arguments.extend(("--proposal-writer", proposal_writer))
if canonical_applier is not None:
arguments.extend(("--canonical-applier", canonical_applier))
if no_ast:
arguments.append("--no-ast")
policy = compose_effective_policy(
selected_mode=selected_mode,
capability_source="explicit",
@ -943,40 +850,6 @@ def generate_client_configuration(
render_configured=descriptor.render is not None,
application_enabled=canonical_applier is not None,
)
projection_policy = compose_projection_policy(
manual=manual_render_policy,
portable_graph=portable_graph_policy,
live_viewer=live_viewer_policy,
manual_configured=descriptor.render is not None,
portable_graph_configured=descriptor.graph_render is not None,
application_enabled=canonical_applier is not None,
)
default_projection_policy = compose_projection_policy(
manual_configured=descriptor.render is not None,
portable_graph_configured=descriptor.graph_render is not None,
application_enabled=canonical_applier is not None,
)
for option, selected, default in (
(
"--manual-render-policy",
projection_policy.manual,
default_projection_policy.manual,
),
(
"--portable-graph-policy",
projection_policy.portable_graph,
default_projection_policy.portable_graph,
),
(
"--live-viewer-policy",
projection_policy.live_viewer,
default_projection_policy.live_viewer,
),
):
if selected != default:
arguments.extend((option, selected))
if no_ast:
arguments.append("--no-ast")
artifact_format, content, warning = _artifact(
selected_client,
server_name=selected_name,
@ -1029,7 +902,6 @@ def generate_client_configuration(
"project_root": str(descriptor.root),
"project_root_fingerprint": fingerprint,
"adapter": descriptor.adapter,
"descriptor_hash": descriptor.descriptor_hash,
}
policy_payload = policy.as_dict()
plan_hash = document_hash(
@ -1040,14 +912,6 @@ def generate_client_configuration(
"project": project_binding,
"binding": binding,
"effective_policy": policy_payload,
"projection_policy": projection_policy.as_dict(),
"projection_policy_hash": projection_policy.policy_hash,
"projection_availability": {
"manual_configured": descriptor.render is not None,
"portable_graph_configured": descriptor.graph_render is not None,
"application_enabled": canonical_applier is not None,
"live_viewer_available": True,
},
"artifact_format": artifact_format,
"artifact_content_sha256": artifact["content_sha256"],
}
@ -1062,14 +926,6 @@ def generate_client_configuration(
"project": project_binding,
"binding": binding,
"effective_policy": policy_payload,
"projection_policy": projection_policy.as_dict(),
"projection_policy_hash": projection_policy.policy_hash,
"projection_availability": {
"manual_configured": descriptor.render is not None,
"portable_graph_configured": descriptor.graph_render is not None,
"application_enabled": canonical_applier is not None,
"live_viewer_available": True,
},
"artifact": artifact,
"configuration_hash": plan_hash,
"warnings": [
@ -1077,5 +933,5 @@ def generate_client_configuration(
*([] if publication_warning is None else [{"code": publication_warning}]),
],
}
_validate_configuration_result(result, trusted_descriptor=descriptor)
_validate_configuration_result(result)
return result

View file

@ -20,7 +20,6 @@ from .project import (
project_root_fingerprint,
validate_descriptor_binding,
)
from .projection_policy import compose_projection_policy
MAX_CLIENT_CONFIG_BYTES = 1_000_000
MAX_CLIENT_SERVERS = 256
@ -615,9 +614,6 @@ def _parse_binding(arguments: list[str]) -> dict[str, object]:
"--proposal-writer",
"--canonical-applier",
"--capability-mode",
"--manual-render-policy",
"--portable-graph-policy",
"--live-viewer-policy",
}
flag_options = {"--no-ast", "--diagnostics"}
position = 0
@ -666,9 +662,6 @@ def _parse_binding(arguments: list[str]) -> dict[str, object]:
"capability_mode_implicit": implicit,
"no_ast": "--no-ast" in flags,
"diagnostics": "--diagnostics" in flags,
"manual_render_policy": values.get("--manual-render-policy"),
"portable_graph_policy": values.get("--portable-graph-policy"),
"live_viewer_policy": values.get("--live-viewer-policy"),
}
@ -756,16 +749,6 @@ def _runtime_policy_check(
canonical_applier is not None and selected_mode in {"application", "operator"}
),
)
compose_projection_policy(
manual=cast(str | None, binding["manual_render_policy"]),
portable_graph=cast(str | None, binding["portable_graph_policy"]),
live_viewer=cast(str | None, binding["live_viewer_policy"]),
manual_configured=project.descriptor.render is not None,
portable_graph_configured=project.descriptor.graph_render is not None,
application_enabled=(
canonical_applier is not None and selected_mode in {"application", "operator"}
),
)
if cast(bool, binding["capability_mode_implicit"]):
return (
"warning",

View file

@ -1,525 +0,0 @@
"""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 (
MAX_GRAPH_VIEW_DEPTH,
MAX_GRAPH_VIEW_EDGES,
MAX_GRAPH_VIEW_FILTERS,
MAX_GRAPH_VIEW_NODES,
MAX_GRAPH_VIEW_QUERY_CHARS,
MAX_GRAPH_VIEW_STRING_CHARS,
MAX_GRAPH_VIEW_WORK,
GraphViewPlanV1,
ProjectionPackageV1,
)
GraphViewMode = Literal["nodes", "flow", "web", "logic"]
_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",
},
}
)
def build_graph_projection_package(
plan: GraphViewPlanV1,
*,
renderer_id: str,
renderer_version: str,
max_output_bytes: int,
) -> ProjectionPackageV1:
"""Bind a graph plan to the fixed portable renderer without adding runtime authority."""
return ProjectionPackageV1.create(
kind="graph",
plan=plan,
renderer={"renderer_id": renderer_id, "renderer_version": renderer_version},
components=[
{"component_id": "graph.portable-document@1"},
{"component_id": "graph.accessible-list@1"},
{"component_id": "graph.relationship-table@1"},
],
assets=[],
output_policy={
"artifact_ids": ["portable-graph.html"],
"max_total_bytes": max_output_bytes,
},
)

View file

@ -1,285 +0,0 @@
"""Strict parsing and confinement for optional portable graph artifacts."""
from __future__ import annotations
from pathlib import Path
from typing import Literal, cast
from .config_validation import (
ID_PATTERN,
confined_path,
positive_int,
require_string,
string_list,
)
from .errors import DocForgeError
from .models import GraphRenderConfig, GraphRenderView, Limits, RenderConfig
from .projection_contract import (
MAX_GRAPH_VIEW_DEPTH,
MAX_GRAPH_VIEW_EDGES,
MAX_GRAPH_VIEW_FILTERS,
MAX_GRAPH_VIEW_NODES,
MAX_GRAPH_VIEW_QUERY_CHARS,
MAX_GRAPH_VIEW_STRING_CHARS,
MAX_GRAPH_VIEW_WORK,
)
_CONFIG_KEYS = frozenset({"output_root", "views"})
_VIEW_KEYS = frozenset(
{
"id",
"renderer",
"output",
"title",
"root",
"query",
"initial_mode",
"depth",
"max_nodes",
"max_edges",
"max_work",
"families",
"relations",
"authorities",
"statuses",
"tags",
"include_logic",
}
)
_MODES = frozenset({"nodes", "flow", "web"})
def _overlaps(first: Path, second: Path) -> bool:
return first == second or first.is_relative_to(second) or second.is_relative_to(first)
def _optional_string(document: dict[str, object], key: str, source: Path) -> str | None:
if key not in document:
return None
return require_string(document, key, source)
def _bounded_string(
document: dict[str, object],
key: str,
source: Path,
*,
maximum: int,
) -> str:
value = require_string(document, key, source)
if len(value) > maximum:
raise DocForgeError("invalid_config", f"{key} exceeds its fixed character limit")
return value
def _bounded_strings(
value: object,
*,
key: str,
source: Path,
) -> tuple[str, ...]:
values = string_list(value, key=key, source=source)
if len(values) > MAX_GRAPH_VIEW_FILTERS or any(
len(item) > MAX_GRAPH_VIEW_STRING_CHARS for item in values
):
raise DocForgeError("invalid_config", f"{key} exceeds its fixed bounds")
return values
def load_graph_render_config(
root: Path,
document: object,
*,
descriptor_path: Path,
content_roots: tuple[Path, ...],
authority_files: tuple[Path, ...],
cache_root: Path,
index_path: Path,
changeset_root: Path,
manual_render: RenderConfig | None,
limits: Limits,
) -> GraphRenderConfig | None:
if document is None:
return None
if not isinstance(document, dict):
raise DocForgeError("invalid_config", "graph_render must be a table")
document = cast(dict[str, object], document)
unknown = sorted(set(document) - _CONFIG_KEYS)
if unknown:
raise DocForgeError("invalid_config", "graph_render has unknown fields", fields=unknown)
output_root = confined_path(
root,
document.get("output_root"),
field="graph_render.output_root",
must_exist=False,
)
protected = [*content_roots, cache_root, changeset_root]
if manual_render is not None:
protected.extend((manual_render.template_root, manual_render.preview_root))
protected.extend(view.output_path for view in manual_render.views)
if any(_overlaps(output_root, path) for path in protected):
raise DocForgeError(
"invalid_config",
"Portable graph output must not overlap canonical or other derived roots",
)
protected_files = (descriptor_path, index_path, *authority_files)
if any(path == output_root or path.is_relative_to(output_root) for path in protected_files):
raise DocForgeError(
"invalid_config",
"Portable graph output overlaps a protected project path",
)
view_values_value = document.get("views")
if not isinstance(view_values_value, list) or not view_values_value:
raise DocForgeError(
"invalid_config",
"graph_render.views must contain at least one view",
)
view_values = cast(list[object], view_values_value)
if len(view_values) > limits.max_render_views:
raise DocForgeError("invalid_config", "graph_render.views exceeds the configured limit")
views: list[GraphRenderView] = []
view_ids: set[str] = set()
outputs: set[Path] = set()
for value in view_values:
if not isinstance(value, dict):
raise DocForgeError("invalid_config", "Each portable graph view must be a table")
view = cast(dict[str, object], value)
unknown_view = sorted(set(view) - _VIEW_KEYS)
if unknown_view:
raise DocForgeError(
"invalid_config",
"Portable graph view has unknown fields",
fields=unknown_view,
)
view_id = require_string(view, "id", descriptor_path)
if ID_PATTERN.fullmatch(view_id) is None or view_id in view_ids:
raise DocForgeError(
"invalid_config",
"Portable graph view ID is invalid or duplicated",
id=view_id,
)
view_ids.add(view_id)
renderer = require_string(view, "renderer", descriptor_path)
if renderer != "portable_graph_html":
raise DocForgeError(
"unsupported_renderer",
"Portable graph view names an unsupported built-in renderer",
renderer=renderer,
)
output = confined_path(
output_root,
view.get("output"),
field="graph_render.view.output",
must_exist=False,
)
if output.suffix != ".html" or output in outputs:
raise DocForgeError(
"invalid_config",
"Portable graph outputs must be unique HTML files",
)
outputs.add(output)
root_node_id = _optional_string(view, "root", descriptor_path)
query = _optional_string(view, "query", descriptor_path)
if (root_node_id is None) == (query is None):
raise DocForgeError(
"invalid_config",
"Portable graph view requires exactly one root or query",
)
if root_node_id is not None and len(root_node_id) > MAX_GRAPH_VIEW_STRING_CHARS:
raise DocForgeError("invalid_config", "Portable graph root exceeds its fixed limit")
if query is not None and len(query) > MAX_GRAPH_VIEW_QUERY_CHARS:
raise DocForgeError("invalid_config", "Portable graph query exceeds its fixed limit")
initial_mode_value = view.get("initial_mode", "nodes")
if not isinstance(initial_mode_value, str):
raise DocForgeError(
"invalid_config",
"Portable graph initial mode is unsupported",
)
initial_mode = cast(
Literal["nodes", "flow", "web", "logic"],
initial_mode_value,
)
if initial_mode not in _MODES:
raise DocForgeError(
"invalid_config",
"Portable graph initial mode is unsupported",
)
depth = positive_int(view.get("depth", 1), "graph_render.view.depth")
max_nodes = positive_int(view.get("max_nodes", 100), "graph_render.view.max_nodes")
max_edges = positive_int(
view.get("max_edges", 400),
"graph_render.view.max_edges",
allow_zero=True,
)
max_work = positive_int(view.get("max_work", 100_000), "graph_render.view.max_work")
if (
depth > min(limits.max_traversal_depth, MAX_GRAPH_VIEW_DEPTH)
or max_nodes > min(limits.max_nodes, MAX_GRAPH_VIEW_NODES)
or max_edges > MAX_GRAPH_VIEW_EDGES
or max_work > MAX_GRAPH_VIEW_WORK
):
raise DocForgeError(
"invalid_config",
"Portable graph view exceeds project or fixed safety limits",
)
include_logic = view.get("include_logic", False)
if type(include_logic) is not bool:
raise DocForgeError(
"invalid_config",
"Portable graph include_logic must be Boolean",
)
if include_logic:
raise DocForgeError(
"unsupported_renderer",
"Portable graph renderer version 1 does not support Logic projections",
)
views.append(
GraphRenderView(
view_id=view_id,
renderer=renderer,
output_path=output,
title=_bounded_string(
view,
"title",
descriptor_path,
maximum=MAX_GRAPH_VIEW_STRING_CHARS,
),
root_node_id=root_node_id,
query=query,
initial_mode=initial_mode,
depth=depth,
max_nodes=max_nodes,
max_edges=max_edges,
max_work=max_work,
families=_bounded_strings(
view.get("families", []),
key="graph_render.view.families",
source=descriptor_path,
),
relations=_bounded_strings(
view.get("relations", []),
key="graph_render.view.relations",
source=descriptor_path,
),
authorities=_bounded_strings(
view.get("authorities", []),
key="graph_render.view.authorities",
source=descriptor_path,
),
statuses=_bounded_strings(
view.get("statuses", []),
key="graph_render.view.statuses",
source=descriptor_path,
),
tags=_bounded_strings(
view.get("tags", []),
key="graph_render.view.tags",
source=descriptor_path,
),
include_logic=include_logic,
)
)
return GraphRenderConfig(
output_root=output_root,
views=tuple(sorted(views, key=lambda item: item.view_id)),
)

View file

@ -1,815 +0,0 @@
"""Declared portable graph planning, publication, and receipt-only status."""
from __future__ import annotations
import fcntl
import json
import os
from collections.abc import Callable, Generator
from contextlib import contextmanager
from pathlib import Path
from typing import cast
from ._fs_safety import (
atomic_replace_bytes_at,
open_confined_directory,
read_bounded_file_at,
require_bound_directory,
safe_file_identity_at,
)
from .errors import DocForgeError
from .graph_projection import (
GraphViewRequestV1,
build_graph_projection_package,
build_graph_view_plan,
)
from .models import (
GenerationRecordingProject,
GraphRenderConfig,
GraphRenderView,
IncrementalStateProject,
ProjectService,
ProjectSnapshot,
ProjectState,
)
from .project import project_root_fingerprint
from .projection_contract import GraphViewPlanV1, ProjectionReceiptV1, projection_hash
from .projection_policy import (
PortableGraphProjectionMode,
validate_portable_graph_projection_mode,
)
from .projection_worker import render_projection_in_worker
GRAPH_RENDERER_ID = "portable_graph_html"
GRAPH_RENDERER_VERSION = "1"
GRAPH_PUBLICATION_MANIFEST_VERSION = 1
GRAPH_PUBLICATION_CONTRACT = "docforge.graph-publication"
MAX_GRAPH_PUBLICATION_BYTES = 256_000
class GraphRenderService:
"""Publish one declared artifact while keeping planning and rendering independent."""
def __init__(
self,
project: ProjectService,
*,
allow_logic: bool = False,
portable_graph_policy: PortableGraphProjectionMode = "explicit",
) -> None:
self.project = project
self.allow_logic = allow_logic
self.portable_graph_policy = validate_portable_graph_projection_mode(portable_graph_policy)
def _require_rendering(self, operation: str) -> None:
if self.portable_graph_policy == "disabled":
raise DocForgeError(
"projection_policy_forbids_operation",
"Portable graph projection policy disables rendering work",
projection="portable_graph",
mode=self.portable_graph_policy,
operation=operation,
)
def plan(self, view_id: str) -> dict[str, object]:
self._require_rendering("plan")
snapshot = self.project.load()
view = self._view(self._config(snapshot), view_id)
plan = self._plan(snapshot, view)
return {
"status": "ok",
**self._identity(snapshot),
"view_id": view.view_id,
"plan": plan.as_dict(),
}
def status(self, view_id: str | None = None) -> dict[str, object]:
config = self.project.descriptor.graph_render
current = self._current_state()
if config is None:
return self._status_result(
current,
configured=False,
state="not_configured",
outputs=[],
)
views = config.views if view_id is None else (self._view(config, view_id),)
first_outputs = [self._manifest_status(view, current) for view in views]
outputs = [self._manifest_status(view, current) for view in views]
if outputs != first_outputs:
for output in outputs:
if output["state"] == "current":
output["state"] = "stale"
output["reason"] = "publication_changed_during_status"
final = self._current_state()
if final != current:
for output in outputs:
if output["state"] == "current":
output["state"] = "stale"
output["reason"] = "source_changed_during_status"
identity = final if final is not None else current
return self._status_result(
identity,
configured=True,
state="current" if all(item["state"] == "current" for item in outputs) else "stale",
outputs=outputs,
)
def render(self, view_id: str) -> dict[str, object]:
self._require_rendering("render")
with self._lock():
current_status = self.status(view_id)
current_outputs = cast(list[dict[str, object]], current_status["outputs"])
if current_status["state"] == "current" and current_outputs:
return {
**current_status,
"publication": "unchanged",
"output": current_outputs[0],
}
snapshot = self.project.load()
view = self._view(self._config(snapshot), view_id)
plan = self._plan(snapshot, view)
package = build_graph_projection_package(
plan,
renderer_id=GRAPH_RENDERER_ID,
renderer_version=GRAPH_RENDERER_VERSION,
max_output_bytes=snapshot.descriptor.limits.max_render_bytes,
)
result = render_projection_in_worker(package)
if len(result.artifacts) != 1:
raise DocForgeError(
"invalid_projection",
"Portable graph renderer returned an unsupported artifact set",
)
artifact = result.artifacts[0]
def verify() -> None:
current = self.project.load()
if (
current.revision != snapshot.revision
or current.source_hash != snapshot.source_hash
):
raise DocForgeError(
"render_input_changed",
"Canonical input changed during portable graph rendering",
)
verify()
if isinstance(self.project, GenerationRecordingProject):
self.project.record_generation(snapshot)
artifact_evidence = artifact.evidence()
try:
store_identity = self._publish_artifact(
snapshot,
artifact_evidence["sha256"],
artifact.content,
verify=verify,
)
except DocForgeError as error:
if self._mutation_committed(error):
return self._degraded_publication(
snapshot,
view,
plan,
package.package_id,
result.receipt.as_dict(),
artifact_evidence,
stage="artifact_store",
error=error,
output_published=False,
)
raise
try:
output_identity = self._publish_output(
snapshot,
view,
artifact.content,
verify=verify,
)
except DocForgeError as error:
if self._mutation_committed(error):
return self._degraded_publication(
snapshot,
view,
plan,
package.package_id,
result.receipt.as_dict(),
artifact_evidence,
stage="output",
error=error,
output_published=True,
)
raise
manifest = self._manifest(
snapshot,
view,
plan,
package.package_id,
result.receipt.as_dict(),
artifact_evidence,
store_identity,
output_identity,
)
try:
self._publish_manifest(snapshot, view, manifest, verify=verify)
except DocForgeError as error:
return self._degraded_publication(
snapshot,
view,
plan,
package.package_id,
result.receipt.as_dict(),
artifact_evidence,
stage="manifest",
error=error,
output_published=True,
)
return {
"status": "ok",
**self._identity(snapshot),
"view_id": view.view_id,
"state": "current",
"publication": "published",
"plan_id": plan.plan_id,
"package_id": package.package_id,
"output": {
**artifact.evidence(),
"path": view.output_path.relative_to(snapshot.descriptor.root).as_posix(),
},
"receipt": result.receipt.as_dict(),
"manifest": {
"state": "current",
"publication_id": manifest["publication_id"],
},
}
@staticmethod
def _mutation_committed(error: DocForgeError) -> bool:
return error.details.get("mutation_committed") is True
def _degraded_publication(
self,
snapshot: ProjectSnapshot,
view: GraphRenderView,
plan: GraphViewPlanV1,
package_id: str,
receipt: dict[str, object],
artifact: dict[str, object],
*,
stage: str,
error: DocForgeError,
output_published: bool,
) -> dict[str, object]:
return {
"status": "ok",
**self._identity(snapshot),
"view_id": view.view_id,
"state": "degraded",
"publication": "published" if output_published else "partial",
"committed_stage": stage,
"plan_id": plan.plan_id,
"package_id": package_id,
"artifact": artifact,
"output": {
**artifact,
"path": view.output_path.relative_to(snapshot.descriptor.root).as_posix(),
"state": "unverified" if output_published else "not_published",
},
"receipt": receipt,
"manifest": {
"state": "failed",
"error": error.as_dict(),
},
}
def _manifest_status(
self,
view: GraphRenderView,
current: ProjectState | None,
) -> dict[str, object]:
manifest = self._read_manifest(view)
base = {
"view_id": view.view_id,
"renderer": GRAPH_RENDERER_ID,
"renderer_version": GRAPH_RENDERER_VERSION,
"path": view.output_path.relative_to(self.project.descriptor.root).as_posix(),
"verification": "manifest",
}
if manifest is None:
return {**base, "state": "missing", "reason": "manifest_missing"}
if not self._valid_manifest(view, manifest):
return {**base, "state": "unverified", "reason": "manifest_invalid"}
if current is None:
reason = (
"source_generation_changed"
if isinstance(self.project, GenerationRecordingProject)
else "source_generation_unavailable"
)
return {
**base,
"state": (
"stale"
if isinstance(self.project, GenerationRecordingProject)
else "unverified"
),
"reason": reason,
"plan_id": manifest.get("plan_id"),
"package_id": manifest.get("package_id"),
}
project = cast(dict[str, object], manifest["project"])
if project["revision"] != current.revision or project["source_hash"] != current.source_hash:
return {
**base,
"state": "stale",
"reason": "source_generation_changed",
"plan_id": manifest["plan_id"],
"package_id": manifest["package_id"],
}
artifact = cast(dict[str, object], manifest["artifact"])
store = cast(dict[str, object], manifest["store"])
artifact_root = self.project.descriptor.cache_root / "projection-artifacts"
if not artifact_root.exists():
return {
**base,
"state": "stale",
"reason": "artifact_store_missing",
"plan_id": manifest["plan_id"],
"package_id": manifest["package_id"],
}
if artifact_root.is_symlink() or not artifact_root.is_dir():
return {
**base,
"state": "unsafe",
"reason": "artifact_store_unsafe",
"plan_id": manifest["plan_id"],
"package_id": manifest["package_id"],
}
artifact_directory: int | None = None
try:
artifact_directory = open_confined_directory(
self.project.descriptor.root,
artifact_root,
create=False,
)
artifact_identity = safe_file_identity_at(
artifact_root,
artifact_directory,
f"{artifact['sha256']}.html",
)
except DocForgeError:
return {
**base,
"state": "unsafe",
"reason": "artifact_store_unsafe",
"plan_id": manifest["plan_id"],
"package_id": manifest["package_id"],
}
finally:
if artifact_directory is not None:
os.close(artifact_directory)
if artifact_identity is None:
return {
**base,
"state": "stale",
"reason": "artifact_store_missing",
"plan_id": manifest["plan_id"],
"package_id": manifest["package_id"],
}
if artifact_identity != store:
return {
**base,
"state": "stale",
"reason": "artifact_store_changed",
"plan_id": manifest["plan_id"],
"package_id": manifest["package_id"],
}
try:
directory = open_confined_directory(
self.project.descriptor.root,
view.output_path.parent,
create=False,
)
except DocForgeError:
return {**base, "state": "unsafe", "reason": "output_root_unsafe"}
try:
identity = safe_file_identity_at(
view.output_path.parent, directory, view.output_path.name
)
except DocForgeError:
return {**base, "state": "unsafe", "reason": "output_unsafe"}
finally:
os.close(directory)
expected = cast(dict[str, object], manifest["output"])
if identity != expected:
return {
**base,
"state": "stale",
"reason": "output_changed",
"plan_id": manifest["plan_id"],
"package_id": manifest["package_id"],
}
return {
**base,
"state": "current",
"reason": None,
"plan_id": manifest["plan_id"],
"package_id": manifest["package_id"],
"publication_id": manifest["publication_id"],
"artifact": manifest["artifact"],
}
def _read_manifest(self, view: GraphRenderView) -> dict[str, object] | None:
root = self._manifest_root()
if not root.is_dir() or root.is_symlink():
return None
try:
descriptor = open_confined_directory(
self.project.descriptor.root,
root,
create=False,
)
except DocForgeError:
return None
try:
raw = read_bounded_file_at(
descriptor,
f"{view.view_id}.json",
MAX_GRAPH_PUBLICATION_BYTES,
)
except DocForgeError:
return None
finally:
os.close(descriptor)
if raw is None:
return None
try:
value: object = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError):
return None
return cast(dict[str, object], value) if isinstance(value, dict) else None
def _valid_manifest(self, view: GraphRenderView, manifest: dict[str, object]) -> bool:
required = {
"schema_version",
"contract",
"publication_id",
"project",
"view_id",
"view_config_hash",
"plan_id",
"package_id",
"renderer",
"receipt",
"artifact",
"store",
"output",
}
try:
if (
set(manifest) != required
or manifest.get("schema_version") != GRAPH_PUBLICATION_MANIFEST_VERSION
or manifest.get("contract") != GRAPH_PUBLICATION_CONTRACT
or manifest.get("view_id") != view.view_id
or manifest.get("view_config_hash") != self._view_hash(view)
or not self._hash(manifest.get("plan_id"))
or not self._hash(manifest.get("package_id"))
):
return False
project = manifest.get("project")
descriptor = self.project.descriptor
if not isinstance(project, dict):
return False
project_document = cast(dict[str, object], project)
if (
set(project_document)
!= {
"project_id",
"project_root_fingerprint",
"adapter",
"revision",
"source_hash",
}
or project_document.get("project_id") != descriptor.project_id
or project_document.get("project_root_fingerprint")
!= project_root_fingerprint(descriptor.root)
or project_document.get("adapter") != descriptor.adapter
or not isinstance(project_document.get("revision"), str)
or not project_document["revision"]
or not self._hash(project_document.get("source_hash"))
):
return False
renderer = manifest.get("renderer")
if renderer != {
"renderer_id": GRAPH_RENDERER_ID,
"renderer_version": GRAPH_RENDERER_VERSION,
}:
return False
receipt_value = manifest.get("receipt")
if not isinstance(receipt_value, dict):
return False
receipt = ProjectionReceiptV1.from_dict(
dict(cast(dict[str, object], receipt_value))
).as_dict()
artifacts = receipt.get("artifacts")
if not isinstance(artifacts, list):
return False
artifact_values = cast(list[object], artifacts)
if (
receipt.get("kind") != "graph"
or receipt.get("plan_id") != manifest["plan_id"]
or receipt.get("package_id") != manifest["package_id"]
or receipt.get("renderer") != renderer
or len(artifact_values) != 1
or manifest.get("artifact") != artifact_values[0]
):
return False
artifact = artifact_values[0]
if not isinstance(artifact, dict):
return False
artifact_document = cast(dict[str, object], artifact)
if (
artifact_document.get("artifact_id") != "portable-graph.html"
or artifact_document.get("media_type") != "text/html; charset=utf-8"
):
return False
artifact_hash = artifact_document.get("sha256")
artifact_bytes = artifact_document.get("bytes")
store = manifest.get("store")
output = manifest.get("output")
if (
not self._file_identity(store, expected_name=f"{artifact_hash}.html")
or not self._file_identity(output, expected_name=view.output_path.name)
or type(artifact_bytes) is not int
or cast(dict[str, object], store)["size"] != artifact_bytes
or cast(dict[str, object], output)["size"] != artifact_bytes
):
return False
body = dict(manifest)
publication_id = body.pop("publication_id", None)
return self._hash(publication_id) and publication_id == projection_hash(body)
except (DocForgeError, KeyError, TypeError, ValueError):
return False
@staticmethod
def _hash(value: object) -> bool:
return (
isinstance(value, str)
and len(value) == 64
and all(character in "0123456789abcdef" for character in value)
)
@staticmethod
def _file_identity(value: object, *, expected_name: str) -> bool:
if not isinstance(value, dict):
return False
document = cast(dict[str, object], value)
required = {"path", "device", "inode", "mode", "size", "mtime_ns", "ctime_ns"}
return (
set(document) == required
and document.get("path") == expected_name
and all(
type(document.get(field)) is int and cast(int, document[field]) >= 0
for field in required - {"path"}
)
)
def _manifest(
self,
snapshot: ProjectSnapshot,
view: GraphRenderView,
plan: GraphViewPlanV1,
package_id: str,
receipt: dict[str, object],
artifact: dict[str, object],
store: dict[str, object],
output: dict[str, object],
) -> dict[str, object]:
body: dict[str, object] = {
"schema_version": GRAPH_PUBLICATION_MANIFEST_VERSION,
"contract": GRAPH_PUBLICATION_CONTRACT,
"project": self._identity(snapshot),
"view_id": view.view_id,
"view_config_hash": self._view_hash(view),
"plan_id": plan.plan_id,
"package_id": package_id,
"renderer": {
"renderer_id": GRAPH_RENDERER_ID,
"renderer_version": GRAPH_RENDERER_VERSION,
},
"receipt": receipt,
"artifact": artifact,
"store": store,
"output": output,
}
return {**body, "publication_id": projection_hash(body)}
def _publish_artifact(
self,
snapshot: ProjectSnapshot,
artifact_hash: object,
content: bytes,
*,
verify: Callable[[], None],
) -> dict[str, object]:
if not isinstance(artifact_hash, str):
raise DocForgeError("invalid_projection", "Artifact hash is invalid")
root = snapshot.descriptor.cache_root / "projection-artifacts"
descriptor = open_confined_directory(snapshot.descriptor.root, root, create=True)
name = f"{artifact_hash}.html"
try:
try:
existing = read_bounded_file_at(descriptor, name, len(content))
except DocForgeError as error:
if error.code != "invalid_projection":
raise
existing = None
if existing == content:
identity = safe_file_identity_at(root, descriptor, name)
assert identity is not None
return identity
return atomic_replace_bytes_at(root, descriptor, name, content, verify=verify)
finally:
os.close(descriptor)
def _publish_output(
self,
snapshot: ProjectSnapshot,
view: GraphRenderView,
content: bytes,
*,
verify: Callable[[], None],
) -> dict[str, object]:
root = view.output_path.parent
descriptor = open_confined_directory(snapshot.descriptor.root, root, create=True)
try:
try:
existing = read_bounded_file_at(
descriptor,
view.output_path.name,
len(content),
)
except DocForgeError as error:
if error.code != "invalid_projection":
raise
existing = None
if existing == content:
identity = safe_file_identity_at(root, descriptor, view.output_path.name)
assert identity is not None
return identity
return atomic_replace_bytes_at(
root,
descriptor,
view.output_path.name,
content,
verify=verify,
)
finally:
os.close(descriptor)
def _publish_manifest(
self,
snapshot: ProjectSnapshot,
view: GraphRenderView,
manifest: dict[str, object],
*,
verify: Callable[[], None],
) -> None:
raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n"
if len(raw) > MAX_GRAPH_PUBLICATION_BYTES:
raise DocForgeError(
"projection_too_large",
"Portable graph publication manifest exceeds its fixed limit",
)
root = self._manifest_root()
descriptor = open_confined_directory(snapshot.descriptor.root, root, create=True)
try:
atomic_replace_bytes_at(
root,
descriptor,
f"{view.view_id}.json",
raw,
verify=verify,
)
finally:
os.close(descriptor)
@contextmanager
def _lock(self) -> Generator[None]:
root = self.project.descriptor.cache_root
descriptor = open_confined_directory(self.project.descriptor.root, root, create=True)
lock_descriptor: int | None = None
try:
lock_descriptor = os.open(
"graph-render.lock",
os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW,
0o600,
dir_fd=descriptor,
)
fcntl.flock(lock_descriptor, fcntl.LOCK_EX)
require_bound_directory(root, descriptor)
yield
except OSError as error:
raise DocForgeError(
"publication_failure",
"Portable graph render lock is unavailable",
) from error
finally:
if lock_descriptor is not None:
os.close(lock_descriptor)
os.close(descriptor)
def _plan(self, snapshot: ProjectSnapshot, view: GraphRenderView) -> GraphViewPlanV1:
return build_graph_view_plan(
snapshot,
GraphViewRequestV1(
view_id=view.view_id,
title=view.title,
root_node_id=view.root_node_id,
query=view.query,
initial_mode=view.initial_mode,
depth=view.depth,
max_nodes=view.max_nodes,
max_edges=view.max_edges,
max_work=view.max_work,
families=view.families,
relations=view.relations,
authorities=view.authorities,
statuses=view.statuses,
tags=view.tags,
include_logic=view.include_logic,
),
self.allow_logic,
)
def _current_state(self) -> ProjectState | None:
if isinstance(self.project, IncrementalStateProject):
return self.project.incremental_state()
return None
def _config(self, snapshot: ProjectSnapshot) -> GraphRenderConfig:
config = snapshot.descriptor.graph_render
if config is None:
raise DocForgeError(
"graph_render_not_configured",
"Project has no portable graph render configuration",
)
return config
@staticmethod
def _view(config: GraphRenderConfig, view_id: str) -> GraphRenderView:
for view in config.views:
if view.view_id == view_id:
return view
raise DocForgeError(
"unknown_graph_render_view",
"Portable graph view is not declared",
view_id=view_id,
)
def _manifest_root(self) -> Path:
return self.project.descriptor.cache_root / "projection-publications" / "graph"
@staticmethod
def _view_hash(view: GraphRenderView) -> str:
return projection_hash(
{
"view_id": view.view_id,
"renderer": view.renderer,
"title": view.title,
"root_node_id": view.root_node_id,
"query": view.query,
"initial_mode": view.initial_mode,
"depth": view.depth,
"max_nodes": view.max_nodes,
"max_edges": view.max_edges,
"max_work": view.max_work,
"families": list(view.families),
"relations": list(view.relations),
"authorities": list(view.authorities),
"statuses": list(view.statuses),
"tags": list(view.tags),
"include_logic": view.include_logic,
}
)
def _status_result(self, current: ProjectState | None, **payload: object) -> dict[str, object]:
descriptor = self.project.descriptor
return {
"status": "ok",
"project_id": descriptor.project_id,
"project_root_fingerprint": project_root_fingerprint(descriptor.root),
"adapter": descriptor.adapter,
"revision": current.revision if current is not None else "unknown",
"source_hash": current.source_hash if current is not None else None,
**payload,
}
@staticmethod
def _identity(snapshot: ProjectSnapshot) -> dict[str, object]:
return {
"project_id": snapshot.descriptor.project_id,
"project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root),
"adapter": snapshot.descriptor.adapter,
"revision": snapshot.revision,
"source_hash": snapshot.source_hash,
}

View file

@ -1,208 +0,0 @@
"""Pure manual planning over one immutable validated graph generation."""
from __future__ import annotations
import hashlib
from collections import defaultdict
from collections.abc import Sequence
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}
reverse_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)
reverse_adjacency[edge.target_id].append(edge.source_id)
for targets in (*adjacency.values(), *reverse_adjacency.values()):
targets.sort()
visited: set[str] = set()
finished: list[str] = []
for node_id in node_ids:
if node_id in visited:
continue
visited.add(node_id)
traversal: list[tuple[str, int]] = [(node_id, 0)]
while traversal:
current, position = traversal[-1]
targets = adjacency[current]
if position < len(targets):
target = targets[position]
traversal[-1] = (current, position + 1)
if target not in visited:
visited.add(target)
traversal.append((target, 0))
continue
finished.append(current)
traversal.pop()
assigned: set[str] = set()
components: list[list[str]] = []
for node_id in reversed(finished):
if node_id in assigned:
continue
assigned.add(node_id)
component: list[str] = []
component_stack = [node_id]
while component_stack:
current = component_stack.pop()
component.append(current)
for target in reversed(reverse_adjacency[current]):
if target not in assigned:
assigned.add(target)
component_stack.append(target)
component.sort()
if len(component) > 1 or component[0] in adjacency[component[0]]:
components.append(component)
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,
render_identity: str | None = None,
fragment_records: Sequence[dict[str, object]] = (),
) -> 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
template_asset: dict[str, object] = {
"asset_id": "manual.template",
"media_type": "text/html; charset=utf-8",
"sha256": hashlib.sha256(template_bytes).hexdigest(),
"text": template,
}
if render_identity is not None:
template_asset["render_identity"] = render_identity
assets = [template_asset]
if fragment_records:
assets.append(
{
"asset_id": "manual.fragments",
"media_type": "application/vnd.docforge.projection-fragments.v1+json",
"records": list(fragment_records),
}
)
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=assets,
output_policy={
"artifact_ids": ["manual.html"],
"max_total_bytes": max_output_bytes,
},
)

View file

@ -15,13 +15,11 @@ from .application import CanonicalApplicationService, CanonicalApplier, GenericC
from .changesets import ChangesetStore
from .context import compile_context
from .errors import DocForgeError
from .graph_rendering import GraphRenderService
from .index import ProjectIndex
from .models import IncrementalStateProject, ProjectService, RuntimeValidatedProject
from .pagination import canonical_hash, decode_cursor, page_limit, page_receipt
from .policy import CapabilityMode, capability_mode, compose_effective_policy
from .project import Project, project_root_fingerprint
from .projection_policy import compose_projection_policy
from .rendering import RenderService
from .retrieval import MAX_TASK_EVIDENCE, TaskKind, build_retrieval_plan
from .telemetry import request, stage
@ -49,8 +47,6 @@ READ_TOOLS = (
"docforge_get_task_context",
"docforge_validate_project",
"docforge_render_status",
"docforge_graph_plan",
"docforge_graph_render_status",
"docforge_visualize",
"docforge_stop_visualization",
"docforge_visualization_status",
@ -138,9 +134,6 @@ class DocForgeService:
no_ast: bool = False,
diagnostics: bool = False,
capability_mode_name: str | None = None,
manual_projection_policy: str | None = None,
portable_graph_policy: str | None = None,
live_viewer_policy: str | None = None,
) -> None:
self.project = project
default_mode: CapabilityMode = (
@ -160,40 +153,19 @@ class DocForgeService:
render_configured=project.descriptor.render is not None,
application_enabled=application_enabled,
)
self.projection_policy = compose_projection_policy(
manual=manual_projection_policy,
portable_graph=portable_graph_policy,
live_viewer=live_viewer_policy,
manual_configured=project.descriptor.render is not None,
portable_graph_configured=project.descriptor.graph_render is not None,
application_enabled=application_enabled,
)
self.index = ProjectIndex(self.project, allow_logic=not self.policy.no_ast)
self.changesets = ChangesetStore(
self.project,
proposal_writer if selected_mode != "read" else None,
)
self.rendering = RenderService(
self.project,
self.changesets,
manual_policy=self.projection_policy.manual,
)
self.graph_rendering = GraphRenderService(
self.project,
allow_logic=not self.policy.no_ast,
portable_graph_policy=self.projection_policy.portable_graph,
)
self.rendering = RenderService(self.project, self.changesets)
self.application = CanonicalApplicationService(
self.project,
applier_id=canonical_applier_id if application_enabled else None,
applier=canonical_applier if application_enabled else None,
index=self.index,
manual_policy=self.projection_policy.manual,
)
self.visualization = ViewerManagerClient(
self.index,
live_viewer_policy=self.projection_policy.live_viewer,
)
self.visualization = ViewerManagerClient(self.index)
self.context_provider = context_provider
self.task_context_available = context_provider is compile_context
self.binding_metadata = dict(binding_metadata or {})
@ -649,7 +621,6 @@ class DocForgeService:
}
capabilities = self.capabilities()
effective_policy = self.policy.as_dict()
projection_policy = self.projection_policy.as_dict()
session_contract: dict[str, object] = {
"schema_version": 1,
"binding": binding,
@ -659,8 +630,6 @@ class DocForgeService:
"freshness": "current",
},
"effective_policy": effective_policy,
"projection_policy": projection_policy,
"projection_policy_hash": self.projection_policy.policy_hash,
"capabilities": capabilities,
"render_policies": {
"manual": effective_policy["manual_render"],
@ -683,8 +652,6 @@ class DocForgeService:
"canonical_paths": [str(path) for path in descriptor.content_roots],
"adapter_policy": self.adapter_policy(),
"effective_policy": effective_policy,
"projection_policy": projection_policy,
"projection_policy_hash": self.projection_policy.policy_hash,
"capabilities": capabilities,
"session_contract": session_contract,
"proposal_access": proposal_access,
@ -747,8 +714,6 @@ class DocForgeService:
),
"adapter_policy": self.adapter_policy(),
"effective_policy": self.policy.as_dict(),
"projection_policy": self.projection_policy.as_dict(),
"projection_policy_hash": self.projection_policy.policy_hash,
"capabilities": self.capabilities(),
"canonical_paths": [
*(relative(path) for path in snapshot.descriptor.content_roots),
@ -864,29 +829,6 @@ class DocForgeService:
operation_name="mcp.render_status",
)
def graph_plan(self, view_id: str) -> dict[str, object]:
"""Plan one declared portable graph without publishing derived output."""
return self.invoke(
lambda: self.graph_rendering.plan(view_id),
synchronize=False,
load_error_identity=False,
operation_name="mcp.graph_plan",
)
def graph_render_status(
self,
view_id: str | None = None,
) -> dict[str, object]:
"""Report portable-graph publication state without planning or rendering."""
return self.invoke(
lambda: self.graph_rendering.status(view_id),
synchronize=False,
load_error_identity=False,
operation_name="mcp.graph_render_status",
)
def context(
self,
profile: str,
@ -1633,18 +1575,6 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
return service.render_status(view_id, deep=deep)
@server.tool(name="docforge_graph_plan")
def graph_plan(view_id: str) -> dict[str, Any]:
"""Plan one declared portable graph without publishing derived output."""
return service.graph_plan(view_id)
@server.tool(name="docforge_graph_render_status")
def graph_render_status(view_id: str | None = None) -> dict[str, Any]:
"""Report portable-graph publication state without rendering."""
return service.graph_render_status(view_id)
@server.tool(name="docforge_visualize")
def visualize(
node_id: str | None = None,
@ -1692,8 +1622,6 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
get_task_context,
validate_project,
render_status,
graph_plan,
graph_render_status,
visualize,
stop_visualization,
visualization_status,
@ -2076,9 +2004,6 @@ def create_server(
no_ast: bool = False,
diagnostics: bool = False,
capability_mode: str | None = None,
manual_projection_policy: str | None = None,
portable_graph_policy: str | None = None,
live_viewer_policy: str | None = None,
) -> FastMCP:
project = Project.open(project_root)
return create_project_server(
@ -2095,9 +2020,6 @@ def create_server(
no_ast=no_ast,
diagnostics=diagnostics,
capability_mode=capability_mode,
manual_projection_policy=manual_projection_policy,
portable_graph_policy=portable_graph_policy,
live_viewer_policy=live_viewer_policy,
)
@ -2112,9 +2034,6 @@ def create_project_server(
no_ast: bool = False,
diagnostics: bool = False,
capability_mode: str | None = None,
manual_projection_policy: str | None = None,
portable_graph_policy: str | None = None,
live_viewer_policy: str | None = None,
) -> FastMCP:
"""Create the full fixed MCP surface for one explicitly configured project service."""
@ -2128,9 +2047,6 @@ def create_project_server(
no_ast=no_ast,
diagnostics=diagnostics,
capability_mode_name=capability_mode,
manual_projection_policy=manual_projection_policy,
portable_graph_policy=portable_graph_policy,
live_viewer_policy=live_viewer_policy,
)
return _create_bound_server(
service,
@ -2146,9 +2062,6 @@ def create_read_only_server(
no_ast: bool = False,
diagnostics: bool = False,
capability_mode: str | None = None,
manual_projection_policy: str | None = None,
portable_graph_policy: str | None = None,
live_viewer_policy: str | None = None,
) -> FastMCP:
"""Create an adapter-capable MCP server exposing only the fixed read tool surface."""
@ -2160,9 +2073,6 @@ def create_read_only_server(
no_ast=no_ast,
diagnostics=diagnostics,
capability_mode_name="read" if capability_mode is None else capability_mode,
manual_projection_policy=manual_projection_policy,
portable_graph_policy=portable_graph_policy,
live_viewer_policy=live_viewer_policy,
)
if service.policy.capability_mode != "read":
raise DocForgeError(
@ -2196,18 +2106,6 @@ def main() -> None:
choices=("read", "proposal", "application", "operator"),
help="Expose the versioned project-bound capability surface",
)
parser.add_argument(
"--manual-render-policy",
choices=("auto", "explicit", "disabled"),
)
parser.add_argument(
"--portable-graph-policy",
choices=("explicit", "disabled"),
)
parser.add_argument(
"--live-viewer-policy",
choices=("on-demand", "disabled"),
)
arguments = parser.parse_args()
create_server(
arguments.project_root,
@ -2216,9 +2114,6 @@ def main() -> None:
no_ast=arguments.no_ast,
diagnostics=arguments.diagnostics,
capability_mode=arguments.capability_mode,
manual_projection_policy=arguments.manual_render_policy,
portable_graph_policy=arguments.portable_graph_policy,
live_viewer_policy=arguments.live_viewer_policy,
).run(transport="stdio")

View file

@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Literal, Protocol, runtime_checkable
from typing import Protocol, runtime_checkable
@dataclass(frozen=True)
@ -49,33 +49,6 @@ class RenderConfig:
views: tuple[RenderView, ...]
@dataclass(frozen=True)
class GraphRenderView:
view_id: str
renderer: str
output_path: Path
title: str
root_node_id: str | None
query: str | None
initial_mode: Literal["nodes", "flow", "web", "logic"]
depth: int
max_nodes: int
max_edges: int
max_work: int
families: tuple[str, ...]
relations: tuple[str, ...]
authorities: tuple[str, ...]
statuses: tuple[str, ...]
tags: tuple[str, ...]
include_logic: bool
@dataclass(frozen=True)
class GraphRenderConfig:
output_root: Path
views: tuple[GraphRenderView, ...]
@dataclass(frozen=True)
class ContextProfile:
profile_id: str
@ -105,7 +78,6 @@ class ProjectDescriptor:
allowed_relations: tuple[str, ...]
profiles: tuple[ContextProfile, ...]
limits: Limits
graph_render: GraphRenderConfig | None = None
@dataclass(frozen=True)

View file

@ -25,7 +25,6 @@ from .config_validation import (
string_list,
)
from .errors import DocForgeError
from .graph_render_config import load_graph_render_config
from .models import (
ContextProfile,
Edge,
@ -67,7 +66,6 @@ _DESCRIPTOR_KEYS = frozenset(
"derived",
"changesets",
"render",
"graph_render",
"graph",
"limits",
"profiles",
@ -550,6 +548,7 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
for field in defaults.__dataclass_fields__
}
)
render = load_render_config(
root,
document.get("render"),
@ -561,18 +560,6 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
changeset_root=changeset_root,
limits=limits,
)
graph_render = load_graph_render_config(
root,
document.get("graph_render"),
descriptor_path=descriptor_path,
content_roots=content_roots,
authority_files=authority_files,
cache_root=cache_root,
index_path=index_path,
changeset_root=changeset_root,
manual_render=render,
limits=limits,
)
profile_documents = document.get("profiles", [])
if not isinstance(profile_documents, list):
@ -636,7 +623,6 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
changeset_root=changeset_root,
proposal_writers=tuple(sorted(proposal_writers, key=lambda writer: writer.writer_id)),
render=render,
graph_render=graph_render,
allowed_relations=allowed_relations,
profiles=tuple(profiles),
limits=limits,

View file

@ -1,536 +0,0 @@
"""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
MAX_GRAPH_VIEW_DEPTH = 32
MAX_GRAPH_VIEW_NODES = 1_000
MAX_GRAPH_VIEW_EDGES = 4_000
MAX_GRAPH_VIEW_WORK = 1_000_000
MAX_GRAPH_VIEW_FILTERS = 64
MAX_GRAPH_VIEW_STRING_CHARS = 1_024
MAX_GRAPH_VIEW_QUERY_CHARS = 10_000
ProjectionKind = Literal["manual", "graph"]
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)
renderer = document.get("renderer")
diagnostics = document.get("diagnostics")
timing = document.get("timing")
if (
not isinstance(renderer, dict)
or not isinstance(diagnostics, dict)
or not isinstance(timing, dict)
):
raise DocForgeError("invalid_projection", "Projection receipt structure is invalid")
renderer_document = cast(dict[str, object], renderer)
diagnostics_document = cast(dict[str, object], diagnostics)
timing_document = cast(dict[str, object], timing)
warnings = diagnostics_document.get("warnings")
if (
len(artifacts) > MAX_PROJECTION_ARTIFACTS
or set(renderer_document) != {"renderer_id", "renderer_version"}
or not all(
isinstance(renderer_document.get(field), str) and renderer_document[field]
for field in ("renderer_id", "renderer_version")
)
or set(diagnostics_document) != {"warnings"}
or not isinstance(warnings, list)
or len(cast(list[object], warnings)) > 10_000
or not all(isinstance(item, str) for item in cast(list[object], warnings))
or set(timing_document) != {"elapsed_ns"}
or type(timing_document.get("elapsed_ns")) is not int
or cast(int, timing_document["elapsed_ns"]) < 0
):
raise DocForgeError("invalid_projection", "Projection receipt structure is invalid")
peak = document.get("peak_memory_bytes")
if peak is not None and (type(peak) is not int or peak < 0):
raise DocForgeError("invalid_projection", "Projection receipt memory value is invalid")
artifact_ids: set[str] = set()
for artifact in artifacts:
if not isinstance(artifact, dict):
raise DocForgeError("invalid_projection", "Projection receipt artifact is invalid")
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")
artifact_id = cast(str, item["artifact_id"])
if artifact_id in artifact_ids:
raise DocForgeError("invalid_projection", "Projection receipt artifacts are duplicated")
artifact_ids.add(artifact_id)
return _validated_identity(
document,
identity_field="receipt_id",
maximum=MAX_RECEIPT_BYTES,
kind="Projection receipt",
)

View file

@ -1,489 +0,0 @@
"""Bounded, path-free, disposable projection fragment caching."""
from __future__ import annotations
import base64
import hashlib
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, cast
from ._fs_safety import (
atomic_replace_bytes_at,
open_confined_directory,
read_bounded_file_at,
require_bound_directory,
)
from .errors import DocForgeError
from .projection_contract import canonical_projection_bytes, projection_hash
FRAGMENT_SCHEMA_VERSION = 1
FRAGMENT_KEY_CONTRACT = "docforge.projection-fragment-key"
FRAGMENT_RECORD_CONTRACT = "docforge.projection-fragment-record"
FRAGMENT_CACHE_DIRECTORY = "projection-fragments-v1"
MAX_FRAGMENT_CONTENT_BYTES = 4_000_000
MAX_FRAGMENT_ID_CHARS = 256
MAX_FRAGMENT_CACHE_ENTRIES = 10_000
MAX_FRAGMENT_CACHE_BYTES = 64_000_000
_MAX_RECORD_OVERHEAD_BYTES = 8_192
ProjectionKind = Literal["manual", "graph"]
def _is_hash(value: object) -> bool:
return (
isinstance(value, str)
and len(value) == 64
and all(character in "0123456789abcdef" for character in value)
)
def _version_string(value: object, *, field: str) -> str:
if (
not isinstance(value, str)
or not value
or value != value.strip()
or len(value) > MAX_FRAGMENT_ID_CHARS
or any(ord(character) < 32 for character in value)
):
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment identity is invalid",
field=field,
)
return value
def fragment_semantic_hash(value: object) -> str:
"""Hash one complete semantic input using the projection canonical JSON form."""
try:
return hashlib.sha256(canonical_projection_bytes(value)).hexdigest()
except (TypeError, ValueError) as error:
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment semantic input is not canonical JSON",
) from error
@dataclass(frozen=True)
class FragmentKey:
"""Versioned identity for one renderer component's complete semantics."""
projection_kind: ProjectionKind
renderer_id: str
renderer_version: str
component_version: str
semantic_input_hash: str
key_id: str
@classmethod
def create(
cls,
*,
projection_kind: ProjectionKind,
renderer_id: str,
renderer_version: str,
component_version: str,
semantic_input_hash: str,
) -> FragmentKey:
body = cls._body(
projection_kind=projection_kind,
renderer_id=renderer_id,
renderer_version=renderer_version,
component_version=component_version,
semantic_input_hash=semantic_input_hash,
)
return cls._from_validated({**body, "key_id": projection_hash(body)})
@classmethod
def from_dict(cls, value: object) -> FragmentKey:
if not isinstance(value, dict):
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment key is invalid",
)
return cls._from_validated(dict(cast(dict[str, object], value)))
@staticmethod
def _body(
*,
projection_kind: object,
renderer_id: object,
renderer_version: object,
component_version: object,
semantic_input_hash: object,
) -> dict[str, object]:
if projection_kind not in {"manual", "graph"}:
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment kind is invalid",
)
if not _is_hash(semantic_input_hash):
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment semantic input hash is invalid",
)
return {
"schema_version": FRAGMENT_SCHEMA_VERSION,
"contract": FRAGMENT_KEY_CONTRACT,
"projection_kind": projection_kind,
"renderer_id": _version_string(renderer_id, field="renderer_id"),
"renderer_version": _version_string(
renderer_version,
field="renderer_version",
),
"component_version": _version_string(
component_version,
field="component_version",
),
"semantic_input_hash": semantic_input_hash,
}
@classmethod
def _from_validated(cls, value: dict[str, object]) -> FragmentKey:
required = {
"schema_version",
"contract",
"projection_kind",
"renderer_id",
"renderer_version",
"component_version",
"semantic_input_hash",
"key_id",
}
if (
set(value) != required
or value.get("schema_version") != FRAGMENT_SCHEMA_VERSION
or value.get("contract") != FRAGMENT_KEY_CONTRACT
):
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment key contract is incompatible",
)
body = cls._body(
projection_kind=value.get("projection_kind"),
renderer_id=value.get("renderer_id"),
renderer_version=value.get("renderer_version"),
component_version=value.get("component_version"),
semantic_input_hash=value.get("semantic_input_hash"),
)
key_id = value.get("key_id")
if not _is_hash(key_id) or key_id != projection_hash(body):
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment key does not match its semantics",
)
return cls(
projection_kind=cast(ProjectionKind, body["projection_kind"]),
renderer_id=cast(str, body["renderer_id"]),
renderer_version=cast(str, body["renderer_version"]),
component_version=cast(str, body["component_version"]),
semantic_input_hash=cast(str, body["semantic_input_hash"]),
key_id=cast(str, key_id),
)
def as_dict(self) -> dict[str, object]:
return {
"schema_version": FRAGMENT_SCHEMA_VERSION,
"contract": FRAGMENT_KEY_CONTRACT,
"projection_kind": self.projection_kind,
"renderer_id": self.renderer_id,
"renderer_version": self.renderer_version,
"component_version": self.component_version,
"semantic_input_hash": self.semantic_input_hash,
"key_id": self.key_id,
}
@dataclass(frozen=True)
class FragmentRecord:
"""One path-free fragment payload with complete byte evidence."""
key: FragmentKey
content: bytes
byte_count: int
content_sha256: str
record_id: str
@classmethod
def create(cls, key: FragmentKey, content: bytes) -> FragmentRecord:
if len(content) > MAX_FRAGMENT_CONTENT_BYTES:
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment content is invalid or oversized",
maximum_bytes=MAX_FRAGMENT_CONTENT_BYTES,
)
body = cls._body(key, content)
return cls(
key=key,
content=content,
byte_count=len(content),
content_sha256=hashlib.sha256(content).hexdigest(),
record_id=projection_hash(body),
)
@classmethod
def from_dict(cls, value: object) -> FragmentRecord:
if not isinstance(value, dict):
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment record is invalid",
)
document = dict(cast(dict[str, object], value))
required = {
"schema_version",
"contract",
"record_id",
"key",
"content_encoding",
"content",
"byte_count",
"content_sha256",
}
if (
set(document) != required
or document.get("schema_version") != FRAGMENT_SCHEMA_VERSION
or document.get("contract") != FRAGMENT_RECORD_CONTRACT
or document.get("content_encoding") != "base64"
):
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment record contract is incompatible",
)
encoded = document.get("content")
if not isinstance(encoded, str):
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment content encoding is invalid",
)
try:
content = base64.b64decode(encoded.encode("ascii"), validate=True)
except (UnicodeEncodeError, ValueError) as error:
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment content encoding is invalid",
) from error
if len(content) > MAX_FRAGMENT_CONTENT_BYTES:
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment content is oversized",
maximum_bytes=MAX_FRAGMENT_CONTENT_BYTES,
)
key = FragmentKey.from_dict(document.get("key"))
body = cls._body(key, content)
record_id = document.get("record_id")
if (
type(document.get("byte_count")) is not int
or document.get("byte_count") != len(content)
or document.get("content_sha256") != hashlib.sha256(content).hexdigest()
or not _is_hash(record_id)
or record_id != projection_hash(body)
):
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment byte evidence is invalid",
)
return cls(
key=key,
content=content,
byte_count=len(content),
content_sha256=hashlib.sha256(content).hexdigest(),
record_id=cast(str, record_id),
)
@classmethod
def from_bytes(cls, raw: bytes) -> FragmentRecord:
try:
value: object = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment record is not valid JSON",
) from error
record = cls.from_dict(value)
if record.to_bytes() != raw:
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment record is not canonically serialized",
)
return record
@staticmethod
def _body(key: FragmentKey, content: bytes) -> dict[str, object]:
return {
"schema_version": FRAGMENT_SCHEMA_VERSION,
"contract": FRAGMENT_RECORD_CONTRACT,
"key": key.as_dict(),
"content_encoding": "base64",
"content": base64.b64encode(content).decode("ascii"),
"byte_count": len(content),
"content_sha256": hashlib.sha256(content).hexdigest(),
}
def as_dict(self) -> dict[str, object]:
return {
**self._body(self.key, self.content),
"record_id": self.record_id,
}
def to_bytes(self) -> bytes:
return canonical_projection_bytes(self.as_dict())
class ProjectionFragmentCache:
"""Confined best-effort storage for immutable projection fragments."""
def __init__(
self,
project_root: Path,
cache_root: Path,
*,
maximum_content_bytes: int = MAX_FRAGMENT_CONTENT_BYTES,
) -> None:
if (
type(maximum_content_bytes) is not int
or not 1 <= maximum_content_bytes <= MAX_FRAGMENT_CONTENT_BYTES
):
raise DocForgeError(
"invalid_projection_fragment",
"Projection fragment cache byte limit is invalid",
maximum_bytes=MAX_FRAGMENT_CONTENT_BYTES,
)
self.project_root = project_root
self.cache_root = cache_root
self.fragment_root = cache_root / FRAGMENT_CACHE_DIRECTORY
self.maximum_content_bytes = maximum_content_bytes
@property
def maximum_record_bytes(self) -> int:
encoded = ((self.maximum_content_bytes + 2) // 3) * 4
return encoded + _MAX_RECORD_OVERHEAD_BYTES
def get(self, key: FragmentKey) -> FragmentRecord | None:
"""Return one exact compatible fragment, treating every cache defect as a miss."""
descriptor: int | None = None
try:
descriptor = self._open(create=False)
raw = read_bounded_file_at(
descriptor,
f"{key.key_id}.json",
self.maximum_record_bytes,
)
if raw is None:
return None
record = FragmentRecord.from_bytes(raw)
if record.key != key or record.byte_count > self.maximum_content_bytes:
return None
require_bound_directory(self.fragment_root, descriptor)
return record
except (DocForgeError, OSError):
return None
finally:
if descriptor is not None:
os.close(descriptor)
def put(self, key: FragmentKey, content: bytes) -> FragmentRecord | None:
"""Durably publish one fragment, returning ``None`` on disposable cache failure."""
if len(content) > self.maximum_content_bytes:
return None
try:
record = FragmentRecord.create(key, content)
except DocForgeError:
return None
raw = record.to_bytes()
if len(raw) > self.maximum_record_bytes:
return None
descriptor: int | None = None
try:
descriptor = self._open(create=True)
name = f"{key.key_id}.json"
try:
existing = read_bounded_file_at(
descriptor,
name,
self.maximum_record_bytes,
)
except DocForgeError as error:
if error.code == "path_escape":
return None
existing = None
if existing == raw:
require_bound_directory(self.fragment_root, descriptor)
return record
atomic_replace_bytes_at(
self.fragment_root,
descriptor,
name,
raw,
verify=lambda: require_bound_directory(self.fragment_root, descriptor),
)
return record
except (DocForgeError, OSError):
return None
finally:
if descriptor is not None:
os.close(descriptor)
def prune(self, keep: tuple[FragmentKey, ...]) -> bool:
"""Remove every stale entry and prove the retained inventory is bounded."""
keep_ids = {key.key_id for key in keep}
if len(keep_ids) > MAX_FRAGMENT_CACHE_ENTRIES:
return False
descriptor: int | None = None
try:
descriptor = self._open(create=False)
retained_entries = 0
retained_bytes = 0
with os.scandir(descriptor) as entries:
for entry in entries:
name = entry.name
retained = (
len(name) == 69
and name.endswith(".json")
and name[:-5] in keep_ids
and all(character in "0123456789abcdef" for character in name[:-5])
)
if retained:
identity = entry.stat(follow_symlinks=False)
if not entry.is_file(follow_symlinks=False):
return False
retained_entries += 1
retained_bytes += identity.st_size
continue
try:
os.unlink(name, dir_fd=descriptor)
except OSError:
return False
if (
retained_entries > MAX_FRAGMENT_CACHE_ENTRIES
or retained_bytes > MAX_FRAGMENT_CACHE_BYTES
):
return False
require_bound_directory(self.fragment_root, descriptor)
os.fsync(descriptor)
return True
except (DocForgeError, OSError):
return False
finally:
if descriptor is not None:
os.close(descriptor)
def _open(self, *, create: bool) -> int:
if self.cache_root == self.project_root or not self.cache_root.is_relative_to(
self.project_root
):
raise DocForgeError(
"path_escape",
"Projection fragment cache is not confined to a derived project root",
)
return open_confined_directory(
self.project_root,
self.fragment_root,
create=create,
)

View file

@ -1,234 +0,0 @@
"""Independent version-2 policy for manual, portable graph, and live projections."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Literal, cast
from .errors import DocForgeError
ManualProjectionMode = Literal["auto", "explicit", "disabled"]
PortableGraphProjectionMode = Literal["explicit", "disabled"]
LiveViewerProjectionMode = Literal["on-demand", "disabled"]
MANUAL_PROJECTION_MODES: tuple[ManualProjectionMode, ...] = (
"auto",
"explicit",
"disabled",
)
PORTABLE_GRAPH_PROJECTION_MODES: tuple[PortableGraphProjectionMode, ...] = (
"explicit",
"disabled",
)
LIVE_VIEWER_PROJECTION_MODES: tuple[LiveViewerProjectionMode, ...] = (
"on-demand",
"disabled",
)
def _invalid_mode(field: str, value: object, allowed: tuple[str, ...]) -> DocForgeError:
return DocForgeError(
"invalid_projection_policy",
"Projection policy mode is unsupported",
projection=field,
mode=value,
allowed=list(allowed),
)
def _select_mode(
value: object | None,
*,
field: str,
default: str,
allowed: tuple[str, ...],
) -> str:
selected: object = default if value is None else value
if not isinstance(selected, str) or selected not in allowed:
raise _invalid_mode(field, selected, allowed)
return selected
def _require_boolean(field: str, value: object) -> bool:
if type(value) is not bool:
raise DocForgeError(
"invalid_projection_policy",
"Projection policy availability must be Boolean",
field=field,
)
return value
def _unavailable(field: str, mode: str, required: str) -> DocForgeError:
return DocForgeError(
"projection_policy_unavailable",
"Projection policy mode is unavailable",
projection=field,
mode=mode,
required=required,
)
def validate_manual_projection_mode(value: object) -> ManualProjectionMode:
"""Validate one direct manual-service policy selection."""
return cast(
ManualProjectionMode,
_select_mode(
value,
field="manual",
default="explicit",
allowed=MANUAL_PROJECTION_MODES,
),
)
def validate_portable_graph_projection_mode(
value: object,
) -> PortableGraphProjectionMode:
"""Validate one direct portable-graph service policy selection."""
return cast(
PortableGraphProjectionMode,
_select_mode(
value,
field="portable_graph",
default="explicit",
allowed=PORTABLE_GRAPH_PROJECTION_MODES,
),
)
def validate_live_viewer_projection_mode(value: object) -> LiveViewerProjectionMode:
"""Validate one direct live-viewer service policy selection."""
return cast(
LiveViewerProjectionMode,
_select_mode(
value,
field="live_viewer",
default="on-demand",
allowed=LIVE_VIEWER_PROJECTION_MODES,
),
)
@dataclass(frozen=True)
class ProjectionPolicyV2:
"""One immutable policy for three independent projection consumers."""
manual: ManualProjectionMode
portable_graph: PortableGraphProjectionMode
live_viewer: LiveViewerProjectionMode
def __post_init__(self) -> None:
if self.manual not in MANUAL_PROJECTION_MODES:
raise _invalid_mode("manual", self.manual, MANUAL_PROJECTION_MODES)
if self.portable_graph not in PORTABLE_GRAPH_PROJECTION_MODES:
raise _invalid_mode(
"portable_graph",
self.portable_graph,
PORTABLE_GRAPH_PROJECTION_MODES,
)
if self.live_viewer not in LIVE_VIEWER_PROJECTION_MODES:
raise _invalid_mode(
"live_viewer",
self.live_viewer,
LIVE_VIEWER_PROJECTION_MODES,
)
def as_dict(self) -> dict[str, object]:
return {
"schema_version": 2,
"manual": self.manual,
"portable_graph": self.portable_graph,
"live_viewer": self.live_viewer,
}
@property
def policy_hash(self) -> str:
raw = json.dumps(
self.as_dict(),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def compose_projection_policy(
*,
manual: str | None = None,
portable_graph: str | None = None,
live_viewer: str | None = None,
manual_configured: bool,
portable_graph_configured: bool,
application_enabled: bool,
live_viewer_available: bool = True,
) -> ProjectionPolicyV2:
"""Compose compatible defaults with explicit availability-checked selections."""
manual_available = _require_boolean("manual_configured", manual_configured)
graph_available = _require_boolean(
"portable_graph_configured",
portable_graph_configured,
)
application_available = _require_boolean("application_enabled", application_enabled)
viewer_available = _require_boolean("live_viewer_available", live_viewer_available)
default_manual = (
"auto"
if manual_available and application_available
else ("explicit" if manual_available else "disabled")
)
default_graph = "explicit" if graph_available else "disabled"
default_viewer = "on-demand" if viewer_available else "disabled"
manual_mode = cast(
ManualProjectionMode,
_select_mode(
manual,
field="manual",
default=default_manual,
allowed=MANUAL_PROJECTION_MODES,
),
)
graph_mode = cast(
PortableGraphProjectionMode,
_select_mode(
portable_graph,
field="portable_graph",
default=default_graph,
allowed=PORTABLE_GRAPH_PROJECTION_MODES,
),
)
viewer_mode = cast(
LiveViewerProjectionMode,
_select_mode(
live_viewer,
field="live_viewer",
default=default_viewer,
allowed=LIVE_VIEWER_PROJECTION_MODES,
),
)
if manual_mode != "disabled" and not manual_available:
raise _unavailable("manual", manual_mode, "manual_render_config")
if manual_mode == "auto" and not application_available:
raise _unavailable("manual", manual_mode, "canonical_application")
if graph_mode == "explicit" and not graph_available:
raise _unavailable(
"portable_graph",
graph_mode,
"portable_graph_render_config",
)
if viewer_mode == "on-demand" and not viewer_available:
raise _unavailable("live_viewer", viewer_mode, "live_viewer_runtime")
return ProjectionPolicyV2(
manual=manual_mode,
portable_graph=graph_mode,
live_viewer=viewer_mode,
)

View file

@ -1,436 +0,0 @@
"""One-shot detached execution for the fixed built-in projection renderers."""
from __future__ import annotations
import base64
import binascii
import json
import os
import resource
import subprocess
import sys
import tempfile
from importlib.metadata import version
from typing import cast
from .errors import DocForgeError
from .projection_contract import (
MAX_PACKAGE_BYTES,
MAX_PROJECTION_ARTIFACTS,
MAX_RECEIPT_BYTES,
ProjectionArtifact,
ProjectionPackageV1,
ProjectionReceiptV1,
ProjectionRenderResult,
canonical_projection_bytes,
)
WORKER_PROTOCOL_VERSION = 1
MAX_WORKER_ARTIFACT_BYTES = 20_000_000
MAX_WORKER_REQUEST_BYTES = MAX_PACKAGE_BYTES + 1
MAX_WORKER_RESPONSE_BYTES = 4 * ((MAX_WORKER_ARTIFACT_BYTES + 2) // 3) + MAX_RECEIPT_BYTES + 256_000
WORKER_TIMEOUT_SECONDS = 30
_GENERIC_HTML_RENDERER_ID = "generic_html"
_PORTABLE_GRAPH_RENDERER_ID = "portable_graph_html"
_PORTABLE_GRAPH_RENDERER_VERSION = "1"
def _generic_html_renderer_version() -> str:
return f"1+markdown-it-py-{version('markdown-it-py')}"
def _worker_failure(message: str, **details: object) -> DocForgeError:
return DocForgeError("projection_worker_failure", message, **details)
def _renderer_identity(package: ProjectionPackageV1) -> dict[str, object]:
renderer_value = package.document.get("renderer")
if not isinstance(renderer_value, dict):
raise DocForgeError("unsupported_renderer", "Projection renderer identity is invalid")
renderer = cast(dict[str, object], renderer_value)
if set(renderer) != {"renderer_id", "renderer_version"}:
raise DocForgeError("unsupported_renderer", "Projection renderer identity is invalid")
renderer_id = renderer.get("renderer_id")
renderer_version = renderer.get("renderer_version")
if not isinstance(renderer_id, str) or not isinstance(renderer_version, str):
raise DocForgeError("unsupported_renderer", "Projection renderer identity is invalid")
supported = (
package.kind == "manual"
and renderer_id == _GENERIC_HTML_RENDERER_ID
and renderer_version == _generic_html_renderer_version()
) or (
package.kind == "graph"
and renderer_id == _PORTABLE_GRAPH_RENDERER_ID
and renderer_version == _PORTABLE_GRAPH_RENDERER_VERSION
)
if not supported:
raise DocForgeError(
"unsupported_renderer",
"Projection worker supports only the fixed built-in renderer versions",
)
return dict(renderer)
def _output_policy(package: ProjectionPackageV1) -> tuple[tuple[str, ...], int]:
policy_value = package.document.get("output_policy")
if not isinstance(policy_value, dict):
raise DocForgeError("invalid_projection", "Projection output policy is invalid")
policy = cast(dict[str, object], policy_value)
if set(policy) != {"artifact_ids", "max_total_bytes"}:
raise DocForgeError("invalid_projection", "Projection output policy is invalid")
artifact_ids_value = policy.get("artifact_ids")
maximum = policy.get("max_total_bytes")
if not isinstance(artifact_ids_value, list):
raise DocForgeError("invalid_projection", "Projection artifact inventory is invalid")
artifact_ids_objects = cast(list[object], artifact_ids_value)
if (
not artifact_ids_objects
or len(artifact_ids_objects) > MAX_PROJECTION_ARTIFACTS
or not all(
isinstance(artifact_id, str)
and bool(artifact_id)
and "/" not in artifact_id
and artifact_id not in {".", ".."}
for artifact_id in artifact_ids_objects
)
):
raise DocForgeError("invalid_projection", "Projection artifact inventory is invalid")
artifact_ids = tuple(cast(list[str], artifact_ids_objects))
if len(set(artifact_ids)) != len(artifact_ids):
raise DocForgeError("invalid_projection", "Projection artifact inventory is duplicated")
if type(maximum) is not int or maximum < 1:
raise DocForgeError(
"invalid_projection",
"Projection output byte allowance is invalid",
)
return artifact_ids, maximum
def _validated_package(package: object) -> ProjectionPackageV1:
if not isinstance(package, ProjectionPackageV1):
raise TypeError("Projection worker requires ProjectionPackageV1")
validated = ProjectionPackageV1.from_dict(package.as_dict())
_renderer_identity(validated)
_output_policy(validated)
return validated
def _validate_result(
package: ProjectionPackageV1,
result: ProjectionRenderResult,
*,
require_peak_memory: bool,
) -> ProjectionRenderResult:
artifact_ids, maximum = _output_policy(package)
if (
len(result.artifacts) != len(artifact_ids)
or tuple(artifact.artifact_id for artifact in result.artifacts) != artifact_ids
):
raise _worker_failure("Projection worker returned an invalid artifact inventory")
total_bytes = 0
evidence: list[dict[str, object]] = []
for artifact in result.artifacts:
if not artifact.media_type or type(artifact.content) is not bytes:
raise _worker_failure("Projection worker returned an invalid artifact")
total_bytes += len(artifact.content)
if total_bytes > maximum or total_bytes > MAX_WORKER_ARTIFACT_BYTES:
raise _worker_failure("Projection worker artifact transfer exceeded its fixed boundary")
evidence.append(artifact.evidence())
try:
receipt = ProjectionReceiptV1.from_dict(result.receipt.as_dict())
except DocForgeError as error:
raise _worker_failure("Projection worker receipt is invalid") from error
receipt_document = receipt.document
renderer = _renderer_identity(package)
timing_value = receipt_document.get("timing")
if not isinstance(timing_value, dict):
raise _worker_failure("Projection worker receipt timing is invalid")
timing = cast(dict[str, object], timing_value)
elapsed = timing.get("elapsed_ns")
peak_memory = receipt_document.get("peak_memory_bytes")
if (
receipt_document.get("kind") != package.kind
or receipt_document.get("package_id") != package.package_id
or receipt_document.get("plan_id") != package.document.get("plan_id")
or receipt_document.get("renderer") != renderer
or receipt_document.get("artifacts") != evidence
or type(elapsed) is not int
or elapsed < 0
or (require_peak_memory and (type(peak_memory) is not int or peak_memory <= 0))
):
raise _worker_failure("Projection worker receipt does not attest the requested package")
return ProjectionRenderResult(tuple(result.artifacts), receipt)
def _decode_canonical_line(raw: bytes, *, maximum: int, label: str) -> dict[str, object]:
if type(raw) is not bytes or len(raw) > maximum:
raise _worker_failure(f"{label} exceeded its fixed boundary", maximum_bytes=maximum)
if not raw or not raw.endswith(b"\n") or raw.count(b"\n") != 1:
raise _worker_failure(f"{label} framing is invalid")
payload = raw[:-1]
try:
value: object = json.loads(payload)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise _worker_failure(f"{label} is not valid JSON") from error
if not isinstance(value, dict):
raise _worker_failure(f"{label} must be one JSON object")
document = cast(dict[str, object], value)
if canonical_projection_bytes(document) != payload:
raise _worker_failure(f"{label} is not canonical JSON")
return document
def _encode_request(package: ProjectionPackageV1) -> bytes:
encoded = canonical_projection_bytes(package.as_dict()) + b"\n"
if len(encoded) > MAX_WORKER_REQUEST_BYTES:
raise DocForgeError(
"projection_too_large",
"Projection worker request exceeds its fixed boundary",
maximum_bytes=MAX_WORKER_REQUEST_BYTES,
)
return encoded
def _invoke_worker(request: bytes) -> subprocess.CompletedProcess[bytes]:
environment = {key: os.environ[key] for key in ("SYSTEMROOT", "WINDIR") if key in os.environ}
environment.update(
{
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1",
}
)
command = [sys.executable, "-I", "-m", "docforge._projection_worker_main"]
with tempfile.TemporaryFile() as output:
completed = subprocess.run(
command,
input=request,
stdout=output,
stderr=subprocess.DEVNULL,
check=False,
timeout=WORKER_TIMEOUT_SECONDS,
shell=False,
cwd=sys.prefix,
env=environment,
)
output.seek(0)
stdout = output.read(MAX_WORKER_RESPONSE_BYTES + 1)
return subprocess.CompletedProcess(
command,
completed.returncode,
stdout=stdout,
)
def _decode_response(package: ProjectionPackageV1, raw: bytes) -> ProjectionRenderResult:
document = _decode_canonical_line(
raw,
maximum=MAX_WORKER_RESPONSE_BYTES,
label="Projection worker response",
)
if (
set(document) != {"schema_version", "artifacts", "receipt"}
or document.get("schema_version") != WORKER_PROTOCOL_VERSION
):
raise _worker_failure("Projection worker response contract is invalid")
artifact_values = document.get("artifacts")
receipt_value = document.get("receipt")
if not isinstance(artifact_values, list) or not isinstance(receipt_value, dict):
raise _worker_failure("Projection worker response structure is invalid")
artifacts: list[ProjectionArtifact] = []
total_bytes = 0
for value in cast(list[object], artifact_values):
if not isinstance(value, dict):
raise _worker_failure("Projection worker artifact envelope is invalid")
artifact = cast(dict[str, object], value)
if set(artifact) != {"artifact_id", "media_type", "content_base64"}:
raise _worker_failure("Projection worker artifact envelope is invalid")
artifact_id = artifact.get("artifact_id")
media_type = artifact.get("media_type")
encoded = artifact.get("content_base64")
if (
not isinstance(artifact_id, str)
or not isinstance(media_type, str)
or not isinstance(encoded, str)
):
raise _worker_failure("Projection worker artifact envelope is invalid")
try:
content = base64.b64decode(encoded.encode("ascii"), validate=True)
except (UnicodeEncodeError, binascii.Error, ValueError) as error:
raise _worker_failure("Projection worker artifact encoding is invalid") from error
total_bytes += len(content)
if total_bytes > MAX_WORKER_ARTIFACT_BYTES:
raise _worker_failure("Projection worker artifact transfer exceeded its fixed boundary")
artifacts.append(ProjectionArtifact(artifact_id, media_type, content))
try:
receipt = ProjectionReceiptV1.from_dict(cast(dict[str, object], receipt_value))
except DocForgeError as error:
raise _worker_failure("Projection worker receipt is invalid") from error
return _validate_result(
package,
ProjectionRenderResult(tuple(artifacts), receipt),
require_peak_memory=True,
)
def render_projection_in_worker(package: ProjectionPackageV1) -> ProjectionRenderResult:
"""Render one validated path-free package in a fixed one-shot child process."""
validated = _validated_package(package)
request = _encode_request(validated)
try:
completed = _invoke_worker(request)
except subprocess.TimeoutExpired as error:
raise DocForgeError(
"projection_worker_timeout",
"Detached projection worker exceeded its fixed timeout",
timeout_seconds=WORKER_TIMEOUT_SECONDS,
) from error
except OSError as error:
raise _worker_failure("Detached projection worker could not be launched") from error
if completed.returncode != 0:
if completed.returncode == 3 and completed.stdout:
try:
failure = _decode_canonical_line(
completed.stdout,
maximum=MAX_RECEIPT_BYTES,
label="Projection worker error response",
)
error = failure.get("error")
error_document = cast(dict[str, object], error) if isinstance(error, dict) else None
if (
set(failure) == {"schema_version", "error"}
and failure.get("schema_version") == WORKER_PROTOCOL_VERSION
and error_document is not None
and set(error_document) == {"code", "message", "details"}
and isinstance(error_document.get("code"), str)
and bool(error_document["code"])
and isinstance(error_document.get("message"), str)
and bool(error_document["message"])
and isinstance(error_document.get("details"), dict)
):
raise DocForgeError(
cast(str, error_document["code"]),
cast(str, error_document["message"]),
**cast(dict[str, object], error_document["details"]),
)
except DocForgeError as error:
if error.code != "projection_worker_failure":
raise
if completed.returncode < 0:
raise _worker_failure(
"Detached projection worker terminated by signal",
signal=-completed.returncode,
)
raise _worker_failure(
"Detached projection worker exited unsuccessfully",
exit_code=completed.returncode,
)
if type(completed.stdout) is not bytes:
raise _worker_failure("Detached projection worker returned invalid output")
return _decode_response(validated, completed.stdout)
def _render_package(package: ProjectionPackageV1) -> ProjectionRenderResult:
renderer = _renderer_identity(package)
if renderer["renderer_id"] == _GENERIC_HTML_RENDERER_ID:
from docforge_renderers.manual import ManualHtmlRenderer
result = ManualHtmlRenderer(cast(str, renderer["renderer_version"])).render(package)
else:
from docforge_renderers.graph import PortableGraphHtmlRenderer
result = PortableGraphHtmlRenderer().render(package)
return _validate_result(package, result, require_peak_memory=False)
def _peak_memory_bytes() -> int:
peak = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
return max(1, peak if sys.platform == "darwin" else peak * 1024)
def _child_response(package: ProjectionPackageV1) -> bytes:
result = _render_package(package)
original = result.receipt.document
receipt = ProjectionReceiptV1.create(
kind=package.kind,
package_id=package.package_id,
plan_id=cast(str, package.document["plan_id"]),
renderer=cast(dict[str, object], original["renderer"]),
artifacts=[artifact.evidence() for artifact in result.artifacts],
diagnostics=cast(dict[str, object], original["diagnostics"]),
timing=cast(dict[str, object], original["timing"]),
peak_memory_bytes=_peak_memory_bytes(),
)
validated = _validate_result(
package,
ProjectionRenderResult(result.artifacts, receipt),
require_peak_memory=True,
)
document: dict[str, object] = {
"schema_version": WORKER_PROTOCOL_VERSION,
"artifacts": [
{
"artifact_id": artifact.artifact_id,
"media_type": artifact.media_type,
"content_base64": base64.b64encode(artifact.content).decode("ascii"),
}
for artifact in validated.artifacts
],
"receipt": validated.receipt.as_dict(),
}
encoded = canonical_projection_bytes(document) + b"\n"
if len(encoded) > MAX_WORKER_RESPONSE_BYTES:
raise _worker_failure(
"Projection worker response exceeded its fixed boundary",
maximum_bytes=MAX_WORKER_RESPONSE_BYTES,
)
return encoded
def _read_child_request() -> ProjectionPackageV1:
raw = sys.stdin.buffer.read(MAX_WORKER_REQUEST_BYTES + 1)
document = _decode_canonical_line(
raw,
maximum=MAX_WORKER_REQUEST_BYTES,
label="Projection worker request",
)
return _validated_package(ProjectionPackageV1.from_dict(document))
def main(argv: list[str] | None = None) -> int:
"""Run the closed one-request child protocol."""
arguments = sys.argv[1:] if argv is None else argv
if arguments:
return 2
try:
package = _read_child_request()
except Exception:
return 2
try:
response = _child_response(package)
sys.stdout.buffer.write(response)
sys.stdout.buffer.flush()
except DocForgeError as error:
response = (
canonical_projection_bytes(
{
"schema_version": WORKER_PROTOCOL_VERSION,
"error": error.as_dict(),
}
)
+ b"\n"
)
if len(response) <= MAX_RECEIPT_BYTES:
sys.stdout.buffer.write(response)
sys.stdout.buffer.flush()
return 3
except Exception:
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

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

View file

@ -1,30 +1,31 @@
"""Compatibility shim over the versioned manual projection boundary."""
"""Deterministic built-in renderer contract and safe template primitives."""
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, cast
from typing import Protocol
from markdown_it import MarkdownIt
from .errors import DocForgeError
from .manual_projection import build_manual_projection_package, build_manual_render_plan
from .models import ProjectSnapshot, RenderView
from .projection_contract import (
ManualRenderPlanV1,
ProjectionPackageV1,
ProjectionRenderResult,
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 .projection_fragments import (
FragmentKey,
FragmentRecord,
ProjectionFragmentCache,
fragment_semantic_hash,
)
from .projection_worker import render_projection_in_worker
@dataclass(frozen=True)
@ -35,7 +36,6 @@ class PreparedRender:
renderer: str
renderer_version: str
template_hash: str
projection_receipt: dict[str, object] | None = None
class Renderer(Protocol):
@ -55,18 +55,16 @@ class Renderer(Protocol):
class GenericHtmlRenderer:
"""Preserve the public v1 renderer API over the plan-only manual renderer."""
"""Render validated nodes through escaped CommonMark and a strict token template."""
renderer_id = "generic_html"
contract_version = "1"
page_component_version = "manual.page@1"
def __init__(self, *, incremental: bool = True) -> None:
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')}"
)
self.incremental = incremental
def prepare(
self,
@ -76,6 +74,22 @@ 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
)
@ -114,31 +128,16 @@ class GenericHtmlRenderer:
render_identity = hashlib.sha256(
json.dumps(identity_payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
plan = build_manual_render_plan(snapshot, view, changeset_hash=changeset_hash)
full_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,
render_identity=render_identity,
)
if self.incremental:
result = self._incremental_result(
snapshot,
plan,
template_bytes,
full_package=full_package,
render_identity=render_identity,
)
else:
result = render_projection_in_worker(full_package)
if len(result.artifacts) != 1:
raise DocForgeError(
"invalid_projection",
"Manual renderer returned an unsupported artifact set",
)
output = result.artifacts[0].content
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"
return PreparedRender(
render_identity=render_identity,
output_hash=hashlib.sha256(output).hexdigest(),
@ -146,114 +145,45 @@ class GenericHtmlRenderer:
renderer=self.renderer_id,
renderer_version=self.renderer_version,
template_hash=template_hash,
projection_receipt=result.receipt.as_dict(),
)
def _incremental_result(
self,
snapshot: ProjectSnapshot,
plan: ManualRenderPlanV1,
template_bytes: bytes,
*,
full_package: ProjectionPackageV1,
render_identity: str,
) -> ProjectionRenderResult:
cache = ProjectionFragmentCache(
snapshot.descriptor.root,
snapshot.descriptor.cache_root,
)
pages = cast(list[object], plan.document["pages"])
records: list[FragmentRecord | None] = []
missing: list[tuple[int, FragmentKey]] = []
for value in pages:
page = cast(dict[str, object], value)
key = FragmentKey.create(
projection_kind="manual",
renderer_id=self.renderer_id,
renderer_version=self.renderer_version,
component_version=self.page_component_version,
semantic_input_hash=fragment_semantic_hash(page),
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>"
)
record = cache.get(key)
if record is None:
missing.append((len(records), key))
records.append(record)
if not records:
return render_projection_in_worker(full_package)
full_result: ProjectionRenderResult | None = None
new_records: list[FragmentRecord] = []
if missing:
full_result = render_projection_in_worker(full_package)
fragments = self._extract_page_fragments(
pages,
full_result.artifacts[0].content,
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(),
]
)
try:
for position, key in missing:
record = FragmentRecord.create(key, fragments[position])
records[position] = record
new_records.append(record)
except DocForgeError:
return full_result
complete_records = [record for record in records if record is not None]
if len(complete_records) != len(records):
return full_result or render_projection_in_worker(full_package)
try:
incremental_package = build_manual_projection_package(
plan,
template_bytes,
fragment_records=[record.as_dict() for record in complete_records],
renderer_id=self.renderer_id,
renderer_version=self.renderer_version,
max_output_bytes=snapshot.descriptor.limits.max_render_bytes,
render_identity=render_identity,
)
incremental_result = render_projection_in_worker(incremental_package)
except DocForgeError:
cache.prune(())
return full_result or render_projection_in_worker(full_package)
if full_result is None:
cache.prune(tuple(record.key for record in complete_records))
return incremental_result
if tuple(
(artifact.artifact_id, artifact.media_type, artifact.content)
for artifact in incremental_result.artifacts
) != tuple(
(artifact.artifact_id, artifact.media_type, artifact.content)
for artifact in full_result.artifacts
):
cache.prune(())
return full_result
for record in new_records:
cache.put(record.key, record.content)
cache.prune(tuple(record.key for record in complete_records))
return incremental_result
@staticmethod
def _extract_page_fragments(
pages: list[object],
output: bytes,
) -> list[bytes]:
"""Extract exact deterministic page sections from one trusted full artifact."""
fragments: list[bytes] = []
cursor = 0
closing = b"</section>"
for value in pages:
page = cast(dict[str, object], value)
node_id = cast(str, page["node_id"])
marker = f'<section id="node-{html.escape(node_id, quote=True)}">'.encode()
start = output.find(marker, cursor)
end = output.find(closing, start + len(marker)) if start >= 0 else -1
if start < 0 or end < 0:
raise DocForgeError(
"invalid_projection",
"Full manual artifact does not contain its planned page fragments",
)
end += len(closing)
fragments.append(output[start:end])
cursor = end
return fragments
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]] = {
@ -261,13 +191,13 @@ _RENDERERS: dict[str, type[GenericHtmlRenderer]] = {
}
def renderer_for(view: RenderView, *, incremental: bool = True) -> Renderer:
def renderer_for(view: RenderView) -> Renderer:
factory = _RENDERERS.get(view.renderer)
if factory is None:
raise DocForgeError(
"unsupported_renderer", "View does not name a supported built-in renderer"
)
return factory(incremental=incremental)
return factory()
def relative_output(snapshot: ProjectSnapshot, path: Path) -> str:

View file

@ -26,8 +26,6 @@ from .models import (
RenderView,
)
from .project import project_root_fingerprint
from .projection_contract import ProjectionReceiptV1
from .projection_policy import ManualProjectionMode, validate_manual_projection_mode
from .render_contract import PreparedRender, relative_output, renderer_for
from .telemetry import increment, stage
@ -38,26 +36,9 @@ MAX_RENDER_RECEIPT_BYTES = 64_000
class RenderService:
"""Render only declared views through fixed built-in renderer implementations."""
def __init__(
self,
project: ProjectService,
changesets: ChangesetStore | None = None,
*,
manual_policy: ManualProjectionMode = "explicit",
) -> None:
def __init__(self, project: ProjectService, changesets: ChangesetStore | None = None) -> None:
self.project = project
self.changesets = changesets or ChangesetStore(project)
self.manual_policy = validate_manual_projection_mode(manual_policy)
def _require_rendering(self, operation: str) -> None:
if self.manual_policy == "disabled":
raise DocForgeError(
"projection_policy_forbids_operation",
"Manual projection policy disables rendering work",
projection="manual",
mode=self.manual_policy,
operation=operation,
)
def status(self, view_id: str | None = None) -> dict[str, object]:
"""Report publication state from bounded receipts without rendering canonical content."""
@ -105,7 +86,6 @@ class RenderService:
def deep_status(self, view_id: str | None = None) -> dict[str, object]:
"""Recompute render output as the explicit side-effect-free equivalence oracle."""
self._require_rendering("deep_status")
snapshot = self.project.load()
config = snapshot.descriptor.render
if config is None:
@ -127,12 +107,7 @@ class RenderService:
snapshot.descriptor.root,
view.output_path,
)
prepared, _ = self._prepare(
snapshot,
view,
changeset_hash=None,
incremental=False,
)
prepared, _ = self._prepare(snapshot, view, changeset_hash=None)
state = "missing"
actual_hash: str | None = None
output = view.output_path
@ -176,7 +151,6 @@ class RenderService:
)
def render(self, view_id: str) -> dict[str, object]:
self._require_rendering("render")
with self._lock():
snapshot = self.project.load()
config = self._config(snapshot)
@ -399,8 +373,6 @@ class RenderService:
"template_file": final_template_file,
"output_file": final_output_file,
}
if prepared.projection_receipt is not None:
payload["projection_receipt"] = prepared.projection_receipt
raw = json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n"
if len(raw) > MAX_RENDER_RECEIPT_BYTES:
raise DocForgeError(
@ -600,10 +572,8 @@ class RenderService:
renderer = renderer_for(view)
template_file = receipt.get("template_file")
output_file = receipt.get("output_file")
fields = set(receipt)
projection_receipt = receipt.get("projection_receipt")
return (
fields in (required, required | {"projection_receipt"})
set(receipt) == required
and receipt.get("schema_version") == RENDER_RECEIPT_SCHEMA_VERSION
and receipt.get("project_id") == descriptor.project_id
and receipt.get("project_root_fingerprint") == project_root_fingerprint(descriptor.root)
@ -632,50 +602,6 @@ class RenderService:
descriptor.limits.max_render_bytes,
)
and cast(dict[str, object], output_file)["size"] == receipt.get("output_bytes")
and (
projection_receipt is None
or self._valid_projection_receipt(
projection_receipt,
renderer_id=renderer.renderer_id,
renderer_version=renderer.renderer_version,
output_hash=cast(str, receipt["output_hash"]),
output_bytes=cast(int, receipt["output_bytes"]),
)
)
)
@staticmethod
def _valid_projection_receipt(
value: object,
*,
renderer_id: str,
renderer_version: str,
output_hash: str,
output_bytes: int,
) -> bool:
if not isinstance(value, dict):
return False
try:
receipt = ProjectionReceiptV1.from_dict(cast(dict[str, object], value))
except DocForgeError:
return False
document = receipt.document
return (
document.get("kind") == "manual"
and document.get("renderer")
== {
"renderer_id": renderer_id,
"renderer_version": renderer_version,
}
and document.get("artifacts")
== [
{
"artifact_id": "manual.html",
"media_type": "text/html; charset=utf-8",
"sha256": output_hash,
"bytes": output_bytes,
}
]
)
@staticmethod
@ -737,7 +663,6 @@ class RenderService:
"reason": reason,
"verification": "receipt",
"receipt_schema_version": payload.get("schema_version"),
"projection_receipt": payload.get("projection_receipt"),
}
def _current_state(self) -> ProjectState | None:
@ -762,7 +687,6 @@ class RenderService:
}
def preview(self, changeset_id: str, view_id: str) -> dict[str, object]:
self._require_rendering("preview")
with self._lock():
snapshot, changeset_hash = self.changesets.projected_snapshot(changeset_id)
config = self._config(snapshot)
@ -807,12 +731,11 @@ class RenderService:
view: RenderView,
*,
changeset_hash: str | None,
incremental: bool = True,
) -> tuple[PreparedRender, bytes]:
increment("render_prepare_calls")
template = self._template_bytes(snapshot, view)
with stage("render.prepare"):
prepared = renderer_for(view, incremental=incremental).prepare(
prepared = renderer_for(view).prepare(
snapshot,
view,
template,
@ -927,7 +850,6 @@ class RenderService:
"expected_output_hash": prepared.output_hash,
"actual_output_hash": actual_hash,
"template_hash": prepared.template_hash,
"projection_receipt": prepared.projection_receipt,
"path": relative_output(snapshot, view.output_path),
"state": state,
}

View file

@ -79,7 +79,6 @@ OPERATION_NAMES = frozenset(
"test",
"benchmark.m1",
"benchmark.m2",
"benchmark.m3",
"mcp.invoke",
"mcp.bootstrap",
"mcp.sync",
@ -97,8 +96,6 @@ OPERATION_NAMES = frozenset(
"mcp.generation_diff",
"mcp.validate_project",
"mcp.render_status",
"mcp.graph_plan",
"mcp.graph_render_status",
"mcp.visualize",
"mcp.visualization_status",
"mcp.stop_visualization",
@ -120,9 +117,6 @@ OPERATION_NAMES = frozenset(
"cli.impact",
"cli.context",
"cli.generation-diff",
"cli.graph-plan",
"cli.graph-render",
"cli.graph-render-status",
"cli.configure",
"cli.doctor",
"cli.render",

View file

@ -28,10 +28,6 @@ from .errors import DocForgeError
from .index import ProjectIndex
from .models import IncrementalStateProject
from .project import project_root_fingerprint
from .projection_policy import (
LiveViewerProjectionMode,
validate_live_viewer_projection_mode,
)
from .telemetry import increment, stage
from .visualization import VISUALIZATION_TEMPLATE, VisualizationIndexSnapshot
@ -627,16 +623,9 @@ class ViewerManager:
class ViewerManagerClient:
"""Project-bound MCP-side client for the separately supervised manager service."""
def __init__(
self,
index: ProjectIndex,
*,
state_path: Path | None = None,
live_viewer_policy: LiveViewerProjectionMode = "on-demand",
) -> None:
def __init__(self, index: ProjectIndex, *, state_path: Path | None = None) -> None:
self.index = index
self.state_path = state_path or default_state_path()
self.live_viewer_policy = validate_live_viewer_projection_mode(live_viewer_policy)
def start(
self,
@ -645,14 +634,6 @@ class ViewerManagerClient:
query: str | None = None,
depth: int = 1,
) -> dict[str, object]:
if self.live_viewer_policy == "disabled":
raise DocForgeError(
"projection_policy_forbids_operation",
"Live viewer projection policy disables viewer startup",
projection="live_viewer",
mode=self.live_viewer_policy,
operation="start",
)
if node_id is not None and query is not None:
raise DocForgeError(
"invalid_visualization_target",

View file

@ -506,11 +506,11 @@ class VisualizationIndexSnapshot:
)
def source(self, node_id: str) -> dict[str, object]:
"""Return bounded source evidence stored in the pinned index generation."""
"""Return one node's bounded, project-confined UTF-8 source file."""
with self._connection() as connection:
row = connection.execute(
"SELECT node_id, source_path, source_anchor, content FROM nodes WHERE node_id = ?",
"SELECT node_id, source_path, source_anchor FROM nodes WHERE node_id = ?",
(node_id,),
).fetchone()
if row is None:
@ -518,8 +518,7 @@ class VisualizationIndexSnapshot:
"""
SELECT logic.logic_id AS node_id,
owner.source_path AS source_path,
logic.source_anchor AS source_anchor,
owner.content AS content
logic.source_anchor AS source_anchor
FROM logic_nodes AS logic
JOIN nodes AS owner ON owner.node_id = logic.owner_node_id
WHERE logic.logic_id = ?
@ -534,26 +533,51 @@ class VisualizationIndexSnapshot:
"No node has the requested stable ID",
node_id=node_id,
)
content = row["content"]
if not isinstance(content, str):
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:
raise DocForgeError(
"invalid_index",
"Pinned source evidence is invalid",
"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",
node_id=node_id,
)
raw = content.encode("utf-8")
raw = source.read_bytes()
if len(raw) > self.max_source_bytes:
raise DocForgeError(
"source_too_large",
"Pinned source evidence exceeds the configured source limit",
"Node source 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,
)

View file

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

View file

@ -1,321 +0,0 @@
"""Deterministic self-contained renderer for one portable graph package."""
from __future__ import annotations
import base64
import hashlib
import html
import json
from time import perf_counter_ns
from typing import cast
from docforge.errors import DocForgeError
from docforge.projection_contract import (
ProjectionArtifact,
ProjectionPackageV1,
ProjectionReceiptV1,
ProjectionRenderResult,
)
PORTABLE_GRAPH_CSS = """
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; background: Canvas; color: CanvasText; }
.skip { position: absolute; left: -10000px; top: auto; }
.skip:focus { left: 1rem; top: 1rem; z-index: 2; padding: .5rem; background: Canvas; }
header, main { width: min(96%, 1100px); margin: 0 auto; }
header { padding: 1rem 0; }
.controls { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; }
label { display: grid; gap: .25rem; font-weight: 600; }
input, select, button { font: inherit; min-height: 2.75rem; padding: .45rem .65rem; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible { outline: .2rem solid Highlight; }
.summary { margin: 1rem 0; }
.layout { display: grid; grid-template-columns: minmax(16rem, 1fr) minmax(20rem, 2fr); gap: 1rem; }
.panel { border: 1px solid GrayText; border-radius: .5rem; padding: 1rem; overflow: auto; }
html[data-enhanced="true"] main[data-mode="nodes"] .layout,
html[data-enhanced="true"] main[data-mode="flow"] .layout { grid-template-columns: 1fr; }
html[data-enhanced="true"] main[data-mode="nodes"] [data-panel="relationships"] { display: none; }
html[data-enhanced="true"] main[data-mode="flow"] [data-panel="nodes"] { display: none; }
.node-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .5rem; }
.node-list button {
width: 100%; text-align: left; border: 1px solid GrayText; border-radius: .35rem;
}
.node-list button[aria-current="true"] { border-width: .2rem; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; border-bottom: 1px solid GrayText; padding: .5rem; vertical-align: top; }
caption { text-align: left; font-weight: 700; margin-bottom: .5rem; }
.muted { color: CanvasText; }
dialog {
max-width: min(42rem, calc(100% - 2rem));
border: 1px solid GrayText; border-radius: .5rem;
}
dialog::backdrop { background: rgb(0 0 0 / 55%); }
@media (max-width: 48rem) { .layout { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; }
}
@media (forced-colors: active) {
.panel, .node-list button, dialog { border: 2px solid CanvasText; }
}
""".strip()
PORTABLE_GRAPH_JAVASCRIPT = r"""
(() => {
"use strict";
const plan = JSON.parse(document.getElementById("docforge-graph-plan").textContent);
const nodes = plan.graph.nodes;
const edges = plan.graph.edges;
const list = document.getElementById("node-list");
const rows = document.getElementById("edge-rows");
const filter = document.getElementById("filter");
const mode = document.getElementById("mode");
const main = document.getElementById("main");
const status = document.getElementById("status");
const dialog = document.getElementById("node-dialog");
const detail = document.getElementById("node-detail");
const close = document.getElementById("close-dialog");
let opener = null;
document.documentElement.dataset.enhanced = "true";
const matches = (node) => {
const query = filter.value.trim().toLocaleLowerCase();
const fields = [
node.node_id, node.title, node.summary, node.family, node.status, ...node.tags
];
return !query || fields
.join(" ").toLocaleLowerCase().includes(query);
};
const selectedIds = () => new Set(nodes.filter(matches).map((node) => node.node_id));
const render = () => {
main.dataset.mode = mode.value;
const visible = nodes.filter(matches);
const ids = selectedIds();
list.replaceChildren(...visible.map((node) => {
const item = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.textContent = `${node.title} (${node.node_id})`;
button.dataset.nodeId = node.node_id;
button.addEventListener("click", () => inspect(node, button));
item.append(button);
return item;
}));
const visibleEdges = edges.filter((edge) => ids.has(edge.source_id) && ids.has(edge.target_id));
rows.replaceChildren(...visibleEdges.map((edge) => {
const row = document.createElement("tr");
[edge.source_id, edge.relation, edge.target_id].forEach((value) => {
const cell = document.createElement("td");
cell.textContent = value;
row.append(cell);
});
return row;
}));
status.textContent = `${visible.length} nodes and ${visibleEdges.length} relationships `
+ `shown in ${mode.value} mode.`;
};
const inspect = (node, button) => {
opener = button;
detail.replaceChildren();
const heading = document.createElement("h2");
heading.id = "node-dialog-title";
heading.textContent = node.title;
const identity = document.createElement("p");
identity.textContent = `${node.node_id} · ${node.family} · ${node.status}`;
const summary = document.createElement("p");
summary.textContent = node.summary;
detail.append(heading, identity, summary);
dialog.showModal();
close.focus();
};
close.addEventListener("click", () => dialog.close());
dialog.addEventListener("close", () => opener?.focus());
filter.addEventListener("input", render);
mode.addEventListener("change", render);
render();
})();
""".strip()
def _csp_hash(content: str) -> str:
digest = hashlib.sha256(content.encode("utf-8")).digest()
return base64.b64encode(digest).decode("ascii")
def _embedded_json(value: object) -> str:
return (
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
.replace("&", "\\u0026")
.replace("<", "\\u003c")
.replace(">", "\\u003e")
)
def _static_node_markup(plan: dict[str, object]) -> str:
graph = cast(dict[str, object], plan["graph"])
nodes = cast(list[dict[str, object]], graph["nodes"])
parts: list[str] = []
for node in nodes:
node_id = html.escape(cast(str, node["node_id"]))
attribute_node_id = html.escape(cast(str, node["node_id"]), quote=True)
title = html.escape(cast(str, node["title"]))
family = html.escape(cast(str, node["family"]))
status = html.escape(cast(str, node["status"]))
summary = html.escape(cast(str, node["summary"]))
parts.append(
"<li><article>"
f'<button type="button" data-node-id="{attribute_node_id}">'
f"{title} ({node_id})</button>"
f'<p class="muted">{family} · {status}</p>'
f"<p>{summary}</p>"
"</article></li>"
)
return "".join(parts)
def _static_edge_markup(plan: dict[str, object]) -> str:
graph = cast(dict[str, object], plan["graph"])
edges = cast(list[dict[str, object]], graph["edges"])
return "".join(
"<tr>"
f"<td>{html.escape(cast(str, edge['source_id']))}</td>"
f"<td>{html.escape(cast(str, edge['relation']))}</td>"
f"<td>{html.escape(cast(str, edge['target_id']))}</td>"
"</tr>"
for edge in edges
)
class PortableGraphHtmlRenderer:
"""Render a validated graph package without querying or publishing project state."""
renderer_id = "portable_graph_html"
renderer_version = "1"
def render(self, package: ProjectionPackageV1) -> ProjectionRenderResult:
started = perf_counter_ns()
package = ProjectionPackageV1.from_dict(package.as_dict())
document = package.document
if package.kind != "graph":
raise DocForgeError("invalid_projection", "Graph renderer requires a graph package")
renderer = cast(dict[str, object], document["renderer"])
if renderer != {
"renderer_id": self.renderer_id,
"renderer_version": self.renderer_version,
}:
raise DocForgeError("unsupported_renderer", "Graph renderer identity is incompatible")
if document["components"] != [
{"component_id": "graph.portable-document@1"},
{"component_id": "graph.accessible-list@1"},
{"component_id": "graph.relationship-table@1"},
]:
raise DocForgeError(
"invalid_projection",
"Portable graph renderer component declarations are incompatible",
)
if document["assets"] != []:
raise DocForgeError(
"invalid_projection",
"Portable graph renderer does not accept project-provided assets",
)
plan = cast(dict[str, object], document["plan"])
view = cast(dict[str, object], plan["view"])
initial_mode = view.get("initial_mode")
policy = cast(dict[str, object], plan["policy"])
if (
initial_mode not in {"nodes", "flow", "web"}
or policy.get("logic_requested") is not False
):
raise DocForgeError(
"unsupported_renderer",
"Portable graph renderer version 1 does not render Logic projections",
)
title = html.escape(cast(str, view["title"]))
project = cast(dict[str, object], plan["project"])
embedded = _embedded_json(plan)
static_nodes = _static_node_markup(plan)
static_edges = _static_edge_markup(plan)
csp = (
"default-src 'none'; "
f"style-src 'sha256-{_csp_hash(PORTABLE_GRAPH_CSS)}'; "
f"script-src 'sha256-{_csp_hash(PORTABLE_GRAPH_JAVASCRIPT)}'; "
"img-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; "
"form-action 'none'; frame-ancestors 'none'"
)
output = (
"<!DOCTYPE html>\n"
'<html lang="en">\n'
"<head>\n"
'<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
'<meta http-equiv="Content-Security-Policy" '
f'content="{html.escape(csp, quote=True)}">\n'
f"<title>{title} · DocForge graph</title>\n"
f"<style>{PORTABLE_GRAPH_CSS}</style>\n"
"</head>\n"
"<body>\n"
'<a class="skip" href="#main">Skip to graph content</a>\n'
"<header>\n"
f"<h1>{title}</h1>\n"
f'<p class="muted">Generation {html.escape(cast(str, project["source_hash"]))}</p>\n'
'<div class="controls" role="group" aria-label="Graph controls">\n'
'<label>Filter nodes<input id="filter" type="search" autocomplete="off"></label>\n'
'<label>View mode<select id="mode">'
f'<option value="nodes"{" selected" if initial_mode == "nodes" else ""}>Nodes</option>'
f'<option value="flow"{" selected" if initial_mode == "flow" else ""}>Flow</option>'
f'<option value="web"{" selected" if initial_mode == "web" else ""}>Web</option>'
"</select></label>\n"
"</div>\n"
'<p id="status" class="summary" role="status" aria-live="polite"></p>\n'
"</header>\n"
f'<main id="main" tabindex="-1" data-mode="{initial_mode}">\n'
'<div class="layout">\n'
'<section class="panel" data-panel="nodes" aria-labelledby="nodes-title">'
'<h2 id="nodes-title">Nodes</h2>'
f'<ul id="node-list" class="node-list">{static_nodes}</ul></section>\n'
'<section class="panel" data-panel="relationships" aria-labelledby="relations-title">'
'<h2 id="relations-title">Relationships</h2>'
"<table><caption>Selected graph facts</caption><thead><tr>"
'<th scope="col">Source</th><th scope="col">Relation</th>'
f'<th scope="col">Target</th></tr></thead><tbody id="edge-rows">{static_edges}</tbody>'
"</table>"
"</section>\n"
"</div>\n"
"</main>\n"
'<dialog id="node-dialog" aria-labelledby="node-dialog-title">'
'<div id="node-detail"><h2 id="node-dialog-title">Node details</h2></div>'
'<button id="close-dialog" type="button">Close</button></dialog>\n'
f'<script id="docforge-graph-plan" type="application/json">{embedded}</script>\n'
f"<script>{PORTABLE_GRAPH_JAVASCRIPT}</script>\n"
"</body>\n"
"</html>\n"
).encode()
policy = cast(dict[str, object], document["output_policy"])
maximum = policy.get("max_total_bytes")
if set(policy) != {"artifact_ids", "max_total_bytes"} or policy.get("artifact_ids") != [
"portable-graph.html"
]:
raise DocForgeError(
"invalid_projection",
"Portable graph output policy is incompatible",
)
if type(maximum) is not int or maximum < 1 or len(output) > maximum:
raise DocForgeError("render_too_large", "Rendered output exceeds the configured limit")
artifact = ProjectionArtifact(
artifact_id="portable-graph.html",
media_type="text/html; charset=utf-8",
content=output,
)
receipt = ProjectionReceiptV1.create(
kind="graph",
package_id=package.package_id,
plan_id=cast(str, document["plan_id"]),
renderer=dict(renderer),
artifacts=[artifact.evidence()],
diagnostics={
"warnings": [],
},
timing={"elapsed_ns": perf_counter_ns() - started},
peak_memory_bytes=None,
)
return ProjectionRenderResult((artifact,), receipt)

View file

@ -1,264 +0,0 @@
"""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,
)
from docforge.projection_fragments import (
FragmentRecord,
fragment_semantic_hash,
)
_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,
)
_MANUAL_PAGE_COMPONENT = "manual.page@1"
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) not in {1, 2} 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)
not in (
{"asset_id", "media_type", "sha256", "text"},
{"asset_id", "media_type", "sha256", "text", "render_identity"},
)
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",
)
packaged_identity = asset.get("render_identity")
if packaged_identity is not None and (
not isinstance(packaged_identity, str)
or len(packaged_identity) != 64
or any(character not in "0123456789abcdef" for character in packaged_identity)
):
raise DocForgeError(
"invalid_projection",
"Manual render identity is invalid",
)
if render_identity is not None and packaged_identity not in {None, render_identity}:
raise DocForgeError(
"invalid_projection",
"Manual render identity is inconsistent",
)
identity = render_identity or packaged_identity or cast(str, plan["plan_id"])
fragments = self._fragments(plan, assets[1:] if len(assets) == 2 else [])
project = cast(dict[str, object], plan["project"])
view = cast(dict[str, object], plan["view"])
replacements = {
"docforge_content": self._content(plan, fragments),
"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 _fragments(
self,
plan: dict[str, object],
assets: list[object],
) -> dict[str, str]:
if not assets:
return {}
asset = assets[0]
if not isinstance(asset, dict):
raise DocForgeError("invalid_projection", "Manual fragment asset is invalid")
document = cast(dict[str, object], asset)
if (
set(document) != {"asset_id", "media_type", "records"}
or document.get("asset_id") != "manual.fragments"
or document.get("media_type") != "application/vnd.docforge.projection-fragments.v1+json"
or not isinstance(document.get("records"), list)
):
raise DocForgeError("invalid_projection", "Manual fragment asset is invalid")
pages = cast(list[object], plan["pages"])
records = cast(list[object], document["records"])
if len(records) != len(pages):
raise DocForgeError(
"invalid_projection",
"Manual fragment inventory is incomplete",
)
fragments: dict[str, str] = {}
for page_value, record_value in zip(pages, records, strict=True):
if not isinstance(page_value, dict):
raise DocForgeError("invalid_projection", "Manual page is invalid")
page = cast(dict[str, object], page_value)
record = FragmentRecord.from_dict(record_value)
if (
record.key.projection_kind != "manual"
or record.key.renderer_id != self.renderer_id
or record.key.renderer_version != self.renderer_version
or record.key.component_version != _MANUAL_PAGE_COMPONENT
or record.key.semantic_input_hash != fragment_semantic_hash(page)
):
raise DocForgeError(
"invalid_projection",
"Manual fragment identity does not match its page semantics",
)
try:
fragment = record.content.decode("utf-8")
except UnicodeDecodeError as error:
raise DocForgeError(
"invalid_projection",
"Manual fragment content is not valid UTF-8",
) from error
node_id = cast(str, page["node_id"])
if node_id in fragments or fragment != self.render_page_fragment(page):
raise DocForgeError(
"invalid_projection",
"Manual fragment content does not match its page semantics",
)
fragments[node_id] = fragment
return fragments
def render_page_fragment(self, page: dict[str, object]) -> str:
"""Render one page from complete plan semantics without project authority."""
node_id = cast(str, page["node_id"])
sections = [
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)
def _content(
self,
plan: dict[str, object],
fragments: dict[str, str],
) -> 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.append(fragments.get(node_id, self.render_page_fragment(page)))
return "\n".join(sections)

View file

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

View file

@ -1,187 +0,0 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
const AXE_TAGS = [
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22a",
"wcag22aa",
];
const MANUAL_AXE_TAGS = AXE_TAGS.filter((tag) => !tag.startsWith("wcag22"));
let fixtureProcess;
let surfaces;
function startFixture() {
const python = process.env.DOCFORGE_PYTHON || ".venv/bin/python";
const child = spawn(python, ["tools/accessibility_fixture.py"], {
cwd: process.cwd(),
stdio: ["pipe", "pipe", "pipe"],
});
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const lines = createInterface({ input: child.stdout });
const ready = new Promise((resolve, reject) => {
let settled = false;
lines.once("line", (line) => {
settled = true;
try {
const payload = JSON.parse(line);
if (
payload.schema_version !== 1
|| typeof payload.manual_html !== "string"
|| typeof payload.portable_html !== "string"
|| typeof payload.live_url !== "string"
) {
throw new Error("Accessibility fixture returned an invalid payload");
}
resolve(payload);
} catch (error) {
reject(error);
} finally {
lines.close();
}
});
child.once("exit", (code, signal) => {
if (!settled) {
reject(
new Error(
`Accessibility fixture exited before readiness `
+ `(code=${code}, signal=${signal}):\n${stderr}`,
),
);
}
});
});
return { child, ready };
}
async function stopFixture(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
const exited = new Promise((resolve) => child.once("exit", resolve));
child.stdin.end();
await Promise.race([
exited,
new Promise((_, reject) => {
setTimeout(() => reject(new Error("Accessibility fixture did not stop")), 5_000);
}),
]);
}
function violationReport(violations) {
return violations.map((violation) => {
const targets = violation.nodes
.flatMap((node) => node.target)
.join(", ");
return `${violation.id} (${violation.impact}): ${violation.help}\n ${targets}`;
}).join("\n");
}
async function expectNoAxeViolations(page, tags = AXE_TAGS) {
const results = await new AxeBuilder({ page }).withTags(tags).analyze();
expect(results.violations, violationReport(results.violations)).toEqual([]);
}
async function tabTo(page, selector, maximumTabs = 40) {
for (let count = 0; count < maximumTabs; count += 1) {
await page.keyboard.press("Tab");
if (await page.evaluate((target) => document.activeElement?.matches(target), selector)) {
return page.locator(selector).filter({ visible: true }).first();
}
}
throw new Error(`Keyboard focus did not reach ${selector}`);
}
test.beforeAll(async () => {
const fixture = startFixture();
fixtureProcess = fixture.child;
surfaces = await fixture.ready;
});
test.afterAll(async () => {
await stopFixture(fixtureProcess);
});
test("generated manual has no axe violations and its navigation works by keyboard", async ({
page,
}) => {
await page.setContent(surfaces.manual_html, { waitUntil: "load" });
await expect(page.locator("main section")).not.toHaveCount(0);
// The generic renderer owns structure, while this frozen project template owns target sizing.
await expectNoAxeViolations(page, MANUAL_AXE_TAGS);
await page.keyboard.press("Tab");
const firstNavigationLink = page.locator("nav[aria-label='Documentation'] a").first();
await expect(firstNavigationLink).toBeFocused();
const target = await firstNavigationLink.getAttribute("href");
expect(target).toMatch(/^#[A-Za-z0-9_.-]+$/);
await page.keyboard.press("Enter");
await expect.poll(() => page.evaluate(() => window.location.hash)).toBe(target);
});
test("portable graph supports skip, filter, view, and dialog keyboard flows", async ({ page }) => {
await page.goto("about:blank");
await page.setContent(surfaces.portable_html, { waitUntil: "load" });
await expect(page.locator("#status")).toContainText("nodes and");
await expectNoAxeViolations(page);
await page.keyboard.press("Tab");
await expect(page.locator("a.skip")).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("main#main")).toBeFocused();
await page.goto("about:blank");
await page.setContent(surfaces.portable_html, { waitUntil: "load" });
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await expect(page.locator("#filter")).toBeFocused();
await page.keyboard.type("guide.workflow");
await expect(page.locator("#status")).toContainText("1 nodes and");
await page.keyboard.press("Tab");
await expect(page.locator("#mode")).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(page.locator("main#main")).toHaveAttribute("data-mode", "flow");
await page.keyboard.press("ArrowUp");
await expect(page.locator("main#main")).toHaveAttribute("data-mode", "nodes");
await page.keyboard.press("Tab");
const nodeButton = page.locator("#node-list button").first();
await expect(nodeButton).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("#node-dialog")).toHaveAttribute("open", "");
await expect(page.locator("#close-dialog")).toBeFocused();
await expectNoAxeViolations(page);
await page.keyboard.press("Escape");
await expect(page.locator("#node-dialog")).not.toHaveAttribute("open", "");
await expect(nodeButton).toBeFocused();
});
test("live viewer passes axe and exposes keyboard graph and resize controls", async ({ page }) => {
await page.goto(surfaces.live_url);
await expect(page.locator("#status")).toContainText("nodes ·");
await expect(page.locator("#graph g.node[role='button']").first()).toBeVisible();
await expectNoAxeViolations(page);
const resizer = await tabTo(page, "#left-resizer");
const originalWidth = Number(await resizer.getAttribute("aria-valuenow"));
await page.keyboard.press("ArrowRight");
await expect(resizer).toHaveAttribute("aria-valuenow", String(originalWidth + 16));
await tabTo(page, "#graph g.node[role='button']");
await page.keyboard.press("Shift+Enter");
await expect(page.locator("#node-dialog")).toHaveAttribute("open", "");
await expect(page.locator("#close-node-dialog")).toBeFocused();
await expectNoAxeViolations(page);
await page.keyboard.press("Escape");
await expect(page.locator("#node-dialog")).not.toHaveAttribute("open", "");
});

View file

@ -17,25 +17,6 @@ from docforge.viewer_manager import ViewerManager
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
GRAPH_CONFIG = """
[graph_render]
output_root = ".docforge/portable-graph"
[[graph_render.views]]
id = "architecture"
renderer = "portable_graph_html"
output = "architecture.html"
title = "Alpha architecture"
root = "guide.workflow"
initial_mode = "nodes"
depth = 2
max_nodes = 20
max_edges = 40
max_work = 1000
include_logic = false
"""
class DocForgeCliTests(unittest.TestCase):
def copy_fixture(self, destination: Path) -> Path:
@ -153,30 +134,6 @@ class DocForgeCliTests(unittest.TestCase):
else:
os.environ["DOCFORGE_VIEWER_MANAGER_STATE"] = previous
def test_portable_graph_plan_render_and_status_are_self_service(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
descriptor = root / ".docforge/project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG,
encoding="utf-8",
)
parser = _parser()
planned = _run(
parser.parse_args(["--project-root", str(root), "graph-plan", "architecture"])
)
self.assertEqual("architecture", planned["view_id"])
rendered = _run(
parser.parse_args(["--project-root", str(root), "graph-render", "architecture"])
)
self.assertEqual("current", rendered["state"])
status = _run(
parser.parse_args(
["--project-root", str(root), "graph-render-status", "architecture"]
)
)
self.assertEqual("current", status["state"])
if __name__ == "__main__":
unittest.main()

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import contextlib
import hashlib
import io
import json
import os
@ -19,7 +18,6 @@ from jsonschema import Draft202012Validator
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from docforge.changeset_contract import document_hash
from docforge.cli import _parser, _run, main
from docforge.client_config import (
_read_existing,
@ -41,19 +39,6 @@ CONFIGURATION_SCHEMA = json.loads(
DOCTOR_SCHEMA = json.loads((SCHEMAS / "doctor-result.schema.json").read_text(encoding="utf-8"))
POLICY_SCHEMA = json.loads((SCHEMAS / "policy.schema.json").read_text(encoding="utf-8"))
GRAPH_RENDER_CONFIG = """
[graph_render]
output_root = ".docforge/portable-graph"
[[graph_render.views]]
id = "architecture"
renderer = "portable_graph_html"
output = "architecture.html"
title = "Alpha architecture"
root = "guide.workflow"
"""
class ClientIntegrationTests(unittest.TestCase):
def copy_fixture(self, destination: Path) -> Path:
@ -171,256 +156,6 @@ class ClientIntegrationTests(unittest.TestCase):
)
self.assertEqual("invalid_capability_binding", escalated.exception.code)
def test_nondefault_projection_policy_selectors_serialize_and_validate(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
descriptor = root / ".docforge" / "project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8") + GRAPH_RENDER_CONFIG,
encoding="utf-8",
)
project = Project.open(root)
expected_arguments = [
"-I",
"-m",
"docforge.mcp_server",
"--project-root",
str(root),
"--capability-mode",
"read",
"--manual-render-policy",
"disabled",
"--portable-graph-policy",
"disabled",
"--live-viewer-policy",
"disabled",
]
for client in ("codex", "claude", "openclaw"):
with self.subTest(client=client):
result = generate_client_configuration(
project,
client,
manual_render_policy="disabled",
portable_graph_policy="disabled",
live_viewer_policy="disabled",
)
Draft202012Validator(CONFIGURATION_SCHEMA).validate(result)
_validate_configuration_result(result)
self.assertEqual(
{
"schema_version": 2,
"manual": "disabled",
"portable_graph": "disabled",
"live_viewer": "disabled",
},
result["projection_policy"],
)
self.assertEqual(expected_arguments, result["binding"]["args"])
content = result["artifact"]["content"]
if client == "codex":
document = tomllib.loads(content)
serialized = document["mcp_servers"][result["server_name"]]["args"]
elif client == "claude":
document = json.loads(content)
serialized = document["mcpServers"][result["server_name"]]["args"]
else:
document = json.loads(content)
serialized = document["mcp"]["servers"][result["server_name"]]["args"]
self.assertEqual(expected_arguments, serialized)
canonical = json.dumps(
result["projection_policy"],
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
self.assertEqual(
hashlib.sha256(canonical).hexdigest(),
result["projection_policy_hash"],
)
def test_projection_policy_schema_and_configuration_validation_reject_drift(self) -> None:
with tempfile.TemporaryDirectory() as directory:
project = Project.open(self.copy_fixture(Path(directory)))
result = generate_client_configuration(
project,
"codex",
manual_render_policy="disabled",
live_viewer_policy="disabled",
)
validator = Draft202012Validator(CONFIGURATION_SCHEMA)
validator.validate(result)
_validate_configuration_result(result)
for field, value in (
("schema_version", 1),
("manual", "on-demand"),
("portable_graph", "auto"),
("live_viewer", "explicit"),
):
with self.subTest(field=field):
drifted = json.loads(json.dumps(result))
drifted["projection_policy"][field] = value
self.assertTrue(list(validator.iter_errors(drifted)))
missing = json.loads(json.dumps(result))
missing.pop("projection_policy")
self.assertTrue(list(validator.iter_errors(missing)))
extra = json.loads(json.dumps(result))
extra["projection_policy"]["project_path"] = "/private/project"
self.assertTrue(list(validator.iter_errors(extra)))
mismatched = json.loads(json.dumps(result))
mismatched["projection_policy"]["manual"] = "explicit"
canonical = json.dumps(
mismatched["projection_policy"],
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
mismatched["projection_policy_hash"] = hashlib.sha256(canonical).hexdigest()
with self.assertRaises(AssertionError):
_validate_configuration_result(mismatched)
bad_hash = json.loads(json.dumps(result))
bad_hash["projection_policy_hash"] = "0" * 64
with self.assertRaises(AssertionError):
_validate_configuration_result(bad_hash)
defaulted = generate_client_configuration(project, "codex")
self.assertNotIn(
"--manual-render-policy",
defaulted["binding"]["args"],
)
defaulted["projection_policy"]["manual"] = "disabled"
canonical = json.dumps(
defaulted["projection_policy"],
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
defaulted["projection_policy_hash"] = hashlib.sha256(canonical).hexdigest()
with self.assertRaises(AssertionError):
_validate_configuration_result(defaulted)
unavailable = json.loads(json.dumps(result))
unavailable["projection_availability"]["manual_configured"] = False
with self.assertRaises(AssertionError):
_validate_configuration_result(unavailable)
descriptor = project.descriptor.descriptor_path
descriptor.write_text(
descriptor.read_text(encoding="utf-8") + GRAPH_RENDER_CONFIG,
encoding="utf-8",
)
graph_project = Project.open(project.descriptor.root)
graph_defaulted = generate_client_configuration(graph_project, "codex")
self.assertNotIn(
"--portable-graph-policy",
graph_defaulted["binding"]["args"],
)
coordinated_graph_drift = json.loads(json.dumps(graph_defaulted))
coordinated_graph_drift["projection_policy"]["portable_graph"] = "disabled"
coordinated_graph_drift["projection_availability"]["portable_graph_configured"] = False
canonical = json.dumps(
coordinated_graph_drift["projection_policy"],
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
coordinated_graph_drift["projection_policy_hash"] = hashlib.sha256(
canonical
).hexdigest()
coordinated_graph_drift["configuration_hash"] = document_hash(
{
"schema_version": 1,
"client": coordinated_graph_drift["client"],
"server_name": coordinated_graph_drift["server_name"],
"project": coordinated_graph_drift["project"],
"binding": coordinated_graph_drift["binding"],
"effective_policy": coordinated_graph_drift["effective_policy"],
"projection_policy": coordinated_graph_drift["projection_policy"],
"projection_policy_hash": coordinated_graph_drift["projection_policy_hash"],
"projection_availability": coordinated_graph_drift["projection_availability"],
"artifact_format": coordinated_graph_drift["artifact"]["format"],
"artifact_content_sha256": coordinated_graph_drift["artifact"][
"content_sha256"
],
}
)
with self.assertRaises(AssertionError):
_validate_configuration_result(coordinated_graph_drift)
invalid_selections = (
{"manual_render_policy": "sometimes"},
{"portable_graph_policy": "auto"},
{"live_viewer_policy": "always"},
)
for selection in invalid_selections:
with self.subTest(selection=selection):
with self.assertRaises(DocForgeError) as raised:
generate_client_configuration(graph_project, "codex", **selection)
self.assertEqual("invalid_projection_policy", raised.exception.code)
def test_doctor_round_trips_projection_selectors_and_rejects_invalid_modes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
project = Project.open(self.copy_fixture(parent))
ProjectIndex(project).build()
config = parent / "openclaw.json"
generated = generate_client_configuration(
project,
"openclaw",
manual_render_policy="disabled",
live_viewer_policy="disabled",
output=config,
)
healthy = run_doctor(
project,
"openclaw",
config_path=config,
server_name=generated["server_name"],
)
Draft202012Validator(DOCTOR_SCHEMA).validate(healthy)
self.assertEqual("healthy", healthy["doctor_state"])
self.assertIn(
"effective_policy_valid",
[check["code"] for check in healthy["checks"]],
)
document = json.loads(config.read_text(encoding="utf-8"))
entry = document["mcp"]["servers"][generated["server_name"]]
arguments = entry["args"]
manual_position = arguments.index("--manual-render-policy") + 1
arguments[manual_position] = "sometimes"
config.write_text(json.dumps(document, sort_keys=True), encoding="utf-8")
invalid = run_doctor(
project,
"openclaw",
config_path=config,
server_name=generated["server_name"],
)
Draft202012Validator(DOCTOR_SCHEMA).validate(invalid)
self.assertEqual("unhealthy", invalid["doctor_state"])
policy_check = next(
check for check in invalid["checks"] if check["check_id"] == "policy.effective"
)
self.assertEqual("invalid_projection_policy", policy_check["code"])
arguments[manual_position] = "auto"
config.write_text(json.dumps(document, sort_keys=True), encoding="utf-8")
unavailable = run_doctor(
project,
"openclaw",
config_path=config,
server_name=generated["server_name"],
)
Draft202012Validator(DOCTOR_SCHEMA).validate(unavailable)
self.assertEqual("unhealthy", unavailable["doctor_state"])
policy_check = next(
check for check in unavailable["checks"] if check["check_id"] == "policy.effective"
)
self.assertEqual("projection_policy_unavailable", policy_check["code"])
def test_explicit_fragment_write_is_atomic_conflict_aware_and_private(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)

View file

@ -1,404 +0,0 @@
from __future__ import annotations
import json
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from typing import Any, cast
from docforge.errors import DocForgeError
from docforge.graph_projection import (
GraphViewRequestV1,
build_graph_view_plan,
)
from docforge.models import Edge, Limits, Node, ProjectDescriptor, ProjectSnapshot
from docforge.projection_contract import GraphViewPlanV1
def _document(plan: GraphViewPlanV1) -> dict[str, Any]:
return cast(dict[str, Any], plan.as_dict())
def _node(
node_id: str,
*,
title: str | None = None,
family: str = "code",
authority: str = "derived",
status: str = "active",
tags: tuple[str, ...] = (),
) -> Node:
return Node(
node_id=node_id,
title=title or node_id,
family=family,
authority=authority,
status=status,
tags=tags,
summary=f"Summary for {node_id}",
content=f"SECRET SOURCE BODY {node_id}",
source_path=f"/private/source/{node_id}.py",
source_anchor=f"line-{len(node_id)}",
content_hash=(node_id.encode("utf-8").hex() + "0" * 64)[:64],
)
def _snapshot(
root: Path,
nodes: tuple[Node, ...],
edges: tuple[Edge, ...],
) -> ProjectSnapshot:
descriptor = ProjectDescriptor(
schema_version=1,
project_id="graph-project",
title="Graph project",
adapter="generic",
root=root,
descriptor_path=root / ".docforge" / "project.toml",
descriptor_hash="d" * 64,
content_roots=(root / "docs",),
authority_files=(),
cache_root=root / ".docforge" / "cache",
index_path=root / ".docforge" / "cache" / "index.sqlite3",
changeset_root=root / ".docforge" / "changesets",
proposal_writers=(),
render=None,
allowed_relations=tuple(sorted({edge.relation for edge in edges})),
profiles=(),
limits=Limits(),
)
return ProjectSnapshot(
descriptor=descriptor,
nodes=nodes,
edges=edges,
revision="revision-1",
source_hash="a" * 64,
)
class GraphProjectionTests(unittest.TestCase):
def test_exact_root_plan_is_deterministic_sorted_and_path_free(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
nodes = (
_node("c", family="docs", tags=("python",)),
_node("a", tags=("python", "callable")),
_node("b", tags=("python",)),
_node("unrelated"),
)
edges = (
Edge("b", "calls", "c"),
Edge("a", "calls", "b"),
Edge("unrelated", "calls", "c"),
)
request = GraphViewRequestV1(
view_id="architecture",
title="Architecture",
root_node_id="a",
depth=2,
max_nodes=10,
max_edges=10,
max_work=100,
)
first = build_graph_view_plan(_snapshot(root, nodes, edges), request, True)
second = build_graph_view_plan(
_snapshot(root, tuple(reversed(nodes)), tuple(reversed(edges))),
request,
True,
)
self.assertEqual(first.as_dict(), second.as_dict())
GraphViewPlanV1.from_dict(first.as_dict())
document = _document(first)
self.assertEqual(
["a", "b", "c"], [node["node_id"] for node in document["graph"]["nodes"]]
)
self.assertEqual(
[
{"source_id": "a", "relation": "calls", "target_id": "b"},
{"source_id": "b", "relation": "calls", "target_id": "c"},
],
document["graph"]["edges"],
)
encoded = json.dumps(document, sort_keys=True)
self.assertNotIn(str(root), encoded)
self.assertNotIn("SECRET SOURCE BODY", encoded)
self.assertNotIn("/private/source/", encoded)
self.assertNotIn("line-1", encoded)
self.assertEqual("excluded", document["policy"]["source_paths"])
self.assertEqual("excluded", document["policy"]["source_bodies"])
self.assertEqual("allowed", document["policy"]["logic"])
self.assertEqual("exact_root", document["view"]["scope"]["kind"])
def test_lexical_scope_uses_metadata_only_and_closed_filters(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
nodes = (
_node(
"api.handler",
title="Request handler",
family="code",
authority="derived",
tags=("python", "route"),
),
_node(
"api.test",
title="Handler proof",
family="test",
authority="approved_plan",
tags=("python", "test"),
),
replace(
_node("hidden.body", family="code", tags=("python",)),
content="request handler appears only in the forbidden source body",
),
)
edges = (
Edge("api.handler", "tested_by", "api.test"),
Edge("hidden.body", "relates_to", "api.handler"),
)
request = GraphViewRequestV1(
view_id="routes",
title="Routes",
query="request handler",
families=("code",),
authorities=("derived",),
tags=("python", "route"),
relations=("tested_by",),
max_nodes=10,
max_edges=10,
max_work=100,
)
plan = build_graph_view_plan(_snapshot(root, nodes, edges), request, False)
document = _document(plan)
self.assertEqual(
["api.handler"],
[node["node_id"] for node in document["graph"]["nodes"]],
)
self.assertEqual([], document["graph"]["edges"])
self.assertEqual(
{
"families": ["code"],
"relations": ["tested_by"],
"authorities": ["derived"],
"statuses": [],
"tags": ["python", "route"],
},
document["view"]["filters"],
)
self.assertEqual("lexical", document["view"]["scope"]["kind"])
def test_result_and_work_limits_emit_explicit_omissions(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
nodes = tuple(_node(value) for value in ("a", "b", "c", "d"))
edges = (
Edge("a", "calls", "b"),
Edge("a", "calls", "c"),
Edge("a", "calls", "d"),
Edge("b", "calls", "c"),
)
result_limited = build_graph_view_plan(
_snapshot(root, nodes, edges),
GraphViewRequestV1(
view_id="limited",
title="Limited",
root_node_id="a",
max_nodes=2,
max_edges=0,
max_work=100,
),
False,
)
result_limited = _document(result_limited)
self.assertEqual(
["a", "b"], [node["node_id"] for node in result_limited["graph"]["nodes"]]
)
self.assertEqual([], result_limited["graph"]["edges"])
self.assertEqual(
["edge_result_limit", "node_result_limit"],
[item["code"] for item in result_limited["omissions"]],
)
work_limited = build_graph_view_plan(
_snapshot(root, nodes, edges),
GraphViewRequestV1(
view_id="work",
title="Work",
root_node_id="a",
max_nodes=10,
max_edges=10,
max_work=1,
),
False,
)
work_limited = _document(work_limited)
self.assertIn(
"work_limit",
[item["code"] for item in work_limited["omissions"]],
)
self.assertEqual(
1,
work_limited["diagnostics"]["examined_work_units"],
)
self.assertTrue(work_limited["diagnostics"]["truncated"])
def test_no_ast_policy_excludes_requested_logic(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
snapshot = _snapshot(root, (_node("a"),), ())
request = GraphViewRequestV1(
view_id="logic",
title="Logic",
root_node_id="a",
initial_mode="logic",
include_logic=True,
)
blocked = _document(build_graph_view_plan(snapshot, request, False))
self.assertEqual("forbidden", blocked["policy"]["logic"])
self.assertEqual([], blocked["graph"]["logic_projections"])
self.assertIn(
"logic_forbidden",
[item["code"] for item in blocked["omissions"]],
)
allowed = _document(build_graph_view_plan(snapshot, request, True))
self.assertEqual("allowed", allowed["policy"]["logic"])
self.assertNotIn(
"logic_forbidden",
[item["code"] for item in allowed["omissions"]],
)
def test_edge_and_node_filters_constrain_exact_root_bfs(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
nodes = (
_node("root", family="code", status="active"),
_node("code-child", family="code", status="active"),
_node("doc-child", family="docs", status="active"),
_node("old-child", family="code", status="historical"),
)
edges = (
Edge("root", "calls", "code-child"),
Edge("root", "documents", "doc-child"),
Edge("root", "calls", "old-child"),
)
plan = build_graph_view_plan(
_snapshot(root, nodes, edges),
GraphViewRequestV1(
view_id="filtered",
title="Filtered",
root_node_id="root",
families=("code",),
statuses=("active",),
relations=("calls",),
max_work=100,
),
False,
)
plan = _document(plan)
self.assertEqual(
["code-child", "root"], [node["node_id"] for node in plan["graph"]["nodes"]]
)
self.assertEqual(
[{"source_id": "root", "relation": "calls", "target_id": "code-child"}],
plan["graph"]["edges"],
)
def test_invalid_requests_and_graphs_fail_closed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
snapshot = _snapshot(root, (_node("a"),), ())
invalid_requests = (
GraphViewRequestV1(view_id="v", title="V"),
GraphViewRequestV1(
view_id="v",
title="V",
root_node_id="a",
query="a",
),
GraphViewRequestV1(
view_id="v",
title="V",
root_node_id="a",
max_nodes=0,
),
GraphViewRequestV1(
view_id="v",
title="V",
query="***",
),
GraphViewRequestV1(
view_id="v",
title="V",
root_node_id="a",
families=("code", "code"),
),
)
for request in invalid_requests:
with self.subTest(request=request), self.assertRaises(DocForgeError) as error:
build_graph_view_plan(snapshot, request, False)
self.assertEqual("invalid_graph_view_request", error.exception.code)
with self.assertRaises(DocForgeError) as missing:
build_graph_view_plan(
snapshot,
GraphViewRequestV1(
view_id="v",
title="V",
root_node_id="missing",
),
False,
)
self.assertEqual("missing_node", missing.exception.code)
duplicate = _snapshot(root, (_node("a"), _node("a")), ())
with self.assertRaises(DocForgeError) as invalid:
build_graph_view_plan(
duplicate,
GraphViewRequestV1(
view_id="v",
title="V",
root_node_id="a",
),
False,
)
self.assertEqual("invalid_projection", invalid.exception.code)
def test_plan_identity_changes_with_generation_request_and_policy(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
snapshot = _snapshot(root, (_node("a"),), ())
request = GraphViewRequestV1(
view_id="v",
title="V",
root_node_id="a",
)
base = build_graph_view_plan(snapshot, request, False)
self.assertEqual(
base.plan_id,
build_graph_view_plan(snapshot, request, False).plan_id,
)
self.assertNotEqual(
base.plan_id,
build_graph_view_plan(
replace(snapshot, source_hash="b" * 64),
request,
False,
).plan_id,
)
self.assertNotEqual(
base.plan_id,
build_graph_view_plan(
snapshot,
replace(request, title="Other"),
False,
).plan_id,
)
self.assertNotEqual(
base.plan_id,
build_graph_view_plan(snapshot, request, True).plan_id,
)
if __name__ == "__main__":
unittest.main()

View file

@ -1,344 +0,0 @@
from __future__ import annotations
import json
import shutil
import tempfile
import tomllib
import unittest
from pathlib import Path
from unittest import mock
from jsonschema import Draft202012Validator
from docforge.errors import DocForgeError
from docforge.graph_rendering import GraphRenderService
from docforge.models import ProjectState
from docforge.project import Project
from docforge.projection_contract import projection_hash
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
PROJECT_SCHEMA = json.loads((ROOT / "schemas/project.schema.json").read_text(encoding="utf-8"))
GRAPH_CONFIG = """
[graph_render]
output_root = ".docforge/portable-graph"
[[graph_render.views]]
id = "architecture"
renderer = "portable_graph_html"
output = "architecture.html"
title = "Alpha architecture"
root = "guide.workflow"
initial_mode = "nodes"
depth = 2
max_nodes = 20
max_edges = 40
max_work = 1000
families = ["guide", "proof"]
relations = ["depends_on", "proves"]
authorities = []
statuses = []
tags = []
include_logic = false
"""
class GraphPublicationTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name) / "alpha"
shutil.copytree(FIXTURES / "alpha", self.root)
descriptor = self.root / ".docforge/project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG,
encoding="utf-8",
)
self.project = Project.open(self.root)
self.service = GraphRenderService(self.project)
def test_descriptor_schema_and_runtime_accept_the_separate_graph_view(self) -> None:
document = tomllib.loads((self.root / ".docforge/project.toml").read_text(encoding="utf-8"))
Draft202012Validator(PROJECT_SCHEMA).validate(document)
config = self.project.descriptor.graph_render
assert config is not None
self.assertEqual(self.root / ".docforge/portable-graph", config.output_root)
self.assertEqual("architecture", config.views[0].view_id)
self.assertEqual("guide.workflow", config.views[0].root_node_id)
self.assertIsNone(config.views[0].query)
def test_render_publication_is_deterministic_durable_and_unchanged_on_reuse(self) -> None:
missing = self.service.status("architecture")
self.assertEqual("stale", missing["state"])
self.assertEqual("missing", missing["outputs"][0]["state"])
first = self.service.render("architecture")
output = self.root / ".docforge/portable-graph/architecture.html"
manifest = self.root / ".docforge/cache/projection-publications/graph/architecture.json"
artifact_root = self.root / ".docforge/cache/projection-artifacts"
self.assertEqual("current", first["state"])
self.assertEqual("published", first["publication"])
self.assertTrue(output.is_file())
self.assertTrue(manifest.is_file())
self.assertEqual(1, len(tuple(artifact_root.glob("*.html"))))
before = output.stat()
before_bytes = output.read_bytes()
current = self.service.status("architecture")
self.assertEqual("current", current["state"])
self.assertEqual("manifest", current["outputs"][0]["verification"])
second = self.service.render("architecture")
after = output.stat()
self.assertEqual("unchanged", second["publication"])
self.assertEqual(before_bytes, output.read_bytes())
self.assertEqual((before.st_dev, before.st_ino), (after.st_dev, after.st_ino))
def test_status_is_manifest_only_and_detects_source_output_and_manifest_changes(self) -> None:
self.service.render("architecture")
output = self.root / ".docforge/portable-graph/architecture.html"
manifest = self.root / ".docforge/cache/projection-publications/graph/architecture.json"
with mock.patch.object(
self.project,
"load",
side_effect=AssertionError("status must not load or plan"),
):
self.assertEqual("current", self.service.status("architecture")["state"])
output.write_bytes(output.read_bytes() + b"\n")
changed_output = self.service.status("architecture")
self.assertEqual("stale", changed_output["state"])
self.assertEqual("output_changed", changed_output["outputs"][0]["reason"])
self.service.render("architecture")
source = self.root / "docs/content/workflow.md"
source.write_text(
source.read_text(encoding="utf-8") + "\nChanged after publication.\n",
encoding="utf-8",
)
changed_source = self.service.status("architecture")
self.assertEqual("stale", changed_source["state"])
self.assertEqual("stale", changed_source["outputs"][0]["state"])
self.assertEqual(
"source_generation_changed",
changed_source["outputs"][0]["reason"],
)
self.service.render("architecture")
manifest.write_text("{bad-json", encoding="utf-8")
corrupt = self.service.status("architecture")
self.assertEqual("missing", corrupt["outputs"][0]["state"])
def test_status_validates_nested_manifest_and_artifact_store_evidence(self) -> None:
self.service.render("architecture")
manifest_path = (
self.root / ".docforge/cache/projection-publications/graph/architecture.json"
)
original = json.loads(manifest_path.read_text(encoding="utf-8"))
mutations = {
"bad_project": lambda value: value.__setitem__("project", "bad"),
"forged_artifact": lambda value: value.__setitem__(
"artifact",
{
"artifact_id": "portable-graph.html",
"media_type": "text/html; charset=utf-8",
"sha256": "0" * 64,
"bytes": 1,
},
),
"bad_store": lambda value: value["store"].__setitem__("size", -1),
}
for name, mutate in mutations.items():
with self.subTest(name=name):
value = json.loads(json.dumps(original))
mutate(value)
value.pop("publication_id")
value["publication_id"] = projection_hash(value)
manifest_path.write_text(
json.dumps(value, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
status = self.service.status("architecture")
self.assertEqual("unverified", status["outputs"][0]["state"])
self.assertEqual("manifest_invalid", status["outputs"][0]["reason"])
manifest_path.write_text(
json.dumps(original, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
artifact = next((self.root / ".docforge/cache/projection-artifacts").glob("*.html"))
artifact.unlink()
missing = self.service.status("architecture")
self.assertEqual("stale", missing["outputs"][0]["state"])
self.assertEqual("artifact_store_missing", missing["outputs"][0]["reason"])
repaired = self.service.render("architecture")
self.assertEqual("current", repaired["state"])
self.assertTrue(artifact.is_file())
def test_status_detects_source_and_publication_races(self) -> None:
self.service.render("architecture")
current = self.project.incremental_state()
assert current is not None
changed = ProjectState(source_hash="0" * 64, revision="changed")
with mock.patch.object(
self.project,
"incremental_state",
side_effect=(current, changed),
):
raced_source = self.service.status("architecture")
self.assertEqual("stale", raced_source["state"])
self.assertEqual(
"source_changed_during_status",
raced_source["outputs"][0]["reason"],
)
baseline = self.service._manifest_status(
self.project.descriptor.graph_render.views[0], # type: ignore[union-attr]
current,
)
replaced = dict(baseline)
replaced["publication_id"] = "f" * 64
with mock.patch.object(
self.service,
"_manifest_status",
side_effect=(baseline, replaced),
):
raced_publication = self.service.status("architecture")
self.assertEqual("stale", raced_publication["state"])
self.assertEqual(
"publication_changed_during_status",
raced_publication["outputs"][0]["reason"],
)
def test_manifest_failure_after_output_is_degraded_success(self) -> None:
with mock.patch.object(
self.service,
"_publish_manifest",
side_effect=DocForgeError(
"publication_failure",
"Synthetic manifest failure",
),
):
result = self.service.render("architecture")
self.assertEqual("degraded", result["state"])
self.assertEqual("published", result["publication"])
self.assertEqual("manifest", result["committed_stage"])
self.assertTrue((self.root / ".docforge/portable-graph/architecture.html").is_file())
self.assertEqual("failed", result["manifest"]["state"])
def test_post_commit_stage_failures_are_degraded_and_precommit_failures_raise(self) -> None:
committed = DocForgeError(
"publication_failure",
"Synthetic committed failure",
mutation_committed=True,
)
cases = (
("_publish_artifact", "artifact_store", "partial", False),
("_publish_output", "output", "published", None),
)
for method, stage, publication, output_exists in cases:
with self.subTest(method=method):
root = Path(self.temporary.name) / method
shutil.copytree(FIXTURES / "alpha", root)
descriptor = root / ".docforge/project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG,
encoding="utf-8",
)
service = GraphRenderService(Project.open(root))
with mock.patch.object(service, method, side_effect=committed):
result = service.render("architecture")
self.assertEqual("degraded", result["state"])
self.assertEqual(stage, result["committed_stage"])
self.assertEqual(publication, result["publication"])
if output_exists is not None:
self.assertEqual(
output_exists,
(root / ".docforge/portable-graph/architecture.html").exists(),
)
uncommitted = DocForgeError(
"publication_failure",
"Synthetic precommit failure",
mutation_committed=False,
)
with (
mock.patch.object(
self.service,
"_publish_output",
side_effect=uncommitted,
),
self.assertRaises(DocForgeError),
):
self.service.render("architecture")
def test_configuration_rejects_unsafe_ambiguous_and_overlapping_views(self) -> None:
cases = {
"both_scope": GRAPH_CONFIG.replace(
'root = "guide.workflow"',
'root = "guide.workflow"\nquery = "workflow"',
),
"output_overlap": GRAPH_CONFIG.replace(
'output_root = ".docforge/portable-graph"',
'output_root = "docs/content"',
),
"active_renderer": GRAPH_CONFIG.replace(
'renderer = "portable_graph_html"',
'renderer = "shell"',
),
"oversized": GRAPH_CONFIG.replace("max_nodes = 20", "max_nodes = 100000"),
"logic_mode": GRAPH_CONFIG.replace('initial_mode = "nodes"', 'initial_mode = "logic"'),
"logic_projection": GRAPH_CONFIG.replace(
"include_logic = false",
"include_logic = true",
),
"long_title": GRAPH_CONFIG.replace(
'title = "Alpha architecture"',
f'title = "{"x" * 1025}"',
),
"long_query": GRAPH_CONFIG.replace(
'root = "guide.workflow"',
f'query = "{"x" * 10001}"',
),
"too_many_filters": GRAPH_CONFIG.replace(
'families = ["guide", "proof"]',
"families = [" + ", ".join(f'"family-{index}"' for index in range(65)) + "]",
),
}
for name, graph_config in cases.items():
with self.subTest(name=name):
root = Path(self.temporary.name) / name
shutil.copytree(FIXTURES / "alpha", root)
descriptor = root / ".docforge/project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8") + graph_config,
encoding="utf-8",
)
parsed = tomllib.loads(descriptor.read_text(encoding="utf-8"))
if name != "output_overlap":
self.assertFalse(Draft202012Validator(PROJECT_SCHEMA).is_valid(parsed))
with self.assertRaises(DocForgeError):
Project.open(root)
relocated = Path(self.temporary.name) / "descriptor-overlap"
shutil.copytree(FIXTURES / "alpha", relocated)
descriptor = relocated / ".docforge/project.toml"
base = descriptor.read_text(encoding="utf-8").replace(
'cache_root = ".docforge/cache"\nindex = ".docforge/cache/index.sqlite3"',
'cache_root = "var/cache"\nindex = "var/cache/index.sqlite3"',
)
descriptor.write_text(
base
+ GRAPH_CONFIG.replace(
'output_root = ".docforge/portable-graph"',
'output_root = ".docforge"',
),
encoding="utf-8",
)
with self.assertRaises(DocForgeError):
Project.open(relocated)
if __name__ == "__main__":
unittest.main()

View file

@ -1,284 +0,0 @@
from __future__ import annotations
import copy
import hashlib
import json
import os
import shutil
import sqlite3
import tempfile
import unittest
from pathlib import Path
from typing import cast
from unittest import mock
from docforge.errors import DocForgeError
from docforge.graph_projection import (
GraphViewRequestV1,
build_graph_projection_package,
build_graph_view_plan,
)
from docforge.project import Project
from docforge.projection_contract import ProjectionPackageV1
from docforge_renderers.graph import PortableGraphHtmlRenderer
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
class PortableGraphRenderingTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name) / "alpha"
shutil.copytree(FIXTURES / "alpha", self.root)
self.project = Project.open(self.root)
self.snapshot = self.project.load()
self.request = GraphViewRequestV1(
view_id="architecture",
title="Alpha architecture",
root_node_id="guide.workflow",
depth=2,
max_nodes=20,
max_edges=40,
max_work=1_000,
)
self.plan = build_graph_view_plan(self.snapshot, self.request, False)
self.package = build_graph_projection_package(
self.plan,
renderer_id=PortableGraphHtmlRenderer.renderer_id,
renderer_version=PortableGraphHtmlRenderer.renderer_version,
max_output_bytes=1_000_000,
)
def test_portable_artifact_is_deterministic_self_contained_and_generation_bound(self) -> None:
renderer = PortableGraphHtmlRenderer()
first = renderer.render(self.package)
second = renderer.render(self.package)
self.assertEqual(first.artifacts, second.artifacts)
self.assertEqual(1, len(first.artifacts))
artifact = first.artifacts[0]
self.assertEqual("portable-graph.html", artifact.artifact_id)
self.assertEqual("text/html; charset=utf-8", artifact.media_type)
receipt = first.receipt.as_dict()
evidence = cast(list[dict[str, object]], receipt["artifacts"])
self.assertEqual(
hashlib.sha256(artifact.content).hexdigest(),
evidence[0]["sha256"],
)
rendered = artifact.content.decode("utf-8")
self.assertIn("Content-Security-Policy", rendered)
self.assertIn("default-src &#x27;none&#x27;", rendered)
self.assertIn('type="application/json"', rendered)
self.assertNotIn(str(self.root), rendered)
self.assertNotIn("docs/content/", rendered)
self.assertNotIn("Editors change canonical nodes", rendered)
self.assertNotIn("fetch(", rendered)
self.assertNotIn("XMLHttpRequest", rendered)
self.assertNotIn("WebSocket", rendered)
self.assertIn(self.snapshot.source_hash, rendered)
def test_embedded_plan_is_exact_and_script_breakout_is_inert(self) -> None:
plan = copy.deepcopy(self.plan.as_dict())
view = plan["view"]
assert isinstance(view, dict)
view["title"] = '</script><script id="injected">alert(1)</script>'
plan.pop("plan_id")
from docforge.projection_contract import GraphViewPlanV1
malicious = GraphViewPlanV1.create(plan)
package = build_graph_projection_package(
malicious,
renderer_id=PortableGraphHtmlRenderer.renderer_id,
renderer_version=PortableGraphHtmlRenderer.renderer_version,
max_output_bytes=1_000_000,
)
rendered = PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8")
self.assertNotIn('<script id="injected">', rendered)
self.assertIn("&lt;/script&gt;", rendered)
prefix = '<script id="docforge-graph-plan" type="application/json">'
embedded = rendered.split(prefix, 1)[1].split("</script>", 1)[0]
self.assertEqual(malicious.as_dict(), json.loads(embedded))
def test_renderer_has_no_project_database_or_filesystem_write_capability(self) -> None:
forbidden = AssertionError("portable graph renderer crossed its capability boundary")
with (
mock.patch.object(Project, "open", side_effect=forbidden),
mock.patch.object(Project, "load", side_effect=forbidden),
mock.patch.object(sqlite3, "connect", side_effect=forbidden),
mock.patch.object(Path, "write_bytes", side_effect=forbidden),
mock.patch.object(Path, "write_text", side_effect=forbidden),
mock.patch.object(Path, "mkdir", side_effect=forbidden),
mock.patch.object(os, "replace", side_effect=forbidden),
mock.patch.object(os, "rename", side_effect=forbidden),
mock.patch.object(os, "unlink", side_effect=forbidden),
):
result = PortableGraphHtmlRenderer().render(self.package)
self.assertEqual(1, len(result.artifacts))
def test_renderer_rejects_wrong_kind_identity_assets_and_size(self) -> None:
wrong = ProjectionPackageV1.create(
kind="graph",
plan=self.plan,
renderer={
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
"renderer_version": "other",
},
components=[],
assets=[],
output_policy={
"artifact_ids": ["portable-graph.html"],
"max_total_bytes": 1_000_000,
},
)
with self.assertRaises(DocForgeError) as unsupported:
PortableGraphHtmlRenderer().render(wrong)
self.assertEqual("unsupported_renderer", unsupported.exception.code)
with_asset = ProjectionPackageV1.create(
kind="graph",
plan=self.plan,
renderer={
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
"renderer_version": PortableGraphHtmlRenderer.renderer_version,
},
components=[],
assets=[
{
"asset_id": "project-script",
"media_type": "text/javascript",
"sha256": "0" * 64,
"text": "alert(1)",
}
],
output_policy={"artifact_ids": ["portable-graph.html"], "max_total_bytes": 1_000_000},
)
with self.assertRaises(DocForgeError) as assets:
PortableGraphHtmlRenderer().render(with_asset)
self.assertEqual("invalid_projection", assets.exception.code)
wrong_components = ProjectionPackageV1.create(
kind="graph",
plan=self.plan,
renderer={
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
"renderer_version": PortableGraphHtmlRenderer.renderer_version,
},
components=[],
assets=[],
output_policy={
"artifact_ids": ["portable-graph.html"],
"max_total_bytes": 1_000_000,
},
)
with self.assertRaises(DocForgeError) as components:
PortableGraphHtmlRenderer().render(wrong_components)
self.assertEqual("invalid_projection", components.exception.code)
wrong_artifact = ProjectionPackageV1.create(
kind="graph",
plan=self.plan,
renderer={
"renderer_id": PortableGraphHtmlRenderer.renderer_id,
"renderer_version": PortableGraphHtmlRenderer.renderer_version,
},
components=self.package.document["components"], # type: ignore[arg-type]
assets=[],
output_policy={
"artifact_ids": ["unexpected.html"],
"max_total_bytes": 1_000_000,
},
)
with self.assertRaises(DocForgeError) as artifact:
PortableGraphHtmlRenderer().render(wrong_artifact)
self.assertEqual("invalid_projection", artifact.exception.code)
tiny = build_graph_projection_package(
self.plan,
renderer_id=PortableGraphHtmlRenderer.renderer_id,
renderer_version=PortableGraphHtmlRenderer.renderer_version,
max_output_bytes=1,
)
with self.assertRaises(DocForgeError) as too_large:
PortableGraphHtmlRenderer().render(tiny)
self.assertEqual("render_too_large", too_large.exception.code)
def test_initial_mode_is_honored_and_logic_is_rejected(self) -> None:
for mode in ("nodes", "flow", "web"):
with self.subTest(mode=mode):
plan = build_graph_view_plan(
self.snapshot,
GraphViewRequestV1(
view_id=f"{mode}-view",
title=f"{mode.title()} view",
root_node_id="guide.workflow",
initial_mode=mode, # type: ignore[arg-type]
depth=2,
max_nodes=20,
max_edges=40,
max_work=1_000,
),
False,
)
package = build_graph_projection_package(
plan,
renderer_id=PortableGraphHtmlRenderer.renderer_id,
renderer_version=PortableGraphHtmlRenderer.renderer_version,
max_output_bytes=1_000_000,
)
rendered = (
PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8")
)
self.assertIn(f'data-mode="{mode}"', rendered)
self.assertIn(f'<option value="{mode}" selected>', rendered)
logic_plan = build_graph_view_plan(
self.snapshot,
GraphViewRequestV1(
view_id="logic-view",
title="Logic view",
root_node_id="guide.workflow",
initial_mode="logic",
include_logic=True,
),
True,
)
logic_package = build_graph_projection_package(
logic_plan,
renderer_id=PortableGraphHtmlRenderer.renderer_id,
renderer_version=PortableGraphHtmlRenderer.renderer_version,
max_output_bytes=1_000_000,
)
with self.assertRaises(DocForgeError) as logic:
PortableGraphHtmlRenderer().render(logic_package)
self.assertEqual("unsupported_renderer", logic.exception.code)
def test_artifact_exposes_accessible_static_and_interactive_structure(self) -> None:
rendered = (
PortableGraphHtmlRenderer().render(self.package).artifacts[0].content.decode("utf-8")
)
static = rendered.split('<script id="docforge-graph-plan"', 1)[0]
self.assertIn("Editing workflow (guide.workflow)", static)
self.assertIn("<td>guide.workflow</td>", static)
self.assertIn('data-panel="nodes"', static)
self.assertIn('data-panel="relationships"', static)
for expected in (
'href="#main"',
'<main id="main" tabindex="-1" data-mode="nodes">',
'role="group" aria-label="Graph controls"',
'role="status" aria-live="polite"',
'<th scope="col">Source</th>',
'<th scope="col">Relation</th>',
'<th scope="col">Target</th>',
'<dialog id="node-dialog"',
'aria-labelledby="node-dialog-title"',
'dialog.addEventListener("close", () => opener?.focus())',
"@media (prefers-reduced-motion: reduce)",
"@media (forced-colors: active)",
):
with self.subTest(expected=expected):
self.assertIn(expected, rendered)
if __name__ == "__main__":
unittest.main()

View file

@ -3,7 +3,6 @@ from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from docforge.cli import _parser, _run
from docforge.errors import DocForgeError
@ -11,28 +10,6 @@ from docforge.project import Project
class DocForgeOnboardingTests(unittest.TestCase):
def test_scaffold_honors_disabled_manual_projection_policy(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "disabled-render"
root.mkdir()
arguments = _parser().parse_args(
[
"--project-root",
str(root),
"--manual-render-policy",
"disabled",
"onboard",
"--scaffold",
]
)
with mock.patch(
"docforge.cli.RenderService.render",
side_effect=AssertionError("disabled onboarding must not render"),
):
result = _run(arguments)
self.assertEqual("skipped", result["render"]["state"])
self.assertFalse((root / ".docforge/rendered/manual.html").exists())
def test_assessment_detects_multiple_languages_without_writing(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "polyglot"

View file

@ -1,669 +0,0 @@
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 (
_cycles,
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.projection_fragments import FRAGMENT_CACHE_DIRECTORY, FragmentRecord
from docforge.projection_worker import render_projection_in_worker
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_cycle_detection_handles_the_maximum_deep_graph_iteratively(self) -> None:
node_ids = tuple(f"node.{index:05d}" for index in range(10_000))
chain = tuple(
Edge(node_ids[index], "depends_on", node_ids[index + 1])
for index in range(len(node_ids) - 1)
)
self.assertEqual([], _cycles(node_ids, chain))
self.assertEqual(
[list(node_ids)],
_cycles(
node_ids,
(*chain, Edge(node_ids[-1], "depends_on", node_ids[0])),
),
)
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_fragments_are_incremental_and_full_worker_output_is_the_oracle(
self,
) -> None:
renderer = GenericHtmlRenderer()
with mock.patch(
"docforge.render_contract.render_projection_in_worker",
wraps=render_projection_in_worker,
) as detached:
cold = renderer.prepare(
self.snapshot,
self.view,
self.template,
changeset_hash=None,
)
self.assertEqual(2, detached.call_count)
fragment_root = self.snapshot.descriptor.cache_root / FRAGMENT_CACHE_DIRECTORY
self.assertEqual(
len(self.plan.document["pages"]),
len(list(fragment_root.glob("*.json"))),
)
with mock.patch(
"docforge.render_contract.render_projection_in_worker",
wraps=render_projection_in_worker,
) as detached:
warm = renderer.prepare(
self.snapshot,
self.view,
self.template,
changeset_hash=None,
)
self.assertEqual(1, detached.call_count)
self.assertEqual(cold.output, warm.output)
with mock.patch(
"docforge.render_contract.render_projection_in_worker",
wraps=render_projection_in_worker,
) as detached:
full = GenericHtmlRenderer(incremental=False).prepare(
self.snapshot,
self.view,
self.template,
changeset_hash=None,
)
self.assertEqual(1, detached.call_count)
self.assertEqual(full.output, warm.output)
first_fragment = sorted(fragment_root.glob("*.json"))[0]
first_fragment.write_bytes(b"{corrupt")
with mock.patch(
"docforge.render_contract.render_projection_in_worker",
wraps=render_projection_in_worker,
) as detached:
recovered = renderer.prepare(
self.snapshot,
self.view,
self.template,
changeset_hash=None,
)
self.assertEqual(2, detached.call_count)
self.assertEqual(full.output, recovered.output)
original_record = FragmentRecord.from_bytes(first_fragment.read_bytes())
first_fragment.write_bytes(
FragmentRecord.create(
original_record.key,
b"<script>forged fragment</script>",
).to_bytes()
)
with mock.patch(
"docforge.render_contract.render_projection_in_worker",
wraps=render_projection_in_worker,
) as detached:
forged = renderer.prepare(
self.snapshot,
self.view,
self.template,
changeset_hash=None,
)
self.assertEqual(2, detached.call_count)
self.assertEqual(full.output, forged.output)
self.assertNotIn(b"forged fragment", forged.output)
def test_oversized_manual_fragment_bypasses_cache_and_uses_full_worker(self) -> None:
content = "x" * 4_100_000
node = replace(
self.snapshot.nodes[0],
content=content,
content_hash=hashlib.sha256(content.encode()).hexdigest(),
)
snapshot = replace(
self.snapshot,
descriptor=replace(
self.snapshot.descriptor,
limits=replace(
self.snapshot.descriptor.limits,
max_render_bytes=20_000_000,
),
),
nodes=(node,),
edges=(),
source_hash=hashlib.sha256(b"oversized-fragment").hexdigest(),
)
with mock.patch(
"docforge.render_contract.render_projection_in_worker",
wraps=render_projection_in_worker,
) as detached:
prepared = GenericHtmlRenderer().prepare(
snapshot,
self.view,
self.template,
changeset_hash=None,
)
self.assertEqual(1, detached.call_count)
full = GenericHtmlRenderer(incremental=False).prepare(
snapshot,
self.view,
self.template,
changeset_hash=None,
)
self.assertEqual(full.output, prepared.output)
def test_fragment_package_overflow_falls_back_to_full_worker(self) -> None:
content = "x" * 2_700_000
base = self.snapshot.nodes[0]
nodes = tuple(
replace(
base,
node_id=f"guide.large-{index}",
title=f"Large {index}",
content=content,
source_path=f"docs/content/large-{index}.md",
content_hash=hashlib.sha256(f"{index}:{content}".encode()).hexdigest(),
)
for index in range(4)
)
descriptor = replace(
self.snapshot.descriptor,
limits=replace(
self.snapshot.descriptor.limits,
max_render_bytes=20_000_000,
),
)
snapshot = replace(
self.snapshot,
descriptor=descriptor,
nodes=nodes,
edges=(),
source_hash=hashlib.sha256(b"large-fragment-fixture").hexdigest(),
)
incremental = GenericHtmlRenderer().prepare(
snapshot,
self.view,
self.template,
changeset_hash=None,
)
full = GenericHtmlRenderer(incremental=False).prepare(
snapshot,
self.view,
self.template,
changeset_hash=None,
)
self.assertGreater(len(full.output), 10_000_000)
self.assertEqual(full.output, incremental.output)
def test_manual_renderer_rejects_project_provided_active_content(self) -> None:
for active in (
"<script>alert(1)</script>{{ docforge_content }}",
'<main onclick="alert(1)">{{ docforge_content }}</main>',
'<a href="javascript:alert(1)">{{ docforge_content }}</a>',
'<iframe src="https://example.invalid"></iframe>{{ docforge_content }}',
'<meta http-equiv="refresh" content="0">{{ docforge_content }}',
):
with self.subTest(active=active):
package = build_manual_projection_package(
self.plan,
active.encode("utf-8"),
renderer_id="generic_html",
renderer_version=ALPHA_RENDERER_VERSION,
max_output_bytes=self.snapshot.descriptor.limits.max_render_bytes,
)
with self.assertRaises(DocForgeError) as rejected:
ManualHtmlRenderer(ALPHA_RENDERER_VERSION).render(package)
self.assertEqual("invalid_template", rejected.exception.code)
def test_manual_renderer_has_no_project_sqlite_or_path_write_capability(self) -> None:
renderer = ManualHtmlRenderer(ALPHA_RENDERER_VERSION)
forbidden = AssertionError("manual renderer crossed its capability boundary")
with (
mock.patch.object(Project, "open", side_effect=forbidden),
mock.patch.object(Project, "load", side_effect=forbidden),
mock.patch.object(sqlite3, "connect", side_effect=forbidden),
mock.patch.object(Path, "write_bytes", side_effect=forbidden),
mock.patch.object(Path, "write_text", side_effect=forbidden),
mock.patch.object(Path, "mkdir", side_effect=forbidden),
mock.patch.object(Path, "touch", side_effect=forbidden),
mock.patch.object(Path, "unlink", side_effect=forbidden),
mock.patch.object(Path, "rename", side_effect=forbidden),
mock.patch.object(Path, "replace", side_effect=forbidden),
mock.patch.object(os, "mkdir", side_effect=forbidden),
mock.patch.object(os, "makedirs", side_effect=forbidden),
mock.patch.object(os, "rename", side_effect=forbidden),
mock.patch.object(os, "replace", side_effect=forbidden),
mock.patch.object(os, "unlink", side_effect=forbidden),
):
result = renderer.render(
self.package,
render_identity=ALPHA_RENDER_IDENTITY,
)
self.assertEqual(1, len(result.artifacts))
self.assertEqual("manual.html", result.artifacts[0].artifact_id)
self.assertEqual(ALPHA_OUTPUT_HASH, result.artifacts[0].evidence()["sha256"])
def test_projection_artifact_evidence_is_canonical_and_content_free(self) -> None:
artifact = ProjectionArtifact(
artifact_id="manual.html",
media_type="text/html; charset=utf-8",
content=b"manual bytes",
)
self.assertEqual(
{
"artifact_id": "manual.html",
"media_type": "text/html; charset=utf-8",
"sha256": hashlib.sha256(b"manual bytes").hexdigest(),
"bytes": len(b"manual bytes"),
},
artifact.evidence(),
)
self.assertEqual(
PROJECTION_PACKAGE_CONTRACT,
self.package.as_dict()["contract"],
)
if __name__ == "__main__":
unittest.main()

View file

@ -1,205 +0,0 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from docforge.projection_fragments import (
FRAGMENT_CACHE_DIRECTORY,
FRAGMENT_KEY_CONTRACT,
FRAGMENT_RECORD_CONTRACT,
FRAGMENT_SCHEMA_VERSION,
FragmentKey,
FragmentRecord,
ProjectionFragmentCache,
fragment_semantic_hash,
)
class ProjectionFragmentCacheTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name) / "project"
self.root.mkdir()
self.cache_root = self.root / ".docforge" / "cache"
self.cache = ProjectionFragmentCache(
self.root,
self.cache_root,
maximum_content_bytes=1_000,
)
self.key = self.make_key({"node": "guide.alpha", "summary": "Alpha"})
@staticmethod
def make_key(
semantics: object,
*,
renderer_version: str = "1",
component_version: str = "graph.node@1",
) -> FragmentKey:
return FragmentKey.create(
projection_kind="graph",
renderer_id="portable_graph_html",
renderer_version=renderer_version,
component_version=component_version,
semantic_input_hash=fragment_semantic_hash(semantics),
)
@property
def entry_path(self) -> Path:
return self.cache_root / FRAGMENT_CACHE_DIRECTORY / f"{self.key.key_id}.json"
def test_miss_hit_and_unchanged_reuse_are_exact(self) -> None:
self.assertIsNone(self.cache.get(self.key))
with mock.patch(
"docforge.projection_fragments.atomic_replace_bytes_at",
wraps=__import__(
"docforge.projection_fragments",
fromlist=["atomic_replace_bytes_at"],
).atomic_replace_bytes_at,
) as atomic:
stored = self.cache.put(self.key, b"<li>Alpha</li>")
self.assertIsNotNone(stored)
self.assertEqual(1, atomic.call_count)
before = self.entry_path.stat()
repeated = self.cache.put(self.key, b"<li>Alpha</li>")
self.assertEqual(1, atomic.call_count)
self.assertEqual(stored, repeated)
self.assertEqual(stored, self.cache.get(self.key))
after = self.entry_path.stat()
self.assertEqual((before.st_dev, before.st_ino), (after.st_dev, after.st_ino))
def test_key_versions_and_complete_semantics_invalidate_independently(self) -> None:
self.assertIsNotNone(self.cache.put(self.key, b"alpha"))
variants = (
self.make_key(
{"node": "guide.alpha", "summary": "Alpha"},
renderer_version="2",
),
self.make_key(
{"node": "guide.alpha", "summary": "Alpha"},
component_version="graph.node@2",
),
self.make_key({"node": "guide.alpha", "summary": "Changed"}),
)
for variant in variants:
with self.subTest(key=variant):
self.assertNotEqual(self.key.key_id, variant.key_id)
self.assertIsNone(self.cache.get(variant))
def test_add_delete_and_reorder_change_sequence_semantics(self) -> None:
base = ["a", "b"]
added = ["a", "b", "c"]
deleted = ["a"]
reordered = ["b", "a"]
keys = [self.make_key(value) for value in (base, added, deleted, reordered)]
self.assertEqual(4, len({key.key_id for key in keys}))
self.assertEqual(
fragment_semantic_hash({"a": 1, "b": 2}),
fragment_semantic_hash({"b": 2, "a": 1}),
)
def test_corrupt_oversized_incompatible_and_foreign_entries_are_misses(self) -> None:
self.assertIsNotNone(self.cache.put(self.key, b"alpha"))
self.entry_path.write_bytes(b"{bad-json")
self.assertIsNone(self.cache.get(self.key))
self.assertIsNotNone(self.cache.put(self.key, b"repaired"))
repaired = self.cache.get(self.key)
self.assertIsNotNone(repaired)
assert repaired is not None
self.assertEqual(b"repaired", repaired.content)
self.entry_path.write_bytes(b"x" * (self.cache.maximum_record_bytes + 1))
self.assertIsNone(self.cache.get(self.key))
incompatible = FragmentRecord.create(self.key, b"alpha").as_dict()
incompatible["schema_version"] = FRAGMENT_SCHEMA_VERSION + 1
self.entry_path.write_bytes(
json.dumps(incompatible, sort_keys=True, separators=(",", ":")).encode()
)
self.assertIsNone(self.cache.get(self.key))
foreign_key = self.make_key({"node": "foreign"})
foreign = FragmentRecord.create(foreign_key, b"foreign").to_bytes()
self.entry_path.write_bytes(foreign)
self.assertIsNone(self.cache.get(self.key))
def test_symlinked_entry_and_cache_root_fail_closed_without_outside_writes(self) -> None:
self.entry_path.parent.mkdir(parents=True)
outside = Path(self.temporary.name) / "outside.json"
outside.write_bytes(FragmentRecord.create(self.key, b"outside").to_bytes())
self.entry_path.symlink_to(outside)
self.assertIsNone(self.cache.get(self.key))
self.assertIsNone(self.cache.put(self.key, b"replacement"))
self.assertEqual(b"outside", FragmentRecord.from_bytes(outside.read_bytes()).content)
outside_cache = Path(self.temporary.name) / "outside-cache"
escaped = ProjectionFragmentCache(self.root, outside_cache)
self.assertIsNone(escaped.put(self.key, b"escaped"))
self.assertFalse(outside_cache.exists())
def test_keys_and_records_have_deterministic_path_free_serialization(self) -> None:
same_key = self.make_key({"summary": "Alpha", "node": "guide.alpha"})
self.assertEqual(self.key, same_key)
first = FragmentRecord.create(self.key, b"\x00fragment\xff")
second = FragmentRecord.create(same_key, b"\x00fragment\xff")
self.assertEqual(first, second)
self.assertEqual(first.to_bytes(), second.to_bytes())
self.assertEqual(first, FragmentRecord.from_bytes(first.to_bytes()))
document = json.loads(first.to_bytes())
self.assertEqual(FRAGMENT_SCHEMA_VERSION, document["schema_version"])
self.assertEqual(FRAGMENT_RECORD_CONTRACT, document["contract"])
self.assertEqual(FRAGMENT_KEY_CONTRACT, document["key"]["contract"])
self.assertNotIn(str(self.root), first.to_bytes().decode("utf-8"))
self.assertEqual(first.byte_count, len(first.content))
self.assertEqual(64, len(first.content_sha256))
def test_cache_write_failure_is_a_miss_and_does_not_mutate_canonical_files(self) -> None:
canonical = self.root / "canonical.md"
canonical.write_text("canonical", encoding="utf-8")
with mock.patch(
"docforge.projection_fragments.atomic_replace_bytes_at",
side_effect=OSError("synthetic cache failure"),
):
self.assertIsNone(self.cache.put(self.key, b"fragment"))
self.assertIsNone(self.cache.get(self.key))
self.assertEqual("canonical", canonical.read_text(encoding="utf-8"))
def test_record_rejects_noncanonical_serialization_and_invalid_evidence(self) -> None:
record = FragmentRecord.create(self.key, b"alpha")
pretty = json.dumps(record.as_dict(), sort_keys=True, indent=2).encode()
with self.assertRaisesRegex(Exception, "canonically serialized"):
FragmentRecord.from_bytes(pretty)
tampered = record.as_dict()
tampered["byte_count"] = record.byte_count + 1
canonical = json.dumps(tampered, sort_keys=True, separators=(",", ":")).encode()
with self.assertRaisesRegex(Exception, "byte evidence"):
FragmentRecord.from_bytes(canonical)
def test_prune_retains_only_the_exact_bounded_active_inventory(self) -> None:
retained = self.key
stale = self.make_key({"node": "stale"})
self.assertIsNotNone(self.cache.put(retained, b"retained"))
self.assertIsNotNone(self.cache.put(stale, b"stale"))
foreign = self.entry_path.parent / "foreign.tmp"
foreign.write_bytes(b"foreign")
self.assertTrue(self.cache.prune((retained,)))
retained_record = self.cache.get(retained)
self.assertIsNotNone(retained_record)
assert retained_record is not None
self.assertEqual(b"retained", retained_record.content)
self.assertIsNone(self.cache.get(stale))
self.assertFalse(foreign.exists())
with mock.patch(
"docforge.projection_fragments.MAX_FRAGMENT_CACHE_BYTES",
1,
):
self.assertFalse(self.cache.prune((retained,)))
if __name__ == "__main__":
unittest.main()

View file

@ -1,266 +0,0 @@
from __future__ import annotations
import copy
import hashlib
import json
import unittest
from dataclasses import FrozenInstanceError
from pathlib import Path
from jsonschema import Draft202012Validator
from docforge.errors import DocForgeError
from docforge.projection_policy import (
ProjectionPolicyV2,
compose_projection_policy,
)
ROOT = Path(__file__).resolve().parents[1]
POLICY_SCHEMA = json.loads(
(ROOT / "schemas" / "projection-policy.schema.json").read_text(encoding="utf-8")
)
class ProjectionPolicyV2Tests(unittest.TestCase):
def test_compatible_defaults_are_independent_and_schema_valid(self) -> None:
cases = (
(
{
"manual_configured": False,
"portable_graph_configured": False,
"application_enabled": False,
},
("disabled", "disabled", "on-demand"),
),
(
{
"manual_configured": True,
"portable_graph_configured": False,
"application_enabled": False,
},
("explicit", "disabled", "on-demand"),
),
(
{
"manual_configured": True,
"portable_graph_configured": True,
"application_enabled": True,
},
("auto", "explicit", "on-demand"),
),
(
{
"manual_configured": True,
"portable_graph_configured": True,
"application_enabled": False,
"live_viewer_available": False,
},
("explicit", "explicit", "disabled"),
),
)
validator = Draft202012Validator(POLICY_SCHEMA)
for arguments, expected in cases:
with self.subTest(arguments=arguments):
policy = compose_projection_policy(**arguments)
self.assertEqual(
expected, (policy.manual, policy.portable_graph, policy.live_viewer)
)
validator.validate(policy.as_dict())
def test_explicit_selections_can_narrow_each_consumer_independently(self) -> None:
policy = compose_projection_policy(
manual="explicit",
portable_graph="disabled",
live_viewer="disabled",
manual_configured=True,
portable_graph_configured=True,
application_enabled=True,
)
self.assertEqual(
{
"schema_version": 2,
"manual": "explicit",
"portable_graph": "disabled",
"live_viewer": "disabled",
},
policy.as_dict(),
)
fully_disabled = compose_projection_policy(
manual="disabled",
portable_graph="disabled",
live_viewer="disabled",
manual_configured=False,
portable_graph_configured=False,
application_enabled=False,
live_viewer_available=False,
)
self.assertEqual("disabled", fully_disabled.manual)
self.assertEqual("disabled", fully_disabled.portable_graph)
self.assertEqual("disabled", fully_disabled.live_viewer)
def test_policy_is_frozen_and_hashes_exact_canonical_payload(self) -> None:
first = compose_projection_policy(
manual_configured=True,
portable_graph_configured=True,
application_enabled=False,
)
second = compose_projection_policy(
manual_configured=True,
portable_graph_configured=True,
application_enabled=False,
)
canonical = json.dumps(
first.as_dict(),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
self.assertEqual(first.as_dict(), second.as_dict())
self.assertEqual(hashlib.sha256(canonical).hexdigest(), first.policy_hash)
self.assertEqual(first.policy_hash, second.policy_hash)
changed = compose_projection_policy(
manual="disabled",
manual_configured=True,
portable_graph_configured=True,
application_enabled=False,
)
self.assertNotEqual(first.policy_hash, changed.policy_hash)
with self.assertRaises(FrozenInstanceError):
first.manual = "disabled" # type: ignore[misc]
def test_invalid_modes_and_direct_construction_fail_closed(self) -> None:
cases = (
("manual", {"manual": "sometimes"}),
("portable_graph", {"portable_graph": "auto"}),
("live_viewer", {"live_viewer": "always"}),
("manual", {"manual": 1}),
)
for expected_projection, selection in cases:
with self.subTest(selection=selection):
with self.assertRaises(DocForgeError) as raised:
compose_projection_policy(
**selection,
manual_configured=True,
portable_graph_configured=True,
application_enabled=True,
)
self.assertEqual("invalid_projection_policy", raised.exception.code)
self.assertEqual(
expected_projection,
raised.exception.details["projection"],
)
with self.assertRaises(DocForgeError) as direct:
ProjectionPolicyV2(
manual="automatic", # type: ignore[arg-type]
portable_graph="explicit",
live_viewer="on-demand",
)
self.assertEqual("invalid_projection_policy", direct.exception.code)
def test_resource_and_capability_unavailability_are_distinct(self) -> None:
cases = (
(
{"manual": "explicit"},
{
"manual_configured": False,
"portable_graph_configured": False,
"application_enabled": False,
},
"manual",
"manual_render_config",
),
(
{"manual": "auto"},
{
"manual_configured": True,
"portable_graph_configured": False,
"application_enabled": False,
},
"manual",
"canonical_application",
),
(
{"portable_graph": "explicit"},
{
"manual_configured": False,
"portable_graph_configured": False,
"application_enabled": False,
},
"portable_graph",
"portable_graph_render_config",
),
(
{"live_viewer": "on-demand"},
{
"manual_configured": False,
"portable_graph_configured": False,
"application_enabled": False,
"live_viewer_available": False,
},
"live_viewer",
"live_viewer_runtime",
),
)
for selection, availability, projection, required in cases:
with self.subTest(selection=selection):
with self.assertRaises(DocForgeError) as raised:
compose_projection_policy(**selection, **availability)
self.assertEqual("projection_policy_unavailable", raised.exception.code)
self.assertEqual(projection, raised.exception.details["projection"])
self.assertEqual(required, raised.exception.details["required"])
def test_availability_inputs_must_be_real_booleans(self) -> None:
cases = (
{"manual_configured": 1},
{"portable_graph_configured": 0},
{"application_enabled": "yes"},
{"live_viewer_available": None},
)
for replacement in cases:
arguments = {
"manual_configured": False,
"portable_graph_configured": False,
"application_enabled": False,
"live_viewer_available": True,
**replacement,
}
with self.subTest(replacement=replacement):
with self.assertRaises(DocForgeError) as raised:
compose_projection_policy(**arguments)
self.assertEqual("invalid_projection_policy", raised.exception.code)
def test_schema_rejects_every_runtime_contract_drift(self) -> None:
validator = Draft202012Validator(POLICY_SCHEMA)
valid = compose_projection_policy(
manual_configured=True,
portable_graph_configured=True,
application_enabled=True,
).as_dict()
invalid_documents: list[dict[str, object]] = []
for field in ("schema_version", "manual", "portable_graph", "live_viewer"):
missing = copy.deepcopy(valid)
missing.pop(field)
invalid_documents.append(missing)
for field, value in (
("schema_version", 1),
("manual", "on-demand"),
("portable_graph", "auto"),
("live_viewer", "explicit"),
):
changed = copy.deepcopy(valid)
changed[field] = value
invalid_documents.append(changed)
extra = copy.deepcopy(valid)
extra["project_path"] = "/private/project"
invalid_documents.append(extra)
for document in invalid_documents:
with self.subTest(document=document):
self.assertFalse(validator.is_valid(document))
if __name__ == "__main__":
unittest.main()

View file

@ -1,239 +0,0 @@
from __future__ import annotations
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from docforge.application import CanonicalApplicationService
from docforge.errors import DocForgeError
from docforge.graph_rendering import GraphRenderService
from docforge.index import ProjectIndex
from docforge.mcp_server import DocForgeService
from docforge.project import Project
from docforge.rendering import RenderService
from docforge.viewer_manager import ViewerManagerClient
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
GRAPH_CONFIG = """
[graph_render]
output_root = ".docforge/portable-graph"
[[graph_render.views]]
id = "architecture"
renderer = "portable_graph_html"
output = "architecture.html"
title = "Alpha architecture"
root = "guide.workflow"
initial_mode = "nodes"
depth = 2
max_nodes = 20
max_edges = 40
max_work = 1000
include_logic = false
"""
class ProjectionPolicyIntegrationTests(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)
def project(self, *, graph: bool = False) -> Project:
if graph:
descriptor = self.root / ".docforge/project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG,
encoding="utf-8",
)
return Project.open(self.root)
def test_disabled_manual_blocks_work_before_load_but_status_remains_receipt_only(self) -> None:
project = self.project()
service = RenderService(project, manual_policy="disabled")
with mock.patch.object(
project,
"load",
side_effect=AssertionError("disabled manual policy must fail before loading"),
):
status = service.status("manual")
self.assertEqual("stale", status["state"])
for operation in (
lambda: service.render("manual"),
lambda: service.deep_status("manual"),
lambda: service.preview("proposal", "manual"),
):
with self.subTest(operation=operation), self.assertRaises(DocForgeError) as raised:
operation()
self.assertEqual(
"projection_policy_forbids_operation",
raised.exception.code,
)
def test_disabled_graph_blocks_plan_and_render_but_status_remains_available(self) -> None:
project = self.project(graph=True)
service = GraphRenderService(project, portable_graph_policy="disabled")
with mock.patch.object(
project,
"load",
side_effect=AssertionError("disabled graph policy must fail before loading"),
):
status = service.status("architecture")
self.assertEqual("stale", status["state"])
for operation in (
lambda: service.plan("architecture"),
lambda: service.render("architecture"),
):
with self.subTest(operation=operation), self.assertRaises(DocForgeError) as raised:
operation()
self.assertEqual(
"projection_policy_forbids_operation",
raised.exception.code,
)
def test_live_viewer_disabled_blocks_start_before_index_work_but_allows_lifecycle(self) -> None:
project = self.project()
index = ProjectIndex(project)
client = ViewerManagerClient(index, live_viewer_policy="disabled")
with (
mock.patch.object(
index,
"check",
side_effect=AssertionError("disabled viewer must fail before index work"),
),
self.assertRaises(DocForgeError) as raised,
):
client.start(node_id="guide.workflow")
self.assertEqual("projection_policy_forbids_operation", raised.exception.code)
with mock.patch.object(
client,
"_lifecycle_request",
side_effect=(
{"status": "ok", "state": "not_running"},
{"status": "ok", "state": "stopped"},
),
):
self.assertEqual("not_running", client.status()["state"])
self.assertEqual("stopped", client.stop()["state"])
def test_application_auto_renders_and_explicit_or_disabled_skip_cleanly(self) -> None:
project = self.project()
for mode, expected_action, expected_calls in (
("auto", "rendered", 1),
("explicit", "skipped_explicit", 0),
("disabled", "skipped_disabled", 0),
):
with self.subTest(mode=mode):
service = CanonicalApplicationService(
project,
applier_id="alpha-editor",
applier=mock.Mock(),
manual_policy=mode, # type: ignore[arg-type]
)
with (
mock.patch.object(
service.changesets,
"apply",
return_value={"status": "ok", "applied": True},
),
mock.patch.object(
service.index,
"build",
return_value={"status": "ok"},
),
mock.patch.object(
service.index,
"check",
return_value={"status": "ok"},
),
mock.patch.object(
service.rendering,
"render",
return_value={"status": "ok", "state": "current"},
) as rendered,
):
result = service.apply("policy-application", "a" * 64)
refresh = result["derived_refresh"]
assert isinstance(refresh, dict)
self.assertEqual(
{"mode": mode, "action": expected_action},
refresh["render_policy"],
)
self.assertEqual(expected_calls, rendered.call_count)
self.assertEqual("ok", refresh["status"])
def test_mcp_exposes_v2_without_changing_v1_policy_or_legacy_render_projection(self) -> None:
project = self.project(graph=True)
baseline = DocForgeService(project, capability_mode_name="read")
narrowed = DocForgeService(
project,
capability_mode_name="read",
manual_projection_policy="disabled",
portable_graph_policy="disabled",
live_viewer_policy="disabled",
)
self.assertEqual(baseline.policy.as_dict(), narrowed.policy.as_dict())
result = narrowed.bootstrap()
self.assertEqual(narrowed.policy.as_dict(), result["effective_policy"])
self.assertEqual(
narrowed.projection_policy.as_dict(),
result["projection_policy"],
)
self.assertEqual(
narrowed.projection_policy.policy_hash,
result["projection_policy_hash"],
)
session = result["session_contract"]
assert isinstance(session, dict)
self.assertEqual(
{
"manual": narrowed.policy.manual_render,
"graph": narrowed.policy.graph_render,
"live_viewer": narrowed.policy.live_viewer,
},
session["render_policies"],
)
self.assertEqual(
"stale",
narrowed.graph_render_status("architecture")["state"],
)
blocked_plan = narrowed.graph_plan("architecture")
self.assertEqual(
"projection_policy_forbids_operation",
blocked_plan["error"]["code"],
)
def test_direct_service_policy_values_are_runtime_validated(self) -> None:
project = self.project(graph=True)
factories = (
lambda: RenderService(project, manual_policy="bogus"), # type: ignore[arg-type]
lambda: GraphRenderService(
project,
portable_graph_policy="bogus", # type: ignore[arg-type]
),
lambda: ViewerManagerClient(
ProjectIndex(project),
live_viewer_policy="bogus", # type: ignore[arg-type]
),
lambda: CanonicalApplicationService(
project,
applier_id=None,
applier=None,
manual_policy="bogus", # type: ignore[arg-type]
),
)
for factory in factories:
with self.subTest(factory=factory), self.assertRaises(DocForgeError) as raised:
factory()
self.assertEqual("invalid_projection_policy", raised.exception.code)
if __name__ == "__main__":
unittest.main()

View file

@ -1,324 +0,0 @@
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.graph_projection import build_graph_projection_package
from docforge.projection_contract import (
GraphViewPlanV1,
ManualRenderPlanV1,
ProjectionPackageV1,
ProjectionReceiptV1,
)
from docforge_renderers.graph import PortableGraphHtmlRenderer
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)
typed_graph_package = build_graph_projection_package(
self.graph_plan(),
renderer_id=PortableGraphHtmlRenderer.renderer_id,
renderer_version=PortableGraphHtmlRenderer.renderer_version,
max_output_bytes=1_000_000,
)
graph_receipt = PortableGraphHtmlRenderer().render(typed_graph_package).receipt.as_dict()
self.validator("projection-receipt.schema.json").validate(graph_receipt)
def test_unknown_fields_are_rejected_at_contract_boundaries(self) -> None:
cases = (
(
"manual-render-plan.schema.json",
self.manual_plan().as_dict(),
),
(
"graph-view-plan.schema.json",
self.graph_plan().as_dict(),
),
(
"projection-package.schema.json",
self.package().as_dict(),
),
(
"projection-receipt.schema.json",
self.receipt().as_dict(),
),
)
for name, document in cases:
with self.subTest(schema=name):
document["unexpected"] = True
self.assertFalse(self.validator(name).is_valid(document))
manual = self.manual_plan().as_dict()
manual["pages"][0]["unexpected"] = True # type: ignore[index]
self.assertFalse(self.validator("manual-render-plan.schema.json").is_valid(manual))
package = self.package().as_dict()
package["assets"][0]["path"] = "/tmp/escape" # type: ignore[index]
self.assertFalse(self.validator("projection-package.schema.json").is_valid(package))
receipt = self.receipt().as_dict()
receipt["artifacts"][0]["content"] = "not receipt evidence" # type: ignore[index]
self.assertFalse(self.validator("projection-receipt.schema.json").is_valid(receipt))
def test_obviously_malformed_identities_and_structures_are_rejected(self) -> None:
manual = self.manual_plan().as_dict()
manual["plan_id"] = "not-a-sha256"
self.assertFalse(self.validator("manual-render-plan.schema.json").is_valid(manual))
graph = self.graph_plan().as_dict()
graph["project"]["project_root_fingerprint"] = "wrong" # type: ignore[index]
self.assertFalse(self.validator("graph-view-plan.schema.json").is_valid(graph))
graph = self.graph_plan().as_dict()
graph["bounds"] = -1
self.assertFalse(self.validator("graph-view-plan.schema.json").is_valid(graph))
package = self.package().as_dict()
package["assets"][0].pop("sha256") # type: ignore[index]
self.assertFalse(self.validator("projection-package.schema.json").is_valid(package))
package = self.package().as_dict()
package["output_policy"]["max_total_bytes"] = 0 # type: ignore[index]
self.assertFalse(self.validator("projection-package.schema.json").is_valid(package))
package = self.package().as_dict()
package["kind"] = "graph"
self.assertFalse(self.validator("projection-package.schema.json").is_valid(package))
receipt = self.receipt().as_dict()
receipt["artifacts"][0]["artifact_id"] = "../manual.html" # type: ignore[index]
self.assertFalse(self.validator("projection-receipt.schema.json").is_valid(receipt))
receipt = self.receipt().as_dict()
receipt["artifacts"][0]["bytes"] = -1 # type: ignore[index]
self.assertFalse(self.validator("projection-receipt.schema.json").is_valid(receipt))
receipt = self.receipt().as_dict()
receipt["peak_memory_bytes"] = True
self.assertFalse(self.validator("projection-receipt.schema.json").is_valid(receipt))
def test_assets_and_artifacts_enforce_fixed_collection_bounds(self) -> None:
package = self.package().as_dict()
package["assets"] = [copy.deepcopy(package["assets"][0]) for _ in range(33)] # type: ignore[index]
self.assertFalse(self.validator("projection-package.schema.json").is_valid(package))
receipt = self.receipt().as_dict()
receipt["artifacts"] = [
copy.deepcopy(receipt["artifacts"][0])
for _ in range(33) # type: ignore[index]
]
self.assertFalse(self.validator("projection-receipt.schema.json").is_valid(receipt))
def test_canonical_identity_equality_remains_a_runtime_check(self) -> None:
document = self.manual_plan().as_dict()
document["plan_id"] = "f" * 64
self.validator("manual-render-plan.schema.json").validate(document)
with self.assertRaises(DocForgeError) as raised:
ManualRenderPlanV1.from_dict(document)
self.assertEqual("invalid_projection", raised.exception.code)
if __name__ == "__main__":
unittest.main()

View file

@ -1,388 +0,0 @@
from __future__ import annotations
import base64
import contextlib
import copy
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from docforge.errors import DocForgeError
from docforge.graph_projection import (
GraphViewRequestV1,
build_graph_projection_package,
build_graph_view_plan,
)
from docforge.manual_projection import (
build_manual_projection_package,
build_manual_render_plan,
)
from docforge.project import Project
from docforge.projection_contract import (
ProjectionPackageV1,
ProjectionReceiptV1,
canonical_projection_bytes,
)
from docforge.projection_worker import (
MAX_WORKER_ARTIFACT_BYTES,
MAX_WORKER_RESPONSE_BYTES,
_child_response,
render_projection_in_worker,
)
from docforge.render_contract import GenericHtmlRenderer
from docforge_renderers.graph import PortableGraphHtmlRenderer
from docforge_renderers.manual import ManualHtmlRenderer
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
class ProjectionWorkerTests(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()
render = self.snapshot.descriptor.render
assert render is not None
self.manual_view = render.views[0]
self.manual_version = GenericHtmlRenderer().renderer_version
self.manual_plan = build_manual_render_plan(
self.snapshot,
self.manual_view,
changeset_hash=None,
)
self.manual_package = build_manual_projection_package(
self.manual_plan,
self.manual_view.template_path.read_bytes(),
renderer_id="generic_html",
renderer_version=self.manual_version,
max_output_bytes=self.snapshot.descriptor.limits.max_render_bytes,
)
self.graph_plan = build_graph_view_plan(
self.snapshot,
GraphViewRequestV1(
view_id="architecture",
title="Alpha architecture",
root_node_id="guide.workflow",
depth=2,
max_nodes=20,
max_edges=40,
max_work=1_000,
),
False,
)
self.graph_package = build_graph_projection_package(
self.graph_plan,
renderer_id=PortableGraphHtmlRenderer.renderer_id,
renderer_version=PortableGraphHtmlRenderer.renderer_version,
max_output_bytes=self.snapshot.descriptor.limits.max_render_bytes,
)
def test_manual_and_graph_workers_match_the_in_process_renderers(self) -> None:
cases = (
(
self.manual_package,
ManualHtmlRenderer(self.manual_version).render(self.manual_package),
),
(
self.graph_package,
PortableGraphHtmlRenderer().render(self.graph_package),
),
)
for package, expected in cases:
with self.subTest(kind=package.kind):
result = render_projection_in_worker(package)
self.assertEqual(expected.artifacts, result.artifacts)
receipt = result.receipt.as_dict()
self.assertEqual(package.package_id, receipt["package_id"])
self.assertEqual(package.document["plan_id"], receipt["plan_id"])
self.assertIs(type(receipt["peak_memory_bytes"]), int)
self.assertGreater(receipt["peak_memory_bytes"], 0)
def test_parent_launch_is_fixed_and_request_contains_no_runtime_authority(self) -> None:
original = subprocess.run
with mock.patch(
"docforge.projection_worker.subprocess.run",
wraps=original,
) as launched:
render_projection_in_worker(self.manual_package)
command = launched.call_args.args[0]
options = launched.call_args.kwargs
self.assertEqual(
[sys.executable, "-I", "-m", "docforge._projection_worker_main"],
command,
)
self.assertIs(options["shell"], False)
self.assertEqual(sys.prefix, options["cwd"])
self.assertEqual(
{"PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"},
options["env"],
)
request = options["input"]
self.assertEqual(
canonical_projection_bytes(self.manual_package.as_dict()) + b"\n",
request,
)
for forbidden in (
str(self.root).encode(),
b"project_path",
b"index_path",
b"database_path",
b'"command"',
b'"module"',
b'"shell"',
b'"sql"',
):
self.assertNotIn(forbidden, request)
def test_worker_ignores_hostile_cwd_and_pythonpath_import_shadows(self) -> None:
shadow_root = Path(self.temporary.name) / "shadow"
shadow_package = shadow_root / "docforge"
shadow_package.mkdir(parents=True)
sentinel = shadow_root / "executed"
shadow_package.joinpath("__init__.py").write_text(
"from pathlib import Path\n"
f"Path({str(sentinel)!r}).write_text('executed', encoding='utf-8')\n",
encoding="utf-8",
)
with (
contextlib.chdir(shadow_root),
mock.patch.dict(
os.environ,
{"PYTHONPATH": str(shadow_root)},
clear=False,
),
):
result = render_projection_in_worker(self.manual_package)
self.assertEqual("manual", result.receipt.document["kind"])
self.assertFalse(sentinel.exists())
def test_parent_accepts_only_valid_packages_and_closed_renderer_versions(self) -> None:
with self.assertRaises(TypeError):
render_projection_in_worker({}) # type: ignore[arg-type]
unsupported = ProjectionPackageV1.create(
kind="manual",
plan=self.manual_plan,
renderer={"renderer_id": "generic_html", "renderer_version": "other"},
components=[{"component_id": "manual.document@1"}],
assets=copy.deepcopy(self.manual_package.document["assets"]),
output_policy=copy.deepcopy(self.manual_package.document["output_policy"]),
)
with (
mock.patch("docforge.projection_worker._invoke_worker") as invoke,
self.assertRaises(DocForgeError) as raised,
):
render_projection_in_worker(unsupported)
self.assertEqual("unsupported_renderer", raised.exception.code)
invoke.assert_not_called()
permissive_allowance = ProjectionPackageV1.create(
kind="manual",
plan=self.manual_plan,
renderer={
"renderer_id": "generic_html",
"renderer_version": self.manual_version,
},
components=[{"component_id": "manual.document@1"}],
assets=copy.deepcopy(self.manual_package.document["assets"]),
output_policy={
"artifact_ids": ["manual.html"],
"max_total_bytes": MAX_WORKER_ARTIFACT_BYTES + 1,
},
)
rendered = render_projection_in_worker(permissive_allowance)
self.assertEqual(
ManualHtmlRenderer(self.manual_version)
.render(permissive_allowance)
.artifacts[0]
.content,
rendered.artifacts[0].content,
)
def test_process_timeout_exit_and_signal_fail_closed(self) -> None:
failures = (
(
subprocess.TimeoutExpired(["worker"], 30),
"projection_worker_timeout",
),
(
subprocess.CompletedProcess(["worker"], 2, stdout=b""),
"projection_worker_failure",
),
(
subprocess.CompletedProcess(["worker"], -9, stdout=b""),
"projection_worker_failure",
),
)
for outcome, code in failures:
with self.subTest(outcome=type(outcome).__name__):
patch = (
mock.patch(
"docforge.projection_worker._invoke_worker",
side_effect=outcome,
)
if isinstance(outcome, BaseException)
else mock.patch(
"docforge.projection_worker._invoke_worker",
return_value=outcome,
)
)
with patch, self.assertRaises(DocForgeError) as raised:
render_projection_in_worker(self.manual_package)
self.assertEqual(code, raised.exception.code)
def test_malformed_trailing_and_oversized_responses_fail_closed(self) -> None:
valid = _child_response(self.manual_package)
malformed = (
b"",
b"{}",
b"not-json\n",
b'{ "schema_version": 1 }\n',
valid + b"{}\n",
b"x" * (MAX_WORKER_RESPONSE_BYTES + 1),
)
for response in malformed:
with (
self.subTest(length=len(response)),
mock.patch(
"docforge.projection_worker._invoke_worker",
return_value=subprocess.CompletedProcess(
["worker"],
0,
stdout=response,
),
),
self.assertRaises(DocForgeError) as raised,
):
render_projection_in_worker(self.manual_package)
self.assertEqual("projection_worker_failure", raised.exception.code)
def test_wrong_artifact_and_receipt_evidence_fail_closed(self) -> None:
valid = json.loads(_child_response(self.manual_package))
cases: dict[str, dict[str, object]] = {}
wrong_content = copy.deepcopy(valid)
wrong_content["artifacts"][0]["content_base64"] = base64.b64encode(b"changed").decode()
cases["content_hash"] = wrong_content
wrong_id = copy.deepcopy(valid)
wrong_id["artifacts"][0]["artifact_id"] = "other.html"
cases["artifact_id"] = wrong_id
missing_artifact = copy.deepcopy(valid)
missing_artifact["artifacts"] = []
cases["artifact_count"] = missing_artifact
wrong_media_type = copy.deepcopy(valid)
wrong_media_type["artifacts"][0]["media_type"] = "application/octet-stream"
cases["media_type"] = wrong_media_type
bad_base64 = copy.deepcopy(valid)
bad_base64["artifacts"][0]["content_base64"] = "***"
cases["base64"] = bad_base64
wrong_package = copy.deepcopy(valid)
receipt = wrong_package["receipt"]
replacement = ProjectionReceiptV1.create(
kind="manual",
package_id="0" * 64,
plan_id=receipt["plan_id"],
renderer=receipt["renderer"],
artifacts=receipt["artifacts"],
diagnostics=receipt["diagnostics"],
timing=receipt["timing"],
peak_memory_bytes=receipt["peak_memory_bytes"],
)
wrong_package["receipt"] = replacement.as_dict()
cases["package_id"] = wrong_package
zero_peak = copy.deepcopy(valid)
receipt = zero_peak["receipt"]
replacement = ProjectionReceiptV1.create(
kind="manual",
package_id=receipt["package_id"],
plan_id=receipt["plan_id"],
renderer=receipt["renderer"],
artifacts=receipt["artifacts"],
diagnostics=receipt["diagnostics"],
timing=receipt["timing"],
peak_memory_bytes=0,
)
zero_peak["receipt"] = replacement.as_dict()
cases["peak_memory"] = zero_peak
wrong_size = copy.deepcopy(valid)
receipt = wrong_size["receipt"]
artifacts = copy.deepcopy(receipt["artifacts"])
artifacts[0]["bytes"] += 1
replacement = ProjectionReceiptV1.create(
kind="manual",
package_id=receipt["package_id"],
plan_id=receipt["plan_id"],
renderer=receipt["renderer"],
artifacts=artifacts,
diagnostics=receipt["diagnostics"],
timing=receipt["timing"],
peak_memory_bytes=receipt["peak_memory_bytes"],
)
wrong_size["receipt"] = replacement.as_dict()
cases["artifact_size"] = wrong_size
configured_oversize = copy.deepcopy(valid)
maximum = self.manual_package.document["output_policy"]["max_total_bytes"]
configured_oversize["artifacts"][0]["content_base64"] = base64.b64encode(
b"x" * (maximum + 1)
).decode()
cases["configured_aggregate"] = configured_oversize
for name, document in cases.items():
response = canonical_projection_bytes(document) + b"\n"
with (
self.subTest(name=name),
mock.patch(
"docforge.projection_worker._invoke_worker",
return_value=subprocess.CompletedProcess(
["worker"],
0,
stdout=response,
),
),
self.assertRaises(DocForgeError) as raised,
):
render_projection_in_worker(self.manual_package)
self.assertEqual("projection_worker_failure", raised.exception.code)
def test_child_rejects_noncanonical_invalid_and_trailing_requests(self) -> None:
valid = canonical_projection_bytes(self.manual_package.as_dict()) + b"\n"
requests = (
b"{}\n",
b'{ "schema_version": 1 }\n',
valid + b"{}\n",
)
for request in requests:
with self.subTest(length=len(request)):
completed = subprocess.run(
[sys.executable, "-I", "-m", "docforge._projection_worker_main"],
cwd=ROOT,
input=request,
capture_output=True,
check=False,
timeout=10,
)
self.assertEqual(2, completed.returncode)
self.assertEqual(b"", completed.stdout)
self.assertEqual(b"", completed.stderr)
if __name__ == "__main__":
unittest.main()

View file

@ -64,16 +64,6 @@ PUBLIC_IMPORTS = {
"docforge.client_config": ("generate_client_configuration",),
"docforge.doctor": ("run_doctor",),
"docforge.index": ("ProjectIndex",),
"docforge.graph_projection": (
"GraphViewRequestV1",
"build_graph_projection_package",
"build_graph_view_plan",
),
"docforge.graph_rendering": ("GraphRenderService",),
"docforge.manual_projection": (
"build_manual_projection_package",
"build_manual_render_plan",
),
"docforge.mcp_server": (
"create_project_server",
"create_read_only_server",
@ -94,30 +84,6 @@ PUBLIC_IMPORTS = {
"capability_mode",
"compose_effective_policy",
),
"docforge.projection_policy": (
"ProjectionPolicyV2",
"compose_projection_policy",
"validate_live_viewer_projection_mode",
"validate_manual_projection_mode",
"validate_portable_graph_projection_mode",
),
"docforge.projection_fragments": (
"FragmentKey",
"FragmentRecord",
"ProjectionFragmentCache",
"fragment_semantic_hash",
),
"docforge.projection_contract": (
"GraphViewPlanV1",
"ManualRenderPlanV1",
"ProjectionArtifact",
"ProjectionPackageV1",
"ProjectionReceiptV1",
"ProjectionRenderResult",
"canonical_projection_bytes",
"projection_hash",
),
"docforge.projection_worker": ("render_projection_in_worker",),
"docforge.retrieval": (
"ContextCapsuleV1",
"RetrievalPlanV1",
@ -131,8 +97,6 @@ PUBLIC_IMPORTS = {
"Renderer",
"renderer_for",
),
"docforge_renderers.manual": ("ManualHtmlRenderer",),
"docforge_renderers.graph": ("PortableGraphHtmlRenderer",),
}
EXPECTED_ENTRY_POINTS = {
@ -152,9 +116,6 @@ EXPECTED_CLI_COMMANDS = {
"doctor",
"filter",
"generation-diff",
"graph-plan",
"graph-render",
"graph-render-status",
"impact",
"info",
"onboard",
@ -200,8 +161,6 @@ EXPECTED_MCP_TOOLS = {
"docforge_rebase_changeset",
"docforge_register_changes",
"docforge_render_status",
"docforge_graph_plan",
"docforge_graph_render_status",
"docforge_search",
"docforge_stop_visualization",
"docforge_sync",
@ -258,9 +217,6 @@ class PublicContractTests(unittest.TestCase):
)
self.assertIn("--project-root", completed.stdout)
self.assertIn("--no-ast", completed.stdout)
self.assertIn("--manual-render-policy", completed.stdout)
self.assertIn("--portable-graph-policy", completed.stdout)
self.assertIn("--live-viewer-policy", completed.stdout)
def test_published_schemas_validate_their_current_contract_examples(self) -> None:
for path in sorted(SCHEMAS.glob("*.json")):

View file

@ -55,14 +55,6 @@ class DocForgeRenderingTests(unittest.TestCase):
self.assertEqual("missing", missing["outputs"][0]["state"])
first = service.render("manual")
projection_receipt = first["output"]["projection_receipt"]
self.assertIsInstance(projection_receipt, dict)
assert isinstance(projection_receipt, dict)
self.assertEqual("manual", projection_receipt["kind"])
self.assertEqual(
first["output"]["actual_output_hash"],
projection_receipt["artifacts"][0]["sha256"],
)
output = root / ".docforge/rendered/manual.html"
first_bytes = output.read_bytes()
second = service.render("manual")
@ -199,12 +191,7 @@ class DocForgeRenderingTests(unittest.TestCase):
self.assertEqual("receipt_corrupt", corrupt["outputs"][0]["reason"])
def test_render_receipt_schema_and_renderer_version_fail_closed(self) -> None:
for mutation in (
"missing_hash",
"renderer_version",
"file_identity",
"projection_artifact",
):
for mutation in ("missing_hash", "renderer_version", "file_identity"):
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
service = RenderService(Project.open(root))
@ -215,8 +202,6 @@ class DocForgeRenderingTests(unittest.TestCase):
receipt.pop("output_hash")
elif mutation == "renderer_version":
receipt["renderer_version"] = "obsolete"
elif mutation == "projection_artifact":
receipt["projection_receipt"]["artifacts"][0]["sha256"] = "0" * 64
else:
receipt["output_file"].pop("ctime_ns")
receipt_path.write_text(
@ -416,19 +401,6 @@ class DocForgeRenderingTests(unittest.TestCase):
with self.assertRaisesRegex(DocForgeError, "configured limit") as limit_error:
limit_service.render("manual")
self.assertEqual("render_too_large", limit_error.exception.code)
excessive = self.copy_fixture("alpha", Path(directory) / "excessive")
excessive_descriptor = excessive / ".docforge/project.toml"
excessive_descriptor.write_text(
excessive_descriptor.read_text(encoding="utf-8").replace(
"max_changeset_bytes = 100000",
"max_changeset_bytes = 100000\nmax_render_bytes = 20000001",
),
encoding="utf-8",
)
permissive_limit = RenderService(Project.open(excessive)).render("manual")
self.assertEqual("current", permissive_limit["receipt"]["state"])
self.assertTrue((excessive / ".docforge/rendered/manual.html").is_file())
self.assertFalse((limit_root / ".docforge/rendered/manual.html").exists())
template_limit_root = self.copy_fixture("alpha", parent / "template-limit")

View file

@ -386,25 +386,6 @@ 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))

View file

@ -1,99 +0,0 @@
"""Serve exact DocForge accessibility fixtures to the Playwright gate."""
from __future__ import annotations
import json
import shutil
import sys
import tempfile
from pathlib import Path
from typing import cast
from docforge.graph_projection import (
GraphViewRequestV1,
build_graph_projection_package,
build_graph_view_plan,
)
from docforge.index import ProjectIndex
from docforge.project import Project
from docforge.rendering import RenderService
from docforge.visualization import VisualizationRunner
from docforge_renderers.graph import PortableGraphHtmlRenderer
ROOT = Path(__file__).resolve().parents[1]
def _manual_html(project: Project) -> str:
result = RenderService(project).render("manual")
output = result.get("output")
if not isinstance(output, dict):
raise RuntimeError("Manual render did not return output identity")
relative_path = cast(dict[str, object], output).get("path")
if not isinstance(relative_path, str):
raise RuntimeError("Manual render did not return an output path")
return (project.descriptor.root / relative_path).read_text(encoding="utf-8")
def _portable_graph_html(project: Project) -> str:
plan = build_graph_view_plan(
project.load(),
GraphViewRequestV1(
view_id="accessibility-gate",
title="Portable graph accessibility fixture",
root_node_id="guide.workflow",
depth=2,
max_nodes=20,
max_edges=40,
max_work=1_000,
),
False,
)
package = build_graph_projection_package(
plan,
renderer_id=PortableGraphHtmlRenderer.renderer_id,
renderer_version=PortableGraphHtmlRenderer.renderer_version,
max_output_bytes=1_000_000,
)
artifact = PortableGraphHtmlRenderer().render(package).artifacts[0]
return artifact.content.decode("utf-8")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="docforge-accessibility-") as directory:
project_root = Path(directory) / "alpha"
shutil.copytree(ROOT / "tests" / "fixtures" / "alpha", project_root)
project = Project.open(project_root)
index = ProjectIndex(project)
index.build()
portable_html = _portable_graph_html(project)
manual_html = _manual_html(project)
runner = VisualizationRunner(
index,
register_atexit=False,
persistent=True,
)
try:
visualization = runner.start(node_id="guide.workflow", depth=2)
live_url = visualization.get("url")
if not isinstance(live_url, str):
raise RuntimeError("Live viewer did not return a URL")
print(
json.dumps(
{
"schema_version": 1,
"manual_html": manual_html,
"portable_html": portable_html,
"live_url": live_url,
},
separators=(",", ":"),
),
flush=True,
)
sys.stdin.buffer.read()
finally:
runner.stop()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -10,19 +10,9 @@ import tempfile
from pathlib import Path
from typing import cast
from docforge.graph_projection import (
GraphViewRequestV1,
build_graph_projection_package,
build_graph_view_plan,
)
from docforge.index import ProjectIndex
from docforge.project import Project
from docforge.rendering import RenderService
from docforge_renderers.graph import (
PORTABLE_GRAPH_CSS,
PORTABLE_GRAPH_JAVASCRIPT,
PortableGraphHtmlRenderer,
)
ROOT = Path(__file__).resolve().parents[1]
ASSET_ROOT = ROOT / "src" / "docforge" / "assets"
@ -49,30 +39,6 @@ def _rendered_manual_html() -> str:
return (project_root / relative_path).read_text(encoding="utf-8")
def _portable_graph_html() -> str:
project = Project.open(ROOT / "tests" / "fixtures" / "alpha")
plan = build_graph_view_plan(
project.load(),
GraphViewRequestV1(
view_id="web-quality",
title="Portable graph quality fixture",
root_node_id="guide.workflow",
depth=2,
max_nodes=20,
max_edges=40,
max_work=1_000,
),
False,
)
package = build_graph_projection_package(
plan,
renderer_id=PortableGraphHtmlRenderer.renderer_id,
renderer_version=PortableGraphHtmlRenderer.renderer_version,
max_output_bytes=1_000_000,
)
return PortableGraphHtmlRenderer().render(package).artifacts[0].content.decode("utf-8")
def _tool(name: str) -> str:
suffix = ".cmd" if os.name == "nt" else ""
path = ROOT / "node_modules" / ".bin" / f"{name}{suffix}"
@ -116,16 +82,6 @@ def main() -> int:
],
_rendered_manual_html(),
),
(
"portable graph HTML",
[
_tool("html-validate"),
"--stdin",
"--stdin-filename=portable-graph.html",
"--max-warnings=0",
],
_portable_graph_html(),
),
(
"CSS",
[
@ -146,26 +102,6 @@ def main() -> int:
],
(ASSET_ROOT / "graph.js").read_text(encoding="utf-8"),
),
(
"portable graph CSS",
[
_tool("stylelint"),
"--stdin",
"--stdin-filename=portable-graph.css",
"--max-warnings=0",
],
PORTABLE_GRAPH_CSS,
),
(
"portable graph JavaScript",
[
_tool("eslint"),
"--stdin",
"--stdin-filename=portable-graph.js",
"--max-warnings=0",
],
PORTABLE_GRAPH_JAVASCRIPT,
),
)
results = [_run(label, command, content) for label, command, content in checks]
return 0 if all(results) else 1

View file

@ -1,893 +0,0 @@
"""Milestone 3 projection, worker, fragment, and publication benchmark gates."""
from __future__ import annotations
import argparse
import gc
import hashlib
import json
import math
import platform
import resource
import statistics
import subprocess
import sys
import tempfile
import time
import tracemalloc
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, replace
from pathlib import Path
from typing import cast
from milestone0_baseline import write_synthetic_project
from docforge.graph_projection import (
GraphViewRequestV1,
build_graph_projection_package,
build_graph_view_plan,
)
from docforge.graph_rendering import GraphRenderService
from docforge.manual_projection import (
build_manual_projection_package,
build_manual_render_plan,
)
from docforge.models import ProjectSnapshot
from docforge.project import Project
from docforge.projection_contract import (
MAX_PACKAGE_BYTES,
MAX_PLAN_BYTES,
MAX_RECEIPT_BYTES,
GraphViewPlanV1,
ManualRenderPlanV1,
ProjectionPackageV1,
ProjectionRenderResult,
canonical_projection_bytes,
projection_hash,
)
from docforge.projection_fragments import (
FragmentKey,
FragmentRecord,
ProjectionFragmentCache,
fragment_semantic_hash,
)
from docforge.projection_worker import (
MAX_WORKER_ARTIFACT_BYTES,
render_projection_in_worker,
)
from docforge.render_contract import GenericHtmlRenderer, PreparedRender
from docforge.rendering import RenderService
from docforge.telemetry import COUNTER_NAMES, request
from docforge_renderers.graph import PortableGraphHtmlRenderer
from docforge_renderers.manual import ManualHtmlRenderer
ROOT = Path(__file__).resolve().parents[1]
FULL_NODE_COUNT = 1_000
SMOKE_NODE_COUNT = 25
DEFAULT_FULL_SAMPLES = 3
DEFAULT_SMOKE_SAMPLES = 1
MAX_STATUS_RESPONSE_BYTES = 256_000
MAX_TRACED_PEAK_BYTES = 256 * 1024 * 1024
MAX_CHILD_PEAK_BYTES = 256 * 1024 * 1024
ZERO_WORK_COUNTERS = tuple(
counter for counter in COUNTER_NAMES if counter != "source_generation_checks"
)
@dataclass(frozen=True)
class _ProjectionSample:
plan_id: str
package_id: str
artifact: bytes
receipt: Mapping[str, object]
plan_bytes: int
package_bytes: int
child_peak_memory_bytes: int | None
@dataclass(frozen=True)
class _StatusSample:
response: Mapping[str, object]
counters: Mapping[str, object]
@dataclass(frozen=True)
class _FragmentSweep:
fragment_count: int
aggregate_content_bytes: int
ordered_record_hash: str
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Gate DocForge2 Milestone 3 projection behavior on a disposable project."
)
parser.add_argument("--mode", choices=("smoke", "full"), default="full")
parser.add_argument("--nodes", type=int)
parser.add_argument("--samples", type=int)
parser.add_argument("--output", type=Path)
return parser
def encode_report(value: object) -> str:
"""Serialize one report deterministically for files, CI logs, and comparisons."""
return json.dumps(value, sort_keys=True, indent=2, ensure_ascii=False) + "\n"
def _git(arguments: list[str]) -> str:
return subprocess.run(
["git", *arguments],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
).stdout.strip()
def _compact_size(value: object) -> int:
return len(canonical_projection_bytes(value))
def _sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def _prepare_fixture(root: Path, node_count: int) -> None:
write_synthetic_project(root, node_count)
descriptor = root / ".docforge" / "project.toml"
maximum_edges = node_count - 1
maximum_work = max(100, node_count * 4)
original = descriptor.read_text(encoding="utf-8")
original = original.replace(
"max_render_bytes = 20000000",
"max_render_bytes = 4000000",
)
descriptor.write_text(
original
+ f"""
[graph_render]
output_root = ".docforge/portable-graph"
[[graph_render.views]]
id = "architecture"
renderer = "portable_graph_html"
output = "architecture.html"
title = "Synthetic architecture"
query = "synthetic measurement"
initial_mode = "web"
depth = 1
max_nodes = {node_count}
max_edges = {maximum_edges}
max_work = {maximum_work}
families = ["guide"]
relations = ["depends_on"]
authorities = []
statuses = ["active"]
tags = []
include_logic = false
""",
encoding="utf-8",
)
def _manual_package(
snapshot: ProjectSnapshot,
) -> tuple[ManualRenderPlanV1, ProjectionPackageV1, str]:
descriptor = snapshot.descriptor
render = descriptor.render
if render is None:
raise RuntimeError("Milestone 3 fixture has no manual render configuration")
view = render.views[0]
renderer_version = GenericHtmlRenderer(incremental=False).renderer_version
plan = build_manual_render_plan(snapshot, view, changeset_hash=None)
package = build_manual_projection_package(
plan,
view.template_path.read_bytes(),
renderer_id=ManualHtmlRenderer.renderer_id,
renderer_version=renderer_version,
max_output_bytes=descriptor.limits.max_render_bytes,
)
return plan, package, renderer_version
def _graph_package(
snapshot: ProjectSnapshot,
node_count: int,
) -> tuple[GraphViewPlanV1, ProjectionPackageV1]:
descriptor = snapshot.descriptor
plan = build_graph_view_plan(
snapshot,
GraphViewRequestV1(
view_id="architecture",
title="Synthetic architecture",
query="synthetic measurement",
initial_mode="web",
depth=1,
max_nodes=node_count,
max_edges=node_count - 1,
max_work=max(100, node_count * 4),
families=("guide",),
relations=("depends_on",),
statuses=("active",),
),
False,
)
package = build_graph_projection_package(
plan,
renderer_id=PortableGraphHtmlRenderer.renderer_id,
renderer_version=PortableGraphHtmlRenderer.renderer_version,
max_output_bytes=descriptor.limits.max_render_bytes,
)
return plan, package
def _projection_sample(
plan: ManualRenderPlanV1 | GraphViewPlanV1,
package: ProjectionPackageV1,
result: ProjectionRenderResult,
) -> _ProjectionSample:
if len(result.artifacts) != 1:
raise RuntimeError("Projection benchmark expected exactly one artifact")
artifact = result.artifacts[0].content
peak = result.receipt.document.get("peak_memory_bytes")
if peak is not None and (type(peak) is not int or peak < 1):
raise RuntimeError("Detached worker did not report valid peak memory")
return _ProjectionSample(
plan_id=plan.plan_id,
package_id=package.package_id,
artifact=artifact,
receipt=result.receipt.as_dict(),
plan_bytes=_compact_size(plan.as_dict()),
package_bytes=_compact_size(package.as_dict()),
child_peak_memory_bytes=peak,
)
def _projection_summary(sample: _ProjectionSample) -> dict[str, object]:
return {
"plan_id": sample.plan_id,
"package_id": sample.package_id,
"artifact_sha256": _sha256(sample.artifact),
"artifact_bytes": len(sample.artifact),
"plan_bytes": sample.plan_bytes,
"package_bytes": sample.package_bytes,
}
def _measure(
operation: Callable[[], object],
*,
samples: int,
p95_limit_ms: float,
response_limit_bytes: int,
summary: Callable[[object], Mapping[str, object]],
response_size: Callable[[object], int],
warmups: int = 0,
) -> tuple[dict[str, object], object]:
for _ in range(warmups):
operation()
durations: list[float] = []
traced_peaks: list[int] = []
response_sizes: list[int] = []
stable_summary: Mapping[str, object] | None = None
last: object = None
for _ in range(samples):
gc.collect()
tracemalloc.start()
started = time.perf_counter_ns()
try:
value = operation()
elapsed_ms = (time.perf_counter_ns() - started) / 1_000_000
_, traced_peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
current_summary = dict(summary(value))
if stable_summary is None:
stable_summary = current_summary
elif current_summary != stable_summary:
raise RuntimeError("Milestone 3 operation changed deterministic result across samples")
current_response_size = response_size(value)
if current_response_size > response_limit_bytes:
raise RuntimeError(
"Milestone 3 response exceeded its fixed benchmark boundary: "
f"{current_response_size} > {response_limit_bytes}"
)
if traced_peak > MAX_TRACED_PEAK_BYTES:
raise RuntimeError(
"Milestone 3 operation exceeded its traced-memory boundary: "
f"{traced_peak} > {MAX_TRACED_PEAK_BYTES}"
)
durations.append(elapsed_ms)
traced_peaks.append(traced_peak)
response_sizes.append(current_response_size)
last = value
ordered = sorted(durations)
p95_index = max(0, math.ceil(len(ordered) * 0.95) - 1)
p95 = ordered[p95_index]
if p95 > p95_limit_ms:
raise RuntimeError(f"Milestone 3 operation p95 {p95:.3f} ms exceeds {p95_limit_ms:.3f} ms")
assert stable_summary is not None
return (
{
"samples": samples,
"median_ms": round(statistics.median(ordered), 3),
"p95_ms": round(p95, 3),
"min_ms": round(ordered[0], 3),
"max_ms": round(ordered[-1], 3),
"p95_limit_ms": p95_limit_ms,
"maximum_response_bytes": max(response_sizes),
"response_limit_bytes": response_limit_bytes,
"maximum_traced_peak_bytes": max(traced_peaks),
"traced_peak_limit_bytes": MAX_TRACED_PEAK_BYTES,
"stable_result": dict(stable_summary),
},
last,
)
def _projection_measurement(
operation: Callable[[], _ProjectionSample],
*,
samples: int,
p95_limit_ms: float,
) -> tuple[dict[str, object], _ProjectionSample]:
child_peaks: list[int] = []
def observed_operation() -> _ProjectionSample:
sample = operation()
if sample.child_peak_memory_bytes is not None:
if sample.child_peak_memory_bytes > MAX_CHILD_PEAK_BYTES:
raise RuntimeError(
"Detached worker peak memory "
f"{sample.child_peak_memory_bytes} exceeds "
f"{MAX_CHILD_PEAK_BYTES} bytes"
)
child_peaks.append(sample.child_peak_memory_bytes)
return sample
measurement, value = _measure(
observed_operation,
samples=samples,
p95_limit_ms=p95_limit_ms,
response_limit_bytes=MAX_RECEIPT_BYTES,
summary=lambda item: _projection_summary(cast(_ProjectionSample, item)),
response_size=lambda item: _compact_size(cast(_ProjectionSample, item).receipt),
)
sample = cast(_ProjectionSample, value)
if sample.plan_bytes > MAX_PLAN_BYTES or sample.package_bytes > MAX_PACKAGE_BYTES:
raise RuntimeError("Projection plan or package exceeded its protocol boundary")
if child_peaks:
measurement["maximum_child_peak_bytes"] = max(child_peaks)
measurement["child_peak_limit_bytes"] = MAX_CHILD_PEAK_BYTES
return measurement, sample
def _profiled_status(operation: Callable[[], Mapping[str, object]]) -> _StatusSample:
with request("benchmark.m3", enabled=True) as collector:
response = operation()
if collector is None:
raise RuntimeError("Milestone 3 telemetry collector was not created")
counters = collector.as_dict(outcome="ok")["counters"]
typed_counters = cast(Mapping[str, object], counters)
for counter in ZERO_WORK_COUNTERS:
if typed_counters[counter] != 0:
raise RuntimeError(f"Receipt-only status performed forbidden work: {counter}")
if typed_counters["source_generation_checks"] != 2:
raise RuntimeError("Receipt-only status did not perform its two race-safe source checks")
if response.get("status") != "ok" or response.get("state") != "current":
raise RuntimeError("Receipt-only status did not report a current publication")
return _StatusSample(response=response, counters=typed_counters)
def _status_summary(value: object) -> Mapping[str, object]:
sample = cast(_StatusSample, value)
return {
"response": dict(sample.response),
"counters": dict(sample.counters),
}
def _fragment_summary(value: object) -> Mapping[str, object]:
sample = cast(_FragmentSweep, value)
return {
"fragment_count": sample.fragment_count,
"aggregate_content_bytes": sample.aggregate_content_bytes,
"ordered_record_hash": sample.ordered_record_hash,
}
def _fragment_sweep(records: Sequence[FragmentRecord]) -> _FragmentSweep:
return _FragmentSweep(
fragment_count=len(records),
aggregate_content_bytes=sum(record.byte_count for record in records),
ordered_record_hash=projection_hash([record.record_id for record in records]),
)
def _prepared_summary(value: object) -> Mapping[str, object]:
prepared = cast(PreparedRender, value)
return {
"render_identity": prepared.render_identity,
"output_sha256": prepared.output_hash,
"output_bytes": len(prepared.output),
}
def _prepared_child_peak(prepared: PreparedRender) -> int:
receipt = prepared.projection_receipt
if not isinstance(receipt, Mapping):
raise RuntimeError("Production detached render did not return a projection receipt")
peak = receipt.get("peak_memory_bytes")
if type(peak) is not int or peak < 1:
raise RuntimeError("Production detached render did not report valid child peak memory")
if peak > MAX_CHILD_PEAK_BYTES:
raise RuntimeError(
f"Production detached worker peak memory {peak} exceeds {MAX_CHILD_PEAK_BYTES} bytes"
)
return peak
def _prepared_response_size(value: object) -> int:
prepared = cast(PreparedRender, value)
return _compact_size(prepared.projection_receipt)
def _prepared_measurement(
operation: Callable[[], PreparedRender],
*,
samples: int,
p95_limit_ms: float,
) -> tuple[dict[str, object], PreparedRender]:
child_peaks: list[int] = []
def observed_operation() -> PreparedRender:
prepared = operation()
child_peaks.append(_prepared_child_peak(prepared))
return prepared
measurement, value = _measure(
observed_operation,
samples=samples,
p95_limit_ms=p95_limit_ms,
response_limit_bytes=MAX_RECEIPT_BYTES,
summary=_prepared_summary,
response_size=_prepared_response_size,
)
measurement["maximum_child_peak_bytes"] = max(child_peaks)
measurement["child_peak_limit_bytes"] = MAX_CHILD_PEAK_BYTES
return measurement, cast(PreparedRender, value)
def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]:
project = Project.open(root)
snapshot = project.load()
if len(snapshot.nodes) != node_count or len(snapshot.edges) != node_count - 1:
raise RuntimeError("Milestone 3 fixture does not have full synthetic coverage")
operations: dict[str, object] = {}
def manual_full() -> _ProjectionSample:
plan, package, renderer_version = _manual_package(snapshot)
result = ManualHtmlRenderer(renderer_version).render(package)
return _projection_sample(plan, package, result)
operations["manual_full_render"], manual_result = _projection_measurement(
manual_full,
samples=samples,
p95_limit_ms=15_000,
)
def graph_full() -> _ProjectionSample:
plan, package = _graph_package(snapshot, node_count)
result = PortableGraphHtmlRenderer().render(package)
return _projection_sample(plan, package, result)
operations["portable_graph_full_render"], graph_result = _projection_measurement(
graph_full,
samples=samples,
p95_limit_ms=10_000,
)
manual_plan, manual_package, manual_renderer_version = _manual_package(snapshot)
graph_plan, graph_package = _graph_package(snapshot, node_count)
graph_diagnostics = cast(Mapping[str, object], graph_plan.document["diagnostics"])
manual_pages = cast(list[dict[str, object]], manual_plan.document["pages"])
if (
len(manual_pages) != node_count
or graph_diagnostics["returned_nodes"] != node_count
or graph_diagnostics["returned_edges"] != node_count - 1
):
raise RuntimeError("Projection plans did not retain every synthetic node and edge")
manual_worker_measurement, manual_worker = _projection_measurement(
lambda: _projection_sample(
manual_plan,
manual_package,
render_projection_in_worker(manual_package),
),
samples=samples,
p95_limit_ms=20_000,
)
operations["manual_detached_worker"] = manual_worker_measurement
manual_worker_peak = cast(
int,
manual_worker_measurement["maximum_child_peak_bytes"],
)
graph_worker_measurement, graph_worker = _projection_measurement(
lambda: _projection_sample(
graph_plan,
graph_package,
render_projection_in_worker(graph_package),
),
samples=samples,
p95_limit_ms=20_000,
)
operations["portable_graph_detached_worker"] = graph_worker_measurement
graph_worker_peak = cast(
int,
graph_worker_measurement["maximum_child_peak_bytes"],
)
if (
manual_worker.artifact != manual_result.artifact
or graph_worker.artifact != graph_result.artifact
):
raise RuntimeError("Detached worker output is not byte-equivalent to in-process output")
manual_renderer = ManualHtmlRenderer(manual_renderer_version)
fragment_records = [
FragmentRecord.create(
FragmentKey.create(
projection_kind="manual",
renderer_id=ManualHtmlRenderer.renderer_id,
renderer_version=manual_renderer_version,
component_version=GenericHtmlRenderer.page_component_version,
semantic_input_hash=fragment_semantic_hash(page),
),
manual_renderer.render_page_fragment(page).encode("utf-8"),
)
for page in manual_pages
]
fragment_cache = ProjectionFragmentCache(
root,
root / ".docforge" / "milestone3-fragment-cache",
)
def fragment_misses() -> _FragmentSweep:
if any(fragment_cache.get(record.key) is not None for record in fragment_records):
raise RuntimeError("Cold fragment-cache lookup unexpectedly hit")
return _fragment_sweep(fragment_records)
operations["fragment_cache_miss_sweep"], _ = _measure(
fragment_misses,
samples=samples,
p95_limit_ms=5_000,
response_limit_bytes=32_768,
summary=_fragment_summary,
response_size=lambda item: _compact_size(_fragment_summary(item)),
)
def fragment_puts() -> _FragmentSweep:
published: list[FragmentRecord] = []
for record in fragment_records:
stored = fragment_cache.put(record.key, record.content)
if stored != record:
raise RuntimeError("Fragment cache did not publish an exact record")
assert stored is not None
published.append(stored)
return _fragment_sweep(published)
operations["fragment_cache_put_sweep"], _ = _measure(
fragment_puts,
samples=1,
p95_limit_ms=10_000,
response_limit_bytes=32_768,
summary=_fragment_summary,
response_size=lambda item: _compact_size(_fragment_summary(item)),
)
def fragment_hits() -> _FragmentSweep:
loaded: list[FragmentRecord] = []
for expected in fragment_records:
record = fragment_cache.get(expected.key)
if record != expected:
raise RuntimeError("Fragment cache hit was not byte-exact")
assert record is not None
loaded.append(record)
return _fragment_sweep(loaded)
operations["fragment_cache_hit_sweep"], _ = _measure(
fragment_hits,
samples=samples,
p95_limit_ms=5_000,
response_limit_bytes=32_768,
summary=_fragment_summary,
response_size=lambda item: _compact_size(_fragment_summary(item)),
)
render_config = snapshot.descriptor.render
assert render_config is not None
incremental_package = build_manual_projection_package(
manual_plan,
render_config.views[0].template_path.read_bytes(),
renderer_id=ManualHtmlRenderer.renderer_id,
renderer_version=manual_renderer_version,
max_output_bytes=snapshot.descriptor.limits.max_render_bytes,
fragment_records=[record.as_dict() for record in fragment_records],
)
def fragment_equivalence() -> _ProjectionSample:
sample = _projection_sample(
manual_plan,
incremental_package,
ManualHtmlRenderer(manual_renderer_version).render(incremental_package),
)
if sample.artifact != manual_result.artifact:
raise RuntimeError("Fragment-assisted manual output is not byte-equivalent")
return sample
operations["fragment_assisted_equivalence"], fragment_result = _projection_measurement(
fragment_equivalence,
samples=samples,
p95_limit_ms=15_000,
)
render_config = snapshot.descriptor.render
assert render_config is not None
manual_view = render_config.views[0]
template_bytes = manual_view.template_path.read_bytes()
production_renderer = GenericHtmlRenderer()
operations["manual_incremental_cold"], cold_prepared = _prepared_measurement(
lambda: production_renderer.prepare(
snapshot,
manual_view,
template_bytes,
changeset_hash=None,
),
samples=1,
p95_limit_ms=20_000,
)
operations["manual_incremental_warm"], warm_prepared = _prepared_measurement(
lambda: production_renderer.prepare(
snapshot,
manual_view,
template_bytes,
changeset_hash=None,
),
samples=samples,
p95_limit_ms=20_000,
)
operations["manual_forced_full"], forced_prepared = _prepared_measurement(
lambda: GenericHtmlRenderer(incremental=False).prepare(
snapshot,
manual_view,
template_bytes,
changeset_hash=None,
),
samples=samples,
p95_limit_ms=20_000,
)
production_values = (cold_prepared, warm_prepared, forced_prepared)
if len({value.output for value in production_values}) != 1:
raise RuntimeError("Production cold, warm, and forced-full manual output differs")
changed_node = replace(
snapshot.nodes[0],
content=snapshot.nodes[0].content + "\nChanged projection content.\n",
content_hash=_sha256(
(snapshot.nodes[0].content + "\nChanged projection content.\n").encode()
),
)
added_node = replace(
snapshot.nodes[-1],
node_id="guide.synthetic-added",
title="Synthetic added",
source_path="docs/content/synthetic-added.md",
content_hash=_sha256(b"synthetic-added"),
)
variants = {
"change": replace(
snapshot,
nodes=(changed_node, *snapshot.nodes[1:]),
source_hash=_sha256(b"manual-change-variant"),
),
"add": replace(
snapshot,
nodes=(*snapshot.nodes, added_node),
source_hash=_sha256(b"manual-add-variant"),
),
"delete": replace(
snapshot,
nodes=snapshot.nodes[:-1],
edges=tuple(
edge
for edge in snapshot.edges
if edge.source_id != snapshot.nodes[-1].node_id
and edge.target_id != snapshot.nodes[-1].node_id
),
source_hash=_sha256(b"manual-delete-variant"),
),
"reorder": replace(
snapshot,
nodes=tuple(reversed(snapshot.nodes)),
source_hash=_sha256(b"manual-reorder-variant"),
),
}
variant_equivalence: dict[str, bool] = {}
variant_child_peaks: list[int] = []
for name, variant in variants.items():
incremental = GenericHtmlRenderer().prepare(
variant,
manual_view,
template_bytes,
changeset_hash=None,
)
full = GenericHtmlRenderer(incremental=False).prepare(
variant,
manual_view,
template_bytes,
changeset_hash=None,
)
variant_child_peaks.extend(
(
_prepared_child_peak(incremental),
_prepared_child_peak(full),
)
)
variant_equivalence[name] = incremental.output == full.output
if not all(variant_equivalence.values()):
raise RuntimeError("Production incremental mutation output differs from forced full")
manual_service = RenderService(project)
graph_service = GraphRenderService(project)
manual_service.render("manual")
graph_service.render("architecture")
operations["manual_status_no_work"], manual_status = _measure(
lambda: _profiled_status(lambda: manual_service.status("manual")),
samples=samples,
p95_limit_ms=500,
response_limit_bytes=MAX_STATUS_RESPONSE_BYTES,
summary=_status_summary,
response_size=lambda item: _compact_size(cast(_StatusSample, item).response),
)
operations["portable_graph_status_no_work"], graph_status = _measure(
lambda: _profiled_status(lambda: graph_service.status("architecture")),
samples=samples,
p95_limit_ms=500,
response_limit_bytes=MAX_STATUS_RESPONSE_BYTES,
summary=_status_summary,
response_size=lambda item: _compact_size(cast(_StatusSample, item).response),
)
return {
"fixture": {
"kind": "synthetic_generic_projection",
"node_count": node_count,
"edge_count": node_count - 1,
"manual_page_count": len(manual_pages),
"portable_graph_node_count": graph_diagnostics["returned_nodes"],
"portable_graph_edge_count": graph_diagnostics["returned_edges"],
"full_coverage": (
len(manual_pages) == node_count
and graph_diagnostics["returned_nodes"] == node_count
and graph_diagnostics["returned_edges"] == node_count - 1
),
},
"operations": operations,
"equivalence": {
"manual_in_process_vs_detached": manual_worker.artifact == manual_result.artifact,
"portable_graph_in_process_vs_detached": graph_worker.artifact == graph_result.artifact,
"manual_full_vs_fragment_assisted": fragment_result.artifact == manual_result.artifact,
"manual_production_cold_warm_full": len({value.output for value in production_values})
== 1,
"manual_production_variants": variant_equivalence,
},
"sizes": {
"manual": {
**_projection_summary(manual_result),
"receipt_bytes": _compact_size(manual_result.receipt),
},
"portable_graph": {
**_projection_summary(graph_result),
"receipt_bytes": _compact_size(graph_result.receipt),
},
"fragment_assisted_manual": {
**_projection_summary(fragment_result),
"receipt_bytes": _compact_size(fragment_result.receipt),
"fragment_count": len(fragment_records),
"aggregate_fragment_content_bytes": sum(
record.byte_count for record in fragment_records
),
},
"manual_status_response_bytes": _compact_size(
cast(_StatusSample, manual_status).response
),
"portable_graph_status_response_bytes": _compact_size(
cast(_StatusSample, graph_status).response
),
},
"memory": {
"process_peak_rss_kib": int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss),
"manual_worker_peak_bytes": manual_worker_peak,
"manual_production_worker_peak_bytes": max(
cast(
int,
operations["manual_incremental_cold"]["maximum_child_peak_bytes"],
),
cast(
int,
operations["manual_incremental_warm"]["maximum_child_peak_bytes"],
),
cast(
int,
operations["manual_forced_full"]["maximum_child_peak_bytes"],
),
*variant_child_peaks,
),
"portable_graph_worker_peak_bytes": graph_worker_peak,
},
}
def main() -> int:
arguments = _parser().parse_args()
default_nodes = FULL_NODE_COUNT if arguments.mode == "full" else SMOKE_NODE_COUNT
default_samples = DEFAULT_FULL_SAMPLES if arguments.mode == "full" else DEFAULT_SMOKE_SAMPLES
node_count = default_nodes if arguments.nodes is None else arguments.nodes
samples = default_samples if arguments.samples is None else arguments.samples
if not 2 <= node_count <= FULL_NODE_COUNT:
raise SystemExit("--nodes must be between 2 and 1000")
if arguments.mode == "full" and node_count != FULL_NODE_COUNT:
raise SystemExit("--mode full requires exactly 1000 nodes")
if samples < 1:
raise SystemExit("--samples must be positive")
with tempfile.TemporaryDirectory(prefix="docforge-milestone3-") as directory:
benchmark_root = Path(directory).resolve()
_prepare_fixture(benchmark_root, node_count)
measurement = _benchmark(benchmark_root, node_count, samples)
status = _git(["status", "--porcelain"])
report: dict[str, object] = {
"schema_version": 1,
"benchmark": "docforge2_milestone3",
"mode": arguments.mode,
"source": {
"revision": _git(["rev-parse", "HEAD"]),
"dirty": bool(status),
},
"environment": {
"platform": platform.platform(),
"machine": platform.machine(),
"python": platform.python_version(),
"implementation": platform.python_implementation(),
},
"method": {
"clock": "time.perf_counter_ns",
"in_process_peak_memory": "tracemalloc per measured invocation",
"detached_peak_memory": "worker receipt resource peak RSS",
"process_peak_memory": "resource.getrusage(RUSAGE_SELF).ru_maxrss",
"response_size": "UTF-8 bytes of canonical compact sorted JSON",
"samples": samples,
"full_mode_node_requirement": FULL_NODE_COUNT,
"determinism": (
"stable semantic summaries must match across samples; report JSON uses sorted keys"
),
"maximum_worker_artifact_bytes": MAX_WORKER_ARTIFACT_BYTES,
},
**measurement,
}
encoded = encode_report(report)
if arguments.output is not None:
output = arguments.output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(encoded, encoding="utf-8")
sys.stdout.write(encoded)
return 0
if __name__ == "__main__":
raise SystemExit(main())