fix(okf): the index entry point never requires 'type', frontmatter or not
navigate_bundle died on an index.md that carried a frontmatter block without
a 'type' field: _parse_index_entry keyed its tolerance on the ABSENCE of the
block, so a frontmatter-ful index fell through to parse_concept_file and
raised. Not merely the index read failed — the whole navigation did.
OKF v0.2 (A-E6) triggers this: it stamps okf_version into index.md. Our own
assumption ("a generated index has no frontmatter") is frozen in the golden,
so nothing caught it. The defect is ours, not theirs.
method-spec §3 Step 1 renders the index as "the index body (the summary)"
and every OTHER file as a "non-index concept file" — the index is not a
concept file, so the 'type' requirement never reached it in the first place.
The tolerance is now keyed on being the index, which is what it always meant.
Load-bearing, both directions detach-proved:
- test_index_with_frontmatter_lacking_type_navigates goes RED when the
tolerance is re-keyed on absence-of-frontmatter.
- test_missing_type_is_an_error goes RED when the index default leaks onto
concept files. Its vehicle moved from index.md to a non-index file: it
proves the concept-file rule, and an index.md vehicle now proves the
opposite of what the test is named for. This also closes a real gap —
nothing tested a non-index file WITH frontmatter but WITHOUT 'type'.
Goldens untouched (shared bundle has type: index; ingest golden index has no
frontmatter). Suite 627 -> 628 passed.
This commit is contained in:
parent
ffb0503da3
commit
f41264fbd3
2 changed files with 58 additions and 19 deletions
|
|
@ -47,10 +47,11 @@ def _strip_matching_quotes(value: str) -> str:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def parse_concept_file(path: Path) -> ConceptFile:
|
def _parse_frontmatter_and_body(path: Path) -> tuple[dict[str, str], str]:
|
||||||
"""Parse frontmatter (leading ``---`` block, line-oriented ``key: value``) + body.
|
"""Split a leading ``---`` block (line-oriented ``key: value``) from the body.
|
||||||
|
|
||||||
The single required field is ``type``; unknown fields are preserved as strings.
|
Unknown fields are preserved as strings. The ``type`` requirement is NOT applied
|
||||||
|
here — it belongs to concept files, not to the index entry point.
|
||||||
"""
|
"""
|
||||||
lines = path.read_text(encoding="utf-8").splitlines()
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
if not lines or lines[0].strip() != "---":
|
if not lines or lines[0].strip() != "---":
|
||||||
|
|
@ -64,30 +65,44 @@ def parse_concept_file(path: Path) -> ConceptFile:
|
||||||
key, sep, value = line.partition(":")
|
key, sep, value = line.partition(":")
|
||||||
if sep:
|
if sep:
|
||||||
frontmatter[key.strip()] = _strip_matching_quotes(value.strip())
|
frontmatter[key.strip()] = _strip_matching_quotes(value.strip())
|
||||||
|
return frontmatter, "\n".join(lines[body_start:]).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_concept_file(path: Path) -> ConceptFile:
|
||||||
|
"""Parse frontmatter (leading ``---`` block, line-oriented ``key: value``) + body.
|
||||||
|
|
||||||
|
The single required field is ``type``; unknown fields are preserved as strings.
|
||||||
|
"""
|
||||||
|
frontmatter, body = _parse_frontmatter_and_body(path)
|
||||||
if "type" not in frontmatter:
|
if "type" not in frontmatter:
|
||||||
raise ValueError(f"{path.name}: frontmatter lacks the required field 'type'")
|
raise ValueError(f"{path.name}: frontmatter lacks the required field 'type'")
|
||||||
return ConceptFile(
|
return ConceptFile(path=path, frontmatter=frontmatter, body=body)
|
||||||
path=path, frontmatter=frontmatter, body="\n".join(lines[body_start:]).strip()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_index_entry(index_path: Path) -> ConceptFile:
|
def _parse_index_entry(index_path: Path) -> ConceptFile:
|
||||||
"""Parse the bundle entry point, tolerating a frontmatter-less index (§3 Step 1).
|
"""Parse the bundle entry point, where ``type`` is not required (§3 Step 1).
|
||||||
|
|
||||||
method-spec §3 Step 1 renders the index as "the index body (the summary)" and
|
method-spec §3 Step 1 renders the index as "the index body (the summary)" and
|
||||||
treats every OTHER file as a "non-index concept file" (`## {type}: {title}`
|
treats every OTHER file as a "non-index concept file" (`## {type}: {title}`
|
||||||
section) — so the index is NOT a concept file, and it MAY omit frontmatter: a
|
section) — so the index is NOT a concept file, and the ``type`` requirement does
|
||||||
generated index carrying only ``bundle_summary`` + cross-links (ingest spec §6,
|
not reach it. It MAY omit frontmatter entirely (a generated index carrying only
|
||||||
frozen in the ingest golden) is valid. A frontmatter-ful index (the curated
|
``bundle_summary`` + cross-links, ingest spec §6, frozen in the ingest golden),
|
||||||
convention, ``type: index``) still parses normally, its extra fields preserved.
|
and it MAY carry frontmatter that says nothing about ``type`` (an OKF v0.2
|
||||||
When frontmatter is absent, the whole file is the body and ``type`` defaults to
|
generated index stamps ``okf_version`` there). In both cases ``type`` defaults to
|
||||||
``index`` so downstream ``.type`` reads never raise.
|
``index`` so downstream ``.type`` reads never raise; other fields are preserved.
|
||||||
|
A curated index (``type: index``) is unaffected.
|
||||||
|
|
||||||
|
Keying this on the ABSENCE of a frontmatter block was the defect: a frontmatter-ful
|
||||||
|
index without ``type`` then raised out of ``parse_concept_file`` and killed the
|
||||||
|
whole navigation, not merely the index read.
|
||||||
"""
|
"""
|
||||||
text = index_path.read_text(encoding="utf-8")
|
text = index_path.read_text(encoding="utf-8")
|
||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
if lines and lines[0].strip() == "---":
|
if not lines or lines[0].strip() != "---":
|
||||||
return parse_concept_file(index_path)
|
return ConceptFile(path=index_path, frontmatter={"type": _INDEX_TYPE}, body=text.strip())
|
||||||
return ConceptFile(path=index_path, frontmatter={"type": _INDEX_TYPE}, body=text.strip())
|
frontmatter, body = _parse_frontmatter_and_body(index_path)
|
||||||
|
frontmatter.setdefault("type", _INDEX_TYPE)
|
||||||
|
return ConceptFile(path=index_path, frontmatter=frontmatter, body=body)
|
||||||
|
|
||||||
|
|
||||||
def navigate_bundle(bundle_dir: Path) -> list[ConceptFile]:
|
def navigate_bundle(bundle_dir: Path) -> list[ConceptFile]:
|
||||||
|
|
@ -96,8 +111,8 @@ def navigate_bundle(bundle_dir: Path) -> list[ConceptFile]:
|
||||||
Targets containing a path separator are out-of-bundle and skipped; resolution is
|
Targets containing a path separator are out-of-bundle and skipped; resolution is
|
||||||
boundary-checked against the bundle directory (fail-closed); broken links are
|
boundary-checked against the bundle directory (fail-closed); broken links are
|
||||||
skipped, never raised. Repeated links are de-duplicated. The index entry point
|
skipped, never raised. Repeated links are de-duplicated. The index entry point
|
||||||
may omit frontmatter (``_parse_index_entry``); non-index concept files still
|
does not require ``type`` — with or without a frontmatter block
|
||||||
require ``type``.
|
(``_parse_index_entry``); non-index concept files still require it.
|
||||||
"""
|
"""
|
||||||
index_path = bundle_dir / _INDEX_FILENAME
|
index_path = bundle_dir / _INDEX_FILENAME
|
||||||
if not index_path.is_file():
|
if not index_path.is_file():
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,24 @@ class TestNavigation:
|
||||||
# The summary flows into the read-context as the leading section.
|
# The summary flows into the read-context as the leading section.
|
||||||
assert bundle_context(tmp_path).startswith("Summary line.")
|
assert bundle_context(tmp_path).startswith("Summary line.")
|
||||||
|
|
||||||
|
def test_index_with_frontmatter_lacking_type_navigates(self, tmp_path: Path) -> None:
|
||||||
|
# The index is the bundle ENTRY POINT, not a "non-index concept file" — so
|
||||||
|
# `type` is not required OF IT, whether or not it carries a frontmatter
|
||||||
|
# block. Keying the tolerance on the ABSENCE of frontmatter was the defect:
|
||||||
|
# an index that carries frontmatter for some OTHER reason (an OKF v0.2
|
||||||
|
# generated index stamps `okf_version` there) reached `parse_concept_file`
|
||||||
|
# and killed the whole navigation — not just the index read. RED if the
|
||||||
|
# tolerance is keyed on absence-of-frontmatter again.
|
||||||
|
_write(tmp_path / "index.md", "---\nokf_version: 0.2\n---\nSummary line.\n- [A](a.md)\n")
|
||||||
|
_write(tmp_path / "a.md", "---\ntype: project\ntitle: A\n---\nBody A.")
|
||||||
|
concepts = navigate_bundle(tmp_path)
|
||||||
|
assert [c.path.name for c in concepts] == ["index.md", "a.md"]
|
||||||
|
index = concepts[0]
|
||||||
|
assert index.type == "index" # defaulted, not read from the file
|
||||||
|
assert index.frontmatter["okf_version"] == "0.2" # unknown fields preserved
|
||||||
|
assert index.body == "Summary line.\n- [A](a.md)" # frontmatter stripped from body
|
||||||
|
assert bundle_context(tmp_path).startswith("Summary line.")
|
||||||
|
|
||||||
def test_frontmatterless_tolerance_is_scoped_to_the_index(self, tmp_path: Path) -> None:
|
def test_frontmatterless_tolerance_is_scoped_to_the_index(self, tmp_path: Path) -> None:
|
||||||
# LOAD-BEARING (honesty): the relaxation is for the index ENTRY POINT only.
|
# LOAD-BEARING (honesty): the relaxation is for the index ENTRY POINT only.
|
||||||
# A NON-index concept file without frontmatter is still malformed and MUST
|
# A NON-index concept file without frontmatter is still malformed and MUST
|
||||||
|
|
@ -142,7 +160,13 @@ class TestFrontmatter:
|
||||||
"""§3 Step 1: leading ``---`` block, line-oriented ``key: value``; ``type`` required."""
|
"""§3 Step 1: leading ``---`` block, line-oriented ``key: value``; ``type`` required."""
|
||||||
|
|
||||||
def test_missing_type_is_an_error(self, tmp_path: Path) -> None:
|
def test_missing_type_is_an_error(self, tmp_path: Path) -> None:
|
||||||
_write(tmp_path / "index.md", "---\ntitle: No type\n---\nBody.")
|
# LOAD-BEARING (honesty), and the scope guard for the index tolerance: the
|
||||||
|
# vehicle MUST be a NON-index concept file. `type` is required of concept
|
||||||
|
# files, never of the entry point — an index.md vehicle would only re-assert
|
||||||
|
# the entry-point behaviour, which is now (correctly) the opposite. RED if
|
||||||
|
# the index's `type` default ever leaks onto concept files.
|
||||||
|
_write(tmp_path / "index.md", "Summary. See [a](a.md).")
|
||||||
|
_write(tmp_path / "a.md", "---\ntitle: No type\n---\nBody.")
|
||||||
with pytest.raises(ValueError, match="type"):
|
with pytest.raises(ValueError, match="type"):
|
||||||
navigate_bundle(tmp_path)
|
navigate_bundle(tmp_path)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue