Enforce strict Pyright gate
This commit is contained in:
parent
90898cfd63
commit
2841042b9c
16 changed files with 214 additions and 77 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue