Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions specs/config/config.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ The configuration file supports the following top-level sections:
6. Root-level source files (no subdirectories) produce `["."]` as source dirs
7. TOML parsing is zero-dependency — uses line-by-line string parsing, not a TOML library
8. The reader accepts both TOML string kinds for scalar and array values: basic `"..."` strings (backslash escapes decoded) and literal `'...'` strings (taken verbatim, no escape processing); a `#`, `,`, `[`, or `]` appearing inside either kind is treated as content, not as a comment or array structure
9. A config file that is absent is expected — defaults apply silently. But a config file that **exists yet cannot be read** (e.g. not valid UTF-8) fails loud: a warning naming the file is printed and built-in defaults are used, rather than silently reverting to defaults (which would downgrade enforcement — strict→warn, exit 1→0 — with no signal). The same applies to the optional local override file (`config.local.toml`)

## Behavioral Examples

Expand Down
1 change: 1 addition & 0 deletions specs/deps/deps.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Cross-module dependency validation. Parses `depends_on` declarations from spec f
4. Cross-project refs (containing `/`) in `depends_on` are skipped — only local deps are validated
5. Undeclared imports (found in source but not in `depends_on`) are reported as warnings, not errors
6. Module names in `depends_on` paths are extracted from the path's directory component
7. A spec or declared source file that exists but cannot be read as UTF-8 is a hard error (not a silent skip): an unreadable spec would otherwise be dropped from the graph — defeating cycle and missing-dependency detection for that module — and an unreadable source would contribute no imports, hiding undeclared-import violations. Both are recorded in `report.errors`, so `cmd_deps` exits 1 rather than under-validating silently

## Behavioral Examples

Expand Down
44 changes: 41 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,19 @@ pub fn load_config(root: &Path) -> SpecSyncConfig {
fn merge_local_config(local_path: &Path, config: &mut SpecSyncConfig) {
let content = match read_config_file(local_path) {
Some(c) => c,
None => return,
None => {
// Local config is optional, but a file that exists yet cannot be read
// (not valid UTF-8) must not be silently ignored — its per-developer
// overrides (ai_provider/ai_model/etc.) would be dropped with no signal.
if local_path.exists() {
eprintln!(
"Warning: local config {} exists but could not be read (not valid UTF-8 or unreadable); \
per-developer overrides are NOT applied",
local_path.display()
);
}
return;
}
};

let mut current_section: Option<String> = None;
Expand Down Expand Up @@ -695,7 +707,18 @@ const KNOWN_JSON_KEYS: &[&str] = &[
fn load_json_config(config_path: &Path, root: &Path) -> SpecSyncConfig {
let content = match read_config_file(config_path) {
Some(c) => c,
None => return SpecSyncConfig::default(),
None => {
// See load_toml_config: a present-but-unreadable config must fail loud
// rather than silently downgrade enforcement to defaults.
if config_path.exists() {
eprintln!(
"Warning: config file {} exists but could not be read (not valid UTF-8 or unreadable); \
using built-in defaults — its settings (enforcement, required sections, etc.) are NOT applied",
config_path.display()
);
}
return SpecSyncConfig::default();
}
};

// Warn about unknown keys
Expand Down Expand Up @@ -742,7 +765,22 @@ fn load_json_config(config_path: &Path, root: &Path) -> SpecSyncConfig {
fn load_toml_config(config_path: &Path, root: &Path) -> SpecSyncConfig {
let content = match read_config_file(config_path) {
Some(c) => c,
None => return SpecSyncConfig::default(),
None => {
// read_config_file returns None for both "absent" and "present but
// unreadable". This function is reached only after an `.exists()` check
// (load_config) or with a known migration source path, so a None on a
// file that still exists means it could not be read (e.g. not valid
// UTF-8) — fail loud instead of silently reverting to defaults, which
// would downgrade enforcement (strict→warn, exit 1→0) with no signal.
if config_path.exists() {
eprintln!(
"Warning: config file {} exists but could not be read (not valid UTF-8 or unreadable); \
using built-in defaults — its settings (enforcement, required sections, etc.) are NOT applied",
config_path.display()
);
}
return SpecSyncConfig::default();
}
};

let mut config = SpecSyncConfig::default();
Expand Down
47 changes: 43 additions & 4 deletions src/deps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,39 @@ pub struct DepsReport {

/// Build the dependency graph from all spec files in the project.
pub fn build_dep_graph(root: &Path, specs_dir: &str) -> HashMap<String, DepNode> {
build_dep_graph_checked(root, specs_dir).0
}

/// Like `build_dep_graph`, but also returns error messages for spec files that
/// EXIST yet could not be read as UTF-8. Silently dropping such a spec removes its
/// node from the graph, which defeats cycle detection and missing-dependency
/// checks for that module — so the validation path (`validate_deps`) surfaces
/// these as hard errors rather than losing them. Kept internal; `build_dep_graph`
/// preserves the original signature for the non-gating callers (visualization,
/// topological order).
fn build_dep_graph_checked(
root: &Path,
specs_dir: &str,
) -> (HashMap<String, DepNode>, Vec<String>) {
let specs_path = root.join(specs_dir);
let spec_files = find_spec_files(&specs_path);
let mut graph: HashMap<String, DepNode> = HashMap::new();
let mut unreadable: Vec<String> = Vec::new();

for spec_file in &spec_files {
let content = match fs::read_to_string(spec_file) {
Ok(c) => c.replace("\r\n", "\n"),
Err(_) => continue,
Err(err) => {
let rel = spec_file
.strip_prefix(root)
.unwrap_or(spec_file)
.to_string_lossy()
.to_string();
unreadable.push(format!(
"{rel}: spec file could not be read as UTF-8; dependency analysis skipped this spec: {err}"
));
continue;
}
};

let parsed = match parse_frontmatter(&content) {
Expand Down Expand Up @@ -117,7 +142,7 @@ pub fn build_dep_graph(root: &Path, specs_dir: &str) -> HashMap<String, DepNode>
);
}

graph
(graph, unreadable)
}

/// Extract a module name from a dependency path.
Expand Down Expand Up @@ -146,8 +171,11 @@ fn extract_module_from_dep_path(dep: &str) -> Option<String> {

/// Validate the entire dependency graph.
pub fn validate_deps(root: &Path, specs_dir: &str) -> DepsReport {
let graph = build_dep_graph(root, specs_dir);
let (graph, unreadable_specs) = build_dep_graph_checked(root, specs_dir);
let mut report = DepsReport::default();
// A spec that exists but couldn't be read was dropped from the graph; record it
// as a hard error so cmd_deps exits 1 instead of silently under-validating.
report.errors.extend(unreadable_specs);

let known_modules: HashSet<&str> = graph.keys().map(|k| k.as_str()).collect();
report.module_count = graph.len();
Expand Down Expand Up @@ -326,7 +354,18 @@ fn check_undeclared_imports(
let full_path = root.join(file);
let content = match fs::read_to_string(&full_path) {
Ok(c) => c,
Err(_) => continue,
// A declared source file that can't be read as UTF-8 silently
// contributed no imports, so `deps --strict` could pass while
// hiding real undeclared-import violations. Fail loud instead —
// mirrors the validator's source-read policy (validator.rs) and,
// because cmd_deps exits 1 on any error, gates CI consistently.
Err(err) => {
report.errors.push(format!(
"{}: source file `{}` could not be read as UTF-8 for dependency analysis: {}",
node.spec_path, file, err
));
continue;
}
};
let file_imports = extract_imports(&full_path, &content);
actual_imports.extend(file_imports);
Expand Down
72 changes: 72 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6455,6 +6455,78 @@ fn deps_strict_passes_when_dependency_is_declared() {
.success();
}

#[test]
fn deps_fails_loud_on_unreadable_source_file() {
// Regression: a declared source file that can't be read as UTF-8 silently
// contributed no imports, so `deps` could pass while hiding undeclared imports.
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_config(root, "specs", &["src"]);
fs::create_dir_all(root.join("src")).unwrap();
let mut bad = b"export function apiFn() {}\n".to_vec();
bad.push(0xFF);
fs::write(root.join("src/a.ts"), bad).unwrap();
fs::create_dir_all(root.join("specs/m")).unwrap();
fs::write(
root.join("specs/m/m.spec.md"),
valid_spec("m", &["src/a.ts"]),
)
.unwrap();

specsync()
.current_dir(root)
.arg("deps")
.assert()
.failure()
.stdout(predicate::str::contains(
"could not be read as UTF-8 for dependency analysis",
));
}

#[test]
fn deps_fails_loud_on_unreadable_spec_file() {
// Regression: a spec file that can't be read as UTF-8 was silently dropped from
// the dependency graph, defeating cycle / missing-dep detection for that module.
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_config(root, "specs", &["src"]);
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/a.rs"), "pub fn f() {}\n").unwrap();
fs::create_dir_all(root.join("specs/m")).unwrap();
let mut bad = b"---\nmodule: m\nfiles:\n - src/a.rs\n---\n# m\n".to_vec();
bad.push(0xFF);
fs::write(root.join("specs/m/m.spec.md"), bad).unwrap();

specsync()
.current_dir(root)
.arg("deps")
.assert()
.failure()
.stdout(predicate::str::contains(
"spec file could not be read as UTF-8",
));
}

#[test]
fn config_warns_on_unreadable_config_file() {
// Regression: a config file that exists but can't be read as UTF-8 silently
// reverted to built-in defaults, downgrading enforcement with no signal.
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/a.rs"), "pub fn f() {}\n").unwrap();
// A config whose keys are valid ASCII but whose tail is invalid UTF-8.
let mut bad = b"specs_dir = \"specs\"\nsource_dirs = [\"src\"]\n".to_vec();
bad.extend_from_slice(&[0xFF, 0xFE]);
fs::write(root.join(".specsync.toml"), bad).unwrap();

specsync()
.current_dir(root)
.arg("check")
.assert()
.stderr(predicate::str::contains("exists but could not be read"));
}

#[test]
fn deps_strict_mermaid_still_gates() {
// Regression: `deps --mermaid`/`--dot` early-returned before the strict gate, so
Expand Down
Loading