mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
fix(windows): sweep remaining bare read_text/write_text sites + linter rule
AST-driven pass over every Path.read_text()/write_text() without an explicit encoding= across non-test code: 71 sites in 34 files (skills_hub, hermes_cli/main+profiles+service_manager+container_boot, mem0/hindsight/honcho plugins, achievements dashboard, release/CI scripts, productivity+comfyui skill helpers, agent/*). Verified zero positional-encoding collisions before insertion; per-file compile() check after. Adds a check-windows-footguns rule flagging bare single-line read_text/write_text (multi-line forms stay covered by the AST guard test from #38985). Together with the salvaged contributor commits this retires the ~169-site bare file-I/O class (#37423's long tail).
This commit is contained in:
parent
adecb0d1a9
commit
75e0d52034
36 changed files with 103 additions and 75 deletions
|
|
@ -373,6 +373,34 @@ FOOTGUNS: list[Footgun] = [
|
|||
and _is_likely_subprocess_call(line)
|
||||
),
|
||||
),
|
||||
Footgun(
|
||||
name="bare Path.read_text()/write_text() without encoding=",
|
||||
# Match ``.read_text(`` / ``.write_text(`` when the same line does
|
||||
# not pass ``encoding=``. Multi-line calls where encoding= sits on
|
||||
# a later line are handled by the post_filter's lookahead-free
|
||||
# heuristic accepting a small false-negative rate — the AST guard
|
||||
# test in tests/gateway/test_gateway_utf8_encoding.py catches the
|
||||
# gateway/adapters exactly, and this rule catches the common
|
||||
# single-line form everywhere else.
|
||||
pattern=re.compile(r"\.(read_text|write_text)\s*\("),
|
||||
message=(
|
||||
"Path.read_text()/write_text() without encoding= uses "
|
||||
"locale.getpreferredencoding() — cp936/cp1252 on Windows — "
|
||||
"so UTF-8 content (config JSON, session state, skills) "
|
||||
"crashes with UnicodeDecodeError or writes mojibake. "
|
||||
"See issue #37423 and the #71014 / read_text campaign."
|
||||
),
|
||||
fix='path.read_text(encoding="utf-8") / path.write_text(data, encoding="utf-8")',
|
||||
post_filter=lambda m, line: (
|
||||
"encoding=" not in line
|
||||
and "encoding =" not in line
|
||||
and not _looks_like_string_literal(line, m)
|
||||
# Skip calls that continue onto the next line — the closing
|
||||
# paren isn't on this line, so encoding= may follow. AST-level
|
||||
# enforcement for those lives in the gateway guard test.
|
||||
and line.rstrip().endswith(")")
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ def main() -> int:
|
|||
if any(skip.rstrip("/") in parts for skip in SKIP_DIRS):
|
||||
continue
|
||||
|
||||
content = py_file.read_text()
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
violations = find_subprocess_calls(content, rel)
|
||||
all_violations.extend(violations)
|
||||
|
||||
|
|
@ -205,7 +205,7 @@ def main() -> int:
|
|||
continue
|
||||
|
||||
try:
|
||||
content = py_file.read_text()
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
violations = find_subprocess_calls(content, rel)
|
||||
|
|
|
|||
|
|
@ -419,7 +419,7 @@ def main() -> int:
|
|||
pending_jobs=pending,
|
||||
)
|
||||
|
||||
args.output.write_text(body)
|
||||
args.output.write_text(body, encoding="utf-8")
|
||||
print(f"Wrote {len(body)} chars to {args.output}")
|
||||
return 0
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ def _load_json(path: Path | None) -> list[dict]:
|
|||
if path is None or not path.exists() or path.stat().st_size == 0:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
|
||||
return []
|
||||
|
|
@ -197,7 +197,7 @@ def main() -> int:
|
|||
|
||||
summary = "\n".join(buf)
|
||||
if args.output:
|
||||
args.output.write_text(summary)
|
||||
args.output.write_text(summary, encoding="utf-8")
|
||||
else:
|
||||
print(summary)
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ def main() -> int:
|
|||
if not path.exists():
|
||||
print(f"\n⚠ no baseline at {path} — run with --save {args.compare} first")
|
||||
else:
|
||||
before = json.loads(path.read_text())
|
||||
before = json.loads(path.read_text(encoding="utf-8"))
|
||||
print(f"\n═══ A/B diff vs /tmp/perf-{args.compare}.json ═══")
|
||||
print(format_diff(before, metrics))
|
||||
|
||||
|
|
|
|||
|
|
@ -2138,7 +2138,7 @@ def next_available_tag(base_tag: str) -> tuple[str, str]:
|
|||
|
||||
def get_current_version():
|
||||
"""Read current semver from __init__.py."""
|
||||
content = VERSION_FILE.read_text()
|
||||
content = VERSION_FILE.read_text(encoding="utf-8")
|
||||
match = re.search(r'__version__\s*=\s*"([^"]+)"', content)
|
||||
return match.group(1) if match else "0.0.0"
|
||||
|
||||
|
|
@ -2168,7 +2168,7 @@ def bump_version(current: str, part: str) -> str:
|
|||
def update_version_files(semver: str, calver_date: str):
|
||||
"""Update version strings in source files."""
|
||||
# Update __init__.py
|
||||
content = VERSION_FILE.read_text()
|
||||
content = VERSION_FILE.read_text(encoding="utf-8")
|
||||
content = re.sub(
|
||||
r'__version__\s*=\s*"[^"]+"',
|
||||
f'__version__ = "{semver}"',
|
||||
|
|
@ -2179,17 +2179,17 @@ def update_version_files(semver: str, calver_date: str):
|
|||
f'__release_date__ = "{calver_date}"',
|
||||
content,
|
||||
)
|
||||
VERSION_FILE.write_text(content)
|
||||
VERSION_FILE.write_text(content, encoding="utf-8")
|
||||
|
||||
# Update pyproject.toml
|
||||
pyproject = PYPROJECT_FILE.read_text()
|
||||
pyproject = PYPROJECT_FILE.read_text(encoding="utf-8")
|
||||
pyproject = re.sub(
|
||||
r'^version\s*=\s*"[^"]+"',
|
||||
f'version = "{semver}"',
|
||||
pyproject,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
PYPROJECT_FILE.write_text(pyproject)
|
||||
PYPROJECT_FILE.write_text(pyproject, encoding="utf-8")
|
||||
|
||||
# Keep the desktop Electron app's package.json version in lockstep with the
|
||||
# Python package version. The desktop About panel reads the live Hermes
|
||||
|
|
|
|||
|
|
@ -533,7 +533,7 @@ def _load_durations(repo_root: Path) -> dict[str, float]:
|
|||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print("[ERROR] Failed to load json durations file! {e}")
|
||||
return {}
|
||||
|
|
@ -555,7 +555,7 @@ def _save_durations(
|
|||
key = _format_file(f, repo_root)
|
||||
data[key] = round(t, 3)
|
||||
path = repo_root / _DURATIONS_FILE
|
||||
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
||||
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _compute_lpt_slices(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue