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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -127,10 +127,14 @@ index, render, and error payloads remain available through the corresponding rea
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -472,7 +472,7 @@ context PROFILE [--budget N]
|
|||
### 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
|
||||
|
|
@ -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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@
|
|||
"status": { "const": "ok" },
|
||||
"project_id": { "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" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ def _parser() -> argparse.ArgumentParser:
|
|||
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")
|
||||
|
|
@ -172,7 +173,12 @@ def _run(arguments: argparse.Namespace) -> dict[str, object]:
|
|||
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":
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ class DocForgeService:
|
|||
*,
|
||||
synchronize: bool = True,
|
||||
mutation: _MutationPolicy | None = None,
|
||||
load_error_identity: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
synchronization: dict[str, object] | None = None
|
||||
maximum = self.project.descriptor.limits.max_tool_output_chars
|
||||
|
|
@ -211,6 +212,7 @@ class DocForgeService:
|
|||
"server_version": SERVER_VERSION,
|
||||
"error": error.as_dict(),
|
||||
}
|
||||
if load_error_identity:
|
||||
try:
|
||||
snapshot = self.project.load()
|
||||
result.update(
|
||||
|
|
@ -221,6 +223,9 @@ class DocForgeService:
|
|||
)
|
||||
except DocForgeError:
|
||||
result.update({"revision": "unknown", "source_hash": None})
|
||||
else:
|
||||
result.update({"revision": "unknown", "source_hash": None})
|
||||
result["staleness"] = "unknown"
|
||||
remediation = self._remediation(error)
|
||||
if remediation is not None:
|
||||
cast(dict[str, object], result["error"])["remediation"] = remediation
|
||||
|
|
@ -497,7 +502,11 @@ class DocForgeService:
|
|||
"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 operation() -> dict[str, object]:
|
||||
|
|
@ -628,8 +637,22 @@ class DocForgeService:
|
|||
|
||||
return self.invoke(operation)
|
||||
|
||||
def render_status(self, view_id: str | None = None) -> dict[str, object]:
|
||||
return self.invoke(lambda: self.rendering.status(view_id))
|
||||
def render_status(
|
||||
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]:
|
||||
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()
|
||||
|
||||
@server.tool(name="docforge_render_status")
|
||||
def render_status(view_id: str | None = None) -> dict[str, Any]:
|
||||
"""Report render configuration state without generating or changing output."""
|
||||
def render_status(
|
||||
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")
|
||||
def visualize(
|
||||
|
|
|
|||
|
|
@ -4,18 +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
|
||||
|
||||
RENDER_RECEIPT_SCHEMA_VERSION = 1
|
||||
MAX_RENDER_RECEIPT_BYTES = 64_000
|
||||
|
||||
|
||||
class RenderService:
|
||||
"""Render only declared views through fixed built-in renderer implementations."""
|
||||
|
|
@ -25,6 +40,47 @@ 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."""
|
||||
|
||||
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 +88,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
|
||||
|
|
@ -50,13 +115,31 @@ class RenderService:
|
|||
raw = output.read_bytes()
|
||||
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 +154,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 +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]:
|
||||
with self._lock():
|
||||
snapshot, changeset_hash = self.changesets.projected_snapshot(changeset_id)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ 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
|
||||
|
|
@ -79,6 +80,10 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
):
|
||||
self.assertIn("limit", tools[name].inputSchema["properties"])
|
||||
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
|
||||
self.assertIn(
|
||||
"deep",
|
||||
tools["docforge_render_status"].inputSchema["properties"],
|
||||
)
|
||||
self.assertFalse(
|
||||
any(
|
||||
token in name
|
||||
|
|
@ -188,6 +193,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"])
|
||||
|
|
@ -220,6 +226,31 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue