From 349a5b682252f32cda58cefa92924161288a4a0c Mon Sep 17 00:00:00 2001 From: cdeust Date: Fri, 26 Jun 2026 12:53:07 +0200 Subject: [PATCH 1/2] fix(windows): port cross-platform compatibility fixes to main Add shared/platform.py abstraction (home_dir, python_executable, to_posix, IS_WINDOWS) and route install lock (fcntl/msvcrt), pipeline discovery (exec-bit bypass + .exe), doctor, and wiki path handling through it. Add Windows event-loop policy in conftest, .gitattributes (eol=lf), and a test-windows CI job (SQLite backend). New unit tests for platform, staleness drive-absolute regex, and install lock. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitattributes | 23 ++++++ .github/workflows/ci.yml | 56 +++++++++++++++ mcp_server/core/staleness.py | 21 ++++++ mcp_server/core/wiki_coverage.py | 6 +- mcp_server/core/wiki_drift.py | 6 +- mcp_server/doctor.py | 6 +- mcp_server/doctor_mcp.py | 9 ++- .../infrastructure/pipeline_discovery.py | 23 ++++-- .../infrastructure/pipeline_install_lock.py | 70 ++++++++++++++----- mcp_server/shared/platform.py | 63 +++++++++++++++++ tests_py/conftest.py | 9 +++ tests_py/core/test_staleness.py | 38 ++++++++++ .../test_pipeline_install_lock.py | 45 ++++++++++++ tests_py/shared/test_platform.py | 43 ++++++++++++ 14 files changed, 389 insertions(+), 29 deletions(-) create mode 100644 .gitattributes create mode 100644 mcp_server/shared/platform.py create mode 100644 tests_py/core/test_staleness.py create mode 100644 tests_py/infrastructure/test_pipeline_install_lock.py create mode 100644 tests_py/shared/test_platform.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3eba3c31 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +# Normalize line endings. Shell scripts and Python with CRLF break on +# checkout under Git Bash (bad interpreter) and trip py_compile diffs. +# source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §7.2 +* text=auto eol=lf + +*.sh text eol=lf +*.py text eol=lf +*.toml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.md text eol=lf + +# Binary assets — never normalize. +*.png binary +*.jpg binary +*.gif binary +*.pdf binary +*.onnx binary +*.so binary +*.dylib binary +*.dll binary +*.exe binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cad508a..a98dc5c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,62 @@ jobs: # on SQLite is tracked separately (full-parity effort). run: pytest tests_py/infrastructure/test_sqlite_backend.py --tb=short -q + test-windows: + name: Test (Windows, SQLite backend) + runs-on: windows-latest + + # Real NT proof for the cross-platform fixes (fcntl→msvcrt, sys.executable, + # NTFS exec bits, $HOME override, path separators). We use the SQLite + # fallback so no PostgreSQL has to be provisioned on the Windows runner — + # the conftest selects it when PG is unreachable; the env var makes it + # explicit. source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §10 (CI Windows) + env: + CORTEX_MEMORY_STORE_BACKEND: sqlite + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Cache HuggingFace models + uses: actions/cache@v4 + with: + path: ~/.cache/huggingface + key: ${{ runner.os }}-hf-all-MiniLM-L6-v2 + + - name: Install dependencies (no postgresql extra) + run: pip install -e ".[dev,sqlite]" + + # Import smoke: the modules that previously crashed at load on Windows + # (fcntl import) or silently misbehaved. If any fails to import, the + # platform branches are wrong — fail fast before the suite. + - name: Import smoke (formerly Windows-broken modules) + run: >- + python -c "import mcp_server.shared.platform, + mcp_server.infrastructure.pipeline_install_lock, + mcp_server.infrastructure.pipeline_discovery, + mcp_server.core.staleness, mcp_server.doctor, mcp_server.doctor_mcp; + print('windows import smoke OK')" + + - name: Pre-download embedding model + run: python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2', device='cpu')" + continue-on-error: true + + # Scope (explicit, not silent): the portability tests plus the modules + # carrying Windows-specific branches and the SQLite backend suite. The + # full PG suite is not run here — it is covered by the ubuntu `test` job. + - name: Run portability + backend tests + run: >- + pytest --tb=short -q + tests_py/shared/test_platform.py + tests_py/infrastructure/test_pipeline_discovery.py + tests_py/infrastructure/test_pipeline_install_lock.py + tests_py/core/test_staleness.py + tests_py/infrastructure/test_sqlite_backend.py + lint: name: Lint runs-on: ubuntu-latest diff --git a/mcp_server/core/staleness.py b/mcp_server/core/staleness.py index 1b0f2c0f..baf2b660 100644 --- a/mcp_server/core/staleness.py +++ b/mcp_server/core/staleness.py @@ -29,6 +29,19 @@ re.MULTILINE, ) +# Matches backslash-separated paths — Windows relative (``src\core\x.py``) +# and drive-absolute (``C:\Users\me\x.py``). Without this, refs stored with +# backslashes are invisible to staleness detection on Windows. +# source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.6 +_WIN_PATH_RE = re.compile( + r"(?:^|[\s\"'`(,])(" + r"(?:[A-Za-z]:\\?)?" # optional drive letter, consuming its backslash + r"(?:[\w@.-]+\\)+" # one or more backslash-separated segments + r"[\w@.-]+\.\w{1,10}" # filename.ext + r")(?:[\s\"'`:),]|$)", + re.MULTILINE, +) + # Matches imports/requires that imply a local path _IMPORT_PATH_RE = re.compile( r"""(?:import|from|require)\s+['"]([./][^'"]+)['"]""", @@ -58,6 +71,14 @@ def extract_file_references(content: str) -> list[str]: if path and not _EXCLUDE_RE.search(path) and len(path) < 256: refs.add(path) + # Backslash paths are normalized to '/' so a single ref is stored + # regardless of the separator the author used; resolution works with + # forward slashes on every OS. + for m in _WIN_PATH_RE.finditer(content): + path = m.group(1).strip().replace("\\", "/") + if path and not _EXCLUDE_RE.search(path) and len(path) < 256: + refs.add(path) + for m in _IMPORT_PATH_RE.finditer(content): path = m.group(1).strip() if path and len(path) < 256: diff --git a/mcp_server/core/wiki_coverage.py b/mcp_server/core/wiki_coverage.py index 158adc14..2cbcbbde 100644 --- a/mcp_server/core/wiki_coverage.py +++ b/mcp_server/core/wiki_coverage.py @@ -56,6 +56,8 @@ from dataclasses import dataclass, field from typing import Final +from mcp_server.shared.platform import to_posix + # Minimum useful page size in bytes. Below this, a page is a stub — # the scope is not really covered. @@ -1266,7 +1268,9 @@ def list_source_files(root: str) -> list[str]: if ext not in _SOURCE_EXTENSIONS: continue full = os.path.join(dirpath, f) - rel = os.path.relpath(full, root) + # relpath emits backslashes on Windows; downstream indexing and + # comparisons assume '/'. source: REPORT_..._CORTEX_WINDOWS §5.3 + rel = to_posix(os.path.relpath(full, root)) out.append(rel) return out diff --git a/mcp_server/core/wiki_drift.py b/mcp_server/core/wiki_drift.py index 751bc496..d83457a9 100644 --- a/mcp_server/core/wiki_drift.py +++ b/mcp_server/core/wiki_drift.py @@ -33,6 +33,8 @@ from dataclasses import dataclass, field from typing import Final +from mcp_server.shared.platform import to_posix + @dataclass class PageDrift: @@ -340,7 +342,9 @@ def audit_wiki_drift( if not f.endswith(".md"): continue full = os.path.join(dirpath, f) - rel = os.path.relpath(full, wiki_root) + # Normalize to '/' so _kind_and_domain_from_path's path parsing + # works on Windows. source: REPORT_..._CORTEX_WINDOWS §5.3 + rel = to_posix(os.path.relpath(full, wiki_root)) kind, domain = _kind_and_domain_from_path(rel) if not domain or not kind: continue diff --git a/mcp_server/doctor.py b/mcp_server/doctor.py index 23aa750a..694b44db 100644 --- a/mcp_server/doctor.py +++ b/mcp_server/doctor.py @@ -28,6 +28,8 @@ from pathlib import Path from typing import Callable +from mcp_server.shared.platform import home_dir + class Check: __slots__ = ("name", "ok", "detail", "fix", "optional") @@ -165,7 +167,9 @@ def _pg_extensions() -> Check: def _methodology_dir() -> Check: - path = Path("~/.claude/methodology").expanduser() + # home_dir() honors $HOME on every OS; Path.expanduser() ignores it on + # Windows. source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.1 + path = home_dir() / ".claude" / "methodology" try: path.mkdir(parents=True, exist_ok=True) probe = path / ".write_probe" diff --git a/mcp_server/doctor_mcp.py b/mcp_server/doctor_mcp.py index eea3e13e..67cb9904 100644 --- a/mcp_server/doctor_mcp.py +++ b/mcp_server/doctor_mcp.py @@ -52,6 +52,7 @@ from dataclasses import asdict, dataclass, field from pathlib import Path +from mcp_server.shared.platform import home_dir, python_executable from mcp_server.shared.redaction import redact_url, scrub_secrets @@ -148,7 +149,7 @@ def _check_python_interpreter() -> McpCheck: def _installed_plugins_path() -> Path: - return Path.home() / ".claude" / "plugins" / "installed_plugins.json" + return home_dir() / ".claude" / "plugins" / "installed_plugins.json" def _check_installed_plugins_json() -> tuple[McpCheck, dict | None]: @@ -362,7 +363,11 @@ def _check_launcher_smoke(install_path: str | None) -> McpCheck: detail="launcher.py missing", attempted=str(launcher), ) - py = shutil.which("python3") or shutil.which("python") or sys.executable + # Never resolve "python3"/"python" by name: on Windows PATH those hit the + # Microsoft Store stub (exit 9009, no interpreter), making this smoke test + # spuriously fail. The launcher must run under THIS interpreter anyway. + # source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.2 + py = python_executable() cmd = [py, str(launcher)] attempted = " ".join(cmd) try: diff --git a/mcp_server/infrastructure/pipeline_discovery.py b/mcp_server/infrastructure/pipeline_discovery.py index c7ecd2a5..51160ff6 100644 --- a/mcp_server/infrastructure/pipeline_discovery.py +++ b/mcp_server/infrastructure/pipeline_discovery.py @@ -30,11 +30,13 @@ import logging import os import shutil +import sys from pathlib import Path from typing import Optional from mcp_server.infrastructure.config import MCP_CONNECTIONS_PATH from mcp_server.infrastructure.file_io import read_json, write_json +from mcp_server.shared.platform import home_dir logger = logging.getLogger(__name__) @@ -54,7 +56,13 @@ "../ai-automatised-pipeline", ) -_BUILT_RELATIVE = ("target/release/automatised-pipeline",) +# Windows builds emit an .exe; list it first so it wins on NT. The +# extension-less name is the Linux/macOS artifact. +# source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.5 +_BUILT_RELATIVE = ( + "target/release/automatised-pipeline.exe", + "target/release/automatised-pipeline", +) # ── Marketplace install (canonical) ───────────────────────────────────── @@ -62,7 +70,7 @@ # own .mcp.json resolves from: installed_plugins.json -> installPath -> # target/release/automatised-pipeline. Preferring it means Cortex spawns the # exact plugin the user installed — no self-built symlink, no version drift. -_INSTALLED_PLUGINS_PATH = Path.home() / ".claude" / "plugins" / "installed_plugins.json" +_INSTALLED_PLUGINS_PATH = home_dir() / ".claude" / "plugins" / "installed_plugins.json" _AP_PLUGIN_KEY = "automatised-pipeline@automatised-pipeline-marketplace" _AP_BINARY_RELATIVE = Path("target") / "release" / "automatised-pipeline" @@ -95,9 +103,9 @@ def _marketplace_pipeline_binary() -> Optional[str]: # Where the silent installer clones and builds the upstream source. # Living next to other methodology artefacts means cleanup is one rm -rf. _INSTALL_SRC_DIR = ( - Path.home() / ".claude" / "methodology" / "src" / "automatised-pipeline" + home_dir() / ".claude" / "methodology" / "src" / "automatised-pipeline" ) -_INSTALL_BIN_DIR = Path.home() / ".claude" / "methodology" / "bin" +_INSTALL_BIN_DIR = home_dir() / ".claude" / "methodology" / "bin" _INSTALL_SYMLINK = _INSTALL_BIN_DIR / "mcp-server" @@ -134,7 +142,12 @@ def discover_pipeline_command() -> Optional[list[str]]: continue for rel in _BUILT_RELATIVE: built = root / rel - if built.is_file() and built.stat().st_mode & 0o111: + # NTFS doesn't store Unix exec bits — st_mode & 0o111 is always 0 + # on Windows, so the bit check would reject every valid binary. + # source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.5 + if built.is_file() and ( + sys.platform == "win32" or built.stat().st_mode & 0o111 + ): return [str(built)] return None diff --git a/mcp_server/infrastructure/pipeline_install_lock.py b/mcp_server/infrastructure/pipeline_install_lock.py index 98515cb5..35e48123 100644 --- a/mcp_server/infrastructure/pipeline_install_lock.py +++ b/mcp_server/infrastructure/pipeline_install_lock.py @@ -4,20 +4,28 @@ the user's manual setup) from corrupting the shared install state: half-cloned src/, racy symlink swap, JSON config truncation. -Uses fcntl.flock (POSIX advisory lock). Non-blocking acquire — contended -runs return immediately so callers can surface a clear ``install_in_progress`` -action rather than hanging the user's terminal for 6 minutes. +Non-blocking acquire — contended runs return immediately so callers can +surface a clear ``install_in_progress`` action rather than hanging the +user's terminal for 6 minutes. + +POSIX uses ``fcntl.flock`` (advisory lock). Windows has no ``fcntl`` — its +import alone would crash this module at load on every Windows install — so +we use ``msvcrt.locking`` (mandatory byte-range lock) there. Both back the +same ``install_lock()`` contract; the only visible difference (advisory vs +mandatory) is immaterial because every caller goes through this function. + +source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.4 """ from __future__ import annotations -import fcntl import os from contextlib import contextmanager -from pathlib import Path from typing import Iterator -_LOCK_FILE = Path.home() / ".claude" / "methodology" / ".install.lock" +from mcp_server.shared.platform import IS_WINDOWS, home_dir + +_LOCK_FILE = home_dir() / ".claude" / "methodology" / ".install.lock" class InstallLockBusy(RuntimeError): @@ -32,28 +40,52 @@ def install_lock() -> Iterator[None]: return a structured ``install_in_progress`` action instead of blocking for the duration of someone else's 6-minute build. - The lock file is opened in append mode so concurrent acquires don't - truncate it; we never read or write content — only the lock metadata - matters. The fd stays open for the duration of the context to keep - the lock held. + We never read or write content — only the lock metadata matters. The fd + stays open for the duration of the context to keep the lock held. """ _LOCK_FILE.parent.mkdir(parents=True, exist_ok=True) fd = os.open(str(_LOCK_FILE), os.O_RDWR | os.O_CREAT, 0o644) try: - try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError as exc: - os.close(fd) - raise InstallLockBusy(str(_LOCK_FILE)) from exc + _acquire(fd) try: yield finally: - try: - fcntl.flock(fd, fcntl.LOCK_UN) - except Exception: - pass + _release(fd) finally: try: os.close(fd) except Exception: pass + + +if IS_WINDOWS: + import msvcrt + + def _acquire(fd: int) -> None: + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + except OSError as exc: + os.close(fd) + raise InstallLockBusy(str(_LOCK_FILE)) from exc + + def _release(fd: int) -> None: + try: + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + except Exception: + pass + +else: + import fcntl + + def _acquire(fd: int) -> None: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + os.close(fd) + raise InstallLockBusy(str(_LOCK_FILE)) from exc + + def _release(fd: int) -> None: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + except Exception: + pass diff --git a/mcp_server/shared/platform.py b/mcp_server/shared/platform.py new file mode 100644 index 00000000..f3999a5b --- /dev/null +++ b/mcp_server/shared/platform.py @@ -0,0 +1,63 @@ +"""Cross-platform primitives. shared/ → Python stdlib only. + +Centralizes the three portability hazards that silently broke Cortex on +Windows (each previously open-coded at several call sites): + + 1. ``python3`` on the Windows PATH resolves to the Microsoft Store stub — + it does not run an interpreter, it prints a message and exits 9009. + Shelling out to a Python interpreter by name is therefore unsafe. + 2. ``Path.home()`` and ``Path.expanduser()`` consult USERPROFILE / + HOMEDRIVE+HOMEPATH on Windows and silently ignore ``$HOME``. Tests that + ``monkeypatch.setenv("HOME", ...)`` and admin setups that point HOME at + a network share both observe the wrong directory as a result. + 3. ``str(Path(...))`` and ``os.path.relpath`` emit backslash separators on + Windows, which fail regex matches and string comparisons authored for + forward slashes. + +These are one-liners, but they were duplicated and each duplication was a +fresh place to forget the Windows branch. Three+ call sites each → extract +(coding-standards §3.3). + +source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.1, §5.2, §5.3 +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Union + +IS_WINDOWS = sys.platform == "win32" + + +def home_dir() -> Path: + """Home directory, honoring an explicit ``$HOME`` override on every OS. + + ``Path.home()`` ignores ``$HOME`` on Windows. We prefer ``$HOME`` when it + is set so test fixtures and admin overrides behave identically across + platforms, and fall back to the OS default otherwise. + """ + override = os.environ.get("HOME") + return Path(override) if override else Path.home() + + +def python_executable() -> str: + """Absolute path to the interpreter currently executing. + + Always use this instead of ``shutil.which("python3")`` / + ``shutil.which("python")`` when spawning a Python subprocess: on Windows + those resolve to the Microsoft Store stub before the real interpreter. + ``sys.executable`` is, by definition, the interpreter running this code. + """ + return sys.executable + + +def to_posix(path: Union[str, os.PathLike]) -> str: + """Render a path with forward slashes regardless of host OS separator. + + Use whenever a path is stringified for storage, comparison, or regex + matching. On POSIX this is a no-op; on Windows it rewrites ``\\`` → ``/``. + """ + text = os.fspath(path) + return text.replace(os.sep, "/") if os.sep != "/" else text diff --git a/tests_py/conftest.py b/tests_py/conftest.py index b0a9955b..4084c953 100644 --- a/tests_py/conftest.py +++ b/tests_py/conftest.py @@ -5,12 +5,21 @@ with per-test isolation via temporary DB files. """ +import asyncio import importlib import os +import sys import tempfile import pytest +# On Windows asyncio defaults to ProactorEventLoop, whose GC-time teardown +# emits a noisy "Event loop is closed" PytestUnraisableExceptionWarning that +# can mask real errors. SelectorEventLoop tears down cleanly and matches the +# POSIX default. source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §6.5 +if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + # ── Resolve test database URL ───────────────────────────────────────────── _CURRENT_URL = os.environ.get("DATABASE_URL", "") diff --git a/tests_py/core/test_staleness.py b/tests_py/core/test_staleness.py new file mode 100644 index 00000000..7928e173 --- /dev/null +++ b/tests_py/core/test_staleness.py @@ -0,0 +1,38 @@ +"""Tests for staleness file-reference extraction, incl. Windows paths. + +source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.6 +""" + +from __future__ import annotations + +from mcp_server.core.staleness import extract_file_references + + +def test_extracts_unix_relative_path(): + refs = extract_file_references("see src/core/staleness.py for details") + assert "src/core/staleness.py" in refs + + +def test_extracts_windows_relative_backslash_path(): + refs = extract_file_references("see src\\core\\staleness.py for details") + # Normalized to forward slashes regardless of separator used. + assert "src/core/staleness.py" in refs + + +def test_extracts_windows_drive_absolute_path(): + refs = extract_file_references( + "memory references C:\\Users\\me\\proj\\app.py which is gone" + ) + assert "C:/Users/me/proj/app.py" in refs + + +def test_backslash_and_forward_slash_dedupe_to_one_ref(): + content = "src/a.py and src\\a.py are the same file" + refs = extract_file_references(content) + assert refs.count("src/a.py") == 1 + assert "src\\a.py" not in refs + + +def test_excludes_urls(): + refs = extract_file_references("docs at https://example.com/page.html") + assert not any("example.com" in r for r in refs) diff --git a/tests_py/infrastructure/test_pipeline_install_lock.py b/tests_py/infrastructure/test_pipeline_install_lock.py new file mode 100644 index 00000000..e29ac58c --- /dev/null +++ b/tests_py/infrastructure/test_pipeline_install_lock.py @@ -0,0 +1,45 @@ +"""Tests for the cross-platform install lock (fcntl on POSIX, msvcrt on NT). + +The import itself is the first assertion: on Windows the previous bare +``import fcntl`` crashed this module at load. The behavioral tests confirm +acquire/release and contention through the same ``install_lock()`` contract. + +source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.4 +""" + +from __future__ import annotations + +import sys + +import pytest + +from mcp_server.infrastructure import pipeline_install_lock as mod +from mcp_server.infrastructure.pipeline_install_lock import ( + InstallLockBusy, + install_lock, +) + + +@pytest.fixture +def lock_in_tmp(monkeypatch, tmp_path): + monkeypatch.setattr(mod, "_LOCK_FILE", tmp_path / ".install.lock") + return tmp_path / ".install.lock" + + +def test_acquire_and_release_creates_lock_file(lock_in_tmp): + with install_lock(): + assert lock_in_tmp.exists() + # Re-acquire after release must succeed (lock was freed). + with install_lock(): + pass + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="POSIX flock contention semantics; NT mandatory locks differ", +) +def test_contended_acquire_raises_busy(lock_in_tmp): + with install_lock(): + with pytest.raises(InstallLockBusy): + with install_lock(): + pass diff --git a/tests_py/shared/test_platform.py b/tests_py/shared/test_platform.py new file mode 100644 index 00000000..999cab86 --- /dev/null +++ b/tests_py/shared/test_platform.py @@ -0,0 +1,43 @@ +"""Tests for shared.platform cross-platform primitives. + +These run on any host: the OS-specific behavior is exercised by mocking +``os.sep`` and ``$HOME`` rather than requiring a Windows runner. The +windows-latest CI job provides the real-NT proof on top of these. + +source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §5.1, §5.2, §5.3 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from mcp_server.shared import platform as plat + + +def test_home_dir_prefers_HOME_override(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + assert plat.home_dir() == tmp_path + + +def test_home_dir_falls_back_to_path_home(monkeypatch): + monkeypatch.delenv("HOME", raising=False) + assert plat.home_dir() == Path.home() + + +def test_python_executable_is_current_interpreter(): + assert plat.python_executable() == sys.executable + + +def test_to_posix_noop_on_forward_slash(): + assert plat.to_posix("a/b/c.py") == "a/b/c.py" + + +def test_to_posix_accepts_pathlike(): + assert plat.to_posix(Path("a") / "b" / "c.py") == "a/b/c.py" + + +def test_to_posix_rewrites_backslashes_when_sep_is_backslash(monkeypatch): + # Simulate the Windows separator so the rewrite branch runs on any host. + monkeypatch.setattr(plat.os, "sep", "\\") + assert plat.to_posix("a\\b\\c.py") == "a/b/c.py" From 3e887fd03ebf1eb353b4c27689909044ce24a953 Mon Sep 17 00:00:00 2001 From: cdeust Date: Fri, 26 Jun 2026 13:26:15 +0200 Subject: [PATCH 2/2] test(windows): assert URL yields no file refs (fix CodeQL alert) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace substring check ("example.com" in r) — flagged by CodeQL py/incomplete-url-substring-sanitization — with a stronger empty-list assertion. extract_file_references already excludes URLs entirely. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests_py/core/test_staleness.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests_py/core/test_staleness.py b/tests_py/core/test_staleness.py index 7928e173..00c46779 100644 --- a/tests_py/core/test_staleness.py +++ b/tests_py/core/test_staleness.py @@ -34,5 +34,8 @@ def test_backslash_and_forward_slash_dedupe_to_one_ref(): def test_excludes_urls(): + # A URL must yield no filesystem references at all (stronger than checking + # for a single host substring, which CodeQL flags as incomplete URL + # sanitization — py/incomplete-url-substring-sanitization). refs = extract_file_references("docs at https://example.com/page.html") - assert not any("example.com" in r for r in refs) + assert refs == []