Skip to content

Commit 205a433

Browse files
corvid-agentclaude0xGaspar
authored
feat: declarative custom validation rules (#190)
* feat: declarative custom validation rules (#157) Add a `customRules` array in specsync.json for user-defined validation rules. Four rule types: require_section, min_word_count, require_pattern, forbid_pattern. Rules support severity levels (error/warning/info) and appliesTo filters (status, module regex). New `specsync rules` command lists all active built-in and custom rules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: document new custom rule exports in specs Add CustomRule, CustomRuleType, RuleSeverity, RuleFilter to types spec and rules submodule to commands spec to resolve --strict warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add spec for specsync rules command Adds cmd_rules spec with companion files (tasks.md, context.md) covering the new declarative rules listing command. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add missing requirements.md for cmd_rules spec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: fix cargo fmt check (extra blank line in integration.rs) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Gaspar.Algo <bruno.placides@proton.me> Co-authored-by: CorvidAgent <corvid-agent@users.noreply.github.com>
1 parent 4127473 commit 205a433

14 files changed

Lines changed: 763 additions & 5 deletions

File tree

specs/cmd_rules/cmd_rules.spec.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
module: cmd_rules
3+
version: 1
4+
status: stable
5+
files:
6+
- src/commands/rules.rs
7+
db_tables: []
8+
tracks: []
9+
depends_on:
10+
- specs/commands/commands.spec.md
11+
- specs/config/config.spec.md
12+
- specs/types/types.spec.md
13+
---
14+
15+
# Cmd Rules
16+
17+
## Purpose
18+
19+
Implements the `specsync rules` command. Lists all active validation rules — both built-in (from `specsync.json` `rules` section) and custom declarative rules (from `customRules` array). Shows configuration status, severity, rule type, and filter criteria.
20+
21+
## Public API
22+
23+
### Exported Functions
24+
25+
| Function | Parameters | Returns | Description |
26+
|----------|-----------|---------|-------------|
27+
| `cmd_rules` | `root: &Path` | `()` | Load config and display all built-in and custom validation rules |
28+
29+
### Internal Functions
30+
31+
| Function | Parameters | Returns | Description |
32+
|----------|-----------|---------|-------------|
33+
| `print_builtin` | `name: &str, description: &str, value: Option<String>` | `()` | Print a built-in rule with its active/off status |
34+
35+
## Invariants
36+
37+
1. Built-in rules always display, showing "active" with value when configured or "off" when unset
38+
2. Five built-in rules listed: `max_changelog_entries`, `require_behavioral_examples`, `min_invariants`, `max_spec_size_kb`, `require_depends_on`
39+
3. Custom rules section only displays when `customRules` is non-empty; otherwise shows guidance to add them
40+
4. Each custom rule displays name, severity (color-coded), type, and optional section/pattern/min_words/applies_to/message fields
41+
5. Severity colors: error → red, warning → yellow, info → blue
42+
43+
## Behavioral Examples
44+
45+
### Scenario: No custom rules defined
46+
47+
- **Given** `specsync.json` has no `customRules` array
48+
- **When** `specsync rules` runs
49+
- **Then** built-in rules are listed, followed by "No custom rules defined." with guidance text
50+
51+
### Scenario: Custom rules with filters
52+
53+
- **Given** a custom rule with `appliesTo: { status: "stable", module: "^auth" }`
54+
- **When** `specsync rules` runs
55+
- **Then** the rule shows `applies_to: status=stable, module=/^auth/`
56+
57+
## Error Cases
58+
59+
| Condition | Behavior |
60+
|-----------|----------|
61+
| Missing `specsync.json` | Config loader handles this (not this module's concern) |
62+
63+
## Dependencies
64+
65+
### Consumes
66+
67+
| Module | What is used |
68+
|--------|-------------|
69+
| config | `load_config` |
70+
| types | `CustomRuleType`, `RuleSeverity` |
71+
72+
### Consumed By
73+
74+
| Module | What is used |
75+
|--------|-------------|
76+
| cli (main.rs) | Entry point for `specsync rules` |
77+
78+
## Change Log
79+
80+
| Date | Change |
81+
|------|--------|
82+
| 2026-04-10 | Initial spec |

specs/cmd_rules/context.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# cmd_rules — Context
2+
3+
## Design Decisions
4+
5+
- **Read-only command**: `rules` is purely informational — it reads config and displays, never modifies state.
6+
- **Built-in rules always shown**: Even when all are "off", they're listed so users know what's available to configure.
7+
- **Color-coded severity**: Matches the color scheme used in `specsync check` output for consistency.
8+
9+
## Related
10+
11+
- Custom rules are defined in `specsync.json` under the `customRules` key.
12+
- Custom rule validation logic lives in `src/validator.rs`, not in this command module.

specs/cmd_rules/requirements.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
spec: cmd_rules.spec.md
3+
---
4+
5+
## User Stories
6+
7+
- As a developer, I want to see all active validation rules (built-in and custom) so I can understand what checks run during `specsync check`
8+
- As a CI operator, I want clear output showing rule severity and filter criteria so I can configure rules appropriate for my project
9+
10+
## Acceptance Criteria
11+
12+
- All exported functions perform their documented purpose
13+
- Built-in rules are always listed with their active/off status
14+
- Custom rules display name, type, severity, and filter criteria when defined
15+
- Error conditions produce clear, actionable messages
16+
- Module follows the project's established patterns for config loading and output formatting
17+
18+
## Constraints
19+
20+
- Must not panic on expected error conditions — return Results or print and exit
21+
- Must work with the project's Clap-based CLI argument parsing
22+
- Read-only command: must never modify config or spec files
23+
24+
## Out of Scope
25+
26+
- GUI or web interface
27+
- Interactive prompts (except wizard module)
28+
- Rule editing or creation (this command is display-only)

specs/cmd_rules/tasks.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# cmd_rules — Tasks
2+
3+
No open tasks.

specs/commands/commands.spec.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Shared command infrastructure used by all CLI subcommands. Provides config loadi
5858
| `new` | Quick-create minimal specs |
5959
| `report` | Per-module coverage report with staleness |
6060
| `resolve` | Resolve cross-project dependency refs |
61+
| `rules` | List active validation rules (built-in and custom) |
6162
| `stale` | Git-based staleness detection for spec drift |
6263
| `scaffold` | Full spec scaffolding with templates |
6364
| `score` | Spec quality scoring (0-100, A-F) |

specs/types/types.spec.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Core data structures and enums shared across the entire spec-sync codebase. Defi
2727
| `ExportLevel` | Export extraction granularity: Type (top-level declarations only) or Member (all public symbols, default) |
2828
| `SpecStatus` | Spec lifecycle status: draft, stable, deprecated. Parsed from frontmatter `status` field |
2929
| `EnforcementMode` | Graduated enforcement level: Warn (always exit 0), EnforceNew (exit 1 for unspecced files), Strict (exit 1 on any error) |
30+
| `CustomRuleType` | Type of a declarative custom validation rule: RequireSection, MinWordCount, RequirePattern, ForbidPattern |
31+
| `RuleSeverity` | Severity level for custom rules: Error, Warning (default), Info |
3032
| `ParseMode` | Export parsing strategy: Regex (default, all languages) or Ast (tree-sitter, supports TypeScript/Python/Rust with regex fallback) |
3133

3234
### Exported Structs
@@ -41,6 +43,8 @@ Core data structures and enums shared across the entire spec-sync codebase. Defi
4143
| `ModuleDefinition` | User-defined module grouping in specsync.json with files and depends_on lists |
4244
| `ValidationRules` | Custom validation rules configured in specsync.json (required_sections, max_staleness_days, etc.) |
4345
| `GitHubConfig` | GitHub integration config — `repo: Option<String>`, `labels: Vec<String>`, `create_on_drift: bool` |
46+
| `CustomRule` | A declarative custom validation rule defined in specsync.json — name, type, section, pattern, min_words, severity, message, applies_to filter |
47+
| `RuleFilter` | Filter to restrict which specs a custom rule applies to — optional status and module regex match |
4448

4549
### Exported AiProvider Functions
4650

@@ -132,7 +136,7 @@ Core data structures and enums shared across the entire spec-sync codebase. Defi
132136
|--------|-------------|
133137
| config | `SpecSyncConfig`, `AiProvider` |
134138
| parser | `Frontmatter` |
135-
| validator | `CoverageReport`, `ValidationResult`, `SpecSyncConfig` |
139+
| validator | `CoverageReport`, `ValidationResult`, `SpecSyncConfig`, `CustomRuleType`, `RuleSeverity`, `Frontmatter` |
136140
| generator | `CoverageReport`, `SpecSyncConfig` |
137141
| ai | `AiProvider`, `SpecSyncConfig` |
138142
| scoring | `SpecSyncConfig` |
@@ -152,3 +156,4 @@ Core data structures and enums shared across the entire spec-sync codebase. Defi
152156
| 2026-03-28 | Document OutputFormat, ExportLevel, ModuleDefinition |
153157
| 2026-04-06 | Add Frontmatter implements/tracks/agent_policy fields, ValidationRules, GitHubConfig structs |
154158
| 2026-04-07 | Document EnforcementMode enum |
159+
| 2026-04-10 | Document CustomRule, CustomRuleType, RuleSeverity, RuleFilter for declarative custom validation rules |

src/cli.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,8 @@ pub enum Command {
245245
#[arg(long, default_value = "main")]
246246
base: String,
247247
},
248+
/// List active validation rules (built-in and custom)
249+
Rules,
248250
/// Generate a changelog of spec changes between two git refs
249251
Changelog {
250252
/// Git ref range (e.g., v0.1..v0.2, HEAD~5..HEAD)

src/commands/mod.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub mod merge;
1616
pub mod new;
1717
pub mod report;
1818
pub mod resolve;
19+
pub mod rules;
1920
pub mod scaffold;
2021
pub mod score;
2122
pub mod stale;
@@ -320,6 +321,41 @@ pub fn run_validation(
320321
println!(" {} {w}", "⚠".yellow());
321322
}
322323

324+
// Custom rule violations and any other uncategorized warnings/errors
325+
let categorized_error_prefixes = [
326+
"Frontmatter",
327+
"Missing or malformed",
328+
"Source file",
329+
"DB table",
330+
"Schema column",
331+
"Missing required section",
332+
"Spec documents",
333+
"Dependency spec",
334+
];
335+
let categorized_warning_prefixes = [
336+
"exports documented",
337+
"Export '",
338+
"Undocumented export '",
339+
"Consumed By",
340+
"Schema column",
341+
];
342+
for e in result
343+
.errors
344+
.iter()
345+
.filter(|e| !categorized_error_prefixes.iter().any(|p| e.starts_with(p)))
346+
{
347+
println!(" {} {e}", "✗".red());
348+
}
349+
for w in warnings.iter().filter(|w| {
350+
!(categorized_warning_prefixes
351+
.iter()
352+
.any(|p| w.starts_with(p) || w.contains(p))
353+
|| (w.starts_with("Section ##") && w.contains("stub"))
354+
|| w.contains("requirements"))
355+
}) {
356+
println!(" {} {w}", "⚠".yellow());
357+
}
358+
323359
// Show fix suggestions when there are errors or warnings with fixes
324360
if !result.fixes.is_empty() && (!result.errors.is_empty() || !warnings.is_empty()) {
325361
println!(" {}", "Suggested fixes:".cyan());

src/commands/rules.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
use colored::Colorize;
2+
use std::path::Path;
3+
4+
use crate::config::load_config;
5+
use crate::types::{CustomRuleType, RuleSeverity};
6+
7+
pub fn cmd_rules(root: &Path) {
8+
let config = load_config(root);
9+
10+
println!("{}", "Built-in rules:".bold());
11+
println!();
12+
print_builtin(
13+
"max_changelog_entries",
14+
"Warn if Change Log exceeds N entries",
15+
config.rules.max_changelog_entries.map(|n| n.to_string()),
16+
);
17+
print_builtin(
18+
"require_behavioral_examples",
19+
"Require at least one ### Scenario",
20+
config
21+
.rules
22+
.require_behavioral_examples
23+
.map(|b| b.to_string()),
24+
);
25+
print_builtin(
26+
"min_invariants",
27+
"Require at least N numbered invariants",
28+
config.rules.min_invariants.map(|n| n.to_string()),
29+
);
30+
print_builtin(
31+
"max_spec_size_kb",
32+
"Warn if spec file exceeds N KB",
33+
config.rules.max_spec_size_kb.map(|n| n.to_string()),
34+
);
35+
print_builtin(
36+
"require_depends_on",
37+
"Require non-empty depends_on",
38+
config.rules.require_depends_on.map(|b| b.to_string()),
39+
);
40+
println!();
41+
42+
if config.custom_rules.is_empty() {
43+
println!("{}", "No custom rules defined.".dimmed());
44+
println!(
45+
"{}",
46+
"Add \"customRules\" to specsync.json to define declarative rules.".dimmed()
47+
);
48+
return;
49+
}
50+
51+
println!(
52+
"{} ({} rule{}):",
53+
"Custom rules".bold(),
54+
config.custom_rules.len(),
55+
if config.custom_rules.len() == 1 {
56+
""
57+
} else {
58+
"s"
59+
}
60+
);
61+
println!();
62+
63+
for rule in &config.custom_rules {
64+
let severity_str = match rule.severity {
65+
RuleSeverity::Error => "error".red().to_string(),
66+
RuleSeverity::Warning => "warning".yellow().to_string(),
67+
RuleSeverity::Info => "info".blue().to_string(),
68+
};
69+
70+
let type_str = match rule.rule_type {
71+
CustomRuleType::RequireSection => "require_section",
72+
CustomRuleType::MinWordCount => "min_word_count",
73+
CustomRuleType::RequirePattern => "require_pattern",
74+
CustomRuleType::ForbidPattern => "forbid_pattern",
75+
};
76+
77+
println!(" {} [{}] ({})", rule.name.bold(), severity_str, type_str);
78+
79+
if let Some(ref section) = rule.section {
80+
println!(" section: {section}");
81+
}
82+
if let Some(ref pattern) = rule.pattern {
83+
println!(" pattern: {pattern}");
84+
}
85+
if let Some(min) = rule.min_words {
86+
println!(" min_words: {min}");
87+
}
88+
if let Some(ref filter) = rule.applies_to {
89+
let mut parts = Vec::new();
90+
if let Some(ref s) = filter.status {
91+
parts.push(format!("status={s}"));
92+
}
93+
if let Some(ref m) = filter.module {
94+
parts.push(format!("module=/{m}/"));
95+
}
96+
if !parts.is_empty() {
97+
println!(" applies_to: {}", parts.join(", "));
98+
}
99+
}
100+
if let Some(ref msg) = rule.message {
101+
println!(" message: {msg}");
102+
}
103+
println!();
104+
}
105+
}
106+
107+
fn print_builtin(name: &str, description: &str, value: Option<String>) {
108+
let status = match &value {
109+
Some(v) => format!("{} = {v}", "active".green()),
110+
None => "off".dimmed().to_string(),
111+
};
112+
println!(" {name:.<40} {status}");
113+
println!(" {}", description.dimmed());
114+
}

src/config.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ const KNOWN_JSON_KEYS: &[&str] = &[
184184
"aiBaseUrl",
185185
"aiTimeout",
186186
"rules",
187+
"customRules",
187188
"taskArchiveDays",
188189
"github",
189190
"enforcement",

0 commit comments

Comments
 (0)