feat(ledger): write-path stamping — a pulled canon the session then instantiates lands as a hook instance row (#2791, milestone 294 step 5)
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 28s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 39s
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 28s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 39s
The prior-art hook now names the shapes being written (shapes=kind:name — every definition in the payload, or the one enclosing an Edit found by walking the file upward) and the server stamps them as instance rows when the session PULLED a snippet inside PULL_WINDOW that the payload references by symbol or that the semantic arm scored for this very payload. classified_by=hook, evidence in reason; never overrides a judgment or a canonical row, overridable by classify_shapes. Offered-but-unopened stamps nothing. Pulled-and-already-seen snippets stay in the semantic query as evidence without re-entering the deduped menu. A brand-new shape gets a provisional row the next sync confirms or vanishes. Read-scoped keys get the hint, never the stamp. Plugin 0.1.34. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -219,3 +219,133 @@ async def test_sync_refiles_rows_whose_snippet_was_purged(seeded):
|
||||
))).scalar_one()
|
||||
assert row.status == "unclassified"
|
||||
assert row.snippet_id is None
|
||||
|
||||
|
||||
# --- #2791: the write-path feed lands hook evidence as rows -------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_write_path_stamp_is_evidence_that_yields_to_judgment(seeded):
|
||||
"""Pulled + referenced → every named shape of the snippet's kind becomes
|
||||
an instance row, classified_by=hook, carrying the evidence as reason. A
|
||||
later agent judgment on one of them stands against a re-stamp; the hook
|
||||
may only overwrite nobody's judgment or its own. The outsider stamps
|
||||
nothing (write-gated like every other ledger write)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scribe.services.shape_ledger import stamp_write_path_instances
|
||||
|
||||
owner, other, pid, sid = (
|
||||
seeded["owner"], seeded["other"], seeded["pid"], seeded["snippet"]
|
||||
)
|
||||
pulled = {sid: datetime.now(timezone.utc)}
|
||||
code = "app = factory()\nreturn app\n" # references the snippet's symbol
|
||||
|
||||
assert await stamp_write_path_instances(
|
||||
other, pid, path="src/app.py", shapes=[("sym", "make_app")],
|
||||
code=code, pulled=pulled,
|
||||
) == []
|
||||
|
||||
stamped = await stamp_write_path_instances(
|
||||
owner, pid, path="src/app.py",
|
||||
shapes=[("sym", "make_app"), ("sym", "Config"), ("css", "nope")],
|
||||
code=code, pulled=pulled,
|
||||
)
|
||||
assert {s["symbol"] for s in stamped} == {"make_app", "Config"} # css skipped: no css canon
|
||||
rows, _ = await list_project_shapes(owner, pid, snippet_id=sid)
|
||||
by_symbol = {r.symbol: r for r in rows}
|
||||
assert by_symbol["make_app"].status == "instance"
|
||||
assert by_symbol["make_app"].classified_by == "hook"
|
||||
assert by_symbol["make_app"].reason == f"hook: pulled #{sid}; payload references `factory`"
|
||||
|
||||
# A judgment lands; the next stamp must leave it alone but may re-stamp
|
||||
# its own earlier row.
|
||||
await classify_shapes(owner, pid, [
|
||||
{"path": "src/app.py", "symbol": "make_app", "status": "exempt",
|
||||
"reason": "the app factory is its own thing"},
|
||||
])
|
||||
again = await stamp_write_path_instances(
|
||||
owner, pid, path="src/app.py",
|
||||
shapes=[("sym", "make_app"), ("sym", "Config")], code=code, pulled=pulled,
|
||||
)
|
||||
assert {s["symbol"] for s in again} == {"Config"}
|
||||
rows, _ = await list_project_shapes(owner, pid, path="src/app.py")
|
||||
by_symbol = {r.symbol: r for r in rows}
|
||||
assert by_symbol["make_app"].status == "exempt"
|
||||
assert by_symbol["Config"].status == "instance"
|
||||
|
||||
# Neither pulled nor in play → nothing, even with shapes named.
|
||||
assert await stamp_write_path_instances(
|
||||
owner, pid, path="src/util.py", shapes=[("sym", "helper")],
|
||||
code="print('unrelated')", pulled=pulled,
|
||||
) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_a_brand_new_shape_gets_a_provisional_row_the_sync_settles(seeded):
|
||||
"""The shape being written right now has no ledger row yet. With the
|
||||
hook's repo key it gets a provisional one — seen markers unset — so the
|
||||
stamp survives until the next sync, which confirms it (sets the marker)
|
||||
or stamps it vanished. Without a repo key only existing rows are touched."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scribe.services.shape_ledger import stamp_write_path_instances
|
||||
|
||||
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
|
||||
pulled = {sid: datetime.now(timezone.utc)}
|
||||
code = "def build():\n return factory()\n"
|
||||
|
||||
assert await stamp_write_path_instances(
|
||||
owner, pid, path="src/new.py", shapes=[("sym", "build")],
|
||||
code=code, pulled=pulled, # no repo_key
|
||||
) == []
|
||||
stamped = await stamp_write_path_instances(
|
||||
owner, pid, path="src/new.py", shapes=[("sym", "build")],
|
||||
code=code, pulled=pulled, repo_key=REPO,
|
||||
)
|
||||
assert [s["symbol"] for s in stamped] == ["build"]
|
||||
async with async_session() as s:
|
||||
row = (await s.execute(select(CodeShape).where(
|
||||
CodeShape.project_id == pid, CodeShape.path == "src/new.py",
|
||||
))).scalar_one()
|
||||
assert row.status == "instance" and row.classified_by == "hook"
|
||||
assert row.first_seen_commit is None and row.last_seen_commit is None
|
||||
|
||||
# The sync sees the shape in the tree → confirmed, stamp intact.
|
||||
await sync_repo_shapes(
|
||||
pid, REPO, SHAPES + [("src/new.py", "sym", "build")], seen_marker="abc123",
|
||||
)
|
||||
rows, _ = await list_project_shapes(owner, pid, path="src/new.py")
|
||||
assert rows[0].status == "instance" and rows[0].last_seen_commit == "abc123"
|
||||
|
||||
# The sync no longer sees it → vanished, out of the live accounting.
|
||||
await sync_repo_shapes(pid, REPO, SHAPES, seen_marker="def456")
|
||||
rows, _ = await list_project_shapes(owner, pid, path="src/new.py")
|
||||
assert rows == []
|
||||
rows, _ = await list_project_shapes(owner, pid, path="src/new.py", include_vanished=True)
|
||||
assert rows[0].vanished_at is not None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_recent_pulls_reads_the_usage_stream(seeded):
|
||||
"""The "actually pulled it" half is the PULLED usage event, inside the
|
||||
window; a surfacing alone is not a pull."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
|
||||
from scribe.services.shape_ledger import recent_pulls
|
||||
|
||||
owner, sid = seeded["owner"], seeded["snippet"]
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as s:
|
||||
s.add_all([
|
||||
NoteUsageEvent(user_id=owner, note_id=sid, event=PULLED, source="mcp_get_snippet"),
|
||||
NoteUsageEvent(user_id=owner, note_id=sid + 1000, event=SURFACED, source="auto_inject"),
|
||||
NoteUsageEvent(user_id=owner, note_id=sid + 2000, event=PULLED,
|
||||
source="mcp_get_snippet", created_at=now - timedelta(days=2)),
|
||||
])
|
||||
await s.commit()
|
||||
pulls = await recent_pulls(owner)
|
||||
assert sid in pulls
|
||||
assert sid + 1000 not in pulls
|
||||
assert sid + 2000 not in pulls
|
||||
|
||||
@@ -82,3 +82,56 @@ def test_classify_and_list_are_mounted_as_mcp_tools():
|
||||
mcp = build_mcp_server()
|
||||
for name in ("classify_shapes", "list_shapes", "refresh_pattern_coverage"):
|
||||
assert mcp._tool_manager.get_tool(name) is not None
|
||||
|
||||
|
||||
# --- step 5: the write-path feed's evidence tests (pure) ---------------------
|
||||
|
||||
|
||||
def test_symbol_reference_is_word_bounded_and_kind_aware():
|
||||
from scribe.services.shape_ledger import references_symbol as ref
|
||||
|
||||
code = "const ok = await confirmed({ title: 'x' });\nif (!ok) return;"
|
||||
assert ref(code, "confirmed", "sym")
|
||||
assert not ref(code, "confirm", "sym") # prefix never claims the call
|
||||
assert not ref("", "confirmed", "sym")
|
||||
assert not ref(code, "", "sym")
|
||||
# CSS: the class as a selector or inside a class attribute; dashes are part
|
||||
# of the name, so `btn` must not claim `btn-primary`.
|
||||
html = '<button class="btn btn-primary">Go</button>'
|
||||
assert ref(html, ".btn-primary", "css")
|
||||
assert ref(html, "btn-primary", "css")
|
||||
assert ref(".btn-primary { color: red }", ".btn-primary", "css")
|
||||
assert not ref('<button class="btn-primary">', ".btn", "css")
|
||||
assert ref('<button class="btn-primary btn">', ".btn", "css")
|
||||
|
||||
|
||||
def test_snippet_kind_reads_the_symbol_then_the_language():
|
||||
from scribe.services.shape_ledger import snippet_kind
|
||||
|
||||
assert snippet_kind(".btn-primary", "css") == "css"
|
||||
assert snippet_kind(".btn-primary", "") == "css"
|
||||
assert snippet_kind("confirmed", "typescript") == "sym"
|
||||
assert snippet_kind("", "scss") == "css" # whole-stylesheet record
|
||||
assert snippet_kind("", "python") == "sym"
|
||||
|
||||
|
||||
def test_route_shapes_param_parses_capped_and_deduped():
|
||||
from scribe.routes.plugin import _SHAPES_CAP, _parse_shapes
|
||||
|
||||
assert _parse_shapes("css:btn-primary,sym:onTrash") == [
|
||||
("css", "btn-primary"), ("sym", "onTrash"),
|
||||
]
|
||||
assert _parse_shapes(" sym:a , sym:a ,bogus:x,sym:,:,") == [("sym", "a")]
|
||||
assert _parse_shapes("") == []
|
||||
many = ",".join(f"sym:f{i}" for i in range(40))
|
||||
assert len(_parse_shapes(many)) == _SHAPES_CAP
|
||||
|
||||
|
||||
def test_hook_is_a_server_internal_classifier():
|
||||
"""`hook` is in the status vocabulary but NOT a via a caller may claim —
|
||||
a classify_shapes call saying via="hook" would launder judgment as
|
||||
evidence (the reverse of the stamping rule's point)."""
|
||||
from scribe.services.shape_ledger import _CALLER_VIAS
|
||||
|
||||
assert "hook" in SHAPE_CLASSIFIERS
|
||||
assert "hook" not in _CALLER_VIAS
|
||||
|
||||
@@ -778,11 +778,13 @@ def test_route_reads_every_arg_the_hook_sends():
|
||||
|
||||
from scribe.routes import plugin as routes
|
||||
src = inspect.getsource(routes.write_path_prior_art)
|
||||
for arg in ("path", "code", "repo", "project_id", "exclude_ids", "exclude_sync_ids"):
|
||||
for arg in ("path", "code", "repo", "project_id", "exclude_ids",
|
||||
"exclude_sync_ids", "shapes"):
|
||||
assert f'request.args.get("{arg}"' in src, f"route ignores {arg}"
|
||||
|
||||
hook = HOOK.read_text()
|
||||
for arg in ("path=", "code=", "repo=", "exclude_ids=", "exclude_sync_ids="):
|
||||
for arg in ("path=", "code=", "repo=", "exclude_ids=", "exclude_sync_ids=",
|
||||
"shapes="):
|
||||
assert arg in hook, f"hook never sends {arg}"
|
||||
|
||||
|
||||
@@ -1009,3 +1011,240 @@ def test_local_arm_finds_duplicates_in_every_language_family(
|
||||
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "already defined" in ctx
|
||||
assert "create_snippet" in ctx
|
||||
|
||||
|
||||
# --- #2791: the write-path feed — hook evidence lands as ledger rows ----------
|
||||
|
||||
|
||||
def _ts():
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stamping_needs_named_shapes_and_a_recent_pull():
|
||||
"""Offered-but-ignored stamps nothing: without a PULLED event there is no
|
||||
evidence, and without the hook naming shapes there is nothing to stamp.
|
||||
Neither case may even read the pull stream."""
|
||||
from scribe.services import plugin_context as pc
|
||||
pulls = AsyncMock(return_value={})
|
||||
stamp = AsyncMock(return_value=[])
|
||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", pulls), \
|
||||
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", stamp):
|
||||
out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, project_id=4)
|
||||
assert out["stamped"] == []
|
||||
pulls.assert_not_awaited() # no shapes → no read
|
||||
out = await pc.build_write_path_hint(
|
||||
1, "src/x.py", code=REAL_CODE, project_id=4,
|
||||
stamp_shapes=[("sym", "debounce")],
|
||||
)
|
||||
pulls.assert_awaited_once()
|
||||
stamp.assert_not_awaited() # shapes, but no pull
|
||||
assert out["stamped"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_pulled_snippet_already_seen_is_evidence_not_menu():
|
||||
"""The pulled-then-written flow IS the dedup-excluded flow: the hint offered
|
||||
#7 earlier (so it sits in exclude_ids), the session pulled it, and now
|
||||
writes code resembling it. #7 must be scored for this payload — and handed
|
||||
to the stamp as resemblance — without being re-listed in the menu."""
|
||||
from scribe.services import plugin_context as pc
|
||||
search = AsyncMock(return_value=[(0.91, _note(7, "pulled")), (0.80, _note(8, "fresh"))])
|
||||
stamp = AsyncMock(return_value=[{
|
||||
"path": "src/x.py", "symbol": "debounce", "kind": "sym",
|
||||
"snippet_id": 7, "reason": "hook: pulled #7; payload resembles it (0.91)",
|
||||
}])
|
||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg(top_k=3))), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc, "semantic_search_notes", search), \
|
||||
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={7: _ts()})), \
|
||||
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", stamp):
|
||||
out = await pc.build_write_path_hint(
|
||||
1, "src/x.py", code=REAL_CODE, project_id=4, exclude_ids=[7],
|
||||
stamp_shapes=[("sym", "debounce")], repo_key="git.example.com/a/b",
|
||||
)
|
||||
# The query kept #7 eligible (and widened the budget by one for it)...
|
||||
kw = search.call_args.kwargs
|
||||
assert 7 not in kw["exclude_ids"]
|
||||
assert kw["limit"] == 4
|
||||
# ...but the menu still honours the session dedup.
|
||||
assert out["note_ids"] == [8]
|
||||
assert "#7" not in "\n".join(
|
||||
line for line in out["context"].splitlines() if "[similar" in line
|
||||
)
|
||||
# The stamp saw the pull and the resemblance score for this payload.
|
||||
skw = stamp.call_args.kwargs
|
||||
assert skw["pulled"] == {7: skw["pulled"][7]}
|
||||
assert skw["resembles"] == {7: 0.91}
|
||||
assert skw["shapes"] == [("sym", "debounce")]
|
||||
assert skw["repo_key"] == "git.example.com/a/b"
|
||||
assert out["stamped"][0]["snippet_id"] == 7
|
||||
# And the session is told what landed, with the way to correct it.
|
||||
assert "Shape accounting" in out["context"]
|
||||
assert "`debounce` → instance of #7" in out["context"]
|
||||
assert "classify_shapes" in out["context"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stamp_renders_even_when_the_hint_is_otherwise_silent():
|
||||
"""After dedup the common case is an empty hint; the stamp must still run
|
||||
and still be reported — silence about accounting is how hook rows would
|
||||
become invisible."""
|
||||
from scribe.services import plugin_context as pc
|
||||
stamped = [{"path": "web/b.css", "symbol": "btn-primary", "kind": "css",
|
||||
"snippet_id": 5, "reason": "hook: pulled #5; payload references `btn-primary`"}]
|
||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={5: _ts()})), \
|
||||
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances",
|
||||
AsyncMock(return_value=stamped)):
|
||||
out = await pc.build_write_path_hint(
|
||||
1, "web/b.css", code=".btn-primary { color: red; }" * 4, project_id=4,
|
||||
stamp_shapes=[("css", "btn-primary")],
|
||||
)
|
||||
assert out["note_ids"] == []
|
||||
assert out["stamped"] == stamped
|
||||
assert "`.btn-primary` → instance of #5" in out["context"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failing_stamp_does_not_sink_the_hint():
|
||||
from scribe.services import plugin_context as pc
|
||||
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=_cfg())), \
|
||||
patch.object(pc.snippets_svc, "list_snippets",
|
||||
AsyncMock(return_value=([_snippet_item(12, "records me")], 1))), \
|
||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \
|
||||
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={12: _ts()})), \
|
||||
patch.object(pc.shape_ledger_svc, "stamp_write_path_instances",
|
||||
AsyncMock(side_effect=RuntimeError("ledger down"))):
|
||||
out = await pc.build_write_path_hint(
|
||||
1, "src/x.py", code=REAL_CODE, project_id=4, stamp_shapes=[("sym", "f")],
|
||||
)
|
||||
assert out["sync_note_ids"] == [12]
|
||||
assert out["stamped"] == []
|
||||
|
||||
|
||||
def test_route_stamps_only_for_a_caller_allowed_to_write():
|
||||
"""A read-scoped key gets the hint — every plugin hook works on a read key
|
||||
— but a GET must never change accounting for it. The route passes the
|
||||
shapes through only when the key is write-scoped (or it's a session)."""
|
||||
import inspect
|
||||
|
||||
from scribe.routes import plugin as routes
|
||||
src = inspect.getsource(routes.write_path_prior_art)
|
||||
assert 'request.args.get("shapes"' in src
|
||||
assert '== "write"' in src
|
||||
assert "stamp_shapes=shapes if may_stamp else None" in src
|
||||
assert "normalize_repo_key(repo)" in src
|
||||
hook = HOOK.read_text()
|
||||
assert "&shapes=" in hook
|
||||
|
||||
|
||||
def test_hook_names_the_shapes_being_written():
|
||||
"""The feed's two inputs: every definition in the payload, or — for an Edit
|
||||
that changes a body, not a signature — the definition enclosing the edit,
|
||||
found by walking the target file upward from the edited lines."""
|
||||
src = HOOK.read_text()
|
||||
assert "scribe_defs()" in src # one extractor, two consumers
|
||||
assert ".tool_input.old_string" in src # the Edit's anchor
|
||||
assert "| tac | scribe_defs | head -1" in src # nearest definition above
|
||||
# The ledger feed sends NAMES, never bodies, and stays on the one GET.
|
||||
assert src.count("/api/plugin/prior-art?") == 1
|
||||
|
||||
|
||||
def _run_hook_against_sink(tmp_path, payload):
|
||||
"""Run the hook with SCRIBE_URL pointed at a throwaway local listener and
|
||||
return the query the hook sent. Lets the shell be tested end to end —
|
||||
the extraction, the encoding, the URL — without a Scribe instance."""
|
||||
import http.server
|
||||
import threading
|
||||
import urllib.parse
|
||||
|
||||
seen: dict = {}
|
||||
|
||||
class _Sink(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
seen.update(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"context":"","note_ids":[]}')
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{server.server_port}")
|
||||
out = subprocess.run(
|
||||
["bash", str(HOOK)], input=json.dumps(payload),
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
assert out.returncode == 0, out.stderr
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
return seen
|
||||
|
||||
|
||||
def test_hook_sends_every_definition_in_a_write(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
env = _hook_runtime_env()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||
seen = _run_hook_against_sink(tmp_path, {
|
||||
"session_id": "s-feed-w", "cwd": str(repo), "tool_name": "Write",
|
||||
"tool_input": {
|
||||
"file_path": str(repo / "new.ts"),
|
||||
"content": "export async function onDelete(): Promise<void> {\n"
|
||||
" const ok = await confirmed({ title: 'x' });\n}\n"
|
||||
".btn-primary {\n color: red;\n}\n",
|
||||
},
|
||||
})
|
||||
assert seen["path"] == ["new.ts"]
|
||||
assert seen["shapes"] == ["css:btn-primary,sym:onDelete"]
|
||||
|
||||
|
||||
def test_hook_sends_the_enclosing_definition_for_a_body_edit(tmp_path):
|
||||
"""An Edit to the inside of onTrash names no definition itself; the hook
|
||||
must walk the file upward from the edited line and send onTrash."""
|
||||
import shutil
|
||||
if shutil.which("tac") is None:
|
||||
pytest.skip("the enclosing-definition walk needs tac")
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
env = _hook_runtime_env()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||
target = repo / "comp.vue"
|
||||
target.write_text(
|
||||
"<script setup lang=\"ts\">\n"
|
||||
"async function onTrash(): Promise<void> {\n"
|
||||
" const ok = await confirmed({ title: 'Move to the trash?' });\n"
|
||||
" if (!ok) return;\n"
|
||||
"}\n"
|
||||
"const other = () => {\n return 1;\n};\n"
|
||||
"</script>\n"
|
||||
)
|
||||
seen = _run_hook_against_sink(tmp_path, {
|
||||
"session_id": "s-feed-e", "cwd": str(repo), "tool_name": "Edit",
|
||||
"tool_input": {
|
||||
"file_path": str(target),
|
||||
"old_string": " if (!ok) return;",
|
||||
"new_string": " if (!ok) return;\n await guarded(() => store.trashNode(id));",
|
||||
},
|
||||
})
|
||||
assert seen["shapes"] == ["sym:onTrash"]
|
||||
|
||||
Reference in New Issue
Block a user