Skip to content

feat(hash): full bound/free identifier split, v2 recipe (#77)#147

Merged
Connorrmcd6 merged 1 commit into
mainfrom
feat/77-bound-free-hash-split
Jun 29, 2026
Merged

feat(hash): full bound/free identifier split, v2 recipe (#77)#147
Connorrmcd6 merged 1 commit into
mainfrom
feat/77-bound-free-hash-split

Conversation

@Connorrmcd6

Copy link
Copy Markdown
Owner

Summary

Closes #77. Completes the v2 hash recipe into the bound/free split — the principled fix for the gate's highest-value blind spot.

v1 alpha-renamed every identifier, so a one-token change re-pointing a span at a different single-occurrence external symbol (PointsTier.TIER_1TIER_2, getHighestgetLowest) produced an identical hash. check stayed green while the claim's prose silently became false.

What changed

  • Bound/free split (surf-core/src/hash.rs). v2 alpha-renames only bound names — the symbol's own name, parameters, locals, loop/range/comprehension vars, with/catch aliases, generic params, destructuring binders — and emits every free identifier verbatim. Re-pointing at a different member, call target, type, enum/const, object key, or decorator is now loud even when it occurs once; consistent local renames stay quiet. Two-pass: collect_bound/bind_here (per-family binding tables) then emit.
  • Subsumes two special cases. The check: member-access names verbatim in the hash (v2 recipe) — high-value slice of #77 #140 member-access-only first cut and the Python decorator rule (Python: decorator span gap when anchoring a function #8) both fall out — they're just free identifiers now. (One dedicated check remains so a member name stays verbatim when it collides with a bound local: x the param vs obj.x.)
  • Recipe decision: completed v2, did not add v3. 0.7.0 is unreleased, so v2 is redefined in place. v1 stays byte-frozen — golden fixtures confirm released v1 stamps still verify.
  • Fail-closed. tree-sitter only, no scope analysis: a position not positively a binding defaults to free. The one accepted approximation (match-arm pattern identifiers left free) is documented and pinned.
  • Differential validation harness (surf-core/tests/differential_hash.rs, new). In-tree v1-vs-v2 A/B across all four languages, kept in-tree so any future canonicalization change reruns the same gate.
  • Governance + docs. Re-pinned v2 golden digests; version table + N-1 support policy + identification/remedy in docs/reference/hash-recipes.md; updated how-it-works.md and CHANGELOG.md; dogfooded — claims in hubs/hash.md anchored to collect_bound/is_member_access_name, all hubs re-stamped.

The external git-history replay over real corpora (prometheus / nansen-python-sdk / a TS repo) named in the issue runs out-of-tree against release binaries; the in-tree harness is its deterministic, always-on counterpart.

Verification

All gates run on this branch:

  • cargo fmt --all --check → clean
  • cargo clippy --all-targets --all-features -- -D warnings → 0 warnings
  • cargo test --all → all pass (incl. golden + differential)
  • Differential harness: 13 benign / 0 regressions, 12 semantic / 100% v2 catch / 0% v1
  • Dogfood surf check → all anchored spans match

🤖 Generated with Claude Code

v1 alpha-renamed every identifier, so re-pointing a span at a different
single-occurrence external symbol was byte-identical and passed the gate
silently while the claim's prose became false. v2 now alpha-renames only
*bound* names (the symbol's own name, params, locals, loop/range/comprehension
vars, with/catch aliases, generic params, destructuring binders) and emits
every *free* identifier verbatim, so swapping a member, call target, type,
enum/const, object key, or decorator is loud even when it occurs once, while
consistent local renames stay quiet.

This completes v2 into the recipe the original design intended: it subsumes the
member-access-only first cut and the Python decorator special case (#8). 0.7.0
is unreleased, so v2 is redefined in place rather than adding a v3; v1 stays
byte-frozen (golden fixtures confirm released stamps are safe).

Binding detection is tree-sitter-only and fail-closed: a position not
positively recognized as a binding defaults to free. The one accepted
approximation (match-arm pattern identifiers are left free) is documented and
pinned. A new in-tree differential harness gates the change — 13 benign renames
with zero regressions, 12 semantic free-swaps with 100% v2 catch / 0% v1 —
alongside re-pinned golden digests, the version-table governance in
docs/reference/hash-recipes.md, and dogfood claims in hubs/hash.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Connorrmcd6 Connorrmcd6 merged commit bc65f21 into main Jun 29, 2026
4 checks passed
@Connorrmcd6 Connorrmcd6 deleted the feat/77-bound-free-hash-split branch June 29, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check: alpha-renaming can't tell a local rename from a changed external reference — PointsTier.TIER_1 → TIER_2 passes the gate

1 participant