feat(phase-3): the index policy becomes configurable, with the reader that judges it
`IndexPolicy` grew five judging fields and `IndexPolicy.violations`, closing the
gap a test has been pinning since `848e395`. `STRICT_V1.index` is now the proving
consumer's shape rather than DEFAULT's.
The design was settled by a conflict rather than by a preference. The convention
owner reported that an index is an authored count of a directory's children and
that a validator enumerating the directory has implemented the wrong contract.
Checked against the proving consumer before adopting it, the two turn out to be
directly opposed: gate BUNDLE_INDEX_COMPLETE (validate.py:1081-1120, ERROR) builds
its expected set by enumerating the directory and demands an exact bidirectional
match, and bundle.py:498-567 writes every index from a tree walk. Neither is
incoherent inside its own spec, so authored-versus-derived is a policy field in
both directions and a library invariant in neither.
Consequences encoded rather than documented: nothing here enumerates a directory
— the caller supplies the listing, `violations` refuses one when the profile's
index is authored and refuses to run without one when it is derived, so code
written to the wrong contract fails at the call instead of passing every test one
would think to write. Root and nested indexes are asymmetric (confirmed in both
consumers, different key sets). A per-entry description is template-level, so
`render_link` refuses both a missing description and an unwanted one.
DEFAULT keeps upstream's root-only index and judges nothing, for the same measured
reason it carries no required frontmatter key set: upstream binds `index.md` to
the bundle root alone, so a judging default would condemn conforming bundles.
25 new tests, 493 green. C1 re-proven: `git diff --stat examples/` empty.
This commit is contained in:
parent
848e3953fc
commit
9436427520
6 changed files with 626 additions and 29 deletions
|
|
@ -49,8 +49,11 @@ are the library baseline. First consumer: `portfolio-optimiser-claude`.
|
|||
2. **Phase 2 — Doors B/C (Python).** Bundle inbox and external-bundle
|
||||
import, guard-gated.
|
||||
3. **Phase 3 — Configurable bundle contract.** Types, layers, frontmatter
|
||||
sets, and reserved-file policy become config instead of constants;
|
||||
proving consumer is `claude-code-llm-wiki` (`strict-v1` profile).
|
||||
sets, index shape, and reserved-file policy become config instead of
|
||||
constants; proving consumer is `claude-code-llm-wiki` (`strict-v1`
|
||||
profile). Two consumers hold opposite postures on whether an index is
|
||||
authored or directory-derived, so neither is a library invariant and
|
||||
nothing here enumerates a directory unless the profile says derived.
|
||||
4. **Phase 4 — Node half (`node/`).** Zero-dependency Node/ESM package
|
||||
(importable *and* CLI-invokable, vendorable per plugin — matching the
|
||||
marketplace precedent) for the second-brain world: bundle check, index
|
||||
|
|
|
|||
|
|
@ -93,9 +93,9 @@ verification criteria:
|
|||
[plan](docs/plan/phase-1-door-a.md).
|
||||
2. Bundle inbox and external-bundle import (Python), guard-gated —
|
||||
[plan](docs/plan/phase-2-doors-b-c.md).
|
||||
3. Configurable bundle contract (types, layers, frontmatter sets, and
|
||||
reserved-file policy as configuration), enabling stricter bundle
|
||||
profiles such as `strict-v1` —
|
||||
3. Configurable bundle contract (types, layers, frontmatter sets, index
|
||||
shape, and reserved-file policy as configuration), enabling stricter
|
||||
bundle profiles such as `strict-v1` —
|
||||
[plan](docs/plan/phase-3-configurable-contract.md).
|
||||
4. A `node/` half: a zero-dependency Node/ESM package (importable and
|
||||
CLI-invokable, vendored per consumer) providing bundle checking, index
|
||||
|
|
|
|||
|
|
@ -251,6 +251,55 @@ them:
|
|||
directions, and a root index carrying `okf_version` / `bundle_profile` /
|
||||
`okf_spec_commit` frontmatter in that key order.
|
||||
|
||||
## The index policy, and the conflict that shaped it
|
||||
|
||||
The convention owner reported on 2026-07-25 that the second-brain spec's §3
|
||||
mandates an `index.md` at every directory level, that `IndexPolicy` as it then
|
||||
stood could not express that, and — the load-bearing part — that an index is an
|
||||
**authored** count of a directory's children and never a filesystem lookup. On
|
||||
their reading, an index reader or validator that enumerates a directory to
|
||||
build or check an index has implemented the wrong contract, with a silent
|
||||
failure mode: code written to the wrong reading passes every test one would
|
||||
think to write.
|
||||
|
||||
That was checked against the proving consumer before it was adopted, and the
|
||||
two consumers turn out to be **directly opposed on exactly this point**:
|
||||
|
||||
- `validate.py:1081-1120`, gate `BUNDLE_INDEX_COMPLETE` (severity ERROR),
|
||||
builds its expected set by enumerating the directory and fails unless the
|
||||
index matches it exactly in both directions.
|
||||
- `bundle.py:498-567` writes every index from what a tree walk finds, and its
|
||||
own docstring calls `index.md` machine-generated.
|
||||
|
||||
So one consumer mandates the derivation the other calls the wrong contract.
|
||||
Neither is incoherent inside its own spec, and this repository is not the venue
|
||||
to adjudicate between them. What it settles is narrower and sufficient:
|
||||
**authored-versus-derived cannot be a library invariant in either direction.**
|
||||
It is a policy field (`entries_match_directory`), as are per-directory scope,
|
||||
the heading requirement, whether prose is admitted, and the root-index key set.
|
||||
|
||||
Three consequences that outlive this phase:
|
||||
|
||||
1. **Nothing in this library enumerates a directory.** The caller supplies the
|
||||
listing; `IndexPolicy.violations` refuses one when the profile's index is
|
||||
authored, and refuses to run without one when it is derived. The
|
||||
convention owner's construction rule is enforced at the call site rather
|
||||
than documented and hoped for.
|
||||
2. **Root and nested indexes are asymmetric**, confirmed independently in both
|
||||
consumers: the root carries frontmatter (`okf_version`/`bundle_profile`/
|
||||
`okf_spec_commit` for the wiki, `okf_version`/`okf_layout` for the catalog)
|
||||
and nested indexes carry none. Different key sets, same shape — which is
|
||||
why it is a profile field and not a constant.
|
||||
3. **A per-entry description is template-level, not a separate requirement.**
|
||||
The wiki requires `* [Title](link) - description`; the catalog requires
|
||||
prose in the index and no per-entry description. A profile that wants none
|
||||
simply does not name `{description}` in its template.
|
||||
|
||||
`DEFAULT` keeps upstream's root-only index and judges nothing, for the same
|
||||
measured reason it carries no required frontmatter key set: upstream OKF binds
|
||||
`index.md` to the bundle root alone, so a default demanding one per level would
|
||||
declare upstream-conforming bundles invalid.
|
||||
|
||||
## What this table does not do
|
||||
|
||||
It does not port anything. Per the plan, porting starts only after this
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ are extension points, not v1 (settled with the operator at phase start).
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Collection, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# The one layer no profile may admit (ingest-spec §3): the promotion gate is
|
||||
|
|
@ -234,9 +234,43 @@ class PathPolicy:
|
|||
import_prefix: str
|
||||
|
||||
|
||||
def _split_frontmatter(text: str) -> tuple[dict[str, str], list[str]]:
|
||||
"""The leading `---` block as ordered keys, and the body lines after it.
|
||||
|
||||
Line-oriented, the same shape `materialize.parse_frontmatter` reads —
|
||||
duplicated rather than imported because `materialize` imports this module,
|
||||
and because that one takes a path where an index reader has only text.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {}, lines
|
||||
head: dict[str, str] = {}
|
||||
for offset, line in enumerate(lines[1:], start=1):
|
||||
if line.strip() == "---":
|
||||
return head, lines[offset + 1 :]
|
||||
key, sep, value = line.partition(":")
|
||||
if sep:
|
||||
head[key.strip()] = value.strip()
|
||||
return head, []
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IndexViolation:
|
||||
"""One way an index file departs from the policy.
|
||||
|
||||
Reported rather than raised, like `SchemaViolation`. `subject` is whatever
|
||||
the finding is about — a line, a link target, or a frontmatter key — and
|
||||
`code` is the stable machine-readable part.
|
||||
"""
|
||||
|
||||
subject: str
|
||||
reason: str
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IndexPolicy:
|
||||
"""The index file and the shape of the links this library manages in it.
|
||||
"""The index file, the shape of its entries, and where indexes must exist.
|
||||
|
||||
`link_template` renders a managed line and `link_pattern` recognises one.
|
||||
Both are carried because §6 does both — append on write, rewrite on
|
||||
|
|
@ -244,14 +278,191 @@ class IndexPolicy:
|
|||
pattern is anchored to the whole line by construction: removal keys on this
|
||||
exact shape, never a bare substring, so curated prose that mentions a
|
||||
target inline survives verbatim.
|
||||
|
||||
The judging fields are read by `violations` and `required_indexes`, and
|
||||
every one of them is off under DEFAULT. They exist because two consumers
|
||||
disagree about this file in ways no single shape can hold:
|
||||
|
||||
- `per_directory` — an index at every level, or only at the bundle root.
|
||||
Upstream OKF binds `index.md` to the root ALONE, so a default demanding
|
||||
one per level would declare upstream-conforming bundles invalid. Policy,
|
||||
never an OKF rule.
|
||||
- `heading_required` / `allows_prose` — the wiki's index is a generated
|
||||
heading plus entries and nothing else; the catalog's *requires*
|
||||
progressive-disclosure prose. Opposite requirements, both expressible.
|
||||
- `entries_match_directory` — whether the index must match the directory
|
||||
exactly, in both directions. The wiki enforces this at ERROR
|
||||
(`BUNDLE_INDEX_COMPLETE`); the catalog holds that an index is an
|
||||
AUTHORED count of a directory's children and that a validator
|
||||
enumerating the directory has implemented the wrong contract. Neither
|
||||
posture is baked in, and this library never enumerates anything: the
|
||||
caller supplies the listing, and a policy that does not judge it refuses
|
||||
to be handed one at all — so code written to the wrong contract fails at
|
||||
the call rather than passing every test one would think to write.
|
||||
- `root_frontmatter` — the ordered keys the ROOT index carries, where
|
||||
nested indexes carry none. Confirmed independently in both consumers, so
|
||||
the asymmetry is shape rather than one repo's preference.
|
||||
"""
|
||||
|
||||
name: str
|
||||
link_template: str
|
||||
link_pattern: re.Pattern[str]
|
||||
per_directory: bool = False
|
||||
heading_required: bool = False
|
||||
allows_prose: bool = True
|
||||
entries_match_directory: bool = False
|
||||
root_frontmatter: tuple[str, ...] = ()
|
||||
|
||||
def render_link(self, label: str, target: str) -> str:
|
||||
return self.link_template.format(label=label, target=target)
|
||||
def __post_init__(self) -> None:
|
||||
if self.requires_description and "description" not in self.link_pattern.groupindex:
|
||||
raise ValueError(
|
||||
"link_template names {description} but link_pattern has no "
|
||||
"'description' group — the pair would round-trip in one "
|
||||
"direction only"
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_description(self) -> bool:
|
||||
"""Whether an entry carries a description alongside label and target."""
|
||||
return "{description}" in self.link_template
|
||||
|
||||
def render_link(self, label: str, target: str, description: str | None = None) -> str:
|
||||
if self.requires_description and description is None:
|
||||
raise ValueError(
|
||||
"this index policy's entries carry a description; rendering "
|
||||
"without one emits a half-written entry that parses as nothing"
|
||||
)
|
||||
if not self.requires_description and description is not None:
|
||||
raise ValueError(
|
||||
"this index policy's entries carry no description; the value "
|
||||
"offered would be dropped silently"
|
||||
)
|
||||
return self.link_template.format(label=label, target=target, description=description)
|
||||
|
||||
def required_indexes(self, directories: Sequence[str]) -> tuple[str, ...]:
|
||||
"""The index paths this policy requires, given the caller's directories.
|
||||
|
||||
Bundle-relative, with `""` for the root. The directories are an
|
||||
argument precisely so that nothing here reaches the filesystem.
|
||||
"""
|
||||
if not self.per_directory:
|
||||
return (self.name,)
|
||||
return tuple(sorted(f"{d}/{self.name}" if d else self.name for d in directories))
|
||||
|
||||
def violations(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
is_root: bool = False,
|
||||
expected_targets: Collection[str] | None = None,
|
||||
) -> tuple[IndexViolation, ...]:
|
||||
"""Every way `text` departs from this policy, deterministically ordered.
|
||||
|
||||
`expected_targets` is the directory's contents as the CALLER sees them,
|
||||
required exactly when `entries_match_directory` is set and refused
|
||||
otherwise. Sorted by `(subject, code)` so two runs over the same file
|
||||
agree.
|
||||
"""
|
||||
if self.entries_match_directory and expected_targets is None:
|
||||
raise ValueError(
|
||||
"this index policy judges the index against the directory, so "
|
||||
"a listing is required — skipping the check silently would "
|
||||
"pass a gate whose input never arrived"
|
||||
)
|
||||
if not self.entries_match_directory and expected_targets is not None:
|
||||
raise ValueError(
|
||||
"this index policy's index is authored rather than derived "
|
||||
"from the directory, so a listing has nothing to judge"
|
||||
)
|
||||
|
||||
found: list[IndexViolation] = []
|
||||
head, body = _split_frontmatter(text)
|
||||
found.extend(self._frontmatter_violations(head, is_root=is_root))
|
||||
|
||||
headings: list[str] = []
|
||||
listed: set[str] = set()
|
||||
for line in body:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if line.startswith("# "):
|
||||
headings.append(line)
|
||||
continue
|
||||
match = self.link_pattern.match(line)
|
||||
if match is not None:
|
||||
listed.add(match.group("target"))
|
||||
continue
|
||||
if not self.allows_prose:
|
||||
found.append(
|
||||
IndexViolation(
|
||||
stripped,
|
||||
"is neither the heading nor an entry",
|
||||
"index_prose_not_allowed",
|
||||
)
|
||||
)
|
||||
|
||||
if self.heading_required:
|
||||
if not headings:
|
||||
found.append(
|
||||
IndexViolation("", "the index carries no `# ` heading", "index_heading_missing")
|
||||
)
|
||||
found.extend(
|
||||
IndexViolation(extra, "is a second `# ` heading", "index_heading_extra")
|
||||
for extra in headings[1:]
|
||||
)
|
||||
|
||||
if expected_targets is not None:
|
||||
expected = set(expected_targets)
|
||||
found.extend(
|
||||
IndexViolation(
|
||||
target, "is in the directory but not in the index", "index_entry_missing"
|
||||
)
|
||||
for target in expected - listed
|
||||
)
|
||||
found.extend(
|
||||
IndexViolation(
|
||||
target, "is in the index but not in the directory", "index_entry_unexpected"
|
||||
)
|
||||
for target in listed - expected
|
||||
)
|
||||
|
||||
return tuple(sorted(found, key=lambda violation: (violation.subject, violation.code)))
|
||||
|
||||
def _frontmatter_violations(
|
||||
self, head: Mapping[str, str], *, is_root: bool
|
||||
) -> list[IndexViolation]:
|
||||
if not self.root_frontmatter:
|
||||
return []
|
||||
if not is_root:
|
||||
# One finding about the block, not one per key: a nested index
|
||||
# carrying frontmatter is a single structural fact.
|
||||
first = next(iter(head), None)
|
||||
if first is None:
|
||||
return []
|
||||
return [
|
||||
IndexViolation(
|
||||
first,
|
||||
"is frontmatter on a nested index, which carries none",
|
||||
"index_root_frontmatter_unexpected",
|
||||
)
|
||||
]
|
||||
|
||||
found = [
|
||||
IndexViolation(key, "is pinned on the root index and absent", "index_root_key_missing")
|
||||
for key in self.root_frontmatter
|
||||
if key not in head
|
||||
]
|
||||
present = [key for key in head if key in self.root_frontmatter]
|
||||
declared = [key for key in self.root_frontmatter if key in head]
|
||||
if present != declared:
|
||||
found.append(
|
||||
IndexViolation(
|
||||
present[0],
|
||||
f"breaks the pinned key order {', '.join(self.root_frontmatter)}",
|
||||
"index_root_key_order",
|
||||
)
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -327,13 +538,16 @@ _STRICT_V1_KEY_ORDER = (
|
|||
# `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.
|
||||
# `paths` is DEFAULT's and that is the whole truth for it: the filename
|
||||
# namespaces are an ingest-spec invariant about what the doors write, not a
|
||||
# consumer preference.
|
||||
#
|
||||
# The index shape is read out of their code rather than recalled:
|
||||
# `bundle.py:498-524` renders `* [Title](target) - description` with
|
||||
# subdirectories linking to their own index; `bundle.py:527-567` walks every
|
||||
# level, writing frontmatter at the bundle root only and a bare `# ` heading
|
||||
# below it; `validate.py:1081-1120` (gate BUNDLE_INDEX_COMPLETE, ERROR) demands
|
||||
# the index and the directory match exactly in both directions.
|
||||
STRICT_V1 = BundleProfile(
|
||||
types=TypePolicy(allowed=frozenset({"Concept", "Guide", "Reference", "Release"})),
|
||||
frontmatter=FrontmatterSchema(
|
||||
|
|
@ -344,5 +558,16 @@ STRICT_V1 = BundleProfile(
|
|||
key_pattern=re.compile(r"^[a-z_]+$"),
|
||||
),
|
||||
paths=DEFAULT.paths,
|
||||
index=DEFAULT.index,
|
||||
index=IndexPolicy(
|
||||
name="index.md",
|
||||
link_template="* [{label}]({target}) - {description}",
|
||||
link_pattern=re.compile(
|
||||
r"^\* \[(?P<label>[^\]]*)\]\((?P<target>[^)\s]+)\) - (?P<description>.+)$"
|
||||
),
|
||||
per_directory=True,
|
||||
heading_required=True,
|
||||
allows_prose=False,
|
||||
entries_match_directory=True,
|
||||
root_frontmatter=("okf_version", "bundle_profile", "okf_spec_commit"),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
279
tests/test_index_policy.py
Normal file
279
tests/test_index_policy.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"""The index policy as configuration, and the reader that judges it (Phase 3).
|
||||
|
||||
Three index shapes have to be expressible without either becoming the default:
|
||||
|
||||
- **DEFAULT** — the ingest-spec shape. One index at the bundle root, entries
|
||||
`- [label](target)`, curated prose alongside them, and no relationship
|
||||
between the index and the directory it sits in.
|
||||
- **STRICT_V1** — `claude-code-llm-wiki`, read out of their code rather than
|
||||
recalled: an index at every level (`bundle.py:527-567`), exactly one `# `
|
||||
heading, entries `* [Title](link) - description` and nothing else, the root
|
||||
index carrying `okf_version`/`bundle_profile`/`okf_spec_commit` while nested
|
||||
indexes carry no frontmatter at all, and gate `BUNDLE_INDEX_COMPLETE`
|
||||
(`validate.py:1081-1120`, severity ERROR) demanding the index and the
|
||||
directory match exactly in both directions.
|
||||
- **The second-brain shape** — not built here, but it must not be excluded.
|
||||
The catalog reported on 2026-07-25 that their index is an *authored* count
|
||||
of a directory's children and never a filesystem lookup, that a reader or
|
||||
validator enumerating the directory has implemented the wrong contract, and
|
||||
that they require prose in the index but no per-entry description.
|
||||
|
||||
The two consumers are directly opposed on exactly one point: the wiki mandates
|
||||
the directory-derived match the catalog calls the wrong contract. That is what
|
||||
settles the design. Every one of these differences is a policy field, none is
|
||||
a library invariant, and `violations` never touches the filesystem — a caller
|
||||
supplies the listing, and a profile whose index is authored refuses to be
|
||||
handed one at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_okf.profiles import DEFAULT, STRICT_V1, IndexPolicy
|
||||
|
||||
# --- DEFAULT keeps the Phase 1/2 shape (assumption C1) --------------------
|
||||
|
||||
|
||||
def test_default_index_policy_judges_nothing() -> None:
|
||||
"""Every new field is off under DEFAULT, so the reader has nothing to say.
|
||||
|
||||
Door A emits an index with a curated summary above managed links, Door B
|
||||
and Door C append to one that may be empty. A judging default would
|
||||
declare invalid the bundles this library itself produces — the same
|
||||
reasoning that kept a required key set off DEFAULT's frontmatter schema.
|
||||
"""
|
||||
text = "# Sales bundle\n\nCurated prose the operator wrote.\n\n- [Sales](ingest-sales.md)\n"
|
||||
assert DEFAULT.index.violations(text) == ()
|
||||
assert DEFAULT.index.violations(text, is_root=True) == ()
|
||||
|
||||
|
||||
def test_default_index_is_root_only_because_upstream_binds_it_there() -> None:
|
||||
"""Upstream OKF binds `index.md` to the bundle root alone.
|
||||
|
||||
A nested directory without its own index is not an error there, which is
|
||||
why per-directory indexing is policy rather than an OKF rule: a DEFAULT
|
||||
demanding one per level would declare upstream-conforming bundles invalid.
|
||||
"""
|
||||
assert DEFAULT.index.per_directory is False
|
||||
assert DEFAULT.index.required_indexes(["", "concepts", "concepts/hooks"]) == ("index.md",)
|
||||
|
||||
|
||||
def test_default_index_link_is_unchanged_and_needs_no_description() -> None:
|
||||
assert DEFAULT.index.requires_description is False
|
||||
assert DEFAULT.index.render_link("Sales", "ingest-sales.md") == "- [Sales](ingest-sales.md)"
|
||||
|
||||
|
||||
# --- the description on an entry ------------------------------------------
|
||||
|
||||
|
||||
def test_render_link_carries_a_description_when_the_template_names_one() -> None:
|
||||
assert STRICT_V1.index.requires_description is True
|
||||
line = STRICT_V1.index.render_link("Hooks", "hooks.md", "How hooks fire.")
|
||||
assert line == "* [Hooks](hooks.md) - How hooks fire."
|
||||
|
||||
|
||||
def test_render_link_refuses_to_emit_a_half_written_entry() -> None:
|
||||
"""A template naming `{description}` and a caller omitting it must not
|
||||
silently render `* [T](t) - `, which parses as nothing and reads as an
|
||||
entry."""
|
||||
with pytest.raises(ValueError, match="description"):
|
||||
STRICT_V1.index.render_link("Hooks", "hooks.md")
|
||||
|
||||
|
||||
def test_a_description_offered_to_a_template_without_one_is_refused() -> None:
|
||||
"""The mirror case: silently dropping it would lose operator bytes."""
|
||||
with pytest.raises(ValueError, match="description"):
|
||||
DEFAULT.index.render_link("Sales", "ingest-sales.md", "dropped on the floor")
|
||||
|
||||
|
||||
def test_strict_v1_link_template_and_pattern_describe_the_same_shape() -> None:
|
||||
"""Rendering and recognising are separate operations; this is the pair's
|
||||
round-trip, the same test DEFAULT's shape already carries."""
|
||||
for label, target, description in (
|
||||
("Hooks", "hooks.md", "How hooks fire."),
|
||||
("Concepts", "concepts/index.md", "Machine-generated index of concepts."),
|
||||
("", "a.md", "-"),
|
||||
):
|
||||
line = STRICT_V1.index.render_link(label, target, description)
|
||||
match = STRICT_V1.index.link_pattern.match(line)
|
||||
assert match is not None
|
||||
assert match.group("label") == label
|
||||
assert match.group("target") == target
|
||||
assert match.group("description") == description
|
||||
|
||||
|
||||
def test_strict_v1_link_pattern_is_anchored_to_the_whole_line() -> None:
|
||||
assert STRICT_V1.index.link_pattern.match("see * [Hooks](hooks.md) - x below") is None
|
||||
|
||||
|
||||
# --- per-directory indexes ------------------------------------------------
|
||||
|
||||
|
||||
def test_per_directory_policy_names_one_index_per_level() -> None:
|
||||
"""The caller supplies the directories; the policy never enumerates them.
|
||||
|
||||
This is the catalog's construction rule honoured at the type level: the
|
||||
library cannot list a directory because it is never given one to list.
|
||||
"""
|
||||
assert STRICT_V1.index.per_directory is True
|
||||
assert STRICT_V1.index.required_indexes(["", "concepts", "concepts/hooks"]) == (
|
||||
"concepts/hooks/index.md",
|
||||
"concepts/index.md",
|
||||
"index.md",
|
||||
)
|
||||
|
||||
|
||||
# --- the reader: headings -------------------------------------------------
|
||||
|
||||
|
||||
ROOT_HEAD = "---\nokf_version: 0.1\nbundle_profile: strict-v1\nokf_spec_commit: d44368c\n---\n"
|
||||
|
||||
|
||||
def test_strict_index_wants_exactly_one_h1() -> None:
|
||||
body = "\n* [Hooks](hooks.md) - How hooks fire.\n"
|
||||
missing = STRICT_V1.index.violations(
|
||||
ROOT_HEAD + body, is_root=True, expected_targets={"hooks.md"}
|
||||
)
|
||||
assert [violation.code for violation in missing] == ["index_heading_missing"]
|
||||
|
||||
two = ROOT_HEAD + "\n# Wiki\n\n# Again\n" + body
|
||||
extra = STRICT_V1.index.violations(two, is_root=True, expected_targets={"hooks.md"})
|
||||
assert [violation.code for violation in extra] == ["index_heading_extra"]
|
||||
assert extra[0].subject == "# Again"
|
||||
|
||||
|
||||
def test_a_second_level_heading_is_prose_not_a_heading() -> None:
|
||||
"""Only `# ` is the heading; `## ` is body text the strict shape forbids."""
|
||||
text = ROOT_HEAD + "\n# Wiki\n\n## Section\n"
|
||||
codes = [v.code for v in STRICT_V1.index.violations(text, is_root=True, expected_targets=set())]
|
||||
assert codes == ["index_prose_not_allowed"]
|
||||
|
||||
|
||||
# --- the reader: prose ----------------------------------------------------
|
||||
|
||||
|
||||
def test_strict_index_admits_nothing_but_the_heading_and_the_entries() -> None:
|
||||
text = ROOT_HEAD + "\n# Wiki\n\nA sentence the generator would never write.\n"
|
||||
found = STRICT_V1.index.violations(text, is_root=True, expected_targets=set())
|
||||
assert [v.code for v in found] == ["index_prose_not_allowed"]
|
||||
assert found[0].subject == "A sentence the generator would never write."
|
||||
|
||||
|
||||
def test_prose_is_the_catalog_shape_and_stays_expressible() -> None:
|
||||
"""Their index requires progressive-disclosure prose and no per-entry
|
||||
description — the opposite of the wiki on both counts, and both have to be
|
||||
constructible."""
|
||||
second_brain = IndexPolicy(
|
||||
name="index.md",
|
||||
link_template="- [{label}]({target})",
|
||||
link_pattern=DEFAULT.index.link_pattern,
|
||||
per_directory=True,
|
||||
heading_required=True,
|
||||
allows_prose=True,
|
||||
root_frontmatter=("okf_version", "okf_layout"),
|
||||
)
|
||||
text = "# Notes\n\nWhat lives here and why.\n\n- [A note](a.md)\n"
|
||||
assert second_brain.violations(text) == ()
|
||||
assert second_brain.entries_match_directory is False
|
||||
|
||||
|
||||
# --- the reader: directory match, both directions -------------------------
|
||||
|
||||
|
||||
def test_the_match_is_bidirectional_and_names_each_side() -> None:
|
||||
text = ROOT_HEAD + "\n# Wiki\n\n* [Hooks](hooks.md) - How hooks fire.\n"
|
||||
found = STRICT_V1.index.violations(
|
||||
text, is_root=True, expected_targets={"skills.md", "concepts/index.md"}
|
||||
)
|
||||
assert [(v.subject, v.code) for v in found] == [
|
||||
("concepts/index.md", "index_entry_missing"),
|
||||
("hooks.md", "index_entry_unexpected"),
|
||||
("skills.md", "index_entry_missing"),
|
||||
]
|
||||
|
||||
|
||||
def test_a_derived_index_cannot_be_judged_without_the_listing() -> None:
|
||||
"""Skipping the check silently is the failure mode that matters: a gate
|
||||
that quietly passes when its input is absent is worse than one that
|
||||
raises."""
|
||||
text = ROOT_HEAD + "\n# Wiki\n"
|
||||
with pytest.raises(ValueError, match="listing"):
|
||||
STRICT_V1.index.violations(text, is_root=True)
|
||||
|
||||
|
||||
def test_an_authored_index_refuses_a_listing() -> None:
|
||||
"""The catalog's rule, enforced rather than documented: a profile whose
|
||||
index is authored cannot be asked to check it against the directory, so
|
||||
code written to the wrong contract fails at the call instead of passing
|
||||
every test anyone would think to write."""
|
||||
with pytest.raises(ValueError, match="authored"):
|
||||
DEFAULT.index.violations("- [Sales](ingest-sales.md)\n", expected_targets={"a.md"})
|
||||
|
||||
|
||||
# --- the reader: root vs nested frontmatter -------------------------------
|
||||
|
||||
|
||||
def test_the_root_index_pins_its_keys_in_order() -> None:
|
||||
text = ROOT_HEAD + "\n# Wiki\n"
|
||||
assert STRICT_V1.index.violations(text, is_root=True, expected_targets=set()) == ()
|
||||
|
||||
|
||||
def test_a_root_index_missing_a_pinned_key_is_reported_per_key() -> None:
|
||||
text = "---\nokf_version: 0.1\n---\n\n# Wiki\n"
|
||||
found = STRICT_V1.index.violations(text, is_root=True, expected_targets=set())
|
||||
assert [(v.subject, v.code) for v in found] == [
|
||||
("bundle_profile", "index_root_key_missing"),
|
||||
("okf_spec_commit", "index_root_key_missing"),
|
||||
]
|
||||
|
||||
|
||||
def test_the_pinned_keys_are_ordered_not_merely_present() -> None:
|
||||
"""Their `ROOT_INDEX_KEY_ORDER` is what makes a regeneration byte-identical,
|
||||
so an out-of-order root index is a finding even with every key present."""
|
||||
text = "---\nbundle_profile: strict-v1\nokf_version: 0.1\nokf_spec_commit: d44368c\n---\n\n# Wiki\n"
|
||||
found = STRICT_V1.index.violations(text, is_root=True, expected_targets=set())
|
||||
assert [v.code for v in found] == ["index_root_key_order"]
|
||||
|
||||
|
||||
def test_a_nested_index_carries_no_frontmatter_at_all() -> None:
|
||||
"""Confirmed independently in two consumers: the wiki writes frontmatter
|
||||
only at the bundle root (`bundle.py:544-557`), and the catalog reports
|
||||
their `okf_version`/`okf_layout` markers as root-exclusive. The asymmetry
|
||||
is shape, not one repo's preference."""
|
||||
found = STRICT_V1.index.violations(ROOT_HEAD + "\n# Concepts\n", expected_targets=set())
|
||||
assert [(v.subject, v.code) for v in found] == [
|
||||
("okf_version", "index_root_frontmatter_unexpected")
|
||||
]
|
||||
|
||||
|
||||
def test_a_nested_index_without_frontmatter_is_clean() -> None:
|
||||
assert STRICT_V1.index.violations("# Concepts\n", expected_targets=set()) == ()
|
||||
|
||||
|
||||
# --- determinism ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_violations_are_sorted_so_two_runs_agree() -> None:
|
||||
text = "---\nokf_spec_commit: d\nokf_version: 0.1\n---\n\nz prose\na prose\n"
|
||||
found = STRICT_V1.index.violations(text, is_root=True, expected_targets={"m.md"})
|
||||
assert list(found) == sorted(found, key=lambda v: (v.subject, v.code))
|
||||
assert found == STRICT_V1.index.violations(text, is_root=True, expected_targets={"m.md"})
|
||||
|
||||
|
||||
def test_index_policy_stays_frozen() -> None:
|
||||
with pytest.raises(Exception):
|
||||
STRICT_V1.index.per_directory = False # type: ignore[misc]
|
||||
|
||||
|
||||
def test_the_pattern_group_names_the_template_needs_are_present() -> None:
|
||||
"""A template naming `{description}` whose pattern cannot recover one is a
|
||||
pair that round-trips in only one direction."""
|
||||
with pytest.raises(ValueError, match="description"):
|
||||
IndexPolicy(
|
||||
name="index.md",
|
||||
link_template="* [{label}]({target}) - {description}",
|
||||
link_pattern=re.compile(r"^\* \[(?P<label>[^\]]*)\]\((?P<target>[^)]+)\)$"),
|
||||
)
|
||||
|
|
@ -307,19 +307,60 @@ def test_strict_v1_path_namespaces_are_the_spec_namespaces() -> None:
|
|||
assert STRICT_V1.paths == DEFAULT.paths
|
||||
|
||||
|
||||
def test_strict_v1_index_policy_is_still_the_default_shape() -> None:
|
||||
"""A known gap, pinned so it cannot drift silently.
|
||||
def test_strict_v1_index_policy_is_the_consumers_shape() -> None:
|
||||
"""The gap this test used to pin is closed; it now states the shape.
|
||||
|
||||
The consumer's index shape — one index per directory, entries
|
||||
`* [Title](link) - description`, exactly one `# ` heading, and a root index
|
||||
carrying okf_version/bundle_profile/okf_spec_commit — is not expressible by
|
||||
`IndexPolicy` as it stands: neither per-directory indexes nor a description
|
||||
on an entry has a field. It lands with the reader that judges it. Until
|
||||
then, nothing consumes a profile's index policy, so this assertion is the
|
||||
honest statement of where the port has reached.
|
||||
Every value is read out of their code rather than recalled — the entry
|
||||
template and the root/nested frontmatter split from `bundle.py:498-567`,
|
||||
the bidirectional match from gate `BUNDLE_INDEX_COMPLETE`
|
||||
(`validate.py:1081-1120`, severity ERROR). The reader that judges all of
|
||||
it lives on `IndexPolicy.violations`, so no field here is written without
|
||||
something that reads it.
|
||||
"""
|
||||
assert STRICT_V1.index == DEFAULT.index
|
||||
assert "description" not in STRICT_V1.index.link_template
|
||||
index = STRICT_V1.index
|
||||
assert index != DEFAULT.index
|
||||
assert index.name == DEFAULT.index.name
|
||||
assert index.render_link("Hooks", "hooks.md", "How hooks fire.") == (
|
||||
"* [Hooks](hooks.md) - How hooks fire."
|
||||
)
|
||||
assert index.per_directory is True
|
||||
assert index.heading_required is True
|
||||
assert index.allows_prose is False
|
||||
assert index.entries_match_directory is True
|
||||
assert index.root_frontmatter == ("okf_version", "bundle_profile", "okf_spec_commit")
|
||||
|
||||
|
||||
def test_a_conforming_wiki_root_index_passes_and_a_default_one_does_not() -> None:
|
||||
"""The plan's second cross-profile test: a bundle valid under one profile
|
||||
is refused by the other. It could not come from frontmatter — DEFAULT has
|
||||
no required key set, by measurement — so it comes from index shape.
|
||||
"""
|
||||
conforming = (
|
||||
"---\nokf_version: 0.1\nbundle_profile: strict-v1\n"
|
||||
"okf_spec_commit: d44368c\n---\n\n# Wiki\n\n"
|
||||
"* [Concepts](concepts/index.md) - Machine-generated index of concepts.\n"
|
||||
)
|
||||
assert (
|
||||
STRICT_V1.index.violations(conforming, is_root=True, expected_targets={"concepts/index.md"})
|
||||
== ()
|
||||
)
|
||||
|
||||
default_shaped = "# Wiki\n\nCurated prose.\n\n- [Concepts](concepts/index.md)\n"
|
||||
codes = {
|
||||
violation.code
|
||||
for violation in STRICT_V1.index.violations(
|
||||
default_shaped, is_root=True, expected_targets={"concepts/index.md"}
|
||||
)
|
||||
}
|
||||
assert codes == {
|
||||
"index_root_key_missing",
|
||||
"index_prose_not_allowed",
|
||||
"index_entry_missing",
|
||||
}
|
||||
# ...and the reverse direction: DEFAULT judges neither of them, because a
|
||||
# judging default would condemn the bundles this library itself writes.
|
||||
assert DEFAULT.index.violations(conforming, is_root=True) == ()
|
||||
assert DEFAULT.index.violations(default_shaped, is_root=True) == ()
|
||||
|
||||
|
||||
def test_strict_v1_emission_is_the_ordered_prefix_then_the_sorted_tail() -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue