diff --git a/hubs/cli-check.md b/hubs/cli-check.md index f5fec45..589f944 100644 --- a/hubs/cli-check.md +++ b/hubs/cli-check.md @@ -6,7 +6,7 @@ anchors: hash. No stored hash → Unverified; an anchor that no longer resolves → Unresolvable; a mismatch → Changed. The verdict is deterministic and needs no git. at: surf-cli/src/check.rs > check_claim - hash: c1eed8d5f41b + hash: e04e680e6d8b - claim: > Scoping is opt-in and intersective: with neither --base nor --files every claim is checked. A claim is in scope when any of its anchored files matches each active filter — the --base @@ -14,6 +14,12 @@ anchors: yields no changed set, falling back to a full check rather than checking nothing. at: surf-cli/src/check.rs > Scope > includes hash: 2e21db33542d + - claim: > + The gate fails closed: a hub whose frontmatter won't parse yields an Unresolvable + divergence (blocking the run) rather than being silently skipped, so a frontmatter typo + can't pass as clean. + at: surf-cli/src/check.rs > check_workspace + hash: c29434a58059 refs: [] --- diff --git a/hubs/cli-for.md b/hubs/cli-for.md index 08be830..5f209e1 100644 --- a/hubs/cli-for.md +++ b/hubs/cli-for.md @@ -12,7 +12,7 @@ anchors: no source parse), optionally narrowed to anchors whose first segment is the given symbol. Malformed hubs are skipped rather than erroring, and results are sorted by hub then anchor. at: surf-cli/src/for_path.rs > find - hash: 749bdeb8e9d6 + hash: 047c1480c650 refs: [] --- diff --git a/surf-cli/src/check.rs b/surf-cli/src/check.rs index 7e508db..7c836a4 100644 --- a/surf-cli/src/check.rs +++ b/surf-cli/src/check.rs @@ -7,11 +7,11 @@ use crate::format::Format; use crate::git; use crate::workspace::{read_site, Workspace}; -use anyhow::{Context, Result}; +use anyhow::Result; use std::process::ExitCode; use surf_core::{ - diff_magnitude, hash_anchor_with, parse_anchor, parse_hub, resolve, CheckReport, Divergence, - DivergenceKind, HashOpts, + diff_magnitude, hash_anchor_with, parse_anchor, resolve, CheckReport, Divergence, + DivergenceKind, HashOpts, HubError, }; pub fn run( @@ -47,24 +47,21 @@ fn check_workspace( let enrich_base = base.unwrap_or("HEAD"); let mut out = Vec::new(); - for hub_path in ws.hub_paths()? { - let rel = hub_path - .strip_prefix(&ws.root) - .unwrap_or(&hub_path) - .display() - .to_string(); - let content = std::fs::read_to_string(&hub_path) - .with_context(|| format!("reading {}", hub_path.display()))?; - let Ok(hub) = parse_hub(&content) else { - // Malformed hubs are lint's job; check skips them rather than miscounting. - continue; + for loaded in ws.iter_hubs()? { + let hub = match loaded.hub { + Ok(hub) => hub, + Err(e) => { + // The gate fails closed: an unparseable hub is unenforceable, not clean. + out.push(malformed_hub_divergence(&loaded.rel, &e)); + continue; + } }; for claim in &hub.frontmatter.anchors { if !scope.includes(claim) { continue; } - if let Some(d) = check_claim(ws, &rel, claim, enrich_base) { + if let Some(d) = check_claim(ws, &loaded.rel, claim, enrich_base) { out.push(d); } } @@ -72,6 +69,22 @@ fn check_workspace( Ok(out) } +fn malformed_hub_divergence(hub: &str, err: &HubError) -> Divergence { + Divergence { + hub: hub.to_string(), + claim: String::new(), + at: String::new(), + kind: DivergenceKind::Unresolvable, + old_hash: None, + new_hash: None, + old_code: None, + new_code: None, + prose: String::new(), + magnitude: None, + detail: Some(format!("invalid hub: {err}")), + } +} + /// Which claims `check` evaluates. Each active filter narrows the set; a claim must satisfy /// every active filter (intersection). With neither filter active, every claim is in scope. struct Scope { @@ -173,7 +186,11 @@ fn check_claim( .get(span.start_byte..span.end_byte) .map(str::to_string); } - site_hashes.push(hash_anchor_with(¤t, lang, &anchor, opts).ok()?); + let hash = match hash_anchor_with(¤t, lang, &anchor, opts) { + Ok(h) => h, + Err(e) => return unresolvable(e.to_string()), + }; + site_hashes.push(hash); } let new_hash = surf_core::combine_site_hashes(&site_hashes); @@ -424,6 +441,24 @@ mod tests { ); } + #[test] + fn malformed_hub_blocks_check() { + // A frontmatter typo must fail the gate, not pass silently (#35). + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "surf.toml", ""); + write( + root, + "hubs/a.md", + "---\nsummary: x\nanchors:\n - claim: [unterminated\n---\n", + ); + + let d = check_workspace(&ws_at(root.to_path_buf()), None, &[]).unwrap(); + assert_eq!(d.len(), 1); + assert_eq!(d[0].kind, DivergenceKind::Unresolvable); + assert!(d[0].detail.as_deref().unwrap().starts_with("invalid hub")); + } + #[test] fn json_contract_has_expected_keys() { let tmp = tempfile::tempdir().unwrap(); diff --git a/surf-cli/src/for_path.rs b/surf-cli/src/for_path.rs index 391bb9d..f1a509a 100644 --- a/surf-cli/src/for_path.rs +++ b/surf-cli/src/for_path.rs @@ -5,10 +5,10 @@ use crate::format::Format; use crate::workspace::Workspace; -use anyhow::{Context, Result}; +use anyhow::Result; use serde::Serialize; use std::process::ExitCode; -use surf_core::{parse_anchor, parse_hub, REPORT_VERSION}; +use surf_core::{parse_anchor, REPORT_VERSION}; #[derive(Debug, Clone, Serialize)] struct ForMatch { @@ -57,15 +57,9 @@ fn normalize(ws: &Workspace, path: &str) -> String { fn find(ws: &Workspace, query: &str, symbol: Option<&str>) -> Result> { let mut out = Vec::new(); - for hub_path in ws.hub_paths()? { - let rel_hub = hub_path - .strip_prefix(&ws.root) - .unwrap_or(&hub_path) - .display() - .to_string(); - let content = std::fs::read_to_string(&hub_path) - .with_context(|| format!("reading {}", hub_path.display()))?; - let Ok(hub) = parse_hub(&content) else { + for loaded in ws.iter_hubs()? { + let rel_hub = loaded.rel; + let Ok(hub) = loaded.hub else { // Malformed hubs are lint's job; skip rather than error out of a query. continue; }; diff --git a/surf-cli/src/lint.rs b/surf-cli/src/lint.rs index d323589..9dc0d81 100644 --- a/surf-cli/src/lint.rs +++ b/surf-cli/src/lint.rs @@ -6,14 +6,12 @@ use crate::format::Format; use crate::workspace::Workspace; -use anyhow::{Context, Result}; +use anyhow::Result; use serde::Serialize; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::process::ExitCode; -use surf_core::{ - find_renamed, parse_anchor, parse_hub, public_fns, resolve, HashOpts, Lang, ResolveError, -}; +use surf_core::{find_renamed, parse_anchor, public_fns, resolve, HashOpts, Lang, ResolveError}; /// Over an anchored span this fraction of its file, the anchor is "whole-file-ish" and any /// edit re-triggers verification — the over-anchoring tension of §8. @@ -78,16 +76,9 @@ fn print_human(findings: &[Finding], blocks: usize, warns: usize) { fn lint_workspace(ws: &Workspace) -> Result> { let mut findings = Vec::new(); - for hub_path in ws.hub_paths()? { - let rel = hub_path - .strip_prefix(&ws.root) - .unwrap_or(&hub_path) - .display() - .to_string(); - let content = std::fs::read_to_string(&hub_path) - .with_context(|| format!("reading {}", hub_path.display()))?; - - let hub = match parse_hub(&content) { + for loaded in ws.iter_hubs()? { + let rel = loaded.rel; + let hub = match loaded.hub { Ok(hub) => hub, Err(e) => { findings.push(Finding { diff --git a/surf-cli/src/suggest.rs b/surf-cli/src/suggest.rs index b8b06e3..6d82e9e 100644 --- a/surf-cli/src/suggest.rs +++ b/surf-cli/src/suggest.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result}; use serde::Serialize; use std::collections::HashSet; use std::process::ExitCode; -use surf_core::{parse_anchor, parse_hub, public_symbols, Lang}; +use surf_core::{parse_anchor, public_symbols, Lang}; #[derive(Debug, Clone, Serialize)] struct Suggestion { @@ -65,10 +65,8 @@ pub fn run(ws: &Workspace, globs: &[String], format: Format) -> Result /// `@N` dropped). Keyed on the whole path so anchoring one method doesn't hide its siblings. fn covered_anchors(ws: &Workspace) -> Result> { let mut covered = HashSet::new(); - for hub_path in ws.hub_paths()? { - let content = std::fs::read_to_string(&hub_path) - .with_context(|| format!("reading {}", hub_path.display()))?; - let Ok(hub) = parse_hub(&content) else { + for loaded in ws.iter_hubs()? { + let Ok(hub) = loaded.hub else { continue; }; for claim in &hub.frontmatter.anchors { diff --git a/surf-cli/src/workspace.rs b/surf-cli/src/workspace.rs index 85525cd..da6d26b 100644 --- a/surf-cli/src/workspace.rs +++ b/surf-cli/src/workspace.rs @@ -5,13 +5,21 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use surf_core::config::{parse_config, Config, CONFIG_FILE}; -use surf_core::{parse_anchor, Anchor, Lang}; +use surf_core::{parse_anchor, parse_hub, Anchor, Hub, HubError, Lang}; pub struct Workspace { pub root: PathBuf, pub config: Config, } +/// One hub file located, read, and parsed. `hub` carries the parse result per-hub so each +/// command must consciously decide what to do with a malformed hub (block, skip, warn) +/// rather than re-implementing — and diverging on — that choice. +pub struct LoadedHub { + pub rel: String, + pub hub: Result, +} + impl Workspace { pub fn discover(start: &Path) -> Result { for dir in start.ancestors() { @@ -47,6 +55,25 @@ impl Workspace { out.dedup(); Ok(out) } + + /// Read and parse every hub. I/O failure hard-errors the run (an unreadable hub is + /// exceptional); a *parse* failure is carried per-hub in `LoadedHub::hub` so each + /// caller handles it explicitly. + pub fn iter_hubs(&self) -> Result> { + let mut out = Vec::new(); + for path in self.hub_paths()? { + let rel = path + .strip_prefix(&self.root) + .unwrap_or(&path) + .display() + .to_string(); + let content = std::fs::read_to_string(&path) + .with_context(|| format!("reading {}", path.display()))?; + let hub = parse_hub(&content); + out.push(LoadedHub { rel, hub }); + } + Ok(out) + } } /// Why a single `at:` site couldn't be loaded for hashing/resolution. Distinct variants so