177c7e9ce8
Files changed: - .gitea/workflows/ci.yml - AGENTS.md - CHANGES.md - VERSION - instructions/CONTRACT.md - instructions/link-taxonomy.md - instructions/migrations/4.0.0-link-taxonomy.md - instructions/setup-instance.md - kb/CONTRACT.md - kb/CONVENTIONS.md - kb/CONVENTIONS.md.template - kb/comparisons/COLLECTION.md - kb/concepts/COLLECTION.md - kb/entities/COLLECTION.md - kb/sources/COLLECTION.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/blocks.py - tools/chemenu/cli.py - tools/chemenu/commands/cite_cmd.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/links_cmd.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/page_ops.py - tools/chemenu/commands/run_budget.py - tools/chemenu/commands/xref.py - tools/chemenu/conventions.py - tools/chemenu/corpus_diff.py - tools/chemenu/frontmatter_io.py - tools/chemenu/kb_collections.py - tools/chemenu/kb_state.py - tools/chemenu/links.py - tools/chemenu/lint_core.py - tools/chemenu/provenance.py - tools/chemenu/sections.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_blocks.py - tools/chemenu/tests/test_cite_cmd.py - tools/chemenu/tests/test_conventions.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_migrate_cmd.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_pipeline_l0.py - tools/chemenu/tests/test_types_cmd.py - tools/chemenu/tests/test_xref.py - types/concept.schema.yaml - types/entity.md - types/entity.schema.yaml - types/instruction.schema.yaml - types/type-spec.md - work/link-taxonomy-migration/README.md - work/link-taxonomy-migration/plan.md
144 lines
5.4 KiB
Python
144 lines
5.4 KiB
Python
"""Labelled edges in a page's `related:` frontmatter.
|
|
|
|
An edge is a **label plus a target**, and the label is an identifier rather than
|
|
prose:
|
|
|
|
related:
|
|
- depends-on: Hermes
|
|
- implements: Hybrid Search
|
|
|
|
It used to be a bare list of titles with the label written only into a body
|
|
bullet - which meant the graph's semantics lived in German prose the tool had to
|
|
parse back, and the vocabulary drifted to 152 distinct labels in 337 bullets
|
|
because nothing could check it. The label moves into the data; the body bullet
|
|
becomes a rendering of the data.
|
|
|
|
**Both shapes read.** A bare string is an edge whose label is not yet declared,
|
|
which is exactly the state a page is in between the machinery landing and the
|
|
corpus migration reaching that page. Readers therefore never crash on the old
|
|
shape, and `lint` is what reports it - the migration is finished when no
|
|
unlabelled edge is left.
|
|
|
|
Direction is authored, never mirrored: an edge lives on the page that asserts
|
|
it, and the inbound view is rendered from the graph rather than stored. See
|
|
instructions/link-taxonomy.md.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, Iterable, Optional
|
|
|
|
# The label a not-yet-migrated bare-string edge reports as. Deliberately not a
|
|
# real catalogue label: it must be impossible for an instance to authorise it,
|
|
# so `lint` cannot be satisfied by declaring the placeholder legal.
|
|
UNLABELLED = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Edge:
|
|
"""One declared relationship: what this page asserts about `target`."""
|
|
|
|
target: str
|
|
label: Optional[str] = UNLABELLED
|
|
|
|
@property
|
|
def is_labelled(self) -> bool:
|
|
return bool(self.label)
|
|
|
|
|
|
def parse_entry(entry: Any) -> Optional[Edge]:
|
|
"""One `related:` element as an Edge, or None if it is not one at all.
|
|
|
|
A single-key mapping is a labelled edge; a bare string is an unlabelled one.
|
|
Anything else - a multi-key mapping, a list, a number - is malformed, and
|
|
returning None rather than guessing is what lets `lint` report it as a
|
|
finding instead of a reader silently inventing an edge.
|
|
"""
|
|
if isinstance(entry, str):
|
|
title = entry.strip()
|
|
return Edge(title) if title else None
|
|
if isinstance(entry, dict) and len(entry) == 1:
|
|
(label, target), = entry.items()
|
|
label, target = str(label).strip(), str(target).strip()
|
|
return Edge(target, label) if label and target else None
|
|
return None
|
|
|
|
|
|
def edges(frontmatter: dict[str, Any], field: str) -> list[Edge]:
|
|
"""Every well-formed edge in `field`, in file order."""
|
|
parsed = (parse_entry(entry) for entry in (frontmatter.get(field) or []))
|
|
return [edge for edge in parsed if edge is not None]
|
|
|
|
|
|
def malformed(frontmatter: dict[str, Any], field: str) -> list[Any]:
|
|
"""Elements of `field` that are neither a title nor a `label: target` pair."""
|
|
return [
|
|
entry for entry in (frontmatter.get(field) or []) if parse_entry(entry) is None
|
|
]
|
|
|
|
|
|
def targets(frontmatter: dict[str, Any], field: str) -> list[str]:
|
|
"""Just the page titles in `field`, labelled or not.
|
|
|
|
The compatibility seam. Every caller that only ever wanted "which pages does
|
|
this reference" - dangling-reference checks, `rename`, `rm`, the link graph -
|
|
goes through here and is untouched by the label carried alongside.
|
|
"""
|
|
return [edge.target for edge in edges(frontmatter, field)]
|
|
|
|
|
|
def render(edge_list: Iterable[Edge]) -> list[Any]:
|
|
"""Edges back into frontmatter form, ready for `dump_frontmatter`.
|
|
|
|
An unlabelled edge round-trips as a bare string rather than being promoted
|
|
to some default label: inventing one here would erase the very thing `lint`
|
|
is looking for.
|
|
"""
|
|
rendered: list[Any] = []
|
|
for edge in edge_list:
|
|
rendered.append({edge.label: edge.target} if edge.is_labelled else edge.target)
|
|
return rendered
|
|
|
|
|
|
def upsert(frontmatter: dict[str, Any], field: str, edge: Edge) -> bool:
|
|
"""Add or relabel `edge` in `field`. True if anything changed.
|
|
|
|
Idempotent by target: one page asserts one thing about another, so a second
|
|
call with a different label *replaces* rather than appends. Two edges to the
|
|
same target would render two bullets and leave no way to say which is meant.
|
|
"""
|
|
current = edges(frontmatter, field)
|
|
for position, existing in enumerate(current):
|
|
if existing.target == edge.target:
|
|
if existing.label == edge.label:
|
|
return False
|
|
current[position] = edge
|
|
frontmatter[field] = render(current)
|
|
return True
|
|
current.append(edge)
|
|
frontmatter[field] = render(current)
|
|
return True
|
|
|
|
|
|
def remove(frontmatter: dict[str, Any], field: str, target: str) -> bool:
|
|
"""Drop every edge pointing at `target`. True if anything changed."""
|
|
current = edges(frontmatter, field)
|
|
kept = [edge for edge in current if edge.target != target]
|
|
if len(kept) == len(current):
|
|
return False
|
|
frontmatter[field] = render(kept)
|
|
return True
|
|
|
|
|
|
def retarget(frontmatter: dict[str, Any], field: str, old: str, new: str) -> bool:
|
|
"""Repoint every edge from `old` to `new`, keeping its label."""
|
|
current = edges(frontmatter, field)
|
|
changed = False
|
|
for position, edge in enumerate(current):
|
|
if edge.target == old:
|
|
current[position] = Edge(new, edge.label)
|
|
changed = True
|
|
if changed:
|
|
frontmatter[field] = render(current)
|
|
return changed
|