Compare commits
11 commits
7e0eff347c
...
a9a75c5c27
| Author | SHA1 | Date | |
|---|---|---|---|
| a9a75c5c27 | |||
| 6253c45a5e | |||
| 529accf858 | |||
| 176b2d2784 | |||
| 24bd13f9d9 | |||
| 0fe968c475 | |||
| 21c4992f9c | |||
| 4ae9b31db5 | |||
| c69cd16515 | |||
| ad4f52b239 | |||
| 3bee200234 |
37 changed files with 7826 additions and 432 deletions
|
|
@ -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 3–5 remain directional context and are not active.
|
||||
|
|
|
|||
346
DEVELOPMENT_NOTES.md
Normal file
346
DEVELOPMENT_NOTES.md
Normal 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.4–1.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 40–52 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.
|
||||
11
Makefile
11
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1381
benchmarks/milestone1-2026-07-29.json
Normal file
1381
benchmarks/milestone1-2026-07-29.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
102
docs/MILESTONE_1_BASELINE.md
Normal file
102
docs/MILESTONE_1_BASELINE.md
Normal 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.
|
||||
|
||||
99
docs/MILESTONE_1_CLOSEOUT.md
Normal file
99
docs/MILESTONE_1_CLOSEOUT.md
Normal 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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
163
src/docforge/pagination.py
Normal 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",
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
220
src/docforge/telemetry.py
Normal 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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
370
tests/test_observability.py
Normal 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
338
tests/test_pagination.py
Normal 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()
|
||||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
468
tools/milestone1_benchmark.py
Normal file
468
tools/milestone1_benchmark.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue