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
55 changes: 55 additions & 0 deletions docs/guides/stats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
title: Adoption metrics (surf stats)
description: How surf stats computes the rubber-stamp and in-place update rates, and how to read them.
---

# Adoption metrics

`surf stats` answers the two falsifiable questions from the proposal's success/kill criteria
(§9.2): *is the gate being routed around, and do docs travel with the code?* Both are computed
deterministically from git history — no model, no network.

```sh
surf stats # all history
surf stats --since 2026-01-01 # a window
surf stats --since 2026-01-01 --until 2026-04-01 --format json
```

## The two rates

**Rubber-stamp rate** — of *re-stamp events* (a commit changed a claim's stored `hash:` to a new
value), the share where the claim's **prose was left untouched**. Re-sealing without re-reading is
the signal that distinguishes a working gate from one being clicked through. A rising rate is a
kill signal.

**In-place update rate** — of *claim-touch events* (a commit changed a file a claim anchors), the
share where the claim's stored hash was **updated in the same commit**. Docs that move with the
code score high; drift scores low.

`--format json` emits a versioned envelope:

```json
{
"version": 1,
"since": "2026-01-01",
"commits": 42,
"rubber_stamp": { "n": 3, "d": 12, "rate": 0.25 },
"in_place": { "n": 30, "d": 40, "rate": 0.75 }
}
```

`rate` is `null` when there were no events (`d == 0`).

## What it assumes (and what that costs)

These are heuristics, surfaced rather than hidden:

- **One commit = one PR.** Merge commits are excluded, so a squash-merge workflow maps cleanly;
a merge-commit workflow attributes work to the individual commits instead.
- **A claim's identity is its `at:` site(s).** Re-pointing a claim to a different anchor reads as
a new claim, not an update of the old one.
- **The in-place denominator counts any change to an anchored file** — including comment or
formatting edits that wouldn't actually diverge the claim. So the *true* in-place rate is at
least the reported one; the number is a floor, not a point estimate.
- **History must be reachable.** On a shallow clone or outside a repo, `stats` errors (non-zero)
rather than printing a misleading zero.
6 changes: 6 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ description: The surf CLI — init, new, suggest, for, lint, check, and verify,
prose still holds; writes the hash into the frontmatter. `<at>` limits to one anchor. `--follow`
re-points a single-segment anchor whose **symbol** was renamed, or whose **file** was moved
(detected via git), and re-hashes in one step — only when the code is otherwise unchanged.
- **`surf stats [--since <date>] [--until <date>] [--format human|json]`** — adoption metrics from
git history (advisory, never a gate): the **rubber-stamp rate** (re-stamps that changed a
claim's stored hash but left its prose untouched) and the **in-place update rate** (commits
touching an anchored file that re-sealed the claim in the same commit). One commit = one PR
(merges excluded). Heuristic by design — see the [stats guide](../guides/stats.md). Errors
(non-zero) if git history is unavailable.

## Per-claim options

Expand Down
15 changes: 15 additions & 0 deletions hubs/cli-git.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ anchors:
None means git couldn't pair the rename — the deterministic verdict never depends on it.
at: surf-cli/src/git.rs > renamed_to
hash: 9622170a3b9a
- claim: >
log_commits lists commit SHAs (newest first) in the optional since/until window with merges
excluded, so surf stats treats each SHA as one unit of work. None when git can't answer.
at: surf-cli/src/git.rs > log_commits
hash: 748b15a706c2
- claim: >
commit_files lists the repo-relative paths a single commit changed versus its first parent
(diff-tree --no-commit-id --name-only -r). None when git can't answer.
at: surf-cli/src/git.rs > commit_files
hash: cef27873a3e1
- claim: >
list_files_at lists every tracked file at a commit (ls-tree -r --name-only), used to find the
hub set as it existed at a past commit. None when git can't answer.
at: surf-cli/src/git.rs > list_files_at
hash: cbe066de9432
refs: []
---

Expand Down
9 changes: 5 additions & 4 deletions hubs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ anchors:
- claim: >
The CLI exposes exactly these subcommands with these flags: init; new <name>; lint
[--format]; check [--format] [--base <ref>] [--files <globs>]; verify [<target>] [--follow]
[--format]; suggest <globs> [--format]; for <path> [symbol] [--format]. Adding, removing, or
renaming a command or flag, or changing a default, diverges this anchor — re-read
docs/reference/commands.md before sealing.
[--format]; suggest <globs> [--format]; for <path> [symbol] [--format]; stats
[--since <date>] [--until <date>] [--format]. Adding, removing, or renaming a command or
flag, or changing a default, diverges this anchor — re-read docs/reference/commands.md
before sealing.
at: surf-cli/src/main.rs > Command
hash: 7dcf2e5a1d8b
hash: b52f85f87955
refs: ["../docs/reference/commands.md"]
---

Expand Down
28 changes: 28 additions & 0 deletions hubs/cli-stats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
summary: surf stats — git-history adoption metrics (rubber-stamp + in-place rates); advisory, never a gate.
anchors:
- claim: >
run computes the two metrics and prints them human-readable or as a versioned envelope; it
always exits 0 on success and surfaces an error (non-zero) only when git history is
unavailable. The metrics are advisory and never gate.
at: surf-cli/src/stats.rs > run
hash: 7f4ab96fac92
- claim: >
compute walks each non-merge commit in the since/until window. A rubber-stamp event is an
already-sealed claim whose stored hash value changed in a commit; it counts toward the
rubber-stamp numerator only when the claim's prose was unchanged. A claim-touch event is a
commit that changed a file the claim anchors; it counts toward the in-place numerator when
the claim's stored hash was updated in that same commit. Claim identity is its at: site(s),
and missing git history is a hard error rather than a silent zero.
at: surf-cli/src/stats.rs > compute
hash: 55561222d721
refs: ["../docs/guides/stats.md"]
---

# surf stats

The proposal's adopt/kill signals (§9.2), computed deterministically from git history. `compute`
parses the hub set as it existed at each commit (and its parent) via `git ls-tree`/`git show`,
compares stored hashes and prose across the pair, and tallies the two rates. Heuristics — one
commit per PR, `at:`-site claim identity, an in-place denominator that counts any anchored-file
edit — are documented in [the stats guide](../docs/guides/stats.md).
55 changes: 55 additions & 0 deletions surf-cli/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,61 @@ pub fn changed_files(root: &Path, base: &str) -> Option<HashSet<String>> {
})
}

/// Commit SHAs (newest first) in the optional `since`/`until` window, merges excluded so each
/// SHA is one unit of work (`surf stats` treats a commit as a PR). `None` if git can't answer.
pub fn log_commits(root: &Path, since: Option<&str>, until: Option<&str>) -> Option<Vec<String>> {
let mut args: Vec<String> = vec!["log".into(), "--no-merges".into(), "--format=%H".into()];
if let Some(s) = since {
args.push(format!("--since={s}"));
}
if let Some(u) = until {
args.push(format!("--until={u}"));
}
let output = Command::new("git")
.current_dir(root)
.args(&args)
.output()
.ok()?;
output.status.success().then(|| {
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_string)
.collect()
})
}

/// Repo-root-relative paths changed by a single commit (vs its first parent). Empty for the root
/// commit's tree-only diff is fine. `None` if git can't answer.
pub fn commit_files(root: &Path, sha: &str) -> Option<Vec<String>> {
let output = Command::new("git")
.current_dir(root)
.args(["diff-tree", "--no-commit-id", "--name-only", "-r", sha])
.output()
.ok()?;
output.status.success().then(|| {
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_string)
.collect()
})
}

/// Every tracked file at `sha` (repo-root-relative). Used to find the hub set as it existed at a
/// past commit. `None` if git can't answer.
pub fn list_files_at(root: &Path, sha: &str) -> Option<Vec<String>> {
let output = Command::new("git")
.current_dir(root)
.args(["ls-tree", "-r", "--name-only", sha])
.output()
.ok()?;
output.status.success().then(|| {
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_string)
.collect()
})
}

/// The contents of `rel_file` at `base` (e.g. `git show HEAD:src/x.rs`). `None` if unavailable.
pub fn show(root: &Path, base: &str, rel_file: &str) -> Option<String> {
let output = Command::new("git")
Expand Down
18 changes: 18 additions & 0 deletions surf-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod git;
mod init;
mod lint;
mod new;
mod stats;
mod suggest;
mod verify;
mod workspace;
Expand Down Expand Up @@ -90,6 +91,18 @@ enum Command {
#[arg(long, value_enum, default_value_t = Format::Human)]
format: Format,
},
/// Adoption metrics from git history: rubber-stamp rate and in-place update rate.
Stats {
/// Only count commits more recent than this date (passed to `git log --since`).
#[arg(long)]
since: Option<String>,
/// Only count commits older than this date (passed to `git log --until`).
#[arg(long)]
until: Option<String>,
/// Output format for the metrics.
#[arg(long, value_enum, default_value_t = Format::Human)]
format: Format,
},
}

fn main() -> std::process::ExitCode {
Expand Down Expand Up @@ -132,5 +145,10 @@ fn run() -> anyhow::Result<std::process::ExitCode> {
symbol,
format,
} => for_path::run(&ws, &path, symbol.as_deref(), format),
Command::Stats {
since,
until,
format,
} => stats::run(&ws, since.as_deref(), until.as_deref(), format),
}
}
Loading