From 753a6ac2cc3a12d71daba4c337b8f23490014dd3 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Mon, 27 Jul 2026 10:46:47 +0100 Subject: [PATCH] test(detect): regression for NFC manifest keys on macOS --update (#2221) Adds the regression test PR #2224 shipped without: an NFD-keyed manifest (portable and legacy-absolute) must match an NFC scan so --update is a no-op instead of re-extracting everything. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_detect.py | 85 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/test_detect.py b/tests/test_detect.py index e97ff5d4..15fb570e 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -1921,6 +1921,91 @@ def test_detect_incremental_portable_across_paths(tmp_path): ) +def _rewrite_manifest_keys_nfd(manifest_path): + """Rewrite a saved manifest so every key is in NFD form, simulating a + manifest written by a macOS run where os.walk/getcwd yielded decomposed + paths (#2221). Returns the rewritten key list for sanity checks.""" + import json + p = Path(manifest_path) + raw = json.loads(p.read_text(encoding="utf-8")) + nfd = {unicodedata.normalize("NFD", k): v for k, v in raw.items()} + p.write_text(json.dumps(nfd), encoding="utf-8") + return list(nfd) + + +def test_manifest_nfc_keys_survive_macos_path_forms(tmp_path): + """#2221 (portable/relative-key manifest): a manifest whose keys were + written in NFD (macOS os.walk form) must still match an NFC scan, so + --update reports nothing new/changed/deleted instead of re-extracting + the whole corpus. + + NOTE: the fixture filename must contain a character that actually + decomposes under NFD ("é" in "café" does, as do "ä" and "й"). Plain + Cyrillic like "заметка" has no decomposition, so NFC == NFD and the + test would pass vacuously even without the fix. Keep the byte-wise + inequality assertion below when changing the fixture name. + """ + corpus = tmp_path / "corpus" + (corpus / "docs").mkdir(parents=True) + nfc_name = unicodedata.normalize("NFC", "café.md") + assert nfc_name != unicodedata.normalize("NFD", nfc_name) # must decompose + (corpus / "docs" / nfc_name).write_text("hello unicode\n") + + # Manifest lives OUTSIDE the corpus so it never enters the scan. + manifest_path = str(tmp_path / "out" / "manifest.json") + full = detect(corpus) + assert full["total_files"] == 1 # sanity: the café file was scanned + save_manifest(full["files"], manifest_path, root=corpus) + + # Simulate the macOS-written manifest: keys stored in NFD form. + nfd_keys = _rewrite_manifest_keys_nfd(manifest_path) + # Sanity: the on-disk keys are genuinely decomposed, not silently NFC. + assert any(unicodedata.normalize("NFC", k) != k for k in nfd_keys) + + inc = detect_incremental(corpus, manifest_path) + assert inc["new_total"] == 0, ( + f"NFD manifest keys must match NFC scan paths (#2221); " + f"new_files={inc['new_files']}" + ) + assert all(v == [] for v in inc["new_files"].values()) + assert inc["deleted_files"] == [], ( + f"NFD keys misreported as deletions: {inc['deleted_files']}" + ) + assert inc["excluded_files"] == [] + + +def test_manifest_nfc_keys_legacy_absolute(tmp_path): + """#2221 exact repro: legacy manifest saved WITHOUT root (absolute keys), + then rewritten to NFD. Before the load_manifest/detect_incremental NFC + normalization, every file looked simultaneously new AND deleted on + --update. + + NOTE: as above, the filename must contain an NFD-decomposable character + ("é"); a non-decomposing name would make this test vacuous. + """ + corpus = tmp_path / "corpus" + (corpus / "docs").mkdir(parents=True) + nfc_name = unicodedata.normalize("NFC", "café.md") + assert nfc_name != unicodedata.normalize("NFD", nfc_name) # must decompose + (corpus / "docs" / nfc_name).write_text("hello unicode\n") + + manifest_path = str(tmp_path / "out" / "manifest.json") + full = detect(corpus) + assert full["total_files"] == 1 + # No root= -> legacy absolute-keyed manifest format. + save_manifest(full["files"], manifest_path) + + _rewrite_manifest_keys_nfd(manifest_path) + + inc = detect_incremental(corpus, manifest_path) + assert inc["new_total"] == 0, ( + f"legacy absolute NFD keys must match NFC scan (#2221); " + f"new_files={inc['new_files']}" + ) + assert inc["deleted_files"] == [] + assert inc["excluded_files"] == [] + + def test_save_manifest_in_root_symlink_roundtrips(tmp_path): """In-root symlinks must store under the symlink's own name, not the resolved target. Resolving the key when relativizing pointed the stored