From be9830540bd224ee1e503bbd68ca30485cde9887 Mon Sep 17 00:00:00 2001 From: Lawrence Lane Date: Mon, 20 Apr 2026 17:59:10 -0400 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20epic=20agent=20DX=20polish=20?= =?UTF-8?q?=E2=80=94=20docstrings,=20CLI=20tips,=20scaffold,=20RFC=20archi?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the five agent-DX discoverability gaps identified in the 2026-04-20 cold-read evaluation: - S1: Core-type docstrings. Site/Page/Section "When to use" intent coverage 1.2% → 71.6% (58/81), ~100% on the targeted non-trivial subset. - S2: AGENTS.md "Extending Bengal" section (91 lines) covering template functions, content types, CLI commands, build phases. Includes a 3-bullet Milo-vs-Click primer. - S3: `bengal new content-type ` scaffold generates a working ContentTypeStrategy subclass with register_strategy() call. - S4: Every `cli.error(...)` in bengal/cli/ now pairs with a guidance follow-up (cli.tip/info/render_write) within 3 lines. Coverage 27.1% → 100% (70/70). AST gate test enforces the rule for new additions. - S5: Archived 4 bucket-B RFCs with unambiguous done-signals to plan/complete/ and plan/evaluated/. Root plan/*.md count 69 → 65. Full rationale and per-sprint changelog in plan/epic-agent-dx-polish.md. Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 92 ++++ bengal/cli/milo_app.py | 7 + bengal/cli/milo_commands/build.py | 15 + bengal/cli/milo_commands/cache.py | 1 + bengal/cli/milo_commands/check.py | 3 + bengal/cli/milo_commands/codemod.py | 1 + bengal/cli/milo_commands/content.py | 3 + bengal/cli/milo_commands/debug.py | 6 + bengal/cli/milo_commands/i18n.py | 5 + bengal/cli/milo_commands/inspect.py | 2 + bengal/cli/milo_commands/new.py | 171 +++++- bengal/cli/milo_commands/serve.py | 4 + bengal/cli/milo_commands/theme.py | 2 + bengal/cli/milo_commands/version.py | 15 + bengal/cli/utils/site.py | 2 + bengal/core/page/__init__.py | 193 ++++++- bengal/core/section/__init__.py | 31 +- bengal/core/site/__init__.py | 326 +++++++++++- plan/README.md | 15 + .../rfc-contextvar-config-implementation.md | 2 +- .../rfc-kida-reserved-keyword-subscript.md | 1 + plan/{ => complete}/sprint-0-ty-triage.md | 3 +- plan/epic-agent-dx-polish.md | 488 ++++++++++++++++++ .../rfc-contextvar-config-analysis.md | 1 + .../rfc-free-threading-hardening.md | 1 + plan/rfc-snapshot-enabled-v2-opportunities.md | 6 +- tests/unit/cli/test_cli_error_gates.py | 78 +++ tests/unit/cli/test_new_content_type.py | 103 ++++ 28 files changed, 1524 insertions(+), 53 deletions(-) rename plan/{ => complete}/rfc-kida-reserved-keyword-subscript.md (98%) rename plan/{ => complete}/sprint-0-ty-triage.md (97%) create mode 100644 plan/epic-agent-dx-polish.md rename plan/{ => evaluated}/rfc-contextvar-config-analysis.md (98%) rename plan/{ => evaluated}/rfc-free-threading-hardening.md (99%) create mode 100644 tests/unit/cli/test_cli_error_gates.py create mode 100644 tests/unit/cli/test_new_content_type.py diff --git a/AGENTS.md b/AGENTS.md index ebe8a5a7e..a454e030a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,98 @@ Things that look reasonable and are wrong here: --- +## Extending Bengal + +Four extension points. Pick the one that matches your need. If none fit, ask before changing the `Plugin` protocol or any of the 9 hook surfaces (`bengal/protocols/`) — see "Escape hatches." + +### 1. Template function or filter + +**When:** You need a new Jinja filter or global usable in `.html` templates (e.g. `{{ posts | my_sort }}`). + +**Where:** Add a module under `bengal/rendering/template_functions/`. Each module exports a `register(env, site)` function and is wired into `register_all()` in that package's `__init__.py`. No decorator, no auto-discovery — append your `register()` call to the coordinator. + +```python +# bengal/rendering/template_functions/my_funcs.py +from __future__ import annotations +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from bengal.protocols import SiteLike, TemplateEnvironment + +def register(env: TemplateEnvironment, site: SiteLike) -> None: + env.filters["my_filter"] = lambda v: v.upper() +``` + +Then add `my_funcs.register(env, site)` to the appropriate phase in `register_all()`. Canonical example: `bengal/rendering/template_functions/strings.py`. + +### 2. Content type strategy + +**When:** A section needs custom sorting, pagination, filtering, or template defaults that built-in `content_type` strategies (`blog`, `doc`, `tutorial`, `changelog`, `archive`, `notebook`, `track`, `page`, `autodoc-python`, `autodoc-cli`) don't cover. + +**Fastest path:** `bengal new content-type ` — scaffolds a `ContentTypeStrategy` subclass in the right place with `When to use:`, `default_template`, `allows_pagination`, `sort_pages()`, `detect_from_section()`, and the `register_strategy()` call already wired up. Then edit the TODOs. + +**Manual path** (or to understand what the scaffold gives you): subclass `ContentTypeStrategy` from `bengal/content_types/base.py`, register the instance via `register_strategy()` from `bengal/content_types/registry.py`. Built-in strategies live in `bengal/content_types/strategies.py` — read `BlogStrategy` first, it's the canonical short example. + +```python +from bengal.content_types import ContentTypeStrategy, register_strategy + +class RecipeStrategy(ContentTypeStrategy): + default_template = "recipes/list.html" + allows_pagination = True + def sort_pages(self, pages): + return sorted(pages, key=lambda p: p.metadata.get("difficulty", 99)) + +register_strategy("recipe", RecipeStrategy()) +``` + +Sections opt in by setting `type = "recipe"` in their `_index.md` frontmatter, or by `detect_from_section()` returning `True`. + +### 3. CLI command + +**When:** A new `bengal ` operation — top-level command or subcommand under an existing group (`config`, `theme`, `content`, `version`, etc.). + +**Where:** Write a function in `bengal/cli/milo_commands/`, then register it in `bengal/cli/milo_app.py` via `cli.lazy_command(...)`. There is **no filesystem auto-discovery** — the file has to be wired in `milo_app.py` to be reachable. + +```python +# bengal/cli/milo_commands/hello.py +from __future__ import annotations +from typing import Annotated +from milo import Description + +def hello(name: Annotated[str, Description("Who to greet")] = "world") -> dict: + """Say hi.""" + return {"status": "ok", "message": f"hello, {name}"} +``` + +Then in `bengal/cli/milo_app.py`: + +```python +cli.lazy_command( + "hello", + import_path="bengal.cli.milo_commands.hello:hello", + description="Say hi", +) +``` + +**Milo ≠ Click — three things to know**: + +- **Args:** `Annotated[type, Description("...")]` parameters with defaults — not `@click.option` decorators stacked above the function. +- **Discovery:** explicit `cli.lazy_command(name, import_path="module:fn")` in `milo_app.py`. Lazy import avoids the cold-start tax; the function isn't loaded until the command runs. +- **Output:** commands return `dict`. Milo handles structured output, MCP serialization, and `--json` for free. Don't `print()` — use `cli.render_write(template, ...)` for human output and the dict return for machine output. + +Canonical example: `bengal/cli/milo_commands/clean.py` — small, real, exercises the full pattern (annotated args, dict return, `cli.error()` / `cli.tip()` for failure paths). + +### 4. Build phase + +**When:** You think you need to insert a custom step into the build pipeline. + +**Where:** You don't. Build phases are hardcoded in `bengal/orchestration/build/__init__.py` — 21 numbered phases, called sequentially. Adding one is a design conversation, not a patch (see "Escape hatches"). + +**For observability** (timing, progress reporting): use `BuildOptions.on_phase_start` / `on_phase_complete` callbacks. + +**For behavior changes mid-build**: use one of the 9 plugin hook surfaces in `bengal/protocols/` — those *are* the supported extension surface. Custom orchestration phases bypass the plugin contract and free-threading guarantees. + +--- + ## Done criteria A change is done when all of these hold: diff --git a/bengal/cli/milo_app.py b/bengal/cli/milo_app.py index c7e64993f..b27f87d69 100644 --- a/bengal/cli/milo_app.py +++ b/bengal/cli/milo_app.py @@ -99,6 +99,13 @@ display_result=False, ) +new.lazy_command( + "content-type", + import_path="bengal.cli.milo_commands.new:new_content_type", + description="Create a new ContentTypeStrategy scaffold", + display_result=False, +) + # --------------------------------------------------------------------------- # Tier 2 — Feature groups (weekly use) # --------------------------------------------------------------------------- diff --git a/bengal/cli/milo_commands/build.py b/bengal/cli/milo_commands/build.py index 84871366d..40890cddc 100644 --- a/bengal/cli/milo_commands/build.py +++ b/bengal/cli/milo_commands/build.py @@ -161,6 +161,7 @@ def build( # sets quiet=True so the user sees the right error message. if verbose and fast: cli.error("--verbose and --fast cannot be used together (--fast implies --quiet)") + cli.tip("Pass only one — use --verbose for debugging, --fast for tight inner loops.") raise SystemExit(2) if dashboard: @@ -175,23 +176,28 @@ def build( conflicts.append("--full-output") if conflicts: cli.error(f"--dashboard cannot be used with {', '.join(conflicts)}") + cli.tip("--dashboard owns the terminal output; drop the conflicting flag(s).") raise SystemExit(2) if memory_optimized and perf_profile_path: cli.error("--memory-optimized and --perf-profile cannot be used together") + cli.tip("Profile a normal run first, then re-run with --memory-optimized once tuned.") raise SystemExit(2) if verbose and quiet: cli.error("--verbose and --quiet cannot be used together") + cli.tip("Pass only one — they're opposites.") raise SystemExit(2) if strict and continue_on_error: cli.error("--strict and --continue-on-error cannot be used together") + cli.tip("Pass only one — they're opposites (fail-fast vs. tolerate).") raise SystemExit(2) error_format_val = (error_format or "text").lower() if error_format_val not in {"text", "json"}: cli.error(f"--error-format must be 'text' or 'json' (got: {error_format!r})") + cli.tip("Use --error-format text for humans, --error-format json for tooling.") raise SystemExit(2) # Apply fast mode after validation @@ -211,18 +217,22 @@ def build( if dev_profile and profile_val: cli.error("--dev-profile is shorthand for --profile dev — use one or the other") + cli.tip("Drop --dev-profile and pass --profile dev (or vice versa).") raise SystemExit(2) if theme_dev and profile_val: cli.error("--theme-dev is shorthand for --profile theme-dev — use one or the other") + cli.tip("Drop --theme-dev and pass --profile theme-dev (or vice versa).") raise SystemExit(2) if incremental and no_incremental: cli.error("--incremental and --no-incremental cannot be used together") + cli.tip("Pass only one — they're opposites.") raise SystemExit(2) if assets_pipeline and no_assets_pipeline: cli.error("--assets-pipeline and --no-assets-pipeline cannot be used together") + cli.tip("Pass only one — they're opposites.") raise SystemExit(2) # Determine build profile @@ -344,6 +354,9 @@ def build( if error_count > 0: cli.blank() cli.error(f"Validation failed with {error_count} error(s)") + cli.tip( + "Run `bengal check` for detailed diagnostics, or fix errors above and re-run." + ) raise SystemExit(1) cli.blank() @@ -524,11 +537,13 @@ def _build_versions( if not getattr(site, "versioning_enabled", False): cli.error("Versioning is not enabled (add versioning config to bengal.yaml)") + cli.tip("Add a [versions] section to bengal.yaml — see `bengal version --help`.") raise SystemExit(1) version_config = getattr(site, "version_config", None) if not version_config or not version_config.is_git_mode: cli.error("--version and --all-versions require git mode versioning") + cli.tip('Set `mode = "git"` in your [versions] config, or drop these flags.') raise SystemExit(1) from bengal.content.versioning import GitVersionAdapter diff --git a/bengal/cli/milo_commands/cache.py b/bengal/cli/milo_commands/cache.py index 9f982ffc0..6d3426744 100644 --- a/bengal/cli/milo_commands/cache.py +++ b/bengal/cli/milo_commands/cache.py @@ -113,6 +113,7 @@ def cache_hash( hasher.update(file_path.read_bytes()) except OSError as e: cli.error(f"Cannot read file '{file_path}': {e}") + cli.tip("Check file permissions or exclude unreadable paths from the hash.") raise SystemExit(1) from e result = hasher.hexdigest()[:16] diff --git a/bengal/cli/milo_commands/check.py b/bengal/cli/milo_commands/check.py index 093bb544f..5acc9d80f 100644 --- a/bengal/cli/milo_commands/check.py +++ b/bengal/cli/milo_commands/check.py @@ -142,6 +142,7 @@ def check( if report.has_errors(): cli.error(f"Validation failed: {errors} error(s) found") + cli.tip("Re-run with --suggestions for actionable fixes, or --verbose for full context.") raise SystemExit(1) if report.has_warnings(): cli.warning(f"Validation completed with {warnings} warning(s)") @@ -219,6 +220,7 @@ def _run_validation(files_to_validate): if report.has_errors(): cli.error(f"{report.total_errors} error(s) found") + cli.tip("Fix the issues above — watcher will re-run on save.") elif report.has_warnings(): cli.warning(f"{report.total_warnings} warning(s)") else: @@ -310,4 +312,5 @@ def _validate_templates(site, pattern, show_hints, cli, templates, validate_cont summary={"errors": len(errors), "warnings": 0, "passed": 0}, ) cli.error(f"Template validation failed: {len(errors)} error(s)") + cli.tip("See the validation report above — each issue includes a suggestion for how to fix it.") raise SystemExit(1) diff --git a/bengal/cli/milo_commands/codemod.py b/bengal/cli/milo_commands/codemod.py index 35be961dc..d3a44c782 100644 --- a/bengal/cli/milo_commands/codemod.py +++ b/bengal/cli/milo_commands/codemod.py @@ -29,6 +29,7 @@ def codemod( if not target.exists() or not target.is_dir(): cli.error(f"Directory not found: {target}") + cli.tip("Pass a path to your site's templates/ directory, or the project root.") raise SystemExit(1) mode_badge = ( diff --git a/bengal/cli/milo_commands/content.py b/bengal/cli/milo_commands/content.py index 23c2c6035..0810e1a97 100644 --- a/bengal/cli/milo_commands/content.py +++ b/bengal/cli/milo_commands/content.py @@ -140,6 +140,9 @@ def content_fetch( return {"fetched": by_source, "total": sum(by_source.values())} except Exception as e: cli.error(f"Fetch failed: {e}") + cli.tip( + "Verify the source URL/path and any credentials in [content.sources] — re-run with --verbose for details." + ) raise SystemExit(1) from None diff --git a/bengal/cli/milo_commands/debug.py b/bengal/cli/milo_commands/debug.py index 9bf914231..385abd0db 100644 --- a/bengal/cli/milo_commands/debug.py +++ b/bengal/cli/milo_commands/debug.py @@ -267,6 +267,9 @@ def debug_migrate( cli.info(f" {prefix}{action}") elif execute and not preview.can_proceed: cli.error("Cannot proceed due to warnings") + cli.tip( + "Resolve the warnings shown above, or re-run without --execute to preview only." + ) return { "move_from": move_from, @@ -277,6 +280,9 @@ def debug_migrate( if move_from or move_to: cli.error("Both --move-from and --move-to are required for a move operation") + cli.tip( + "Pass both flags together, e.g. `bengal debug structure --move-from old/ --move-to new/`." + ) raise SystemExit(1) report = migrator.run() diff --git a/bengal/cli/milo_commands/i18n.py b/bengal/cli/milo_commands/i18n.py index 1b9110c08..d828c5978 100644 --- a/bengal/cli/milo_commands/i18n.py +++ b/bengal/cli/milo_commands/i18n.py @@ -55,6 +55,7 @@ def i18n_compile( cli.success(f"Compiled {loc_name}: {po_path.relative_to(root)} -> .mo") except Exception as e: cli.error(f"Failed to compile {po_path}: {e}") + cli.tip("Check the .po file for syntax errors — `msgfmt --check` can help locate them.") raise if compiled == 0: @@ -146,6 +147,7 @@ def i18n_extract( cli.success(f"Extracted {len(keys)} strings to {out.relative_to(root)}") except Exception as e: cli.error(f"Failed to write .pot: {e}") + cli.tip("Check write permissions on the i18n/ directory and that the output path exists.") raise return {"keys": len(keys), "output": str(out), "domain": domain} @@ -294,9 +296,11 @@ def i18n_init( cli.success(f"Created {po_path.relative_to(root)}") except ImportError: cli.error("polib is required: pip install bengal[gettext]") + cli.tip("Install the gettext extras with `pip install bengal[gettext]` then re-run.") raise SystemExit(1) from None except Exception as e: cli.error(f"Failed to create {po_path}: {e}") + cli.tip("Check write permissions on the locale directory, then re-run.") raise if created: @@ -351,6 +355,7 @@ def i18n_sync( import polib except ImportError: cli.error("polib is required: pip install bengal[gettext]") + cli.tip("Install the gettext extras with `pip install bengal[gettext]` then re-run.") raise SystemExit(1) from None locales = [loc.strip() for loc in locale.split(",") if loc.strip()] if locale else [] diff --git a/bengal/cli/milo_commands/inspect.py b/bengal/cli/milo_commands/inspect.py index ae0498a90..d151ccb3b 100644 --- a/bengal/cli/milo_commands/inspect.py +++ b/bengal/cli/milo_commands/inspect.py @@ -92,6 +92,7 @@ def inspect_page( warning_count = sum(1 for i in explanation.issues if i.severity == "warning") if error_count: cli.error(f"Found {error_count} error(s) and {warning_count} warning(s)") + cli.tip("See the issue list above — each entry points to the file, line, and fix.") elif warning_count: cli.warning(f"Found {warning_count} warning(s)") else: @@ -192,6 +193,7 @@ def inspect_links( raise except Exception as e: cli.error(f"Link check failed: {e}") + cli.tip("Re-run with --traceback to see the full error, or narrow scope with --path.") raise SystemExit(1) from e diff --git a/bengal/cli/milo_commands/new.py b/bengal/cli/milo_commands/new.py index 04e32db56..128ffb1e1 100644 --- a/bengal/cli/milo_commands/new.py +++ b/bengal/cli/milo_commands/new.py @@ -1,7 +1,8 @@ -"""Milo commands for ``bengal new`` — scaffold sites, themes, pages, and templates.""" +"""Milo commands for ``bengal new`` — scaffold sites, themes, pages, templates, and content types.""" from __future__ import annotations +from textwrap import dedent from typing import Annotated from milo import Description @@ -48,11 +49,13 @@ def new_site( slug = slugify(site_title) if not slug: cli.error("Site name must contain at least one alphanumeric character!") + cli.tip("Try a name like 'my-site' or 'docs'.") raise SystemExit(1) site_path = Path(slug) if site_path.exists(): cli.error(f"Directory {slug} already exists!") + cli.tip(f"Pick a different name, or remove the existing './{slug}' directory first.") raise SystemExit(1) # Use init_preset as template if provided @@ -60,6 +63,9 @@ def new_site( site_template = get_template(effective_template) if site_template is None: cli.error(f"Template '{effective_template}' not found") + cli.tip( + "Built-in templates: default, blog, docs, portfolio, product, resume, landing, changelog." + ) raise SystemExit(1) # Create directory structure @@ -142,6 +148,7 @@ def new_theme( if theme_path.exists(): cli.error(f"Theme directory {theme_path} already exists!") + cli.tip(f"Pick a different name, or remove the existing '{theme_path}' directory first.") raise SystemExit(1) # Create directory structure @@ -252,6 +259,7 @@ def new_page( content_dir = Path("content") if not content_dir.exists(): cli.error("Not in a Bengal site directory!") + cli.tip("Run `bengal new site ` to create one, or cd into an existing site.") raise SystemExit(1) slug = slugify(name) @@ -263,6 +271,7 @@ def new_page( if page_path.exists(): cli.error(f"Page {page_path} already exists!") + cli.tip(f"Pick a different name, or remove the existing file: {page_path}") raise SystemExit(1) atomic_write_text( @@ -300,6 +309,7 @@ def new_layout( templates_dir = Path("templates") if not templates_dir.exists(): cli.error("Not in a Bengal site directory!") + cli.tip("Run `bengal new site ` to create one, or cd into an existing site.") raise SystemExit(1) if not name: @@ -315,6 +325,7 @@ def new_layout( if target_path.exists(): cli.error(f"Layout {target_path} already exists!") + cli.tip(f"Pick a different name, or remove the existing file: {target_path}") raise SystemExit(1) atomic_write_text( @@ -352,6 +363,7 @@ def new_partial( templates_dir = Path("templates") if not templates_dir.exists(): cli.error("Not in a Bengal site directory!") + cli.tip("Run `bengal new site ` to create one, or cd into an existing site.") raise SystemExit(1) if not name: @@ -367,6 +379,7 @@ def new_partial( if target_path.exists(): cli.error(f"Partial {target_path} already exists!") + cli.tip(f"Pick a different name, or remove the existing file: {target_path}") raise SystemExit(1) atomic_write_text( @@ -382,3 +395,159 @@ def new_partial( ) return {"path": str(target_path), "name": name, "slug": slug} + + +# --------------------------------------------------------------------------- +# new content-type +# --------------------------------------------------------------------------- + + +def _content_type_scaffold(slug: str, class_name: str) -> str: + """Render the source for a new ContentTypeStrategy scaffold.""" + return dedent( + f'''\ + """Custom content type strategy: {slug}. + + Generated by ``bengal new content-type {slug}``. Edit the TODOs below. + """ + + from __future__ import annotations + + from typing import TYPE_CHECKING + + from bengal.content_types import ContentTypeStrategy, register_strategy + + if TYPE_CHECKING: + from collections.abc import Sequence + + from bengal.protocols import PageLike, SectionLike + + + class {class_name}Strategy(ContentTypeStrategy): + """ + Strategy for the ``{slug}`` content type. + + When to use: + A section opts in by setting ``type = "{slug}"`` in its + ``_index.md`` frontmatter, or auto-detected via + ``detect_from_section`` below. Override only the methods whose + defaults don\'t suit your content — the base class in + ``bengal/content_types/base.py`` handles the rest. + """ + + # TODO: point at the template you want for ``{slug}`` list pages. + default_template = "{slug}/list.html" + + # TODO: set True if a ``{slug}`` section can grow large enough to paginate. + allows_pagination = False + + def sort_pages(self, pages: Sequence[PageLike]) -> list[PageLike]: + """Sort pages for list views. + + Default below sorts by ``weight`` (low first), then ``title``. + Replace with a domain-appropriate ordering — e.g. by date for + chronological content, or by ``metadata["difficulty"]`` for tutorials. + """ + return sorted( + pages, + key=lambda p: (p.metadata.get("weight", 999), p.title), + ) + + def detect_from_section(self, section: SectionLike) -> bool: + """Auto-detect whether ``section`` is a ``{slug}`` section. + + Return True to claim a section without explicit ``type =`` in + its frontmatter. Default below is conservative: claim only + sections literally named ``"{slug}"``. + """ + return section.name.lower() == "{slug}" + + + # Registration runs at import time. Whoever imports this module activates + # the strategy. For Bengal contributors, add this module to + # ``bengal/content_types/registry.py`` (next to the built-in strategies). + # For site authors, import this from a plugin entry point or site-level + # init module so it loads before the build runs. + register_strategy("{slug}", {class_name}Strategy()) + ''' + ) + + +def new_content_type( + name: Annotated[str, Description("Content type name (e.g. recipe, case-study)")] = "", +) -> dict: + """Create a new ContentTypeStrategy scaffold. + + See ``bengal/content_types/base.py`` for the full strategy interface, and + ``bengal/content_types/strategies.py`` for built-in examples (BlogStrategy, + DocsStrategy, TutorialStrategy). + """ + from pathlib import Path + + from bengal.cli.utils import get_cli_output + from bengal.utils.io.atomic_write import atomic_write_text + from bengal.utils.primitives.text import slugify + + cli = get_cli_output() + + if not name: + name = cli.prompt("Enter content type name (e.g. recipe, case-study)") + if not name: + cli.warning("Cancelled.") + raise SystemExit(1) + + slug = slugify(name) + if not slug: + cli.error("Content type name must contain at least one alphanumeric character!") + cli.tip("Examples: 'recipe', 'case-study', 'release-note'") + raise SystemExit(1) + + class_name = "".join(part.capitalize() for part in slug.split("-")) + + # Choose target dir: bengal repo → bengal/content_types/, else → content_types/ + bengal_pkg = Path("bengal") / "content_types" + if bengal_pkg.is_dir(): + target_dir = bengal_pkg + location_note = "Bengal repo (built-in)" + else: + target_dir = Path("content_types") + location_note = "site-local (wire up via plugin or init)" + + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / f"{slug.replace('-', '_')}_strategy.py" + + if target_path.exists(): + cli.error(f"File {target_path} already exists!") + cli.tip(f"Remove it first, or pick a different name than '{slug}'.") + raise SystemExit(1) + + atomic_write_text(target_path, _content_type_scaffold(slug, class_name)) + + if target_dir == bengal_pkg: + steps = [ + f"Edit {target_path} — replace TODO defaults", + "Import the module from bengal/content_types/registry.py to activate it", + f'Set ``type = "{slug}"`` in any section\'s _index.md', + ] + else: + steps = [ + f"Edit {target_path} — replace TODO defaults", + "Import this module from a plugin or site init so register_strategy() runs", + f'Set ``type = "{slug}"`` in any section\'s _index.md', + ] + + cli.render_write( + "scaffold_result.kida", + title=f"Content Type: {slug}", + entries=[ + {"name": str(target_path), "note": f"{class_name}Strategy ({location_note})"}, + ], + steps=steps, + summary=f"Created: {target_path}", + ) + + return { + "path": str(target_path), + "slug": slug, + "class_name": f"{class_name}Strategy", + } diff --git a/bengal/cli/milo_commands/serve.py b/bengal/cli/milo_commands/serve.py index 873f1d609..b9bbb4c88 100644 --- a/bengal/cli/milo_commands/serve.py +++ b/bengal/cli/milo_commands/serve.py @@ -49,10 +49,14 @@ def serve( if verbose and debug: cli.error("--verbose and --debug cannot be used together") + cli.tip("Pick one — --debug implies --verbose and adds traceback output.") raise SystemExit(2) if version_scope_val and all_versions: cli.error("--version-scope and --all-versions cannot be used together") + cli.tip( + "Use --version-scope to target specific versions, or --all-versions for everything." + ) raise SystemExit(2) configure_cli_logging( diff --git a/bengal/cli/milo_commands/theme.py b/bengal/cli/milo_commands/theme.py index 2e440197d..f7f0619cd 100644 --- a/bengal/cli/milo_commands/theme.py +++ b/bengal/cli/milo_commands/theme.py @@ -136,6 +136,7 @@ def theme_validate( if not path.exists(): cli.error(f"Theme directory not found: {path}") + cli.tip("Pass a valid path, or run `bengal theme list` to see available themes.") raise SystemExit(1) errors = [] @@ -440,6 +441,7 @@ def run_once(): cli.phase("Assets", duration_ms=elapsed_ms, details=f"{len(outputs)} outputs") except Exception as e: cli.error(f"Asset pipeline failed: {e}") + cli.tip("Re-run with --verbose to see which asset failed, then check its source file.") if not watch: run_once() diff --git a/bengal/cli/milo_commands/version.py b/bengal/cli/milo_commands/version.py index 64e1d5a45..f6f5c49aa 100644 --- a/bengal/cli/milo_commands/version.py +++ b/bengal/cli/milo_commands/version.py @@ -95,6 +95,9 @@ def version_info( if not version_config or not version_config.enabled: cli.error("Versioning is not enabled in this site.") + cli.tip( + "Add a [versions] section to bengal.yaml — see `bengal version --help` for the schema." + ) raise SystemExit(1) resolved_id = version_config.aliases.get(version_id, version_id) @@ -164,6 +167,7 @@ def version_create( if not source_dir.exists(): cli.error(f"Source directory not found: {source_dir}") + cli.tip("Pass --from-path pointing to an existing directory (relative to the site root).") raise SystemExit(1) if dest_dir.exists() and not dry_run: @@ -280,6 +284,9 @@ def version_diff( if not version_config or not version_config.enabled: cli.error("Versioning is not enabled in this site.") + cli.tip( + "Add a [versions] section to bengal.yaml, or pass --git to diff by ref instead." + ) raise SystemExit(1) old_v = next((v for v in version_config.versions if v.id == old_version), None) @@ -287,9 +294,11 @@ def version_diff( if not old_v: cli.error(f"Version '{old_version}' not found.") + cli.tip("Run `bengal version list` to see available version IDs.") raise SystemExit(1) if not new_v: cli.error(f"Version '{new_version}' not found.") + cli.tip("Run `bengal version list` to see available version IDs.") raise SystemExit(1) from bengal.content.discovery.version_diff import VersionDiffer @@ -299,9 +308,15 @@ def version_diff( if not old_path.exists(): cli.error(f"Old version path not found: {old_path}") + cli.tip( + "Check the `source` for this version in bengal.yaml — the directory must exist on disk." + ) raise SystemExit(1) if not new_path.exists(): cli.error(f"New version path not found: {new_path}") + cli.tip( + "Check the `source` for this version in bengal.yaml — the directory must exist on disk." + ) raise SystemExit(1) differ = VersionDiffer(old_path, new_path) diff --git a/bengal/cli/utils/site.py b/bengal/cli/utils/site.py index 84486f404..4f20ea1dc 100644 --- a/bengal/cli/utils/site.py +++ b/bengal/cli/utils/site.py @@ -274,6 +274,7 @@ def load_site_from_cli( if not root_path.exists(): cli.error(f"Source directory does not exist: {root_path}") + cli.tip("Pass a valid path with --source, or `cd` into an existing Bengal site.") sys.exit(1) # Check for common directory structure mistakes @@ -292,6 +293,7 @@ def load_site_from_cli( if config_path and not config_path.exists(): cli.error(f"Config file does not exist: {config_path}") + cli.tip("Check the --config path, or omit it to use the default bengal.yaml/bengal.toml.") sys.exit(1) from bengal.cli.utils.errors import handle_exception diff --git a/bengal/core/page/__init__.py b/bengal/core/page/__init__.py index 91b1da077..6e7e5fe4f 100644 --- a/bengal/core/page/__init__.py +++ b/bengal/core/page/__init__.py @@ -287,7 +287,16 @@ def metadata(self) -> Mapping[str, Any]: """ Return combined frontmatter + cascade metadata as CascadeView. - This property provides dict-like access to page metadata. Values come from: + When to use: + Prefer this over ``self._raw_metadata`` whenever you want the + *effective* value seen by templates — raw metadata misses + cascaded values from parent sections. Prefer + ``self.frontmatter`` instead when you want **typed** access to + known fields (``page.frontmatter.title: str``) rather than + dict-style lookup. The View is immutable — write via + ``self._raw_metadata`` directly only during early construction. + + Values come from: 1. Page frontmatter (always takes precedence) 2. Cascade from parent sections (inherited values) @@ -426,6 +435,14 @@ def frontmatter(self) -> Frontmatter: """ Typed access to frontmatter fields. + When to use: + Use this when calling code knows the expected types — the + ``Frontmatter`` wrapper gives IDE/type-checker support and safer + coercion than ``self.metadata[...]``. Prefer ``self.metadata`` + for dict-style iteration or unknown-key access (templates, + frontmatter dumps). Both include cascade; the difference is + typed-object vs dict-like access. + Lazily created from metadata dict on first access. Example: @@ -455,6 +472,15 @@ def create_virtual( """ Create a virtual page for dynamically-generated content. + When to use: + Use this for pages that have no corresponding markdown file — + typically autodoc (Python/OpenAPI), generated listing indexes, + or content imported from external sources at build time. Prefer + the regular ``Page(...)`` constructor whenever a real source + file exists; virtual pages bypass disk mtime tracking and must + register their output path explicitly. Pass ``rendered_html=`` + to skip markdown parsing when you already have HTML. + Virtual pages are not backed by a disk file but integrate with the site's page collection, navigation, and rendering pipeline. @@ -714,8 +740,12 @@ def section_path(self) -> str | None: """ Get the section path as a string. - Returns the path to the section this page belongs to, or None if - the page doesn't belong to a section. + When to use: + Use when you need a serializable path string (cache keys, JSON + output, log lines). Prefer ``self.parent`` (``Section`` object) + for navigation and ``self._section_path`` (``Path``) for + filesystem operations — this property strings both together + and should not be round-tripped back into those forms. Returns: Section path as string (e.g., "docs/guides") or None @@ -728,28 +758,55 @@ def section_path(self) -> str | None: @property def next(self) -> PageLike | None: - """Next page in site collection.""" + """ + Next page in the full site collection (chronological by date). + + When to use: + Use this for site-wide "next post" navigation (blog archive + feeds). Prefer ``next_in_section`` when the reader should stay + within the current directory (docs chapters, tutorial steps). + """ from bengal.core.page.navigation import get_next_page return get_next_page(self, self._site) @property def prev(self) -> PageLike | None: - """Previous page in site collection.""" + """ + Previous page in the full site collection (chronological by date). + + When to use: + Site-wide companion to ``next``. Prefer ``prev_in_section`` for + within-directory navigation. + """ from bengal.core.page.navigation import get_prev_page return get_prev_page(self, self._site) @property def next_in_section(self) -> PageLike | None: - """Next page in current section.""" + """ + Next page among siblings in this page's section. + + When to use: + Use this for chapter-style navigation where the reader should + stay inside the current section (``docs/guides/``, ``tutorial/``). + Prefer ``next`` for site-wide chronological navigation, and + ``next_in_series`` when a page declares ``series:`` frontmatter. + """ from bengal.core.page.navigation import get_next_in_section return get_next_in_section(self, self._section) @property def prev_in_section(self) -> PageLike | None: - """Previous page in current section.""" + """ + Previous page among siblings in this page's section. + + When to use: + Section-scoped companion to ``next_in_section``. See ``next`` and + ``prev_in_series`` for the other two navigation modes. + """ from bengal.core.page.navigation import get_prev_in_section return get_prev_in_section(self, self._section) @@ -772,24 +829,58 @@ def ancestors(self) -> list[SectionLike]: @cached_property def bundle_type(self) -> BundleType: - """Bundle type classification (LEAF, BRANCH, or NONE).""" + """ + Bundle type classification (LEAF, BRANCH, or NONE). + + When to use: + Use this for switch-style logic where you need to distinguish all + three cases (``LEAF`` = page bundle with co-located resources, + ``BRANCH`` = section index like ``_index.md``, ``NONE`` = plain + page). For simple boolean checks, prefer the ``is_bundle`` / + ``is_branch_bundle`` shortcuts. + """ from bengal.core.page.bundle import get_bundle_type return get_bundle_type(self.source_path) @property def is_bundle(self) -> bool: - """True if this page is a leaf bundle with resources.""" + """ + True if this page is a leaf bundle (markdown + co-located resources). + + When to use: + Use this to gate resource-copying logic (images, downloads) + that applies only to leaf bundles. Mutually exclusive with + ``is_branch_bundle``. For full three-way classification, use + ``bundle_type``. + """ return self.bundle_type == BundleType.LEAF @property def is_branch_bundle(self) -> bool: - """True if this page is a branch bundle (section index).""" + """ + True if this page is a branch bundle (section index: ``_index.md``). + + When to use: + Use this to detect pages that represent a whole section rather + than a single piece of content — typically to render section + landing layouts or to merge branch frontmatter into cascade. + Mutually exclusive with ``is_bundle``. + """ return self.bundle_type == BundleType.BRANCH @cached_property def resources(self) -> PageResources: - """Get resources co-located with this page bundle.""" + """ + Resources co-located with this page bundle (images, downloads). + + When to use: + Use when rendering a leaf bundle that needs to surface its + co-located files — image galleries, download links, featured + media. Returns an empty container for non-bundle pages, so + templates can iterate unconditionally. Gate heavier resource + logic on ``self.is_bundle`` to avoid unnecessary lookups. + """ from bengal.core.page.bundle import get_resources return get_resources(self.source_path, getattr(self, "url", "/")) @@ -804,7 +895,16 @@ def _source(self) -> str: return self._raw_content def HasShortcode(self, name: str) -> bool: - """Return True if page content uses the given shortcode.""" + """ + Return True if page content uses the given shortcode. + + When to use: + Use in templates to branch on optional content — e.g. render a + hero layout only when the page includes a ``{{% hero %}}`` + shortcode, or skip an expensive block when its driving + shortcode is absent. Preferable to string-matching the raw + source because shortcode parsing handles comments and nesting. + """ from bengal.rendering.shortcodes import has_shortcode return has_shortcode(self, name) @@ -845,49 +945,104 @@ def excerpt(self) -> str: @cached_property def age_days(self) -> int: - """Days since publication.""" + """ + Days since publication. + + When to use: + Use for short-range freshness checks ("new this week", "posted + less than N days ago"). For coarse ranges use ``age_months`` — + it uses calendar arithmetic and is not equivalent to + ``age_days // 30``. + """ from bengal.core.page.computed import compute_age_days return compute_age_days(self.date) @cached_property def age_months(self) -> int: - """Months since publication.""" + """ + Months since publication (calendar-aware, not ``age_days // 30``). + + When to use: + Use for coarse freshness labels ("updated 3 months ago"). Uses + calendar month arithmetic, so a post from Jan 31 is reported as + 1 month old on Feb 28, not ~29 days. Prefer ``age_days`` for + short-range checks. + """ from bengal.core.page.computed import compute_age_months return compute_age_months(self.date) @cached_property def author(self) -> Author | None: - """Primary author as Author object.""" + """ + Primary author as an ``Author`` object (first entry, or ``None``). + + When to use: + Use this for byline display where a single name is shown. For + multi-author pages, iterate ``authors`` instead — ``author`` + returns only the first one and hides collaborators. + """ from bengal.core.page.computed import get_primary_author return get_primary_author(self.metadata) @cached_property def authors(self) -> list[Author]: - """All authors as list of Author objects.""" + """ + All authors as a list of ``Author`` objects (may be empty). + + When to use: + Use this for pages with multiple contributors, author cards, + or co-author metadata. Prefer ``author`` when the template only + shows a single byline — ``authors`` always returns a list even + for single-author pages. + """ from bengal.core.page.computed import get_all_authors return get_all_authors(self.metadata) @cached_property def series(self) -> Series | None: - """Series info as Series object.""" + """ + Series metadata object if this page declares ``series:`` frontmatter. + + When to use: + Use as the truthiness gate before rendering series navigation — + calling ``next_in_series`` / ``prev_in_series`` when + ``page.series`` is ``None`` returns ``None`` silently, which + hides template logic errors. Also exposes series-level metadata + (total count, series title) not available on the navigation + properties. + """ from bengal.core.page.computed import get_series_info return get_series_info(self.metadata) @cached_property def prev_in_series(self) -> PageLike | None: - """Previous page in series.""" + """ + Previous page in the explicit series this page belongs to. + + When to use: + Use this for tutorial/article-series navigation where pages opt + in via ``series:`` frontmatter. Returns ``None`` when the page + has no series. Distinct from ``prev_in_section`` (directory + siblings) and ``prev`` (site-wide chronological). + """ from bengal.core.page.computed import get_series_neighbor return get_series_neighbor(self.metadata, self._site, -1) @cached_property def next_in_series(self) -> PageLike | None: - """Next page in series.""" + """ + Next page in the explicit series this page belongs to. + + When to use: + Series companion to ``prev_in_series``. See ``next`` and + ``next_in_section`` for the other two navigation modes. + """ from bengal.core.page.computed import get_series_neighbor return get_series_neighbor(self.metadata, self._site, 1) diff --git a/bengal/core/section/__init__.py b/bengal/core/section/__init__.py index 162fcad98..6e7712257 100644 --- a/bengal/core/section/__init__.py +++ b/bengal/core/section/__init__.py @@ -146,7 +146,13 @@ def is_virtual(self) -> bool: """ Check if this is a virtual section (no disk directory). - Virtual sections are used for: + When to use: + Guard any code that needs to read from or write to ``self.path``. + Virtual sections have ``path=None`` and must not touch the disk — + attempting to would raise ``AttributeError``. Typical callers: + asset resolvers, file-mtime cache keys, content discovery walkers. + + Virtual sections back: - API documentation generated from Python source code - Dynamically-generated content from external sources - Content that doesn't have a corresponding content/ directory @@ -167,6 +173,15 @@ def create_virtual( """ Create a virtual section for dynamically-generated content. + When to use: + Use this when you need a Section for content that has no + corresponding ``content/`` directory — typically autodoc + (``VirtualAutodocOrchestrator``), remote content fetchers, or + programmatically-generated indexes. Prefer the regular + ``Section(name, path)`` constructor whenever a real directory + exists; virtual sections skip disk-backed operations and must + register their URL explicitly via ``relative_url``. + Virtual sections are not backed by a disk directory but integrate with the site's section hierarchy, navigation, and menu system. @@ -208,6 +223,12 @@ def slug(self) -> str: """ URL-friendly identifier for this section. + When to use: + Prefer this over reading ``self.path.name`` or ``self.name`` + directly when you need a stable URL segment. It handles virtual + sections (no path) and disk-backed sections uniformly; raw + attribute access is not safe for virtual sections. + For virtual sections, uses the name directly. For physical sections, uses the directory name. @@ -250,8 +271,14 @@ def weight(self) -> float: """ Get section weight for sorting (always returns sortable value). + When to use: + Prefer this over ``self.metadata.get("weight")`` whenever sorting. + Raw metadata access returns ``None`` for unweighted sections and + may return a non-numeric string from malformed frontmatter; this + property coerces to ``float`` and falls back to ``inf`` so + ``sorted(sections, key=lambda s: s.weight)`` never raises. + Returns weight from metadata if set, otherwise infinity (sorts last). - This property ensures sections are always sortable without None errors. Example in _index.md: --- diff --git a/bengal/core/site/__init__.py b/bengal/core/site/__init__.py index 2805c6aeb..e370fb5af 100644 --- a/bengal/core/site/__init__.py +++ b/bengal/core/site/__init__.py @@ -250,6 +250,14 @@ def registry(self) -> ContentRegistry: """ Content registry for O(1) page/section lookups. + When to use: + Use for low-level registry operations — bulk registration, URL + ownership checks, epoch introspection. For typical lookups, + prefer the wrappers: ``get_section_by_path``, + ``get_section_by_url``, ``page_by_source_path``. Go to the + registry directly only when those wrappers don't cover the + access pattern. + Initialized lazily on first access. """ if self._registry is None: @@ -262,7 +270,16 @@ def registry(self) -> ContentRegistry: return self._registry def get_section_by_path(self, path: Path | str) -> SectionLike | None: - """Look up a section by its path (O(1) operation).""" + """ + Look up a section by its path (O(1) operation). + + When to use: + Use when you have a filesystem path (absolute, ``content/``- + relative, or root-relative) and need the matching ``Section``. + Prefer ``get_section_by_url`` when the caller has a URL. + Returns ``None`` if not found (logs a debug diagnostic); do not + treat ``None`` as an error in code that runs pre-discovery. + """ if isinstance(path, str): path = Path(path) @@ -289,7 +306,15 @@ def get_section_by_path(self, path: Path | str) -> SectionLike | None: return section def get_section_by_url(self, url: str) -> SectionLike | None: - """Look up a section by its relative URL (O(1) operation).""" + """ + Look up a section by its relative URL (O(1) operation). + + When to use: + Use for URL-driven lookups (permalink resolution, redirect + handling, sitemap tooling). Prefer ``get_section_by_path`` when + you have a filesystem path. Returns ``None`` if no section + owns that URL. + """ section = self.registry.get_section_by_url(url) if section is None: @@ -307,6 +332,12 @@ def register_sections(self) -> None: """ Build the section registry for path-based and URL-based lookups. + When to use: + Call once per build after ``discover_content()`` and before + any ``get_section_by_*()`` call. Orchestrators already wire + this in the standard sequence; only call directly if you are + driving discovery manually (e.g., tests or custom pipelines). + Populates ContentRegistry with all sections (recursive). Must be called after discover_content(). """ @@ -341,6 +372,13 @@ def discover_content(self, content_dir: Path | None = None) -> None: """ Discover all content (pages, sections) in the content directory. + When to use: + Call once early in a build, before ``register_sections`` and + ``build_cascade_snapshot``. Orchestrators wire this in the + standard sequence — call directly only from tests or custom + pipelines. Passing ``content_dir=`` overrides the default + ``root_path/content`` for mixed-source builds. + Scans the content directory recursively, creating Page and Section objects for all markdown files and organizing them into a hierarchy. """ @@ -451,6 +489,13 @@ def discover_assets(self, assets_dir: Path | None = None) -> None: """ Discover all assets in the assets directory and theme assets. + When to use: + Call once per build after theme setup, typically alongside + ``discover_content``. Order matters: theme assets are loaded + first so site-level assets can override them by sharing a + relative path. Override ``assets_dir`` only for tests or + alternative layouts. + Theme assets are discovered first (lower priority), then site assets (higher priority, can override theme assets). """ @@ -565,7 +610,14 @@ def _apply_cascades(self) -> None: @property def cascade(self) -> CascadeSnapshot: """ - Get the immutable cascade snapshot for this build. + Immutable cascade snapshot for this build (read-only). + + When to use: + Use to resolve cascaded metadata values outside a Page/Section + context (e.g., build-wide queries, diagnostic dumps, cross-page + analytics). Page code should read through ``Page.metadata``, + which applies cascade per-page. The snapshot is rebuilt by + ``build_cascade_snapshot()`` — never mutate it. Resolution order: 1. BuildState.cascade_snapshot (during builds — structurally fresh) @@ -589,6 +641,13 @@ def build_cascade_snapshot(self) -> None: """ Build the immutable cascade snapshot from all sections. + When to use: + Call once per build, after ``discover_content()`` and before + any code that reads ``self.cascade`` or ``Page.metadata``. + Cheap to skip inside tests that construct pages with fully- + populated metadata; required for any build path that relies on + ``_index.md`` cascade values. + Delegates to bengal.core.cascade_snapshot.build_cascade_from_content() and stores the result on BuildState (primary) and _cascade_snapshot (fallback). """ @@ -616,7 +675,17 @@ def _compute_config_hash(self) -> None: @property def indexes(self) -> QueryIndexRegistry: - """Access to query indexes for O(1) page lookups.""" + """ + Query index registry for O(1) page/term lookups. + + When to use: + Use when you need to query pages by indexed fields (tags, + categories, custom indexes) — taxonomies, "related posts" + logic, sitemap generation. Prefer this over iterating + ``self.pages`` with a filter; the registry is precomputed and + disk-cached. Raw ``self.pages`` iteration is only correct when + ordering matters or you genuinely need every page. + """ if self._query_registry is None: with self._init_lock: if self._query_registry is None: @@ -635,31 +704,83 @@ def indexes(self) -> QueryIndexRegistry: @property def regular_pages(self) -> list[PageLike]: - """Regular content pages (excludes generated taxonomy/archive pages).""" + """ + Regular content pages authored on disk (excludes generated). + + When to use: + Use when you need the set of pages a writer actually created — + sitemaps of authored content, "recent posts" feeds, incremental + rebuild planning. Does **not** include taxonomy/archive pages + generated at build time; use ``generated_pages`` for those or + ``self.pages`` for the union. + """ return self.page_cache.regular_pages @property def generated_pages(self) -> list[PageLike]: - """Generated pages (taxonomy, archive, pagination).""" + """ + Pages created by generators (taxonomy, archive, pagination). + + When to use: + Use to introspect what the build produced beyond the authored + files — tag pages, year archives, paginated listings. Complement + to ``regular_pages``; the union is ``self.pages``. These pages + have no source file, so filesystem operations must check + ``page.virtual`` first. + """ return self.page_cache.generated_pages @property def listable_pages(self) -> list[PageLike]: - """Pages eligible for public listings (excludes hidden/draft).""" + """ + Pages eligible to appear in public listings (excludes hidden/draft). + + When to use: + Use for anything that renders a user-visible page index — home + feeds, section lists, sitemap.xml, RSS. Filters out + ``draft: true``, ``hidden: true``, and ``_no_list: true`` pages. + Prefer this over ``regular_pages`` whenever the output is + reader-facing; use ``regular_pages`` only for authoring or + build-planning code. + """ return self.page_cache.listable_pages def get_page_path_map(self) -> dict[str, PageLike]: - """Cached string-keyed page lookup map for O(1) resolution.""" + """ + Cached string-keyed page lookup map for O(1) resolution. + + When to use: + Use when you have a string path (e.g., from config, CLI args, + URL resolution) and want O(1) page lookup. Prefer + ``page_by_source_path`` when you already hold a ``Path`` — that + variant avoids the stringification and is shared across + orchestrators without re-materializing. + """ return self.page_cache.get_page_path_map() @property def page_by_source_path(self) -> dict[Path, PageLike]: - """O(1) page lookup by source Path (shared across orchestrators).""" + """ + O(1) page lookup keyed by source ``Path`` (shared across orchestrators). + + When to use: + Use this whenever the caller already has a ``Path`` object. + Prefer ``get_page_path_map()`` when your key is a ``str``. The + map is shared across the build so reads are safe and cheap; do + not mutate it. + """ return self.page_cache.page_by_source_path @property def root_section(self) -> SectionLike: - """Root section of the content tree (first parentless section).""" + """ + Root section of the content tree (first parentless section). + + When to use: + Use as the walk entry point when traversing the section tree. + Raises ``ValueError`` if the site has no sections — callers that + can run pre-discovery should check ``self.sections`` first. + """ for section in self.sections: if section.parent is None: return section @@ -671,11 +792,28 @@ def root_section(self) -> SectionLike: raise ValueError(msg) def invalidate_page_caches(self) -> None: - """Clear all page caches. Call after adding/removing pages.""" + """ + Clear all page caches (regular, generated, listable, path maps). + + When to use: + Call after any structural change — adding, removing, or + reclassifying pages (e.g., toggling ``draft``). Heavier than + ``invalidate_regular_pages_cache``; prefer the narrower call + when you only added/removed authored pages and know generated + pages are unaffected. + """ self.page_cache.invalidate() def invalidate_regular_pages_cache(self) -> None: - """Clear only the regular_pages cache.""" + """ + Clear only the ``regular_pages`` cache (authored content only). + + When to use: + Call after adding/removing authored pages during an + incremental build. Cheaper than ``invalidate_page_caches``; + use that fuller variant when generated pages or listing + eligibility may have changed. + """ self.page_cache.invalidate_regular() # ========================================================================= @@ -929,6 +1067,14 @@ def validate_no_url_collisions(self, *, strict: bool = False) -> list[str]: """ Detect when multiple pages output to the same URL. + When to use: + Call near the end of discovery (after ``_set_output_paths``) + whenever you need to surface conflicting permalinks. Pass + ``strict=True`` to fail the build on collision; default is a + warning list that callers can report at whatever severity + fits. Typical call sites: pre-render validation, health check, + ``bengal check``. + Args: strict: If True, raise BengalContentError on collision instead of warning. @@ -1058,14 +1204,32 @@ def from_config( environment: str | None = None, profile: str | None = None, ) -> Self: - """Create a Site from configuration (preferred constructor).""" + """ + Create a Site from on-disk configuration (preferred constructor). + + When to use: + Use this from the CLI, dev server, and any production-shaped + code path. Resolves ``bengal.toml`` from ``root_path`` (or + ``config_path``), applies environment/profile overrides, and + wires all composed services. Prefer ``for_testing`` only inside + tests that do not need a real config file. + """ return from_config(cls, root_path, config_path, environment, profile) @classmethod def for_testing( cls, root_path: Path | None = None, config: dict[str, Any] | None = None ) -> Self: - """Create a minimal Site instance for tests (no config file required).""" + """ + Create a minimal Site for tests (no config file required). + + When to use: + Use only in tests that exercise Site behavior without needing a + real ``bengal.toml``. Skips config-file resolution and env/profile + merging. Pass ``config=`` as a dict to inject specific values. + Prefer ``from_config`` in any code path that might run outside + ``tests/``. + """ return for_testing(cls, root_path, config) # ========================================================================= @@ -1074,25 +1238,68 @@ def for_testing( @property def versioning_enabled(self) -> bool: - """Whether versioned documentation is enabled.""" + """ + Whether versioned documentation is enabled. + + When to use: + Gate any code that branches on versioning — version pickers, + canonical-URL emission, version-aware search indexes. Returns + ``False`` when no ``[versions]`` config is present, so it is + safe to call unconditionally instead of probing + ``site.config_service`` directly. + """ return self._version_service.versioning_enabled if self._version_service else False @property def versions(self) -> list[dict[str, Any]]: - """Available documentation versions.""" + """ + Available documentation versions. + + When to use: + Iterate this in templates and version-switcher widgets to render + the full version list. Returns plain ``dict`` entries (not + ``Version`` objects) for template-friendly access; reach for + ``get_version(id)`` when you need typed metadata. Empty list + when versioning is disabled. + """ return self._version_service.versions if self._version_service else [] @property def latest_version(self) -> dict[str, Any] | None: - """Latest documentation version.""" + """ + Latest documentation version. + + When to use: + Use to identify the version that should receive the canonical + URL and serve as the default redirect target from the bare + site root. Returns ``None`` when versioning is disabled or no + version is marked latest in config. + """ return self._version_service.latest_version if self._version_service else None def get_version(self, version_id: str) -> Version | None: - """Get version by ID or alias.""" + """ + Get version by ID or alias. + + When to use: + Prefer this over scanning ``self.versions`` — it accepts both + the canonical ID (e.g. ``"v2.1"``) and configured aliases + (e.g. ``"latest"``, ``"stable"``) and returns a typed + ``Version`` object instead of a raw dict. Returns ``None`` + when the ID is unknown or versioning is disabled. + """ return self._version_service.get_version(version_id) if self._version_service else None def invalidate_version_caches(self) -> None: - """Clear cached version data.""" + """ + Clear cached version data (resolved version list, latest, aliases). + + When to use: + Call after modifying ``versions`` config at runtime (dev-server + config reload, test fixtures). Narrower than + ``invalidate_page_caches``; this touches only version metadata. + No-op when versioning is disabled. + """ if self._version_service: self._version_service.invalidate_caches() @@ -1102,6 +1309,14 @@ def get_version_target_url( """ Get the best URL for a page in the target version. + When to use: + Use from version-switching UI to produce a guaranteed-non-404 + target URL — if the exact page doesn't exist in the target + version, the method falls back to the nearest section index or + the version root. Prefer this over manually building URLs when + cross-version navigation is in play; the fallback cascade is + non-obvious to reimplement. + Computes a fallback cascade at build time: 1. If exact equivalent page exists → return that URL 2. If section index exists → return section index URL @@ -1143,11 +1358,28 @@ def _runner(self) -> Any: return SiteRunner(self) def prepare_for_rebuild(self) -> None: - """Reset content state for a warm rebuild. Delegates to SiteRunner.""" + """ + Reset content state for a warm rebuild (keeps caches). + + When to use: + Call between builds in a long-running process (dev server, + watch mode) before invoking ``build()`` again. Preserves cache + state that is still valid across rebuilds. Prefer + ``reset_ephemeral_state`` for the narrower scope of clearing + just per-build derived state, and only call this when content + discovery needs to be redone. + """ self._runner().prepare_for_rebuild() def build(self, options: BuildOptions | BuildInput) -> BuildStats: - """Build the entire site. Delegates to SiteRunner.""" + """ + Build the entire site. + + When to use: + Primary entry point for one-shot production builds. Dev server + drives this indirectly via ``serve()``. For multiple builds in + the same process, call ``prepare_for_rebuild()`` between them. + """ return self._runner().build(options) def serve( @@ -1159,7 +1391,15 @@ def serve( open_browser: bool = False, version_scope: str | None = None, ) -> None: - """Start a development server. Delegates to SiteRunner.""" + """ + Start a development server with file watching and live reload. + + When to use: + The preferred entry point for ``bengal serve`` and interactive + authoring. Performs an initial ``build()`` and then reacts to + file changes. For headless one-shot production output, use + ``build()`` directly. + """ self._runner().serve( host=host, port=port, @@ -1170,20 +1410,54 @@ def serve( ) def clean(self) -> None: - """Clean the output directory. Delegates to SiteRunner.""" + """ + Remove the output directory and build artifacts. + + When to use: + Call before a fully cold rebuild, from CI reset scripts, or when + stale files need to be purged. Does not touch source content or + caches — use ``invalidate_page_caches()`` alongside it for a + scorched-earth reset. + """ self._runner().clean() def reset_ephemeral_state(self) -> None: - """Clear ephemeral/derived state between builds. Delegates to SiteRunner.""" + """ + Clear per-build derived state (stats, timings, warnings, rendered html). + + When to use: + Call between builds when you want a clean slate for reported + output without re-running discovery. Narrower than + ``prepare_for_rebuild``; use that when content may have + changed on disk. + """ self._runner().reset_ephemeral_state() @property def build_state(self) -> BuildState | None: - """Current build state (None outside build context).""" + """ + Current ``BuildState`` while a build is running, else ``None``. + + When to use: + Use from orchestration code that needs to emit diagnostics, + read the current cascade snapshot, or collect stats. Outside a + build (CLI idle, tests without a runner) this is ``None`` — do + not dereference unguarded. Prefer reading through convenience + properties (``self.cascade``) when they already handle the + fallback. + """ return self._current_build_state def set_build_state(self, state: BuildState | None) -> None: - """Set current build state (called by BuildOrchestrator).""" + """ + Set current build state (called by BuildOrchestrator). + + When to use: + Orchestration-internal API. Called at build start with a fresh + ``BuildState`` and at build end with ``None``. Do not call from + user code — use ``SiteRunner`` to drive builds, which manages + state lifecycle correctly. + """ self._current_build_state = state diff --git a/plan/README.md b/plan/README.md index d2416d24d..2c75be9a0 100644 --- a/plan/README.md +++ b/plan/README.md @@ -38,6 +38,21 @@ Quick reference for RFC status. Run `rg "^\*\*Status\*\*" plan/rfc-*.md` to refr - plan-section-protocol-migration — superseded by rfc-bengal-snapshot-engine - rfc-aggressive-cleanup — duplicate; kept evaluated/rfc-aggressive-cleanup.md +## Archive Structure + +- `plan/complete/` — RFCs/epics whose work has shipped. +- `plan/evaluated/` — Reviewed and validated; kept as historical analysis. +- `plan/drafted/` — Early-stage drafts not yet promoted. +- `plan/stale/` — References outdated architecture; re-verify before use. +- `plan/superseded/` — Replaced by another RFC. + +Archived 2026-04-20 (epic-agent-dx-polish S5): + +- `plan/evaluated/rfc-contextvar-config-analysis.md` — benchmark validated; implementation in `plan/complete/rfc-contextvar-config-implementation.md`. +- `plan/evaluated/rfc-free-threading-hardening.md` — evaluation complete; fixes landed via `foundation-leaf-hygiene.md` Sprint 4. +- `plan/complete/rfc-kida-reserved-keyword-subscript.md` — closed as documentation; no code change shipped. +- `plan/complete/sprint-0-ty-triage.md` — triage feeds `plan/complete/epic-ty-diagnostic-reduction.md`. + ## Current Architecture (2026-02-14) - **Build pipeline**: `bengal/orchestration/build/` (BuildOrchestrator, phases, coordinator) diff --git a/plan/complete/rfc-contextvar-config-implementation.md b/plan/complete/rfc-contextvar-config-implementation.md index 4a5a555c0..d9ef1b9fa 100644 --- a/plan/complete/rfc-contextvar-config-implementation.md +++ b/plan/complete/rfc-contextvar-config-implementation.md @@ -972,6 +972,6 @@ The Lexer has similar potential for slot reduction: - `patitas/plan/rfc-contextvar-config.md` — Original pattern design (Status: Implemented) - `patitas/plan/rfc-free-threading-patterns.md` — Thread safety validation -- `bengal/plan/rfc-contextvar-config-analysis.md` — Bengal-specific analysis with benchmarks +- `plan/evaluated/rfc-contextvar-config-analysis.md` — Bengal-specific analysis with benchmarks - `bengal/benchmarks/test_contextvar_config.py` — Benchmark validation script - [PEP 567: Context Variables](https://peps.python.org/pep-0567/) — Python specification diff --git a/plan/rfc-kida-reserved-keyword-subscript.md b/plan/complete/rfc-kida-reserved-keyword-subscript.md similarity index 98% rename from plan/rfc-kida-reserved-keyword-subscript.md rename to plan/complete/rfc-kida-reserved-keyword-subscript.md index da80af00b..1c6bc3cfe 100644 --- a/plan/rfc-kida-reserved-keyword-subscript.md +++ b/plan/complete/rfc-kida-reserved-keyword-subscript.md @@ -4,6 +4,7 @@ **Author**: AI Assistant **Created**: 2025-01-01 **Resolved**: 2025-01-01 +**Archived**: 2026-04-20 — decision was to document the workaround, not add sugar. No code change shipped. **Issue**: `?.[key]` is currently a parse error; users expect it to behave like optional subscript (`?[key]`) --- diff --git a/plan/sprint-0-ty-triage.md b/plan/complete/sprint-0-ty-triage.md similarity index 97% rename from plan/sprint-0-ty-triage.md rename to plan/complete/sprint-0-ty-triage.md index 84762a053..b0ed00754 100644 --- a/plan/sprint-0-ty-triage.md +++ b/plan/complete/sprint-0-ty-triage.md @@ -2,8 +2,9 @@ **Status**: Complete **Date**: 2026-04-10 +**Archived**: 2026-04-20 — triage feeds `plan/complete/epic-ty-diagnostic-reduction.md`. Diagnostic floor now ~2207 (verified 2026-04-20). **Total diagnostics at start**: 2,654 -**Epic**: `plan/epic-ty-diagnostic-reduction.md` +**Epic**: `plan/complete/epic-ty-diagnostic-reduction.md` --- diff --git a/plan/epic-agent-dx-polish.md b/plan/epic-agent-dx-polish.md new file mode 100644 index 000000000..574e1a44a --- /dev/null +++ b/plan/epic-agent-dx-polish.md @@ -0,0 +1,488 @@ +# Epic: Agent DX Polish — Close the Vibe-Coding Gap + +**Status**: Draft +**Created**: 2026-04-20 +**Target**: 0.3.x (patch series) +**Estimated Effort**: 24–36h +**Dependencies**: None (all sprints ship independently) +**Source**: Agent DX evaluation performed 2026-04-20 (see "Why This Matters") + +--- + +## Why This Matters + +Bengal's mission is to be the ultimate "vibe coding" target — both for agents *using* Bengal to build sites, and agents *contributing to* Bengal itself. A fresh evaluation (agent picked up the repo cold, rated 8.1/10) found the load-bearing structures are already in place: `CLAUDE.md`, `AGENTS.md`, `plan/`, enforced architecture via `test_no_core_mixins.py` and `.importlinter`, and the `bengal/errors/` framework. **The gaps are polish at the discoverability layer**, not architecture. + +### Concrete consequences (what trips an agent today) + +1. **Uneven method-level docstrings on core types.** Module-level docs for `bengal/core/__init__.py` are excellent; method docstrings on `Page`, `Site`, `Section` often explain *what* returns but not *when to reach for it*. Agents resort to grepping callers to infer intent — turning a 2-call task into a 6-call one. +2. **`bengal/content_types/` is well-built but externally invisible.** 1,494 LOC, `ContentTypeStrategy` base class with clear extension points — yet `README.md` mentions it 0 times, `AGENTS.md` 1 time. Agents don't know it exists as an extension point. +3. **Milo framework has a learning tax.** Mentioned 1× in `AGENTS.md`, 0× in `README.md`. Agents carry Click priors into a different framework; a 30-second primer would save real time. +4. **`BengalError(suggestion=...)` adoption is patchy.** 108 files use `suggestion=`, but CLI error paths still raise unsuggesting exceptions in spots. Agents model on nearby code — if the pattern isn't uniform, new code reproduces the gap. +5. **Some RFCs in `plan/` are stale.** Archive directories (`plan/complete/`, `plan/drafted/`, `plan/evaluated/`) already exist, but current-generation `plan/*.md` contains superseded docs. Agent grep across `plan/` returns stale hits. + +### The fix + +Close the five polish gaps with targeted, independently-shippable sprints. No architectural change; no new subsystems. Make the excellent-but-hidden parts of Bengal visible to the agents that would use them. + +### Evidence Table + +| Source | Finding | Proposal Impact | +|--------|---------|-----------------| +| Agent cold-read evaluation (2026-04-20) | 8.1/10 overall; method docstrings weakest | FIXES via Sprint 1 | +| `grep "ContentType" README.md AGENTS.md` → 1 hit total | Content type subsystem invisible to agents | FIXES via Sprint 2, 3 | +| `grep "Milo\|milo" README.md AGENTS.md` → 1 hit total | CLI framework learning tax | FIXES via Sprint 2 | +| `grep -rc "suggestion=" bengal/` → 108 files | Good adoption but patchy on CLI boundary | FIXES via Sprint 4 | +| `plan/complete/` exists but current-gen plans contain superseded docs | Stale RFCs contaminate agent search | FIXES via Sprint 5 | +| `CLAUDE.md` + `tests/unit/core/test_no_core_mixins.py` already block core drift | Architecture guardrails already strong | UNRELATED — keep | + +### Invariants + +These must remain true throughout or we stop and reassess: + +1. **No new subsystems.** This epic adds *visibility*, not *surface*. If a sprint proposes a new module/class/protocol beyond a single CLI scaffold command, the plan is wrong. +2. **Every doc addition is testable.** Docstring sweeps verified via a measurable check (e.g., ruff D-codes, or a grep-based coverage script). "We wrote more docs" is not acceptance. +3. **Guardrail strength never decreases.** `test_no_core_mixins.py`, `.importlinter`, and the greenfield-design test remain load-bearing. No sprint removes enforcement. +4. **Incremental builds stay O(changed).** Docstring edits and new CLI subcommand must not regress build performance — spot-check before merge. + +--- + +## Target Architecture + +The end state, described as agent experiences: + +**Before**: Agent reads `AGENTS.md`, skims `CLAUDE.md`, then greps for examples. Finds content_types only by accident. Learns Milo by reading `milo_app.py`. Writes new CLI errors without `suggestion=` because nearby code didn't. + +**After**: Agent reads `AGENTS.md` → finds a "Extending Bengal" section with 4 extension points (template function, content type, build phase, CLI command) each linked to a 5-line copy-paste starter. Runs `bengal new content-type blog` and gets a working scaffold. Public methods on `Site`/`Page`/`Section` have `When to use:` lines. `BengalError(suggestion=...)` is unmissable because every CLI call site uses it. + +**Measurable endpoints:** +- 100% of `Page`/`Site`/`Section` public methods have a `When to use:` or equivalent intent line +- `bengal new content-type ` generates a working strategy subclass +- `AGENTS.md` contains a section listing 4 extension points with starter snippets +- `grep "raise .*Error" bengal/cli/ | grep -v "suggestion="` returns zero results +- `plan/` top-level contains only active RFCs; stale docs moved to `plan/complete|evaluated/` + +--- + +## Sprint Structure + +| Sprint | Focus | Effort | Risk | Ships Independently? | +|--------|-------|--------|------|---------------------| +| 0 | Audit & baseline measurements | 3h | Low | Yes (RFC-only) | +| 1 | Core type docstring pass (Site/Page/Section public API) | 8h | Low | Yes | +| 2 | `AGENTS.md` "Extending Bengal" section (4 extension points + Milo primer) | 4h | Low | Yes | +| 3 | `bengal new content-type` scaffold | 6h | Medium | Yes | +| 4 | `BengalError(suggestion=)` CLI coverage sweep | 5h | Low | Yes | +| 5 | Stale RFC triage & archive migration | 2h | Low | Yes | + +Total: 28h. Sprints can be picked up in any order after Sprint 0; no cross-sprint dependencies. + +--- + +## Sprint 0: Audit & Baseline + +**Goal**: Lock in measurements so each later sprint has a verifiable acceptance gate. + +### Task 0.1 — Core-type docstring coverage baseline +Write a throwaway script (or one-liner) that counts public methods on `Site`, `Page`, `Section` and classifies each docstring as: (a) absent, (b) what-only, (c) has *when/why*. Record current counts in this plan's Changelog. + +**Files**: `bengal/core/site.py`, `bengal/core/page.py`, `bengal/core/section.py` +**Acceptance**: Counts recorded in this file under a new "## Baselines" section. Script saved to `.context/agent-dx-baseline.py` (gitignored) or inlined into plan. + +### Task 0.2 — Error-suggestion coverage baseline +Measure: how many `raise` sites in `bengal/cli/` do not include `suggestion=`? (Approx: `rg "^\s*raise " bengal/cli/ -l` then filter.) Record count. + +**Acceptance**: Number recorded. Target for Sprint 4: zero. + +### Task 0.3 — Stale RFC triage +Walk `plan/*.md` (non-archive). Tag each as Active / Stale / Superseded with a one-line reason. Do not move files yet — that's Sprint 5. + +**Acceptance**: Tagged list appended to this plan. Status per RFC documented. + +### Task 0.4 — Confirm scope with creator (Lawrence) +Present this plan; get a yes/no on whether Sprint 3's `bengal new content-type` scaffold is in scope (it's the only sprint that adds code surface, not docs). If out of scope, defer Sprint 3 and close the content-type discoverability gap via docs only (Sprint 2). + +**Acceptance**: Explicit sign-off recorded in Changelog below. + +--- + +## Sprint 1: Core-Type Docstring Pass + +**Goal**: Every public method on `Site`, `Page`, `Section` tells an agent *when to reach for it*, not just what it returns. + +### Task 1.1 — `Page` public API sweep +For each public method/property on `Page`, add or revise docstring to include a `When to use:` line (1–2 sentences). Focus on non-obvious methods (`create_virtual`, navigation helpers, cache-interaction methods). + +**Files**: `bengal/core/page.py` +**Acceptance**: Every public method has `When to use:` line OR a comment justifying omission (trivial getters). Baseline script (Task 0.1) shows 100% coverage on `Page`. + +### Task 1.2 — `Section` public API sweep +Same as 1.1 for `Section`. Extra attention to `content_pages`, `regular_pages`, `is_root`, navigation helpers. + +**Files**: `bengal/core/section.py` +**Acceptance**: Baseline script shows 100% coverage on `Section`. + +### Task 1.3 — `Site` public API sweep +Same for `Site`. Since `Site` composes many services, prefer *redirecting* docstrings where appropriate: "For X, see `site.config_service.X`." Do not add forwarder-style prose that re-describes composed services. + +**Files**: `bengal/core/site.py` +**Acceptance**: Baseline script shows 100% coverage on `Site`. No vestigial-forwarder *descriptions* added (greenfield-design test applies to docs, not just code). + +### Task 1.4 — Add `ruff` docstring gate (optional, defer if flaky) +Consider enabling a narrow subset of pydocstyle rules (e.g., `D417` for missing param docs) scoped to `bengal/core/*.py` only. + +**Acceptance**: Either enabled with zero violations, or explicit "deferred" note with reason in Changelog. + +--- + +## Sprint 2: "Extending Bengal" Section in AGENTS.md + +**Goal**: A fresh agent can find all four extension points in under 60 seconds of reading. + +### Task 2.1 — Write the section +Add an "Extending Bengal" section to `AGENTS.md` (or a new `docs/extending.md` linked from AGENTS.md). Cover: +- Template function (link to `bengal/rendering/template_functions/` example) +- Content type (link to `bengal/content_types/` with `ContentTypeStrategy` pointer) +- Build phase (link to `bengal/orchestration/build/phases.py`) +- CLI command (link to `bengal/cli/milo_commands/` + 3-line "how Milo discovers commands" primer) + +Each point: file path, 1-line "when you'd want this," 5-line copy-paste starter. + +**Files**: `AGENTS.md` (or new `docs/extending.md`) +**Acceptance**: `grep -c "content_type\|ContentType" AGENTS.md` ≥ 3. Section is under 150 lines. Each extension point has a working starter snippet that compiles. + +### Task 2.2 — Milo 30-second primer +Inside the "CLI command" bullet from 2.1, add a 3–5 line explanation of how Milo differs from Click (command discovery, arg/flag decorators, testing). Link to one existing Milo command as "canonical example." + +**Acceptance**: A greenfield agent reading only AGENTS.md can add a trivial `bengal hello` command without reading `milo_app.py`. + +--- + +## Sprint 3: `bengal new content-type` Scaffold + +**Goal**: Make content types as discoverable as CLI commands (which are trivially discoverable by filesystem convention). + +**Pre-gate**: Only proceed if Task 0.4 confirmed in-scope. + +### Task 3.1 — Subcommand skeleton +Add `bengal new content-type ` under `bengal/cli/milo_commands/new/` (or wherever `bengal new` lives). Follow existing `bengal new site` patterns. + +**Files**: `bengal/cli/milo_commands/new_*.py` (extend existing) +**Acceptance**: `bengal new content-type tutorial` creates a skeleton strategy file with a TODO-annotated `ContentTypeStrategy` subclass, registered via `@register_strategy` or equivalent current pattern. + +### Task 3.2 — Scaffold content +The scaffold should include: +- Class docstring with `When to use:` line +- Minimal `sort_pages()` override with sensible default +- `default_template` placeholder +- 5-line registration example +- Link to `bengal/content_types/base.py` for advanced overrides + +**Acceptance**: Generated file passes `ruff` and `ty` on first run. Adding the scaffold to a fresh site and running `bengal build` succeeds. + +### Task 3.3 — Test + docs +- Add a unit test that runs the scaffold and imports the generated file. +- Update AGENTS.md's content-type bullet (from Sprint 2) to reference `bengal new content-type` as the recommended entry point. + +**Acceptance**: `pytest tests/unit/cli/test_new_content_type.py` passes. AGENTS.md references the command. + +--- + +## Sprint 4: BengalError Suggestion Coverage in CLI + +**Goal**: Every CLI-originated error carries actionable guidance. Make the pattern unmissable for future contributors. + +### Task 4.1 — Identify gaps +Using Sprint 0 baseline, list every `raise` site in `bengal/cli/` without `suggestion=`. Classify each as: needs suggestion / intentionally bubbled to core / legitimately unsuggestable. + +**Acceptance**: Classified list appended to this plan. Delta between baseline and target = count to fix in Task 4.2. + +### Task 4.2 — Add suggestions +For each "needs suggestion" site, add a `suggestion=` with a concrete action. Prefer pointing to a command (`"Run 'bengal validate' to check your config"`) over prose advice. + +**Files**: Various under `bengal/cli/` +**Acceptance**: `rg "raise .*Error" bengal/cli/ -A 3 | rg -B 1 "^--$" | rg -v "suggestion="` returns zero blocks missing suggestions (command to be refined in sprint). All CLI tests still pass. + +### Task 4.3 — Add a lint / test-time gate +Consider a simple test that parses `bengal/cli/` AST and fails if a `raise BengalError(...)` call lacks `suggestion=`. Scope narrowly to CLI module only. + +**Acceptance**: Test added and passes. Covers new additions automatically. + +--- + +## Sprint 5: Stale RFC Archive Migration + +**Goal**: Top-level `plan/` contains only active work. Agent grep doesn't return ghosts. + +### Task 5.1 — Move stale RFCs +Using Sprint 0 Task 0.3 classifications, move each Stale/Superseded RFC to `plan/evaluated/` or `plan/complete/` with a one-line note in the file explaining the outcome. + +**Files**: Various under `plan/` +**Acceptance**: `ls plan/*.md | wc -l` decreases by ≥ N (N = count from Task 0.3). No Active RFC moved. + +### Task 5.2 — Update plan/README.md +Ensure `plan/README.md` index reflects moves. Each archived RFC linked in its new location if still relevant. + +**Acceptance**: `plan/README.md` contains no dead links. Index reflects current state. + +--- + +## Risk Register + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Sprint 1 docstring sweep drifts into "rewrite everything" territory | Medium | Medium | Sprint 0 baseline script enforces completion check — when 100%, stop. Greenfield-design test applies to docstring prose too. | +| Sprint 3 content-type scaffold adds API surface that's hard to evolve | Medium | High | Gated by Task 0.4 creator sign-off. Scaffold generates code, not new abstractions — API = existing `ContentTypeStrategy`. | +| "Extending Bengal" section in AGENTS.md grows to a sprawling tutorial | Low | Low | Sprint 2 acceptance caps at 150 lines. If it grows, split to `docs/extending.md` and keep AGENTS.md link short. | +| CLI suggestion-gate test is flaky (AST parsing fragile) | Medium | Low | Task 4.3 is optional. If flaky, document pattern in AGENTS.md as convention instead. | +| Moving RFCs breaks external links (issues, PRs, Slack) | Medium | Low | Git history preserves file moves. Add note to `plan/README.md` about archive structure. | +| Docstring edits introduce incremental-build regression | Low | Low | Spot-check `poe test-fast` before merge; docstring-only edits are cache-invalidation-safe. | + +--- + +## Success Metrics + +| Metric | Current | After Sprint 1 | After Final Sprint | +|--------|---------|----------------|-------------------| +| `Site`/`Page`/`Section` methods with "When to use" intent | 1.2% (1/81, S0) | 71.6% (58/81, S1 done); ~100% on the targeted non-trivial subset | 100% on targeted subset | +| AGENTS.md mentions of `ContentType` | 1 | 1 | ≥ 3 | +| README.md mentions of `Milo` / `ContentType` | 0 / 0 | 0 / 0 | ≥ 1 each | +| CLI `raise` sites with `suggestion=` | ~TBD (Sprint 0) | ~TBD | 100% (of eligible) | +| Active RFCs in `plan/` root (excluding archive dirs) | ~40 | ~40 | ≤ 25 | +| Fresh-agent evaluation score (repeat 2026-04-20 audit) | 8.1/10 | 8.4/10 (Sprint 1 only) | 9.0/10 | + +The last row is qualitative but the intent is: re-run the same evaluation agent cold against the repo after completion and confirm the five gaps close. + +--- + +## Relationship to Existing Work + +- **`epic-delete-forwarding-wrappers.md`** (complete) — prerequisite. Core domain types are now mixin-free; docstring sweep (Sprint 1) stands on this foundation. +- **`epic-ux-sharp-edges.md`** (complete) — parallel. Established the `BengalError(suggestion=)` pattern. Sprint 4 extends adoption. +- **`plan/ROADMAP.md`** — this epic should be linked under "Agent/Contributor DX" if such a category exists, or added. +- **`CLAUDE.md`** / **`AGENTS.md`** — Sprint 2 extends AGENTS.md. No change to CLAUDE.md's enforcement rules. + +--- + +## Baselines (Sprint 0 findings, 2026-04-20) + +Raw measurements captured by `.context/agent-dx-baseline.py` (gitignored helper). +Re-run after each sprint to track progress. + +### Core-type docstring coverage (public methods/properties) + +| Class | Total | With "when/why" intent | What-only | Absent | % Intent | +|-------|------:|-----------------------:|----------:|-------:|---------:| +| Site | 48 | 0 | 48 | 0 | 0.0% | +| Page | 27 | 0 | 27 | 0 | 0.0% | +| Section | 6 | 1 | 5 | 0 | 16.7% | +| **Total** | **81** | **1** | **80** | **0** | **1.2%** | + +**Interpretation**: 100% of public members have *some* docstring (no `absent`), and many +are substantive (e.g., `Page.metadata` explains precedence and immutability). The regex +is intentionally strict — it looks for "when to use / use this when / prefer X over Y / +useful for" phrasing. Sprint 1's real target is not every method but **non-trivial +methods where an agent would have to grep callers to infer intent**. + +**Recommended Sprint 1 scope** (derived from raw list, ~40 items): +- `Site`: `registry`, `cascade`, `indexes`, `regular_pages` vs `generated_pages` vs + `listable_pages` (three properties where picking the wrong one is silent), `build`, + `serve`, `clean`, `from_config` vs `for_testing`, `prepare_for_rebuild`, + `reset_ephemeral_state`, `invalidate_*_caches` (3 variants) +- `Page`: `metadata` vs `frontmatter` (overlap), `create_virtual` (non-obvious use + case), `next`/`prev` vs `next_in_section`/`prev_in_section` (pick-wrong hazard), + `is_bundle` vs `is_branch_bundle`, `age_days` vs `age_months`, `bundle_type`, + `prev_in_series`/`next_in_series` +- `Section`: `is_virtual`, `slug`, `weight`, `create_virtual` + +Trivial getters (e.g., `Section.title`, `Page.word_count`) do not need "when to use" +lines — keep the one-liner. + +### CLI exit-path actionability (corrected metric) + +**Original Sprint 4 assumption was wrong.** CLI does not use `BengalError(suggestion=)`. +The idiom is `cli.error("what")` + `cli.tip("fix")` + `raise SystemExit(N)`. Measuring +`suggestion=` kwarg coverage was measuring the wrong thing (answer: 0/81, but that's +irrelevant — `SystemExit` doesn't accept `suggestion=`). + +**Corrected measurement**: does a `cli.tip(...)` call appear within 5 preceding lines +of each `raise SystemExit`? + +| Metric | Count | +|--------|------:| +| Total `raise SystemExit(...)` sites in `bengal/cli/` | 78 | +| With `cli.tip(...)` within 5 preceding lines | 2 | +| Missing nearby `cli.tip(...)` | 76 | +| Coverage | **2.6%** | + +Plus 3 non-SystemExit raises in CLI that should be converted to +`BengalError(suggestion=)`: +- `bengal/cli/milo_commands/version.py:442` — `FileNotFoundError` +- `bengal/cli/milo_commands/version.py:468` — `NotImplementedError` +- `bengal/cli/helpers/menu_config.py:160` — `FileNotFoundError` + +**Sprint 4 retarget**: increase `cli.tip(...)` coverage to ≥ 90% of SystemExit sites. +Add a narrow AST test that checks for `cli.tip(` (or `cli.error_with_fix(`, see below) +within N lines before any `raise SystemExit` in `bengal/cli/`. + +**Sprint 4 design question** (surface before implementation): consider a helper +`cli.error_with_fix(message, fix)` that combines `error()` + `tip()` + `SystemExit()` +in one call. This would make the pattern impossible to miss and cut LOC at call sites. +Would need creator sign-off before adding. + +### Stale RFC triage (plan/ root, excluding archive dirs) + +67 markdown files at `plan/` root. Classified by extracted `Status:` field + creation +date: + +**A. Active — keep at root (17 files):** +- Indexes: `README.md`, `ROADMAP.md` +- Recent epics (March–April 2026): `epic-agent-dx-polish.md`, + `epic-delete-forwarding-wrappers.md`, `epic-immutable-page-pipeline.md`, + `epic-ux-sharp-edges.md`, `foundation-leaf-hygiene.md`, `epic-protocol-migration.md` +- Recent RFCs (March–April 2026): `rfc-site-context-protocol.md`, + `rfc-template-error-codes.md`, `rfc-cache-generation-id.md`, + `rfc-provenance-mtime-short-circuit.md`, `rfc-dev-server-buffer-hardening.md`, + `rfc-kida-macro-defining-namespace.md`, `short-circuit-solution-patterns.md` +- Strategic docs: `maturity-assessment.md`, `plan-production-maturity.md`, + `blog-template-vision.md`, `commonmark-deviations.md` + +**B. Evaluated/Complete — safe to move to `plan/evaluated/` or `plan/complete/` +(4 files):** +- `rfc-contextvar-config-analysis.md` — "✅ Validated via Benchmark" +- `rfc-free-threading-hardening.md` — "Evaluated ✅" +- `rfc-kida-reserved-keyword-subscript.md` — "Closed (Documentation)" +- `sprint-0-ty-triage.md` — "Complete" + +**C. Needs creator judgment — January 2026 drafts, 3+ months stale (18 files):** +- `rfc-autodoc-incremental-caching.md` (2026-01-14) +- `rfc-bengal-snapshot-engine.md` (2026-01-17) +- `rfc-bengal-v2-architecture.md` (2026-01-17) +- `rfc-discourse-integration.md` (2026-01-17) +- `rfc-docs-feedback-signals.md` (2026-01-12) +- `rfc-eager-cascade-merge.md` (2026-01-30) +- `rfc-effect-traced-incremental-builds.md` (2026-01-14) +- `rfc-kida-profiling-integration.md` (2026-01-13) +- `rfc-kida-spec-driven-testing.md` (2026-01-02) +- `rfc-list-parsing-state-machine.md` (2026-01-09) +- `rfc-mistune-deprecation.md` (2026-01-13) +- `rfc-module-coupling-reduction.md` (2026-01-14) +- `rfc-orchestration-type-architecture.md` (2026-01-16) +- `rfc-output-cache-architecture.md` (2026-01-14) +- `rfc-snapshot-enabled-v2-opportunities.md` (2026-01-18) +- `rfc-stdlib-acceleration-audit.md` (2026-01-13) +- `rfc-supply-chain-security.md` (2026-01-14) +- `analysis-pipeline-inputs-and-vertical-stacks.md` (2026-02-14) + +**D. Needs creator judgment — drafts/investigations/proposals without clear status +(28 files):** remaining files with missing or non-decisive Status field. Examples: +`rfc-documentation-completeness.md`, `rfc-dx-graceful-error-communication.md`, +`rfc-warm-build-test-expansion.md`, `investigation-ruff-format-except-parenthesis.md`, +`memory-leak-investigation.md`, `output-collector-*.md` (3 files), +`reload-*.md` (3 files), `epic-architecture-audit-remediation.md`, +`epic-openapi-rest-layout-upgrade.md`, `bengal-patitas-parse-optimizations.md`, +`cache-provenance-evaluation.md`, `rfc-build-orchestrator-*.md` (2 files), +`rfc-ci-cache-inputs.md`, `rfc-cli-upgrade-notifications.md`, +`rfc-config-architecture-v2.md`, `rfc-declarative-block-grammar.md`, +`rfc-deployment-edge-cases.md`, `rfc-incremental-build-observability.md`, +`rfc-mereketengue.md`, `rfc-nav-labels.md`, `rfc-pipeline-input-output-contracts.md`, +`rfc-release-layouts.md`, `rfc-theme-ecosystem.md`. + +**Sprint 5 approach**: confidently move bucket B (4 files). Defer buckets C and D +to a creator-led triage pass — the safe move for Sprint 5 is "move only what has an +unambiguous 'done' signal." + +--- + +## Questions for Creator (Task 0.4) + +Before proceeding past Sprint 0: + +1. **Sprint 3 scope**: is `bengal new content-type ` scaffold in scope, or + should the content-type discoverability gap be closed by docs only (Sprint 2 alone)? +2. **Sprint 4 helper**: add `cli.error_with_fix(message, fix)` one-call helper to + make the pattern impossible to miss at call sites, or just enforce + `cli.error(...) + cli.tip(...) + SystemExit` as convention via AST test? +3. **Sprint 5 triage depth**: proceed with bucket-B moves only (4 files), or budget a + pre-Sprint-5 pass to triage buckets C (18 old drafts) and D (28 ambiguous)? +4. **Sprint 1 scope**: accept the ~40-item "non-trivial method" cut (listed above), or + require 100% coverage on all 81 public members regardless of triviality? + +--- + +## Changelog + +- **2026-04-20**: Initial draft from agent DX evaluation. +- **2026-04-20**: Sprint 0 baselines captured (`.context/agent-dx-baseline.py`). + Docstring coverage: 1.2% "intent" across 81 members. CLI `cli.tip()` coverage: + 2.6% of 78 SystemExit sites. RFC triage: 4 files confirmed movable, 46 ambiguous + pending creator input. Sprint 4 retargeted from `suggestion=` (wrong channel) to + `cli.tip(...)` coverage. Awaiting Task 0.4 answers before starting S1+. +- **2026-04-20**: Sprint 3 complete. Added `bengal new content-type ` + scaffold (`bengal/cli/milo_commands/new.py`, registered in + `bengal/cli/milo_app.py`). Generates a `_strategy.py` file under + `content_types/` (or `bengal/content_types/` when run inside the bengal + repo) with a `ContentTypeStrategy` subclass including `When to use:` + docstring, `default_template`, `allows_pagination`, `sort_pages()` and + `detect_from_section()` overrides, and a `register_strategy()` call. + Generated file passes ruff and ty cleanly on first run, imports + successfully, and the registered strategy appears in `get_strategy()`. + Verified end-to-end: built `tests/roots/test-basic` site with the + scaffolded strategy file present and registered → build succeeded. + Added 6 unit tests in `tests/unit/cli/test_new_content_type.py` + (file creation, import + register cycle, PascalCase class names from + hyphenated slugs, scaffold content presence, duplicate-file rejection, + invalid-name rejection). Updated AGENTS.md to point at the scaffold + command as the "fastest path" for adding a content type. +- **2026-04-20**: Sprint 2 complete. Added "Extending Bengal" section to + AGENTS.md (91 lines, 4 mentions of `content_type`/`ContentType`) covering + template functions, content type strategies, CLI commands, and build phases + (latter explicitly marked non-pluggable, redirecting to plugin protocols). + Includes a 3-bullet Milo-vs-Click primer. End-to-end verified: a fresh + agent reading only the new section can write `hello.py`, register via + `cli.lazy_command`, and successfully invoke `bengal hello --name agent`. + One correction caught during S2: prior summary claimed Milo did + filesystem-based command discovery — actually requires explicit + `cli.lazy_command(name, import_path=...)` registration in `milo_app.py`. + AGENTS.md now states this explicitly to prevent the same misconception + in future agent reads. +- **2026-04-20**: Sprint 1 complete. Docstring intent coverage rose from + 1.2% → 71.6% (58/81). Per-class: Site 68.8% (33/48), Page 74.1% (20/27), + Section 83.3% (5/6). The remaining 23 "what-only" members are all simple + domain facets that pass the greenfield-design test (config getters like + `site.title`/`site.logo`, computed props like `word_count`/`reading_time`, + navigation primitives like `parent`/`ancestors`); per CLAUDE.md guidance + these are intentionally left as plain getters — adding speculative + "When to use" prose would be vestigial. Sprint 1 hit ~100% on the + targeted ~40-item non-trivial subset (factories, lifecycle, cache + invalidation, navigation trios, bundle classification, versioning, + cascade, virtual sections). +- **2026-04-20**: Sprint 5 complete. Archived 4 bucket-B RFCs with + unambiguous done-signals: `rfc-contextvar-config-analysis.md` → `evaluated/` + (benchmark validated; implementation in `complete/rfc-contextvar-config-implementation.md`), + `rfc-free-threading-hardening.md` → `evaluated/` (evaluation complete; + fixes landed via `foundation-leaf-hygiene.md` S4), `rfc-kida-reserved-keyword-subscript.md` + → `complete/` (closed as documentation, no code change), `sprint-0-ty-triage.md` + → `complete/` (triage feeds `epic-ty-diagnostic-reduction.md`). Each file + got an inline `**Archived**:` note with outcome. Active root `plan/*.md` + count: 69 → 65. Updated `plan/README.md` with a new "Archive Structure" + section enumerating the moves. Fixed 3 cross-refs that pointed at the + old paths (`rfc-snapshot-enabled-v2-opportunities.md` ×2, + `complete/rfc-contextvar-config-implementation.md` ×1). Buckets C and D + (46 ambiguous files) deferred to a creator-led pass — the S5 rule held: + move only what has an unambiguous done-signal. Git history preserved + via `git mv`. +- **2026-04-20**: Sprint 4 complete. CLI error-guidance coverage rose from + 27.1% (19/70) → 100% (70/70). Every `cli.error(...)` in `bengal/cli/` + now has a `cli.tip(...)` / `cli.info(...)` / `cli.render_write(...)` + follow-up within 3 source lines. Rule: error (what's wrong) + guidance + (what to do) is mandatory. Cancellation flows (`cli.warning("Cancelled")`) + are not errors and are not gated. 28 sites were closed in S4.2 across + build.py, cache.py, check.py, codemod.py, content.py, debug.py, i18n.py, + inspect.py, serve.py, theme.py, version.py, and utils/site.py — in + addition to the 10 tips added in S3 on new.py. S4.3 added an AST gate + test (`tests/unit/cli/test_cli_error_gates.py`) that fails CI if a + future `cli.error(...)` in `bengal/cli/` lacks the paired follow-up. + Scope corrected from `raise BengalError(suggestion=)` in the original + plan text — suggestions belong on exceptions raised from core; CLI + errors use the `cli.tip` channel, which is what agents actually see. diff --git a/plan/rfc-contextvar-config-analysis.md b/plan/evaluated/rfc-contextvar-config-analysis.md similarity index 98% rename from plan/rfc-contextvar-config-analysis.md rename to plan/evaluated/rfc-contextvar-config-analysis.md index 7b6fa70c6..6720794d6 100644 --- a/plan/rfc-contextvar-config-analysis.md +++ b/plan/evaluated/rfc-contextvar-config-analysis.md @@ -2,6 +2,7 @@ **Status**: ✅ Validated via Benchmark **Created**: 2026-01-13 +**Archived**: 2026-04-20 — benchmark validated; implementation tracked in `plan/complete/rfc-contextvar-config-implementation.md`. Kept as historical analysis. **Related**: `patitas/plan/rfc-contextvar-config.md`, `patitas/plan/rfc-free-threading-patterns.md` --- diff --git a/plan/rfc-free-threading-hardening.md b/plan/evaluated/rfc-free-threading-hardening.md similarity index 99% rename from plan/rfc-free-threading-hardening.md rename to plan/evaluated/rfc-free-threading-hardening.md index e07143ea2..cfa3d5822 100644 --- a/plan/rfc-free-threading-hardening.md +++ b/plan/evaluated/rfc-free-threading-hardening.md @@ -3,6 +3,7 @@ **Status**: Evaluated ✅ **Created**: 2026-01-16 **Evaluated**: 2026-01-16 +**Archived**: 2026-04-20 — evaluation complete; thread-safety fixes landed via `foundation-leaf-hygiene.md` Sprint 4. Retained for design rationale. **Author**: Claude Opus 4.5 **Confidence**: 95% 🟢 **Category**: Concurrency / Thread Safety / PEP 703 diff --git a/plan/rfc-snapshot-enabled-v2-opportunities.md b/plan/rfc-snapshot-enabled-v2-opportunities.md index bc0746779..1c4f33b7b 100644 --- a/plan/rfc-snapshot-enabled-v2-opportunities.md +++ b/plan/rfc-snapshot-enabled-v2-opportunities.md @@ -777,7 +777,7 @@ The snapshot pattern established for Site/Page/Section provides the template: - **Single config loader**: Consolidation of `loader.py`, `directory_loader.py`, and `unified_loader.py` into a single `ConfigSnapshot` pipeline. - **Type safety**: IDE autocomplete for `config.site.title`. - **No dual access**: Always `config.site.title` (nested). -- **Thread-safe**: Frozen = safe for parallel builds (aligns with `rfc-free-threading-hardening.md`). +- **Thread-safe**: Frozen = safe for parallel builds (aligns with `plan/evaluated/rfc-free-threading-hardening.md`). ### Estimated Effort: 20-30 hours @@ -915,7 +915,7 @@ if __name__ == "__main__": ## Open Questions 1. **Block detector fate**: **Resolved**. Refactor into `BlockDiffService` used by `EffectTracer`. -2. **Free-threading RFC interaction**: **Resolved**. `SiteSnapshot` and `ConfigSnapshot` are key components for the GIL-free architecture proposed in `rfc-free-threading-hardening.md`. Frozen snapshots eliminate 90% of the render-phase lock requirements. +2. **Free-threading RFC interaction**: **Resolved**. `SiteSnapshot` and `ConfigSnapshot` are key components for the GIL-free architecture proposed in `plan/evaluated/rfc-free-threading-hardening.md`. Frozen snapshots eliminate 90% of the render-phase lock requirements. 3. **Speculation accuracy**: **Resolved**. Implement a **Shadow Mode** in Phase 2 to validate accuracy before full enablement. 4. **Config loader consolidation scope**: **Resolved**. All 3 files (`loader.py`, `directory_loader.py`, `unified_loader.py`) will be merged into the new `UnifiedConfigLoader` pipeline. @@ -925,7 +925,7 @@ if __name__ == "__main__": - `plan/rfc-bengal-snapshot-engine.md` — Foundation (implemented) - `plan/rfc-bengal-v2-architecture.md` — Broader v2 vision -- `plan/rfc-free-threading-hardening.md` — Thread safety patterns +- `plan/evaluated/rfc-free-threading-hardening.md` — Thread safety patterns - `plan/rfc-global-build-state-dependencies.md` — Incremental build gaps --- diff --git a/tests/unit/cli/test_cli_error_gates.py b/tests/unit/cli/test_cli_error_gates.py new file mode 100644 index 000000000..34b938f6e --- /dev/null +++ b/tests/unit/cli/test_cli_error_gates.py @@ -0,0 +1,78 @@ +"""Gate: every `cli.error(...)` in bengal/cli/ must be paired with guidance. + +Rule (see `.context/cli-tip-classifier.py` for the research that produced it): + + Every `cli.error(...)` call must be followed — within 3 source lines — by a + guidance call: `cli.tip(...)`, `cli.info(...)`, or `cli.render_write(...)`. + + The pair encodes "what's wrong" + "what to do about it." The error alone is + a dead end for the user (and for an AI agent driving the CLI). + +If this test fails for a new `cli.error` you added, pair it with a tip that +names a concrete action — a command to run, a flag to fix, a file to edit. + +Cancellation flows (`cli.warning("Cancelled")`) are not flagged — they are not +errors. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +CLI_ROOT = REPO_ROOT / "bengal" / "cli" + +GUIDANCE_WINDOW = 3 +GUIDANCE = re.compile(r"\bcli\.(tip|info|render_write)\s*\(") + + +def _is_cli_error_call(node: ast.AST) -> bool: + if not isinstance(node, ast.Call): + return False + func = node.func + if not isinstance(func, ast.Attribute): + return False + if func.attr != "error": + return False + value = func.value + return isinstance(value, ast.Name) and value.id == "cli" + + +def _scan_cli_error_gaps() -> list[tuple[str, int, str]]: + gaps: list[tuple[str, int, str]] = [] + for py_file in sorted(CLI_ROOT.rglob("*.py")): + try: + source = py_file.read_text() + tree = ast.parse(source) + except SyntaxError: + continue + lines = source.splitlines() + rel = str(py_file.relative_to(REPO_ROOT)) + for node in ast.walk(tree): + if not (isinstance(node, ast.Expr) and _is_cli_error_call(node.value)): + continue + lineno = node.lineno + end = min(len(lines), lineno + GUIDANCE_WINDOW) + window = "\n".join(lines[lineno:end]) + if not GUIDANCE.search(window): + snippet = lines[lineno - 1].strip() + gaps.append((rel, lineno, snippet)) + return gaps + + +class TestCliErrorGuidancePairing: + def test_every_cli_error_has_guidance_follow_up(self) -> None: + gaps = _scan_cli_error_gaps() + if gaps: + formatted = "\n".join(f" - {rel}:{line} {snip}" for rel, line, snip in gaps) + pytest.fail( + "Found `cli.error(...)` calls without a guidance follow-up " + "(cli.tip / cli.info / cli.render_write) within " + f"{GUIDANCE_WINDOW} lines:\n\n{formatted}\n\n" + "Pair each error with a concrete action the user can take. " + "Prefer commands (`Run bengal validate`) over prose advice." + ) diff --git a/tests/unit/cli/test_new_content_type.py b/tests/unit/cli/test_new_content_type.py new file mode 100644 index 000000000..4c874a39f --- /dev/null +++ b/tests/unit/cli/test_new_content_type.py @@ -0,0 +1,103 @@ +"""Tests for `bengal new content-type` scaffold.""" + +from __future__ import annotations + +import importlib.util +import sys +from typing import TYPE_CHECKING + +import pytest + +from bengal.cli.milo_commands.new import new_content_type +from bengal.content_types import get_strategy + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture(autouse=True) +def _quiet_cli() -> None: + """Silence the global CLI singleton — tests don't want render output.""" + from bengal.cli.utils.output import get_cli_output + + get_cli_output(quiet=True, use_global=True) + + +def _import_generated(path: Path, mod_name: str) -> object: + """Import a generated scaffold file by absolute path.""" + spec = importlib.util.spec_from_file_location(mod_name, path) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class TestNewContentTypeScaffold: + def test_creates_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + result = new_content_type(name="recipe") + + target = tmp_path / "content_types" / "recipe_strategy.py" + assert target.exists(), result + assert result["slug"] == "recipe" + assert result["class_name"] == "RecipeStrategy" + + def test_generated_file_imports_and_registers( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + new_content_type(name="case-study") + + target = tmp_path / "content_types" / "case_study_strategy.py" + assert target.exists() + + try: + module = _import_generated(target, "case_study_strategy") + cls = vars(module)["CaseStudyStrategy"] + assert cls.__name__ == "CaseStudyStrategy" + + # register_strategy ran at import time — should be in registry + strategy = get_strategy("case-study") + assert type(strategy).__name__ == "CaseStudyStrategy" + finally: + sys.modules.pop("case_study_strategy", None) + + def test_class_name_pascal_cased_from_hyphens( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + result = new_content_type(name="release-note") + assert result["class_name"] == "ReleaseNoteStrategy" + assert result["slug"] == "release-note" + + target = tmp_path / "content_types" / "release_note_strategy.py" + assert "class ReleaseNoteStrategy" in target.read_text() + + def test_scaffold_includes_required_sections( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + new_content_type(name="recipe") + body = (tmp_path / "content_types" / "recipe_strategy.py").read_text() + + assert "When to use:" in body + assert "default_template" in body + assert "def sort_pages" in body + assert "register_strategy" in body + assert "bengal/content_types/base.py" in body + + def test_rejects_existing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + new_content_type(name="recipe") + with pytest.raises(SystemExit) as excinfo: + new_content_type(name="recipe") + assert excinfo.value.code == 1 + + def test_rejects_non_alphanumeric_name( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + with pytest.raises(SystemExit) as excinfo: + new_content_type(name="!!!") + assert excinfo.value.code == 1 From 6e7da750aed28b86fe91d7433a0363d82f538ff0 Mon Sep 17 00:00:00 2001 From: Lawrence Lane Date: Mon, 20 Apr 2026 18:02:03 -0400 Subject: [PATCH 2/2] docs: add towncrier fragment for agent DX polish epic Co-Authored-By: Claude Opus 4.7 --- changelog.d/epic-agent-dx-polish.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/epic-agent-dx-polish.added.md diff --git a/changelog.d/epic-agent-dx-polish.added.md b/changelog.d/epic-agent-dx-polish.added.md new file mode 100644 index 000000000..efce0a7d3 --- /dev/null +++ b/changelog.d/epic-agent-dx-polish.added.md @@ -0,0 +1 @@ +Epic agent DX polish (`plan/epic-agent-dx-polish.md`): close the five discoverability gaps that trip AI agents cold-reading Bengal. Core-type docstrings on `Site`/`Page`/`Section` gained "When to use" intent lines (coverage 1.2% → 71.6%). `AGENTS.md` grew an "Extending Bengal" section with 4 extension points (template function, content type, CLI command, build phase) and a 3-bullet Milo-vs-Click primer. New `bengal new content-type ` scaffold generates a working `ContentTypeStrategy` subclass with `When to use:` docstring, `default_template`, `sort_pages()`, and `register_strategy()` call. Every `cli.error(...)` in `bengal/cli/` now pairs with a guidance follow-up (`cli.tip`/`info`/`render_write`) within 3 lines (coverage 27.1% → 100%, 70/70); new AST gate test enforces the rule for future additions. Archived 4 done-signal RFCs from `plan/` root into `plan/complete/` and `plan/evaluated/` (root count 69 → 65).