From 2a06f6e0b8fae841b2fb0c75f7dd6c0752eb1555 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 26 Jun 2026 11:02:42 +0200 Subject: [PATCH 1/4] fix: quote wiki frontmatter titles so colons don't break the Quartz build --- stacklets/memory/bot/cli/wiki.py | 28 +++++--- tests/stacklets/test_memory_wiki.py | 100 +++++++++++++++++++++------- 2 files changed, 95 insertions(+), 33 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index e212c1e..2d539fc 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -120,6 +120,18 @@ def _err(msg: str) -> None: print(msg, file=sys.stderr) +# YAML-safe scalar for a frontmatter value. Human strings (a display +# name, a section title like "Notes: Admin") carry colons, leading `&`, +# `#`, quotes -- all of which a bare YAML scalar mis-parses. Quartz's +# parser hard-fails the whole page on one of these (observed live: a +# `title: Notes: Admin` index page took the entire wiki build down). +# Always-quote and escape; double quotes with backslash-escaped `"` and +# `\` is the one form that round-trips every printable string. +def _yaml_str(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + # Citation extractor — single use here, inlined to keep the command # stacklet-local. Matches `[N]`, `[N, M]`, and back-to-back `[N][M]` # patterns. Returns unique numbers in first-seen order so the caller @@ -507,14 +519,14 @@ def _member_preamble(slug: str, display: str, synonyms: list[str]) -> str: others = [s for s in synonyms if s != canonical] lines = [ "---", - f"title: {canonical}", + f"title: {_yaml_str(canonical)}", f"slug: {slug}", "type: person", - f"canonical: {canonical}", + f"canonical: {_yaml_str(canonical)}", ] if others: lines.append("synonyms:") - lines.extend(f" - {s}" for s in others) + lines.extend(f" - {_yaml_str(s)}" for s in others) lines.append("---") return "\n".join(lines) @@ -693,10 +705,10 @@ def _correspondent_preamble(slug_: str, canonical: str) -> str: """ return "\n".join([ "---", - f"title: {canonical}", + f"title: {_yaml_str(canonical)}", f"slug: {slug_}", "type: correspondent", - f"canonical: {canonical}", + f"canonical: {_yaml_str(canonical)}", "---", ]) @@ -847,10 +859,10 @@ def _topic_preamble(slug: str, display: str, scope: str) -> str: return "\n".join([ "---", - f"title: {display}", + f"title: {_yaml_str(display)}", f"slug: {slug}", "type: topic", - f"scope: {scope}", + f"scope: {_yaml_str(scope)}", "---", ]) @@ -1019,7 +1031,7 @@ async def _publish_capture_indexes( await _publish( content, target_path=target_path, shared_bucket=shared_bucket, commit_msg=f"{COMMIT_PREFIX} {page_dir} {kind} index", - default_preamble=f"---\ntitle: {_KIND_LABEL[kind]}: {display}\n---", + default_preamble=f"---\ntitle: {_yaml_str(f'{_KIND_LABEL[kind]}: {display}')}\n---", ) diff --git a/tests/stacklets/test_memory_wiki.py b/tests/stacklets/test_memory_wiki.py index 6139857..a6e72df 100644 --- a/tests/stacklets/test_memory_wiki.py +++ b/tests/stacklets/test_memory_wiki.py @@ -16,6 +16,8 @@ import sys from pathlib import Path +import yaml + _REPO_ROOT = Path(__file__).resolve().parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "stacklets")) sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli")) @@ -38,9 +40,22 @@ _topic_entries, _topic_locations, _topic_preamble, + _yaml_str, ) +def _frontmatter(preamble: str) -> dict: + """Parse a `---`-fenced preamble the way Quartz's YAML parser does. + + The wiki publishes these blocks verbatim; a value YAML mis-reads + (a bare colon, a leading `&`) hard-fails the whole site build. These + tests assert the block round-trips through a real YAML parser. + """ + body = preamble.strip() + assert body.startswith("---") and body.endswith("---") + return yaml.safe_load(body.strip("-\n")) or {} + + # ── Fixture helpers ───────────────────────────────────────────────────── @@ -312,18 +327,18 @@ class TestMemberPreamble: def test_carries_okf_type_and_canonical(self): pre = _member_preamble("maggie", "Maggie", ["Maggie", "Margaret"]) - assert "type: person" in pre # OKF concept kind - assert "title: Margaret" in pre # longest synonym is canonical - assert "canonical: Margaret" in pre - assert "slug: maggie" in pre - assert pre.startswith("---") - assert pre.rstrip().endswith("---") + fm = _frontmatter(pre) + assert fm["type"] == "person" # OKF concept kind + assert fm["title"] == "Margaret" # longest synonym is canonical + assert fm["canonical"] == "Margaret" + assert fm["slug"] == "maggie" def test_no_synonyms_collapses_to_display(self): pre = _member_preamble("homer", "Homer", []) - assert "type: person" in pre - assert "title: Homer" in pre - assert "synonyms:" not in pre + fm = _frontmatter(pre) + assert fm["type"] == "person" + assert fm["title"] == "Homer" + assert "synonyms" not in fm # ── Correspondents ──────────────────────────────────────────────────── @@ -377,12 +392,19 @@ class TestCorrespondentPreamble: def test_carries_okf_type_and_canonical(self): pre = _correspondent_preamble("duff-insurance", "Duff Insurance") - assert "type: correspondent" in pre # OKF concept kind - assert "title: Duff Insurance" in pre - assert "canonical: Duff Insurance" in pre - assert "slug: duff-insurance" in pre - assert pre.startswith("---") - assert pre.rstrip().endswith("---") + fm = _frontmatter(pre) + assert fm["type"] == "correspondent" # OKF concept kind + assert fm["title"] == "Duff Insurance" + assert fm["canonical"] == "Duff Insurance" + assert fm["slug"] == "duff-insurance" + + def test_canonical_with_colon_stays_valid_yaml(self): + # A correspondent like "Müller: Steuerberatung" carries a colon; + # unquoted it would break the whole Quartz build (the prod bug). + pre = _correspondent_preamble("mueller", "Müller: Steuerberatung") + fm = _frontmatter(pre) + assert fm["title"] == "Müller: Steuerberatung" + assert fm["canonical"] == "Müller: Steuerberatung" class TestCorrespondentBody: @@ -411,24 +433,52 @@ class TestTopicPreamble: def test_shared_topic_carries_scope_and_slug(self): pre = _topic_preamble("camping", "Camping", "shared") - assert "title: Camping" in pre - assert "slug: camping" in pre - assert "scope: shared" in pre - assert "type: topic" in pre - # Opens and closes with the YAML fence. - assert pre.startswith("---") - assert pre.rstrip().endswith("---") + fm = _frontmatter(pre) + assert fm["title"] == "Camping" + assert fm["slug"] == "camping" + assert fm["scope"] == "shared" + assert fm["type"] == "topic" def test_personal_topic_scope_recorded(self): pre = _topic_preamble("gravel", "Gravel", "personal") - assert "scope: personal" in pre + assert _frontmatter(pre)["scope"] == "personal" def test_display_with_special_chars(self): """A topic named `Van Life` keeps the casing + spaces in the display title, even though the slug is hyphenated.""" pre = _topic_preamble("van-life", "Van Life", "shared") - assert "title: Van Life" in pre - assert "slug: van-life" in pre + fm = _frontmatter(pre) + assert fm["title"] == "Van Life" + assert fm["slug"] == "van-life" + + def test_display_with_colon_stays_valid_yaml(self): + # A topic display carrying a colon ("Itchy: The Park") must quote, + # or the YAML parser reads a nested mapping and fails the build. + pre = _topic_preamble("itchy", "Itchy: The Park", "shared") + assert _frontmatter(pre)["title"] == "Itchy: The Park" + + +# ── _yaml_str ────────────────────────────────────────────────────────── + + +class TestYamlStr: + """Every human string in frontmatter routes through `_yaml_str`. A + bare colon here is what took the live wiki build down.""" + + def test_index_title_with_colon_round_trips(self): + # The exact prod failure: a folder-index title `Notes: Admin` + # emitted unquoted as `title: Notes: Admin` is invalid YAML. + title = _yaml_str("Notes: Admin") + assert yaml.safe_load(f"title: {title}") == {"title": "Notes: Admin"} + + def test_quotes_and_backslashes_escaped(self): + value = 'He said "hi" \\ bye' + assert yaml.safe_load(f"x: {_yaml_str(value)}") == {"x": value} + + def test_yaml_special_leads_round_trip(self): + # Leading `&`, `*`, `#`, `-` all change meaning in a bare scalar. + for value in ("&anchor", "*alias", "# not a comment", "- dash"): + assert yaml.safe_load(f"x: {_yaml_str(value)}") == {"x": value} # ── Anchored regen helpers ────────────────────────────────────────────── From 66b4ef9b1186f513163bb76d1d9f9e2fa00c5477 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 26 Jun 2026 11:16:25 +0200 Subject: [PATCH 2/4] fix: validate page frontmatter before publishing to the vault --- stacklets/memory/bot/cli/wiki.py | 32 +++++++++++++++++++++++++++++ tests/stacklets/test_memory_wiki.py | 25 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 2d539fc..f024944 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -52,6 +52,8 @@ import tomllib from pathlib import Path +import yaml + # Sibling stacklets — memory.lib gives us summary callout extraction # and frontmatter parsing without re-implementing them here. sys.path.insert(0, str(Path(__file__).resolve().parents[3])) @@ -132,6 +134,28 @@ def _yaml_str(value: str) -> str: return f'"{escaped}"' +# Write-boundary gate. Quartz parses frontmatter with a strict YAML +# parser and hard-fails the whole site build on one bad page; the +# failure surfaces three hops downstream, in the running wiki container, +# not here where the page is composed. So we parse the page's own +# frontmatter with the same strictness before pushing it to Forgejo. A +# page that won't load is refused at the source -- the previous good +# version stays live. `_parse_frontmatter` in memory.lib is deliberately +# lenient (skips malformed lines), so it can't stand in for this check. +def _frontmatter_error(page: str) -> str | None: + """Return a YAML error string if `page`'s frontmatter won't parse, else None.""" + if not page.startswith("---\n"): + return None # no frontmatter block to validate + end = page.find("\n---", 4) + if end < 0: + return "unterminated frontmatter block" + try: + yaml.safe_load(page[4:end]) + except yaml.YAMLError as e: + return str(e).replace("\n", " ") + return None + + # Citation extractor — single use here, inlined to keep the command # stacklet-local. Matches `[N]`, `[N, M]`, and back-to-back `[N][M]` # patterns. Returns unique numbers in first-seen order so the caller @@ -1165,6 +1189,14 @@ async def _publish(page: str, *, target_path: str, shared_bucket: str, prior = existing.get("content", "") if existing else "" merged = _splice_generated(prior, page, default_preamble=default_preamble) + # Refuse to publish a page whose frontmatter won't parse -- one + # bad page takes the entire Quartz build down, so it never leaves + # this process. The previously published version stays live. + fm_error = _frontmatter_error(merged) + if fm_error: + _err(f"refusing to publish {target_path}: invalid frontmatter ({fm_error})") + return 1 + await asyncio.to_thread( client.put_file, repo_owner, _REPO_NAME, target_path, diff --git a/tests/stacklets/test_memory_wiki.py b/tests/stacklets/test_memory_wiki.py index a6e72df..7e80bd2 100644 --- a/tests/stacklets/test_memory_wiki.py +++ b/tests/stacklets/test_memory_wiki.py @@ -31,6 +31,7 @@ _correspondent_roster, _entry_kind, _format_topic_evidence, + _frontmatter_error, _index_vault, _member_preamble, _member_slugs, @@ -481,6 +482,30 @@ def test_yaml_special_leads_round_trip(self): assert yaml.safe_load(f"x: {_yaml_str(value)}") == {"x": value} +# ── _frontmatter_error (write-boundary gate) ─────────────────────────── + + +class TestFrontmatterError: + """The publish gate parses a page's frontmatter the strict way Quartz + does, so a page that would crash the build never reaches Forgejo.""" + + def test_valid_page_passes(self): + page = '---\ntitle: "Notes: Admin"\ntype: note\n---\n\n# body\n' + assert _frontmatter_error(page) is None + + def test_unquoted_colon_title_is_rejected(self): + # The exact prod page that took the wiki down. + page = "---\ntitle: Notes: Admin\n---\n\n# body\n" + assert _frontmatter_error(page) is not None + + def test_no_frontmatter_is_not_an_error(self): + # A bodyless page or one without a block is valid, not malformed. + assert _frontmatter_error("# just a heading\n") is None + + def test_unterminated_block_is_rejected(self): + assert _frontmatter_error("---\ntitle: x\nno closing fence\n") is not None + + # ── Anchored regen helpers ────────────────────────────────────────────── from wiki import _previous_generated, _renumber_citations # noqa: E402 From 9f983482284c359ee6591823f4fc439ef902d4d8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 26 Jun 2026 11:27:21 +0200 Subject: [PATCH 3/4] feat: stack memory wiki clean to delete generated pages for a full rebuild --- stacklets/memory/bot/cli/wiki.py | 131 ++++++++++++++++++++++++---- tests/stacklets/test_memory_wiki.py | 24 +++++ 2 files changed, 136 insertions(+), 19 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index f024944..a6817dc 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -9,6 +9,8 @@ stack memory wiki --topic camping just one topic's page stack memory wiki --topics every topic page, no home/members stack memory wiki --dry-run preview to stdout, no writes + stack memory wiki clean delete every generated page (rebuild slate) + stack memory wiki clean --dry-run list the pages clean would delete `--member` and `--topic` repeat and combine with `--home`: any selection flag switches from the full sweep to "generate exactly this @@ -178,7 +180,16 @@ def _extract_citations(text: str) -> list[int]: async def run(llm: LLM, argv: list[str]) -> int: - dry_run = "--dry-run" in argv + dry_run = "--dry-run" in argv or "--dry" in argv + + # `clean` is a slate-wiper, not a generator: it removes every + # generated page so a following bare `wiki` rebuilds the whole site + # from the captures. It talks only to Forgejo, so it runs before the + # local-vault checks below (it needs no vault mount). + if argv and argv[0] == "clean": + shared_bucket = os.environ.get("SHARED_BUCKET", "family") + return await _clean_generated(shared_bucket=shared_bucket, dry_run=dry_run) + home_sel = "--home" in argv topics_only = "--topics" in argv members_sel = _arg_values(argv, "--member") @@ -1146,6 +1157,103 @@ def _splice_generated(existing: str, generated: str, *, # ── Forgejo publish ───────────────────────────────────────────────────────── +async def _admin_forgejo_client() -> "ForgejoClient | None": + """A Forgejo client authenticated as the Matrix admin user. + + Issues a short-lived token from the admin creds in the env rather + than reusing the archivist-bot's persisted token -- the CLI is a + manual one-shot, so its auth stays independent of the bot's + lifecycle. Returns None (after logging) when the creds aren't set. + The caller wraps the call in its own try so a `ForgejoError` from + token issue surfaces with the caller's context. + """ + code_url = os.environ.get("CODE_URL", "") + admin_user = os.environ.get("MATRIX_ADMIN_USER", "") + admin_password = os.environ.get("MATRIX_ADMIN_PASSWORD", "") + if not (code_url and admin_user and admin_password): + _err("CODE_URL / MATRIX_ADMIN_USER / MATRIX_ADMIN_PASSWORD not set") + return None + # issue_token deletes and reissues on name collision, so repeated CLI + # runs are safe. + admin_client = await asyncio.to_thread( + ForgejoClient, + url=code_url, admin_user=admin_user, admin_password=admin_password, + ) + token = await asyncio.to_thread( + admin_client.issue_token, + admin_user, admin_password, _TOKEN_NAME, _TOKEN_SCOPES, + ) + return await asyncio.to_thread(ForgejoClient, url=code_url, token=token) + + +def _is_generated_page(content: str) -> bool: + """True if `content` is a page the wiki produced. + + Identified by the splice marker every published page carries. Source + captures (notes, bookmarks, documents, email threads) carry only + their own `` / section markers, never this one, so + the predicate is the safe delete filter for `clean`: it cannot match + a capture. + """ + return _BEGIN in content + + +async def _clean_generated(*, shared_bucket: str, dry_run: bool) -> int: + """Delete every wiki-generated page, leaving a clean rebuild slate. + + "Generated" means the page carries the splice marker (see + `_is_generated_page`); source captures never do, so this only ever + removes derived pages -- and each removal is its own commit, so git + history keeps the copy. `README.md` files are skipped: they pair + hand-written guidance with a generated region, so they are not a + pure projection to throw away. `--dry-run` lists what would go, + one path per line, without touching the repo. + """ + repo_owner = shared_bucket + try: + client = await _admin_forgejo_client() + if client is None: + return 1 + + tree = await asyncio.to_thread( + client.list_tree, repo_owner, _REPO_NAME, _BRANCH, + ) + candidates = [ + e["path"] for e in tree + if e.get("type") == "blob" + and e.get("path", "").endswith(".md") + and Path(e["path"]).name != "README.md" + ] + + targets: list[tuple[str, str]] = [] + for path in candidates: + existing = await asyncio.to_thread( + client.get_file, repo_owner, _REPO_NAME, path, _BRANCH, + ) + if existing and _is_generated_page(existing.get("content", "")): + targets.append((path, existing["sha"])) + + if not targets: + _err("no generated wiki pages found -- nothing to clean") + return 0 + + for path, sha in targets: + if dry_run: + print(path) + continue + await asyncio.to_thread( + client.delete_file, repo_owner, _REPO_NAME, path, + sha=sha, message=f"{COMMIT_PREFIX} clean {path}", branch=_BRANCH, + ) + _err(f"deleted {path}") + + _err(f"{'would delete' if dry_run else 'deleted'} {len(targets)} generated page(s)") + return 0 + except ForgejoError as e: + _err(f"forgejo clean failed: {e}") + return 1 + + async def _publish(page: str, *, target_path: str, shared_bucket: str, commit_msg: str, default_preamble: str = "") -> int: """Splice the generated page into `target_path` on the memory repo. @@ -1160,27 +1268,12 @@ async def _publish(page: str, *, target_path: str, shared_bucket: str, wiki's working copy on disk) so the splice preserves whatever the family last committed outside the brackets. """ - code_url = os.environ.get("CODE_URL", "") - admin_user = os.environ.get("MATRIX_ADMIN_USER", "") - admin_password = os.environ.get("MATRIX_ADMIN_PASSWORD", "") - if not (code_url and admin_user and admin_password): - _err("CODE_URL / MATRIX_ADMIN_USER / MATRIX_ADMIN_PASSWORD not set") - return 1 - repo_owner = shared_bucket # default-install convention; see run() try: - # Issue a token for the admin user. issue_token deletes and - # reissues on name collision, so repeated CLI runs are safe. - admin_client = await asyncio.to_thread( - ForgejoClient, - url=code_url, admin_user=admin_user, admin_password=admin_password, - ) - token = await asyncio.to_thread( - admin_client.issue_token, - admin_user, admin_password, _TOKEN_NAME, _TOKEN_SCOPES, - ) - client = await asyncio.to_thread(ForgejoClient, url=code_url, token=token) + client = await _admin_forgejo_client() + if client is None: + return 1 existing = await asyncio.to_thread( client.get_file, repo_owner, _REPO_NAME, target_path, _BRANCH, diff --git a/tests/stacklets/test_memory_wiki.py b/tests/stacklets/test_memory_wiki.py index 7e80bd2..b3b7bfd 100644 --- a/tests/stacklets/test_memory_wiki.py +++ b/tests/stacklets/test_memory_wiki.py @@ -33,6 +33,7 @@ _format_topic_evidence, _frontmatter_error, _index_vault, + _is_generated_page, _member_preamble, _member_slugs, _month_label, @@ -506,6 +507,29 @@ def test_unterminated_block_is_rejected(self): assert _frontmatter_error("---\ntitle: x\nno closing fence\n") is not None +# ── _is_generated_page (clean's delete filter) ───────────────────────── + + +class TestIsGeneratedPage: + """`clean` deletes a page only if this returns True. It must never + match a source capture, or clean would eat real content.""" + + def test_generated_page_matches(self): + page = '---\ntitle: "Camping"\n---\n\nbody\n\n' + assert _is_generated_page(page) is True + + def test_note_capture_does_not_match(self): + # A note capture carries frontmatter + body, no splice marker. + note = "---\ntype: note\ntopics: [camping]\n---\n\n# Tent idea\n\nbuy a bigger tent\n" + assert _is_generated_page(note) is False + + def test_email_capture_with_mid_marker_does_not_match(self): + # Email threads carry `mid:` markers, which must not be confused + # with the generated-region marker. + email = "---\ntype: email\n---\n\n## 2026-06-01 - Bart\n\nhi\n" + assert _is_generated_page(email) is False + + # ── Anchored regen helpers ────────────────────────────────────────────── from wiki import _previous_generated, _renumber_citations # noqa: E402 From 0d202144236e316e68c57631b43339dc897c5c32 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 26 Jun 2026 11:35:56 +0200 Subject: [PATCH 4/4] feat: confirm before wiki clean deletes, with --yes to skip --- stacklets/memory/bot/cli/wiki.py | 32 ++++++++++++++++++++++++++--- tests/stacklets/test_memory_wiki.py | 13 ++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index a6817dc..89f1891 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -9,8 +9,9 @@ stack memory wiki --topic camping just one topic's page stack memory wiki --topics every topic page, no home/members stack memory wiki --dry-run preview to stdout, no writes - stack memory wiki clean delete every generated page (rebuild slate) + stack memory wiki clean delete every generated page (asks first) stack memory wiki clean --dry-run list the pages clean would delete + stack memory wiki clean --yes skip the confirmation (scripted rebuild) `--member` and `--topic` repeat and combine with `--home`: any selection flag switches from the full sweep to "generate exactly this @@ -188,7 +189,10 @@ async def run(llm: LLM, argv: list[str]) -> int: # local-vault checks below (it needs no vault mount). if argv and argv[0] == "clean": shared_bucket = os.environ.get("SHARED_BUCKET", "family") - return await _clean_generated(shared_bucket=shared_bucket, dry_run=dry_run) + assume_yes = "--yes" in argv or "-y" in argv + return await _clean_generated( + shared_bucket=shared_bucket, dry_run=dry_run, assume_yes=assume_yes, + ) home_sel = "--home" in argv topics_only = "--topics" in argv @@ -1198,7 +1202,13 @@ def _is_generated_page(content: str) -> bool: return _BEGIN in content -async def _clean_generated(*, shared_bucket: str, dry_run: bool) -> int: +def _is_affirmative(response: str) -> bool: + """A yes to a `[y/N]` prompt -- anything else (including empty) is no.""" + return response.strip().lower() in ("y", "yes") + + +async def _clean_generated(*, shared_bucket: str, dry_run: bool, + assume_yes: bool = False) -> int: """Delete every wiki-generated page, leaving a clean rebuild slate. "Generated" means the page carries the splice marker (see @@ -1237,6 +1247,22 @@ async def _clean_generated(*, shared_bucket: str, dry_run: bool) -> int: _err("no generated wiki pages found -- nothing to clean") return 0 + # Destructive: confirm before deleting (skipped under --dry-run, + # which deletes nothing, and --yes, for scripted rebuilds). A + # non-interactive stdin reads as "no" so an automated caller + # without --yes aborts safely rather than wiping the wiki. + if not dry_run and not assume_yes: + for path, _ in targets: + _err(f" {path}") + prompt = f"Delete {len(targets)} generated wiki page(s)? [y/N] " + try: + answer = await asyncio.to_thread(input, prompt) + except EOFError: + answer = "" + if not _is_affirmative(answer): + _err("aborted -- nothing deleted") + return 0 + for path, sha in targets: if dry_run: print(path) diff --git a/tests/stacklets/test_memory_wiki.py b/tests/stacklets/test_memory_wiki.py index b3b7bfd..aed6f0f 100644 --- a/tests/stacklets/test_memory_wiki.py +++ b/tests/stacklets/test_memory_wiki.py @@ -33,6 +33,7 @@ _format_topic_evidence, _frontmatter_error, _index_vault, + _is_affirmative, _is_generated_page, _member_preamble, _member_slugs, @@ -530,6 +531,18 @@ def test_email_capture_with_mid_marker_does_not_match(self): assert _is_generated_page(email) is False +class TestIsAffirmative: + """The clean confirmation defaults to no -- only an explicit yes deletes.""" + + def test_yes_variants(self): + for r in ("y", "Y", "yes", "YES", " yes "): + assert _is_affirmative(r) is True + + def test_everything_else_is_no(self): + for r in ("", "n", "no", "\n", "yeah", "sure"): + assert _is_affirmative(r) is False + + # ── Anchored regen helpers ────────────────────────────────────────────── from wiki import _previous_generated, _renumber_citations # noqa: E402