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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,9 @@ surf check --format json

- `surf init` — bootstrap a workspace: write `surf.toml` and create the hubs directory (idempotent).
- `surf new <name>` — scaffold a new empty hub under your hubs directory.
- `surf lint` — validate frontmatter and that every `at:` resolves to exactly one symbol. Warns on a renamed symbol (suggests `verify --follow`); blocks on ambiguous or vanished anchors.
- `surf check [--format human|json] [--base <ref>]` — the gate. AST-canonical-hash each anchored span and compare to the stored hash; non-zero exit on any divergence. `--base` (default `HEAD`) is the git ref used to recover the advisory `old_code` / `magnitude` fields.
- `surf verify [<at>] [--follow]` — re-seal after you've confirmed the prose still holds; writes the hash into the frontmatter. `<at>` limits to one anchor; `--follow` re-points a renamed single-symbol anchor and re-hashes in one step.
- `surf lint [--format human|json]` — validate frontmatter and that every `at:` resolves to exactly one symbol. Warns on a renamed symbol (suggests `verify --follow`); blocks on ambiguous or vanished anchors. Also emits advisory granularity warnings (never blocking, §8): a near-whole-file anchor span, a hub with too many anchors, and public functions in an anchored file that no claim covers.
- `surf check [--format human|json] [--base <ref>] [--files <globs>]` — the gate. AST-canonical-hash each anchored span and compare to the stored hash; non-zero exit on any divergence. By default every claim is checked. `--base <ref>` scopes to claims whose anchored files changed since the merge base **and** recovers the advisory `old_code` / `magnitude` fields from that ref (omit it for a full check with enrichment against `HEAD`). `--files <globs>` scopes to claims whose anchored file(s) match a comma-separated glob (e.g. `surf-core/**`).
- `surf verify [<at>] [--follow] [--format human|json]` — re-seal after you've confirmed the prose still holds; writes the hash into the frontmatter. `<at>` limits to one anchor; `--follow` re-points a renamed single-symbol anchor and re-hashes in one step.

## Configuration

Expand Down
4 changes: 3 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ branding:

# Consumers: a plain `actions/checkout@v4` is enough — do NOT set `fetch-depth: 0`.
# The verdict only needs the working tree; git history is used solely for the advisory
# old_code/magnitude enrichment in the JSON report.
# old_code/magnitude enrichment in the JSON report. If you pass `check --base <ref>` to
# diff-scope the gate to changed files, fetch enough history to reach the merge base (a
# shallow `git fetch <ref>` is plenty) — still not `fetch-depth: 0`.

inputs:
args:
Expand Down
10 changes: 9 additions & 1 deletion hubs/cli-check.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
summary: surf check — the gate. Hash each anchored span, compare to the stored hash, block on divergence.
summary: surf check — the gate. Hash each anchored span, compare to the stored hash, block on divergence. Optionally scope to changed files.
anchors:
- claim: >
Per claim: resolve and hash every site, combine into one hash, compare to the stored
Expand All @@ -12,10 +12,18 @@ anchors:
`git show <base>:<path>`; absent git the verdict is unchanged and those fields are omitted.
at: surf-cli/src/check.rs > git_show
hash: cd1f35beb1ec
- 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
refs: []
---

# surf check

`check_claim` is the verdict; `git_show` only feeds the advisory `old_code`/`magnitude` in the
`--format json` report. Any divergence makes `run` exit non-zero (the CI-blocking signal).
`Scope` narrows which claims `check_workspace` evaluates when `--base`/`--files` are given.
14 changes: 11 additions & 3 deletions hubs/cli-lint.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
---
summary: surf lint — every anchor resolves to exactly one symbol; renames warn, not block.
summary: surf lint — anchors must resolve to one symbol (renames warn); plus advisory granularity warnings.
anchors:
- claim: >
lint produces a Finding per anchor site: ambiguous or vanished anchors block, while a
renamed-but-present symbol (stored-hash match) only warns and points at verify --follow.
Block-level findings set a non-zero exit; warnings alone keep exit 0.
at: surf-cli/src/lint.rs > lint_site
hash: 840a2d93cf22
hash: 51bb818e87ae
- claim: >
Advisory granularity guidance (§8), never blocking: lint_under_coverage flags public
functions in a hub's already-anchored files that no claim covers — but only for files
whose anchors all resolved cleanly, so coverage nags never pile onto broken anchors.
at: surf-cli/src/lint.rs > lint_under_coverage
hash: dad7767e0594
refs: []
---

# surf lint

`lint_workspace` loads every hub and runs `lint_site` over each anchor; `run` prints the
findings and chooses the exit code.
findings and chooses the exit code. Beyond resolution, lint emits advisory warnings (§8) that
nudge granularity: a near-whole-file span (`lint_coarse_span`), too many anchors in one hub,
and public functions with no covering claim (`lint_under_coverage`).
5 changes: 3 additions & 2 deletions hubs/cli-verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ refs: []

# surf verify

The human escape hatch. `run` applies each `plan_claim` result through the surgical hub editor
and only rewrites a file when something actually changed.
The human escape hatch. `verify_all` applies each `plan_claim` result through the surgical hub
editor and only rewrites a file when something actually changed; `run` then renders the
collected report as human text or JSON.
211 changes: 193 additions & 18 deletions surf-cli/src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@
//! deterministic and needs no git. `old_code`/`magnitude` are recovered best-effort from the
//! previous source via `git show <base>:<path>` and are advisory only.

use crate::format::Format;
use crate::workspace::{read_site, Workspace};
use anyhow::{Context, Result};
use clap::ValueEnum;
use std::collections::HashSet;
use std::path::Path;
use std::process::{Command, ExitCode};
use surf_core::{diff_magnitude, hash_anchor, parse_hub, resolve, Divergence, DivergenceKind};
use surf_core::{
diff_magnitude, hash_anchor, parse_anchor, parse_hub, resolve, Divergence, DivergenceKind,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Format {
Human,
Json,
}

pub fn run(ws: &Workspace, format: Format, base: &str) -> Result<ExitCode> {
let divergences = check_workspace(ws, base)?;
pub fn run(
ws: &Workspace,
format: Format,
base: Option<&str>,
files: &[String],
) -> Result<ExitCode> {
let divergences = check_workspace(ws, base, files)?;

match format {
Format::Json => {
Expand All @@ -34,7 +36,15 @@ pub fn run(ws: &Workspace, format: Format, base: &str) -> Result<ExitCode> {
})
}

fn check_workspace(ws: &Workspace, base: &str) -> Result<Vec<Divergence>> {
fn check_workspace(
ws: &Workspace,
base: Option<&str>,
files: &[String],
) -> Result<Vec<Divergence>> {
let scope = Scope::build(ws, base, files);
// Enrichment always needs a ref; an explicit --base doubles as the diff base, else HEAD.
let enrich_base = base.unwrap_or("HEAD");

let mut out = Vec::new();
for hub_path in ws.hub_paths()? {
let rel = hub_path
Expand All @@ -50,14 +60,60 @@ fn check_workspace(ws: &Workspace, base: &str) -> Result<Vec<Divergence>> {
};

for claim in &hub.frontmatter.anchors {
if let Some(d) = check_claim(ws, &rel, claim, base) {
if !scope.includes(claim) {
continue;
}
if let Some(d) = check_claim(ws, &rel, claim, enrich_base) {
out.push(d);
}
}
}
Ok(out)
}

/// 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 {
changed: Option<HashSet<String>>,
globs: Vec<glob::Pattern>,
}

impl Scope {
fn build(ws: &Workspace, base: Option<&str>, files: &[String]) -> Scope {
// A bad ref / non-repo yields None — we fall back to a full check rather than
// silently checking nothing.
let changed = base.and_then(|b| git_changed_files(&ws.root, b));
let globs = files
.iter()
.filter_map(|p| glob::Pattern::new(p).ok())
.collect();
Scope { changed, globs }
}

fn includes(&self, claim: &surf_core::Claim) -> bool {
let anchor_files: Vec<String> = claim
.at
.sites()
.iter()
.filter_map(|s| parse_anchor(s).ok().map(|a| a.file))
.collect();

if let Some(changed) = &self.changed {
if !anchor_files.iter().any(|f| changed.contains(f)) {
return false;
}
}
if !self.globs.is_empty()
&& !anchor_files
.iter()
.any(|f| self.globs.iter().any(|g| g.matches(f)))
{
return false;
}
true
}
}

fn check_claim(
ws: &Workspace,
hub: &str,
Expand Down Expand Up @@ -168,6 +224,33 @@ fn enrich_from_git(
(old_code, magnitude)
}

/// Files changed between the merge base of `base`..HEAD and the working tree. Paths are
/// repo-root-relative; they match `Anchor.file` (workspace-root-relative) when the
/// workspace root is the repo root, the normal case. `None` if git can't answer.
fn git_changed_files(root: &Path, base: &str) -> Option<HashSet<String>> {
let merge_base = Command::new("git")
.current_dir(root)
.args(["merge-base", base, "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
// Shallow clones may lack the merge base; diff against the ref directly.
.unwrap_or_else(|| base.to_string());

let output = Command::new("git")
.current_dir(root)
.args(["diff", "--name-only", &merge_base])
.output()
.ok()?;
output.status.success().then(|| {
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_string)
.collect()
})
}

fn git_show(root: &Path, base: &str, rel_file: &str) -> Option<String> {
let output = Command::new("git")
.current_dir(root)
Expand Down Expand Up @@ -257,7 +340,7 @@ mod tests {
&format!("---\nsummary: x\nanchors:\n - claim: add sums\n at: src/m.rs > add\n hash: {h}\n---\n"),
);

assert!(check_workspace(&ws_at(root.to_path_buf()), "HEAD")
assert!(check_workspace(&ws_at(root.to_path_buf()), None, &[])
.unwrap()
.is_empty());
}
Expand All @@ -281,7 +364,7 @@ mod tests {
&format!("---\nsummary: x\nanchors:\n - claim: add sums\n at: src/m.rs > add\n hash: {h}\n---\n"),
);

assert!(check_workspace(&ws_at(root.to_path_buf()), "HEAD")
assert!(check_workspace(&ws_at(root.to_path_buf()), None, &[])
.unwrap()
.is_empty());
}
Expand All @@ -298,7 +381,7 @@ mod tests {
"---\nsummary: x\nanchors:\n - claim: c\n at: src/m.rs > add\n---\n",
);

let d = check_workspace(&ws_at(root.to_path_buf()), "HEAD").unwrap();
let d = check_workspace(&ws_at(root.to_path_buf()), None, &[]).unwrap();
assert_eq!(d.len(), 1);
assert_eq!(d[0].kind, DivergenceKind::Unverified);
}
Expand Down Expand Up @@ -343,7 +426,7 @@ mod tests {
"pub fn add(a: i64, b: i64) -> i64 { a - b }\n",
);

let d = check_workspace(&ws_at(root.to_path_buf()), "HEAD").unwrap();
let d = check_workspace(&ws_at(root.to_path_buf()), None, &[]).unwrap();
assert_eq!(d.len(), 1);
let d = &d[0];
assert_eq!(d.kind, DivergenceKind::Changed);
Expand All @@ -366,7 +449,7 @@ mod tests {
"---\nsummary: x\nanchors:\n - claim: c\n at: schema.sql > users\n---\n",
);

let d = check_workspace(&ws_at(root.to_path_buf()), "HEAD").unwrap();
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_eq!(
Expand All @@ -393,7 +476,7 @@ mod tests {
&format!("---\nsummary: x\nanchors:\n - claim: add sums\n at: src/m.rs > add\n hash: {h}\n---\n"),
);

let d = check_workspace(&ws_at(root.to_path_buf()), "HEAD").unwrap();
let d = check_workspace(&ws_at(root.to_path_buf()), None, &[]).unwrap();
let json = serde_json::to_value(&d).unwrap();
let obj = json[0].as_object().unwrap();
for key in [
Expand All @@ -402,4 +485,96 @@ mod tests {
assert!(obj.contains_key(key), "missing key `{key}` in {obj:?}");
}
}

/// Two diverged claims in different files; both surface with no scope, but a `--files`
/// glob narrows the result to the matching file.
fn two_diverged_files(root: &Path) {
let a = "pub fn add(a: i64, b: i64) -> i64 { a + b }\n";
let s = "pub fn sub(a: i64, b: i64) -> i64 { a - b }\n";
let ha = stored_hash(a, "src/a.rs > add");
let hs = stored_hash(s, "src/b.rs > sub");
write(root, "surf.toml", "");
// Working tree diverges from the stored hashes.
write(
root,
"src/a.rs",
"pub fn add(a: i64, b: i64) -> i64 { a - b }\n",
);
write(
root,
"src/b.rs",
"pub fn sub(a: i64, b: i64) -> i64 { a + b }\n",
);
write(
root,
"hubs/a.md",
&format!(
"---\nsummary: x\nanchors:\n - claim: add\n at: src/a.rs > add\n hash: {ha}\n - claim: sub\n at: src/b.rs > sub\n hash: {hs}\n---\n"
),
);
}

#[test]
fn files_scope_limits_claims() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
two_diverged_files(root);
let ws = ws_at(root.to_path_buf());

assert_eq!(check_workspace(&ws, None, &[]).unwrap().len(), 2);

let scoped = check_workspace(&ws, None, &["src/a.rs".to_string()]).unwrap();
assert_eq!(scoped.len(), 1);
assert_eq!(scoped[0].at, "src/a.rs > add");

let globbed = check_workspace(&ws, None, &["src/b*.rs".to_string()]).unwrap();
assert_eq!(globbed.len(), 1);
assert_eq!(globbed[0].at, "src/b.rs > sub");
}

#[test]
fn base_scope_limits_to_changed() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
two_diverged_files(root);

// Commit the diverged working tree as v0, then change only src/a.rs.
git(root, &["init", "-q"]);
git(
root,
&["-c", "user.email=t@t", "-c", "user.name=t", "add", "."],
);
git(
root,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"-m",
"v0",
],
);
write(
root,
"src/a.rs",
"pub fn add(a: i64, b: i64) -> i64 { a * b }\n",
);

let ws = ws_at(root.to_path_buf());
let scoped = check_workspace(&ws, Some("HEAD"), &[]).unwrap();
assert_eq!(scoped.len(), 1);
assert_eq!(scoped[0].at, "src/a.rs > add");
}

#[test]
fn no_flags_checks_everything() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
two_diverged_files(root);
let ws = ws_at(root.to_path_buf());
assert_eq!(check_workspace(&ws, None, &[]).unwrap().len(), 2);
}
}
9 changes: 9 additions & 0 deletions surf-cli/src/format.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! Output format shared by `check`, `lint`, and `verify`.

use clap::ValueEnum;

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Format {
Human,
Json,
}
Loading