fix: comparison and source pages accept the sources: cite add writes; sources may cite sources (#173)
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:
1 parent
662a844cf1
commit
862bc04d9c
15 files changed
+310
-19
No files matched your search
+42
-1
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8.0.0-beta.39 - 2026-10-04 - wiki-ingest and wiki-manage call xref add with --rel, not the --rel-a/--rel-b removed in 4.0.0
|
## 8.0.0-beta.40 - 2026-10-04 - Comparison and source pages accept the sources: that cite add writes; sources may cite sources
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
@@ -107,6 +107,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
- Link-Katalog: Beteiligungs- und RACI-Label von der Projektseite aus
|
- Link-Katalog: Beteiligungs- und RACI-Label von der Projektseite aus
|
||||||
- Organisationsseiten: Personen als Abschnitt mit Aufstieg, entity_type organization, member-of, Lint-Befund broken_anchors
|
- Organisationsseiten: Personen als Abschnitt mit Aufstieg, entity_type organization, member-of, Lint-Befund broken_anchors
|
||||||
- lint: Unfilled Template Sections - a section still holding only its template's TODO placeholders (advisory)
|
- lint: Unfilled Template Sections - a section still holding only its template's TODO placeholders (advisory)
|
||||||
|
- Comparison and source pages accept the sources: that cite add writes; sources may cite sources
|
||||||
|
|
||||||
**Low impact**
|
**Low impact**
|
||||||
- version bump no longer points at version release in its output
|
- version bump no longer points at version release in its output
|
||||||
@@ -152,6 +153,46 @@ concern - readable here, never shipped as something to parse.
|
|||||||
- wiki-ingest and wiki-manage call xref add with --rel, not the --rel-a/--rel-b removed in 4.0.0
|
- wiki-ingest and wiki-manage call xref add with --rel, not the --rel-a/--rel-b removed in 4.0.0
|
||||||
<!-- /wikitool:bumps -->
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
### Comparison and source pages accept the sources: that cite add writes; sources may cite sources
|
||||||
|
|
||||||
|
`cite add` writes the cited source into the page's `sources:`, but neither the `comparison` nor
|
||||||
|
the `source` type declared that field, and both schemas set `additionalProperties: false`. So a
|
||||||
|
comparison page cited the way `kb/comparisons/COLLECTION.md` asks for, or a source page citing one
|
||||||
|
of its own raw files with `--file`, failed `lint` with `'sources' was unexpected`. Both types now
|
||||||
|
declare `sources` as an optional array, in the schema and in `page_ref_fields:`. The second part
|
||||||
|
matters: `rename`/`rm` carry only declared reference fields, and `lint` resolves only declared
|
||||||
|
fields. A fix to the schema alone would leave dead `sources:` entries behind after a source is
|
||||||
|
renamed, and nothing would report them.
|
||||||
|
|
||||||
|
That also makes a citation between two sources legal: `cite add --page "Source - A" --source
|
||||||
|
"Source - B"` records `Source - B` on A and leaves B alone. Two changes keep that edge directed:
|
||||||
|
|
||||||
|
- `cite add` no longer writes a page's own title into its `sources:`. A source page citing its own
|
||||||
|
raw file gets the footnote definition and leaves `sources:` untouched, not even as an empty
|
||||||
|
list. `lint` and `citing_pages()` already ignored the self-edge.
|
||||||
|
- `xref link-source` refuses a target that is itself a source page. It writes nothing for that
|
||||||
|
target, names it with the `cite add` command to use instead, links the others and exits 1. It
|
||||||
|
used to write `B.sources += A`, claiming the citation in the wrong direction, onto a page whose
|
||||||
|
schema then rejected it.
|
||||||
|
|
||||||
|
`kb/CONTRACT.md` § Provenance and citation and `wiki-ingest` step 9 describe both.
|
||||||
|
|
||||||
|
**What an existing instance has to do by hand.** The schema and type-spec of `comparison` and
|
||||||
|
`source` belong to the instance once adopted. `dist upgrade` only renews the `.template` beside
|
||||||
|
them, so the type half of this change does not arrive on its own. For each of the two types:
|
||||||
|
|
||||||
|
1. Copy the `sources` property from `types/<type>.schema.yaml.template` into
|
||||||
|
`types/<type>.schema.yaml`.
|
||||||
|
2. Add `sources` to `page_ref_fields:` in `types/<type>.md`. Without it a rename leaves the
|
||||||
|
entries pointing at the old title.
|
||||||
|
3. Copy the `sources` row of the frontmatter table into the same file.
|
||||||
|
|
||||||
|
An instance that has already loosened only the schema still needs step 2. The `cite add` and
|
||||||
|
`link-source` changes are under `tools/` and arrive with the normal upgrade. Until the steps
|
||||||
|
are done, `lint` keeps reporting a cited comparison or source page as a schema error, exactly as
|
||||||
|
before. Nothing that worked before breaks. Found while writing the first comparison page of an
|
||||||
|
instance on 7.0.0 (Gitea #173).
|
||||||
|
|
||||||
### wiki-ingest and wiki-manage call xref add with --rel, not the --rel-a/--rel-b removed in 4.0.0
|
### wiki-ingest and wiki-manage call xref add with --rel, not the --rel-a/--rel-b removed in 4.0.0
|
||||||
|
|
||||||
Since 4.0.0 `xref add` declares one directed edge and takes a single `--rel`, but the
|
Since 4.0.0 `xref add` declares one directed edge and takes a single `--rel`, but the
|
||||||
|
|||||||
@@ -351,6 +351,10 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
call of its own, is `kb/CONTRACT.md` § Linking. The second links the new source to everything
|
call of its own, is `kb/CONTRACT.md` § Linking. The second links the new source to everything
|
||||||
it backs in one pass.
|
it backs in one pass.
|
||||||
|
|
||||||
|
An older source page the new one cites does not go into `--entities`: `link-source` refuses
|
||||||
|
it. Record it as a citation instead, `tools/wikitool cite add --page "Source - <Title>"
|
||||||
|
--source "<older source>"` - `kb/CONTRACT.md` § Provenance and citation has the rule.
|
||||||
|
|
||||||
10. **Check coverage.**
|
10. **Check coverage.**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+11
-4
@@ -300,7 +300,8 @@ Every claim is either traceable to a raw file or explicitly marked as not.
|
|||||||
[--file <qualifier>]` mints the id, upserts its `[[Source - X]]` (or
|
[--file <qualifier>]` mints the id, upserts its `[[Source - X]]` (or
|
||||||
`[[Source - X|storage-model.md]]` for a multi-file source) definition in the page's trailing
|
`[[Source - X|storage-model.md]]` for a multi-file source) definition in the page's trailing
|
||||||
Footnotes block (named per [Section headings](#section-headings)), and adds `Source - X` to
|
Footnotes block (named per [Section headings](#section-headings)), and adds `Source - X` to
|
||||||
`sources:` - it prints the marker to paste at
|
`sources:` - unless the page *is* `Source - X`, citing one of its own raw files, since a page
|
||||||
|
never lists its own title there. It prints the marker to paste at
|
||||||
the fact; placing it is still manual. Never hand-type a cite-id (AGENTS.md invariant 1). This
|
the fact; placing it is still manual. Never hand-type a cite-id (AGENTS.md invariant 1). This
|
||||||
differs from a plain `[[Source - X]]` link, which only means "related to".
|
differs from a plain `[[Source - X]]` link, which only means "related to".
|
||||||
- **Notation inside code is notation, not a reference.** A `[^cite-id]` or a `[[wikilink]]`
|
- **Notation inside code is notation, not a reference.** A `[^cite-id]` or a `[[wikilink]]`
|
||||||
@@ -309,13 +310,19 @@ Every claim is either traceable to a raw file or explicitly marked as not.
|
|||||||
means a marker appended to a line *inside* a fence cites nothing - put it on a source line
|
means a marker appended to a line *inside* a fence cites nothing - put it on a source line
|
||||||
under the block (`<source-word>: [^cite-id]`, in the KB language), where it renders as a
|
under the block (`<source-word>: [^cite-id]`, in the KB language), where it renders as a
|
||||||
footnote instead of travelling with the command when someone copies it.
|
footnote instead of travelling with the command when someone copies it.
|
||||||
- A source cited inline must also appear in the page's frontmatter `sources:` list;
|
- **A source page may cite another source page**, the same way any page does: `cite add --page
|
||||||
`wikitool lint` checks this in both directions, and hard-errors on a leftover pre-migration
|
"Source - A" --source "Source - B"` writes `Source - B` into A's `sources:` and leaves B
|
||||||
|
untouched. A citation between two sources has a direction, and `cite add` is the only command
|
||||||
|
that records it - `xref link-source` refuses a target that is itself a source page.
|
||||||
|
- A source cited inline must also appear in the page's frontmatter `sources:` list - except a
|
||||||
|
source page's citation of itself, which never does; `wikitool lint` checks this in both
|
||||||
|
directions, and hard-errors on a leftover pre-migration
|
||||||
`^[[...]]` marker, an undefined `[^cite-id]` reference, or an orphaned Footnotes definition.
|
`^[[...]]` marker, an undefined `[^cite-id]` reference, or an orphaned Footnotes definition.
|
||||||
`tools/wikitool cite sync` reconciles a page's block after a prose edit changes which ids are
|
`tools/wikitool cite sync` reconciles a page's block after a prose edit changes which ids are
|
||||||
actually referenced.
|
actually referenced.
|
||||||
- `tools/wikitool xref link-source --source "Source - X" --entities A,B,C` adds a new source
|
- `tools/wikitool xref link-source --source "Source - X" --entities A,B,C` adds a new source
|
||||||
to every page it backs in one pass.
|
to every page it backs in one pass. Its targets are the pages the source *mentions*, never
|
||||||
|
another source page.
|
||||||
- Every raw file is expected to be claimed by some source page;
|
- Every raw file is expected to be claimed by some source page;
|
||||||
`tools/wikitool sources coverage` lists the ones that are not.
|
`tools/wikitool sources coverage` lists the ones that are not.
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -755,12 +755,14 @@ Batch-link a source page to every entity/concept it mentions.
|
|||||||
- 0 success
|
- 0 success
|
||||||
- 1 Source page not found
|
- 1 Source page not found
|
||||||
- 1 A page in `--entities` does not exist, or a target could not be written; the others were linked
|
- 1 A page in `--entities` does not exist, or a target could not be written; the others were linked
|
||||||
|
- 1 A page in `--entities` is itself a source page; the others were linked
|
||||||
- 1 The source page itself could not be written after its targets were
|
- 1 The source page itself could not be written after its targets were
|
||||||
|
|
||||||
**ON FAILURE**
|
**ON FAILURE**
|
||||||
|
|
||||||
- Source page not found -> Fix the title and retry once
|
- Source page not found -> Fix the title and retry once
|
||||||
- A page in `--entities` does not exist, or a target could not be written; the others were linked -> Fix the named pages and re-run - safe, since every write is idempotent. `sources trace --page "<Title>"` shows who is already linked
|
- A page in `--entities` does not exist, or a target could not be written; the others were linked -> Fix the named pages and re-run - safe, since every write is idempotent. `sources trace --page "<Title>"` shows who is already linked
|
||||||
|
- A page in `--entities` is itself a source page; the others were linked -> Drop it from `--entities` and record the citation with `cite add --page "<citing source>" --source "<cited source>"` instead
|
||||||
- The source page itself could not be written after its targets were -> Fix the write failure and re-run (idempotent)
|
- The source page itself could not be written after its targets were -> Fix the write failure and re-run (idempotent)
|
||||||
|
|
||||||
**NOTES**
|
**NOTES**
|
||||||
@@ -769,6 +771,7 @@ Batch-link a source page to every entity/concept it mentions.
|
|||||||
- Which field a target lands in follows its collection (`kb/entities/` -> `entities:`), so a new collection needs no code change.
|
- Which field a target lands in follows its collection (`kb/entities/` -> `entities:`), so a new collection needs no code change.
|
||||||
- A target whose collection matches no reference field the source type declares is linked one way and named in the output.
|
- A target whose collection matches no reference field the source type declares is linked one way and named in the output.
|
||||||
- A target that does not exist is skipped and named; the others are still linked, and the run then exits 1.
|
- A target that does not exist is skipped and named; the others are still linked, and the run then exits 1.
|
||||||
|
- A target that is itself a source page is refused, named with the `cite add` command to use instead, and handled like a missing page: nothing is written for it, the others are still linked, and the run exits 1. `link-source` means "A mentions X" and writes `X.sources += A`, which for X = another source would claim the citation in the wrong direction; a citation between two sources has a direction only `cite add` records.
|
||||||
- Idempotent in both directions. `--dry-run` reports what would be linked.
|
- Idempotent in both directions. `--dry-run` reports what would be linked.
|
||||||
|
|
||||||
**SEE ALSO**
|
**SEE ALSO**
|
||||||
@@ -897,7 +900,8 @@ Upsert a `[^cite-id]: [[Source - X]]` definition in a page's footnotes region.
|
|||||||
|
|
||||||
- Upserts a `[^cite-id]: [[Source - X]]` definition in the page's generated footnotes region, creating the region between `<!-- wikitool:footnotes -->` markers if absent.
|
- Upserts a `[^cite-id]: [[Source - X]]` definition in the page's generated footnotes region, creating the region between `<!-- wikitool:footnotes -->` markers if absent.
|
||||||
- Reuses the id when the page already cites this exact source/file pair, so a repeat changes nothing.
|
- Reuses the id when the page already cites this exact source/file pair, so a repeat changes nothing.
|
||||||
- Adds `Source - X` to the page's frontmatter `sources:`.
|
- Adds `Source - X` to the page's frontmatter `sources:` - except on `Source - X` itself: a source page citing one of its own raw files (`--file`) gets the definition and leaves `sources:` untouched, since a page never lists its own title there.
|
||||||
|
- A source page may cite another source page this way - `cite add` is the command that records a citation between two sources, in the direction it was made.
|
||||||
- Prints the `[^cite-id]` marker; pasting it into the prose is a manual, editorial step.
|
- Prints the `[^cite-id]` marker; pasting it into the prose is a manual, editorial step.
|
||||||
- `--dry-run` reports without writing.
|
- `--dry-run` reports without writing.
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,12 @@ def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) ->
|
|||||||
"""Ensure `page` has a Footnotes definition for (source_title, qualifier)
|
"""Ensure `page` has a Footnotes definition for (source_title, qualifier)
|
||||||
and that source_title is in its frontmatter `sources:`. Returns
|
and that source_title is in its frontmatter `sources:`. Returns
|
||||||
(cite_id_to_use, new_body, changed) - reuses an existing definition for
|
(cite_id_to_use, new_body, changed) - reuses an existing definition for
|
||||||
the same pair instead of minting a duplicate id."""
|
the same pair instead of minting a duplicate id.
|
||||||
|
|
||||||
|
A source page citing itself (one of its own raw files, via `--file`) gets
|
||||||
|
the definition but no `sources:` entry - not even an empty list. An edge
|
||||||
|
to itself carries nothing, and `lint` and `provenance.citing_pages()`
|
||||||
|
already ignore it."""
|
||||||
head, definitions = split_cite_block(page.body)
|
head, definitions = split_cite_block(page.body)
|
||||||
|
|
||||||
existing_id = next(
|
existing_id = next(
|
||||||
@@ -103,10 +108,12 @@ def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) ->
|
|||||||
definitions[marker_id] = (source_title, qualifier)
|
definitions[marker_id] = (source_title, qualifier)
|
||||||
block_changed = True
|
block_changed = True
|
||||||
|
|
||||||
sources = page.frontmatter.setdefault("sources", [])
|
sources_changed = False
|
||||||
sources_changed = source_title not in sources
|
if source_title != page.title:
|
||||||
if sources_changed:
|
sources = page.frontmatter.setdefault("sources", [])
|
||||||
sources.append(source_title)
|
sources_changed = source_title not in sources
|
||||||
|
if sources_changed:
|
||||||
|
sources.append(source_title)
|
||||||
|
|
||||||
new_body = render_page_body(head, definitions)
|
new_body = render_page_body(head, definitions)
|
||||||
changed = block_changed or sources_changed or new_body != page.body
|
changed = block_changed or sources_changed or new_body != page.body
|
||||||
@@ -131,7 +138,11 @@ def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) ->
|
|||||||
"region, creating the region between `<!-- wikitool:footnotes -->` markers if absent.",
|
"region, creating the region between `<!-- wikitool:footnotes -->` markers if absent.",
|
||||||
"Reuses the id when the page already cites this exact source/file pair, so a repeat "
|
"Reuses the id when the page already cites this exact source/file pair, so a repeat "
|
||||||
"changes nothing.",
|
"changes nothing.",
|
||||||
"Adds `Source - X` to the page's frontmatter `sources:`.",
|
"Adds `Source - X` to the page's frontmatter `sources:` - except on `Source - X` "
|
||||||
|
"itself: a source page citing one of its own raw files (`--file`) gets the definition "
|
||||||
|
"and leaves `sources:` untouched, since a page never lists its own title there.",
|
||||||
|
"A source page may cite another source page this way - `cite add` is the command that "
|
||||||
|
"records a citation between two sources, in the direction it was made.",
|
||||||
"Prints the `[^cite-id]` marker; pasting it into the prose is a manual, editorial "
|
"Prints the `[^cite-id]` marker; pasting it into the prose is a manual, editorial "
|
||||||
"step.",
|
"step.",
|
||||||
"`--dry-run` reports without writing.",
|
"`--dry-run` reports without writing.",
|
||||||
@@ -168,7 +179,7 @@ def cite_add(
|
|||||||
):
|
):
|
||||||
"""Upsert a Footnotes definition for `--source` (reusing it if the page
|
"""Upsert a Footnotes definition for `--source` (reusing it if the page
|
||||||
already cites the same source/file pair) and ensure `--source` is in the
|
already cites the same source/file pair) and ensure `--source` is in the
|
||||||
page's frontmatter `sources:`. Prints the `[^cite-id]` marker to paste
|
page's frontmatter `sources:` (unless the page is `--source` itself). Prints the `[^cite-id]` marker to paste
|
||||||
into the prose - placing it is still the caller's job.
|
into the prose - placing it is still the caller's job.
|
||||||
"""
|
"""
|
||||||
pages = load_kb_pages(config.KB_DIR)
|
pages = load_kb_pages(config.KB_DIR)
|
||||||
|
|||||||
@@ -389,6 +389,11 @@ def xref_remove(
|
|||||||
"linked one way and named in the output.",
|
"linked one way and named in the output.",
|
||||||
"A target that does not exist is skipped and named; the others are still linked, and "
|
"A target that does not exist is skipped and named; the others are still linked, and "
|
||||||
"the run then exits 1.",
|
"the run then exits 1.",
|
||||||
|
"A target that is itself a source page is refused, named with the `cite add` command to "
|
||||||
|
"use instead, and handled like a missing page: nothing is written for it, the others "
|
||||||
|
"are still linked, and the run exits 1. `link-source` means \"A mentions X\" and writes "
|
||||||
|
"`X.sources += A`, which for X = another source would claim the citation in the wrong "
|
||||||
|
"direction; a citation between two sources has a direction only `cite add` records.",
|
||||||
"Idempotent in both directions. `--dry-run` reports what would be linked.",
|
"Idempotent in both directions. `--dry-run` reports what would be linked.",
|
||||||
),
|
),
|
||||||
failures=(
|
failures=(
|
||||||
@@ -402,6 +407,11 @@ def xref_remove(
|
|||||||
reaction="Fix the named pages and re-run - safe, since every write is idempotent. "
|
reaction="Fix the named pages and re-run - safe, since every write is idempotent. "
|
||||||
"`sources trace --page \"<Title>\"` shows who is already linked",
|
"`sources trace --page \"<Title>\"` shows who is already linked",
|
||||||
),
|
),
|
||||||
|
cli_contract.Failure(
|
||||||
|
cause="A page in `--entities` is itself a source page; the others were linked",
|
||||||
|
reaction="Drop it from `--entities` and record the citation with "
|
||||||
|
"`cite add --page \"<citing source>\" --source \"<cited source>\"` instead",
|
||||||
|
),
|
||||||
cli_contract.Failure(
|
cli_contract.Failure(
|
||||||
cause="The source page itself could not be written after its targets were",
|
cause="The source page itself could not be written after its targets were",
|
||||||
reaction="Fix the write failure and re-run (idempotent)",
|
reaction="Fix the write failure and re-run (idempotent)",
|
||||||
@@ -431,6 +441,7 @@ def xref_link_source(
|
|||||||
|
|
||||||
linked: list[str] = []
|
linked: list[str] = []
|
||||||
skipped: list[str] = []
|
skipped: list[str] = []
|
||||||
|
refused: list[str] = []
|
||||||
failed: list[str] = []
|
failed: list[str] = []
|
||||||
unrouted: list[str] = []
|
unrouted: list[str] = []
|
||||||
source_changed = False
|
source_changed = False
|
||||||
@@ -439,6 +450,13 @@ def xref_link_source(
|
|||||||
if page is None:
|
if page is None:
|
||||||
skipped.append(name)
|
skipped.append(name)
|
||||||
continue
|
continue
|
||||||
|
if page.kind == "source":
|
||||||
|
# Refused rather than linked: this would write `name.sources +=
|
||||||
|
# source` ("name cites source" - the wrong direction), and the
|
||||||
|
# back-reference would add `source.sources += name` as well. Which
|
||||||
|
# of two sources cites the other is only said by `cite add`.
|
||||||
|
refused.append(name)
|
||||||
|
continue
|
||||||
sources = page.frontmatter.setdefault("sources", [])
|
sources = page.frontmatter.setdefault("sources", [])
|
||||||
if source not in sources:
|
if source not in sources:
|
||||||
sources.append(source)
|
sources.append(source)
|
||||||
@@ -489,6 +507,12 @@ def xref_link_source(
|
|||||||
typer.echo(f"{verb} source '{source}' to: {', '.join(linked)}")
|
typer.echo(f"{verb} source '{source}' to: {', '.join(linked)}")
|
||||||
if skipped:
|
if skipped:
|
||||||
typer.echo(f"Skipped (page not found): {', '.join(skipped)}")
|
typer.echo(f"Skipped (page not found): {', '.join(skipped)}")
|
||||||
|
for name in refused:
|
||||||
|
typer.echo(
|
||||||
|
f"Refused (a source page - record a citation between sources with "
|
||||||
|
f"`wikitool cite add --page \"{source}\" --source \"{name}\"` if '{source}' cites it, "
|
||||||
|
f"or the other way round): {name}"
|
||||||
|
)
|
||||||
if failed:
|
if failed:
|
||||||
typer.echo(f"Failed to write (fix and re-run for just these names): {', '.join(failed)}")
|
typer.echo(f"Failed to write (fix and re-run for just these names): {', '.join(failed)}")
|
||||||
|
|
||||||
@@ -496,10 +520,12 @@ def xref_link_source(
|
|||||||
typer.echo("No files written (--dry-run).")
|
typer.echo("No files written (--dry-run).")
|
||||||
return
|
return
|
||||||
|
|
||||||
if skipped or failed:
|
if skipped or refused or failed:
|
||||||
parts = []
|
parts = []
|
||||||
if skipped:
|
if skipped:
|
||||||
parts.append(f"page(s) not found: {', '.join(skipped)}")
|
parts.append(f"page(s) not found: {', '.join(skipped)}")
|
||||||
|
if refused:
|
||||||
|
parts.append(f"source page(s) refused as targets: {', '.join(refused)}")
|
||||||
if failed:
|
if failed:
|
||||||
parts.append(f"page(s) failed to write: {', '.join(failed)}")
|
parts.append(f"page(s) failed to write: {', '.join(failed)}")
|
||||||
fail(f"Linked {len(linked)}/{len(names)} page(s); " + "; ".join(parts))
|
fail(f"Linked {len(linked)}/{len(names)} page(s); " + "; ".join(parts))
|
||||||
|
|||||||
@@ -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"])
|
result = runner.invoke(app, ["cite", "sync", "--all", "--dry-run"])
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
assert "No pages needed a Footnotes block change." in 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
|
||||||
@@ -193,6 +193,36 @@ def test_rename_fixes_a_dangling_source_reference(patched_wiki):
|
|||||||
assert frontmatter["sources"] == ["Source - Aurora Notes"]
|
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):
|
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 -
|
"""A write failing partway through must not still rename the target file -
|
||||||
that would leave some references pointing at the new title and others
|
that would leave some references pointing at the new title and others
|
||||||
|
|||||||
@@ -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."""
|
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/entity.md") == ["related", "sources"]
|
||||||
assert resolver.get_page_ref_fields("types/concept.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/source.md") == ["entities", "concepts", "sources"]
|
||||||
assert resolver.get_page_ref_fields("types/comparison.md") == ["entities", "related"]
|
assert resolver.get_page_ref_fields("types/comparison.md") == ["entities", "related", "sources"]
|
||||||
|
|
||||||
|
|
||||||
def test_page_ref_fields_exist_in_the_type_schema():
|
def test_page_ref_fields_exist_in_the_type_schema():
|
||||||
|
|||||||
@@ -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
|
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 ------------------------------------------------------
|
# --- the inbound view ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ name: comparison
|
|||||||
description: Structured type for comparison pages that set several entities or approaches against one another
|
description: Structured type for comparison pages that set several entities or approaches against one another
|
||||||
schema: types/comparison.schema.yaml
|
schema: types/comparison.schema.yaml
|
||||||
base_dir: comparisons
|
base_dir: comparisons
|
||||||
page_ref_fields: [entities, related]
|
page_ref_fields: [entities, related, sources]
|
||||||
guidance: types/comparison.guidance.md
|
guidance: types/comparison.guidance.md
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ and how to write a conforming page is [types/comparison.guidance.md](comparison.
|
|||||||
| `created` | Yes | Creation date (YYYY-MM-DD) |
|
| `created` | Yes | Creation date (YYYY-MM-DD) |
|
||||||
| `entities` | Yes | Titles of the entities compared |
|
| `entities` | Yes | Titles of the entities compared |
|
||||||
| `related` | No | Declared outbound edges - one `compares-with` edge per subject, written by `wikitool xref add` |
|
| `related` | No | Declared outbound edges - one `compares-with` edge per subject, written by `wikitool xref add` |
|
||||||
|
| `sources` | No | Titles of the source pages cited on this page, written by `wikitool cite add` |
|
||||||
| `summary` | Yes | One-liner for `kb/index.md` |
|
| `summary` | Yes | One-liner for `kb/index.md` |
|
||||||
|
|
||||||
## Template
|
## Template
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ properties:
|
|||||||
provenance-style list, so without this one the edge the page exists to
|
provenance-style list, so without this one the edge the page exists to
|
||||||
make would live only in hand-written prose - which is the thing
|
make would live only in hand-written prose - which is the thing
|
||||||
instructions/link-taxonomy.md was built to end.
|
instructions/link-taxonomy.md was built to end.
|
||||||
|
sources:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
Source page titles cited on this page, written by `wikitool cite add`.
|
||||||
|
Optional, so a comparison that cites nothing keeps validating without it.
|
||||||
summary:
|
summary:
|
||||||
type: string
|
type: string
|
||||||
description: 1-line summary for index.md
|
description: 1-line summary for index.md
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,7 @@ schema: types/source.schema.yaml
|
|||||||
subtype_field: source_type
|
subtype_field: source_type
|
||||||
base_dir: sources
|
base_dir: sources
|
||||||
title_prefix: "Source - "
|
title_prefix: "Source - "
|
||||||
page_ref_fields: [entities, concepts]
|
page_ref_fields: [entities, concepts, sources]
|
||||||
capture_fields: [fidelity, authority]
|
capture_fields: [fidelity, authority]
|
||||||
guidance: types/source.guidance.md
|
guidance: types/source.guidance.md
|
||||||
layout:
|
layout:
|
||||||
@@ -42,6 +42,7 @@ how to write a conforming page is [types/source.guidance.md](source.guidance.md)
|
|||||||
| `tags` | No | Navigation tags for categorization |
|
| `tags` | No | Navigation tags for categorization |
|
||||||
| `entities` | No | Titles of the entities mentioned in this source |
|
| `entities` | No | Titles of the entities mentioned in this source |
|
||||||
| `concepts` | No | Titles of the concepts mentioned in this source |
|
| `concepts` | No | Titles of the concepts mentioned in this source |
|
||||||
|
| `sources` | No | Titles of *other* source pages this source cites, written by `wikitool cite add` - never its own title |
|
||||||
| `summary` | Yes | One-liner for `kb/index.md` |
|
| `summary` | Yes | One-liner for `kb/index.md` |
|
||||||
|
|
||||||
## Template
|
## Template
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ properties:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Concept titles mentioned in this source
|
description: Concept titles mentioned in this source
|
||||||
|
sources:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
Other source pages this source cites, written by `wikitool cite add`.
|
||||||
|
Never this page's own title - a citation of one of its own raw files
|
||||||
|
leaves the field untouched. Optional, so a source that cites no other
|
||||||
|
source keeps validating without it.
|
||||||
summary:
|
summary:
|
||||||
type: string
|
type: string
|
||||||
description: 1-line summary for index.md
|
description: 1-line summary for index.md
|
||||||
|
|||||||
Reference in new issue
Block a user