diff --git a/docs/docs/wiki/Glossary.md b/docs/docs/wiki/Glossary.md new file mode 100644 index 0000000..ed7aeab --- /dev/null +++ b/docs/docs/wiki/Glossary.md @@ -0,0 +1,153 @@ +# Glossary + +One paragraph per term. Read this first if any CodingScaffold output uses a word you +have not seen before. Each entry links to the wiki page that goes deeper. + +## adapter + +A small, native-format integration that teaches a specific coding tool how to behave +inside this project. `tools adapt --tool opencode` writes OpenCode-shaped files; +`--tool claude-code` writes the Claude-Code shape; and so on. Adapters are generated, +reviewable, and have no runtime. See [Tool-Adapters](./Tool-Adapters.md). + +## agentic change + +A code change planned or written with help from an AI coding agent (OpenCode, Claude +Code, Codex, …). CodingScaffold's `session` command + `agentic-change.md` PR template +exist so these changes ship with the same shape as any other PR. See +[Getting-Started](./Getting-Started.md). + +## artifact + +A file or directory CodingScaffold knows about: `AGENTS.md`, `.coding-scaffold/policy/`, +`PR template`, eval config, sessions directory, knowledge base. `doctor` surveys +artifacts; the canonical list with rationale lives in +[`src/coding_scaffold/artifacts.py`](https://github.com/JRS1986/CodingScaffold/blob/main/src/coding_scaffold/artifacts.py). + +## context + +The bytes that go into the agent's input window for a single turn: the prompt, project +rules, file excerpts, knowledge notes, prior messages. `context budget` estimates the +size; `context lint` checks the files; `context compress` writes safer sidecars. See +[Context-Hygiene](./Context-Hygiene.md). + +## doctor + +`coding-scaffold doctor` — the accessibility hub. Surveys what is set up, recommends +1–3 next commands, and explicitly says which advanced surfaces a beginner can ignore. +Never installs or writes; always safe to run. + +## eval + +A small readiness benchmark: `eval init` creates the config, `eval run` exercises it, +`eval report` summarizes. It validates that the scaffold is set up — not that the model +is good. See [Getting-Started](./Getting-Started.md). + +## knowledge base + +A team-shared, reviewable corpus of notes under `.coding-scaffold/knowledge/`. Multiple +backends are supported (markdown, obsidian, foam, mempalace, html). Layered by scope +(team/department/unit/company) and maturity (raw → curated wiki). See +[Knowledge-Base](./Knowledge-Base.md). + +## maturity + +The lifecycle stage of a knowledge note: `raw` (captured but unreviewed), `wiki` +(curated for the team), and the layered scopes (team/department/unit/company). +`knowledge promote` moves notes between maturity levels with an audit trail. + +## MCP + +Model Context Protocol — a way for an agent to talk to external integrations (Slack, +Notion, your bug tracker). `mcp policy`, `mcp scan`, `mcp snapshot`, `mcp diff` +govern which integrations the project allows. See [Security](./Security.md). + +## memory + +Reviewable memory entries the agent can recall: `memory capture` proposes one, +`memory review` accepts/rejects, `memory promote` moves it between maturity classes, +`memory audit` lists what is in effect. Distinct from knowledge: memory is short +runtime hints; knowledge is shared documentation. + +## orchestration + +A multi-step recipe a tool can drive (`tools orchestrate`). Distinct from a single +prompt: orchestration plans the steps, picks a model per step, and tracks results. + +## persona + +A target user shape: `beginner`, `control-and-reproducibility`, `security-review`, +`team-lead`. Used by `doctor --persona` / `pilot --persona` to surface a focused +recipe instead of the full firehose. Personas are documented in +[Team-Rollout](./Team-Rollout.md). + +## pilot + +`coding-scaffold pilot --target . --tool ` — read-only guided wrapper. Probes +the local environment and prints the exact 10-minute happy-path commands. Never +installs and never writes files. The printed recipe is what the user actually runs. + +## policy pack + +Files under `.coding-scaffold/policy/` that encode the team's non-negotiables: +allowed providers, network rules, model selection floors, MCP allowlist. See +[Policy-Packs](./Policy-Packs.md). + +## provider + +A source of model inference: a local runtime (Ollama, LM Studio) or a cloud API +(Anthropic, OpenAI, …). `probe` lists what is available; `routing.json` describes +how the project routes between them. + +## routing + +The plan for which model handles which class of request: `weak_model` (fast/cheap), +`strong_model` (high-quality), plus the endpoint and policy. Stored in +`.coding-scaffold/routing.json`. + +## scaffold artifact + +See [artifact](#artifact). + +## scaffold version + +A SHA256 snapshot of every generated file written by setup, stored in +`.coding-scaffold/scaffold-version.json`. `setup update` uses it to tell unchanged +files (safe to rewrite) from user-edited files (write a `.new` sidecar instead). + +## session trace + +A reviewable Markdown file under `.coding-scaffold/sessions/` recording a single +agentic change: task, commands run, diff, follow-ups. Powers reversibility: +`session checkpoint`, `session diff`, `session rollback`. + +## skill + +A reusable scoped instruction the agent loads on demand: a Markdown file under +`.coding-scaffold/skills//` with a `SKILL.md`, optional helpers, and an +approved checksum. `skills new`, `skills lint`, `skills approve`, `skills export`. +See [Skills-and-Agents](./Skills-and-Agents.md). + +## stability marker + +A label rendered in `--help` next to each command name: `[stable]`, `[preview]`, +`[experimental]`. Tells experienced teams what they can build infrastructure around. +The contract per marker lives in [Stability](./Stability.md). + +## strong model + +The model class used for high-quality output (planning, review, hard reasoning). +Set in `routing.json`. The pilot recipe avoids picking one for the user — that is +the team's call. + +## team manifest + +A JSON file (`team-onboarding.json`) shipped from a team repo that defines shared +policy, skills, knowledge sources, and version requirements. `team connect` / +`team sync` apply it; `team push` nominates local artifacts upward; `team doctor` +shows the effective merged view. See [Team-Sync](./Team-Sync.md). + +## weak model + +The model class used for fast/cheap calls (extraction, classification, format +conversion). Set in `routing.json`. diff --git a/docs/docs/wiki/Stability.md b/docs/docs/wiki/Stability.md new file mode 100644 index 0000000..dd61513 --- /dev/null +++ b/docs/docs/wiki/Stability.md @@ -0,0 +1,67 @@ +# Stability + +Every top-level `coding-scaffold` command carries one of three stability markers, +rendered in `--help` next to the command name: + +``` +COMMANDS (DAILY WORKFLOW): + session init [stable] create a reviewable session trace + memory capture [preview] capture a memory candidate for review + team push [preview] nominate local artifacts upward +``` + +The marker is a contract about how aggressively the command may change. + +## What each marker promises + +### `[stable]` + +- The flag set, output shape, and exit codes will not change in a backward-incompatible + way without a major version bump (`0.x → 1.0`) and a deprecation cycle. +- A removed flag is preserved as a hidden alias for at least one minor release with + a deprecation message. +- The CHANGELOG names every change under a `Deprecated` heading. + +Build automation, CI checks, and team docs against `stable` commands without +hesitation. + +### `[preview]` + +- Feature-complete and tested. May still grow new flags or refine output in a minor + release. +- Breaking shape changes are possible but called out in the CHANGELOG with a migration + note. +- Used in production with caution: a `preview` command is on the path to `stable`, + and the wiki page for that area should note the target release. + +Treat `preview` commands like a beta API: depend on them, pin your scaffold version, +and watch the CHANGELOG. + +### `[experimental]` + +- Fast-moving. May change shape, flag names, or behavior without warning. +- Output formats are not part of the contract; do not parse them in CI. +- Useful for exploration and feedback; not yet suitable as a build dependency. + +If you find an `experimental` command load-bearing for your team, open an issue — +graduating to `preview` is a signal we look for. + +## Lifecycle + +- A new command lands as `experimental` by default. +- It graduates to `preview` once the flag set is settled and at least one team is + using it. +- It graduates to `stable` once the maintainers commit to the deprecation policy + above. The graduation lands in a minor release; the CHANGELOG notes it. + +## How to read the marker registry + +`src/coding_scaffold/cli_stability.py` is the single source of truth. The marker for +a command is whatever appears there; the `--help` text is generated from it. + +A test (`tests/test_cli_stability.py`) asserts: +- Every command surfaced in `--help` has a registry entry. +- Every registry entry uses one of `stable | preview | experimental`. + +When you move a command between markers, update the registry and add a CHANGELOG +entry under `Stability`. diff --git a/docs/docs/wiki/_meta.json b/docs/docs/wiki/_meta.json index 8276286..cc5ac0b 100644 --- a/docs/docs/wiki/_meta.json +++ b/docs/docs/wiki/_meta.json @@ -1,5 +1,6 @@ [ "Getting-Started", + "Glossary", "Core-Concepts", "Model-Selection-and-Providers", "Skills-and-Agents", @@ -12,5 +13,6 @@ "Advanced-Workflows", "Team-Rollout", "Review-Backlog", + "Stability", "FAQ" ] diff --git a/docs/docs/wiki/index.md b/docs/docs/wiki/index.md index 09429c3..59c812f 100644 --- a/docs/docs/wiki/index.md +++ b/docs/docs/wiki/index.md @@ -4,6 +4,9 @@ CodingScaffold is a local-first onboarding, configuration, and governance scaffo AI-assisted software development teams. The README is the quick front door; this wiki explains the project in more depth and gives teams a shared rollout playbook. +**New here?** Read the [Glossary](./Glossary.md) first — every term `doctor` prints +(artifact, persona, MCP, skill, manifest, scope, maturity) is defined in one place. + ## What CodingScaffold Does CodingScaffold creates project-local guidance and lightweight configuration for: diff --git a/src/coding_scaffold/artifacts.py b/src/coding_scaffold/artifacts.py new file mode 100644 index 0000000..b102795 --- /dev/null +++ b/src/coding_scaffold/artifacts.py @@ -0,0 +1,123 @@ +"""Single source of truth for scaffold artifacts and the rationale behind each one. + +`doctor`, `pilot`, and any future onboarding command consume this registry instead of +duplicating their own artifact tables. The `why` line answers \"what does this unlock?\" +in <100 chars so a first-time reader can interpret `doctor` output without leaving the CLI. + +Add a new artifact here, not in `doctor.py`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Artifact: + """A scaffold artifact `doctor`/`pilot` can recommend, with a short rationale.""" + + key: str + relative_path: str + why: str + + def __post_init__(self) -> None: + if len(self.why) > 100: + raise ValueError( + f"Artifact rationale for {self.key!r} is {len(self.why)} chars; keep under 100." + ) + + +ARTIFACTS: tuple[Artifact, ...] = ( + Artifact( + key="AGENTS.md", + relative_path="AGENTS.md", + why="Codex reads this first; without it the agent has no project rules.", + ), + Artifact( + key="CLAUDE.md", + relative_path="CLAUDE.md", + why="Claude Code reads this first; project rules and links to local context.", + ), + Artifact( + key="pr_template", + relative_path=".github/PULL_REQUEST_TEMPLATE/agentic-change.md", + why="Makes every agentic change reviewable in the same shape across the team.", + ), + Artifact( + key=".coding-scaffold/", + relative_path=".coding-scaffold", + why="Holds project-local scaffold config: routing, providers, sessions, knowledge.", + ), + Artifact( + key="knowledge_base", + relative_path=".coding-scaffold/knowledge", + why="Shared, reviewable team notes; replaces tribal knowledge in chat history.", + ), + Artifact( + key="sessions_dir", + relative_path=".coding-scaffold/sessions", + why="Per-change traces; enables rollback and reviewable agentic edits.", + ), + Artifact( + key="policy_pack", + relative_path=".coding-scaffold/policy", + why="Provider/MCP/runtime rules; lets you enforce 'no internet at runtime'.", + ), + Artifact( + key="permissions_json", + relative_path=".coding-scaffold/agent-permissions.json", + why="Tool allowlist for agents; defaults to read-only until you opt in.", + ), + Artifact( + key="mcp_policy", + relative_path=".coding-scaffold/mcp-policy.json", + why="MCP server allow/deny rules; governs which integrations the agent may use.", + ), + Artifact( + key="skills_dir", + relative_path=".coding-scaffold/skills", + why="Reusable skill packs the agent can load on demand; reviewable in git.", + ), + Artifact( + key="memory_dir", + relative_path=".coding-scaffold/memory", + why="Captured agent memory entries; reviewed and promoted before they take effect.", + ), + Artifact( + key="eval_config", + relative_path=".coding-scaffold/eval-config.json", + why="Readiness benchmark config; `eval run` validates the project is set up.", + ), + Artifact( + key="pyproject.toml", + relative_path="pyproject.toml", + why="Python project marker; doctor uses this to tailor language-specific guidance.", + ), + Artifact( + key="package.json", + relative_path="package.json", + why="Node project marker; doctor uses this to tailor language-specific guidance.", + ), +) + + +_INDEX: dict[str, Artifact] = {a.key: a for a in ARTIFACTS} + + +def artifact_keys() -> tuple[str, ...]: + """Stable, ordered list of artifact keys — matches the order shown by `doctor`.""" + + return tuple(a.key for a in ARTIFACTS) + + +def get_artifact(key: str) -> Artifact: + """Look up an artifact by key. Raises KeyError if missing — callers should pass a + key from ``artifact_keys()``.""" + + return _INDEX[key] + + +def rationale_for(key: str) -> str: + """Convenience for `get_artifact(key).why`.""" + + return _INDEX[key].why diff --git a/src/coding_scaffold/cli.py b/src/coding_scaffold/cli.py index b8a2646..a6236cc 100644 --- a/src/coding_scaffold/cli.py +++ b/src/coding_scaffold/cli.py @@ -7,6 +7,7 @@ from pathlib import Path from .adapters import write_route_backend, write_tool_adapter, write_workflow_backend +from .cli_stability import marker_for from .context import ( DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_CONTEXT_RATIO, @@ -126,6 +127,9 @@ policy, mcp, skills, memory, team, permissions, tools, knowledge distill The full command list is below. Every command supports --help. +Markers next to each command name: [stable] [preview] [experimental]. + See https://jrs1986.github.io/CodingScaffold/wiki/Stability for what they promise. +Glossary of terms: https://jrs1986.github.io/CodingScaffold/wiki/Glossary """ @@ -741,6 +745,7 @@ def build_parser() -> argparse.ArgumentParser: ) pilot.add_argument("--json", action="store_true", help="Print machine-readable JSON.") _hide_suppressed_subcommands(sub) + _annotate_stability(sub) return parser @@ -750,6 +755,23 @@ def _hide_suppressed_subcommands(subparsers: argparse._SubParsersAction) -> None ] +def _annotate_stability(subparsers: argparse._SubParsersAction) -> None: + """Prefix each visible top-level command's help text with its stability marker. + + Only operates on the help-display objects (`_choices_actions`); the actual + subparser objects in `choices` keep their original help so behavior, argument + parsing, and `help=argparse.SUPPRESS` filtering all stay unchanged. + """ + + for action in subparsers._choices_actions: # noqa: SLF001 - argparse has no public hook. + if action.help is argparse.SUPPRESS: + continue + command = action.dest + if not command: + continue + action.help = f"{marker_for(command)} {action.help or ''}".rstrip() + + def _add_setup_run_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--target", type=Path, default=Path.cwd(), help="Project directory.") parser.add_argument("--language", help="Primary language, e.g. python, rust, typescript.") diff --git a/src/coding_scaffold/cli_stability.py b/src/coding_scaffold/cli_stability.py new file mode 100644 index 0000000..7249b94 --- /dev/null +++ b/src/coding_scaffold/cli_stability.py @@ -0,0 +1,81 @@ +"""Stability markers for CLI commands. + +Each top-level command (and a few load-bearing subcommands) is annotated with a +stability level so experienced teams know what they can build on. Markers render +inside `--help` next to the command name; the wiki page `Stability.md` defines +what each marker promises. + +- `stable` — backward compatible. Breaking changes require a major-version bump + and a deprecation cycle. +- `preview` — feature-complete but the shape may shift in a minor release. Used + in production with caution; the wiki notes when each command will + move to `stable`. +- `experimental` — fast-moving. May change without warning. Use for exploration; do + not build automation around it yet. + +Add new commands here, not in `cli.py`. Tests assert every command in `--help` has +an entry. +""" + +from __future__ import annotations + +STABILITY_LEVELS: tuple[str, ...] = ("stable", "preview", "experimental") + + +# Top-level commands (the ones surfaced in `coding-scaffold --help`). +COMMAND_STABILITY: dict[str, str] = { + "probe": "stable", + "setup": "stable", + "credentials": "stable", + "knowledge": "preview", + "context": "preview", + "session": "stable", + "memory": "preview", + "pr-template": "stable", + "permissions": "preview", + "mcp": "preview", + "skills": "preview", + "eval": "preview", + "team": "preview", + "policy": "preview", + "tools": "stable", + "doctor": "stable", + "pilot": "stable", + "skill": "stable", + # Hidden flat aliases. Same stability as the canonical group. + "init": "stable", + "wizard": "stable", + "knowledge-status": "preview", + "context-budget": "preview", + "compress-context": "preview", + "orchestrate": "experimental", + "setup-tool": "stable", + "setup-addon": "stable", + "setup-knowledge": "preview", + "adapt": "stable", + "route": "experimental", + "select-model": "stable", + "workflow": "experimental", + "update": "stable", +} + + +def marker_for(command: str) -> str: + """Return ``[stable]`` / ``[preview]`` / ``[experimental]``. + + Falls back to ``[preview]`` for commands missing from the registry so that + adding a command doesn't immediately break `--help`; tests still flag the + omission so the new command gets a real entry. + """ + + level = COMMAND_STABILITY.get(command, "preview") + return f"[{level}]" + + +def annotate(help_text: str, command: str) -> str: + """Prefix the help string with the stability marker. + + The marker comes first so the visible width is consistent across commands. + """ + + return f"{marker_for(command)} {help_text}" diff --git a/src/coding_scaffold/doctor.py b/src/coding_scaffold/doctor.py index 16c24fc..68b23b6 100644 --- a/src/coding_scaffold/doctor.py +++ b/src/coding_scaffold/doctor.py @@ -15,6 +15,7 @@ from dataclasses import dataclass from pathlib import Path +from .artifacts import ARTIFACTS, rationale_for from .hardware import probe_hardware from .pr_template import PR_TEMPLATE_RELATIVE @@ -69,27 +70,25 @@ def run_doctor(target: Path | None = None) -> DoctorReport: def _survey_artifacts(root: Path) -> dict[str, bool]: - """File-existence survey. Keys are stable for golden tests / --json consumers.""" + """File-existence survey. Keys are stable for golden tests / --json consumers. + + Order and key set come from `artifacts.ARTIFACTS` so the registry is the single + source of truth. + """ pr_template_glob_present = (root / ".github" / "PULL_REQUEST_TEMPLATE").exists() and any( (root / ".github" / "PULL_REQUEST_TEMPLATE").iterdir() ) - return { - "AGENTS.md": (root / "AGENTS.md").exists(), - "CLAUDE.md": (root / "CLAUDE.md").exists(), - "pr_template": pr_template_glob_present or (root / PR_TEMPLATE_RELATIVE).exists(), - ".coding-scaffold/": (root / ".coding-scaffold").exists(), - "knowledge_base": (root / ".coding-scaffold" / "knowledge").exists(), - "sessions_dir": (root / ".coding-scaffold" / "sessions").exists(), - "policy_pack": (root / ".coding-scaffold" / "policy").exists(), - "permissions_json": (root / ".coding-scaffold" / "agent-permissions.json").exists(), - "mcp_policy": (root / ".coding-scaffold" / "mcp-policy.json").exists(), - "skills_dir": (root / ".coding-scaffold" / "skills").exists(), - "memory_dir": (root / ".coding-scaffold" / "memory").exists(), - "eval_config": (root / ".coding-scaffold" / "eval-config.json").exists(), - "pyproject.toml": (root / "pyproject.toml").exists(), - "package.json": (root / "package.json").exists(), - } + + presence: dict[str, bool] = {} + for artifact in ARTIFACTS: + if artifact.key == "pr_template": + presence[artifact.key] = ( + pr_template_glob_present or (root / PR_TEMPLATE_RELATIVE).exists() + ) + continue + presence[artifact.key] = (root / artifact.relative_path).exists() + return presence def _system_notes() -> list[str]: @@ -183,9 +182,13 @@ def format_doctor_text(report: DoctorReport) -> str: lines.append(f"CodingScaffold doctor — {report.target}") lines.append("") lines.append("Scaffold artifacts:") + # Widest key sets the column for the rationale line so output stays aligned. + key_width = max((len(k) for k in report.artifacts), default=0) for key, present in report.artifacts.items(): mark = "[x]" if present else "[ ]" - lines.append(f" {mark} {key}") + lines.append(f" {mark} {key.ljust(key_width)} -> {rationale_for(key)}") + lines.append("") + lines.append("Glossary: https://jrs1986.github.io/CodingScaffold/wiki/Glossary") lines.append("") if report.notes: lines.append("System:") @@ -201,4 +204,6 @@ def format_doctor_text(report: DoctorReport) -> str: lines.append("") lines.append("Ignore for now (advanced):") lines.append(f" {', '.join(report.ignore_for_now)}") + lines.append("") + lines.append("Terms: https://jrs1986.github.io/CodingScaffold/wiki/Glossary") return "\n".join(lines) diff --git a/tests/test_artifacts_and_stability.py b/tests/test_artifacts_and_stability.py new file mode 100644 index 0000000..97c929a --- /dev/null +++ b/tests/test_artifacts_and_stability.py @@ -0,0 +1,172 @@ +"""Coverage for the artifact registry, doctor rationale lines, Glossary, and the +CLI stability markers (issues #87, #88, #95).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coding_scaffold.artifacts import ( + ARTIFACTS, + artifact_keys, + get_artifact, + rationale_for, +) +from coding_scaffold.cli import build_parser +from coding_scaffold.cli_stability import ( + COMMAND_STABILITY, + STABILITY_LEVELS, + marker_for, +) +from coding_scaffold.doctor import format_doctor_text, run_doctor + + +# --------------------------------------------------------------------------- +# Artifact registry +# --------------------------------------------------------------------------- + + +def test_artifact_keys_match_doctor_survey(tmp_path: Path) -> None: + """Doctor's artifact dict must enumerate exactly the registry.""" + + report = run_doctor(tmp_path) + assert tuple(report.artifacts) == artifact_keys() + + +def test_every_artifact_has_a_short_rationale() -> None: + for artifact in ARTIFACTS: + assert artifact.why, f"artifact {artifact.key!r} missing rationale" + # Hard limit enforced in __post_init__; double-check here too. + assert len(artifact.why) <= 100 + + +def test_rationale_lookup_round_trip() -> None: + for artifact in ARTIFACTS: + assert rationale_for(artifact.key) == artifact.why + assert get_artifact(artifact.key) is artifact + + +# --------------------------------------------------------------------------- +# Doctor output renders rationale + glossary link +# --------------------------------------------------------------------------- + + +def test_doctor_renders_rationale_for_each_artifact(tmp_path: Path) -> None: + report = run_doctor(tmp_path) + text = format_doctor_text(report) + for artifact in ARTIFACTS: + # The arrow + rationale appears on the same line as the artifact key. + line = next( + (raw for raw in text.splitlines() if artifact.key in raw and "->" in raw), + None, + ) + assert line is not None, ( + f"doctor output missing rationale line for {artifact.key!r}" + ) + assert artifact.why in line + + +def test_doctor_renders_glossary_link(tmp_path: Path) -> None: + text = format_doctor_text(run_doctor(tmp_path)) + assert "Glossary" in text + assert "Glossary" in text + + +# --------------------------------------------------------------------------- +# Glossary file exists + is linked from the wiki index and CLI help +# --------------------------------------------------------------------------- + + +def test_glossary_page_exists_and_lists_core_terms() -> None: + root = Path(__file__).resolve().parent.parent + glossary = root / "docs" / "docs" / "wiki" / "Glossary.md" + assert glossary.exists(), "wiki Glossary.md is the cheapest new-dev win; ship it" + text = glossary.read_text(encoding="utf-8") + # Required terms — every word a doctor user will see in output. + for term in ( + "adapter", + "artifact", + "context", + "doctor", + "eval", + "knowledge base", + "MCP", + "memory", + "persona", + "pilot", + "policy pack", + "provider", + "scaffold version", + "session trace", + "skill", + "stability marker", + "team manifest", + "weak model", + "strong model", + ): + assert term.lower() in text.lower(), f"Glossary missing required term: {term!r}" + + +def test_top_level_help_links_glossary_and_stability() -> None: + text = build_parser().format_help() + assert "Glossary" in text + assert "Stability" in text + + +# --------------------------------------------------------------------------- +# Stability markers +# --------------------------------------------------------------------------- + + +def test_stability_levels_are_a_known_set() -> None: + for command, level in COMMAND_STABILITY.items(): + assert level in STABILITY_LEVELS, ( + f"command {command!r} has unknown stability {level!r}" + ) + + +def test_marker_for_known_command_round_trips() -> None: + for command, level in COMMAND_STABILITY.items(): + assert marker_for(command) == f"[{level}]" + + +def test_every_visible_top_level_command_has_a_stability_entry() -> None: + """Every command rendered in `--help` must carry a marker. + + Catches the common omission of forgetting to update `cli_stability.py` when + adding a new top-level command. + """ + + parser = build_parser() + # The single subparsers action sits as the only positional. Walk it. + sub_action = next( + action + for action in parser._actions # noqa: SLF001 — argparse has no public hook + if isinstance(action.choices, dict) and action.choices + ) + visible = [ + name + for name, sub in sub_action.choices.items() + if any( + ca.dest == name and ca.help and ca.help != "==SUPPRESS==" + for ca in sub_action._choices_actions # noqa: SLF001 + ) + ] + for command in visible: + assert command in COMMAND_STABILITY, ( + f"command {command!r} appears in --help but has no entry in " + "src/coding_scaffold/cli_stability.py" + ) + + +def test_top_level_help_renders_stability_markers() -> None: + text = build_parser().format_help() + # At least one of each marker level appears in the rendered help. + assert "[stable]" in text + assert "[preview]" in text + + +@pytest.mark.parametrize("command", sorted(COMMAND_STABILITY)) +def test_each_marker_is_one_of_three_levels(command: str) -> None: + assert COMMAND_STABILITY[command] in STABILITY_LEVELS