fix: stale wiki/ path literals nach kb/ nachgezogen, mit Test-Guard gegen die naechste Umbenennung
CI / verify (push) Successful in 55s
Release / release (push) Successful in 39s

Files changed:
- CHANGES.md
- VERSION
- kb/entities/projects/Chemenu.md
- kb/log.md
- tools/chemenu/commands/_util.py
- tools/chemenu/commands/cite_cmd.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/log_append.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/provenance_cmd.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/touch.py
- tools/chemenu/commands/xref.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_log_append.py
- tools/chemenu/tests/test_source_hygiene.py
- tools/chemenu/tests/test_touch.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/type_resolver.py
- tools/wikitool
- types/type-spec.md
- types/type-spec.schema.yaml
This commit is contained in:
2026-09-17 08:59:25 +02:00
parent aa31d431fc
commit 24cd221b21
24 changed files with 154 additions and 36 deletions
+1 -1
View File
@@ -179,7 +179,7 @@ def rel_path(path: Path) -> str:
def check_collision(name: str) -> None:
"""Fail if any page under wiki/ already has `name` as its filename stem.
"""Fail if any page under kb/ already has `name` as its filename stem.
The stem *is* the page title and wikilinks resolve by title alone, so two
files sharing a stem in different directories are indistinguishable to
+3 -3
View File
@@ -38,7 +38,7 @@ app = typer.Typer(help="Manage [^cite-id] footnote citations and their Footnotes
def _find_page(pages: dict[str, Page], title: str) -> Page:
if title not in pages:
fail(f"No page titled '{title}' found under wiki/.")
fail(f"No page titled '{title}' found under kb/.")
return pages[title]
@@ -100,7 +100,7 @@ def cite_add(
pages = load_kb_pages(config.KB_DIR)
page = _find_page(pages, page_title)
if source not in pages:
fail(f"No page titled '{source}' found under wiki/ - citing a page that doesn't exist would be a dangling reference.")
fail(f"No page titled '{source}' found under kb/ - citing a page that doesn't exist would be a dangling reference.")
marker_id, new_body, changed = upsert_citation(page, source, file)
marker = f"[^{marker_id}]"
@@ -159,7 +159,7 @@ def sync_page(page: Page) -> tuple[str, bool, list[str], list[str]]:
@app.command("sync")
def cite_sync(
page_title: Optional[str] = typer.Option(None, "--page", help="Sync just this page"),
all_pages: bool = typer.Option(False, "--all", help="Sync every page under wiki/"),
all_pages: bool = typer.Option(False, "--all", help="Sync every page under kb/"),
dry_run: bool = typer.Option(False, "--dry-run", help="Report what would change instead of writing"),
):
"""Prune orphan Footnotes definitions and re-render each page's block in
+1 -1
View File
@@ -14,7 +14,7 @@ whose push failed leaves a real, unpushed commit sitting on the branch, and
the next `publish` now pushes it instead of reporting "Nothing to commit"
forever.
Also implements the Mass-Update Gate (wiki/concepts/Mass-Update Gate.md):
Also implements the Mass-Update Gate (kb/concepts/workflows/Mass-Update Gate.md):
a push to origin/main is the one action in this system with a real,
irreversible external effect (publicly visible commit history, possible CI
triggers, other clients pulling). Small/normal publishes (< threshold
+3 -3
View File
@@ -1,4 +1,4 @@
"""Append correctly-formatted entries to wiki/log.md."""
"""Append correctly-formatted entries to kb/log.md."""
from __future__ import annotations
import re
@@ -10,7 +10,7 @@ import typer
from chemenu import config
from chemenu.commands._util import fail, rel_path, success, today_iso
app = typer.Typer(help="Manage wiki/log.md.")
app = typer.Typer(help="Manage kb/log.md.")
VALID_OPS = ["ingest", "query", "lint", "create", "update", "delete", "rename", "move"]
@@ -74,7 +74,7 @@ def log_status():
`lint` - the deterministic trigger for the Maintenance Schedule's "every
10 sources" full-lint cadence. Read-only."""
if not config.LOG_FILE.exists():
success("No wiki/log.md yet; nothing logged.")
success("No kb/log.md yet; nothing logged.")
return
entries = parse_log_entries(config.LOG_FILE.read_text(encoding="utf-8"))
count = ingests_since_last_lint(entries)
+4 -4
View File
@@ -236,7 +236,7 @@ def rename_command(
if references_only:
if new not in pages:
fail(
f"Neither '{old}' nor '{new}' is a page under wiki/. Repointing references "
f"Neither '{old}' nor '{new}' is a page under kb/. Repointing references "
f"to '{new}' would just move the dangling reference; create the page first "
"with `wikitool new ...`, or drop the reference with `wikitool xref remove`."
)
@@ -316,7 +316,7 @@ def rm_command(
pages = load_kb_pages(config.KB_DIR)
target = pages.get(page_title)
if target is None:
fail(f"No page titled '{page_title}' found under wiki/.")
fail(f"No page titled '{page_title}' found under kb/.")
inbound = inbound_pages(pages, page_title)
if inbound and not yes:
@@ -404,7 +404,7 @@ def move_command(
None, "--page", help="Exact title of the page to move to its computed location"
),
reconcile: bool = typer.Option(
False, "--reconcile", help="Move every page under wiki/ that is not at its computed location"
False, "--reconcile", help="Move every page under kb/ that is not at its computed location"
),
dry_run: bool = typer.Option(False, "--dry-run", help="List what would move without writing"),
):
@@ -475,7 +475,7 @@ def move_command(
target = pages.get(page_title)
if target is None:
fail(f"No page titled '{page_title}' found under wiki/.")
fail(f"No page titled '{page_title}' found under kb/.")
type_path = target.frontmatter.get("type")
if not type_path:
+1 -1
View File
@@ -171,7 +171,7 @@ def build_provenance_index(kb_dir: Path, raw_dir: Path) -> str:
@app.command("rebuild-index")
def rebuild_index(
dry_run: bool = typer.Option(False, "--dry-run", help="Print the result instead of writing wiki/provenance.md"),
dry_run: bool = typer.Option(False, "--dry-run", help="Print the result instead of writing kb/provenance.md"),
):
content = build_provenance_index(config.KB_DIR, config.RAW_DIR)
provenance_file = config.KB_DIR / "provenance.md"
+1 -1
View File
@@ -382,7 +382,7 @@ def raw_accept_command(
pages = load_kb_pages(config.KB_DIR)
target_page = pages.get(page)
if target_page is None:
fail(f"No page titled '{page}' found under wiki/. Create it first, or omit --page.")
fail(f"No page titled '{page}' found under kb/. Create it first, or omit --page.")
existing_rel = source_raw_files(target_page)
if not existing_rel:
fail(
+1 -1
View File
@@ -7,7 +7,7 @@ This closes the gap documented in AGENTS.md's "Gates" section: unlike a
prompt instruction ("stop after N steps"), this check runs
in-process on every `wikitool` invocation and cannot be skipped by the
calling agent "politely trying again". It mirrors the Mass-Update Gate
pattern (see git_publish.py / wiki/concepts/Mass-Update Gate.md), but that
pattern (see git_publish.py / kb/concepts/workflows/Mass-Update Gate.md), but that
gate is scoped to the *size* of a single publish, while this one is scoped to
*iteration volume* across a whole session (e.g. a wiki-ingest or wiki-lint run
that could otherwise loop unbounded over many entity/concept pages).
+1 -1
View File
@@ -232,7 +232,7 @@ def touch_command(
pages = load_kb_pages(config.KB_DIR)
page = pages.get(page_title)
if page is None:
fail(f"No page titled '{page_title}' found under wiki/. Create it first with `wikitool new ...`.")
fail(f"No page titled '{page_title}' found under kb/. Create it first with `wikitool new ...`.")
type_path = page.frontmatter.get("type")
if not type_path:
+1 -1
View File
@@ -37,7 +37,7 @@ app = typer.Typer(help="Manage bidirectional cross-references between wiki pages
def _find_page(pages: dict[str, Page], name: str) -> Page:
if name not in pages:
fail(f"No page titled '{name}' found under wiki/. Create it first with `wikitool new ...`.")
fail(f"No page titled '{name}' found under kb/. Create it first with `wikitool new ...`.")
return pages[name]
+1 -1
View File
@@ -1,5 +1,5 @@
"""Read/write markdown files with YAML frontmatter, matching the formatting
conventions already used across wiki/ (inline flow-style lists, unquoted
conventions already used across kb/ (inline flow-style lists, unquoted
dates).
We deliberately avoid a generic yaml.dump() for the frontmatter block because
+1 -1
View File
@@ -526,7 +526,7 @@ def _section(lines: list[str], title: str, items: list, formatter) -> None:
def render_markdown(report: dict) -> str:
lines = [f"# Structural Lint Report ({report['generated']})", ""]
lines.append(f"Scanned {report['page_count']} pages under `wiki/`. This report covers only")
lines.append(f"Scanned {report['page_count']} pages under `kb/`. This report covers only")
lines.append("mechanically-verifiable structural issues; see the Semantic Review section")
lines.append("below for judgment calls the LLM should complete.")
lines.append("")
+1 -1
View File
@@ -58,7 +58,7 @@ def test_log_status_reports_zero_with_no_log_file(tmp_path, monkeypatch, capsys)
monkeypatch.setattr(config, "LOG_FILE", tmp_path / "log.md")
log_status()
assert "No wiki/log.md yet" in capsys.readouterr().out
assert "No kb/log.md yet" in capsys.readouterr().out
def test_log_status_warns_at_the_ten_ingest_threshold(tmp_path, monkeypatch, capsys):
@@ -0,0 +1,70 @@
"""Guard against a renamed stage leaving its old path standing in source.
`wiki/` was renamed to `kb/` on 2026-08-21. The directory moved; the string did
not, in 27 places - error messages, `--help` text, docstrings and the lint
report's own header, all naming a directory that no longer exists. Nothing
caught it, because no check reads a path literal in source.
This is that check. It is deliberately a plain substring scan over the source
tree rather than a `docs verify` check: `docs verify` reads `shipped_prose()`,
which is markdown only, and the bulk of the defect sat in `.py` strings.
"""
from __future__ import annotations
from pathlib import Path
TOOLS_DIR = Path(__file__).resolve().parents[2]
# Retired stage path -> what replaced it. A future rename adds a row here in the
# same change that does the renaming, which is what makes the next occurrence a
# test failure instead of a string nobody reads for a year.
RETIRED_STAGE_PATHS = {
"wiki/": "kb/",
}
# Occurrences that are not stage paths at all. Kept as an explicit list with a
# reason rather than dodged by a cleverer regex: an exception a reader can see
# is worth more than one a pattern hides.
ALLOWED = {
# A fixture release URL, where `wiki` is a repository name in `owner/repo`.
("tests/test_dist_cmd.py", "https://example/torben/wiki/releases/tag/v0.3.1"),
}
SELF = Path(__file__).resolve()
def _scanned_files() -> list[Path]:
"""Every source file in `tools/` a stale path literal could hide in.
This module is excluded, and has to be: it is the one file whose job is to
name the retired paths, so scanning it would make the guard fail on its own
declaration.
"""
candidates = [*(TOOLS_DIR / "chemenu").rglob("*.py"), TOOLS_DIR / "wikitool"]
return sorted(path for path in candidates if path.resolve() != SELF)
def test_no_retired_stage_path_survives_in_source():
findings = []
for path in _scanned_files():
relative = path.relative_to(TOOLS_DIR / "chemenu" if path.suffix == ".py" else TOOLS_DIR)
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
for retired, replacement in RETIRED_STAGE_PATHS.items():
if retired not in line:
continue
if any(key == str(relative) and excerpt in line for key, excerpt in ALLOWED):
continue
findings.append(
f"{relative}:{number} names the retired path `{retired}` "
f"(now `{replacement}`): {line.strip()}"
)
assert findings == [], "Retired stage paths still in source:\n" + "\n".join(findings)
def test_the_guard_actually_scans_something():
"""A scan that silently matches no file passes for the wrong reason."""
scanned = _scanned_files()
assert len(scanned) > 40
assert TOOLS_DIR / "wikitool" in scanned
assert SELF not in scanned
+1 -1
View File
@@ -44,7 +44,7 @@ def _touch(**overrides):
def test_touch_bumps_modified(touch_wiki):
_touch(page_title="aurora")
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
# Unquoted YAML dates round-trip as date objects, matching the rest of wiki/.
# Unquoted YAML dates round-trip as date objects, matching the rest of kb/.
assert str(frontmatter["modified"]) == datetime.date.today().isoformat()
+1 -1
View File
@@ -149,7 +149,7 @@ def test_get_layout_reads_entity_type_specs_own_layout_field():
"person": "people",
}
assert all(spec.get("title") for spec in layout.values())
# Order drives wiki/index.md section order.
# Order drives kb/index.md section order.
assert list(layout) == ["project", "system", "tool", "technology", "person"]
+4 -4
View File
@@ -59,7 +59,7 @@ class TypeResolver:
if not type_path.endswith('.md'):
raise ValueError(f"Type path must end with .md: {type_path}")
# Try as repo-relative from /types/ or /wiki/**/types/
# Try as repo-relative from /types/ or /kb/**/types/
if type_path.startswith('types/'):
candidate = (self.repo_root / type_path).resolve()
# Ensure it's within the repo - resolve() first so `..` segments
@@ -77,7 +77,7 @@ class TypeResolver:
# Ensure it's within the repo and starts with types/ or has types/ in path
try:
candidate.relative_to(self.repo_root)
# Allow relative paths that resolve to types/ or wiki/**/types/
# Allow relative paths that resolve to types/ or kb/**/types/
if 'types' in str(candidate.relative_to(self.repo_root).parts):
if candidate.exists() and candidate.is_file():
return candidate
@@ -300,8 +300,8 @@ class TypeResolver:
def get_layout(self, type_path: str, source_file: Path = None) -> Optional[Dict[str, Dict[str, str]]]:
"""Return a type-spec's `layout:` frontmatter - a map of subtype value
to `{dir, title}`, declaring where instances of each subtype are
written under wiki/ and what section title/order to use in
wiki/index.md. This is the single source of truth for directory
written under kb/ and what section title/order to use in
kb/index.md. This is the single source of truth for directory
placement, so callers (e.g. `new_page.py`, `index_build.py`) never
need a hand-maintained `entity_type -> subdirectory` Python dict.
+1 -1
View File
@@ -18,7 +18,7 @@ if [ ! -x "$DIR/.venv/bin/python" ]; then
fi
# Do NOT cd into $DIR: that would resolve relative CLI arguments (e.g.
# --markdown "wiki/Lint Report.md") against tools/ instead of the caller's cwd.
# --markdown "kb/Lint Report.md") against tools/ instead of the caller's cwd.
# Add $DIR to PYTHONPATH instead so `chemenu` is importable regardless of
# where this script is invoked from.
export PYTHONPATH="$DIR${PYTHONPATH:+:$PYTHONPATH}"