feat: Prosa ist kein Identifier - Link-Taxonomie als Enum, generierte Regionen mit Markern (4.0.0)
Files changed: - .gitea/workflows/ci.yml - AGENTS.md - CHANGES.md - VERSION - instructions/CONTRACT.md - instructions/link-taxonomy.md - instructions/migrations/4.0.0-link-taxonomy.md - instructions/setup-instance.md - kb/CONTRACT.md - kb/CONVENTIONS.md - kb/CONVENTIONS.md.template - kb/comparisons/COLLECTION.md - kb/concepts/COLLECTION.md - kb/entities/COLLECTION.md - kb/sources/COLLECTION.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/blocks.py - tools/chemenu/cli.py - tools/chemenu/commands/cite_cmd.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/links_cmd.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/page_ops.py - tools/chemenu/commands/run_budget.py - tools/chemenu/commands/xref.py - tools/chemenu/conventions.py - tools/chemenu/corpus_diff.py - tools/chemenu/frontmatter_io.py - tools/chemenu/kb_collections.py - tools/chemenu/kb_state.py - tools/chemenu/links.py - tools/chemenu/lint_core.py - tools/chemenu/provenance.py - tools/chemenu/sections.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_blocks.py - tools/chemenu/tests/test_cite_cmd.py - tools/chemenu/tests/test_conventions.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_migrate_cmd.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_pipeline_l0.py - tools/chemenu/tests/test_types_cmd.py - tools/chemenu/tests/test_xref.py - types/concept.schema.yaml - types/entity.md - types/entity.schema.yaml - types/instruction.schema.yaml - types/type-spec.md - work/link-taxonomy-migration/README.md - work/link-taxonomy-migration/plan.md
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"""Generated regions inside a page body, found by delimiter rather than by prose.
|
||||
|
||||
`xref` owns the links block and `cite` owns the footnotes block. Both used to be
|
||||
located by matching their **heading text** - `^## Beziehungen$` - which made a
|
||||
translated heading a structural fact and put the KB language into the compiler.
|
||||
It also made the region's *end* a guess: the footnotes block ran to the next
|
||||
heading, and before that to the end of the file, which silently deleted whatever
|
||||
sat after it on eight pages.
|
||||
|
||||
A marker pair answers both questions exactly:
|
||||
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **depends-on:** [[Hermes]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
Everything between the markers is generated and is replaced wholesale on the
|
||||
next write - heading included, which is why the heading text is a *rendering*
|
||||
value from `kb/CONVENTIONS.md` rather than something the tool searches for. An
|
||||
author never edits inside the markers; anything they put there is overwritten
|
||||
without warning, exactly like `kb/index.md`.
|
||||
|
||||
The markers are HTML comments: invisible in every renderer this corpus is read
|
||||
through, and the same convention `dist:strip-start`/`-end` already uses in
|
||||
`AGENTS.md`. They cost a reader nothing and cost an LLM about twenty tokens a
|
||||
page - the price of not having to guess where a generated region ends.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
# The two regions the tool owns. `see-also` is deliberately absent: it was the
|
||||
# reciprocal half of the old bidirectional `xref add`, and under authored
|
||||
# directional edges it is a *label* (`see-also`) inside the links block, not a
|
||||
# section of its own.
|
||||
LINKS = "links"
|
||||
FOOTNOTES = "footnotes"
|
||||
BLOCKS = (LINKS, FOOTNOTES)
|
||||
|
||||
_NAME = r"[a-z][a-z0-9-]*"
|
||||
|
||||
|
||||
def open_marker(name: str) -> str:
|
||||
return f"<!-- wikitool:{name} -->"
|
||||
|
||||
|
||||
def close_marker(name: str) -> str:
|
||||
return f"<!-- /wikitool:{name} -->"
|
||||
|
||||
|
||||
def _region_re(name: str) -> re.Pattern[str]:
|
||||
"""The whole region including both markers and the blank line around it."""
|
||||
return re.compile(
|
||||
r"\n*"
|
||||
+ re.escape(open_marker(name))
|
||||
+ r".*?"
|
||||
+ re.escape(close_marker(name))
|
||||
+ r"\n*",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
_ANY_OPEN_RE = re.compile(rf"<!-- wikitool:({_NAME}) -->")
|
||||
_ANY_CLOSE_RE = re.compile(rf"<!-- /wikitool:({_NAME}) -->")
|
||||
|
||||
|
||||
def find(body: str, name: str) -> Optional[str]:
|
||||
"""The generated content of `name`'s region, markers excluded, or None."""
|
||||
match = _region_re(name).search(body)
|
||||
if not match:
|
||||
return None
|
||||
text = match.group(0)
|
||||
start = text.index(open_marker(name)) + len(open_marker(name))
|
||||
end = text.index(close_marker(name))
|
||||
return text[start:end].strip("\n")
|
||||
|
||||
|
||||
def render(name: str, heading: str, lines: list[str]) -> str:
|
||||
"""A whole region, ready to place into a body. Empty `lines` renders "".
|
||||
|
||||
An empty region is no region at all rather than a heading with nothing under
|
||||
it: a page that cites nothing should not carry an empty Footnotes section,
|
||||
and the same holds for a page with no declared edges.
|
||||
"""
|
||||
if not lines:
|
||||
return ""
|
||||
parts = [open_marker(name), f"## {heading}", "", *lines, close_marker(name)]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def replace(body: str, name: str, region: str) -> str:
|
||||
"""Put `region` where `name`'s region is, or append it if there is none.
|
||||
|
||||
Appending at the end is right for both blocks: they are the page's trailing
|
||||
machine-owned material, and an author's prose never follows them. A region
|
||||
that is `""` removes what was there.
|
||||
"""
|
||||
existing = _region_re(name).search(body)
|
||||
if existing:
|
||||
replacement = f"\n\n{region}\n" if region else "\n"
|
||||
return (body[: existing.start()] + replacement + body[existing.end():]).rstrip("\n") + "\n"
|
||||
if not region:
|
||||
return body
|
||||
return body.rstrip("\n") + "\n\n" + region + "\n"
|
||||
|
||||
|
||||
def strip(body: str, name: str) -> str:
|
||||
"""The body with `name`'s region removed entirely."""
|
||||
return replace(body, name, "")
|
||||
|
||||
|
||||
def marker_pairs(body: str) -> dict[str, int]:
|
||||
"""How many complete open/close pairs each region name has in `body`.
|
||||
|
||||
The invariant `migrate verify` checks. An agent rewriting prose next to a
|
||||
boundary can drop or duplicate a marker, and the failure is otherwise silent:
|
||||
a lost opening marker turns a generated region into ordinary prose that the
|
||||
next write appends a second copy beside.
|
||||
"""
|
||||
opens = [m.group(1) for m in _ANY_OPEN_RE.finditer(body)]
|
||||
closes = [m.group(1) for m in _ANY_CLOSE_RE.finditer(body)]
|
||||
names = set(opens) | set(closes)
|
||||
return {name: min(opens.count(name), closes.count(name)) for name in sorted(names)}
|
||||
|
||||
|
||||
def unbalanced_markers(body: str) -> list[str]:
|
||||
"""Region names whose open and close markers do not pair up."""
|
||||
opens = [m.group(1) for m in _ANY_OPEN_RE.finditer(body)]
|
||||
closes = [m.group(1) for m in _ANY_CLOSE_RE.finditer(body)]
|
||||
return sorted(
|
||||
name
|
||||
for name in set(opens) | set(closes)
|
||||
if opens.count(name) != closes.count(name)
|
||||
)
|
||||
@@ -21,6 +21,7 @@ try:
|
||||
index_build,
|
||||
instructions_cmd,
|
||||
lint as lint_module,
|
||||
links_cmd,
|
||||
log_append,
|
||||
migrate_cmd,
|
||||
new_page,
|
||||
@@ -54,6 +55,7 @@ app = typer.Typer(
|
||||
|
||||
app.add_typer(xref.app, name="xref")
|
||||
app.add_typer(cite_cmd.app, name="cite")
|
||||
app.add_typer(links_cmd.app, name="links")
|
||||
app.add_typer(index_build.app, name="index")
|
||||
app.add_typer(log_append.app, name="log")
|
||||
app.add_typer(confidence_decay.app, name="confidence")
|
||||
|
||||
@@ -28,7 +28,6 @@ from chemenu.kb_scan import load_kb_pages
|
||||
from chemenu.provenance import (
|
||||
CITE_REF_RE,
|
||||
cite_id,
|
||||
cite_block_heading,
|
||||
render_page_body,
|
||||
split_cite_block,
|
||||
unique_cite_id,
|
||||
@@ -81,7 +80,7 @@ def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) ->
|
||||
if sources_changed:
|
||||
sources.append(source_title)
|
||||
|
||||
new_body = render_page_body(head, definitions, cite_block_heading(page.body))
|
||||
new_body = render_page_body(head, definitions)
|
||||
changed = block_changed or sources_changed or new_body != page.body
|
||||
return marker_id, new_body, changed
|
||||
|
||||
@@ -152,7 +151,7 @@ def sync_page(page: Page) -> tuple[str, bool, list[str], list[str]]:
|
||||
ordered[cid] = definitions[cid]
|
||||
seen.add(cid)
|
||||
|
||||
new_body = render_page_body(head, ordered, cite_block_heading(page.body))
|
||||
new_body = render_page_body(head, ordered)
|
||||
changed = new_body != page.body
|
||||
return new_body, changed, pruned, undefined
|
||||
|
||||
|
||||
@@ -264,6 +264,60 @@ class Origin(NamedTuple):
|
||||
update_url: Optional[str] = None
|
||||
|
||||
|
||||
def instance_owned_type_stems() -> set[str]:
|
||||
"""Type-spec stems whose instances are knowledge pages, and which therefore
|
||||
belong to the instance rather than to the stack.
|
||||
|
||||
The line is `root:`, and it was already in the frontmatter before anyone
|
||||
drew it: `root: kb` means the type describes a page the instance writes, so
|
||||
its prose, its template and its language are the instance's business.
|
||||
Anything else - `instruction` (`root: repo`), `lint-report` (no `base_dir`
|
||||
at all), `type-spec` itself - describes a stack artifact and ships verbatim.
|
||||
|
||||
Read from `types/` rather than listed, so an instance adding its own page
|
||||
type gets the same treatment without a code change.
|
||||
"""
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
stems: set[str] = set()
|
||||
for type_path, frontmatter in resolver.list_type_specs():
|
||||
stem = Path(type_path).stem
|
||||
if stem == "type-spec":
|
||||
continue
|
||||
if not frontmatter.get("base_dir"):
|
||||
continue
|
||||
if (frontmatter.get("root") or "kb") != "kb":
|
||||
continue
|
||||
stems.add(stem)
|
||||
return stems
|
||||
|
||||
|
||||
def _plan_types() -> dict[str, PlannedFile]:
|
||||
"""`types/`, with the page type-specs re-keyed as templates.
|
||||
|
||||
Same split as the collection contracts, for the same reason and by the same
|
||||
mechanism: the shipped content is a working default rather than something
|
||||
wrong for the receiver, so the file itself crosses - under a name that has
|
||||
to be adopted before it counts. A type-spec's `.schema.yaml` travels with
|
||||
it, because the two are one type (see types/type-spec.md § Anatomy) and
|
||||
adopting half of it would leave a spec validated by a file it does not own.
|
||||
"""
|
||||
plan = _copy_tree(config.TYPES_DIR, "types", frozenset())
|
||||
stems = instance_owned_type_stems()
|
||||
if not stems:
|
||||
return plan
|
||||
|
||||
rekeyed: dict[str, PlannedFile] = {}
|
||||
for relative, planned in plan.items():
|
||||
name = relative.rsplit("/", 1)[-1]
|
||||
stem = name.split(".", 1)[0]
|
||||
if stem in stems:
|
||||
rekeyed[f"{relative}.template"] = planned
|
||||
else:
|
||||
rekeyed[relative] = planned
|
||||
return rekeyed
|
||||
|
||||
|
||||
def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
||||
"""Every (destination-relative path -> planned file) the export writes."""
|
||||
plan: dict[str, PlannedFile] = {}
|
||||
@@ -286,7 +340,7 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
||||
plan[name] = _read_planned_file(source, name)
|
||||
|
||||
plan.update(_copy_tree(config.INSTRUCTIONS_DIR, "instructions", frozenset(INSTRUCTIONS_EXCLUDE_DIRS)))
|
||||
plan.update(_copy_tree(config.TYPES_DIR, "types", frozenset()))
|
||||
plan.update(_plan_types())
|
||||
plan.update(_copy_tree(
|
||||
config.ROOT / "tools", "tools", frozenset(TOOLS_EXCLUDE_DIRS), _is_coverage_output
|
||||
))
|
||||
@@ -382,6 +436,7 @@ _INSTANCE_OWNED_KB_FILES = (kb_collections.CONTRACT_NAME, conventions.CONVENTION
|
||||
|
||||
def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
|
||||
"""Planned paths that carry one instance's own data instead of machinery."""
|
||||
owned_types = instance_owned_type_stems()
|
||||
leaks: list[str] = []
|
||||
for relative in sorted(plan):
|
||||
name = relative.rsplit("/", 1)[-1]
|
||||
@@ -389,6 +444,12 @@ def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
|
||||
leaks.append(f"{relative} (one instance's own personalization)")
|
||||
elif relative.startswith("kb/") and name in _INSTANCE_OWNED_KB_FILES:
|
||||
leaks.append(f"{relative} (this instance's authoring conventions; ship the .template)")
|
||||
elif (
|
||||
relative.startswith("types/")
|
||||
and not relative.endswith(".template")
|
||||
and name.split(".", 1)[0] in owned_types
|
||||
):
|
||||
leaks.append(f"{relative} (this instance's page type-spec; ship the .template)")
|
||||
elif relative.startswith("instructions/dev/"):
|
||||
leaks.append(f"{relative} (stack-development only)")
|
||||
elif relative.startswith(_CONTENT_PREFIXES) and name not in _CONTENT_ALLOWED_NAMES:
|
||||
|
||||
@@ -260,10 +260,53 @@ def check_collection_contracts() -> list[str]:
|
||||
|
||||
issues += kb_collections.declaration_issues()
|
||||
issues += conventions.declaration_issues()
|
||||
issues += check_stack_required_types()
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_stack_required_types() -> list[str]:
|
||||
"""The minimum the stack asks of the type layer, and nothing beyond it.
|
||||
|
||||
The four page type-specs belong to the instance: it may translate them,
|
||||
rewrite their templates, add sections. What it may not do is remove the one
|
||||
type the provenance path is built on, or drop the field that path reads.
|
||||
Everything else about `types/source.md` - its prose, its template, its title
|
||||
prefix, its directory - is the instance's, and is deliberately not checked
|
||||
here.
|
||||
"""
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
issues: list[str] = []
|
||||
for type_name in kb_collections.STACK_REQUIRED_TYPES:
|
||||
try:
|
||||
type_path = resolver.find_type_by_name(type_name)
|
||||
except (ValueError, OSError) as exc:
|
||||
issues.append(f"types/ could not be read to find the `{type_name}` type: {exc}")
|
||||
continue
|
||||
if not type_path:
|
||||
issues.append(
|
||||
f"no type-spec declares `name: {type_name}` - `sources coverage`, `[^cite-id]` "
|
||||
f"resolution and `kb/provenance.md` all ask `page.kind == \"{type_name}\"`, so "
|
||||
f"without it the whole raw/ -> kb/ provenance path resolves against nothing"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
schema = resolver.get_schema(type_path) or {}
|
||||
except (ValueError, OSError) as exc:
|
||||
issues.append(f"{type_path}: its schema could not be read: {exc}")
|
||||
continue
|
||||
declared = set(schema.get("required") or [])
|
||||
for field in kb_collections.STACK_REQUIRED_TYPE_FIELDS.get(type_name, ()):
|
||||
if field not in declared:
|
||||
issues.append(
|
||||
f"{type_path}: its schema must require `{field}` - it is what the "
|
||||
f"provenance path reads, and a `{type_name}` page without it claims no "
|
||||
f"raw material at all"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def check_legacy_type_blocks() -> list[str]:
|
||||
issues = []
|
||||
guarded = [
|
||||
|
||||
@@ -217,17 +217,18 @@ def check_conventions() -> Check:
|
||||
"""Whether this instance has said how its own pages are written.
|
||||
|
||||
`kb/CONVENTIONS.md` carries the decisions `kb/CONTRACT.md` deliberately no
|
||||
longer makes: the KB language and its three tool-owned section headings, the
|
||||
relationship-label vocabulary, the tone examples, the confidence rubric, the
|
||||
ADR prefix. The compiler reads the section names out of it, so an instance
|
||||
without one is not merely undocumented - `xref add` and `cite add` fall back
|
||||
to the names this stack hardcoded before the file existed, which is right
|
||||
only for a corpus that was written under them.
|
||||
longer makes: the KB language and the headings its two generated regions
|
||||
render under, the tone examples, the confidence rubric, the naming forms.
|
||||
|
||||
Hence `FAIL` rather than `WARN`, and hence the same two failure modes the
|
||||
personalization pair has: the distribution can ship the template but never
|
||||
the filled file, so a template renamed and left unanswered looks present and
|
||||
decides nothing.
|
||||
`FAIL` rather than `WARN` because those decisions bind every page, and
|
||||
because it has the same two failure modes the personalization pair has: the
|
||||
distribution can ship the template but never the filled file, so a template
|
||||
renamed and left unanswered looks present and decides nothing.
|
||||
|
||||
The headings themselves are only cosmetic now - the marker pair carries each
|
||||
region's identity, so a default renders wrong words rather than corrupting
|
||||
structure. That is why this check is about the *file*, not about rescuing a
|
||||
lookup the compiler can no longer get wrong.
|
||||
"""
|
||||
path = conventions.conventions_file()
|
||||
fix = (
|
||||
@@ -246,7 +247,9 @@ def check_conventions() -> Check:
|
||||
if issues:
|
||||
return Check("conventions", "FAIL", "; ".join(issues), fix)
|
||||
declared = conventions.language() or "unspecified"
|
||||
headings = ", ".join(conventions.canonical(slot) for slot in conventions.SLOTS)
|
||||
from chemenu import blocks
|
||||
|
||||
headings = ", ".join(conventions.heading(block) for block in blocks.BLOCKS)
|
||||
return Check(
|
||||
"conventions", "OK",
|
||||
f"kb/{conventions.CONVENTIONS_FILENAME} present, language {declared}, "
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""`wikitool links` - the declared graph around one page, both directions.
|
||||
|
||||
The half that makes authored directional edges liveable. An edge is written once,
|
||||
on the page that asserts it, so the question "what points at *this* page" has no
|
||||
answer stored anywhere - it is computed from the graph, which is the only way it
|
||||
is ever complete. A mirrored edge only ever recorded what someone remembered to
|
||||
mirror.
|
||||
|
||||
Read-only, and exempt from the iteration budget for the same reason `search` is:
|
||||
it answers a question rather than changing anything, and an agent that has to
|
||||
ration looking things up starts guessing instead.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config, links
|
||||
from chemenu.commands._util import console, fail
|
||||
from chemenu.kb_scan import load_kb_pages
|
||||
from chemenu.page import Page
|
||||
|
||||
app = typer.Typer(help="Show the declared edges into and out of a page.")
|
||||
|
||||
EDGE_FIELD = "related"
|
||||
|
||||
|
||||
def _collection_of(page: Page) -> Optional[str]:
|
||||
try:
|
||||
return page.path.relative_to(config.KB_DIR).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def outbound(pages: dict[str, Page], title: str) -> list[dict]:
|
||||
"""Edges this page asserts, in file order."""
|
||||
page = pages[title]
|
||||
return [
|
||||
{"target": edge.target, "label": edge.label, "resolves": edge.target in pages}
|
||||
for edge in links.edges(page.frontmatter, EDGE_FIELD)
|
||||
]
|
||||
|
||||
|
||||
def inbound(pages: dict[str, Page], title: str) -> list[dict]:
|
||||
"""Edges other pages assert *about* this one.
|
||||
|
||||
A full scan of the corpus rather than a stored list, deliberately: the whole
|
||||
argument for dropping mirrored edges is that this answer is derived and
|
||||
therefore cannot go stale or be half-written.
|
||||
"""
|
||||
found = [
|
||||
{"source": other, "label": edge.label, "collection": _collection_of(page)}
|
||||
for other, page in pages.items()
|
||||
for edge in links.edges(page.frontmatter, EDGE_FIELD)
|
||||
if edge.target == title
|
||||
]
|
||||
return sorted(found, key=lambda item: (item["label"] or "", item["source"]))
|
||||
|
||||
|
||||
@app.command("show")
|
||||
def links_show(
|
||||
page: str = typer.Option(..., "--page", help="Exact page title"),
|
||||
json_out: bool = typer.Option(False, "--json", help="Print the edges as JSON"),
|
||||
):
|
||||
"""Show the edges out of and into a page.
|
||||
|
||||
Outbound is what the page declares in `related:`. Inbound is computed across
|
||||
the corpus - nothing stores it, which is exactly why it is complete."""
|
||||
pages = load_kb_pages(config.KB_DIR)
|
||||
if page not in pages:
|
||||
fail(f"No page titled '{page}' found under kb/.")
|
||||
|
||||
out, back = outbound(pages, page), inbound(pages, page)
|
||||
|
||||
if json_out:
|
||||
typer.echo(_json.dumps({"page": page, "outbound": out, "inbound": back}, indent=2))
|
||||
return
|
||||
|
||||
console.print(f"[bold]{page}[/bold]")
|
||||
console.print(f"\n[cyan]asserts ({len(out)})[/cyan]")
|
||||
if not out:
|
||||
console.print(" (none)")
|
||||
for edge in out:
|
||||
label = edge["label"] or "[dim]unlabelled[/dim]"
|
||||
missing = "" if edge["resolves"] else " [red](no such page)[/red]"
|
||||
console.print(f" {label} -> [[{edge['target']}]]{missing}")
|
||||
|
||||
console.print(f"\n[cyan]asserted about it ({len(back)})[/cyan]")
|
||||
if not back:
|
||||
console.print(" (none - nothing in the corpus declares an edge to this page)")
|
||||
for edge in back:
|
||||
label = edge["label"] or "[dim]unlabelled[/dim]"
|
||||
console.print(f" [[{edge['source']}]] {label} ->")
|
||||
@@ -64,6 +64,7 @@ def list_command(
|
||||
"name": m.name,
|
||||
"migrates_to": str(m.target),
|
||||
"migration_kind": m.kind,
|
||||
"obligation": m.obligation,
|
||||
"description": m.description,
|
||||
"path": m.relative_path,
|
||||
}
|
||||
@@ -78,7 +79,10 @@ def list_command(
|
||||
success(f"No migration documents under {rel_path(kb_state.migrations_dir())}.")
|
||||
return
|
||||
for migration in migrations:
|
||||
console.print(f"[bold]{migration.target}[/bold] {migration.name} ({migration.kind})")
|
||||
console.print(
|
||||
f"[bold]{migration.target}[/bold] {migration.name} "
|
||||
f"({migration.kind}, {migration.obligation})"
|
||||
)
|
||||
if migration.description:
|
||||
console.print(f" {migration.description}")
|
||||
|
||||
@@ -86,6 +90,50 @@ def list_command(
|
||||
# --- migrate status --------------------------------------------------------
|
||||
|
||||
|
||||
def _report_offers(
|
||||
offered: list["kb_state.Migration"], divergent: Optional[list[str]]
|
||||
) -> None:
|
||||
"""Print the optional half of `status`, above the outstanding chain.
|
||||
|
||||
Deliberately never affects the exit code and never says "outstanding". An
|
||||
offer is the stack proposing a better default for a file the instance owns;
|
||||
an instance that keeps its own version is in a correct state, not a late
|
||||
one. Mixing the two is how the message that actually matters - your content
|
||||
no longer fits your machinery - stops being read.
|
||||
"""
|
||||
if not offered:
|
||||
return
|
||||
console.print(
|
||||
f"[cyan]{len(offered)} optional upgrade(s) available[/cyan] - none of them block:"
|
||||
)
|
||||
for migration in offered:
|
||||
console.print(f" {migration.target} {migration.name} ({migration.kind})")
|
||||
if migration.description:
|
||||
console.print(f" {migration.description}")
|
||||
console.print(f" {migration.relative_path}")
|
||||
|
||||
if divergent is None:
|
||||
console.print(
|
||||
" [dim]This tree carries no release stamp, so which of your files still match "
|
||||
"what you were given cannot be answered here.[/dim]"
|
||||
)
|
||||
return
|
||||
if divergent:
|
||||
console.print(
|
||||
f" [dim]{len(divergent)} file(s) differ from the release you installed - those are "
|
||||
"yours to reconcile by hand rather than overwrite:[/dim]"
|
||||
)
|
||||
for relative in divergent[:10]:
|
||||
console.print(f" [dim]{relative}[/dim]")
|
||||
if len(divergent) > 10:
|
||||
console.print(f" [dim]... and {len(divergent) - 10} more[/dim]")
|
||||
else:
|
||||
console.print(
|
||||
" [dim]No file differs from the release you installed, so an offer can be taken "
|
||||
"by copying.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def status_command(
|
||||
json_out: bool = typer.Option(False, "--json", help="Print the chain as JSON"),
|
||||
@@ -113,6 +161,8 @@ def status_command(
|
||||
return
|
||||
|
||||
pending = kb_state.chain(migrations, kb_version, stack)
|
||||
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
|
||||
divergent = kb_state.divergent_files()
|
||||
|
||||
if json_out:
|
||||
typer.echo(
|
||||
@@ -124,6 +174,11 @@ def status_command(
|
||||
{"name": m.name, "migrates_to": str(m.target), "migration_kind": m.kind}
|
||||
for m in pending
|
||||
],
|
||||
"offered": [
|
||||
{"name": m.name, "migrates_to": str(m.target), "migration_kind": m.kind}
|
||||
for m in offered
|
||||
],
|
||||
"divergent_files": divergent,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
@@ -131,6 +186,7 @@ def status_command(
|
||||
return
|
||||
|
||||
console.print(f"stack {stack}, content {kb_version}")
|
||||
_report_offers(offered, divergent)
|
||||
if not pending:
|
||||
if kb_version < stack:
|
||||
console.print(
|
||||
@@ -166,7 +222,12 @@ def done_command(
|
||||
|
||||
Refuses any version that is not the *next* link in the chain: skipping a
|
||||
migration is how a corpus ends up in a shape no version describes, and an
|
||||
interrupted multi-step upgrade has to be resumable rather than guessable."""
|
||||
interrupted multi-step upgrade has to be resumable rather than guessable.
|
||||
|
||||
An `offered` migration is recorded but does not move the version, and no
|
||||
ordering rule applies to it - it is not a link in the chain. The record is
|
||||
the only thing that distinguishes an offer someone took from one they
|
||||
ignored, precisely because the version stays put."""
|
||||
stack, kb_version = _versions()
|
||||
if kb_version is None:
|
||||
fail(
|
||||
@@ -182,6 +243,34 @@ def done_command(
|
||||
return
|
||||
|
||||
migrations = kb_state.load_migrations()
|
||||
state = kb_state.read_kb_state() or {}
|
||||
|
||||
# An offer is recorded but does not advance the version: it is not a link in
|
||||
# the chain, so there is no ordering rule to check and nothing to skip. The
|
||||
# ledger is what makes it stop being offered - without that record there
|
||||
# would be no way to tell a taken offer from an ignored one, because
|
||||
# `kb_version` deliberately does not move.
|
||||
offered = {m.name: m for m in migrations if not m.is_required}
|
||||
taken = next((m for m in offered.values() if str(m.target) == version), None)
|
||||
if taken is not None:
|
||||
if taken.name in kb_state.applied_names(state):
|
||||
success(f"{taken.name} is already recorded as taken. Nothing to do.")
|
||||
return
|
||||
if dry_run:
|
||||
success(f"Dry run: would record the optional {taken.name}. Nothing written.")
|
||||
return
|
||||
applied = list(state.get("applied") or [])
|
||||
entry = {"migration": taken.name, "at": today_iso(), "obligation": kb_state.OFFERED}
|
||||
if pages is not None:
|
||||
entry["pages"] = pages
|
||||
applied.append(entry)
|
||||
kb_state.write_kb_state(kb_version, applied)
|
||||
success(
|
||||
f"Recorded the optional {taken.name}. Content stays at {kb_version} - an offer "
|
||||
"changes a file you own, not the shape of your content."
|
||||
)
|
||||
return
|
||||
|
||||
expected = kb_state.next_link(migrations, kb_version, stack)
|
||||
if expected is None:
|
||||
fail(
|
||||
@@ -197,7 +286,6 @@ def done_command(
|
||||
)
|
||||
return
|
||||
|
||||
state = kb_state.read_kb_state() or {}
|
||||
applied = list(state.get("applied") or [])
|
||||
entry = {"migration": expected.name, "at": today_iso()}
|
||||
if pages is not None:
|
||||
|
||||
@@ -24,7 +24,7 @@ import re
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config, conventions
|
||||
from chemenu import config
|
||||
from chemenu.commands._util import (
|
||||
check_collision,
|
||||
check_raw_files_exist,
|
||||
@@ -166,11 +166,7 @@ def _apply_template_variables(template: str, variables: Dict[str, Any]) -> str:
|
||||
"""Apply variable substitutions to a template string.
|
||||
|
||||
Supports:
|
||||
- `{field}` - plain substitution from `variables[field]`, including the
|
||||
`{section.<slot>}` names this instance gave the three tool-owned
|
||||
headings (see chemenu.conventions). Those are what took the KB language
|
||||
out of `types/*.md`: a template writes `## {section.relationships}`, so
|
||||
scaffolding a page in another language needs no edit under `types/`
|
||||
- `{field}` - plain substitution from `variables[field]`
|
||||
- `{field|filter}` - apply a named filter (bullets, join, capitalize)
|
||||
to `variables[field]`'s value, so templates can render list/enum
|
||||
frontmatter fields directly instead of the caller precomputing a
|
||||
@@ -333,7 +329,6 @@ def new_page_command(
|
||||
**frontmatter,
|
||||
"name": name,
|
||||
"today": today.isoformat(),
|
||||
**conventions.section_variables(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import config, links
|
||||
from chemenu.commands._util import check_collision, fail, rel_path, success
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.page import Page
|
||||
@@ -33,7 +33,6 @@ from chemenu.kb_scan import load_kb_pages
|
||||
from chemenu.provenance import (
|
||||
CITE_REF_RE,
|
||||
cite_id,
|
||||
cite_block_heading,
|
||||
render_page_body,
|
||||
split_cite_block,
|
||||
unique_cite_id,
|
||||
@@ -103,7 +102,7 @@ def retarget_cite_ids(body: str, old: str, new: str) -> str:
|
||||
return body
|
||||
|
||||
new_head = CITE_REF_RE.sub(lambda m: f"[^{renames.get(m.group(1), m.group(1))}]", head)
|
||||
return render_page_body(new_head, new_definitions, cite_block_heading(body))
|
||||
return render_page_body(new_head, new_definitions)
|
||||
|
||||
|
||||
def retarget_frontmatter(page: Page, old: str, new: str) -> bool:
|
||||
@@ -114,9 +113,11 @@ def retarget_frontmatter(page: Page, old: str, new: str) -> bool:
|
||||
values = page.frontmatter.get(field)
|
||||
if not values:
|
||||
continue
|
||||
updated = [new if value == old else value for value in values]
|
||||
if updated != values:
|
||||
page.frontmatter[field] = updated
|
||||
# Through `links` so a labelled edge keeps its label across a rename:
|
||||
# the entry is `{label: target}`, and a plain equality swap would have
|
||||
# compared the mapping against a title and silently left it pointing at
|
||||
# the old page.
|
||||
if links.retarget(page.frontmatter, field, old, new):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
@@ -157,8 +158,10 @@ def strip_frontmatter_ref(page: Page, title: str) -> bool:
|
||||
values = page.frontmatter.get(field)
|
||||
if not values:
|
||||
continue
|
||||
updated = [value for value in values if value != title]
|
||||
if updated == values:
|
||||
before = list(values)
|
||||
links.remove(page.frontmatter, field, title)
|
||||
updated = page.frontmatter.get(field) or []
|
||||
if updated == before:
|
||||
continue
|
||||
if not updated and field not in declared:
|
||||
del page.frontmatter[field]
|
||||
|
||||
@@ -82,6 +82,10 @@ SKIP_COMMAND_PATHS = {
|
||||
("eval", "score"),
|
||||
("eval", "sessions"),
|
||||
("cite", "id"),
|
||||
# Retrieval, like `search`: an agent that has to ration looking up what
|
||||
# points at a page starts guessing instead - and under authored directional
|
||||
# edges this is the *only* way to ask that question.
|
||||
("links", "show"),
|
||||
("version", "show"),
|
||||
("version", "check"),
|
||||
("version", "notes"),
|
||||
|
||||
+116
-96
@@ -1,9 +1,22 @@
|
||||
"""Bidirectional cross-reference management between wiki pages.
|
||||
"""Cross-reference management between wiki pages.
|
||||
|
||||
`xref add` keeps two pages' frontmatter `related:` lists AND their body
|
||||
"## Relationships" sections in sync in one operation, instead of the 3-5
|
||||
separate manual edits this used to take per pair of pages. It is idempotent:
|
||||
re-running it never duplicates a link.
|
||||
`xref add` writes **one** edge: a label plus a target, into the asserting page's
|
||||
`related:` frontmatter, and re-renders that page's generated links region from
|
||||
it. It is idempotent, and re-running with a different label relabels rather than
|
||||
duplicating.
|
||||
|
||||
It used to write four things at once - `related:` and a Relationships bullet on
|
||||
both pages, plus reciprocal See Also bullets. That made every edge symmetric by
|
||||
construction, which is not what a link means: an edge is an authored reader aid,
|
||||
and "follow this to verify the premise" rarely reads the same from the other
|
||||
end. Worse, it is incompatible with per-collection label authorisation, because
|
||||
the mirrored half is written into a collection whose rules the author never
|
||||
read.
|
||||
|
||||
The reverse direction is therefore authored separately, when it is a primary
|
||||
statement of its own - and navigation does not depend on anyone bothering:
|
||||
`index rebuild` renders the inbound view from the graph, completely and without
|
||||
maintenance. See instructions/link-taxonomy.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,7 +25,7 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from chemenu import config, sections
|
||||
from chemenu import blocks, config, conventions, kb_collections, links
|
||||
from chemenu.commands._util import fail, parse_list, success
|
||||
from chemenu.commands.page_ops import strip_frontmatter_ref
|
||||
from chemenu.frontmatter_io import write_page
|
||||
@@ -71,121 +84,119 @@ def _back_reference_field(source: Page, target: Page) -> str | None:
|
||||
return collection if collection in _declared_ref_fields(source) else None
|
||||
|
||||
|
||||
def add_related(frontmatter: dict, other_title: str) -> bool:
|
||||
"""Add other_title to frontmatter['related'] if not already present.
|
||||
Returns True if a change was made."""
|
||||
related = frontmatter.setdefault("related", [])
|
||||
if other_title in related:
|
||||
return False
|
||||
related.append(other_title)
|
||||
return True
|
||||
|
||||
|
||||
def _section_bounds(body: str, heading: str) -> tuple[int, int] | None:
|
||||
match = sections.heading_re(heading).search(body)
|
||||
if not match:
|
||||
def _collection_of(page: Page) -> str | None:
|
||||
"""The collection a page lives in, or None if it is outside `kb/`."""
|
||||
try:
|
||||
return page.path.relative_to(config.KB_DIR).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
start = match.end()
|
||||
next_heading = re.search(r"^## ", body[start:], re.MULTILINE)
|
||||
end = start + next_heading.start() if next_heading else len(body)
|
||||
return start, end
|
||||
|
||||
|
||||
def add_bullet_to_section(body: str, heading: str, bullet: str, dedup_link: str) -> str:
|
||||
"""Insert `bullet` into the `## {heading}` section of body, unless a
|
||||
wikilink to dedup_link already appears there. Creates the section
|
||||
(before the See Also section if present, else at the end) if missing.
|
||||
def render_links_block(page: Page) -> str:
|
||||
"""The page's generated links region, built from its `related:` edges.
|
||||
|
||||
`heading` is a canonical name from `sections`; an existing section is found
|
||||
under its aliases too, so a page that has not been translated yet is still
|
||||
appended to rather than given a duplicate section. A section this creates
|
||||
always carries the canonical name."""
|
||||
bounds = _section_bounds(body, heading)
|
||||
if bounds is None:
|
||||
section = f"## {heading}\n\n{bullet}\n\n"
|
||||
see_also = sections.heading_re(sections.SEE_ALSO).search(body)
|
||||
if heading != sections.SEE_ALSO and see_also:
|
||||
return body[: see_also.start()] + section + body[see_also.start() :]
|
||||
return body.rstrip("\n") + "\n\n" + section.rstrip("\n") + "\n"
|
||||
|
||||
start, end = bounds
|
||||
section_text = body[start:end]
|
||||
if f"[[{dedup_link}]]" in section_text:
|
||||
return body
|
||||
trimmed = section_text.rstrip("\n")
|
||||
new_section = trimmed + "\n" + bullet + "\n\n"
|
||||
return body[:start] + new_section + body[end:]
|
||||
The body is a *rendering* of the frontmatter, not a second place the graph
|
||||
is stored. That is what removed the need to parse a German bullet back into
|
||||
a relationship: the label lives in the data, and this writes it out.
|
||||
"""
|
||||
lines = []
|
||||
for edge in links.edges(page.frontmatter, "related"):
|
||||
if edge.is_labelled:
|
||||
lines.append(f"- **{edge.label}:** [[{edge.target}]]")
|
||||
else:
|
||||
lines.append(f"- [[{edge.target}]]")
|
||||
return blocks.render(blocks.LINKS, conventions.heading(blocks.LINKS), lines)
|
||||
|
||||
|
||||
def add_relationship_bullet(body: str, label: str, other_title: str) -> str:
|
||||
bullet = f"- **{label}:** [[{other_title}]]"
|
||||
return add_bullet_to_section(body, sections.RELATIONSHIPS, bullet, other_title)
|
||||
def apply_links_block(page: Page, body: str | None = None) -> str:
|
||||
"""`body` with the links region re-rendered from `page.frontmatter`."""
|
||||
return blocks.replace(
|
||||
page.body if body is None else body, blocks.LINKS, render_links_block(page)
|
||||
)
|
||||
|
||||
|
||||
def add_see_also_bullet(body: str, other_title: str) -> str:
|
||||
return add_bullet_to_section(body, sections.SEE_ALSO, f"- [[{other_title}]]", other_title)
|
||||
def _check_authorised(source: Page, target: Page, label: str) -> None:
|
||||
"""Refuse a label the source collection has not authorised for that
|
||||
destination.
|
||||
|
||||
Checked here rather than only in `lint` because this is the moment the
|
||||
author is present: a refusal names the authorised set and can be answered by
|
||||
picking a better label, while a lint finding a day later is answered by
|
||||
whoever is holding the report.
|
||||
"""
|
||||
source_collection = _collection_of(source)
|
||||
destination = _collection_of(target)
|
||||
if source_collection is None or destination is None:
|
||||
return
|
||||
allowed = kb_collections.authorised_labels(source_collection, destination)
|
||||
if not allowed:
|
||||
fail(
|
||||
f"kb/{source_collection}/COLLECTION.md authorises no labels for edges into "
|
||||
f"kb/{destination}/. Add an `outbound:` entry for it, or do not link there "
|
||||
f"from this collection."
|
||||
)
|
||||
if label not in allowed:
|
||||
fail(
|
||||
f"'{label}' is not authorised for kb/{source_collection}/ -> kb/{destination}/.\n"
|
||||
f" Authorised: {', '.join(sorted(allowed))}\n"
|
||||
f" The catalogue and what each label asserts: instructions/link-taxonomy.md\n"
|
||||
f" Authorising a further label is a deliberate edit to "
|
||||
f"kb/{source_collection}/COLLECTION.md, not a way around this refusal."
|
||||
)
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def xref_add(
|
||||
a: str = typer.Option(..., "--a", help="Exact title of page A"),
|
||||
b: str = typer.Option(..., "--b", help="Exact title of page B"),
|
||||
rel_a: str = typer.Option("related to", "--rel-a", help="Relationship label on A pointing to B"),
|
||||
rel_b: str = typer.Option("related to", "--rel-b", help="Relationship label on B pointing to A"),
|
||||
see_also: bool = typer.Option(True, "--see-also/--no-see-also", help="Also add reciprocal 'See Also' bullets"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes to both pages instead of writing"),
|
||||
a: str = typer.Option(..., "--a", help="Exact title of the page that asserts the edge"),
|
||||
b: str = typer.Option(..., "--b", help="Exact title of the page it points at"),
|
||||
rel: str = typer.Option(
|
||||
..., "--rel", help="Label from instructions/link-taxonomy.md, e.g. depends-on"
|
||||
),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview the change instead of writing"),
|
||||
):
|
||||
"""Declare that A <rel> B. One edge, on A only.
|
||||
|
||||
Say the sentence before choosing the label: `[A] <rel> [B]`. If it only
|
||||
reads true backwards, the edge belongs on B - run this the other way round
|
||||
rather than reaching for an inverse label.
|
||||
|
||||
B is not modified and does not need to point back. Its inbound view is
|
||||
rendered from the graph.
|
||||
"""
|
||||
pages = load_kb_pages(config.KB_DIR)
|
||||
page_a = _find_page(pages, a)
|
||||
page_b = _find_page(pages, b)
|
||||
|
||||
# Both refusals before either write, so a rejected pair leaves no half-link.
|
||||
# Every refusal before the single write, so a rejected edge leaves nothing.
|
||||
_require_related_field(page_a, a)
|
||||
_require_related_field(page_b, b)
|
||||
_check_authorised(page_a, page_b, rel)
|
||||
|
||||
related_changed_a = add_related(page_a.frontmatter, b)
|
||||
related_changed_b = add_related(page_b.frontmatter, a)
|
||||
|
||||
body_a = add_relationship_bullet(page_a.body, rel_a, b)
|
||||
body_b = add_relationship_bullet(page_b.body, rel_b, a)
|
||||
if see_also:
|
||||
body_a = add_see_also_bullet(body_a, b)
|
||||
body_b = add_see_also_bullet(body_b, a)
|
||||
|
||||
changed_a = related_changed_a or body_a != page_a.body
|
||||
changed_b = related_changed_b or body_b != page_b.body
|
||||
changed = links.upsert(page_a.frontmatter, "related", links.Edge(b, rel))
|
||||
body = apply_links_block(page_a)
|
||||
changed = changed or body != page_a.body
|
||||
|
||||
if dry_run:
|
||||
state_a = "would update" if changed_a else "already up to date"
|
||||
state_b = "would update" if changed_b else "already up to date"
|
||||
typer.echo(f"[dry-run] '{a}': {state_a} (related / Relationships / See Also)")
|
||||
typer.echo(f"[dry-run] '{b}': {state_b} (related / Relationships / See Also)")
|
||||
typer.echo(
|
||||
f"[dry-run] '{a}': {'would declare' if changed else 'already declares'} "
|
||||
f"{rel} -> '{b}'"
|
||||
)
|
||||
typer.echo("No files written (--dry-run).")
|
||||
return
|
||||
|
||||
try:
|
||||
write_page(page_a.path, page_a.frontmatter, body_a)
|
||||
except OSError as exc:
|
||||
fail(f"Failed to write '{a}': {exc}. '{b}' was not touched - fix the write failure and retry once.")
|
||||
if not changed:
|
||||
success(f"'{a}' already declares {rel} -> '{b}'; nothing changed.")
|
||||
return
|
||||
|
||||
try:
|
||||
write_page(page_b.path, page_b.frontmatter, body_b)
|
||||
write_page(page_a.path, page_a.frontmatter, body)
|
||||
except OSError as exc:
|
||||
fail(
|
||||
f"'{a}' was updated but writing '{b}' failed: {exc}. The link is now one-directional - "
|
||||
f"fix the write failure, then re-run `xref add --a \"{a}\" --b \"{b}\"` (idempotent, safe to retry)."
|
||||
)
|
||||
success(f"Linked '{a}' <-> '{b}' ({rel_a} / {rel_b})")
|
||||
fail(f"Failed to write '{a}': {exc}")
|
||||
success(f"'{a}' {rel} '{b}'")
|
||||
|
||||
|
||||
def remove_related(frontmatter: dict, other_title: str) -> bool:
|
||||
"""Drop other_title from frontmatter['related'] if present. Returns True if
|
||||
a change was made."""
|
||||
related = frontmatter.get("related")
|
||||
if not related or other_title not in related:
|
||||
return False
|
||||
frontmatter["related"] = [title for title in related if title != other_title]
|
||||
return True
|
||||
"""Drop every edge pointing at other_title. True if a change was made."""
|
||||
return links.remove(frontmatter, "related", other_title)
|
||||
|
||||
def remove_link_bullets(body: str, other_title: str) -> str:
|
||||
"""Remove the whole-line Relationships/See Also bullets `xref add` writes -
|
||||
@@ -223,14 +234,19 @@ def xref_remove(
|
||||
page_a = _find_page(pages, a)
|
||||
page_b = pages.get(b)
|
||||
|
||||
body_a = remove_link_bullets(page_a.body, b)
|
||||
changed_a = strip_frontmatter_ref(page_a, b) or body_a != page_a.body
|
||||
# The frontmatter first, then the region re-rendered from it - the body is a
|
||||
# rendering, so editing the bullet out directly would leave an empty region
|
||||
# behind and, worse, put the two out of step.
|
||||
changed_a = strip_frontmatter_ref(page_a, b)
|
||||
body_a = apply_links_block(page_a, remove_link_bullets(page_a.body, b))
|
||||
changed_a = changed_a or body_a != page_a.body
|
||||
|
||||
changed_b = False
|
||||
body_b = ""
|
||||
if page_b is not None:
|
||||
body_b = remove_link_bullets(page_b.body, a)
|
||||
changed_b = strip_frontmatter_ref(page_b, a) or body_b != page_b.body
|
||||
changed_b = strip_frontmatter_ref(page_b, a)
|
||||
body_b = apply_links_block(page_b, remove_link_bullets(page_b.body, a))
|
||||
changed_b = changed_b or body_b != page_b.body
|
||||
|
||||
if dry_run:
|
||||
typer.echo(f"[dry-run] '{a}': {'would update' if changed_a else 'no reference to remove'}")
|
||||
@@ -278,7 +294,11 @@ def xref_link_source(
|
||||
sources = page.frontmatter.setdefault("sources", [])
|
||||
if source not in sources:
|
||||
sources.append(source)
|
||||
body = add_see_also_bullet(page.body, source)
|
||||
# No body bullet. `sources:` *is* the record, and the See Also bullet
|
||||
# this used to add was the reciprocal half of a bidirectional model
|
||||
# that no longer exists - 353 of the corpus's 555 such bullets were
|
||||
# provably redundant with an edge that already said the same thing.
|
||||
body = page.body
|
||||
|
||||
# The way back. Until this existed the command wrote only the targets,
|
||||
# so a source page's own `entities:`/`concepts:` stayed as `new` left
|
||||
|
||||
@@ -35,30 +35,30 @@ from chemenu.frontmatter_io import read_page
|
||||
CONVENTIONS_FILENAME = "CONVENTIONS.md"
|
||||
CONVENTIONS_TEMPLATE = f"{CONVENTIONS_FILENAME}.template"
|
||||
|
||||
# The three tool-owned headings, by slot name. The slot is the stable
|
||||
# identifier - it is what code, the type-spec templates and the conventions
|
||||
# file all key on - while the heading text itself is the instance's to choose.
|
||||
RELATIONSHIPS = "relationships"
|
||||
SEE_ALSO = "see_also"
|
||||
FOOTNOTES = "footnotes"
|
||||
SLOTS = (RELATIONSHIPS, SEE_ALSO, FOOTNOTES)
|
||||
|
||||
# Frontmatter keys read out of kb/CONVENTIONS.md.
|
||||
# The two tool-owned regions, keyed by the block name in `chemenu.blocks`. The
|
||||
# block name is the identifier - it is what the marker pair carries and what the
|
||||
# tool locates the region by - while the heading text below it is prose the
|
||||
# instance chooses.
|
||||
#
|
||||
# `see_also` is gone as a section: it was the reciprocal half of the old
|
||||
# bidirectional `xref add`, and under authored directional edges it is a *label*
|
||||
# inside the links block rather than a region of its own.
|
||||
SECTIONS_KEY = "sections"
|
||||
SECTION_ALIASES_KEY = "section_aliases"
|
||||
LANGUAGE_KEY = "language"
|
||||
|
||||
# Every heading name this stack has ever written as canonical, newest first.
|
||||
# Two jobs, and they are separate: the first entry is the fallback for an
|
||||
# instance that has no conventions file yet, and the whole tuple is an implicit
|
||||
# alias set that every instance recognizes regardless of what it declares. The
|
||||
# second is what makes a corpus translatable page by page - a page still
|
||||
# carrying `## Footnotes` is untranslated, not broken, and `cite sync` has to
|
||||
# stay a no-op on it.
|
||||
PRE_CONVENTIONS_NAMES: dict[str, tuple[str, ...]] = {
|
||||
RELATIONSHIPS: ("Beziehungen", "Relationships"),
|
||||
SEE_ALSO: ("Siehe auch", "See Also"),
|
||||
FOOTNOTES: ("Fußnoten", "Footnotes"),
|
||||
# What a heading renders as when the instance has not said. Purely cosmetic, and
|
||||
# that is a genuine change from before: while the tool located a region by
|
||||
# matching this text, a wrong default silently split a page into two sections and
|
||||
# `xref add` appended to the wrong one. Now the marker pair carries the identity,
|
||||
# so a region rendered under the wrong words is a *display* fault that the next
|
||||
# write repairs by itself once `kb/CONVENTIONS.md` says otherwise.
|
||||
#
|
||||
# So this is a fallback for the window between installing the machinery and
|
||||
# writing the conventions file - `doctor` is what makes that window loud - and
|
||||
# not a language the compiler has an opinion about.
|
||||
DEFAULT_HEADINGS: dict[str, str] = {
|
||||
"links": "Relationships",
|
||||
"footnotes": "Footnotes",
|
||||
}
|
||||
|
||||
|
||||
@@ -119,44 +119,12 @@ def language() -> Optional[str]:
|
||||
return str(value).strip() or None
|
||||
|
||||
|
||||
def canonical(slot: str) -> str:
|
||||
"""The heading name this instance writes for `slot`."""
|
||||
declared = _mapping(SECTIONS_KEY).get(slot)
|
||||
def heading(block: str) -> str:
|
||||
"""The heading this instance renders above `block`'s generated region."""
|
||||
declared = _mapping(SECTIONS_KEY).get(block)
|
||||
if isinstance(declared, str) and declared.strip():
|
||||
return declared.strip()
|
||||
return PRE_CONVENTIONS_NAMES[slot][0]
|
||||
|
||||
|
||||
def names(slot: str) -> tuple[str, ...]:
|
||||
"""Every heading name `slot` is recognized under, canonical first.
|
||||
|
||||
The canonical name, then any `section_aliases:` the instance declared, then
|
||||
the names this stack wrote before the conventions file existed. Deduplicated
|
||||
while preserving that order, so an instance declaring English does not end
|
||||
up with `Relationships` listed twice.
|
||||
"""
|
||||
declared_aliases = _mapping(SECTION_ALIASES_KEY).get(slot)
|
||||
extra = declared_aliases if isinstance(declared_aliases, list) else []
|
||||
ordered = [
|
||||
canonical(slot),
|
||||
*(str(name).strip() for name in extra if str(name).strip()),
|
||||
*PRE_CONVENTIONS_NAMES[slot],
|
||||
]
|
||||
seen: dict[str, None] = {}
|
||||
for name in ordered:
|
||||
seen.setdefault(name, None)
|
||||
return tuple(seen)
|
||||
|
||||
|
||||
def section_variables() -> dict[str, str]:
|
||||
"""The `{section.<slot>}` substitutions a type-spec template can use.
|
||||
|
||||
This is what took the three German headings out of `types/*.md`: a template
|
||||
writes `## {section.relationships}` and the instance's own conventions fill
|
||||
it in, so scaffolding a page in another language needs no edit under
|
||||
`types/`.
|
||||
"""
|
||||
return {f"section.{slot}": canonical(slot) for slot in SLOTS}
|
||||
return DEFAULT_HEADINGS.get(block, block.title())
|
||||
|
||||
|
||||
def declaration_issues() -> list[str]:
|
||||
@@ -167,6 +135,8 @@ def declaration_issues() -> list[str]:
|
||||
looks like. An absent file is *not* reported here - that is a separate
|
||||
finding with a separate fix, and only `doctor` makes it one.
|
||||
"""
|
||||
from chemenu import blocks
|
||||
|
||||
path = conventions_file()
|
||||
if not path.is_file():
|
||||
return []
|
||||
@@ -176,46 +146,29 @@ def declaration_issues() -> list[str]:
|
||||
if not frontmatter:
|
||||
return [
|
||||
f"kb/{CONVENTIONS_FILENAME} has no readable frontmatter - it must declare "
|
||||
f"`{SECTIONS_KEY}:` with the heading names this instance writes"
|
||||
f"`{SECTIONS_KEY}:` with the headings this instance renders"
|
||||
]
|
||||
|
||||
declared = frontmatter.get(SECTIONS_KEY)
|
||||
if not isinstance(declared, dict):
|
||||
return [
|
||||
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}:` must be a mapping of "
|
||||
f"{'/'.join(SLOTS)} to the heading text this instance writes"
|
||||
f"{'/'.join(blocks.BLOCKS)} to the heading this instance renders above it"
|
||||
]
|
||||
for slot in SLOTS:
|
||||
value = declared.get(slot)
|
||||
for block in blocks.BLOCKS:
|
||||
value = declared.get(block)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
issues.append(
|
||||
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{slot}` is missing or empty - "
|
||||
"`xref add` and `cite add` write into a heading this instance has not named"
|
||||
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{block}` is missing or empty - "
|
||||
f"the generated `{block}` region would render under a default heading rather "
|
||||
"than this instance's own"
|
||||
)
|
||||
for slot in sorted(set(declared) - set(SLOTS)):
|
||||
for block in sorted(set(declared) - set(blocks.BLOCKS)):
|
||||
issues.append(
|
||||
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{slot}` is not a section the tool "
|
||||
f"owns; the slots are {', '.join(SLOTS)}"
|
||||
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{block}` is not a region the tool "
|
||||
f"generates; the regions are {', '.join(blocks.BLOCKS)}"
|
||||
)
|
||||
|
||||
aliases = frontmatter.get(SECTION_ALIASES_KEY, {})
|
||||
if not isinstance(aliases, dict):
|
||||
issues.append(
|
||||
f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}:` must be a mapping of a "
|
||||
"slot to the list of headings still recognized under it"
|
||||
)
|
||||
else:
|
||||
for slot, value in sorted(aliases.items()):
|
||||
if slot not in SLOTS:
|
||||
issues.append(
|
||||
f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}.{slot}` is not a "
|
||||
f"section the tool owns; the slots are {', '.join(SLOTS)}"
|
||||
)
|
||||
elif not isinstance(value, list):
|
||||
issues.append(
|
||||
f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}.{slot}` must be a list"
|
||||
)
|
||||
|
||||
if config.TEMPLATE_SENTINEL in path.read_text(encoding="utf-8"):
|
||||
issues.append(
|
||||
f"kb/{CONVENTIONS_FILENAME} still carries the `{config.TEMPLATE_SENTINEL}` line - "
|
||||
|
||||
@@ -26,7 +26,7 @@ from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from chemenu import kb_scan, provenance
|
||||
from chemenu import blocks, kb_scan, provenance
|
||||
from chemenu.page import Page
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
@@ -59,6 +59,7 @@ class PageShape:
|
||||
cite_refs: Counter
|
||||
cite_defs: dict[str, str]
|
||||
fields: dict[str, Any]
|
||||
markers: dict[str, int]
|
||||
body: str
|
||||
|
||||
@classmethod
|
||||
@@ -81,6 +82,7 @@ class PageShape:
|
||||
cite_refs=Counter(m.group(1) for m in provenance.CITE_REF_RE.finditer(head)),
|
||||
cite_defs={cite_id: source for cite_id, (source, _) in definitions.items()},
|
||||
fields=fields,
|
||||
markers=blocks.marker_pairs(page.body),
|
||||
body=page.body,
|
||||
)
|
||||
|
||||
@@ -163,6 +165,23 @@ def compare_page(path: str, before: PageShape, after: PageShape) -> list[PageFin
|
||||
changed.append(f"[^{cite_id}] {was!r} -> {now!r}")
|
||||
findings.append(PageFinding(path, "cite-defs", ", ".join(changed)))
|
||||
|
||||
# A generated region that lost or gained a marker is the failure mode the
|
||||
# delimiters were introduced against, and it is silent: a lost opening
|
||||
# marker turns the region into ordinary prose, and the next write appends a
|
||||
# second region beside it. An agent rewriting prose at the boundary is
|
||||
# exactly how that happens, which is what makes it a migration invariant
|
||||
# rather than a lint nicety.
|
||||
#
|
||||
# Counts, not presence - the same reasoning as the wikilink counter. A page
|
||||
# that goes from one links region to two has the same *set* of region names.
|
||||
if before.markers != after.markers:
|
||||
changed_regions = []
|
||||
for name in sorted(set(before.markers) | set(after.markers)):
|
||||
was, now = before.markers.get(name, 0), after.markers.get(name, 0)
|
||||
if was != now:
|
||||
changed_regions.append(f"{name}: {was} -> {now}")
|
||||
findings.append(PageFinding(path, "markers", ", ".join(changed_regions)))
|
||||
|
||||
changed_fields = []
|
||||
for name in sorted(set(before.fields) | set(after.fields)):
|
||||
was, now = before.fields.get(name), after.fields.get(name)
|
||||
|
||||
@@ -267,10 +267,41 @@ def _format_list(items: list[Any]) -> str:
|
||||
return "[" + ", ".join(_format_scalar(v, flow=True) for v in items) + "]"
|
||||
|
||||
|
||||
def _is_single_key_mapping(value: Any) -> bool:
|
||||
return isinstance(value, dict) and len(value) == 1
|
||||
|
||||
|
||||
def _format_mapping_list(key: str, items: list[Any]) -> str:
|
||||
"""A list holding `label: target` pairs, rendered block-style.
|
||||
|
||||
The inline `[...]` form this file uses everywhere else cannot carry a
|
||||
mapping without quoting rules nobody reading the file would guess, so a
|
||||
labelled edge list is the one place block style earns its keep:
|
||||
|
||||
related:
|
||||
- depends-on: Hermes
|
||||
- Borealis
|
||||
|
||||
Bare strings mixed in stay bare - that is an edge whose label has not been
|
||||
declared yet, and promoting it to some default here would erase exactly what
|
||||
`lint` is looking for.
|
||||
"""
|
||||
lines = [f"{key}:"]
|
||||
for item in items:
|
||||
if _is_single_key_mapping(item):
|
||||
(label, target), = item.items()
|
||||
lines.append(f" - {_format_scalar(label)}: {_format_scalar(target)}")
|
||||
else:
|
||||
lines.append(f" - {_format_scalar(item)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def dump_frontmatter(frontmatter: dict[str, Any]) -> str:
|
||||
lines = []
|
||||
for key, value in frontmatter.items():
|
||||
if isinstance(value, list):
|
||||
if isinstance(value, list) and any(_is_single_key_mapping(v) for v in value):
|
||||
lines.append(_format_mapping_list(key, value))
|
||||
elif isinstance(value, list):
|
||||
lines.append(f"{key}: {_format_list(value)}")
|
||||
else:
|
||||
lines.append(f"{key}: {_format_scalar(value)}")
|
||||
|
||||
@@ -37,14 +37,59 @@ CONTRACT_NAME = "COLLECTION.md"
|
||||
PROFILE_FIELD = "profile"
|
||||
REQUIRED_BY_STACK_FIELD = "required_by_stack"
|
||||
|
||||
# Collections `wikitool` itself depends on by name, as opposed to ones that
|
||||
# merely hold pages. `sources` is here because three parts of the stack resolve
|
||||
# against it rather than against a page's type: `sources coverage` asks which
|
||||
# raw files no source page claims, every `[^cite-id]` footnote resolves to a
|
||||
# page in it, and `sources rebuild-index` writes `kb/provenance.md` from it. An
|
||||
# instance may add, rename or drop any collection that is not on this list;
|
||||
# renaming one that is leaves those three with nothing to resolve against.
|
||||
STACK_REQUIRED_COLLECTIONS = ("sources",)
|
||||
# Which link labels a page in this collection may use, per destination
|
||||
# collection. The **source** collection decides, which is the whole point: an
|
||||
# edge is an authored reader-aid written on the page that asserts it, so the
|
||||
# rules that govern it are the rules of the collection that page lives in. A
|
||||
# destination is another collection's name, or `any`.
|
||||
#
|
||||
# This is Commonplace's ADR-019 adopted directly, and it is what makes a
|
||||
# 35-label catalogue usable: a collection authorises the six that make sense
|
||||
# from it, and the rest of the palette is simply not on its menu.
|
||||
OUTBOUND_FIELD = "outbound"
|
||||
ANY_DESTINATION = "any"
|
||||
|
||||
# The types `wikitool` itself depends on existing, as opposed to ones an
|
||||
# instance keeps because they are useful. `source` is here because the whole
|
||||
# `raw/ -> kb/` provenance path is built on it: `sources coverage` asks which
|
||||
# raw files no source page claims, every `[^cite-id]` resolves to a source page,
|
||||
# and `sources rebuild-index` writes `kb/provenance.md` from them. All three ask
|
||||
# `page.kind == "source"`, so what is load-bearing is the type-spec's `name:`
|
||||
# and its schema requiring `raw_files:` - not the directory, not the title
|
||||
# prefix, and not a word of its prose or its template.
|
||||
#
|
||||
# That is the whole anchor, and it is deliberately this small: the four page
|
||||
# type-specs belong to the instance (see types/type-spec.md), so anything more
|
||||
# would be the stack reaching into a file it does not own.
|
||||
STACK_REQUIRED_TYPES = ("source",)
|
||||
STACK_REQUIRED_TYPE_FIELDS = {"source": ("raw_files",)}
|
||||
|
||||
|
||||
def stack_required_collections() -> tuple[str, ...]:
|
||||
"""Collection names an instance may not rename or drop.
|
||||
|
||||
**Derived, not listed.** The required collection is whichever one the
|
||||
required type writes into - so an instance that legitimately renames
|
||||
`kb/sources/` to something else, and says so in the type-spec's `base_dir:`,
|
||||
stays consistent instead of tripping a constant that hardcoded the old name.
|
||||
A second literal list would only be a copy that drifts.
|
||||
"""
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
names: list[str] = []
|
||||
for type_name in STACK_REQUIRED_TYPES:
|
||||
try:
|
||||
type_path = resolver.find_type_by_name(type_name)
|
||||
if not type_path:
|
||||
continue
|
||||
if resolver.get_root(type_path) != "kb":
|
||||
continue
|
||||
base_dir = resolver.get_base_dir(type_path)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if base_dir:
|
||||
names.append(str(base_dir).strip("/"))
|
||||
return tuple(dict.fromkeys(names))
|
||||
|
||||
|
||||
def iter_kb_collections(kb_dir: Path | None = None) -> list[Path]:
|
||||
@@ -119,6 +164,25 @@ def collection_declaration(collection: Path) -> dict[str, Any]:
|
||||
return frontmatter
|
||||
|
||||
|
||||
def authorised_labels(source: str, destination: str, kb_dir: Path | None = None) -> set[str]:
|
||||
"""Labels a page in `source` may use on an edge into `destination`.
|
||||
|
||||
The union of the destination's own entry and `any`. An empty result means
|
||||
the collection authorises nothing for that destination - which is a real
|
||||
answer ("do not link there from here"), not a missing declaration.
|
||||
"""
|
||||
root = kb_dir if kb_dir is not None else config.KB_DIR
|
||||
declared = collection_declaration(root / source).get(OUTBOUND_FIELD)
|
||||
if not isinstance(declared, dict):
|
||||
return set()
|
||||
labels: set[str] = set()
|
||||
for key in (destination, ANY_DESTINATION):
|
||||
entry = declared.get(key)
|
||||
if isinstance(entry, list):
|
||||
labels.update(str(label).strip() for label in entry if str(label).strip())
|
||||
return labels
|
||||
|
||||
|
||||
def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||
"""What each `COLLECTION.md` fails to declare about itself.
|
||||
|
||||
@@ -127,19 +191,22 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||
free text, because the profile catalogue is a palette rather than an enum,
|
||||
and a collection an instance invented has no entry there to name.
|
||||
`required_by_stack:` is not the instance's to choose at all: it must agree
|
||||
with `STACK_REQUIRED_COLLECTIONS`, so a collection whose contract claims the
|
||||
stack depends on it - or one the stack does depend on and that says it does
|
||||
not - is a finding rather than a preference.
|
||||
with what `stack_required_collections()` derives from the required types, so
|
||||
a collection whose contract claims the stack depends on it - or one the
|
||||
stack does depend on and that says it does not - is a finding rather than a
|
||||
preference.
|
||||
"""
|
||||
root = kb_dir if kb_dir is not None else config.KB_DIR
|
||||
issues: list[str] = []
|
||||
|
||||
required = stack_required_collections()
|
||||
present = {path.name for path in iter_kb_collections(root)}
|
||||
for name in STACK_REQUIRED_COLLECTIONS:
|
||||
for name in required:
|
||||
if name not in present:
|
||||
issues.append(
|
||||
f"kb/{name}/ is missing - `sources coverage`, `[^cite-id]` resolution and "
|
||||
f"`kb/provenance.md` all resolve against it by name"
|
||||
f"kb/{name}/ is missing - it is where the stack-required `source` type writes, "
|
||||
f"and `sources coverage`, `[^cite-id]` resolution and `kb/provenance.md` all "
|
||||
f"depend on those pages existing"
|
||||
)
|
||||
|
||||
for collection in iter_kb_collections(root):
|
||||
@@ -159,17 +226,17 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||
f"instructions/kb-profiles.md this collection adopted, or `none`"
|
||||
)
|
||||
|
||||
required = declared.get(REQUIRED_BY_STACK_FIELD)
|
||||
expected = collection.name in STACK_REQUIRED_COLLECTIONS
|
||||
if not isinstance(required, bool):
|
||||
required_flag = declared.get(REQUIRED_BY_STACK_FIELD)
|
||||
expected = collection.name in required
|
||||
if not isinstance(required_flag, bool):
|
||||
issues.append(
|
||||
f"{relative}: `{REQUIRED_BY_STACK_FIELD}:` is missing or not a boolean - "
|
||||
f"it must be {str(expected).lower()} for this collection"
|
||||
)
|
||||
elif required != expected:
|
||||
elif required_flag != expected:
|
||||
issues.append(
|
||||
f"{relative}: `{REQUIRED_BY_STACK_FIELD}: {str(required).lower()}` contradicts "
|
||||
f"the stack, which "
|
||||
f"{relative}: `{REQUIRED_BY_STACK_FIELD}: {str(required_flag).lower()}` "
|
||||
f"contradicts the stack, which "
|
||||
+ (
|
||||
"does depend on this collection by name"
|
||||
if expected
|
||||
|
||||
+101
-6
@@ -39,6 +39,16 @@ def kb_state_file() -> Path:
|
||||
return config.ROOT / KB_STATE_FILENAME
|
||||
|
||||
|
||||
# Whether a migration has to run, as opposed to how it is carried out. The two
|
||||
# are independent: a `mechanical` migration can be optional and an `assisted`
|
||||
# one mandatory. Keeping them on one axis is what would make `migrate status`
|
||||
# cry wolf - an instance nagged about an improvement it declined stops reading
|
||||
# the nag that means its content no longer fits the machinery.
|
||||
REQUIRED = "required"
|
||||
OFFERED = "offered"
|
||||
OBLIGATIONS = (REQUIRED, OFFERED)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
"""One migration document under `instructions/migrations/`."""
|
||||
@@ -48,6 +58,11 @@ class Migration:
|
||||
kind: str # "mechanical" | "assisted"
|
||||
description: str
|
||||
path: Path
|
||||
obligation: str = REQUIRED
|
||||
|
||||
@property
|
||||
def is_required(self) -> bool:
|
||||
return self.obligation != OFFERED
|
||||
|
||||
@property
|
||||
def relative_path(self) -> str:
|
||||
@@ -130,6 +145,7 @@ def load_migrations() -> list[Migration]:
|
||||
target = Version.parse(str(raw_target))
|
||||
except VersionError:
|
||||
continue
|
||||
obligation = str(frontmatter.get("obligation") or REQUIRED)
|
||||
migrations.append(
|
||||
Migration(
|
||||
name=str(frontmatter.get("name") or path.stem),
|
||||
@@ -137,6 +153,7 @@ def load_migrations() -> list[Migration]:
|
||||
kind=str(frontmatter.get("migration_kind") or "assisted"),
|
||||
description=str(frontmatter.get("description") or ""),
|
||||
path=path,
|
||||
obligation=obligation if obligation in OBLIGATIONS else REQUIRED,
|
||||
)
|
||||
)
|
||||
return sorted(migrations, key=lambda m: m.target)
|
||||
@@ -147,13 +164,46 @@ def chain(
|
||||
) -> list[Migration]:
|
||||
"""The migrations still owed, in the order they must run.
|
||||
|
||||
Every migration whose target lies in `(kb_version, stack_version]`, oldest
|
||||
first. An instance at 1.3.1 upgrading to 2.0.0 gets 1.4.0, 1.7.0, 2.0.0 -
|
||||
and the absence of any migration targeting 1.3.x is not a special case, it
|
||||
simply is not in the interval. Targets above the installed machinery are
|
||||
excluded: the instance has no code for them yet.
|
||||
Every **required** migration whose target lies in
|
||||
`(kb_version, stack_version]`, oldest first. An instance at 1.3.1 upgrading
|
||||
to 2.0.0 gets 1.4.0, 1.7.0, 2.0.0 - and the absence of any migration
|
||||
targeting 1.3.x is not a special case, it simply is not in the interval.
|
||||
Targets above the installed machinery are excluded: the instance has no code
|
||||
for them yet.
|
||||
|
||||
`offered` migrations are deliberately absent. They are not links in the
|
||||
version chain: declining one leaves the content in a shape the machinery
|
||||
still accepts, so counting it as owed would make `kb_version` unreachable
|
||||
for an instance that simply kept its own file.
|
||||
"""
|
||||
return [m for m in migrations if kb_version < m.target <= stack_version]
|
||||
return [
|
||||
m for m in migrations if m.is_required and kb_version < m.target <= stack_version
|
||||
]
|
||||
|
||||
|
||||
def applied_names(state: Optional[dict]) -> set[str]:
|
||||
"""Every migration this instance has recorded as carried out."""
|
||||
entries = (state or {}).get("applied") or []
|
||||
return {
|
||||
str(entry.get("migration"))
|
||||
for entry in entries
|
||||
if isinstance(entry, dict) and entry.get("migration")
|
||||
}
|
||||
|
||||
|
||||
def offers(migrations: list[Migration], applied: set[str]) -> list[Migration]:
|
||||
"""Optional upgrades this instance has not taken, oldest target first.
|
||||
|
||||
Bounded by the **applied ledger**, not by `kb_version`, and that is not a
|
||||
detail: taking an offer deliberately does not move `kb_version`, so the
|
||||
version says nothing about whether an offer was taken. Filtering by it
|
||||
would hide every offer the moment some unrelated required migration ran.
|
||||
|
||||
Not bounded above by the stack version either. An offer is about a file the
|
||||
instance owns rather than about the shape of its content, so it stays on the
|
||||
table until it is recorded - or until the operator deletes the document.
|
||||
"""
|
||||
return [m for m in migrations if not m.is_required and m.name not in applied]
|
||||
|
||||
|
||||
def next_link(
|
||||
@@ -161,3 +211,48 @@ def next_link(
|
||||
) -> Optional[Migration]:
|
||||
pending = chain(migrations, kb_version, stack_version)
|
||||
return pending[0] if pending else None
|
||||
|
||||
|
||||
# --- what this instance changed about what it was given --------------------
|
||||
|
||||
|
||||
def divergent_files() -> Optional[list[str]]:
|
||||
"""Files whose content no longer matches the release this instance installed.
|
||||
|
||||
Reads the per-file sha256 in `.wikitool-release.json`, which `dist export`
|
||||
has been writing since the stamp existed and which nothing has read until
|
||||
now. Its own docstring says why it is there: it is the only way a later
|
||||
upgrade can tell a file the instance *edited* from one it merely *received*.
|
||||
|
||||
That distinction is what makes an `offered` migration actionable. The stack
|
||||
proposing a better `entity` template needs to know whether it may be copied
|
||||
over or whether the instance has its own version that a person has to
|
||||
reconcile - and only the recorded hash can answer that.
|
||||
|
||||
Returns None when the question is unanswerable (a development tree, which
|
||||
carries no stamp), which is different from `[]` (nothing diverged).
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
from chemenu import version as version_mod
|
||||
|
||||
try:
|
||||
stamp = version_mod.read_stamp()
|
||||
except VersionError:
|
||||
return None
|
||||
if not stamp:
|
||||
return None
|
||||
recorded = stamp.get("files")
|
||||
if not isinstance(recorded, dict):
|
||||
return None
|
||||
|
||||
divergent: list[str] = []
|
||||
for relative, digest in sorted(recorded.items()):
|
||||
path = config.ROOT / relative
|
||||
if not path.is_file():
|
||||
divergent.append(relative)
|
||||
continue
|
||||
current = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if current != digest:
|
||||
divergent.append(relative)
|
||||
return divergent
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Labelled edges in a page's `related:` frontmatter.
|
||||
|
||||
An edge is a **label plus a target**, and the label is an identifier rather than
|
||||
prose:
|
||||
|
||||
related:
|
||||
- depends-on: Hermes
|
||||
- implements: Hybrid Search
|
||||
|
||||
It used to be a bare list of titles with the label written only into a body
|
||||
bullet - which meant the graph's semantics lived in German prose the tool had to
|
||||
parse back, and the vocabulary drifted to 152 distinct labels in 337 bullets
|
||||
because nothing could check it. The label moves into the data; the body bullet
|
||||
becomes a rendering of the data.
|
||||
|
||||
**Both shapes read.** A bare string is an edge whose label is not yet declared,
|
||||
which is exactly the state a page is in between the machinery landing and the
|
||||
corpus migration reaching that page. Readers therefore never crash on the old
|
||||
shape, and `lint` is what reports it - the migration is finished when no
|
||||
unlabelled edge is left.
|
||||
|
||||
Direction is authored, never mirrored: an edge lives on the page that asserts
|
||||
it, and the inbound view is rendered from the graph rather than stored. See
|
||||
instructions/link-taxonomy.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
# The label a not-yet-migrated bare-string edge reports as. Deliberately not a
|
||||
# real catalogue label: it must be impossible for an instance to authorise it,
|
||||
# so `lint` cannot be satisfied by declaring the placeholder legal.
|
||||
UNLABELLED = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Edge:
|
||||
"""One declared relationship: what this page asserts about `target`."""
|
||||
|
||||
target: str
|
||||
label: Optional[str] = UNLABELLED
|
||||
|
||||
@property
|
||||
def is_labelled(self) -> bool:
|
||||
return bool(self.label)
|
||||
|
||||
|
||||
def parse_entry(entry: Any) -> Optional[Edge]:
|
||||
"""One `related:` element as an Edge, or None if it is not one at all.
|
||||
|
||||
A single-key mapping is a labelled edge; a bare string is an unlabelled one.
|
||||
Anything else - a multi-key mapping, a list, a number - is malformed, and
|
||||
returning None rather than guessing is what lets `lint` report it as a
|
||||
finding instead of a reader silently inventing an edge.
|
||||
"""
|
||||
if isinstance(entry, str):
|
||||
title = entry.strip()
|
||||
return Edge(title) if title else None
|
||||
if isinstance(entry, dict) and len(entry) == 1:
|
||||
(label, target), = entry.items()
|
||||
label, target = str(label).strip(), str(target).strip()
|
||||
return Edge(target, label) if label and target else None
|
||||
return None
|
||||
|
||||
|
||||
def edges(frontmatter: dict[str, Any], field: str) -> list[Edge]:
|
||||
"""Every well-formed edge in `field`, in file order."""
|
||||
parsed = (parse_entry(entry) for entry in (frontmatter.get(field) or []))
|
||||
return [edge for edge in parsed if edge is not None]
|
||||
|
||||
|
||||
def malformed(frontmatter: dict[str, Any], field: str) -> list[Any]:
|
||||
"""Elements of `field` that are neither a title nor a `label: target` pair."""
|
||||
return [
|
||||
entry for entry in (frontmatter.get(field) or []) if parse_entry(entry) is None
|
||||
]
|
||||
|
||||
|
||||
def targets(frontmatter: dict[str, Any], field: str) -> list[str]:
|
||||
"""Just the page titles in `field`, labelled or not.
|
||||
|
||||
The compatibility seam. Every caller that only ever wanted "which pages does
|
||||
this reference" - dangling-reference checks, `rename`, `rm`, the link graph -
|
||||
goes through here and is untouched by the label carried alongside.
|
||||
"""
|
||||
return [edge.target for edge in edges(frontmatter, field)]
|
||||
|
||||
|
||||
def render(edge_list: Iterable[Edge]) -> list[Any]:
|
||||
"""Edges back into frontmatter form, ready for `dump_frontmatter`.
|
||||
|
||||
An unlabelled edge round-trips as a bare string rather than being promoted
|
||||
to some default label: inventing one here would erase the very thing `lint`
|
||||
is looking for.
|
||||
"""
|
||||
rendered: list[Any] = []
|
||||
for edge in edge_list:
|
||||
rendered.append({edge.label: edge.target} if edge.is_labelled else edge.target)
|
||||
return rendered
|
||||
|
||||
|
||||
def upsert(frontmatter: dict[str, Any], field: str, edge: Edge) -> bool:
|
||||
"""Add or relabel `edge` in `field`. True if anything changed.
|
||||
|
||||
Idempotent by target: one page asserts one thing about another, so a second
|
||||
call with a different label *replaces* rather than appends. Two edges to the
|
||||
same target would render two bullets and leave no way to say which is meant.
|
||||
"""
|
||||
current = edges(frontmatter, field)
|
||||
for position, existing in enumerate(current):
|
||||
if existing.target == edge.target:
|
||||
if existing.label == edge.label:
|
||||
return False
|
||||
current[position] = edge
|
||||
frontmatter[field] = render(current)
|
||||
return True
|
||||
current.append(edge)
|
||||
frontmatter[field] = render(current)
|
||||
return True
|
||||
|
||||
|
||||
def remove(frontmatter: dict[str, Any], field: str, target: str) -> bool:
|
||||
"""Drop every edge pointing at `target`. True if anything changed."""
|
||||
current = edges(frontmatter, field)
|
||||
kept = [edge for edge in current if edge.target != target]
|
||||
if len(kept) == len(current):
|
||||
return False
|
||||
frontmatter[field] = render(kept)
|
||||
return True
|
||||
|
||||
|
||||
def retarget(frontmatter: dict[str, Any], field: str, old: str, new: str) -> bool:
|
||||
"""Repoint every edge from `old` to `new`, keeping its label."""
|
||||
current = edges(frontmatter, field)
|
||||
changed = False
|
||||
for position, edge in enumerate(current):
|
||||
if edge.target == old:
|
||||
current[position] = Edge(new, edge.label)
|
||||
changed = True
|
||||
if changed:
|
||||
frontmatter[field] = render(current)
|
||||
return changed
|
||||
@@ -16,7 +16,7 @@ from __future__ import annotations
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import blocks, config, kb_collections, links
|
||||
from chemenu.frontmatter_io import frontmatter_error
|
||||
from chemenu.markdown_code import strip_code_spans
|
||||
from chemenu.provenance import broken_raw_refs as find_broken_raw_refs
|
||||
@@ -164,7 +164,16 @@ def run_lint(kb_dir: Path) -> dict:
|
||||
# propagated, a deleted page, or a URL pasted where a title belongs - used
|
||||
# to pass every check. Which fields hold page titles is declared by each
|
||||
# type-spec's `page_ref_fields:`, not hardcoded here.
|
||||
def _collection_of(page):
|
||||
try:
|
||||
return page.path.relative_to(config.KB_DIR).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
dangling_frontmatter_refs = []
|
||||
malformed_edges: list[dict] = []
|
||||
unlabelled_edges: list[dict] = []
|
||||
unauthorised_labels: list[dict] = []
|
||||
for title, page in sorted(pages.items()):
|
||||
type_path = page.frontmatter.get("type")
|
||||
if not type_path:
|
||||
@@ -174,11 +183,53 @@ def run_lint(kb_dir: Path) -> dict:
|
||||
except ValueError:
|
||||
continue # unresolvable type is already reported as type_resolution_errors
|
||||
for field in ref_fields:
|
||||
for target in page.frontmatter.get(field) or []:
|
||||
# Through `links` so a labelled edge (`- depends-on: Hermes`) is read
|
||||
# as its target rather than as a mapping - the entry carries the
|
||||
# label alongside the title now, and comparing the whole entry would
|
||||
# report every declared edge as dangling.
|
||||
for target in links.targets(page.frontmatter, field):
|
||||
if target not in pages:
|
||||
dangling_frontmatter_refs.append(
|
||||
{"page": title, "field": field, "target": target}
|
||||
)
|
||||
for entry in links.malformed(page.frontmatter, field):
|
||||
malformed_edges.append(
|
||||
{"page": title, "field": field, "entry": str(entry)}
|
||||
)
|
||||
# Labels are checked on `related:` only. `sources:`/`entities:`/
|
||||
# `concepts:` are the provenance path, unlabelled by construction.
|
||||
if "related" in ref_fields:
|
||||
source_collection = _collection_of(page)
|
||||
for edge in links.edges(page.frontmatter, "related"):
|
||||
if not edge.is_labelled:
|
||||
unlabelled_edges.append({"page": title, "target": edge.target})
|
||||
continue
|
||||
target_page = pages.get(edge.target)
|
||||
if source_collection is None or target_page is None:
|
||||
continue
|
||||
destination = _collection_of(target_page)
|
||||
if destination is None:
|
||||
continue
|
||||
allowed = kb_collections.authorised_labels(source_collection, destination)
|
||||
if edge.label not in allowed:
|
||||
unauthorised_labels.append(
|
||||
{
|
||||
"page": title,
|
||||
"target": edge.target,
|
||||
"label": edge.label,
|
||||
"destination": destination,
|
||||
}
|
||||
)
|
||||
|
||||
# A generated region whose markers do not pair up is not a tidiness problem:
|
||||
# the next write appends a second region beside it instead of replacing it,
|
||||
# and the page then carries two. An agent rewriting prose at the boundary is
|
||||
# how a marker goes missing, which is why this is a hard error.
|
||||
unbalanced_marker_findings = [
|
||||
{"page": title, "region": name}
|
||||
for title, page in sorted(pages.items())
|
||||
for name in blocks.unbalanced_markers(page.body)
|
||||
]
|
||||
|
||||
quote_limit_violations = []
|
||||
for title, page in sorted(pages.items()):
|
||||
@@ -238,6 +289,10 @@ def run_lint(kb_dir: Path) -> dict:
|
||||
"undefined_footnote_refs": undefined_footnote_refs,
|
||||
"orphan_footnote_defs": orphan_footnote_defs,
|
||||
"dangling_frontmatter_refs": dangling_frontmatter_refs,
|
||||
"malformed_edges": malformed_edges,
|
||||
"unlabelled_edges": unlabelled_edges,
|
||||
"unauthorised_labels": unauthorised_labels,
|
||||
"unbalanced_markers": unbalanced_marker_findings,
|
||||
"quote_limit_violations": quote_limit_violations,
|
||||
"invalid_type_paths": invalid_type_paths,
|
||||
"type_resolution_errors": type_resolution_errors,
|
||||
@@ -322,6 +377,23 @@ def render_markdown(report: dict) -> str:
|
||||
lines, "Orphan Footnote Definitions", report["orphan_footnote_defs"],
|
||||
lambda i: f"[[{i['page']}]] defines `[^{i['id']}]` (-> [[{i['source']}]]) but nothing references it - run `wikitool cite sync`",
|
||||
)
|
||||
_section(
|
||||
lines, "Malformed Edges", report.get("malformed_edges", []),
|
||||
lambda i: f"[[{i['page']}]] `{i['field']}`: {i['entry']}",
|
||||
)
|
||||
_section(
|
||||
lines, "Unbalanced Generated-Region Markers", report.get("unbalanced_markers", []),
|
||||
lambda i: f"[[{i['page']}]]: `{i['region']}`",
|
||||
)
|
||||
_section(
|
||||
lines, "Unlabelled Edges", report.get("unlabelled_edges", []),
|
||||
lambda i: f"[[{i['page']}]] -> [[{i['target']}]]",
|
||||
)
|
||||
_section(
|
||||
lines, "Labels Not Authorised by the Source Collection",
|
||||
report.get("unauthorised_labels", []),
|
||||
lambda i: f"[[{i['page']}]] `{i['label']}` -> kb/{i['destination']}/ ([[{i['target']}]])",
|
||||
)
|
||||
_section(
|
||||
lines, "Dangling Frontmatter References", report["dangling_frontmatter_refs"],
|
||||
lambda i: f"[[{i['page']}]] `{i['field']}:` names `{i['target']}`, which is not a page",
|
||||
@@ -406,6 +478,16 @@ def default_report_path(report: dict) -> Path:
|
||||
# through the index or navigation only. `quote_limit_violations` is advisory
|
||||
# too - it flags a habit, not a broken tree.
|
||||
#
|
||||
# `unlabelled_edges` and `unauthorised_labels` are advisory **for now**, and
|
||||
# that is a dated decision rather than a judgment about severity: they describe
|
||||
# exactly the state a corpus is in between the 4.0.0 machinery landing and the
|
||||
# migration reaching each page, which is the window `.wikitool-kb.json` exists
|
||||
# to represent. They become hard errors once the migration is recorded - the
|
||||
# same path `legacy_citation_markers` took.
|
||||
#
|
||||
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
|
||||
# describes an unconverted page, only a broken one.
|
||||
#
|
||||
# One definition, used by `lint --fail-on-error` and by the eval scorecard: if
|
||||
# the two disagreed, a run could pass its score while lint refused it.
|
||||
HARD_ERROR_KEYS = (
|
||||
@@ -421,6 +503,8 @@ HARD_ERROR_KEYS = (
|
||||
"undefined_footnote_refs",
|
||||
"orphan_footnote_defs",
|
||||
"dangling_frontmatter_refs",
|
||||
"malformed_edges",
|
||||
"unbalanced_markers",
|
||||
"invalid_type_paths",
|
||||
"type_resolution_errors",
|
||||
"schema_validation_errors",
|
||||
|
||||
+104
-95
@@ -20,7 +20,7 @@ import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config, sections
|
||||
from chemenu import blocks, config, conventions
|
||||
from chemenu.markdown_code import strip_code_spans
|
||||
from chemenu.page import Page
|
||||
|
||||
@@ -45,10 +45,6 @@ CITE_DEF_RE = re.compile(
|
||||
)
|
||||
CITE_REF_RE = re.compile(rf"\[\^({_CITE_ID_PATTERN})\]")
|
||||
|
||||
# Where the Footnotes block stops: the next ATX heading of any level. Without
|
||||
# this the block ran to the end of the file and took any following section with
|
||||
# it - see split_cite_block().
|
||||
_NEXT_HEADING_RE = re.compile(r"^#{1,6} ", re.MULTILINE)
|
||||
|
||||
# The pre-migration marker: `^[[Source - X]]` or `^[[Source - X|file.md]]`,
|
||||
# read by a Pandoc-style parser as an inline footnote wrapping a broken
|
||||
@@ -61,27 +57,29 @@ LEGACY_CITE_RE = re.compile(r"\^\[\[([^\]|#]+)(?:\|([^\]]+))?\]\]")
|
||||
# footnote definitions regardless of the heading text; this heading is purely
|
||||
# for human readability when the raw markdown is read directly.
|
||||
#
|
||||
# Written under the canonical name, but split_cite_block() matches the aliases
|
||||
# too - a page whose block still says "## Footnotes" keeps working until it is
|
||||
# translated. See chemenu/sections.py.
|
||||
# The prefix a source page's title carries, stripped when minting a cite id so
|
||||
# the id is not "s-source-x". It is the `source` type-spec's own
|
||||
# `title_prefix:`, asked for at call time rather than written down here: the
|
||||
# type-spec belongs to the instance, so hardcoding the string made a documented
|
||||
# instance decision into a compiler constant - the same leak `sections.py` had.
|
||||
#
|
||||
# Resolved on access rather than bound at import (PEP 562), because the
|
||||
# canonical name is now this instance's own - `kb/CONVENTIONS.md`, via
|
||||
# chemenu.conventions - and a module constant would freeze whichever corpus the
|
||||
# process started in. The functions below take it as a default the same way, via
|
||||
# None rather than an evaluated default argument.
|
||||
def __getattr__(name: str) -> str:
|
||||
if name == "CITE_BLOCK_HEADING":
|
||||
return f"## {sections.FOOTNOTES}"
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
# The literal survives as the fallback for a tree with no resolvable `source`
|
||||
# type (a fixture, a half-built instance). It is what this stack shipped, so a
|
||||
# corpus that can reach the fallback was minted under it, and ids stay stable.
|
||||
_FALLBACK_SOURCE_TITLE_PREFIX = "Source - "
|
||||
|
||||
|
||||
def cite_block_heading_default() -> str:
|
||||
"""The Footnotes heading this instance writes, `## ` included."""
|
||||
return f"## {sections.FOOTNOTES}"
|
||||
def source_title_prefix() -> str:
|
||||
"""This instance's source-page title prefix, from the type-spec."""
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
|
||||
_SOURCE_TITLE_PREFIX = "Source - "
|
||||
try:
|
||||
type_path = resolver.find_type_by_name("source")
|
||||
if type_path:
|
||||
return resolver.get_title_prefix(type_path)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return _FALLBACK_SOURCE_TITLE_PREFIX
|
||||
|
||||
|
||||
|
||||
@@ -117,7 +115,8 @@ def cite_id(title: str, qualifier: Optional[str] = None) -> str:
|
||||
NFKD transliteration is lossy), so callers resolving a real page use
|
||||
unique_cite_id() to add a `-2`/`-3` suffix on collision.
|
||||
"""
|
||||
base_title = title[len(_SOURCE_TITLE_PREFIX):] if title.startswith(_SOURCE_TITLE_PREFIX) else title
|
||||
prefix = source_title_prefix()
|
||||
base_title = title[len(prefix):] if prefix and title.startswith(prefix) else title
|
||||
slug = "s-" + _slugify(base_title)
|
||||
if qualifier:
|
||||
slug += "--" + _slugify(qualifier)
|
||||
@@ -138,62 +137,64 @@ def unique_cite_id(existing_ids: set[str], title: str, qualifier: Optional[str]
|
||||
return f"{base}-{suffix}"
|
||||
|
||||
|
||||
def split_cite_block(body: str) -> tuple[str, dict[str, tuple[str, Optional[str]]]]:
|
||||
"""Split the Footnotes block off `body`.
|
||||
# Headings a pre-4.0.0 page carries above its citation definitions, for the
|
||||
# migration window only. Before the block was delimited it was *located* by this
|
||||
# text, which is why there are four of them - two languages times two eras. The
|
||||
# list is read, never written, and `instructions/migrations/` removes the need
|
||||
# for it once every page carries markers.
|
||||
_LEGACY_FOOTNOTE_HEADINGS = ("Fußnoten", "Footnotes", "Fussnoten", "Notes")
|
||||
|
||||
Returns (body_without_block, definitions), where definitions maps
|
||||
cite_id -> (source_title, qualifier_or_None) in file order. If there is
|
||||
no Footnotes block, definitions is {} and body is returned with trailing
|
||||
blank lines trimmed (so re-rendering after emptying the block is stable).
|
||||
_LEGACY_HEADING_RE = re.compile(
|
||||
r"^## (?:" + "|".join(re.escape(name) for name in _LEGACY_FOOTNOTE_HEADINGS) + r")[ \t]*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
_NEXT_HEADING_RE = re.compile(r"^#{1,6} ", re.MULTILINE)
|
||||
|
||||
**The block is not "everything to the end of the file".** It used to be,
|
||||
and every caller here reassembles a page as `head + rendered block` - so a
|
||||
section that happened to sit after the block was silently deleted on the
|
||||
next `cite add`, `cite sync` or `rename`. That is not hypothetical: `xref
|
||||
add` appends its Relationships and See Also sections at the end of the
|
||||
file, so whether a page kept its cross-references came down to which of the
|
||||
two commands ran last. Eight pages were carrying content in that position
|
||||
when this was found.
|
||||
|
||||
So the block ends where the next heading begins, and everything after it -
|
||||
plus anything inside it that is not a citation definition - is folded back
|
||||
on to `head`. Nothing is discarded, and because the rendered block is
|
||||
always emitted last, a page that had drifted into the broken layout is
|
||||
normalised the first time any of these commands touches it.
|
||||
def _definitions_in(block: str) -> dict[str, tuple[str, Optional[str]]]:
|
||||
"""Every `[^id]: [[Target]]` definition in one region, code masked out.
|
||||
|
||||
A fenced example of a definition line is an illustration, not a definition.
|
||||
`strip_code_spans` preserves offsets and line structure, so the masked text
|
||||
reads line-for-line against the real one.
|
||||
"""
|
||||
# Where the block *starts* is decided on the unmasked body, deliberately.
|
||||
# Masking first would mean one unclosed fence anywhere in the prose blanks
|
||||
# the real `## Footnotes` heading too, and the page then reads as having no
|
||||
# definitions at all - every citation on it undefined, from a single typo.
|
||||
# A fenced example of the heading itself is the rarer accident and the
|
||||
# cheaper one: it costs one page its block, not every citation on it.
|
||||
match = sections.heading_re(sections.FOOTNOTES).search(body)
|
||||
masked = strip_code_spans(block)
|
||||
return {
|
||||
m.group(1): (m.group(2).strip(), m.group(3).strip() if m.group(3) else None)
|
||||
for m in CITE_DEF_RE.finditer(masked)
|
||||
}
|
||||
|
||||
|
||||
def _split_legacy_block(body: str) -> tuple[str, dict[str, tuple[str, Optional[str]]]]:
|
||||
"""The pre-marker layout: a heading, then definitions, ending at the next
|
||||
heading.
|
||||
|
||||
Kept only so the corpus stays readable between this machinery landing and
|
||||
the migration reaching each page. Every weakness of the old approach lives
|
||||
here - it guesses the region's end, and it can be fooled by a fenced example
|
||||
of the heading - which is the argument the marker pair settles.
|
||||
"""
|
||||
match = _LEGACY_HEADING_RE.search(body)
|
||||
if not match:
|
||||
return body.rstrip("\n"), {}
|
||||
head, rest = body[: match.start()], body[match.end():]
|
||||
|
||||
next_section = _NEXT_HEADING_RE.search(rest)
|
||||
block, trailing = (rest[: next_section.start()], rest[next_section.start():]) if next_section else (rest, "")
|
||||
following = _NEXT_HEADING_RE.search(rest)
|
||||
block, trailing = (
|
||||
(rest[: following.start()], rest[following.start():]) if following else (rest, "")
|
||||
)
|
||||
|
||||
# Inside the block, code is masked: a fenced example of a definition line is
|
||||
# an illustration, not a definition. strip_code_spans() preserves offsets
|
||||
# and line structure, so the masked block can be read line-for-line against
|
||||
# the real one.
|
||||
definitions = _definitions_in(block)
|
||||
masked_block = strip_code_spans(block)
|
||||
definitions = {
|
||||
m.group(1): (m.group(2).strip(), m.group(3).strip() if m.group(3) else None)
|
||||
for m in CITE_DEF_RE.finditer(masked_block)
|
||||
}
|
||||
# Lines inside the block that are not definitions are content too - prose
|
||||
# someone left there, a stray bullet. Rescued rather than rejected: this
|
||||
# runs under `lint` and `corpus_diff` as well, where raising would refuse
|
||||
# to read a page instead of reporting it.
|
||||
# someone left there, a stray bullet. Rescued rather than rejected: this runs
|
||||
# under `lint` and `corpus_diff` as well, where raising would refuse to read
|
||||
# a page instead of reporting it.
|
||||
stray = "\n".join(
|
||||
line
|
||||
for line, masked in zip(block.splitlines(), masked_block.splitlines())
|
||||
if line.strip() and not CITE_DEF_RE.match(masked)
|
||||
)
|
||||
|
||||
rescued = "\n\n".join(part.strip("\n") for part in (stray, trailing) if part.strip())
|
||||
head = head.rstrip("\n")
|
||||
if rescued:
|
||||
@@ -201,49 +202,57 @@ def split_cite_block(body: str) -> tuple[str, dict[str, tuple[str, Optional[str]
|
||||
return head, definitions
|
||||
|
||||
|
||||
def cite_block_heading(body: str) -> str:
|
||||
"""The Footnotes heading `body` actually carries, canonical if it has none.
|
||||
def split_cite_block(body: str) -> tuple[str, dict[str, tuple[str, Optional[str]]]]:
|
||||
"""Split the citation region off `body`.
|
||||
|
||||
Rewriting a page must not silently retitle its block: a page still using an
|
||||
alias is untranslated, not broken, and `cite sync` has to stay a no-op on
|
||||
it. Translating the heading is the migration's job, not the tool's."""
|
||||
match = sections.heading_re(sections.FOOTNOTES).search(body)
|
||||
return match.group(0).strip() if match else cite_block_heading_default()
|
||||
Returns (body_without_region, definitions), where definitions maps
|
||||
cite_id -> (source_title, qualifier_or_None) in file order.
|
||||
|
||||
**The region is delimited, not guessed.** It used to end "at the next
|
||||
heading", and before that "at the end of the file" - and every caller here
|
||||
reassembles a page as `head + rendered region`, so a section that happened to
|
||||
sit after it was silently deleted on the next `cite add`, `cite sync` or
|
||||
`rename`. Eight pages were carrying content in that position when it was
|
||||
found. A marker pair answers where the region stops exactly, which is the
|
||||
whole reason for it.
|
||||
|
||||
A page with no markers is read through the legacy path instead, so the
|
||||
corpus stays readable until the migration reaches it.
|
||||
"""
|
||||
region = blocks.find(body, blocks.FOOTNOTES)
|
||||
if region is None:
|
||||
return _split_legacy_block(body)
|
||||
return blocks.strip(body, blocks.FOOTNOTES).rstrip("\n"), _definitions_in(region)
|
||||
|
||||
|
||||
def render_cite_block(
|
||||
definitions: dict[str, tuple[str, Optional[str]]], heading: Optional[str] = None
|
||||
) -> str:
|
||||
"""Render the Footnotes block for `definitions` (cite_id -> (title,
|
||||
qualifier)), preserving dict order. Empty dict renders "" - a page with
|
||||
no citations carries no block at all.
|
||||
def render_cite_block(definitions: dict[str, tuple[str, Optional[str]]]) -> str:
|
||||
"""The citation region for `definitions`, markers included, in dict order.
|
||||
|
||||
`heading=None` means this instance's canonical Footnotes heading, resolved
|
||||
at call time. It cannot be an evaluated default: the name comes from
|
||||
`kb/CONVENTIONS.md`, so a default bound at import would answer for whichever
|
||||
corpus the process started in."""
|
||||
if not definitions:
|
||||
return ""
|
||||
lines = [heading or cite_block_heading_default(), ""]
|
||||
An empty dict renders "" - a page with no citations carries no region at
|
||||
all, rather than a heading with nothing under it.
|
||||
"""
|
||||
lines = []
|
||||
for cid, (title, qualifier) in definitions.items():
|
||||
target = f"{title}|{qualifier}" if qualifier else title
|
||||
lines.append(f"[^{cid}]: [[{target}]]")
|
||||
return "\n".join(lines) + "\n"
|
||||
return blocks.render(
|
||||
blocks.FOOTNOTES, conventions.heading(blocks.FOOTNOTES), lines
|
||||
)
|
||||
|
||||
|
||||
def render_page_body(
|
||||
head: str,
|
||||
definitions: dict[str, tuple[str, Optional[str]]],
|
||||
heading: Optional[str] = None,
|
||||
head: str, definitions: dict[str, tuple[str, Optional[str]]]
|
||||
) -> str:
|
||||
"""Reassemble a page body from its non-Footnotes content and citation
|
||||
definitions - the inverse of split_cite_block(). Pass the original body's
|
||||
`cite_block_heading()` to preserve an alias the page still uses."""
|
||||
head = head.rstrip("\n")
|
||||
block = render_cite_block(definitions, heading)
|
||||
if not block:
|
||||
return head + "\n"
|
||||
return head + "\n\n" + block
|
||||
"""Reassemble a page body from its non-citation content and its definitions -
|
||||
the inverse of `split_cite_block`.
|
||||
|
||||
The heading is no longer threaded through from the caller. It used to be, so
|
||||
that rewriting a page would not silently retitle a block whose text the tool
|
||||
was *matching on*; now the marker carries the identity and the heading is a
|
||||
rendering value, so re-rendering it under this instance's own words is a
|
||||
repair rather than a rename.
|
||||
"""
|
||||
return blocks.replace(head.rstrip("\n") + "\n", blocks.FOOTNOTES, render_cite_block(definitions))
|
||||
|
||||
|
||||
def extract_inline_cites(body: str) -> set[tuple[str, Optional[str]]]:
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""The section headings wikitool reads and writes inside a page body.
|
||||
|
||||
These headings are structural, not prose: `xref add` locates Relationships and
|
||||
See Also by name, and `cite add` owns the trailing Footnotes block. An author
|
||||
may add any other heading they like - only the ones named here are matched by
|
||||
the tool, and only these have to stay predictable.
|
||||
|
||||
**Which words they are is the instance's decision, not the stack's.** They
|
||||
follow the KB language, and the KB language is declared in `kb/CONVENTIONS.md`
|
||||
(see `chemenu.conventions`). This module used to hold `RELATIONSHIPS =
|
||||
"Beziehungen"` as a Python constant, which made an instance writing its pages
|
||||
in any other language edit the compiler to say so - the one place a documented
|
||||
instance convention had leaked into code.
|
||||
|
||||
Each heading has one **canonical** name - what the tool writes - and any number
|
||||
of **aliases** it still recognizes. That asymmetry is what lets a corpus migrate
|
||||
page by page instead of all at once: a page still carrying `## Relationships` is
|
||||
found and appended to correctly, and only takes the canonical name when the page
|
||||
itself is translated. Removing an alias is therefore a breaking change for every
|
||||
page not yet converted, not a cleanup.
|
||||
|
||||
The three module attributes below resolve on access (PEP 562), the same way
|
||||
`config`'s paths do and for the same reason: a caller that repoints `KB_DIR`
|
||||
must not be answered out of a value bound at import time by whichever tree the
|
||||
process started in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from chemenu import conventions
|
||||
|
||||
# The slots, re-exported so a caller keeps using `sections.RELATIONSHIPS` as an
|
||||
# opaque handle. The value it resolves to is the heading text; the name it is
|
||||
# looked up under is stable.
|
||||
_SLOT_ATTRS = {
|
||||
"RELATIONSHIPS": conventions.RELATIONSHIPS,
|
||||
"SEE_ALSO": conventions.SEE_ALSO,
|
||||
"FOOTNOTES": conventions.FOOTNOTES,
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> str:
|
||||
slot = _SLOT_ATTRS.get(name)
|
||||
if slot is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
return conventions.canonical(slot)
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted([*globals(), *_SLOT_ATTRS])
|
||||
|
||||
|
||||
def _slot_of(canonical: str) -> str:
|
||||
"""The slot whose current canonical name is `canonical`.
|
||||
|
||||
Callers hold on to the resolved heading text (`sections.FOOTNOTES`), not to
|
||||
the slot, so the lookup has to go back the other way. Falls back to matching
|
||||
against every name a slot is recognized under, so a caller that resolved the
|
||||
attribute before the conventions file changed still lands on the right slot.
|
||||
"""
|
||||
for slot in conventions.SLOTS:
|
||||
if canonical == conventions.canonical(slot):
|
||||
return slot
|
||||
for slot in conventions.SLOTS:
|
||||
if canonical in conventions.names(slot):
|
||||
return slot
|
||||
raise ValueError(f"{canonical!r} is not a tool-owned section heading")
|
||||
|
||||
|
||||
def names(canonical: str) -> tuple[str, ...]:
|
||||
"""Every name `canonical` is recognized under, canonical first."""
|
||||
return conventions.names(_slot_of(canonical))
|
||||
|
||||
|
||||
def heading_re(canonical: str) -> re.Pattern[str]:
|
||||
"""Match a `## <heading>` line for `canonical` or any of its aliases."""
|
||||
alternation = "|".join(re.escape(name) for name in names(canonical))
|
||||
return re.compile(rf"^## (?:{alternation})[ \t]*$", re.MULTILINE)
|
||||
|
||||
|
||||
def is_known(heading: str) -> bool:
|
||||
"""True if `heading` is a canonical name or an alias of one."""
|
||||
return any(heading in conventions.names(slot) for slot in conventions.SLOTS)
|
||||
@@ -171,9 +171,21 @@ def kb_dir(tmp_path: Path) -> Path:
|
||||
"entities/technologies", "entities/people",
|
||||
"concepts", "sources", "comparisons"):
|
||||
(kb / sub).mkdir(parents=True)
|
||||
# The contracts carry a real declaration, because three things now read one:
|
||||
# `docs verify` checks `profile:`/`required_by_stack:`, and `xref add` asks
|
||||
# `outbound:` whether a label is authorised from this collection. A fixture
|
||||
# contract without it would make every `xref add` in the suite fail for a
|
||||
# reason that has nothing to do with what the test is about.
|
||||
for collection in ("entities", "concepts", "sources", "comparisons"):
|
||||
(kb / collection / "COLLECTION.md").write_text(
|
||||
f"# kb/{collection}/ - Collection Contract\n", encoding="utf-8"
|
||||
"---\n"
|
||||
f"profile: {collection}\n"
|
||||
f"required_by_stack: {'true' if collection == 'sources' else 'false'}\n"
|
||||
"outbound:\n"
|
||||
" any: [depends-on, required-by, runs-on, hosts, uses, implements, see-also]\n"
|
||||
"---\n\n"
|
||||
f"# kb/{collection}/ - Collection Contract\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
write_page(
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for generated regions - the delimiters that replaced heading matching.
|
||||
|
||||
The whole point is that a region's *identity* stops depending on its heading
|
||||
text. Everything here is about the two questions the old approach answered by
|
||||
guessing: where does the region start, and where does it stop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from chemenu import blocks
|
||||
|
||||
PROSE = "# Page\n\n## Beschreibung\n\nProse.\n"
|
||||
|
||||
|
||||
def _links(heading="Beziehungen", lines=("- **uses:** [[X]]",)):
|
||||
return blocks.render(blocks.LINKS, heading, list(lines))
|
||||
|
||||
|
||||
def test_a_region_round_trips():
|
||||
body = blocks.replace(PROSE, blocks.LINKS, _links())
|
||||
assert blocks.find(body, blocks.LINKS) == "## Beziehungen\n\n- **uses:** [[X]]"
|
||||
assert blocks.strip(body, blocks.LINKS) == PROSE
|
||||
|
||||
|
||||
def test_replacing_does_not_append_a_second_region():
|
||||
body = blocks.replace(PROSE, blocks.LINKS, _links())
|
||||
again = blocks.replace(body, blocks.LINKS, _links(lines=["- **uses:** [[Y]]"]))
|
||||
assert again.count(blocks.open_marker(blocks.LINKS)) == 1
|
||||
assert "[[X]]" not in again and "[[Y]]" in again
|
||||
|
||||
|
||||
def test_the_heading_inside_a_region_is_not_how_it_is_found():
|
||||
"""A page whose region carries a heading the instance never declared - an
|
||||
unconverted page, a hand-edit, another language - is still located exactly.
|
||||
Under heading matching this was the case that silently created a second
|
||||
section."""
|
||||
body = blocks.replace(PROSE, blocks.LINKS, _links(heading="Something Else Entirely"))
|
||||
assert "[[X]]" in blocks.find(body, blocks.LINKS)
|
||||
assert blocks.strip(body, blocks.LINKS) == PROSE
|
||||
|
||||
|
||||
def test_content_after_a_region_survives_a_rewrite():
|
||||
"""The eight-page bug, as a test. The old block ran to the next heading -
|
||||
and before that to the end of the file - so anything sitting after it was
|
||||
deleted on the next write."""
|
||||
body = blocks.replace(PROSE, blocks.LINKS, _links()) + "\n## Afterwards\n\nKeep me.\n"
|
||||
rewritten = blocks.replace(body, blocks.LINKS, _links(lines=["- **uses:** [[Z]]"]))
|
||||
assert "Keep me." in rewritten
|
||||
assert rewritten.count("## Afterwards") == 1
|
||||
|
||||
|
||||
def test_two_regions_coexist_without_reading_each_other():
|
||||
body = blocks.replace(PROSE, blocks.LINKS, _links())
|
||||
body = blocks.replace(
|
||||
body, blocks.FOOTNOTES,
|
||||
blocks.render(blocks.FOOTNOTES, "Fußnoten", ["[^s-x]: [[Source - X]]"]),
|
||||
)
|
||||
assert "[[X]]" in blocks.find(body, blocks.LINKS)
|
||||
assert "[^s-x]" in blocks.find(body, blocks.FOOTNOTES)
|
||||
assert blocks.marker_pairs(body) == {"links": 1, "footnotes": 1}
|
||||
|
||||
|
||||
def test_an_empty_region_is_no_region_at_all():
|
||||
"""A page that cites nothing must not carry an empty Footnotes heading."""
|
||||
assert blocks.render(blocks.LINKS, "Beziehungen", []) == ""
|
||||
body = blocks.replace(PROSE, blocks.LINKS, _links())
|
||||
assert blocks.replace(body, blocks.LINKS, "") == PROSE
|
||||
|
||||
|
||||
def test_an_absent_region_reads_as_none_not_as_empty():
|
||||
"""None and "" have to stay distinguishable: one means the page has no
|
||||
region, the other that it has one holding nothing."""
|
||||
assert blocks.find(PROSE, blocks.LINKS) is None
|
||||
|
||||
|
||||
def test_a_dropped_marker_is_detectable():
|
||||
"""An agent rewriting prose at the boundary can lose one. Silent otherwise:
|
||||
the region becomes ordinary prose and the next write appends a second one
|
||||
beside it."""
|
||||
body = blocks.replace(PROSE, blocks.LINKS, _links())
|
||||
assert blocks.unbalanced_markers(body) == []
|
||||
assert blocks.unbalanced_markers(body.replace(blocks.close_marker(blocks.LINKS), "")) == ["links"]
|
||||
assert blocks.unbalanced_markers(body.replace(blocks.open_marker(blocks.LINKS), "")) == ["links"]
|
||||
|
||||
|
||||
def test_marker_pairs_counts_rather_than_sets():
|
||||
"""A page that went from one region to two has the same set of names and a
|
||||
different count - which is why `migrate verify` compares counts."""
|
||||
body = blocks.replace(PROSE, blocks.LINKS, _links())
|
||||
doubled = body + "\n" + _links() + "\n"
|
||||
assert blocks.marker_pairs(doubled) == {"links": 2}
|
||||
@@ -133,27 +133,50 @@ def test_sync_page_is_idempotent_once_clean():
|
||||
|
||||
from chemenu.page import Page
|
||||
|
||||
body = "\n# X\n\n## Definition\n\nCites [^s-a].\n\n## Fußnoten\n\n[^s-a]: [[Source - A]]\n"
|
||||
from chemenu import blocks
|
||||
|
||||
body = blocks.replace(
|
||||
"\n# X\n\n## Definition\n\nCites [^s-a].\n",
|
||||
blocks.FOOTNOTES,
|
||||
blocks.render(blocks.FOOTNOTES, "Fußnoten", ["[^s-a]: [[Source - A]]"]),
|
||||
)
|
||||
page = Page(path=Path("/tmp/X.md"), frontmatter={}, body=body)
|
||||
new_body, changed, pruned, undefined = sync_page(page)
|
||||
assert changed is False
|
||||
assert new_body == body
|
||||
assert pruned == []
|
||||
assert undefined == []
|
||||
|
||||
|
||||
def test_sync_page_leaves_an_untranslated_footnotes_heading_alone():
|
||||
"""`cite sync` must not retitle a block just because the page has not been
|
||||
translated yet - that would make it rewrite the whole corpus on one run."""
|
||||
def test_sync_page_upgrades_a_pre_marker_block_to_a_delimited_region():
|
||||
"""The mechanical half of the marker migration, done by the command that
|
||||
already owns the block.
|
||||
|
||||
This inverts an older rule. While the tool *located* the block by matching
|
||||
its heading, re-rendering one under a different name was a rewrite of the
|
||||
whole corpus on a single run, so `cite sync` had to leave an untranslated
|
||||
heading alone. Now the marker carries the identity: converting the region is
|
||||
a repair, and the heading follows `kb/CONVENTIONS.md` from then on."""
|
||||
from pathlib import Path
|
||||
|
||||
from chemenu import blocks
|
||||
from chemenu.page import Page
|
||||
|
||||
body = "\n# X\n\n## Definition\n\nCites [^s-a].\n\n## Footnotes\n\n[^s-a]: [[Source - A]]\n"
|
||||
page = Page(path=Path("/tmp/X.md"), frontmatter={}, body=body)
|
||||
new_body, changed, pruned, undefined = sync_page(page)
|
||||
assert changed is False
|
||||
assert "## Footnotes" in new_body
|
||||
assert "## Fußnoten" not in new_body
|
||||
new_body, changed, _pruned, undefined = sync_page(page)
|
||||
|
||||
assert changed is True
|
||||
assert undefined == []
|
||||
assert blocks.unbalanced_markers(new_body) == []
|
||||
assert "[^s-a]: [[Source - A]]" in blocks.find(new_body, blocks.FOOTNOTES)
|
||||
# The prose above it is untouched, and the legacy heading is not left behind
|
||||
# as a second, now-empty section.
|
||||
assert "## Definition" in new_body
|
||||
# The legacy heading is not left behind as a second, now-empty section: the
|
||||
# region carries its own heading, rendered from `kb/CONVENTIONS.md`.
|
||||
assert "## Footnotes" not in new_body
|
||||
assert new_body.count("<!-- wikitool:footnotes -->") == 1
|
||||
|
||||
|
||||
def test_cite_sync_command_over_kb(kb_dir, raw_dir, monkeypatch):
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Tests for `kb/CONVENTIONS.md` - the instance-owned half of the authoring rules.
|
||||
|
||||
Two things are under test here, and they are the two the split exists for: the
|
||||
compiler reads its section headings from the corpus rather than from Python, and
|
||||
a collection declares who owns its rules rather than having it inferred from the
|
||||
directory name.
|
||||
headings the compiler *renders* come from the corpus rather than from Python,
|
||||
and a collection declares who owns its rules rather than having it inferred from
|
||||
the directory name.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,15 +11,15 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, conventions, kb_collections, sections
|
||||
from chemenu import blocks, config, conventions, kb_collections
|
||||
from chemenu.tests.conftest import use_shipped_type_specs
|
||||
|
||||
GERMAN = (
|
||||
"---\n"
|
||||
"language: de\n"
|
||||
"profile: german\n"
|
||||
"sections:\n"
|
||||
" relationships: Beziehungen\n"
|
||||
" see_also: Siehe auch\n"
|
||||
" links: Beziehungen\n"
|
||||
" footnotes: Fußnoten\n"
|
||||
"---\n\n# conventions\n"
|
||||
)
|
||||
@@ -29,11 +29,8 @@ FRENCH = (
|
||||
"language: fr\n"
|
||||
"profile: none\n"
|
||||
"sections:\n"
|
||||
" relationships: Relations\n"
|
||||
" see_also: Voir aussi\n"
|
||||
" links: Relations\n"
|
||||
" footnotes: Notes\n"
|
||||
"section_aliases:\n"
|
||||
" relationships: [Beziehungen]\n"
|
||||
"---\n\n# conventions\n"
|
||||
)
|
||||
|
||||
@@ -44,6 +41,11 @@ def kb_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
kb.mkdir()
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(config, "KB_DIR", kb)
|
||||
# Which collection the stack requires is *derived* from where the required
|
||||
# `source` type writes, so these tests need the shipped `types/` reachable -
|
||||
# a fixture tree without one derives an empty requirement and would assert
|
||||
# against a rule that is not running. See conftest.use_shipped_type_specs.
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
conventions.reset_cache()
|
||||
yield kb
|
||||
conventions.reset_cache()
|
||||
@@ -64,65 +66,53 @@ def _collection(kb: Path, name: str, profile: str = "none", required: bool = Fal
|
||||
return directory
|
||||
|
||||
|
||||
def test_missing_file_falls_back_to_what_the_stack_used_to_hardcode(kb_root):
|
||||
"""The state between installing this machinery and running the migration
|
||||
that writes the file. Every command has to keep working through it, and the
|
||||
only corpus that can be in it was written under these names."""
|
||||
assert conventions.canonical(conventions.FOOTNOTES) == "Fußnoten"
|
||||
assert sections.FOOTNOTES == "Fußnoten"
|
||||
def test_a_missing_file_renders_under_a_cosmetic_default(kb_root):
|
||||
"""The window between installing the machinery and writing the conventions
|
||||
file. It has to render *something*, and a wrong heading is now merely wrong
|
||||
words: the marker pair carries the region's identity, so the next write
|
||||
repairs it once the instance declares one. Before markers, the same mistake
|
||||
split a page into two sections."""
|
||||
assert conventions.heading(blocks.FOOTNOTES) == "Footnotes"
|
||||
assert conventions.heading(blocks.LINKS) == "Relationships"
|
||||
|
||||
|
||||
def test_the_compiler_writes_the_headings_the_instance_declared(kb_root):
|
||||
def test_the_compiler_renders_the_headings_the_instance_declared(kb_root):
|
||||
_write(kb_root, FRENCH)
|
||||
assert sections.RELATIONSHIPS == "Relations"
|
||||
assert sections.SEE_ALSO == "Voir aussi"
|
||||
assert sections.FOOTNOTES == "Notes"
|
||||
|
||||
|
||||
def test_declared_aliases_and_the_pre_conventions_names_are_both_recognized(kb_root):
|
||||
"""The translation path. A page still carrying the old heading has to be
|
||||
found and appended to, or a language change would silently split every page
|
||||
into two Relationships sections."""
|
||||
_write(kb_root, FRENCH)
|
||||
pattern = sections.heading_re(sections.RELATIONSHIPS)
|
||||
for heading in ("## Relations", "## Beziehungen", "## Relationships"):
|
||||
assert pattern.search(f"# Page\n\n{heading}\n\n- x\n"), heading
|
||||
|
||||
|
||||
def test_the_canonical_name_is_not_duplicated_among_its_aliases(kb_root):
|
||||
"""An instance declaring the pre-conventions name gets it once, not twice -
|
||||
otherwise `heading_re`'s alternation carries a redundant branch and
|
||||
`names()` misreports what a page could be carrying."""
|
||||
_write(
|
||||
kb_root,
|
||||
"---\nsections:\n relationships: Relationships\n"
|
||||
" see_also: See Also\n footnotes: Footnotes\n---\n",
|
||||
)
|
||||
names = conventions.names(conventions.RELATIONSHIPS)
|
||||
assert names[0] == "Relationships"
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
|
||||
def test_section_variables_are_what_a_type_spec_template_substitutes(kb_root):
|
||||
_write(kb_root, GERMAN)
|
||||
assert conventions.section_variables() == {
|
||||
"section.relationships": "Beziehungen",
|
||||
"section.see_also": "Siehe auch",
|
||||
"section.footnotes": "Fußnoten",
|
||||
}
|
||||
assert conventions.heading(blocks.LINKS) == "Relations"
|
||||
assert conventions.heading(blocks.FOOTNOTES) == "Notes"
|
||||
|
||||
|
||||
def test_a_rewritten_file_is_not_answered_out_of_the_cache(kb_root):
|
||||
_write(kb_root, GERMAN)
|
||||
assert sections.FOOTNOTES == "Fußnoten"
|
||||
assert conventions.heading(blocks.FOOTNOTES) == "Fußnoten"
|
||||
_write(kb_root, FRENCH)
|
||||
assert sections.FOOTNOTES == "Notes"
|
||||
assert conventions.heading(blocks.FOOTNOTES) == "Notes"
|
||||
|
||||
|
||||
def test_a_region_is_found_by_its_marker_not_by_its_heading(kb_root):
|
||||
"""The point of the whole change. A page whose heading says something the
|
||||
instance never declared - an untranslated page, a hand-edit, another
|
||||
language entirely - is still located exactly."""
|
||||
_write(kb_root, FRENCH)
|
||||
body = blocks.replace(
|
||||
"# Page\n\nProse.\n",
|
||||
blocks.LINKS,
|
||||
blocks.render(blocks.LINKS, "Ganz andere Wörter", ["- **uses:** [[X]]"]),
|
||||
)
|
||||
assert "- **uses:** [[X]]" in blocks.find(body, blocks.LINKS)
|
||||
|
||||
|
||||
def test_an_unknown_section_key_is_reported(kb_root):
|
||||
_write(
|
||||
kb_root,
|
||||
"---\nsections:\n links: L\n footnotes: F\n see_also: S\n---\n",
|
||||
)
|
||||
assert any("see_also" in issue for issue in conventions.declaration_issues())
|
||||
|
||||
|
||||
def test_an_incomplete_sections_block_is_reported(kb_root):
|
||||
_write(kb_root, "---\nlanguage: de\nsections:\n relationships: Beziehungen\n---\n")
|
||||
_write(kb_root, "---\nlanguage: de\nsections:\n links: Beziehungen\n---\n")
|
||||
issues = conventions.declaration_issues()
|
||||
assert any("sections.see_also" in issue for issue in issues)
|
||||
assert any("sections.footnotes" in issue for issue in issues)
|
||||
|
||||
|
||||
@@ -165,3 +155,40 @@ def test_a_correct_declaration_reports_nothing(kb_root):
|
||||
_collection(kb_root, "sources", profile="sources", required=True)
|
||||
_collection(kb_root, "entities", profile="entities")
|
||||
assert kb_collections.declaration_issues(kb_root) == []
|
||||
|
||||
|
||||
# --- outbound authorisation ------------------------------------------------
|
||||
|
||||
|
||||
def _authorising(kb: Path, name: str, outbound: str, required: bool = False) -> Path:
|
||||
directory = kb / name
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / kb_collections.CONTRACT_NAME).write_text(
|
||||
f"---\nprofile: {name}\nrequired_by_stack: {str(required).lower()}\n"
|
||||
f"outbound:\n{outbound}\n---\n\n# {name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return directory
|
||||
|
||||
|
||||
def test_the_source_collection_decides_which_labels_may_be_used(kb_root):
|
||||
"""Commonplace ADR-019, adopted: the rules that govern an edge are the rules
|
||||
of the collection the *asserting* page lives in. That is also why the reverse
|
||||
edge cannot be written automatically - it would be governed by a contract the
|
||||
author never read."""
|
||||
_authorising(kb_root, "entities", " concepts: [implements]\n entities: [uses]")
|
||||
assert kb_collections.authorised_labels("entities", "concepts") == {"implements"}
|
||||
assert kb_collections.authorised_labels("entities", "entities") == {"uses"}
|
||||
|
||||
|
||||
def test_any_widens_every_destination(kb_root):
|
||||
_authorising(kb_root, "entities", " any: [see-also]\n concepts: [implements]")
|
||||
assert kb_collections.authorised_labels("entities", "concepts") == {"implements", "see-also"}
|
||||
assert kb_collections.authorised_labels("entities", "sources") == {"see-also"}
|
||||
|
||||
|
||||
def test_an_undeclared_destination_authorises_nothing(kb_root):
|
||||
"""An empty result is a real answer - "do not link there from here" - not a
|
||||
missing declaration to be filled in with a permissive default."""
|
||||
_authorising(kb_root, "entities", " concepts: [implements]")
|
||||
assert kb_collections.authorised_labels("entities", "sources") == set()
|
||||
|
||||
@@ -76,6 +76,19 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
types_dir = root / "types"
|
||||
types_dir.mkdir()
|
||||
(types_dir / "entity.schema.yaml").write_text("type: object\n", encoding="utf-8")
|
||||
# Two real type-specs, one on each side of the `root:` line, so the export's
|
||||
# split has something to split. `entity` writes into kb/ and is therefore
|
||||
# the instance's; `instruction` writes into the repo and is the stack's.
|
||||
(types_dir / "entity.md").write_text(
|
||||
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
|
||||
"schema: types/entity.schema.yaml\nbase_dir: entities\n---\n\n# Entity\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(types_dir / "instruction.md").write_text(
|
||||
"---\ntype: types/type-spec.md\nname: instruction\ndescription: d\n"
|
||||
"schema: null\nbase_dir: instructions\nroot: repo\n---\n\n# Instruction\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
tools_dir = root / "tools"
|
||||
(tools_dir / "chemenu").mkdir(parents=True)
|
||||
@@ -274,6 +287,10 @@ def test_find_leaks_is_silent_on_a_clean_plan(repo):
|
||||
# filled name must never cross - only the `.template` beside it does.
|
||||
"kb/CONVENTIONS.md",
|
||||
"kb/entities/COLLECTION.md",
|
||||
# A page type-spec under its filled name: the instance's, not the
|
||||
# stack's, so shipping it would hand a new instance this one's
|
||||
# authoring language as though the stack had decided it.
|
||||
"types/entity.md",
|
||||
],
|
||||
)
|
||||
def test_find_leaks_catches_one_instance_own_data(repo, relative):
|
||||
@@ -318,6 +335,26 @@ def test_plan_ships_the_conventions_template_and_not_the_filled_file(repo):
|
||||
assert "kb/CONVENTIONS.md" not in plan
|
||||
|
||||
|
||||
def test_page_type_specs_ship_as_templates_and_stack_types_do_not(repo, monkeypatch):
|
||||
"""The `root:` line, applied. A type-spec whose instances are pages under
|
||||
`kb/` describes what this instance writes, so its prose, template and
|
||||
language are the instance's; one whose instances are stack artifacts ships
|
||||
verbatim. The `.schema.yaml` travels with its spec - the two are one type,
|
||||
and adopting half would leave a spec validated by a file it does not own."""
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
monkeypatch.setattr(resolver, "_repo_root", config.ROOT)
|
||||
plan = dist_cmd.build_plan()
|
||||
|
||||
assert "types/entity.md.template" in plan
|
||||
assert "types/entity.schema.yaml.template" in plan
|
||||
assert "types/entity.md" not in plan
|
||||
assert "types/entity.schema.yaml" not in plan
|
||||
|
||||
assert "types/instruction.md" in plan
|
||||
assert "types/instruction.md.template" not in plan
|
||||
|
||||
|
||||
def test_plan_creates_empty_raw_subdirs_not_real_content(repo):
|
||||
plan = dist_cmd.build_plan()
|
||||
for sub in ("articles", "documents", "notes", "assets"):
|
||||
|
||||
@@ -25,14 +25,18 @@ def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
kb = root / "kb"
|
||||
for sub in ("entities", "concepts", "sources", "comparisons"):
|
||||
(kb / sub).mkdir(parents=True)
|
||||
(kb / sub / "COLLECTION.md").write_text(f"# {sub}\n", encoding="utf-8")
|
||||
(kb / sub / "COLLECTION.md").write_text(
|
||||
f"---\nprofile: {sub}\nrequired_by_stack: "
|
||||
f"{'true' if sub == 'sources' else 'false'}\n---\n\n# {sub}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(kb / "index.md").write_text("# Index\n", encoding="utf-8")
|
||||
(kb / "log.md").write_text("# Log\n", encoding="utf-8")
|
||||
(kb / "provenance.md").write_text("# Provenance\n", encoding="utf-8")
|
||||
(kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8")
|
||||
(kb / "CONVENTIONS.md").write_text(
|
||||
"---\nlanguage: en\nprofile: none\nsections:\n"
|
||||
" relationships: Relationships\n see_also: See Also\n footnotes: Footnotes\n"
|
||||
" links: Relationships\n footnotes: Footnotes\n"
|
||||
"---\n\n# conventions\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -220,7 +224,7 @@ def test_conventions_with_an_incomplete_sections_block_fail(instance):
|
||||
"""Present and deciding nothing - the same failure mode the personalization
|
||||
sentinel check exists for, one directory down."""
|
||||
(config.KB_DIR / conventions.CONVENTIONS_FILENAME).write_text(
|
||||
"---\nlanguage: en\nsections:\n relationships: Relationships\n---\n", encoding="utf-8"
|
||||
"---\nlanguage: en\nsections:\n links: Relationships\n---\n", encoding="utf-8"
|
||||
)
|
||||
conventions.reset_cache()
|
||||
assert _status(doctor.run_doctor(), "conventions") == "FAIL"
|
||||
|
||||
@@ -16,7 +16,13 @@ from chemenu.version import Version
|
||||
CHANGES = "# Changelog\n\n---\n\n## 1.0.0 - 2026-08-30 - First\n\nBody.\n"
|
||||
|
||||
|
||||
def write_migration(directory: Path, target: str, slug: str, kind: str = "assisted") -> Path:
|
||||
def write_migration(
|
||||
directory: Path,
|
||||
target: str,
|
||||
slug: str,
|
||||
kind: str = "assisted",
|
||||
obligation: str = "required",
|
||||
) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{target}-{slug}.md"
|
||||
path.write_text(
|
||||
@@ -27,6 +33,7 @@ def write_migration(directory: Path, target: str, slug: str, kind: str = "assist
|
||||
"manual: true\n"
|
||||
f"migrates_to: {target}\n"
|
||||
f"migration_kind: {kind}\n"
|
||||
f"obligation: {obligation}\n"
|
||||
"---\n\n# Migration\n\nSteps.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -259,3 +266,139 @@ def test_verify_reports_an_unknown_revision(git_instance):
|
||||
json_out=False,
|
||||
fail_on_error=False,
|
||||
)
|
||||
|
||||
|
||||
# --- obligation: required vs offered ---------------------------------------
|
||||
|
||||
|
||||
def test_an_offered_migration_is_not_in_the_outstanding_chain(instance):
|
||||
"""An offer is the stack proposing a better default for a file the instance
|
||||
owns. Declining it leaves the content in a shape the machinery accepts, so
|
||||
counting it as owed would make `kb_version` unreachable for an instance that
|
||||
simply kept its own file."""
|
||||
write_migration(
|
||||
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
|
||||
kind="mechanical", obligation="offered",
|
||||
)
|
||||
migrations = kb_state.load_migrations()
|
||||
pending = kb_state.chain(migrations, Version.parse("1.3.0"), Version.parse("2.0.0"))
|
||||
assert "1.9.0-nicer-template" not in [m.name for m in pending]
|
||||
assert [str(m.target) for m in pending] == ["1.4.0", "1.7.0", "2.0.0"]
|
||||
|
||||
|
||||
def test_an_offered_migration_is_listed_separately(instance):
|
||||
write_migration(
|
||||
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
|
||||
kind="mechanical", obligation="offered",
|
||||
)
|
||||
offered = kb_state.offers(kb_state.load_migrations(), applied=set())
|
||||
assert [m.name for m in offered] == ["1.9.0-nicer-template"]
|
||||
|
||||
|
||||
def test_an_offer_stays_on_the_table_regardless_of_the_version(instance):
|
||||
"""Offers are bounded by the applied ledger, not by kb_version - taking one
|
||||
deliberately does not move the version, so the version can say nothing about
|
||||
whether it was taken. Nor are they bounded above by the stack: an offer is
|
||||
about a file the instance owns, not about the content shape."""
|
||||
directory = instance / "instructions" / "migrations"
|
||||
write_migration(directory, "1.1.0", "old-default", obligation="offered")
|
||||
write_migration(directory, "2.1.0", "later-default", obligation="offered")
|
||||
offered = kb_state.offers(kb_state.load_migrations(), applied=set())
|
||||
assert [m.name for m in offered] == ["1.1.0-old-default", "2.1.0-later-default"]
|
||||
|
||||
|
||||
def test_taking_an_offer_records_it_without_moving_the_version(instance):
|
||||
write_migration(
|
||||
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
|
||||
obligation="offered",
|
||||
)
|
||||
set_kb_version(instance, "1.3.1")
|
||||
migrate_cmd.done_command(version="1.9.0", pages=None, dry_run=False)
|
||||
|
||||
state = kb_state.read_kb_state()
|
||||
assert state["kb_version"] == "1.3.1"
|
||||
assert state["applied"][-1]["migration"] == "1.9.0-nicer-template"
|
||||
assert kb_state.offers(kb_state.load_migrations(), kb_state.applied_names(state)) == []
|
||||
|
||||
|
||||
def test_an_offer_out_of_order_is_not_refused(instance):
|
||||
"""The chain's ordering rule exists because skipping a link leaves the
|
||||
corpus in an undescribed shape. An offer is not a link, so there is nothing
|
||||
to skip - and refusing it would make the required chain a prerequisite for
|
||||
an unrelated file upgrade."""
|
||||
write_migration(
|
||||
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
|
||||
obligation="offered",
|
||||
)
|
||||
set_kb_version(instance, "1.3.1") # 1.4.0 is the next *required* link
|
||||
migrate_cmd.done_command(version="1.9.0", pages=None, dry_run=False)
|
||||
assert kb_state.read_kb_version() == Version(1, 3, 1)
|
||||
|
||||
|
||||
def test_obligation_defaults_to_required_when_undeclared(instance):
|
||||
"""Every migration written before this axis existed is mandatory, and an
|
||||
unreadable value must not silently downgrade one."""
|
||||
directory = instance / "instructions" / "migrations"
|
||||
path = write_migration(directory, "1.5.0", "legacy")
|
||||
path.write_text(
|
||||
path.read_text(encoding="utf-8").replace("obligation: required\n", ""), encoding="utf-8"
|
||||
)
|
||||
bogus = write_migration(directory, "1.6.0", "bogus", obligation="whatever")
|
||||
assert bogus.is_file()
|
||||
|
||||
by_name = {m.name: m for m in kb_state.load_migrations()}
|
||||
assert by_name["1.5.0-legacy"].obligation == kb_state.REQUIRED
|
||||
assert by_name["1.6.0-bogus"].obligation == kb_state.REQUIRED
|
||||
|
||||
|
||||
def test_status_never_blocks_on_an_offer(instance, capsys):
|
||||
write_migration(
|
||||
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
|
||||
obligation="offered",
|
||||
)
|
||||
set_kb_version(instance, "2.0.0")
|
||||
migrate_cmd.status_command(json_out=True)
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["pending"] == []
|
||||
assert [m["name"] for m in result["offered"]] == ["1.9.0-nicer-template"]
|
||||
# The chain is empty and the offer is listed: `status` reports both without
|
||||
# the offer ever counting as owed.
|
||||
|
||||
|
||||
# --- divergence against the release stamp ----------------------------------
|
||||
|
||||
|
||||
def _write_stamp(root: Path, files: dict[str, str]) -> None:
|
||||
import hashlib
|
||||
|
||||
digests = {}
|
||||
for relative, content in files.items():
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
digests[relative] = "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
(root / ".wikitool-release.json").write_text(
|
||||
json.dumps({"schema": 1, "version": "2.0.0", "files": digests}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_divergent_files_tells_an_edited_file_from_a_received_one(instance):
|
||||
"""The half of the release stamp that has existed since it was written and
|
||||
that nothing read until offers needed it: may this file be overwritten, or
|
||||
does a person have to reconcile it?"""
|
||||
_write_stamp(instance, {"types/entity.md": "shipped\n", "types/concept.md": "shipped\n"})
|
||||
(instance / "types" / "entity.md").write_text("locally changed\n", encoding="utf-8")
|
||||
|
||||
assert kb_state.divergent_files() == ["types/entity.md"]
|
||||
|
||||
|
||||
def test_a_deleted_file_counts_as_divergent(instance):
|
||||
_write_stamp(instance, {"types/entity.md": "shipped\n"})
|
||||
(instance / "types" / "entity.md").unlink()
|
||||
assert kb_state.divergent_files() == ["types/entity.md"]
|
||||
|
||||
|
||||
def test_divergence_is_unanswerable_without_a_stamp(instance):
|
||||
"""None, not []. A development tree carries no stamp, and reporting
|
||||
"nothing diverged" there would be a fabricated answer."""
|
||||
assert kb_state.divergent_files() is None
|
||||
|
||||
@@ -75,29 +75,20 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
|
||||
assert "# gateway.example.net" in body
|
||||
|
||||
|
||||
def test_scaffolded_body_carries_the_headings_this_instance_declared(monkeypatch, kb_dir):
|
||||
"""The type-spec writes `## {section.relationships}`, not a heading text, so
|
||||
an instance in another language scaffolds its own headings without editing
|
||||
anything under `types/`. This is that path end to end."""
|
||||
from chemenu import conventions
|
||||
|
||||
(kb_dir / conventions.CONVENTIONS_FILENAME).write_text(
|
||||
"---\nlanguage: fr\nprofile: none\nsections:\n relationships: Relations\n"
|
||||
" see_also: Voir aussi\n footnotes: Notes\n---\n\n# conventions\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
conventions.reset_cache()
|
||||
try:
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "entity", "--name", "passerelle", "--set", "entity_type=system",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
_fm, body = read_page(kb_dir / "entities/systems/passerelle.md")
|
||||
assert "## Relations" in body
|
||||
assert "## Voir aussi" in body
|
||||
assert "{section." not in body
|
||||
finally:
|
||||
conventions.reset_cache()
|
||||
def test_a_scaffolded_body_carries_no_tool_owned_region(monkeypatch, kb_dir):
|
||||
"""A template must not scaffold the links or footnotes regions. They are
|
||||
generated between markers from frontmatter and re-rendered on every write,
|
||||
so a scaffolded copy would be a section the author may not edit and the tool
|
||||
would replace anyway - and, before the markers existed, a second one it
|
||||
appended beside."""
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "entity", "--name", "passerelle", "--set", "entity_type=system",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
_fm, body = read_page(kb_dir / "entities/systems/passerelle.md")
|
||||
assert "wikitool:links" not in body
|
||||
assert "wikitool:footnotes" not in body
|
||||
assert "{section." not in body
|
||||
|
||||
|
||||
def test_new_entity_applies_schema_declared_defaults(monkeypatch, kb_dir):
|
||||
|
||||
@@ -41,7 +41,11 @@ def empty_kb(tmp_path, monkeypatch):
|
||||
for collection in COLLECTIONS:
|
||||
(kb / collection).mkdir(parents=True)
|
||||
(kb / collection / "COLLECTION.md").write_text(
|
||||
f"# kb/{collection}/ - Collection Contract\n", encoding="utf-8"
|
||||
f"---\nprofile: {collection}\n"
|
||||
f"required_by_stack: {'true' if collection == 'sources' else 'false'}\n"
|
||||
"outbound:\n any: [implements, uses, see-also]\n---\n\n"
|
||||
f"# kb/{collection}/ - Collection Contract\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
raw = tmp_path / "raw"
|
||||
raw.mkdir()
|
||||
@@ -76,7 +80,8 @@ def build_wiki(kb):
|
||||
"--set", "summary=A concept created by the pipeline test"])
|
||||
for page in kb.rglob("Pipeline *.md"):
|
||||
finish_page(page)
|
||||
invoke(["xref", "add", "--a", "Pipeline Host", "--b", "Pipeline Concept"])
|
||||
invoke(["xref", "add", "--a", "Pipeline Host", "--b", "Pipeline Concept",
|
||||
"--rel", "implements"])
|
||||
invoke(["index", "rebuild"])
|
||||
|
||||
|
||||
@@ -101,12 +106,23 @@ def test_a_wiki_built_by_the_tools_lints_clean(empty_kb):
|
||||
assert not has_hard_errors(report), f"hard errors after a clean build: {found}"
|
||||
|
||||
|
||||
def test_the_pages_reach_each_other(empty_kb):
|
||||
"""`xref add` is what makes two pages findable from one another; if it and
|
||||
the link checker disagreed, the lint above would report a broken link."""
|
||||
def test_an_edge_points_one_way_and_the_far_end_stops_being_an_orphan(empty_kb):
|
||||
"""`xref add` declares one direction, and that is what the orphan check now
|
||||
measures: reachability.
|
||||
|
||||
It used to assert that *neither* page was an orphan, which only held because
|
||||
`xref add` wrote a mirror edge on the target. With authored directional
|
||||
edges the source of the only edge in a two-page wiki genuinely has nothing
|
||||
pointing at it - so the check reporting it is the check working, not a
|
||||
regression. A real corpus answers this by having entry points that other
|
||||
pages point at."""
|
||||
build_wiki(empty_kb)
|
||||
|
||||
assert run_lint(empty_kb)["orphan_pages"] == []
|
||||
report = run_lint(empty_kb)
|
||||
assert "Pipeline Concept" not in report["orphan_pages"]
|
||||
assert report["orphan_pages"] == ["Pipeline Host"]
|
||||
assert report["broken_links"] == []
|
||||
assert report["dangling_frontmatter_refs"] == []
|
||||
|
||||
|
||||
def test_the_catalog_covers_what_was_created(empty_kb):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from chemenu import conventions
|
||||
from chemenu.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -44,11 +43,13 @@ def test_types_describe_entity_reports_schema_and_body():
|
||||
"project", "system", "tool", "technology", "person",
|
||||
]
|
||||
assert fields_by_name["tags"]["required"] is False
|
||||
# The body must carry the page skeleton an authoring LLM works from. Anchored on the
|
||||
# template *variable* rather than on any heading text: the spec no longer names the
|
||||
# tool-owned sections at all - `kb/CONVENTIONS.md` does, and `new` substitutes it - so a
|
||||
# literal here would assert the very coupling that was removed.
|
||||
assert f"## {{section.{conventions.RELATIONSHIPS}}}" in data["body"]
|
||||
# The body must carry the page skeleton an authoring LLM works from...
|
||||
assert "## Kerndaten" in data["body"]
|
||||
# ...and must *not* carry a tool-owned region. Those are generated between
|
||||
# markers from frontmatter, so scaffolding one would create a section the
|
||||
# author may not edit and the next write would replace anyway.
|
||||
assert "wikitool:links" not in data["body"]
|
||||
assert "wikitool:footnotes" not in data["body"]
|
||||
|
||||
|
||||
def test_types_describe_unknown_name_fails_cleanly():
|
||||
|
||||
@@ -1,25 +1,56 @@
|
||||
from chemenu import blocks, links
|
||||
from chemenu.frontmatter_io import read_page
|
||||
from chemenu.commands.xref import (
|
||||
add_related,
|
||||
add_relationship_bullet,
|
||||
add_see_also_bullet,
|
||||
apply_links_block,
|
||||
remove_link_bullets,
|
||||
remove_related,
|
||||
render_links_block,
|
||||
)
|
||||
from chemenu.kb_scan import load_kb_pages
|
||||
from chemenu.page import Page
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_add_related_is_deduplicated():
|
||||
fm = {"related": ["A"]}
|
||||
assert add_related(fm, "B") is True
|
||||
assert add_related(fm, "B") is False
|
||||
assert fm["related"] == ["A", "B"]
|
||||
def _page(related, body="\n# X\n\nProse.\n"):
|
||||
return Page(Path("kb/entities/X.md"), {"related": related}, body)
|
||||
|
||||
|
||||
def test_remove_related_is_the_inverse_of_add():
|
||||
fm = {"related": ["A", "B"]}
|
||||
def test_an_edge_carries_its_label_in_the_data():
|
||||
fm = {}
|
||||
assert links.upsert(fm, "related", links.Edge("B", "depends-on")) is True
|
||||
assert links.upsert(fm, "related", links.Edge("B", "depends-on")) is False
|
||||
assert fm["related"] == [{"depends-on": "B"}]
|
||||
|
||||
|
||||
def test_relabelling_replaces_rather_than_appends():
|
||||
"""One page asserts one thing about another. Two edges to the same target
|
||||
would render two bullets with no way to say which is meant."""
|
||||
fm = {"related": [{"uses": "B"}]}
|
||||
assert links.upsert(fm, "related", links.Edge("B", "depends-on")) is True
|
||||
assert fm["related"] == [{"depends-on": "B"}]
|
||||
|
||||
|
||||
def test_a_bare_title_reads_as_an_unlabelled_edge():
|
||||
"""The shape every page is in between this machinery landing and the
|
||||
migration reaching it. Readers must not crash on it, and it must stay
|
||||
visibly unlabelled so `lint` can report it."""
|
||||
fm = {"related": ["B", {"uses": "C"}]}
|
||||
edges = links.edges(fm, "related")
|
||||
assert [(e.target, e.label) for e in edges] == [("B", None), ("C", "uses")]
|
||||
assert links.targets(fm, "related") == ["B", "C"]
|
||||
assert [e.is_labelled for e in edges] == [False, True]
|
||||
|
||||
|
||||
def test_a_malformed_entry_is_reported_rather_than_guessed_at():
|
||||
fm = {"related": [{"a": "X", "b": "Y"}, 42, "Fine"]}
|
||||
assert links.targets(fm, "related") == ["Fine"]
|
||||
assert len(links.malformed(fm, "related")) == 2
|
||||
|
||||
|
||||
def test_remove_related_is_the_inverse_of_upsert():
|
||||
fm = {"related": [{"uses": "A"}, {"uses": "B"}]}
|
||||
assert remove_related(fm, "B") is True
|
||||
assert fm["related"] == ["A"]
|
||||
assert fm["related"] == [{"uses": "A"}]
|
||||
assert remove_related(fm, "B") is False
|
||||
|
||||
|
||||
@@ -27,44 +58,58 @@ def test_remove_related_tolerates_a_missing_field():
|
||||
assert remove_related({}, "B") is False
|
||||
|
||||
|
||||
def test_remove_link_bullets_removes_what_add_wrote():
|
||||
body = "\n# X\n\n## Relationships\n\n- **uses:** [[B]]\n\n## See Also\n\n- [[B]]\n"
|
||||
def test_retarget_keeps_the_label():
|
||||
fm = {"related": [{"depends-on": "Old"}]}
|
||||
assert links.retarget(fm, "related", "Old", "New") is True
|
||||
assert fm["related"] == [{"depends-on": "New"}]
|
||||
|
||||
|
||||
def test_the_body_block_is_rendered_from_the_frontmatter():
|
||||
"""The body is a rendering of the graph, not a second place it is stored.
|
||||
That is what removed the need to parse a German bullet back into a
|
||||
relationship."""
|
||||
page = _page([{"depends-on": "Hermes"}, "Unlabelled"])
|
||||
body = apply_links_block(page)
|
||||
region = blocks.find(body, blocks.LINKS)
|
||||
assert "- **depends-on:** [[Hermes]]" in region
|
||||
assert "- [[Unlabelled]]" in region
|
||||
|
||||
|
||||
def test_rendering_is_idempotent_and_replaces_rather_than_appends():
|
||||
page = _page([{"uses": "A"}])
|
||||
once = apply_links_block(page)
|
||||
twice = apply_links_block(page, once)
|
||||
assert once == twice
|
||||
page.frontmatter["related"] = [{"uses": "B"}]
|
||||
thrice = apply_links_block(page, once)
|
||||
assert thrice.count("<!-- wikitool:links -->") == 1
|
||||
assert "[[A]]" not in thrice and "[[B]]" in thrice
|
||||
|
||||
|
||||
def test_a_page_with_no_edges_carries_no_region():
|
||||
page = _page([])
|
||||
body = apply_links_block(page)
|
||||
assert "wikitool:links" not in body
|
||||
assert render_links_block(page) == ""
|
||||
|
||||
|
||||
def test_prose_after_the_region_survives_a_rewrite():
|
||||
"""The failure the marker pair exists to make impossible. The old block ran
|
||||
to the next heading - and before that to the end of the file - so a section
|
||||
sitting after it was deleted on the next write. Eight pages were carrying
|
||||
content in that position when it was found."""
|
||||
page = _page([{"uses": "A"}])
|
||||
body = apply_links_block(page) + "\n## Afterwards\n\nKeep me.\n"
|
||||
page.frontmatter["related"] = [{"uses": "B"}]
|
||||
rewritten = apply_links_block(page, body)
|
||||
assert "Keep me." in rewritten
|
||||
assert rewritten.count("## Afterwards") == 1
|
||||
|
||||
|
||||
def test_remove_link_bullets_removes_what_the_renderer_wrote():
|
||||
body = "\n# X\n\n## Beziehungen\n\n- **uses:** [[B]]\n- [[B]]\n"
|
||||
result = remove_link_bullets(body, "B")
|
||||
assert "[[B]]" not in result
|
||||
assert "## Relationships" in result and "## See Also" in result
|
||||
|
||||
|
||||
def test_relationship_bullet_idempotent():
|
||||
body = "\n# X\n\n## Relationships\n\n- **Related to:** [[A]]\n\n## See Also\n\n- [[A]]\n"
|
||||
once = add_relationship_bullet(body, "hosts", "B")
|
||||
twice = add_relationship_bullet(once, "hosts", "B")
|
||||
assert once == twice
|
||||
assert "[[B]]" in once
|
||||
|
||||
|
||||
def test_see_also_bullet_creates_section_if_missing():
|
||||
body = "\n# X\n\n## Description\n\nSomething.\n"
|
||||
updated = add_see_also_bullet(body, "Y")
|
||||
assert "## Siehe auch" in updated
|
||||
assert "[[Y]]" in updated
|
||||
|
||||
|
||||
def test_see_also_bullet_appends_to_an_untranslated_section():
|
||||
"""A page still carrying the English heading is appended to, not given a
|
||||
second section - that is what lets the corpus migrate page by page."""
|
||||
body = "\n# X\n\n## Description\n\nSomething.\n\n## See Also\n\n- [[A]]\n"
|
||||
updated = add_see_also_bullet(body, "Y")
|
||||
assert updated.count("## See Also") == 1
|
||||
assert "## Siehe auch" not in updated
|
||||
assert "[[Y]]" in updated
|
||||
|
||||
|
||||
def test_relationship_bullet_appends_to_an_untranslated_section():
|
||||
body = "\n# X\n\n## Relationships\n\n- **Related to:** [[A]]\n"
|
||||
updated = add_relationship_bullet(body, "hosts", "B")
|
||||
assert updated.count("## Relationships") == 1
|
||||
assert "## Beziehungen" not in updated
|
||||
assert "- **hosts:** [[B]]" in updated
|
||||
|
||||
|
||||
def test_xref_add_updates_both_pages_on_disk(kb_dir):
|
||||
@@ -76,23 +121,25 @@ def test_xref_add_updates_both_pages_on_disk(kb_dir):
|
||||
config.INDEX_FILE = kb_dir / "index.md"
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel-a", "uses", "--rel-b", "used by"])
|
||||
modbus_before = (kb_dir / "concepts/Modbus.md").read_text(encoding="utf-8")
|
||||
result = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
pages = load_kb_pages(kb_dir)
|
||||
assert "Modbus" in pages["gdeploy"].frontmatter["related"]
|
||||
assert "gdeploy" in pages["Modbus"].frontmatter["related"]
|
||||
assert "[[Modbus]]" in pages["gdeploy"].body
|
||||
assert "[[gdeploy]]" in pages["Modbus"].body
|
||||
assert links.targets(pages["gdeploy"].frontmatter, "related") == ["Modbus"]
|
||||
assert "- **uses:** [[Modbus]]" in pages["gdeploy"].body
|
||||
|
||||
fm_before, body_before = read_page(kb_dir / "entities/tools/gdeploy.md")
|
||||
link_count_before = body_before.count("[[Modbus]]") # one in Relationships, one in See Also
|
||||
# B is not touched at all. Its inbound view is rendered from the graph, so
|
||||
# nothing has to be written there for a reader to find its way back.
|
||||
assert (kb_dir / "concepts/Modbus.md").read_text(encoding="utf-8") == modbus_before
|
||||
|
||||
# Re-running must not duplicate the relationship or See Also bullets.
|
||||
result2 = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel-a", "uses", "--rel-b", "used by"])
|
||||
_fm_before, body_before = read_page(kb_dir / "entities/tools/gdeploy.md")
|
||||
link_count_before = body_before.count("[[Modbus]]")
|
||||
|
||||
result2 = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses"])
|
||||
assert result2.exit_code == 0
|
||||
fm_after, body_after = read_page(kb_dir / "entities/tools/gdeploy.md")
|
||||
assert fm_after["related"].count("Modbus") == 1
|
||||
assert links.targets(fm_after, "related").count("Modbus") == 1
|
||||
assert body_after.count("[[Modbus]]") == link_count_before
|
||||
|
||||
|
||||
@@ -107,14 +154,14 @@ def test_xref_remove_undoes_xref_add(kb_dir):
|
||||
runner = CliRunner()
|
||||
before = (kb_dir / "entities/tools/gdeploy.md").read_text(encoding="utf-8")
|
||||
|
||||
added = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus"])
|
||||
added = runner.invoke(app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses"])
|
||||
assert added.exit_code == 0, added.output
|
||||
removed = runner.invoke(app, ["xref", "remove", "--a", "gdeploy", "--b", "Modbus"])
|
||||
assert removed.exit_code == 0, removed.output
|
||||
|
||||
pages = load_kb_pages(kb_dir)
|
||||
assert "Modbus" not in pages["gdeploy"].frontmatter["related"]
|
||||
assert "gdeploy" not in pages["Modbus"].frontmatter["related"]
|
||||
assert "Modbus" not in links.targets(pages["gdeploy"].frontmatter, "related")
|
||||
assert "gdeploy" not in links.targets(pages["Modbus"].frontmatter, "related")
|
||||
assert "[[Modbus]]" not in pages["gdeploy"].body
|
||||
assert before # sanity: fixture page was non-empty
|
||||
|
||||
@@ -198,10 +245,10 @@ def test_xref_add_dry_run_writes_nothing(kb_dir):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel-a", "uses", "--rel-b", "used by", "--dry-run"],
|
||||
["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "would update" in result.output
|
||||
assert "would declare" in result.output
|
||||
assert "No files written" in result.output
|
||||
|
||||
assert gdeploy_path.read_text(encoding="utf-8") == gdeploy_before
|
||||
@@ -230,9 +277,11 @@ def test_xref_link_source_dry_run_writes_nothing(kb_dir):
|
||||
assert gdeploy_path.read_text(encoding="utf-8") == gdeploy_before
|
||||
|
||||
|
||||
def test_xref_add_reports_a_write_failure_without_silently_leaving_a_one_way_link(kb_dir, monkeypatch):
|
||||
"""If writing B fails after A already succeeded, the command must fail
|
||||
loudly (not silently succeed with a one-directional link) and say so."""
|
||||
def test_xref_add_reports_a_write_failure(kb_dir, monkeypatch):
|
||||
"""One edge, one write - so there is no half-written pair to report any
|
||||
more. The old two-sided `xref add` could update A and fail on B, leaving a
|
||||
link the user had to be told was one-directional; a directional edge has
|
||||
nothing to be half of."""
|
||||
from typer.testing import CliRunner
|
||||
from chemenu.cli import app
|
||||
from chemenu.commands import xref as xref_module
|
||||
@@ -241,25 +290,40 @@ def test_xref_add_reports_a_write_failure_without_silently_leaving_a_one_way_lin
|
||||
config.KB_DIR = kb_dir
|
||||
config.INDEX_FILE = kb_dir / "index.md"
|
||||
|
||||
real_write_page = xref_module.write_page
|
||||
|
||||
def flaky_write_page(path, frontmatter, body):
|
||||
if path.name == "Modbus.md":
|
||||
raise OSError("disk full")
|
||||
return real_write_page(path, frontmatter, body)
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(xref_module, "write_page", flaky_write_page)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel-a", "uses", "--rel-b", "used by"]
|
||||
app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "disk full" in result.output
|
||||
assert "one-directional" in result.output
|
||||
|
||||
pages = load_kb_pages(kb_dir)
|
||||
assert "Modbus" in pages["gdeploy"].frontmatter["related"] # A's write already happened
|
||||
assert "Modbus" not in links.targets(pages["gdeploy"].frontmatter, "related")
|
||||
|
||||
|
||||
def test_xref_add_refuses_a_label_the_collection_does_not_authorise(kb_dir):
|
||||
"""The source collection decides which labels may be used from it. Refused
|
||||
here rather than only in `lint`, because this is the moment the author is
|
||||
present and can pick a better one."""
|
||||
from typer.testing import CliRunner
|
||||
from chemenu.cli import app
|
||||
import chemenu.config as config
|
||||
|
||||
config.KB_DIR = kb_dir
|
||||
config.INDEX_FILE = kb_dir / "index.md"
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "hängt ab von"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "not authorised" in result.output
|
||||
assert "link-taxonomy" in result.output
|
||||
|
||||
|
||||
def test_xref_link_source_distinguishes_write_failures_from_missing_pages(kb_dir, monkeypatch):
|
||||
@@ -334,7 +398,9 @@ def test_xref_add_refuses_a_type_without_a_related_field(kb_dir):
|
||||
`related:` there produced frontmatter the schema rejects, and `xref remove`
|
||||
could not clear it - one command creating a state another could not undo."""
|
||||
runner, app = _runner_env(kb_dir)
|
||||
result = runner.invoke(app, ["xref", "add", "--a", "Source - Aurora", "--b", "aurora"])
|
||||
result = runner.invoke(
|
||||
app, ["xref", "add", "--a", "Source - Aurora", "--b", "aurora", "--rel", "uses"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
# Rich wraps the message to the terminal width, so compare on collapsed
|
||||
# whitespace rather than pinning the line breaks.
|
||||
@@ -414,3 +480,58 @@ def test_xref_link_source_dry_run_leaves_the_source_page_alone(kb_dir):
|
||||
"--dry-run"],
|
||||
)
|
||||
assert path.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
# --- the inbound view ------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_inbound_view_is_derived_not_stored(kb_dir):
|
||||
"""The load-bearing half of dropping mirrored edges. Nothing writes an edge
|
||||
onto the target, so the only way "what points at this page" can be answered
|
||||
completely is by computing it - which is also why it cannot go stale or be
|
||||
half-written the way a mirror could."""
|
||||
from typer.testing import CliRunner
|
||||
from chemenu.cli import app
|
||||
import chemenu.config as config
|
||||
|
||||
config.KB_DIR = kb_dir
|
||||
config.INDEX_FILE = kb_dir / "index.md"
|
||||
|
||||
runner = CliRunner()
|
||||
assert runner.invoke(
|
||||
app, ["xref", "add", "--a", "gdeploy", "--b", "Modbus", "--rel", "uses"]
|
||||
).exit_code == 0
|
||||
|
||||
import json
|
||||
|
||||
result = runner.invoke(app, ["links", "show", "--page", "Modbus", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.output)
|
||||
assert data["inbound"] == [{"source": "gdeploy", "label": "uses", "collection": "entities"}]
|
||||
# Nothing was written onto Modbus itself to make that answer possible.
|
||||
assert "gdeploy" not in links.targets(
|
||||
load_kb_pages(kb_dir)["Modbus"].frontmatter, "related"
|
||||
)
|
||||
|
||||
|
||||
def test_an_unresolvable_outbound_edge_is_marked(kb_dir):
|
||||
"""`lint` reports dangling references corpus-wide; this reports it for the
|
||||
one page someone is looking at, which is where it gets fixed."""
|
||||
from typer.testing import CliRunner
|
||||
from chemenu.cli import app
|
||||
import chemenu.config as config
|
||||
import json
|
||||
|
||||
config.KB_DIR = kb_dir
|
||||
config.INDEX_FILE = kb_dir / "index.md"
|
||||
|
||||
page = load_kb_pages(kb_dir)["gdeploy"]
|
||||
links.upsert(page.frontmatter, "related", links.Edge("Gone", "uses"))
|
||||
from chemenu.frontmatter_io import write_page
|
||||
|
||||
write_page(page.path, page.frontmatter, page.body)
|
||||
|
||||
result = CliRunner().invoke(app, ["links", "show", "--page", "gdeploy", "--json"])
|
||||
assert json.loads(result.output)["outbound"] == [
|
||||
{"target": "Gone", "label": "uses", "resolves": False}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user