Contracts
cli-contract.md
CLI Contract: spec-kitty docs query
Command
spec-kitty docs query <TERM> [--json] [--divio-type <TYPE>] [--section <ANCHOR-SLUG>]
Registered as a docs Typer sub-app (src/specify_cli/cli/commands/docs.py), added next to glossary in src/specify_cli/cli/commands/__init__.py. The command imports DocsQueryEntry / DocsIndexStore from the packaged specify_cli.docs.index_model (src→src) — it MUST NOT import scripts.* (unpackaged; would fail in the installed CLI).
Arguments & options
| Name | Kind | Required | Behavior |
|---|---|---|---|
TERM | positional str | yes | Case-insensitive substring matched against each page's title, any anchor text/slug, and abstract. Empty/whitespace → usage error (exit 2). |
--json | flag | no | Emit machine-readable JSON (see shape below) via print(json.dumps(...)). Without it, render a Rich table for humans. |
--divio-type | str | no | Restrict to pages of this Divio type. MUST validate against DivioType (`tutorial\ |
--section | str | no | Restrict to pages containing an anchor whose slug equals this value. |
JSON result shape (--json)
A JSON array (possibly empty). Each element:
{
"path": "docs/architecture/execution-lanes.md",
"title": "Execution lanes",
"divio_type": "explanation",
"abstract": "How Spec Kitty allocates worktrees per computed lane.",
"anchors": [
{"slug": "lane-allocation", "text": "Lane allocation", "level": 2}
]
}
order. When the match was on title/abstract only (no anchor matched) anchors MAY be empty.
anchorscontains only the anchors that MATCHED the term/--sectionfilter (FR-003), in document- Keys are always present;
abstractmay be"".
Behavioral contract
| # | Given | When | Then |
|---|---|---|---|
| 1 | index has a page matching TERM | docs query "<term>" --json | that page appears with path, title, matching anchors, abstract, divio_type; exit 0 |
| 2 | no page matches | docs query "<term>" --json | prints []; exit 0 (NOT an error) |
| 3 | --divio-type reference | matching pages exist of other types | only reference pages returned |
| 4 | --section <slug> | some pages contain that anchor | only those pages returned |
| 5 | invalid --divio-type value | — | usage error, exit 2, no traceback |
| 6 | no docs/ tree / index file missing | docs query "x" | clear error message to stderr, non-zero exit; no Python traceback |
| 7 | human mode (no --json) | match exists | Rich table with path/title/type/matching-anchors; NO Rich markup leaks into piped output |
Non-goals (contract-level)
- No HTTP/REST/GraphQL surface (C-002).
- No full-text body search — matching is over title/anchor/abstract only (C-003).
- Anchors are source-heading slugs, not guaranteed byte-identical to rendered DocFX fragments (C-005).
index-file-contract.md
Artifact Contract: docs retrieval index + freshness gate
The generated index file
ONLY this, src→src):
from src, scripts→src):
rglob, slugify+ordinal)
refreshes the file, --strict exits non-zero on drift.
excludes scripts, so an installed-CLI import of scripts.* would ModuleNotFoundError). A test MUST assert the CLI-facing symbols (DocsQueryEntry, Anchor, DocsIndexStore) import from specify_cli.docs.index_model.
regenerations over an unchanged tree produce byte-identical output.
- Path:
docs/development/3-2-docs-retrieval-index.yaml(new, git-tracked). - Packaged model —
src/specify_cli/docs/index_model.py(shipped in the wheel; the CLI imports Anchor,DocsQueryEntry(frozen dataclasses)render_index(entries) -> str(deterministic, byte-stable string)parse_index(text) -> list[DocsQueryEntry]compare_index(committed, regenerated) -> IndexDrift(mirrorsInventoryDrift)DocsIndexStore.load(path)+.query(...)DEFAULT_INDEX_PATH- Generator (build-tooling) —
scripts/docs/docs_index.py(NOT shipped; imports the model DOWN generate_index(docs_root) -> list[DocsQueryEntry](usesparse_frontmatter,DivioType, sortedscan_headings,resolve_title,resolve_abstract,slug_for_headingshelpersrun_generate_and_compare(..., write: bool, strict: bool)—__main__entrypoint;--write- Import-direction invariant:
scripts → srcis legal;src → scriptsis forbidden (the wheel - Determinism (NFR-001): entries sorted by
path; anchors in document order; slugs pure. Two - Header: a
# GENERATED — do not edit by hand.leading comment (inventory-lockfile convention).
C-001 invariants (page-inventory untouched)
The following MUST be byte-for-byte unchanged by this mission:
docs/development/3-2-page-inventory.yamlscripts/docs/_inventory.py::PageInventoryEntry(schema,_REQUIRED_KEYS)scripts/docs/inventory_lockfile.py::render_lockfilescripts/docs/check_docs_freshness.py::_check_inventory_lockfile_drifttests/docs/test_inventory_path_stable.py,tests/docs/test_bulk_ref_rewrite.py::test_inventory_lockfile_untouched
A regression test SHOULD assert the docs-index generator does not import/mutate PageInventoryEntry.
Freshness gate
index in-memory, compares to the committed file, emits an error-severity finding on drift.
_check_inventory_lockfile_drift. Rule id e.g. DOCS-INDEX-DRIFT.
an explicit step only if the aggregate does not.
- New checker:
scripts/docs/check_docs_freshness.py::_check_docs_index_drift— regenerates the - Registration: folded into the aggregate ruler list alongside (not replacing)
- CI:
.github/workflows/docs-freshness.yml— verify the aggregate call covers the new checker; add - Behavioral contract:
| Given | When | Then |
|---|---|---|
| committed index matches the tree | check_docs_freshness --ci | new checker passes (exit 0 contribution) |
| a page's heading/description changed but index not regenerated | check_docs_freshness --ci | DOCS-INDEX-DRIFT error, aggregate exits non-zero |
| index regenerated + committed | check_docs_freshness --ci | green again |
Reuse (C-004)
Import from existing scripts/docs/ surfaces — do not fork: parse_frontmatter, the sorted rglob("*.md") walk, DivioType, the InventoryDrift/compare diff shape, and slugify (+ ordinal-dedup pattern) from generate_kitty_specs_docs.py.