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
4 changes: 4 additions & 0 deletions docs/guides/authoring-hubs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]`).
Expand Down
2 changes: 1 addition & 1 deletion docs/surface-proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions hubs/hub-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: >
Expand All @@ -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
Expand Down
104 changes: 104 additions & 0 deletions surf-cli/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ fn lint_workspace(ws: &Workspace) -> Result<Vec<Finding>> {
}
}

lint_covers(&rel, &hub, &mut findings);

if hub.frontmatter.anchors.len() > MAX_ANCHORS_PER_HUB {
findings.push(Finding {
severity: Severity::Warn,
Expand Down Expand Up @@ -218,6 +220,59 @@ fn lint_agents_pointer(ws: &Workspace, findings: &mut Vec<Finding>) {
}
}

/// 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<Finding>) {
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<String> = 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<Item = &str> {
text.split("](")
Expand Down Expand Up @@ -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<Finding> {
lint_workspace(ws)
.unwrap()
Expand Down
41 changes: 36 additions & 5 deletions surf-core/src/hub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -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]
Expand Down
Loading