feat(index): one ordering helper, called by both doors
An index ordering a profile names must be honoured wherever this library writes an index. Door B and Door C have separate index writers, so an ordering built on Door B's `_index_sort_key` seam alone would have been a profile field Door B obeys and Door C ignores -- silently, because nothing raises and both files still parse. That is `IndexPolicy.per_directory` again: a field that reads as global and acts on one path. `IndexPolicy` gains `sort_key`, `sort_order` and `sort_missing`. Both order fields draw from CLOSED sets, and `sort_order` is deliberately not a caller-supplied callable: a callable cannot be serialised into the bundle, reproduced from it, or audited by a reader, which is the whole of what a deterministic bundle claims. A `sort_key` the facet policy does not name is refused too -- every entry would be missing the key and the ordering would silently do nothing, which is this row's own defect class. `IndexPolicy.sort_entries` is the one helper. Four stable passes, so each is the tie-break of the next: concept path, then the named key, then the missing group partitioned to whichever end the policy says, then navigation last. Passes 2 and 3 are separate on purpose -- folding them into one reversible key tuple would flip the missing group along with the order, so `sort_missing="last"` would mean "first" under `descending`. The tie-break is the CONCEPT PATH, not the link target, and that is measured rather than assumed: `notes-beta.md` precedes `notes/alpha.md` by concept path and follows it by generated filename, so ordering Door C on the target would have re-ordered every existing Door C bundle. `IndexEntry` carries the path for that reason; `parse_entry` leaves it `None` and the ordering falls back to the target, which costs nothing because no caller sorts entries it read back off disk. Door B's two reprojection writers and Door C's index emission all route through the helper. Door B's unfaceted path is not routed and does not need to be: `sort_key` requires a facet policy, and a faceted profile never reaches that writer. Door C's guarantee is bounded and stated in the code -- `link_in_index` appends what is absent and leaves what is present, so the order holds within a run and never re-orders entries an earlier run wrote. Default ordering, unchanged and now stated: with no `sort_key`, concepts before navigation, each group ascending by concept path. TDD, and the red was watched twice. First behaviourally with the fields inert (both doors emitted the exact reverse of the named order), then again with Door B routed and Door C not -- the broken world reproduced, where a Door-B-only test would have passed. 882 tests (868 before). The five byte-pinned goldens are untouched and green; no shipped profile moved. Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
parent
ac6dffe51e
commit
d2a8c43d77
5 changed files with 391 additions and 36 deletions
|
|
@ -849,13 +849,6 @@ def _refresh_root_frontmatter(index_path: Path, head: str) -> None:
|
|||
index_path.write_bytes((head + body).encode("utf-8"))
|
||||
|
||||
|
||||
#: The basename a navigation entry points at. A nav line is a link to a child
|
||||
#: directory's index, and recognising it by its TARGET -- rather than by a flag
|
||||
#: carried beside it -- is what lets an entry read back off disk sort exactly
|
||||
#: like one about to be written.
|
||||
INDEX_NAV_SUFFIX = DEFAULT.index.name
|
||||
|
||||
|
||||
def _owned_listing(bundle: Path, profile: BundleProfile) -> dict[str, DocumentStructure]:
|
||||
"""Every inbox-owned concept in the bundle, keyed by bundle-relative path.
|
||||
|
||||
|
|
@ -872,19 +865,6 @@ def _owned_listing(bundle: Path, profile: BundleProfile) -> dict[str, DocumentSt
|
|||
return listing
|
||||
|
||||
|
||||
def _index_sort_key(entry: IndexEntry) -> tuple[bool, str]:
|
||||
"""The ONE ordering seam for every index this door writes.
|
||||
|
||||
Concepts first, navigation last, each group by target. Routed through a
|
||||
single named helper on purpose: a consumer-controlled ordering is then a
|
||||
parameter passed to this function, not a refactor of every place that
|
||||
happened to call `sorted`. Navigation is recognised by its target -- a link
|
||||
to a child's index -- rather than by a flag carried alongside, so an entry
|
||||
read back off disk sorts the same way as one about to be written.
|
||||
"""
|
||||
return (entry.target.rpartition("/")[2] == INDEX_NAV_SUFFIX, entry.target)
|
||||
|
||||
|
||||
def _reproject_indexes(
|
||||
bundle: Path,
|
||||
profile: BundleProfile,
|
||||
|
|
@ -939,6 +919,7 @@ def _reproject_indexes(
|
|||
label=document.title or target,
|
||||
target=target,
|
||||
facets=facet_values(relative, resolved, profile.index.facets.keys),
|
||||
concept_path=relative,
|
||||
)
|
||||
)
|
||||
for child in children.get(directory, set()):
|
||||
|
|
@ -952,7 +933,7 @@ def _reproject_indexes(
|
|||
|
||||
block = [
|
||||
profile.index.render_link(entry.label, entry.target, facets=entry.facets or None) + "\n"
|
||||
for entry in sorted(entries, key=_index_sort_key)
|
||||
for entry in profile.index.sort_entries(entries)
|
||||
]
|
||||
_write_index(
|
||||
bundle,
|
||||
|
|
@ -1050,15 +1031,22 @@ def _reproject_index(bundle: Path, profile: BundleProfile) -> None:
|
|||
documents[key] = structure_from_frontmatter(parse_frontmatter(path))
|
||||
|
||||
resolved = resolve_structure(documents)
|
||||
block = [
|
||||
profile.index.render_link(
|
||||
documents[name].title or name,
|
||||
name,
|
||||
entries = [
|
||||
IndexEntry(
|
||||
label=documents[name].title or name,
|
||||
target=name,
|
||||
facets=facet_values(name, resolved, profile.index.facets.keys),
|
||||
# Flat: the concept ID and the link target are the same string. It
|
||||
# is still named, because the tie-break is the concept path and
|
||||
# agreeing with the target here is this writer's fact, not a rule.
|
||||
concept_path=name,
|
||||
)
|
||||
+ "\n"
|
||||
for name in sorted(documents)
|
||||
]
|
||||
block = [
|
||||
profile.index.render_link(entry.label, entry.target, facets=entry.facets) + "\n"
|
||||
for entry in profile.index.sort_entries(entries)
|
||||
]
|
||||
|
||||
index_path = bundle / profile.index.name
|
||||
kept: list[str] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue