From f25fdd6907c7178600f40c91e4b16402d42cf212 Mon Sep 17 00:00:00 2001 From: Gabriel Wu <13583761+lucifer1004@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:29:03 +0800 Subject: [PATCH 1/3] fix(validation): warn on bare artifact references Add W0112 source-level diagnostics for known artifact IDs in reviewable governed prose. Update reviewer agents to rely on govctl check diagnostics instead of rendered output inference. Fixes #24. Refs WI-2026-06-11-001. --- .claude/agents/adr-reviewer.md | 8 +- .claude/agents/rfc-reviewer.md | 8 +- .claude/agents/wi-reviewer.md | 6 +- docs/rfc/RFC-0000.md | 26 ++- .../clauses/C-REFERENCE-HIERARCHY.toml | 14 +- gov/rfc/RFC-0000/rfc.toml | 12 +- ...eference-syntax-for-reviewer-evidence.toml | 35 +++ src/diagnostic/code/metadata.rs | 4 +- src/diagnostic/code/mod.rs | 7 + src/validate/bracket_refs.rs | 202 ++++++++++++++++-- src/validate/mod.rs | 4 +- tests/error_tests/rfc_clause_cases/check.rs | 143 +++++++++++++ tests/error_tests/work.rs | 39 ++++ tests/test_errors.rs | 4 +- 14 files changed, 476 insertions(+), 36 deletions(-) create mode 100644 gov/work/2026-06-11-validate-raw-artifact-reference-syntax-for-reviewer-evidence.toml diff --git a/.claude/agents/adr-reviewer.md b/.claude/agents/adr-reviewer.md index 97e9814c..e1d19060 100644 --- a/.claude/agents/adr-reviewer.md +++ b/.claude/agents/adr-reviewer.md @@ -15,8 +15,9 @@ It does not edit artifacts, execute lifecycle verbs, create work items, or perfo When invoked: 1. Read the rendered ADR using `govctl adr show ` (never read the raw TOML file — use the rendered markdown) -2. Evaluate against the checklist below -3. Report findings organized by severity +2. Run or inspect `govctl check` diagnostics when evaluating source-sensitive reference syntax +3. Evaluate against the checklist below +4. Report findings organized by severity ## Review Checklist @@ -58,7 +59,8 @@ When invoked: ### References - [ ] Links to related RFCs/ADRs that constrained or informed the decision -- [ ] All artifact IDs in prose use `[[artifact-id]]` syntax — never bare IDs like "ADR-0026" or "RFC-0001" in running text. The `[[...]]` wrapper makes references clickable when rendered. +- [ ] Source-sensitive inline reference syntax is backed by `govctl check` diagnostics. Do not infer raw `[[artifact-id]]` usage from rendered output alone. +- [ ] If `govctl check` reports `W0112` for this ADR, flag the corresponding known artifact ID as needing `[[artifact-id]]` syntax. If no source diagnostics are available, report raw reference syntax as not assessed rather than guessing from rendered IDs. - [ ] `refs` field uses plain IDs (not `[[...]]` syntax) - [ ] `refs` field uses clause-level precision where applicable (e.g., `RFC-0000:C-WORK-DEF` not just `RFC-0000`) - [ ] No redundant "References:" paragraph at the end of content fields — the `refs` field already tracks cross-references; repeating them as prose is noise diff --git a/.claude/agents/rfc-reviewer.md b/.claude/agents/rfc-reviewer.md index 45d40590..534bf80e 100644 --- a/.claude/agents/rfc-reviewer.md +++ b/.claude/agents/rfc-reviewer.md @@ -17,8 +17,9 @@ It does not edit artifacts, execute lifecycle verbs, create work items, or perfo When invoked: 1. Read the rendered RFC using `govctl rfc show ` (never read raw artifact files directly — use the rendered markdown) -2. Evaluate against the checklist below -3. Report findings organized by severity +2. Run or inspect `govctl check` diagnostics when evaluating source-sensitive reference syntax +3. Evaluate against the checklist below +4. Report findings organized by severity ## Review Checklist @@ -44,7 +45,8 @@ When invoked: ### Cross-references -- [ ] All artifact IDs in clause text use `[[artifact-id]]` syntax — never bare IDs like "ADR-0026" or "RFC-0001" in running text. The `[[...]]` wrapper makes references clickable when rendered. +- [ ] Source-sensitive inline reference syntax is backed by `govctl check` diagnostics. Do not infer raw `[[artifact-id]]` usage from rendered output alone. +- [ ] If `govctl check` reports `W0112` for this RFC, flag the corresponding known artifact ID as needing `[[artifact-id]]` syntax. If no source diagnostics are available, report raw reference syntax as not assessed rather than guessing from rendered IDs. - [ ] `refs` field uses clause-level precision where applicable (e.g., `RFC-0000:C-WORK-DEF` not just `RFC-0000`) - [ ] No redundant "References:" paragraph at the end of clause text — the `refs` field already tracks cross-references - [ ] Referenced artifacts exist and are not deprecated diff --git a/.claude/agents/wi-reviewer.md b/.claude/agents/wi-reviewer.md index 68fb40bc..ec4c3037 100644 --- a/.claude/agents/wi-reviewer.md +++ b/.claude/agents/wi-reviewer.md @@ -16,7 +16,8 @@ When invoked: 1. Read the rendered work item using `govctl work show ` (never read the raw TOML file — use the rendered markdown) 2. Use the rendered acceptance-criteria category labels such as `added:`, `fixed:`, `changed:`, and `chore:` as the source of truth for category checks -3. Report findings organized by severity +3. Run or inspect `govctl check` diagnostics when evaluating source-sensitive reference syntax +4. Report findings organized by severity ## Review Checklist @@ -56,7 +57,8 @@ When invoked: ### References -- [ ] All artifact IDs in description and notes use `[[artifact-id]]` syntax — never bare IDs like "ADR-0026" or "RFC-0001" in running text +- [ ] Source-sensitive inline reference syntax is backed by `govctl check` diagnostics. Do not infer raw `[[artifact-id]]` usage from rendered output alone. +- [ ] If `govctl check` reports `W0112` for this Work Item, flag the corresponding known artifact ID as needing `[[artifact-id]]` syntax. If no source diagnostics are available, report raw reference syntax as not assessed rather than guessing from rendered IDs. - [ ] `refs` field uses clause-level precision where applicable (e.g., `RFC-0000:C-WORK-DEF` not just `RFC-0000`) - [ ] No redundant "References:" paragraph at the end of content fields — the `refs` field already tracks cross-references - [ ] If implementing an RFC, the RFC ID is in refs diff --git a/docs/rfc/RFC-0000.md b/docs/rfc/RFC-0000.md index a21a9ef2..9c96e2b2 100644 --- a/docs/rfc/RFC-0000.md +++ b/docs/rfc/RFC-0000.md @@ -1,9 +1,9 @@ - + # RFC-0000: govctl Governance Framework -> **Version:** 1.3.2 | **Status:** normative | **Phase:** stable +> **Version:** 1.3.3 | **Status:** normative | **Phase:** stable --- @@ -119,12 +119,24 @@ Implementations MUST validate these rules during project validation (for example - Work Item `refs` entries and `[[...]]` link targets MAY identify any artifact type. -This clause only defines the RFC/ADR/Work Item authority hierarchy. It does not impose additional target-kind restrictions for artifact types outside that hierarchy. +Known artifact-ID mentions in reviewable governed RFC clause text, governed ADR content fields, and governed Work Item prose SHOULD use `[[artifact-id]]` inline reference syntax. This inline syntax expectation does not apply to structured `refs` field entries. + +For this inline syntax warning, reviewable governed prose means draft RFC clause text, proposed ADR content fields, and Work Item description, notes, and acceptance criteria for Work Items that are not `done`. + +Project validation SHOULD report a warning when a known artifact ID appears in reviewable governed prose outside `[[...]]` inline reference syntax. + +Project validation MAY omit this inline syntax warning for accepted, stable, deprecated, superseded, done, or otherwise historical artifacts. + +Unknown artifact-ID-shaped text MAY appear as an example without being treated as an artifact reference. + +This clause only defines the RFC/ADR/Work Item authority hierarchy and inline reference syntax expectations. It does not impose additional target-kind restrictions for artifact types outside that hierarchy. **Rationale:** This hierarchy prevents circular dependencies and maintains clear authority chains. An RFC that links to or names an ADR or Work Item as a governed reference inverts the dependency direction — lower layers become authorities over the specification. +Inline reference syntax makes artifact links explicit in source while allowing rendered projections to remain human-readable. A validation warning gives reviewers source-level evidence for raw reference syntax without requiring them to infer it from rendered output. Limiting this warning to reviewable artifacts avoids forcing historical backfill before existing accepted or completed artifacts can pass normal project validation. + **Prose outside governed reference surfaces:** Normative RFC clause text SHOULD remain self-contained. Non-governed explanatory text MAY mention artifact identifier shapes as examples, but known lower-authority artifact identifiers in governed RFC clause text and governed ADR content fields are validated as references even without `[[...]]` delimiters. @@ -316,6 +328,14 @@ A guard check MUST pass only when the command exits successfully. If `pattern` i ## Changelog +### v1.3.3 (2026-06-11) + +Clarify inline reference validation + +#### Changed + +- Project validation should warn on known artifact IDs in governed prose that are not written with inline reference syntax + ### v1.3.2 (2026-06-04) Align reference hierarchy wording diff --git a/gov/rfc/RFC-0000/clauses/C-REFERENCE-HIERARCHY.toml b/gov/rfc/RFC-0000/clauses/C-REFERENCE-HIERARCHY.toml index 564f6772..326948a5 100644 --- a/gov/rfc/RFC-0000/clauses/C-REFERENCE-HIERARCHY.toml +++ b/gov/rfc/RFC-0000/clauses/C-REFERENCE-HIERARCHY.toml @@ -30,12 +30,24 @@ Implementations MUST validate these rules during project validation (for example - Work Item `refs` entries and `[[...]]` link targets MAY identify any artifact type. -This clause only defines the RFC/ADR/Work Item authority hierarchy. It does not impose additional target-kind restrictions for artifact types outside that hierarchy. +Known artifact-ID mentions in reviewable governed RFC clause text, governed ADR content fields, and governed Work Item prose SHOULD use `[[artifact-id]]` inline reference syntax. This inline syntax expectation does not apply to structured `refs` field entries. + +For this inline syntax warning, reviewable governed prose means draft RFC clause text, proposed ADR content fields, and Work Item description, notes, and acceptance criteria for Work Items that are not `done`. + +Project validation SHOULD report a warning when a known artifact ID appears in reviewable governed prose outside `[[...]]` inline reference syntax. + +Project validation MAY omit this inline syntax warning for accepted, stable, deprecated, superseded, done, or otherwise historical artifacts. + +Unknown artifact-ID-shaped text MAY appear as an example without being treated as an artifact reference. + +This clause only defines the RFC/ADR/Work Item authority hierarchy and inline reference syntax expectations. It does not impose additional target-kind restrictions for artifact types outside that hierarchy. **Rationale:** This hierarchy prevents circular dependencies and maintains clear authority chains. An RFC that links to or names an ADR or Work Item as a governed reference inverts the dependency direction — lower layers become authorities over the specification. +Inline reference syntax makes artifact links explicit in source while allowing rendered projections to remain human-readable. A validation warning gives reviewers source-level evidence for raw reference syntax without requiring them to infer it from rendered output. Limiting this warning to reviewable artifacts avoids forcing historical backfill before existing accepted or completed artifacts can pass normal project validation. + **Prose outside governed reference surfaces:** Normative RFC clause text SHOULD remain self-contained. Non-governed explanatory text MAY mention artifact identifier shapes as examples, but known lower-authority artifact identifiers in governed RFC clause text and governed ADR content fields are validated as references even without `[[...]]` delimiters.""" diff --git a/gov/rfc/RFC-0000/rfc.toml b/gov/rfc/RFC-0000/rfc.toml index 29036614..67f3b859 100644 --- a/gov/rfc/RFC-0000/rfc.toml +++ b/gov/rfc/RFC-0000/rfc.toml @@ -3,19 +3,19 @@ [govctl] id = "RFC-0000" title = "govctl Governance Framework" -version = "1.3.2" +version = "1.3.3" status = "normative" phase = "stable" owners = ["@govctl-org"] created = "2026-01-17" -updated = "2026-06-04" +updated = "2026-06-11" tags = [ "core", "schema", "validation", "lifecycle", ] -signature = "496196ed1073947f867c6730c56c34da58f1b045bf939d98b7804511f8feea9a" +signature = "ba16714af2ca8c1b32b812963d4b02da4c67a647eafc1c88b03fa622085b1205" [[sections]] title = "Summary" @@ -50,6 +50,12 @@ clauses = ["clauses/C-RELEASE-DEF.toml"] title = "Verification Guard Specification" clauses = ["clauses/C-GUARD-DEF.toml"] +[[changelog]] +version = "1.3.3" +date = "2026-06-11" +notes = "Clarify inline reference validation" +changed = ["Project validation should warn on known artifact IDs in governed prose that are not written with inline reference syntax"] + [[changelog]] version = "1.3.2" date = "2026-06-04" diff --git a/gov/work/2026-06-11-validate-raw-artifact-reference-syntax-for-reviewer-evidence.toml b/gov/work/2026-06-11-validate-raw-artifact-reference-syntax-for-reviewer-evidence.toml new file mode 100644 index 00000000..ee68af92 --- /dev/null +++ b/gov/work/2026-06-11-validate-raw-artifact-reference-syntax-for-reviewer-evidence.toml @@ -0,0 +1,35 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-06-11-001" +title = "Validate raw artifact reference syntax for reviewer evidence" +status = "done" +created = "2026-06-11" +started = "2026-06-11" +completed = "2026-06-11" +refs = [ + "ADR-0024", + "RFC-0000:C-REFERENCE-HIERARCHY", +] +tags = [ + "skills-agents", + "validation", +] + +[content] +description = "Add source-sensitive validation for bare known artifact IDs in governed prose so reviewer agents can rely on deterministic diagnostics instead of inferring raw reference syntax from rendered output. Update reviewer guidance to distinguish rendered review evidence from raw-source validation evidence." + +[[content.acceptance_criteria]] +text = "reviewer agents no longer judge raw reference syntax from rendered output alone" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "govctl check warns when reviewable governed prose contains a known artifact ID outside [[...]] syntax" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "govctl check and cargo test pass" +status = "done" +category = "chore" diff --git a/src/diagnostic/code/metadata.rs b/src/diagnostic/code/metadata.rs index 9118528c..0ae93f80 100644 --- a/src/diagnostic/code/metadata.rs +++ b/src/diagnostic/code/metadata.rs @@ -10,7 +10,8 @@ pub(super) fn level(code: &DiagnosticCode) -> DiagnosticLevel { | DiagnosticCode::W0108WorkPlaceholderDescription | DiagnosticCode::W0109WorkNoActive | DiagnosticCode::W0110SchemaOutdated - | DiagnosticCode::W0111ProjectSupportOutdated => DiagnosticLevel::Warning, + | DiagnosticCode::W0111ProjectSupportOutdated + | DiagnosticCode::W0112BareArtifactReference => DiagnosticLevel::Warning, DiagnosticCode::I0401WorkLegacyInlineHistory => DiagnosticLevel::Info, _ => DiagnosticLevel::Error, } @@ -138,6 +139,7 @@ pub(super) fn code(code: &DiagnosticCode) -> &'static str { DiagnosticCode::W0109WorkNoActive => "W0109", DiagnosticCode::W0110SchemaOutdated => "W0110", DiagnosticCode::W0111ProjectSupportOutdated => "W0111", + DiagnosticCode::W0112BareArtifactReference => "W0112", // I04xx - Work Item info DiagnosticCode::I0401WorkLegacyInlineHistory => "I0401", } diff --git a/src/diagnostic/code/mod.rs b/src/diagnostic/code/mod.rs index 95b3787f..67109e5b 100644 --- a/src/diagnostic/code/mod.rs +++ b/src/diagnostic/code/mod.rs @@ -152,6 +152,8 @@ pub enum DiagnosticCode { W0109WorkNoActive, W0110SchemaOutdated, W0111ProjectSupportOutdated, + /// Known artifact ID appears in governed prose without [[...]] syntax. + W0112BareArtifactReference, // Informational diagnostics (I04xx) I0401WorkLegacyInlineHistory, @@ -186,6 +188,7 @@ mod tests { assert_eq!(DiagnosticCode::E1201LoopStateInvalid.code(), "E1201"); assert_eq!(DiagnosticCode::W0101RfcNoChangelog.code(), "W0101"); assert_eq!(DiagnosticCode::W0111ProjectSupportOutdated.code(), "W0111"); + assert_eq!(DiagnosticCode::W0112BareArtifactReference.code(), "W0112"); assert_eq!(DiagnosticCode::I0401WorkLegacyInlineHistory.code(), "I0401"); } @@ -207,6 +210,10 @@ mod tests { DiagnosticCode::W0111ProjectSupportOutdated.level(), DiagnosticLevel::Warning ); + assert_eq!( + DiagnosticCode::W0112BareArtifactReference.level(), + DiagnosticLevel::Warning + ); assert_eq!( DiagnosticCode::I0401WorkLegacyInlineHistory.level(), DiagnosticLevel::Info diff --git a/src/validate/bracket_refs.rs b/src/validate/bracket_refs.rs index 565a0d45..3faa95b8 100644 --- a/src/validate/bracket_refs.rs +++ b/src/validate/bracket_refs.rs @@ -3,7 +3,7 @@ use super::reference_hierarchy::{ReferenceSurface, check_ref_hierarchy}; use crate::artifact_index::artifact_ref_ids; use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode}; -use crate::model::ProjectIndex; +use crate::model::{AdrStatus, ProjectIndex, RfcStatus, WorkItemStatus}; use regex::Regex; use std::collections::HashSet; @@ -15,7 +15,7 @@ struct ReferenceScanner { known_ids: HashSet, } -/// Validate references in RFC and ADR content per [[RFC-0000:C-REFERENCE-HIERARCHY]]. +/// Validate inline references in governed prose per [[RFC-0000:C-REFERENCE-HIERARCHY]]. pub(super) fn validate_bracket_reference_hierarchy( index: &ProjectIndex, config: &Config, @@ -52,6 +52,7 @@ pub(super) fn validate_bracket_reference_hierarchy( for rfc in &index.rfcs { let rfc_path = config.display_path(&rfc.path).display().to_string(); let rid = rfc.rfc.rfc_id.as_str(); + let warn_on_bare_text = rfc.rfc.status == RfcStatus::Draft; for clause in &rfc.clauses { let clause_path = config.display_path(&clause.path).display().to_string(); scan_rfc_reference_hierarchy( @@ -60,12 +61,13 @@ pub(super) fn validate_bracket_reference_hierarchy( rid, &clause_path, true, + warn_on_bare_text, result, ); } for entry in &rfc.rfc.changelog { if let Some(ref notes) = entry.notes { - scan_rfc_reference_hierarchy(&scanner, notes, rid, &rfc_path, false, result); + scan_rfc_reference_hierarchy(&scanner, notes, rid, &rfc_path, false, false, result); } for line in entry .added @@ -76,7 +78,7 @@ pub(super) fn validate_bracket_reference_hierarchy( .chain(entry.fixed.iter()) .chain(entry.security.iter()) { - scan_rfc_reference_hierarchy(&scanner, line, rid, &rfc_path, false, result); + scan_rfc_reference_hierarchy(&scanner, line, rid, &rfc_path, false, false, result); } } } @@ -84,23 +86,101 @@ pub(super) fn validate_bracket_reference_hierarchy( for adr in &index.adrs { let adr_path = config.display_path(&adr.path).display().to_string(); let aid = adr.meta().id.as_str(); + let warn_on_bare_text = adr.meta().status == AdrStatus::Proposed; let c = &adr.spec.content; - scan_adr_reference_hierarchy(&scanner, &c.context, aid, &adr_path, result); - scan_adr_reference_hierarchy(&scanner, &c.decision, aid, &adr_path, result); - scan_adr_reference_hierarchy(&scanner, &c.consequences, aid, &adr_path, result); + scan_adr_reference_hierarchy( + &scanner, + &c.context, + aid, + &adr_path, + warn_on_bare_text, + result, + ); + scan_adr_reference_hierarchy( + &scanner, + &c.decision, + aid, + &adr_path, + warn_on_bare_text, + result, + ); + scan_adr_reference_hierarchy( + &scanner, + &c.consequences, + aid, + &adr_path, + warn_on_bare_text, + result, + ); for alt in &c.alternatives { - scan_adr_reference_hierarchy(&scanner, &alt.text, aid, &adr_path, result); + scan_adr_reference_hierarchy( + &scanner, + &alt.text, + aid, + &adr_path, + warn_on_bare_text, + result, + ); for p in &alt.pros { - scan_adr_reference_hierarchy(&scanner, p, aid, &adr_path, result); + scan_adr_reference_hierarchy( + &scanner, + p, + aid, + &adr_path, + warn_on_bare_text, + result, + ); } for cons in &alt.cons { - scan_adr_reference_hierarchy(&scanner, cons, aid, &adr_path, result); + scan_adr_reference_hierarchy( + &scanner, + cons, + aid, + &adr_path, + warn_on_bare_text, + result, + ); } if let Some(ref rr) = alt.rejection_reason { - scan_adr_reference_hierarchy(&scanner, rr, aid, &adr_path, result); + scan_adr_reference_hierarchy( + &scanner, + rr, + aid, + &adr_path, + warn_on_bare_text, + result, + ); } } } + + for work in &index.work_items { + let work_path = config.display_path(&work.path).display().to_string(); + let wid = work.meta().id.as_str(); + let warn_on_bare_text = work.meta().status != WorkItemStatus::Done; + let content = &work.spec.content; + scan_work_reference_syntax( + &scanner, + &content.description, + wid, + &work_path, + warn_on_bare_text, + result, + ); + for criterion in &content.acceptance_criteria { + scan_work_reference_syntax( + &scanner, + &criterion.text, + wid, + &work_path, + warn_on_bare_text, + result, + ); + } + for note in &content.notes { + scan_work_reference_syntax(&scanner, note, wid, &work_path, warn_on_bare_text, result); + } + } } fn scan_rfc_reference_hierarchy( @@ -109,9 +189,18 @@ fn scan_rfc_reference_hierarchy( rfc_id: &str, path: &str, scan_bare_text: bool, + warn_on_bare_text: bool, result: &mut ValidationResult, ) { - scan_reference_hierarchy(scanner, text, rfc_id, path, scan_bare_text, result); + scan_reference_hierarchy( + scanner, + text, + rfc_id, + path, + scan_bare_text, + warn_on_bare_text, + result, + ); } fn scan_adr_reference_hierarchy( @@ -119,9 +208,29 @@ fn scan_adr_reference_hierarchy( text: &str, adr_id: &str, path: &str, + warn_on_bare_text: bool, + result: &mut ValidationResult, +) { + scan_reference_hierarchy(scanner, text, adr_id, path, true, warn_on_bare_text, result); +} + +fn scan_work_reference_syntax( + scanner: &ReferenceScanner, + text: &str, + work_id: &str, + path: &str, + warn_on_bare_text: bool, result: &mut ValidationResult, ) { - scan_reference_hierarchy(scanner, text, adr_id, path, true, result); + scan_reference_hierarchy( + scanner, + text, + work_id, + path, + true, + warn_on_bare_text, + result, + ); } fn scan_reference_hierarchy( @@ -130,6 +239,7 @@ fn scan_reference_hierarchy( owner_id: &str, path: &str, scan_bare_text: bool, + warn_on_bare_text: bool, result: &mut ValidationResult, ) { let mut bracket_ranges = Vec::new(); @@ -166,14 +276,26 @@ fn scan_reference_hierarchy( if !scanner.known_ids.contains(target) { continue; } - if let Err(diagnostic) = - check_ref_hierarchy(owner_id, target, path, ReferenceSurface::BareText) - { - result.diagnostics.push(diagnostic); + match check_ref_hierarchy(owner_id, target, path, ReferenceSurface::BareText) { + Ok(()) if warn_on_bare_text => result + .diagnostics + .push(bare_artifact_reference_warning(owner_id, target, path)), + Ok(()) => {} + Err(diagnostic) => result.diagnostics.push(diagnostic), } } } +fn bare_artifact_reference_warning(owner_id: &str, target: &str, path: &str) -> Diagnostic { + Diagnostic::new( + DiagnosticCode::W0112BareArtifactReference, + format!( + "Artifact '{owner_id}' mentions known artifact ID {target} without [[...]] inline reference syntax (hint: use [[{target}]])" + ), + path, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -222,6 +344,7 @@ mod tests { "RFC-0001", "f", true, + true, &mut result, ); @@ -244,6 +367,51 @@ mod tests { "RFC-0001", "f", true, + true, + &mut result, + ); + + assert!(result.diagnostics.is_empty()); + Ok(()) + } + + #[test] + fn bare_known_rfc_in_adr_text_warns() -> DiagnosticResult<()> { + let mut known_ids = HashSet::new(); + known_ids.insert("RFC-0001".to_string()); + let mut result = ValidationResult::default(); + + scan_reference_hierarchy( + &scanner(known_ids)?, + "This follows RFC-0001.", + "ADR-0001", + "f", + true, + true, + &mut result, + ); + + assert_eq!(result.diagnostics.len(), 1); + assert_eq!( + result.diagnostics[0].code, + DiagnosticCode::W0112BareArtifactReference + ); + Ok(()) + } + + #[test] + fn bracketed_known_rfc_in_adr_text_does_not_warn() -> DiagnosticResult<()> { + let mut known_ids = HashSet::new(); + known_ids.insert("RFC-0001".to_string()); + let mut result = ValidationResult::default(); + + scan_reference_hierarchy( + &scanner(known_ids)?, + "This follows [[RFC-0001]].", + "ADR-0001", + "f", + true, + true, &mut result, ); diff --git a/src/validate/mod.rs b/src/validate/mod.rs index 1d1661e8..a07d1a0c 100644 --- a/src/validate/mod.rs +++ b/src/validate/mod.rs @@ -3,7 +3,7 @@ //! Implements validation per [[RFC-0000]] and [[RFC-0001]]: //! - [[ADR-0003]] signature verification for rendered projections //! - [[ADR-0010]] placeholder description detection -//! - [[RFC-0000:C-REFERENCE-HIERARCHY]] structured refs and [[...]] link targets +//! - [[RFC-0000:C-REFERENCE-HIERARCHY]] structured refs, [[...]] link targets, and bare known IDs use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode}; @@ -103,7 +103,7 @@ pub fn validate_project(index: &ProjectIndex, config: &Config) -> ValidationResu .diagnostics .extend(validate_work_dependencies(index, config)); - // [[...]] in RFC/ADR governed text — [[RFC-0000:C-REFERENCE-HIERARCHY]] + // Inline reference syntax in governed prose — [[RFC-0000:C-REFERENCE-HIERARCHY]] validate_bracket_reference_hierarchy(index, config, &mut result); // Validate work item descriptions diff --git a/tests/error_tests/rfc_clause_cases/check.rs b/tests/error_tests/rfc_clause_cases/check.rs index f6ef0a18..9d343016 100644 --- a/tests/error_tests/rfc_clause_cases/check.rs +++ b/tests/error_tests/rfc_clause_cases/check.rs @@ -260,6 +260,149 @@ text = "This RFC mentions ADR-9999 as a nonexistent example." Ok(()) } +#[test] +fn test_proposed_adr_plain_text_known_rfc_reference_warns() -> common::TestResult { + let temp_dir = init_project()?; + + write_rfc_toml( + temp_dir.path(), + r#"[govctl] +schema = 1 +id = "RFC-0001" +title = "Known RFC" +version = "1.0.0" +status = "normative" +phase = "stable" +owners = ["test@example.com"] +created = "2026-01-01" + +[[sections]] +title = "Overview" +clauses = ["clauses/C-TEST.toml"] + +[[changelog]] +version = "1.0.0" +date = "2026-01-01" +added = ["Initial release"] +"#, + )?; + + write_clause_toml( + temp_dir.path(), + "C-TEST.toml", + r#"[govctl] +schema = 1 +id = "C-TEST" +title = "Test Clause" +kind = "normative" +status = "active" +since = "1.0.0" + +[content] +text = "Specification." +"#, + )?; + + write_adr_toml( + temp_dir.path(), + r#"[govctl] +schema = 1 +id = "ADR-0001" +title = "Decision" +status = "proposed" +date = "2026-01-01" +refs = ["RFC-0001"] + +[content] +context = "Context" +decision = "This decision follows RFC-0001." +consequences = "Consequences" +"#, + )?; + + let output = run_commands( + temp_dir.path(), + &[&["check"], &["check", "--deny-warnings"]], + )?; + assert!(output.contains("warning[W0112]"), "output: {}", output); + assert!(output.contains("use [[RFC-0001]]"), "output: {}", output); + assert!(output.contains("$ govctl check\n"), "output: {}", output); + assert!(output.contains("exit: 0"), "output: {}", output); + assert!( + output.contains("$ govctl check --deny-warnings\n"), + "output: {}", + output + ); + assert!(output.contains("exit: 1"), "output: {}", output); + Ok(()) +} + +#[test] +fn test_adr_bracketed_known_rfc_reference_does_not_warn() -> common::TestResult { + let temp_dir = init_project()?; + + write_rfc_toml( + temp_dir.path(), + r#"[govctl] +schema = 1 +id = "RFC-0001" +title = "Known RFC" +version = "1.0.0" +status = "normative" +phase = "stable" +owners = ["test@example.com"] +created = "2026-01-01" + +[[sections]] +title = "Overview" +clauses = ["clauses/C-TEST.toml"] + +[[changelog]] +version = "1.0.0" +date = "2026-01-01" +added = ["Initial release"] +"#, + )?; + + write_clause_toml( + temp_dir.path(), + "C-TEST.toml", + r#"[govctl] +schema = 1 +id = "C-TEST" +title = "Test Clause" +kind = "normative" +status = "active" +since = "1.0.0" + +[content] +text = "Specification." +"#, + )?; + + write_adr_toml( + temp_dir.path(), + r#"[govctl] +schema = 1 +id = "ADR-0001" +title = "Decision" +status = "accepted" +date = "2026-01-01" +refs = ["RFC-0001"] + +[content] +context = "Context" +decision = "This decision follows [[RFC-0001]]." +consequences = "Consequences" +"#, + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(!output.contains("warning[W0112]"), "output: {}", output); + assert!(output.contains("exit: 0"), "output: {}", output); + Ok(()) +} + #[test] fn test_rfc_changelog_plain_text_adr_reference_is_allowed() -> common::TestResult { let temp_dir = init_project()?; diff --git a/tests/error_tests/work.rs b/tests/error_tests/work.rs index cb0d7ba5..26ec147d 100644 --- a/tests/error_tests/work.rs +++ b/tests/error_tests/work.rs @@ -72,6 +72,45 @@ category = "added" Ok(()) } +#[test] +fn test_work_plain_text_known_rfc_reference_warns() -> common::TestResult { + let temp_dir = init_project()?; + write_minimal_rfc(temp_dir.path(), "RFC-0001", "Known RFC")?; + + fs::write( + temp_dir + .path() + .join("gov/work/2026-01-01-bare-reference.toml"), + r#"[govctl] +schema = 1 +id = "WI-2026-01-01-001" +title = "Bare Reference" +status = "queue" +created = "2026-01-01" +refs = ["RFC-0001"] + +[content] +description = "This work follows RFC-0001." + +[[content.acceptance_criteria]] +text = "Use [[RFC-0001]] in bracketed form here" +status = "pending" +category = "chore" +"#, + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("warning[W0112]"), "output: {}", output); + assert!( + output.contains("Artifact 'WI-2026-01-01-001' mentions known artifact ID RFC-0001"), + "output: {}", + output + ); + assert!(output.contains("use [[RFC-0001]]"), "output: {}", output); + assert!(output.contains("exit: 0"), "output: {}", output); + Ok(()) +} + #[test] fn test_check_rejects_unknown_work_dependency() -> common::TestResult { let temp_dir = init_project()?; diff --git a/tests/test_errors.rs b/tests/test_errors.rs index 0493277d..a0e4a2cf 100644 --- a/tests/test_errors.rs +++ b/tests/test_errors.rs @@ -2,7 +2,9 @@ mod common; -use common::{init_project, init_project_with_date, normalize_output, run_commands}; +use common::{ + init_project, init_project_with_date, normalize_output, run_commands, write_minimal_rfc, +}; use std::fs; macro_rules! assert_error_snapshot { From 9718ed6ca332fe522893e9cff931413b3ecd1074 Mon Sep 17 00:00:00 2001 From: Gabriel Wu <13583761+lucifer1004@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:40:46 +0800 Subject: [PATCH 2/3] fix(work): expose verification edit paths Add canonical work edit/get support for verification.required_guards and existing waiver guard/reason fields. Update work help text and cover the paths with edit CLI regression tests. Refs WI-2026-06-11-002. --- gov/schema/edit-ops.json | 54 +++++++++ ...rk-item-verification-guard-edit-paths.toml | 37 ++++++ src/cli/resources/work.rs | 3 + tests/edit_tests/work_tests/mod.rs | 1 + tests/edit_tests/work_tests/verification.rs | 107 ++++++++++++++++++ tests/snapshots/test_help__work_get_help.snap | 2 + 6 files changed, 204 insertions(+) create mode 100644 gov/work/2026-06-11-expose-work-item-verification-guard-edit-paths.toml create mode 100644 tests/edit_tests/work_tests/verification.rs diff --git a/gov/schema/edit-ops.json b/gov/schema/edit-ops.json index 108d226c..b89c3b46 100644 --- a/gov/schema/edit-ops.json +++ b/gov/schema/edit-ops.json @@ -779,6 +779,60 @@ } } }, + { + "artifact": "work", + "root": "verification", + "content_path": ["verification"], + "node": { + "kind": "object", + "verbs": ["get"], + "fields": [ + { + "name": "required_guards", + "node": { + "kind": "list", + "verbs": ["get", "set", "add", "remove"], + "text_key": null, + "item": { + "kind": "scalar", + "verbs": ["get", "set"], + "set_mode": { "type": "string" } + } + } + }, + { + "name": "waivers", + "node": { + "kind": "list", + "verbs": ["get", "remove"], + "text_key": "guard", + "item": { + "kind": "object", + "verbs": ["get"], + "fields": [ + { + "name": "guard", + "node": { + "kind": "scalar", + "verbs": ["get", "set"], + "set_mode": { "type": "string" } + } + }, + { + "name": "reason", + "node": { + "kind": "scalar", + "verbs": ["get", "set"], + "set_mode": { "type": "string" } + } + } + ] + } + } + } + ] + } + }, { "artifact": "guard", "root": "check", diff --git a/gov/work/2026-06-11-expose-work-item-verification-guard-edit-paths.toml b/gov/work/2026-06-11-expose-work-item-verification-guard-edit-paths.toml new file mode 100644 index 00000000..7a437a0f --- /dev/null +++ b/gov/work/2026-06-11-expose-work-item-verification-guard-edit-paths.toml @@ -0,0 +1,37 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-06-11-002" +title = "Expose work item verification guard edit paths" +status = "done" +created = "2026-06-11" +started = "2026-06-11" +completed = "2026-06-11" +refs = [ + "RFC-0001:C-GATE-CONDITIONS", + "RFC-0000:C-WORK-DEF", + "ADR-0037", +] + +[content] +description = "Expose Work Item verification guard fields through the canonical path-first edit surface so agents and users can add, remove, and inspect per-work-item guard requirements without hand-editing TOML." + +[[content.acceptance_criteria]] +text = "work edit supports adding and removing verification.required_guards" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "work get can inspect verification.required_guards" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "work edit supports verification.waivers guard and reason fields" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "govctl check and targeted edit tests pass" +status = "done" +category = "chore" diff --git a/src/cli/resources/work.rs b/src/cli/resources/work.rs index bb6f72eb..f1e7b3ef 100644 --- a/src/cli/resources/work.rs +++ b/src/cli/resources/work.rs @@ -30,11 +30,13 @@ EXAMPLES: VALID FIELDS: - title, description, status, completed_at, refs, depends_on - notes, acceptance_criteria + - verification.required_guards, verification.waivers EXAMPLES: govctl work get WI-2026-04-06-001 govctl work get WI-2026-04-06-001 description govctl work get WI-2026-04-06-001 acceptance_criteria[0].status + govctl work get WI-2026-04-06-001 verification.required_guards ")] Get(CommonGetArgs), /// Show rendered work item content @@ -68,6 +70,7 @@ EXAMPLES: govctl work edit WI-2026-04-06-001 depends_on --add WI-2026-04-06-002 govctl work edit WI-2026-04-06-001 content.acceptance_criteria --add \"add: Implement feature X\" govctl work edit WI-2026-04-06-001 content.acceptance_criteria[0] --tick done + govctl work edit WI-2026-04-06-001 verification.required_guards --add GUARD-CARGO-TEST ")] Edit(WorkEditArgs), /// Set work item field value diff --git a/tests/edit_tests/work_tests/mod.rs b/tests/edit_tests/work_tests/mod.rs index d153ae7d..36135695 100644 --- a/tests/edit_tests/work_tests/mod.rs +++ b/tests/edit_tests/work_tests/mod.rs @@ -5,3 +5,4 @@ mod fields; mod identity; mod journal; mod references; +mod verification; diff --git a/tests/edit_tests/work_tests/verification.rs b/tests/edit_tests/work_tests/verification.rs new file mode 100644 index 00000000..ac4f2b78 --- /dev/null +++ b/tests/edit_tests/work_tests/verification.rs @@ -0,0 +1,107 @@ +use super::*; + +const REQUIRED_GUARDS: &str = "verification.required_guards"; + +fn work_edit_add_required_guard(id: &str, guard: &str) -> Vec { + command(&["work", "edit", id, REQUIRED_GUARDS, "--add", guard]) +} + +fn work_edit_remove_required_guard(id: &str, guard: &str) -> Vec { + command(&["work", "edit", id, REQUIRED_GUARDS, "--remove", guard]) +} + +fn work_edit_set_waiver_reason(id: &str, index: usize, reason: &str) -> Vec { + let field = format!("verification.waivers[{index}].reason"); + command(&["work", "edit", id, &field, "--set", reason]) +} + +fn work_edit_set_waiver_guard(id: &str, index: usize, guard: &str) -> Vec { + let field = format!("verification.waivers[{index}].guard"); + command(&["work", "edit", id, &field, "--set", guard]) +} + +#[test] +fn test_work_edit_required_guards_add_get_remove() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + let id = first_work_id(&date); + common::write_guard(temp_dir.path(), "GUARD-EDIT", "true")?; + + let output = common::run_dynamic_commands( + temp_dir.path(), + &[ + work_new("Guarded Task"), + work_edit_add_required_guard(&id, "GUARD-EDIT"), + work_get_field(&id, REQUIRED_GUARDS), + work_edit_remove_required_guard(&id, "GUARD-EDIT"), + work_get_field(&id, REQUIRED_GUARDS), + ], + )?; + + assert!( + output.contains(&format!( + "Added 'GUARD-EDIT' to {id}.verification.required_guards" + )), + "output: {}", + output + ); + assert!( + output.contains(&format!( + "$ govctl work get {id} verification.required_guards\nGUARD-EDIT" + )), + "output: {}", + output + ); + assert!( + output.contains(&format!( + "Removed 'GUARD-EDIT' from {id}.verification.required_guards" + )), + "output: {}", + output + ); + Ok(()) +} + +#[test] +fn test_work_edit_existing_waiver_guard_and_reason() -> common::TestResult { + let temp_dir = init_project()?; + common::write_guard(temp_dir.path(), "GUARD-WAIVED", "true")?; + common::write_guarded_work_item( + temp_dir.path(), + "WI-2026-01-01-001", + "GUARD-WAIVED", + Some("Initial reason"), + )?; + + let output = common::run_dynamic_commands( + temp_dir.path(), + &[ + work_get_field("WI-2026-01-01-001", "verification.waivers[0].guard"), + work_get_field("WI-2026-01-01-001", "verification.waivers[0].reason"), + work_edit_set_waiver_guard("WI-2026-01-01-001", 0, "GUARD-WAIVED"), + work_edit_set_waiver_reason("WI-2026-01-01-001", 0, "Updated waiver reason"), + work_get_field("WI-2026-01-01-001", "verification.waivers[0].guard"), + work_get_field("WI-2026-01-01-001", "verification.waivers[0].reason"), + ], + )?; + + assert!(output.contains("GUARD-WAIVED"), "output: {}", output); + assert!(output.contains("Initial reason"), "output: {}", output); + assert!( + output.contains("Set WI-2026-01-01-001.verification.waivers[0].guard = GUARD-WAIVED"), + "output: {}", + output + ); + assert!( + output.contains( + "Set WI-2026-01-01-001.verification.waivers[0].reason = Updated waiver reason" + ), + "output: {}", + output + ); + assert!( + output.contains("Updated waiver reason"), + "output: {}", + output + ); + Ok(()) +} diff --git a/tests/snapshots/test_help__work_get_help.snap b/tests/snapshots/test_help__work_get_help.snap index a013adb1..53634fcf 100644 --- a/tests/snapshots/test_help__work_get_help.snap +++ b/tests/snapshots/test_help__work_get_help.snap @@ -19,9 +19,11 @@ Options: VALID FIELDS: - title, description, status, completed_at, refs, depends_on - notes, acceptance_criteria + - verification.required_guards, verification.waivers EXAMPLES: govctl work get WI--001 govctl work get WI--001 description govctl work get WI--001 acceptance_criteria[0].status + govctl work get WI--001 verification.required_guards exit: 0 From 973a7de0a747e6c1fa65d62af87c97f736130a3d Mon Sep 17 00:00:00 2001 From: Gabriel Wu <13583761+lucifer1004@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:14:38 +0800 Subject: [PATCH 3/3] fix(check): use project root for subdirectory runs Resolve check-time local support and source scan paths relative to the project root so subdirectory invocations do not emit false W0111 warnings or skip source refs. --- CHANGELOG.md | 13 +++ ...-fix-subdirectory-check-path-handling.toml | 31 +++++++ ...-06-12-raise-patch-coverage-for-pr-26.toml | 31 +++++++ src/cmd/check.rs | 2 +- src/cmd/migrate/mod.rs | 2 +- src/cmd/new/mod.rs | 2 +- src/cmd/project_support.rs | 30 ++++--- src/config/runtime.rs | 14 +++- src/scan.rs | 9 ++- tests/error_tests/rfc_clause_cases/check.rs | 44 ++++++++++ tests/error_tests/schema.rs | 27 +++++++ tests/error_tests/work.rs | 81 +++++++++++++++++++ tests/test_init.rs | 29 +++++++ tests/test_scan.rs | 27 +++++++ 14 files changed, 319 insertions(+), 23 deletions(-) create mode 100644 gov/work/2026-06-12-fix-subdirectory-check-path-handling.toml create mode 100644 gov/work/2026-06-12-raise-patch-coverage-for-pr-26.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index cbb9712b..61a086e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ Release entries are curated summaries for readers. Work item traceability remain ## [Unreleased] +### Added + +- govctl check warns when reviewable governed prose contains a known artifact ID outside [[...]] syntax (WI-2026-06-11-001) + +### Fixed + +- reviewer agents no longer judge raw reference syntax from rendered output alone (WI-2026-06-11-001) +- work edit supports adding and removing verification.required_guards (WI-2026-06-11-002) +- work get can inspect verification.required_guards (WI-2026-06-11-002) +- work edit supports verification.waivers guard and reason fields (WI-2026-06-11-002) +- check from a subdirectory does not emit W0111 when the project root .gitignore contains local-state entries (WI-2026-06-12-001) +- source scanning during check is project-root relative even when invoked from a subdirectory (WI-2026-06-12-001) + ## [0.9.4] - 2026-06-09 0.9.4 is a workflow hygiene and distribution patch. It tightens agent guidance diff --git a/gov/work/2026-06-12-fix-subdirectory-check-path-handling.toml b/gov/work/2026-06-12-fix-subdirectory-check-path-handling.toml new file mode 100644 index 00000000..f185c26c --- /dev/null +++ b/gov/work/2026-06-12-fix-subdirectory-check-path-handling.toml @@ -0,0 +1,31 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-06-12-001" +title = "Fix subdirectory check path handling" +status = "done" +created = "2026-06-12" +started = "2026-06-12" +completed = "2026-06-12" +refs = [ + "RFC-0002", + "ADR-0009", +] + +[content] +description = "Fix govctl check so running from a project subdirectory still uses project-root paths for local support files and source scanning, preventing false .gitignore warnings and preserving source reference validation." + +[[content.acceptance_criteria]] +text = "check from a subdirectory does not emit W0111 when the project root .gitignore contains local-state entries" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "source scanning during check is project-root relative even when invoked from a subdirectory" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "focused regression tests and govctl check pass" +status = "done" +category = "chore" diff --git a/gov/work/2026-06-12-raise-patch-coverage-for-pr-26.toml b/gov/work/2026-06-12-raise-patch-coverage-for-pr-26.toml new file mode 100644 index 00000000..9a74c597 --- /dev/null +++ b/gov/work/2026-06-12-raise-patch-coverage-for-pr-26.toml @@ -0,0 +1,31 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-06-12-002" +title = "Raise patch coverage for PR 26" +status = "done" +created = "2026-06-12" +started = "2026-06-12" +completed = "2026-06-12" +refs = [ + "RFC-0000", + "RFC-0002", +] + +[content] +description = "Add focused regression coverage for the PR #26 Codecov patch gaps in bracket reference validation and project support .gitignore handling, without changing user-visible behavior." + +[[content.acceptance_criteria]] +text = "Bracket reference validation patch paths are covered by focused tests" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "Project support .gitignore patch paths are covered by focused tests" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "govctl check and relevant coverage tests pass" +status = "done" +category = "chore" diff --git a/src/cmd/check.rs b/src/cmd/check.rs index cb634a29..36acda94 100644 --- a/src/cmd/check.rs +++ b/src/cmd/check.rs @@ -85,7 +85,7 @@ pub(crate) fn collect_diagnostics( )); } all_diagnostics.extend(installed_schema_diagnostics(config)); - all_diagnostics.extend(crate::cmd::project_support::local_state_gitignore_diagnostics()); + all_diagnostics.extend(crate::cmd::project_support::local_state_gitignore_diagnostics(config)); // Load project (with warnings for parse errors) let load_result = match load_project_with_warnings(config) { diff --git a/src/cmd/migrate/mod.rs b/src/cmd/migrate/mod.rs index a12ff85d..7c029773 100644 --- a/src/cmd/migrate/mod.rs +++ b/src/cmd/migrate/mod.rs @@ -47,7 +47,7 @@ pub fn migrate(config: &Config, op: WriteOp) -> DiagnosticResult { // Always sync bundled JSON Schemas regardless of schema version. [[ADR-0035]] let schemas_synced = sync_schemas(config, op)?; let gitignore_entries_synced = - crate::cmd::project_support::ensure_local_state_gitignore_entries(op)?; + crate::cmd::project_support::ensure_local_state_gitignore_entries(config, op)?; let current = config.schema.version; if current >= CURRENT_SCHEMA_VERSION { diff --git a/src/cmd/new/mod.rs b/src/cmd/new/mod.rs index be79a155..ef1bd6c1 100644 --- a/src/cmd/new/mod.rs +++ b/src/cmd/new/mod.rs @@ -73,7 +73,7 @@ pub fn init_project(config: &Config, force: bool, op: WriteOp) -> DiagnosticResu } // Ensure .gitignore contains local govctl state entries. - crate::cmd::project_support::ensure_local_state_gitignore_entries(op)?; + crate::cmd::project_support::ensure_local_state_gitignore_entries(config, op)?; if !op.is_preview() { ui::success("Project initialized"); diff --git a/src/cmd/project_support.rs b/src/cmd/project_support.rs index 58911c5a..4f3da904 100644 --- a/src/cmd/project_support.rs +++ b/src/cmd/project_support.rs @@ -1,5 +1,6 @@ //! Project support file synchronization shared by init, migrate, and check. +use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult, Diagnostics}; use crate::ui; use crate::write::{WriteOp, write_file}; @@ -11,8 +12,12 @@ const LOCAL_STATE_GITIGNORE_ENTRIES: &[&str] = &[".govctl.lock", ".govctl/"]; // Implements [[RFC-0002:C-GLOBAL-COMMANDS]]: migrate refreshes local-state // .gitignore entries regardless of schema version. -pub(crate) fn ensure_local_state_gitignore_entries(op: WriteOp) -> DiagnosticResult { - let gitignore_path = gitignore_path(); +pub(crate) fn ensure_local_state_gitignore_entries( + config: &Config, + op: WriteOp, +) -> DiagnosticResult { + let gitignore_path = gitignore_path(config); + let display_path = config.display_path(&gitignore_path); match std::fs::read_to_string(&gitignore_path) { Ok(content) => { @@ -27,7 +32,7 @@ pub(crate) fn ensure_local_state_gitignore_entries(op: WriteOp) -> DiagnosticRes } else { format!("{content}\n{missing_content}\n") }; - write_file(&gitignore_path, &new_content, op, None)?; + write_file(&gitignore_path, &new_content, op, Some(&display_path))?; if !op.is_preview() { ui::info(format!( "Added local govctl state entries to .gitignore: {}", @@ -41,24 +46,25 @@ pub(crate) fn ensure_local_state_gitignore_entries(op: WriteOp) -> DiagnosticRes "# govctl local state\n{}\n", LOCAL_STATE_GITIGNORE_ENTRIES.join("\n") ); - write_file(&gitignore_path, &content, op, None)?; + write_file(&gitignore_path, &content, op, Some(&display_path))?; if !op.is_preview() { - ui::created_path(&gitignore_path); + ui::created_path(&display_path); } Ok(LOCAL_STATE_GITIGNORE_ENTRIES.len()) } Err(err) => Err(Diagnostic::io_error( "read .gitignore", err, - gitignore_path.display().to_string(), + display_path.display().to_string(), )), } } // Implements [[RFC-0002:C-GLOBAL-COMMANDS]]: check warns when govctl-managed // local-state .gitignore entries are missing or outdated. -pub(crate) fn local_state_gitignore_diagnostics() -> Diagnostics { - let gitignore_path = gitignore_path(); +pub(crate) fn local_state_gitignore_diagnostics(config: &Config) -> Diagnostics { + let gitignore_path = gitignore_path(config); + let display_path = config.display_path(&gitignore_path); let missing_entries = match std::fs::read_to_string(&gitignore_path) { Ok(content) => missing_local_state_gitignore_entries(&content), Err(err) if err.kind() == ErrorKind::NotFound => LOCAL_STATE_GITIGNORE_ENTRIES.to_vec(), @@ -66,7 +72,7 @@ pub(crate) fn local_state_gitignore_diagnostics() -> Diagnostics { return vec![Diagnostic::io_error( "read .gitignore", err, - gitignore_path.display().to_string(), + display_path.display().to_string(), )]; } }; @@ -81,12 +87,12 @@ pub(crate) fn local_state_gitignore_diagnostics() -> Diagnostics { "Local govctl state entries missing from .gitignore: {}. Run `govctl migrate` to refresh local-state .gitignore entries.", missing_entries.join(", ") ), - gitignore_path.display().to_string(), + display_path.display().to_string(), )] } -fn gitignore_path() -> PathBuf { - PathBuf::from(".gitignore") +fn gitignore_path(config: &Config) -> PathBuf { + config.project_root().join(".gitignore") } fn missing_local_state_gitignore_entries(content: &str) -> Vec<&'static str> { diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 1fe674c2..b8ee9504 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -56,6 +56,14 @@ impl Config { } } + /// Project root directory, derived as the parent of `gov/`. + pub fn project_root(&self) -> &Path { + self.gov_root + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + } + pub fn rfc_dir(&self) -> PathBuf { self.gov_root.join("rfc") } @@ -116,10 +124,8 @@ impl Config { /// Path for user-facing display: relative to project root when under it. pub fn display_path(&self, path: &Path) -> PathBuf { - self.gov_root - .parent() - .and_then(|root| path.strip_prefix(root).ok()) + path.strip_prefix(self.project_root()) .map(PathBuf::from) - .unwrap_or_else(|| path.to_path_buf()) + .unwrap_or_else(|_| path.to_path_buf()) } } diff --git a/src/scan.rs b/src/scan.rs index a8f3412a..ee22d81d 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -61,8 +61,10 @@ pub fn scan_source_refs(config: &Config, index: &ProjectIndex) -> ScanResult { } }; - // Walk from current directory, filter by include/exclude - let files = WalkDir::new(".") + let project_root = config.project_root(); + + // Walk from project root, filter by project-relative include/exclude globs. + let files = WalkDir::new(project_root) .follow_links(false) .into_iter() .filter_map(|e| e.ok()) @@ -70,8 +72,7 @@ pub fn scan_source_refs(config: &Config, index: &ProjectIndex) -> ScanResult { for entry in files { let path = entry.path(); - // Strip leading "./" for glob matching - let match_path = path.strip_prefix("./").unwrap_or(path); + let match_path = path.strip_prefix(project_root).unwrap_or(path); // Check include/exclude if !include_set.is_match(match_path) || exclude_set.is_match(match_path) { diff --git a/tests/error_tests/rfc_clause_cases/check.rs b/tests/error_tests/rfc_clause_cases/check.rs index 9d343016..185bf813 100644 --- a/tests/error_tests/rfc_clause_cases/check.rs +++ b/tests/error_tests/rfc_clause_cases/check.rs @@ -337,6 +337,50 @@ consequences = "Consequences" Ok(()) } +#[test] +fn test_proposed_adr_alternative_plain_text_known_rfc_references_warn() -> common::TestResult { + let temp_dir = init_project()?; + write_minimal_rfc(temp_dir.path(), "RFC-0001", "Known RFC")?; + + write_adr_toml( + temp_dir.path(), + r#"[govctl] +schema = 1 +id = "ADR-0001" +title = "Decision" +status = "proposed" +date = "2026-01-01" +refs = ["RFC-0001"] + +[content] +context = "Context" +decision = "Decision" +consequences = "Consequences" + +[[content.alternatives]] +text = "This alternative follows RFC-0001." +pros = ["RFC-0001 gives this option clear authority."] +cons = ["RFC-0001 adds migration pressure."] +rejection_reason = "Rejected after comparing with RFC-0001." +"#, + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert_eq!( + output.matches("warning[W0112]").count(), + 4, + "output: {}", + output + ); + assert!( + output.contains("Artifact 'ADR-0001' mentions known artifact ID RFC-0001"), + "output: {}", + output + ); + assert!(output.contains("exit: 0"), "output: {}", output); + Ok(()) +} + #[test] fn test_adr_bracketed_known_rfc_reference_does_not_warn() -> common::TestResult { let temp_dir = init_project()?; diff --git a/tests/error_tests/schema.rs b/tests/error_tests/schema.rs index f71bf0c3..33347d87 100644 --- a/tests/error_tests/schema.rs +++ b/tests/error_tests/schema.rs @@ -247,3 +247,30 @@ fn test_check_reports_missing_local_state_gitignore_entry() -> common::TestResul assert!(output.contains("govctl migrate"), "output: {}", output); Ok(()) } + +#[test] +fn test_check_uses_root_gitignore_when_run_from_subdirectory() -> common::TestResult { + let temp_dir = init_project()?; + let docs_dir = temp_dir.path().join("docs"); + fs::create_dir_all(&docs_dir)?; + fs::write(docs_dir.join(".gitignore"), "book/\n")?; + + let output = run_commands(&docs_dir, &[&["check"]])?; + assert!(!output.contains("warning[W0111]"), "output: {}", output); + Ok(()) +} + +#[test] +fn test_check_reports_gitignore_read_error() -> common::TestResult { + let temp_dir = init_project()?; + let gitignore_path = temp_dir.path().join(".gitignore"); + fs::remove_file(&gitignore_path)?; + fs::create_dir(&gitignore_path)?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(output.contains("error"), "output: {}", output); + assert!(output.contains("read .gitignore"), "output: {}", output); + assert!(output.contains(".gitignore"), "output: {}", output); + assert!(output.contains("exit: 1"), "output: {}", output); + Ok(()) +} diff --git a/tests/error_tests/work.rs b/tests/error_tests/work.rs index 26ec147d..56304a4a 100644 --- a/tests/error_tests/work.rs +++ b/tests/error_tests/work.rs @@ -111,6 +111,87 @@ category = "chore" Ok(()) } +#[test] +fn test_work_acceptance_and_notes_plain_text_known_rfc_reference_warns() -> common::TestResult { + let temp_dir = init_project()?; + write_minimal_rfc(temp_dir.path(), "RFC-0001", "Known RFC")?; + + fs::write( + temp_dir + .path() + .join("gov/work/2026-01-01-bare-reference-fields.toml"), + r#"[govctl] +schema = 1 +id = "WI-2026-01-01-001" +title = "Bare Reference Fields" +status = "active" +created = "2026-01-01" +started = "2026-01-01" +refs = ["RFC-0001"] + +[content] +description = "Work description." +notes = ["Keep RFC-0001 in mind for closure."] + +[[content.acceptance_criteria]] +text = "Complete the RFC-0001 follow-up" +status = "pending" +category = "chore" +"#, + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert_eq!( + output.matches("warning[W0112]").count(), + 2, + "output: {}", + output + ); + assert!( + output.contains("Artifact 'WI-2026-01-01-001' mentions known artifact ID RFC-0001"), + "output: {}", + output + ); + assert!(output.contains("exit: 0"), "output: {}", output); + Ok(()) +} + +#[test] +fn test_done_work_plain_text_known_rfc_reference_is_allowed() -> common::TestResult { + let temp_dir = init_project()?; + write_minimal_rfc(temp_dir.path(), "RFC-0001", "Known RFC")?; + + fs::write( + temp_dir + .path() + .join("gov/work/2026-01-01-done-bare-reference.toml"), + r#"[govctl] +schema = 1 +id = "WI-2026-01-01-001" +title = "Done Bare Reference" +status = "done" +created = "2026-01-01" +started = "2026-01-01" +completed = "2026-01-01" +refs = ["RFC-0001"] + +[content] +description = "This completed work followed RFC-0001." +notes = ["RFC-0001 remains useful historical context."] + +[[content.acceptance_criteria]] +text = "Completed the RFC-0001 follow-up" +status = "done" +category = "chore" +"#, + )?; + + let output = run_commands(temp_dir.path(), &[&["check"]])?; + assert!(!output.contains("warning[W0112]"), "output: {}", output); + assert!(output.contains("exit: 0"), "output: {}", output); + Ok(()) +} + #[test] fn test_check_rejects_unknown_work_dependency() -> common::TestResult { let temp_dir = init_project()?; diff --git a/tests/test_init.rs b/tests/test_init.rs index 81eb1def..31432803 100644 --- a/tests/test_init.rs +++ b/tests/test_init.rs @@ -95,6 +95,35 @@ fn test_init_appends_to_existing_gitignore() -> common::TestResult { Ok(()) } +#[test] +fn test_init_appends_to_existing_gitignore_without_trailing_newline() -> common::TestResult { + let temp_dir = TempDir::new()?; + + let gitignore_path = temp_dir.path().join(".gitignore"); + fs::write(&gitignore_path, "target/")?; + + let output = run_commands(temp_dir.path(), &[&["init"]])?; + assert!(output.contains("Project initialized")); + + let content = fs::read_to_string(&gitignore_path)?; + assert_eq!(content, "target/\n.govctl.lock\n.govctl/\n"); + Ok(()) +} + +#[test] +fn test_init_reports_gitignore_read_error() -> common::TestResult { + let temp_dir = TempDir::new()?; + + fs::create_dir(temp_dir.path().join(".gitignore"))?; + + let output = run_commands(temp_dir.path(), &[&["init"]])?; + assert!(output.contains("error"), "output: {}", output); + assert!(output.contains("read .gitignore"), "output: {}", output); + assert!(output.contains(".gitignore"), "output: {}", output); + assert!(output.contains("exit: 1"), "output: {}", output); + Ok(()) +} + #[test] fn test_init_no_duplicate_gitignore_entry() -> common::TestResult { let temp_dir = TempDir::new()?; diff --git a/tests/test_scan.rs b/tests/test_scan.rs index 1ea8821b..024635af 100644 --- a/tests/test_scan.rs +++ b/tests/test_scan.rs @@ -80,6 +80,33 @@ fn test_scan_valid_rfc_reference() -> common::TestResult { assert_scan_check_snapshot!(temp_dir, &date) } +#[test] +fn test_scan_uses_project_root_when_run_from_subdirectory() -> common::TestResult { + let (temp_dir, _) = init_source_scan_project()?; + + create_normative_rfc(temp_dir.path(), "RFC-0001", "Test RFC")?; + write_main_rs( + temp_dir.path(), + "// Implements [[RFC-0001]]\nfn main() {}\n", + )?; + + let docs_dir = temp_dir.path().join("docs"); + fs::create_dir_all(&docs_dir)?; + let output = run_commands(&docs_dir, &[&["check"]])?; + + assert!( + output.contains(" 1 source files scanned"), + "output: {}", + output + ); + assert!( + output.contains(" 1 references found"), + "output: {}", + output + ); + Ok(()) +} + #[test] fn test_scan_valid_clause_reference() -> common::TestResult { let (temp_dir, date) = init_source_scan_project()?;