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
350 lines
14 KiB
Python
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.")
|