refactor(tests+frontend): one http_sink helper for the hook tests; apiErrorMessage replaces ten hand-rolled error-body parses; type X, import specifiers are not definitions (#2904, milestone 299 step 6)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 9s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m11s
CI & Build / Build & push image (push) Successful in 38s

tests/helpers.http_sink replaces three module-local _Sink handlers (the
write-path tests and the after-write test). ProjectView + SettingsView
parsed `(e as {body?:{error?}}).body?.error || fallback` by hand ten times
beside the apiErrorMessage canon (#2853) - all ten now call it. The
extractor (server + the hook awk mirror) no longer reads `import { type Foo }`
as a definition of Foo - that was the last "identical body" sym family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 15:07:23 -04:00
co-authored by Claude Fable 5
parent 449f437048
commit 590203a293
8 changed files with 137 additions and 133 deletions
+2 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client"; import { apiGet, apiPatch, apiDelete, apiPost, apiPut, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks"; import { useTasksStore } from "@/stores/tasks";
@@ -543,8 +543,7 @@ async function saveForgePin() {
if (project.value) project.value.forge_connection_id = forgePin.value; if (project.value) project.value.forge_connection_id = forgePin.value;
await loadCoverage(); await loadCoverage();
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toast.show(apiErrorMessage(e, "Failed to change the project's forge"), "error");
toast.show(body?.error || "Failed to change the project's forge", "error");
forgePin.value = project.value?.forge_connection_id ?? null; forgePin.value = project.value?.forge_connection_id ?? null;
} finally { } finally {
savingForgePin.value = false; savingForgePin.value = false;
+9 -18
View File
@@ -780,8 +780,7 @@ async function saveConnection() {
connFormOpen.value = false; connFormOpen.value = false;
await loadForgeConnections(); await loadForgeConnections();
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Failed to save forge connection"), "error");
toastStore.show(body?.error || "Failed to save forge connection", "error");
} finally { } finally {
savingConn.value = false; savingConn.value = false;
} }
@@ -793,8 +792,7 @@ async function removeConnection(id: number) {
connTestResult.value = null; connTestResult.value = null;
await loadForgeConnections(); await loadForgeConnections();
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Failed to delete forge connection"), "error");
toastStore.show(body?.error || "Failed to delete forge connection", "error");
} }
} }
@@ -810,10 +808,9 @@ async function testConnection(id: number) {
message: `Connected — ${res.version}, authenticated as ${res.username}`, message: `Connected — ${res.version}, authenticated as ${res.username}`,
}; };
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body;
connTestResult.value = { connTestResult.value = {
id, ok: false, id, ok: false,
message: body?.error || "Connection test failed", message: apiErrorMessage(e, "Connection test failed"),
}; };
} finally { } finally {
testingConnId.value = 0; testingConnId.value = 0;
@@ -830,8 +827,7 @@ async function saveForgeWebhook() {
forgeWebhookSaved.value = true; forgeWebhookSaved.value = true;
setTimeout(() => (forgeWebhookSaved.value = false), 2000); setTimeout(() => (forgeWebhookSaved.value = false), 2000);
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Failed to save webhook secret"), "error");
toastStore.show(body?.error || "Failed to save webhook secret", "error");
} finally { } finally {
savingForgeWebhook.value = false; savingForgeWebhook.value = false;
} }
@@ -864,8 +860,7 @@ async function saveMarketplaceUrl() {
marketplaceUrlSaved.value = true; marketplaceUrlSaved.value = true;
setTimeout(() => (marketplaceUrlSaved.value = false), 2000); setTimeout(() => (marketplaceUrlSaved.value = false), 2000);
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Failed to save marketplace URL"), "error");
toastStore.show(body?.error || "Failed to save marketplace URL", "error");
} finally { } finally {
savingMarketplaceUrl.value = false; savingMarketplaceUrl.value = false;
} }
@@ -882,8 +877,7 @@ async function saveDbMaintenance() {
dbMaintSaved.value = true; dbMaintSaved.value = true;
setTimeout(() => (dbMaintSaved.value = false), 2000); setTimeout(() => (dbMaintSaved.value = false), 2000);
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Failed to save maintenance settings"), "error");
toastStore.show(body?.error || "Failed to save maintenance settings", "error");
} finally { } finally {
savingDbMaint.value = false; savingDbMaint.value = false;
} }
@@ -912,8 +906,7 @@ async function runDbMaintenanceNow() {
); );
await loadDbHealth(); // reflect the dead-tuple drop await loadDbHealth(); // reflect the dead-tuple drop
} catch (e) { } catch (e) {
const body = (e as { body?: { error?: string } }).body; toastStore.show(apiErrorMessage(e, "Maintenance run failed"), "error");
toastStore.show(body?.error || "Maintenance run failed", "error");
} finally { } finally {
runningDbMaint.value = false; runningDbMaint.value = false;
} }
@@ -1144,8 +1137,7 @@ async function sendInvite() {
inviteEmail.value = ""; inviteEmail.value = "";
await fetchInvitations(); await fetchInvitations();
} catch (e: unknown) { } catch (e: unknown) {
const body = (e as { body?: { error?: string } })?.body; toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
toastStore.show(body?.error || "Failed to send invitation", "error");
} finally { } finally {
sendingInvite.value = false; sendingInvite.value = false;
} }
@@ -1196,8 +1188,7 @@ async function deleteUser(userId: number) {
users.value = users.value.filter((u) => u.id !== userId); users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted"); toastStore.show("User deleted");
} catch (e: unknown) { } catch (e: unknown) {
const body = (e as { body?: { error?: string } })?.body; toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
toastStore.show(body?.error || "Failed to delete user", "error");
} finally { } finally {
deleting.value = null; deleting.value = null;
} }
+7
View File
@@ -58,6 +58,13 @@ scribe_defs() {
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) { if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
t = line; sub(/^[a-z]+[[:space:]]+/, "", t) t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t) sub(/[^A-Za-z0-9_$].*$/, "", t)
# `type` defines only when something follows the name (= or {); an
# import specifier `type Foo,` is the same two words and defines
# nothing (mirror of coverage.py, #2904).
if (line ~ /^type[[:space:]]/) {
rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest)
if (rest !~ /[={]/) next
}
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
} }
# Arrow/expression assignment: const name = (…) / let name = async ( # Arrow/expression assignment: const name = (…) / let name = async (
+6
View File
@@ -123,6 +123,12 @@ def _definition_on(raw: str) -> tuple[str, str] | None:
name = m.group(1) name = m.group(1)
if name.startswith("__") and name.endswith("__"): if name.startswith("__") and name.endswith("__"):
return None return None
# `type` announces a definition only when something is declared after
# the name (`type Foo = …`, `type Foo struct {`); an import specifier
# (`import { type Foo, bar }`) is the same two words and defines
# nothing — it showed up as a two-file "identical body" family (#2904).
if line.startswith("type") and not re.search(r"[={]", line[m.end():]):
return None
return ("sym", name) return ("sym", name)
if m := _ARROW_RE.match(line): if m := _ARROW_RE.match(line):
return ("sym", m.group(1)) return ("sym", m.group(1))
+37
View File
@@ -6,6 +6,7 @@ them; a module imports what it needs with ``from tests.helpers import ...``.
""" """
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -182,3 +183,39 @@ def design_token_stub(name, value_by_mode, group_name=None, purpose=None,
name=name, value_by_mode=value_by_mode, group_name=group_name, name=name, value_by_mode=value_by_mode, group_name=group_name,
purpose=purpose, order_index=order_index, supersedes=supersedes or [], purpose=purpose, order_index=order_index, supersedes=supersedes or [],
) )
@contextmanager
def http_sink(reply: bytes = b'{"context":"","note_ids":[]}'):
"""A throwaway local HTTP listener for hook end-to-end tests: yields
``(port, seen)`` where ``seen`` collects every GET's parsed query string
(one dict per request, in order). Lets the shell be tested end to end —
the extraction, the encoding, the URL — without a Scribe instance.
Three test modules each carried their own ``_Sink`` handler before #2904
consolidated them here; pass ``reply`` for the body the hook should see.
"""
import http.server
import threading
import urllib.parse
seen: list[dict] = []
class _Sink(http.server.BaseHTTPRequestHandler):
def do_GET(self):
seen.append(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(reply)
def log_message(self, *a):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
yield server.server_port, seen
finally:
server.shutdown()
server.server_close()
+35 -59
View File
@@ -6,17 +6,16 @@ the pre-write hook's end-to-end tests. Skips where the hook's tools are
missing; asserts on content where they are present.""" missing; asserts on content where they are present."""
from __future__ import annotations from __future__ import annotations
import http.server
import json import json
import os import os
import shutil import shutil
import subprocess import subprocess
import threading
import urllib.parse
from pathlib import Path from pathlib import Path
import pytest import pytest
from tests.helpers import http_sink
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
HOOK = PLUGIN / "hooks" / "scribe_after_write.sh" HOOK = PLUGIN / "hooks" / "scribe_after_write.sh"
@@ -53,66 +52,43 @@ def _run(repo, env, session="s-after-1", tool="Bash"):
return out.stdout return out.stdout
class _Sink(http.server.BaseHTTPRequestHandler): SINK_REPLY = b'{"context":"> family named","note_ids":[],"sync_note_ids":[],"derive_keys":["dup:483a"]}'
seen: list[dict] = []
reply = b'{"context":"> family named","note_ids":[],"sync_note_ids":[],"derive_keys":["dup:483a"]}'
def do_GET(self):
type(self).seen.append(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(type(self).reply)
def log_message(self, *a):
pass
@pytest.fixture def test_after_write_names_what_bash_just_wrote_then_stays_quiet_until_the_next_change(tmp_path):
def sink(): with http_sink(SINK_REPLY) as (port, seen):
_Sink.seen = [] env = _env(tmp_path, url=f"http://127.0.0.1:{port}")
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink) repo = _repo(tmp_path, env)
threading.Thread(target=server.serve_forever, daemon=True).start() # "A Bash call" wrote an untracked stylesheet and appended to a tracked file.
try: (repo / "a.css").write_text(".log-empty {\n color: red;\n}\n")
yield server (repo / "b.py").write_text("def one():\n return 1\n\ndef slug(t):\n return t\n")
finally: out = _run(repo, env)
server.shutdown() by_path = {q["path"][0]: q for q in seen}
assert set(by_path) == {"a.css", "b.py"} # repo-relative, like the pre hook
assert by_path["a.css"]["shapes"] == ["css:log-empty"]
assert by_path["b.py"]["shapes"] == ["sym:slug"]
# Added lines only for the tracked file — the existing def is not "just written".
assert "def slug" in by_path["b.py"]["code"][0] and "def one" not in by_path["b.py"]["code"][0]
ctx = json.loads(out)["hookSpecificOutput"]
assert ctx["hookEventName"] == "PostToolUse"
assert "> family named" in ctx["additionalContext"]
# The local by-name arm rides along: `slug` already lives in c.py.
assert "`slug` is already defined in 1 other file(s): c.py" in ctx["additionalContext"]
# Derive keys landed on the SHARED channel the pre-write hook reads.
state = tmp_path / "scribe-priorart" / "s-after-1.derive.ids"
assert "dup:483a" in state.read_text().split()
# Nothing changed → one git status, no request, no output.
seen.clear()
assert _run(repo, env) == ""
assert seen == []
def test_after_write_names_what_bash_just_wrote_then_stays_quiet_until_the_next_change(tmp_path, sink): # Another change → only that file, and the dedup channel goes back up.
env = _env(tmp_path, url=f"http://127.0.0.1:{sink.server_port}") (repo / "a.css").write_text(".log-empty {\n color: red;\n}\n.other {\n margin: 0;\n}\n")
repo = _repo(tmp_path, env) _run(repo, env)
# "A Bash call" wrote an untracked stylesheet and appended to a tracked file. assert [q["path"][0] for q in seen] == ["a.css"]
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n") assert seen[0]["exclude_derive"] == ["dup:483a"]
(repo / "b.py").write_text("def one():\n return 1\n\ndef slug(t):\n return t\n") assert set(seen[0]["shapes"][0].split(",")) == {"css:log-empty", "css:other"}
out = _run(repo, env)
by_path = {q["path"][0]: q for q in _Sink.seen}
assert set(by_path) == {"a.css", "b.py"} # repo-relative, like the pre hook
assert by_path["a.css"]["shapes"] == ["css:log-empty"]
assert by_path["b.py"]["shapes"] == ["sym:slug"]
# Added lines only for the tracked file — the existing def is not "just written".
assert "def slug" in by_path["b.py"]["code"][0] and "def one" not in by_path["b.py"]["code"][0]
ctx = json.loads(out)["hookSpecificOutput"]
assert ctx["hookEventName"] == "PostToolUse"
assert "> family named" in ctx["additionalContext"]
# The local by-name arm rides along: `slug` already lives in c.py.
assert "`slug` is already defined in 1 other file(s): c.py" in ctx["additionalContext"]
# Derive keys landed on the SHARED channel the pre-write hook reads.
state = tmp_path / "scribe-priorart" / "s-after-1.derive.ids"
assert "dup:483a" in state.read_text().split()
# Nothing changed → one git status, no request, no output.
_Sink.seen = []
assert _run(repo, env) == ""
assert _Sink.seen == []
# Another change → only that file, and the dedup channel goes back up.
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n.other {\n margin: 0;\n}\n")
_run(repo, env)
assert [q["path"][0] for q in _Sink.seen] == ["a.css"]
assert _Sink.seen[0]["exclude_derive"] == ["dup:483a"]
assert set(_Sink.seen[0]["shapes"][0].split(",")) == {"css:log-empty", "css:other"}
def test_after_write_is_silent_where_it_has_nothing_to_say(tmp_path): def test_after_write_is_silent_where_it_has_nothing_to_say(tmp_path):
env = _env(tmp_path) env = _env(tmp_path)
+15
View File
@@ -149,6 +149,21 @@ def test_largest_gaps_ranks_by_unclassified_and_drops_clean_dirs():
assert gaps == [{"dir": "src", "unclassified": 2, "total": 3}] assert gaps == [{"dir": "src", "unclassified": 2, "total": 3}]
def test_type_import_specifiers_are_not_definitions():
"""#2904: `import { type Foo, bar }` is the same two words as `type Foo =`
and defines nothing; only a `type` line with a declaration after the
name counts (TS alias, Go/Rust type)."""
from scribe.services.coverage import extract_shapes
src = (
'import { type DesignSystem, fetchDesignSystems } from "@/api/designSystems";\n'
'import { type Project } from "./x";\n'
"type Baz = { a: number };\n"
"type Wide<T> = T | null;\n"
"type Point struct {\n\tX int\n}\n"
)
assert extract_shapes(src) == [("sym", "Baz"), ("sym", "Wide"), ("sym", "Point")]
def test_coverage_line_is_evidence_carrying_and_labeled_estimate(): def test_coverage_line_is_evidence_carrying_and_labeled_estimate():
line = coverage_line({ line = coverage_line({
"total": 4573, "accounted": 3100, "unclassified": 1473, "total": 4573, "accounted": 3100, "unclassified": 1473,
+26 -53
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tests.helpers import fake_note from tests.helpers import fake_note, http_sink
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
HOOK = PLUGIN / "hooks" / "scribe_prior_art.sh" HOOK = PLUGIN / "hooks" / "scribe_prior_art.sh"
@@ -1149,6 +1149,23 @@ def test_route_stamps_only_for_a_caller_allowed_to_write():
assert "&shapes=" in hook assert "&shapes=" in hook
def test_hook_does_not_name_a_type_import_specifier_as_a_shape(tmp_path):
"""#2904, the awk mirror of the server rule: `type Foo,` inside an import
list is not a definition; `type Baz = …` on its own line is."""
env = _hook_runtime_env()
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
seen = _run_hook_against_sink(tmp_path, {
"session_id": "s-type", "cwd": str(repo), "tool_name": "Write",
"tool_input": {"file_path": str(repo / "x.ts"),
"content": 'import { type Foo, bar } from "./y";\n'
"type Baz = { a: number };\n"
"export function use(): Baz {\n return { a: 1 };\n}\n"},
})
assert seen["shapes"] == ["sym:Baz,sym:use"]
def test_hook_names_the_shapes_being_written(): def test_hook_names_the_shapes_being_written():
"""The feed's two inputs: every definition in the payload, or — for an Edit """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, that changes a body, not a signature — the definition enclosing the edit,
@@ -1165,39 +1182,15 @@ def test_hook_names_the_shapes_being_written():
def _run_hook_against_sink(tmp_path, payload): def _run_hook_against_sink(tmp_path, payload):
"""Run the hook with SCRIBE_URL pointed at a throwaway local listener and """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 — return the query the hook sent (tests.helpers.http_sink)."""
the extraction, the encoding, the URL — without a Scribe instance.""" with http_sink() as (port, seen):
import http.server env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
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( out = subprocess.run(
["bash", str(HOOK)], input=json.dumps(payload), ["bash", str(HOOK)], input=json.dumps(payload),
capture_output=True, text=True, env=env, capture_output=True, text=True, env=env,
) )
assert out.returncode == 0, out.stderr assert out.returncode == 0, out.stderr
finally: return seen[0] if seen else {}
server.shutdown()
server.server_close()
return seen
def test_hook_sends_every_definition_in_a_write(tmp_path): def test_hook_sends_every_definition_in_a_write(tmp_path):
@@ -1321,28 +1314,10 @@ def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path):
"""#2900: derive keys the server returns land in the session's own """#2900: derive keys the server returns land in the session's own
`.derive.ids` file and go back as `exclude_derive` on the next write — `.derive.ids` file and go back as `exclude_derive` on the next write —
a family is named once per session, not at every edit.""" a family is named once per session, not at every edit."""
import http.server reply = (b'{"context":"> family","note_ids":[],"sync_note_ids":[],'
import threading b'"derive_keys":["dup:483a","canon:2855"]}')
import urllib.parse with http_sink(reply) as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}",
seen: list[dict] = []
class _Sink(http.server.BaseHTTPRequestHandler):
def do_GET(self):
seen.append(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":"> family","note_ids":[],"sync_note_ids":[],'
b'"derive_keys":["dup:483a","canon:2855"]}')
def log_message(self, *a):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{server.server_port}",
TMPDIR=str(tmp_path)) TMPDIR=str(tmp_path))
payload = {"session_id": "s-derive-1", "cwd": str(tmp_path), "tool_name": "Write", payload = {"session_id": "s-derive-1", "cwd": str(tmp_path), "tool_name": "Write",
"tool_input": {"file_path": str(tmp_path / "a.css"), "tool_input": {"file_path": str(tmp_path / "a.css"),
@@ -1351,8 +1326,6 @@ def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path):
out = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload), out = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
capture_output=True, text=True, env=env) capture_output=True, text=True, env=env)
assert out.returncode == 0, out.stderr assert out.returncode == 0, out.stderr
finally:
server.shutdown()
assert "exclude_derive" not in seen[0] assert "exclude_derive" not in seen[0]
assert seen[1]["exclude_derive"] == ["dup:483a,canon:2855"] assert seen[1]["exclude_derive"] == ["dup:483a,canon:2855"]
state = tmp_path / "scribe-priorart" / "s-derive-1.derive.ids" state = tmp_path / "scribe-priorart" / "s-derive-1.derive.ids"