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

Compare commits

..

11 commits

37 changed files with 7826 additions and 432 deletions

View file

@ -1,13 +1,12 @@
# Milestone status
# Active milestone
```text
Milestone: 0 — successor foundation and measured baseline
Goal: Seed DocForge2 from the most advanced local lineage without breaking DocForge v1 contracts.
In scope: Complete Git lineage; verified no-AST work; adapter lifecycle safeguards; compatibility guarantees; repository-native contract and quality gates; cold/warm, memory, rendering, and response-size baselines; public successor migration; fresh-clone verification.
Out of scope: Storage redesign; compiler or renderer redesign; portable render plans; self-hosting; production MCP repointing; WorldForge or ScrapeStation changes; tags and releases.
Done when: administrator/DocForge2 is public and seeded from the advanced clean tree; administrator/DocForge remains intact; origin and legacy identify the successor and v1 remotes; all gates and a fresh-clone proof pass; the recorded baseline identifies measured bottlenecks without speculative optimization.
Status: Complete. DocForge2 is public and seeded from the complete advanced lineage. Compatibility and quality gates pass locally and from an anonymous fresh clone. The legacy repository and production MCP bindings remain unchanged.
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: Active. Read-only contract audits begin from the verified Milestone 1 boundary.
```
No later milestone is active. `main` is the verified Milestone 0 state. `dev` begins at the same
commit and remains inactive until the next milestone is explicitly opened.
Milestones 35 remain directional context and are not active.

346
DEVELOPMENT_NOTES.md Normal file
View file

@ -0,0 +1,346 @@
# DocForge2 development notes
This is the running implementation record for DocForge2. It records what is active, what was
measured, what changed, what failed, why architectural decisions were made, and which ideas were
deferred. Stable user and compatibility contracts still belong in dedicated documentation.
## Working rules
- Only one milestone is active at a time.
- `main` remains the last fully verified milestone.
- Active implementation occurs on `dev`.
- Every milestone begins from direct repository evidence and ends with focused tests, the complete
repository gate, updated measurements, documentation closeout, and a clean pushed state.
- WorldForge, ScrapeStation, legacy DocForge, and production MCP bindings remain out of scope.
- DocForge2 does not self-host during this program.
- Release tags and Forgejo releases require Rob's explicit approval.
## Milestone 0 — complete
Milestone 0 established the public successor, preserved the complete lineage and v1 tag, integrated
the no-AST and adapter-lifecycle work, froze compatibility guarantees, added repository-native
quality and contract gates, and recorded cold/warm performance, memory, rendering, and response
sizes.
The central measurement was decisive: a 1,000-node warm exact lookup took about 286 ms while the
generation-pinned SQLite query path took about 0.41.4 ms. Repeated whole-source loading and
validation, not SQLite, is the first optimization target.
## Milestone 1 — complete: fast, observable core
### Outcome
Warm retrieval should disappear into normal tool overhead. Routine reads must not parse project
sources. Status must not render or rebuild hidden work. Results must remain bounded independently
of project size.
### Starting evidence
- Generic `Project.load()` walks, captures, parses, rereads, validates, hashes, and checks Git for
the complete source set.
- Exact retrieval validates twice around one bounded SQLite query.
- Context compilation performs three full project loads.
- Render status recompiles the complete manual.
- Incremental adapters already prove that manifest attestation can make no-change synchronization
and exact retrieval sub-millisecond on a tiny fixture.
- Pinned viewer queries prove the current SQLite schema can serve bounded reads quickly.
### Final outcome
Routine generic reads now parse zero canonical source files, use one generation-pinned SQLite
snapshot, and return bounded results. Status checks perform no hidden rendering or rebuilding.
Structured counters prove those invariants independently of machine timing. Complete loading and
deep validation remain recovery and equivalence oracles.
### Work log
#### Linear dependency validation
The inherited dependency-cycle preparation scanned every edge once for every node. The graph
validator now constructs dependency adjacency in one edge pass and sorts each adjacency list before
an iterative deterministic depth-first cycle check. The iterative stack also removes recursion
depth as a failure mode on large valid graphs. The same edge pass now rejects missing sources as
well as missing targets.
A 10,000-node regression test counts complete edge-collection iteration passes and caps them at
four. The focused correctness and bounded-pass tests pass, and the configured strict source type
gate is clean.
One validation command initially included `tests/test_core.py` in a direct Pyright invocation.
Repository Pyright intentionally covers `src` and `tools`, so that command reported existing
untyped test-result indexing rather than a source defect. Rerunning the repository-configured type
gate produced zero diagnostics.
#### Persistent generic source generations
Generic projects now persist a version-1 source-generation receipt only after complete source
loading and fully verified index publication. The receipt binds the explicit generic source
contract, project and root identity, adapter, revision, source hash, every canonical/authority/
descriptor regular-file identity, and every source-membership directory identity.
A normal warm check reads no canonical source bytes. It validates the known directories and files
directly using device, inode, mode, size, nanosecond modification time, and nanosecond change time.
Directory identities detect add, delete, and rename operations without an `rglob`. Any missing,
malformed, incompatible, foreign, or dirty receipt becomes a cache miss and falls back to the full
canonical load and row-verification oracle. Successful fallback verification repairs the disposable
receipt.
The final 1,000-node evidence run initially exposed a repeatable 52 ms exact-read maximum against
the 50 ms target. Profiling showed no source parsing or SQLite cost; each request rebuilt and
revalidated 1,000 `Path` objects from the unchanged JSON receipt twice. The project binding now
caches only the strictly validated receipt structure behind its device, inode, size, modification
time, and change-time signature. Canonical file and directory identities are still recaptured
before and after every query. Receipt replacement or mutation invalidates the cache and fails
closed. The same ordered exact-read profile fell from about 4052 ms to about 18 ms without
weakening stale-read refusal.
The receipt is deliberately generic-project behavior. Incremental adapter manifests retain
authority over generated or specialist source identities. A one-method legacy adapter continues to
work even when it cannot provide a cheap generation.
#### Request-scoped immutable reads
Index reads now use one read-only SQLite transaction pinned to one verified file signature and one
source identity. Existence checks and queries share that connection. Before returning, the request
rechecks the index signature and current cheap source generation. A concurrent source or index
change fails closed.
Context compilation hydrates nodes and edges from the pinned derived snapshot while retaining
profiles from the immutable descriptor. It no longer loads or parses canonical sources. The public
full `Project.load()` and deep `ProjectIndex.check()` behavior remains the recovery and equivalence
oracle.
Focused tests prove that fresh-process-style generic reads can run exact, search, filter,
backlinks, dependency, impact, context, and no-change synchronization operations while
`Project.load()` is forbidden. They also prove a final source-generation change is rejected before
return and missing/corrupt receipts fall back and repair.
The final clean 1,000-file run recorded:
| Operation | Milestone 0 median | Milestone 1 median | Milestone 1 p95 |
|---|---:|---:|---:|
| Warm no-change synchronize | 142.479 ms | 9.192 ms | 9.324 ms |
| Exact node | 286.306 ms | 17.887 ms | 18.577 ms |
| Search, limit 20 | 288.793 ms | 19.518 ms | 19.884 ms |
| Dependencies, depth 8 | 287.791 ms | 18.117 ms | 19.061 ms |
| Context, 32k, page 20 | 436.897 ms | 25.395 ms | 25.867 ms |
| Render status | 150.591 ms | 18.969 ms | 19.613 ms |
| Visualization status | — | 9.875 ms | 10.836 ms |
The recorded run came from clean commit `6253c45a5eca01efa8c73ea3dfe4d85c55878ada`.
Every measured operation passed its p95 threshold and work-counter contract.
#### Read-only audit reconciliation
The three Milestone 1 audits agreed on the main architecture:
- Keep complete loading and deep checking as independent truth oracles.
- Trust only versioned, identity-bound disposable generation receipts.
- Use one pinned read transaction and retain a final dirty check.
- Hydrate context from the current index.
- Replace full-edge traversal scans with bounded indexed frontier reads.
- Add compact success receipts before allowing large mutations to report post-write size errors.
- Replace hidden render-status rendering with a receipt comparison.
- Add algorithmic counters and parse-count gates alongside wall-clock thresholds.
One audit identified a correctness risk beyond latency: a large mutating MCP operation can commit
successfully and then be replaced by `result_too_large`. This must be fixed in Milestone 1 so
exactly-once operations never report a false failure after mutation.
#### Mutation success receipts
Proposal, preview, and canonical-application MCP mutations now declare an internal response policy.
Before runtime validation or mutation, the service proves that a minimum receipt containing the
actual input identity and fixed-length hash fields fits the configured output limit. If it cannot,
the operation returns a preflight size error with `mutation_committed = false` and does not call the
mutation.
Small results retain the existing full payload. Oversized successful results become a version-1
compact receipt that preserves exact changeset identity, hash, workflow scalars, and lifecycle
state while omitting full operations. Application receipts also preserve changed-source counts and
derived-refresh status/counts. If the compact form is still too large, the service returns the
minimum receipt proven by preflight. It never converts committed success into a post-write size
failure.
End-to-end MCP tests exercise two large hash-chained appends followed by canonical application.
Each response stays within 1,600 compact JSON characters, exposes the new exact hash, and reports
committed success. A separate 700-character preflight test proves the callback and changeset file
are never created. Changeset lifecycle receipts now obey the configured changeset byte limit on
both write and read.
#### Receipt-based render status
Successful declared renders now publish a bounded, atomic version-1 receipt below the disposable
cache. It binds project/root/adapter/source identity, the normalized view configuration, renderer
identity, template and output hashes, byte size, and safe regular-file identities. Generic renders
also publish the verified source generation used by cheap status.
Normal status compares only source-generation, descriptor/view, template-file, output-file, and
receipt identities. It does not call `Project.load()`, prepare the renderer, construct HTML, read
the full output, rebuild the index, or repair missing state. Missing and corrupt receipts are
`unverified`; source, template, or output changes are `stale`. An explicit `deep` option on the
Python, CLI, and MCP status surfaces preserves the old side-effect-free full-render equivalence
oracle.
Receipt failure after atomic output replacement is reported as degraded publication success, not a
false render failure. Canonical application converts the same condition into a degraded
derived-refresh report while retaining canonical success. Focused tests forbid source loading and
renderer preparation during warm status and cover output, template, missing-receipt, corrupt-
receipt, and post-publication receipt-failure behavior.
After race hardening, a 50-sample three-node receipt-status check measured a 3.202 ms median and
3.509 ms p95, compared with the 1.941 ms Milestone 0 three-node full-render status. The small
fixture does not show the scaling benefit; the 1,000-node Milestone 0 status baseline was
150.591 ms and will be rerun in the final Milestone 1 evidence pass.
#### Bounded indexed retrieval
Search, metadata filtering, backlinks, dependency traversal, and impact traversal now query one
extra row beyond the requested bound and report `limit` plus `truncated`. Backlinks, dependency,
and impact APIs accept the same additive `limit` option through Python, CLI, and MCP surfaces.
Omitted limits are capped by the project `max_results` policy.
Traversal no longer loads the complete edge table and repeatedly scans it. It performs
deterministically ordered frontier queries through the existing source primary key or target index.
Each request also has a deterministic edge-examination budget derived from its result limit. The
response includes `candidate_edges_consumed`, `candidate_edges_limit`, and `truncation_reason`
counters so algorithmic work can be asserted independently of machine timing. `truncated` is true
when either another unique result exists or the work budget prevents proving completeness.
The read-only query-plan audit found that source-ordered unfiltered incoming traversal required a
temporary SQLite sort with the version-2 `(target_id, relation, source_id)` index. Direct
`EXPLAIN QUERY PLAN` evidence showed `USE TEMP B-TREE FOR ORDER BY`. A measured additive
`(target_id, source_id, relation)` index removes that sort. The disposable index schema is now
version 3, so existing version-2 indexes rebuild without changing canonical source or proposals.
Frontier cursors are streamed and stop immediately on the first omitted unique result. A focused
core, CLI, MCP, Ruff, and Pyright gate passes for this work-in-progress slice.
#### Structured profiling and zero-work gates
DocForge now has an opt-in, request-local diagnostics collector backed by `ContextVar`. It emits
one bounded version-1 aggregate with a fixed operation name, outcome, total elapsed nanoseconds,
fixed stage timing keys, and fixed integer counters. It never records paths, node IDs, queries,
source text, or SQL. Disabled mode reads no clock and adds no response field, preserving the
existing CLI and MCP payloads.
The generic loader, adapter projection and extraction paths, source-generation checks, index
checks/synchronization/build/read transactions, render status/preparation/output hashing, MCP
runtime validation, and viewer-manager requests now expose direct proof counters. A warm
incremental adapter cache hit still counts the enclosing project load, so the counters cannot hide
full adapter assembly merely because extraction was reused.
MCP servers and the CLI accept the additive `--diagnostics` startup option. Diagnostics are
attached to structured successes and errors only when the complete MCP response still fits its
configured output budget; they are discarded before any primary result or compact mutation
receipt. Warm generic error decoration now reads the persisted source generation before falling
back to complete loading. Render- and visualization-status error paths explicitly disable both
recovery synchronization and complete identity loading.
Context isolation tests cover threads, concurrent async tasks, repeated stages, nested collectors,
exceptions, and disabled collection. Repository tests assert that warm success and error reads,
render status, and visualization status perform zero project loads, source parses, adapter
projection/extraction, index builds, render preparation, output construction, and output hashing.
The result JSON schema contains the same closed operation, stage, and counter sets as the
implementation.
The maintained `tools/milestone1_benchmark.py` harness adds hard counter and p95 latency gates to a
disposable generic project. The smoke target is part of `make gate`; the final 1,000-node evidence
is recorded in `benchmarks/milestone1-2026-07-29.json`. The historical Milestone 0 harness remains
behaviorally unchanged as comparison evidence; it only exposes shared fixture and measurement
helpers to the Milestone 1 harness.
#### Visualization snapshot freshness
Visualization workers now receive a version-1 snapshot specification containing the exact
validated index publication signature: device, inode, size, modification time, and change time.
Both the manager and worker reject a launch if that publication changes before startup. The
transmitted project root, root fingerprint, source identity, adapter, counts, limits, and confined
index path are strictly validated before the worker may serve source or graph data.
Worker health reports index freshness through stat-only comparison. It does not open SQLite and
does not renew the browser activity lease. The version-2 viewer-manager protocol validates the
complete worker identity and distinguishes an unreachable worker from a live stale worker. A stale
worker stays lifecycle `running` for accurate diagnosis, but the next visualize request stops it
and launches a newly validated snapshot instead of reusing it.
Client status separately compares the worker's pinned source identity with
`IncrementalStateProject.incremental_state()`. The composite snapshot is stale if either proof is
stale, current only when both proofs are current, and unknown otherwise. A stopped worker has
unknown snapshot identity. MCP preserves this state at the top-level `staleness` field and disables
recovery synchronization and full-load error decoration.
Tests cover signature mutation before worker startup, malformed identity, missing and symlinked
indexes, stat-only health, unchanged activity, current/unknown/stale source states, live stale
workers, non-reuse, and zero-load status. The Milestone 1 benchmark now measures current, stale,
not-running, and unavailable visualization status separately with the same zero-work and 50 ms p95
gates as other receipt status operations.
#### Bounded pagination and exact large-result review
The final Milestone 1 contract audit found that count limits and the global MCP output ceiling were
not sufficient. A 1,000-node context response already exceeded the normal 200,000-character tool
limit, and one allowed changeset operation can be larger than that limit. Returning
`result_too_large` kept transport bounded but stranded useful evidence.
Version-1 pagination now uses canonical, base64url cursors with a domain-separated SHA-256
corruption checksum. Cursors bind the project, adapter, canonical generation, semantic query,
collection hash, and position. They are deliberately unkeyed read tokens rather than authorization
credentials. Corrupt tokens fail as `invalid_cursor`; changed generations or collections fail as
`stale_cursor` with explicit pagination-restart remediation.
Context transport flattens the compiler's deterministic selected entries followed by all explicit
omissions, then partitions each page back into the existing arrays. Both item count and exact
compact-JSON response size constrain packing. An individually oversized entry becomes a bounded,
hash-identified omission and advances the cursor, avoiding an infinite retry while preserving the
fact that evidence was excluded.
Changeset list, inspection, validation, and diff reads preserve direct full-result defaults while
MCP uses bounded pages. Pages retain exact changeset identity and hash. Large operation pages
compact content-bearing fields into hashes and character counts. A single oversized structured
diff is serialized once as canonical ASCII JSON and returned through hash-bound chunks that
reconstruct the exact legacy `operations` and `changes` arrays. This solves transport growth
without lowering canonical changeset limits or adding cursor storage.
The benchmark now validates zero-work and operation-specific counters for every warmup and measured
sample, records bounded semantic response summaries, covers filter, backlinks, outgoing and
incoming traversal, and measures current/stale/missing/corrupt render receipts plus all
visualization lifecycle states. A maintained query-plan test prevents the incoming traversal
temporary sort from returning.
#### Milestone closeout
The complete repository gate passed with 138 tests and 77 subtests, zero Pyright diagnostics,
warning-strict execution, package builds, contract checks, and both benchmark smoke gates. The
clean 1,000-node benchmark passed all maintained thresholds and is interpreted in
`docs/MILESTONE_1_BASELINE.md`. Compatibility, measured decisions, limitations, and scope evidence
are frozen in `docs/MILESTONE_1_CLOSEOUT.md`.
Milestone 1 made no storage rewrite, self-hosting change, production integration change, tag, or
release.
### Initial design constraints
- Full rebuild remains the recovery and equivalence oracle.
- Canonical content remains authoritative.
- Existing one-method `load_projection()` adapters remain unchanged.
- No-AST adapters remain first-class.
- Indexes, source-generation receipts, and caches remain disposable.
- Cheap reads may trust only identity-bound, versioned, corruption-checked receipts.
- Any optimization must fail closed on source mutation and must preserve stale-read refusal.
### Future ideas and suggestions
These are notes, not commitments:
- A stable source-generation provider may deserve a public adapter capability only after both the
generic project and one incremental adapter prove the same boundary.
- Profiling receipts could eventually feed the human-facing project control panel, but Milestone 1
should expose structured data before adding UI.
- A durable telemetry exporter remains deliberately deferred. Request-local bounded aggregates are
enough to prove compiler work in Milestone 1 without adding persistence, cardinality, or privacy
risks.
- The stat identity is a cheap publication proof, not a cryptographic integrity scan. Full index
validation remains the launch and query oracle.
- Cursor authentication remains deliberately absent. If read cursors ever carry authority rather
than bounded positions, they will need a different versioned security contract and persisted key
lifecycle.

View file

@ -5,7 +5,7 @@ NPM := npm
PYTHONPYCACHEPREFIX := /tmp/docforge-quality-pycache
PYTEST_BASETEMP := /tmp/docforge-quality-pytest
.PHONY: benchmark benchmark-smoke build compile contract dependencies format-check gate lint lock test type
.PHONY: benchmark benchmark-m1 benchmark-m1-smoke benchmark-smoke build compile contract dependencies format-check gate lint lock test type
format-check:
$(PYTHON) -m ruff format --check src tests tools
@ -49,4 +49,11 @@ benchmark-smoke:
benchmark:
$(PYTHON) tools/milestone0_baseline.py --nodes 1000 --samples 10 --cold-samples 3
gate: format-check lint type compile contract test lock dependencies build benchmark-smoke
benchmark-m1-smoke:
$(PYTHON) tools/milestone1_benchmark.py --nodes 25 --samples 1 \
--output /tmp/docforge-milestone1-smoke.json > /dev/null
benchmark-m1:
$(PYTHON) tools/milestone1_benchmark.py --nodes 1000 --samples 10
gate: format-check lint type compile contract test lock dependencies build benchmark-smoke benchmark-m1-smoke

View file

@ -156,8 +156,13 @@ make gate
```
Focused entry points are available as `make contract`, `make test`, `make type`,
`make benchmark-smoke`, and `make benchmark`.
`make benchmark-smoke`, `make benchmark`, `make benchmark-m1-smoke`, and
`make benchmark-m1`.
The committed 1,000-node baseline and its measurement method are under `benchmarks/`.
Pass `--diagnostics` to `docforge` or `docforge-mcp` to attach bounded request-local stage timings
and compiler-work counters. Diagnostics are disabled by default and are dropped before primary MCP
results when the configured output budget is tight.
See [AGENTS.md](AGENTS.md) before changing core boundaries.

View file

@ -15,6 +15,18 @@ Run the 1,000-node generic baseline:
make benchmark
```
Run the Milestone 1 warm-operation counter and latency smoke gate:
```bash
make benchmark-m1-smoke
```
Run the maintained 1,000-node Milestone 1 benchmark:
```bash
make benchmark-m1
```
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.
@ -24,6 +36,27 @@ mutate repository content.
`time.perf_counter_ns()` for durations. The file is data, not a performance threshold. Later work
must explain fixture or environment changes before comparing results.
`milestone1-2026-07-29.json` is the clean-tree fast-core baseline captured from commit
`6253c45a5eca01efa8c73ea3dfe4d85c55878ada`. Unlike the historical baseline, the Milestone 1
harness enforces operation-specific p95 ceilings and fixed zero-work counter invariants. Its
human-readable interpretation is in
[`docs/MILESTONE_1_BASELINE.md`](../docs/MILESTONE_1_BASELINE.md).
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
projection, extract adapter sources, build an index, prepare a render, construct rendered output,
or hash complete rendered output. Its latency ceilings are the Milestone 1 targets, not claims
about all hardware.
Every warmup and measured invocation is validated. The recorded counter ranges also require one
index synchronization for the synchronization operation, no hidden synchronization for reads and
status, one index check for each retrieval snapshot, and exactly one manager request for viewer
status. The 1,000-node run records bounded semantic summaries for exact errors, search, filtering,
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.

File diff suppressed because it is too large Load diff

View file

@ -68,9 +68,14 @@ Milestone 0 preserves:
- Edge schema version 1.
- Changeset schema version 1.
- Result-envelope schema version 1.
- SQLite index schema version 2.
- SQLite index schema version 3. Version 2 indexes remain disposable and automatically rebuild;
version 3 adds a source-ordered incoming-edge index for bounded impact traversal.
- Index-attestation schema version 1.
- Incremental extraction-cache schema version 1.
- Read-pagination schema version 1. Existing tool names and required arguments are unchanged.
Context and changeset MCP reads accept optional limits and opaque generation-bound cursors.
Direct Python changeset methods and the ordinary CLI context command retain full legacy results
when pagination is not requested.
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
@ -153,7 +158,10 @@ Milestone 0 records rather than redesigns these areas:
- Tree-sitter and the JavaScript and C++ grammars remain mandatory installation dependencies even
when their runtime modules are unused.
- Several version strings and defaults remain duplicated.
- Large changeset results and context responses need compact receipt or pagination contracts.
- One individually oversized context entry is represented as explicit bounded omission evidence;
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.
- 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

@ -6,6 +6,12 @@ configured `--proposal-writer`. It opens no network listener at startup. The exp
`docforge_visualize` read tool may start one token-protected loopback-only HTTP listener for the
same immutable project binding.
The additive `--diagnostics` startup option attaches a bounded version-1 request-local aggregate
to successes and structured errors. Fixed stage timings and counters expose source parsing,
adapter projection/extraction, index work, rendering work, and viewer-manager requests without
including content, paths, queries, node IDs, or SQL. Diagnostics are disabled by default. They are
the first response field discarded when the configured output limit would otherwise be exceeded.
Canonical application is a second independent startup gate. The generic server accepts
`--canonical-applier WRITER_ID`. A project adapter must also supply a compatible project-owned
canonical applier implementation.
@ -38,6 +44,25 @@ returns the complete fixed binding, active index path, proposal and application
recommended workflow. `docforge_sync` exposes the same idempotent synchronization explicitly.
Neither operation changes canonical sources.
Search, filter, backlinks, dependencies, and impact accept explicit result limits bounded by the
project `max_results` policy. Omitted limits are still capped. Collection responses report whether
they were truncated. Traversal also reports whether truncation came from the result limit or its
deterministic candidate-edge work budget; it does not scan or materialize the complete edge table.
`docforge_get_context` accepts optional `limit` and `cursor` arguments. Its page is one deterministic
stream containing selected entries first and explicit omission evidence second. The page receipt
reports the returned count, total evidence count, whether another page exists, and an opaque
generation-bound cursor. Packing also observes the configured MCP response limit. An individually
oversized entry advances as a hash-identified `response size limit` omission so pagination cannot
loop; targeted retrieval remains available for that node. The existing three-argument custom
context-provider contract is unchanged because pagination is applied after provider selection.
Version-1 cursors are canonical JSON encoded as base64url with a domain-separated SHA-256
corruption checksum. They are opaque and fail closed, but are not authenticated authorization
tokens. Cursors bind the project, adapter, source generation, operation parameters, collection
hash, and position. A changed generation or collection returns `stale_cursor` with
`restart_pagination`; DocForge never silently restarts at page one or combines generations.
Adapter-backed servers also validate their process-start implementation fingerprint before every
tool. `adapter_restart_required` is stale but not synchronizable. Its remediation is
`restart_project_server`; the current process does not reload project code, update Git staging, or
@ -90,6 +115,24 @@ Changeset listing returns draft and ready work by default. Stale, applied, and a
remain available through an explicit status or history request. Applied and abandoned proposals no
longer participate in overlap conflict detection.
Changeset list, inspection, validation, and diff reads accept optional `limit` and `cursor`
arguments. Direct Python and CLI methods still return their complete legacy result when pagination
is not requested. MCP defaults to bounded pages while preserving the exact changeset hash and
ordered operation sequence. Pages may contain fewer records than requested to remain inside the
response policy. Oversized inspection or validation pages return deterministic operation summaries
with hashes and character counts. An individually oversized diff becomes a sequence of
`canonical_json_chunk` pages; concatenating the ASCII chunks, decoding the JSON, and verifying its
payload hash reconstructs the exact `operations` and `changes` arrays without duplication.
Successful mutations return their existing full result while it fits the configured output limit.
Before any proposal, preview, or canonical mutation, the server verifies that a minimum exact
success receipt can fit. An impossible receipt fails with `result_too_large`,
`stage = "preflight"`, and `mutation_committed = false` before calling the mutation. If a successful
full result is too large, the server returns a version-1 compact receipt containing the exact
changeset ID and hash plus the operation outcome. It may fall back to a preflight-guaranteed
minimum receipt, but it never replaces a committed mutation with a failure response. Direct Python
and CLI integrations retain their detailed return values.
## Canonical application tool
- `docforge_apply_changeset`
@ -107,12 +150,20 @@ application with a degraded derived-refresh report and explicit remediation; the
caller to apply the same canonical change twice. DocForge does not run project commands, shell,
Git, builds, deployment, or publication.
When the full application result exceeds the tool-output limit, its compact success receipt retains
the applied lifecycle, exact hash, changed-source counts, and a derived-refresh summary. Detailed
index, render, and error payloads remain available through the corresponding read and status tools.
## Render boundary
`docforge_render_status` recomputes expected hashes without writing. `docforge_preview_changeset`
runs only a project-declared view through DocForge's fixed built-in renderer registry and writes one
atomic HTML file below the configured preview root. Rendering declared project output is available
only through the explicit local CLI integration command.
`docforge_render_status` reads bounded publication receipts and cheap file/source identities by
default. It does not parse canonical nodes, prepare Markdown, construct HTML, hash the complete
output, rebuild the index, or write state. Missing or corrupt receipts are conservative
`unverified` results. Callers may pass `deep = true` to explicitly request the side-effect-free
full-render equivalence oracle. `docforge_preview_changeset` runs only a project-declared view
through DocForge's fixed built-in renderer registry and writes one atomic HTML file below the
configured preview root. Rendering declared project output is available only through the explicit
local CLI integration command.
## Visualization boundary
@ -154,9 +205,16 @@ bridges its retained predecessors and successors with an explicit omitted path.
a project-bound worker owned by
the separately supervised per-user viewer manager. Standard-input transaction completion and MCP
host exit do not close the listener. Repeated visualization requests reuse the current worker while
its exact snapshot remains valid. `docforge_visualization_status` reports lifecycle state, and
`docforge_stop_visualization` explicitly stops the current project's worker. The manager reclaims a
worker only after one hour with no browser activity.
its exact snapshot remains valid. The version-2 manager protocol binds each worker to the exact
validated five-field index publication signature. Health checks compare that signature without
opening SQLite. A stale worker remains `state = running` but is never reused.
`docforge_visualization_status` reports lifecycle and freshness independently. `snapshot_state` and
top-level `staleness` are `stale` when either the index or cheap source identity is proven stale,
`current` only when both are proven current, and `unknown` otherwise. The `freshness` object exposes
the separate index and source states. Status never checks, synchronizes, or rebuilds the index and
never performs a complete project load. `docforge_stop_visualization` explicitly stops the current
project's worker. The manager reclaims a worker only after one hour with no browser activity.
## Excluded tools

View file

@ -0,0 +1,102 @@
# DocForge2 Milestone 1 baseline
Milestone 1 removes repeated whole-project work from routine warm reads while retaining complete
loading and deep validation as recovery and equivalence oracles. The maintained machine-readable
evidence is
[`benchmarks/milestone1-2026-07-29.json`](../benchmarks/milestone1-2026-07-29.json), captured from
clean commit `6253c45a5eca01efa8c73ea3dfe4d85c55878ada`.
## Environment and method
- Platform: x86-64 Linux 7.1.3 with glibc 2.43.
- Python: CPython 3.14.6.
- Fixture: 1,000 Markdown files, 1,000 nodes, and 999 dependency edges.
- Samples: ten measured invocations after validated warmups.
- Duration clock: `time.perf_counter_ns()`.
- Percentile: nearest rank, so p95 is the maximum with ten samples.
- Response size: UTF-8 bytes of compact, sorted JSON.
- Process memory: cumulative main-process `RUSAGE_SELF` high-water mark.
All canonical sources, caches, indexes, changesets, renders, and viewer state were created in a
disposable temporary directory. The run did not read WorldForge, ScrapeStation, legacy DocForge
indexes, or production MCP state.
The benchmark validates every warmup and measured result. It also fails when a routine warm
operation performs a project load, parses canonical source, rebuilds an adapter projection,
extracts adapter sources, builds an index, prepares a render, constructs rendered output, or hashes
complete rendered output.
## Maintained 1,000-node results
| Operation | Median | p95 | Gate | Response |
|---|---:|---:|---:|---:|
| Warm no-change synchronization | 9.192 ms | 9.324 ms | 100 ms | 1,570 B |
| Exact node | 17.887 ms | 18.577 ms | 50 ms | 1,487 B |
| Missing-node error | 18.170 ms | 18.320 ms | 50 ms | 1,130 B |
| Search, limit 20 | 19.518 ms | 19.884 ms | 100 ms | 11,164 B |
| Filter, limit 20 | 18.274 ms | 19.534 ms | 100 ms | 8,572 B |
| Backlinks, limit 20 | 18.039 ms | 18.455 ms | 100 ms | 1,210 B |
| Dependencies, depth 8 | 18.117 ms | 19.061 ms | 100 ms | 2,559 B |
| Impact, depth 8 | 18.059 ms | 18.716 ms | 100 ms | 2,553 B |
| Context, 32,000-token budget, page 20 | 25.395 ms | 25.867 ms | 250 ms | 13,152 B |
| Current render receipt status | 18.969 ms | 19.613 ms | 50 ms | 1,603 B |
| Stale render receipt status | 18.910 ms | 19.087 ms | 50 ms | 1,551 B |
| Missing render receipt status | 17.986 ms | 18.746 ms | 50 ms | 1,352 B |
| Corrupt render receipt status | 18.007 ms | 18.511 ms | 50 ms | 1,352 B |
| Current visualization status | 9.875 ms | 10.836 ms | 50 ms | 1,446 B |
| Stale visualization status | 10.050 ms | 10.472 ms | 50 ms | 1,438 B |
| Not-running visualization status | 0.258 ms | 0.288 ms | 50 ms | 1,008 B |
| Unavailable visualization status | 0.053 ms | 0.081 ms | 50 ms | 1,138 B |
Every measured p95 passed its maintained ceiling. Exact retrieval is 15.4 times faster than the
Milestone 0 median. Warm synchronization is 15.5 times faster. The paged context response is 17.2
times faster and 19.6 times smaller than the inherited full response.
The cumulative process peak was 940,116 KiB. This is not an operation-local steady-state value. It
includes fixture construction, all benchmark phases, and Python allocator high-water behavior. It
excludes the detached visualization worker. Milestone 0's isolated subprocess measurements remain
the better evidence for per-operation steady-state memory until a maintained operation-local memory
harness is added.
## Work-proof counters
Routine retrieval and status operations recorded:
- Zero complete project loads.
- Zero canonical files or bytes parsed.
- Zero adapter projection loads and source extractions.
- Zero index builds.
- Zero render preparations, output bytes constructed, or complete output bytes hashed.
- One index check and two cheap source-generation checks for each pinned retrieval.
- One viewer-manager request for each running visualization-status query.
No-change synchronization recorded one synchronization and no build. Receipt and visualization
status recorded no hidden synchronization. The counter contract is fixed, schema-validated, and
executed by the repository gate.
## Meaning of the result
The Milestone 0 evidence showed that SQLite queries were already fast after a generation was
pinned. Milestone 1 confirms that repeated source discovery, parsing, and validation were the
dominant cost. A versioned source-generation receipt, immutable SQLite read snapshot, and bounded
indexed operations remove that cost without changing graph authority or storage.
The evidence still does not justify replacing SQLite. Complete project loading, complete index
checking, full adapter projection, and deep render validation remain independent truth and recovery
oracles.
## Known limits
- The context compiler still materializes its bounded selected graph before transport pagination.
A streaming planner requires separate scale evidence.
- Generic stat identities are cheap publication proofs, not cryptographic integrity scans.
- Legacy non-incremental adapters may not provide a cheap generation identity.
- Incremental adapter manifest, invalidation, extraction, and assembly are not yet measured at
1,000-source scale.
- Pagination cursors detect corruption and stale generations. They are not authenticated
authorization tokens.
- An individually oversized context entry is returned as explicit hash-identified omission
evidence. Targeted retrieval is required for its content.
- The maintained process peak is cumulative and excludes detached worker memory.
- Manual and graph render plans do not exist until Milestone 3.

View file

@ -0,0 +1,99 @@
# DocForge2 Milestone 1 closeout
Milestone 1 establishes a fast, observable core without changing graph meaning, canonical
authority, the supported `docforge` identity, or the legacy adapter boundary.
## Completed contracts
- Generic projects publish a versioned source-generation receipt only after complete stable
verification.
- Routine reads validate file and membership-directory identities without parsing canonical
sources.
- Every indexed read uses one immutable read-only SQLite transaction pinned between source and
index identity checks.
- Dependency validation is linear in nodes and edges and uses an iterative deterministic cycle
check.
- Search, filtering, backlinks, dependency, impact, context, and changeset review results are
bounded independently of project size.
- Version-1 cursors bind the project, adapter, canonical generation, query, collection, and
position. Corrupt and stale cursors fail closed.
- Mutation tools preflight their minimum receipt and never report `result_too_large` after a
committed operation.
- Render status uses a publication receipt and performs no hidden render, source parse, index
rebuild, or repair.
- Visualization status separates lifecycle from source and index freshness and performs no hidden
SQLite validation or source parse.
- Request-local diagnostics expose fixed bounded stage timings and work counters without recording
source text, paths, node IDs, queries, or SQL.
Complete loading, deep index checking, deep render status, and full adapter projection remain the
recovery and equivalence oracles.
## Compatibility
The distribution, import package, three executable names, existing CLI commands, existing MCP tool
names, and required arguments remain supported. New limits, cursors, deep-status switches, and
diagnostics are additive.
A one-method `load_projection()` adapter remains supported. Incremental behavior remains optional.
The no-AST binding continues to reject Logic publication and every Logic retrieval surface,
including application refresh and live visualization.
The disposable SQLite index schema is version 3. Version 2 indexes rebuild automatically. No
canonical source or stored proposal is migrated to satisfy the new index.
## Verification
The complete repository gate passed at clean commit
`6253c45a5eca01efa8c73ea3dfe4d85c55878ada`:
- Ruff formatting and lint.
- HTML, rendered-manual HTML, CSS, and JavaScript lint.
- Pyright with zero diagnostics.
- Python compilation.
- Public-contract and no-AST checks.
- 138 tests and 77 subtests under warnings-as-errors.
- Lock and JavaScript dependency-tree checks.
- Wheel and source-distribution builds.
- Milestone 0 benchmark smoke.
- Milestone 1 counter and latency smoke.
The maintained clean 1,000-node benchmark passed every latency and work-counter threshold. Exact
retrieval measured 18.577 ms p95. Paged 32,000-token context measured 25.867 ms p95. Warm no-change
synchronization measured 9.324 ms p95. Render receipt status measured 19.613 ms p95.
Visualization status measured 10.836 ms p95.
Detailed evidence is in
[`MILESTONE_1_BASELINE.md`](MILESTONE_1_BASELINE.md) and
[`benchmarks/milestone1-2026-07-29.json`](../benchmarks/milestone1-2026-07-29.json).
## Measured decisions
SQLite remains the derived retrieval store. The benchmark demonstrates that whole-source
validation around SQLite, not SQLite retrieval itself, caused the inherited latency. No speculative
storage rewrite was made.
Receipt caches remain disposable and fail closed. A missing, corrupt, incompatible, foreign, or
changed receipt falls back to the complete oracle or reports an explicit unverified state according
to the operation's safety contract.
Pagination is transport state, not project authority. It adds no database and grants no
authorization.
## Remaining weaknesses
- Context selection is bounded but not yet streaming internally.
- Scaled incremental-adapter performance remains unmeasured.
- Generic cheap generation proof uses filesystem identity rather than content hashing on every
read.
- Legacy adapters without incremental state cannot always prove current identity cheaply.
- One oversized context entry requires targeted retrieval after an explicit omission.
- Operation-local and detached-worker memory need a maintained isolated harness.
- Tree-sitter and its JavaScript and C++ grammars remain mandatory package dependencies.
- `ManualRenderPlan`, `GraphViewPlan`, and independent renderer packages remain Milestone 3 work.
## Scope confirmation
Milestone 1 did not self-host DocForge2, change WorldForge or ScrapeStation, repoint a production
MCP integration, modify the legacy Forgejo repository, create a tag, or create a release.

View file

@ -254,6 +254,17 @@ docforge --project-root "$PROJECT" visualize --query persistence
The command opens the default browser. Add `--no-open` when a script only needs the returned JSON
URL. Use `visualization-status` and `visualization-stop` to inspect or stop the project viewer.
Status separates the worker lifecycle from snapshot freshness. A worker may remain `running` while
`snapshot_state` is `stale`; it will not be reused by the next `visualize` call. `freshness.index`
checks the exact pinned index publication with file identity only. `freshness.source` compares the
cheap project generation when the project can prove one. Unavailable proof is `unknown`, never
silently `current`. Status does not load project content, open SQLite, rebuild the index, or renew
browser activity.
The freshness protocol requires viewer manager version 2. After upgrading an already running
installation, rerun `docforge-viewer-manager install-user-service` or restart the foreground
manager before requesting status.
## Visualization usage
- Left-click a node for its compact descriptor.
@ -463,16 +474,16 @@ validate-index
show NODE_ID
search QUERY [--limit N]
filter [--family X] [--authority X] [--status X] [--tag X] [--limit N]
backlinks NODE_ID [--relation RELATION]
dependencies NODE_ID [--depth N]
impact NODE_ID [--depth N]
context PROFILE [--budget N]
backlinks NODE_ID [--relation RELATION] [--limit N]
dependencies NODE_ID [--depth N] [--limit N]
impact NODE_ID [--depth N] [--limit N]
context PROFILE [--budget N] [--limit N] [--cursor OPAQUE]
```
### Render and proposal commands
```text
render-status [VIEW_ID]
render-status [VIEW_ID] [--deep]
render VIEW_ID
preview CHANGESET_ID VIEW_ID
apply CHANGESET_ID --changeset-hash SHA256 --applier WRITER_ID
@ -502,6 +513,11 @@ docforge-mcp \
Omit `--proposal-writer` when the MCP client should not create or append proposals.
Add `--diagnostics` when profiling a development or benchmark session. Each MCP response then
includes bounded stage timings and compiler-work counters. The same flag is available on
`docforge`. Diagnostics are disabled by default, record no project content or paths, and never
displace a primary MCP result that already needs the configured output budget.
To expose canonical application, add a separate explicit startup gate:
```bash
@ -614,15 +630,44 @@ The older create-and-append tools remain supported for interactive proposal cons
For update, move, and delete operations it captures the synchronized current node hash when
`expected_content_hash` is omitted.
MCP mutations are preflighted against the configured response limit. Small mutations keep their
full response. Large successful mutations return a compact or minimum version-1 receipt with
`mutation_committed = true` and the exact current changeset hash. A preflight size failure has
`mutation_committed = false`; it is safe to correct the request or policy before retrying. A
committed mutation is never reported as `result_too_large`.
Active changeset listing includes draft and ready proposals. Stale work remains available through
an explicit `status="stale"` query for rebase decisions. Applied and abandoned proposals are
terminal history, remain available by status or history request, and no longer block new proposals
against the same canonical base.
Context and changeset reads use version-1 continuation receipts when their evidence exceeds one
page. Follow `pagination.next_cursor` with the same tool and semantic arguments until
`pagination.has_more` is false. Page size may change between calls. Treat the cursor as opaque.
It is bound to the project, adapter, source generation, query, exact changeset hash, and collection
identity. `stale_cursor` means evidence changed between pages; discard prior pages and restart the
read instead of mixing generations.
`docforge_get_context` paginates one ordered evidence stream: selected entries followed by explicit
omissions. An entry too large for one MCP response is represented by a bounded omission carrying
its node ID and detail hash, and the cursor advances. `docforge_list_changesets`,
`docforge_get_changeset`, `docforge_validate_changeset`, and `docforge_get_changeset_diff` accept
the same optional `limit` and `cursor` fields. Small results keep their familiar fields. Large
inspection pages may use hash summaries. A large diff may return `result_mode =
"canonical_json_chunk"`; concatenate the chunks in order and verify `payload_hash` before decoding
the reconstructed `operations` and `changes` object.
Canonical application records its terminal receipt immediately after the project-owned serializer
verifies the new canonical state. A later index or render refresh failure is reported as degraded
derived state with remediation, not as permission to apply the same canonical change again.
Every successful declared render publishes a bounded version-1 receipt below the disposable cache.
Normal `render-status` compares cheap source-generation, view-configuration, template-file, and
output-file identities. It does not parse canonical nodes, prepare Markdown, construct HTML, or
hash the complete output. Missing or corrupt receipts are `unverified`; changed sources, templates,
or outputs are `stale`. Use `render-status --deep` only when explicitly requesting the
side-effect-free full-render equivalence oracle.
Use `docforge_propose_relationship_update` when the intended change is only an edge addition or
removal. It uses the same underlying validated update contract, but rejects empty relationship
lists and makes it explicit that node content will remain unchanged.
@ -798,13 +843,11 @@ the process so it binds the new descriptor deliberately.
Run the complete release gate from the DocForge repository:
```bash
npx pyright
npm run lint:web
uv run ruff check src tests tools
uv run ruff format --check src tests tools
uv run python -m compileall -q src tests tools
uv run pytest -q
make gate
```
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

@ -2,6 +2,173 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://docforge.local/schema/result-v1.json",
"title": "DocForge result envelope",
"$defs": {
"diagnostics": {
"type": "object",
"required": [
"schema_version",
"operation",
"outcome",
"elapsed_ns",
"stages",
"counters"
],
"properties": {
"schema_version": { "const": 1 },
"operation": {
"enum": [
"test",
"benchmark.m1",
"mcp.invoke",
"mcp.bootstrap",
"mcp.sync",
"mcp.project_info",
"mcp.contract",
"mcp.get_node",
"mcp.get_logic",
"mcp.search",
"mcp.filter",
"mcp.backlinks",
"mcp.dependencies",
"mcp.impact",
"mcp.context",
"mcp.validate_project",
"mcp.render_status",
"mcp.visualize",
"mcp.visualization_status",
"mcp.stop_visualization",
"mcp.changeset",
"mcp.mutation",
"cli.onboard",
"cli.info",
"cli.validate",
"cli.build",
"cli.reindex",
"cli.sync",
"cli.check",
"cli.validate-index",
"cli.show",
"cli.search",
"cli.filter",
"cli.backlinks",
"cli.dependencies",
"cli.impact",
"cli.context",
"cli.render",
"cli.render-status",
"cli.preview",
"cli.apply",
"cli.visualize",
"cli.visualization-status",
"cli.visualization-stop"
]
},
"outcome": { "enum": ["ok", "error"] },
"elapsed_ns": { "type": "integer", "minimum": 0 },
"stages": {
"type": "object",
"maxProperties": 14,
"propertyNames": {
"enum": [
"source.generation",
"source.parse",
"adapter.projection",
"adapter.extract",
"index.check",
"index.synchronize",
"index.build",
"index.read",
"render.status",
"render.prepare",
"render.output_hash",
"visualization.status",
"viewer.manager",
"mcp.runtime_validation"
]
},
"additionalProperties": {
"type": "object",
"required": ["calls", "elapsed_ns"],
"properties": {
"calls": { "type": "integer", "minimum": 1 },
"elapsed_ns": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
}
},
"counters": {
"type": "object",
"required": [
"project_loads",
"source_files_parsed",
"source_bytes_parsed",
"adapter_projection_loads",
"adapter_source_extractions",
"source_generation_checks",
"index_checks",
"index_synchronizations",
"index_builds",
"render_prepare_calls",
"render_output_bytes_built",
"render_output_bytes_hashed",
"viewer_manager_requests"
],
"properties": {
"project_loads": { "type": "integer", "minimum": 0 },
"source_files_parsed": { "type": "integer", "minimum": 0 },
"source_bytes_parsed": { "type": "integer", "minimum": 0 },
"adapter_projection_loads": { "type": "integer", "minimum": 0 },
"adapter_source_extractions": { "type": "integer", "minimum": 0 },
"source_generation_checks": { "type": "integer", "minimum": 0 },
"index_checks": { "type": "integer", "minimum": 0 },
"index_synchronizations": { "type": "integer", "minimum": 0 },
"index_builds": { "type": "integer", "minimum": 0 },
"render_prepare_calls": { "type": "integer", "minimum": 0 },
"render_output_bytes_built": { "type": "integer", "minimum": 0 },
"render_output_bytes_hashed": { "type": "integer", "minimum": 0 },
"viewer_manager_requests": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"pagination": {
"type": "object",
"required": [
"schema_version",
"kind",
"returned_count",
"limit",
"total_count",
"has_more",
"next_cursor"
],
"properties": {
"schema_version": { "const": 1 },
"kind": {
"enum": [
"context.items",
"changeset.list",
"changeset.inspect",
"changeset.validate",
"changeset.diff",
"changeset.diff-chunks"
]
},
"returned_count": { "type": "integer", "minimum": 0 },
"limit": { "type": "integer", "minimum": 1 },
"total_count": { "type": "integer", "minimum": 0 },
"has_more": { "type": "boolean" },
"next_cursor": {
"type": ["string", "null"],
"minLength": 1,
"maxLength": 8192
}
},
"additionalProperties": false
}
},
"oneOf": [
{
"type": "object",
@ -10,8 +177,13 @@
"status": { "const": "ok" },
"project_id": { "type": "string" },
"revision": { "type": "string" },
"source_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
"adapter": { "type": "string" }
"source_hash": {
"type": ["string", "null"],
"pattern": "^[0-9a-f]{64}$"
},
"adapter": { "type": "string" },
"pagination": { "$ref": "#/$defs/pagination" },
"diagnostics": { "$ref": "#/$defs/diagnostics" }
},
"additionalProperties": true
},
@ -35,6 +207,7 @@
"content_warning": { "type": "string" },
"staleness": { "enum": ["current", "stale", "unknown"] },
"synchronization": { "type": "object" },
"diagnostics": { "$ref": "#/$defs/diagnostics" },
"error": {
"type": "object",
"required": ["code", "message", "details"],

View file

@ -38,6 +38,7 @@ from .models import (
ProposalWriter,
RenderConfig,
)
from .telemetry import increment, stage
@dataclass(frozen=True)
@ -185,6 +186,21 @@ MAX_IMPLEMENTATION_FILES = 4_096
MAX_IMPLEMENTATION_BYTES = 64_000_000
def _load_adapter_projection(loader: AdapterLoader) -> AdapterProjection:
increment("adapter_projection_loads")
with stage("adapter.projection"):
return loader.load_projection()
def _extract_adapter_source(
loader: IncrementalAdapterLoader,
source: AdapterSource,
) -> AdapterSourceProjection:
increment("adapter_source_extractions")
with stage("adapter.extract"):
return loader.extract_source(source)
@dataclass(frozen=True)
class AdapterImplementation:
"""One confined implementation boundary that must remain stable for a process."""
@ -256,7 +272,7 @@ class AdapterProject:
allowed_relations = manifest.allowed_relations
estimated_nodes = manifest.estimated_nodes
else:
initial = loader.load_projection()
initial = _load_adapter_projection(loader)
validate_projection(initial)
root = initial.root
project_id = initial.project_id
@ -370,13 +386,14 @@ class AdapterProject:
self._implementation_snapshot = self._capture_implementation(initial=True)
def load(self) -> ProjectSnapshot:
increment("project_loads")
self.validate_runtime()
canonical_sources = self.canonical_source_paths()
captured = {path: path.read_bytes() for path in canonical_sources}
projection = (
self._load_incremental()
if self._incremental_loader is not None
else self.loader.load_projection()
else _load_adapter_projection(self.loader)
)
validate_projection(projection)
identity = (
@ -411,6 +428,11 @@ class AdapterProject:
def incremental_state(self) -> ProjectState | None:
"""Return current source identity without reconstructing the complete projection."""
increment("source_generation_checks")
with stage("source.generation"):
return self._incremental_state()
def _incremental_state(self) -> ProjectState | None:
self.validate_runtime()
loader = self._incremental_loader
if loader is None:
@ -509,7 +531,7 @@ class AdapterProject:
"incremental_disabled", "Adapter does not implement incremental extraction"
)
incremental = self._load_incremental()
full = self.loader.load_projection()
full = _load_adapter_projection(self.loader)
validate_projection(full)
fields = {
"project_id": incremental.project_id == full.project_id,
@ -578,7 +600,7 @@ class AdapterProject:
hits: list[str] = []
for source in manifest.sources:
if source.source_id in invalidated:
contribution = loader.extract_source(source)
contribution = _extract_adapter_source(loader, source)
reparsed.append(source.source_id)
cache_record = CachedSource(
source_id=source.source_id,

View file

@ -405,7 +405,28 @@ class CanonicalApplicationService:
if config is not None:
for view in config.views:
try:
renders.append(self.rendering.render(view.view_id))
rendered = self.rendering.render(view.view_id)
renders.append(rendered)
if rendered.get("state") == "degraded":
receipt = rendered.get("receipt")
refresh_errors.append(
{
"component": "render_receipt",
"view_id": view.view_id,
"error": (
cast(Mapping[str, object], receipt).get("error")
if isinstance(receipt, dict)
else {
"code": "render_receipt_failure",
"message": (
"Rendered output was published without a "
"verification receipt"
),
"details": {},
}
),
}
)
except DocForgeError as error:
refresh_errors.append(
{

View file

@ -20,9 +20,12 @@ from .changeset_contract import (
)
from .errors import DocForgeError
from .models import Edge, Node, ProjectService, ProjectSnapshot, ProposalWriter
from .pagination import canonical_hash, decode_cursor, page_limit, page_receipt
from .project import project_root_fingerprint
from .proposal_projection import ProposalProjector
MAX_ABANDON_REASON_CHARS = 2_000
class ChangesetStore:
"""One project-bound proposal store with an optional immutable writer identity."""
@ -277,23 +280,37 @@ class ChangesetStore:
},
)
def validate(self, changeset_id: str) -> dict[str, object]:
def validate(
self,
changeset_id: str,
*,
limit: int | None = None,
cursor: str | None = None,
) -> dict[str, object]:
validate_id(changeset_id, "changeset_id")
with self._lock():
snapshot, document, nodes, edges = self._validate_locked(changeset_id)
return self._result(
result = self._result(
snapshot,
document,
valid=True,
projected_node_count=len(nodes),
projected_edge_count=len(edges),
)
return self._page_document_result(
result,
kind="changeset.validate",
limit=limit,
cursor=cursor,
)
def list_changesets(
self,
*,
include_history: bool = True,
status: str | None = None,
limit: int | None = None,
cursor: str | None = None,
) -> dict[str, object]:
with self._lock():
snapshot = self.project.load()
@ -322,14 +339,93 @@ class ChangesetStore:
"operation_count": len(document["operations"]),
}
)
return self._base_result(snapshot, count=len(records), changesets=records)
result = self._base_result(snapshot, count=len(records), changesets=records)
if limit is None and cursor is None:
return result
selected_limit = page_limit(
limit,
default=20,
maximum=self.project.descriptor.limits.max_results,
)
binding = {
"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,
"include_history": include_history,
"status": status,
"collection_hash": canonical_hash(records),
}
position = decode_cursor(
cursor,
kind="changeset.list",
binding=binding,
total_count=len(records),
)
page = records[position : position + selected_limit]
while page:
candidate = {
**result,
"count": len(page),
"total_count": len(records),
"changesets": page,
"pagination": page_receipt(
kind="changeset.list",
binding=binding,
position=position,
count=len(page),
limit=selected_limit,
total_count=len(records),
),
}
if self._encoded_length(candidate) <= self._safe_page_chars():
return candidate
page.pop()
if position < len(records):
compact_record = self._compact_list_record(records[position])
return {
**result,
"count": 1,
"total_count": len(records),
"changesets": [compact_record],
"result_mode": "changeset_summaries",
"pagination": page_receipt(
kind="changeset.list",
binding=binding,
position=position,
count=1,
limit=selected_limit,
total_count=len(records),
),
}
return {
**result,
"count": 0,
"total_count": len(records),
"changesets": [],
"pagination": page_receipt(
kind="changeset.list",
binding=binding,
position=position,
count=0,
limit=selected_limit,
total_count=len(records),
),
}
def inspect(self, changeset_id: str) -> dict[str, object]:
def inspect(
self,
changeset_id: str,
*,
limit: int | None = None,
cursor: str | None = None,
) -> dict[str, object]:
validate_id(changeset_id, "changeset_id")
with self._lock():
document = self._read(self._path(changeset_id))
snapshot = self.project.load()
return self._result(
result = self._result(
snapshot,
document,
base_state=self._base_state(document, snapshot),
@ -338,6 +434,12 @@ class ChangesetStore:
self._base_state(document, snapshot),
),
)
return self._page_document_result(
result,
kind="changeset.inspect",
limit=limit,
cursor=cursor,
)
def rebase(
self,
@ -398,8 +500,15 @@ class ChangesetStore:
validate_id(changeset_id, "changeset_id")
validate_hash(expected_changeset_hash, "expected_changeset_hash")
if not reason.strip():
normalized_reason = reason.strip()
if not normalized_reason:
raise DocForgeError("invalid_operation", "Abandon reason must be non-empty")
if len(normalized_reason) > MAX_ABANDON_REASON_CHARS:
raise DocForgeError(
"changeset_too_large",
"Abandon reason exceeds its character limit",
maximum=MAX_ABANDON_REASON_CHARS,
)
with self._lock():
document = self._read(self._path(changeset_id))
actual_hash = document_hash(document)
@ -418,7 +527,7 @@ class ChangesetStore:
{
"status": "abandoned",
"changeset_hash": actual_hash,
"reason": reason.strip(),
"reason": normalized_reason,
"revision": snapshot.revision,
"source_hash": snapshot.source_hash,
},
@ -430,7 +539,13 @@ class ChangesetStore:
lifecycle=receipt,
)
def diff(self, changeset_id: str) -> dict[str, object]:
def diff(
self,
changeset_id: str,
*,
limit: int | None = None,
cursor: str | None = None,
) -> dict[str, object]:
validate_id(changeset_id, "changeset_id")
with self._lock():
snapshot, document, _, _ = self._validate_locked(changeset_id)
@ -453,7 +568,14 @@ class ChangesetStore:
sorted(before_edges - edges),
)
)
return self._result(snapshot, document, valid=True, changes=changes)
result = self._result(snapshot, document, valid=True, changes=changes)
return self._page_document_result(
result,
kind="changeset.diff",
limit=limit,
cursor=cursor,
parallel_key="changes",
)
def projected_snapshot(self, changeset_id: str) -> tuple[ProjectSnapshot, str]:
"""Return a validated in-memory proposal projection for derived preview use."""
@ -688,6 +810,12 @@ class ChangesetStore:
state_path = self._state_root() / f"{document['changeset_id']}.json"
if state_path.is_file() and not state_path.is_symlink():
try:
if state_path.stat().st_size > self.project.descriptor.limits.max_changeset_bytes:
raise DocForgeError(
"changeset_too_large",
"Changeset lifecycle record exceeds the configured size limit",
changeset_id=document["changeset_id"],
)
parsed: object = json.loads(state_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise DocForgeError(
@ -784,6 +912,219 @@ class ChangesetStore:
**payload,
)
def _page_document_result(
self,
result: dict[str, object],
*,
kind: str,
limit: int | None,
cursor: str | None,
parallel_key: str | None = None,
) -> dict[str, object]:
"""Page bulky operation-aligned payloads while preserving direct full defaults."""
if limit is None and cursor is None:
return result
selected_limit = page_limit(
limit,
default=20,
maximum=self.project.descriptor.limits.max_results,
)
operations_value = result.get("operations")
if not isinstance(operations_value, list):
raise DocForgeError(
"invalid_pagination_source",
"Changeset result does not contain a deterministic operation list",
)
operations = cast(list[object], operations_value)
parallel: list[object] | None = None
if parallel_key is not None:
parallel_value = result.get(parallel_key)
if not isinstance(parallel_value, list):
raise DocForgeError(
"invalid_pagination_source",
"Changeset result does not contain an aligned detail list",
)
parallel = cast(list[object], parallel_value)
if len(parallel) != len(operations):
raise DocForgeError(
"invalid_pagination_source",
"Changeset detail list is not aligned with its operations",
)
binding = {
"project_id": result["project_id"],
"project_root_fingerprint": result["project_root_fingerprint"],
"revision": result["revision"],
"source_hash": result["source_hash"],
"changeset_id": result["changeset_id"],
"changeset_hash": result["changeset_hash"],
"adapter": result["adapter"],
"result_hash": canonical_hash(
{
"operations": operations,
**({parallel_key: parallel} if parallel_key is not None else {}),
}
),
}
if (
kind == "changeset.diff"
and parallel_key is not None
and parallel is not None
and self._encoded_length(result) > self._safe_page_chars()
):
return self._page_json_chunks(
result,
operations=operations,
changes=parallel,
binding=binding,
cursor=cursor,
)
position = decode_cursor(
cursor,
kind=kind,
binding=binding,
total_count=len(operations),
)
page = operations[position : position + selected_limit]
paged = {
**result,
"operations": page,
"returned_operation_count": len(page),
"pagination": page_receipt(
kind=kind,
binding=binding,
position=position,
count=len(page),
limit=selected_limit,
total_count=len(operations),
),
}
if parallel_key is not None and parallel is not None:
paged[parallel_key] = parallel[position : position + selected_limit]
if self._encoded_length(paged) > self._safe_page_chars():
paged["operations"] = [
self._operation_summary(item)
for item in operations[position : position + selected_limit]
]
paged["result_mode"] = "operation_summaries"
paged["detail_tool"] = "docforge_get_changeset_diff"
return paged
def _page_json_chunks(
self,
result: dict[str, object],
*,
operations: list[object],
changes: list[object],
binding: Mapping[str, object],
cursor: str | None,
) -> dict[str, object]:
payload = {"operations": operations, "changes": changes}
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
allow_nan=False,
)
chunk_chars = max(512, min(64_000, self._safe_page_chars() // 2))
chunks = [
encoded[offset : offset + chunk_chars] for offset in range(0, len(encoded), chunk_chars)
] or [""]
chunk_binding = {
**binding,
"payload_hash": canonical_hash(payload),
"chunk_chars": chunk_chars,
}
position = decode_cursor(
cursor,
kind="changeset.diff-chunks",
binding=chunk_binding,
total_count=len(chunks),
)
compact = {
key: value for key, value in result.items() if key not in {"operations", "changes"}
}
return {
**compact,
"result_mode": "canonical_json_chunk",
"payload": "changeset_diff",
"payload_hash": chunk_binding["payload_hash"],
"payload_characters": len(encoded),
"chunk": {
"index": position,
"characters": len(chunks[position]),
"content": chunks[position],
},
"pagination": page_receipt(
kind="changeset.diff-chunks",
binding=chunk_binding,
position=position,
count=1,
limit=1,
total_count=len(chunks),
),
}
@staticmethod
def _operation_summary(operation: object) -> dict[str, object]:
if not isinstance(operation, Mapping):
raise DocForgeError(
"invalid_pagination_source",
"Changeset operation is not a deterministic object",
)
payload = cast(Mapping[str, object], operation)
summary = {
key: payload.get(key)
for key in (
"sequence",
"operation",
"node_id",
"expected_content_hash",
"target_source",
)
}
for key in ("metadata", "content", "relationship_changes", "rationale"):
value = payload.get(key)
summary[f"{key}_hash"] = canonical_hash(value)
summary[f"{key}_characters"] = len(
json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
allow_nan=False,
)
)
return summary
@staticmethod
def _compact_list_record(record: dict[str, object]) -> dict[str, object]:
lifecycle = record.get("lifecycle")
if not isinstance(lifecycle, Mapping):
return record
lifecycle_payload = cast(Mapping[str, object], lifecycle)
reason = lifecycle_payload.get("reason")
if not isinstance(reason, str):
return record
return {
**record,
"lifecycle": {
**lifecycle_payload,
"reason": {
"characters": len(reason),
"sha256": canonical_hash(reason),
},
},
}
def _safe_page_chars(self) -> int:
return max(1_024, self.project.descriptor.limits.max_tool_output_chars - 2_048)
@staticmethod
def _encoded_length(value: Mapping[str, object]) -> int:
return len(json.dumps(value, sort_keys=True, separators=(",", ":")))
@staticmethod
def _base_result(snapshot: ProjectSnapshot, **payload: object) -> dict[str, object]:
return {
@ -920,6 +1261,12 @@ class ChangesetStore:
root = self._state_root()
path = root / f"{changeset_id}.json"
raw = json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n"
if len(raw) > self.project.descriptor.limits.max_changeset_bytes:
raise DocForgeError(
"changeset_too_large",
"Changeset lifecycle record exceeds the configured size limit",
changeset_id=changeset_id,
)
descriptor, temporary_name = tempfile.mkstemp(prefix=".state-", dir=root)
temporary = Path(temporary_name)
try:

View file

@ -15,12 +15,18 @@ from .index import ProjectIndex
from .onboarding import assess_project, scaffold_project
from .project import Project, project_root_fingerprint
from .rendering import RenderService
from .telemetry import request
from .viewer_manager import ViewerManagerClient
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="docforge")
parser.add_argument("--project-root", type=Path, required=True)
parser.add_argument(
"--diagnostics",
action="store_true",
help="Attach bounded request-local stage timings and counters",
)
commands = parser.add_subparsers(dest="command", required=True)
onboard = commands.add_parser("onboard")
onboard.add_argument("--language", action="append", default=[])
@ -53,13 +59,17 @@ def _parser() -> argparse.ArgumentParser:
command.add_argument("--relation")
else:
command.add_argument("--depth", type=int, default=2)
command.add_argument("--limit", type=int)
context = commands.add_parser("context")
context.add_argument("profile")
context.add_argument("--budget", type=int)
context.add_argument("--limit", type=int)
context.add_argument("--cursor")
render = commands.add_parser("render")
render.add_argument("view_id")
render_status = commands.add_parser("render-status")
render_status.add_argument("view_id", nargs="?")
render_status.add_argument("--deep", action="store_true")
preview = commands.add_parser("preview")
preview.add_argument("changeset_id")
preview.add_argument("view_id")
@ -149,17 +159,43 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
limit=arguments.limit,
)
if arguments.command == "backlinks":
return index.backlinks(arguments.node_id, relation=arguments.relation)
return index.backlinks(
arguments.node_id,
relation=arguments.relation,
limit=arguments.limit,
)
if arguments.command == "dependencies":
return index.dependencies(arguments.node_id, depth=arguments.depth)
return index.dependencies(
arguments.node_id,
depth=arguments.depth,
limit=arguments.limit,
)
if arguments.command == "impact":
return index.impact(arguments.node_id, depth=arguments.depth)
return index.impact(
arguments.node_id,
depth=arguments.depth,
limit=arguments.limit,
)
if arguments.command == "context":
if arguments.limit is not None or arguments.cursor is not None:
from .mcp_server import DocForgeService
return DocForgeService(project).context(
arguments.profile,
arguments.budget,
limit=arguments.limit,
cursor=arguments.cursor,
)
return compile_context(index, arguments.profile, arguments.budget)
if arguments.command == "render":
return RenderService(project).render(arguments.view_id)
if arguments.command == "render-status":
return RenderService(project).status(arguments.view_id)
rendering = RenderService(project)
return (
rendering.deep_status(arguments.view_id)
if arguments.deep
else rendering.status(arguments.view_id)
)
if arguments.command == "preview":
return RenderService(project).preview(arguments.changeset_id, arguments.view_id)
if arguments.command == "apply":
@ -195,12 +231,20 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
def main(argv: list[str] | None = None) -> int:
parser = _parser()
arguments = parser.parse_args(argv)
try:
result = _run(arguments)
code = 0
except DocForgeError as error:
result = {"status": "error", "error": error.as_dict()}
code = 2
with request(
f"cli.{arguments.command}",
enabled=arguments.diagnostics,
) as collector:
try:
result = _run(arguments)
code = 0
except DocForgeError as error:
result = {"status": "error", "error": error.as_dict()}
code = 2
if collector is not None:
result["diagnostics"] = collector.as_dict(
outcome="ok" if code == 0 else "error",
)
print(json.dumps(result, sort_keys=True, indent=2))
return code

View file

@ -35,92 +35,83 @@ def _profile(snapshot: ProjectSnapshot, profile_id: str) -> ContextProfile:
def compile_context(
index: ProjectIndex, profile_id: str, budget: int | None = None
) -> dict[str, object]:
checked = index.check()
snapshot = index.project.load()
if snapshot.source_hash != checked["source_hash"] or snapshot.revision != checked["revision"]:
raise DocForgeError("source_changed", "Canonical source changed before context selection")
profile = _profile(snapshot, profile_id)
selected_budget = profile.token_budget if budget is None else budget
if (
isinstance(selected_budget, bool)
or selected_budget < 1
or selected_budget > snapshot.descriptor.limits.max_context_tokens
):
raise DocForgeError("invalid_budget", "Context budget is outside the configured range")
def select(snapshot: ProjectSnapshot) -> dict[str, object]:
profile = _profile(snapshot, profile_id)
selected_budget = profile.token_budget if budget is None else budget
if (
isinstance(selected_budget, bool)
or selected_budget < 1
or selected_budget > snapshot.descriptor.limits.max_context_tokens
):
raise DocForgeError("invalid_budget", "Context budget is outside the configured range")
node_by_id = {node.node_id: node for node in snapshot.nodes}
dependency_edges = {
node_id: tuple(
edge.target_id
for edge in snapshot.edges
if edge.source_id == node_id and edge.relation == "depends_on"
)
for node_id in node_by_id
}
reasons: dict[str, str] = {node_id: "required by profile" for node_id in profile.required_nodes}
queue = deque((node_id, 0) for node_id in profile.required_nodes)
while queue:
node_id, depth = queue.popleft()
if depth >= profile.dependency_depth:
continue
for dependency in dependency_edges[node_id]:
if dependency not in reasons:
reasons[dependency] = f"dependency of {node_id}"
queue.append((dependency, depth + 1))
node_by_id = {node.node_id: node for node in snapshot.nodes}
dependency_lists: dict[str, list[str]] = {node_id: [] for node_id in node_by_id}
for edge in snapshot.edges:
if edge.relation == "depends_on":
dependency_lists[edge.source_id].append(edge.target_id)
dependency_edges = {
node_id: tuple(targets) for node_id, targets in dependency_lists.items()
}
reasons: dict[str, str] = {
node_id: "required by profile" for node_id in profile.required_nodes
}
queue = deque((node_id, 0) for node_id in profile.required_nodes)
while queue:
node_id, depth = queue.popleft()
if depth >= profile.dependency_depth:
continue
for dependency in dependency_edges[node_id]:
if dependency not in reasons:
reasons[dependency] = f"dependency of {node_id}"
queue.append((dependency, depth + 1))
eligible = [
node
for node in snapshot.nodes
if (not profile.families or node.family in profile.families)
and (not profile.statuses or node.status in profile.statuses)
]
ordered_ids = [*profile.required_nodes]
ordered_ids.extend(sorted(set(reasons) - set(ordered_ids)))
ordered_ids.extend(node.node_id for node in eligible if node.node_id not in reasons)
eligible = [
node
for node in snapshot.nodes
if (not profile.families or node.family in profile.families)
and (not profile.statuses or node.status in profile.statuses)
]
ordered_ids = [*profile.required_nodes]
ordered_ids.extend(sorted(set(reasons) - set(ordered_ids)))
ordered_ids.extend(node.node_id for node in eligible if node.node_id not in reasons)
entries: list[ContextEntry] = []
omissions: list[dict[str, str]] = []
used_tokens = 0
required = set(profile.required_nodes)
for node_id in ordered_ids:
node = node_by_id[node_id]
text = _node_text(node)
tokens = _estimate_tokens(text)
if used_tokens + tokens > selected_budget:
if node_id in required:
raise DocForgeError(
"budget_too_small",
"Context budget cannot contain every required node",
entries: list[ContextEntry] = []
omissions: list[dict[str, str]] = []
used_tokens = 0
required = set(profile.required_nodes)
for node_id in ordered_ids:
node = node_by_id[node_id]
text = _node_text(node)
tokens = _estimate_tokens(text)
if used_tokens + tokens > selected_budget:
if node_id in required:
raise DocForgeError(
"budget_too_small",
"Context budget cannot contain every required node",
node_id=node_id,
required_tokens=used_tokens + tokens,
)
omissions.append({"node_id": node_id, "reason": "token budget"})
continue
entries.append(
ContextEntry(
node_id=node_id,
required_tokens=used_tokens + tokens,
reason=reasons.get(node_id, "eligible profile node"),
estimated_tokens=tokens,
source_path=node.source_path,
content_hash=node.content_hash,
text=text,
)
omissions.append({"node_id": node_id, "reason": "token budget"})
continue
entries.append(
ContextEntry(
node_id=node_id,
reason=reasons.get(node_id, "eligible profile node"),
estimated_tokens=tokens,
source_path=node.source_path,
content_hash=node.content_hash,
text=text,
)
)
used_tokens += tokens
used_tokens += tokens
after = index.check()
if after["source_hash"] != checked["source_hash"] or after["revision"] != checked["revision"]:
raise DocForgeError("source_changed", "Canonical source changed during context selection")
return {
"status": "ok",
"project_id": checked["project_id"],
"project_root_fingerprint": checked["project_root_fingerprint"],
"revision": checked["revision"],
"source_hash": checked["source_hash"],
"adapter": checked["adapter"],
"profile": profile.profile_id,
"budget": selected_budget,
"estimated_tokens": used_tokens,
"entries": [entry.as_dict() for entry in entries],
"omissions": omissions,
}
return {
"profile": profile.profile_id,
"budget": selected_budget,
"estimated_tokens": used_tokens,
"entries": [entry.as_dict() for entry in entries],
"omissions": omissions,
}
return index.read_project_snapshot(select)

View file

@ -10,8 +10,9 @@ import sqlite3
import tempfile
import time
from collections import deque
from collections.abc import Generator
from collections.abc import Callable, Generator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import cast
@ -19,19 +20,22 @@ from .errors import DocForgeError
from .models import (
BuildReportingProject,
Edge,
GenerationRecordingProject,
IncrementalStateProject,
LogicEdge,
LogicNode,
LogicProject,
LogicProjection,
Node,
ProjectDescriptor,
ProjectService,
ProjectSnapshot,
ProjectState,
)
from .project import project_root_fingerprint
from .telemetry import increment, stage
INDEX_SCHEMA_VERSION = 2
INDEX_SCHEMA_VERSION = 3
APPLICATION_ID = 1_146_683_778
@ -104,6 +108,45 @@ def _status(
}
@dataclass(frozen=True)
class _IndexReadSnapshot:
"""One request-scoped read transaction over a verified immutable generation."""
connection: sqlite3.Connection
checked: dict[str, object]
def project_snapshot(self, descriptor: ProjectDescriptor) -> ProjectSnapshot:
nodes = tuple(
_row_to_node(row)
for row in self.connection.execute("SELECT * FROM nodes ORDER BY node_id")
)
edges = tuple(
Edge(*row)
for row in self.connection.execute(
"SELECT source_id, relation, target_id FROM edges "
"ORDER BY source_id, relation, target_id"
)
)
return ProjectSnapshot(
descriptor=descriptor,
nodes=nodes,
edges=edges,
source_hash=cast(str, self.checked["source_hash"]),
revision=cast(str, self.checked["revision"]),
)
def result(self, **payload: object) -> dict[str, object]:
return {
"status": "ok",
"project_id": self.checked["project_id"],
"project_root_fingerprint": self.checked["project_root_fingerprint"],
"revision": self.checked["revision"],
"source_hash": self.checked["source_hash"],
"adapter": self.checked["adapter"],
**payload,
}
class ProjectIndex:
"""A disposable index that always checks current canonical source before queries."""
@ -131,6 +174,11 @@ class ProjectIndex:
def synchronize(self) -> dict[str, object]:
"""Return a current index, rebuilding disposable state when necessary."""
increment("index_synchronizations")
with stage("index.synchronize"):
return self._synchronize()
def _synchronize(self) -> dict[str, object]:
started = time.perf_counter()
try:
checked = self.check(verify_rows=False)
@ -181,6 +229,11 @@ class ProjectIndex:
return {**checked, "synchronization": synchronization}
def _build_locked(self) -> dict[str, object]:
increment("index_builds")
with stage("index.build"):
return self._build_locked_core()
def _build_locked_core(self) -> dict[str, object]:
snapshot = self.project.load()
logic = self._logic_projections()
status = _status(snapshot, logic)
@ -223,6 +276,8 @@ class ProjectIndex:
PRIMARY KEY (source_id, relation, target_id)
);
CREATE INDEX edges_target ON edges(target_id, relation, source_id);
CREATE INDEX edges_target_source
ON edges(target_id, source_id, relation);
CREATE TABLE logic_owners (
owner_node_id TEXT PRIMARY KEY,
source_id TEXT NOT NULL
@ -346,6 +401,8 @@ class ProjectIndex:
os.replace(temporary, self.path)
self._verified_index_signature = self._index_signature()
self._write_attestation()
if isinstance(self.project, GenerationRecordingProject):
self.project.record_generation(current)
except sqlite3.Error as error:
temporary.unlink(missing_ok=True)
raise DocForgeError("index_failure", "Could not build the derived index") from error
@ -408,7 +465,92 @@ class ProjectIndex:
logic_projection_count=projection_count,
)
@contextmanager
def _read_snapshot(self) -> Generator[_IndexReadSnapshot, None, None]:
"""Pin one verified index and source generation for a complete read request."""
with stage("index.read"), self._read_snapshot_core() as snapshot:
yield snapshot
@contextmanager
def _read_snapshot_core(self) -> Generator[_IndexReadSnapshot, None, None]:
checked = self.check(verify_rows=False)
signature = self._verified_index_signature
if signature is None or self._index_signature() != signature:
raise DocForgeError(
"invalid_index",
"Derived index changed after validation",
)
with _read_connection(self.path) as connection:
connection.execute("PRAGMA query_only=ON")
connection.execute("BEGIN")
application_id = connection.execute("PRAGMA application_id").fetchone()[0]
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if application_id != APPLICATION_ID or schema_version != INDEX_SCHEMA_VERSION:
raise DocForgeError("invalid_index", "Derived index has an unsupported schema")
metadata = dict(connection.execute("SELECT key, value FROM metadata"))
for key in (
"project_id",
"project_root_fingerprint",
"revision",
"source_hash",
"index_schema_version",
"adapter",
):
if metadata.get(key) != str(checked[key]):
raise DocForgeError(
"invalid_index",
"Derived index changed after validation",
field=key,
)
try:
logic_projection_count = int(metadata["logic_projection_count"])
except (KeyError, ValueError) as error:
raise DocForgeError(
"invalid_index",
"Derived index has invalid Logic metadata",
) from error
self._require_logic_allowed(logic_projection_count)
snapshot = _IndexReadSnapshot(connection=connection, checked=checked)
try:
yield snapshot
except Exception:
raise
else:
self._confirm_read(snapshot.checked, signature)
def _confirm_read(
self,
checked: dict[str, object],
signature: tuple[int, int, int, int, int],
) -> None:
if self._index_signature() != signature:
raise DocForgeError("invalid_index", "Derived index changed during the query")
state: ProjectState | None = None
if isinstance(self.project, IncrementalStateProject):
state = self.project.incremental_state()
if state is None:
current = self.project.load()
state = ProjectState(source_hash=current.source_hash, revision=current.revision)
if state.source_hash != checked["source_hash"] or state.revision != checked["revision"]:
raise DocForgeError("source_changed", "Canonical source changed during the query")
def read_project_snapshot(
self,
reader: Callable[[ProjectSnapshot], dict[str, object]],
) -> dict[str, object]:
"""Run one bounded reader against an immutable derived project snapshot."""
with self._read_snapshot() as snapshot:
payload = reader(snapshot.project_snapshot(self.project.descriptor))
return snapshot.result(**payload)
def check(self, *, verify_rows: bool = True) -> dict[str, object]:
increment("index_checks")
with stage("index.check"):
return self._check(verify_rows=verify_rows)
def _check(self, *, verify_rows: bool = True) -> dict[str, object]:
if isinstance(self.project, IncrementalStateProject):
state = self.project.incremental_state()
if state is not None:
@ -465,6 +607,9 @@ class ProjectIndex:
or fts_count != len(snapshot.nodes)
):
raise DocForgeError("invalid_index", "Derived index rows do not match source")
self._verified_index_signature = self._index_signature()
if isinstance(self.project, GenerationRecordingProject):
self.project.record_generation(snapshot)
return {**expected, "database": str(self.path)}
def _check_incremental_state(
@ -654,39 +799,38 @@ class ProjectIndex:
)
def get_node(self, node_id: str) -> dict[str, object]:
checked = self.check(verify_rows=False)
with _read_connection(self.path) as connection:
row = connection.execute("SELECT * FROM nodes WHERE node_id = ?", (node_id,)).fetchone()
if row is None:
raise DocForgeError(
"missing_node", "No node has the requested stable ID", node_id=node_id
)
return self._result(checked, node=_row_to_node(row).as_dict())
with self._read_snapshot() as snapshot:
row = snapshot.connection.execute(
"SELECT * FROM nodes WHERE node_id = ?",
(node_id,),
).fetchone()
if row is None:
raise DocForgeError(
"missing_node", "No node has the requested stable ID", node_id=node_id
)
return snapshot.result(node=_row_to_node(row).as_dict())
def get_logic(self, owner_node_id: str) -> dict[str, object]:
"""Return one function-scoped control-flow projection without expanding the graph."""
checked = self.check(verify_rows=False)
with _read_connection(self.path) as connection:
owner = connection.execute(
with self._read_snapshot() as snapshot:
owner = snapshot.connection.execute(
"SELECT * FROM nodes WHERE node_id = ?", (owner_node_id,)
).fetchone()
projection = _logic_projection_from_connection(connection, owner_node_id)
if owner is None:
raise DocForgeError(
"missing_node",
"No node has the requested stable ID",
node_id=owner_node_id,
projection = _logic_projection_from_connection(snapshot.connection, owner_node_id)
if owner is None:
raise DocForgeError(
"missing_node",
"No node has the requested stable ID",
node_id=owner_node_id,
)
return snapshot.result(
owner=_row_to_node(owner).as_dict(include_content=False),
available=projection is not None,
projection=projection.as_dict() if projection is not None else None,
)
return self._result(
checked,
owner=_row_to_node(owner).as_dict(include_content=False),
available=projection is not None,
projection=projection.as_dict() if projection is not None else None,
)
def search(self, query: str, *, limit: int | None = None) -> dict[str, object]:
checked = self.check(verify_rows=False)
limits = self.project.descriptor.limits
if not query.strip() or len(query) > limits.max_query_chars:
raise DocForgeError("invalid_query", "Search query is empty or exceeds its limit")
@ -695,8 +839,8 @@ class ProjectIndex:
if not terms:
raise DocForgeError("invalid_query", "Search query contains no searchable text")
expression = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in terms)
with _read_connection(self.path) as connection:
rows = connection.execute(
with self._read_snapshot() as snapshot:
rows = snapshot.connection.execute(
"""
SELECT nodes.*, bm25(node_fts) AS rank,
snippet(node_fts, 3, '[', ']', '', 18) AS snippet
@ -705,14 +849,21 @@ class ProjectIndex:
ORDER BY rank, nodes.node_id
LIMIT ?
""",
(expression, bounded),
(expression, bounded + 1),
).fetchall()
results: list[dict[str, object]] = []
for row in rows:
payload = _row_to_node(row).as_dict(include_content=False)
payload.update({"rank": row["rank"], "snippet": row["snippet"]})
results.append(payload)
return self._result(checked, query=query, count=len(results), results=results)
results: list[dict[str, object]] = []
for row in rows[:bounded]:
payload = _row_to_node(row).as_dict(include_content=False)
payload.update({"rank": row["rank"], "snippet": row["snippet"]})
results.append(payload)
return snapshot.result(
query=query,
count=len(results),
limit=bounded,
truncated=len(rows) > bounded,
truncation_reason="result_limit" if len(rows) > bounded else None,
results=results,
)
def filter_nodes(
self,
@ -723,7 +874,6 @@ class ProjectIndex:
tag: str | None = None,
limit: int | None = None,
) -> dict[str, object]:
checked = self.check(verify_rows=False)
bounded = _bounded_limit(limit, self.project.descriptor.limits.max_results, default=100)
clauses: list[str] = []
values: list[object] = []
@ -735,110 +885,189 @@ class ProjectIndex:
clauses.append("EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?)")
values.append(tag)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with _read_connection(self.path) as connection:
rows = connection.execute(
f"SELECT * FROM nodes {where} ORDER BY node_id LIMIT ?", (*values, bounded)
with self._read_snapshot() as snapshot:
rows = snapshot.connection.execute(
f"SELECT * FROM nodes {where} ORDER BY node_id LIMIT ?",
(*values, bounded + 1),
).fetchall()
results = [_row_to_node(row).as_dict(include_content=False) for row in rows]
return self._result(checked, count=len(results), results=results)
results = [_row_to_node(row).as_dict(include_content=False) for row in rows[:bounded]]
return snapshot.result(
count=len(results),
limit=bounded,
truncated=len(rows) > bounded,
truncation_reason="result_limit" if len(rows) > bounded else None,
results=results,
)
def backlinks(self, node_id: str, *, relation: str | None = None) -> dict[str, object]:
return self._edges(node_id, incoming=True, relation=relation)
def backlinks(
self,
node_id: str,
*,
relation: str | None = None,
limit: int | None = None,
) -> dict[str, object]:
return self._edges(node_id, incoming=True, relation=relation, limit=limit)
def dependencies(self, node_id: str, *, depth: int = 2) -> dict[str, object]:
return self._traverse(node_id, incoming=False, depth=depth, relation="depends_on")
def dependencies(
self,
node_id: str,
*,
depth: int = 2,
limit: int | None = None,
) -> dict[str, object]:
return self._traverse(
node_id,
incoming=False,
depth=depth,
relation="depends_on",
limit=limit,
)
def impact(self, node_id: str, *, depth: int = 2) -> dict[str, object]:
return self._traverse(node_id, incoming=True, depth=depth, relation=None)
def impact(
self,
node_id: str,
*,
depth: int = 2,
limit: int | None = None,
) -> dict[str, object]:
return self._traverse(
node_id,
incoming=True,
depth=depth,
relation=None,
limit=limit,
)
def _edges(self, node_id: str, *, incoming: bool, relation: str | None) -> dict[str, object]:
checked = self.check(verify_rows=False)
self._require_node(node_id)
def _edges(
self,
node_id: str,
*,
incoming: bool,
relation: str | None,
limit: int | None,
) -> dict[str, object]:
bounded = _bounded_limit(
limit,
self.project.descriptor.limits.max_results,
default=self.project.descriptor.limits.max_results,
)
source_column = "target_id" if incoming else "source_id"
relation_clause = " AND relation = ?" if relation is not None else ""
values: tuple[object, ...] = (node_id, relation) if relation is not None else (node_id,)
with _read_connection(self.path) as connection:
rows = connection.execute(
values: tuple[object, ...] = (
(node_id, relation, bounded + 1) if relation is not None else (node_id, bounded + 1)
)
with self._read_snapshot() as snapshot:
self._require_node(snapshot.connection, node_id)
rows = snapshot.connection.execute(
f"SELECT source_id, relation, target_id FROM edges "
f"WHERE {source_column} = ?{relation_clause} "
"ORDER BY source_id, relation, target_id",
"ORDER BY source_id, relation, target_id LIMIT ?",
values,
).fetchall()
return self._result(checked, edges=[Edge(*row).as_dict() for row in rows])
truncated = len(rows) > bounded
edges = [Edge(*row).as_dict() for row in rows[:bounded]]
return snapshot.result(
root=node_id,
relation=relation,
count=len(edges),
limit=bounded,
truncated=truncated,
truncation_reason="result_limit" if truncated else None,
edges=edges,
)
def _traverse(
self, node_id: str, *, incoming: bool, depth: int, relation: str | None
self,
node_id: str,
*,
incoming: bool,
depth: int,
relation: str | None,
limit: int | None,
) -> dict[str, object]:
checked = self.check(verify_rows=False)
self._require_node(node_id)
maximum = self.project.descriptor.limits.max_traversal_depth
if type(depth) is not int or depth < 0 or depth > maximum:
raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit")
with _read_connection(self.path) as connection:
edges = tuple(
Edge(*row)
for row in connection.execute(
"SELECT source_id, relation, target_id FROM edges "
"ORDER BY source_id, relation, target_id"
)
)
queue: deque[tuple[str, int, tuple[str, ...]]] = deque([(node_id, 0, (node_id,))])
seen = {node_id}
results: list[dict[str, object]] = []
while queue:
current, current_depth, path = queue.popleft()
if current_depth >= depth:
continue
candidates = [
edge
for edge in edges
if (relation is None or edge.relation == relation)
and ((edge.target_id if incoming else edge.source_id) == current)
]
for edge in candidates:
target = edge.source_id if incoming else edge.target_id
if target in seen:
bounded = _bounded_limit(
limit,
self.project.descriptor.limits.max_results,
default=self.project.descriptor.limits.max_results,
)
examined_limit = (bounded + 1) ** 2
source_column = "target_id" if incoming else "source_id"
relation_clause = " AND relation = ?" if relation is not None else ""
with self._read_snapshot() as snapshot:
self._require_node(snapshot.connection, node_id)
queue: deque[tuple[str, int, tuple[str, ...]]] = deque([(node_id, 0, (node_id,))])
seen = {node_id}
results: list[dict[str, object]] = []
truncation_reason: str | None = None
candidate_edges_consumed = 0
while queue and truncation_reason is None:
current, current_depth, path = queue.popleft()
if current_depth >= depth:
continue
seen.add(target)
target_path = (*path, target)
results.append(
{
"node_id": target,
"depth": current_depth + 1,
"relation": edge.relation,
"path": target_path,
}
remaining = examined_limit - candidate_edges_consumed
if remaining <= 0:
truncation_reason = "edge_examination_limit"
break
values: tuple[object, ...] = (
(current, relation, remaining + 1)
if relation is not None
else (current, remaining + 1)
)
queue.append((target, current_depth + 1, target_path))
return self._result(checked, root=node_id, depth=depth, count=len(results), results=results)
candidates = snapshot.connection.execute(
"SELECT source_id, relation, target_id FROM edges "
f"WHERE {source_column} = ?{relation_clause} "
"ORDER BY source_id, relation, target_id LIMIT ?",
values,
)
for row in candidates:
if candidate_edges_consumed >= examined_limit:
truncation_reason = "edge_examination_limit"
break
candidate_edges_consumed += 1
edge = Edge(*row)
target = edge.source_id if incoming else edge.target_id
if target in seen:
continue
if len(results) >= bounded:
truncation_reason = "result_limit"
break
seen.add(target)
target_path = (*path, target)
results.append(
{
"node_id": target,
"depth": current_depth + 1,
"relation": edge.relation,
"path": target_path,
}
)
queue.append((target, current_depth + 1, target_path))
return snapshot.result(
root=node_id,
depth=depth,
count=len(results),
limit=bounded,
truncated=truncation_reason is not None,
truncation_reason=truncation_reason,
candidate_edges_consumed=candidate_edges_consumed,
candidate_edges_limit=examined_limit,
results=results,
)
def _require_node(self, node_id: str) -> None:
with _read_connection(self.path) as connection:
exists = connection.execute(
"SELECT 1 FROM nodes WHERE node_id = ?", (node_id,)
).fetchone()
@staticmethod
def _require_node(connection: sqlite3.Connection, node_id: str) -> None:
exists = connection.execute(
"SELECT 1 FROM nodes WHERE node_id = ?",
(node_id,),
).fetchone()
if exists is None:
raise DocForgeError(
"missing_node", "No node has the requested stable ID", node_id=node_id
)
def _result(self, checked: dict[str, object], **payload: object) -> dict[str, object]:
after = self.check(verify_rows=False)
if (
after["source_hash"] != checked["source_hash"]
or after["revision"] != checked["revision"]
):
raise DocForgeError("source_changed", "Canonical source changed during the query")
return {
"status": "ok",
"project_id": checked["project_id"],
"project_root_fingerprint": checked["project_root_fingerprint"],
"revision": checked["revision"],
"source_hash": checked["source_hash"],
"adapter": checked["adapter"],
**payload,
}
def _row_to_node(row: sqlite3.Row) -> Node:
return Node(

File diff suppressed because it is too large Load diff

View file

@ -204,6 +204,13 @@ class IncrementalStateProject(ProjectService, Protocol):
def incremental_state(self) -> ProjectState | None: ...
@runtime_checkable
class GenerationRecordingProject(IncrementalStateProject, Protocol):
"""Optional project boundary that can persist a verified cheap source generation."""
def record_generation(self, snapshot: ProjectSnapshot) -> None: ...
@runtime_checkable
class RuntimeValidatedProject(ProjectService, Protocol):
"""Optional project boundary that proves its loaded implementation is current."""

163
src/docforge/pagination.py Normal file
View file

@ -0,0 +1,163 @@
"""Deterministic, generation-bound pagination cursors for bounded public results."""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import json
from collections.abc import Mapping
from typing import cast
from .errors import DocForgeError
CURSOR_SCHEMA_VERSION = 1
MAX_CURSOR_CHARS = 8_192
_CURSOR_DOMAIN = b"docforge-page-cursor-v1\0"
_CURSOR_KEYS = frozenset({"schema_version", "kind", "binding", "position", "checksum"})
def canonical_hash(value: object) -> str:
"""Hash one JSON-compatible value using DocForge's deterministic JSON form."""
try:
encoded = _canonical_bytes(value)
except (TypeError, ValueError) as error:
raise DocForgeError(
"invalid_pagination_source",
"Pagination source data is not deterministic JSON",
) from error
return hashlib.sha256(encoded).hexdigest()
def page_limit(limit: int | None, *, default: int, maximum: int) -> int:
"""Validate one additive page size against the project result policy."""
selected = default if limit is None else limit
if type(selected) is not int or selected < 1 or selected > maximum:
raise DocForgeError(
"invalid_limit",
"Page limit is outside the configured result limit",
maximum=maximum,
)
return selected
def encode_cursor(
*,
kind: str,
binding: Mapping[str, object],
position: int,
) -> str:
"""Encode a corruption-detecting cursor bound to an immutable result identity."""
if not kind or type(position) is not int or position < 0:
raise ValueError("Cursor kind and position must be valid")
body: dict[str, object] = {
"schema_version": CURSOR_SCHEMA_VERSION,
"kind": kind,
"binding": dict(binding),
"position": position,
}
checksum = hashlib.sha256(_CURSOR_DOMAIN + _canonical_bytes(body)).hexdigest()
envelope = {**body, "checksum": checksum}
return base64.urlsafe_b64encode(_canonical_bytes(envelope)).decode("ascii").rstrip("=")
def decode_cursor(
cursor: str | None,
*,
kind: str,
binding: Mapping[str, object],
total_count: int,
) -> int:
"""Return a validated position, rejecting corrupt, foreign, or stale cursors."""
if cursor is None:
return 0
if not cursor or len(cursor) > MAX_CURSOR_CHARS or not cursor.isascii():
raise _invalid_cursor()
padding = "=" * (-len(cursor) % 4)
try:
raw = base64.b64decode(
(cursor + padding).encode("ascii"),
altchars=b"-_",
validate=True,
)
parsed: object = json.loads(raw.decode("utf-8"))
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError):
raise _invalid_cursor() from None
if base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") != cursor or not isinstance(
parsed, dict
):
raise _invalid_cursor()
payload = cast(dict[str, object], parsed)
checksum = payload.get("checksum")
position = payload.get("position")
stored_binding = payload.get("binding")
if (
frozenset(payload) != _CURSOR_KEYS
or payload.get("schema_version") != CURSOR_SCHEMA_VERSION
or payload.get("kind") != kind
or not isinstance(stored_binding, dict)
or type(position) is not int
or position < 0
or position >= total_count
or not isinstance(checksum, str)
or len(checksum) != 64
):
raise _invalid_cursor()
body = {key: payload[key] for key in payload if key != "checksum"}
expected = hashlib.sha256(_CURSOR_DOMAIN + _canonical_bytes(body)).hexdigest()
if not hmac.compare_digest(checksum, expected):
raise _invalid_cursor()
if stored_binding != dict(binding):
raise DocForgeError(
"stale_cursor",
"Pagination cursor does not match the current result generation",
)
return position
def page_receipt(
*,
kind: str,
binding: Mapping[str, object],
position: int,
count: int,
limit: int,
total_count: int,
) -> dict[str, object]:
"""Return one bounded page receipt and the next generation-bound cursor."""
next_position = position + count
has_more = next_position < total_count
return {
"schema_version": CURSOR_SCHEMA_VERSION,
"kind": kind,
"returned_count": count,
"limit": limit,
"total_count": total_count,
"has_more": has_more,
"next_cursor": (
encode_cursor(kind=kind, binding=binding, position=next_position) if has_more else None
),
}
def _canonical_bytes(value: object) -> bytes:
return json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
allow_nan=False,
).encode("utf-8")
def _invalid_cursor() -> DocForgeError:
return DocForgeError(
"invalid_cursor",
"Pagination cursor is malformed or does not match its operation",
)

View file

@ -4,12 +4,15 @@ from __future__ import annotations
import hashlib
import json
import os
import stat
import subprocess
import tempfile
import tomllib
from collections import Counter
from collections.abc import Mapping
from dataclasses import replace
from pathlib import Path
from dataclasses import dataclass, replace
from pathlib import Path, PurePosixPath
from typing import Any, cast
from .config_validation import (
@ -28,9 +31,14 @@ from .models import (
Node,
ProjectDescriptor,
ProjectSnapshot,
ProjectState,
ProposalWriter,
)
from .render_config import load_render_config
from .telemetry import increment, stage
SOURCE_GENERATION_SCHEMA_VERSION = 1
GENERIC_SOURCE_CONTRACT = "docforge-core:0.7.1:index:1"
_CORE_METADATA = frozenset(
{
@ -72,10 +80,136 @@ _PROFILE_KEYS = frozenset(
_OPERATIONS = frozenset({"create", "update", "move", "delete"})
@dataclass(frozen=True)
class _CapturedGeneration:
source_hash: str
revision: str
files: tuple[tuple[str, int, int, int, int, int, int], ...]
directories: tuple[tuple[str, int, int, int, int, int], ...]
@dataclass(frozen=True)
class _ParsedGenerationReceipt:
signature: tuple[int, int, int, int, int]
source_hash: str
revision: str
files: tuple[tuple[object, ...], ...]
directories: tuple[tuple[object, ...], ...]
file_paths: tuple[Path, ...]
directory_paths: tuple[Path, ...]
def project_root_fingerprint(root: Path) -> str:
return hashlib.sha256(str(root).encode()).hexdigest()[:16]
def _file_generation(
root: Path,
paths: tuple[Path, ...],
) -> tuple[tuple[str, int, int, int, int, int, int], ...]:
"""Capture cheap identities that change on ordinary source or metadata mutation."""
identities: list[tuple[str, int, int, int, int, int, int]] = []
for path in paths:
try:
status = path.lstat()
except OSError as error:
raise DocForgeError(
"source_changed",
"Canonical source disappeared during generation capture",
source=path.relative_to(root).as_posix(),
) from error
if not stat.S_ISREG(status.st_mode):
raise DocForgeError(
"source_changed",
"Canonical generation inputs must remain regular files",
source=path.relative_to(root).as_posix(),
)
identities.append(
(
path.relative_to(root).as_posix(),
status.st_dev,
status.st_ino,
status.st_mode,
status.st_size,
status.st_mtime_ns,
status.st_ctime_ns,
)
)
return tuple(identities)
def _directory_generation(
root: Path,
paths: tuple[Path, ...],
) -> tuple[tuple[str, int, int, int, int, int], ...]:
"""Capture directory identities so source membership changes invalidate a receipt."""
identities: list[tuple[str, int, int, int, int, int]] = []
for path in paths:
try:
status = path.lstat()
except OSError as error:
raise DocForgeError(
"source_changed",
"Canonical source directory disappeared during generation capture",
source=path.relative_to(root).as_posix(),
) from error
if not stat.S_ISDIR(status.st_mode):
raise DocForgeError(
"source_changed",
"Canonical source directories must remain directories",
source=path.relative_to(root).as_posix(),
)
identities.append(
(
path.relative_to(root).as_posix(),
status.st_dev,
status.st_ino,
status.st_mode,
status.st_mtime_ns,
status.st_ctime_ns,
)
)
return tuple(identities)
def _receipt_paths(root: Path, value: object, *, width: int) -> tuple[Path, ...] | None:
if not isinstance(value, list):
return None
paths: list[Path] = []
for raw_item in cast(list[object], value):
if not isinstance(raw_item, list):
return None
item = cast(list[object], raw_item)
if len(item) != width or not isinstance(item[0], str):
return None
relative = PurePosixPath(item[0])
if relative.is_absolute() or not relative.parts or ".." in relative.parts:
return None
path = root.joinpath(*relative.parts)
if not path.is_relative_to(root):
return None
paths.append(path)
return tuple(paths)
def _receipt_signature(path: Path) -> tuple[int, int, int, int, int] | None:
try:
status = path.lstat()
except OSError:
return None
if not stat.S_ISREG(status.st_mode):
return None
return (
status.st_dev,
status.st_ino,
status.st_size,
status.st_mtime_ns,
status.st_ctime_ns,
)
def _load_descriptor(root: Path) -> ProjectDescriptor:
descriptor_path = root / ".docforge" / "project.toml"
if not descriptor_path.is_file():
@ -472,41 +606,60 @@ def validate_graph(nodes: tuple[Node, ...], edges: tuple[Edge, ...]) -> None:
counts = Counter(node.node_id for node in nodes)
duplicates = sorted(node_id for node_id, count in counts.items() if count > 1)
raise DocForgeError("duplicate_node", "Stable node IDs must be unique", ids=duplicates)
edge_keys = {(edge.source_id, edge.relation, edge.target_id) for edge in edges}
if len(edge_keys) != len(edges):
raise DocForgeError("duplicate_edge", "Relationships must be unique")
missing = sorted({edge.target_id for edge in edges if edge.target_id not in node_ids})
if missing:
raise DocForgeError("broken_edge", "Relationships target missing nodes", targets=missing)
dependencies = {
node_id: sorted(
edge.target_id
for edge in edges
if edge.source_id == node_id and edge.relation == "depends_on"
dependencies: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
edge_keys: set[tuple[str, str, str]] = set()
missing_sources: set[str] = set()
missing_targets: set[str] = set()
for edge in edges:
key = (edge.source_id, edge.relation, edge.target_id)
if key in edge_keys:
raise DocForgeError("duplicate_edge", "Relationships must be unique")
edge_keys.add(key)
if edge.source_id not in node_ids:
missing_sources.add(edge.source_id)
if edge.target_id not in node_ids:
missing_targets.add(edge.target_id)
if edge.relation == "depends_on" and edge.source_id in dependencies:
dependencies[edge.source_id].append(edge.target_id)
if missing_sources or missing_targets:
raise DocForgeError(
"broken_edge",
"Relationships reference missing nodes",
sources=sorted(missing_sources),
targets=sorted(missing_targets),
)
for node_id in sorted(node_ids)
}
visiting: set[str] = set()
visited: set[str] = set()
for targets in dependencies.values():
targets.sort()
def visit(node_id: str, trail: tuple[str, ...]) -> None:
if node_id in visiting:
raise DocForgeError(
"dependency_cycle",
"depends_on relationships contain a cycle",
path=(*trail, node_id),
)
if node_id in visited:
return
visiting.add(node_id)
for target in dependencies[node_id]:
visit(target, (*trail, node_id))
visiting.remove(node_id)
visited.add(node_id)
for node_id in sorted(node_ids):
visit(node_id, ())
states: dict[str, int] = {}
for root in sorted(node_ids):
if states.get(root) == 2:
continue
path: list[str] = []
stack: list[tuple[str, int]] = [(root, 0)]
while stack:
node_id, child_index = stack[-1]
if states.get(node_id, 0) == 0:
states[node_id] = 1
path.append(node_id)
targets = dependencies[node_id]
if child_index < len(targets):
target = targets[child_index]
stack[-1] = (node_id, child_index + 1)
state = states.get(target, 0)
if state == 1:
raise DocForgeError(
"dependency_cycle",
"depends_on relationships contain a cycle",
path=(*path, target),
)
if state == 0:
stack.append((target, 0))
continue
stack.pop()
path.pop()
states[node_id] = 2
def validate_source_layout(nodes: tuple[Node, ...]) -> None:
@ -571,6 +724,8 @@ class Project:
def __init__(self, descriptor: ProjectDescriptor) -> None:
self.descriptor = descriptor
self._captured_generation: _CapturedGeneration | None = None
self._generation_receipt_cache: _ParsedGenerationReceipt | None = None
@classmethod
def open(cls, project_root: str | Path) -> Project:
@ -583,25 +738,37 @@ class Project:
return cls(_load_descriptor(root))
def load(self) -> ProjectSnapshot:
increment("project_loads")
descriptor_bytes = self.descriptor.descriptor_path.read_bytes()
if hashlib.sha256(descriptor_bytes).hexdigest() != self.descriptor.descriptor_hash:
raise DocForgeError(
"source_changed", "Project descriptor changed after the project was opened"
)
ordered_sources = self.canonical_source_paths()
captured = {
path: path.read_bytes()
for path in (
self.descriptor.descriptor_path,
*self.descriptor.authority_files,
*ordered_sources,
)
}
ordered_sources, ordered_directories = self._canonical_inventory()
generation_paths = (
self.descriptor.descriptor_path,
*self.descriptor.authority_files,
*ordered_sources,
)
before_generation = _file_generation(self.descriptor.root, generation_paths)
before_directories = _directory_generation(
self.descriptor.root,
ordered_directories,
)
captured = {path: path.read_bytes() for path in generation_paths}
nodes: list[Node] = []
edges: list[Edge] = []
for path in ordered_sources:
source_nodes, source_edges = _load_source_file(self.descriptor, path, captured[path])
raw = captured[path]
increment("source_files_parsed")
increment("source_bytes_parsed", len(raw))
with stage("source.parse"):
source_nodes, source_edges = _load_source_file(
self.descriptor,
path,
raw,
)
nodes.extend(source_nodes)
edges.extend(source_edges)
if len(nodes) > self.descriptor.limits.max_nodes:
@ -619,7 +786,8 @@ class Project:
"invalid_config", "Context profile requires missing nodes", nodes=missing
)
if self.canonical_source_paths() != ordered_sources:
current_sources, current_directories = self._canonical_inventory()
if current_sources != ordered_sources or current_directories != ordered_directories:
raise DocForgeError("source_changed", "Canonical source set changed during loading")
for path, raw in captured.items():
if not path.is_file() or path.read_bytes() != raw:
@ -628,6 +796,16 @@ class Project:
"Canonical source changed during loading",
source=path.relative_to(self.descriptor.root).as_posix(),
)
after_generation = _file_generation(self.descriptor.root, generation_paths)
after_directories = _directory_generation(
self.descriptor.root,
ordered_directories,
)
if after_generation != before_generation or after_directories != before_directories:
raise DocForgeError(
"source_changed",
"Canonical source metadata changed during loading",
)
digest = hashlib.sha256()
for path in sorted(
@ -637,21 +815,203 @@ class Project:
digest.update(relative.encode())
digest.update(b"\0")
digest.update(hashlib.sha256(captured[path]).digest())
digest.update(b"docforge-core:0.7.1:index:1")
return ProjectSnapshot(
digest.update(GENERIC_SOURCE_CONTRACT.encode("ascii"))
source_hash = digest.hexdigest()
revision = _revision(self.descriptor.root)
snapshot = ProjectSnapshot(
descriptor=self.descriptor,
nodes=ordered_nodes,
edges=ordered_edges,
source_hash=digest.hexdigest(),
revision=_revision(self.descriptor.root),
source_hash=source_hash,
revision=revision,
)
self._captured_generation = _CapturedGeneration(
source_hash=source_hash,
revision=revision,
files=after_generation,
directories=after_directories,
)
return snapshot
@property
def generation_path(self) -> Path:
"""Return the confined disposable receipt for one verified source generation."""
return self.descriptor.cache_root / "source-generation.json"
def incremental_state(self) -> ProjectState | None:
"""Return current source identity without reading or parsing canonical source bytes."""
increment("source_generation_checks")
with stage("source.generation"):
return self._incremental_state()
def _incremental_state(self) -> ProjectState | None:
path = self.generation_path
signature = _receipt_signature(path)
if signature is None:
self._generation_receipt_cache = None
return None
receipt = self._generation_receipt_cache
if receipt is None or receipt.signature != signature:
receipt = self._parse_generation_receipt(path, signature)
self._generation_receipt_cache = receipt
if receipt is None:
return None
try:
current_directories = _directory_generation(
self.descriptor.root,
receipt.directory_paths,
)
except DocForgeError:
return None
if receipt.directories != cast(tuple[tuple[object, ...], ...], current_directories):
return None
try:
current_files = _file_generation(self.descriptor.root, receipt.file_paths)
except DocForgeError:
return None
if receipt.files != cast(tuple[tuple[object, ...], ...], current_files):
return None
if _revision(self.descriptor.root) != receipt.revision:
return None
return ProjectState(
source_hash=receipt.source_hash,
revision=receipt.revision,
)
def _parse_generation_receipt(
self,
path: Path,
signature: tuple[int, int, int, int, int],
) -> _ParsedGenerationReceipt | None:
try:
parsed: object = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return None
if _receipt_signature(path) != signature or not isinstance(parsed, dict):
return None
payload = cast(dict[str, object], parsed)
source_hash = payload.get("source_hash")
revision = payload.get("revision")
files_value = payload.get("files")
directories_value = payload.get("directories")
if (
payload.get("schema_version") != SOURCE_GENERATION_SCHEMA_VERSION
or payload.get("source_contract") != GENERIC_SOURCE_CONTRACT
or payload.get("project_id") != self.descriptor.project_id
or payload.get("project_root_fingerprint")
!= project_root_fingerprint(self.descriptor.root)
or payload.get("adapter") != self.descriptor.adapter
or not isinstance(source_hash, str)
or len(source_hash) != 64
or not isinstance(revision, str)
or not isinstance(files_value, list)
or not isinstance(directories_value, list)
):
return None
directory_paths = _receipt_paths(
self.descriptor.root,
cast(list[object], directories_value),
width=6,
)
file_paths = _receipt_paths(
self.descriptor.root,
cast(list[object], files_value),
width=7,
)
if directory_paths is None or file_paths is None:
return None
return _ParsedGenerationReceipt(
signature=signature,
source_hash=source_hash,
revision=revision,
files=tuple(
tuple(cast(list[object], item))
for item in cast(list[object], files_value)
if isinstance(item, list)
),
directories=tuple(
tuple(cast(list[object], item))
for item in cast(list[object], directories_value)
if isinstance(item, list)
),
file_paths=file_paths,
directory_paths=directory_paths,
)
def record_generation(self, snapshot: ProjectSnapshot) -> None:
"""Persist a generation only after its complete derived index was verified."""
captured = self._captured_generation
if (
captured is None
or captured.source_hash != snapshot.source_hash
or captured.revision != snapshot.revision
):
raise DocForgeError(
"source_changed",
"Cannot record a source generation without a matching complete load",
)
root = self.descriptor.cache_root
path = self.generation_path
if path.parent != root or path.is_symlink() or root.resolve(strict=False) != root:
raise DocForgeError("path_escape", "Source generation receipt path is not safe")
root.mkdir(parents=True, exist_ok=True)
if not root.is_dir() or root.resolve(strict=False) != root:
raise DocForgeError("path_escape", "Source generation receipt directory is not safe")
payload = {
"schema_version": SOURCE_GENERATION_SCHEMA_VERSION,
"source_contract": GENERIC_SOURCE_CONTRACT,
"project_id": self.descriptor.project_id,
"project_root_fingerprint": project_root_fingerprint(self.descriptor.root),
"adapter": self.descriptor.adapter,
"source_hash": captured.source_hash,
"revision": captured.revision,
"files": [list(identity) for identity in captured.files],
"directories": [list(identity) for identity in captured.directories],
}
raw = json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n"
descriptor, temporary_name = tempfile.mkstemp(prefix=".source-generation-", dir=root)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(raw)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
directory_descriptor = os.open(root, os.O_RDONLY)
try:
os.fsync(directory_descriptor)
finally:
os.close(directory_descriptor)
except Exception:
temporary.unlink(missing_ok=True)
raise
def canonical_source_paths(self) -> tuple[Path, ...]:
"""Return the deterministic confined canonical source set."""
sources, _ = self._canonical_inventory()
return sources
def _canonical_inventory(self) -> tuple[tuple[Path, ...], tuple[Path, ...]]:
"""Return deterministic canonical files and membership-bearing directories."""
source_paths: set[Path] = set()
directories: set[Path] = set()
for content_root in self.descriptor.content_roots:
directories.add(content_root)
for path in content_root.rglob("*"):
if path.is_dir():
resolved_directory = path.resolve()
if not resolved_directory.is_relative_to(self.descriptor.root):
raise DocForgeError(
"path_escape",
"Canonical source directory resolves outside project root",
)
directories.add(resolved_directory)
continue
if path.suffix not in {".md", ".toml"} or not path.is_file():
continue
resolved = path.resolve()
@ -665,7 +1025,11 @@ class Project:
)
if not ordered_sources:
raise DocForgeError("empty_project", "No canonical Markdown or TOML sources were found")
return tuple(ordered_sources)
ordered_directories = sorted(
directories,
key=lambda path: path.relative_to(self.descriptor.root).as_posix(),
)
return tuple(ordered_sources), tuple(ordered_directories)
def validate_proposal(
self,

View file

@ -4,17 +4,33 @@ from __future__ import annotations
import fcntl
import hashlib
import json
import os
import stat
import tempfile
from collections.abc import Callable, Generator
from contextlib import contextmanager
from pathlib import Path
from typing import cast
from .changesets import ChangesetStore
from .errors import DocForgeError
from .models import ProjectService, ProjectSnapshot, RenderConfig, RenderView
from .models import (
GenerationRecordingProject,
IncrementalStateProject,
ProjectDescriptor,
ProjectService,
ProjectSnapshot,
ProjectState,
RenderConfig,
RenderView,
)
from .project import project_root_fingerprint
from .render_contract import PreparedRender, relative_output, renderer_for
from .telemetry import increment, stage
RENDER_RECEIPT_SCHEMA_VERSION = 1
MAX_RENDER_RECEIPT_BYTES = 64_000
class RenderService:
@ -25,6 +41,51 @@ class RenderService:
self.changesets = changesets or ChangesetStore(project)
def status(self, view_id: str | None = None) -> dict[str, object]:
"""Report publication state from bounded receipts without rendering canonical content."""
with stage("render.status"):
return self._status(view_id)
def _status(self, view_id: str | None = None) -> dict[str, object]:
descriptor = self.project.descriptor
config = descriptor.render
current_state = self._current_state()
if config is None:
return self._status_result(
descriptor,
current_state,
configured=False,
state="not_configured",
verification="receipt",
outputs=[],
)
views = self._views(config, view_id)
first_outputs = [self._receipt_status(descriptor, view, current_state) for view in views]
outputs = [self._receipt_status(descriptor, view, current_state) 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_state = self._current_state()
if final_state != current_state:
for output in outputs:
if output["state"] == "current":
output["state"] = "stale"
output["reason"] = "source_changed_during_status"
identity = final_state if final_state is not None else current_state
return self._status_result(
descriptor,
identity,
configured=True,
state="current" if all(item["state"] == "current" for item in outputs) else "stale",
verification="receipt",
outputs=outputs,
)
def deep_status(self, view_id: str | None = None) -> dict[str, object]:
"""Recompute render output as the explicit side-effect-free equivalence oracle."""
snapshot = self.project.load()
config = snapshot.descriptor.render
if config is None:
@ -32,11 +93,20 @@ class RenderService:
snapshot,
configured=False,
state="not_configured",
verification="deep",
outputs=[],
)
views = self._views(config, view_id)
outputs: list[dict[str, object]] = []
for view in views:
template_before = self._safe_file_identity(
snapshot.descriptor.root,
view.template_path,
)
output_before = self._safe_file_identity(
snapshot.descriptor.root,
view.output_path,
)
prepared, _ = self._prepare(snapshot, view, changeset_hash=None)
state = "missing"
actual_hash: str | None = None
@ -48,15 +118,35 @@ class RenderService:
state = "oversized"
else:
raw = output.read_bytes()
actual_hash = hashlib.sha256(raw).hexdigest()
increment("render_output_bytes_hashed", len(raw))
with stage("render.output_hash"):
actual_hash = hashlib.sha256(raw).hexdigest()
state = "current" if actual_hash == prepared.output_hash else "stale"
outputs.append(
self._view_result(snapshot, view, prepared, state=state, actual_hash=actual_hash)
result = self._view_result(
snapshot,
view,
prepared,
state=state,
actual_hash=actual_hash,
)
if template_before != self._safe_file_identity(
snapshot.descriptor.root, view.template_path
) or output_before != self._safe_file_identity(
snapshot.descriptor.root, view.output_path
):
result["state"] = "stale"
result["reason"] = "publication_changed_during_deep_status"
outputs.append(result)
current = self.project.load()
if current.source_hash != snapshot.source_hash or current.revision != snapshot.revision:
for output in outputs:
output["state"] = "stale"
output["reason"] = "source_changed_during_deep_status"
return self._result(
snapshot,
configured=True,
state="current" if all(item["state"] == "current" for item in outputs) else "stale",
verification="deep",
outputs=outputs,
)
@ -71,10 +161,32 @@ class RenderService:
prepared.output,
verify=lambda: self._verify_canonical(snapshot, view, template_bytes),
)
receipt: dict[str, object]
state = "current"
try:
if isinstance(self.project, GenerationRecordingProject):
self.project.record_generation(snapshot)
receipt = self._publish_receipt(snapshot, view, prepared)
except (DocForgeError, OSError) as error:
state = "degraded"
receipt = {
"state": "failed",
"error": (
error.as_dict()
if isinstance(error, DocForgeError)
else {
"code": "render_receipt_failure",
"message": "Rendered output was published but its receipt failed",
"details": {},
}
),
}
return self._result(
snapshot,
configured=True,
state="current",
state=state,
publication="published",
receipt=receipt,
output=self._view_result(
snapshot,
view,
@ -84,6 +196,496 @@ class RenderService:
),
)
def _receipt_status(
self,
descriptor: ProjectDescriptor,
view: RenderView,
current_state: ProjectState | None,
) -> dict[str, object]:
receipt, receipt_state = self._read_receipt(view)
output_state = self._safe_file_identity(descriptor.root, view.output_path)
if output_state is None:
state = "unsafe" if view.output_path.is_symlink() else "missing"
return self._receipt_view_result(
descriptor,
view,
receipt,
state=state,
reason="output_not_safe" if state == "unsafe" else "output_missing",
)
if receipt is None:
return self._receipt_view_result(
descriptor,
view,
receipt,
state="unverified",
reason=receipt_state,
)
if not self._receipt_matches_binding(descriptor, view, receipt):
return self._receipt_view_result(
descriptor,
view,
receipt,
state="unverified",
reason="foreign_or_incompatible_receipt",
)
template_state = self._safe_file_identity(descriptor.root, view.template_path)
if template_state is None:
return self._receipt_view_result(
descriptor,
view,
receipt,
state="unsafe",
reason="template_not_safe",
)
if receipt.get("template_file") != template_state:
return self._receipt_view_result(
descriptor,
view,
receipt,
state="stale",
reason="template_changed",
)
if receipt.get("output_file") != output_state:
return self._receipt_view_result(
descriptor,
view,
receipt,
state="stale",
reason="output_changed",
)
if current_state is None:
reason = (
"source_generation_unavailable"
if isinstance(self.project, GenerationRecordingProject)
else "source_generation_unsupported"
)
return self._receipt_view_result(
descriptor,
view,
receipt,
state=(
"stale"
if isinstance(self.project, GenerationRecordingProject)
else "unverified"
),
reason=reason,
)
if (
receipt.get("source_hash") != current_state.source_hash
or receipt.get("revision") != current_state.revision
):
return self._receipt_view_result(
descriptor,
view,
receipt,
state="stale",
reason="source_generation_changed",
)
return self._receipt_view_result(
descriptor,
view,
receipt,
state="current",
reason=None,
)
def _publish_receipt(
self,
snapshot: ProjectSnapshot,
view: RenderView,
prepared: PreparedRender,
) -> dict[str, object]:
source_before = self._current_state()
if isinstance(self.project, GenerationRecordingProject) and (
source_before is None
or source_before.source_hash != snapshot.source_hash
or source_before.revision != snapshot.revision
):
raise DocForgeError(
"render_receipt_failure",
"Canonical source changed before render receipt publication",
)
if source_before is not None and (
source_before.source_hash != snapshot.source_hash
or source_before.revision != snapshot.revision
):
raise DocForgeError(
"render_receipt_failure",
"Canonical source changed before render receipt publication",
)
template_file, template_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.template_path,
snapshot.descriptor.limits.max_template_bytes,
)
output_file, output_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.output_path,
snapshot.descriptor.limits.max_render_bytes,
)
if (
template_hash != prepared.template_hash
or output_hash != prepared.output_hash
or output_file["size"] != len(prepared.output)
):
raise DocForgeError(
"render_receipt_failure",
"Published render files do not match the verified render",
)
final_template_file, final_template_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.template_path,
snapshot.descriptor.limits.max_template_bytes,
)
final_output_file, final_output_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.output_path,
snapshot.descriptor.limits.max_render_bytes,
)
source_after = self._current_state()
if (
template_file != final_template_file
or output_file != final_output_file
or template_hash != final_template_hash
or output_hash != final_output_hash
or source_before != source_after
):
raise DocForgeError(
"render_receipt_failure",
"Render publication changed while its receipt was being prepared",
)
payload: dict[str, object] = {
"schema_version": RENDER_RECEIPT_SCHEMA_VERSION,
"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_id": view.view_id,
"view_config_hash": self._view_config_hash(snapshot.descriptor, view),
"renderer": prepared.renderer,
"renderer_version": prepared.renderer_version,
"render_identity": prepared.render_identity,
"template_hash": prepared.template_hash,
"output_hash": prepared.output_hash,
"output_bytes": len(prepared.output),
"template_file": final_template_file,
"output_file": final_output_file,
}
raw = json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n"
if len(raw) > MAX_RENDER_RECEIPT_BYTES:
raise DocForgeError(
"render_receipt_failure",
"Render publication receipt exceeds its fixed size limit",
)
root = self._receipt_root(create=True)
path = root / f"{view.view_id}.json"
if path.is_symlink():
raise DocForgeError(
"path_escape",
"Render publication receipt path is not safe",
)
descriptor, temporary_name = tempfile.mkstemp(prefix=".render-receipt-", dir=root)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(raw)
handle.flush()
os.fsync(handle.fileno())
last_template_file, last_template_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.template_path,
snapshot.descriptor.limits.max_template_bytes,
)
last_output_file, last_output_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.output_path,
snapshot.descriptor.limits.max_render_bytes,
)
if (
last_template_file != final_template_file
or last_output_file != final_output_file
or last_template_hash != final_template_hash
or last_output_hash != final_output_hash
or self._current_state() != source_after
):
raise DocForgeError(
"render_receipt_failure",
"Render publication changed before receipt publication",
)
os.replace(temporary, path)
directory_descriptor = os.open(root, os.O_RDONLY)
try:
os.fsync(directory_descriptor)
finally:
os.close(directory_descriptor)
except Exception:
temporary.unlink(missing_ok=True)
raise
return {
"state": "current",
"schema_version": RENDER_RECEIPT_SCHEMA_VERSION,
"path": path.relative_to(snapshot.descriptor.root).as_posix(),
}
def _read_receipt(
self,
view: RenderView,
) -> tuple[dict[str, object] | None, str]:
try:
root = self._receipt_root(create=False)
except DocForgeError:
return None, "receipt_root_unsafe"
path = root / f"{view.view_id}.json"
if path.is_symlink():
return None, "receipt_unsafe"
if not path.is_file():
return None, "receipt_missing"
try:
if path.stat().st_size > MAX_RENDER_RECEIPT_BYTES:
return None, "receipt_oversized"
raw = path.read_bytes()
if len(raw) > MAX_RENDER_RECEIPT_BYTES:
return None, "receipt_oversized"
parsed: object = json.loads(raw)
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return None, "receipt_corrupt"
if not isinstance(parsed, dict):
return None, "receipt_corrupt"
return cast(dict[str, object], parsed), "receipt"
def _receipt_root(self, *, create: bool) -> Path:
cache_root = self.project.descriptor.cache_root
root = cache_root / "render-receipts"
if (
cache_root.resolve(strict=False) != cache_root
or root.is_symlink()
or root.resolve(strict=False) != root
or not root.is_relative_to(cache_root)
):
raise DocForgeError("path_escape", "Render receipt root is not safe")
if create:
cache_root.mkdir(parents=True, exist_ok=True)
root.mkdir(parents=True, exist_ok=True)
if root.exists() and not root.is_dir():
raise DocForgeError("path_escape", "Render receipt root is not safe")
return root
@staticmethod
def _safe_file_identity(root: Path, path: Path) -> dict[str, object] | None:
if path.is_symlink() or path.resolve(strict=False) != path or not path.is_relative_to(root):
return None
try:
current = path.lstat()
except OSError:
return None
if not stat.S_ISREG(current.st_mode):
return None
return {
"path": path.relative_to(root).as_posix(),
"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,
}
@staticmethod
def _verified_file_digest(
root: Path,
path: Path,
maximum: int,
) -> tuple[dict[str, object], str]:
if path.is_symlink() or path.resolve(strict=False) != path or not path.is_relative_to(root):
raise DocForgeError(
"render_receipt_failure",
"Render publication file is not safe for verification",
)
try:
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as error:
raise DocForgeError(
"render_receipt_failure",
"Render publication file is not readable for verification",
) 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:
raise DocForgeError(
"render_receipt_failure",
"Render publication file failed receipt validation",
)
digest = hashlib.file_digest(handle, "sha256").hexdigest()
return (
{
"path": path.relative_to(root).as_posix(),
"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,
},
digest,
)
@staticmethod
def _view_config_hash(descriptor: ProjectDescriptor, view: RenderView) -> str:
payload = {
"view_id": view.view_id,
"renderer": view.renderer,
"template": view.template_path.relative_to(descriptor.root).as_posix(),
"output": view.output_path.relative_to(descriptor.root).as_posix(),
"title": view.title,
"families": list(view.families),
}
return hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
def _receipt_matches_binding(
self,
descriptor: ProjectDescriptor,
view: RenderView,
receipt: dict[str, object],
) -> bool:
required = {
"schema_version",
"project_id",
"project_root_fingerprint",
"adapter",
"revision",
"source_hash",
"view_id",
"view_config_hash",
"renderer",
"renderer_version",
"render_identity",
"template_hash",
"output_hash",
"output_bytes",
"template_file",
"output_file",
}
renderer = renderer_for(view)
template_file = receipt.get("template_file")
output_file = receipt.get("output_file")
return (
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)
and receipt.get("adapter") == descriptor.adapter
and receipt.get("view_id") == view.view_id
and receipt.get("view_config_hash") == self._view_config_hash(descriptor, view)
and receipt.get("renderer") == renderer.renderer_id
and receipt.get("renderer_version") == renderer.renderer_version
and self._is_hash(receipt.get("source_hash"))
and isinstance(receipt.get("revision"), str)
and bool(receipt.get("revision"))
and self._is_hash(receipt.get("view_config_hash"))
and self._is_hash(receipt.get("render_identity"))
and self._is_hash(receipt.get("template_hash"))
and self._is_hash(receipt.get("output_hash"))
and type(receipt.get("output_bytes")) is int
and 0 <= cast(int, receipt["output_bytes"]) <= descriptor.limits.max_render_bytes
and self._valid_receipt_file(
template_file,
view.template_path.relative_to(descriptor.root).as_posix(),
descriptor.limits.max_template_bytes,
)
and self._valid_receipt_file(
output_file,
view.output_path.relative_to(descriptor.root).as_posix(),
descriptor.limits.max_render_bytes,
)
and cast(dict[str, object], output_file)["size"] == receipt.get("output_bytes")
)
@staticmethod
def _is_hash(value: object) -> bool:
return (
isinstance(value, str)
and len(value) == 64
and all(character in "0123456789abcdef" for character in value)
)
@staticmethod
def _valid_receipt_file(
value: object,
expected_path: str,
maximum: int,
) -> bool:
if not isinstance(value, dict):
return False
payload = cast(dict[str, object], value)
return (
set(payload)
== {
"path",
"device",
"inode",
"mode",
"size",
"mtime_ns",
"ctime_ns",
}
and payload.get("path") == expected_path
and all(
type(payload.get(key)) is int and cast(int, payload[key]) >= 0
for key in ("device", "inode", "mode", "size", "mtime_ns", "ctime_ns")
)
and cast(int, payload["size"]) <= maximum
)
@staticmethod
def _receipt_view_result(
descriptor: ProjectDescriptor,
view: RenderView,
receipt: dict[str, object] | None,
*,
state: str,
reason: str | None,
) -> dict[str, object]:
payload = receipt or {}
return {
"view_id": view.view_id,
"renderer": payload.get("renderer", view.renderer),
"renderer_version": payload.get("renderer_version"),
"render_identity": payload.get("render_identity"),
"expected_output_hash": payload.get("output_hash"),
"actual_output_hash": (payload.get("output_hash") if state == "current" else None),
"template_hash": payload.get("template_hash"),
"path": view.output_path.relative_to(descriptor.root).as_posix(),
"state": state,
"reason": reason,
"verification": "receipt",
"receipt_schema_version": payload.get("schema_version"),
}
def _current_state(self) -> ProjectState | None:
if isinstance(self.project, IncrementalStateProject):
return self.project.incremental_state()
return None
@staticmethod
def _status_result(
descriptor: ProjectDescriptor,
identity: ProjectState | None,
**payload: object,
) -> dict[str, object]:
return {
"status": "ok",
"project_id": descriptor.project_id,
"project_root_fingerprint": project_root_fingerprint(descriptor.root),
"adapter": descriptor.adapter,
"revision": identity.revision if identity is not None else "unknown",
"source_hash": identity.source_hash if identity is not None else None,
**payload,
}
def preview(self, changeset_id: str, view_id: str) -> dict[str, object]:
with self._lock():
snapshot, changeset_hash = self.changesets.projected_snapshot(changeset_id)
@ -130,13 +732,16 @@ class RenderService:
*,
changeset_hash: str | None,
) -> tuple[PreparedRender, bytes]:
increment("render_prepare_calls")
template = self._template_bytes(snapshot, view)
prepared = renderer_for(view).prepare(
snapshot,
view,
template,
changeset_hash=changeset_hash,
)
with stage("render.prepare"):
prepared = renderer_for(view).prepare(
snapshot,
view,
template,
changeset_hash=changeset_hash,
)
increment("render_output_bytes_built", len(prepared.output))
if len(prepared.output) > snapshot.descriptor.limits.max_render_bytes:
raise DocForgeError("render_too_large", "Rendered output exceeds the configured limit")
return prepared, template

220
src/docforge/telemetry.py Normal file
View file

@ -0,0 +1,220 @@
"""Bounded request-local diagnostics for repository gates and explicit profiling."""
from __future__ import annotations
import time
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Literal
CounterName = Literal[
"project_loads",
"source_files_parsed",
"source_bytes_parsed",
"adapter_projection_loads",
"adapter_source_extractions",
"source_generation_checks",
"index_checks",
"index_synchronizations",
"index_builds",
"render_prepare_calls",
"render_output_bytes_built",
"render_output_bytes_hashed",
"viewer_manager_requests",
]
StageName = Literal[
"source.generation",
"source.parse",
"adapter.projection",
"adapter.extract",
"index.check",
"index.synchronize",
"index.build",
"index.read",
"render.status",
"render.prepare",
"render.output_hash",
"visualization.status",
"viewer.manager",
"mcp.runtime_validation",
]
COUNTER_NAMES: tuple[CounterName, ...] = (
"project_loads",
"source_files_parsed",
"source_bytes_parsed",
"adapter_projection_loads",
"adapter_source_extractions",
"source_generation_checks",
"index_checks",
"index_synchronizations",
"index_builds",
"render_prepare_calls",
"render_output_bytes_built",
"render_output_bytes_hashed",
"viewer_manager_requests",
)
STAGE_NAMES: frozenset[StageName] = frozenset(
{
"source.generation",
"source.parse",
"adapter.projection",
"adapter.extract",
"index.check",
"index.synchronize",
"index.build",
"index.read",
"render.status",
"render.prepare",
"render.output_hash",
"visualization.status",
"viewer.manager",
"mcp.runtime_validation",
}
)
OPERATION_NAMES = frozenset(
{
"test",
"benchmark.m1",
"mcp.invoke",
"mcp.bootstrap",
"mcp.sync",
"mcp.project_info",
"mcp.contract",
"mcp.get_node",
"mcp.get_logic",
"mcp.search",
"mcp.filter",
"mcp.backlinks",
"mcp.dependencies",
"mcp.impact",
"mcp.context",
"mcp.validate_project",
"mcp.render_status",
"mcp.visualize",
"mcp.visualization_status",
"mcp.stop_visualization",
"mcp.changeset",
"mcp.mutation",
"cli.onboard",
"cli.info",
"cli.validate",
"cli.build",
"cli.reindex",
"cli.sync",
"cli.check",
"cli.validate-index",
"cli.show",
"cli.search",
"cli.filter",
"cli.backlinks",
"cli.dependencies",
"cli.impact",
"cli.context",
"cli.render",
"cli.render-status",
"cli.preview",
"cli.apply",
"cli.visualize",
"cli.visualization-status",
"cli.visualization-stop",
}
)
@dataclass
class _StageAggregate:
calls: int = 0
elapsed_ns: int = 0
@dataclass
class Collector:
"""One bounded aggregate owned by the current request context."""
operation: str
counters: dict[CounterName, int] = field(
default_factory=lambda: {name: 0 for name in COUNTER_NAMES}
)
stages: dict[StageName, _StageAggregate] = field(default_factory=lambda: {})
elapsed_ns: int = 0
def as_dict(self, *, outcome: str) -> dict[str, object]:
if outcome not in {"ok", "error"}:
raise ValueError("Telemetry outcome must be ok or error")
return {
"schema_version": 1,
"operation": self.operation,
"outcome": outcome,
"elapsed_ns": self.elapsed_ns,
"stages": {
name: {
"calls": aggregate.calls,
"elapsed_ns": aggregate.elapsed_ns,
}
for name, aggregate in sorted(self.stages.items())
},
"counters": {name: self.counters[name] for name in COUNTER_NAMES},
}
_CURRENT: ContextVar[Collector | None] = ContextVar(
"docforge_telemetry",
default=None,
)
@contextmanager
def request(
operation: str,
*,
enabled: bool,
) -> Generator[Collector | None, None, None]:
"""Collect one explicit request without affecting the disabled path."""
if operation not in OPERATION_NAMES:
raise ValueError("Unknown telemetry operation")
if not enabled:
yield None
return
collector = Collector(operation=operation)
token = _CURRENT.set(collector)
started = time.perf_counter_ns()
try:
yield collector
finally:
collector.elapsed_ns = time.perf_counter_ns() - started
_CURRENT.reset(token)
def increment(counter: CounterName | str, amount: int = 1) -> None:
"""Increment one fixed counter when a request collector is active."""
if counter not in COUNTER_NAMES:
raise ValueError("Unknown telemetry counter")
if type(amount) is not int or amount < 0:
raise ValueError("Telemetry increments must be nonnegative integers")
collector = _CURRENT.get()
if collector is not None:
collector.counters[counter] += amount
@contextmanager
def stage(name: StageName | str) -> Generator[None, None, None]:
"""Aggregate one fixed stage while avoiding a clock read when disabled."""
if name not in STAGE_NAMES:
raise ValueError("Unknown telemetry stage")
collector = _CURRENT.get()
if collector is None:
yield
return
started = time.perf_counter_ns()
try:
yield
finally:
aggregate = collector.stages.setdefault(name, _StageAggregate())
aggregate.calls += 1
aggregate.elapsed_ns += time.perf_counter_ns() - started

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import argparse
import json
import math
import os
import plistlib
import secrets
@ -25,14 +26,27 @@ from typing import BinaryIO, cast
from .errors import DocForgeError
from .index import ProjectIndex
from .models import IncrementalStateProject
from .project import project_root_fingerprint
from .telemetry import increment, stage
from .visualization import VISUALIZATION_TEMPLATE, VisualizationIndexSnapshot
MANAGER_PROTOCOL = "docforge-viewer-manager@1"
MANAGER_RUNTIME = "viewer-manager@1"
MANAGER_PROTOCOL = "docforge-viewer-manager@2"
MANAGER_RUNTIME = "viewer-manager@2"
DEFAULT_IDLE_TIMEOUT_SECONDS = 3600.0
DEFAULT_CHECK_INTERVAL_SECONDS = 30.0
MAX_MESSAGE_BYTES = 1_000_000
WORKER_SNAPSHOT_KEYS = frozenset(
{
"project_id",
"project_root_fingerprint",
"revision",
"source_hash",
"adapter",
"node_count",
"edge_count",
}
)
def default_runtime_root() -> Path:
@ -224,6 +238,12 @@ class _ManagedWorker:
last_activity_at: float
@dataclass(frozen=True)
class _WorkerHealth:
last_activity_at: float
index_state: str
class ViewerManager:
"""Own project viewer processes behind an authenticated loopback control API."""
@ -425,12 +445,13 @@ class ViewerManager:
self._workers.pop(key, None)
self._stop_worker(worker)
return {"status": "ok", "state": "not_running"}
worker.last_activity_at = activity
worker.last_activity_at = activity.last_activity_at
return {
"status": "ok",
"state": "running",
"snapshot": dict(worker.snapshot),
"idle_seconds": max(0, int(time.time() - activity)),
"index_state": activity.index_state,
"idle_seconds": max(0, int(time.time() - activity.last_activity_at)),
"idle_timeout_seconds": self.idle_timeout_seconds,
}
@ -500,9 +521,9 @@ class ViewerManager:
if worker.snapshot != snapshot.identity or worker.process.poll() is not None:
return False
activity = self._health(worker)
if activity is None:
if activity is None or activity.index_state != "current":
return False
worker.last_activity_at = activity
worker.last_activity_at = activity.last_activity_at
return True
@staticmethod
@ -517,7 +538,7 @@ class ViewerManager:
worker.process.wait(timeout=2)
@staticmethod
def _health(worker: _ManagedWorker) -> float | None:
def _health(worker: _ManagedWorker) -> _WorkerHealth | None:
request = urllib.request.Request(
f"http://127.0.0.1:{worker.port}/{worker.token}/api/health",
headers={"Accept": "application/json"},
@ -530,10 +551,25 @@ class ViewerManager:
if not isinstance(payload, dict):
return None
payload = cast(dict[str, object], payload)
if payload.get("viewer") != "alive":
if payload.get("status") != "ok" or payload.get("viewer") != "alive":
return None
if frozenset(worker.snapshot) != WORKER_SNAPSHOT_KEYS:
return None
if any(payload.get(key) != value for key, value in worker.snapshot.items()):
return None
activity = payload.get("last_activity_at")
return float(activity) if isinstance(activity, int | float) else None
index_state = payload.get("index_state")
if (
isinstance(activity, bool)
or not isinstance(activity, int | float)
or not math.isfinite(activity)
or index_state not in {"current", "stale"}
):
return None
return _WorkerHealth(
last_activity_at=float(activity),
index_state=cast(str, index_state),
)
def _result(
self,
@ -574,11 +610,11 @@ class ViewerManager:
with self._lock:
expired: list[tuple[str, _ManagedWorker]] = []
for key, worker in self._workers.items():
activity = self._health(worker)
if activity is None or activity < cutoff:
health = self._health(worker)
if health is None or health.last_activity_at < cutoff:
expired.append((key, worker))
else:
worker.last_activity_at = activity
worker.last_activity_at = health.last_activity_at
for key, worker in expired:
self._workers.pop(key, None)
self._stop_worker(worker)
@ -635,7 +671,78 @@ class ViewerManagerClient:
return self._lifecycle_request("stop")
def status(self) -> dict[str, object]:
return self._lifecycle_request("status")
with stage("visualization.status"):
response = self._lifecycle_request("status")
lifecycle = response.get("state")
if lifecycle not in {"running", "not_running"}:
raise DocForgeError(
"visualization_unavailable",
"Viewer manager returned an invalid lifecycle state",
)
if lifecycle != "running":
return {
**response,
"revision": "unknown",
"source_hash": None,
"snapshot_state": "unknown",
"staleness": "unknown",
"freshness": {
"index": "unknown",
"source": "unknown",
},
}
index_value = response.get("index_state")
index_state = (
cast(str, index_value) if index_value in {"current", "stale"} else "unknown"
)
snapshot_value = response.get("snapshot")
snapshot_payload: dict[str, object] = (
cast(dict[str, object], snapshot_value) if isinstance(snapshot_value, dict) else {}
)
source_state = self._source_state(snapshot_payload)
revision = snapshot_payload.get("revision")
source_hash = snapshot_payload.get("source_hash")
snapshot_state = (
"stale"
if "stale" in {index_state, source_state}
else (
"current"
if index_state == "current" and source_state == "current"
else "unknown"
)
)
return {
**response,
"revision": revision if isinstance(revision, str) else "unknown",
"source_hash": source_hash if isinstance(source_hash, str) else None,
"snapshot_state": snapshot_state,
"staleness": snapshot_state,
"freshness": {
"index": index_state,
"source": source_state,
},
}
def _source_state(self, snapshot: object) -> str:
project = self.index.project
if not isinstance(project, IncrementalStateProject) or not isinstance(snapshot, dict):
return "unknown"
snapshot = cast(dict[str, object], snapshot)
revision = snapshot.get("revision")
source_hash = snapshot.get("source_hash")
if not isinstance(revision, str) or not isinstance(source_hash, str):
return "unknown"
try:
state = project.incremental_state()
except (DocForgeError, OSError, RuntimeError, TypeError, ValueError):
return "unknown"
if state is None:
return "unknown"
return (
"current"
if state.revision == revision and state.source_hash == source_hash
else "stale"
)
def _lifecycle_request(self, action: str) -> dict[str, object]:
descriptor = self.index.project.descriptor
@ -654,6 +761,11 @@ class ViewerManagerClient:
}
def _request(self, request: dict[str, object]) -> dict[str, object]:
increment("viewer_manager_requests")
with stage("viewer.manager"):
return self._request_core(request)
def _request_core(self, request: dict[str, object]) -> dict[str, object]:
state = self._read_state()
host = state["host"]
port = state["port"]

View file

@ -9,6 +9,7 @@ import secrets
import signal
import socket
import sqlite3
import stat
import sys
import tempfile
import threading
@ -73,6 +74,9 @@ LEASE_MONITOR_INTERVAL_SECONDS = 1.0
VISUALIZATION_REGISTRY_NAME = ".visualization.json"
VISUALIZATION_LOCK_NAME = ".visualization.lock"
VISUALIZATION_RUNTIME = "persistent-worker@1"
VISUALIZATION_SNAPSHOT_SCHEMA_VERSION = 1
IndexSignature = tuple[int, int, int, int, int]
class _VisualizationHttpServer(ThreadingHTTPServer):
@ -103,10 +107,13 @@ class VisualizationIndexSnapshot:
self.max_depth = index.project.descriptor.limits.max_traversal_depth
self.identity: dict[str, object] = {key: checked[key] for key in self._IDENTITY_KEYS}
self._stat = self._safe_stat()
self._validate_snapshot()
@classmethod
def from_spec(cls, spec: dict[str, object]) -> VisualizationIndexSnapshot:
snapshot = cls.__new__(cls)
if spec.get("schema_version") != VISUALIZATION_SNAPSHOT_SCHEMA_VERSION:
raise DocForgeError("invalid_index", "Visualization snapshot version is invalid")
path = spec["path"]
title = spec["title"]
project_root = spec["project_root"]
@ -117,11 +124,16 @@ class VisualizationIndexSnapshot:
if (
not isinstance(path, str)
or not isinstance(title, str)
or not title
or not isinstance(project_root, str)
or type(max_source_bytes) is not int
or max_source_bytes < 1
or type(max_query_chars) is not int
or max_query_chars < 1
or type(max_results) is not int
or max_results < 1
or type(max_depth) is not int
or max_depth < 1
):
raise DocForgeError("invalid_index", "Visualization snapshot is invalid")
snapshot.path = Path(path)
@ -131,6 +143,12 @@ class VisualizationIndexSnapshot:
raise DocForgeError("invalid_index", "Visualization project root is invalid") from error
if not snapshot.project_root.is_dir():
raise DocForgeError("invalid_index", "Visualization project root is invalid")
if not snapshot.path.is_absolute():
raise DocForgeError("invalid_index", "Visualization index path is invalid")
try:
snapshot.path.relative_to(snapshot.project_root)
except ValueError as error:
raise DocForgeError("invalid_index", "Visualization index path is invalid") from error
snapshot.title = title
snapshot.max_source_bytes = max_source_bytes
snapshot.max_query_chars = max_query_chars
@ -139,13 +157,59 @@ class VisualizationIndexSnapshot:
identity = spec["identity"]
if not isinstance(identity, dict):
raise DocForgeError("invalid_index", "Visualization identity is invalid")
typed_identity = cast(dict[str, object], identity)
snapshot.identity = {key: typed_identity[key] for key in cls._IDENTITY_KEYS}
snapshot._stat = snapshot._safe_stat()
snapshot.identity = cls._parse_identity(
cast(dict[str, object], identity),
snapshot.project_root,
)
snapshot._stat = cls._parse_index_signature(spec.get("index_signature"))
if snapshot.index_state() != "current":
raise DocForgeError(
"visualization_stale",
"The validated index changed before the visualization worker started",
)
snapshot._validate_snapshot()
return snapshot
@classmethod
def _parse_identity(
cls,
value: dict[str, object],
project_root: Path,
) -> dict[str, object]:
if set(value) != set(cls._IDENTITY_KEYS):
raise DocForgeError("invalid_index", "Visualization identity is invalid")
project_id = value.get("project_id")
fingerprint = value.get("project_root_fingerprint")
revision = value.get("revision")
source_hash = value.get("source_hash")
adapter = value.get("adapter")
node_count = value.get("node_count")
edge_count = value.get("edge_count")
if (
not isinstance(project_id, str)
or not project_id
or not isinstance(fingerprint, str)
or len(fingerprint) != 16
or any(character not in "0123456789abcdef" for character in fingerprint)
or fingerprint != project_root_fingerprint(project_root)
or not isinstance(revision, str)
or not revision
or not isinstance(source_hash, str)
or len(source_hash) != 64
or any(character not in "0123456789abcdef" for character in source_hash)
or not isinstance(adapter, str)
or not adapter
or type(node_count) is not int
or node_count < 0
or type(edge_count) is not int
or edge_count < 0
):
raise DocForgeError("invalid_index", "Visualization identity is invalid")
return {key: value[key] for key in cls._IDENTITY_KEYS}
def spec(self) -> dict[str, object]:
return {
"schema_version": VISUALIZATION_SNAPSHOT_SCHEMA_VERSION,
"path": str(self.path),
"project_root": str(self.project_root),
"title": self.title,
@ -154,8 +218,44 @@ class VisualizationIndexSnapshot:
"max_results": self.max_results,
"max_depth": self.max_depth,
"identity": dict(self.identity),
"index_signature": {
"schema_version": 1,
"device": self._stat[0],
"inode": self._stat[1],
"size": self._stat[2],
"mtime_ns": self._stat[3],
"ctime_ns": self._stat[4],
},
}
@staticmethod
def _parse_index_signature(value: object) -> IndexSignature:
if not isinstance(value, dict):
raise DocForgeError("invalid_index", "Visualization index signature is invalid")
payload = cast(dict[str, object], value)
if payload.get("schema_version") != 1:
raise DocForgeError("invalid_index", "Visualization index signature is invalid")
fields = ("device", "inode", "size", "mtime_ns", "ctime_ns")
values: list[int] = []
for field in fields:
item = payload.get(field)
if type(item) is not int or item < 0:
raise DocForgeError("invalid_index", "Visualization index signature is invalid")
values.append(item)
return cast(IndexSignature, tuple(values))
def index_state(self) -> str:
"""Return cheap publication freshness without opening SQLite."""
try:
return "current" if self._safe_stat() == self._stat else "stale"
except DocForgeError:
return "stale"
def _validate_snapshot(self) -> None:
with self._connection():
pass
def overview(self) -> dict[str, object]:
with self._connection() as connection:
return self._result(
@ -749,15 +849,26 @@ class VisualizationIndexSnapshot:
raise DocForgeError("invalid_limit", "Result limit is outside the configured range")
return value
def _safe_stat(self) -> tuple[int, int, int, int]:
if (
self.path.is_symlink()
or not self.path.is_file()
or self.path.resolve(strict=True) != self.path
):
raise DocForgeError("missing_index", "Validated visualization index is unavailable")
stat = self.path.stat()
return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns)
def _safe_stat(self) -> IndexSignature:
try:
status = self.path.lstat()
if not stat.S_ISREG(status.st_mode) or self.path.resolve(strict=True) != self.path:
raise DocForgeError(
"missing_index",
"Validated visualization index is unavailable",
)
except OSError as error:
raise DocForgeError(
"missing_index",
"Validated visualization index is unavailable",
) from error
return (
status.st_dev,
status.st_ino,
status.st_size,
status.st_mtime_ns,
status.st_ctime_ns,
)
@contextmanager
def _connection(self) -> Generator[sqlite3.Connection, None, None]:
@ -1074,7 +1185,11 @@ class VisualizationRunner:
if parsed.path == f"{prefix}/api/health":
with self._lock:
last_activity = self._activity_last_seen
payload = reader.result(viewer="alive", last_activity_at=last_activity)
payload = reader.result(
viewer="alive",
last_activity_at=last_activity,
index_state=reader.index_state(),
)
elif parsed.path == f"{prefix}/api/overview":
self._touch_lease()
payload = reader.overview()

View file

@ -47,6 +47,7 @@ from docforge.models import (
RenderConfig,
RenderView,
)
from docforge.telemetry import request
from docforge.viewer_manager import ViewerManagerClient
from docforge.visualization import VisualizationIndexSnapshot
@ -497,6 +498,76 @@ class AdapterContractTests(unittest.TestCase):
captured.exception.details["changed"],
)
def test_warm_incremental_adapter_load_remains_visible_to_diagnostics(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
loader = IncrementalLoader(root)
project = AdapterProject(loader, cache_root=root / ".cache" / "incremental")
with request("test", enabled=True) as cold_collector:
project.load()
assert cold_collector is not None
cold_counters = cold_collector.as_dict(outcome="ok")["counters"]
self.assertEqual(1, cold_counters["project_loads"])
self.assertEqual(0, cold_counters["adapter_projection_loads"])
self.assertEqual(2, cold_counters["adapter_source_extractions"])
loader.extract_calls.clear()
with request("test", enabled=True) as warm_collector:
project.load()
assert warm_collector is not None
counters = warm_collector.as_dict(outcome="ok")["counters"]
self.assertEqual(1, counters["project_loads"])
self.assertEqual(0, counters["adapter_projection_loads"])
self.assertEqual(0, counters["adapter_source_extractions"])
self.assertEqual([], loader.extract_calls)
index = ProjectIndex(project)
index.build()
with request("test", enabled=True) as read_collector:
index.get_node("guide.workflow")
assert read_collector is not None
read_counters = read_collector.as_dict(outcome="ok")["counters"]
self.assertEqual(0, read_counters["project_loads"])
self.assertEqual(0, read_counters["adapter_projection_loads"])
self.assertEqual(0, read_counters["adapter_source_extractions"])
self.assertEqual(0, read_counters["index_builds"])
pinned_state = project.incremental_state()
assert pinned_state is not None
loader.sources["guide.foundation"] = "Changed after viewer pin."
client = ViewerManagerClient(index)
self.assertEqual(
"stale",
client._source_state(
{
"revision": pinned_state.revision,
"source_hash": pinned_state.source_hash,
}
),
)
legacy = AdapterProject(
Loader(self.projection(root)),
cache_root=root / ".cache" / "legacy",
)
with request("test", enabled=True) as legacy_collector:
legacy.load()
assert legacy_collector is not None
legacy_counters = legacy_collector.as_dict(outcome="ok")["counters"]
self.assertEqual(1, legacy_counters["project_loads"])
self.assertEqual(1, legacy_counters["adapter_projection_loads"])
self.assertEqual(0, legacy_counters["adapter_source_extractions"])
self.assertEqual(
"unknown",
ViewerManagerClient(ProjectIndex(legacy))._source_state(
{
"revision": "legacy",
"source_hash": "0" * 64,
}
),
)
def test_incremental_adapter_reuses_sources_and_invalidates_reverse_dependencies(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()

View file

@ -317,6 +317,22 @@ class DocForgeChangesetTests(unittest.TestCase):
self.assertEqual("stale", stale["changesets"][0]["lifecycle"]["status"])
self.assertEqual("ready", second["lifecycle"])
def test_lifecycle_receipts_obey_the_changeset_size_limit(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
store = ChangesetStore(Project.open(root), "alpha-editor")
created = store.create("bounded-lifecycle")
with self.assertRaises(DocForgeError) as oversized:
store.abandon(
"bounded-lifecycle",
str(created["changeset_hash"]),
"x" * 100_001,
)
self.assertEqual("changeset_too_large", oversized.exception.code)
self.assertFalse((root / ".docforge/changesets/.state/bounded-lifecycle.json").exists())
def test_applied_receipt_survives_a_derived_refresh_failure(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
@ -761,7 +777,7 @@ operations = ["create", "update", "move", "delete"]
mock.patch.object(
race_project,
"canonical_source_paths",
side_effect=[sources, sources, sources, (*sources, invented)],
side_effect=[sources, (*sources, invented)],
),
self.assertRaisesRegex(DocForgeError, "changed during changeset storage"),
):

View file

@ -24,6 +24,36 @@ class DocForgeCliTests(unittest.TestCase):
shutil.copytree(FIXTURES / "alpha", root)
return root
def test_traversal_commands_accept_explicit_result_limits(self) -> None:
parser = _parser()
for command in ("backlinks", "dependencies", "impact"):
with self.subTest(command=command):
arguments = parser.parse_args(
[
"--project-root",
"/tmp/project",
command,
"guide.workflow",
"--limit",
"7",
]
)
self.assertEqual(7, arguments.limit)
context = parser.parse_args(
[
"--project-root",
"/tmp/project",
"context",
"active",
"--limit",
"7",
"--cursor",
"opaque",
]
)
self.assertEqual(7, context.limit)
self.assertEqual("opaque", context.cursor)
def test_reindex_apply_and_visualization_commands_are_self_service(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)

View file

@ -8,7 +8,10 @@ import sqlite3
import sys
import tempfile
import unittest
from collections.abc import Iterator
from dataclasses import replace
from pathlib import Path
from typing import TypeVar, cast
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
@ -18,9 +21,26 @@ from docforge.cli import main # noqa: E402
from docforge.context import compile_context # noqa: E402
from docforge.errors import DocForgeError # noqa: E402
from docforge.index import ProjectIndex # noqa: E402
from docforge.project import Project # noqa: E402
from docforge.models import Edge, ProjectState # noqa: E402
from docforge.project import Project, validate_graph # noqa: E402
FIXTURES = ROOT / "tests" / "fixtures"
T = TypeVar("T")
class CountingTuple(tuple[T, ...]):
"""Count complete iteration passes without changing tuple behavior."""
iterations: int
def __new__(cls, values: tuple[T, ...]) -> CountingTuple[T]:
instance = super().__new__(cls, values)
instance.iterations = 0
return instance
def __iter__(self) -> Iterator[T]:
self.iterations += 1
return super().__iter__()
class DocForgeCoreTests(unittest.TestCase):
@ -118,6 +138,7 @@ class DocForgeCoreTests(unittest.TestCase):
def test_duplicate_nodes_broken_edges_and_dependency_cycles_fail(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
snapshot = Project.open(root).load()
content = root / "docs" / "content"
duplicate = content / "duplicate.md"
duplicate.write_text((content / "foundation.md").read_text(), encoding="utf-8")
@ -149,6 +170,48 @@ class DocForgeCoreTests(unittest.TestCase):
with self.assertRaisesRegex(DocForgeError, "cycle"):
Project.open(root).load()
with self.assertRaises(DocForgeError) as missing_source:
validate_graph(
snapshot.nodes,
(
Edge(
"missing.source",
"depends_on",
snapshot.nodes[0].node_id,
),
),
)
self.assertEqual("broken_edge", missing_source.exception.code)
self.assertEqual(
["missing.source"],
missing_source.exception.details["sources"],
)
def test_graph_validation_uses_a_bounded_number_of_edge_passes(self) -> None:
snapshot = Project.open(FIXTURES / "alpha").load()
nodes = tuple(
replace(
snapshot.nodes[0],
node_id=f"linear.node-{index:05d}",
source_path=f"docs/node-{index:05d}.md",
)
for index in range(10_000)
)
edges = CountingTuple(
tuple(
Edge(
f"linear.node-{index:05d}",
"depends_on",
f"linear.node-{index - 1:05d}",
)
for index in range(1, len(nodes))
)
)
validate_graph(nodes, cast(tuple[Edge, ...], edges))
self.assertLessEqual(edges.iterations, 4)
def test_index_build_is_repeatable_and_validates_every_row(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
@ -163,6 +226,58 @@ class DocForgeCoreTests(unittest.TestCase):
self.assertEqual(3, validated["node_count"])
self.assertEqual(2, validated["edge_count"])
def test_persisted_generic_generation_avoids_warm_source_loading(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
ProjectIndex(Project.open(root)).build()
fresh_project = Project.open(root)
fresh_index = ProjectIndex(fresh_project)
with mock.patch.object(
fresh_project,
"load",
side_effect=AssertionError("warm reads must not load canonical sources"),
):
self.assertEqual(
"Editing workflow",
fresh_index.get_node("guide.workflow")["node"]["title"],
)
self.assertEqual(1, fresh_index.search("canonical nodes")["count"])
self.assertEqual(1, fresh_index.filter_nodes(family="proof")["count"])
self.assertEqual(1, len(fresh_index.backlinks("guide.workflow")["edges"]))
self.assertEqual(1, fresh_index.dependencies("guide.workflow")["count"])
self.assertEqual(2, fresh_index.impact("guide.foundation")["count"])
self.assertEqual("active", compile_context(fresh_index, "active")["profile"])
self.assertEqual("current", fresh_index.synchronize()["synchronization"]["action"])
workflow = root / "docs" / "content" / "workflow.md"
workflow.write_text(
workflow.read_text(encoding="utf-8") + "\nChanged after generation.\n",
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "does not match"):
fresh_index.get_node("guide.workflow")
def test_missing_or_corrupt_generation_falls_back_and_repairs(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
index = ProjectIndex(project)
index.build()
generation_path = project.generation_path
for raw in (None, "{not-json"):
with self.subTest(raw=raw):
if raw is None:
generation_path.unlink(missing_ok=True)
else:
generation_path.write_text(raw, encoding="utf-8")
with mock.patch.object(project, "load", wraps=project.load) as load:
checked = index.check(verify_rows=False)
self.assertEqual("ok", checked["status"])
self.assertGreaterEqual(load.call_count, 1)
self.assertIsNotNone(project.incremental_state())
def test_index_rejects_tampered_rows_and_another_project_cache(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
@ -218,6 +333,9 @@ class DocForgeCoreTests(unittest.TestCase):
self.assertEqual(
["proof.validation"], [item["node_id"] for item in filtered["results"]]
)
limited_filter = index.filter_nodes(limit=1)
self.assertEqual(1, limited_filter["count"])
self.assertTrue(limited_filter["truncated"])
dependencies = index.dependencies("guide.workflow", depth=2)
self.assertEqual(
["guide.foundation"], [item["node_id"] for item in dependencies["results"]]
@ -231,27 +349,109 @@ class DocForgeCoreTests(unittest.TestCase):
["guide.workflow", "proof.validation"],
[item["node_id"] for item in impact["results"]],
)
limited = index.impact("guide.foundation", depth=2, limit=1)
self.assertEqual(["guide.workflow"], [item["node_id"] for item in limited["results"]])
self.assertEqual(1, limited["limit"])
self.assertTrue(limited["truncated"])
self.assertEqual("result_limit", limited["truncation_reason"])
self.assertLessEqual(
limited["candidate_edges_consumed"],
limited["candidate_edges_limit"],
)
complete_limit = index.dependencies("guide.workflow", depth=2, limit=1)
self.assertEqual(1, complete_limit["count"])
self.assertFalse(complete_limit["truncated"])
self.assertIsNone(complete_limit["truncation_reason"])
limited_backlinks = index.backlinks("guide.workflow", limit=1)
self.assertEqual(1, limited_backlinks["count"])
self.assertEqual(1, limited_backlinks["limit"])
self.assertFalse(limited_backlinks["truncated"])
for operation in (
lambda: index.backlinks("guide.workflow", limit=0),
lambda: index.dependencies("guide.workflow", limit=True),
lambda: index.impact("guide.workflow", limit=101),
):
with self.subTest(operation=operation), self.assertRaises(DocForgeError) as invalid:
operation()
self.assertEqual("invalid_limit", invalid.exception.code)
def test_incoming_traversal_uses_source_ordered_covering_index(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
connection = sqlite3.connect(index.path)
try:
plan = connection.execute(
"EXPLAIN QUERY PLAN "
"SELECT source_id, relation, target_id FROM edges "
"WHERE target_id = ? "
"ORDER BY source_id, relation, target_id LIMIT ?",
("guide.foundation", 101),
).fetchall()
finally:
connection.close()
details = " ".join(str(row[3]) for row in plan)
self.assertIn("edges_target_source", details)
self.assertNotIn("USE TEMP B-TREE", details)
def test_query_rechecks_source_identity_before_returning(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
checked = index.build()
changed = {**checked, "source_hash": "0" * 64}
project = Project.open(root)
index = ProjectIndex(project)
index.build()
current = project.incremental_state()
self.assertIsNotNone(current)
changed = ProjectState(
source_hash="0" * 64,
revision=current.revision,
)
with (
mock.patch.object(index, "check", side_effect=[checked, changed]),
mock.patch.object(
project,
"incremental_state",
side_effect=[current, changed],
),
self.assertRaisesRegex(DocForgeError, "changed during the query"),
):
index.get_node("guide.workflow")
def test_source_generation_receipt_cache_is_signature_bound(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
ProjectIndex(project).build()
first = project.incremental_state()
self.assertIsNotNone(first)
with mock.patch.object(
Path,
"read_text",
side_effect=AssertionError("warm generation check reparsed its receipt"),
):
self.assertEqual(first, project.incremental_state())
project.generation_path.write_text("{", encoding="utf-8")
self.assertIsNone(project.incremental_state())
def test_source_set_change_during_load_fails_closed(self) -> None:
project = Project.open(FIXTURES / "alpha")
sources = project.canonical_source_paths()
sources, directories = project._canonical_inventory()
invented = project.descriptor.root / "docs" / "content" / "invented.md"
with (
mock.patch.object(
project, "canonical_source_paths", side_effect=[sources, (*sources, invented)]
project,
"_canonical_inventory",
side_effect=[
(sources, directories),
((*sources, invented), directories),
],
),
self.assertRaisesRegex(DocForgeError, "source set changed"),
):

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import json
import os
import shutil
import sys
@ -9,11 +10,13 @@ import time
import unittest
from contextlib import contextmanager
from pathlib import Path
from unittest import mock
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.shared.memory import create_connected_server_and_client_session
from docforge.changesets import ChangesetStore
from docforge.index import ProjectIndex
from docforge.mcp_server import (
ALL_TOOLS,
@ -70,6 +73,28 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
names = tuple(tool.name for tool in response.tools)
self.assertEqual(ALL_TOOLS, names)
self.assertEqual(14, len(PROPOSAL_TOOLS))
tools = {tool.name: tool for tool in response.tools}
for name in (
"docforge_backlinks",
"docforge_dependencies",
"docforge_impact",
):
self.assertIn("limit", tools[name].inputSchema["properties"])
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
for name in (
"docforge_get_context",
"docforge_list_changesets",
"docforge_get_changeset",
"docforge_validate_changeset",
"docforge_get_changeset_diff",
):
for field in ("limit", "cursor"):
self.assertIn(field, tools[name].inputSchema["properties"])
self.assertNotIn(field, tools[name].inputSchema.get("required", []))
self.assertIn(
"deep",
tools["docforge_render_status"].inputSchema["properties"],
)
self.assertFalse(
any(
token in name
@ -78,6 +103,152 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
)
)
async def test_factory_diagnostics_are_additive_through_real_mcp(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
ProjectIndex(Project.open(root)).build()
async with create_connected_server_and_client_session(
create_server(root, diagnostics=True),
raise_exceptions=True,
) as session:
result = await session.call_tool(
"docforge_get_node",
{"node_id": "guide.workflow"},
)
diagnostics = result.structuredContent["diagnostics"]
self.assertEqual("mcp.get_node", diagnostics["operation"])
self.assertEqual(0, diagnostics["counters"]["project_loads"])
self.assertEqual(0, diagnostics["counters"]["source_files_parsed"])
async def test_context_pagination_is_complete_and_stale_cursors_fail_closed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
ProjectIndex(Project.open(root)).build()
async with create_connected_server_and_client_session(
create_server(root), raise_exceptions=True
) as session:
first = await session.call_tool(
"docforge_get_context",
{"profile": "active", "limit": 1},
)
cursor = first.structuredContent["pagination"]["next_cursor"]
pages = [first.structuredContent]
while cursor is not None:
page = await session.call_tool(
"docforge_get_context",
{
"profile": "active",
"limit": 2,
"cursor": cursor,
},
)
pages.append(page.structuredContent)
cursor = page.structuredContent["pagination"]["next_cursor"]
stale_cursor = first.structuredContent["pagination"]["next_cursor"]
workflow = root / "docs" / "content" / "workflow.md"
workflow.write_text(
workflow.read_text(encoding="utf-8") + "\nChanged between pages.\n",
encoding="utf-8",
)
stale = await session.call_tool(
"docforge_get_context",
{
"profile": "active",
"limit": 1,
"cursor": stale_cursor,
},
)
evidence = [
*(("entry", item["node_id"]) for page in pages for item in page["entries"]),
*(("omission", item["node_id"]) for page in pages for item in page["omissions"]),
]
self.assertEqual(len(evidence), pages[0]["summary"]["evidence_count"])
self.assertEqual(len(evidence), len(set(evidence)))
self.assertEqual("stale_cursor", stale.structuredContent["error"]["code"])
self.assertEqual("stale", stale.structuredContent["staleness"])
self.assertEqual(
"restart_pagination",
stale.structuredContent["error"]["remediation"]["action"],
)
async def test_oversized_changeset_reads_return_exact_pages_and_chunks(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8").replace(
"max_changeset_bytes = 100000",
"max_changeset_bytes = 100000\nmax_tool_output_chars = 20000",
),
encoding="utf-8",
)
project = Project.open(root)
ProjectIndex(project).build()
store = ChangesetStore(project, "alpha-editor")
created = store.create("mcp-chunked-diff")
foundation = next(
node for node in project.load().nodes if node.node_id == "guide.foundation"
)
proposed = store.propose_update(
changeset_id="mcp-chunked-diff",
expected_changeset_hash=str(created["changeset_hash"]),
node_id=foundation.node_id,
expected_content_hash=foundation.content_hash,
metadata=None,
content="replacement " * 4_000,
relationship_changes=[],
rationale="Exercise bounded MCP diff reconstruction.",
)
direct = store.diff("mcp-chunked-diff")
async with create_connected_server_and_client_session(
create_server(root), raise_exceptions=True
) as session:
inspected = await session.call_tool(
"docforge_get_changeset",
{"changeset_id": "mcp-chunked-diff"},
)
validated = await session.call_tool(
"docforge_validate_changeset",
{"changeset_id": "mcp-chunked-diff"},
)
cursor: str | None = None
chunks: list[str] = []
while True:
page = await session.call_tool(
"docforge_get_changeset_diff",
{
"changeset_id": "mcp-chunked-diff",
"cursor": cursor,
},
)
self.assertLessEqual(
len(json.dumps(page.structuredContent, separators=(",", ":"))),
20_000,
)
chunks.append(page.structuredContent["chunk"]["content"])
cursor = page.structuredContent["pagination"]["next_cursor"]
if cursor is None:
break
self.assertEqual("operation_summaries", inspected.structuredContent["result_mode"])
self.assertEqual("operation_summaries", validated.structuredContent["result_mode"])
self.assertTrue(validated.structuredContent["valid"])
self.assertEqual(
proposed["changeset_hash"],
validated.structuredContent["changeset_hash"],
)
self.assertEqual(
{
"changes": direct["changes"],
"operations": direct["operations"],
},
json.loads("".join(chunks)),
)
async def test_no_ast_binding_preserves_adapter_and_blocks_logic(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
@ -131,9 +302,15 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
("docforge_get_logic", {"owner_node_id": "guide.workflow"}),
("docforge_search", {"query": "canonical nodes", "limit": 5}),
("docforge_filter_nodes", {"family": "proof", "tag": "validation"}),
("docforge_backlinks", {"node_id": "guide.workflow"}),
("docforge_dependencies", {"node_id": "guide.workflow", "depth": 2}),
("docforge_impact", {"node_id": "guide.foundation", "depth": 2}),
("docforge_backlinks", {"node_id": "guide.workflow", "limit": 5}),
(
"docforge_dependencies",
{"node_id": "guide.workflow", "depth": 2, "limit": 5},
),
(
"docforge_impact",
{"node_id": "guide.foundation", "depth": 2, "limit": 5},
),
("docforge_get_context", {"profile": "active", "budget": 180}),
("docforge_validate_project", {}),
("docforge_render_status", {}),
@ -155,7 +332,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
finally:
service.visualization.stop()
for result in results:
for position, result in enumerate(results):
self.assertFalse(result.isError)
self.assertIsNotNone(result.structuredContent)
payload = result.structuredContent
@ -163,7 +340,10 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual("alpha-docs", payload["project_id"])
self.assertEqual(CONTENT_WARNING, payload["content_warning"])
self.assertTrue(payload["project_root_fingerprint"])
self.assertEqual("current", payload["staleness"])
self.assertEqual(
"unknown" if position == 14 else "current",
payload["staleness"],
)
contract = results[1].structuredContent
self.assertFalse(contract["canonical_writes_allowed"])
self.assertFalse(contract["project_switching_allowed"])
@ -173,6 +353,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
self.assertFalse(contract["proposal_access"]["enabled"])
self.assertFalse(results[3].structuredContent["available"])
self.assertTrue(results[11].structuredContent["configured"])
self.assertEqual("receipt", results[11].structuredContent["verification"])
self.assertEqual("stale", results[11].structuredContent["state"])
visualization = results[12].structuredContent["visualization"]
self.assertTrue(visualization["read_only"])
@ -183,10 +364,60 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
self.assertEqual("stopped", results[13].structuredContent["state"])
self.assertEqual("not_running", results[14].structuredContent["state"])
self.assertEqual("unknown", results[14].structuredContent["revision"])
self.assertIsNone(results[14].structuredContent["source_hash"])
self.assertEqual("unknown", results[14].structuredContent["snapshot_state"])
self.assertEqual(
{"index": "unknown", "source": "unknown"},
results[14].structuredContent["freshness"],
)
context = results[9].structuredContent
self.assertLessEqual(context["estimated_tokens"], 180)
self.assertTrue(context["omissions"])
async def test_invalid_traversal_limit_is_a_structured_domain_error(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
ProjectIndex(Project.open(root)).build()
async with create_connected_server_and_client_session(
create_server(root), raise_exceptions=True
) as session:
result = await session.call_tool(
"docforge_impact",
{"node_id": "guide.foundation", "limit": 0},
)
self.assertEqual("error", result.structuredContent["status"])
self.assertEqual(
"invalid_limit",
result.structuredContent["error"]["code"],
)
async def test_render_status_error_never_loads_or_synchronizes_project(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
service = DocForgeService(project)
with (
mock.patch.object(
project,
"load",
side_effect=AssertionError("status error decoration must remain cheap"),
),
mock.patch.object(
service.index,
"synchronize",
side_effect=AssertionError("status must not synchronize"),
),
):
result = service.render_status("not-a-view")
self.assertEqual("error", result["status"])
self.assertEqual("unknown_render_view", result["error"]["code"])
self.assertEqual("unknown", result["revision"])
self.assertIsNone(result["source_hash"])
self.assertEqual("unknown", result["staleness"])
async def test_sync_register_rebase_apply_and_lifecycle_are_one_bound_workflow(
self,
) -> None:
@ -199,6 +430,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
root,
"alpha-editor",
canonical_applier_id="alpha-editor",
diagnostics=True,
),
raise_exceptions=True,
) as session:
@ -348,6 +580,124 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual("result_too_large", payload["error"]["code"])
self.assertNotIn("canonical_paths", payload)
async def test_mutation_overflow_returns_exact_compact_success_receipts(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8").replace(
"max_context_tokens = 2000",
"max_context_tokens = 2000\nmax_tool_output_chars = 1600",
),
encoding="utf-8",
)
project = Project.open(root)
ProjectIndex(project).build()
node_hashes = {node.node_id: node.content_hash for node in project.load().nodes}
async with create_connected_server_and_client_session(
create_server(
root,
"alpha-editor",
canonical_applier_id="alpha-editor",
),
raise_exceptions=True,
) as session:
created = await session.call_tool(
"docforge_create_changeset",
{"changeset_id": "compact-mutation"},
)
first = await session.call_tool(
"docforge_propose_node_update",
{
"changeset_id": "compact-mutation",
"expected_changeset_hash": created.structuredContent["changeset_hash"],
"node_id": "guide.workflow",
"expected_content_hash": node_hashes["guide.workflow"],
"metadata": None,
"content": "Updated workflow.\n\n" + ("bounded receipt evidence " * 200),
"relationship_changes": [],
"rationale": "Exercise exact compact append receipts.",
},
)
second = await session.call_tool(
"docforge_propose_node_update",
{
"changeset_id": "compact-mutation",
"expected_changeset_hash": first.structuredContent["changeset_hash"],
"node_id": "guide.foundation",
"expected_content_hash": node_hashes["guide.foundation"],
"metadata": None,
"content": "Updated foundation.\n\n" + ("second exact receipt " * 200),
"relationship_changes": [],
"rationale": "Prove the returned hash supports the next append.",
},
)
applied = await session.call_tool(
"docforge_apply_changeset",
{
"changeset_id": "compact-mutation",
"expected_changeset_hash": second.structuredContent["changeset_hash"],
},
)
for result in (first, second, applied):
payload = result.structuredContent
self.assertEqual("ok", payload["status"])
self.assertTrue(payload["mutation_committed"])
self.assertEqual("receipt", payload["result_mode"])
self.assertNotIn("diagnostics", payload)
self.assertLessEqual(
len(json.dumps(payload, sort_keys=True, separators=(",", ":"))),
1600,
)
self.assertNotEqual(
first.structuredContent["changeset_hash"],
second.structuredContent["changeset_hash"],
)
self.assertTrue(applied.structuredContent["applied"])
self.assertEqual(
"applied",
applied.structuredContent["lifecycle"]["status"],
)
self.assertEqual(
"ok",
applied.structuredContent["derived_refresh"]["status"],
)
self.assertIn(
"Updated workflow.",
(root / "docs/content/workflow.md").read_text(encoding="utf-8"),
)
async def test_mutation_preflight_rejects_before_writing(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8").replace(
"max_context_tokens = 2000",
"max_context_tokens = 2000\nmax_tool_output_chars = 700",
),
encoding="utf-8",
)
ProjectIndex(Project.open(root)).build()
changeset_id = "must-not-exist-" + ("x" * 100)
async with create_connected_server_and_client_session(
create_server(root, "alpha-editor", diagnostics=True),
raise_exceptions=True,
) as session:
result = await session.call_tool(
"docforge_create_changeset",
{"changeset_id": changeset_id},
)
payload = result.structuredContent
self.assertEqual("error", payload["status"])
self.assertEqual("result_too_large", payload["error"]["code"])
self.assertEqual("preflight", payload["error"]["details"]["stage"])
self.assertFalse(payload["error"]["details"]["mutation_committed"])
self.assertNotIn("diagnostics", payload)
self.assertFalse((root / f".docforge/changesets/{changeset_id}.json").exists())
async def test_proposal_tools_use_fixed_writer_and_never_change_canonical_content(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))

370
tests/test_observability.py Normal file
View file

@ -0,0 +1,370 @@
from __future__ import annotations
import asyncio
import io
import json
import shutil
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from pathlib import Path
from unittest import mock
import jsonschema
from docforge.cli import main as cli_main
from docforge.context import compile_context
from docforge.errors import DocForgeError
from docforge.index import ProjectIndex
from docforge.mcp_server import DocForgeService
from docforge.project import Project
from docforge.rendering import RenderService
from docforge.telemetry import (
COUNTER_NAMES,
OPERATION_NAMES,
STAGE_NAMES,
increment,
request,
stage,
)
from docforge.viewer_manager import ViewerManagerClient
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
RESULT_SCHEMA = json.loads((ROOT / "schemas" / "result.schema.json").read_text())
ZERO_WORK_COUNTERS = (
"project_loads",
"source_files_parsed",
"source_bytes_parsed",
"adapter_projection_loads",
"adapter_source_extractions",
"index_synchronizations",
"index_builds",
"render_prepare_calls",
"render_output_bytes_built",
"render_output_bytes_hashed",
)
class TelemetryContractTests(unittest.TestCase):
def copy_fixture(self, destination: Path) -> Path:
root = destination / "alpha"
shutil.copytree(FIXTURES / "alpha", root)
return root
def test_disabled_collection_reads_no_clock_and_emits_nothing(self) -> None:
with (
mock.patch(
"docforge.telemetry.time.perf_counter_ns",
side_effect=AssertionError("disabled telemetry read the clock"),
),
request("test", enabled=False) as collector,
):
increment("project_loads")
with stage("source.parse"):
pass
self.assertIsNone(collector)
def test_fixed_names_aggregation_and_exception_cleanup(self) -> None:
with (
self.assertRaisesRegex(ValueError, "operation"),
request(
"unknown",
enabled=True,
),
):
pass
with self.assertRaisesRegex(ValueError, "counter"):
increment("unknown")
with self.assertRaisesRegex(ValueError, "stage"), stage("unknown"):
pass
with request("test", enabled=True) as collector:
increment("source_files_parsed", 2)
with stage("source.parse"):
pass
with stage("source.parse"):
pass
assert collector is not None
diagnostics = collector.as_dict(outcome="ok")
self.assertEqual(2, diagnostics["counters"]["source_files_parsed"])
self.assertEqual(2, diagnostics["stages"]["source.parse"]["calls"])
with (
self.assertRaisesRegex(RuntimeError, "failed"),
request(
"test",
enabled=True,
),
stage("index.read"),
):
raise RuntimeError("failed")
with request("test", enabled=True) as next_collector:
increment("project_loads")
assert next_collector is not None
self.assertEqual(1, next_collector.as_dict(outcome="ok")["counters"]["project_loads"])
with request("test", enabled=True) as outer_collector:
increment("project_loads")
with request("test", enabled=True) as inner_collector:
increment("project_loads", 5)
increment("project_loads")
assert outer_collector is not None
assert inner_collector is not None
self.assertEqual(2, outer_collector.as_dict(outcome="ok")["counters"]["project_loads"])
self.assertEqual(5, inner_collector.as_dict(outcome="ok")["counters"]["project_loads"])
def test_schema_fixed_names_match_the_implementation(self) -> None:
properties = RESULT_SCHEMA["$defs"]["diagnostics"]["properties"]
self.assertEqual(
set(OPERATION_NAMES),
set(properties["operation"]["enum"]),
)
self.assertEqual(
set(STAGE_NAMES),
set(properties["stages"]["propertyNames"]["enum"]),
)
self.assertEqual(
set(COUNTER_NAMES),
set(properties["counters"]["required"]),
)
self.assertEqual(
set(COUNTER_NAMES),
set(properties["counters"]["properties"]),
)
def test_thread_and_async_request_contexts_are_isolated(self) -> None:
barrier = threading.Barrier(2)
def thread_worker(amount: int) -> int:
with request("test", enabled=True) as collector:
increment("project_loads", amount)
barrier.wait()
barrier.wait()
assert collector is not None
return int(collector.as_dict(outcome="ok")["counters"]["project_loads"])
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(thread_worker, amount) for amount in (1, 3)]
self.assertEqual([1, 3], [future.result() for future in futures])
async def verify_async_isolation() -> list[int]:
ready = [asyncio.Event(), asyncio.Event()]
proceed = asyncio.Event()
async def async_worker(position: int, amount: int) -> int:
with request("test", enabled=True) as collector:
increment("project_loads", amount)
ready[position].set()
await proceed.wait()
assert collector is not None
return int(collector.as_dict(outcome="ok")["counters"]["project_loads"])
tasks = [
asyncio.create_task(async_worker(position, amount))
for position, amount in enumerate((2, 5))
]
await asyncio.gather(*(event.wait() for event in ready))
proceed.set()
return list(await asyncio.gather(*tasks))
self.assertEqual([2, 5], asyncio.run(verify_async_isolation()))
def test_generic_positive_controls_and_warm_zero_work_invariants(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
project = Project.open(root)
with request("test", enabled=True) as load_collector:
project.load()
assert load_collector is not None
load_counters = load_collector.as_dict(outcome="ok")["counters"]
self.assertEqual(1, load_counters["project_loads"])
self.assertGreater(load_counters["source_files_parsed"], 0)
self.assertGreater(load_counters["source_bytes_parsed"], 0)
index = ProjectIndex(project)
with request("test", enabled=True) as build_collector:
index.build()
assert build_collector is not None
build_counters = build_collector.as_dict(outcome="ok")["counters"]
self.assertEqual(1, build_counters["index_builds"])
self.assertGreater(build_counters["project_loads"], 0)
renderer = RenderService(project)
with request("test", enabled=True) as render_collector:
renderer.render("manual")
assert render_collector is not None
render_counters = render_collector.as_dict(outcome="ok")["counters"]
self.assertGreater(render_counters["render_prepare_calls"], 0)
self.assertGreater(render_counters["render_output_bytes_built"], 0)
with request("test", enabled=True) as deep_collector:
renderer.deep_status("manual")
assert deep_collector is not None
deep_counters = deep_collector.as_dict(outcome="ok")["counters"]
self.assertGreater(deep_counters["render_output_bytes_hashed"], 0)
service = DocForgeService(project, diagnostics=True)
warm_operations = {
"sync": service.synchronize,
"node": lambda: service.invoke(
lambda: service.index.get_node("guide.workflow"),
operation_name="mcp.get_node",
),
"search": lambda: service.invoke(
lambda: service.index.search("workflow", limit=5),
operation_name="mcp.search",
),
"filter": lambda: service.invoke(
lambda: service.index.filter_nodes(family="guide", limit=5),
operation_name="mcp.filter",
),
"backlinks": lambda: service.invoke(
lambda: service.index.backlinks("guide.foundation", limit=5),
operation_name="mcp.backlinks",
),
"dependencies": lambda: service.invoke(
lambda: service.index.dependencies("guide.workflow", depth=2, limit=5),
operation_name="mcp.dependencies",
),
"impact": lambda: service.invoke(
lambda: service.index.impact("guide.foundation", depth=2, limit=5),
operation_name="mcp.impact",
),
"context": lambda: service.invoke(
lambda: compile_context(service.index, "active"),
operation_name="mcp.context",
),
}
warm_results: dict[str, dict[str, object]] = {}
for name, operation in warm_operations.items():
with self.subTest(operation=name):
result = operation()
warm_results[name] = result
diagnostics = result["diagnostics"]
for counter in ZERO_WORK_COUNTERS:
expected = (
1 if name == "sync" and counter == "index_synchronizations" else 0
)
self.assertEqual(
expected,
diagnostics["counters"][counter],
counter,
)
self.assertGreater(diagnostics["counters"]["source_generation_checks"], 0)
node_result = warm_results["node"]
node_diagnostics = node_result["diagnostics"]
self.assertGreater(node_diagnostics["counters"]["index_checks"], 0)
missing_result = service.invoke(
lambda: service.index.get_node("missing.node"),
operation_name="mcp.get_node",
)
self.assertEqual("error", missing_result["status"])
for counter in ZERO_WORK_COUNTERS:
self.assertEqual(
0,
missing_result["diagnostics"]["counters"][counter],
counter,
)
render_result = service.render_status("manual")
render_diagnostics = render_result["diagnostics"]
for counter in ZERO_WORK_COUNTERS:
self.assertEqual(0, render_diagnostics["counters"][counter], counter)
self.assertEqual(1, render_diagnostics["stages"]["render.status"]["calls"])
jsonschema.validate(node_result, RESULT_SCHEMA)
jsonschema.validate(render_result, RESULT_SCHEMA)
def test_structured_errors_include_diagnostics_and_schema_validation(self) -> None:
with tempfile.TemporaryDirectory() as directory:
project = Project.open(self.copy_fixture(Path(directory)))
service = DocForgeService(project, diagnostics=True)
def fail() -> dict[str, object]:
raise DocForgeError("intentional", "Intentional telemetry error")
result = service.invoke(
fail,
synchronize=False,
load_error_identity=False,
operation_name="mcp.invoke",
)
self.assertEqual("error", result["status"])
self.assertEqual("error", result["diagnostics"]["outcome"])
jsonschema.validate(result, RESULT_SCHEMA)
def test_visualization_status_error_has_one_request_and_no_hidden_work(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
project = Project.open(root)
service = DocForgeService(project, diagnostics=True)
service.visualization = ViewerManagerClient(
service.index,
state_path=root / ".docforge" / "missing-viewer-manager.json",
)
result = service.visualization_status()
self.assertEqual("error", result["status"])
counters = result["diagnostics"]["counters"]
for counter in ZERO_WORK_COUNTERS:
self.assertEqual(0, counters[counter], counter)
self.assertEqual(1, counters["viewer_manager_requests"])
self.assertEqual(
1,
result["diagnostics"]["stages"]["visualization.status"]["calls"],
)
self.assertEqual(
1,
result["diagnostics"]["stages"]["viewer.manager"]["calls"],
)
def test_diagnostics_are_dropped_before_the_primary_result(self) -> None:
with tempfile.TemporaryDirectory() as directory:
original = Project.open(self.copy_fixture(Path(directory)))
limits = replace(original.descriptor.limits, max_tool_output_chars=600)
project = Project(replace(original.descriptor, limits=limits))
service = DocForgeService(project, diagnostics=True)
result = service.invoke(
lambda: {
"status": "ok",
"project_id": project.descriptor.project_id,
"revision": "unversioned",
"source_hash": "0" * 64,
},
synchronize=False,
operation_name="mcp.invoke",
)
self.assertEqual("ok", result["status"])
self.assertNotIn("diagnostics", result)
def test_cli_diagnostics_flag_is_additive_and_defaults_off(self) -> None:
base_arguments = ["--project-root", "/unused", "info"]
with mock.patch("docforge.cli._run", return_value={"status": "ok"}):
with mock.patch("sys.stdout", new_callable=io.StringIO) as output:
self.assertEqual(0, cli_main(base_arguments))
self.assertNotIn("diagnostics", json.loads(output.getvalue()))
with mock.patch("sys.stdout", new_callable=io.StringIO) as output:
self.assertEqual(
0,
cli_main(
[
"--project-root",
"/unused",
"--diagnostics",
"info",
]
),
)
result = json.loads(output.getvalue())
self.assertEqual("cli.info", result["diagnostics"]["operation"])
if __name__ == "__main__":
unittest.main()

338
tests/test_pagination.py Normal file
View file

@ -0,0 +1,338 @@
from __future__ import annotations
import json
import shutil
import tempfile
import unittest
from pathlib import Path
from jsonschema import Draft202012Validator
from docforge.changesets import ChangesetStore
from docforge.errors import DocForgeError
from docforge.mcp_server import DocForgeService
from docforge.pagination import decode_cursor, encode_cursor
from docforge.project import Project, project_root_fingerprint
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
class PaginationTests(unittest.TestCase):
def copy_fixture(self, destination: Path, *, max_tool_chars: int | None = None) -> Path:
root = destination / "alpha"
shutil.copytree(FIXTURES / "alpha", root)
if max_tool_chars is not None:
descriptor = root / ".docforge" / "project.toml"
text = descriptor.read_text(encoding="utf-8")
text = text.replace(
"max_changeset_bytes = 100000",
(f"max_changeset_bytes = 100000\nmax_tool_output_chars = {max_tool_chars}"),
)
descriptor.write_text(text, encoding="utf-8")
return root
def test_cursor_is_canonical_corruption_detecting_and_binding_bound(self) -> None:
binding = {"project_id": "alpha", "source_hash": "a" * 64}
cursor = encode_cursor(kind="context.items", binding=binding, position=2)
self.assertEqual(
2,
decode_cursor(
cursor,
kind="context.items",
binding=binding,
total_count=4,
),
)
with self.assertRaises(DocForgeError) as corrupt:
decode_cursor(
f"{cursor[:-1]}{'A' if cursor[-1] != 'A' else 'B'}",
kind="context.items",
binding=binding,
total_count=4,
)
self.assertEqual("invalid_cursor", corrupt.exception.code)
with self.assertRaises(DocForgeError) as foreign:
decode_cursor(
cursor,
kind="context.items",
binding={**binding, "source_hash": "b" * 64},
total_count=4,
)
self.assertEqual("stale_cursor", foreign.exception.code)
with self.assertRaises(DocForgeError) as wrong_operation:
decode_cursor(
cursor,
kind="changeset.list",
binding=binding,
total_count=4,
)
self.assertEqual("invalid_cursor", wrong_operation.exception.code)
def test_context_pages_entries_then_omissions_without_loss(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
project = Project.open(root)
source_hash = "a" * 64
def context_provider(
_index: object, profile: str, budget: int | None
) -> dict[str, object]:
return {
"status": "ok",
"project_id": project.descriptor.project_id,
"project_root_fingerprint": project_root_fingerprint(root),
"adapter": project.descriptor.adapter,
"revision": "test-revision",
"source_hash": source_hash,
"profile": profile,
"budget": budget,
"estimated_tokens": 3,
"entries": [
{
"node_id": "guide.one",
"reason": "required",
"estimated_tokens": 1,
"text": "one",
},
{
"node_id": "guide.two",
"reason": "eligible",
"estimated_tokens": 2,
"text": "two",
},
],
"omissions": [
{"node_id": "guide.three", "reason": "token budget"},
{"node_id": "guide.four", "reason": "token budget"},
],
}
service = DocForgeService(project, context_provider=context_provider)
result_validator = Draft202012Validator(
json.loads((ROOT / "schemas" / "result.schema.json").read_text(encoding="utf-8"))
)
cursor: str | None = None
evidence: list[tuple[str, str]] = []
limits = [1, 2, 1]
page_index = 0
while True:
page = service.context(
"active",
600,
limit=limits[min(page_index, len(limits) - 1)],
cursor=cursor,
)
result_validator.validate(page)
evidence.extend(
("entry", str(item["node_id"]))
for item in page["entries"]
if isinstance(item, dict)
)
evidence.extend(
("omission", str(item["node_id"]))
for item in page["omissions"]
if isinstance(item, dict)
)
pagination = page["pagination"]
self.assertIsInstance(pagination, dict)
cursor = pagination["next_cursor"]
page_index += 1
if cursor is None:
break
self.assertEqual(
[
("entry", "guide.one"),
("entry", "guide.two"),
("omission", "guide.three"),
("omission", "guide.four"),
],
evidence,
)
def test_context_oversized_item_advances_as_a_bounded_omission(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory), max_tool_chars=4_000)
project = Project.open(root)
def context_provider(
_index: object, profile: str, budget: int | None
) -> dict[str, object]:
return {
"status": "ok",
"project_id": project.descriptor.project_id,
"project_root_fingerprint": project_root_fingerprint(root),
"adapter": project.descriptor.adapter,
"revision": "test-revision",
"source_hash": "a" * 64,
"profile": profile,
"budget": budget,
"estimated_tokens": 10_000,
"entries": [
{
"node_id": "guide.oversized",
"reason": "required",
"estimated_tokens": 10_000,
"text": "x" * 20_000,
}
],
"omissions": [],
}
result = DocForgeService(project, context_provider=context_provider).context(
"active",
600,
limit=1,
)
self.assertEqual("ok", result["status"])
self.assertEqual([], result["entries"])
self.assertEqual("response size limit", result["omissions"][0]["reason"])
self.assertLess(len(json.dumps(result, separators=(",", ":"))), 4_000)
self.assertFalse(result["pagination"]["has_more"])
def test_page_limits_reject_boolean_zero_and_policy_overflow(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
project = Project.open(root)
store = ChangesetStore(project, "alpha-editor")
store.create("bounded-page")
for limit in (True, 0, project.descriptor.limits.max_results + 1):
with self.subTest(limit=limit), self.assertRaises(DocForgeError) as invalid:
store.list_changesets(limit=limit)
self.assertEqual("invalid_limit", invalid.exception.code)
def test_changeset_pages_preserve_full_direct_defaults_and_exact_order(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
project = Project.open(root)
store = ChangesetStore(project, "alpha-editor")
created = store.create("paged-operations")
first = store.propose_update(
changeset_id="paged-operations",
expected_changeset_hash=str(created["changeset_hash"]),
node_id="guide.foundation",
expected_content_hash=next(
node.content_hash
for node in project.load().nodes
if node.node_id == "guide.foundation"
),
metadata={"summary": "First paged update."},
content=None,
relationship_changes=[],
rationale="First page.",
)
store.propose_update(
changeset_id="paged-operations",
expected_changeset_hash=str(first["changeset_hash"]),
node_id="proof.validation",
expected_content_hash=next(
node.content_hash
for node in project.load().nodes
if node.node_id == "proof.validation"
),
metadata={"summary": "Second paged update."},
content=None,
relationship_changes=[],
rationale="Second page.",
)
full = store.inspect("paged-operations")
self.assertNotIn("pagination", full)
first_page = store.inspect("paged-operations", limit=1)
second_page = store.inspect(
"paged-operations",
limit=2,
cursor=str(first_page["pagination"]["next_cursor"]),
)
combined = [*first_page["operations"], *second_page["operations"]]
self.assertEqual(full["operations"], combined)
self.assertEqual(full["changeset_hash"], second_page["changeset_hash"])
full_diff = store.diff("paged-operations")
diff_page = store.diff("paged-operations", limit=1)
diff_next = store.diff(
"paged-operations",
limit=1,
cursor=str(diff_page["pagination"]["next_cursor"]),
)
self.assertEqual(
full_diff["changes"],
[*diff_page["changes"], *diff_next["changes"]],
)
def test_changeset_list_cursor_stales_when_collection_changes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory))
store = ChangesetStore(Project.open(root), "alpha-editor")
store.create("page-a")
store.create("page-b")
first = store.list_changesets(limit=1)
cursor = str(first["pagination"]["next_cursor"])
second = store.list_changesets(limit=2, cursor=cursor)
self.assertEqual(
["page-a", "page-b"],
[
*(
item["changeset_id"]
for item in first["changesets"]
if isinstance(item, dict)
),
*(
item["changeset_id"]
for item in second["changesets"]
if isinstance(item, dict)
),
],
)
store.create("page-c")
with self.assertRaises(DocForgeError) as stale:
store.list_changesets(limit=1, cursor=cursor)
self.assertEqual("stale_cursor", stale.exception.code)
def test_oversized_diff_is_reconstructable_from_hash_bound_chunks(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture(Path(directory), max_tool_chars=20_000)
project = Project.open(root)
store = ChangesetStore(project, "alpha-editor")
created = store.create("chunked-diff")
store.propose_update(
changeset_id="chunked-diff",
expected_changeset_hash=str(created["changeset_hash"]),
node_id="guide.foundation",
expected_content_hash=next(
node.content_hash
for node in project.load().nodes
if node.node_id == "guide.foundation"
),
metadata=None,
content="replacement " * 4_000,
relationship_changes=[],
rationale="Exercise deterministic chunk transport.",
)
full = store.diff("chunked-diff")
cursor: str | None = None
chunks: list[str] = []
while True:
page = store.diff("chunked-diff", limit=20, cursor=cursor)
self.assertEqual("canonical_json_chunk", page["result_mode"])
chunks.append(page["chunk"]["content"])
cursor = page["pagination"]["next_cursor"]
if cursor is None:
break
reconstructed = json.loads("".join(chunks))
self.assertEqual(
{
"changes": full["changes"],
"operations": full["operations"],
},
reconstructed,
)
if __name__ == "__main__":
unittest.main()

View file

@ -77,6 +77,171 @@ class DocForgeRenderingTests(unittest.TestCase):
self.assertEqual("stale", stale["outputs"][0]["state"])
self.assertEqual(first_bytes, output.read_bytes())
def test_warm_render_status_uses_only_publication_receipts(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
rendered = RenderService(Project.open(root)).render("manual")
self.assertEqual("current", rendered["receipt"]["state"])
project = Project.open(root)
service = RenderService(project)
with (
mock.patch.object(
project,
"load",
side_effect=AssertionError("receipt status must not load canonical source"),
),
mock.patch.object(
service,
"_prepare",
side_effect=AssertionError("receipt status must not render"),
),
):
current = service.status("manual")
self.assertEqual("current", current["state"])
self.assertEqual("receipt", current["verification"])
self.assertEqual("current", current["outputs"][0]["state"])
output = root / ".docforge/rendered/manual.html"
output.write_bytes(output.read_bytes() + b"\n")
changed_output = service.status("manual")
self.assertEqual("stale", changed_output["state"])
self.assertEqual("output_changed", changed_output["outputs"][0]["reason"])
RenderService(Project.open(root)).render("manual")
template = root / "docs/templates/manual.html"
template.write_text(
template.read_text(encoding="utf-8") + "\n",
encoding="utf-8",
)
changed_template = service.status("manual")
self.assertEqual("template_changed", changed_template["outputs"][0]["reason"])
def test_render_receipt_failures_are_degraded_after_output_publication(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
service = RenderService(Project.open(root))
with mock.patch.object(
service,
"_publish_receipt",
side_effect=DocForgeError(
"render_receipt_failure",
"Synthetic receipt failure",
),
):
result = service.render("manual")
self.assertEqual("degraded", result["state"])
self.assertEqual("published", result["publication"])
self.assertEqual("failed", result["receipt"]["state"])
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
def test_render_receipt_refuses_post_render_input_and_output_changes(self) -> None:
for changed in ("template", "output", "source"):
with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
service = RenderService(Project.open(root))
publish = service._publish_receipt
def mutate_then_publish(
snapshot,
view,
prepared,
*,
changed_kind=changed,
project_root=root,
publish_receipt=publish,
):
if changed_kind == "template":
target = project_root / "docs/templates/manual.html"
elif changed_kind == "output":
target = project_root / ".docforge/rendered/manual.html"
else:
target = project_root / "docs/content/workflow.md"
target.write_bytes(target.read_bytes() + b"\nChanged before receipt.\n")
return publish_receipt(snapshot, view, prepared)
with mock.patch.object(
service,
"_publish_receipt",
side_effect=mutate_then_publish,
):
result = service.render("manual")
self.assertEqual("degraded", result["state"])
self.assertEqual("published", result["publication"])
self.assertNotEqual("current", service.status("manual")["state"])
self.assertEqual("stale", service.deep_status("manual")["state"])
def test_missing_and_corrupt_render_receipts_are_conservative(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
service = RenderService(Project.open(root))
service.render("manual")
receipt = root / ".docforge/cache/render-receipts/manual.json"
receipt.unlink()
missing = service.status("manual")
self.assertEqual("unverified", missing["outputs"][0]["state"])
self.assertEqual("receipt_missing", missing["outputs"][0]["reason"])
receipt.write_text("{not-json", encoding="utf-8")
corrupt = service.status("manual")
self.assertEqual("unverified", corrupt["outputs"][0]["state"])
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"):
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
service = RenderService(Project.open(root))
service.render("manual")
receipt_path = root / ".docforge/cache/render-receipts/manual.json"
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
if mutation == "missing_hash":
receipt.pop("output_hash")
elif mutation == "renderer_version":
receipt["renderer_version"] = "obsolete"
else:
receipt["output_file"].pop("ctime_ns")
receipt_path.write_text(
json.dumps(receipt, sort_keys=True, indent=2) + "\n",
encoding="utf-8",
)
status = service.status("manual")
self.assertEqual("unverified", status["outputs"][0]["state"])
self.assertEqual(
"foreign_or_incompatible_receipt",
status["outputs"][0]["reason"],
)
def test_render_status_detects_change_between_bounded_captures(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
service = RenderService(Project.open(root))
service.render("manual")
receipt_status = service._receipt_status
calls = 0
def mutate_between_captures(descriptor, view, current_state):
nonlocal calls
calls += 1
if calls == 2:
output = root / ".docforge/rendered/manual.html"
output.write_bytes(output.read_bytes() + b"\n")
return receipt_status(descriptor, view, current_state)
with mock.patch.object(
service,
"_receipt_status",
side_effect=mutate_between_captures,
):
result = service.status("manual")
self.assertEqual("stale", result["state"])
self.assertNotEqual("current", result["outputs"][0]["state"])
def test_changeset_preview_is_deterministic_escaped_and_isolated(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
@ -312,6 +477,7 @@ class DocForgeRenderingTests(unittest.TestCase):
("render", "manual"),
("render-status", "manual"),
("preview", "cli-preview", "manual"),
("render-status", "manual", "--deep"),
)
results: list[dict] = []
for command in commands:
@ -322,6 +488,8 @@ class DocForgeRenderingTests(unittest.TestCase):
self.assertEqual("current", results[0]["state"])
self.assertEqual("current", results[1]["state"])
self.assertEqual("current", results[2]["state"])
self.assertEqual("receipt", results[1]["verification"])
self.assertEqual("deep", results[3]["verification"])
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
self.assertTrue((root / ".docforge/previews/cli-preview/manual.html").is_file())

View file

@ -12,11 +12,14 @@ import urllib.parse
import urllib.request
from contextlib import contextmanager
from pathlib import Path
from unittest import mock
from docforge.errors import DocForgeError
from docforge.index import ProjectIndex
from docforge.mcp_server import DocForgeService
from docforge.models import ProjectState
from docforge.project import Project
from docforge.viewer_manager import ViewerManager, ViewerManagerClient
from docforge.viewer_manager import ViewerManager, ViewerManagerClient, _ManagedWorker
from docforge.visualization import (
_GRAPH_BROWSER_CSS,
_GRAPH_BROWSER_HTML,
@ -61,6 +64,256 @@ class VisualizationTests(unittest.TestCase):
manager.shutdown()
thread.join(timeout=2)
def test_snapshot_spec_binds_the_exact_validated_index_publication(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())
spec = snapshot.spec()
self.assertEqual(1, spec["schema_version"])
self.assertEqual(1, spec["index_signature"]["schema_version"])
self.assertEqual("current", VisualizationIndexSnapshot.from_spec(spec).index_state())
malformed = {**spec, "index_signature": {"schema_version": 1}}
with self.assertRaises(DocForgeError) as invalid:
VisualizationIndexSnapshot.from_spec(malformed)
self.assertEqual("invalid_index", invalid.exception.code)
wrong_fingerprint = {
**spec,
"identity": {
**spec["identity"],
"project_root_fingerprint": "0" * 16,
},
}
with self.assertRaises(DocForgeError) as invalid_fingerprint:
VisualizationIndexSnapshot.from_spec(wrong_fingerprint)
self.assertEqual("invalid_index", invalid_fingerprint.exception.code)
string_count = {
**spec,
"identity": {
**spec["identity"],
"node_count": str(spec["identity"]["node_count"]),
},
}
with self.assertRaises(DocForgeError) as invalid_count:
VisualizationIndexSnapshot.from_spec(string_count)
self.assertEqual("invalid_index", invalid_count.exception.code)
with index.path.open("ab") as stream:
stream.write(b"\n")
self.assertEqual("stale", snapshot.index_state())
with self.assertRaises(DocForgeError) as stale:
VisualizationIndexSnapshot.from_spec(spec)
self.assertEqual("visualization_stale", stale.exception.code)
def test_snapshot_index_state_rejects_missing_and_symlinked_publications(self) -> None:
for mutation in ("delete", "replace", "symlink"):
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
snapshot = VisualizationIndexSnapshot(index, index.check())
if mutation == "delete":
index.path.unlink()
elif mutation == "replace":
replacement = index.path.with_suffix(".replacement")
shutil.copy2(index.path, replacement)
replacement.replace(index.path)
else:
backup = index.path.with_suffix(".backup")
index.path.rename(backup)
index.path.symlink_to(backup)
self.assertEqual("stale", snapshot.index_state())
def test_health_is_stat_only_and_reports_stale_without_renewing_activity(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
runner = VisualizationRunner(index)
try:
result = runner.start()
url = str(result["url"]).split("?", 1)[0] + "api/health"
with (
mock.patch(
"docforge.visualization.sqlite3.connect",
side_effect=AssertionError("health opened SQLite"),
),
urllib.request.urlopen(url, timeout=2) as response,
):
current = json.load(response)
self.assertEqual("current", current["index_state"])
with index.path.open("ab") as stream:
stream.write(b"\n")
with (
mock.patch(
"docforge.visualization.sqlite3.connect",
side_effect=AssertionError("health opened SQLite"),
),
urllib.request.urlopen(url, timeout=2) as response,
):
stale = json.load(response)
self.assertEqual("stale", stale["index_state"])
self.assertEqual(current["last_activity_at"], stale["last_activity_at"])
finally:
runner.stop()
def test_manager_health_rejects_malformed_and_identity_mismatched_payloads(self) -> None:
snapshot = {
"project_id": "alpha-docs",
"project_root_fingerprint": "0" * 16,
"revision": "revision",
"source_hash": "a" * 64,
"adapter": "generic",
"node_count": 3,
"edge_count": 2,
}
worker = _ManagedWorker(
process=mock.Mock(),
port=12345,
token="token",
snapshot=snapshot,
last_activity_at=1.0,
)
valid = {
"status": "ok",
"viewer": "alive",
"last_activity_at": 1.0,
"index_state": "current",
**snapshot,
}
invalid_payloads = (
{key: value for key, value in valid.items() if key != "status"},
{**valid, "project_id": "other"},
{**valid, "last_activity_at": True},
{**valid, "last_activity_at": float("nan")},
{key: value for key, value in valid.items() if key != "index_state"},
)
for payload in invalid_payloads:
with self.subTest(payload=payload):
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = json.dumps(payload).encode()
with mock.patch(
"docforge.viewer_manager.urllib.request.urlopen",
return_value=response,
):
self.assertIsNone(ViewerManager._health(worker))
def test_manager_status_separates_lifecycle_index_and_source_freshness(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
index = ProjectIndex(project)
index.build()
state_path = Path(directory) / "viewer-manager.json"
with self.running_manager(state_path) as manager:
client = ViewerManagerClient(index, state_path=state_path)
first = client.start()
with (
mock.patch.object(
project,
"load",
side_effect=AssertionError("status loaded the project"),
),
mock.patch.object(
index,
"check",
side_effect=AssertionError("status checked the index"),
),
mock.patch.object(
index,
"build",
side_effect=AssertionError("status built the index"),
),
mock.patch.object(
index,
"synchronize",
side_effect=AssertionError("status synchronized the index"),
),
):
current = client.status()
self.assertEqual("running", current["state"])
self.assertEqual("current", current["snapshot_state"])
self.assertEqual(
{"index": "current", "source": "current"},
current["freshness"],
)
self.assertEqual(first["snapshot"]["source_hash"], current["source_hash"])
service = DocForgeService(project, diagnostics=True)
service.visualization = ViewerManagerClient(
service.index,
state_path=state_path,
)
mcp_current = service.visualization_status()
counters = mcp_current["diagnostics"]["counters"]
self.assertEqual(0, counters["project_loads"])
self.assertEqual(0, counters["source_files_parsed"])
self.assertEqual(0, counters["index_checks"])
self.assertEqual(0, counters["index_synchronizations"])
self.assertEqual(0, counters["index_builds"])
self.assertEqual(1, counters["viewer_manager_requests"])
project.generation_path.unlink()
unknown = client.status()
self.assertEqual("running", unknown["state"])
self.assertEqual("unknown", unknown["snapshot_state"])
self.assertEqual("unknown", unknown["freshness"]["source"])
index.build()
stale = client.status()
self.assertEqual("running", stale["state"])
self.assertEqual("stale", stale["snapshot_state"])
self.assertEqual("stale", stale["freshness"]["index"])
time.sleep(0.06)
self.assertEqual(1, len(manager._workers))
restarted = client.start()
self.assertFalse(restarted["reused"])
self.assertNotEqual(
str(first["url"]).split("?", 1)[0],
str(restarted["url"]).split("?", 1)[0],
)
self.assertEqual("current", client.status()["snapshot_state"])
def test_client_source_freshness_distinguishes_mismatch_and_unknown(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
index = ProjectIndex(project)
checked = index.build()
client = ViewerManagerClient(index)
response = {
"status": "ok",
"state": "running",
"index_state": "current",
"snapshot": {
"revision": checked["revision"],
"source_hash": checked["source_hash"],
},
"project_id": project.descriptor.project_id,
"project_root_fingerprint": "test",
"adapter": project.descriptor.adapter,
}
with mock.patch.object(client, "_lifecycle_request", return_value=response):
with mock.patch.object(
project,
"incremental_state",
return_value=ProjectState(source_hash="f" * 64, revision="changed"),
):
stale = client.status()
self.assertEqual("stale", stale["freshness"]["source"])
self.assertEqual("stale", stale["snapshot_state"])
with mock.patch.object(project, "incremental_state", return_value=None):
unknown = client.status()
self.assertEqual("unknown", unknown["freshness"]["source"])
self.assertEqual("unknown", unknown["snapshot_state"])
def test_overview_and_neighborhood_are_deterministic_and_bounded(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))

View file

@ -144,6 +144,12 @@ This deterministic benchmark content exists only in a disposable temporary direc
)
def write_synthetic_project(root: Path, node_count: int) -> None:
"""Create the shared disposable generic benchmark fixture."""
_write_project(root, node_count)
def _json_size(value: object) -> int | None:
if value is None:
return None
@ -183,6 +189,29 @@ def _measure(
return result, last
def measure_operation(
operation: Callable[[], object],
*,
samples: int,
warmups: int = 1,
response_size: bool = True,
) -> tuple[dict[str, object], object]:
"""Measure one operation using the shared baseline method."""
return _measure(
operation,
samples=samples,
warmups=warmups,
response_size=response_size,
)
def synthetic_node_id(index: int) -> str:
"""Return one deterministic node identifier from the shared fixture."""
return _node_id(index)
def _run(command: list[str]) -> str:
return subprocess.run(
command,

View file

@ -0,0 +1,468 @@
"""Milestone 1 warm-operation benchmark with algorithmic zero-work gates."""
from __future__ import annotations
import argparse
import json
import platform
import resource
import subprocess
import sys
import tempfile
import threading
import time
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import cast
from milestone0_baseline import (
measure_operation,
synthetic_node_id,
write_synthetic_project,
)
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 ViewerManager, ViewerManagerClient
ROOT = Path(__file__).resolve().parents[1]
ZERO_WORK_COUNTERS = (
"project_loads",
"source_files_parsed",
"source_bytes_parsed",
"adapter_projection_loads",
"adapter_source_extractions",
"index_builds",
"render_prepare_calls",
"render_output_bytes_built",
"render_output_bytes_hashed",
)
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Gate warm DocForge2 core work on a disposable deterministic project."
)
parser.add_argument("--nodes", type=int, default=1000)
parser.add_argument("--samples", type=int, default=10)
parser.add_argument("--output", type=Path)
return parser
def _git(command: list[str]) -> str:
return subprocess.run(
["git", *command],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
).stdout.strip()
def _diagnostics(result: object) -> Mapping[str, object]:
if not isinstance(result, Mapping):
raise RuntimeError("Measured operation returned a non-object result")
result_payload = cast(Mapping[str, object], result)
diagnostics_value = result_payload.get("diagnostics")
if not isinstance(diagnostics_value, Mapping):
raise RuntimeError("Measured operation did not return diagnostics")
diagnostics = cast(Mapping[str, object], diagnostics_value)
counters_value = diagnostics.get("counters")
if not isinstance(counters_value, Mapping):
raise RuntimeError("Measured diagnostics did not return counters")
counters = cast(Mapping[str, object], counters_value)
for counter in ZERO_WORK_COUNTERS:
if counters.get(counter) != 0:
raise RuntimeError(f"Warm operation performed forbidden work: {counter}")
return diagnostics
def _result_summary(result: Mapping[str, object]) -> dict[str, object]:
"""Retain bounded semantic evidence without copying primary result payloads."""
summary: dict[str, object] = {}
for key in (
"status",
"count",
"limit",
"truncated",
"truncation_reason",
"candidate_edges_consumed",
"candidate_edges_limit",
"budget",
"estimated_tokens",
"state",
"verification",
"configured",
"snapshot_state",
"staleness",
):
if key in result:
summary[key] = result[key]
error = result.get("error")
if isinstance(error, Mapping):
error_payload = cast(Mapping[str, object], error)
if isinstance(error_payload.get("code"), str):
summary["error_code"] = error_payload["code"]
synchronization = result.get("synchronization")
if isinstance(synchronization, Mapping):
synchronization_payload = cast(Mapping[str, object], synchronization)
if isinstance(synchronization_payload.get("action"), str):
summary["synchronization_action"] = synchronization_payload["action"]
freshness = result.get("freshness")
if isinstance(freshness, Mapping):
freshness_payload = cast(Mapping[str, object], freshness)
summary["freshness"] = {
key: freshness_payload[key]
for key in ("index", "source")
if isinstance(freshness_payload.get(key), str)
}
outputs = result.get("outputs")
if isinstance(outputs, list):
summarized_outputs: list[dict[str, object]] = []
for item in cast(list[object], outputs)[:10]:
if not isinstance(item, Mapping):
continue
item_payload = cast(Mapping[str, object], item)
summarized_outputs.append(
{
key: item_payload[key]
for key in ("view_id", "state", "reason")
if key in item_payload
}
)
summary["outputs"] = summarized_outputs
entries = result.get("entries")
if isinstance(entries, list):
summary["entry_count"] = len(cast(list[object], entries))
omissions = result.get("omissions")
if isinstance(omissions, list):
summary["omission_count"] = len(cast(list[object], omissions))
pagination = result.get("pagination")
if isinstance(pagination, Mapping):
pagination_payload = cast(Mapping[str, object], pagination)
summary["pagination"] = {
key: pagination_payload[key]
for key in ("kind", "returned_count", "limit", "total_count", "has_more")
if key in pagination_payload
}
return summary
def _operation(
operation: Callable[[], dict[str, object]],
*,
samples: int,
p95_limit_ms: float,
expected_status: str = "ok",
expected_counters: Mapping[str, int],
) -> dict[str, object]:
diagnostics_records: list[Mapping[str, object]] = []
result_summaries: list[dict[str, object]] = []
def validated_operation() -> dict[str, object]:
result = operation()
diagnostics = _diagnostics(result)
counters_value = diagnostics["counters"]
if not isinstance(counters_value, Mapping):
raise RuntimeError("Measured diagnostics did not return counters")
counters = cast(Mapping[str, object], counters_value)
for counter, expected in expected_counters.items():
if counters.get(counter) != expected:
raise RuntimeError(
f"Warm operation expected {counter}={expected}, "
f"received {counters.get(counter)!r}"
)
diagnostics_records.append(diagnostics)
result_summaries.append(_result_summary(result))
return result
measurement, last = measure_operation(validated_operation, samples=samples)
if not isinstance(last, Mapping):
raise RuntimeError(f"Measured operation did not return status={expected_status}")
last_payload = cast(Mapping[str, object], last)
if last_payload.get("status") != expected_status:
raise RuntimeError(f"Measured operation did not return status={expected_status}")
for summary in result_summaries:
if summary.get("status") != expected_status:
raise RuntimeError(f"Measured operation did not return status={expected_status}")
p95_ms = float(cast(float, measurement["p95_ms"]))
if p95_ms > p95_limit_ms:
raise RuntimeError(f"Warm operation p95 {p95_ms:.3f} ms exceeds {p95_limit_ms:.3f} ms")
counter_names = sorted(
{
key
for diagnostics in diagnostics_records
for key in cast(Mapping[str, object], diagnostics["counters"])
}
)
counter_ranges = {
counter: {
"minimum": min(
cast(int, cast(Mapping[str, object], record["counters"])[counter])
for record in diagnostics_records
),
"maximum": max(
cast(int, cast(Mapping[str, object], record["counters"])[counter])
for record in diagnostics_records
),
}
for counter in counter_names
}
return {
**measurement,
"p95_limit_ms": p95_limit_ms,
"validated_invocations": len(diagnostics_records),
"counter_expectations": dict(sorted(expected_counters.items())),
"counter_ranges": counter_ranges,
"result_summary": result_summaries[-1],
}
def _benchmark(root: Path, node_count: int, samples: int) -> dict[str, object]:
project = Project.open(root)
ProjectIndex(project).build()
RenderService(project).render("manual")
service = DocForgeService(project, diagnostics=True)
target = synthetic_node_id(node_count - 1)
backlink_target = synthetic_node_id(node_count - 2)
first = synthetic_node_id(0)
read_counters = {
"index_checks": 1,
"index_synchronizations": 0,
"viewer_manager_requests": 0,
}
status_counters = {
"index_checks": 0,
"index_synchronizations": 0,
"viewer_manager_requests": 0,
}
visualization_counters = {
"index_checks": 0,
"index_synchronizations": 0,
"viewer_manager_requests": 1,
}
operations = {
"warm_no_change_synchronize": _operation(
service.synchronize,
samples=samples,
p95_limit_ms=100,
expected_counters={
"index_checks": 1,
"index_synchronizations": 1,
"viewer_manager_requests": 0,
},
),
"exact_node": _operation(
lambda: service.invoke(
lambda: service.index.get_node(target),
operation_name="mcp.get_node",
),
samples=samples,
p95_limit_ms=50,
expected_counters=read_counters,
),
"missing_node_error": _operation(
lambda: service.invoke(
lambda: service.index.get_node("missing.node"),
operation_name="mcp.get_node",
),
samples=samples,
p95_limit_ms=50,
expected_status="error",
expected_counters=read_counters,
),
"search_limit_20": _operation(
lambda: service.invoke(
lambda: service.index.search("Synthetic measurement", limit=20),
operation_name="mcp.search",
),
samples=samples,
p95_limit_ms=100,
expected_counters=read_counters,
),
"filter_limit_20": _operation(
lambda: service.invoke(
lambda: service.index.filter_nodes(family="guide", limit=20),
operation_name="mcp.filter",
),
samples=samples,
p95_limit_ms=100,
expected_counters=read_counters,
),
"backlinks_limit_20": _operation(
lambda: service.invoke(
lambda: service.index.backlinks(backlink_target, limit=20),
operation_name="mcp.backlinks",
),
samples=samples,
p95_limit_ms=100,
expected_counters=read_counters,
),
"dependencies_depth_8": _operation(
lambda: service.invoke(
lambda: service.index.dependencies(target, depth=8, limit=100),
operation_name="mcp.dependencies",
),
samples=samples,
p95_limit_ms=100,
expected_counters=read_counters,
),
"impact_depth_8": _operation(
lambda: service.invoke(
lambda: service.index.impact(first, depth=8, limit=100),
operation_name="mcp.impact",
),
samples=samples,
p95_limit_ms=100,
expected_counters=read_counters,
),
"context_32k": _operation(
lambda: service.context("active", 32_000, limit=20),
samples=samples,
p95_limit_ms=250,
expected_counters=read_counters,
),
"render_receipt_status": _operation(
lambda: service.render_status("manual"),
samples=samples,
p95_limit_ms=50,
expected_counters=status_counters,
),
}
template = root / "docs" / "templates" / "manual.html"
original_template = template.read_bytes()
template.write_bytes(original_template + b"\n")
operations["render_stale_status"] = _operation(
lambda: service.render_status("manual"),
samples=samples,
p95_limit_ms=50,
expected_counters=status_counters,
)
template.write_bytes(original_template)
RenderService(project).render("manual")
receipt_path = root / ".docforge" / "cache" / "render-receipts" / "manual.json"
receipt_path.unlink()
operations["render_missing_receipt_status"] = _operation(
lambda: service.render_status("manual"),
samples=samples,
p95_limit_ms=50,
expected_counters=status_counters,
)
RenderService(project).render("manual")
receipt_path.write_text("{", encoding="utf-8")
operations["render_corrupt_receipt_status"] = _operation(
lambda: service.render_status("manual"),
samples=samples,
p95_limit_ms=50,
expected_counters=status_counters,
)
RenderService(project).render("manual")
state_path = root / ".docforge" / "benchmark-viewer-manager.json"
manager = ViewerManager(state_path, check_interval_seconds=0.02)
manager_thread = threading.Thread(target=manager.serve_forever, daemon=True)
manager_thread.start()
deadline = time.monotonic() + 2
while not state_path.exists() and time.monotonic() < deadline:
time.sleep(0.01)
if not state_path.exists():
manager.shutdown()
manager_thread.join(timeout=2)
raise RuntimeError("Viewer manager did not start")
service.visualization = ViewerManagerClient(service.index, state_path=state_path)
try:
service.visualization.start()
operations["visualization_current_status"] = _operation(
service.visualization_status,
samples=samples,
p95_limit_ms=50,
expected_counters=visualization_counters,
)
with service.index.path.open("ab") as stream:
stream.write(b"\n")
operations["visualization_stale_status"] = _operation(
service.visualization_status,
samples=samples,
p95_limit_ms=50,
expected_counters=visualization_counters,
)
service.stop_visualization()
operations["visualization_not_running_status"] = _operation(
service.visualization_status,
samples=samples,
p95_limit_ms=50,
expected_counters=visualization_counters,
)
finally:
manager.shutdown()
manager_thread.join(timeout=2)
service.visualization = ViewerManagerClient(service.index, state_path=state_path)
operations["visualization_unavailable_status"] = _operation(
service.visualization_status,
samples=samples,
p95_limit_ms=50,
expected_status="error",
expected_counters=visualization_counters,
)
return {
"fixture": {
"kind": "synthetic_generic",
"node_count": node_count,
"edge_count": node_count - 1,
"source_file_count": node_count,
},
"operations": operations,
"process_peak_rss_kib": int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss),
}
def main() -> int:
arguments = _parser().parse_args()
if arguments.nodes < 2:
raise SystemExit("--nodes must be at least 2")
if arguments.samples < 1:
raise SystemExit("--samples must be positive")
with tempfile.TemporaryDirectory(prefix="docforge-milestone1-") as directory:
root = Path(directory).resolve()
write_synthetic_project(root, arguments.nodes)
measurement = _benchmark(root, arguments.nodes, arguments.samples)
result = {
"schema_version": 1,
"benchmark": "docforge2_milestone1",
"source": {
"revision": _git(["rev-parse", "HEAD"]),
"dirty": bool(_git(["status", "--porcelain"])),
},
"environment": {
"platform": platform.platform(),
"machine": platform.machine(),
"python": platform.python_version(),
"implementation": platform.python_implementation(),
},
"method": {
"clock": "time.perf_counter_ns",
"memory": "resource.getrusage(RUSAGE_SELF).ru_maxrss",
"memory_scope": (
"cumulative main-process high-water mark; detached viewer-worker memory excluded"
),
"response_size": "UTF-8 bytes of compact sorted JSON",
"samples": arguments.samples,
"warmups": 1,
"percentile": "nearest-rank",
"zero_work_counters": list(ZERO_WORK_COUNTERS),
},
**measurement,
}
encoded = json.dumps(result, sort_keys=True, indent=2) + "\n"
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__":
sys.exit(main())