| title | How the gate works |
|---|---|
| description | Locate, canonicalize, hash, compare - the four steps behind surf check, and the versioned JSON seam every plugin reads. |
The gate runs in four steps.
- Locate. tree-sitter parses the file and resolves the
at:path (a qualifiedfile > A > Bpath, with@Nfor genuine name collisions) to the exact node span. A scope is treated as a set of nodes, so a type and itsimpl/methods - which share a name - disambiguate by path:Typealone is ambiguous,Type > methodis unique. In Python the path also resolves non-callables: module constants, type aliases, and class attributes. - Canonicalize. Walk that span's syntax tree into a token stream. Whitespace and comments
aren't in the tree, so they drop out for free; operators, keywords, and literal values are
kept verbatim. Identifiers split into two kinds: a bound name (the symbol's own name,
parameters, locals, loop/destructuring binders) is alpha-renamed to a positional placeholder,
so a consistent local rename yields the same tokens; a free name (external members, call
targets, types, enum/constant references, object keys, decorators) is kept verbatim, so
re-pointing a span at a different symbol -
PointsTier.TIER_1→TIER_2,getHighest→getLowest,@cache→@lru_cache- changes the hash even when the name occurs once. (This bound/free split is the v2 recipe; see Hash recipes.) - 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. - Compare against the stamp stored in the frontmatter (written by
surf verify). The stamp carries its recipe - a v2 stamp is prefixed2:, a bare hex stamp is an older v1 - and is verified under its own recipe, so existing v1 stamps keep passing untilsurf verifyupgrades 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 result.
A claim can opt a narrower scope with ignore_literals: true, which excludes string-literal
content from its hash (a copy edit no longer re-opens the gate; logic still does). The stored
hash is computed in that mode, so the option lives on the claim.
surf check --format json is the seam every optional layer reads. The payload is a versioned
envelope:
{
"version": 1,
"divergences": [
{
"hub": "hubs/auth.md",
"claim": "refresh rotation is single-use; reuse triggers global logout",
"at": "src/auth/refresh.ts > rotateRefreshToken",
"kind": "changed",
"old_hash": "9b1c33ade8f1",
"new_hash": "4d5e6f2a0b7c",
"new_code": "function rotateRefreshToken(...) { ... }",
"prose": "refresh rotation is single-use; reuse triggers global logout",
"magnitude": "small"
}
]
}Per diverged claim: hub, claim, at, kind (changed | unverified | unresolvable),
old_hash, new_hash, old_code, new_code, prose, magnitude, and a detail string on an
unresolvable claim. magnitude (small / medium / large) is advisory triage only - it helps a
human decide which blocked claim to read first, and it never affects pass/fail.
Stability. version is the contract version. Within a major version the shape is
additive-only: new optional fields may appear, but existing fields are never removed, renamed,
or repurposed. A breaking change bumps version. Consumers should read .divergences and tolerate
unknown fields.