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

Enforce strict Pyright gate

This commit is contained in:
Andraxion 2026-07-24 22:26:01 -04:00
parent 90898cfd63
commit 2841042b9c
16 changed files with 214 additions and 77 deletions

View file

@ -12,4 +12,5 @@
- Use deterministic ordering, hashes, JSON results, and structured errors. - Use deterministic ordering, hashes, JSON results, and structured errors.
- Fail closed on stale caches, invalid configuration, ambiguous IDs, and unauthorized families. - Fail closed on stale caches, invalid configuration, ambiguous IDs, and unauthorized families.
- Keep dependencies small and pinned by compatible major version. - Keep dependencies small and pinned by compatible major version.
- Run formatting, static checks, focused tests, and the complete test suite before closing a gate. - Run strict `pyright`, formatting, Ruff, compilation, focused tests, and the complete warning-strict
test suite before closing a gate.

View file

@ -44,8 +44,14 @@ progressive hop-distance shading.
## Development ## Development
Install Pyright once with `npm install -g pyright`. DocForge configures it to use the repository
virtual environment and treats a clean strict run as a required development gate.
```bash ```bash
uv sync uv sync
pyright
uv run ruff check src tests
uv run ruff format --check src tests
uv run python -m unittest discover -s tests -v uv run python -m unittest discover -s tests -v
uv run docforge --project-root tests/fixtures/alpha validate uv run docforge --project-root tests/fixtures/alpha validate
uv run docforge --project-root tests/fixtures/alpha render-status manual uv run docforge --project-root tests/fixtures/alpha render-status manual

View file

@ -1,5 +1,20 @@
# Completed slices # Completed slices
## DFG-15 strict static typing gate
### Changed
- Made the existing strict Pyright configuration resolve DocForge's `.venv` automatically.
- Converted validated TOML, JSON, subprocess, socket, MCP, render, and visualization boundaries
from unknown dynamic values into explicit checked types.
- Kept runtime validation and fail-closed behavior at every untrusted input boundary.
- Made strict `pyright` an explicit repository development gate.
### Verification
- Pyright reports zero errors, warnings, or informational diagnostics across all source modules.
- Ruff lint and formatting, Python compilation, all 52 warning-strict tests, and diff checks pass.
## DFG-14.1 detached viewer lifecycle correction ## DFG-14.1 detached viewer lifecycle correction
### Changed ### Changed

View file

@ -30,3 +30,5 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
pythonVersion = "3.12" pythonVersion = "3.12"
typeCheckingMode = "strict" typeCheckingMode = "strict"
include = ["src"] include = ["src"]
venvPath = "."
venv = ".venv"

View file

@ -355,7 +355,7 @@ class AdapterProject:
def _resolved_directories( def _resolved_directories(
root: Path, paths: tuple[Path, ...], *, label: str root: Path, paths: tuple[Path, ...], *, label: str
) -> tuple[Path, ...]: ) -> tuple[Path, ...]:
resolved = [] resolved: list[Path] = []
for path in paths: for path in paths:
if path.is_symlink(): if path.is_symlink():
raise DocForgeError("path_escape", f"Adapter {label} is unsafe") raise DocForgeError("path_escape", f"Adapter {label} is unsafe")
@ -369,7 +369,7 @@ class AdapterProject:
@staticmethod @staticmethod
def _resolved_files(root: Path, paths: tuple[Path, ...], *, label: str) -> tuple[Path, ...]: def _resolved_files(root: Path, paths: tuple[Path, ...], *, label: str) -> tuple[Path, ...]:
resolved = [] resolved: list[Path] = []
for path in paths: for path in paths:
if path.is_symlink(): if path.is_symlink():
raise DocForgeError("path_escape", f"Adapter {label} is unsafe") raise DocForgeError("path_escape", f"Adapter {label} is unsafe")

View file

@ -6,7 +6,7 @@ import hashlib
import json import json
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from .errors import DocForgeError from .errors import DocForgeError
from .models import Node, ProjectService from .models import Node, ProjectService
@ -80,14 +80,16 @@ def edge_dict(edge: tuple[str, str, str], *, action: str) -> dict[str, str]:
} }
def validate_id(value: Any, field: str) -> None: def validate_id(value: object, field: str) -> str:
if not isinstance(value, str) or ID_PATTERN.fullmatch(value) is None: if not isinstance(value, str) or ID_PATTERN.fullmatch(value) is None:
raise DocForgeError("invalid_id", f"{field} must be a stable ID", field=field) raise DocForgeError("invalid_id", f"{field} must be a stable ID", field=field)
return value
def validate_hash(value: Any, field: str) -> None: def validate_hash(value: object, field: str) -> str:
if not isinstance(value, str) or HASH_PATTERN.fullmatch(value) is None: if not isinstance(value, str) or HASH_PATTERN.fullmatch(value) is None:
raise DocForgeError("invalid_hash", f"{field} must be a lowercase SHA-256 hash") raise DocForgeError("invalid_hash", f"{field} must be a lowercase SHA-256 hash")
return value
def normalize_operation(operation: dict[str, Any], *, sequence: int) -> dict[str, Any]: def normalize_operation(operation: dict[str, Any], *, sequence: int) -> dict[str, Any]:
@ -109,21 +111,28 @@ def normalize_operation(operation: dict[str, Any], *, sequence: int) -> dict[str
if not isinstance(relationships, list): if not isinstance(relationships, list):
raise DocForgeError("invalid_operation", "relationship_changes must be an array") raise DocForgeError("invalid_operation", "relationship_changes must be an array")
normalized_relationships: list[dict[str, str]] = [] normalized_relationships: list[dict[str, str]] = []
for change in relationships: for change_value in cast(list[object], relationships):
if not isinstance(change, dict) or set(change) != RELATIONSHIP_KEYS: if not isinstance(change_value, dict):
raise DocForgeError( raise DocForgeError(
"invalid_operation", "Relationship changes require exact structured fields" "invalid_operation", "Relationship changes require exact structured fields"
) )
if change["action"] not in {"add", "remove"}: change = cast(dict[str, object], change_value)
if set(change) != set(RELATIONSHIP_KEYS):
raise DocForgeError(
"invalid_operation", "Relationship changes require exact structured fields"
)
action = change["action"]
if not isinstance(action, str) or action not in {"add", "remove"}:
raise DocForgeError("invalid_operation", "Relationship action is invalid") raise DocForgeError("invalid_operation", "Relationship action is invalid")
for field in ("source_id", "relation", "target_id"): source_id = validate_id(change["source_id"], "source_id")
validate_id(change[field], field) relation = validate_id(change["relation"], "relation")
target_id = validate_id(change["target_id"], "target_id")
normalized_relationships.append( normalized_relationships.append(
{ {
"action": change["action"], "action": action,
"source_id": change["source_id"], "source_id": source_id,
"relation": change["relation"], "relation": relation,
"target_id": change["target_id"], "target_id": target_id,
} }
) )
keys = [ keys = [
@ -136,6 +145,7 @@ def normalize_operation(operation: dict[str, Any], *, sequence: int) -> dict[str
if metadata is not None and not isinstance(metadata, dict): if metadata is not None and not isinstance(metadata, dict):
raise DocForgeError("invalid_operation", "metadata must be an object or null") raise DocForgeError("invalid_operation", "metadata must be an object or null")
if isinstance(metadata, dict): if isinstance(metadata, dict):
metadata = cast(dict[str, object], metadata)
unknown_metadata = sorted(set(metadata) - METADATA_KEYS) unknown_metadata = sorted(set(metadata) - METADATA_KEYS)
if unknown_metadata: if unknown_metadata:
raise DocForgeError( raise DocForgeError(
@ -172,7 +182,12 @@ def normalize_operation(operation: dict[str, Any], *, sequence: int) -> dict[str
def validate_document(project: ProjectService, document: Any, *, path: Path) -> dict[str, Any]: def validate_document(project: ProjectService, document: Any, *, path: Path) -> dict[str, Any]:
if not isinstance(document, dict) or set(document) != CHANGESET_KEYS: if not isinstance(document, dict):
raise DocForgeError(
"invalid_changeset", "Changeset has missing or unknown fields", path=path.name
)
document = cast(dict[str, Any], document)
if set(document) != set(CHANGESET_KEYS):
raise DocForgeError( raise DocForgeError(
"invalid_changeset", "Changeset has missing or unknown fields", path=path.name "invalid_changeset", "Changeset has missing or unknown fields", path=path.name
) )
@ -207,11 +222,17 @@ def validate_document(project: ProjectService, document: Any, *, path: Path) ->
operations = document["operations"] operations = document["operations"]
if not isinstance(operations, list): if not isinstance(operations, list):
raise DocForgeError("invalid_changeset", "operations must be an array") raise DocForgeError("invalid_changeset", "operations must be an array")
if len(operations) > descriptor.limits.max_changeset_operations: typed_operations = cast(list[object], operations)
if len(typed_operations) > descriptor.limits.max_changeset_operations:
raise DocForgeError("changeset_operation_limit", "Changeset has too many operations") raise DocForgeError("changeset_operation_limit", "Changeset has too many operations")
normalized: list[dict[str, Any]] = [] normalized: list[dict[str, Any]] = []
for index, operation in enumerate(operations, start=1): for index, operation_value in enumerate(typed_operations, start=1):
if not isinstance(operation, dict) or set(operation) != OPERATION_KEYS: if not isinstance(operation_value, dict):
raise DocForgeError(
"invalid_changeset", "Stored operation has missing or unknown fields"
)
operation = cast(dict[str, Any], operation_value)
if set(operation) != set(OPERATION_KEYS):
raise DocForgeError( raise DocForgeError(
"invalid_changeset", "Stored operation has missing or unknown fields" "invalid_changeset", "Stored operation has missing or unknown fields"
) )

View file

@ -6,7 +6,7 @@ import fcntl
import json import json
import os import os
import tempfile import tempfile
from collections.abc import Iterator from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -517,7 +517,7 @@ class ChangesetStore:
return root return root
@contextmanager @contextmanager
def _lock(self) -> Iterator[None]: def _lock(self) -> Generator[None]:
root = self._root() root = self._root()
lock_path = root / ".lock" lock_path = root / ".lock"
try: try:

View file

@ -3,8 +3,9 @@
from __future__ import annotations from __future__ import annotations
import re import re
from collections.abc import Mapping
from pathlib import Path from pathlib import Path
from typing import Any from typing import cast
from .errors import DocForgeError from .errors import DocForgeError
@ -13,7 +14,7 @@ AUTHORITIES = frozenset({"authoritative", "approved_plan", "derived", "proposal"
_SECRET_PARTS = frozenset({".git", ".ssh", ".gnupg", "secrets", "credentials"}) _SECRET_PARTS = frozenset({".git", ".ssh", ".gnupg", "secrets", "credentials"})
def require_string(document: dict[str, Any], key: str, source: Path) -> str: def require_string(document: Mapping[str, object], key: str, source: Path) -> str:
value = document.get(key) value = document.get(key)
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
raise DocForgeError("invalid_source", f"{source.name}: {key} must be a non-empty string") raise DocForgeError("invalid_source", f"{source.name}: {key} must be a non-empty string")
@ -21,11 +22,16 @@ def require_string(document: dict[str, Any], key: str, source: Path) -> str:
def string_list(value: object, *, key: str, source: Path) -> tuple[str, ...]: def string_list(value: object, *, key: str, source: Path) -> tuple[str, ...]:
if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value): if not isinstance(value, list):
raise DocForgeError("invalid_source", f"{source.name}: {key} must be a string list") raise DocForgeError("invalid_source", f"{source.name}: {key} must be a string list")
if len(value) != len(set(value)): items: list[str] = []
for item in cast(list[object], value):
if not isinstance(item, str) or not item:
raise DocForgeError("invalid_source", f"{source.name}: {key} must be a string list")
items.append(item)
if len(items) != len(set(items)):
raise DocForgeError("invalid_source", f"{source.name}: {key} contains duplicates") raise DocForgeError("invalid_source", f"{source.name}: {key} contains duplicates")
return tuple(value) return tuple(items)
def confined_path( def confined_path(

View file

@ -42,8 +42,7 @@ def compile_context(
profile = _profile(snapshot, profile_id) profile = _profile(snapshot, profile_id)
selected_budget = profile.token_budget if budget is None else budget selected_budget = profile.token_budget if budget is None else budget
if ( if (
not isinstance(selected_budget, int) isinstance(selected_budget, bool)
or isinstance(selected_budget, bool)
or selected_budget < 1 or selected_budget < 1
or selected_budget > snapshot.descriptor.limits.max_context_tokens or selected_budget > snapshot.descriptor.limits.max_context_tokens
): ):

View file

@ -6,7 +6,7 @@ import argparse
import json import json
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@ -272,7 +272,7 @@ class DocForgeService:
query=query, query=query,
depth=depth, depth=depth,
) )
snapshot = visualization["snapshot"] snapshot = cast(dict[str, object], visualization["snapshot"])
return { return {
"status": "ok", "status": "ok",
"project_id": snapshot["project_id"], "project_id": snapshot["project_id"],
@ -396,6 +396,20 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
return service.visualize(node_id=node_id, query=query, depth=depth) return service.visualize(node_id=node_id, query=query, depth=depth)
_registered_read_tools = (
project_info,
get_contract,
get_node,
search,
filter_nodes,
backlinks,
dependencies,
impact,
get_context,
validate_project,
render_status,
visualize,
)
if read_only: if read_only:
return server return server
@ -531,6 +545,18 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
return service.invoke(lambda: service.rendering.preview(changeset_id, view_id)) return service.invoke(lambda: service.rendering.preview(changeset_id, view_id))
_registered_proposal_tools = (
create_changeset,
list_changesets,
get_changeset,
propose_node_create,
propose_node_update,
propose_node_move,
propose_node_delete,
validate_changeset,
get_changeset_diff,
preview_changeset,
)
return server return server

View file

@ -10,7 +10,7 @@ from collections import Counter
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from .config_validation import ( from .config_validation import (
AUTHORITIES, AUTHORITIES,
@ -82,7 +82,7 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
raise DocForgeError("missing_config", "Missing .docforge/project.toml") raise DocForgeError("missing_config", "Missing .docforge/project.toml")
try: try:
descriptor_bytes = descriptor_path.read_bytes() descriptor_bytes = descriptor_path.read_bytes()
document = tomllib.loads(descriptor_bytes.decode("utf-8")) document = cast(dict[str, object], tomllib.loads(descriptor_bytes.decode("utf-8")))
except UnicodeDecodeError as error: except UnicodeDecodeError as error:
raise DocForgeError("invalid_config", "Project descriptor is not UTF-8") from error raise DocForgeError("invalid_config", "Project descriptor is not UTF-8") from error
except tomllib.TOMLDecodeError as error: except tomllib.TOMLDecodeError as error:
@ -119,6 +119,10 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
raise DocForgeError( raise DocForgeError(
"invalid_config", "sources, derived, changesets, and graph tables are required" "invalid_config", "sources, derived, changesets, and graph tables are required"
) )
sources = cast(dict[str, object], sources)
derived = cast(dict[str, object], derived)
changesets = cast(dict[str, object], changesets)
graph = cast(dict[str, object], graph)
for table, allowed, name in ( for table, allowed, name in (
(sources, _SOURCE_KEYS, "sources"), (sources, _SOURCE_KEYS, "sources"),
(derived, _DERIVED_KEYS, "derived"), (derived, _DERIVED_KEYS, "derived"),
@ -192,9 +196,10 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
raise DocForgeError("invalid_config", "changesets.writers must be an array of tables") raise DocForgeError("invalid_config", "changesets.writers must be an array of tables")
proposal_writers: list[ProposalWriter] = [] proposal_writers: list[ProposalWriter] = []
writer_ids: set[str] = set() writer_ids: set[str] = set()
for writer in writer_documents: for writer_value in cast(list[object], writer_documents):
if not isinstance(writer, dict): if not isinstance(writer_value, dict):
raise DocForgeError("invalid_config", "Each changeset writer must be a table") raise DocForgeError("invalid_config", "Each changeset writer must be a table")
writer = cast(dict[str, object], writer_value)
unknown_writer = sorted(set(writer) - _WRITER_KEYS) unknown_writer = sorted(set(writer) - _WRITER_KEYS)
if unknown_writer: if unknown_writer:
raise DocForgeError( raise DocForgeError(
@ -241,6 +246,7 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
limit_values = document.get("limits", {}) limit_values = document.get("limits", {})
if not isinstance(limit_values, dict): if not isinstance(limit_values, dict):
raise DocForgeError("invalid_config", "limits must be a table") raise DocForgeError("invalid_config", "limits must be a table")
limit_values = cast(dict[str, object], limit_values)
defaults = Limits() defaults = Limits()
unknown_limits = sorted(set(limit_values) - set(defaults.__dataclass_fields__)) unknown_limits = sorted(set(limit_values) - set(defaults.__dataclass_fields__))
if unknown_limits: if unknown_limits:
@ -269,9 +275,10 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
raise DocForgeError("invalid_config", "profiles must be an array of tables") raise DocForgeError("invalid_config", "profiles must be an array of tables")
profiles: list[ContextProfile] = [] profiles: list[ContextProfile] = []
profile_ids: set[str] = set() profile_ids: set[str] = set()
for profile in profile_documents: for profile_value in cast(list[object], profile_documents):
if not isinstance(profile, dict): if not isinstance(profile_value, dict):
raise DocForgeError("invalid_config", "Each profile must be a table") raise DocForgeError("invalid_config", "Each profile must be a table")
profile = cast(dict[str, object], profile_value)
unknown_profile = sorted(set(profile) - _PROFILE_KEYS) unknown_profile = sorted(set(profile) - _PROFILE_KEYS)
if unknown_profile: if unknown_profile:
raise DocForgeError( raise DocForgeError(
@ -416,7 +423,7 @@ def _load_source_file(
relative = path.relative_to(descriptor.root).as_posix() relative = path.relative_to(descriptor.root).as_posix()
if path.suffix == ".md": if path.suffix == ".md":
record, content = _markdown_record(path, text) record, content = _markdown_record(path, text)
node, edges = validated_node_from_record( node, node_edges = validated_node_from_record(
record, record,
content=content, content=content,
source=path, source=path,
@ -424,10 +431,10 @@ def _load_source_file(
relations=descriptor.allowed_relations, relations=descriptor.allowed_relations,
hash_bytes=raw, hash_bytes=raw,
) )
return (node,), edges return (node,), node_edges
if path.suffix == ".toml": if path.suffix == ".toml":
try: try:
document = tomllib.loads(text) document = cast(dict[str, object], tomllib.loads(text))
except tomllib.TOMLDecodeError as error: except tomllib.TOMLDecodeError as error:
raise DocForgeError("invalid_source", f"{path.name}: invalid TOML: {error}") from error raise DocForgeError("invalid_source", f"{path.name}: invalid TOML: {error}") from error
records = document.get("nodes") records = document.get("nodes")
@ -435,9 +442,10 @@ def _load_source_file(
raise DocForgeError("invalid_source", f"{path.name}: TOML sources require [[nodes]]") raise DocForgeError("invalid_source", f"{path.name}: TOML sources require [[nodes]]")
nodes: list[Node] = [] nodes: list[Node] = []
edges: list[Edge] = [] edges: list[Edge] = []
for index, record in enumerate(records): for index, record_value in enumerate(cast(list[object], records)):
if not isinstance(record, dict): if not isinstance(record_value, dict):
raise DocForgeError("invalid_source", f"{path.name}: nodes must be tables") raise DocForgeError("invalid_source", f"{path.name}: nodes must be tables")
record = cast(dict[str, Any], record_value)
content = record.get("content") content = record.get("content")
if not isinstance(content, str): if not isinstance(content, str):
raise DocForgeError( raise DocForgeError(

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import difflib import difflib
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from .changeset_contract import ( from .changeset_contract import (
METADATA_KEYS, METADATA_KEYS,
@ -113,8 +113,13 @@ class ProposalProjector:
raise DocForgeError("node_exists", "Create target already exists", node_id=node_id) raise DocForgeError("node_exists", "Create target already exists", node_id=node_id)
if operation["expected_content_hash"] is not None: if operation["expected_content_hash"] is not None:
raise DocForgeError("invalid_operation", "Create expected_content_hash must be null") raise DocForgeError("invalid_operation", "Create expected_content_hash must be null")
metadata = operation["metadata"] metadata_value = operation["metadata"]
if not isinstance(metadata, dict) or not REQUIRED_CREATE_METADATA.issubset(metadata): if not isinstance(metadata_value, dict):
raise DocForgeError(
"invalid_operation", "Create requires complete node metadata", node_id=node_id
)
metadata = cast(dict[str, Any], metadata_value)
if not REQUIRED_CREATE_METADATA.issubset(metadata):
raise DocForgeError( raise DocForgeError(
"invalid_operation", "Create requires complete node metadata", node_id=node_id "invalid_operation", "Create requires complete node metadata", node_id=node_id
) )
@ -156,9 +161,10 @@ class ProposalProjector:
) -> None: ) -> None:
if operation["target_source"] is not None: if operation["target_source"] is not None:
raise DocForgeError("invalid_operation", "Update cannot move a node") raise DocForgeError("invalid_operation", "Update cannot move a node")
metadata = operation["metadata"] metadata_value = operation["metadata"]
if metadata is not None and not isinstance(metadata, dict): if metadata_value is not None and not isinstance(metadata_value, dict):
raise DocForgeError("invalid_operation", "Update metadata must be an object or null") raise DocForgeError("invalid_operation", "Update metadata must be an object or null")
metadata = None if metadata_value is None else cast(dict[str, Any], metadata_value)
if ( if (
metadata is None metadata is None
and operation["content"] is None and operation["content"] is None
@ -171,7 +177,7 @@ class ProposalProjector:
"invalid_operation", "Update may change only relationships owned by its node" "invalid_operation", "Update may change only relationships owned by its node"
) )
self._apply_relationship_changes(edges, changes) self._apply_relationship_changes(edges, changes)
merged = {**node_metadata(current), **(metadata or {})} merged: dict[str, Any] = {**node_metadata(current), **(metadata or {})}
content = current.content if operation["content"] is None else operation["content"] content = current.content if operation["content"] is None else operation["content"]
node = self._build_node( node = self._build_node(
snapshot, snapshot,

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import cast
from .config_validation import ID_PATTERN, confined_path, require_string, string_list from .config_validation import ID_PATTERN, confined_path, require_string, string_list
from .errors import DocForgeError from .errors import DocForgeError
@ -33,6 +34,7 @@ def load_render_config(
return None return None
if not isinstance(document, dict): if not isinstance(document, dict):
raise DocForgeError("invalid_config", "render must be a table") raise DocForgeError("invalid_config", "render must be a table")
document = cast(dict[str, object], document)
unknown = sorted(set(document) - _RENDER_KEYS) unknown = sorted(set(document) - _RENDER_KEYS)
if unknown: if unknown:
raise DocForgeError("invalid_config", "render has unknown fields", fields=unknown) raise DocForgeError("invalid_config", "render has unknown fields", fields=unknown)
@ -65,14 +67,16 @@ def load_render_config(
view_documents = document.get("views") view_documents = document.get("views")
if not isinstance(view_documents, list) or not view_documents: if not isinstance(view_documents, list) or not view_documents:
raise DocForgeError("invalid_config", "render.views must contain at least one view") raise DocForgeError("invalid_config", "render.views must contain at least one view")
if len(view_documents) > limits.max_render_views: view_values = cast(list[object], view_documents)
if len(view_values) > limits.max_render_views:
raise DocForgeError("invalid_config", "render.views exceeds the configured limit") raise DocForgeError("invalid_config", "render.views exceeds the configured limit")
views: list[RenderView] = [] views: list[RenderView] = []
view_ids: set[str] = set() view_ids: set[str] = set()
output_paths: set[Path] = set() output_paths: set[Path] = set()
for view_document in view_documents: for view_value in view_values:
if not isinstance(view_document, dict): if not isinstance(view_value, dict):
raise DocForgeError("invalid_config", "Each render view must be a table") raise DocForgeError("invalid_config", "Each render view must be a table")
view_document = cast(dict[str, object], view_value)
unknown_view = sorted(set(view_document) - _VIEW_KEYS) unknown_view = sorted(set(view_document) - _VIEW_KEYS)
if unknown_view: if unknown_view:
raise DocForgeError( raise DocForgeError(

View file

@ -6,7 +6,7 @@ import fcntl
import hashlib import hashlib
import os import os
import tempfile import tempfile
from collections.abc import Callable, Iterator from collections.abc import Callable, Generator
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
@ -279,7 +279,7 @@ class RenderService:
} }
@contextmanager @contextmanager
def _lock(self) -> Iterator[None]: def _lock(self) -> Generator[None]:
root = self.project.descriptor.cache_root root = self.project.descriptor.cache_root
if root.resolve(strict=False) != root: if root.resolve(strict=False) != root:
raise DocForgeError("path_escape", "Render lock directory is not safe") raise DocForgeError("path_escape", "Render lock directory is not safe")

View file

@ -18,6 +18,7 @@ from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from time import monotonic from time import monotonic
from typing import cast
from .errors import DocForgeError from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
@ -54,21 +55,35 @@ class VisualizationIndexSnapshot:
self.max_query_chars = index.project.descriptor.limits.max_query_chars self.max_query_chars = index.project.descriptor.limits.max_query_chars
self.max_results = index.project.descriptor.limits.max_results self.max_results = index.project.descriptor.limits.max_results
self.max_depth = index.project.descriptor.limits.max_traversal_depth self.max_depth = index.project.descriptor.limits.max_traversal_depth
self.identity = {key: checked[key] for key in self._IDENTITY_KEYS} self.identity: dict[str, object] = {key: checked[key] for key in self._IDENTITY_KEYS}
self._stat = self._safe_stat() self._stat = self._safe_stat()
@classmethod @classmethod
def from_spec(cls, spec: dict[str, object]) -> VisualizationIndexSnapshot: def from_spec(cls, spec: dict[str, object]) -> VisualizationIndexSnapshot:
snapshot = cls.__new__(cls) snapshot = cls.__new__(cls)
snapshot.path = Path(str(spec["path"])) path = spec["path"]
snapshot.title = str(spec["title"]) title = spec["title"]
snapshot.max_query_chars = int(spec["max_query_chars"]) max_query_chars = spec["max_query_chars"]
snapshot.max_results = int(spec["max_results"]) max_results = spec["max_results"]
snapshot.max_depth = int(spec["max_depth"]) max_depth = spec["max_depth"]
if (
not isinstance(path, str)
or not isinstance(title, str)
or type(max_query_chars) is not int
or type(max_results) is not int
or type(max_depth) is not int
):
raise DocForgeError("invalid_index", "Visualization snapshot is invalid")
snapshot.path = Path(path)
snapshot.title = title
snapshot.max_query_chars = max_query_chars
snapshot.max_results = max_results
snapshot.max_depth = max_depth
identity = spec["identity"] identity = spec["identity"]
if not isinstance(identity, dict): if not isinstance(identity, dict):
raise DocForgeError("invalid_index", "Visualization identity is invalid") raise DocForgeError("invalid_index", "Visualization identity is invalid")
snapshot.identity = {key: identity[key] for key in cls._IDENTITY_KEYS} typed_identity = cast(dict[str, object], identity)
snapshot.identity = {key: typed_identity[key] for key in cls._IDENTITY_KEYS}
snapshot._stat = snapshot._safe_stat() snapshot._stat = snapshot._safe_stat()
return snapshot return snapshot
@ -131,7 +146,7 @@ class VisualizationIndexSnapshot:
+ " ORDER BY rank, nodes.node_id LIMIT ?", + " ORDER BY rank, nodes.node_id LIMIT ?",
values, values,
).fetchall() ).fetchall()
results = [] results: list[dict[str, object]] = []
for row in rows: for row in rows:
item = _node_dict(row, include_content=False) item = _node_dict(row, include_content=False)
item.update({"rank": row["rank"], "snippet": row["snippet"]}) item.update({"rank": row["rank"], "snippet": row["snippet"]})
@ -294,6 +309,9 @@ class VisualizationIndexSnapshot:
**payload, **payload,
} }
def result(self, **payload: object) -> dict[str, object]:
return self._result(**payload)
class VisualizationRunner: class VisualizationRunner:
"""Start one token-protected loopback reader for one immutable project binding.""" """Start one token-protected loopback reader for one immutable project binding."""
@ -387,7 +405,8 @@ class VisualizationRunner:
def do_DELETE(self) -> None: # noqa: N802 def do_DELETE(self) -> None: # noqa: N802
self.do_POST() self.do_POST()
def log_message(self, _format: str, *_args: object) -> None: def log_message(self, format: str, *args: object) -> None:
del format, args
return return
self._server = _VisualizationHttpServer(("127.0.0.1", 0), Handler) self._server = _VisualizationHttpServer(("127.0.0.1", 0), Handler)
@ -514,7 +533,7 @@ class VisualizationRunner:
if parsed.path == f"{prefix}/api/overview": if parsed.path == f"{prefix}/api/overview":
payload = reader.overview() payload = reader.overview()
elif parsed.path == f"{prefix}/api/heartbeat": elif parsed.path == f"{prefix}/api/heartbeat":
payload = reader._result( payload = reader.result(
viewer="alive", viewer="alive",
lease_seconds=self.lease_seconds, lease_seconds=self.lease_seconds,
) )
@ -761,7 +780,7 @@ class DetachedVisualizationRunner:
"visualization_unavailable", "visualization_unavailable",
"The detached visualization worker rejected the snapshot", "The detached visualization worker rejected the snapshot",
) )
return response["visualization"] return cast(dict[str, object], response["visualization"])
def stop(self) -> None: def stop(self) -> None:
process = self._process process = self._process
@ -793,7 +812,7 @@ def _receive_worker_response(control: socket.socket) -> dict[str, object]:
result = json.loads(payload) result = json.loads(payload)
if not isinstance(result, dict): if not isinstance(result, dict):
raise ValueError("Visualization worker returned an invalid response") raise ValueError("Visualization worker returned an invalid response")
return result return cast(dict[str, object], result)
def _one(params: dict[str, list[str]], name: str) -> str: def _one(params: dict[str, list[str]], name: str) -> str:

View file

@ -8,7 +8,7 @@ import os
import socket import socket
import time import time
from contextlib import suppress from contextlib import suppress
from typing import Any from typing import cast
from .errors import DocForgeError from .errors import DocForgeError
from .visualization import VisualizationRunner from .visualization import VisualizationRunner
@ -20,7 +20,7 @@ def _parser() -> argparse.ArgumentParser:
return parser return parser
def _read_request(control: socket.socket) -> dict[str, Any]: def _read_request(control: socket.socket) -> dict[str, object]:
chunks: list[bytes] = [] chunks: list[bytes] = []
size = 0 size = 0
while True: while True:
@ -37,7 +37,7 @@ def _read_request(control: socket.socket) -> dict[str, Any]:
request = json.loads(payload) request = json.loads(payload)
if not isinstance(request, dict): if not isinstance(request, dict):
raise ValueError("Visualization launch request is invalid") raise ValueError("Visualization launch request is invalid")
return request return cast(dict[str, object], request)
def _pid_exists(pid: int) -> bool: def _pid_exists(pid: int) -> bool:
@ -59,21 +59,45 @@ def main(argv: list[str] | None = None) -> int:
try: try:
request = _read_request(control) request = _read_request(control)
target = request["target"] target = request["target"]
if not isinstance(target, dict): snapshot = request["snapshot"]
token = request["token"]
initial_grace = request["initial_grace_seconds"]
lease = request["lease_seconds"]
monitor_interval = request["monitor_interval_seconds"]
owner = request["owner_pid"]
if not isinstance(target, dict) or not isinstance(snapshot, dict):
raise ValueError("Visualization target is invalid") raise ValueError("Visualization target is invalid")
target = cast(dict[str, object], target)
node_id = target.get("node_id")
query = target.get("query")
depth = target.get("depth")
if (
(node_id is not None and not isinstance(node_id, str))
or (query is not None and not isinstance(query, str))
or type(depth) is not int
or not isinstance(token, str)
or not isinstance(initial_grace, int | float)
or isinstance(initial_grace, bool)
or not isinstance(lease, int | float)
or isinstance(lease, bool)
or not isinstance(monitor_interval, int | float)
or isinstance(monitor_interval, bool)
or type(owner) is not int
):
raise ValueError("Visualization launch request is invalid")
runner = VisualizationRunner( runner = VisualizationRunner(
None, None,
snapshot_spec=request["snapshot"], snapshot_spec=cast(dict[str, object], snapshot),
token=str(request["token"]), token=token,
register_atexit=False, register_atexit=False,
initial_grace_seconds=float(request["initial_grace_seconds"]), initial_grace_seconds=float(initial_grace),
lease_seconds=float(request["lease_seconds"]), lease_seconds=float(lease),
monitor_interval_seconds=float(request["monitor_interval_seconds"]), monitor_interval_seconds=float(monitor_interval),
) )
visualization = runner.start( visualization = runner.start(
node_id=target.get("node_id"), node_id=node_id,
query=target.get("query"), query=query,
depth=int(target["depth"]), depth=depth,
) )
control.sendall( control.sendall(
json.dumps( json.dumps(
@ -83,7 +107,7 @@ def main(argv: list[str] | None = None) -> int:
).encode("utf-8") ).encode("utf-8")
+ b"\n" + b"\n"
) )
owner_pid = int(request["owner_pid"]) owner_pid = owner
except (DocForgeError, KeyError, OSError, TypeError, ValueError) as error: except (DocForgeError, KeyError, OSError, TypeError, ValueError) as error:
with suppress(OSError): with suppress(OSError):
control.sendall( control.sendall(