doubled-graph 2.0.0__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.0 → doubled_graph-2.0.2}/PKG-INFO +2 -2
  2. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/README.md +1 -1
  3. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/docs/CHANGELOG.md +98 -0
  4. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/docs/TOOLS.md +3 -3
  5. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/upgrade-methodology/00-entry-prompt.md +2 -0
  6. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/upgrade-methodology/01-content-gaps.md +84 -1
  7. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/pyproject.toml +1 -1
  8. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/cli.py +1 -1
  9. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/config.py +3 -3
  10. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/graphs/declared.py +67 -8
  11. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/graphs/grace4.py +8 -3
  12. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/hooks_installer.py +18 -5
  13. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/integrations/grace_cli.py +125 -9
  14. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/migrations/__init__.py +1 -1
  15. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/migrations/grace3_to_grace4.py +443 -59
  16. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/policy/phase.py +6 -3
  17. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/server.py +1 -1
  18. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/setup.py +25 -6
  19. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/tools/context.py +4 -4
  20. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/tools/detect_changes.py +1 -1
  21. doubled_graph-2.0.2/tests/test_bom_support.py +204 -0
  22. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/tests/test_grace4_artifacts.py +16 -0
  23. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/tests/test_grace_cli_engine.py +1 -1
  24. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/tests/test_migration_grace3_to_grace4.py +306 -0
  25. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/.gitignore +0 -0
  26. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/ARTICLE.md +0 -0
  27. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/INDEX.md +0 -0
  28. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/LICENSE +0 -0
  29. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/docs/HOOKS.md +0 -0
  30. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/docs/SPEC.md +0 -0
  31. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/for-ai-existing-project/README.md +0 -0
  32. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/for-ai-maintenance/README.md +0 -0
  33. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/for-ai-new-project/README.md +0 -0
  34. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/for-humans/FAQ.md +0 -0
  35. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/for-humans/deep-dive.md +0 -0
  36. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/for-humans/getting-started.md +0 -0
  37. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/README.md +0 -0
  38. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/approval-checkpoints.md +0 -0
  39. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/artifacts.md +0 -0
  40. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/auto-scaling.md +0 -0
  41. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/drift-and-priority.md +0 -0
  42. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/language-adapters/_template.md +0 -0
  43. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/language-adapters/csharp.md +0 -0
  44. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/language-adapters/go.md +0 -0
  45. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/language-adapters/java.md +0 -0
  46. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/language-adapters/python.md +0 -0
  47. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/language-adapters/rust.md +0 -0
  48. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/language-adapters/swift.md +0 -0
  49. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/language-adapters/typescript.md +0 -0
  50. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/principles.md +0 -0
  51. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/roles.md +0 -0
  52. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/runtime-adapters/_template.md +0 -0
  53. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/runtime-adapters/claude-code.md +0 -0
  54. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/runtime-adapters/codex.md +0 -0
  55. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/runtime-adapters/continue.md +0 -0
  56. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/runtime-adapters/cursor.md +0 -0
  57. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/runtime-adapters/local-llm.md +0 -0
  58. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/runtime-adapters/windsurf.md +0 -0
  59. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/tools.md +0 -0
  60. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/methodology/workflow.md +0 -0
  61. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/maintenance/on-after-edit.md +0 -0
  62. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/maintenance/on-before-edit.md +0 -0
  63. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/maintenance/on-drift-detected.md +0 -0
  64. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/maintenance/on-user-manual-edit.md +0 -0
  65. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/migrate-existing-project/00-entry-prompt.md +0 -0
  66. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/migrate-existing-project/01-discover.md +0 -0
  67. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/migrate-existing-project/02-reconstruct-intent.md +0 -0
  68. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/migrate-existing-project/03-draft-plan.md +0 -0
  69. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/migrate-existing-project/04-markup-codebase.md +0 -0
  70. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/migrate-existing-project/05-verification-and-logs.md +0 -0
  71. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/migrate-existing-project/06-validation-gates.md +0 -0
  72. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/new-project/00-entry-prompt.md +0 -0
  73. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/new-project/01-intent-interview.md +0 -0
  74. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/new-project/02-requirements.md +0 -0
  75. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/new-project/03-technology.md +0 -0
  76. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/new-project/04-development-plan.md +0 -0
  77. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/new-project/05-verification-plan.md +0 -0
  78. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/new-project/06-generate.md +0 -0
  79. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/prompts/new-project/07-post-init-sync.md +0 -0
  80. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/__init__.py +0 -0
  81. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/__main__.py +0 -0
  82. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-after-edit/SKILL.md +0 -0
  83. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-before-edit/SKILL.md +0 -0
  84. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-drift/SKILL.md +0 -0
  85. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-guide/SKILL.md +0 -0
  86. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-manual-edit/SKILL.md +0 -0
  87. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-refactor/SKILL.md +0 -0
  88. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-verify/SKILL.md +0 -0
  89. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/graphs/__init__.py +0 -0
  90. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/graphs/crossref.py +0 -0
  91. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/integrations/__init__.py +0 -0
  92. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/integrations/cgc.py +0 -0
  93. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/policy/__init__.py +0 -0
  94. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/storage/__init__.py +0 -0
  95. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/storage/paths.py +0 -0
  96. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/tools/__init__.py +0 -0
  97. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/tools/_meta.py +0 -0
  98. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/tools/analyze.py +0 -0
  99. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/src/doubled_graph/tools/impact.py +0 -0
  100. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/tests/__init__.py +0 -0
  101. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/tests/test_cgc_contract.py +0 -0
  102. {doubled_graph-2.0.0 → doubled_graph-2.0.2}/tests/test_setup_artifacts.py +0 -0
  103. {doubled_graph-2.0.0 → 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.0
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.0
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.0
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,104 @@
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.
42
+ ## [2.0.1] — 2026-10-23
43
+
44
+ Migration-fidelity fixes on top of 2.0.0 (already published). No artifact-format
45
+ change: upgrade is additive, `pipx upgrade doubled-graph` and a re-run of
46
+ `doubled-graph setup` (or `migrate apply` on an unmigrated repo) is enough.
47
+
48
+ **Why:** 2.0.0 could migrate a real GRACE 3 repository into a project where
49
+ `grace lint` is green but the context documents are empty (`unknown`,
50
+ `Migrated project`, one generic goal) and `TestingStack` cannot produce a
51
+ `<Command>` for `V-<M>`. The checks below make that state visible and prevent
52
+ silent data loss during the copy.
53
+
54
+ ### Fixed — legacy format edge cases (all lose data silently if missed)
55
+ - Namespaced legacy XML: tags arrive as `{urn}M-AUTH`, every `^M-` match failed
56
+ and a project migrated with **zero modules**. Namespaces are stripped before
57
+ matching (elements and attributes).
58
+ - UTF-16/UTF-32/BOM legacy documents: `read_text("utf-8")` raised
59
+ `UnicodeDecodeError`; reads now try `utf-8-sig` → `utf-16` → `utf-32` → `cp1251`.
60
+ - `CDATA` values (`<Language><![CDATA[C#]]></Language>`) were stripped to empty;
61
+ `CDATA` is unwrapped before tag removal.
62
+ - Module `NAME` / `STATUS` / `TYPE` attributes were dropped (the migrator wrote
63
+ `Name` from the id and `Status=existing`); they are preserved (`Type` as a
64
+ free child tag, which GRACE 4 does not validate).
65
+ - Several GRACE 3 verification records for one module (`V-M-AUTH-1`, `-2`, …)
66
+ collapsed into one canonical `V-M-AUTH` and only the first record's evidence
67
+ survived; commands, scenarios, markers, trace assertions and test files are
68
+ now merged.
69
+ - Lowercase/mixed-case contract keys (`role:`, `map_mode:`, `links:`) are
70
+ silently ignored by the engine's field parser and produced
71
+ `markup.invalid-role`; the markup repair normalises keys to UPPERCASE and
72
+ ROLE values to upper case.
73
+ - Windows-style paths (`src\\auth\\a.py`) are normalised to forward slashes, so
74
+ `LINKS`-based file-to-module linkage matches.
75
+ - Self-closing `<UC-1 ... />` entries were skipped by the use-case extractor;
76
+ they are now rendered as goals.
77
+ - `migration.context-skeleton` was emitted even when the repository has no
78
+ artifacts at all (kind `none`); the warning is now gated to migrated projects.
79
+
80
+ ### Fixed — context documents are copied, not skeletonised
81
+ - The migrator now **copies** the legacy context documents instead of shipping a
82
+ skeleton: `technology.xml` fields (`Language`, `Runtime`, `Framework`,
83
+ `TestingStack`, `Observability`) and `requirements.xml` sections
84
+ (`ProjectName`, `Goals` from `UC-*` as text, `Users` from `ACTOR`, `Constraints`)
85
+ are taken from the old document; undeclared fields are marked
86
+ `not specified` (never guessed), and `LegacySource` keeps the archive copy.
87
+ - New `context_gaps` in the migration report plus `migration.context-skeleton`
88
+ warning. The check is gated: it fires when the copied documents are a skeleton
89
+ (0–1 real field) **or** when `TestingStack` is still a placeholder — a richer
90
+ document is trusted as-is. It reads the current `.grace/context`, not the
91
+ legacy documents (which stay on disk forever), so a completed migration
92
+ reports no gaps. Recognised placeholders include the old migrator/bootstrap
93
+ prose (`unknown`, `Migrated project`, `Keep the project governed…`,
94
+ `Describe the primary…`, `Restore the product intent…`), and the bootstrap
95
+ path now writes `not specified` instead of `unknown`.
96
+ - `01-content-gaps.md` gained **Шаг 0 «context-документы»** (before
97
+ `MODULE_MAP`) and a mandatory **Шаг 4a «context gate»**: a `grep` over
98
+ `.grace/context/*.xml` for placeholders plus `context_gaps == []` from
99
+ `migrate check`, with `deployment`/`ux` `not-applicable` explicitly allowed.
100
+ `<Command>` for `V-<M>` must not be derived while `TestingStack` is a
101
+ placeholder.
102
+
5
103
  ## [2.0.0] — 2026-10-23
6
104
 
7
105
  **Breaking: GRACE 4.** The methodology now runs on the `.grace` artifact model
@@ -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.0", "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.0", "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.0", "...": "..."}
235
+ "meta": {"phase": "...", "tool_version": "2.0.2", "...": "..."}
236
236
  }
237
237
  ```
238
238
 
@@ -56,6 +56,8 @@ doubled-graph lint --repo . # summary.errors
56
56
  prompts/upgrade-methodology/01-content-gaps.md
57
57
  ```
58
58
 
59
+ Первым его шагом идёт **Шаг 0 — context-документы**: если `migrate check` показывает `context_gaps` (legacy объявил 0–1 поле), ИИ заполняет `technology.xml` (в первую очередь `TestingStack`) и `requirements.xml` из `<LegacySource>` / манифестов проекта, иначе `<Command>` для `V-<M>` вывести не из чего.
60
+
59
61
  Он детерминированно проходит `map_pending` (карты модулей), `placeholders` (пустые `V-<M>`), `markup_pending` (`LINKS`) и заканчивается финальными CLI-проверками. Выдуманные команды запрещены: если тест не найден или не проходит — запись остаётся пустой, ставится `Priority: low` и строка в `docs/DRIFT.md`.
60
62
 
61
63
  6. Вернуться к обычному циклу: `prompts/maintenance/on-before-edit.md` / `on-after-edit.md`. Legacy `docs/` можно заархивировать отдельным решением (бэкап + approval), это не обязательный шаг.
@@ -23,6 +23,35 @@ doubled-graph health --repo . # модули без verification
23
23
 
24
24
  ---
25
25
 
26
+ ## Шаг 0 — context-документы (до всего остального)
27
+
28
+ Мигратор копирует старые `requirements.xml`/`technology.xml` в `.grace/context/`, но если legacy объявлял 0–1 поле, документ остаётся скелетом: `migrate check` показывает `context_gaps` и warning `migration.context-skeleton`, а поля помечены `not specified`. Пока это не заполнено, из `technology.xml` нельзя вывести `<Command>` для `V-<M>` (шаг 2) — поэтому шаг 0 обязателен и идёт первым.
29
+
30
+ 1. Проверь маркеры командой:
31
+
32
+ ```bash
33
+ grep -n "not specified\|unknown\|Migrated project" .grace/context/*.xml
34
+ ```
35
+
36
+ 2. Заполняй поля в этом порядке (первый источник, который даёт факт, — истина):
37
+
38
+ | Поле | Источник 1 | Источник 2 | Источник 3 |
39
+ |---|---|---|---|
40
+ | `Language`, `Runtime`, `Framework`, `TestingStack`, `Observability` (`technology.xml`) | legacy-значения внутри `<LegacySource>` (там лежит старый `docs/technology.xml` целиком) | манифесты проекта: `*.csproj`/`*.sln`, `package.json`, `pyproject.toml`/`pytest.ini`, `go.mod`, `Cargo.toml`, `pom.xml`/`build.gradle`, `Gemfile`, `composer.json`, `Package.swift`, `mix.exs`, `CMakeLists.txt`/`Makefile` — найденные `find . -maxdepth 3 -name '<manifest>'` | README / CI (`.github/workflows/*`, `.gitlab-ci.yml`) |
41
+ | `ProjectName`, `Goals`, `Users`, `Constraints` (`requirements.xml`) | legacy `<LegacySource>`: `UC-*` → `Goal` текстом, `ACTOR`/`User` → `Users`, `Constraint`/`NFR` → `Constraints` | README (первый абзац/раздел «О проекте») и существующие issue/ADR | — |
42
+ | `deployment.xml` | манифесты инфраструктуры: `Dockerfile`, `docker-compose*.yml`, `k8s/*.yaml`, `helm/`, `Chart.yaml`, `Procfile`, `fly.toml`, `*.tf`, `serverless.yml`, `vercel.json` | README/CI deploy-шаг | `not-applicable` + `<Reason>infrastructure manifests not found</Reason>` |
43
+ | `ux-guidelines.xml` | манифесты фронтенда: `index.html`, `*.xaml`/`*.razor`/`*.vue`/`*.svelte`, `package.json` с react/vue/angular/svelte | README/скриншоты | `not-applicable` + `<Reason>frontend manifests not found</Reason>` |
44
+
45
+ 3. Правила:
46
+ - значения **копируются** из legacy/манифестов, не придумываются; язык — по манифестам проекта, а не по типу задачи;
47
+ - `TestingStack` — команда раннера из манифеста (`package.json → scripts.test`, `pyproject/pytest.ini → pytest`, `*proj/*sln → dotnet test`, `go.mod → go test ./...`, `Cargo.toml → cargo test`, `pom.xml → mvn test`, `build.gradle → ./gradlew test`, `Gemfile → bundle exec rspec`, `composer.json → phpunit`, `Package.swift → swift test`, `mix.exs → mix test`, `CMakeLists.txt/Makefile → ctest/make test`);
48
+ - если факт не найден ни в одном источнике — поле остаётся `not specified`, строка в `docs/DRIFT.md`; шаг 2 в этом случае не выводит `Command` для `V-<M>` (модуль получает `Priority: low`, как в fallback шага 2);
49
+ - `deployment`/`ux` как `not-applicable` с причиной — законное состояние, а не пробел.
50
+
51
+ 4. Проверь: `doubled-graph lint --repo .` (0 errors) и `doubled-graph migrate check --repo .` (`context_gaps == []`).
52
+
53
+ ---
54
+
26
55
  ## Шаг 1 — `MODULE_MAP` (устраняет `markup.module-map-missing`)
27
56
 
28
57
  Для каждого пути из `map_pending`, по порядку:
@@ -155,6 +184,41 @@ doubled-graph module health <M-*> --repo . # критичные моду
155
184
 
156
185
  ---
157
186
 
187
+ ## Шаг 4a — context gate: в context нет заглушек
188
+
189
+ Обязательная проверка перед отчётом: `context/*.xml` не должны содержать ни `unknown`, ни шаблонных фраз мигратора/бутстрапа. Проверяется командой, а не «на глаз»:
190
+
191
+ ```bash
192
+ grep -nE "not specified|unknown|Migrated project|Describe the primary|replace with the real product statement|Keep the project governed|Existing project maintainers|Legacy GRACE 3 documents stay in place|No operational constraints were declared|Users were not described|Restore the product intent|Not configured yet" .grace/context/*.xml
193
+ doubled-graph migrate check --repo . # context_gaps должен быть []
194
+ ```
195
+
196
+ Что означает каждая находка:
197
+
198
+ | Находка | Действие |
199
+ |---|---|
200
+ | `technology: … TestingStack is still a placeholder` | заполнить `TestingStack` из `<LegacySource>` или манифеста (шаг 0), иначе `<Command>` для `V-<M>` не выводится |
201
+ | `technology: 0–1/5 real field(s)` | заполнить Language/Runtime/Framework/Observability по шагу 0 |
202
+ | `requirements: … real section(s)` / `Goals are still a placeholder` | перенести `UC-*`/README-цели в `<Goals>` (шаг 0) |
203
+ | `deployment`/`ux` с `not-applicable` + `<Reason>` | не находка: это законное состояние, пропустить |
204
+
205
+ Если после заполнения `context_gaps` пуст, а `grep` всё ещё что-то находит — это строка внутри `<LegacySource>` (архив), её не трогай: проверяй только рабочие секции, не архив.
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
+
220
+ ---
221
+
158
222
  ## Шаг 5 — отчёт пользователю (формат фиксированный)
159
223
 
160
224
  ```
@@ -173,7 +237,8 @@ health: модулей без verification=<N>
173
237
  | Проверка | Готово, когда |
174
238
  |---|---|
175
239
  | `doubled-graph lint` | `summary.errors == 0` |
176
- | `doubled-graph migrate check` | `map_pending == []`, `placeholders == []` |
240
+ | `doubled-graph migrate check` | `map_pending == []`, `placeholders == []`, `context_gaps == []` |
241
+ | `grep -nE "not specified\|unknown\|Migrated project\|Describe the primary\|Keep the project governed" .grace/context/*.xml` | пусто вне `<LegacySource>`, кроме `deployment`/`ux` с `not-applicable` и `<Reason>` |
177
242
  | `doubled-graph detect-changes --scope all` | `missing_verification` только у записей с `DRIFT.md` |
178
243
  | `doubled-graph health` | нет модулей без `V-<M>` |
179
244
  | `module health` критичных модулей | не `blocked` |
@@ -203,6 +268,19 @@ health: модулей без verification=<N>
203
268
  doubled-graph health --repo .
204
269
  Работай по спискам map_pending / placeholders / markup_pending из вывода.
205
270
 
271
+ Шаг 0. Context-документы (обязательно до MODULE_MAP и V-<M>):
272
+ - проверь маркеры: grep -n "not specified\|unknown\|Migrated project" .grace/context/*.xml
273
+ - technology.xml: Language/Runtime/Framework/TestingStack/Observability — из <LegacySource> (старый documents/technology.xml),
274
+ затем из манифестов проекта (find . -maxdepth 3: *.csproj/*.sln, package.json, pyproject.toml/pytest.ini, go.mod,
275
+ Cargo.toml, pom.xml/build.gradle, Gemfile, composer.json, Package.swift, mix.exs, CMakeLists.txt/Makefile), затем README/CI;
276
+ TestingStack = команда раннера из манифеста (не догадка);
277
+ - requirements.xml: ProjectName/Goals/Users/Constraints — из <LegacySource> (UC-* -> Goal текстом, ACTOR/User -> Users,
278
+ Constraint/NFR -> Constraints), затем README;
279
+ - deployment.xml/ux-guidelines.xml: по инфраструктурным/фронтенд-манифестам; если их нет — <Applicability>not-applicable</Applicability>
280
+ + <Reason>;
281
+ - если факт не найден — поле оставь "not specified" и добавь строку в docs/DRIFT.md; НЕ выдумывай стек.
282
+ - проверь: doubled-graph migrate check --repo . -> context_gaps = []
283
+
206
284
  Шаг 1. MODULE_MAP для каждого пути из map_pending:
207
285
  - прочитай контракт: doubled-graph file show <path> --repo .;
208
286
  - извлеки публичные символы командой `grep` по правилам для языка:
@@ -250,6 +328,11 @@ health: модулей без verification=<N>
250
328
  Если lint вернул project.grace3-detected — выполни doubled-graph migrate apply --repo . и повтори шаги 1–3.
251
329
  Если grace_engine.upgrade_required = true — npm install -g @osovv/grace-cli@4.2.1 и повтори setup.
252
330
 
331
+ Шаг 4a. Context gate (обязательно):
332
+ grep -nE "not specified|unknown|Migrated project|Describe the primary|replace with the real product statement|Keep the project governed|Existing project maintainers|Legacy GRACE 3 documents stay in place|No operational constraints were declared|Users were not described|Restore the product intent|Not configured yet" .grace/context/*.xml
333
+ doubled-graph migrate check --repo . (context_gaps = [])
334
+ Находки вне <LegacySource> исправляй по шагу 0; TestingStack-заглушка блокирует шаг 2.
335
+
253
336
  Шаг 5. Отчёт в фиксированном формате:
254
337
  MODULE_MAP: <N дополнено, K NONE/CONFIG>
255
338
  V-<M>: <N заполнено и прогоняется, K без команды>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "doubled-graph"
7
- version = "2.0.0"
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
 
@@ -61,6 +61,11 @@ class DeclaredModule:
61
61
  paths: list[str] = field(default_factory=list)
62
62
  exports: list[str] = field(default_factory=list)
63
63
  crosslinks: list[str] = field(default_factory=list)
64
+ #: GRACE 3 declared these as attributes; losing them silently rewrites
65
+ #: module identity/status during migration, so keep them.
66
+ name: str = ""
67
+ status: str = ""
68
+ type: str = ""
64
69
 
65
70
 
66
71
  @dataclass
@@ -165,20 +170,66 @@ def _load_grace4(root: Path, scan_files: bool) -> DeclaredGraph:
165
170
  # ---------------------------------------------------------------------------
166
171
 
167
172
 
173
+ def _read_xml_text(path: Path) -> str:
174
+ """Read a legacy XML file whatever its encoding (UTF-8/UTF-16/BOM).
175
+
176
+ GRACE 3 projects were written by whatever editor the team used; a UTF-16
177
+ document is legal XML and must not abort a migration with a traceback.
178
+ """
179
+ raw = path.read_bytes()
180
+ for encoding in ("utf-8-sig", "utf-16", "utf-32", "cp1251"):
181
+ try:
182
+ return raw.decode(encoding)
183
+ except (UnicodeDecodeError, UnicodeError, LookupError):
184
+ continue
185
+ return raw.decode("utf-8", errors="replace")
186
+
187
+
188
+ def _strip_namespaces(root: ET.Element) -> ET.Element:
189
+ """Drop ``{namespace}`` prefixes so tag matching works on namespaced XML.
190
+
191
+ Legacy documents occasionally declare a default ``xmlns``; ElementTree then
192
+ reports tags as ``{urn}M-AUTH`` and every ``^M-`` regex misses, which
193
+ silently produced a zero-module migration.
194
+ """
195
+ for element in root.iter():
196
+ if isinstance(element.tag, str) and "}" in element.tag:
197
+ element.tag = element.tag.rsplit("}", 1)[-1]
198
+ for key in list(element.attrib):
199
+ if "}" in key:
200
+ element.attrib[key.rsplit("}", 1)[-1]] = element.attrib.pop(key)
201
+ return root
202
+
203
+
204
+ def _attribute(element: ET.Element, *names: str) -> str:
205
+ """Attribute value by any of ``names``, case-insensitively."""
206
+ for key, value in element.attrib.items():
207
+ if key.lower() in {name.lower() for name in names}:
208
+ text = (value or "").strip()
209
+ if text:
210
+ return text
211
+ return ""
212
+
213
+
214
+ def _normalize_path(value: str) -> str:
215
+ """Store paths in a single portable form (forward slashes)."""
216
+ return value.replace("\\", "/").strip()
217
+
218
+
168
219
  def _safe_parse(path: Path) -> tuple[ET.Element | None, str]:
169
220
  """Parse XML, escaping bare ``&`` on retry. Returns (root, error_msg)."""
170
221
  if not path.exists():
171
222
  return None, ""
172
- raw = path.read_text(encoding="utf-8")
223
+ raw = _read_xml_text(path)
173
224
  try:
174
- return ET.fromstring(raw), ""
225
+ return _strip_namespaces(ET.fromstring(raw)), ""
175
226
  except ET.ParseError:
176
227
  pass
177
228
  sanitized = _BARE_AMP.sub("&amp;", raw)
178
229
  if sanitized == raw:
179
230
  return None, f"XML parse error in {path.name}"
180
231
  try:
181
- return ET.fromstring(sanitized), ""
232
+ return _strip_namespaces(ET.fromstring(sanitized)), ""
182
233
  except ET.ParseError as exc:
183
234
  return None, f"XML parse error in {path.name}: {exc}"
184
235
 
@@ -215,12 +266,16 @@ def _merge_dev_plan_module(graph: DeclaredGraph, mod: ET.Element) -> None:
215
266
  if purpose and not existing.purpose:
216
267
  existing.purpose = purpose
217
268
 
269
+ existing.name = existing.name or _attribute(mod, "NAME", "Name")
270
+ existing.status = existing.status or _attribute(mod, "STATUS", "Status")
271
+ existing.type = existing.type or _attribute(mod, "TYPE", "Type")
272
+
218
273
  for src in mod.findall("target/source"):
219
274
  if src.text:
220
- _append_unique(existing.paths, src.text.strip())
275
+ _append_unique(existing.paths, _normalize_path(src.text))
221
276
  for tst in mod.findall("target/tests"):
222
277
  if tst.text:
223
- _append_unique(existing.paths, tst.text.strip())
278
+ _append_unique(existing.paths, _normalize_path(tst.text))
224
279
 
225
280
  for el in mod.findall("interface/*"):
226
281
  name = _export_name(el.tag or "")
@@ -238,9 +293,13 @@ def _merge_kg_module(graph: DeclaredGraph, mod: ET.Element) -> None:
238
293
  if purpose and not existing.purpose:
239
294
  existing.purpose = purpose
240
295
 
296
+ existing.name = existing.name or _attribute(mod, "NAME", "Name")
297
+ existing.status = existing.status or _attribute(mod, "STATUS", "Status")
298
+ existing.type = existing.type or _attribute(mod, "TYPE", "Type")
299
+
241
300
  for el in mod.findall("path"):
242
301
  if el.text:
243
- _append_unique(existing.paths, el.text.strip())
302
+ _append_unique(existing.paths, _normalize_path(el.text))
244
303
 
245
304
  for el in mod.findall("annotations/*"):
246
305
  name = _export_name(el.tag or "")
@@ -282,8 +341,8 @@ def _load_legacy(repo_path: Path) -> DeclaredGraph:
282
341
  for mod in _iter_module_tags(root):
283
342
  _merge_kg_module(graph, mod)
284
343
  for cl in root.iter("CrossLink"):
285
- src_id = cl.get("from") or ""
286
- tgt_id = cl.get("to") or ""
344
+ src_id = _attribute(cl, "from", "source", "src")
345
+ tgt_id = _attribute(cl, "to", "target", "dst")
287
346
  if not src_id or not tgt_id:
288
347
  continue
289
348
  mod = graph.modules.get(src_id)
@@ -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")