Skip to content

feat(hub): Merkle content-hash change detector (Track 1, step 1) - #85

Merged
study8677 merged 1 commit into
mainfrom
feat/merkle-change-detector
May 24, 2026
Merged

feat(hub): Merkle content-hash change detector (Track 1, step 1)#85
study8677 merged 1 commit into
mainfrom
feat/merkle-change-detector

Conversation

@study8677

Copy link
Copy Markdown
Owner

First slice of incremental refresh. A standalone, side-effect-light module
that hashes the workspace at file -> module -> repo granularity and diffs
against a persisted snapshot to report which modules changed — without
trusting git state (sees uncommitted edits, survives branch switches, works
in non-git dirs).

What's here (hub/_merkle.py)

  • build_tree(...) — pure: rolls per-file hashes up into module hashes and a
    root hash (deterministic, order-independent).
  • build_workspace_tree(workspace) — uses the same module detection and
    file-loading as the refresh pipeline, so a "changed module" equals a unit
    refresh would regenerate. File reads only; no LLM, no network.
  • diff_trees(prev, cur) — added / modified / removed modules.
  • save_snapshot / load_snapshot — JSON with schema versioning; a
    missing / corrupt / stale snapshot loads as None so callers fall back to
    a full rebuild.

Why module-grained (not Cursor-style per-chunk)

Cursor re-embeds only changed files because an embedding is a context-free
function of one chunk. Antigravity's per-module knowledge is interpreted and
cross-references other modules, so the eventual cache key will be
hash(own files + dependency closure). The raw hashes built here are the
inputs that key is computed from.

Behaviour change

None. This is not wired into the refresh pipeline. The follow-up PR
consumes diff_trees to skip unchanged modules and recompute only a changed
module plus its graph impact closure (with live-source verification as the
correctness backstop).

Testing

  • 12 unit tests (test_hub_merkle.py): hashing determinism/order-independence,
    diff add/modify/remove, snapshot round-trip + degradation paths, and a real
    tmp-workspace end-to-end (edit a file -> root changes -> diff flags modified).
  • Hub subset green locally (85 passed).

🤖 Generated with Claude Code

…fresh

Standalone foundation for incremental refresh: a file -> module -> repo
content-hash tree that diffs against a persisted snapshot to report which
modules changed, independent of git state (catches uncommitted edits,
survives branch switches, works in non-git dirs).

- _merkle.py: build_tree (pure), build_workspace_tree (uses the same module
  detection / file-loading as refresh, so a 'changed module' equals a unit
  refresh would regenerate), diff_trees, and save/load_snapshot with schema
  versioning that degrades to a full rebuild on stale/corrupt snapshots.
- Not wired into the refresh pipeline yet -- no behavioural change. The
  follow-up consumes diff_trees to skip unchanged modules and recompute only
  a changed module plus its graph impact closure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@study8677
study8677 merged commit 4d4c95e into main May 24, 2026
8 checks passed
@study8677
study8677 deleted the feat/merkle-change-detector branch May 24, 2026 14:08

@study8677 study8677 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总体评价

代码质量很高,设计清晰,架构合理:纯函数 build_tree + 独立 I/O 层 + 版本化快照是教科书级别的模式。12 个测试覆盖了正常路径和所有降级路径,且与主 pipeline 使用相同的模块检测逻辑,确保"已变更模块"语义一致。建议 Approve,有两处🟡建议值得修复,三处🟢优化可选。


问题清单

级别 文件 & 行号 描述 建议
🟡 建议 _merkle.py L55–66 @dataclass(frozen=True)dict 字段,隐式不可哈希。frozen=True 通常意味着可哈希性(即可作 dict key / set 元素),但实际调用 hash(node) 会抛 TypeError: unhashable type: 'dict',会让未来的维护者踩坑 若不需要哈希,改用 @dataclass(eq=True)(去掉 frozen);若需要真正不可变,将 dict 改为 tuple[tuple[str,str],...] 或明确加 __hash__ = None 并注释
🟡 建议 _merkle.py L87–89 _hash_lines"\n" 作为行分隔符,而文件路径中理论上可含 \n(部分 OS 允许)。注释已说明 _SEP="\0" 防 token 内注入,但行间分隔符漏洞未处理,理论上两条不同的 (path, hash) 组合可以产生相同的拼接串 行分隔符也改用 "\0"(hex hash 和 NUL 分隔的路径都不含 NUL),完全消除歧义
🟢 优化 _merkle.py L84–85 changed_modules 将两个已互不相交的列表(added 来自 cur-prevmodified 来自 cur∩prev)转换为 set 再取并集,有不必要的开销 改为 return sorted(self.added + self.modified) 即可,注释说明两者不相交
🟢 优化 _merkle.py L115–116 延迟导入放在函数体内,原因(避免循环导入)对读者不透明 加一行注释:# deferred to avoid circular imports at module load time
🟢 优化 test_hub_merkle.py 缺少三个边界用例:① 空模块(无文件)build_tree({"m": {}}) — 所有空模块会共享同一 hash;② 仅重命名文件(内容不变、路径改变)应注册为 modified;③ 空工作区(无可检测模块)build_workspace_tree 补充对应 test case,防止后续逻辑假设 module 至少有一个文件

亮点

  • 纯函数 build_tree:零副作用,与 I/O 完全解耦,极易测试和推理 ✓
  • _SEP = "\0" + 注释:主动防御路径注入,有文字说明设计意图 ✓
  • SNAPSHOT_VERSION + load_snapshot 返回 None:版本不匹配静默降级为全量重建,健壮且对调用方透明 ✓
  • 测试的降级路径覆盖:missing / bad JSON / version mismatch 三个 load_snapshot 失效路径全部有测试 ✓
  • PR 描述中的设计文档:为什么是模块粒度而非 Cursor 风格的 per-chunk,解释清晰,非常有助于后续接手的人理解 ✓

修改示例

🟡 Issue 1 — frozen dataclass + unhashable dict(若不需要哈希,最简修法)

# 去掉 frozen=True,保留 eq(__eq__ 自动生成)
@dataclass
class ModuleNode:
    hash: str
    files: dict[str, str] = field(default_factory=dict)

@dataclass
class MerkleTree:
    root: str
    modules: dict[str, ModuleNode] = field(default_factory=dict)

若需要真正不可变且可哈希,改用 tuple 存储文件列表:

@dataclass(frozen=True)
class ModuleNode:
    hash: str
    files: tuple[tuple[str, str], ...] = field(default_factory=tuple)
    # usage: dict(node.files) to get {rel_path: hash} view

🟡 Issue 2 — 更安全的行分隔符

def _hash_lines(lines: list[str]) -> str:
    """Hash a set of ``name<SEP>hash`` lines order-independently."""
    # NUL as separator: it cannot appear in file paths or hex digests,
    # so no two distinct line-sets can produce the same joined string.
    return hashlib.sha256("\0".join(sorted(lines)).encode("utf-8")).hexdigest()

🟢 Optimization 1 — changed_modules

@property
def changed_modules(self) -> list[str]:
    """Modules whose knowledge must be (re)generated: added + modified."""
    # added and modified are disjoint by construction (cur-prev vs cur∩prev)
    return sorted(self.added + self.modified)

Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 860e2a9753

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

hash=str(entry.get("hash", "")),
files=dict(entry.get("files", {})),
)
for module_id, entry in data.get("modules", {}).items()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gracefully reject malformed snapshot payloads

load_snapshot promises to return None for unreadable/corrupt snapshots, but MerkleTree.from_dict assumes modules and each entry are mappings; if merkle.json parses and has the right version but wrong shape (for example "modules": [] or a module value that is not an object), this comprehension raises AttributeError/TypeError and aborts refresh instead of falling back to full rebuild. This can occur with hand-edited or partially corrupted snapshot files that remain valid JSON.

Useful? React with 👍 / 👎.

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.

1 participant