diff --git a/CHANGELOG.md b/CHANGELOG.md index bcee7a4..ea99623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,15 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added -- **`refs` hub composition — validation pass (#4).** A hub's `refs:` now compose *other hubs*: a - path relative to the referencing hub (`./resolve.md`), optionally `> symbol` to address one claim - within the target (matched against that claim's `at:` anchor). `surf lint` blocks a ref that - doesn't resolve to a loaded hub, points at its own hub, or names a claim the target lacks — the - same fail-on-typo discipline as `covers`. The `check` verdict does **not** read `refs` yet - (staleness does not propagate across hubs); this ships the validated navigation graph first, per - the §9.3 unlock discipline. The repo's own hubs now declare their cross-hub `refs`, and the two +- **`refs` hub composition (#4).** A hub's `refs:` now compose *other hubs*: a path relative to the + referencing hub (`./resolve.md`), optionally `> symbol` to address one claim within the target + (matched against that claim's `at:` anchor). `surf lint` blocks a ref that doesn't resolve to a + loaded hub, points at its own hub, or names a claim the target lacks — the same fail-on-typo + discipline as `covers`. The `surf check` gate then **propagates staleness one hop**: a hub that + directly references a stale hub (or a stale claim within one) inherits a `referenced_stale` + divergence and fails the gate, so the signal that flags a dependency flags everything composed on + it. Propagation is one-hop by construction (built only from base divergences), so a chain + `A → B → C` doesn't cascade. The repo's own hubs now declare their cross-hub `refs`, and the two prior doc-pointing `refs` were reclassified to prose links. - **`surf lint` consolidation nudges (#142).** Two advisory warnings push hubs away from the "claim-log" shape (one claim per function) and toward onboarding docs: a *claim-log* warning diff --git a/docs/dogfood-log.md b/docs/dogfood-log.md index 62ebaf5..b155d28 100644 --- a/docs/dogfood-log.md +++ b/docs/dogfood-log.md @@ -6,6 +6,48 @@ did about it, the lesson.* Keep it honest; the failures are the interesting part --- +## 2026-06-29 — `refs` propagation fired first on the commit that created it + +**Context:** PR2 of `refs` hub composition (#4) turns on staleness *propagation*: `surf check` +now flags a hub when a hub it `refs` has an open divergence (one-hop). The change lives in +`check_workspace` — which is itself an anchored claim in `cli-check.md`, and `cli-check.md` is +referenced by `cli-workspace.md` and `hub-format.md`. + +**What happened:** the first `surf check` after wiring up propagation went red three ways: + +``` +DIVERGED hubs/cli-check.md :: surf-cli/src/check.rs > check_workspace + stored 2:4f5890aca70c → now 2:b7b7fd55206e (magnitude: Large) +REF-STALE hubs/cli-workspace.md :: ./cli-check.md + referenced hub `hubs/cli-check.md` has an open divergence — review it, then re-verify +REF-STALE hubs/hub-format.md :: ./cli-check.md + referenced hub `hubs/cli-check.md` has an open divergence — review it, then re-verify +surf check: 3 divergence(s). +``` + +The new feature's *first real firing* was on the very diff that introduced it: editing +`check_workspace` diverged its own claim, and propagation — the thing being added — immediately +walked the two hubs that compose it and flagged them too. Fixing the root cleared all three at +once: I updated the `check_workspace` claim prose to describe propagation, re-sealed it +(`surf verify "surf-cli/src/check.rs > check_workspace"`), and both inherited `REF-STALE`s +vanished with it. + +**Why it's a good story:** the composition graph proved itself end-to-end without a contrived +example. It also makes the §8/§11.3 risk concrete: one genuine divergence amplified into three +findings (1 root + 2 inherited). That's the cascade the proposal worried about — but the shape +held up: the inherited flags are clearly labelled, point at the root, and clear the instant the +root is re-sealed. One-hop did its job too — `cli-check` itself `refs` `cli-git`/`cli-verify`, +which were clean, so nothing spread further, and the two `REF-STALE` hubs didn't re-propagate +onto *their* referrers (propagation is built only from base divergences). + +**Lesson / open question:** "fix the root, the inherited flags clear" is the property that makes +propagation usable rather than noisy — but it relies on the author recognising a `REF-STALE` as +*derived*, not a second thing to fix. Open question: at scale, is a 1→N amplification per stale +hub still legible, or does `check` eventually want to *group* inherited flags under their root +(print the root divergence, then "and N hubs that ref it") rather than as N peer lines? + +--- + ## 2026-06-29 — The new claim-log nudges flagged 22 of our own hubs **Context:** #142 argues the CLI's in-loop signals (`surf suggest`, `lint_under_coverage`) teach diff --git a/docs/guides/authoring-hubs.md b/docs/guides/authoring-hubs.md index 855c230..644e348 100644 --- a/docs/guides/authoring-hubs.md +++ b/docs/guides/authoring-hubs.md @@ -32,9 +32,10 @@ Prose a human (or agent) reads to understand this domain. - **`refs`** — hub composition: paths to *other hubs* this one builds on, written relative to this hub (`./resolve.md`), optionally `> symbol` to point at one claim within the target (`./resolve.md > resolve_nodes`, matched against that claim's `at:` anchor). `surf lint` blocks a - ref that doesn't resolve to a hub, points at this hub, or names a claim the target lacks — so a - typo can't rot silently. The `check` *verdict* doesn't read `refs` yet (a referenced hub going - stale won't flag this one); refs are a validated navigation graph for now. + ref that doesn't resolve to a hub, points at this hub, or names a claim the target lacks. The + `check` gate also **propagates staleness one hop**: when a hub you `ref` has an open divergence, + this hub fails too (a `referenced_stale` divergence) — review the dependency and re-verify. Only + *direct* refs propagate; a chain `A → B → C` stops at one hop. - **`covers`** — advisory file-scope globs; parsed and lint-validated but never affects `surf check`. Leave it empty unless you have a reason — the feature that consumes it isn't shipped. diff --git a/hubs/cli-check.md b/hubs/cli-check.md index 133c531..4bcfcb1 100644 --- a/hubs/cli-check.md +++ b/hubs/cli-check.md @@ -21,12 +21,15 @@ anchors: - 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. Alongside the divergences it returns the --files patterns that - matched no anchored file (run warns on stderr for each and exits non-zero when every - pattern matched nothing, so a typo'd --files can't read as a clean run) and a count of - clean anchors still stamped under v1, so run can nudge the one-time `surf verify` upgrade. + can't pass as clean. After the per-claim walk it propagates refs one hop — a hub that + directly references a stale hub (or a stale claim within one) inherits a ReferencedStale + divergence, built only from base divergences so a chain stops at the first hop. Alongside + the divergences it returns the --files patterns that matched no anchored file (run warns on + stderr for each and exits non-zero when every pattern matched nothing, so a typo'd --files + can't read as a clean run) and a count of clean anchors still stamped under v1, so run can + nudge the one-time `surf verify` upgrade. at: surf-cli/src/check.rs > check_workspace - hash: 2:4f5890aca70c + hash: 2:b7b7fd55206e refs: - ./cli-git.md - ./cli-verify.md @@ -42,7 +45,9 @@ produces the same answer; the git helpers in [`cli-git.md`](./cli-git.md) only f `check_claim` is the per-claim verdict; `check_workspace` walks every hub, and `Scope` narrows which claims it evaluates when `--base` or `--files` is given — opt-in and intersective, falling back to a full check rather than checking nothing. Any divergence (including a hub whose -frontmatter won't parse — the gate fails closed) makes `run` exit non-zero. +frontmatter won't parse — the gate fails closed) makes `run` exit non-zero. A hub also fails when a +hub it [`refs`](./hub-format.md) is stale: composition propagates one hop (#4), so the gate that +flags a dependency flags everything built on it. **Boundary:** green means "nothing anchored changed since last sign-off," not "the prose is true"; that confirmation is [`surf verify`](./cli-verify.md)'s job, not the gate's. diff --git a/hubs/hub-format.md b/hubs/hub-format.md index f05e519..e359391 100644 --- a/hubs/hub-format.md +++ b/hubs/hub-format.md @@ -3,8 +3,9 @@ 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 are rejected — though - forward-declared fields (`refs`, `covers`) are accepted and stored but inert in the verdict. + scalar or a list, hash is optional until verified, and unknown fields are rejected — while + `refs`/`covers` are accepted and stored verbatim, parse_hub resolving neither (acting on them + is lint/check's job). at: surf-core/src/hub.rs > parse_hub hash: 2:c510c6032ba7 - claim: > @@ -25,8 +26,8 @@ A hub is the unit every command reads and writes: a `---`-fenced YAML frontmatte machine-checkable `anchors`) followed by a markdown body (the prose a human or agent reads). `parse_hub` is the contract everything else binds to — its shape is why `at:` can be a scalar or a list, why `hash` is optional until verified, and why unknown fields are rejected (so a typo can't -masquerade as a new field) while `refs`/`covers` are accepted and lint-validated but never gate the -`check` verdict. +masquerade as a new field) while `refs`/`covers` are accepted and lint-validated — `covers` never +gates, but a stale `refs` target now propagates into the [`check`](./cli-check.md) verdict (#4). **The distinction that drives the design:** a human reviews every write, so edits must be *surgical*. Writes go through the line-level editor (`set_anchor_hash` / `set_anchor_at`) rather diff --git a/surf-cli/src/check.rs b/surf-cli/src/check.rs index d6106eb..f4735f0 100644 --- a/surf-cli/src/check.rs +++ b/surf-cli/src/check.rs @@ -6,12 +6,13 @@ use crate::format::Format; use crate::git; -use crate::workspace::{read_site, Workspace}; +use crate::workspace::{read_site, resolve_ref_path, Workspace}; use anyhow::Result; +use std::collections::HashMap; use std::process::ExitCode; use surf_core::{ - combine_site_hashes, diff_magnitude, format_stamp, hash_anchor_raw, parse_anchor, parse_stamp, - resolve, CheckReport, Divergence, DivergenceKind, HashOpts, HubError, Recipe, + combine_site_hashes, diff_magnitude, format_stamp, hash_anchor_raw, parse_anchor, parse_ref, + parse_stamp, resolve, CheckReport, Divergence, DivergenceKind, HashOpts, HubError, Recipe, }; pub fn run( @@ -62,14 +63,23 @@ fn check_workspace( // Enrichment always needs a ref; an explicit --base doubles as the diff base, else HEAD. let enrich_base = base.unwrap_or("HEAD"); + let loaded = ws.iter_hubs()?; let mut out = Vec::new(); let mut v1_clean = 0usize; - for loaded in ws.iter_hubs()? { - let hub = match loaded.hub { + // Which hubs carry an open divergence, and the anchor-segment path of each diverged claim — + // the input to ref propagation below. A hub is keyed here the moment any of its claims (or the + // hub itself) diverges; the paths let a claim-level `ref` (`> symbol`) check the *specific* + // claim rather than the whole hub. + let mut stale: HashMap<&str, Vec>> = HashMap::new(); + + for loaded_hub in &loaded { + let rel = loaded_hub.rel.as_str(); + let hub = match &loaded_hub.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)); + out.push(malformed_hub_divergence(rel, e)); + stale.entry(rel).or_default(); continue; } }; @@ -78,16 +88,82 @@ fn check_workspace( if !scope.includes(claim) { continue; } - match check_claim(ws, &loaded.rel, claim, enrich_base) { - ClaimCheck::Diverged(d) => out.push(*d), + match check_claim(ws, rel, claim, enrich_base) { + ClaimCheck::Diverged(d) => { + let paths = stale.entry(rel).or_default(); + for site in claim.at.sites() { + if let Ok(a) = parse_anchor(site) { + paths.push(a.segments.iter().map(|s| s.name.clone()).collect()); + } + } + out.push(*d); + } ClaimCheck::Clean { v1: true } => v1_clean += 1, ClaimCheck::Clean { v1: false } => {} } } } + + out.extend(propagate_refs(&loaded, &stale)); Ok((out, scope.unmatched_globs(), v1_clean)) } +/// One-hop `refs` propagation (§9.3, #4): a hub that directly references a stale hub (or a stale +/// claim within one) inherits a `ReferencedStale` divergence, so the gate that flags the +/// dependency also flags everything composed on top of it. Built only from base divergences — +/// never from other propagated ones — so a chain A→B→C stops at one hop. Malformed `refs` are +/// skipped here; surfacing them is `lint`'s job. +fn propagate_refs( + loaded: &[crate::workspace::LoadedHub], + stale: &HashMap<&str, Vec>>, +) -> Vec { + let mut out = Vec::new(); + for loaded_hub in loaded { + let Ok(hub) = &loaded_hub.hub else { continue }; + for raw in &hub.frontmatter.refs { + let Ok(parsed) = parse_ref(raw) else { continue }; + let target = resolve_ref_path(&loaded_hub.rel, &parsed.path); + let Some(paths) = stale.get(target.as_str()) else { + continue; + }; + let detail = if parsed.segments.is_empty() { + format!( + "referenced hub `{target}` has an open divergence — review it, then re-verify" + ) + } else { + let names: Vec<&str> = parsed.segments.iter().map(|s| s.name.as_str()).collect(); + let matched = paths.iter().any(|p| { + p.iter() + .map(String::as_str) + .collect::>() + .ends_with(&names) + }); + if !matched { + continue; + } + format!( + "referenced claim `{}` in `{target}` has diverged — review it, then re-verify", + names.join(" > ") + ) + }; + out.push(Divergence { + hub: loaded_hub.rel.clone(), + claim: String::new(), + at: raw.clone(), + kind: DivergenceKind::ReferencedStale, + old_hash: None, + new_hash: None, + old_code: None, + new_code: None, + prose: String::new(), + magnitude: None, + detail: Some(detail), + }); + } + } + out +} + fn malformed_hub_divergence(hub: &str, err: &HubError) -> Divergence { Divergence { hub: hub.to_string(), @@ -326,6 +402,7 @@ fn print_human(divergences: &[Divergence]) { DivergenceKind::Changed => ("DIVERGED", None), DivergenceKind::Unverified => ("UNVERIFIED", Some("run `surf verify`")), DivergenceKind::Unresolvable => ("UNRESOLVED", Some("run `surf lint`")), + DivergenceKind::ReferencedStale => ("REF-STALE", None), }; println!("{tag} {} :: {}", d.hub, d.at); if let Some(detail) = &d.detail { @@ -950,4 +1027,158 @@ mod tests { .unwrap() .contains("unrecognized hash version")); } + + // --- refs composition: staleness propagation (#4, PR2) ---------------------- + + #[test] + fn ref_to_stale_hub_propagates() { + // b.md seals `add`; the working tree diverges it. a.md refs ./b.md, so a.md inherits a + // REF-STALE divergence alongside b.md's own DIVERGED. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let orig = "pub fn add(a: i64, b: i64) -> i64 { a + b }\n"; + let h = stored_hash(orig, "src/m.rs > add"); + write(root, "surf.toml", ""); + write( + root, + "src/m.rs", + "pub fn add(a: i64, b: i64) -> i64 { a - b }\n", + ); + write( + root, + "hubs/b.md", + &format!("---\nsummary: y\nanchors:\n - claim: add sums\n at: src/m.rs > add\n hash: {h}\n---\n"), + ); + write( + root, + "hubs/a.md", + "---\nsummary: x\nrefs:\n - ./b.md\n---\n", + ); + + let d = check_workspace(&ws_at(root.to_path_buf()), None, &[]) + .unwrap() + .0; + assert_eq!(d.len(), 2, "b diverges and a inherits: {d:?}"); + assert!(d + .iter() + .any(|x| x.hub == "hubs/b.md" && x.kind == DivergenceKind::Changed)); + let refstale = d + .iter() + .find(|x| x.kind == DivergenceKind::ReferencedStale) + .expect("expected a propagated REF-STALE"); + assert_eq!(refstale.hub, "hubs/a.md"); + assert_eq!(refstale.at, "./b.md"); + assert!(refstale.detail.as_deref().unwrap().contains("hubs/b.md")); + } + + #[test] + fn ref_to_clean_hub_does_not_propagate() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let src = "pub fn add(a: i64, b: i64) -> i64 { a + b }\n"; + let h = stored_hash(src, "src/m.rs > add"); + write(root, "surf.toml", ""); + write(root, "src/m.rs", src); + write( + root, + "hubs/b.md", + &format!("---\nsummary: y\nanchors:\n - claim: add sums\n at: src/m.rs > add\n hash: {h}\n---\n"), + ); + write( + root, + "hubs/a.md", + "---\nsummary: x\nrefs:\n - ./b.md\n---\n", + ); + + assert!(check_workspace(&ws_at(root.to_path_buf()), None, &[]) + .unwrap() + .0 + .is_empty()); + } + + #[test] + fn claim_level_ref_targets_the_named_claim() { + // b.md seals two claims; only `add` diverges. A ref to `./b.md > add` propagates; a ref to + // the still-clean `./b.md > other` does not — claim-level refs are precise. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let orig = "pub fn add(a: i64, b: i64) -> i64 { a + b }\npub fn other() -> i64 { 1 }\n"; + let hadd = stored_hash(orig, "src/m.rs > add"); + let hother = stored_hash(orig, "src/m.rs > other"); + write(root, "surf.toml", ""); + write( + root, + "src/m.rs", + "pub fn add(a: i64, b: i64) -> i64 { a - b }\npub fn other() -> i64 { 1 }\n", + ); + write( + root, + "hubs/b.md", + &format!("---\nsummary: y\nanchors:\n - claim: add\n at: src/m.rs > add\n hash: {hadd}\n - claim: other\n at: src/m.rs > other\n hash: {hother}\n---\n"), + ); + write( + root, + "hubs/a.md", + "---\nsummary: x\nrefs:\n - ./b.md > add\n---\n", + ); + write( + root, + "hubs/e.md", + "---\nsummary: z\nrefs:\n - ./b.md > other\n---\n", + ); + + let d = check_workspace(&ws_at(root.to_path_buf()), None, &[]) + .unwrap() + .0; + let refstale: Vec<_> = d + .iter() + .filter(|x| x.kind == DivergenceKind::ReferencedStale) + .collect(); + assert_eq!( + refstale.len(), + 1, + "only the ref to the diverged claim fires" + ); + assert_eq!(refstale[0].hub, "hubs/a.md"); + } + + #[test] + fn propagation_is_one_hop() { + // c.md → a.md → b.md, with b.md stale. a.md inherits REF-STALE; c.md does NOT, because a's + // own claims are clean and propagation is built only from base divergences. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let orig = "pub fn add(a: i64, b: i64) -> i64 { a + b }\n"; + let h = stored_hash(orig, "src/m.rs > add"); + write(root, "surf.toml", ""); + write( + root, + "src/m.rs", + "pub fn add(a: i64, b: i64) -> i64 { a - b }\n", + ); + write( + root, + "hubs/b.md", + &format!("---\nsummary: b\nanchors:\n - claim: add\n at: src/m.rs > add\n hash: {h}\n---\n"), + ); + write( + root, + "hubs/a.md", + "---\nsummary: a\nrefs:\n - ./b.md\n---\n", + ); + write( + root, + "hubs/c.md", + "---\nsummary: c\nrefs:\n - ./a.md\n---\n", + ); + + let d = check_workspace(&ws_at(root.to_path_buf()), None, &[]) + .unwrap() + .0; + assert!( + !d.iter().any(|x| x.hub == "hubs/c.md"), + "one-hop: c must not inherit through a clean a: {d:?}" + ); + assert_eq!(d.len(), 2, "only b (DIVERGED) and a (REF-STALE): {d:?}"); + } } diff --git a/surf-core/src/report.rs b/surf-core/src/report.rs index b275281..ae10e95 100644 --- a/surf-core/src/report.rs +++ b/surf-core/src/report.rs @@ -46,6 +46,9 @@ pub enum DivergenceKind { Unverified, /// The anchor no longer resolves to exactly one symbol (run `surf lint`). Unresolvable, + /// A hub this one `refs` has an open divergence — composition propagated (§9.3, #4). One-hop: + /// only a *direct* ref to a stale hub fires this, never a transitive chain. + ReferencedStale, } #[derive(Debug, Clone, Serialize)] @@ -65,8 +68,9 @@ pub struct Divergence { pub prose: String, #[serde(skip_serializing_if = "Option::is_none")] pub magnitude: Option, - /// Human-readable reason for an `Unresolvable` divergence (unsupported file type, - /// unreadable file, ambiguous anchor, symbol not found). `None` for clean verdicts. + /// Human-readable reason for an `Unresolvable` or `ReferencedStale` divergence (unsupported + /// file type, unreadable file, ambiguous anchor, symbol not found, or which referenced hub + /// went stale). `None` for clean verdicts. #[serde(skip_serializing_if = "Option::is_none")] pub detail: Option, }