CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Python tests (push) Successful in 1m59s
CI & Build / Build & push image (push) Successful in 15s
Twelve more test files drop issue, milestone, rule, note, lesson and snippet numbers and the dated or one-project figures that recorded how each property was found; the docstrings state the property and why it holds. The contract-around-the-change guard keeps the dict-key example as the failure a signature-watcher misses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
8.9 KiB
Python
217 lines
8.9 KiB
Python
"""Entries in the rule exclusion ledger age out.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
The ledger is cleared when an EVENT destroys context — a compaction, a /clear.
|
|
This covers the case with no event at all: a long session where a rule was
|
|
named two hundred turns ago and has simply fallen out of attention. Present in
|
|
context and salient at the moment are different properties, and time
|
|
separates them as surely as a compaction does.
|
|
|
|
WHAT IS PINNED, AND WHAT DELIBERATELY IS NOT
|
|
|
|
Not the TTL's value. 2700 is a judgement made to be revised once there is
|
|
telemetry to revise it with, and a test asserting `== 2700` would turn a
|
|
legitimate tuning change into a red build — which is how a number nobody may
|
|
touch gets one. The tests read the constant out of the shell and assert the
|
|
PROPERTY around it, so any TTL works and only a broken comparison fails.
|
|
|
|
Runs the real shell, like `test_session_context_ledger` and the after-write
|
|
hook's tests, and deliberately with no SCRIBE_URL/SCRIBE_TOKEN: ageing is
|
|
local, keyless and networkless, and must work on an instance that is
|
|
unreachable or was never configured.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" / "hooks"
|
|
DEFS = PLUGIN / "scribe_defs.sh"
|
|
|
|
|
|
def _ttl() -> int:
|
|
"""The TTL as the shell defines it — never a copy of the number."""
|
|
m = re.search(r"^_SCRIBE_RULE_TTL=(\d+)", DEFS.read_text(), re.M)
|
|
assert m, "_SCRIBE_RULE_TTL is gone from scribe_defs.sh"
|
|
return int(m.group(1))
|
|
|
|
|
|
def _live(ledger: Path) -> list[str]:
|
|
"""`scribe_rules_live` against a real ledger, as the hooks call it."""
|
|
for tool in ("bash", "awk"):
|
|
if shutil.which(tool) is None:
|
|
pytest.skip(f"hook runtime tool {tool!r} not installed")
|
|
proc = subprocess.run(
|
|
["bash", "-c", f'. "{DEFS}"; scribe_rules_live "{ledger}"'],
|
|
capture_output=True, text=True, timeout=30,
|
|
env={"PATH": os.environ["PATH"]}, # no credentials — see the docstring
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
out = proc.stdout.strip()
|
|
return out.split(",") if out else []
|
|
|
|
|
|
def _write_ledger(ledger: Path, entries: list[tuple[str, int | None]]) -> None:
|
|
"""A ledger written in AGES rather than absolute stamps.
|
|
|
|
Named for what it is: `tests/helpers._now` is a datetime for record
|
|
fixtures and this file wants the shell's epoch, so they are deliberately
|
|
not the same helper and deliberately not the same name. `time.time()`
|
|
rather than shelling out to `date` — both read the one system clock, so
|
|
the subprocess bought nothing.
|
|
"""
|
|
now = int(time.time())
|
|
ledger.write_text("".join(
|
|
f"{rid}\n" if ago is None else f"{rid}\t{now - ago}\n"
|
|
for rid, ago in entries
|
|
))
|
|
|
|
|
|
def test_an_old_entry_ages_out_and_a_recent_one_does_not(tmp_path):
|
|
"""BOTH DIRECTIONS IN ONE ASSERTION, because either half alone
|
|
passes against a broken implementation.
|
|
|
|
A test that only checks the old id is gone passes against a helper that
|
|
returns nothing at all — which would drop every exclusion and bring back
|
|
the repetition the ledger exists to prevent. A test that only checks the
|
|
recent id survives passes against the flat `tr '\\n' ','` read this
|
|
replaces, i.e. against the defect itself.
|
|
"""
|
|
ttl = _ttl()
|
|
ledger = tmp_path / "s.rules.ids"
|
|
_write_ledger(ledger, [("156", ttl + 600), ("168", 60)])
|
|
|
|
assert _live(ledger) == ["168"], (
|
|
f"expected the entry {ttl + 600}s old to age out and the 60s-old one "
|
|
f"to survive (TTL {ttl}s). An empty list means nothing is excluded any "
|
|
f"more; both ids means nothing ages."
|
|
)
|
|
|
|
|
|
def test_a_repeatedly_surfaced_rule_appears_once_in_the_exclusion_list(tmp_path):
|
|
"""List hygiene, and it is not cosmetic.
|
|
|
|
The ledger is append-only, so a rule surfaced five times has five lines.
|
|
The output is spliced straight into `&exclude_rule_ids=` and parsed by the
|
|
server as a list; duplicates make that list grow without bound over a long
|
|
session, and a trailing or doubled comma is an empty element the parser has
|
|
to decide what to do with. Neither is visible from the hint.
|
|
|
|
(There was a boundary test here — `ttl` vs `ttl + 1`. It was racy by
|
|
construction: a ledger written at T is read at T+n, so the entry ages by
|
|
however long the subprocess took and the two cases swap. A one-second
|
|
distinction on a 45-minute window is also not a behaviour anyone can
|
|
observe, so it was pinning a flake rather than a property.)
|
|
"""
|
|
ledger = tmp_path / "s.rules.ids"
|
|
_write_ledger(ledger, [("156", 60), ("168", 30), ("156", 10), ("156", 5)])
|
|
|
|
live = _live(ledger)
|
|
assert live == ["156", "168"], (
|
|
f"got {live}: an id repeated in the ledger must appear once in the "
|
|
f"exclusion list, in first-seen order"
|
|
)
|
|
raw = subprocess.run(
|
|
["bash", "-c", f'. "{DEFS}"; scribe_rules_live "{ledger}"'],
|
|
capture_output=True, text=True, timeout=30,
|
|
env={"PATH": os.environ["PATH"]},
|
|
).stdout.strip()
|
|
assert ",," not in raw and not raw.endswith(","), (
|
|
f"the exclusion list has an empty element: {raw!r}"
|
|
)
|
|
|
|
|
|
def test_a_rule_that_ages_out_and_returns_does_not_ping_pong(tmp_path):
|
|
"""THE FAILURE THIS COSTS THE MOST TO GET WRONG.
|
|
|
|
The ledger is append-only, so a rule that ages out, gets surfaced fresh and
|
|
is appended again has TWO lines. An implementation reading the FIRST leaves
|
|
it permanently expired — and it then re-announces itself on every single
|
|
call for the rest of the session. The mechanism meant to quieten things
|
|
becomes the loudest thing in the hint.
|
|
"""
|
|
ttl = _ttl()
|
|
ledger = tmp_path / "s.rules.ids"
|
|
_write_ledger(ledger, [("156", ttl + 600)])
|
|
|
|
subprocess.run(
|
|
["bash", "-c", f'. "{DEFS}"; printf \'156\\n\' | scribe_rules_append "{ledger}"'],
|
|
capture_output=True, text=True, timeout=30,
|
|
env={"PATH": os.environ["PATH"]},
|
|
)
|
|
|
|
assert _live(ledger) == ["156"], (
|
|
"a re-surfaced rule read as still expired; the most recent entry for "
|
|
"an id must win, or it re-announces itself on every call from here on"
|
|
)
|
|
|
|
|
|
def test_a_bare_id_from_the_old_format_is_treated_as_live(tmp_path):
|
|
"""The compatibility decision, asserted rather than described.
|
|
|
|
Every session in flight when this ships has a ledger of bare ids. Reading
|
|
unknown as EXPIRED would make all of them re-announce every rule they had
|
|
already been told, all at once — the exact noise this feature exists to
|
|
prevent, delivered by the feature itself on the day it ships.
|
|
|
|
Unknown means "not measured", never "old" — the same discipline the
|
|
nullable retrieval_logs columns use. Those entries never age, which is
|
|
bounded, because the session ends.
|
|
"""
|
|
ttl = _ttl()
|
|
ledger = tmp_path / "s.rules.ids"
|
|
ledger.write_text("156\n168\n")
|
|
assert _live(ledger) == ["156", "168"]
|
|
|
|
# And a bare id keeps its own meaning next to a stale stamped one, rather
|
|
# than the file falling back to one format or the other wholesale.
|
|
_write_ledger(ledger, [("9", None), ("156", ttl + 600)])
|
|
assert _live(ledger) == ["9"]
|
|
|
|
|
|
def test_a_missing_or_empty_ledger_excludes_nothing(tmp_path):
|
|
"""Fail-open, like every other path in these hooks: no ledger means no
|
|
exclusions, not an error and not a stray comma the server would parse as
|
|
an id."""
|
|
assert _live(tmp_path / "never-written.rules.ids") == []
|
|
empty = tmp_path / "empty.rules.ids"
|
|
empty.write_text("")
|
|
assert _live(empty) == []
|
|
|
|
|
|
def test_both_hooks_read_the_ledger_through_the_shared_helper():
|
|
"""Structural, because the defect this would reintroduce is invisible.
|
|
|
|
Two hooks share one ledger so a rule named by one arm is not re-offered by
|
|
the other. If either goes back to reading the file flat, that hook stops
|
|
ageing while its sibling keeps ageing — the two disagree about which rules
|
|
are live, and nothing in the output says so.
|
|
|
|
Pinned on the flat-read SHAPE rather than on a helper name: a hook that
|
|
stops calling the helper but still ages correctly some other way is not a
|
|
defect, and a rename of the helper is not one either.
|
|
"""
|
|
for name in ("scribe_prior_art.sh", "scribe_tool_rules.sh"):
|
|
src = (PLUGIN / name).read_text()
|
|
rule_reads = [
|
|
ln for ln in src.splitlines()
|
|
if "rulefile" in ln and "tr " in ln and r"'\n' ','" in ln
|
|
]
|
|
assert not rule_reads, (
|
|
f"{name} reads the rule ledger flat again: {rule_reads}. That "
|
|
f"hook's exclusions would stop ageing while its sibling's keep "
|
|
f"ageing, and the two would disagree silently about which rules "
|
|
f"this session still holds."
|
|
)
|
|
assert "scribe_rules_live" in src, (
|
|
f"{name} no longer reads the rule ledger through the shared "
|
|
f"helper, so the two hooks can drift on the ledger format"
|
|
)
|