generated from coulomb/repo-seed
context loading, path resolution, form state, dynamic rules, and provider-neutral assessment runner/cache boundary
This commit is contained in:
53
src/markitect_tool/runtime/__init__.py
Normal file
53
src/markitect_tool/runtime/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Runtime context, form state, rules, and assessment extension APIs."""
|
||||
|
||||
from markitect_tool.runtime.assessment import (
|
||||
AssessmentAdapter,
|
||||
AssessmentCache,
|
||||
AssessmentRequest,
|
||||
AssessmentResult,
|
||||
AssessmentRunResult,
|
||||
AssessmentRunner,
|
||||
MemoryAssessmentCache,
|
||||
assessment_requests_for_contract,
|
||||
run_contract_assessments,
|
||||
)
|
||||
from markitect_tool.runtime.context import (
|
||||
RuntimeContext,
|
||||
RuntimeContextLoadResult,
|
||||
RuntimeContextSource,
|
||||
load_runtime_context_file,
|
||||
load_runtime_context_file_result,
|
||||
)
|
||||
from markitect_tool.runtime.forms import (
|
||||
FieldState,
|
||||
FormState,
|
||||
build_runtime_bindings,
|
||||
evaluate_form_state,
|
||||
)
|
||||
from markitect_tool.runtime.paths import comparable_value, resolve_path
|
||||
from markitect_tool.runtime.rules import ConditionResult, evaluate_condition
|
||||
|
||||
__all__ = [
|
||||
"AssessmentAdapter",
|
||||
"AssessmentCache",
|
||||
"AssessmentRequest",
|
||||
"AssessmentResult",
|
||||
"AssessmentRunResult",
|
||||
"AssessmentRunner",
|
||||
"ConditionResult",
|
||||
"FieldState",
|
||||
"FormState",
|
||||
"MemoryAssessmentCache",
|
||||
"RuntimeContext",
|
||||
"RuntimeContextLoadResult",
|
||||
"RuntimeContextSource",
|
||||
"assessment_requests_for_contract",
|
||||
"build_runtime_bindings",
|
||||
"comparable_value",
|
||||
"evaluate_condition",
|
||||
"evaluate_form_state",
|
||||
"load_runtime_context_file",
|
||||
"load_runtime_context_file_result",
|
||||
"resolve_path",
|
||||
"run_contract_assessments",
|
||||
]
|
||||
309
src/markitect_tool/runtime/assessment.py
Normal file
309
src/markitect_tool/runtime/assessment.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""Provider-neutral assessment protocol for rubric-backed contract checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from typing import Any, Protocol
|
||||
|
||||
from markitect_tool.contract.model import DocumentContract
|
||||
from markitect_tool.core import Document, Section
|
||||
from markitect_tool.diagnostics import Diagnostic, SourceLocation, has_error
|
||||
from markitect_tool.runtime.context import RuntimeContext
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssessmentRequest:
|
||||
"""A provider-neutral request for rubric assessment."""
|
||||
|
||||
contract_id: str | None
|
||||
rule_id: str
|
||||
scope: str
|
||||
text: str
|
||||
criteria: Any
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
structured_inputs: dict[str, Any] = field(default_factory=dict)
|
||||
severity: str = "error"
|
||||
threshold: float | None = None
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def cache_key(self) -> str:
|
||||
payload = {
|
||||
"contract_id": self.contract_id,
|
||||
"rule_id": self.rule_id,
|
||||
"scope": self.scope,
|
||||
"text": self.text,
|
||||
"criteria": self.criteria,
|
||||
"context": self.context,
|
||||
"structured_inputs": self.structured_inputs,
|
||||
"threshold": self.threshold,
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
return "assessment:" + hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["cache_key"] = self.cache_key
|
||||
return _drop_empty(data)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssessmentResult:
|
||||
"""Normalized result returned by an assessment adapter."""
|
||||
|
||||
rule_id: str
|
||||
passed: bool
|
||||
score: float | None = None
|
||||
reason: str | None = None
|
||||
diagnostics: list[Diagnostic] = field(default_factory=list)
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
cache_key: str | None = None
|
||||
cached: bool = False
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return self.passed and not has_error(self.diagnostics)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = {
|
||||
"rule_id": self.rule_id,
|
||||
"passed": self.passed,
|
||||
"score": self.score,
|
||||
"reason": self.reason,
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
"cache_key": self.cache_key,
|
||||
"cached": self.cached,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
return _drop_empty(data)
|
||||
|
||||
|
||||
class AssessmentAdapter(Protocol):
|
||||
"""Adapter boundary for an LLM or other semantic grader."""
|
||||
|
||||
def assess(self, request: AssessmentRequest) -> AssessmentResult | dict[str, Any]:
|
||||
"""Assess a request and return a normalized result or mapping."""
|
||||
|
||||
|
||||
class AssessmentCache(Protocol):
|
||||
"""Minimal pluggable assessment cache."""
|
||||
|
||||
def get(self, cache_key: str) -> AssessmentResult | None:
|
||||
"""Return a cached assessment result if available."""
|
||||
|
||||
def set(self, cache_key: str, result: AssessmentResult) -> None:
|
||||
"""Store an assessment result."""
|
||||
|
||||
|
||||
class MemoryAssessmentCache:
|
||||
"""Transparent in-memory cache useful for tests and short workflow runs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._results: dict[str, AssessmentResult] = {}
|
||||
|
||||
def get(self, cache_key: str) -> AssessmentResult | None:
|
||||
return self._results.get(cache_key)
|
||||
|
||||
def set(self, cache_key: str, result: AssessmentResult) -> None:
|
||||
self._results[cache_key] = result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssessmentRunResult:
|
||||
"""Result of executing one or more rubric assessments."""
|
||||
|
||||
assessments: list[AssessmentResult] = field(default_factory=list)
|
||||
diagnostics: list[Diagnostic] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return not has_error(self.diagnostics)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = {
|
||||
"valid": self.valid,
|
||||
"assessments": [assessment.to_dict() for assessment in self.assessments],
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
||||
}
|
||||
return _drop_empty(data)
|
||||
|
||||
|
||||
class AssessmentRunner:
|
||||
"""Invoke an injected assessment adapter and normalize diagnostics."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: AssessmentAdapter,
|
||||
*,
|
||||
cache: AssessmentCache | None = None,
|
||||
) -> None:
|
||||
self.adapter = adapter
|
||||
self.cache = cache
|
||||
|
||||
def assess(self, request: AssessmentRequest) -> AssessmentResult:
|
||||
if self.cache:
|
||||
cached = self.cache.get(request.cache_key)
|
||||
if cached:
|
||||
return replace(cached, cached=True)
|
||||
|
||||
raw_result = self.adapter.assess(request)
|
||||
result = _normalize_assessment_result(raw_result, request)
|
||||
if self.cache:
|
||||
self.cache.set(request.cache_key, result)
|
||||
return result
|
||||
|
||||
def assess_all(self, requests: list[AssessmentRequest]) -> AssessmentRunResult:
|
||||
assessments = [self.assess(request) for request in requests]
|
||||
diagnostics: list[Diagnostic] = []
|
||||
for request, assessment in zip(requests, assessments, strict=True):
|
||||
diagnostics.extend(_diagnostics_for_result(request, assessment))
|
||||
return AssessmentRunResult(assessments=assessments, diagnostics=diagnostics)
|
||||
|
||||
|
||||
def assessment_requests_for_contract(
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
runtime_context: RuntimeContext | None = None,
|
||||
) -> list[AssessmentRequest]:
|
||||
"""Create assessment requests for contract rubric declarations."""
|
||||
|
||||
context = runtime_context or RuntimeContext.empty()
|
||||
requests: list[AssessmentRequest] = []
|
||||
for index, rubric in enumerate(contract.rubrics):
|
||||
if not isinstance(rubric, dict):
|
||||
continue
|
||||
rule_id = str(rubric.get("id") or f"rubric-{index + 1}")
|
||||
scope = str(rubric.get("scope") or "document")
|
||||
text = _text_for_scope(document, contract, scope)
|
||||
requests.append(
|
||||
AssessmentRequest(
|
||||
contract_id=contract.id,
|
||||
rule_id=rule_id,
|
||||
scope=scope,
|
||||
text=text,
|
||||
criteria=rubric.get("criteria") or rubric.get("prompt") or rubric,
|
||||
context=context.binding(),
|
||||
structured_inputs={
|
||||
"frontmatter": document.frontmatter,
|
||||
"contract": contract.to_dict(),
|
||||
},
|
||||
severity=str(rubric.get("severity", "error")),
|
||||
threshold=rubric.get("threshold"),
|
||||
provider=rubric.get("provider"),
|
||||
model=rubric.get("model"),
|
||||
metadata={"rubric": rubric},
|
||||
)
|
||||
)
|
||||
return requests
|
||||
|
||||
|
||||
def run_contract_assessments(
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
adapter: AssessmentAdapter,
|
||||
*,
|
||||
runtime_context: RuntimeContext | None = None,
|
||||
cache: AssessmentCache | None = None,
|
||||
) -> AssessmentRunResult:
|
||||
"""Run all rubrics in a contract with an injected assessment adapter."""
|
||||
|
||||
runner = AssessmentRunner(adapter, cache=cache)
|
||||
return runner.assess_all(
|
||||
assessment_requests_for_contract(document, contract, runtime_context)
|
||||
)
|
||||
|
||||
|
||||
def _normalize_assessment_result(
|
||||
raw_result: AssessmentResult | dict[str, Any],
|
||||
request: AssessmentRequest,
|
||||
) -> AssessmentResult:
|
||||
if isinstance(raw_result, AssessmentResult):
|
||||
result = raw_result
|
||||
else:
|
||||
result = AssessmentResult(
|
||||
rule_id=str(raw_result.get("rule_id") or request.rule_id),
|
||||
passed=bool(raw_result.get("passed")),
|
||||
score=raw_result.get("score"),
|
||||
reason=raw_result.get("reason"),
|
||||
diagnostics=list(raw_result.get("diagnostics", [])),
|
||||
provider=raw_result.get("provider"),
|
||||
model=raw_result.get("model"),
|
||||
metadata=raw_result.get("metadata", {}),
|
||||
)
|
||||
return replace(
|
||||
result,
|
||||
rule_id=result.rule_id or request.rule_id,
|
||||
cache_key=request.cache_key,
|
||||
provider=result.provider or request.provider,
|
||||
model=result.model or request.model,
|
||||
)
|
||||
|
||||
|
||||
def _diagnostics_for_result(
|
||||
request: AssessmentRequest, assessment: AssessmentResult
|
||||
) -> list[Diagnostic]:
|
||||
diagnostics = list(assessment.diagnostics)
|
||||
if not assessment.passed:
|
||||
diagnostics.append(
|
||||
Diagnostic(
|
||||
severity=request.severity,
|
||||
code="runtime.assessment.failed",
|
||||
message=assessment.reason or f"Assessment `{request.rule_id}` failed.",
|
||||
rule_id=request.rule_id,
|
||||
details={
|
||||
"scope": request.scope,
|
||||
"score": assessment.score,
|
||||
"threshold": request.threshold,
|
||||
"provider": assessment.provider,
|
||||
"model": assessment.model,
|
||||
"cache_key": assessment.cache_key,
|
||||
},
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _text_for_scope(document: Document, contract: DocumentContract, scope: str) -> str:
|
||||
if scope == "document":
|
||||
return document.body
|
||||
if scope.startswith("section."):
|
||||
section_id = scope.split(".", 1)[1]
|
||||
for section_spec in contract.sections:
|
||||
if section_spec.id != section_id:
|
||||
continue
|
||||
section = _matching_section(document, section_spec.headings)
|
||||
if section:
|
||||
return "\n".join(block.text for block in section.blocks if block.text)
|
||||
if scope.startswith("field."):
|
||||
field_id = scope.split(".", 1)[1]
|
||||
value = document.frontmatter.get(field_id)
|
||||
return "" if value is None else str(value)
|
||||
return document.body
|
||||
|
||||
|
||||
def _matching_section(document: Document, headings: list[str]) -> Section | None:
|
||||
expected = {heading.strip().lower() for heading in headings}
|
||||
for section in document.sections:
|
||||
if section.heading.text.strip().lower() in expected:
|
||||
return section
|
||||
return None
|
||||
|
||||
|
||||
def _drop_empty(data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in data.items()
|
||||
if value not in (None, [], {}, "")
|
||||
}
|
||||
297
src/markitect_tool/runtime/context.py
Normal file
297
src/markitect_tool/runtime/context.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Runtime context loading and validation for contract execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from jsonschema import Draft202012Validator, SchemaError, ValidationError
|
||||
|
||||
from markitect_tool.diagnostics import Diagnostic, SourceLocation, has_error
|
||||
|
||||
|
||||
CONTEXT_RESERVED_KEYS = {"context", "metadata", "schema", "schemas", "sources"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeContextSource:
|
||||
"""Origin metadata for one loaded context object."""
|
||||
|
||||
name: str | None = None
|
||||
path: str | None = None
|
||||
kind: str = "file"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return _drop_empty(asdict(self))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeContext:
|
||||
"""Named external data available to contract checks and generation."""
|
||||
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
source_path: str | None = None
|
||||
sources: list[RuntimeContextSource] = field(default_factory=list)
|
||||
schemas: dict[str, Any] = field(default_factory=dict)
|
||||
diagnostics: list[Diagnostic] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return not has_error(self.diagnostics)
|
||||
|
||||
def binding(self) -> dict[str, Any]:
|
||||
"""Return the object bound at `context` in runtime expressions."""
|
||||
|
||||
return self.data
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = {
|
||||
"valid": self.valid,
|
||||
"source_path": self.source_path,
|
||||
"data": self.data,
|
||||
"metadata": self.metadata,
|
||||
"sources": [source.to_dict() for source in self.sources],
|
||||
"schemas": self.schemas,
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
||||
}
|
||||
return _drop_empty(data)
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> "RuntimeContext":
|
||||
return cls()
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls,
|
||||
raw: dict[str, Any],
|
||||
*,
|
||||
source_path: str | None = None,
|
||||
) -> "RuntimeContext":
|
||||
if "context" in raw:
|
||||
context_data = raw.get("context") or {}
|
||||
if not isinstance(context_data, dict):
|
||||
return cls(
|
||||
source_path=source_path,
|
||||
diagnostics=[
|
||||
_diagnostic(
|
||||
"runtime.context.invalid",
|
||||
"`context` must be a mapping.",
|
||||
source_path=source_path,
|
||||
)
|
||||
],
|
||||
)
|
||||
else:
|
||||
context_data = {
|
||||
key: value for key, value in raw.items() if key not in CONTEXT_RESERVED_KEYS
|
||||
}
|
||||
|
||||
metadata = raw.get("metadata") if isinstance(raw.get("metadata"), dict) else {}
|
||||
schemas: dict[str, Any] = {}
|
||||
if isinstance(raw.get("schema"), dict):
|
||||
schemas["$context"] = raw["schema"]
|
||||
if isinstance(raw.get("schemas"), dict):
|
||||
schemas.update(raw["schemas"])
|
||||
|
||||
sources = _sources_from_raw(raw.get("sources"), source_path)
|
||||
diagnostics = _validate_context_schemas(context_data, schemas, source_path)
|
||||
return cls(
|
||||
data=context_data,
|
||||
metadata=metadata,
|
||||
source_path=source_path,
|
||||
sources=sources,
|
||||
schemas=schemas,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeContextLoadResult:
|
||||
"""Context load result that can carry diagnostics instead of raising."""
|
||||
|
||||
context: RuntimeContext
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return self.context.valid
|
||||
|
||||
@property
|
||||
def diagnostics(self) -> list[Diagnostic]:
|
||||
return self.context.diagnostics
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return self.context.to_dict()
|
||||
|
||||
|
||||
def load_runtime_context_file(path: str | Path) -> RuntimeContext:
|
||||
"""Load a runtime context from JSON or YAML."""
|
||||
|
||||
return load_runtime_context_file_result(path).context
|
||||
|
||||
|
||||
def load_runtime_context_file_result(path: str | Path) -> RuntimeContextLoadResult:
|
||||
"""Load a runtime context and represent malformed input as diagnostics."""
|
||||
|
||||
context_path = Path(path)
|
||||
try:
|
||||
text = context_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
return RuntimeContextLoadResult(
|
||||
RuntimeContext(
|
||||
source_path=str(context_path),
|
||||
diagnostics=[
|
||||
_diagnostic(
|
||||
"runtime.context.read_failed",
|
||||
f"Cannot read runtime context: {exc}",
|
||||
source_path=str(context_path),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
raw = _load_mapping(text, context_path)
|
||||
except ValueError as exc:
|
||||
return RuntimeContextLoadResult(
|
||||
RuntimeContext(
|
||||
source_path=str(context_path),
|
||||
diagnostics=[
|
||||
_diagnostic(
|
||||
"runtime.context.malformed",
|
||||
str(exc),
|
||||
source_path=str(context_path),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
return RuntimeContextLoadResult(
|
||||
RuntimeContext.from_mapping(raw, source_path=str(context_path))
|
||||
)
|
||||
|
||||
|
||||
def _load_mapping(text: str, path: Path) -> dict[str, Any]:
|
||||
suffix = path.suffix.lower()
|
||||
try:
|
||||
if suffix == ".json":
|
||||
data = json.loads(text) if text.strip() else {}
|
||||
else:
|
||||
data = yaml.safe_load(text) if text.strip() else {}
|
||||
except (json.JSONDecodeError, yaml.YAMLError) as exc:
|
||||
raise ValueError(f"Invalid context file `{path}`: {exc}") from exc
|
||||
if data is None:
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Runtime context file must contain a mapping.")
|
||||
return data
|
||||
|
||||
|
||||
def _sources_from_raw(
|
||||
raw_sources: Any, source_path: str | None
|
||||
) -> list[RuntimeContextSource]:
|
||||
if not raw_sources:
|
||||
return [RuntimeContextSource(path=source_path)] if source_path else []
|
||||
if isinstance(raw_sources, dict):
|
||||
return [
|
||||
RuntimeContextSource(
|
||||
name=str(name),
|
||||
path=str(raw.get("path")) if isinstance(raw, dict) and raw.get("path") else None,
|
||||
kind=str(raw.get("kind", "file")) if isinstance(raw, dict) else "file",
|
||||
metadata=raw.get("metadata", {}) if isinstance(raw, dict) else {},
|
||||
)
|
||||
for name, raw in raw_sources.items()
|
||||
]
|
||||
if isinstance(raw_sources, list):
|
||||
sources: list[RuntimeContextSource] = []
|
||||
for item in raw_sources:
|
||||
if isinstance(item, dict):
|
||||
sources.append(
|
||||
RuntimeContextSource(
|
||||
name=item.get("name"),
|
||||
path=item.get("path"),
|
||||
kind=str(item.get("kind", "file")),
|
||||
metadata=item.get("metadata", {}),
|
||||
)
|
||||
)
|
||||
return sources
|
||||
return [RuntimeContextSource(path=source_path)] if source_path else []
|
||||
|
||||
|
||||
def _validate_context_schemas(
|
||||
data: dict[str, Any], schemas: dict[str, Any], source_path: str | None
|
||||
) -> list[Diagnostic]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
for name, schema in schemas.items():
|
||||
if not isinstance(schema, dict):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.context.schema_invalid",
|
||||
f"Context schema `{name}` must be a mapping.",
|
||||
source_path=source_path,
|
||||
rule_id=str(name),
|
||||
)
|
||||
)
|
||||
continue
|
||||
target = data if name == "$context" else data.get(name)
|
||||
if name != "$context" and name not in data:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.context.schema_target_missing",
|
||||
f"Context schema `{name}` has no matching context object.",
|
||||
source_path=source_path,
|
||||
rule_id=str(name),
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
Draft202012Validator.check_schema(schema)
|
||||
Draft202012Validator(schema).validate(target)
|
||||
except SchemaError as exc:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.context.schema_invalid",
|
||||
f"Invalid context schema `{name}`: {exc.message}",
|
||||
source_path=source_path,
|
||||
rule_id=str(name),
|
||||
)
|
||||
)
|
||||
except ValidationError as exc:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.context.schema",
|
||||
f"Context object `{name}` does not match its schema: {exc.message}",
|
||||
source_path=source_path,
|
||||
rule_id=str(name),
|
||||
details={"path": list(exc.absolute_path)},
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _diagnostic(
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
source_path: str | None = None,
|
||||
rule_id: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> Diagnostic:
|
||||
return Diagnostic(
|
||||
severity="error",
|
||||
code=code,
|
||||
message=message,
|
||||
source=SourceLocation(path=source_path) if source_path else None,
|
||||
rule_id=rule_id,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
|
||||
def _drop_empty(data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in data.items()
|
||||
if value not in (None, [], {}, "")
|
||||
}
|
||||
839
src/markitect_tool/runtime/forms.py
Normal file
839
src/markitect_tool/runtime/forms.py
Normal file
@@ -0,0 +1,839 @@
|
||||
"""Form state and deterministic runtime rule evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from typing import Any
|
||||
|
||||
from markitect_tool.contract.model import DocumentContract, FieldSpec, SectionSpec
|
||||
from markitect_tool.core import Document
|
||||
from markitect_tool.diagnostics import (
|
||||
Diagnostic,
|
||||
SourceLocation,
|
||||
has_error,
|
||||
valid_severity,
|
||||
)
|
||||
from markitect_tool.runtime.context import RuntimeContext
|
||||
from markitect_tool.runtime.paths import comparable_value, resolve_path
|
||||
from markitect_tool.runtime.rules import evaluate_condition
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldState:
|
||||
"""UI-neutral runtime state for one contract field."""
|
||||
|
||||
id: str
|
||||
path: str | None = None
|
||||
source: str | None = None
|
||||
type: str | None = None
|
||||
label: str | None = None
|
||||
description: str | None = None
|
||||
value: Any = None
|
||||
exists: bool = False
|
||||
origin: str = "missing"
|
||||
required: bool = False
|
||||
visible: bool = True
|
||||
enabled: bool = True
|
||||
allowed_values: list[Any] | None = None
|
||||
diagnostics: list[Diagnostic] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return not has_error(self.diagnostics)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["diagnostics"] = [diagnostic.to_dict() for diagnostic in self.diagnostics]
|
||||
return _drop_empty(data)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormState:
|
||||
"""A complete runtime form state derived from a document contract."""
|
||||
|
||||
contract_id: str | None
|
||||
document_path: str | None = None
|
||||
context_path: str | None = None
|
||||
fields: list[FieldState] = field(default_factory=list)
|
||||
diagnostics: list[Diagnostic] = field(default_factory=list)
|
||||
rules_applied: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return not has_error(self.diagnostics)
|
||||
|
||||
@property
|
||||
def field_values(self) -> dict[str, Any]:
|
||||
return {field.id: field.value for field in self.fields if field.exists}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = {
|
||||
"valid": self.valid,
|
||||
"contract_id": self.contract_id,
|
||||
"document_path": self.document_path,
|
||||
"context_path": self.context_path,
|
||||
"field_values": self.field_values,
|
||||
"fields": [field.to_dict() for field in self.fields],
|
||||
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
||||
"rules_applied": self.rules_applied,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
return _drop_empty(data)
|
||||
|
||||
|
||||
def evaluate_form_state(
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
runtime_context: RuntimeContext | None = None,
|
||||
) -> FormState:
|
||||
"""Evaluate contract fields, prefill sources, and dynamic runtime rules."""
|
||||
|
||||
context = runtime_context or RuntimeContext.empty()
|
||||
bindings = build_runtime_bindings(document, contract, context)
|
||||
fields = [
|
||||
_resolve_field_state(field_spec, document, contract, bindings)
|
||||
for field_spec in contract.fields
|
||||
]
|
||||
diagnostics: list[Diagnostic] = list(context.diagnostics)
|
||||
rules_applied: list[str] = []
|
||||
|
||||
fields_by_id = {field.id: field for field in fields}
|
||||
bindings = build_runtime_bindings(document, contract, context, fields_by_id)
|
||||
fields_by_id, rule_diagnostics, rules_applied = _apply_dynamic_rules(
|
||||
fields_by_id,
|
||||
document,
|
||||
contract,
|
||||
context,
|
||||
bindings,
|
||||
)
|
||||
diagnostics.extend(rule_diagnostics)
|
||||
|
||||
validated_fields: list[FieldState] = []
|
||||
for field_spec in contract.fields:
|
||||
field_state = fields_by_id[_field_key(field_spec)]
|
||||
field_diagnostics = [
|
||||
*field_state.diagnostics,
|
||||
*_validate_field_state(field_spec, field_state, document, contract),
|
||||
]
|
||||
validated_fields.append(replace(field_state, diagnostics=field_diagnostics))
|
||||
diagnostics.extend(field_diagnostics)
|
||||
|
||||
return FormState(
|
||||
contract_id=contract.id,
|
||||
document_path=document.source_path,
|
||||
context_path=context.source_path,
|
||||
fields=validated_fields,
|
||||
diagnostics=diagnostics,
|
||||
rules_applied=rules_applied,
|
||||
)
|
||||
|
||||
|
||||
def build_runtime_bindings(
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
context: RuntimeContext,
|
||||
fields_by_id: dict[str, FieldState] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build deterministic bindings used by field sources and rule conditions."""
|
||||
|
||||
field_states = fields_by_id or {}
|
||||
return {
|
||||
"document": document.to_dict(),
|
||||
"frontmatter": document.frontmatter,
|
||||
"context": context.binding(),
|
||||
"contract": contract.to_dict(),
|
||||
"fields": {
|
||||
field_id: field.to_dict() | {"value": field.value}
|
||||
for field_id, field in field_states.items()
|
||||
},
|
||||
"field_values": {
|
||||
field_id: field.value
|
||||
for field_id, field in field_states.items()
|
||||
if field.exists
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_field_state(
|
||||
field_spec: FieldSpec,
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
bindings: dict[str, Any],
|
||||
) -> FieldState:
|
||||
field_id = field_spec.id or "<missing>"
|
||||
path = field_spec.path or (f"frontmatter.{field_id}" if field_spec.id else None)
|
||||
source_candidates = _source_candidates(field_spec)
|
||||
diagnostics: list[Diagnostic] = []
|
||||
|
||||
document_value, document_exists = resolve_path(bindings, path)
|
||||
source_values = [
|
||||
(source, value)
|
||||
for source in source_candidates
|
||||
for value, exists in [resolve_path(bindings, source)]
|
||||
if exists
|
||||
]
|
||||
|
||||
if document_exists:
|
||||
origin = "manual"
|
||||
value = document_value
|
||||
exists = True
|
||||
for source, source_value in source_values:
|
||||
if comparable_value(source_value) != comparable_value(document_value):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.field.conflict",
|
||||
(
|
||||
f"Field `{field_id}` is provided by the document and "
|
||||
f"context source `{source}` with different values."
|
||||
),
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_id,
|
||||
severity=_conflict_severity(field_spec),
|
||||
details={
|
||||
"path": path,
|
||||
"source": source,
|
||||
"document_value": comparable_value(document_value),
|
||||
"context_value": comparable_value(source_value),
|
||||
},
|
||||
)
|
||||
)
|
||||
elif source_values:
|
||||
origin = "prefilled"
|
||||
value = source_values[0][1]
|
||||
exists = True
|
||||
distinct = {
|
||||
repr(comparable_value(item_value))
|
||||
for _source, item_value in source_values
|
||||
}
|
||||
if len(distinct) > 1:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.field.ambiguous",
|
||||
f"Field `{field_id}` has multiple source values.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_id,
|
||||
details={"sources": [source for source, _value in source_values]},
|
||||
)
|
||||
)
|
||||
elif field_spec.default is not None:
|
||||
origin = "defaulted"
|
||||
value = field_spec.default
|
||||
exists = True
|
||||
else:
|
||||
origin = "missing"
|
||||
value = None
|
||||
exists = False
|
||||
|
||||
return FieldState(
|
||||
id=field_id,
|
||||
path=path,
|
||||
source=source_candidates[0] if source_candidates else field_spec.source,
|
||||
type=field_spec.type,
|
||||
label=field_spec.label,
|
||||
description=field_spec.description,
|
||||
value=value,
|
||||
exists=exists,
|
||||
origin=origin,
|
||||
required=field_spec.required,
|
||||
allowed_values=field_spec.enum,
|
||||
diagnostics=diagnostics,
|
||||
metadata={
|
||||
"sources": source_candidates,
|
||||
"coerce": bool(field_spec.raw.get("coerce", False))
|
||||
if isinstance(field_spec.raw, dict)
|
||||
else False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _source_candidates(field_spec: FieldSpec) -> list[str]:
|
||||
raw = field_spec.raw if isinstance(field_spec.raw, dict) else {}
|
||||
candidates: list[str] = []
|
||||
source = raw.get("source", field_spec.source)
|
||||
if isinstance(source, list):
|
||||
candidates.extend(str(item) for item in source if item)
|
||||
elif source:
|
||||
candidates.append(str(source))
|
||||
sources = raw.get("sources")
|
||||
if isinstance(sources, list):
|
||||
candidates.extend(str(item) for item in sources if item)
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _field_key(field_spec: FieldSpec) -> str:
|
||||
return field_spec.id or "<missing>"
|
||||
|
||||
|
||||
def _apply_dynamic_rules(
|
||||
fields_by_id: dict[str, FieldState],
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
context: RuntimeContext,
|
||||
bindings: dict[str, Any],
|
||||
) -> tuple[dict[str, FieldState], list[Diagnostic], list[str]]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
applied: list[str] = []
|
||||
rules = contract.rules
|
||||
for index, rule in enumerate(rules):
|
||||
if not isinstance(rule, dict):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.rule.invalid",
|
||||
"Dynamic rule must be a mapping.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
)
|
||||
)
|
||||
continue
|
||||
rule_id = str(rule.get("id") or f"rule-{index + 1}")
|
||||
result = evaluate_condition(rule.get("if"), bindings, rule_id=rule_id)
|
||||
diagnostics.extend(
|
||||
_with_contract_location(item, document=document, contract=contract)
|
||||
for item in result.diagnostics
|
||||
)
|
||||
action = rule.get("then") if result.matched else rule.get("else")
|
||||
if result.matched:
|
||||
applied.append(rule_id)
|
||||
if action is not None:
|
||||
fields_by_id, action_diagnostics = _apply_rule_action(
|
||||
fields_by_id,
|
||||
action,
|
||||
document,
|
||||
contract,
|
||||
context,
|
||||
rule_id,
|
||||
)
|
||||
diagnostics.extend(action_diagnostics)
|
||||
bindings = build_runtime_bindings(document, contract, context, fields_by_id)
|
||||
if "assert" in rule and result.matched:
|
||||
diagnostics.extend(
|
||||
_evaluate_runtime_assertions(
|
||||
rule["assert"],
|
||||
bindings,
|
||||
document,
|
||||
contract,
|
||||
rule_id,
|
||||
rule,
|
||||
)
|
||||
)
|
||||
return fields_by_id, diagnostics, applied
|
||||
|
||||
|
||||
def _apply_rule_action(
|
||||
fields_by_id: dict[str, FieldState],
|
||||
action: Any,
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
context: RuntimeContext,
|
||||
rule_id: str,
|
||||
) -> tuple[dict[str, FieldState], list[Diagnostic]]:
|
||||
if not isinstance(action, dict):
|
||||
return fields_by_id, [
|
||||
_diagnostic(
|
||||
"runtime.rule.action_invalid",
|
||||
f"Dynamic rule `{rule_id}` action must be a mapping.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=rule_id,
|
||||
)
|
||||
]
|
||||
|
||||
diagnostics: list[Diagnostic] = []
|
||||
updated = dict(fields_by_id)
|
||||
bindings = build_runtime_bindings(document, contract, context, updated)
|
||||
|
||||
for field_id in _field_id_list(action.get("required")):
|
||||
updated, diagnostics = _set_field_attr(
|
||||
updated, diagnostics, field_id, {"required": True}, document, contract, rule_id
|
||||
)
|
||||
for field_id in _field_id_list(action.get("optional")):
|
||||
updated, diagnostics = _set_field_attr(
|
||||
updated, diagnostics, field_id, {"required": False}, document, contract, rule_id
|
||||
)
|
||||
for field_id, visible in _field_bool_mapping(action.get("visible")).items():
|
||||
updated, diagnostics = _set_field_attr(
|
||||
updated, diagnostics, field_id, {"visible": visible}, document, contract, rule_id
|
||||
)
|
||||
for field_id in _field_id_list(action.get("hidden")):
|
||||
updated, diagnostics = _set_field_attr(
|
||||
updated, diagnostics, field_id, {"visible": False}, document, contract, rule_id
|
||||
)
|
||||
for field_id, enabled in _field_bool_mapping(action.get("enabled")).items():
|
||||
updated, diagnostics = _set_field_attr(
|
||||
updated, diagnostics, field_id, {"enabled": enabled}, document, contract, rule_id
|
||||
)
|
||||
for field_id in _field_id_list(action.get("disabled")):
|
||||
updated, diagnostics = _set_field_attr(
|
||||
updated, diagnostics, field_id, {"enabled": False}, document, contract, rule_id
|
||||
)
|
||||
if isinstance(action.get("allowed_values"), dict):
|
||||
for field_id, allowed in action["allowed_values"].items():
|
||||
updated, diagnostics = _set_field_attr(
|
||||
updated,
|
||||
diagnostics,
|
||||
str(field_id),
|
||||
{"allowed_values": allowed if isinstance(allowed, list) else [allowed]},
|
||||
document,
|
||||
contract,
|
||||
rule_id,
|
||||
)
|
||||
if isinstance(action.get("set"), dict):
|
||||
for field_id, raw_value in action["set"].items():
|
||||
value = _resolve_template_value(raw_value, bindings)
|
||||
updated, diagnostics = _set_field_attr(
|
||||
updated,
|
||||
diagnostics,
|
||||
str(field_id),
|
||||
{"value": value, "exists": True, "origin": "calculated"},
|
||||
document,
|
||||
contract,
|
||||
rule_id,
|
||||
)
|
||||
if "assert" in action:
|
||||
diagnostics.extend(
|
||||
_evaluate_runtime_assertions(
|
||||
action["assert"],
|
||||
build_runtime_bindings(document, contract, context, updated),
|
||||
document,
|
||||
contract,
|
||||
rule_id,
|
||||
action,
|
||||
)
|
||||
)
|
||||
if "sections" in action:
|
||||
diagnostics.extend(_check_dynamic_sections(action["sections"], document, contract, rule_id))
|
||||
return updated, diagnostics
|
||||
|
||||
|
||||
def _set_field_attr(
|
||||
fields_by_id: dict[str, FieldState],
|
||||
diagnostics: list[Diagnostic],
|
||||
field_id: str,
|
||||
updates: dict[str, Any],
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
rule_id: str,
|
||||
) -> tuple[dict[str, FieldState], list[Diagnostic]]:
|
||||
if field_id not in fields_by_id:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.rule.unknown_field",
|
||||
f"Dynamic rule `{rule_id}` references unknown field `{field_id}`.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=rule_id,
|
||||
)
|
||||
)
|
||||
return fields_by_id, diagnostics
|
||||
fields_by_id[field_id] = replace(fields_by_id[field_id], **updates)
|
||||
return fields_by_id, diagnostics
|
||||
|
||||
|
||||
def _evaluate_runtime_assertions(
|
||||
raw_assertions: Any,
|
||||
bindings: dict[str, Any],
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
rule_id: str,
|
||||
rule: dict[str, Any],
|
||||
) -> list[Diagnostic]:
|
||||
assertions = raw_assertions if isinstance(raw_assertions, list) else [raw_assertions]
|
||||
diagnostics: list[Diagnostic] = []
|
||||
for assertion in assertions:
|
||||
result = evaluate_condition(assertion, bindings, rule_id=rule_id)
|
||||
diagnostics.extend(
|
||||
_with_contract_location(item, document=document, contract=contract)
|
||||
for item in result.diagnostics
|
||||
)
|
||||
if not result.matched:
|
||||
severity = _severity_from_mapping(assertion, rule)
|
||||
message = (
|
||||
assertion.get("message")
|
||||
if isinstance(assertion, dict) and assertion.get("message")
|
||||
else rule.get("message")
|
||||
or f"Runtime assertion `{rule_id}` was not satisfied."
|
||||
)
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.rule.assertion_failed",
|
||||
str(message),
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=rule_id,
|
||||
severity=severity,
|
||||
details={"assertion": assertion if isinstance(assertion, dict) else None},
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _check_dynamic_sections(
|
||||
raw_sections: Any,
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
rule_id: str,
|
||||
) -> list[Diagnostic]:
|
||||
if not isinstance(raw_sections, dict):
|
||||
return [
|
||||
_diagnostic(
|
||||
"runtime.rule.sections_invalid",
|
||||
"`sections` action must be a mapping.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=rule_id,
|
||||
)
|
||||
]
|
||||
section_specs = {section.id: section for section in contract.sections if section.id}
|
||||
diagnostics: list[Diagnostic] = []
|
||||
for section_id, raw in raw_sections.items():
|
||||
section_spec = section_specs.get(str(section_id))
|
||||
if section_spec is None:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.rule.unknown_section",
|
||||
f"Dynamic rule `{rule_id}` references unknown section `{section_id}`.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=rule_id,
|
||||
)
|
||||
)
|
||||
continue
|
||||
presence = raw.get("presence") if isinstance(raw, dict) else raw
|
||||
diagnostics.extend(_check_dynamic_section_presence(document, contract, section_spec, str(presence), rule_id))
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _check_dynamic_section_presence(
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
section_spec: SectionSpec,
|
||||
presence: str,
|
||||
rule_id: str,
|
||||
) -> list[Diagnostic]:
|
||||
matches = _matching_section_lines(document, section_spec)
|
||||
if presence == "required" and not matches:
|
||||
return [
|
||||
_diagnostic(
|
||||
"runtime.section.missing",
|
||||
f"Dynamic rule requires section `{section_spec.id}`.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=rule_id,
|
||||
guidance=f"Add a {'#' * (section_spec.level or 2)} {section_spec.title or section_spec.id} section.",
|
||||
)
|
||||
]
|
||||
if presence == "recommended" and not matches:
|
||||
return [
|
||||
_diagnostic(
|
||||
"runtime.section.recommended_missing",
|
||||
f"Dynamic rule recommends section `{section_spec.id}`.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=rule_id,
|
||||
severity="warning",
|
||||
)
|
||||
]
|
||||
if presence == "forbidden" and matches:
|
||||
return [
|
||||
_diagnostic(
|
||||
"runtime.section.forbidden",
|
||||
f"Dynamic rule forbids section `{section_spec.id}`.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=rule_id,
|
||||
source_line=matches[0],
|
||||
)
|
||||
]
|
||||
if presence == "discouraged" and matches:
|
||||
return [
|
||||
_diagnostic(
|
||||
"runtime.section.discouraged",
|
||||
f"Dynamic rule discourages section `{section_spec.id}`.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=rule_id,
|
||||
source_line=matches[0],
|
||||
severity="warning",
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _validate_field_state(
|
||||
field_spec: FieldSpec,
|
||||
field_state: FieldState,
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
) -> list[Diagnostic]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
if field_state.required and field_state.visible and not field_state.exists:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"contract.field.missing",
|
||||
f"Required field `{field_state.id}` is missing.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_state.id,
|
||||
guidance=f"Provide `{field_state.path}` in the document or context.",
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
if not field_state.exists:
|
||||
return diagnostics
|
||||
|
||||
value = field_state.value
|
||||
if field_state.metadata.get("coerce"):
|
||||
value, coerced, coercion_error = _coerce_value(value, field_state.type)
|
||||
if coercion_error:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.field.coercion_failed",
|
||||
f"Field `{field_state.id}` could not be coerced to `{field_state.type}`.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_state.id,
|
||||
)
|
||||
)
|
||||
elif coerced:
|
||||
object.__setattr__(field_state, "value", value)
|
||||
|
||||
if field_state.type and not _value_matches_type(value, field_state.type):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"contract.field.type_mismatch",
|
||||
(
|
||||
f"Field `{field_state.id}` must be `{field_state.type}`, "
|
||||
f"got `{type(value).__name__}`."
|
||||
),
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_state.id,
|
||||
)
|
||||
)
|
||||
allowed_values = field_state.allowed_values
|
||||
if allowed_values is not None and value not in allowed_values:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"contract.field.enum",
|
||||
f"Field `{field_state.id}` must be one of {allowed_values}.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_state.id,
|
||||
)
|
||||
)
|
||||
if field_spec.pattern and isinstance(value, str) and not re.search(field_spec.pattern, value):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"contract.field.pattern",
|
||||
f"Field `{field_state.id}` does not match its required pattern.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_state.id,
|
||||
)
|
||||
)
|
||||
if field_spec.min_length is not None and hasattr(value, "__len__") and len(value) < field_spec.min_length:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"contract.field.min_length",
|
||||
f"Field `{field_state.id}` is shorter than {field_spec.min_length}.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_state.id,
|
||||
)
|
||||
)
|
||||
if field_spec.max_length is not None and hasattr(value, "__len__") and len(value) > field_spec.max_length:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"contract.field.max_length",
|
||||
f"Field `{field_state.id}` is longer than {field_spec.max_length}.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_state.id,
|
||||
)
|
||||
)
|
||||
if field_spec.min is not None and isinstance(value, int | float) and value < field_spec.min:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"contract.field.min",
|
||||
f"Field `{field_state.id}` is below {field_spec.min}.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_state.id,
|
||||
)
|
||||
)
|
||||
if field_spec.max is not None and isinstance(value, int | float) and value > field_spec.max:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"contract.field.max",
|
||||
f"Field `{field_state.id}` is above {field_spec.max}.",
|
||||
document=document,
|
||||
contract=contract,
|
||||
rule_id=field_state.id,
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _coerce_value(value: Any, expected_type: str | None) -> tuple[Any, bool, bool]:
|
||||
if expected_type == "string" and not isinstance(value, str):
|
||||
return str(value), True, False
|
||||
if expected_type == "integer" and isinstance(value, str):
|
||||
try:
|
||||
return int(value), True, False
|
||||
except ValueError:
|
||||
return value, False, True
|
||||
if expected_type == "number" and isinstance(value, str):
|
||||
try:
|
||||
return float(value), True, False
|
||||
except ValueError:
|
||||
return value, False, True
|
||||
if expected_type == "boolean" and isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"true", "yes", "1"}:
|
||||
return True, True, False
|
||||
if normalized in {"false", "no", "0"}:
|
||||
return False, True, False
|
||||
return value, False, True
|
||||
return value, False, False
|
||||
|
||||
|
||||
def _value_matches_type(value: Any, expected_type: str) -> bool:
|
||||
if expected_type == "string":
|
||||
return isinstance(value, str)
|
||||
if expected_type == "number":
|
||||
return isinstance(value, int | float) and not isinstance(value, bool)
|
||||
if expected_type == "integer":
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
if expected_type == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if expected_type == "array":
|
||||
return isinstance(value, list)
|
||||
if expected_type == "object":
|
||||
return isinstance(value, dict)
|
||||
if expected_type == "date":
|
||||
return isinstance(value, str)
|
||||
return True
|
||||
|
||||
|
||||
def _field_id_list(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value if item]
|
||||
if isinstance(value, dict):
|
||||
return [str(key) for key, enabled in value.items() if enabled]
|
||||
return []
|
||||
|
||||
|
||||
def _field_bool_mapping(value: Any) -> dict[str, bool]:
|
||||
if value is None:
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return {str(key): bool(item) for key, item in value.items()}
|
||||
if isinstance(value, str):
|
||||
return {value: True}
|
||||
if isinstance(value, list):
|
||||
return {str(item): True for item in value if item}
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_template_value(value: Any, bindings: dict[str, Any]) -> Any:
|
||||
if isinstance(value, str):
|
||||
full = re.fullmatch(r"\$\{([^}]+)\}", value.strip())
|
||||
if full:
|
||||
resolved, exists = resolve_path(bindings, full.group(1))
|
||||
return resolved if exists else value
|
||||
|
||||
def replace_match(match: re.Match[str]) -> str:
|
||||
resolved, exists = resolve_path(bindings, match.group(1))
|
||||
return str(resolved) if exists else match.group(0)
|
||||
|
||||
return re.sub(r"\$\{([^}]+)\}", replace_match, value)
|
||||
if isinstance(value, list):
|
||||
return [_resolve_template_value(item, bindings) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _resolve_template_value(item, bindings) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _conflict_severity(field_spec: FieldSpec) -> str:
|
||||
raw = field_spec.raw if isinstance(field_spec.raw, dict) else {}
|
||||
severity = raw.get("conflict") or raw.get("conflict_severity") or "warning"
|
||||
return str(severity) if valid_severity(str(severity)) else "warning"
|
||||
|
||||
|
||||
def _severity_from_mapping(*items: Any) -> str:
|
||||
for item in items:
|
||||
if isinstance(item, dict) and valid_severity(str(item.get("severity"))):
|
||||
return str(item["severity"])
|
||||
return "error"
|
||||
|
||||
|
||||
def _matching_section_lines(document: Document, section_spec: SectionSpec) -> list[int]:
|
||||
expected = {_normalize_heading(value) for value in section_spec.headings}
|
||||
return [
|
||||
section.heading.line
|
||||
for section in document.sections
|
||||
if _normalize_heading(section.heading.text) in expected
|
||||
]
|
||||
|
||||
|
||||
def _normalize_heading(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text.strip().lower())
|
||||
|
||||
|
||||
def _with_contract_location(
|
||||
diagnostic: Diagnostic,
|
||||
*,
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
) -> Diagnostic:
|
||||
return Diagnostic(
|
||||
severity=diagnostic.severity,
|
||||
code=diagnostic.code,
|
||||
message=diagnostic.message,
|
||||
source=diagnostic.source or SourceLocation(path=document.source_path),
|
||||
contract=diagnostic.contract or SourceLocation(path=contract.source_path, line=contract.source_line),
|
||||
rule_id=diagnostic.rule_id,
|
||||
guidance=diagnostic.guidance,
|
||||
details=diagnostic.details,
|
||||
)
|
||||
|
||||
|
||||
def _diagnostic(
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
document: Document,
|
||||
contract: DocumentContract,
|
||||
rule_id: str | None = None,
|
||||
severity: str = "error",
|
||||
guidance: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
source_line: int | None = 1,
|
||||
) -> Diagnostic:
|
||||
return Diagnostic(
|
||||
severity=severity,
|
||||
code=code,
|
||||
message=message,
|
||||
source=SourceLocation(path=document.source_path, line=source_line),
|
||||
contract=SourceLocation(path=contract.source_path, line=contract.source_line),
|
||||
rule_id=rule_id,
|
||||
guidance=guidance,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
|
||||
def _drop_empty(data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in data.items()
|
||||
if value not in (None, [], {}, "")
|
||||
}
|
||||
45
src/markitect_tool/runtime/paths.py
Normal file
45
src/markitect_tool/runtime/paths.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Small path helpers for runtime context and rule evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def resolve_path(data: Any, path: str | None) -> tuple[Any, bool]:
|
||||
"""Resolve a dotted path against dictionaries and lists.
|
||||
|
||||
The runtime path vocabulary is intentionally small: `context.user.name`,
|
||||
`frontmatter.status`, `fields.owner.value`, and numeric list indexes are
|
||||
enough for contract checks, form state, and deterministic rules without
|
||||
embedding a general expression language.
|
||||
"""
|
||||
|
||||
if not path:
|
||||
return None, False
|
||||
current = data
|
||||
for part in _path_parts(path):
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
continue
|
||||
if isinstance(current, list) and part.isdigit():
|
||||
index = int(part)
|
||||
if index < len(current):
|
||||
current = current[index]
|
||||
continue
|
||||
return None, False
|
||||
return current, True
|
||||
|
||||
|
||||
def comparable_value(value: Any) -> Any:
|
||||
"""Return a stable scalar-ish value for diagnostics and equality checks."""
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {key: comparable_value(value[key]) for key in sorted(value)}
|
||||
if isinstance(value, list):
|
||||
return [comparable_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _path_parts(path: str) -> list[str]:
|
||||
normalized = str(path).strip().removeprefix("$.")
|
||||
return [part for part in normalized.split(".") if part]
|
||||
162
src/markitect_tool/runtime/rules.py
Normal file
162
src/markitect_tool/runtime/rules.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Deterministic rule conditions for runtime forms and checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from markitect_tool.diagnostics import Diagnostic
|
||||
from markitect_tool.runtime.paths import resolve_path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConditionResult:
|
||||
"""Result of evaluating one deterministic condition."""
|
||||
|
||||
matched: bool
|
||||
diagnostics: list[Diagnostic] = field(default_factory=list)
|
||||
|
||||
|
||||
def evaluate_condition(
|
||||
condition: Any,
|
||||
bindings: dict[str, Any],
|
||||
*,
|
||||
rule_id: str | None = None,
|
||||
) -> ConditionResult:
|
||||
"""Evaluate a small Markitect-native condition mapping."""
|
||||
|
||||
if condition is None:
|
||||
return ConditionResult(True)
|
||||
if isinstance(condition, bool):
|
||||
return ConditionResult(condition)
|
||||
if isinstance(condition, list):
|
||||
diagnostics: list[Diagnostic] = []
|
||||
results = [evaluate_condition(item, bindings, rule_id=rule_id) for item in condition]
|
||||
for result in results:
|
||||
diagnostics.extend(result.diagnostics)
|
||||
return ConditionResult(all(result.matched for result in results), diagnostics)
|
||||
if not isinstance(condition, dict):
|
||||
return ConditionResult(
|
||||
False,
|
||||
[
|
||||
_diagnostic(
|
||||
"runtime.rule.condition_invalid",
|
||||
"Rule condition must be a mapping, list, or boolean.",
|
||||
rule_id=rule_id,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if "all" in condition:
|
||||
return _combine_conditions(condition["all"], bindings, rule_id, all)
|
||||
if "any" in condition:
|
||||
return _combine_conditions(condition["any"], bindings, rule_id, any)
|
||||
if "not" in condition:
|
||||
result = evaluate_condition(condition["not"], bindings, rule_id=rule_id)
|
||||
return ConditionResult(not result.matched, result.diagnostics)
|
||||
|
||||
path = condition.get("path")
|
||||
if not path:
|
||||
return ConditionResult(
|
||||
False,
|
||||
[
|
||||
_diagnostic(
|
||||
"runtime.rule.condition_missing_path",
|
||||
"Rule condition must declare `path`.",
|
||||
rule_id=rule_id,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
value, exists = resolve_path(bindings, str(path))
|
||||
diagnostics: list[Diagnostic] = []
|
||||
matched = True
|
||||
if "exists" in condition:
|
||||
matched = matched and (exists is bool(condition["exists"]))
|
||||
elif not exists:
|
||||
matched = False
|
||||
|
||||
if exists:
|
||||
matched = matched and _operator_matches(value, condition, diagnostics, rule_id)
|
||||
return ConditionResult(matched, diagnostics)
|
||||
|
||||
|
||||
def _combine_conditions(
|
||||
raw_conditions: Any,
|
||||
bindings: dict[str, Any],
|
||||
rule_id: str | None,
|
||||
combiner: Any,
|
||||
) -> ConditionResult:
|
||||
conditions = raw_conditions if isinstance(raw_conditions, list) else [raw_conditions]
|
||||
results = [evaluate_condition(item, bindings, rule_id=rule_id) for item in conditions]
|
||||
diagnostics: list[Diagnostic] = []
|
||||
for result in results:
|
||||
diagnostics.extend(result.diagnostics)
|
||||
return ConditionResult(combiner(result.matched for result in results), diagnostics)
|
||||
|
||||
|
||||
def _operator_matches(
|
||||
value: Any,
|
||||
condition: dict[str, Any],
|
||||
diagnostics: list[Diagnostic],
|
||||
rule_id: str | None,
|
||||
) -> bool:
|
||||
matched = True
|
||||
if "equals" in condition:
|
||||
matched = matched and value == condition["equals"]
|
||||
if "eq" in condition:
|
||||
matched = matched and value == condition["eq"]
|
||||
if "not_equals" in condition:
|
||||
matched = matched and value != condition["not_equals"]
|
||||
if "in" in condition:
|
||||
expected = condition["in"]
|
||||
matched = matched and isinstance(expected, list) and value in expected
|
||||
if "contains" in condition:
|
||||
expected = condition["contains"]
|
||||
matched = matched and _contains(value, expected)
|
||||
if "matches" in condition:
|
||||
pattern = str(condition["matches"])
|
||||
try:
|
||||
matched = matched and re.search(pattern, str(value)) is not None
|
||||
except re.error as exc:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"runtime.rule.regex_invalid",
|
||||
f"Invalid rule regular expression `{pattern}`: {exc}",
|
||||
rule_id=rule_id,
|
||||
)
|
||||
)
|
||||
matched = False
|
||||
for key, predicate in {
|
||||
"gt": lambda actual, expected: actual > expected,
|
||||
"gte": lambda actual, expected: actual >= expected,
|
||||
"lt": lambda actual, expected: actual < expected,
|
||||
"lte": lambda actual, expected: actual <= expected,
|
||||
}.items():
|
||||
if key not in condition:
|
||||
continue
|
||||
try:
|
||||
matched = matched and predicate(value, condition[key])
|
||||
except TypeError:
|
||||
matched = False
|
||||
return matched
|
||||
|
||||
|
||||
def _contains(value: Any, expected: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return str(expected) in value
|
||||
if isinstance(value, list | tuple | set):
|
||||
return expected in value
|
||||
if isinstance(value, dict):
|
||||
return expected in value
|
||||
return False
|
||||
|
||||
|
||||
def _diagnostic(code: str, message: str, *, rule_id: str | None = None) -> Diagnostic:
|
||||
return Diagnostic(
|
||||
severity="error",
|
||||
code=code,
|
||||
message=message,
|
||||
rule_id=rule_id,
|
||||
)
|
||||
Reference in New Issue
Block a user