From 986ddba285f5d14d002b66e93b694dae53c622a0 Mon Sep 17 00:00:00 2001 From: rohit-jsfreaky Date: Tue, 25 Aug 2026 19:49:15 +0530 Subject: [PATCH] fix(windows): stop a PowerShell BOM breaking hook rebuilds (#3028) --- graphify/build.py | 2 +- graphify/hooks.py | 4 +-- graphify/reflect.py | 2 +- graphify/skill-windows.md | 11 +++++--- graphify/watch.py | 2 +- .../test_build_merge_hyperedges_and_prune.py | 24 ++++++++++++++++++ tests/test_hooks.py | 25 ++++++++++++++++++- tests/test_skillgen.py | 22 ++++++++++++++++ .../expected/graphify__skill-windows.md | 11 +++++--- tools/skillgen/fragments/shell/powershell.md | 11 +++++--- 10 files changed, 99 insertions(+), 15 deletions(-) diff --git a/graphify/build.py b/graphify/build.py index 8efdcbd6e2..f721382b7f 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -439,7 +439,7 @@ def _infer_merge_root(graph_path: Path) -> str | None: try: marker = parent / ".graphify_root" if marker.exists(): - recorded = marker.read_text(encoding="utf-8").strip() + recorded = marker.read_text(encoding="utf-8-sig").strip() if recorded: return str(Path(recorded).resolve()) except OSError: diff --git a/graphify/hooks.py b/graphify/hooks.py index 211c074be9..ab4289079f 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -165,7 +165,7 @@ def _bail(): _out = os.environ.get('GRAPHIFY_OUT', 'graphify-out') _saved = Path(_out) / '.graphify_root' if _saved.exists(): - _txt = _saved.read_text(encoding='utf-8').strip() + _txt = _saved.read_text(encoding='utf-8-sig').strip() if _txt: _root = Path(_txt) _rebuild_code(_root, changed_paths=changed, force=_force) @@ -214,7 +214,7 @@ def _bail(): _out = os.environ.get('GRAPHIFY_OUT', 'graphify-out') _saved = Path(_out) / '.graphify_root' if _saved.exists(): - _txt = _saved.read_text(encoding='utf-8').strip() + _txt = _saved.read_text(encoding='utf-8-sig').strip() if _txt: _root = Path(_txt) _rebuild_code(_root, force=_force) diff --git a/graphify/reflect.py b/graphify/reflect.py index 4e04e05891..5cb2fcd598 100644 --- a/graphify/reflect.py +++ b/graphify/reflect.py @@ -691,7 +691,7 @@ def _resolve_source_path(src: str, graph_path: Path) -> Path | None: out_dir = gp.parent candidates: list[Path] = [] try: - recorded = (out_dir / ".graphify_root").read_text(encoding="utf-8").strip() + recorded = (out_dir / ".graphify_root").read_text(encoding="utf-8-sig").strip() if recorded: candidates.append(Path(recorded)) except (OSError, ValueError): diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md index d631821ec3..b09ecca3c4 100644 --- a/graphify/skill-windows.md +++ b/graphify/skill-windows.md @@ -116,10 +116,15 @@ if (-not $GRAPHIFY_PYTHON) { $GRAPHIFY_PYTHON = Find-GraphifyPython } -# Save interpreter path — all subsequent steps read this -$GRAPHIFY_PYTHON | Out-File -FilePath graphify-out\.graphify_python -Encoding utf8 -NoNewline +# Save interpreter path — all subsequent steps read this. +# `Out-File -Encoding utf8` always writes a BOM on Windows PowerShell 5.1 (utf8NoBOM +# only exists from PowerShell 6), and that BOM rides into the saved path, so the hook +# rebuild fails with WinError 123 (#3028). WriteAllText with an explicit BOM-less +# encoding writes the bytes POSIX writes, and adds no trailing newline. +$Utf8NoBom = New-Object System.Text.UTF8Encoding $false +[System.IO.File]::WriteAllText((Join-Path $PWD 'graphify-out\.graphify_python'), [string]$GRAPHIFY_PYTHON, $Utf8NoBom) # Save scan root so `graphify update` (no args) knows where to look next time -(Resolve-Path INPUT_PATH).Path | Out-File -FilePath graphify-out\.graphify_root -Encoding utf8 -NoNewline +[System.IO.File]::WriteAllText((Join-Path $PWD 'graphify-out\.graphify_root'), (Resolve-Path INPUT_PATH).Path, $Utf8NoBom) ``` If the import succeeds, print nothing and move straight to Step 2. diff --git a/graphify/watch.py b/graphify/watch.py index 8ad02c4df8..48f20dd18e 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -369,7 +369,7 @@ def __init__( root_marker = out / ".graphify_root" if root_marker.exists(): try: - saved_root = Path(root_marker.read_text(encoding="utf-8").strip()) + saved_root = Path(root_marker.read_text(encoding="utf-8-sig").strip()) if saved_root.is_absolute(): # #2603: the marker holds the SCAN root, but stored # source_file values are relative to the BUILD's cwd diff --git a/tests/test_build_merge_hyperedges_and_prune.py b/tests/test_build_merge_hyperedges_and_prune.py index d3629a73cd..2e341c3b8e 100644 --- a/tests/test_build_merge_hyperedges_and_prune.py +++ b/tests/test_build_merge_hyperedges_and_prune.py @@ -264,3 +264,27 @@ def test_prune_reextracted_absolute_node_not_deleted(tmp_path): G = build_merge([new_chunk], graph_path, prune_sources=["mod.py"], dedup=False) labels = {d["label"] for _, d in G.nodes(data=True)} assert "gone" in labels, "re-extracted file wrongly pruned across mismatched forms (#2012/#1796)" + + +def test_graphify_root_marker_with_a_utf8_bom_still_resolves(tmp_path): + """A marker written by Windows PowerShell 5.1 carries a UTF-8 BOM (#3028). + + `Out-File -Encoding utf8` on 5.1 always prepends EF BB BF — there is no + BOM-less utf8 there — and `str.strip()` does not remove U+FEFF, so the BOM + survived into the recorded path. Worse than an error: `C:\...` is no + longer drive-qualified, so `Path.resolve()` treated it as relative and silently + joined it onto the cwd. Reading with `utf-8-sig` drops an optional BOM and + leaves a BOM-less file untouched, so existing broken checkouts heal in place. + """ + out = tmp_path / "out" + out.mkdir() + graph_path = out / "graph.json" + real_root = tmp_path / "elsewhere" / "repo" + real_root.mkdir(parents=True) + (out / ".graphify_root").write_bytes( + b"\xef\xbb\xbf" + str(real_root).encode("utf-8") + ) + + resolved = _infer_merge_root(graph_path) + assert resolved == str(real_root.resolve()) + assert "" not in (resolved or "") diff --git a/tests/test_hooks.py b/tests/test_hooks.py index e00d7e2989..342c4d8b15 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -320,7 +320,9 @@ def test_rebuild_bodies_read_graphify_root(name, body): # The recovered root is what gets rebuilt, not a hardcoded cwd. assert "_rebuild_code(_root" in body, f"{name} does not pass the recovered root" # Quote-safe inside the shell-double-quoted launcher: single quotes only. - assert "read_text(encoding='utf-8')" in body, f"{name} root read is not single-quoted" + # `utf-8-sig` rather than `utf-8` so a Windows PowerShell 5.1 BOM cannot ride + # into the path (#3028); the codec is a no-op on a BOM-less marker. + assert "read_text(encoding='utf-8-sig')" in body, f"{name} root read is not single-quoted" def test_rebuild_bodies_with_graphify_root_are_valid_python(): @@ -1144,3 +1146,24 @@ def test_both_hooks_configured(tmp_path): for name in ("post-commit", "post-checkout"): hook_text = (repo / ".git" / "hooks" / name).read_text() assert 'export GRAPHIFY_VIZ_NODE_LIMIT="${GRAPHIFY_VIZ_NODE_LIMIT:-42}"' in hook_text + + +@pytest.mark.parametrize( + "name,body", + [("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)], +) +def test_rebuild_bodies_tolerate_a_bom_in_graphify_root(name, body): + """The rebuild must survive a marker written by Windows PowerShell 5.1 (#3028). + + `Out-File -Encoding utf8` on 5.1 always writes a UTF-8 BOM, so the path the + hook reads back begins with U+FEFF. `strip()` does not remove it, and it rode + straight into a Windows path API: every post-commit rebuild died with + `WinError 123` while `hook install` / `hook status` still reported success. + `utf-8-sig` drops an optional BOM and is a no-op on a clean file. + """ + assert "encoding='utf-8-sig'" in body, ( + f"{name} rebuild body must read .graphify_root BOM-tolerantly" + ) + assert "encoding='utf-8')" not in body, ( + f"{name} rebuild body still has a BOM-intolerant read" + ) diff --git a/tests/test_skillgen.py b/tests/test_skillgen.py index cf116869f2..5a960aac91 100644 --- a/tests/test_skillgen.py +++ b/tests/test_skillgen.py @@ -1093,3 +1093,25 @@ def test_semantic_cache_calls_pass_prompt_file_for_every_split_host(): ) # The placeholder is inert unless the body tells the agent what to substitute. assert "SPEC_PATH below is the **absolute** path" in a.content, a.path + + +def test_windows_skill_writes_marker_files_without_a_bom(): + """The Windows bootstrap must not write the sidecar markers with a BOM (#3028). + + `Out-File -Encoding utf8` always emits EF BB BF on Windows PowerShell 5.1 -- + `utf8NoBOM` only exists from PowerShell 6 -- and `-NoNewline` does nothing about + it. The BOM then rode into `.graphify_python` / `.graphify_root`, so every + post-commit rebuild died with WinError 123 while `hook install` and `hook status` + both still reported success. The readers now decode with `utf-8-sig`; this keeps + the writer from producing the BOM in the first place. + """ + core, _ = _platform_artifacts("windows") + for marker in (".graphify_python", ".graphify_root"): + assert f"Out-File -FilePath graphify-out\{marker} -Encoding utf8" not in core, ( + f"the windows render still writes {marker} with a BOM-emitting Out-File" + ) + assert f"WriteAllText((Join-Path $PWD 'graphify-out\{marker}')" in core, ( + f"{marker} must be written through WriteAllText with a BOM-less encoding" + ) + assert "New-Object System.Text.UTF8Encoding $false" in core, \ + "the BOM-less encoding object must be constructed in the windows render" diff --git a/tools/skillgen/expected/graphify__skill-windows.md b/tools/skillgen/expected/graphify__skill-windows.md index d631821ec3..b09ecca3c4 100644 --- a/tools/skillgen/expected/graphify__skill-windows.md +++ b/tools/skillgen/expected/graphify__skill-windows.md @@ -116,10 +116,15 @@ if (-not $GRAPHIFY_PYTHON) { $GRAPHIFY_PYTHON = Find-GraphifyPython } -# Save interpreter path — all subsequent steps read this -$GRAPHIFY_PYTHON | Out-File -FilePath graphify-out\.graphify_python -Encoding utf8 -NoNewline +# Save interpreter path — all subsequent steps read this. +# `Out-File -Encoding utf8` always writes a BOM on Windows PowerShell 5.1 (utf8NoBOM +# only exists from PowerShell 6), and that BOM rides into the saved path, so the hook +# rebuild fails with WinError 123 (#3028). WriteAllText with an explicit BOM-less +# encoding writes the bytes POSIX writes, and adds no trailing newline. +$Utf8NoBom = New-Object System.Text.UTF8Encoding $false +[System.IO.File]::WriteAllText((Join-Path $PWD 'graphify-out\.graphify_python'), [string]$GRAPHIFY_PYTHON, $Utf8NoBom) # Save scan root so `graphify update` (no args) knows where to look next time -(Resolve-Path INPUT_PATH).Path | Out-File -FilePath graphify-out\.graphify_root -Encoding utf8 -NoNewline +[System.IO.File]::WriteAllText((Join-Path $PWD 'graphify-out\.graphify_root'), (Resolve-Path INPUT_PATH).Path, $Utf8NoBom) ``` If the import succeeds, print nothing and move straight to Step 2. diff --git a/tools/skillgen/fragments/shell/powershell.md b/tools/skillgen/fragments/shell/powershell.md index dc239b504e..71e493cf8d 100644 --- a/tools/skillgen/fragments/shell/powershell.md +++ b/tools/skillgen/fragments/shell/powershell.md @@ -50,10 +50,15 @@ if (-not $GRAPHIFY_PYTHON) { $GRAPHIFY_PYTHON = Find-GraphifyPython } -# Save interpreter path — all subsequent steps read this -$GRAPHIFY_PYTHON | Out-File -FilePath graphify-out\.graphify_python -Encoding utf8 -NoNewline +# Save interpreter path — all subsequent steps read this. +# `Out-File -Encoding utf8` always writes a BOM on Windows PowerShell 5.1 (utf8NoBOM +# only exists from PowerShell 6), and that BOM rides into the saved path, so the hook +# rebuild fails with WinError 123 (#3028). WriteAllText with an explicit BOM-less +# encoding writes the bytes POSIX writes, and adds no trailing newline. +$Utf8NoBom = New-Object System.Text.UTF8Encoding $false +[System.IO.File]::WriteAllText((Join-Path $PWD 'graphify-out\.graphify_python'), [string]$GRAPHIFY_PYTHON, $Utf8NoBom) # Save scan root so `graphify update` (no args) knows where to look next time -(Resolve-Path INPUT_PATH).Path | Out-File -FilePath graphify-out\.graphify_root -Encoding utf8 -NoNewline +[System.IO.File]::WriteAllText((Join-Path $PWD 'graphify-out\.graphify_root'), (Resolve-Path INPUT_PATH).Path, $Utf8NoBom) ``` If the import succeeds, print nothing and move straight to Step 2.