feat(okf): navigate hierarchy — escape, not depth, is forbidden

The pulled method-spec (commons 9801d35) retires the "a target containing a
path separator is out-of-bundle" heuristic, which conflated depth with escape
and forbade valid hierarchy. Triage of the pull found FIVE contradictions in
okf.py, not the two STATE had measured on line 127 alone:

1. the separator ban skipped every legal nested target;
2. de-duplication keyed on the RAW target (`resolved` was computed a line
   later), not on the resolved path;
3. navigation never recursed — only the root index's links were read;
4. a leading `/` became filesystem-absolute via pathlib rather than denoting
   the bundle root (safe, because the boundary check caught it, but the right
   outcome for the wrong reason — and wrong the moment `/a/index.md` must be
   FOLLOWED);
5. rendering excluded only `verdict`, so a nested index body would render as
   content.

navigate_bundle is now depth-first in first-seen link order, de-duplicating on
the resolved path (so `./a.md` and `a.md` are one entry and cycles terminate);
resolution and the fail-closed boundary check move to _resolve_target, the sole
in-/out-of-bundle test. The missing-index rule binds the bundle root alone.
bundle_context renders flat regardless of depth and drops nested index bodies:
only the root index is the summary.

The gate is the commons-owned nav-golden pair that arrived with the same pull —
bundle in, expected-read-context out. Its negative case exists so the gate can
go red at all, and carries a real decoy one level up plus a `/etc/passwd` trap.

Detach-proved (mutate, run, restore from copy) — each new seam goes RED:
  D1 reinstate the separator heuristic -> RED
  D2 re-key dedup on the raw target   -> RED
  D3 read a leading `/` as absolute   -> RED
  D4 render nested index bodies       -> RED
Control after restore: 24 passed. Suite 631 -> 637, ruff + mypy --strict clean.

Comments asserting the retired doctrine were corrected rather than left to
document a rule the code no longer follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M1zp3BxCuzRnUtJPzvEFTQ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 18:13:20 +02:00
commit 7d8de32543
2 changed files with 157 additions and 42 deletions

View file

@ -20,6 +20,7 @@ import pytest
from portfolio_optimiser_claude.okf import ConceptFile, bundle_context, navigate_bundle
BUNDLE = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
NAV_GOLDENS = Path(__file__).resolve().parents[1] / "shared" / "examples"
SRC_PKG = Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser_claude"
@ -99,8 +100,10 @@ class TestNavigation:
navigate_bundle(tmp_path)
def test_out_of_bundle_and_escaping_targets_are_skipped(self, tmp_path: Path) -> None:
# A target containing a path separator is out-of-bundle — skipped, never
# raised; ../-escapes never resolve outside the bundle (fail-closed).
# ../-escapes never resolve outside the bundle (fail-closed), and are skipped
# rather than raised. `sub/inner.md` is skipped here because it does not
# EXIST, not because it carries a separator — the separator heuristic is
# retired (see TestNavigationBoundary); depth is legal, escape is not.
outside = tmp_path / "outside.md"
_write(outside, "---\ntype: project\ntitle: Outside\n---\nSecret.")
bundle_dir = tmp_path / "bundle"
@ -123,14 +126,13 @@ class TestNavigation:
assert names == ["index.md", "a.md"]
def test_null_byte_target_is_tolerated_never_raised(self, tmp_path: Path) -> None:
# LOAD-BEARING (§11, method-spec §72: a broken cross-link MUST be "skipped,
# never raised"). A NUL-byte target carries no path separator, so it slips
# past the out-of-bundle filter and reaches resolution, where
# ``(bundle_dir / target).resolve()`` raises ``ValueError: embedded null
# byte``. That ValueError MUST be caught and the link skipped — a NUL target
# is a broken link, not a fatal error. This is the FIRST test that drives a
# dangerous target THROUGH the filter into resolution (the ``/``-filter
# short-circuits every ``/``-bearing and scheme-bearing case before it).
# LOAD-BEARING (§11 "Navigation boundary": a malformed target MUST be
# skipped, not raised). A NUL byte is an INVALID PATH COMPONENT, so
# ``resolve``/``is_file`` raises ``ValueError: embedded null byte`` inside
# ``_resolve_target``. That ValueError MUST be caught and the link skipped —
# a NUL target is a broken link, not a fatal error. It is the malformed
# sub-class the escape golden's README delegates here, precisely because a
# literal NUL byte does not belong in a committed text fixture.
# RED (ValueError propagates) if the resolve guard is detached.
bundle = _make_bundle(
tmp_path,
@ -156,6 +158,81 @@ class TestNavigation:
}
class TestNavigationBoundary:
"""LOAD-BEARING (§11 seam "Navigation boundary"): escape, not depth, is forbidden.
Gated by the COMMONS-OWNED nav-goldens, whose shape is ``bundle/`` in
``expected-read-context.md`` out the only shape that can express §3 Step 1's
property "two conformant implementations produce an identical read-context from
the same bundle". The positive case pins hierarchy, both link forms, depth-first
first-seen order, resolved-path dedup, cycle termination, recursive verdict
exclusion and flat rendering; the negative case pins escape, and exists so the
gate can go RED at all (a gate that can only pass proves nothing).
RED if the retired "a path separator means out-of-bundle" heuristic returns (the
hierarchy case loses every nested concept), if dedup re-keys on the raw target,
if a leading ``/`` is read as filesystem-absolute, or if nested index bodies
start rendering as content.
"""
def _read_context(self, case: str) -> tuple[str, str]:
root = NAV_GOLDENS / case
expected = (root / "expected-read-context.md").read_text(encoding="utf-8")
# The fixture README pins the shape and permits trailing-whitespace
# normalization; only the trailing newline is normalized here.
return bundle_context(root / "bundle").rstrip("\n"), expected.rstrip("\n")
def test_hierarchy_golden_renders_the_expected_read_context(self) -> None:
rendered, expected = self._read_context("nav-golden-hierarchy")
assert rendered == expected
def test_escape_golden_renders_the_expected_read_context(self) -> None:
rendered, expected = self._read_context("nav-golden-escape")
assert rendered == expected
def test_escape_golden_never_reads_the_out_of_bundle_decoy(self) -> None:
# Stated separately from the golden compare because the decoy REALLY exists
# one level above ``bundle/``: a ``..`` escape would succeed, so its absence
# is a boundary proof rather than a missing-file accident.
rendered, _ = self._read_context("nav-golden-escape")
assert "MUST never be reached" not in rendered
assert "Decoy" not in rendered
def test_leading_slash_resolves_to_the_bundle_root_and_is_followed(
self, tmp_path: Path
) -> None:
# The root-relative form must be FOLLOWED, not merely survived. The escape
# golden's `/etc/passwd` trap is skipped either way (as a bundle-root miss
# under the rule, as a boundary violation under a filesystem reading), so it
# cannot distinguish the two; a root-relative target that EXISTS can.
bundle = _make_bundle(tmp_path, "Root-relative: [T](/deep/target.md).", {})
(bundle / "deep").mkdir()
_write(
bundle / "deep" / "target.md",
"---\ntype: project\ntitle: T\n---\nRoot-relative body.",
)
assert [c.path.name for c in navigate_bundle(bundle)] == ["index.md", "target.md"]
def test_dedup_is_keyed_on_the_resolved_path_not_the_raw_target(self, tmp_path: Path) -> None:
# `./a.md` and `a.md` are two raw strings and one resolved path — RED if the
# dedup key regresses to the raw target.
bundle = _make_bundle(
tmp_path,
"See [a](a.md) then [a again](./a.md).",
{"a.md": "---\ntype: project\ntitle: A\n---\nBody A."},
)
assert [c.path.name for c in navigate_bundle(bundle)] == ["index.md", "a.md"]
def test_a_nested_directory_without_its_own_index_is_not_an_error(self, tmp_path: Path) -> None:
# The missing-index rule binds the bundle ROOT alone: an intermediate
# directory is navigated only through the links its files carry, so a nested
# directory lacking index.md is unreachable, never fatal.
bundle = _make_bundle(tmp_path, "Down: [d](sub/doc.md).", {})
(bundle / "sub").mkdir()
_write(bundle / "sub" / "doc.md", "---\ntype: project\ntitle: D\n---\nNested body.")
assert [c.path.name for c in navigate_bundle(bundle)] == ["index.md", "doc.md"]
class TestFrontmatter:
"""§3 Step 1: leading ``---`` block, line-oriented ``key: value``; ``type`` required."""