Make render status receipt based
This commit is contained in:
parent
21c4992f9c
commit
0fe968c475
10 changed files with 913 additions and 27 deletions
|
|
@ -160,6 +160,31 @@ committed success. A separate 700-character preflight test proves the callback a
|
||||||
are never created. Changeset lifecycle receipts now obey the configured changeset byte limit on
|
are never created. Changeset lifecycle receipts now obey the configured changeset byte limit on
|
||||||
both write and read.
|
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
|
#### Bounded indexed retrieval
|
||||||
|
|
||||||
Search, metadata filtering, backlinks, dependency traversal, and impact traversal now query one
|
Search, metadata filtering, backlinks, dependency traversal, and impact traversal now query one
|
||||||
|
|
|
||||||
|
|
@ -127,10 +127,14 @@ index, render, and error payloads remain available through the corresponding rea
|
||||||
|
|
||||||
## Render boundary
|
## Render boundary
|
||||||
|
|
||||||
`docforge_render_status` recomputes expected hashes without writing. `docforge_preview_changeset`
|
`docforge_render_status` reads bounded publication receipts and cheap file/source identities by
|
||||||
runs only a project-declared view through DocForge's fixed built-in renderer registry and writes one
|
default. It does not parse canonical nodes, prepare Markdown, construct HTML, hash the complete
|
||||||
atomic HTML file below the configured preview root. Rendering declared project output is available
|
output, rebuild the index, or write state. Missing or corrupt receipts are conservative
|
||||||
only through the explicit local CLI integration command.
|
`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
|
## Visualization boundary
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -472,7 +472,7 @@ context PROFILE [--budget N]
|
||||||
### Render and proposal commands
|
### Render and proposal commands
|
||||||
|
|
||||||
```text
|
```text
|
||||||
render-status [VIEW_ID]
|
render-status [VIEW_ID] [--deep]
|
||||||
render VIEW_ID
|
render VIEW_ID
|
||||||
preview CHANGESET_ID VIEW_ID
|
preview CHANGESET_ID VIEW_ID
|
||||||
apply CHANGESET_ID --changeset-hash SHA256 --applier WRITER_ID
|
apply CHANGESET_ID --changeset-hash SHA256 --applier WRITER_ID
|
||||||
|
|
@ -629,6 +629,13 @@ Canonical application records its terminal receipt immediately after the project
|
||||||
verifies the new canonical state. A later index or render refresh failure is reported as degraded
|
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.
|
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
|
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
|
removal. It uses the same underlying validated update contract, but rejects empty relationship
|
||||||
lists and makes it explicit that node content will remain unchanged.
|
lists and makes it explicit that node content will remain unchanged.
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@
|
||||||
"status": { "const": "ok" },
|
"status": { "const": "ok" },
|
||||||
"project_id": { "type": "string" },
|
"project_id": { "type": "string" },
|
||||||
"revision": { "type": "string" },
|
"revision": { "type": "string" },
|
||||||
"source_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
"source_hash": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"pattern": "^[0-9a-f]{64}$"
|
||||||
|
},
|
||||||
"adapter": { "type": "string" }
|
"adapter": { "type": "string" }
|
||||||
},
|
},
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
|
|
|
||||||
|
|
@ -405,7 +405,28 @@ class CanonicalApplicationService:
|
||||||
if config is not None:
|
if config is not None:
|
||||||
for view in config.views:
|
for view in config.views:
|
||||||
try:
|
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:
|
except DocForgeError as error:
|
||||||
refresh_errors.append(
|
refresh_errors.append(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ def _parser() -> argparse.ArgumentParser:
|
||||||
render.add_argument("view_id")
|
render.add_argument("view_id")
|
||||||
render_status = commands.add_parser("render-status")
|
render_status = commands.add_parser("render-status")
|
||||||
render_status.add_argument("view_id", nargs="?")
|
render_status.add_argument("view_id", nargs="?")
|
||||||
|
render_status.add_argument("--deep", action="store_true")
|
||||||
preview = commands.add_parser("preview")
|
preview = commands.add_parser("preview")
|
||||||
preview.add_argument("changeset_id")
|
preview.add_argument("changeset_id")
|
||||||
preview.add_argument("view_id")
|
preview.add_argument("view_id")
|
||||||
|
|
@ -172,7 +173,12 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
|
||||||
if arguments.command == "render":
|
if arguments.command == "render":
|
||||||
return RenderService(project).render(arguments.view_id)
|
return RenderService(project).render(arguments.view_id)
|
||||||
if arguments.command == "render-status":
|
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":
|
if arguments.command == "preview":
|
||||||
return RenderService(project).preview(arguments.changeset_id, arguments.view_id)
|
return RenderService(project).preview(arguments.changeset_id, arguments.view_id)
|
||||||
if arguments.command == "apply":
|
if arguments.command == "apply":
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,7 @@ class DocForgeService:
|
||||||
*,
|
*,
|
||||||
synchronize: bool = True,
|
synchronize: bool = True,
|
||||||
mutation: _MutationPolicy | None = None,
|
mutation: _MutationPolicy | None = None,
|
||||||
|
load_error_identity: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
synchronization: dict[str, object] | None = None
|
synchronization: dict[str, object] | None = None
|
||||||
maximum = self.project.descriptor.limits.max_tool_output_chars
|
maximum = self.project.descriptor.limits.max_tool_output_chars
|
||||||
|
|
@ -211,6 +212,7 @@ class DocForgeService:
|
||||||
"server_version": SERVER_VERSION,
|
"server_version": SERVER_VERSION,
|
||||||
"error": error.as_dict(),
|
"error": error.as_dict(),
|
||||||
}
|
}
|
||||||
|
if load_error_identity:
|
||||||
try:
|
try:
|
||||||
snapshot = self.project.load()
|
snapshot = self.project.load()
|
||||||
result.update(
|
result.update(
|
||||||
|
|
@ -221,6 +223,9 @@ class DocForgeService:
|
||||||
)
|
)
|
||||||
except DocForgeError:
|
except DocForgeError:
|
||||||
result.update({"revision": "unknown", "source_hash": None})
|
result.update({"revision": "unknown", "source_hash": None})
|
||||||
|
else:
|
||||||
|
result.update({"revision": "unknown", "source_hash": None})
|
||||||
|
result["staleness"] = "unknown"
|
||||||
remediation = self._remediation(error)
|
remediation = self._remediation(error)
|
||||||
if remediation is not None:
|
if remediation is not None:
|
||||||
cast(dict[str, object], result["error"])["remediation"] = remediation
|
cast(dict[str, object], result["error"])["remediation"] = remediation
|
||||||
|
|
@ -497,7 +502,11 @@ class DocForgeService:
|
||||||
"recommended_workflow": recommended_workflow,
|
"recommended_workflow": recommended_workflow,
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.invoke(operation, synchronize=False)
|
return self.invoke(
|
||||||
|
operation,
|
||||||
|
synchronize=False,
|
||||||
|
load_error_identity=False,
|
||||||
|
)
|
||||||
|
|
||||||
def project_info(self) -> dict[str, object]:
|
def project_info(self) -> dict[str, object]:
|
||||||
def operation() -> dict[str, object]:
|
def operation() -> dict[str, object]:
|
||||||
|
|
@ -628,8 +637,22 @@ class DocForgeService:
|
||||||
|
|
||||||
return self.invoke(operation)
|
return self.invoke(operation)
|
||||||
|
|
||||||
def render_status(self, view_id: str | None = None) -> dict[str, object]:
|
def render_status(
|
||||||
return self.invoke(lambda: self.rendering.status(view_id))
|
self,
|
||||||
|
view_id: str | None = None,
|
||||||
|
*,
|
||||||
|
deep: bool = False,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
operation = (
|
||||||
|
(lambda: self.rendering.deep_status(view_id))
|
||||||
|
if deep
|
||||||
|
else (lambda: self.rendering.status(view_id))
|
||||||
|
)
|
||||||
|
return self.invoke(
|
||||||
|
operation,
|
||||||
|
synchronize=False,
|
||||||
|
load_error_identity=False,
|
||||||
|
)
|
||||||
|
|
||||||
def context(self, profile: str, budget: int | None = None) -> dict[str, Any]:
|
def context(self, profile: str, budget: int | None = None) -> dict[str, Any]:
|
||||||
return self.invoke(lambda: self.context_provider(self.index, profile, budget))
|
return self.invoke(lambda: self.context_provider(self.index, profile, budget))
|
||||||
|
|
@ -808,10 +831,13 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
|
||||||
return service.validate_project()
|
return service.validate_project()
|
||||||
|
|
||||||
@server.tool(name="docforge_render_status")
|
@server.tool(name="docforge_render_status")
|
||||||
def render_status(view_id: str | None = None) -> dict[str, Any]:
|
def render_status(
|
||||||
"""Report render configuration state without generating or changing output."""
|
view_id: str | None = None,
|
||||||
|
deep: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Report receipt state, or explicitly recompute the side-effect-free render oracle."""
|
||||||
|
|
||||||
return service.render_status(view_id)
|
return service.render_status(view_id, deep=deep)
|
||||||
|
|
||||||
@server.tool(name="docforge_visualize")
|
@server.tool(name="docforge_visualize")
|
||||||
def visualize(
|
def visualize(
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,33 @@ from __future__ import annotations
|
||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import stat
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import Callable, Generator
|
from collections.abc import Callable, Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from .changesets import ChangesetStore
|
from .changesets import ChangesetStore
|
||||||
from .errors import DocForgeError
|
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 .project import project_root_fingerprint
|
||||||
from .render_contract import PreparedRender, relative_output, renderer_for
|
from .render_contract import PreparedRender, relative_output, renderer_for
|
||||||
|
|
||||||
|
RENDER_RECEIPT_SCHEMA_VERSION = 1
|
||||||
|
MAX_RENDER_RECEIPT_BYTES = 64_000
|
||||||
|
|
||||||
|
|
||||||
class RenderService:
|
class RenderService:
|
||||||
"""Render only declared views through fixed built-in renderer implementations."""
|
"""Render only declared views through fixed built-in renderer implementations."""
|
||||||
|
|
@ -25,6 +40,47 @@ class RenderService:
|
||||||
self.changesets = changesets or ChangesetStore(project)
|
self.changesets = changesets or ChangesetStore(project)
|
||||||
|
|
||||||
def status(self, view_id: str | None = None) -> dict[str, object]:
|
def status(self, view_id: str | None = None) -> dict[str, object]:
|
||||||
|
"""Report publication state from bounded receipts without rendering canonical content."""
|
||||||
|
|
||||||
|
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()
|
snapshot = self.project.load()
|
||||||
config = snapshot.descriptor.render
|
config = snapshot.descriptor.render
|
||||||
if config is None:
|
if config is None:
|
||||||
|
|
@ -32,11 +88,20 @@ class RenderService:
|
||||||
snapshot,
|
snapshot,
|
||||||
configured=False,
|
configured=False,
|
||||||
state="not_configured",
|
state="not_configured",
|
||||||
|
verification="deep",
|
||||||
outputs=[],
|
outputs=[],
|
||||||
)
|
)
|
||||||
views = self._views(config, view_id)
|
views = self._views(config, view_id)
|
||||||
outputs: list[dict[str, object]] = []
|
outputs: list[dict[str, object]] = []
|
||||||
for view in views:
|
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)
|
prepared, _ = self._prepare(snapshot, view, changeset_hash=None)
|
||||||
state = "missing"
|
state = "missing"
|
||||||
actual_hash: str | None = None
|
actual_hash: str | None = None
|
||||||
|
|
@ -50,13 +115,31 @@ class RenderService:
|
||||||
raw = output.read_bytes()
|
raw = output.read_bytes()
|
||||||
actual_hash = hashlib.sha256(raw).hexdigest()
|
actual_hash = hashlib.sha256(raw).hexdigest()
|
||||||
state = "current" if actual_hash == prepared.output_hash else "stale"
|
state = "current" if actual_hash == prepared.output_hash else "stale"
|
||||||
outputs.append(
|
result = self._view_result(
|
||||||
self._view_result(snapshot, view, prepared, state=state, actual_hash=actual_hash)
|
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(
|
return self._result(
|
||||||
snapshot,
|
snapshot,
|
||||||
configured=True,
|
configured=True,
|
||||||
state="current" if all(item["state"] == "current" for item in outputs) else "stale",
|
state="current" if all(item["state"] == "current" for item in outputs) else "stale",
|
||||||
|
verification="deep",
|
||||||
outputs=outputs,
|
outputs=outputs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -71,10 +154,32 @@ class RenderService:
|
||||||
prepared.output,
|
prepared.output,
|
||||||
verify=lambda: self._verify_canonical(snapshot, view, template_bytes),
|
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(
|
return self._result(
|
||||||
snapshot,
|
snapshot,
|
||||||
configured=True,
|
configured=True,
|
||||||
state="current",
|
state=state,
|
||||||
|
publication="published",
|
||||||
|
receipt=receipt,
|
||||||
output=self._view_result(
|
output=self._view_result(
|
||||||
snapshot,
|
snapshot,
|
||||||
view,
|
view,
|
||||||
|
|
@ -84,6 +189,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]:
|
def preview(self, changeset_id: str, view_id: str) -> dict[str, object]:
|
||||||
with self._lock():
|
with self._lock():
|
||||||
snapshot, changeset_hash = self.changesets.projected_snapshot(changeset_id)
|
snapshot, changeset_hash = self.changesets.projected_snapshot(changeset_id)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import time
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
|
|
@ -79,6 +80,10 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||||
):
|
):
|
||||||
self.assertIn("limit", tools[name].inputSchema["properties"])
|
self.assertIn("limit", tools[name].inputSchema["properties"])
|
||||||
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
|
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
|
||||||
|
self.assertIn(
|
||||||
|
"deep",
|
||||||
|
tools["docforge_render_status"].inputSchema["properties"],
|
||||||
|
)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
any(
|
any(
|
||||||
token in name
|
token in name
|
||||||
|
|
@ -188,6 +193,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertFalse(contract["proposal_access"]["enabled"])
|
self.assertFalse(contract["proposal_access"]["enabled"])
|
||||||
self.assertFalse(results[3].structuredContent["available"])
|
self.assertFalse(results[3].structuredContent["available"])
|
||||||
self.assertTrue(results[11].structuredContent["configured"])
|
self.assertTrue(results[11].structuredContent["configured"])
|
||||||
|
self.assertEqual("receipt", results[11].structuredContent["verification"])
|
||||||
self.assertEqual("stale", results[11].structuredContent["state"])
|
self.assertEqual("stale", results[11].structuredContent["state"])
|
||||||
visualization = results[12].structuredContent["visualization"]
|
visualization = results[12].structuredContent["visualization"]
|
||||||
self.assertTrue(visualization["read_only"])
|
self.assertTrue(visualization["read_only"])
|
||||||
|
|
@ -220,6 +226,31 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||||
result.structuredContent["error"]["code"],
|
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(
|
async def test_sync_register_rebase_apply_and_lifecycle_are_one_bound_workflow(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,171 @@ class DocForgeRenderingTests(unittest.TestCase):
|
||||||
self.assertEqual("stale", stale["outputs"][0]["state"])
|
self.assertEqual("stale", stale["outputs"][0]["state"])
|
||||||
self.assertEqual(first_bytes, output.read_bytes())
|
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:
|
def test_changeset_preview_is_deterministic_escaped_and_isolated(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = self.copy_fixture("alpha", Path(directory))
|
root = self.copy_fixture("alpha", Path(directory))
|
||||||
|
|
@ -312,6 +477,7 @@ class DocForgeRenderingTests(unittest.TestCase):
|
||||||
("render", "manual"),
|
("render", "manual"),
|
||||||
("render-status", "manual"),
|
("render-status", "manual"),
|
||||||
("preview", "cli-preview", "manual"),
|
("preview", "cli-preview", "manual"),
|
||||||
|
("render-status", "manual", "--deep"),
|
||||||
)
|
)
|
||||||
results: list[dict] = []
|
results: list[dict] = []
|
||||||
for command in commands:
|
for command in commands:
|
||||||
|
|
@ -322,6 +488,8 @@ class DocForgeRenderingTests(unittest.TestCase):
|
||||||
self.assertEqual("current", results[0]["state"])
|
self.assertEqual("current", results[0]["state"])
|
||||||
self.assertEqual("current", results[1]["state"])
|
self.assertEqual("current", results[1]["state"])
|
||||||
self.assertEqual("current", results[2]["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/rendered/manual.html").is_file())
|
||||||
self.assertTrue((root / ".docforge/previews/cli-preview/manual.html").is_file())
|
self.assertTrue((root / ".docforge/previews/cli-preview/manual.html").is_file())
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue