Skip to content

Commit d9ea874

Browse files
montfortclaude
andcommitted
feat: add --staged flag to devtrail validate for pre-commit hooks
Replaces pre-commit-docs.sh (464 lines) with native CLI support. Adds validate_paths() for partial validation of git-staged files, skipping orphan checks. Makes detect_doc_type() public. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 19c2a7b commit d9ea874

6 files changed

Lines changed: 266 additions & 19 deletions

File tree

cli/src/commands/init.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ pub fn run(path: &str) -> Result<()> {
7070
println!(" 2. Check DEVTRAIL.md for governance rules");
7171
println!(
7272
" 3. Run {} to validate your setup",
73-
"bash scripts/pre-commit-docs.sh".cyan()
73+
"devtrail validate".cyan()
7474
);
7575
println!(
7676
" 4. Commit: {}",

cli/src/commands/validate.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
use anyhow::Result;
1+
use anyhow::{bail, Result};
22
use colored::Colorize;
33
use std::collections::BTreeMap;
44
use std::path::PathBuf;
55

66
use crate::utils;
77
use crate::validation::{self, Severity, ValidationIssue};
88

9-
pub fn run(path: &str, fix: bool) -> Result<()> {
9+
pub fn run(path: &str, fix: bool, staged: bool) -> Result<()> {
1010
let resolved = match utils::resolve_project_root(path) {
1111
Some(r) => r,
1212
None => {
@@ -32,6 +32,11 @@ pub fn run(path: &str, fix: bool) -> Result<()> {
3232
let target = resolved.path;
3333
let devtrail_dir = target.join(".devtrail");
3434

35+
// --staged mode: validate only git-staged .devtrail/ documents
36+
if staged {
37+
return run_staged(&target, &devtrail_dir);
38+
}
39+
3540
// Header
3641
println!();
3742
println!(" {}", "DevTrail Validate".bold().cyan());
@@ -46,7 +51,7 @@ pub fn run(path: &str, fix: bool) -> Result<()> {
4651
println!(
4752
" {} Create documents with {} or {}",
4853
"→".blue().bold(),
49-
"devtrail-new".cyan(),
54+
"devtrail new".cyan(),
5055
"/devtrail-new".cyan()
5156
);
5257
println!();
@@ -66,6 +71,58 @@ pub fn run(path: &str, fix: bool) -> Result<()> {
6671
exit_with_code(&result)
6772
}
6873

74+
fn run_staged(project_root: &std::path::Path, devtrail_dir: &std::path::Path) -> Result<()> {
75+
// Get staged files from git
76+
let output = std::process::Command::new("git")
77+
.args(["diff", "--cached", "--name-only"])
78+
.current_dir(project_root)
79+
.output();
80+
81+
let output = match output {
82+
Ok(o) if o.status.success() => o,
83+
_ => {
84+
bail!("Not a git repository or git is not available. --staged requires a git repo.");
85+
}
86+
};
87+
88+
let stdout = String::from_utf8_lossy(&output.stdout);
89+
let staged_paths: Vec<PathBuf> = stdout
90+
.lines()
91+
.filter(|line| line.starts_with(".devtrail/") && line.ends_with(".md"))
92+
.map(|line| project_root.join(line))
93+
.collect();
94+
95+
if staged_paths.is_empty() {
96+
println!(
97+
" {} No staged documentation to validate.",
98+
"✓".green().bold()
99+
);
100+
return Ok(());
101+
}
102+
103+
// Header
104+
println!();
105+
println!(" {}", "DevTrail Validate (staged)".bold().cyan());
106+
println!(
107+
" {} file(s)",
108+
staged_paths.len().to_string().dimmed()
109+
);
110+
println!();
111+
112+
let (result, doc_count) = validation::validate_paths(&staged_paths, devtrail_dir);
113+
114+
if doc_count == 0 {
115+
println!(
116+
" {} No DevTrail documents among staged files.",
117+
"✓".green().bold()
118+
);
119+
return Ok(());
120+
}
121+
122+
print_results(&result, doc_count);
123+
exit_with_code(&result)
124+
}
125+
69126
fn apply_fixes(devtrail_dir: &std::path::Path) {
70127
let paths = crate::document::discover_documents(devtrail_dir);
71128
let mut fixed_count = 0;

cli/src/document.rs

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,68 @@ impl DocType {
6363
"AILOG", "AIDEC", "ADR", "ETH", "REQ", "TES", "INC", "TDE",
6464
"SEC", "MCARD", "SBOM", "DPIA",
6565
];
66+
67+
/// All DocType variants in display order
68+
pub const ALL: &'static [DocType] = &[
69+
DocType::Ailog, DocType::Aidec, DocType::Adr, DocType::Eth,
70+
DocType::Req, DocType::Tes, DocType::Inc, DocType::Tde,
71+
DocType::Sec, DocType::Mcard, DocType::Sbom, DocType::Dpia,
72+
];
73+
74+
/// Human-readable display name
75+
pub fn display_name(&self) -> &'static str {
76+
match self {
77+
DocType::Ailog => "AI Action Log",
78+
DocType::Aidec => "AI Decision",
79+
DocType::Adr => "Architecture Decision Record",
80+
DocType::Eth => "Ethical Review",
81+
DocType::Req => "Requirement",
82+
DocType::Tes => "Test Plan",
83+
DocType::Inc => "Incident Post-mortem",
84+
DocType::Tde => "Technical Debt",
85+
DocType::Sec => "Security Assessment",
86+
DocType::Mcard => "Model/System Card",
87+
DocType::Sbom => "Software Bill of Materials",
88+
DocType::Dpia => "Data Protection Impact Assessment",
89+
}
90+
}
91+
92+
/// Subdirectory under .devtrail/ where this document type lives
93+
pub fn directory(&self) -> &'static str {
94+
match self {
95+
DocType::Ailog => "07-ai-audit/agent-logs",
96+
DocType::Aidec => "07-ai-audit/decisions",
97+
DocType::Eth => "07-ai-audit/ethical-reviews",
98+
DocType::Adr => "02-design/decisions",
99+
DocType::Req => "01-requirements",
100+
DocType::Tes => "04-testing",
101+
DocType::Inc => "05-operations/incidents",
102+
DocType::Tde => "06-evolution/technical-debt",
103+
DocType::Sec => "08-security",
104+
DocType::Mcard => "09-ai-models",
105+
DocType::Sbom => "07-ai-audit",
106+
DocType::Dpia => "07-ai-audit/ethical-reviews",
107+
}
108+
}
109+
110+
/// Parse a DocType from a user-provided string (case-insensitive)
111+
pub fn from_str_loose(s: &str) -> Option<DocType> {
112+
match s.to_lowercase().as_str() {
113+
"ailog" => Some(DocType::Ailog),
114+
"aidec" => Some(DocType::Aidec),
115+
"adr" => Some(DocType::Adr),
116+
"eth" => Some(DocType::Eth),
117+
"req" => Some(DocType::Req),
118+
"tes" => Some(DocType::Tes),
119+
"inc" => Some(DocType::Inc),
120+
"tde" => Some(DocType::Tde),
121+
"sec" => Some(DocType::Sec),
122+
"mcard" => Some(DocType::Mcard),
123+
"sbom" => Some(DocType::Sbom),
124+
"dpia" => Some(DocType::Dpia),
125+
_ => None,
126+
}
127+
}
66128
}
67129

68130
impl fmt::Display for DocType {
@@ -154,7 +216,7 @@ pub fn parse_document(path: &Path) -> Result<DevTrailDocument> {
154216
}
155217

156218
/// Detect document type from filename prefix
157-
fn detect_doc_type(filename: &str) -> Option<DocType> {
219+
pub fn detect_doc_type(filename: &str) -> Option<DocType> {
158220
for prefix in DocType::ALL_PREFIXES {
159221
if filename.starts_with(&format!("{}-", prefix)) {
160222
return DocType::from_prefix(prefix);
@@ -276,4 +338,37 @@ mod tests {
276338
assert_eq!(fm.title.as_deref(), Some("Test"));
277339
assert!(body.contains("# Body"));
278340
}
341+
342+
#[test]
343+
fn test_doc_type_all_has_12_entries() {
344+
assert_eq!(DocType::ALL.len(), 12);
345+
assert_eq!(DocType::ALL_PREFIXES.len(), 12);
346+
}
347+
348+
#[test]
349+
fn test_doc_type_directory_mapping() {
350+
for dt in DocType::ALL {
351+
let dir = dt.directory();
352+
assert!(!dir.is_empty(), "{} has empty directory", dt.prefix());
353+
assert!(!dir.starts_with('/'), "{} directory should be relative", dt.prefix());
354+
}
355+
}
356+
357+
#[test]
358+
fn test_doc_type_display_names() {
359+
for dt in DocType::ALL {
360+
let name = dt.display_name();
361+
assert!(!name.is_empty(), "{} has empty display_name", dt.prefix());
362+
}
363+
}
364+
365+
#[test]
366+
fn test_doc_type_from_str_loose() {
367+
assert_eq!(DocType::from_str_loose("ailog"), Some(DocType::Ailog));
368+
assert_eq!(DocType::from_str_loose("AILOG"), Some(DocType::Ailog));
369+
assert_eq!(DocType::from_str_loose("AiLog"), Some(DocType::Ailog));
370+
assert_eq!(DocType::from_str_loose("sec"), Some(DocType::Sec));
371+
assert_eq!(DocType::from_str_loose("mcard"), Some(DocType::Mcard));
372+
assert_eq!(DocType::from_str_loose("invalid"), None);
373+
}
279374
}

cli/src/main.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ enum Commands {
6868
/// Automatically fix simple issues
6969
#[arg(long)]
7070
fix: bool,
71+
/// Validate only git-staged files (for pre-commit hooks)
72+
#[arg(long)]
73+
staged: bool,
7174
},
7275
/// Check regulatory compliance (EU AI Act, ISO 42001, NIST AI RMF)
7376
Compliance {
@@ -114,6 +117,18 @@ enum Commands {
114117
#[arg(long, default_value = "text", value_parser = ["text", "markdown", "json"])]
115118
output: String,
116119
},
120+
/// Create a new DevTrail document from a template
121+
New {
122+
/// Target directory (default: current directory)
123+
#[arg(default_value = ".")]
124+
path: String,
125+
/// Document type (e.g., ailog, adr, sec)
126+
#[arg(long, short = 't')]
127+
doc_type: Option<String>,
128+
/// Document title
129+
#[arg(long)]
130+
title: Option<String>,
131+
},
117132
/// Show version, author, and license information
118133
About,
119134
/// Analyze code complexity using cognitive and cyclomatic metrics
@@ -153,7 +168,7 @@ fn main() {
153168
Commands::UpdateFramework => commands::update_framework::run(),
154169
Commands::UpdateCli => commands::update_cli::run(),
155170
Commands::Remove { full } => commands::remove::run(full),
156-
Commands::Validate { path, fix } => commands::validate::run(&path, fix),
171+
Commands::Validate { path, fix, staged } => commands::validate::run(&path, fix, staged),
157172
Commands::Audit {
158173
path,
159174
from,
@@ -172,6 +187,11 @@ fn main() {
172187
period,
173188
output,
174189
} => commands::metrics::run(&path, &period, &output),
190+
Commands::New {
191+
path,
192+
doc_type,
193+
title,
194+
} => commands::new::run(&path, doc_type.as_deref(), title.as_deref()),
175195
Commands::Status { path } => commands::status::run(&path),
176196
Commands::Repair { path } => commands::repair::run(&path),
177197
Commands::About => commands::about::run(),

cli/src/validation.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,44 @@ pub fn validate_all(devtrail_dir: &Path) -> (ValidationResult, usize) {
8484
(result, doc_count)
8585
}
8686

87+
/// Validate a specific set of document paths (used for --staged mode).
88+
/// Skips orphan document checking since that is not meaningful for partial validation.
89+
pub fn validate_paths(paths: &[PathBuf], devtrail_dir: &Path) -> (ValidationResult, usize) {
90+
let mut result = ValidationResult::default();
91+
let mut doc_count = 0;
92+
93+
for path in paths {
94+
if !path.exists() {
95+
continue;
96+
}
97+
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
98+
if document::detect_doc_type(filename).is_none() {
99+
continue;
100+
}
101+
match document::parse_document(path) {
102+
Ok(doc) => {
103+
doc_count += 1;
104+
result.merge(validate_document(&doc, devtrail_dir));
105+
}
106+
Err(e) => {
107+
doc_count += 1;
108+
result.errors.push(ValidationIssue {
109+
file: path.clone(),
110+
rule: "PARSE-001".to_string(),
111+
message: format!("Failed to parse document: {e}"),
112+
severity: Severity::Error,
113+
fix_hint: Some(
114+
"Check that the file has valid YAML frontmatter between --- delimiters"
115+
.to_string(),
116+
),
117+
});
118+
}
119+
}
120+
}
121+
122+
(result, doc_count)
123+
}
124+
87125
/// REF-002: Check for documents with no traceability links.
88126
/// A document is orphan if it has no `related` field AND is not referenced
89127
/// by any other document's `related` field.

cli/tests/validate_test.rs

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -417,24 +417,61 @@ fn test_new_templates_exist_in_dist() {
417417
assert!(devtrail.join("09-ai-models").exists(), "09-ai-models/ directory missing");
418418
}
419419

420-
/// F2.QA.02.02 — Verify devtrail-new.sh has all 12 types configured
420+
/// F2.QA.02.02 — Verify devtrail new supports all 12 document types via DocType::ALL
421421
#[test]
422-
fn test_devtrail_new_script_has_all_types() {
423-
let script_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
424-
.parent()
425-
.unwrap()
426-
.join("dist/scripts/devtrail-new.sh");
422+
fn test_new_supports_all_doc_types() {
423+
let source_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
424+
.join("src/document.rs");
427425

428-
let content = std::fs::read_to_string(&script_path).expect("Cannot read devtrail-new.sh");
426+
let content = std::fs::read_to_string(&source_path).expect("Cannot read document.rs");
429427

430-
// Verify all 12 types are in DOC_PATHS
431-
for doc_type in &["ailog", "aidec", "adr", "eth", "req", "tes", "inc", "tde", "sec", "mcard", "sbom", "dpia"] {
428+
for doc_type in &["Ailog", "Aidec", "Adr", "Eth", "Req", "Tes", "Inc", "Tde", "Sec", "Mcard", "Sbom", "Dpia"] {
432429
assert!(
433-
content.contains(&format!("[\"{}\"]", doc_type)),
434-
"devtrail-new.sh missing DOC_PATHS entry for '{}'", doc_type
430+
content.contains(&format!("DocType::{}", doc_type)),
431+
"document.rs missing DocType::{}", doc_type
435432
);
436433
}
434+
}
435+
436+
#[test]
437+
fn test_validate_staged_no_git_repo() {
438+
let dir = tempfile::TempDir::new().unwrap();
439+
440+
// Create minimal .devtrail/
441+
let devtrail = dir.path().join(".devtrail");
442+
std::fs::create_dir_all(&devtrail).unwrap();
443+
std::fs::write(devtrail.join("config.yml"), "language: en\n").unwrap();
444+
445+
let mut cmd = Command::cargo_bin("devtrail").unwrap();
446+
cmd.arg("validate")
447+
.arg("--staged")
448+
.arg(dir.path().to_str().unwrap())
449+
.assert()
450+
.failure()
451+
.stderr(predicates::str::contains("git"));
452+
}
453+
454+
#[test]
455+
fn test_validate_staged_no_staged_docs() {
456+
let dir = tempfile::TempDir::new().unwrap();
457+
458+
// Init a git repo
459+
std::process::Command::new("git")
460+
.args(["init"])
461+
.current_dir(dir.path())
462+
.output()
463+
.unwrap();
464+
465+
// Create .devtrail/
466+
let devtrail = dir.path().join(".devtrail");
467+
std::fs::create_dir_all(&devtrail).unwrap();
468+
std::fs::write(devtrail.join("config.yml"), "language: en\n").unwrap();
437469

438-
// Verify the menu has 12 options
439-
assert!(content.contains("1-12 or name"), "devtrail-new.sh menu does not show 1-12 range");
470+
let mut cmd = Command::cargo_bin("devtrail").unwrap();
471+
cmd.arg("validate")
472+
.arg("--staged")
473+
.arg(dir.path().to_str().unwrap())
474+
.assert()
475+
.success()
476+
.stdout(predicates::str::contains("No staged documentation"));
440477
}

0 commit comments

Comments
 (0)