Files
chemenu/tools/chemenu/commands/cite_cmd.py
T
torbenandClaude Opus 5.5 862bc04d9c
CI / verify (push) Successful in 5m20s
CI / pwsh (push) Successful in 2m2s
Release / release (push) Successful in 36s
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
2026-10-04 21:35:24 +02:00

350 lines
14 KiB
Python

"""`wikitool cite ...` - real GFM footnote citations.
A citation marker is `[^cite-id]` in a page's prose, resolved by a
`[^cite-id]: [[Source - X]]` (or `[[Source - X|file.md]]`) definition line in
the page's trailing Footnotes block (see chemenu.provenance for the
regexes and cite_id() derivation). AGENTS.md invariant 1 forbids hand-writing
generated structure, and a cite-id is exactly that - an author must never
compute or paste one by hand. `cite add` is the only way to get one onto a
page; `cite sync` is the only way to reconcile a page's block after prose
edits changed which ids are actually referenced.
None of this writes the inline `[^cite-id]` reference into prose: where a
citation belongs in a sentence is an editorial call, same as the prose itself
(see tools/CONTRACT.md's design notes). `cite add` prints the marker to paste
in; the LLM places it.
"""
from __future__ import annotations
from typing import Optional
import typer
from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success
from chemenu.frontmatter_io import write_page
from chemenu.page import Page
from chemenu.kb_scan import load_kb_pages
from chemenu.provenance import (
CITE_REF_RE,
cite_id,
render_page_body,
split_cite_block,
unique_cite_id,
)
app = typer.Typer(help="Manage [^cite-id] footnote citations and their Footnotes definition blocks.")
def _find_page(pages: dict[str, Page], title: str) -> Page:
if title not in pages:
fail(f"No page titled '{title}' found under kb/.")
return pages[title]
@app.command("id")
@cli_contract.record(cli_contract.CommandRecord(
path="cite id",
summary="Print the deterministic footnote id `cite add` would use for this (title, file) pair.",
synopsis=(cli_contract.Variant(usage='cite id --title "Source - X" [--file <qualifier>]'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes=(
"Prints the footnote id `cite add` would use for this (title, file) pair.",
"A preview only: it does not check that the id is free on any given page.",
"Never fails; safe to retry freely. Read-only and exempt from the Iteration Budget "
"Gate.",
),
failures=(),
examples=(
'tools/wikitool cite id --title "Source - Docker Cheatsheet"',
),
never=(
"Never paste an id from here into a page by hand - `cite add` writes the definition and "
"prints the marker to paste.",
),
see_also=(
"`wikitool cite add` - writes the definition",
),
))
def cite_id_command(
title: str = typer.Option(..., "--title", help="Source page title, e.g. 'Source - Docker Cheatsheet'"),
file: Optional[str] = typer.Option(None, "--file", help="Qualifier for a multi-file source, e.g. 'storage-model.md'"),
):
"""Print the deterministic id cite_id() would derive for (--title, --file).
Read-only preview - does not check the id is actually free on any given
page (two pages, or two distinct pairs on one page, can share this base
id; `cite add`/`cite sync` are what apply the real -2/-3 suffixing).
"""
typer.echo(cite_id(title, file))
def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) -> tuple[str, str, bool]:
"""Ensure `page` has a Footnotes definition for (source_title, qualifier)
and that source_title is in its frontmatter `sources:`. Returns
(cite_id_to_use, new_body, changed) - reuses an existing definition for
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)
existing_id = next(
(cid for cid, pair in definitions.items() if pair == (source_title, qualifier)),
None,
)
if existing_id is not None:
marker_id = existing_id
block_changed = False
else:
marker_id = unique_cite_id(set(definitions), source_title, qualifier)
definitions[marker_id] = (source_title, qualifier)
block_changed = True
sources_changed = False
if source_title != page.title:
sources = page.frontmatter.setdefault("sources", [])
sources_changed = source_title not in sources
if sources_changed:
sources.append(source_title)
new_body = render_page_body(head, definitions)
changed = block_changed or sources_changed or new_body != page.body
return marker_id, new_body, changed
@app.command("add")
@cli_contract.record(cli_contract.CommandRecord(
path="cite add",
summary="Upsert a `[^cite-id]: [[Source - X]]` definition in a page's footnotes region.",
synopsis=(cli_contract.Variant(
usage='cite add --page "<Title>" --source "Source - X" [--file <qualifier>] [--dry-run]',
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - single file write",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"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.",
"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.",
"`--dry-run` reports without writing.",
),
failures=(
cli_contract.Failure(
cause="The page is not found",
reaction="Fix the title and retry once",
),
cli_contract.Failure(
cause="The source page is not found - citing it would be a dangling reference",
reaction="Fix the source title, or create the source page first, then retry once",
),
),
examples=(
'tools/wikitool cite add --page "Docker" --source "Source - Docker Cheatsheet"',
'tools/wikitool cite add --page "Docker" --source "Source - Docker Cheatsheet" '
"--file part-2",
),
never=(
"Never compute or type a `[^cite-id]` or its definition by hand - paste the marker this "
"prints.",
),
see_also=(
"`wikitool cite sync` - prunes and re-orders the region",
"`wikitool cite id` - previews an id",
),
))
def cite_add(
page_title: str = typer.Option(..., "--page", help="Exact title of the page to add a citation on"),
source: str = typer.Option(..., "--source", help="Exact title of the source page being cited, e.g. 'Source - X'"),
file: Optional[str] = typer.Option(None, "--file", help="Qualifier for a multi-file source, e.g. 'storage-model.md'"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview instead of writing"),
):
"""Upsert a Footnotes definition for `--source` (reusing it if the page
already cites the same source/file pair) and ensure `--source` is in the
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.
"""
pages = load_kb_pages(config.KB_DIR)
page = _find_page(pages, page_title)
if source not in pages:
fail(f"No page titled '{source}' found under kb/ - citing a page that doesn't exist would be a dangling reference.")
marker_id, new_body, changed = upsert_citation(page, source, file)
marker = f"[^{marker_id}]"
if dry_run:
state = "would update" if changed else "already up to date"
typer.echo(f"[dry-run] '{page_title}': {state}")
typer.echo(f"marker: {marker}")
typer.echo("No files written (--dry-run).")
return
if changed:
write_page(page.path, page.frontmatter, new_body)
typer.echo(f"marker: {marker}")
success(
f"{'Updated' if changed else 'Already up to date:'} '{page_title}' cites '{source}'"
+ (f" ({file})" if file else "")
+ f". Paste {marker} at the point in the prose the fact appears."
)
def sync_page(page: Page) -> tuple[str, bool, list[str], list[str]]:
"""Reconcile one page's Footnotes block against its actual `[^id]`
references: prune definitions nothing references any more, and re-render
the block in first-reference order. Never mints or recomputes an id from
a title - a reference with no definition is reported, not guessed at.
A page still carrying the pre-4.0.0 undelimited block is converted to a
marked region in the same pass: the marker pair, not the heading text,
carries the region's identity now, so re-rendering it under this
instance's heading is a repair rather than a rename.
Returns (new_body, changed, pruned_ids, undefined_ref_ids).
"""
head, definitions = split_cite_block(page.body)
referenced_ids = [m.group(1) for m in CITE_REF_RE.finditer(head)]
referenced_set = set(referenced_ids)
if not definitions and not referenced_ids:
# No citation content at all - leave the page's whitespace exactly as
# it is. Without this, re-rendering an empty block still normalizes
# trailing newlines, which would make `cite sync --all` rewrite every
# page in the wiki instead of just the ones it actually has work to do.
return page.body, False, [], []
pruned = [cid for cid in definitions if cid not in referenced_set]
undefined = sorted({cid for cid in referenced_ids if cid not in definitions})
ordered: dict[str, tuple[str, Optional[str]]] = {}
seen: set[str] = set()
for cid in referenced_ids:
if cid in definitions and cid not in seen:
ordered[cid] = definitions[cid]
seen.add(cid)
new_body = render_page_body(head, ordered)
changed = new_body != page.body
return new_body, changed, pruned, undefined
@app.command("sync")
@cli_contract.record(cli_contract.CommandRecord(
path="cite sync",
summary="Reconcile each page's footnotes region against its actual `[^id]` references.",
synopsis=(cli_contract.Variant(
usage='cite sync [--page "<Title>" | --all] [--dry-run]',
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - one write per page, each idempotent",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Prunes definitions nothing references any more, re-renders the region in "
"first-reference order, and reports every `[^id]` reference left with no definition.",
"An undefined-reference report is not a failure: fix the reference, or run `cite add`, "
"and re-run.",
"A page still carrying the pre-4.0.0 undelimited footnote block is converted to a "
"marked region in the same pass.",
"Each page's re-render is idempotent; safe to retry freely. `--dry-run` reports "
"without writing.",
),
failures=(
cli_contract.Failure(
cause="Neither or both of `--page`/`--all` given, or the page is not found",
reaction="Fix the arguments and retry once",
),
cli_contract.Failure(
cause="A page write failed partway",
reaction="Resolve the write failure and re-run - each page's re-render is idempotent",
),
),
examples=(
'tools/wikitool cite sync --page "Docker"',
"tools/wikitool cite sync --all --dry-run",
),
see_also=(
"`wikitool cite add` - writes a definition",
),
))
def cite_sync(
page_title: Optional[str] = typer.Option(None, "--page", help="Sync just this page"),
all_pages: bool = typer.Option(False, "--all", help="Sync every page under kb/"),
dry_run: bool = typer.Option(False, "--dry-run", help="Report what would change instead of writing"),
):
"""Prune orphan Footnotes definitions and re-render each page's block in
first-reference order. Reports any `[^id]` reference left with no
definition - that is an editorial gap (a citation whose `cite add` never
ran, or a hand-typed id), not something this command can fix."""
if bool(page_title) == bool(all_pages):
fail("Provide exactly one of --page or --all")
pages = load_kb_pages(config.KB_DIR)
targets = [_find_page(pages, page_title)] if page_title else sorted(pages.values(), key=lambda p: p.path)
touched: list[str] = []
undefined_report: dict[str, list[str]] = {}
failed: list[str] = []
for page in targets:
new_body, changed, pruned, undefined = sync_page(page)
title = page.path.stem
if undefined:
undefined_report[title] = undefined
if not changed:
continue
touched.append(title)
if dry_run:
continue
try:
write_page(page.path, page.frontmatter, new_body)
except OSError as exc:
failed.append(f"{title} ({exc})")
if failed:
fail(
f"Synced {len(touched) - len(failed)}/{len(touched)} page(s) before a write failed: "
f"{', '.join(failed)}. Safe to retry - each page's re-render is idempotent."
)
verb = "Would update" if dry_run else "Updated"
if touched:
typer.echo(f"{verb} {len(touched)} page(s):")
for title in touched:
typer.echo(f" - {title}")
else:
typer.echo("No pages needed a Footnotes block change.")
if undefined_report:
typer.echo("")
typer.echo("Undefined [^id] reference(s) - run `cite add` for these, or fix the typo:")
for title, ids in undefined_report.items():
typer.echo(f" - {title}: {', '.join(ids)}")
if dry_run:
typer.echo("No files written (--dry-run).")
return
if not touched and not undefined_report:
success("Every Footnotes block already matches its page's references.")