fix: comparison and source pages accept the sources: cite add writes; sources may cite sources (#173)
CI / verify (push) Successful in 5m20s
CI / pwsh (push) Successful in 2m2s
Release / release (push) Successful in 36s

Files changed:
- CHANGES.md
- VERSION
- instructions/wiki-ingest/SKILL.md
- kb/CONTRACT.md
- tools/CONTRACT.md
- tools/chemenu/commands/cite_cmd.py
- tools/chemenu/commands/xref.py
- tools/chemenu/tests/test_cite_cmd.py
- tools/chemenu/tests/test_page_ops.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/tests/test_xref.py
- types/comparison.md
- types/comparison.schema.yaml
- types/source.md
- types/source.schema.yaml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-04 21:35:24 +02:00
1 parent 662a844cf1
commit 862bc04d9c
15 files changed
+310 -19

No files matched your search

+118
View File
@@ -203,3 +203,121 @@ def test_cite_sync_command_over_kb(kb_dir, raw_dir, monkeypatch):
result = runner.invoke(app, ["cite", "sync", "--all", "--dry-run"])
assert result.exit_code == 0, result.output
assert "No pages needed a Footnotes block change." in result.output
# --- citing on comparison and source pages ----------------------------------
#
# Both types once declared no `sources:` field, while `cite add` wrote one onto
# every page it touched - so a correctly cited comparison or source page failed
# `lint` with `'sources' was unexpected`, under `additionalProperties: false`.
def _write_source(kb_dir, title, raw_file):
"""A source page the shipped schema accepts as it stands - the fixture's own
`Source - Aurora` carries a stray `source:` key and would mask the finding."""
from chemenu.frontmatter_io import write_page
path = kb_dir / "sources/notes" / f"{title}.md"
write_page(
path,
{
"type": "types/source.md", "source_type": "notes", "author": "Fixture Author",
"raw_files": [raw_file], "date": "2026-08-02", "summary": f"{title} summary",
},
f"\n# Source: {title}\n\n## Summary\n\nNotes.\n",
)
return path
def _schema_errors_for(kb_dir, title):
from chemenu.lint_core import run_lint
return [e for e in run_lint(kb_dir)["schema_validation_errors"] if e["page"] == title]
def test_cite_add_on_a_comparison_page_keeps_it_schema_valid(kb_dir, raw_dir):
from chemenu.cli import app
from chemenu.frontmatter_io import write_page
path = kb_dir / "comparisons/aurora vs Borealis.md"
write_page(
path,
{
"type": "types/comparison.md", "created": "2026-08-02",
"entities": ["aurora", "Borealis"], "summary": "Server against workstation",
},
"\n# Comparison: aurora vs Borealis\n\n## Überblick\n\nTwo machines.\n",
)
assert _schema_errors_for(kb_dir, "aurora vs Borealis") == []
result = runner.invoke(
app, ["cite", "add", "--page", "aurora vs Borealis", "--source", "Source - Aurora"]
)
assert result.exit_code == 0, result.output
fm, _body = read_page(path)
assert fm["sources"] == ["Source - Aurora"]
assert _schema_errors_for(kb_dir, "aurora vs Borealis") == []
def test_a_source_page_may_cite_another_source_page(kb_dir, raw_dir):
"""The citation lands on the citing side only, in the direction it was made."""
from chemenu.cli import app
from chemenu.provenance import citing_pages
path_a = _write_source(kb_dir, "Source - A", "raw/notes/Aurora.md")
path_b = _write_source(kb_dir, "Source - B", "raw/notes/Uningested.md")
before_b = path_b.read_bytes()
result = runner.invoke(app, ["cite", "add", "--page", "Source - A", "--source", "Source - B"])
assert result.exit_code == 0, result.output
fm_a, _body = read_page(path_a)
assert fm_a["sources"] == ["Source - B"]
assert path_b.read_bytes() == before_b
assert _schema_errors_for(kb_dir, "Source - A") == []
assert "Source - A" in citing_pages(load_kb_pages(kb_dir), "Source - B")
def test_a_source_citing_its_own_file_writes_no_sources_entry(kb_dir, raw_dir):
"""A self-citation names one of the page's own raw files. The definition is
written; `sources:` is left exactly as it was - not even an empty list."""
path = _write_source(kb_dir, "Source - A", "raw/notes/Aurora.md")
page = _reload(kb_dir, "Source - A")
assert "sources" not in page.frontmatter
marker_id, body, changed = upsert_citation(page, "Source - A", "Aurora.md")
assert changed is True
assert "sources" not in page.frontmatter
assert f"[^{marker_id}]: [[Source - A|Aurora.md]]" in body
page.body = body
_id, _body, changed_again = upsert_citation(page, "Source - A", "Aurora.md")
assert changed_again is False
assert path.exists()
def test_a_self_citation_leaves_an_existing_sources_list_unchanged(kb_dir, raw_dir):
_write_source(kb_dir, "Source - A", "raw/notes/Aurora.md")
page = _reload(kb_dir, "Source - A")
page.frontmatter["sources"] = ["Source - Aurora"]
upsert_citation(page, "Source - A", "Aurora.md")
assert page.frontmatter["sources"] == ["Source - Aurora"]
def test_cite_add_self_citation_on_disk(kb_dir, raw_dir):
from chemenu.cli import app
path = _write_source(kb_dir, "Source - A", "raw/notes/Aurora.md")
args = ["cite", "add", "--page", "Source - A", "--source", "Source - A", "--file", "Aurora.md"]
result = runner.invoke(app, args)
assert result.exit_code == 0, result.output
fm, body = read_page(path)
assert "sources" not in fm
assert f"[^{cite_id('Source - A', 'Aurora.md')}]: [[Source - A|Aurora.md]]" in body
assert _schema_errors_for(kb_dir, "Source - A") == []
again = runner.invoke(app, args)
assert again.exit_code == 0, again.output
assert "Already up to date" in again.output
+30
View File
@@ -193,6 +193,36 @@ def test_rename_fixes_a_dangling_source_reference(patched_wiki):
assert frontmatter["sources"] == ["Source - Aurora Notes"]
def test_rename_carries_sources_on_comparison_and_source_pages(patched_wiki):
"""Both types declare `sources` in `page_ref_fields:`. A schema-only fix
would have let them validate, but a rename would then have left a dead
`sources:` entry behind on each - unreported, since `lint` resolves only
the declared fields too."""
from chemenu.lint_core import run_lint
write_page(
patched_wiki / "comparisons/aurora vs Borealis.md",
{"type": "types/comparison.md", "created": "2026-08-02",
"entities": ["aurora", "Borealis"], "sources": ["Source - Aurora"],
"summary": "Server against workstation"},
"\n# Comparison: aurora vs Borealis\n",
)
write_page(
patched_wiki / "sources/notes/Source - B.md",
{"type": "types/source.md", "source_type": "notes", "author": "Fixture Author",
"raw_files": ["raw/notes/Uningested.md"], "date": "2026-08-02",
"sources": ["Source - Aurora"], "summary": "B"},
"\n# Source: B\n",
)
page_ops.rename_command(old="Source - Aurora", new="Source - Aurora Notes", dry_run=False)
for path in ("comparisons/aurora vs Borealis.md", "sources/notes/Source - B.md"):
frontmatter, _body = read_page(patched_wiki / path)
assert frontmatter["sources"] == ["Source - Aurora Notes"]
dangling = {e["page"] for e in run_lint(patched_wiki)["dangling_frontmatter_refs"]}
assert not dangling & {"aurora vs Borealis", "Source - B"}
def test_rename_stops_before_renaming_the_file_if_a_reference_write_fails(patched_wiki, monkeypatch):
"""A write failing partway through must not still rename the target file -
that would leave some references pointing at the new title and others
+2 -2
View File
@@ -45,8 +45,8 @@ def test_get_page_ref_fields_reads_the_type_spec():
so `lint`/`rename`/`rm` need no hardcoded list to update for a new type."""
assert resolver.get_page_ref_fields("types/entity.md") == ["related", "sources"]
assert resolver.get_page_ref_fields("types/concept.md") == ["related", "sources"]
assert resolver.get_page_ref_fields("types/source.md") == ["entities", "concepts"]
assert resolver.get_page_ref_fields("types/comparison.md") == ["entities", "related"]
assert resolver.get_page_ref_fields("types/source.md") == ["entities", "concepts", "sources"]
assert resolver.get_page_ref_fields("types/comparison.md") == ["entities", "related", "sources"]
def test_page_ref_fields_exist_in_the_type_schema():
+32
View File
@@ -482,6 +482,38 @@ def test_xref_link_source_dry_run_leaves_the_source_page_alone(kb_dir):
assert path.read_text(encoding="utf-8") == before
def test_xref_link_source_refuses_a_target_that_is_itself_a_source(kb_dir):
"""`link-source` means "A mentions X" and writes `X.sources += A`; for X =
another source that claims "B cites A", and the back-reference would add
`A.sources += B` too. A citation between sources has a direction, and only
`cite add` says which one - so the target is refused, the rest still linked."""
from chemenu.frontmatter_io import write_page
runner, app = _runner_env(kb_dir)
path_b = kb_dir / "sources/notes/Source - B.md"
write_page(
path_b,
{"type": "types/source.md", "source_type": "notes", "author": "Fixture Author",
"raw_files": ["raw/notes/Uningested.md"], "date": "2026-08-02", "summary": "B"},
"\n# Source: B\n",
)
result = runner.invoke(
app,
["xref", "link-source", "--source", "Source - Aurora", "--entities", "gdeploy,Source - B"],
)
assert result.exit_code == 1
assert "Source - B" in result.output
assert 'cite add --page "Source - Aurora" --source "Source - B"' in result.output
pages = load_kb_pages(kb_dir)
assert "Source - Aurora" in pages["gdeploy"].frontmatter["sources"]
source_a = pages["Source - Aurora"].frontmatter
source_b = pages["Source - B"].frontmatter
for field in ("sources", "entities", "concepts"):
assert "Source - B" not in (source_a.get(field) or [])
assert "Source - Aurora" not in (source_b.get(field) or [])
# --- the inbound view ------------------------------------------------------