feat(phase-3): STRICT_V1's type enum and frontmatter schema, with the reader
Phase 3 step 3, frontmatter half. FrontmatterSchema grows the four judging fields the proving consumer's contract needs — required, allowed, nullable, key_pattern — and `violations` is what reads them, so no field lands as an untested claim. STRICT_V1 requires the eight keys their operator ratified on 2026-07-25 inside the eleven-key allowlist that doubles as emission order, both derived from one tuple so they cannot drift; `summary` is the single nullable key; a value is a string scalar or a NON-EMPTY LIST OF STRINGS, because their Release pages carry block lists and a scalars-only schema would reject pages they ship today. Measured, not assumed: DEFAULT must NOT grow a required set. Door A emits seven keys, Door B six, and Door C writes an imported concept verbatim with whatever the sender wrote, so any required set on DEFAULT would declare invalid the bundles this library itself produces. The plan's other cross-profile direction — a wiki bundle rejected under DEFAULT — therefore has to come from index and path shape, not from frontmatter. Recorded here because the plan implied otherwise. Violations are reported, not raised, like TypeRejection, and sorted by (key, code): a report that depended on mapping order would not be reproducible. An unsatisfiable schema — a required or nullable key outside its own allowlist — fails at construction. Two boundaries kept explicit. `violations` judges an already-parsed mapping; parsing strict frontmatter needs a parser this library does not have, which is the same reason Door C writes verbatim. And STRICT_V1's index policy is still DEFAULT's, pinned by a test as a known gap: per-directory indexes and an entry description have no field on IndexPolicy yet, and nothing consumes a profile's index policy today. C1 re-proven: 443 existing tests unmodified and green (468 total), and `git diff --stat examples/` empty.
This commit is contained in:
parent
cf1f7984c4
commit
848e3953fc
2 changed files with 480 additions and 5 deletions
|
|
@ -79,9 +79,38 @@ class TypePolicy:
|
|||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchemaViolation:
|
||||
"""One way a document's frontmatter departs from the schema.
|
||||
|
||||
Reported rather than raised, like `TypeRejection`: a document can depart in
|
||||
several ways at once, and the caller decides whether that is a refusal, a
|
||||
report line, or a migration to-do. `code` is the stable machine-readable
|
||||
part; `reason` completes the sentence "<key> ...".
|
||||
"""
|
||||
|
||||
key: str
|
||||
reason: str
|
||||
code: str
|
||||
|
||||
|
||||
def _is_legal_value(value: object) -> bool:
|
||||
"""A string scalar, or a non-empty list of strings — nothing else.
|
||||
|
||||
The list shape is real, not hypothetical: the proving consumer's `Release`
|
||||
pages carry block lists. A schema that assumed scalars-only would reject
|
||||
pages they ship today.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return True
|
||||
if isinstance(value, list):
|
||||
return bool(value) and all(isinstance(item, str) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrontmatterSchema:
|
||||
"""The frontmatter key namespace and how it is emitted.
|
||||
"""The frontmatter key namespace, what it admits, and how it is emitted.
|
||||
|
||||
`order` is the canonical emission order and `collapsed_keys` names the keys
|
||||
whose whitespace runs collapse to single spaces on the way out. The DEFAULT
|
||||
|
|
@ -89,14 +118,81 @@ class FrontmatterSchema:
|
|||
B six, and each door's subset comes out in exactly the order it wrote by
|
||||
hand in Phases 1 and 2.
|
||||
|
||||
The required/allowlisted key sets the proving consumer needs (see
|
||||
`docs/phase-3-split-table.md`) are not here yet: no code reads them until
|
||||
the STRICT_V1 validator exists, and a field nothing reads is a claim
|
||||
nothing tests.
|
||||
The judging fields — `required`, `allowed`, `nullable`, `key_pattern` — are
|
||||
read by `violations`. `allowed` is `None` for an open namespace, which is
|
||||
what DEFAULT keeps: Door A emits seven keys, Door B six, and Door C writes
|
||||
an imported concept verbatim with whatever the sender wrote, so a closed
|
||||
namespace or a required set on DEFAULT would declare invalid the very
|
||||
bundles this library produces.
|
||||
"""
|
||||
|
||||
order: tuple[str, ...]
|
||||
collapsed_keys: frozenset[str] = field(default_factory=frozenset)
|
||||
required: frozenset[str] = field(default_factory=frozenset)
|
||||
allowed: frozenset[str] | None = None
|
||||
nullable: frozenset[str] = field(default_factory=frozenset)
|
||||
key_pattern: re.Pattern[str] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.allowed is None:
|
||||
return
|
||||
for label, keys in (("required", self.required), ("nullable", self.nullable)):
|
||||
stray = sorted(keys - self.allowed)
|
||||
if stray:
|
||||
raise ValueError(
|
||||
f"{label} keys must be inside the allowlist "
|
||||
f"(got {', '.join(repr(key) for key in stray)}) — a schema that "
|
||||
"demands a key it also forbids can never be satisfied"
|
||||
)
|
||||
|
||||
def violations(self, values: Mapping[str, object]) -> tuple[SchemaViolation, ...]:
|
||||
"""Every way `values` departs from this schema, deterministically ordered.
|
||||
|
||||
Sorted by `(key, code)` rather than reported in mapping order: two runs
|
||||
over the same document must produce the same report, and mapping order
|
||||
is an accident of how the document was parsed.
|
||||
|
||||
Takes an already-parsed mapping. Parsing strict frontmatter is a
|
||||
separate concern — this library's line-oriented parser cannot represent
|
||||
the block lists and nulls a strict schema admits, which is why Door C
|
||||
writes imported concepts verbatim rather than round-tripping them.
|
||||
"""
|
||||
found: list[SchemaViolation] = []
|
||||
|
||||
for key in self.required - set(values):
|
||||
found.append(SchemaViolation(key, "is required and absent", "frontmatter_key_missing"))
|
||||
|
||||
for key, value in values.items():
|
||||
if self.key_pattern is not None and not self.key_pattern.fullmatch(key):
|
||||
found.append(
|
||||
SchemaViolation(
|
||||
key,
|
||||
f"is not a legal key name (must match {self.key_pattern.pattern})",
|
||||
"frontmatter_key_malformed",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if self.allowed is not None and key not in self.allowed:
|
||||
found.append(
|
||||
SchemaViolation(
|
||||
key, "is not on the key allowlist", "frontmatter_key_not_allowed"
|
||||
)
|
||||
)
|
||||
continue
|
||||
if value is None:
|
||||
if key not in self.nullable:
|
||||
found.append(SchemaViolation(key, "must not be null", "frontmatter_value_null"))
|
||||
continue
|
||||
if not _is_legal_value(value):
|
||||
found.append(
|
||||
SchemaViolation(
|
||||
key,
|
||||
"must be a string or a non-empty list of strings",
|
||||
"frontmatter_value_shape",
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(sorted(found, key=lambda violation: (violation.key, violation.code)))
|
||||
|
||||
def emit(self, values: Mapping[str, str]) -> str:
|
||||
"""Render `values` as line-oriented `key: value`, one line per key.
|
||||
|
|
@ -201,3 +297,52 @@ DEFAULT = BundleProfile(
|
|||
link_pattern=re.compile(r"^- \[(?P<label>[^\]]*)\]\((?P<target>[^)]+)\)$"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# `FRONTMATTER_KEY_ORDER` in the proving consumer's `bundle.py`: an eleven-key
|
||||
# allowlist that doubles as canonical emission order. The first eight are the
|
||||
# required set their operator ratified on 2026-07-25 (measured present on
|
||||
# 522/522 documents); the last three are layer-specific and stay optional.
|
||||
# Deriving both sets from this one tuple is what keeps them from drifting apart
|
||||
# — their BUNDLE_HASH_REGISTRY gate depends on the order.
|
||||
_STRICT_V1_KEY_ORDER = (
|
||||
"type",
|
||||
"title",
|
||||
"description",
|
||||
"timestamp",
|
||||
"layer",
|
||||
"source_tier",
|
||||
"source_url",
|
||||
"source_sha",
|
||||
"version",
|
||||
"date",
|
||||
"summary",
|
||||
)
|
||||
|
||||
# The `claude-code-llm-wiki` contract, from `docs/phase-3-split-table.md`.
|
||||
#
|
||||
# Deliberately stricter than the consumer's own validator: their REQUIRED_KEYS
|
||||
# constant is the first four keys, which is their emit-path minimum rather than
|
||||
# their contract. If they ever ship a document class that legitimately lacks
|
||||
# `source_sha`, the agreed resolution is to bump this profile — they send the
|
||||
# requirement before shipping the class, not after.
|
||||
#
|
||||
# `paths` and `index` are DEFAULT's, and only `paths` is the whole truth: the
|
||||
# filename namespaces are an ingest-spec invariant about what the doors write,
|
||||
# not a consumer preference. The consumer's index shape — one index per
|
||||
# directory, `* [Title](link) - description` entries, a root index carrying
|
||||
# okf_version/bundle_profile/okf_spec_commit — is NOT expressible by IndexPolicy
|
||||
# yet and lands with the reader that judges it. Nothing consumes a profile's
|
||||
# index policy today, so the gap is pinned by a test rather than left to drift.
|
||||
STRICT_V1 = BundleProfile(
|
||||
types=TypePolicy(allowed=frozenset({"Concept", "Guide", "Reference", "Release"})),
|
||||
frontmatter=FrontmatterSchema(
|
||||
order=_STRICT_V1_KEY_ORDER,
|
||||
required=frozenset(_STRICT_V1_KEY_ORDER[:8]),
|
||||
allowed=frozenset(_STRICT_V1_KEY_ORDER),
|
||||
nullable=frozenset({"summary"}),
|
||||
key_pattern=re.compile(r"^[a-z_]+$"),
|
||||
),
|
||||
paths=DEFAULT.paths,
|
||||
index=DEFAULT.index,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue