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: 2 additions & 0 deletions deps/skills/bump/reference/adding-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ def handle(verb, argv):
if verb == "apply":
# argv: list of specs to apply (e.g., ["requests==2.31.0", "flask==3.0.0"])
# Return: {"applied": [spec, ...], "filesModified": [path, ...]}
# On failure: {"applied": [], "filesModified": [], "error": "<failed command>: <output tail>"}
# filesModified must list only files that actually changed (git-verified).
...

if verb == "validate":
Expand Down
2 changes: 1 addition & 1 deletion dev/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dev",
"version": "2.4.2",
"version": "2.4.3",
"description": "Developer toolkit (sem-powered): annotate code with durable SEM@<sha> intent markers (sem-annotate), keep them maintained via a project convention (sem-auto), and find dead code + duplication and produce a ranked plan (dedupe). Uses the sem CLI entity graph. Supports Go, TypeScript/JavaScript, and Python.",
"author": { "name": "efitz" }
}
2 changes: 1 addition & 1 deletion dev/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dev",
"version": "2.4.2",
"version": "2.4.3",
"description": "Developer toolkit (sem-powered): annotate code with durable SEM@<sha> intent markers (sem-annotate), keep them maintained via a project convention (sem-auto), and find dead code + duplication and produce a ranked plan (dedupe). Uses the sem CLI entity graph. Supports Go, TypeScript/JavaScript, and Python.",
"author": {
"name": "efitz"
Expand Down
27 changes: 27 additions & 0 deletions dev/scripts/sem_annotate.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ def head_sha(cwd=None):
return ""


def sha_reachable(sha, cwd=None):
"""True when sha is an ancestor of HEAD.

Squash-merge workflows orphan every branch commit: the objects still resolve, but
`sem diff <orphan>..HEAD` silently reports no changes, which classified genuinely
stale markers as fresh (issue #30). An anchor we cannot compare against must be
re-anchored, never trusted.
"""
r = subprocess.run(["git", "merge-base", "--is-ancestor", sha, "HEAD"],
cwd=cwd, capture_output=True, text=True)
return r.returncode == 0


def scan(paths, cwd=None, rebuild=False):
"""Worklist for entities classified missing/stale (or all when rebuild=True).

Expand Down Expand Up @@ -247,6 +260,14 @@ def scan(paths, cwd=None, rebuild=False):
logic = False
if existing_sha and not _is_uncommitted(anchor_sha) \
and not anchor_sha.startswith(existing_sha):
if not sha_reachable(existing_sha, cwd=cwd):
work.append({
"file": f, "name": e["name"],
"start_line": e["start_line"], "end_line": e["end_line"],
"status": "orphaned", "anchor_sha": anchor_sha,
"existing_desc": existing_desc, "bad_sha": existing_sha,
})
continue
try:
logic = e["name"] in logic_changed_entities(existing_sha, f, cwd=cwd)
except SemError:
Expand All @@ -265,6 +286,12 @@ def scan(paths, cwd=None, rebuild=False):
"status": status, "anchor_sha": anchor_sha,
"existing_desc": existing_desc,
})
orphans = sum(1 for w in work if w["status"] == "orphaned")
if orphans:
print(f"warning: {orphans} marker(s) anchored to commits unreachable from HEAD "
"(orphaned by squash-merge or history rewrite); staleness could not be "
"computed against them, so they are queued for re-annotation.",
file=sys.stderr)
return work


Expand Down
9 changes: 6 additions & 3 deletions dev/skills/sem-annotate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,12 @@ to `{missing: 0}` with no `stale` entries.
- `stale` — marker present but entity has a logical change since the anchored commit
- `uncommitted` — marker present but the anchor SHA is blank or all-zeros (dirty tree with
no committed history for this entity); will resolve once changes are committed
- `invalid-sha` — a previously-written marker carries a SHA that `sem diff` cannot resolve
(the commit was garbage-collected or the SHA is corrupt); the entity will be re-annotated
by the next annotate pass
- `orphaned` — marker present but its anchor commit is not reachable from HEAD (typically
orphaned by a squash-merge, or the sha no longer resolves); staleness cannot be computed
against it, so the entity is re-described and re-anchored at a reachable commit
- `invalid-sha` — a previously-written marker carries a SHA that is reachable but `sem diff`
cannot process (corrupt or unresolvable to sem); the entity will be re-annotated by the
next annotate pass

### 6. Offer the CLAUDE.md convention note (once)
If the project's `CLAUDE.md` does not already mention SEM markers, offer to add a short
Expand Down
58 changes: 58 additions & 0 deletions docs/upstream/sem-orphaned-anchor-staleness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# DRAFT upstream issue for ataraxy-labs/sem — not yet filed

Adapted from ericfitz/skills#30. Review before filing.

---

**Title:** `sem diff <base>..HEAD` silently reports no changes when `<base>` is an
orphaned commit (squash-merge workflows), so staleness checks built on it never fire

## Summary

When the base revision passed to `sem diff` (and consumed by scan/update flows built on
it) resolves as a git object but is **not reachable from HEAD**, sem reports no changes
instead of erroring or flagging the condition. In squash-merge workflows this is the
normal end state for any commit recorded on a feature branch: after the squash-merge the
original commits become orphaned objects — `git cat-file -e <sha>` succeeds, but
`git merge-base --is-ancestor <sha> HEAD` exits 1.

Any tooling that anchors semantic state to a commit sha and later asks sem "did this
entity change since `<sha>`?" gets a silent false "no" for every pre-squash anchor. The
failure does not self-report: no error, no warning, exit 0.

## Reproduction

1. In a repo using squash merges: record a sha on a feature branch (any commit that
touches a tracked function).
2. Squash-merge the branch to main; delete the branch.
3. Change the function's behavior on main.
4. Run `sem diff <branch-sha>..HEAD --no-cosmetics -- <file>`.

Expected: an error (unreachable base) or the logical change reported.
Actual: empty diff, exit 0.

Check: `git merge-base --is-ancestor <sha> HEAD; echo $?` → `1` for affected shas.

## Impact

Squash-merge is a very common GitHub workflow, so any sem-based staleness tracking
silently stops working for a repo's entire pre-squash history. Users reasonably conclude
"nothing changed" and move on.

## Suggested fixes (any of)

1. During diff/scan, check base reachability (`merge-base --is-ancestor`); treat an
unreachable base as an error or a distinct "orphaned" result — never as "no changes".
2. Fall back to comparing against the nearest reachable commit instead of giving up.
3. At minimum, emit a loud warning when a base fails reachability so the silent mode is
impossible.
4. Longer-term: content-hash anchors (hash of the entity's normalized body) instead of
commit shas — immune to history rewriting entirely.

Options 1 + 3 together would have surfaced this immediately.

## Environment

- sem-cli 0.21.0 (Homebrew), macOS (darwin 25.6.0)
- Observed via SEM@sha marker tooling in ericfitz/skills (see ericfitz/skills#30 for the
downstream write-up and the consumer-side workaround)
90 changes: 86 additions & 4 deletions tests/test_sem_annotate.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import io
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import ClassVar
from unittest import mock

import sem_annotate as sa

Expand Down Expand Up @@ -239,19 +243,20 @@ class TestScanInvalidSha(unittest.TestCase):
def setUp(self):
self.files = {"src/a.ts": "// SEM@deadbee: old\nfunction A() {}\n"}
self._orig = (sa._read_text, sa.sem_entities, sa.sem_blame,
sa.entity_logic_sha, sa.logic_changed_entities)
sa.entity_logic_sha, sa.logic_changed_entities, sa.sha_reachable)
sa._read_text = lambda p: self.files[p]
sa.sem_entities = lambda paths, cwd=None: [
{"name": "A", "type": "function", "file": "src/a.ts", "start_line": 2, "end_line": 2}]
sa.sem_blame = lambda f, cwd=None: [{"name": "A", "commit": "ffff999"}]
sa.entity_logic_sha = lambda name, f, cwd=None, fallback_sha="": "ffff999"
sa.sha_reachable = lambda sha, cwd=None: True
def boom(base, f, cwd=None):
raise sa.InvalidRevError("revspec not found")
sa.logic_changed_entities = boom

def tearDown(self):
(sa._read_text, sa.sem_entities, sa.sem_blame,
sa.entity_logic_sha, sa.logic_changed_entities) = self._orig
sa.entity_logic_sha, sa.logic_changed_entities, sa.sha_reachable) = self._orig

def test_bad_hash_reported_not_crashed(self):
work = sa.scan(["src/a.ts"])
Expand All @@ -268,12 +273,13 @@ class TestScanInvalidShaPlainSemError(unittest.TestCase):
def setUp(self):
self.files = {"src/a.ts": "// SEM@deadbee: old\nfunction A() {}\n"}
self._orig = (sa._read_text, sa.sem_entities, sa.sem_blame,
sa.entity_logic_sha, sa.logic_changed_entities)
sa.entity_logic_sha, sa.logic_changed_entities, sa.sha_reachable)
sa._read_text = lambda p: self.files[p]
sa.sem_entities = lambda paths, cwd=None: [
{"name": "A", "type": "function", "file": "src/a.ts", "start_line": 2, "end_line": 2}]
sa.sem_blame = lambda f, cwd=None: [{"name": "A", "commit": "ffff999"}]
sa.entity_logic_sha = lambda name, f, cwd=None, fallback_sha="": "ffff999"
sa.sha_reachable = lambda sha, cwd=None: True
def boom(base, f, cwd=None):
raise sa.SemError(
"sem diff failed: the git_object of id 'deadbee...' "
Expand All @@ -283,7 +289,7 @@ def boom(base, f, cwd=None):

def tearDown(self):
(sa._read_text, sa.sem_entities, sa.sem_blame,
sa.entity_logic_sha, sa.logic_changed_entities) = self._orig
sa.entity_logic_sha, sa.logic_changed_entities, sa.sha_reachable) = self._orig

def test_plain_semerror_on_bad_sha_reported_not_crashed(self):
work = sa.scan(["src/a.ts"])
Expand Down Expand Up @@ -544,5 +550,81 @@ def test_db_update_no_files_is_auto(self):
self.assertEqual(called["auto"], 1)


class TestShaReachable(unittest.TestCase):
"""Squash-merge orphans branch commits: they resolve as objects but are not
ancestors of HEAD, and sem diff against them silently reports nothing (#30)."""

GIT_ENV: ClassVar[dict] = {
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"HOME": "/dev/null", "PATH": "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin"}

def _git(self, *args):
r = subprocess.run(["git", *args], cwd=self.root, env=self.GIT_ENV,
capture_output=True, text=True, check=True)
return r.stdout.strip()

def setUp(self):
self._tmp = TemporaryDirectory()
self.root = Path(self._tmp.name)
self.addCleanup(self._tmp.cleanup)
self._git("init", "-q", "-b", "main")
(self.root / "a.txt").write_text("1\n")
self._git("add", "a.txt")
self._git("commit", "-qm", "base")
self.base = self._git("rev-parse", "HEAD")
self._git("checkout", "-qb", "feat")
(self.root / "a.txt").write_text("2\n")
self._git("commit", "-qam", "feat work")
self.branch_sha = self._git("rev-parse", "HEAD")
self._git("checkout", "-q", "main")
self._git("merge", "--squash", "feat")
self._git("commit", "-qm", "squashed")
self._git("branch", "-qD", "feat")

def test_reachable_ancestor(self):
self.assertTrue(sa.sha_reachable(self.base, cwd=self.root))

def test_orphaned_commit_resolves_but_is_unreachable(self):
# the object still exists...
subprocess.run(["git", "cat-file", "-e", self.branch_sha], cwd=self.root,
env=self.GIT_ENV, check=True)
# ...but must be treated as unreachable
self.assertFalse(sa.sha_reachable(self.branch_sha, cwd=self.root))

def test_garbage_sha_is_unreachable(self):
self.assertFalse(sa.sha_reachable("zzzzzzz", cwd=self.root))


class TestScanClassifiesOrphaned(unittest.TestCase):
"""An unreachable anchor must never classify fresh -- sem diff cannot compute
against it, so the silent no-op previously hid every squash-merged stale marker."""

def setUp(self):
ent = {"name": "F", "type": "function", "file": "a.go",
"start_line": 2, "end_line": 4}
self.enterContext(mock.patch.object(sa, "sem_entities", return_value=[ent]))
self.enterContext(mock.patch.object(sa, "sem_blame", return_value=[]))
self.enterContext(mock.patch.object(sa, "entity_logic_sha", return_value="a1b2c3d4e5"))
self.enterContext(mock.patch.object(
sa, "_read_text", return_value="// SEM@deadbee: does a thing\nfunc F() {}\n"))
self.diff = self.enterContext(mock.patch.object(sa, "logic_changed_entities"))

def test_unreachable_anchor_yields_orphaned_not_fresh(self):
with mock.patch.object(sa, "sha_reachable", return_value=False):
work = sa.scan(["a.go"])
self.assertEqual(len(work), 1)
self.assertEqual(work[0]["status"], "orphaned")
self.assertEqual(work[0]["bad_sha"], "deadbee")
self.diff.assert_not_called() # sem diff against an orphan is meaningless

def test_reachable_anchor_still_uses_sem_diff(self):
self.diff.return_value = set() # cosmetic-only change
with mock.patch.object(sa, "sha_reachable", return_value=True):
work = sa.scan(["a.go"])
self.assertEqual(work, []) # fresh: not in worklist
self.diff.assert_called_once()


if __name__ == "__main__":
unittest.main()
Loading