feat: Windows portability - path separators, LF line endings, UTF-8 decoding and output, msvcrt lock fallback (#152)
CI / verify (push) Successful in 2m42s
CI / pwsh (push) Successful in 1m55s
Release / release (push) Successful in 36s

Files changed:
- .gitattributes
- CHANGES.md
- VERSION
- raw/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/_util.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/eval_cmd.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/index_build.py
- tools/chemenu/commands/lint.py
- tools/chemenu/commands/log_append.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/provenance_cmd.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/upstream_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/commands/work_cmd.py
- tools/chemenu/config.py
- tools/chemenu/corpus_cache.py
- tools/chemenu/filelock.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/kb_scan.py
- tools/chemenu/kb_state.py
- tools/chemenu/lint_core.py
- tools/chemenu/prerequisites.py
- tools/chemenu/provenance.py
- tools/chemenu/search/base.py
- tools/chemenu/search/ripgrep.py
- tools/chemenu/telemetry/writer.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_portability.py
- tools/chemenu/tests/test_search.py
- tools/chemenu/tests/test_trace_ingest.py
- tools/chemenu/type_resolver.py
- tools/chemenu/upload.py
- tools/chemenu/version.py
- tools/run_wikitool.py
- tools/trace_ingest.py
This commit is contained in:
torben committed 2026-10-01 21:15:03 +02:00
1 parent 9d06050338
commit d0f08d1fba
42 files changed
+780 -89

No files matched your search

+8
View File
@@ -41,6 +41,7 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
(root / "EVALS.md").write_text("# EVALS\n", encoding="utf-8")
(root / "CLAUDE.md").write_text("# CLAUDE\n\n@AGENTS.md\n", encoding="utf-8")
(root / ".gitignore").write_text("*.pyc\n", encoding="utf-8")
(root / ".gitattributes").write_text("* text=auto eol=lf\n", encoding="utf-8")
(root / "VERSION").write_text("0.3.1\n", encoding="utf-8")
for name in config.LICENSE_FILES:
@@ -291,6 +292,13 @@ def test_plan_ships_the_environment_template_but_not_the_filled_file(repo):
assert config.ENVIRONMENT_FILE not in plan
def test_plan_ships_the_line_ending_rule(repo):
"""An instance cloned onto Windows with `core.autocrlf=true` needs it as much
as this repository does: without it the sh launcher gets CRLF (Gitea #152)."""
plan = dist_cmd.build_plan()
assert "eol=lf" in plan[".gitattributes"].content
def test_plan_ships_the_claude_harness_shim(repo):
"""Claude Code loads `CLAUDE.md` and not `AGENTS.md`, so a distributed
instance running that harness would start every session without the
+423
View File
@@ -0,0 +1,423 @@
"""Windows portability, held on a Linux CI that never runs Windows (Gitea #152).
Most of what breaks on Windows is code that is right on POSIX by accident: a path
stringified with the native separator, a text file written in the native line ending,
a pipe decoded in the locale's code page, a lock imported from a module Windows does
not have. None of it fails here, so nothing here would notice it coming back.
The guards below therefore read the source rather than run it. Each guard comes with
a test that feeds it the defect it exists for, because a guard that silently matches
nothing passes for the wrong reason. The remaining tests simulate the Windows side of
what can be simulated: a missing `fcntl`, a `cp1252` pipe.
"""
from __future__ import annotations
import ast
import os
import subprocess
import sys
import types
from pathlib import Path
import pytest
from chemenu import filelock
TOOLS_DIR = Path(__file__).resolve().parents[2]
PACKAGE_DIR = TOOLS_DIR / "chemenu"
# Receivers whose `open` is not a text-file open: `os.open` takes flags, and the
# archive modules open binary members.
NOT_TEXT_OPEN = {"os", "tarfile", "zipfile", "gzip"}
# The one module allowed to import the platform lock modules.
LOCK_MODULE = PACKAGE_DIR / "filelock.py"
def _source_files() -> list[Path]:
"""Every shipped Python file: `tools/chemenu` without its tests, and the scripts
beside it in `tools/` - `trace_ingest.py` among them, which a harness runs on
every tool call."""
package = (p for p in PACKAGE_DIR.rglob("*.py") if "tests" not in p.relative_to(PACKAGE_DIR).parts)
return sorted([*package, *TOOLS_DIR.glob("*.py")])
def _call_name(call: ast.Call) -> tuple[str | None, str | None]:
"""(function name, receiver name) - `('open', 'os')` for `os.open(...)`."""
func = call.func
if isinstance(func, ast.Name):
return func.id, None
if isinstance(func, ast.Attribute):
receiver = func.value.id if isinstance(func.value, ast.Name) else None
return func.attr, receiver
return None, None
def _keywords(call: ast.Call) -> set[str | None]:
return {keyword.arg for keyword in call.keywords}
def _open_mode(call: ast.Call, receiver: str | None) -> ast.expr | None:
"""The mode argument of an `open`: second for `open`/`io.open`, first for `Path.open`."""
for keyword in call.keywords:
if keyword.arg == "mode":
return keyword.value
index = 1 if isinstance(call.func, ast.Name) or receiver == "io" else 0
return call.args[index] if len(call.args) > index else None
# --- the four detectors, each a function of source text --------------------------
def stringified_relative_paths(source: str) -> list[int]:
"""Lines that turn a `relative_to` result into a string with the native separator.
`str(p.relative_to(root))` and `f"{p.relative_to(root)}"` both give `kb\\x.md` on
Windows; `.as_posix()` is the portable spelling.
"""
def is_relative_to(node: ast.AST) -> bool:
return (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "relative_to"
)
lines = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Call) and _call_name(node) == ("str", None):
if node.args and is_relative_to(node.args[0]):
lines.append(node.lineno)
if isinstance(node, ast.FormattedValue) and is_relative_to(node.value):
lines.append(node.value.lineno)
return sorted(lines)
def unportable_file_io(source: str) -> list[tuple[int, str]]:
"""Text file access that depends on the platform: a missing `encoding=` on any
text open, read or write, and a missing `newline=` on a write.
Without `encoding=`, Windows reads and writes in the locale's code page; without
`newline=`, a write produces CRLF there.
"""
findings = []
for node in ast.walk(ast.parse(source)):
if not isinstance(node, ast.Call):
continue
name, receiver = _call_name(node)
keywords = _keywords(node)
method = isinstance(node.func, ast.Attribute)
if name == "read_text" and method:
writes = False
elif name == "write_text" and method:
writes = True
elif name == "open" and receiver not in NOT_TEXT_OPEN:
mode = _open_mode(node, receiver)
if mode is None:
writes = False
elif isinstance(mode, ast.Constant) and isinstance(mode.value, str):
if "b" in mode.value:
continue
writes = bool(set(mode.value) & set("wax+"))
else:
writes = True # a mode this scan cannot read is treated as a write
else:
continue
if "encoding" not in keywords:
findings.append((node.lineno, f"{name} without encoding="))
if writes and "newline" not in keywords:
findings.append((node.lineno, f"{name} writes without newline="))
return sorted(findings)
def undecoded_subprocess_text(source: str) -> list[int]:
"""Lines where a subprocess call asks for text but not for an encoding.
`text=True` alone decodes with the locale's code page - cp1252 on Windows - and
`git` and `rg` write UTF-8. Only calls spelled `subprocess.<name>(...)` are read:
`text=` is an ordinary parameter name elsewhere, and every call site in the
package uses that spelling.
"""
lines = []
for node in ast.walk(ast.parse(source)):
if not isinstance(node, ast.Call) or _call_name(node)[1] != "subprocess":
continue
for keyword in node.keywords:
if keyword.arg in ("text", "universal_newlines") and not (
isinstance(keyword.value, ast.Constant) and keyword.value.value is False
):
if "encoding" not in _keywords(node):
lines.append(node.lineno)
return sorted(lines)
def platform_lock_imports(source: str) -> list[int]:
"""Lines importing `fcntl` or `msvcrt` - which only `chemenu.filelock` may do."""
lines = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import) and any(a.name in ("fcntl", "msvcrt") for a in node.names):
lines.append(node.lineno)
if isinstance(node, ast.ImportFrom) and node.module in ("fcntl", "msvcrt"):
lines.append(node.lineno)
return sorted(lines)
def _scan(detector, *, skip: Path | None = None) -> list[str]:
findings = []
for path in _source_files():
if path == skip:
continue
relative = path.relative_to(TOOLS_DIR).as_posix()
for finding in detector(path.read_text(encoding="utf-8")):
findings.append(f"{relative}: {finding}")
return findings
# --- the guards ------------------------------------------------------------------
def test_the_guards_scan_the_whole_package():
files = _source_files()
assert len(files) > 60
assert PACKAGE_DIR / "search" / "ripgrep.py" in files
assert TOOLS_DIR / "trace_ingest.py" in files
assert not any("tests" in p.relative_to(TOOLS_DIR).parts for p in files)
def test_no_relative_path_is_stringified_with_the_native_separator():
assert _scan(stringified_relative_paths) == []
def test_every_text_file_access_names_its_encoding_and_every_write_its_newline():
assert _scan(unportable_file_io) == []
def test_every_text_subprocess_names_its_encoding():
assert _scan(undecoded_subprocess_text) == []
def test_subprocess_is_only_called_through_its_module_name():
"""The spelling `undecoded_subprocess_text` relies on - a `from subprocess
import run` would take its call sites out of the guard's sight."""
def from_imports(source: str) -> list[int]:
return [
node.lineno for node in ast.walk(ast.parse(source))
if isinstance(node, ast.ImportFrom) and node.module == "subprocess"
]
assert _scan(from_imports) == []
def test_only_the_lock_module_imports_a_platform_lock():
assert _scan(platform_lock_imports, skip=LOCK_MODULE) == []
assert platform_lock_imports(LOCK_MODULE.read_text(encoding="utf-8")) != []
# --- each detector catches the defect it exists for ------------------------------
@pytest.mark.parametrize("source", [
"x = str(path.relative_to(root))",
"x = str(Path(p).relative_to(config.ROOT))",
'x = f"{path.relative_to(root)} is missing"',
])
def test_a_stringified_relative_path_is_caught(source):
assert stringified_relative_paths(source) == [1]
def test_a_posix_relative_path_passes():
assert stringified_relative_paths("x = path.relative_to(root).as_posix()") == []
assert stringified_relative_paths("x = path.relative_to(root).parts[0]") == []
@pytest.mark.parametrize("source", [
'path.write_text(text, encoding="utf-8")',
'open(target, "a", encoding="utf-8")',
'path.open("w", encoding="utf-8")',
'open(target, mode="x", encoding="utf-8")',
'open(target, mode, encoding="utf-8")',
])
def test_a_write_without_newline_is_caught(source):
assert unportable_file_io(source) != []
@pytest.mark.parametrize("source", [
"path.read_text()",
"open(target)",
'path.write_text(text, newline="\\n")',
])
def test_a_text_access_without_encoding_is_caught(source):
assert unportable_file_io(source) != []
@pytest.mark.parametrize("source", [
'write_text(path, text)', # a module's own helper of that name, not Path's
'path.write_text(text, encoding="utf-8", newline="\\n")',
'open(target, "a", encoding="utf-8", newline="\\n")',
'path.read_text(encoding="utf-8")',
'open(target, "rb")',
'dest.open("wb")',
"os.open(path, flags)",
'tarfile.open(path, "w:gz")',
])
def test_portable_file_access_passes(source):
assert unportable_file_io(source) == []
def test_text_without_encoding_is_caught_and_with_it_passes():
assert undecoded_subprocess_text("subprocess.run(argv, capture_output=True, text=True)") == [1]
assert undecoded_subprocess_text("subprocess.run(argv, universal_newlines=True)") == [1]
assert undecoded_subprocess_text('subprocess.run(argv, text=True, encoding="utf-8")') == []
assert undecoded_subprocess_text("subprocess.run(argv, capture_output=True)") == []
def test_a_platform_lock_import_is_caught():
assert platform_lock_imports("import fcntl") == [1]
assert platform_lock_imports("def f():\n import msvcrt\n") == [2]
assert platform_lock_imports("from fcntl import flock") == [1]
# --- the lock without fcntl ------------------------------------------------------
class FakeMsvcrt(types.ModuleType):
"""What `msvcrt.locking` is asked to do, and from which position."""
LK_UNLCK, LK_LOCK, LK_NBLCK = 0, 1, 2
def __init__(self, failures: int = 0):
super().__init__("msvcrt")
self.calls: list[tuple[int, int, int]] = []
self.failures = failures
def locking(self, fd: int, mode: int, nbytes: int) -> None:
self.calls.append((mode, os.lseek(fd, 0, os.SEEK_CUR), nbytes))
if mode == self.LK_LOCK and self.failures:
self.failures -= 1
raise OSError(36, "Resource deadlock avoided")
@pytest.fixture
def windows_locking(monkeypatch):
"""Hide `fcntl` the way Windows does and hand `chemenu.filelock` a fake `msvcrt`."""
fake = FakeMsvcrt()
monkeypatch.setitem(sys.modules, "fcntl", None) # `import fcntl` now raises ImportError
monkeypatch.setitem(sys.modules, "msvcrt", fake)
return fake
def test_without_fcntl_the_lock_falls_back_to_msvcrt(tmp_path, windows_locking):
target = tmp_path / "trace.jsonl"
with open(target, "a", encoding="utf-8", newline="\n") as handle:
handle.write("first\n")
handle.flush()
before = os.lseek(handle.fileno(), 0, os.SEEK_CUR)
with filelock.exclusive(handle):
assert windows_locking.calls == [(FakeMsvcrt.LK_LOCK, filelock.WINDOWS_LOCK_OFFSET, 1)]
assert os.lseek(handle.fileno(), 0, os.SEEK_CUR) == before
handle.write("second\n")
assert windows_locking.calls[-1] == (FakeMsvcrt.LK_UNLCK, filelock.WINDOWS_LOCK_OFFSET, 1)
# The lock sits past the data, so the lines themselves are untouched.
assert target.read_text(encoding="utf-8") == "first\nsecond\n"
def test_a_contended_windows_lock_is_waited_for_like_flock(tmp_path, monkeypatch):
fake = FakeMsvcrt(failures=2)
monkeypatch.setitem(sys.modules, "fcntl", None)
monkeypatch.setitem(sys.modules, "msvcrt", fake)
with open(tmp_path / "budget.lock", "a", encoding="utf-8", newline="\n") as handle:
with filelock.exclusive(handle):
pass
modes = [mode for mode, _, _ in fake.calls]
assert modes == [FakeMsvcrt.LK_LOCK] * 3 + [FakeMsvcrt.LK_UNLCK]
def test_the_budget_counter_locks_without_fcntl(tmp_path, monkeypatch, windows_locking):
from chemenu.commands import run_budget
monkeypatch.setattr(run_budget, "STATE_DIR", tmp_path)
monkeypatch.setattr(run_budget, "LOCK_FILE", tmp_path / "budget.lock")
with run_budget._state_lock():
pass
assert [mode for mode, _, _ in windows_locking.calls] == [FakeMsvcrt.LK_LOCK, FakeMsvcrt.LK_UNLCK]
def test_the_telemetry_writer_locks_without_fcntl(tmp_path, windows_locking):
from chemenu.telemetry import writer
target = tmp_path / "trace.jsonl"
with open(target, "a", encoding="utf-8", newline="\n") as handle:
writer._locked_write(handle, '{"event":"x"}\n')
assert [mode for mode, _, _ in windows_locking.calls] == [FakeMsvcrt.LK_LOCK, FakeMsvcrt.LK_UNLCK]
assert target.read_text(encoding="utf-8") == '{"event":"x"}\n'
def test_with_fcntl_the_lock_is_flock(tmp_path):
fcntl = pytest.importorskip("fcntl")
with open(tmp_path / "lock", "a", encoding="utf-8", newline="\n") as handle:
with filelock.exclusive(handle):
with open(tmp_path / "lock", "a", encoding="utf-8", newline="\n") as other:
with pytest.raises(BlockingIOError):
fcntl.flock(other.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
# --- the launcher's own output ---------------------------------------------------
def _launcher_stderr(**env: str) -> bytes:
"""What `run_wikitool.py` writes for an unknown command named `Fußnoten`.
Typer echoes the command name back, which makes it a non-ASCII line that
needs no checkout, no corpus and no preflight.
"""
result = subprocess.run(
[sys.executable, str(TOOLS_DIR / "run_wikitool.py"), "Fußnoten"],
capture_output=True,
env={**os.environ, **env},
)
assert result.returncode != 0
return result.stderr
def test_output_into_a_pipe_is_utf8():
"""Linux and macOS: the bytes are what they always were."""
assert "Fußnoten".encode("utf-8") in _launcher_stderr()
def test_output_into_a_pipe_is_utf8_even_under_a_cp1252_locale():
"""What Windows does to a pipe, simulated: Python would otherwise write `Fu\\xdfnoten`."""
stderr = _launcher_stderr(PYTHONIOENCODING="cp1252")
assert "Fußnoten".encode("utf-8") in stderr
assert "Fußnoten".encode("cp1252") not in stderr
# --- line endings in a checkout --------------------------------------------------
@pytest.mark.parametrize("path, eol", [
("tools/wikitool", "lf"),
("tools/trace-hook", "lf"),
("tools/preflight.sh", "lf"),
("AGENTS.md", "lf"),
])
def test_every_text_file_is_checked_out_with_lf(path, eol):
"""`core.autocrlf=true` would otherwise give the sh launchers `bash\\r`."""
result = subprocess.run(
["git", "check-attr", "text", "eol", "--", path],
cwd=TOOLS_DIR.parent, capture_output=True, text=True, encoding="utf-8",
)
if result.returncode != 0:
pytest.skip("not a git checkout")
assert f"{path}: text: auto" in result.stdout
assert f"{path}: eol: {eol}" in result.stdout
def test_sources_are_kept_byte_for_byte():
"""raw/ is immutable: a CRLF source must not be normalized on `git add`."""
result = subprocess.run(
["git", "check-attr", "text", "--", "raw/articles/x.md", "incoming/x.md"],
cwd=TOOLS_DIR.parent, capture_output=True, text=True, encoding="utf-8",
)
if result.returncode != 0:
pytest.skip("not a git checkout")
assert "raw/articles/x.md: text: unset" in result.stdout
assert "incoming/x.md: text: unset" in result.stdout
+48
View File
@@ -1,3 +1,4 @@
import json
import subprocess
import time
from pathlib import Path
@@ -442,3 +443,50 @@ def test_a_page_with_no_frontmatter_at_all_is_reported(kb_dir, tmp_path):
(kb_dir / "entities" / "Naked.md").write_text("# Naked\n\nProse only.\n", encoding="utf-8")
reported = unreadable_pages(load_pages_by_path(kb_dir, tmp_path))
assert [entry["path"] for entry in reported] == ["kb/entities/Naked.md"]
# What `rg --json --path-separator / Contract kb` printed on the Windows target
# system (Gitea #152, T3): the flag does not reach JSON output.
T3_MATCH = (
r'{"type":"match","data":{"path":{"text":"kb\\comparisons\\COLLECTION.md"},'
r'"lines":{"text":"Contract\n"},"line_number":1,"absolute_offset":0,"submatches":[]}}'
)
def test_a_windows_rg_path_reaches_the_parser_with_forward_slashes(monkeypatch):
data = json.loads(T3_MATCH)["data"]
monkeypatch.setattr(ripgrep, "NATIVE_SEPARATOR", "\\")
assert ripgrep._record_path(data) == "kb/comparisons/COLLECTION.md"
def test_a_posix_rg_path_is_left_as_it_is(monkeypatch):
"""On POSIX a backslash is an ordinary character of a name, not a separator."""
data = json.loads(T3_MATCH)["data"]
monkeypatch.setattr(ripgrep, "NATIVE_SEPARATOR", "/")
assert ripgrep._record_path(data) == "kb\\comparisons\\COLLECTION.md"
@pytest.mark.parametrize("separator, found", [("\\", True), ("/", False)])
def test_a_windows_rg_match_becomes_a_full_hit(monkeypatch, kb_dir, tmp_path, pages, separator, found):
"""The T3 line shape, pointed at a real fixture page: with the Windows separator
the hit carries the repo-relative POSIX key, its title and its frontmatter."""
page = kb_dir / "entities" / "systems" / "aurora.md"
match = json.loads(T3_MATCH)
match["data"]["path"]["text"] = str(page).replace("/", "\\")
match["data"]["lines"]["text"] = "Hosts things.\n"
def fake_run(argv, **kwargs):
return subprocess.CompletedProcess(argv, 0, json.dumps(match) + "\n", "")
monkeypatch.setattr(ripgrep.subprocess, "run", fake_run)
monkeypatch.setattr(ripgrep, "NATIVE_SEPARATOR", separator)
hits = RipgrepBackend(kb_dir, tmp_path).search(SearchQuery(text="Hosts"), pages)
if not found:
assert hits == []
return
[hit] = hits
assert hit.path == "kb/entities/systems/aurora.md"
assert hit.title == "aurora"
assert hit.kind == "entity"
assert hit.collection == "entities"
assert "ZFS" in hit.summary
+18
View File
@@ -5,6 +5,7 @@ change in our normalisation shows up here rather than in a silent gap in a
scored run.
"""
import json
import os
import subprocess
import sys
import tomllib
@@ -145,6 +146,23 @@ def test_malformed_stdin_never_fails_a_tool_call(tmp_path, payload):
assert result.stdout == ""
def test_a_utf8_payload_survives_a_cp1252_locale():
"""What Windows does to `sys.stdin`, simulated: the harness writes UTF-8, and a
locale-decoded read fails outright on a byte cp1252 leaves undefined - `Ł` is
`C5 81` in UTF-8 (Gitea #152). A `ß` alone would not show it: misread on the
way in and miswritten on the way out, it comes back intact."""
payload = {**VIBE_POST_TOOL, "session_id": "v-Łódź-Fußnoten"}
result = subprocess.run(
[sys.executable, str(SCRIPT), "--source", "mistral-vibe", "--dry-run"],
input=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
capture_output=True,
env={**os.environ, "PYTHONIOENCODING": "cp1252"},
)
assert result.returncode == 0, result.stderr
record = json.loads(result.stdout.decode("utf-8"))
assert record["session_id"] == "v-Łódź-Fußnoten"
def test_dry_run_prints_exactly_one_valid_event():
result = subprocess.run(
[sys.executable, str(SCRIPT), "--source", "mistral-vibe", "--dry-run"],