Files
chemenu/tools/chemenu/links.py
T
torben 177c7e9ce8
CI / verify (push) Successful in 55s
Release / release (push) Successful in 38s
feat: Prosa ist kein Identifier - Link-Taxonomie als Enum, generierte Regionen mit Markern (4.0.0)
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
2026-09-02 18:39:22 +02:00

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