diff --git a/docs/guides/authoring-hubs.md b/docs/guides/authoring-hubs.md index 3887cd4..205c2ff 100644 --- a/docs/guides/authoring-hubs.md +++ b/docs/guides/authoring-hubs.md @@ -29,6 +29,10 @@ Prose a human (or agent) reads to understand this domain. - **`at`** — the anchor: where the claim's logic lives (grammar below). - **`hash`** — the seal. Absent until you `surf verify`; the gate treats a hashless claim as *unverified*. +- **`refs` / `covers`** — forward-declared and currently inert. `refs` (hub composition) is parsed + but unused; `covers` (advisory file-scope globs) is parsed and lint-validated but never affects + `surf check`. Leave them empty unless you have a reason — the features that consume them aren't + shipped. Where hubs live is configured by the `hubs` glob in `surf.toml` (default `hubs/*.md`); keep them central or co-locate them with code (`["**/_hub.md"]`). diff --git a/docs/surface-proposal.md b/docs/surface-proposal.md index ffdd910..cc0a133 100644 --- a/docs/surface-proposal.md +++ b/docs/surface-proposal.md @@ -178,7 +178,7 @@ Then **stop**. No `refs` resolver, no catalog, no MCP service, **no reviewer plu **A note on the CI step that everyone gets wrong:** the gate hashes the working-tree span and compares to the hash committed in frontmatter. It needs the checkout, *not* full git history — do **not** `fetch-depth: 0`. The only thing the base ref buys you is scoping (re-check only anchors whose files changed in the PR), and a shallow fetch of the merge base covers that. -**`covers` is deliberately absent from the MVP schema.** It is consumed only by the reviewer plugin (§7), which the MVP excludes — so asking authors to write it now would be maintaining a field that does nothing, the exact ceremony §4 warns against. Forward-declared, not shipped. +**`covers` is authorable but inert.** The field is accepted, stored, and lint-validated (a malformed glob blocks), but **the verdict never reads it** — `surf check` is byte-for-byte identical whether or not a hub declares `covers`. The louder coverage pass that consumes these globs is consumed only by the reviewer plugin (§7), which the MVP excludes, and stays deferred until its false-negative rate is measured. So a hub may declare its scope today; nothing acts on it yet. ### 9.2 The MVP has a falsifiable success criterion diff --git a/hubs/hub-format.md b/hubs/hub-format.md index 25440a6..1e4307d 100644 --- a/hubs/hub-format.md +++ b/hubs/hub-format.md @@ -3,8 +3,8 @@ summary: The hub document format and the minimal-diff frontmatter editor used by anchors: - claim: > A hub is a `---`-fenced YAML frontmatter block followed by a markdown body; `at:` is a - scalar or a list, hash is optional until verified, and unknown fields (e.g. covers) are - rejected. + scalar or a list, hash is optional until verified, and unknown fields are rejected — though + forward-declared fields (`refs`, `covers`) are accepted and stored but inert in the verdict. at: surf-core/src/hub.rs > parse_hub hash: e97cc54f48d3 - claim: > @@ -13,6 +13,8 @@ anchors: at: surf-core/src/hub.rs > set_anchor_hash hash: a65d5c324dc5 refs: [] +covers: + - surf-core/src/hub.rs --- # Hub format diff --git a/surf-cli/src/lint.rs b/surf-cli/src/lint.rs index 4dec6e0..094da8e 100644 --- a/surf-cli/src/lint.rs +++ b/surf-cli/src/lint.rs @@ -145,6 +145,8 @@ fn lint_workspace(ws: &Workspace) -> Result> { } } + lint_covers(&rel, &hub, &mut findings); + if hub.frontmatter.anchors.len() > MAX_ANCHORS_PER_HUB { findings.push(Finding { severity: Severity::Warn, @@ -218,6 +220,59 @@ fn lint_agents_pointer(ws: &Workspace, findings: &mut Vec) { } } +/// Validate a hub's advisory `covers` globs (§9.1). The verdict never reads `covers`, so this +/// is the only place a malformed glob can be caught — a bad pattern blocks (silently dropping it +/// would let a typo'd scope go unnoticed, the same reasoning as `--files` in `check`, #38). When +/// the globs are well-formed, warn for any of the hub's own anchored files that none of them +/// match: a hub whose `covers` excludes its own anchors is almost certainly a fat-fingered glob. +fn lint_covers(rel: &str, hub: &surf_core::Hub, findings: &mut Vec) { + if hub.frontmatter.covers.is_empty() { + return; + } + + let mut patterns = Vec::new(); + for raw in &hub.frontmatter.covers { + match glob::Pattern::new(raw) { + Ok(p) => patterns.push(p), + Err(e) => findings.push(Finding { + severity: Severity::Block, + hub: rel.to_string(), + claim: String::new(), + at: raw.clone(), + message: format!("invalid `covers` glob \"{raw}\": {e}"), + }), + } + } + if patterns.len() != hub.frontmatter.covers.len() { + return; // a glob didn't compile — don't run the coverage nudge on a partial pattern set + } + + // The hub's own anchored files (deduped, sorted for deterministic output). + let mut anchored: Vec = hub + .frontmatter + .anchors + .iter() + .flat_map(|c| c.at.sites()) + .filter_map(|s| parse_anchor(s).ok().map(|a| a.file)) + .collect(); + anchored.sort(); + anchored.dedup(); + + for file in anchored { + if !patterns.iter().any(|p| p.matches(&file)) { + findings.push(Finding { + severity: Severity::Warn, + hub: rel.to_string(), + claim: String::new(), + at: file.clone(), + message: format!( + "anchored file `{file}` is not matched by any `covers` glob — check the globs cover this hub's own anchors" + ), + }); + } + } +} + /// Markdown link targets (`](target)`) in a fragment of text. fn link_targets(text: &str) -> impl Iterator { text.split("](") @@ -644,6 +699,55 @@ mod tests { ); } + #[test] + fn covers_valid_globs_are_silent() { + let (_t, ws) = ws_with(&[ + ("src/auth.rs", "pub fn greet() {}\n"), + ( + "hubs/a.md", + "---\nsummary: x\ncovers:\n - src/**\nanchors:\n - claim: greeting\n at: src/auth.rs > greet\n---\n", + ), + ]); + assert!(lint_workspace(&ws).unwrap().is_empty()); + } + + #[test] + fn covers_malformed_glob_blocks() { + let (_t, ws) = ws_with(&[ + ("src/auth.rs", "pub fn greet() {}\n"), + ( + "hubs/a.md", + "---\nsummary: x\ncovers:\n - 'src/[unterminated'\nanchors:\n - claim: greeting\n at: src/auth.rs > greet\n---\n", + ), + ]); + let f = lint_workspace(&ws).unwrap(); + let block = f + .iter() + .find(|x| x.message.contains("invalid `covers` glob")) + .expect("expected a covers glob error"); + assert_eq!(block.severity, Severity::Block); + } + + #[test] + fn covers_not_matching_own_anchor_warns() { + // `covers` scopes only `lib/**`, but the hub anchors a file under `src/` — the fat-finger + // the nudge exists to catch. + let (_t, ws) = ws_with(&[ + ("src/auth.rs", "pub fn greet() {}\n"), + ( + "hubs/a.md", + "---\nsummary: x\ncovers:\n - lib/**\nanchors:\n - claim: greeting\n at: src/auth.rs > greet\n---\n", + ), + ]); + let f = lint_workspace(&ws).unwrap(); + let warn = f + .iter() + .find(|x| x.message.contains("not matched by any `covers` glob")) + .expect("expected an unmatched-anchor warning"); + assert_eq!(warn.severity, Severity::Warn); + assert_eq!(warn.at, "src/auth.rs"); + } + fn agents_findings(ws: &Workspace) -> Vec { lint_workspace(ws) .unwrap() diff --git a/surf-core/src/hub.rs b/surf-core/src/hub.rs index bd5a734..a70e393 100644 --- a/surf-core/src/hub.rs +++ b/surf-core/src/hub.rs @@ -19,6 +19,12 @@ pub struct Frontmatter { /// Hub composition. Forward-declared per §9.3 — parsed but inert in the MVP. #[serde(default)] pub refs: Vec, + /// Advisory coverage scope: repo-root-relative globs (same dialect as `config.hubs`) + /// naming the files this hub claims a relationship with. Forward-declared per §9.1 — + /// parsed, stored, and lint-validated, but **the verdict never reads it** (§5/§8). The + /// louder coverage pass that consumes these globs is deferred to #5. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub covers: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -288,11 +294,36 @@ mod tests { } #[test] - fn covers_field_is_rejected() { - // `covers` is deliberately absent from the MVP schema (§9.1); deny_unknown_fields - // surfaces it as a clear error rather than silently ignoring a field that does nothing. - let err = parse_hub("---\nsummary: x\ncovers:\n - src/**\n---\nbody\n").unwrap_err(); - assert!(matches!(err, HubError::Yaml(_))); + fn covers_field_is_accepted() { + // `covers` is forward-declared per §9.1: parsed and stored, but inert in the verdict + // (the louder coverage pass that consumes it is deferred to #5). + let hub = + parse_hub("---\nsummary: x\ncovers:\n - src/**\n - lib/foo.rs\n---\nbody\n").unwrap(); + assert_eq!( + hub.frontmatter.covers, + vec!["src/**".to_string(), "lib/foo.rs".to_string()] + ); + } + + #[test] + fn covers_defaults_to_empty() { + // A hub that omits `covers` parses with an empty list, and serializing it back does not + // introduce an empty `covers:` key — existing hubs are byte-unaffected. + let hub = parse_hub("---\nsummary: x\n---\nbody\n").unwrap(); + assert!(hub.frontmatter.covers.is_empty()); + let yaml = serde_yaml::to_string(&hub.frontmatter).unwrap(); + assert!( + !yaml.contains("covers"), + "empty covers should not serialize: {yaml}" + ); + } + + #[test] + fn covers_round_trips() { + let hub = parse_hub("---\nsummary: x\ncovers:\n - src/**\n---\nbody\n").unwrap(); + let yaml = serde_yaml::to_string(&hub.frontmatter).unwrap(); + let reparsed: Frontmatter = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(hub.frontmatter, reparsed); } #[test]