diff --git a/CHANGELOG.md b/CHANGELOG.md index adb86200..2be8a418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,13 +26,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - Add `rune proof run --check` (transcript, sections, and head ancestry) and `--instruction ` (record a model's answer through `rune run`). - Feed a step's standard input from `< text` lines in its fence, recorded as command lines, so a scene carries its own script or brief. - Mark every scene in `proof.cast` with a rule, a `# Scenario:` title with its index, and an ok or unproven verdict line, so a cast player lists the scenes and an expected refusal reads as one. -- Add `rune spec glossary`, which prints every ontology term as `- **label**: comment [TAG]` with the `[TAG]: ` definitions a document copies, and `--json`. -- Add `rune spec glossary --write [PATH]`, which writes the glossary file (header, term list, definitions) from the ontology into `glossary.md` beside the specifications or the given path. -- Add `rune spec glossary --check [PATH]`, which compares the file with the ontology, names each term whose line is missing, extra, or changed, and exits 1 on drift or a missing file. -- Read defined terms from `ontology/rune.ttl` (or the `ontology` path in `deck.yaml`) in `rune spec validate` and `rune spec doctor`, falling back to `glossary.md` only when no ontology file exists. +- Add `rune spec glossary`, which prints every ontology term as `- **label**: comment [TAG]` with a `[TAG]: ` definition per term, and `--json`. +- Add `rune spec glossary --write [PATH]`, which writes one glossary page per term and an `index.md` under `glossary/` beside the specifications, or under the given directory. +- Give each glossary page `title`, `iri`, `kind`, and `status` front matter, the term's comment, and a generated-from line. +- Remove, on `--write`, the pages of terms the ontology no longer defines and the single `glossary.md` an older render wrote. +- Add `rune spec glossary --check [PATH]`: regenerates every page in memory and reports each page whose bytes differ. +- Read defined terms from `ontology/rune.ttl`, or from the `ontology` path in `deck.yaml`, in `rune spec validate` and `rune spec doctor`. A repository without an ontology has no terms. +- Accept `rune:status "draft"` on a term. - Fail validation on an unreadable ontology, an undeclared prefix, two terms of one kind whose labels match without case, or an ontology with no labeled term. -- Report `term-reference-missing` when the first italic use of an ontology term carries no `[TAG]`, and `term-reference-unresolved` when the tag is undefined or names another IRI. -- Apply the term reference rules in `rune validate` to `docs/decisions` and `runes` of a deck, and to the record and rune directories of a module, when the root has an ontology. +- Mark a term as a link to its glossary page: `*[text][TAG]*` at first use, `[text][TAG]` later, and `[TAG]: ` at the end of the file. +- Resolve a term link through its page's `iri`, or through the tag when the page is absent. Plain italics are emphasis and are not checked. +- Report `spec-term-undefined` when a link names no term, including a reference tag with no definition in the file, and `spec-term-draft` (warning) when the term is a draft. +- Report `spec-term-first-use` when the first use of a term in a file is not emphasized, or a later use is. +- Report `spec-term-legacy` (warning) on the old `*term* [TAG]` form or an IRI definition, naming the new form. A delta gets `delta-` twins of every term code. +- Apply the term link rules in `rune validate` to `docs/decisions` and `runes` of a deck, and to the record and rune directories of a module, when the root has an ontology. +- Add `rune spec fix --terms`: adds a draft ontology entry for every term link that names no term and writes its page. +- Add `rune spec fix --links`: rewrites the legacy italics form to the link form across the repository, idempotently. +- Run both fixer passes when `rune spec fix` gets no flag. Both are idempotent. +- Skip, in the fixer's walk, version control, build, dependency, and fixture trees, a deployed tree (a directory with a `.manifest`), the change archive, and the glossary. +- Refuse `rune spec archive` on a change whose files link to a draft term or to no term, naming each link. - Refuse `rune sign queue` on a head that `origin` already holds or sits above, because signing it in place would rewrite a pushed tip. - Report jj's immutable-commit refusal in `rune sign next` without a retry, print the pinentry focus hint on every timeout, and print the `jj git push` line after a signed head. - Add `rune launch --check` and `rune run --check`, which ask each base URL in the plan for its model list and report every model the plan sends as served or missing, without spawning. @@ -140,6 +152,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Changed +- Share one path-confinement helper and one atomic-replace writer across adopt, deploy, doctor, the review record, and the bench writers. - Sort two glossary terms that share a label by kind, the class first, then by name, so `rune spec glossary` keeps one order whatever the order of the ontology file. - Run the Vale hook with `--no-global`, so the guarded gate reads the repository's style alone and a rule in the machine's global config cannot fail or mask it. - Rename `rune run --clean-harness-state` to `--clean`, and the JSON field to `clean`. The old spelling and the `_scope` field are gone. @@ -152,7 +165,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - Sync the ceremony files to skeleton `6374d8fc` through `copier update`: seven dcg packs with fixtures, `.dcg.toml`, the lane table, the direct-push signature check, admin-role rulesets. - Change the correctness caller to skeleton `5e4657bf`: the controller runs on every same-repository push, draft or ready, so the green draft starts the review round. - Change the ceremony files and the embedded skeleton to `60ad8249`: owner-seal messages and nonce, jq guards, versioned display names, `thread-resolver.yaml` retired. -- Change `rune sign` to read `KEYS` as `signer
...` lines (skeleton `trusted-key-anchor`), with the armored block as a fallback and a malformed line refused. +- Change `rune sign` to read `KEYS` as `signer
...` lines (skeleton `sign-with-trusted-keys`), with the armored block as a fallback and a malformed line refused. - Change `rune sign open` to accept a head the owner signed already, because the open-seal is a new commit above it, and to refuse only a signature from a key outside `KEYS`. - Change `rune sign open` to read the body and `schemas/PULL_REQUEST.mdschema` from the bookmark's own tree, so `--repo` may point at a workspace on another branch. - Change `rune sign adopt` to judge the outside body against the protected branch's schema, never the outside tree's. diff --git a/KEYS b/KEYS index 7c46cb7b..3705c891 100644 --- a/KEYS +++ b/KEYS @@ -1,4 +1,4 @@ -# Trusted signers for this repository (docs/specs/trusted-key-anchor). +# Trusted signers for this repository (docs/specs/sign-with-trusted-keys). # One line per signer: the pinned fingerprint, then the addresses whose Web # Key Directory (or GitHub key list) supplies the bytes, tried in order. # scripts/trusted-keys resolves this file; nothing is trusted but the pin. diff --git a/authors.yaml b/authors.yaml index 2a28b312..ee692959 100644 --- a/authors.yaml +++ b/authors.yaml @@ -1,6 +1,6 @@ # Commit attribution policy. Human identities and vendor aliases stay explicit. # Model identities use matching IDs and an approved harness domain. -# See docs/specs/model-commit-attribution/spec.md for the contract. +# See docs/specs/model-identity-on-commits/spec.md for the contract. authors: - Martin Zeman - Claude (claude) diff --git a/defaults.yaml b/defaults.yaml index be7ef08e..be437dea 100644 --- a/defaults.yaml +++ b/defaults.yaml @@ -132,3 +132,7 @@ providers: strong: [claude-opus-4-6] fast: [claude-sonnet-4-6] light: [claude-sonnet-4-6] + +# A change id or a capability name says what it guarantees, in at least four words. +spec: + min_name_words: 4 diff --git a/docs/README.md b/docs/README.md index 4d478974..79d36138 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ - [Crew Parity](Crew%20Parity.md): capability matrix against crew, pinned with non-goals - [Exit Codes](Exit%20Codes.md): the exit-code and locking contract for scripts and CI - [Command Map](Command%20Map.md): how install/assemble/deploy/copy, the health ladder, and import/adopt relate -- [Skill authoring](changes/interactive-rune-shell/design.md): the Stable shell heading convention, which checker enforces which part of it, and where the rule lives +- [Skill authoring](changes/stable-shell-for-skills/design.md): the Stable shell heading convention, which checker enforces which part of it, and where the rule lives - [Schemas](Schemas.md): every schema-shaped file, what it constrains, who loads it, and why the skills schema has three copies ## Walkthroughs diff --git a/docs/Schemas.md b/docs/Schemas.md index a4581991..8a548993 100644 --- a/docs/Schemas.md +++ b/docs/Schemas.md @@ -89,5 +89,5 @@ otherwise. The two are not equivalent: the built-in checker implements required section presence, heading level continuity, and maximum depth, and skips optional sections before reading them. Section order, unexpected sections, permitted subsection placement, and heading uniqueness exist only in the -standalone checker. See [Skill authoring](changes/interactive-rune-shell/design.md) for the +standalone checker. See [Skill authoring](changes/stable-shell-for-skills/design.md) for the requirement-by-requirement split. diff --git a/docs/changes/adopt-block-review/specs/adopt-block-review/spec.md b/docs/changes/adopt-block-review/specs/adopt-block-review/spec.md deleted file mode 100644 index 0ff99231..00000000 --- a/docs/changes/adopt-block-review/specs/adopt-block-review/spec.md +++ /dev/null @@ -1,102 +0,0 @@ -## ADDED Requirements - -### Requirement: Adoption opens a review session - -`rune adopt start ` MUST perform the import mechanics (pinned fetch or local copy, adopt/v1 provenance sidecars), segment every adopted markdown file into review blocks, and write an external temporary session in which every block's verdict is `pending`. The adopt sidecar MUST record the pending-review state. - -#### Scenario: Start from a commit-pinned URL - -- **WHEN** `rune adopt start https://github.com///blob//skills/x/SKILL.md --module runes/meta` runs -- **THEN** the artifact lands under the module with adopt/v1 sidecars, an external temporary session exists with every block `pending`, and the sidecar carries `review: pending` - -#### Scenario: Start refuses a second session for the same artifact - -- **WHEN** `rune adopt start` targets an artifact that already has a temporary session with pending verdicts -- **THEN** the command fails and names the in-flight session - -### Requirement: Segmentation is deterministic - -Segmentation MUST be line-based and reproducible: identical input yields identical blocks and ids. Frontmatter is one block. A fenced code block is atomic. Paragraphs split at blank lines. Consecutive list items group into one block. Consecutive table lines group. Headings are their own blocks. Each block MUST carry an ordinal id per file and a SHA-256 digest of its content. - -#### Scenario: Fences are never split - -- **WHEN** a fenced code block contains blank lines -- **THEN** it is a single block - -#### Scenario: Re-segmenting is stable - -- **WHEN** the same file is segmented twice -- **THEN** blocks, ids, and digests are identical - -### Requirement: Verdicts are recorded one block at a time - -`rune adopt verdict ` MUST record the verdict in the temporary session. `adapt` and `cut` MUST require `--note`. An unknown block id or an already-decided block MUST be an error unless `--force` re-decides it. - -#### Scenario: Cut without a note is rejected - -- **WHEN** `rune adopt verdict SKILL.md:4 cut` runs without `--note` -- **THEN** the command fails asking for the rationale - -### Requirement: Progress is inspectable and injectable - -`rune adopt status` MUST list in-flight sessions with per-file pending/decided counts, and `rune adopt next` MUST emit the next pending blocks (id, kind, content). Both MUST support `--json` for machine consumption and dynamic context injection. - -#### Scenario: Status with one session - -- **WHEN** one session is in flight and `rune adopt status --json` runs -- **THEN** the output identifies the artifact, total blocks, decided count, and next pending id - -### Requirement: Finalize enforces the review - -`rune adopt finalize` MUST fail while any verdict is pending. It MUST verify verdict consistency against the edited files: a `cut` block's content no longer appears, a `keep` block's content still appears, an `adapt` block's content differs (whitespace-normalized comparisons). It MUST run `mdschema check` with the kind's schema and fail on schema violations or when `mdschema` is absent. On success it MUST re-sync the adopt sidecar's subject digest to the reviewed content, flip the sidecar to `review: reviewed`, add concise final review metadata, and delete the temporary session after all sidecars are safe. - -#### Scenario: Pending block blocks finalize - -- **WHEN** any block is `pending` and `rune adopt finalize` runs -- **THEN** the command fails listing the pending ids - -#### Scenario: Kept content was deleted - -- **WHEN** a block with verdict `keep` no longer appears in the edited file -- **THEN** finalize fails naming the block - -#### Scenario: Schema violation blocks finalize - -- **WHEN** the reviewed artifact violates the kind's `.mdschema` -- **THEN** finalize fails with the mdschema report - -### Requirement: Final review authority is the adopt sidecar - -The completed review MUST leave no block ledger in the artifact tree. Adopt/v1 sidecars MUST carry final subject digests, `review: reviewed`, reviewer identity, completion time, and a concise adaptation summary. Temporary block entries MUST be deleted only after all sidecars are safely updated. - -#### Scenario: Finalize removes workflow state - -- **WHEN** a fully decided review finalizes successfully -- **THEN** reviewed sidecars contain final metadata, the external session is gone, and no `review.yaml` or `*.review.yaml` exists in the artifact tree - -### Requirement: Agents and rules are adoptable - -`rune import` (and therefore `rune adopt start`) MUST accept `--kind agent` and `--kind rule`, placing single-file artifacts at `agents/.md` and `rules/.md` with the same sidecar treatment as skills. - -#### Scenario: Rule adoption placement - -- **WHEN** `rune adopt start --kind rule --name no-tabs` runs against a module -- **THEN** the file lands at `rules/no-tabs.md` with an adopt sidecar and a review session opens - -### Requirement: Names follow the source schema - -Adopted artifact names MUST match `^[A-Za-z0-9]+([-_]?[A-Za-z0-9]+)*$`, be at most 64 characters, and equal the containing directory name for skills. - -#### Scenario: Explicit deck casing - -- **WHEN** an artifact is adopted with `--name AdoptArtifact`, `--name adopt-artifact`, or `--name adopt_artifact` -- **THEN** Rune preserves that valid source name - -### Requirement: Abandon closes a session safely - -`rune adopt abandon` MUST close an in-flight session by moving the imported artifact, its sidecars, and temporary session to the trash, never deleting the artifact in place. - -#### Scenario: Abandon mid-review - -- **WHEN** a session has recorded verdicts and `rune adopt abandon` runs with confirmation -- **THEN** the artifact directory and temporary session are trashed and `rune adopt status` reports no session diff --git a/docs/changes/adopt-review-hardening/specs/adopt-review-hardening/spec.md b/docs/changes/adopt-review-hardening/specs/adopt-review-hardening/spec.md deleted file mode 100644 index b441e6cf..00000000 --- a/docs/changes/adopt-review-hardening/specs/adopt-review-hardening/spec.md +++ /dev/null @@ -1,66 +0,0 @@ -## ADDED Requirements - -### Requirement: Unreviewed adoptions never deploy - -Assembly MUST exclude any source artifact whose adopt sidecar does not record `review: reviewed`: pending, missing, and unparsable review states all fail closed, each exclusion reported by name. A skill's `SKILL.md` review state MUST govern every file in its tree. Content without an adopt sidecar (first-party) deploys normally, and the install MUST otherwise complete. `rune release` and `rune copy` MUST refuse outright while any adoption review is open. - -#### Scenario: Pending artifact is skipped - -- **WHEN** `rune install` runs over a module containing an artifact mid-review -- **THEN** the artifact is absent from the build and the output names it with a pointer to `rune adopt status`, while other artifacts deploy normally - -#### Scenario: Finalized artifact deploys - -- **WHEN** the same artifact's review is finalized and `rune install` re-runs -- **THEN** it deploys - -#### Scenario: Strict mode fails closed - -- **WHEN** `rune install --strict` runs with any pending-review artifact in the source -- **THEN** the install fails naming the pending artifacts and deploys nothing - -### Requirement: Verdicts carry decision timestamps - -`rune adopt verdict` MUST record `decidedOn` (UTC RFC 3339) on the block entry at write time, and the temporary session MUST preserve the timeline until finalize. - -#### Scenario: Timeline in the pending session - -- **WHEN** finalize seals a session -- **THEN** every block entry carries the timestamp of its verdict - -### Requirement: Suspect blocks are flagged at segmentation - -Segmentation MUST attach `flags` to blocks matching injection heuristics: instruction-override phrasing, tool-invocation shapes, dynamic-injection (`!`-command) lines, base64 or high-entropy runs, hidden-unicode controls, and URLs outside the upstream host in executable contexts. Flags MUST ride through `rune adopt next --json` and into the record. Flags MUST never block a finalize, but a `keep` verdict on a flagged block MUST require a rationale note. - -#### Scenario: Override phrasing is flagged - -- **WHEN** a block contains "ignore previous instructions" -- **THEN** its entry carries an `instruction-override` flag visible in `next --json` and in the pending session - -### Requirement: Doctor verifies sessions and reviewed sidecars - -`rune adopt doctor` MUST report pending external sessions, verify reviewed adopt/v1 sidecar subject digests against files, and diagnose legacy `review.yaml` / `*.review.yaml` ledgers with an actionable inspect-and-remove-or-archive message. It MUST NOT require a committed review ledger for reviewed artifacts. It MUST exit non-zero on integrity errors and zero on warnings alone. - -#### Scenario: Reviewed artifact drifts - -- **WHEN** a reviewed artifact's file is edited after finalize without reseal -- **THEN** doctor reports the sidecar-to-file digest disagreement as an error naming the file - -#### Scenario: Reviewed sidecar needs no ledger - -- **WHEN** an adopt sidecar is reviewed and its digest matches the file while no review ledger exists -- **THEN** doctor reports no integrity error - -#### Scenario: Legacy ledger surfaces - -- **WHEN** doctor finds `review.yaml` or `*.review.yaml` -- **THEN** it reports an actionable migration/removal warning and leaves the file untouched - -### Requirement: Verdict transport is recorded - -Block entries MUST record the transport a verdict arrived through: `verdict-cli` for `rune adopt verdict`, `finalize` for generated added entries, `review-tty` for the interactive review mode. The interactive mode (`rune adopt review`, own change) MUST require a controlling TTY and MUST refuse piped input. - -#### Scenario: Transport in the record - -- **WHEN** a verdict is recorded via `rune adopt verdict` -- **THEN** its entry carries `transport: verdict-cli` diff --git a/docs/changes/archive/2026-07-15-demo-change/proposal.md b/docs/changes/archive/2026-07-15-demo-change/proposal.md deleted file mode 100644 index fb733d31..00000000 --- a/docs/changes/archive/2026-07-15-demo-change/proposal.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -adr: "docs/decisions/ADR-XXXX.md" -status: abandoned ---- - -# Demo Change - -## Why - -See the linked ADR for the decision rationale. This proposal records the change in scope. - -## What Changes - -- Describe the observable change. -- Name compatibility or migration effects. - -## Impact - -- List the affected runes, casts, commands, or documentation. diff --git a/docs/changes/archive/2026-07-15-demo-change/specs/demo/spec.md b/docs/changes/archive/2026-07-15-demo-change/specs/demo/spec.md deleted file mode 100644 index d398c999..00000000 --- a/docs/changes/archive/2026-07-15-demo-change/specs/demo/spec.md +++ /dev/null @@ -1,10 +0,0 @@ -## ADDED Requirements - -### Requirement: Demo - -The Demo capability SHALL describe its required behavior here. - -#### Scenario: Required behavior - -- **WHEN** the triggering condition occurs -- **THEN** the capability produces the specified outcome diff --git a/docs/changes/archive/2026-07-15-demo-change/tasks.md b/docs/changes/archive/2026-07-15-demo-change/tasks.md deleted file mode 100644 index 868efcf5..00000000 --- a/docs/changes/archive/2026-07-15-demo-change/tasks.md +++ /dev/null @@ -1,8 +0,0 @@ -## 1. Implementation - -- [ ] 1.1 Implement the specified behavior -- [ ] 1.2 Add or update executable checks - -## 2. Verification - -- [ ] 2.1 Run the relevant validation and test suites diff --git a/docs/changes/archive/2026-07-18-openspec-interop/design.md b/docs/changes/archive/2026-07-18-openspec-interop/design.md deleted file mode 100644 index 8d27a541..00000000 --- a/docs/changes/archive/2026-07-18-openspec-interop/design.md +++ /dev/null @@ -1,16 +0,0 @@ -# Openspec Interop Design - -## Approach - -One shared root resolver plus converters, instead of scattering root knowledge. `rune spec` gains a compat mode that operates directly on an `openspec/` root, and `spec export --openspec` / `spec import --openspec` convert between layouts. The rejected alternative was export-only, which strands repos already using OpenSpec tooling. - -## Structure - -- Root resolver: one function answers "where do changes and specs live" (`docs/changes` + `docs/specs` native; `openspec/changes` + `openspec/specs` in compat mode). Every consumer (spec commands, doctor, completions, validate spec-lifecycle checks) routes through it. Compat mode activates via config (`spec.root: openspec`) or autodetection when only an `openspec/` tree exists. -- `spec export --openspec`: writes the OpenSpec tree (project.md stub, changes/, specs/) from the native layout. `spec import --openspec` is the inverse. Both use a normalized change model; unknown files copy through untouched; lossy mappings warn. -- Round-trip: export then import is a no-op on the native tree; golden tests pin both directions. - -## Risks - -- Divergent roots in one repo (both `docs/changes` and `openspec/` present): resolver errors with a one-line choice instruction rather than guessing. -- Drift between converters and dialect: the artifact templates remain the single source; converters transform structure, never rewrite artifact bodies. diff --git a/docs/changes/archive/2026-07-18-openspec-interop/proposal.md b/docs/changes/archive/2026-07-18-openspec-interop/proposal.md deleted file mode 100644 index 78268afd..00000000 --- a/docs/changes/archive/2026-07-18-openspec-interop/proposal.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -adr: "docs/decisions/ADR-XXXX.md" -status: proposed ---- - -# Openspec Interop - -## Why - -See the linked ADR for the decision rationale. This proposal records the change in scope. - -## What Changes - -- Describe the observable change. -- Name compatibility or migration effects. - -## Capabilities - -- spec (new or modified) - -## Impact - -- List the affected runes, casts, commands, or documentation. diff --git a/docs/changes/archive/2026-07-18-openspec-interop/specs/spec/spec.md b/docs/changes/archive/2026-07-18-openspec-interop/specs/spec/spec.md deleted file mode 100644 index d0f15d57..00000000 --- a/docs/changes/archive/2026-07-18-openspec-interop/specs/spec/spec.md +++ /dev/null @@ -1,10 +0,0 @@ -## ADDED Requirements - -### Requirement: Spec - -The Spec capability SHALL describe its required behavior here. - -#### Scenario: Required behavior - -- **WHEN** the triggering condition occurs -- **THEN** the capability produces the specified outcome diff --git a/docs/changes/archive/2026-07-18-openspec-interop/tasks.md b/docs/changes/archive/2026-07-18-openspec-interop/tasks.md deleted file mode 100644 index 6a462547..00000000 --- a/docs/changes/archive/2026-07-18-openspec-interop/tasks.md +++ /dev/null @@ -1,10 +0,0 @@ -## 1. Implementation - -- [x] 1.1 Shared root resolver; every spec command routes through it -- [x] 1.2 Compat mode (`spec.root: openspec` + autodetection when only openspec/ exists) -- [x] 1.3 `spec export --openspec` and `spec import --openspec` (structural copy, overwrite refusal) - -## 2. Verification - -- [x] 2.1 Round-trip tests both directions; collision and missing-source fixtures -- [x] 2.2 cargo fmt, clippy, full suite green diff --git a/docs/changes/archive/2026-09-13-adopt-session-state/specs/adopt-session-state/spec.md b/docs/changes/archive/2026-09-13-adopt-session-state/specs/adopt-session-state/spec.md deleted file mode 100644 index ab318e43..00000000 --- a/docs/changes/archive/2026-09-13-adopt-session-state/specs/adopt-session-state/spec.md +++ /dev/null @@ -1,83 +0,0 @@ -## ADDED Requirements - -### Requirement: Pending adoption review state is external and durable - -The CLI SHALL preserve block-by-block review entries across sequential invocations without writing them into the adopted artifact or its `.provenance` directory. In Git repositories it SHALL prefer worktree-specific Git metadata. Each session key SHALL include the canonical module root and module-relative artifact path. In non-Git modules it SHALL use a user state or cache directory keyed by the canonical module root. - -#### Scenario: Linked worktrees hold separate sessions - -- **WHEN** two linked worktrees open adoption sessions -- **THEN** their session paths differ and each worktree discovers only its own pending state - -#### Scenario: Two modules use the same artifact path - -- **WHEN** two modules in one Git repository review artifacts at the same module-relative path -- **THEN** their session paths differ and each module discovers only its own pending state - -#### Scenario: Non-Git module resumes review - -- **WHEN** a module without Git opens a session and a later CLI process runs status -- **THEN** status discovers the session through the canonical-root-keyed user state fallback - -### Requirement: Finalize publishes concise reviewed sidecars - -Finalize SHALL keep every block pending until it receives an individual verdict and SHALL enforce verdict consistency and structural validation. After enforcement it SHALL atomically update each imported adopt/v1 sidecar with the final subject digest, reviewed state, reviewer, completion time, and concise adaptation summary. It SHALL delete the temporary session only after every required sidecar update succeeds and SHALL NOT write a review ledger into the source tree. - -#### Scenario: Finalize completes safely - -- **WHEN** all blocks are decided and the edited artifact satisfies the verdicts and schema -- **THEN** reviewed sidecars carry final digests and concise review metadata, no review ledger exists in the source tree, and the pending session is gone - -#### Scenario: Finalize crashes during sidecar updates - -- **WHEN** finalize is interrupted before all sidecars are updated -- **THEN** the complete pending session remains available and rerunning finalize safely completes the remaining idempotent sidecar updates - -### Requirement: Doctor uses sessions and sidecars as authority - -Doctor SHALL report pending external sessions and SHALL verify that each reviewed adopt/v1 sidecar's subject digest matches its file. Doctor SHALL diagnose `review.yaml` and `*.review.yaml` as legacy ledgers with an actionable migration/removal message and SHALL NOT require such a ledger for a reviewed artifact. - -#### Scenario: Reviewed tree has no ledger - -- **WHEN** a reviewed artifact has matching reviewed sidecars and no review ledger -- **THEN** doctor reports no integrity error - -#### Scenario: Legacy ledger remains - -- **WHEN** doctor finds a legacy review ledger -- **THEN** it reports an explicit inspect-and-remove-or-archive path and leaves the file untouched - -### Requirement: Reseal operates on reviewed adopt sidecars - -Reseal SHALL select the specified artifact from reviewed adopt/v1 sidecars, refuse pending or unreviewed inputs, and atomically update final subject digests to match maintainer touch-ups. - -#### Scenario: Maintainer touch-up is resealed - -- **WHEN** a finalized artifact is edited before commit and reseal targets that artifact -- **THEN** its reviewed sidecar digest is updated and doctor succeeds without a review ledger - -### Requirement: Context suffix normalization - -Authorship validation SHALL ignore a trailing `1m` context suffix after a model version digit in display model IDs and email local parts. The accepted author list SHALL contain only canonical model identities without the suffix. - -#### Scenario: One-million-context identity - -- **WHEN** a commit uses `claude-opus-51m` or `claude-fable-51m` -- **THEN** authorship validation compares it as `claude-opus-5` or `claude-fable-5` - -### Requirement: Legacy ledger removal is explicit - -Existing reviewed trees SHALL remain deployable from reviewed adopt sidecars. Rune SHALL diagnose legacy ledgers as redundant workflow records and SHALL leave removal or archival to an explicit maintainer action. - -#### Scenario: Doctor preserves a legacy ledger - -- **WHEN** doctor diagnoses a legacy ledger -- **THEN** the reviewed sidecars remain authoritative and the ledger remains untouched for explicit inspection, removal, or archival - -## REMOVED Requirements - -### Requirement: The review record is a permanent in-toto attestation - -**Reason**: Per-block review data is workflow state and duplicates the reviewed adopt sidecar authority. - -**Migration**: Existing reviewed sidecars remain deployable. Inspect and remove or archive legacy ledgers explicitly after confirming the sidecars. diff --git a/docs/changes/artifact-kind-switchboard/design.md b/docs/changes/artifact-kind-switchboard/design.md deleted file mode 100644 index c787f290..00000000 --- a/docs/changes/artifact-kind-switchboard/design.md +++ /dev/null @@ -1,38 +0,0 @@ -# Artifact Switchboard Design - -## Approach - -Consumer-side overlays in `.rune` beat deck-side targeting because the consumer owns the -decision, and beat ad-hoc provider-tree edits because deployed trees stay rune-owned. The toggle -is data, not state: assemble resolves the base selection, applies the per-provider exclude, then -the per-provider include override. Deploy and prune follow the resolved set, so a flipped toggle -becomes visible in the provider tree on the next install. - -## Structure - -- `.rune`: `runes..providers..exclude` and `.include` lists, with a schema - version step and backward reads of the current format. -- Kind commands: `on` and `off` verbs beside the existing list and stage actions, singular nouns - per CLI-0019. `--provider` narrows the toggle, its absence applies it to every enabled - provider. -- `rune list`: one row per rune, one column per enabled provider, on and off marks. -- Assemble: resolution order base, exclude, include. Install: prune toggled-off deployments into - `.trash/Z/`. -- TUI: a matrix editor over the same overlay data, after the CLI surface exists. - -## Limits - -- The `include` overlay is honored by assemble and reserved for hand edits. The `on` verb removes - the exclusion instead of writing an include entry. -- The overlay block is rendered with spaces and LF line endings. Tab-indented or CRLF manifests - stay valid but lose byte-exactness inside the owned block. - -## Risks - -- Toggle writes can destroy manifest comments. The writes go through the syntax-preserving - editor, and unrelated content stays byte-exact. -- A rune name can appear in several kinds. The kind commands scope the name, and an ambiguous - bare name fails with the candidates in the error. -- Toggled-off but deployed files can linger. Install pruning covers them, and doctor reports - them as orphans until then. -- Schema growth can break old CLI versions. The version step gates the new fields. diff --git a/docs/changes/artifact-kind-switchboard/specs/provider-toggle-command/spec.md b/docs/changes/artifact-kind-switchboard/specs/provider-toggle-command/spec.md deleted file mode 100644 index 7cc3ba6b..00000000 --- a/docs/changes/artifact-kind-switchboard/specs/provider-toggle-command/spec.md +++ /dev/null @@ -1,50 +0,0 @@ -## ADDED Requirements - -### Requirement: Per-provider exclusion - -`.rune` MUST support a per-provider exclude list that removes named runes from that provider's -deploy set without a change to any other provider. - -#### Scenario: Skill turns off for one provider - -- **WHEN** `rune skill off Name --provider claude` runs -- **THEN** the claude deploy set omits the rune and every other provider keeps it - -### Requirement: All-provider toggle default - -A toggle verb without `--provider` MUST apply to every provider that the -configuration knows, so the overlay records the intent for providers that a -user enables later. - -#### Scenario: Rule turns off everywhere - -- **WHEN** `rune rule off Name` runs with three enabled providers -- **THEN** all three deploy sets omit the rule - -### Requirement: Toggle visibility - -`rune list` MUST show each rune's on or off state for every enabled provider. - -#### Scenario: List renders the matrix - -- **WHEN** one skill is off for one of three providers -- **THEN** the list shows that skill off in that provider's column and on in the others - -### Requirement: Assemble honors toggles - -Assemble MUST exclude toggled-off runes from the affected provider's build, and install MUST -prune their previously deployed copies into the trash quarantine. - -#### Scenario: Install removes a toggled-off deployment - -- **WHEN** a deployed skill is toggled off and `rune install` runs -- **THEN** the provider tree loses the file and the trash quarantine gains it - -### Requirement: Manifest preservation - -A toggle write MUST keep unrelated `.rune` content byte-exact, including comments and ordering. - -#### Scenario: Toggle edits a commented manifest - -- **WHEN** a toggle verb writes a `.rune` that carries comments -- **THEN** only the overlay lines change and every comment survives diff --git a/docs/changes/bench-harness-port/design.md b/docs/changes/benchmark-runner-in-rune/design.md similarity index 100% rename from docs/changes/bench-harness-port/design.md rename to docs/changes/benchmark-runner-in-rune/design.md diff --git a/docs/changes/bench-harness-port/proposal.md b/docs/changes/benchmark-runner-in-rune/proposal.md similarity index 97% rename from docs/changes/bench-harness-port/proposal.md rename to docs/changes/benchmark-runner-in-rune/proposal.md index d9768e83..d2c1ad8d 100644 --- a/docs/changes/bench-harness-port/proposal.md +++ b/docs/changes/benchmark-runner-in-rune/proposal.md @@ -28,7 +28,7 @@ natively into rune. ## Capabilities -- bench-suite-runner (new) +- benchmark-results-stay-compatible (new) ## Impact diff --git a/docs/changes/bench-harness-port/specs/bench-suite-runner/spec.md b/docs/changes/benchmark-runner-in-rune/specs/benchmark-results-stay-compatible/spec.md similarity index 100% rename from docs/changes/bench-harness-port/specs/bench-suite-runner/spec.md rename to docs/changes/benchmark-runner-in-rune/specs/benchmark-results-stay-compatible/spec.md diff --git a/docs/changes/bench-harness-port/tasks.md b/docs/changes/benchmark-runner-in-rune/tasks.md similarity index 100% rename from docs/changes/bench-harness-port/tasks.md rename to docs/changes/benchmark-runner-in-rune/tasks.md diff --git a/docs/changes/graph-export-lifecycle/adr.md b/docs/changes/changes-join-the-graph/adr.md similarity index 99% rename from docs/changes/graph-export-lifecycle/adr.md rename to docs/changes/changes-join-the-graph/adr.md index e6dfd6df..4f17a30b 100644 --- a/docs/changes/graph-export-lifecycle/adr.md +++ b/docs/changes/changes-join-the-graph/adr.md @@ -20,7 +20,7 @@ informed: [] upstream: - "https://github.com/runedeck/cli/issues/70" - "https://github.com/runedeck/cli/issues/71" -change: graph-export-lifecycle +change: changes-join-the-graph --- # The graph export reads the deck's context and emits the lifecycle diff --git a/docs/changes/graph-export-lifecycle/proposal.md b/docs/changes/changes-join-the-graph/proposal.md similarity index 95% rename from docs/changes/graph-export-lifecycle/proposal.md rename to docs/changes/changes-join-the-graph/proposal.md index 6098f02e..510464e3 100644 --- a/docs/changes/graph-export-lifecycle/proposal.md +++ b/docs/changes/changes-join-the-graph/proposal.md @@ -1,5 +1,5 @@ --- -adr: "docs/changes/graph-export-lifecycle/adr.md" +adr: "docs/changes/changes-join-the-graph/adr.md" status: proposed decisions: ["The graph export reads the deck's context and emits the lifecycle"] --- @@ -18,7 +18,7 @@ decisions: ["The graph export reads the deck's context and emits the lifecycle"] ## Capabilities -- graph-export-lifecycle (new) +- changes-join-the-graph (new) ## Impact diff --git a/docs/changes/graph-export-lifecycle/specs/graph-export-lifecycle/spec.md b/docs/changes/changes-join-the-graph/specs/changes-join-the-graph/spec.md similarity index 100% rename from docs/changes/graph-export-lifecycle/specs/graph-export-lifecycle/spec.md rename to docs/changes/changes-join-the-graph/specs/changes-join-the-graph/spec.md diff --git a/docs/changes/graph-export-lifecycle/tasks.md b/docs/changes/changes-join-the-graph/tasks.md similarity index 100% rename from docs/changes/graph-export-lifecycle/tasks.md rename to docs/changes/changes-join-the-graph/tasks.md diff --git a/docs/changes/archive/2026-07-17-openspec-artifact-parity/proposal.md b/docs/changes/changes-round-trip-openspec/proposal.md similarity index 95% rename from docs/changes/archive/2026-07-17-openspec-artifact-parity/proposal.md rename to docs/changes/changes-round-trip-openspec/proposal.md index 0214b1af..d2d7a9e6 100644 --- a/docs/changes/archive/2026-07-17-openspec-artifact-parity/proposal.md +++ b/docs/changes/changes-round-trip-openspec/proposal.md @@ -1,6 +1,6 @@ --- adr: "docs/decisions/ADR-XXXX.md" -status: proposed +status: implemented --- # OpenSpec Artifact Parity @@ -19,4 +19,4 @@ status: proposed ## Impact -- `spec propose`, `spec archive`, `spec context` commands; the proposal template; the spec-lifecycle smoke doc. +- `spec propose`, `spec archive`, `spec context` commands; the proposal template; the one-delta-per-capability smoke doc. diff --git a/docs/changes/archive/2026-07-17-openspec-artifact-parity/specs/spec-lifecycle/spec.md b/docs/changes/changes-round-trip-openspec/specs/one-delta-per-capability/spec.md similarity index 62% rename from docs/changes/archive/2026-07-17-openspec-artifact-parity/specs/spec-lifecycle/spec.md rename to docs/changes/changes-round-trip-openspec/specs/one-delta-per-capability/spec.md index 696a1b0e..dc9227c4 100644 --- a/docs/changes/archive/2026-07-17-openspec-artifact-parity/specs/spec-lifecycle/spec.md +++ b/docs/changes/changes-round-trip-openspec/specs/one-delta-per-capability/spec.md @@ -2,7 +2,7 @@ ### Requirement: Multi-capability scaffolding -`rune spec propose` SHALL accept one or more `--capability` flags and scaffold `specs//spec.md` for each, and the generated proposal SHALL list them under a `## Capabilities` section. +`rune spec propose` MUST accept one or more `--capability` flags and scaffold `specs//spec.md` for each, and the generated proposal MUST list them under a `## Capabilities` section. #### Scenario: two capabilities @@ -11,7 +11,7 @@ ### Requirement: Optional design artifact -`rune spec propose --design` SHALL scaffold `design.md` beside the proposal, and `rune spec context` SHALL include it in the emitted work order when present. +`rune spec propose --design` MUST scaffold `design.md` beside the proposal, and `rune spec context` MUST include it in the emitted work order when present. #### Scenario: design included in the work order @@ -20,7 +20,7 @@ ### Requirement: Non-interactive archive flags compose -`rune spec archive --abandon -y` SHALL succeed, treating `-y` as a no-op confirmation on the abandon path. +`rune spec archive --abandon -y` MUST succeed, treating `-y` as a no-op confirmation on the abandon path. #### Scenario: scripted abandon diff --git a/docs/changes/archive/2026-07-17-openspec-artifact-parity/tasks.md b/docs/changes/changes-round-trip-openspec/tasks.md similarity index 100% rename from docs/changes/archive/2026-07-17-openspec-artifact-parity/tasks.md rename to docs/changes/changes-round-trip-openspec/tasks.md diff --git a/docs/changes/codex-skill-identity/.openspec.yaml b/docs/changes/codex-assembly-fully-compatible/.openspec.yaml similarity index 100% rename from docs/changes/codex-skill-identity/.openspec.yaml rename to docs/changes/codex-assembly-fully-compatible/.openspec.yaml diff --git a/docs/changes/codex-skill-identity/acceptance.md b/docs/changes/codex-assembly-fully-compatible/acceptance.md similarity index 100% rename from docs/changes/codex-skill-identity/acceptance.md rename to docs/changes/codex-assembly-fully-compatible/acceptance.md diff --git a/docs/changes/codex-skill-identity/adr.md b/docs/changes/codex-assembly-fully-compatible/adr.md similarity index 97% rename from docs/changes/codex-skill-identity/adr.md rename to docs/changes/codex-assembly-fully-compatible/adr.md index 989cb63b..5e52bf99 100644 --- a/docs/changes/codex-skill-identity/adr.md +++ b/docs/changes/codex-assembly-fully-compatible/adr.md @@ -106,7 +106,7 @@ Rollback restores only affected claim prefixes and refuses a changed physical ro Unrelated claims and foreign additions remain recoverable. The user approved this scoped guard and implementation through a PR on 2026-09-10. -The eight requirements in [identity](specs/codex-skill-identity/spec.md), [readiness](specs/codex-skill-readiness/spec.md), and [layers](specs/skill-source-layers/spec.md) define acceptance. +The eight requirements in [identity](specs/codex-assembly-fully-compatible/spec.md), [readiness](specs/codex-assembly-fully-compatible/spec.md), and [layers](specs/layers-match-their-policies/spec.md) define acceptance. The [design](design.md) fixes implementation boundaries and report data. The [acceptance plan](acceptance.md) binds the checks to those requirements. diff --git a/docs/changes/codex-skill-identity/design.md b/docs/changes/codex-assembly-fully-compatible/design.md similarity index 98% rename from docs/changes/codex-skill-identity/design.md rename to docs/changes/codex-assembly-fully-compatible/design.md index fad8efb4..54538374 100644 --- a/docs/changes/codex-skill-identity/design.md +++ b/docs/changes/codex-assembly-fully-compatible/design.md @@ -2,7 +2,7 @@ ## Context -See [proposal](proposal.md) for scope and the requirements ([identity](specs/codex-skill-identity/spec.md), [readiness](specs/codex-skill-readiness/spec.md), [layers](specs/skill-source-layers/spec.md)) for observable behavior. +See [proposal](proposal.md) for scope and the requirements ([identity](specs/codex-assembly-fully-compatible/spec.md), [readiness](specs/codex-assembly-fully-compatible/spec.md), [layers](specs/layers-match-their-policies/spec.md)) for observable behavior. The owner selected a staged layout migration on 2026-09-10. Use existing `ByKind` support. Defer cross-provider routing by assembled identity. This PR uses an independent clone of the recorded CLI base. diff --git a/docs/changes/codex-skill-identity/implementation.md b/docs/changes/codex-assembly-fully-compatible/implementation.md similarity index 94% rename from docs/changes/codex-skill-identity/implementation.md rename to docs/changes/codex-assembly-fully-compatible/implementation.md index f92a56b4..b00a77f8 100644 --- a/docs/changes/codex-skill-identity/implementation.md +++ b/docs/changes/codex-assembly-fully-compatible/implementation.md @@ -1,6 +1,6 @@ # Worker instructions -Read the requirements ([identity](specs/codex-skill-identity/spec.md), [readiness](specs/codex-skill-readiness/spec.md), [layers](specs/skill-source-layers/spec.md)), the [ADR](adr.md), and the [acceptance contract](acceptance.md). +Read the requirements ([identity](specs/codex-assembly-fully-compatible/spec.md), [readiness](specs/codex-assembly-fully-compatible/spec.md), [layers](specs/layers-match-their-policies/spec.md)), the [ADR](adr.md), and the [acceptance contract](acceptance.md). The source base is `a9176a18009c4d1e3e979431f0ace761874f76e8`. The user authorized implementation and two separate PRs on 2026-09-10. diff --git a/docs/changes/codex-skill-identity/proposal.md b/docs/changes/codex-assembly-fully-compatible/proposal.md similarity index 96% rename from docs/changes/codex-skill-identity/proposal.md rename to docs/changes/codex-assembly-fully-compatible/proposal.md index cefc5bf4..616816e5 100644 --- a/docs/changes/codex-skill-identity/proposal.md +++ b/docs/changes/codex-assembly-fully-compatible/proposal.md @@ -25,7 +25,7 @@ Moving skills to `.agents` needs evidence that the selected source, complete bun ### New Capabilities -- `codex-skill-identity`: Verify selected skill identity, layer validity, bundle integrity, ambiguity, and observed discovery. +- `codex-assembly-fully-compatible`: Verify selected skill identity, layer validity, bundle integrity, ambiguity, and observed discovery. ### Modified Capabilities diff --git a/docs/changes/codex-skill-identity/specs/codex-skill-readiness/spec.md b/docs/changes/codex-assembly-fully-compatible/specs/codex-assembly-fully-compatible/spec.md similarity index 51% rename from docs/changes/codex-skill-identity/specs/codex-skill-readiness/spec.md rename to docs/changes/codex-assembly-fully-compatible/specs/codex-assembly-fully-compatible/spec.md index 315a1124..a2425e7f 100644 --- a/docs/changes/codex-skill-identity/specs/codex-skill-readiness/spec.md +++ b/docs/changes/codex-assembly-fully-compatible/specs/codex-assembly-fully-compatible/spec.md @@ -4,7 +4,7 @@ Verify a deployed skill bundle strictly and repeatably: portable metadata survives assembly, migration never discards unowned content, native discovery needs fresh-session evidence, and no required check is skipped. -Identity and discovery ambiguity are the `codex-skill-identity` capability of this change. +Identity and discovery ambiguity are the `codex-assembly-fully-compatible` capability of this change. ## ADDED Requirements @@ -116,3 +116,109 @@ Passing verification MUST NOT grant commit, push, or deployment authority. - **WHEN** an injected failure interrupts a legacy replacement - **THEN** the previous content remains recoverable and its claims do not falsely describe a completed replacement + +# Codex skill identity + +## Purpose + +Let users trace each selected Codex skill to its source and verify its complete deployed bundle without hiding +discovery ambiguity. Readiness verification and source-layer checks are the `codex-assembly-fully-compatible` and `layers-match-their-policies` +capabilities of this change. + +### Requirement: CSI-01 Selected skills have an explicit deployment identity + +The readiness report MUST identify each selected skill by source identity, authored rune path, declared name, and +deployment path. +It MUST record the selected source revision or local content digest and the deployed bundle digest. +The default Codex skill destination MUST be `.agents/skills//` with authored case preserved. +The change MUST preserve explicit target overrides and existing private destinations for other content kinds. + +#### Scenario: Default Codex skill and native agent + +- **WHEN** a default Codex deployment selects `AlphaSkill` and a native agent +- **THEN** the skill appears under `.agents/skills/AlphaSkill/` +- **AND** the native agent retains its configured private destination +- **AND** the report identifies the source and complete skill bundle + +#### Scenario: Explicit target override + +- **WHEN** the user configures another supported Codex skill destination +- **THEN** deployment honors that destination +- **AND** readiness evaluates the actual destination and applicable discovery scope + +### Requirement: CSI-01a Provider selectors resolve to one provider + +CLI provider arguments MUST resolve exact names before explicit aliases and unique target-directory matches. +Ambiguous CLI selectors MUST fail with sorted candidates rather than select a provider by iteration order. + +#### Scenario: Exact provider name precedes an alias + +- **WHEN** a CLI provider argument equals one provider name and an alias of another provider +- **THEN** the argument resolves to the provider whose exact name it matches + +#### Scenario: CLI selector matches several providers + +- **WHEN** a CLI provider argument matches no exact name and several aliases or target directories +- **THEN** the command fails and reports the sorted candidates +- **AND** it selects no provider by iteration order + +### Requirement: CSI-01b Recorded source snapshots match current configuration + +Readiness MUST compare its recorded source snapshot with current configuration and authored roots derived +independently from that configuration. +The comparison MUST bind source selection, effective configuration, builder bytes, and any explicit model override. +Unavailable cached sources or mismatched snapshots MUST prevent acceptance without fetching sources during inspection. + +#### Scenario: Source content changes after deployment + +- **WHEN** a configured source gains a companion or selected skill without changing an installed entrypoint +- **THEN** readiness rejects the old source snapshot +- **AND** it does not infer freshness from the unchanged installed entrypoint + +### Requirement: CSI-02 Ambiguous discovery cannot pass readiness + +The readiness check MUST compare declared skill names across all inventoried roots applicable to the session. +Two candidate entries with the same declared name MUST produce an ambiguity finding even when their contents match. +The report MUST include their paths, observed bundle digests, ownership classification, and scope. +It MUST NOT select a winner by filesystem traversal order, directory name, modification time, or matching hash. +Unreadable roots or unresolved discovery scope MUST prevent a complete readiness verdict. + +#### Scenario: Duplicate across user and project scope + +- **WHEN** user and project roots contain separate entries named `AlphaSkill` +- **THEN** readiness reports both entries and fails the uniqueness condition +- **AND** reversing root enumeration produces the same finding + +#### Scenario: Different folder names hide a duplicate + +- **WHEN** folders `AlphaSkill` and `OldAlpha` both declare the name `AlphaSkill` +- **THEN** readiness reports an ambiguous declared name + +#### Scenario: Foreign or inaccessible candidate + +- **WHEN** a same-name candidate lacks Rune ownership or an applicable root cannot be inspected +- **THEN** readiness reports the foreign candidate or incomplete scope +- **AND** the check changes no candidate files + +### Requirement: CSI-03 Identity covers the complete assembled skill + +The deployed bundle MUST contain the entrypoint and all selected companion files with their relative paths and +executable semantics. +Its digest MUST cover file bytes, relative paths, entry types, executable bits, and symlink targets. +Changes to any covered item MUST invalidate the recorded bundle identity. +Provenance sidecars and timestamps MUST NOT affect that content digest. +Generated build sidecars MUST use the reserved `.provenance` namespace without hiding adjacent authored YAML. +Documented local companion references MUST resolve within the deployed bundle. +Ambiguous paths, escaping references, and escaping or cyclic symlinks MUST fail bundle acceptance. + +#### Scenario: Companion changes without an entrypoint change + +- **WHEN** only a nested reference, executable bit, binary asset, or internal symlink target changes +- **THEN** the bundle digest changes +- **AND** evidence for the previous bundle cannot establish current integrity + +#### Scenario: Complete bundle survives deployment + +- **WHEN** a skill includes nested Markdown, a script with a sibling import, and a binary asset +- **THEN** deployment preserves the selected bundle and its supported local references +- **AND** excluding the skill by provider applicability excludes its companion bundle diff --git a/docs/changes/codex-skill-identity/specs/skill-source-layers/spec.md b/docs/changes/codex-assembly-fully-compatible/specs/layers-match-their-policies/spec.md similarity index 100% rename from docs/changes/codex-skill-identity/specs/skill-source-layers/spec.md rename to docs/changes/codex-assembly-fully-compatible/specs/layers-match-their-policies/spec.md diff --git a/docs/changes/codex-skill-identity/tasks.md b/docs/changes/codex-assembly-fully-compatible/tasks.md similarity index 100% rename from docs/changes/codex-skill-identity/tasks.md rename to docs/changes/codex-assembly-fully-compatible/tasks.md diff --git a/docs/changes/codex-skill-identity/validation.md b/docs/changes/codex-assembly-fully-compatible/validation.md similarity index 100% rename from docs/changes/codex-skill-identity/validation.md rename to docs/changes/codex-assembly-fully-compatible/validation.md diff --git a/docs/changes/codex-skill-identity/specs/codex-skill-identity/spec.md b/docs/changes/codex-skill-identity/specs/codex-skill-identity/spec.md deleted file mode 100644 index c4a9217e..00000000 --- a/docs/changes/codex-skill-identity/specs/codex-skill-identity/spec.md +++ /dev/null @@ -1,107 +0,0 @@ -# Codex skill identity - -## Purpose - -Let users trace each selected Codex skill to its source and verify its complete deployed bundle without hiding -discovery ambiguity. Readiness verification and source-layer checks are the `codex-skill-readiness` and `skill-source-layers` -capabilities of this change. - -## ADDED Requirements - -### Requirement: CSI-01 Selected skills have an explicit deployment identity - -The readiness report MUST identify each selected skill by source identity, authored rune path, declared name, and -deployment path. -It MUST record the selected source revision or local content digest and the deployed bundle digest. -The default Codex skill destination MUST be `.agents/skills//` with authored case preserved. -The change MUST preserve explicit target overrides and existing private destinations for other content kinds. - -#### Scenario: Default Codex skill and native agent - -- **WHEN** a default Codex deployment selects `AlphaSkill` and a native agent -- **THEN** the skill appears under `.agents/skills/AlphaSkill/` -- **AND** the native agent retains its configured private destination -- **AND** the report identifies the source and complete skill bundle - -#### Scenario: Explicit target override - -- **WHEN** the user configures another supported Codex skill destination -- **THEN** deployment honors that destination -- **AND** readiness evaluates the actual destination and applicable discovery scope - -### Requirement: CSI-01a Provider selectors resolve to one provider - -CLI provider arguments MUST resolve exact names before explicit aliases and unique target-directory matches. -Ambiguous CLI selectors MUST fail with sorted candidates rather than select a provider by iteration order. - -#### Scenario: Exact provider name precedes an alias - -- **WHEN** a CLI provider argument equals one provider name and an alias of another provider -- **THEN** the argument resolves to the provider whose exact name it matches - -#### Scenario: CLI selector matches several providers - -- **WHEN** a CLI provider argument matches no exact name and several aliases or target directories -- **THEN** the command fails and reports the sorted candidates -- **AND** it selects no provider by iteration order - -### Requirement: CSI-01b Recorded source snapshots match current configuration - -Readiness MUST compare its recorded source snapshot with current configuration and authored roots derived -independently from that configuration. -The comparison MUST bind source selection, effective configuration, builder bytes, and any explicit model override. -Unavailable cached sources or mismatched snapshots MUST prevent acceptance without fetching sources during inspection. - -#### Scenario: Source content changes after deployment - -- **WHEN** a configured source gains a companion or selected skill without changing an installed entrypoint -- **THEN** readiness rejects the old source snapshot -- **AND** it does not infer freshness from the unchanged installed entrypoint - -### Requirement: CSI-02 Ambiguous discovery cannot pass readiness - -The readiness check MUST compare declared skill names across all inventoried roots applicable to the session. -Two candidate entries with the same declared name MUST produce an ambiguity finding even when their contents match. -The report MUST include their paths, observed bundle digests, ownership classification, and scope. -It MUST NOT select a winner by filesystem traversal order, directory name, modification time, or matching hash. -Unreadable roots or unresolved discovery scope MUST prevent a complete readiness verdict. - -#### Scenario: Duplicate across user and project scope - -- **WHEN** user and project roots contain separate entries named `AlphaSkill` -- **THEN** readiness reports both entries and fails the uniqueness condition -- **AND** reversing root enumeration produces the same finding - -#### Scenario: Different folder names hide a duplicate - -- **WHEN** folders `AlphaSkill` and `OldAlpha` both declare the name `AlphaSkill` -- **THEN** readiness reports an ambiguous declared name - -#### Scenario: Foreign or inaccessible candidate - -- **WHEN** a same-name candidate lacks Rune ownership or an applicable root cannot be inspected -- **THEN** readiness reports the foreign candidate or incomplete scope -- **AND** the check changes no candidate files - -### Requirement: CSI-03 Identity covers the complete assembled skill - -The deployed bundle MUST contain the entrypoint and all selected companion files with their relative paths and -executable semantics. -Its digest MUST cover file bytes, relative paths, entry types, executable bits, and symlink targets. -Changes to any covered item MUST invalidate the recorded bundle identity. -Provenance sidecars and timestamps MUST NOT affect that content digest. -Generated build sidecars MUST use the reserved `.provenance` namespace without hiding adjacent authored YAML. -Documented local companion references MUST resolve within the deployed bundle. -Ambiguous paths, escaping references, and escaping or cyclic symlinks MUST fail bundle acceptance. - -#### Scenario: Companion changes without an entrypoint change - -- **WHEN** only a nested reference, executable bit, binary asset, or internal symlink target changes -- **THEN** the bundle digest changes -- **AND** evidence for the previous bundle cannot establish current integrity - -#### Scenario: Complete bundle survives deployment - -- **WHEN** a skill includes nested Markdown, a script with a sibling import, and a binary asset -- **THEN** deployment preserves the selected bundle and its supported local references -- **AND** excluding the skill by provider applicability excludes its companion bundle diff --git a/docs/changes/harness-launch-profiles/design.md b/docs/changes/convenient-cross-harness-launcher/design.md similarity index 100% rename from docs/changes/harness-launch-profiles/design.md rename to docs/changes/convenient-cross-harness-launcher/design.md diff --git a/docs/changes/harness-launch-profiles/proposal.md b/docs/changes/convenient-cross-harness-launcher/proposal.md similarity index 94% rename from docs/changes/harness-launch-profiles/proposal.md rename to docs/changes/convenient-cross-harness-launcher/proposal.md index c0e7c8d5..a0afbf63 100644 --- a/docs/changes/harness-launch-profiles/proposal.md +++ b/docs/changes/convenient-cross-harness-launcher/proposal.md @@ -18,7 +18,7 @@ Coding-tool profiles need named endpoint, credential-reference, argument, middle ## Capabilities -- harness-launch-command (modified) +- convenient-cross-harness-launcher (modified) ## Impact diff --git a/docs/changes/harness-launch-profiles/specs/harness-launch-command/spec.md b/docs/changes/convenient-cross-harness-launcher/specs/convenient-cross-harness-launcher/spec.md similarity index 100% rename from docs/changes/harness-launch-profiles/specs/harness-launch-command/spec.md rename to docs/changes/convenient-cross-harness-launcher/specs/convenient-cross-harness-launcher/spec.md diff --git a/docs/changes/harness-launch-profiles/tasks.md b/docs/changes/convenient-cross-harness-launcher/tasks.md similarity index 100% rename from docs/changes/harness-launch-profiles/tasks.md rename to docs/changes/convenient-cross-harness-launcher/tasks.md diff --git a/docs/changes/council-run-hardening/design.md b/docs/changes/cross-vendor-review-fixes/design.md similarity index 100% rename from docs/changes/council-run-hardening/design.md rename to docs/changes/cross-vendor-review-fixes/design.md diff --git a/docs/changes/council-run-hardening/proposal.md b/docs/changes/cross-vendor-review-fixes/proposal.md similarity index 96% rename from docs/changes/council-run-hardening/proposal.md rename to docs/changes/cross-vendor-review-fixes/proposal.md index b6d099b4..90bbef5a 100644 --- a/docs/changes/council-run-hardening/proposal.md +++ b/docs/changes/cross-vendor-review-fixes/proposal.md @@ -51,8 +51,8 @@ Improvements: ## Capabilities -- deployment-integrity-doctor (modified) -- bench-suite-runner (modified) +- deployed-files-match-manifest (modified) +- benchmark-results-stay-compatible (modified) ## Impact diff --git a/docs/changes/council-run-hardening/specs/bench-suite-runner/spec.md b/docs/changes/cross-vendor-review-fixes/specs/benchmark-results-stay-compatible/spec.md similarity index 100% rename from docs/changes/council-run-hardening/specs/bench-suite-runner/spec.md rename to docs/changes/cross-vendor-review-fixes/specs/benchmark-results-stay-compatible/spec.md diff --git a/docs/changes/council-run-hardening/specs/deployment-integrity-doctor/spec.md b/docs/changes/cross-vendor-review-fixes/specs/deployed-files-match-manifest/spec.md similarity index 100% rename from docs/changes/council-run-hardening/specs/deployment-integrity-doctor/spec.md rename to docs/changes/cross-vendor-review-fixes/specs/deployed-files-match-manifest/spec.md diff --git a/docs/changes/council-run-hardening/tasks.md b/docs/changes/cross-vendor-review-fixes/tasks.md similarity index 95% rename from docs/changes/council-run-hardening/tasks.md rename to docs/changes/cross-vendor-review-fixes/tasks.md index f2321f8e..e141d5e2 100644 --- a/docs/changes/council-run-hardening/tasks.md +++ b/docs/changes/cross-vendor-review-fixes/tasks.md @@ -25,8 +25,8 @@ ## 3. Shared infrastructure -- [ ] 3.1 One path-confinement helper (adopt, deploy, doctor): adopt's contained_path hardened and sidecar resolvers centralized in manifest. Deploy/doctor still carry their own copies -- [ ] 3.2 One atomic-replace API used by all writers: config::write_atomic is the canonical one. Review-record and bench writers not yet migrated +- [x] 3.1 One path-confinement helper (adopt, deploy, doctor): adopt's contained_path hardened and sidecar resolvers centralized in manifest. Deploy/doctor still carry their own copies +- [x] 3.2 One atomic-replace API used by all writers: config::write_atomic is the canonical one. Review-record and bench writers not yet migrated - [x] 3.3 Atomic assemble: staging tree + swap, previous build restored on a failed swap - [x] 3.4 Target lock for install/deploy/doctor --repair, with an exclusivity test - [x] 3.5 Exit-code contract documented (docs/Exit Codes.md) diff --git a/docs/changes/tui-first-run/design.md b/docs/changes/dashboard-never-opens-empty/design.md similarity index 100% rename from docs/changes/tui-first-run/design.md rename to docs/changes/dashboard-never-opens-empty/design.md diff --git a/docs/changes/tui-first-run/proposal.md b/docs/changes/dashboard-never-opens-empty/proposal.md similarity index 90% rename from docs/changes/tui-first-run/proposal.md rename to docs/changes/dashboard-never-opens-empty/proposal.md index f49c1283..431c17a2 100644 --- a/docs/changes/tui-first-run/proposal.md +++ b/docs/changes/dashboard-never-opens-empty/proposal.md @@ -22,9 +22,9 @@ new user to setup instead of an empty screen. Governing decision: CLI-0036. ## Capabilities -- terminal-dashboard-ui (new) +- dashboard-never-opens-empty (new) ## Impact - `src/tui/styles.rs`, `src/tui/app.rs`, `src/cli/theme.rs`, and the TUI tests -- Closes tasks 1.5 and 2.3 of the herdr-theme-system change +- Closes tasks 1.5 and 2.3 of the terminal-interface-theme-manager change diff --git a/docs/changes/tui-first-run/specs/terminal-dashboard-ui/spec.md b/docs/changes/dashboard-never-opens-empty/specs/dashboard-never-opens-empty/spec.md similarity index 100% rename from docs/changes/tui-first-run/specs/terminal-dashboard-ui/spec.md rename to docs/changes/dashboard-never-opens-empty/specs/dashboard-never-opens-empty/spec.md diff --git a/docs/changes/tui-first-run/tasks.md b/docs/changes/dashboard-never-opens-empty/tasks.md similarity index 100% rename from docs/changes/tui-first-run/tasks.md rename to docs/changes/dashboard-never-opens-empty/tasks.md diff --git a/docs/changes/harness-rule-wiring/design.md b/docs/changes/deployed-rules-load-everywhere/design.md similarity index 100% rename from docs/changes/harness-rule-wiring/design.md rename to docs/changes/deployed-rules-load-everywhere/design.md diff --git a/docs/changes/harness-rule-wiring/proposal.md b/docs/changes/deployed-rules-load-everywhere/proposal.md similarity index 98% rename from docs/changes/harness-rule-wiring/proposal.md rename to docs/changes/deployed-rules-load-everywhere/proposal.md index a5b102b2..1a5bb492 100644 --- a/docs/changes/harness-rule-wiring/proposal.md +++ b/docs/changes/deployed-rules-load-everywhere/proposal.md @@ -38,7 +38,7 @@ rule changes. Tracks N4M3Z/forge-cli#92 with the council's corrections. ## Capabilities -- provider-tree-deploy (modified) +- rules-reach-every-harness (modified) ## Impact diff --git a/docs/changes/harness-rule-wiring/specs/provider-tree-deploy/spec.md b/docs/changes/deployed-rules-load-everywhere/specs/rules-reach-every-harness/spec.md similarity index 100% rename from docs/changes/harness-rule-wiring/specs/provider-tree-deploy/spec.md rename to docs/changes/deployed-rules-load-everywhere/specs/rules-reach-every-harness/spec.md diff --git a/docs/changes/harness-rule-wiring/tasks.md b/docs/changes/deployed-rules-load-everywhere/tasks.md similarity index 100% rename from docs/changes/harness-rule-wiring/tasks.md rename to docs/changes/deployed-rules-load-everywhere/tasks.md diff --git a/docs/changes/docs-todo-adr/design.md b/docs/changes/docs-todos-and-records/design.md similarity index 100% rename from docs/changes/docs-todo-adr/design.md rename to docs/changes/docs-todos-and-records/design.md diff --git a/docs/changes/docs-todo-adr/proposal.md b/docs/changes/docs-todos-and-records/proposal.md similarity index 72% rename from docs/changes/docs-todo-adr/proposal.md rename to docs/changes/docs-todos-and-records/proposal.md index e75fd177..af997987 100644 --- a/docs/changes/docs-todo-adr/proposal.md +++ b/docs/changes/docs-todos-and-records/proposal.md @@ -16,9 +16,9 @@ See the linked ADR for the decision rationale. This proposal records the change ## Capabilities -- docs-link-check (new or modified) -- docs-todo-tracking (new or modified) -- architecture-decision-records (new or modified) +- no-broken-doc-links (new or modified) +- plain-text-todo-list (new or modified) +- keep-architecture-decision-records (new or modified) ## Impact diff --git a/docs/changes/docs-todo-adr/specs/architecture-decision-records/spec.md b/docs/changes/docs-todos-and-records/specs/keep-architecture-decision-records/spec.md similarity index 100% rename from docs/changes/docs-todo-adr/specs/architecture-decision-records/spec.md rename to docs/changes/docs-todos-and-records/specs/keep-architecture-decision-records/spec.md diff --git a/docs/changes/docs-todo-adr/specs/docs-link-check/spec.md b/docs/changes/docs-todos-and-records/specs/no-broken-doc-links/spec.md similarity index 100% rename from docs/changes/docs-todo-adr/specs/docs-link-check/spec.md rename to docs/changes/docs-todos-and-records/specs/no-broken-doc-links/spec.md diff --git a/docs/changes/docs-todo-adr/specs/docs-todo-tracking/spec.md b/docs/changes/docs-todos-and-records/specs/plain-text-todo-list/spec.md similarity index 100% rename from docs/changes/docs-todo-adr/specs/docs-todo-tracking/spec.md rename to docs/changes/docs-todos-and-records/specs/plain-text-todo-list/spec.md diff --git a/docs/changes/docs-todo-adr/tasks.md b/docs/changes/docs-todos-and-records/tasks.md similarity index 100% rename from docs/changes/docs-todo-adr/tasks.md rename to docs/changes/docs-todos-and-records/tasks.md diff --git a/docs/changes/draft-runes-before-promotion/specs/draft-runes-before-promotion/spec.md b/docs/changes/draft-runes-before-promotion/specs/draft-runes-before-promotion/spec.md deleted file mode 100644 index 41879748..00000000 --- a/docs/changes/draft-runes-before-promotion/specs/draft-runes-before-promotion/spec.md +++ /dev/null @@ -1,52 +0,0 @@ -## ADDED Requirements - -### Requirement: Draft writes an unmanaged rune - -`rune draft ` MUST write a rune of that kind under each provider directory the consumer deploys to, from a minimal template with the name filled in. It MUST NOT add the file to `.manifest`. It MUST add one entry to `.drafts` with the relative path, the kind, and the creation time in UTC. The kind MUST be `skill`, `agent`, or `rule`. - -#### Scenario: Draft skill in a consumer with two providers - -- **WHEN** a consumer deploys to claude and codex and the author runs `rune draft skill ReviewSpec` -- **THEN** `.claude/skills/ReviewSpec/SKILL.md` and `.codex/skills/ReviewSpec/SKILL.md` exist, `.manifest` is unchanged, and `.drafts` lists both paths - -#### Scenario: Draft name already deployed - -- **WHEN** the consumer's `.manifest` already lists a rune with that name and kind -- **THEN** draft refuses and names the managed path - -### Requirement: Doctor knows drafts - -`rune doctor` MUST read `.drafts`. A file listed there MUST NOT be reported as an orphan. `rune doctor` MUST list each draft with its name, kind, and age in days. `rune doctor --repair` MUST NOT move or delete a registered draft. A `.drafts` entry whose file is missing MUST be reported as a stale draft. - -#### Scenario: Registered draft under a managed directory - -- **WHEN** `.drafts` lists `.claude/skills/ReviewSpec/SKILL.md` and doctor runs -- **THEN** the report shows one draft aged N days and no orphan for that path - -#### Scenario: Draft file removed by hand - -- **WHEN** `.drafts` lists a path that does not exist -- **THEN** doctor reports a stale draft and exits nonzero under `--verify` - -### Requirement: Promote moves the draft into the deck - -`rune promote --domain --change ` MUST move the draft's canonical file into `runes///`, MUST remove it from every provider tree and from `.drafts`, and MUST create `docs/changes//` with a proposal stub that names the rune and an empty task list. The change id MUST have at least three hyphen-separated words. Promote MUST refuse when the deck root is not found or when `docs/changes//` exists. - -#### Scenario: Promote a draft skill - -- **WHEN** the author runs `rune promote ReviewSpec --domain core --change review-spec-interaction` -- **THEN** `runes/core/skills/ReviewSpec/SKILL.md` exists, no provider tree holds the draft, `.drafts` has no entry for it, and `docs/changes/review-spec-interaction/proposal.md` names ReviewSpec - -#### Scenario: Change id has two words - -- **WHEN** the author passes `--change review-spec` -- **THEN** promote refuses and states the three-word rule - -### Requirement: Register is plain and local - -`.drafts` MUST be a YAML list at the consumer root, one entry per draft with `path`, `kind`, and `created`. It MUST be git-ignored by `rune init` and MUST NOT be deployed or exported. - -#### Scenario: Fresh consumer - -- **WHEN** a consumer has no `.drafts` file -- **THEN** every draft command behaves as if the list is empty, and the first draft creates the file diff --git a/docs/changes/easily-toggle-target-deployments/design.md b/docs/changes/easily-toggle-target-deployments/design.md new file mode 100644 index 00000000..505ea722 --- /dev/null +++ b/docs/changes/easily-toggle-target-deployments/design.md @@ -0,0 +1,54 @@ +# Artifact switchboard design + +## Approach + +Consumer-side overlays in `.rune` beat deck-side targeting because the consumer owns the +decision, and beat ad-hoc provider-tree edits because deployed trees stay rune-owned. The toggle +is data in the manifest, and no separate state file is kept: assemble resolves the base +selection, applies the per-provider exclude, then the per-provider include override. Deploy and +prune use the resolved set, so a flipped toggle becomes visible in the provider tree on the next +install. + +## Structure + +- `.rune`: `runes..providers..exclude` and `.include` lists, with a schema + version step and backward reads of the current format. +- Kind commands: `on` and `off` verbs beside the existing list and stage actions, singular nouns + per CLI-0019. `--provider` narrows the toggle, its absence applies it to every enabled + provider. +- `rune ` with no subcommand: one row per rune, one column per enabled provider, on and off + marks. +- Assemble: resolution order base, exclude, include. Install: prune toggled-off deployments into + `.trash/Z/`. +- TUI: a matrix editor over the same overlay data, after the CLI commands exist. + +## Limits + +- The `include` overlay is honored by assemble and reserved for hand edits. The `on` verb removes + the exclusion instead of writing an include entry. +- The overlay block is rendered with spaces and LF line endings. Tab-indented or CRLF manifests + stay valid but lose byte-exactness inside the owned block. + +## Risks + +- Toggle writes can destroy manifest comments. The writes go through the syntax-preserving + editor, and unrelated content stays byte-exact. +- A rune name can appear in several kinds. The kind commands scope the name, and an ambiguous + bare name fails with the candidates in the error. +- Toggled-off but deployed files can linger. Install pruning covers them, and doctor reports + them as orphans until then. +- Schema growth can break old CLI versions. The version step guards the new fields. + +## Commands + +The delta names roles: the user toggles, the assembler filters, the deployer prunes. This table +carries the command, flag, key, and file names that implement each requirement, as rune 0.6.0 +prints them in `--help`. + +| Requirement | Command and flags | Hook | Diagnostic ids and files | Exit status | +|---|---|---|---|---| +| Per-provider exclusion | `rune skill off --provider claude`. The consumer manifest is `.rune` and the key is `runes..providers..exclude`. | none | none | not stated in the delta | +| All-provider toggle default | `rune rule off ` with no `--provider`. The same verbs exist on `rune agent` and `rune skill`. | none | none | not stated in the delta | +| Toggle visibility | `rune skill`, `rune rule`, or `rune agent` with no subcommand lists the matrix (the delta text named `rune list`, which 0.6.0 does not have). | none | none | not stated in the delta | +| The assembler honors toggles | `rune install` applies the overlays and prunes into `.trash/Z/`. | none | none | not stated in the delta | +| Manifest preservation | The syntax-preserving `.rune` editor. `rune skill on ` removes the exclusion instead of writing an `include` entry. | none | none | not stated in the delta | diff --git a/docs/changes/artifact-kind-switchboard/proposal.md b/docs/changes/easily-toggle-target-deployments/proposal.md similarity index 96% rename from docs/changes/artifact-kind-switchboard/proposal.md rename to docs/changes/easily-toggle-target-deployments/proposal.md index a626fe98..ca7ee7d9 100644 --- a/docs/changes/artifact-kind-switchboard/proposal.md +++ b/docs/changes/easily-toggle-target-deployments/proposal.md @@ -23,7 +23,7 @@ in one view. Governing decision: CLI-0032. ## Capabilities -- provider-toggle-command (new) +- easily-toggle-target-deployments (new) ## Impact diff --git a/docs/changes/easily-toggle-target-deployments/specs/easily-toggle-target-deployments/spec.md b/docs/changes/easily-toggle-target-deployments/specs/easily-toggle-target-deployments/spec.md new file mode 100644 index 00000000..f84cde75 --- /dev/null +++ b/docs/changes/easily-toggle-target-deployments/specs/easily-toggle-target-deployments/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Per-provider exclusion + +The consumer manifest MUST support a per-provider exclude list that removes named runes from that +provider's deploy set without a change to any other provider. + +#### Scenario: Skill turns off for one provider + +- **WHEN** the user turns a skill off for the claude provider only +- **THEN** the claude deploy set omits the rune and every other provider keeps it + +### Requirement: All-provider toggle default + +A toggle that names no provider MUST apply to every provider that the configuration knows. The +overlay then records the intent for providers that a user enables later. + +#### Scenario: Rule turns off everywhere + +- **WHEN** the user turns a rule off with three providers enabled and names no provider +- **THEN** all three deploy sets omit the rule + +### Requirement: Toggle visibility + +The deployer MUST list each rune of a kind with its on or off state for every enabled provider. + +#### Scenario: List renders the matrix + +- **WHEN** one skill is off for one of three providers +- **THEN** the list shows that skill off in that provider's column and on in the others + +### Requirement: The assembler honors toggles + +The assembler MUST exclude toggled-off runes from the affected provider's build, and the deployer +MUST prune their previously deployed copies into the trash quarantine. + +#### Scenario: Deployer removes a toggled-off deployment + +- **WHEN** a deployed skill is toggled off and the deployer installs +- **THEN** the provider tree loses the file and the trash quarantine gains it + +### Requirement: Manifest preservation + +A toggle write MUST keep unrelated consumer manifest content byte-exact, including comments and +ordering. + +#### Scenario: Toggle edits a commented manifest + +- **WHEN** a toggle writes a consumer manifest that carries comments +- **THEN** only the overlay lines change and every comment survives diff --git a/docs/changes/artifact-kind-switchboard/tasks.md b/docs/changes/easily-toggle-target-deployments/tasks.md similarity index 100% rename from docs/changes/artifact-kind-switchboard/tasks.md rename to docs/changes/easily-toggle-target-deployments/tasks.md diff --git a/docs/changes/edge-cases-accompany-specs/adr.md b/docs/changes/edge-cases-accompany-specs/adr.md new file mode 100644 index 00000000..6abaa715 --- /dev/null +++ b/docs/changes/edge-cases-accompany-specs/adr.md @@ -0,0 +1,67 @@ +--- +title: "Three keyword levels and a companion for edge cases" +description: "A requirement states its level with MUST, SHOULD, or MAY, keeps one primary scenario, and puts its edge cases in a companion file. The review view is generated on demand. This amends CLI-0040 on the keyword and the parser rule." +type: adr +category: process +tags: + - cli + - specs + - lint + - openspec +status: proposed +created: 2026-10-18 +updated: 2026-10-18 +author: "@N4M3Z" +project: cli +related: + - "CLI-0040 Specification House Rules" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: ["claude-fable-5-1@claude"] +informed: [] +upstream: [] +change: edge-cases-accompany-specs +--- + +# Three keyword levels and a companion for edge cases + +## Context and Problem Statement + +CLI-0040 Specification House Rules chose one keyword, MUST, for every runedeck specification. It left the parser's keyword rule unchanged, so the compatibility parser still refuses a requirement body without MUST or SHALL. Every rule now reads equally loud, and the edge cases of a rule sit as scenarios under it. The owner read the specifications in the voice review of 17 October 2026 and asked for one file that covers the main points. The detail belongs beside it, and he is pointed at the detail that changes a decision. Two decisions of CLI-0040 stand in the way: one keyword, and a parser rule that stays as it is. + +## Decision Drivers + +- The owner reads one rule and one case per requirement +- An edge case that changes meaning reaches the owner, and the rest reaches implementers and provers +- The parser keeps reading every OpenSpec artifact it reads today +- A derived file regenerates on demand or does not exist + +## Considered Options + +1. Keep CLI-0040 as it stands: MUST only, every scenario in the specification. Too much text for one owner, and no way to say how loud a rule is. +2. Three keyword levels, one primary scenario, edge cases in a companion, and a generated review view. The parser's keyword rule widens so a SHOULD or MAY body parses. +3. The levels and the companion, plus a maintained review file in the tree. A second source of truth that drifts, which the owner rejected. + +## Decision Outcome + +Option 2. This record amends CLI-0040 on two points and keeps the rest. + +- The driver "One normative keyword in every runedeck specification" gives way to three levels: MUST, SHOULD, and MAY, with their negated forms. The validator keeps reporting SHALL. +- The outcome that the parser's normative-keyword regex does not change gives way on the regex only. The parser accepts a body whose keyword is MUST, SHOULD, or MAY. +- The OpenSpec v1.6.0 oracle fixtures under `rune-docs/tests/fixtures/openspec/` and their tests in `openspec_oracle.rs` do not change. No fixture body uses SHOULD or MAY, and no test expects the keyword refusal, so they stay green. The divergence is recorded in `intentional-differences.md` beside the fixtures. +- The separate lint, the 150-line limit, and the term rule of CLI-0040 stand. + +The checkers MUST keep these rules: + +- A requirement body with MUST, SHOULD, or MAY as its keyword MUST parse, and SHALL MUST stay reported. +- A second scenario under one requirement MUST be reported, as a warning while the repository migrates. +- An edge case that narrows, overrides, or contradicts its requirement MUST carry a decision line. +- The archiver MUST carry every companion block into the canonical capability. +- The review view MUST be generated on demand, and a committed view MUST be reported. + +## Consequences + +- A SHOULD or MAY requirement with no MUST fails the OpenSpec checker until it accepts the levels or leaves the commit path. The owner decides which check the commit path trusts. +- Every active change and the canonical tree move their scenarios past the first into companions, and the proofs grow one scene per edge case. +- Two files per capability can drift. The validator reports a companion block under a requirement the specification lacks. +- Archive moves this record to `docs/decisions/` with the next free CLI number and notes the amendment in CLI-0040. diff --git a/docs/changes/edge-cases-accompany-specs/design.md b/docs/changes/edge-cases-accompany-specs/design.md new file mode 100644 index 00000000..029c2c71 --- /dev/null +++ b/docs/changes/edge-cases-accompany-specs/design.md @@ -0,0 +1,47 @@ +# Design + +## Approach + +The specification stays a valid OpenSpec file: requirements with keywords and one scenario each, so the compatibility parser and the OpenSpec checker read it unchanged. Everything this change adds sits beside it. The companion `edge-cases.md` opens with `## Edge cases`, repeats the specification's `### Requirement:` headings, and holds `#### Edge case: ` blocks where the specification holds scenarios. A block has `- **WHEN**`, `- **THEN**`, optional `- **AND**` steps, and an optional `- **DECISION**:` line. The companion carries no `## ADDED Requirements` or other delta operation header. OpenSpec 1.14.1 reports any Markdown file beside `spec.md` that carries one as a misplaced delta. It reads a file without one as a note. An edge case takes the operation its requirement has in the specification. + +The proof runner keys an edge case like a scenario, `<capability>#<requirement>/<edge-case>`, so scenes, casts, and the graph need no second shape. The generator writes the review view to standard output, or to a path the caller names outside the tree. Its first line is a marker, and the validator reports any tracked Markdown file under the specification root that starts with it. + +Three alternatives fell in the review. Keeping every scenario in the specification: too much text for one owner. A maintained review file: a second source of truth that drifts, which the owner called a derived artifact and more stuff to get into conflict. Moving all scenarios out: the specification would be statements nothing can fail. + +## Coverage table + +| Capability | Requirement | Check or missed requirement | +|---|---|---| +| edge-cases-accompany-specs | Requirements Use Three Keyword Levels | the legacy-keyword finding for `SHALL`, and a missed requirement for a body with no keyword | +| edge-cases-accompany-specs | Each Requirement Keeps One Primary Scenario | the second-scenario finding | +| edge-cases-accompany-specs | Edge Cases Live Beside The Specification | the edge-case shape finding, and review for a missing decision line | +| edge-cases-accompany-specs | Proofs Cover Every Edge Case | the proof check's unproven report | +| edge-cases-accompany-specs | The Review View Is Generated On Demand | the committed-view finding, and the generator's tests for content and order | + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids | Exit status | +|---|---|---|---|---| +| Requirements Use Three Keyword Levels | `rune spec validate [NAME]` and `rune spec doctor` | `rune-spec-doctor` | `spec-shall-keyword` and `delta-shall-keyword` (unchanged). The parser's `must contain SHALL or MUST` refusal widens to the three levels | nonzero on `SHALL` | +| Each Requirement Keeps One Primary Scenario | `rune spec validate [NAME]` and `rune spec doctor`. Configuration `spec.primary_scenario: warn` (default) or `error` | `rune-spec-doctor` | `spec-second-scenario`, `delta-second-scenario` | 0 while a warning, nonzero once an error | +| Edge Cases Live Beside The Specification | `rune spec validate [NAME]` and `rune spec doctor`. `rune spec archive <id>` carries the companion | `rune-spec-doctor` | `spec-edge-case-invalid`, `delta-edge-case-invalid` | nonzero | +| Proofs Cover Every Edge Case | `rune proof scaffold <change>`, `rune proof run <change>` | none | the unproven verdict line, keyed `<capability>#<requirement>/<edge-case>` | nonzero on an unproven scene | +| The Review View Is Generated On Demand | `rune spec view <change-id>`, `--out <path>` outside the tree | none | `spec-view-committed` | nonzero on a committed view | + +The command name `rune spec view`, the configuration key, and the diagnostic ids are proposed here. The implementer may pick other names, and then updates this table. + +## Structure + +- `rune-docs/src/spec/parse.rs`: the keyword rule accepts MUST, SHOULD, and MAY. The companion parser lives here too, used by the lint, the proof runner, the archiver, and the generator. +- `rune-docs/src/spec/lint.rs`: `lint_scenarios` (second scenario) and `lint_edge_cases` (companion shape), shared by validate and doctor. +- `rune-docs/src/spec/apply.rs`: the companion moves with the delta's requirements into `docs/specs/<capability>/edge-cases.md`. +- `src/cli/proof/mod.rs`: `scaffold` and `run` enumerate edge cases after scenarios. +- `src/cli/spec/`: the generator behind `rune spec view`. +- `rune-docs/templates/spec/delta-spec.md` and `templates/spec/delta-spec.md`: one scenario and a companion stub. + +## Risks + +- A SHOULD or MAY requirement with no MUST fails the OpenSpec checker, which requires `SHALL` or `MUST` in every requirement body. Today it fails the compatibility parser for the same reason. The parser widens to the three levels in this change. The OpenSpec checker does not, so the two checks disagree until OpenSpec accepts the levels or leaves the commit path. The owner decides which check the commit path trusts. +- An unmarked edge case changes meaning and never reaches the owner. A reviewing model can read the companion for unmarked cases that change meaning, as proposed in the review and not decided. Until then it is review work. +- Two sources drift when an author edits a requirement and forgets its companion. The shape finding reports a block under a requirement the specification no longer has. +- The migration leaves scenarios in place and the warning becomes noise. A task raises the level once the tree is clean. diff --git a/docs/changes/edge-cases-accompany-specs/proposal.md b/docs/changes/edge-cases-accompany-specs/proposal.md new file mode 100644 index 00000000..b3d46d55 --- /dev/null +++ b/docs/changes/edge-cases-accompany-specs/proposal.md @@ -0,0 +1,34 @@ +--- +adr: docs/changes/edge-cases-accompany-specs/adr.md +status: proposed +--- + +# Requirement levels and edge cases + +## Why + +Every requirement in this repository uses MUST, so every rule reads equally loud. The edge cases of one rule sit as scenarios right under it. A specification with twenty requirements and three scenarios each is too much text for one owner to read. An edge case is often where a real decision hides. + +The owner asked in the review of 17 October 2026 for one file he has to read that covers the main points. The detail sits beside it where implementers find it, and he is pointed at the detail that changes a decision. The keyword levels of RFC 2119 (Request for Comments 2119, the Internet standard for requirement keywords) give the levels. MoSCoW (must have, should have, could have, will not have) is the planning form of the same scale. This change's record amends CLI-0040 Specification House Rules, which chose one keyword and left the parser's keyword rule unchanged. + +## What Changes + +- A requirement states its level with MUST, SHOULD, or MAY. The validator keeps reporting `SHALL`. +- Each requirement keeps exactly one primary scenario in the specification. The validator reports a second one, as a warning while the repository migrates. +- Edge cases move to a companion file beside the specification. Each is a titled block with WHEN and THEN. A case that narrows, overrides, or contradicts its requirement carries a decision line. +- The proof runner covers every edge case the way it covers every scenario. +- The review view the owner reads is generated on demand from the specification and the marked edge cases. It is never committed, and the validator reports a committed one. +- The compatibility parser keeps reading the specification unchanged, and the companion is invisible to OpenSpec. The parser's keyword rule widens to the three levels, which is the one divergence from OpenSpec this change adds. +- This change uses its own shape: one scenario per requirement in `spec.md`, the rest in `edge-cases.md`. + +## Capabilities + +### New Capabilities + +- `edge-cases-accompany-specs`: three keyword levels, one primary scenario per requirement, edge cases in a companion, proofs over edge cases, and a generated review view. + +## Impact + +- `rune-docs/src/spec/{lint.rs,parse.rs,apply.rs}`, the proof runner and its scene writer, a new review view generator, the delta template, their tests. +- Every active change: scenarios past the first move into companions during the migration. The archiver carries companions into the canonical tree. +- The deck's review skill can read the review view instead of the raw specification. diff --git a/docs/changes/edge-cases-accompany-specs/specs/edge-cases-accompany-specs/edge-cases.md b/docs/changes/edge-cases-accompany-specs/specs/edge-cases-accompany-specs/edge-cases.md new file mode 100644 index 00000000..9dc16664 --- /dev/null +++ b/docs/changes/edge-cases-accompany-specs/specs/edge-cases-accompany-specs/edge-cases.md @@ -0,0 +1,68 @@ +## Edge cases + +### Requirement: Requirements Use Three Keyword Levels + +#### Edge case: Requirement uses the legacy keyword + +- **WHEN** a requirement body in a delta uses `SHALL` as its keyword +- **THEN** the validator reports the legacy keyword finding, as it does today + +#### Edge case: Requirement uses a negated form + +- **WHEN** a requirement body uses MUST NOT or SHOULD NOT +- **THEN** the validator reports nothing for the keyword, because the negated form belongs to its level + +#### Edge case: Requirement uses SHOULD or MAY as its only keyword + +- **WHEN** a requirement body in a delta uses SHOULD or MAY and no MUST +- **THEN** the validator reports nothing for the keyword, and the OpenSpec checker reports a body without `SHALL` or `MUST` +- **DECISION**: contradicts the requirement for a repository that runs the OpenSpec checker beside the validator. The compatibility parser widens to the three levels, the OpenSpec checker does not, and the owner decides which check the commit path trusts. + +### Requirement: Each Requirement Keeps One Primary Scenario + +#### Edge case: Canonical specification carries two scenarios + +- **WHEN** a requirement in a canonical specification has two scenario blocks +- **THEN** the validator reports the second one with the canonical twin of the finding + +#### Edge case: Repository raises the level + +- **WHEN** the repository configuration sets the second-scenario finding to an error after its specifications migrated +- **THEN** the validator reports an error and the change fails validation + +### Requirement: Edge Cases Live Beside The Specification + +#### Edge case: Block lacks a THEN line + +- **WHEN** an edge case block in the companion has a WHEN line and no THEN line +- **THEN** the validator reports the block by its title and the line it lacks + +#### Edge case: Block sits under a requirement the specification lacks + +- **WHEN** the companion holds a requirement heading that the specification beside it does not have +- **THEN** the validator reports the heading, because an edge case without a requirement is a rule nobody decided + +#### Edge case: Edge case carries no decision line + +- **WHEN** an edge case block has WHEN and THEN and no decision line +- **THEN** the validator reports nothing, the proof runner covers it, and the review view leaves it out +- **DECISION**: narrows what the owner reads. An unmarked case is detail for implementers and provers. Whether a model checks that no unmarked case changes meaning stays open. + +#### Edge case: Change is archived + +- **WHEN** the archiver merges a change whose capability has a companion +- **THEN** the archiver merges the companion's blocks into the canonical capability's companion under the same requirement, with the operations it applies to the specification + +### Requirement: Proofs Cover Every Edge Case + +#### Edge case: Recorded proof lacks a scene for an edge case + +- **WHEN** a proof is checked and the companion holds an edge case with no scene in the proof +- **THEN** the proof check reports the edge case by its key as unproven + +### Requirement: The Review View Is Generated On Demand + +#### Edge case: Review view is committed + +- **WHEN** a tracked Markdown file under the specification root starts with the view's generated marker line +- **THEN** the validator reports the file as a committed view, and the change fails validation diff --git a/docs/changes/edge-cases-accompany-specs/specs/edge-cases-accompany-specs/spec.md b/docs/changes/edge-cases-accompany-specs/specs/edge-cases-accompany-specs/spec.md new file mode 100644 index 00000000..174c66fc --- /dev/null +++ b/docs/changes/edge-cases-accompany-specs/specs/edge-cases-accompany-specs/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: Requirements Use Three Keyword Levels + +A requirement body states its level with MUST, SHOULD, or MAY. These are the keyword levels of the Internet standard RFC 2119 (Request for Comments 2119). The negated forms MUST NOT and SHOULD NOT belong to their level. MUST is a rule nothing may break, SHOULD is a rule with a stated reason to break it, and MAY is a choice. The validator MUST keep reporting `SHALL` as a legacy keyword. The levels tell the owner how loud each rule is. + +#### Scenario: Requirement states a SHOULD level + +- **WHEN** a requirement body in a delta uses SHOULD as its keyword +- **THEN** the validator reports nothing for the keyword + +### Requirement: Each Requirement Keeps One Primary Scenario + +A requirement in the specification MUST keep exactly one primary scenario, the case that shows the rule at work. Every other case is an edge case and lives in the companion file. The validator MUST report a second scenario under one requirement and name the companion to move it to. While the repository migrates its specifications the finding is a warning, so existing changes keep validating. The owner reads one rule and one case per requirement. + +#### Scenario: Requirement carries two scenarios + +- **WHEN** a requirement in a delta has two scenario blocks +- **THEN** the validator reports the second one by its title and names the companion file to move it to, and the change still validates + +### Requirement: Edge Cases Live Beside The Specification + +Edge cases MUST live in a companion file beside the specification. The companion repeats each requirement heading and holds one titled block per edge case with WHEN and THEN lines. The archiver MUST carry each block into the canonical companion under the same requirement. An edge case that narrows, overrides, or contradicts its requirement MUST carry a decision line that says which it does and why. The validator MUST report a block that lacks WHEN or THEN, or sits under no requirement. + +#### Scenario: Author records an edge case that narrows its requirement + +- **WHEN** an author adds a companion block under the requirement's heading with WHEN, THEN, and a decision line that says it narrows the rule +- **THEN** the validator reports nothing for the companion, and the edge case is bound to its requirement + +### Requirement: Proofs Cover Every Edge Case + +The proof runner MUST cover every edge case of a change the way it covers every scenario. When it creates a proof it writes one scene per edge case after the scenario scenes. Its check reports an edge case with no recorded scene as unproven. An edge case moved out of the specification loses no proof, so the companion holds detail and the proof still covers it. + +#### Scenario: Proof runner writes a scene per edge case + +- **WHEN** an author creates the proof of a change whose companion holds three edge cases +- **THEN** the proof has one scene per scenario and one scene per edge case, each keyed to its requirement + +### Requirement: The Review View Is Generated On Demand + +The review view the owner reads is a projection. The generator MUST build it on demand from the specification and the edge cases that carry a decision line. The view lists every requirement with its level, its primary scenario, and its marked edge cases, in specification order. The view MUST NOT be committed or edited, and the validator MUST report a committed view. Truth stays in two places that do not overlap: the requirement and its edge cases. + +#### Scenario: Owner asks for the review view of a change + +- **WHEN** the owner asks for the review view of a change with four requirements and two marked edge cases +- **THEN** the view holds the four requirements, their primary scenarios, and the two marked edge cases, and the repository tree gains no file diff --git a/docs/changes/edge-cases-accompany-specs/tasks.md b/docs/changes/edge-cases-accompany-specs/tasks.md new file mode 100644 index 00000000..d50621ff --- /dev/null +++ b/docs/changes/edge-cases-accompany-specs/tasks.md @@ -0,0 +1,29 @@ +## 1. Implementation + +- [ ] 1.1 Parser: the keyword rule accepts MUST, SHOULD, and MAY in a requirement body. The oracle fixtures stay green, and the divergence is recorded in `intentional-differences.md` beside them +- [ ] 1.2 Companion parser: headings mirrored to the requirement, `#### Edge case:` blocks, WHEN, THEN, AND, DECISION +- [ ] 1.3 Lint: `spec-second-scenario` and `delta-second-scenario` behind `spec.primary_scenario` (default `warn`), plus `spec-edge-case-invalid` and `delta-edge-case-invalid` +- [ ] 1.4 Archive carries the companion into `docs/specs/<capability>/edge-cases.md` under the same requirement, with the delta operations +- [ ] 1.5 Proof `scaffold` and `run` enumerate edge cases after scenarios with the key `<capability>#<requirement>/<edge-case>` +- [ ] 1.6 `rune spec view <change-id>`: requirements in order, level, primary scenario, marked edge cases, a marker first line, and nothing written into the tree +- [ ] 1.7 Lint: `spec-view-committed` for a tracked file under the specification root that starts with the marker +- [ ] 1.8 Delta templates: one scenario and a companion stub + +## 2. Verification + +- [ ] 2.1 Lint unit tests: SHOULD and MAY pass, `SHALL` reported, second scenario warned then errored. Companion without THEN and companion under an unknown requirement reported +- [ ] 2.2 Proof tests: a scene per edge case, a missing scene reported as unproven +- [ ] 2.3 Generator tests: content, order, unmarked case left out, marker line, nothing written into the tree +- [ ] 2.4 `cargo test -p rune-docs`, `cargo test --all-features`, `rune spec validate --source .` +- [ ] 2.5 One recorded proof per scenario and edge case under `docs/proofs/edge-cases-accompany-specs/` + +## 3. Migration + +- [ ] 3.1 Move every scenario past the first into a companion, in each active change and the canonical tree. Mark the cases that change meaning +- [ ] 3.2 Raise `spec.primary_scenario` to `error` once the tree reports no warning +- [ ] 3.3 Spec walkthrough, rune-docs changelog, cli changelog + +## 4. Record + +- [ ] 4.1 Archive moves `adr.md` to `docs/decisions/` with the next free CLI number +- [ ] 4.2 Archive adds the new record to the `related:` list of CLI-0040 Specification House Rules and notes the amendment under its `## More Information` diff --git a/docs/changes/every-term-cites-definition/adr.md b/docs/changes/every-term-cites-definition/adr.md new file mode 100644 index 00000000..34385e67 --- /dev/null +++ b/docs/changes/every-term-cites-definition/adr.md @@ -0,0 +1,56 @@ +--- +title: "Ontology as the term source" +description: "rune spec validate and rune validate read defined terms from ontology/rune.ttl, mark a term as a link to its generated glossary page with the first use emphasized, repair drafts and the legacy form with rune spec fix, and refuse to archive a change whose terms are not settled" +type: adr +category: architecture +tags: + - cli + - spec + - ontology +status: proposed +created: 2026-09-23 +updated: 2026-10-18 +author: "@N4M3Z" +project: cli +related: + - "CLI-0040 Specification House Rules" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: ["claude-fable-5-1@claude", "gpt-6-astra@codex"] +informed: [] +upstream: [] +change: every-term-cites-definition +--- + +# Ontology as the term source + +## Context and Problem Statement + +CLI-0040 made an italic term an error unless `docs/specs/glossary.md` carries a `- **term**: definition` bullet. The deck then defined every term once, in `ontology/rune.ttl` with `rdfs:label` and `rdfs:comment`, and wrote two scripts: one generated the glossary from the ontology so the validator would accept the terms, the other checked that the first italic use of a term cites its term address, an IRI (internationalized resource identifier), as `*term* [TAG]` with `[TAG]: https://runedeck.ai/ns#Term`. The generated file drifted whenever the ontology changed without a regeneration, the citation check ran only where someone invoked the script, and neither check reached decision records or runes, where the same terms appear. Italics marked a term with a form no reader could tell from emphasis, and the IRI definition led a reader to raw Turtle or to nothing. rumdl already reports an undefined `[TAG]` (MD052) and an unused definition (MD053), so those two checks need no second home. + +## Considered Options + +1. **Keep the scripts and the generated glossary**, and add a CI step per repository. Every repository copies two scripts and a regeneration hook, and the citation rule stays outside `rune spec validate`. +2. **Read the ontology in `rune spec validate`**, mark a term as a link to its generated glossary page, print the list with `rune spec glossary`, repair with `rune spec fix`, and run the same rules from `rune validate` over records and runes. +3. **Parse the ontology with an RDF library.** Correct for every Turtle form, and a dependency the crate does not otherwise carry, for a file whose shape the deck controls. + +## Decision Outcome + +Option 2, with the mark changed from italics to a link. + +`rune-docs/src/spec/terms.rs` reads every labeled class, property, and SKOS concept from the ontology with two regular expressions over the Turtle statement shape the deck writes. A term is marked as a Markdown link to its generated glossary page: `*[text][TAG]*` at the first use in a file, `[text][TAG]` after it, and `[TAG]: <relative page path>` at the end of the file. The lint resolves the link through the `iri` the page records, and through the tag when the page is absent, so `spec-term-undefined` and `delta-term-undefined` keep their names. The lint also reports a draft term (`spec-term-draft`), a first use that is not emphasized or a later use that is (`spec-term-first-use`), a page that is missing or differs from the render (`spec-term-page`), and the legacy `*term* [TAG]` form with a definition that names the term address (`spec-term-legacy`), which still resolves. Plain italics are not checked. A repository without an ontology has no terms, so a reference link there is an ordinary link. + +`rune spec glossary` prints the list with tags in upper case, built from the label with a `-KEY` suffix where a property collides with a class, and a page definition per term. `rune spec fix --terms` adds a draft entry for every link that names no term and writes its page, and `rune spec fix --links` migrates the legacy form, as a step separate from validation, because a validator that edits is no longer a validator. `rune spec archive` refuses a change whose files link to a draft term or to no term. + +`rune validate` applies the term rules to `docs/decisions` and `runes` of a repository that carries a deck marker and to the record and rune directories of a module root, when the root has an ontology, and reports nothing otherwise. YAML front matter and fenced code are skipped everywhere. `deck.yaml` may name the ontology under `ontology`, and the crate receives the path through a lookup hook like `spec.root`. + +### Consequences + +- [+] One definition per term, in the ontology, and one validator for its use, in `rune`. The deck's scripts become fallbacks for a machine without rune, and the single glossary file goes away. +- [+] A reader reaches the term's page from any file, and a vault draws the links as a graph. +- [+] Records and runes get the same check as specifications, from the command that already validates them. +- [-] The Turtle reader is a statement-shape parser. It reads `prefix:Name a <type> ... .` with the period ending a line, `rdf:type` for `a`, comment lines, and the `\"` and `\\` escapes. A term written another way (a full-IRI subject, a type list, a triple-quoted literal) is invisible until someone writes it the deck's way. What the reader can detect it refuses: an undeclared prefix, two terms of one kind whose labels match without case, and an ontology with no labeled term are errors. A class and a property may share a label (`Change` and `change` in the deck). The page decides which one a link names, and the property's page and tag carry a key suffix. +- [-] The Markdown side is a line scanner. A link is `[text][TAG]`, emphasized when single asterisks wrap it, code is a backtick or tilde fence or a backtick span, and a front matter block is skipped when `---` opens the file and `---` or `...` closes it. Indented code blocks and HTML comments are scanned as prose. +- [-] Every use of a term is now a link, and a file with many terms ends in a reference block. `rune spec glossary` prints the definitions to paste, and `rune spec fix --links` converts a file. +- [-] The `Defined terms have a glossary entry` requirement of `specs-must-stay-short` becomes the link rule, and this change's requirement supersedes it once both archive. Until then the two deltas describe one rule twice. +- [-] A consumer's ontology is an installed copy with a manifest fingerprint. The draft pass of the fixer would edit that copy and break the fingerprint, so a consumer runs only the links pass. diff --git a/docs/changes/every-term-cites-definition/design.md b/docs/changes/every-term-cites-definition/design.md new file mode 100644 index 00000000..87053c7b --- /dev/null +++ b/docs/changes/every-term-cites-definition/design.md @@ -0,0 +1,41 @@ +# Ontology Term References Design + +## Approach + +A term is marked by a Markdown link to its generated glossary page. The *[validator][VALIDATOR]* reads the term address from the page and matches it to the ontology, so the link text and the tag carry no meaning of their own. This beat the italics model, which marked a term with a form no reader could tell from emphasis, and it beat a parenthetical definition at first use, which copies the definition into every file. The fixer is a separate step, because a validator that edits files is no longer a validator. + +## Structure + +- `rune-docs/src/spec/terms.rs` reads every labeled class, property, and concept from the Turtle statement shape the deck writes. It derives the tag from the label, with a `-KEY` suffix on a collision, and names the page `kebab(label).md`, with `-key` on a collision. +- `rune-docs/src/spec/term_links.rs` parses the link forms and the definitions, resolves a link through the page's `iri:` line and then the tag, and walks the Markdown files of a repository. +- `rune-docs/src/spec/lint.rs` holds the five term rules. `rune-docs/src/spec/prose.rs` blanks code and front matter byte-aligned so the fixer can rewrite spans. +- `rune-docs/src/spec/fix.rs` holds the two fixer passes. `rune-docs/src/spec/commands.rs` refuses an archive that merges unsettled terms. +- `src/cli/spec.rs` and `src/cli/validate/mod.rs` adapt the commands. + +## Commands + +One row per requirement of the delta. The requirement names a role. This table names the program. Every validate row is `rune spec validate` and `rune spec doctor`, and the `rune-spec-doctor` hook runs doctor on the commit path. + +| Requirement | Command and flags | Hook | Diagnostic ids | Exit status | +|---|---|---|---|---| +| Defined terms link to the ontology | validate | `rune-spec-doctor` | `spec-term-undefined`, `delta-term-undefined` | 1 | +| The ontology is the term source | validate. The ontology is `ontology/rune.ttl` unless `deck.yaml` names another path under `ontology` | | `spec-term-draft`, `delta-term-draft` (warning) | 0 on a warning alone | +| A broken ontology fails validation | validate | | none. The error carries no id and names the ontology path | 1 | +| First use of a term is emphasized | validate | | `spec-term-first-use`, `delta-term-first-use` | 1 | +| Term pages back every link | validate. Pages live under `docs/specs/glossary/` | `glossary-current` (deck) | `spec-term-page`, `delta-term-page` | 1 | +| The legacy form warns | validate | | `spec-term-legacy`, `delta-term-legacy` (warning) | 0 | +| Reference definitions follow Markdown | validate | | none | | +| The generator lists every term | `rune spec glossary [--json]`. A definition reads `[TAG]: docs/specs/glossary/<page>.md`. The JSON carries `source` (the ontology path), `glossary` (the directory), and under `terms` each term's `label`, `comment`, `name`, `iri`, `kind`, `tag`, `page`, and `status` | | none | 0 | +| The fixer adds draft terms | `rune spec fix --terms [--source DIR] [--json]`. The draft block is `rune:<Name> a rdfs:Class ; rdfs:label "<text>" ; rune:status "draft" .` | `fix-terms` (deck, before `check-terms`) | none. A skipped link prints its reason | 0 | +| The fixer migrates the legacy form | `rune spec fix --links`. No flag runs both passes. The walk skips `.git`, `.jj`, `.provenance`, `.specstory`, `.trash`, `.workspaces`, `.worktrees`, `archive`, `build`, `fixtures`, `node_modules`, `target`, and the glossary directory | | none | 0 | +| Archive refuses unsettled terms | `rune spec archive <id>`. `--abandon` is not refused | | an error that lists `draft '<label>' as [text][TAG]: path:line` and `undefined [text][TAG]: path:line` | 1 | +| Records and runes are checked | `rune validate`, the `terms` item, over `docs/decisions` and `runes` of a repository that carries a deck marker, and `docs/decisions`, `agents`, `rules`, `skills` of a module root | | none, messages only | 1 | + +## Risks + +- The Turtle reader is a statement-shape parser. A term written another way (a subject written as a full address, a type list, a triple-quoted literal) is invisible until someone writes it the deck's way. What the reader can detect it refuses. +- The Markdown side is a line scanner. Indented code blocks and HTML comments are scanned as prose. A bold link `**[text][TAG]**` is read as an emphasized link. +- The fixer drafts the label from the link text as written, so a plural link text drafts a plural term. The tests cover the singular case. +- An installed ontology copy carries a manifest fingerprint. Running the draft fixer in a consumer edits that copy and breaks the fingerprint, so the draft pass belongs in the repository that owns the ontology. + +[VALIDATOR]: ../../specs/glossary/validator.md diff --git a/docs/changes/every-term-cites-definition/proposal.md b/docs/changes/every-term-cites-definition/proposal.md new file mode 100644 index 00000000..c6f3ac76 --- /dev/null +++ b/docs/changes/every-term-cites-definition/proposal.md @@ -0,0 +1,27 @@ +--- +adr: docs/changes/every-term-cites-definition/adr.md +status: proposed +decisions: ["Ontology as the term source"] +--- + +# Ontology Term References + +## Why + +CLI-0040 made an italic term an error unless `docs/specs/glossary.md` defines it. The deck then defined every term once, in `ontology/rune.ttl`, generated the glossary from it with a script, and checked the citations with a second script. Two scripts and a generated file carried what belongs in one validator. Italics also marked a term with a form no reader could tell from emphasis, so a term carried meaning only the checker saw. This change moves the checks into `rune spec validate` and `rune validate`, marks a term as a link to its generated glossary page, adds `rune spec fix` for the repairs a validator must not make, and has archive refuse a change whose terms are not settled. + +## What Changes + +- A term is a Markdown link to its glossary page: emphasized at the first use in a file, plain after it, with a reference definition at the end of the file that points at the page by relative path. Plain italics are emphasis and are not checked. +- The term source is the ontology. A repository without one has no terms, so the glossary fallback goes away. A term may carry a draft status. +- The validator reports a link that names no term, a draft term, a first use that is not emphasized or a later use that is, a page that is missing or stale, and the legacy italics form, which still resolves and warns with the new form to write. The design's Commands table names the diagnostic ids. rumdl MD052 and MD053 keep reporting an undefined and an unused tag. +- `rune spec glossary` prints the term list with tags and page definitions. +- `rune spec fix --terms` adds a draft entry and a page for every link that names no term. `rune spec fix --links` migrates the legacy form in every Markdown file. Both change nothing on a second run. +- `rune spec archive` refuses a change whose files link to a draft term or to no term. +- `rune validate` applies the term rules to `docs/decisions` and `runes` of a repository that carries a deck marker, and to the record and rune directories of a module, when an ontology exists. +- The `Defined terms have a glossary entry` requirement of `specs-must-stay-short` becomes the link rule, and this change's `Defined terms link to the ontology` supersedes it once both changes archive. + +## Scope + +- `rune-docs/src/spec/{terms,term_links,lint,prose,fix,glossary,commands,mod}.rs`, `src/cli/spec.rs`, `src/cli/validate/mod.rs`, and `src/cli/mod.rs`. +- Not in scope: assembly stripping the links from an instance and adding a per-instance definition block (a later change), reading `context.jsonld` for the graph export (cli #70), or any change to the ontology itself beyond the draft status. diff --git a/docs/changes/every-term-cites-definition/specs/lifecycle-of-a-change/spec.md b/docs/changes/every-term-cites-definition/specs/lifecycle-of-a-change/spec.md new file mode 100644 index 00000000..e2859433 --- /dev/null +++ b/docs/changes/every-term-cites-definition/specs/lifecycle-of-a-change/spec.md @@ -0,0 +1,121 @@ +## ADDED Requirements + +### Requirement: Defined terms link to the ontology + +A defined term MUST be a Markdown link to its glossary page. This holds in a *[capability][CAPABILITY]*, a *[delta specification][DELTASPECIFICATION]*, a *[decision record][DECISIONRECORD]*, and a *[rune][RUNE]*. The *[validator][VALIDATOR]* MUST resolve the link through the term address the page records, or through the tag when the page is absent. A link that resolves to no ontology term MUST be reported as an error. A plain italic run is emphasis and MUST NOT be reported. + +#### Scenario: Term link names no term + +- **WHEN** a specification links a term and no ontology term matches the page or the tag +- **THEN** the validator reports the term, the file and line, and the ontology's location + +### Requirement: The ontology is the term source + +The validator MUST read every labeled class, property, and concept from the repository's ontology. A repository without an ontology file has no defined terms. A reference link there is an ordinary link and MUST NOT be reported. A term MAY carry a draft status. The validator MUST warn once for each draft term a file links. + +#### Scenario: Repository without an ontology + +- **WHEN** the repository has no ontology file and a file carries a reference link +- **THEN** the validator reports nothing for terms + +### Requirement: A broken ontology fails validation + +An unreadable ontology, an undeclared prefix, or an ontology with no labeled term MUST fail validation. Two terms of one kind whose labels match without case MUST fail validation too. The failure MUST name the ontology's location. A class and a property MAY share a label. + +#### Scenario: Two classes share a label + +- **WHEN** the ontology labels one class `Thing` and another class `thing` +- **THEN** validation fails and names both term addresses + +#### Scenario: Class and property share a label + +- **WHEN** the class `Change` and the property `change` are both labeled `change` +- **THEN** the property's page and tag take a key suffix, the class's stay plain, and each link resolves to the term its page names + +### Requirement: First use of a term is emphasized + +The first link to a term in a file MUST be emphasized. Every later link to the same term in that file MUST be plain. The validator MUST report a first use that is not emphasized and a later use that is, with the form to write. The link text MAY be the plural of the label. A file MUST define each tag it uses as a reference definition. The definition MUST point at the term's page by a path relative to the file. The file SHOULD hold each definition once, at its end. + +#### Scenario: First use is plain + +- **WHEN** a record links a term twice and the first link is not emphasized +- **THEN** the validator reports the line and prints the emphasized form to write + +### Requirement: Term pages back every link + +Every term link MUST reach a glossary page whose bytes equal the generator's render of that term. The validator MUST report a page that is missing and a page whose bytes differ from the render. It MUST also report a tag the file uses but never defines. Each report MUST name the definition line or the regeneration step that repairs it. + +#### Scenario: Page missing for a defined term + +- **WHEN** a file links a term the ontology defines and no page exists for it +- **THEN** the validator names the page and the step that writes it + +### Requirement: The legacy form warns + +An italic term with its tag after it, and a definition that names the term address instead of a page, is the legacy form. The validator MUST still resolve it, MUST warn, and MUST name the link form and the relative page path to write. A link whose definition names the term address instead of a page MUST be treated the same way. + +#### Scenario: Legacy form in a delta + +- **WHEN** a delta writes an italic term with its tag after it and defines the tag as the term address +- **THEN** the validator warns and prints the emphasized link and the page definition to write + +### Requirement: Reference definitions follow Markdown + +A tag MUST compare without case at its use and its definition. A definition destination MAY be wrapped in angle brackets. A definition MAY be indented up to three spaces. The first definition of a tag in a file MUST win. + +#### Scenario: Lowercase tag with a bracketed destination + +- **WHEN** a record links a term with a lower-case tag and defines the tag with its destination in angle brackets +- **THEN** the validator reports nothing for the term + +### Requirement: The generator lists every term + +On request the generator MUST print one line per ontology term, sorted by label, with the label, the comment, and the tag. It MUST then print one definition per term that points at the term's page. The tag MUST be the label in upper case with every non-letter removed. When a property's label collides with a class label, the class keeps the plain tag. The property then takes a key suffix until its tag is unique. The structured output MUST carry every term with its page and status. + +#### Scenario: Property and class share a name + +- **WHEN** the ontology defines the class `Change` and the property `change` +- **THEN** the class takes the plain tag, the property the suffixed tag, and each definition points at its own page + +### Requirement: The fixer adds draft terms + +The fixer is a separate step the author invokes or a hook runs before the validator. It MUST add a draft entry to the ontology for every term link that names no term. The entry MUST be labeled with the link text and named by its upper camel case. The fixer MUST write the term's page. It MUST skip a link whose page names no term, whose text gives no name, or whose name already exists under another label. It MUST name the reason for each skip. A second run MUST change nothing. + +#### Scenario: Link names an undefined term + +- **WHEN** a record links a term the ontology lacks and the author runs the fixer +- **THEN** the ontology holds a draft entry for the term, its page exists, and the validator warns on the draft instead of failing + +### Requirement: The fixer migrates the legacy form + +The fixer MUST rewrite the legacy form to the link form in every Markdown file of the repository. It MUST skip version control trees, archived changes, generated trees, and test fixtures. In each file it MUST emphasize the first use of a term and make later uses plain. It MUST point every definition at the term's page by relative path and add a missing definition. A converted file MUST be left unchanged by a second run. + +#### Scenario: Legacy file is migrated twice + +- **WHEN** the fixer runs twice over a file in the legacy form +- **THEN** the first run rewrites it to the link form and the second run reports no change + +### Requirement: Archive refuses unsettled terms + +The archiver MUST refuse to merge a *[change][CHANGE]* whose files link to a draft term or to no term. It MUST name each such link by file and line with the step that settles it. Abandoning a change MUST NOT be refused for its terms, because nothing reaches the canonical specifications. + +#### Scenario: Change links a draft term + +- **WHEN** the owner archives a change and one of its files links a term whose entry is a draft +- **THEN** the archiver refuses, names the file, the line, and the term, and the canonical specifications stay unchanged + +### Requirement: Records and runes are checked + +The validator MUST apply the term rules to every Markdown file under the decision records and runes of a repository with a deck marker. In a module root it MUST cover the records, agents, rules, and skills. It MUST do so only when the root has an ontology. A root without one MUST report nothing for terms. It MUST skip provenance, archive, and build trees, fenced code, and a YAML front matter block. + +#### Scenario: Rule links an undefined term + +- **WHEN** a rule under the runes directory links a term the ontology lacks +- **THEN** artifact validation fails the terms item and names the file and line + +[CAPABILITY]: ../../../../specs/glossary/capability.md +[CHANGE]: ../../../../specs/glossary/change.md +[DECISIONRECORD]: ../../../../specs/glossary/decision-record.md +[DELTASPECIFICATION]: ../../../../specs/glossary/delta-specification.md +[RUNE]: ../../../../specs/glossary/rune.md +[VALIDATOR]: ../../../../specs/glossary/validator.md diff --git a/docs/changes/ontology-term-references/tasks.md b/docs/changes/every-term-cites-definition/tasks.md similarity index 51% rename from docs/changes/ontology-term-references/tasks.md rename to docs/changes/every-term-cites-definition/tasks.md index b8f28e38..3d4cb71c 100644 --- a/docs/changes/ontology-term-references/tasks.md +++ b/docs/changes/every-term-cites-definition/tasks.md @@ -1,7 +1,7 @@ ## 1. Implementation -- [x] 1.1 `terms.rs`: read labeled classes, properties, and SKOS concepts from Turtle, the glossary fallback, and tags with the `-KEY` rule -- [x] 1.2 `lint.rs`: term lookup through the source, `term-reference-missing` and `term-reference-unresolved`, and `lint_document` for records and runes +- [x] 1.1 `terms.rs`: read labeled classes, properties, and SKOS concepts from Turtle, and tags with the `-KEY` rule +- [x] 1.2 `lint.rs`: term lookup through the source, and `lint_document` for records and runes - [x] 1.3 `rune spec glossary` with `--json`, and the `deck.yaml` `ontology` key through the lookup hook - [x] 1.4 `rune validate`: term check over `docs/decisions` and `runes` (deck) or the module content directories, when an ontology exists @@ -15,3 +15,13 @@ ## 3. Documentation - [x] 3.1 Spec walkthrough house rules, cli CHANGELOG, rune-docs CHANGELOG, ADR CLI-0043 + +## 4. Link model + +- [x] 4.1 `term_links.rs`, `prose.rs`: the link forms, the definitions, page resolution through `iri`, the byte-aligned prose view. `lint.rs`: the five term rules, italics unchecked. The glossary fallback removed from `terms.rs` +- [x] 4.2 `fix.rs`: `rune spec fix --terms` and `--links`, no flag runs both. `commands.rs`: archive refuses a draft or undefined term link +- [x] 4.3 Unit tests per rule in `lint/tests.rs`, `tests/spec_terms.rs` for the pages, the link rules, `fix`, and the archive refusal +- [x] 4.4 The delta, `design.md` with the Commands table, this change's record, the cli CHANGELOG +- [ ] 4.5 `docs/walkthroughs/Spec.md` house rules section, `rune-docs/CHANGELOG.md` +- [ ] 4.6 Proof under `docs/proofs/every-term-cites-definition/` +- [ ] 4.7 Assembly strips the links from an instance and adds a per-instance definition block (a later change) diff --git a/docs/changes/artifact-implementation-loop/.openspec.yaml b/docs/changes/evidence-cannot-go-stale/.openspec.yaml similarity index 100% rename from docs/changes/artifact-implementation-loop/.openspec.yaml rename to docs/changes/evidence-cannot-go-stale/.openspec.yaml diff --git a/docs/changes/artifact-implementation-loop/adr.md b/docs/changes/evidence-cannot-go-stale/adr.md similarity index 100% rename from docs/changes/artifact-implementation-loop/adr.md rename to docs/changes/evidence-cannot-go-stale/adr.md diff --git a/docs/changes/artifact-implementation-loop/design.md b/docs/changes/evidence-cannot-go-stale/design.md similarity index 66% rename from docs/changes/artifact-implementation-loop/design.md rename to docs/changes/evidence-cannot-go-stale/design.md index af51d283..d1c8c0e6 100644 --- a/docs/changes/artifact-implementation-loop/design.md +++ b/docs/changes/evidence-cannot-go-stale/design.md @@ -46,3 +46,16 @@ The runner executes reviewed local commands and is not a sandbox. 2. Publish the runbook and an example contract. 3. Route skill authoring through the runbook in the separate content PR. 4. Require a reviewed contract and external verifier ownership for future implementation runs. + +## Commands + +The delta already names roles (the coordinator, the runner, the reviewers, the validators) and no +command, flag, or path moved out of it. This table records what implements each requirement, as +the [runbook](../../evidence-cannot-go-stale.md) describes it. + +| Requirement | Command and flags | Hook | Diagnostic ids and files | Exit status | +|---|---|---|---|---| +| The run starts from a reviewed contract | `python3 scripts/run-artifact-checks.py freeze --root <DIR> --manifest <FILE> --runner-sha256 <SHA> --manifest-sha256 <SHA> --output <FILE>`. The example manifest is `docs/examples/artifact-checks.json`. | none | "The reviewed pin does not match" | 1 on a pin mismatch | +| Required execution fails closed | `python3 scripts/run-artifact-checks.py run ... --snapshot <FILE> --snapshot-sha256 <SHA>`. Adapters are `command` (exit zero only, no test count) and the counted test adapters. | none | missing executable, deadline expired, zero tests counted | 1 when the receipt is not accepted | +| Receipts bind to current inputs | The receipt is the runner's output file outside the candidate source. | none | changed input, changed executable | 1 on a change during validation | +| Repair and restart preserve evidence limits | The procedure in the runbook. No command. | none | none | none | diff --git a/docs/changes/artifact-implementation-loop/proposal.md b/docs/changes/evidence-cannot-go-stale/proposal.md similarity index 86% rename from docs/changes/artifact-implementation-loop/proposal.md rename to docs/changes/evidence-cannot-go-stale/proposal.md index aff257f2..46e8864a 100644 --- a/docs/changes/artifact-implementation-loop/proposal.md +++ b/docs/changes/evidence-cannot-go-stale/proposal.md @@ -18,7 +18,7 @@ Model reviews remain necessary for requirements that have no deterministic check ### New Capabilities -- `artifact-implementation-loop`: Reproduce reviewed artifact checks and their repair procedure against identified inputs. +- `evidence-cannot-go-stale`: Reproduce reviewed artifact checks and their repair procedure against identified inputs. ### Modified Capabilities @@ -32,5 +32,5 @@ The existing source-layer validator supplies generic, harness, and model checks. Existing repository hooks and signing rules remain authoritative. The runner does not start model sessions, publish changes, or alter approval metadata. -The [implementation runbook](../../artifact-implementation-loop.md) defines the reusable procedure. +The [implementation runbook](../../evidence-cannot-go-stale.md) defines the reusable procedure. The [example contract](../../examples/artifact-checks.json) exercises the runner in a disposable checkout. diff --git a/docs/changes/artifact-implementation-loop/specs/artifact-implementation-loop/spec.md b/docs/changes/evidence-cannot-go-stale/specs/evidence-cannot-go-stale/spec.md similarity index 91% rename from docs/changes/artifact-implementation-loop/specs/artifact-implementation-loop/spec.md rename to docs/changes/evidence-cannot-go-stale/specs/evidence-cannot-go-stale/spec.md index de504e1c..a050648b 100644 --- a/docs/changes/artifact-implementation-loop/specs/artifact-implementation-loop/spec.md +++ b/docs/changes/evidence-cannot-go-stale/specs/evidence-cannot-go-stale/spec.md @@ -6,7 +6,7 @@ The coordinator MUST identify requirements, decision records, writable paths, in checks before implementation. Expected runner and manifest hashes MUST come from outside the worker's editable scope. -#### Scenario: A worker changes the required check list +#### Scenario: Worker changes the required check list - **WHEN** the candidate manifest differs from the coordinator's expected hash - **THEN** the runner refuses execution @@ -18,12 +18,12 @@ The runner MUST execute reviewed argument arrays without a shell and enforce exp It MUST reject missing, duplicate, malformed, failed, skipped, or empty required test evidence. It MUST distinguish command-only lint results from counted tests. -#### Scenario: A test command succeeds without tests +#### Scenario: Test command succeeds without tests - **WHEN** a counted test adapter observes zero executed tests - **THEN** the check fails despite a zero process exit code -#### Scenario: A required command cannot finish +#### Scenario: Required command cannot finish - **WHEN** its executable is absent or its deadline expires - **THEN** the run records failure @@ -35,7 +35,7 @@ Each receipt MUST identify its runner, manifest, declared source inventory, exec The runner MUST compare source and executable identities before and after execution. Receipts MUST reside outside candidate source. -#### Scenario: A validator changes a declared input +#### Scenario: Validator changes a declared input - **WHEN** an input changes during validation - **THEN** the run fails diff --git a/docs/changes/artifact-implementation-loop/tasks.md b/docs/changes/evidence-cannot-go-stale/tasks.md similarity index 100% rename from docs/changes/artifact-implementation-loop/tasks.md rename to docs/changes/evidence-cannot-go-stale/tasks.md diff --git a/docs/changes/artifact-implementation-loop/validation.md b/docs/changes/evidence-cannot-go-stale/validation.md similarity index 100% rename from docs/changes/artifact-implementation-loop/validation.md rename to docs/changes/evidence-cannot-go-stale/validation.md diff --git a/docs/changes/automated-tool-run/design.md b/docs/changes/execute-prompts-off-session/design.md similarity index 100% rename from docs/changes/automated-tool-run/design.md rename to docs/changes/execute-prompts-off-session/design.md diff --git a/docs/changes/automated-tool-run/proposal.md b/docs/changes/execute-prompts-off-session/proposal.md similarity index 86% rename from docs/changes/automated-tool-run/proposal.md rename to docs/changes/execute-prompts-off-session/proposal.md index 07f4a404..a9879615 100644 --- a/docs/changes/automated-tool-run/proposal.md +++ b/docs/changes/execute-prompts-off-session/proposal.md @@ -18,9 +18,9 @@ Automation needs a stable coding-tool command that reuses launch profiles and pr ## Capabilities -- harness-run-command (new) -- harness-launch-command (modified) -- bench-suite-runner (modified) +- execute-prompts-off-session (new) +- convenient-cross-harness-launcher (modified) +- benchmark-results-stay-compatible (modified) ## Impact diff --git a/docs/changes/automated-tool-run/specs/harness-run-command/spec.md b/docs/changes/execute-prompts-off-session/specs/execute-prompts-off-session/spec.md similarity index 100% rename from docs/changes/automated-tool-run/specs/harness-run-command/spec.md rename to docs/changes/execute-prompts-off-session/specs/execute-prompts-off-session/spec.md diff --git a/docs/changes/automated-tool-run/tasks.md b/docs/changes/execute-prompts-off-session/tasks.md similarity index 100% rename from docs/changes/automated-tool-run/tasks.md rename to docs/changes/execute-prompts-off-session/tasks.md diff --git a/docs/changes/community-deck-discovery/design.md b/docs/changes/find-decks-by-topic/design.md similarity index 100% rename from docs/changes/community-deck-discovery/design.md rename to docs/changes/find-decks-by-topic/design.md diff --git a/docs/changes/community-deck-discovery/proposal.md b/docs/changes/find-decks-by-topic/proposal.md similarity index 95% rename from docs/changes/community-deck-discovery/proposal.md rename to docs/changes/find-decks-by-topic/proposal.md index 6697684f..e32bd558 100644 --- a/docs/changes/community-deck-discovery/proposal.md +++ b/docs/changes/find-decks-by-topic/proposal.md @@ -18,7 +18,7 @@ but nothing lists what the community publishes. Governing decision: CLI-0033. ## Capabilities -- community-deck-discovery (new) +- find-decks-by-topic (new) ## Impact diff --git a/docs/changes/community-deck-discovery/specs/community-deck-discovery/spec.md b/docs/changes/find-decks-by-topic/specs/find-decks-by-topic/spec.md similarity index 100% rename from docs/changes/community-deck-discovery/specs/community-deck-discovery/spec.md rename to docs/changes/find-decks-by-topic/specs/find-decks-by-topic/spec.md diff --git a/docs/changes/community-deck-discovery/tasks.md b/docs/changes/find-decks-by-topic/tasks.md similarity index 100% rename from docs/changes/community-deck-discovery/tasks.md rename to docs/changes/find-decks-by-topic/tasks.md diff --git a/docs/changes/glossary-renders-from-ontology/design.md b/docs/changes/glossary-renders-from-ontology/design.md new file mode 100644 index 00000000..5b7fec62 --- /dev/null +++ b/docs/changes/glossary-renders-from-ontology/design.md @@ -0,0 +1,25 @@ +# Render Spec Glossary Design + +## Approach + +One generated page per term, the way a vault keeps one note per concept, so a term link from any file reaches a page a reader can open and a graph tool can draw. This beat a single glossary file with an anchor per term, which no link checker resolves and no graph shows as a node. The check regenerates in memory and compares bytes, so a hand edit is caught and the ontology stays the only place a term is defined. + +## Structure + +- `rune-docs/src/spec/glossary.rs` renders the page and index bytes, writes the directory, removes stale pages and the single file, and compares a directory with the render. +- `rune-docs/src/spec/terms.rs` supplies the label, comment, address, kind, status, tag, and page name per term. +- `src/cli/mod.rs` and `src/cli/spec.rs` carry the two flags. + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids | Exit status | +|---|---|---|---|---| +| The glossary is rendered from the ontology | `rune spec glossary --write [PATH]`. PATH defaults to `glossary/` beside the specifications, `docs/specs/glossary/` in the native tree, and a relative PATH resolves against `--source`. Prints `wrote <dir>: N page(s) and the index, M file(s) changed` | | none | 0 | +| A page carries one term | the same. The page is `kebab(label).md` with `-key` on a collision: `change.md` and `change-key.md`, `occams-razor.md`. Front matter keys `title`, `iri`, `kind`, `status` | | none | 0 | +| The check reports drift | `rune spec glossary --check [PATH]`, exclusive with `--write`. Prints `<dir> is current`, or `<dir> is stale: run <write command>` with one `missing`, `extra`, or `changed` line per page, or `<dir> is missing: run <write command>` | `glossary-current` (deck) | none | 0 current, 1 stale or missing, 2 for both flags | + +## Risks + +- The page bytes are a contract shared with the deck's fallback generator. A change to the page shape breaks the deck's hook until the fallback matches it again. +- A write removes every Markdown file in the directory that the render does not produce, because the directory is generated. A hand-written page there is lost on the next write. A check before the write reports it as extra. +- The proof under `docs/proofs/glossary-renders-from-ontology/` was recorded against the single file and must be recorded again for the directory. diff --git a/docs/changes/glossary-renders-from-ontology/proposal.md b/docs/changes/glossary-renders-from-ontology/proposal.md new file mode 100644 index 00000000..d42540e8 --- /dev/null +++ b/docs/changes/glossary-renders-from-ontology/proposal.md @@ -0,0 +1,28 @@ +--- +status: proposed +--- + +# Render Spec Glossary + +## Why + +A reader of a specification meets a term and wants its definition one hop away. That hop exists only when the repository carries a human-readable glossary a link can reach. The ontology, `ontology/rune.ttl`, is the single home of every term, and it is Turtle, which a reader of Markdown does not open. `rune spec glossary` prints the term list to a terminal, so the list lives in no file a reader or a link can reach. A single glossary file would need an anchor per term, which no link checker resolves and no graph tool draws as a node. The deck keeps a Python script that writes and checks the glossary for a machine without the command, and that script is a second renderer of the same bytes. + +OpenSpec has no glossary artifact or command: the `dist/` tree of openspec 1.12.0 holds no match for the word, and its README links only the project's own documentation page. The CLI owns the files. + +## What Changes + +- `rune spec glossary --write [PATH]` writes one page per labeled term and an index into the glossary directory from the ontology. A page carries the label, the address, the kind, and the status as front matter, then the label as a heading, the comment, and a generated-from line. PATH defaults to `glossary/` beside the capability specifications. A write removes the page of a term the ontology no longer defines and the single `glossary.md` an older render wrote. +- `rune spec glossary --check [PATH]` renders every page and the index in memory, compares the directory byte for byte, names each page that is missing, extra, or changed, and exits 1 on drift or a missing directory. +- The bare command and `--json` keep printing the term list, with a page definition per term. +- Term links in every file point at the pages (the change `every-term-cites-definition`). + +## Capabilities + +- glossary-renders-from-ontology (new) + +## Impact + +- `rune-docs/src/spec/glossary.rs` (new), `terms.rs`, `src/cli/spec.rs`, `src/cli/mod.rs`, `tests/spec_terms.rs`. +- `docs/walkthroughs/Spec.md`, the two changelogs, and the proof under `docs/proofs/glossary-renders-from-ontology/`, which was recorded against the single file and must be recorded again. +- The deck's `scripts/fallback/generate-glossary` writes the same bytes for a machine without rune and hands over to the command when the installed rune writes a directory. The deck's `glossary-current` hook runs the check. diff --git a/docs/changes/glossary-renders-from-ontology/specs/glossary-renders-from-ontology/spec.md b/docs/changes/glossary-renders-from-ontology/specs/glossary-renders-from-ontology/spec.md new file mode 100644 index 00000000..2645a557 --- /dev/null +++ b/docs/changes/glossary-renders-from-ontology/specs/glossary-renders-from-ontology/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: The glossary is rendered from the ontology + +The glossary is a *[render][RENDER]* of the ontology. The generator MUST write one page per labeled ontology term and one index. They go into the glossary directory beside the canonical specifications, or into a directory the owner names. The index MUST list every page sorted by label with the term's comment. One ontology MUST produce the same bytes on every write. A write MUST remove the page of a term the ontology no longer defines and the single glossary file an older render wrote. + +#### Scenario: Two writes read one ontology + +- **WHEN** the owner runs the write twice over one ontology +- **THEN** every page and the index hold the same bytes after each write, and every labeled term has a page + +### Requirement: A page carries one term + +A page MUST carry the label, the term address, the kind, and the status as front matter. The body MUST hold the label as a heading, the comment as one paragraph, and a line that names the ontology to edit. A page's name MUST be the label in lower case with apostrophes dropped. Every run of other characters MUST become one hyphen, trimmed at both ends. A property whose label matches a class label MUST take a key suffix. A draft term's page MUST carry the draft sentence instead of a comment. + +#### Scenario: Property label collides with a class label + +- **WHEN** the ontology labels the class `Change` and the property `change` the same +- **THEN** the class keeps the plain page name, the property's page carries the key suffix, and each page records its own term address + +### Requirement: The check reports drift + +The check MUST regenerate every page and the index in memory and compare the directory with them byte for byte. A match MUST pass. A difference MUST name the directory, each page that is missing, extra, or changed, and the write step, then fail. A missing directory MUST name the directory and fail. A run that asks for a write and a check together MUST be refused as a usage error. + +#### Scenario: Page edited by hand + +- **WHEN** one page's bytes differ from the rendered page for that term +- **THEN** the check names the page as changed and the write step, and fails + +#### Scenario: Glossary directory absent + +- **WHEN** no glossary directory exists +- **THEN** the check names the directory and fails + +[RENDER]: ../../../../specs/glossary/render.md diff --git a/docs/changes/glossary-renders-from-ontology/tasks.md b/docs/changes/glossary-renders-from-ontology/tasks.md new file mode 100644 index 00000000..1f5f95f4 --- /dev/null +++ b/docs/changes/glossary-renders-from-ontology/tasks.md @@ -0,0 +1,16 @@ +## 1. Implementation + +- [x] 1.1 `glossary.rs`: render one page per term and the index from `GlossaryOutput`, write the directory, remove stale pages and the single file, and compare a directory against the rendering by page +- [x] 1.2 `src/cli/mod.rs` and `src/cli/spec.rs`: `--write [PATH]` and `--check [PATH]` on `rune spec glossary`, mutually exclusive, with `--json` + +## 2. Verification + +- [x] 2.1 Unit tests: two writes give the same bytes and remove the single file, a hand edit, a missing page, an extra file, and a missing directory are drift +- [x] 2.2 Integration tests: write then check through the binary, drift exits 1 and names the page +- [ ] 2.3 `cargo fmt --check`, clippy with `-D warnings`, the full suite, `rune spec validate glossary-renders-from-ontology` +- [x] 2.4 The command's directory over the deck's ontology, checked byte for byte against the deck's fallback script's pages (`rune spec glossary --check --source <deck>`: current) + +## 3. Documentation + +- [ ] 3.1 `docs/walkthroughs/Spec.md`, `CHANGELOG.md`, `rune-docs/CHANGELOG.md` +- [ ] 3.2 Proof under `docs/proofs/glossary-renders-from-ontology/`, recorded again for the directory diff --git a/docs/changes/adopt-command-split/design.md b/docs/changes/import-copies-adopt-reviews/design.md similarity index 100% rename from docs/changes/adopt-command-split/design.md rename to docs/changes/import-copies-adopt-reviews/design.md diff --git a/docs/changes/adopt-command-split/proposal.md b/docs/changes/import-copies-adopt-reviews/proposal.md similarity index 88% rename from docs/changes/adopt-command-split/proposal.md rename to docs/changes/import-copies-adopt-reviews/proposal.md index 8255b3e4..05756b29 100644 --- a/docs/changes/adopt-command-split/proposal.md +++ b/docs/changes/import-copies-adopt-reviews/proposal.md @@ -16,7 +16,7 @@ See the linked ADR for the decision rationale. This proposal records the change ## Capabilities -- adopt-upstream-rune (new or modified) +- review-upstream-runes-first (new or modified) ## Impact diff --git a/docs/changes/adopt-command-split/specs/adopt-upstream-rune/spec.md b/docs/changes/import-copies-adopt-reviews/specs/review-upstream-runes-first/spec.md similarity index 100% rename from docs/changes/adopt-command-split/specs/adopt-upstream-rune/spec.md rename to docs/changes/import-copies-adopt-reviews/specs/review-upstream-runes-first/spec.md diff --git a/docs/changes/adopt-command-split/tasks.md b/docs/changes/import-copies-adopt-reviews/tasks.md similarity index 71% rename from docs/changes/adopt-command-split/tasks.md rename to docs/changes/import-copies-adopt-reviews/tasks.md index faa0b9ac..62982708 100644 --- a/docs/changes/adopt-command-split/tasks.md +++ b/docs/changes/import-copies-adopt-reviews/tasks.md @@ -1,9 +1,9 @@ ## 1. Implementation - [x] 1.1 Rename: `rune import` carries the copy. `adopt` alias prints the rename note -- [x] 1.2 Provenance gains the pending-review state written by import (delivered by adopt-block-review) +- [x] 1.2 Provenance gains the pending-review state written by import (delivered by review-every-adopted-block) - [x] 1.3 Adoption skill in the deck formalizing the process (adopt-artifact wraps rune import) -- [x] 1.4 `rune adopt` review state machine (delivered by adopt-block-review, supersedes the launch-dispatch shape) +- [x] 1.4 `rune adopt` review state machine (delivered by review-every-adopted-block, supersedes the launch-dispatch shape) ## 2. Verification diff --git a/docs/changes/workshop-repository-init/design.md b/docs/changes/init-a-workshop-repository/design.md similarity index 100% rename from docs/changes/workshop-repository-init/design.md rename to docs/changes/init-a-workshop-repository/design.md diff --git a/docs/changes/workshop-repository-init/proposal.md b/docs/changes/init-a-workshop-repository/proposal.md similarity index 78% rename from docs/changes/workshop-repository-init/proposal.md rename to docs/changes/init-a-workshop-repository/proposal.md index b7bb4609..282d6457 100644 --- a/docs/changes/workshop-repository-init/proposal.md +++ b/docs/changes/init-a-workshop-repository/proposal.md @@ -16,8 +16,8 @@ See the linked ADR for the decision rationale. This proposal records the change ## Capabilities -- project-init-command (new or modified) -- consumer-manifest-dotrune (new or modified) +- init-offers-install-step (new or modified) +- consumer-manifest-lists-members (new or modified) ## Impact diff --git a/docs/changes/workshop-repository-init/specs/consumer-manifest-dotrune/spec.md b/docs/changes/init-a-workshop-repository/specs/consumer-manifest-lists-members/spec.md similarity index 100% rename from docs/changes/workshop-repository-init/specs/consumer-manifest-dotrune/spec.md rename to docs/changes/init-a-workshop-repository/specs/consumer-manifest-lists-members/spec.md diff --git a/docs/changes/workshop-repository-init/specs/project-init-command/spec.md b/docs/changes/init-a-workshop-repository/specs/init-offers-install-step/spec.md similarity index 100% rename from docs/changes/workshop-repository-init/specs/project-init-command/spec.md rename to docs/changes/init-a-workshop-repository/specs/init-offers-install-step/spec.md diff --git a/docs/changes/workshop-repository-init/tasks.md b/docs/changes/init-a-workshop-repository/tasks.md similarity index 100% rename from docs/changes/workshop-repository-init/tasks.md rename to docs/changes/init-a-workshop-repository/tasks.md diff --git a/docs/changes/project-scaffold-setup/proposal.md b/docs/changes/init-offers-install-step/proposal.md similarity index 97% rename from docs/changes/project-scaffold-setup/proposal.md rename to docs/changes/init-offers-install-step/proposal.md index 80c5dc21..805b61ff 100644 --- a/docs/changes/project-scaffold-setup/proposal.md +++ b/docs/changes/init-offers-install-step/proposal.md @@ -22,7 +22,7 @@ it as broken on every run of `rune spec doctor`. ## Capabilities -- `project-init-command` +- `init-offers-install-step` ## Impact diff --git a/docs/changes/project-scaffold-setup/specs/project-init-command/spec.md b/docs/changes/init-offers-install-step/specs/init-offers-install-step/spec.md similarity index 100% rename from docs/changes/project-scaffold-setup/specs/project-init-command/spec.md rename to docs/changes/init-offers-install-step/specs/init-offers-install-step/spec.md diff --git a/docs/changes/project-scaffold-setup/tasks.md b/docs/changes/init-offers-install-step/tasks.md similarity index 100% rename from docs/changes/project-scaffold-setup/tasks.md rename to docs/changes/init-offers-install-step/tasks.md diff --git a/docs/changes/install-deck-ontology/specs/install-deck-ontology/spec.md b/docs/changes/install-deck-ontology/specs/install-deck-ontology/spec.md deleted file mode 100644 index 2f81ec0a..00000000 --- a/docs/changes/install-deck-ontology/specs/install-deck-ontology/spec.md +++ /dev/null @@ -1,51 +0,0 @@ -## ADDED Requirements - -### Requirement: Consumer names its ontology source - -The consumer manifest MUST accept one key that names one of its declared sources as the ontology source. A name that matches no declared source MUST be refused with a message that names the key and the name. - -#### Scenario: Key names an absent source - -- **WHEN** the consumer manifest names an ontology source the manifest does not declare -- **THEN** the install is refused and the message names the key and the name - -### Requirement: Install deploys the ontology with provenance - -An install in a consumer that names an ontology source MUST copy that source's ontology, byte for byte, to the path the term reader opens by default. The copy MUST be recorded as an *instance* [INSTANCE]: a *sidecar* [SIDECAR] that carries its *provenance* [PROVENANCE] and a fingerprint entry in a manifest beside it. The term reader MUST NOT change. - -#### Scenario: Consumer cites a deck term - -- **WHEN** the install completes and a delta specification in the consumer cites a term the source ontology defines -- **THEN** validation of that change passes, and the copy, its sidecar, and its manifest entry exist - -### Requirement: Consumer without the key installs as before - -A consumer manifest without an ontology key MUST install exactly as before, and no ontology copy MUST be written. - -#### Scenario: Manifest carries no ontology key - -- **WHEN** the install runs in a consumer whose manifest names no ontology source -- **THEN** the requested runes deploy and no ontology copy exists - -### Requirement: Source without an ontology file is refused - -An ontology source whose root carries no ontology file MUST be refused with a message that names the key and the source, before any *target* [TARGET] is written. - -#### Scenario: Source carries no ontology file - -- **WHEN** the named source has no ontology file at its declared or default path -- **THEN** the install is refused, the message names the key and the source, and no provider tree is written - -### Requirement: Deployed copy reports drift - -A deployed copy that differs from its recorded fingerprint or from its source MUST be reported as drift and counted as stale. A later install MUST skip a hand-edited copy as user modified, and a forced install MUST restore it. - -#### Scenario: Copy edited by hand - -- **WHEN** the deployed copy is edited after the install -- **THEN** drift reports the copy, status counts it stale, an install skips it as user modified, and a forced install restores the source bytes - -[INSTANCE]: https://runedeck.ai/ns#Instance -[PROVENANCE]: https://runedeck.ai/ns#Provenance -[SIDECAR]: https://runedeck.ai/ns#Sidecar -[TARGET]: https://runedeck.ai/ns#Target diff --git a/docs/changes/release-distribution-channels/design.md b/docs/changes/installs-verify-their-checksums/design.md similarity index 100% rename from docs/changes/release-distribution-channels/design.md rename to docs/changes/installs-verify-their-checksums/design.md diff --git a/docs/changes/release-distribution-channels/proposal.md b/docs/changes/installs-verify-their-checksums/proposal.md similarity index 95% rename from docs/changes/release-distribution-channels/proposal.md rename to docs/changes/installs-verify-their-checksums/proposal.md index bb4318ab..eee57c68 100644 --- a/docs/changes/release-distribution-channels/proposal.md +++ b/docs/changes/installs-verify-their-checksums/proposal.md @@ -20,7 +20,7 @@ Governing decision: CLI-0034. ## Capabilities -- release-distribution-channels (new) +- installs-verify-their-checksums (new) ## Impact diff --git a/docs/changes/release-distribution-channels/specs/release-distribution-channels/spec.md b/docs/changes/installs-verify-their-checksums/specs/installs-verify-their-checksums/spec.md similarity index 100% rename from docs/changes/release-distribution-channels/specs/release-distribution-channels/spec.md rename to docs/changes/installs-verify-their-checksums/specs/installs-verify-their-checksums/spec.md diff --git a/docs/changes/release-distribution-channels/tasks.md b/docs/changes/installs-verify-their-checksums/tasks.md similarity index 100% rename from docs/changes/release-distribution-channels/tasks.md rename to docs/changes/installs-verify-their-checksums/tasks.md diff --git a/docs/changes/draft-runes-before-promotion/adr.md b/docs/changes/instructions-start-as-drafts/adr.md similarity index 98% rename from docs/changes/draft-runes-before-promotion/adr.md rename to docs/changes/instructions-start-as-drafts/adr.md index 365cf1f9..28085a08 100644 --- a/docs/changes/draft-runes-before-promotion/adr.md +++ b/docs/changes/instructions-start-as-drafts/adr.md @@ -20,7 +20,7 @@ accountable: ["@N4M3Z"] consulted: ["claude-fable-5-1"] informed: [] upstream: [] -change: draft-runes-before-promotion +change: instructions-start-as-drafts --- # Drafts live in the consumer and promote into the deck diff --git a/docs/changes/instructions-start-as-drafts/design.md b/docs/changes/instructions-start-as-drafts/design.md new file mode 100644 index 00000000..b9e4cca4 --- /dev/null +++ b/docs/changes/instructions-start-as-drafts/design.md @@ -0,0 +1,17 @@ +# Draft runes before promotion design + +## Commands + +The delta names roles: the author drafts and promotes, the deployer owns the provider trees and +the draft register, the promotion step moves a draft into the deck, the validator reports, the +fixer repairs. This table carries the command, flag, +file, and diagnostic names that implement each requirement, as rune 0.6.0 prints them in `--help`. +Exit statuses come from [Exit Codes](../../Exit%20Codes.md): 1 is a refusal or a finding, 2 is a +usage error. + +| Requirement | Command and flags | Hook | Diagnostic ids and files | Exit status | +|---|---|---|---|---| +| Drafting writes an unmanaged rune | `rune draft <kind> <name>`, `rune draft --list`, `rune draft --drop <name>`, `--target <DIR>` for the consumer root | none | The draft register is `.drafts` beside `.manifest`. A refusal names the managed path. | 1 on refusal | +| The validator knows drafts | `rune doctor` reads `.drafts`. `rune doctor --verify` fails on findings. The fixer is `rune repair` (the delta text named `rune doctor --repair`, which 0.6.0 does not have). | none | a stale draft finding | `rune doctor --verify` exits 1 on a stale draft, 0 otherwise | +| Promotion moves the draft into the deck | `rune promote <name> --domain <domain> --change <id>`, with `--target <DIR>` and `--deck <DIR>`. The destination is `runes/<domain>/<kind>/` and the stub is `docs/changes/<id>/`. | none | Refusals: a change id under three words, no deck root, `docs/changes/<id>/` exists. | 1 on refusal, 2 on a missing argument | +| Register is plain and local | `.drafts` is a YAML list with `path`, `kind`, and `created` per entry (the source also writes `name`). `rune init` does not yet add `.drafts` to `.gitignore` in 0.6.0 (no template names it), so that sentence of the requirement is unimplemented. | none | none | none | diff --git a/docs/changes/draft-runes-before-promotion/proposal.md b/docs/changes/instructions-start-as-drafts/proposal.md similarity index 94% rename from docs/changes/draft-runes-before-promotion/proposal.md rename to docs/changes/instructions-start-as-drafts/proposal.md index f5ff7956..e1aeec5f 100644 --- a/docs/changes/draft-runes-before-promotion/proposal.md +++ b/docs/changes/instructions-start-as-drafts/proposal.md @@ -1,5 +1,5 @@ --- -adr: docs/changes/draft-runes-before-promotion/adr.md +adr: docs/changes/instructions-start-as-drafts/adr.md status: proposed decisions: ["Drafts live in the consumer and promote into the deck"] --- @@ -20,7 +20,7 @@ A new skill enters the deck through a change: a proposal, a delta specification, ## Capabilities -- draft-runes-before-promotion (new) +- instructions-start-as-drafts (new) ## Impact diff --git a/docs/changes/instructions-start-as-drafts/specs/instructions-start-as-drafts/spec.md b/docs/changes/instructions-start-as-drafts/specs/instructions-start-as-drafts/spec.md new file mode 100644 index 00000000..ad728b32 --- /dev/null +++ b/docs/changes/instructions-start-as-drafts/specs/instructions-start-as-drafts/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Drafting writes an unmanaged rune + +When the author drafts a rune by kind and name, the deployer MUST write one copy under each provider directory the consumer deploys to. The copy comes from a minimal template with the name filled in. It MUST NOT add the copy to the deployment manifest. It MUST add one entry to the draft register with the relative path, the kind, and the creation time in UTC. The kind MUST be a skill, an agent, or a rule. + +#### Scenario: Author drafts a skill in a consumer with two providers + +- **WHEN** a consumer deploys to claude and codex and the author drafts a skill named ReviewSpec +- **THEN** each of the two provider directories holds a skill file for ReviewSpec, the deployment manifest is unchanged, and the draft register lists both paths + +#### Scenario: Draft name is already deployed + +- **WHEN** the deployment manifest already lists a rune with that name and kind +- **THEN** the deployer refuses the draft and names the managed path + +### Requirement: The validator knows drafts + +The validator MUST read the draft register. A file listed there MUST NOT be reported as an orphan. The validator MUST list each draft with its name, kind, and age in days. The fixer MUST NOT move or delete a registered draft. A register entry whose file is missing MUST be reported as a stale draft. + +#### Scenario: Registered draft sits under a managed directory + +- **WHEN** the draft register lists a skill file under a managed provider directory and the validator runs +- **THEN** the report shows one draft aged N days and no orphan for that path + +#### Scenario: Author removes a draft file by hand + +- **WHEN** the draft register lists a path that does not exist +- **THEN** the validator reports a stale draft, and a verifying run fails on it + +### Requirement: Promotion moves the draft into the deck + +The author promotes a draft by name, domain, and change id. The promotion step MUST then move the canonical file into the deck's directory for that domain and kind. It MUST remove the draft from every provider directory and the draft register. It MUST create the change directory with a proposal stub that names the rune and an empty task list. The change id MUST have at least three hyphen-separated words. The step MUST refuse when no deck is found or when the change directory already exists. + +#### Scenario: Author promotes a draft skill + +- **WHEN** the author promotes ReviewSpec into the core domain with the change id review-spec-interaction +- **THEN** the deck's core skills directory holds ReviewSpec and no provider directory holds the draft +- **AND** the draft register has no entry for it, and the new change's proposal names ReviewSpec + +#### Scenario: Change id has two words + +- **WHEN** the author gives the change id review-spec +- **THEN** the promotion step refuses and states the three-word rule + +### Requirement: Register is plain and local + +The draft register MUST be a YAML list at the consumer root, one entry per draft with its path, kind, and creation time. The step that initializes a consumer MUST add the register to the version-control ignore list. The register MUST NOT be deployed or exported. + +#### Scenario: Consumer is fresh + +- **WHEN** a consumer has no draft register +- **THEN** every draft action behaves as if the list is empty, and the first draft creates the register diff --git a/docs/changes/draft-runes-before-promotion/tasks.md b/docs/changes/instructions-start-as-drafts/tasks.md similarity index 90% rename from docs/changes/draft-runes-before-promotion/tasks.md rename to docs/changes/instructions-start-as-drafts/tasks.md index f13680d3..a69a9243 100644 --- a/docs/changes/draft-runes-before-promotion/tasks.md +++ b/docs/changes/instructions-start-as-drafts/tasks.md @@ -12,7 +12,7 @@ - [x] 2.1 `cargo test` covers each scenario above - [x] 2.2 One manual run in the runedeck workshop: draft a skill, see it in doctor, promote it, see the change stub -- [x] 2.3 One recorded proof per scenario under `docs/proofs/draft-runes-before-promotion/` +- [x] 2.3 One recorded proof per scenario under `docs/proofs/instructions-start-as-drafts/` ## 3. Record diff --git a/docs/changes/openspec-tree-fallback/proposal.md b/docs/changes/keep-or-migrate-openspec/proposal.md similarity index 91% rename from docs/changes/openspec-tree-fallback/proposal.md rename to docs/changes/keep-or-migrate-openspec/proposal.md index 9db1f15c..f934c3c2 100644 --- a/docs/changes/openspec-tree-fallback/proposal.md +++ b/docs/changes/keep-or-migrate-openspec/proposal.md @@ -17,8 +17,8 @@ Repositories with an `openspec/` tree need a durable choice between direct opera ## Capabilities -- spec-change-lifecycle (modified) -- openspec-tree-interop (modified) +- lifecycle-of-a-change (modified) +- openspec-trees-full-compatibility (modified) ## Impact diff --git a/docs/changes/openspec-tree-fallback/specs/openspec-tree-interop/spec.md b/docs/changes/keep-or-migrate-openspec/specs/openspec-trees-full-compatibility/spec.md similarity index 100% rename from docs/changes/openspec-tree-fallback/specs/openspec-tree-interop/spec.md rename to docs/changes/keep-or-migrate-openspec/specs/openspec-trees-full-compatibility/spec.md diff --git a/docs/changes/openspec-tree-fallback/tasks.md b/docs/changes/keep-or-migrate-openspec/tasks.md similarity index 100% rename from docs/changes/openspec-tree-fallback/tasks.md rename to docs/changes/keep-or-migrate-openspec/tasks.md diff --git a/docs/changes/launch-model-check/adr.md b/docs/changes/models-checked-before-launch/adr.md similarity index 98% rename from docs/changes/launch-model-check/adr.md rename to docs/changes/models-checked-before-launch/adr.md index 964bae09..df942b40 100644 --- a/docs/changes/launch-model-check/adr.md +++ b/docs/changes/models-checked-before-launch/adr.md @@ -19,7 +19,7 @@ accountable: ["@N4M3Z"] consulted: ["claude-fable-5-1"] informed: [] upstream: [] -change: launch-model-check +change: models-checked-before-launch --- # Model routes are checked against the endpoint on demand diff --git a/docs/changes/launch-model-check/design.md b/docs/changes/models-checked-before-launch/design.md similarity index 100% rename from docs/changes/launch-model-check/design.md rename to docs/changes/models-checked-before-launch/design.md diff --git a/docs/changes/launch-model-check/proposal.md b/docs/changes/models-checked-before-launch/proposal.md similarity index 87% rename from docs/changes/launch-model-check/proposal.md rename to docs/changes/models-checked-before-launch/proposal.md index 39e6c631..5d4459d2 100644 --- a/docs/changes/launch-model-check/proposal.md +++ b/docs/changes/models-checked-before-launch/proposal.md @@ -1,5 +1,5 @@ --- -adr: docs/changes/launch-model-check/adr.md +adr: docs/changes/models-checked-before-launch/adr.md status: proposed decisions: ["Model routes are checked against the endpoint on demand"] --- @@ -19,10 +19,10 @@ A launch profile names a model route, and the route names a model id that an end ## Capabilities -- harness-launch-command (modified) -- harness-run-command (modified) +- convenient-cross-harness-launcher (modified) +- execute-prompts-off-session (modified) ## Impact -- `src/cli/launch/mod.rs`, a new `src/cli/launch/check.rs`, `src/cli/run/mod.rs`, their tests, the `launch` and `run` help text, and the recorded proof under `docs/proofs/launch-model-check/`. +- `src/cli/launch/mod.rs`, a new `src/cli/launch/check.rs`, `src/cli/run/mod.rs`, their tests, the `launch` and `run` help text, and the recorded proof under `docs/proofs/models-checked-before-launch/`. - The owner's config gains a `kimi@claude` profile. That edit is outside the repository. diff --git a/docs/changes/launch-model-check/pull-request.md b/docs/changes/models-checked-before-launch/pull-request.md similarity index 71% rename from docs/changes/launch-model-check/pull-request.md rename to docs/changes/models-checked-before-launch/pull-request.md index 2e41b908..6acfb7d5 100644 --- a/docs/changes/launch-model-check/pull-request.md +++ b/docs/changes/models-checked-before-launch/pull-request.md @@ -2,7 +2,7 @@ ## Plan -Change `launch-model-check`: proposal, design, two ADDED deltas (`harness-launch-command`, `harness-run-command`), tasks, and `adr.md` under `docs/changes/launch-model-check/`. Proof under `docs/proofs/launch-model-check/`. +Change `models-checked-before-launch`: proposal, design, two ADDED deltas (`convenient-cross-harness-launcher`, `execute-prompts-off-session`), tasks, and `adr.md` under `docs/changes/models-checked-before-launch/`. Proof under `docs/proofs/models-checked-before-launch/`. ## Changes @@ -17,8 +17,8 @@ Change `launch-model-check`: proposal, design, two ADDED deltas (`harness-launch - `cargo test`: 46 tests under `cli::launch` including a local stub endpoint for served, missing, 401, no-list, unreachable, and token redaction. Full suite green. - `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` clean. -- `rune spec validate launch-model-check` and `rune spec doctor` pass. -- Recorded proof `docs/proofs/launch-model-check/proof.{cast,gif,txt}` with one scene per scenario, plus a live transcript against the owner's proxy: `kimi-k3` served, a wrong `--model` missing, and one Kimi K3 completion through `rune run kimi@claude`. +- `rune spec validate models-checked-before-launch` and `rune spec doctor` pass. +- Recorded proof `docs/proofs/models-checked-before-launch/proof.{cast,gif,txt}` with one scene per scenario, plus a live transcript against the owner's proxy: `kimi-k3` served, a wrong `--model` missing, and one Kimi K3 completion through `rune run kimi@claude`. ## Release Notes diff --git a/docs/changes/launch-model-check/specs/harness-launch-command/spec.md b/docs/changes/models-checked-before-launch/specs/convenient-cross-harness-launcher/spec.md similarity index 100% rename from docs/changes/launch-model-check/specs/harness-launch-command/spec.md rename to docs/changes/models-checked-before-launch/specs/convenient-cross-harness-launcher/spec.md diff --git a/docs/changes/launch-model-check/specs/harness-run-command/spec.md b/docs/changes/models-checked-before-launch/specs/execute-prompts-off-session/spec.md similarity index 100% rename from docs/changes/launch-model-check/specs/harness-run-command/spec.md rename to docs/changes/models-checked-before-launch/specs/execute-prompts-off-session/spec.md diff --git a/docs/changes/launch-model-check/tasks.md b/docs/changes/models-checked-before-launch/tasks.md similarity index 84% rename from docs/changes/launch-model-check/tasks.md rename to docs/changes/models-checked-before-launch/tasks.md index d1c02ce2..14d11238 100644 --- a/docs/changes/launch-model-check/tasks.md +++ b/docs/changes/models-checked-before-launch/tasks.md @@ -11,8 +11,8 @@ - [x] 2.1 `cargo test` covers each scenario in the two delta specs with a local stub endpoint - [x] 2.2 One live run against the owner's proxy: `rune launch kimi@claude --check` green, a wrong `--model` red, one Kimi completion (transcript in the proof README) -- [x] 2.3 Recorded proof per scenario under `docs/proofs/launch-model-check/`, with the GIF -- [x] 2.4 `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `rune spec validate launch-model-check` +- [x] 2.3 Recorded proof per scenario under `docs/proofs/models-checked-before-launch/`, with the GIF +- [x] 2.4 `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `rune spec validate models-checked-before-launch` ## 3. Record diff --git a/docs/changes/repository-hygiene-sweep/adr.md b/docs/changes/no-leftovers-in-tree/adr.md similarity index 98% rename from docs/changes/repository-hygiene-sweep/adr.md rename to docs/changes/no-leftovers-in-tree/adr.md index 7422b6ab..fc7efa19 100644 --- a/docs/changes/repository-hygiene-sweep/adr.md +++ b/docs/changes/no-leftovers-in-tree/adr.md @@ -19,7 +19,7 @@ accountable: ["@N4M3Z"] consulted: ["claude-fable-5-1"] informed: [] upstream: [] -change: repository-hygiene-sweep +change: no-leftovers-in-tree --- # A consumer keeps its template pin current and its tree free of leftovers diff --git a/docs/changes/repository-hygiene-sweep/proposal.md b/docs/changes/no-leftovers-in-tree/proposal.md similarity index 85% rename from docs/changes/repository-hygiene-sweep/proposal.md rename to docs/changes/no-leftovers-in-tree/proposal.md index 7609366e..8aa477e4 100644 --- a/docs/changes/repository-hygiene-sweep/proposal.md +++ b/docs/changes/no-leftovers-in-tree/proposal.md @@ -1,5 +1,5 @@ --- -adr: docs/changes/repository-hygiene-sweep/adr.md +adr: docs/changes/no-leftovers-in-tree/adr.md status: proposed decisions: ["A consumer keeps its template pin current and its tree free of leftovers"] --- @@ -20,7 +20,7 @@ The repository root held `openspec/project.md`, a stub from a one-time `rune spe ### New Capabilities -- `repository-hygiene-sweep`: the tree carries no leftover spec exports, its Copier pin matches the skeleton main it last adopted, and every build directory is ignored. +- `no-leftovers-in-tree`: the tree carries no leftover spec exports, its Copier pin matches the skeleton main it last adopted, and every build directory is ignored. ## Impact diff --git a/docs/changes/repository-hygiene-sweep/specs/repository-hygiene-sweep/spec.md b/docs/changes/no-leftovers-in-tree/specs/no-leftovers-in-tree/spec.md similarity index 100% rename from docs/changes/repository-hygiene-sweep/specs/repository-hygiene-sweep/spec.md rename to docs/changes/no-leftovers-in-tree/specs/no-leftovers-in-tree/spec.md diff --git a/docs/changes/repository-hygiene-sweep/tasks.md b/docs/changes/no-leftovers-in-tree/tasks.md similarity index 100% rename from docs/changes/repository-hygiene-sweep/tasks.md rename to docs/changes/no-leftovers-in-tree/tasks.md diff --git a/docs/changes/setup-command-experience/design.md b/docs/changes/nothing-installs-without-approval/design.md similarity index 100% rename from docs/changes/setup-command-experience/design.md rename to docs/changes/nothing-installs-without-approval/design.md diff --git a/docs/changes/setup-command-experience/proposal.md b/docs/changes/nothing-installs-without-approval/proposal.md similarity index 92% rename from docs/changes/setup-command-experience/proposal.md rename to docs/changes/nothing-installs-without-approval/proposal.md index d52e039c..5c453d64 100644 --- a/docs/changes/setup-command-experience/proposal.md +++ b/docs/changes/nothing-installs-without-approval/proposal.md @@ -29,10 +29,10 @@ CLI-0030. ## Capabilities -- setup-command-flow (new) -- source-config-layers (new) -- provider-registry-config (new) -- structured-error-output (new) +- approve-plan-before-execution (new) +- config-validation-provides-hints (new) +- one-registry-for-harnesses (new) +- every-error-names-its-fix (new) ## Impact diff --git a/docs/changes/setup-command-experience/specs/setup-command-flow/spec.md b/docs/changes/nothing-installs-without-approval/specs/approve-plan-before-execution/spec.md similarity index 100% rename from docs/changes/setup-command-experience/specs/setup-command-flow/spec.md rename to docs/changes/nothing-installs-without-approval/specs/approve-plan-before-execution/spec.md diff --git a/docs/changes/setup-command-experience/specs/source-config-layers/spec.md b/docs/changes/nothing-installs-without-approval/specs/config-validation-provides-hints/spec.md similarity index 100% rename from docs/changes/setup-command-experience/specs/source-config-layers/spec.md rename to docs/changes/nothing-installs-without-approval/specs/config-validation-provides-hints/spec.md diff --git a/docs/changes/setup-command-experience/specs/structured-error-output/spec.md b/docs/changes/nothing-installs-without-approval/specs/every-error-names-its-fix/spec.md similarity index 100% rename from docs/changes/setup-command-experience/specs/structured-error-output/spec.md rename to docs/changes/nothing-installs-without-approval/specs/every-error-names-its-fix/spec.md diff --git a/docs/changes/setup-command-experience/specs/provider-registry-config/spec.md b/docs/changes/nothing-installs-without-approval/specs/one-registry-for-harnesses/spec.md similarity index 100% rename from docs/changes/setup-command-experience/specs/provider-registry-config/spec.md rename to docs/changes/nothing-installs-without-approval/specs/one-registry-for-harnesses/spec.md diff --git a/docs/changes/setup-command-experience/tasks.md b/docs/changes/nothing-installs-without-approval/tasks.md similarity index 100% rename from docs/changes/setup-command-experience/tasks.md rename to docs/changes/nothing-installs-without-approval/tasks.md diff --git a/docs/changes/ontology-deploys-with-provenance/design.md b/docs/changes/ontology-deploys-with-provenance/design.md new file mode 100644 index 00000000..50745a7e --- /dev/null +++ b/docs/changes/ontology-deploys-with-provenance/design.md @@ -0,0 +1,17 @@ +# Design for Install deck ontology + +## Context + +The [proposal](proposal.md) states the scope. The [delta specification](specs/ontology-deploys-with-provenance/spec.md) states +the behavior in role terms: the deployer copies the ontology with its record, the validator reports drift, the term +reader stays unchanged. This file maps each requirement to the command, flags, paths, and strings that implement it. + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids and messages | Exit status | +|---|---|---|---|---| +| Consumer names its ontology source | The `.rune` top-level key `ontology: <source-name>` names one of its `sources`. `rune install` reads it. | none | ``.rune: ontology '<name>' has no matching `sources` entry`` | nonzero at parse, nothing written | +| Install deploys the ontology with provenance | `rune install` in consumer mode copies `<source>/ontology/rune.ttl`, or the path the source's `deck.yaml` names under `ontology`, to `<consumer>/ontology/rune.ttl`. It writes `ontology/.provenance/rune.ttl.yaml` and a fingerprint entry in `ontology/.manifest`. The term reader is `rune spec validate` and `rune spec doctor`. | none | none | 0 | +| Consumer without the key installs as before | `rune install` with no `ontology` key in `.rune`. | none | none | 0 | +| Source without an ontology file is refused | `rune install` resolves the source before any provider tree is written. | none | `install.ontology_missing`: `.rune: ontology '<name>' names source '<name>' at <path>, which has no ontology file at <relative>` | nonzero, nothing written | +| Deployed copy reports drift | `rune drift` verifies `ontology/.manifest` beside the provider manifests. `rune drift --target <dir>` compares the source ontology with the copy. `rune status` lists `ontology` as a deploy target with its ok and stale counts. `rune install` skips a hand-edited copy as user modified. `rune install --force` restores it. | none | Drift lists the copy. Status counts it stale. Install reports the copy as user modified. | not set by the requirement | diff --git a/docs/changes/install-deck-ontology/proposal.md b/docs/changes/ontology-deploys-with-provenance/proposal.md similarity index 71% rename from docs/changes/install-deck-ontology/proposal.md rename to docs/changes/ontology-deploys-with-provenance/proposal.md index 9eb07c9e..c604fc02 100644 --- a/docs/changes/install-deck-ontology/proposal.md +++ b/docs/changes/ontology-deploys-with-provenance/proposal.md @@ -6,7 +6,7 @@ status: proposed ## Why -There is one ontology, the deck's `ontology/rune.ttl`, and every specification that sets a term in italics cites a term it defines. `rune spec validate` and `rune spec doctor` read terms from `ontology/rune.ttl` at the repository root, or from the path the `ontology` key of `deck.yaml` names. A consumer is not a deck and has no `deck.yaml`, so it has no way to read the deck's ontology today, and an italic term in its specifications fails as a term that needs a glossary. The cli repository is the first consumer: its specifications cite *instance* [INSTANCE], *provenance* [PROVENANCE], *sidecar* [SIDECAR], and *target* [TARGET], which only that file defines. +There is one ontology, the deck's `ontology/rune.ttl`, and every specification that links a term links one it defines. `rune spec validate` and `rune spec doctor` read terms from `ontology/rune.ttl` at the repository root, or from the path the `ontology` key of `deck.yaml` names. A consumer carries no `deck.yaml`, so it has no way to read the deck's ontology today, and a term link in its specifications goes unchecked. Once the copy exists, the link fails with the page diagnostic until the glossary pages are written. The cli repository is the first consumer: its specifications link *[instance][INSTANCE]*, *[provenance][PROVENANCE]*, *[sidecar][SIDECAR]*, and *[target][TARGET]*, which only that file defines. The deck already deploys runes into a consumer with a fingerprint and a provenance sidecar per file. The ontology is one more file from the same source, and it gets the same record. @@ -20,7 +20,7 @@ The deck already deploys runes into a consumer with a fingerprint and a provenan ## Capabilities -- install-deck-ontology (new) +- ontology-deploys-with-provenance (new) ## Impact @@ -29,7 +29,7 @@ The deck already deploys runes into a consumer with a fingerprint and a provenan - `docs/walkthroughs/Spec.md`, the two changelogs. - The cli repository becomes a consumer of itself: `.rune` names the deck as a git source and `ontology: deck`, and the deployed copy is committed with its sidecar once the deck's ontology is on origin main. -[INSTANCE]: https://runedeck.ai/ns#Instance -[PROVENANCE]: https://runedeck.ai/ns#Provenance -[SIDECAR]: https://runedeck.ai/ns#Sidecar -[TARGET]: https://runedeck.ai/ns#Target +[INSTANCE]: ../../specs/glossary/instance.md +[PROVENANCE]: ../../specs/glossary/provenance.md +[SIDECAR]: ../../specs/glossary/sidecar.md +[TARGET]: ../../specs/glossary/target.md diff --git a/docs/changes/ontology-deploys-with-provenance/specs/ontology-deploys-with-provenance/spec.md b/docs/changes/ontology-deploys-with-provenance/specs/ontology-deploys-with-provenance/spec.md new file mode 100644 index 00000000..761ea5d9 --- /dev/null +++ b/docs/changes/ontology-deploys-with-provenance/specs/ontology-deploys-with-provenance/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Consumer names its ontology source + +The consumer manifest MUST accept one key that names one of its declared sources as the ontology source. The deployer MUST refuse a name that matches no declared source, with a message that names the key and the name. + +#### Scenario: Key names an absent source + +- **WHEN** the consumer manifest names an ontology source the manifest does not declare +- **THEN** the deployer refuses the deployment and the message names the key and the name + +### Requirement: Install deploys the ontology with provenance + +When a consumer names an ontology source, the deployer MUST copy that ontology, byte for byte, to the term reader's default path. The deployer MUST record the copy as an *instance* [INSTANCE]: a *[sidecar][SIDECAR]* that carries its *[provenance][PROVENANCE]* and a fingerprint entry in a manifest beside it. The term reader MUST NOT change. + +#### Scenario: Consumer cites a deck term + +- **WHEN** the deployment completes and a delta specification in the consumer cites a term the source ontology defines +- **THEN** validation of that change passes, and the copy, its sidecar, and its manifest entry exist + +### Requirement: Consumer without the key installs as before + +A consumer manifest without an ontology key MUST deploy exactly as before, and no ontology copy MUST be written. + +#### Scenario: Manifest carries no ontology key + +- **WHEN** the deployment runs in a consumer whose manifest names no ontology source +- **THEN** the requested runes deploy and no ontology copy exists + +### Requirement: Source without an ontology file is refused + +The deployer MUST refuse an ontology source whose root carries no ontology file, before any *[target][TARGET]* is written. The message MUST name the key and the source. + +#### Scenario: Source carries no ontology file + +- **WHEN** the named source has no ontology file at its declared or default path +- **THEN** the deployer refuses the deployment, the message names the key and the source, and no provider tree is written + +### Requirement: Deployed copy reports drift + +The validator MUST report a deployed copy that differs from its recorded fingerprint or from its source as drift and count it as stale. A later deployment MUST skip a hand-edited copy as user modified, and a forced deployment MUST restore it. + +#### Scenario: Copy edited by hand + +- **WHEN** the deployed copy is edited after the deployment +- **THEN** the validator reports the copy as drift and counts it stale, a later deployment skips it as user modified, and a forced deployment restores the source bytes + +[INSTANCE]: ../../../../specs/glossary/instance.md +[PROVENANCE]: ../../../../specs/glossary/provenance.md +[SIDECAR]: ../../../../specs/glossary/sidecar.md +[TARGET]: ../../../../specs/glossary/target.md diff --git a/docs/changes/install-deck-ontology/tasks.md b/docs/changes/ontology-deploys-with-provenance/tasks.md similarity index 91% rename from docs/changes/install-deck-ontology/tasks.md rename to docs/changes/ontology-deploys-with-provenance/tasks.md index 1817b532..b3faec85 100644 --- a/docs/changes/install-deck-ontology/tasks.md +++ b/docs/changes/ontology-deploys-with-provenance/tasks.md @@ -9,12 +9,12 @@ - [x] 2.1 Unit tests: the key parses and an unknown name is refused, the source resolves by default path and by `deck.yaml` key, and a copy is written, a hand edit is refused, and a forced overwrite restores it - [x] 2.2 Integration tests: a temp deck with an ontology and a temp consumer naming it, `rune install`, then `rune spec validate` on a delta that cites a term, plus the no-key, unknown-source, missing-file, and hand-edit cases with drift, status, and `--force` -- [x] 2.3 `cargo fmt --check`, clippy with `-D warnings`, the full suite, `rune spec validate install-deck-ontology`, `rune docs check` +- [x] 2.3 `cargo fmt --check`, clippy with `-D warnings`, the full suite, `rune spec validate ontology-deploys-with-provenance`, `rune docs check` ## 3. Documentation - [x] 3.1 `docs/walkthroughs/Spec.md`, `CHANGELOG.md`, `rune-docs/CHANGELOG.md` -- [x] 3.2 Proof under `docs/proofs/install-deck-ontology/` +- [x] 3.2 Proof under `docs/proofs/ontology-deploys-with-provenance/` ## 4. Self-consumption @@ -26,7 +26,7 @@ DECK_COMMIT=$(git -C /Users/N4M3Z/Developer/runedeck/deck rev-parse origin/main) printf 'version: 1\nsources:\n deck:\n git: https://github.com/runedeck/deck\n ref: %s\nontology: deck\n' "$DECK_COMMIT" > .rune rune install - rune spec validate install-deck-ontology + rune spec validate ontology-deploys-with-provenance jj describe -m 'chore: consume the deck ontology through .rune' ``` diff --git a/docs/changes/ontology-term-references/adr.md b/docs/changes/ontology-term-references/adr.md deleted file mode 100644 index 93c39c02..00000000 --- a/docs/changes/ontology-term-references/adr.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: "Ontology as the term source" -description: "rune spec validate and rune validate read defined terms from ontology/rune.ttl, require the first italic use of a term to cite its IRI, and print the term list with rune spec glossary; glossary.md stays as the fallback for a repository without an ontology" -type: adr -category: architecture -tags: - - cli - - spec - - ontology -status: proposed -created: 2026-09-23 -updated: 2026-09-23 -author: "@N4M3Z" -project: cli -related: - - "CLI-0040 Specification House Rules" -responsible: ["@N4M3Z"] -accountable: ["@N4M3Z"] -consulted: ["claude-fable-5-1@claude", "gpt-6-astra@codex"] -informed: [] -upstream: [] -change: ontology-term-references ---- - -# Ontology as the term source - -## Context and Problem Statement - -CLI-0040 made an italic term an error unless `docs/specs/glossary.md` carries a `- **term**: definition` bullet. The deck then defined every term once, in `ontology/rune.ttl` with `rdfs:label` and `rdfs:comment`, and wrote two scripts: one generated the glossary from the ontology so the validator would accept the terms, the other checked that the first italic use of a term cites its IRI as `*term* [TAG]` with `[TAG]: https://runedeck.ai/ns#Term`. The generated file drifted whenever the ontology changed without a regeneration, the citation check ran only where someone invoked the script, and neither check reached decision records or runes, where the same terms appear. rumdl already reports an undefined `[TAG]` (MD052) and an unused definition (MD053), so those two checks need no second home. - -## Considered Options - -1. **Keep the scripts and the generated glossary**, and add a CI step per repository. Every repository copies two scripts and a regeneration hook, and the citation rule stays outside `rune spec validate`. -2. **Read the ontology in `rune spec validate`**, cite at first use, print the list with `rune spec glossary`, and run the same rules from `rune validate` over records and runes. `glossary.md` stays as the source for a repository without an ontology. -3. **Parse the ontology with an RDF library.** Correct for every Turtle form, and a dependency the crate does not otherwise carry, for a file whose shape the deck controls. - -## Decision Outcome - -Option 2. - -`rune-docs/src/spec/terms.rs` reads every labeled class, property, and SKOS concept from the ontology with two regular expressions over the Turtle statement shape the deck writes, and falls back to the glossary. The lint resolves an italic term against that source with the existing case-insensitive and plain-plural rule, so `spec-term-undefined` and `delta-term-undefined` keep their names. With an ontology, the first italic use of each term in a file must carry `[TAG]` and the file must define the tag to the term's IRI: `term-reference-missing` and `term-reference-unresolved`. A definition under the ontology namespace that names no term is also unresolved. `rune spec glossary` prints the list the generator used to write, tags in upper case with a `-KEY` suffix where a property collides with a class. `rune validate` applies the term rules to `docs/decisions` and `runes` of a deck root and to the record and rune directories of a module root, when the root has an ontology, and reports nothing otherwise. Outside the specification tree only the reference rules run: an italic run that matches no term is emphasis there (`*not*`, an attribution line), not a definition, and YAML front matter is skipped everywhere. `deck.yaml` may name the ontology under `ontology`, and the crate receives the path through a lookup hook like `spec.root`. - -### Consequences - -- [+] One definition per term, in the ontology, and one validator for its use, in `rune`. The deck deletes `scripts/generate-glossary`, `scripts/check-terms`, and `docs/specs/glossary.md`. -- [+] Records and runes get the same check as specifications, from the command that already validates them. -- [-] The Turtle reader is a statement-shape parser, not an RDF parser. It reads `prefix:Name a <type> ... .` with the period ending a line, `rdf:type` for `a`, comment lines, and the `\"` and `\\` escapes. A term written another way (a full-IRI subject, a type list, a triple-quoted literal) is invisible until someone writes it the deck's way. What the reader can detect it refuses: an undeclared prefix, two terms of one kind whose labels match without case, and an ontology with no labeled term are errors, and only a missing file falls back to the glossary. A class and a property may share a label (`Change` and `change` in the deck). The italic term is the class. -- [-] The Markdown side is a line scanner, not a parser: an italic run is `*text*` with no `*` or word character on either side and no backslash before it, code is a backtick or tilde fence or a backtick span, and a front matter block is skipped when `---` opens the file and `---` or `...` closes it. Indented code blocks and HTML comments are scanned as prose. -- [-] Every first use of a term now needs a tag and a definition line, so a file with many terms grows a reference block at its end. `rune spec glossary` prints the lines to paste. -- [-] The `Defined terms have a glossary entry` requirement of `spec-house-rules` is superseded by this change's requirement once both archive. Until then the two deltas describe one rule twice. diff --git a/docs/changes/ontology-term-references/proposal.md b/docs/changes/ontology-term-references/proposal.md deleted file mode 100644 index 65d2be03..00000000 --- a/docs/changes/ontology-term-references/proposal.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -adr: docs/changes/ontology-term-references/adr.md -status: proposed -decisions: ["Ontology as the term source"] ---- - -# Ontology Term References - -## Why - -CLI-0040 made an italic term an error unless `docs/specs/glossary.md` defines it. The deck now defines every term once, in `ontology/rune.ttl`, generated the glossary from it with a script, and checked the citations with a second script. Two scripts and a generated file carried what one validator should: the term exists, and the first use of it names the IRI. This change moves both checks into `rune spec validate` and `rune validate`, and adds `rune spec glossary` for the human-readable list, so the deck deletes the scripts and the generated file. - -## What Changes - -- The term source is the ontology when the repository has one, else the glossary. `spec-term-undefined` and `delta-term-undefined` keep their names. -- The first italic use of an ontology term in a file must cite it (`term-reference-missing`), and the tag must resolve to the term's IRI (`term-reference-unresolved`). rumdl MD052 and MD053 keep reporting undefined and unused tags. -- `rune spec glossary` prints the term list with tags and definitions. -- `rune validate` applies the reference rules to `docs/decisions` and `runes` of a deck, and to the record and rune directories of a module, when an ontology exists. Plain emphasis there is not a term. -- The `Defined terms have a glossary entry` requirement of `spec-house-rules` is superseded by `Defined terms resolve to the ontology` once both changes archive. - -## Scope - -- `rune-docs/src/spec/terms.rs` (new), `lint.rs`, `validate.rs`, and `mod.rs`, plus `src/cli/spec.rs`, `src/cli/validate/mod.rs`, and `src/cli/mod.rs`. -- Not in scope: reading `context.jsonld` for the graph export (cli #70), or any change to the ontology itself. diff --git a/docs/changes/ontology-term-references/specs/spec-change-lifecycle/spec.md b/docs/changes/ontology-term-references/specs/spec-change-lifecycle/spec.md deleted file mode 100644 index 9521bf06..00000000 --- a/docs/changes/ontology-term-references/specs/spec-change-lifecycle/spec.md +++ /dev/null @@ -1,70 +0,0 @@ -## ADDED Requirements - -### Requirement: Defined terms resolve to the ontology - -A term marked with single-asterisk italics in a specification, delta, decision record, or rune MUST match the `rdfs:label` of a class, property, or SKOS concept in the repository ontology, compared without case, with a plain plural (`-s` or `-es`) matching its singular. A term with no match in a specification or delta MUST fail with `spec-term-undefined` or `delta-term-undefined`. Outside the specification tree an italic run that matches no term is emphasis and MUST NOT be reported. - -#### Scenario: Term absent from the ontology - -- **WHEN** a specification marks a term in italics and no ontology term carries that label -- **THEN** validation names the term and the ontology path - -### Requirement: The ontology is the term source when it exists - -The ontology is `ontology/rune.ttl` unless `deck.yaml` names another file under `ontology`. A repository without an ontology file MUST fall back to `<specs root>/glossary.md` entries of the form `- **term**: definition`. Only a missing ontology file falls back: an unreadable file, an undeclared prefix, two terms of one kind whose labels match without case, or an ontology with no labeled term MUST fail validation with the ontology path. When a class and a property share a label, the italic term MUST resolve to the class. - -#### Scenario: Repository without an ontology - -- **WHEN** the repository has no ontology file and a glossary defines the term -- **THEN** validation accepts the term from the glossary and reports no reference finding - -#### Scenario: Two classes share a label - -- **WHEN** the ontology labels one class `Thing` and another class `thing` -- **THEN** validation fails and names both IRIs - -#### Scenario: Class and property share a label - -- **WHEN** the class `Change` and the property `change` are both labeled `change` -- **THEN** `*change*` resolves to the class and `[CHANGE-KEY]` still names the property - -### Requirement: First use of a term cites it - -With an ontology, the first italic use of each term in a file MUST carry a reference tag, `*term* [TAG]`, and the file MUST define `[TAG]: <iri>` with the term's IRI. A first use without a tag MUST fail with `term-reference-missing` and name the tag and definition to add. A tag that is undefined or defined to another IRI MUST fail with `term-reference-unresolved`. A definition under the ontology namespace that names no term MUST fail with `term-reference-unresolved`. Later uses of the same term in the file MUST NOT need a tag. - -#### Scenario: First use carries no tag - -- **WHEN** a record marks `*harness*` in italics with no `[HARNESS]` after it -- **THEN** validation reports `term-reference-missing` with `*harness* [HARNESS]` and `[HARNESS]: https://runedeck.ai/ns#Harness` - -#### Scenario: Tag points at another term - -- **WHEN** `[HARNESS]` is defined as the IRI of `Canon` -- **THEN** validation reports `term-reference-unresolved` with the definition line to write - -### Requirement: Reference definitions follow Markdown - -A tag MUST compare without case at its use and its definition, a definition destination MAY be wrapped in angle brackets, a definition MAY be indented up to three spaces, and the first definition of a tag in a file MUST win. - -#### Scenario: Lowercase tag with a bracketed destination - -- **WHEN** a record writes `*harness* [harness]` and defines `[harness]: <https://runedeck.ai/ns#Harness>` -- **THEN** validation reports nothing for the term - -### Requirement: Glossary command lists the terms - -`rune spec glossary` MUST print one `- **label**: comment [TAG]` line per ontology term sorted by label, then one `[TAG]: <iri>` definition per term. The tag MUST be the local name in upper case. When two terms collide the class keeps the plain tag and each later term takes `-KEY` suffixes until its tag is unique. `--json` MUST emit the source path and each term's label, comment, name, IRI, and tag. - -#### Scenario: Property and class share a name - -- **WHEN** the ontology defines the class `Change` and the property `change` -- **THEN** the class is `[CHANGE]` and the property is `[CHANGE-KEY]` - -### Requirement: Runes and records are checked by validate - -`rune validate` MUST apply the reference rules to every Markdown file under `docs/decisions` and `runes` of a deck root, and under `docs/decisions`, `agents`, `rules`, and `skills` of a module root, when that root has an ontology, skipping `.provenance`, `archive`, and `build` trees, fenced code, and a YAML front matter block closed by `---` or `...`. A root without an ontology MUST report nothing for terms. - -#### Scenario: Rule marks an uncited term - -- **WHEN** a rule under `runes/` marks an ontology term in italics without a tag -- **THEN** `rune validate` fails the `terms` item and names the file and line diff --git a/docs/changes/openspec-import-destination/adr.md b/docs/changes/openspec-imports-reach-docs/adr.md similarity index 98% rename from docs/changes/openspec-import-destination/adr.md rename to docs/changes/openspec-imports-reach-docs/adr.md index b098c051..150533ff 100644 --- a/docs/changes/openspec-import-destination/adr.md +++ b/docs/changes/openspec-imports-reach-docs/adr.md @@ -19,7 +19,7 @@ accountable: ["@N4M3Z"] consulted: ["claude-fable-5-1"] informed: [] upstream: [] -change: openspec-import-destination +change: openspec-imports-reach-docs --- # Import writes the native root, and the doctor stays out of sibling checkouts diff --git a/docs/changes/openspec-import-destination/proposal.md b/docs/changes/openspec-imports-reach-docs/proposal.md similarity index 94% rename from docs/changes/openspec-import-destination/proposal.md rename to docs/changes/openspec-imports-reach-docs/proposal.md index 7a6eeacc..c043dc36 100644 --- a/docs/changes/openspec-import-destination/proposal.md +++ b/docs/changes/openspec-imports-reach-docs/proposal.md @@ -1,5 +1,5 @@ --- -adr: docs/changes/openspec-import-destination/adr.md +adr: docs/changes/openspec-imports-reach-docs/adr.md status: proposed decisions: ["Import writes the native root, and the doctor stays out of sibling checkouts"] --- @@ -22,7 +22,7 @@ The first live `rune sign open` on cli #67 signed the seal and then failed to pu ### New Capabilities -- `openspec-import-destination`: import from an autodetected `openspec/` root lands in `docs/`, and the adoption doctor skips sibling checkouts. +- `openspec-imports-reach-docs`: import from an autodetected `openspec/` root lands in `docs/`, and the adoption doctor skips sibling checkouts. ## Impact diff --git a/docs/changes/openspec-import-destination/pull-request.md b/docs/changes/openspec-imports-reach-docs/pull-request.md similarity index 95% rename from docs/changes/openspec-import-destination/pull-request.md rename to docs/changes/openspec-imports-reach-docs/pull-request.md index 64c58064..700e374b 100644 --- a/docs/changes/openspec-import-destination/pull-request.md +++ b/docs/changes/openspec-imports-reach-docs/pull-request.md @@ -8,7 +8,7 @@ Two defects found while moving the html-tools and docs spec trees: import conver - Redirect an autodetected `openspec/` root to `docs` in `import_openspec_with_io`, while a configured `spec.root: openspec` keeps in-place ownership. Add `spec_root_is_configured`. - Add `.workspaces`, `.worktrees`, `.codex-prs` to the adoption doctor's `SKIP_WALK`. -- Add `docs/changes/openspec-import-destination/` and two unit tests. +- Add `docs/changes/openspec-imports-reach-docs/` and two unit tests. - Check the push transport in `rune sign open` before the key touch: push through `gh auth git-credential` for that one process, refuse an HTTPS origin with neither a gh login nor a trusted helper, and count URL-scoped helpers as trusted. `verify-seal` reads `pull_request`, the field the seal writes. ## Testing diff --git a/docs/changes/openspec-import-destination/specs/openspec-import-destination/spec.md b/docs/changes/openspec-imports-reach-docs/specs/openspec-imports-reach-docs/spec.md similarity index 100% rename from docs/changes/openspec-import-destination/specs/openspec-import-destination/spec.md rename to docs/changes/openspec-imports-reach-docs/specs/openspec-imports-reach-docs/spec.md diff --git a/docs/changes/openspec-import-destination/tasks.md b/docs/changes/openspec-imports-reach-docs/tasks.md similarity index 100% rename from docs/changes/openspec-import-destination/tasks.md rename to docs/changes/openspec-imports-reach-docs/tasks.md diff --git a/docs/changes/owner-key-seals-merges/design.md b/docs/changes/owner-key-seals-merges/design.md new file mode 100644 index 00000000..0a99f198 --- /dev/null +++ b/docs/changes/owner-key-seals-merges/design.md @@ -0,0 +1,16 @@ +# Design for Rune Sign + +## Context + +The [proposal](proposal.md) states the scope. The [delta specification](specs/owner-key-seals-merges/spec.md) states the +owner's ceremony in role terms: the signer seals, tags, and verifies. This file maps each requirement to the command, +flags, and strings that implement it in the rune command line tool. + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids and messages | Exit status | +|---|---|---|---|---| +| Seal Commit | `rune sign` on the current branch. The seal commit subject is `seal: approve`. The signature is checked against the repository's `KEYS` file. | none | The failure names the missing signing configuration. | nonzero when no signing key is available to git | +| Seal Freshness | `rune sign --verify [ref]` reads the branch's latest commit. | none | none | nonzero when the latest commit is not the owner-verified seal | +| Tag Sealing | `rune sign --tag <name> [commit]`, commit default `HEAD`. The tag is verified with `git verify-tag` and exactly `refs/tags/<name>` is pushed. | none | none | 0 | +| Verification | `rune sign --verify [ref]` against the repository's `KEYS` file. | none | The failure names the signing fingerprint. | nonzero when the signature does not match a key in `KEYS` | diff --git a/docs/changes/owner-key-signing/proposal.md b/docs/changes/owner-key-seals-merges/proposal.md similarity index 97% rename from docs/changes/owner-key-signing/proposal.md rename to docs/changes/owner-key-seals-merges/proposal.md index 1b494405..6d91c283 100644 --- a/docs/changes/owner-key-signing/proposal.md +++ b/docs/changes/owner-key-seals-merges/proposal.md @@ -18,7 +18,7 @@ The review ceremony keeps agent commits unsigned and anchors trust in the owner' ## Capabilities -- owner-key-signing (new) +- owner-key-seals-merges (new) ## Impact diff --git a/docs/changes/owner-key-seals-merges/specs/owner-key-seals-merges/spec.md b/docs/changes/owner-key-seals-merges/specs/owner-key-seals-merges/spec.md new file mode 100644 index 00000000..03187e4f --- /dev/null +++ b/docs/changes/owner-key-seals-merges/specs/owner-key-seals-merges/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Seal Commit + +The signer MUST create an empty commit signed with the owner's OpenPGP key on the current branch, and push it to the branch's remote. So the owner's key attests the exact state under review. + +#### Scenario: Sealing a pull request branch + +- **WHEN** the owner seals a pull request branch after reviewing it +- **THEN** an empty signed commit with the seal subject is added to the branch and pushed, and its signature verifies against the repository's trusted-key file + +#### Scenario: Unsigned environment + +- **WHEN** no signing key is available to git +- **THEN** the signer fails before creating any commit and names the missing configuration + +### Requirement: Seal Freshness + +A seal MUST attest only the history beneath it. Every verification step MUST treat a branch whose latest commit is not the owner-verified seal as unsealed. + +#### Scenario: Push after seal + +- **WHEN** any commit is added to the branch after the seal commit +- **THEN** the branch counts as unsealed until the owner seals again + +### Requirement: Tag Sealing + +When the owner names a tag, the signer MUST create the owner-signed annotated tag on the named commit, the current head by default. It MUST verify the tag locally and push only that tag. One ceremony covers release tags and post-merge seals. + +#### Scenario: Release tag + +- **WHEN** the owner tags the release commit as `v1.2.3` +- **THEN** the annotated tag is created signed, git's tag verification passes, and exactly that one tag reference is pushed + +### Requirement: Verification + +The owner's verification step MUST report whether a ref's latest commit or a named tag carries a valid signature. The signature counts only when it matches a key in the repository's trusted-key file. The step MUST exit nonzero when no valid matching signature exists. + +#### Scenario: Foreign signature + +- **WHEN** the ref is signed by a key absent from the trusted-key file +- **THEN** verification fails and names the signing fingerprint diff --git a/docs/changes/owner-key-signing/tasks.md b/docs/changes/owner-key-seals-merges/tasks.md similarity index 100% rename from docs/changes/owner-key-signing/tasks.md rename to docs/changes/owner-key-seals-merges/tasks.md diff --git a/docs/changes/owner-key-signing/specs/owner-key-signing/spec.md b/docs/changes/owner-key-signing/specs/owner-key-signing/spec.md deleted file mode 100644 index 2a3a8c82..00000000 --- a/docs/changes/owner-key-signing/specs/owner-key-signing/spec.md +++ /dev/null @@ -1,42 +0,0 @@ -## ADDED Requirements - -### Requirement: Seal Commit - -`rune sign` MUST create an empty commit signed with the owner's OpenPGP key on the current branch and push it to the branch's remote, so the owner's key attests the exact state under review. - -#### Scenario: Sealing a pull request branch - -- **WHEN** the owner runs `rune sign` on a pull request branch after reviewing it -- **THEN** an empty signed commit with subject `seal: approve` lands on the branch and is pushed, and its signature verifies against the repository's `KEYS` - -#### Scenario: Unsigned environment - -- **WHEN** no signing key is available to git -- **THEN** `rune sign` fails before creating any commit and names the missing configuration - -### Requirement: Seal Freshness - -A seal MUST attest only the history beneath it: verification helpers MUST treat a branch whose latest commit is not the owner-verified seal as unsealed. - -#### Scenario: Push after seal - -- **WHEN** any commit lands on the branch after the seal commit -- **THEN** the branch counts as unsealed until the owner seals again - -### Requirement: Tag Sealing - -`rune sign --tag <name>` MUST create the owner-signed annotated tag on the named commit (HEAD by default), verify it locally, and push only that tag, covering release tags and post-merge seals with one ceremony. - -#### Scenario: Release tag - -- **WHEN** the owner runs `rune sign --tag v1.2.3` on the release commit -- **THEN** the annotated tag is created signed, `git verify-tag` passes, and exactly `refs/tags/v1.2.3` is pushed - -### Requirement: Verification - -`rune sign --verify [ref]` MUST report whether the ref's latest commit or named tag carries a valid signature matching a key in the repository's `KEYS` file, exiting nonzero when it does not. - -#### Scenario: Foreign signature - -- **WHEN** the ref is signed by a key absent from `KEYS` -- **THEN** verification fails and names the signing fingerprint diff --git a/docs/changes/owner-key-signs-merges/design.md b/docs/changes/owner-key-signs-merges/design.md new file mode 100644 index 00000000..1529a581 --- /dev/null +++ b/docs/changes/owner-key-signs-merges/design.md @@ -0,0 +1,41 @@ +# Design + +The full design lives in `docs/decisions/CLI-0042 Sealed Review Ceremony Commands.md` and in the deck repository's DECK-0017. This file holds what the requirements leave out on purpose: the commands, flags, settings, labels, and exit statuses that implement each role. + +## Roles + +- signer: `rune sign open`, `rune sign next`, `rune sign adopt`, and `rune sign submit`, which admits a head to the queue. `rune sign queue` keeps the in-place path of CLI-0041, and `rune sign next` prints the view and completes a request. The key touch runs through the queue's pinned `jj sign`. +- validator: `rune sign --verify --seal <ref> [--pull-request <n>] [--keys-ref <ref>]`, the seal verifier the headings name, called by the skeleton's `owner-seal.yaml` workflow. +- forge validator: the review app `runeseer`, the controller the headings name, matched by the check run's `app.slug`. It opens drafts and publishes the review scorecard. +- version control tool: `jj`. The plain version control tool is `git`, pinned through `git.executable-path` on every jj call. +- forge: GitHub, reached through `gh`. +- remote: `origin`. +- trusted key list: the `KEYS` file as read from the protected branch on `origin`. +- change pull request file: `docs/changes/<id>/pull-request.md` for the bookmark `change/<id>`. +- pull request body schema: `schemas/PULL_REQUEST.mdschema`. + +## Commands + +| Requirement | Command and flags | Hook or setting | Diagnostic ids and literals | Exit status | +|---|---|---|---|---| +| The owner opens a draft with an open-seal | `rune sign open <bookmark> [--body-file <file>]` | every jj call pins `git.executable-path` to `git` | protected branches `main`, `master`, and `trunk` | nonzero on refusal, nothing signed | +| The open-seal commit names the draft | `jj sign` through the queue's pinned call, read back with `git` | none | message `open-seal: {repo, base, pull_request, tree, nonce}` | n/a | +| The seal reaches the remote under pinned settings | `git push` to `origin` under a lease on the sealed head | `core.hooksPath` to an empty directory, `core.sshCommand` to `ssh`, `core.askPass` and `core.gitProxy` empty, `credential.helper` reset to the system and user values | none | n/a | +| The draft carries the nonce | `gh pr ready`. `rune sign open <bookmark> --queue` records the request. `rune sign next` completes it | none | body line `Open-Seal-Nonce: <nonce>`. Request kind `open` | n/a | +| Open refuses a head that is not a fresh draft | `rune sign open <bookmark>` | none | remote-tracking head `origin/<bookmark>`. Body checked against `schemas/PULL_REQUEST.mdschema` | nonzero, `origin` unchanged | +| Open seals above an owner-signed head and reads the branch's body | `rune sign open change/<id> --repo <workspace>`. The push runs `gh auth git-credential` for its own process | credential helpers of system and user scope only | the refusal names `gh auth login` and an SSH remote as the fixes | nonzero before the key touch | +| The controller's review scorecard admits a head to the merge-seal | `rune sign submit <bookmark>`. Required checks come from `gh pr checks --required` in the `pass` bucket. `rune sign queue <bookmark>` keeps CLI-0041 | none | lane states and thread dispositions as the requirement lists them | nonzero, nothing recorded | +| The verdict binds to the reviewed head and its generation | `rune sign submit <bookmark>` | none | review scorecard fields `reviewed_sha`, `verdict`, `generation`, and `coverage` | nonzero, names both generations | +| The review scorecard comes from the controller app | `gh api repos/<owner>/<name>/commits/<sha>/check-runs?check_name=ledger --paginate --slurp`. The newest run whose `app.slug` is `runeseer` wins | none | first output line `ledger: {artifact_id, digest, generation, pull_request, reviewed_sha}`. The refusal names the app | nonzero | +| Submit checks the artifact and the pull request | `gh api repos/<owner>/<name>/actions/artifacts/<id>/zip`, then `runeseer-ledger.json` inside it. The pull request comes from `gh pr list --head <bookmark>` | none | the refusal says the artifact is not the review scorecard | nonzero, nothing recorded | +| The request records the coverage it was admitted on | `rune sign submit <bookmark>`. `rune sign next` prints the coverage | none | coverage `free-lanes-only` with the review scorecard's coverage text as the reason | n/a | +| The owner acknowledges the view before the merge-seal | `rune sign next`. It reads one line from `/dev/tty`, or from the file `RUNE_SIGN_TTY` names | none | `y` or `Y` signs, any other answer refuses | nonzero on refusal, the request stays current | +| The view names the fields the signature authorizes | `rune sign next` | none | none | n/a | +| The merge-seal names the reviewed head and its generation | `rune sign next` on a request of kind `merge`. `jj sign` pinned, read back with `git` | none | message `merge-seal: {reviewed_sha, generation}`. Request state `stale` | n/a, nothing pushed | +| The owner adopts an outside pull request | `rune sign adopt <number>`. `gh api repos/<owner>/<name>` must report `permissions.admin`. `gh pr view <number>`. Fetch of `refs/pull/<number>/head` from `origin`. Bookmark `adopt/<number>` | the pinned transport settings of the push row | none | nonzero before the pull request is read when the owner is not an administrator | +| The adopted head is sealed before anything is pushed | `rune sign adopt <number>`. Push under a lease that `adopt/<number>` did not exist. `gh pr list --head adopt/<number>` repeated until the draft appears. `gh pr ready` | none | none | a refused key leaves `origin` untouched | +| The seal verifier serves the owner-seal check | `rune sign --verify --seal <ref>`. Search range `refs/remotes/origin/<base>..<reviewed>`, at most 2000 commits | the skeleton's `owner-seal.yaml` fetches the base and the protected branch first | one line per check reads `ok` or names the failed check. Body line `Open-Seal-Nonce:` | 0 when every check holds, 1 when one fails | +| A copied nonce is reported and does not fail the check | `rune sign --verify --seal <ref>` | none | the copy is reported by pull request number | 0 for the original while it is open | +| The check binds one open pull request to the ref | `--pull-request <n>`, else `gh pr list --state open` filtered by head | none | the failure says it wants one pull request | 1 | +| The verifier resolves the ref and KEYS through git | `--keys-ref <ref>`, by default the first of `refs/remotes/origin/main`, `master`, and `trunk` that exists. `git` alone, with `gpg` on `PATH` | none | one line per check | 0 when every check holds, 1 when one fails, 2 when the repository or the forge cannot be read | +| A merge-seal sits on the reviewed head it names | `rune sign --verify --seal <ref>` | none | the parent check and the tree check | 1 on a failed check | diff --git a/docs/changes/sealed-review-ceremony/proposal.md b/docs/changes/owner-key-signs-merges/proposal.md similarity index 79% rename from docs/changes/sealed-review-ceremony/proposal.md rename to docs/changes/owner-key-signs-merges/proposal.md index 10924810..c23aefa9 100644 --- a/docs/changes/sealed-review-ceremony/proposal.md +++ b/docs/changes/owner-key-signs-merges/proposal.md @@ -11,7 +11,7 @@ The review ceremony now puts the owner's key at two points before a tag: an open becomes ready, and a merge-seal when a reviewed head may merge. The deck records the decision in DECK-0017 and the skeleton's `review-ceremony` and `release-ceremony` specifications carry the rules. The CLI has the signing queue (CLI-0041) and the bare `rune sign` seal, and neither writes a seal that binds a pull request, reads the controller's -ledger, or verifies a seal the way the `owner-seal` check needs. +review scorecard, or verifies a seal the way the `owner-seal` check needs. ## What Changes @@ -21,7 +21,7 @@ ledger, or verifies a seal the way the `owner-seal` check needs. under a lease with every git setting that names a program pinned, flips the draft ready, and appends `Open-Seal-Nonce: <nonce>` to the body. `--queue` records the request for `rune sign next`. `KEYS` comes from the protected branch on `origin`, never from the working tree. -- `rune sign submit <bookmark>` reads the ledger the controller app publishes as a pull request comment and refuses +- `rune sign submit <bookmark>` reads the review scorecard the controller app publishes as a pull request comment and refuses unless the verdict on the head at the current generation is clean or `free-lanes-only` with a reason, every lane is terminal, no thread is open or disposed `owner`, every required check passes, and the receipt is present. The coverage state is recorded on the request. `rune sign queue <bookmark>` keeps the CLI-0041 in-place signing. @@ -37,13 +37,17 @@ ledger, or verifies a seal the way the `owner-seal` check needs. ## Capabilities -- sealed-review-ceremony (new) +### New Capabilities + +- `owner-key-marks-draft-ready`: the owner's key moves a pull request from draft to ready for review with an open-seal, including an adopted outside pull request. +- `scorecard-admits-the-merge`: the review scorecard admits a reviewed head, the owner acknowledges the view, and the key writes the merge-seal. +- `every-seal-gets-verified`: the verifier checks both seal kinds for the owner-seal check through git alone. ## Impact - `src/cli/sign/`: `seal.rs`, `verify.rs`, and the queue's `ceremony.rs`, `gh.rs`, `ledger.rs`, and `open.rs`. - `src/cli/mod.rs`: `Sign` gains `--seal`, `--pull-request`, and `--keys-ref`, and the queue gains `open` and `adopt`. -- `docs/decisions/`: CLI-0042. The `owner-key-signing` proposal now points at DECK-0017. +- `docs/decisions/`: CLI-0042. The `owner-key-seals-merges` proposal now points at DECK-0017. - The skeleton's `owner-seal.yaml` calls the verifier with `--pull-request` and `--keys-ref`, after fetching the base and the protected branch. diff --git a/docs/changes/owner-key-signs-merges/specs/every-seal-gets-verified/spec.md b/docs/changes/owner-key-signs-merges/specs/every-seal-gets-verified/spec.md new file mode 100644 index 00000000..0812b4b0 --- /dev/null +++ b/docs/changes/owner-key-signs-merges/specs/every-seal-gets-verified/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: The seal verifier serves the owner-seal check + +The validator MUST search for the open-seal between the pull request's remote base and the reviewed head. It reads at most two thousand commits, so a seal in merged history never qualifies. A missing base ref is an error. The nearest open-seal MUST be signed by a trusted key and name its tree, the remote's repository, the pull request's number, and its base. For an adoption-bookmark head that number is the outside pull request's. The pull request body MUST carry the seal's nonce as a nonce line. + +#### Scenario: Valid pair accepted + +- **WHEN** the ref is a merge-seal above a head that carries an open-seal naming the pull request +- **AND** that pull request's body carries the nonce and its head is the ref +- **THEN** the validator reports every check as `ok` and succeeds + +#### Scenario: Inherited open-seal rejected + +- **WHEN** a branch forked from a sealed branch opens its own pull request and the ref is that branch's head +- **THEN** the seal names the original pull request, the new one carries no nonce, and the validator reports a failed check + +### Requirement: A copied nonce is reported and does not fail the check + +Another open body that carries the nonce MUST be reported and MUST NOT fail the check. + +#### Scenario: Copied nonce rejected + +- **WHEN** another pull request pastes the nonce into its body, whether the original is open, closed, or merged +- **THEN** that pull request fails on the seal's number, or on the missing seal when the original merged. The original, while open, passes with the copy reported + +### Requirement: The check binds one open pull request to the ref + +The pull request under check is the one the caller names, which MUST be open. Else it is the one open pull request whose head is the ref, as the forge lists open pull requests. Two at one head fail the check. The pull request's head MUST be the ref. + +#### Scenario: One head under two bases + +- **WHEN** two open pull requests share the ref as head with different bases +- **THEN** the one the seal names passes when the caller names it, and the other fails +- **AND** with no pull request named, the check fails for want of one pull request + +### Requirement: The verifier resolves the ref and KEYS through git + +The validator MUST resolve the ref through git alone, so a plain checkout verifies. It MUST read the trusted key list from the ref the caller names, and never from the working tree. The default ref is the first of the remote's `main`, `master`, or `trunk` that exists. A ref without the key list is an error. The validator MUST print one line per check. It MUST report one of three outcomes: every check holds, one check fails, or the repository or the forge cannot be read. + +#### Scenario: Plain checkout + +- **WHEN** the validator runs in a plain clone with no other version control tool and the OpenPGP signing tool on the search path +- **THEN** a valid pair succeeds, a forged tree reports a failed check, and a forge outage reports that the forge cannot be read + +### Requirement: A merge-seal sits on the reviewed head it names + +When the ref's message is a merge-seal, the validator MUST check three things. The ref's sole parent is the `reviewed_sha` the message names. The ref's tree equals that parent's tree. The signature is from a trusted key. It MUST then search for the open-seal beneath `reviewed_sha`. Otherwise it MUST search beneath the ref. + +#### Scenario: Merge-seal with a moved parent rejected + +- **WHEN** the ref's message names a `reviewed_sha` that is not its sole parent +- **THEN** the validator reports the parent check as failed + +#### Scenario: Merge-seal with an unequal tree rejected + +- **WHEN** the ref is a child of the `reviewed_sha` it names and its tree differs from that parent's tree +- **THEN** the validator reports the tree check as failed diff --git a/docs/changes/owner-key-signs-merges/specs/owner-key-marks-draft-ready/spec.md b/docs/changes/owner-key-signs-merges/specs/owner-key-marks-draft-ready/spec.md new file mode 100644 index 00000000..95541b1c --- /dev/null +++ b/docs/changes/owner-key-signs-merges/specs/owner-key-marks-draft-ready/spec.md @@ -0,0 +1,103 @@ +## ADDED Requirements + +### Requirement: The owner opens a draft with an open-seal + +The signer MUST refuse to open a seal on a protected branch (`main`, `master`, `trunk`). It MUST refuse a bookmark with no open pull request at its head. When the bookmark names a change and its pull request file exists, the body MUST come from that file. Else it MUST come from a body file the owner names. Before the key touch the signer MUST print the bookmark, the base, the diff stat against it, and the body. Every call to the version control tool MUST pin the executable it delegates to. + +#### Scenario: Open refused on the protected branch + +- **WHEN** a session asks the signer to open a seal on `main` with a body file +- **THEN** the signer refuses, names the protected branch, and signs nothing + +#### Scenario: Open refused without a draft + +- **WHEN** no open pull request has the bookmark as its head +- **THEN** the signer refuses, tells the session to push and let the forge validator open the draft, and pushes nothing + +### Requirement: The open-seal commit names the draft + +The signer MUST write an empty commit above the head, move the bookmark there, and sign it with the pinned signing step. Its message is the open-seal label and a JSON (JavaScript Object Notation) object. It names the repository, base, draft number, tree, and a thirty-two-byte random nonce in lowercase hexadecimal. A read-back through the plain version control tool MUST show the head as sole parent, its tree, and the signed message. The key MUST be one the remote's protected branch trusts. + +#### Scenario: Draft opened with a seal + +- **WHEN** the bookmark has a draft at its head and the body passes the schema +- **THEN** the bookmark points at a signed empty commit above the old head. Its message names the repository, the base, the draft's number, the tree, and a nonce +- **AND** the remote holds that commit for the bookmark and no hook of the repository ran +- **AND** the draft is ready and the body ends with the nonce line + +### Requirement: The seal reaches the remote under pinned settings + +The signer MUST then push the seal to the remote under a lease on the head that was sealed. Every setting of the plain version control tool that names a program MUST be pinned on the command line. The hooks path points to an empty directory. The SSH (secure shell) command is the plain client. The askpass and proxy programs are set to nothing. The credential helper is reset to the system and user values. So nothing in the repository's own tree or config runs as the owner. + +#### Scenario: Seal pushed under a lease + +- **WHEN** the repository's own tree configures a hook or a program and the seal is pushed +- **THEN** the remote holds the sealed head under a lease on the head that was sealed +- **AND** no program the repository names runs as the owner + +### Requirement: The draft carries the nonce + +The signer MUST then mark the draft ready on the forge and replace the body with the validated body plus one nonce line. When the session asks to queue instead, the signer MUST record a request of kind `open` and sign nothing. The owner's next signing turn completes such a request after the acknowledgment. + +#### Scenario: Draft readied with the nonce + +- **WHEN** the seal is on the remote +- **THEN** the draft is ready and its body is the validated body plus one nonce line + +#### Scenario: Session queues the open request + +- **WHEN** a session asks the signer to queue the open seal for `change/x` +- **THEN** the signer records a request of kind `open` and signs nothing +- **AND** the owner's next signing turn completes that request after the acknowledgment + +### Requirement: Open refuses a head that is not a fresh draft + +The signer MUST refuse to open a seal in six cases. The bookmark does not descend from its remote-tracking head. The bookmark's head carries a signature from a key outside the trusted key list. More than one open pull request sits at the bookmark's head. The pull request is not a draft. The pull request's head is not the bookmark's head. The body fails the pull request body schema. + +#### Scenario: Open refused on a foreign branch, a ready draft, a stale head, two drafts, a bad body, a foreign key + +- **WHEN** the bookmark, its pull request, or the body fails one of the six conditions the requirement lists +- **THEN** the signer refuses, names the refusal, and the remote still holds the head it held + +### Requirement: Open seals above an owner-signed head and reads the branch's body + +An owner-signed head MUST be accepted, because the open-seal is a new commit above it. The body MUST be read from the change's pull request file in the bookmark's own tree. So the signer's workspace need not be on that branch. The push MUST get the forge credential for its own process. Besides it, the push trusts only system- and user-scope credential helpers. The signer MUST refuse before signing when an HTTPS (secure web) push address has no credential. + +#### Scenario: Open refuses an HTTPS origin with no trusted helper + +- **WHEN** the push address is HTTPS, the forge client is logged out for the host, and no system- or user-scope helper covers it +- **THEN** the signer refuses before the key touch and names the forge login and an SSH remote as the fixes + +#### Scenario: Open accepts an owner-signed head + +- **WHEN** the owner signed the head before asking for the open seal +- **THEN** the signer says the head is already owner-signed and seals above it + +#### Scenario: Open reads the body from the branch + +- **WHEN** the signer opens a seal for `change/x` from a workspace on another branch +- **THEN** the body is the pull request file as `change/x` commits it, and the working copy's file is ignored + +### Requirement: The owner adopts an outside pull request + +The signer MUST refuse an adoption early unless the forge reports the owner as an administrator. The owner test is the key. It MUST refuse a pull request that is not open, or whose adoption bookmark exists locally or on the remote. It MUST validate the outside body against the schema and fetch the pull request's head from the remote under the pinned transport settings. It MUST refuse a fetched commit that differs from the pull request's head, then create the adoption bookmark there. + +#### Scenario: Adopt refused without admin permission + +- **WHEN** the forge does not report the owner as an administrator of the repository +- **THEN** the signer refuses before it reads the pull request + +### Requirement: The adopted head is sealed before anything is pushed + +The signer MUST print the view and sign the open-seal above the fetched head before anything is pushed. The seal names the outside pull request. Only then it MUST push the sealed adoption bookmark to the remote under a lease that the branch did not exist. It MUST poll the forge for the forge validator's draft on that bookmark. It MUST mark that draft ready and write the nonce into its body. A refused key MUST leave nothing on the remote and the bookmark back on the fetched head. + +#### Scenario: Outside head adopted + +- **WHEN** the owner adopts pull request 12 and the draft for `adopt/12` appears +- **THEN** `adopt/12` carries an open-seal above the fork's head that names pull request 12 +- **AND** the remote holds only the sealed head for the branch, and the draft is ready with the nonce in its body + +#### Scenario: Key refused during adopt + +- **WHEN** the owner cancels the signature +- **THEN** the remote has no `adopt/12`, no draft was readied, and the local bookmark is at the fetched head diff --git a/docs/changes/owner-key-signs-merges/specs/scorecard-admits-the-merge/spec.md b/docs/changes/owner-key-signs-merges/specs/scorecard-admits-the-merge/spec.md new file mode 100644 index 00000000..0ba7dffa --- /dev/null +++ b/docs/changes/owner-key-signs-merges/specs/scorecard-admits-the-merge/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: The controller's review scorecard admits a head to the merge-seal + +The signer MUST queue a head only on four conditions. Every lane in `lanes` is `completed`, `completed-no-findings`, `skipped`, `ineligible`, `failed`, or `rate-limited`. Every thread in `threads` is disposed `fixed` or `rejected`. Every required check passes. The receipt qualifies the head. A thread disposed `owner` or undisposed MUST be refused by name. A head that carries the owner's open-seal MAY be submitted. The in-place signing path of CLI-0041 stays and reads no review scorecard. + +#### Scenario: Submit refused on an open thread + +- **WHEN** the review scorecard holds a thread with no disposition +- **THEN** the signer refuses the submission, names the thread, and records nothing + +### Requirement: The verdict binds to the reviewed head and its generation + +The signer MUST refuse a submission unless the review scorecard's `reviewed_sha` is the bookmark's head. It MUST also refuse unless one of two verdict conditions holds. Either the `verdict` is `clean` and binds to that `reviewed_sha` at the review scorecard's `generation`. Or `verdict` is null and `coverage` starts with `free lanes only`. In the second case the coverage is recorded as `free-lanes-only` with the coverage text as the reason. + +#### Scenario: Submit refused on an undisposed generation + +- **WHEN** the review scorecard's generation is greater than the generation the verdict binds to +- **THEN** the signer refuses the submission, names both generations, and records nothing + +### Requirement: The review scorecard comes from the controller app + +The signer MUST keep the receipt rules of CLI-0041. It MUST read the review scorecard that the forge validator publishes as a check run named `ledger` on the head. It is the newest such check run the forge validator created, read from the forge's check-run listing. A same-named check run from any other app MUST be ignored, and the refusal names the app. The check run's first output line is the review scorecard line: artifact id, digest, generation, pull request number, and reviewed head. + +#### Scenario: Submit ignores a review scorecard from another app + +- **WHEN** the only `ledger` check run is another app's, or a newer check run from another app comes after the forge validator's +- **THEN** the signer refuses for want of a forge validator review scorecard in the first case, and reads the forge validator's check run in the second + +### Requirement: Submit checks the artifact and the pull request + +The signer MUST download the artifact the review scorecard line names from the forge and read the review scorecard file from it. It MUST refuse unless the file's sha256 equals `digest` and its `reviewed_sha` and `generation` equal the line's. The pull request is the one open pull request whose head is the bookmark, as the forge lists it. It MUST NOT be a draft, and the line's `pull_request` MUST be its number. + +#### Scenario: Submit refuses an artifact that is not the review scorecard + +- **WHEN** the artifact the forge validator's line names does not hash to the line's `digest` +- **THEN** the signer refuses the submission, says the artifact is not the review scorecard, and records nothing + +### Requirement: The request records the coverage it was admitted on + +The signer MUST record the repository, pull request number, base, `reviewed_sha`, `generation`, `digest`, verdict, reason, lanes, and threads on the request as its coverage. + +#### Scenario: Free lanes only in the view + +- **WHEN** the review scorecard's verdict is null, its coverage starts with `free lanes only`, and every other condition holds +- **THEN** the head is queued and the owner's next signing turn prints `free-lanes-only` with that reason as the coverage + +### Requirement: The owner acknowledges the view before the merge-seal + +For a `merge` or `open` request the signer MUST print a view before the key touch. The view holds the bookmark, base, diff stat against it, and the coverage (lanes and thread dispositions) or the body. It holds the proof path and exit line when a receipt exists, and the fields the signature authorizes. The signer MUST sign only on a `y` or `Y` read as one line from the terminal, or from the file the owner's environment names. A refusal MUST leave the request current and report a failure. + +#### Scenario: Next declined + +- **WHEN** the owner answers `n` to the acknowledgment +- **THEN** the view was printed, nothing is signed, the request stays current, and the signer reports a failure + +### Requirement: The view names the fields the signature authorizes + +For a merge request the fields the signature authorizes MUST be the repository, the bookmark, `reviewed_sha`, and `generation`. For an open request they MUST be the repository, the bookmark, the base, and the tree. The view prints them, so the owner sees what the key binds before the key touch. + +#### Scenario: Merge view lists its authorized fields + +- **WHEN** the current request is a merge request and the signer prints the view +- **THEN** the view lists the repository, the bookmark, `reviewed_sha`, and `generation` as the fields the signature authorizes + +### Requirement: The merge-seal names the reviewed head and its generation + +On acknowledgment of a merge request the signer MUST write an empty commit whose sole parent is `reviewed_sha`. Its message is the merge-seal label and one JSON object with `reviewed_sha` and `generation`. The signer MUST move the bookmark onto it and sign it through the pinned signing step. It MUST read it back through the plain version control tool and record the signed commit. It MUST NOT push. A merge request whose bookmark no longer points at `reviewed_sha` MUST be `stale`. + +#### Scenario: Next acknowledged + +- **WHEN** the owner answers `y` +- **THEN** the bookmark points at a signed empty commit whose sole parent is `reviewed_sha` and whose message names `reviewed_sha` and the generation, and nothing was pushed diff --git a/docs/changes/sealed-review-ceremony/tasks.md b/docs/changes/owner-key-signs-merges/tasks.md similarity index 71% rename from docs/changes/sealed-review-ceremony/tasks.md rename to docs/changes/owner-key-signs-merges/tasks.md index 3cdac775..6f69deab 100644 --- a/docs/changes/sealed-review-ceremony/tasks.md +++ b/docs/changes/owner-key-signs-merges/tasks.md @@ -4,7 +4,7 @@ - [x] 1.1 Seal messages and the nonce in `seal.rs`: `open-seal: {repo, base, pull_request, tree, nonce}` and `merge-seal: {reviewed_sha, generation}` - [x] 1.2 `rune sign open <bookmark> [--body-file] [--queue]`: the refusals, the view, the seal, the leased push under pinned transport settings, the flip, the nonce line -- [x] 1.3 `rune sign submit <bookmark>`: the ledger read through `gh api` from the controller app alone, the admission rule, the coverage on the request +- [x] 1.3 `rune sign submit <bookmark>`: the review scorecard read through `gh api` from the controller app alone, the admission rule, the coverage on the request - [x] 1.4 `rune sign next`: the view, one `y/N` acknowledgment, the merge-seal above `reviewed_sha`, the queued open flow - [x] 1.5 `rune sign adopt <number>`: owner check, fetch, `adopt/<number>`, view, seal, push, draft wait, ready - [x] 1.6 `rune sign --verify --seal <ref> [--pull-request] [--keys-ref]`: both seal kinds with exit codes, through git alone on a plain checkout, `KEYS` from the protected ref, the seal searched in `origin/<base>..<head>` and bound to the pull request's number @@ -12,13 +12,13 @@ ## 2. Verification -- [x] 2.1 Unit tests: nonce generation, seal message parsing, body nonce, ledger parsing and admission -- [x] 2.2 Integration tests with a stub `gh`, a bare `origin`, and a hook that records if it runs: every open refusal, open seals and appends the nonce, the queued open, submit refusals and the ledger author, free-lanes-only in the view, next view and acknowledgment, adopt seals before it pushes, verify accepts a pair and rejects the inherited seal, the copied nonce, the merged seal, the second base, and the forged parent and tree, on the jj workspace and on a plain `git clone` -- [x] 2.3 `cargo fmt --check`, `cargo clippy --all-targets`, `cargo test`, `rune validate`, `rune spec validate sealed-review-ceremony`, Vale +- [x] 2.1 Unit tests: nonce generation, seal message parsing, body nonce, review scorecard parsing and admission +- [x] 2.2 Integration tests with a stub `gh`, a bare `origin`, and a hook that records if it runs: every open refusal, open seals and appends the nonce, the queued open, submit refusals and the review scorecard author, free-lanes-only in the view, next view and acknowledgment, adopt seals before it pushes, verify accepts a pair and rejects the inherited seal, the copied nonce, the merged seal, the second base, and the forged parent and tree, on the jj workspace and on a plain `git clone` +- [x] 2.3 `cargo fmt --check`, `cargo clippy --all-targets`, `cargo test`, `rune validate`, `rune spec validate owner-key-signs-merges`, Vale ## 3. Follow-up - [ ] 3.1 Behavior proof per DECK-0015 once the skeleton's `owner-seal.yaml` calls the verifier -- [ ] 3.2 `rune sign --verify --seal` compares the merge-seal generation with the ledger when the controller publishes it on `main` +- [ ] 3.2 `rune sign --verify --seal` compares the merge-seal generation with the review scorecard when the controller publishes it on `main` - [x] 3.3 Integration test for `rune sign adopt` against a local bare remote serving `refs/pull/<n>/head` - [ ] 3.4 The skeleton's `owner-seal.yaml` passes `--pull-request` and `--keys-ref`, fetches the base and the protected branch, and imports the `KEYS` blob it verifies against diff --git a/docs/changes/owner-signing-queue/specs/owner-signing-queue/spec.md b/docs/changes/owner-signing-queue/specs/owner-signing-queue/spec.md deleted file mode 100644 index ed9de4f6..00000000 --- a/docs/changes/owner-signing-queue/specs/owner-signing-queue/spec.md +++ /dev/null @@ -1,223 +0,0 @@ -## ADDED Requirements - -### Requirement: A session queues a signing request - -`rune sign queue <bookmark>` and `rune sign submit <bookmark>` MUST record a signing request for the bookmark in the -current jj workspace, or in the workspace named by `--repo`. The request MUST carry a request id, the repository's -git directory, the workspace path, the bookmark, the head's change id, commit id, tree id, author, description, and -parent change ids, the receipt path with its digest, and the request time. It MUST NOT sign anything. On success it -MUST print one line that names the request and the command the owner runs, and MUST attempt a desktop notification -without failing when none can be sent. - -#### Scenario: Head queued after a passing dry run - -- **WHEN** a session runs `rune sign queue change/x --receipt dryrun.log`, the receipt names the head's commit id, and - it ends with `dryrun-exit=0` -- **THEN** a request exists for `change/x` with the head's identity fields and the receipt digest -- **AND** the output names `rune sign next` as the owner's command - -### Requirement: The queue refuses a request it cannot trust - -`--receipt` is required. The receipt's last line MUST match `<name>exit=<status>` where `<name>` is empty or one -lowercase word and a hyphen, so `exit=0` and `dryrun-exit=0` qualify and `not-an-exit=0` does not. The command MUST -refuse a bookmark that does not exist, a head that is already signed, a receipt that does not name the head's commit -id, a receipt whose last line is not such a line with status zero, and a bookmark that already has a request that is -not stale. Requests are stored by request id, never by bookmark name, so a bookmark with a slash cannot nest or -collide. - -#### Scenario: Receipt checked a different commit - -- **WHEN** the receipt does not contain the head's commit id -- **THEN** the command exits nonzero, names the commit the receipt covers, and records nothing - -#### Scenario: Receipt reports a failure - -- **WHEN** the receipt's last exit line is nonzero -- **THEN** the command exits nonzero, names the receipt, and records nothing - -#### Scenario: Head already signed - -- **WHEN** the bookmark's head carries a signature -- **THEN** the command exits nonzero and records nothing - -#### Scenario: Receipt has no exit line - -- **WHEN** the receipt's last line is not an exit line -- **THEN** the command exits nonzero and records nothing - -### Requirement: The queue is one ledger for every repository - -The queue is one ledger for every repository, `$XDG_STATE_HOME/rune/sign-queue` unless `RUNE_STATE_DIR` replaces the -`rune` directory, and when it holds no request the listing and `next` MUST name that directory, so an empty queue -and a different directory are told apart. `--json` MUST emit the same records as a JSON array. `--prune` MUST remove -stale, signed, and failed requests and report each removal, and MUST keep an `unverified` one, because only the -owner's `next` can tell a crashed signer from a foreign key. - -#### Scenario: Queue is empty - -- **WHEN** no request exists and a session or the owner runs `rune sign queue` or `rune sign next` -- **THEN** the output names the ledger directory it read - -### Requirement: The queue lists requests in stack order - -`rune sign queue` with no bookmark MUST list every request across repositories. Within one repository, identified by -its git directory so that workspaces of one repository share an order, the list MUST place a request before every -request whose head descends from it, on any parent path. A request whose workspace no longer exists MUST list as -`stale`, MUST stay removable with `drop` and `--prune`, and MUST NOT stop any command for the other requests. - -#### Scenario: Stack of two - -- **WHEN** `change/base` and `change/top` are queued and `change/top` descends from `change/base` -- **THEN** the list shows `change/base` first and `change/top` as `blocked` until `change/base` is signed - -### Requirement: Each request shows one state - -Each request MUST show one state: `current` when the -bookmark's head has the recorded change id, tree id, author, description, and parent change ids, `stale` when any of -them differs, `blocked` when a queued ancestor is not signed, `claimed` when a signer holds it, `unverified` when the -bookmark's head carries a signature the queue has not verified, whether a signer that died after signing or a hand -signing produced it, and `signed` when the queue verified and recorded it. A request MAY differ from its record only -in commit id and signature and stay `current`, which is what signing an ancestor changes. - -#### Scenario: Head amended after the request - -- **WHEN** the bookmark's head changes its tree, author, description, or parents after the request -- **THEN** the request is listed as `stale` - -#### Scenario: Head rewritten by signing an ancestor - -- **WHEN** the bookmark's head is rewritten with a new commit id and the same recorded identity fields -- **THEN** the request stays `current` - -### Requirement: The owner signs from the queue - -`rune sign next` MUST sign the first `current` request in stack order, and `rune sign all` MUST sign every `current` -request in that order, stopping at the first failure. A `stale` or `blocked` request MUST be reported as skipped and -MUST NOT stop the search for the next `current` one, and the invocation MUST exit nonzero when one was skipped. The -command MUST retry `jj sign` only when its output reports a timeout, at most three times, and MUST stop on a -cancellation or any other failure. The exit status MUST be nonzero when any request failed or was skipped. - -#### Scenario: Pinentry timed out once - -- **WHEN** the first `jj sign` reports a timeout and the second succeeds -- **THEN** the request is recorded as signed with the new commit id and the command exits zero - -#### Scenario: Pinentry cancelled - -- **WHEN** `jj sign` reports that the operation was cancelled -- **THEN** the command releases the claim, does not retry, and exits nonzero - -### Requirement: Each request is claimed and verified under the lock - -For each request the command MUST claim it by request id, re-derive its state under the lock, refuse it when it is -not `current`, record the head's commit id as observed under the lock, run `jj sign -r <that commit id>` in the -request's workspace, confirm that the bookmark now points at a commit with the recorded identity fields and a -signature, verify that signature against the repository's `KEYS` file with the same check as `rune sign --verify`, and -record the signed commit id under the same request id. The commands MUST NOT push. - -#### Scenario: Bookmark moved after the claim - -- **WHEN** the bookmark's head differs from the commit id observed under the lock when the command runs `jj sign` -- **THEN** nothing is signed, the request is reported as stale, and the command exits nonzero - -#### Scenario: Signature from another key - -- **WHEN** the signature on the signed commit does not match a key in `KEYS` -- **THEN** the request is recorded as failed, the command names the fingerprint, and exits nonzero - -### Requirement: The signer locks the repository queue before it signs - -Before claiming a request the command MUST check that its receipt is unchanged on disk and still qualifies the -recorded commit. Before signing anything in a repository the command MUST take a lock on that repository's queue, -held until the invocation ends, and a second invocation MUST report the holder and exit nonzero. The command MUST -take the locks before it computes the stack order, and MUST read the owner's signing settings only when a request is -about to be signed. A `stale` or `blocked` request MUST be skipped and reported, never signed. - -#### Scenario: Two owners at once - -- **WHEN** two invocations of `rune sign next` start together on one repository -- **THEN** exactly one holds the lock and signs, and the other reports the holder and exits nonzero - -### Requirement: Claims are recoverable - -A claim MUST record the signer's process id and start time. A claim whose process is gone or older than a bounded -age MUST be reclaimable. Before signing a reclaimed request the command MUST re-derive its state, and when the -bookmark's head already carries a verified signature with the recorded identity fields it MUST record the request as -signed without signing again. A signature from a key outside `KEYS` on a queued head MUST be recorded as a failure, -never signed over. - -#### Scenario: Signer crashed after signing - -- **WHEN** a request is claimed by a dead process and its head is already signed with the recorded identity -- **THEN** `rune sign next` records it as signed and does not run `jj sign` - -### Requirement: The ceremony command and the queue do not mix - -The bare `rune sign`, `rune sign --amend`, `rune sign --tag`, and `rune sign --verify` forms MUST keep their meaning: a -bare word after a ceremony flag is the commit to tag, never a queue subcommand, so `rune sign --tag v1 next` tags the -commit named `next`. A queue subcommand followed by a ceremony flag MUST be rejected with an error. - -#### Scenario: Ceremony flag before a subcommand name - -- **WHEN** the owner runs `rune sign --tag v1 next` -- **THEN** the command tags the commit named `next` and never touches the queue - -#### Scenario: Subcommand followed by a ceremony flag - -- **WHEN** the owner runs `rune sign next --tag v1` -- **THEN** the command exits nonzero and names the unexpected flag - -### Requirement: Requests are inspected and withdrawn - -`rune sign show <bookmark>` MUST print the request record with its state, and `rune sign drop <bookmark>` MUST remove -the request unless it is claimed. Both resolve the bookmark in the current workspace's repository, or in the one named -by `--repo`, and MUST exit nonzero when no request exists for it there. `--id <request id>` selects a request -without a repository. - -#### Scenario: Request withdrawn - -- **WHEN** a session runs `rune sign drop change/x` -- **THEN** the request no longer appears in `rune sign queue` - -### Requirement: Nothing the repository configures is trusted - -The owner runs the queue against a repository a session controls, and jj renders every read through templates and -revsets that a repository's own configuration can redefine. When the owner signs, every signing setting jj reads, -the backend, the program, the behavior, and the key, MUST come from the owner's user-scope configuration or the tool -default, never from the repository's own configuration, and the behavior MUST be `drop`, so the rewrite of -descendants signs nothing else. - -#### Scenario: Repository names a hostile signing program - -- **WHEN** the repository's config sets `signing.backends.gpg.program` to a program of its own -- **THEN** `rune sign next` signs through the owner's user-scope program and the repository's program never runs - -### Requirement: Every read goes through git - -Every read MUST go through git: the queue MUST run `jj git export` and resolve the bookmark as a git ref, read the -identity fields from the commit object, compute ancestry with `git merge-base`, and verify signatures with -`git verify-commit --raw`, the same check as `rune sign --verify`, with the gpg program pinned on the command line. -A bookmark jj refuses to export as conflicted MUST be refused at every step. A configuration that aliases a template -keyword the settings listing uses MUST be refused. - -#### Scenario: Bookmark is conflicted - -- **WHEN** jj refuses to export a bookmark because it is conflicted -- **THEN** queueing, listing, and signing refuse it and name the conflict - -#### Scenario: Repository aliases a template keyword - -- **WHEN** the repository's config defines `template-aliases.value` -- **THEN** `rune sign next` refuses to sign and names the alias - -### Requirement: The repository lock is an operating-system file lock - -The repository lock MUST be an operating-system file lock held for the signer's lifetime, so a crash releases it and -nothing can steal it, and `submit`, `drop`, and `--prune` MUST take it too, and `--prune` MUST observe each request -again under it before removal. The receipt path MUST be recorded absolute, because the owner signs from another -directory. `rune sign queue <bookmark>` under `--json` MUST print one JSON object. - -#### Scenario: Prune removes a request under the lock - -- **WHEN** the owner runs `--prune` and the command takes the repository lock -- **THEN** it observes each request again under that lock before it removes one diff --git a/docs/changes/provider-plugin-manifests/design.md b/docs/changes/plugins-never-break-install/design.md similarity index 100% rename from docs/changes/provider-plugin-manifests/design.md rename to docs/changes/plugins-never-break-install/design.md diff --git a/docs/changes/provider-plugin-manifests/proposal.md b/docs/changes/plugins-never-break-install/proposal.md similarity index 95% rename from docs/changes/provider-plugin-manifests/proposal.md rename to docs/changes/plugins-never-break-install/proposal.md index d01d9b6d..e62e7252 100644 --- a/docs/changes/provider-plugin-manifests/proposal.md +++ b/docs/changes/plugins-never-break-install/proposal.md @@ -20,7 +20,7 @@ no way to react to rune events. Governing decision: CLI-0035. ## Capabilities -- provider-plugin-layout (new) +- plugins-never-break-install (new) ## Impact diff --git a/docs/changes/provider-plugin-manifests/specs/provider-plugin-layout/spec.md b/docs/changes/plugins-never-break-install/specs/plugins-never-break-install/spec.md similarity index 100% rename from docs/changes/provider-plugin-manifests/specs/provider-plugin-layout/spec.md rename to docs/changes/plugins-never-break-install/specs/plugins-never-break-install/spec.md diff --git a/docs/changes/provider-plugin-manifests/tasks.md b/docs/changes/plugins-never-break-install/tasks.md similarity index 100% rename from docs/changes/provider-plugin-manifests/tasks.md rename to docs/changes/plugins-never-break-install/tasks.md diff --git a/docs/changes/prose-length-caps/specs/prose-length-caps/spec.md b/docs/changes/prose-length-caps/specs/prose-length-caps/spec.md deleted file mode 100644 index 88b21c6c..00000000 --- a/docs/changes/prose-length-caps/specs/prose-length-caps/spec.md +++ /dev/null @@ -1,48 +0,0 @@ -## ADDED Requirements - -### Requirement: Requirement statements and scenario steps have a word cap - -`rune spec validate` and `rune spec doctor` MUST report an error for a requirement statement over 100 words and for a `WHEN`, `THEN`, `AND`, or `GIVEN` step over 30 words. A requirement statement is the prose between `### Requirement:` and the next heading. Words are whitespace runs after inline code is blanked and fenced code is skipped. The rule applies to canonical specifications and to change deltas alike. The diagnostic MUST name the file, the heading line, the count, and the limit. - -#### Scenario: Requirement runs to a wall of text - -- **WHEN** a requirement statement in a delta has 191 words -- **THEN** validation reports `delta-requirement-too-long` at the heading line and exits nonzero - -#### Scenario: Step lists a command - -- **WHEN** a `THEN` step holds a long command in inline code and ten words of prose -- **THEN** validation passes, because code spans do not count - -### Requirement: The changelog keeps one line per change - -`rune docs check` MUST lint `CHANGELOG.md` when the file exists. The first heading MUST be `# Changelog`. Release headings MUST be `## [Unreleased]` first, then `## [X.Y.Z] - YYYY-MM-DD` newest first. Groups MUST come from `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`, in that order, each at most once per release. A group MUST hold only list lines that start with a hyphen. A change line MUST be at most 200 characters, MUST start with a capitalized verb or `**Breaking:**`, and MUST NOT carry a `type:` prefix or an em-dash. A release MUST hold at most one notice paragraph and no other prose. An absent changelog MUST NOT be an error. - -#### Scenario: Entry is a paragraph - -- **WHEN** a change line has 1,400 characters -- **THEN** `rune docs check` reports `entry-length` with the line number and exits nonzero - -#### Scenario: Entry starts with the command name - -- **WHEN** a change line starts with a code span instead of a verb -- **THEN** `rune docs check` reports `entry-verb` - -#### Scenario: Repository keeps no changelog - -- **WHEN** the repository root has no `CHANGELOG.md` -- **THEN** `rune docs check` reports the link check alone and exits by that result - -### Requirement: Names have a word floor - -`rune spec validate` and `rune spec doctor` MUST report an error for a change id or a capability name with fewer hyphen-separated words than `spec.min_name_words` in the repository config, default 3. A nested capability is judged by its last segment. A value of `0` MUST turn the rule off. - -#### Scenario: Two-word capability - -- **WHEN** a change adds a capability directory named `cast-player` with the default config -- **THEN** validation reports `delta-capability-name-short` and exits nonzero - -#### Scenario: Repository turns the rule off - -- **WHEN** the repository config sets `spec.min_name_words: 0` -- **THEN** validation reports no name error diff --git a/docs/changes/archive/2026-09-13-adopt-session-state/proposal.md b/docs/changes/record-provenance-for-adoptions/proposal.md similarity index 92% rename from docs/changes/archive/2026-09-13-adopt-session-state/proposal.md rename to docs/changes/record-provenance-for-adoptions/proposal.md index 1ff00f02..642ab8f7 100644 --- a/docs/changes/archive/2026-09-13-adopt-session-state/proposal.md +++ b/docs/changes/record-provenance-for-adoptions/proposal.md @@ -1,6 +1,6 @@ --- adr: "docs/decisions/CLI-0027 Temporary Adoption Session State.md" -status: abandoned +status: implemented --- # Adopt Session State @@ -19,7 +19,7 @@ The adoption review state machine currently commits its entire block ledger besi ## Capabilities -- adopt-session-state (new, supersedes the permanent review-record requirements in adopt-review and adopt-hardening) +- record-provenance-for-adoptions (new, supersedes the permanent review-record requirements in adopt-review and adopt-hardening) ## Impact diff --git a/docs/changes/record-provenance-for-adoptions/specs/record-provenance-for-adoptions/spec.md b/docs/changes/record-provenance-for-adoptions/specs/record-provenance-for-adoptions/spec.md new file mode 100644 index 00000000..5be994f1 --- /dev/null +++ b/docs/changes/record-provenance-for-adoptions/specs/record-provenance-for-adoptions/spec.md @@ -0,0 +1,42 @@ +## MODIFIED Requirements + +### Requirement: Temporary block review + +Rune MUST store block text, verdicts, notes, flags, and timestamps outside the adopted artifact and its `.provenance` directory. Git worktrees MUST use worktree-specific metadata. Each session key MUST include the canonical module root and module-relative artifact path. Non-Git modules MUST use user state keyed by the canonical module root. + +#### Scenario: Two worktrees review artifacts + +- **WHEN** two linked worktrees start adoption sessions +- **THEN** each worktree discovers only its own session + +#### Scenario: Two modules use the same artifact path + +- **WHEN** two modules in one Git repository review artifacts at the same module-relative path +- **THEN** their session paths differ and each module discovers only its own session + +### Requirement: Concise finalized provenance + +Finalize MUST require a verdict for every block. It MUST update each adopted file's source-level `adopt/v1` sidecar before it removes the temporary session. It MUST NOT write `review.yaml` or `*.review.yaml` into the source tree. + +#### Scenario: Adoption finalizes + +- **WHEN** every block has a verdict and the artifact passes validation +- **THEN** reviewed sidecars contain final digests and the temporary session is removed + +### Requirement: Sidecar authority + +Doctor and reseal MUST use reviewed `adopt/v1` sidecars as authority. Doctor MUST report legacy review ledgers without deleting them. Reseal MUST refuse pending or unreviewed artifacts. + +#### Scenario: Reviewed tree has no ledger + +- **WHEN** reviewed sidecar digests match their subjects +- **THEN** doctor reports no integrity error + +### Requirement: Canonical model identities + +Authorship validation MUST ignore a trailing `1m` context suffix after a model version digit. The accepted author list MUST contain only canonical identities without the suffix. + +#### Scenario: One-million-context identity + +- **WHEN** a commit uses `claude-opus-51m` or `claude-fable-51m` +- **THEN** validation compares it as `claude-opus-5` or `claude-fable-5` diff --git a/docs/changes/archive/2026-09-13-adopt-session-state/tasks.md b/docs/changes/record-provenance-for-adoptions/tasks.md similarity index 100% rename from docs/changes/archive/2026-09-13-adopt-session-state/tasks.md rename to docs/changes/record-provenance-for-adoptions/tasks.md diff --git a/docs/changes/render-spec-glossary/proposal.md b/docs/changes/render-spec-glossary/proposal.md deleted file mode 100644 index c813daf7..00000000 --- a/docs/changes/render-spec-glossary/proposal.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -status: proposed ---- - -# Render Spec Glossary - -## Why - -A reader of a specification meets a term in italics and wants its definition one hop away. That hop exists only when the repository carries a human-readable glossary. The ontology, `ontology/rune.ttl`, is the single home of every term, and it is Turtle, which a reader of Markdown does not open. `rune spec glossary` prints the term list to a terminal, so the list lives in no file a reader or a link can reach. The deck keeps a Python script that writes and checks `docs/specs/glossary.md` for a rune without the command, and that script is a second renderer of the same bytes. - -OpenSpec has no glossary artifact or command: the `dist/` tree of openspec 1.12.0 holds no match for the word, and its README links only the project's own documentation page. The CLI owns the file. - -## What Changes - -- `rune spec glossary --write [PATH]` writes the glossary file from the ontology: a header, one entry per labeled term, and the reference definitions. PATH defaults to `glossary.md` beside the capability specifications. -- `rune spec glossary --check [PATH]` renders the same text in memory, compares bytes with the file, prints the terms that differ, and exits 1 on drift or a missing file. -- The bare command and `--json` keep printing the term list. - -## Capabilities - -- render-spec-glossary (new) - -## Impact - -- `rune-docs/src/spec/terms.rs`, `src/cli/spec.rs`, `src/cli/mod.rs`, `tests/spec_terms.rs`. -- `docs/walkthroughs/Spec.md`, the two changelogs. -- The deck replaces `scripts/fallback/generate-glossary` with the two flags and adds the check to CI. diff --git a/docs/changes/render-spec-glossary/specs/render-spec-glossary/spec.md b/docs/changes/render-spec-glossary/specs/render-spec-glossary/spec.md deleted file mode 100644 index cb72db09..00000000 --- a/docs/changes/render-spec-glossary/specs/render-spec-glossary/spec.md +++ /dev/null @@ -1,24 +0,0 @@ -## ADDED Requirements - -### Requirement: The glossary is rendered from the ontology - -`rune spec glossary --write [PATH]` MUST write the glossary from the ontology: a header that names the ontology path and the write command, one `- **label**: comment [TAG]` line per labeled term sorted by label, then one `[TAG]: <iri>` definition per term. PATH defaults to `glossary.md` beside the capability specifications, and a relative PATH resolves against the source root. One ontology MUST produce the same bytes on every write. - -#### Scenario: Two writes read one ontology - -- **WHEN** the owner runs `--write` twice over one ontology -- **THEN** the file holds the same bytes after each write, and every labeled term appears with its IRI, comment, and tag - -### Requirement: The check reports drift - -`rune spec glossary --check [PATH]` MUST render the glossary in memory and compare it byte for byte with the file at PATH. A match MUST exit 0. A difference MUST print the path, the label of each term whose line is missing, extra, or changed, and the write command, then exit 1. A missing file MUST print the path and exit 1. `--write` and `--check` MUST NOT combine. - -#### Scenario: Term line edited in the file - -- **WHEN** one entry line in the file differs from the rendered line for that term -- **THEN** the check prints the term's label and the write command, and exits 1 - -#### Scenario: Glossary file absent - -- **WHEN** no file exists at PATH -- **THEN** the check prints the path and exits 1 diff --git a/docs/changes/render-spec-glossary/tasks.md b/docs/changes/render-spec-glossary/tasks.md deleted file mode 100644 index 0bfa6b8f..00000000 --- a/docs/changes/render-spec-glossary/tasks.md +++ /dev/null @@ -1,16 +0,0 @@ -## 1. Implementation - -- [ ] 1.1 `terms.rs`: render the glossary file (header, entries, definitions) from `GlossaryOutput`, write it, and compare a file against the rendering by term -- [ ] 1.2 `src/cli/mod.rs` and `src/cli/spec.rs`: `--write [PATH]` and `--check [PATH]` on `rune spec glossary`, mutually exclusive, with `--json` - -## 2. Verification - -- [ ] 2.1 Unit tests: two writes give the same bytes, a clean check, a check after one edited term, a check with no file -- [ ] 2.2 Integration tests: write then check through the binary, drift exits 1 and names the term -- [ ] 2.3 `cargo fmt --check`, clippy with `-D warnings`, the full suite, `rune spec validate render-spec-glossary` -- [ ] 2.4 The CLI output over the deck's ontology, diffed against the deck's fallback script - -## 3. Documentation - -- [ ] 3.1 `docs/walkthroughs/Spec.md`, `CHANGELOG.md`, `rune-docs/CHANGELOG.md` -- [ ] 3.2 Proof under `docs/proofs/render-spec-glossary/` diff --git a/docs/changes/review-and-proof/design.md b/docs/changes/review-and-proof/design.md deleted file mode 100644 index 4257d925..00000000 --- a/docs/changes/review-and-proof/design.md +++ /dev/null @@ -1,24 +0,0 @@ -# Design - -The full design, the adversarial pass, the proof contract, the hand partition, and the proof of the proof mechanism live in the workshop under `docs/specs/2026-09-24-rune-review/`. This file holds what the code needs. - -## Modules - -- `src/cli/closure/`: `Target` (WorkingCopy, Revisions { from, to }, Change(id)) resolves to `Resolved { head, base, parts, scenarios }`. `Part { name, role, paths, digest }`. The role map is a table per repository kind: the cli crate layout, or a deck's `deck.yaml` domains. Reading order and the deepest-directory split live here and nowhere else. -- `src/cli/proof/`: `Frontmatter { change, head, recorded, transcript, scenes }`, `Scene { scenario, kind, model }`, the README reader and writer, `scaffold`, `run`, `check`. The runner parses the fences itself in `fence.rs`, because trycmd's `TestCases::run` returns nothing and keeps outcomes private. It runs each command with the standard process API with stdout and stderr on one capture file (`runner.rs`), and matches output with its own `[..]` and `...` rules (`matcher.rs`), so every scene has an outcome the runner owns. A `< text` line after the `$` and `>` lines feeds the step's standard input from a file the runner writes beside the capture, so a script or a brief lives in the fence. Each scene gets its own temporary directory, a `$ cd` first line sets the working directory under the repository root, and `RUNE_PROOF_ROOT` names the repository for `sh -c` scenes. The cast is written from the captured chunks in asciinema v2. -- `src/cli/graph/lifecycle.rs`: `render_proofs` walks `docs/proofs/*/README.md`, keys scenes by the same `iri_fragment` the scenarios use, and emits `rune:Proof` with `proves` and `recordedWith`. -- `src/cli/review.rs`: `parts`, `open`, `diff`, `close`, and marker ingestion in `export`. `open` shells out to `jj workspace add -r <head> --sparse-patterns=empty`, `jj sparse set`, and `jj status` on the root. `close` reads `.review/REVIEW.md`, re-resolves, and writes the receipt. - -## Data - -- `REVIEW.md` is markdown with one YAML block: `target`, `head`, `base`, `parts` (name, role, paths, digest), `scenarios` (key, kind), `findings` (path, old_lines, kind, text), `verdict`, `reviewer`. The same record is written to the state file under the rune state directory, keyed by workspace name, and `close` re-resolves from the state file, then reads `verdict` and `findings` from the block. -- A part digest is the sha256 of the part's git-format diff for a diff target and of the part's file contents at the head for a closure, so a rewritten source under the same path changes it. -- The review receipt is the queue's receipt shape with `kind: review`, `target`, `head`, `base`, `parts`, `verdict`, `reviewer`, and ends with `review-exit=0` for `accept` and `review-exit=1` otherwise. - -## Order of work - -Group 1 is merged alone, because the deck's acceptance shape waits on the proof nodes. Group 2 is merged next and retires the bash driver. Group 3 is merged with the two reproductions as its proof. Evolution and operation-range targets, and the container runner, are separate changes. - -## Markers - -A marker is resolved by the author's later commit, never by the reviewer deleting it. `close` reads markers from every recorded path in the annotation commit, so the block in `REVIEW.md` is the reviewer's verdict and findings, and the markers are the line-bound findings. Both are inputs to `close`. diff --git a/docs/changes/review-and-proof/specs/proof-scene-runner/spec.md b/docs/changes/review-and-proof/specs/proof-scene-runner/spec.md deleted file mode 100644 index 031a5ac6..00000000 --- a/docs/changes/review-and-proof/specs/proof-scene-runner/spec.md +++ /dev/null @@ -1,135 +0,0 @@ -## ADDED Requirements - -### Requirement: Proof Frontmatter Is The Record - -A proof MUST be `docs/proofs/<directory>/README.md` with frontmatter `type: proof`, `change`, `head`, `recorded`, `transcript`, and `scenes`, where each scene names a `scenario` as `<capability>#<requirement-slug>/<scenario-slug>` with the slugs the exporter mints, a `kind` in `check`, `new`, `run`, `instruction`, or `unproven`, and a `model` on an instruction scene only. -A proof belongs to the change its frontmatter names, never to the directory it sits in, because a directory name is free text. -A README with `type: proof` and a missing or malformed field is a validation error that names the field. - -#### Scenario: Proof directory carries another change's name - -- **WHEN** `docs/proofs/canvas-a/README.md` has `change: other` -- **THEN** the closure of `canvas-a` holds no proof and the closure of `other` holds this one - -#### Scenario: Instruction scene without a model - -- **WHEN** a scene has `kind: instruction` and no `model` -- **THEN** `rune spec validate` fails and names the scenario key and the missing field - -### Requirement: Head And Transcript Bind The Record - -`transcript` MUST be the sha256 of `proof.txt` beside the README, compared without regard to case, and `recorded` MUST be a calendar date. -`head` is the commit id the scenes ran against, written by the runner and absent from a hand-recorded proof. - -#### Scenario: Hand-recorded proof has no head - -- **WHEN** a README carries every field but `head` -- **THEN** `rune spec validate` passes and the graph emits no commit edge for the proof - -### Requirement: One Scene Per Scenario Is Written First - -`rune proof scaffold <change>` MUST create the README with the frontmatter above, every scene `kind: unproven`, and one empty console fence under a heading of each scenario key, in closure order. -It MUST refuse to overwrite a README that exists, so a filled scene is never lost. -The scenario keys come from the delta specs of the change, and a change with no scenario is refused with that reason. - -#### Scenario: Change with nine delta specs gets its skeleton - -- **WHEN** the deck runs `rune proof scaffold core-foundation-principles` -- **THEN** the README holds 41 scenes, all `unproven`, in the order the proposal and the specs declare - -#### Scenario: README already exists - -- **WHEN** the README exists with one filled scene -- **THEN** the command exits non-zero, writes nothing, and names the file - -### Requirement: Run Executes Every Executable Scene - -`rune proof run <change>` MUST parse each console fence in the trycmd grammar and execute it: `$ command` runs the command, `> ` continues it, `? <status>` is the expected exit code, `[..]` elides within a line, and `...` elides lines, with stdout and stderr captured as one stream. -A scene whose kind is `instruction` is not executable and is skipped with its kind and model kept. -The runner resolves `rune` to its own path and every other command through `PATH`, and MUST fail a scene whose command it cannot resolve, so no scene is silently skipped. -The run prints one line per scene with its key and outcome. - -#### Scenario: Scene names a command the runner cannot find - -- **WHEN** a fence starts with `$ nonesuch --flag` -- **THEN** the run fails, names the scenario key and the command, and marks that scene `unproven` - -#### Scenario: Expected output disagrees - -- **WHEN** a fence expects `rune 0.5.0 ([..])` and the binary prints `rune 0.6.0 (c47af0ea) built [..]` -- **THEN** the run fails with a diff of the two lines and marks that scene `unproven` - -### Requirement: A Step Reads Its Input From The Fence - -A `< text` line after a step's command lines MUST feed `text` and a newline to the command's standard input, in order, so a scene can pass a script or a brief without a file beside the README. -A step without a `<` line reads an empty input. -The `<` lines are command lines: the transcript records them and `--check` compares them. - -#### Scenario: Step feeds a script through standard input - -- **WHEN** a fence holds `$ sh -s` and the lines `< echo one` and `< echo two` -- **THEN** the step prints `one` and `two`, and its transcript section records both `<` lines - -#### Scenario: Input line before any command - -- **WHEN** a fence starts with `< text` -- **THEN** the run refuses the fence and names the line - -### Requirement: Every Scene Starts Clean - -Every executable scene MUST run in its own fresh temporary directory, and a fence whose first line is `$ cd <path>` runs the rest in that path under the repository root. The runner exports `RUNE_PROOF_ROOT` so a scene run through `sh -c` can copy a fixture. - -#### Scenario: Scenes do not share a directory - -- **WHEN** one scene writes `note.txt` and the next scene lists its directory -- **THEN** the second scene sees no `note.txt` - -### Requirement: The Transcript Binds The Claim - -After a run, `proof.txt` MUST hold one section per scene with the scenario key, the kind, the command lines, and the captured output, in scene order, so changing a key, command, or kind changes the digest. -The run sets `transcript` to the file's sha256, `head` to the commit it ran against, and `recorded` to the date. A passed scene keeps the `check`, `new`, or `run` it declares, or becomes `check` from `unproven`. A failed or empty scene becomes `unproven`. -An instruction scene's section holds the instruction and the model's answer, written by `run --instruction <key>` through `rune run <model>`, or the scene stays `unproven`. - -#### Scenario: Scene key edited after the run - -- **WHEN** a scenario key in the frontmatter is changed and `proof.txt` is not -- **THEN** `rune proof run --check` exits non-zero and names the section that disagrees - -#### Scenario: Instruction scene never run - -- **WHEN** a scene has `kind: instruction`, a model, and no section in `proof.txt` -- **THEN** the graph emits no proves edge for it and `run --check` lists it as unproven - -### Requirement: Check Refuses Drift - -`rune proof run --check` MUST rebuild the expected transcript from the README, compare its sha256 with `transcript`, and exit non-zero naming the recorded and the computed digest when they differ. -It MUST also fail when `head` is not an ancestor of the current commit, so a proof recorded on an abandoned line cannot pass. - -#### Scenario: Transcript drifted after the run - -- **WHEN** `proof.txt` changed after the frontmatter was written -- **THEN** the check exits non-zero and names both digests - -### Requirement: The Run Records Its Own Cast - -The run MUST write `proof.cast` beside the README as an asciinema v2 stream of the output it captured, one event per step with its offset from the start, so no external recorder is needed and the scene kind `run` is available on every machine. `asciinema` and `agg` remain the tools that re-record interactively and render a GIF. - -#### Scenario: Cast written from the captured output - -- **WHEN** every fence passes on a machine without `asciinema` -- **THEN** `proof.cast` exists beside the README, its header is asciinema version 2, and a scene declared `run` keeps that kind - -### Requirement: The Graph Shows Only Proven Scenes - -`rune graph export` MUST emit one `rune:Proof` node per proof README with a `rune:scene` node per scene carrying its scenario, kind, and model, and `rune:proves` to the commit named by `head` and to each scenario whose scene is not `unproven` and is recorded in a `proof.txt` that matches `transcript`, by key or by a `# Scenario:` line whose slug equals the scenario slug. -When the transcript does not match, the proof node exists with no proves edge, because a scenario without an edge is what the deck's acceptance shape refuses. - -#### Scenario: Four deck proofs carry frontmatter - -- **WHEN** the export runs on a deck whose four proofs carry the frontmatter and matching transcripts -- **THEN** the graph holds four `rune:Proof` nodes and a proves edge for every scene not marked `unproven` - -#### Scenario: Proof transcript does not match - -- **WHEN** a proof's `proof.txt` hashes to a value other than `transcript` -- **THEN** its node exists and none of its scenes has a proves edge diff --git a/docs/changes/review-and-proof/specs/sparse-review-workspace/spec.md b/docs/changes/review-and-proof/specs/sparse-review-workspace/spec.md deleted file mode 100644 index 0c1805a8..00000000 --- a/docs/changes/review-and-proof/specs/sparse-review-workspace/spec.md +++ /dev/null @@ -1,149 +0,0 @@ -## ADDED Requirements - -### Requirement: A Target Resolves To One Head, One Base, And Parts - -A review target MUST resolve to one immutable head, at most one immutable base, and an ordered list of parts, from what the repository declares and never from a guess. -Every changed path appears in exactly one part, and a path no declaration places goes to a last part named `unplaced`. - -#### Scenario: File outside every declaration - -- **WHEN** a revision comparison touches `scripts/oddity` and no role map names `scripts/` -- **THEN** the path appears once, in the part `unplaced`, and the output says so - -### Requirement: Three Targets Exist - -No argument MUST mean the working copy after a snapshot of the root, with its single parent as base. `--from` and `--to` name two revisions. A change id is a change-directory closure whose head is the commit the root snapshot produced and which has no base. -A working copy with two parents, or a revision that resolves to more than one commit, is refused with the revset that was ambiguous. - -#### Scenario: Working copy is a merge - -- **WHEN** the root `@` has two parents and `rune review parts` runs with no target -- **THEN** the command exits non-zero and names both parents - -### Requirement: A Change Declares Its Closure - -The resolver MUST take a change's delta specs by directory, in the order the proposal's `### New Capabilities` lists them, or in directory order without that heading, its records from `decisions:`, the files under `docs/ideas` whose name slugs to the change id or to one of its capabilities, and its proofs by frontmatter `change`. -The closure of `core-foundation-principles` is therefore its proposal, nine delta specs, ten CORE records, and the proofs that name it, read in that order, because a record cites a requirement and the requirement is read first. - -#### Scenario: Change directory closure reproduces canvas A - -- **WHEN** `rune review parts core-foundation-principles` runs on the deck at commit `7b6401f5` -- **THEN** the union of the parts equals the files the fourteen sparse patterns of `.workspaces/review-canvas-a` select at that commit, and the parts read proposal, specs, records, ideas, proofs - -### Requirement: Parts Partition A Diff By Role - -A diff target MUST split by role from the repository's path map: records, delta specs, canonical specs, rune sources, code, tests, workflows, docs, proofs, generated output. -Inside a role a part is the deepest directory that holds more than one changed file, and a parent's own changed files form their own part. A `tests.rs` beside a source is a test by name. Recordings and binaries are generated output. -No similarity of names or symbols joins two parts. The role map is a declared table, not a heuristic. - -#### Scenario: Signing queue commit is partitioned - -- **WHEN** `rune review parts --from 9dd8965f- --to 9dd8965f` runs on cli -- **THEN** the nineteen files fall into ten parts, `sources/src/cli/sign/queue` before `sources/src/cli/sign` before `sources/src/cli`, and `proof.cast` and `proof.gif` form the `generated` part - -### Requirement: Reading Links Come From Declarations - -A reading link between two parts MUST exist for each record whose `upstream` names a requirement of the change and for each proof scene that names a scenario of it, and for nothing else. A link never merges parts. - -#### Scenario: Record cites a requirement - -- **WHEN** a record in the closure has `upstream: ["cap#req-slug"]` and the delta spec of `cap` holds that requirement -- **THEN** `REVIEW.md` lists one link from the record's part to the spec's part, and the two parts stay separate - -### Requirement: Parts Read In One Order - -Reading order MUST be the same for a closure and a diff: proposal and delta specs, records, sources, tests, workflows, docs, proofs, generated. -Sources within a role take the change's task-group order when a task line names their paths, else the split's directory order, deepest first, then by name, so a diff that names no paths still has one order. -A part name is its role and directory, prefixed with the change id for a closure, so two reviews never collide in one `REVIEW.md`. -`open --no-generated` leaves the generated parts out of the sparse set and lists their paths. - -#### Scenario: Recording is left out - -- **WHEN** `rune review open --from 9dd8965f- --to 9dd8965f --no-generated` runs -- **THEN** the sparse set holds every part but `generated`, and `REVIEW.md` lists the left-out paths - -### Requirement: The Workspace Is A Checkout Of The Head - -`rune review open` MUST create `.workspaces/review-<name>` with `jj workspace add -r <head> --sparse-patterns=empty`, which makes a new working-copy commit whose parent is the head, and then set the sparse patterns to the union of the parts plus `.review/`. -It MUST refuse a name whose workspace exists. Before the working-copy target it MUST snapshot the root and say so. -The reviewer's edits are that child commit, which is never merged, and `open` never squashes. - -#### Scenario: Historical revision opened - -- **WHEN** `rune review open --from 9dd8965f- --to 9dd8965f` runs while the root `@` is a later commit -- **THEN** the workspace's parent is `9dd8965f`, the sparse set is the nineteen files plus `.review/`, and the printed `tuicr -r` line reads `9dd8965f-..9dd8965f` - -#### Scenario: Working copy opened while the root holds edits - -- **WHEN** the root has unsnapshotted edits and `rune review open` runs with no target -- **THEN** the root is snapshotted first, the output names the snapshot, and the workspace's parent is that snapshot - -### Requirement: Open Records What It Resolved - -`open` MUST write the resolved record twice: `.review/REVIEW.md` in the workspace for the reviewer, and a state file outside the tree under the rune state directory, keyed by the workspace name. -Both hold the target as spelled, head, base, the parts with their paths and a digest per part, the reading links, and every scenario in the closure with its scene kind or `unproven`. -A part digest is the sha256 of the part's git-format diff for a diff target, and the sha256 of the part's file contents at the head for a closure. - -#### Scenario: Scenario without a scene is listed - -- **WHEN** the closure holds a scenario whose proof has no scene or a scene marked `unproven` -- **THEN** `REVIEW.md` lists the scenario key with `unproven` - -### Requirement: Diff Prints The Grouped Patch - -`rune review diff <name>` MUST print the parts in reading order as one git-format patch, each part introduced by a comment line with its name, so any patch reader shows the review in the order `REVIEW.md` lists it. - -#### Scenario: Patch follows the parts - -- **WHEN** `rune review diff` runs on the signing queue workspace -- **THEN** the output holds ten part headers in the order of `REVIEW.md` and the diff of every file under its part - -### Requirement: Open Prints The Way In - -`open` MUST print the workspace path, the `tuicr -r '<base>..<head>'` line for a diff target, and the two recoveries: `jj workspace update-stale` for a stale checkout, and `jj abandon` of the copy without the reviewer's edits for a divergent annotation. - -#### Scenario: Diff target prints its comparison - -- **WHEN** `rune review open --from 9dd8965f- --to 9dd8965f` succeeds -- **THEN** the output holds the workspace path, `tuicr -r '9dd8965f-..9dd8965f'`, and both recovery lines - -### Requirement: Close Re-Resolves From The State File - -`rune review close <name> --verdict accept|changes|reject` MUST read the target from the state file, never from `REVIEW.md`, re-resolve it, and refuse when head, base, or any part digest differs from the state file, naming the part. -It MUST scan every path the state file records, read from the annotation commit rather than the sparse checkout, so a narrowed sparse set hides nothing. -`close` validates the annotation only. The reviewed tree is proven by the guarded push. - -#### Scenario: Source rewritten after open - -- **WHEN** a part's files changed on the head after `open` and `close --verdict accept` runs -- **THEN** the command exits non-zero, names the part, and writes no receipt - -#### Scenario: REVIEW.md block edited to match - -- **WHEN** the reviewer edits head and digests in `REVIEW.md` after a source rewrite -- **THEN** `close` still refuses, because it compares against the state file - -### Requirement: Only An Acceptance Is A Receipt - -On success `close` MUST write a review receipt beside the checks receipts, naming target, head, base, parts, verdict, and reviewer, whose last line is `review-exit=0` for `accept` and `review-exit=1` otherwise, so only an acceptance can serve as a signing receipt. -It MUST NOT write the controller's ledger. - -#### Scenario: Rejection is not a signing receipt - -- **WHEN** `close --verdict reject` succeeds and its receipt is given to `rune sign queue` -- **THEN** the queue refuses it, because its last line is `review-exit=1` - -### Requirement: Verdicts Keep Their Findings - -`close` MUST refuse `accept` while an `[ISSUE]` marker is open in any recorded path of the annotation commit or an issue finding is listed in `REVIEW.md`, and MUST keep every marker under `changes` and `reject`, because the markers are the findings. -A finding on a deleted file or an old-side line has no line in the head checkout, so `REVIEW.md` MUST hold a findings list with path, old-side range, and kind for it, and `rune review export` MUST read file markers with `.rune-comments.yaml` when it exists. - -#### Scenario: Accept with an open issue marker - -- **WHEN** a recorded path holds a line whose first token is `[ISSUE]`, in or out of the sparse set -- **THEN** `close --verdict accept` is refused with the file and line, and `close --verdict changes` succeeds with the marker kept - -#### Scenario: Finding on a deleted file - -- **WHEN** the reviewer records an issue finding on a path the diff removes -- **THEN** `REVIEW.md` lists it with the path and the old-side lines, `close --verdict accept` is refused, and `export` prints it with the others diff --git a/docs/changes/review-and-proof/adr.md b/docs/changes/review-changes-with-proofs/adr.md similarity index 99% rename from docs/changes/review-and-proof/adr.md rename to docs/changes/review-changes-with-proofs/adr.md index 43dc002f..50565647 100644 --- a/docs/changes/review-and-proof/adr.md +++ b/docs/changes/review-changes-with-proofs/adr.md @@ -21,7 +21,7 @@ accountable: ["@N4M3Z"] consulted: ["claude-fable-5-1", "gpt-6-astra"] informed: [] upstream: [] -change: review-and-proof +change: review-changes-with-proofs --- # Review and proof share one closure over the specification graph diff --git a/docs/changes/review-changes-with-proofs/design.md b/docs/changes/review-changes-with-proofs/design.md new file mode 100644 index 00000000..3a8682b3 --- /dev/null +++ b/docs/changes/review-changes-with-proofs/design.md @@ -0,0 +1,58 @@ +# Design + +The full design, the adversarial pass, the proof contract, the hand partition, and the proof of the proof mechanism live in the workshop under `docs/specs/2026-09-24-rune-review/`. This file holds what the code needs. + +## Modules + +- `src/cli/closure/`: `Target` (WorkingCopy, Revisions { from, to }, Change(id)) resolves to `Resolved { head, base, parts, scenarios }`. `Part { name, role, paths, digest }`. The role map is a table per repository kind: the cli crate layout, or a deck's `deck.yaml` domains. Reading order and the deepest-directory split live here and nowhere else. +- `src/cli/proof/`: `Frontmatter { change, head, recorded, transcript, scenes }`, `Scene { scenario, kind, model }`, the README reader and writer, `scaffold`, `run`, `check`. The runner parses the fences itself in `fence.rs`, because trycmd's `TestCases::run` returns nothing and keeps outcomes private. It runs each command with the standard process API with stdout and stderr on one capture file (`runner.rs`), and matches output with its own `[..]` and `...` rules (`matcher.rs`), so every scene has an outcome the runner owns. A `< text` line after the `$` and `>` lines feeds the step's standard input from a file the runner writes beside the capture, so a script or a brief lives in the fence. Each scene gets its own temporary directory, a `$ cd` first line sets the working directory under the repository root, and `RUNE_PROOF_ROOT` names the repository for `sh -c` scenes. The cast is written from the captured chunks in asciinema v2. +- `src/cli/graph/lifecycle.rs`: `render_proofs` walks `docs/proofs/*/README.md`, keys scenes by the same `iri_fragment` the scenarios use, and emits `rune:Proof` with `proves` and `recordedWith`. +- `src/cli/review.rs`: `parts`, `open`, `diff`, `close`, and marker ingestion in `export`. `open` shells out to `jj workspace add -r <head> --sparse-patterns=empty`, `jj sparse set`, and `jj status` on the root. `close` reads `.review/REVIEW.md`, re-resolves, and writes the receipt. + +## Data + +- `REVIEW.md` is markdown with one YAML block: `target`, `head`, `base`, `parts` (name, role, paths, digest), `scenarios` (key, kind), `findings` (path, old_lines, kind, text), `verdict`, `reviewer`. The same record is written to the state file under the rune state directory, keyed by workspace name, and `close` re-resolves from the state file, then reads `verdict` and `findings` from the block. +- A part digest is the sha256 of the part's git-format diff for a diff target and of the part's file contents at the head for a closure, so a rewritten source under the same path changes it. +- The review receipt is the queue's receipt shape with `kind: review`, `target`, `head`, `base`, `parts`, `verdict`, `reviewer`, and ends with `review-exit=0` for `accept` and `review-exit=1` otherwise. + +## Order of work + +Group 1 is merged alone, because the deck repository's acceptance shape waits on the proof nodes. Group 2 is merged next and retires the bash driver. Group 3 is merged with the two reproductions as its proof. Evolution and operation-range targets, and the container runner, are separate changes. + +## Markers + +A marker is resolved by the author's later commit, never by the reviewer deleting it. `close` reads markers from every recorded path in the annotation commit, so the block in `REVIEW.md` is the reviewer's verdict and findings, and the markers are the line-bound findings. Both are inputs to `close`. + +## Commands + +The requirements name roles and behavior. This table holds every command, flag, environment variable, path, label, and exit status that implements a requirement. The roles and the commands that fill them today: + +- validator: `rune spec validate` for the proof frontmatter, `rune proof run --check` for drift, and every `rune review` subcommand. `rune review parts` resolves a target through `src/cli/closure/`, `rune review open` and `rune review diff` open a review, and `rune review close` and `rune review export` close one. +- proof runner: `rune proof scaffold <change>`, `rune proof run <change>`, and `rune proof run --instruction <key>`, which records an instruction scene through `rune run <model>`. It resolves a change through `src/cli/closure/` too. +- generator: `rune graph export` writes the graph. + +| Requirement | Command and flags | Hook | Diagnostic ids and literals | Exit status | +|---|---|---|---|---| +| Proof Frontmatter Is The Record | `rune spec validate`. The proof sits at `docs/proofs/<directory>/README.md`. The scenario key is `<capability>#<requirement-slug>/<scenario-slug>` | none | the validation error names the field | nonzero on a malformed proof | +| Head And Transcript Bind The Record | `rune spec validate`. The transcript file is `proof.txt` | none | none | 0 when `head` is absent and the rest is valid | +| One Scene Per Scenario Is Written First | `rune proof scaffold <change>` | none | the refusal names the existing README, or says the change has no scenario | nonzero on refusal, nothing written | +| Run Executes Every Executable Scene | `rune proof run <change>`. Fence grammar: `$ command` runs a command, a line that starts with `>` continues it, `? <status>` is the expected exit code, `[..]` elides within a line, and `...` elides lines. `rune` resolves to the runner's own path and every other command through `PATH` | none | one line per scene with key and outcome. A failure names the scenario key and the command | nonzero when a scene fails | +| A Step Reads Its Input From The Fence | `rune proof run <change>` and `rune proof run --check`. Input lines have the form `< text` | none | a `<` line before any command is a parse error that names the line | nonzero on the parse error | +| Every Scene Starts Clean | `rune proof run <change>`. `$ cd <path>` as the first fence line. `RUNE_PROOF_ROOT` is exported for `sh -c` scenes | none | none | n/a | +| The Transcript Binds The Claim | `rune proof run <change>` writes `proof.txt`. `rune proof run --instruction <key>` records an instruction scene through `rune run <model>` | none | none | n/a | +| Check Refuses Drift | `rune proof run --check` | none | names the recorded and the computed digest, or a `head` that is not an ancestor | nonzero on drift or a non-ancestor head | +| The Run Records Its Own Cast | `rune proof run <change>` writes `proof.cast`. `asciinema` re-records by hand and `agg` renders a GIF. Fallback recorders: `scripts/fallback/asciinema.py` and `docs/proofs/cast.py` | none | none | n/a | +| The Graph Shows Only Proven Scenes | `rune graph export`. Nodes `rune:Proof` and `rune:scene`, edges `rune:proves` and `rune:recordedWith`. A `# Scenario:` heading line also matches a scene | none | none | n/a | +| A Target Resolves To One Head, One Base, And Parts | `rune review parts <target> [--json]` | none | last part `unplaced` | nonzero on an ambiguous target | +| Three Targets Exist | No argument for the working copy. `--from <rev> --to <rev>` for a comparison. `<change-id>` for a closure | none | the refusal names the ambiguous revset or both parents | nonzero on refusal | +| A Change Declares Its Closure | `rune review parts <change-id>`. Ideas live under `docs/ideas`. The hand-built workspace is `.workspaces/review-canvas-a` | none | none | n/a | +| Parts Partition A Diff By Role | `rune review parts --from <rev> --to <rev>`. The role map is a table per repository kind: the cli crate layout, or a deck's `deck.yaml` domains | none | none | n/a | +| Reading Links Come From Declarations | `rune review open` writes the links to `.review/REVIEW.md` | none | none | n/a | +| Parts Read In One Order | `rune review open --no-generated` | none | none | n/a | +| The Workspace Is A Checkout Of The Head | `rune review open` runs `jj workspace add -r <head> --sparse-patterns=empty`, `jj sparse set`, and `jj status` on the root. The workspace is `.workspaces/review-<name>` and the review directory is `.review/` | none | the refusal names the existing workspace | nonzero on refusal | +| Open Records What It Resolved | `.review/REVIEW.md` in the workspace. The state file sits under the rune state directory, keyed by workspace name | none | none | n/a | +| Diff Prints The Grouped Patch | `rune review diff <name>` | none | none | 0 | +| Open Prints The Way In | Printed lines: `tuicr -r '<base>..<head>'`, `jj workspace update-stale`, and `jj abandon` of the copy without the reviewer's edits | none | none | n/a | +| Close Re-Resolves From The State File | `rune review close <name> --verdict accept\|changes\|reject` | the guarded push proves the reviewed tree | the refusal names the part | nonzero on refusal, no receipt | +| Only An Acceptance Is A Receipt | The receipt sits beside the checks receipts and `rune sign queue` reads it. Its last line is `review-exit=0` for `accept` and `review-exit=1` otherwise | none | the `review-exit` line | 0 when the receipt is written | +| Verdicts Keep Their Findings | `rune review close --verdict accept\|changes\|reject`. `rune review export` reads `[ISSUE]`, `[NOTE]`, `[SUGGESTION]`, and `[PRAISE]` markers with `.rune-comments.yaml` | none | the refusal names the file and line | nonzero on a refused `accept` | diff --git a/docs/changes/review-and-proof/proposal.md b/docs/changes/review-changes-with-proofs/proposal.md similarity index 95% rename from docs/changes/review-and-proof/proposal.md rename to docs/changes/review-changes-with-proofs/proposal.md index 1dcdc8b8..69f7dbcf 100644 --- a/docs/changes/review-and-proof/proposal.md +++ b/docs/changes/review-changes-with-proofs/proposal.md @@ -1,5 +1,5 @@ --- -adr: docs/changes/review-and-proof/adr.md +adr: docs/changes/review-changes-with-proofs/adr.md status: proposed decisions: ["Review and proof share one closure over the specification graph"] --- @@ -22,8 +22,8 @@ Proofs have the same gap. A proof is recorded by hand through the deck's bash dr ## Capabilities -- proof-scene-runner (new) -- sparse-review-workspace (new) +- every-scenario-has-proof (new) +- review-the-whole-change (new) ## Impact diff --git a/docs/changes/review-changes-with-proofs/specs/every-scenario-has-proof/spec.md b/docs/changes/review-changes-with-proofs/specs/every-scenario-has-proof/spec.md new file mode 100644 index 00000000..ed52a076 --- /dev/null +++ b/docs/changes/review-changes-with-proofs/specs/every-scenario-has-proof/spec.md @@ -0,0 +1,135 @@ +## ADDED Requirements + +### Requirement: Proof Frontmatter Is The Record + +A proof MUST be a README in the proofs directory with frontmatter `type: proof`, `change`, `head`, `recorded`, `transcript`, and `scenes`. Each scene names a `scenario` key as the generator mints it. Its `kind` is `check`, `new`, `run`, `instruction`, or `unproven`. Only an instruction scene names a `model`. +A proof belongs to the change its frontmatter names, never to its directory, because a directory name is free text. +The validator reports a missing or malformed field by name. + +#### Scenario: Proof directory carries another change's name + +- **WHEN** the proof README in the directory `canvas-a` has `change: other` +- **THEN** the closure of `canvas-a` holds no proof and the closure of `other` holds this one + +#### Scenario: Instruction scene without a model + +- **WHEN** a scene has `kind: instruction` and no `model` +- **THEN** the validator fails the proof and names the scenario key and the missing field + +### Requirement: Head And Transcript Bind The Record + +`transcript` MUST be the sha256 of the transcript file beside the README, compared without regard to case, and `recorded` MUST be a calendar date. +`head` is the commit id the scenes ran against. The proof runner writes it, and a hand-recorded proof has none. + +#### Scenario: Hand-recorded proof has no head + +- **WHEN** a README carries every field but `head` +- **THEN** the validator passes it and the generator emits no commit edge for the proof + +### Requirement: One Scene Per Scenario Is Written First + +The proof runner MUST write the skeleton first. The skeleton is the README with the frontmatter above and every scene `kind: unproven`. One empty console fence sits under a heading per scenario key, in closure order. +It MUST refuse to overwrite a README that exists, so a filled scene is never lost. +The scenario keys come from the delta specs of the change, and a change with no scenario is refused with that reason. + +#### Scenario: Change with nine delta specs gets its skeleton + +- **WHEN** an author asks the proof runner for the skeleton of `core-foundation-principles` +- **THEN** the README holds 41 scenes, all `unproven`, in the order the proposal and the specs declare + +#### Scenario: README already exists + +- **WHEN** the README exists with one filled scene +- **THEN** the proof runner refuses, writes nothing, and names the file + +### Requirement: Run Executes Every Executable Scene + +The proof runner MUST parse and execute each console fence in the grammar the design names. It captures standard output and standard error as one stream. +It resolves its own binary to its own path and every other command through the search path. It MUST fail a scene whose command it cannot resolve, so no scene is silently skipped. +A scene whose kind is `instruction` is not executable and is skipped with its kind and model kept. +The run prints one line per scene with its key and outcome. + +#### Scenario: Scene names a command the runner cannot find + +- **WHEN** a fence starts with `$ nonesuch --flag` +- **THEN** the run fails, names the scenario key and the command, and marks that scene `unproven` + +#### Scenario: Expected output disagrees + +- **WHEN** a fence expects `rune 0.5.0 ([..])` and the binary prints `rune 0.6.0 (c47af0ea) built [..]` +- **THEN** the run fails with a diff of the two lines and marks that scene `unproven` + +### Requirement: A Step Reads Its Input From The Fence + +A `< text` line after a step's command lines MUST feed `text` and a newline to the command's standard input, in order. A scene can then pass a script or a brief without a file beside the README. +A step without a `<` line reads an empty input. +The `<` lines are command lines: the transcript records them and the validator compares them. + +#### Scenario: Step feeds a script through standard input + +- **WHEN** a fence holds `$ sh -s` and the lines `< echo one` and `< echo two` +- **THEN** the step prints `one` and `two`, and its transcript section records both `<` lines + +#### Scenario: Input line before any command + +- **WHEN** a fence starts with `< text` +- **THEN** the run refuses the fence and names the line + +### Requirement: Every Scene Starts Clean + +Every executable scene MUST run in its own fresh temporary directory. A fence whose first line changes directory runs the rest in that path under the repository root. The proof runner exports the repository root as an environment variable, so a scene run through a shell can copy a fixture. + +#### Scenario: Scenes do not share a directory + +- **WHEN** one scene writes `note.txt` and the next scene lists its directory +- **THEN** the second scene sees no `note.txt` + +### Requirement: The Transcript Binds The Claim + +The transcript file MUST hold one section per scene, in scene order. Each holds the scenario key, kind, command lines, and captured output, so an edit changes the digest. +The proof runner sets `transcript` to the file's sha256, `head` to the commit, and `recorded` to the date. For an instruction scene it records the instruction and the model's answer, or the scene stays `unproven`. +A passed scene keeps its kind, or becomes `check` from `unproven`. A failed or empty scene becomes `unproven`. + +#### Scenario: Scene key edited after the run + +- **WHEN** a scenario key in the frontmatter is changed and the transcript file is not +- **THEN** the validator refuses the proof and names the section that disagrees + +#### Scenario: Instruction scene never run + +- **WHEN** a scene has `kind: instruction`, a model, and no section in the transcript file +- **THEN** the generator emits no proves edge for it and the validator lists it as unproven + +### Requirement: Check Refuses Drift + +The validator MUST rebuild the expected transcript from the README and compare its sha256 with `transcript`. When they differ it MUST refuse the proof and name the recorded and the computed digest. +It MUST also refuse when `head` is not an ancestor of the current commit, so a proof recorded on an abandoned line cannot pass. + +#### Scenario: Transcript drifted after the run + +- **WHEN** the transcript file changed after the frontmatter was written +- **THEN** the validator refuses the proof and names both digests + +### Requirement: The Run Records Its Own Cast + +The proof runner MUST write a cast file beside the README as an asciinema v2 stream of the output it captured. The stream holds one event per step with its offset from the start. So no external recorder is needed and the scene kind `run` is available on every machine. An interactive recorder and a renderer of animated GIF images remain the outside tools that re-record a scene by hand and render the image. + +#### Scenario: Cast written from the captured output + +- **WHEN** every fence passes on a machine without the interactive recorder +- **THEN** the cast file exists beside the README, its header is asciinema version 2, and a scene declared `run` keeps that kind + +### Requirement: The Graph Shows Only Proven Scenes + +The generator MUST emit one `rune:Proof` node per proof and a `rune:scene` node per scene with its scenario, kind, and model. `rune:proves` goes to the `head` commit and to each proven scenario. A scene proves its scenario when it is not `unproven` and a transcript matching `transcript` records it. It matches by key or by a scenario heading with the same slug. +On a mismatch the proof node exists with no proves edge, because the repository's acceptance shape refuses a scenario without an edge. + +#### Scenario: Four deck proofs carry frontmatter + +- **WHEN** the generator exports the graph of a repository whose four proofs carry the frontmatter and matching transcripts +- **THEN** the graph holds four `rune:Proof` nodes and a proves edge for every scene not marked `unproven` + +#### Scenario: Proof transcript does not match + +- **WHEN** a proof's transcript file hashes to a value other than `transcript` +- **THEN** its node exists and none of its scenes has a proves edge diff --git a/docs/changes/review-changes-with-proofs/specs/review-the-whole-change/spec.md b/docs/changes/review-changes-with-proofs/specs/review-the-whole-change/spec.md new file mode 100644 index 00000000..76cd1be4 --- /dev/null +++ b/docs/changes/review-changes-with-proofs/specs/review-the-whole-change/spec.md @@ -0,0 +1,149 @@ +## ADDED Requirements + +### Requirement: A Target Resolves To One Head, One Base, And Parts + +The validator MUST resolve a review target to one immutable head, at most one immutable base, and an ordered list of parts. It resolves from what the repository declares and never from a guess. +Every changed path appears in exactly one part, and a path no declaration places goes to a last part named `unplaced`. + +#### Scenario: File outside every declaration + +- **WHEN** a revision comparison touches `scripts/oddity` and no role map names `scripts/` +- **THEN** the path appears once, in the part `unplaced`, and the output says so + +### Requirement: Three Targets Exist + +No target MUST mean the working copy after a snapshot of the root, with its single parent as base. A revision comparison names two revisions. A change id is a change-directory closure whose head is the commit the root snapshot produced and which has no base. +The validator refuses a working copy with two parents, or a revision that resolves to more than one commit, and names the ambiguous revset. + +#### Scenario: Working copy is a merge + +- **WHEN** the root working copy has two parents and the validator lists parts with no target +- **THEN** the validator refuses and names both parents + +### Requirement: A Change Declares Its Closure + +The validator MUST take a change's delta specs in the order the proposal's `### New Capabilities` heading lists them, else in directory order. Its records come from `decisions:` and its proofs from frontmatter `change`. Its idea files are those whose name slugs to the change id or one of its capabilities. +The closure of `core-foundation-principles` is its proposal, nine delta specs, ten CORE records, and the proofs that name it, in that order. A record cites a requirement, which is read first. + +#### Scenario: Change directory closure reproduces canvas A + +- **WHEN** the validator lists the parts of `core-foundation-principles` on the deck repository at commit `7b6401f5` +- **THEN** the parts read proposal, specs, records, ideas, proofs, and their union is the fourteen-pattern selection of the hand-built canvas A workspace at that commit + +### Requirement: Parts Partition A Diff By Role + +The validator MUST split a diff target by role. The role map lists records, delta specs, canonical specs, rune sources, code, tests, workflows, docs, proofs, generated output. +Inside a role a part is the deepest directory that holds more than one changed file. A parent's own changed files form their own part. A `tests.rs` beside a source is a test by name. Recordings and binaries are generated output. +No similarity of names or symbols joins two parts. The role map is a declared table. + +#### Scenario: Signing queue commit is partitioned + +- **WHEN** the validator lists the parts between `9dd8965f-` and `9dd8965f` on cli +- **THEN** the nineteen files fall into ten parts, `sources/src/cli/sign/queue` before `sources/src/cli/sign` before `sources/src/cli`, and `proof.cast` and `proof.gif` form the `generated` part + +### Requirement: Reading Links Come From Declarations + +A reading link between two parts MUST exist for each record whose `upstream` names a requirement of the change. One MUST exist for each proof scene that names a scenario of it, and for nothing else. A link never merges parts. + +#### Scenario: Record cites a requirement + +- **WHEN** a record in the closure has `upstream: ["cap#req-slug"]` and the delta spec of `cap` holds that requirement +- **THEN** the review record lists one link from the record's part to the spec's part, and the two parts stay separate + +### Requirement: Parts Read In One Order + +Closure and diff MUST share one reading order: proposal and delta specs, records, sources, tests, workflows, docs, proofs, generated. +Within a role, sources take the task-group order when a task line names their paths, else directory order, deepest first, then by name. +A part name is its role and directory, prefixed with the change id for a closure, so two reviews never collide in one review record. +On request the validator leaves generated parts out of the sparse set and lists their paths. + +#### Scenario: Recording is left out + +- **WHEN** the validator opens the comparison between `9dd8965f-` and `9dd8965f` without the generated parts +- **THEN** the sparse set holds every part but `generated`, and the review record lists the left-out paths + +### Requirement: The Workspace Is A Checkout Of The Head + +When it opens a review, the validator MUST create a review workspace named for the review. The workspace is a new working-copy commit whose parent is the head, with an empty sparse set. It then sets the sparse patterns to the union of the parts plus the review directory. +It MUST refuse a name whose workspace exists. Before the working-copy target it MUST snapshot the root and say so. +The reviewer's edits are that child commit, which is never merged, and the validator never squashes. + +#### Scenario: Historical revision opened + +- **WHEN** the validator opens the comparison between `9dd8965f-` and `9dd8965f` while the root working copy is a later commit +- **THEN** the workspace's parent is `9dd8965f`, the sparse set is the nineteen files plus the review directory, and the printed review-tool line names the comparison `9dd8965f-..9dd8965f` + +#### Scenario: Working copy opened while the root holds edits + +- **WHEN** the root has unsnapshotted edits and the validator opens with no target +- **THEN** the root is snapshotted first, the output names the snapshot, and the workspace's parent is that snapshot + +### Requirement: Open Records What It Resolved + +On open the validator MUST write the resolved record twice. One is the review record in the workspace, for the reviewer. The other is a state file outside the tree, keyed by the workspace name. +Both hold the target as spelled, head, base, the parts with their paths and digests, and the reading links. Both list every closure scenario with its scene kind or `unproven`. +A part digest is the sha256 of the part's git-format diff for a diff target, or of its files at the head for a closure. + +#### Scenario: Scenario without a scene is listed + +- **WHEN** the closure holds a scenario whose proof has no scene or a scene marked `unproven` +- **THEN** the review record lists the scenario key with `unproven` + +### Requirement: Diff Prints The Grouped Patch + +On request the validator MUST print the parts in reading order as one git-format patch. A comment line with the part's name introduces each part. So any patch reader shows the review in the order the review record lists it. + +#### Scenario: Patch follows the parts + +- **WHEN** the reviewer asks for the patch of the signing queue workspace +- **THEN** the output holds ten part headers in the order of the review record and the diff of every file under its part + +### Requirement: Open Prints The Way In + +When it opens a review, the validator MUST print the workspace path. For a diff target it MUST print the review-tool line that names the base and head comparison. It MUST print the two recoveries. One is the workspace refresh for a stale checkout. The other abandons the copy without the reviewer's edits for a divergent annotation. + +#### Scenario: Diff target prints its comparison + +- **WHEN** the validator opens the comparison between `9dd8965f-` and `9dd8965f` and succeeds +- **THEN** the output holds the workspace path, the review-tool line for `9dd8965f-..9dd8965f`, and both recovery lines + +### Requirement: Close Re-Resolves From The State File + +When it closes a review, the validator MUST read the target from the state file, never from the review record, and resolve it again. It MUST refuse when head, base, or any part digest differs from the state file, and name the part. +It MUST scan every path the state file records. It reads from the annotation commit rather than the sparse checkout, so a narrowed sparse set hides nothing. +Closing validates the annotation only. The reviewed tree is proven by the guarded push. + +#### Scenario: Source rewritten after open + +- **WHEN** a part's files changed on the head after the review was opened and the reviewer closes with an acceptance +- **THEN** the validator refuses, names the part, and writes no receipt + +#### Scenario: REVIEW.md block edited to match + +- **WHEN** the reviewer edits head and digests in the review record after a source rewrite +- **THEN** the validator still refuses, because it compares against the state file + +### Requirement: Only An Acceptance Is A Receipt + +On success the validator MUST write a review receipt beside the checks receipts. The receipt names target, head, base, parts, verdict, and reviewer. Its last line marks the review as passed for an acceptance and as failed otherwise, so only an acceptance can serve as a signing receipt. +It MUST NOT write the ledger that the forge validator publishes. + +#### Scenario: Rejection is not a signing receipt + +- **WHEN** a rejection closes and its receipt is given to the signer +- **THEN** the signer refuses it, because its last line marks the review as failed + +### Requirement: Verdicts Keep Their Findings + +On close the validator MUST refuse an acceptance while any recorded path holds an open `[ISSUE]` marker. An issue finding in the review record refuses it too. It MUST keep every marker under a changes or reject verdict: the markers are the findings. +A finding on a deleted file or an old-side line has no line in the head checkout. So the review record MUST list such findings with path, old-side range, and kind. The validator's export MUST read file markers with the comment file when it exists. + +#### Scenario: Accept with an open issue marker + +- **WHEN** a recorded path holds a line whose first token is `[ISSUE]`, in or out of the sparse set +- **THEN** an acceptance is refused with the file and line, and a changes verdict succeeds with the marker kept + +#### Scenario: Finding on a deleted file + +- **WHEN** the reviewer records an issue finding on a path the diff removes +- **THEN** the review record lists it with the path and the old-side lines, an acceptance is refused, and the export prints it with the others diff --git a/docs/changes/review-and-proof/tasks.md b/docs/changes/review-changes-with-proofs/tasks.md similarity index 91% rename from docs/changes/review-and-proof/tasks.md rename to docs/changes/review-changes-with-proofs/tasks.md index e2efe9ad..f507eb14 100644 --- a/docs/changes/review-and-proof/tasks.md +++ b/docs/changes/review-changes-with-proofs/tasks.md @@ -14,7 +14,7 @@ - [x] 2.2 `rune proof run <change>`: own fence parser in the trycmd grammar, execution through snapbox with `code` and `stdout_eq`, `rune` resolved to its own path and the rest through `PATH`, a fresh directory per scene, `$ cd` honored, unresolvable command fails the scene, one line per scene - [x] 2.3 Run writes `proof.txt` with one section per scene, `transcript`, `head`, `recorded`, and each scene's kind, failed scenes `unproven`. Instruction scenes skipped, recorded by `run --instruction <key>` through `rune run`. Cast through `asciinema`, else `scripts/fallback/asciinema.py`, else `docs/proofs/cast.py`, when one answers - [x] 2.4 `run --check` rebuilds the expected transcript, refuses a digest mismatch and a `head` that is not an ancestor -- [x] 2.5 `docs/proofs/review-and-proof/README.md` holds a scene for every scenario of this change. Twelve proof-runner scenes pass `rune proof run` against `tests/fixtures/proof/`, two stay unproven for the deck (canvas A skeleton, four deck proofs), and the sixteen review scenes wait for group 3 +- [x] 2.5 `docs/proofs/review-changes-with-proofs/README.md` holds a scene for every scenario of this change. Twelve proof-runner scenes pass `rune proof run` against `tests/fixtures/proof/`, two stay unproven for the deck (canvas A skeleton, four deck proofs), and the sixteen review scenes wait for group 3 - [x] 2.7 `< text` lines feed a step's standard input, recorded as command lines, and a `<` line before any command is a parse error - [x] 2.6 Tests: fence grammar, unregistered command, output mismatch diff, drifted transcript, recorder absent @@ -30,7 +30,7 @@ ## 4. Verification -- [ ] 4.1 `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D warnings`, full suite, `rune spec validate review-and-proof`, `rune docs check` +- [ ] 4.1 `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D warnings`, full suite, `rune spec validate review-changes-with-proofs`, `rune docs check` - [ ] 4.2 Adversarial review by astra after each of groups 1, 2, and 3, findings and dispositions in the workshop under `docs/specs/2026-09-24-rune-review/` - [ ] 4.3 Deck: `rune proof scaffold core-foundation-principles` runs, the deck fills scenes, `prove.sh` retires. `.review/` in the deck's lint excludes - [ ] 4.4 CHANGELOG entries under Unreleased, `rune` help text, `AGENTS.md` rows diff --git a/docs/changes/review-every-adopted-block/design.md b/docs/changes/review-every-adopted-block/design.md new file mode 100644 index 00000000..66ac0fba --- /dev/null +++ b/docs/changes/review-every-adopted-block/design.md @@ -0,0 +1,21 @@ +# Adopt block review design + +## Commands + +The delta names roles: the maintainer decides, the importer fetches, splits, +records, and enforces. The importer is a role beyond the readback's five (validator, assembler, +generator, deployer, fixer), and the deck ontology defines it. This table carries the command, flag, file, and diagnostic names that implement each +requirement, as rune 0.6.0 prints them in `--help`. Exit statuses come from +[Exit Codes](../../Exit%20Codes.md): 1 is a refusal or a finding, 2 is a usage error. + +| Requirement | Command and flags | Hook | Diagnostic ids and files | Exit status | +|---|---|---|---|---| +| Adoption opens a review session | `rune adopt start <source> --module <DIR>`, with `--name <NAME>`, `--kind <KIND>`, and `--source-url <URL>`. The sidecars are `adopt/v1` with `review: pending`. The temporary session is an external `session.yaml`. | none | A second start names the session in flight. | 1 on refusal | +| Segmentation is deterministic | The segmentation version `segment/v1` is recorded in the session. A block id is the file name and an ordinal, such as `SKILL.md:4`. | none | none | none | +| Verdicts are recorded one block at a time | `rune adopt verdict <block-id> <verdict> --note <TEXT>`, with `--force` to re-decide, `--artifact <PATH>` to pick a session, and `--replacement <TEXT>` or `--replacement-file <PATH>` for an adapted block. The verdict is keep, adapt, or cut. | none | `block <id> already decided (<verdict>); pass --force to re-decide`, unknown block id, missing note | 1 on refusal | +| Progress is inspectable and injectable | `rune adopt status --json` and `rune adopt next --count <N> --json`. The adoption skill reads them through dynamic context injection (a `!`-command line). | none | none | 0 | +| Finalizing enforces the review | `rune adopt finalize --reviewer <TEXT>`, with `--allow-new` to accept files created during the review. The structure check is the internal mdschema checker with the kind's `.mdschema`. | none | the pending ids, the kept block name, the mdschema report | 1 on refusal | +| Final review authority is the adoption sidecar | The sidecar flips to `review: reviewed`. No `review.yaml` or `*.review.yaml` stays in the tree. | none | none | 0 | +| Agents and rules are adoptable | `rune import --kind agent` or `--kind rule`, and the same flag on `rune adopt start`. Placement is `agents/<name>.md` and `rules/<name>.md`. | none | none | 0 | +| Names match the source schema | The pattern is `^[A-Za-z0-9]+([-_]?[A-Za-z0-9]+)*$` from `schemas/skill.schema.yaml`. The name comes from `--name <NAME>`. | none | invalid name refusal | 1 on refusal | +| Abandoning closes a session safely | `rune adopt abandon --yes`, with `--artifact <PATH>` to pick a session. The destination is the tree trash. | none | none | 0 | diff --git a/docs/changes/adopt-block-review/proposal.md b/docs/changes/review-every-adopted-block/proposal.md similarity index 95% rename from docs/changes/adopt-block-review/proposal.md rename to docs/changes/review-every-adopted-block/proposal.md index 78da2328..3d3e484f 100644 --- a/docs/changes/adopt-block-review/proposal.md +++ b/docs/changes/review-every-adopted-block/proposal.md @@ -26,11 +26,11 @@ Adoption today is a byte-copy with provenance (`rune import`) plus a skill that ## Capabilities -- adopt-block-review (new) +- review-every-adopted-block (new) ## Impact - `src/cli/adopt/` gains `segment.rs` and `review.rs`. `src/cli/mod.rs` re-wires the `adopt` subcommand family. `src/manifest/` gains review-statement types. - `rune doctor` gains an mdschema presence check. -- Supersedes adopt-command-split tasks 1.2 (pending-review state) and 1.4 (`rune adopt` dispatch). Noted there. +- Supersedes import-copies-adopt-reviews tasks 1.2 (pending-review state) and 1.4 (`rune adopt` dispatch). Noted there. - Deck: `runes/*/skills/.mdschema`, `runes/*/agents/.mdschema`, `runes/*/rules/.mdschema`, rewritten `runes/meta/skills/adopt-artifact/`. diff --git a/docs/changes/review-every-adopted-block/specs/review-every-adopted-block/spec.md b/docs/changes/review-every-adopted-block/specs/review-every-adopted-block/spec.md new file mode 100644 index 00000000..b61f0cfc --- /dev/null +++ b/docs/changes/review-every-adopted-block/specs/review-every-adopted-block/spec.md @@ -0,0 +1,102 @@ +## ADDED Requirements + +### Requirement: Adoption opens a review session + +When the maintainer starts an adoption from a source, the importer MUST fetch the source at its pin or copy it locally. It MUST write adoption provenance sidecars and split every adopted markdown file into review blocks. It MUST open a temporary session outside the artifact tree with every block's verdict pending. The adoption sidecar MUST record that the review is pending. + +#### Scenario: Maintainer starts from a commit-pinned address + +- **WHEN** the maintainer starts an adoption into a module from a web address pinned to one commit +- **THEN** the artifact is placed under the module with adoption sidecars, a temporary session exists with every block pending, and the sidecar records a pending review + +#### Scenario: Importer refuses a second session for the same artifact + +- **WHEN** the maintainer starts an adoption of an artifact that already has a temporary session with pending verdicts +- **THEN** the importer refuses and names the session in flight + +### Requirement: Segmentation is deterministic + +The importer MUST split by line and reproducibly: identical input yields identical blocks and ids. Frontmatter is one block. A fenced code block is atomic. Paragraphs split at blank lines. Consecutive list items group into one block. Consecutive table lines group. Headings are their own blocks. Each block MUST carry an ordinal id per file and a SHA-256 digest of its content. + +#### Scenario: Fences are never split + +- **WHEN** a fenced code block contains blank lines +- **THEN** it is a single block + +#### Scenario: Re-segmenting is stable + +- **WHEN** the same file is segmented twice +- **THEN** blocks, ids, and digests are identical + +### Requirement: Verdicts are recorded one block at a time + +When the maintainer gives one block a verdict of keep, adapt, or cut, the importer MUST record it in the temporary session. Adapt and cut MUST require a note with the reason. An unknown block id or an already-decided block MUST be an error unless the maintainer asks to re-decide it. + +#### Scenario: Cut without a note is rejected + +- **WHEN** the maintainer cuts a block without a note +- **THEN** the importer refuses and asks for the reason + +### Requirement: Progress is inspectable and injectable + +The importer MUST list the sessions in flight with per-file counts of pending and decided blocks. It MUST emit the next pending blocks with their id, kind, and content. Both listings MUST be available in a machine-readable form, for tools and for dynamic context injection into a skill. + +#### Scenario: Status shows one session + +- **WHEN** one session is in flight and the maintainer asks for the machine-readable status +- **THEN** the output identifies the artifact, the total block count, the decided count, and the next pending id + +### Requirement: Finalizing enforces the review + +The importer MUST refuse to finalize while any verdict is pending. It MUST verify each verdict against the edited files with whitespace-normalized comparisons. A cut block's content no longer appears, a kept block's content still appears, and an adapted block's content differs. It MUST check the artifact against its kind's structure schema and MUST refuse on a schema violation or when no schema checker is available. + +#### Scenario: Pending block blocks finalize + +- **WHEN** any block is pending and the maintainer finalizes +- **THEN** the importer refuses and lists the pending ids + +#### Scenario: Kept content was deleted + +- **WHEN** a block with the verdict keep no longer appears in the edited file +- **THEN** the importer refuses and names the block + +#### Scenario: Schema violation blocks finalize + +- **WHEN** the reviewed artifact violates its kind's structure schema +- **THEN** the importer refuses and shows the schema report + +### Requirement: Final review authority is the adoption sidecar + +The completed review MUST leave no block ledger in the artifact tree. On success the importer MUST re-sync each adoption sidecar's subject digest to the reviewed content and mark it reviewed. It MUST add the reviewer identity, the completion time, and a concise adaptation summary. It MUST delete the temporary session only after every sidecar is safely updated. + +#### Scenario: Finalize removes workflow state + +- **WHEN** a fully decided review finalizes successfully +- **THEN** the reviewed sidecars carry the final metadata, the temporary session is gone, and no review ledger file exists in the artifact tree + +### Requirement: Agents and rules are adoptable + +The importer MUST accept the kinds agent and rule, whether the maintainer imports or starts an adoption. It MUST place a single-file artifact in the module's agents or rules directory under its name, with the same sidecar treatment as a skill. + +#### Scenario: Rule adoption places the file in the rules directory + +- **WHEN** the maintainer starts an adoption of kind rule named no-tabs against a module +- **THEN** the file is placed in the module's rules directory as no-tabs with an adoption sidecar, and a review session opens + +### Requirement: Names match the source schema + +An adopted artifact name MUST match the source schema's name pattern: runs of letters and digits, with at most one hyphen or underscore between runs. It MUST be at most 64 characters and, for a skill, MUST equal the containing directory name. + +#### Scenario: Deck casing is explicit + +- **WHEN** the maintainer adopts an artifact named AdoptArtifact, adopt-artifact, or adopt_artifact +- **THEN** the importer preserves that valid source name + +### Requirement: Abandoning closes a session safely + +When the maintainer abandons a session in flight, the importer MUST move the imported artifact, its sidecars, and the temporary session to the trash. It MUST never delete the artifact in place. + +#### Scenario: Maintainer abandons mid-review + +- **WHEN** a session has recorded verdicts and the maintainer abandons it with confirmation +- **THEN** the artifact directory and the temporary session are trashed, and the status shows no session diff --git a/docs/changes/adopt-block-review/tasks.md b/docs/changes/review-every-adopted-block/tasks.md similarity index 100% rename from docs/changes/adopt-block-review/tasks.md rename to docs/changes/review-every-adopted-block/tasks.md diff --git a/docs/changes/run-accepts-profile-settings/proposal.md b/docs/changes/run-accepts-profile-settings/proposal.md index 8edf748d..ceb85725 100644 --- a/docs/changes/run-accepts-profile-settings/proposal.md +++ b/docs/changes/run-accepts-profile-settings/proposal.md @@ -20,7 +20,7 @@ decisions: ["Automated run drops the profile arguments it owns and keeps the res ## Capabilities -- harness-run-command (modified) +- execute-prompts-off-session (modified) ## Impact diff --git a/docs/changes/run-accepts-profile-settings/pull-request.md b/docs/changes/run-accepts-profile-settings/pull-request.md index 7b2b111e..9821a34e 100644 --- a/docs/changes/run-accepts-profile-settings/pull-request.md +++ b/docs/changes/run-accepts-profile-settings/pull-request.md @@ -2,7 +2,7 @@ ## Plan -Change `run-accepts-profile-settings`: proposal, design, one ADDED delta under `harness-run-command`, tasks, and `adr.md` under `docs/changes/run-accepts-profile-settings/`. Proof under `docs/proofs/run-accepts-profile-settings/`. Reviewed by astra and grok before the push. Their eight fixes are in. +Change `run-accepts-profile-settings`: proposal, design, one ADDED delta under `execute-prompts-off-session`, tasks, and `adr.md` under `docs/changes/run-accepts-profile-settings/`. Proof under `docs/proofs/run-accepts-profile-settings/`. Reviewed by astra and grok before the push. Their eight fixes are in. ## Changes diff --git a/docs/changes/run-accepts-profile-settings/specs/harness-run-command/spec.md b/docs/changes/run-accepts-profile-settings/specs/execute-prompts-off-session/spec.md similarity index 100% rename from docs/changes/run-accepts-profile-settings/specs/harness-run-command/spec.md rename to docs/changes/run-accepts-profile-settings/specs/execute-prompts-off-session/spec.md diff --git a/docs/changes/sealed-review-ceremony/specs/sealed-review-ceremony/spec.md b/docs/changes/sealed-review-ceremony/specs/sealed-review-ceremony/spec.md deleted file mode 100644 index 44ec0b40..00000000 --- a/docs/changes/sealed-review-ceremony/specs/sealed-review-ceremony/spec.md +++ /dev/null @@ -1,292 +0,0 @@ -## ADDED Requirements - -### Requirement: The owner opens a draft with an open-seal - -`rune sign open <bookmark>` MUST refuse a protected branch (`main`, `master`, `trunk`) and a bookmark with no open -pull request at its head. The body MUST come from `docs/changes/<id>/pull-request.md` when the bookmark is -`change/<id>` and that file exists, else from `--body-file`. Before the key the command MUST print the bookmark, -the base, the diff stat against the base, and the body. Every jj call MUST pin `git.executable-path` to `git`. - -#### Scenario: Open refused on the protected branch - -- **WHEN** a session runs `rune sign open main --body-file body.md` -- **THEN** the command exits nonzero, names the protected branch, and signs nothing - -#### Scenario: Open refused without a draft - -- **WHEN** no open pull request has the bookmark as its head -- **THEN** the command exits nonzero, tells the session to push and let the app open the draft, and pushes nothing - -### Requirement: The open-seal commit names the draft - -After the print `rune sign open` MUST write an empty commit above the head whose message is `open-seal:`, one space, -and one JSON object with `repo`, `base`, `pull_request`, `tree`, and `nonce`, where `pull_request` is the draft's -number and `nonce` is thirty-two random bytes as lowercase hex, move the bookmark onto it, sign it through the -queue's pinned `jj sign`, and read the result back through git: sole parent the head, the head's tree, the message -as signed, a signature from a key in `KEYS` as read from the protected branch on `origin`. - -#### Scenario: Draft opened with a seal - -- **WHEN** the bookmark has a draft at its head and the body passes the schema -- **THEN** the bookmark points at a signed empty commit above the old head whose message names the repository, the - base, the draft's number, the tree, and a nonce, `origin` holds that commit for the bookmark, no hook of the - repository ran, the draft is ready, and the body ends with `Open-Seal-Nonce:` and that nonce - -### Requirement: The seal reaches the remote under pinned settings - -`rune sign open` MUST then push the seal to `origin` with `git push` under a lease on the head that was sealed, -with every git setting that names a program pinned on the command line (`core.hooksPath` to an empty directory, -`core.sshCommand` to `ssh`, `core.askPass` and `core.gitProxy` empty, `credential.helper` reset to the system and -user values), so nothing in the repository's own tree or config runs as the owner. - -#### Scenario: Seal pushed under a lease - -- **WHEN** the repository's own tree configures a hook or a program and the seal is pushed -- **THEN** `origin` holds the sealed head under a lease on the head that was sealed -- **AND** no program the repository names runs as the owner - -### Requirement: The draft carries the nonce - -`rune sign open` MUST then flip the draft with `gh pr ready` and replace the body with the validated body plus one -line `Open-Seal-Nonce: <nonce>`. With `--queue` it MUST record a request of kind `open` and sign nothing. -`rune sign next` completes such a request after the acknowledgment. - -#### Scenario: Draft readied with the nonce - -- **WHEN** the seal is on `origin` -- **THEN** the draft is ready and its body is the validated body plus one `Open-Seal-Nonce:` line - -#### Scenario: Session queues the open request - -- **WHEN** a session runs `rune sign open change/x --queue` -- **THEN** the command records a request of kind `open` and signs nothing -- **AND** `rune sign next` completes that request after the acknowledgment - -### Requirement: Open refuses a head that is not a fresh draft - -`rune sign open <bookmark>` MUST refuse a bookmark that does not descend from its remote-tracking head, a bookmark -whose head carries a signature from a key that is not in `KEYS`, a bookmark with more than one open pull request at -its head, a pull request that is not a draft, a pull request whose head is not the bookmark's head, and a body that -fails `schemas/PULL_REQUEST.mdschema`. - -#### Scenario: Open refused on a foreign branch, a ready draft, a stale head, two drafts, a bad body, a foreign key - -- **WHEN** the bookmark does not descend from `origin/<bookmark>`, or the pull request is not a draft, or its head - is not the bookmark's head, or two open pull requests have that head, or the body fails the schema, or the head - carries a signature from a key that is not in `KEYS` -- **THEN** the command exits nonzero, names the refusal, and `origin` still holds the head it held - -### Requirement: Open seals above an owner-signed head and reads the branch's body - -A head the owner signed already MUST be accepted, because the open-seal is a new commit above it. The body MUST be -read from `docs/changes/<id>/pull-request.md` in the bookmark's own tree, so the workspace `open` runs in need not -be on that branch. The push MUST run `gh auth git-credential` for its own process and trust only system- and user-scope helpers besides, never a repository-scope one, and `open` MUST refuse before signing when an HTTPS push URL has no credential. - -#### Scenario: Open refuses an HTTPS origin with no trusted helper - -- **WHEN** the push URL is HTTPS, `gh` is logged out for the host, and no system- or user-scope helper covers it -- **THEN** the command exits nonzero before the key touch and names `gh auth login` and an SSH remote as the fixes - -#### Scenario: Open accepts an owner-signed head - -- **WHEN** the owner ran `jj sign` on the head before `rune sign open` -- **THEN** the command says the head is already owner-signed and seals above it - -#### Scenario: Open reads the body from the branch - -- **WHEN** `rune sign open change/x --repo <workspace>` runs from a workspace on another branch -- **THEN** the body is `docs/changes/x/pull-request.md` as `change/x` commits it, not the working copy's file - -### Requirement: The controller's ledger admits a head to the merge-seal - -Every lane in `lanes` MUST be `completed`, `completed-no-findings`, `skipped`, `ineligible`, `failed`, or -`rate-limited`, every thread in `threads` is disposed `fixed` or `rejected`, every check from -`gh pr checks --required` is in the `pass` bucket, and the receipt qualifies the head. A thread disposed `owner` or -without a disposition MUST be refused by name. A head that carries the owner's open-seal MAY be submitted. -`rune sign queue <bookmark>` keeps the in-place signing of CLI-0041 and reads no ledger. - -#### Scenario: Submit refused on an open thread - -- **WHEN** the ledger holds a thread with no disposition -- **THEN** `rune sign submit` exits nonzero, names the thread, and records nothing - -### Requirement: The verdict binds to the reviewed head and its generation - -`rune sign submit` MUST refuse unless the ledger's `reviewed_sha` is the bookmark's head and either its `verdict` -binds to that `reviewed_sha` at the ledger's `generation` with `verdict` equal to `clean`, or `verdict` is null and -`coverage` starts with `free lanes only`, in which case the coverage is recorded as `free-lanes-only` with the -coverage text as the reason. - -#### Scenario: Submit refused on an undisposed generation - -- **WHEN** the ledger's generation is greater than the generation the verdict binds to -- **THEN** `rune sign submit` exits nonzero, names both generations, and records nothing - -### Requirement: The ledger comes from the controller app - -`rune sign submit <bookmark>` MUST keep the CLI-0041 receipt rules and MUST read the ledger the controller publishes -as a check run named `ledger` on the head, created by the controller app `runeseer`: the newest check run whose -`app.slug` is `runeseer`, read through `gh api repos/<owner>/<name>/commits/<sha>/check-runs?check_name=ledger ---paginate --slurp`. A same-named check run from any other app MUST be ignored, and the refusal names the app. The -first line of the check run's output text is `ledger: {artifact_id, digest, generation, pull_request, -reviewed_sha}`. - -#### Scenario: Submit ignores a ledger from another app - -- **WHEN** the only `ledger` check run was created by an app other than the controller, or the controller's check - run is followed by a newer one from another app -- **THEN** `rune sign submit` refuses for want of a ledger from `runeseer` in the first case and reads the - controller's check run in the second - -### Requirement: Submit checks the artifact and the pull request - -The command MUST download the artifact through `gh api -repos/<owner>/<name>/actions/artifacts/<id>/zip`, read `runeseer-ledger.json` from it, and refuse unless its sha256 -equals `digest` and its `reviewed_sha` and `generation` equal the line's. The pull request is the one open pull -request whose head is the bookmark, read through `gh pr list --head`, it MUST NOT be a draft, and the line's -`pull_request` MUST be its number. - -#### Scenario: Submit refuses an artifact that is not the ledger - -- **WHEN** the artifact the controller's line names does not hash to the line's `digest` -- **THEN** `rune sign submit` exits nonzero, says the artifact is not the ledger, and records nothing - -### Requirement: The request records the coverage it was admitted on - -The command MUST record the repository, pull request number, base, `reviewed_sha`, `generation`, `digest`, verdict, -reason, lanes, and threads on the request as its coverage. - -#### Scenario: Free lanes only in the view - -- **WHEN** the ledger's verdict is null, its coverage starts with `free lanes only`, and every other condition holds -- **THEN** the head is queued and `rune sign next` prints `free-lanes-only` with that reason as the coverage - -### Requirement: The owner acknowledges the view before the merge-seal - -For a request of kind `merge` or `open`, `rune sign next` MUST print the bookmark, the base, the diff stat against -the base, the coverage state with its lanes and thread dispositions or the body, the proof path and exit line when -a receipt exists, and the fields the signature authorizes: repository, bookmark, `reviewed_sha`, and `generation` -for a merge, repository, bookmark, base, and tree for an open. It MUST then read one line from `/dev/tty`, or from -the file `RUNE_SIGN_TTY` names, and MUST sign only on `y` or `Y`. A refusal MUST leave the request current and exit -nonzero. - -#### Scenario: Next declined - -- **WHEN** the owner answers `n` to the acknowledgment -- **THEN** the view was printed, nothing is signed, the request stays current, and the command exits nonzero - -### Requirement: The merge-seal names the reviewed head and its generation - -On acknowledgment of a merge request the command MUST write an empty commit whose sole parent is -`reviewed_sha` and whose message is `merge-seal:`, one space, and one JSON object with `reviewed_sha` and `generation`, -move the bookmark onto it, sign it through the pinned `jj sign`, read it back through git, and record the signed -commit. It MUST NOT push. A merge request whose bookmark no longer points at `reviewed_sha` MUST be `stale`. - -#### Scenario: Next acknowledged - -- **WHEN** the owner answers `y` -- **THEN** the bookmark points at a signed empty commit whose sole parent is `reviewed_sha` and whose message names - `reviewed_sha` and the generation, and nothing was pushed - -### Requirement: The owner adopts an outside pull request - -`rune sign adopt <number>` MUST refuse early unless `gh api repos/<owner>/<name>` reports `permissions.admin`. The -owner test is the key. It MUST read the pull request with `gh pr view` and refuse one that is not open, refuse when -`adopt/<number>` exists locally or on `origin`, validate the outside body against the schema, fetch -`refs/pull/<number>/head` from `origin` under the pinned transport settings, refuse when the fetched commit differs -from the pull request's head, and create the bookmark `adopt/<number>` at that commit. - -#### Scenario: Adopt refused without admin permission - -- **WHEN** `gh api repos/<owner>/<name>` does not report `permissions.admin` -- **THEN** the command exits nonzero before it reads the pull request - -### Requirement: The adopted head is sealed before anything is pushed - -`rune sign adopt <number>` MUST print the view and sign the open-seal above the fetched head, with `pull_request` -naming the outside pull request, before anything is pushed. Only then it MUST push the sealed bookmark to `origin` -under a lease that the branch did not exist, wait for the app's draft on `adopt/<number>` through repeated -`gh pr list --head`, flip that draft, and write the nonce into its body. A refused key MUST leave nothing on -`origin` and the bookmark back on the fetched head. - -#### Scenario: Outside head adopted - -- **WHEN** the owner runs `rune sign adopt 12` and the draft for `adopt/12` appears -- **THEN** `adopt/12` carries an open-seal above the fork's head that names pull request 12, `origin` holds only the - sealed head for the branch, and the draft is ready with the nonce in its body - -#### Scenario: Key refused during adopt - -- **WHEN** the owner cancels the signature -- **THEN** `origin` has no `adopt/12`, no draft was readied, and the local bookmark is at the fetched head - -### Requirement: The seal verifier serves the owner-seal check - -The open-seal MUST be searched in `refs/remotes/origin/<base>..<reviewed>` for the pull request's -base, at most two thousand commits, so a seal in merged history never qualifies, and the missing base ref is an -error. The nearest open-seal MUST be signed by a `KEYS` key, MUST name its own tree, MUST name the repository -`origin` points at, MUST name the pull request's number or, for an adopted pull request whose head branch is -`adopt/<number>`, that number, and MUST name the pull request's base. The pull request's body MUST carry the seal's -nonce as an `Open-Seal-Nonce:` line. - -#### Scenario: Valid pair accepted - -- **WHEN** the ref is a merge-seal above a head that carries an open-seal naming the pull request whose body carries - the nonce and whose head is the ref -- **THEN** every line reads `ok` and the command exits 0 - -#### Scenario: Inherited open-seal rejected - -- **WHEN** a branch forked from a sealed branch opens its own pull request and the ref is that branch's head -- **THEN** the seal names the original pull request, the new one carries no nonce, and the command exits 1 - -### Requirement: A copied nonce is reported and does not fail the check - -Another open body that carries the nonce MUST be reported and MUST NOT fail the check. - -#### Scenario: Copied nonce rejected - -- **WHEN** another pull request pastes the nonce into its body, whether the original is open, closed, or merged -- **THEN** that pull request fails on the seal's number, or on the missing seal when the original merged, and the - original, while open, passes with the copy reported - -### Requirement: The check binds one open pull request to the ref - -The pull request under check is the one `--pull-request` names, which MUST be open, or else the one open pull -request whose head is the ref, read through `gh pr list --state open`. Two at one head fail the check. The pull -request's head MUST be the ref. - -#### Scenario: One head under two bases - -- **WHEN** two open pull requests share the ref as head with different bases -- **THEN** the one the seal names passes with `--pull-request`, the other fails, and without the number the check - fails for want of one pull request - -### Requirement: The verifier resolves the ref and KEYS through git - -`rune sign --verify --seal <ref> [--pull-request <n>] [--keys-ref <ref>]` MUST resolve the ref through git alone, -so a plain checkout verifies, and MUST read `KEYS` from `--keys-ref`, by default the first of -`refs/remotes/origin/main`, `master`, `trunk` that exists, and never from the working tree. A ref without `KEYS` -is an error. The command MUST print one line per check, MUST exit 0 when every check holds, 1 when one fails, and 2 -when the repository or the forge cannot be read. - -#### Scenario: Plain checkout - -- **WHEN** the verifier runs in a `git clone` with no jj and `gpg` on PATH -- **THEN** a valid pair exits 0, a forged tree exits 1, and a forge outage exits 2 - -### Requirement: A merge-seal sits on the reviewed head it names - -When the ref's message is a merge-seal the command MUST check that the ref's sole parent is the `reviewed_sha` the -message names, that the ref's tree equals that parent's tree, and that the signature is from a `KEYS` key, and -MUST then search for the open-seal beneath `reviewed_sha`. Otherwise it MUST search beneath the ref. - -#### Scenario: Merge-seal with a moved parent rejected - -- **WHEN** the ref's message names a `reviewed_sha` that is not its sole parent -- **THEN** the command reports the parent check as failed and exits 1 - -#### Scenario: Merge-seal with an unequal tree rejected - -- **WHEN** the ref is a child of the `reviewed_sha` it names and its tree differs from that parent's tree -- **THEN** the command reports the tree check as failed and exits 1 diff --git a/docs/changes/adopt-command-restructure/design.md b/docs/changes/sidecars-move-with-artifacts/design.md similarity index 100% rename from docs/changes/adopt-command-restructure/design.md rename to docs/changes/sidecars-move-with-artifacts/design.md diff --git a/docs/changes/adopt-command-restructure/proposal.md b/docs/changes/sidecars-move-with-artifacts/proposal.md similarity index 87% rename from docs/changes/adopt-command-restructure/proposal.md rename to docs/changes/sidecars-move-with-artifacts/proposal.md index 7bedd448..4a7005f5 100644 --- a/docs/changes/adopt-command-restructure/proposal.md +++ b/docs/changes/sidecars-move-with-artifacts/proposal.md @@ -7,7 +7,7 @@ status: proposed ## Why -A reviewed adopt sidecar names its subject twice: the holder directory it sits in and the `subject.name` it records. Doctor read one, `rune provenance` read the other, and a hand move split them (cli#18, cli#20). `rune adopt doctor --repair` did nothing (cli#21). An `adapt` verdict kept its rationale but not the approved text, so finalize could not prove the wording arrived (cli#16). Two smaller gaps stayed open from adopt-block-review: the name pattern lived in code and schema with no parity test (cli#17), and a local directory source recorded a bare path as its upstream (cli#19). +A reviewed adopt sidecar names its subject twice: the holder directory it sits in and the `subject.name` it records. Doctor read one, `rune provenance` read the other, and a hand move split them (cli#18, cli#20). `rune adopt doctor --repair` did nothing (cli#21). An `adapt` verdict kept its rationale but not the approved text, so finalize could not prove the wording arrived (cli#16). Two smaller gaps stayed open from review-every-adopted-block: the name pattern lived in code and schema with no parity test (cli#17), and a local directory source recorded a bare path as its upstream (cli#19). [CLI-0037](../../decisions/CLI-0037%20Subject%20Identity%20and%20Transfer%20Records.md) records the one resolver and the transfer record. [CLI-0038](../../decisions/CLI-0038%20Repair%20as%20the%20Single%20Write%20Path.md) records the read-only doctors and `rune repair`. [CLI-0039](../../decisions/CLI-0039%20Replacement%20Content%20Store.md) records the replacement store. @@ -20,12 +20,12 @@ A reviewed adopt sidecar names its subject twice: the holder directory it sits i - `rune adopt verdict <id> adapt` accepts `--replacement` or `--replacement-file`. Finalize proves the text is present and writes `.provenance/replacements/<sha256>`. - A local directory source without `--source-url` records `file://<canonical path>`. - A test pins `ARTIFACT_NAME_RE` to the pattern in `schemas/skill.schema.yaml`. -- The adopt-review-hardening delta names the `transport` field the code writes. +- The unreviewed-adoptions-never-deploy delta names the `transport` field the code writes. - forge-core PROV-0006 enters as ASSEMBLY-0012 through reviewed adoption, and CLI-0023 and CLI-0027 point at it. ## Capabilities -- adoption-session-state (modified) +- record-provenance-for-adoptions (modified) ## Impact diff --git a/docs/changes/adopt-command-restructure/specs/adoption-session-state/spec.md b/docs/changes/sidecars-move-with-artifacts/specs/record-provenance-for-adoptions/spec.md similarity index 100% rename from docs/changes/adopt-command-restructure/specs/adoption-session-state/spec.md rename to docs/changes/sidecars-move-with-artifacts/specs/record-provenance-for-adoptions/spec.md diff --git a/docs/changes/adopt-command-restructure/tasks.md b/docs/changes/sidecars-move-with-artifacts/tasks.md similarity index 92% rename from docs/changes/adopt-command-restructure/tasks.md rename to docs/changes/sidecars-move-with-artifacts/tasks.md index cb03e781..8023f9ee 100644 --- a/docs/changes/adopt-command-restructure/tasks.md +++ b/docs/changes/sidecars-move-with-artifacts/tasks.md @@ -5,7 +5,7 @@ - [x] 1.3 `rune move <from> <to>` with the whole-artifact, same-repository, open-session, pending-skill, and decision-record refusals, and the `transferredFrom` record - [x] 1.4 `rune repair` as the single module-wide write path. Both doctors lose `--repair` and print the repair command - [x] 1.5 `--replacement` and `--replacement-file` on adapt verdicts, the finalize proof, and the `.provenance/replacements/<sha256>` store -- [x] 1.6 `file://<canonical>` attribution for directory sources, the name pattern parity test, and the adopt-block-review and adopt-review-hardening residue +- [x] 1.6 `file://<canonical>` attribution for directory sources, the name pattern parity test, and the review-every-adopted-block and unreviewed-adoptions-never-deploy residue - [x] 1.7 PROV-0006 adopted as ASSEMBLY-0012. CLI-0023 and CLI-0027 related entries resolve ## 2. Verification diff --git a/docs/changes/sign-before-publish-guard/specs/sign-before-publish-guard/spec.md b/docs/changes/sign-before-publish-guard/specs/sign-before-publish-guard/spec.md deleted file mode 100644 index 92458e02..00000000 --- a/docs/changes/sign-before-publish-guard/specs/sign-before-publish-guard/spec.md +++ /dev/null @@ -1,34 +0,0 @@ -## ADDED Requirements - -### Requirement: A head request is refused once the remote holds the head - -`rune sign queue <bookmark>` MUST refuse a head that `refs/remotes/origin/<bookmark>` already points at, or that is an ancestor of that remote ref. The refusal MUST say that signing in place would rewrite a published tip, MUST say to sign before pushing, and MUST name the manual override (`jj sign --ignore-immutable` and a leased force-push) as the owner's own step. A remote ref that points elsewhere, or no remote ref, MUST NOT refuse the request. - -#### Scenario: Head already pushed - -- **WHEN** `origin/main` points at the head of `main` and a session runs `rune sign queue main --receipt ok.log` -- **THEN** the command exits nonzero with `already published` and `Sign before you push`, and records no request - -#### Scenario: Remote moved past the head - -- **WHEN** `origin/change/x` points at a descendant of the head of `change/x` -- **THEN** the request is refused the same way - -#### Scenario: Remote holds another commit - -- **WHEN** `origin/change/x` points at a commit that is not the head and not its descendant -- **THEN** the request is recorded - -### Requirement: The signer names the immutable refusal and the pinentry trap - -When `jj sign` reports that the commit is immutable, `rune sign next` MUST record the request as not signed with a reason that says the head is published and to sign before pushing, and MUST NOT retry. On every pinentry timeout the command MUST print the hint that the pinentry needs focus and that a key touch elsewhere types a one-time password into that window. After a head request is signed the command MUST print the publication command, `jj git push --remote origin --bookmark <bookmark>`, and MUST NOT run it. - -#### Scenario: jj refuses an immutable commit - -- **WHEN** `jj sign` fails with `Commit <id> is immutable` -- **THEN** the request is reported as `refused: <bookmark> is immutable, which for a pushed bookmark means the head is published` and no second attempt runs - -#### Scenario: Head signed - -- **WHEN** `rune sign next` signs a head request -- **THEN** the output carries `signed <bookmark> <commit>` and a line `publish with: jj git push --remote origin --bookmark <bookmark>` diff --git a/docs/changes/sign-before-publish-guard/adr.md b/docs/changes/sign-before-publishing-online/adr.md similarity index 98% rename from docs/changes/sign-before-publish-guard/adr.md rename to docs/changes/sign-before-publishing-online/adr.md index 482f85e7..c8d2192f 100644 --- a/docs/changes/sign-before-publish-guard/adr.md +++ b/docs/changes/sign-before-publishing-online/adr.md @@ -20,7 +20,7 @@ accountable: ["@N4M3Z"] consulted: ["claude-fable-5-1"] informed: [] upstream: [] -change: sign-before-publish-guard +change: sign-before-publishing-online --- # Sign before publish, and the queue refuses a published head diff --git a/docs/changes/sign-before-publishing-online/design.md b/docs/changes/sign-before-publishing-online/design.md new file mode 100644 index 00000000..da13ed33 --- /dev/null +++ b/docs/changes/sign-before-publishing-online/design.md @@ -0,0 +1,15 @@ +# Design for Sign before publish guard + +## Context + +The [proposal](proposal.md) states the scope and the [draft decision record](adr.md) the reasoning. The +[delta specification](specs/sign-before-publishing-online/spec.md) states the guard in role terms: the queue refuses a +published head, the signer names the immutable refusal, the pinentry trap, and the publication command. This file maps +each requirement to the command, flags, and strings that implement it. + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids and messages | Exit status | +|---|---|---|---|---| +| A head request is refused once the remote holds the head | `rune sign queue <bookmark> --receipt <file>` compares the head with `refs/remotes/origin/<bookmark>`. The manual override is `jj sign -r <bookmark> --ignore-immutable` and a leased force-push, run by the owner. | none | `sign.head_published`: ``<bookmark> at <commit> is already published on <remote>: signing it in place would rewrite a pushed tip. Sign before you push. To re-sign a published head anyway, run `jj sign -r <bookmark> --ignore-immutable` yourself and force-push under a lease`` | nonzero, no request recorded | +| The signer names the immutable refusal and the pinentry trap | `rune sign next`. The immutable refusal is `jj sign` output that contains `is immutable`. The publication command is `jj git push --remote origin --bookmark <bookmark>`, printed as `publish with: ...` after `signed <bookmark> <commit>`, and carried as the `push` field under `--json`. | none | The reason is `refused: <bookmark> is immutable, which for a pushed bookmark means the head is published. Sign before you push.` The timeout hint is `Keep the pinentry focused and touch the key as soon as it appears; a touch while another window has focus types an OTP into that window.` | nonzero on the refusal, 0 after a signed head | diff --git a/docs/changes/sign-before-publish-guard/proposal.md b/docs/changes/sign-before-publishing-online/proposal.md similarity index 85% rename from docs/changes/sign-before-publish-guard/proposal.md rename to docs/changes/sign-before-publishing-online/proposal.md index fc17776f..beab0805 100644 --- a/docs/changes/sign-before-publish-guard/proposal.md +++ b/docs/changes/sign-before-publishing-online/proposal.md @@ -1,5 +1,5 @@ --- -adr: docs/changes/sign-before-publish-guard/adr.md +adr: docs/changes/sign-before-publishing-online/adr.md status: proposed decisions: ["Sign before publish, and the queue refuses a published head"] --- @@ -19,7 +19,7 @@ On 2026-09-20 two heads reached `main` unsigned and were signed afterwards by ha ### New Capabilities -- `sign-before-publish-guard`: a head request is refused when the remote already holds the head. The signer names the immutable refusal, the pinentry trap, and the publication command. +- `sign-before-publishing-online`: a head request is refused when the remote already holds the head. The signer names the immutable refusal, the pinentry trap, and the publication command. ## Impact diff --git a/docs/changes/sign-before-publish-guard/pull-request.md b/docs/changes/sign-before-publishing-online/pull-request.md similarity index 86% rename from docs/changes/sign-before-publish-guard/pull-request.md rename to docs/changes/sign-before-publishing-online/pull-request.md index 1b380d07..ecf58138 100644 --- a/docs/changes/sign-before-publish-guard/pull-request.md +++ b/docs/changes/sign-before-publishing-online/pull-request.md @@ -11,7 +11,7 @@ Signing in place rewrites the commit. On 2026-09-20 two heads reached `main` uns - Print `TIMEOUT_HINT` on every pinentry timeout in `src/cli/sign/queue/ceremony.rs`. - Print `publish with: jj git push --remote origin --bookmark <bookmark>` after a signed head, and carry it as `push` in `--json`, in `src/cli/sign/queue/mod.rs`. - Extract the signing proof fixture into `docs/proofs/signing-fixture.sh`, give it a bare `origin` with `main` at the base, and source it from both signing proofs. -- Add `docs/proofs/sign-before-publish-guard/` (record.sh, cast, gif, transcript) and `docs/changes/sign-before-publish-guard/` with proposal, delta spec, tasks, and `adr.md`. +- Add `docs/proofs/sign-before-publishing-online/` (record.sh, cast, gif, transcript) and `docs/changes/sign-before-publishing-online/` with proposal, delta spec, tasks, and `adr.md`. - Add one `CHANGELOG.md` line under Unreleased. ## Testing @@ -19,7 +19,7 @@ Signing in place rewrites the commit. On 2026-09-20 two heads reached `main` uns - [x] `cargo test --all-features`: 791 unit tests and every integration suite pass, including the new `a_request_refuses_a_head_the_remote_already_holds`. - [x] `cargo clippy --all-features --all-targets -- -D warnings` and `cargo fmt --check` clean. - [x] `prek run --all-files` and `prek run --stage pre-push --all-files` in an isolated clone, both exit 0. -- [x] `docs/proofs/sign-before-publish-guard/record.sh` runs its six scenes to exit 0 against the built binary, and the recording is committed. +- [x] `docs/proofs/sign-before-publishing-online/record.sh` runs its six scenes to exit 0 against the built binary, and the recording is committed. ## Release Notes diff --git a/docs/changes/sign-before-publishing-online/specs/sign-before-publishing-online/spec.md b/docs/changes/sign-before-publishing-online/specs/sign-before-publishing-online/spec.md new file mode 100644 index 00000000..b3e141ba --- /dev/null +++ b/docs/changes/sign-before-publishing-online/specs/sign-before-publishing-online/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: A head request is refused once the remote holds the head + +The queue MUST refuse a head request when the remote's copy of the bookmark already points at the head or a descendant of it. The refusal MUST say that signing in place would rewrite a published tip, and MUST say to sign before pushing. It MUST name the manual override, signing past the immutable mark and a leased force-push, as the owner's own step. The queue MUST NOT refuse the request when the remote copy points elsewhere or when there is no remote copy. + +#### Scenario: Head already pushed + +- **WHEN** the remote's copy of `main` points at the head of `main` and a session queues `main` with a passing receipt +- **THEN** the queue exits nonzero, says the head is already published and to sign before pushing, and records no request + +#### Scenario: Remote moved past the head + +- **WHEN** the remote's copy of `change/x` points at a descendant of the head of `change/x` +- **THEN** the request is refused the same way + +#### Scenario: Remote holds another commit + +- **WHEN** the remote's copy of `change/x` points at a commit that is not the head and not its descendant +- **THEN** the request is recorded + +### Requirement: The signer names the immutable refusal and the pinentry trap + +When the signing tool reports that the commit is immutable, the signer MUST record the request as not signed, and MUST NOT retry. The reason MUST say that the head is published and to sign before pushing. On every pinentry timeout the signer MUST print a hint. The hint says that the pinentry needs focus and that a key touch elsewhere types a one-time password into that window. After a head request is signed the signer MUST print the publication command and MUST NOT run it. + +#### Scenario: jj refuses an immutable commit + +- **WHEN** the signing tool refuses the commit as immutable +- **THEN** the request is reported as refused because the head is published, and no second attempt runs + +#### Scenario: Head signed + +- **WHEN** the signer signs a head request +- **THEN** the output names the signed bookmark and commit, and a line carries the publication command the signer did not run diff --git a/docs/changes/sign-before-publish-guard/tasks.md b/docs/changes/sign-before-publishing-online/tasks.md similarity index 94% rename from docs/changes/sign-before-publish-guard/tasks.md rename to docs/changes/sign-before-publishing-online/tasks.md index f1b1fc34..d468274a 100644 --- a/docs/changes/sign-before-publish-guard/tasks.md +++ b/docs/changes/sign-before-publishing-online/tasks.md @@ -10,7 +10,7 @@ ## 2. Verification - [x] 2.1 Unit test for the classifier, integration test for the three remote positions (equal, descendant, unrelated) -- [x] 2.2 One recorded proof under `docs/proofs/sign-before-publish-guard/` on the shared `signing-fixture.sh` +- [x] 2.2 One recorded proof under `docs/proofs/sign-before-publishing-online/` on the shared `signing-fixture.sh` - [x] 2.3 Both prek stages in an isolated clone ## 3. Record diff --git a/docs/changes/sign-commits-in-batches/design.md b/docs/changes/sign-commits-in-batches/design.md new file mode 100644 index 00000000..29c627fc --- /dev/null +++ b/docs/changes/sign-commits-in-batches/design.md @@ -0,0 +1,34 @@ +# Design for Signing Queue + +## Context + +The [proposal](proposal.md) states the scope. The delta specifications [queue-holds-signing-requests](specs/queue-holds-signing-requests/spec.md) and +[protect-signing-with-mutex](specs/protect-signing-with-mutex/spec.md) state the behavior in role terms: a session asks, the queue records and lists, the signer signs at the owner's key. This file +maps each requirement to the command, flags, paths, and strings that implement it in the rune command line tool, so +the specification never has to name them. + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids and messages | Exit status | +|---|---|---|---|---| +| A session queues a signing request | `rune sign queue <bookmark> --receipt <file>` records a `head` request, signed in place. `rune sign submit <bookmark> --receipt <file>` is a separate command that records a `merge` request admitted against the controller's review scorecard, under [CLI-0042](../../decisions/CLI-0042%20Sealed%20Review%20Ceremony%20Commands.md) and the `owner-key-signs-merges` change. `--repo <dir>` names the workspace, default the enclosing jj workspace. | none | none | 0 when recorded | +| The queue hands the request to the owner | The handoff line names `rune sign next`. The notification goes through the desktop notifier. | none | none | 0 even when no notification can be sent | +| The queue refuses a request it cannot trust | `rune sign queue <bookmark> --receipt <file>`. `--receipt` is required. | none | The refusal names the missing bookmark, the signed head, the commit the receipt covers, or the existing request. | nonzero, nothing recorded | +| A receipt ends with an exit line | `--receipt <file>` whose last line is `exit=<status>` or `<stage>-exit=<status>`. | none | The refusal names the receipt. | nonzero, nothing recorded | +| The queue is one directory for every repository | `rune sign queue` lists, `rune sign queue --json` emits the array, `rune sign queue --prune` prunes. The queue directory is `$XDG_STATE_HOME/rune/sign-queue`, by default `~/.local/state/rune/sign-queue`. `RUNE_STATE_DIR` replaces the `rune` directory. | none | The empty listing and `rune sign next` name the queue directory. | 0 | +| The queue lists requests in stack order | `rune sign queue` with no bookmark lists. With a bookmark it queues that bookmark. `rune sign drop <bookmark>` and `rune sign queue --prune` remove a `stale` request. | none | States `stale` and `blocked` in the listing. | 0 | +| Each request shows one state | `rune sign queue`, `rune sign show <bookmark>`. | none | States `current`, `stale`, `blocked`, `claimed`, `unverified`, `signed`. | 0 | +| Signing an ancestor keeps a request current | `rune sign queue` after `rune sign next` signed the ancestor. | none | State `current`. | 0 | +| The owner signs from the queue | `rune sign next` signs the first request, `rune sign all` every one. The signing tool is `jj sign -r <commit id>`. A timeout is attempted `SIGN_ATTEMPTS` = 3 times in all, so at most two retries. | none | A skipped request is reported with its state. | nonzero when any request failed or was skipped | +| Each request is claimed and signed under the lock | `rune sign next` and `rune sign all` run `jj sign -r <commit id>` in the request's workspace. | none | The failure reports the request as stale. | nonzero on a moved bookmark | +| Each signed head is verified and recorded | `rune sign next` and `rune sign all` verify with `git verify-commit --raw` against `KEYS`, the `rune sign --verify` check. Neither pushes. | none | The failure names the foreign fingerprint. | nonzero on a foreign key | +| The signer locks the repository queue before it signs | `rune sign next`, `rune sign all`. One lock per repository git directory. | none | The second invocation reports the lock holder. | nonzero for the second invocation | +| Claims are recoverable | `rune sign next`, `rune sign all`. A claim records the process id and the start time. The bounded age is `CLAIM_MAX_AGE_SECONDS`, 3600 seconds. | none | A foreign key is recorded as `failed`. | 0 when a reclaimed head is recorded as signed | +| The ceremony command and the queue do not mix | Bare `rune sign`, `rune sign --amend`, `rune sign --tag <name> [commit]`, and `rune sign --verify [ref]` keep their meaning. `rune sign --tag v1 next` tags the commit named `next`. `rune sign next --tag v1` is rejected. | none | The parser names the unexpected flag. | nonzero for a queue subcommand with a ceremony flag after it | +| Requests are inspected and withdrawn | `rune sign show <bookmark>`, `rune sign drop <bookmark>`. `--repo <dir>` names the repository. `--id <request>` selects by request id. | none | The refusal names the missing request. | nonzero when no request exists | +| Nothing the repository configures is trusted | `rune sign next` passes `signing.backends.gpg.program=<the owner's user-scope value>` and `signing.behavior="drop"` on the jj command line. The repository-scope `signing.*` keys are never read. | none | none | none specified | +| Every read goes through git | `jj git export`, `git merge-base`, `git verify-commit --raw` with the gpg program pinned on the command line. | none | The refusal names the conflicted bookmark. | nonzero | +| The signer refuses an aliased template keyword | `jj config get template-aliases.<keyword>` detects an aliased keyword before `rune sign next` or `rune sign all` signs. | none | `the jj config defines template-aliases.<keyword>, which changes what jj reports: unset it before signing` | nonzero | +| The repository lock is an operating-system file lock | `rune sign submit`, `rune sign drop`, and `rune sign queue --prune` take the same lock. `rune sign queue <bookmark> --json` prints one JSON (JavaScript Object Notation) object. | none | none | none specified | + +The decision record is [CLI-0041 Signing Queue](../../decisions/CLI-0041%20Signing%20Queue.md). diff --git a/docs/changes/owner-signing-queue/proposal.md b/docs/changes/sign-commits-in-batches/proposal.md similarity index 78% rename from docs/changes/owner-signing-queue/proposal.md rename to docs/changes/sign-commits-in-batches/proposal.md index 5239fc25..736a7cb1 100644 --- a/docs/changes/owner-signing-queue/proposal.md +++ b/docs/changes/sign-commits-in-batches/proposal.md @@ -17,7 +17,8 @@ by reading the transcript. - `rune sign` keeps its meaning, the owner's seal, tag, and verify ceremony, and gains a queue of signing requests as subcommands. -- `rune sign queue <bookmark>` and its alias `rune sign submit <bookmark>` record a request from a session: repository, +- `rune sign queue <bookmark>` records a request from a session (`rune sign submit <bookmark>` was its alias until + CLI-0042 gave `submit` the merge-seal admission, per the decision record's audit note): repository, workspace, bookmark, the head's change and commit ids, and the check receipt that qualified it. A request for a signed head, a head without a passing receipt, or a bookmark that does not exist is refused. A desktop notification tells the owner. @@ -31,7 +32,10 @@ by reading the transcript. ## Capabilities -- owner-signing-queue (new) +### New Capabilities + +- `queue-holds-signing-requests`: a session queues a request with its receipt, and the queue lists, inspects, withdraws, and prunes requests by state. +- `protect-signing-with-mutex`: the owner signs the next request under a per-repository file lock, with claims, trusted keys, and verification. ## Impact diff --git a/docs/changes/sign-commits-in-batches/specs/protect-signing-with-mutex/spec.md b/docs/changes/sign-commits-in-batches/specs/protect-signing-with-mutex/spec.md new file mode 100644 index 00000000..6bde7e7b --- /dev/null +++ b/docs/changes/sign-commits-in-batches/specs/protect-signing-with-mutex/spec.md @@ -0,0 +1,128 @@ +## ADDED Requirements + +### Requirement: The owner signs from the queue + +The signer MUST sign the first `current` request in stack order, or all `current` ones in that order on request. It +MUST stop at the first failure. A `stale` or `blocked` request MUST be skipped and reported, never signed, and MUST NOT +stop the search for the next `current` one. The signer MUST make at most three signing attempts, retrying only on a +reported timeout. It MUST stop on a cancellation or any other failure. The exit status MUST be nonzero when any request +failed or was skipped. + +#### Scenario: Pinentry timed out once + +- **WHEN** the first signing attempt reports a timeout and the second succeeds +- **THEN** the request is recorded as signed with the new commit id and the signer exits zero + +#### Scenario: Pinentry cancelled + +- **WHEN** the signing tool reports that the operation was cancelled +- **THEN** the signer releases the claim, does not retry, and exits nonzero + +### Requirement: Each request is claimed and signed under the lock + +The signer MUST claim each request by request id. Under the lock it MUST re-derive the request's state and refuse it +unless `current`. It MUST record the head's commit id as seen under the lock. It MUST sign exactly that commit in the +request's workspace. + +#### Scenario: Bookmark moved after the claim + +- **WHEN** the bookmark's head differs from the commit id observed under the lock when the signer signs +- **THEN** nothing is signed, the request is reported as stale, and the signer exits nonzero + +### Requirement: Each signed head is verified and recorded + +After signing, the signer MUST confirm that the bookmark points at a commit with the recorded identity fields and a +signature. It MUST verify that signature against the trusted-key file with the owner's verification check. It MUST +record the signed commit id under the same request id. The signer MUST NOT push. + +#### Scenario: Signature from another key + +- **WHEN** the signature on the signed commit does not match a key in the trusted-key file +- **THEN** the request is recorded as failed, the signer names the fingerprint, and exits nonzero + +### Requirement: The signer locks the repository queue before it signs + +Before claiming a request the signer MUST check that its receipt is unchanged on disk and still qualifies the recorded +commit. Before signing anything in a repository the signer MUST take a lock on that repository's queue, held until the +signer exits. A second signer MUST report the holder and exit nonzero. The signer MUST take the locks before it +computes the stack order. It MUST read the owner's signing settings only when a request is about to be signed. + +#### Scenario: Two owners at once + +- **WHEN** two signers start together on one repository +- **THEN** exactly one holds the lock and signs, and the other reports the holder and exits nonzero + +### Requirement: Claims are recoverable + +A claim MUST record the signer's process id and start time. A claim whose process is gone or older than a bounded +age MUST be reclaimable. Before signing a reclaimed request the signer MUST re-derive its state. When the bookmark's +head already carries a verified signature with the recorded identity fields, the signer MUST record it as signed +without signing again. A signature from a key outside the trusted-key file on a queued head MUST be recorded as a +failure, never signed over. + +#### Scenario: Signer crashed after signing + +- **WHEN** a request is claimed by a dead process and its head is already signed with the recorded identity +- **THEN** the signer records it as signed and does not sign again + +### Requirement: The ceremony command and the queue do not mix + +The owner's ceremony (the seal, the amended seal, the tag, and the verification) and the queue share one entry point. +The two MUST keep their meaning apart. The signer MUST read a bare word after a ceremony option as the commit to tag, +never as a queue step. The signer MUST reject a ceremony option after a queue step, with an error. + +#### Scenario: Ceremony flag before a subcommand name + +- **WHEN** the owner names a tag and then the bare word `next` +- **THEN** the signer tags the commit named `next` and never touches the queue + +#### Scenario: Subcommand followed by a ceremony flag + +- **WHEN** the owner names the queue step `next` and then a tag option +- **THEN** the signer exits nonzero and names the unexpected option + +### Requirement: Nothing the repository configures is trusted + +A session controls the repository the owner signs in, and the version control tool renders every read through +templates and revsets its configuration can redefine. When the owner signs, every signing setting MUST come from the +owner's user-scope configuration or the tool default. The signer MUST NOT read the backend, the program, the behavior, +or the key from the repository's configuration. The behavior MUST drop the signatures of rewritten descendants, so the +rewrite signs nothing else. + +#### Scenario: Repository names a hostile signing program + +- **WHEN** the repository's own configuration names a signing program of its own +- **THEN** the signer signs through the owner's user-scope program and the repository's program never runs + +### Requirement: Every read goes through git + +The queue and the signer MUST make every read through git. They MUST export to git, resolve the bookmark as a git ref, +and read the identity fields from the commit object. They MUST compute ancestry with git's merge base. They MUST verify +signatures with git's raw commit verification, the owner's verification check, with the OpenPGP program pinned on the +command line. Both MUST refuse a conflicted bookmark that cannot be exported, at every step. + +#### Scenario: Bookmark is conflicted + +- **WHEN** the bookmark cannot be exported to git because it is conflicted +- **THEN** queueing, listing, and signing refuse it and name the conflict + +### Requirement: The signer refuses an aliased template keyword + +The signer MUST refuse a configuration that aliases a template keyword of the settings listing. + +#### Scenario: Repository aliases a template keyword + +- **WHEN** the repository's own configuration aliases a template keyword the settings listing uses +- **THEN** the signer refuses to sign and names the alias + +### Requirement: The repository lock is an operating-system file lock + +The repository lock MUST be an operating-system file lock held for the signer's lifetime, so a crash releases it and +nothing can steal it. Queueing, withdrawal, and pruning MUST take it too, and pruning MUST observe each request again +under it before removal. The receipt path MUST be recorded absolute, because the owner signs from another directory. +The JSON form of queueing one bookmark MUST print one JSON object. + +#### Scenario: Prune removes a request under the lock + +- **WHEN** the owner prunes the queue and the pruning step takes the repository lock +- **THEN** it observes each request again under that lock before it removes one diff --git a/docs/changes/sign-commits-in-batches/specs/queue-holds-signing-requests/spec.md b/docs/changes/sign-commits-in-batches/specs/queue-holds-signing-requests/spec.md new file mode 100644 index 00000000..260a0d9e --- /dev/null +++ b/docs/changes/sign-commits-in-batches/specs/queue-holds-signing-requests/spec.md @@ -0,0 +1,114 @@ +## ADDED Requirements + +### Requirement: A session queues a signing request + +When a session asks, the queue MUST record a signing request for a bookmark in the current workspace or in one the +session names. The request MUST carry a request id, the repository's git directory, the workspace path, and the +bookmark. It MUST carry the head's change id, commit id, tree id, author, description, and parent change ids. It MUST +carry the receipt path with its digest, and the request time. Recording MUST NOT sign anything. + +#### Scenario: Head queued after a passing dry run + +- **WHEN** a session queues `change/x` with a receipt that names the head's commit id and ends with `dryrun-exit=0` +- **THEN** a request exists for `change/x` with the head's identity fields and the receipt digest +- **AND** the output names the owner's signing step + +### Requirement: The queue hands the request to the owner + +On success the queue MUST print one line that names the request and the owner's next step. It MUST attempt a desktop +notification, and MUST NOT fail when none can be sent. + +#### Scenario: Notification cannot be sent + +- **WHEN** a session queues a head on a machine with no desktop notification service +- **THEN** the request is recorded, the output names the owner's signing step, and the queue exits zero + +### Requirement: The queue refuses a request it cannot trust + +A request MUST name a receipt. The queue MUST refuse a bookmark that does not exist, and a head that is already signed. +It MUST refuse a receipt that does not name the head's commit id, or whose last line is not a passing exit line. It +MUST refuse a bookmark that already has a request that is not stale. The queue stores requests by request id, never by +bookmark name, so a bookmark with a slash cannot nest or collide. + +#### Scenario: Receipt checked a different commit + +- **WHEN** the receipt does not contain the head's commit id +- **THEN** the queue exits nonzero, names the commit the receipt covers, and records nothing + +#### Scenario: Head already signed + +- **WHEN** the bookmark's head carries a signature +- **THEN** the queue exits nonzero and records nothing + +### Requirement: A receipt ends with an exit line + +The receipt's last line MUST match `<name>exit=<status>`, where `<name>` is empty or one lowercase word and a hyphen. +So `exit=0` and `dryrun-exit=0` qualify and `not-an-exit=0` does not. A passing exit line carries status zero. + +#### Scenario: Receipt reports a failure + +- **WHEN** the receipt's last exit line is nonzero +- **THEN** the queue exits nonzero, names the receipt, and records nothing + +#### Scenario: Receipt has no exit line + +- **WHEN** the receipt's last line is not an exit line +- **THEN** the queue exits nonzero and records nothing + +### Requirement: The queue is one directory for every repository + +The queue is one directory under the user's state directory. When it holds no request, the listing and the signer +MUST name that directory, so an empty queue and a different directory are told apart. The JSON (JavaScript Object +Notation) listing MUST emit the same records as an array. Pruning MUST remove stale, signed, and failed requests and +report each removal. It MUST keep an `unverified` one, because only the signer can tell a crashed signing step from a +foreign key. + +#### Scenario: Queue is empty + +- **WHEN** no request exists and a session lists the queue or the owner signs from it +- **THEN** the output names the queue directory it read + +### Requirement: The queue lists requests in stack order + +The listing MUST show every request across repositories. It identifies a repository by its git directory, so +workspaces of one repository share an order. Within one repository, the listing MUST place a request before every +request whose head descends from it, on any parent path. A request whose workspace no longer exists MUST list as +`stale`. It MUST stay removable by withdrawal and by pruning, and MUST NOT stop any step for the other requests. + +#### Scenario: Stack of two + +- **WHEN** `change/base` and `change/top` are queued and `change/top` descends from `change/base` +- **THEN** the list shows `change/base` first and `change/top` as `blocked` until `change/base` is signed + +### Requirement: Each request shows one state + +Each request MUST show one state. `current`: the bookmark's head has the recorded change id, tree id, author, +description, and parent change ids. `stale`: any of them differs. `blocked`: a queued ancestor is not signed. `claimed`: +a signer holds it. `unverified`: the head carries a signature the queue has not verified, whether a signer that died +after signing or a hand signing produced it. `signed`: the queue verified and recorded it. + +#### Scenario: Head amended after the request + +- **WHEN** the bookmark's head changes its tree, author, description, or parents after the request +- **THEN** the request is listed as `stale` + +### Requirement: Signing an ancestor keeps a request current + +A request MAY differ from its record in commit id and signature. It MUST stay `current` when nothing else differs, +which is what signing an ancestor changes. + +#### Scenario: Head rewritten by signing an ancestor + +- **WHEN** the bookmark's head is rewritten with a new commit id and the same recorded identity fields +- **THEN** the request stays `current` + +### Requirement: Requests are inspected and withdrawn + +Inspection MUST print the request record with its state, and withdrawal MUST remove the request unless it is claimed. +Both resolve the bookmark in the current workspace's repository, or in a repository the caller names. Both MUST exit +nonzero when no request exists for it there. A request id selects a request without a repository. + +#### Scenario: Request withdrawn + +- **WHEN** a session withdraws the request for `change/x` +- **THEN** the request no longer appears in the listing diff --git a/docs/changes/owner-signing-queue/tasks.md b/docs/changes/sign-commits-in-batches/tasks.md similarity index 91% rename from docs/changes/owner-signing-queue/tasks.md rename to docs/changes/sign-commits-in-batches/tasks.md index 7c7b4c06..6d7d5742 100644 --- a/docs/changes/owner-signing-queue/tasks.md +++ b/docs/changes/sign-commits-in-batches/tasks.md @@ -15,7 +15,7 @@ - [x] 2.2 Integration test: queue two stacked bookmarks in a temporary jj repository, sign with a stub backend, both end signed and the descendant is not reported stale - [x] 2.3 `cargo test`, clippy, and the commit and push stages through the ContinuousIntegration procedure -- [x] 2.4 Behavior proof: the scenes in `docs/proofs/owner-signing-queue/record.sh` run end to end against the built binary +- [x] 2.4 Behavior proof: the scenes in `docs/proofs/sign-commits-in-batches/record.sh` run end to end against the built binary and their transcript, cast, and GIF are committed ## 3. Follow-up diff --git a/docs/changes/sign-open-branch-tree/design.md b/docs/changes/sign-open-branch-tree/design.md new file mode 100644 index 00000000..3410f91b --- /dev/null +++ b/docs/changes/sign-open-branch-tree/design.md @@ -0,0 +1,17 @@ +# Design for Sign open branch tree + +## Context + +The [proposal](proposal.md) states the scope and the [draft decision record](adr.md) the architecture. The +[delta specification](specs/sign-open-branch-tree/spec.md) states the four fixes in role terms: the archiver, the +validator, the promotion step, and the build workspace. This file maps each requirement to the command, flags, paths, +hook, and strings that implement it. + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids and messages | Exit status | +|---|---|---|---|---| +| Archive takes a new capability's purpose from the proposal | `rune spec archive <id>` reads the capability's bullet under `### New Capabilities` in `proposal.md` (the name as a code span, a colon, the purpose) and writes `docs/specs/<name>/spec.md` opening with `## Purpose`. | none | The placeholder is `TBD - created by archiving change <id>. Update Purpose after archive.` | 0 | +| The validator names a parse refusal | `rune spec doctor`. | `rune-spec-doctor` | The parser's message, for a missing keyword `requirement '<name>' must contain SHALL or MUST in its body`, with the file and line. `canonical specification has no recognized requirements` only for a specification that parses and holds none. | nonzero on an error | +| Promotion refuses divergent provider copies | `rune promote <name> --domain <domain> --change <id>` compares the registered provider copies, such as `.claude/skills/X/SKILL.md` and `.codex/skills/X/SKILL.md`. | none | `draft '<name>' differs between <path> and <path>; edit them to match or drop one first` | nonzero, nothing touched | +| The rune-docs crate is a workspace member | `Cargo.toml` declares `[workspace] members = ["rune-docs"]`. `cargo test --workspace` runs in `test.yaml` and `release.yaml`. | `cargo-test` in the `prek run --stage pre-push` stage | none | nonzero when a unit test fails | diff --git a/docs/changes/sign-open-branch-tree/proposal.md b/docs/changes/sign-open-branch-tree/proposal.md index e08012cd..ebd86574 100644 --- a/docs/changes/sign-open-branch-tree/proposal.md +++ b/docs/changes/sign-open-branch-tree/proposal.md @@ -27,7 +27,7 @@ Tonight's landings hit four rough edges. `rune sign open` refused a head the own ### Modified Capabilities -- `sealed-review-ceremony`: the open refusal list drops the signed head and gains the foreign key, and a new requirement covers the owner-signed head and the branch body. +- `owner-key-signs-merges`: the open refusal list drops the signed head and gains the foreign key, and a new requirement covers the owner-signed head and the branch body. ## Impact diff --git a/docs/changes/sign-open-branch-tree/pull-request.md b/docs/changes/sign-open-branch-tree/pull-request.md index 8d3483c0..d3e95b3a 100644 --- a/docs/changes/sign-open-branch-tree/pull-request.md +++ b/docs/changes/sign-open-branch-tree/pull-request.md @@ -10,7 +10,7 @@ Four rough edges from tonight's landings, each with a test: `open` refused a `jj - Accept an owner-signed head in `open`, refuse a signature from a key outside `KEYS`, document `--repo`. - Take a new capability's purpose from the proposal in `archive`, report parse issues with their line in `doctor`, and refuse divergent provider copies in `promote`. - Add `rune-docs` to the Cargo workspace, run `cargo test --workspace` in `test.yaml`, `release.yaml`, and the push hook, and fix the five broken unit tests, the two clippy findings, and the rustfmt drift. -- Update the `sealed-review-ceremony` delta and add `docs/changes/sign-open-branch-tree/`. +- Update the `owner-key-signs-merges` delta and add `docs/changes/sign-open-branch-tree/`. ## Testing diff --git a/docs/changes/sign-open-branch-tree/specs/sign-open-branch-tree/spec.md b/docs/changes/sign-open-branch-tree/specs/sign-open-branch-tree/spec.md index eb99bb95..4429d5cd 100644 --- a/docs/changes/sign-open-branch-tree/specs/sign-open-branch-tree/spec.md +++ b/docs/changes/sign-open-branch-tree/specs/sign-open-branch-tree/spec.md @@ -2,41 +2,41 @@ ### Requirement: Archive takes a new capability's purpose from the proposal -`rune spec archive` MUST write the purpose of a capability that has no canonical specification from the proposal's `### New Capabilities` bullet for that capability, and MUST fall back to the OpenSpec placeholder only when the proposal has no such bullet. +The archiver MUST write the purpose of a capability that has no canonical specification from the proposal's new-capabilities bullet for that capability. It MUST fall back to the OpenSpec placeholder only when the proposal has no such bullet. #### Scenario: Proposal names the capability -- **WHEN** `proposal.md` holds `- \`search\`: find a rune by name.` under `### New Capabilities` and the change is archived -- **THEN** `docs/specs/search/spec.md` opens with `## Purpose` and `find a rune by name.` +- **WHEN** the proposal lists the capability `search` with the purpose `find a rune by name.` under its new capabilities and the archiver archives the change +- **THEN** the canonical specification for `search` opens with a purpose section that reads `find a rune by name.` #### Scenario: Proposal has no bullet - **WHEN** the proposal names no bullet for the capability -- **THEN** the archived purpose is the `TBD - created by archiving change <id>` placeholder +- **THEN** the archived purpose is the placeholder that names the archived change -### Requirement: Doctor names a parse refusal +### Requirement: The validator names a parse refusal -`rune spec doctor` MUST report each parse issue of a canonical specification it cannot read as an error with the file and line, and MUST report `no recognized requirements` only for a specification that parses and holds none. +The validator MUST report each parse issue of a canonical specification it cannot read as an error, with the file and line. It MUST report that no requirement was recognized only for a specification that parses and holds none. #### Scenario: Requirement without MUST - **WHEN** a canonical requirement body carries no normative keyword -- **THEN** doctor prints the parser's `must contain` message with the file path and line as an error +- **THEN** the validator reports the parser's finding, which names the missing keyword, with the file path and line as an error -### Requirement: Promote refuses divergent provider copies +### Requirement: Promotion refuses divergent provider copies -`rune promote` MUST compare every provider copy of the draft it promotes and MUST refuse, touching nothing, when two copies differ, naming both paths. +The promotion step MUST compare every provider copy of the draft it promotes and MUST refuse, touching nothing, when two copies differ, naming both paths. #### Scenario: Copies differ -- **WHEN** `.claude/skills/X/SKILL.md` and `.codex/skills/X/SKILL.md` are both registered for draft `X` and their bytes differ -- **THEN** `rune promote X` exits nonzero with `differs between`, and the deck, the change stub, and the register are unchanged +- **WHEN** two provider copies of the draft `X` are both registered and their bytes differ +- **THEN** promotion exits nonzero, names both copies, and the deck, the change stub, and the register are unchanged ### Requirement: The rune-docs crate is a workspace member -The repository MUST declare `rune-docs` as a Cargo workspace member, and the push hook and CI MUST run `cargo test --workspace`, so the crate's unit tests run wherever the CLI's do. +The repository MUST declare the `rune-docs` crate as a member of its one build workspace. The push hook and the continuous integration MUST run the tests of the whole workspace. So the crate's unit tests run wherever the command line tool's do. #### Scenario: Unit test fails in rune-docs - **WHEN** a `rune-docs` unit test fails -- **THEN** `prek run --stage pre-push` and the release workflow fail with it +- **THEN** the pre-push hook stage and the release workflow fail with it diff --git a/docs/changes/trusted-key-anchor-adoption/adr.md b/docs/changes/sign-with-trusted-keys/adr.md similarity index 97% rename from docs/changes/trusted-key-anchor-adoption/adr.md rename to docs/changes/sign-with-trusted-keys/adr.md index 8ba8b960..f11f0f56 100644 --- a/docs/changes/trusted-key-anchor-adoption/adr.md +++ b/docs/changes/sign-with-trusted-keys/adr.md @@ -19,7 +19,7 @@ consulted: ["claude-fable-5-1"] informed: [] upstream: - "https://github.com/runedeck/skeleton" -change: trusted-key-anchor-adoption +change: sign-with-trusted-keys --- # rune sign reads the KEYS signer format diff --git a/docs/changes/sign-with-trusted-keys/design.md b/docs/changes/sign-with-trusted-keys/design.md new file mode 100644 index 00000000..4bd8553a --- /dev/null +++ b/docs/changes/sign-with-trusted-keys/design.md @@ -0,0 +1,13 @@ +# Design for Trusted key anchor adoption + +## Context + +The [proposal](proposal.md) states the scope and the [draft decision record](adr.md) the reasoning. The +[delta specification](specs/sign-with-trusted-keys/spec.md) states the behavior in role terms: the signer reads the +trusted-key file. This file maps the requirement to the command, file, and strings that implement it. + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids and messages | Exit status | +|---|---|---|---|---| +| The signer reads the trusted-key file format | `rune sign`, `rune sign open`, and `rune sign next` read the `KEYS` file on the protected branch. A signer line is `signer <fingerprint> <address>...`. An armored block that starts with `-----BEGIN PGP PUBLIC KEY BLOCK-----` is read through `gpg --show-keys`. The accepted fingerprint is the `VALIDSIG` fingerprint gpg reports. | none | The refusal names the malformed line. | nonzero on a malformed line, nothing signed | diff --git a/docs/changes/trusted-key-anchor-adoption/proposal.md b/docs/changes/sign-with-trusted-keys/proposal.md similarity index 88% rename from docs/changes/trusted-key-anchor-adoption/proposal.md rename to docs/changes/sign-with-trusted-keys/proposal.md index ad2e5377..a6754aac 100644 --- a/docs/changes/trusted-key-anchor-adoption/proposal.md +++ b/docs/changes/sign-with-trusted-keys/proposal.md @@ -1,5 +1,5 @@ --- -adr: docs/changes/trusted-key-anchor-adoption/adr.md +adr: docs/changes/sign-with-trusted-keys/adr.md status: proposed decisions: ["rune sign reads the KEYS signer format"] --- @@ -19,7 +19,7 @@ The skeleton change `trusted-key-anchor-format` turns `KEYS` from an armored key ### New Capabilities -- `trusted-key-anchor`: the cli side of the skeleton capability of the same name. +- `sign-with-trusted-keys`: the cli side of the skeleton capability of the same name. ## Impact diff --git a/docs/changes/sign-with-trusted-keys/specs/sign-with-trusted-keys/spec.md b/docs/changes/sign-with-trusted-keys/specs/sign-with-trusted-keys/spec.md new file mode 100644 index 00000000..8981528e --- /dev/null +++ b/docs/changes/sign-with-trusted-keys/specs/sign-with-trusted-keys/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: The signer reads the trusted-key file format + +The signer MUST read the pins of the repository's trusted-key file from `signer <fingerprint> <address>...` lines, upper-cased. It MUST refuse a line with another keyword, a fingerprint that is not 40 hexadecimal characters, or no address. So a typo never shrinks the trusted set to nothing and never widens it. The signer MUST read a trusted-key file that is still an armored key block through the OpenPGP tool's key listing. So a consumer from before the format change keeps working. + +#### Scenario: Signer lines + +- **WHEN** the trusted-key file on the protected branch holds `signer 29DD2145CE7A818929459B2649F08103D3DA399E git@martinzeman.net` +- **THEN** the open-seal step accepts a seal whose verified signing fingerprint is `29DD2145…` and refuses any other + +#### Scenario: Malformed line + +- **WHEN** a line reads `trusted 29DD… a@b` +- **THEN** the signer exits nonzero naming the line and signs nothing + +#### Scenario: Armored block + +- **WHEN** the trusted-key file starts with `-----BEGIN PGP PUBLIC KEY BLOCK-----` +- **THEN** the pins are every primary and subkey fingerprint in the block, as before diff --git a/docs/changes/trusted-key-anchor-adoption/tasks.md b/docs/changes/sign-with-trusted-keys/tasks.md similarity index 100% rename from docs/changes/trusted-key-anchor-adoption/tasks.md rename to docs/changes/sign-with-trusted-keys/tasks.md diff --git a/docs/changes/spec-house-rules/specs/spec-change-lifecycle/spec.md b/docs/changes/spec-house-rules/specs/spec-change-lifecycle/spec.md deleted file mode 100644 index ceb7c51b..00000000 --- a/docs/changes/spec-house-rules/specs/spec-change-lifecycle/spec.md +++ /dev/null @@ -1,57 +0,0 @@ -## ADDED Requirements - -### Requirement: House rules are a separate lint - -`rune spec validate` and `rune spec doctor` MUST apply the runedeck house rules to every canonical specification and every active delta through one lint that the OpenSpec compatibility parser does not share. The parser MUST keep accepting both upstream normative keywords. - -#### Scenario: Legacy keyword in prose - -- **WHEN** a prose line outside a code fence or inline code span contains `SHALL` -- **THEN** validation reports `spec-shall-keyword` or `delta-shall-keyword` with the line number, and the artifact still parses - -#### Scenario: Upstream oracle fixture - -- **WHEN** an OpenSpec v1.6.0 oracle fixture that uses `SHALL` is validated -- **THEN** the only diagnostics are the house keyword rule, and archive still merges the delta - -### Requirement: House rules run in the commit-stage checks - -The `rune-spec-doctor` hook MUST run `rune spec doctor` for every change under `docs/specs/` or `docs/changes/`, and under `REQUIRE_RUNE` an absent `rune` binary MUST fail the hook. The Build quality job, which has the binary built from the same commit, MUST set `REQUIRE_RUNE`. - -#### Scenario: Binary absent in CI - -- **WHEN** the commit-stage checks run with `REQUIRE_RUNE` set and no `rune` on the path -- **THEN** the hook fails instead of skipping - -#### Scenario: Binary absent on a fresh clone - -- **WHEN** the hooks run without `REQUIRE_RUNE` and no `rune` on the path -- **THEN** the hook skips so the clone stays usable before `make install` - -### Requirement: Capabilities stay short - -A canonical specification longer than 150 lines MUST fail validation with `spec-too-long`. A delta longer than 150 lines MUST warn with `delta-too-long`. - -#### Scenario: Specification over the limit - -- **WHEN** a canonical specification has 151 lines -- **THEN** validation reports an error that names the limit and asks for a split - -#### Scenario: Specification at the limit - -- **WHEN** a canonical specification has 150 lines -- **THEN** validation reports nothing for its length - -### Requirement: Defined terms have a glossary entry - -A term marked with single-asterisk italics in a specification or delta MUST have an entry in `<specs root>/glossary.md` of the form `- **term**: definition`, compared without case, with a plain plural matching its singular. A term with no entry MUST fail validation with `spec-term-undefined` or `delta-term-undefined`. An italic label that ends in a colon MUST NOT count as a defined term. - -#### Scenario: Term without an entry - -- **WHEN** a specification marks a term in italics and the glossary has no such entry -- **THEN** validation names the term, the glossary path, and the entry shape to add - -#### Scenario: Plural term - -- **WHEN** a specification marks the plural of a term in italics and the glossary defines its singular -- **THEN** validation reports nothing for the term diff --git a/docs/changes/specifications-in-plain-language/adr.md b/docs/changes/specifications-in-plain-language/adr.md new file mode 100644 index 00000000..21dab09d --- /dev/null +++ b/docs/changes/specifications-in-plain-language/adr.md @@ -0,0 +1,63 @@ +--- +title: "The validator reports a program name in a requirement body" +description: "A requirement body names a role, and a program name has no place in it. The validator reports an inline code span that names a command or a flag, as a warning while the repository migrates and an error afterwards." +type: adr +category: process +tags: + - cli + - specs + - lint +status: proposed +created: 2026-10-18 +updated: 2026-10-18 +author: "@N4M3Z" +project: cli +related: + - "CORE-0023 Rune Fills the Tool Roles" + - "CLI-0040 Specification House Rules" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: ["claude-fable-5-1@claude"] +informed: [] +upstream: [] +change: specifications-in-plain-language +--- + +# The validator reports a program name in a requirement body + +## Context and Problem Statement + +The owner decided in the voice review of 17 October 2026 that a specification names roles. A requirement says what happens and who does it, and a program name has no place in it. The actors are a validator, an assembler, a generator, a deployer, a fixer, a signer, and an archiver. The deck's record CORE-0023 Rune Fills the Tool Roles binds those roles to rune. Most requirements in the review, signing, and adoption changes of this repository name a command and its flags today, and nothing reports it. CLI-0040 put the house rules in a lint beside the compatibility parser, and this record adds one rule to that lint. + +## Decision Drivers + +- A requirement outlives a command name, so a rename changes no specification +- The rule holds in every repository that validates its specifications, whether or not the style checker runs there +- The names a body loses stay findable, in the change's design +- Existing changes keep validating while the repository migrates + +## Considered Options + +1. A style-checker rule alone. It sees prose, not requirement bodies, never fails specification validation, and runs only where the style checker runs. +2. A finding in the shared lint. The validator reports an inline code span in a requirement body whose first word is in the program list, or that starts with a dash. Severity comes from the repository configuration. +3. Review alone. A reviewer catches the names, and the next author writes them back. + +## Decision Outcome + +Option 2, with the style checker as the second net. + +The validator MUST keep these rules: + +- A code span in a requirement body MUST be reported when its first word is a listed program or it starts with a dash. Canonical specifications and deltas count alike. +- The finding MUST be a warning by default and an error once the repository configuration raises it. +- A span in a scenario step, a span in a fenced block, or a path with no command MUST NOT raise this finding. The style checker's bare-command rule covers those. +- Each name a body loses MUST appear in the change's design, in a commands table with one row per requirement. + +The house rule covers more than the finding. Hook ids, paths, and diagnostic ids have no place in a body either, and review catches those until a check exists. + +## Consequences + +- Every active change whose requirements name a command moves the names into a design commands table during the migration. +- A literal that starts with a listed word is a false positive. The list is short, and the finding warns until the tree is clean. +- The program list needs upkeep. An unknown program passes until an author lists it in the repository configuration. +- Archive moves this record to `docs/decisions/` with the next free CLI number. diff --git a/docs/changes/specifications-in-plain-language/design.md b/docs/changes/specifications-in-plain-language/design.md new file mode 100644 index 00000000..4566edbd --- /dev/null +++ b/docs/changes/specifications-in-plain-language/design.md @@ -0,0 +1,34 @@ +# Design + +## Approach + +The rule has two halves: a house rule for authors and one validator finding. The finding runs in the shared lint that validate and doctor already use, beside the keyword, length, and prose rules. It scans the requirement body, the prose between the requirement heading and the next heading, after fenced code is skipped. An inline code span counts as a program name when its first word is in the program list. A span that starts with a dash counts too. The program list is the known tools of this repository plus a repository configuration extension. Severity comes from the repository configuration: a warning by default while the migration runs, an error once the repository raises it. + +The alternative was a style-checker rule alone. The deck's style checker gains a bare-command rule in the same round of changes. That rule covers paths and scenario steps, runs only where the style checker runs, and never fails specification validation. The validator finding makes the rule part of specification validation in every repository that uses rune. The style rule stays as the second net for paths and for commands in scenario steps. + +## Coverage table + +| Capability | Requirement | Check or missed requirement | +|---|---|---| +| specifications-in-plain-language | Requirement Bodies Name Roles | the validator's program-name finding for commands and flags, and a missed requirement for hook ids, paths, and diagnostic ids, which review catches | +| specifications-in-plain-language | The Validator Reports A Program Name | the program-name finding, covered by the lint unit tests | + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids | Exit status | +|---|---|---|---|---| +| Requirement Bodies Name Roles | none for the authored rule. `rune spec validate [NAME]` and `rune spec doctor` report the finding below | `rune-spec-doctor` | `spec-program-name`, `delta-program-name` | 0 while a warning, nonzero once an error | +| The Validator Reports A Program Name | `rune spec validate [NAME]` and `rune spec doctor`. Configuration `spec.program_names: warn` (default) or `error`, and `spec.programs: [...]` extends the program list | `rune-spec-doctor` | `spec-program-name`, `delta-program-name` | 0 for a warning, nonzero for an error | + +The configuration keys and the diagnostic ids are proposed here. The implementer may pick other names, and then updates this table. + +## Structure + +- `rune-docs/src/spec/lint.rs`: `lint_program_names` beside `lint_prose`, called from `lint_canonical` and `lint_delta`. +- The merged configuration: `spec.program_names` and `spec.programs`, read the way `spec.min_name_words` is. +- Default program list: `rune`, `jj`, `git`, `gh`, `cargo`, `openspec`, `python3`, `vale`, `mdschema`, `lychee`, `prek`, `bash`, `sh`, `make`. + +## Risks + +- A false positive on a literal that starts with a listed word. The list is short and the finding warns during the migration, so authors see the pattern before it blocks. +- The migration never ends and the warning becomes noise. The rewrite of the review, signing, and adoption changes in this round moves the names into design tables. A task raises the level once the tree is clean. diff --git a/docs/changes/specifications-in-plain-language/proposal.md b/docs/changes/specifications-in-plain-language/proposal.md new file mode 100644 index 00000000..bed0e153 --- /dev/null +++ b/docs/changes/specifications-in-plain-language/proposal.md @@ -0,0 +1,30 @@ +--- +adr: docs/changes/specifications-in-plain-language/adr.md +status: proposed +--- + +# Plain language specifications + +## Why + +A requirement that names a command binds the rule to one program's interface. Commands get renamed while the behavior stays, and most requirements in the review, signing, and adoption changes name a command and its flags today. The owner decided in the specification review of 17 October 2026 that a specification names roles. The roles are a validator, an assembler, a generator, a deployer, a fixer, a signer, and an archiver. A program name has no place in a requirement. The deck's record CORE-0023 Rune Fills the Tool Roles says that rune fills those roles. The register and the four-part explanation live in the deck's style rules and review skill. The register means: lead with the point, name an actor, one idea per sentence. This change covers only what the validator does. + +## What Changes + +- The validator reports an inline code span inside a requirement body that names a command or a flag. The finding is a warning while the repository migrates its specifications and becomes an error afterwards. +- The names a requirement body loses move to the change's design, into a commands table with one row per requirement. Nothing is lost, it moves. +- Scenario steps are outside the validator's finding. The house rule still covers them: a step describes observable behavior, and the deck's style checker reports a bare command or path there. +- The compatibility parser is unchanged. +- This change uses the shape of the requirement levels change: one scenario per requirement in `spec.md`, the rest in `edge-cases.md`. + +## Capabilities + +### New Capabilities + +- `specifications-in-plain-language`: the validator reports a program name in a requirement body, and the design's commands table carries the names. + +## Impact + +- `rune-docs/src/spec/lint.rs` and its tests. The repository configuration gains the severity switch and the program list. +- Every active change whose requirements name a command: the names move into a `design.md` commands table during the migration. +- The deck's style rules and review skill own the register and the explanation template. This change does not touch them. diff --git a/docs/changes/specifications-in-plain-language/specs/specifications-in-plain-language/edge-cases.md b/docs/changes/specifications-in-plain-language/specs/specifications-in-plain-language/edge-cases.md new file mode 100644 index 00000000..693eef9f --- /dev/null +++ b/docs/changes/specifications-in-plain-language/specs/specifications-in-plain-language/edge-cases.md @@ -0,0 +1,36 @@ +## Edge cases + +### Requirement: The Validator Reports A Program Name + +#### Edge case: Flag stands alone in a code span + +- **WHEN** a requirement body holds an inline code span that starts with a dash and names no command before it +- **THEN** the validator reports the same finding as for a command + +#### Edge case: Path stands alone in a code span + +- **WHEN** a requirement body holds an inline code span that is a file path and names no command +- **THEN** the validator reports nothing for it +- **DECISION**: narrows Requirement Bodies Name Roles, which bans paths too. The validator reports commands and flags only, and the style checker's bare-command rule reports paths, so the two checks do not overlap. + +#### Edge case: Command appears in a scenario step + +- **WHEN** a scenario step, not the requirement body, holds an inline code span that names a command +- **THEN** the validator reports nothing for it +- **DECISION**: narrows the validator's finding to the requirement body. The house rule still covers scenario steps: a step describes observable behavior, and the style checker's bare-command rule reports a command there. + +#### Edge case: Code span names a program outside the list + +- **WHEN** a requirement body holds an inline code span that starts with no dash and whose first word is not in the repository's program list +- **THEN** the validator reports nothing, and the author extends the program list in the repository configuration when a program is missing +- **DECISION**: narrows The Validator Reports A Program Name. A command is a word in the repository's program list, so an unknown program passes until the author lists it. + +#### Edge case: Command sits inside a fenced block + +- **WHEN** a requirement body holds a fenced code block that shows a command +- **THEN** the validator reports nothing, because fenced code is an example and not prose + +#### Edge case: Repository raises the level + +- **WHEN** the repository configuration sets the program-name finding to an error after its specifications migrated +- **THEN** the validator reports an error and the change fails validation diff --git a/docs/changes/specifications-in-plain-language/specs/specifications-in-plain-language/spec.md b/docs/changes/specifications-in-plain-language/specs/specifications-in-plain-language/spec.md new file mode 100644 index 00000000..01ed5786 --- /dev/null +++ b/docs/changes/specifications-in-plain-language/specs/specifications-in-plain-language/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Requirement Bodies Name Roles + +A requirement body says what happens and which role does it. The roles are a validator, an assembler, a generator, a deployer, a fixer, a signer, and an archiver. The body MUST NOT name a program: no command, flag, hook id, file path, or diagnostic id. Each name the body drops MUST appear in the change's design, in a commands table with one row per requirement. A command gets renamed while the behavior stays, and a specification describes behavior. + +#### Scenario: Author moves a command name into the design + +- **WHEN** an author moves a command, its flags, and its diagnostic ids from a requirement body into the design's commands table +- **THEN** the body names the role that acts, the validator reports nothing, and the design row keeps every name the body lost + +### Requirement: The Validator Reports A Program Name + +The validator MUST report an inline code span inside a requirement body that names a command or a flag. It reports canonical specifications and change deltas alike. The finding names the file, the requirement heading line, and the span. While the repository migrates its specifications the finding is a warning, so existing changes keep validating. Once the repository raises the level in its configuration, the finding is an error. Scenario steps are outside the validator's finding. + +#### Scenario: Requirement body names a command + +- **WHEN** a requirement body in a delta holds an inline code span whose first word is a command +- **THEN** the validator reports a named finding at the requirement heading as a warning, and the change still validates diff --git a/docs/changes/specifications-in-plain-language/tasks.md b/docs/changes/specifications-in-plain-language/tasks.md new file mode 100644 index 00000000..181eb710 --- /dev/null +++ b/docs/changes/specifications-in-plain-language/tasks.md @@ -0,0 +1,21 @@ +## 1. Implementation + +- [ ] 1.1 Add the program-name rule to the shared lint: body only, code spans, listed first word or a leading dash +- [ ] 1.2 Read `spec.program_names` (default `warn`) and `spec.programs` from the merged configuration +- [ ] 1.3 Emit `spec-program-name` and `delta-program-name` with the file, the heading line, and the span + +## 2. Verification + +- [ ] 2.1 Lint unit tests: command span and flag span reported, error level fails. Path span, scenario step, fenced block, and unknown program pass +- [ ] 2.2 `cargo test -p rune-docs` and `rune spec validate --source .` over this repository with the default level +- [ ] 2.3 One recorded proof per scenario and edge case under `docs/proofs/specifications-in-plain-language/` + +## 3. Migration + +- [ ] 3.1 Move every command, flag, hook id, path, and diagnostic id from each active change's requirement bodies into its design commands table +- [ ] 3.2 Raise `spec.program_names` to `error` in this repository once the tree reports no warning +- [ ] 3.3 Mirror the deck's bare-command style rule in this repository's style configuration, so a path in requirement prose is reported too + +## 4. Record + +- [ ] 4.1 Archive moves `adr.md` to `docs/decisions/` with the next free CLI number, related to the deck's CORE-0023 Rune Fills the Tool Roles diff --git a/docs/changes/specs-must-stay-short/design.md b/docs/changes/specs-must-stay-short/design.md new file mode 100644 index 00000000..69d75313 --- /dev/null +++ b/docs/changes/specs-must-stay-short/design.md @@ -0,0 +1,25 @@ +# Spec House Rules Design + +## Approach + +The house rules are one lint layered over the OpenSpec compatibility parser, so the parser keeps accepting upstream artifacts and the rules can change without a parser fork. This beat a stricter parser, which would reject the v1.6.0 oracle fixtures, and it beat a separate script, which runs only where someone invokes it. [CLI-0040](../../decisions/CLI-0040%20Specification%20House%20Rules.md) records the decision. + +## Structure + +- `rune-docs/src/spec/lint.rs`: the keyword, length, and term rules, shared by validate and doctor. +- `rune-docs/templates/spec/delta-spec.md`: the delta template uses MUST. +- `.pre-commit-config.yaml` and `.github/workflows/build.yaml`: the `rune-spec-doctor` hook and `REQUIRE_RUNE`. + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids | Exit status | +|---|---|---|---|---| +| House rules are a separate lint | `rune spec validate`, `rune spec doctor` | `rune-spec-doctor` | `spec-shall-keyword`, `delta-shall-keyword` | 1 on an error | +| House rules run in the commit-stage checks | `rune spec doctor` for every change under `docs/specs/` or `docs/changes/` | `rune-spec-doctor`, with `REQUIRE_RUNE` set by the Build quality job. A fresh clone before `make install` skips | none | the hook fails with the doctor's status when the binary is required | +| Capabilities stay short | the same | | `spec-too-long` (error), `delta-too-long` (warning) | 1, and 0 for the warning | +| Defined terms have a glossary entry | the same. Pages under `docs/specs/glossary/`, written by `rune spec glossary --write`, migrated by `rune spec fix --links` | `rune-spec-doctor` | `spec-term-undefined`, `delta-term-undefined`, and the other term ids in the `every-term-cites-definition` design | 1 | + +## Risks + +- The two term deltas, this change's and `every-term-cites-definition`, describe one rule twice until both archive. The later one supersedes. +- A consumer that lints with this release converts SHALL to MUST and splits any capability over the limit. diff --git a/docs/changes/spec-house-rules/proposal.md b/docs/changes/specs-must-stay-short/proposal.md similarity index 52% rename from docs/changes/spec-house-rules/proposal.md rename to docs/changes/specs-must-stay-short/proposal.md index f29c27e6..3ec6b676 100644 --- a/docs/changes/spec-house-rules/proposal.md +++ b/docs/changes/specs-must-stay-short/proposal.md @@ -11,17 +11,18 @@ The skeleton's review-ceremony specification grew past three hundred lines, mixe ## What Changes -- `rune spec validate` and `rune spec doctor` report a prose line that uses SHALL, a canonical specification over 150 lines (a delta over that length warns), and an italic defined term with no entry in `docs/specs/glossary.md`. +- `rune spec validate` and `rune spec doctor` report a prose line that uses SHALL, a canonical specification over 150 lines (a delta over that length warns), and a term link that names no ontology term. The design's Commands table names the diagnostic ids. +- A defined term is a link to its generated glossary page, emphasized at the first use in a file and plain after it. Plain italics are emphasis. The change `every-term-cites-definition` carries the term rules in full. - The OpenSpec compatibility parser and its v1.6.0 oracle fixtures are unchanged. Separate tests cover the house rules. -- The cli's own specifications use MUST. `spec-change-lifecycle` splits off `openspec-tree-interop`, and `model-commit-attribution` splits off `worktree-commit-identity`, so each capability stays under the limit. -- The scaffolded delta template uses MUST. +- The cli's own specifications use MUST. `lifecycle-of-a-change` splits off `openspec-trees-full-compatibility`, and `model-identity-on-commits` splits off `commits-carry-model-identity`, so each capability stays under the limit. +- The delta template uses MUST. ## Capabilities -- spec-change-lifecycle (modified) +- lifecycle-of-a-change (modified) ## Impact - `rune-docs/src/spec/{lint.rs,validate.rs,doctor.rs}`, `rune-docs/templates/spec/delta-spec.md`, `rune-docs/tests/fixtures/spec/*`. -- `docs/specs/{spec-change-lifecycle,openspec-tree-interop,model-commit-attribution,worktree-commit-identity}/spec.md`, every active delta, `docs/walkthroughs/Spec.md`. -- Consumers that lint their specifications with this release convert SHALL to MUST and split any capability over the limit. +- `docs/specs/{lifecycle-of-a-change,openspec-trees-full-compatibility,model-identity-on-commits,commits-carry-model-identity}/spec.md`, every active delta, `docs/walkthroughs/Spec.md`. +- Consumers that lint their specifications with this release convert SHALL to MUST, split any capability over the limit, and run `rune spec fix --links` to convert their term marks. diff --git a/docs/changes/specs-must-stay-short/specs/lifecycle-of-a-change/spec.md b/docs/changes/specs-must-stay-short/specs/lifecycle-of-a-change/spec.md new file mode 100644 index 00000000..5aa2cf5a --- /dev/null +++ b/docs/changes/specs-must-stay-short/specs/lifecycle-of-a-change/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: House rules are a separate lint + +The *[validator][VALIDATOR]* MUST apply the runedeck house rules to every canonical specification and every active *[delta specification][DELTASPECIFICATION]*. It MUST do so through one lint that the OpenSpec compatibility parser does not share. The parser MUST keep accepting both upstream normative keywords. + +#### Scenario: Legacy keyword in prose + +- **WHEN** a prose line outside a code fence or inline code span contains `SHALL` +- **THEN** the validator reports a named diagnostic with the line number, and the artifact still parses + +#### Scenario: Upstream oracle fixture + +- **WHEN** an OpenSpec v1.6.0 oracle fixture that uses `SHALL` is validated +- **THEN** the only diagnostics are the house keyword rule, and archive still merges the delta + +### Requirement: House rules run in the commit-stage checks + +The commit-stage check MUST run the validator's advisory pass for every change under the specifications or the changes directory. When the environment requires the validator and none is installed, the hook MUST fail. The build quality job, which has the validator built from the same commit, MUST require it. Without that requirement an absent validator MUST skip, so a fresh clone stays usable before the tools are installed. + +#### Scenario: Binary absent in CI + +- **WHEN** the commit-stage checks run with the validator required and none on the path +- **THEN** the hook fails instead of skipping + +#### Scenario: Binary absent on a fresh clone + +- **WHEN** the hooks run without the requirement and no validator on the path +- **THEN** the hook skips so the clone stays usable before the tools are installed + +### Requirement: Capabilities stay short + +A canonical specification longer than 150 lines MUST fail validation. A delta longer than 150 lines MUST warn. The diagnostic MUST name the limit and ask for a split. + +#### Scenario: Specification over the limit + +- **WHEN** a canonical specification has 151 lines +- **THEN** the validator reports an error that names the limit and asks for a split + +#### Scenario: Specification at the limit + +- **WHEN** a canonical specification has 150 lines +- **THEN** the validator reports nothing for its length + +### Requirement: Defined terms have a glossary entry + +A defined term in a specification or delta MUST be a link to the term's generated glossary page. The first use in the file MUST be emphasized and every later use plain. A definition at the end of the file MUST point at the page by relative path. The link text MAY be the plural of the label. A link that names no ontology term MUST fail validation. Plain italics are emphasis and MUST NOT be reported. + +#### Scenario: Term link without a page + +- **WHEN** a specification links a term and the ontology defines no such term +- **THEN** the validator names the term, the file and line, and the ontology to extend + +#### Scenario: Plural term + +- **WHEN** a specification links the plural of a term whose page the definition names +- **THEN** the validator reports nothing for the term + +[DELTASPECIFICATION]: ../../../../specs/glossary/delta-specification.md +[VALIDATOR]: ../../../../specs/glossary/validator.md diff --git a/docs/changes/spec-house-rules/tasks.md b/docs/changes/specs-must-stay-short/tasks.md similarity index 74% rename from docs/changes/spec-house-rules/tasks.md rename to docs/changes/specs-must-stay-short/tasks.md index 2960dd98..2ec12593 100644 --- a/docs/changes/spec-house-rules/tasks.md +++ b/docs/changes/specs-must-stay-short/tasks.md @@ -2,7 +2,7 @@ - [x] 1.1 Add the `lint` module with the keyword, length, and glossary rules, shared by validate and doctor - [x] 1.2 Keep the compatibility parser and the OpenSpec v1.6.0 oracle fixtures unchanged -- [x] 1.3 Convert the cli specifications and deltas to MUST and split `spec-change-lifecycle` and `model-commit-attribution` +- [x] 1.3 Convert the cli specifications and deltas to MUST and split `lifecycle-of-a-change` and `model-identity-on-commits` - [x] 1.4 Switch the delta template and the archive fixtures to MUST ## 2. Verification @@ -15,3 +15,8 @@ - [x] 3.1 Spec walkthrough, rune-docs changelog, cli changelog - [x] 3.2 The rune hooks fail under `REQUIRE_RUNE` when the binary is absent, and the Build quality job sets it + +## 4. Link model + +- [x] 4.1 The term rule becomes the link rule: the delta and `design.md` with the Commands table. The change `every-term-cites-definition` carries the implementation and its tests +- [ ] 4.2 `docs/walkthroughs/Spec.md` house rules section diff --git a/docs/changes/interactive-rune-shell/design.md b/docs/changes/stable-shell-for-skills/design.md similarity index 100% rename from docs/changes/interactive-rune-shell/design.md rename to docs/changes/stable-shell-for-skills/design.md diff --git a/docs/changes/interactive-rune-shell/proposal.md b/docs/changes/stable-shell-for-skills/proposal.md similarity index 97% rename from docs/changes/interactive-rune-shell/proposal.md rename to docs/changes/stable-shell-for-skills/proposal.md index 41173ca4..33f3b74a 100644 --- a/docs/changes/interactive-rune-shell/proposal.md +++ b/docs/changes/stable-shell-for-skills/proposal.md @@ -24,7 +24,7 @@ Agent Skills leaves the Markdown body unrestricted, so this structure is a Runed ## Capabilities -- portable-skill-authoring (new) +- skills-portable-across-harnesses (new) ## Impact diff --git a/docs/changes/interactive-rune-shell/specs/portable-skill-authoring/spec.md b/docs/changes/stable-shell-for-skills/specs/skills-portable-across-harnesses/spec.md similarity index 100% rename from docs/changes/interactive-rune-shell/specs/portable-skill-authoring/spec.md rename to docs/changes/stable-shell-for-skills/specs/skills-portable-across-harnesses/spec.md diff --git a/docs/changes/interactive-rune-shell/tasks.md b/docs/changes/stable-shell-for-skills/tasks.md similarity index 100% rename from docs/changes/interactive-rune-shell/tasks.md rename to docs/changes/stable-shell-for-skills/tasks.md diff --git a/docs/changes/copier-consumer-sync/design.md b/docs/changes/stay-current-with-skeleton/design.md similarity index 94% rename from docs/changes/copier-consumer-sync/design.md rename to docs/changes/stay-current-with-skeleton/design.md index 2525cc0c..9cc67595 100644 --- a/docs/changes/copier-consumer-sync/design.md +++ b/docs/changes/stay-current-with-skeleton/design.md @@ -9,7 +9,7 @@ Rewrite the phantom pin to the deck's real baseline, let Copier merge to the cur - `answers.yaml`, `Makefile`, `.github/workflows/quality.yaml`, `.pre-commit-config.yaml`: template base plus cli additions. - `.rumdl.toml`, `typos.toml`: template base plus cli excludes and accepted words. - `.github/workflows/{release,test,module-release}.yaml`: cli-only workflows hardened to the template's zizmor baseline. -- `docs/changes/copier-consumer-sync`: this change. +- `docs/changes/stay-current-with-skeleton`: this change. ## Risks diff --git a/docs/changes/copier-consumer-sync/proposal.md b/docs/changes/stay-current-with-skeleton/proposal.md similarity index 95% rename from docs/changes/copier-consumer-sync/proposal.md rename to docs/changes/stay-current-with-skeleton/proposal.md index ca3fe903..585cbe6b 100644 --- a/docs/changes/copier-consumer-sync/proposal.md +++ b/docs/changes/stay-current-with-skeleton/proposal.md @@ -19,8 +19,8 @@ The cli's `answers.yaml` pinned `v0.5.0`, a tag skeleton never had, so Copier co ## Capabilities -- skeleton-template-composition (new) -- pull-request-review-ceremony (new) +- stay-current-with-skeleton (new) +- changes-require-stated-specs (new) ## Impact diff --git a/docs/changes/copier-consumer-sync/specs/pull-request-review-ceremony/spec.md b/docs/changes/stay-current-with-skeleton/specs/changes-require-stated-specs/spec.md similarity index 100% rename from docs/changes/copier-consumer-sync/specs/pull-request-review-ceremony/spec.md rename to docs/changes/stay-current-with-skeleton/specs/changes-require-stated-specs/spec.md diff --git a/docs/changes/copier-consumer-sync/specs/skeleton-template-composition/spec.md b/docs/changes/stay-current-with-skeleton/specs/stay-current-with-skeleton/spec.md similarity index 100% rename from docs/changes/copier-consumer-sync/specs/skeleton-template-composition/spec.md rename to docs/changes/stay-current-with-skeleton/specs/stay-current-with-skeleton/spec.md diff --git a/docs/changes/copier-consumer-sync/tasks.md b/docs/changes/stay-current-with-skeleton/tasks.md similarity index 91% rename from docs/changes/copier-consumer-sync/tasks.md rename to docs/changes/stay-current-with-skeleton/tasks.md index dfbe2168..649a03b1 100644 --- a/docs/changes/copier-consumer-sync/tasks.md +++ b/docs/changes/stay-current-with-skeleton/tasks.md @@ -9,5 +9,5 @@ ## 2. Verification - [x] 2.1 `vale`, `rumdl`, `typos`, `lychee --offline`, `actionlint`, and `zizmor` pass under the merged configs -- [x] 2.2 `rune spec validate copier-consumer-sync` passes +- [x] 2.2 `rune spec validate stay-current-with-skeleton` passes - [x] 2.3 First Quality run on main is green and the first weekly parity run reports no cli drift diff --git a/docs/changes/herdr-theme-system/design.md b/docs/changes/terminal-interface-theme-manager/design.md similarity index 100% rename from docs/changes/herdr-theme-system/design.md rename to docs/changes/terminal-interface-theme-manager/design.md diff --git a/docs/changes/herdr-theme-system/proposal.md b/docs/changes/terminal-interface-theme-manager/proposal.md similarity index 96% rename from docs/changes/herdr-theme-system/proposal.md rename to docs/changes/terminal-interface-theme-manager/proposal.md index 747693d1..737fb89d 100644 --- a/docs/changes/herdr-theme-system/proposal.md +++ b/docs/changes/terminal-interface-theme-manager/proposal.md @@ -22,7 +22,7 @@ that follows the host terminal, and single-token overrides. Governing decision: ## Capabilities -- dashboard-theme-system (new) +- one-palette-for-all-output (new) ## Impact diff --git a/docs/changes/herdr-theme-system/specs/dashboard-theme-system/spec.md b/docs/changes/terminal-interface-theme-manager/specs/one-palette-for-all-output/spec.md similarity index 100% rename from docs/changes/herdr-theme-system/specs/dashboard-theme-system/spec.md rename to docs/changes/terminal-interface-theme-manager/specs/one-palette-for-all-output/spec.md diff --git a/docs/changes/herdr-theme-system/tasks.md b/docs/changes/terminal-interface-theme-manager/tasks.md similarity index 100% rename from docs/changes/herdr-theme-system/tasks.md rename to docs/changes/terminal-interface-theme-manager/tasks.md diff --git a/docs/changes/trusted-key-anchor-adoption/specs/trusted-key-anchor/spec.md b/docs/changes/trusted-key-anchor-adoption/specs/trusted-key-anchor/spec.md deleted file mode 100644 index bc7c7c49..00000000 --- a/docs/changes/trusted-key-anchor-adoption/specs/trusted-key-anchor/spec.md +++ /dev/null @@ -1,20 +0,0 @@ -## ADDED Requirements - -### Requirement: rune sign reads the signer format - -`rune sign` MUST read the pins of a `KEYS` file written as `signer <fingerprint> <address>...` lines, upper-cased, and MUST refuse a line with another keyword, a fingerprint that is not 40 hexadecimal characters, or no address, so a typo never shrinks the trusted set to nothing and never widens it. A `KEYS` that is still an armored key block MUST be read through gpg's listing, so a consumer before the format change keeps working. - -#### Scenario: Signer lines - -- **WHEN** `KEYS` on the protected branch holds `signer 29DD2145CE7A818929459B2649F08103D3DA399E git@martinzeman.net` -- **THEN** `rune sign open` accepts a seal whose VALIDSIG fingerprint is `29DD2145…` and refuses any other - -#### Scenario: Malformed line - -- **WHEN** a line reads `trusted 29DD… a@b` -- **THEN** `rune sign` exits nonzero naming the line and signs nothing - -#### Scenario: Armored block - -- **WHEN** `KEYS` begins with `-----BEGIN PGP PUBLIC KEY BLOCK-----` -- **THEN** the pins are every primary and subkey fingerprint in the block, as before diff --git a/docs/changes/unreviewed-adoptions-never-deploy/design.md b/docs/changes/unreviewed-adoptions-never-deploy/design.md new file mode 100644 index 00000000..01a4ddea --- /dev/null +++ b/docs/changes/unreviewed-adoptions-never-deploy/design.md @@ -0,0 +1,19 @@ +# Adopt review hardening design + +## Commands + +The delta names roles: the assembler and the deployer refuse unreviewed content, the +importer records and flags, the validator verifies. The importer is a role beyond +the readback's five (validator, assembler, generator, deployer, fixer), and the deck ontology +defines it. This table carries the command, flag, field, +and diagnostic names that implement each requirement, as rune 0.6.0 prints them in `--help` and +writes them in `src/cli/adopt/review.rs`. Exit statuses come from +[Exit Codes](../../Exit%20Codes.md): 1 is a refusal or a finding, 2 is a usage error. + +| Requirement | Command and flags | Hook | Diagnostic ids and files | Exit status | +|---|---|---|---|---| +| Unreviewed adoptions never deploy | `rune install` skips a pending artifact and points to `rune adopt status`. `rune install --strict` fails instead. `rune release` and `rune copy` refuse. The sidecar value is `review: reviewed`. The skill entrypoint is `SKILL.md`. | none | each skipped artifact, named | 0 on a skip with a warning, 1 under `--strict` or on a refusal | +| Verdicts carry decision timestamps | `rune adopt verdict` writes the field `decidedOn` on the block entry (the Rust field is `decided_on`, serialized in camel case). | none | none | none | +| Suspect blocks are flagged at segmentation | The flag ids are `instruction-override`, `tool-invocation`, `high-entropy`, `external-url`, and `unicode-hidden`, under the lint version `lint/v1`. A dynamic-injection line (`!` and then a command in backticks) raises `tool-invocation`. `rune adopt next --json` carries `flags`. | none | the flag ids | none | +| The validator verifies sessions and reviewed sidecars | `rune adopt doctor`. The legacy ledgers are `review.yaml` and `*.review.yaml`. The fixer is `rune repair`. | none | sidecar-to-file digest disagreement (error), legacy ledger (warning) | 1 on an integrity error, 0 on warnings alone | +| Verdict transport is recorded | The field is `transport` with the values `verdict-cli`, `finalize`, and `review-tty`. The interactive mode `rune adopt review` is its own change and is not built in 0.6.0. | none | none | none | diff --git a/docs/changes/adopt-review-hardening/proposal.md b/docs/changes/unreviewed-adoptions-never-deploy/proposal.md similarity index 96% rename from docs/changes/adopt-review-hardening/proposal.md rename to docs/changes/unreviewed-adoptions-never-deploy/proposal.md index 07ca63d4..2fec3c56 100644 --- a/docs/changes/adopt-review-hardening/proposal.md +++ b/docs/changes/unreviewed-adoptions-never-deploy/proposal.md @@ -20,7 +20,7 @@ The review state machine (CLI-0023) enforces that verdicts exist before finalize ## Capabilities -- adopt-review-hardening (new, hardens the adopt-block-review state machine) +- unreviewed-adoptions-never-deploy (new, hardens the review-every-adopted-block state machine) ## Impact diff --git a/docs/changes/unreviewed-adoptions-never-deploy/specs/unreviewed-adoptions-never-deploy/spec.md b/docs/changes/unreviewed-adoptions-never-deploy/specs/unreviewed-adoptions-never-deploy/spec.md new file mode 100644 index 00000000..48b4777f --- /dev/null +++ b/docs/changes/unreviewed-adoptions-never-deploy/specs/unreviewed-adoptions-never-deploy/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: Unreviewed adoptions never deploy + +The assembler MUST exclude any source artifact whose adoption sidecar does not record a completed review. A pending, missing, or unparsable state fails closed, and each exclusion is reported by name. A skill's entrypoint review state MUST govern every file in its tree. First-party content (no adoption sidecar) deploys normally, and the install MUST otherwise complete. The assembler MUST refuse to package a release, and the deployer MUST refuse to copy, while any adoption review is open. + +#### Scenario: Pending artifact is skipped + +- **WHEN** the deployer installs a module that holds an artifact mid-review +- **THEN** the artifact is absent from the build, the output names it and points to the review status, and the other artifacts deploy normally + +#### Scenario: Finalized artifact deploys + +- **WHEN** the same artifact's review is finalized and the deployer installs again +- **THEN** it deploys + +#### Scenario: Strict mode fails closed + +- **WHEN** the deployer installs in strict mode with any pending-review artifact in the source +- **THEN** the install fails, names the pending artifacts, and deploys nothing + +### Requirement: Verdicts carry decision timestamps + +When the importer records a verdict, it MUST stamp the block entry with the decision time in UTC (RFC 3339) at write time. The temporary session MUST preserve that timeline until finalize. + +#### Scenario: Timeline survives in the pending session + +- **WHEN** finalize seals a session +- **THEN** every block entry carries the time of its verdict + +### Requirement: Suspect blocks are flagged at segmentation + +The importer MUST flag blocks that match injection heuristics. The heuristics are instruction-override phrasing, tool-invocation shapes, and dynamic-injection lines (lines a harness runs at skill load). They also cover base64 or high-entropy runs, hidden unicode controls, and addresses outside the upstream host in executable contexts. Flags MUST reach the next-block listing and the record. A flag MUST never block finalize, but a keep verdict on a flagged block MUST require a reason note. + +#### Scenario: Override phrasing is flagged + +- **WHEN** a block contains "ignore previous instructions" +- **THEN** its entry carries a named instruction-override flag, visible in the next-block listing and in the pending session + +### Requirement: The validator verifies sessions and reviewed sidecars + +The validator MUST report pending temporary sessions. It MUST verify each reviewed adoption sidecar's subject digest against its file. It MUST diagnose a legacy committed review ledger with a message that says to inspect it and then remove or archive it. It MUST NOT require a committed review ledger for a reviewed artifact. It MUST fail on an integrity error and pass when only warnings are found. + +#### Scenario: Reviewed artifact drifts + +- **WHEN** a reviewed artifact's file is edited after finalize without a reseal +- **THEN** the validator reports the disagreement between the sidecar digest and the file as an error that names the file + +#### Scenario: Reviewed sidecar needs no ledger + +- **WHEN** an adoption sidecar is reviewed and its digest matches the file while no review ledger exists +- **THEN** the validator reports no integrity error + +#### Scenario: Legacy ledger is found + +- **WHEN** the validator finds a legacy committed review ledger +- **THEN** it reports a warning that says how to migrate or remove the ledger, and leaves the file untouched + +### Requirement: Verdict transport is recorded + +Each block entry MUST record the transport its verdict arrived through. The transports are the command line for a typed verdict, the finalize step for the added-file entries it generates, and the interactive terminal mode. The interactive mode, specified here and built as its own change, MUST require a controlling terminal and MUST refuse piped input. + +#### Scenario: Transport is in the record + +- **WHEN** the maintainer records a verdict on the command line +- **THEN** its entry names the command line as the transport diff --git a/docs/changes/adopt-review-hardening/tasks.md b/docs/changes/unreviewed-adoptions-never-deploy/tasks.md similarity index 97% rename from docs/changes/adopt-review-hardening/tasks.md rename to docs/changes/unreviewed-adoptions-never-deploy/tasks.md index f0603d98..790f13f0 100644 --- a/docs/changes/adopt-review-hardening/tasks.md +++ b/docs/changes/unreviewed-adoptions-never-deploy/tasks.md @@ -14,6 +14,6 @@ ## 3. Follow-up -- [ ] 3.1 `rune adopt review` interactive TTY mode (own change, specced in adopt-review-hardening delta) +- [ ] 3.1 `rune adopt review` interactive TTY mode (own change, specced in unreviewed-adoptions-never-deploy delta) - [ ] 3.2 Doctor commit-layer checks: verify the sealed record against committed blobs and the introducing commit's signature (coordinated file+sidecar+record edits are invisible to path-level doctor) - [ ] 3.3 Lint v2: markdown link-destination mismatch, homoglyph hosts, cross-block payloads diff --git a/docs/changes/prose-length-caps/adr.md b/docs/changes/word-limits-on-specs/adr.md similarity index 98% rename from docs/changes/prose-length-caps/adr.md rename to docs/changes/word-limits-on-specs/adr.md index fa5dced0..7848461d 100644 --- a/docs/changes/prose-length-caps/adr.md +++ b/docs/changes/word-limits-on-specs/adr.md @@ -20,7 +20,7 @@ accountable: ["@N4M3Z"] consulted: ["claude-fable-5-1"] informed: [] upstream: [] -change: prose-length-caps +change: word-limits-on-specs --- # Prose has a cap the checker enforces diff --git a/docs/changes/word-limits-on-specs/design.md b/docs/changes/word-limits-on-specs/design.md new file mode 100644 index 00000000..f7479b5a --- /dev/null +++ b/docs/changes/word-limits-on-specs/design.md @@ -0,0 +1,17 @@ +# Design for Prose length caps + +## Context + +The [proposal](proposal.md) states the scope and the [draft decision record](adr.md) the reasoning. The +[delta specification](specs/word-limits-on-specs/spec.md) states the caps in role terms: the validator caps requirement +statements, steps, and names, and the documentation validator caps the changelog. This file maps each requirement to +the command, hook, config key, and diagnostic ids that implement it. + +## Commands + +| Requirement | Command and flags | Hook | Diagnostic ids and messages | Exit status | +|---|---|---|---|---| +| Requirement statements and scenario steps have a word cap | `rune spec validate` and `rune spec doctor`. The limits are `MAX_REQUIREMENT_WORDS = 100` and `MAX_STEP_WORDS = 30` in `rune-docs/src/spec/lint.rs`. A requirement statement starts after `### Requirement:`. | `rune-spec-doctor` | `delta-requirement-too-long`, `spec-requirement-too-long`, `delta-step-too-long`, `spec-step-too-long`. The message reads `requirement statement has <n> words; the limit is 100, split it into one requirement per MUST cluster`. | nonzero on an error | +| The changelog has one shape | `rune docs check` lints `CHANGELOG.md` at the repository root. | `rune-docs-check`, whose `files:` pattern includes `CHANGELOG.md` after the skeleton change | `title`, `heading-depth`, `release-heading`, `release-order`, `release-prose`, `group-outside-release`, `group-name`, `group-repeated`, `group-order`, `entry-outside-group`, `entry-wrapped` | nonzero on an error, and with no changelog the link check's status | +| The changelog keeps one line per change | `rune docs check`. The limit is `MAX_ENTRY_CHARS = 200` in `rune-docs/src/changelog.rs`. | `rune-docs-check` | `entry-length`, `entry-verb`, `entry-prefix`, `entry-dash` | nonzero on an error | +| Names have a word floor | `rune spec validate` and `rune spec doctor`. The config key is `spec.min_name_words` in the repository config, default 3, and `0` turns the rule off. | `rune-spec-doctor` | `delta-capability-name-short`, `spec-capability-name-short`, `change-name-short` | nonzero on an error | diff --git a/docs/changes/prose-length-caps/proposal.md b/docs/changes/word-limits-on-specs/proposal.md similarity index 75% rename from docs/changes/prose-length-caps/proposal.md rename to docs/changes/word-limits-on-specs/proposal.md index 94cc47cf..e37dbc82 100644 --- a/docs/changes/prose-length-caps/proposal.md +++ b/docs/changes/word-limits-on-specs/proposal.md @@ -1,5 +1,5 @@ --- -adr: docs/changes/prose-length-caps/adr.md +adr: docs/changes/word-limits-on-specs/adr.md status: proposed decisions: ["Prose has a cap the checker enforces"] --- @@ -13,7 +13,7 @@ Twelve requirement statements in this repository ran past 100 words, the longest ## What Changes - `rune spec validate` and `rune spec doctor` report an error for a requirement statement over 100 words and a scenario step over 30 words, in canonical specs and deltas. -- `rune spec validate` reports an error for a change id or capability name under `spec.min_name_words` hyphen-separated words (default 3, `0` off). The deck passes at 3. This repository renames 21 change ids and 35 capabilities to pass, and `docs/proofs/signing-queue/` becomes `docs/proofs/owner-signing-queue/`. +- `rune spec validate` reports an error for a change id or capability name under `spec.min_name_words` hyphen-separated words (default 3, `0` off). The deck passes at 3. This repository renames 21 change ids and 35 capabilities to pass, and `docs/proofs/signing-queue/` becomes `docs/proofs/sign-commits-in-batches/`. - `rune docs check` lints `CHANGELOG.md`: Keep a Changelog 1.1.0 structure (title, `[Unreleased]` first, `[X.Y.Z] - YYYY-MM-DD` newest first, the six groups in order) and Common Changelog line discipline (one line per change, at most 200 characters, verb first, no `type:` prefix, no em-dash). - The thirteen requirement statements and the changelog in this repository are rewritten to pass. @@ -21,10 +21,10 @@ Twelve requirement statements in this repository ran past 100 words, the longest ### New Capabilities -- `prose-length-caps`: the spec lint caps requirement statements and steps by word count and floors change and capability names by word count, and the docs check caps changelog entries by line and character count. +- `word-limits-on-specs`: the spec lint caps requirement statements and steps by word count and floors change and capability names by word count, and the docs check caps changelog entries by line and character count. ## Impact - `rune-docs/src/spec/lint.rs`, `rune-docs/src/changelog.rs`, `src/cli/docs/mod.rs`, their tests. -- `docs/specs/model-commit-attribution`, four change deltas, and `CHANGELOG.md` in this repository. The deck has one canonical spec, three deltas, and its changelog to fix under the same rule once its pinned rune moves. +- `docs/specs/model-identity-on-commits`, four change deltas, and `CHANGELOG.md` in this repository. The deck has one canonical spec, three deltas, and its changelog to fix under the same rule once its pinned rune moves. - The `rune-docs-check` hook's `files:` pattern in each repository should include `CHANGELOG.md`. Today it fires on `docs/` alone, so a changelog-only commit is checked in CI's all-files run and not locally. That is a skeleton change. diff --git a/docs/changes/prose-length-caps/pull-request.md b/docs/changes/word-limits-on-specs/pull-request.md similarity index 80% rename from docs/changes/prose-length-caps/pull-request.md rename to docs/changes/word-limits-on-specs/pull-request.md index fe91a2ac..d3011494 100644 --- a/docs/changes/prose-length-caps/pull-request.md +++ b/docs/changes/word-limits-on-specs/pull-request.md @@ -10,10 +10,10 @@ Twelve requirement statements ran past 100 words, the changelog carried 55 entri - Add `lint_names` to `rune-docs/src/spec/lint.rs`: a change id or capability name under `spec.min_name_words` hyphen-separated words is an error, default 3, `0` off, read from the merged config through `set_name_rule_lookup`. - Add `rune-docs/src/changelog.rs` and wire it into `rune docs check` in `src/cli/docs/mod.rs`: Keep a Changelog structure, one line per change of at most 200 characters, verb first, no `type:` prefix, no em-dash. - Add `spec.min_name_words` to `SpecConfig` in `src/cli/config/source.rs` and regenerate `docs/config-reference.json`. -- Split 13 requirement statements in `docs/specs/model-commit-attribution/spec.md` and the `codex-skill-identity`, `sealed-review-ceremony`, and `owner-signing-queue` deltas, every MUST and scenario kept. +- Split 13 requirement statements in `docs/specs/model-identity-on-commits/spec.md` and the `codex-assembly-fully-compatible`, `owner-key-signs-merges`, and `sign-commits-in-batches` deltas, every MUST and scenario kept. - Rewrite `CHANGELOG.md` from 175 entries to 343 one-line changes, `**Breaking:**` on the `--repair` removal. -- Rename 21 change directories and 35 capability directories to three words, and `docs/proofs/signing-queue/` to `docs/proofs/owner-signing-queue/`, with every reference updated. -- Add `docs/proofs/prose-length-caps/` (seven scenes) and `docs/changes/prose-length-caps/` with proposal, delta spec, tasks, and `adr.md`. +- Rename 21 change directories and 35 capability directories to three words, and `docs/proofs/signing-queue/` to `docs/proofs/sign-commits-in-batches/`, with every reference updated. +- Add `docs/proofs/word-limits-on-specs/` (seven scenes) and `docs/changes/word-limits-on-specs/` with proposal, delta spec, tasks, and `adr.md`. ## Testing @@ -21,7 +21,7 @@ Twelve requirement statements ran past 100 words, the changelog carried 55 entri - [x] `cargo clippy --all-features --all-targets -- -D warnings` and `cargo fmt --check` clean. - [x] `prek run --all-files` and `prek run --stage pre-push --all-files` in an isolated clone of `c520f6f7`, both exit 0. - [x] `rune spec validate` and `rune spec doctor` from this build print no error for this repository and for the deck's `adopt-prose-length-caps` branch. -- [x] `docs/proofs/prose-length-caps/record.sh` runs its seven scenes to exit 0 against the built binary. +- [x] `docs/proofs/word-limits-on-specs/record.sh` runs its seven scenes to exit 0 against the built binary. ## Release Notes diff --git a/docs/changes/word-limits-on-specs/specs/word-limits-on-specs/spec.md b/docs/changes/word-limits-on-specs/specs/word-limits-on-specs/spec.md new file mode 100644 index 00000000..da6d838d --- /dev/null +++ b/docs/changes/word-limits-on-specs/specs/word-limits-on-specs/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Requirement statements and scenario steps have a word cap + +The validator MUST report an error for a requirement statement over 100 words and for a scenario step over 30 words. A requirement statement is the prose between the requirement heading and the next heading. A step is a `WHEN`, `THEN`, `AND`, or `GIVEN` line. Words are whitespace runs after inline code is blanked and fenced code is skipped. The rule applies to canonical specifications and to change deltas alike. The finding MUST name the file, the heading line, the count, and the limit. + +#### Scenario: Requirement runs to a wall of text + +- **WHEN** a requirement statement in a delta has 191 words +- **THEN** the validator reports a named diagnostic at the heading line and exits nonzero + +#### Scenario: Step lists a command + +- **WHEN** a `THEN` step holds a long command in inline code and ten words of prose +- **THEN** validation passes, because code spans do not count + +### Requirement: The changelog has one shape + +The documentation validator MUST lint the changelog when the file exists, and an absent changelog MUST NOT be an error. The first heading MUST be `# Changelog`. Release headings MUST be `## [Unreleased]` first, then `## [X.Y.Z] - YYYY-MM-DD` newest first. Groups MUST come from `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`, in that order, each at most once per release. A group MUST hold only list lines that start with a hyphen. + +#### Scenario: Repository keeps no changelog + +- **WHEN** the repository root has no changelog +- **THEN** the documentation validator reports the link check alone and exits by that result + +### Requirement: The changelog keeps one line per change + +A change line MUST be at most 200 characters. It MUST start with a capitalized verb or `**Breaking:**`. It MUST NOT carry a `type:` prefix or an em-dash. A release MUST hold at most one notice paragraph and no other prose. + +#### Scenario: Entry is a paragraph + +- **WHEN** a change line has 1,400 characters +- **THEN** the documentation validator reports a named diagnostic with the line number and exits nonzero + +#### Scenario: Entry starts with the command name + +- **WHEN** a change line starts with a code span instead of a verb +- **THEN** the documentation validator reports a named diagnostic + +### Requirement: Names have a word floor + +The validator MUST report an error for a change id or a capability name with fewer hyphen-separated words than the repository's configured minimum, default 3. A nested capability is judged by its last segment. A minimum of 0 MUST turn the rule off. + +#### Scenario: Two-word capability + +- **WHEN** a change adds a capability directory named `cast-player` with the default minimum +- **THEN** the validator reports a named diagnostic and exits nonzero + +#### Scenario: Repository turns the rule off + +- **WHEN** the repository config sets the minimum to 0 +- **THEN** the validator reports no name error diff --git a/docs/changes/prose-length-caps/tasks.md b/docs/changes/word-limits-on-specs/tasks.md similarity index 84% rename from docs/changes/prose-length-caps/tasks.md rename to docs/changes/word-limits-on-specs/tasks.md index 17550ae0..adf1e105 100644 --- a/docs/changes/prose-length-caps/tasks.md +++ b/docs/changes/word-limits-on-specs/tasks.md @@ -9,14 +9,14 @@ ## 2. Rewrite -- [x] 2.1 Split the thirteen over-long requirement statements in this repository (model-commit-attribution, codex-skill-identity, sealed-review-ceremony, owner-signing-queue) +- [x] 2.1 Split the thirteen over-long requirement statements in this repository (model-identity-on-commits, codex-assembly-fully-compatible, owner-key-signs-merges, sign-commits-in-batches) - [x] 2.2 Rewrite `CHANGELOG.md` to the shape, every change kept as one line ## 3. Verification - [x] 3.1 Unit tests for both rules - [x] 3.2 `rune spec validate`, `rune docs check`, and both prek stages pass on this repository -- [x] 3.3 One recorded proof per scenario under `docs/proofs/prose-length-caps/` +- [x] 3.3 One recorded proof per scenario under `docs/proofs/word-limits-on-specs/` ## 4. Record diff --git a/docs/config-reference.json b/docs/config-reference.json index 5ddaeeb0..42878e5d 100644 --- a/docs/config-reference.json +++ b/docs/config-reference.json @@ -350,14 +350,14 @@ }, { "default": { - "min_name_words": null, + "min_name_words": 4, "root": null }, "key": "spec", "type": "object" }, { - "default": null, + "default": 4, "key": "spec.min_name_words", "type": "integer | null" }, diff --git a/docs/decisions/ASSEMBLY-0001 Content Assembly Pipeline.md b/docs/decisions/ASSEMBLY-0001 Content Assembly Pipeline.md index 209de4a2..10e6f85e 100644 --- a/docs/decisions/ASSEMBLY-0001 Content Assembly Pipeline.md +++ b/docs/decisions/ASSEMBLY-0001 Content Assembly Pipeline.md @@ -60,7 +60,7 @@ Without that replacement, one provider/model or provider variant combines with t User companions replace the same relative paths. Provider/model companion folders do not merge into the emitted bundle. Model qualifiers use a nested provider/model path, such as `claude/claude-opus-4-6/`. See the [current skill -contract](../changes/codex-skill-identity/design.md#store-variant-text-in-existing-qualifier-folders). +contract](../changes/codex-assembly-fully-compatible/design.md#store-variant-text-in-existing-qualifier-folders). This note records implementation behavior. It does not change this ADR's accepted status or approve the new draft ADR. Steps: diff --git a/docs/decisions/ASSEMBLY-0004 Assembly and Deployment Pipeline.md b/docs/decisions/ASSEMBLY-0004 Assembly and Deployment Pipeline.md index ed58566e..8d72c51a 100644 --- a/docs/decisions/ASSEMBLY-0004 Assembly and Deployment Pipeline.md +++ b/docs/decisions/ASSEMBLY-0004 Assembly and Deployment Pipeline.md @@ -61,7 +61,7 @@ For example, the skill sidecar is `build/claude/skills/MySkill/.provenance/SKILL Adjacent authored YAML remains content and is copied with the selected bundle. Deployment also writes provenance into target `.provenance` directories and records its path in `.manifest`. The complete selected-source snapshot uses `.provenance/source-snapshot.json` in build and target metadata. -See the [current metadata contract](../changes/codex-skill-identity/design.md#separate-source-and-build-metadata). +See the [current metadata contract](../changes/codex-assembly-fully-compatible/design.md#separate-source-and-build-metadata). This note records implementation behavior. It does not change this ADR's accepted status or approve the new draft ADR. ```text diff --git a/docs/decisions/CLI-0023 Adoption Review State Machine.md b/docs/decisions/CLI-0023 Adoption Review State Machine.md index 770670a7..157852a4 100644 --- a/docs/decisions/CLI-0023 Adoption Review State Machine.md +++ b/docs/decisions/CLI-0023 Adoption Review State Machine.md @@ -66,7 +66,7 @@ Per-block prompting stays one question per block by design. The harness batches ## More Information -No standardized in-toto predicate for human review exists. A custom predicateType is the sanctioned path ([predicate directory][ITPRED]), and the open proposal for a human-review predicate ([in-toto/attestation#77][IT77]) carries the same structure this record uses (reviewer identity, timestamps, per-subject results), leaving room to converge if it lands. TypeURIs are namespaced, unregistered, and SHOULD resolve to a human-readable description ([field types][ITFT]). Publishing the predicate document at the recorded URI is tracked in the adopt-block-review change. The record follows the monotonic principle from the [new-predicate guidelines][ITNPG]: every block carries an explicit verdict, so deleting an entry can never turn a refusal into an approval. +No standardized in-toto predicate for human review exists. A custom predicateType is the sanctioned path ([predicate directory][ITPRED]), and the open proposal for a human-review predicate ([in-toto/attestation#77][IT77]) carries the same structure this record uses (reviewer identity, timestamps, per-subject results), leaving room to converge if it lands. TypeURIs are namespaced, unregistered, and SHOULD resolve to a human-readable description ([field types][ITFT]). Publishing the predicate document at the recorded URI is tracked in the review-every-adopted-block change. The record follows the monotonic principle from the [new-predicate guidelines][ITNPG]: every block carries an explicit verdict, so deleting an entry can never turn a refusal into an approval. [ITPRED]: https://github.com/in-toto/attestation/tree/main/spec/predicates [IT77]: https://github.com/in-toto/attestation/issues/77 diff --git a/docs/artifact-implementation-loop.md b/docs/evidence-cannot-go-stale.md similarity index 100% rename from docs/artifact-implementation-loop.md rename to docs/evidence-cannot-go-stale.md diff --git a/docs/proofs/glossary-renders-from-ontology/README.md b/docs/proofs/glossary-renders-from-ontology/README.md new file mode 100644 index 00000000..e0ff237f --- /dev/null +++ b/docs/proofs/glossary-renders-from-ontology/README.md @@ -0,0 +1,82 @@ +--- +type: proof +change: glossary-renders-from-ontology +head: d935b16de3e7e20588382adbe85efeebcbe84934 +recorded: 2026-10-17 +transcript: db58d4f185878ac5426b26b8c6bcd3ee044f6c7d896aeaa060ec3bebff6a99a0 +scenes: +- scenario: glossary-renders-from-ontology#the-glossary-is-rendered-from-the-ontology/two-writes-read-one-ontology + kind: check +- scenario: glossary-renders-from-ontology#the-check-reports-drift/page-edited-by-hand + kind: check +- scenario: glossary-renders-from-ontology#the-check-reports-drift/glossary-directory-absent + kind: check +--- + +# Proof: glossary-renders-from-ontology + +One scene per scenario, in closure order. Fill each `console` fence with `$ command`, an optional `? <status>`, and the expected output, where `[..]` and `...` elide. Then run `rune proof run glossary-renders-from-ontology`. + +## glossary-renders-from-ontology#the-glossary-is-rendered-from-the-ontology/two-writes-read-one-ontology + +```console +$ sh -c 'mkdir -p repo/ontology && cp "$RUNE_PROOF_ROOT/tests/fixtures/spec-glossary/rune.ttl" repo/ontology/rune.ttl' +$ rune spec glossary --write --source repo +wrote docs/specs/glossary: 4 page(s) and the index, 5 file(s) changed +$ sh -c 'cp -R repo/docs/specs/glossary first' +$ rune spec glossary --write --source repo +wrote docs/specs/glossary: 4 page(s) and the index, 0 file(s) changed +$ diff -r first repo/docs/specs/glossary +$ ls repo/docs/specs/glossary +change-key.md +change.md +falsifiability.md +index.md +stage.md +$ cat repo/docs/specs/glossary/index.md +# Glossary + +Generated from `ontology/rune.ttl`. Edit the ontology, not these pages. + +- [change](change.md): One spec-driven unit of work. +- [change](change-key.md): The change a record belongs to. +- [falsifiability](falsifiability.md): A requirement states a check that can fail. +- [stage](stage.md):[..] +$ cat repo/docs/specs/glossary/change-key.md +--- +title: change +iri: https://runedeck.ai/ns#change +kind: property +status: defined +--- + +# change + +The change a record belongs to. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. +``` + +## glossary-renders-from-ontology#the-check-reports-drift/page-edited-by-hand + +```console +$ sh -c 'mkdir -p repo/ontology && cp "$RUNE_PROOF_ROOT/tests/fixtures/spec-glossary/rune.ttl" repo/ontology/rune.ttl' +$ rune spec glossary --write --source repo +wrote docs/specs/glossary: 4 page(s) and the index, 5 file(s) changed +$ rune spec glossary --check --source repo +docs/specs/glossary is current +$ sh -c 'sed "s/One spec-driven unit of work./Edited by hand./" repo/docs/specs/glossary/change.md > edited.md && mv edited.md repo/docs/specs/glossary/change.md' +$ rune spec glossary --check --source repo +? 1 +docs/specs/glossary is stale: run `rune spec glossary --write` + changed change (change.md) +``` + +## glossary-renders-from-ontology#the-check-reports-drift/glossary-directory-absent + +```console +$ sh -c 'mkdir -p repo/ontology && cp "$RUNE_PROOF_ROOT/tests/fixtures/spec-glossary/rune.ttl" repo/ontology/rune.ttl' +$ rune spec glossary --check --source repo +? 1 +docs/specs/glossary is missing: run `rune spec glossary --write` +``` diff --git a/docs/proofs/render-spec-glossary/proof.cast b/docs/proofs/glossary-renders-from-ontology/proof.cast similarity index 100% rename from docs/proofs/render-spec-glossary/proof.cast rename to docs/proofs/glossary-renders-from-ontology/proof.cast diff --git a/docs/proofs/render-spec-glossary/proof.txt b/docs/proofs/glossary-renders-from-ontology/proof.txt similarity index 100% rename from docs/proofs/render-spec-glossary/proof.txt rename to docs/proofs/glossary-renders-from-ontology/proof.txt diff --git a/docs/proofs/draft-runes-before-promotion/README.md b/docs/proofs/instructions-start-as-drafts/README.md similarity index 78% rename from docs/proofs/draft-runes-before-promotion/README.md rename to docs/proofs/instructions-start-as-drafts/README.md index 71abdf1e..271114fb 100644 --- a/docs/proofs/draft-runes-before-promotion/README.md +++ b/docs/proofs/instructions-start-as-drafts/README.md @@ -1,6 +1,6 @@ -# Behavior proof: draft-runes-before-promotion +# Behavior proof: instructions-start-as-drafts -`record.sh` holds one scene per scenario of the `draft-runes-before-promotion` delta specification, in the +`record.sh` holds one scene per scenario of the `instructions-start-as-drafts` delta specification, in the grammar of `docs/proofs/driver.sh`. `proof.cast` is its recording against the built binary on 2026-09-20, exit 0. `proof.txt` is the transcript `asciinema convert -f txt` wrote from it, and `proof.gif` the render. @@ -13,7 +13,7 @@ with `rune install`. Nothing outside the scratch directory changes. Re-record after a change to the scenes or the command: ```sh -RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/draft-runes-before-promotion/record.sh" \ +RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/instructions-start-as-drafts/record.sh" \ --headless --window-size 100x30 --idle-time-limit 2 --overwrite --return proof.cast agg --theme github-dark --font-size 12 --fps-cap 4 --last-frame-duration 4 proof.cast proof.gif asciinema convert -f txt --overwrite proof.cast proof.txt @@ -22,5 +22,5 @@ asciinema convert -f txt --overwrite proof.cast proof.txt Where asciinema cannot open a pseudo terminal, `docs/proofs/cast.py` writes the same cast from the script's output stream: ```sh -RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/draft-runes-before-promotion/record.sh proof.cast +RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/instructions-start-as-drafts/record.sh proof.cast ``` diff --git a/docs/proofs/draft-runes-before-promotion/proof.cast b/docs/proofs/instructions-start-as-drafts/proof.cast similarity index 100% rename from docs/proofs/draft-runes-before-promotion/proof.cast rename to docs/proofs/instructions-start-as-drafts/proof.cast diff --git a/docs/proofs/draft-runes-before-promotion/proof.gif b/docs/proofs/instructions-start-as-drafts/proof.gif similarity index 100% rename from docs/proofs/draft-runes-before-promotion/proof.gif rename to docs/proofs/instructions-start-as-drafts/proof.gif diff --git a/docs/proofs/draft-runes-before-promotion/proof.txt b/docs/proofs/instructions-start-as-drafts/proof.txt similarity index 100% rename from docs/proofs/draft-runes-before-promotion/proof.txt rename to docs/proofs/instructions-start-as-drafts/proof.txt diff --git a/docs/proofs/draft-runes-before-promotion/record.sh b/docs/proofs/instructions-start-as-drafts/record.sh similarity index 100% rename from docs/proofs/draft-runes-before-promotion/record.sh rename to docs/proofs/instructions-start-as-drafts/record.sh diff --git a/docs/proofs/launch-model-check/README.md b/docs/proofs/models-checked-before-launch/README.md similarity index 87% rename from docs/proofs/launch-model-check/README.md rename to docs/proofs/models-checked-before-launch/README.md index a3a8c51e..72df739a 100644 --- a/docs/proofs/launch-model-check/README.md +++ b/docs/proofs/models-checked-before-launch/README.md @@ -1,6 +1,6 @@ -# Behavior proof: launch-model-check +# Behavior proof: models-checked-before-launch -`record.sh` holds one scene per scenario of the `launch-model-check` delta specifications, in the +`record.sh` holds one scene per scenario of the `models-checked-before-launch` delta specifications, in the grammar of `docs/proofs/driver.sh`. `proof.cast` is its recording against the built binary on 2026-09-20, exit 0. `proof.txt` is the transcript `asciinema convert -f txt` wrote from it, and `proof.gif` the render. @@ -41,7 +41,7 @@ CA that curl and the harness trust. Re-record after a change to the scenes or the command: ```sh -RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/launch-model-check/record.sh" \ +RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/models-checked-before-launch/record.sh" \ --headless --window-size 100x30 --idle-time-limit 2 --overwrite --return proof.cast agg --theme github-dark --font-size 12 --fps-cap 4 --last-frame-duration 4 proof.cast proof.gif asciinema convert -f txt --overwrite proof.cast proof.txt @@ -50,5 +50,5 @@ asciinema convert -f txt --overwrite proof.cast proof.txt Where asciinema cannot open a pseudo terminal, `docs/proofs/cast.py` writes the same cast from the script's output stream: ```sh -RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/launch-model-check/record.sh proof.cast +RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/models-checked-before-launch/record.sh proof.cast ``` diff --git a/docs/proofs/launch-model-check/proof.cast b/docs/proofs/models-checked-before-launch/proof.cast similarity index 100% rename from docs/proofs/launch-model-check/proof.cast rename to docs/proofs/models-checked-before-launch/proof.cast diff --git a/docs/proofs/launch-model-check/proof.gif b/docs/proofs/models-checked-before-launch/proof.gif similarity index 100% rename from docs/proofs/launch-model-check/proof.gif rename to docs/proofs/models-checked-before-launch/proof.gif diff --git a/docs/proofs/launch-model-check/proof.txt b/docs/proofs/models-checked-before-launch/proof.txt similarity index 100% rename from docs/proofs/launch-model-check/proof.txt rename to docs/proofs/models-checked-before-launch/proof.txt diff --git a/docs/proofs/launch-model-check/record.sh b/docs/proofs/models-checked-before-launch/record.sh similarity index 100% rename from docs/proofs/launch-model-check/record.sh rename to docs/proofs/models-checked-before-launch/record.sh diff --git a/docs/proofs/install-deck-ontology/README.md b/docs/proofs/ontology-deploys-with-provenance/README.md similarity index 71% rename from docs/proofs/install-deck-ontology/README.md rename to docs/proofs/ontology-deploys-with-provenance/README.md index 86c21bb1..e7d0be2e 100644 --- a/docs/proofs/install-deck-ontology/README.md +++ b/docs/proofs/ontology-deploys-with-provenance/README.md @@ -1,27 +1,27 @@ --- type: proof -change: install-deck-ontology +change: ontology-deploys-with-provenance head: e60aed9434ed4149e663f682e70a3f70a87a12dc recorded: 2026-10-16 transcript: f92e040bc56084a5b92e913caf49cbc1963873d3d26a1431e44e82982d33fccb scenes: -- scenario: install-deck-ontology#consumer-names-its-ontology-source/key-names-an-absent-source +- scenario: ontology-deploys-with-provenance#consumer-names-its-ontology-source/key-names-an-absent-source kind: check -- scenario: install-deck-ontology#install-deploys-the-ontology-with-provenance/consumer-cites-a-deck-term +- scenario: ontology-deploys-with-provenance#install-deploys-the-ontology-with-provenance/consumer-cites-a-deck-term kind: check -- scenario: install-deck-ontology#consumer-without-the-key-installs-as-before/manifest-carries-no-ontology-key +- scenario: ontology-deploys-with-provenance#consumer-without-the-key-installs-as-before/manifest-carries-no-ontology-key kind: check -- scenario: install-deck-ontology#source-without-an-ontology-file-is-refused/source-carries-no-ontology-file +- scenario: ontology-deploys-with-provenance#source-without-an-ontology-file-is-refused/source-carries-no-ontology-file kind: check -- scenario: install-deck-ontology#deployed-copy-reports-drift/copy-edited-by-hand +- scenario: ontology-deploys-with-provenance#deployed-copy-reports-drift/copy-edited-by-hand kind: check --- -# Proof: install-deck-ontology +# Proof: ontology-deploys-with-provenance -One scene per scenario, in closure order. Each scene copies the fixture deck from `tests/fixtures/install-ontology/deck`, one rule beside an ontology with three terms, next to a consumer whose `.rune` names it. Fill each `console` fence with `$ command`, an optional `? <status>`, and the expected output, where `[..]` and `...` elide. Then run `rune proof run install-deck-ontology`. +One scene per scenario, in closure order. Each scene copies the fixture deck from `tests/fixtures/install-ontology/deck`, one rule beside an ontology with three terms, next to a consumer whose `.rune` names it. Fill each `console` fence with `$ command`, an optional `? <status>`, and the expected output, where `[..]` and `...` elide. Then run `rune proof run ontology-deploys-with-provenance`. -## install-deck-ontology#consumer-names-its-ontology-source/key-names-an-absent-source +## ontology-deploys-with-provenance#consumer-names-its-ontology-source/key-names-an-absent-source ```console $ sh -s @@ -35,7 +35,7 @@ $ sh -c 'test ! -e consumer/ontology && echo "no ontology copy"' no ontology copy ``` -## install-deck-ontology#install-deploys-the-ontology-with-provenance/consumer-cites-a-deck-term +## ontology-deploys-with-provenance#install-deploys-the-ontology-with-provenance/consumer-cites-a-deck-term ```console $ sh -s @@ -44,10 +44,9 @@ $ sh -s < printf 'version: 1\nsources:\n deck:\n local: ../deck\nrunes:\n deck:\n rules: [CiteTerms]\nontology: deck\n' > consumer/.rune < printf -- '---\nstatus: proposed\n---\n\n# Cite the instance\n\n## Why\n\nA reader meets a term.\n\n## What Changes\n\n- Cite it.\n\n## Capabilities\n\n- deployed-copy-records (new)\n\n## Impact\n\n- The specification.\n' > consumer/docs/changes/cite-the-instance/proposal.md < printf '## 1. Work\n\n- [ ] 1.1 Cite\n' > consumer/docs/changes/cite-the-instance/tasks.md -< printf '## ADDED Requirements\n\n### Requirement: Copy carries a record\n\nEvery *instance* [INSTANCE] MUST carry a record.\n\n#### Scenario: Copy lands\n\n- **WHEN** a copy lands\n- **THEN** its record exists\n\n[INSTANCE]: https://runedeck.ai/ns#Instance\n' > consumer/docs/changes/cite-the-instance/specs/deployed-copy-records/spec.md +< printf '## ADDED Requirements\n\n### Requirement: Copy carries a record\n\nEvery *[instance][INSTANCE]* MUST carry a record.\n\n#### Scenario: Copy lands\n\n- **WHEN** a copy lands\n- **THEN** its record exists\n\n[INSTANCE]: ../../../../specs/glossary/instance.md\n' > consumer/docs/changes/cite-the-instance/specs/deployed-copy-records/spec.md $ rune spec validate cite-the-instance --source consumer -? 1 -error[delta-term-undefined]: docs/changes/cite-the-instance/specs/deployed-copy-records/spec.md:5: defined term 'instance' needs a glossary; create docs/specs/glossary.md with `- **instance**: <definition>` +specification validation passed $ rune install --source consumer ✓ claude @@ -68,10 +67,15 @@ $ sh -c 'grep -E "name: ontology/rune.ttl|source: |uri: " consumer/ontology/.pro source: [..]/deck - uri: ontology/rune.ttl $ rune spec validate cite-the-instance --source consumer +? 1 +error[delta-term-page]: docs/changes/cite-the-instance/specs/deployed-copy-records/spec.md:5: glossary page docs/specs/glossary/instance.md is missing; run `rune spec glossary --write` +$ rune spec glossary --write --source consumer +wrote docs/specs/glossary: 3 page(s) and the index, 4 file(s) changed +$ rune spec validate cite-the-instance --source consumer specification validation passed ``` -## install-deck-ontology#consumer-without-the-key-installs-as-before/manifest-carries-no-ontology-key +## ontology-deploys-with-provenance#consumer-without-the-key-installs-as-before/manifest-carries-no-ontology-key ```console $ sh -s @@ -89,7 +93,7 @@ $ sh -c 'test -f consumer/.claude/rules/CiteTerms.md && test ! -e consumer/ontol rule deployed, no ontology copy ``` -## install-deck-ontology#source-without-an-ontology-file-is-refused/source-carries-no-ontology-file +## ontology-deploys-with-provenance#source-without-an-ontology-file-is-refused/source-carries-no-ontology-file ```console $ sh -s @@ -104,7 +108,7 @@ $ sh -c 'test ! -e consumer/.claude && test ! -e consumer/ontology && echo "no p no provider tree, no ontology copy ``` -## install-deck-ontology#deployed-copy-reports-drift/copy-edited-by-hand +## ontology-deploys-with-provenance#deployed-copy-reports-drift/copy-edited-by-hand ```console $ sh -s diff --git a/docs/proofs/install-deck-ontology/proof.cast b/docs/proofs/ontology-deploys-with-provenance/proof.cast similarity index 100% rename from docs/proofs/install-deck-ontology/proof.cast rename to docs/proofs/ontology-deploys-with-provenance/proof.cast diff --git a/docs/proofs/install-deck-ontology/proof.txt b/docs/proofs/ontology-deploys-with-provenance/proof.txt similarity index 100% rename from docs/proofs/install-deck-ontology/proof.txt rename to docs/proofs/ontology-deploys-with-provenance/proof.txt diff --git a/docs/proofs/render-spec-glossary/README.md b/docs/proofs/render-spec-glossary/README.md deleted file mode 100644 index c3c498a6..00000000 --- a/docs/proofs/render-spec-glossary/README.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -type: proof -change: render-spec-glossary -head: d935b16de3e7e20588382adbe85efeebcbe84934 -recorded: 2026-10-17 -transcript: db58d4f185878ac5426b26b8c6bcd3ee044f6c7d896aeaa060ec3bebff6a99a0 -scenes: -- scenario: render-spec-glossary#the-glossary-is-rendered-from-the-ontology/two-writes-read-one-ontology - kind: check -- scenario: render-spec-glossary#the-check-reports-drift/term-line-edited-in-the-file - kind: check -- scenario: render-spec-glossary#the-check-reports-drift/glossary-file-absent - kind: check ---- - -# Proof: render-spec-glossary - -One scene per scenario, in closure order. Fill each `console` fence with `$ command`, an optional `? <status>`, and the expected output, where `[..]` and `...` elide. Then run `rune proof run render-spec-glossary`. - -## render-spec-glossary#the-glossary-is-rendered-from-the-ontology/two-writes-read-one-ontology - -```console -$ sh -c 'mkdir -p repo/ontology && cp "$RUNE_PROOF_ROOT/tests/fixtures/spec-glossary/rune.ttl" repo/ontology/rune.ttl' -$ rune spec glossary --write --source repo -wrote docs/specs/glossary.md -$ sh -c 'cp repo/docs/specs/glossary.md first.md' -$ rune spec glossary --write --source repo -wrote docs/specs/glossary.md -$ cmp first.md repo/docs/specs/glossary.md -$ cat repo/docs/specs/glossary.md -# Glossary - -Generated from `ontology/rune.ttl` by `rune spec glossary --write`. Do not edit this file. Change the ontology. -A specification sets a term in italics at first use and cites it as `*term* [TERM]` with -`[TERM]: https://runedeck.ai/ns#Term` at the end of the file. - -- **change**: One spec-driven unit of work. [CHANGE] -- **change**: The change a record belongs to. [CHANGE-KEY] -- **falsifiability**: A requirement states a check that can fail. [FALSIFIABILITY] -- **stage**: [STAGE] - -[CHANGE]: https://runedeck.ai/ns#Change -[CHANGE-KEY]: https://runedeck.ai/ns#change -[FALSIFIABILITY]: https://runedeck.ai/ns#Falsifiability -[STAGE]: https://runedeck.ai/ns#Stage -``` - -## render-spec-glossary#the-check-reports-drift/term-line-edited-in-the-file - -```console -$ sh -c 'mkdir -p repo/ontology && cp "$RUNE_PROOF_ROOT/tests/fixtures/spec-glossary/rune.ttl" repo/ontology/rune.ttl' -$ rune spec glossary --write --source repo -wrote docs/specs/glossary.md -$ rune spec glossary --check --source repo -docs/specs/glossary.md is current -$ sh -c 'sed "s/One spec-driven unit of work./Edited by hand./" repo/docs/specs/glossary.md > edited.md && mv edited.md repo/docs/specs/glossary.md' -$ rune spec glossary --check --source repo -? 1 -docs/specs/glossary.md is stale: run `rune spec glossary --write` - changed change [CHANGE] -``` - -## render-spec-glossary#the-check-reports-drift/glossary-file-absent - -```console -$ sh -c 'mkdir -p repo/ontology && cp "$RUNE_PROOF_ROOT/tests/fixtures/spec-glossary/rune.ttl" repo/ontology/rune.ttl' -$ rune spec glossary --check --source repo -? 1 -docs/specs/glossary.md is missing: run `rune spec glossary --write` -``` diff --git a/docs/proofs/review-and-proof/README.md b/docs/proofs/review-changes-with-proofs/README.md similarity index 56% rename from docs/proofs/review-and-proof/README.md rename to docs/proofs/review-changes-with-proofs/README.md index 797644ba..19a54342 100644 --- a/docs/proofs/review-and-proof/README.md +++ b/docs/proofs/review-changes-with-proofs/README.md @@ -1,81 +1,81 @@ --- type: proof -change: review-and-proof +change: review-changes-with-proofs head: 2ca3b9b5fb8450780310227eaaa85e339e19423e recorded: 2026-09-25 transcript: 4299681170e406b0928a8fea7a88123b29350097e77f87f8cc1090e44f05636d scenes: -- scenario: proof-scene-runner#proof-frontmatter-is-the-record/proof-directory-carries-another-change-s-name +- scenario: every-scenario-has-proof#proof-frontmatter-is-the-record/proof-directory-carries-another-change-s-name kind: check -- scenario: proof-scene-runner#proof-frontmatter-is-the-record/instruction-scene-without-a-model +- scenario: every-scenario-has-proof#proof-frontmatter-is-the-record/instruction-scene-without-a-model kind: check -- scenario: proof-scene-runner#head-and-transcript-bind-the-record/hand-recorded-proof-has-no-head +- scenario: every-scenario-has-proof#head-and-transcript-bind-the-record/hand-recorded-proof-has-no-head kind: check -- scenario: proof-scene-runner#one-scene-per-scenario-is-written-first/change-with-nine-delta-specs-gets-its-skeleton +- scenario: every-scenario-has-proof#one-scene-per-scenario-is-written-first/change-with-nine-delta-specs-gets-its-skeleton kind: unproven -- scenario: proof-scene-runner#one-scene-per-scenario-is-written-first/readme-already-exists +- scenario: every-scenario-has-proof#one-scene-per-scenario-is-written-first/readme-already-exists kind: check -- scenario: proof-scene-runner#run-executes-every-executable-scene/scene-names-a-command-the-runner-cannot-find +- scenario: every-scenario-has-proof#run-executes-every-executable-scene/scene-names-a-command-the-runner-cannot-find kind: check -- scenario: proof-scene-runner#run-executes-every-executable-scene/expected-output-disagrees +- scenario: every-scenario-has-proof#run-executes-every-executable-scene/expected-output-disagrees kind: check -- scenario: proof-scene-runner#a-step-reads-its-input-from-the-fence/step-feeds-a-script-through-standard-input +- scenario: every-scenario-has-proof#a-step-reads-its-input-from-the-fence/step-feeds-a-script-through-standard-input kind: check -- scenario: proof-scene-runner#a-step-reads-its-input-from-the-fence/input-line-before-any-command +- scenario: every-scenario-has-proof#a-step-reads-its-input-from-the-fence/input-line-before-any-command kind: check -- scenario: proof-scene-runner#every-scene-starts-clean/scenes-do-not-share-a-directory +- scenario: every-scenario-has-proof#every-scene-starts-clean/scenes-do-not-share-a-directory kind: check -- scenario: proof-scene-runner#the-transcript-binds-the-claim/scene-key-edited-after-the-run +- scenario: every-scenario-has-proof#the-transcript-binds-the-claim/scene-key-edited-after-the-run kind: check -- scenario: proof-scene-runner#the-transcript-binds-the-claim/instruction-scene-never-run +- scenario: every-scenario-has-proof#the-transcript-binds-the-claim/instruction-scene-never-run kind: check -- scenario: proof-scene-runner#check-refuses-drift/transcript-drifted-after-the-run +- scenario: every-scenario-has-proof#check-refuses-drift/transcript-drifted-after-the-run kind: check -- scenario: proof-scene-runner#the-run-records-its-own-cast/cast-written-from-the-captured-output +- scenario: every-scenario-has-proof#the-run-records-its-own-cast/cast-written-from-the-captured-output kind: check -- scenario: proof-scene-runner#the-graph-shows-only-proven-scenes/four-deck-proofs-carry-frontmatter +- scenario: every-scenario-has-proof#the-graph-shows-only-proven-scenes/four-deck-proofs-carry-frontmatter kind: unproven -- scenario: proof-scene-runner#the-graph-shows-only-proven-scenes/proof-transcript-does-not-match +- scenario: every-scenario-has-proof#the-graph-shows-only-proven-scenes/proof-transcript-does-not-match kind: check -- scenario: sparse-review-workspace#a-target-resolves-to-one-head-one-base-and-parts/file-outside-every-declaration +- scenario: review-the-whole-change#a-target-resolves-to-one-head-one-base-and-parts/file-outside-every-declaration kind: unproven -- scenario: sparse-review-workspace#three-targets-exist/working-copy-is-a-merge +- scenario: review-the-whole-change#three-targets-exist/working-copy-is-a-merge kind: unproven -- scenario: sparse-review-workspace#a-change-declares-its-closure/change-directory-closure-reproduces-canvas-a +- scenario: review-the-whole-change#a-change-declares-its-closure/change-directory-closure-reproduces-canvas-a kind: unproven -- scenario: sparse-review-workspace#parts-partition-a-diff-by-role/signing-queue-commit-is-partitioned +- scenario: review-the-whole-change#parts-partition-a-diff-by-role/signing-queue-commit-is-partitioned kind: unproven -- scenario: sparse-review-workspace#reading-links-come-from-declarations/record-cites-a-requirement +- scenario: review-the-whole-change#reading-links-come-from-declarations/record-cites-a-requirement kind: unproven -- scenario: sparse-review-workspace#parts-read-in-one-order/recording-is-left-out +- scenario: review-the-whole-change#parts-read-in-one-order/recording-is-left-out kind: unproven -- scenario: sparse-review-workspace#the-workspace-is-a-checkout-of-the-head/historical-revision-opened +- scenario: review-the-whole-change#the-workspace-is-a-checkout-of-the-head/historical-revision-opened kind: unproven -- scenario: sparse-review-workspace#the-workspace-is-a-checkout-of-the-head/working-copy-opened-while-the-root-holds-edits +- scenario: review-the-whole-change#the-workspace-is-a-checkout-of-the-head/working-copy-opened-while-the-root-holds-edits kind: unproven -- scenario: sparse-review-workspace#open-records-what-it-resolved/scenario-without-a-scene-is-listed +- scenario: review-the-whole-change#open-records-what-it-resolved/scenario-without-a-scene-is-listed kind: unproven -- scenario: sparse-review-workspace#diff-prints-the-grouped-patch/patch-follows-the-parts +- scenario: review-the-whole-change#diff-prints-the-grouped-patch/patch-follows-the-parts kind: unproven -- scenario: sparse-review-workspace#open-prints-the-way-in/diff-target-prints-its-comparison +- scenario: review-the-whole-change#open-prints-the-way-in/diff-target-prints-its-comparison kind: unproven -- scenario: sparse-review-workspace#close-re-resolves-from-the-state-file/source-rewritten-after-open +- scenario: review-the-whole-change#close-re-resolves-from-the-state-file/source-rewritten-after-open kind: unproven -- scenario: sparse-review-workspace#close-re-resolves-from-the-state-file/review-md-block-edited-to-match +- scenario: review-the-whole-change#close-re-resolves-from-the-state-file/review-md-block-edited-to-match kind: unproven -- scenario: sparse-review-workspace#only-an-acceptance-is-a-receipt/rejection-is-not-a-signing-receipt +- scenario: review-the-whole-change#only-an-acceptance-is-a-receipt/rejection-is-not-a-signing-receipt kind: unproven -- scenario: sparse-review-workspace#verdicts-keep-their-findings/accept-with-an-open-issue-marker +- scenario: review-the-whole-change#verdicts-keep-their-findings/accept-with-an-open-issue-marker kind: unproven -- scenario: sparse-review-workspace#verdicts-keep-their-findings/finding-on-a-deleted-file +- scenario: review-the-whole-change#verdicts-keep-their-findings/finding-on-a-deleted-file kind: unproven --- -# Proof: review-and-proof +# Proof: review-changes-with-proofs -One scene per scenario, in closure order. Fill each `console` fence with `$ command`, an optional `? <status>`, and the expected output, where `[..]` and `...` elide. Then run `rune proof run review-and-proof`. +One scene per scenario, in closure order. Fill each `console` fence with `$ command`, an optional `? <status>`, and the expected output, where `[..]` and `...` elide. Then run `rune proof run review-changes-with-proofs`. -## proof-scene-runner#proof-frontmatter-is-the-record/proof-directory-carries-another-change-s-name +## every-scenario-has-proof#proof-frontmatter-is-the-record/proof-directory-carries-another-change-s-name ```console $ cd tests/fixtures/proof/two-changes @@ -87,7 +87,7 @@ $ rune graph export --source . ... ``` -## proof-scene-runner#proof-frontmatter-is-the-record/instruction-scene-without-a-model +## every-scenario-has-proof#proof-frontmatter-is-the-record/instruction-scene-without-a-model ```console $ cd tests/fixtures/proof/bad-instruction @@ -96,7 +96,7 @@ $ rune spec validate --source . error[proof-frontmatter-invalid]: [..]README.md: sample-capability-one#thing-holds/version-prints: an instruction scene names the model that ran it ``` -## proof-scene-runner#head-and-transcript-bind-the-record/hand-recorded-proof-has-no-head +## every-scenario-has-proof#head-and-transcript-bind-the-record/hand-recorded-proof-has-no-head ```console $ cd tests/fixtures/proof/two-changes @@ -112,12 +112,12 @@ $ sh -c 'rune graph export --source . | awk '"'"'/proof\/canvas-a> a rune:Proof/ rune:sourcePath "docs/proofs/canvas-a/README.md" . ``` -## proof-scene-runner#one-scene-per-scenario-is-written-first/change-with-nine-delta-specs-gets-its-skeleton +## every-scenario-has-proof#one-scene-per-scenario-is-written-first/change-with-nine-delta-specs-gets-its-skeleton ```console ``` -## proof-scene-runner#one-scene-per-scenario-is-written-first/readme-already-exists +## every-scenario-has-proof#one-scene-per-scenario-is-written-first/readme-already-exists ```console $ sh -c 'cp -Rf "$RUNE_PROOF_ROOT/tests/fixtures/proof/one-change" repo && rune proof scaffold sample-change-one --source repo && rune proof scaffold sample-change-one --source repo' @@ -126,7 +126,7 @@ wrote [..]README.md with 2 unproven scenes fatal: [..]README.md exists; a filled scene is never overwritten ``` -## proof-scene-runner#run-executes-every-executable-scene/scene-names-a-command-the-runner-cannot-find +## every-scenario-has-proof#run-executes-every-executable-scene/scene-names-a-command-the-runner-cannot-find ```console $ sh -c 'cp -Rf "$RUNE_PROOF_ROOT/tests/fixtures/proof/filled" repo && rune proof run sample-change-one --source repo' @@ -136,7 +136,7 @@ sample-capability-one#thing-holds/missing-tag-fails ... unproven: command not fo 2 scenes, 1 proven, 1 unproven; transcript [..] ``` -## proof-scene-runner#run-executes-every-executable-scene/expected-output-disagrees +## every-scenario-has-proof#run-executes-every-executable-scene/expected-output-disagrees ```console $ sh -c 'cp -Rf "$RUNE_PROOF_ROOT/tests/fixtures/proof/mismatch" repo && rune proof run sample-change-one --source repo' @@ -148,7 +148,7 @@ sample-capability-one#thing-holds/missing-tag-fails ... ok (check) 2 scenes, 1 proven, 1 unproven; transcript [..] ``` -## proof-scene-runner#a-step-reads-its-input-from-the-fence/step-feeds-a-script-through-standard-input +## every-scenario-has-proof#a-step-reads-its-input-from-the-fence/step-feeds-a-script-through-standard-input ```console $ python3 - @@ -157,11 +157,11 @@ $ python3 - < print("two lines in") brief for - two lines in -$ sh -c 'grep -c "^< " "$RUNE_PROOF_ROOT/docs/proofs/review-and-proof/README.md"' +$ sh -c 'grep -c "^< " "$RUNE_PROOF_ROOT/docs/proofs/review-changes-with-proofs/README.md"' [..] ``` -## proof-scene-runner#a-step-reads-its-input-from-the-fence/input-line-before-any-command +## every-scenario-has-proof#a-step-reads-its-input-from-the-fence/input-line-before-any-command ```console $ sh -c 'cp -Rf "$RUNE_PROOF_ROOT/tests/fixtures/proof/input-first" repo && rune proof run sample-change-one --source repo' @@ -169,7 +169,7 @@ $ sh -c 'cp -Rf "$RUNE_PROOF_ROOT/tests/fixtures/proof/input-first" repo && rune fatal: [..]README.md: line 14: an input line `< text` comes after the command it feeds ``` -## proof-scene-runner#every-scene-starts-clean/scenes-do-not-share-a-directory +## every-scenario-has-proof#every-scene-starts-clean/scenes-do-not-share-a-directory ```console $ sh -c 'cp -Rf "$RUNE_PROOF_ROOT/tests/fixtures/proof/isolation" repo && rune proof run sample-change-one --source repo' @@ -178,7 +178,7 @@ sample-capability-one#thing-holds/missing-tag-fails ... ok (check) 2 scenes, 2 proven, 0 unproven; transcript [..] ``` -## proof-scene-runner#the-transcript-binds-the-claim/scene-key-edited-after-the-run +## every-scenario-has-proof#the-transcript-binds-the-claim/scene-key-edited-after-the-run ```console $ cd tests/fixtures/proof/edited-key @@ -188,7 +188,7 @@ error[proof-check]: [..]README.md: sample-capability-one#thing-holds/version-pri error[proof-check]: [..]README.md: sample-capability-one#thing-holds/missing-tag-fails is check but has no section ``` -## proof-scene-runner#the-transcript-binds-the-claim/instruction-scene-never-run +## every-scenario-has-proof#the-transcript-binds-the-claim/instruction-scene-never-run ```console $ cd tests/fixtures/proof/instruction-pending @@ -204,7 +204,7 @@ $ rune graph export --source . ... ``` -## proof-scene-runner#check-refuses-drift/transcript-drifted-after-the-run +## every-scenario-has-proof#check-refuses-drift/transcript-drifted-after-the-run ```console $ cd tests/fixtures/proof/drifted @@ -214,7 +214,7 @@ error[proof-check]: [..]README.md: transcript drifted: recorded [..] computed [. ... ``` -## proof-scene-runner#the-run-records-its-own-cast/cast-written-from-the-captured-output +## every-scenario-has-proof#the-run-records-its-own-cast/cast-written-from-the-captured-output ```console $ sh -c 'cp -Rf "$RUNE_PROOF_ROOT/tests/fixtures/proof/filled-good" repo && rune proof run sample-change-one --source repo >/dev/null && head -c 13 repo/docs/proofs/sample-change-one/proof.cast && echo && awk '"'"'/^\[.*"o"/{n++} END{print n " events"}'"'"' repo/docs/proofs/sample-change-one/proof.cast' @@ -222,12 +222,12 @@ $ sh -c 'cp -Rf "$RUNE_PROOF_ROOT/tests/fixtures/proof/filled-good" repo && rune 6 events ``` -## proof-scene-runner#the-graph-shows-only-proven-scenes/four-deck-proofs-carry-frontmatter +## every-scenario-has-proof#the-graph-shows-only-proven-scenes/four-deck-proofs-carry-frontmatter ```console ``` -## proof-scene-runner#the-graph-shows-only-proven-scenes/proof-transcript-does-not-match +## every-scenario-has-proof#the-graph-shows-only-proven-scenes/proof-transcript-does-not-match ```console $ cd tests/fixtures/proof/drifted @@ -247,82 +247,82 @@ $ sh -c 'rune graph export --source . | awk '"'"'/proof\/sample> a rune:Proof/,/ rune:sourcePath "docs/proofs/sample/README.md" . ``` -## sparse-review-workspace#a-target-resolves-to-one-head-one-base-and-parts/file-outside-every-declaration +## review-the-whole-change#a-target-resolves-to-one-head-one-base-and-parts/file-outside-every-declaration ```console ``` -## sparse-review-workspace#three-targets-exist/working-copy-is-a-merge +## review-the-whole-change#three-targets-exist/working-copy-is-a-merge ```console ``` -## sparse-review-workspace#a-change-declares-its-closure/change-directory-closure-reproduces-canvas-a +## review-the-whole-change#a-change-declares-its-closure/change-directory-closure-reproduces-canvas-a ```console ``` -## sparse-review-workspace#parts-partition-a-diff-by-role/signing-queue-commit-is-partitioned +## review-the-whole-change#parts-partition-a-diff-by-role/signing-queue-commit-is-partitioned ```console ``` -## sparse-review-workspace#reading-links-come-from-declarations/record-cites-a-requirement +## review-the-whole-change#reading-links-come-from-declarations/record-cites-a-requirement ```console ``` -## sparse-review-workspace#parts-read-in-one-order/recording-is-left-out +## review-the-whole-change#parts-read-in-one-order/recording-is-left-out ```console ``` -## sparse-review-workspace#the-workspace-is-a-checkout-of-the-head/historical-revision-opened +## review-the-whole-change#the-workspace-is-a-checkout-of-the-head/historical-revision-opened ```console ``` -## sparse-review-workspace#the-workspace-is-a-checkout-of-the-head/working-copy-opened-while-the-root-holds-edits +## review-the-whole-change#the-workspace-is-a-checkout-of-the-head/working-copy-opened-while-the-root-holds-edits ```console ``` -## sparse-review-workspace#open-records-what-it-resolved/scenario-without-a-scene-is-listed +## review-the-whole-change#open-records-what-it-resolved/scenario-without-a-scene-is-listed ```console ``` -## sparse-review-workspace#diff-prints-the-grouped-patch/patch-follows-the-parts +## review-the-whole-change#diff-prints-the-grouped-patch/patch-follows-the-parts ```console ``` -## sparse-review-workspace#open-prints-the-way-in/diff-target-prints-its-comparison +## review-the-whole-change#open-prints-the-way-in/diff-target-prints-its-comparison ```console ``` -## sparse-review-workspace#close-re-resolves-from-the-state-file/source-rewritten-after-open +## review-the-whole-change#close-re-resolves-from-the-state-file/source-rewritten-after-open ```console ``` -## sparse-review-workspace#close-re-resolves-from-the-state-file/review-md-block-edited-to-match +## review-the-whole-change#close-re-resolves-from-the-state-file/review-md-block-edited-to-match ```console ``` -## sparse-review-workspace#only-an-acceptance-is-a-receipt/rejection-is-not-a-signing-receipt +## review-the-whole-change#only-an-acceptance-is-a-receipt/rejection-is-not-a-signing-receipt ```console ``` -## sparse-review-workspace#verdicts-keep-their-findings/accept-with-an-open-issue-marker +## review-the-whole-change#verdicts-keep-their-findings/accept-with-an-open-issue-marker ```console ``` -## sparse-review-workspace#verdicts-keep-their-findings/finding-on-a-deleted-file +## review-the-whole-change#verdicts-keep-their-findings/finding-on-a-deleted-file ```console ``` diff --git a/docs/proofs/review-and-proof/proof.cast b/docs/proofs/review-changes-with-proofs/proof.cast similarity index 100% rename from docs/proofs/review-and-proof/proof.cast rename to docs/proofs/review-changes-with-proofs/proof.cast diff --git a/docs/proofs/review-and-proof/proof.txt b/docs/proofs/review-changes-with-proofs/proof.txt similarity index 100% rename from docs/proofs/review-and-proof/proof.txt rename to docs/proofs/review-changes-with-proofs/proof.txt diff --git a/docs/proofs/sign-before-publish-guard/README.md b/docs/proofs/sign-before-publishing-online/README.md similarity index 86% rename from docs/proofs/sign-before-publish-guard/README.md rename to docs/proofs/sign-before-publishing-online/README.md index 3e2c9e87..b1460f07 100644 --- a/docs/proofs/sign-before-publish-guard/README.md +++ b/docs/proofs/sign-before-publishing-online/README.md @@ -1,6 +1,6 @@ -# Behavior proof: sign-before-publish-guard +# Behavior proof: sign-before-publishing-online -`record.sh` holds one scene per scenario of the `sign-before-publish-guard` delta specification, in the +`record.sh` holds one scene per scenario of the `sign-before-publishing-online` delta specification, in the grammar of `docs/proofs/driver.sh` on the fixture of `docs/proofs/signing-fixture.sh`. `proof.cast` is its recording against the built binary on 2026-09-20, exit 0. `proof.txt` is the transcript `asciinema convert -f txt` wrote from it, and `proof.gif` the render. @@ -13,7 +13,7 @@ remote-tracking ref alone, so a scene publishes a head with `git update-ref`, as Re-record after a change to the scenes or the command: ```sh -RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/sign-before-publish-guard/record.sh" \ +RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/sign-before-publishing-online/record.sh" \ --headless --window-size 100x30 --idle-time-limit 2 --overwrite --return proof.cast agg --theme github-dark --font-size 11 --fps-cap 3 --last-frame-duration 3 proof.cast proof.gif asciinema convert -f txt --overwrite proof.cast proof.txt @@ -22,5 +22,5 @@ asciinema convert -f txt --overwrite proof.cast proof.txt Where asciinema cannot open a pseudo terminal, `docs/proofs/cast.py` writes the same cast from the script's output stream: ```sh -RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/sign-before-publish-guard/record.sh proof.cast +RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/sign-before-publishing-online/record.sh proof.cast ``` diff --git a/docs/proofs/sign-before-publish-guard/proof.cast b/docs/proofs/sign-before-publishing-online/proof.cast similarity index 100% rename from docs/proofs/sign-before-publish-guard/proof.cast rename to docs/proofs/sign-before-publishing-online/proof.cast diff --git a/docs/proofs/sign-before-publish-guard/proof.gif b/docs/proofs/sign-before-publishing-online/proof.gif similarity index 100% rename from docs/proofs/sign-before-publish-guard/proof.gif rename to docs/proofs/sign-before-publishing-online/proof.gif diff --git a/docs/proofs/sign-before-publish-guard/proof.txt b/docs/proofs/sign-before-publishing-online/proof.txt similarity index 100% rename from docs/proofs/sign-before-publish-guard/proof.txt rename to docs/proofs/sign-before-publishing-online/proof.txt diff --git a/docs/proofs/sign-before-publish-guard/record.sh b/docs/proofs/sign-before-publishing-online/record.sh similarity index 100% rename from docs/proofs/sign-before-publish-guard/record.sh rename to docs/proofs/sign-before-publishing-online/record.sh diff --git a/docs/proofs/owner-signing-queue/README.md b/docs/proofs/sign-commits-in-batches/README.md similarity index 72% rename from docs/proofs/owner-signing-queue/README.md rename to docs/proofs/sign-commits-in-batches/README.md index f8ebeed0..27507bcc 100644 --- a/docs/proofs/owner-signing-queue/README.md +++ b/docs/proofs/sign-commits-in-batches/README.md @@ -1,6 +1,6 @@ -# Behavior proof: owner-signing-queue +# Behavior proof: sign-commits-in-batches -`record.sh` holds one scene per scenario of the `owner-signing-queue` delta specification, in the grammar of +`record.sh` holds one scene per scenario of the two `sign-commits-in-batches` delta specifications, in the grammar of `docs/proofs/driver.sh`. `proof.cast` is its recording against the built binary on 2026-09-15, exit 0. `proof.txt` is the transcript `asciinema convert -f txt` wrote from it, and `proof.gif` the render. @@ -9,7 +9,7 @@ Transcript digest (`shasum -a 256 proof.txt`): `83f9c0d5c7f764a54c93ac5bd9f126ac Re-record after a change to the scenes or the command: ```sh -RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/owner-signing-queue/record.sh" \ +RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/sign-commits-in-batches/record.sh" \ --headless --window-size 100x30 --idle-time-limit 2 --overwrite --return proof.cast agg --theme github-dark --font-size 14 --fps-cap 8 --last-frame-duration 4 proof.cast proof.gif asciinema convert -f txt --overwrite proof.cast proof.txt @@ -18,5 +18,5 @@ asciinema convert -f txt --overwrite proof.cast proof.txt Where asciinema cannot open a pseudo terminal, `docs/proofs/cast.py` writes the same cast from the script's output stream: ```sh -RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/owner-signing-queue/record.sh proof.cast +RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/sign-commits-in-batches/record.sh proof.cast ``` diff --git a/docs/proofs/owner-signing-queue/proof.cast b/docs/proofs/sign-commits-in-batches/proof.cast similarity index 100% rename from docs/proofs/owner-signing-queue/proof.cast rename to docs/proofs/sign-commits-in-batches/proof.cast diff --git a/docs/proofs/owner-signing-queue/proof.gif b/docs/proofs/sign-commits-in-batches/proof.gif similarity index 100% rename from docs/proofs/owner-signing-queue/proof.gif rename to docs/proofs/sign-commits-in-batches/proof.gif diff --git a/docs/proofs/owner-signing-queue/proof.txt b/docs/proofs/sign-commits-in-batches/proof.txt similarity index 100% rename from docs/proofs/owner-signing-queue/proof.txt rename to docs/proofs/sign-commits-in-batches/proof.txt diff --git a/docs/proofs/owner-signing-queue/record.sh b/docs/proofs/sign-commits-in-batches/record.sh similarity index 100% rename from docs/proofs/owner-signing-queue/record.sh rename to docs/proofs/sign-commits-in-batches/record.sh diff --git a/docs/proofs/prose-length-caps/README.md b/docs/proofs/word-limits-on-specs/README.md similarity index 77% rename from docs/proofs/prose-length-caps/README.md rename to docs/proofs/word-limits-on-specs/README.md index b3052778..9bc62022 100644 --- a/docs/proofs/prose-length-caps/README.md +++ b/docs/proofs/word-limits-on-specs/README.md @@ -1,6 +1,6 @@ -# Behavior proof: prose-length-caps +# Behavior proof: word-limits-on-specs -`record.sh` holds one scene per scenario of the `prose-length-caps` delta specification, in the grammar of +`record.sh` holds one scene per scenario of the `word-limits-on-specs` delta specification, in the grammar of `docs/proofs/driver.sh`. `proof.cast` is its recording against the built binary on 2026-09-20, exit 0. `proof.txt` is the transcript `asciinema convert -f txt` wrote from it, and `proof.gif` the render. @@ -12,7 +12,7 @@ The scenes build a scratch repository with one canonical specification, one chan Re-record after a change to the scenes or the command: ```sh -RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/prose-length-caps/record.sh" \ +RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/word-limits-on-specs/record.sh" \ --headless --window-size 100x30 --idle-time-limit 2 --overwrite --return proof.cast agg --theme github-dark --font-size 11 --fps-cap 3 --last-frame-duration 3 proof.cast proof.gif asciinema convert -f txt --overwrite proof.cast proof.txt @@ -21,5 +21,5 @@ asciinema convert -f txt --overwrite proof.cast proof.txt Where asciinema cannot open a pseudo terminal, `docs/proofs/cast.py` writes the same cast from the script's output stream: ```sh -RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/prose-length-caps/record.sh proof.cast +RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/word-limits-on-specs/record.sh proof.cast ``` diff --git a/docs/proofs/prose-length-caps/proof.cast b/docs/proofs/word-limits-on-specs/proof.cast similarity index 100% rename from docs/proofs/prose-length-caps/proof.cast rename to docs/proofs/word-limits-on-specs/proof.cast diff --git a/docs/proofs/prose-length-caps/proof.gif b/docs/proofs/word-limits-on-specs/proof.gif similarity index 100% rename from docs/proofs/prose-length-caps/proof.gif rename to docs/proofs/word-limits-on-specs/proof.gif diff --git a/docs/proofs/prose-length-caps/proof.txt b/docs/proofs/word-limits-on-specs/proof.txt similarity index 100% rename from docs/proofs/prose-length-caps/proof.txt rename to docs/proofs/word-limits-on-specs/proof.txt diff --git a/docs/proofs/prose-length-caps/record.sh b/docs/proofs/word-limits-on-specs/record.sh similarity index 100% rename from docs/proofs/prose-length-caps/record.sh rename to docs/proofs/word-limits-on-specs/record.sh diff --git a/docs/specs/worktree-commit-identity/spec.md b/docs/specs/commits-carry-model-identity/spec.md similarity index 100% rename from docs/specs/worktree-commit-identity/spec.md rename to docs/specs/commits-carry-model-identity/spec.md diff --git a/docs/specs/glossary/accountable.md b/docs/specs/glossary/accountable.md new file mode 100644 index 00000000..5bcace58 --- /dev/null +++ b/docs/specs/glossary/accountable.md @@ -0,0 +1,12 @@ +--- +title: accountable +iri: https://runedeck.ai/ns#accountable +kind: property +status: defined +--- + +# accountable + +On a record: who approves the decision, the A of RACI. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/agent.md b/docs/specs/glossary/agent.md new file mode 100644 index 00000000..fe7c031f --- /dev/null +++ b/docs/specs/glossary/agent.md @@ -0,0 +1,12 @@ +--- +title: agent +iri: https://runedeck.ai/ns#Agent +kind: class +status: defined +--- + +# agent + +The document that defines a persona and its tools for a harness to run. The running process is a prov:SoftwareAgent. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/aliases.md b/docs/specs/glossary/aliases.md new file mode 100644 index 00000000..36edc02a --- /dev/null +++ b/docs/specs/glossary/aliases.md @@ -0,0 +1,12 @@ +--- +title: aliases +iri: https://runedeck.ai/ns#aliases +kind: property +status: defined +--- + +# aliases + +On every artifact: other names it is known by, an Obsidian default. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/analysis-baseline.md b/docs/specs/glossary/analysis-baseline.md new file mode 100644 index 00000000..f96acc41 --- /dev/null +++ b/docs/specs/glossary/analysis-baseline.md @@ -0,0 +1,12 @@ +--- +title: analysis baseline +iri: https://runedeck.ai/ns#AnalysisBaseline +kind: class +status: defined +--- + +# analysis baseline + +The set of results one run of analysis tools produced on a set of artifacts, kept so a later run is compared against it. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/analysis-result.md b/docs/specs/glossary/analysis-result.md new file mode 100644 index 00000000..3665b7f1 --- /dev/null +++ b/docs/specs/glossary/analysis-result.md @@ -0,0 +1,12 @@ +--- +title: analysis result +iri: https://runedeck.ai/ns#AnalysisResult +kind: class +status: defined +--- + +# analysis result + +One condition an analysis tool reports in an artifact, with the rule it violates and the location. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/analysis-rule.md b/docs/specs/glossary/analysis-rule.md new file mode 100644 index 00000000..6cdb6883 --- /dev/null +++ b/docs/specs/glossary/analysis-rule.md @@ -0,0 +1,12 @@ +--- +title: analysis rule +iri: https://runedeck.ai/ns#AnalysisRule +kind: class +status: defined +--- + +# analysis rule + +One criterion for correctness that an analysis tool verifies, a general one or a convention of this deck. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/analysis-tool.md b/docs/specs/glossary/analysis-tool.md new file mode 100644 index 00000000..2e23ce28 --- /dev/null +++ b/docs/specs/glossary/analysis-tool.md @@ -0,0 +1,12 @@ +--- +title: analysis tool +iri: https://runedeck.ai/ns#AnalysisTool +kind: class +status: defined +--- + +# analysis tool + +A program that examines artifacts to detect problems without executing them: a linter, a validator, a shape checker, or a check script. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/architecture-decision-record.md b/docs/specs/glossary/architecture-decision-record.md new file mode 100644 index 00000000..204081df --- /dev/null +++ b/docs/specs/glossary/architecture-decision-record.md @@ -0,0 +1,12 @@ +--- +title: architecture decision record +iri: https://runedeck.ai/ns#ArchitectureDecisionRecord +kind: class +status: defined +--- + +# architecture decision record + +A decision record for a software architecture decision in the shape Michael Nygard set out in 2011: context, decision, status, consequences. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/archived.md b/docs/specs/glossary/archived.md new file mode 100644 index 00000000..f046f245 --- /dev/null +++ b/docs/specs/glossary/archived.md @@ -0,0 +1,12 @@ +--- +title: archived +iri: https://runedeck.ai/ns#archived +kind: property +status: defined +--- + +# archived + +On a proposal: the date the archive step accepted the change, which is the acceptance date of every record it produced. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/artifact.md b/docs/specs/glossary/artifact.md new file mode 100644 index 00000000..a08d3c80 --- /dev/null +++ b/docs/specs/glossary/artifact.md @@ -0,0 +1,12 @@ +--- +title: artifact +iri: https://runedeck.ai/ns#Artifact +kind: class +status: defined +--- + +# artifact + +Any file the system tracks: an instruction, a record, a specification, or a companion. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/assembly.md b/docs/specs/glossary/assembly.md new file mode 100644 index 00000000..8be2ac1b --- /dev/null +++ b/docs/specs/glossary/assembly.md @@ -0,0 +1,12 @@ +--- +title: assembly +iri: https://runedeck.ai/ns#Assembly +kind: class +status: defined +--- + +# assembly + +The activity that resolves one canonical rune, its companions, and its variants into one instance for each harness and model, and records provenance. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/attestation.md b/docs/specs/glossary/attestation.md new file mode 100644 index 00000000..837a4cb5 --- /dev/null +++ b/docs/specs/glossary/attestation.md @@ -0,0 +1,12 @@ +--- +title: attestation +iri: https://runedeck.ai/ns#Attestation +kind: class +status: defined +--- + +# attestation + +A proof recorded as a run of a change's scenarios against one commit, with what the transcript shows for each, bound by the transcript digest. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/author.md b/docs/specs/glossary/author.md new file mode 100644 index 00000000..cf380df5 --- /dev/null +++ b/docs/specs/glossary/author.md @@ -0,0 +1,12 @@ +--- +title: author +iri: https://runedeck.ai/ns#author +kind: property +status: defined +--- + +# author + +On every artifact: who wrote it, by handle. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/canon.md b/docs/specs/glossary/canon.md new file mode 100644 index 00000000..4438b7d3 --- /dev/null +++ b/docs/specs/glossary/canon.md @@ -0,0 +1,12 @@ +--- +title: canon +iri: https://runedeck.ai/ns#Canon +kind: class +status: defined +--- + +# canon + +The harness-neutral entrypoint document of a rune: SKILL.md for a skill, the rule file for a rule. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/capability.md b/docs/specs/glossary/capability.md new file mode 100644 index 00000000..6ad7a309 --- /dev/null +++ b/docs/specs/glossary/capability.md @@ -0,0 +1,12 @@ +--- +title: capability +iri: https://runedeck.ai/ns#Capability +kind: class +status: defined +--- + +# capability + +One specification: canonical under docs/specs/, or a delta inside a change. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/change-key.md b/docs/specs/glossary/change-key.md new file mode 100644 index 00000000..61d0f0f3 --- /dev/null +++ b/docs/specs/glossary/change-key.md @@ -0,0 +1,12 @@ +--- +title: change +iri: https://runedeck.ai/ns#change +kind: property +status: defined +--- + +# change + +On a record: the change that produced it. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/change.md b/docs/specs/glossary/change.md new file mode 100644 index 00000000..51f0f36e --- /dev/null +++ b/docs/specs/glossary/change.md @@ -0,0 +1,12 @@ +--- +title: change +iri: https://runedeck.ai/ns#Change +kind: class +status: defined +--- + +# change + +One spec-driven change under docs/changes/, active or archived. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/companion.md b/docs/specs/glossary/companion.md new file mode 100644 index 00000000..a56177ad --- /dev/null +++ b/docs/specs/glossary/companion.md @@ -0,0 +1,12 @@ +--- +title: companion +iri: https://runedeck.ai/ns#Companion +kind: class +status: defined +--- + +# companion + +A document beside a canon that the canon routes to: reference material, or the instructions and quirks of one harness, kept out of the canon so they are easy to review and to compile. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/consulted.md b/docs/specs/glossary/consulted.md new file mode 100644 index 00000000..9eef90e1 --- /dev/null +++ b/docs/specs/glossary/consulted.md @@ -0,0 +1,12 @@ +--- +title: consulted +iri: https://runedeck.ai/ns#consulted +kind: property +status: defined +--- + +# consulted + +On a record: whose input was used, people and models as model-id@harness, MADR's consulted. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/coverage.md b/docs/specs/glossary/coverage.md new file mode 100644 index 00000000..cda3c43e --- /dev/null +++ b/docs/specs/glossary/coverage.md @@ -0,0 +1,12 @@ +--- +title: coverage +iri: https://runedeck.ai/ns#Coverage +kind: class +status: defined +--- + +# coverage + +The binding of each requirement to the analysis tool that enforces it, or its listing as a missed requirement. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/created.md b/docs/specs/glossary/created.md new file mode 100644 index 00000000..9a0d7982 --- /dev/null +++ b/docs/specs/glossary/created.md @@ -0,0 +1,12 @@ +--- +title: created +iri: https://runedeck.ai/ns#created +kind: property +status: defined +--- + +# created + +On every artifact: the date it was first written. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/decision-record.md b/docs/specs/glossary/decision-record.md new file mode 100644 index 00000000..d4fd65ba --- /dev/null +++ b/docs/specs/glossary/decision-record.md @@ -0,0 +1,12 @@ +--- +title: decision record +iri: https://runedeck.ai/ns#DecisionRecord +kind: class +status: defined +--- + +# decision record + +A record of one decision: its context, the options considered, and the outcome. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/decisions.md b/docs/specs/glossary/decisions.md new file mode 100644 index 00000000..1475c87a --- /dev/null +++ b/docs/specs/glossary/decisions.md @@ -0,0 +1,12 @@ +--- +title: decisions +iri: https://runedeck.ai/ns#decisionRecord +kind: property +status: defined +--- + +# decisions + +On a proposal: the records the change produced, by title. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/delta-specification.md b/docs/specs/glossary/delta-specification.md new file mode 100644 index 00000000..1f2bfcb7 --- /dev/null +++ b/docs/specs/glossary/delta-specification.md @@ -0,0 +1,12 @@ +--- +title: delta specification +iri: https://runedeck.ai/ns#Delta +kind: class +status: defined +--- + +# delta specification + +A change-local specification that lists added, modified, removed, or renamed requirements, merged into the canonical specification at sync or archive. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/description.md b/docs/specs/glossary/description.md new file mode 100644 index 00000000..62fe529f --- /dev/null +++ b/docs/specs/glossary/description.md @@ -0,0 +1,12 @@ +--- +title: description +iri: https://runedeck.ai/ns#description +kind: property +status: defined +--- + +# description + +On every artifact: one sentence that says what it is for. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/dispatcher-answer.md b/docs/specs/glossary/dispatcher-answer.md new file mode 100644 index 00000000..7d1873e5 --- /dev/null +++ b/docs/specs/glossary/dispatcher-answer.md @@ -0,0 +1,12 @@ +--- +title: dispatcher answer +iri: https://runedeck.ai/ns#DispatcherAnswer +kind: class +status: defined +--- + +# dispatcher answer + +The answer the hook dispatcher encodes for the harness after an event's hook executions: allow, deny, or stop the turn, with a reason. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/falsifiability.md b/docs/specs/glossary/falsifiability.md new file mode 100644 index 00000000..2af15d8e --- /dev/null +++ b/docs/specs/glossary/falsifiability.md @@ -0,0 +1,12 @@ +--- +title: falsifiability +iri: https://runedeck.ai/ns#Falsifiability +kind: concept +status: defined +--- + +# falsifiability + +A requirement states a check that can fail. A statement nothing can fail is not a requirement. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/fixture.md b/docs/specs/glossary/fixture.md new file mode 100644 index 00000000..edea8006 --- /dev/null +++ b/docs/specs/glossary/fixture.md @@ -0,0 +1,12 @@ +--- +title: fixture +iri: https://runedeck.ai/ns#Fixture +kind: class +status: defined +--- + +# fixture + +An input a test runs against, with the result the test asserts for it. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/harness.md b/docs/specs/glossary/harness.md new file mode 100644 index 00000000..89511b08 --- /dev/null +++ b/docs/specs/glossary/harness.md @@ -0,0 +1,12 @@ +--- +title: harness +iri: https://runedeck.ai/ns#Harness +kind: class +status: defined +--- + +# harness + +A program that runs a model with tools and loads instructions from directories it owns. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/hook-dispatcher.md b/docs/specs/glossary/hook-dispatcher.md new file mode 100644 index 00000000..b97fa3ad --- /dev/null +++ b/docs/specs/glossary/hook-dispatcher.md @@ -0,0 +1,12 @@ +--- +title: hook dispatcher +iri: https://runedeck.ai/ns#HookDispatcher +kind: class +status: defined +--- + +# hook dispatcher + +The program that takes one harness event, runs the hooks deployed for it in order, and encodes one answer for the harness. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/hook-execution.md b/docs/specs/glossary/hook-execution.md new file mode 100644 index 00000000..c18d6c59 --- /dev/null +++ b/docs/specs/glossary/hook-execution.md @@ -0,0 +1,12 @@ +--- +title: hook execution +iri: https://runedeck.ai/ns#HookExecution +kind: class +status: defined +--- + +# hook execution + +One execution of the hook dispatcher for one harness event, with every hook it ran or failed to start. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/hook-metrics.md b/docs/specs/glossary/hook-metrics.md new file mode 100644 index 00000000..e12b3e8f --- /dev/null +++ b/docs/specs/glossary/hook-metrics.md @@ -0,0 +1,12 @@ +--- +title: hook metrics +iri: https://runedeck.ai/ns#HookMetrics +kind: class +status: defined +--- + +# hook metrics + +Counted facts a hook returns with its result, as identifiers and integers, copied into the hook record. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/hook-record.md b/docs/specs/glossary/hook-record.md new file mode 100644 index 00000000..e093980f --- /dev/null +++ b/docs/specs/glossary/hook-record.md @@ -0,0 +1,12 @@ +--- +title: hook record +iri: https://runedeck.ai/ns#HookRecord +kind: class +status: defined +--- + +# hook record + +One line the hook dispatcher writes per hook it ran: harness, event, hook, session, file, duration, exit status, the answer, and metrics. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/hook.md b/docs/specs/glossary/hook.md new file mode 100644 index 00000000..a291effb --- /dev/null +++ b/docs/specs/glossary/hook.md @@ -0,0 +1,12 @@ +--- +title: hook +iri: https://runedeck.ai/ns#Hook +kind: class +status: defined +--- + +# hook + +A script bound to harness events, run by the hook dispatcher. It is code. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/idea.md b/docs/specs/glossary/idea.md new file mode 100644 index 00000000..26e332e1 --- /dev/null +++ b/docs/specs/glossary/idea.md @@ -0,0 +1,12 @@ +--- +title: idea +iri: https://runedeck.ai/ns#Idea +kind: class +status: defined +--- + +# idea + +The human-authored capture a change starts from: a drawing, a transcript, or a prompt in the owner's vault. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/index.md b/docs/specs/glossary/index.md new file mode 100644 index 00000000..68d28e86 --- /dev/null +++ b/docs/specs/glossary/index.md @@ -0,0 +1,83 @@ +# Glossary + +Generated from `ontology/rune.ttl`. Edit the ontology, not these pages. + +- [Occam's razor](occams-razor.md): Of two instructions that pass the same check, the shorter one wins. +- [accountable](accountable.md): On a record: who approves the decision, the A of RACI. +- [agent](agent.md): The document that defines a persona and its tools for a harness to run. The running process is a prov:SoftwareAgent. +- [aliases](aliases.md): On every artifact: other names it is known by, an Obsidian default. +- [analysis baseline](analysis-baseline.md): The set of results one run of analysis tools produced on a set of artifacts, kept so a later run is compared against it. +- [analysis result](analysis-result.md): One condition an analysis tool reports in an artifact, with the rule it violates and the location. +- [analysis rule](analysis-rule.md): One criterion for correctness that an analysis tool verifies, a general one or a convention of this deck. +- [analysis tool](analysis-tool.md): A program that examines artifacts to detect problems without executing them: a linter, a validator, a shape checker, or a check script. +- [architecture decision record](architecture-decision-record.md): A decision record for a software architecture decision in the shape Michael Nygard set out in 2011: context, decision, status, consequences. +- [archived](archived.md): On a proposal: the date the archive step accepted the change, which is the acceptance date of every record it produced. +- [artifact](artifact.md): Any file the system tracks: an instruction, a record, a specification, or a companion. +- [assembly](assembly.md): The activity that resolves one canonical rune, its companions, and its variants into one instance for each harness and model, and records provenance. +- [attestation](attestation.md): A proof recorded as a run of a change's scenarios against one commit, with what the transcript shows for each, bound by the transcript digest. +- [author](author.md): On every artifact: who wrote it, by handle. +- [canon](canon.md): The harness-neutral entrypoint document of a rune: SKILL.md for a skill, the rule file for a rule. +- [capability](capability.md): One specification: canonical under docs/specs/, or a delta inside a change. +- [change](change.md): One spec-driven change under docs/changes/, active or archived. +- [change](change-key.md): On a record: the change that produced it. +- [companion](companion.md): A document beside a canon that the canon routes to: reference material, or the instructions and quirks of one harness, kept out of the canon so they are easy to review and to compile. +- [consulted](consulted.md): On a record: whose input was used, people and models as model-id@harness, MADR's consulted. +- [coverage](coverage.md): The binding of each requirement to the analysis tool that enforces it, or its listing as a missed requirement. +- [created](created.md): On every artifact: the date it was first written. +- [decision record](decision-record.md): A record of one decision: its context, the options considered, and the outcome. +- [decisions](decisions.md): On a proposal: the records the change produced, by title. +- [delta specification](delta-specification.md): A change-local specification that lists added, modified, removed, or renamed requirements, merged into the canonical specification at sync or archive. +- [description](description.md): On every artifact: one sentence that says what it is for. +- [dispatcher answer](dispatcher-answer.md): The answer the hook dispatcher encodes for the harness after an event's hook executions: allow, deny, or stop the turn, with a reason. +- [falsifiability](falsifiability.md): A requirement states a check that can fail. A statement nothing can fail is not a requirement. +- [fixture](fixture.md): An input a test runs against, with the result the test asserts for it. +- [harness](harness.md): A program that runs a model with tools and loads instructions from directories it owns. +- [hook](hook.md): A script bound to harness events, run by the hook dispatcher. It is code. +- [hook dispatcher](hook-dispatcher.md): The program that takes one harness event, runs the hooks deployed for it in order, and encodes one answer for the harness. +- [hook execution](hook-execution.md): One execution of the hook dispatcher for one harness event, with every hook it ran or failed to start. +- [hook metrics](hook-metrics.md): Counted facts a hook returns with its result, as identifiers and integers, copied into the hook record. +- [hook record](hook-record.md): One line the hook dispatcher writes per hook it ran: harness, event, hook, session, file, duration, exit status, the answer, and metrics. +- [idea](idea.md): The human-authored capture a change starts from: a drawing, a transcript, or a prompt in the owner's vault. +- [informed](informed.md): On a record: who is told the outcome, MADR's informed. +- [instance](instance.md): One concrete realization of a kind, as the file assembly writes into a target for one harness is an instance of its rune. +- [instruction](instruction.md): A Markdown file a harness loads to steer a model: a skill, a rule, an agent, or a routine. +- [lifecycle](lifecycle.md): The path a change takes: an optional canvas, then the specification, the decision record, the implementation, the review, and the merge. +- [lifecycle stage](lifecycle-stage.md): One step of the lifecycle, in order: canvas, specification, decision, implementation, review, merge. +- [linter](linter.md): An analysis tool that matches rule patterns against a file, one file at a time, and reports style and convention violations. +- [markdown architecture decision record](markdown-architecture-decision-record.md): An architecture decision record in Markdown with the MADR template: decision drivers, considered options, their pros and cons, and the outcome. +- [missed requirement](missed-requirement.md): A requirement no analysis tool checks yet, listed in the coverage as such. +- [model](model.md): The language model a harness runs. +- [module](module.md): One domain of a deck that keeps its instructions, its defaults, and its checks together behind a small interface, so it can be added, replaced, or removed without a change elsewhere. +- [name](name.md): On a rune canon: its name, which the Agent Skills specification requires beside description. +- [project](project.md): On a record: the project it belongs to, a DOAP project such as the deck. +- [projection](projection.md): A representation that drops information from its source. A search index stays uncommitted, and a snapshot of a source outside the repository is committed only with a provenance sidecar that names its upstream. +- [proof](proof.md): Evidence that a candidate commit shows the behavior a scenario states. +- [provenance](provenance.md): The sealed statement of where an instance came from: its source and digest, the transforms applied, and the builder, kept in the sidecar as an in-toto statement. +- [recorded](recorded.md): The day the proof was recorded. +- [related](related.md): On every artifact: neighbor artifacts, by id and title. +- [render](render.md): A committed representation that a command the repository carries produces from a plain-text source and that regenerates from it byte for byte. +- [representation](representation.md): One form an artifact's content takes. The plain-text representation is the authoritative one, and every other representation derives from it. +- [requirement](requirement.md): One `### Requirement:` with MUST text, part of a capability. +- [responsible](responsible.md): On a record: who does the work, the R of RACI, which MADR leaves out. +- [routine](routine.md): An instruction installed on a harness account, which runs it on a schedule. +- [rule](rule.md): An always-on instruction artifact under a rules/ tree. +- [rune](rune.md): An instruction that follows the rune template: one canon, optional sidecar, companions, and variants. A skill, a rule, or an agent is a rune when it does. +- [scenario](scenario.md): One `#### Scenario:` with WHEN and THEN, part of a requirement. +- [scenes](scenes.md): What the proof records about one scenario. +- [sidecar](sidecar.md): A file beside a rune that holds what the canon does not: harness metadata, or sealed provenance under .provenance/. A sidecar never repeats a canon field. +- [skill](skill.md): A procedure artifact with a SKILL.md canon. +- [status](status.md): On a record or a proposal: its asserted lifecycle status. The frontmatter is the authority, and a shape only catches a disagreement with the file's location. +- [structure schema](structure-schema.md): The per-directory structural contract, serialized as .mdschema, that validates the files beside it. +- [structured architecture decision record](structured-architecture-decision-record.md): A markdown architecture decision record with YAML frontmatter for status, dates, RACI, and provenance: the runeADR format the deck's records under docs/decisions/ use. +- [supersedes](supersedes.md): On a record: the older record it replaces, which then states status superseded. +- [tags](tags.md): On every artifact: the topics it belongs to, as Obsidian tags. +- [target](target.md): A directory one harness loads instructions from, and the destination a deployment writes into. +- [template](template.md): A file with placeholders that a renderer fills to produce another file. +- [title](title.md): On every artifact: its human name. A rune canon says `name` instead, because Agent Skills owns that frontmatter. +- [transcript](transcript.md): The sha256 of proof.txt as the README states it. +- [type](type.md): On every artifact: its kind as the directory states it: skill, rule, agent, hook, routine, adr, or proof. A shape holds the list, so the key and the directory never disagree. +- [updated](updated.md): On every artifact: the date of its last change. +- [upstream](upstream.md): On every artifact: the sources it was adapted from, as links, with the origin first. +- [validator](validator.md): An analysis tool that decides whether an artifact is valid against a grammar, a schema, or a set of shapes, and reports nothing about style. +- [variant](variant.md): A first-class alternative of a rune for one harness, one model, or one user, resolved at assembly: user, then harness and model, then harness, then the base rune. +- [verdict](verdict.md): The bench's judgment of one artifact against its baseline, from the metrics the bench measured. diff --git a/docs/specs/glossary/informed.md b/docs/specs/glossary/informed.md new file mode 100644 index 00000000..595d2ea7 --- /dev/null +++ b/docs/specs/glossary/informed.md @@ -0,0 +1,12 @@ +--- +title: informed +iri: https://runedeck.ai/ns#informed +kind: property +status: defined +--- + +# informed + +On a record: who is told the outcome, MADR's informed. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/instance.md b/docs/specs/glossary/instance.md new file mode 100644 index 00000000..bf9c96b6 --- /dev/null +++ b/docs/specs/glossary/instance.md @@ -0,0 +1,12 @@ +--- +title: instance +iri: https://runedeck.ai/ns#Instance +kind: class +status: defined +--- + +# instance + +One concrete realization of a kind, as the file assembly writes into a target for one harness is an instance of its rune. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/instruction.md b/docs/specs/glossary/instruction.md new file mode 100644 index 00000000..6e0fabcf --- /dev/null +++ b/docs/specs/glossary/instruction.md @@ -0,0 +1,12 @@ +--- +title: instruction +iri: https://runedeck.ai/ns#Instruction +kind: class +status: defined +--- + +# instruction + +A Markdown file a harness loads to steer a model: a skill, a rule, an agent, or a routine. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/lifecycle-stage.md b/docs/specs/glossary/lifecycle-stage.md new file mode 100644 index 00000000..fc4edb0f --- /dev/null +++ b/docs/specs/glossary/lifecycle-stage.md @@ -0,0 +1,12 @@ +--- +title: lifecycle stage +iri: https://runedeck.ai/ns#Stage +kind: class +status: defined +--- + +# lifecycle stage + +One step of the lifecycle, in order: canvas, specification, decision, implementation, review, merge. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/lifecycle.md b/docs/specs/glossary/lifecycle.md new file mode 100644 index 00000000..38c78b0e --- /dev/null +++ b/docs/specs/glossary/lifecycle.md @@ -0,0 +1,12 @@ +--- +title: lifecycle +iri: https://runedeck.ai/ns#Lifecycle +kind: concept +status: defined +--- + +# lifecycle + +The path a change takes: an optional canvas, then the specification, the decision record, the implementation, the review, and the merge. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/linter.md b/docs/specs/glossary/linter.md new file mode 100644 index 00000000..01f68036 --- /dev/null +++ b/docs/specs/glossary/linter.md @@ -0,0 +1,12 @@ +--- +title: linter +iri: https://runedeck.ai/ns#Linter +kind: class +status: defined +--- + +# linter + +An analysis tool that matches rule patterns against a file, one file at a time, and reports style and convention violations. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/markdown-architecture-decision-record.md b/docs/specs/glossary/markdown-architecture-decision-record.md new file mode 100644 index 00000000..f433ff28 --- /dev/null +++ b/docs/specs/glossary/markdown-architecture-decision-record.md @@ -0,0 +1,12 @@ +--- +title: markdown architecture decision record +iri: https://runedeck.ai/ns#MarkdownArchitectureDecisionRecord +kind: class +status: defined +--- + +# markdown architecture decision record + +An architecture decision record in Markdown with the MADR template: decision drivers, considered options, their pros and cons, and the outcome. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/missed-requirement.md b/docs/specs/glossary/missed-requirement.md new file mode 100644 index 00000000..4039cedc --- /dev/null +++ b/docs/specs/glossary/missed-requirement.md @@ -0,0 +1,12 @@ +--- +title: missed requirement +iri: https://runedeck.ai/ns#MissedRequirement +kind: class +status: defined +--- + +# missed requirement + +A requirement no analysis tool checks yet, listed in the coverage as such. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/model.md b/docs/specs/glossary/model.md new file mode 100644 index 00000000..e8d2931b --- /dev/null +++ b/docs/specs/glossary/model.md @@ -0,0 +1,12 @@ +--- +title: model +iri: https://runedeck.ai/ns#Model +kind: class +status: defined +--- + +# model + +The language model a harness runs. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/module.md b/docs/specs/glossary/module.md new file mode 100644 index 00000000..8768016e --- /dev/null +++ b/docs/specs/glossary/module.md @@ -0,0 +1,12 @@ +--- +title: module +iri: https://runedeck.ai/ns#Module +kind: class +status: defined +--- + +# module + +One domain of a deck that keeps its instructions, its defaults, and its checks together behind a small interface, so it can be added, replaced, or removed without a change elsewhere. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/name.md b/docs/specs/glossary/name.md new file mode 100644 index 00000000..561b9366 --- /dev/null +++ b/docs/specs/glossary/name.md @@ -0,0 +1,12 @@ +--- +title: name +iri: https://runedeck.ai/ns#name +kind: property +status: defined +--- + +# name + +On a rune canon: its name, which the Agent Skills specification requires beside description. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/occams-razor.md b/docs/specs/glossary/occams-razor.md new file mode 100644 index 00000000..0d921f84 --- /dev/null +++ b/docs/specs/glossary/occams-razor.md @@ -0,0 +1,12 @@ +--- +title: Occam's razor +iri: https://runedeck.ai/ns#OccamsRazor +kind: concept +status: defined +--- + +# Occam's razor + +Of two instructions that pass the same check, the shorter one wins. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/project.md b/docs/specs/glossary/project.md new file mode 100644 index 00000000..988a859c --- /dev/null +++ b/docs/specs/glossary/project.md @@ -0,0 +1,12 @@ +--- +title: project +iri: https://runedeck.ai/ns#project +kind: property +status: defined +--- + +# project + +On a record: the project it belongs to, a DOAP project such as the deck. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/projection.md b/docs/specs/glossary/projection.md new file mode 100644 index 00000000..91c4e971 --- /dev/null +++ b/docs/specs/glossary/projection.md @@ -0,0 +1,12 @@ +--- +title: projection +iri: https://runedeck.ai/ns#Projection +kind: class +status: defined +--- + +# projection + +A representation that drops information from its source. A search index stays uncommitted, and a snapshot of a source outside the repository is committed only with a provenance sidecar that names its upstream. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/proof.md b/docs/specs/glossary/proof.md new file mode 100644 index 00000000..c7cc2050 --- /dev/null +++ b/docs/specs/glossary/proof.md @@ -0,0 +1,12 @@ +--- +title: proof +iri: https://runedeck.ai/ns#Proof +kind: class +status: defined +--- + +# proof + +Evidence that a candidate commit shows the behavior a scenario states. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/provenance.md b/docs/specs/glossary/provenance.md new file mode 100644 index 00000000..5365cf6e --- /dev/null +++ b/docs/specs/glossary/provenance.md @@ -0,0 +1,12 @@ +--- +title: provenance +iri: https://runedeck.ai/ns#Provenance +kind: class +status: defined +--- + +# provenance + +The sealed statement of where an instance came from: its source and digest, the transforms applied, and the builder, kept in the sidecar as an in-toto statement. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/recorded.md b/docs/specs/glossary/recorded.md new file mode 100644 index 00000000..d032ec6b --- /dev/null +++ b/docs/specs/glossary/recorded.md @@ -0,0 +1,12 @@ +--- +title: recorded +iri: https://runedeck.ai/ns#recorded +kind: property +status: defined +--- + +# recorded + +The day the proof was recorded. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/related.md b/docs/specs/glossary/related.md new file mode 100644 index 00000000..944b14f4 --- /dev/null +++ b/docs/specs/glossary/related.md @@ -0,0 +1,12 @@ +--- +title: related +iri: https://runedeck.ai/ns#related +kind: property +status: defined +--- + +# related + +On every artifact: neighbor artifacts, by id and title. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/render.md b/docs/specs/glossary/render.md new file mode 100644 index 00000000..887e1b22 --- /dev/null +++ b/docs/specs/glossary/render.md @@ -0,0 +1,12 @@ +--- +title: render +iri: https://runedeck.ai/ns#Render +kind: class +status: defined +--- + +# render + +A committed representation that a command the repository carries produces from a plain-text source and that regenerates from it byte for byte. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/representation.md b/docs/specs/glossary/representation.md new file mode 100644 index 00000000..a98adcf6 --- /dev/null +++ b/docs/specs/glossary/representation.md @@ -0,0 +1,12 @@ +--- +title: representation +iri: https://runedeck.ai/ns#Representation +kind: class +status: defined +--- + +# representation + +One form an artifact's content takes. The plain-text representation is the authoritative one, and every other representation derives from it. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/requirement.md b/docs/specs/glossary/requirement.md new file mode 100644 index 00000000..723e9f0a --- /dev/null +++ b/docs/specs/glossary/requirement.md @@ -0,0 +1,12 @@ +--- +title: requirement +iri: https://runedeck.ai/ns#Requirement +kind: class +status: defined +--- + +# requirement + +One `### Requirement:` with MUST text, part of a capability. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/responsible.md b/docs/specs/glossary/responsible.md new file mode 100644 index 00000000..5d1b6706 --- /dev/null +++ b/docs/specs/glossary/responsible.md @@ -0,0 +1,12 @@ +--- +title: responsible +iri: https://runedeck.ai/ns#responsible +kind: property +status: defined +--- + +# responsible + +On a record: who does the work, the R of RACI, which MADR leaves out. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/routine.md b/docs/specs/glossary/routine.md new file mode 100644 index 00000000..d0abb402 --- /dev/null +++ b/docs/specs/glossary/routine.md @@ -0,0 +1,12 @@ +--- +title: routine +iri: https://runedeck.ai/ns#Routine +kind: class +status: defined +--- + +# routine + +An instruction installed on a harness account, which runs it on a schedule. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/rule.md b/docs/specs/glossary/rule.md new file mode 100644 index 00000000..1168a1dc --- /dev/null +++ b/docs/specs/glossary/rule.md @@ -0,0 +1,12 @@ +--- +title: rule +iri: https://runedeck.ai/ns#Rule +kind: class +status: defined +--- + +# rule + +An always-on instruction artifact under a rules/ tree. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/rune.md b/docs/specs/glossary/rune.md new file mode 100644 index 00000000..15aff560 --- /dev/null +++ b/docs/specs/glossary/rune.md @@ -0,0 +1,12 @@ +--- +title: rune +iri: https://runedeck.ai/ns#Rune +kind: class +status: defined +--- + +# rune + +An instruction that follows the rune template: one canon, optional sidecar, companions, and variants. A skill, a rule, or an agent is a rune when it does. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/scenario.md b/docs/specs/glossary/scenario.md new file mode 100644 index 00000000..14e10598 --- /dev/null +++ b/docs/specs/glossary/scenario.md @@ -0,0 +1,12 @@ +--- +title: scenario +iri: https://runedeck.ai/ns#Scenario +kind: class +status: defined +--- + +# scenario + +One `#### Scenario:` with WHEN and THEN, part of a requirement. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/scenes.md b/docs/specs/glossary/scenes.md new file mode 100644 index 00000000..6abe910d --- /dev/null +++ b/docs/specs/glossary/scenes.md @@ -0,0 +1,12 @@ +--- +title: scenes +iri: https://runedeck.ai/ns#scene +kind: property +status: defined +--- + +# scenes + +What the proof records about one scenario. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/sidecar.md b/docs/specs/glossary/sidecar.md new file mode 100644 index 00000000..41a2f939 --- /dev/null +++ b/docs/specs/glossary/sidecar.md @@ -0,0 +1,12 @@ +--- +title: sidecar +iri: https://runedeck.ai/ns#Sidecar +kind: class +status: defined +--- + +# sidecar + +A file beside a rune that holds what the canon does not: harness metadata, or sealed provenance under .provenance/. A sidecar never repeats a canon field. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/skill.md b/docs/specs/glossary/skill.md new file mode 100644 index 00000000..1738b720 --- /dev/null +++ b/docs/specs/glossary/skill.md @@ -0,0 +1,12 @@ +--- +title: skill +iri: https://runedeck.ai/ns#Skill +kind: class +status: defined +--- + +# skill + +A procedure artifact with a SKILL.md canon. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/status.md b/docs/specs/glossary/status.md new file mode 100644 index 00000000..65c56832 --- /dev/null +++ b/docs/specs/glossary/status.md @@ -0,0 +1,12 @@ +--- +title: status +iri: https://runedeck.ai/ns#status +kind: property +status: defined +--- + +# status + +On a record or a proposal: its asserted lifecycle status. The frontmatter is the authority, and a shape only catches a disagreement with the file's location. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/structure-schema.md b/docs/specs/glossary/structure-schema.md new file mode 100644 index 00000000..1aaa2940 --- /dev/null +++ b/docs/specs/glossary/structure-schema.md @@ -0,0 +1,12 @@ +--- +title: structure schema +iri: https://runedeck.ai/ns#StructureSchema +kind: class +status: defined +--- + +# structure schema + +The per-directory structural contract, serialized as .mdschema, that validates the files beside it. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/structured-architecture-decision-record.md b/docs/specs/glossary/structured-architecture-decision-record.md new file mode 100644 index 00000000..1eeccbba --- /dev/null +++ b/docs/specs/glossary/structured-architecture-decision-record.md @@ -0,0 +1,12 @@ +--- +title: structured architecture decision record +iri: https://runedeck.ai/ns#StructuredArchitectureDecisionRecord +kind: class +status: defined +--- + +# structured architecture decision record + +A markdown architecture decision record with YAML frontmatter for status, dates, RACI, and provenance: the runeADR format the deck's records under docs/decisions/ use. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/supersedes.md b/docs/specs/glossary/supersedes.md new file mode 100644 index 00000000..7881ca21 --- /dev/null +++ b/docs/specs/glossary/supersedes.md @@ -0,0 +1,12 @@ +--- +title: supersedes +iri: https://runedeck.ai/ns#supersedes +kind: property +status: defined +--- + +# supersedes + +On a record: the older record it replaces, which then states status superseded. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/tags.md b/docs/specs/glossary/tags.md new file mode 100644 index 00000000..c6d1657a --- /dev/null +++ b/docs/specs/glossary/tags.md @@ -0,0 +1,12 @@ +--- +title: tags +iri: https://runedeck.ai/ns#tags +kind: property +status: defined +--- + +# tags + +On every artifact: the topics it belongs to, as Obsidian tags. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/target.md b/docs/specs/glossary/target.md new file mode 100644 index 00000000..66d20a6e --- /dev/null +++ b/docs/specs/glossary/target.md @@ -0,0 +1,12 @@ +--- +title: target +iri: https://runedeck.ai/ns#Target +kind: class +status: defined +--- + +# target + +A directory one harness loads instructions from, and the destination a deployment writes into. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/template.md b/docs/specs/glossary/template.md new file mode 100644 index 00000000..83bf4e0b --- /dev/null +++ b/docs/specs/glossary/template.md @@ -0,0 +1,12 @@ +--- +title: template +iri: https://runedeck.ai/ns#Template +kind: class +status: defined +--- + +# template + +A file with placeholders that a renderer fills to produce another file. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/title.md b/docs/specs/glossary/title.md new file mode 100644 index 00000000..05e9703a --- /dev/null +++ b/docs/specs/glossary/title.md @@ -0,0 +1,12 @@ +--- +title: title +iri: https://runedeck.ai/ns#title +kind: property +status: defined +--- + +# title + +On every artifact: its human name. A rune canon says `name` instead, because Agent Skills owns that frontmatter. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/transcript.md b/docs/specs/glossary/transcript.md new file mode 100644 index 00000000..b80f5911 --- /dev/null +++ b/docs/specs/glossary/transcript.md @@ -0,0 +1,12 @@ +--- +title: transcript +iri: https://runedeck.ai/ns#transcript +kind: property +status: defined +--- + +# transcript + +The sha256 of proof.txt as the README states it. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/type.md b/docs/specs/glossary/type.md new file mode 100644 index 00000000..062c04c2 --- /dev/null +++ b/docs/specs/glossary/type.md @@ -0,0 +1,12 @@ +--- +title: type +iri: https://runedeck.ai/ns#type +kind: property +status: defined +--- + +# type + +On every artifact: its kind as the directory states it: skill, rule, agent, hook, routine, adr, or proof. A shape holds the list, so the key and the directory never disagree. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/updated.md b/docs/specs/glossary/updated.md new file mode 100644 index 00000000..010a2c35 --- /dev/null +++ b/docs/specs/glossary/updated.md @@ -0,0 +1,12 @@ +--- +title: updated +iri: https://runedeck.ai/ns#updated +kind: property +status: defined +--- + +# updated + +On every artifact: the date of its last change. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/upstream.md b/docs/specs/glossary/upstream.md new file mode 100644 index 00000000..4d41c80e --- /dev/null +++ b/docs/specs/glossary/upstream.md @@ -0,0 +1,12 @@ +--- +title: upstream +iri: https://runedeck.ai/ns#upstream +kind: property +status: defined +--- + +# upstream + +On every artifact: the sources it was adapted from, as links, with the origin first. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/validator.md b/docs/specs/glossary/validator.md new file mode 100644 index 00000000..3573afc6 --- /dev/null +++ b/docs/specs/glossary/validator.md @@ -0,0 +1,12 @@ +--- +title: validator +iri: https://runedeck.ai/ns#Validator +kind: class +status: defined +--- + +# validator + +An analysis tool that decides whether an artifact is valid against a grammar, a schema, or a set of shapes, and reports nothing about style. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/variant.md b/docs/specs/glossary/variant.md new file mode 100644 index 00000000..93ebaa1b --- /dev/null +++ b/docs/specs/glossary/variant.md @@ -0,0 +1,12 @@ +--- +title: variant +iri: https://runedeck.ai/ns#Variant +kind: class +status: defined +--- + +# variant + +A first-class alternative of a rune for one harness, one model, or one user, resolved at assembly: user, then harness and model, then harness, then the base rune. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/glossary/verdict.md b/docs/specs/glossary/verdict.md new file mode 100644 index 00000000..cb85bf61 --- /dev/null +++ b/docs/specs/glossary/verdict.md @@ -0,0 +1,12 @@ +--- +title: verdict +iri: https://runedeck.ai/ns#Verdict +kind: class +status: defined +--- + +# verdict + +The bench's judgment of one artifact against its baseline, from the metrics the bench measured. + +Generated from `ontology/rune.ttl`. Edit the ontology, not this page. diff --git a/docs/specs/spec-change-lifecycle/spec.md b/docs/specs/lifecycle-of-a-change/spec.md similarity index 98% rename from docs/specs/spec-change-lifecycle/spec.md rename to docs/specs/lifecycle-of-a-change/spec.md index f8959132..8e086f39 100644 --- a/docs/specs/spec-change-lifecycle/spec.md +++ b/docs/specs/lifecycle-of-a-change/spec.md @@ -2,7 +2,7 @@ ## Purpose -Define the repository roots, the artifact dialect, and the validation behavior of `rune spec`. Conversion between the native root and an `openspec/` tree, and the recovery of interrupted mutations, are in `openspec-tree-interop`. +Define the repository roots, the artifact dialect, and the validation behavior of `rune spec`. Conversion between the native root and an `openspec/` tree, and the recovery of interrupted mutations, are in `openspec-trees-full-compatibility`. ## Requirements diff --git a/docs/specs/model-commit-attribution/spec.md b/docs/specs/model-identity-on-commits/spec.md similarity index 100% rename from docs/specs/model-commit-attribution/spec.md rename to docs/specs/model-identity-on-commits/spec.md diff --git a/docs/specs/openspec-tree-interop/spec.md b/docs/specs/openspec-trees-full-compatibility/spec.md similarity index 100% rename from docs/specs/openspec-tree-interop/spec.md rename to docs/specs/openspec-trees-full-compatibility/spec.md diff --git a/docs/specs/adoption-session-state/spec.md b/docs/specs/record-provenance-for-adoptions/spec.md similarity index 100% rename from docs/specs/adoption-session-state/spec.md rename to docs/specs/record-provenance-for-adoptions/spec.md diff --git a/docs/walkthroughs/Adr.md b/docs/walkthroughs/Adr.md index 1ba9e7c7..fc638f86 100644 --- a/docs/walkthroughs/Adr.md +++ b/docs/walkthroughs/Adr.md @@ -1,6 +1,6 @@ # ADR Walkthrough -Design spec and future review session for `rune adr` (not yet implemented, this document is the contract the implementation must satisfy). Full design: docs/changes/docs-todo-adr/design.md. +Design spec and future review session for `rune adr` (not yet implemented, this document is the contract the implementation must satisfy). Full design: docs/changes/docs-todos-and-records/design.md. ## Format diff --git a/docs/walkthroughs/Spec.md b/docs/walkthroughs/Spec.md index 01a7ce50..3349fc6d 100644 --- a/docs/walkthroughs/Spec.md +++ b/docs/walkthroughs/Spec.md @@ -80,16 +80,18 @@ A missing executable produces no finding. A failure or timeout is advisory and d - Every normative statement uses `MUST`. A prose line with `SHALL` fails with `spec-shall-keyword`. - A canonical specification stays under 150 lines, or it splits into capabilities. A delta over the limit warns. -- A term marked in *italics* is an ontology term: its `rdfs:label` in `ontology/rune.ttl` (or the file `deck.yaml` names under `ontology`). A plain plural matches its singular. Without an ontology, a `- **term**: definition` entry in `docs/specs/glossary.md` serves. -- The first italic use of a term in a file cites it, `*term* [TAG]`, and the file defines `[TAG]: <iri>`. `rune spec glossary` prints every term with its tag and definition line. -- `rune spec glossary --write` renders the same list into `docs/specs/glossary.md` with a header, so a reader of a specification is one hop from every definition. `--check` compares the file with the ontology, names each term that differs, and exits 1 on drift or a missing file. Both take an optional PATH relative to the source root. +- A defined term is a link to its glossary page: `*[term][TAG]*` at the first use in a file, `[term][TAG]` after it, and `[TAG]: <relative page path>` at the end of the file. The validator resolves the link through the page's `iri` and matches it to a term in `ontology/rune.ttl` (or the file `deck.yaml` names under `ontology`). Plain italics are emphasis. Without an ontology there are no terms. +- A link that names no term fails (`spec-term-undefined`), a draft term warns (`spec-term-draft`), a first use that is not emphasized or a later use that is fails (`spec-term-first-use`), a missing or stale page fails (`spec-term-page`), and the old `*term* [TAG]` form with a definition that names the term address warns with the new form (`spec-term-legacy`). +- `rune spec glossary` prints every term with its tag and page definition. `rune spec glossary --write` writes one page per term and an index under `docs/specs/glossary/`, so a term link reaches a page a reader can open. `--check` regenerates every page in memory, names each page that is missing, extra, or changed, and exits 1 on drift or a missing directory. Both take an optional PATH relative to the source root. +- `rune spec fix --terms` adds a draft entry to the ontology for every link that names no term and writes its page. `rune spec fix --links` rewrites the old form to links in every Markdown file. Without a flag it runs both. `rune spec archive` refuses a change whose files link to a draft term or to no term. ```sh rune spec validate # house rules run with the structural checks rune spec doctor # the same findings beside relationship health -rune spec glossary # every term, its tag, and the definition line to paste -rune spec glossary --write # write docs/specs/glossary.md from the ontology -rune spec glossary --check # exit 1 when the file drifted from the ontology +rune spec glossary # every term, its tag, and the page definition to paste +rune spec glossary --write # write docs/specs/glossary/ from the ontology +rune spec glossary --check # exit 1 when a page drifted from the ontology +rune spec fix # draft the unknown terms and convert the old form ``` A consumer has no `deck.yaml`, so it reads the deck's ontology through a deployed copy. Name the source under `ontology` in `.rune`: @@ -103,7 +105,7 @@ sources: ontology: deck ``` -`rune install` copies `<source>/ontology/rune.ttl`, or the path the source's `deck.yaml` names under `ontology`, to `ontology/rune.ttl` at the consumer root, with its fingerprint in `ontology/.manifest` and its sidecar at `ontology/.provenance/rune.ttl.yaml`. `rune drift` and `rune status` report a copy that left that record, and a hand-edited copy is skipped on the next install unless `--force` is passed. A source with no ontology file fails the install before any provider tree is written. +`rune install` copies `<source>/ontology/rune.ttl`, or the path the source's `deck.yaml` names under `ontology`, to `ontology/rune.ttl` at the consumer root, with its fingerprint in `ontology/.manifest` and its sidecar at `ontology/.provenance/rune.ttl.yaml`. `rune drift` and `rune status` report a copy that left that record, and a hand-edited copy is skipped on the next install unless `--force` is passed. A source with no ontology file fails the install before any provider tree is written. After the install, run `rune spec glossary --write` and commit the pages, or every term link fails with the page diagnostic. ## Review checklist @@ -112,7 +114,7 @@ ontology: deck - [ ] Import and export restore unknown text and binary artifacts byte for byte. - [ ] `rune spec validate --json` retains every diagnostic field, including `null` values. - [ ] Completed archive and import retries preserve results without extra writes. -- [ ] A `SHALL` line, a 151-line specification, and an undefined italic term each fail validation with the named rule. +- [ ] A `SHALL` line, a 151-line specification, and a term link that names no term each fail validation with the named rule. The executable fixtures and recovery checks are in [Manual Testing](../Manual Testing.md#spec). diff --git a/docs/walkthroughs/Todo.md b/docs/walkthroughs/Todo.md index cc96a845..798503c9 100644 --- a/docs/walkthroughs/Todo.md +++ b/docs/walkthroughs/Todo.md @@ -1,6 +1,6 @@ # Todo Walkthrough -Design spec and future review session for `rune todo` (not yet implemented, this document is the contract the implementation must satisfy). Full design: docs/changes/docs-todo-adr/design.md. +Design spec and future review session for `rune todo` (not yet implemented, this document is the contract the implementation must satisfy). Full design: docs/changes/docs-todos-and-records/design.md. ## Format diff --git a/rune-docs/src/spec/commands.rs b/rune-docs/src/spec/commands.rs index 55dce07d..9d65c6e1 100644 --- a/rune-docs/src/spec/commands.rs +++ b/rune-docs/src/spec/commands.rs @@ -5,13 +5,15 @@ use super::tasks::{parse_tasks, read_tasks}; use super::templates::{DELTA_SPEC_TEMPLATE, DESIGN_TEMPLATE, PROPOSAL_TEMPLATE, TASKS_TEMPLATE}; +use super::term_links::{Resolution, markdown_files, resolve_document}; +use super::terms::{TermSource, Terms}; use super::transaction::{self, FileWrite, Operation}; use super::{ - ChangeState, ChangeSummary, MergeSummary, SpecificationSummary, archived_change_exists, - archived_change_path, archived_change_status, build_merge_plans, changes_root, - discover_capabilities, io_error, load_with_override, parse_canonical, read, read_directories, - relative_display, resolve_spec_root, specs_root, state_label, strip_frontmatter, substitute, - validate_capability, validate_change_identifier, + ChangeState, ChangeSummary, MergeSummary, SpecRoot, SpecificationSummary, + archived_change_exists, archived_change_path, archived_change_status, build_merge_plans, + changes_root, discover_capabilities, io_error, load_with_override, parse_canonical, read, + read_directories, relative_display, resolve_spec_root, specs_root, state_label, + strip_frontmatter, substitute, validate_capability, validate_change_identifier, }; use crate::error::{Error, ErrorKind}; use chrono::Utc; @@ -417,10 +419,50 @@ pub fn archive_output( if abandon { archive_abandoned(root, id, &change_dir, &archive_dir, &mut transaction) } else { + refuse_unsettled_terms(&spec_root, id, &change_dir)?; archive_merged(root, id, yes, &change_dir, &archive_dir, &mut transaction) } } +/// Archive refuses a change whose files link to a draft term or to no +/// term, naming each link, so nothing undefined reaches the canonical +/// specifications. A repository without an ontology has no terms. +fn refuse_unsettled_terms(spec_root: &SpecRoot, id: &str, change_dir: &Path) -> Result<(), Error> { + let terms = Terms::load(spec_root.repository(), spec_root.specifications())?; + if terms.source() != TermSource::Ontology { + return Ok(()); + } + let mut lines = Vec::new(); + for path in markdown_files(change_dir, &[])? { + let content = read(&path)?; + let display = relative_display(spec_root.repository(), &path); + for (link, resolution) in resolve_document(&content, &path, &terms) { + match resolution { + Resolution::Term { term, .. } if term.draft => lines.push(format!( + " draft '{}' as [{}][{}]: {display}:{}", + term.label, link.text, link.tag, link.line + )), + Resolution::Undefined { .. } => lines.push(format!( + " undefined [{}][{}]: {display}:{}", + link.text, link.tag, link.line + )), + _ => {} + } + } + } + if lines.is_empty() { + return Ok(()); + } + Err(Error::new( + ErrorKind::Validate, + format!( + "change '{id}' links to terms that are not settled:\n{}\ndefine each term in {} (a draft comes from `rune spec fix --terms`), add any missing definition, and rerun", + lines.join("\n"), + terms.location() + ), + )) +} + fn archive_abandoned( root: &Path, id: &str, diff --git a/rune-docs/src/spec/fix.rs b/rune-docs/src/spec/fix.rs new file mode 100644 index 00000000..4740f886 --- /dev/null +++ b/rune-docs/src/spec/fix.rs @@ -0,0 +1,604 @@ +//! `rune spec fix`: the fixer. It edits what the validator only reports. +//! `--terms` adds a draft entry to the ontology for every term link that +//! names no term and writes the glossary pages. `--links` rewrites the +//! legacy `*term* [TAG]` form to the link form, emphasizes the first use +//! of each term in a file and no later use, points every definition at the +//! term's page, and adds a definition a known tag lacks. Both passes are +//! idempotent: a second run changes nothing. + +use super::commands::print_json; +use super::glossary::write_glossary_output; +use super::prose::{prose_lines, without_bom}; +use super::term_links::{ + Definition, Resolution, Target, TermLink, Via, classify, is_definition, legacy_links, + markdown_files, reference_definitions, reference_links, relative_link, resolve, + resolve_document, +}; +use super::terms::{Term, TermSource, Terms, kebab, ontology_path, upper_camel}; +use super::{Error, ErrorKind, read, relative_display, resolve_spec_root}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +/// What `rune spec fix` did. +#[derive(Debug, Serialize)] +pub struct FixOutput { + pub ontology: String, + pub glossary: String, + pub drafts: Vec<DraftTerm>, + pub skipped: Vec<SkippedTerm>, + /// Files `--links` rewrote, repository-relative. + pub rewritten: Vec<String>, + /// Whether the glossary pages were regenerated. + pub pages_written: bool, +} + +/// One draft entry `--terms` added. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DraftTerm { + pub label: String, + pub name: String, + pub page: String, + /// `path:line` of the link that introduced the term. + pub origin: String, +} + +/// One unresolved link `--terms` left alone, with the reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SkippedTerm { + pub text: String, + pub tag: String, + pub origin: String, + pub reason: String, +} + +/// Run the fixer without printing. +pub fn fix_output(source: &str, terms: bool, links: bool) -> Result<FixOutput, Error> { + let spec_root = resolve_spec_root(Path::new(source))?; + let repository = spec_root.repository(); + let mut vocabulary = Terms::load(repository, spec_root.specifications())?; + if vocabulary.source() != TermSource::Ontology { + return Err(Error::new( + ErrorKind::Config, + format!( + "no ontology at {}; the fixer adds terms to the ontology", + relative_display(repository, &ontology_path(repository)?) + ), + )); + } + let ontology = ontology_path(repository)?; + let mut output = FixOutput { + ontology: relative_display(repository, &ontology), + glossary: relative_display(repository, vocabulary.glossary_dir()), + drafts: Vec::new(), + skipped: Vec::new(), + rewritten: Vec::new(), + pages_written: false, + }; + let archive = spec_root.changes().join("archive"); + let files = markdown_files(repository, &[vocabulary.glossary_dir(), &archive])?; + if links { + for path in &files { + let content = read(path)?; + if let Some(rewritten) = migrate_links(&content, path, &vocabulary) { + write(path, &rewritten)?; + output.rewritten.push(relative_display(repository, path)); + } + } + } + if terms { + draft_terms( + source, + &spec_root, + &ontology, + &files, + &mut vocabulary, + &mut output, + )?; + } + Ok(output) +} + +/// The `--terms` pass: a draft entry per term link that names no term, +/// then the glossary pages. +fn draft_terms( + source: &str, + spec_root: &super::SpecRoot, + ontology: &Path, + files: &[std::path::PathBuf], + vocabulary: &mut Terms, + output: &mut FixOutput, +) -> Result<(), Error> { + let repository = spec_root.repository(); + let mut blocks = String::new(); + let mut drafted: BTreeSet<String> = BTreeSet::new(); + for path in files { + let content = read(path)?; + for unresolved in unresolved_links(&content, path, vocabulary) { + let link = unresolved.link; + let origin = format!("{}:{}", relative_display(repository, path), link.line); + let label = draft_label(&link.text, unresolved.page.as_deref()); + let name = upper_camel(&label); + let reason = if !unresolved.draftable { + Some(unresolved.reason) + } else if !name.chars().next().is_some_and(char::is_alphabetic) { + Some("the link text gives no local name".to_string()) + } else if vocabulary.has_name(&name) { + Some(format!( + "a term named {name} exists; point the definition at its page" + )) + } else { + None + }; + if let Some(reason) = reason { + output.skipped.push(SkippedTerm { + text: link.text, + tag: link.tag, + origin, + reason, + }); + continue; + } + if !drafted.insert(name.to_lowercase()) { + continue; + } + let Some(block) = vocabulary.draft_block(&name, &label, &origin) else { + continue; + }; + blocks.push_str(&block); + output.drafts.push(DraftTerm { + label, + name, + page: String::new(), + origin, + }); + } + } + if blocks.is_empty() { + return Ok(()); + } + let mut text = read(ontology)?; + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(&blocks); + write(ontology, &text)?; + *vocabulary = Terms::load(repository, spec_root.specifications())?; + let (_, namespace) = vocabulary.primary_prefix().unwrap_or_default(); + for draft in &mut output.drafts { + draft.page = vocabulary + .by_iri(&format!("{namespace}{}", draft.name)) + .map(|term: &Term| vocabulary.page_name(term).to_string()) + .unwrap_or_default(); + } + write_glossary_output(source, None)?; + output.pages_written = true; + Ok(()) +} + +/// Run the fixer and print what it did. +pub fn fix(source: &str, terms: bool, links: bool, json: bool) -> Result<i32, Error> { + let output = fix_output(source, terms, links)?; + if json { + print_json(&output)?; + } else { + print!("{}", render_fix(&output)); + } + Ok(0) +} + +pub fn render_fix(output: &FixOutput) -> String { + use std::fmt::Write as _; + let mut text = String::new(); + for path in &output.rewritten { + let _ = writeln!(text, "rewrote {path}"); + } + for draft in &output.drafts { + let _ = writeln!( + text, + "added draft '{}' as {} ({}/{}) from {}", + draft.label, draft.name, output.glossary, draft.page, draft.origin + ); + } + for skipped in &output.skipped { + let _ = writeln!( + text, + "skipped '{}' [{}] at {}: {}", + skipped.text, skipped.tag, skipped.origin, skipped.reason + ); + } + if output.pages_written { + let _ = writeln!(text, "wrote {} and {}", output.ontology, output.glossary); + } + if text.is_empty() { + text.push_str("nothing to fix\n"); + } + text +} + +fn write(path: &Path, content: &str) -> Result<(), Error> { + crate::support::write_atomic(path, content) + .map_err(|message| Error::new(ErrorKind::Io, message)) +} + +/// The prose view of a document as owned lines, keyed by line number. +fn prose(content: &str) -> BTreeMap<usize, String> { + prose_lines(content).collect() +} + +fn definitions_of(prose: &BTreeMap<usize, String>) -> BTreeMap<String, Definition> { + reference_definitions(prose.iter().map(|(number, line)| (*number, line.as_str()))) +} + +/// Rewrite one document to the link model. `None` when nothing changes. +pub(super) fn migrate_links(content: &str, path: &Path, terms: &Terms) -> Option<String> { + let bom = content.len() - without_bom(content).len(); + let body = without_bom(content); + let trailing_newline = body.ends_with('\n'); + let file_dir = path.parent().unwrap_or(Path::new("")); + let mut lines: Vec<String> = body.lines().map(str::to_string).collect(); + + // Definitions: an IRI that names a term becomes the term's page. + let view = prose(&join(&lines)); + for definition in definitions_of(&view).values() { + if let Target::Iri(iri) = classify(Some(definition), file_dir, terms) + && let Some(term) = terms.by_iri(&iri) + { + let relative = relative_link(file_dir, &terms.page_path(term)); + let line = &mut lines[definition.line - 1]; + line.replace_range(definition.target_start..definition.target_end, &relative); + } + } + + // The legacy form becomes a link when its tag reaches a term. + let view = prose(&join(&lines)); + let definitions = definitions_of(&view); + for (number, line) in &view { + for link in legacy_links(*number, line).into_iter().rev() { + if matches!( + resolve(terms, &link, &definitions, file_dir), + Resolution::Term { .. } + ) { + lines[number - 1].replace_range(link.start..link.end, &link.render(false)); + } + } + } + + // The first use of each term is emphasized and no later use is. + let view = prose(&join(&lines)); + let definitions = definitions_of(&view); + let mut seen: BTreeSet<String> = BTreeSet::new(); + let mut missing: Vec<(String, String)> = Vec::new(); + let mut known: BTreeSet<String> = BTreeSet::new(); + for (number, line) in &view { + let mut edits = Vec::new(); + for link in reference_links(*number, line) { + let Resolution::Term { term, via, .. } = resolve(terms, &link, &definitions, file_dir) + else { + continue; + }; + let first = seen.insert(term.iri.clone()); + if link.emphasized != first { + edits.push((link.start, link.end, link.render(first))); + } + if via == Via::NoDefinition && known.insert(link.tag.to_uppercase()) { + missing.push(( + link.tag.clone(), + relative_link(file_dir, &terms.page_path(term)), + )); + } + } + for (start, end, replacement) in edits.into_iter().rev() { + lines[number - 1].replace_range(start..end, &replacement); + } + } + + // A known tag without a definition gets one at the end of the file. + let appended = !missing.is_empty(); + if appended { + while lines.last().is_some_and(|line| line.trim().is_empty()) { + lines.pop(); + } + if !lines.last().is_some_and(|line| is_definition(line)) { + lines.push(String::new()); + } + for (tag, relative) in missing { + lines.push(format!("[{tag}]: {relative}")); + } + } + + let mut result = String::with_capacity(content.len()); + result.push_str(&content[..bom]); + result.push_str(&join(&lines)); + if trailing_newline || appended { + result.push('\n'); + } + (result != content).then_some(result) +} + +fn join(lines: &[String]) -> String { + lines.join("\n") +} + +/// One link of a document that names no term. +pub(super) struct Unresolved { + pub link: TermLink, + /// The glossary page its definition names, when it names one. + pub page: Option<std::path::PathBuf>, + /// Whether the fixer may draft the term. + pub draftable: bool, + pub reason: String, +} + +/// The links of one document that name no term. +pub(super) fn unresolved_links(content: &str, path: &Path, terms: &Terms) -> Vec<Unresolved> { + resolve_document(content, path, terms) + .into_iter() + .filter_map(|(link, resolution)| match resolution { + Resolution::Undefined { + page, + draftable, + reason, + .. + } => Some(Unresolved { + link, + page, + draftable, + reason, + }), + _ => None, + }) + .collect() +} + +/// The label of a draft: the link text as written, unless the definition +/// names a page whose stem is not the text's (a plural link text such as +/// `[gizmos][GIZMO]` over `gizmo.md`), in which case the page stem names +/// the term so the link resolves once the page exists. +fn draft_label(text: &str, page: Option<&Path>) -> String { + let text = text.trim(); + let stem = page + .and_then(Path::file_stem) + .map(|stem| stem.to_string_lossy().into_owned()); + match stem { + Some(stem) if !stem.is_empty() && kebab(text) != stem => stem.replace('-', " "), + _ => text.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::super::glossary::DRAFT_COMMENT; + use super::super::terms::tests::TURTLE; + use super::*; + + fn repository() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("ontology")).unwrap(); + std::fs::create_dir_all(root.path().join("docs/specs")).unwrap(); + std::fs::create_dir_all(root.path().join("docs/decisions")).unwrap(); + std::fs::write(root.path().join("ontology/rune.ttl"), TURTLE).unwrap(); + write_glossary_output(root.path().to_str().unwrap(), None).unwrap(); + root + } + + fn source(root: &tempfile::TempDir) -> &str { + root.path().to_str().unwrap() + } + + #[test] + fn links_migrates_the_legacy_form_emphasis_and_definitions_once() { + let root = repository(); + let record = root.path().join("docs/decisions/CLI-0001 Example.md"); + std::fs::write( + &record, + "\u{FEFF}# Example\n\nEvery *change* [CHANGE] has a *lifecycle stage* [STAGE]. A *[change][CHANGE]* again.\nThe [tight][TIGHT] one, then *[tight][TIGHT]*. Plain *emphasis* stays.\n\n[CHANGE]: https://runedeck.ai/ns#Change\n[STAGE]: <https://runedeck.ai/ns#Stage>\n", + ) + .unwrap(); + + let output = fix_output(source(&root), false, true).unwrap(); + assert_eq!(output.rewritten, ["docs/decisions/CLI-0001 Example.md"]); + assert!(output.drafts.is_empty()); + let migrated = std::fs::read_to_string(&record).unwrap(); + assert_eq!( + migrated, + "\u{FEFF}# Example\n\nEvery *[change][CHANGE]* has a *[lifecycle stage][STAGE]*. A [change][CHANGE] again.\nThe *[tight][TIGHT]* one, then [tight][TIGHT]. Plain *emphasis* stays.\n\n[CHANGE]: ../specs/glossary/change.md\n[STAGE]: ../specs/glossary/lifecycle-stage.md\n[TIGHT]: ../specs/glossary/tight.md\n" + ); + + let again = fix_output(source(&root), false, true).unwrap(); + assert!(again.rewritten.is_empty(), "{again:?}"); + assert_eq!(std::fs::read_to_string(&record).unwrap(), migrated); + } + + #[test] + fn links_leaves_ordinary_links_unknown_tags_and_fenced_code_alone() { + let root = repository(); + let record = root.path().join("docs/decisions/X.md"); + let content = "# X\n\nSee [the readme][README] and *ghost* [GHOST].\n\n```md\nEvery *change* [CHANGE] here.\n```\n\n[README]: ../../README.md\n[GHOST]: https://runedeck.ai/ns#Ghost\n"; + std::fs::write(&record, content).unwrap(); + let output = fix_output(source(&root), false, true).unwrap(); + assert!(output.rewritten.is_empty(), "{output:?}"); + assert_eq!(std::fs::read_to_string(&record).unwrap(), content); + } + + #[test] + fn terms_adds_a_draft_writes_its_page_and_is_idempotent() { + let root = repository(); + let record = root.path().join("docs/decisions/X.md"); + std::fs::write( + &record, + "# X\n\nA *[gadget][GADGET]* and a *[Change][CHANGE]* and [gadget][GADGET] twice; the *[sprocket wheel][SPROCKETWHEEL]* too.\n\n[GADGET]: ../specs/glossary/gadget.md\n[CHANGE]: ../specs/glossary/change.md\n[SPROCKETWHEEL]: ../specs/glossary/sprocket-wheel.md\n", + ) + .unwrap(); + + let output = fix_output(source(&root), true, false).unwrap(); + assert_eq!( + output.drafts, + [ + DraftTerm { + label: "gadget".into(), + name: "Gadget".into(), + page: "gadget.md".into(), + origin: "docs/decisions/X.md:3".into(), + }, + DraftTerm { + label: "sprocket wheel".into(), + name: "SprocketWheel".into(), + page: "sprocket-wheel.md".into(), + origin: "docs/decisions/X.md:3".into(), + }, + ] + ); + assert!(output.skipped.is_empty(), "{output:?}"); + assert!(output.pages_written); + let ontology = std::fs::read_to_string(root.path().join("ontology/rune.ttl")).unwrap(); + assert!(ontology.ends_with( + "# Draft term. The fixer added it from docs/decisions/X.md:3; define it or remove the link.\nrune:SprocketWheel a rdfs:Class ;\n rdfs:label \"sprocket wheel\" ;\n rune:status \"draft\" .\n" + )); + let page = + std::fs::read_to_string(root.path().join("docs/specs/glossary/gadget.md")).unwrap(); + assert!(page.contains("status: draft\n")); + assert!(page.contains(DRAFT_COMMENT)); + assert_eq!( + render_fix(&output), + "added draft 'gadget' as Gadget (docs/specs/glossary/gadget.md) from docs/decisions/X.md:3\nadded draft 'sprocket wheel' as SprocketWheel (docs/specs/glossary/sprocket-wheel.md) from docs/decisions/X.md:3\nwrote ontology/rune.ttl and docs/specs/glossary\n" + ); + + let again = fix_output(source(&root), true, true).unwrap(); + assert!(again.drafts.is_empty(), "{again:?}"); + assert!(!again.pages_written); + assert_eq!(render_fix(&again), "nothing to fix\n"); + } + + #[test] + fn terms_skips_a_link_whose_name_exists_and_a_page_with_a_foreign_iri() { + let root = repository(); + let record = root.path().join("docs/decisions/X.md"); + std::fs::write( + root.path().join("docs/specs/glossary/foreign.md"), + "---\ntitle: foreign\niri: https://example.org/Foreign\n---\n\n# foreign\n", + ) + .unwrap(); + std::fs::write( + &record, + "# X\n\nA *[stage][STAGEX]* and a *[foreign][FOREIGN]*.\n\n[STAGEX]: ../specs/glossary/stage.md\n[FOREIGN]: ../specs/glossary/foreign.md\n", + ) + .unwrap(); + let output = fix_output(source(&root), true, false).unwrap(); + assert!(output.drafts.is_empty(), "{output:?}"); + let reasons: Vec<_> = output + .skipped + .iter() + .map(|s| (s.text.as_str(), s.reason.as_str())) + .collect(); + assert_eq!( + reasons, + [ + ( + "stage", + "a term named Stage exists; point the definition at its page" + ), + ( + "foreign", + "its page names https://example.org/Foreign, which is not a term in ontology/rune.ttl" + ), + ] + ); + assert!(!output.pages_written); + } + + #[test] + fn terms_names_a_plural_draft_after_the_page_its_definition_names() { + let root = repository(); + let record = root.path().join("docs/decisions/Plural.md"); + std::fs::write( + &record, + "# Plural\n\nTwo *[gizmos][GIZMO]* and *[Murphy's law][MURPHYSLAW]*.\n\n[GIZMO]: ../specs/glossary/gizmo.md\n[MURPHYSLAW]: ../specs/glossary/murphys-law.md\n", + ) + .unwrap(); + + let output = fix_output(source(&root), true, false).unwrap(); + let drafts: Vec<_> = output + .drafts + .iter() + .map(|draft| { + ( + draft.label.as_str(), + draft.name.as_str(), + draft.page.as_str(), + ) + }) + .collect(); + assert_eq!( + drafts, + [ + ("gizmo", "Gizmo", "gizmo.md"), + ("Murphy's law", "MurphysLaw", "murphys-law.md"), + ], + "{output:?}" + ); + assert!(output.skipped.is_empty(), "{output:?}"); + let vocabulary = Terms::load(root.path(), &root.path().join("docs/specs")).unwrap(); + assert!( + unresolved_links( + &std::fs::read_to_string(&record).unwrap(), + &record, + &vocabulary + ) + .is_empty(), + "the plural link resolves through the page the draft created" + ); + + let again = fix_output(source(&root), true, false).unwrap(); + assert_eq!(render_fix(&again), "nothing to fix\n"); + } + + #[test] + fn terms_skips_a_tag_without_a_definition_and_names_the_line_to_add() { + let root = repository(); + let record = root.path().join("docs/decisions/Dangling.md"); + std::fs::write(&record, "# Dangling\n\nA [phantom][PHANTOM] here.\n").unwrap(); + + let output = fix_output(source(&root), true, true).unwrap(); + assert!(output.drafts.is_empty(), "{output:?}"); + assert!(output.rewritten.is_empty(), "{output:?}"); + assert_eq!( + render_fix(&output), + "skipped 'phantom' [PHANTOM] at docs/decisions/Dangling.md:3: the tag has no definition in this file and no term has the tag\n" + ); + } + + #[test] + fn the_walk_skips_generated_deployed_and_archived_trees() { + let root = repository(); + const LEGACY: &str = "*change* [CHANGE]\n\n[CHANGE]: https://runedeck.ai/ns#Change\n"; + for directory in [ + "docs/changes/archive/x", + "build", + ".trash", + "docs/specs/glossary", + ".claude/rules", + "docs/decisions/archive", + ] { + std::fs::create_dir_all(root.path().join(directory)).unwrap(); + std::fs::write(root.path().join(directory).join("a.md"), LEGACY).unwrap(); + } + // A deployed tree carries a manifest; only the change archive is an + // archive. + std::fs::write(root.path().join(".claude/.manifest"), "{}\n").unwrap(); + let output = fix_output(source(&root), false, true).unwrap(); + assert_eq!( + output.rewritten, + ["docs/decisions/archive/a.md"], + "{output:?}" + ); + } + + #[test] + fn a_repository_without_an_ontology_is_an_error() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("docs/specs")).unwrap(); + let error = fix_output(source(&root), true, true).unwrap_err(); + assert!(error.message().contains("no ontology at ontology/rune.ttl")); + } +} diff --git a/rune-docs/src/spec/glossary.rs b/rune-docs/src/spec/glossary.rs new file mode 100644 index 00000000..b06b0ef4 --- /dev/null +++ b/rune-docs/src/spec/glossary.rs @@ -0,0 +1,652 @@ +//! `rune spec glossary`: the term list, and the generated glossary pages +//! that term links point at: one page per term plus an index, under +//! `docs/specs/glossary/`. The ontology is the only source. People edit +//! it, never the pages, and `--check` regenerates every page in memory and +//! compares the bytes, so a hand edit is caught. + +use super::commands::print_json; +use super::terms::{GLOSSARY_FILE, Term, TermKind, TermSource, Terms, ontology_path}; +use super::{Error, ErrorKind, relative_display, resolve_spec_root}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// The paragraph a draft term's page carries instead of a comment. +pub const DRAFT_COMMENT: &str = "Draft term. The fixer added it and nobody has defined it yet. Archive refuses a change that links to it."; +/// The index file of the glossary directory. +pub const INDEX_FILE: &str = "index.md"; +/// The command the index and a drift report tell the reader to run. +const WRITE_COMMAND: &str = "rune spec glossary --write"; + +/// The one paragraph a page and the index show for a term. +pub(super) fn page_comment(term: &Term) -> &str { + if term.draft { + DRAFT_COMMENT + } else { + &term.comment + } +} + +/// The bytes of one term's page. +pub(super) fn render_page(terms: &Terms, term: &Term) -> String { + format!( + "---\ntitle: {label}\niri: {iri}\nkind: {kind}\nstatus: {status}\n---\n\n# {label}\n\n{comment}\n\nGenerated from `{location}`. Edit the ontology, not this page.\n", + label = term.label, + iri = term.iri, + kind = term.kind.name(), + status = if term.draft { "draft" } else { "defined" }, + comment = page_comment(term), + location = terms.location(), + ) +} + +/// The bytes of the index: one entry per term, sorted by label. +pub(super) fn render_index(terms: &Terms) -> String { + use std::fmt::Write as _; + let mut text = format!( + "# Glossary\n\nGenerated from `{}`. Edit the ontology, not these pages.\n\n", + terms.location() + ); + for term in terms.terms() { + let _ = writeln!( + text, + "- [{}]({}): {}", + term.label, + terms.page_name(term), + page_comment(term) + ); + } + text +} + +/// Every file of the glossary directory by file name: the pages and the index. +pub(super) fn render_pages(terms: &Terms) -> BTreeMap<String, String> { + let mut files: BTreeMap<String, String> = terms + .terms() + .iter() + .map(|term| (terms.page_name(term).to_string(), render_page(terms, term))) + .collect(); + files.insert(INDEX_FILE.to_string(), render_index(terms)); + files +} + +/// The term list `rune spec glossary` prints, and its `--json` form. +#[derive(Debug, Serialize)] +pub struct GlossaryOutput { + pub source: String, + /// Repository-relative path of the glossary directory. + pub glossary: String, + pub terms: Vec<GlossaryTerm>, +} + +#[derive(Debug, Serialize)] +pub struct GlossaryTerm { + pub label: String, + pub comment: String, + pub name: String, + pub iri: String, + pub kind: TermKind, + pub tag: String, + /// The page's file name under the glossary directory. + pub page: String, + pub status: &'static str, +} + +impl GlossaryOutput { + fn from_terms(repository: &Path, terms: &Terms) -> Self { + Self { + source: terms.location().to_string(), + glossary: relative_display(repository, terms.glossary_dir()), + terms: terms + .terms() + .iter() + .map(|term| GlossaryTerm { + label: term.label.clone(), + comment: page_comment(term).to_string(), + name: term.name.clone(), + iri: term.iri.clone(), + kind: term.kind, + tag: terms.tag(term).to_string(), + page: terms.page_name(term).to_string(), + status: if term.draft { "draft" } else { "defined" }, + }) + .collect(), + } + } +} + +pub fn glossary_output(repository: &Path, specifications: &Path) -> Result<GlossaryOutput, Error> { + let terms = Terms::load(repository, specifications)?; + Ok(GlossaryOutput::from_terms(repository, &terms)) +} + +/// Print the term list, or its JSON form. +pub fn glossary(source: &str, json: bool) -> Result<i32, Error> { + let spec_root = resolve_spec_root(Path::new(source))?; + let output = glossary_output(spec_root.repository(), spec_root.specifications())?; + if json { + print_json(&output)?; + } else { + print!("{}", render_glossary(&output)); + } + Ok(0) +} + +/// The term list: one `- **label**: comment [TAG]` line per term, then the +/// `[TAG]: <page>` definitions with repository-relative page paths. A +/// document that copies them makes the paths relative to itself. +pub fn render_glossary(output: &GlossaryOutput) -> String { + let mut lines: Vec<String> = output.terms.iter().map(entry_line).collect(); + if !output.terms.is_empty() { + lines.push(String::new()); + lines.extend( + output + .terms + .iter() + .map(|term| format!("[{}]: {}/{}", term.tag, output.glossary, term.page)), + ); + } + let mut text = lines.join("\n"); + text.push('\n'); + text +} + +/// One `- **label**: comment [TAG]` line. A term without a comment keeps +/// one space before its tag. +fn entry_line(term: &GlossaryTerm) -> String { + let mut line = format!("- **{}**:", term.label); + if !term.comment.is_empty() { + line.push(' '); + line.push_str(&term.comment); + } + line.push_str(" ["); + line.push_str(&term.tag); + line.push(']'); + line +} + +/// Where the glossary directory sits and what the rendering looks like, +/// for `--write` and `--check`. The ontology is the only source a page can +/// be rendered from: a repository without one has nothing to render. +struct GlossaryDir { + path: PathBuf, + display: String, + custom: bool, + /// The single file an older render wrote, when the default directory + /// is in use. + stale_file: Option<PathBuf>, + files: BTreeMap<String, String>, + terms: usize, +} + +impl GlossaryDir { + fn resolve(source: &str, path: Option<&str>) -> Result<Self, Error> { + let spec_root = resolve_spec_root(Path::new(source))?; + let repository = spec_root.repository(); + let terms = Terms::load(repository, spec_root.specifications())?; + if terms.source() != TermSource::Ontology { + return Err(Error::new( + ErrorKind::Config, + format!( + "no ontology at {}; the glossary pages are rendered from the ontology", + relative_display(repository, &ontology_path(repository)?) + ), + )); + } + let directory = match path { + Some(path) => repository.join(path), + None => terms.glossary_dir().to_path_buf(), + }; + Ok(Self { + display: relative_display(repository, &directory), + path: directory, + custom: path.is_some(), + stale_file: path + .is_none() + .then(|| spec_root.specifications().join(GLOSSARY_FILE)), + files: render_pages(&terms), + terms: terms.terms().len(), + }) + } + + fn write_command(&self) -> String { + if self.custom { + format!("{WRITE_COMMAND} {}", self.display) + } else { + WRITE_COMMAND.to_string() + } + } + + /// The `.md` files in the directory, by name. + fn existing(&self) -> Result<BTreeMap<String, PathBuf>, Error> { + let entries = match std::fs::read_dir(&self.path) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(BTreeMap::new()); + } + Err(error) => return Err(self.io_error(&error)), + }; + let mut files = BTreeMap::new(); + for entry in entries { + let path = entry.map_err(|error| self.io_error(&error))?.path(); + if path.is_file() + && path.extension().is_some_and(|extension| extension == "md") + && let Some(name) = path.file_name().and_then(|name| name.to_str()) + { + files.insert(name.to_string(), path); + } + } + Ok(files) + } + + /// Whether the older single file still exists and is a render. + fn stale_file_present(&self) -> bool { + self.stale_file.as_ref().is_some_and(|file| { + std::fs::read_to_string(file).is_ok_and(|text| text.starts_with("# Glossary\n")) + }) + } + + fn io_error(&self, error: &std::io::Error) -> Error { + Error::new( + ErrorKind::Io, + format!("cannot write {}: {error}", self.display), + ) + } +} + +/// What `--write` reports. +#[derive(Debug, Serialize)] +pub struct GlossaryWriteOutput { + /// Repository-relative path of the glossary directory. + pub path: String, + pub terms: usize, + /// Files created or changed, by name. + pub written: Vec<String>, + /// Stale pages and the older single file removed, repository-relative. + pub removed: Vec<String>, +} + +/// Write the glossary pages and the index from the ontology, creating the +/// directory, removing pages of terms the ontology no longer defines and +/// the single file an older render wrote. +pub fn write_glossary_output( + source: &str, + path: Option<&str>, +) -> Result<GlossaryWriteOutput, Error> { + let directory = GlossaryDir::resolve(source, path)?; + let repository = resolve_spec_root(Path::new(source))? + .repository() + .to_path_buf(); + std::fs::create_dir_all(&directory.path).map_err(|error| directory.io_error(&error))?; + let existing = directory.existing()?; + let mut written = Vec::new(); + for (name, bytes) in &directory.files { + let file = directory.path.join(name); + if std::fs::read_to_string(&file).is_ok_and(|current| ¤t == bytes) { + continue; + } + std::fs::write(&file, bytes).map_err(|error| directory.io_error(&error))?; + written.push(name.clone()); + } + let mut removed = Vec::new(); + for (name, file) in existing { + if !directory.files.contains_key(&name) { + std::fs::remove_file(&file).map_err(|error| directory.io_error(&error))?; + removed.push(relative_display(&repository, &file)); + } + } + if directory.stale_file_present() + && let Some(file) = &directory.stale_file + { + std::fs::remove_file(file).map_err(|error| directory.io_error(&error))?; + removed.push(relative_display(&repository, file)); + } + Ok(GlossaryWriteOutput { + path: directory.display, + terms: directory.terms, + written, + removed, + }) +} + +pub fn write_glossary(source: &str, path: Option<&str>, json: bool) -> Result<i32, Error> { + let output = write_glossary_output(source, path)?; + if json { + print_json(&output)?; + } else { + print!("{}", render_glossary_write(&output)); + } + Ok(0) +} + +pub fn render_glossary_write(output: &GlossaryWriteOutput) -> String { + use std::fmt::Write as _; + let mut text = format!( + "wrote {}: {} page(s) and the index, {} file(s) changed\n", + output.path, + output.terms, + output.written.len() + ); + for removed in &output.removed { + let _ = writeln!(text, " removed {removed}"); + } + text +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GlossaryStatus { + Current, + Stale, + Missing, +} + +/// One file a drift report names: a page by its term's label and file +/// name, the index, or an extra file by its path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GlossaryEntry { + pub label: String, + pub page: String, +} + +impl GlossaryEntry { + fn render(&self) -> String { + if self.label.is_empty() { + self.page.clone() + } else { + format!("{} ({})", self.label, self.page) + } + } +} + +/// What `--check` reports: the pages the directory lacks, the files it +/// holds that the render does not produce, and the pages whose bytes +/// differ. +#[derive(Debug, Serialize)] +pub struct GlossaryCheckOutput { + pub path: String, + pub status: GlossaryStatus, + pub write_command: String, + pub missing: Vec<GlossaryEntry>, + pub extra: Vec<GlossaryEntry>, + pub changed: Vec<GlossaryEntry>, +} + +impl GlossaryCheckOutput { + pub fn is_current(&self) -> bool { + self.status == GlossaryStatus::Current + } +} + +/// The label a page's bytes carry, for a drift report. +fn page_label(bytes: &str) -> String { + bytes + .lines() + .find_map(|line| line.strip_prefix("title: ")) + .unwrap_or_default() + .to_string() +} + +/// Render every page in memory and compare the directory with it, byte +/// for byte. +pub fn check_glossary_output( + source: &str, + path: Option<&str>, +) -> Result<GlossaryCheckOutput, Error> { + let directory = GlossaryDir::resolve(source, path)?; + let repository = resolve_spec_root(Path::new(source))? + .repository() + .to_path_buf(); + let mut output = GlossaryCheckOutput { + path: directory.display.clone(), + status: GlossaryStatus::Current, + write_command: directory.write_command(), + missing: Vec::new(), + extra: Vec::new(), + changed: Vec::new(), + }; + if !directory.path.is_dir() { + output.status = GlossaryStatus::Missing; + return Ok(output); + } + let existing = directory.existing()?; + for (name, bytes) in &directory.files { + let entry = GlossaryEntry { + label: if name == INDEX_FILE { + "index".to_string() + } else { + page_label(bytes) + }, + page: name.clone(), + }; + match existing.get(name) { + None => output.missing.push(entry), + Some(file) => { + let current = std::fs::read_to_string(file).map_err(|error| { + Error::new( + ErrorKind::Io, + format!( + "cannot read {}: {error}", + relative_display(&repository, file) + ), + ) + })?; + if ¤t != bytes { + output.changed.push(entry); + } + } + } + } + output.extra = existing + .iter() + .filter(|(name, _)| !directory.files.contains_key(*name)) + .map(|(name, _)| GlossaryEntry { + label: String::new(), + page: name.clone(), + }) + .collect(); + if directory.stale_file_present() + && let Some(file) = &directory.stale_file + { + output.extra.push(GlossaryEntry { + label: String::new(), + page: relative_display(&repository, file), + }); + } + if !(output.missing.is_empty() && output.extra.is_empty() && output.changed.is_empty()) { + output.status = GlossaryStatus::Stale; + } + Ok(output) +} + +pub fn render_glossary_check(output: &GlossaryCheckOutput) -> String { + use std::fmt::Write as _; + let mut text = match output.status { + GlossaryStatus::Current => return format!("{} is current\n", output.path), + GlossaryStatus::Missing => { + return format!( + "{} is missing: run `{}`\n", + output.path, output.write_command + ); + } + GlossaryStatus::Stale => { + format!("{} is stale: run `{}`\n", output.path, output.write_command) + } + }; + let rows = [ + ("missing", &output.missing), + ("extra", &output.extra), + ("changed", &output.changed), + ]; + for (kind, entries) in rows { + for entry in entries { + let _ = writeln!(text, " {kind:<8} {}", entry.render()); + } + } + text +} + +pub fn check_glossary(source: &str, path: Option<&str>, json: bool) -> Result<i32, Error> { + let output = check_glossary_output(source, path)?; + if json { + print_json(&output)?; + } else { + print!("{}", render_glossary_check(&output)); + } + Ok(i32::from(!output.is_current())) +} + +#[cfg(test)] +mod tests { + use super::super::terms::tests::TURTLE; + use super::*; + + /// A repository root with the ontology and an empty `docs/specs`. + pub(in crate::spec) fn ontology_repository() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("ontology")).unwrap(); + std::fs::create_dir_all(root.path().join("docs/specs")).unwrap(); + std::fs::write(root.path().join("ontology/rune.ttl"), TURTLE).unwrap(); + root + } + + fn source(root: &tempfile::TempDir) -> &str { + root.path().to_str().unwrap() + } + + #[test] + fn a_page_and_the_index_follow_the_exact_shape() { + let root = ontology_repository(); + let terms = Terms::load(root.path(), &root.path().join("docs/specs")).unwrap(); + let change = terms.by_iri("https://runedeck.ai/ns#Change").unwrap(); + assert_eq!( + render_page(&terms, change), + "---\ntitle: change\niri: https://runedeck.ai/ns#Change\nkind: class\nstatus: defined\n---\n\n# change\n\nOne spec-driven unit of work.\n\nGenerated from `ontology/rune.ttl`. Edit the ontology, not this page.\n" + ); + let widget = terms.by_iri("https://runedeck.ai/ns#Widget").unwrap(); + assert_eq!( + render_page(&terms, widget), + format!( + "---\ntitle: widget\niri: https://runedeck.ai/ns#Widget\nkind: class\nstatus: draft\n---\n\n# widget\n\n{DRAFT_COMMENT}\n\nGenerated from `ontology/rune.ttl`. Edit the ontology, not this page.\n" + ) + ); + let index = render_index(&terms); + assert!(index.starts_with( + "# Glossary\n\nGenerated from `ontology/rune.ttl`. Edit the ontology, not these pages.\n\n- [Occam's razor](occams-razor.md): Of two instructions that pass the same check, the shorter wins.\n- [change](change.md): One spec-driven unit of work.\n- [change](change-key.md): The change a record belongs to.\n" + )); + assert!(index.ends_with(&format!("- [widget](widget.md): {DRAFT_COMMENT}\n"))); + let files = render_pages(&terms); + assert_eq!(files.len(), 8); + assert!(files.contains_key("index.md")); + assert!(files.contains_key("lifecycle-stage.md")); + } + + #[test] + fn the_term_list_names_tags_and_repository_relative_pages() { + let root = ontology_repository(); + let output = glossary_output(root.path(), &root.path().join("docs/specs")).unwrap(); + assert_eq!(output.glossary, "docs/specs/glossary"); + let text = render_glossary(&output); + assert!(text.contains("- **change**: One spec-driven unit of work. [CHANGE]\n")); + assert!(text.contains("- **change**: The change a record belongs to. [CHANGE-KEY]\n")); + assert!(text.contains(&format!("- **widget**: {DRAFT_COMMENT} [WIDGET]\n"))); + assert!(text.contains("\n[CHANGE-KEY]: docs/specs/glossary/change-key.md\n")); + assert!(text.ends_with("[WIDGET]: docs/specs/glossary/widget.md\n")); + assert_eq!(output.terms[0].status, "defined"); + assert_eq!(output.terms[0].page, "occams-razor.md"); + } + + #[test] + fn two_writes_give_the_same_bytes_and_remove_the_single_file() { + let root = ontology_repository(); + let single = root.path().join("docs/specs/glossary.md"); + std::fs::write(&single, "# Glossary\n\nold render\n").unwrap(); + + let first = write_glossary_output(source(&root), None).unwrap(); + assert_eq!(first.path, "docs/specs/glossary"); + assert_eq!(first.terms, 7); + assert_eq!(first.written.len(), 8); + assert_eq!(first.removed, ["docs/specs/glossary.md"]); + assert!(!single.exists()); + let page = + std::fs::read_to_string(root.path().join("docs/specs/glossary/change.md")).unwrap(); + assert!(page.starts_with("---\ntitle: change\n")); + + let second = write_glossary_output(source(&root), None).unwrap(); + assert!(second.written.is_empty()); + assert!(second.removed.is_empty()); + let check = check_glossary_output(source(&root), None).unwrap(); + assert!(check.is_current()); + assert_eq!( + render_glossary_check(&check), + "docs/specs/glossary is current\n" + ); + } + + #[test] + fn a_hand_edit_a_missing_page_an_extra_file_and_the_single_file_are_drift() { + let root = ontology_repository(); + write_glossary_output(source(&root), None).unwrap(); + let directory = root.path().join("docs/specs/glossary"); + let page = directory.join("change-key.md"); + std::fs::write( + &page, + std::fs::read_to_string(&page) + .unwrap() + .replace("record", "file"), + ) + .unwrap(); + std::fs::remove_file(directory.join("tight.md")).unwrap(); + std::fs::write(directory.join("stray.md"), "# stray\n").unwrap(); + std::fs::write(root.path().join("docs/specs/glossary.md"), "# Glossary\n").unwrap(); + + let check = check_glossary_output(source(&root), None).unwrap(); + assert_eq!(check.status, GlossaryStatus::Stale); + assert_eq!( + render_glossary_check(&check), + "docs/specs/glossary is stale: run `rune spec glossary --write`\n missing tight (tight.md)\n extra stray.md\n extra docs/specs/glossary.md\n changed change (change-key.md)\n" + ); + + let rewrite = write_glossary_output(source(&root), None).unwrap(); + assert_eq!(rewrite.written, ["change-key.md", "tight.md"]); + assert_eq!( + rewrite.removed, + ["docs/specs/glossary/stray.md", "docs/specs/glossary.md"] + ); + assert!( + check_glossary_output(source(&root), None) + .unwrap() + .is_current() + ); + } + + #[test] + fn a_missing_directory_and_a_custom_path_are_reported_with_their_command() { + let root = ontology_repository(); + let check = check_glossary_output(source(&root), None).unwrap(); + assert_eq!(check.status, GlossaryStatus::Missing); + assert_eq!( + render_glossary_check(&check), + "docs/specs/glossary is missing: run `rune spec glossary --write`\n" + ); + + write_glossary_output(source(&root), Some("docs/terms")).unwrap(); + let custom = check_glossary_output(source(&root), Some("docs/terms")).unwrap(); + assert_eq!(custom.path, "docs/terms"); + assert!(custom.is_current()); + assert_eq!( + custom.write_command, + "rune spec glossary --write docs/terms" + ); + assert!(root.path().join("docs/terms/index.md").is_file()); + } + + #[test] + fn a_repository_without_an_ontology_cannot_render_pages() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("docs/specs")).unwrap(); + let error = write_glossary_output(source(&root), None).unwrap_err(); + assert!(error.message().contains("no ontology at ontology/rune.ttl")); + assert!(check_glossary_output(source(&root), None).is_err()); + } +} diff --git a/rune-docs/src/spec/lint.rs b/rune-docs/src/spec/lint.rs index f5d0400b..3d21fac5 100644 --- a/rune-docs/src/spec/lint.rs +++ b/rune-docs/src/spec/lint.rs @@ -1,9 +1,14 @@ //! Prose rules for specification artifacts, beyond structure and parsing: -//! one normative keyword, a length ceiling, and defined terms that must -//! exist in the ontology (or the glossary) and cite it. Validation and -//! doctor share these so acceptance cannot differ by command. +//! one normative keyword, a length ceiling, and term links that must reach +//! a term the ontology defines. Validation and doctor share these so +//! acceptance cannot differ by command. -use super::terms::{TermSource, Terms}; +use super::prose::prose_lines; +use super::term_links::{ + Definition, Resolution, TermLink, Via, canonical, legacy_links, reference_definitions, + reference_links, relative_link, resolve, +}; +use super::terms::{Term, TermSource, Terms}; use super::{DiagnosticSeverity, SpecViolation, relative_display}; use regex::Regex; use std::collections::{BTreeMap, BTreeSet}; @@ -50,21 +55,6 @@ pub fn name_words(name: &str) -> usize { static SHALL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\bSHALL\b").expect("normative keyword regex is valid")); -/// A single-asterisk italic run marks a defined term. The boundaries are -/// checked by [`italic_terms`], not consumed here, so two runs separated -/// by one space are both found. -static ITALIC_RUN: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"\*([^*\n]+?)\*").expect("italic run regex is valid")); -/// The reference tag that may follow an italic run: `*term* [TAG]`. -/// Markdown labels are case-insensitive, so any case is a tag here. -static REFERENCE_TAG: LazyLock<Regex> = LazyLock::new(|| { - Regex::new(r"^\s*\[([A-Za-z0-9][A-Za-z0-9_-]*)\]").expect("reference tag regex is valid") -}); -/// A reference definition, `[TAG]: <target>`, indented up to three spaces. -static REFERENCE_DEFINITION: LazyLock<Regex> = LazyLock::new(|| { - Regex::new(r"^ {0,3}\[([A-Za-z0-9][A-Za-z0-9_-]*)\]:\s*(\S+)") - .expect("reference definition regex is valid") -}); static REQUIREMENT_HEADING: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"^###\s+Requirement:").expect("requirement heading regex is valid") }); @@ -97,27 +87,20 @@ pub(super) fn lint_canonical( "canonical specification", diagnostics, ); - lint_terms( - target, - content, - terms, - Some("spec-term-undefined"), - diagnostics, - ); + lint_terms(target, content, terms, "spec", diagnostics); lint_prose(target, content, "spec", diagnostics); lint_names(target, "spec", diagnostics); } /// Lint one document outside the specification tree: a decision record or -/// a rune. Only the reference rules apply. An italic run that matches no -/// term is emphasis there, not a definition, so it is not reported. +/// a rune. Only the term link rules apply, under the `spec-` codes. pub(super) fn lint_document( target: LintTarget<'_>, content: &str, terms: &Terms, diagnostics: &mut Vec<SpecViolation>, ) { - lint_terms(target, content, terms, None, diagnostics); + lint_terms(target, content, terms, "spec", diagnostics); } /// Lint one change delta. @@ -136,13 +119,7 @@ pub(super) fn lint_delta( "delta specification", diagnostics, ); - lint_terms( - target, - content, - terms, - Some("delta-term-undefined"), - diagnostics, - ); + lint_terms(target, content, terms, "delta", diagnostics); lint_prose(target, content, "delta", diagnostics); lint_names(target, "delta", diagnostics); } @@ -284,221 +261,248 @@ fn lint_prose( close(&mut requirement, diagnostics); } -/// Every italic term exists in the term source, reported under -/// `undefined_code` when the caller passes one. With an ontology, the first -/// italic use of each term in the file also cites it: `*term* [TAG]` with -/// `[TAG]: <iri>` defined in the file, and a definition under an ontology -/// namespace names a real term. +/// Every term link in the file reaches a term the ontology defines. The +/// rules, under `<prefix>-term-*`: `undefined` (error: the link names no +/// term), `draft` (warning: the term is a draft), `first-use` (error: the +/// first use in the file is not emphasized, or a later use is), `page` +/// (error: the page is missing, differs from the render, or the tag has +/// no definition), and `legacy` (warning: the old `*term* [TAG]` form, or +/// a definition that names an IRI instead of the page). Plain italics are +/// emphasis and are not checked. A repository without an ontology has no +/// terms, so nothing is checked. fn lint_terms( target: LintTarget<'_>, content: &str, terms: &Terms, - undefined_code: Option<&str>, + code_prefix: &str, diagnostics: &mut Vec<SpecViolation>, ) { - let location = terms.location(); - let tags = terms.tags(); - let definitions = reference_definitions(content); - if terms.source() == TermSource::Ontology { - for (tag, (line, iri)) in &definitions { - if terms.in_namespace(iri) && terms.by_iri(iri).is_none() { - diagnostics.push(violation( - target, - "term-reference-unresolved", - DiagnosticSeverity::Error, - Some(*line), - format!("[{tag}] names {iri}, which is not a term in {location}"), - )); - } - } + if terms.source() != TermSource::Ontology { + return; } - let mut cited = BTreeSet::new(); - for (index, line) in prose_lines(content) { - for (term, tag) in italic_terms(&line) { - // `*Rationale:*` and similar labels are emphasis, not definitions. - if term.is_empty() || term.ends_with(':') { - continue; - } - let Some(entry) = terms.resolve(term) else { - if let Some(code) = undefined_code { - diagnostics.push(violation( - target, - code, - DiagnosticSeverity::Error, - Some(index), - undefined_message(terms, term), - )); - } - continue; - }; - if terms.source() != TermSource::Ontology || !cited.insert(entry.iri.clone()) { - continue; - } - let expected = tags.get(&entry.iri).cloned().unwrap_or_default(); - match tag { - None => diagnostics.push(violation( - target, - "term-reference-missing", - DiagnosticSeverity::Error, - Some(index), - format!( - "first use of *{term}* has no reference tag; write `*{term}* [{expected}]` and define `[{expected}]: {}`", - entry.iri - ), - )), - Some(tag) => { - let defined = definitions - .get(&tag.to_uppercase()) - .map(|(_, iri)| iri.as_str()); - if defined != Some(entry.iri.as_str()) { - diagnostics.push(violation( - target, - "term-reference-unresolved", - DiagnosticSeverity::Error, - Some(index), - format!("[{tag}] must be defined as `[{tag}]: {}`", entry.iri), - )); - } - } - } + let view: Vec<(usize, String)> = prose_lines(content).collect(); + let definitions = + reference_definitions(view.iter().map(|(number, line)| (*number, line.as_str()))); + let mut lint = TermLint { + target, + terms, + code_prefix, + file_dir: target.path.parent().unwrap_or(Path::new("")), + seen: BTreeSet::new(), + reported: BTreeSet::new(), + pages: BTreeMap::new(), + diagnostics, + }; + for definition in definitions.values() { + if terms.in_namespace(&definition.target) && terms.by_iri(&definition.target).is_none() { + lint.report( + "undefined", + DiagnosticSeverity::Error, + definition.line, + format!( + "[{}] names {}, which is not a term in {}", + definition.tag, + definition.target, + terms.location() + ), + ); } } -} - -/// The single-asterisk italic runs of one prose line with the tag that -/// follows each, in order. A run counts when the character before its -/// opening `*` and after its closing `*` is neither `*` nor a word -/// character, and the opening `*` is not escaped. -fn italic_terms(line: &str) -> Vec<(&str, Option<&str>)> { - let mut found = Vec::new(); - for capture in ITALIC_RUN.captures_iter(line) { - let whole = capture.get(0).expect("whole match"); - let before = line[..whole.start()].chars().next_back(); - let after = line[whole.end()..].chars().next(); - let bounded = - |c: Option<char>| c.is_none_or(|c| c != '*' && !c.is_alphanumeric() && c != '_'); - if !bounded(before) || before == Some('\\') { - continue; + for (number, line) in &view { + for link in legacy_links(*number, line) { + lint.legacy(&link, &definitions); } - let rest = &line[whole.end()..]; - let tag = REFERENCE_TAG - .captures(rest) - .map(|tag| tag.get(1).expect("tag").as_str()); - let after_tag = tag.map_or(after, |_| { - let end = REFERENCE_TAG.find(rest).map_or(0, |m| m.end()); - rest[end..].chars().next() - }); - if !bounded(after) && tag.is_none() || !bounded(after_tag) { - continue; + for link in reference_links(*number, line) { + lint.link(&link, &definitions); } - found.push((capture.get(1).expect("term").as_str().trim(), tag)); } - found } -fn undefined_message(terms: &Terms, term: &str) -> String { - let location = terms.location(); - match terms.source() { - TermSource::Ontology => format!( - "defined term '{term}' is not in {location}; add a term with `rdfs:label \"{term}\"` or drop the emphasis" - ), - TermSource::Glossary => format!( - "defined term '{term}' has no entry in {location}; add `- **{term}**: <definition>`" - ), - TermSource::Absent => format!( - "defined term '{term}' needs a glossary; create {location} with `- **{term}**: <definition>`" - ), - } +/// The state of the term rules over one file. +struct TermLint<'a> { + target: LintTarget<'a>, + terms: &'a Terms, + code_prefix: &'a str, + file_dir: &'a Path, + /// Terms linked so far, by IRI: the first link is the first use. + seen: BTreeSet<String>, + /// Findings reported once per term and rule. + reported: BTreeSet<(String, &'static str)>, + /// Whether each page read so far matches its render. + pages: BTreeMap<std::path::PathBuf, bool>, + diagnostics: &'a mut Vec<SpecViolation>, } -/// Reference definitions by upper-cased tag (Markdown labels are -/// case-insensitive), with the line they sit on. The first definition of a -/// tag wins, as in Markdown, and an angle-bracketed destination is unwrapped. -fn reference_definitions(content: &str) -> BTreeMap<String, (usize, String)> { - let mut definitions = BTreeMap::new(); - for (index, line) in prose_lines(content) { - if let Some(capture) = REFERENCE_DEFINITION.captures(&line) { - let destination = capture[2] - .strip_prefix('<') - .and_then(|inner| inner.strip_suffix('>')) - .unwrap_or(&capture[2]); - definitions - .entry(capture[1].to_uppercase()) - .or_insert((index, destination.to_string())); +impl TermLint<'_> { + fn report(&mut self, rule: &str, severity: DiagnosticSeverity, line: usize, message: String) { + self.diagnostics.push(violation( + self.target, + &format!("{}-term-{rule}", self.code_prefix), + severity, + Some(line), + message, + )); + } + + fn once(&mut self, key: &str, rule: &'static str) -> bool { + self.reported.insert((key.to_string(), rule)) + } + + /// The definition a file at this path writes for a term. + fn page_link(&self, term: &Term) -> String { + relative_link(self.file_dir, &self.terms.page_path(term)) + } + + /// The legacy `*text* [TAG]` form warns when its tag reaches a term or + /// its definition sits under a term namespace. + fn legacy(&mut self, link: &TermLink, definitions: &BTreeMap<String, Definition>) { + let resolution = resolve(self.terms, link, definitions, self.file_dir); + let under_namespace = definitions + .get(&link.tag.to_uppercase()) + .is_some_and(|definition| self.terms.in_namespace(&definition.target)); + let relative = match resolution { + Resolution::Term { term, .. } => self.page_link(term), + _ if under_namespace => format!("<path to the page of {}>", link.tag), + _ => return, + }; + self.report( + "legacy", + DiagnosticSeverity::Warning, + link.line, + format!( + "`*{text}* [{tag}]` is the legacy form; write `*[{text}][{tag}]*` at first use, `[{text}][{tag}]` later, and define `[{tag}]: {relative}`", + text = link.text, + tag = link.tag + ), + ); + } + + fn link(&mut self, link: &TermLink, definitions: &BTreeMap<String, Definition>) { + match resolve(self.terms, link, definitions, self.file_dir) { + Resolution::Ordinary => {} + Resolution::Undefined { reason, hint, .. } => self.report( + "undefined", + DiagnosticSeverity::Error, + link.line, + format!( + "term link [{}][{}] names no term: {reason}; {hint}", + link.text, link.tag + ), + ), + Resolution::Term { term, via, page } => { + let first = self.seen.insert(term.iri.clone()); + self.first_use(link, first); + if term.draft && self.once(&term.iri, "draft") { + self.report( + "draft", + DiagnosticSeverity::Warning, + link.line, + format!( + "term '{}' is a draft; define it in {} before archive", + term.label, + self.terms.location() + ), + ); + } + self.reached(link, term, via, page); + } } } - definitions -} -/// Non-fenced lines with their one-based numbers, inline code spans -/// blanked: a quoted literal such as `` `SHALL` `` is a mention, not a -/// normative statement or a defined term. Backtick and tilde fences both -/// count. A leading YAML front matter block, closed by `---` or `...`, is -/// data, not prose, and is skipped whole; an unclosed block is prose. -fn prose_lines(content: &str) -> impl Iterator<Item = (usize, String)> + '_ { - let content = content.strip_prefix('\u{FEFF}').unwrap_or(content); - let mut fence: Option<(char, usize)> = None; - let mut front_matter = front_matter_end(content); - content - .lines() - .enumerate() - .filter_map(move |(index, line)| { - if front_matter.is_some_and(|end| index <= end) { - if front_matter == Some(index) { - front_matter = None; + /// The first use of a term in a file is `*[text][TAG]*`; every later + /// use is `[text][TAG]`. + fn first_use(&mut self, link: &TermLink, first: bool) { + if link.emphasized == first { + return; + } + let message = if first { + format!( + "first use of [{text}][{tag}] in this file is not emphasized; write `*[{text}][{tag}]*`", + text = link.text, + tag = link.tag + ) + } else { + format!( + "later use of [{text}][{tag}] is emphasized; only the first use in a file is, write `[{text}][{tag}]`", + text = link.text, + tag = link.tag + ) + }; + self.report("first-use", DiagnosticSeverity::Error, link.line, message); + } + + /// The page behind a resolved link exists and matches the render, the + /// tag is defined, and the definition points at the page. + fn reached( + &mut self, + link: &TermLink, + term: &Term, + via: Via, + page: Option<std::path::PathBuf>, + ) { + let expected = self.page_link(term); + match via { + Via::Page => { + let page = page.expect("a page resolution carries its path"); + let terms = self.terms; + let current = *self.pages.entry(page.clone()).or_insert_with(|| { + std::fs::read_to_string(&page) + .is_ok_and(|bytes| bytes == super::glossary::render_page(terms, term)) + }); + if !current && self.once(&term.iri, "page") { + self.report( + "page", + DiagnosticSeverity::Error, + link.line, + format!( + "glossary page {} differs from the ontology render; run `rune spec glossary --write`", + relative_display(&canonical(self.target.repository), &page) + ), + ); + } + } + Via::PageMissing => { + if self.once(&term.iri, "page") { + let page = page.expect("a missing page resolution carries its path"); + self.report( + "page", + DiagnosticSeverity::Error, + link.line, + format!( + "glossary page {} is missing; run `rune spec glossary --write`", + relative_display(&canonical(self.target.repository), &page) + ), + ); } - return None; } - let trimmed = line.trim_start(); - let marker = trimmed.chars().next().filter(|c| *c == '`' || *c == '~'); - let run = marker.map_or(0, |c| trimmed.chars().take_while(|d| *d == c).count()); - match fence { - Some((open, width)) - if marker == Some(open) && run >= width && trimmed[run..].trim().is_empty() => - { - fence = None; - None + Via::NoDefinition => { + if self.once(&link.tag.to_uppercase(), "page") { + self.report( + "page", + DiagnosticSeverity::Error, + link.line, + format!( + "[{}] has no definition; add `[{}]: {expected}` at the end of the file", + link.tag, link.tag + ), + ); } - Some(_) => None, - None if run >= 3 => { - fence = marker.map(|c| (c, run)); - None + } + Via::Iri => { + if self.once(&term.iri, "legacy") { + self.report( + "legacy", + DiagnosticSeverity::Warning, + link.line, + format!( + "[{}] is defined as an IRI, the legacy form; point it at the page: `[{}]: {expected}`", + link.tag, link.tag + ), + ); } - None => Some((index + 1, without_inline_code(line))), } - }) -} - -/// The zero-based line index of the `---` or `...` that closes a leading -/// YAML front matter block, when the content opens with `---` and a -/// closing line exists. -fn front_matter_end(content: &str) -> Option<usize> { - let mut lines = content.lines().enumerate(); - let (_, first) = lines.next()?; - if first.trim_end() != "---" { - return None; - } - lines - .find(|(_, line)| matches!(line.trim_end(), "---" | "...")) - .map(|(index, _)| index) -} - -/// Replace every `` `...` `` span with spaces of the same length so column -/// positions survive and the span's text never matches a rule. -fn without_inline_code(line: &str) -> String { - let mut result = String::with_capacity(line.len()); - let mut inside = false; - for character in line.chars() { - if character == '`' { - inside = !inside; - result.push(' '); - } else if inside { - result.push(' '); - } else { - result.push(character); } } - result } fn violation( diff --git a/rune-docs/src/spec/lint/tests.rs b/rune-docs/src/spec/lint/tests.rs index 3fe96071..012d7d85 100644 --- a/rune-docs/src/spec/lint/tests.rs +++ b/rune-docs/src/spec/lint/tests.rs @@ -1,5 +1,4 @@ use super::*; -use crate::spec::terms::GLOSSARY_FILE; use tempfile::TempDir; fn target<'target>(repository: &'target Path, path: &'target Path) -> LintTarget<'target> { @@ -112,7 +111,7 @@ fn exactly_the_limit_passes() { } #[test] -fn defined_term_needs_a_glossary_entry() { +fn italics_are_emphasis_and_need_no_term() { let root = TempDir::new().unwrap(); let specs = root.path().join("docs/specs"); std::fs::create_dir_all(&specs).unwrap(); @@ -129,35 +128,7 @@ fn defined_term_needs_a_glossary_entry() { &Terms::load(root.path(), &specs).unwrap(), &mut diagnostics, ); - let messages: Vec<_> = diagnostics - .iter() - .map(|diagnostic| diagnostic.message.as_str()) - .collect(); - assert_eq!(diagnostics.len(), 2, "{messages:?}"); - assert!(messages[0].contains("'search index' needs a glossary")); - assert!(messages[0].contains("docs/specs/glossary.md")); - assert!(messages[1].contains("'scan'")); - assert!( - !messages.iter().any(|message| message.contains("Rationale")), - "labels ending in a colon are emphasis, not definitions" - ); - - std::fs::write( - specs.join(GLOSSARY_FILE), - "# Glossary\n\n- **Search Index**: the prebuilt term table.\n", - ) - .unwrap(); - let mut diagnostics = Vec::new(); - lint_canonical( - target(root.path(), &path), - &content, - &Terms::load(root.path(), &specs).unwrap(), - &mut diagnostics, - ); - assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); - assert_eq!(diagnostics[0].code, "spec-term-undefined"); - assert!(diagnostics[0].message.contains("'scan' has no entry")); - assert_eq!(diagnostics[0].line, Some(5)); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); } #[test] @@ -209,30 +180,6 @@ fn must_passes_the_house_rule_and_shall_fails_it() { ); } -#[test] -fn a_plain_plural_finds_its_singular_entry() { - let root = TempDir::new().unwrap(); - let specs = root.path().join("docs/specs"); - std::fs::create_dir_all(&specs).unwrap(); - std::fs::write( - specs.join(GLOSSARY_FILE), - "# Glossary\n\n- **trailer**: a key-value line in the commit footer.\n", - ) - .unwrap(); - let path = specs.join("search/spec.md"); - let content = CLEAN.replace("Find runes.", "Attribute through *trailers*."); - let mut diagnostics = Vec::new(); - - lint_canonical( - target(root.path(), &path), - &content, - &Terms::load(root.path(), &specs).unwrap(), - &mut diagnostics, - ); - - assert!(diagnostics.is_empty(), "{diagnostics:?}"); -} - #[test] fn a_requirement_over_the_word_cap_is_an_error_at_its_heading() { let root = TempDir::new().unwrap(); @@ -308,7 +255,7 @@ fn short_capability_and_change_names_are_errors_by_the_default_rule() { let codes: Vec<&str> = diagnostics.iter().map(|d| d.code.as_str()).collect(); assert!(codes.contains(&"delta-capability-name-short"), "{codes:?}"); assert!(codes.contains(&"change-name-short"), "{codes:?}"); - assert_eq!(name_words("sign-before-publish-guard"), 4); + assert_eq!(name_words("sign-before-publishing-online"), 4); assert_eq!(name_words("a--b"), 2); } @@ -322,13 +269,22 @@ rune:Harness a rdfs:Class ; rune:Canon a rdfs:Class ; rdfs:label "canon" ; rdfs:comment "The authored source of one instruction." . + +rune:Ghost a rdfs:Class ; + rdfs:label "ghost" ; + rune:status "draft" . "#; +/// The definitions a specification under `docs/specs/<capability>/` writes. +const DEFINITIONS: &str = "\n\n[HARNESS]: ../glossary/harness.md\n[CANON]: ../glossary/canon.md\n[GHOST]: ../glossary/ghost.md"; + +/// A repository with the ontology and its generated glossary pages. fn ontology_root() -> TempDir { let root = TempDir::new().unwrap(); std::fs::create_dir_all(root.path().join("ontology")).unwrap(); std::fs::create_dir_all(root.path().join("docs/specs")).unwrap(); std::fs::write(root.path().join("ontology/rune.ttl"), ONTOLOGY).unwrap(); + crate::spec::write_glossary_output(root.path().to_str().unwrap(), None).unwrap(); root } @@ -345,168 +301,241 @@ fn lint_with_ontology(root: &TempDir, prose: &str) -> Vec<SpecViolation> { diagnostics } +fn codes(diagnostics: &[SpecViolation]) -> Vec<&str> { + diagnostics.iter().map(|d| d.code.as_str()).collect() +} + #[test] -fn an_ontology_replaces_the_glossary_as_the_term_source() { +fn an_emphasized_first_use_and_plain_later_uses_pass() { let root = ontology_root(); - std::fs::write( - root.path().join("docs/specs").join(GLOSSARY_FILE), - "# Glossary\n\n- **scan**: a full read.\n", - ) - .unwrap(); - let diagnostics = lint_with_ontology( &root, - "Every *harness* [HARNESS] runs a *scan*.\n\n[HARNESS]: https://runedeck.ai/ns#Harness", - ); - - assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); - assert_eq!(diagnostics[0].code, "spec-term-undefined"); - assert!( - diagnostics[0] - .message - .contains("'scan' is not in ontology/rune.ttl") + &format!( + "Every *[harness][HARNESS]* loads a *[canon][CANON]*. The [harnesses][HARNESS] share the [canon][CANON].{DEFINITIONS}" + ), ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); } #[test] -fn a_cited_first_use_with_its_definition_passes_and_later_uses_need_no_tag() { +fn plain_italics_and_ordinary_links_are_not_terms() { let root = ontology_root(); let diagnostics = lint_with_ontology( &root, - "Every *harness* [HARNESS] loads a *canon* [CANON]. The *harnesses* share the canon.\n\n[HARNESS]: https://runedeck.ai/ns#Harness\n[CANON]: https://runedeck.ai/ns#Canon", + "A *widget* is *not* a term, *Rationale:* neither, and [the readme][README] is a link.\n\n[README]: ../../README.md", ); assert!(diagnostics.is_empty(), "{diagnostics:?}"); } #[test] -fn a_first_use_without_a_tag_names_the_tag_and_the_definition_to_add() { +fn a_plain_first_use_and_an_emphasized_later_use_are_errors() { let root = ontology_root(); - let diagnostics = lint_with_ontology(&root, "Every *harness* loads instructions."); - - assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); - assert_eq!(diagnostics[0].code, "term-reference-missing"); + let diagnostics = lint_with_ontology( + &root, + &format!( + "A [harness][HARNESS] loads a *[canon][CANON]*. Again *[canon][CANON]*.{DEFINITIONS}" + ), + ); + assert_eq!( + codes(&diagnostics), + ["spec-term-first-use", "spec-term-first-use"], + "{diagnostics:?}" + ); assert_eq!(diagnostics[0].line, Some(5)); assert!( - diagnostics[0].message.contains( - "write `*harness* [HARNESS]` and define `[HARNESS]: https://runedeck.ai/ns#Harness`" - ), - "{}", - diagnostics[0].message + diagnostics[0] + .message + .contains("write `*[harness][HARNESS]*`") + ); + assert!( + diagnostics[1] + .message + .contains("later use of [canon][CANON]") ); + assert_eq!(diagnostics[1].severity, DiagnosticSeverity::Error); } #[test] -fn a_tag_defined_to_another_iri_or_not_defined_is_unresolved() { +fn a_link_to_no_term_is_undefined_and_names_the_fixer() { let root = ontology_root(); - let wrong = lint_with_ontology( + let diagnostics = lint_with_ontology( &root, - "Every *harness* [HARNESS] loads a *canon* [CANON].\n\n[HARNESS]: https://runedeck.ai/ns#Canon", + "A *[widget][WIDGET]* here.\n\n[WIDGET]: ../glossary/widget.md", ); - let codes: Vec<_> = wrong.iter().map(|d| d.code.as_str()).collect(); assert_eq!( - codes, - ["term-reference-unresolved", "term-reference-unresolved"], - "{wrong:?}" + codes(&diagnostics), + ["spec-term-undefined"], + "{diagnostics:?}" + ); + assert_eq!(diagnostics[0].line, Some(5)); + assert!( + diagnostics[0] + .message + .contains("[widget][WIDGET] names no term") ); assert!( - wrong[0] + diagnostics[0] .message - .contains("[HARNESS] must be defined as `[HARNESS]: https://runedeck.ai/ns#Harness`") + .contains("run `rune spec fix --terms`") ); - assert!(wrong[1].message.contains("[CANON] must be defined as")); } #[test] -fn a_definition_under_the_namespace_must_name_a_term() { +fn a_tag_without_a_definition_is_a_dangling_link() { let root = ontology_root(); - let diagnostics = lint_with_ontology( - &root, - "No terms here.\n\n[GHOST]: https://runedeck.ai/ns#Ghost\n[OTHER]: https://example.org/elsewhere", + let diagnostics = lint_with_ontology(&root, "A [phantom][PHANTOM] here."); + assert_eq!( + codes(&diagnostics), + ["spec-term-undefined"], + "{diagnostics:?}" ); - - assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); - assert_eq!(diagnostics[0].code, "term-reference-unresolved"); + assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error); assert!( diagnostics[0] .message - .contains("[GHOST] names https://runedeck.ai/ns#Ghost, which is not a term") + .contains("the tag has no definition in this file and no term has the tag") + ); + assert!( + diagnostics[0] + .message + .contains("add `[PHANTOM]: ../glossary/phantom.md` at the end of the file") ); } #[test] -fn a_document_outside_the_tree_gets_reference_rules_only_and_no_capability() { +fn a_draft_term_warns_once_per_file() { let root = ontology_root(); - let path = root.path().join("docs/decisions/CLI-0001 Example.md"); - let mut diagnostics = Vec::new(); - lint_document( - LintTarget { - repository: root.path(), - path: &path, - capability: None, - change: None, - }, - "# Example\n\nThe *harness* and the *widget*.\n", - &Terms::load(root.path(), &root.path().join("docs/specs")).unwrap(), - &mut diagnostics, + let diagnostics = lint_with_ontology( + &root, + &format!("A *[ghost][GHOST]* and the [ghost][GHOST] again.{DEFINITIONS}"), ); - let codes: Vec<_> = diagnostics.iter().map(|d| d.code.as_str()).collect(); - assert_eq!(codes, ["term-reference-missing"], "{diagnostics:?}"); - assert!(diagnostics.iter().all(|d| d.capability.is_none())); + assert_eq!(codes(&diagnostics), ["spec-term-draft"], "{diagnostics:?}"); + assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Warning); + assert!(diagnostics[0].message.contains("term 'ghost' is a draft")); } #[test] -fn front_matter_and_plain_emphasis_are_not_terms_in_a_document() { +fn a_missing_page_and_a_hand_edited_page_are_page_errors() { let root = ontology_root(); - let path = root.path().join("runes/core/skills/Example/SKILL.md"); - let mut diagnostics = Vec::new(); - lint_document( - LintTarget { - repository: root.path(), - path: &path, - capability: None, - change: None, - }, - "---\nallowed-tools: Bash(git add:*), Bash(git diff:*)\n---\n\nDo *not* skip the *harness* [HARNESS] step.\n\n[HARNESS]: https://runedeck.ai/ns#Harness\n", - &Terms::load(root.path(), &root.path().join("docs/specs")).unwrap(), - &mut diagnostics, + std::fs::remove_file(root.path().join("docs/specs/glossary/canon.md")).unwrap(); + let missing = lint_with_ontology(&root, &format!("A *[canon][CANON]*.{DEFINITIONS}")); + assert_eq!(codes(&missing), ["spec-term-page"], "{missing:?}"); + assert!( + missing[0] + .message + .contains("glossary page docs/specs/glossary/canon.md is missing") + ); + + let page = root.path().join("docs/specs/glossary/harness.md"); + std::fs::write( + &page, + std::fs::read_to_string(&page) + .unwrap() + .replace("coding", "code"), + ) + .unwrap(); + let edited = lint_with_ontology( + &root, + &format!("A *[harness][HARNESS]* and [harness][HARNESS].{DEFINITIONS}"), + ); + assert_eq!(codes(&edited), ["spec-term-page"], "{edited:?}"); + assert!( + edited[0] + .message + .contains("differs from the ontology render") ); - assert!(diagnostics.is_empty(), "{diagnostics:?}"); } #[test] -fn adjacent_italic_runs_escaped_asterisks_and_lowercase_tags_follow_markdown() { +fn a_known_tag_without_a_definition_names_the_definition_to_add() { let root = ontology_root(); - let diagnostics = lint_with_ontology( + let diagnostics = lint_with_ontology(&root, "A *[harness][HARNESS]* loads."); + assert_eq!(codes(&diagnostics), ["spec-term-page"], "{diagnostics:?}"); + assert!( + diagnostics[0] + .message + .contains("add `[HARNESS]: ../glossary/harness.md` at the end of the file") + ); +} + +#[test] +fn the_legacy_form_and_an_iri_definition_warn_and_name_the_new_form() { + let root = ontology_root(); + let legacy = lint_with_ontology( &root, - "*harness* [harness] *canon* [CANON] \\*not a term\\* here.\n\n[harness]: <https://runedeck.ai/ns#Harness>\n[CANON]: https://runedeck.ai/ns#Canon\n", + "Every *harness* [HARNESS] loads.\n\n[HARNESS]: https://runedeck.ai/ns#Harness", ); - assert!(diagnostics.is_empty(), "{diagnostics:?}"); + assert_eq!(codes(&legacy), ["spec-term-legacy"], "{legacy:?}"); + assert_eq!(legacy[0].severity, DiagnosticSeverity::Warning); + assert!(legacy[0].message.contains( + "write `*[harness][HARNESS]*` at first use, `[harness][HARNESS]` later, and define `[HARNESS]: ../glossary/harness.md`" + )); - let missing_second = lint_with_ontology( + let iri = lint_with_ontology( &root, - "*harness* [HARNESS] *canon* here.\n\n[HARNESS]: https://runedeck.ai/ns#Harness\n", + "A *[canon][CANON]* and [canon][CANON].\n\n[CANON]: <https://runedeck.ai/ns#Canon>", + ); + assert_eq!(codes(&iri), ["spec-term-legacy"], "{iri:?}"); + assert!( + iri[0] + .message + .contains("point it at the page: `[CANON]: ../glossary/canon.md`") ); - let codes: Vec<_> = missing_second.iter().map(|d| d.code.as_str()).collect(); - assert_eq!(codes, ["term-reference-missing"], "{missing_second:?}"); - assert!(missing_second[0].message.contains("*canon* [CANON]")); } #[test] -fn the_first_definition_wins_and_indented_definitions_count() { +fn a_definition_under_the_namespace_must_name_a_term() { let root = ontology_root(); let diagnostics = lint_with_ontology( &root, - "A *harness* [HARNESS] runs.\n\n [HARNESS]: https://runedeck.ai/ns#Canon\n[HARNESS]: https://runedeck.ai/ns#Harness\n", + "No terms here.\n\n[NOPE]: https://runedeck.ai/ns#Nope\n[OTHER]: https://example.org/elsewhere", + ); + assert_eq!( + codes(&diagnostics), + ["spec-term-undefined"], + "{diagnostics:?}" + ); + assert_eq!(diagnostics[0].line, Some(7)); + assert!( + diagnostics[0] + .message + .contains("[NOPE] names https://runedeck.ai/ns#Nope, which is not a term") ); - let codes: Vec<_> = diagnostics.iter().map(|d| d.code.as_str()).collect(); - assert_eq!(codes, ["term-reference-unresolved"], "{diagnostics:?}"); - assert_eq!(diagnostics[0].line, Some(5)); } #[test] -fn tilde_fences_and_closed_front_matter_are_skipped_but_an_unclosed_block_is_prose() { +fn a_delta_reports_the_delta_twins() { let root = ontology_root(); - let path = root.path().join("docs/decisions/X.md"); + let path = root + .path() + .join("docs/changes/add-search-index/specs/search/spec.md"); + let content = CLEAN.replace( + "Find runes.", + "A [harness][HARNESS] and a *[ghost][GHOST]*.\n\n[HARNESS]: ../../../../specs/glossary/harness.md\n[GHOST]: ../../../../specs/glossary/ghost.md", + ); + let mut diagnostics = Vec::new(); + lint_delta( + LintTarget { + repository: root.path(), + path: &path, + capability: Some("search-index-rules"), + change: Some("add-search-index"), + }, + &content, + &Terms::load(root.path(), &root.path().join("docs/specs")).unwrap(), + &mut diagnostics, + ); + assert_eq!( + codes(&diagnostics), + ["delta-term-first-use", "delta-term-draft"], + "{diagnostics:?}" + ); +} + +#[test] +fn a_document_outside_the_tree_gets_the_term_rules_only_and_no_capability() { + let root = ontology_root(); + let path = root.path().join("docs/decisions/CLI-0001 Example.md"); let terms = Terms::load(root.path(), &root.path().join("docs/specs")).unwrap(); let lint = |content: &str| { let mut diagnostics = Vec::new(); @@ -523,8 +552,57 @@ fn tilde_fences_and_closed_front_matter_are_skipped_but_an_unclosed_block_is_pro ); diagnostics }; - assert!(lint("---\ntitle: *harness*\n...\n\n~~~\n*harness*\n~~~\n").is_empty()); - let unclosed = lint("---\ntitle: x\n\nThe *harness* here.\n"); - assert_eq!(unclosed.len(), 1, "{unclosed:?}"); - assert_eq!(unclosed[0].code, "term-reference-missing"); + let clean = lint( + "# Example\n\nThe *[harness][HARNESS]* and the *widget*.\n\n[HARNESS]: ../specs/glossary/harness.md\n", + ); + assert!(clean.is_empty(), "{clean:?}"); + let plain = + lint("# Example\n\nThe [harness][HARNESS].\n\n[HARNESS]: ../specs/glossary/harness.md\n"); + assert_eq!(codes(&plain), ["spec-term-first-use"], "{plain:?}"); + assert!(plain.iter().all(|d| d.capability.is_none())); +} + +#[test] +fn front_matter_fences_and_images_are_not_term_links() { + let root = ontology_root(); + let diagnostics = lint_with_ontology( + &root, + &format!( + "![harness][HARNESS] then *[harness][HARNESS]* ok.\n\n```md\n[canon][CANON]\n```\n\nThe *[canon][CANON]*.{DEFINITIONS}" + ), + ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + + let path = root.path().join("docs/decisions/X.md"); + let terms = Terms::load(root.path(), &root.path().join("docs/specs")).unwrap(); + let mut front = Vec::new(); + lint_document( + LintTarget { + repository: root.path(), + path: &path, + capability: None, + change: None, + }, + "---\ntitle: [harness][HARNESS]\n---\n\n*[harness][HARNESS]* first.\n\n[HARNESS]: ../specs/glossary/harness.md\n", + &terms, + &mut front, + ); + assert!(front.is_empty(), "{front:?}"); +} + +#[test] +fn a_repository_without_an_ontology_checks_no_links() { + let root = TempDir::new().unwrap(); + let path = root.path().join("docs/specs/search/spec.md"); + let mut diagnostics = Vec::new(); + lint_canonical( + target(root.path(), &path), + &CLEAN.replace( + "Find runes.", + "A [harness][HARNESS].\n\n[HARNESS]: ../glossary/harness.md", + ), + &Terms::default(), + &mut diagnostics, + ); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); } diff --git a/rune-docs/src/spec/mod.rs b/rune-docs/src/spec/mod.rs index cd52fc4e..a9ab3af6 100644 --- a/rune-docs/src/spec/mod.rs +++ b/rune-docs/src/spec/mod.rs @@ -3,13 +3,17 @@ //! Module map, by concern: //! //! - Public command surface: [`commands`] (propose, list, context, archive, -//! show), [`validate`], [`doctor`]; each command has an output-returning -//! `*_output` form, a pure `render_*` form, and a printing wrapper. +//! show), [`validate`], [`doctor`], [`glossary`] (the term list and the +//! generated pages), [`fix`] (the fixer); each command has an +//! output-returning `*_output` form, a pure `render_*` form, and a +//! printing wrapper. //! - Internal pipeline: [`parse`] and [`model`] read and edit canonical //! Markdown, [`apply`] merges deltas, [`transaction`] makes archive and //! conversion crash-safe, [`root`] resolves the spec root (shared source //! with the CLI fallback), [`tasks`] reads checklists, [`templates`] -//! embeds scaffolding. +//! embeds scaffolding, [`terms`] reads the ontology, [`term_links`] +//! parses and resolves term links over the [`prose`] view, [`lint`] +//! applies the prose rules. //! - This file keeps the shared vocabulary (summaries, diagnostics) and //! the file/discovery plumbing every submodule uses. //! - Tests: unit tests at the bottom of each module; the `OpenSpec` v1.6.0 @@ -45,14 +49,18 @@ use std::sync::LazyLock; mod apply; mod commands; mod doctor; +mod fix; +mod glossary; mod lint; mod model; #[cfg(test)] mod openspec_oracle; mod parse; +mod prose; mod root; mod tasks; mod templates; +mod term_links; mod terms; pub(crate) mod transaction; mod validate; @@ -65,16 +73,20 @@ pub use commands::{ scan_changes, scan_specifications, show, show_output, sorted_changes, }; pub use doctor::{SpecDoctorOutput, doctor, doctor_output, render_doctor}; +pub use fix::{DraftTerm, FixOutput, SkippedTerm, fix, fix_output, render_fix}; +pub use glossary::{ + DRAFT_COMMENT, GlossaryCheckOutput, GlossaryEntry, GlossaryOutput, GlossaryStatus, + GlossaryTerm, GlossaryWriteOutput, INDEX_FILE, check_glossary, check_glossary_output, glossary, + glossary_output, render_glossary, render_glossary_check, render_glossary_write, write_glossary, + write_glossary_output, +}; pub use lint::{ DEFAULT_MIN_NAME_WORDS, MAX_REQUIREMENT_WORDS, MAX_SPEC_LINES, MAX_STEP_WORDS, min_name_words, name_words, set_name_rule_lookup, }; pub use terms::{ - DEFAULT_ONTOLOGY_FILE, GLOSSARY_FILE, GlossaryCheckOutput, GlossaryEntry, GlossaryOutput, - GlossaryStatus, GlossaryTerm, GlossaryWriteOutput, Term, TermKind, TermSource, Terms, - check_glossary, check_glossary_output, glossary, glossary_output, ontology_path, - render_glossary, render_glossary_check, render_glossary_file, set_ontology_lookup, - write_glossary, write_glossary_output, + DEFAULT_ONTOLOGY_FILE, GLOSSARY_DIR, GLOSSARY_FILE, Term, TermKind, TermSource, Terms, kebab, + ontology_path, set_ontology_lookup, upper_camel, }; pub use validate::lint_documents; pub use validate::{ diff --git a/rune-docs/src/spec/prose.rs b/rune-docs/src/spec/prose.rs new file mode 100644 index 00000000..38ffd27d --- /dev/null +++ b/rune-docs/src/spec/prose.rs @@ -0,0 +1,122 @@ +//! The prose view of a Markdown document: the lines outside fenced code +//! and outside a leading YAML front matter block, with inline code spans +//! blanked. Every rule that reads prose (keywords, term links, word caps) +//! and the fixer that rewrites it share this view, so a quoted literal +//! never counts as a statement or a term, and a byte offset found in the +//! view is the same offset in the original line. + +/// Non-fenced lines with their one-based numbers, inline code spans +/// blanked: a quoted literal such as `` `SHALL` `` is a mention, not a +/// normative statement or a defined term. Backtick and tilde fences both +/// count. A leading YAML front matter block, closed by `---` or `...`, is +/// data, not prose, and is skipped whole; an unclosed block is prose. A +/// leading byte order mark is dropped, so a caller that rewrites lines +/// strips it the same way. +pub(super) fn prose_lines(content: &str) -> impl Iterator<Item = (usize, String)> + '_ { + let content = without_bom(content); + let mut fence: Option<(char, usize)> = None; + let mut front_matter = front_matter_end(content); + content + .lines() + .enumerate() + .filter_map(move |(index, line)| { + if front_matter.is_some_and(|end| index <= end) { + if front_matter == Some(index) { + front_matter = None; + } + return None; + } + let trimmed = line.trim_start(); + let marker = trimmed.chars().next().filter(|c| *c == '`' || *c == '~'); + let run = marker.map_or(0, |c| trimmed.chars().take_while(|d| *d == c).count()); + match fence { + Some((open, width)) + if marker == Some(open) && run >= width && trimmed[run..].trim().is_empty() => + { + fence = None; + None + } + Some(_) => None, + None if run >= 3 => { + fence = marker.map(|c| (c, run)); + None + } + None => Some((index + 1, without_inline_code(line))), + } + }) +} + +/// The content without a leading byte order mark. +pub(super) fn without_bom(content: &str) -> &str { + content.strip_prefix('\u{FEFF}').unwrap_or(content) +} + +/// The zero-based line index of the `---` or `...` that closes a leading +/// YAML front matter block, when the content opens with `---` and a +/// closing line exists. +fn front_matter_end(content: &str) -> Option<usize> { + let mut lines = content.lines().enumerate(); + let (_, first) = lines.next()?; + if first.trim_end() != "---" { + return None; + } + lines + .find(|(_, line)| matches!(line.trim_end(), "---" | "...")) + .map(|(index, _)| index) +} + +/// Replace every `` `...` `` span with one space per byte, so a byte +/// offset in the result is the same offset in the line and the span's +/// text never matches a rule. +fn without_inline_code(line: &str) -> String { + let mut result = String::with_capacity(line.len()); + let mut inside = false; + for character in line.chars() { + if character == '`' { + inside = !inside; + result.push(' '); + } else if inside { + result.extend(std::iter::repeat_n(' ', character.len_utf8())); + } else { + result.push(character); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fences_front_matter_and_code_spans_leave_the_prose_view() { + let content = + "---\ntitle: x\n---\n\nA `code` line.\n\n```\nfenced\n```\n~~~\ntilde\n~~~\nlast\n"; + let lines: Vec<_> = prose_lines(content).collect(); + assert_eq!( + lines, + [ + (4, String::new()), + (5, "A line.".to_string()), + (6, String::new()), + (13, "last".to_string()), + ] + ); + } + + #[test] + fn blanking_keeps_byte_offsets_for_multibyte_code() { + let line = "a `ü` b"; + let blanked = without_inline_code(line); + assert_eq!(blanked.len(), line.len()); + assert_eq!(&blanked[blanked.len() - 1..], "b"); + } + + #[test] + fn an_unclosed_front_matter_block_is_prose_and_a_bom_is_dropped() { + let unclosed: Vec<_> = prose_lines("---\ntitle: x\n\nprose\n").collect(); + assert_eq!(unclosed.len(), 4); + let bom: Vec<_> = prose_lines("\u{FEFF}first\n").collect(); + assert_eq!(bom, [(1, "first".to_string())]); + } +} diff --git a/rune-docs/src/spec/term_links.rs b/rune-docs/src/spec/term_links.rs new file mode 100644 index 00000000..0c95da91 --- /dev/null +++ b/rune-docs/src/spec/term_links.rs @@ -0,0 +1,596 @@ +//! Term links in prose: `*[text][TAG]*` at first use, `[text][TAG]` after +//! that, resolved through the `[TAG]: <relative page path>` definition at +//! the end of the file to a glossary page whose `iri:` names the ontology +//! term. When the page is absent the tag resolves the term. The legacy +//! form `*text* [TAG]` with an IRI definition is still read, so the fixer +//! can rewrite it and the validator can warn about it. + +use super::prose::prose_lines; +use super::terms::{Term, Terms, kebab}; +use super::{Error, ErrorKind}; +use regex::Regex; +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; +use std::sync::LazyLock; + +/// Directories a document walk never enters by name: version control, +/// generated, dependency, and fixture trees. A deployed tree (a directory +/// below the root that holds a `.manifest`) and the directories a caller +/// excludes, such as the glossary and the change archive, are skipped +/// by path. +pub(super) const SKIPPED: [&str; 11] = [ + ".git", + ".jj", + ".provenance", + ".specstory", + ".trash", + ".workspaces", + ".worktrees", + "build", + "fixtures", + "node_modules", + "target", +]; + +/// The deployment record a provider tree carries; a directory that holds +/// one is deployed output, which the walk never edits. +const MANIFEST: &str = ".manifest"; + +/// A reference-style link `[text][TAG]`, with the `*` that may wrap it. +static REFERENCE_LINK: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"(\*?)\[([^\[\]\n]+)\]\[([A-Za-z0-9][A-Za-z0-9_-]*)\](\*?)") + .expect("reference link regex is valid") +}); +/// The legacy form: a single-asterisk italic run followed by a bare tag. +static LEGACY_LINK: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"\*([^*\[\]\n]+?)\*\s*\[([A-Za-z0-9][A-Za-z0-9_-]*)\]") + .expect("legacy link regex is valid") +}); +/// A reference definition, `[TAG]: <target>`, indented up to three spaces. +static REFERENCE_DEFINITION: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"^ {0,3}\[([A-Za-z0-9][A-Za-z0-9_-]*)\]:\s*(\S+)") + .expect("reference definition regex is valid") +}); + +/// One link in a prose line that may name a term. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct TermLink { + /// One-based line number. + pub line: usize, + pub text: String, + /// The tag as written; Markdown compares it without case. + pub tag: String, + /// `*[text][TAG]*`: the form of a first use. + pub emphasized: bool, + /// `*text* [TAG]`: the form before the link model. + pub legacy: bool, + /// Byte span of the whole link within the line, emphasis included. + pub start: usize, + pub end: usize, +} + +impl TermLink { + /// The link in the link model, emphasized or plain. + pub fn render(&self, emphasized: bool) -> String { + if emphasized { + format!("*[{}][{}]*", self.text, self.tag) + } else { + format!("[{}][{}]", self.text, self.tag) + } + } +} + +/// The reference-style links of one prose line, in order. An image +/// `![alt][TAG]` is not a link to a page. +pub(super) fn reference_links(number: usize, line: &str) -> Vec<TermLink> { + REFERENCE_LINK + .captures_iter(line) + .filter_map(|capture| { + let whole = capture.get(0).expect("whole match"); + let opening = capture.get(1).expect("opening emphasis").as_str(); + let closing = capture.get(4).expect("closing emphasis").as_str(); + let emphasized = !opening.is_empty() && !closing.is_empty(); + // Without matching emphasis the asterisks belong to the text + // around the link, so the span is the bare link. + let start = if emphasized { + whole.start() + } else { + whole.start() + opening.len() + }; + let end = if emphasized { + whole.end() + } else { + whole.end() - closing.len() + }; + if line[..start].ends_with('!') { + return None; + } + Some(TermLink { + line: number, + text: capture[2].to_string(), + tag: capture[3].to_string(), + emphasized, + legacy: false, + start, + end, + }) + }) + .collect() +} + +/// The legacy `*text* [TAG]` runs of one prose line. A run counts when +/// the character before its opening `*` is neither `*`, a word character, +/// nor a backslash, and the tag is not the start of a link or a +/// definition. +pub(super) fn legacy_links(number: usize, line: &str) -> Vec<TermLink> { + LEGACY_LINK + .captures_iter(line) + .filter_map(|capture| { + let whole = capture.get(0).expect("whole match"); + let before = line[..whole.start()].chars().next_back(); + let bounded = + before.is_none_or(|c| c != '*' && !c.is_alphanumeric() && c != '_' && c != '\\'); + let after = line[whole.end()..].chars().next(); + if !bounded || matches!(after, Some('[' | '(' | ':')) { + return None; + } + Some(TermLink { + line: number, + text: capture[1].trim().to_string(), + tag: capture[2].to_string(), + emphasized: true, + legacy: true, + start: whole.start(), + end: whole.end(), + }) + }) + .collect() +} + +/// One `[TAG]: <target>` line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Definition { + pub line: usize, + /// The tag as written. + pub tag: String, + /// The destination, angle brackets removed. + pub target: String, + /// Byte span of the destination as written, angle brackets included. + pub target_start: usize, + pub target_end: usize, +} + +/// Reference definitions by upper-cased tag (Markdown labels are +/// case-insensitive). The first definition of a tag wins, as in Markdown. +pub(super) fn reference_definitions<'a>( + prose: impl Iterator<Item = (usize, &'a str)>, +) -> BTreeMap<String, Definition> { + let mut definitions = BTreeMap::new(); + for (number, line) in prose { + if let Some(capture) = REFERENCE_DEFINITION.captures(line) { + let raw = capture.get(2).expect("destination"); + let target = raw + .as_str() + .strip_prefix('<') + .and_then(|inner| inner.strip_suffix('>')) + .unwrap_or(raw.as_str()); + definitions + .entry(capture[1].to_uppercase()) + .or_insert(Definition { + line: number, + tag: capture[1].to_string(), + target: target.to_string(), + target_start: raw.start(), + target_end: raw.end(), + }); + } + } + definitions +} + +/// Whether a line is a reference definition. +pub(super) fn is_definition(line: &str) -> bool { + REFERENCE_DEFINITION.is_match(line) +} + +/// What a definition points at. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Target { + /// A page under the glossary directory. + Page(PathBuf), + /// An IRI under a namespace the ontology declares terms in. + Iri(String), + /// Another file or address: an ordinary link. + Other, + /// The tag has no definition in the file. + Undefined, +} + +pub(super) fn classify(definition: Option<&Definition>, file_dir: &Path, terms: &Terms) -> Target { + let Some(definition) = definition else { + return Target::Undefined; + }; + if definition.target.contains("://") { + return if terms.in_namespace(&definition.target) { + Target::Iri(definition.target.clone()) + } else { + Target::Other + }; + } + let glossary = terms.glossary_dir(); + if glossary.as_os_str().is_empty() { + return Target::Other; + } + let target = definition.target.split('#').next().unwrap_or_default(); + let path = normalize(&canonical(file_dir).join(target)); + if path.starts_with(canonical(glossary)) && path.extension().is_some_and(|e| e == "md") { + Target::Page(path) + } else { + Target::Other + } +} + +/// The path with its deepest existing ancestor canonicalized, so two +/// spellings of one root (`/tmp` and `/private/tmp`, a symlinked +/// checkout) compare equal, and a file that does not exist yet still gets +/// a canonical parent. +pub(super) fn canonical(path: &Path) -> PathBuf { + let path = normalize(path); + let mut rest = Vec::new(); + let mut ancestor = path.as_path(); + loop { + if let Ok(real) = ancestor.canonicalize() { + let mut result = real; + for component in rest.iter().rev() { + result.push(component); + } + return result; + } + let Some(name) = ancestor.file_name() else { + return path; + }; + rest.push(name.to_os_string()); + let Some(parent) = ancestor.parent() else { + return path; + }; + ancestor = parent; + } +} + +/// How a link reached its term. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Via { + /// The page exists and its `iri:` names the term. + Page, + /// The page is absent; the tag names the term. + PageMissing, + /// The tag has no definition; the tag names the term. + NoDefinition, + /// The definition is the term's IRI, the form before pages existed. + Iri, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Resolution<'t> { + /// Not a term link: its definition points at another file or address. + Ordinary, + Term { + term: &'t Term, + via: Via, + /// The page the definition points at, when it points at one. + page: Option<PathBuf>, + }, + /// A link that names no term: its definition reaches the glossary or + /// a term namespace without naming one, or it has no definition at + /// all, which no link resolves through. + Undefined { + page: Option<PathBuf>, + /// The page is absent and the tag names nothing: the fixer may + /// add a draft. + draftable: bool, + reason: String, + /// What settles the link, for the validator's message. + hint: String, + }, +} + +pub(super) fn resolve<'t>( + terms: &'t Terms, + link: &TermLink, + definitions: &BTreeMap<String, Definition>, + file_dir: &Path, +) -> Resolution<'t> { + let definition = definitions.get(&link.tag.to_uppercase()); + let define = format!("define it in {}", terms.location()); + let fixer = "run `rune spec fix --terms` to add a draft".to_string(); + match classify(definition, file_dir, terms) { + Target::Page(page) => match std::fs::read_to_string(&page) { + Ok(text) => match page_iri(&text) { + Some(iri) => terms.by_iri(iri).map_or_else( + || Resolution::Undefined { + page: Some(page.clone()), + draftable: false, + reason: format!( + "its page names {iri}, which is not a term in {}", + terms.location() + ), + hint: define.clone(), + }, + |term| Resolution::Term { + term, + via: Via::Page, + page: Some(page.clone()), + }, + ), + None => Resolution::Undefined { + page: Some(page), + draftable: false, + reason: "its page has no iri field".to_string(), + hint: "run `rune spec glossary --write`".to_string(), + }, + }, + Err(_) => terms.by_tag(&link.tag).map_or_else( + || Resolution::Undefined { + page: Some(page.clone()), + draftable: true, + reason: format!( + "the page is missing and no term in {} has the tag", + terms.location() + ), + hint: fixer.clone(), + }, + |term| Resolution::Term { + term, + via: Via::PageMissing, + page: Some(page.clone()), + }, + ), + }, + Target::Iri(iri) => terms.by_iri(&iri).map_or_else( + || Resolution::Undefined { + page: None, + draftable: false, + reason: format!("{iri} is not a term in {}", terms.location()), + hint: define.clone(), + }, + |term| Resolution::Term { + term, + via: Via::Iri, + page: None, + }, + ), + Target::Other => Resolution::Ordinary, + // No definition at all: nothing resolves such a link, so it is a + // dangling link whether or not a term was meant. + Target::Undefined => terms.by_tag(&link.tag).map_or_else( + || { + let glossary = terms.glossary_dir(); + let page = if glossary.as_os_str().is_empty() { + "<page path>".to_string() + } else { + relative_link( + file_dir, + &glossary.join(format!("{}.md", kebab(&link.text))), + ) + }; + Resolution::Undefined { + page: None, + draftable: false, + reason: "the tag has no definition in this file and no term has the tag" + .to_string(), + hint: format!( + "add `[{}]: {page}` at the end of the file for a term, or the target of an ordinary link", + link.tag + ), + } + }, + |term| Resolution::Term { + term, + via: Via::NoDefinition, + page: None, + }, + ), + } +} + +/// Every reference-style link of a document with its resolution, in +/// document order. +pub(super) fn resolve_document<'t>( + content: &str, + path: &Path, + terms: &'t Terms, +) -> Vec<(TermLink, Resolution<'t>)> { + let file_dir = path.parent().unwrap_or(Path::new("")); + let view: Vec<(usize, String)> = prose_lines(content).collect(); + let definitions = + reference_definitions(view.iter().map(|(number, line)| (*number, line.as_str()))); + view.iter() + .flat_map(|(number, line)| reference_links(*number, line)) + .map(|link| { + let resolution = resolve(terms, &link, &definitions, file_dir); + (link, resolution) + }) + .collect() +} + +/// Every Markdown file below `root`, sorted, outside the skipped +/// directories, outside every directory in `excludes`, and outside any +/// deployed tree below the root. Symlinks are not followed. +pub(super) fn markdown_files(root: &Path, excludes: &[&Path]) -> Result<Vec<PathBuf>, Error> { + let io_error = |path: &Path, error: std::io::Error| { + Error::new( + ErrorKind::Io, + format!("cannot read {}: {error}", path.display()), + ) + }; + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(directory) = stack.pop() { + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(io_error(&directory, error)), + }; + for entry in entries { + let entry = entry.map_err(|error| io_error(&directory, error))?; + let path = entry.path(); + let file_type = entry.file_type().map_err(|error| io_error(&path, error))?; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + let name = entry.file_name(); + if !excludes.contains(&path.as_path()) + && !SKIPPED.contains(&name.to_string_lossy().as_ref()) + && !path.join(MANIFEST).is_file() + { + stack.push(path); + } + } else if path.extension().is_some_and(|extension| extension == "md") { + files.push(path); + } + } + } + files.sort(); + Ok(files) +} + +/// The `iri:` value of a page's front matter. +pub(super) fn page_iri(text: &str) -> Option<&str> { + let rest = text.strip_prefix("---\n")?; + let end = rest.find("\n---\n")?; + rest[..end] + .lines() + .find_map(|line| line.strip_prefix("iri:")) + .map(str::trim) + .filter(|iri| !iri.is_empty()) +} + +/// The path without `.` and `..` components, resolved lexically. +pub(super) fn normalize(path: &Path) -> PathBuf { + let mut result = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !result.pop() { + result.push(".."); + } + } + other => result.push(other), + } + } + result +} + +/// The relative link from a directory to a file, with `/` separators, as +/// `os.path.relpath` computes it. +pub(super) fn relative_link(from_dir: &Path, to: &Path) -> String { + let from_dir = canonical(from_dir); + let to = canonical(to); + let from: Vec<_> = from_dir.components().collect(); + let to: Vec<_> = to.components().collect(); + let shared = from + .iter() + .zip(to.iter()) + .take_while(|(left, right)| left == right) + .count(); + let mut parts: Vec<String> = vec!["..".to_string(); from.len() - shared]; + parts.extend( + to[shared..] + .iter() + .map(|component| component.as_os_str().to_string_lossy().into_owned()), + ); + parts.join("/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reference_links_carry_emphasis_and_skip_images() { + let links = reference_links( + 3, + "A *[harness][HARNESS]* loads a [canon][CANON]; ![alt][IMG] *[x][X] and [y][Y]*.", + ); + let summary: Vec<_> = links + .iter() + .map(|link| (link.text.as_str(), link.tag.as_str(), link.emphasized, &"A *[harness][HARNESS]* loads a [canon][CANON]; ![alt][IMG] *[x][X] and [y][Y]*."[link.start..link.end])) + .collect(); + assert_eq!( + summary, + [ + ("harness", "HARNESS", true, "*[harness][HARNESS]*"), + ("canon", "CANON", false, "[canon][CANON]"), + ("x", "X", false, "[x][X]"), + ("y", "Y", false, "[y][Y]"), + ] + ); + assert_eq!(links[0].render(false), "[harness][HARNESS]"); + assert_eq!(links[1].render(true), "*[canon][CANON]*"); + } + + #[test] + fn legacy_links_need_a_bare_tag_after_an_italic_run() { + let line = + "Every *harness* [HARNESS] and *canon* [CANON](x) or x*not* [N] and *[new][NEW]*."; + let links = legacy_links(1, line); + let found: Vec<_> = links + .iter() + .map(|link| &line[link.start..link.end]) + .collect(); + assert_eq!(found, ["*harness* [HARNESS]"]); + assert!(links[0].legacy); + assert_eq!(links[0].text, "harness"); + } + + #[test] + fn definitions_keep_the_first_tag_and_unwrap_angle_brackets() { + let content = "[A]: <https://x/A>\n[a]: other\n [B]: ../glossary/b.md \"title\"\n"; + let definitions = + reference_definitions(content.lines().enumerate().map(|(i, l)| (i + 1, l))); + assert_eq!(definitions["A"].target, "https://x/A"); + assert_eq!(definitions["A"].line, 1); + assert_eq!(definitions["B"].target, "../glossary/b.md"); + assert_eq!( + &content.lines().nth(2).unwrap() + [definitions["B"].target_start..definitions["B"].target_end], + "../glossary/b.md" + ); + assert!(is_definition("[X]: y")); + assert!(!is_definition("[x][y]")); + } + + #[test] + fn paths_normalize_and_relative_links_climb() { + assert_eq!( + normalize(Path::new("/r/docs/changes/x/./specs/../../glossary/a.md")), + PathBuf::from("/r/docs/changes/glossary/a.md") + ); + assert_eq!( + relative_link( + Path::new("/r/docs/changes/x/specs/y"), + Path::new("/r/docs/specs/glossary/a.md") + ), + "../../../../specs/glossary/a.md" + ); + assert_eq!( + relative_link( + Path::new("/r/docs/specs"), + Path::new("/r/docs/specs/glossary/a.md") + ), + "glossary/a.md" + ); + } + + #[test] + fn a_page_iri_comes_from_the_front_matter() { + assert_eq!( + page_iri("---\ntitle: x\niri: https://runedeck.ai/ns#X\n---\n\n# x\n"), + Some("https://runedeck.ai/ns#X") + ); + assert_eq!(page_iri("# no front matter\n"), None); + assert_eq!(page_iri("---\ntitle: x\n---\n"), None); + } +} diff --git a/rune-docs/src/spec/terms.rs b/rune-docs/src/spec/terms.rs index a2c323ce..01b3c055 100644 --- a/rune-docs/src/spec/terms.rs +++ b/rune-docs/src/spec/terms.rs @@ -1,23 +1,26 @@ -//! The vocabulary a repository's prose may set in italics: every term the -//! ontology defines, or, without an ontology, the entries of `glossary.md`. -//! An ontology term also carries an IRI, so a document that sets the term -//! in italics cites it as `*term* [TAG]` with `[TAG]: <iri>` at the end of -//! the file, and validation proves the citation resolves. +//! The vocabulary a repository's prose may link: every term the ontology +//! defines. A document marks a term as a reference-style link to the +//! term's generated glossary page, `*[text][TAG]*` at first use and +//! `[text][TAG]` after that, with `[TAG]: <relative page path>` once at +//! the end of the file. Validation proves every link resolves. //! //! The Turtle reader is a statement-shape parser, not an RDF parser. It //! reads the shape the deck writes: `prefix:Name a <type> ; predicates .` //! with the terminating period at the end of a line. Anything it cannot //! read is an error, never a silently missing term. -use super::commands::print_json; -use super::{Error, ErrorKind, relative_display, resolve_spec_root}; +use super::{Error, ErrorKind, relative_display}; use regex::Regex; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::{LazyLock, OnceLock}; -/// The glossary sits beside the capability directories. +/// The generated pages sit in this directory beside the capability +/// directories: one page per term and an `index.md`. +pub const GLOSSARY_DIR: &str = "glossary"; +/// The single glossary file an older render wrote beside the capability +/// directories. `rune spec glossary --write` removes it. pub const GLOSSARY_FILE: &str = "glossary.md"; /// The ontology sits at the repository root unless `deck.yaml` names another file. pub const DEFAULT_ONTOLOGY_FILE: &str = "ontology/rune.ttl"; @@ -64,25 +67,35 @@ static LABEL: LazyLock<Regex> = LazyLock::new(|| { static COMMENT: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r#"rdfs:comment\s+"((?:[^"\\]|\\.)*)""#).expect("comment regex is valid") }); +/// The status the fixer writes on a term nobody has defined yet. +static DRAFT: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r#"(?:[A-Za-z_][\w-]*)?:status\s+"draft""#).expect("draft regex is valid") +}); static TURTLE_COMMENT_LINE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^\s*#[^\n]*$").expect("comment line regex is valid")); -static GLOSSARY_ENTRY: LazyLock<Regex> = LazyLock::new(|| { - Regex::new(r"^\s*-\s+\*\*([^*\n]+?)\*\*\s*:\s*(.*?)\s*$") - .expect("glossary entry regex is valid") -}); /// What kind of resource a term is. Classes take the plain reference tag -/// when a property shares their name. +/// and page name when a property shares their label. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "lowercase")] pub enum TermKind { Class, Property, Concept, - /// A glossary entry, which has no RDF type. - Entry, } -/// One defined term. From a glossary the name is the label and the IRI is empty. +impl TermKind { + /// The word the glossary page's `kind:` field carries. + pub fn name(self) -> &'static str { + match self { + Self::Class => "class", + Self::Property => "property", + Self::Concept => "concept", + } + } +} + +/// One defined term. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Term { pub name: String, @@ -90,15 +103,17 @@ pub struct Term { pub label: String, pub comment: String, pub kind: TermKind, + /// The fixer added this term from a link and nobody has defined it + /// yet. The validator warns on it and archive refuses it. + pub draft: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TermSource { /// `ontology/rune.ttl` or the file `deck.yaml` names. Ontology, - /// `<specs root>/glossary.md`, the fallback for a repository without an ontology. - Glossary, - /// Neither file exists; the first italic term is an error that names the glossary to create. + /// No ontology file exists: the repository defines no terms, and a + /// reference link is an ordinary link. #[default] Absent, } @@ -106,51 +121,59 @@ pub enum TermSource { #[derive(Debug, Default)] pub struct Terms { source: TermSource, - /// Repository-relative path of the source file, for diagnostics. + /// Repository-relative path of the ontology, for diagnostics and the + /// generated pages. location: String, + /// The directory the glossary pages live in. + glossary: PathBuf, entries: Vec<Term>, - by_label: BTreeMap<String, usize>, by_iri: BTreeMap<String, usize>, /// Every IRI prefix a term was declared under. namespaces: BTreeSet<String>, + /// Turtle prefix by namespace IRI, for the fixer's draft block. + prefixes: BTreeMap<String, String>, + /// Reference tag per entry: the label in upper case, letters only, + /// with `-KEY` suffixes where a property collides with a class. + tags: Vec<String>, + /// The tag an older render gave each entry: the local name in upper + /// case. Accepted when a page is absent, so existing files resolve. + name_tags: Vec<String>, + /// Page file name per entry, `kebab(label).md` with `-key` suffixes + /// that mirror the tag. + pages: Vec<String>, + by_tag: BTreeMap<String, usize>, } impl Terms { - /// The ontology when the repository has one, else the glossary beside - /// the specifications, else an absent source that names the glossary. - /// Only a missing ontology file falls back; any other failure to read + /// The ontology when the repository has one, else an absent source. + /// Only a missing ontology file is absent; any other failure to read /// or parse it is an error. pub fn load(repository: &Path, specifications: &Path) -> Result<Self, Error> { let ontology = ontology_path(repository)?; - let ontology_location = relative_display(repository, &ontology); + let location = relative_display(repository, &ontology); + let glossary = specifications.join(GLOSSARY_DIR); match std::fs::read_to_string(&ontology) { - Ok(text) => return Self::from_turtle(&text, &ontology_location), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(Error::new( - ErrorKind::Io, - format!("cannot read ontology {ontology_location}: {error}"), - )); + Ok(text) => { + let mut terms = Self::from_turtle(&text, &location)?; + terms.glossary = glossary; + Ok(terms) } - } - let glossary = specifications.join(GLOSSARY_FILE); - let location = relative_display(repository, &glossary); - match std::fs::read_to_string(&glossary) { - Ok(text) => Ok(Self::from_glossary(&text, location)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self { location, + glossary, ..Self::default() }), Err(error) => Err(Error::new( ErrorKind::Io, - format!("cannot read glossary {location}: {error}"), + format!("cannot read ontology {location}: {error}"), )), } } /// Read every labeled class, property, and concept from Turtle text. - /// An undeclared prefix, two terms sharing a label (compared without - /// case), or an ontology with no labeled term is an error. + /// An undeclared prefix, two terms of one kind sharing a label + /// (compared without case), or an ontology with no labeled term is an + /// error. pub fn from_turtle(text: &str, location: &str) -> Result<Self, Error> { let text = text.strip_prefix('\u{FEFF}').unwrap_or(text); let text = TURTLE_COMMENT_LINE.replace_all(text, ""); @@ -168,6 +191,7 @@ impl Terms { location: location.to_string(), ..Self::default() }; + let mut by_label: BTreeMap<(String, TermKind), String> = BTreeMap::new(); for capture in TERM.captures_iter(&text) { let prefix = capture.get(1).map_or("", |prefix| prefix.as_str()); let name = &capture[2]; @@ -188,6 +212,10 @@ impl Terms { _ => TermKind::Class, }; terms.namespaces.insert((*namespace).to_string()); + terms + .prefixes + .entry((*namespace).to_string()) + .or_insert_with(|| prefix.to_string()); let term = Term { name: name.to_string(), iri: format!("{namespace}{name}"), @@ -197,25 +225,16 @@ impl Terms { .map(|comment| unescape(&comment[1])) .unwrap_or_default(), kind, + draft: DRAFT.is_match(&capture[4]), }; - let label = normalize(&term.label); - match terms.by_label.get(&label).copied() { - Some(index) if terms.entries[index].kind == term.kind => { - return Err(Error::new( - ErrorKind::Parse, - format!( - "{location}: {} and {} share the label \"{}\"; labels are compared without case", - terms.entries[index].iri, term.iri, term.label - ), - )); - } - // A class and its property may share a label (`Change` and - // `change`); the italic term is the class, the property is - // reached through its own tag. - Some(index) if precedence(term.kind) >= precedence(terms.entries[index].kind) => {} - _ => { - terms.by_label.insert(label, terms.entries.len()); - } + if let Some(other) = by_label.insert((normalize(&term.label), kind), term.iri.clone()) { + return Err(Error::new( + ErrorKind::Parse, + format!( + "{location}: {other} and {} share the label \"{}\"; labels are compared without case", + term.iri, term.label + ), + )); } terms.entries.push(term); } @@ -225,11 +244,6 @@ impl Terms { format!("{location}: no class, property, or concept carries an rdfs:label"), )); } - let preferred: Vec<String> = terms - .by_label - .values() - .map(|index| terms.entries[*index].iri.clone()) - .collect(); // By label, then the class before the property that shares it, then // by name, so the order does not depend on the order of the file. terms.entries.sort_by(|left, right| { @@ -238,62 +252,30 @@ impl Terms { .then_with(|| precedence(left.kind).cmp(&precedence(right.kind))) .then_with(|| left.name.cmp(&right.name)) }); - terms.index(&preferred); + terms.index(); Ok(terms) } - /// Read `- **term**: definition` bullets outside fenced code. - pub fn from_glossary(text: &str, location: String) -> Self { - let mut terms = Self { - source: TermSource::Glossary, - location, - ..Self::default() - }; - let mut fence: Option<(char, usize)> = None; - for line in text.lines() { - let trimmed = line.trim_start(); - let marker = trimmed.chars().next().filter(|c| *c == '`' || *c == '~'); - let run = marker.map_or(0, |c| trimmed.chars().take_while(|d| *d == c).count()); - match fence { - Some((open, width)) if marker == Some(open) && run >= width => { - fence = None; - continue; - } - Some(_) => continue, - None if run >= 3 => { - fence = marker.map(|c| (c, run)); - continue; - } - None => {} - } - if let Some(capture) = GLOSSARY_ENTRY.captures(line) { - let label = capture[1].trim().to_string(); - terms.entries.push(Term { - name: label.clone(), - iri: String::new(), - label, - comment: capture[2].to_string(), - kind: TermKind::Entry, - }); - } + /// Rebuild the lookups, tags, and page names after sorting. + fn index(&mut self) { + self.by_iri = self + .entries + .iter() + .enumerate() + .map(|(index, term)| (term.iri.clone(), index)) + .collect(); + self.tags = unique_tags(&self.entries, |term| tag_base(&term.label)); + self.name_tags = unique_tags(&self.entries, |term| term.name.to_uppercase()); + self.pages = unique_tags(&self.entries, |term| kebab(&term.label)) + .into_iter() + .map(|page| format!("{}.md", page.replace("-KEY", "-key"))) + .collect(); + self.by_tag.clear(); + for (index, tag) in self.name_tags.iter().enumerate() { + self.by_tag.insert(tag.clone(), index); } - terms.index(&[]); - terms - } - - /// Rebuild the lookups after sorting. `preferred` names the IRI that - /// owns each shared label; every other label maps to its only term. - fn index(&mut self, preferred: &[String]) { - self.by_label.clear(); - self.by_iri.clear(); - for (index, term) in self.entries.iter().enumerate() { - let label = normalize(&term.label); - if preferred.contains(&term.iri) || !self.by_label.contains_key(&label) { - self.by_label.insert(label, index); - } - if !term.iri.is_empty() { - self.by_iri.insert(term.iri.clone(), index); - } + for (index, tag) in self.tags.iter().enumerate() { + self.by_tag.insert(tag.clone(), index); } } @@ -305,6 +287,11 @@ impl Terms { &self.location } + /// The directory the glossary pages live in. + pub fn glossary_dir(&self) -> &Path { + &self.glossary + } + /// Whether an IRI sits under a namespace the ontology declares terms in. pub fn in_namespace(&self, iri: &str) -> bool { self.namespaces @@ -312,443 +299,176 @@ impl Terms { .any(|namespace| iri.starts_with(namespace)) } + /// The Turtle prefix and namespace the fixer writes a draft under: the + /// namespace of the first term. + pub fn primary_prefix(&self) -> Option<(&str, &str)> { + let namespace = self.entries.first().and_then(|term| { + self.namespaces + .iter() + .find(|namespace| term.iri.starts_with(namespace.as_str())) + })?; + Some((self.prefixes.get(namespace)?.as_str(), namespace.as_str())) + } + /// Every term, sorted by label. pub fn terms(&self) -> &[Term] { &self.entries } - /// A term matches its label exactly or as a plain plural (`trailers` - /// finds `trailer`, `harnesses` finds `harness`), compared without case. - pub fn resolve(&self, italic: &str) -> Option<&Term> { - let normalized = normalize(italic); - let singulars = [ - Some(normalized.as_str()), - normalized.strip_suffix('s'), - normalized.strip_suffix("es"), - ]; - singulars - .into_iter() - .flatten() - .find_map(|candidate| self.by_label.get(candidate)) - .map(|index| &self.entries[*index]) - } - pub fn by_iri(&self, iri: &str) -> Option<&Term> { self.by_iri.get(iri).map(|index| &self.entries[*index]) } - /// The reference tag for each term, keyed by IRI: the local name in - /// upper case. Markdown reference labels are case-insensitive, so when - /// two names collide the class keeps the plain tag and every later - /// term takes `-KEY` suffixes until its tag is unique. - pub fn tags(&self) -> BTreeMap<String, String> { - let mut order: Vec<&Term> = self.entries.iter().collect(); - order.sort_by(|left, right| (left.kind, &left.label).cmp(&(right.kind, &right.label))); - let mut tags = BTreeMap::new(); - let mut taken = BTreeSet::new(); - for term in order { - let mut tag = term.name.to_uppercase(); - while !taken.insert(tag.clone()) { - tag.push_str("-KEY"); - } - tags.insert(term.iri.clone(), tag); - } - tags + /// The term a reference tag names, compared without case: the label + /// tag first, then the local-name tag an older render used. + pub fn by_tag(&self, tag: &str) -> Option<&Term> { + self.by_tag + .get(&tag.to_uppercase()) + .map(|index| &self.entries[*index]) } -} -/// Which kind owns a label two terms share: the class, then the concept, -/// then the property. -fn precedence(kind: TermKind) -> u8 { - match kind { - TermKind::Class => 0, - TermKind::Concept => 1, - TermKind::Property => 2, - TermKind::Entry => 3, + /// Whether a local name is taken, compared without case. + pub fn has_name(&self, name: &str) -> bool { + self.entries + .iter() + .any(|term| term.name.eq_ignore_ascii_case(name)) } -} - -fn normalize(term: &str) -> String { - term.trim().to_lowercase() -} - -/// Undo the Turtle string escapes the deck uses: `\"` and `\\`. -fn unescape(literal: &str) -> String { - literal.replace("\\\"", "\"").replace("\\\\", "\\") -} - -/// `rune spec glossary`: the term list, one `- **label**: comment [TAG]` line -/// per term and the `[TAG]: <iri>` definitions a document copies. -#[derive(Debug, Serialize)] -pub struct GlossaryOutput { - pub source: String, - pub terms: Vec<GlossaryTerm>, -} - -#[derive(Debug, Serialize)] -pub struct GlossaryTerm { - pub label: String, - pub comment: String, - pub name: String, - pub iri: String, - pub kind: TermKind, - pub tag: String, -} -impl GlossaryOutput { - fn from_terms(terms: &Terms) -> Self { - let tags = terms.tags(); - Self { - source: terms.location().to_string(), - terms: terms - .terms() - .iter() - .map(|term| GlossaryTerm { - label: term.label.clone(), - comment: term.comment.clone(), - name: term.name.clone(), - iri: term.iri.clone(), - kind: term.kind, - tag: tags.get(&term.iri).cloned().unwrap_or_default(), - }) - .collect(), - } + /// The reference tag of a term: its label in upper case with every + /// non-letter removed, `-KEY` suffixed while another term holds it. + pub fn tag(&self, term: &Term) -> &str { + self.by_iri + .get(&term.iri) + .map_or("", |index| self.tags[*index].as_str()) } -} -pub fn glossary_output(repository: &Path, specifications: &Path) -> Result<GlossaryOutput, Error> { - Ok(GlossaryOutput::from_terms(&Terms::load( - repository, - specifications, - )?)) -} - -/// Print the term list, or its JSON form. -pub fn glossary(source: &str, json: bool) -> Result<i32, Error> { - let spec_root = resolve_spec_root(Path::new(source))?; - let output = glossary_output(spec_root.repository(), spec_root.specifications())?; - if json { - print_json(&output)?; - } else { - print!("{}", render_glossary(&output)); + /// The file name of a term's glossary page. + pub fn page_name(&self, term: &Term) -> &str { + self.by_iri + .get(&term.iri) + .map_or("", |index| self.pages[*index].as_str()) } - Ok(0) -} -pub fn render_glossary(output: &GlossaryOutput) -> String { - let mut lines: Vec<String> = output.terms.iter().map(entry_line).collect(); - let definitions: Vec<String> = output - .terms - .iter() - .filter(|term| !term.tag.is_empty()) - .map(|term| format!("[{}]: {}", term.tag, term.iri)) - .collect(); - if !definitions.is_empty() { - lines.push(String::new()); - lines.extend(definitions); + /// The path of a term's glossary page. + pub fn page_path(&self, term: &Term) -> PathBuf { + self.glossary.join(self.page_name(term)) } - let mut text = lines.join("\n"); - text.push('\n'); - text -} -/// One `- **label**: comment [TAG]` line. A term without a comment keeps -/// one space before its tag. -fn entry_line(term: &GlossaryTerm) -> String { - let mut line = format!("- **{}**:", term.label); - if !term.comment.is_empty() { - line.push(' '); - line.push_str(&term.comment); - } - if !term.tag.is_empty() { - line.push_str(" ["); - line.push_str(&term.tag); - line.push(']'); + /// The Turtle block the fixer appends for a draft term. + pub fn draft_block(&self, name: &str, label: &str, origin: &str) -> Option<String> { + let (prefix, _) = self.primary_prefix()?; + Some(format!( + "\n# Draft term. The fixer added it from {origin}; define it or remove the link.\n\ + {prefix}:{name} a rdfs:Class ;\n rdfs:label \"{}\" ;\n {prefix}:status \"draft\" .\n", + escape(label) + )) } - line } -/// The command the glossary header and a drift report tell the reader to run. -const WRITE_COMMAND: &str = "rune spec glossary --write"; - -/// The glossary file: a header that names the ontology and the write -/// command, then the term list `render_glossary` prints. -pub fn render_glossary_file(output: &GlossaryOutput) -> String { - let namespace = output - .terms - .first() - .and_then(|term| term.iri.strip_suffix(term.name.as_str())) - .unwrap_or("https://runedeck.ai/ns#"); - format!( - "# Glossary\n\n\ - Generated from `{}` by `{WRITE_COMMAND}`. Do not edit this file. Change the ontology.\n\ - A specification sets a term in italics at first use and cites it as `*term* [TERM]` with\n\ - `[TERM]: {namespace}Term` at the end of the file.\n\n{}", - output.source, - render_glossary(output) - ) -} - -/// Where the glossary file sits and what the rendering looks like, for -/// `--write` and `--check`. The ontology is the only source a file can be -/// rendered from: a repository that reads its terms from a glossary has -/// nothing to render. -struct GlossaryFile { - path: PathBuf, - display: String, - custom: bool, - output: GlossaryOutput, +/// Which kind owns a label two terms share: the class, then the concept, +/// then the property. +fn precedence(kind: TermKind) -> u8 { + match kind { + TermKind::Class => 0, + TermKind::Concept => 1, + TermKind::Property => 2, + } } -impl GlossaryFile { - fn resolve(source: &str, path: Option<&str>) -> Result<Self, Error> { - let spec_root = resolve_spec_root(Path::new(source))?; - let repository = spec_root.repository(); - let terms = Terms::load(repository, spec_root.specifications())?; - if terms.source() != TermSource::Ontology { - return Err(Error::new( - ErrorKind::Config, - format!( - "no ontology at {}; the glossary file is rendered from the ontology", - relative_display(repository, &ontology_path(repository)?) - ), - )); +/// One tag per entry, in entry order. Markdown reference labels are +/// case-insensitive, so when two bases collide the class keeps the plain +/// tag and every later term takes `-KEY` suffixes until its tag is unique. +fn unique_tags(entries: &[Term], base: impl Fn(&Term) -> String) -> Vec<String> { + let mut order: Vec<usize> = (0..entries.len()).collect(); + order.sort_by(|left, right| { + (precedence(entries[*left].kind), &entries[*left].label) + .cmp(&(precedence(entries[*right].kind), &entries[*right].label)) + }); + let mut tags = vec![String::new(); entries.len()]; + let mut taken = BTreeSet::new(); + for index in order { + let term = &entries[index]; + let mut tag = base(term); + if tag.is_empty() { + tag = term.name.to_uppercase(); } - let file = match path { - Some(path) => repository.join(path), - None => spec_root.specifications().join(GLOSSARY_FILE), - }; - Ok(Self { - display: relative_display(repository, &file), - path: file, - custom: path.is_some(), - output: GlossaryOutput::from_terms(&terms), - }) - } - - fn write_command(&self) -> String { - if self.custom { - format!("{WRITE_COMMAND} {}", self.display) - } else { - WRITE_COMMAND.to_string() + while !taken.insert(tag.to_uppercase()) { + tag.push_str("-KEY"); } + tags[index] = tag; } + tags } -/// What `--write` reports. -#[derive(Debug, Serialize)] -pub struct GlossaryWriteOutput { - pub path: String, - pub terms: usize, -} - -/// Write the glossary file from the ontology, creating its directory. -pub fn write_glossary_output( - source: &str, - path: Option<&str>, -) -> Result<GlossaryWriteOutput, Error> { - let file = GlossaryFile::resolve(source, path)?; - let io_error = |error: std::io::Error| { - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", file.display), - ) - }; - if let Some(parent) = file.path.parent() { - std::fs::create_dir_all(parent).map_err(io_error)?; - } - std::fs::write(&file.path, render_glossary_file(&file.output)).map_err(io_error)?; - Ok(GlossaryWriteOutput { - path: file.display, - terms: file.output.terms.len(), - }) -} - -pub fn write_glossary(source: &str, path: Option<&str>, json: bool) -> Result<i32, Error> { - let output = write_glossary_output(source, path)?; - if json { - print_json(&output)?; - } else { - println!("wrote {}", output.path); - } - Ok(0) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum GlossaryStatus { - Current, - Stale, - Missing, -} - -/// One term a drift report names. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct GlossaryEntry { - pub label: String, - pub tag: String, +/// The label in upper case with every non-letter removed: +/// `analysis tool` is `ANALYSISTOOL`, `Occam's razor` is `OCCAMSRAZOR`. +fn tag_base(label: &str) -> String { + label + .chars() + .filter(char::is_ascii_alphabetic) + .flat_map(char::to_uppercase) + .collect() } -impl GlossaryEntry { - fn render(&self) -> String { - if self.tag.is_empty() { - self.label.clone() - } else { - format!("{} [{}]", self.label, self.tag) +/// The page file stem of a label: lower case, apostrophes dropped, every +/// run outside `a-z0-9` one hyphen, hyphens trimmed at both ends. +pub fn kebab(label: &str) -> String { + let mut result = String::with_capacity(label.len()); + for character in label.to_lowercase().chars() { + if matches!(character, '\'' | '\u{2019}') { + continue; + } + if character.is_ascii_alphanumeric() { + result.push(character); + } else if !result.ends_with('-') { + result.push('-'); } } + result.trim_matches('-').to_string() } -/// What `--check` reports: the terms the file lacks, the terms it holds -/// that the ontology no longer defines, and the terms whose line differs. -#[derive(Debug, Serialize)] -pub struct GlossaryCheckOutput { - pub path: String, - pub status: GlossaryStatus, - pub write_command: String, - pub missing: Vec<GlossaryEntry>, - pub extra: Vec<GlossaryEntry>, - pub changed: Vec<GlossaryEntry>, -} - -impl GlossaryCheckOutput { - pub fn is_current(&self) -> bool { - self.status == GlossaryStatus::Current - } -} - -static ENTRY_LINE: LazyLock<Regex> = LazyLock::new(|| { - Regex::new(r"^- \*\*(?P<label>[^*\n]+)\*\*:(?:.*?)(?: \[(?P<tag>[^\[\]\s]+)\])?$") - .expect("entry line regex is valid") -}); - -/// The entry lines of a glossary text, keyed by tag, or by label for a -/// line that carries no tag. -fn entry_lines(text: &str) -> Vec<(String, GlossaryEntry, &str)> { - text.lines() - .filter_map(|line| { - let capture = ENTRY_LINE.captures(line)?; - let entry = GlossaryEntry { - label: capture["label"].to_string(), - tag: capture - .name("tag") - .map_or_else(String::new, |tag| tag.as_str().to_string()), - }; - let key = if entry.tag.is_empty() { - format!("label:{}", entry.label) +/// A local name for a draft term: the link text in `UpperCamelCase`, with +/// apostrophes dropped (`Occam's razor` is `OccamsRazor`). +pub fn upper_camel(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut start = true; + for character in text.chars() { + if matches!(character, '\'' | '\u{2019}') { + continue; + } + if character.is_alphanumeric() { + if start { + result.extend(character.to_uppercase()); + start = false; } else { - format!("tag:{}", entry.tag) - }; - Some((key, entry, line)) - }) - .collect() -} - -/// Name the terms whose entry lines differ between the rendering and the file. -fn compare_glossary(output: &mut GlossaryCheckOutput, rendered: &str, current: &str) { - let wanted = entry_lines(rendered); - let found = entry_lines(current); - let found_by_key: BTreeMap<&str, &str> = found - .iter() - .map(|(key, _, line)| (key.as_str(), *line)) - .collect(); - let wanted_keys: BTreeSet<&str> = wanted.iter().map(|(key, _, _)| key.as_str()).collect(); - for (key, entry, line) in &wanted { - match found_by_key.get(key.as_str()) { - None => output.missing.push(entry.clone()), - Some(existing) if existing != line => output.changed.push(entry.clone()), - Some(_) => {} + result.push(character); + } + } else { + start = true; } } - output.extra = found - .iter() - .filter(|(key, _, _)| !wanted_keys.contains(key.as_str())) - .map(|(_, entry, _)| entry.clone()) - .collect(); + result } -/// Render the glossary in memory and compare it with the file, byte for byte. -pub fn check_glossary_output( - source: &str, - path: Option<&str>, -) -> Result<GlossaryCheckOutput, Error> { - let file = GlossaryFile::resolve(source, path)?; - let rendered = render_glossary_file(&file.output); - let mut output = GlossaryCheckOutput { - path: file.display.clone(), - status: GlossaryStatus::Current, - write_command: file.write_command(), - missing: Vec::new(), - extra: Vec::new(), - changed: Vec::new(), - }; - let current = match std::fs::read_to_string(&file.path) { - Ok(text) => text, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - output.status = GlossaryStatus::Missing; - return Ok(output); - } - Err(error) => { - return Err(Error::new( - ErrorKind::Io, - format!("cannot read {}: {error}", file.display), - )); - } - }; - if current != rendered { - output.status = GlossaryStatus::Stale; - compare_glossary(&mut output, &rendered, ¤t); - } - Ok(output) +fn normalize(term: &str) -> String { + term.trim().to_lowercase() } -pub fn render_glossary_check(output: &GlossaryCheckOutput) -> String { - use std::fmt::Write as _; - let mut text = match output.status { - GlossaryStatus::Current => return format!("{} is current\n", output.path), - GlossaryStatus::Missing => { - return format!( - "{} is missing: run `{}`\n", - output.path, output.write_command - ); - } - GlossaryStatus::Stale => { - format!("{} is stale: run `{}`\n", output.path, output.write_command) - } - }; - let rows = [ - ("missing", &output.missing), - ("extra", &output.extra), - ("changed", &output.changed), - ]; - let mut listed = false; - for (kind, entries) in rows { - for entry in entries { - listed = true; - let _ = writeln!(text, " {kind:<8} {}", entry.render()); - } - } - if !listed { - text.push_str(" the header or the definitions differ\n"); - } - text +/// Undo the Turtle string escapes the deck uses: `\"` and `\\`. +fn unescape(literal: &str) -> String { + literal.replace("\\\"", "\"").replace("\\\\", "\\") } -pub fn check_glossary(source: &str, path: Option<&str>, json: bool) -> Result<i32, Error> { - let output = check_glossary_output(source, path)?; - if json { - print_json(&output)?; - } else { - print!("{}", render_glossary_check(&output)); - } - Ok(i32::from(!output.is_current())) +/// Apply the Turtle string escapes the deck uses. +fn escape(literal: &str) -> String { + literal.replace('\\', "\\\\").replace('"', "\\\"") } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; - const TURTLE: &str = r#"@prefix rune: <https://runedeck.ai/ns#> . + pub(in crate::spec) const TURTLE: &str = r#"@prefix rune: <https://runedeck.ai/ns#> . @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> . @prefix skos: <http://www.w3.org/2004/02/skos/core#> . @@ -758,7 +478,7 @@ rune:Change a rdfs:Class ; rdfs:comment "One spec-driven unit of work." . rune:change a rdf:Property ; - rdfs:label "change key" ; + rdfs:label "change" ; rdfs:comment "The change a record belongs to." . rune:Falsifiability a skos:Concept ; skos:inScheme rune:principles ; @@ -768,6 +488,18 @@ rune:Falsifiability a skos:Concept ; skos:inScheme rune:principles ; rune:Unlabeled a rdfs:Class ; rdfs:comment "No label, so prose cannot name it." . +rune:OccamsRazor a skos:Concept ; + rdfs:label "Occam's razor" ; + rdfs:comment "Of two instructions that pass the same check, the shorter wins." . + +rune:Stage a rdfs:Class ; + rdfs:label "lifecycle stage" ; + rdfs:comment "Where a change sits." . + +rune:Widget a rdfs:Class ; + rdfs:label "widget" ; + rune:status "draft" . + rune:Tight a rdfs:Class ; rdfs:label "tight" ; rdfs:comment "Ends without a space before the period.". # trailing comment "#; @@ -776,50 +508,77 @@ rune:Tight a rdfs:Class ; rdfs:label "tight" ; rdfs:comment "Ends without a spac } #[test] - fn turtle_terms_carry_iri_label_kind_and_unescaped_comment() { + fn turtle_terms_carry_iri_label_kind_draft_and_unescaped_comment() { let terms = terms(); assert_eq!(terms.source(), TermSource::Ontology); assert!(terms.in_namespace("https://runedeck.ai/ns#Anything")); + assert_eq!( + terms.primary_prefix(), + Some(("rune", "https://runedeck.ai/ns#")) + ); let labels: Vec<_> = terms .terms() .iter() .map(|term| term.label.as_str()) .collect(); - assert_eq!(labels, ["change", "change key", "falsifiability", "tight"]); - let concept = terms.resolve("Falsifiability").expect("skos concept"); + assert_eq!( + labels, + [ + "Occam's razor", + "change", + "change", + "falsifiability", + "lifecycle stage", + "tight", + "widget" + ] + ); + let concept = terms + .by_iri("https://runedeck.ai/ns#Falsifiability") + .expect("skos concept"); assert_eq!(concept.kind, TermKind::Concept); - assert_eq!(concept.iri, "https://runedeck.ai/ns#Falsifiability"); assert!(concept.comment.contains("\"nothing can fail\"")); + assert!(!concept.draft); + let widget = terms.by_tag("widget").expect("draft term"); + assert!(widget.draft); + assert_eq!(widget.comment, ""); + assert_eq!( + terms.by_tag("TIGHT").map(|term| term.comment.as_str()), + Some("Ends without a space before the period.") + ); + assert!(terms.by_tag("unlabeled").is_none()); + assert!(terms.by_tag("fake").is_none()); + assert!(terms.has_name("stage")); + } + + #[test] + fn tags_follow_the_label_and_a_colliding_property_takes_the_key_suffix() { + let terms = terms(); + let class = terms.by_iri("https://runedeck.ai/ns#Change").unwrap(); + let property = terms.by_iri("https://runedeck.ai/ns#change").unwrap(); + assert_eq!(terms.tag(class), "CHANGE"); + assert_eq!(terms.tag(property), "CHANGE-KEY"); + assert_eq!(terms.page_name(class), "change.md"); + assert_eq!(terms.page_name(property), "change-key.md"); + let razor = terms.by_iri("https://runedeck.ai/ns#OccamsRazor").unwrap(); + assert_eq!(terms.tag(razor), "OCCAMSRAZOR"); + assert_eq!(terms.page_name(razor), "occams-razor.md"); + let stage = terms.by_iri("https://runedeck.ai/ns#Stage").unwrap(); + assert_eq!(terms.tag(stage), "LIFECYCLESTAGE"); + assert_eq!(terms.page_name(stage), "lifecycle-stage.md"); + // The older name-based tag still resolves, so existing files keep working. assert_eq!( - terms.resolve("changes").map(|term| term.name.as_str()), - Some("Change") + terms.by_tag("STAGE").map(|t| t.name.as_str()), + Some("Stage") ); assert_eq!( - terms.resolve("change key").map(|term| term.kind), + terms.by_tag("change-key").map(|t| t.kind), Some(TermKind::Property) ); assert_eq!( - terms.resolve("tight").map(|term| term.comment.as_str()), - Some("Ends without a space before the period.") + terms.by_tag("Change").map(|t| t.kind), + Some(TermKind::Class) ); - assert!(terms.resolve("unlabeled").is_none()); - assert!(terms.resolve("fake").is_none()); - } - - #[test] - fn a_property_sharing_a_class_name_takes_the_key_suffix_by_kind() { - let tags = terms().tags(); - assert_eq!(tags["https://runedeck.ai/ns#Change"], "CHANGE"); - assert_eq!(tags["https://runedeck.ai/ns#change"], "CHANGE-KEY"); - let lowercase_class = Terms::from_turtle( - "@prefix ex: <https://example.org/> .\nex:thing a rdfs:Class ; rdfs:label \"thing\" .\nex:Thing a rdf:Property ; rdfs:label \"thing key\" .\nex:THING a rdf:Property ; rdfs:label \"thing ref\" .\n", - "o.ttl", - ) - .unwrap(); - let tags = lowercase_class.tags(); - assert_eq!(tags["https://example.org/thing"], "THING"); - assert_eq!(tags["https://example.org/Thing"], "THING-KEY"); - assert_eq!(tags["https://example.org/THING"], "THING-KEY-KEY"); } #[test] @@ -841,16 +600,6 @@ rune:Tight a rdfs:Class ; rdfs:label "tight" ; rdfs:comment "Ends without a spac .message() .contains("share the label \"thing\"") ); - let shared = Terms::from_turtle( - "@prefix ex: <https://example.org/> .\nex:change a rdf:Property ; rdfs:label \"change\" .\nex:Change a rdfs:Class ; rdfs:label \"Change\" .\n", - "o.ttl", - ) - .unwrap(); - assert_eq!( - shared.resolve("changes").map(|term| term.iri.as_str()), - Some("https://example.org/Change") - ); - assert_eq!(shared.tags()["https://example.org/change"], "CHANGE-KEY"); let empty = Terms::from_turtle("@prefix ex: <https://example.org/> .\n", "o.ttl"); assert!( empty @@ -861,206 +610,27 @@ rune:Tight a rdfs:Class ; rdfs:label "tight" ; rdfs:comment "Ends without a spac } #[test] - fn glossary_entries_keep_their_definitions_and_skip_fenced_examples() { - let terms = Terms::from_glossary( - "# Glossary\n\n- **Trailer**: a key-value footer line.\n- plain bullet\n\n```markdown\n- **example**: not an entry.\n```\n", - "docs/specs/glossary.md".into(), - ); - assert_eq!(terms.source(), TermSource::Glossary); - let trailer = terms.resolve("trailers").expect("plural finds singular"); - assert_eq!(trailer.comment, "a key-value footer line."); - assert!(trailer.iri.is_empty()); - assert!(terms.resolve("example").is_none()); - } - - #[test] - fn rendered_glossary_lists_terms_then_definitions() { - let terms = terms(); - let tags = terms.tags(); - let output = GlossaryOutput { - source: terms.location().to_string(), - terms: terms - .terms() - .iter() - .map(|term| GlossaryTerm { - label: term.label.clone(), - comment: term.comment.clone(), - name: term.name.clone(), - iri: term.iri.clone(), - kind: term.kind, - tag: tags[&term.iri].clone(), - }) - .collect(), - }; - let text = render_glossary(&output); - assert!(text.starts_with("- **change**: One spec-driven unit of work. [CHANGE]\n")); - assert!(text.contains("- **change key**: The change a record belongs to. [CHANGE-KEY]\n")); - assert!(text.contains("\n[CHANGE-KEY]: https://runedeck.ai/ns#change\n")); - assert!(text.ends_with("[TIGHT]: https://runedeck.ai/ns#Tight\n")); - } - - /// A repository root with the ontology and an empty `docs/specs`. - fn ontology_repository() -> tempfile::TempDir { - let root = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(root.path().join("ontology")).unwrap(); - std::fs::create_dir_all(root.path().join("docs/specs")).unwrap(); - std::fs::write(root.path().join("ontology/rune.ttl"), TURTLE).unwrap(); - root - } - - fn source(root: &tempfile::TempDir) -> &str { - root.path().to_str().unwrap() - } - - #[test] - fn two_writes_from_one_ontology_give_the_same_bytes() { - let root = ontology_repository(); - let glossary = root.path().join("docs/specs/glossary.md"); - - let first = write_glossary_output(source(&root), None).unwrap(); - let first_bytes = std::fs::read(&glossary).unwrap(); - let second = write_glossary_output(source(&root), None).unwrap(); - let second_bytes = std::fs::read(&glossary).unwrap(); - - assert_eq!(first.path, "docs/specs/glossary.md"); - assert_eq!(first.terms, 4); - assert_eq!(second.terms, 4); - assert_eq!(first_bytes, second_bytes); - let text = String::from_utf8(first_bytes).unwrap(); - assert!(text.starts_with( - "# Glossary\n\nGenerated from `ontology/rune.ttl` by `rune spec glossary --write`. Do not edit this file. Change the ontology.\n" - )); - assert!(text.contains("`[TERM]: https://runedeck.ai/ns#Term` at the end of the file.\n\n- **change**: One spec-driven unit of work. [CHANGE]\n")); - assert!(text.ends_with("\n[TIGHT]: https://runedeck.ai/ns#Tight\n")); - } - - #[test] - fn a_written_glossary_checks_current_at_the_default_and_a_custom_path() { - let root = ontology_repository(); - write_glossary_output(source(&root), None).unwrap(); - let check = check_glossary_output(source(&root), None).unwrap(); - assert_eq!(check.status, GlossaryStatus::Current); - assert!(check.is_current()); - assert_eq!( - render_glossary_check(&check), - "docs/specs/glossary.md is current\n" - ); - - write_glossary_output(source(&root), Some("docs/terms.md")).unwrap(); - let custom = check_glossary_output(source(&root), Some("docs/terms.md")).unwrap(); - assert_eq!(custom.path, "docs/terms.md"); - assert!(custom.is_current()); - assert_eq!( - custom.write_command, - "rune spec glossary --write docs/terms.md" - ); + fn kebab_and_upper_camel_follow_the_page_and_draft_rules() { + assert_eq!(kebab("Occam's razor"), "occams-razor"); + assert_eq!(kebab("analysis tool"), "analysis-tool"); + assert_eq!(kebab(" UTF-8 / encoding!"), "utf-8-encoding"); + assert_eq!(upper_camel("analysis tool"), "AnalysisTool"); + assert_eq!(upper_camel("Occam's razor"), "OccamsRazor"); + assert_eq!(upper_camel("UTF-8 encoding"), "UTF8Encoding"); } #[test] - fn an_edited_term_line_is_named_as_drift() { - let root = ontology_repository(); - let glossary = root.path().join("docs/specs/glossary.md"); - write_glossary_output(source(&root), None).unwrap(); - let edited = std::fs::read_to_string(&glossary) - .unwrap() - .replace( - "- **change key**: The change a record belongs to. [CHANGE-KEY]", - "- **change key**: Edited by hand. [CHANGE-KEY]", - ) - .replace( - "- **tight**: Ends without a space before the period. [TIGHT]\n", - "", - ) - + "- **stray**: Not in the ontology. [STRAY]\n"; - std::fs::write(&glossary, edited).unwrap(); - - let check = check_glossary_output(source(&root), None).unwrap(); - assert_eq!(check.status, GlossaryStatus::Stale); - assert_eq!( - check.changed, - [GlossaryEntry { - label: "change key".into(), - tag: "CHANGE-KEY".into() - }] - ); - assert_eq!( - check.missing, - [GlossaryEntry { - label: "tight".into(), - tag: "TIGHT".into() - }] - ); - assert_eq!( - check.extra, - [GlossaryEntry { - label: "stray".into(), - tag: "STRAY".into() - }] - ); - assert_eq!( - render_glossary_check(&check), - "docs/specs/glossary.md is stale: run `rune spec glossary --write`\n missing tight [TIGHT]\n extra stray [STRAY]\n changed change key [CHANGE-KEY]\n" - ); - } - - #[test] - fn a_header_edit_is_drift_without_a_term() { - let root = ontology_repository(); - let glossary = root.path().join("docs/specs/glossary.md"); - write_glossary_output(source(&root), None).unwrap(); - let edited = std::fs::read_to_string(&glossary) - .unwrap() - .replace("# Glossary", "# Terms"); - std::fs::write(&glossary, edited).unwrap(); - - let check = check_glossary_output(source(&root), None).unwrap(); - assert_eq!(check.status, GlossaryStatus::Stale); - assert_eq!(check.missing, []); - assert_eq!(check.extra, []); - assert_eq!(check.changed, []); - assert!( - render_glossary_check(&check).ends_with(" the header or the definitions differ\n") - ); - } - - #[test] - fn a_missing_glossary_file_is_reported_with_its_path() { - let root = ontology_repository(); - let check = check_glossary_output(source(&root), None).unwrap(); - assert_eq!(check.status, GlossaryStatus::Missing); - assert!(!check.is_current()); - assert_eq!( - render_glossary_check(&check), - "docs/specs/glossary.md is missing: run `rune spec glossary --write`\n" - ); - } - - #[test] - fn a_repository_without_an_ontology_cannot_render_the_file() { - let root = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(root.path().join("docs/specs")).unwrap(); - std::fs::write( - root.path().join("docs/specs/glossary.md"), - "# Glossary\n\n- **scan**: a full read.\n", - ) - .unwrap(); - let error = write_glossary_output(source(&root), None).unwrap_err(); - assert!(error.message().contains("no ontology at ontology/rune.ttl")); - assert!(check_glossary_output(source(&root), None).is_err()); - } - - #[test] - fn a_term_without_a_comment_keeps_one_space_before_its_tag() { - let terms = Terms::from_turtle( - "@prefix ex: <https://example.org/> .\nex:Bare a rdfs:Class ; rdfs:label \"bare\" .\n", - "o.ttl", - ) - .unwrap(); - let output = GlossaryOutput::from_terms(&terms); + fn a_draft_block_names_the_prefix_the_label_and_the_origin() { + let block = terms() + .draft_block("Gadget", "gad\"get", "docs/x.md:3") + .unwrap(); assert_eq!( - render_glossary(&output), - "- **bare**: [BARE]\n\n[BARE]: https://example.org/Bare\n" + block, + "\n# Draft term. The fixer added it from docs/x.md:3; define it or remove the link.\nrune:Gadget a rdfs:Class ;\n rdfs:label \"gad\\\"get\" ;\n rune:status \"draft\" .\n" ); - assert!(render_glossary_file(&output).contains("`[TERM]: https://example.org/Term`")); + let reread = Terms::from_turtle(&format!("{TURTLE}{block}"), "o.ttl").unwrap(); + let gadget = reread.by_tag("GADGET").unwrap(); + assert!(gadget.draft); + assert_eq!(gadget.label, "gad\"get"); } } diff --git a/rune-docs/tests/public_api.rs b/rune-docs/tests/public_api.rs index 04f67dd4..f0888ad6 100644 --- a/rune-docs/tests/public_api.rs +++ b/rune-docs/tests/public_api.rs @@ -9,7 +9,7 @@ use rune_docs::error::Error; use rune_docs::interop::{self, ConversionReport}; use rune_docs::sheet::Sheet; use rune_docs::spec::{ - self, ArchiveOutput, ChangeState, ChangeSummary, ContextOutput, DiagnosticSeverity, + self, ArchiveOutput, ChangeState, ChangeSummary, ContextOutput, DiagnosticSeverity, FixOutput, GlossaryCheckOutput, GlossaryOutput, GlossaryStatus, GlossaryWriteOutput, ListSort, LiveTrees, MdschemaCheck, MergeSummary, ProposeOutput, ShowChangeOutput, ShowOutput, ShowSpecOutput, SpecDoctorOutput, SpecLayout, SpecRoot, SpecViolation, SpecificationSummary, @@ -52,6 +52,7 @@ fn the_output_surface_is_pinned() { spec::check_glossary_output; let _: fn(&GlossaryCheckOutput) -> bool = GlossaryCheckOutput::is_current; let _ = GlossaryStatus::Stale; + let _: fn(&str, bool, bool) -> Result<FixOutput, Error> = spec::fix_output; let _: fn(&ArchiveOutput) -> &[String] = ArchiveOutput::warnings; let _: fn(&SpecDoctorOutput) -> bool = SpecDoctorOutput::has_errors; let _: fn(&SpecViolation) -> bool = SpecViolation::is_error; @@ -73,8 +74,9 @@ fn the_render_surface_is_pinned() { let _: fn(&[SpecViolation]) -> String = spec::render_diagnostics; let _: fn(&SpecDoctorOutput) -> String = spec::render_doctor; let _: fn(&GlossaryOutput) -> String = spec::render_glossary; - let _: fn(&GlossaryOutput) -> String = spec::render_glossary_file; + let _: fn(&GlossaryWriteOutput) -> String = spec::render_glossary_write; let _: fn(&GlossaryCheckOutput) -> String = spec::render_glossary_check; + let _: fn(&FixOutput) -> String = spec::render_fix; // `render_json` is generic over `Serialize`; instantiating it here pins // its shape. let rendered = spec::render_json(&Vec::<SpecViolation>::new()).unwrap(); @@ -102,6 +104,10 @@ fn the_printing_wrappers_are_pinned() { let _: fn(&str, bool) -> Result<i32, Error> = spec::doctor; let _: fn(&str, Option<&str>, bool, MdschemaCheck) -> Result<i32, Error> = spec::validate; let _: fn(&str, &str, bool, bool, bool) -> Result<i32, Error> = spec::archive; + let _: fn(&str, bool) -> Result<i32, Error> = spec::glossary; + let _: fn(&str, Option<&str>, bool) -> Result<i32, Error> = spec::write_glossary; + let _: fn(&str, Option<&str>, bool) -> Result<i32, Error> = spec::check_glossary; + let _: fn(&str, bool, bool, bool) -> Result<i32, Error> = spec::fix; let _: fn(&str, bool) -> Result<i32, Error> = interop::export_openspec; let _: fn(&str, bool) -> Result<i32, Error> = interop::import_openspec; let _: fn(&str, &str, bool) -> Result<i32, Error> = interop::import_openspec_into; diff --git a/scripts/trusted-keys b/scripts/trusted-keys index 2fcd775f..59894c11 100755 --- a/scripts/trusted-keys +++ b/scripts/trusted-keys @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build a gpg keyring from a KEYS file (docs/specs/trusted-key-anchor). +# Build a gpg keyring from a KEYS file (docs/specs/sign-with-trusted-keys). # # trusted-keys --keys FILE --homedir DIR [--offline] # diff --git a/scripts/verify-seal b/scripts/verify-seal index c0c94177..1620caa7 100755 --- a/scripts/verify-seal +++ b/scripts/verify-seal @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Verify the owner's seals on a pull request head (docs/specs/owner-release-ceremony, -# docs/specs/trusted-key-anchor for the KEYS format). +# docs/specs/sign-with-trusted-keys for the KEYS format). # # verify-seal open --head SHA --repo OWNER/NAME --base REF --number N \ # --keys FILE --open-bodies FILE [--since SHA] diff --git a/src/cli/adopt/mod.rs b/src/cli/adopt/mod.rs index 5b0816e7..bd8d5cef 100644 --- a/src/cli/adopt/mod.rs +++ b/src/cli/adopt/mod.rs @@ -2,6 +2,7 @@ use clap::ValueEnum; use regex::Regex; use rune::manifest; use rune::parse; +use rune::services::confine::{Violation, confine_for_write}; use std::fs; use std::io::Read as _; use std::path::{Component, Path, PathBuf}; @@ -160,15 +161,15 @@ where fs::create_dir_all(parent) .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; } - fs::write(&plan.artifact_path, &plan.content) - .map_err(|error| format!("cannot write {}: {error}", plan.artifact_path.display()))?; + crate::cli::config::write_atomic(&plan.artifact_path, &plan.content) + .map_err(|error| error.to_string())?; if let Some(parent) = plan.sidecar_path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; } - fs::write(&plan.sidecar_path, &plan.sidecar_yaml) - .map_err(|error| format!("cannot write {}: {error}", plan.sidecar_path.display()))?; + crate::cli::config::write_atomic(&plan.sidecar_path, &plan.sidecar_yaml) + .map_err(|error| error.to_string())?; println!("adopted {}", plan.artifact_relative); Ok(adopted) @@ -580,44 +581,31 @@ fn canonical_module_root(module: &Path) -> Result<PathBuf, String> { // Containment is validated against the deepest existing ancestor without // creating anything: planning must stay side-effect free so `--dry-run` // leaves no directories behind. Directories are created at write time. +// The directory that will hold the file is what gets confined; a symlink +// at the file itself is the destination guards' concern. fn contained_path(module_root: &Path, relative_path: &Path) -> Result<PathBuf, String> { let safe_relative = validate_relative_path(&path_to_slash(relative_path))?; let target = module_root.join(&safe_relative); let parent = target .parent() .ok_or_else(|| format!("{} has no parent directory", target.display()))?; - - let mut probe = parent.to_path_buf(); - let existing = loop { - match fs::symlink_metadata(&probe) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err(format!( - "{} is a symlink; refusing to write through it", - probe.display() - )); - } - break probe; - } - Err(_) => match probe.parent() { - Some(ancestor) => probe = ancestor.to_path_buf(), - None => { - return Err(format!( - "no existing ancestor for {}; cannot validate containment", - target.display() - )); - } - }, + confine_for_write(module_root, parent).map_err(|violation| match violation { + Violation::Symlink { link, .. } => format!( + "{} is a symlink; refusing to write through it", + link.display() + ), + Violation::NoAncestor { .. } => format!( + "no existing ancestor for {}; cannot validate containment", + target.display() + ), + Violation::Unresolvable { path, error } => { + format!("cannot canonicalize {}: {error}", path.display()) } - }; - let canonical_existing = fs::canonicalize(&existing) - .map_err(|error| format!("cannot canonicalize {}: {error}", existing.display()))?; - if !canonical_existing.starts_with(module_root) { - return Err(format!( + Violation::Escape { .. } => format!( "target escapes rune source root: {}", relative_path.display() - )); - } + ), + })?; Ok(target) } diff --git a/src/cli/adopt/review.rs b/src/cli/adopt/review.rs index 5111a4ca..377fc352 100644 --- a/src/cli/adopt/review.rs +++ b/src/cli/adopt/review.rs @@ -1919,20 +1919,9 @@ fn write_record(path: &Path, record: &ReviewRecord) -> Result<(), String> { atomic_write(path, &yaml) } -/// Temp-file-and-rename so a crash mid-write never leaves a torn record. +/// The shared atomic-replace writer with this module's string errors. fn atomic_write(path: &Path, content: &str) -> Result<(), String> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let temp = parent.join(format!( - ".{}.tmp-{}", - path.file_name().unwrap_or_default().to_string_lossy(), - std::process::id() - )); - fs::write(&temp, content) - .map_err(|error| format!("cannot write {}: {error}", temp.display()))?; - fs::rename(&temp, path).map_err(|error| { - let _ = fs::remove_file(&temp); - format!("cannot replace {}: {error}", path.display()) - }) + crate::cli::config::write_atomic(path, content).map_err(|error| error.to_string()) } fn display_relative(root: &Path, path: &Path) -> String { diff --git a/src/cli/adopt/tree.rs b/src/cli/adopt/tree.rs index b2c82ad8..e869d9b1 100644 --- a/src/cli/adopt/tree.rs +++ b/src/cli/adopt/tree.rs @@ -199,8 +199,7 @@ fn write_file(path: &Path, content: &[u8]) -> Result<(), String> { std::fs::create_dir_all(parent) .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; } - std::fs::write(path, content) - .map_err(|error| format!("cannot write {}: {error}", path.display())) + crate::cli::config::write_atomic(path, content).map_err(|error| error.to_string()) } fn collect_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> { diff --git a/src/cli/bench/cache.rs b/src/cli/bench/cache.rs index 6dfae638..3e110e3d 100644 --- a/src/cli/bench/cache.rs +++ b/src/cli/bench/cache.rs @@ -189,8 +189,7 @@ pub fn write_cache_entry(params: &CacheWriteParams<'_>) -> Result<PathBuf, Strin let rendered = serde_json::to_string_pretty(&payload) .map_err(|error| format!("cannot render cache entry: {error}"))?; - std::fs::write(&path, rendered) - .map_err(|error| format!("cannot write {}: {error}", path.display()))?; + crate::cli::config::write_atomic(&path, rendered).map_err(|error| error.to_string())?; Ok(path) } diff --git a/src/cli/bench/dashboard.rs b/src/cli/bench/dashboard.rs index f578e6b7..50f14853 100644 --- a/src/cli/bench/dashboard.rs +++ b/src/cli/bench/dashboard.rs @@ -563,8 +563,7 @@ pub fn build_dashboard( std::fs::create_dir_all(parent) .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; } - std::fs::write(out_path, page) - .map_err(|error| format!("cannot write {}: {error}", out_path.display()))?; + crate::cli::config::write_atomic(out_path, page).map_err(|error| error.to_string())?; let summary = suites .iter() diff --git a/src/cli/bench/report.rs b/src/cli/bench/report.rs index 44bb7caa..2e06582f 100644 --- a/src/cli/bench/report.rs +++ b/src/cli/bench/report.rs @@ -533,19 +533,18 @@ pub fn write_outputs( let results_path = directory.join(format!("test-results-{stamp}.json")); let rendered = serde_json::to_string_pretty(&results_document) .map_err(|error| format!("cannot render results: {error}"))?; - std::fs::write(&results_path, rendered) - .map_err(|error| format!("cannot write {}: {error}", results_path.display()))?; + crate::cli::config::write_atomic(&results_path, rendered).map_err(|error| error.to_string())?; let markdown_path = directory.join(format!("test-results-{stamp}.md")); let markdown = render_markdown_report(params, &results_document.metadata); - std::fs::write(&markdown_path, markdown) - .map_err(|error| format!("cannot write {}: {error}", markdown_path.display()))?; + crate::cli::config::write_atomic(&markdown_path, markdown) + .map_err(|error| error.to_string())?; let summary_path = directory.join(format!("summary-{stamp}.json")); let summary_rendered = serde_json::to_string_pretty(&summary_document) .map_err(|error| format!("cannot render summary: {error}"))?; - std::fs::write(&summary_path, &summary_rendered) - .map_err(|error| format!("cannot write {}: {error}", summary_path.display()))?; + crate::cli::config::write_atomic(&summary_path, &summary_rendered) + .map_err(|error| error.to_string())?; let mut written = WrittenOutputs { results_path, @@ -556,7 +555,7 @@ pub fn write_outputs( if let Some(visualizer) = visualizer_data_path { // Best-effort upstream-parity copy; no mkdir, failure is non-fatal (§5c). - if std::fs::write(visualizer, &summary_rendered).is_ok() { + if crate::cli::config::write_atomic(visualizer, &summary_rendered).is_ok() { written.visualizer_path = Some(visualizer.to_path_buf()); } } diff --git a/src/cli/completion.rs b/src/cli/completion.rs index 45eac5ab..e77fa7c4 100644 --- a/src/cli/completion.rs +++ b/src/cli/completion.rs @@ -126,13 +126,10 @@ impl InstallPlan { .with_fix_command(format!("rune completion install {}", self.shell.name())) })?; } - std::fs::write(&self.destination, &self.content).map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", self.destination.display()), - ) - .with_code("completion.write_failed") - .with_fix_command(format!("rune completion install {}", self.shell.name())) + crate::cli::config::write_atomic(&self.destination, &self.content).map_err(|error| { + error + .with_code("completion.write_failed") + .with_fix_command(format!("rune completion install {}", self.shell.name())) })?; for path in &self.cache_removals { std::fs::remove_file(path).map_err(|error| { diff --git a/src/cli/config/mod.rs b/src/cli/config/mod.rs index 5c137ba8..a0c81440 100644 --- a/src/cli/config/mod.rs +++ b/src/cli/config/mod.rs @@ -82,59 +82,10 @@ pub fn lock_target(base: &Path) -> Result<TargetLock, Error> { } } -/// Write through an adjacent temporary file plus rename, so interruption -/// never leaves a truncated file. The temporary carries a per-process -/// unique suffix and is opened with `create_new`, so a pre-planted -/// symlink at a predictable name cannot redirect the write; a symlink at -/// the destination itself is refused rather than followed. -static WRITE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -pub fn write_atomic(path: &Path, content: &str) -> Result<(), Error> { - if path.symlink_metadata().is_ok_and(|meta| meta.is_symlink()) { - return Err(Error::new( - ErrorKind::Config, - format!("{} is a symlink; refusing to replace it", path.display()), - )); - } - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let base_name = path.file_name().map_or_else( - || "rune-write".to_string(), - |name| name.to_string_lossy().into_owned(), - ); - let sequence = WRITE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let temporary = parent.join(format!( - ".{base_name}.{}.{sequence}.tmp", - std::process::id() - )); - { - use std::io::Write as _; - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temporary) - .map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot create {}: {error}", temporary.display()), - ) - })?; - file.write_all(content.as_bytes()).map_err(|error| { - let _ = fs::remove_file(&temporary); - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", temporary.display()), - ) - })?; - let _ = file.sync_all(); - } - fs::rename(&temporary, path).map_err(|error| { - let _ = fs::remove_file(&temporary); - Error::new( - ErrorKind::Io, - format!("cannot replace {}: {error}", path.display()), - ) - }) -} +/// The one atomic-replace writer: temporary file beside the target, then +/// rename. The implementation lives in the library so every writer, in the +/// binary and in the library, shares it. +pub use rune::services::atomic::write_atomic; /// Read a file to string with consistent error handling. pub fn read_file(path: &Path) -> Result<String, Error> { diff --git a/src/cli/config/recovery.rs b/src/cli/config/recovery.rs index f101f4ca..fc3a1cb6 100644 --- a/src/cli/config/recovery.rs +++ b/src/cli/config/recovery.rs @@ -287,13 +287,7 @@ pub fn reset(key: &str, scope: FileScope, json: bool) -> Result<i32, Error> { )); fs::write(&backup, &content) .map_err(|error| Error::io(format!("cannot write {}: {error}", backup.display())))?; - let temp = path.with_file_name(format!(".config-reset-{}.tmp", std::process::id())); - fs::write(&temp, &updated) - .and_then(|()| fs::rename(&temp, &path)) - .map_err(|error| { - let _ = fs::remove_file(&temp); - Error::io(format!("cannot rewrite {}: {error}", path.display())) - })?; + super::write_atomic(&path, &updated)?; let restore = format!( "command cp {} {}", diff --git a/src/cli/copy/mod.rs b/src/cli/copy/mod.rs index bca39903..3dc1b567 100644 --- a/src/cli/copy/mod.rs +++ b/src/cli/copy/mod.rs @@ -201,12 +201,7 @@ fn copy_directory_recursive( ) })?; - fs::write(&target_path, &content).map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", target_path.display()), - ) - })?; + crate::cli::config::write_atomic(&target_path, &content)?; if !source_uri.is_empty() { write_copy_provenance( @@ -265,12 +260,7 @@ fn write_copy_provenance( ) })?; - fs::write(&sidecar_path, &statement).map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", sidecar_path.display()), - ) - })?; + crate::cli::config::write_atomic(&sidecar_path, &statement)?; Ok(()) } diff --git a/src/cli/dashboard/routes/artifact.rs b/src/cli/dashboard/routes/artifact.rs index 4ca95a14..cc2995dc 100644 --- a/src/cli/dashboard/routes/artifact.rs +++ b/src/cli/dashboard/routes/artifact.rs @@ -371,19 +371,19 @@ fn read_deployed_file( for base in bases { let harness_root = base.join(provider_dir); let candidate = harness_root.join(path); - let (Ok(canonical_root), Ok(canonical_file)) = - (harness_root.canonicalize(), candidate.canonicalize()) - else { - continue; - }; - if !canonical_file.starts_with(&canonical_root) { - eprintln!( - "dashboard: refused deployed path escaping {}: {}", - canonical_root.display(), - canonical_file.display() - ); - continue; - } + let canonical_file = + match rune::services::confine::confine_existing(&harness_root, &candidate) { + Ok(file) => file, + Err(rune::services::confine::Violation::Escape { resolved, .. }) => { + eprintln!( + "dashboard: refused deployed path escaping {}: {}", + harness_root.display(), + resolved.display() + ); + continue; + } + Err(_) => continue, + }; if let Ok(content) = std::fs::read_to_string(&canonical_file) { let display = display_path(&canonical_file, home.as_deref()); return Some((display, content)); diff --git a/src/cli/deploy/migration.rs b/src/cli/deploy/migration.rs index 92bf10cd..aba3c2f7 100644 --- a/src/cli/deploy/migration.rs +++ b/src/cli/deploy/migration.rs @@ -8,6 +8,7 @@ use rune::error::{Error, ErrorKind}; use rune::manifest::{self, ManifestEntry}; use rune::provider::{ContentKind, ProviderConfig}; use rune::result::{ActionResult, PrunedFile}; +use rune::services::confine::is_plain_relative; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -129,7 +130,7 @@ pub(super) fn check_writers( let Some(relative) = &entry.provenance else { continue; }; - if !confined(Path::new(relative)) { + if !is_plain_relative(Path::new(relative)) { return Err(conflict(&root, "unconfined provenance claim")); } if !canonical_target(&root.join(relative))?.starts_with(&physical) { @@ -162,13 +163,6 @@ pub(super) fn check_writers( Ok(()) } -fn confined(path: &Path) -> bool { - !path.as_os_str().is_empty() - && path - .components() - .all(|part| matches!(part, Component::Normal(_))) -} - struct LegacySkill { relative: PathBuf, digest: String, @@ -524,7 +518,7 @@ fn inspect_legacy( } let mut claimed = HashSet::new(); for key in &keys { - if !confined(Path::new(key)) { + if !is_plain_relative(Path::new(key)) { return Err(conflict(&bundle, "invalid manifest key")); } let entry = &entries[key]; @@ -536,7 +530,8 @@ fn inspect_legacy( let Some(provenance) = &entry.provenance else { return Err(conflict(&file, "source ownership is unknown")); }; - if !confined(Path::new(provenance)) || !Path::new(provenance).starts_with(relative) { + if !is_plain_relative(Path::new(provenance)) || !Path::new(provenance).starts_with(relative) + { return Err(conflict(&file, "provenance leaves the skill bundle")); } let source = provenance_source(&root.join(provenance), key, &entry.fingerprint)?; diff --git a/src/cli/deploy/mod.rs b/src/cli/deploy/mod.rs index 1356ec88..cbf07238 100644 --- a/src/cli/deploy/mod.rs +++ b/src/cli/deploy/mod.rs @@ -3,6 +3,7 @@ use rune::error::{Error, ErrorKind}; use rune::manifest; use rune::manifest::bundle::is_build_sidecar; use rune::result::{ActionResult, DeployedFile, PrunedFile, SkipReason, SkippedFile}; +use rune::services::confine::{Violation, confine_for_write, is_plain_relative}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -876,12 +877,7 @@ fn write_generated_file( ) })?; } - fs::write(&target_path, content).map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", target_path.display()), - ) - })?; + config::write_atomic(&target_path, content)?; existing_manifest.insert( manifest_key, manifest::ManifestEntry { @@ -921,9 +917,7 @@ fn prune_stale_files( .any(|prefix| key.starts_with(prefix)) }) .filter(|(key, _)| { - let confined = std::path::Path::new(key) - .components() - .all(|component| matches!(component, std::path::Component::Normal(_))); + let confined = is_plain_relative(Path::new(key)); if !confined { eprintln!("rune prune: ignoring malformed manifest key {key}"); } @@ -1197,8 +1191,10 @@ fn write_manifest( } /// Verify that writing `target_path` cannot escape `base`: the deepest -/// existing ancestor (which any symlinked component resolves through) must -/// canonicalize inside the base directory. +/// existing ancestor must canonicalize inside the base directory, and a +/// symlink there is refused. `copy_file` unlinks a symlink at the +/// destination itself instead of writing through it, so for that case the +/// directory the file lands in is what gets confined. fn ensure_destination_within(target_path: &Path, base: &Path) -> Result<(), Error> { fs::create_dir_all(base).map_err(|error| { Error::new( @@ -1206,34 +1202,32 @@ fn ensure_destination_within(target_path: &Path, base: &Path) -> Result<(), Erro format!("cannot create {}: {error}", base.display()), ) })?; - let resolved_base = base.canonicalize().map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot resolve {}: {error}", base.display()), - ) - })?; - let existing = target_path - .ancestors() - .find(|ancestor| ancestor.exists()) - .unwrap_or(base); - let resolved = existing.canonicalize().map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot resolve {}: {error}", existing.display()), - ) - })?; - if resolved.starts_with(&resolved_base) { - Ok(()) + let destination = if fs::symlink_metadata(target_path) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + target_path.parent().unwrap_or(base) } else { - Err(Error::new( + target_path + }; + confine_for_write(base, destination).map_err(|violation| match violation { + Violation::Escape { resolved, .. } => Error::new( ErrorKind::Config, format!( "destination escapes target: {} resolves to {}", target_path.display(), resolved.display() ), - )) - } + ), + Violation::Symlink { link, .. } => Error::new( + ErrorKind::Config, + format!( + "destination escapes target: {} resolves through the symlink {}", + target_path.display(), + link.display() + ), + ), + other => Error::new(ErrorKind::Io, other), + }) } /// Copy a file, creating parent directories as needed. diff --git a/src/cli/dotrune/mod.rs b/src/cli/dotrune/mod.rs index efc51c85..bf410e2b 100644 --- a/src/cli/dotrune/mod.rs +++ b/src/cli/dotrune/mod.rs @@ -24,8 +24,7 @@ pub mod toggle; mod tests; use rune::error::{Error, ErrorKind}; -use std::fs::{self, OpenOptions}; -use std::io::Write as _; +use std::fs; use std::path::Path; pub use git::cached_worktree; @@ -86,8 +85,7 @@ pub fn load(repo_root: &Path) -> Result<Option<DotRune>, Error> { /// Atomically replace `.rune` with pre-rendered content. The toggle writer /// uses this path so a surgical edit keeps every unrelated byte. pub(crate) fn write_atomic_content(repo_root: &Path, content: &str) -> Result<(), Error> { - let path = repo_root.join(".rune"); - atomic_write_with(&path, content.as_bytes(), |from, to| fs::rename(from, to)) + crate::cli::config::write_atomic(&repo_root.join(".rune"), content) } /// Serialize and atomically replace a consumer manifest. @@ -95,70 +93,8 @@ pub(crate) fn write_atomic_content(repo_root: &Path, content: &str) -> Result<() /// Both `rune add` and interactive editors use this durability path so a /// failed rename never leaves a partially written `.rune` file. pub fn write_atomic(repo_root: &Path, manifest: &DotRune) -> Result<(), Error> { - let path = repo_root.join(".rune"); let content = serde_yaml::to_string(manifest).map_err(|error| { Error::new(ErrorKind::Parse, format!("cannot serialize .rune: {error}")) })?; - atomic_write_with(&path, content.as_bytes(), |from, to| fs::rename(from, to)) -} - -fn atomic_write_with<F>(path: &Path, content: &[u8], rename: F) -> Result<(), Error> -where - F: FnOnce(&Path, &Path) -> std::io::Result<()>, -{ - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let temp = parent.join(format!(".rune.tmp-{}-{nonce}", std::process::id())); - let mut created = false; - let result = (|| { - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp)?; - created = true; - file.write_all(content)?; - file.sync_all()?; - drop(file); - rename(&temp, path) - })(); - if let Err(error) = result { - if created { - let _ = fs::remove_file(&temp); - } - return Err(Error::new( - ErrorKind::Io, - format!("cannot atomically rewrite {}: {error}", path.display()), - )); - } - Ok(()) -} - -#[cfg(test)] -mod write_tests { - use super::*; - - #[test] - fn atomic_write_cleans_temp_and_preserves_destination_on_rename_failure() { - let root = tempfile::tempdir().unwrap(); - let destination = root.path().join(".rune"); - fs::write(&destination, "original\n").unwrap(); - - let error = atomic_write_with(&destination, b"replacement\n", |_, _| { - Err(std::io::Error::other("simulated rename failure")) - }) - .unwrap_err(); - - assert!(error.to_string().contains("simulated rename failure")); - assert_eq!(fs::read_to_string(destination).unwrap(), "original\n"); - let leftovers = fs::read_dir(root.path()) - .unwrap() - .filter_map(Result::ok) - .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .filter(|name| name.starts_with(".rune.tmp-")) - .collect::<Vec<_>>(); - assert_eq!(leftovers, Vec::<String>::new()); - } + crate::cli::config::write_atomic(&repo_root.join(".rune"), content) } diff --git a/src/cli/init/mod.rs b/src/cli/init/mod.rs index a07ec3be..bd360f60 100644 --- a/src/cli/init/mod.rs +++ b/src/cli/init/mod.rs @@ -1083,12 +1083,7 @@ fn append_missing_gitignore_entries(target: &Path, addition: &[u8]) -> Result<bo let Some(merged) = merged_gitignore_contents(target, addition)? else { return Ok(false); }; - fs::write(target, merged).map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", target.display()), - ) - })?; + crate::cli::config::write_atomic(target, merged)?; Ok(true) } @@ -1677,12 +1672,7 @@ pub fn execute(path: &str) -> Result<ActionResult, Error> { Error::new(ErrorKind::Io, format!("{}: {error}", parent.display())) })?; } - fs::write(&provenance_path, &statement).map_err(|error| { - Error::new( - ErrorKind::Io, - format!("{}: {error}", provenance_path.display()), - ) - })?; + crate::cli::config::write_atomic(&provenance_path, &statement)?; manifest_entries.insert( filename.to_string(), @@ -1697,8 +1687,7 @@ pub fn execute(path: &str) -> Result<ActionResult, Error> { if !manifest_entries.is_empty() { let yaml = manifest::write(&manifest_entries) .map_err(|error| Error::new(ErrorKind::Io, format!("manifest: {error}")))?; - fs::write(module_root.join(".manifest"), &yaml) - .map_err(|error| Error::new(ErrorKind::Io, format!(".manifest: {error}")))?; + crate::cli::config::write_atomic(&module_root.join(".manifest"), &yaml)?; } Ok(result) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8d6e19fc..5e9687f1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1002,11 +1002,13 @@ enum SpecAction { source: String, }, - /// Print every defined term: the ontology's labels, comments, and reference tags + /// Print every defined term: the ontology's labels, comments, reference tags, and pages Glossary { - /// Write the glossary file from the ontology. PATH defaults to - /// `glossary.md` beside the capability specifications; a relative - /// PATH resolves against the source root. + /// Write the glossary pages and index from the ontology: one page + /// per term under PATH, which defaults to `glossary/` beside the + /// capability specifications; a relative PATH resolves against the + /// source root. Pages of terms the ontology no longer defines and + /// the single `glossary.md` an older render wrote are removed. #[arg( long, value_name = "PATH", @@ -1016,8 +1018,10 @@ enum SpecAction { )] write: Option<String>, - /// Compare the glossary file with the ontology: name each term that - /// differs and exit 1 on drift or a missing file. PATH as for --write. + /// Regenerate every page in memory and compare the directory with + /// it byte for byte: name each page that is missing, extra, or + /// changed, and exit 1 on drift or a missing directory. PATH as + /// for --write. #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")] check: Option<String>, @@ -1026,6 +1030,28 @@ enum SpecAction { source: String, }, + /// Fix what validation reports about terms: draft entries and the link form + Fix { + /// Add a draft entry to the ontology for every term link that names + /// no term, and write its glossary page. + #[arg(long)] + terms: bool, + + /// Rewrite the legacy `*term* [TAG]` form to `*[term][TAG]*` in the + /// Markdown files under the source root, emphasize only the first + /// use of a term in a file, point every definition at the term's + /// page, and add a missing definition. The walk skips version + /// control, build, dependency, and fixture trees, a deployed tree + /// (a directory with a `.manifest`), the change archive, and the + /// glossary. Idempotent on converted files. + #[arg(long)] + links: bool, + + /// Deck or rune source root. Defaults to the current directory. + #[arg(long, value_name = "DIR", default_value = ".")] + source: String, + }, + /// Validate the specification tree or one change or capability Validate { /// Active change or canonical capability. Omit to validate the tree. @@ -2295,7 +2321,7 @@ fn spec_help(help: &mut String) { help_command( help, "spec", - "propose | list | show | glossary | doctor | archive", + "propose | list | glossary | fix | doctor | archive", "Spec-driven change lifecycle under docs/", ); } @@ -2855,6 +2881,7 @@ fn run_spec(action: SpecAction, json: bool) -> i32 { | SpecAction::Show { source, .. } | SpecAction::Doctor { source, .. } | SpecAction::Glossary { source, .. } + | SpecAction::Fix { source, .. } | SpecAction::Validate { source, .. } | SpecAction::Archive { source, .. } | SpecAction::Context { source, .. } => source.clone(), @@ -2904,6 +2931,11 @@ fn run_spec(action: SpecAction, json: bool) -> i32 { check, source, } => spec::glossary(&source, write.as_deref(), check.as_deref(), json), + SpecAction::Fix { + terms, + links, + source, + } => spec::fix(&source, terms, links, json), SpecAction::Archive { change_id, yes, diff --git a/src/cli/ontology.rs b/src/cli/ontology.rs index e35182da..198b7373 100644 --- a/src/cli/ontology.rs +++ b/src/cli/ontology.rs @@ -336,8 +336,7 @@ fn write_config_document(path: &Path, document: &serde_yaml::Value) -> Result<() } let content = serde_yaml::to_string(document) .map_err(|error| Error::config(format!("cannot serialize config: {error}")))?; - fs::write(path, content) - .map_err(|error| Error::io(format!("cannot write {}: {error}", path.display()))) + crate::cli::config::write_atomic(path, content) } fn format_source(source: Source) -> &'static str { diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index 7f897aff..23f4bd6d 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -180,19 +180,18 @@ fn confined_exec(directory: &Path, exec: &str) -> Result<PathBuf, String> { )); } let candidate = directory.join(exec); - let directory = directory - .canonicalize() - .map_err(|error| format!("{}: {error}", directory.display()))?; - let resolved = candidate - .canonicalize() - .map_err(|error| format!("{}: {error}", candidate.display()))?; - if !resolved.starts_with(&directory) { - return Err(format!( - "{}: exec '{exec}' escapes the plugin directory; the plugin does not run", - directory.display() - )); - } - Ok(resolved) + rune::services::confine::confine_existing(directory, &candidate).map_err(|violation| { + match violation { + rune::services::confine::Violation::Escape { .. } => format!( + "{}: exec '{exec}' escapes the plugin directory; the plugin does not run", + directory.display() + ), + rune::services::confine::Violation::Unresolvable { path, error } => { + format!("{}: {error}", path.display()) + } + other => other.to_string(), + } + }) } fn run_plugin(plugin: &Plugin, payload: &str) -> Result<(), String> { diff --git a/src/cli/promote/mod.rs b/src/cli/promote/mod.rs index 1cf957d0..fb58db3c 100644 --- a/src/cli/promote/mod.rs +++ b/src/cli/promote/mod.rs @@ -226,12 +226,7 @@ fn write_change_stub( "- List the affected runes, casts, commands, or documentation.", &format!("- `{rune_rel}` (new)."), ); - fs::write(&proposal_path, proposal).map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", proposal_path.display()), - ) - }) + crate::cli::config::write_atomic(&proposal_path, proposal) } /// The command entry: promote and print the outcome. diff --git a/src/cli/proof/mod.rs b/src/cli/proof/mod.rs index 866c98d1..efdc96c7 100644 --- a/src/cli/proof/mod.rs +++ b/src/cli/proof/mod.rs @@ -203,8 +203,7 @@ fn run(root: &Path, proof: &Proof) -> Result<i32, Error> { failed += 1; } } - fs::write(proof.dir.join("proof.txt"), &transcript) - .map_err(|error| Error::io(error.to_string()))?; + crate::cli::config::write_atomic(&proof.dir.join("proof.txt"), &transcript)?; write_cast(&proof.dir.join("proof.cast"), &cast)?; frontmatter.transcript = sha256(transcript.as_bytes()); frontmatter.recorded = today(); @@ -412,8 +411,7 @@ fn record_instruction(root: &Path, proof: &Proof, key: &str) -> Result<i32, Erro transcript.push_str(§ion); } } - fs::write(proof.dir.join("proof.txt"), &transcript) - .map_err(|error| Error::io(error.to_string()))?; + crate::cli::config::write_atomic(&proof.dir.join("proof.txt"), &transcript)?; let mut frontmatter = proof.frontmatter.clone(); frontmatter.transcript = sha256(transcript.as_bytes()); frontmatter.recorded = today(); @@ -480,8 +478,7 @@ fn write_readme(readme: &Path, frontmatter: &Frontmatter, body: &str) -> Result< .strip_prefix('\n') .unwrap_or(body) .trim_end_matches('\n'); - fs::write(readme, format!("---\n{yaml}---\n\n{body}\n")) - .map_err(|error| Error::io(error.to_string())) + crate::cli::config::write_atomic(readme, format!("---\n{yaml}---\n\n{body}\n")) } /// An asciinema v2 cast of the captured output, one event per chunk. @@ -501,7 +498,7 @@ fn write_cast(path: &Path, chunks: &[(f64, String)]) -> Result<(), Error> { ]); let _ = writeln!(cast, "{event}"); } - fs::write(path, cast).map_err(|error| Error::io(error.to_string())) + crate::cli::config::write_atomic(path, cast) } fn today() -> String { diff --git a/src/cli/provenance/scan.rs b/src/cli/provenance/scan.rs index ddeca56b..4ceb3cf0 100644 --- a/src/cli/provenance/scan.rs +++ b/src/cli/provenance/scan.rs @@ -433,9 +433,7 @@ fn resolve_in_repo(module_root: &Path, repo_relative: &str) -> Option<PathBuf> { /// Canonicalize an existing path as given and require it inside the module /// root. Returns `None` when the file is missing or outside the repo. fn confine_existing(module_root: &Path, candidate: &Path) -> Option<PathBuf> { - let canonical = fs::canonicalize(candidate).ok()?; - let root_canonical = fs::canonicalize(module_root).ok()?; - canonical.starts_with(&root_canonical).then_some(canonical) + rune::services::confine::confine_existing(module_root, candidate).ok() } /// Resolve an in-repo dependency URI (typically `<module-name>/<path>`) under diff --git a/src/cli/sign/mod.rs b/src/cli/sign/mod.rs index cd96fdd9..e44ccea3 100644 --- a/src/cli/sign/mod.rs +++ b/src/cli/sign/mod.rs @@ -335,7 +335,7 @@ fn keys_fingerprints_at(repo: &queue::repo::Repo, reference: &str) -> Result<Vec } /// Every fingerprint `KEYS` pins. The file is `signer FINGERPRINT ADDRESS...` -/// lines (skeleton `trusted-key-anchor`): the fingerprint is +/// lines (skeleton `sign-with-trusted-keys`): the fingerprint is /// the pin and the addresses say where a verifier fetches the bytes. The /// cli needs only the pins, because it verifies with the owner's own gpg /// keyring, which holds the key. A `KEYS` that is still an armored block diff --git a/src/cli/sign/queue/store.rs b/src/cli/sign/queue/store.rs index bf3a7002..0b660e62 100644 --- a/src/cli/sign/queue/store.rs +++ b/src/cli/sign/queue/store.rs @@ -210,18 +210,10 @@ impl Store { /// Write through a temporary file so a reader never sees a torn record. pub(crate) fn save(&self, request: &Request) -> Result<(), Error> { let path = self.request_path(&request.id); - let temporary = path.with_extension("json.tmp"); let body = serde_json::to_string_pretty(request).map_err(|error| { Error::new(ErrorKind::Io, format!("cannot encode request: {error}")) })?; - fs::write(&temporary, body) - .and_then(|()| fs::rename(&temporary, &path)) - .map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", path.display()), - ) - }) + crate::cli::config::write_atomic(&path, body) } pub(crate) fn remove(&self, id: &str) -> Result<(), Error> { diff --git a/src/cli/skill.rs b/src/cli/skill.rs index e83bfd6d..691bb595 100644 --- a/src/cli/skill.rs +++ b/src/cli/skill.rs @@ -110,12 +110,7 @@ fn install_into(base: &Path, content: &str) -> Result<InstallReport, Error> { format!("cannot create {}: {error}", destination.display()), ) })?; - fs::write(&skill_path, content).map_err(|error| { - Error::new( - ErrorKind::Io, - format!("cannot write {}: {error}", skill_path.display()), - ) - })?; + crate::cli::config::write_atomic(&skill_path, content)?; } Ok(InstallReport { path: skill_path, diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 716a10ec..f6ba9dd6 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -52,8 +52,8 @@ pub(crate) fn context(source: &str, id: &str, json: bool) -> Result<i32, Error> } /// Print the term list, or with `--write` or `--check` render the glossary -/// file. A flag given without a PATH arrives as an empty string and means -/// the default path. +/// pages. A flag given without a PATH arrives as an empty string and means +/// the default directory. pub(crate) fn glossary( source: &str, write: Option<&str>, @@ -71,6 +71,13 @@ pub(crate) fn glossary( result.map_err(|error| convert(&error)) } +/// The fixer. Without a flag it runs both passes. +pub(crate) fn fix(source: &str, terms: bool, links: bool, json: bool) -> Result<i32, Error> { + let both = !terms && !links; + rune_docs::spec::fix(source, terms || both, links || both, json) + .map_err(|error| convert(&error)) +} + /// Term diagnostics for documents outside the specification tree. pub(crate) fn lint_documents( repository: &Path, diff --git a/src/cli/target/mod.rs b/src/cli/target/mod.rs index 48387438..944efc39 100644 --- a/src/cli/target/mod.rs +++ b/src/cli/target/mod.rs @@ -161,7 +161,7 @@ fn unbind_target(state_path: &Path) -> Result<i32, Error> { } let content = serde_yaml::to_string(&document) .map_err(|error| Error::new(ErrorKind::Parse, format!("cannot serialize: {error}")))?; - std::fs::write(state_path, content).map_err(|error| io_error(state_path, "write", &error))?; + crate::cli::config::write_atomic(state_path, content)?; println!("target unbound"); Ok(0) } @@ -273,7 +273,7 @@ fn write_binding(state_path: &Path, target: &Path) -> Result<(), Error> { } let content = serde_yaml::to_string(&document) .map_err(|error| Error::new(ErrorKind::Parse, format!("cannot serialize: {error}")))?; - std::fs::write(state_path, content).map_err(|error| io_error(state_path, "write", &error)) + crate::cli::config::write_atomic(state_path, content) } fn read_document(state_path: &Path) -> Result<serde_yaml::Value, Error> { diff --git a/src/cli/validate/mod.rs b/src/cli/validate/mod.rs index be994d1d..9905fba0 100644 --- a/src/cli/validate/mod.rs +++ b/src/cli/validate/mod.rs @@ -500,8 +500,9 @@ fn check_spec_lifecycle(module_root: &Path, report: &mut ValidationReport) -> Re } } -/// Every italic term in the decision records and runes exists in the -/// ontology and cites it. A root without an ontology has nothing to cite. +/// Every term link in the decision records and runes reaches a term the +/// ontology defines, through its glossary page. A root without an +/// ontology has no terms. fn check_terms( root: &Path, directories: &[&str], diff --git a/src/cli/watchlist/mod.rs b/src/cli/watchlist/mod.rs index 489f3871..5e23d534 100644 --- a/src/cli/watchlist/mod.rs +++ b/src/cli/watchlist/mod.rs @@ -120,7 +120,7 @@ fn save(config: &WatchlistConfig) -> Result<(), String> { .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; } let yaml = serde_yaml::to_string(config).map_err(|error| format!("serialize: {error}"))?; - fs::write(&path, yaml).map_err(|error| format!("cannot write {}: {error}", path.display())) + crate::cli::config::write_atomic(&path, yaml).map_err(|error| error.to_string()) } fn announce(json: bool, message: &str) { diff --git a/src/manifest/drafts.rs b/src/manifest/drafts.rs index ab3c79d0..e8fcd91e 100644 --- a/src/manifest/drafts.rs +++ b/src/manifest/drafts.rs @@ -83,12 +83,7 @@ impl Register { return Ok(()); } let content = render(self)?; - // Write beside, then rename: a crash mid-write never truncates the register. - let staging = consumer_root.join(format!("{DRAFTS_FILE}.tmp")); - std::fs::write(&staging, content) - .map_err(|error| format!("cannot write {}: {error}", staging.display()))?; - std::fs::rename(&staging, &path) - .map_err(|error| format!("cannot replace {}: {error}", path.display())) + crate::services::atomic::write_atomic(&path, content).map_err(|error| error.to_string()) } /// Every registered path, relative to the consumer root. diff --git a/src/services/atomic.rs b/src/services/atomic.rs new file mode 100644 index 00000000..4e51141a --- /dev/null +++ b/src/services/atomic.rs @@ -0,0 +1,166 @@ +//! The one atomic-replace writer every durable file goes through. +//! +//! A write lands in an adjacent temporary file and is renamed over the +//! destination, so an interruption never leaves a truncated file. The +//! temporary carries a per-process unique suffix and is opened with +//! `create_new`, so a pre-planted symlink at a predictable name cannot +//! redirect the write. A symlink at the destination itself is refused +//! rather than followed or replaced. An existing regular file keeps its +//! permissions, so an executable script stays executable after an edit. + +use std::fs; +use std::io::Write as _; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::error::{Error, ErrorKind}; + +static WRITE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Replace `path` with `content` through a temporary file and a rename. +pub fn write_atomic(path: &Path, content: impl AsRef<[u8]>) -> Result<(), Error> { + write_atomic_with(path, content.as_ref(), |from, to| fs::rename(from, to)) +} + +fn write_atomic_with<F>(path: &Path, content: &[u8], rename: F) -> Result<(), Error> +where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, +{ + if path + .symlink_metadata() + .is_ok_and(|metadata| metadata.is_symlink()) + { + return Err(Error::new( + ErrorKind::Config, + format!("{} is a symlink; refusing to replace it", path.display()), + )); + } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let base_name = path.file_name().map_or_else( + || "rune-write".to_string(), + |name| name.to_string_lossy().into_owned(), + ); + let sequence = WRITE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temporary = parent.join(format!( + ".{base_name}.{}.{sequence}.tmp", + std::process::id() + )); + { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|error| { + Error::new( + ErrorKind::Io, + format!("cannot create {}: {error}", temporary.display()), + ) + })?; + file.write_all(content).map_err(|error| { + let _ = fs::remove_file(&temporary); + Error::new( + ErrorKind::Io, + format!("cannot write {}: {error}", temporary.display()), + ) + })?; + let _ = file.sync_all(); + } + if let Ok(metadata) = fs::metadata(path) + && metadata.is_file() + { + let _ = fs::set_permissions(&temporary, metadata.permissions()); + } + rename(&temporary, path).map_err(|error| { + let _ = fs::remove_file(&temporary); + Error::new( + ErrorKind::Io, + format!("cannot replace {}: {error}", path.display()), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn leftovers(directory: &Path) -> Vec<String> { + fs::read_dir(directory) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| { + Path::new(name) + .extension() + .is_some_and(|extension| extension == "tmp") + }) + .collect() + } + + #[test] + fn replaces_content_and_leaves_no_temporary_behind() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("record.yaml"); + fs::write(&path, "before\n").unwrap(); + + write_atomic(&path, "after\n").unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), "after\n"); + assert_eq!(leftovers(root.path()), Vec::<String>::new()); + } + + #[test] + fn accepts_bytes_and_creates_a_missing_file() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("asset.bin"); + + write_atomic(&path, b"\x00\xff binary").unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"\x00\xff binary"); + } + + #[test] + fn refuses_a_symlinked_destination() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("real.txt"); + fs::write(&target, "real\n").unwrap(); + let link = root.path().join("link.txt"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let error = write_atomic(&link, "replacement\n").unwrap_err(); + + assert!(error.message().contains("symlink"), "{error}"); + assert_eq!(fs::read_to_string(&target).unwrap(), "real\n"); + } + + #[cfg(unix)] + #[test] + fn keeps_the_permissions_of_the_replaced_file() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("script.sh"); + fs::write(&path, "#!/bin/sh\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + + write_atomic(&path, "#!/bin/sh\necho edited\n").unwrap(); + + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o755); + } + + #[test] + fn cleans_temp_and_preserves_destination_on_rename_failure() { + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join(".rune"); + fs::write(&destination, "original\n").unwrap(); + + let error = write_atomic_with(&destination, b"replacement\n", |_, _| { + Err(std::io::Error::other("simulated rename failure")) + }) + .unwrap_err(); + + assert!(error.to_string().contains("simulated rename failure")); + assert_eq!(fs::read_to_string(destination).unwrap(), "original\n"); + assert_eq!(leftovers(root.path()), Vec::<String>::new()); + } +} diff --git a/src/services/confine.rs b/src/services/confine.rs index adf07a77..30a84894 100644 --- a/src/services/confine.rs +++ b/src/services/confine.rs @@ -2,26 +2,96 @@ //! `starts_with` check that keeps every write and read inside its allowed //! root. Raw `..` components and symlinks are resolved before comparison, //! so neither can slip a path outside the boundary. +//! +//! A refusal is a [`Violation`], so a caller can keep its own wording for +//! each kind while the walk itself lives here once. +use std::fmt; use std::path::{Component, Path, PathBuf}; +/// Why a candidate path failed confinement. +#[derive(Debug)] +pub enum Violation { + /// The candidate resolves to a location outside the base. + Escape { + candidate: PathBuf, + base: PathBuf, + resolved: PathBuf, + }, + /// The deepest existing component of a write target is a symlink, so + /// the not-yet-created suffix cannot be proven to land inside the base. + Symlink { + candidate: PathBuf, + base: PathBuf, + link: PathBuf, + }, + /// A path could not be resolved on the filesystem. + Unresolvable { + path: PathBuf, + error: std::io::Error, + }, + /// No ancestor of the candidate exists, so there is nothing to resolve. + NoAncestor { candidate: PathBuf }, +} + +impl fmt::Display for Violation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Escape { + candidate, base, .. + } + | Self::Symlink { + candidate, base, .. + } => write!( + formatter, + "{} escapes {}", + candidate.display(), + base.display() + ), + Self::Unresolvable { path, error } => { + write!(formatter, "cannot resolve {}: {error}", path.display()) + } + Self::NoAncestor { candidate } => write!( + formatter, + "cannot resolve any ancestor of {}", + candidate.display() + ), + } + } +} + +impl std::error::Error for Violation {} + +impl From<Violation> for String { + fn from(violation: Violation) -> Self { + violation.to_string() + } +} + +/// True when `path` is non-empty and every component is a plain name: no +/// root, no prefix, no `.` or `..`. The lexical check for a manifest key or +/// a relative path read from a record before it is joined onto a root. +#[must_use] +pub fn is_plain_relative(path: &Path) -> bool { + !path.as_os_str().is_empty() + && path + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + /// Resolve an EXISTING `candidate` and require it inside `base`. /// Returns the canonical candidate path. -pub fn confine_existing(base: &Path, candidate: &Path) -> Result<PathBuf, String> { - let resolved_base = base - .canonicalize() - .map_err(|error| format!("cannot resolve {}: {error}", base.display()))?; - let resolved = candidate - .canonicalize() - .map_err(|error| format!("cannot resolve {}: {error}", candidate.display()))?; +pub fn confine_existing(base: &Path, candidate: &Path) -> Result<PathBuf, Violation> { + let resolved_base = canonicalize(base)?; + let resolved = canonicalize(candidate)?; if resolved.starts_with(&resolved_base) { Ok(resolved) } else { - Err(format!( - "{} escapes {}", - candidate.display(), - base.display() - )) + Err(Violation::Escape { + candidate: candidate.to_path_buf(), + base: base.to_path_buf(), + resolved, + }) } } @@ -30,34 +100,37 @@ pub fn confine_existing(base: &Path, candidate: &Path) -> Result<PathBuf, String /// part that exists), then the not-yet-created suffix is resolved lexically: /// each `..` must not climb above `base`, and a symlinked terminal component /// is refused because its target cannot be proven to stay inside. -pub fn confine_for_write(base: &Path, candidate: &Path) -> Result<(), String> { - let resolved_base = base - .canonicalize() - .map_err(|error| format!("cannot resolve {}: {error}", base.display()))?; +pub fn confine_for_write(base: &Path, candidate: &Path) -> Result<(), Violation> { + let resolved_base = canonicalize(base)?; let mut existing = candidate; while existing.symlink_metadata().is_err() { - existing = existing - .parent() - .ok_or_else(|| format!("cannot resolve any ancestor of {}", candidate.display()))?; + existing = existing.parent().ok_or_else(|| Violation::NoAncestor { + candidate: candidate.to_path_buf(), + })?; } if existing .symlink_metadata() .is_ok_and(|metadata| metadata.file_type().is_symlink()) { - return Err(format!( - "{} escapes {}", - candidate.display(), - base.display() - )); + return Err(Violation::Symlink { + candidate: candidate.to_path_buf(), + base: base.to_path_buf(), + link: existing.to_path_buf(), + }); } - let resolved_existing = existing - .canonicalize() - .map_err(|error| format!("cannot resolve {}: {error}", existing.display()))?; + let resolved_existing = canonicalize(existing)?; + let escape = |resolved: PathBuf| Violation::Escape { + candidate: candidate.to_path_buf(), + base: base.to_path_buf(), + resolved, + }; let suffix = candidate .strip_prefix(existing) - .map_err(|_| format!("cannot resolve {}", candidate.display()))?; + .map_err(|_| Violation::NoAncestor { + candidate: candidate.to_path_buf(), + })?; let mut projected = resolved_existing; for component in suffix.components() { match component { @@ -66,26 +139,24 @@ pub fn confine_for_write(base: &Path, candidate: &Path) -> Result<(), String> { Component::ParentDir => { projected.pop(); } - Component::RootDir | Component::Prefix(_) => { - return Err(format!( - "{} escapes {}", - candidate.display(), - base.display() - )); - } + Component::RootDir | Component::Prefix(_) => return Err(escape(projected)), } } if projected.starts_with(&resolved_base) { Ok(()) } else { - Err(format!( - "{} escapes {}", - candidate.display(), - base.display() - )) + Err(escape(projected)) } } +fn canonicalize(path: &Path) -> Result<PathBuf, Violation> { + path.canonicalize() + .map_err(|error| Violation::Unresolvable { + path: path.to_path_buf(), + error, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -113,7 +184,22 @@ mod tests { ); let error = confine_existing(root.path(), &escape).unwrap_err(); - assert!(error.contains("escapes"), "{error}"); + assert!(matches!(error, Violation::Escape { .. }), "{error:?}"); + assert!(error.to_string().contains("escapes"), "{error}"); + } + + #[test] + fn absolute_path_outside_root_is_rejected() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let file = outside.path().join("elsewhere.txt"); + std::fs::write(&file, "content outside the root\n").unwrap(); + + let existing = confine_existing(root.path(), &file).unwrap_err(); + assert!(matches!(existing, Violation::Escape { .. }), "{existing:?}"); + + let future = confine_for_write(root.path(), &outside.path().join("new.txt")).unwrap_err(); + assert!(matches!(future, Violation::Escape { .. }), "{future:?}"); } #[test] @@ -126,7 +212,8 @@ mod tests { std::os::unix::fs::symlink(&secret, &link).unwrap(); let error = confine_existing(root.path(), &link).unwrap_err(); - assert!(error.contains("escapes"), "{error}"); + assert!(matches!(error, Violation::Escape { .. }), "{error:?}"); + assert!(error.to_string().contains("escapes"), "{error}"); } #[test] @@ -138,7 +225,7 @@ mod tests { let outside = tempfile::tempdir().unwrap(); let escape = outside.path().join("new.txt"); let error = confine_for_write(root.path(), &escape).unwrap_err(); - assert!(error.contains("escapes"), "{error}"); + assert!(error.to_string().contains("escapes"), "{error}"); } #[test] @@ -152,7 +239,7 @@ mod tests { .join("evil.txt"); let error = confine_for_write(root.path(), &escape).unwrap_err(); - assert!(error.contains("escapes"), "{error}"); + assert!(matches!(error, Violation::Escape { .. }), "{error:?}"); } #[test] @@ -164,6 +251,30 @@ mod tests { std::os::unix::fs::symlink(&target, &link).unwrap(); let error = confine_for_write(root.path(), &link).unwrap_err(); - assert!(error.contains("escapes"), "{error}"); + assert!( + matches!(error, Violation::Symlink { ref link, .. } if link == &root.path().join("dest.txt")), + "{error:?}" + ); + assert!(error.to_string().contains("escapes"), "{error}"); + } + + #[test] + fn unresolvable_base_is_reported_as_such() { + let root = tempfile::tempdir().unwrap(); + let missing = root.path().join("never-created"); + + let error = confine_for_write(&missing, &missing.join("file.txt")).unwrap_err(); + assert!(matches!(error, Violation::Unresolvable { .. }), "{error:?}"); + assert!(error.to_string().contains("cannot resolve"), "{error}"); + } + + #[test] + fn plain_relative_accepts_names_and_rejects_the_rest() { + assert!(is_plain_relative(Path::new("skills/Demo/SKILL.md"))); + assert!(!is_plain_relative(Path::new(""))); + assert!(!is_plain_relative(Path::new("../escape.md"))); + assert!(!is_plain_relative(Path::new("skills/../rules/x.md"))); + assert!(!is_plain_relative(Path::new("/etc/passwd"))); + assert!(!is_plain_relative(Path::new("./skills/x.md"))); } } diff --git a/src/services/editing.rs b/src/services/editing.rs index a277628a..6328d8d3 100644 --- a/src/services/editing.rs +++ b/src/services/editing.rs @@ -12,21 +12,7 @@ pub fn atomic_write(path: &Path, content: &str) -> Result<(), String> { .ok_or_else(|| format!("{} has no parent directory", path.display()))?; std::fs::create_dir_all(parent) .map_err(|error| format!("could not create {}: {error}", parent.display()))?; - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let temporary = parent.join(format!(".rune-edit-{}-{nonce}.tmp", std::process::id())); - std::fs::write(&temporary, content) - .map_err(|error| format!("could not write {}: {error}", temporary.display()))?; - if let Ok(metadata) = std::fs::metadata(path) { - let _ = std::fs::set_permissions(&temporary, metadata.permissions()); - } - if let Err(error) = std::fs::rename(&temporary, path) { - let _ = std::fs::remove_file(&temporary); - return Err(format!("could not replace {}: {error}", path.display())); - } - Ok(()) + super::atomic::write_atomic(path, content).map_err(|error| error.to_string()) } /// Return the adjacent `user/` override, creating it from `source` when absent. diff --git a/src/services/mod.rs b/src/services/mod.rs index 0a66395c..a2cef112 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -6,6 +6,7 @@ //! and groups artifacts by their source module (via provenance `source_uri`). mod adr; +pub mod atomic; pub mod builders; pub mod confine; mod discovery; @@ -42,8 +43,6 @@ use provenance::collect_provenance; use references::artifact_staleness; use std::collections::{BTreeMap, HashSet}; use std::fs; -use std::fs::OpenOptions; -use std::io::Write as _; use std::path::{Path, PathBuf}; use target::{PendingCompanion, attach_companions, scan_target}; @@ -743,30 +742,7 @@ fn deck_rune_ids(deck: &crate::deck::Deck) -> Vec<String> { } fn atomic_write(path: &Path, content: &[u8]) -> Result<(), Error> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let temp = parent.join(format!(".cast.tmp-{}-{nonce}", std::process::id())); - let result = (|| { - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp)?; - file.write_all(content)?; - file.sync_all()?; - drop(file); - fs::rename(&temp, path) - })(); - if let Err(error) = result { - let _ = fs::remove_file(&temp); - return Err(Error::new( - ErrorKind::Io, - format!("cannot atomically rewrite {}: {error}", path.display()), - )); - } - Ok(()) + atomic::write_atomic(path, content) } /// Directory-name allowlist for ADRs and schemas: the active modules plus the diff --git a/src/tui/rich.rs b/src/tui/rich.rs index ca9ac288..25739aa5 100644 --- a/src/tui/rich.rs +++ b/src/tui/rich.rs @@ -208,14 +208,9 @@ fn glow_style_path() -> Option<String> { .get_or_init(|| { let user = std::env::var("USER").unwrap_or_else(|_| "shared".to_string()); let path = std::env::temp_dir().join(format!("rune-glow-style-{user}.json")); - let staging = std::env::temp_dir().join(format!( - "rune-glow-style-{user}-{}.json.tmp", - std::process::id() - )); - // Write-then-rename keeps concurrent rune processes from ever + // The atomic writer keeps concurrent rune processes from ever // observing a truncated style file. - std::fs::write(&staging, include_str!("glow_style.json")).ok()?; - std::fs::rename(&staging, &path).ok()?; + rune::services::atomic::write_atomic(&path, include_str!("glow_style.json")).ok()?; Some(path.to_string_lossy().into_owned()) }) .clone() diff --git a/templates/init/skills/.mdschema b/templates/init/skills/.mdschema index 0ba4de14..1e12773c 100644 --- a/templates/init/skills/.mdschema +++ b/templates/init/skills/.mdschema @@ -1,28 +1,22 @@ frontmatter: fields: - # rune convention: required for module skills - name: name type: string - name: description type: string - - name: version - type: string - - # Claude Code optional fields - # https://code.claude.com/docs/en/skills#frontmatter-reference - # `hooks` is intentionally omitted: mdschema has no `object` type yet, - # and a hooks map (`{ PreToolUse: [...] }`) cannot be represented as - # one of string|number|boolean|array|date. - - name: when_to_use + - name: license type: string optional: true - - name: argument-hint + - name: compatibility type: string optional: true - - name: arguments - type: array + - name: metadata + type: object optional: true - name: allowed-tools + type: string + optional: true + - name: targets type: array optional: true - name: disable-model-invocation @@ -31,42 +25,35 @@ frontmatter: - name: user-invocable type: boolean optional: true - - name: model - type: string - optional: true - - name: effort - type: string - optional: true - - name: context - type: string - optional: true - - name: agent - type: string - optional: true - - name: paths - type: array - optional: true - - name: shell - type: string - optional: true heading_rules: no_skip_levels: true + unique_per_level: true max_depth: 3 structure: - heading: - pattern: "^# .+" - regex: true - allow_additional: true + pattern: "# .+" + count: + max: 1 children: - - heading: - pattern: "## Workflow Routing" + - heading: "## Prerequisites" + optional: true + + - heading: "## Constraints" optional: true - tables: - - min: 1 - - heading: - pattern: "## Constraints" + allow_additional: true + + - heading: "## Instructions" + allow_additional: true + + - heading: "## Verification" + optional: true + allow_additional: true + + - heading: "## Troubleshooting" + optional: true + allow_additional: true + + - heading: "## References" optional: true - lists: - - min: 1 diff --git a/templates/skeleton/base/KEYS b/templates/skeleton/base/KEYS index 7c46cb7b..3705c891 100644 --- a/templates/skeleton/base/KEYS +++ b/templates/skeleton/base/KEYS @@ -1,4 +1,4 @@ -# Trusted signers for this repository (docs/specs/trusted-key-anchor). +# Trusted signers for this repository (docs/specs/sign-with-trusted-keys). # One line per signer: the pinned fingerprint, then the addresses whose Web # Key Directory (or GitHub key list) supplies the bytes, tried in order. # scripts/trusted-keys resolves this file; nothing is trusted but the pin. diff --git a/templates/skeleton/base/scripts/trusted-keys b/templates/skeleton/base/scripts/trusted-keys index 2fcd775f..59894c11 100755 --- a/templates/skeleton/base/scripts/trusted-keys +++ b/templates/skeleton/base/scripts/trusted-keys @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build a gpg keyring from a KEYS file (docs/specs/trusted-key-anchor). +# Build a gpg keyring from a KEYS file (docs/specs/sign-with-trusted-keys). # # trusted-keys --keys FILE --homedir DIR [--offline] # diff --git a/templates/skeleton/base/scripts/verify-seal b/templates/skeleton/base/scripts/verify-seal index c0c94177..1620caa7 100755 --- a/templates/skeleton/base/scripts/verify-seal +++ b/templates/skeleton/base/scripts/verify-seal @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Verify the owner's seals on a pull request head (docs/specs/owner-release-ceremony, -# docs/specs/trusted-key-anchor for the KEYS format). +# docs/specs/sign-with-trusted-keys for the KEYS format). # # verify-seal open --head SHA --repo OWNER/NAME --base REF --number N \ # --keys FILE --open-bodies FILE [--since SHA] diff --git a/templates/spec/delta-spec.md b/templates/spec/delta-spec.md index 8de4dd92..77372757 100644 --- a/templates/spec/delta-spec.md +++ b/templates/spec/delta-spec.md @@ -2,7 +2,7 @@ ### Requirement: ${CAPABILITY_TITLE} -The ${CAPABILITY_TITLE} capability SHALL describe its required behavior here. +The ${CAPABILITY_TITLE} capability MUST describe its required behavior here. #### Scenario: Required behavior diff --git a/tests/cli.rs b/tests/cli.rs index 2d93e457..fba20cac 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -524,8 +524,11 @@ fn setup_verification_failure_writes_no_record() { .join(".local/share/bash-completion/completions/rune"); std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); std::fs::create_dir_all(completion_path.parent().unwrap()).unwrap(); - std::fs::write(&config_path, "deck: /tmp/deck\n").unwrap(); - std::fs::hard_link(&config_path, &completion_path).unwrap(); + // The completion write must corrupt the user config so verification + // fails after apply. Writes replace the inode, so a hard link no longer + // aliases the two; the config is a symlink onto the completion file. + std::fs::write(&completion_path, "deck: /tmp/deck\n").unwrap(); + std::os::unix::fs::symlink(&completion_path, &config_path).unwrap(); rune() .current_dir(source.path()) diff --git a/tests/fixtures/root-help.txt b/tests/fixtures/root-help.txt index ab874fdb..c9827ee6 100644 --- a/tests/fixtures/root-help.txt +++ b/tests/fixtures/root-help.txt @@ -17,7 +17,7 @@ review list | export [--target <DIR>] Inspect or export TUI review comments Spec: - spec propose | list | show | glossary | doctor | archive Spec-driven change lifecycle under docs/ + spec propose | list | glossary | fix | doctor | archive Spec-driven change lifecycle under docs/ Deck: discover [QUERY] Find community decks by GitHub topic diff --git a/tests/fixtures/spec-glossary/rune.ttl b/tests/fixtures/spec-glossary/rune.ttl index b435b79a..71eef391 100644 --- a/tests/fixtures/spec-glossary/rune.ttl +++ b/tests/fixtures/spec-glossary/rune.ttl @@ -1,4 +1,4 @@ -# A four-term ontology for the render-spec-glossary proof: a class and a +# A four-term ontology for the glossary-renders-from-ontology proof: a class and a # property that share a label, a SKOS concept, and a class without a comment. @prefix rune: <https://runedeck.ai/ns#> . @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> . diff --git a/tests/install_ontology.rs b/tests/install_ontology.rs index 77b9a8ea..d8a84da6 100644 --- a/tests/install_ontology.rs +++ b/tests/install_ontology.rs @@ -44,20 +44,20 @@ fn install(consumer: &Path, extra: &[&str]) -> assert_cmd::assert::Assert { .assert() } -/// One change in the consumer whose delta specification cites a term the -/// fixture ontology defines. +/// One change in the consumer whose delta specification links a term the +/// fixture ontology defines, through the term's glossary page. fn write_change_citing_a_term(consumer: &Path) { - let change = consumer.join("docs/changes/cite-the-instance"); - fs::create_dir_all(change.join("specs/deployed-copy-records")).unwrap(); + let change = consumer.join("docs/changes/cite-the-deck-instance"); + fs::create_dir_all(change.join("specs/deployed-copy-records-provenance")).unwrap(); fs::write( change.join("proposal.md"), - "---\nstatus: proposed\n---\n\n# Cite the instance\n\n## Why\n\nA reader meets a term.\n\n## What Changes\n\n- Cite it.\n\n## Capabilities\n\n- deployed-copy-records (new)\n\n## Impact\n\n- The specification.\n", + "---\nstatus: proposed\n---\n\n# Cite the deck instance\n\n## Why\n\nA reader meets a term.\n\n## What Changes\n\n- Cite it.\n\n## Capabilities\n\n- deployed-copy-records-provenance (new)\n\n## Impact\n\n- The specification.\n", ) .unwrap(); fs::write(change.join("tasks.md"), "## 1. Work\n\n- [ ] 1.1 Cite\n").unwrap(); fs::write( - change.join("specs/deployed-copy-records/spec.md"), - "## ADDED Requirements\n\n### Requirement: Copy carries a record\n\nEvery *instance* [INSTANCE] MUST carry a record.\n\n#### Scenario: Copy lands\n\n- **WHEN** a copy lands\n- **THEN** its record exists\n\n[INSTANCE]: https://runedeck.ai/ns#Instance\n", + change.join("specs/deployed-copy-records-provenance/spec.md"), + "## ADDED Requirements\n\n### Requirement: Copy carries a record\n\nEvery *[instance][INSTANCE]* MUST carry a record.\n\n#### Scenario: Copy lands\n\n- **WHEN** a copy lands\n- **THEN** its record exists\n\n[INSTANCE]: ../../../../specs/glossary/instance.md\n", ) .unwrap(); } @@ -68,18 +68,33 @@ fn install_copies_the_ontology_with_a_sidecar_and_validate_reads_the_copy() { write_dotrune(consumer.path(), &fixture_deck(), Some("deck")); write_change_citing_a_term(consumer.path()); - // Without the copy, the italic term has no definition in the consumer. + // Without the copy the consumer defines no terms, so the link is an + // ordinary link and nothing is checked. rune() - .args(["spec", "validate", "cite-the-instance", "--source"]) + .args(["spec", "validate", "cite-the-deck-instance", "--source"]) .arg(consumer.path()) .assert() - .failure() - .stdout(predicate::str::contains("term")); + .success(); install(consumer.path(), &[]) .success() .stdout(predicate::str::contains("ontology")); + // With the copy the tag names the term, and its page is missing. + rune() + .args(["spec", "validate", "cite-the-deck-instance", "--source"]) + .arg(consumer.path()) + .assert() + .failure() + .stdout(predicate::str::contains( + "glossary page docs/specs/glossary/instance.md is missing", + )); + rune() + .args(["spec", "glossary", "--write", "--source"]) + .arg(consumer.path()) + .assert() + .success(); + let copy = consumer.path().join("ontology/rune.ttl"); assert_eq!(fs::read(©).unwrap(), fixture_ontology()); let sidecar = consumer.path().join("ontology/.provenance/rune.ttl.yaml"); @@ -107,7 +122,7 @@ fn install_copies_the_ontology_with_a_sidecar_and_validate_reads_the_copy() { ); rune() - .args(["spec", "validate", "cite-the-instance", "--source"]) + .args(["spec", "validate", "cite-the-deck-instance", "--source"]) .arg(consumer.path()) .assert() .success(); diff --git a/tests/spec_terms.rs b/tests/spec_terms.rs index 9b85bacc..582a612a 100644 --- a/tests/spec_terms.rs +++ b/tests/spec_terms.rs @@ -1,5 +1,6 @@ -//! `rune spec glossary` and the term check `rune validate` runs over -//! decision records and runes when the root has an ontology. +//! `rune spec glossary`, `rune spec fix`, the term check `rune validate` +//! runs over decision records and runes when the root has an ontology, +//! and the archive refusal of a draft term. use assert_cmd::Command; use predicates::prelude::*; use std::path::Path; @@ -12,7 +13,7 @@ rune:Harness a rdfs:Class ; rdfs:comment "A coding agent that loads instructions." . rune:change a rdf:Property ; - rdfs:label "change key" ; + rdfs:label "change" ; rdfs:comment "The change a record belongs to." . rune:Change a rdfs:Class ; @@ -20,6 +21,8 @@ rune:Change a rdfs:Class ; rdfs:comment "One spec-driven unit of work." . "#; +const DRAFT_COMMENT: &str = "Draft term. The fixer added it and nobody has defined it yet. Archive refuses a change that links to it."; + fn rune() -> Command { Command::cargo_bin("rune").unwrap() } @@ -41,8 +44,20 @@ fn write_module(root: &Path, rule: &str) { std::fs::create_dir_all(root.join("docs/specs")).unwrap(); } +/// A module with its glossary pages written. +fn module_with_pages(rule: &str) -> tempfile::TempDir { + let module = tempfile::tempdir().unwrap(); + write_module(module.path(), rule); + rune() + .args(["spec", "glossary", "--write", "--source"]) + .arg(module.path()) + .assert() + .success(); + module +} + #[test] -fn glossary_prints_terms_and_reference_definitions_from_the_ontology() { +fn glossary_prints_terms_with_tags_and_page_definitions_from_the_ontology() { let module = tempfile::tempdir().unwrap(); write_module(module.path(), "A rule.\n"); @@ -55,10 +70,10 @@ fn glossary_prints_terms_and_reference_definitions_from_the_ontology() { "- **change**: One spec-driven unit of work. [CHANGE]\n", )) .stdout(predicate::str::contains( - "- **change key**: The change a record belongs to. [CHANGE-KEY]\n", + "- **change**: The change a record belongs to. [CHANGE-KEY]\n", )) .stdout(predicate::str::contains( - "[HARNESS]: https://runedeck.ai/ns#Harness\n", + "[HARNESS]: docs/specs/glossary/harness.md\n", )); let output = rune() @@ -68,38 +83,52 @@ fn glossary_prints_terms_and_reference_definitions_from_the_ontology() { .unwrap(); let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(value["source"], "ontology/rune.ttl"); + assert_eq!(value["glossary"], "docs/specs/glossary"); assert_eq!(value["terms"][0]["tag"], "CHANGE"); + assert_eq!(value["terms"][1]["page"], "change-key.md"); assert_eq!(value["terms"][2]["iri"], "https://runedeck.ai/ns#Harness"); + assert_eq!(value["terms"][2]["status"], "defined"); } #[test] fn glossary_write_then_check_is_current_and_a_hand_edit_is_drift() { let module = tempfile::tempdir().unwrap(); write_module(module.path(), "A rule.\n"); + std::fs::write( + module.path().join("docs/specs/glossary.md"), + "# Glossary\n\nold single file\n", + ) + .unwrap(); rune() .args(["spec", "glossary", "--write", "--source"]) .arg(module.path()) .assert() .success() - .stdout("wrote docs/specs/glossary.md\n"); - let glossary = module.path().join("docs/specs/glossary.md"); - let text = std::fs::read_to_string(&glossary).unwrap(); - assert!(text.starts_with( - "# Glossary\n\nGenerated from `ontology/rune.ttl` by `rune spec glossary --write`." - )); - assert!(text.contains("\n- **change**: One spec-driven unit of work. [CHANGE]\n")); - assert!(text.ends_with("\n[HARNESS]: https://runedeck.ai/ns#Harness\n")); + .stdout( + "wrote docs/specs/glossary: 3 page(s) and the index, 4 file(s) changed\n removed docs/specs/glossary.md\n", + ); + assert!(!module.path().join("docs/specs/glossary.md").exists()); + let page = module.path().join("docs/specs/glossary/harness.md"); + assert_eq!( + std::fs::read_to_string(&page).unwrap(), + "---\ntitle: harness\niri: https://runedeck.ai/ns#Harness\nkind: class\nstatus: defined\n---\n\n# harness\n\nA coding agent that loads instructions.\n\nGenerated from `ontology/rune.ttl`. Edit the ontology, not this page.\n" + ); + assert_eq!( + std::fs::read_to_string(module.path().join("docs/specs/glossary/index.md")).unwrap(), + "# Glossary\n\nGenerated from `ontology/rune.ttl`. Edit the ontology, not these pages.\n\n- [change](change.md): One spec-driven unit of work.\n- [change](change-key.md): The change a record belongs to.\n- [harness](harness.md): A coding agent that loads instructions.\n" + ); rune() .args(["spec", "glossary", "--check", "--source"]) .arg(module.path()) .assert() .success() - .stdout("docs/specs/glossary.md is current\n"); + .stdout("docs/specs/glossary is current\n"); + let text = std::fs::read_to_string(&page).unwrap(); std::fs::write( - &glossary, + &page, text.replace("A coding agent that loads instructions.", "Edited."), ) .unwrap(); @@ -109,12 +138,12 @@ fn glossary_write_then_check_is_current_and_a_hand_edit_is_drift() { .assert() .code(1) .stdout( - "docs/specs/glossary.md is stale: run `rune spec glossary --write`\n changed harness [HARNESS]\n", + "docs/specs/glossary is stale: run `rune spec glossary --write`\n changed harness (harness.md)\n", ); } #[test] -fn glossary_check_without_a_file_exits_one_and_the_flags_do_not_combine() { +fn glossary_check_without_a_directory_exits_one_and_the_flags_do_not_combine() { let module = tempfile::tempdir().unwrap(); write_module(module.path(), "A rule.\n"); @@ -123,7 +152,7 @@ fn glossary_check_without_a_file_exits_one_and_the_flags_do_not_combine() { .arg(module.path()) .assert() .code(1) - .stdout("docs/specs/glossary.md is missing: run `rune spec glossary --write`\n"); + .stdout("docs/specs/glossary is missing: run `rune spec glossary --write`\n"); let output = rune() .args(["spec", "glossary", "--check", "--json", "--source"]) @@ -133,7 +162,7 @@ fn glossary_check_without_a_file_exits_one_and_the_flags_do_not_combine() { assert_eq!(output.status.code(), Some(1)); let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(value["status"], "missing"); - assert_eq!(value["path"], "docs/specs/glossary.md"); + assert_eq!(value["path"], "docs/specs/glossary"); rune() .args(["spec", "glossary", "--write", "--check", "--source"]) @@ -144,11 +173,9 @@ fn glossary_check_without_a_file_exits_one_and_the_flags_do_not_combine() { } #[test] -fn validate_reports_an_uncited_term_and_ignores_plain_emphasis_in_a_rule() { - let module = tempfile::tempdir().unwrap(); - write_module( - module.path(), - "Every *harness* loads this rule. A *widget* is emphasis, *not* a term.\n", +fn validate_reports_a_plain_first_use_and_ignores_emphasis_in_a_rule() { + let module = module_with_pages( + "Every [harness][HARNESS] loads this rule. A *widget* is emphasis, *not* a term.\n\n[HARNESS]: ../docs/specs/glossary/harness.md\n", ); rune() @@ -158,17 +185,15 @@ fn validate_reports_an_uncited_term_and_ignores_plain_emphasis_in_a_rule() { .failure() .stdout(predicate::str::contains("rules/Terms.md")) .stdout(predicate::str::contains( - "first use of *harness* has no reference tag", + "first use of [harness][HARNESS] in this file is not emphasized", )) .stdout(predicate::str::contains("widget").not()); } #[test] -fn validate_passes_a_rule_that_cites_its_terms() { - let module = tempfile::tempdir().unwrap(); - write_module( - module.path(), - "Every *harness* [HARNESS] loads this rule.\n\n[HARNESS]: https://runedeck.ai/ns#Harness\n", +fn validate_passes_a_rule_that_links_its_terms_and_warns_on_the_legacy_form() { + let module = module_with_pages( + "Every *[harness][HARNESS]* loads this rule, and every [harness][HARNESS] again.\n\n[HARNESS]: ../docs/specs/glossary/harness.md\n", ); rune() @@ -177,12 +202,30 @@ fn validate_passes_a_rule_that_cites_its_terms() { .assert() .success() .stdout(predicate::str::contains("✓ terms")); + + std::fs::write( + module.path().join("rules/Terms.md"), + "Every *harness* [HARNESS] loads this rule.\n\n[HARNESS]: https://runedeck.ai/ns#Harness\n", + ) + .unwrap(); + rune() + .args(["validate", "--source"]) + .arg(module.path()) + .assert() + .success() + .stdout(predicate::str::contains("1 warning")) + .stdout(predicate::str::contains( + "`*harness* [HARNESS]` is the legacy form; write `*[harness][HARNESS]*` at first use, `[harness][HARNESS]` later, and define `[HARNESS]: ../docs/specs/glossary/harness.md`", + )); } #[test] fn validate_skips_the_term_check_without_an_ontology() { let module = tempfile::tempdir().unwrap(); - write_module(module.path(), "Every *harness* loads this rule.\n"); + write_module( + module.path(), + "Every [harness][HARNESS] loads this rule.\n\n[HARNESS]: ../docs/specs/glossary/harness.md\n", + ); std::fs::remove_file(module.path().join("ontology/rune.ttl")).unwrap(); rune() @@ -192,3 +235,91 @@ fn validate_skips_the_term_check_without_an_ontology() { .success() .stdout(predicate::str::contains("terms").not()); } + +#[test] +fn fix_links_migrates_the_legacy_form_and_fix_terms_drafts_an_unknown_term() { + let module = module_with_pages( + "Every *harness* [HARNESS] loads a *[gadget][GADGET]*; the *harness* [HARNESS] again.\n\n[HARNESS]: https://runedeck.ai/ns#Harness\n[GADGET]: ../docs/specs/glossary/gadget.md\n", + ); + + rune() + .args(["validate", "--source"]) + .arg(module.path()) + .assert() + .failure() + .stdout(predicate::str::contains( + "term link [gadget][GADGET] names no term: the page is missing and no term in ontology/rune.ttl has the tag; run `rune spec fix --terms` to add a draft", + )); + + rune() + .args(["spec", "fix", "--source"]) + .arg(module.path()) + .assert() + .success() + .stdout( + "rewrote rules/Terms.md\nadded draft 'gadget' as Gadget (docs/specs/glossary/gadget.md) from rules/Terms.md:1\nwrote ontology/rune.ttl and docs/specs/glossary\n", + ); + assert_eq!( + std::fs::read_to_string(module.path().join("rules/Terms.md")).unwrap(), + "Every *[harness][HARNESS]* loads a *[gadget][GADGET]*; the [harness][HARNESS] again.\n\n[HARNESS]: ../docs/specs/glossary/harness.md\n[GADGET]: ../docs/specs/glossary/gadget.md\n" + ); + let ontology = std::fs::read_to_string(module.path().join("ontology/rune.ttl")).unwrap(); + assert!(ontology.ends_with( + "rune:Gadget a rdfs:Class ;\n rdfs:label \"gadget\" ;\n rune:status \"draft\" .\n" + )); + let page = + std::fs::read_to_string(module.path().join("docs/specs/glossary/gadget.md")).unwrap(); + assert!(page.contains("status: draft\n")); + assert!(page.contains(DRAFT_COMMENT)); + + rune() + .args(["validate", "--source"]) + .arg(module.path()) + .assert() + .success() + .stdout(predicate::str::contains( + "term 'gadget' is a draft; define it in ontology/rune.ttl before archive", + )); + + rune() + .args(["spec", "fix", "--source"]) + .arg(module.path()) + .assert() + .success() + .stdout("nothing to fix\n"); +} + +#[test] +fn archive_refuses_a_change_that_links_to_a_draft_term() { + let module = module_with_pages("A rule.\n"); + let change = module.path().join("docs/changes/add-the-gadget-rules"); + std::fs::create_dir_all(change.join("specs/gadget-rules")).unwrap(); + std::fs::write(change.join("proposal.md"), "# Change\n").unwrap(); + std::fs::write(change.join("tasks.md"), "- [x] done\n").unwrap(); + std::fs::write( + change.join("specs/gadget-rules/spec.md"), + "## ADDED Requirements\n\n### Requirement: Gadget Rules\n\nA *[gadget][GADGET]* MUST spin, and a [phantom][PHANTOM] too.\n\n#### Scenario: Gadget spins\n\n- **WHEN** a gadget exists\n- **THEN** it spins\n\n[GADGET]: ../../../../specs/glossary/gadget.md\n", + ) + .unwrap(); + rune() + .args(["spec", "fix", "--terms", "--source"]) + .arg(module.path()) + .assert() + .success(); + + rune() + .args(["spec", "archive", "add-the-gadget-rules", "--source"]) + .arg(module.path()) + .assert() + .failure() + .stderr(predicate::str::contains( + "change 'add-the-gadget-rules' links to terms that are not settled:", + )) + .stderr(predicate::str::contains( + "draft 'gadget' as [gadget][GADGET]: docs/changes/add-the-gadget-rules/specs/gadget-rules/spec.md:5", + )) + .stderr(predicate::str::contains( + "undefined [phantom][PHANTOM]: docs/changes/add-the-gadget-rules/specs/gadget-rules/spec.md:5", + )); + assert!(change.is_dir()); +}