diff --git a/CHANGES.md b/CHANGES.md index fa2c05b..7067359 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -35,6 +35,87 @@ dev-checkout concern - readable here, never shipped as something to parse. --- +## 4.7.0 - 2026-09-04 - Link-Katalog: authored, alternative-to, addresses; entity→entity-Lineage; Lint-Befund gegen redundante see-also + +**Author:** Torben Nehmer + + +- Link-Katalog: authored, alternative-to, addresses; Lint-Befund gegen redundante see-also + + +Drei neue Label, zwei geschlossene Autorisierungslücken und ein Lint-Befund - ausgelöst von der +anstehenden ersten produktiven Instanz. Katalog und `COLLECTION.md` gehen über `dist export` in +jede neue Instanz; was hier fehlt, fehlt dort ab Tag eins, und nachträglich ist eine +Katalogerweiterung eine Korpus-Migration statt einer Datenzeile. Gitea #43 und #49. + +**Die Messung, die den Zuschnitt bestimmt hat.** Die 180 `see-also`-Kanten des Korpus zerfallen +exakt in drei Klassen: 57 (32 %) sind Spiegel einer bereits typisierten Gegenkante, 70 (39 %) +sind wechselseitige `see-also`-Paare, 53 (29 %) stehen einseitig. Die erste Klasse ist kein +Vokabularproblem - `Wine see-also Wine GE` steht neben `Wine GE depends-on Wine`, `RAG see-also +NotebookLM` neben `NotebookLM implements RAG`. Der Katalog war für ein Drittel der Fälle längst +ausreichend; es hat sie nur nichts gemeldet. Genau dafür ist der Lint-Befund unten da, und er ist +der Grund, warum diese Version mehr ist als zwei Katalogzeilen. + +**`authored`** (operationales Register). „hat das Ziel als einmaligen Akt geschaffen." Der Katalog +kannte fortlaufende Rechenschaft (`owns`) und fortlaufende Arbeit (`maintains`), aber nicht den +historischen Ursprung - Urheberschaft stand im Korpus deshalb in vier unvereinbaren Formen +nebeneinander: `source.author` als Freitext, `owns`, `see-also` und ein Prosa-Bullet. Eine davon +war sachlich falsch: `Vannevar Bush owns Memex` behauptet laufende Rechenschaft für einen 1974 +Verstorbenen, und eine falsche maschinenlesbare Kante ist schlechter als eine schwache, weil sie +geglaubt wird. Geschrieben wird das Label auf der Entity-Seite (`Andrej Karpathy authored LLM +Wiki Pattern`) - die Gegenrichtung `authored-by` auf der Concept-Seite hätte `xref remove` +gebraucht, das beidseitig abräumt, statt `xref add`, das relabelt. Vier Kanten im Korpus +umgestellt, keine verloren. + +**`alternative-to`** (operationales Register, selbst-dual). „erfüllt denselben Zweck wie das Ziel, +so dass ein Leser, der zwischen beiden wählt, beide will." Belegt durch rund 30 Paare, darunter +die sieben Agent-CLIs, die untereinander *ausschließlich* `see-also` tragen - keine einzige +typisierte Kante. Abgegrenzt gegen `contrasts` (behauptet einen lesenswerten Unterschied) und +`compares-with` (wiegt auf benannten Dimensionen ab und führt in dieser Instanz auf eine +`kb/comparisons/`-Seite): zwei Agent-CLIs sind austauschbar, zwei gegensätzliche Entwurfsprinzipien +sind es nicht. + +Der Katalog sagt jetzt ausdrücklich, dass ein selbst-duales Label **einmal pro Paar** geschrieben +wird. Ohne diesen Satz wäre aus einer 22-Kanten-`see-also`-Clique eine 22-Kanten- +`alternative-to`-Clique geworden und nichts gewonnen: sieben austauschbare Werkzeuge sind 21 +Paare, beidseitig deklariert 42 Kanten, von denen die zweiten 21 nichts sagen. + +**`addresses`** (konzeptionelles Register). „ist eine Antwort auf das Problem, das das Ziel +beschreibt." `types/concept.md` deklariert `problem` und `decision` als eigene Subtypen, und der +Katalog hatte kein Label, das eine Entscheidung mit dem Problem verbindet, das sie löst - eine +Collection konnte ein Problem benennen und nie sagen, was dagegen unternommen wurde. Abgegrenzt +gegen `rests-on`, das das Ziel als *Prämisse* nimmt statt als zu lösendes Problem. Im Korpus nur +vier belegte Paare, also dünn nach dem sonst geltenden „erst der Anwendungsfall"-Maßstab; die +Ausnahme ist bewusst und gilt dem Auslieferungszeitpunkt, der die Kosten umdreht. + +**Zwei Autorisierungslücken entity→entity.** `kb/entities/COLLECTION.md` erlaubte bisher keine +Lineage zwischen zwei Entities - ein Fork, eine Neuimplementierung, ein Nachbau war nicht +ausdrückbar; `derived-from` und `adapted-from` sind jetzt freigegeben. Ebenso `implements`, für +eine Entity, die eine als Entity geführte Konvention umsetzt. Ein autorisiertes Label ohne +Live-Nutzung ist ausdrücklich in Ordnung (`instructions/dev/corpus-policy.md`). + +**Lint-Befund `redundant_see_also`.** Meldet eine `see-also`-Kante, deren Gegenrichtung bereits +ein typisiertes Label trägt. Gegen den Korpus dieser Instanz meldet er genau die gemessenen 57. +**Advisory, nicht hart**, aus zwei Gründen zugleich: eine schwache Kante neben einer spezifischen +ist redundant, nicht kaputt - und der Befund kommt lange nach den Korpora, die er beurteilt, also +würde eine harte Einstufung jede bestehende Instanz mit dem Upgrade rot schalten, das ihn +ausliefert. Anders als `unlabelled_edges` ist er auch nicht migrations-gegatet: es gibt keine +Version, ab der die Redundanz zum Fehler wird, nur einen Sweep, zu dem jemand kommt oder nicht. + +`links.SEE_ALSO` ist damit das einzige Katalog-Label, das das Werkzeug beim Namen kennt. Das ist +eine begründete Ausnahme, keine Aufweichung: `see-also` ist der erklärte letzte Ausweg des +Katalogs und behauptet nur, dass nichts Besseres passte - was der einzige Grund ist, warum `lint` +eine Kante als *schwächer als* eine andere über demselben Paar beurteilen kann. Alles andere am +Vokabular bleibt in `instructions/link-taxonomy.md` und den `outbound:`-Blöcken. + +**Nicht dabei, bewusst.** Der Sweep der 180 bestehenden Kanten (#48) - diese Version ändert außer +den vier Urheberschaftskanten keine Korpus-Kante. Verworfen wurden außerdem `variant-of` (die +Wine-Forks tragen bereits `depends-on Wine`), `implemented-by` (Spiegel von `implements`, den die +Inbound-View rendert), `sibling-of` für die Concept-Cliquen (Über-Verlinkung, kein fehlendes Wort) +und `builds-on` (Vokabularkollision mit `extends`/`derived-from`/`adapted-from`). + +--- + ## 4.6.1 - 2026-09-04 - DEVELOPMENT.md im Kommandotabellen-Check, selbstbeschriftete Release-Notes, Prosa-Korrekturen (#47 Block 3) **Author:** Torben Nehmer diff --git a/VERSION b/VERSION index 8ac28bf..f6cdf40 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.6.1 +4.7.0 diff --git a/instructions/link-taxonomy.md b/instructions/link-taxonomy.md index 87e58a5..6e18196 100644 --- a/instructions/link-taxonomy.md +++ b/instructions/link-taxonomy.md @@ -44,6 +44,16 @@ That is why most labels below have no inverse. Only three pairs do, because in e direction is a genuine primary statement someone would write on its own: `depends-on` / `required-by`, `runs-on` / `hosts`, and `composition` / `part-of`. +**A self-dual label is still written once.** `alternative-to` is its own inverse - the sentence +reads identically from either end - and that makes it the easiest label in the catalogue to +write twice by reflex. Symmetry means the relation holds in both directions, not that both pages +must declare it: one edge per pair, and the other page's inbound view carries it. The difference +is not cosmetic at scale. Seven mutually substitutable tools are 21 pairs; declared once each +that is 21 edges, declared from both ends it is 42, and the second 21 say nothing the first did +not. This is the shape a `see-also` clique already had in this corpus before the labels existed, +and relabelling such a clique without dropping to one edge per pair moves the problem rather +than fixing it. + The third was added after the 4.0.0 migration, from measurement rather than from the desk. A parent-child structure - a tier list and its tiers, a spectrum and its levels - produces the question on nearly every page: the parent writes `composition`, and the child then reaches for @@ -100,10 +110,23 @@ entity to entity. | `consumes` | — | reads the target as an artifact or data | | `maintains` | — | carries the upkeep of the target | | `owns` | — | is accountable for the target's existence and decisions | +| `authored` | — | created the target as a one-time act | +| `alternative-to` | itself | serves the same purpose as the target, so a reader choosing between them wants both | `uses` versus `depends-on` is the distinction worth keeping sharp: if removing the target breaks -this thing, it is `depends-on`. `owns` versus `maintains`: accountability versus labour, and -they are often different people. +this thing, it is `depends-on`. + +`authored`, `owns` and `maintains` are three different sentences about the same pair, and often +three different people: origination, accountability, labour. `owns` is a *standing* claim - it +says someone answers for this thing now - so it reads false about a person who is dead or long +gone from the project, however plainly they made it. That is the case `authored` exists for, and +picking `owns` for it is not a weaker edge but a wrong one. + +`alternative-to` versus `contrasts` versus `compares-with`: `contrasts` asserts a *difference +worth reading both for*, `alternative-to` asserts *substitutability* - two things a reader might +pick between for the same job. `compares-with` weighs them on named dimensions, which in this +instance is what routes to a `kb/comparisons/` page. Two agent CLIs are `alternative-to`; two +opposed design principles are `contrasts`, and swapping the two says something false about both. ### Realization @@ -135,6 +158,7 @@ Inference and comparison between ideas. | `contrasts` | differs from the target in a way worth reading both for | | `compares-with` | is weighed against the target on shared dimensions | | `contradicts` | asserts something the target denies | +| `addresses` | is a response to the problem the target describes | | `composition` | is composed of the target | | `part-of` | is a component of the target | @@ -149,6 +173,13 @@ about the same fact. listed separately rather than as inverses because either page may legitimately carry only its own side. +`addresses` is the edge from a solution to the problem it answers - a decision to the trouble +that forced it, a mechanism to the failure it prevents. Keep it apart from `rests-on`, which +takes the target as a *premise* the source argues from: a decision usually does both, and the +one worth writing is the one a reader here would follow. `addresses` has no inverse. The problem +page's inbound view already answers "what did anyone do about this?", which is the only reason +someone would want the reverse. + ### Lineage Where something came from, and what replaced it. diff --git a/kb/concepts/COLLECTION.md b/kb/concepts/COLLECTION.md index c62dbea..2fc230e 100644 --- a/kb/concepts/COLLECTION.md +++ b/kb/concepts/COLLECTION.md @@ -1,8 +1,8 @@ --- profile: concepts outbound: - concepts: [extends, grounds, rests-on, enables, precondition, exemplifies, abstracted-from, contrasts, compares-with, contradicts, composition, part-of, supersedes, derived-from, adapted-from, see-also] - entities: [operationalized-from, mechanism, procedure, applies-when, operates-on, invokes, exemplifies, see-also] + concepts: [extends, grounds, rests-on, enables, precondition, exemplifies, abstracted-from, contrasts, compares-with, contradicts, addresses, alternative-to, composition, part-of, supersedes, derived-from, adapted-from, see-also] + entities: [operationalized-from, mechanism, procedure, applies-when, operates-on, invokes, exemplifies, alternative-to, see-also] sources: [evidenced-by, derived-from, adapted-from, defined-in, see-also] comparisons: [compares-with, see-also] required_by_stack: false @@ -58,6 +58,11 @@ nothing on its own. The widest authorisation in this instance, because argumentation is what concept pages do. Note that the operational labels are absent: a concept does not `depend-on` anything - the entity implementing it does. +`addresses` is the one that pairs with this collection's own subtypes: a `concept_type: decision` +or a mechanism pointing at the `concept_type: problem` it answers. Without it, the collection can +declare a problem and never say what was done about it. `alternative-to` is self-dual and written +once per pair - see [instructions/link-taxonomy.md](../../instructions/link-taxonomy.md). + Adding a label here is a deliberate contract change, not a way around a refusal. ## Outbound linking diff --git a/kb/entities/COLLECTION.md b/kb/entities/COLLECTION.md index 3f2d772..7e6f20f 100644 --- a/kb/entities/COLLECTION.md +++ b/kb/entities/COLLECTION.md @@ -1,8 +1,8 @@ --- profile: entities outbound: - entities: [depends-on, required-by, runs-on, hosts, uses, produces, consumes, maintains, owns, part-of, composition, supersedes, see-also] - concepts: [implements, exemplifies, rests-on, applies-when, operates-on, invokes, see-also] + entities: [depends-on, required-by, runs-on, hosts, uses, produces, consumes, maintains, owns, authored, alternative-to, implements, part-of, composition, supersedes, derived-from, adapted-from, see-also] + concepts: [implements, exemplifies, rests-on, applies-when, operates-on, invokes, authored, alternative-to, see-also] sources: [evidenced-by, defined-in, see-also] comparisons: [compares-with, see-also] required_by_stack: false @@ -57,6 +57,14 @@ nothing on its own. Operational labels dominate here because an entity's relationships are mostly to other concrete things. `implements` points *out* to a concept; the concept does not point back unless that direction is a statement of its own. +Three of these carry a caveat this area produces more often than the others. `authored` belongs +on a person page pointing at what they made, and it is the label a dead or departed creator +takes - `owns` claims someone answers for the thing *now*. `alternative-to` is self-dual and is +written **once per pair**, never from both ends: a set of interchangeable tools is where a +mirrored clique grows fastest. `derived-from` and `adapted-from` are here for the fork and the +re-implementation - one tool worked up out of another - which is a lineage claim the operational +labels cannot make. + Adding a label here is a deliberate contract change, not a way around a refusal. ## Outbound linking diff --git a/kb/entities/people/Andrej Karpathy.md b/kb/entities/people/Andrej Karpathy.md index f2b9ba0..6341ef6 100644 --- a/kb/entities/people/Andrej Karpathy.md +++ b/kb/entities/people/Andrej Karpathy.md @@ -5,9 +5,9 @@ tags: [researcher, ai, machine-learning, open-source] created: 2026-07-26 modified: 2026-08-29 related: - - see-also: LLM Wiki Pattern - - see-also: Three-Layer Architecture - - see-also: Knowledge Compounding + - authored: LLM Wiki Pattern + - authored: Three-Layer Architecture + - authored: Knowledge Compounding sources: [Source - LLM Wiki v2, Source - LLM Wiki Pattern] confidence: 0.95 confidence_base: 0.95 @@ -42,7 +42,7 @@ Seine ursprüngliche Einsicht - "stop re-deriving, start compiling" - bildet die ## Beziehungen -- **see-also:** [[LLM Wiki Pattern]] -- **see-also:** [[Three-Layer Architecture]] -- **see-also:** [[Knowledge Compounding]] +- **authored:** [[LLM Wiki Pattern]] +- **authored:** [[Three-Layer Architecture]] +- **authored:** [[Knowledge Compounding]] diff --git a/kb/entities/people/Vannevar Bush.md b/kb/entities/people/Vannevar Bush.md index 0d0b9b0..969e74f 100644 --- a/kb/entities/people/Vannevar Bush.md +++ b/kb/entities/people/Vannevar Bush.md @@ -6,7 +6,7 @@ created: 2026-07-26 modified: 2026-08-29 related: - see-also: LLM Wiki Pattern - - owns: Memex + - authored: Memex sources: [Source - LLM Wiki Pattern] confidence: 0.90 confidence_base: 0.90 @@ -72,5 +72,5 @@ Laut dem Artikel [[LLM Wiki Pattern]] war Bushs Memex-Vision: ## Beziehungen - **see-also:** [[LLM Wiki Pattern]] -- **owns:** [[Memex]] +- **authored:** [[Memex]] diff --git a/tools/chemenu/evals/scorecard.py b/tools/chemenu/evals/scorecard.py index bc900e8..d85a5a5 100644 --- a/tools/chemenu/evals/scorecard.py +++ b/tools/chemenu/evals/scorecard.py @@ -30,6 +30,7 @@ ADVISORY_KEYS = ( "unmarked_provenance", "missing_from_index", "title_mismatches", + "redundant_see_also", ) diff --git a/tools/chemenu/links.py b/tools/chemenu/links.py index 433b982..4046a0c 100644 --- a/tools/chemenu/links.py +++ b/tools/chemenu/links.py @@ -33,6 +33,17 @@ from typing import Any, Iterable, Optional # so `lint` cannot be satisfied by declaring the placeholder legal. UNLABELLED = None +# The one catalogue label this tool knows by name. Everything else about the +# vocabulary lives in `instructions/link-taxonomy.md` and each collection's +# `outbound:` block, on purpose - an instance may authorise any label it likes +# and the tool never has an opinion about which. `see-also` is the exception +# because it is the catalogue's declared last resort: it asserts only that +# nothing better fit, which is what lets `lint` judge it as *weaker than* +# another edge on the same pair rather than merely different. No behaviour +# depends on the string beyond that comparison, and an instance that dropped +# `see-also` from every contract would simply never see the finding. +SEE_ALSO = "see-also" + @dataclass(frozen=True) class Edge: diff --git a/tools/chemenu/lint_core.py b/tools/chemenu/lint_core.py index cb51c76..3c12a61 100644 --- a/tools/chemenu/lint_core.py +++ b/tools/chemenu/lint_core.py @@ -181,6 +181,26 @@ def run_lint(kb_dir: Path) -> dict: malformed_edges: list[dict] = [] unlabelled_edges: list[dict] = [] unauthorised_labels: list[dict] = [] + redundant_see_also: list[dict] = [] + + # Every specific thing any page asserts about any pair, collected before the + # loop below because the reverse direction of an edge is not known while + # standing on the page that carries it. + # + # What it is for: `see-also` is the catalogue's declared last resort - it + # asserts that nothing better fit. When the *other* page already says + # something specific about the same pair (`Wine GE depends-on Wine` opposite + # `Wine see-also Wine GE`), the weak edge adds nothing a reader did not + # have: direction is authored but the inbound view is rendered, so the + # labelled edge already shows on both pages. Measured once on this corpus, + # that was 57 of 180 `see-also` edges - the largest single class, and none + # of it a vocabulary gap. + typed_edges: dict[tuple[str, str], str] = {} + for source_title, source_page in pages.items(): + for edge in links.edges(source_page.frontmatter, "related"): + if edge.is_labelled and edge.label != links.SEE_ALSO: + typed_edges[(source_title, edge.target)] = edge.label + for title, page in sorted(pages.items()): type_path = page.frontmatter.get("type") if not type_path: @@ -211,6 +231,16 @@ def run_lint(kb_dir: Path) -> dict: if not edge.is_labelled: unlabelled_edges.append({"page": title, "target": edge.target}) continue + if edge.label == links.SEE_ALSO: + reverse_label = typed_edges.get((edge.target, title)) + if reverse_label is not None: + redundant_see_also.append( + { + "page": title, + "target": edge.target, + "reverse_label": reverse_label, + } + ) target_page = pages.get(edge.target) if source_collection is None or target_page is None: continue @@ -301,6 +331,7 @@ def run_lint(kb_dir: Path) -> dict: "malformed_edges": malformed_edges, "unlabelled_edges": unlabelled_edges, "unauthorised_labels": unauthorised_labels, + "redundant_see_also": redundant_see_also, "unbalanced_markers": unbalanced_marker_findings, "quote_limit_violations": quote_limit_violations, "invalid_type_paths": invalid_type_paths, @@ -403,6 +434,11 @@ def render_markdown(report: dict) -> str: report.get("unauthorised_labels", []), lambda i: f"[[{i['page']}]] `{i['label']}` -> kb/{i['destination']}/ ([[{i['target']}]])", ) + _section( + lines, "Redundant see-also (the other page already says something specific)", + report.get("redundant_see_also", []), + lambda i: f"[[{i['page']}]] `see-also` -> [[{i['target']}]], but [[{i['target']}]] already asserts `{i['reverse_label']}` back - drop the weaker edge, the inbound view renders the other one here", + ) _section( lines, "Dangling Frontmatter References", report["dangling_frontmatter_refs"], lambda i: f"[[{i['page']}]] `{i['field']}:` names `{i['target']}`, which is not a page", @@ -485,7 +521,13 @@ def default_report_path(report: dict) -> Path: # Findings that make a tree structurally wrong rather than merely untidy. # `orphan_pages` is deliberately absent: many pages are validly reachable # through the index or navigation only. `quote_limit_violations` is advisory -# too - it flags a habit, not a broken tree. +# too - it flags a habit, not a broken tree. `redundant_see_also` joins them for +# both of those reasons at once: a weak edge beside a specific one is redundant +# rather than wrong, and the check arrived long after the corpora it judges, so +# promoting it would turn every existing instance red on the upgrade that +# shipped it. Unlike `unlabelled_edges` it is not migration-gated either - there +# is no version at which the redundancy becomes an error, only a sweep someone +# does or does not get to. # # `malformed_edges` and `unbalanced_markers` are hard from the start: neither # describes an unconverted page, only a broken one. diff --git a/tools/chemenu/tests/test_lint.py b/tools/chemenu/tests/test_lint.py index a75f604..9f1a966 100644 --- a/tools/chemenu/tests/test_lint.py +++ b/tools/chemenu/tests/test_lint.py @@ -579,3 +579,93 @@ def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(kb_dir): migration for a gated finding to be the noise of.""" assert kb_state.read_kb_version() is None assert hard_error_keys() == HARD_ERROR_KEYS + + +# --- `redundant_see_also` -------------------------------------------------- +# +# `see-also` is the catalogue's declared last resort. The finding is about the +# case where the *other* page already said something specific about the same +# pair, so the weak edge carries nothing the inbound view did not already +# render. Measured once on this repo's corpus, that was 57 of 180 see-also +# edges - which is why it is worth a check rather than a habit. + + +def _pair(kb_dir, forward, backward): + """Two tool pages asserting `forward` and `backward` about each other.""" + for name, edge in (("nearside", forward), ("farside", backward)): + other = "farside" if name == "nearside" else "nearside" + write_page( + kb_dir / f"entities/tools/{name}.md", + {"type": "types/entity.md", "entity_type": "tool", "tags": [], + "created": "2026-09-04", "modified": "2026-09-04", + "related": [] if edge is None else [{edge: other}], + "sources": [], "confidence": 0.8, "provenance": "general", + "summary": f"One half of a pair, asserting {edge} about the other."}, + f"\n# {name}\n\nHalf a pair.\n", + ) + + +def test_see_also_is_redundant_when_the_other_page_asserts_something_specific(kb_dir): + """`nearside see-also farside` beside `farside depends-on nearside`: the + labelled edge already shows on both pages, so the weak one says nothing.""" + _pair(kb_dir, "see-also", "depends-on") + report = run_lint(kb_dir) + assert { + "page": "nearside", "target": "farside", "reverse_label": "depends-on" + } in report["redundant_see_also"] + + +def test_a_see_also_with_no_reverse_edge_at_all_is_not_redundant(kb_dir): + """The ordinary case the label exists for - nothing more specific fits, and + the other page says nothing back.""" + _pair(kb_dir, "see-also", None) + assert [ + i for i in run_lint(kb_dir)["redundant_see_also"] if i["page"] == "nearside" + ] == [] + + +def test_a_mutual_see_also_pair_is_not_reported_here(kb_dir): + """Two weak edges about one pair is a different finding - a mirror, which + the catalogue's 'direction is authored, never mirrored' rule covers and a + corpus sweep resolves. This check must not claim it: it is about a weak + edge standing beside a *specific* one, and reporting the mutual case here + would tell an author to drop an edge without saying which.""" + _pair(kb_dir, "see-also", "see-also") + assert [ + i for i in run_lint(kb_dir)["redundant_see_also"] + if i["page"] in ("nearside", "farside") + ] == [] + + +def test_the_specific_edge_is_never_the_one_reported(kb_dir): + """Only the `see-also` side is a finding. Reporting the labelled edge too + would make the pair unfixable - dropping both loses the assertion.""" + _pair(kb_dir, "see-also", "depends-on") + assert [ + i for i in run_lint(kb_dir)["redundant_see_also"] if i["page"] == "farside" + ] == [] + + +def test_redundant_see_also_is_advisory_at_every_kb_version(kb_dir): + """Redundant, not wrong - and the check arrived long after the corpora it + judges, so promoting it would turn every existing instance red on the + upgrade that shipped it. Unlike `unlabelled_edges` there is no version at + which it becomes an error, so it is not migration-gated either.""" + _pair(kb_dir, "see-also", "depends-on") + report = run_lint(kb_dir) + assert report["redundant_see_also"] != [] + for version in (Version(3, 0, 0), Version(4, 0, 0), Version(5, 0, 0)): + kb_state.write_kb_state(version, []) + assert "redundant_see_also" not in hard_error_keys() + assert has_hard_errors({"redundant_see_also": report["redundant_see_also"]}) is False + + +def test_redundant_see_also_reaches_the_rendered_report_and_the_summary(kb_dir): + """A finding nobody prints is a finding nobody acts on. `render_summary` + drops every empty section, so this also proves the section is not empty.""" + _pair(kb_dir, "see-also", "depends-on") + report = run_lint(kb_dir) + assert "Redundant see-also" in render_markdown(report) + summary = render_summary(report) + assert "Redundant see-also" in summary + assert "[[nearside]]" in summary and "depends-on" in summary