Files
hermes-agent/tests/scripts/test_footgun_encoding_direction.py
ethernet 6e294f543d fix: read source files with encoding=utf-8-sig
Read text files with the encoding utf-8-sig so a BOM at the start of a
file does not cause a Unicode decode error (Windows editors add BOMs).

Reconstructed from ethie/pm commits 48a32b135b + 013219e814 onto the
current upstream/main base: only the utf-8 -> utf-8-sig transforms were
carried (370 exact line pairs across 205 files); pm-rename hunks that
rode in the original commit were left to the pm-store commit, and
utf8sig hunks entangled with content changes ride their owning commit.

Rebuilt on ethie/pm-clean off ac6c8028e0 (upstream/main).
2026-08-29 21:23:00 -04:00

282 lines
11 KiB
Python

"""Tests for the encoding-direction footgun rules in
``scripts/check-windows-footguns.py``.
Two rules enforce the repo encoding policy (reads ``utf-8-sig``, writes
``utf-8``):
- ``read with encoding='utf-8' (BOM-intolerant — use 'utf-8-sig')`` —
Windows tooling (PowerShell Set-Content/Out-File, some editors)
BOM-prefixes files it touches; a plain-utf-8 read hands ``'\\ufeff{...'``
to ``json.load``, which fails with "Expecting value". Live case: PR #3 —
a BOM'd ``install-stamp.json`` made ``read_build_info`` demote the tree
to ``unknown``.
- ``write with encoding='utf-8-sig' (emits a BOM)`` — the inverse: writing
with ``utf-8-sig`` emits the exact bytes the read rule exists to
tolerate.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
LINTER_PATH = REPO_ROOT / "scripts" / "check-windows-footguns.py"
READ_RULE = "read with encoding='utf-8' (BOM-intolerant — use 'utf-8-sig')"
WRITE_RULE = "write with encoding='utf-8-sig' (emits a BOM)"
def _load_linter_module():
"""Import the linter script as a module (it's not a package).
Register the module in sys.modules BEFORE exec_module so that
``@dataclass`` can resolve ``cls.__module__`` (CPython 3.11+).
"""
spec = importlib.util.spec_from_file_location(
"check_windows_footguns", LINTER_PATH
)
mod = importlib.util.module_from_spec(spec)
sys.modules["check_windows_footguns"] = mod
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def linter():
return _load_linter_module()
def _find_footgun(linter, name: str):
for fg in linter.FOOTGUNS:
if fg.name == name:
return fg
pytest.fail(f"Footgun rule '{name}' not found in FOOTGUNS")
def _scan_line(linter, line: str, footgun_name: str) -> bool:
"""Return True if the given line triggers the named footgun rule.
Replicates the relevant checks from scan_file(): suppression marker,
guard hints, comment stripping, then pattern + post_filter — so the
test exercises the real detection path.
"""
fg = _find_footgun(linter, footgun_name)
if linter.SUPPRESS_MARKER.search(line):
return False
if any(hint in line for hint in linter.GUARD_HINTS):
return False
code = linter._strip_code(line)
if not code.strip():
return False
match = fg.pattern.search(code)
if not match:
return False
if fg.post_filter is not None:
try:
if not fg.post_filter(match, line):
return False
except (IndexError, AttributeError):
return False
return True
# ---------------------------------------------------------------------------
# READ rule — plain utf-8 on read-shaped lines SHOULD be flagged
# ---------------------------------------------------------------------------
class TestReadRuleDetection:
def test_flags_read_text_plain_utf8(self, linter):
line = ' data = path.read_text(encoding="utf-8")'
assert _scan_line(linter, line, READ_RULE)
def test_flags_open_read_mode_plain_utf8(self, linter):
line = " with open(path, 'r', encoding='utf-8') as f:"
assert _scan_line(linter, line, READ_RULE)
def test_flags_open_omitted_mode_plain_utf8(self, linter):
# open() defaults to mode 'r' — still a read.
line = " with open(path, encoding='utf-8') as f:"
assert _scan_line(linter, line, READ_RULE)
def test_flags_fdopen_read_mode_plain_utf8(self, linter):
line = " f = os.fdopen(fd, 'r', encoding='utf-8')"
assert _scan_line(linter, line, READ_RULE)
def test_flags_underscore_spelling(self, linter):
line = ' data = path.read_text(encoding="utf_8")'
assert _scan_line(linter, line, READ_RULE)
def test_flags_mode_keyword_read(self, linter):
line = " with open(path, mode='r', encoding='utf-8') as f:"
assert _scan_line(linter, line, READ_RULE)
class TestReadRuleNegatives:
def test_does_not_flag_utf8_sig_read(self, linter):
# The policy-compliant form. The pattern must stop at the closing
# quote of 'utf-8' and never match 'utf-8-sig'.
line = ' data = path.read_text(encoding="utf-8-sig")'
assert not _scan_line(linter, line, READ_RULE)
def test_does_not_flag_write_text_plain_utf8(self, linter):
# Writes with plain utf-8 are the CORRECT form.
line = ' path.write_text(data, encoding="utf-8")'
assert not _scan_line(linter, line, READ_RULE)
def test_does_not_flag_open_write_mode_plain_utf8(self, linter):
line = " with open(path, 'w', encoding='utf-8') as f:"
assert not _scan_line(linter, line, READ_RULE)
def test_does_not_flag_append_mode(self, linter):
line = " with open(path, 'a', encoding='utf-8') as f:"
assert not _scan_line(linter, line, READ_RULE)
def test_does_not_flag_plus_mode(self, linter):
# 'r+' can write — treat as write-capable, not a read.
line = " with open(path, 'r+', encoding='utf-8') as f:"
assert not _scan_line(linter, line, READ_RULE)
def test_does_not_flag_subprocess_encoding(self, linter):
# Not a file read; the rule stays quiet on unclassifiable lines.
line = " subprocess.run(cmd, text=True, encoding='utf-8')"
assert not _scan_line(linter, line, READ_RULE)
def test_does_not_flag_suppressed_line(self, linter):
line = (
" data = path.read_text(encoding='utf-8')"
" # windows-footgun: ok — file is repo-owned, never BOM'd"
)
assert not _scan_line(linter, line, READ_RULE)
def test_does_not_flag_comment_only_line(self, linter):
line = " # use path.read_text(encoding='utf-8') here"
assert not _scan_line(linter, line, READ_RULE)
# ---------------------------------------------------------------------------
# WRITE rule — utf-8-sig on write-shaped lines SHOULD be flagged
# ---------------------------------------------------------------------------
class TestWriteRuleDetection:
def test_flags_write_text_utf8_sig(self, linter):
line = ' path.write_text(data, encoding="utf-8-sig")'
assert _scan_line(linter, line, WRITE_RULE)
def test_flags_open_write_mode_utf8_sig(self, linter):
line = " with open(path, 'w', encoding='utf-8-sig') as f:"
assert _scan_line(linter, line, WRITE_RULE)
def test_flags_append_mode_utf8_sig(self, linter):
line = " with open(path, 'a', encoding='utf-8-sig') as f:"
assert _scan_line(linter, line, WRITE_RULE)
def test_flags_fdopen_write_mode_utf8_sig(self, linter):
line = " f = os.fdopen(fd, 'w', encoding='utf-8-sig')"
assert _scan_line(linter, line, WRITE_RULE)
def test_flags_path_open_append_utf8_sig(self, linter):
# Path.open puts the mode in the FIRST argument — the original
# classifier only understood the builtin second-arg shape, so the
# live sweep rewrote 18 append/write sites to utf-8-sig (BOM per
# append in jsonl logs; caught by test_lifecycle_ledger).
line = ' with path.open("a", encoding="utf-8-sig") as fh:'
assert _scan_line(linter, line, WRITE_RULE)
def test_flags_path_open_plus_mode_utf8_sig(self, linter):
# r+/a+ can write at position 0 — write-shaped.
line = ' lock = path.open("a+", encoding="utf-8-sig")'
assert _scan_line(linter, line, WRITE_RULE)
def test_flags_underscore_spelling(self, linter):
line = ' path.write_text(data, encoding="utf_8_sig")'
assert _scan_line(linter, line, WRITE_RULE)
class TestWriteRuleNegatives:
def test_does_not_flag_utf8_sig_read(self, linter):
# Reads with utf-8-sig are the CORRECT form.
line = ' data = path.read_text(encoding="utf-8-sig")'
assert not _scan_line(linter, line, WRITE_RULE)
def test_does_not_flag_open_read_mode_utf8_sig(self, linter):
line = " with open(path, 'r', encoding='utf-8-sig') as f:"
assert not _scan_line(linter, line, WRITE_RULE)
def test_does_not_flag_open_omitted_mode_utf8_sig(self, linter):
# Omitted mode defaults to 'r' — a read.
line = " with open(path, encoding='utf-8-sig') as f:"
assert not _scan_line(linter, line, WRITE_RULE)
def test_does_not_flag_plain_utf8_write(self, linter):
line = ' path.write_text(data, encoding="utf-8")'
assert not _scan_line(linter, line, WRITE_RULE)
def test_does_not_flag_unclassifiable_line(self, linter):
# No file-opening call — conservative default is no flag.
line = " codec = 'utf-8-sig'"
assert not _scan_line(linter, line, WRITE_RULE)
def test_does_not_flag_nested_call_first_arg(self, linter):
# Regression: the first live sweep false-positived this line from
# _startup_fast.py — the old mode regex swallowed the nested
# ``join(`` paren and captured "install-stamp.json" as the mode
# (its 'a' made it look write-shaped). Omitted mode = read.
line = (
' with open(os.path.join(root, "install-stamp.json"), '
'encoding="utf-8-sig") as handle:'
)
assert not _scan_line(linter, line, WRITE_RULE)
def test_does_not_flag_path_open_read_mode_utf8_sig(self, linter):
line = ' with path.open("r", encoding="utf-8-sig") as fh:'
assert not _scan_line(linter, line, WRITE_RULE)
def test_read_rule_does_not_rewrite_path_open_append(self, linter):
# The inverse guard on the READ rule: an append-mode Path.open with
# plain utf-8 is CORRECT and must never be flagged for conversion.
line = ' with path.open("a", encoding="utf-8") as fh:'
assert not _scan_line(linter, line, READ_RULE)
def test_read_rule_skips_method_open_without_mode(self, linter):
# `.open(` with no recognizable mode is unclassified — the sweep
# must never rewrite on a guess about the receiver.
line = ' with something.open(encoding="utf-8") as fh:'
assert not _scan_line(linter, line, READ_RULE)
def test_does_not_flag_suppressed_line(self, linter):
line = (
" path.write_text(data, encoding='utf-8-sig')"
" # windows-footgun: ok — downstream consumer requires a BOM"
)
assert not _scan_line(linter, line, WRITE_RULE)
# ---------------------------------------------------------------------------
# Suppression marker cannot be a bare comment mention (the real invariant —
# a comment that merely NAMES the marker text elsewhere must not exempt).
# ---------------------------------------------------------------------------
class TestShapeHelpers:
def test_read_shaped_read_text(self, linter):
assert linter._is_read_shaped('x = p.read_text(encoding="utf-8")')
def test_read_shaped_rejects_write_text(self, linter):
assert not linter._is_read_shaped('p.write_text(d, encoding="utf-8")')
def test_write_shaped_x_mode(self, linter):
assert linter._is_write_shaped("open(p, 'x', encoding='utf-8-sig')")
def test_write_shaped_rejects_no_call(self, linter):
assert not linter._is_write_shaped("enc = 'utf-8-sig'")
def test_read_shaped_rejects_no_call(self, linter):
# No open/fdopen/read_text on the line at all.
assert not linter._is_read_shaped("enc = 'utf-8'")