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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ anchors:
surf lint blocks when AGENTS.md carries a surf:hubs block that does not link the configured
hubs directory, or when that directory does not exist; without the block it stays silent.
at: surf-cli/src/lint.rs > lint_agents_pointer
hash: 938380798f7a
hash: 2:9a5f7d9fd0db
refs: []
---

Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed
- **Hash recipe v2 (member-access names verbatim).** The canonical hash now keeps the
property/field component of a member-access expression verbatim instead of alpha-renaming it,
so re-pointing an anchored span at a *different* external symbol — `PointsTier.TIER_1` →
`TIER_2`, `b.Del` → `b.Keep`, `ProbeColor.RED` → `GREEN` — changes the hash even when the name
occurs once. Previously these passed the gate silently while the claim's prose became false
(#140, the member-access slice of #77). Consistent local/parameter renames stay quiet, as
before. Covers TypeScript, Go, Rust, and Python.
- **Versioned stamps.** Stored hashes now carry their recipe: a v2 stamp is prefixed `2:`, a bare
12-hex stamp is an implicit v1. `surf check` verifies each stamp under its own recipe, so
existing v1 stamps keep passing (with a one-line nudge) until `surf verify` re-stamps them as
v2 — one pass per repo. An unrecognized stamp prefix fails closed. See
[Hash recipes](docs/reference/hash-recipes.md). **Action on upgrade: run `surf verify` once.**

## [0.6.3] - 2026-06-18

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,4 @@ Agents are a multiplier, not the foundation.

- [Install](./getting-started/install.md) · [Quickstart](./getting-started/quickstart.md)
- [Authoring hubs](./guides/authoring-hubs.md) · [CI integration](./guides/ci-integration.md) · [Examples](./examples.md)
- Reference: [Commands](./reference/commands.md) · [Configuration](./reference/configuration.md) · [How the gate works](./reference/how-it-works.md) · [FAQ](./reference/faq.md)
- Reference: [Commands](./reference/commands.md) · [Configuration](./reference/configuration.md) · [How the gate works](./reference/how-it-works.md) · [Hash recipes](./reference/hash-recipes.md) · [FAQ](./reference/faq.md)
94 changes: 94 additions & 0 deletions docs/reference/hash-recipes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
title: Hash recipes
description: The versioned canonicalization recipes behind every stored stamp — what each one does, how stamps are labelled, and how to migrate.
---

A **stamp** is the value `surf verify` writes into a claim's `hash:` field and `surf check`
compares against. It is produced by a **recipe**: the exact rules for turning a resolved span into
a canonical token stream (see [How the gate works](./how-it-works.md), step 2). Changing those
rules changes the output for unchanged code, which would silently invalidate every stamp in the
wild — so each recipe has a number, and every stamp records the recipe that produced it.

## Stamp format

```
hash: 2:f1075e760a17 # v2 stamp — explicit prefix
hash: f1075e760a17 # bare 12-hex — implicitly v1 (written before recipes were numbered)
```

`surf check` reads the prefix, verifies the span under that recipe, and:

- **matches** → passes. If the stamp is v1, it adds a one-line nudge inviting `surf verify` to
upgrade (so the span gains newer protections).
- **differs** → blocks, exactly as before.
- **unrecognized prefix** (e.g. a `3:` stamp written by a newer surf) → fails closed: an
unverifiable stamp is never treated as clean.

New stamps are always written under the current recipe (**v2**).

## Migration

Upgrading surf does **not** mass-flag your repo. v1 stamps keep verifying in v1 mode until you run:

```
surf verify
```

once, which re-stamps every anchor under the current recipe — including v1 anchors whose hash
still matches (the one narrow case where `verify` rewrites an otherwise-unchanged stamp). After
that single pass the whole repo is on v2.

> Forced re-verify is deliberately *not* automatic on upgrade. `verify` stamps whatever the code
> is *now*; if a repo already contains drift that v1 missed, a blind re-stamp would launder it
> green. v1-compat keeps the gate honest *through* the migration — `check` can still tell
> "unchanged under the old recipe" (pass) from "actually changed" (block).

## Recipes

### v1 — original (surf ≤ 0.6.x; bare-hex stamps)

Walk the resolved span's syntax tree into tokens:

- whitespace and comments are absent from the tree → ignored;
- every **identifier** is alpha-renamed to a positional placeholder (`#0`, `#1`, …) in order of
first occurrence — a *consistent* rename hashes identically, swapping two names does not;
- operators, keywords, punctuation, and literal **values** are kept verbatim;
- a Python **decorator name** is kept verbatim (`@cache` → `@lru_cache` is loud).

SHA-256 of the token stream, truncated to 12 hex.

**Known blind spot (#77):** because *every* identifier is alpha-renamed, re-pointing a span at a
different single-occurrence external symbol (`PointsTier.TIER_1` → `TIER_2`, `b.Del` → `b.Keep`)
yields a byte-identical stream — the claim's prose silently becomes false while the gate stays
green.

### v2 — member-access names verbatim (surf ≥ 0.7.0; `2:` prefix)

v1, plus one rule: the **property/field component of a member-access expression** is kept verbatim
(`kind:text`) instead of alpha-renamed. These positions name an *external* member, never a local
binding, so emitting them verbatim distinguishes "re-pointed at a different symbol" (loud) from
"renamed my own local" (still quiet — rename tolerance is preserved). Per family:

| Family | Member-access position |
|---|---|
| TypeScript | `property_identifier` / `private_property_identifier` as the `property` of a `member_expression` |
| Go | `field_identifier` as the `field` of a `selector_expression` |
| Rust | `field_identifier` as the `field` of a `field_expression` |
| Python | the `attribute` identifier of an `attribute` node |

Everything else is identical to v1, so v1 ≡ v2 minus this single rule — a member-access-free span
hashes the same under both. This closes the #77 blind spot for member accesses (every reported
reproduction). Re-pointing at a non-member free identifier — a bare `Enum::VARIANT` path, a renamed
imported function called by bare name — is **not** yet covered; that is the full bound/free split
tracked in [#77](https://github.com/Connorrmcd6/surface/issues/77).

## Policy (for maintainers)

- **Any** change to canonical output is a new recipe number — no exceptions. An innocent-looking
refactor of the tokenizer that changes one byte of output is silently a new recipe wearing an old
number, which corrupts every stamp in the wild. The golden fixtures in
`surf-core/tests/golden_hash.rs` pin each recipe's output (v1 and v2 digests for representative
symbols per language) precisely to make that break loud.
- A recipe is kept as a verification mode only while it is expressible as a flag over the current
code (v1 ≡ v2 with the member-access rule off — one branch, no frozen copy). The N-1 support
policy and the broader version-table governance are tracked in #77.
11 changes: 8 additions & 3 deletions docs/reference/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ The gate runs in four steps.
placeholders (a *consistent* rename yields the same tokens, swapping two names does not);
operators, keywords, and literal *values* are kept verbatim. Python decorators are part of the
span, and a decorator's *name* is kept verbatim — so swapping `@cache` for `@lru_cache`, or
`@staticmethod` for `@classmethod`, changes the hash.
`@staticmethod` for `@classmethod`, changes the hash. **Member-access names are kept verbatim
too** (`obj.foo`, `pkg.Bar`, `Enum.VARIANT`), so re-pointing a span at a *different* external
symbol — `PointsTier.TIER_1` → `TIER_2`, `b.Del` → `b.Keep` — changes the hash even when the
name occurs once. (This last rule is the **v2** recipe; see [Hash recipes](./hash-recipes.md).)
3. **Hash.** SHA-256 of that stream, truncated to 12 hex. A list `at:` combines its sites into one
hash, so the claim is stale if *any* listed span changes.
4. **Compare** against the hash stored in the frontmatter (written by `surf verify`). Equal → pass;
different → block.
4. **Compare** against the stamp stored in the frontmatter (written by `surf verify`). The stamp
carries its recipe — a v2 stamp is prefixed `2:`, a bare hex stamp is an older v1 — and is
verified under *its own* recipe, so existing v1 stamps keep passing until `surf verify` upgrades
them. Equal → pass; different → block.

Quiet on cosmetics, loud on logic — and **reproducible**, because the parser ships *inside* the
binary and is version-pinned. There is no separate formatter or language server in CI to skew the
Expand Down
2 changes: 1 addition & 1 deletion hubs/anchor.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ anchors:
a 1-based `@N` positional suffix for genuine name collisions. Empty/zero/missing parts
are typed parse errors.
at: surf-core/src/anchor.rs > parse_anchor
hash: 8818a44052c1
hash: 2:0f9a4f9d406d
refs: []
---

Expand Down
19 changes: 11 additions & 8 deletions hubs/cli-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
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
hash. No stored hash → Unverified; an anchor that no longer resolves → Unresolvable;
a mismatch → Changed. The verdict is deterministic and needs no git.
Per claim: resolve and hash every site under the stored stamp's own recipe (v1/v2),
combine into one hash, compare to the stored hash. No stored hash → Unverified; an anchor
that no longer resolves, or a stamp with an unrecognized version prefix → Unresolvable;
a mismatch → Changed; a clean match is tagged with whether the stamp was still v1. The
verdict is deterministic and needs no git.
at: surf-cli/src/check.rs > check_claim
hash: e04e680e6d8b
hash: 2:36cbbc039ab1
- 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
Expand All @@ -15,15 +17,16 @@ anchors:
records whether it ever matched an anchored file (tallied before the --base filter), so a
pattern that scopes the gate to nothing is detectable after the walk.
at: surf-cli/src/check.rs > Scope > includes
hash: f18aefc5097e
hash: 2:d459cc00d69b
- 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.
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: 567ba4ebe18e
hash: 2:d8957ecb971d
refs: []
---

Expand Down
4 changes: 2 additions & 2 deletions hubs/cli-for.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ anchors:
versioned {version, path, matches} envelope (JSON), always exiting 0 whether or not anything
matched.
at: surf-cli/src/for_path.rs > run
hash: 3143f824dcfb
hash: 2:4ef15aadc147
- claim: >
find collects every claim whose anchored file equals the queried path (matched on path only —
no source parse), optionally narrowed to anchors whose first segment is the given symbol.
Malformed hubs are skipped rather than erroring, and results are sorted by hub then anchor.
at: surf-cli/src/for_path.rs > find
hash: 047c1480c650
hash: 2:6eb52572ab68
refs: []
---

Expand Down
10 changes: 5 additions & 5 deletions hubs/cli-git.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,33 @@ anchors:
subdirectory. A missing merge base (shallow clone) falls back to diffing the ref directly;
if git can't answer at all it returns None.
at: surf-cli/src/git.rs > changed_files
hash: 454e65cc8aa3
hash: 2:e395bff5410d
- claim: >
show returns the contents of a file at a git ref (git show <base>:<path>), used to recover
the previous source for advisory old_code/magnitude. None when unavailable — the verdict is
unchanged either way.
at: surf-cli/src/git.rs > show
hash: 6398bf958ad1
hash: 2:ea9143b47615
- claim: >
renamed_to asks git's rename detection (diff --name-status --find-renames HEAD) for the new
path a file moved to, letting lint warn and verify --follow re-point instead of hard-blocking.
Best-effort: a pure mv with no content match may show as delete+add and not be detected, and
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
hash: 2:a51ff4adba72
- claim: >
log_stream returns the whole history window in one git spawn: every reachable commit (newest
first, children before parents) with its parents and its first-parent name-status diff.
Merges are included with --diff-merges=first-parent so surf stats can propagate hub state
through them, and --no-renames keeps a rename reading as delete+add. None when git can't
answer.
at: surf-cli/src/git.rs > log_stream
hash: 8827a8266fc9
hash: 2:c5d2fccc872e
- 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
hash: 2:23c36e64fc4d
refs: []
---

Expand Down
6 changes: 3 additions & 3 deletions hubs/cli-lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ anchors:
as does a file that git reports has moved. Block-level findings set a non-zero exit;
warnings alone keep exit 0.
at: surf-cli/src/lint.rs > lint_site
hash: 1ec63fccf77f
hash: 2:69018813a373
- claim: >
Advisory granularity guidance (§8), never blocking: lint_under_coverage flags public
symbols — top-level functions and methods — in an already-anchored file that no claim
Expand All @@ -16,14 +16,14 @@ anchors:
uncovered symbol is reported once against the file's first anchoring hub. It runs only on
files whose anchors all resolved cleanly, so coverage nags never pile onto broken anchors.
at: surf-cli/src/lint.rs > lint_under_coverage
hash: 569a7e6fe417
hash: 2:3ca608c27462
- claim: >
AGENTS.md enforcement is opt-in (§11.6): only when the file carries a surf:hubs marker
block does lint require it to link the configured hubs directory (which must exist),
blocking otherwise. It points agents at the directory to search — never enumerating
individual hubs, which would push an agent to read everything.
at: surf-cli/src/lint.rs > lint_agents_pointer
hash: 938380798f7a
hash: 2:9a5f7d9fd0db
refs: []
---

Expand Down
2 changes: 1 addition & 1 deletion hubs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ anchors:
flag, or changing a default, diverges this anchor — re-read docs/reference/commands.md
before sealing.
at: surf-cli/src/main.rs > Command
hash: 0d910ff4886d
hash: 2:0d910ff4886d
refs: ["../docs/reference/commands.md"]
---

Expand Down
4 changes: 2 additions & 2 deletions hubs/cli-scaffold.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ anchors:
init writes surf.toml + creates hubs/ in the cwd, and is idempotent — an existing
surf.toml is left untouched.
at: surf-cli/src/init.rs > run
hash: cfd3bdbdd15d
hash: 2:dd57e4e7c5d9
- claim: >
new derives the target directory from the literal prefix of the first hub glob, then
writes a hub with no anchors so it is lint-clean immediately; it refuses to overwrite.
at: surf-cli/src/new.rs > hub_dir
hash: 598296b19fb6
hash: 2:d921913bf7bf
refs: []
---

Expand Down
4 changes: 2 additions & 2 deletions hubs/cli-stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ anchors:
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
hash: 2:7f4ab96fac92
- claim: >
compute reads the whole since/until window from one streamed git log and scores each
non-merge commit, propagating hub claim state incrementally — a commit inherits its first
Expand All @@ -19,7 +19,7 @@ anchors:
and missing git history or an invalid hub glob in surf.toml is a hard error rather than a
silent zero or a quietly-narrowed hub set.
at: surf-cli/src/stats.rs > compute
hash: c4d39cabab48
hash: 2:73bc9fa9daac
refs: ["../docs/guides/stats.md"]
---

Expand Down
2 changes: 1 addition & 1 deletion hubs/cli-suggest.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ anchors:
never writes a file and never computes or stamps a hash — the author edits the claims and
verifies.
at: surf-cli/src/suggest.rs > run
hash: 5b5ebe5de616
hash: 2:6d5ea2dc7760
refs: []
---

Expand Down
11 changes: 6 additions & 5 deletions hubs/cli-verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
summary: surf verify — re-seal a claim after a human confirms the prose, with optional --follow.
anchors:
- claim: >
For each claim, plan_claim re-hashes every site (combined) when all resolve, returning
Unchanged when that hash already matches the stored one or Hash to re-stamp otherwise.
For each claim, plan_claim re-hashes every site (combined) under the current recipe when
all resolve, returning Unchanged only when the stored stamp already matches that recipe's
stamp, else Hash to re-stamp — so one pass also upgrades a still-matching v1 stamp to v2.
Under --follow, a site that no longer resolves re-points a renamed single-segment anchor
via find_renamed; a site whose file is unreadable asks git where it moved and re-points the
path (only when the code is otherwise unchanged). Otherwise it skips with a reason. It
never edits prose, only the hash/at line.
path (only when the code is otherwise unchanged under the stored recipe). Otherwise it skips
with a reason. It never edits prose, only the hash/at line.
at: surf-cli/src/verify.rs > plan_claim
hash: 6de72f5412b9
hash: 2:cc47fe88418b
refs: []
---

Expand Down
4 changes: 2 additions & 2 deletions hubs/cli-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ anchors:
discover walks up from a starting directory to the nearest surf.toml (like git/ruff),
parses it, and returns the root + config; it errors if no marker is found in any parent.
at: surf-cli/src/workspace.rs > Workspace > discover
hash: 3ab1ddc44a2e
hash: 2:f9a5e81dc046
- claim: >
hub_paths globs the config's hub patterns relative to the discovered root, sorted and
deduped.
at: surf-cli/src/workspace.rs > Workspace > hub_paths
hash: d51a6b74add6
hash: 2:275e1726b702
refs: []
---

Expand Down
2 changes: 1 addition & 1 deletion hubs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ anchors:
surf.toml parses into a Config whose hubs default to ["hubs/*.md"]; unknown keys are
rejected. Filesystem discovery (walking up for the marker) lives in the CLI, not here.
at: surf-core/src/config.rs > parse_config
hash: 57cd4f316e4a
hash: 2:7b98f22a91b6
refs: []
---

Expand Down
Loading