doubled-graph 2.0.1__tar.gz → 2.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/PKG-INFO +2 -2
  2. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/README.md +1 -1
  3. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/docs/CHANGELOG.md +37 -0
  4. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/docs/TOOLS.md +3 -3
  5. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/upgrade-methodology/01-content-gaps.md +13 -0
  6. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/pyproject.toml +1 -1
  7. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/cli.py +1 -1
  8. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/config.py +3 -3
  9. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/graphs/grace4.py +8 -3
  10. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/hooks_installer.py +18 -5
  11. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/integrations/grace_cli.py +125 -9
  12. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/migrations/__init__.py +1 -1
  13. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/migrations/grace3_to_grace4.py +87 -15
  14. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/policy/phase.py +6 -3
  15. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/server.py +1 -1
  16. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/setup.py +25 -6
  17. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/context.py +4 -4
  18. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/detect_changes.py +1 -1
  19. doubled_graph-2.0.2/tests/test_bom_support.py +204 -0
  20. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_grace4_artifacts.py +16 -0
  21. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_grace_cli_engine.py +1 -1
  22. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_migration_grace3_to_grace4.py +21 -0
  23. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/.gitignore +0 -0
  24. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/ARTICLE.md +0 -0
  25. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/INDEX.md +0 -0
  26. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/LICENSE +0 -0
  27. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/docs/HOOKS.md +0 -0
  28. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/docs/SPEC.md +0 -0
  29. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-ai-existing-project/README.md +0 -0
  30. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-ai-maintenance/README.md +0 -0
  31. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-ai-new-project/README.md +0 -0
  32. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-humans/FAQ.md +0 -0
  33. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-humans/deep-dive.md +0 -0
  34. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-humans/getting-started.md +0 -0
  35. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/README.md +0 -0
  36. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/approval-checkpoints.md +0 -0
  37. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/artifacts.md +0 -0
  38. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/auto-scaling.md +0 -0
  39. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/drift-and-priority.md +0 -0
  40. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/_template.md +0 -0
  41. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/csharp.md +0 -0
  42. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/go.md +0 -0
  43. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/java.md +0 -0
  44. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/python.md +0 -0
  45. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/rust.md +0 -0
  46. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/swift.md +0 -0
  47. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/typescript.md +0 -0
  48. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/principles.md +0 -0
  49. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/roles.md +0 -0
  50. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/_template.md +0 -0
  51. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/claude-code.md +0 -0
  52. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/codex.md +0 -0
  53. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/continue.md +0 -0
  54. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/cursor.md +0 -0
  55. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/local-llm.md +0 -0
  56. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/windsurf.md +0 -0
  57. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/tools.md +0 -0
  58. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/workflow.md +0 -0
  59. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/maintenance/on-after-edit.md +0 -0
  60. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/maintenance/on-before-edit.md +0 -0
  61. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/maintenance/on-drift-detected.md +0 -0
  62. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/maintenance/on-user-manual-edit.md +0 -0
  63. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/00-entry-prompt.md +0 -0
  64. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/01-discover.md +0 -0
  65. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/02-reconstruct-intent.md +0 -0
  66. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/03-draft-plan.md +0 -0
  67. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/04-markup-codebase.md +0 -0
  68. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/05-verification-and-logs.md +0 -0
  69. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/06-validation-gates.md +0 -0
  70. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/00-entry-prompt.md +0 -0
  71. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/01-intent-interview.md +0 -0
  72. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/02-requirements.md +0 -0
  73. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/03-technology.md +0 -0
  74. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/04-development-plan.md +0 -0
  75. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/05-verification-plan.md +0 -0
  76. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/06-generate.md +0 -0
  77. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/07-post-init-sync.md +0 -0
  78. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/upgrade-methodology/00-entry-prompt.md +0 -0
  79. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/__init__.py +0 -0
  80. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/__main__.py +0 -0
  81. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-after-edit/SKILL.md +0 -0
  82. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-before-edit/SKILL.md +0 -0
  83. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-drift/SKILL.md +0 -0
  84. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-guide/SKILL.md +0 -0
  85. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-manual-edit/SKILL.md +0 -0
  86. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-refactor/SKILL.md +0 -0
  87. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-verify/SKILL.md +0 -0
  88. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/graphs/__init__.py +0 -0
  89. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/graphs/crossref.py +0 -0
  90. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/graphs/declared.py +0 -0
  91. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/integrations/__init__.py +0 -0
  92. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/integrations/cgc.py +0 -0
  93. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/policy/__init__.py +0 -0
  94. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/storage/__init__.py +0 -0
  95. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/storage/paths.py +0 -0
  96. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/__init__.py +0 -0
  97. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/_meta.py +0 -0
  98. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/analyze.py +0 -0
  99. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/impact.py +0 -0
  100. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/__init__.py +0 -0
  101. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_cgc_contract.py +0 -0
  102. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_setup_artifacts.py +0 -0
  103. {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: doubled-graph
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: MCP facade over CodeGraphContext + GRACE 4 (.grace artifacts): impact, context, drift-detect, migration for LLM-driven codebases.
5
5
  Project-URL: Homepage, https://github.com/rour/doubled-graph-mcp
6
6
  Project-URL: Repository, https://github.com/rour/doubled-graph-mcp
@@ -154,7 +154,7 @@ head -1 "$(which doubled-graph)" # вывести shebang — это и ест
154
154
  ### 4. Проверить, что всё встало
155
155
 
156
156
  ```bash
157
- doubled-graph --version # 2.0.1
157
+ doubled-graph --version # 2.0.2
158
158
  doubled-graph status --repo . # artifacts.project_kind = grace4, grace_engine.upgrade_required = false
159
159
  doubled-graph lint --repo . # summary.errors = 0
160
160
  doubled-graph migrate check --repo . # map_pending / placeholders — что осталось заполнить
@@ -119,7 +119,7 @@ head -1 "$(which doubled-graph)" # вывести shebang — это и ест
119
119
  ### 4. Проверить, что всё встало
120
120
 
121
121
  ```bash
122
- doubled-graph --version # 2.0.1
122
+ doubled-graph --version # 2.0.2
123
123
  doubled-graph status --repo . # artifacts.project_kind = grace4, grace_engine.upgrade_required = false
124
124
  doubled-graph lint --repo . # summary.errors = 0
125
125
  doubled-graph migrate check --repo . # map_pending / placeholders — что осталось заполнить
@@ -2,6 +2,43 @@
2
2
 
3
3
  All notable changes to doubled-graph. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/).
4
4
 
5
+ ## [2.0.2] — 2026-10-23
6
+
7
+ BOM support on top of 2.0.1 (already published). No artifact-format change:
8
+ `pipx upgrade doubled-graph` is enough; no re-migration required.
9
+
10
+ **Why:** a real C# repository had 216 `.cs` files starting with a UTF-8 BOM.
11
+ The JS engine treats U+FEFF as whitespace, but Python's `\s` does not, so
12
+ doubled-graph saw those files as ungoverned while `grace lint` was green:
13
+ modules lost `localFiles`, `file show` found nothing, `detect-changes` reported
14
+ `code_without_module`.
15
+
16
+ ### Fixed — UTF-8 BOM in sources and inputs
17
+ - UTF-8 BOM everywhere, not only in source files. Python's `\s` does not match
18
+ U+FEFF while the JS engine's `\s` does, so `^\s*// MODULE:` never matched and
19
+ every BOM file (216 in one real repository) looked ungoverned to
20
+ doubled-graph — modules lost their files, `module show` returned no
21
+ `localFiles`, `detect-changes` reported `code_without_module` — while
22
+ `grace lint` was green. All readers now use `utf-8-sig`: source scanning
23
+ (`graphs/grace4.py`, markup repair, `context`, `detect_changes`), `.grace`
24
+ documents, `.doubled-graph/config.json`, `.mcp.json`,
25
+ `.continue/config.json`, `.claude/settings.json`, `AGENTS.md` and legacy
26
+ `docs/*.xml`. The markup repair writes a source file back **with** its BOM;
27
+ writers of `.grace`/JSON/hook scripts never add one (a BOM-broken shebang
28
+ would not execute).
29
+ - `doubled-graph lint` now **works on BOM sources without modifying them**. The
30
+ engine's Python/TypeScript/Dart analyzers reject U+FEFF (`ast.parse`), and its
31
+ index check misses `.grace` documents behind a BOM — so lint runs against a
32
+ temporary snapshot in which BOM is normalised (`.grace/*.xml` and
33
+ adapter-backed sources only), with the engine `cwd` set to the snapshot and
34
+ `--path .` (4.2.1 reports `projection.*.unindexed-document` for an absolute
35
+ `--path` outside cwd). JSON paths are rebased to the real repository before
36
+ returning. Client files are never written: verified — `errors: 1` before,
37
+ `errors: 0` after, BOM still present in `src/a.py` and `.grace/graph/index.xml`.
38
+ Files in languages without an adapter (C#, Go, Rust, Java, Swift, …) are
39
+ copied verbatim, BOM included: the engine runs no analyzer for them, so their
40
+ BOM never reaches `ast.parse` and does not affect lint. `migrate check` reports `bom_files` / `bom_adapter_files` and
41
+ warns `migration.bom-in-code`; `01-content-gaps.md` explains the mirror.
5
42
  ## [2.0.1] — 2026-10-23
6
43
 
7
44
  Migration-fidelity fixes on top of 2.0.0 (already published). No artifact-format
@@ -55,7 +55,7 @@
55
55
  "warnings": [
56
56
  {"code": "HASKELL_PARSER_UNSTABLE", "file": "src/foo.hs", "detail": "…"}
57
57
  ],
58
- "meta": {"phase": "post_migration", "tool_version": "2.0.1", "timestamp_iso": "2026-10-23T10:22:04Z", "trace_id": "…"}
58
+ "meta": {"phase": "post_migration", "tool_version": "2.0.2", "timestamp_iso": "2026-10-23T10:22:04Z", "trace_id": "…"}
59
59
  }
60
60
  ```
61
61
 
@@ -139,7 +139,7 @@ Declared-сторона (`graphs/grace4.py`) при `analyze` не вызыва
139
139
  "phase=post_migration — contract drift check required"
140
140
  ]
141
141
  },
142
- "meta": {"phase": "post_migration", "tool_version": "2.0.1", "timestamp_iso": "...", "trace_id": "..."}
142
+ "meta": {"phase": "post_migration", "tool_version": "2.0.2", "timestamp_iso": "...", "trace_id": "..."}
143
143
  }
144
144
  ```
145
145
 
@@ -232,7 +232,7 @@ Declared-сторона (`graphs/grace4.py`) при `analyze` не вызыва
232
232
  "plan_status": "approved",
233
233
  "tasks": ["T-001", "T-002"]
234
234
  },
235
- "meta": {"phase": "...", "tool_version": "2.0.1", "...": "..."}
235
+ "meta": {"phase": "...", "tool_version": "2.0.2", "...": "..."}
236
236
  }
237
237
  ```
238
238
 
@@ -204,6 +204,19 @@ doubled-graph migrate check --repo . # context_gaps должен быть []
204
204
 
205
205
  Если после заполнения `context_gaps` пуст, а `grep` всё ещё что-то находит — это строка внутри `<LegacySource>` (архив), её не трогай: проверяй только рабочие секции, не архив.
206
206
 
207
+ ### BOM в исходниках
208
+
209
+ `doubled-graph` сам читает BOM-файлы (`utf-8-sig`), но анализатор движка для Python/TypeScript/Dart через `ast.parse`/тулинг отвергает U+FEFF: `grace lint` даёт `analysis.adapter-failed: invalid non-printable character U+FEFF at line 1:1`. Проверь заранее:
210
+
211
+ ```bash
212
+ doubled-graph migrate check --repo . # bom_files / bom_adapter_files, warning migration.bom-in-code
213
+ ```
214
+
215
+ - **файлы клиента не изменяются**: `doubled-graph lint` при наличии BOM запускает движок на временной копии, где BOM нормализован (только `.grace/*.xml` и adapter-исходники `.py/.pyi/.ts/.tsx/.js/.jsx/.mjs/.cjs/.mts/.cts/.dart`), и переписывает пути отчёта обратно на реальный репозиторий;
216
+ - если `bom_adapter_files` непусто, но `lint` зелёный — это и есть работа зеркала: BOM остался в исходниках, гейт пройден;
217
+ - файлы без адаптера (C#, Go, Rust, Java, Swift, …) попадают в копию **как есть**, BOM у них не снимается: движок не запускает для них анализатор, поэтому lint от BOM не зависит;
218
+ - ничего не удаляй и не перекодируй вручную; если lint всё же даёт `analysis.adapter-failed` с U+FEFF — это сигнал, что зеркало не сработало (сообщи о баге), а не повод править файлы.
219
+
207
220
  ---
208
221
 
209
222
  ## Шаг 5 — отчёт пользователю (формат фиксированный)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "doubled-graph"
7
- version = "2.0.1"
7
+ version = "2.0.2"
8
8
  description = "MCP facade over CodeGraphContext + GRACE 4 (.grace artifacts): impact, context, drift-detect, migration for LLM-driven codebases."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -325,7 +325,7 @@ def _cmd_health(args: argparse.Namespace) -> int:
325
325
  drift_md = repo / "docs" / "DRIFT.md"
326
326
  open_drift_entries = 0
327
327
  if drift_md.exists():
328
- text = drift_md.read_text(encoding="utf-8")
328
+ text = drift_md.read_text(encoding="utf-8-sig")
329
329
  # Sections separated by `## D-`; resolved if section contains "Resolved"
330
330
  sections = [s for s in text.split("\n## D-") if s.strip()]
331
331
  open_drift_entries = sum(1 for s in sections if "Resolved" not in s and "resolved" not in s)
@@ -47,9 +47,9 @@ def _load_yaml(p: Path) -> dict:
47
47
  except ModuleNotFoundError:
48
48
  import json as _json
49
49
 
50
- with p.open("r", encoding="utf-8") as f:
50
+ with p.open("r", encoding="utf-8-sig") as f:
51
51
  return _json.load(f)
52
- with p.open("r", encoding="utf-8") as f:
52
+ with p.open("r", encoding="utf-8-sig") as f:
53
53
  return yaml.safe_load(f) or {}
54
54
 
55
55
 
@@ -61,7 +61,7 @@ def load_config(repo_path: Path) -> Config:
61
61
  if p.suffix in (".yaml", ".yml"):
62
62
  data = _load_yaml(p)
63
63
  else:
64
- with p.open("r", encoding="utf-8") as f:
64
+ with p.open("r", encoding="utf-8-sig") as f:
65
65
  data = json.load(f)
66
66
  return Config(**data)
67
67
 
@@ -109,7 +109,9 @@ _BARE_AMP = re.compile(r"&(?!amp;|lt;|gt;|apos;|quot;|#\d+;|#x[0-9a-fA-F]+;)")
109
109
  _PATH_FIELD = re.compile(r"(?:Path|File)\s+([^\s]+)")
110
110
  _LINKS_VALUE = re.compile(r"(?im)^\s*(?://|#|--|;+|\*)\s*LINKS:\s*(?P<value>.+)$")
111
111
  _CONTRACT_SECTION = re.compile(
112
- r"(?ms)^\s*(?://|#|--|;+|\*)\s*START_MODULE_CONTRACT\s*$(?P<body>.*?)^\s*(?://|#|--|;+|\*)\s*END_MODULE_CONTRACT\s*$"
112
+ # ``\ufeff?``: a BOM may survive when the text did not come from a
113
+ # ``utf-8-sig`` read; Python's ``\s`` does not match U+FEFF (JS does).
114
+ r"(?ms)^\ufeff?\s*(?://|#|--|;+|\*)\s*START_MODULE_CONTRACT\s*$(?P<body>.*?)^\s*(?://|#|--|;+|\*)\s*END_MODULE_CONTRACT\s*$"
113
115
  )
114
116
 
115
117
 
@@ -247,7 +249,7 @@ def legacy_documents(root: Path) -> list[str]:
247
249
  def _safe_parse(path: Path) -> tuple[ET.Element | None, str]:
248
250
  """Parse XML, escaping bare ``&`` on retry. Returns (root, error message)."""
249
251
  try:
250
- raw = path.read_text(encoding="utf-8")
252
+ raw = path.read_text(encoding="utf-8-sig")
251
253
  except OSError as exc:
252
254
  return None, f"cannot read {path.name}: {exc}"
253
255
  try:
@@ -469,7 +471,10 @@ def scan_governed_links(
469
471
  issues: list[Issue] = []
470
472
  for file_path in iter_code_files(resolved, ignored):
471
473
  try:
472
- text = file_path.read_text(encoding="utf-8", errors="replace")
474
+ # `utf-8-sig` strips a BOM: Python's `\s` does not match U+FEFF (the
475
+ # JS engine's `\s` does), so a BOM would hide every marker from our
476
+ # `^\s*//` regex while `grace lint` still sees the file.
477
+ text = file_path.read_text(encoding="utf-8-sig", errors="replace")
473
478
  except OSError:
474
479
  continue
475
480
  section = _CONTRACT_SECTION.search(text)
@@ -61,7 +61,7 @@ def _install_post_commit(repo: Path, dry_run: bool) -> dict:
61
61
 
62
62
  # Soft-safety: if file exists and is non-empty AND doesn't contain our signature — back up.
63
63
  if target.exists():
64
- existing = target.read_text(encoding="utf-8", errors="replace")
64
+ existing = target.read_text(encoding="utf-8-sig", errors="replace")
65
65
  if "doubled-graph init-hooks" not in existing:
66
66
  backup = target.with_suffix(".backup")
67
67
  target.rename(backup)
@@ -76,13 +76,25 @@ def _install_post_commit(repo: Path, dry_run: bool) -> dict:
76
76
  return {"post_commit": "installed", "path": str(target), "note": replaced}
77
77
 
78
78
 
79
+ _BOM_PREFIX = b"\xef\xbb\xbf"
80
+
81
+
82
+ def _had_bom(path: Path) -> bool:
83
+ """True when an existing file starts with a UTF-8 BOM."""
84
+ try:
85
+ return path.read_bytes().startswith(_BOM_PREFIX)
86
+ except OSError:
87
+ return False
88
+
89
+
79
90
  def _install_claude_code(repo: Path, dry_run: bool) -> dict:
80
91
  settings = repo / ".claude" / "settings.json"
81
92
  settings.parent.mkdir(parents=True, exist_ok=True)
93
+ had_bom = _had_bom(settings)
82
94
  data: dict = {}
83
95
  if settings.exists():
84
96
  try:
85
- data = json.loads(settings.read_text(encoding="utf-8"))
97
+ data = json.loads(settings.read_text(encoding="utf-8-sig"))
86
98
  except json.JSONDecodeError:
87
99
  return {"claude_code": "error", "reason": f"cannot parse {settings}"}
88
100
  hooks = data.setdefault("hooks", {})
@@ -100,7 +112,8 @@ def _install_claude_code(repo: Path, dry_run: bool) -> dict:
100
112
  return {"claude_code": "dry-run", "path": str(settings), "would_add": CLAUDE_HOOK_ENTRY}
101
113
 
102
114
  post_tool_use.append(CLAUDE_HOOK_ENTRY)
103
- settings.write_text(json.dumps(data, indent=2), encoding="utf-8")
115
+ # Keep the client's BOM: rewriting a settings file must not re-encode it.
116
+ settings.write_text(json.dumps(data, indent=2), encoding="utf-8-sig" if had_bom else "utf-8")
104
117
  return {"claude_code": "installed", "path": str(settings)}
105
118
 
106
119
 
@@ -125,13 +138,13 @@ def ensure_gitignore_entries(repo_path: Path, dry_run: bool = False) -> dict:
125
138
  so skipping hooks shouldn't leave the user's `git status` polluted.
126
139
  """
127
140
  gi = repo_path / ".gitignore"
128
- existing = gi.read_text(encoding="utf-8") if gi.exists() else ""
141
+ existing = gi.read_text(encoding="utf-8-sig") if gi.exists() else ""
129
142
  missing = [line for line in DOUBLED_GRAPH_GITIGNORE_LINES if line not in existing]
130
143
  if not missing:
131
144
  return {"status": "already_present", "lines": DOUBLED_GRAPH_GITIGNORE_LINES}
132
145
  if dry_run:
133
146
  return {"status": "dry_run", "would_append": missing}
134
- with gi.open("a", encoding="utf-8") as f:
147
+ with gi.open("a", encoding="utf-8-sig") as f:
135
148
  if existing and not existing.endswith("\n"):
136
149
  f.write("\n")
137
150
  f.write("\n# doubled-graph\n" + "\n".join(missing) + "\n")
@@ -14,9 +14,11 @@ import re
14
14
  import shutil
15
15
  import subprocess
16
16
  import sys
17
+ import tempfile
18
+ from contextlib import contextmanager
17
19
  from dataclasses import dataclass
18
20
  from pathlib import Path
19
- from typing import Any
21
+ from typing import Any, Iterator
20
22
 
21
23
 
22
24
  class GraceCLIMissing(RuntimeError):
@@ -773,6 +775,111 @@ class FileRecord:
773
775
  raw: dict | None = None
774
776
 
775
777
 
778
+ _BOM = b"\xef\xbb\xbf"
779
+ _ADAPTER_EXTENSIONS = frozenset({
780
+ ".py", ".pyi", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".dart",
781
+ })
782
+
783
+
784
+ def _adapter_bom_files(repo_path: Path) -> list[Path]:
785
+ """Adapter-backed sources that start with a UTF-8 BOM (client files untouched)."""
786
+ from doubled_graph.graphs.grace4 import iter_code_files
787
+
788
+ found: list[Path] = []
789
+ for file_path in iter_code_files(repo_path, ignored=(".doubled-graph",)):
790
+ if file_path.suffix.lower() not in _ADAPTER_EXTENSIONS:
791
+ continue
792
+ try:
793
+ with file_path.open("rb") as handle:
794
+ if handle.read(3) == _BOM:
795
+ found.append(file_path)
796
+ except OSError:
797
+ continue
798
+ return found
799
+
800
+
801
+ def _grace_has_bom(repo_path: Path) -> bool:
802
+ """True when any `.grace` document starts with a BOM (the engine misses it)."""
803
+ grace = repo_path / ".grace"
804
+ if not grace.is_dir():
805
+ return False
806
+ for path in grace.rglob("*.xml"):
807
+ try:
808
+ with path.open("rb") as handle:
809
+ if handle.read(3) == _BOM:
810
+ return True
811
+ except OSError:
812
+ continue
813
+ return False
814
+
815
+
816
+ def _copy_tree_for_lint(repo_path: Path, mirror: Path) -> None:
817
+ """Snapshot `.grace` + code files; BOM files are normalised in the copy only.
818
+
819
+ The engine rejects U+FEFF in adapter analyzers and skips `<Path>` entries in
820
+ a BOM-prefixed index, so lint needs BOM-free real copies — but the client's
821
+ working tree is never written to.
822
+ """
823
+ from doubled_graph.graphs.grace4 import iter_code_files
824
+
825
+ grace = repo_path / ".grace"
826
+ for directory in [grace, *(path for path in grace.rglob("*") if path.is_dir())]:
827
+ if directory.is_dir():
828
+ (mirror / directory.relative_to(repo_path)).mkdir(parents=True, exist_ok=True)
829
+ for source in grace.rglob("*"):
830
+ if not source.is_file():
831
+ continue
832
+ target = mirror / source.relative_to(repo_path)
833
+ target.parent.mkdir(parents=True, exist_ok=True)
834
+ raw = source.read_bytes()
835
+ target.write_bytes(raw[len(_BOM):] if raw.startswith(_BOM) else raw)
836
+ config = repo_path / ".doubled-graph" / "config.json"
837
+ if config.exists():
838
+ (mirror / ".doubled-graph").mkdir(parents=True, exist_ok=True)
839
+ shutil.copy2(config, mirror / ".doubled-graph" / "config.json")
840
+ for file_path in iter_code_files(repo_path, ignored=(".doubled-graph",)):
841
+ relative = file_path.relative_to(repo_path)
842
+ target = mirror / relative
843
+ target.parent.mkdir(parents=True, exist_ok=True)
844
+ raw = file_path.read_bytes()
845
+ if file_path.suffix.lower() in _ADAPTER_EXTENSIONS and raw.startswith(_BOM):
846
+ raw = raw[len(_BOM):]
847
+ target.write_bytes(raw)
848
+
849
+
850
+ @contextmanager
851
+ def lint_mirror(repo_path: Path) -> Iterator[Path]:
852
+ """Yield a lintable root: the repository itself, or a BOM-normalised snapshot.
853
+
854
+ Why a snapshot: the methodology must work on BOM sources, and the client's
855
+ files must not be modified. The engine's Python/TS/Dart analyzer fails on
856
+ U+FEFF, so lint runs on a temporary copy where those BOMs are absent; JSON
857
+ paths are rebased back to the real repository before returning.
858
+ """
859
+ if not _adapter_bom_files(repo_path) and not _grace_has_bom(repo_path):
860
+ yield repo_path
861
+ return
862
+ temp_root = Path(tempfile.mkdtemp(prefix="doubled-graph-lint-"))
863
+ try:
864
+ mirror = temp_root / "repo"
865
+ mirror.mkdir()
866
+ _copy_tree_for_lint(repo_path, mirror)
867
+ yield mirror
868
+ finally:
869
+ shutil.rmtree(temp_root, ignore_errors=True)
870
+
871
+
872
+ def _rebase(value: Any, old: str, new: str) -> Any:
873
+ """Replace the temporary mirror root with the real repository path."""
874
+ if isinstance(value, str):
875
+ return value.replace(old, new)
876
+ if isinstance(value, list):
877
+ return [_rebase(item, old, new) for item in value]
878
+ if isinstance(value, dict):
879
+ return {key: _rebase(item, old, new) for key, item in value.items()}
880
+ return value
881
+
882
+
776
883
  class GraceCLI:
777
884
  """Thin subprocess wrapper around the upstream `grace` CLI (Bun, MIT).
778
885
 
@@ -914,7 +1021,7 @@ class GraceCLI:
914
1021
  "is available. Install Bun (https://bun.sh/) or tsx (`npm install -g tsx`)."
915
1022
  )
916
1023
 
917
- def _run_soft(self, args: list[str]) -> dict[str, Any]:
1024
+ def _run_soft(self, args: list[str], cwd: Path | None = None) -> dict[str, Any]:
918
1025
  """Run grace, return rc/stdout/stderr structured. Raise only on real failure.
919
1026
 
920
1027
  Soft-failure protocol (1.4.4): rc=0 = success, rc=1 = expected
@@ -935,7 +1042,7 @@ class GraceCLI:
935
1042
  cmd = self._build_cmd(args)
936
1043
  proc = subprocess.run(
937
1044
  cmd,
938
- cwd=str(self.repo_path),
1045
+ cwd=str(cwd or self.repo_path),
939
1046
  capture_output=True,
940
1047
  text=True,
941
1048
  check=False,
@@ -991,7 +1098,7 @@ class GraceCLI:
991
1098
  raise RuntimeError("\n ".join(parts))
992
1099
  return proc.stdout
993
1100
 
994
- def _json_command(self, args: list[str]) -> dict:
1101
+ def _json_command(self, args: list[str], cwd: Path | None = None) -> dict:
995
1102
  """Run a GRACE 4 command that answers with JSON (body or error envelope).
996
1103
 
997
1104
  Engine 4.x contract: success prints the payload; failure prints
@@ -1000,7 +1107,7 @@ class GraceCLI:
1000
1107
  both shapes intact and always stamp ``ok`` / ``exit_code`` so callers
1001
1108
  never have to reason about rc alone.
1002
1109
  """
1003
- soft = self._run_soft(args)
1110
+ soft = self._run_soft(args, cwd=cwd)
1004
1111
  out = soft["stdout"]
1005
1112
  err = soft["stderr"].strip()
1006
1113
  parsed: Any
@@ -1048,10 +1155,19 @@ class GraceCLI:
1048
1155
  streams ``--run-commands`` output, which is not what our CLI flag
1049
1156
  means.
1050
1157
  """
1051
- args = ["lint", "--path", str(self.repo_path), "--format", "json"]
1052
- if verbose:
1053
- args.append("--remediate")
1054
- return self._json_command(args)
1158
+ with lint_mirror(self.repo_path) as lint_root:
1159
+ # `--path .` + cwd=mirror: engine 4.2.1 reports
1160
+ # `projection.*.unindexed-document` for an absolute --path outside cwd.
1161
+ path_arg = "." if lint_root != self.repo_path else str(self.repo_path)
1162
+ args = ["lint", "--path", path_arg, "--format", "json"]
1163
+ if verbose:
1164
+ args.append("--remediate")
1165
+ # cwd matters: the engine resolves `.grace` relative to cwd, so a
1166
+ # mirror with --path alone would mix the original index with copies.
1167
+ result = self._json_command(args, cwd=lint_root)
1168
+ if lint_root != self.repo_path:
1169
+ result = _rebase(result, str(lint_root), str(self.repo_path))
1170
+ return result
1055
1171
 
1056
1172
  def module_show(self, id_or_path: str, with_verification: bool = False) -> ModuleRecord:
1057
1173
  """Fetch module record: contract + exports + deps + (opt.) V-M-* list.
@@ -7,7 +7,7 @@
7
7
  ``doubled-graph setup``.
8
8
  """
9
9
 
10
- from doubled_graph.migrations.grace3_to_grace4 import ( # noqa: F401
10
+ from doubled_graph.migrations.grace3_to_grace4 import (
11
11
  MigrationReport,
12
12
  apply_migration,
13
13
  bootstrap_project,
@@ -53,8 +53,30 @@ ROLE_MAP_MODE: dict[str, str] = {
53
53
  }
54
54
 
55
55
  _CONTEXT_MARKER = r"(?://|#|--|;+|\*)"
56
+ _BOM = b"\xef\xbb\xbf"
57
+
58
+
59
+ def _read_code_file(path: Path) -> tuple[str, bool]:
60
+ """Read a source file and report whether it carried a UTF-8 BOM.
61
+
62
+ Python's ``\\s`` does not match U+FEFF, so a BOM left in place hides
63
+ ``^\\s*// MODULE:`` from every regex here (the JS engine's ``\\s`` does
64
+ match it, so ``grace lint`` still sees the file — a silent divergence).
65
+ """
66
+ try:
67
+ raw = path.read_bytes()
68
+ except OSError:
69
+ return "", False
70
+ has_bom = raw.startswith(_BOM)
71
+ return raw.decode("utf-8-sig", errors="replace"), has_bom
72
+
73
+
74
+ def _write_code_file(path: Path, text: str, has_bom: bool) -> None:
75
+ """Write source text back, preserving the original BOM."""
76
+ path.write_text(text, encoding="utf-8-sig" if has_bom else "utf-8")
56
77
  _CONTRACT_SECTION = re.compile(
57
- rf"(?ms)^\s*{_CONTEXT_MARKER}\s*START_MODULE_CONTRACT\s*$(?P<body>.*?)^\s*{_CONTEXT_MARKER}\s*END_MODULE_CONTRACT\s*$"
78
+ # ``\ufeff?``: tolerate a BOM when the text was not read with ``utf-8-sig``.
79
+ rf"(?ms)^\ufeff?\s*{_CONTEXT_MARKER}\s*START_MODULE_CONTRACT\s*$(?P<body>.*?)^\s*{_CONTEXT_MARKER}\s*END_MODULE_CONTRACT\s*$"
58
80
  )
59
81
  _MODULE_FIELD = re.compile(rf"(?im)^(?P<indent>\s*{_CONTEXT_MARKER}\s*)MODULE:\s*(?P<value>M-[A-Za-z0-9-]+)\s*$")
60
82
  _LINKS_LINE = re.compile(rf"(?im)^(?P<prefix>\s*{_CONTEXT_MARKER}\s*)LINKS:\s*(?P<value>.*)$")
@@ -100,6 +122,12 @@ class MigrationReport:
100
122
  #: Context documents the legacy artifacts left as a skeleton (0-1 field),
101
123
  #: so the content prompt must fill them instead of trusting the migration.
102
124
  context_gaps: list[str] = field(default_factory=list)
125
+ #: Code files that start with a UTF-8 BOM. Our commands handle them, but the
126
+ #: engine's Python/TypeScript/Dart analyzer rejects U+FEFF (`ast.parse`),
127
+ #: so lint fails for these files until the BOM is removed.
128
+ bom_files: list[str] = field(default_factory=list)
129
+ #: Subset of ``bom_files`` in languages the engine analyzes with an adapter.
130
+ bom_adapter_files: list[str] = field(default_factory=list)
103
131
  modules: int = 0
104
132
  verifications: int = 0
105
133
  placeholders: list[str] = field(default_factory=list)
@@ -132,6 +160,8 @@ class MigrationReport:
132
160
  "markup_pending": list(self.markup_pending),
133
161
  "map_pending": list(self.map_pending),
134
162
  "context_gaps": list(self.context_gaps),
163
+ "bom_files": list(self.bom_files),
164
+ "bom_adapter_files": list(self.bom_adapter_files),
135
165
  "modules": self.modules,
136
166
  "verifications": self.verifications,
137
167
  "placeholders": list(self.placeholders),
@@ -194,7 +224,9 @@ def check_migration(root: Path) -> MigrationReport:
194
224
  if report.markup_pending:
195
225
  report.issues.append(Issue("warning", "migration.markup-pending", ".",
196
226
  f"{len(report.markup_pending)} governed file(s) declare no M-* in MODULE_CONTRACT LINKS."))
227
+ report.bom_files, report.bom_adapter_files = _bom_scan(resolved)
197
228
  _add_context_gap_warning(report)
229
+ _add_bom_warning(report)
198
230
  report.ok = not report.errors
199
231
  return report
200
232
 
@@ -239,7 +271,9 @@ def apply_migration(
239
271
 
240
272
  report.map_pending = _plan_map_work(resolved)
241
273
  report.context_gaps = _context_gaps(resolved)
274
+ report.bom_files, report.bom_adapter_files = _bom_scan(resolved)
242
275
  _add_context_gap_warning(report)
276
+ _add_bom_warning(report)
243
277
 
244
278
  if fix_markup:
245
279
  changed, pending = _apply_markup_fix(resolved, dry_run=dry_run)
@@ -424,7 +458,7 @@ def _scan_unsupported(root: Path) -> list[str]:
424
458
  if docs.is_dir():
425
459
  for path in sorted(docs.glob("*.xml")):
426
460
  try:
427
- texts[str(path.relative_to(root))] = path.read_text(encoding="utf-8", errors="replace")
461
+ texts[str(path.relative_to(root))] = _read_code_file(path)[0]
428
462
  except OSError:
429
463
  continue
430
464
  found: list[str] = []
@@ -666,7 +700,7 @@ def _context_values(root: Path) -> tuple[dict[str, str], dict[str, int]]:
666
700
  grace_tech = root / ".grace" / "context" / "technology.xml"
667
701
  grace_req = root / ".grace" / "context" / "requirements.xml"
668
702
  if grace_tech.exists():
669
- text = grace_tech.read_text(encoding="utf-8", errors="replace")
703
+ text = grace_tech.read_text(encoding="utf-8-sig", errors="replace")
670
704
  technology = {
671
705
  field: value
672
706
  for field, _aliases in _TECHNOLOGY_FIELDS
@@ -680,7 +714,7 @@ def _context_values(root: Path) -> tuple[dict[str, str], dict[str, int]]:
680
714
  }
681
715
 
682
716
  if grace_req.exists():
683
- text = grace_req.read_text(encoding="utf-8", errors="replace")
717
+ text = grace_req.read_text(encoding="utf-8-sig", errors="replace")
684
718
  requirements = {
685
719
  "project": _legacy_value(text, "ProjectName") if not is_placeholder(_legacy_value(text, "ProjectName")) else None,
686
720
  "goals": [value for value in _list_values(text, "Goal") if not is_placeholder(value)],
@@ -742,6 +776,47 @@ def _context_gaps(root: Path) -> list[str]:
742
776
  return gaps
743
777
 
744
778
 
779
+ #: Source languages the engine analyzes with its own adapters; their analyzers
780
+ #: (Python `ast.parse`, TypeScript/Dart tooling) reject a leading U+FEFF.
781
+ _ADAPTER_EXTENSIONS = frozenset({".py", ".pyi", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".dart"})
782
+
783
+ _BOM_BYTES = b"\xef\xbb\xbf"
784
+
785
+
786
+ def _bom_scan(root: Path) -> tuple[list[str], list[str]]:
787
+ """Return (all BOM files, BOM files whose language has an engine adapter)."""
788
+ all_bom: list[str] = []
789
+ adapter_bom: list[str] = []
790
+ for file_path in iter_code_files(root, ignored=(".doubled-graph",)):
791
+ try:
792
+ with file_path.open("rb") as handle:
793
+ prefix = handle.read(3)
794
+ except OSError:
795
+ continue
796
+ if prefix != _BOM_BYTES:
797
+ continue
798
+ relative = file_path.relative_to(root).as_posix()
799
+ all_bom.append(relative)
800
+ if file_path.suffix.lower() in _ADAPTER_EXTENSIONS:
801
+ adapter_bom.append(relative)
802
+ return sorted(all_bom), sorted(adapter_bom)
803
+
804
+
805
+ def _add_bom_warning(report: MigrationReport) -> None:
806
+ if not report.bom_adapter_files:
807
+ return
808
+ report.issues.append(Issue(
809
+ "warning",
810
+ "migration.bom-in-code",
811
+ report.bom_adapter_files[0],
812
+ f"{len(report.bom_adapter_files)} source file(s) start with a UTF-8 BOM; the engine's "
813
+ "Python/TypeScript/Dart analyzer rejects U+FEFF, so `grace lint` reports "
814
+ "`analysis.adapter-failed` for them until the BOM is removed. "
815
+ "doubled-graph commands handle BOM files themselves; languages without an adapter "
816
+ "(C#, Go, Rust, Java, Swift, …) are unaffected.",
817
+ ))
818
+
819
+
745
820
  def _add_context_gap_warning(report: MigrationReport) -> None:
746
821
  """Warn only when the legacy context documents were a skeleton (0-1 field).
747
822
 
@@ -1002,7 +1077,7 @@ def _parse_xml(path: Path):
1002
1077
  import xml.etree.ElementTree as ET
1003
1078
 
1004
1079
  try:
1005
- raw = path.read_text(encoding="utf-8", errors="replace")
1080
+ raw = path.read_text(encoding="utf-8-sig", errors="replace")
1006
1081
  except OSError:
1007
1082
  return None
1008
1083
  try:
@@ -1060,9 +1135,8 @@ def _plan_markup_fix(root: Path) -> list[tuple[str, str]]:
1060
1135
  """Return ``(relative_path, module_id)`` for files whose LINKS lack M-*."""
1061
1136
  planned: list[tuple[str, str]] = []
1062
1137
  for file_path in iter_code_files(root, ignored=(".doubled-graph",)):
1063
- try:
1064
- text = file_path.read_text(encoding="utf-8", errors="replace")
1065
- except OSError:
1138
+ text, _has_bom = _read_code_file(file_path)
1139
+ if not text:
1066
1140
  continue
1067
1141
  section = _CONTRACT_SECTION.search(text)
1068
1142
  if section is None:
@@ -1091,9 +1165,8 @@ def _plan_map_work(root: Path) -> list[str]:
1091
1165
  """
1092
1166
  pending: list[str] = []
1093
1167
  for file_path in iter_code_files(root, ignored=(".doubled-graph",)):
1094
- try:
1095
- text = file_path.read_text(encoding="utf-8", errors="replace")
1096
- except OSError:
1168
+ text, _has_bom = _read_code_file(file_path)
1169
+ if not text:
1097
1170
  continue
1098
1171
  section = _CONTRACT_SECTION.search(text)
1099
1172
  if section is None:
@@ -1119,15 +1192,14 @@ def _apply_markup_fix(root: Path, *, dry_run: bool) -> tuple[list[str], list[str
1119
1192
  if dry_run:
1120
1193
  continue
1121
1194
  path = root / relative
1122
- try:
1123
- text = path.read_text(encoding="utf-8")
1124
- except OSError:
1195
+ text, has_bom = _read_code_file(path)
1196
+ if not text:
1125
1197
  continue
1126
1198
  new_text = _fix_file_markup(text, module_id)
1127
1199
  if new_text == text:
1128
1200
  continue
1129
1201
  try:
1130
- path.write_text(new_text, encoding="utf-8")
1202
+ _write_code_file(path, new_text, has_bom)
1131
1203
  except OSError:
1132
1204
  continue
1133
1205
  changed.append(relative)