use crate::config::{default_schema_pattern, discover_manifest_modules};
use crate::exports::{get_exported_symbols_full, has_extension, is_test_file};
use crate::parser::{
find_stub_sections, get_missing_sections, get_spec_symbols, parse_frontmatter,
};
use crate::schema::{self, SchemaTable};
use crate::types::{
CoverageReport, CustomRuleType, Frontmatter, RuleSeverity, SpecSyncConfig, ValidationResult,
};
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use walkdir::WalkDir;
static CONSUMED_BY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)### Consumed By\s*\n(.*?)(?:\n## |\n### |$)").unwrap());
static FILE_REF_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\|\s*`([^`]+\.\w+)`\s*\|").unwrap());
static NUMBERED_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^\d+\.\s+\S").unwrap());
pub fn is_cross_project_ref(dep: &str) -> bool {
dep.contains('/') && dep.contains('@')
}
pub fn parse_cross_project_ref(dep: &str) -> Option<(&str, &str)> {
if !is_cross_project_ref(dep) {
return None;
}
let at_pos = dep.find('@')?;
let repo = &dep[..at_pos];
let module = &dep[at_pos + 1..];
if repo.is_empty() || module.is_empty() {
return None;
}
Some((repo, module))
}
pub fn get_schema_table_names(root: &Path, config: &SpecSyncConfig) -> HashSet<String> {
let mut tables = HashSet::new();
let schema_dir = match &config.schema_dir {
Some(d) => root.join(d),
None => return tables,
};
if !schema_dir.exists() {
return tables;
}
let pattern_str = config
.schema_pattern
.as_deref()
.unwrap_or_else(|| default_schema_pattern());
let re = match Regex::new(pattern_str) {
Ok(r) => r,
Err(_) => return tables,
};
if let Ok(entries) = fs::read_dir(&schema_dir) {
for entry in entries.flatten() {
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "ts" && ext != "sql" {
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
for caps in re.captures_iter(&content) {
if let Some(name) = caps.get(1) {
tables.insert(name.as_str().to_string());
}
}
}
}
}
tables
}
pub fn find_spec_files(dir: &Path) -> Vec<PathBuf> {
let mut results = Vec::new();
if !dir.exists() {
return results;
}
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
&& name.ends_with(".spec.md")
{
results.push(path.to_path_buf());
}
}
results.sort();
results
}
fn find_source_files(
dir: &Path,
exclude_dirs: &HashSet<String>,
config: &SpecSyncConfig,
) -> Vec<PathBuf> {
let mut results = Vec::new();
if !dir.exists() {
return results;
}
for entry in WalkDir::new(dir)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_dir() {
let name = e.file_name().to_str().unwrap_or("");
!exclude_dirs.contains(name)
} else {
true
}
})
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() && has_extension(path, &config.source_extensions) && !is_test_file(path) {
results.push(path.to_path_buf());
}
}
results
}
pub fn validate_spec(
spec_path: &Path,
root: &Path,
schema_tables: &HashSet<String>,
schema_columns: &HashMap<String, SchemaTable>,
config: &SpecSyncConfig,
) -> ValidationResult {
let rel_path = spec_path
.strip_prefix(root)
.unwrap_or(spec_path)
.to_string_lossy()
.to_string();
let mut result = ValidationResult::new(rel_path);
let content = match fs::read_to_string(spec_path) {
Ok(c) => c.replace("\r\n", "\n"),
Err(e) => {
result.errors.push(format!("Cannot read spec: {e}"));
return result;
}
};
let parsed = match parse_frontmatter(&content) {
Some(p) => p,
None => {
result.errors.push(
"Missing or malformed YAML frontmatter (expected --- delimiters)".to_string(),
);
return result;
}
};
let fm = &parsed.frontmatter;
let body = &parsed.body;
if fm.parsed_status() == Some(crate::types::SpecStatus::Archived) {
return result;
}
if fm.module.is_none() {
result
.errors
.push("Frontmatter missing required field: module".to_string());
result
.fixes
.push("Add `module: your-module-name` to the YAML frontmatter block".to_string());
}
if fm.version.is_none() {
result
.errors
.push("Frontmatter missing required field: version".to_string());
result
.fixes
.push("Add `version: 1` to the YAML frontmatter block".to_string());
}
if fm.status.is_none() {
result
.errors
.push("Frontmatter missing required field: status".to_string());
result.fixes.push(
"Add `status: active` (or draft/review/stable/deprecated/archived) to the frontmatter"
.to_string(),
);
} else if let Some(status_str) = &fm.status {
if fm.parsed_status().is_none() {
result.warnings.push(format!(
"Unknown status '{}' — expected one of: draft, review, active, stable, deprecated, archived",
status_str
));
}
}
let spec_status = fm.parsed_status();
if spec_status == Some(crate::types::SpecStatus::Deprecated) {
result
.warnings
.push("Spec is deprecated — consider archiving with `specsync lifecycle promote <spec>` or `specsync lifecycle set <spec> archived`".to_string());
}
if let Some(policy) = &fm.agent_policy {
match policy.as_str() {
"read-only" | "suggest-only" | "full-access" => {}
_ => {
result.warnings.push(format!(
"Unknown agent_policy '{}' — expected: read-only, suggest-only, or full-access",
policy
));
}
}
}
if fm.files.is_empty() {
result.errors.push(
"Frontmatter missing required field: files (must be a non-empty list)".to_string(),
);
result.fixes.push(
"Add a `files:` list with relative paths to source files this spec covers".to_string(),
);
}
for file in &fm.files {
let full_path = root.join(file);
if !full_path.exists() {
result.errors.push(format!("Source file not found: {file}"));
if let Some(suggestion) = suggest_similar_file(root, file) {
result.fixes.push(format!(
"Did you mean `{suggestion}`? Update the path in frontmatter"
));
} else {
result.fixes.push(format!(
"Remove `{file}` from files list or create the source file"
));
}
}
}
for table in &fm.db_tables {
if !schema_tables.is_empty() && !schema_tables.contains(table) {
result
.errors
.push(format!("DB table not found in schema: {table}"));
result.fixes.push(format!(
"Remove `{table}` from db_tables or add a CREATE TABLE migration"
));
}
}
if !schema_columns.is_empty() {
let spec_schema = schema::parse_spec_schema(body);
for table_name in &fm.db_tables {
if let Some(actual_table) = schema_columns.get(table_name)
&& let Some(spec_cols) = spec_schema.get(table_name)
{
let actual_names: HashSet<&str> = actual_table
.columns
.iter()
.map(|c| c.name.as_str())
.collect();
let spec_names: HashSet<&str> = spec_cols.iter().map(|c| c.name.as_str()).collect();
for sc in spec_cols {
if !actual_names.contains(sc.name.as_str()) {
result.errors.push(format!(
"Schema column `{}.{}` documented in spec but not found in migrations",
table_name, sc.name
));
result.fixes.push(format!(
"Remove `{}` from the ### Schema section or add it via ALTER TABLE",
sc.name
));
}
}
for ac in &actual_table.columns {
if !spec_names.contains(ac.name.as_str()) {
result.warnings.push(format!(
"Schema column `{}.{}` exists in migrations but not documented in spec",
table_name, ac.name
));
}
}
for sc in spec_cols {
if let Some(ac) = actual_table.columns.iter().find(|c| c.name == sc.name) {
let spec_type = sc.col_type.to_uppercase();
let actual_type = ac.col_type.to_uppercase();
if spec_type != actual_type {
result.warnings.push(format!(
"Schema column `{}.{}` type mismatch: spec says {} but migrations say {}",
table_name, sc.name, spec_type, actual_type
));
}
}
}
}
}
}
let is_draft = spec_status == Some(crate::types::SpecStatus::Draft);
let is_review = spec_status == Some(crate::types::SpecStatus::Review);
if !is_draft {
let missing = get_missing_sections(body, &config.required_sections);
for section in &missing {
if is_review && section == "Public API" {
continue; }
result
.errors
.push(format!("Missing required section: ## {section}"));
result
.fixes
.push(format!("Add `## {section}` heading to the spec body"));
}
}
let stub_sections = find_stub_sections(body, &config.required_sections);
if !stub_sections.is_empty() {
for section in &stub_sections {
result.warnings.push(format!(
"Section ## {section} contains only stub/placeholder text (TBD, N/A, TODO, etc.)"
));
result.fixes.push(format!(
"Replace placeholder content in ## {section} with real documentation"
));
}
}
let skip_api = matches!(
spec_status,
Some(crate::types::SpecStatus::Draft) | Some(crate::types::SpecStatus::Review)
);
if !fm.files.is_empty() && !skip_api {
let mut exports_by_file: Vec<(String, String)> = Vec::new(); let mut all_exports: Vec<String> = Vec::new();
for file in &fm.files {
let full_path = root.join(file);
let exports =
get_exported_symbols_full(&full_path, config.export_level, config.parse_mode);
for sym in &exports {
exports_by_file.push((sym.clone(), file.clone()));
}
all_exports.extend(exports);
}
let mut seen = HashSet::new();
all_exports.retain(|s| seen.insert(s.clone()));
let spec_symbols = get_spec_symbols(body);
let spec_set: HashSet<&str> = spec_symbols.iter().map(|s| s.as_str()).collect();
let export_set: HashSet<&str> = all_exports.iter().map(|s| s.as_str()).collect();
for sym in &spec_symbols {
if !export_set.contains(sym.as_str()) {
result.errors.push(format!(
"Spec documents '{sym}' but no matching export found in source"
));
}
}
for sym in &all_exports {
if !spec_set.contains(sym.as_str()) {
let source_file = exports_by_file
.iter()
.find(|(s, _)| s == sym)
.map(|(_, f)| f.as_str());
match source_file {
Some(file) => {
result
.warnings
.push(format!("Undocumented export '{sym}' from {file}"));
}
None => {
result
.warnings
.push(format!("Export '{sym}' not in spec (undocumented)"));
}
}
}
}
let documented = spec_symbols
.iter()
.filter(|s| export_set.contains(s.as_str()))
.count();
if !all_exports.is_empty() {
let summary = format!("{documented}/{} exports documented", all_exports.len());
if documented < all_exports.len() {
result.warnings.insert(0, summary);
} else {
result.export_summary = Some(summary);
}
}
}
if !fm.depends_on.is_empty() {
for dep in &fm.depends_on {
if is_cross_project_ref(dep) {
continue;
}
let full_path = root.join(dep);
if !full_path.exists() {
result
.errors
.push(format!("Dependency spec not found: {dep}"));
}
}
}
if let Some(caps) = CONSUMED_BY_RE.captures(body) {
let section = caps.get(1).unwrap().as_str();
for caps in FILE_REF_RE.captures_iter(section) {
if let Some(file_ref) = caps.get(1) {
let file_path = root.join(file_ref.as_str());
if !file_path.exists() {
result.warnings.push(format!(
"Consumed By references missing file: {}",
file_ref.as_str()
));
}
}
}
}
if !is_draft && spec_status != Some(crate::types::SpecStatus::Review) {
let has_inline_requirements = {
let lower = body.to_ascii_lowercase();
lower.contains("## requirements") || lower.contains("## acceptance criteria")
};
if has_inline_requirements {
result.warnings.push(
"Inline requirements detected — specs are technical contracts; user stories and acceptance criteria belong in a companion requirements.md file".to_string()
);
result.fixes.push(
"Run `specsync add-spec <name>` to scaffold requirements.md, then move ## Requirements / ## Acceptance Criteria content there".to_string()
);
}
if let Some(parent) = spec_path.parent() {
let req_path = parent.join("requirements.md");
if !req_path.exists() {
result.warnings.push(
"Missing companion requirements.md — run `specsync add-spec <name>` or `specsync generate` to scaffold one".to_string()
);
}
}
}
apply_custom_rules(spec_path, body, fm, config, &mut result);
result
}
fn apply_custom_rules(
spec_path: &Path,
body: &str,
fm: &Frontmatter,
config: &SpecSyncConfig,
result: &mut ValidationResult,
) {
let rules = &config.rules;
if let Some(max_kb) = rules.max_spec_size_kb {
if let Ok(meta) = fs::metadata(spec_path) {
let size_kb = meta.len() as usize / 1024;
if size_kb > max_kb {
result.warnings.push(format!(
"Spec file is {size_kb} KB — exceeds limit of {max_kb} KB"
));
}
}
}
if let Some(max_entries) = rules.max_changelog_entries {
let count = count_changelog_entries(body);
if count > max_entries {
result.warnings.push(format!(
"Change Log has {count} entries — exceeds limit of {max_entries} (run `specsync compact`)"
));
}
}
if rules.require_behavioral_examples == Some(true) {
let scenario_count = body.matches("### Scenario").count();
if scenario_count == 0 {
result.errors.push(
"No behavioral examples found (rule: require_behavioral_examples)".to_string(),
);
result.fixes.push(
"Add at least one `### Scenario:` under `## Behavioral Examples`".to_string(),
);
}
}
if let Some(min) = rules.min_invariants {
let count = count_invariants(body);
if count < min {
result.warnings.push(format!(
"Only {count} invariant(s) found — minimum is {min}"
));
}
}
if rules.require_depends_on == Some(true) && fm.depends_on.is_empty() {
result
.warnings
.push("No consumed dependencies documented (rule: require_depends_on)".to_string());
}
for rule in &config.custom_rules {
if !custom_rule_applies(rule, fm) {
continue;
}
if let Some(msg) = evaluate_custom_rule(rule, body) {
let tagged = format!("{msg} (rule: {})", rule.name);
match rule.severity {
RuleSeverity::Error => result.errors.push(tagged),
RuleSeverity::Warning => result.warnings.push(tagged),
RuleSeverity::Info => result.warnings.push(format!("[info] {tagged}")),
}
}
}
}
fn custom_rule_applies(rule: &crate::types::CustomRule, fm: &Frontmatter) -> bool {
let Some(ref filter) = rule.applies_to else {
return true;
};
if let Some(ref status) = filter.status {
let spec_status = fm.status.as_deref().unwrap_or("");
if !spec_status.eq_ignore_ascii_case(status) {
return false;
}
}
if let Some(ref module_pattern) = filter.module {
let spec_module = fm.module.as_deref().unwrap_or("");
if let Ok(re) = Regex::new(module_pattern) {
if !re.is_match(spec_module) {
return false;
}
}
}
true
}
fn evaluate_custom_rule(rule: &crate::types::CustomRule, body: &str) -> Option<String> {
match rule.rule_type {
CustomRuleType::RequireSection => {
let section = rule.section.as_deref()?;
let header = format!("## {section}");
if !body.contains(&header) {
let msg = rule
.message
.clone()
.unwrap_or_else(|| format!("Missing required section: ## {section}"));
return Some(msg);
}
None
}
CustomRuleType::MinWordCount => {
let section = rule.section.as_deref()?;
let min = rule.min_words.unwrap_or(1);
let header = format!("## {section}");
let section_start = body.find(&header)?;
let after_header = &body[section_start + header.len()..];
let section_end = after_header.find("\n## ").unwrap_or(after_header.len());
let section_body = &after_header[..section_end];
let word_count = section_body.split_whitespace().count();
if word_count < min {
let msg = rule.message.clone().unwrap_or_else(|| {
format!("Section ## {section} has {word_count} words — minimum is {min}")
});
return Some(msg);
}
None
}
CustomRuleType::RequirePattern => {
let pattern = rule.pattern.as_deref()?;
let re = Regex::new(pattern).ok()?;
if !re.is_match(body) {
let msg = rule
.message
.clone()
.unwrap_or_else(|| format!("Required pattern not found: {pattern}"));
return Some(msg);
}
None
}
CustomRuleType::ForbidPattern => {
let pattern = rule.pattern.as_deref()?;
let re = Regex::new(pattern).ok()?;
if re.is_match(body) {
let msg = rule
.message
.clone()
.unwrap_or_else(|| format!("Forbidden pattern found: {pattern}"));
return Some(msg);
}
None
}
}
}
fn count_changelog_entries(body: &str) -> usize {
let changelog_start = match body.find("## Change Log") {
Some(pos) => pos,
None => return 0,
};
let section = &body[changelog_start..];
let section_end = section[1..]
.find("\n## ")
.map(|p| p + 1)
.unwrap_or(section.len());
let section = §ion[..section_end];
let mut table_line_count = 0usize;
section
.lines()
.filter(|line| {
let trimmed = line.trim();
if !trimmed.starts_with('|') {
return false;
}
table_line_count += 1;
table_line_count > 2
})
.count()
}
fn count_invariants(body: &str) -> usize {
let inv_start = match body.find("## Invariants") {
Some(pos) => pos,
None => return 0,
};
let section = &body[inv_start..];
let section_end = section[1..]
.find("\n## ")
.map(|p| p + 1)
.unwrap_or(section.len());
let section = §ion[..section_end];
NUMBERED_RE.find_iter(section).count()
}
fn suggest_similar_file(root: &Path, missing_file: &str) -> Option<String> {
let missing_name = Path::new(missing_file).file_name()?.to_str()?;
let parent = Path::new(missing_file).parent()?;
let search_dir = root.join(parent);
if !search_dir.exists() {
return None;
}
let entries = std::fs::read_dir(&search_dir).ok()?;
let mut best: Option<(String, usize)> = None;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !entry.path().is_file() {
continue;
}
let dist = levenshtein(missing_name, &name);
if dist <= 3 && (best.is_none() || dist < best.as_ref().unwrap().1) {
let suggestion = parent.join(&name).to_string_lossy().replace('\\', "/");
best = Some((suggestion, dist));
}
}
best.map(|(s, _)| s)
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for (i, row) in dp.iter_mut().enumerate().take(a.len() + 1) {
row[0] = i;
}
for (j, val) in dp[0].iter_mut().enumerate().take(b.len() + 1) {
*val = j;
}
for i in 1..=a.len() {
for j in 1..=b.len() {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
}
}
dp[a.len()][b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_cross_project_ref() {
assert!(is_cross_project_ref("corvid-labs/algochat@auth"));
assert!(is_cross_project_ref("owner/repo@module"));
assert!(!is_cross_project_ref("specs/auth/auth.spec.md"));
assert!(!is_cross_project_ref("auth"));
assert!(!is_cross_project_ref("owner/repo")); assert!(!is_cross_project_ref("@module")); }
#[test]
fn test_parse_cross_project_ref() {
let (repo, module) = parse_cross_project_ref("corvid-labs/algochat@auth").unwrap();
assert_eq!(repo, "corvid-labs/algochat");
assert_eq!(module, "auth");
assert!(parse_cross_project_ref("not-a-ref").is_none());
assert!(parse_cross_project_ref("/@").is_none()); }
#[test]
fn test_levenshtein() {
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("abc", "abc"), 0);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("config.ts", "confg.ts"), 1);
}
#[test]
fn test_find_spec_files_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
let files = find_spec_files(tmp.path());
assert!(files.is_empty());
}
#[test]
fn test_find_spec_files_nonexistent() {
let files = find_spec_files(Path::new("/nonexistent/path"));
assert!(files.is_empty());
}
#[test]
fn test_find_spec_files_with_specs() {
let tmp = tempfile::tempdir().unwrap();
let spec_dir = tmp.path().join("auth");
fs::create_dir_all(&spec_dir).unwrap();
fs::write(spec_dir.join("auth.spec.md"), "---\nmodule: auth\n---\n").unwrap();
fs::write(spec_dir.join("not-a-spec.md"), "other").unwrap();
let files = find_spec_files(tmp.path());
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("auth.spec.md"));
}
#[test]
fn test_validate_spec_missing_frontmatter() {
let tmp = tempfile::tempdir().unwrap();
let spec = tmp.path().join("bad.spec.md");
fs::write(&spec, "# No frontmatter\n\nJust text.").unwrap();
let tables = HashSet::new();
let schema_cols = HashMap::new();
let config = SpecSyncConfig::default();
let result = validate_spec(&spec, tmp.path(), &tables, &schema_cols, &config);
assert!(!result.errors.is_empty());
assert!(result.errors[0].contains("frontmatter"));
}
#[test]
fn test_validate_spec_missing_required_fields() {
let tmp = tempfile::tempdir().unwrap();
let spec = tmp.path().join("partial.spec.md");
fs::write(&spec, "---\nmodule: test\n---\n\n## Purpose\nTest\n").unwrap();
let tables = HashSet::new();
let schema_cols = HashMap::new();
let config = SpecSyncConfig::default();
let result = validate_spec(&spec, tmp.path(), &tables, &schema_cols, &config);
assert!(result.errors.iter().any(|e| e.contains("version")));
assert!(result.errors.iter().any(|e| e.contains("status")));
assert!(result.errors.iter().any(|e| e.contains("files")));
}
#[test]
fn test_validate_spec_missing_source_file() {
let tmp = tempfile::tempdir().unwrap();
let spec = tmp.path().join("missing.spec.md");
fs::write(
&spec,
"---\nmodule: test\nversion: 1\nstatus: active\nfiles:\n - src/nonexistent.ts\n---\n\n## Purpose\nTest\n## Requirements\n## Public API\n## Invariants\n## Behavioral Examples\n## Error Cases\n## Dependencies\n## Change Log\n",
)
.unwrap();
let tables = HashSet::new();
let schema_cols = HashMap::new();
let config = SpecSyncConfig::default();
let result = validate_spec(&spec, tmp.path(), &tables, &schema_cols, &config);
assert!(
result
.errors
.iter()
.any(|e| e.contains("Source file not found"))
);
}
#[test]
fn test_validate_spec_schema_columns() {
let tmp = tempfile::tempdir().unwrap();
let src_dir = tmp.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(src_dir.join("msg.ts"), "export function send() {}").unwrap();
let spec = tmp.path().join("msg.spec.md");
fs::write(
&spec,
r#"---
module: msg
version: 1
status: active
files:
- src/msg.ts
db_tables:
- messages
---
## Purpose
Messaging
## Requirements
### Schema: messages
| Column | Type | Constraints |
|--------|------|-------------|
| `id` | INTEGER | PRIMARY KEY |
| `content` | TEXT | NOT NULL |
| `ghost_col` | TEXT | NOT NULL |
## Public API
### Exported Functions
| Function | Parameters | Returns | Description |
|----------|-----------|---------|-------------|
| `send` | msg: string | void | Sends |
## Invariants
## Behavioral Examples
## Error Cases
## Dependencies
## Change Log
"#,
)
.unwrap();
let mut table_names = HashSet::new();
table_names.insert("messages".to_string());
let mut schema_cols = HashMap::new();
schema_cols.insert(
"messages".to_string(),
SchemaTable {
columns: vec![
crate::schema::SchemaColumn {
name: "id".to_string(),
col_type: "INTEGER".to_string(),
nullable: false,
has_default: false,
is_primary_key: true,
},
crate::schema::SchemaColumn {
name: "content".to_string(),
col_type: "TEXT".to_string(),
nullable: false,
has_default: false,
is_primary_key: false,
},
crate::schema::SchemaColumn {
name: "created_at".to_string(),
col_type: "TEXT".to_string(),
nullable: false,
has_default: true,
is_primary_key: false,
},
],
},
);
let config = SpecSyncConfig::default();
let result = validate_spec(&spec, tmp.path(), &table_names, &schema_cols, &config);
assert!(result.errors.iter().any(|e| e.contains("ghost_col")));
assert!(result.warnings.iter().any(|w| w.contains("created_at")));
}
#[test]
fn test_validate_spec_schema_type_mismatch() {
let tmp = tempfile::tempdir().unwrap();
let src_dir = tmp.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(src_dir.join("t.ts"), "export function f() {}").unwrap();
let spec = tmp.path().join("t.spec.md");
fs::write(
&spec,
r#"---
module: t
version: 1
status: active
files:
- src/t.ts
db_tables:
- items
---
## Purpose
Test
## Requirements
### Schema: items
| Column | Type | Constraints |
|--------|------|-------------|
| `id` | INTEGER | PRIMARY KEY |
| `price` | TEXT | |
## Public API
### Exported Functions
| Function | Parameters | Returns | Description |
|----------|-----------|---------|-------------|
| `f` | | void | Does stuff |
## Invariants
## Behavioral Examples
## Error Cases
## Dependencies
## Change Log
"#,
)
.unwrap();
let mut table_names = HashSet::new();
table_names.insert("items".to_string());
let mut schema_cols = HashMap::new();
schema_cols.insert(
"items".to_string(),
SchemaTable {
columns: vec![
crate::schema::SchemaColumn {
name: "id".to_string(),
col_type: "INTEGER".to_string(),
nullable: false,
has_default: false,
is_primary_key: true,
},
crate::schema::SchemaColumn {
name: "price".to_string(),
col_type: "REAL".to_string(),
nullable: true,
has_default: false,
is_primary_key: false,
},
],
},
);
let config = SpecSyncConfig::default();
let result = validate_spec(&spec, tmp.path(), &table_names, &schema_cols, &config);
assert!(
result
.warnings
.iter()
.any(|w| w.contains("type mismatch") && w.contains("price"))
);
}
}
fn collect_specced_files(spec_files: &[PathBuf]) -> HashSet<String> {
let mut specced = HashSet::new();
for spec_file in spec_files {
if let Ok(content) = fs::read_to_string(spec_file) {
let content = content.replace("\r\n", "\n");
if let Some(parsed) = parse_frontmatter(&content) {
for f in &parsed.frontmatter.files {
specced.insert(f.clone());
}
}
}
}
specced
}
fn get_module_dirs(dir: &Path, exclude_dirs: &HashSet<String>) -> Vec<String> {
let mut modules = Vec::new();
if !dir.exists() {
return modules;
}
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
if let Ok(ft) = entry.file_type()
&& ft.is_dir()
{
let name = entry.file_name().to_string_lossy().to_string();
if !exclude_dirs.contains(&name) {
modules.push(name);
}
}
}
}
modules.sort();
modules
}
fn get_spec_module_dirs(dir: &Path) -> Vec<String> {
let mut modules = Vec::new();
if !dir.exists() {
return modules;
}
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
if let Ok(ft) = entry.file_type()
&& ft.is_dir()
{
let subdir = entry.path();
let has_spec = fs::read_dir(&subdir)
.ok()
.map(|entries| {
entries.flatten().any(|e| {
e.path().is_file()
&& e.file_name()
.to_str()
.map(|n| n.ends_with(".spec.md"))
.unwrap_or(false)
})
})
.unwrap_or(false);
if has_spec {
let name = entry.file_name().to_string_lossy().to_string();
modules.push(name);
}
}
}
}
modules.sort();
modules
}
pub fn compute_coverage(
root: &Path,
spec_files: &[PathBuf],
config: &SpecSyncConfig,
) -> CoverageReport {
let specced_files = collect_specced_files(spec_files);
let exclude_dirs: HashSet<String> = config.exclude_dirs.iter().cloned().collect();
let mut all_source_files: Vec<String> = Vec::new();
for src_dir in &config.source_dirs {
let full_dir = root.join(src_dir);
let files: Vec<String> = find_source_files(&full_dir, &exclude_dirs, config)
.into_iter()
.filter_map(|f| {
let rel = f.strip_prefix(root).ok()?;
let rel_str = rel.to_string_lossy().replace('\\', "/");
for pattern in &config.exclude_patterns {
if pattern.starts_with("**/") && pattern.ends_with("/**") {
let dir_part = &pattern[3..pattern.len() - 3];
if rel_str.contains(dir_part) {
return None;
}
}
else if let Some(suffix) = pattern.strip_prefix("**/") {
if let Some(ext) = suffix.strip_prefix('*') {
if rel_str.ends_with(ext) {
return None;
}
} else {
if rel_str.ends_with(&format!("/{suffix}")) || rel_str == *suffix {
return None;
}
}
}
else if rel_str.contains(pattern.as_str()) {
return None;
}
}
Some(rel_str)
})
.collect();
all_source_files.extend(files);
}
let file_loc: std::collections::HashMap<&str, usize> = all_source_files
.iter()
.map(|f| {
let loc = fs::read_to_string(root.join(f))
.map(|c| c.lines().count())
.unwrap_or(0);
(f.as_str(), loc)
})
.collect();
let total_loc: usize = file_loc.values().sum();
let specced_loc: usize = all_source_files
.iter()
.filter(|f| specced_files.contains(*f))
.map(|f| file_loc.get(f.as_str()).copied().unwrap_or(0))
.sum();
let unspecced_files: Vec<String> = all_source_files
.iter()
.filter(|f| !specced_files.contains(*f))
.cloned()
.collect();
let mut unspecced_file_loc: Vec<(String, usize)> = unspecced_files
.iter()
.map(|f| (f.clone(), file_loc.get(f.as_str()).copied().unwrap_or(0)))
.collect();
unspecced_file_loc.sort_by(|a, b| b.1.cmp(&a.1));
let specs_dir = root.join(&config.specs_dir);
let spec_modules: HashSet<String> = get_spec_module_dirs(&specs_dir).into_iter().collect();
let mut unspecced_modules = Vec::new();
let mut seen_modules: HashSet<String> = HashSet::new();
if !config.modules.is_empty() {
for name in config.modules.keys() {
if !spec_modules.contains(name) && seen_modules.insert(name.clone()) {
unspecced_modules.push(name.clone());
}
}
}
let manifest = discover_manifest_modules(root);
for name in manifest.modules.keys() {
if !spec_modules.contains(name) && seen_modules.insert(name.clone()) {
unspecced_modules.push(name.clone());
}
}
for src_dir in &config.source_dirs {
let full_dir = root.join(src_dir);
for module in get_module_dirs(&full_dir, &exclude_dirs) {
if !spec_modules.contains(&module) && seen_modules.insert(module.clone()) {
unspecced_modules.push(module);
}
}
}
let skip_stems: HashSet<&str> = ["main", "lib", "mod", "index", "__init__", "app"]
.into_iter()
.collect();
for src_dir in &config.source_dirs {
let full_dir = root.join(src_dir);
if let Ok(entries) = fs::read_dir(&full_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file()
|| !has_extension(&path, &config.source_extensions)
|| is_test_file(&path)
{
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
if skip_stems.contains(stem.as_str()) {
continue;
}
if !spec_modules.contains(&stem) && seen_modules.insert(stem.clone()) {
unspecced_modules.push(stem);
}
}
}
}
let specced_count = all_source_files.len() - unspecced_files.len();
let coverage_percent = if all_source_files.is_empty() {
100
} else {
(specced_count * 100) / all_source_files.len()
};
let loc_coverage_percent = if total_loc == 0 {
100
} else {
(specced_loc * 100) / total_loc
};
CoverageReport {
total_source_files: all_source_files.len(),
specced_file_count: specced_count,
unspecced_files,
unspecced_modules,
coverage_percent,
total_loc,
specced_loc,
loc_coverage_percent,
unspecced_file_loc,
}
}