move: source_type: Default streichen, unclassified als sichtbares Fach, layout: fuer source (schliesst #66)
CI / verify (push) Successful in 53s
Release / release (push) Successful in 35s

Files changed:
- CHANGES.md
- VERSION
- instructions/dev/corpus-policy.md
- instructions/wiki-ingest/SKILL.md
- kb/index.md
- kb/log.md
- kb/sources/COLLECTION.md
- kb/sources/INDEX.md
- kb/sources/Source - AMD Powermanagement CPU.md
- kb/sources/Source - Arch Linux Cheat Sheet.md
- kb/sources/Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04.md
- kb/sources/Source - Conversation - Auto Mode and Tool Choice Session 2026-08-31.md
- kb/sources/Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31.md
- kb/sources/Source - Conversation - ENVIRONMENT.md as an Optional Third Session-Level File Session 2026-08-31.md
- kb/sources/Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31.md
- kb/sources/Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31.md
- kb/sources/Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31.md
- kb/sources/Source - Conversation - Nightly Drift-Check Workflow and doctor's Bootstrap Gap Session 2026-08-31.md
- kb/sources/Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31.md
- kb/sources/Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30.md
- kb/sources/Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31.md
- kb/sources/Source - Copilot Skill Restructure Instructions.md
- kb/sources/Source - Docker Cheatsheet.md
- kb/sources/Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md
- kb/sources/Source - Gitea Issues 62-63 - status-incoming Label Introduction 2026-09-04.md
- kb/sources/Source - LLM Improvements Codex Analysis.md
- kb/sources/Source - LLM Improvements Production Agent Gaps 2026.md
- kb/sources/Source - LLM Improvements Sonnet Analysis.md
- kb/sources/Source - LLM Wiki Pattern.md
- kb/sources/Source - LLM Wiki v2.md
- kb/sources/Source - MCP Read Server Implementation Session 2026-09-02.md
- kb/sources/Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01.md
- kb/sources/Source - Public Release, Corpus Purge and History Squash Session 2026-09-01.md
- kb/sources/Source - Publish-Remote Gate and Issue Triage Session 2026-09-01.md
- kb/sources/Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02.md
- kb/sources/Source - Wine.md
- kb/sources/Source - qmd - GitHub Repository.md
- kb/sources/analyses/Source - Copilot Skill Restructure Instructions.md
- kb/sources/analyses/Source - LLM Improvements Codex Analysis.md
- kb/sources/analyses/Source - LLM Improvements Production Agent Gaps 2026.md
- kb/sources/analyses/Source - LLM Improvements Sonnet Analysis.md
- kb/sources/articles/Source - AMD Powermanagement CPU.md
- kb/sources/articles/Source - LLM Wiki Pattern.md
- kb/sources/articles/Source - LLM Wiki v2.md
- kb/sources/documents/Source - qmd - GitHub Repository.md
- kb/sources/notes/Source - Arch Linux Cheat Sheet.md
- kb/sources/notes/Source - Docker Cheatsheet.md
- kb/sources/notes/Source - Wine.md
- kb/sources/trackers/Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md
- kb/sources/trackers/Source - Gitea Issues 62-63 - status-incoming Label Introduction 2026-09-04.md
- kb/sources/transcripts/Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04.md
- kb/sources/transcripts/Source - Conversation - Auto Mode and Tool Choice Session 2026-08-31.md
- kb/sources/transcripts/Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31.md
- kb/sources/transcripts/Source - Conversation - ENVIRONMENT.md as an Optional Third Session-Level File Session 2026-08-31.md
- kb/sources/transcripts/Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31.md
- kb/sources/transcripts/Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31.md
- kb/sources/transcripts/Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31.md
- kb/sources/transcripts/Source - Conversation - Nightly Drift-Check Workflow and doctor's Bootstrap Gap Session 2026-08-31.md
- kb/sources/transcripts/Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31.md
- kb/sources/transcripts/Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30.md
- kb/sources/transcripts/Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31.md
- kb/sources/transcripts/Source - MCP Read Server Implementation Session 2026-09-02.md
- kb/sources/transcripts/Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01.md
- kb/sources/transcripts/Source - Public Release, Corpus Purge and History Squash Session 2026-09-01.md
- kb/sources/transcripts/Source - Publish-Remote Gate and Issue Triage Session 2026-09-01.md
- kb/sources/transcripts/Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02.md
- tools/CONTRACT.md
- tools/chemenu/lint_core.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_provenance.py
- tools/chemenu/tests/test_touch.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/tests/test_xref.py
- types/source.md
- types/source.schema.yaml
- types/type-spec.md
- work/reclassify-source-types/README.md
- work/reclassify-source-types/plan.md
This commit is contained in:
2026-09-08 20:38:15 +02:00
parent 7f74303a00
commit b138fd8e64
51 changed files with 467 additions and 96 deletions
+29
View File
@@ -127,6 +127,22 @@ def misplaced_pages(pages: dict[str, Page]) -> list[dict]:
]
def unclassified_source_pages(pages: dict[str, Page]) -> list[dict]:
"""Source pages sitting in the `unclassified` catalog slot.
Advisory, like `misplaced_pages` above: `unclassified` is the visible
fallback for a genuinely unclear source (Gitea #66, replacing the old
silent `default: notes`), not a broken state - a page there is exactly as
valid as one anywhere else, only waiting for someone to look at it and
pick a real category with `wikitool touch --set source_type=<value>`."""
return [
{"page": title}
for title, page in sorted(pages.items())
if page.frontmatter.get("type") == "types/source.md"
and page.frontmatter.get("source_type") == "unclassified"
]
def nested_pages(kb_dir: Path, pages: dict[str, Page]) -> list[dict]:
"""Report form of `find_nested_pages`: `{"page", "at", "depth"}` per
finding.
@@ -474,6 +490,7 @@ def run_lint(kb_dir: Path) -> dict:
"misplaced_pages": misplaced,
"nested_pages": nested,
"unsharded_collections": unsharded_collections(kb_dir, pages),
"unclassified_source_pages": unclassified_source_pages(pages),
"uncovered_raw_files": find_uncovered_raw_files(config.RAW_DIR, pages),
"broken_raw_refs": find_broken_raw_refs(pages),
"duplicate_raw_file_owners": find_duplicate_raw_file_owners(pages),
@@ -567,6 +584,12 @@ def render_markdown(report: dict) -> str:
"`wikitool move --reconcile` and `wikitool index rebuild`"
),
)
_section(
lines, "Unclassified Source Pages (source_type: unclassified) - recommendation, not an error",
report.get("unclassified_source_pages", []),
lambda i: f"[[{i['page']}]] - `wikitool touch --set source_type=<value>` once its "
"category is known",
)
_section(
lines, "Uncovered Raw Files (no source page)", report["uncovered_raw_files"],
lambda i: f"`{i}`",
@@ -728,6 +751,12 @@ def default_report_path(report: dict) -> Path:
# Failing on it would also make `lint` red on a corpus that is entirely
# self-consistent, which is the state the recommendation is asking to improve.
#
# `unclassified_source_pages` is advisory for the same reason `misplaced_pages`
# is: `unclassified` (Gitea #66) is a deliberately visible catalog slot for a
# source whose category is genuinely unclear, not a defect - failing on it
# would penalise the honest "I don't know yet" that the slot exists to allow,
# where the old silent `default: notes` hid the same uncertainty for free.
#
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
# describes an unconverted page, only a broken one.
#
+1 -1
View File
@@ -311,7 +311,7 @@ def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"\n# Modbus\n\n## Definition\n\nIndustrial protocol.\n",
)
write_page(
kb / "sources/Source - Aurora.md",
kb / "sources/notes/Source - Aurora.md",
{
"type": "types/source.md", "source_type": "notes", "author": "Torben",
"source": "raw/notes/Aurora.md", "date": "2026-08-02",
+58 -11
View File
@@ -1,9 +1,11 @@
import json
import shutil
from datetime import date
import pytest
from chemenu import config, kb_state
from chemenu.type_resolver import resolver
from chemenu.commands.lint import (
HARD_ERROR_KEYS,
hard_error_keys,
@@ -98,7 +100,7 @@ def test_lint_detects_dangling_source_page_entity_ref(kb_dir):
"""Source pages declare `entities:`/`concepts:`, not `related:`/`sources:` -
the field list comes from each type-spec's own `page_ref_fields:`."""
write_page(
kb_dir / "sources/Source - Aurora.md",
kb_dir / "sources/notes/Source - Aurora.md",
{"type": "types/source.md", "source_type": "notes", "author": "Torben",
"source": "raw/notes/Aurora.md", "date": "2026-08-02", "tags": [],
"entities": ["aurora", "ghost-entity"], "concepts": []},
@@ -293,16 +295,59 @@ def test_lint_recommends_areas_for_a_collection_past_the_threshold(kb_dir):
assert finding["layout_declared"] is True
def test_the_area_recommendation_says_the_layout_is_missing_when_it_is(kb_dir):
"""`types/source.md` deliberately declares no `layout:`, so a sources
collection that grew past the threshold has nothing for `move` to compute a
destination from - the fix starts one step earlier, and the report says so."""
def test_the_area_recommendation_says_the_layout_is_missing_when_it_is(kb_dir, tmp_path, monkeypatch):
"""A collection whose type has a `subtype_field:` but no `layout:` has
nothing for `move` to compute a destination from - the fix starts one step
earlier, and the report says so.
`types/source.md` used to be the worked example, but Gitea #66 gave it a
`layout:` covering every enum value, and `comparison` (the other type with
no `layout:`) has no `subtype_field:` at all to exercise this branch with.
So this is now the one place in the suite that still needs the
"has a subtype axis, hasn't been given a layout" shape, which is a fixture
type-spec, copied in beside the real ones so every other fixture page
(entities, concepts, sources, comparisons) keeps resolving normally.
Filled into `comparisons/` rather than `sources/`: the fixture wiki's own
source page now lives correctly under `sources/notes/`, and reusing that
directory here would mix its real `types/source.md` pages into this
fixture type's counts - `comparisons/` stays empty in the base fixture,
so filling it is self-contained."""
types_dir = tmp_path / "types"
shutil.copytree(config._PACKAGE_ROOT / "types", types_dir)
(types_dir / "badtype.md").write_text(
"---\n"
"type: types/type-spec.md\n"
"name: badtype\n"
"description: fixture type with a subtype field and no layout\n"
"schema: types/badtype.schema.yaml\n"
"subtype_field: bad_type\n"
"base_dir: badthings\n"
"---\n\n# badtype\n",
encoding="utf-8",
)
(types_dir / "badtype.schema.yaml").write_text(
"type: object\n"
"properties:\n"
" type: {type: string, const: 'types/badtype.md'}\n"
" bad_type: {type: string}\n"
" created: {type: string, format: date}\n"
" modified: {type: string, format: date}\n"
" provenance: {type: string, enum: [sourced, general, mixed]}\n"
" summary: {type: string}\n"
"required: [type, bad_type, created, modified, provenance, summary]\n"
"additionalProperties: false\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
monkeypatch.setattr(resolver, "_repo_root", tmp_path)
_fill_collection(
kb_dir, "sources", "types/source.md", "source_type",
kb_dir, "comparisons", "types/badtype.md", "bad_type",
{"notes": 30, "article": 21},
)
report = run_lint(kb_dir)
finding = next(i for i in report["unsharded_collections"] if i["collection"] == "sources")
finding = next(i for i in report["unsharded_collections"] if i["collection"] == "comparisons")
assert finding["layout_declared"] is False
assert "Declare a `layout:`" in render_markdown(report)
@@ -323,10 +368,12 @@ def test_the_area_recommendation_is_not_a_failure(kb_dir):
def test_lint_is_silent_about_a_collection_under_the_threshold(kb_dir):
"""The sources shape: 29 pages, lopsided across `source_type` - and under
the threshold anyway, so it never fires. That is what keeps the bad split
(one area of 25 plus four splinters) from ever being recommended, without
the check needing to know anything about sources."""
"""The pre-#66 sources shape: 29 pages, lopsided across `source_type`
because the schema handed out `notes` as a default whenever nobody chose
a value - and under the threshold anyway, so it never fires regardless.
That is what kept the bad split (one area of 25 plus four splinters) from
ever being recommended while the default still existed, without the check
needing to know anything about sources."""
_fill_collection(
kb_dir, "sources", "types/source.md", "source_type",
{"notes": 25, "article": 3, "document": 1},
+32 -6
View File
@@ -43,8 +43,11 @@ def test_page_subdir_falls_back_for_unmapped_subtype():
def test_page_subdir_is_none_for_types_without_layout():
assert _page_subdir(None, "types/source.md") is None
assert _page_subdir("anything", "types/source.md") is None
# `comparison` has no `layout:` at all (and no `subtype_field:` to have one
# for) - `types/source.md` no longer qualifies, since Gitea #66 gave it a
# `layout:` covering every enum value.
assert _page_subdir(None, "types/comparison.md") is None
assert _page_subdir("anything", "types/comparison.md") is None
def test_coerce_set_value_uses_declared_schema_type():
@@ -190,21 +193,42 @@ def test_new_source_prefixes_title_and_prefills_related_entities(monkeypatch, kb
_fixture_raw_file(monkeypatch, kb_dir, "raw/notes/gateway.example.net.md")
result = _invoke_new(monkeypatch, kb_dir, [
"new", "source", "--name", "gateway.example.net",
"--set", "source_type=notes",
"--set", "raw_files=raw/notes/gateway.example.net.md",
"--set", "entities=aurora,Borealis",
])
assert result.exit_code == 0, result.output
path = kb_dir / "sources/Source - gateway.example.net.md"
# Gitea #66: `types/source.md` now declares a `layout:`, so `source_type:
# notes` lands in its area - the same placement rule an entity or concept
# already follows.
path = kb_dir / "sources/notes/Source - gateway.example.net.md"
assert path.exists()
fm, body = read_page(path)
assert fm["type"] == "types/source.md"
assert fm["entities"] == ["aurora", "Borealis"]
assert fm["author"] == "Torben" # WIKI_AUTHOR override
assert fm["source_type"] == "notes" # schema default
assert fm["source_type"] == "notes"
assert "[[aurora]]" in body
assert "[[Borealis]]" in body
def test_new_source_rejects_missing_source_type(monkeypatch, kb_dir):
"""Gitea #66: `source_type` lost its `default: notes` - `new` must refuse
rather than silently picking a value, and must not write a file or create
a directory while refusing."""
monkeypatch.setenv("WIKI_AUTHOR", "Torben") # isolate the refusal from the author check
_fixture_raw_file(monkeypatch, kb_dir, "raw/notes/gateway.example.net.md")
result = _invoke_new(monkeypatch, kb_dir, [
"new", "source", "--name", "no-source-type",
"--set", "raw_files=raw/notes/gateway.example.net.md",
])
assert result.exit_code != 0
assert "source_type" in result.output
# No page written anywhere under sources/ - not even into an area that
# legitimately pre-exists (the fixture wiki's own `sources/notes/`).
assert not list(kb_dir.glob("sources/**/Source - no-source-type.md"))
def test_new_source_author_falls_back_to_git_config(monkeypatch, kb_dir):
"""No WIKI_AUTHOR set - default_author() falls back to `git config
user.name`, run with cwd=config.ROOT.
@@ -225,10 +249,11 @@ def test_new_source_author_falls_back_to_git_config(monkeypatch, kb_dir):
_fixture_raw_file(monkeypatch, kb_dir, "raw/notes/gateway.example.net.md")
result = _invoke_new(monkeypatch, kb_dir, [
"new", "source", "--name", "git-config-author",
"--set", "source_type=notes",
"--set", "raw_files=raw/notes/gateway.example.net.md",
])
assert result.exit_code == 0, result.output
fm, _body = read_page(kb_dir / "sources/Source - git-config-author.md")
fm, _body = read_page(kb_dir / "sources/notes/Source - git-config-author.md")
assert fm["author"] == "Fixture Author"
@@ -336,6 +361,7 @@ def test_raw_files_error_points_at_the_comma_split(monkeypatch, kb_dir, raw_dir)
monkeypatch.setattr(config, "ROOT", kb_dir.parent)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "source", "--name", "Split Path",
"--set", "source_type=notes",
"--set", "raw_files=raw/notes/Versioning, CI-CD.md",
])
assert result.exit_code == 1
@@ -358,5 +384,5 @@ def test_source_page_accepts_a_raw_file_whose_name_has_a_comma(monkeypatch, kb_d
"--set", "source_type=notes",
])
assert result.exit_code == 0, result.output
frontmatter, _ = read_page(kb_dir / "sources/Source - Comma Source.md")
frontmatter, _ = read_page(kb_dir / "sources/notes/Source - Comma Source.md")
assert frontmatter["raw_files"] == ["raw/notes/Versioning, CI-CD.md"]
+3 -2
View File
@@ -400,10 +400,11 @@ def test_new_source_with_multiple_raw_files(kb_dir, raw_dir, monkeypatch):
result = runner.invoke(app, [
"new", "source", "--name", "Multi",
"--set", "source_type=notes",
"--set", "raw_files=raw/notes/Aurora.md,raw/notes/Second.md",
])
assert result.exit_code == 0, result.output
fm, _body = read_page(kb_dir / "sources/Source - Multi.md")
fm, _body = read_page(kb_dir / "sources/notes/Source - Multi.md")
assert fm["raw_files"] == ["raw/notes/Aurora.md", "raw/notes/Second.md"]
@@ -483,7 +484,7 @@ def test_lint_does_not_flag_source_page_self_citation(kb_dir, raw_dir, monkeypat
refs, block = _footnote_block(("Source - Aurora", "Aurora.md"))
write_page(
kb_dir / "sources/Source - Aurora.md",
kb_dir / "sources/notes/Source - Aurora.md",
{
"type": "source", "source_type": "notes", "author": "Torben",
"raw_files": ["raw/notes/Aurora.md"], "date": "2026-08-02",
+4 -4
View File
@@ -82,7 +82,7 @@ def test_touch_uses_date_field_for_source_pages(touch_wiki):
"""Source pages declare `date:`, not `modified:` - the field comes from
the type's schema rather than a hardcoded name."""
_touch(page_title="Source - Aurora", date="2026-08-13")
frontmatter, _ = read_page(touch_wiki / "sources/Source - Aurora.md")
frontmatter, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
assert str(frontmatter["date"]) == "2026-08-13"
assert "modified" not in frontmatter
@@ -92,9 +92,9 @@ def test_touch_leaves_a_sources_publication_date_alone(touch_wiki):
record of when the page was last edited. Auto-bumping it to today replaced a
fact about the world and left the page contradicting the date printed in its
own body - so it moves only on an explicit --date."""
before, _ = read_page(touch_wiki / "sources/Source - Aurora.md")
before, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
_touch(page_title="Source - Aurora", summary="Neue Zusammenfassung")
frontmatter, _ = read_page(touch_wiki / "sources/Source - Aurora.md")
frontmatter, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
assert frontmatter["summary"] == "Neue Zusammenfassung"
assert str(frontmatter["date"]) == str(before["date"])
@@ -221,7 +221,7 @@ def test_set_raw_files_accepts_an_existing_path_with_a_comma(touch_wiki, raw_dir
page_title="Source - Aurora",
set_fields=[r"raw_files=raw/notes/Versioning\, CI-CD.md"],
)
frontmatter, _ = read_page(touch_wiki / "sources/Source - Aurora.md")
frontmatter, _ = read_page(touch_wiki / "sources/notes/Source - Aurora.md")
assert frontmatter["raw_files"] == ["raw/notes/Versioning, CI-CD.md"]
+30 -8
View File
@@ -108,13 +108,33 @@ def test_concept_layout_covers_every_declared_concept_type():
assert all(spec.get("title") for spec in layout.values())
def test_source_layout_covers_every_declared_source_type():
"""Gitea #66: `source_type` had a silent `default: notes` that made the
collection lopsided (25 of 29 pages), which is what made #59 leave
`source` flat. Removing the default and reclassifying by what the pages
actually are turned it into six real areas - this is the concept-layout
test's counterpart for `source`, checking the same enum/layout agreement."""
layout = resolver.get_layout("types/source.md")
assert layout is not None
assert set(layout) == set(resolver.get_enum("types/source.md", "source_type"))
assert {key: spec["dir"] for key, spec in layout.items()} == {
"transcript": "transcripts",
"analysis": "analyses",
"article": "articles",
"document": "documents",
"notes": "notes",
"tracker": "trackers",
"unclassified": "unclassified",
}
assert all(spec.get("title") for spec in layout.values())
def test_get_layout_is_none_for_types_without_one():
"""`comparison` has no subtype field at all; `source` has one and
deliberately declares no `layout:` anyway - 25 of its 29 pages carry the
same `source_type`, so splitting on it would make one area and four
splinters (Gitea #59). Having a subtype axis is not a reason to use it."""
"""`comparison` has no subtype field at all, so there is nothing a
`layout:` could key on - the worked example for a type that stays flat
because the axis itself doesn't exist (as opposed to `source` pre-#66,
which had the axis but a lopsided distribution - see #59)."""
assert resolver.get_layout("types/comparison.md") is None
assert resolver.get_layout("types/source.md") is None
def test_get_layout_rejects_a_dir_with_a_path_separator(tmp_path):
@@ -262,7 +282,9 @@ def test_subtype_dir_falls_back_for_unmapped_subtype():
def test_subtype_dir_is_none_without_layout_or_subtype():
assert resolver.subtype_dir("types/source.md", "notes") is None
# `comparison` has no `layout:` (and no `subtype_field:` to have one for)
# - the value passed is arbitrary, since the type has no subtype axis at all.
assert resolver.subtype_dir("types/comparison.md", "whatever") is None
assert resolver.subtype_dir("types/entity.md", None) is None
@@ -272,8 +294,8 @@ def test_compute_target_dir_applies_layout_subdirectory():
def test_compute_target_dir_is_flat_for_a_type_without_layout():
target = resolver.compute_target_dir("types/source.md", {"source_type": "notes"})
assert target == config.KB_DIR / "sources"
target = resolver.compute_target_dir("types/comparison.md", {})
assert target == config.KB_DIR / "comparisons"
def test_compute_target_dir_resolves_against_repo_root_for_root_repo_types():
+2 -2
View File
@@ -426,7 +426,7 @@ def test_xref_remove_clears_an_undeclared_leftover_field(kb_dir):
page that already carries one is a dead end."""
from chemenu.frontmatter_io import write_page
path = kb_dir / "sources/Source - Aurora.md"
path = kb_dir / "sources/notes/Source - Aurora.md"
frontmatter, body = read_page(path)
frontmatter["related"] = ["aurora"]
write_page(path, frontmatter, body)
@@ -472,7 +472,7 @@ def test_xref_link_source_is_idempotent_on_the_source_page(kb_dir):
def test_xref_link_source_dry_run_leaves_the_source_page_alone(kb_dir):
runner, app = _runner_env(kb_dir)
path = kb_dir / "sources/Source - Aurora.md"
path = kb_dir / "sources/notes/Source - Aurora.md"
before = path.read_text(encoding="utf-8")
runner.invoke(
app,