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.
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/PKG-INFO +2 -2
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/README.md +1 -1
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/docs/CHANGELOG.md +37 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/docs/TOOLS.md +3 -3
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/upgrade-methodology/01-content-gaps.md +13 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/pyproject.toml +1 -1
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/cli.py +1 -1
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/config.py +3 -3
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/graphs/grace4.py +8 -3
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/hooks_installer.py +18 -5
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/integrations/grace_cli.py +125 -9
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/migrations/__init__.py +1 -1
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/migrations/grace3_to_grace4.py +87 -15
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/policy/phase.py +6 -3
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/server.py +1 -1
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/setup.py +25 -6
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/context.py +4 -4
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/detect_changes.py +1 -1
- doubled_graph-2.0.2/tests/test_bom_support.py +204 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_grace4_artifacts.py +16 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_grace_cli_engine.py +1 -1
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_migration_grace3_to_grace4.py +21 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/.gitignore +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/ARTICLE.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/INDEX.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/LICENSE +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/docs/HOOKS.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/docs/SPEC.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-ai-existing-project/README.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-ai-maintenance/README.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-ai-new-project/README.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-humans/FAQ.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-humans/deep-dive.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/for-humans/getting-started.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/README.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/approval-checkpoints.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/artifacts.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/auto-scaling.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/drift-and-priority.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/_template.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/csharp.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/go.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/java.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/python.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/rust.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/swift.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/language-adapters/typescript.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/principles.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/roles.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/_template.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/claude-code.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/codex.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/continue.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/cursor.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/local-llm.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/runtime-adapters/windsurf.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/tools.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/methodology/workflow.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/maintenance/on-after-edit.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/maintenance/on-before-edit.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/maintenance/on-drift-detected.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/maintenance/on-user-manual-edit.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/00-entry-prompt.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/01-discover.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/02-reconstruct-intent.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/03-draft-plan.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/04-markup-codebase.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/05-verification-and-logs.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/migrate-existing-project/06-validation-gates.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/00-entry-prompt.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/01-intent-interview.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/02-requirements.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/03-technology.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/04-development-plan.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/05-verification-plan.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/06-generate.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/new-project/07-post-init-sync.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/prompts/upgrade-methodology/00-entry-prompt.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/__init__.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/__main__.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-after-edit/SKILL.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-before-edit/SKILL.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-drift/SKILL.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-guide/SKILL.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-manual-edit/SKILL.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-refactor/SKILL.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/data/skills/doubled-graph-verify/SKILL.md +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/graphs/__init__.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/graphs/crossref.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/graphs/declared.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/integrations/__init__.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/integrations/cgc.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/policy/__init__.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/storage/__init__.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/storage/paths.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/__init__.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/_meta.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/analyze.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/tools/impact.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/__init__.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_cgc_contract.py +0 -0
- {doubled_graph-2.0.1 → doubled_graph-2.0.2}/tests/test_setup_artifacts.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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.
|
{doubled_graph-2.0.1 → doubled_graph-2.0.2}/src/doubled_graph/migrations/grace3_to_grace4.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
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
|
|
1202
|
+
_write_code_file(path, new_text, has_bom)
|
|
1131
1203
|
except OSError:
|
|
1132
1204
|
continue
|
|
1133
1205
|
changed.append(relative)
|