From 866fb25677dd5ec41340aa140e113ebb6b6c7731 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Aug 2026 10:07:00 -0500 Subject: [PATCH] Docs(refactor[agents]): Route instead of restate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: AGENTS.md is read on every task, so a policy that matters to one class of change costs context on all the others. Splitting how we work from how we write lets each be loaded when it applies, and keeps both discoverable to humans rather than only to agents. what: - Replace the AGENTS.md body with a router: the project map grouped by the workspace's own tiers, universal change discipline, then one pointer per class of change - Add .github/WRITING.md for prose policy: voice, the docs/ authoring conventions merged in from docs/AGENTS.md, the corrected doctest mechanism (Markdown under docs/ and README.md is not collected — only .py under testpaths is), the Django-style CHANGES convention, docstrings, Sphinx/MyST role and {include} conventions, CSS naming, source comments, and slop prevention - Add .github/CONTRIBUTING.md for workflow policy: setup (including the pnpm/Node requirement and its SPHINX_VITE_BUILDER_SKIP escape hatch), the real CI gate commands, code style, the full test hierarchy and fixture reference, docs build, and releasing - Delete docs/AGENTS.md, docs/CLAUDE.md, and .github/contributing.md (lowercase), now absorbed into the files above - Turn docs/project/contributing.md and docs/project/code-style.md into pointer pages at the canonical .github/ files ({include} does not carry relative links across a directory boundary here, per the fleet's settled finding) - Trim packages/sphinx-vite-builder/AGENTS.md to what is package specific, route it to the root docs, and correct two stale claims (the Sphinx extension is not a Phase 1 placeholder; it has no long-lived watch process) plus a stale actions/setup-node pin - Replace the sphinx-vite-builder CLAUDE.md pointer file with a real symlink to AGENTS.md, matching the root - Add the alpha warning to README.md and reflow it to 80 columns --- .github/CONTRIBUTING.md | 333 ++++++++ .github/WRITING.md | 782 ++++++++++++++++++ .github/contributing.md | 27 - AGENTS.md | 1039 ++---------------------- README.md | 45 +- docs/AGENTS.md | 156 ---- docs/CLAUDE.md | 1 - docs/project/code-style.md | 32 +- docs/project/contributing.md | 181 +---- packages/sphinx-vite-builder/AGENTS.md | 78 +- packages/sphinx-vite-builder/CLAUDE.md | 9 +- 11 files changed, 1270 insertions(+), 1413 deletions(-) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/WRITING.md delete mode 100644 .github/contributing.md delete mode 100644 docs/AGENTS.md delete mode 120000 docs/CLAUDE.md mode change 100644 => 120000 packages/sphinx-vite-builder/CLAUDE.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..984bbef3 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,333 @@ +# Contributing + +Thanks for looking. gp-sphinx is alpha (`0.1.0a37`) — the most useful +thing right now is a bug report with a reproduction, or a note on where +a `docs/` page misled you. + +How this project writes prose — README, `CHANGES`, commit messages, +docstrings, source comments, and every `docs/` page — is set out +separately in [WRITING.md](WRITING.md). Read that before changing any of +it. The constraints every change is held to, and the map of what is +where, are in [AGENTS.md](../AGENTS.md). + +## Getting set up + +Install [git] and [uv]: + +```console +$ git clone https://github.com/git-pull/gp-sphinx.git +``` + +```console +$ cd gp-sphinx +``` + +```console +$ uv sync --all-packages --all-extras --group dev +``` + +`gp-furo-theme`'s build backend (`sphinx_vite_builder.build`) runs +`pnpm exec vite build` during that install, so it needs [pnpm] and +[Node] on `PATH`. Working on Python only, without a JS toolchain, set +the documented escape hatch first — it makes the backend short-circuit +instead of failing: + +```console +$ export SPHINX_VITE_BUILDER_SKIP=1 +``` + +[git]: https://git-scm.com/ +[uv]: https://github.com/astral-sh/uv +[pnpm]: https://pnpm.io/installation +[Node]: https://nodejs.org/ + +## The gates + +CI is the order of record (`.github/workflows/tests.yml`); every gate it +runs has to pass before a change is done. + +Format: + +```console +$ uv run ruff format . --check +``` + +Lint: + +```console +$ uv run ruff check . +``` + +Type-check: + +```console +$ uv run mypy . +``` + +Test: + +```console +$ uv run pytest +``` + +Documentation is a gate, not a courtesy. Examples in docstrings and in +the `docs/_ext/` demo modules are executed by `pytest`; the doctest +flags live in `pyproject.toml`, so there is no separate doctest step and +a green `pytest` is the proof for those. Markdown pages under `docs/` +are not executed — see +[WRITING.md](WRITING.md#documented-examples-that-run) for exactly which +blocks run and the one mistake that silently removes a test. + +Before claiming a test or a gate works, show it failing. A gate that has +never been red is an assumption. + +## Code style + +`ruff` and `mypy` catch most of this automatically; the rest is +convention the linters do not enforce. + +- **Standard library imports are namespaced**: `import enum`, not + `from enum import Enum`. Third-party packages may use `from X import + Y`. `dataclasses` is the one standard-library exception, for the + cleaner `from dataclasses import dataclass, field` decorator syntax. +- **Typing uses `import typing as t`**, accessed via the namespace: + `t.NamedTuple`, `t.Callable`, and so on. +- **`from __future__ import annotations`** is required at the top of + every Python file; `ruff`'s `required-imports` enforces it. +- **Prefer the typed `env.domains._domain` accessors** over + `env.get_domain("")` — `env.domains.python_domain`, not + `env.get_domain("py")`. The typed accessors return the concrete + domain subclass, so mypy sees subclass-specific attributes + (`progoptions`, `data["objects"]`, …) without a cast. They require + Sphinx 8.1's `_DomainsContainer`, which is this workspace's floor. + +### Logging + +- Use `logging.getLogger(__name__)` in every module; add a + `NullHandler` in library `__init__.py` files. Never configure + handlers, levels, or formatters in library code — that is the + application's job. +- Use lazy formatting — `logger.debug("msg %s", val)`, not an f-string — + so interpolation is skipped when the level is filtered and log + aggregators group messages by template instead of by literal string. + Guard an expensive `val` with `if logger.isEnabledFor(logging.DEBUG)`. +- Messages are lowercase, past tense, and end without punctuation: + `"config merged"`, not `"Config merged."`. Keep the message short; put + details in `extra`. +- Use `logger.exception()` only inside an `except` block you are not + re-raising from. Use `logger.error(..., exc_info=True)` for a + traceback outside an `except` block. `logger.exception()` followed by + `raise` duplicates the traceback. +- Assert on `caplog.records`, not `caplog.text` — `caplog.record_tuples` + cannot see `extra` fields. Scope capture with + `caplog.at_level(logging.DEBUG, logger="gp_sphinx.config")`. + +## Tests + +Preferred local commands use a fixed pytest temp root under `.cache/` +and disable tmp-path retention for speed: + +```console +$ just test +``` + +Use raw `uv run pytest` for the conservative direct runner without that +local optimization — this is also CI's own command. + +Fast local loop, without `--doctest-modules` or integration tests: + +```console +$ just test-fast +``` + +Do not use the fast lane to reason about full-suite coverage or total +suite performance; it is intentionally deselected for local iteration. +`just test` (or plain `pytest`) is the coverage-complete lane. + +Run continuously while developing: + +```console +$ just start +``` + +Requires [pytest-watcher]; `just start-fast` runs the fast lane +continuously instead, and `just watch-test` (requires [entr(1)]) is a +third option. + +[pytest-watcher]: https://github.com/olzhasar/pytest-watcher +[entr(1)]: http://eradman.com/entrproject/ + +### Test level hierarchy + +Pick the **lightest** level that exercises the behaviour. Never reach +for a full Sphinx build when a docutils node test suffices — an +integration build takes 2-10 s, a node test runs in microseconds. + +| Level | When to use | +| --- | --- | +| Pure unit | Transforming strings, dicts, dataclasses — no nodes, no Sphinx | +| Docutils tree unit | Testing transforms/visitors/renderers by constructing `nodes.*` directly | +| Snapshot unit | Same as docutils tree, but output is large or complex — assert via `snapshot_doctree` | +| Sphinx integration (`@pytest.mark.integration`) | Any test that constructs a `Sphinx` app, including `buildername="dummy"`, walks a built doctree, or asserts on `result.warnings` | + +All tests are plain `def test_*` functions — no `class TestFoo:` +groupings. Every test function and every `NamedTuple` fixture class is +fully type-annotated; mypy runs over `tests/` in CI. + +### Parametrizing with NamedTuple + +Use `t.NamedTuple` for any parametrized test with three or more inputs. +Two wiring styles are in use; pick whichever reads more clearly for the +case at hand — unpack all fields as separate parameters (dominant, +self-documenting signature), or pass the whole struct as `case` when it +is reused in assertion messages or has many fields. `test_id: str` is +always the first field; the fixture list is `_FOO_FIXTURES` +(module-private, all-caps); the fixture class is `FooFixture` or +`FooCase`, never `TestFoo`. + +### Docutils tree unit tests + +Construct `docutils.nodes` and `sphinx.addnodes` objects directly to +test transforms, visitors, and renderers without a Sphinx build — follow +the pattern in `tests/ext/layout/test_transforms.py`. Put `_make_*()` +builder helpers at the top of the test file. Never import +`sphinx.application.Sphinx` in a pure tree test. + +### Snapshot tests + +[syrupy] backs three fixtures (`tests/_snapshots.py`, loaded via +`pytest_plugins`) that normalize their inputs before asserting, so +build-path churn and docutils version noise do not cause spurious +failures: `snapshot_doctree`, `snapshot_html_fragment`, and +`snapshot_warnings`. Update stored snapshots after an intentional output +change: + +```console +$ uv run pytest --snapshot-update +``` + +[syrupy]: https://github.com/toptal/syrupy + +### Integration tests (full Sphinx build) + +Use the harness in `tests/_sphinx_scenarios.py`: +`SphinxScenario`/`ScenarioFile` describe a synthetic project; +`build_shared_sphinx_result()` builds once per content-hash digest and +`build_isolated_sphinx_result()` builds fresh per test for mutating +assertions; `get_doctree()` and `read_output()` read back the result. +Always use a **module-** or **session-scoped** fixture for the build, +never function-scoped, so the expensive build is shared across the +module's tests — follow `tests/ext/typehints_gp/test_integration.py`. +Mark every such test `@pytest.mark.integration`. + +`build_shared_sphinx_result()`'s content-hash caching is why the full +suite runs in seconds rather than tens of seconds; see +`notes/test-analysis.md` for the profiling data and the per-package +migration history behind that harness. + +### Available fixtures + +| Fixture | Source | When to use | +| --- | --- | --- | +| `tmp_path`, `tmp_path_factory` | pytest | Per-test / per-session temp directories | +| `monkeypatch` | pytest | Env vars, module attributes, `sys.modules` patching | +| `caplog` | pytest | Log assertions — use `.records`, not `.text` | +| `snapshot_doctree`, `snapshot_html_fragment`, `snapshot_warnings` | `tests/_snapshots.py` | Normalized snapshot assertions | +| `spf_suite_root`, `spf_doctree_root`, `spf_html_root` | `tests/ext/pytest_fixtures/conftest.py` | Session roots for the pytest-fixtures extension tests | +| `simple_parser`, `parser_with_groups`, … | `tests/ext/argparse/conftest.py` | `ArgumentParser` permutations for argparse tests | + +### Anti-patterns + +No `class TestFoo:` groupings. No `unittest.mock.patch` — use +`monkeypatch`. No `tempfile.mkdtemp()` — use `tmp_path`. No `Sphinx()` +instantiation in a unit test — build docutils nodes directly. No +unannotated test functions. No inline tuples in `parametrize` with three +or more fields — use `NamedTuple`. No function-scoped Sphinx build +fixtures. + +## Documentation + +Default preview server: . + +[sphinx-autobuild] builds the docs, watches for file changes, and serves +them: + +```console +$ just start-docs +``` + +Build once: + +```console +$ just build-docs +``` + +Both are repository-root wrappers around `docs/justfile`; run +`just html`, `just serve`, `just watch-docs` (requires [entr(1)]), or +`just dev-docs` directly from inside `docs/` for the individual steps. + +CI builds with warnings as errors: + +```console +$ uv run sphinx-build -W -b dirhtml docs docs/_build/html +``` + +`docs/packages//` pages, the API reference, and the changelog page +are generated from live workspace metadata and `CHANGES` respectively — +see [WRITING.md](WRITING.md#generated-pages) before hand-editing one. + +[sphinx-autobuild]: https://github.com/executablebooks/sphinx-autobuild + +## Releasing + +Never create tags. Never push tags. The owner handles tagging and tag +pushes, because a tag triggers the publish workflow. See +[Release commits](WRITING.md#release-commits). + +All publishable workspace packages share one lockstep version. Bump it +everywhere with: + +```console +$ just bump-version +``` + +That updates every `pyproject.toml` and exposed `__version__`, relocks, +and validates the result via `scripts/ci/package_tools.py +check-versions`. Update `CHANGES` before or alongside the bump. The +release commit itself is plain and short (`Tag v`), per +[Release commits](WRITING.md#release-commits) — the release manager +creates and pushes the tag after review; that push is what triggers +`release.yml` to build every package and publish to PyPI. See +[the releasing reference](https://github.com/git-pull/gp-sphinx/blob/main/docs/project/releasing.md) +for the full checklist. + +## Pull requests + +One subject per pull request. Unrelated cleanup found along the way +belongs in its own commit, and usually in its own pull request. + +Discuss a substantial change via an issue before making it. + +You may merge once you have the sign-off of one other developer. If you +do not have permission to merge, ask a reviewer to merge it for you. + +Commit format is in [WRITING.md](WRITING.md#commits). + +## Decorum + +- Participants will be tolerant of opposing views. +- Participants must ensure that their language and actions are free of + personal attacks and disparaging personal remarks. +- When interpreting the words and actions of others, participants + should always assume good intentions. +- Behaviour which can be reasonably considered harassment will not be + tolerated. + +Based on [Ruby's Community Conduct Guideline](https://www.ruby-lang.org/en/conduct/). + +## Security + +Please do not open a public issue for a vulnerability. Use GitHub's +private reporting instead: open a +[new security advisory](https://github.com/git-pull/gp-sphinx/security/advisories/new) +on this repository. diff --git a/.github/WRITING.md b/.github/WRITING.md new file mode 100644 index 00000000..4d17dd44 --- /dev/null +++ b/.github/WRITING.md @@ -0,0 +1,782 @@ +# Writing + +How this project writes prose, for humans and agents alike. It governs +`README.md`, `CHANGES`, commit messages, docstrings, source comments, and +every `docs/` page — including the MyST and Sphinx conventions downstream +repositories inherit through `merge_sphinx_config()`. + +For environment setup, the gates, and pull request workflow, see +[CONTRIBUTING.md](CONTRIBUTING.md). + +## Voice + +Three surfaces, one voice. A docstring says what a caller may rely on; a +`CHANGES` entry says what changed; prose says what happens. All three are +present tense, lead with the thing being described, and stop. Why it was +built that way belongs in the commit message, which is timestamped and +attached to the diff. + +The most useful editing operation is deleting the introductory sentence. + +Lead with verbs and name concrete things. Put identifiers in backticks. +Prefer short declarative sentences, one operational fact each. Do not +explain Python to Python developers; do explain this project's semantics. + +Type annotations describe shape. Documentation describes meaning. A +sentence that restates a signature has said nothing. + +Use MUST, SHOULD, and MAY only where the normative sense is meant. Say what +actually happens rather than that something is "supported". + +| Instead of | Prefer | +| --------------------------------- | --------------------------------- | +| "We added…" | "`merge_sphinx_config()` now accepts…" | +| "New and improved" | "`DEFAULT_EXTENSIONS` now…" | +| "powerful", "seamless" | state the capability | +| "easily", "simply", "just" | omit | +| "simple", "obvious", "intuitive" | omit | +| "robust" | name the failure that is handled | +| "comprehensive" | name what is covered | +| "production-ready" | state the guarantee | +| "optimized", "blazingly fast" | give the magnitude | +| "various fixes" | name the components | +| "under the hood" | omit unless observable | +| "please note that", "note that" | state the fact | +| "leverage", "utilize" | "use" | +| "delve into" | "read", or omit | +| "best practices" | name the practice | +| "in order to" | "to" | + +## Who you are writing for + +The default reader is fluent in Python and new to this workspace. They can +read a signature; they cannot guess gp-sphinx's semantics. Serve them +first. + +A second, smaller reader works *on* the workspace: adding a package, +extending an autodoc extension, touching `gp-furo-theme`'s `web/src` +assets or the `sphinx-vite-builder` backend. Serve them too, but mark +their material opt-in — "for workspace contributors", "advanced" — so the +default reader knows they can stop. + +Rules that follow: + +- **Second person, present tense, active.** "You pass `docs_url`", not "SEO + values are derived". Address the reader doing the thing. +- **Concept before API surface.** Open by saying what the object or + function *is* and what it does for the reader. The signature, or the + kwarg list, is the last detail they need, not the first. A page that + opens with "pass these keyword arguments" has buried the idea under its + mechanics. +- **Say when they can stop.** Lead with the default and the reassurance: + the ~10-line `merge_sphinx_config()` call is the whole integration; + everything past it is optional. Let a skimmer leave after one + paragraph. +- **Grant permission, do not demand attention.** "Reach for this when…" + tells readers they are in the right place without implying they must + read on. +- **Progressive disclosure.** Order by how many readers need it: the + coordinator call → the one kwarg a few will tune (`docs_url`, + `extra_extensions`) → a single package's own options → workspace + internals. Each step is for a smaller audience than the last. +- **Lean on the merge order.** The reader's mental model of + `merge_sphinx_config()` is a pipeline: shared `DEFAULT_*` constants, + then values auto-computed from `source_repository` and `docs_url`, then + `**overrides` applied last — an explicit value always wins. Reinforce + that order when explaining who sets what. On package pages the + equivalent is the tier map: shared infrastructure → autodoc extensions + → theme and coordinator. +- **Name the trade-off.** If a call or option costs something — an extra + round trip, a stale object needing a refresh, a polling wait — say so, + and say what it buys. `vite_orchestration=True` spawns a pnpm/Vite + watcher under `sphinx-autobuild`: contributors need Node, wheel + consumers don't. State it; do not sell it. +- **Frame by concept, not by mechanism.** Do not headline a feature by its + kwarg or CSS custom property in prose; that names the implementation + surface, the reader's last concern. Name the concept. The mechanics + vocabulary — a Parameter/Type/Default table, a `DEFAULT_*` constant — + is correct in `docs/configuration.md`'s reference tables, and only + there. + +### What stays precise + +Warm the framing, never the facts. Parameter tables, auto-computed value +mappings, `DEFAULT_*` constant tables, exact extension names, and +cross-references carry meaning in their exact form — leave them alone. +The friendly voice belongs in the sentences *around* a precise block, +introducing it, not inside it paraphrasing it into vagueness. + +### Keeping examples honest + +The `conf.py` snippets on `docs/` pages are illustrative — no test +executes them (see +[Documented examples that run](#documented-examples-that-run)), so every +kwarg shown must exist in `merge_sphinx_config()`'s real signature. The +nearest thing to a test: this site is its own flagship consumer, so +building the docs exercises what the snippets promise. What *does* run: +the gallery renders live from the demo modules in `docs/_ext/` — nothing +is mocked — and those modules' doctests execute as part of `pytest`. +Keep them passing. + +### Generated pages + +Every `docs/packages//` page gets its "Copyable config snippet" and +"Package metadata" sections from the `{package-landing}` and +`{package-reference}` directives (`docs/_ext/package_reference.py`), +which read live workspace metadata — do not hand-write what they +generate; a new `packages//pyproject.toml` appears on the next +build with no code change. Surface documentation for config values, +directives, and roles belongs to `autoconfigvalues`, `autodirective`, +and `autorole`; invoke them instead of transcribing it into prose. + +### Cross-references + +Point the advanced reader at the deep-dive rather than inlining it, and +put the link where their interest peaks — on the phrase that made them +curious ("write your own autodoc extension") — not as a footnote the eye +skips. See [Sphinx and MyST conventions](#sphinx-and-myst-conventions) +for which role to reach for. + +Link the first prose mention of any symbol that has a useful destination +on that page: Python objects, gp-sphinx APIs, workspace package pages, +configuration anchors, and external tools or projects. After the first +linked mention on a page, later mentions can stay plain unless distance +or context makes another link useful. Do not rely on a later reference +section to satisfy the first-mention rule — if the first occurrence would +be a heading, grid-card teaser, or introductory sentence, link that +occurrence or retitle the heading. Leave command examples, code blocks, +and literal configuration values as code; link the surrounding prose +instead. + +A `{ref}` must match its target's anchor exactly — anchors mix hyphen and +underscore forms, sometimes inside one anchor (`from-docs_url`). Building +the docs catches a broken `{ref}`; nothing else does. A py-domain role +(`{py:class}`, `{py:data}`, …) is not covered by that check — +`nitpicky` is unset, so an unresolved one renders as plain text and the +build stays silent. Confirm by opening the built page and checking the +name sits inside an ``. + +### A page that does this + +`docs/packages/gp-sphinx/how-to.md` is the worked example — the concept +before the config, the trade-off named, the deep dive linked instead of +inlined. Read it before reshaping another page. + +## README + +A README is the shortest path from "what is this?" to competent use, not +the project's autobiography. + +The first sentence is a contract. It says what abstraction the reader has +been handed, concretely enough to tell this package apart from the +neighbouring one. + +Get to a runnable command or snippet before anything the reader can skip. +A logo, a mission statement, a comparison matrix and three paragraphs of +history in front of the install line all cost the same thing. + +State the minimum Python version and meaningful platform constraints in +prose, not only in badges. `requires-python` in `pyproject.toml` is the +authority; the README must agree with it. + +Name the distribution, the import, and the executable separately wherever +they differ. That distinction prevents a Python-specific class of +confusion. + +Examples are executable, not illustrative fiction. Never +`your-command `. See +[Documented examples that run](#documented-examples-that-run) for which +blocks are executed and how to write one that qualifies. + +Document the semantic model, not the flag list. What it cannot say is +precedence, filesystem effects, what goes to stdout versus stderr, and +what a non-zero exit means. + +State defaults explicitly — defaults are API. State negative guarantees +where they exist: "does not modify your configuration file", "no network +access", "never writes outside the destination". They establish +boundaries faster than any amount of description. + +Headings stay conventional and stable, because people deep-link them. +Badges are few and load-bearing. + +## Documented examples that run + +Examples in this fleet are tests, where the collector reaches them. This +section is the contract for writing one the test suite can actually see +**in this repository**. + +**A fence tag is cosmetic. Only a `>>> ` prompt executes, and only inside +a Python module.** `pytest`'s `--doctest-modules` flag (set in +`pyproject.toml`'s `addopts`) collects doctests from importable `.py` +files under `testpaths` — the workspace `tests/`, `docs/` (its `_ext/` +demo and extension modules), and every `packages/*/src`. It does **not** +collect Markdown or reStructuredText: gp-sphinx has no `doctest-glob` or +RST doctest plugin configured, so a `>>> ` block inside `README.md` or a +page under `docs/` is prose that looks like a test. Nothing runs it. + +This matters more here than elsewhere in the fleet, because gp-sphinx's +own docs (`docs/packages/*/how-to.md`) are full of fenced `python` +blocks written to *look* like the docstring examples they document. That +resemblance is intentional — see +[Keeping examples honest](#keeping-examples-honest) — but it is +illustration, not a test. Do not add a `>>> ` prompt to a Markdown page +expecting it to run; add it to the docstring the page is illustrating. + +**The fence tag is `python`.** Not `pycon`, not bare. This stays uniform +even for illustrative blocks, so a reader cannot tell test from +illustration by fence tag alone — only by whether the file is a `.py` +module under `testpaths`. + +**`# doctest: +SKIP` is not permitted.** It is a workaround that tests +nothing. Use the fixtures. + +**Do not downgrade a doctest to a non-executed block to make it pass.** A +`.. code-block::` or an unprompted fence does not run. If an example +cannot pass, fix the example or fix the code. + +**Option flags.** `ELLIPSIS` and `NORMALIZE_WHITESPACE` are enabled +globally, so `...` elides variable output and whitespace differences do +not fail a comparison. Reach for an inline `# doctest: +FLAG` only for +the block that needs it. `sphinx-autodoc-typehints-gp` additionally +recognizes `# doctest: +HIDE` to drop incidental setup lines — socket +paths, environment scaffolding — from the *rendered* docstring on an API +page without touching the source; it has no effect under plain `pytest`. + +**Docstring examples** use the NumPy `Examples` section. A public +function carries one where a short call demonstrates the behaviour +clearly: + + Examples + -------- + >>> from gp_sphinx.defaults import DEFAULT_EXTENSIONS + >>> "myst_parser" in DEFAULT_EXTENSIONS + True + +Treat a function without one as unfinished documentation, not as a +policy violation to block on — nothing in CI enforces a doctest on every +function, and inventing one that exercises nothing but plumbing is worse +than no example. + +**The doctest namespace is scoped to `tests/`, not the fleet.** A +`doctest_namespace` fixture in `tests/conftest.py` injects `tmp_path` (a +session-scoped writable directory) for doctests collected under `tests/`. +pytest's conftest discovery does not cross into sibling trees, so a +doctest under `packages/*/src` or `docs/_ext/` cannot rely on it — those +examples must stay self-contained or build what they need inline. Add a +name to `tests/conftest.py`'s `_doctest_namespace` fixture only if you +also confirm, by running the suite, which directories can actually see +it. + +## The changelog + +`CHANGES` is the changelog. Not `CHANGELOG.md`. It is rendered as the +project's changelog page, and follows Django's release-notes shape: +deliverables get titles and prose, not bullets. + +**Release entry boilerplate.** Every release header is +`## gp-sphinx X.Y.Z (YYYY-MM-DD)`. The file opens with a +`## gp-sphinx X.Y.Z (unreleased)` block prefaced by a single +`` HTML comment — new release entries land +below the most recent released entry, never between the comment and the +unreleased header. + +**Open with a multi-sentence lead paragraph.** Plain prose, no italic. +Open with the version as sentence subject ("gp-sphinx X.Y.Z ships …") so +the lead is self-contained when excerpted. Two to four sentences telling +the reader what shipped and who cares — user-visible takeaways, not +internal mechanism. Cross-reference detail docs with `{ref}` to keep the +lead compact. + +**Lead paragraphs are release-time material — off-limits to branches and +PRs.** The unreleased entry carries no lead paragraph and no version +summary: sections only (`### Breaking changes`, `### What's new` +deliverables, `### Fixes`, …). Speaking for the release — what the +version "is", "ships", or "focuses on" — is presumptuous before its +scope is final; only the person cutting the release writes that, and +only when the user explicitly asks to release. Never write or edit a +lead from a feature branch, and never ask or imply that a release should +happen. + +**Each deliverable is a section, not a bullet.** Inside `### What's new`, +every distinct deliverable gets a `#### Deliverable title` heading naming +it in user vocabulary, followed by one to three prose paragraphs +explaining what shipped. Do not wrap a paragraph in `- ` — bullets are +for enumerable lists, not paragraph containers. Cross-link detail docs +("See {ref}`foo` for details.") so prose stays focused. + +**The deliverable test.** Before writing an entry, ask: "What's the +deliverable, in user vocabulary?" If you cannot answer in one sentence, +the entry is not ready. Mechanism — helper internals, byte counters, +schema-validation locations — belongs in PR descriptions and code +comments, not the changelog. + +**Fixed subheadings**, in this order when present: `### Breaking +changes`, `### Dependencies`, `### What's new`, `### Fixes`, +`### Documentation`, `### Development`. Dev tooling (helper scripts, +internal automation) lives under `### Development`. For breaking +changes, show the migration path with concrete inline code (a `# Before` +/ `# After` fenced block). Dependency floor bumps use the form +`` Minimum `pkg>=X.Y.Z` (was `>=X.Y.W`) ``. + +**PR refs `(#NN)`** sit at the end of each deliverable's prose body, not +in the `####` heading. + +**When bullets are appropriate.** Catch-all sections (`### Fixes`, +occasionally `### Documentation`) with three or more genuinely small +items use bullets — one line each, never paragraphs. If a bullet swells +past two lines, promote it to a `#### Title` heading with prose body. + +**Anti-patterns.** Fragile metrics that go stale silently — token +ceilings, third-party version pins, percent benchmarks, exact byte +counts. Describe the capability, not the math. Internal jargon: private +symbols (leading-underscore identifiers), algorithm names exposed for +the first time, backend scaffolding. Walls of text dressed up as +bullets. Buried breaking changes — give them their own subheading at the +top of the entry. + +**Always link autodoc'd APIs.** Any class, method, function, exception, +or attribute with its own rendered page is cited via the matching role — +never plain backticks. Doc pages without an explicit ref label use +`{doc}`. Plain backticks are correct for code syntax, env vars, +parameter names, and file paths that are not doc pages. See +[Sphinx and MyST conventions](#sphinx-and-myst-conventions) for the role +list. + +## Docstrings + +The prime directive: never restate the type. The annotation is the +source of truth; the docstring carries what the annotation cannot. + +This is documentation debt wearing a docstring: + + def get_project_name(config: dict[str, str]) -> str: + """Get the project name. + + Parameters + ---------- + config : dict[str, str] + The config. + + Returns + ------- + str + The name. + """ + +Document instead the dimensions the type system cannot encode: + +- **Mutation.** What it changes in place. +- **Ownership.** What the caller must close, release, or keep alive. +- **Ordering.** Whether results come back in a guaranteed order. +- **Timing.** What has finished by the time the call returns. +- **Failure.** Which exceptions are raised and what triggers each. +- **Idempotence.** Whether calling twice does anything the second time. +- **Concurrency.** Whether calls are coalesced, queued, or independent. +- **Units and ranges.** What a number means and what values are + accepted. +- **Boundary behaviour.** What zero, empty, and the maximum do. +- **Platform.** Behaviour that differs by Sphinx or docutils version — + see `_compat.py` for the floors this workspace already works around. +- **Security boundary.** What is executed, and what is only read — load + bearing wherever a directive introspects a user's own modules + (autodoc, argparse, fastmcp, pytest-fixtures extensions). + +The first sentence stands alone; tooling truncates there. PEP 257 +applies: triple double quotes, an imperative one-line summary ending in +a period, a blank line before any extended description. Do not repeat an +introspectable signature. + +NumPy docstring style is the one dialect this repository uses, enforced +by `ruff`'s `pydocstyle` convention rather than relitigated in review: + + """Short description of the function or class. + + Detailed description using reStructuredText format. + + Parameters + ---------- + param1 : type + Description of param1. + + Returns + ------- + type + Description of return value. + """ + +**Class-level names get exactly one description each.** Every name a +class declares that autodoc renders — `NamedTuple` fields, dataclass +fields including `InitVar`, `TypedDict` keys, `Enum` members, `ClassVar`s, +and plain constants — needs a description. The shape does not change the +rule. Three styles count, and `tests/docs/test_docstring_policy.py` +enforces them: a NumPy `Attributes` entry in the class docstring, a +docstring under the assignment, or a `#:` comment above it. Prefer +`Attributes`: + + class ToctreeSection(t.NamedTuple): + """One section of pages grouped by toctree caption. + + Attributes + ---------- + caption : str | None + Toctree caption, or ``None`` for an uncaptioned toctree. + docnames : list[str] + Docnames listed under the caption, in toctree order. + """ + +A `Parameters` section does **not** describe attributes — it documents +the initializer, and the attribute entries still render bare beneath it. +A field nobody describes reaches the reference as a bare name. A +`ClassVar` nobody describes is withheld from it entirely, so an +undescribed one is silently missing rather than visibly empty. + +## Sphinx and MyST conventions + +gp-sphinx owns `DEFAULT_EXTENSIONS` +(`packages/gp-sphinx/src/gp_sphinx/defaults.py`) — the extension list +every repository in the fleet inherits through `merge_sphinx_config()`. +This section is the authoring contract that list implies for `docs/` +pages here and, by inheritance, downstream. + +**MyST roles.** Class references use `{class}` (or the explicit +`{py:class}` this repository prefers on its own pages), methods +`{meth}`, functions `{func}`, exceptions `{exc}`, attributes `{attr}`, +modules `{py:mod}`, config values `{py:data}` or `{confval}`, internal +anchors `{ref}`, doc-path links `{doc}`. Use the most specific target +available; a Markdown link is correct only for something with no +autodoc destination — an external project or tool. + +**MyST parser extensions.** `DEFAULT_EXTENSIONS` turns on `colon_fence` +(the `:::` admonition and grid-card syntax used throughout `docs/`), +`substitution`, `replacements`, `strikethrough`, and `linkify`. Grid +cards (`sphinx_design`) are the standard way to present a set of +sibling links — see `docs/project/index.md` or any `docs/packages/*` +landing page for the pattern. + +**`{include}` does not carry relative links across a directory +boundary.** MyST rewrites an included file's relative links, but if the +included file lives outside the including page's directory tree, +Sphinx resolves the rewritten targets as internal cross-references and +emits dead anchors instead of following them out. `AGENTS.md` and +`CLAUDE.md` are also in `docs/conf.py`'s `exclude_patterns`, which rules +out including either directly. Where a docs page used to host content +that now lives in `.github/`, make it a short pointer page instead: keep +any `(label)=` anchor and the page title, then link the canonical file +on GitHub. + +**Generated surface documentation.** Config values, directives, and +roles that ship in this workspace are documented via `autoconfigvalues`, +`autodirective`, and `autorole` rather than transcribed into prose — +see [Generated pages](#generated-pages). + +## CSS and directive naming + +Every class, custom property, and MyST directive name a workspace +package adds lives under the `gp-sphinx-*` namespace — the naming +vocabulary downstream themes and doc authors read and depend on: + +- **Tier A (shared concepts)** — `gp-sphinx-` (`gp-sphinx-badge`, + `gp-sphinx-toolbar`). Used by multiple packages. +- **Tier B (package-owned)** — `gp-sphinx-__` BEM-style + (`gp-sphinx-fastmcp__safety-readonly`, + `gp-sphinx-pytest-fixtures__fixture-index`). +- **Modifiers** — axis-value pairs `---` + (`gp-sphinx-badge--size-xs`, `gp-sphinx-badge--type-function`). +- **Custom properties** mirror the class namespace: + `--gp-sphinx--`. Furo-owned variables (`--color-api-*`, + `--font-stack--*`) stay untouched. + +A package's own CSS must style every class its Python code emits. +Cross-package **reuse** of a shared class is fine; cross-package +**dependence** — a feature rendering correctly only because a sibling +package happens to be loaded — is not. A downstream user installing one +extension standalone must get the correct visual result. + +## Terminology and capitalization + +Pick the domain noun and keep it. If the code calls something a +`docname`, do not call it a "page path" in one paragraph and a "doc ID" +in the next. If the function is `merge_sphinx_config`, write "merge" +everywhere rather than alternating with "combine", "build", and +"assemble". + +Stable vocabulary is what makes search, deep links, and an agent's +retrieval work at all. + +Python and PyPI keep their own capitalisation. Distribution names are +written as they are published. + +Do not write counts into prose — how many packages this workspace has, +how many tests there are. They go stale silently and no reader needs +them. Counts that pin a fixture or guard an invariant are different, and +belong in code. + +## Markdown + +Prose wraps at 80 columns. Table rows, badge lines, and long links are +exempt, because breaking them harms rendering. A pull request or issue +body does not wrap at all: GitHub renders a single newline as a space in +a file and as a line break in a comment, so a wrapped comment body +arrives as ragged stubs. + +GitHub alert blocks — `> [!NOTE]`, `> [!WARNING]` — render as literal +text outside GitHub, so reserve them for at most one load-bearing +warning per document. Write the sentence so it carries the fact on its +own, and a renderer that drops the marker loses nothing. + +Do not use a local absolute path or an email address in anything +published. + +## Code blocks + +Code blocks are paste-and-run units: pasting one block runs exactly one +intended action. Executed examples are exempt — the test suite runs +them, nobody pastes them. + +- **One command per block.** Multiple steps may share a block only when + explicitly chained with `&&`, `;`, or `\` continuations — the chain is + then one logical command. +- **Explanations go in prose above the block**, never as `#` comments + inside it. +- **Command menus are per-command blocks with prose lead-ins**, not + tables. +- **Shell commands use the `console` tag with a `$ ` prefix.** This + separates interactive commands from scripts and enables prompt-aware + copy. +- **Split long commands with `\`** — one flag or flag+value pair per + indented continuation line, positional arguments last. + +Good — show the last ten commits as a graph: + +```console +$ git log \ + --max-count=10 \ + --graph \ + --oneline +``` + +Bad: + +```console +# Show the last ten commits as a graph +$ git log --max-count=10 --graph --oneline +``` + +## Commits + +``` +Scope(type[detail]): concise description + +why: Explanation of necessity or impact. + +what: +- Specific technical changes made +- Focused on a single topic +``` + +Keep the subject to 50 characters or fewer, excluding any trailing +`(#NN)` pull request reference, and wrap body lines at 72. Separate the +`why:` and `what:` blocks with a blank line. + +Routine maintenance commits drop the colon and take a capitalised +description, which is what distinguishes them at a glance in +`git log --oneline`: + +``` +py(deps[dev]) Bump dev packages +ai(rules[AGENTS]) Judge comments by three gates +``` + +Everything that changes behaviour keeps the colon. + +Common types: + +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting +- **ci**: Workflow and pipeline changes +- **py(deps)**: Dependencies +- **py(deps[dev])**: Dev dependencies +- **ai(rules[AGENTS])**: AI rule updates +- **ai(claude[rules])**: Claude Code rules (`CLAUDE.md`) +- **ai(claude[command])**: Claude Code command changes + +Example: + +``` +config(feat[merge]): Add deep-merge support for theme options + +why: Enable per-project theme overrides without replacing entire dict. + +what: +- Add deep_merge() helper for nested dict merging +- Update merge_sphinx_config() to deep-merge theme_options +- Add tests for nested override behavior +``` + +For a multi-line message, use a heredoc so the formatting survives: + +```console +$ git commit -m "$(cat <<'EOF' +Scope(feat[detail]): Concise description + +why: Explanation of the change. + +what: +- First change +- Second change +EOF +)" +``` + +### Release commits + +Never create tags. Never push tags. The owner handles tagging and tag +pushes, because a tag triggers the PyPI publish workflow. + +A release commit subject is plain and short: `Tag v`. The +detailed why and what go in the body. Do not use the +`Scope(type[detail]):` format for a release — it buries the lede. + +## Slop prevention + +Treat AI slop as review-hostile noise, not as proof that text or code is +wrong. The goal is to maximise information density. + +- **AI signatures.** No "Generated by", no conversational filler, no + unexplained emoji, no tool metadata. +- **Brittle references.** No hard-coded line numbers, fragile file + counts, dated "as of" claims, bare SHAs, or local absolute paths — + unless they are strict evidentiary artefacts such as a benchmark log. +- **Diff narration.** Do not restate what moved, was renamed, or was + removed in anything the reader holds alongside the diff: code, + docstrings, README, `CHANGES`, or a pull request description. The diff + and the commit message already carry it. +- **Branch-internal narrative.** Do not mention intermediate states, + abandoned approaches, or "no longer" behaviour unless users of a + published release actually experienced the old state — did users of + the most recently published release ever experience this old name, + old behaviour, or bug? If not, it belongs in the commit message, not + the artefact. +- **Low-value scaffolding.** No ownerless TODOs, unused + future-proofing, debug artefacts, or defensive wrappers around + failure modes nothing can reach. +- **Prose inflation.** The diction table under [Voice](#voice) governs; + replace an inflated word with a concrete description of behaviour, + constraints, or trade-offs. +- **Coded labels.** Write rules and findings as plain imperatives. No + `[R1]`, `Option B`, or any index a reader has to decode. + +Preserve the "why". Never delete a comment documenting an invariant, a +protocol constraint, a platform quirk, or an upstream workaround — those +are the facts [Source comments](#source-comments) keeps, and every other +comment is judged by it. Preserve exact counts, dates, and SHAs when they +serve as evidence in benchmark results, `CHANGES` entries, or lockfiles. + +### Durable source links + +Link to a pinned revision, never to trunk, when citing source in prose an +agent or contributor will read later — a `blob/main/…` link rots +silently as the file moves and lines shift while the link keeps +resolving, landing on unrelated code. + +- Prefer a release tag (`blob/v0.1.0a37/…`). Most durable, and it tells + the reader which released version the claim held for. +- Otherwise use a 7-character commit SHA (`blob/9a29b1a/…`) reachable + from `main`. Use when there is no tag or the claim is about + unreleased code. Never a PR-head SHA — it can be rebased or + garbage-collected. +- Reserve `blob/main/…` for living documents meant to always show the + latest state, such as this file or `CONTRIBUTING.md`. +- Line anchors (`#L120-L145`) are only safe on a pinned ref. + +## Source comments + +A comment ships only if it passes all three gates. Fail any: delete or +rewrite. Borderline: delete — borderline means the information is +reconstructible, which is what makes deletion cheap. + +**Loss.** Three years from now, would losing this cost a maintainer real +time rediscovering intent, an invariant, a constraint, or a failure mode +the code and tests do not already make obvious? + +**Elite.** Would SQLite, Redis, the Go standard library, or CPython +write this comment, at this length? Those projects state the constraint +and stop. They do not argue with an imagined objector. + +**Upkeep.** Will it stay true without maintenance? A comment that +hand-syncs a value the code owns — a count, an offset, a line reference, +a duplicated constant — is false the first time that value moves. + +### Ceiling + +One or two lines. A comment reaching four is either carrying several +facts, in which case split it, or arguing, in which case cut it to the +fact. + +Rationale, alternatives weighed, and the story of how the code got here +belong in the commit message: timestamped, attached to the exact diff, +and free to maintain. + +A comment often holds both a constraint and the deliberation that found +it. Keep the constraint, cut the deliberation. "Runs at most once per +second" survives; "this is the right trade for now" does not. + +### Keep + +- Why over how: upstream quirks, protocol and compatibility constraints, + performance tradeoffs still part of the contract. +- Invariants, preconditions, ordering, lifetime, and concurrency + requirements that types and tests cannot express. +- Code that looks wrong but is not, so a later cleanup does not + reintroduce the bug. +- A high-level sketch of an algorithm whose local operations do not + reveal the whole. + +### Delete + +- Narration of the next lines; code translated into English. +- Restated names, types, defaults, or control flow. +- Values duplicated from the code and hand-synced. +- Justification, hedging, or apology for a choice. +- Speculation about future requirements. +- History version control already holds, including commented-out code. +- Ticket and issue numbers. They say nothing to a reader without tracker + access, and they rot when the tracker moves. Unfinished work goes in + the tracker, not the source. +- Transient observations — "currently", "for now", "the latest + release" — that go stale with no nearby edit. + +### The upkeep gate in practice + +It reaches values that track our own code. It does not reach frozen +external facts. + +Bad (Delete): + +```python +# There are 321 tests to complete for servers. +``` + +Good (Keep): + +```python +# Sphinx < 8.1 has no typed env.domains accessors, so this branch +# falls back to env.get_domain("py"). +``` + +### Documentation exception + +Doctests, minimal usage examples, and `Parameters`, `Returns`, and +`Attributes` entries on public API are exempt from the loss gate — they +serve the caller, not the maintainer. They are exempt from nothing else. +Ceiling: a good man page entry. Autodoc ships every field whether or not +you describe it, and a doctest that runs is also a test, so an +undescribed field or an unrun example is a documentation gap, not a +slop violation. diff --git a/.github/contributing.md b/.github/contributing.md deleted file mode 100644 index c0eddac2..00000000 --- a/.github/contributing.md +++ /dev/null @@ -1,27 +0,0 @@ -# Contributing - -When contributing to this repository, please first discuss the change you wish to make via issue, -email, or any other method with the maintainers of this repository before making a change. - -See [developing](../docs/developing.md) for environment setup and [AGENTS.md](../AGENTS.md) for -detailed coding standards. - -## Pull Request Process - -1. **Format and lint**: `uv run ruff format .` then `uv run ruff check . --fix --show-fixes` -2. **Type check**: `uv run mypy` -3. **Test**: `uv run pytest` — all tests must pass before submitting -4. **Document**: Update docs if your change affects the public interface -5. You may merge the Pull Request once you have the sign-off of one other developer. If you - do not have permission to do that, you may request a reviewer to merge it for you. - -## Decorum - -- Participants will be tolerant of opposing views. -- Participants must ensure that their language and actions are free of personal - attacks and disparaging personal remarks. -- When interpreting the words and actions of others, participants should always - assume good intentions. -- Behaviour which can be reasonably considered harassment will not be tolerated. - -Based on [Ruby's Community Conduct Guideline](https://www.ruby-lang.org/en/conduct/) diff --git a/AGENTS.md b/AGENTS.md index 5ae360b0..01a4a724 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,985 +1,66 @@ # AGENTS.md -This file provides guidance to AI agents (including Claude Code, Cursor, and other LLM-powered tools) when working with code in this repository. - -## CRITICAL REQUIREMENTS - -### Test Success -- ALL tests MUST pass for code to be considered complete and working -- Never describe code as "working as expected" if there are ANY failing tests -- Even if specific feature tests pass, failing tests elsewhere indicate broken functionality -- Changes that break existing tests must be fixed before considering implementation complete -- A successful implementation must pass linting, type checking, AND all existing tests - -## Project Overview - -gp-sphinx (`gp_sphinx`) is a shared Sphinx documentation platform for Python projects. It consolidates duplicated docs configuration, extensions, theme settings, and workarounds from 14+ repositories into a single reusable package. - -Key features: -- `merge_sphinx_config()` API for shared defaults with per-project overrides -- Shared extension list (autodoc, intersphinx, myst_parser, sphinx_design, etc.) -- Shared Furo theme configuration (CSS variables, fonts, sidebar, footer) -- Bundled workarounds (tabs.js removal, spa-nav.js injection) -- Shared font configuration (IBM Plex via Fontsource) - -## Development Environment - -This project uses: -- Python 3.10+ -- Sphinx 8.1+ (required for the typed `env.domains._domain` accessors) -- [uv](https://github.com/astral-sh/uv) for dependency management -- [ruff](https://github.com/astral-sh/ruff) for linting and formatting -- [mypy](https://github.com/python/mypy) for type checking -- [pytest](https://docs.pytest.org/) for testing - - [pytest-watcher](https://github.com/olzhasar/pytest-watcher) for continuous testing - -## Common Commands - -### Setting Up Environment - -```bash -# Install dependencies -uv sync --all-packages - -# Install with development dependencies -uv sync --all-packages --all-extras --group dev -``` - -### Running Tests - -```bash -# Run all tests -just test -# or directly with pytest -uv run pytest - -# Run a single test file -uv run pytest tests/test_config.py - -# Run a specific test -uv run pytest tests/test_config.py::test_merge_sphinx_config - -# Run tests with test watcher -just start -# or -uv run ptw . - -# Run tests with doctests -uv run ptw . --now --doctest-modules -``` - -### Linting and Type Checking - -```bash -# Run ruff for linting -just ruff -# or directly -uv run ruff check . - -# Format code with ruff -just ruff-format -# or directly -uv run ruff format . - -# Run ruff linting with auto-fixes -uv run ruff check . --fix --show-fixes - -# Run mypy for type checking -just mypy -# or directly -uv run mypy src tests - -# Watch mode for linting (using entr) -just watch-ruff -just watch-mypy -``` - -### Development Workflow - -Follow this workflow for code changes: - -1. **Format First**: `uv run ruff format .` -2. **Run Tests**: `uv run pytest` -3. **Run Linting**: `uv run ruff check . --fix --show-fixes` -4. **Check Types**: `uv run mypy` -5. **Verify Tests Again**: `uv run pytest` - -### Documentation - -```bash -# Build documentation -just build-docs - -# Start documentation server with auto-reload -just start-docs -``` - -## Code Architecture - -gp-sphinx provides a shared configuration layer for Sphinx documentation: - -``` -gp_sphinx/ - __init__.py # Package entry point - config.py # merge_sphinx_config() and config building logic - defaults.py # Default extensions, theme options, MyST config, fonts - assets/ # Shared JS/CSS (spa-nav.js, workarounds) - _compat.py # Sphinx/docutils version compatibility -``` - -### Core Modules - -1. **Config** (`src/gp_sphinx/config.py`) - - `merge_sphinx_config()` API for building complete Sphinx config - - Deep-merge support for theme options - - Per-project override mechanism - -2. **Defaults** (`src/gp_sphinx/defaults.py`) - - `DEFAULT_EXTENSIONS` list - - `DEFAULT_THEME_OPTIONS` dict - - `DEFAULT_MYST_EXTENSIONS` list - - `DEFAULT_FONT_FAMILIES` dict - - Shared sidebar configuration - -### Package CSS self-containment - -A workspace package's own CSS must style every class its Python code -emits. If a directive appends `SAB.X` (or any gp-sphinx-* class) to a -node, the package's own CSS file carries a rule targeting `SAB.X`. -Cross-package **reuse** of a shared class (e.g., `gp-sphinx-badge` -styled in `sphinx-ux-badges`) is fine; cross-package **dependence** — -where your feature only renders correctly because a sibling package -happens to be loaded — is not. A downstream user installing a single -extension standalone must get the correct visual result. - -## Testing Strategy - -All tests are plain functions (`def test_*`). No `class TestFoo:` groupings. Every test -function and every `NamedTuple` fixture class must be fully type-annotated; mypy runs as -part of CI. - -Run continuously while developing: - -```console -$ uv run ptw . -``` - -Include doctests: - -```console -$ uv run ptw . --now --doctest-modules -``` - -### Test Level Hierarchy - -Pick the **lightest** level that exercises the behavior. Never reach for a full Sphinx -build when a docutils node test suffices — an integration build takes 2–10 s, a node -test runs in microseconds. - -| Level | When to use | -|---|---| -| **Pure unit** | Transforming strings, dicts, dataclasses — no nodes, no Sphinx | -| **Docutils tree unit** | Testing transforms/visitors/renderers by constructing `nodes.*` directly | -| **Snapshot unit** | Same as docutils tree, but output is large or complex — assert via `snapshot_doctree` | -| **Sphinx integration** (`@pytest.mark.integration`) | **Any test that constructs a `Sphinx` app.** `build_shared_sphinx_result` / `build_isolated_sphinx_result` with any builder — *including `buildername="dummy"`* — counts. If the test touches `env.domains.*`, walks a built doctree, or asserts on `result.warnings`, it is integration. | - -### Type Annotations (required everywhere) - -Every test function must annotate all parameters and the return type: - -```python -def test_something(value: str, expected: int) -> None: - assert compute(value) == expected -``` - -Every `NamedTuple` fixture class must annotate all fields. - -### NamedTuple Parametrization - -Use `t.NamedTuple` for any parametrized test with three or more inputs. Two wiring -styles are in use — pick whichever reads more clearly for the case at hand. - -**Style A — unpack all fields** (dominant; used in `test_unit.py`, lexer tests, etc.) - -Each field becomes a typed parameter in the test function, which makes the signature -self-documenting: - -```python -import typing as t - -import pytest - - -class FooFixture(t.NamedTuple): - """Test case for foo().""" - - test_id: str # always the first field - input: str - expected: str - - -_FOO_FIXTURES: list[FooFixture] = [ - FooFixture(test_id="basic", input="a", expected="A"), - FooFixture(test_id="empty", input="", expected=""), -] - - -@pytest.mark.parametrize( - list(FooFixture._fields), - _FOO_FIXTURES, - ids=[f.test_id for f in _FOO_FIXTURES], -) -def test_foo(test_id: str, input: str, expected: str) -> None: - """foo() uppercases its input.""" - assert foo(input) == expected -``` - -**Style B — pass whole struct as `case`** (used in `test_directives.py`, -`test_nodes.py`, when the struct is reused in assertion messages or has many fields): - -```python -@pytest.mark.parametrize( - "case", - _FOO_FIXTURES, - ids=lambda c: c.test_id, -) -def test_foo(case: FooFixture) -> None: - """foo() uppercases its input.""" - assert foo(case.input) == case.expected -``` - -Naming conventions: - -- `test_id: str` is **always the first field** -- Fixture list: `_FOO_FIXTURES` (module-private, all-caps) -- Fixture class: `FooFixture` or `FooCase` — never `TestFoo` - -### Docutils Tree Unit Tests (no Sphinx build) - -Test transforms, visitors, and renderers by constructing `docutils.nodes` and -`sphinx.addnodes` objects directly. Follow the pattern in -`tests/ext/layout/test_transforms.py`: - -```python -from docutils import nodes -from sphinx import addnodes - - -def _make_desc( - *content_children: nodes.Node, - domain: str = "py", - objtype: str = "function", -) -> addnodes.desc: - desc = addnodes.desc(domain=domain, objtype=objtype) - desc += addnodes.desc_signature() - content = addnodes.desc_content() - for child in content_children: - content += child - desc += content - return desc - - -def test_transform_wraps_content_runs() -> None: - """_wrap_content_runs groups consecutive content nodes.""" - desc = _make_desc(nodes.paragraph("", "summary"), nodes.field_list()) - _wrap_content_runs(desc) - assert any(isinstance(n, ContentGroup) for n in desc[1]) -``` - -- Put `_make_*()` builder helpers at the top of the test file, near the tests that use - them. -- Never import `sphinx.application.Sphinx` in a pure tree test. -- Use `nodes.document()` (with a minimal `settings` object from - `docutils.frontend.OptionParser`) only when the transform requires a real document - root. - -### Snapshot Tests (syrupy) - -Use when the expected output is too large or fragile to inline. The three fixtures -(from `tests/_snapshots.py`, loaded automatically via `pytest_plugins`) normalize their -inputs before asserting so that build-path churn and docutils version noise do not cause -spurious failures: - -- `snapshot_doctree(doctree, *, name=None, roots=())` — normalizes a `nodes.Node` -- `snapshot_html_fragment(fragment, *, name=None, roots=())` — strips ANSI, normalizes whitespace -- `snapshot_warnings(warnings, *, name=None, roots=())` — strips noise lines and ANSI codes - -```python -import typing as t - - -def test_layout_render( - snapshot_doctree: t.Callable[..., None], -) -> None: - """Transform produces a stable doctree.""" - desc = _make_large_signature_desc() - on_doctree_resolved(desc) - snapshot_doctree(desc) -``` - -Update stored snapshots after intentional output changes: - -```console -$ uv run pytest --snapshot-update -``` - -### Integration Tests (full Sphinx build) - -Use the harness in `tests/_sphinx_scenarios.py`. The key types and helpers: - -- `SphinxScenario(files=(...), confoverrides={}, buildername="html")` — describes the - synthetic project; `buildername` defaults to `"html"`, override for text builds -- `ScenarioFile(relative_path, contents, substitute_srcdir=False)` — one source file -- `build_shared_sphinx_result(cache_root, scenario, *, purge_modules=())` — builds - once per content-hash digest; `purge_modules` removes named modules from `sys.modules` - before the initial build to prevent stale import cache — required when scenario files - inject a Python module into `sys.path` -- `build_isolated_sphinx_result(cache_root, tmp_path, scenario, *, purge_modules=())` - — fresh build per test, for mutating assertions -- `derive_sphinx_scenario_cache_root(tmp_path)` — derives a stable per-session cache - root from any `tmp_path` by using its parent directory -- `copy_scenario_tree(cache_root, scenario, destination_root)` — materialize source - files into a directory without running a Sphinx build -- `get_doctree(result, docname, post_transforms=False)` — deep-copied doctree from - the built environment -- `read_output(result, filename)` — reads a built output file as a string - -Always use a **module-scoped** (or session-scoped) fixture for the build — never -function-scoped — so the expensive Sphinx build is shared across all tests in the -module. Follow the pattern in `tests/ext/typehints_gp/test_integration.py`: - -```python -import textwrap - -import pytest - -from tests._sphinx_scenarios import ( - SCENARIO_SRCDIR_TOKEN, - ScenarioFile, - SharedSphinxResult, - SphinxScenario, - build_shared_sphinx_result, - read_output, -) - -_CONF_PY = textwrap.dedent( - """\ - import sys - sys.path.insert(0, r"__SCENARIO_SRCDIR__") - extensions = ["sphinx.ext.autodoc", "my_extension"] - """ -) - -_INDEX_RST = textwrap.dedent( - """\ - Demo - ==== - - .. autofunction:: my_module.my_function - """ -) - - -@pytest.fixture(scope="module") -def my_html_result( - tmp_path_factory: pytest.TempPathFactory, -) -> SharedSphinxResult: - """Build a minimal Sphinx project using my_extension.""" - cache_root = tmp_path_factory.mktemp("my-ext-html") - scenario = SphinxScenario( - files=( - ScenarioFile("index.rst", _INDEX_RST), - ScenarioFile( - "conf.py", - _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), - substitute_srcdir=True, - ), - ), - ) - return build_shared_sphinx_result( - cache_root, - scenario, - purge_modules=("my_module", "my_extension"), - ) - - -@pytest.mark.integration -def test_my_feature_appears_in_html(my_html_result: SharedSphinxResult) -> None: - """Extension renders the expected markup.""" - html = read_output(my_html_result, "index.html") - assert "my-feature" in html -``` - -Rules: -- Always mark with `@pytest.mark.integration` -- Always `scope="module"` or `scope="session"` on the build fixture — never - `scope="function"` -- Use `textwrap.dedent("""...""")` for inline source strings -- Use `SCENARIO_SRCDIR_TOKEN` + `substitute_srcdir=True` for `sys.path` injection in - `conf.py` - -> **See also:** `notes/test-analysis.md` — profiling data, 9.5x speedup rationale, -> and the per-package migration history for the shared autodoc stack. - -### Available Fixtures Reference - -| Fixture | Source | When to use | -|---|---|---| -| `tmp_path` | pytest built-in | Per-test temp directory | -| `tmp_path_factory` | pytest built-in | Session/module fixtures that create temp dirs | -| `monkeypatch` | pytest built-in | Env vars, module attributes, `sys.modules` patching | -| `caplog` | pytest built-in | Log assertions; use `caplog.records`, not `caplog.text` | -| `snapshot_doctree` | `tests/_snapshots.py` | Normalized doctree snapshot assertion | -| `snapshot_html_fragment` | `tests/_snapshots.py` | Normalized HTML string snapshot assertion | -| `snapshot_warnings` | `tests/_snapshots.py` | Normalized Sphinx warning snapshot assertion | -| `spf_suite_root`, `spf_doctree_root`, `spf_html_root` | `tests/ext/pytest_fixtures/conftest.py` | Session roots for sphinx-pytest-fixture ext tests | -| `simple_parser`, `parser_with_groups`, … | `tests/ext/argparse/conftest.py` | `ArgumentParser` permutations for argparse tests | - -### Anti-Patterns - -- **No `class TestFoo:` groupings** — use descriptive function names and file - organization instead -- **No `unittest.mock.patch`** — use `monkeypatch` -- **No `tempfile.mkdtemp()`** — use `tmp_path` -- **No `Sphinx()` instantiation in a unit test** — build docutils nodes directly -- **No unannotated test functions** — every parameter and `-> None` must be typed -- **No `# doctest: +SKIP`** in module doctests (see Doctests section) -- **No inline tuples in `parametrize`** when there are three or more fields — use - `NamedTuple` -- **No function-scoped Sphinx build fixtures** — always module- or session-scoped - -## CSS Standards - -All CSS classes, custom properties, and MyST directive names added by a -workspace package live under the `gp-sphinx-*` namespace: - -- **Tier A (shared concepts)** — `gp-sphinx-` (e.g., - `gp-sphinx-badge`, `gp-sphinx-toolbar`). Used by multiple packages. -- **Tier B (package-owned)** — `gp-sphinx-__` BEM-style - (e.g., `gp-sphinx-fastmcp__safety-readonly`, - `gp-sphinx-pytest-fixtures__fixture-index`). -- **Modifiers** — axis-value pairs `---` (e.g., - `gp-sphinx-badge--size-xs`, `gp-sphinx-badge--type-function`). -- **Custom properties** — mirror the class namespace: - `--gp-sphinx--`. Furo-owned variables (`--color-api-*`, - `--font-stack--*`, etc.) stay untouched. -- **Specificity** — prefer chained class selectors - (`.gp-sphinx-badge.gp-sphinx-badge--dense`); keep selectors at 0,3,0 - max. - -## Coding Standards - -Key highlights: - -### Imports - -- **Use namespace imports for standard library modules**: `import enum` instead of `from enum import Enum` - - **Exception**: `dataclasses` module may use `from dataclasses import dataclass, field` for cleaner decorator syntax - - This rule applies to Python standard library only; third-party packages may use `from X import Y` -- **For typing**, use `import typing as t` and access via namespace: `t.NamedTuple`, etc. -- **Use `from __future__ import annotations`** at the top of all Python files - -### Sphinx domain access - -Prefer the typed accessors on `env.domains` over `env.get_domain()`: - -- `env.domains.standard_domain` — not `env.get_domain("std")` -- `env.domains.python_domain` — not `env.get_domain("py")` -- Similarly: `c_domain`, `cpp_domain`, `javascript_domain`, - `restructuredtext_domain`, `changeset_domain`, `citation_domain`, - `index_domain`, `math_domain` - -The typed accessors return the concrete domain subclass -(`StandardDomain`, `PythonDomain`, etc.), so mypy sees subclass-specific -attributes (`progoptions`, `add_program_option`, `data["objects"]`, …) -without `t.cast` or `# type: ignore`. The accessors were added in Sphinx -8.1 (`_DomainsContainer`), which is the workspace floor. - -### Docstrings - -Follow NumPy docstring style for all functions and methods: - -```python -"""Short description of the function or class. - -Detailed description using reStructuredText format. - -Parameters ----------- -param1 : type - Description of param1 -param2 : type - Description of param2 - -Returns -------- -type - Description of return value -""" -``` - -**Class-level names** — every name a class declares that autodoc renders gets -exactly one description. That covers `NamedTuple` fields, dataclass fields -including `InitVar`, `TypedDict` keys, `Enum` members, `ClassVar`s, and plain -constants. The shape does not change the rule. - -Three styles count, and `tests/docs/test_docstring_policy.py` enforces them: a -NumPy `Attributes` entry in the class docstring, a docstring under the -assignment, or a `#:` comment above it. Prefer `Attributes`: - -```python -class ToctreeSection(t.NamedTuple): - """One section of pages grouped by toctree caption. - - Attributes - ---------- - caption : str | None - Toctree caption, or ``None`` for an uncaptioned toctree. - docnames : list[str] - Docnames listed under the caption, in toctree order. - """ -``` - -A `Parameters` section does **not** describe attributes — it documents the -initializer, and the attribute entries still render bare beneath it. - -A field nobody describes reaches the reference as a bare name. A `ClassVar` -nobody describes is withheld from it entirely, so an undescribed one is -silently missing rather than visibly empty. - -### Doctests - -**All functions and methods MUST have working doctests.** Doctests serve as both documentation and tests. - -**CRITICAL RULES:** -- Doctests MUST actually execute - never comment out function calls or similar -- Doctests MUST NOT be converted to `.. code-block::` as a workaround (code-blocks don't run) -- If you cannot create a working doctest, **STOP and ask for help** - -**Available tools for doctests:** -- `doctest_namespace` fixtures (from conftest.py): `tmp_path` -- Ellipsis for variable output: `# doctest: +ELLIPSIS` -- Update `conftest.py` to add new fixtures to `doctest_namespace` - -**`# doctest: +SKIP` is NOT permitted** - it's just another workaround that doesn't test anything. - -**When output varies, use ellipsis:** -```python ->>> result = merge_sphinx_config(project="test", version="1.0", copyright="2026") ->>> result["project"] -'test' ->>> len(result["extensions"]) > 10 # doctest: +ELLIPSIS -True -``` - -### Logging Standards - -These rules guide future logging changes; existing code may not yet conform. - -#### Logger setup - -- Use `logging.getLogger(__name__)` in every module -- Add `NullHandler` in library `__init__.py` files -- Never configure handlers, levels, or formatters in library code -- that's the application's job - -#### Lazy formatting - -`logger.debug("msg %s", val)` not f-strings. Two rationales: -- Deferred string interpolation: skipped entirely when level is filtered -- Aggregator message template grouping: `"Running %s"` is one signature grouped x10,000; f-strings make each line unique - -When computing `val` itself is expensive, guard with `if logger.isEnabledFor(logging.DEBUG)`. - -#### Log levels - -| Level | Use for | Examples | -|-------|---------|----------| -| `DEBUG` | Internal mechanics | Config merge steps, extension resolution | -| `INFO` | User-visible operations | Config loaded, extensions resolved | -| `WARNING` | Recoverable issues, deprecation | Unknown extension, deprecated option | -| `ERROR` | Failures that stop an operation | Invalid config, missing dependency | - -#### Message style - -- Lowercase, past tense for events: `"config merged"`, `"extension resolved"` -- No trailing punctuation -- Keep messages short; put details in `extra`, not the message string - -#### Exception logging - -- Use `logger.exception()` only inside `except` blocks when you are **not** re-raising -- Use `logger.error(..., exc_info=True)` when you need the traceback outside an `except` block -- Avoid `logger.exception()` followed by `raise` -- this duplicates the traceback - -#### Testing logs - -Assert on `caplog.records` attributes, not string matching on `caplog.text`: -- Scope capture: `caplog.at_level(logging.DEBUG, logger="gp_sphinx.config")` -- Filter records rather than index by position -- `caplog.record_tuples` cannot access extra fields -- always use `caplog.records` - -#### Avoid - -- f-strings/`.format()` in log calls -- Catch-log-reraise without adding new context -- `print()` for diagnostics -- Logging secret env var values (log key names only) - -### Git Commit Standards - -Format commit messages as: -``` -Scope(type[detail]): concise description - -why: Explanation of necessity or impact. - -what: -- Specific technical changes made -- Focused on a single topic -``` - -Keep the subject ≤50 chars (excluding any trailing `(#NN)` PR ref); wrap -body lines at ≤72 chars. Separate the `why:` and `what:` blocks with a -blank line. - -Common commit types: -- **feat**: New features or enhancements -- **fix**: Bug fixes -- **refactor**: Code restructuring without functional change -- **docs**: Documentation updates -- **chore**: Maintenance (dependencies, tooling, config) -- **test**: Test-related updates -- **style**: Code style and formatting -- **py(deps)**: Dependencies -- **py(deps[dev])**: Dev Dependencies -- **ai(rules[AGENTS])**: AI rule updates -- **ai(claude[rules])**: Claude Code rules (CLAUDE.md) -- **ai(claude[command])**: Claude Code command changes - -Example: -``` -config(feat[merge]): Add deep-merge support for theme options - -why: Enable per-project theme overrides without replacing entire dict - -what: -- Add deep_merge() helper for nested dict merging -- Update merge_sphinx_config() to deep-merge theme_options -- Add tests for nested override behavior -``` -#### Release commits - -Never create tags. Never push tags. The user handles tagging and tag -pushes (tags trigger the CI publish workflow). - -Release commit subjects are plain and short: `Tag v`. Put -the detailed why/what in the commit body. Don't use the -`Scope(type[detail]):` format for releases — don't bury the lede. - -For multi-line commits, use heredoc to preserve formatting: -```bash -git commit -m "$(cat <<'EOF' -feat(Component[method]) add feature description - -why: Explanation of the change. - -what: -- First change -- Second change -EOF -)" -``` - -## Documentation Standards - -### Code Blocks - -Code blocks are paste-and-run units: pasting one block runs exactly one -intended action. Doctests and other executed examples are exempt — the test -suite runs them, nobody pastes them. - -- **One command per block.** Multiple steps may share a block only when - explicitly chained with `&&`, `;`, or `\` continuations — the chain is - then one logical command. -- **Explanations go in prose above the block**, never as `#` comments inside it. -- **Command menus are per-command blocks with prose lead-ins**, not tables. -- **Shell commands use the `console` tag with a `$ ` prefix.** This separates - interactive commands from scripts and enables prompt-aware copy. -- **Split long commands with `\`** — one flag or flag+value pair per indented - continuation line, positional arguments last. - -Good: - -Show the last ten commits as a graph: - -```console -$ git log \ - --max-count=10 \ - --graph \ - --oneline -``` - -Bad: - -```console -# Show the last ten commits as a graph -$ git log --max-count=10 --graph --oneline -``` - -### Changelog Conventions - -These rules apply when authoring entries in `CHANGES`, which is rendered as the Sphinx changelog page. Modeled on Django's release-notes shape — deliverables get titles and prose, not bullets. - -**Release entry boilerplate.** Every release header is `## gp-sphinx X.Y.Z (YYYY-MM-DD)`. The file opens with a `## gp-sphinx X.Y.Z (unreleased)` block prefaced by a single `` HTML comment — new release entries land below the most recent released entry, never between the comment and the unreleased header. - -**Open with a multi-sentence lead paragraph.** Plain prose, no italic. Open with the version as sentence subject (*"gp-sphinx X.Y.Z ships …"*) so the lead is self-contained when excerpted. Two to four sentences telling the reader what shipped and who cares — user-visible takeaways, not internal mechanism. Cross-reference detail docs with `{ref}` to keep the lead compact. - -**Lead paragraphs are release-time material — off-limits to branches and PRs.** The unreleased entry carries no lead paragraph and no version summary: sections only (`### Breaking changes`, `### What's new` deliverables, `### Fixes`, …). Speaking for the release — what the version "is", "ships", or "focuses on" — is presumptuous before its scope is final; only the person cutting the release writes that, and only when the user explicitly asks to release. Never write or edit a lead from a feature branch, and never ask or imply that a release should happen. - -**Each deliverable is a section, not a bullet.** Inside `### What's new`, every distinct deliverable gets a `#### Deliverable title` heading naming it in user vocabulary, followed by 1-3 prose paragraphs explaining what shipped. Don't wrap a paragraph in `- ` — bullets are for enumerable lists, not paragraph containers. Cross-link detail docs (`See {ref}\`foo\` for details.`) so prose stays focused. - -**The deliverable test.** Before writing an entry, ask: "What's the deliverable, in user vocabulary?" If you can't answer in one sentence, the entry isn't ready. Mechanism (helper internals, byte counters, schema-validation locations) belongs in PR descriptions and code comments, not the changelog. - -**Fixed subheadings**, in this order when present: `### Breaking changes`, `### Dependencies`, `### What's new`, `### Fixes`, `### Documentation`, `### Development`. Dev tooling (helper scripts, internal automation) lives under `### Development`. For breaking changes, show the migration path with concrete inline code (e.g. a `# Before` / `# After` fenced code block). Dependency floor bumps use the form ``Minimum `pkg>=X.Y.Z` (was `>=X.Y.W`)``. - -**PR refs `(#NN)`** sit at the end of each deliverable's prose body, not in the `####` heading. - -**When bullets are appropriate.** Catch-all sections (`### Fixes`, occasionally `### Documentation`) with 3+ genuinely small items use bullets — one line each, never paragraphs. If a bullet swells past two lines, promote it to a `#### Title` heading with prose body. - -**Anti-patterns.** - -- Fragile metrics: token ceilings, third-party version pins, percent benchmarks, exact byte counts. Describe the *capability*, not the math. -- Internal jargon: private symbols (leading-underscore identifiers), algorithm names exposed for the first time, backend scaffolding. -- Walls of text dressed up as bullets. -- Buried breaking changes — they get their own subheading at the top of the entry. - -**Always link autodoc'd APIs.** Any class, method, function, exception, or attribute that has its own rendered page must be cited via the appropriate role (`{class}`, `{meth}`, `{func}`, `{exc}`, `{attr}`) — never with plain backticks. Doc pages without explicit ref labels use `{doc}`. Plain backticks are correct for code syntax, env vars, parameter names, and file paths that aren't doc pages — anything without an autodoc destination. - -**MyST roles.** Class references use `{class}`, methods use `{meth}`, functions use `{func}`, exceptions use `{exc}`, attributes use `{attr}`, internal anchors use `{ref}`, doc-path links use `{doc}`. - -**Summarization style.** When a user asks "what changed in the latest version?" or similar, lead with the entry's lead paragraph (paraphrased if needed), followed by each `####` deliverable heading under `### What's new` with a one-sentence summary. Cite `(#NN)` only if the user asks for source links. Don't invent versions, dates, or numbers not present in `CHANGES`. Don't quote line numbers or file offsets — those shift as the file evolves. - -## Debugging Tips - -When stuck in debugging loops: - -1. **Pause and acknowledge the loop** -2. **Minimize to MVP**: Remove all debugging cruft and experimental code -3. **Document the issue** comprehensively for a fresh approach -4. **Format for portability** (using quadruple backticks) - -## Comments earn their maintenance cost - -A comment ships only if it passes all three gates. Fail any: delete or rewrite. -Borderline: delete — borderline means the information is reconstructible, which -is what makes deletion cheap. - -**Loss.** Three years from now, would losing this cost a maintainer real time -rediscovering intent, an invariant, a constraint, or a failure mode the code and -tests do not already make obvious? - -**Elite.** Would SQLite, Redis, the Go standard library, or CPython write this -comment, at this length? Those projects state the constraint and stop. They do -not argue with an imagined objector. - -**Upkeep.** Will it stay true without maintenance? A comment that hand-syncs a -value the code owns — a count, an offset, a line reference, a duplicated -constant — is false the first time that value moves. - -### Ceiling - -One or two lines. A comment reaching four is either carrying several facts, in -which case split it, or arguing, in which case cut it to the fact. - -Rationale, alternatives weighed, and the story of how the code got here belong -in the commit message: timestamped, attached to the exact diff, and free to -maintain. - -A comment often holds both a constraint and the deliberation that found it. Keep -the constraint, cut the deliberation. "Runs at most once per second" survives; -"this is the right trade for now" does not. - -### Keep - -- Why over how: upstream quirks, protocol and compatibility constraints, - performance tradeoffs still part of the contract. -- Invariants, preconditions, ordering, lifetime, and concurrency requirements - that types and tests cannot express. -- Code that looks wrong but is not, so a later cleanup does not reintroduce the - bug. -- A high-level sketch of an algorithm whose local operations do not reveal the - whole. - -### Delete - -- Narration of the next lines; code translated into English. -- Restated names, types, defaults, or control flow. -- Values duplicated from the code and hand-synced. -- Justification, hedging, or apology for a choice. -- Speculation about future requirements. -- History version control already holds, including commented-out code. -- Ticket and issue numbers. They say nothing to a reader without tracker access, - and they rot when the tracker moves. Unfinished work goes in the tracker, not - the source. -- Transient observations — "currently", "for now", "the latest release" — - that go stale with no nearby edit. - -### The upkeep gate in practice - -It reaches values that track our own code. It does not reach frozen external -facts. - -Bad (Delete): - -```python -# There are 321 tests to complete for servers. -``` - -Good (Keep): - -```python -# CPython < 3.11 has no ExceptionGroup, so this branch stays. -``` - -### Documentation exception - -Doctests, minimal usage examples, and param, return, and raises lines on public -API are exempt from the loss gate — they serve the caller, not the maintainer. -They are exempt from nothing else. Ceiling: a good man page entry. - -NumPy-style `Parameters`, `Returns`, and `Attributes` sections and executable -doctests fall under this exception — autodoc ships every field whether or not -you describe it, and a doctest that runs is also a test. TSDoc summaries, -`@param` and `@returns` tags, and the compiled examples fall under this -exception. - -## AI Slop Prevention - -Treat AI slop as **review-hostile noise**, not as proof that text or -code is wrong. The goal is to maximize information density by removing -artifacts that make the repository harder to trust or navigate. - -### The Anti-Slop Rubric - -Before committing, audit all AI-assisted changes for these noise -patterns: - -- **AI Signatures:** Remove "Generated by", footers, conversational - filler ("Certainly!", "Here is..."), unexplained emojis (🤖, ✨), and - AI-tool metadata. -- **Brittle References:** Avoid hard-coded line numbers, fragile - file/test counts, dated "as of" claims, bare SHAs, and local - absolute paths unless they are strict evidentiary artifacts (e.g., - benchmark logs). -- **Diff Narration:** Do not restate what moved, was renamed, or was - removed in artifacts the downstream reader holds: code, docstrings, - README, CHANGES, PR descriptions, or release notes. The diff and - commit message already carry this history. -- **Branch-Internal Narrative:** Do not mention intermediate branch - states, abandoned approaches, or "no longer" behavior unless users - of a published release actually experienced the old state (**The - Published-Release Test**). -- **Low-Value Scaffolding:** Remove ownerless TODOs (`TODO: revisit`), - unused future-proofing, debug artifacts, and defensive wrappers that - do not protect a currently reachable failure mode. -- **Prose Inflation:** Replace generic AI "tells" like *comprehensive, - robust, seamless, production-ready, leverage, delve, tapestry,* and - *best practices* with concrete descriptions of behavior, - constraints, or trade-offs. -- **Coded Labels:** Write rules, options, and findings as plain - imperatives. Don't tag them with codes like `[R1]`, `A1`, or - `Option B` in artifacts a human reads — the reader shouldn't have to - decode an index. Internal agent bookkeeping may use ids; shipped text - may not. - -### Durable Source Links - -Link to a pinned revision, never to trunk. A pinned permalink is not a -brittle reference; an unlinked SHA dropped into prose is. `blob/main/…` -links rot silently — the file moves, lines shift, and the anchor lands -on unrelated code while still resolving. - -- Prefer a release tag (`blob/v1.4.0/…`). Most durable, and it tells - the reader which released version the claim held for. -- Otherwise use a 7-char commit ref (`blob/9a29b1a/…`) reachable from - trunk. Use when there is no tag or the claim is about unreleased - code. Never a PR-head SHA — it can be rebased or garbage-collected. -- Reserve `blob/main/…` for living documents meant to always show the - latest state, such as a contributing guide. -- Line anchors (`#L120-L145`) are only safe on a pinned ref. - -### Preservation & Context - -Subjective cleanup must never remove load-bearing rationale. Adjudicate -comments with the comment policy above; borderline cases are deleted, not -kept. - -- **Preserve the "Why":** You MUST NOT delete comments that document - invariants, protocol constraints, platform quirks, security - boundaries, and upstream workarounds. -- **Evidence is Immune:** Preserve exact counts, dates, and SHAs when - they serve as evidence in benchmark results, release notes, stack - traces, or lockfiles. -- **Behavior Over Inventory:** A useful description explains what - changed for the *system or user*; it does not provide an inventory - of files or functions the diff already shows. - -### The Published-Release Test - -Long-running branches accumulate tactical decisions — renames, -refactors, attempts-then-reverts. When deciding what counts as -branch-internal, use trunk or the parent branch as the baseline — not -intermediate states inside the current branch. Ask: - -> Did users of the most recently published release ever experience -> this old name, old behavior, or bug? - -If the answer is **no**, it is branch-internal narrative. Move it to -the commit message and describe only the final state in the artifact. - -**Keep in shipped artifacts:** -- Deprecations and migration guides for symbols that actually shipped. -- `### Fixes` entries for bugs that affected users of a published - release. -- Comments explaining *why the current code looks this way* - (invariants, platform quirks) that make sense to a reader who never - saw the previous version. - -### Cleanup in Hindsight - -When applying these rules retroactively from inside a feature branch, -first establish scope by diffing against the parent branch (or trunk) -to identify which commits this branch actually introduced. Then: - -- **In-branch commits:** Prompt the user with two options: `fixup!` - commits with `git rebase --autosquash` to address each causal commit - at its source, or a single cleanup commit at branch tip. -- **Trunk/Parent commits:** Default to leaving them alone. Act only on - explicit user instruction. If the user opts in, fold the cleanup - into a single commit at branch tip; do not rewrite shared history. -- **Scope guard:** If cleaning prior slop would touch a colleague's - work or expand the branch beyond its stated goal, stay in lane: - protect the current goal and leave prior slop alone. - -### Change Discipline +gp-sphinx is a uv workspace of Sphinx documentation-platform packages: a +coordinator (`merge_sphinx_config()`), autodoc extensions, a Furo-based +theme, and the SEO/build tooling that ships around them for the +git-pull fleet. + +Follow the conventions already in the tree, and keep a change scoped to +what was asked for. + +## What is here + +| Path | What it is | +| --- | --- | +| `packages/gp-sphinx/` | Coordinator: `merge_sphinx_config()`, `DEFAULT_EXTENSIONS` | +| `packages/sphinx-gp-theme/`, `packages/gp-furo-theme/` | Furo-based theme; `gp-furo-theme/web/` is the Vite/CSS source | +| `packages/gp-furo-tokens/` | Design tokens (TS, pnpm-only, excluded from the uv workspace) | +| `packages/sphinx-autodoc-*/` | Autodoc extensions: api-style, argparse, docutils, fastmcp, pytest-fixtures, sphinx, typehints-gp | +| `packages/sphinx-ux-*/`, `sphinx-fonts/` | Shared layout, badges, fonts | +| `packages/sphinx-gp-{opengraph,sitemap,llms}/` | SEO, auto-loaded when `docs_url` is set | +| `packages/sphinx-gp-{mermaid,highlighting}/` | Diagrams and syntax highlighting | +| `packages/sphinx-vite-builder/` | PEP 517 build backend + Sphinx extension; own `AGENTS.md` | +| `src/gp_sphinx_workspace/` | Bootstrap package for the workspace root | +| `docs/` | This project's own docs site — its own flagship consumer | +| `tests/` | pytest suite: `_sphinx_scenarios.py`, `_snapshots.py`, `docs/` policy tests | +| `scripts/ci/` | Version bump and release-metadata tooling | +| `CHANGES` | The changelog | + +## Which policy applies + +- Documentation, user-facing text, `CHANGES`, commit messages, + docstrings, and source comments: + [.github/WRITING.md](.github/WRITING.md) +- Environment, the gates, tests, documentation builds, releases, and + pull requests: [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) + +Each of those is the single home for its subject. Where a rule seems to +be stated twice, the file listed above is the one that governs. + +## Change discipline - Make the smallest coherent change that solves the verified problem; keep unrelated cleanup out of it. -- Reuse an existing file, component, helper, API, or test before adding - a new one. Modify in place when the change fits the file's - responsibility. -- Keep new APIs private until a caller outside the module needs them. +- Reuse an existing file, helper, API, or test before adding a new one. - Add a file only for a durable boundary — a distinct responsibility, - independent reuse, or splitting an oversized high-touch module — not - for a single-use helper or a one-line re-export. - -### Keep Instructions Lean - -Treat this file like code and prune it. - -- Delete a line whose removal would not cause a mistake. -- Move multi-step procedures into skills, path-specific rules into - nested AGENTS.md files, and hard limits into hooks or CI. -- Keep only non-obvious, broadly applicable defaults here. Anything a - reader can infer from the code, a manifest, or a linter does not - belong. + independent reuse, or splitting an oversized module — not for a + single-use helper or a one-line re-export. +- Add a test for every user-visible behaviour change, and a `CHANGES` + entry for every change to the public API, CLI, configuration, or + output. +- A passing gate is evidence only once it has been shown capable of + failing. Pair a new test with a deliberate break that proves it bites. + +`pytest --doctest-modules` collects doctests from `.py` files under +`testpaths` only: `packages/*/src`, `docs/_ext/`, and `tests/`. A +`>>> ` prompt on a Markdown page under `docs/` or in `README.md` does +not run — see +[WRITING.md](.github/WRITING.md#documented-examples-that-run). All +publishable packages share one lockstep version, bumped with +`just bump-version `. + +## References + +- Changelog: [CHANGES](CHANGES) +- Docs: +- Source: diff --git a/README.md b/README.md index b3cb992e..3d5b1506 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # gp-sphinx · [![Python Package](https://img.shields.io/pypi/v/gp-sphinx.svg)](https://pypi.org/project/gp-sphinx/) [![License](https://img.shields.io/github/license/git-pull/gp-sphinx.svg)](https://github.com/git-pull/gp-sphinx/blob/main/LICENSE) An integrated autodoc design system for Sphinx that replaces ~300 lines -of duplicated `docs/conf.py` with ~10 lines and produces beautiful, -consistent API documentation. +of duplicated `docs/conf.py` with ~10 lines and produces consistent, +cross-referenced API documentation. + +> [!WARNING] +> **Alpha.** Releases carry an `-alpha` prerelease tag. The API is not +> settled, and any release may change or remove exported identifiers +> without a deprecation period. Pin an exact version. Not recommended +> for production. ## Requirements @@ -48,24 +54,39 @@ globals().update(conf) Out of the box, `merge_sphinx_config()` activates: -- **Componentized layouts** (`sphinx-ux-autodoc-layout`) — card containers, parameter folding, managed signatures -- **Clean type hints** (`sphinx-autodoc-typehints-gp`) — simplified annotations with cross-referenced links, replacing `sphinx-autodoc-typehints` and `sphinx.ext.napoleon` -- **Unified badge system** (`sphinx-ux-badges`) — type and modifier badges with a shared colour palette -- **Autodoc extensions** — Python API, argparse CLIs, pytest fixtures, FastMCP tools, docutils directives, Sphinx config values +- **Componentized layouts** (`sphinx-ux-autodoc-layout`) — card + containers, parameter folding, managed signatures +- **Clean type hints** (`sphinx-autodoc-typehints-gp`) — simplified + annotations with cross-referenced links, replacing + `sphinx-autodoc-typehints` and `sphinx.ext.napoleon` +- **Unified badge system** (`sphinx-ux-badges`) — type and modifier + badges with a shared colour palette +- **Autodoc extensions** — Python API, argparse CLIs, pytest fixtures, + FastMCP tools, docutils directives, Sphinx config values - **IBM Plex fonts** via Fontsource with preloaded web fonts - **Full dark mode** theming via CSS custom properties -See the [Gallery](https://gp-sphinx.git-pull.com/gallery.html) for live demos of every component. +See the [Gallery](https://gp-sphinx.git-pull.com/gallery.html) for live +demos of every component. ## Workspace architecture Lower layers never depend on higher ones: -- **Common libraries** — `sphinx-ux-badges`, `sphinx-ux-autodoc-layout`, `sphinx-autodoc-typehints-gp`, `sphinx-fonts` -- **Autodoc extensions** — `sphinx-autodoc-api-style`, `sphinx-autodoc-argparse`, `sphinx-autodoc-docutils`, `sphinx-autodoc-fastmcp`, `sphinx-autodoc-pytest-fixtures`, `sphinx-autodoc-sphinx` -- **Build utils** — `sphinx-vite-builder` ([PEP 517](https://peps.python.org/pep-0517/) backend + hatchling build hook + Sphinx extension that runs Vite via pnpm; publishable to PyPI for use outside this workspace) -- **Theme and coordinator** — `gp-sphinx`, `sphinx-gp-theme`, `gp-furo-theme` -- **SEO** — `sphinx-gp-opengraph`, `sphinx-gp-sitemap` (auto-loaded by `gp-sphinx` when `docs_url` is set) +- **Common libraries** — `sphinx-ux-badges`, `sphinx-ux-autodoc-layout`, + `sphinx-autodoc-typehints-gp`, `sphinx-fonts` +- **Autodoc extensions** — `sphinx-autodoc-api-style`, + `sphinx-autodoc-argparse`, `sphinx-autodoc-docutils`, + `sphinx-autodoc-fastmcp`, `sphinx-autodoc-pytest-fixtures`, + `sphinx-autodoc-sphinx` +- **Build utils** — `sphinx-vite-builder` + ([PEP 517](https://peps.python.org/pep-0517/) backend + hatchling + build hook + Sphinx extension that runs Vite via pnpm; publishable to + PyPI for use outside this workspace) +- **Theme and coordinator** — `gp-sphinx`, `sphinx-gp-theme`, + `gp-furo-theme` +- **SEO** — `sphinx-gp-opengraph`, `sphinx-gp-sitemap` (auto-loaded by + `gp-sphinx` when `docs_url` is set) See the [Architecture](https://gp-sphinx.git-pull.com/architecture.html) and [Packages](https://gp-sphinx.git-pull.com/packages/) pages for the diff --git a/docs/AGENTS.md b/docs/AGENTS.md deleted file mode 100644 index e6b8c1c3..00000000 --- a/docs/AGENTS.md +++ /dev/null @@ -1,156 +0,0 @@ -# Documentation voice - -This file covers the *voice* of prose under `docs/` — how to frame a -page so a reader meets the idea before its configuration. It -complements the repository-root `AGENTS.md`, which already governs -code blocks, shell-command formatting, doctests, changelog -conventions, and MyST roles. When the two overlap, the root file -wins; this one only answers: how should the prose sound? - -## Who you are writing for - -The default reader maintains another project's docs site and wires -`merge_sphinx_config()` into that project's `docs/conf.py`. They are -fluent in Sphinx itself — `conf.py`, extensions, themes, MyST -Markdown — but you cannot assume they know gp-sphinx's internals: the -workspace tier map, the merge order (`DEFAULT_*` constants, then -values auto-computed from `source_repository` and `docs_url`, then -`**overrides`), or the theme's Vite asset pipeline. - -A second, smaller reader works *on* the workspace: adding a package, -extending an autodoc extension, touching `gp-furo-theme`'s `web/src` -assets or the `sphinx-vite-builder` backend. Serve them too, but mark -their material opt-in ("for workspace contributors", "advanced") so -the default reader knows they can stop. Never make the common case -pay a comprehension tax for the advanced one. - -## Voice - -- **Second person, present tense, active.** "You pass `docs_url`", - not "SEO values are derived". Address the reader doing the thing. -- **Concept before configuration.** Open by saying what the thing - *is* and what it does for the reader's site. The kwarg surface is - the last detail they need, not the first. A page that opens with - "pass these keyword arguments" has buried the idea under its - mechanics. -- **Say when they can stop.** Lead with the default and the - reassurance: the ~10-line `merge_sphinx_config()` call is the whole - integration; everything past it is optional. Let a skimmer leave - after one paragraph. -- **Progressive disclosure.** Order by how many readers need it: the - coordinator call → the one kwarg a few will tune (`docs_url`, - `extra_extensions`) → a single package's own options → workspace - internals. Each step is for a smaller audience than the last. -- **Lean on the merge order.** The reader's mental model of - `merge_sphinx_config()` is a pipeline: shared defaults, then values - auto-computed from `source_repository` and `docs_url`, then - `**overrides` applied last — an explicit value always wins. - Reinforce that order when you explain who sets what. On package - pages the equivalent is the tier map: shared infrastructure → - autodoc extensions → theme and coordinator. -- **Name the trade-off.** If an option costs something, say so, and - say what it buys: `vite_orchestration=True` spawns a pnpm/Vite - watcher under `sphinx-autobuild` — contributors need Node, wheel - consumers don't. State it; don't sell it. -- **Frame by concept, not by mechanism.** Don't headline a feature by - its kwarg or CSS custom property in prose; that names the - implementation surface, the reader's last concern. Name the - concept. The mechanics vocabulary — a Parameter/Type/Default table, - a `DEFAULT_*` constant — is correct in the `docs/configuration.md` - reference tables, and only there. - -## What stays precise - -Warm the framing, never the facts. Parameter tables, auto-computed -value mappings, `DEFAULT_*` constant tables, exact extension names, -and cross-references carry meaning in their exact form — leave them -alone. The friendly voice belongs in the sentences *around* a precise -block, introducing it, not inside it paraphrasing it into vagueness. - -## Keeping examples honest - -The `conf.py` snippets on docs pages are illustrative — no test -executes them, so every kwarg you show must exist in -`merge_sphinx_config()`'s real signature. The nearest thing to a -test: this site is its own flagship consumer, so `just build-docs` -exercises what the snippets promise. What *does* run: the gallery -renders live from the demo modules in `docs/_ext/` (nothing is -mocked), and pytest collects those modules' doctests (`testpaths` -includes `docs`; `addopts` carries `--doctest-modules`) — keep them -passing. - -## Generated pages - -Every `docs/packages//` page gets its "Copyable config snippet" -and "Package metadata" sections from the `{package-landing}` and -`{package-reference}` directives (`docs/_ext/package_reference.py`), -which read live workspace metadata — don't hand-write what they -generate; a new `packages//pyproject.toml` appears on the next -build with no code change. Surface documentation — config values, -directives, roles — belongs to `autoconfigvalues`, `autodirective`, -and `autorole`; invoke them instead of transcribing it into prose. - -## Cross-references - -Point the advanced reader at the deep-dive rather than inlining it, -and put the link where their interest peaks — on the phrase that made -them curious ("write your own autodoc extension") — not as a footnote -the eye skips. Use the MyST roles listed in the -root `AGENTS.md`; docs pages here usually spell the py-domain forms -explicitly (`{py:func}`, `{py:data}`, `{py:mod}`). A `{ref}` must -match its target's anchor exactly — anchors mix hyphen and underscore -forms, sometimes inside one anchor (`from-docs_url`). `just -build-docs` catches a broken `{ref}`; nothing else does — so build the -docs before you commit. - -A py-domain role is not covered by that build check. `nitpicky` is -unset, so an unresolved `{py:class}` or `{py:data}` renders as plain -text and the build stays silent — a clean build is no evidence the -link works. Two ways to get one wrong: citing a symbol nothing -autodocs (add the directive, or drop the role), and citing a project -absent from `intersphinx_mapping` in `docs/conf.py`, which currently -maps only `py` and `sphinx` — every other project is a Markdown link. -Confirm by opening the built page and checking the name sits inside an -``. - -Link the first prose mention of any symbol that has a useful -destination on that page. This includes Python objects, gp-sphinx -APIs, workspace package pages, configuration anchors, and external -tools or projects. Use the most specific target available: -`{py:func}`, `{py:class}`, `{py:mod}`, or `{py:data}` for API -objects; `{ref}` or `{doc}` for documentation pages and section -anchors; and a Markdown link for external projects. After the first -linked mention on a page, later mentions can stay plain unless the -distance or context makes another link useful. - -Do not rely on a later reference section to satisfy the first-mention -rule. If the first occurrence would be a heading, grid-card teaser, -or introductory sentence, link that occurrence or retitle the heading -so the first prose mention can carry the link. Leave command -examples, code blocks, and literal configuration values as code; link -the surrounding prose instead. - -## A page that does this - -`docs/packages/gp-sphinx/how-to.md` is the worked example: it opens -with the exact downstream `conf.py` the default reader came to copy, -says what the call injects in reader vocabulary, reassures that -passing `docs_url` is the only step SEO needs, defers precise key -mappings to the configuration reference, and ends with a live-example -admonition pointing at this site's own `docs/conf.py`. Read it before -reshaping another page. - -## Before you commit - -- Does the page open with what the feature *is*, or how to configure - it? -- Can a reader who needs only the ~10-line call stop after one - paragraph? -- Is anything framed as "the kwargs" that should be named by concept? -- Are the workspace-internal and advanced parts marked opt-in? -- Did you leave every table, anchor, extension name, and - cross-reference exact — and generated sections to their directives? -- Did `just build-docs` stay clean — no new warning, no broken - cross-reference? -- Did you open the built page and confirm each new py-domain role - rendered as a link? A silent build does not prove it. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md deleted file mode 120000 index 47dc3e3d..00000000 --- a/docs/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/docs/project/code-style.md b/docs/project/code-style.md index 28975674..d664af0b 100644 --- a/docs/project/code-style.md +++ b/docs/project/code-style.md @@ -1,26 +1,10 @@ # Code Style -## Formatting - -gp-sphinx uses [ruff](https://github.com/astral-sh/ruff) for both linting and formatting. - -```console -$ uv run ruff format . -``` - -```console -$ uv run ruff check . --fix --show-fixes -``` - -## Type Checking - -Strict [mypy](https://mypy-lang.org/) is enforced across `src/` and `tests/`. - -```console -$ uv run mypy . -``` - -## Docstrings - -Follow [NumPy docstring style](https://numpydoc.readthedocs.io/en/latest/format.html) -for all public functions, methods, and classes. +This page split in two when the repository's agent and contributor +documentation moved to `.github/`: + +- Formatting, linting, type-checking, imports, and Sphinx-domain-access + conventions are in + [`.github/CONTRIBUTING.md`](https://github.com/git-pull/gp-sphinx/blob/main/.github/CONTRIBUTING.md#code-style). +- Docstring style and content is in + [`.github/WRITING.md`](https://github.com/git-pull/gp-sphinx/blob/main/.github/WRITING.md#docstrings). diff --git a/docs/project/contributing.md b/docs/project/contributing.md index c9bf3660..a6f2b942 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -1,177 +1,20 @@ # Contributing -Install [git] and [uv]. - -Clone: - -```console -$ git clone https://github.com/git-pull/gp-sphinx.git -``` - -```console -$ cd gp-sphinx -``` - -Install packages: - -```console -$ uv sync --all-packages --all-extras --group dev -``` - -## Tests - -Preferred local commands use a fixed pytest temp root under `.cache/` and disable -tmp-path retention for speed. `just test` keeps full coverage, while -`just test-fast` is a feedback loop only and intentionally excludes -`integration` tests: - -```console -$ just test -``` - -```console -$ uv run pytest -``` - -Use raw `uv run pytest` when you want the conservative direct runner without the -local temp-dir optimization. - -Fast local loop without doctest-modules or integration tests: - -```console -$ just test-fast -``` - -Canonical direct pytest command for the same fast lane: - -```console -$ uv run pytest \ - -o "addopts=--tb=short --no-header --showlocals" \ - -o tmp_path_retention_policy=none \ - --basetemp="$(pwd)/.cache/pytest-fast-direct" \ - -q \ - --capture=tee-sys \ - tests \ - -m "not integration" -``` - -Do not use the fast lane to reason about full-suite coverage or total suite -performance; it is intentionally deselected for local iteration. - -### Automatically run tests on file save - -1. `just start` (via [pytest-watcher], full local lane) -2. `just start-fast` for the fast local loop -3. `just watch-test` (requires installing [entr(1)]) - -[pytest-watcher]: https://github.com/olzhasar/pytest-watcher +Development setup, the gates, and pull request workflow now live in +the repository's own contributing guide, so the same file governs +GitHub's contributing prompt and this published page. Read it on +GitHub: +. ## Documentation -Default preview server: http://localhost:3124 - -[sphinx-autobuild] will automatically build the docs, watch for file changes and launch a server. - -From the repository root: - -```console -$ just start-docs -``` - -From inside `docs/`: - -```console -$ just start -``` - -[sphinx-autobuild]: https://github.com/executablebooks/sphinx-autobuild - -### Manual documentation (the hard way) - -Build from inside `docs/`: - -```console -$ cd docs && just html -``` - -Start the static file server: - -```console -$ just serve -``` - -Repository-root helpers: - -```console -$ just build-docs -``` - -```console -$ just serve-docs -``` - -Rebuild docs on file change: - -```console -$ just watch-docs -``` - -Requires [entr(1)]. - -Rebuild docs and run the server in one terminal: - -```console -$ just dev-docs -``` - -Requires [entr(1)]. +Building, previewing, and serving the docs is +[the Documentation section](https://github.com/git-pull/gp-sphinx/blob/main/.github/CONTRIBUTING.md#documentation) +of that guide. ## Test hierarchy -Pick the **lightest** level that exercises the behavior: - -| Level | When to use | Speed | -|---|---|---| -| **Pure unit** | Strings, dicts, dataclasses — no nodes, no Sphinx | microseconds | -| **Docutils tree unit** | Constructing `docutils.nodes.*` or `sphinx.addnodes.*` directly | microseconds | -| **Snapshot unit** | Large or complex output — assert via `snapshot_doctree` | microseconds | -| **Sphinx integration** (`@pytest.mark.integration`) | Must verify actual HTML output or Sphinx event wiring | 2–10 s | - -The `just test-fast` lane skips integration tests for rapid feedback. -The full `just test` lane runs everything. - -### Scenario caching - -Integration tests use the harness in `tests/_sphinx_scenarios.py`. -`build_shared_sphinx_result()` caches builds by a SHA-256 content-hash -digest, achieving a **9.5x speedup** (~40 s to ~4.2 s for 916 tests). - -Key rules: - -- Always `scope="module"` or `scope="session"` on build fixtures — never - `scope="function"` -- Use `purge_modules` to remove synthetic Python modules from `sys.modules` - before the initial build -- Use `SCENARIO_SRCDIR_TOKEN` + `substitute_srcdir=True` for `sys.path` - injection in scenario `conf.py` files - -### Snapshot testing - -The project uses [syrupy](https://github.com/toptal/syrupy) for snapshot -assertions. Three custom fixtures (from `tests/_snapshots.py`) normalize -their inputs before asserting: - -- `snapshot_doctree(doctree)` — normalizes a `nodes.Node` -- `snapshot_html_fragment(html)` — strips ANSI, normalizes whitespace -- `snapshot_warnings(warnings)` — strips noise lines and ANSI codes - -Update stored snapshots after intentional output changes: - -```console -$ uv run pytest --snapshot-update -``` - -[git]: https://git-scm.com/ -[uv]: https://github.com/astral-sh/uv -[entr(1)]: http://eradman.com/entrproject/ -[`entr(1)`]: http://eradman.com/entrproject/ +The pytest level hierarchy — pure unit, docutils tree, snapshot, and +Sphinx integration — is +[the Tests section](https://github.com/git-pull/gp-sphinx/blob/main/.github/CONTRIBUTING.md#tests) +of that guide. diff --git a/packages/sphinx-vite-builder/AGENTS.md b/packages/sphinx-vite-builder/AGENTS.md index 8526087e..0148e8e2 100644 --- a/packages/sphinx-vite-builder/AGENTS.md +++ b/packages/sphinx-vite-builder/AGENTS.md @@ -1,39 +1,49 @@ # AGENTS.md — `sphinx-vite-builder` -Guidance for AI agents (Claude Code, Cursor, Copilot, Codex, …) and -human contributors working on this package. Mirrors the higher-level -guidance at `gp-sphinx/CLAUDE.md`; `packages/sphinx-vite-builder/CLAUDE.md` -points here so Claude Code reads the same content as other agent runners. +Package-specific rules only. The workspace project map, prose policy, +and workflow are at the repository root — see +[../../AGENTS.md](../../AGENTS.md), +[../../.github/WRITING.md](../../.github/WRITING.md), and +[../../.github/CONTRIBUTING.md](../../.github/CONTRIBUTING.md). Those +govern docstrings, doctests, typing, and commits here too; nothing +below repeats them. ## What this package is Two orthogonal entry points sharing one subprocess core: -1. **PEP 517 build backend** at `sphinx_vite_builder.build`. Runs - `pnpm exec vite build` before delegating wheel/sdist construction - to `hatchling.build`. Consumer packages declare it via - `[build-system].build-backend = "sphinx_vite_builder.build"`. +1. **PEP 517 build backend** at `sphinx_vite_builder.build` (and a + hatchling-build-hook variant at `hatch_plugin.py` for consumers who + keep `build-backend = "hatchling.build"`). Runs `pnpm exec vite + build` before delegating wheel/sdist construction to + `hatchling.build`. Consumer packages declare it via + `[build-system].build-backend = "sphinx_vite_builder.build"`, or via + `[tool.hatch.build.hooks.vite]` for the hook variant. 2. **Sphinx extension** at `sphinx_vite_builder:setup`. Hooks - `builder-inited` and `build-finished` so `sphinx-build` / - `sphinx-autobuild` automatically run vite — one-shot for prod, a - long-lived `vite build --watch` child process for autobuild — with - graceful teardown on signal / `atexit`. + `builder-inited` to run `pnpm exec vite build` synchronously, + blocking until it finishes, then `build-finished` to log any + exception. Both `sphinx-build` and `sphinx-autobuild` take this same + path: each `sphinx-autobuild` rebuild is a fresh subprocess, so a + synchronous one-shot vite per rebuild — not a persistent watcher — + is what keeps Sphinx's `copy_static_files` phase from racing stale + assets. `vite_watch_command()` exists in `_internal/vite.py` but is + not currently called by either head. Both heads consume the smart-subprocess core under `sphinx_vite_builder._internal/`: `process.py` (`AsyncProcess` — asyncio subprocess wrapper with POSIX session isolation, SIGTERM-then-SIGKILL teardown, line-buffered stdout/stderr drainers, captured stderr for error surfacing), `bus.py` (`AsyncioBus` — single -asyncio loop in a daemon thread for sync↔async bridging), -`vite.py` (orchestration: detect `web/`, check pnpm via `shutil.which`, -spawn install/build), and `errors.py` (`PnpmMissingError`, -`NodeModulesInstallError`, `ViteFailedError`). +asyncio loop in a daemon thread that lets the synchronous Sphinx hook +block on the async subprocess call), `config.py` (mode detection from +`argv`/`SPHINX_AUTOBUILD`/parent command line — pure functions, no +Sphinx fixture needed to test them), `vite.py` (orchestration: detect +`web/`, check pnpm via `shutil.which`, spawn install/build), and +`errors.py` (`PnpmMissingError`, `NodeModulesInstallError`, +`ViteFailedError`). -**Phase 1 status:** The PEP 517 backend is fully implemented and -tested. The Sphinx extension `setup()` is a placeholder — it -registers cleanly in `conf.py` but doesn't yet hook the docs build -lifecycle. The full extension implementation (event handlers, vite -watch, teardown) lands in a follow-up release. +Both entry points are fully implemented and tested — this is not a +staged rollout. ## The design contract — keep this invariant @@ -114,14 +124,17 @@ new row here AND a corresponding test in ``` sphinx_vite_builder/ -├── __init__.py Sphinx extension entry: setup(app) -├── build.py PEP 517/660 hooks (delegate to hatchling) +├── __init__.py Sphinx extension entry: setup(app) +├── build.py PEP 517/660 hooks (delegate to hatchling) +├── hatch_plugin.py Hatchling build-hook variant (ViteBuildHook) ├── py.typed └── _internal/ - ├── errors.py SphinxViteBuilderError + 3 subclasses - ├── process.py AsyncProcess (asyncio subprocess wrapper) - ├── bus.py AsyncioBus (sync↔async bridge) - └── vite.py run_vite_build() + CI detection + hint formatters + ├── errors.py SphinxViteBuilderError + 3 subclasses + ├── process.py AsyncProcess (asyncio subprocess wrapper) + ├── bus.py AsyncioBus (sync/async bridge) + ├── config.py Mode detection + config dataclass + ├── hooks.py builder-inited / build-finished handlers + └── vite.py run_vite_build() + CI detection + hint formatters ``` Neither head calls the other; both consume `_internal/`. The PEP 517 @@ -131,15 +144,6 @@ Optional hooks (`get_requires_for_build_*`, `prepare_metadata_for_build_*`) alias to hatchling by identity — vite has no influence on dependency resolution or distribution metadata, so wrapping them would be wrong. -## When you add a new public function - -- Add doctests. Every public function MUST have working doctests - per the workspace convention. Use ELLIPSIS for variable output. -- Add NumPy-style docstrings: short summary, Parameters, Returns, - Raises, Examples. -- Add type annotations everywhere, including return types - (`-> None` on test functions). mypy runs strict mode. - ## When you add a new error path - Add a new `*Error` subclass in `errors.py` if the failure has a @@ -165,7 +169,7 @@ motivated this whole package). The required steps are: - uses: pnpm/action-setup@v6 with: version: 10 -- uses: actions/setup-node@v6 +- uses: actions/setup-node@v7 with: node-version: 22 ``` diff --git a/packages/sphinx-vite-builder/CLAUDE.md b/packages/sphinx-vite-builder/CLAUDE.md deleted file mode 100644 index 75740456..00000000 --- a/packages/sphinx-vite-builder/CLAUDE.md +++ /dev/null @@ -1,8 +0,0 @@ -# CLAUDE.md - -Claude Code reads this file. Other agent runners (Cursor, Copilot, -Codex, …) read [`AGENTS.md`](AGENTS.md). The two files have identical -content via this passthrough — every guideline lives in `AGENTS.md`, -and edits go there. - -→ See [`AGENTS.md`](AGENTS.md). diff --git a/packages/sphinx-vite-builder/CLAUDE.md b/packages/sphinx-vite-builder/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/packages/sphinx-vite-builder/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file