Match the SQL indent of the two edited lines in metrics.py to their neighbours (one space too deep, which made the diff noisier than the change), drop test_src_has_no_unexpected_hits from the tripwire (an identical copy of test_no_iso_t_datetime_comparison misfiled under the positive controls), and replace an em dash in a comment with ASCII. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KkCGRantZsSwmcFpet6FTa
137 lines
4.7 KiB
Python
137 lines
4.7 KiB
Python
"""Tripwire: detect ISO-T vs datetime('now', ...) comparisons in src/*.py.
|
|
|
|
SQLite ``datetime()`` returns a space-separated string (``2026-08-23 00:18:04``).
|
|
Columns in this codebase are written by ``datetime.now(timezone.utc).isoformat()``,
|
|
so they carry a ``T`` separator (``2026-08-23T00:20:04.577131+00:00``).
|
|
|
|
Comparing them with ``>=`` / ``<=`` / ``>`` / ``<`` is a silent bug: ``T > ' '``,
|
|
so once the dates match the time-of-day never participates and the window
|
|
becomes "everything today".
|
|
|
|
Use ``julianday()`` on both sides of the comparison instead.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
SRC = Path(__file__).resolve().parent.parent / "src"
|
|
|
|
_COLUMNS = (
|
|
"observed_at|ticked_at|created_at|opened_at|"
|
|
"last_fired_at|resolved_at|updated_at"
|
|
)
|
|
|
|
# Column on the left: observed_at >= datetime('now', ...)
|
|
_RE_COL_OP_NOW = re.compile(
|
|
rf"\b(?:{_COLUMNS})\s*(?:>=|<=|>|<)\s*"
|
|
rf"datetime\(\s*'now'"
|
|
)
|
|
|
|
# Column on the right: datetime('now', ...) <= observed_at
|
|
_RE_NOW_OP_COL = re.compile(
|
|
rf"datetime\(\s*'now'[^)]*\)\s*(?:>=|<=|>|<)\s*"
|
|
rf"\b(?:{_COLUMNS})\b"
|
|
)
|
|
|
|
_FAIL_MSG = (
|
|
"compare with julianday(col) >= julianday('now', ...); "
|
|
"the column is ISO with a T, "
|
|
"datetime() returns a space, "
|
|
"and 'T' > ' ' makes the time of day never participate."
|
|
)
|
|
|
|
|
|
def _iter_source_files() -> list[Path]:
|
|
"""Return all .py files under src/, sorted."""
|
|
return sorted(SRC.rglob("*.py"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tripwire
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_no_iso_t_datetime_comparison():
|
|
"""Fail if any src/*.py file compares an ISO-T column against
|
|
datetime('now', ...) with an operator."""
|
|
hits: list[tuple[Path, int, str]] = []
|
|
for fpath in _iter_source_files():
|
|
text = fpath.read_text(encoding="utf-8")
|
|
for lineno, line in enumerate(text.splitlines(), 1):
|
|
if _RE_COL_OP_NOW.search(line) or _RE_NOW_OP_COL.search(line):
|
|
hits.append((fpath, lineno, line.strip()))
|
|
|
|
if hits:
|
|
parts = [_FAIL_MSG, ""]
|
|
for fpath, lineno, line in hits:
|
|
parts.append(f" {fpath}:{lineno} {line}")
|
|
pytest.fail("\n".join(parts))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Positive controls: verify the regexes themselves are correct
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegexPositiveControls:
|
|
"""Regexes must match the bad pattern and reject the safe pattern."""
|
|
|
|
# --- bad pattern (column op datetime) -----------------------------------
|
|
|
|
def test_re_col_op_now_matches_forbidden_pattern(self):
|
|
assert _RE_COL_OP_NOW.search(
|
|
"AND observed_at >= datetime('now', '-24 hours')"
|
|
)
|
|
|
|
def test_re_now_op_col_matches_forbidden_pattern(self):
|
|
assert _RE_NOW_OP_COL.search(
|
|
"AND datetime('now', '-24 hours') <= observed_at"
|
|
)
|
|
|
|
def test_re_col_op_now_matches_gt(self):
|
|
assert _RE_COL_OP_NOW.search("WHERE observed_at > datetime('now')")
|
|
|
|
def test_re_col_op_now_matches_lt(self):
|
|
assert _RE_COL_OP_NOW.search("WHERE ticked_at < datetime('now')")
|
|
|
|
def test_re_now_op_col_matches_ge(self):
|
|
assert _RE_NOW_OP_COL.search(
|
|
"datetime('now') >= opened_at"
|
|
)
|
|
|
|
# --- safe pattern (julianday) -------------------------------------------
|
|
|
|
def test_re_col_op_now_rejects_julianday(self):
|
|
assert not _RE_COL_OP_NOW.search(
|
|
"AND julianday(observed_at) >= julianday('now', '-24 hours')"
|
|
)
|
|
|
|
def test_re_now_op_col_rejects_julianday(self):
|
|
assert not _RE_NOW_OP_COL.search(
|
|
"AND julianday(observed_at) >= julianday('now', '-24 hours')"
|
|
)
|
|
|
|
def test_both_regexes_reject_julianday_all_columns(self):
|
|
"""Spot-check a few column names with julianday wrappers."""
|
|
for col in ("observed_at", "created_at", "updated_at", "last_fired_at",
|
|
"resolved_at", "ticked_at", "opened_at"):
|
|
sql = (
|
|
f"AND julianday({col}) >= "
|
|
f"julianday('now', '-24 hours')"
|
|
)
|
|
assert not _RE_COL_OP_NOW.search(sql), f"col side failed for {col}"
|
|
assert not _RE_NOW_OP_COL.search(sql), f"now side failed for {col}"
|
|
|
|
# --- regression: docstring must not trip the scan -----------------------
|
|
|
|
def test_docstring_does_not_trip(self):
|
|
"""The docstring near dispatcher.py:3187 mentions both names without
|
|
an operator between them."""
|
|
line = "``datetime('now', ...)``. ``observed_at`` is written by"
|
|
assert not _RE_COL_OP_NOW.search(line)
|
|
assert not _RE_NOW_OP_COL.search(line)
|