testsAndMisc/python_pkg/session_autopsy/signatures.py
Krzysztof kuhy Rudnicki a83b5abc27
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
python_pkg: track session_autopsy, finishing its interrupted refactor
The transcript analyzer behind /compile-candidate lived only in the working
tree. Untracked meant unlinted — pre-commit only inspects files handed to it —
so nothing had ever checked it, and it had been abandoned mid-refactor with
every parse raising AttributeError: '_Accumulator' object has no attribute
'first_timestamp'.

The accumulator had been moved onto the grouped records dataclasses, but three
increment sites and parse_session still read flat attributes that no longer
existed. parse_session now hands over acc.meta and acc.counts directly; only
subagent_count comes from elsewhere, since no transcript line carries it.

Two modules broke the 500-line limit, so they were split along seams that were
already there. signatures.py takes the text-signature helpers — traces.py was
importing them from parse.py, making parse an accidental hub, and now neither
imports the other. stats.py takes the pure number and time helpers. parse.py
521 -> 458, detectors.py 559 -> 467.

The structural lint findings were refactored, not configured away.
_counter_candidates took eight arguments; the five naming which counter field to
mine and at what thresholds became a frozen _CounterSpec, so both call sites are
one line. _extract_skill carried in_span and a line budget as loop variables
across thirteen branches; a _SpanBuffer owns that state now, leaving the walk at
four. record() took twelve flat kwargs and now takes one override per record
block, with session_meta() so a partial meta override keeps the standard
timestamp — a bare SessionMeta(slug=...) would have nulled started_at and
quietly changed the per-week arithmetic. All 36 call sites rewritten by an AST
pass. tests/factories.py moved into conftest.py, where every other package here
keeps shared builders and where name-tests-test does not object.

965 tests pass at 100% statement and branch coverage, every hook is green, and
no suppression was added anywhere. `candidates` and `traces skill-finish` both
run against the real corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:56:43 +02:00

68 lines
2.1 KiB
Python

"""Stable text signatures for messages, commands and errors.
Two modules need these: :mod:`python_pkg.session_autopsy.parse` while streaming
a transcript, and :mod:`python_pkg.session_autopsy.traces` when re-reading one to
pull evidence. They live here rather than in either of those so neither has to
import the other.
"""
from __future__ import annotations
import re
MAX_SIG_LEN = 120
_PATH_RE = re.compile(r"(?:~|/)[\w.@+/-]{4,}")
_HEX_RE = re.compile(r"[0-9a-fA-F]{8,}")
_NUM_RE = re.compile(r"\b\d+\b")
_WS_RE = re.compile(r"\s+")
# Wrapper commands whose real command is the following word.
_WRAPPERS = frozenset({"cd", "sudo", "env", "nice", "command", "timeout"})
def normalize_signature(text: str) -> str:
"""Collapse a message/command/error into a stable cross-session signature.
Takes the first line only and masks paths, long hex runs, and numbers so
that reoccurrences of the same event count as one signature.
Args:
text: Raw text (may be multi-line).
Returns:
The normalized first line, truncated to :data:`MAX_SIG_LEN` chars.
"""
stripped = text.strip()
if not stripped:
return ""
first = stripped.splitlines()[0]
first = _PATH_RE.sub("<PATH>", first)
first = _HEX_RE.sub("<HEX>", first)
first = _NUM_RE.sub("<N>", first)
return _WS_RE.sub(" ", first).strip()[:MAX_SIG_LEN]
def command_signature(command: str) -> str:
"""Reduce a Bash command to its leading command word(s).
Wrapper commands (``cd``, ``sudo``, ``timeout`` …) keep their first
non-flag, non-numeric argument so sequences stay meaningful.
Args:
command: The raw (possibly multi-line) Bash tool input.
Returns:
A short signature such as ``"git"`` or ``"cd <PATH>"``, or ``""``.
"""
normalized = normalize_signature(command)
words = normalized.split()
if not words:
return ""
head = words[0]
if head not in _WRAPPERS:
return head
for word in words[1:]:
if not word.startswith(("-", "<N>")):
return f"{head} {word}"
return head