From c3dd65b3839e4b1f3a5cecf279594ca3fb7055ca Mon Sep 17 00:00:00 2001 From: DroidAgent <154886644+echobt@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:44:39 +0000 Subject: [PATCH] fix(ferndesk): make problems-* slug sanitization explicit + regression-proof (COR-444) The 400s on `problems-*` creates were never about the article body. Every `problems/*.mdx` whose file name carries an underscore failed `POST /articles` with 400 (19 pages), while `problems/conflict`, `problems/forbidden`, and `problems/gone` created fine. FernDesk accepts a slug only in `[a-z0-9]+(-[a-z0-9]+)*`, and this repo mirrors the backend's snake_case `ErrorCode` names as file names. `#17` fixed the symptom by chaining `.replace("_", "-")` onto the slug. This commit makes the rule explicit and guards it: - `sanitize_slug()` states the FernDesk charset in one place: lowercase, every run of other characters collapsed to one hyphen, ends trimmed. It raises rather than emitting an empty slug. - `discover_pages()` refuses to sync when two paths sanitize to one slug (`a_b.mdx` + `a-b.mdx`), which would otherwise silently overwrite an article. - `ferndesk-sync-retry.test.py` runs `discover_pages` over this repository and fails if a problem page yields an illegal slug, keeps an underscore, collides with another page, or keeps a bare snake_case title. 13 new checks (62 total). - `FERNDESK.md` documents why the MDX file names cannot be renamed: they match `ErrorCode::as_str` and the page's `/problems/{code}` type URI. Behavior is unchanged for every current page: all 99 slugs are byte-identical to before this commit, and titles/markdown are untouched. The guard is proven by mutation: reverting `sanitize_slug` to the underscore-preserving form fails 8 checks. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- scripts/FERNDESK.md | 27 +++++++++++ scripts/ferndesk_sync.py | 42 ++++++++++++++-- scripts/tests/ferndesk-sync-retry.test.py | 58 +++++++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/scripts/FERNDESK.md b/scripts/FERNDESK.md index a6edad6..068f327 100644 --- a/scripts/FERNDESK.md +++ b/scripts/FERNDESK.md @@ -58,6 +58,33 @@ A create that trips a slug conflict (409/422, or a message naming the slug) is recovered by looking the article up and PATCHing it, so a stale cache or a pagination miss does not surface as a failure. +## Slugs and titles (FernDesk POST 400) + +FernDesk accepts a slug only in `[a-z0-9]+(-[a-z0-9]+)*`. It answers `POST +/articles` with **400** for anything else, so `problems/bad_request` used to be +sent verbatim as `problems-bad_request` and rejected: 19 `problems-*` pages +failed while `problems-conflict`, `problems-forbidden`, and `problems-gone` +created fine. + +The MDX file name cannot change — it matches `ErrorCode::as_str` and the page's +`/problems/{code}` type URI — so the slug is sanitized at the sync boundary by +`sanitize_slug()`: lowercased, every run of other characters (`_`, `/`, spaces, +`--`) collapsed to one hyphen, ends trimmed. `problems/bad_request` → +`problems-bad-request`, `api/errors` → `api-errors`. + +Two source paths that sanitize to one slug (`a_b.mdx` and `a-b.mdx`) would +silently overwrite one article, so `discover_pages` raises instead of syncing. + +Titles are separate: a bare snake_case code is not a usable article title, so a +page whose title is `bad_request` is published as `Bad request (bad_request)` +using its `description`. The title keeps the snake_case code, since that is the +wire contract readers search for. + +`scripts/tests/ferndesk-sync-retry.test.py` holds both rules: it runs +`discover_pages` over this repository and fails if any problem page produces an +illegal slug, keeps an underscore, collides with another page, or keeps a bare +snake_case title. + ## Factory Droid path (agent) When Manager Deploy lands **prod** and content needs judgment (rewrites, gap fill, migration QA), launch Factory Droid only: diff --git a/scripts/ferndesk_sync.py b/scripts/ferndesk_sync.py index 69c6c1a..45a899b 100755 --- a/scripts/ferndesk_sync.py +++ b/scripts/ferndesk_sync.py @@ -14,6 +14,9 @@ DOCS_ROOT docs repo root (default: cwd) Idempotent upsert by slug. Never deletes FernDesk-only articles (safe migration). +Slugs are sanitized to FernDesk's `[a-z0-9]+(-[a-z0-9]+)*` (COR-444: snake_case +problem codes such as `problems/bad_request` 400'd on create) and bare +snake_case titles are humanized from the page `description`. """ from __future__ import annotations @@ -392,8 +395,33 @@ def content_fingerprint(md: str) -> str: return hashlib.sha256(md.encode()).hexdigest()[:16] +# FernDesk answers `POST /articles` with 400 for a slug outside +# `[a-z0-9]+(-[a-z0-9]+)*`. The backend's problem codes are snake_case +# (`bad_request`) and this repo mirrors them as file names, so every +# `problems/*.mdx` with an underscore failed to create (COR-444: 19 pages). +# Sanitize the slug at the sync boundary rather than renaming the MDX: the file +# name has to keep matching `ErrorCode::as_str` and the page's +# `/problems/{code}` type URI. +_SLUG_SEPARATORS = re.compile(r"[^a-z0-9]+") + + +def sanitize_slug(raw: str) -> str: + """FernDesk-safe slug: lowercase, hyphen-separated, no `_` or stray `-`. + + Collapses every run of other characters (`_`, `/`, spaces, `--`) to one + hyphen and trims the ends, so `problems/bad_request` becomes + `problems-bad-request`. Raises when nothing slug-worthy is left, rather + than publishing an article at an empty or nonsense slug. + """ + slug = _SLUG_SEPARATORS.sub("-", raw.strip().lower()).strip("-") + if not slug: + raise ValueError(f"cannot build a FernDesk slug from {raw!r}") + return slug + + def discover_pages(docs_root: Path) -> list[dict]: - pages = [] + pages: list[dict] = [] + seen_slugs: dict[str, str] = {} for path in sorted(docs_root.rglob("*")): if not path.is_file() or path.suffix not in {".mdx", ".md"}: continue @@ -425,12 +453,20 @@ def discover_pages(docs_root: Path) -> list[dict]: if not coll: continue fp = content_fingerprint(md) + # Two paths can sanitize to one slug (`a_b.mdx` + `a-b.mdx`); the second + # would silently overwrite the first article. Fail loudly instead. + slug = sanitize_slug(slug) + prior = seen_slugs.get(slug) + if prior is not None: + raise ValueError( + f"slug collision: {rel} and {prior} both map to FernDesk slug {slug!r}" + ) + seen_slugs[slug] = rel pages.append( { "path": rel, "title": title, - # FernDesk rejects underscores in slugs (POST 400); normalize. - "slug": slug.replace("/", "-").replace("_", "-"), + "slug": slug, "collection": coll, "markdown": md, "fp": fp, diff --git a/scripts/tests/ferndesk-sync-retry.test.py b/scripts/tests/ferndesk-sync-retry.test.py index d041ff0..183030a 100644 --- a/scripts/tests/ferndesk-sync-retry.test.py +++ b/scripts/tests/ferndesk-sync-retry.test.py @@ -10,6 +10,7 @@ import importlib.util import json import os +import re import sys import tempfile from contextlib import contextmanager @@ -33,6 +34,17 @@ def check(name: str, cond: bool, detail: object = "") -> None: print(f"FAIL {name} {detail}") +def _raises(fn, *args, **kwargs) -> bool: + """True when `fn(*args)` raises ValueError — used for the input guards.""" + try: + fn(*args, **kwargs) + except ValueError: + return True + except Exception: + return False + return False + + @contextmanager def env(**pairs: str): saved = {k: os.environ.get(k) for k in pairs} @@ -252,6 +264,52 @@ def flaky_then_ok(method, url, n): finally: restore(saved) +# --- slug sanitization (COR-444: 19 problems-* pages 400'd on create) -------- +# FernDesk 400s a slug outside `[a-z0-9]+(-[a-z0-9]+)*`. Problem codes are +# snake_case and mirrored as file names, so `problems/bad_request` used to be +# sent verbatim as `problems-bad_request` and rejected. The page has to keep its +# file name (it matches `ErrorCode::as_str`), so the slug is sanitized here. +_SLUG_OK = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") + +check("snake_case becomes hyphenated", fs.sanitize_slug("problems/bad_request") == "problems-bad-request", + fs.sanitize_slug("problems/bad_request")) +check("folder separator becomes a hyphen", fs.sanitize_slug("api/errors") == "api-errors", + fs.sanitize_slug("api/errors")) +check("already-clean slug is unchanged", fs.sanitize_slug("problems-conflict") == "problems-conflict") +check("uppercase is folded down", fs.sanitize_slug("Problems/Bad_Request") == "problems-bad-request", + fs.sanitize_slug("Problems/Bad_Request")) +check("repeated separators collapse", fs.sanitize_slug("a__b--c") == "a-b-c", fs.sanitize_slug("a__b--c")) +check("edge separators are trimmed", fs.sanitize_slug("__lead_trail__") == "lead-trail", + fs.sanitize_slug("__lead_trail__")) +check("a slug with nothing usable raises", _raises(fs.sanitize_slug, "___")) + +# Every problem page in this repository must produce a FernDesk-legal slug, and +# the sanitizer must not make two pages collide onto one article. +if (ROOT / "problems").is_dir(): + _pages = fs.discover_pages(ROOT) + _problem_pages = [p for p in _pages if p["path"].startswith("problems/")] + check("problem pages are discovered", len(_problem_pages) >= 20, len(_problem_pages)) + _bad = [p["slug"] for p in _problem_pages if not _SLUG_OK.fullmatch(p["slug"])] + check("every problem slug is FernDesk-legal", not _bad, _bad) + _underscored = [p["slug"] for p in _problem_pages if "_" in p["slug"]] + check("no problem slug keeps an underscore", not _underscored, _underscored) + _slugs = [p["slug"] for p in _pages] + check("sanitized slugs stay unique repo-wide", len(_slugs) == len(set(_slugs)), + len(_slugs) - len(set(_slugs))) + # The wire contract for `type` is the snake_case code; sanitizing the slug + # must not leave a bare snake_case title, which FernDesk also rejects. + _bad_titles = [p["title"] for p in _problem_pages if "_" in p["title"] and " " not in p["title"]] + check("no bare snake_case title survives", not _bad_titles, _bad_titles) + +# A collision introduced by sanitizing must fail the run, not overwrite a page. +_collide_root = Path(tempfile.mkdtemp(prefix="ferndesk-collide-")) +(_collide_root / "getting-started").mkdir(parents=True) +for _name in ("a_b.mdx", "a-b.mdx"): + (_collide_root / "getting-started" / _name).write_text( + "---\ntitle: Collide\n---\n\nHi.\n", encoding="utf-8" + ) +check("colliding slugs raise instead of overwriting", _raises(fs.discover_pages, _collide_root)) + # --- shared fixtures for the end-to-end runs --------------------------------- docs_root = Path(tempfile.mkdtemp(prefix="ferndesk-docs-")) (docs_root / "getting-started").mkdir(parents=True)