diff --git a/skills/doorstop/README.md b/skills/doorstop/README.md new file mode 100644 index 00000000..940a6af8 --- /dev/null +++ b/skills/doorstop/README.md @@ -0,0 +1,96 @@ +# Doorstop Skill + +An Agent Skill that gives any compatible AI coding agent (Claude Code, Cursor, +Gemini CLI, Codex CLI, Antigravity) comprehensive, lazily-loaded knowledge of +the [Doorstop](https://github.com/doorstop-dev/doorstop) requirements-management +system. + +## What it covers + +- Every `doorstop` CLI subcommand and flag +- The Python API (`Tree`, `Document`, `Item`, `build`, `find_document`, + `find_item`, `exporter`, `importer`, `publisher`) and its exception hierarchy +- `.doorstop.yml` and item YAML / Markdown-frontmatter schemas, including + extended attributes, `attributes.reviewed`, `attributes.publish`, and + `!include` composition +- End-to-end workflows: bootstrap, add/link/review/publish, CSV/XLSX round-trip, + suspect-link resolution, release publishing +- The validation engine — full severity matrix and when each flag (`-L`, `-R`, + `-C`, `-Z`, `-S`, `-W`, `-w`, `-e`) is safe +- The `doorstop-server` REST API +- Custom validators (`extensions.item_validator`), programmatic hooks, and + `item_sha_required` +- A JSON snapshot script for agent loops and a CI-friendly lint wrapper + +## Install + +This skill uses the [OpenSkills](https://github.com/numman-ali/openskills) +universal installer so one package works across Claude Code, Cursor, Gemini +CLI, Codex CLI, and Antigravity. + +```sh +# Install from a published GitHub repo, project-local (./.claude/skills/doorstop) +npx openskills install doorstop-dev/doorstop + +# Install globally (~/.claude/skills/doorstop) +npx openskills install doorstop-dev/doorstop --global + +# Install from a local checkout while developing this skill +npx openskills install ./skills/doorstop --global +``` + +After install, agents automatically discover the skill via its description in +`SKILL.md` — no further wiring required. + +> **Note on `npx skills add`**: some docs use the shorthand `npx skills add …`; +> in April 2026 the working command is `npx openskills install …`. If an alias +> is added to `openskills` later, both forms will work. + +## Layout + +``` +skills/doorstop/ +├── SKILL.md # entry point (always-on description, lean body) +├── README.md # this file +├── references/ # lazy-loaded deep references +│ ├── cli-commands.md +│ ├── python-api.md +│ ├── file-formats.md +│ ├── workflows.md +│ ├── validation.md +│ ├── publishing.md +│ ├── import-export.md +│ ├── server-api.md +│ ├── extensions.md +│ └── troubleshooting.md +├── scripts/ +│ ├── doorstop_snapshot.py # JSON snapshot for agents +│ └── doorstop_lint.sh # CI-friendly validation +└── assets/ + └── example_reqs/ # tiny two-document example tree +``` + +## Design + +This skill follows the **Thin Harness, Fat Skills** principle: + +- The always-on surface (SKILL.md frontmatter) is compact. +- Domain judgment lives in `references/*.md`, loaded only when the task needs + it. +- Deterministic truth (flag tables, schemas, REST routes) is expressed as + tables and example files — never as unbounded prose. +- The skill never tries to control harness runtime (scheduling, tool + orchestration). It describes *what* to do; the harness decides *how*. + +## Requirements + +- Python 3.9+ with Doorstop installed (`pip install doorstop`) +- `git` (Doorstop requires a VCS root) +- Optional: `openpyxl` for XLSX round-trip (installed automatically with + Doorstop), `bottle` for the REST server (same) + +## Contributing + +This skill lives inside the `doorstop` source repository so it versions with the +tool itself. When Doorstop's CLI surface, file format, or Python API changes, +update the matching reference in `references/` in the same PR. diff --git a/skills/doorstop/SKILL.md b/skills/doorstop/SKILL.md new file mode 100644 index 00000000..0e62b685 --- /dev/null +++ b/skills/doorstop/SKILL.md @@ -0,0 +1,153 @@ +--- +name: doorstop +description: Use for Doorstop requirements work — creating/linking/validating/reviewing/publishing items, editing .doorstop.yml, authoring validators, or driving the doorstop CLI, Python API, or REST server. +--- + +# Doorstop + +Doorstop is a version-controlled requirements management system. It stores +requirements as YAML or Markdown files (one file per item, one directory per +document), linked bidirectionally into a tree, validated for consistency, and +published to text/Markdown/HTML/LaTeX. Every state change has an exact +representation on disk, so git is the source of truth. + +## When to use + +- Creating, editing, linking, or deleting requirement items +- Bootstrapping a new `reqs/` or `tests/` tree in a repo +- Writing or updating `.doorstop.yml`, item YAML, or Markdown-frontmatter items +- Validating a tree (`doorstop`) and fixing warnings/errors +- Clearing suspect links or marking reviews (`doorstop clear`, `doorstop review`) +- Importing/exporting via CSV/TSV/XLSX for spreadsheet round-trip +- Publishing HTML, Markdown, LaTeX, or text snapshots +- Authoring a custom validator or validation hook +- Calling Doorstop from a Python script or using the REST server + +## When not to use + +- General Python, YAML, or Markdown authoring with no requirements-management + dimension — use the base tools directly. +- Editing the internal `doorstop/` package code of this repo — that is software + development on Doorstop itself, not *use* of Doorstop. Read the project's + `CONTRIBUTING.md` instead. +- Driving the desktop GUI (`doorstop-gui`) — it is a Tkinter app and not agent- + drivable. + +## Core concepts (internalize these) + +- **Tree** → **Document** → **Item** → **Link**. Each *document* is a directory + holding a `.doorstop.yml` config and one item file per requirement. Items link + *upward* (child → parent); the tree is bidirectional at runtime. +- **UID** = ``, e.g. `REQ001`, `REQ-001`, `TST_007`. + `prefix`, `sep`, and `digits` are set in `.doorstop.yml`. +- **Fingerprint (reviewed)**: SHA-256 of `uid + text + ref + references + links` + (plus any extended attrs listed under `attributes.reviewed`). Stored in the + item's `reviewed:` field. *Never hand-edit it — use `doorstop review`.* +- **Suspect link**: a child's stored parent-fingerprint no longer matches the + parent's current fingerprint. Clear with `doorstop clear`, not by re-reviewing. +- **Level**: outline position like `1.2.3`. `*.0` + `normative: false` ⇒ heading. +- Items are referenced by UID, not by filename. **UIDs are immutable** — renaming + is delete + re-add + re-link. + +## First move + +When the user asks for any doorstop task, before mutating anything: + +1. `git rev-parse --show-toplevel` — Doorstop requires a VCS root. If the user is + outside one, say so and stop. +2. `doorstop` (no args) — if it prints a tree and exits 0, you have a working + tree. If it errors with "no documents found", user is bootstrapping. +3. If you need a structured view, run + `python scripts/doorstop_snapshot.py` (shipped with this skill) to get JSON. +4. Pick the right reference from the map below, read it, then act. + +## Workflow map + +| Task | Reference | +|---|---| +| Bootstrap a project / create a document | `references/workflows.md` | +| Add / edit / remove items | `references/cli-commands.md` + `references/file-formats.md` | +| Link items across documents | `references/workflows.md` | +| Validate / fix warnings / clear suspects / mark reviewed | `references/validation.md` | +| Publish HTML / Markdown / LaTeX / text | `references/publishing.md` | +| Round-trip via spreadsheet (CSV / TSV / XLSX) | `references/import-export.md` | +| Drive Doorstop from Python / write a script | `references/python-api.md` | +| REST API / integrations | `references/server-api.md` | +| Custom validator / hook / extension | `references/extensions.md` | +| Suspect links / unreviewed / cycles / skipped levels | `references/troubleshooting.md` | +| Every CLI flag + exact subcommand semantics | `references/cli-commands.md` | +| `.doorstop.yml` and item YAML/Markdown schemas | `references/file-formats.md` | + +## Non-negotiables + +- **Always `doorstop review` after editing item content.** Never write a + `reviewed:` hash yourself — the hash function is SHA-256 over a specific + ordered value set and *will* drift if you compute it wrong. +- **Prefer the CLI for mutations**, not manual YAML edits. `doorstop add` / + `edit` / `link` / `unlink` / `remove` / `clear` / `review` enforce invariants + that hand-edits skip. +- **Run `doorstop` after every batch of changes.** Validation is fast and the + failure modes (suspect links, orphaned children, level gaps) are cheap to fix + when caught immediately. +- **Never mix item formats within a single document.** A document's `itemformat` + is set at `create` time and is effectively read-only after the first item. +- **Never rename an item's file.** The filename *is* the UID. +- **Level `X.0` + `normative: false` is a heading**, not a requirement. Don't + link requirements to headings — validation will warn. +- **Derived items don't need parent links**; mark `derived: true` when the + requirement has no upstream source. +- When generating a wave of items, use `doorstop add -c N` rather than looping — + `add` reserves UIDs atomically through the server when one is running. + +## Output expectations + +A task is complete when: + +- `doorstop` exits 0 (or only with `INFO`-level messages you explicitly accept). +- Every edited item has been passed through `doorstop review`. +- If the task called for publication, the publish artifact exists and contains + the expected items (`index.html` + per-document files for `publish all`). +- Item files and `.doorstop.yml` files are committed (or staged) — remind the + user to commit; doorstop state is meaningless outside version control. + +## Reference map + +Load references on demand — they are progressive-disclosure files, not always-on +context. + +- `references/cli-commands.md` — every `doorstop` subcommand, every flag, canonical + invocations, exit semantics, and when each command is (and isn't) the right tool. +- `references/python-api.md` — `doorstop.build`, `Tree`/`Document`/`Item`, exception + hierarchy, iteration patterns, scripting examples, validation-hook signatures. +- `references/file-formats.md` — `.doorstop.yml` schema, YAML item schema, + Markdown-frontmatter item schema, UID grammar, `references:` block, extended + attributes, publish-list, `!include` tag. +- `references/workflows.md` — end-to-end recipes: bootstrap, add-child-document, + link, review-after-churn, publish-release, bulk-import. +- `references/validation.md` — full INFO/WARNING/ERROR matrix, suspect-link + mechanics, what each `-L/-R/-C/-Z/-S/-W/-w/-e` flag does, when skipping is OK. +- `references/publishing.md` — format matrix, `--template`, `--index`, + `--no-child-links`, `--no-levels`, publish-all layout, traceability matrix. +- `references/import-export.md` — export formats, round-trip via XLSX, the + "blank UID" new-item convention, `--map` for column renames. +- `references/server-api.md` — every REST endpoint with JSON shapes, how the + `--server`/`--port`/`-f/--force` client flags affect item-number reservation. +- `references/extensions.md` — `extensions.item_validator` in `.doorstop.yml`, + `tree.validate(document_hook=, item_hook=)`, `item_sha_required`, custom + publishers, custom attributes. +- `references/troubleshooting.md` — decision tree for the most common failure + modes with the exact commands to run. + +## Scripts + +- `scripts/doorstop_snapshot.py` — dumps the tree, documents, items, links, + and validation issues as JSON. Use this when you want a structured view + for agent reasoning rather than parsing CLI stdout. +- `scripts/doorstop_lint.sh` — CI-friendly wrapper: runs `doorstop -e` (errors + on any warning) with sensible defaults, returns non-zero on any issue. + +## Example tree + +`assets/example_reqs/` is a tiny two-document example (`REQ` + `TST`, one link, +one heading) that validates clean. Copy it as a starting template, or read it to +confirm the canonical on-disk shape when you are unsure. diff --git a/skills/doorstop/assets/.doorstop.skip-all b/skills/doorstop/assets/.doorstop.skip-all new file mode 100644 index 00000000..f7c7f3ee --- /dev/null +++ b/skills/doorstop/assets/.doorstop.skip-all @@ -0,0 +1,12 @@ +# This marker tells `doorstop` not to descend into the `skills/doorstop/assets/` +# subtree when building its document tree. The example requirements tree under +# `example_reqs/` is a teaching fixture — it is a real Doorstop tree, but it is +# not part of the Doorstop project's own requirements. Without this marker, a +# plain `doorstop` run at the Doorstop repo root would try to merge +# `example_reqs/` into the project tree and fail with "multiple root documents". +# +# Users copying `example_reqs/` into their own repo should copy the contents of +# that directory only (e.g. `cp -r example_reqs/. /path/to/new/repo`); this +# marker stays behind, so the copied tree is discovered normally. +# +# See `doorstop/core/builder.py` for the scan-prune logic. diff --git a/skills/doorstop/assets/example_reqs/README.md b/skills/doorstop/assets/example_reqs/README.md new file mode 100644 index 00000000..c6f88c85 --- /dev/null +++ b/skills/doorstop/assets/example_reqs/README.md @@ -0,0 +1,44 @@ +# Example reqs tree + +A minimal two-document doorstop tree: + +- `reqs/` — root document, prefix `REQ` + - `REQ001` — heading ("System Requirements"), level 1.0 + - `REQ002` — normative requirement ("Boot time"), level 1.1 +- `tests/` — child document, prefix `TST`, parent `REQ` + - `TST001` — normative test linked to `REQ002` + +Use it as: + +- A copy-paste starting template for a new project. +- Ground truth for the canonical on-disk shape (see + `references/file-formats.md`). +- A target for skill verification — `doorstop` from the root should exit 0 + (with INFO about unreviewed items until you run `doorstop review all`). + +## Why the parent directory has `.doorstop.skip-all` + +The sibling `skills/doorstop/assets/.doorstop.skip-all` marker tells +doorstop not to descend into this directory when scanning a VCS root. That +matters when the skill is installed inside a repo that is *itself* a +Doorstop project — without the marker, doorstop would try to merge this +example tree into the host project's tree and fail with "multiple root +documents". + +The marker lives one level **above** this directory, so it stays behind +when you copy this tree elsewhere. + +## Try it + +```sh +# Copy into a fresh git repo: +mkdir /tmp/demo && cd /tmp/demo && git init +cp -r /path/to/skills/doorstop/assets/example_reqs/. . +doorstop # validate (INFO: unreviewed) +doorstop review all # stamp everything +doorstop # clean +doorstop publish REQ ./REQ.md # single-doc Markdown publish +``` + +Note the trailing `/.` in the `cp` above — copies the contents, not the +directory itself. diff --git a/skills/doorstop/assets/example_reqs/reqs/.doorstop.yml b/skills/doorstop/assets/example_reqs/reqs/.doorstop.yml new file mode 100644 index 00000000..8f39e751 --- /dev/null +++ b/skills/doorstop/assets/example_reqs/reqs/.doorstop.yml @@ -0,0 +1,5 @@ +settings: + digits: 3 + itemformat: yaml + prefix: REQ + sep: '' diff --git a/skills/doorstop/assets/example_reqs/reqs/REQ001.yml b/skills/doorstop/assets/example_reqs/reqs/REQ001.yml new file mode 100644 index 00000000..e04e0fb8 --- /dev/null +++ b/skills/doorstop/assets/example_reqs/reqs/REQ001.yml @@ -0,0 +1,10 @@ +active: true +derived: false +header: '' +level: 1.0 +links: [] +normative: false +ref: '' +reviewed: null +text: | + System Requirements diff --git a/skills/doorstop/assets/example_reqs/reqs/REQ002.yml b/skills/doorstop/assets/example_reqs/reqs/REQ002.yml new file mode 100644 index 00000000..677409e1 --- /dev/null +++ b/skills/doorstop/assets/example_reqs/reqs/REQ002.yml @@ -0,0 +1,11 @@ +active: true +derived: false +header: | + Boot time +level: 1.1 +links: [] +normative: true +ref: '' +reviewed: null +text: | + The system **shall** complete startup within 5 seconds of power-on. diff --git a/skills/doorstop/assets/example_reqs/tests/.doorstop.yml b/skills/doorstop/assets/example_reqs/tests/.doorstop.yml new file mode 100644 index 00000000..e88991fa --- /dev/null +++ b/skills/doorstop/assets/example_reqs/tests/.doorstop.yml @@ -0,0 +1,6 @@ +settings: + digits: 3 + itemformat: yaml + parent: REQ + prefix: TST + sep: '' diff --git a/skills/doorstop/assets/example_reqs/tests/TST001.yml b/skills/doorstop/assets/example_reqs/tests/TST001.yml new file mode 100644 index 00000000..b9935715 --- /dev/null +++ b/skills/doorstop/assets/example_reqs/tests/TST001.yml @@ -0,0 +1,13 @@ +active: true +derived: false +header: | + Boot-time smoke test +level: 1.0 +links: +- REQ002: null +normative: true +ref: '' +reviewed: null +text: | + Power-cycle the unit and measure elapsed time from power-on to the + "ready" signal. The measured value must be ≤ 5 seconds. diff --git a/skills/doorstop/references/cli-commands.md b/skills/doorstop/references/cli-commands.md new file mode 100644 index 00000000..84833195 --- /dev/null +++ b/skills/doorstop/references/cli-commands.md @@ -0,0 +1,369 @@ +# CLI Commands Reference + +Every `doorstop` subcommand with its exact flags, canonical invocations, and +when to reach for it. + +## Global invocation + +``` +doorstop [global-options] [ [subcommand-options] [args]] +``` + +With no subcommand, `doorstop` validates the tree and prints the hierarchy. +Exits 0 on success, 1 on validation failure or any error. + +### Global options (apply to every subcommand) + +| Flag | Effect | +|---|---| +| `-j PATH`, `--project PATH` | Project root (default: auto-detected via VCS) | +| `-v, --verbose` | Increase logging (stackable: `-vv`, `-vvv`) | +| `-q, --quiet` | Errors and prompts only | +| `-V, --version` | Print version and exit | +| `--server HOST` | Point at a running `doorstop-server` for UID reservation | +| `--port NUMBER` | Server port (default 7867) | +| `-f, --force` | Perform the action without the server (don't reserve UIDs) | + +### Validation options (apply to the bare `doorstop` invocation) + +| Flag | Effect | +|---|---| +| `-F, --no-reformat` | Don't rewrite item files to canonical form | +| `-r, --reorder` | Auto-reorder item levels during validation | +| `-L, --no-level-check` | Skip level-gap / duplicate-level checks | +| `-R, --no-ref-check` | Skip external-reference resolution | +| `-C, --no-child-check` | Skip child-link (reverse-link) checks | +| `-Z, --strict-child-check` | *Require* child links from every document below | +| `-S, --no-suspect-check` | Skip suspect-link checks | +| `-W, --no-review-check` | Skip unreviewed-item checks | +| `-s PREFIX, --skip PREFIX` | Skip a document (repeatable) | +| `-w, --warn-all` | Escalate `INFO` to `WARNING` | +| `-e, --error-all` | Escalate `WARNING` to `ERROR` (non-zero exit) | + +See `references/validation.md` for when each flag is safe. + +--- + +## `doorstop` (no subcommand) + +Validates the tree. Runs cycle detection, level checks, link resolution, +suspect-link checks, and review-status checks. + +``` +doorstop # full validation +doorstop -e # CI mode: warnings become errors +doorstop -s REQ -s TST # skip two documents +doorstop --no-suspect-check # quick structural check ignoring staleness +``` + +Exits 0 iff the tree is valid under the active flags. + +--- + +## `doorstop create PREFIX PATH` + +Create a new document directory (writes `PATH/.doorstop.yml`). + +| Flag | Default | Effect | +|---|---|---| +| `-p PREFIX, --parent PREFIX` | none (root doc) | Parent document's prefix | +| `-i {yaml,markdown}, --itemformat` | `yaml` | Item file format (set *once*) | +| `-d N, --digits N` | `3` | Zero-padded UID digit count | +| `-s SEP, --separator SEP` | `""` | Separator in UIDs; only `-`, `_`, `.` allowed | + +``` +doorstop create REQ ./reqs # root doc +doorstop create TST ./tests --parent REQ # child of REQ +doorstop create LLR ./reqs/llr --parent REQ -d 4 -s _ # LLR_0001 +doorstop create SPEC ./spec -i markdown # markdown items +``` + +Fails if `.doorstop.yml` already exists, if `sep` uses a forbidden character, +or if `parent` doesn't resolve. + +--- + +## `doorstop delete PREFIX` + +Delete the named document and every item in it. Destructive — confirm first. + +``` +doorstop delete TST +``` + +--- + +## `doorstop add PREFIX` + +Create a new item file in a document. Without `--edit`, the file is created +populated with defaults and saved. + +| Flag | Effect | +|---|---| +| `-l LEVEL, --level LEVEL` | Desired level (e.g. `1.2.3`); auto-computed if omitted | +| `-n NANU, --name NANU`, `--number NANU` | Use this name/number instead of auto-increment | +| `-c N, --count N` | Create N items (default 1) | +| `--edit` | Open the new item in `$EDITOR` after creating | +| `-T PROGRAM, --tool PROGRAM` | Override `$EDITOR` for `--edit` | +| `-d FILE, --defaults FILE` | YAML file with default attribute values | +| `--noreorder` | Don't reorder sibling levels after add | + +``` +doorstop add REQ # next auto-numbered UID, default level +doorstop add REQ -c 5 # add 5 items +doorstop add REQ -l 2.1.3 # specific level +doorstop add REQ -n login # UID = REQ-login (requires non-empty sep) +doorstop add REQ --edit # open in editor after create +``` + +Without a running `doorstop-server`, UID reservation is local. With `--server`, +Doorstop reserves via `POST /documents//numbers`. + +--- + +## `doorstop remove UID` + +Delete a single item by UID. Does **not** unlink it first — if anything links +to it, the link becomes dangling and validation will error. + +``` +doorstop remove REQ042 +``` + +Always `doorstop unlink` first if there are children. + +--- + +## `doorstop edit LABEL` + +Edit an item (by UID) or a whole document (by prefix). + +| Flag | Effect | +|---|---| +| `-a, --all` | Edit the whole item (YAML), not just the `text:` field | +| `-i, --item` | Force `label` to be parsed as an item UID | +| `-d, --document` | Force `label` to be parsed as a document prefix | +| `-y, --yaml` | When editing a document, round-trip through YAML (default) | +| `-c, --csv` | Round-trip through CSV | +| `-t, --tsv` | Round-trip through TSV | +| `-x, --xlsx` | Round-trip through XLSX | +| `-T PROGRAM, --tool` | Override `$EDITOR` | + +Item edits lock the file via the VCS integration to prevent concurrent writes. + +``` +doorstop edit REQ001 # edit text only +doorstop edit REQ001 -a # edit all attributes +doorstop edit REQ -d -x # dump REQ to XLSX, open editor, re-import +``` + +When editing a whole document, Doorstop exports → opens → re-imports and then +offers to delete the intermediate file. + +--- + +## `doorstop reorder PREFIX` + +Organize a document's outline. Three modes: + +- Default (no flags): generate an `index.yml` in the document directory, open + it in `$EDITOR` for manual editing, then re-import. +- `-a, --auto`: auto-shift levels to eliminate duplicates and gaps without an + index file. +- `-m, --manual`: manual mode; do not auto-fix after reading the index. + +| Flag | Effect | +|---|---| +| `-a, --auto` | Pure auto (no index interaction) | +| `-m, --manual` | Pure manual (no auto-fix after index) | +| `-T PROGRAM, --tool` | Override `$EDITOR` | + +``` +doorstop reorder REQ -a # auto-fix duplicate/skipped levels +doorstop reorder REQ # generate index.yml, edit by hand, re-import +``` + +--- + +## `doorstop link CHILD PARENT` + +Add a traceability link from `CHILD` to `PARENT` (both UIDs). Fails on +self-links, cycles, and missing items. + +``` +doorstop link TST007 REQ023 +``` + +## `doorstop unlink CHILD PARENT` + +Remove the link. Fails silently-with-warning if the link did not exist. + +``` +doorstop unlink TST007 REQ023 +``` + +--- + +## `doorstop clear LABEL [PARENTS...]` + +Mark suspect links as cleared. `LABEL` is an item UID, a document prefix, or +`all`. Optional positional `PARENTS` narrow the clearing to specific parents. + +| Flag | Effect | +|---|---| +| `-i, --item` | Force `label` as item UID | +| `-d, --document` | Force `label` as document prefix | + +``` +doorstop clear TST007 # clear TST007's suspects from all parents +doorstop clear TST007 REQ023 REQ024 # only to those parents +doorstop clear TST # clear every item in TST +doorstop clear all # nuclear: clear everything +``` + +Clearing updates the stored parent-fingerprint to the *current* parent +fingerprint — it does **not** change the item's own `reviewed:` hash. + +--- + +## `doorstop review LABEL` + +Mark an item, document, or the whole tree as reviewed. Rewrites `reviewed:` to +the current fingerprint. + +| Flag | Effect | +|---|---| +| `-i, --item` | Force `label` as item UID | +| `-d, --document` | Force `label` as document prefix | + +``` +doorstop review REQ001 +doorstop review REQ +doorstop review all +``` + +If `.doorstop.yml` has `extensions.item_sha_required: true`, `review` also +updates the `sha:` on every external file reference. + +--- + +## `doorstop import PATH PREFIX` + +Three disjoint modes selected by argument shape: + +``` +# Mode 1: import items from an exported file into an existing document +doorstop import items.xlsx REQ +doorstop import items.csv REQ -m "{'Requirement': 'text'}" + +# Mode 2: register a pre-existing document directory +doorstop import -d REQ ./reqs -p SYS # -p = parent + +# Mode 3: create a specific item by UID (backfill) +doorstop import -i REQ REQ042 -a "{'text': 'Legacy item.'}" +``` + +| Flag | Effect | +|---|---| +| `-d PREFIX PATH, --document PREFIX PATH` | Mode 2 | +| `-i PREFIX UID, --item PREFIX UID` | Mode 3 | +| `-p PREFIX, --parent PREFIX` | Parent prefix (mode 2) | +| `-a DICT, --attrs DICT` | Python-literal dict of item attributes | +| `-m DICT, --map DICT` | Python-literal dict mapping source → Doorstop attr names | + +Supported extensions for mode 1: `.yml`, `.csv`, `.tsv`, `.xlsx`. + +In XLSX round-trip, **leave the UID cell blank to create a new item**. Never +rename an existing UID. + +--- + +## `doorstop export PREFIX [PATH]` + +Export a single document or `all` documents. Without `PATH`, writes to stdout. + +| Flag | Default per ctx | Effect | +|---|---|---| +| `-y, --yaml` | default (no path) | YAML | +| `-c, --csv` | default for `all` | CSV | +| `-t, --tsv` | | TSV | +| `-x, --xlsx` | | XLSX | +| `-w N, --width N` | | Line width on text | + +``` +doorstop export REQ # YAML to stdout +doorstop export REQ REQ.xlsx # XLSX file +doorstop export all ./out # one CSV per document in ./out +``` + +Note: `all` cannot be displayed to stdout. + +--- + +## `doorstop publish PREFIX [PATH]` + +Publish to a human-readable format. `PREFIX` can be `all`. Without `PATH`, +writes to stdout. + +| Flag | Default per ctx | Effect | +|---|---|---| +| `-t, --text` | default (no path) | Plain text | +| `-m, --markdown` | | Markdown | +| `-l, --latex` | | LaTeX | +| `-H, --html` | default for `all` | HTML | +| `-w N, --width N` | | Line width on text | +| `-C, --no-child-links` | | Omit reverse-link sections | +| `--no-levels {all,body}` | | Hide levels on {everything, body items only} | +| `--template FILE` | | Custom template (HTML/Markdown) | +| `--index` | | Generate top-level index (Markdown/HTML) | + +``` +doorstop publish REQ REQ.html +doorstop publish REQ REQ.md -m +doorstop publish all ./publish # multi-doc HTML with index +doorstop publish all ./publish --index # force-generate index +doorstop publish REQ -m --no-child-links # Markdown to stdout, no reverse-links +``` + +`publish all ` produces: + +``` +/ +├── index.html # top-level index linking all docs +├── traceability.csv # matrix of parent↔child links (when applicable) +└── documents/ + ├── REQ.html + ├── TST.html + └── assets/ # copied from each document's assets/ +``` + +See `references/publishing.md` for template authoring and the matrix format. + +--- + +## Subcommand semantics quick-reference + +| Command | Mutates disk | Mutates VCS | Needs server | Typical exit | +|---|---|---|---|---| +| `doorstop` | no (read-only unless `-F`/`-r`) | no | no | 0 valid, 1 invalid | +| `create` | yes (new dir + config) | no | no | 0 / 1 | +| `delete` | yes (removes dir) | no | no | 0 / 1 | +| `add` | yes (new file) | no | optional (UID reservation) | 0 / 1 | +| `remove` | yes (removes file) | no | no | 0 / 1 | +| `edit` | yes | locks via VCS | no | 0 / 1 | +| `reorder` | yes (levels) | no | no | 0 / 1 | +| `link` / `unlink` | yes (child file) | no | no | 0 / 1 | +| `clear` / `review` | yes (item fields) | no | no | 0 / 1 | +| `import` | yes | no | optional | 0 / 1 | +| `export` / `publish` | yes (output only) | no | no | 0 / 1 | + +## When **not** to reach for each command + +- `delete PREFIX` — if you actually want to move the document, there is no + rename; delete + recreate + re-import is the only path. +- `remove UID` — if children link to it; `unlink` first. +- `clear` — if the parent *actually changed meaningfully* and you want the + child to flag unreviewed; in that case do `review` on the parent, then + re-review the child intentionally instead of masking with `clear`. +- `--no-suspect-check`, `--no-review-check` — in CI. These turn off the very + checks requirements management exists to enforce. diff --git a/skills/doorstop/references/extensions.md b/skills/doorstop/references/extensions.md new file mode 100644 index 00000000..e44978ef --- /dev/null +++ b/skills/doorstop/references/extensions.md @@ -0,0 +1,204 @@ +# Extensions + +Three extension points, from least to most invasive: + +1. **`extensions.item_validator`** in `.doorstop.yml` — per-document custom + validator loaded from a Python file alongside the document. +2. **`tree.validate(document_hook=, item_hook=)`** from a Python script + that runs in place of `doorstop`. +3. **Custom publisher / custom attribute** — subclass into + `doorstop.core.publishers` or read extended attributes via the Python + API. + +Plus one opt-in flag for reference integrity: **`item_sha_required`**. + +## `extensions.item_validator` — per-document validator + +Enable in a document's `.doorstop.yml`: + +```yaml +settings: + digits: 3 + prefix: REQ + sep: '' +extensions: + item_validator: validators/req_validator.py # path relative to .doorstop.yml +``` + +Create the Python file (here `reqs/req/validators/req_validator.py`): + +```python +from doorstop import DoorstopError, DoorstopInfo, DoorstopWarning + + +def item_validator(item): + """Yield Doorstop{Error,Warning,Info} for each issue.""" + if not item.get("owner"): + yield DoorstopWarning("no owner assigned") + if item.derived and not item.get("rationale"): + yield DoorstopError("derived item without rationale") + if item.active and not item.get("type"): + yield DoorstopInfo("no type tag") +``` + +Rules: + +- The function **must** be named `item_validator` and take a single `item` + argument. +- It may `yield` or `return` an iterable. Yielding is idiomatic. +- Yield `DoorstopInfo`, `DoorstopWarning`, or `DoorstopError` — severity + maps 1:1 to the validation severity matrix (see `validation.md`). +- Errors short-circuit the item's validation exit status; warnings and infos + don't. +- The validator runs on **every** item in the document, every time + `doorstop` (or `tree.validate()`) runs. +- The validator is loaded via `importlib` from the path in `.doorstop.yml`. + Keep it free of heavy imports — no network, no DB. +- Path is relative to `.doorstop.yml`. Absolute paths and `!include` are not + supported here. + +Use cases: + +- Enforce required extended attributes (`owner`, `type`, + `verification-method`). +- Enforce ref-file SHA integrity (the canonical example — see + `item_sha_required` below). +- Enforce text-style rules ("shall" vocabulary, length limits). +- Cross-check links against an external source (sparingly — hits disk every + validation). + +## `item_sha_required` — reference-file integrity + +Opt in per-document: + +```yaml +extensions: + item_sha_required: true +``` + +With this flag: + +- Every entry in an item's `references:` list must carry a `sha:` field + (SHA-256 of the referenced file's contents). +- On `doorstop review `, doorstop computes and inserts the `sha:` if + missing. +- When a referenced file changes on disk, its SHA won't match the stored + value → the item is suspect in a way that isn't caught by the default + fingerprint. Pair with a custom `item_validator` to turn the mismatch + into a `DoorstopError` (`docs/api/scripting.md:100-107`): + +```python +from doorstop import DoorstopError + +def item_validator(item): + if getattr(item, "references", None) is None: + return + for ref in item.references: + if ref.get("sha") != item._hash_reference(ref["path"]): + yield DoorstopError("referenced file changed without re-review") +``` + +## `tree.validate(document_hook=, item_hook=)` — programmatic validator + +Replace `doorstop` (the CLI) with a Python script for cases where +`item_validator` isn't enough (cross-document logic, shared state, expensive +setup you want done once): + +```python +#!/usr/bin/env python +import sys + +from doorstop import build, DoorstopError, DoorstopInfo, DoorstopWarning + + +def main(): + tree = build() + ok = tree.validate(document_hook=check_document, item_hook=check_item) + sys.exit(0 if ok else 1) + + +def check_document(document, tree): + normative_count = sum(1 for i in document if i.normative) + if normative_count < 10: + yield DoorstopInfo(f"{document}: only {normative_count} normative items") + + +def check_item(item, document, tree): + if not item.get("type"): + yield DoorstopWarning(f"{item.uid}: no type tag") + if item.derived and not item.get("rationale"): + yield DoorstopError(f"{item.uid}: derived but no rationale") + + +if __name__ == "__main__": + main() +``` + +Source: `docs/api/scripting.md:39-71`. + +Hook signatures: + +- `document_hook(document, tree)` — called once per document. +- `item_hook(item, document, tree)` — called once per item. + +Both are generators. Both are optional; pass only the ones you need. + +Exit: `tree.validate()` returns `True` on success (no errors), `False` +otherwise. Your script chooses how to map that to an exit code. + +## Custom publisher + +Built-in publishers live in `doorstop/core/publishers/` (text, markdown, +html, latex). To add a new format: + +1. Subclass `BasePublisher` from `doorstop/core/publishers/base.py`. +2. Register it by importing and invoking it directly — the CLI's `publish` + subcommand only knows about the built-in formats. For one-off use, call + from a script: + +```python +import doorstop +from my_pkg.publishers import ReqIFPublisher + +tree = doorstop.build() +ReqIFPublisher().publish(tree, "/tmp/out.reqif") +``` + +The CLI `publish` subcommand is not pluggable without forking doorstop or +patching `doorstop.core.publisher`. Keep custom publishers in a sibling +package and drive them from a script for maintainability. + +## Custom attributes (reminder) + +Extended attributes are first-class via the Python API — no extension +needed. See `file-formats.md` for `attributes.defaults`, +`attributes.reviewed`, and `attributes.publish`. Common uses: `owner`, +`priority`, `ticket`, `type`, `verification-method`. + +To enforce them, combine: + +- `attributes.defaults` (so items without the attr still have a value) +- `attributes.reviewed` (so changes feed the fingerprint) +- `extensions.item_validator` (to raise errors on bad values) + +## Non-negotiables for extensions + +- **Don't mutate items from a validator.** Validators run during + `tree.validate()` — mutations during validation are undefined behavior + and won't be re-checked. +- **Don't raise exceptions** from a validator. Yield `DoorstopError` + instead. Uncaught exceptions abort the entire validation run. +- **Keep validators idempotent and pure.** The same item should always + yield the same set of issues. +- **Don't rely on ordering** between `document_hook` and `item_hook` + invocations — order is an implementation detail. + +## Debugging a validator + +If a validator isn't running: + +1. Confirm the path in `.doorstop.yml` is correct relative to the + `.doorstop.yml` file (not to the project root). +2. Confirm the function is named `item_validator` exactly. +3. Run `doorstop -v` to surface import errors from the validator file. +4. Add a `print` at the top of `item_validator` to confirm it's loaded. diff --git a/skills/doorstop/references/file-formats.md b/skills/doorstop/references/file-formats.md new file mode 100644 index 00000000..a466e179 --- /dev/null +++ b/skills/doorstop/references/file-formats.md @@ -0,0 +1,257 @@ +# File formats + +This is the authoritative on-disk reference: `.doorstop.yml` for documents, +YAML or Markdown-with-frontmatter for items, and the surrounding conventions +(UIDs, `references:`, extended attributes, `!include`). + +Prefer the CLI for mutations. Hand-edits are for schema-level things the CLI +doesn't expose: extending `attributes.defaults`, `attributes.reviewed`, +`attributes.publish`, `extensions`, or switching `sep`. + +## Document: `.doorstop.yml` + +Every document directory contains exactly one `.doorstop.yml` file. Everything +else in the directory is either an item file or child content. + +```yaml +settings: + prefix: REQ # mandatory, read-only after first item + sep: '' # mandatory, read-only after first item ('' or '-' or '_') + digits: 3 # mandatory, read-only + parent: SYS # optional; set by `doorstop create --parent SYS` + itemformat: yaml # 'yaml' (default) or 'markdown' — read-only after first item +attributes: + defaults: # optional — default values for extended attributes + type: functional + verification-method: test + reviewed: # optional — extended attrs that feed the fingerprint + - type + - verification-method + publish: # optional — extended attrs that appear in published output + - invented-by + - type +extensions: # optional — per-document validator hooks and flags + item_validator: validators/req_validator.py # path to .py file, relative to .doorstop.yml + item_sha_required: true # require `sha` on each `references:` entry +``` + +### `settings` keys + +| Key | Required | Read-only after first item | Default | Meaning | +|---|---|---|---|---| +| `prefix` | yes | yes | — | UID prefix. e.g. `REQ`, `TST`, `LLR`. Must be unique across the tree. | +| `sep` | yes | yes | `''` | Separator between prefix and number. `''` → `REQ001`; `-` → `REQ-001`; `_` → `REQ_001`. | +| `digits` | yes | yes | `3` | Zero-padding width for numeric UIDs. | +| `parent` | no | yes | — | Prefix of parent document. Set by `doorstop create -p`. Root document has no parent. | +| `itemformat` | yes | yes | `yaml` | `yaml` or `markdown`. Mixing within one document is forbidden. | + +"Read-only after first item" = changing these values will strand or break +existing items. Decide `prefix` / `sep` / `digits` / `itemformat` at `create` +time and do not revisit. + +### `attributes` keys + +| Key | Type | Purpose | +|---|---|---| +| `defaults` | mapping | Fallback values for extended attributes that don't appear in an item file. | +| `reviewed` | list of strings | Extended attribute names whose values feed the `reviewed:` fingerprint. | +| `publish` | list of strings | Extended attribute names to render in publish output (since v2.2). | + +### `extensions` keys + +| Key | Type | Purpose | +|---|---|---| +| `item_validator` | path | Python file (relative to the document dir) that exports `item_validator(item)`. Called on every item during `tree.validate()`. | +| `item_sha_required` | bool | When true, each entry in `references:` must carry a `sha:` field. `doorstop review` inserts the SHA if missing. | + +### `!include` composition + +Inside `.doorstop.yml` you can pull content from a sibling YAML file with the +`!include` tag. Paths are relative to the file containing the tag. Absolute +paths are not supported. + +```yaml +# .doorstop.yml +attributes: + defaults: + text: !include templates/boilerplate-text.yml +``` + +```yaml +# templates/boilerplate-text.yml +| + Shared boilerplate that many items start from. + Multi-line YAML block scalar. +``` + +Use `!include` for large repeated defaults or template text — not for item +content itself. + +## Item: YAML format + +Filename = UID + `.yml` or `.yaml`. The filename (minus extension) **is** the +UID. Never rename an item file — the UID is immutable. + +```yaml +active: true +derived: false +header: | + Identifiers +level: 2.1 +links: + - REQ010: null # bare-UID form also legal: `- REQ010` + - REQ011: avwblqPimDJ2OgTrRCXxRPN8FQhUBWqPIXm7kSR95C4= +normative: true +ref: '' # legacy single-reference string +references: # new-style array of external refs + - path: src/sensors/temp.c + type: file + - path: tests/test_temp.c + type: file + keyword: REQ-TEMP # optional — grep-like line search + sha: 28c16553... # required when item_sha_required: true +reviewed: 9TcFUzsQWUHhoh5wsqnhL7VRtSqMaIhrCXg7mfIkxKM= +text: | + Doorstop **shall** provide unique and permanent identifiers to linkable + sections of text. + +# Custom extended attributes follow — any valid YAML below this point is fair +# game. Values can be scalars, lists, or mappings. +invented-by: jane@example.com +type: functional +verification-method: test +``` + +### Standard attributes + +| Attr | Type | Default | In fingerprint? | Meaning | +|---|---|---|---|---| +| `active` | bool | `true` | no | `false` hides the item from publish and skips validation. | +| `derived` | bool | `false` | no | `true` = no upstream source required; validation won't complain about missing parent link. | +| `normative` | bool | `true` | no | `false` + `level` ending in `.0` = heading. Headings aren't linked or validated as requirements. | +| `level` | outline | `1.0` | no | Presentation order, e.g. `1.2.3`. Quote non-float values: `level: '1.10'`. | +| `header` | text | `''` | no | Short title shown next to UID in published output. Different from a heading item. | +| `text` | text | `''` | **yes** | Main body. Markdown. Multi-line via `|` or `>-`. | +| `ref` | string | `''` | **yes** | Legacy single reference (file or keyword). Prefer `references:`. | +| `references` | list | `null` | **yes** | Array of external refs. See table below. | +| `links` | list | `[]` | UIDs only | Parent UIDs (child → parent direction). Fingerprints are stored but don't feed the item's own fingerprint. | +| `reviewed` | stamp | `null` | — | Stored SHA-256 (url-safe Base64) of the fingerprint at last review. **Never hand-edit.** | + +### `references:` entry shape + +Each entry is a mapping: + +| Key | Required | Meaning | +|---|---|---| +| `path` | yes | Relative path from repo root to the referenced file. | +| `type` | yes | Currently always `file`. | +| `keyword` | no | If set, doorstop also grep-searches the file for this keyword and records the matching line. | +| `sha` | no (unless `item_sha_required: true`) | SHA-256 of the referenced file's content. `doorstop review` fills this. | + +### UID grammar + +``` +UID := PREFIX SEP (NUMBER | NAME) +PREFIX := starts with a letter, alphanumeric + '_' (no SEP chars) +SEP := '' | '-' | '_' (from .doorstop.yml) +NUMBER := zero-padded decimal, width = settings.digits +NAME := arbitrary string without SEP — used for named items +``` + +Examples: `REQ001` (`sep: ''`, `digits: 3`), `REQ-001` (`sep: '-'`), `TST_007` +(`sep: '_'`), `REQ-login-flow` (named item). + +### Fingerprint computation + +The `reviewed:` stamp is SHA-256 (URL-safe Base64) over a canonical +serialization of, in order: + +1. `uid` +2. `text` +3. `ref` +4. `references` (full list including `path`, `type`, `keyword`, `sha`) +5. `links` — UIDs only (not the stored link fingerprints) +6. Any extended attribute listed in `attributes.reviewed` (in the order listed) + +Attributes **not** in this set — `active`, `derived`, `normative`, `level`, +`header`, custom attrs not in `attributes.reviewed` — do **not** affect the +stamp. Changing them does not mark the item unreviewed. + +Do not compute this yourself. Use `doorstop review `. + +### Legacy `ref` vs new `references` + +Both can coexist. New items should prefer `references:`. The legacy `ref` +string is still supported and checked: + +- `ref: 'src/foo.c'` — filename search in project tree; first match wins. +- `ref: 'MY-KEYWORD'` — grep-like search across text files; first hit wins. + +If a referenced file/keyword is unresolved, validation fails with an ERROR +unless `-r/--no-ref-check` is passed. + +## Item: Markdown-with-frontmatter format + +Filename = UID + `.md`. Selected with `itemformat: markdown` in `.doorstop.yml` +at document creation time. + +```markdown +--- +active: true +derived: false +level: 2.1 +links: + - REQ010: null +normative: true +ref: '' +reviewed: 9TcFUzsQWUHhoh5wsqnhL7VRtSqMaIhrCXg7mfIkxKM= +--- + +# Identifiers + +Doorstop **shall** provide unique and permanent identifiers to linkable +sections of text. +``` + +Rules specific to this format: + +- Frontmatter (between `---` fences) holds every attribute **except** `text` + and `header`. +- The body below the frontmatter is the `text` value. +- The first `# heading` line in the body is parsed out as `header` and + stripped from `text`. Any subsequent headings stay in `text`. +- Everything else — fingerprint rules, validation, linking, levels — + behaves exactly as in the YAML format. +- Two documents in the same tree can use different `itemformat`s, but every + item inside a given document must match that document's format. + +## Extended attributes + +Any key that isn't in the standard list is an "extended attribute". They are: + +- Preserved as-is on save. +- Excluded from published output by default — add them to + `attributes.publish` to render them. +- Excluded from the fingerprint by default — add them to + `attributes.reviewed` to pull them in. +- Optionally defaulted via `attributes.defaults`. + +Common uses: `type`, `verification-method`, `owner`, `ticket`, `priority`. + +## Do / Don't + +- **Do** let `doorstop create` / `add` / `edit` / `link` / `review` write + these files. They know about format, fingerprint, and link invariants. +- **Do** hand-edit `.doorstop.yml` when changing `attributes.defaults`, + `attributes.reviewed`, `attributes.publish`, or `extensions`. No CLI for + these. +- **Don't** hand-edit `reviewed:` stamps. They drift, and validation will + tell on you. +- **Don't** rename item files. The filename is the UID; to rename, `remove` + the old UID and `add` a new one and re-link. +- **Don't** mix `itemformat` within a single document. Set it at `create` + time and leave it. +- **Don't** set `sep` or `digits` after items exist. Filename-to-UID parsing + will stop matching the old files. +- **Don't** assume ordering of YAML keys matters. It doesn't — doorstop + re-sorts on save. diff --git a/skills/doorstop/references/import-export.md b/skills/doorstop/references/import-export.md new file mode 100644 index 00000000..d7c3992b --- /dev/null +++ b/skills/doorstop/references/import-export.md @@ -0,0 +1,155 @@ +# Import and export + +Export rehydrates a document as a portable file. Import pushes changes back. +The round trip is the primary integration path for spreadsheet-first +authors and for bulk edits from external tools. + +## Format matrix + +| Format | Export flag | Import format detection | Best for | +|---|---|---|---| +| YAML | `-y` / `--yaml` | `.yml`, `.yaml` | Scripting, diffing, machine round-trip | +| CSV | `-c` / `--csv` | `.csv` | Git-friendly spreadsheet | +| TSV | `-t` / `--tsv` | `.tsv` | Spreadsheet with tab-separated columns | +| XLSX | `-x` / `--xlsx` | `.xlsx` | Excel/LibreOffice authoring | + +Sources: `doorstop/cli/main.py:487-506` (export), `:448-484` (import). + +## Export + +```sh +doorstop export REQ /tmp/req.xlsx # explicit path + extension +doorstop export REQ # stdout, YAML (default with no path) +doorstop export all /tmp/out # every document to a directory +``` + +### `-w/--width` (text-ish output) + +Wraps exported text columns. Applies to formats that render free text +columns (CSV/TSV), not XLSX cells. + +### `all` special prefix + +`doorstop export all ` emits one file per document into ``. +Default format for `all` is CSV if no format flag or extension is +detectable. + +## Import + +```sh +doorstop import /tmp/req.xlsx REQ # positional form +doorstop import --document REQ ./imported/ # create a new document from path +doorstop import --item REQ REQ042 -a level=1.5 -a normative=true +``` + +Source: `doorstop/cli/main.py:448-484`. + +### The three import modes + +| Mode | Form | What it does | +|---|---|---| +| Round-trip (file) | `doorstop import ` | Reads file, creates new items for blank UIDs, updates existing for known UIDs. | +| New document | `doorstop import --document PREFIX PATH [-p PARENT]` | Registers an already-laid-out directory of item files as a doorstop document. | +| New item | `doorstop import --item PREFIX UID -a key=value [-a ...]` | Creates one item with the given UID and attribute overrides. | + +Only one of `--document` or `--item` can be passed. + +### Round-trip: the blank-UID convention + +In a spreadsheet import, any row whose UID column is **blank** is treated +as a new item. Doorstop reserves a fresh UID (through the server if +running, otherwise locally) and creates the item with the row's attribute +values. Rows with existing UIDs are updated in place. + +After any import, the touched items are left **unreviewed** — the whole +point is that content changed. Run `doorstop review ` (or `doorstop +review -d` for the whole document) once you've confirmed the +import is what you wanted. + +### `-a key=value` attribute overrides + +Repeatable. On `--item` mode, sets attributes at create time. On round-trip +mode, can be used as fallbacks for columns the spreadsheet omitted. + +Common keys: `level`, `normative`, `active`, `derived`, `header`, or any +custom extended attribute. + +### `-m/--map` column renames + +When the spreadsheet's column names don't match doorstop's attribute names: + +```sh +doorstop import /tmp/req.xlsx REQ -m "{'Requirement': 'text', 'Tag': 'type'}" +``` + +The argument is a Python-literal dict (use single quotes inside double +quotes on the shell). Every occurrence of `Requirement` in the spreadsheet +header row is read as `text`, and `Tag` is read as custom attribute `type`. + +### `-p PARENT` — only with `--document` + +When creating a document from a directory, sets the parent prefix (same as +the `--parent` flag on `doorstop create`). + +## Round-trip recipe + +```sh +doorstop export REQ /tmp/req.xlsx +# Edit /tmp/req.xlsx in Excel/LibreOffice. +# - Change text cells freely. +# - Add new rows with a blank UID column → they become new items. +# - Never edit the `reviewed` column by hand. +doorstop import /tmp/req.xlsx REQ +doorstop # validate +doorstop review REQ -d # mark the whole document reviewed +``` + +Before importing a spreadsheet authored by someone else, **diff** it +against a fresh export of the live tree — spreadsheet tools often eat +newlines, coerce numbers, or strip leading zeros. + +## Import a whole document from a directory + +Scenario: someone handed you a folder of YAML files laid out in doorstop's +format, but never registered as a document. + +```sh +doorstop import --document REQ /path/to/their/reqs +``` + +Equivalent to: create the `.doorstop.yml` if missing, register every +matching file as an item. + +## Import a single item + +```sh +doorstop import --item REQ REQ042 -a text="the rule" -a level=2.3 -a normative=true +``` + +Useful for scripted creation when `doorstop add` doesn't fit (e.g. you need +a specific UID). + +## Gotchas + +- **Leading zeros**: Excel drops `001` → `1`. If your `digits: 3` and a UID + becomes `REQ1`, doorstop will treat it as a new item (or error) because + the filename no longer matches the UID grammar. Format the UID column as + text in Excel before editing. +- **Line endings**: CSV from Windows has CRLF, from Mac has LF. Doorstop + handles both, but git diffs will be noisy. Use `.gitattributes` if you + commit CSV. +- **Multi-line text**: CSV encodes newlines within a cell as `\n` inside + quoted strings. Excel mostly gets this right. LibreOffice mostly gets it + right. Script-generated CSV needs care. +- **Extended attributes**: preserved on round-trip only if they appear as + columns. Adding a new column ≈ adding a new extended attribute. Remove a + column to drop it from all items (dangerous; prefer targeted edits). +- **`reviewed` column**: exported but **never re-imported as-is**. Doorstop + clears the stamp on re-import because content may have changed. + +## Programmatic alternatives + +If the spreadsheet middleware is painful, use the Python API directly +(`references/python-api.md`): `doorstop.importer.import_file`, +`doorstop.exporter.export`, or iterate `Item` objects and set attributes. +Scripts are easier to review than hand-edited XLSX. diff --git a/skills/doorstop/references/publishing.md b/skills/doorstop/references/publishing.md new file mode 100644 index 00000000..ddbc9148 --- /dev/null +++ b/skills/doorstop/references/publishing.md @@ -0,0 +1,161 @@ +# Publishing + +`doorstop publish` renders a document (or the whole tree) to one of four +formats. Format selection is driven by explicit flags first, then by the +output file's extension, then by defaults. + +## Subcommand shape + +``` +doorstop publish [] + [-t|-m|-l|-H] + [-w WIDTH] + [-C|--no-child-links] + [--no-levels {all|body}] + [--template FILE] + [--index] +``` + +Source: `doorstop/cli/main.py:509-546`. + +## Format matrix + +| Format | Flag | Extension auto-detect | Default for | +|---|---|---|---| +| Text | `-t`, `--text` | `.txt` | Stdout (no path) | +| Markdown | `-m`, `--markdown` | `.md` | — | +| LaTeX | `-l`, `--latex` | `.tex` | — | +| HTML | `-H`, `--html` | `.html` | `publish all ` | + +Rules: + +- Explicit flag always wins. +- If no flag and `path` has a recognized extension, the extension picks the + format. +- If no flag and no path, it's text to stdout. +- If no flag and `publish all ` (directory), HTML. + +## `publish all ` — full tree + +Generates a directory with: + +- `index.html` — tree-wide TOC linking to each document. +- `.html` per document. +- Assets (CSS, JS) for HTML output copied alongside. + +```sh +doorstop publish all ./publish +``` + +After running, open `./publish/index.html` in a browser. + +## Single-document publish + +Two forms — the extension-driven form is easier to read: + +```sh +# Extension picks the format: +doorstop publish REQ ./out/REQ.html +doorstop publish REQ ./out/REQ.md +doorstop publish REQ ./out/REQ.tex +doorstop publish REQ ./out/REQ.txt + +# Flag picks the format: +doorstop publish REQ ./out/REQ -H # HTML +doorstop publish REQ ./out/REQ -m # Markdown +``` + +## Flags + +| Flag | What it does | When to use | +|---|---|---| +| `-w WIDTH` | Wrap text output at WIDTH columns. Text/LaTeX only. | Constraining terminal output or diff-friendly plain text. | +| `-C`, `--no-child-links` | Omit the reverse-link annotations (children pointing up to this item). | You want a clean requirements spec without a traceability view mixed in. | +| `--no-levels all` | Strip every level number from output. | Publishing to a downstream tool that imposes its own numbering. | +| `--no-levels body` | Strip levels only from non-heading items. | Keep section numbers on headings, drop them on leaf requirements. | +| `--template FILE` | Use a custom Jinja2 (or format-specific) template. | Branded HTML, corporate LaTeX class, custom Markdown scaffolding. | +| `--index` | In Markdown mode, also produce a top-level `index.md`. | Publishing a docs-site-friendly tree of Markdown files. | + +## Templates + +Each publisher uses the format's native templating. Built-in templates live +under `doorstop/core/publishers/`. To customize: + +1. Copy a built-in template to your project (e.g. + `doorstop/core/publishers/templates/html/base.html`). +2. Edit. +3. Pass `--template `. + +HTML templates use Jinja2. LaTeX templates are passthrough `.tex` scaffolds. +Markdown has a small scaffold for `--index` mode. + +## Traceability + +The HTML publish output includes a per-document traceability sidebar by +default: each item shows its parent links and (unless `-C`) its child links. +For a cross-document traceability matrix, use the Python API: + +```python +import doorstop +tree = doorstop.build() +matrix = tree.get_traceability() # iterable of tuples (REQ, ..., TST) +``` + +Or use `doorstop publish all` and inspect the generated index, which links +through every item. + +## `attributes.publish` — extended attributes in output + +To include custom (extended) attributes in published output, list them in +the document's `.doorstop.yml`: + +```yaml +attributes: + publish: + - invented-by + - type + - verification-method +``` + +Attributes in this list render as labeled rows beneath each item's text. +Requires doorstop v2.2+. + +## Headings vs. normative items + +- Heading: `level: X.0` + `normative: false`. Renders as a section header. + Does not print a UID. Contributes to the outline but not to the + requirements count. +- Normative item: `normative: true`. Renders with its UID and optional + `header:` text, followed by `text:`. + +Levels ending in `.0` with `normative: true` are still normative items, not +headings. The heading rule requires **both** `.0` level and `normative: +false`. + +## `--linkify` (HTML mode) + +When publishing HTML, doorstop linkifies UIDs within `text:` automatically: +if `REQ001` appears in a Markdown text block and `REQ001` exists in the +tree, it becomes a hyperlink. This is built-in — no flag to enable, no flag +to disable cleanly. + +## Pre-publish checklist + +Before `doorstop publish all`: + +1. `doorstop` exits 0 (no WARNING/ERROR). Publishing a tree with suspect + links is valid but meaningless. +2. Every edited item is `doorstop review`ed. Unreviewed items still publish + but signal nothing about intent. +3. Commit the tree — you want the publish directory to correspond to a + specific SHA. + +## Output expectations for this skill + +If the user asked to publish, the task is done when: + +- The publish directory exists and contains the expected files. +- `index.html` (or the single output file) renders. +- The run produced no errors. + +Never claim "publish succeeded" without confirming the artifact is on disk. diff --git a/skills/doorstop/references/python-api.md b/skills/doorstop/references/python-api.md new file mode 100644 index 00000000..8dc30153 --- /dev/null +++ b/skills/doorstop/references/python-api.md @@ -0,0 +1,375 @@ +# Python API Reference + +Drive Doorstop from a Python script or REPL. Use the API when the CLI can't +express what you need — bulk programmatic changes, custom reports, validation +hooks, integrations. + +## Getting started + +```python +import doorstop + +tree = doorstop.build() # build from cwd (walks up to VCS root) +tree = doorstop.build(root="/abs/path/to/project") # explicit root + +print(tree) # one-line tree representation +print(tree.draw()) # ASCII/box tree +print(len(tree.documents)) +``` + +`build()` walks the VCS root, discovers every `.doorstop.yml`, and constructs a +`Tree`. It does **not** load item contents eagerly — call `tree.load()` if you +want to force it. + +## Public surface + +Importable from the top-level `doorstop` package: + +| Name | What it is | +|---|---| +| `doorstop.build` | Build the tree from a project root | +| `doorstop.find_document(prefix)` | Find a document without an explicit tree | +| `doorstop.find_item(uid)` | Find an item without an explicit tree | +| `doorstop.Tree` | Tree class | +| `doorstop.Document` | Document class | +| `doorstop.Item` | Item class | +| `doorstop.builder` | Module with `build`, `_get_tree` | +| `doorstop.editor` | Editor launcher (`editor.edit`) | +| `doorstop.exporter` | `export()`, `export_lines()`, `check()` | +| `doorstop.importer` | `import_file()`, `create_document()`, `add_item()` | +| `doorstop.publisher` | `publish()`, `publish_lines()`, `check()` | +| `doorstop.DoorstopError` | Hard error | +| `doorstop.DoorstopWarning` | Warning | +| `doorstop.DoorstopInfo` | Info-level message | + +`DoorstopFileError` lives at `doorstop.common.DoorstopFileError`. + +## `Tree` + +```python +tree = doorstop.build() + +# Lookups +req = tree.find_document("REQ") # DoorstopError if missing +item = tree.find_item("REQ042") # DoorstopError if missing + +# Enumeration +for document in tree: # iterates documents + for item in document: + ... + +tree.documents # list, same as list(tree) + +# Mutations +doc = tree.create_document( + path="./reqs", + value="REQ", # prefix + sep="-", + digits=3, + parent=None, + itemformat="yaml", # or "markdown" +) +item = tree.add_item("REQ", level="1.2", number=None) +tree.remove_item("REQ042") +child, parent = tree.link_items("TST007", "REQ023") +child, parent = tree.unlink_items("TST007", "REQ023") + +# Validation +ok = tree.validate(skip=["OLD"], document_hook=None, item_hook=None) +for issue in tree.get_issues(skip=["OLD"]): # yields DoorstopInfo/Warning/Error + print(issue) + +# Traceability +rows = tree.get_traceability() # list of tuples (Item | None, ...) + +# Structure +tree.draw() # human tree diagram +tree.draw(encoding="utf-8") # force encoding +tree.draw(html_links=True) # emit tags (server use) + +# Delete EVERYTHING (destructive) +tree.delete() +``` + +**`tree.validate(...)`** returns `True` iff no `DoorstopError` was yielded; any +number of `DoorstopWarning`s or `DoorstopInfo`s don't flip the flag. The flag +set comes from `doorstop.settings`; CLI flags map there. + +## `Document` + +Defined in `doorstop.core.document.Document`. A document is a directory with a +`.doorstop.yml` config. + +```python +doc = tree.find_document("REQ") + +# Config +doc.prefix # "REQ" +doc.sep # "" or "-" or "_" or "." +doc.digits # 3 (default) +doc.parent # parent prefix or "" +doc.itemformat # "yaml" or "markdown" +doc.extensions # dict from .doorstop.yml +doc.extended_reviewed # list[str] of attrs contributing to fingerprint + +# Paths +doc.path # absolute path to document dir +doc.root # absolute path to project root +doc.relpath # path relative to project root +doc.config # path to .doorstop.yml +doc.assets # path to assets/ or None +doc.template # path to template/ or None + +# Items +for item in doc: # yields ALL items (active + inactive) + ... +doc.items # sorted list of ACTIVE items +len(doc) # count of active items +doc.find_item("REQ042") # DoorstopError if missing or inactive +doc.next_number # integer (consults server if configured) +doc.depth # max level depth + +# Mutations +item = doc.add_item(level=None, number=None, name=None, defaults=None, reorder=True) +doc.remove_item("REQ042", reorder=True) +doc.reorder(manual=True, automatic=True, start=None, keep=None) +doc.save() # rewrite .doorstop.yml +doc.load(reload=True) # re-read from disk +doc.delete() # recursively delete + +# Skip flag +doc.skip # True if a .doorstop.skip file sits in doc dir +``` + +### Document-level `extensions` + +Values come from `.doorstop.yml`'s `extensions:` block. Doorstop reads: + +| Key | Meaning | +|---|---| +| `item_validator` | Path (relative to `.doorstop.yml`) to a Python file exposing `item_validator(item)`. See `references/extensions.md`. | +| `item_sha_required` | `true` ⇒ `references[*].sha` is included in the stamp and updated on `review`. | +| `item_sha_buffer_size` | Read-buffer size when hashing reference files (default 65536). | + +Unknown keys live in `doc.extensions` untouched; extensions you write can read +from there. + +## `Item` + +Defined in `doorstop.core.item.Item`. One item = one file. + +```python +item = tree.find_item("REQ042") + +# Identity & file location +item.uid # UID object; str() → "REQ042" +item.path # absolute path to .yml or .md file +item.relpath # relative to project root +item.document # parent Document + +# Core attributes (all auto-load, most auto-save on set) +item.level # Level object; str(item.level) → "1.2.3" +item.active # bool +item.derived # bool +item.normative # bool +item.heading # computed: level ends in .0 AND not normative +item.text # Text object (str-like) +item.header # Text (for headings or titled items) +item.ref # legacy single string ref (deprecated) +item.references # list of {"path": str, "type": "file", "keyword"?: str, "sha"?: str} +item.links # sorted list of parent UIDs (UID objects) +item.parent_links # alias for links + +# Reviewed / cleared state +item.reviewed # True iff stored reviewed stamp == current stamp(links=True) +item.cleared # True iff no link parent-fingerprint is stale +item.stamp() # current fingerprint +item.stamp(links=True) # includes link UIDs (used for "reviewed") + +# Traversal +item.parent_items # list[Item] +item.parent_documents # usually [parent-document] +item.child_items # list[Item] (reverse links, FIRST level only) +item.child_links # list[UID] +item.child_documents # list[Document] + +# Custom attributes (anything in the YAML not in the standard set) +item.attribute("invented-by") # None if missing +# OR directly via the backing data: +item.data # dict for YAML dumping + +# Mutations (auto-save) +item.level = "1.2.3" +item.text = "New text." +item.active = False +item.links = ["REQ023", "REQ024"] # replaces entire set +item.link("REQ099") # adds one +item.unlink("REQ023") # removes one +item.references = [{"path": "src/foo.py", "type": "file"}] +item.set_attributes({"invented-by": "alice@example.com"}) + +# Review / clear +item.review() # update stored reviewed stamp; also refreshes ref shas if item_sha_required +item.clear() # absolve all suspect links +item.clear(parents=["REQ023"]) # only for that parent + +# External refs +path, line = item.find_ref() # legacy ref +found = item.find_references() # new array form + +# Delete +item.delete() + +# Edit (spawns $EDITOR) +item.edit(tool=None, edit_all=False) # edit_all=True → full YAML; False → text only +``` + +### `UID`, `Level`, `Stamp`, `Text`, `Prefix` + +Defined in `doorstop.core.types`. You rarely construct these by hand; pass +plain strings and Doorstop wraps them. Key behavior: + +- `UID("REQ-042")` ⇒ parses prefix/sep/number. `uid.prefix`, `uid.number`, + `uid.name`, `uid.stamp` (link fingerprint). +- `Level("1.2.3")` — addition/shifts supported (`+ 1`, `>> 1`, `<< 1`). + Heading flag `.heading` when `.0`. +- `Stamp(*values)` — SHA-256 of URL-safe base64 over the joined string + representation of all values. +- `Text("...")` — like `str` but preserves paragraph structure for YAML dump. + +## Scripting patterns + +### Read-only report + +```python +import doorstop + +tree = doorstop.build() +for document in tree: + active = [i for i in document if i.active] + unreviewed = [i for i in active if not i.reviewed] + print(f"{document.prefix}: {len(active)} active, {len(unreviewed)} unreviewed") +``` + +### Bulk attribute update (safe: use `set_attributes`) + +```python +import doorstop + +tree = doorstop.build() +for item in tree.find_document("REQ"): + if not item.attribute("priority"): + item.set_attributes({"priority": "medium"}) +# After this, you'll want to: doorstop review REQ +``` + +### Bulk link + +```python +import doorstop + +tree = doorstop.build() +for child in tree.find_document("TST"): + if not child.links and child.text: + # naive: link to same-numbered REQ + parent_uid = f"REQ{str(child.uid.number).zfill(3)}" + try: + tree.link_items(child.uid, parent_uid) + except doorstop.DoorstopError as exc: + print(f"skip {child.uid}: {exc}") +``` + +### Validation with custom hooks + +```python +#!/usr/bin/env python +import sys +from doorstop import build, DoorstopError, DoorstopInfo, DoorstopWarning + +def check_document(document, tree): + if sum(1 for i in document if i.normative) < 10: + yield DoorstopInfo("fewer than 10 normative items") + +def check_item(item, document, tree): + if not item.attribute("type"): + yield DoorstopWarning("no `type` attribute") + if item.derived and not item.attribute("rationale"): + yield DoorstopError("derived but no `rationale`") + +def main(): + tree = build() + ok = tree.validate(document_hook=check_document, item_hook=check_item) + sys.exit(0 if ok else 1) + +if __name__ == "__main__": + main() +``` + +Hook contracts: + +- `document_hook(document, tree)` — yields `DoorstopInfo/Warning/Error`. +- `item_hook(item, document, tree)` — yields `DoorstopInfo/Warning/Error`. +- Any `DoorstopError` yielded makes `validate()` return `False`. + +### Exporting / publishing programmatically + +```python +from doorstop import build, exporter, publisher + +tree = build() +req = tree.find_document("REQ") + +# Export +exporter.export(req, "/tmp/req.xlsx", ext=".xlsx") +# Or stream lines +for line in exporter.export_lines(req, ext=".yml"): + print(line) + +# Publish +publisher.publish(req, "/tmp/req.html", ext=".html", linkify=True) +publisher.publish(tree, "/tmp/publish", ext=".html", index=True, matrix=True) +``` + +`publish()` signature: `publish(obj, path, ext=None, linkify=None, index=None, +matrix=None, template=None, toc=True, **kwargs)`. When `obj` is a `Tree`, +`path` is a directory; when it's a `Document`, `path` is a file. + +### Importing programmatically + +```python +from doorstop import build, importer + +tree = build() +doc = tree.find_document("REQ") + +# From an exported file +importer.import_file("/tmp/reqs.xlsx", doc, ext=".xlsx", mapping={"Requirement": "text"}) + +# Bootstrap a pre-existing directory as a Doorstop doc +doc = importer.create_document("SPEC", "./spec", parent="REQ") + +# Backfill a specific item +item = importer.add_item("REQ", "REQ042", attrs={"text": "Legacy."}) +``` + +## Exceptions + +| Class | Path | When | +|---|---|---| +| `DoorstopError` | `doorstop.DoorstopError` | Invalid state, missing item/doc, cycle | +| `DoorstopWarning` | `doorstop.DoorstopWarning` | Validation warning | +| `DoorstopInfo` | `doorstop.DoorstopInfo` | Informational validation message | +| `DoorstopFileError` | `doorstop.common.DoorstopFileError` | Disk/format problems | + +All inherit from `Exception`. The three validation severity classes are what +hooks yield; CLI severity flags (`-w`, `-e`) promote between them. + +## Performance notes + +- `tree.find_item` / `tree.find_document` cache by default. Mutating the disk + outside the API can desynchronize the cache; in long scripts call + `tree.load(reload=True)` after external changes. +- `tree.get_traceability()` is O(items × path-depth); don't call it in tight + loops. +- Item save is atomic per-file but not transactional across items. If you + abort mid-script, partial saves may remain. diff --git a/skills/doorstop/references/server-api.md b/skills/doorstop/references/server-api.md new file mode 100644 index 00000000..348cc1d4 --- /dev/null +++ b/skills/doorstop/references/server-api.md @@ -0,0 +1,192 @@ +# Server / REST API + +The `doorstop-server` is a Bottle app that exposes the tree over HTTP. Two +purposes: + +1. **Human**: browse the tree in a browser with per-item HTML views and a + traceability matrix. +2. **Agent**: safely reserve item numbers across concurrent clients via + `POST /documents//numbers`, and read JSON views of documents and + items. + +Source: `doorstop/server/main.py`. + +## Start the server + +```sh +doorstop-server # defaults to 127.0.0.1:7867 +doorstop-server -P 8080 # custom port +doorstop-server -H 0.0.0.0 -P 7867 # bind all interfaces +doorstop-server -j /path/to/project # alternate project root +doorstop-server --launch # open browser automatically +doorstop-server --debug # verbose, auto-reload on code change +doorstop-server --wsgi -b /doorstop # when hosted behind a reverse proxy +``` + +Source: `doorstop/server/main.py:32-89`. + +Defaults: + +| Option | Default | Override | +|---|---|---| +| Host | `127.0.0.1` | `-H` / `--host` | +| Port | `7867` | `-P` / `--port` | +| Project root | Auto-detected via `vcs.find_root` | `-j` / `--project` | +| Base URL | `""` | `-b` / `--baseurl` (WSGI) | + +## Content negotiation + +Every JSON-capable endpoint returns HTML by default. To receive JSON, send +`Accept: application/json` (handled by `utilities.json_response`). + +```sh +curl http://127.0.0.1:7867/documents \ + -H "Accept: application/json" +``` + +## Endpoints + +### GET `/` and `/index` + +HTML only. Renders the tree index page. + +### GET `/traceability` + +- HTML: traceability matrix page. +- JSON: `{"traceability": [["REQ001", "LLR003", "TST010"], ...]}` — rows are + link chains across the tree; items are stringified UIDs. + +### GET `/documents` + +- HTML: per-document links. +- JSON: `{"prefixes": ["REQ", "LLR", "TST"]}`. + +### GET `/documents/all` + +- JSON only (HTML falls back to the `/documents` list page). +- Body: `{"REQ": {"REQ001": {...item data...}, "REQ002": {...}}, "LLR": {...}}`. + +Dumps every item in every document as a single JSON blob. Useful for a +full-tree snapshot. + +### GET `/documents/` + +- HTML: doorstop-rendered document page with a TOC and linkified UIDs. +- JSON: `{"REQ001": {...item data...}, "REQ002": {...}}` — same shape as + `/documents/all`, scoped to one prefix. + +### GET `/documents//items` + +- JSON: `{"uids": ["REQ001", "REQ002", ...]}`. + +### GET `/documents//items/` + +- HTML: item page. +- JSON: `{"data": {...item data...}}`. + +### GET `/documents//items//attrs` + +- JSON: `{"attrs": ["active", "derived", "level", ...]}` — sorted attribute + names. + +### GET `/documents//items//attrs/` + +- JSON: `{"value": }` — raw attribute value (string, bool, list, + dict, or null). + +### POST `/documents//numbers` + +**The one mutating endpoint.** Reserves and returns the next item number +for ``: + +- JSON: `{"next": 17}`. +- Plain text fallback: `17`. + +The server tracks `numbers[prefix]` in memory and also honors the on-disk +`next_number` computed from the current tree. When the CLI (`doorstop add +`) runs with `--server`, it calls this endpoint to reserve a UID +atomically. + +### GET `/template/` + +Serves built-in HTML template assets (CSS, JS). 404 if not found. + +### GET `/documents/assets/` + +Serves document-level assets (images embedded in requirement texts). +Searches every document's `assets/` folder. + +## How the CLI uses the server + +When the server is running and reachable, these CLI commands will: + +- `doorstop add ` — reserve a UID via `POST + /documents//numbers`. Prevents two concurrent shells from + reserving the same number. + +When the server is unreachable or `-f/--force` is set, the CLI falls back +to local number counting. In that case, concurrent writers can collide. + +Flags affecting server behavior on the client side: + +- `--server HOST` — hostname (default from `settings.SERVER_HOST`). +- `--port NUMBER` — port (default from `settings.SERVER_PORT` = 7867). +- `-f/--force` — bypass the server entirely. + +## Agent patterns + +**Read the tree without parsing CLI output**: + +```sh +curl -s -H "Accept: application/json" \ + http://127.0.0.1:7867/documents/all > /tmp/tree.json +``` + +**Get one item's attrs for decision-making**: + +```sh +curl -s -H "Accept: application/json" \ + http://127.0.0.1:7867/documents/REQ/items/REQ001 +``` + +**Reserve a UID before creating an item out-of-band** (rare — prefer +`doorstop add`): + +```sh +curl -s -X POST -H "Accept: application/json" \ + http://127.0.0.1:7867/documents/REQ/numbers +# {"next": 18} +``` + +## CORS + +`Access-Control-Allow-Origin: *` is set on every response +(`server/main.py:154`). This is convenient for local tooling; if you +expose the server beyond localhost, put it behind an authenticated proxy. + +## WSGI deployment + +```sh +doorstop-server --wsgi -b /doorstop +``` + +The `-b` flag tells Bottle the base URL the proxy forwards under, so +generated links and static-asset paths are correct. + +## What the server does **not** do + +- No PATCH/PUT/DELETE — item mutations must go through the CLI or Python + API. The REST surface is read-mostly with a single POST to reserve + numbers. +- No authentication or TLS. Don't expose this directly on the public + internet. +- No live-reload of the tree. If you edit files on disk, restart the + server (or run with `--debug` which auto-reloads on file change). + +## Related + +- For atomic UID reservation semantics from the CLI side, see + `cli-commands.md` under `add`. +- For the Python API equivalent of "inspect everything", use + `doorstop.build()` and iterate — no server required. + (See `python-api.md`.) diff --git a/skills/doorstop/references/troubleshooting.md b/skills/doorstop/references/troubleshooting.md new file mode 100644 index 00000000..3954fd25 --- /dev/null +++ b/skills/doorstop/references/troubleshooting.md @@ -0,0 +1,343 @@ +# Troubleshooting + +Decision tree for the most common failure modes. Each entry: symptom, +diagnosis, exact commands to run. + +## `doorstop` exits with "no tree found" / "no documents found" + +**Symptom**: `doorstop` prints `ERROR: no documents found` or exits +immediately. + +**Diagnosis**: Either you're outside a VCS root, or no `.doorstop.yml` +exists under the cwd. + +```sh +git rev-parse --show-toplevel # should print repo root +find . -name ".doorstop.yml" # should list at least one +``` + +**Fix**: + +- Outside VCS → `cd` into the repo, or `git init` if genuinely bootstrapping. +- No `.doorstop.yml` anywhere → this is a fresh tree; run `doorstop create + ` (see `workflows.md#bootstrap`). + +## "invalid item filename" + +**Symptom**: `DoorstopError: invalid item filename: foo.yml`. + +**Diagnosis**: A file in the document directory has a name that doesn't +parse as a UID under the document's `prefix` / `sep` / `digits` settings. +Common causes: stray files, renamed items, importing a spreadsheet that +stripped leading zeros. + +**Fix**: + +```sh +ls reqs/req/ # find offending file(s) +``` + +- If it's a stray file (README, `.gitkeep`) → move it out of the document + directory. +- If it's a valid requirement with a broken filename → rename it to a + correct UID (preserving the number), or `doorstop remove` + `doorstop + add` to regenerate. + +## Item is flagged "unreviewed" after a tiny edit + +**Symptom**: `WARNING: REQ: REQ002: unreviewed changes` after you only +changed `header:` or `level:`. + +**Diagnosis**: Not the usual case. `header` and `level` don't feed the +fingerprint (see `validation.md#fingerprint-and-suspect-links`). Check +whether an extended attribute that is listed in `attributes.reviewed` also +changed. + +**Fix**: + +```sh +git diff reqs/req/REQ002.yml +grep -A5 "^attributes" reqs/req/.doorstop.yml +``` + +If the diff shows text/ref/references/links changed, `doorstop review +REQ002`. If only `header`/`level` changed, look harder at +`attributes.reviewed` for an extended attr you forgot you listed. + +## Suspect links after parent edit + +**Symptom**: + +``` +WARNING: LLR: LLR003: suspect link: REQ002 +WARNING: LLR: LLR004: suspect link: REQ002 +``` + +**Diagnosis**: Normal. Parent content changed; children stored the old +parent fingerprint. See `workflows.md` recipe 4. + +**Fix** in this order: + +```sh +doorstop review REQ002 # accept the parent edit +# Then for each suspect child, either: +doorstop clear LLR003 # accept parent change, no child edit +# or: +$EDITOR reqs/llr/LLR003.yml +doorstop review LLR003 # stamp the child content change +``` + +**Do not** `doorstop clear` without reviewing the parent first. That stamps +the children with the old parent's fingerprint, which then won't flag as +suspect on the next change — you'll miss a real regression. + +## Cycle detected + +**Symptom**: `WARNING: ...: detected a cycle with a back edge from REQ002 +to LLR003`. + +**Diagnosis**: A link path forms a cycle (A → B → ... → A). Doorstop's +`CycleTracker` (DFS) reports it. + +**Fix**: + +```sh +doorstop # see the full cycle +doorstop unlink LLR003 REQ002 # pick an edge and remove it +``` + +The right edge to cut is the one that shouldn't exist semantically. If you +can't tell, ask whoever authored the tree. + +## Unresolved external reference + +**Symptom**: `ERROR: REQ: REQ010: external reference not found: +src/sensors/temp.c`. + +**Diagnosis**: `ref:` or `references:` points to a file or keyword that +doesn't exist in the project tree. + +**Fix**: + +```sh +grep -rn "src/sensors/temp.c" # did the file move? +``` + +- File moved → `doorstop edit REQ010` and update `references:` path. +- File deleted legitimately → `doorstop edit REQ010` and remove the ref. +- File exists but outside the checkout (e.g., only in a submodule not + cloned) → either clone it, or run with `-R/--no-ref-check` for that + session (don't ship it). + +## "multiple root documents" on `doorstop` + +**Symptom**: `ERROR: multiple root documents: ...` listing two or more +document directories (often with the same prefix). + +**Diagnosis**: Doorstop walks the VCS root (by default) and picks up every +`.doorstop.yml` it finds. If your repo contains a second, unrelated +Doorstop tree — a vendored example, a teaching fixture, a submodule, a +test asset — both roots get merged into the same tree and clash. + +**Fix**: Drop a `.doorstop.skip-all` marker at the top of the subtree you +want excluded. The file's contents are ignored; only its presence matters. + +```sh +touch path/to/unrelated/doorstop/tree/.doorstop.skip-all +doorstop # rescans — the subtree is pruned +``` + +The marker is recognized by `doorstop/core/builder.py`: when `os.walk` +visits a directory containing a `.doorstop.skip-all` file, that directory +and everything beneath it are removed from discovery. + +This is the same mechanism the skill itself uses: see +`skills/doorstop/assets/.doorstop.skip-all` — it keeps the bundled +`example_reqs/` tree from colliding with Doorstop's own requirements when +this skill lives inside a repo that is itself a Doorstop project. + +## "document already exists" on `doorstop create` + +**Symptom**: Create fails even though the path looks empty. + +**Diagnosis**: Either a stray `.doorstop.yml` exists at the target path, +or another document in the tree already claims that prefix. + +**Fix**: + +```sh +find . -name ".doorstop.yml" -exec grep -l "prefix: REQ" {} + +``` + +- If the prefix is taken elsewhere → choose a different prefix. +- If a stale `.doorstop.yml` lingers → `doorstop delete ` (safer) + or remove the file manually and retry. + +## "items numbered out of order" after parallel edits + +**Symptom**: Two agents ran `doorstop add REQ` concurrently and collided on +UIDs. + +**Diagnosis**: Number reservation wasn't atomic because no server was +running (or `-f/--force` was passed). + +**Fix**: + +```sh +doorstop-server & # background the server +doorstop add REQ -T "..." # subsequent adds reserve atomically +``` + +For existing collisions: `git reset --hard` back to before the collision +and replay the adds sequentially. + +## Levels skipped or duplicated + +**Symptom**: + +``` +INFO: REQ: REQ005: skipped level 1.3 +WARNING: REQ: REQ006 and REQ007 both at level 1.4 +``` + +**Fix**: + +- Skipped (INFO) → cosmetic, optional. Run `doorstop -r` to re-pack levels + (writes to disk). +- Duplicate (WARNING) → `doorstop edit ` on one item and change + `level`, or `doorstop reorder ` for an interactive pack. + +## UID prefix no longer matches document prefix + +**Symptom**: `INFO: UID REQ001: UID prefix is not equal to document prefix +REQ-V2`. + +**Diagnosis**: Someone edited `.doorstop.yml` to change `prefix:` after +items were created. Don't do this. + +**Fix**: + +- Revert the `.doorstop.yml` change: `git checkout -- + reqs/req/.doorstop.yml`. +- If the rename was intentional: delete the document + (`doorstop delete REQ`) and import as a new one, or use + `doorstop import` with the new prefix. Expect a full re-link pass. + +## Publish artifact looks wrong / missing items + +**Symptom**: `publish/index.html` is missing items, or levels are jumbled. + +**Diagnosis**: Either pre-publish validation wasn't clean, or `active: +false` items are being omitted (this is correct behavior; they're hidden). + +**Fix**: + +```sh +doorstop # confirm tree is clean +grep -rh "^active:" reqs/ | sort -u # are unexpected items inactive? +``` + +Publish again after the tree validates clean. To include inactive items in +publish, set them `active: true`. + +## `doorstop review` did nothing / `reviewed:` stamp still null + +**Symptom**: After `doorstop review REQ001`, the item still shows +`reviewed: null` and the warning persists. + +**Diagnosis**: Rare. Usually the item file is read-only or the process was +run without write access to the project root. + +**Fix**: + +```sh +ls -l reqs/req/REQ001.yml +doorstop review REQ001 -v # verbose run to see errors +``` + +Check file perms, disk space, and that the cwd isn't inside a read-only +overlay filesystem. + +## Server won't start / port in use + +**Symptom**: `doorstop-server` exits with `OSError: [Errno 48] Address +already in use`. + +**Fix**: + +```sh +lsof -i :7867 # find the offender +doorstop-server -P 7868 # use a different port +``` + +If agents are configured to talk to the default port, either kill the old +server or pass `--port 7868` to clients. + +## Import from XLSX dropped leading zeros on UIDs + +**Symptom**: After round-trip through Excel, items are named `REQ1` instead +of `REQ001`. Doorstop may treat them as new items. + +**Fix**: Format the UID column as **text** in Excel *before* editing. If +already corrupted: + +```sh +git checkout -- reqs/req/ # revert +``` + +Then re-export, re-format, re-import. + +## I edited `reviewed:` by hand and everything broke + +**Symptom**: Every item shows unreviewed or suspect, even ones you didn't +touch. + +**Fix**: + +```sh +git checkout -- reqs/ +``` + +Never edit `reviewed:` manually. The stamp is SHA-256 over a specific +canonical representation and will not match anything you type. Use +`doorstop review`. + +## Validator throws an exception + +**Symptom**: `doorstop` aborts with a traceback pointing into your +`item_validator.py`. + +**Diagnosis**: Validators must yield `DoorstopError` (or friends), not +raise. + +**Fix**: Wrap risky operations in try/except inside the validator, and +`yield DoorstopError(str(exc))` instead of letting the exception propagate. +See `extensions.md` for signature rules. + +## Tree `validate()` returns False but no issues printed + +**Symptom**: CLI says "command failed" but you don't see WARNING/ERROR +lines. + +**Diagnosis**: You're at the default verbosity. INFO lines are hidden, but +an unresolved internal error can still set the exit status. + +**Fix**: + +```sh +doorstop -v # INFO visible +doorstop -vv # DEBUG visible +``` + +## When to reach for `scripts/doorstop_snapshot.py` + +If any of the above needs a structured view that's hard to extract from +CLI stdout: + +```sh +python skills/doorstop/scripts/doorstop_snapshot.py > /tmp/tree.json +jq '.documents.REQ.items.REQ002' /tmp/tree.json +``` + +The snapshot captures every item's attributes, links, and the validation +issues, all as JSON. diff --git a/skills/doorstop/references/validation.md b/skills/doorstop/references/validation.md new file mode 100644 index 00000000..e5b98a14 --- /dev/null +++ b/skills/doorstop/references/validation.md @@ -0,0 +1,151 @@ +# Validation + +Running `doorstop` (no subcommand) builds the tree, loads every document, and +validates every item. This reference covers the severity matrix, what each +global flag does, how suspect links work, and when skipping a check is safe +vs. sweeping a real problem under the rug. + +## Severity matrix + +| Level | Condition | Source of truth | +|---|---|---| +| INFO | Skipped levels within a document (e.g. `1.1` → `1.3`). | `docs/cli/validation.md:26` | +| INFO | No initial review done for an item (`reviewed: null`). | `docs/cli/validation.md:27` | +| INFO | UID prefix doesn't match document prefix (renamed document). | `docs/cli/validation.md:28` | +| INFO | Link UID prefix doesn't match target document's prefix. | `docs/cli/validation.md:29` | +| WARNING | Document contains no items. | `docs/cli/validation.md:33` | +| WARNING | Duplicate levels within a document. | `docs/cli/validation.md:34` | +| WARNING | Item has empty `text:`. | `docs/cli/validation.md:35` | +| WARNING | Item has unreviewed changes (current fingerprint ≠ `reviewed:`). | `docs/cli/validation.md:36` | +| WARNING | Child link references an **inactive** item. | `docs/cli/validation.md:37` | +| WARNING | Item is linked to a non-normative item (heading). | `docs/cli/validation.md:38` | +| WARNING | Item linked to itself. | `docs/cli/validation.md:39` | +| WARNING | Suspect link: stored parent-fingerprint ≠ parent's current fingerprint. | `docs/cli/validation.md:40` | +| WARNING | `-Z/--strict-child-check`: item in a parent doc has no child links. | `docs/cli/validation.md:42` | +| WARNING | Normative, non-derived item in a child doc has no parent links. | `docs/cli/validation.md:43` | +| WARNING | Non-normative item has links. | `docs/cli/validation.md:44` | +| WARNING | Cycle of item links (DFS detected by `CycleTracker`). | `docs/cli/validation.md:45` | +| ERROR | Parent link points to an **inactive** item. | `docs/cli/validation.md:49` | +| ERROR | Link UID is invalid or unknown. | `docs/cli/validation.md:50` | +| ERROR | `references:` / `ref:` cannot be resolved (file or keyword not found). | `docs/cli/validation.md:51` | + +## Global validation flags + +All of these apply to the bare `doorstop` command (the implicit "validate" +subcommand). They are parsed at the top level in +`doorstop/cli/main.py:83-149`. + +| Flag | Effect | When to use | +|---|---|---| +| `-F`, `--no-reformat` | Don't rewrite item files in canonical YAML during validation. | You want to inspect exactly what's on disk without side effects. CI where writes would dirty the tree. | +| `-r`, `--reorder` | Re-pack document levels to remove gaps. Writes to disk. | After bulk deletes, when levels are full of gaps. | +| `-L`, `--no-level-check` | Skip level checks (skipped/duplicate/gaps). | Temporarily acceptable during bulk edits. Don't ship with this on. | +| `-R`, `--no-ref-check` | Skip `references:` / `ref:` file-existence checks. | Running outside the source checkout where referenced files live. | +| `-C`, `--no-child-check` | Skip child/reverse link checks. | When working on a single leaf document in isolation. | +| `-Z`, `--strict-child-check` | Require child links from every parent item. | Enforces fully-linked coverage. Use in CI for safety-critical trees. | +| `-S`, `--no-suspect-check` | Don't flag suspect links. | Never OK long-term. Sometimes useful during a planned cascade review. | +| `-W`, `--no-review-check` | Don't flag unreviewed items. | During bulk import, before the initial review pass. | +| `-s PREFIX`, `--skip PREFIX` | Exclude one document from validation. Repeatable. | The document is known broken and quarantined. | +| `-w`, `--warn-all` | Promote INFO → WARNING. | Pre-release sweeps. | +| `-e`, `--error-all` | Promote WARNING → ERROR (non-zero exit). | CI gate. See `scripts/doorstop_lint.sh`. | + +Exit codes: + +- `0` — no issues at or above the effective threshold. +- `1` — any issue at or above the effective threshold, or any uncaught + `DoorstopError`/`DoorstopFileError`. + +## Fingerprint and suspect links + +Every item has a **current fingerprint** — SHA-256 (URL-safe Base64) of: + +1. `uid` +2. `text` +3. `ref` +4. `references` +5. UIDs in `links` +6. Values of extended attrs listed in `attributes.reviewed` + +When you run `doorstop review `, doorstop writes the current fingerprint +into the item's `reviewed:` field **and** copies the fingerprint of each +parent into the child's `links:` entry for that parent. + +Later, when something changes: + +- Item's current fingerprint drifts from its stored `reviewed:` → **unreviewed + change** warning. +- Parent's current fingerprint drifts from the stamp in a child's `links:` + entry → **suspect link** warning on the child. + +A change to a parent **does not** automatically bump the parent's `reviewed:` +stamp. It bumps only when the parent itself is re-`review`ed. So the usual +churn flow is: + +1. Edit parent `text`. → parent is unreviewed, children have suspect links. +2. `doorstop review ` → parent is reviewed. Children still suspect + (the new parent fingerprint ≠ child's stored parent-stamp). +3. On each child: either `doorstop clear ` (accept parent change + without revisiting child text) or edit the child and `doorstop review + ` (acknowledge parent change *and* update child content). + +`clear` ≠ `review`. `clear` updates only the stored parent-stamps in the +child's `links:`. `review` updates the child's own `reviewed:` stamp (and +also refreshes link stamps as a side effect). + +## Unresolved references + +An item with `ref:` or `references:` that cannot be found on disk (or whose +keyword can't be grep'd from any text file) is an ERROR. To quiet this for a +specific run: + +- `-R/--no-ref-check` — skip the check for the whole run. +- `doorstop -s ` — skip the offending document entirely. + +Don't paper over this in `.doorstop.yml` — fix the path, remove the +reference, or park the document in `-s` until it's fixed. + +## Cycle detection + +Implemented in `commands.CycleTracker` (DFS). A cycle is reported once per +cycle as WARNING, with the cycle path rendered as `A → B → C → A`. Resolve by +`doorstop unlink ` on one of the edges. + +## Levels: skipped, duplicate, gaps + +- **Skipped**: `1.1` directly followed by `1.3`. INFO. Harmless but usually + indicates a deleted sibling — consider `doorstop -r` to re-pack. +- **Duplicate**: two items share a level. WARNING. Fix with `doorstop edit + ` to assign a unique level, or `doorstop reorder ` to let + doorstop resolve it interactively. +- **Headings**: `X.0` + `normative: false` is a heading. Headings do **not** + count as duplicates of other headings in the same document. + +## Strict-child-check + +`-Z/--strict-child-check` enforces: every parent item must be referenced +(have a child link) from every child document. I.e. coverage must be +complete in both directions. Use this as a CI gate once the tree is mature. + +## Warning-to-error promotion + +`-e/--error-all` escalates every WARNING (and INFO, if also `-w`) to ERROR. +Combined with `-W` to tolerate unreviewed items while still hard-failing on +suspect links or cycles, this gives a tunable CI gate. See +`scripts/doorstop_lint.sh` for the canonical recipe. + +## When skipping is safe + +| Flag | Safe to skip *temporarily* | Safe to skip *permanently* | +|---|---|---| +| `-L` no-level-check | During bulk restructure | Rarely — level coherence matters for publishing | +| `-R` no-ref-check | Running outside source checkout | For docs with no external refs, it's harmless | +| `-C` no-child-check | Working on a single leaf doc | No — coverage should matter | +| `-S` no-suspect-check | Planned cascade review in flight | **Never** — suspect links are the whole point | +| `-W` no-review-check | Fresh import before initial review | **Never** after initial review pass | +| `-s PREFIX` | Quarantine a known-broken doc | Only if that doc is legitimately outside scope | + +## What "valid" means for publishing + +`doorstop publish` does its own minimal validation but won't catch every +issue. Treat `doorstop` exit-0 as a precondition for publishing a release +snapshot. For CI, gate on `scripts/doorstop_lint.sh` (wraps `doorstop -e`). diff --git a/skills/doorstop/references/workflows.md b/skills/doorstop/references/workflows.md new file mode 100644 index 00000000..f81674dc --- /dev/null +++ b/skills/doorstop/references/workflows.md @@ -0,0 +1,225 @@ +# Workflows + +End-to-end recipes for the common tasks. Each recipe lists the commands in +order, the expected output, and checkpoints where you should pause and +validate before continuing. + +For flag details, see `cli-commands.md`. For file-format details, see +`file-formats.md`. For validation semantics, see `validation.md`. + +## 1. Bootstrap a new project + +**Precondition**: you're in a git repo (or `git init` before starting). +Doorstop refuses to run outside a VCS root. + +```sh +git init # if the repo is fresh +doorstop create REQ ./reqs/req # root document, no parent +doorstop add REQ -T "System shall boot within 5 seconds" +doorstop create LLR ./reqs/llr --parent REQ # child document +doorstop add LLR -T "Bootloader shall finish in ≤ 2 s" +doorstop link LLR001 REQ001 # child → parent +doorstop # validate +``` + +Checkpoints: + +- After `create`: a `reqs/req/.doorstop.yml` exists with `prefix: REQ`. +- After `add`: an item file `REQ001.yml` exists. No review yet (`reviewed: + null`) — expected INFO. +- After `link`: `LLR001.yml` has a `links: [{REQ001: null}]` entry. +- After `doorstop`: tree renders, no WARNING/ERROR. Unreviewed items show as + INFO (visible with `-v`). + +First-time commit: + +```sh +git add reqs/ +git commit -m "bootstrap doorstop tree" +``` + +## 2. Add an item and review it + +```sh +doorstop add REQ -T "REQ005: power-off within 1 s when lid closes" +$EDITOR reqs/req/REQ005.yml # optional — refine text/level +doorstop # verify the edit +doorstop review REQ005 +``` + +After `review`, `REQ005.yml` will have a non-null `reviewed:` stamp. Commit. + +### Reserve a batch of UIDs atomically + +When you know you need N items, don't loop — let `add` reserve them through +the server (or the local counter, if no server is running): + +```sh +doorstop add REQ -c 5 # creates REQ00N..REQ00N+4 +``` + +This is safer under concurrent agent runs because number reservation is +atomic at the server (`POST /documents//numbers`, see +`server-api.md`). + +## 3. Link items across documents + +```sh +doorstop link TST010 REQ002 # TST010 declares REQ002 as parent +``` + +Linking direction is always child → parent. After linking, the child's +`links:` gains `{REQ002: }`. To remove: `doorstop unlink +TST010 REQ002`. + +If you renamed a requirement (delete + re-add under a new UID), every child +that pointed to the old UID must be re-linked. There's no rename operation. + +## 4. Edit content, then bring reviews and suspect links back in line + +Scenario: you changed `REQ002.text`. Expected cascade: + +```sh +$EDITOR reqs/req/REQ002.yml +doorstop # see warnings +``` + +You'll see: + +- `WARNING: REQ: REQ002: unreviewed changes` +- `WARNING: LLR: LLR003: suspect link: REQ002` (for every child) + +Resolve in this order: + +```sh +doorstop review REQ002 # accept the parent edit +doorstop # parent is now clean + # children still show suspect +# For each child, choose: +doorstop clear LLR003 # accept parent change, no child edits +# — OR — +$EDITOR reqs/llr/LLR003.yml # edit the child text +doorstop review LLR003 # stamp child (also refreshes link stamps) +``` + +Do **not** invert this order. If you `clear` children before reviewing the +parent, the link stamps will record an intermediate fingerprint and the next +round of parent edits won't flag them as suspect when they should. + +## 5. Publish a release snapshot + +```sh +doorstop # MUST exit 0 +doorstop publish all ./publish +``` + +Layout under `./publish`: + +``` +publish/ +├── index.html # tree TOC across all documents +├── REQ.html +├── LLR.html +├── TST.html +└── traceability.csv # if --traceability passed +``` + +Single-document publish: + +```sh +doorstop publish REQ ./publish/REQ.html # explicit extension drives format +doorstop publish REQ ./publish/REQ.md +doorstop publish REQ ./publish/REQ.tex +doorstop publish REQ ./publish/REQ.txt +``` + +See `publishing.md` for templates, `--no-levels`, `--no-child-links`, +traceability, and linkifying. + +## 6. Round-trip via spreadsheet + +```sh +doorstop export REQ /tmp/req.xlsx # export +# Edit /tmp/req.xlsx in Excel/LibreOffice. Leave UID blank for new items. +doorstop import /tmp/req.xlsx REQ # import back +doorstop # validate — expect unreviewed +``` + +New items (blank UID column) get freshly-reserved UIDs. Modified items keep +their UIDs but land as **unreviewed**. Run `doorstop review` on each, or do +a bulk review from the API (see `python-api.md`). + +See `import-export.md` for CSV/TSV, column mapping (`-m/--map`), and the +explicit `create` form (`doorstop import --item REQ REQ042 -a level=1.5`). + +## 7. Bulk changes from Python + +When a restructure needs more than a few commands, script it: + +```python +import doorstop + +tree = doorstop.build(".") +req = tree.find_document("REQ") +for item in req: + if item.get("type") == "deprecated": + item.set("active", False) + item.save() +doorstop.publisher.publish(tree, "./publish", "html") +``` + +Always run `doorstop` after the script to confirm the tree is still valid, +then review any items the script touched. Script changes bypass the +`doorstop edit` wrapper, so they will *not* be auto-reviewed. + +## 8. Promote a document to have a parent + +You cannot edit `parent:` once items exist under the document — it isn't +truly read-only on disk, but child link checks against the old tree +structure will silently diverge. Safe path: + +1. Create a new sibling document with the correct parent. +2. `doorstop export OLD /tmp/old.xlsx` and import into the new doc. +3. `doorstop delete OLD` once everything is migrated and links are + re-pointed. + +## 9. CI gate recipe + +```sh +# scripts/doorstop_lint.sh (shipped with this skill) +doorstop -e -Z # errors on warnings, strict children +``` + +Exit 0 → green. Non-zero → fail the build. Pair with: + +```yaml +# .github/workflows/requirements.yml — illustrative +- run: pip install doorstop +- run: ./skills/doorstop/scripts/doorstop_lint.sh +``` + +## 10. Restore after destructive mistakes + +Doorstop state is git-backed. If something went wrong (accidental `delete`, +stray `review`, borked import): + +```sh +git status reqs/ +git diff reqs/ +git checkout -- reqs/ # revert everything +# or, surgically: +git checkout -- reqs/req/REQ005.yml +``` + +This is why **commit after every logical batch of changes**. Uncommitted +doorstop state is nearly meaningless — it can't be reproduced, reviewed in a +PR, or rolled back cleanly. + +## Checkpoints every workflow should hit + +Before declaring done: + +- `doorstop` exits 0 (or with only accepted INFO). +- Every item you edited has been passed through `doorstop review`. +- If you published, the publish directory exists and the artifact loads. +- Changes are staged or committed. diff --git a/skills/doorstop/scripts/doorstop_lint.sh b/skills/doorstop/scripts/doorstop_lint.sh new file mode 100755 index 00000000..83060113 --- /dev/null +++ b/skills/doorstop/scripts/doorstop_lint.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-3.0-only +# +# CI-friendly doorstop validation wrapper. +# +# Runs `doorstop` with flags that escalate WARNING to ERROR, so any real issue +# fails the build. Accepts extra args that are passed through to doorstop (so +# callers can e.g. `doorstop_lint.sh -s LEGACY` to skip one document). +# +# Default behavior: +# -e escalate WARNING -> ERROR (non-zero exit) +# -Z strict-child-check: every parent item must be referenced downstream +# +# Examples: +# ./doorstop_lint.sh # default gate +# ./doorstop_lint.sh -s LEGACY # skip the LEGACY document +# ./doorstop_lint.sh -R # also skip ref-file checks +# +# Set DOORSTOP_LINT_NO_STRICT=1 to drop -Z if your tree isn't ready for it. + +set -euo pipefail + +args=(-e) + +if [[ "${DOORSTOP_LINT_NO_STRICT:-0}" != "1" ]]; then + args+=(-Z) +fi + +exec doorstop "${args[@]}" "$@" diff --git a/skills/doorstop/scripts/doorstop_snapshot.py b/skills/doorstop/scripts/doorstop_snapshot.py new file mode 100755 index 00000000..8c220776 --- /dev/null +++ b/skills/doorstop/scripts/doorstop_snapshot.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python +# SPDX-License-Identifier: LGPL-3.0-only +"""Dump a doorstop tree as JSON for agent consumption. + +Writes a single JSON object to stdout with the following shape: + + { + "root": "/abs/path/to/project", + "documents": { + "REQ": { + "path": ".../reqs/req", + "parent": null, + "prefix": "REQ", + "sep": "", + "digits": 3, + "itemformat": "yaml", + "items": { + "REQ001": { + "level": "1.0", + "active": true, + "derived": false, + "normative": true, + "header": "", + "text": "...", + "ref": "", + "references": null, + "links": ["SYS001", ...], + "reviewed": "abc...==" | null, + "reviewed_current": "abc...==", + "reviewed_ok": true | false, + "path": ".../reqs/req/REQ001.yml", + "extended": { ... } + }, + ... + } + }, + ... + }, + "issues": [ + {"severity": "INFO" | "WARNING" | "ERROR", + "document": "REQ" | null, + "item": "REQ001" | null, + "message": "..."} + ], + "valid": true | false + } + +Run from anywhere inside a doorstop project: + + python doorstop_snapshot.py + python doorstop_snapshot.py --root /path/to/project + python doorstop_snapshot.py --pretty +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any, Dict, List + +import doorstop +from doorstop.common import DoorstopError, DoorstopInfo, DoorstopWarning + + +STANDARD_ATTRS = { + "active", + "derived", + "header", + "level", + "links", + "normative", + "ref", + "references", + "reviewed", + "text", +} + + +def _item_snapshot(item) -> Dict[str, Any]: + data = dict(item.data) + extended = {k: v for k, v in data.items() if k not in STANDARD_ATTRS} + + current_stamp = str(item.stamp()) + stored_stamp = data.get("reviewed") + stored_stamp = str(stored_stamp) if stored_stamp else None + + return { + "uid": str(item.uid), + "path": item.path, + "level": str(item.level), + "active": bool(item.active), + "derived": bool(item.derived), + "normative": bool(item.normative), + "header": str(data.get("header", "")), + "text": str(data.get("text", "")), + "ref": data.get("ref", ""), + "references": data.get("references"), + "links": [str(uid) for uid in item.links], + "reviewed": stored_stamp, + "reviewed_current": current_stamp, + "reviewed_ok": bool(item.reviewed), + "extended": extended, + } + + +def _document_snapshot(document) -> Dict[str, Any]: + return { + "prefix": str(document.prefix), + "parent": str(document.parent) if document.parent else None, + "sep": document.sep, + "digits": document.digits, + "itemformat": document.itemformat, + "path": document.path, + "items": { + str(item.uid): _item_snapshot(item) for item in document + }, + } + + +def _collect_issues(tree) -> (List[Dict[str, Any]], bool): + issues: List[Dict[str, Any]] = [] + # tree.issues is a generator of Doorstop{Info,Warning,Error}; call issues() + # via validate() so hooks run. + valid = True + + def record(exc, document=None, item=None): + nonlocal valid + if isinstance(exc, DoorstopInfo): + severity = "INFO" + elif isinstance(exc, DoorstopWarning): + severity = "WARNING" + elif isinstance(exc, DoorstopError): + severity = "ERROR" + valid = False + else: + severity = type(exc).__name__ + issues.append( + { + "severity": severity, + "document": str(document.prefix) if document else None, + "item": str(item.uid) if item else None, + "message": str(exc), + } + ) + + # Walk issues via tree.get_issues() — returns a generator over all. + for issue in tree.get_issues(): + record(issue) + + return issues, valid + + +def snapshot(root: str = None) -> Dict[str, Any]: + tree = doorstop.build(root=root) if root else doorstop.build() + tree.load() + + documents = {str(d.prefix): _document_snapshot(d) for d in tree} + issues, valid = _collect_issues(tree) + + return { + "root": tree.root, + "documents": documents, + "issues": issues, + "valid": valid, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + help="project root (defaults to git root / cwd)", + default=None, + ) + parser.add_argument( + "--pretty", + action="store_true", + help="indent JSON output", + ) + args = parser.parse_args() + + try: + data = snapshot(root=args.root) + except DoorstopError as exc: + print(json.dumps({"error": str(exc)}), file=sys.stderr) + return 1 + + kwargs = {"indent": 2, "sort_keys": True} if args.pretty else {"sort_keys": True} + json.dump(data, sys.stdout, default=str, **kwargs) + sys.stdout.write("\n") + return 0 if data["valid"] else 0 # never fail on validation — snapshot is read-only + + +if __name__ == "__main__": + sys.exit(main())