Skip to content

Commit d960518

Browse files
0xLeifclaude
andauthored
Fix: file-read fail-loud for config + deps (unreadable-but-present files silently degraded) (#325)
* Fix: file-read fail-loud for config + deps — unreadable-but-present files no longer silently degrade Audit Batch 3 (config & deps half) — three "a file that EXISTS but can't be read must fail loud, not silently degrade" defects: - **[medium] Unreadable config silently reverts to defaults.** load_toml_config / load_json_config mapped a read failure to SpecSyncConfig::default() with no signal, downgrading enforcement (strict→warn, exit 1→0) on a config that was present but not valid UTF-8. Now, since callers reach these only after an `.exists()` check, a None on a still-existing file warns loudly (naming the file) before falling back. Same for the optional local override (config.local.toml). - **[low] deps drops an unreadable source's imports.** check_undeclared_imports did `Err(_) => continue`, so a non-UTF-8 declared source contributed no imports and `deps --strict` could pass while hiding undeclared-import violations. Now pushes a hard error (mirrors validator.rs's source-read policy); cmd_deps exits 1. - **[medium] deps drops an unreadable spec from the graph.** build_dep_graph did `Err(_) => continue`, silently removing the node — defeating cycle and missing-dependency detection for that module. Refactored into an internal build_dep_graph_checked that also returns read-failure messages; validate_deps extends report.errors with them (build_dep_graph keeps its signature for the non-gating visualization / topological-order callers). cmd_deps exits 1. Reproduced: non-UTF-8 config → loud "exists but could not be read" warning (was silent); non-UTF-8 declared source → deps error + exit 1 (was exit 0); non-UTF-8 spec → deps error + exit 1 (was exit 0, node silently dropped). Tests: 3 integration regressions (deps unreadable source, deps unreadable spec, config unreadable warning). Documented as config spec invariant #9 and deps spec invariant #7. 750 unit + 180 integration, fmt / clippy / self-check 100%. (The schema-discovery fail-open — build_schema / get_schema_table_names silently skipping unreadable migration files — is the same theme but needs signature changes across schema.rs + validator.rs + check; it ships as its own follow-up PR.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDJxU4R8hUEuq1Y5jzft5m * Update: sync deps/config specs with fail-loud behavior (review changes) Addresses Kyntrin's CHANGES_REQUESTED on #325: - deps.spec.md: Error Cases table now reflects hard-error behavior for unreadable declared source AND spec files (was "Skipped during import extraction"); added unreadable-spec row. Version bump 2->3 + Change Log. - config.spec.md: Error Cases table made explicit that a present-but- unreadable config warns loudly before falling back. Version bump 1->2 + Change Log. specsync check --strict passes (60/60). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent bdb06fb commit d960518

5 files changed

Lines changed: 166 additions & 11 deletions

File tree

specs/config/config.spec.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
module: config
3-
version: 1
3+
version: 2
44
status: stable
55
files:
66
- src/config.rs
@@ -60,6 +60,7 @@ The configuration file supports the following top-level sections:
6060
6. Root-level source files (no subdirectories) produce `["."]` as source dirs
6161
7. TOML parsing is zero-dependency — uses line-by-line string parsing, not a TOML library
6262
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
63+
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`)
6364

6465
## Behavioral Examples
6566

@@ -85,7 +86,8 @@ The configuration file supports the following top-level sections:
8586

8687
| Condition | Behavior |
8788
|-----------|----------|
88-
| Config file unreadable | Falls back to `SpecSyncConfig::default()` |
89+
| Config file exists but unreadable (e.g. not valid UTF-8) | Prints a warning naming the file to stderr, then falls back to `SpecSyncConfig::default()` (fail-loud, not silent) |
90+
| Config file absent | Silently uses `SpecSyncConfig::default()` with auto-detected source dirs (expected) |
8991
| Malformed JSON config | Prints warning to stderr, falls back to defaults |
9092
| Empty project root | Returns `["src"]` as source dirs |
9193

@@ -116,3 +118,4 @@ The configuration file supports the following top-level sections:
116118
| 2026-03-28 | Document discover_manifest_modules |
117119
| 2026-04-06 | Document github config section, rules section, and full config file structure |
118120
| 2026-07-03 | Document `read_config_file` (leading-BOM-tolerant config read, shared with `migrate`) |
121+
| 2026-07-06 | Document fail-loud behavior for present-but-unreadable config (invariant 9): unreadable config/local-override files now warn loudly before falling back to defaults; clarified Error Cases table |

specs/deps/deps.spec.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
module: deps
3-
version: 2
3+
version: 3
44
status: stable
55
files:
66
- src/deps.rs
@@ -45,6 +45,7 @@ Cross-module dependency validation. Parses `depends_on` declarations from spec f
4545
4. Cross-project refs (containing `/`) in `depends_on` are skipped — only local deps are validated
4646
5. Undeclared imports (found in source but not in `depends_on`) are reported as warnings, not errors
4747
6. Module names in `depends_on` paths are extracted from the path's directory component
48+
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
4849

4950
## Behavioral Examples
5051

@@ -70,7 +71,8 @@ Cross-module dependency validation. Parses `depends_on` declarations from spec f
7071

7172
| Condition | Behavior |
7273
|-----------|----------|
73-
| Source file unreadable | Skipped during import extraction |
74+
| Declared source file exists but unreadable as UTF-8 | Hard error recorded in `report.errors`; `cmd_deps` exits 1 (not skipped during import extraction) |
75+
| Spec file exists but unreadable as UTF-8 | Hard error recorded in `report.errors`; node is not silently dropped from the graph; `cmd_deps` exits 1 |
7476
| Spec frontmatter unparseable | Module excluded from dependency graph |
7577
| No specs found in specs_dir | Returns empty graph and clean DepsReport |
7678

@@ -94,5 +96,6 @@ Cross-module dependency validation. Parses `depends_on` declarations from spec f
9496

9597
| Date | Change |
9698
|------|--------|
99+
| 2026-07-06 | Documented fail-loud behavior for unreadable declared source and spec files: added invariant 7 and updated Error Cases table so an existing-but-non-UTF-8 file is a hard error gating `cmd_deps` rather than a silent skip |
97100
| 2026-04-10 | Populated requirements.md with user stories, acceptance criteria, constraints, and out-of-scope items |
98101
| 2026-04-07 | Initial spec |

src/config.rs

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,19 @@ pub fn load_config(root: &Path) -> SpecSyncConfig {
249249
fn merge_local_config(local_path: &Path, config: &mut SpecSyncConfig) {
250250
let content = match read_config_file(local_path) {
251251
Some(c) => c,
252-
None => return,
252+
None => {
253+
// Local config is optional, but a file that exists yet cannot be read
254+
// (not valid UTF-8) must not be silently ignored — its per-developer
255+
// overrides (ai_provider/ai_model/etc.) would be dropped with no signal.
256+
if local_path.exists() {
257+
eprintln!(
258+
"Warning: local config {} exists but could not be read (not valid UTF-8 or unreadable); \
259+
per-developer overrides are NOT applied",
260+
local_path.display()
261+
);
262+
}
263+
return;
264+
}
253265
};
254266

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

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

748786
let mut config = SpecSyncConfig::default();

src/deps.rs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,39 @@ pub struct DepsReport {
6969

7070
/// Build the dependency graph from all spec files in the project.
7171
pub fn build_dep_graph(root: &Path, specs_dir: &str) -> HashMap<String, DepNode> {
72+
build_dep_graph_checked(root, specs_dir).0
73+
}
74+
75+
/// Like `build_dep_graph`, but also returns error messages for spec files that
76+
/// EXIST yet could not be read as UTF-8. Silently dropping such a spec removes its
77+
/// node from the graph, which defeats cycle detection and missing-dependency
78+
/// checks for that module — so the validation path (`validate_deps`) surfaces
79+
/// these as hard errors rather than losing them. Kept internal; `build_dep_graph`
80+
/// preserves the original signature for the non-gating callers (visualization,
81+
/// topological order).
82+
fn build_dep_graph_checked(
83+
root: &Path,
84+
specs_dir: &str,
85+
) -> (HashMap<String, DepNode>, Vec<String>) {
7286
let specs_path = root.join(specs_dir);
7387
let spec_files = find_spec_files(&specs_path);
7488
let mut graph: HashMap<String, DepNode> = HashMap::new();
89+
let mut unreadable: Vec<String> = Vec::new();
7590

7691
for spec_file in &spec_files {
7792
let content = match fs::read_to_string(spec_file) {
7893
Ok(c) => c.replace("\r\n", "\n"),
79-
Err(_) => continue,
94+
Err(err) => {
95+
let rel = spec_file
96+
.strip_prefix(root)
97+
.unwrap_or(spec_file)
98+
.to_string_lossy()
99+
.to_string();
100+
unreadable.push(format!(
101+
"{rel}: spec file could not be read as UTF-8; dependency analysis skipped this spec: {err}"
102+
));
103+
continue;
104+
}
80105
};
81106

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

120-
graph
145+
(graph, unreadable)
121146
}
122147

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

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

152180
let known_modules: HashSet<&str> = graph.keys().map(|k| k.as_str()).collect();
153181
report.module_count = graph.len();
@@ -326,7 +354,18 @@ fn check_undeclared_imports(
326354
let full_path = root.join(file);
327355
let content = match fs::read_to_string(&full_path) {
328356
Ok(c) => c,
329-
Err(_) => continue,
357+
// A declared source file that can't be read as UTF-8 silently
358+
// contributed no imports, so `deps --strict` could pass while
359+
// hiding real undeclared-import violations. Fail loud instead —
360+
// mirrors the validator's source-read policy (validator.rs) and,
361+
// because cmd_deps exits 1 on any error, gates CI consistently.
362+
Err(err) => {
363+
report.errors.push(format!(
364+
"{}: source file `{}` could not be read as UTF-8 for dependency analysis: {}",
365+
node.spec_path, file, err
366+
));
367+
continue;
368+
}
330369
};
331370
let file_imports = extract_imports(&full_path, &content);
332371
actual_imports.extend(file_imports);

tests/integration.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6455,6 +6455,78 @@ fn deps_strict_passes_when_dependency_is_declared() {
64556455
.success();
64566456
}
64576457

6458+
#[test]
6459+
fn deps_fails_loud_on_unreadable_source_file() {
6460+
// Regression: a declared source file that can't be read as UTF-8 silently
6461+
// contributed no imports, so `deps` could pass while hiding undeclared imports.
6462+
let tmp = TempDir::new().unwrap();
6463+
let root = tmp.path();
6464+
write_config(root, "specs", &["src"]);
6465+
fs::create_dir_all(root.join("src")).unwrap();
6466+
let mut bad = b"export function apiFn() {}\n".to_vec();
6467+
bad.push(0xFF);
6468+
fs::write(root.join("src/a.ts"), bad).unwrap();
6469+
fs::create_dir_all(root.join("specs/m")).unwrap();
6470+
fs::write(
6471+
root.join("specs/m/m.spec.md"),
6472+
valid_spec("m", &["src/a.ts"]),
6473+
)
6474+
.unwrap();
6475+
6476+
specsync()
6477+
.current_dir(root)
6478+
.arg("deps")
6479+
.assert()
6480+
.failure()
6481+
.stdout(predicate::str::contains(
6482+
"could not be read as UTF-8 for dependency analysis",
6483+
));
6484+
}
6485+
6486+
#[test]
6487+
fn deps_fails_loud_on_unreadable_spec_file() {
6488+
// Regression: a spec file that can't be read as UTF-8 was silently dropped from
6489+
// the dependency graph, defeating cycle / missing-dep detection for that module.
6490+
let tmp = TempDir::new().unwrap();
6491+
let root = tmp.path();
6492+
write_config(root, "specs", &["src"]);
6493+
fs::create_dir_all(root.join("src")).unwrap();
6494+
fs::write(root.join("src/a.rs"), "pub fn f() {}\n").unwrap();
6495+
fs::create_dir_all(root.join("specs/m")).unwrap();
6496+
let mut bad = b"---\nmodule: m\nfiles:\n - src/a.rs\n---\n# m\n".to_vec();
6497+
bad.push(0xFF);
6498+
fs::write(root.join("specs/m/m.spec.md"), bad).unwrap();
6499+
6500+
specsync()
6501+
.current_dir(root)
6502+
.arg("deps")
6503+
.assert()
6504+
.failure()
6505+
.stdout(predicate::str::contains(
6506+
"spec file could not be read as UTF-8",
6507+
));
6508+
}
6509+
6510+
#[test]
6511+
fn config_warns_on_unreadable_config_file() {
6512+
// Regression: a config file that exists but can't be read as UTF-8 silently
6513+
// reverted to built-in defaults, downgrading enforcement with no signal.
6514+
let tmp = TempDir::new().unwrap();
6515+
let root = tmp.path();
6516+
fs::create_dir_all(root.join("src")).unwrap();
6517+
fs::write(root.join("src/a.rs"), "pub fn f() {}\n").unwrap();
6518+
// A config whose keys are valid ASCII but whose tail is invalid UTF-8.
6519+
let mut bad = b"specs_dir = \"specs\"\nsource_dirs = [\"src\"]\n".to_vec();
6520+
bad.extend_from_slice(&[0xFF, 0xFE]);
6521+
fs::write(root.join(".specsync.toml"), bad).unwrap();
6522+
6523+
specsync()
6524+
.current_dir(root)
6525+
.arg("check")
6526+
.assert()
6527+
.stderr(predicate::str::contains("exists but could not be read"));
6528+
}
6529+
64586530
#[test]
64596531
fn deps_strict_mermaid_still_gates() {
64606532
// Regression: `deps --mermaid`/`--dot` early-returned before the strict gate, so

0 commit comments

Comments
 (0)