From ec9f6dc639950e6723eb6166b7fc6eac0be6f4e7 Mon Sep 17 00:00:00 2001 From: Gabriel Wu <13583761+lucifer1004@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:34:28 +0800 Subject: [PATCH 1/3] docs(adr): draft ADR-0039 (FTS search, proposed) and accept ADR-0040 (controlled-vocabulary tags) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0039: SQLite FTS5 as read-only search index — on hold pending tags evaluation. ADR-0040: Controlled-vocabulary tags for governance artifacts — accepted. --- ...search-index-for-governance-artifacts.toml | 123 ++++++++++++++++++ ...abulary-tags-for-governance-artifacts.toml | 111 ++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 gov/adr/ADR-0039-use-sqlite-fts5-as-read-only-search-index-for-governance-artifacts.toml create mode 100644 gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml diff --git a/gov/adr/ADR-0039-use-sqlite-fts5-as-read-only-search-index-for-governance-artifacts.toml b/gov/adr/ADR-0039-use-sqlite-fts5-as-read-only-search-index-for-governance-artifacts.toml new file mode 100644 index 0000000..3d64bc0 --- /dev/null +++ b/gov/adr/ADR-0039-use-sqlite-fts5-as-read-only-search-index-for-governance-artifacts.toml @@ -0,0 +1,123 @@ +#:schema ../schema/adr.schema.json + +[govctl] +id = "ADR-0039" +title = "Use SQLite FTS5 as read-only search index for governance artifacts" +status = "proposed" +date = "2026-04-09" +refs = [ + "RFC-0002", + "RFC-0004", +] + +[content] +context = """ +govctl manages governance artifacts (RFCs, ADRs, clauses, work items, guards) as TOML files in `gov/`. As the corpus grows (currently 200+ artifacts, projected to reach 1000+ in active projects), finding artifacts by content becomes increasingly difficult. + +### Problem Statement + +Users need to answer questions like "which ADR discussed caching?", "which RFC clause mentions backward compatibility?", or "which work items reference RFC-0002?". Currently this requires: + +- `grep` over raw TOML files (poor UX, no ranking, no stemming) +- `govctl list` + manual inspection (only searches titles) +- Memorizing artifact IDs + +None of these scale or provide relevance-ranked results. + +### Constraints + +- [[RFC-0002]] establishes TOML files as the source of truth — any index must be derived, not authoritative +- [[RFC-0004]] governs concurrent write safety — the index must not interfere with the file locking protocol +- The index must work offline with no external services +- Rebuild must be fast enough to run transparently on every search query""" +decision = """ +We will use **SQLite FTS5 with lazy incremental sync** as the search backend for `govctl search`. + +### Design + +1. **Index location:** `gov/.search.db` (gitignored). Disposable — can be deleted and rebuilt transparently. + +2. **Indexed content:** All artifact types (RFCs, clauses, ADRs, work items, guards). Each entry stores the artifact ID, type, title, and a concatenation of all human-readable text fields. Tokenized with Porter stemming for English morphological matching. + +3. **Sync strategy — lazy incremental:** + - On every `govctl search`, compare content hashes of `gov/` TOML files against an index-side manifest + - New/changed files: parse and upsert into the search index + - Deleted files: remove from index + - Unchanged files: skip + - Missing or corrupt index: full rebuild (no error, just slower first query) + +4. **No write-through optimization.** The lazy scan is correct in all cases and fast enough (~10ms at current scale). Adding write-through coupling between the artifact write path and the index is premature complexity. + +5. **Concurrency:** SQLite WAL mode handles concurrent search invocations safely. If two `govctl search` calls trigger a sync simultaneously, both will complete without corruption. + +6. **Explicit escape hatch:** `govctl search --reindex` forces a full rebuild. + +### Why This Design + +- Lazy sync avoids coupling between the write path and the index — files changed by manual editing, VCS operations, or govctl all sync identically +- A single read-only cache file is simpler than a persistent daemon or event-driven index +- The index is not covered by [[RFC-0004]] file locking because it is a derived cache, not a governance artifact +- `gov/.search.db` should be added to `.gitignore` by `govctl init`""" +consequences = """ +### Positive + +- Users can find artifacts by content with relevance ranking — "which ADR discussed caching?" returns ranked results instantly +- Porter stemming handles morphological variants (cache/caching/cached) without exact-match frustration +- Index is disposable and self-healing — delete `gov/.search.db` and the next search rebuilds it +- No daemon, no external service, no network — works fully offline +- Lazy sync means zero ceremony — no separate index-build step, no cache invalidation protocol + +### Negative + +- Adds `rusqlite` (bundled) as a dependency, increasing binary size by ~3MB (mitigation: feature-gate search behind a default-on cargo feature if binary size becomes a concern) +- First search after bulk file changes (e.g., `git checkout` switching branches, large merge) will be slower due to lazy-sync catch-up cost (mitigation: rebuild is still sub-second for 1000 artifacts; `--reindex` makes this explicit when needed) +- CJK text requires additional tokenizer configuration beyond the default Porter stemmer (mitigation: defer to a follow-up if CJK projects adopt govctl) +- The index file must be gitignored — if a user commits it accidentally, it will cause noisy diffs (mitigation: `govctl init` adds `gov/.search.db` to `.gitignore` by default) + +### Neutral + +- The search index introduces a second file format (SQLite) into the `gov/` directory alongside TOML, but it is explicitly non-authoritative and disposable""" + +[[content.alternatives]] +text = "SQLite FTS5 with lazy incremental sync: single-file read-only index using rusqlite (bundled), Porter stemming, BM25 ranking, and content-hash-based incremental updates on each search query." +status = "accepted" +pros = [ + "Battle-tested BM25 ranking out of the box", + "Single-file index, no daemon or external service", + "Porter stemming handles English morphology (cache/caching/cached)", + "rusqlite is mature with bundled compilation — no system SQLite dependency", + "Lazy sync means no separate build step or cache invalidation protocol", +] +cons = [ + "Adds ~3MB to binary size from bundled SQLite", + "CJK segmentation requires additional tokenizer configuration", +] + +[[content.alternatives]] +text = "Tantivy (Rust-native full-text search): Use the tantivy crate, a Lucene-inspired search engine written in Rust. Supports BM25, tokenizers, and schema-defined fields natively." +status = "rejected" +pros = [ + "Pure Rust, no C dependency", + "More powerful query language (boolean, phrase, fuzzy)", + "Purpose-built for search — better performance at scale", +] +cons = [ + "Much heavier dependency (~50 crates in dependency tree)", + "Index is a directory of segment files, not a single file", + "Overkill for <1000 documents", +] +rejection_reason = "Dependency weight and complexity are disproportionate to the scale of govctl's artifact corpus. SQLite FTS5 covers the requirements with a single well-understood dependency." + +[[content.alternatives]] +text = "In-memory inverted index with no persistence: Build a simple inverted index on every search invocation by scanning all TOML files, tokenizing content, and ranking by term frequency. No disk cache." +status = "rejected" +pros = [ + "Zero dependencies — no SQLite, no new crates", + "No cache invalidation problem — always fresh", +] +cons = [ + "Full rebuild on every query (~100ms at 200 files, grows linearly)", + "No stemming or advanced tokenization without additional code", + "No BM25 — would need a custom ranking implementation", +] +rejection_reason = "Lacks stemming and BM25 ranking out of the box. Rebuild cost scales linearly and becomes noticeable beyond 500 artifacts. The UX gap versus FTS5 is significant for the marginal dependency savings." diff --git a/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml b/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml new file mode 100644 index 0000000..3503ca5 --- /dev/null +++ b/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml @@ -0,0 +1,111 @@ +#:schema ../schema/adr.schema.json + +[govctl] +id = "ADR-0040" +title = "Controlled-vocabulary tags for governance artifacts" +status = "accepted" +date = "2026-04-09" +refs = [ + "RFC-0002", + "ADR-0039", +] + +[content] +context = """ +As the govctl artifact corpus grows (currently 200+ artifacts), finding related artifacts by domain becomes difficult. Users resort to `grep` or memorizing IDs. + +### Problem Statement + +There is no structured way to answer "show me everything related to caching" or "which ADRs touch the parser". Artifact titles provide some signal, but titles are inconsistent and not designed for cross-cutting categorization. + +### Constraints + +- [[RFC-0002:C-RESOURCES]] defines the field surface for each artifact type — adding `tags` requires a schema amendment +- [[RFC-0002:C-CRUD-VERBS]] governs how fields are mutated — tags must follow existing `add`/`remove` verb semantics +- Tags must be diffable and reviewable in PRs (no hidden state) +- The system should prevent tag sprawl — typos and near-duplicates degrade signal + +### Options Considered + +Two tagging models: controlled vocabulary (registry-first) vs. free-form (tag-on-use). See alternatives for analysis.""" +decision = """ +We will use a **controlled-vocabulary tag system** where tags must be registered in a project-level allowed list before any artifact can reference them. + +### Why Controlled Vocabulary + +The core trade-off is between friction and signal quality. Free-form tags have zero friction but degrade rapidly — typos, case variants, and synonyms fragment the taxonomy. In a governed workflow where artifacts are meant to be auditable and cross-referenced, unreliable metadata defeats the purpose. + +A controlled vocabulary enforces consistency at the cost of a one-time registration step for each new tag. This cost is intentional: introducing a new domain category is a project-level decision that should be visible and reviewable. + +### Design Outline + +- **Registry**: a `[tags] allowed` list in `gov/config.toml` — flat, lowercase kebab-case strings +- **Artifact field**: an optional `tags` array in the `[govctl]` section of RFCs, ADRs, work items, and guards. Clauses inherit discoverability from the parent RFC. +- **Management**: registry-level add/remove/list commands; artifact-level tagging via existing `add`/`remove` verbs +- **Filtering**: `--tag` flag on existing `list` commands +- **Validation**: `govctl check` rejects tags not in the allowed set + +Detailed command syntax, schema changes, and validation rules will be specified in an RFC-0002 amendment. + +### Constraints + +- No maximum tag count per artifact — signal quality is maintained by the controlled vocabulary, not by limiting labels +- The initial seed list of allowed tags is a separate operational decision from the mechanism itself +- Tags complement but do not replace potential future full-text search (see [[ADR-0039]])""" +consequences = """ +### Positive + +- Cross-cutting discovery becomes a first-class operation — "show me everything about caching" is a single command +- Controlled vocabulary prevents tag sprawl — consistency is enforced, not hoped for +- Tags are part of the TOML source — diffable, reviewable in PRs, greppable +- Agents can enumerate available tags and use them programmatically +- Extends existing `add`/`remove`/`list` verb model — minimal new CLI grammar + +### Negative + +- Friction to introduce a new tag — requires a config edit before first use (mitigation: this friction is intentional and the operation is a one-liner) +- Retroactive tagging of existing artifacts requires effort (mitigation: incremental adoption — untagged artifacts simply don't appear in filtered queries) +- Schema change across all four artifact types (mitigation: `tags` is optional with empty-array default — existing artifacts remain valid without modification) + +### Neutral + +- `govctl tag` becomes a new top-level command namespace for registry management +- The tag vocabulary will need periodic curation as the project evolves — orphaned or overly broad tags should be pruned +- Tags complement but do not replace full-text search; [[ADR-0039]] remains a viable future option if content-level discovery is needed +- An RFC-0002 amendment is a prerequisite before implementation — this ADR authorizes the design direction but not the schema change""" + +[[content.alternatives]] +text = "Controlled vocabulary: tags registered in gov/config.toml before use, enforced by govctl check. Lowercase kebab-case, flat list." +status = "accepted" +pros = [ + "Prevents tag sprawl — typos and near-duplicates are caught at check time", + "Registry is diffable and reviewable in PRs", + "Tag list is enumerable — agents and CLI completion can offer suggestions", + "Removing a tag from the registry is an explicit, auditable decision", +] +cons = ["Friction to add a new tag — requires a config edit before first use"] + +[[content.alternatives]] +text = "Free-form tags: any string can be used as a tag on any artifact. No registry. Tags are created implicitly on first use." +status = "rejected" +pros = ["Zero friction — tag immediately without config changes"] +cons = [ + "Tag sprawl is inevitable — cache vs caching vs Cache are all different tags", + "No way to enforce consistency across contributors", + "Removing a stale tag requires finding and editing every artifact that uses it", +] +rejection_reason = "In a governed workflow, uncontrolled metadata defeats the purpose of structured artifacts. Tag sprawl would quickly make filtering unreliable." + +[[content.alternatives]] +text = "No tags — improve search and filtering instead: rely on title grep, rendered markdown search tools (rg, qmd), or future FTS (ADR-0039) to find artifacts by content rather than adding structured metadata." +status = "rejected" +pros = [ + "Zero schema changes — no new fields, no config section, no validation rules", + "No tagging discipline burden on authors", +] +cons = [ + "Finding all artifacts related to a topic requires remembering the right search terms", + "No enumerable taxonomy — agents cannot discover what categories exist", + "Cross-cutting queries remain ad hoc and fragile", +] +rejection_reason = "Search finds text matches, not intentional categorization. Tags express author intent about which domain an artifact belongs to — a dimension that free-text search cannot reliably recover." From 2b7a30272d625f33273fc4739ab31bdeb7c30d05 Mon Sep 17 00:00:00 2001 From: Gabriel Wu <13583761+lucifer1004@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:29:24 +0800 Subject: [PATCH 2/3] feat(tags): implement controlled-vocabulary tags for governance artifacts Per ADR-0040 and RFC-0002 v0.7.0: - Add optional `tags` array to RFC, ADR, work item, and guard schemas - Add `govctl tag new/delete/list` for registry management in config - Add `--tag` filter on `rfc/adr/work/guard list` with AND semantics - Add `govctl check` validation: rejects invalid format and unregistered tags - Add immediate rejection at `add` time for unregistered tags (E1105) - Add edit-ops SSOT entries for `add/remove/get` on tags field - Add 11 integration tests covering registry, tagging, validation, filtering - Amend RFC-0002 v0.7.0: C-RESOURCES, C-CRUD-VERBS, C-GLOBAL-COMMANDS --- CHANGELOG.md | 7 + docs/rfc/RFC-0002.md | 55 +++- ...search-index-for-governance-artifacts.toml | 4 +- ...abulary-tags-for-governance-artifacts.toml | 10 +- gov/config.toml | 18 +- gov/rfc/RFC-0002/clauses/C-CRUD-VERBS.toml | 5 +- .../RFC-0002/clauses/C-GLOBAL-COMMANDS.toml | 24 +- gov/rfc/RFC-0002/clauses/C-RESOURCES.toml | 20 +- gov/rfc/RFC-0002/rfc.toml | 11 +- gov/schema/adr.schema.json | 7 + gov/schema/clause.schema.json | 4 + gov/schema/edit-ops.json | 66 +++++ gov/schema/guard.schema.json | 7 + gov/schema/rfc.schema.json | 7 + gov/schema/work.schema.json | 7 + ...abulary-tags-for-governance-artifacts.toml | 56 ++++ src/cli.rs | 58 ++++ src/cmd/edit/mod.rs | 22 ++ src/cmd/guard.rs | 1 + src/cmd/list.rs | 41 ++- src/cmd/mod.rs | 1 + src/cmd/new.rs | 4 + src/cmd/tag.rs | 272 ++++++++++++++++++ src/command_router.rs | 35 ++- src/config.rs | 21 ++ src/diagnostic.rs | 18 ++ src/model.rs | 20 ++ src/render.rs | 4 + src/resource_plan.rs | 13 +- src/validate.rs | 66 +++++ .../test_tags__artifact_add_tag.snap | 19 ++ ...t_tags__artifact_add_unregistered_tag.snap | 11 + ...st_tags__check_accepts_registered_tag.snap | 23 ++ .../test_tags__check_rejects_unknown_tag.snap | 16 ++ .../test_tags__list_filter_by_tag.snap | 11 + .../test_tags__list_filter_multiple_tags.snap | 18 ++ tests/snapshots/test_tags__tag_delete.snap | 18 ++ .../test_tags__tag_delete_referenced.snap | 19 ++ tests/snapshots/test_tags__tag_new.snap | 15 + .../test_tags__tag_new_duplicate.snap | 11 + .../test_tags__tag_new_invalid_format.snap | 7 + tests/test_tags.rs | 240 ++++++++++++++++ 42 files changed, 1242 insertions(+), 50 deletions(-) create mode 100644 gov/work/2026-04-09-implement-controlled-vocabulary-tags-for-governance-artifacts.toml create mode 100644 src/cmd/tag.rs create mode 100644 tests/snapshots/test_tags__artifact_add_tag.snap create mode 100644 tests/snapshots/test_tags__artifact_add_unregistered_tag.snap create mode 100644 tests/snapshots/test_tags__check_accepts_registered_tag.snap create mode 100644 tests/snapshots/test_tags__check_rejects_unknown_tag.snap create mode 100644 tests/snapshots/test_tags__list_filter_by_tag.snap create mode 100644 tests/snapshots/test_tags__list_filter_multiple_tags.snap create mode 100644 tests/snapshots/test_tags__tag_delete.snap create mode 100644 tests/snapshots/test_tags__tag_delete_referenced.snap create mode 100644 tests/snapshots/test_tags__tag_new.snap create mode 100644 tests/snapshots/test_tags__tag_new_duplicate.snap create mode 100644 tests/snapshots/test_tags__tag_new_invalid_format.snap create mode 100644 tests/test_tags.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 157137e..67eda6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- tags field in all five artifact schemas (rfc, clause, adr, work, guard) (WI-2026-04-09-001) +- govctl tag new/delete/list commands with usage counts (WI-2026-04-09-001) +- govctl check validates tags against config allowed list (WI-2026-04-09-001) +- --tag filter on rfc/clause/adr/work/guard list commands (WI-2026-04-09-001) + ## [0.8.1] - 2026-04-08 ### Added diff --git a/docs/rfc/RFC-0002.md b/docs/rfc/RFC-0002.md index b3e5784..b97c13e 100644 --- a/docs/rfc/RFC-0002.md +++ b/docs/rfc/RFC-0002.md @@ -1,9 +1,9 @@ - + # RFC-0002: CLI Resource Model and Command Architecture -> **Version:** 0.6.1 | **Status:** normative | **Phase:** test +> **Version:** 0.7.0 | **Status:** normative | **Phase:** test --- @@ -72,29 +72,29 @@ The following resource types MUST be supported as top-level command namespaces: Manages RFC specifications (normative documents defining system behavior). - ID Format: `RFC-NNNN` (e.g., RFC-0001) -- Lifecycle: draft → normative → deprecated (per [RFC-0001:C-RFC-STATUS](../rfc/RFC-0001.md#rfc-0001c-rfc-status)) -- Phase: spec → impl → test → stable (per [RFC-0001:C-RFC-PHASE](../rfc/RFC-0001.md#rfc-0001c-rfc-phase)) +- Lifecycle: draft → normative → deprecated (per RFC-0001:C-RFC-STATUS) +- Phase: spec → impl → test → stable (per RFC-0001:C-RFC-PHASE) **2. `adr` - Architecture Decision Record** Manages ADRs (records of architectural decisions). - ID Format: `ADR-NNNN` (e.g., ADR-0001) -- Lifecycle: proposed → accepted → superseded, or proposed → rejected (per [RFC-0001:C-ADR-STATUS](../rfc/RFC-0001.md#rfc-0001c-adr-status)) +- Lifecycle: proposed → accepted → superseded, or proposed → rejected (per RFC-0001:C-ADR-STATUS) **3. `work` - Work Item** Manages work items (tasks, features, bugs). - ID Format: `WI-YYYY-MM-DD-NNN` (e.g., WI-2026-01-19-001) -- Lifecycle: queue → active → done, with cancellation at any stage (per [RFC-0001:C-WORK-STATUS](../rfc/RFC-0001.md#rfc-0001c-work-status)) +- Lifecycle: queue → active → done, with cancellation at any stage (per RFC-0001:C-WORK-STATUS) **4. `clause` - RFC Clause** Manages individual clauses within RFCs. - ID Format: `RFC-NNNN:C-NAME` (e.g., RFC-0001:C-SUMMARY) -- Lifecycle: active → deprecated → superseded (per [RFC-0001:C-CLAUSE-STATUS](../rfc/RFC-0001.md#rfc-0001c-clause-status)) +- Lifecycle: active → deprecated → superseded (per RFC-0001:C-CLAUSE-STATUS) **Clause Namespace vs Storage:** @@ -104,7 +104,7 @@ The CLI namespace is independent of filesystem layout. Implementations MAY store **5. `guard` - Verification Guard** -Manages reusable executable completion checks defined by [RFC-0000:C-GUARD-DEF](../rfc/RFC-0000.md#rfc-0000c-guard-def). +Manages reusable executable completion checks defined by RFC-0000:C-GUARD-DEF. - ID Format: `GUARD-NAME` (e.g., GUARD-CARGO-TEST) - Storage: `gov/guard/` as individual TOML files @@ -115,7 +115,7 @@ Manages reusable executable completion checks defined by [RFC-0000:C-GUARD-DEF]( Manages published versions and their included work item references. - ID Format: Semantic version (e.g., 1.0.0) -- Storage: `gov/releases.toml` as defined by [RFC-0000:C-RELEASE-DEF](../rfc/RFC-0000.md#rfc-0000c-release-def) +- Storage: `gov/releases.toml` as defined by RFC-0000:C-RELEASE-DEF - No lifecycle (immutable once created) **Resource Identification:** @@ -131,9 +131,13 @@ Each resource type MUST have a unique, predictable ID format that: All date-valued resource metadata fields (such as `created`, `updated`, `started`, `completed`, and `date`) MUST use ISO 8601 calendar date format `YYYY-MM-DD`. +**Tags:** + +RFCs, clauses, ADRs, work items, and guards MAY include an optional `tags` array in the `[govctl]` section. Each tag MUST be a string from the project's controlled vocabulary defined in `gov/config.toml` under `[tags] allowed`. Tags MUST match the pattern `[a-z][a-z0-9-]*` (lowercase kebab-case). Releases do not carry tags. `govctl check` MUST reject any artifact that references a tag not present in the allowed set. + **Future Extensions:** -Additional resource types MAY be added via RFC amendment. New resource types MUST follow the same structural patterns defined in [RFC-0002:C-CRUD-VERBS](../rfc/RFC-0002.md#rfc-0002c-crud-verbs). +Additional resource types MAY be added via RFC amendment. New resource types MUST follow the same structural patterns defined in RFC-0002:C-CRUD-VERBS. *Since: v0.1.0* @@ -158,7 +162,7 @@ Guard-specific: `govctl guard new ""` scaffolds a new guard TOML file und **2. `list` - List Resources** -Syntax: `govctl <resource> list [filter]` +Syntax: `govctl <resource> list [filter] [--tag <tag>[,<tag>...]]` Lists all instances of the resource type. Optional filter narrows results. @@ -168,6 +172,7 @@ Behavior: - Supports filtering (exact match or substring) - Sorted by ID (lexicographic order) - MUST respect [RFC-0002:C-OUTPUT-FORMAT](../rfc/RFC-0002.md#rfc-0002c-output-format) flags +- With `--tag`: filters results to artifacts that carry ALL specified tags. MUST support comma-separated tag values. Applies to rfc, clause, adr, work, and guard (releases do not carry tags). **3. `get` - Read Resource** @@ -244,7 +249,7 @@ Permanent deletion breaks referential integrity. These constraints ensure deleti - MUST NOT use different verb names for the same operation (e.g., "show" vs "get") - MUST NOT reorder arguments between resource types (ID always comes before field) -- MUST NOT have resource-specific flags for universal operations +- MUST NOT have resource-specific flags for universal operations. Flags that filter by a cross-resource metadata field (e.g., `--tag`) are permitted on resources that carry that field and silently ignored or unavailable on resources that do not. *Since: v0.1.0* @@ -535,6 +540,24 @@ Behavior: - Reports created/updated/skipped counts - This command is separate from `init` because plugin users receive skills globally and do not need local copies +**10. `govctl tag`** + +Manages the project's controlled tag vocabulary. + +Syntax: +- `govctl tag new <tag>` +- `govctl tag delete <tag>` +- `govctl tag list` + +Behavior: +- `new`: registers a new tag in `gov/config.toml` under `[tags] allowed`. Tags MUST match `[a-z][a-z0-9-]*` (lowercase kebab-case). MUST error if the tag already exists. +- `delete`: removes a tag from the allowed list. MUST error if any artifact still references the tag. +- `list`: displays all registered tags with usage counts (how many artifacts reference each tag). + +Artifact-level tagging uses existing resource verbs on taggable types (rfc, clause, adr, work, guard): +- `govctl {rfc|clause|adr|work|guard} add <ID> tags <tag>` — assign a tag to an artifact +- `govctl {rfc|clause|adr|work|guard} remove <ID> tags <tag>` — remove a tag from an artifact + **Rationale:** These commands are global because they: @@ -549,6 +572,8 @@ These commands are global because they: `govctl init-skills` qualifies because it performs project-level initialization of agent assets (criterion 2). +`govctl tag` qualifies because it manages project-level configuration that applies across all resource types (criterion 1). + **Future Additions:** New global commands MAY be added via RFC amendment. They MUST meet at least one criterion: @@ -558,8 +583,6 @@ New global commands MAY be added via RFC amendment. They MUST meet at least one *Since: v0.1.0* -*Since: v0.1.0* - ### [RFC-0002:C-VERIFY-CONFIG] Verification Configuration (Normative) <a id="rfc-0002c-verify-config"></a> The project config file `gov/config.toml` MAY include an optional `[verification]` section. @@ -584,6 +607,10 @@ Work Item `verification.required_guards` remain effective regardless of the proj ## Changelog +### v0.7.0 (2026-04-09) + +Add controlled-vocabulary tags: tags field on RFC/clause/ADR/work/guard, --tag filter on list, govctl tag new/delete/list for registry management (ADR-0040) + ### v0.6.1 (2026-04-08) Add --dir flag to init-skills for one-step directory override without config editing diff --git a/gov/adr/ADR-0039-use-sqlite-fts5-as-read-only-search-index-for-governance-artifacts.toml b/gov/adr/ADR-0039-use-sqlite-fts5-as-read-only-search-index-for-governance-artifacts.toml index 3d64bc0..0e6d682 100644 --- a/gov/adr/ADR-0039-use-sqlite-fts5-as-read-only-search-index-for-governance-artifacts.toml +++ b/gov/adr/ADR-0039-use-sqlite-fts5-as-read-only-search-index-for-governance-artifacts.toml @@ -35,7 +35,7 @@ We will use **SQLite FTS5 with lazy incremental sync** as the search backend for ### Design -1. **Index location:** `gov/.search.db` (gitignored). Disposable — can be deleted and rebuilt transparently. +1. **Index location:** `gov/.search.db` (gitignored, along with WAL sidecars `gov/.search.db-wal` and `gov/.search.db-shm`). Disposable — can be deleted and rebuilt transparently. 2. **Indexed content:** All artifact types (RFCs, clauses, ADRs, work items, guards). Each entry stores the artifact ID, type, title, and a concatenation of all human-readable text fields. Tokenized with Porter stemming for English morphological matching. @@ -57,7 +57,7 @@ We will use **SQLite FTS5 with lazy incremental sync** as the search backend for - Lazy sync avoids coupling between the write path and the index — files changed by manual editing, VCS operations, or govctl all sync identically - A single read-only cache file is simpler than a persistent daemon or event-driven index - The index is not covered by [[RFC-0004]] file locking because it is a derived cache, not a governance artifact -- `gov/.search.db` should be added to `.gitignore` by `govctl init`""" +- `govctl init` should add `gov/.search.db*` to `.gitignore` to cover the database and WAL sidecars""" consequences = """ ### Positive diff --git a/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml b/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml index 3503ca5..8d28159 100644 --- a/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml +++ b/gov/adr/ADR-0040-controlled-vocabulary-tags-for-governance-artifacts.toml @@ -40,10 +40,10 @@ A controlled vocabulary enforces consistency at the cost of a one-time registrat ### Design Outline - **Registry**: a `[tags] allowed` list in `gov/config.toml` — flat, lowercase kebab-case strings -- **Artifact field**: an optional `tags` array in the `[govctl]` section of RFCs, ADRs, work items, and guards. Clauses inherit discoverability from the parent RFC. -- **Management**: registry-level add/remove/list commands; artifact-level tagging via existing `add`/`remove` verbs -- **Filtering**: `--tag` flag on existing `list` commands -- **Validation**: `govctl check` rejects tags not in the allowed set +- **Artifact field**: an optional `tags` array in the `[govctl]` section of RFCs, clauses, ADRs, work items, and guards (releases do not carry tags) +- **Management**: registry-level `new`/`delete`/`list` commands; artifact-level tagging via existing `add`/`remove` verbs +- **Filtering**: `--tag` flag on existing `list` commands for taggable resource types +- **Validation**: `govctl check` rejects tags not in the allowed set; `add` rejects unregistered tags immediately Detailed command syntax, schema changes, and validation rules will be specified in an RFC-0002 amendment. @@ -65,7 +65,7 @@ consequences = """ - Friction to introduce a new tag — requires a config edit before first use (mitigation: this friction is intentional and the operation is a one-liner) - Retroactive tagging of existing artifacts requires effort (mitigation: incremental adoption — untagged artifacts simply don't appear in filtered queries) -- Schema change across all four artifact types (mitigation: `tags` is optional with empty-array default — existing artifacts remain valid without modification) +- Schema change across all five taggable artifact types (mitigation: `tags` is optional with empty-array default — existing artifacts remain valid without modification) ### Neutral diff --git a/gov/config.toml b/gov/config.toml index 30f9231..c1b3593 100644 --- a/gov/config.toml +++ b/gov/config.toml @@ -1,18 +1,24 @@ -[project] -name = "govctl" -default_owner = "@govctl-org" - [paths] docs_output = "docs" +[project] +default_owner = "@govctl-org" +name = "govctl" + [schema] version = 2 [source_scan] enabled = true -include = ["src/**/*.rs"] exclude = [] +include = ["src/**/*.rs"] + +[tags] +allowed = [] [verification] +default_guards = [ + "GUARD-GOVCTL-CHECK", + "GUARD-CARGO-TEST", +] enabled = true -default_guards = ["GUARD-GOVCTL-CHECK", "GUARD-CARGO-TEST"] diff --git a/gov/rfc/RFC-0002/clauses/C-CRUD-VERBS.toml b/gov/rfc/RFC-0002/clauses/C-CRUD-VERBS.toml index 7c8ad07..9c0618e 100644 --- a/gov/rfc/RFC-0002/clauses/C-CRUD-VERBS.toml +++ b/gov/rfc/RFC-0002/clauses/C-CRUD-VERBS.toml @@ -28,7 +28,7 @@ Guard-specific: `govctl guard new "<title>"` scaffolds a new guard TOML file und **2. `list` - List Resources** -Syntax: `govctl <resource> list [filter]` +Syntax: `govctl <resource> list [filter] [--tag <tag>[,<tag>...]]` Lists all instances of the resource type. Optional filter narrows results. @@ -38,6 +38,7 @@ Behavior: - Supports filtering (exact match or substring) - Sorted by ID (lexicographic order) - MUST respect [[RFC-0002:C-OUTPUT-FORMAT]] flags +- With `--tag`: filters results to artifacts that carry ALL specified tags. MUST support comma-separated tag values. Applies to rfc, clause, adr, work, and guard (releases do not carry tags). **3. `get` - Read Resource** @@ -114,4 +115,4 @@ Permanent deletion breaks referential integrity. These constraints ensure deleti - MUST NOT use different verb names for the same operation (e.g., "show" vs "get") - MUST NOT reorder arguments between resource types (ID always comes before field) -- MUST NOT have resource-specific flags for universal operations""" +- MUST NOT have resource-specific flags for universal operations. Flags that filter by a cross-resource metadata field (e.g., `--tag`) are permitted on resources that carry that field and silently ignored or unavailable on resources that do not.""" diff --git a/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml b/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml index c7544db..bf624c6 100644 --- a/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml +++ b/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml @@ -151,6 +151,24 @@ Behavior: - Reports created/updated/skipped counts - This command is separate from `init` because plugin users receive skills globally and do not need local copies +**10. `govctl tag`** + +Manages the project's controlled tag vocabulary. + +Syntax: +- `govctl tag new <tag>` +- `govctl tag delete <tag>` +- `govctl tag list` + +Behavior: +- `new`: registers a new tag in `gov/config.toml` under `[tags] allowed`. Tags MUST match `[a-z][a-z0-9-]*` (lowercase kebab-case). MUST error if the tag already exists. +- `delete`: removes a tag from the allowed list. MUST error if any artifact still references the tag. +- `list`: displays all registered tags with usage counts (how many artifacts reference each tag). + +Artifact-level tagging uses existing resource verbs on taggable types (rfc, clause, adr, work, guard): +- `govctl {rfc|clause|adr|work|guard} add <ID> tags <tag>` — assign a tag to an artifact +- `govctl {rfc|clause|adr|work|guard} remove <ID> tags <tag>` — remove a tag from an artifact + **Rationale:** These commands are global because they: @@ -165,11 +183,11 @@ These commands are global because they: `govctl init-skills` qualifies because it performs project-level initialization of agent assets (criterion 2). +`govctl tag` qualifies because it manages project-level configuration that applies across all resource types (criterion 1). + **Future Additions:** New global commands MAY be added via RFC amendment. They MUST meet at least one criterion: 1. Operate on multiple resource types 2. Perform project-level initialization or cleanup -3. Provide meta-information about the CLI itself - -*Since: v0.1.0*""" +3. Provide meta-information about the CLI itself""" diff --git a/gov/rfc/RFC-0002/clauses/C-RESOURCES.toml b/gov/rfc/RFC-0002/clauses/C-RESOURCES.toml index 8810796..d44de01 100644 --- a/gov/rfc/RFC-0002/clauses/C-RESOURCES.toml +++ b/gov/rfc/RFC-0002/clauses/C-RESOURCES.toml @@ -16,29 +16,29 @@ The following resource types MUST be supported as top-level command namespaces: Manages RFC specifications (normative documents defining system behavior). - ID Format: `RFC-NNNN` (e.g., RFC-0001) -- Lifecycle: draft → normative → deprecated (per [[RFC-0001:C-RFC-STATUS]]) -- Phase: spec → impl → test → stable (per [[RFC-0001:C-RFC-PHASE]]) +- Lifecycle: draft → normative → deprecated (per RFC-0001:C-RFC-STATUS) +- Phase: spec → impl → test → stable (per RFC-0001:C-RFC-PHASE) **2. `adr` - Architecture Decision Record** Manages ADRs (records of architectural decisions). - ID Format: `ADR-NNNN` (e.g., ADR-0001) -- Lifecycle: proposed → accepted → superseded, or proposed → rejected (per [[RFC-0001:C-ADR-STATUS]]) +- Lifecycle: proposed → accepted → superseded, or proposed → rejected (per RFC-0001:C-ADR-STATUS) **3. `work` - Work Item** Manages work items (tasks, features, bugs). - ID Format: `WI-YYYY-MM-DD-NNN` (e.g., WI-2026-01-19-001) -- Lifecycle: queue → active → done, with cancellation at any stage (per [[RFC-0001:C-WORK-STATUS]]) +- Lifecycle: queue → active → done, with cancellation at any stage (per RFC-0001:C-WORK-STATUS) **4. `clause` - RFC Clause** Manages individual clauses within RFCs. - ID Format: `RFC-NNNN:C-NAME` (e.g., RFC-0001:C-SUMMARY) -- Lifecycle: active → deprecated → superseded (per [[RFC-0001:C-CLAUSE-STATUS]]) +- Lifecycle: active → deprecated → superseded (per RFC-0001:C-CLAUSE-STATUS) **Clause Namespace vs Storage:** @@ -48,7 +48,7 @@ The CLI namespace is independent of filesystem layout. Implementations MAY store **5. `guard` - Verification Guard** -Manages reusable executable completion checks defined by [[RFC-0000:C-GUARD-DEF]]. +Manages reusable executable completion checks defined by RFC-0000:C-GUARD-DEF. - ID Format: `GUARD-NAME` (e.g., GUARD-CARGO-TEST) - Storage: `gov/guard/` as individual TOML files @@ -59,7 +59,7 @@ Manages reusable executable completion checks defined by [[RFC-0000:C-GUARD-DEF] Manages published versions and their included work item references. - ID Format: Semantic version (e.g., 1.0.0) -- Storage: `gov/releases.toml` as defined by [[RFC-0000:C-RELEASE-DEF]] +- Storage: `gov/releases.toml` as defined by RFC-0000:C-RELEASE-DEF - No lifecycle (immutable once created) **Resource Identification:** @@ -75,6 +75,10 @@ Each resource type MUST have a unique, predictable ID format that: All date-valued resource metadata fields (such as `created`, `updated`, `started`, `completed`, and `date`) MUST use ISO 8601 calendar date format `YYYY-MM-DD`. +**Tags:** + +RFCs, clauses, ADRs, work items, and guards MAY include an optional `tags` array in the `[govctl]` section. Each tag MUST be a string from the project's controlled vocabulary defined in `gov/config.toml` under `[tags] allowed`. Tags MUST match the pattern `[a-z][a-z0-9-]*` (lowercase kebab-case). Releases do not carry tags. `govctl check` MUST reject any artifact that references a tag not present in the allowed set. + **Future Extensions:** -Additional resource types MAY be added via RFC amendment. New resource types MUST follow the same structural patterns defined in [[RFC-0002:C-CRUD-VERBS]].""" +Additional resource types MAY be added via RFC amendment. New resource types MUST follow the same structural patterns defined in RFC-0002:C-CRUD-VERBS.""" diff --git a/gov/rfc/RFC-0002/rfc.toml b/gov/rfc/RFC-0002/rfc.toml index 4365fe2..0027e0f 100644 --- a/gov/rfc/RFC-0002/rfc.toml +++ b/gov/rfc/RFC-0002/rfc.toml @@ -3,13 +3,13 @@ [govctl] id = "RFC-0002" title = "CLI Resource Model and Command Architecture" -version = "0.6.1" +version = "0.7.0" status = "normative" phase = "test" owners = ["@govctl-org"] created = "2026-01-19" -updated = "2026-04-08" -signature = "cc151bfa5029e201e922222957ebad390015a041fed9f8bbfa287bafc4c62af2" +updated = "2026-04-09" +signature = "34e3af95677441443e8931aed179b3bc3f67407276bab9c3857c3295d44e1172" [[sections]] title = "Summary" @@ -27,6 +27,11 @@ clauses = [ "clauses/C-VERIFY-CONFIG.toml", ] +[[changelog]] +version = "0.7.0" +date = "2026-04-09" +notes = "Add controlled-vocabulary tags: tags field on RFC/clause/ADR/work/guard, --tag filter on list, govctl tag new/delete/list for registry management (ADR-0040)" + [[changelog]] version = "0.6.1" date = "2026-04-08" diff --git a/gov/schema/adr.schema.json b/gov/schema/adr.schema.json index e20a3f3..cdd376d 100644 --- a/gov/schema/adr.schema.json +++ b/gov/schema/adr.schema.json @@ -36,6 +36,13 @@ "pattern": "^(RFC-\\d{4}(?::C-[A-Z][A-Z0-9-]*)?|ADR-\\d{4}|WI-\\d{4}-\\d{2}-\\d{2}-(?:[a-f0-9]{4}(?:-\\d{3})?|\\d{3}))$" } }, + "tags": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, "schema": { "type": "integer" } diff --git a/gov/schema/clause.schema.json b/gov/schema/clause.schema.json index eb0d3ed..4cece76 100644 --- a/gov/schema/clause.schema.json +++ b/gov/schema/clause.schema.json @@ -38,6 +38,10 @@ "type": "array", "items": { "type": "string" } }, + "tags": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" } + }, "schema": { "type": "integer" } diff --git a/gov/schema/edit-ops.json b/gov/schema/edit-ops.json index fe77573..73a27af 100644 --- a/gov/schema/edit-ops.json +++ b/gov/schema/edit-ops.json @@ -30,6 +30,7 @@ "title", "date", "refs", + "tags", "superseded_by", "started", "completed", @@ -83,6 +84,12 @@ "kind": "list", "verbs": ["add", "remove"] }, + { + "artifact": "clause", + "name": "tags", + "kind": "list", + "verbs": ["add", "remove", "get"] + }, { "artifact": "rfc", @@ -120,6 +127,12 @@ "kind": "list", "verbs": ["get", "add", "remove"] }, + { + "artifact": "rfc", + "name": "tags", + "kind": "list", + "verbs": ["add", "remove", "get"] + }, { "artifact": "rfc", "name": "supersedes", @@ -169,6 +182,12 @@ "kind": "list", "verbs": ["get", "add", "remove"] }, + { + "artifact": "adr", + "name": "tags", + "kind": "list", + "verbs": ["add", "remove", "get"] + }, { "artifact": "adr", "name": "context", @@ -224,6 +243,12 @@ "kind": "list", "verbs": ["get", "add", "remove"] }, + { + "artifact": "work", + "name": "tags", + "kind": "list", + "verbs": ["add", "remove", "get"] + }, { "artifact": "work", "name": "description", @@ -256,6 +281,12 @@ "kind": "list", "verbs": ["get", "add", "remove"] }, + { + "artifact": "guard", + "name": "tags", + "kind": "list", + "verbs": ["add", "remove", "get"] + }, { "artifact": "guard", "name": "command", @@ -318,6 +349,13 @@ "set": null, "list_path": ["refs"] }, + { + "artifact": "rfc", + "name": "tags", + "get": { "path": ["tags"], "render": "csv_strings" }, + "set": null, + "list_path": ["tags"] + }, { "artifact": "rfc", "name": "supersedes", @@ -397,6 +435,13 @@ "set": null, "list_path": ["anchors"] }, + { + "artifact": "clause", + "name": "tags", + "get": { "path": ["tags"], "render": "csv_strings" }, + "set": null, + "list_path": ["tags"] + }, { "artifact": "adr", @@ -433,6 +478,13 @@ "set": null, "list_path": ["govctl", "refs"] }, + { + "artifact": "adr", + "name": "tags", + "get": { "path": ["govctl", "tags"], "render": "csv_strings" }, + "set": null, + "list_path": ["govctl", "tags"] + }, { "artifact": "adr", "name": "context", @@ -505,6 +557,13 @@ "set": null, "list_path": ["govctl", "refs"] }, + { + "artifact": "work", + "name": "tags", + "get": { "path": ["govctl", "tags"], "render": "csv_strings" }, + "set": null, + "list_path": ["govctl", "tags"] + }, { "artifact": "work", "name": "description", @@ -549,6 +608,13 @@ "set": null, "list_path": ["govctl", "refs"] }, + { + "artifact": "guard", + "name": "tags", + "get": { "path": ["govctl", "tags"], "render": "csv_strings" }, + "set": null, + "list_path": ["govctl", "tags"] + }, { "artifact": "guard", "name": "command", diff --git a/gov/schema/guard.schema.json b/gov/schema/guard.schema.json index 674c997..a98c714 100644 --- a/gov/schema/guard.schema.json +++ b/gov/schema/guard.schema.json @@ -24,6 +24,13 @@ "pattern": "^(RFC-\\d{4}(?::C-[A-Z][A-Z0-9-]*)?|ADR-\\d{4}|WI-\\d{4}-\\d{2}-\\d{2}-(?:[a-f0-9]{4}(?:-\\d{3})?|\\d{3}))$" } }, + "tags": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, "schema": { "type": "integer" } diff --git a/gov/schema/rfc.schema.json b/gov/schema/rfc.schema.json index cb5acf3..741821e 100644 --- a/gov/schema/rfc.schema.json +++ b/gov/schema/rfc.schema.json @@ -61,6 +61,13 @@ "pattern": "^(RFC-\\d{4}(?::C-[A-Z][A-Z0-9-]*)?|ADR-\\d{4}|WI-\\d{4}-\\d{2}-\\d{2}-(?:[a-f0-9]{4}(?:-\\d{3})?|\\d{3}))$" } }, + "tags": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, "signature": { "type": "string", "pattern": "^[0-9a-f]{64}$" diff --git a/gov/schema/work.schema.json b/gov/schema/work.schema.json index 66c2589..7f2ffb0 100644 --- a/gov/schema/work.schema.json +++ b/gov/schema/work.schema.json @@ -40,6 +40,13 @@ "pattern": "^(RFC-\\d{4}(?::C-[A-Z][A-Z0-9-]*)?|ADR-\\d{4}|WI-\\d{4}-\\d{2}-\\d{2}-(?:[a-f0-9]{4}(?:-\\d{3})?|\\d{3}))$" } }, + "tags": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + } + }, "schema": { "type": "integer" } diff --git a/gov/work/2026-04-09-implement-controlled-vocabulary-tags-for-governance-artifacts.toml b/gov/work/2026-04-09-implement-controlled-vocabulary-tags-for-governance-artifacts.toml new file mode 100644 index 0000000..8586446 --- /dev/null +++ b/gov/work/2026-04-09-implement-controlled-vocabulary-tags-for-governance-artifacts.toml @@ -0,0 +1,56 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-04-09-001" +title = "Implement controlled-vocabulary tags for governance artifacts" +status = "done" +created = "2026-04-09" +started = "2026-04-09" +completed = "2026-04-09" +refs = [ + "RFC-0002", + "ADR-0040", +] + +[content] +description = "Add controlled-vocabulary tag support per ADR-0040. Amend RFC-0002 with tags field on all taggable artifact types (rfc, clause, adr, work, guard), govctl tag registry commands, --tag filter on list commands, and govctl check validation. Implement in Rust." + +[[content.journal]] +date = "2026-04-09" +scope = "rfc" +content = "Amended RFC-0002 v0.7.0: C-RESOURCES (tags field), C-CRUD-VERBS (--tag filter on list), C-GLOBAL-COMMANDS (govctl tag new/delete/list). All checks pass." + +[[content.journal]] +date = "2026-04-09" +scope = "impl" +content = "Implementation complete: schemas, config, model, CLI (tag new/delete/list), validation (E1101-E1105), --tag list filter, edit-ops SSOT. All tests pass. Smoke test verified." + +[[content.acceptance_criteria]] +text = "RFC-0002 amended with tags spec (C-RESOURCES, C-CRUD-VERBS, C-GLOBAL-COMMANDS)" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "tags field in all five artifact schemas (rfc, clause, adr, work, guard)" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "govctl tag new/delete/list commands with usage counts" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "govctl check validates tags against config allowed list" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "--tag filter on rfc/clause/adr/work/guard list commands" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "govctl check passes" +status = "done" +category = "chore" diff --git a/src/cli.rs b/src/cli.rs index a94cac3..ca56f68 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -352,6 +352,61 @@ NOTES: /// Launch interactive TUI dashboard #[cfg(feature = "tui")] Tui, + + /// Manage controlled-vocabulary tags + #[command(after_help = "\ +EXAMPLES: + govctl tag list + govctl tag new caching + govctl tag delete caching + +NOTES: + - Tags are defined project-wide in gov/config.toml [tags] allowed. + - Artifacts may only reference tags declared here. + - Implements [[RFC-0002:C-RESOURCES]] controlled-vocabulary tags. +")] + Tag { + #[command(subcommand)] + command: TagCommand, + }, +} + +/// Tag management subcommands +#[derive(Subcommand, Clone, Debug)] +pub(crate) enum TagCommand { + /// Add a new allowed tag to config.toml + #[command(after_help = "\ +EXAMPLES: + govctl tag new caching + govctl tag new breaking-change +")] + New { + /// Tag name (must match ^[a-z][a-z0-9-]*$) + tag: String, + }, + /// Remove an allowed tag from config.toml (fails if any artifact uses it) + #[command(after_help = "\ +EXAMPLES: + govctl tag delete caching +")] + Delete { + /// Tag name to remove + tag: String, + }, + /// List all allowed tags and their usage counts + #[command( + visible_alias = "ls", + after_help = "\ +EXAMPLES: + govctl tag list + govctl tag list -o json +" + )] + List { + /// Output format + #[arg(short = 'o', long, value_enum, default_value = "table")] + output: crate::OutputFormat, + }, } #[derive(ValueEnum, Clone, Copy, Debug)] @@ -503,6 +558,9 @@ pub(crate) struct CommonListArgs { /// Output format #[arg(short = 'o', long, value_enum, default_value = "table")] pub(crate) output: OutputFormat, + /// Filter by tag (comma-separated, artifact must have ALL specified tags) + #[arg(long)] + pub(crate) tag: Option<String>, } #[derive(Args, Clone, Debug)] diff --git a/src/cmd/edit/mod.rs b/src/cmd/edit/mod.rs index 09382f1..e73b34f 100644 --- a/src/cmd/edit/mod.rs +++ b/src/cmd/edit/mod.rs @@ -1107,6 +1107,28 @@ pub fn add_to_field( .expect("mutation planning should produce target"); let value = resolve_owned_value(value, stdin)?; let value = value.as_str(); + + // Validate tags against controlled vocabulary at add time — [[RFC-0002:C-RESOURCES]] + if fp.as_simple() == Some("tags") { + if !crate::cmd::tag::TAG_RE.is_match(value) { + return Err(Diagnostic::new( + DiagnosticCode::E1101TagInvalidFormat, + format!("Invalid tag format '{value}': must match ^[a-z][a-z0-9-]*$"), + id, + ) + .into()); + } + let allowed = &config.tags.allowed; + if !allowed.iter().any(|t| t == value) { + return Err(Diagnostic::new( + DiagnosticCode::E1105TagUnknown, + format!("Tag '{value}' is not in config.toml [tags] allowed. Register it first with: govctl tag new {value}"), + id, + ) + .into()); + } + } + match artifact { ArtifactType::Adr => { let mut entry = AdrTomlAdapter::load(config, id)?; diff --git a/src/cmd/guard.rs b/src/cmd/guard.rs index 295d6e8..e950ef8 100644 --- a/src/cmd/guard.rs +++ b/src/cmd/guard.rs @@ -52,6 +52,7 @@ pub fn new_guard(config: &Config, title: &str, op: WriteOp) -> anyhow::Result<Ve id: id.clone(), title: title.to_string(), refs: vec![], + tags: vec![], }, check: GuardCheck { command: "echo 'GUARD NOT CONFIGURED: replace this command' && exit 1".to_string(), diff --git a/src/cmd/list.rs b/src/cmd/list.rs index 5d82c4a..31fb9a6 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -63,10 +63,11 @@ pub fn list( filter: Option<&str>, limit: Option<usize>, output: OutputFormat, + tags: &[String], ) -> anyhow::Result<Vec<Diagnostic>> { if target == ListTarget::Guard { let result = load_guards_with_warnings(config).map_err(anyhow::Error::from)?; - list_guards(&result.items, filter, limit, output); + list_guards(&result.items, filter, limit, output, tags); return Ok(result.warnings); } @@ -76,10 +77,10 @@ pub fn list( }; match target { - ListTarget::Rfc => list_rfcs(&index, filter, limit, output), - ListTarget::Clause => list_clauses(&index, filter, limit, output), - ListTarget::Adr => list_adrs(&index, filter, limit, output), - ListTarget::Work => list_work_items(&index, filter, limit, output), + ListTarget::Rfc => list_rfcs(&index, filter, limit, output, tags), + ListTarget::Clause => list_clauses(&index, filter, limit, output, tags), + ListTarget::Adr => list_adrs(&index, filter, limit, output, tags), + ListTarget::Work => list_work_items(&index, filter, limit, output, tags), ListTarget::Guard => unreachable!("handled above"), } @@ -158,6 +159,7 @@ fn list_rfcs( filter: Option<&str>, limit: Option<usize>, output: OutputFormat, + tags: &[String], ) { let mut rfcs: Vec<_> = index.rfcs.iter().collect(); @@ -168,6 +170,11 @@ fn list_rfcs( }); } + // Filter by tags (artifact must have ALL specified tags) + if !tags.is_empty() { + rfcs.retain(|r| tags.iter().all(|t| r.rfc.tags.contains(t))); + } + rfcs.sort_by(|a, b| a.rfc.rfc_id.cmp(&b.rfc.rfc_id)); // Apply limit if specified @@ -226,6 +233,7 @@ fn list_clauses( filter: Option<&str>, limit: Option<usize>, output: OutputFormat, + tags: &[String], ) { let mut clauses: Vec<_> = index .iter_clauses() @@ -239,6 +247,11 @@ fn list_clauses( }); } + // Filter by tags (clause must have ALL specified tags) + if !tags.is_empty() { + clauses.retain(|(_, c)| tags.iter().all(|t| c.spec.tags.contains(t))); + } + clauses.sort_by(|a, b| { a.0.cmp(&b.0) .then_with(|| a.1.spec.clause_id.cmp(&b.1.spec.clause_id)) @@ -291,6 +304,7 @@ fn list_adrs( filter: Option<&str>, limit: Option<usize>, output: OutputFormat, + tags: &[String], ) { let mut adrs: Vec<_> = index.adrs.iter().collect(); @@ -299,6 +313,11 @@ fn list_adrs( adrs.retain(|a| a.meta().status.as_ref() == f || a.meta().id.contains(f)); } + // Filter by tags (artifact must have ALL specified tags) + if !tags.is_empty() { + adrs.retain(|a| tags.iter().all(|t| a.meta().tags.contains(t))); + } + adrs.sort_by(|a, b| a.meta().id.cmp(&b.meta().id)); // Apply limit if specified @@ -353,6 +372,7 @@ fn list_guards( filter: Option<&str>, limit: Option<usize>, output: OutputFormat, + tags: &[String], ) { let mut items: Vec<_> = guards.iter().collect(); @@ -360,6 +380,11 @@ fn list_guards( items.retain(|g| g.meta().id.contains(f) || g.meta().title.contains(f)); } + // Filter by tags (artifact must have ALL specified tags) + if !tags.is_empty() { + items.retain(|g| tags.iter().all(|t| g.meta().tags.contains(t))); + } + items.sort_by(|a, b| a.meta().id.cmp(&b.meta().id)); if let Some(n) = limit { @@ -386,6 +411,7 @@ fn list_work_items( filter: Option<&str>, limit: Option<usize>, output: OutputFormat, + tags: &[String], ) { let mut items: Vec<_> = index.work_items.iter().collect(); @@ -409,6 +435,11 @@ fn list_work_items( } } + // Filter by tags (artifact must have ALL specified tags) + if !tags.is_empty() { + items.retain(|i| tags.iter().all(|t| i.meta().tags.contains(t))); + } + items.sort_by(|a, b| a.meta().id.cmp(&b.meta().id)); // Apply limit if specified diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index ee856e2..95ff078 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -12,4 +12,5 @@ pub mod move_; pub mod new; pub mod render; pub mod status; +pub mod tag; pub mod verify; diff --git a/src/cmd/new.rs b/src/cmd/new.rs index 687a43b..ac8e796 100644 --- a/src/cmd/new.rs +++ b/src/cmd/new.rs @@ -361,6 +361,7 @@ fn create_rfc( updated: None, supersedes: None, refs: vec![], + tags: vec![], sections: vec![ SectionSpec { title: "Summary".to_string(), @@ -448,6 +449,7 @@ fn create_clause( anchors: vec![], superseded_by: None, since: None, // Will be set by rfc bump + tags: vec![], }; let clause_path = config @@ -533,6 +535,7 @@ fn create_adr(config: &Config, title: &str, op: WriteOp) -> anyhow::Result<Vec<D date: today(), superseded_by: None, refs: vec![], + tags: vec![], }, content: AdrContent { context: "Describe the context and problem statement.\nWhat is the issue that we're seeing that is motivating this decision?".to_string(), @@ -621,6 +624,7 @@ fn create_work_item( started, completed: None, refs: vec![], + tags: vec![], }, content: WorkItemContent { description: diff --git a/src/cmd/tag.rs b/src/cmd/tag.rs new file mode 100644 index 0000000..dd119c1 --- /dev/null +++ b/src/cmd/tag.rs @@ -0,0 +1,272 @@ +//! Tag management commands: new, delete, list. +//! +//! Implements controlled-vocabulary tag operations per [[RFC-0002:C-RESOURCES]]. +//! Tags are stored in gov/config.toml under [tags] allowed. + +use crate::OutputFormat; +use crate::config::Config; +use crate::diagnostic::{Diagnostic, DiagnosticCode}; +use crate::load::load_rfcs; +use crate::parse::{load_adrs, load_guards_with_warnings, load_work_items}; +use anyhow::{Context, Result}; +use comfy_table::{Attribute, Cell, ContentArrangement, Table, presets::UTF8_FULL}; +use regex::Regex; +use serde::Serialize; +use std::sync::LazyLock; + +/// Tag format regex: `^[a-z][a-z0-9-]*$` — [[RFC-0002:C-RESOURCES]] +pub static TAG_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9-]*$").expect("valid regex")); + +fn validate_tag_format(tag: &str) -> Result<()> { + if !TAG_RE.is_match(tag) { + return Err(Diagnostic::new( + DiagnosticCode::E1101TagInvalidFormat, + format!( + "Invalid tag format '{tag}': tags must match ^[a-z][a-z0-9-]*$ (lowercase letters, digits, hyphens; start with a letter)" + ), + tag, + ) + .into()); + } + Ok(()) +} + +/// Read config.toml as a raw TOML table for in-place modification. +fn read_config_table(config: &Config) -> Result<toml::Table> { + let config_path = config.gov_root.join("config.toml"); + let content = std::fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read config: {}", config_path.display()))?; + toml::from_str::<toml::Table>(&content) + .with_context(|| format!("Failed to parse config: {}", config_path.display())) +} + +/// Write a modified TOML table back to config.toml. +fn write_config_table(config: &Config, table: &toml::Table) -> Result<()> { + let config_path = config.gov_root.join("config.toml"); + let content = toml::to_string_pretty(table).with_context(|| "Failed to serialize config")?; + std::fs::write(&config_path, content) + .with_context(|| format!("Failed to write config: {}", config_path.display()))?; + Ok(()) +} + +/// Get the current allowed tags array from a TOML table. +fn get_allowed_tags(table: &toml::Table) -> Result<Vec<String>> { + let Some(tags_val) = table.get("tags") else { + return Ok(vec![]); + }; + let tags_table = tags_val.as_table().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + "'tags' in config.toml must be a table", + "gov/config.toml", + ) + })?; + let Some(allowed_val) = tags_table.get("allowed") else { + return Ok(vec![]); + }; + let arr = allowed_val.as_array().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + "'tags.allowed' in config.toml must be an array", + "gov/config.toml", + ) + })?; + let mut tags = Vec::new(); + for item in arr { + let s = item.as_str().ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + "'tags.allowed' items must be strings", + "gov/config.toml", + ) + })?; + tags.push(s.to_string()); + } + Ok(tags) +} + +/// Set the allowed tags array in a TOML table. +fn set_allowed_tags(table: &mut toml::Table, tags: Vec<String>) -> Result<()> { + let arr: toml::value::Array = tags.into_iter().map(toml::Value::String).collect(); + + let tags_table = table + .entry("tags") + .or_insert_with(|| toml::Value::Table(toml::Table::new())) + .as_table_mut() + .ok_or_else(|| { + Diagnostic::new( + DiagnosticCode::E0501ConfigInvalid, + "'tags' in config.toml must be a table", + "gov/config.toml", + ) + })?; + + tags_table.insert("allowed".to_string(), toml::Value::Array(arr)); + Ok(()) +} + +/// Count how many artifacts use a given tag across all artifact types. +fn count_tag_usage(config: &Config, tag: &str) -> Result<usize> { + let mut count = 0; + + let rfcs = load_rfcs(config).map_err(|e| anyhow::anyhow!("{e:?}"))?; + for rfc_index in &rfcs { + if rfc_index.rfc.tags.iter().any(|t| t == tag) { + count += 1; + } + for clause in &rfc_index.clauses { + if clause.spec.tags.iter().any(|t| t == tag) { + count += 1; + } + } + } + + let adrs = load_adrs(config).map_err(|e| anyhow::anyhow!("{e:?}"))?; + for adr in &adrs { + if adr.spec.govctl.tags.iter().any(|t| t == tag) { + count += 1; + } + } + + let items = load_work_items(config).map_err(|e| anyhow::anyhow!("{e:?}"))?; + for item in &items { + if item.spec.govctl.tags.iter().any(|t| t == tag) { + count += 1; + } + } + + let guard_result = load_guards_with_warnings(config).map_err(|e| anyhow::anyhow!("{e:?}"))?; + for guard in &guard_result.items { + if guard.spec.govctl.tags.iter().any(|t| t == tag) { + count += 1; + } + } + + Ok(count) +} + +/// Add a new allowed tag to config.toml [tags] allowed. +pub fn tag_new(config: &Config, tag: &str, op: crate::write::WriteOp) -> Result<Vec<Diagnostic>> { + validate_tag_format(tag)?; + + let mut table = read_config_table(config)?; + let mut allowed = get_allowed_tags(&table)?; + + if allowed.contains(&tag.to_string()) { + return Err(Diagnostic::new( + DiagnosticCode::E1102TagAlreadyExists, + format!("Tag '{tag}' already exists in [tags] allowed"), + tag, + ) + .into()); + } + + allowed.push(tag.to_string()); + set_allowed_tags(&mut table, allowed)?; + + if !op.is_preview() { + write_config_table(config, &table)?; + println!("Added tag: {tag}"); + } else { + println!("Would add tag: {tag}"); + } + Ok(vec![]) +} + +/// Remove an allowed tag from config.toml [tags] allowed. +/// Fails if any artifact still references the tag. +pub fn tag_delete( + config: &Config, + tag: &str, + op: crate::write::WriteOp, +) -> Result<Vec<Diagnostic>> { + let mut table = read_config_table(config)?; + let allowed = get_allowed_tags(&table)?; + + if !allowed.contains(&tag.to_string()) { + return Err(Diagnostic::new( + DiagnosticCode::E1103TagNotFound, + format!("Tag '{tag}' not found in [tags] allowed"), + tag, + ) + .into()); + } + + // Check for usage across all artifact types — [[RFC-0002:C-RESOURCES]] + let usage = count_tag_usage(config, tag)?; + if usage > 0 { + return Err(Diagnostic::new( + DiagnosticCode::E1104TagStillReferenced, + format!( + "Cannot delete tag '{tag}': still used by {usage} artifact(s). Remove the tag from all artifacts first." + ), + tag, + ) + .into()); + } + + let new_allowed: Vec<String> = allowed.into_iter().filter(|t| t != tag).collect(); + set_allowed_tags(&mut table, new_allowed)?; + + if !op.is_preview() { + write_config_table(config, &table)?; + println!("Deleted tag: {tag}"); + } else { + println!("Would delete tag: {tag}"); + } + Ok(vec![]) +} + +/// List all allowed tags and their usage counts across all artifacts. +pub fn tag_list(config: &Config, output: OutputFormat) -> Result<Vec<Diagnostic>> { + let table = read_config_table(config)?; + let allowed = get_allowed_tags(&table)?; + + #[derive(Serialize)] + struct TagEntry { + tag: String, + usage: usize, + } + + let entries: Vec<TagEntry> = allowed + .iter() + .map(|tag| { + let usage = count_tag_usage(config, tag).unwrap_or(0); + TagEntry { + tag: tag.clone(), + usage, + } + }) + .collect(); + + match output { + OutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string()) + ); + } + OutputFormat::Plain => { + for e in &entries { + println!("{}\t{}", e.tag, e.usage); + } + } + OutputFormat::Table => { + let mut table_out = Table::new(); + table_out + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + Cell::new("Tag").add_attribute(Attribute::Bold), + Cell::new("Usage").add_attribute(Attribute::Bold), + ]); + for e in &entries { + table_out.add_row(vec![Cell::new(&e.tag), Cell::new(e.usage.to_string())]); + } + println!("{table_out}"); + } + } + + Ok(vec![]) +} diff --git a/src/command_router.rs b/src/command_router.rs index 98dae1b..a781d52 100644 --- a/src/command_router.rs +++ b/src/command_router.rs @@ -84,6 +84,15 @@ pub enum BuiltinOp { version: String, date: Option<String>, }, + TagNew { + tag: String, + }, + TagDelete { + tag: String, + }, + TagList { + output: crate::OutputFormat, + }, } #[derive(Debug, Clone)] @@ -159,6 +168,8 @@ pub enum Op { filter: Option<String>, limit: Option<usize>, output: OutputFormat, + /// Tags to filter by (artifact must have ALL specified tags) — [[RFC-0002:C-CRUD-VERBS]] + tags: Vec<String>, }, Get, Show { @@ -198,6 +209,7 @@ impl CommandPlan { | Op::Builtin(BuiltinOp::Verify { .. }) | Op::Builtin(BuiltinOp::Describe { .. }) | Op::Builtin(BuiltinOp::Completions { .. }) + | Op::Builtin(BuiltinOp::TagList { .. }) | Op::Get | Op::List { .. } | Op::Show { .. } => LockDisposition::None, @@ -500,6 +512,9 @@ fn execute_builtin( BuiltinOp::ReleaseCut { version, date } => { cmd::lifecycle::cut_release(config, version, date.as_deref(), op) } + BuiltinOp::TagNew { tag } => cmd::tag::tag_new(config, tag, op), + BuiltinOp::TagDelete { tag } => cmd::tag::tag_delete(config, tag, op), + BuiltinOp::TagList { output } => cmd::tag::tag_list(config, *output), } } @@ -557,6 +572,7 @@ fn execute_list( filter: Option<&str>, limit: Option<usize>, output: OutputFormat, + tags: &[String], ) -> anyhow::Result<Vec<Diagnostic>> { cmd::list::list( config, @@ -564,6 +580,7 @@ fn execute_list( filter, limit, output, + tags, ) } @@ -718,7 +735,8 @@ fn execute_plan( filter, limit, output, - } => execute_list(plan, config, filter.as_deref(), *limit, *output), + tags, + } => execute_list(plan, config, filter.as_deref(), *limit, *output, tags), Op::Get => execute_get(plan, config), Op::Show { output } => execute_show(plan, config, *output), Op::Edit(edit) => execute_edit(plan, config, edit, op), @@ -737,6 +755,7 @@ pub(crate) fn plan_list( filter: Option<String>, limit: Option<usize>, output: OutputFormat, + tags: Vec<String>, ) -> CommandPlan { collection( target_kind, @@ -744,6 +763,7 @@ pub(crate) fn plan_list( filter, limit, output, + tags, }, ) } @@ -848,6 +868,19 @@ impl CommandPlan { version: version.clone(), date: date.clone(), }))), + Commands::Tag { command } => match command { + crate::TagCommand::New { tag } => { + Ok(global(Op::Builtin(BuiltinOp::TagNew { tag: tag.clone() }))) + } + crate::TagCommand::Delete { tag } => { + Ok(global(Op::Builtin(BuiltinOp::TagDelete { + tag: tag.clone(), + }))) + } + crate::TagCommand::List { output } => { + Ok(global(Op::Builtin(BuiltinOp::TagList { output: *output }))) + } + }, } } } diff --git a/src/config.rs b/src/config.rs index 3920fe8..3ddc79f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,8 @@ pub struct Config { pub verification: VerificationConfig, #[serde(default)] pub concurrency: ConcurrencyConfig, + #[serde(default)] + pub tags: TagsConfig, } impl Default for Config { @@ -39,10 +41,24 @@ impl Default for Config { work_item: WorkItemConfig::default(), verification: VerificationConfig::default(), concurrency: ConcurrencyConfig::default(), + tags: TagsConfig::default(), } } } +/// Controlled-vocabulary tag configuration. +/// +/// Defines the allowed tag set for the project. Artifacts may only use tags +/// listed here. Implements [[RFC-0002:C-RESOURCES]] controlled-vocabulary tags. +/// An empty `allowed` list means no tags are permitted (deny-all). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TagsConfig { + /// Allowed tag values (each must match `^[a-z][a-z0-9-]*$`). + /// Empty = deny all. + #[serde(default)] + pub allowed: Vec<String>, +} + /// Project-level verification guard policy. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct VerificationConfig { @@ -405,6 +421,11 @@ version = {schema_version} # Maximum seconds to wait for exclusive lock before failing (default: 30) # Implements [[RFC-0004]] concurrent write safety # lock_timeout_secs = 30 + +# [tags] +# Controlled-vocabulary tags for artifact classification — [[RFC-0002:C-RESOURCES]] +# Artifacts may only use tags listed here. +# allowed = ["security", "breaking-change", "performance"] "# ) } diff --git a/src/diagnostic.rs b/src/diagnostic.rs index 7247df7..7c45e0e 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -86,6 +86,18 @@ pub enum DiagnosticCode { E1006GuardInvalidTitle, E1007GuardStillReferenced, + // Tag errors (E11xx) + /// Tag format is invalid (must match ^[a-z][a-z0-9-]*$) + E1101TagInvalidFormat, + /// Tag already exists in config.toml [tags] allowed + E1102TagAlreadyExists, + /// Tag not found in config.toml [tags] allowed + E1103TagNotFound, + /// Tag is still referenced by one or more artifacts + E1104TagStillReferenced, + /// Artifact uses a tag not in config.toml [tags] allowed — per [[RFC-0002:C-RESOURCES]] + E1105TagUnknown, + // CLI/Command errors (E08xx) E0801MissingRequiredArg, E0802ConflictingArgs, @@ -212,6 +224,12 @@ impl DiagnosticCode { Self::E1005GuardTimeout => "E1005", Self::E1006GuardInvalidTitle => "E1006", Self::E1007GuardStillReferenced => "E1007", + // E11xx - Tags + Self::E1101TagInvalidFormat => "E1101", + Self::E1102TagAlreadyExists => "E1102", + Self::E1103TagNotFound => "E1103", + Self::E1104TagStillReferenced => "E1104", + Self::E1105TagUnknown => "E1105", // E08xx - CLI/Command Self::E0801MissingRequiredArg => "E0801", Self::E0802ConflictingArgs => "E0802", diff --git a/src/model.rs b/src/model.rs index 1dceab0..6264f2a 100644 --- a/src/model.rs +++ b/src/model.rs @@ -31,6 +31,8 @@ pub struct RfcSpec { pub supersedes: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refs: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<String>, pub sections: Vec<SectionSpec>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub changelog: Vec<ChangelogEntry>, @@ -63,6 +65,8 @@ pub struct ClauseSpec { pub superseded_by: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub since: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<String>, } // ============================================================================= @@ -99,6 +103,8 @@ pub struct RfcMeta { pub supersedes: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refs: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub signature: Option<String>, } @@ -118,6 +124,7 @@ impl From<RfcSpec> for RfcWire { updated: s.updated, supersedes: s.supersedes, refs: s.refs, + tags: s.tags, signature: s.signature, }, sections: s.sections, @@ -139,6 +146,7 @@ impl From<RfcWire> for RfcSpec { updated: w.govctl.updated, supersedes: w.govctl.supersedes, refs: w.govctl.refs, + tags: w.govctl.tags, sections: w.sections, changelog: w.changelog, signature: w.govctl.signature, @@ -170,6 +178,8 @@ pub struct ClauseMeta { pub superseded_by: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub since: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<String>, } /// Clause content section `[content]` @@ -190,6 +200,7 @@ impl From<ClauseSpec> for ClauseWire { anchors: s.anchors, superseded_by: s.superseded_by, since: s.since, + tags: s.tags, }, content: ClauseContent { text: s.text }, } @@ -207,6 +218,7 @@ impl From<ClauseWire> for ClauseSpec { anchors: w.govctl.anchors, superseded_by: w.govctl.superseded_by, since: w.govctl.since, + tags: w.govctl.tags, } } } @@ -298,6 +310,8 @@ pub struct AdrMeta { pub superseded_by: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refs: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<String>, } /// Status for ADR alternatives @@ -395,6 +409,8 @@ pub struct WorkItemMeta { pub completed: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refs: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<String>, } /// Work item-specific verification policy. @@ -543,6 +559,8 @@ pub struct GuardMeta { pub title: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refs: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<String>, } /// Executable check for a verification guard. @@ -850,6 +868,7 @@ mod tests { date: "2026-01-17".to_string(), superseded_by: None, refs: vec![], + tags: vec![], }, content: AdrContent::default(), }, @@ -872,6 +891,7 @@ mod tests { started: None, completed: None, refs: vec![], + tags: vec![], }, content: WorkItemContent::default(), verification: WorkItemVerification::default(), diff --git a/src/render.rs b/src/render.rs index cee047a..2442f58 100644 --- a/src/render.rs +++ b/src/render.rs @@ -720,6 +720,7 @@ mod tests { date: "2026-02-22".to_string(), superseded_by: None, refs: vec![], + tags: vec![], }, content: AdrContent { context: "Test context".to_string(), @@ -755,6 +756,7 @@ mod tests { date: "2026-02-22".to_string(), superseded_by: None, refs: vec![], + tags: vec![], }, content: AdrContent { context: "Test context".to_string(), @@ -792,6 +794,7 @@ mod tests { started: Some("2026-02-22".to_string()), completed: None, refs: vec![], + tags: vec![], }, content: WorkItemContent { description: "Test description".to_string(), @@ -827,6 +830,7 @@ mod tests { started: Some("2026-02-22".to_string()), completed: None, refs: vec![], + tags: vec![], }, content: WorkItemContent { description: "Test description".to_string(), diff --git a/src/resource_plan.rs b/src/resource_plan.rs index b84f02b..4545619 100644 --- a/src/resource_plan.rs +++ b/src/resource_plan.rs @@ -19,7 +19,18 @@ pub(crate) trait ToPlan { } fn compile_common_list(target: ListTarget, args: &CommonListArgs) -> CommandPlan { - plan_list(target, args.filter.clone(), args.limit, args.output) + // Parse comma-separated tags from --tag option + let tags: Vec<String> = args + .tag + .as_deref() + .map(|t| { + t.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + plan_list(target, args.filter.clone(), args.limit, args.output, tags) } fn compile_common_get(args: &CommonGetArgs) -> anyhow::Result<CommandPlan> { diff --git a/src/validate.rs b/src/validate.rs index 48b100f..2c7bb44 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -364,6 +364,9 @@ pub fn validate_project(index: &ProjectIndex, config: &Config) -> ValidationResu // Validate work item descriptions validate_work_item_descriptions(index, config, &mut result); + // Validate tags against allowed set — [[RFC-0002:C-RESOURCES]] + validate_artifact_tags(index, config, &mut result); + result } @@ -896,6 +899,69 @@ fn validate_work_item_descriptions( } } +/// Validate that all artifact tags are in the allowed set and well-formed. +/// +/// Per [[RFC-0002:C-RESOURCES]] controlled-vocabulary tags: every tag used by an +/// artifact must be listed in config.toml [tags] allowed, and each tag must match +/// the format `^[a-z][a-z0-9-]*$`. +fn validate_artifact_tags(index: &ProjectIndex, config: &Config, result: &mut ValidationResult) { + let allowed = &config.tags.allowed; + + let mut check_tags = |tags: &[String], artifact_id: &str, path_display: &str| { + for tag in tags { + // Validate format + if !crate::cmd::tag::TAG_RE.is_match(tag) { + result.diagnostics.push(Diagnostic::new( + DiagnosticCode::E1101TagInvalidFormat, + format!( + "Artifact '{artifact_id}' has invalid tag format '{tag}': must match ^[a-z][a-z0-9-]*$" + ), + path_display, + )); + continue; + } + // Validate against allowed set (deny-all when empty) + if !allowed.contains(tag) { + result.diagnostics.push(Diagnostic::new( + DiagnosticCode::E1105TagUnknown, + format!( + "Artifact '{artifact_id}' uses unknown tag '{tag}' (not in config.toml [tags] allowed)" + ), + path_display, + )); + } + } + }; + + for rfc in &index.rfcs { + let path = config.display_path(&rfc.path).display().to_string(); + check_tags(&rfc.rfc.tags, &rfc.rfc.rfc_id, &path); + } + + for (rfc, clause) in index.iter_clauses() { + let clause_id = format!("{}:{}", rfc.rfc.rfc_id, clause.spec.clause_id); + let path = config.display_path(&clause.path).display().to_string(); + check_tags(&clause.spec.tags, &clause_id, &path); + } + + for adr in &index.adrs { + let path = config.display_path(&adr.path).display().to_string(); + check_tags(&adr.spec.govctl.tags, &adr.meta().id, &path); + } + + for work in &index.work_items { + let path = config.display_path(&work.path).display().to_string(); + check_tags(&work.spec.govctl.tags, &work.meta().id, &path); + } + + if let Ok(guard_result) = crate::parse::load_guards_with_warnings(config) { + for guard in &guard_result.items { + let path = config.display_path(&guard.path).display().to_string(); + check_tags(&guard.spec.govctl.tags, &guard.meta().id, &path); + } + } +} + /// Check if RFC status transition is valid pub fn is_valid_status_transition(from: RfcStatus, to: RfcStatus) -> bool { matches!( diff --git a/tests/snapshots/test_tags__artifact_add_tag.snap b/tests/snapshots/test_tags__artifact_add_tag.snap new file mode 100644 index 0000000..758fbd3 --- /dev/null +++ b/tests/snapshots/test_tags__artifact_add_tag.snap @@ -0,0 +1,19 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl tag new caching +Added tag: caching +exit: 0 + +$ govctl adr new Test Decision +Created ADR: gov/adr/ADR-XXXX-test-decision.toml +exit: 0 + +$ govctl adr add ADR-0001 tags caching +Added 'caching' to ADR-0001.tags +exit: 0 + +$ govctl adr get ADR-0001 tags +caching +exit: 0 diff --git a/tests/snapshots/test_tags__artifact_add_unregistered_tag.snap b/tests/snapshots/test_tags__artifact_add_unregistered_tag.snap new file mode 100644 index 0000000..fe9890f --- /dev/null +++ b/tests/snapshots/test_tags__artifact_add_unregistered_tag.snap @@ -0,0 +1,11 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl adr new Test Decision +Created ADR: gov/adr/ADR-XXXX-test-decision.toml +exit: 0 + +$ govctl adr add ADR-0001 tags nonexistent +error[E1105]: Tag 'nonexistent' is not in config.toml [tags] allowed. Register it first with: govctl tag new nonexistent (ADR-0001) +exit: 1 diff --git a/tests/snapshots/test_tags__check_accepts_registered_tag.snap b/tests/snapshots/test_tags__check_accepts_registered_tag.snap new file mode 100644 index 0000000..b5bb347 --- /dev/null +++ b/tests/snapshots/test_tags__check_accepts_registered_tag.snap @@ -0,0 +1,23 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl adr new Test Decision +Created ADR: gov/adr/ADR-XXXX-test-decision.toml +exit: 0 + +$ govctl adr add ADR-0001 tags caching +Added 'caching' to ADR-0001.tags +exit: 0 + +$ govctl check +Checked: + 0 RFCs + 0 clauses + 1 ADRs + 0 work items + 0 verification guards + +warning[W0103]: ADR has no artifact references (hint: `govctl adr add ADR-0001 refs RFC-XXXX`) (gov/adr/ADR-XXXX-test-decision.toml) +warning[W0103]: ADR has placeholder context (hint: `govctl adr set ADR-0001 context "..."`) (gov/adr/ADR-XXXX-test-decision.toml) +exit: 0 diff --git a/tests/snapshots/test_tags__check_rejects_unknown_tag.snap b/tests/snapshots/test_tags__check_rejects_unknown_tag.snap new file mode 100644 index 0000000..82b0a9b --- /dev/null +++ b/tests/snapshots/test_tags__check_rejects_unknown_tag.snap @@ -0,0 +1,16 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl check +Checked: + 0 RFCs + 0 clauses + 1 ADRs + 0 work items + 0 verification guards + +warning[W0103]: ADR has no artifact references (hint: `govctl adr add ADR-0001 refs RFC-XXXX`) (gov/adr/ADR-XXXX-test-decision.toml) +warning[W0103]: ADR has placeholder context (hint: `govctl adr set ADR-0001 context "..."`) (gov/adr/ADR-XXXX-test-decision.toml) +error[E1105]: Artifact 'ADR-0001' uses unknown tag 'unknown-tag' (not in config.toml [tags] allowed) (gov/adr/ADR-XXXX-test-decision.toml) +exit: 1 diff --git a/tests/snapshots/test_tags__list_filter_by_tag.snap b/tests/snapshots/test_tags__list_filter_by_tag.snap new file mode 100644 index 0000000..e390b20 --- /dev/null +++ b/tests/snapshots/test_tags__list_filter_by_tag.snap @@ -0,0 +1,11 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl adr list --tag caching +┌──────────┬──────────┬────────────┬─────────────────┐ +│ ADR ┆ Status ┆ Date ┆ Title │ +╞══════════╪══════════╪════════════╪═════════════════╡ +│ ADR-0001 ┆ proposed ┆ <DATE> ┆ Tagged Decision │ +└──────────┴──────────┴────────────┴─────────────────┘ +exit: 0 diff --git a/tests/snapshots/test_tags__list_filter_multiple_tags.snap b/tests/snapshots/test_tags__list_filter_multiple_tags.snap new file mode 100644 index 0000000..b3f44ca --- /dev/null +++ b/tests/snapshots/test_tags__list_filter_multiple_tags.snap @@ -0,0 +1,18 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl adr list --tag caching,performance +┌──────────┬──────────┬────────────┬───────────────────────┐ +│ ADR ┆ Status ┆ Date ┆ Title │ +╞══════════╪══════════╪════════════╪═══════════════════════╡ +│ ADR-0001 ┆ proposed ┆ <DATE> ┆ Multi-Tagged Decision │ +└──────────┴──────────┴────────────┴───────────────────────┘ +exit: 0 + +$ govctl adr list --tag caching,security +┌─────┬────────┬──────┬───────┐ +│ ADR ┆ Status ┆ Date ┆ Title │ +╞═════╪════════╪══════╪═══════╡ +└─────┴────────┴──────┴───────┘ +exit: 0 diff --git a/tests/snapshots/test_tags__tag_delete.snap b/tests/snapshots/test_tags__tag_delete.snap new file mode 100644 index 0000000..6dbda46 --- /dev/null +++ b/tests/snapshots/test_tags__tag_delete.snap @@ -0,0 +1,18 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl tag new caching +Added tag: caching +exit: 0 + +$ govctl tag delete caching +Deleted tag: caching +exit: 0 + +$ govctl tag list +┌─────┬───────┐ +│ Tag ┆ Usage │ +╞═════╪═══════╡ +└─────┴───────┘ +exit: 0 diff --git a/tests/snapshots/test_tags__tag_delete_referenced.snap b/tests/snapshots/test_tags__tag_delete_referenced.snap new file mode 100644 index 0000000..0da835e --- /dev/null +++ b/tests/snapshots/test_tags__tag_delete_referenced.snap @@ -0,0 +1,19 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl tag new caching +Added tag: caching +exit: 0 + +$ govctl adr new Test Decision +Created ADR: gov/adr/ADR-XXXX-test-decision.toml +exit: 0 + +$ govctl adr add ADR-0001 tags caching +Added 'caching' to ADR-0001.tags +exit: 0 + +$ govctl tag delete caching +error[E1104]: Cannot delete tag 'caching': still used by 1 artifact(s). Remove the tag from all artifacts first. (caching) +exit: 1 diff --git a/tests/snapshots/test_tags__tag_new.snap b/tests/snapshots/test_tags__tag_new.snap new file mode 100644 index 0000000..5d40fa3 --- /dev/null +++ b/tests/snapshots/test_tags__tag_new.snap @@ -0,0 +1,15 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl tag new caching +Added tag: caching +exit: 0 + +$ govctl tag list +┌─────────┬───────┐ +│ Tag ┆ Usage │ +╞═════════╪═══════╡ +│ caching ┆ 0 │ +└─────────┴───────┘ +exit: 0 diff --git a/tests/snapshots/test_tags__tag_new_duplicate.snap b/tests/snapshots/test_tags__tag_new_duplicate.snap new file mode 100644 index 0000000..34d0198 --- /dev/null +++ b/tests/snapshots/test_tags__tag_new_duplicate.snap @@ -0,0 +1,11 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl tag new caching +Added tag: caching +exit: 0 + +$ govctl tag new caching +error[E1102]: Tag 'caching' already exists in [tags] allowed (caching) +exit: 1 diff --git a/tests/snapshots/test_tags__tag_new_invalid_format.snap b/tests/snapshots/test_tags__tag_new_invalid_format.snap new file mode 100644 index 0000000..730d895 --- /dev/null +++ b/tests/snapshots/test_tags__tag_new_invalid_format.snap @@ -0,0 +1,7 @@ +--- +source: tests/test_tags.rs +expression: "normalize_output(&output, temp_dir.path(), &date)" +--- +$ govctl tag new UPPER +error[E1101]: Invalid tag format 'UPPER': tags must match ^[a-z][a-z0-9-]*$ (lowercase letters, digits, hyphens; start with a letter) (UPPER) +exit: 1 diff --git a/tests/test_tags.rs b/tests/test_tags.rs new file mode 100644 index 0000000..28ca6bf --- /dev/null +++ b/tests/test_tags.rs @@ -0,0 +1,240 @@ +//! Integration tests for the govctl tags feature. +//! +//! Covers: tag registry management, artifact tagging, validation, and list filtering. + +mod common; + +use common::{init_project, normalize_output, run_commands, today}; +use std::fs; + +// ============================================================================ +// Helper +// ============================================================================ + +/// Register allowed tags in config.toml by editing the TOML table directly. +fn register_tags(dir: &std::path::Path, tags: &[&str]) { + let config_path = dir.join("gov/config.toml"); + let content = fs::read_to_string(&config_path).unwrap(); + let mut doc: toml::Table = toml::from_str(&content).unwrap(); + let arr: toml::value::Array = tags + .iter() + .map(|t| toml::Value::String(t.to_string())) + .collect(); + let mut tags_table = toml::Table::new(); + tags_table.insert("allowed".into(), toml::Value::Array(arr)); + doc.insert("tags".into(), toml::Value::Table(tags_table)); + fs::write(&config_path, toml::to_string_pretty(&doc).unwrap()).unwrap(); +} + +// ============================================================================ +// Registry management +// ============================================================================ + +#[test] +fn test_tag_new() { + let temp_dir = init_project(); + let date = today(); + let output = run_commands( + temp_dir.path(), + &[&["tag", "new", "caching"], &["tag", "list"]], + ); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} + +#[test] +fn test_tag_new_duplicate() { + let temp_dir = init_project(); + let date = today(); + let output = run_commands( + temp_dir.path(), + &[&["tag", "new", "caching"], &["tag", "new", "caching"]], + ); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} + +#[test] +fn test_tag_new_invalid_format() { + let temp_dir = init_project(); + let date = today(); + let output = run_commands(temp_dir.path(), &[&["tag", "new", "UPPER"]]); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} + +#[test] +fn test_tag_delete() { + let temp_dir = init_project(); + let date = today(); + let output = run_commands( + temp_dir.path(), + &[ + &["tag", "new", "caching"], + &["tag", "delete", "caching"], + &["tag", "list"], + ], + ); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} + +#[test] +fn test_tag_delete_referenced() { + let temp_dir = init_project(); + let date = today(); + let output = run_commands( + temp_dir.path(), + &[ + &["tag", "new", "caching"], + &["adr", "new", "Test Decision"], + &["adr", "add", "ADR-0001", "tags", "caching"], + &["tag", "delete", "caching"], + ], + ); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} + +// ============================================================================ +// Artifact tagging +// ============================================================================ + +#[test] +fn test_artifact_add_tag() { + let temp_dir = init_project(); + let date = today(); + let output = run_commands( + temp_dir.path(), + &[ + &["tag", "new", "caching"], + &["adr", "new", "Test Decision"], + &["adr", "add", "ADR-0001", "tags", "caching"], + &["adr", "get", "ADR-0001", "tags"], + ], + ); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} + +#[test] +fn test_artifact_add_unregistered_tag() { + let temp_dir = init_project(); + let date = today(); + + register_tags(temp_dir.path(), &["registered"]); + + let output = run_commands( + temp_dir.path(), + &[ + &["adr", "new", "Test Decision"], + &["adr", "add", "ADR-0001", "tags", "nonexistent"], + ], + ); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} + +// ============================================================================ +// Validation +// ============================================================================ + +#[test] +fn test_check_rejects_unknown_tag() { + let temp_dir = init_project(); + let date = today(); + + register_tags(temp_dir.path(), &["allowed-tag"]); + + run_commands(temp_dir.path(), &[&["adr", "new", "Test Decision"]]); + + // Find the ADR file and inject an unregistered tag + let adr_dir = temp_dir.path().join("gov/adr"); + let adr_path = fs::read_dir(&adr_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("ADR-0001") && n.ends_with(".toml")) + .unwrap_or(false) + }) + .expect("ADR-0001 file not found in gov/adr"); + + let content = fs::read_to_string(&adr_path).unwrap(); + let mut doc: toml::Table = toml::from_str(&content).unwrap(); + let govctl = doc + .get_mut("govctl") + .and_then(|v| v.as_table_mut()) + .expect("[govctl] table must exist in ADR TOML"); + govctl.insert( + "tags".into(), + toml::Value::Array(vec![toml::Value::String("unknown-tag".into())]), + ); + fs::write(&adr_path, toml::to_string_pretty(&doc).unwrap()).unwrap(); + + let output = run_commands(temp_dir.path(), &[&["check"]]); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} + +#[test] +fn test_check_accepts_registered_tag() { + let temp_dir = init_project(); + let date = today(); + + register_tags(temp_dir.path(), &["caching"]); + + let output = run_commands( + temp_dir.path(), + &[ + &["adr", "new", "Test Decision"], + &["adr", "add", "ADR-0001", "tags", "caching"], + &["check"], + ], + ); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} + +// ============================================================================ +// List filtering +// ============================================================================ + +#[test] +fn test_list_filter_by_tag() { + let temp_dir = init_project(); + let date = today(); + + run_commands( + temp_dir.path(), + &[ + &["tag", "new", "caching"], + &["adr", "new", "Tagged Decision"], + &["adr", "new", "Untagged Decision"], + &["adr", "add", "ADR-0001", "tags", "caching"], + ], + ); + + let output = run_commands(temp_dir.path(), &[&["adr", "list", "--tag", "caching"]]); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} + +#[test] +fn test_list_filter_multiple_tags() { + let temp_dir = init_project(); + let date = today(); + + run_commands( + temp_dir.path(), + &[ + &["tag", "new", "caching"], + &["tag", "new", "performance"], + &["tag", "new", "security"], + &["adr", "new", "Multi-Tagged Decision"], + &["adr", "add", "ADR-0001", "tags", "caching"], + &["adr", "add", "ADR-0001", "tags", "performance"], + ], + ); + + let output = run_commands( + temp_dir.path(), + &[ + &["adr", "list", "--tag", "caching,performance"], + &["adr", "list", "--tag", "caching,security"], + ], + ); + insta::assert_snapshot!(normalize_output(&output, temp_dir.path(), &date)); +} From 422c90c6684f3c2da3f0a5ccd783d890b56be838 Mon Sep 17 00:00:00 2001 From: Gabriel Wu <13583761+lucifer1004@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:00:37 +0800 Subject: [PATCH 3/3] fix(edit): infer --set when --stdin is used without an explicit edit action When `--stdin` is present with no `--set`/`--add`/`--remove`/`--tick`, the edit command now infers `--set` instead of erroring with "exactly one edit action is required". Fixes the documented but broken pattern: govctl clause edit RFC-0001:C-SUMMARY text --stdin Closes WI-2026-04-10-001. --- CHANGELOG.md | 4 ++++ ...thout-set-infers-set-in-edit-commands.toml | 23 +++++++++++++++++++ src/command_router.rs | 8 +++++++ 3 files changed, 35 insertions(+) create mode 100644 gov/work/2026-04-10-fix-stdin-without-set-infers-set-in-edit-commands.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 67eda6b..2542867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - govctl check validates tags against config allowed list (WI-2026-04-09-001) - --tag filter on rfc/clause/adr/work/guard list commands (WI-2026-04-09-001) +### Fixed + +- clause edit <ID> text --stdin works without explicit --set (WI-2026-04-10-001) + ## [0.8.1] - 2026-04-08 ### Added diff --git a/gov/work/2026-04-10-fix-stdin-without-set-infers-set-in-edit-commands.toml b/gov/work/2026-04-10-fix-stdin-without-set-infers-set-in-edit-commands.toml new file mode 100644 index 0000000..b25b66f --- /dev/null +++ b/gov/work/2026-04-10-fix-stdin-without-set-infers-set-in-edit-commands.toml @@ -0,0 +1,23 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-04-10-001" +title = "Fix --stdin without --set infers --set in edit commands" +status = "done" +created = "2026-04-10" +started = "2026-04-10" +completed = "2026-04-10" +refs = ["RFC-0002"] + +[content] +description = "When --stdin is used without an explicit edit action (--set/--add/--remove/--tick), infer --set. Fixes the documented but broken pattern: govctl clause edit <ID> text --stdin." + +[[content.acceptance_criteria]] +text = "clause edit <ID> text --stdin works without explicit --set" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "govctl check passes" +status = "done" +category = "chore" diff --git a/src/command_router.rs b/src/command_router.rs index a781d52..0659621 100644 --- a/src/command_router.rs +++ b/src/command_router.rs @@ -259,6 +259,14 @@ pub(crate) fn owned_edit_action(args: &EditActionArgs) -> anyhow::Result<OwnedEd + usize::from(args.remove.is_some()); if action_count == 0 { + // When --stdin is present with no explicit action, infer --set + if args.stdin { + reject_selector_flags_for_value_action("set (inferred from --stdin)", args)?; + return Ok(OwnedEditAction::Set { + value: Some(None), + stdin: true, + }); + } return Err(Diagnostic::new( DiagnosticCode::E0801MissingRequiredArg, "exactly one edit action is required",