guarantee-based-coding 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl

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 (39) hide show
  1. gbc/__init__.py +1 -1
  2. gbc/app/config/project.py +24 -11
  3. gbc/app/i18n/lang.py +81 -3
  4. gbc/app/intent/base.py +414 -1
  5. gbc/app/intent/editor.py +59 -9
  6. gbc/app/interface/base.py +33 -7
  7. gbc/app/interface/cli.py +99 -55
  8. gbc/app/interface/mcp.py +14 -0
  9. gbc/app/models/errors.py +11 -0
  10. gbc/assets/editor/app.js +6 -0
  11. gbc/assets/editor/css/foundation.css +71 -0
  12. gbc/assets/editor/css/review.css +41 -0
  13. gbc/assets/editor/css/workspace.css +65 -0
  14. gbc/assets/editor/i18n.js +352 -0
  15. gbc/assets/editor/index.html +77 -294
  16. gbc/assets/editor/js/actions.js +597 -0
  17. gbc/assets/editor/js/api.js +43 -0
  18. gbc/assets/editor/js/diff.js +30 -0
  19. gbc/assets/editor/js/dom.js +42 -0
  20. gbc/assets/editor/js/editor-view.js +218 -0
  21. gbc/assets/editor/js/review-view.js +110 -0
  22. gbc/assets/editor/js/semantic.js +166 -0
  23. gbc/assets/editor/js/state.js +19 -0
  24. gbc/assets/editor/js/tree-view.js +43 -0
  25. gbc/assets/i18n/catalog/en.json +54 -4
  26. gbc/assets/i18n/catalog/zh.json +53 -4
  27. gbc/assets/i18n/texts/rules.en.md +55 -20
  28. gbc/assets/i18n/texts/rules.zh.md +32 -14
  29. gbc/assets/i18n/texts/setup.en.md +13 -0
  30. gbc/assets/i18n/texts/setup.zh.md +9 -0
  31. gbc/assets/skills/gbc-cli/SKILL.md +1 -1
  32. gbc/entry.py +42 -6
  33. {guarantee_based_coding-0.2.0.dist-info → guarantee_based_coding-0.2.2.dist-info}/METADATA +1 -1
  34. guarantee_based_coding-0.2.2.dist-info/RECORD +62 -0
  35. {guarantee_based_coding-0.2.0.dist-info → guarantee_based_coding-0.2.2.dist-info}/WHEEL +1 -1
  36. guarantee_based_coding-0.2.0.dist-info/RECORD +0 -48
  37. {guarantee_based_coding-0.2.0.dist-info → guarantee_based_coding-0.2.2.dist-info}/entry_points.txt +0 -0
  38. {guarantee_based_coding-0.2.0.dist-info → guarantee_based_coding-0.2.2.dist-info}/licenses/LICENSE +0 -0
  39. {guarantee_based_coding-0.2.0.dist-info → guarantee_based_coding-0.2.2.dist-info}/top_level.txt +0 -0
gbc/__init__.py CHANGED
@@ -18,4 +18,4 @@
18
18
  显式、可执行、可验证的保证。本包是该方法的实现,也是它的第一个使用者。
19
19
  """
20
20
 
21
- __version__ = "0.2.0"
21
+ __version__ = "0.2.2"
gbc/app/config/project.py CHANGED
@@ -12,24 +12,37 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ import os
15
16
  from pathlib import Path
16
17
 
17
18
 
18
- # 初始化当前路径:进程启动时的 cwd。CLI 单次命令默认就用这个——“当前项目 = 跑命令时所在目录”,
19
- # 无需环境变量,不会静默回退到包安装位置。常驻服务(mcp up / editor up)cwd 不可靠,
20
- # 它们自己收显式参数覆盖,不依赖这个默认值。
19
+ GBC_PROJECT_ROOT = "GBC_PROJECT_ROOT"
20
+
21
+
22
+ def _normalize_project_path(path: str | os.PathLike[str]) -> Path:
23
+ """Return an absolute, normalized project path without requiring it to exist."""
24
+ raw_path = os.fspath(path)
25
+ if not raw_path.strip():
26
+ raise ValueError("project path must not be blank")
27
+ return Path(raw_path).expanduser().resolve(strict=False)
28
+
29
+
30
+ # 初始化当前路径:进程启动时固定按 GBC_PROJECT_ROOT > cwd 选择一次,不向上搜索。
21
31
  def _init_current_project() -> Path:
22
- return Path.cwd()
32
+ configured_root = os.environ.get(GBC_PROJECT_ROOT)
33
+ if configured_root is not None and configured_root.strip():
34
+ return _normalize_project_path(configured_root)
35
+ return _normalize_project_path(Path.cwd())
36
+
23
37
 
24
- # 会被使用的当前路径变量
25
38
  CURRENT_PROJECT = _init_current_project()
26
39
 
27
- # 更新当前路径
28
- def set_current_project(new_path: str):
29
- new_path = Path(new_path)
30
40
 
41
+ def set_current_project(new_path: str | os.PathLike[str]) -> None:
42
+ """Explicitly override the current project root."""
31
43
  global CURRENT_PROJECT
32
- CURRENT_PROJECT = new_path
44
+ CURRENT_PROJECT = _normalize_project_path(new_path)
45
+
33
46
 
34
- def get_current_project():
35
- return CURRENT_PROJECT
47
+ def get_current_project() -> Path:
48
+ return CURRENT_PROJECT
gbc/app/i18n/lang.py CHANGED
@@ -12,18 +12,24 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- """语言判定 + **从文件自动发现受支持语言**。
15
+ """语言判定、用户级持久偏好与从文件自动发现受支持语言。
16
16
 
17
17
  关键设计:受支持语言不硬编码,而是扫描资源文件推导——放一个 `catalog/ja.json`
18
18
  (以及可选的 `texts/*.ja.md`)就自动支持日语,无需改代码。
19
19
 
20
- 判定优先级(高→低):显式 lang 参数 > 环境变量 GBC_LANG > 系统 locale > 默认 en。
20
+ 判定优先级(高→低):显式 lang 参数 > 环境变量 GBC_LANG > 持久偏好 >
21
+ 系统 locale > 默认 en。
21
22
  """
22
23
  import locale
23
24
  import os
25
+ from pathlib import Path
26
+ import tempfile
27
+
24
28
  from gbc.app.assets import I18N_CATALOG_DIR as _CATALOG_DIR
25
29
 
26
30
  DEFAULT_LANG = "en"
31
+ GBC_CONFIG_HOME = "GBC_CONFIG_HOME"
32
+ LANG_PREFERENCE_FILENAME = "lang"
27
33
 
28
34
  # 进程内当前语言(命令入口解析一次后 set_lang 固定;库函数读 current_lang)。
29
35
  _current: str = DEFAULT_LANG
@@ -53,16 +59,88 @@ def normalize(raw: str | None) -> str | None:
53
59
  return None
54
60
 
55
61
 
62
+ def _preference_dir() -> Path:
63
+ """返回用户语言偏好目录,不查询项目目录。"""
64
+ configured = os.environ.get(GBC_CONFIG_HOME)
65
+ if configured:
66
+ return Path(configured)
67
+
68
+ if os.name == "nt":
69
+ appdata = os.environ.get("APPDATA")
70
+ if appdata:
71
+ return Path(appdata) / "gbc"
72
+
73
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
74
+ if xdg_config_home:
75
+ return Path(xdg_config_home) / "gbc"
76
+ return Path.home() / ".config" / "gbc"
77
+
78
+
79
+ def _preference_file() -> Path:
80
+ return _preference_dir() / LANG_PREFERENCE_FILENAME
81
+
82
+
83
+ def read_language_preference() -> str | None:
84
+ """读取格式严格的用户语言偏好;缺失、损坏或不可读时返回 None。"""
85
+ try:
86
+ content = _preference_file().read_bytes().decode("utf-8")
87
+ except (OSError, UnicodeError):
88
+ return None
89
+
90
+ for lang in supported_langs():
91
+ if content == f"{lang}\n":
92
+ return lang
93
+ return None
94
+
95
+
96
+ def set_language_preference(value: str) -> str | None:
97
+ """原子替换用户语言偏好;``auto`` 幂等删除偏好文件。"""
98
+ preference_file = _preference_file()
99
+ if value == "auto":
100
+ preference_file.unlink(missing_ok=True)
101
+ return None
102
+
103
+ if not isinstance(value, str) or normalize(value) != value:
104
+ raise ValueError(f"unsupported language: {value!r}")
105
+
106
+ preference_file.parent.mkdir(parents=True, exist_ok=True)
107
+ temporary_file: Path | None = None
108
+ try:
109
+ with tempfile.NamedTemporaryFile(
110
+ mode="w",
111
+ encoding="utf-8",
112
+ dir=preference_file.parent,
113
+ prefix=f".{LANG_PREFERENCE_FILENAME}.",
114
+ delete=False,
115
+ newline="\n",
116
+ ) as handle:
117
+ temporary_file = Path(handle.name)
118
+ handle.write(f"{value}\n")
119
+ os.replace(temporary_file, preference_file)
120
+ finally:
121
+ if temporary_file is not None:
122
+ try:
123
+ temporary_file.unlink(missing_ok=True)
124
+ except OSError:
125
+ pass
126
+ return value
127
+
128
+
56
129
  def resolve_lang(explicit: str | None = None) -> str:
57
130
  """按优先级判定语言,永远返回一个受支持码(兜底 DEFAULT_LANG)。
58
131
 
59
- 显式 explicit > 环境变量 GBC_LANG > 系统 locale > DEFAULT_LANG。
132
+ 显式 explicit > 环境变量 GBC_LANG > 持久偏好 > 系统 locale >
133
+ DEFAULT_LANG。无效的高优先级值会继续向后判定。
60
134
  """
61
135
  for candidate in (explicit, os.environ.get("GBC_LANG")):
62
136
  norm = normalize(candidate)
63
137
  if norm:
64
138
  return norm
65
139
 
140
+ norm = normalize(read_language_preference())
141
+ if norm:
142
+ return norm
143
+
66
144
  try:
67
145
  sys_lang, _ = locale.getlocale()
68
146
  except (ValueError, TypeError):
gbc/app/intent/base.py CHANGED
@@ -24,9 +24,14 @@
24
24
  gbc.md 解析单源复用 gbc.app.utils.gbc_md。
25
25
  """
26
26
  from pathlib import Path
27
+ import re
28
+ import threading
27
29
 
28
30
  from gbc.app.utils import gbc_md as gf
29
- from gbc.app.models.errors import IntentDocError
31
+ from gbc.app.models.errors import IntentDocError, IntentConflictError
32
+ from gbc.app.models.meta import FileMeta
33
+ from gbc.app.utils.json_model_operator import load_model_from_json
34
+ from gbc.app.utils.safe_file_writer import SafeFileWriter
30
35
 
31
36
  GBC_FILE = "gbc.md"
32
37
 
@@ -160,6 +165,44 @@ def show(gbc_root: Path, rel: str) -> str:
160
165
  return "\n".join(lines)
161
166
 
162
167
 
168
+ # ---- 文件条目 → .gbc json 只读视图 -------------------------------------------
169
+
170
+ def file_meta_view(gbc_root: Path, path: str) -> dict | None:
171
+ """返回一个文件条目对应的 .gbc json(FileMeta)只读可读视图;json 缺失/损坏返回 None。
172
+
173
+ path 是完整语义文件路径(如 "app/main.py";根层文件即 "main.py")。在 base 内做完整校验:
174
+ 复用 _validate_path(拒绝对路径/.. /空段/首尾空白/反斜杠),且 root("")、folder 尾斜杠、
175
+ 空 name 均非法——只接受指向 file 的路径。非法抛 IntentDocError,由上层表面转 400。
176
+
177
+ 定位规则与 file_utils.to_gbc_json_path 一致:源码 ``<rel>/<name>`` 的元数据落在
178
+ .gbc 镜像下 ``<gbc_root>/<rel>/gbc.<name>.json``(gbc_root 即 .gbc 镜像层,对应项目根)。
179
+ 只读视图——绝不写盘、绝不创建缺失的 json、解析失败静默返回 None。
180
+ """
181
+ _validate_path(path)
182
+ if path == "" or path.endswith("/"):
183
+ raise IntentDocError("exc.doc_flat_bad_path", path=path)
184
+ rel, _, name = path.rpartition("/")
185
+ json_path = (gbc_root / rel / f"gbc.{name}.json") if rel else (gbc_root / f"gbc.{name}.json")
186
+ if not json_path.is_file():
187
+ return None
188
+ try:
189
+ meta = load_model_from_json(json_path, FileMeta)
190
+ except (OSError, ValueError): # 缺失/损坏/不符合模型 → 只读视图不抛错
191
+ return None
192
+ return {
193
+ "provides": [
194
+ {"id": gid, "desc": g.desc, "disabled": g.disabled,
195
+ "dependents": list(g.dependents)}
196
+ for gid, g in meta.provides.items()
197
+ ],
198
+ "depends_on": [
199
+ {"symbol": d.symbol, "provider": d.symbol.split(":", 1)[0],
200
+ "guarantees": list(d.guarantees)}
201
+ for d in meta.depends_on
202
+ ],
203
+ }
204
+
205
+
163
206
  # ---- 整树读写(供 web 编辑器) -----------------------------------------------
164
207
 
165
208
  def empty_tree(gbc_root: Path) -> dict:
@@ -306,3 +349,373 @@ def migrate(gbc_root: Path) -> list[str]:
306
349
  md.write_text(new, encoding="utf-8")
307
350
  changed.append(str(md))
308
351
  return changed
352
+
353
+
354
+ # ---- flat 语义模型(供 web 编辑器) -------------------------------------------
355
+
356
+ # root path 为 "";folder path 以 "/" 结尾;file path 不以 "/" 结尾。
357
+ # folder node = {kind:"folder", intent, constraints};file node = {kind:"file", desc}。
358
+
359
+
360
+ def _last_segment(path: str) -> str:
361
+ """"app/" -> "app";"app/main.py" -> "main.py";"" -> ""。"""
362
+ body = path[:-1] if path.endswith("/") else path
363
+ return body.rsplit("/", 1)[-1]
364
+
365
+
366
+ def _parent_flat_key(path: str) -> str:
367
+ """flat 路径的父文件夹 key(""/"a/" 都回 "")。"""
368
+ body = path[:-1] if path.endswith("/") else path
369
+ idx = body.rfind("/")
370
+ return body[:idx] + "/" if idx != -1 else ""
371
+
372
+
373
+ def _children_paths(flat: dict, folder_key: str) -> list[str]:
374
+ return [p for p in flat if p != folder_key and _parent_flat_key(p) == folder_key]
375
+
376
+
377
+ def _node_eq(a, b) -> bool:
378
+ """语义节点相等:kind 参与比较(区分类型),folder 比 intent+constraints,file 比 desc。"""
379
+ if a is None or b is None:
380
+ return a is b
381
+ if not isinstance(a, dict) or not isinstance(b, dict):
382
+ return False
383
+ if a.get("kind") != b.get("kind"):
384
+ return False
385
+ kind = a.get("kind")
386
+ if kind == "folder":
387
+ return a.get("intent", "") == b.get("intent", "") and a.get("constraints", "") == b.get("constraints", "")
388
+ if kind == "file":
389
+ return a.get("desc", "") == b.get("desc", "")
390
+ return False
391
+
392
+
393
+ def nested_to_flat(node: dict) -> dict:
394
+ """纯函数:nested dict 树(如 read_tree 输出) -> flat {path: node}。"""
395
+ flat: dict[str, dict] = {}
396
+
397
+ def walk(n: dict, folder_key: str) -> None:
398
+ flat[folder_key] = {"kind": "folder",
399
+ "intent": n.get("intent", ""),
400
+ "constraints": n.get("constraints", "")}
401
+ for e in n.get("entries", []):
402
+ name = e["name"].rstrip("/")
403
+ if _is_dir_name(e["name"]):
404
+ child_key = (f"{folder_key}{name}" if folder_key else name) + "/"
405
+ walk(e.get("child") or {}, child_key)
406
+ else:
407
+ file_key = f"{folder_key}{name}" if folder_key else name
408
+ flat[file_key] = {"kind": "file", "desc": e.get("desc", "")}
409
+
410
+ walk(node, "")
411
+ return flat
412
+
413
+
414
+ def flat_to_nested(flat: dict, name: str = "") -> dict:
415
+ """纯函数:flat {path: node} -> nested dict 树(根)。name 为根节点展示名。"""
416
+
417
+ def build(folder_key: str) -> dict:
418
+ node = flat.get(folder_key, {"kind": "folder", "intent": "", "constraints": ""})
419
+ nested = {
420
+ "name": _last_segment(folder_key),
421
+ "path": folder_key.rstrip("/"),
422
+ "intent": node.get("intent", ""),
423
+ "constraints": node.get("constraints", ""),
424
+ "entries": [],
425
+ }
426
+ for path in sorted(_children_paths(flat, folder_key)):
427
+ child = flat[path]
428
+ seg = _last_segment(path)
429
+ if child.get("kind") == "folder":
430
+ nested["entries"].append({"name": f"{seg}/", "child": build(path)})
431
+ else:
432
+ nested["entries"].append({"name": seg, "desc": child.get("desc", "")})
433
+ return nested
434
+
435
+ root = build("")
436
+ root["name"] = name
437
+ return root
438
+
439
+
440
+ def _validate_path(path: str) -> None:
441
+ """拒绝绝对路径、..、空段、首尾空白、反斜杠;root "" 合法。"""
442
+ if not isinstance(path, str):
443
+ raise IntentDocError("exc.doc_flat_bad_path", path=repr(path))
444
+ if path == "":
445
+ return
446
+ if path != path.strip():
447
+ raise IntentDocError("exc.doc_flat_bad_path", path=path)
448
+ if "\\" in path:
449
+ raise IntentDocError("exc.doc_flat_bad_path", path=path)
450
+ body = path[:-1] if path.endswith("/") else path
451
+ if body.startswith("/") or re.match(r"^[A-Za-z]:", body):
452
+ raise IntentDocError("exc.doc_flat_bad_path", path=path)
453
+ segs = body.split("/")
454
+ if "" in segs or any(s in (".", "..") for s in segs):
455
+ raise IntentDocError("exc.doc_flat_bad_path", path=path)
456
+
457
+
458
+ def _validate_node(path: str, node) -> None:
459
+ if not isinstance(node, dict):
460
+ raise IntentDocError("exc.doc_flat_bad_node", path=path, reason="not an object")
461
+ kind = node.get("kind")
462
+ expected = "folder" if (path == "" or path.endswith("/")) else "file"
463
+ if kind != expected:
464
+ raise IntentDocError("exc.doc_flat_bad_node", path=path,
465
+ reason=f"kind must be '{expected}'")
466
+ if kind == "folder":
467
+ for key in ("intent", "constraints"):
468
+ if key in node and not isinstance(node[key], str):
469
+ raise IntentDocError("exc.doc_flat_bad_node", path=path, reason=f"{key} must be a string")
470
+ else:
471
+ if "desc" in node and not isinstance(node["desc"], str):
472
+ raise IntentDocError("exc.doc_flat_bad_node", path=path, reason="desc must be a string")
473
+
474
+
475
+ def _validate_flat(flat: dict) -> None:
476
+ if not isinstance(flat, dict):
477
+ raise IntentDocError("exc.doc_flat_bad_node", path="<root>", reason="flat must be an object")
478
+ if "" not in flat:
479
+ raise IntentDocError("exc.doc_flat_bad_node", path="", reason="root node is required")
480
+ for path, node in flat.items():
481
+ _validate_path(path)
482
+ _validate_node(path, node)
483
+ for path in flat:
484
+ if path == "":
485
+ continue
486
+ parent = _parent_flat_key(path)
487
+ if parent not in flat or flat[parent].get("kind") != "folder":
488
+ raise IntentDocError("exc.doc_flat_missing_ancestor", path=path, parent=parent)
489
+
490
+
491
+ def read_flat(gbc_root: Path) -> dict:
492
+ """读磁盘 -> flat map。gbc_root 不存在时回仅根节点的空树。"""
493
+ if not gbc_root.exists():
494
+ return {"": {"kind": "folder", "intent": "", "constraints": ""}}
495
+ return nested_to_flat(read_tree(gbc_root))
496
+
497
+
498
+ # ---- 选择性保存(冲突感知,最小写盘) -------------------------------------------
499
+
500
+ _save_locks: dict[str, threading.Lock] = {}
501
+ _save_locks_guard = threading.Lock()
502
+
503
+
504
+ def _lock_for(root_key: str) -> threading.Lock:
505
+ with _save_locks_guard:
506
+ if root_key not in _save_locks:
507
+ _save_locks[root_key] = threading.Lock()
508
+ return _save_locks[root_key]
509
+
510
+
511
+ def _entries_for_folder(flat: dict, folder_key: str) -> list[gf.Entry]:
512
+ entries: list[gf.Entry] = []
513
+ for path in sorted(_children_paths(flat, folder_key)):
514
+ node = flat[path]
515
+ seg = _last_segment(path)
516
+ if node.get("kind") == "folder":
517
+ entries.append(gf.Entry(name=f"{seg}/", is_dir=True, desc=node.get("intent", "")))
518
+ else:
519
+ entries.append(gf.Entry(name=seg, is_dir=False, desc=node.get("desc", "")))
520
+ return entries
521
+
522
+
523
+ def _reconcile_entries(disk_entries: list[gf.Entry], target_entries: list[gf.Entry]) -> list[gf.Entry]:
524
+ """保留盘上条目顺序(未涉及条目原样),更新已存在条目的 desc,新条目追加到末尾。"""
525
+ target_map = {(e.name.rstrip("/"), e.is_dir): e for e in target_entries}
526
+ result: list[gf.Entry] = []
527
+ seen: set[tuple[str, bool]] = set()
528
+ for e in disk_entries:
529
+ key = (e.name.rstrip("/"), e.is_dir)
530
+ te = target_map.get(key)
531
+ if te is not None:
532
+ result.append(gf.Entry(name=e.name, is_dir=e.is_dir, desc=te.desc))
533
+ seen.add(key)
534
+ for te in target_entries:
535
+ key = (te.name.rstrip("/"), te.is_dir)
536
+ if key not in seen:
537
+ result.append(te)
538
+ return result
539
+
540
+
541
+ def _docs_equal(a: gf.ParsedDoc, b: gf.ParsedDoc) -> bool:
542
+ if a.intent != b.intent or a.constraints != b.constraints:
543
+ return False
544
+ if len(a.entries) != len(b.entries):
545
+ return False
546
+ for ea, eb in zip(a.entries, b.entries):
547
+ if ea.name != eb.name or ea.is_dir != eb.is_dir or ea.desc != eb.desc:
548
+ return False
549
+ return True
550
+
551
+
552
+ def _render_doc(flat: dict, folder_key: str, order_source: gf.ParsedDoc | None) -> gf.ParsedDoc:
553
+ node = flat.get(folder_key, {"kind": "folder", "intent": "", "constraints": ""})
554
+ entries = _entries_for_folder(flat, folder_key)
555
+ if order_source is not None:
556
+ entries = _reconcile_entries(order_source.entries, entries)
557
+ return gf.ParsedDoc(intent=node.get("intent", ""), constraints=node.get("constraints", ""),
558
+ entries=entries)
559
+
560
+
561
+ def _write_doc_atomic(gbc_root: Path, rel: str, text: str) -> Path:
562
+ p = _doc_path(gbc_root, rel)
563
+ p.parent.mkdir(parents=True, exist_ok=True)
564
+ with SafeFileWriter(p).open(mode="w", encoding="utf-8") as f:
565
+ f.write(text)
566
+ return p
567
+
568
+
569
+ def _apply_flat(gbc_root: Path, disk_flat: dict, merged_flat: dict) -> tuple[list[str], list[str]]:
570
+ """把 merged 落到磁盘:只重写内容变化的 gbc.md;删除文件夹时 unlink 子树 gbc.md,绝不碰 json。"""
571
+ written: list[str] = []
572
+ deleted: list[str] = []
573
+
574
+ merged_folders = {p for p in merged_flat if p == "" or p.endswith("/")}
575
+ disk_folders = {p for p in disk_flat if p == "" or p.endswith("/")}
576
+
577
+ # 先删(深路径在前),避免残留孤儿 gbc.md
578
+ for f in sorted(disk_folders - merged_folders, key=len, reverse=True):
579
+ p = _doc_path(gbc_root, f)
580
+ if p.exists():
581
+ p.unlink()
582
+ deleted.append(str(p))
583
+
584
+ for f in sorted(merged_folders):
585
+ order_source = None
586
+ if f in disk_folders and _doc_path(gbc_root, f).exists():
587
+ order_source = read_doc(gbc_root, f)
588
+ disk_doc = _render_doc(disk_flat, f, order_source) if f in disk_folders else gf.ParsedDoc()
589
+ target_doc = _render_doc(merged_flat, f, order_source)
590
+ if not _docs_equal(disk_doc, target_doc):
591
+ text = gf.serialize(target_doc.intent, target_doc.constraints, target_doc.entries)
592
+ _write_doc_atomic(gbc_root, f, text)
593
+ written.append(str(_doc_path(gbc_root, f)))
594
+
595
+ return written, deleted
596
+
597
+
598
+ _FOLDER_FIELDS = ("intent", "constraints")
599
+ _FILE_FIELDS = ("desc",)
600
+
601
+
602
+ def _fields_for_kind(kind: str) -> tuple[str, ...]:
603
+ return _FOLDER_FIELDS if kind == "folder" else _FILE_FIELDS
604
+
605
+
606
+ def _merge_path_fields(path: str, base_node: dict, disk_node: dict, current_node: dict,
607
+ force: bool, conflicts: list[dict]) -> tuple[dict | None, bool]:
608
+ """三方都存在且 kind 相同的节点:逐字段三方合并。返回 (最终节点, 是否冲突)。
609
+
610
+ file 仅 desc;folder 为 intent/constraints。用户字段未改→取 disk;仅用户改→current;
611
+ 仅外部改→disk;双方改到相同值→该值;双方改到不同值→冲突字段(force 时取 current)。
612
+ """
613
+ fields = _fields_for_kind(base_node.get("kind"))
614
+ merged = dict(current_node)
615
+ conflict_fields: list[str] = []
616
+ for f in fields:
617
+ b = base_node.get(f, "")
618
+ d = disk_node.get(f, "")
619
+ c = current_node.get(f, "")
620
+ if c == b:
621
+ merged[f] = d # 用户未改该字段 → 保留外部(disk)值
622
+ elif d == b:
623
+ merged[f] = c # 仅用户改 → current
624
+ elif c == d:
625
+ merged[f] = c # 双方改到相同值 → 该值
626
+ else:
627
+ conflict_fields.append(f)
628
+ merged[f] = c if force else d
629
+ if conflict_fields and not force:
630
+ conflicts.append({"path": path, "fields": conflict_fields,
631
+ "disk": disk_node, "user": current_node})
632
+ return None, True
633
+ return merged, False
634
+
635
+
636
+ def _merge_path(path: str, base_node, disk_node, current_node, force: bool,
637
+ conflicts: list[dict]) -> tuple[dict | None, bool]:
638
+ """单路径三方合并。返回 (最终节点或 None=删除, 是否冲突)。
639
+
640
+ 节点新增/删除/type change 按整节点冲突;三方都存在且 kind 相同才走字段级合并。
641
+ """
642
+ base_present = base_node is not None
643
+ disk_present = disk_node is not None
644
+ current_present = current_node is not None
645
+
646
+ if not base_present:
647
+ # 新增语义
648
+ if not disk_present and not current_present:
649
+ return None, False
650
+ if disk_present and not current_present:
651
+ return disk_node, False
652
+ if current_present and not disk_present:
653
+ return current_node, False
654
+ if _node_eq(disk_node, current_node):
655
+ return current_node, False
656
+ conflicts.append({"path": path, "disk": disk_node, "user": current_node})
657
+ return (current_node if force else disk_node), True
658
+
659
+ # base 存在:先处理删除
660
+ if not disk_present and not current_present:
661
+ return None, False # 双方都删 → 删除
662
+ if not disk_present: # 外部删、用户保留
663
+ if not _node_eq(current_node, base_node):
664
+ conflicts.append({"path": path, "disk": disk_node, "user": current_node})
665
+ return (current_node if force else None), True
666
+ return None, False # 用户没改 → 外部删除生效
667
+ if not current_present: # 用户删、外部保留
668
+ if not _node_eq(disk_node, base_node):
669
+ conflicts.append({"path": path, "disk": disk_node, "user": current_node})
670
+ return (None if force else disk_node), True
671
+ return None, False # 外部没改 → 用户删除生效
672
+
673
+ # 双方都在:类型变化 → 整节点
674
+ base_kind = base_node.get("kind")
675
+ if disk_node.get("kind") != base_kind or current_node.get("kind") != base_kind:
676
+ if _node_eq(disk_node, current_node):
677
+ return current_node, False
678
+ conflicts.append({"path": path, "disk": disk_node, "user": current_node})
679
+ return (current_node if force else disk_node), True
680
+
681
+ return _merge_path_fields(path, base_node, disk_node, current_node, force, conflicts)
682
+
683
+
684
+ def save_flat(gbc_root: Path, base_flat: dict, current_flat: dict, force: bool = False) -> dict:
685
+ """冲突感知的选择性保存(字段级三方合并)。
686
+
687
+ 重新读盘比较 external=base vs disk、user=base vs current:
688
+ - 节点新增/删除/type change 按整节点冲突;
689
+ - 三方都存在且 kind 相同:逐字段三方合并(file 仅 desc;folder 为 intent/constraints)。
690
+ 无冲突:以 disk 为合并基线只应用 user 改动,保留不相关外部变动,只重写内容变化的 gbc.md。
691
+ force 仅覆盖冲突字段/冲突节点。返回 {"written", "deleted", "count"};冲突且 !force 抛
692
+ IntentConflictError。
693
+ """
694
+ _validate_flat(base_flat)
695
+ _validate_flat(current_flat)
696
+
697
+ with _lock_for(str(gbc_root)):
698
+ gbc_root.mkdir(parents=True, exist_ok=True)
699
+ disk_flat = read_flat(gbc_root)
700
+
701
+ all_paths = set(base_flat) | set(disk_flat) | set(current_flat)
702
+ conflicts: list[dict] = []
703
+ external_changes: list[str] = []
704
+ merged: dict = {}
705
+ for path in all_paths:
706
+ base_node = base_flat.get(path)
707
+ disk_node = disk_flat.get(path)
708
+ current_node = current_flat.get(path)
709
+ if not _node_eq(disk_node, base_node):
710
+ external_changes.append(path)
711
+ final, _ = _merge_path(path, base_node, disk_node, current_node, force, conflicts)
712
+ if final is not None:
713
+ merged[path] = final
714
+ # 根节点永不可删
715
+ merged.setdefault("", {"kind": "folder", "intent": "", "constraints": ""})
716
+
717
+ if conflicts and not force:
718
+ raise IntentConflictError(conflicts, external_changes)
719
+
720
+ written, deleted = _apply_flat(gbc_root, disk_flat, merged)
721
+ return {"written": written, "deleted": deleted, "count": len(written)}