Skip to content
Merged
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
8 changes: 7 additions & 1 deletion hubs/cli-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,20 @@ 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
changed-files set (merge-base..working-tree) and/or the --files globs. A bad ref or non-repo
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: []
---

Expand Down
2 changes: 1 addition & 1 deletion hubs/cli-for.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
---

Expand Down
67 changes: 51 additions & 16 deletions surf-cli/src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -47,31 +47,44 @@ 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);
}
}
}
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 {
Expand Down Expand Up @@ -173,7 +186,11 @@ fn check_claim(
.get(span.start_byte..span.end_byte)
.map(str::to_string);
}
site_hashes.push(hash_anchor_with(&current, lang, &anchor, opts).ok()?);
let hash = match hash_anchor_with(&current, 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);

Expand Down Expand Up @@ -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();
Expand Down
16 changes: 5 additions & 11 deletions surf-cli/src/for_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -57,15 +57,9 @@ fn normalize(ws: &Workspace, path: &str) -> String {

fn find(ws: &Workspace, query: &str, symbol: Option<&str>) -> Result<Vec<ForMatch>> {
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;
};
Expand Down
19 changes: 5 additions & 14 deletions surf-cli/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -78,16 +76,9 @@ fn print_human(findings: &[Finding], blocks: usize, warns: usize) {

fn lint_workspace(ws: &Workspace) -> Result<Vec<Finding>> {
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 {
Expand Down
8 changes: 3 additions & 5 deletions surf-cli/src/suggest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -65,10 +65,8 @@ pub fn run(ws: &Workspace, globs: &[String], format: Format) -> Result<ExitCode>
/// `@N` dropped). Keyed on the whole path so anchoring one method doesn't hide its siblings.
fn covered_anchors(ws: &Workspace) -> Result<HashSet<String>> {
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 {
Expand Down
29 changes: 28 additions & 1 deletion surf-cli/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hub, HubError>,
}

impl Workspace {
pub fn discover(start: &Path) -> Result<Workspace> {
for dir in start.ancestors() {
Expand Down Expand Up @@ -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<Vec<LoadedHub>> {
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
Expand Down