diff --git a/README.md b/README.md index 26b757d..05a23a7 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,82 @@ -# sdg-claude +# xspec -A Claude-native implementation of **Spec-Driven Generation (SDG)** — a structured process for building software by maintaining a master specification and letting AI generate everything else: the spec, the test spec, the certifications, the test harness, and the product. Humans answer clarifying questions to remove ambiguity; they do not write code or specs by hand. +**Requirement traceability for specifications written in MDX.** -This repository is the **template**. Dropped into a project, it makes every Claude Code session in that project run the SDG process automatically — no commands to learn, no CLI. You chat; the process does the rest. +xspec turns your spec documents into a typed, queryable dependency graph. Mark requirement sections with `` tags; xspec compiles each document into a strongly typed TypeScript module, links requirements to the code that implements and tests them, and uses the resulting project-wide graph to validate references, enforce dependency policy, measure coverage, analyze the impact of changes, and drive staged reviews. -## How it works +```mdx +{/* specs/AUTH.mdx */} + +Authentication. -- [`specs/PROCESS.md`](specs/PROCESS.md) — the harness-agnostic process specification (authoritative, immutable). -- [`specs/CLAUDE-PROCESS.md`](specs/CLAUDE-PROCESS.md) — how the process binds to Claude Code: the main thread is a "dumb" Orchestrator that only steps through phases; a forked **Liaison** subagent owns all Developer communication and `specs/PHILOSOPHY.md`; **Reviewer**, **Driver**, **Engineer**, and **Specialist** run as fresh-context subagents driven by per-phase mission prompts in `.claude/prompts/`. -- [`CLAUDE.md`](CLAUDE.md) — marks the project as SDG-governed, end to end, all or nothing. + +Users sign in with an email address and a password. + + +``` + +```ts +// src/auth.ts +import AUTH from "../specs/AUTH.xspec" + +export function login(email: string, password: string): boolean { + AUTH.auth.login // type-checked reference: this code implements that requirement + return email.includes("@") && password.length > 0 +} +``` + +```sh +xspec build # typed modules + Markdown + project graph +xspec check # validate everything; exit 1 on any finding +xspec coverage tested --check # gate CI on requirement coverage +xspec impact --base main # what does this change touch? +``` + +## Why -Defaults: every agent runs Claude **Fable** (`max` effort for the session/Liaison/Reviewer, `high` elsewhere), and `.claude/settings.json` sets `bypassPermissions` — this is built to run unattended in a sandboxed or cloud environment (Claude Code web, a container, a VM). Adjust `settings.json` if that doesn't describe your machine. +- **References that can't rot.** Requirement references in code are real TypeScript — hover shows the requirement text, go-to-definition jumps into the `.mdx`, and a renamed or deleted requirement is a compile error, not silent drift. +- **A graph, not a convention.** Sections, declared dependencies (`d`), text embeddings (`{text(...)}`), and code references form one project-wide graph you can query, gate, and diff. +- **Coverage as reachability.** Named profiles ask precise questions — "is every product requirement referenced by a test?" — and `--check` turns them into CI gates. +- **Impact you can trust.** Hash-based change detection attributes every downstream effect to the edit that caused it; `xspec rename`/`xspec move` record identity mappings in a journal, so refactoring produces *zero* spurious impact. +- **Reviews with memory.** Changes, audits, and coverage gaps become durable, staged checklists that unlock bottom-up and flag resolutions invalidated by later edits. +- **Deterministic by construction.** Every output and generated file is byte-identical for identical input: no timestamps, no randomness, no network. Exit codes mean things (`0` success, `1` findings, `2` usage/config errors). Every command speaks `--json`. -## Bootstrap a project +## Getting started -Use the **sdg-bootstrap skill** (in [`sdg-bootstrap/`](sdg-bootstrap/)) — share it with Claude and ask it to set up SDG in your project. Or do it manually: +Requires **Node.js ≥ 22**. Not yet published to npm — run from a checkout: ```sh -# in your new project directory -npx degit modularcloud/sdg-claude sdg-tmp -rsync -a --exclude README.md --exclude LICENSE --exclude sdg-bootstrap sdg-tmp/ ./ && rm -rf sdg-tmp +git clone https://github.com/modularcloud/xspec.git +cd xspec && npm ci && npm run build +npm link # puts `xspec` on your PATH ``` -Then make sure the project is a git repository with a GitHub remote and Actions enabled, open Claude Code in it, and describe what you want to build. +Then follow **[docs/getting-started.md](docs/getting-started.md)** to set up a project in minutes. -**Requirements:** Claude Code (desktop, CLI, or web) with access to Claude Fable; `git` plus authenticated GitHub access (the `gh` CLI locally; the built-in GitHub integration on web); a GitHub repository with Actions enabled; a sandboxed/disposable environment (see above). +## Documentation -### Running on Claude Code web +Usage guides live in [`docs/`](docs/README.md): -- **Set `CLAUDE_CODE_FORK_SUBAGENT=1` in the repository's environment configuration** on claude.ai/code (the same place you'd set API keys). Forked subagents are how Liaison inherits your conversation; the scaffold's `.claude/settings.json` also sets this variable, but the platform-level channel is the reliable one — without it, Liaison cannot fork and the process halts at Phase 1 by design. -- The web sandbox has no `gh` CLI; that's fine — the built-in GitHub integration covers branches, PRs, review comments, and CI status, and the process prompts are tool-agnostic about which is used. +| | | +|---|---| +| [Getting started](docs/getting-started.md) | Install → first project → first coverage report | +| [Writing specs](docs/writing-specs.md) | The `.mdx` syntax: sections, IDs, `d`, `text()`, tags | +| [Configuration](docs/configuration.md) | `xspec.config.ts`: groups, Markdown, coverage profiles, policy | +| [TypeScript integration](docs/typescript.md) | Generated modules, markers, `text()`, compiler setup | +| [CLI reference](docs/cli.md) | Every command, flag, exit code, and convention | +| [Coverage](docs/coverage.md) | Profiles, boundaries, modes, CI gating | +| [Impact analysis](docs/impact.md) | Hashes, change categories, baselines, impacted code | +| [Reviews](docs/reviews.md) | Staged review sessions and strategies | +| [Renaming & moving](docs/refactoring.md) | Identity-preserving refactoring and the journal | +| [Workspace files](docs/workspace.md) | What xspec writes and what to commit | -## Layout +The authoritative behavioral specification is [`specs/SPEC.md`](specs/SPEC.md); the docs are the guide, the spec is the law. -``` -CLAUDE.md Orchestrator charter — auto-loads every session -specs/ - PROCESS.md the SDG process (never modified) - CLAUDE-PROCESS.md Claude Code bindings, protocols, phase runbook - PHILOSOPHY.md Liaison-only memory of Developer principles - GOALS.md non-negotiable goals (Developer-approved edits only) - tmp/ patches/ process working files -.claude/ - settings.json model, effort, permissions defaults - agents/ sdg-reviewer, sdg-driver, sdg-engineer, sdg-specialist - prompts/ Liaison charter + per-phase mission prompts -sdg-bootstrap/ the bootstrap skill (not copied into projects) -``` +## Development + +This repository is built and maintained through **Spec-Driven Generation (SDG)**: the specification is the master artifact, and the spec, tests, and implementation are generated and kept in lockstep by the process defined in [`specs/PROCESS.md`](specs/PROCESS.md) (with Claude Code bindings in [`specs/CLAUDE-PROCESS.md`](specs/CLAUDE-PROCESS.md) and scaffolding under `.claude/`). Humans steer by editing goals and answering questions — not by hand-writing code — so issues and ideas are welcome as problem statements rather than patches. + +Build and test instructions (for CI and process runs) are in [`AGENTS.md`](AGENTS.md): `npm ci`, `npm run build`, `npm test`. The test harness under `test/` is a separate program that drives the built `xspec` executable as a subprocess; certification fixtures keep the harness itself honest. ## License diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f023a7c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,68 @@ +# xspec documentation + +xspec is a requirement-traceability tool for specifications written in MDX. You mark requirement sections in your spec documents with `` tags, and xspec compiles them into strongly typed TypeScript modules, builds a project-wide dependency graph between requirements and code, and uses that graph to validate references, enforce dependency policy, measure coverage, analyze the impact of changes, and drive staged reviews. + +These pages are the usage guide. The authoritative behavioral specification is [`specs/SPEC.md`](../specs/SPEC.md); if a page here ever disagrees with it, the specification wins. + +## Thirty-second tour + +```mdx +{/* specs/AUTH.mdx */} + +Authentication. + + +Users sign in with an email address and a password. + + +``` + +```ts +// src/auth.ts +import AUTH from "../specs/AUTH.xspec" + +export function login(email: string, password: string): boolean { + AUTH.auth.login // ← dependency marker: this code implements that requirement + return email.includes("@") && password.length > 0 +} +``` + +```sh +xspec build # generate typed modules, Markdown, and the graph +xspec check # validate everything; exit 1 on any finding +xspec coverage # which requirements are exercised, and by what +xspec impact --base main # what a change touches, up and down the graph +``` + +Requirement references are real, type-checked TypeScript — renaming a requirement without updating the code is a compile error, and `xspec rename` updates every reference for you while preserving identity in the change-tracking journal. + +## Guide + +Read in order if you are new: + +1. **[Getting started](getting-started.md)** — install, create a project, first build, first coverage report. +2. **[Writing specs](writing-specs.md)** — the `.mdx` source syntax: sections, IDs, dependencies, embedding, tags, Markdown output. +3. **[Configuration](configuration.md)** — `xspec.config.ts`: spec and code groups, Markdown emission, coverage profiles, policy rules. +4. **[Using specs from TypeScript](typescript.md)** — generated modules, `text()`, dependency markers, compiler setup. + +Reference and workflows: + +5. **[CLI reference](cli.md)** — every command, flag, exit code, and output convention. +6. **[Coverage](coverage.md)** — profiles, boundaries, direct vs. transitive coverage, gating CI. +7. **[Impact analysis](impact.md)** — hashes, change categories, baselines, impacted code. +8. **[Reviews](reviews.md)** — staged review sessions: `path-blocks`, `audit`, and `coverage` strategies. +9. **[Renaming and moving](refactoring.md)** — `xspec rename`, `xspec move`, and the identity journal. +10. **[Workspace files](workspace.md)** — what xspec writes, derived vs. durable files, what to commit. + +## What xspec is not + +- **Not a semantic checker.** Coverage is graph reachability; a `depends` edge or a code marker asserts a relationship, it does not prove the code is correct. +- **Not a renderer.** Markdown output is a plain-text export of your specs with the annotations stripped; xspec does not build doc sites. +- **Not networked.** xspec performs no network access, reads git data only where baselines call for it (`impact --base`, baseline review sessions), and never writes to git. + +## Guarantees worth knowing up front + +- **Deterministic output.** All output, generated files, and stored data are byte-deterministic for identical input: no timestamps, no randomness, no absolute paths. Diffs stay meaningful and CI stays reproducible. +- **Three exit codes.** `0` success, `1` findings (validation errors, policy violations, uncovered requirements under `--check`, refused operations), `2` usage or configuration errors. Anything else (the CLI uses `70`) is an internal error, never a defined outcome. +- **`--json` everywhere.** Every command can emit a single JSON document with the same information as the human report. +- **Durable identity.** `xspec rename` and `xspec move` record identity mappings in a journal, so refactoring your spec tree does not show up as spurious change in impact reports or invalidate review work. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..fb38f02 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,194 @@ +# CLI reference + +``` +xspec [arguments] [flags] +``` + +Commands: `build`, `check`, `ids`, `show`, `coverage`, `impact`, `review `, `query `, `rename`, `move`. + +## Global conventions + +These hold for **every** command: + +- **`--json`** — emit a single JSON document on stdout containing the same information as the human report. For `query` and `review export` the output is JSON with or without the flag. +- **`--config `** — use this configuration file instead of searching upward from the working directory for `xspec.config.ts`. +- **Flag syntax** — flags are space-separated (`--config path`, not `--config=path`); each flag may be given at most once; list-valued flags (`--kinds`) take one comma-separated value (`--kinds depends,embeds`). +- **Arguments** — node and file arguments (``, ``, ``, `--file`) are **workspace-relative** (`specs/AUTH.mdx#auth.login`), independent of your working directory. Only `--config` and `--test-hold` are ordinary filesystem paths resolved against the working directory. Values must be valid UTF-8. +- **Streams** — reports (including findings) go to **stdout**; usage/configuration errors and diagnostics go to **stderr**. With `--json`, the JSON document is the entire stdout; when an exit-2 error prevents emitting one, stdout is empty. +- **Determinism** — all output and files are byte-deterministic for identical input: no timestamps, no randomness, no absolute paths. Where "one shortest path" is reported and several tie, the byte-least one is chosen — always the same one. +- **Comparisons** — IDs, tags, identities, session names, and paths compare byte-wise and case-sensitively throughout. + +### Exit codes + +| Code | Class | Examples | +|---|---|---| +| `0` | Success | Clean `build`/`check`; every informational report (`ids`, `show`, `impact`, `query`, review reads, `coverage` without `--check`) | +| `1` | Findings | `build` on invalid sources; `check` findings; `coverage --check` with uncovered requirements; refused `rename`/`move`; refused review operations; corrupt review session | +| `2` | Usage / configuration errors | Unknown command, flag, or flag value; missing arguments; unknown profile/session/group/node/file named in arguments; invalid or missing configuration; unreadable baseline; a mutating command blocked by another one running | +| `70` | Internal error | A crash — never a defined outcome; report it | + +### Freshness model + +`build` is the only command that (re)generates TypeScript and Markdown. The read commands (`ids`, `show`, `coverage`, `impact`, `review`, `query`) never answer from stale data: if the graph data under `.xspec/` does not match the current sources, they refresh it silently first (graph data only — never generated TS/Markdown). If the sources fail validation, they report the errors and exit `1` without answering. `check` is the exception: it never refreshes anything — it reports staleness as a finding. + +--- + +## `xspec build` + +Parses configured sources; validates structure, IDs, tags, and references; resolves dependencies; generates TypeScript modules; emits Markdown (if enabled); writes graph data. Silent on success. + +- Does **not** evaluate policy rules — those are `check` findings only. +- Regenerates every derived file and removes recorded derived files that are no longer generated. +- A failed build (exit `1` findings, exit `2` config error) **modifies nothing**. + +```sh +$ xspec build +$ echo $? +0 +``` + +## `xspec check` + +Everything `build` validates, plus: generated files match current sources byte-for-byte (staleness), no orphaned derived files, all references resolve and are static, no dependency or import cycles, the journal is well-formed and replayable, **no policy violations**, review sessions are intact. Writes nothing. Exit `1` on any finding; findings print to stdout, one per line, followed by a count: + +```sh +$ xspec check +specs/AUTH.mdx:331-353: invalid structural ID (14.2): invalid structural ID +"unrelated.section": a top-level section's ID is checked against the empty +prefix and is exactly one segment (SPEC 1.3, 14.2) +1 finding +``` + +Every finding names the file/location, the condition (numbered per SPEC §14), and the correction. `check` is the CI gate: `xspec build && xspec check` proving a workspace clean is the everyday invariant. + +## `xspec ids` + +Lists requirement IDs grouped by file (files in byte order, IDs in document order). + +| Flag | Meaning | +|---|---| +| `--tree` | Render each file's IDs as a nested tree instead of a flat list | +| `--file ` | Restrict to files matching the glob | +| `--unreferenced` | Only nodes with **no incoming dependency edges** from specs or code (`contains` doesn't count) | + +```sh +$ xspec ids --tree +specs/AUTH.mdx + auth + auth.login + auth.login.valid + auth.login.invalid + auth.lockout +``` + +`--unreferenced` answers "what does nothing point at?" — not the same as uncovered (a node can be referenced by something outside a profile's boundary and still be uncovered). + +## `xspec show ` + +Prints one requirement for human reading: identity, source range, tags, coverage attribute, all four hashes, incoming/outgoing edges by kind, own text, and subtree text. `` is `path#id`, or a bare `path` for a file's root node. `xspec query node` is the machine-facing equivalent. + +## `xspec coverage [] [--check]` + +Runs all configured coverage profiles, or one by name. Reports counts plus the identity of every covered (with one shortest covering path), uncovered, and ignored node (with exclusion reasons). With `--check`, exits `1` if any required node is uncovered. See [Coverage](coverage.md). + +## `xspec impact --base ` + +Compares the current workspace against the graph reconstructed at a git ref (identities mapped through the journal). Reports requirement change categories with attribution, then directly/transitively impacted code with witness paths. Informational: exits `0` either way. See [Impact analysis](impact.md). + +## `xspec review …` + +Staged review sessions over graph results. Eight subcommands: + +```sh +xspec review create --base --name # path-blocks strategy +xspec review create --strategy audit --name # audit strategy +xspec review create --coverage --name # coverage strategy +xspec review list +xspec review status +xspec review next [--json] +xspec review show +xspec review split +xspec review resolve --status [--note ] +xspec review export +``` + +`create` requires exactly one of `--base`, `--strategy audit`, `--coverage`. `resolve --status` accepts `updated`, `no-change`, `skipped`. `export` emits JSON always. Sessions live in `.xspec/reviews/.json`; names are limited to `A–Z a–z 0–9 . _ -` and must not start with `.`. See [Reviews](reviews.md) for the model and a worked session. + +## `xspec query ` + +Set-level, **JSON-only** access to the graph for scripts and agents: + +```sh +xspec query node +xspec query nodes [--group ] [--file ] [--tag ] [--coverage required|none] +xspec query edges [--from ] [--to ] [--kinds ] +xspec query subtree +xspec query ancestors +xspec query reachable --from --to [--kinds ] +``` + +- `` is a requirement identity (`path#id`, bare `path` = root). `` is that or a code location (`path`, `path#unit`, `path#unit@N`). Whether a bare path is a spec root or a code file follows from its group. +- `node` returns identity, source range, own/subtree text, all four hashes, tags, coverage attribute, and all edges. `nodes`/`subtree`/`ancestors` return one row per node (identity, range, tags, coverage). `subtree` is the node plus descendants in document order; `ancestors` is proper ancestors nearest-first. +- `nodes` filters combine conjunctively; `--group` takes a spec group name only. +- `edges` filters over all four kinds; `reachable` accepts only the three dependency kinds and reports whether a dependency path exists plus one shortest witness: + +```sh +$ xspec query reachable --from "test/auth.test.ts#testValidLogin" \ + --to "specs/AUTH.mdx#auth.login.valid" +{ + "path": [ + "test/auth.test.ts#testValidLogin", + "specs/AUTH.mdx#auth.login.valid" + ], + "reachable": true +} +``` + +## `xspec rename ` + +Renames a requirement ID, rewrites descendant IDs and **every reference across the workspace** (spec `id`s, `d` refs, `text(...)` refs, TypeScript markers), and appends the identity mapping to the journal. Refuses (exit `1`) rather than corrupt: invalid or colliding new ID, broken structural rules, or a workspace that doesn't currently pass `build` validation. Finishes by regenerating derived files. See [Renaming and moving](refactoring.md). + +## `xspec move ` + +Two forms: + +```sh +xspec move # relocate a whole source file +xspec move # # # extract/move a section subtree +``` + +Both rewrite all references (converting between local and imported forms, adding/removing imports as needed), append the mapping to the journal, and regenerate. The section form's exact text edits and refusal conditions are covered in [Renaming and moving](refactoring.md). + +--- + +## Concurrency + +All state is workspace-local. Mutating commands — `rename`, `move`, `review create|resolve|split` — are **mutually exclusive per workspace**: while one runs, a second fails promptly with a usage error (exit `2`) and modifies nothing. Exclusivity ends when the holder's process terminates, even abnormally. Everything else may run concurrently; file writes are atomic in effect (a reader sees the old content or the new, never a torn write), and any derived-file inconsistency from concurrent non-mutating runs is fixed by re-running `build`. + +Each mutating command also accepts `--test-hold ` — a deterministic test seam that creates the given file after acquiring exclusivity and waits until it is deleted before proceeding. You will not need it outside of testing xspec itself. + +## Validation findings catalog + +The conditions `build`/`check` report, in SPEC §14 numbering (each finding cites its number). All are reported by both commands except where noted: + +| # | Condition | +|---|---| +| 1–4 | Missing ID; invalid structural ID; duplicate ID; invalid segment or tag | +| 5–7 | Unresolved `d` reference; unresolved `text(...)` target; unresolved TypeScript reference | +| 8 | Non-static reference/argument; wrong `text(...)` arity; string-form `text()` in TS | +| 9 | Dependency cycle or spec import cycle (with the full path) | +| 10 | Stale/orphaned generated output — **`check` only** | +| 11 | Cross-module `text` call | +| 12 | Policy violation — **`check` only** | +| 13 | Journal error (malformed/conflicting/unreplayable entries) | +| 14 | Configuration error — reported by every command as exit `2`, precedes all source analysis | +| 15 | Invalid import (in spec or TypeScript files) | +| 16 | Invalid construct (foreign JSX/expression/`export` in a spec file) | +| 17 | Invalid prop on ``/`` | +| 18 | Unsupported node usage in TypeScript | +| 19 | Invalid source path (`#` in path, non-UTF-8, non-`.mdx` spec file) | +| 20 | Unparseable source (bad MDX/TypeScript, invalid UTF-8, BOM) | +| 21 | Corrupt review session — `check` and `review` subcommands, not `build` | +| 22 | Symbolic link in a write path | + +Multiple conditions are all reported together — not just the first — except where one masks another (an unparseable file hides what's inside it; references into it report as unresolved). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..4b4d547 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,166 @@ +# Configuration: `xspec.config.ts` + +Every xspec project is configured by a single `xspec.config.ts`. Its directory is the **workspace root**: all globs and paths resolve relative to it, and every identity xspec prints is workspace-relative. + +Commands find the file by upward search from the working directory; `--config ` (available on every command) points at it explicitly. A missing or invalid configuration is a usage error — exit `2`, before any source is read. + +## The file is data, not code + +xspec **parses the configuration statically and never executes or imports it**. The file must consist of exactly: + +1. an import of `defineConfig` from the module specifier `"xspec"` (aliasing allowed), and +2. a default export of one call to that binding, whose argument is built only from object literals (plain identifier or string keys), array literals, plain quoted strings, and `true`/`false`. + +No spreads, no computed values, no other statements. This makes configuration incapable of side effects or environment-dependent behavior — a property the rest of the tool's byte-determinism relies on. Anything outside this form, and any unknown key anywhere in the argument, is a configuration error (exit `2`). + +`defineConfig` is an identity function whose only job is editor type support while you author the file. Because xspec never resolves the import, the CLI works even when the `xspec` package is not installed in your project; install it (or exclude the config from your tsconfig) only to keep your own `tsc` happy. + +## Full example + +```ts +import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + product: ["specs/product/**/*.mdx"], + tests: ["specs/tests/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts", "src/**/*.tsx"], + tests: ["test/**/*.ts", "test/**/*.tsx"] + }, + markdown: { emit: true, outDir: "build/md" }, + coverage: [ + { + name: "product-tested", + target: "product", + boundary: "tests", + boundaryKind: "code", + mode: "direct" + } + ], + policy: [ + { + name: "product-depends-on-nothing", + type: "forbidden", + from: { group: "product" }, + to: { group: "tests", kind: "spec" } + } + ] +}) +``` + +`specs` is required. `code`, `markdown`, `coverage`, and `policy` are optional; omitting one simply means no code groups, no Markdown emission, no coverage profiles, or no policy rules. Empty `coverage`/`policy` arrays are valid and equivalent to omission. + +## `specs` — spec groups + +Named groups of `.mdx` source files, each a list of globs: + +```ts +specs: { + product: ["specs/product/**/*.mdx"], + tests: ["specs/tests/**/*.mdx"] +} +``` + +- A file may belong to several groups. +- Every matched file must end in `.mdx`; a glob matching anything else is an error. +- A group whose globs match nothing is valid — discovery just yields fewer sources. + +Group names are what coverage profiles, policy selectors, and `query nodes --group` refer to. + +## `code` — code groups + +Named groups of TypeScript files. Code groups serve two purposes: they can be a coverage **boundary** ("covered = referenced from this code"), and they are the population reported by [impacted-code analysis](impact.md#impacted-code). + +A file matched by both a spec group and a code group is a configuration error. Files whose names carry `.xspec.` (generated modules), files under `.xspec/`, and configured Markdown output destinations are never discovered as sources, so generated artifacts cannot sneak into groups via a broad glob. + +## `markdown` — pure-Markdown emission + +```ts +markdown: { emit: true, outDir: "build/md" } +``` + +- `emit` (required boolean): whether each `NAME.mdx` compiles to a pure `NAME.md` ([what that means](writing-specs.md#markdown-compilation)). +- `outDir` (optional): redirect emitted files into a directory, preserving workspace-relative paths. Must resolve inside the workspace root. Default: emit next to each source file. + +## `coverage` — coverage profiles + +Each profile is a named question of the form "is every requirement in *target* reachable from *boundary*?" — evaluated by `xspec coverage`. See [Coverage](coverage.md) for semantics; the fields: + +| Field | Required | Meaning | +|---|---|---| +| `name` | yes | Unique profile name; `xspec coverage ` runs just this one. | +| `target` | yes | Spec group whose requirements must be covered. | +| `targetTags` | no | Restrict targets to nodes carrying at least one of these tags. Empty list = error. | +| `targets` | no | `"leaves"` (default: only childless nodes are targets) or `"all"`. | +| `boundary` | yes | Spec **or** code group that counts as "covering". | +| `boundaryKind` | see below | `"spec"` or `"code"`. | +| `mode` | yes | `"direct"` (one edge) or `"transitive"` (a path of edges). | +| `edgeKinds` | no | Subset of `["depends", "embeds", "references"]`; default all three. Empty list = error. | + +`boundaryKind` must be omitted when the group name is unambiguous and must be given when the same name exists as both a spec and a code group. Referring to a group that does not exist is a configuration error. + +## `policy` — dependency policy rules + +Policy rules constrain which dependency edges (`depends`, `embeds`, `references`) may exist. They are evaluated by `xspec check` only — `build` regenerates output regardless, so a policy violation never blocks builds, it fails the gate. + +| Field | Required | Meaning | +|---|---|---| +| `name` | yes | Unique rule name, cited in findings. | +| `type` | yes | `"forbidden"` or `"allowedOnly"`. | +| `from`, `to` | yes | Selectors (below). | +| `kinds` | no | Subset of the dependency edge kinds; default all three. Empty list = error. | + +Semantics over edges of the rule's kinds: + +- **`forbidden`** — any edge whose source matches `from` *and* whose target matches `to` is a violation. +- **`allowedOnly`** — every edge whose source matches `from` must have a target matching `to`; each edge that does not is a violation. + +### Selectors + +A selector matches nodes or code locations by exactly one of: + +```ts +{ group: "product" } // members of a named group +{ group: "tests", kind: "code" } // kind required only when the name is ambiguous +{ files: "src/legacy/**" } // a path glob +{ tags: ["draft", "internal"] } // carries at least one listed tag +``` + +### Capture wildcards in `files` selectors + +The `from` pattern may contain captures `$1`…`$9` (each at most once), and the `to` pattern may reference them — the way to express *parallel-structure* rules like "a module's spec may only be depended on by that module's own code": + +```ts +{ + name: "same-module-only", + type: "allowedOnly", + from: { files: "src/$1/**" }, + to: { files: "specs/$1/**" } +} +``` + +A capture matches one or more bytes within a single path segment (never `/`). Matching is disambiguated left to right, each wildcard and capture taking as few bytes as possible — so every match and every capture value is unique. For example, `$1-$2.ts` against `a-b-c.ts` captures `$1 = a`, `$2 = b-c`. A `to` referencing a capture absent from `from` is a configuration error. + +A violation finding names the rule and the offending edge: + +``` +policy violation (14.12): policy violation: rule "app-avoids-drafts": the +references edge src/auth.ts#login -> specs/AUTH.mdx#auth.login.valid violates +the rule — its source matches "from" and its target matches "to" of the +forbidden rule (SPEC 7.5); remove or redirect the dependency, or revise the +rule in the configuration (SPEC 14.12) +``` + +## Glob rules + +Globs appear in groups, selectors, `markdown.outDir` handling, and the `--file` flags of `ids`/`query nodes`. The language is deliberately small: + +- `*` — any (possibly empty) run of bytes within one path segment +- `?` — exactly one byte within a segment +- `**` — any number of whole segments, including none + +Matching is byte-wise and case-sensitive. A path segment beginning with `.` is matched only by a pattern segment that itself starts with `.` — `**/*.mdx` does not see `specs/.drafts/x.mdx`. A pattern that resolves outside the workspace root is an error. + +Discovery never follows symbolic links — a symlink (to a file or directory, broken or not) is never a discovered source and never traversed, so linked or cyclic directory structures cannot pull outside content into the workspace. diff --git a/docs/coverage.md b/docs/coverage.md new file mode 100644 index 0000000..22aa33c --- /dev/null +++ b/docs/coverage.md @@ -0,0 +1,86 @@ +# Coverage + +Coverage answers one question per configured profile: **is every requirement I care about reachable from the things that are supposed to exercise it?** It is graph reachability over dependency edges — deliberate, inspectable, and deterministic — not proof of semantic correctness. + +## The model + +A profile ([configured](configuration.md#coverage--coverage-profiles) in `xspec.config.ts`) names: + +- a **target**: the spec group whose requirements must be covered, optionally narrowed by `targetTags` and, by default, to leaves; +- a **boundary**: the spec or code group that counts as "covering" — test code, test specs, a design layer; +- a **mode**: `direct` (a single dependency edge from a boundary node to the target) or `transitive` (a path of one or more edges); +- optionally **edgeKinds**: which of `depends`, `embeds`, `references` may carry coverage (default: all three). + +A target requirement is **covered** when a permitted path exists from a boundary node to it. Two exclusions apply everywhere: `contains` edges never grant coverage (structure isn't testing), and root nodes never appear in coverage paths at all — not as boundary, intermediate, or target. A spec group used as a boundary contributes only its non-root requirement nodes. + +### The required set + +For each profile, the required nodes are: the target group's nodes → restricted to those carrying a `targetTags` tag (when configured) → restricted to leaves (unless `targets: "all"`) → minus `coverage="none"` nodes → minus roots. Everything excluded is reported as **ignored**, with every reason that applies. + +## Running it + +`xspec coverage` runs all profiles; `xspec coverage ` runs one: + +```sh +$ xspec coverage +profile tested + required: 3, covered: 2, uncovered: 1, ignored: 5 + covered: + specs/AUTH.mdx#auth.login.valid + path: test/auth.test.ts#testValidLogin -> specs/AUTH.mdx#auth.login.valid + specs/AUTH.mdx#auth.login.invalid + path: test/auth.test.ts#testInvalidLogin -> specs/AUTH.mdx#auth.login.invalid + uncovered: + specs/AUTH.mdx#auth.lockout + ignored: + specs/AUTH.mdx: root node; non-leaf under targets: "leaves" + specs/AUTH.mdx#auth: non-leaf under targets: "leaves" + specs/AUTH.mdx#auth.login: non-leaf under targets: "leaves" + specs/OVERVIEW.mdx: root node; non-leaf under targets: "leaves" + specs/OVERVIEW.mdx#overview: coverage="none" +``` + +Every covered node comes with **one shortest covering path** (ties broken byte-deterministically), so "covered" is always a claim you can click through, not a boolean. `--json` gives the same information as data. + +### Gating CI + +```sh +xspec coverage tested --check +``` + +exits `1` if any required node is uncovered (`0` otherwise). Without `--check`, `coverage` is informational and exits `0`. A typical CI sequence: + +```sh +xspec build +xspec check +xspec coverage tested --check +``` + +## Choosing a mode + +**`direct`** is the strict form: the boundary itself must reference the requirement. Use it when tests carry [markers](typescript.md#two-ways-to-reference-a-requirement) straight to the requirements they exercise: + +``` +test/auth.test.ts#testValidLogin ──references──▶ specs/AUTH.mdx#auth.login.valid +``` + +**`transitive`** allows chains, for layered projects. A common shape: product requirements are covered by *test specs* which are in turn referenced by test code — the covering path runs boundary → intermediate → target: + +``` +test code ──references──▶ test spec ──depends──▶ product requirement +``` + +With `mode: "transitive"`, a profile targeting the product group and bounded by the test-spec group (or the test-code group) accepts such paths. Use `edgeKinds` to tighten what may carry coverage — e.g. `["depends"]` to insist on declared dependencies and ignore incidental text embeddings. + +## Interpreting the numbers + +- **uncovered** is your work list: requirements no boundary node reaches. Create a [coverage review session](reviews.md#coverage-sessions--burn-down-uncovered-requirements) (`xspec review create --coverage --name `) to burn it down as a checklist. +- **ignored** is the audit trail for scope: every target-group node excluded from the required set, with reasons in a fixed order (`root node`, `coverage="none"`, `non-leaf under targets: "leaves"`, lacking every `targetTags` tag). If something you expected to be measured shows up here, the reason tells you which knob to turn. +- `xspec ids --unreferenced` is related but different: it lists nodes with **no incoming dependency edges at all**, regardless of any profile. A node can be referenced (by the app, by a sibling spec) yet still uncovered for a profile whose boundary is the test group. + +## Practical tips + +- **Leaves are the default target for a reason**: parents are prose structure; leaves are the testable statements. Switch a profile to `targets: "all"` only when parent sections carry independent requirements of their own. +- **Multiple profiles are cheap.** A `tested` profile (boundary: test code, `direct`) next to a `designed` profile (boundary: design specs, `transitive`) gives two orthogonal gates over the same graph. +- **Tag-scoped profiles** (`targetTags: ["critical"]`) let you hard-gate a subset (`--check` in CI) while the broader profile stays informational. +- Coverage reads the graph, so it reflects the last `build`/refresh — the read commands refresh graph data automatically; you do not need to `build` first unless you also want regenerated modules. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..3acfe5f --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,236 @@ +# Getting started + +This walkthrough takes you from an empty directory to a validated, coverage-measured spec project. Every command and output shown here was produced by the real tool. + +## Install + +xspec requires **Node.js ≥ 22**. It is not yet published to npm, so run it from a checkout of this repository: + +```sh +git clone https://github.com/modularcloud/xspec.git +cd xspec +npm ci +npm run build +``` + +The executable is `dist/cli/bin.js`. Either invoke it directly: + +```sh +node /path/to/xspec/dist/cli/bin.js +``` + +or link it once so `xspec` is on your `PATH`: + +```sh +npm link # from the xspec checkout +xspec build # now available anywhere +``` + +The rest of the documentation writes `xspec ` and assumes one of the above. + +## Create a project + +An xspec project is any directory with an `xspec.config.ts` at its root. The config names your spec files and (optionally) the code that consumes them: + +```ts +// xspec.config.ts +import { defineConfig } from "xspec" + +export default defineConfig({ + specs: { + product: ["specs/**/*.mdx"] + }, + code: { + app: ["src/**/*.ts"], + tests: ["test/**/*.ts"] + }, + markdown: { emit: true }, + coverage: [ + { + name: "tested", + target: "product", + boundary: "tests", + mode: "direct" + } + ] +}) +``` + +Two things to know about this file: + +- xspec **parses it statically and never executes it** — it must be purely declarative (literals only). See [Configuration](configuration.md) for the full schema. +- The `import { defineConfig } from "xspec"` exists for editor type support. xspec itself never resolves it, so your project does not need xspec installed as a dependency for the CLI to work. If your own `tsc` run includes `xspec.config.ts`, either install the package (e.g. `npm install /path/to/xspec/checkout`) or exclude the config file from your tsconfig. + +## Write a first spec + +Spec files are MDX documents in which requirement sections are wrapped in `` tags. Each section carries a structural dot-path `id`: + +```mdx +{/* specs/AUTH.mdx */} + +Authentication. + + +Users sign in with an email address and a password. + + +A user submitting valid credentials is signed in. + + + +Invalid credentials are rejected with a generic error message. + + + + +Five consecutive failed attempts lock the account for 15 minutes. + + +``` + +The nesting is the structure: `auth.login.valid` must be a child of `auth.login`, which must be a child of `auth`. See [Writing specs](writing-specs.md) for the complete syntax. + +## Build + +```sh +$ xspec build +$ echo $? +0 +``` + +`build` validates the sources and generates everything (see [Workspace files](workspace.md) for details): + +``` +specs/AUTH.mdx ← your source +specs/AUTH.md ← pure Markdown (annotations stripped), because markdown.emit +specs/AUTH.xspec.ts ← generated typed module +specs/AUTH.xspec.impl.js ← companions of the generated module +specs/AUTH.xspec.impl.d.ts +specs/AUTH.xspec.impl.d.ts.map +.xspec/graph.json ← the project graph +``` + +A failed `build` (invalid sources, exit `1`) modifies nothing — derived files stay exactly as they were. + +## Reference requirements from code + +Code declares which requirements it implements or exercises by importing the generated module: + +```ts +// src/auth.ts +import AUTH from "../specs/AUTH.xspec" + +export function login(email: string, password: string): boolean { + AUTH.auth.login.valid // dependency marker → "references" edge + return email.includes("@") && password.length > 0 +} +``` + +```ts +// test/auth.test.ts +import AUTH, { text } from "../specs/AUTH.xspec" + +export function testValidLogin(): string { + AUTH.auth.login.valid // marker: this test exercises that requirement + return text(AUTH.auth.login.valid) // the requirement text, as a string +} + +export function testInvalidLogin(): void { + AUTH.auth.login.invalid +} +``` + +A marker is an ordinary property read at runtime (harmless, no tooling required) and a type-checked reference at compile time: if the requirement is renamed or deleted, your build breaks instead of silently drifting. Setup details and rules are in [Using specs from TypeScript](typescript.md). + +Re-run `xspec build` after adding code references so the graph includes them. + +## Inspect the graph + +List every requirement: + +```sh +$ xspec ids --tree +specs/AUTH.mdx + auth + auth.login + auth.login.valid + auth.login.invalid + auth.lockout +``` + +Show one requirement — its text, tags, hashes, and every edge in and out: + +```sh +$ xspec show "specs/AUTH.mdx#auth.login.valid" +specs/AUTH.mdx#auth.login.valid +source range: bytes 104-202 +tags: happy-path +coverage: required +hashes: + ownHash: b235263b… + subtreeHash: bc3ccfe3… + effectiveHash: 08b71bba… + metadataHash: d2d935c3… +edges: + incoming: + contains from specs/AUTH.mdx#auth.login + references from src/auth.ts#login + embeds from test/auth.test.ts#testValidLogin + references from test/auth.test.ts#testValidLogin + outgoing: +own text: + A user submitting valid credentials is signed in. +subtree text: + A user submitting valid credentials is signed in. +``` + +`xspec query` is the machine-facing equivalent (JSON only) — see the [CLI reference](cli.md#xspec-query-sub). + +## Measure coverage + +The `tested` profile above asks: which `product` requirements are directly referenced by anything in the `tests` code group? + +```sh +$ xspec coverage +profile tested + required: 3, covered: 2, uncovered: 1, ignored: 5 + covered: + specs/AUTH.mdx#auth.login.valid + path: test/auth.test.ts#testValidLogin -> specs/AUTH.mdx#auth.login.valid + specs/AUTH.mdx#auth.login.invalid + path: test/auth.test.ts#testInvalidLogin -> specs/AUTH.mdx#auth.login.invalid + uncovered: + specs/AUTH.mdx#auth.lockout + ignored: + specs/AUTH.mdx: root node; non-leaf under targets: "leaves" + ... +``` + +`auth.lockout` has no test yet. `xspec coverage tested --check` exits `1` while that is true — wire it into CI to keep specs and tests honest. Details in [Coverage](coverage.md). + +## Validate continuously + +```sh +$ xspec check +$ echo $? +0 +``` + +`check` runs every build validation without writing anything, and additionally verifies that generated files are up to date, references resolve, no dependency cycles exist, policy rules hold, the journal replays, and review sessions are intact. Any finding exits `1` with an actionable message: + +```sh +$ echo "// tampered" >> specs/AUTH.xspec.ts && xspec check +specs/AUTH.xspec.ts: stale generated output (14.10): stale generated output: +specs/AUTH.xspec.ts does not match what the current sources and configuration +generate; run `xspec build` to regenerate every derived file (SPEC 14.10) +1 finding +``` + +`xspec build && xspec check` is the everyday loop; `check` alone is the CI gate. + +## Where to go next + +- Track what a spec edit affects: [Impact analysis](impact.md) +- Turn changes into a reviewable checklist: [Reviews](reviews.md) +- Restructure specs without losing history: [Renaming and moving](refactoring.md) +- Commit the right files: [Workspace files](workspace.md) diff --git a/docs/impact.md b/docs/impact.md new file mode 100644 index 0000000..a735564 --- /dev/null +++ b/docs/impact.md @@ -0,0 +1,118 @@ +# Impact analysis + +```sh +xspec impact --base +``` + +`impact` compares the current workspace against a **baseline**: the graph reconstructed from the workspace content at a git ref — sources *and* configuration as they stood there — with identities mapped forward through the [journal](refactoring.md#the-journal). It answers "what did this change touch?" at two levels: requirement nodes (with change categories and attribution) and code (which locations are impacted, with witness paths). + +`impact` is informational: it exits `0` whether or not differences exist, and `--json` emits the full report as data. It reads git history but never writes to git. + +## Hashes + +Every requirement node carries four hashes; the categories below are defined in terms of them. In practice you rarely look at hash values — you look at the categories — but knowing what each hash *covers* tells you why a node shows up: + +| Hash | Covers | Changes when | +|---|---|---| +| `ownHash` | The node's own content: its text runs plus the positions and identities of its children and embedded references | You edit the node's prose; add/remove/reorder its children; add, remove, retarget, or reposition a `{text(...)}` embedding | +| `subtreeHash` | `ownHash` + all descendants' subtree hashes, in order | Anything changes anywhere in the subtree | +| `effectiveHash` | `subtreeHash` inputs + the dependency edges of the node and its subtree, each as (target identity, target's `effectiveHash`) | The subtree changes, a dependency is added/removed/retargeted, or **any upstream target's `effectiveHash` changes** — this is the hash that propagates through the graph | +| `metadataHash` | The node's `d` target set, `coverage` attribute, and tags | Metadata edits only | + +Two consequences worth internalizing: + +- **Embedding insulates the embedder.** `{text(X)}` hashes as a *reference to X*, not as X's expanded text. Editing X changes X's hashes; the embedder is affected only via `effectiveHash` — an upstream change — while its Markdown output still re-expands on the next build. +- **References hash by canonical identity**, resolved through the journal. A journaled `rename`/`move` changes no hash anywhere; hand-editing an ID does (it's a delete plus an add). + +## Change categories + +Relative to the baseline, each node receives zero or more categories, each **attributed to the originating nodes** — the places actual edits happened: + +| Category | Meaning | +|---|---| +| `changed` | The node was added or deleted, or its own content changed (structural edits — adding/removing a child — originate at the parent, which is also `changed`) | +| `metadata-changed` | Its `d` targets, `coverage`, or tags changed | +| `descendant-changed` | Something in its subtree changed (attributed to the descendants that changed) | +| `upstream-changed` | A dependency target of the node or its subtree changed effectively (attributed to the originating edits upstream) | + +Categories are independent flags; a node can carry several. A node added or deleted since the baseline is `changed` and nothing else. + +## Impacted code + +Code locations enter through their `references` and `embeds` edges (the union of both graphs — an edge deleted since the baseline still implicates its location): + +- **directly impacted** — the location has an edge to a node whose `subtreeHash` changed: the text it points at is different. +- **transitively impacted** — the location has an edge to a node whose `effectiveHash` changed but whose `subtreeHash` did not: what it points at reads the same, but something it depends on moved. + +Each impacted location is reported with one impact edge and **one shortest witness path** from that edge's target to a node whose own edit explains the change — so every "you are impacted" comes with a traceable why. Deleted requirements and deleted code locations are reported under their baseline identities. + +## Reading a report + +An edit to the text of `auth.lockout` in the [getting-started project](getting-started.md): + +```sh +$ xspec impact --base HEAD +baseline 56b30aaab448c0a1b0207b9c67dd8a0571485501 +changed: + specs/AUTH.mdx#auth.lockout — attributed to: specs/AUTH.mdx#auth.lockout +descendant-changed: + specs/AUTH.mdx, specs/AUTH.mdx#auth — attributed to: specs/AUTH.mdx#auth.lockout +upstream-changed: + specs/OVERVIEW.mdx — attributed to: specs/AUTH.mdx#auth.lockout + specs/OVERVIEW.mdx#overview — attributed to: specs/AUTH.mdx#auth.lockout +``` + +Reading it: the lockout requirement itself changed; its ancestors (`auth` and the file root — collapsed onto one line because they form a chain with identical attribution) contain the change; the `OVERVIEW` document depends on the `auth` section, so it is upstream-affected — all attributed to the one node that was actually edited. When impacted code exists, it follows with the qualifying edge and witness path: + +``` +directly impacted code: + test/auth.test.ts#testInvalidLogin — via references specs/AUTH.mdx#auth.login.invalid; path: specs/AUTH.mdx#auth.login.invalid +``` + +In JSON (`--json`), the same content is structured per node group — note `nodes` is a list because ancestor chains collapse into one entry: + +```json +{ + "baseline": "56b30aa…", + "requirements": [ + { + "nodes": ["specs/AUTH.mdx#auth.lockout"], + "deleted": false, + "categories": [ + { "category": "changed", "attributedTo": ["specs/AUTH.mdx#auth.lockout"] } + ] + } + ], + "code": { + "direct": [ + { + "location": "test/auth.test.ts#testInvalidLogin", + "edge": { "from": "test/auth.test.ts#testInvalidLogin", + "kind": "references", + "to": "specs/AUTH.mdx#auth.login.invalid" }, + "path": ["specs/AUTH.mdx#auth.login.invalid"] + } + ], + "transitive": [] + } +} +``` + +## Baselines and refactoring + +Because baselines replay the journal, **journaled renames and moves are invisible to impact**. After `xspec rename specs/AUTH.mdx auth.lockout auth.throttling`: + +```sh +$ xspec impact --base HEAD +baseline ba39f7296cccc1abc4c63ac33189505bb270857c +``` + +— an empty report. The rename rewrote every reference and recorded the identity mapping; nothing *changed* in the graph's terms. Restructure specs by hand instead and the same operation reports a deletion plus an addition, with every dependent upstream-changed. This is the payoff of [`rename`/`move`](refactoring.md). + +A baseline that cannot be read or reconstructed — unknown ref, sources at the ref that don't validate, a journal whose baseline content is not a prefix of the current one — is a usage error (exit `2`) naming the offending entries or files. + +## Where impact fits + +- **Pre-merge summary**: `xspec impact --base origin/main --json` in CI annotates a PR with exactly which requirements changed and which code is affected. +- **Review scoping**: [`xspec review create --base `](reviews.md) turns the same comparison into a durable, staged checklist with blocking order — impact is the report, review is the workflow. +- **Change auditing**: since attribution always points at originating nodes, an unexpected entry in the report traces to the edit that caused it in one hop. diff --git a/docs/refactoring.md b/docs/refactoring.md new file mode 100644 index 0000000..168fb34 --- /dev/null +++ b/docs/refactoring.md @@ -0,0 +1,101 @@ +# Renaming and moving requirements + +Spec trees need restructuring — IDs outgrow their names, sections belong in other files. The naive way (hand-editing IDs and paths) destroys history: every tool that compares against a baseline sees a deletion plus an addition, dependents light up as changed, resolved review items invalidate. + +`xspec rename` and `xspec move` exist so restructuring is **identity-preserving**: they rewrite every reference across the workspace and record the identity mapping in a journal that every baseline comparison replays. + +## `xspec rename` + +```sh +xspec rename +``` + +Renames a requirement ID within its file. In one atomic operation it: + +- rewrites the section's `id` and every descendant ID by prefix replacement (`auth.lockout` → `auth.throttling` carries `auth.lockout.reset` → `auth.throttling.reset`); +- rewrites **every reference** across all configured sources: `id` attributes, `d` references, `text(...)` targets (MDX and TypeScript), and TypeScript markers — as minimal in-place edits that preserve each reference's quote style and access form; +- appends the mapping to the journal; +- regenerates derived files, exactly as `xspec build` would. + +```sh +$ xspec rename specs/AUTH.mdx auth.lockout auth.throttling +$ echo $? +0 + +$ xspec impact --base HEAD # baseline from before the rename +baseline ba39f7296cccc1abc4c63ac33189505bb270857c +``` + +No output below the baseline line: **a rename produces no changes** against any baseline. That is the identity guarantee — rename is pure, every hash in the workspace is byte-identical afterward. + +One deliberate exception: *type-only* TypeScript references record no edges and are **not rewritten** — a rename can leave them naming vacated identities. That surfaces as an ordinary TypeScript error in your build, never as silent drift. + +## `xspec move` + +```sh +xspec move # file form +xspec move # # # section form +``` + +**File form** relocates an entire source file. IDs are unchanged; identities change only in their file part. The moved file's own imports, and every other file's imports of it, are rewritten so everything keeps resolving. The file form is pure like rename: no hash changes, no impact. + +**Section form** extracts a section subtree: removed from its origin, inserted as the last child of the target parent (or at the end of the target file for a top-level ``), re-identified by prefix replacement. The target file is created if absent. All references are rewritten — converting between local (`"a.b"`) and imported (`MOD.a.b`) forms as needed, adding spec imports where a file now needs one and removing imports left without references. + +```sh +$ xspec move "specs/AUTH.mdx#auth.throttling" "specs/THROTTLING.mdx#throttling" +$ cat specs/THROTTLING.mdx + +Five consecutive failed attempts lock the account for 30 minutes. + +``` + +### How pure is a section move? + +The moved subtree keeps its identity and metadata, so *the moved nodes themselves* typically show no change. The two parents necessarily change — the origin lost a child, the target gained one — and that is exactly what impact reports: + +```sh +$ xspec impact --base HEAD # baseline from before the move +changed: + specs/AUTH.mdx#auth — attributed to: specs/AUTH.mdx#auth + specs/THROTTLING.mdx — attributed to: specs/THROTTLING.mdx +descendant-changed: + specs/AUTH.mdx — attributed to: specs/AUTH.mdx#auth +upstream-changed: + specs/OVERVIEW.mdx#overview — attributed to: specs/AUTH.mdx#auth +``` + +One edge case to know: the moved text travels verbatim, but on the two lines the section's opening and closing tags sit on, Markdown's line-dropping rules consult characters *outside* the moved text. A section whose tag shares a line with other content at the origin can therefore have slightly different own content at the destination — that node is then reported `changed`, with the ordinary cascades. Sections formatted with their tags on their own lines (the usual style) move without any change to the subtree. + +## Validation and refusals + +Both commands refuse (exit `1`) rather than leave a broken workspace, and they check **before modifying anything**: + +- the workspace must currently pass `build` validation — the commands only ever rewrite a valid workspace, which is what makes the finishing regeneration infallible; +- the new ID must be valid, differ from the old, collide with nothing, and keep structural parent rules satisfied; all rewritten references must resolve. + +Move additionally refuses: a move that would create an import or dependency cycle; a file-form destination that already exists; a section-form target parent that is missing or lies inside the moved subtree; a destination path that would not be a valid spec source (outside every configured spec group, also matched by a code group, containing `#`, not UTF-8, or not `.mdx`). The exact self-move (`same-file#same-id`) is refused; a cross-file move keeping the same ID is fine. + +A nonexistent origin file or old ID is a usage error (exit `2`) instead — argument checks precede everything else. + +## The journal + +Journaled operations append one line each to `.xspec/journal`: + +``` +{"from":"specs/AUTH.mdx#auth.lockout","map":[["specs/AUTH.mdx#auth.lockout","specs/AUTH.mdx#auth.throttling"]],"op":"rename","to":"specs/AUTH.mdx#auth.throttling"} +{"from":"specs/AUTH.mdx#auth.throttling","map":[["specs/AUTH.mdx#auth.throttling","specs/THROTTLING.mdx#throttling"]],"op":"move-section","to":"specs/THROTTLING.mdx#throttling"} +``` + +Treat the file as opaque except for its contract: **plain text, one entry per line, append-only, written only by `rename` and `move`**. It is a [durable file](workspace.md#derived-vs-durable): commit it, never edit or delete it, let concurrent branches merge it textually. Every baseline-taking command replays the entries added since the baseline to map old identities to new ones — chains compose, so `rename` → `move` → `rename` still resolves. `xspec check` validates the journal and reports malformed, conflicting, or unreplayable entries; a baseline whose journal is not a prefix of the current one (someone rewrote it) is a hard error naming the offending entries. + +Because identity flows through the journal, an ID that was vacated by a rename and later reintroduced by a brand-new section is a *different* node — references to the two never compare equal, and hashes never collide across the reuse. + +## Manual restructuring + +Editing IDs or moving text by hand is always *valid* — xspec just treats it as a deletion plus an addition, with everything that follows: impact reports both sides, dependents show `upstream-changed`, resolved review items over the old nodes invalidate. Do it when that is what you mean (the requirement genuinely was replaced); use `rename`/`move` when it isn't. + +## Practical workflow + +1. Land content edits and refactoring in the same branch freely — journaled operations keep the two separable in every report. +2. Run restructuring through the commands even for "trivial" one-reference renames; the journal entry is the cheap part, and hand-edits are the ones you end up explaining in review. +3. `rename`/`move` are [mutually exclusive](cli.md#concurrency) with other mutating commands per workspace, and a refused or interrupted operation modifies nothing; `xspec check` reports any inconsistency an interrupted run could leave. diff --git a/docs/reviews.md b/docs/reviews.md new file mode 100644 index 0000000..c99e084 --- /dev/null +++ b/docs/reviews.md @@ -0,0 +1,146 @@ +# Reviews + +A review session turns graph results into a **staged, durable checklist**: each item is one focused judgment ("does this subtree still cohere?", "does this dependent still hold given its target changed?"), items unlock in a deliberate order, resolutions survive restarts and merges, and anything that changes after you resolved it gets flagged — not silently forgotten. + +Sessions are stored as plain JSON at `.xspec/reviews/.json` — [durable files](workspace.md#derived-vs-durable) you commit alongside the specs they review. + +## Creating a session + +```sh +xspec review create --base --name # path-blocks strategy +xspec review create --strategy audit --name # audit strategy +xspec review create --coverage --name # coverage strategy +``` + +Exactly one of `--base` / `--strategy audit` / `--coverage` is required. Creation records the session's parameters *fully resolved* — the commit the ref pointed at, or the profile's definition with group names expanded to their globs — so later renaming a branch, editing a profile, or re-pointing a ref never changes what the session reviews. + +### `path-blocks` — review a change (the default, baseline-based) + +For a change relative to `--base`, the session contains: + +- one **`subtree-coherence`** item per changed subtree root — the node and all its descendants, reviewed as a single block; +- one **`parent-consistency`** item per ancestor of a change — "does this parent's own prose still make sense given what changed beneath it?" — *blocked by* the items for the changed branches beneath it, so parents unlock only after their children are reviewed; +- one **`metadata-consistency`** item per node whose `d`/`coverage`/`tags` changed; +- one **`dependency-consistency`** item per node depending on a target whose effective content changed — "your upstream moved; do you still hold?"; +- one **`code-impact`** item per [impacted code location](impact.md#impacted-code). + +Item order is deepest-first for requirement items, then code items — matching the blocking direction, so `next` naturally walks bottom-up. + +### `audit` — review everything + +One `subtree-coherence` item per requirement node (roots included), no baseline. Each item is blocked by its children's items, so leaves unlock first and every subtree is confirmed bottom-up. Use it for a first adoption pass over an existing spec corpus, or periodic full audits. + +### `coverage` sessions — burn down uncovered requirements + +One **`uncovered-requirement`** item per uncovered required node of the profile at creation time. Use it to turn a [coverage](coverage.md) gap into a tracked work list. + +## The item model + +Every item carries: `id`, `kind`, `scope` (what is under review), `context` (the nodes whose text frames the judgment), `origin` (the originating edits, when applicable), `reason` (a sentence explaining why the item exists), recorded `baseline`/`current` state, a `status`, an optional `note`, and `blockedBy`. + +### Statuses + +| Status | Meaning | +|---|---| +| `unresolved` | Not reviewed yet (every item starts here) | +| `updated` | Reviewed; you changed the sources in response | +| `no-change` | Reviewed; intentionally left as is | +| `skipped` | Intentionally deferred or ignored | +| `invalidated` | Was resolved, but relevant state changed since | + +`updated`, `no-change`, and `skipped` are the *resolved* statuses. An item is **blocked** while any of its `blockedBy` items is unresolved — and since `invalidated` is not resolved, a blocker that gets invalidated re-blocks everything above it until it is re-resolved. + +### Invalidation — resolutions that stop being true get flagged + +Resolving an item records the relevant state for its kind (per-kind relevant hashes plus node presence). Whenever the session is read (`status`, `next`, `show`, `export`), each resolved item is re-checked against the current graph; if a relevant hash changed, a node appeared/disappeared, or the item's context set changed, the item reports as `invalidated` and needs review again. Reads never write the session file — invalidation is computed, not persisted. + +Two properties keep this workable: + +- **Journaled renames/moves never invalidate anything** — recorded nodes compare by canonical identity through the journal, and reads present them under their current names. Refactor freely mid-review with [`rename`/`move`](refactoring.md). +- **Deletion review is resolvable**: a node that was already absent when you resolved doesn't re-invalidate by staying absent. + +### Re-derivation — sessions follow the work + +Resolving an item as `updated` re-runs the session's generators against the current workspace (with the recorded creation parameters): new items appear for newly changed nodes, existing items are matched by kind + scope (keeping their id, status, and history), items that no longer generate remain, and blocking is recomputed. Your checklist tracks the change as it evolves, instead of describing only its first draft. + +### `split` — decompose a big review block + +`xspec review split ` decomposes a `subtree-coherence` item into one item per child subtree plus a `parent-consistency` item for the root's own text (blocked by the children). The decomposition is recorded durably and honored by later re-derivations. Use it when a subtree is too large to judge as one block. + +## A worked session + +The [getting-started project](getting-started.md), after editing `auth.lockout`'s text (15 → 30 minutes): + +```sh +$ xspec review create --base HEAD --name lockout-change +created review session 'lockout-change' (path-blocks): 3 item(s) + +$ xspec review status lockout-change +item-1 subtree-coherence specs/AUTH.mdx#auth.lockout unresolved blocked=false +item-2 dependency-consistency specs/OVERVIEW.mdx#overview unresolved blocked=false +item-3 parent-consistency specs/AUTH.mdx#auth unresolved blocked=true +totals: unresolved=3 updated=0 no-change=0 skipped=0 invalidated=0 +``` + +The parent item is blocked until the changed subtree beneath it is reviewed. `next` serves the first unblocked item needing review, with everything required to judge it — the changed text, its context, and the before/after of the originating edit: + +```sh +$ xspec review next lockout-change +item item-1 + kind: subtree-coherence + ... + reason: the subtree rooted at specs/AUTH.mdx#auth.lockout changed relative + to the baseline; review the node and all its descendants as a + single block (SPEC 10.5) + scope: specs/AUTH.mdx#auth.lockout (present) + text: | + Five consecutive failed attempts lock the account for 30 minutes. + origin: + - specs/AUTH.mdx#auth.lockout + before: present + text: | + Five consecutive failed attempts lock the account for 15 minutes. + after: present + text: | + Five consecutive failed attempts lock the account for 30 minutes. +``` + +Work through the session: + +```sh +$ xspec review resolve lockout-change item-1 --status updated \ + --note "30-minute lockout confirmed with support team" +resolved item 'item-1' of session 'lockout-change' as updated + +$ xspec review resolve lockout-change item-2 --status no-change +$ xspec review resolve lockout-change item-3 --status no-change # unblocked now + +$ xspec review next lockout-change +review session 'lockout-change' is fully resolved: no item needs review + +$ xspec review list +lockout-change path-blocks unresolved=0 updated=1 no-change=2 skipped=0 invalidated=0 +``` + +Resolving a blocked item is refused; so is creating a session under an existing name (compared case-insensitively at create time, so session files stay unambiguous on case-insensitive filesystems). + +## Scripting and agents + +`review next --json` returns a **self-contained payload**: the item plus every scope/context/origin node with identity, presence, source range, and full text (before/after for origins) — enough to act on the item without further reads. When nothing needs review it exits `0` with `"fullyResolved": true` and no item, which makes the driver loop trivial: + +```sh +while item=$(xspec review next my-session --json) && \ + [ "$(jq .fullyResolved <<<"$item")" = "false" ]; do + # judge the item, possibly edit sources… + xspec review resolve my-session "$(jq -r .item.id <<<"$item")" --status no-change +done +``` + +`review show ` prints one item in the same depth; `review export ` emits the entire session — parameters, decompositions, every item with its payload and blocked state, read-time invalidation applied — as one JSON document for reporting or archival. + +## Operational notes + +- `status`, `next`, `show`, `export` are reads; `create`, `resolve`, `split` are mutating and [mutually exclusive](cli.md#concurrency) per workspace. +- Session names: `A–Z a–z 0–9 . _ -`, not starting with `.`. +- A session file that is damaged or hand-edited into inconsistency is reported **corrupt**: every subcommand naming it (and `check`, and `review list`) says so and exits `1` without touching it. Restore it from version control; nothing regenerates it. +- Sessions read the graph at the current sources — if the workspace fails validation, review commands report the findings and exit `1` like every other read. diff --git a/docs/typescript.md b/docs/typescript.md new file mode 100644 index 0000000..ea75a8c --- /dev/null +++ b/docs/typescript.md @@ -0,0 +1,114 @@ +# Using specs from TypeScript + +`xspec build` compiles each spec source `NAME.mdx` into a typed TypeScript module next to it. Code imports that module to reference requirements; every reference is type-checked, navigable, and recorded as an edge in the project graph. + +## Importing a spec module + +```ts +import AUTH, { text } from "../specs/AUTH.xspec" +``` + +- The specifier is the source path with `.mdx` replaced by `.xspec` — a relative path ending in `.xspec`, resolved like any relative import. +- The permitted bindings are exactly the **default export** (the file's root node) and the named **`text`** export, each optionally aliased. Importing anything else from a spec module is a build error. +- Type-only imports (`import type AUTH from …`, or a `type` modifier on a binding) are allowed; a type-only binding is a type-level name that records no edges (see below). + +Everything else that smells like module linking with a `.xspec` specifier is invalid by design: dynamic `import("./A.xspec")`, every `export … from "./A.xspec"` re-export form, and `import X = require("./A.xspec")`. Spec nodes never travel through re-exports — each file that uses a requirement imports the module itself, which is what keeps edge attribution honest. Importing a generated file by its underlying name (`./A.xspec.ts`, `./A.xspec.impl.js`, anything under `.xspec/`, or an emitted Markdown path) is likewise invalid: derived files are consumed only through the `.xspec` specifier. + +## Nodes are opaque, typed tokens + +The default export is the root node; child sections hang off it as readonly properties named by ID segment: + +```ts +AUTH.auth.login.valid // the node for specs/AUTH.mdx#auth.login.valid +AUTH["auth"]["login"]["valid"] // same node; bracket form for non-identifier segments +``` + +- A missing or misspelled path is a **TypeScript type error** against the generated module. +- Nodes carry no requirement text as values. The only supported operations are child property access and passing the node to `text()`. +- Every node has a documentation comment holding its own text (truncated to 1000 code points), so editors show the requirement on hover; go-to-definition on a reference lands in the source `.mdx` at the `` section (file start for the root). + +## Two ways to reference a requirement + +### Dependency markers — "this code implements that" + +A bare requirement reference in expression-statement position is a **marker**: + +```ts +export function login(email: string, password: string): boolean { + AUTH.auth.login.valid // ← marker: records a `references` edge + return email.includes("@") && password.length > 0 +} +``` + +At runtime a marker is a harmless property read — no tooling, no side effects. In the graph it records a `references` edge from the enclosing code location to the node. Markers are how code participates in [coverage](coverage.md) and [impact analysis](impact.md). + +A marker to a *root* node (`AUTH` alone on a line) records an edge, but roots never participate in coverage paths — its practical effect is to make the code location impacted by any change in the whole document or upstream of it. + +### `text(node)` — "give me the requirement's text" + +```ts +const requirement = text(AUTH.auth.login.valid) +``` + +`text(node)` returns the node's subtree text as a `string` and records an `embeds` edge from the calling code location. This is the **only** way requirement text is reachable at runtime — a consumer that never imports `text` gets no text from the module, only opaque tokens. + +The string form (`text("auth.login")`) is MDX-only; in TypeScript it is a build error. + +## The rules markers live by + +References must be statically analyzable, and the sanctioned value-level uses of spec bindings are exact: + +- A node expression (the default binding, or a chain of child accesses from it) may appear only **as a marker** or **as the sole argument to its own module's `text`**. +- A `text` binding may appear only as the callee of such a call. +- Chains use only dot access or string-literal bracket access. Optional chaining, parentheses, non-null assertions, or any computed index make the reference non-static — a build error. +- Everything else — aliasing a node into a variable, destructuring, storing nodes in data structures, passing them to other functions, re-exporting them — is a build error ("unsupported node usage"). This is what guarantees the graph is complete: every reference is visible in the source, rooted at an import. + +Scoping is respected: an identifier that resolves to a local declaration shadowing the import is not a spec reference, and a type-only binding used at the value level is your TypeScript error, not an xspec edge. Purely type-level references (`typeof AUTH.auth.login` and friends) are unrestricted and record nothing — with the corollary that `xspec rename`/`move` do not rewrite them. + +### Module branding + +Node types are branded per module: passing a node from one spec module to another module's `text` is a TypeScript type error *and* a runtime throw naming both modules. When one file consumes several spec modules, alias the `text` imports: + +```ts +import AUTH, { text as authText } from "../specs/AUTH.xspec" +import BILLING, { text as billingText } from "../specs/BILLING.xspec" +``` + +## Code locations: how references are attributed + +Graph edges from code are attributed to a **code location**: either the whole file (`src/auth.ts`) or a named unit within it (`src/auth.ts#LoginService.validate`). + +Named units are constructs that statically bind a plain identifier to executable code: function and class declarations, class members with identifier names (methods, getters/setters, function-valued properties), `const`/`let`/`var` bindings whose initializer is a function or class expression, namespaces (`namespace A.B` yields `A.B`), and default exports (`#default` when anonymous). A reference is attributed to the innermost enclosing named unit, or to the file when none encloses it. When the same unit chain occurs more than once in a file (getter/setter pairs, same names in sibling scopes), later occurrences are disambiguated as `path#unit@2`, `@3`, … in document order. + +This identity is what you see in `coverage` paths, `impact` reports, `query` results, and review items. + +## Compiler and runtime setup + +Generated modules work under standard TypeScript tooling with **no xspec runtime dependency**. `NAME.xspec.ts` re-exports from its companion `NAME.xspec.impl.js` (plain JavaScript, with `NAME.xspec.impl.d.ts` carrying the types and declaration maps pointing editors back into the `.mdx`). + +A verified minimal setup — CommonJS-mode project, compiled in place: + +```jsonc +// tsconfig.json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true + }, + "include": ["src", "test", "specs/*.xspec.ts"] +} +``` + +```sh +tsc -p tsconfig.json # typechecks references, emits JS beside sources +node src/print.js # runs under plain Node — no loader, no dependency +``` + +Notes from the trenches: + +- **Include the generated modules in your program** (the `specs/*.xspec.ts` entry above) so they emit JavaScript alongside their `.impl.js` companions. +- **Compile in place rather than through `outDir`.** With `outDir`, tsc copies `NAME.xspec.js` into the output tree but not its `.impl.js` companion, and the runtime import breaks. If you need an output tree, bundle instead. +- **Extensionless specifiers and ESM.** `./NAME.xspec` resolves in CommonJS-mode files (the setup above) and under `moduleResolution: "bundler"`. In a `"type": "module"` package, Node's ESM rules make extensionless relative specifiers unresolvable — consume spec modules from CJS-mode files or through a bundler in that case. +- The marker statement (`AUTH.auth.login.valid`) is intentionally expression-only. If your lint setup flags unused expressions, allow it for spec references — that is the feature. diff --git a/docs/workspace.md b/docs/workspace.md new file mode 100644 index 0000000..82a56a1 --- /dev/null +++ b/docs/workspace.md @@ -0,0 +1,69 @@ +# Workspace files + +Everything xspec writes is a **plain file with deterministic bytes** — stable ordering, sorted keys, no timestamps, no absolute paths — deliberately suitable for committing and diffing. This page inventories those files, explains the derived/durable distinction, and gives version-control guidance. + +## Inventory + +For a source file `specs/AUTH.mdx` and default configuration: + +| Path | What | Class | +|---|---|---| +| `specs/AUTH.xspec.ts` | Generated TypeScript module (the `./AUTH.xspec` import target) | Derived | +| `specs/AUTH.xspec.impl.js` | Companion: runtime implementation | Derived | +| `specs/AUTH.xspec.impl.d.ts` | Companion: types + hover docs | Derived | +| `specs/AUTH.xspec.impl.d.ts.map` | Companion: declaration map (go-to-definition into the `.mdx`) | Derived | +| `specs/AUTH.md` | Pure-Markdown emission (only with `markdown.emit`; placed per `outDir`) | Derived | +| `.xspec/graph.json` | Graph data serving `check`/`ids`/`show`/`coverage`/`impact`/`review`/`query` | Derived | +| `.xspec/journal` | Identity journal written by `rename`/`move` | **Durable** | +| `.xspec/reviews/.json` | One review session each | **Durable** | + +Companion sets are an implementation detail and may evolve; the stable rule is that every companion sits beside the module and carries `.xspec.` in its name. Graph data's format is likewise opaque — its contract is its location, classification, and freshness behavior. + +## Derived vs. durable + +**Derived files** are fully reproducible from sources + configuration + journal via `xspec build`. Anything wrong with one — merge conflict, corruption, deletion, tampering — is correctly resolved by rebuilding. `build` also removes derived files it recorded earlier that current sources no longer generate, so renames don't strand orphans. + +**Durable files** (the journal, review sessions) record operations and resolutions. They are **not reproducible, never regenerated, and must not be modified except by their owning commands**. Both are line-oriented or stably keyed so concurrent branches merge textually; `xspec check` validates their integrity and reports unresolvable states. + +Derived-file *paths belong to xspec*: writing a derived file replaces whatever sits at that path, whether or not xspec wrote it. Don't park anything at a path matching a derived name. + +Three path classes are **never discovered as sources**, no matter what your globs say: paths whose file name contains `.xspec.`, anything under `.xspec/`, and the configured Markdown emit destinations. A broad `specs/**/*.mdx` therefore never accidentally ingests emitted output. + +## What to commit + +Commit **everything xspec writes**, alongside your sources: + +- **The journal and review sessions: non-negotiable.** They cannot be regenerated; losing the journal breaks identity mapping for every baseline that crosses a rename or move. If you commit nothing else, commit `.xspec/journal`. +- **Generated modules and companions: commit them.** Consumers' builds and editors resolve `./NAME.xspec` without running xspec first; diffs of generated files are deterministic and reviewable; `xspec check` in CI proves they match the sources (staleness is a finding), so they cannot silently drift. +- **Emitted Markdown and `.xspec/graph.json`: commit for the same reasons**, or — if repository size argues otherwise — ignore them and run `xspec build` in CI before `check`. Choose per file class, not per file; a half-committed derived set is the confusing middle ground. + +A `.gitignore` for the commit-everything policy needs no xspec entries at all. For the regenerate-in-CI policy: + +```gitignore +# derived (rebuilt by `xspec build`) — keep .xspec/journal and .xspec/reviews! +*.xspec.* +specs/**/*.md +.xspec/graph.json +``` + +Never ignore `.xspec/` wholesale — that would drop the journal and review sessions. + +## Freshness, staleness, and repair + +- `xspec build` regenerates every derived file; a failed build changes nothing. +- Read commands (`ids`, `show`, `coverage`, `impact`, `review`, `query`) silently refresh graph data when it doesn't match the current sources — you never get stale answers, and they never touch generated TS/Markdown. +- `xspec check` never refreshes; a derived file that doesn't match the sources is a **staleness finding**: + +``` +specs/AUTH.xspec.ts: stale generated output (14.10): … run `xspec build` to +regenerate every derived file (SPEC 14.10) +``` + + which is also your tamper detector: manual edits to generated files, orphaned outputs after config changes, and merge damage all surface here, with rebuild as the universal fix. + +## Filesystem behavior worth knowing + +- **Writes are atomic in effect**: concurrent readers and interrupted commands see the old content or the new, never a torn file. +- **Mutating commands** (`rename`, `move`, `review create/resolve/split`) are mutually exclusive per workspace; a second one fails fast (exit `2`) touching nothing. Different workspaces never interfere. +- **Symbolic links are never followed**: not in discovery (a symlink is never a source), not in writes (a symlink in a write path's directories is a refusal; a symlink *at* a derived file's own path is simply replaced as a plain file). Journal or session paths occupied by non-plain-files are errors, never written through. +- xspec performs no network access, reads git only where documented (`impact --base`, `review create --base`, baseline reconstruction), and never writes to git. diff --git a/docs/writing-specs.md b/docs/writing-specs.md new file mode 100644 index 0000000..9bd64f7 --- /dev/null +++ b/docs/writing-specs.md @@ -0,0 +1,189 @@ +# Writing specs + +xspec source files are MDX documents (`.mdx`) in which requirement sections are marked with `` tags. Everything else in the file is ordinary Markdown. This page covers the complete authoring syntax; validation of every rule here is enforced by `xspec build` / `xspec check` with errors that name the file, location, and fix. + +Source files must be valid UTF-8 without a byte-order mark, and must have the `.mdx` extension. Which files are spec sources at all is decided only by the globs in [`xspec.config.ts`](configuration.md) — imports never pull extra files into the workspace. + +## Sections + +A requirement section wraps part of the document in `` … ``: + +```mdx + +The product supports login. + +``` + +- `` is an exact synonym of ``; the short form is preferred. +- A self-closing section `` is valid: an empty leaf — no text, no children. Useful as a placeholder to reserve an ID. +- Sections nest to any depth, and nesting is meaningful — it defines the requirement tree. + +### The implicit root + +Every file also has an implicit **root node** representing the whole document. It has no `id` and is identified by the file path alone (e.g. `specs/AUTH.mdx`). The root is what the generated module default-exports, and it is never a coverage target. + +## Requirement IDs + +Every non-root section must have an `id`, and IDs are **structural paths**: a child's ID is exactly its parent's ID plus `.` plus one new segment. + +```mdx + +Login behavior. + + +A user with valid credentials can log in. + + +``` + +All of the following are invalid, and `build` says so: + +- `` nested inside `login` (child must be `login.validCredentials`) +- `` nested inside some other section +- a top-level `` when no `auth` section encloses it (IDs cannot skip levels) +- two sections with the same ID in one file + +The full identity of a requirement is `path#id` — e.g. `specs/AUTH.mdx#login.validCredentials`. That is the form every CLI command accepts and prints. Paths are always workspace-relative with `/` separators, on every platform. + +### Segment rules + +Each dot-separated segment must be non-empty and must not contain `.`, `#`, whitespace, or control characters, and must not be one of the reserved words `$`, `__proto__`, `prototype`, `constructor`, `then`. + +camelCase segments that are valid TypeScript identifiers are recommended — they read as `SPEC.login.validCredentials` in code. Nothing else is enforced; a segment like `login-v2` is legal and is accessed with bracket notation (`SPEC["login-v2"]`) in TypeScript and in references. + +## Declaring dependencies: the `d` prop + +`d` declares that one requirement depends on another. It takes a single reference or an array of references: + +```mdx +import BASE from "./BASE.xspec" + + +Derived behavior. + +``` + +- **External form**: a property chain rooted at an imported spec module (`BASE.auth.login`). The module itself (`d={BASE}`) targets that file's root node. +- **Local form**: a string literal naming an ID in the same file (`"local.requirement"`). +- The two forms mix freely in one array; duplicates collapse to one edge; `d={[]}` is the same as omitting the prop. + +`d` records a `depends` edge in the graph. It does not render into Markdown output and it does not prove anything by itself — it is the raw material for [coverage](coverage.md), [policy](configuration.md#policy--dependency-policy-rules), [impact](impact.md), and [reviews](reviews.md). + +A section must not depend on (or embed) itself or its own ancestor — that is a dependency cycle, and cycles of any length are build errors reported with the full cycle path. + +## Embedding requirement text: `{text(...)}` + +`{text(...)}` splices the target's full text into this document's compiled Markdown output and records an `embeds` edge: + +```mdx + +As specified: + +{text(BASE.auth.login)} +{text("local.requirement")} + +``` + +The argument follows the same external/local duality as `d`. The expansion is the target's *subtree* text — the target section and everything nested in it, fully expanded. + +Embedding is a dependency like any other, with one behavior worth internalizing: editing the embedded target's text changes the *target's* hashes, not the embedder's own hash — the embedder sees it as an upstream change, while its Markdown output still re-expands to the new text on the next build. + +## The static-argument rule + +Every reference — `d` entries, `text(...)` arguments — must be *static*: + +- a plain single- or double-quoted string literal (template literals don't count), or +- a property chain rooted at an imported spec module, using only dot access (`.login`) or string-literal computed access (`["login-v2"]`). + +Optional chaining, non-null assertions, parentheses, variables, or any computed expression make the reference dynamic — a build error. `text(...)` takes exactly one argument. xspec resolves everything statically; there is no runtime resolution to fall back on. + +## Imports + +The **only** imports permitted in a spec file are other spec modules, in exactly this form: + +```mdx +import BASE from "./BASE.xspec" +``` + +- The specifier must be relative (`./` or `../`) and end in `.xspec`; `DIR/NAME.xspec` designates the source file `DIR/NAME.mdx`, which must itself be a discovered spec source. +- Only a single default binding is allowed — no named, namespace, or side-effect imports. +- No import may bind `S`, `Spec`, or `text` (those names belong to the compiler), and no two imports may bind the same identifier. +- Import cycles between spec files are invalid, even without a requirement-level cycle. A file importing itself counts. +- An import whose binding is never used is valid and records nothing. + +## Tags + +```mdx + +Repeated failed logins lock the account. + +``` + +`tags` is a whitespace-separated list. Duplicates collapse; an empty value is the same as no prop. A tag follows the same character rules as an ID segment, except that tags may contain `.`. Tags are recorded in the graph and usable in coverage target filters and policy selectors; they do **not** render into Markdown and are **not** inherited by child sections. + +## Excluding a node from coverage + +```mdx + +Authored by the project owner. + +``` + +The only values are `required` (the default) and `none`. `coverage="none"` removes the node from coverage *targets* only: it can still be depended on, still appears in impact reports, and its descendants keep their own coverage settings. + +## What else may appear in a file + +Beyond ordinary Markdown content, exactly four constructs are permitted: spec imports, ``/`` sections, `{text(...)}` embeddings, and MDX comments `{/* … */}`. Any other JSX element, any other `{expression}`, and any `export` statement is a build error. Comments are pure annotations — they never enter requirement text or hashes, and Markdown output drops them. + +Prop syntax is strict: + +- The defined props are `id`, `d`, `coverage`, `tags`. Unknown props are errors; no prop may repeat; spread attributes (`{...props}`) are errors. +- `id`, `coverage`, `tags` must be plain quoted strings: `id="login"`, never `id={"login"}`. +- `d` must be a braced expression: `d={BASE.auth.login}` or `d={[…]}`, never a quoted string. + +## Markdown compilation + +With [`markdown.emit`](configuration.md#markdown--pure-markdown-emission) enabled, each `NAME.mdx` compiles to a pure-Markdown `NAME.md`: + +- spec imports, ``/`` tags (with their props), and MDX comments are removed; +- each `{text(...)}` is replaced by the target's fully expanded subtree text; +- everything else — content and author whitespace — is preserved byte-for-byte. + +Removal is exact textual deletion. A line that had non-whitespace content in the source but is left empty *purely by removals* is dropped entirely; every other line keeps whatever remains. So a tag on its own line vanishes without leaving a blank line, but tags are otherwise transparent — xspec does not insert spacing for you. In-line tags keep reading in-line: + +```mdx +Example:1. A +``` + +strips to `Example:1. A` — if you want a break, write one. + +### Own text vs. subtree text + +Two text values of a node show up throughout xspec (`show`, `query`, review payloads): + +- **subtree text** — the node's full contribution to the compiled Markdown: its own content with every descendant interleaved in document order. +- **own text** — the same with every child's contribution excised; just the node's directly-owned prose. + +Both are exact bytes with `text(...)` embeddings fully expanded. Hashing works on a related but distinct value (own *content*, where embeddings count as references rather than expanded text) — the practical consequences are described in [Impact analysis](impact.md#hashes). + +## A complete example + +```mdx +import BASE from "./BASE.xspec" + +{/* Editorial note: keep the summary in sync with marketing copy. */} + + +The product summary, for context. + +{text(BASE.auth.login)} + + + +Five consecutive failed attempts lock the account for 15 minutes. + + + +``` + +This file declares two top-level requirements; `overview` depends on `BASE.auth` and embeds the login requirement's text; `lockout.reset` is a reserved empty leaf awaiting content.