From 521eeb9fbcf73e507f4fd28dd60c84938df504b8 Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 24 Jul 2026 12:40:22 -0600 Subject: [PATCH 1/6] fix: new/add-spec/scaffold emit required sections, warn on greedy fallback, strict naming (closes #442) --- src/commands/new.rs | 45 +++++++--- src/commands/scaffold.rs | 184 +++++++++++---------------------------- 2 files changed, 83 insertions(+), 146 deletions(-) diff --git a/src/commands/new.rs b/src/commands/new.rs index e5c0be69..a80c662b 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -7,16 +7,20 @@ use crate::config::load_config; use crate::exports; use crate::generator; -use super::validate_module_name; +use super::validate_scaffold_module_name; /// Quick-create a minimal spec for a module with auto-detected source files. pub fn cmd_new(root: &Path, module_name: &str, full: bool) { - if let Err(e) = validate_module_name(module_name) { + if let Err(e) = validate_scaffold_module_name(module_name) { eprintln!("{e}"); process::exit(1); } let config = load_config(root); let specs_dir = root.join(&config.specs_dir); + if let Err(e) = super::check_case_collision(&specs_dir, module_name) { + eprintln!("{e}"); + process::exit(1); + } let spec_dir = specs_dir.join(module_name); let spec_file = spec_dir.join(format!("{module_name}.spec.md")); @@ -34,8 +38,23 @@ pub fn cmd_new(root: &Path, module_name: &str, full: bool) { process::exit(1); } - // Auto-detect source files for this module - let source_files = detect_module_sources(root, module_name, &config); + // Auto-detect source files for this module. The single-source-file + // fallback claims the project's only source file even when the module + // name doesn't match it — warn so a greedy claim (and the duplicate spec + // ownership it can cause) is visible at creation time. + let mut source_files = detect_module_sources(root, module_name, &config); + if source_files.is_empty() + && let Some(single) = generator::find_single_source_fallback(root, &config) + { + eprintln!( + "{} No source files matched module '{module_name}' by name — claiming the project's only source file '{single}'.", + "⚠".yellow() + ); + eprintln!( + " Verify this mapping; if another spec already covers this file, `specsync check` reports duplicate spec ownership." + ); + source_files.push(single); + } if source_files.is_empty() { eprintln!( "{} No source files matched module '{module_name}' — the spec is created with an empty `files:` list.", @@ -96,6 +115,16 @@ pub fn cmd_new(root: &Path, module_name: &str, full: bool) { Document this module's responsibility, inputs, outputs, and ownership boundaries.\n\n\ ## Public API\n\n\ {api_table}\n\n\ + ## Invariants\n\n\ + 1. Define an invariant that must remain true for supported inputs.\n\n\ + ## Behavioral Examples\n\n\ + ### Scenario: Core behavior\n\n\ + - **Given** precondition\n\ + - **When** action\n\ + - **Then** result\n\n\ + ## Error Cases\n\n\ + | Condition | Behavior |\n\ + |-----------|----------|\n\n\ ## Dependencies\n\n\ List runtime dependencies and the specific symbols, services, or data they provide.\n\n\ ## Change Log\n\n\ @@ -212,14 +241,6 @@ fn detect_module_sources( } } - // Fallback: a single-source-file project (e.g. only src/lib.rs) has exactly - // one possible source — use it even though the name doesn't match. - if files.is_empty() - && let Some(single) = generator::find_single_source_fallback(root, config) - { - files.push(single); - } - files.sort(); files } diff --git a/src/commands/scaffold.rs b/src/commands/scaffold.rs index 8d3db45c..5ca657e9 100644 --- a/src/commands/scaffold.rs +++ b/src/commands/scaffold.rs @@ -7,15 +7,19 @@ use crate::config::load_config; use crate::generator; use crate::registry; -use super::validate_module_name; +use super::validate_scaffold_module_name; pub fn cmd_add_spec(root: &Path, module_name: &str) { - if let Err(e) = validate_module_name(module_name) { + if let Err(e) = validate_scaffold_module_name(module_name) { eprintln!("{e}"); process::exit(1); } let config = load_config(root); let specs_dir = root.join(&config.specs_dir); + if let Err(e) = super::check_case_collision(&specs_dir, module_name) { + eprintln!("{e}"); + process::exit(1); + } let spec_dir = specs_dir.join(module_name); let spec_file = spec_dir.join(format!("{module_name}.spec.md")); @@ -39,138 +43,37 @@ pub fn cmd_add_spec(root: &Path, module_name: &str) { process::exit(1); } - // Use the same deterministic template generator as `generate`. - let template_path = specs_dir.join("_template.spec.md"); - let template = if template_path.exists() { - fs::read_to_string(&template_path).unwrap_or_default() - } else { - String::new() - }; - - // Find any matching source files - let module_files: Vec = config - .source_dirs - .iter() - .flat_map(|src_dir| { - let module_dir = root.join(src_dir).join(module_name); - if module_dir.exists() { - walkdir::WalkDir::new(&module_dir) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| { - e.path().is_file() - && crate::exports::has_configured_extension( - e.path(), - &config.source_extensions, - config.include_extensionless, - ) - && !crate::exports::is_test_file(e.path(), root) - }) - .map(|e| { - e.path() - .strip_prefix(root) - .unwrap_or(e.path()) - .to_string_lossy() - .replace('\\', "/") - }) - .collect::>() - } else { - vec![] - } - }) - .collect(); - - let _ = template; // Template handling is done by generate_spec internal - - // Generate spec content using the internal generate function - let spec_content = { - let title = module_name - .split('-') - .map(|w| { - let mut chars = w.chars(); - match chars.next() { - Some(c) => c.to_uppercase().to_string() + chars.as_str(), - None => String::new(), - } - }) - .collect::>() - .join(" "); - - let files_yaml = if module_files.is_empty() { - " # - path/to/source/file".to_string() - } else { - module_files - .iter() - .map(|f| format!(" - {f}")) - .collect::>() - .join("\n") - }; - - format!( - r#"--- -module: {module_name} -version: 1 -status: draft -files: -{files_yaml} -db_tables: [] -depends_on: [] ---- - -# {title} - -## Purpose - -Document this module's responsibility, inputs, outputs, and ownership boundaries. - -## Public API - -### Exported Functions - -| Function | Parameters | Returns | Description | -|----------|-----------|---------|-------------| - -### Exported Types - -| Type | Description | -|------|-------------| - -## Invariants - -1. Define an invariant that must remain true for supported inputs. - -## Behavioral Examples - -### Scenario: Core behavior - -- **Given** precondition -- **When** action -- **Then** result - -## Error Cases - -| Condition | Behavior | -|-----------|----------| - -## Dependencies - -### Consumes - -| Module | What is used | -|--------|-------------| - -### Consumed By - -| Module | What is used | -|--------|-------------| + // Detect source files with the same logic as `generate`/`scaffold` so all + // generators agree: config module definitions, module subdirectories, flat + // name-matched files, then the single-source-file fallback. + let mut module_files = generator::find_files_for_module(root, module_name, &config); + if module_files.is_empty() + && let Some(single) = generator::find_single_source_fallback(root, &config) + { + eprintln!( + "{} No source files matched module '{module_name}' by name — claiming the project's only source file '{single}'.", + "⚠".yellow() + ); + eprintln!( + " Verify this mapping; if another spec already covers this file, `specsync check` reports duplicate spec ownership." + ); + module_files.push(single); + } -## Change Log + if module_files.is_empty() { + eprintln!( + "{} No source files matched module '{module_name}' — the spec is created with an empty `files:` list.", + "⚠".yellow() + ); + eprintln!( + " Add the module's source path(s) to the `files:` list in the spec frontmatter;" + ); + eprintln!(" `specsync check` fails on empty `files:`."); + } -| Date | Author | Change | -|------|--------|--------| -"# - ) - }; + // Generate spec content with the shared deterministic generator (language-aware + // template, valid `files:` list, pre-populated Public API table). + let spec_content = generator::generate_spec(module_name, &module_files, root, &specs_dir); match fs::write(&spec_file, &spec_content) { Ok(_) => { @@ -222,12 +125,16 @@ pub fn cmd_scaffold( dir: Option, template: Option, ) { - if let Err(e) = validate_module_name(module_name) { + if let Err(e) = validate_scaffold_module_name(module_name) { eprintln!("{e}"); process::exit(1); } let config = load_config(root); let specs_dir = dir.unwrap_or_else(|| root.join(&config.specs_dir)); + if let Err(e) = super::check_case_collision(&specs_dir, module_name) { + eprintln!("{e}"); + process::exit(1); + } let spec_dir = specs_dir.join(module_name); let spec_file = spec_dir.join(format!("{module_name}.spec.md")); @@ -261,11 +168,20 @@ pub fn cmd_scaffold( } // Auto-detect source files matching the module name; for single-source-file - // projects (e.g. only src/lib.rs) fall back to that file. + // projects (e.g. only src/lib.rs) fall back to that file. The fallback + // claims a file whose name doesn't match the module — warn, since a wrong + // claim causes duplicate spec ownership errors at check time. let mut module_files = generator::find_files_for_module(root, module_name, &config); if module_files.is_empty() && let Some(single) = generator::find_single_source_fallback(root, &config) { + eprintln!( + "{} No source files matched module '{module_name}' by name — claiming the project's only source file '{single}'.", + "⚠".yellow() + ); + eprintln!( + " Verify this mapping; if another spec already covers this file, `specsync check` reports duplicate spec ownership." + ); module_files.push(single); } From 6e803c61f9fa7bb4e9e1fe53348837dbd6109076 Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 24 Jul 2026 12:44:16 -0600 Subject: [PATCH 2/6] fix: scaffold-time naming rules, case-collision check, inline frontmatter errors (closes #442) --- src/commands/mod.rs | 153 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 7 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1b976378..32dc9ac0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -79,6 +79,29 @@ pub fn load_and_discover(root: &Path, allow_empty: bool) -> (types::SpecSyncConf /// misses. Control characters are rejected so a name cannot inject into the generated /// YAML frontmatter or create a control-char directory. Returns `Err` (to be printed and /// exited on) rather than writing outside the project. +/// Maximum module-name length. Generous for real names while keeping the +/// generated `//.spec.md` path far from filesystem +/// limits (a 200+ character name otherwise dies with a raw OS error at mkdir). +pub const MAX_MODULE_NAME_LEN: usize = 64; + +/// Names that must never become module directories: tool-reserved directory +/// names (`change`/`changes`/`specs` collide with `.specsync/` internals and +/// the specs root) and Windows device names (`con`, `nul`, …) that are +/// unwritable on Windows checkouts regardless of case. +fn is_reserved_module_name(lower: &str) -> bool { + const RESERVED: &[&str] = &[ + "change", "changes", "spec", "specs", "con", "prn", "aux", "nul", "com1", "com2", "com3", + "com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", + "lpt6", "lpt7", "lpt8", "lpt9", + ]; + RESERVED.contains(&lower) +} + +/// Documented naming rules, shared by the error message and `--help` text. +pub const MODULE_NAME_RULES: &str = "1-64 chars: letters, digits, `-`, `_`, `.`; \ + must start with a letter or digit; no spaces, path separators, or reserved \ + names (`change`, `specs`, Windows device names)"; + pub fn validate_module_name(module_name: &str) -> Result<(), String> { let single_normal_segment = { let mut components = Path::new(module_name).components(); @@ -98,6 +121,69 @@ pub fn validate_module_name(module_name: &str) -> Result<(), String> { )) } +/// Stricter naming rules for names the scaffolding commands (`new`, `add-spec`, +/// `scaffold`, `wizard`) are about to mint. On top of the traversal-safety base +/// rules, a newly scaffolded name must satisfy the documented naming rules: +/// length, character set, reserved words. Internal callers validating +/// pre-existing module names use [`validate_module_name`] so older repos with +/// legacy names keep working. +pub fn validate_scaffold_module_name(module_name: &str) -> Result<(), String> { + validate_module_name(module_name)?; + + // Length limit (character count, not bytes). + let char_count = module_name.chars().count(); + if char_count == 0 || char_count > MAX_MODULE_NAME_LEN { + return Err(format!( + "invalid module name `{}`: must be 1-{MAX_MODULE_NAME_LEN} characters (got {char_count}). Rules: {MODULE_NAME_RULES}", + module_name.escape_default() + )); + } + + // Character set: letters/digits plus `-`, `_`, `.`; must start with a + // letter or digit (no leading dash, dot, emoji, or whitespace). + let mut chars = module_name.chars(); + let first = chars.next().unwrap_or(' '); + let charset_ok = first.is_alphanumeric() + && module_name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'); + if !charset_ok { + return Err(format!( + "invalid module name `{}`: {MODULE_NAME_RULES}", + module_name.escape_default() + )); + } + + // Reserved words (case-insensitive — `CON` is as unwritable as `con`). + if is_reserved_module_name(&module_name.to_lowercase()) { + return Err(format!( + "invalid module name `{module_name}`: reserved name. Rules: {MODULE_NAME_RULES}" + )); + } + + Ok(()) +} + +/// Reject a module name that would collide with an existing spec directory +/// under a case-folding filesystem (e.g. `Lib` vs existing `lib`). Linux +/// allows both, but the repo then breaks on macOS/Windows checkouts. +pub fn check_case_collision(specs_dir: &Path, module_name: &str) -> Result<(), String> { + let entries = match std::fs::read_dir(specs_dir) { + Ok(e) => e, + Err(_) => return Ok(()), // no specs dir yet — nothing to collide with + }; + for entry in entries.flatten() { + let existing = entry.file_name().to_string_lossy().to_string(); + if existing != module_name && existing.eq_ignore_ascii_case(module_name) { + return Err(format!( + "invalid module name `{module_name}`: collides with existing spec directory \ + `{existing}` on case-insensitive filesystems (macOS/Windows) — pick a distinct name" + )); + } + } + Ok(()) +} + /// Filter spec files by user-provided spec names/paths. /// Matches against: exact file path, relative path, module name (from filename stem). /// Returns the full list if `filters` is empty. @@ -382,15 +468,22 @@ pub fn run_validation( println!("\n{}", result.spec_path.bold()); - // Frontmatter check - let has_fm_errors = result + // Frontmatter check — on failure, print each specific frontmatter + // error inline; a bare "Frontmatter invalid" with the cause buried in + // Suggested fixes leaves users guessing. + let fm_errors: Vec<&str> = result .errors .iter() - .any(|e| e.starts_with("Frontmatter") || e.starts_with("Missing or malformed")); - if has_fm_errors { - println!(" {} Frontmatter invalid", "✗".red()); - } else { + .filter(|e| e.starts_with("Frontmatter") || e.starts_with("Missing or malformed")) + .map(|s| s.as_str()) + .collect(); + if fm_errors.is_empty() { println!(" {} Frontmatter valid", "✓".green()); + } else { + println!(" {} Frontmatter invalid", "✗".red()); + for e in &fm_errors { + println!(" {} {e}", "✗".red()); + } } // File existence @@ -851,7 +944,7 @@ pub fn create_drift_issues( #[cfg(test)] mod tests { - use super::validate_module_name; + use super::{check_case_collision, validate_module_name, validate_scaffold_module_name}; #[test] fn validate_module_name_accepts_plain_names() { @@ -905,4 +998,50 @@ mod tests { assert!(validate_module_name("C:foo").is_err()); assert!(validate_module_name("C:\\abs").is_err()); } + + // ── #442 regression: naming rules ────────────────────────────────── + + #[test] + fn validate_scaffold_module_name_rejects_overlong_names() { + let long = "a".repeat(200); + let err = validate_scaffold_module_name(&long).unwrap_err(); + assert!(err.contains("1-64"), "{err}"); + let max_ok = "a".repeat(64); + assert!(validate_scaffold_module_name(&max_ok).is_ok()); + } + + #[test] + fn validate_scaffold_module_name_rejects_reserved_names() { + for name in ["change", "specs", "con", "CON", "nul", "com1", "Changes"] { + assert!( + validate_scaffold_module_name(name).is_err(), + "`{name}` is reserved and must be rejected" + ); + } + } + + #[test] + fn validate_scaffold_module_name_rejects_spaces_leading_dash_emoji() { + for name in ["my module", "-dash", "🚀rocket", ".hidden", "trailing "] { + assert!( + validate_scaffold_module_name(name).is_err(), + "`{name}` must be rejected" + ); + } + // Still accepted: documented charset. + for name in ["auth", "auth-service", "user_profile", "a.b", "v2", "Módulo"] { + assert!(validate_scaffold_module_name(name).is_ok(), "`{name}` should stay valid"); + } + } + + #[test] + fn case_collision_detects_case_fold_duplicates() { + let tmp = tempfile::tempdir().unwrap(); + let specs_dir = tmp.path().join("specs"); + std::fs::create_dir_all(specs_dir.join("lib")).unwrap(); + assert!(check_case_collision(&specs_dir, "Lib").is_err()); + assert!(check_case_collision(&specs_dir, "LIB").is_err()); + assert!(check_case_collision(&specs_dir, "lib").is_ok()); // same name: normal "already exists" path + assert!(check_case_collision(&specs_dir, "other").is_ok()); + } } From da5b8fd6c3f8954010e4c270691c339a035baf2e Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 24 Jul 2026 12:56:06 -0600 Subject: [PATCH 3/6] fix: wizard uses strict scaffold name validation (closes #442) --- src/commands/wizard.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/wizard.rs b/src/commands/wizard.rs index 1e93a241..8b03092b 100644 --- a/src/commands/wizard.rs +++ b/src/commands/wizard.rs @@ -29,7 +29,7 @@ pub fn cmd_wizard(root: &Path) { .unwrap_or_else(|_| process::exit(0)); let module_name = module_name.trim().to_string(); - if let Err(e) = super::validate_module_name(&module_name) { + if let Err(e) = super::validate_scaffold_module_name(&module_name) { eprintln!("{} {e}", "Error:".red()); process::exit(1); } From ab968223a2902ce8a73d00f239cb0e189a8ceab2 Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 24 Jul 2026 12:59:11 -0600 Subject: [PATCH 4/6] fix: document naming rules in new --help; add Init --repair flag (closes #442) --- src/cli.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 68e30105..56e72a9c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -90,7 +90,13 @@ pub enum Command { batch: Vec, }, /// Create .specsync/config.toml and initialize the verified SDD layout - Init, + Init { + /// Repair a partially-initialized project: restore missing .specsync + /// support files (version stamp, .gitignore, sdd.json, directories) + /// without touching the existing config + #[arg(long)] + repair: bool, + }, /// Score spec quality (0-100) with letter grades and improvement suggestions Score { /// Show detailed per-category breakdown explaining exactly why each spec lost points @@ -205,7 +211,10 @@ pub enum Command { }, /// Quick-create a minimal spec for a module (auto-detects source files) New { - /// Module name for the new spec + /// Module name for the new spec. + /// Rules: 1-64 chars — letters, digits, `-`, `_`, `.`; must start with a + /// letter or digit; no spaces, path separators, or reserved names + /// (`change`, `specs`, Windows device names like `con`). name: String, /// Also create required companions (requirements.md, tasks.md, context.md, testing.md) /// and optional design.md when design artifacts are enabled @@ -398,7 +407,6 @@ pub enum ChangeAction { /// Change ID id: String, /// Human actor authorizing the audited reopen transition - #[arg(long)] actor: String, /// Non-empty reason the accepted delivery evidence became stale #[arg(long)] From 62e5228eba6193832e2bc7eae058b268024dc0d5 Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 24 Jul 2026 13:03:20 -0600 Subject: [PATCH 5/6] fix: restore #[arg(long)] on change reopen --actor (closes #442) --- src/cli.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli.rs b/src/cli.rs index 56e72a9c..e8496b95 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -407,6 +407,7 @@ pub enum ChangeAction { /// Change ID id: String, /// Human actor authorizing the audited reopen transition + #[arg(long)] actor: String, /// Non-empty reason the accepted delivery evidence became stale #[arg(long)] From 4495592a4dcb810efa69d192520d5dc085263260 Mon Sep 17 00:00:00 2001 From: Leif Date: Fri, 24 Jul 2026 13:32:17 -0600 Subject: [PATCH 6/6] fix: draft specs skip companion-marker and stub-section warnings (closes #442) --- src/validator.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/src/validator.rs b/src/validator.rs index 215005a2..e996f846 100644 --- a/src/validator.rs +++ b/src/validator.rs @@ -219,7 +219,12 @@ pub fn validate_spec( return result; } - validate_companion_scaffold_markers(spec_path, root, &mut result); + // Freshly scaffolded companions are drafts by definition — only flag + // unfilled scaffold markers once the spec leaves draft status, so the + // output of `new --full` / `add-spec` passes the tool's own check. + if fm.parsed_status() != Some(crate::types::SpecStatus::Draft) { + validate_companion_scaffold_markers(spec_path, root, &mut result); + } let config_hint = config .config_path @@ -466,8 +471,11 @@ pub fn validate_spec( } // ─── Level 1.7: Empty-Draft Section Detection ─────────────────── + // Drafts are scaffolds by definition ("structure only" above) — flagging + // their unfinished sections would make every freshly generated spec fail + // its own --strict check, so this warning starts at review/active. let stub_sections = find_stub_sections(body, &config.required_sections); - if !stub_sections.is_empty() { + if !is_draft && !stub_sections.is_empty() { for section in &stub_sections { result.warnings.push(format!( "Section ## {section} contains only unfinished draft text" @@ -1596,6 +1604,87 @@ Test result.errors ); } + + #[test] + fn draft_specs_skip_companion_scaffold_markers() { + // #442: freshly scaffolded companions are drafts by definition — the + // unfilled-marker warnings only apply once the spec leaves draft. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/widget.rs"), "pub fn w() {} +").unwrap(); + let spec_dir = root.join("specs").join("widget"); + std::fs::create_dir_all(&spec_dir).unwrap(); + let spec_path = spec_dir.join("widget.spec.md"); + std::fs::write( + &spec_path, + "--- +module: widget +version: 1 +status: draft +files: + - src/widget.rs +db_tables: [] +depends_on: [] +--- + +# Widget +", + ) + .unwrap(); + std::fs::write( + spec_dir.join("tasks.md"), + "--- +spec: widget.spec.md +--- + +## Tasks + +- [ ] Add implementation, validation, or release tasks that belong to this spec. +", + ) + .unwrap(); + + let config = crate::types::SpecSyncConfig::default(); + let tables = std::collections::HashSet::new(); + let cols = std::collections::HashMap::new(); + let result = validate_spec(&spec_path, root, &tables, &cols, &config); + assert!( + !result + .warnings + .iter() + .any(|w| w.contains("Unfilled companion scaffold marker")), + "draft spec must not emit scaffold-marker warnings: {:?}", + result.warnings + ); + + // Promoted to active, the same marker is flagged. + std::fs::write( + &spec_path, + "--- +module: widget +version: 1 +status: active +files: + - src/widget.rs +db_tables: [] +depends_on: [] +--- + +# Widget +", + ) + .unwrap(); + let result = validate_spec(&spec_path, root, &tables, &cols, &config); + assert!( + result + .warnings + .iter() + .any(|w| w.contains("Unfilled companion scaffold marker")), + "active spec must still emit scaffold-marker warnings" + ); + } } // ─── Coverage ──────────────────────────────────────────────────────────── @@ -1855,4 +1944,5 @@ pub fn compute_coverage( loc_coverage_percent, unspecced_file_loc, } + }