guarantee-based-coding 0.2.0__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 (48) hide show
  1. gbc/__init__.py +21 -0
  2. gbc/app/__init__.py +14 -0
  3. gbc/app/assets.py +38 -0
  4. gbc/app/config/__init__.py +14 -0
  5. gbc/app/config/backups.py +28 -0
  6. gbc/app/config/base.py +29 -0
  7. gbc/app/config/executor.py +132 -0
  8. gbc/app/config/project.py +35 -0
  9. gbc/app/core/__init__.py +14 -0
  10. gbc/app/core/env.py +64 -0
  11. gbc/app/core/executor.py +116 -0
  12. gbc/app/core/guarantee.py +338 -0
  13. gbc/app/i18n/__init__.py +43 -0
  14. gbc/app/i18n/lang.py +88 -0
  15. gbc/app/i18n/translate.py +80 -0
  16. gbc/app/intent/__init__.py +20 -0
  17. gbc/app/intent/base.py +308 -0
  18. gbc/app/intent/cli.py +124 -0
  19. gbc/app/intent/editor.py +93 -0
  20. gbc/app/interface/__init__.py +14 -0
  21. gbc/app/interface/base.py +851 -0
  22. gbc/app/interface/cli.py +585 -0
  23. gbc/app/interface/mcp.py +616 -0
  24. gbc/app/models/__init__.py +14 -0
  25. gbc/app/models/errors.py +179 -0
  26. gbc/app/models/meta.py +92 -0
  27. gbc/app/models/verify.py +63 -0
  28. gbc/app/utils/__init__.py +14 -0
  29. gbc/app/utils/file_utils.py +24 -0
  30. gbc/app/utils/gbc_md.py +121 -0
  31. gbc/app/utils/json_model_operator.py +85 -0
  32. gbc/app/utils/safe_file_writer.py +158 -0
  33. gbc/assets/editor/index.html +299 -0
  34. gbc/assets/i18n/catalog/en.json +52 -0
  35. gbc/assets/i18n/catalog/zh.json +52 -0
  36. gbc/assets/i18n/texts/rules.en.md +30 -0
  37. gbc/assets/i18n/texts/rules.zh.md +24 -0
  38. gbc/assets/i18n/texts/setup.en.md +74 -0
  39. gbc/assets/i18n/texts/setup.zh.md +69 -0
  40. gbc/assets/skills/README.md +16 -0
  41. gbc/assets/skills/gbc-cli/SKILL.md +143 -0
  42. gbc/entry.py +126 -0
  43. guarantee_based_coding-0.2.0.dist-info/METADATA +108 -0
  44. guarantee_based_coding-0.2.0.dist-info/RECORD +48 -0
  45. guarantee_based_coding-0.2.0.dist-info/WHEEL +5 -0
  46. guarantee_based_coding-0.2.0.dist-info/entry_points.txt +2 -0
  47. guarantee_based_coding-0.2.0.dist-info/licenses/LICENSE +202 -0
  48. guarantee_based_coding-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,851 @@
1
+ # Copyright 2026 Jesse-x86
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """IO / 编排层:路径解析、meta 文件读写、跨文件双向写、全局扫描。
16
+
17
+ core 层是纯模型操作(不碰磁盘);这里负责把「provider/consumer 文件字符串」解析成
18
+ 项目相对路径、加载/保存对应的 .gbc json,并把 core 的纯操作包成可被 cli / mcp 调用的
19
+ 函数。依赖登记是跨两个文件的双向写,这里用双文件 session 统一处理。
20
+ """
21
+
22
+ import re
23
+ import shutil
24
+ import subprocess
25
+ from contextlib import contextmanager
26
+ from pathlib import Path
27
+ from typing import Iterator
28
+
29
+ from pydantic import ValidationError
30
+
31
+ from gbc.app.config.backups import META_BACKUPS
32
+ from gbc.app.config.executor import ExecutorModel
33
+ from gbc.app.config.project import get_current_project
34
+ from gbc.app.core import executor
35
+ from gbc.app.core import guarantee as gtee
36
+ from gbc.app.models.errors import (
37
+ IllegalFilePathError,
38
+ ExecutorConfigInvalidError,
39
+ MetaNotFoundError,
40
+ GuaranteeNotFoundError,
41
+ GuaranteeDuplicatedError,
42
+ )
43
+ from gbc.app.models.meta import FileMeta, Guarantee, Dependency
44
+ from gbc.app.models.verify import VerifyModel, VerifySummary
45
+ from gbc.app.utils import gbc_md
46
+ from gbc.app.utils.file_utils import to_gbc_json_path
47
+ from gbc.app.utils.json_model_operator import load_model_from_json, save_model_to_json
48
+
49
+ GBC_FILE = "gbc.md"
50
+
51
+
52
+ # ============================================================================
53
+ # 路径与持久化基元
54
+ # ============================================================================
55
+
56
+ def _resolve(file_str: str) -> Path:
57
+ """把输入文件路径解析到项目内并校验。
58
+
59
+ 相对路径按**当前项目根**(get_current_project())解释,而非进程 cwd——这样工具作为
60
+ MCP server 在任意 cwd 下启动(其 app 包与目标项目可能重名)都能正确定位目标项目的文件。
61
+ """
62
+ project = get_current_project()
63
+ path = Path(file_str)
64
+ if not path.is_absolute():
65
+ path = project / path
66
+ if not path.is_relative_to(project):
67
+ raise IllegalFilePathError(path)
68
+ return path
69
+
70
+
71
+ def _to_rel(file_str: str) -> str:
72
+ """项目相对 POSIX 字符串——一切存进 .gbc 的路径都用这种形式(稳定、可移植)。"""
73
+ return _resolve(file_str).relative_to(get_current_project()).as_posix()
74
+
75
+
76
+ def _load_meta(abs_path: Path, *, create_if_missing: bool) -> FileMeta:
77
+ """加载某源文件的 FileMeta;不存在时按需新建空模型或抛 MetaNotFoundError。"""
78
+ json_path = to_gbc_json_path(abs_path)
79
+ if json_path.exists():
80
+ return load_model_from_json(json_path, FileMeta)
81
+ if create_if_missing:
82
+ return FileMeta()
83
+ raise MetaNotFoundError(original_file=abs_path, target_file=json_path)
84
+
85
+
86
+ def _save_meta(meta: FileMeta, abs_path: Path) -> None:
87
+ save_model_to_json(meta, to_gbc_json_path(abs_path), META_BACKUPS)
88
+
89
+
90
+ @contextmanager
91
+ def meta_session(file_str: str, *, readonly: bool = False, create_if_missing: bool = False):
92
+ """单文件 session:加载 → yield → (非只读时)保存。"""
93
+ abs_path = _resolve(file_str)
94
+ meta = _load_meta(abs_path, create_if_missing=create_if_missing)
95
+ yield meta
96
+ if not readonly:
97
+ _save_meta(meta, abs_path)
98
+
99
+
100
+ @contextmanager
101
+ def dual_session(consumer_str: str, provider_str: str):
102
+ """双文件 session:依赖登记要同时改 consumer(depends_on)与 provider(dependents)。
103
+
104
+ consumer 允许不存在(首次声明依赖时新建);provider 必须存在(行为级依赖的保证
105
+ 必须已在 provider 上)。两个文件都会被保存。
106
+ """
107
+ consumer_path = _resolve(consumer_str)
108
+ provider_path = _resolve(provider_str)
109
+ consumer_meta = _load_meta(consumer_path, create_if_missing=True)
110
+ provider_meta = _load_meta(provider_path, create_if_missing=False)
111
+ yield consumer_meta, provider_meta
112
+ _save_meta(consumer_meta, consumer_path)
113
+ _save_meta(provider_meta, provider_path)
114
+
115
+
116
+ def _iter_all_metas() -> Iterator[tuple[str, FileMeta]]:
117
+ """遍历 .gbc 树下所有 meta,产出 (源文件项目相对路径, FileMeta)。
118
+
119
+ 反推:`.gbc/<dir>/gbc.<name>.json` → 源文件 `<dir>/<name>`。
120
+ """
121
+ gbc_root = get_current_project() / ".gbc"
122
+ if not gbc_root.exists():
123
+ return
124
+ for json_file in gbc_root.rglob("gbc.*.json"):
125
+ rel_dir = json_file.parent.relative_to(gbc_root)
126
+ src_name = json_file.name[len("gbc."):-len(".json")]
127
+ src_rel = (rel_dir / src_name).as_posix()
128
+ try:
129
+ meta = load_model_from_json(json_file, FileMeta)
130
+ except (ValueError, ValidationError):
131
+ continue # 损坏的 meta 跳过,由 check_consistency 之外的手段处理
132
+ yield src_rel, meta
133
+
134
+
135
+ # ============================================================================
136
+ # Provider 侧:保证生命周期
137
+ # ============================================================================
138
+
139
+ def create_guarantee(
140
+ provider: str,
141
+ gid: str,
142
+ desc: str,
143
+ test: str,
144
+ executor_name: str,
145
+ heavy: int = 0,
146
+ timeout_override: int = -1,
147
+ disabled: bool = False,
148
+ ) -> None:
149
+ provider_rel = _to_rel(provider)
150
+ with meta_session(provider, create_if_missing=True) as meta:
151
+ gtee.create_guarantee(
152
+ meta, provider_rel, gid,
153
+ desc=desc, test=test, executor_name=executor_name,
154
+ heavy=heavy, timeout_override=timeout_override, disabled=disabled,
155
+ )
156
+
157
+
158
+ def disable_guarantee(provider: str, gid: str) -> None:
159
+ provider_rel = _to_rel(provider)
160
+ with meta_session(provider) as meta:
161
+ gtee.disable_guarantee(meta, provider_rel, gid)
162
+
163
+
164
+ def enable_guarantee(provider: str, gid: str) -> None:
165
+ provider_rel = _to_rel(provider)
166
+ with meta_session(provider) as meta:
167
+ gtee.enable_guarantee(meta, provider_rel, gid)
168
+
169
+
170
+ def update_guarantee(
171
+ provider: str,
172
+ gid: str,
173
+ *,
174
+ desc: str | None = None,
175
+ test: str | None = None,
176
+ executor_name: str | None = None,
177
+ heavy: int | None = None,
178
+ timeout_override: int | None = None,
179
+ ) -> None:
180
+ provider_rel = _to_rel(provider)
181
+ with meta_session(provider) as meta:
182
+ gtee.update_guarantee(
183
+ meta, provider_rel, gid,
184
+ desc=desc, test=test, executor_name=executor_name,
185
+ heavy=heavy, timeout_override=timeout_override,
186
+ )
187
+
188
+
189
+ def retire_guarantee(provider: str, gid: str) -> None:
190
+ provider_rel = _to_rel(provider)
191
+ with meta_session(provider) as meta:
192
+ gtee.retire_guarantee(meta, provider_rel, gid)
193
+
194
+
195
+ # ============================================================================
196
+ # Consumer 侧:依赖边(双向写)
197
+ # ============================================================================
198
+
199
+ def add_dependency(consumer: str, provider: str, symbol: str, guarantee_id: str | None = None) -> None:
200
+ consumer_rel = _to_rel(consumer)
201
+ provider_rel = _to_rel(provider)
202
+
203
+ if guarantee_id is None:
204
+ # 免费 symbol 依赖:只动 consumer 一个文件
205
+ with meta_session(consumer, create_if_missing=True) as cmeta:
206
+ gtee.add_dependency(cmeta, consumer_rel, FileMeta(), provider_rel, symbol, None)
207
+ return
208
+
209
+ with dual_session(consumer, provider) as (cmeta, pmeta):
210
+ gtee.add_dependency(cmeta, consumer_rel, pmeta, provider_rel, symbol, guarantee_id)
211
+
212
+
213
+ def remove_dependency(consumer: str, provider: str, symbol: str, guarantee_id: str | None = None) -> None:
214
+ consumer_rel = _to_rel(consumer)
215
+ provider_rel = _to_rel(provider)
216
+
217
+ if guarantee_id is None:
218
+ # 撤销整条 symbol 边:可能涉及 provider 反向边,走双文件
219
+ with dual_session(consumer, provider) as (cmeta, pmeta):
220
+ gtee.remove_dependency(cmeta, consumer_rel, pmeta, provider_rel, symbol, None)
221
+ return
222
+
223
+ with dual_session(consumer, provider) as (cmeta, pmeta):
224
+ gtee.remove_dependency(cmeta, consumer_rel, pmeta, provider_rel, symbol, guarantee_id)
225
+
226
+
227
+ # ============================================================================
228
+ # Refactor / 重定位:移动文件 + 全图重写路径引用(id 不动,路径无关)
229
+ # ============================================================================
230
+
231
+ def _git_or_fs_move(src: Path, dst: Path) -> str:
232
+ """把 src 移到 dst:优先 git mv(保历史),失败/非 git 则 shutil.move。懒建父目录。"""
233
+ project = get_current_project()
234
+ dst.parent.mkdir(parents=True, exist_ok=True)
235
+ try:
236
+ r = subprocess.run(
237
+ ["git", "mv", str(src), str(dst)],
238
+ cwd=project, capture_output=True, text=True,
239
+ )
240
+ if r.returncode == 0:
241
+ return "git"
242
+ except FileNotFoundError:
243
+ pass # 没装 git
244
+ shutil.move(str(src), str(dst))
245
+ return "fs"
246
+
247
+
248
+ def _remap_prefix(path: str, old_rel: str, new_rel: str) -> str:
249
+ """路径前缀重映射:path == old_rel(单文件)或 path 在 old_rel/ 下(目录子树)则改写。
250
+
251
+ 一个函数同时覆盖「单文件移动」与「目录整体移动」——这正是 refactor_file 能既搬一个
252
+ 文件也搬一整个文件夹的原因。其它路径原样返回。
253
+ """
254
+ if path == old_rel:
255
+ return new_rel
256
+ if path.startswith(old_rel + "/"):
257
+ return new_rel + path[len(old_rel):]
258
+ return path
259
+
260
+
261
+ def _rewrite_path_refs(old_rel: str, new_rel: str) -> int:
262
+ """全图重写指向 old_rel(子树)的路径引用 → new_rel:consumer 的 depends_on.symbol
263
+ 前缀、以及 provider 各保证 dependents 里的消费者路径。返回改写计数。id 一律不动。"""
264
+ count = 0
265
+ for src_rel, meta in list(_iter_all_metas()):
266
+ changed = False
267
+ for dep in meta.depends_on:
268
+ prov, sep, sym = dep.symbol.partition(":")
269
+ new_prov = _remap_prefix(prov, old_rel, new_rel)
270
+ if new_prov != prov:
271
+ dep.symbol = f"{new_prov}{sep}{sym}"
272
+ changed = True
273
+ count += 1
274
+ for guarantee in meta.provides.values():
275
+ for i, d in enumerate(guarantee.dependents):
276
+ nd = _remap_prefix(d, old_rel, new_rel)
277
+ if nd != d:
278
+ guarantee.dependents[i] = nd
279
+ changed = True
280
+ count += 1
281
+ if changed:
282
+ _save_meta(meta, _resolve(src_rel))
283
+ return count
284
+
285
+
286
+ def _disable_providers_under(rel: str) -> list[dict]:
287
+ """把 rel(子树)下所有文件提供的保证置为停用,返回 [{provider, guarantee}] 清单。
288
+
289
+ 重定位后这些保证的测试多半因 import 失效而跑不过,先 disable 守住边(不撕依赖),
290
+ 待 AI 修好测试再逐个 enable 补跑门禁。已停用的跳过。
291
+ """
292
+ disabled: list[dict] = []
293
+ for src_rel, meta in list(_iter_all_metas()):
294
+ if src_rel != rel and not src_rel.startswith(rel + "/"):
295
+ continue
296
+ changed = False
297
+ for gid, guarantee in meta.provides.items():
298
+ if not guarantee.disabled:
299
+ guarantee.disabled = True
300
+ changed = True
301
+ disabled.append({"provider": src_rel, "guarantee": gid})
302
+ if changed:
303
+ _save_meta(meta, _resolve(src_rel))
304
+ return disabled
305
+
306
+
307
+ _MD_REF_RE = re.compile(r"\[\[([^\[\]]+)\]\]") # gbc.md 散文里的引用标记 [[项目相对路径(:符号)]]
308
+
309
+
310
+ def _rewrite_md_refs(transform) -> int:
311
+ """扫描所有 .gbc/**/gbc.md,对 ``[[...]]`` 标记内的引用套 transform 重写,返回改动计数。
312
+
313
+ 约定:gbc.md 散文引用代码一律写成 ``[[项目相对路径]]`` 或 ``[[项目相对路径:符号]]``。
314
+ 只动 ``[[ ]]`` 标记内的字符串、绝不碰 gbc.md 结构(# 意图/# 内部约束/# 文件)——
315
+ 这让 refactor 把散文引用也一起修对,迁移不再留手动尾巴。"""
316
+ gbc_root = get_current_project() / ".gbc"
317
+ if not gbc_root.exists():
318
+ return 0
319
+ total = 0
320
+ for md in gbc_root.rglob(GBC_FILE):
321
+ text = md.read_text(encoding="utf-8")
322
+ counter = [0]
323
+
324
+ def _sub(m, _c=counter):
325
+ inner = m.group(1)
326
+ new_inner = transform(inner)
327
+ if new_inner != inner:
328
+ _c[0] += 1
329
+ return f"[[{new_inner}]]"
330
+
331
+ new_text = _MD_REF_RE.sub(_sub, text)
332
+ if counter[0]:
333
+ md.write_text(new_text, encoding="utf-8")
334
+ total += counter[0]
335
+ return total
336
+
337
+
338
+ def _md_path_remap(inner: str, old_rel: str, new_rel: str) -> str:
339
+ """[[路径]] / [[路径:符号]] 的路径段做前缀重映射(符号段原样)。"""
340
+ path, sep, sym = inner.partition(":")
341
+ return _remap_prefix(path, old_rel, new_rel) + sep + sym
342
+
343
+
344
+ def _md_symbol_remap(inner: str, provider_rel: str, old_symbol: str, new_symbol: str) -> str:
345
+ """[[provider:old_symbol]] 的符号段改名(路径恰为本 provider 时)。"""
346
+ path, sep, sym = inner.partition(":")
347
+ if sep and path == provider_rel and sym == old_symbol:
348
+ sym = new_symbol
349
+ return path + sep + sym
350
+
351
+
352
+ def refactor_file(old: str, new: str, *, disable_guarantees: bool = True) -> dict:
353
+ """重定位一个文件或目录子树,并把整张依赖图里指向它的路径引用一次性改对。
354
+
355
+ GBC 负责的是「结构 + 元数据」:移动代码文件/目录 + 它的 .gbc 产物(json/.pyi)、
356
+ 全图重写路径引用、并把被移动方提供的保证自动停用(测试此刻会因 import 失效跑不过)。
357
+ AI 负责的是「内容 + 验证」:修移动文件与其消费者的 import、搬测试文件并 update 选择器、
358
+ 再对每条停用保证 enable_guarantee 补跑门禁。
359
+
360
+ 移动是幂等的:old 在、new 不在 → GBC 来搬;old 已不在、new 已在 → 视作已搬过、
361
+ 只重写引用(这让本工具能收拾「文件已手动搬走、只剩图引用过期」的残局)。
362
+ 保证 id 一律不动——id 已是路径无关的 <symbol>.<behavior>,移动不该改它。
363
+
364
+ 返回报告 dict:{old, new, code_move, gbc_move, refs_rewritten, disabled, next_steps}。
365
+ """
366
+ old_rel = _to_rel(old)
367
+ new_rel = _to_rel(new)
368
+ old_abs = _resolve(old)
369
+ new_abs = _resolve(new)
370
+ report: dict = {"old": old_rel, "new": new_rel}
371
+
372
+ # 1. 移动代码文件/目录(幂等)
373
+ if old_abs.exists() and not new_abs.exists():
374
+ report["code_move"] = _git_or_fs_move(old_abs, new_abs)
375
+ elif new_abs.exists() and not old_abs.exists():
376
+ report["code_move"] = "already"
377
+ elif old_abs.exists() and new_abs.exists():
378
+ raise IllegalFilePathError(new_abs) # 两端都在,意图不明,拒绝
379
+ else:
380
+ report["code_move"] = "neither" # 代码两端都不在,仅做图引用收尾
381
+
382
+ # 是否目录移动:从存在的那一端判断(幂等场景下 new 在)。
383
+ probe = new_abs if new_abs.exists() else old_abs
384
+ is_dir_move = probe.is_dir() if probe.exists() else (
385
+ (get_current_project() / ".gbc" / old_rel).is_dir()
386
+ )
387
+
388
+ # 2. 移动 .gbc 产物
389
+ if is_dir_move:
390
+ old_gbc = get_current_project() / ".gbc" / old_rel
391
+ new_gbc = get_current_project() / ".gbc" / new_rel
392
+ if old_gbc.exists() and not new_gbc.exists():
393
+ _git_or_fs_move(old_gbc, new_gbc)
394
+ report["gbc_move"] = "moved"
395
+ else:
396
+ report["gbc_move"] = "already" if new_gbc.exists() else "none"
397
+ else:
398
+ old_json = to_gbc_json_path(old_abs)
399
+ new_json = to_gbc_json_path(new_abs)
400
+ if old_json.exists() and not new_json.exists():
401
+ _git_or_fs_move(old_json, new_json)
402
+ report["gbc_move"] = "moved"
403
+ else:
404
+ report["gbc_move"] = "already" if new_json.exists() else "none"
405
+ # .pyi stub(若有):同目录、<stem>.pyi
406
+ old_stub = old_json.parent / (old_abs.stem + ".pyi")
407
+ new_stub = new_json.parent / (new_abs.stem + ".pyi")
408
+ if old_stub.exists() and not new_stub.exists():
409
+ _git_or_fs_move(old_stub, new_stub)
410
+
411
+ # 3. 全图重写路径引用 old → new(json 依赖图 + gbc.md 散文里的 [[...]] 标记)
412
+ report["refs_rewritten"] = _rewrite_path_refs(old_rel, new_rel)
413
+ report["md_refs_rewritten"] = _rewrite_md_refs(
414
+ lambda inner: _md_path_remap(inner, old_rel, new_rel)
415
+ )
416
+
417
+ # 4. 自动停用被移动方提供的保证(守边,待 AI 修测试后 enable)
418
+ report["disabled"] = _disable_providers_under(new_rel) if disable_guarantees else []
419
+
420
+ report["next_steps"] = (
421
+ "AI: fix imports in the moved file(s) and their consumers; move/rename test files and "
422
+ "`update_guarantee(test=...)` their selectors; then `enable_guarantee` each disabled id "
423
+ "(born-green re-runs at the new selector). id is unchanged — to rename ids use refactor_func."
424
+ )
425
+ return report
426
+
427
+
428
+ def rename_guarantee(provider: str, old_id: str, new_id: str) -> dict:
429
+ """把一条保证的 id 改名 old_id → new_id,并同步全部依赖它的消费者(双向一致)。
430
+
431
+ id 是双边的:出现在 provider 的 provides 键、以及每个消费者 depends_on 的 guarantees 里。
432
+ 本工具一次改对两边——provider 处换键(Guarantee 对象连同 disabled/dependents/test 原样保留),
433
+ 再沿 dependents 把每个消费者那条 guarantees 里的 old_id 换成 new_id。
434
+
435
+ 纯 id 改名,不碰测试、不碰符号、不碰路径——用于「把带路径前缀的旧 id 归一成路径无关的
436
+ <symbol>.<behavior>」这类净化。被改名的保证若处于停用态,停用态原样保留。
437
+ """
438
+ provider_rel = _to_rel(provider)
439
+ with meta_session(provider) as pmeta:
440
+ guarantee = pmeta.provides.get(old_id)
441
+ if guarantee is None:
442
+ raise GuaranteeNotFoundError(target_file=provider_rel, guarantee_path=old_id)
443
+ if new_id != old_id and new_id in pmeta.provides:
444
+ raise GuaranteeDuplicatedError(target_file=provider_rel, guarantee_path=new_id)
445
+ del pmeta.provides[old_id]
446
+ pmeta.provides[new_id] = guarantee
447
+ dependents = list(guarantee.dependents)
448
+
449
+ updated: list[str] = []
450
+ for consumer_rel in dependents:
451
+ with meta_session(consumer_rel) as cmeta:
452
+ changed = False
453
+ for dep in cmeta.depends_on:
454
+ if dep.symbol.split(":", 1)[0] == provider_rel and old_id in dep.guarantees:
455
+ dep.guarantees = [new_id if g == old_id else g for g in dep.guarantees]
456
+ changed = True
457
+ if not changed: # 反向边记了它、但正向边没有 → 不静默吞,交给 check 暴露
458
+ continue
459
+ updated.append(consumer_rel)
460
+ return {"provider": provider_rel, "old_id": old_id, "new_id": new_id, "consumers_updated": updated}
461
+
462
+
463
+ def refactor_func(
464
+ provider: str, old_symbol: str, new_symbol: str, *, disable_guarantees: bool = True
465
+ ) -> dict:
466
+ """重命名 provider 上的一个符号 old_symbol → new_symbol,并修对全图里对它的引用。
467
+
468
+ GBC 做元数据部分:① 把消费者 depends_on 里 ``provider:old_symbol`` 的符号改成
469
+ ``provider:new_symbol``;② 按 id 约定 <symbol>.<behavior> 把该符号名下的保证 id
470
+ (``old_symbol`` 或 ``old_symbol.*``)改名到 new_symbol 名下(双向,复用 rename_guarantee);
471
+ ③ 自动停用这些保证(测试还在调旧符号名、此刻会跑不过)。
472
+
473
+ GBC 不改源码里的符号定义(那是 AST 级内容编辑)——AI 负责把源码 `def old_symbol` 改名、
474
+ 更新调用处与测试,再逐个 enable_guarantee 补跑门禁。路径不动;id 只换符号段。
475
+
476
+ 返回报告 dict:{provider, old_symbol, new_symbol, symbol_refs_rewritten, ids_renamed, disabled}。
477
+ """
478
+ provider_rel = _to_rel(provider)
479
+ old_full = f"{provider_rel}:{old_symbol}"
480
+ new_full = f"{provider_rel}:{new_symbol}"
481
+
482
+ # 1. 消费者 depends_on 的符号字段改名
483
+ symbol_refs = 0
484
+ for src_rel, meta in list(_iter_all_metas()):
485
+ changed = False
486
+ for dep in meta.depends_on:
487
+ if dep.symbol == old_full:
488
+ dep.symbol = new_full
489
+ changed = True
490
+ symbol_refs += 1
491
+ if changed:
492
+ _save_meta(meta, _resolve(src_rel))
493
+
494
+ # 2. 该符号名下的保证 id 改名(old_symbol / old_symbol.* → new_symbol[...])
495
+ with meta_session(provider, readonly=True) as pmeta:
496
+ affected = [
497
+ gid for gid in pmeta.provides
498
+ if gid == old_symbol or gid.startswith(old_symbol + ".")
499
+ ]
500
+ ids_renamed: list[dict] = []
501
+ for gid in affected:
502
+ new_gid = new_symbol + gid[len(old_symbol):]
503
+ rename_guarantee(provider, gid, new_gid)
504
+ ids_renamed.append({"old": gid, "new": new_gid})
505
+
506
+ # 3. 自动停用改名后的保证(源码符号未改、测试会跑不过,先守边)
507
+ disabled: list[str] = []
508
+ if disable_guarantees and ids_renamed:
509
+ with meta_session(provider) as pmeta:
510
+ for item in ids_renamed:
511
+ g = pmeta.provides.get(item["new"])
512
+ if g is not None and not g.disabled:
513
+ g.disabled = True
514
+ disabled.append(item["new"])
515
+
516
+ # 4. gbc.md 散文里 [[provider:old_symbol]] 的符号段改名
517
+ md_refs = _rewrite_md_refs(
518
+ lambda inner: _md_symbol_remap(inner, provider_rel, old_symbol, new_symbol)
519
+ )
520
+
521
+ return {
522
+ "provider": provider_rel,
523
+ "old_symbol": old_symbol,
524
+ "new_symbol": new_symbol,
525
+ "symbol_refs_rewritten": symbol_refs,
526
+ "md_refs_rewritten": md_refs,
527
+ "ids_renamed": ids_renamed,
528
+ "disabled": disabled,
529
+ "next_steps": (
530
+ "AI: rename the symbol in the source (def/usages) and in test files, then "
531
+ "`enable_guarantee` each renamed id (born-green re-runs once the symbol matches)."
532
+ ),
533
+ }
534
+
535
+
536
+ # ============================================================================
537
+ # 读 / 反查
538
+ # ============================================================================
539
+
540
+ def list_provides(provider: str) -> dict[str, Guarantee]:
541
+ with meta_session(provider, readonly=True) as meta:
542
+ return gtee.list_provides(meta)
543
+
544
+
545
+ def list_depends_on(consumer: str) -> list[Dependency]:
546
+ with meta_session(consumer, readonly=True) as meta:
547
+ return gtee.list_depends_on(meta)
548
+
549
+
550
+ def who_depends_on(
551
+ provider: str,
552
+ *,
553
+ symbol: str | None = None,
554
+ guarantee_id: str | None = None,
555
+ ) -> dict:
556
+ """反查谁依赖 provider。
557
+
558
+ - 给 guarantee_id:O(1) 直接读该保证的 dependents(行为级,免费反向边)。
559
+ - 否则:全局扫描所有文件的 depends_on,找出 symbol 指向本 provider 的依赖边
560
+ (symbol 进一步过滤到具体符号)。这是 symbol 级免费依赖唯一的反查途径。
561
+ """
562
+ provider_rel = _to_rel(provider)
563
+
564
+ if guarantee_id is not None:
565
+ with meta_session(provider, readonly=True) as meta:
566
+ return {
567
+ "guarantee": guarantee_id,
568
+ "dependents": gtee.dependents_of(meta, guarantee_id),
569
+ }
570
+
571
+ prefix = f"{provider_rel}:"
572
+ wanted_symbol = f"{provider_rel}:{symbol}" if symbol is not None else None
573
+ hits: list[dict] = []
574
+ for src_rel, meta in _iter_all_metas():
575
+ for dep in meta.depends_on:
576
+ if wanted_symbol is not None:
577
+ if dep.symbol != wanted_symbol:
578
+ continue
579
+ elif not dep.symbol.startswith(prefix):
580
+ continue
581
+ hits.append({
582
+ "consumer": src_rel,
583
+ "symbol": dep.symbol,
584
+ "guarantees": list(dep.guarantees),
585
+ })
586
+ return {"provider": provider_rel, "symbol": symbol, "dependents": hits}
587
+
588
+
589
+ def check_consistency() -> list[dict]:
590
+ """全局一致性体检:扫描 .gbc 树,报告悬空引用、双向边漂移、以及停用保证。
591
+
592
+ 检查项(错误,破坏图一致性):
593
+ - dangling_guarantee : 某 consumer 依赖了一个 provider 上不存在的保证 id。
594
+ - missing_reverse : consumer 挂了 gid,但 provider 的 dependents 里没有该 consumer。
595
+ - missing_forward : provider 的 dependents 列了某 consumer,但 consumer 没有对应依赖边。
596
+
597
+ 停用提示(非错误,但**必须响亮**——disabled 是 born-green 的逃生口,不能静默留存):
598
+ - disabled_guarantee : 某 provider 上有一条停用保证(门禁暂缓,待 enable 重证)。
599
+ - depends_on_disabled: 某 consumer 依赖的保证当前处于停用态(= 依赖了一个未验证的承诺)。
600
+
601
+ 只要存在停用保证,返回列表就**非空**——意即「在所有 disabled 被 enable 回去之前,
602
+ check 永远不干净」,逼着停用态被收掉而非烂在那。调用方可按 type 区分错误与提示。
603
+ """
604
+ # 先建 provides 索引:(provider_rel, gid) -> dependents;并记停用态。
605
+ provides_index: dict[tuple[str, str], list[str]] = {}
606
+ disabled_index: dict[tuple[str, str], bool] = {}
607
+ metas: dict[str, FileMeta] = {}
608
+ for src_rel, meta in _iter_all_metas():
609
+ metas[src_rel] = meta
610
+ for gid, guarantee in meta.provides.items():
611
+ provides_index[(src_rel, gid)] = guarantee.dependents
612
+ disabled_index[(src_rel, gid)] = guarantee.disabled
613
+
614
+ violations: list[dict] = []
615
+
616
+ # 正向:consumer.depends_on -> provider.provides
617
+ for consumer_rel, meta in metas.items():
618
+ for dep in meta.depends_on:
619
+ provider_rel = dep.symbol.split(":", 1)[0]
620
+ for gid in dep.guarantees:
621
+ key = (provider_rel, gid)
622
+ if key not in provides_index:
623
+ violations.append({
624
+ "type": "dangling_guarantee",
625
+ "consumer": consumer_rel,
626
+ "provider": provider_rel,
627
+ "guarantee": gid,
628
+ })
629
+ elif consumer_rel not in provides_index[key]:
630
+ violations.append({
631
+ "type": "missing_reverse",
632
+ "consumer": consumer_rel,
633
+ "provider": provider_rel,
634
+ "guarantee": gid,
635
+ })
636
+
637
+ # 反向:provider.dependents -> consumer.depends_on
638
+ for (provider_rel, gid), dependents in provides_index.items():
639
+ for consumer_rel in dependents:
640
+ cmeta = metas.get(consumer_rel)
641
+ ok = cmeta is not None and any(
642
+ dep.symbol.startswith(f"{provider_rel}:") and gid in dep.guarantees
643
+ for dep in cmeta.depends_on
644
+ )
645
+ if not ok:
646
+ violations.append({
647
+ "type": "missing_forward",
648
+ "consumer": consumer_rel,
649
+ "provider": provider_rel,
650
+ "guarantee": gid,
651
+ })
652
+
653
+ # 停用提示(响亮,非错误):每条停用保证报一次;每条「依赖了停用保证」的边再报一次。
654
+ for (provider_rel, gid), is_disabled in disabled_index.items():
655
+ if is_disabled:
656
+ violations.append({
657
+ "type": "disabled_guarantee",
658
+ "provider": provider_rel,
659
+ "guarantee": gid,
660
+ })
661
+ for consumer_rel, meta in metas.items():
662
+ for dep in meta.depends_on:
663
+ provider_rel = dep.symbol.split(":", 1)[0]
664
+ for gid in dep.guarantees:
665
+ if disabled_index.get((provider_rel, gid)):
666
+ violations.append({
667
+ "type": "depends_on_disabled",
668
+ "consumer": consumer_rel,
669
+ "provider": provider_rel,
670
+ "guarantee": gid,
671
+ })
672
+
673
+ return violations
674
+
675
+
676
+ # ============================================================================
677
+ # 验证
678
+ # ============================================================================
679
+
680
+ def verify_provider(provider: str, *, auto_run_max_heavy: int = 0, timeout: int = -1) -> VerifySummary:
681
+ with meta_session(provider, readonly=True) as meta:
682
+ return gtee.verify_provider(meta, auto_run_max_heavy=auto_run_max_heavy, timeout=timeout)
683
+
684
+
685
+ def verify_guarantee(provider: str, gid: str, *, timeout: int = -1) -> VerifyModel:
686
+ provider_rel = _to_rel(provider)
687
+ with meta_session(provider, readonly=True) as meta:
688
+ return gtee.verify_guarantee(meta, provider_rel, gid, timeout=timeout)
689
+
690
+
691
+ # ============================================================================
692
+ # Executors
693
+ # ============================================================================
694
+
695
+ def upsert_executor(config_name: str, config_data: dict) -> None:
696
+ try:
697
+ model = ExecutorModel(**config_data)
698
+ except ValidationError:
699
+ raise ExecutorConfigInvalidError(config_name)
700
+ executor.upsert_exec_config(config_name=config_name, model=model)
701
+
702
+
703
+ # ============================================================================
704
+ # 全树渲染:把 .gbc 整合成一份 AI 可读的依赖树
705
+ # ============================================================================
706
+
707
+ _TREE_LEGEND = "图例: 📁 文件夹 / 📄 文件 / → 依赖<provider:符号> [保证] / ⊕ 提供<保证> ← 被依赖 / ⊘ 停用保证(门禁暂缓)"
708
+
709
+
710
+ def _tree_read_doc(gbc_root: Path, rel: str) -> gbc_md.ParsedDoc:
711
+ p = (gbc_root / rel / GBC_FILE) if rel else (gbc_root / GBC_FILE)
712
+ return gbc_md.parse(p.read_text(encoding="utf-8")) if p.exists() else gbc_md.ParsedDoc()
713
+
714
+
715
+ def _tree_block(text: str, indent: str) -> str:
716
+ """把多行文本接到一个标签后:首行原位,后续行按 indent 对齐。"""
717
+ parts = text.strip().splitlines()
718
+ if not parts:
719
+ return ""
720
+ return parts[0] + "".join(f"\n{indent}{p}" for p in parts[1:])
721
+
722
+
723
+ def _tree_render_file(rel: str, entry: gbc_md.Entry, metas: dict[str, FileMeta],
724
+ depth: int, lines: list[str], detail: bool) -> None:
725
+ pad = " " * depth
726
+ inner = pad + " "
727
+ lines.append(f"{pad}📄 {entry.name}")
728
+ if entry.desc:
729
+ lines.append(f"{inner}{_tree_block(entry.desc, inner)}")
730
+ src_rel = f"{rel}/{entry.name}" if rel else entry.name
731
+ meta = metas.get(src_rel)
732
+ if meta is None:
733
+ return
734
+ for dep in meta.depends_on:
735
+ tag = f" [{', '.join(dep.guarantees)}]" if dep.guarantees else ""
736
+ lines.append(f"{inner}→ {dep.symbol}{tag}")
737
+ for gid, g in meta.provides.items():
738
+ tail = f" ← {', '.join(g.dependents)}" if g.dependents else ""
739
+ # 停用保证用 ⊘ + [DISABLED] 醒目标记(对比启用的 ⊕),让它在树里藏不住。
740
+ mark = "⊘" if g.disabled else "⊕"
741
+ flag = " [DISABLED]" if g.disabled else ""
742
+ lines.append(f"{inner}{mark} {gid}{flag}{tail}")
743
+ if detail: # 2a:展开保证的承诺/测试/成本秩
744
+ if g.desc:
745
+ lines.append(f"{inner} {_tree_block(g.desc, inner + ' ')}")
746
+ bits = [f"test={g.test}"] if g.test else []
747
+ if g.heavy:
748
+ bits.append(f"heavy={g.heavy}")
749
+ if bits:
750
+ lines.append(f"{inner} ({'; '.join(bits)})")
751
+
752
+
753
+ def _tree_render_folder(gbc_root: Path, rel: str, metas: dict[str, FileMeta],
754
+ depth: int, lines: list[str], detail: bool) -> None:
755
+ pad = " " * depth
756
+ inner = pad + " "
757
+ doc = _tree_read_doc(gbc_root, rel)
758
+ lines.append(f"{pad}📁 {rel + '/' if rel else '(根)'}")
759
+ if doc.intent:
760
+ lines.append(f"{inner}[意图] {_tree_block(doc.intent, inner)}")
761
+ if doc.constraints:
762
+ lines.append(f"{inner}[约束] {_tree_block(doc.constraints, inner)}")
763
+ if detail: # 2b(i):列本 .gbc 目录下的其它产物(.pyi stub 等),缺失即接口未物化
764
+ others = _tree_other_artifacts(gbc_root, rel)
765
+ if others:
766
+ lines.append(f"{inner}· 其它产物: {', '.join(others)}")
767
+ # 按 gbc.md `# 文件` 的登记顺序:文件叶子就地展开,子文件夹递归读其自身 gbc.md。
768
+ for e in doc.entries:
769
+ if not e.is_dir:
770
+ _tree_render_file(rel, e, metas, depth + 1, lines, detail)
771
+ continue
772
+ child = f"{rel}/{e.name.rstrip('/')}".lstrip("/")
773
+ if (gbc_root / child / GBC_FILE).exists():
774
+ _tree_render_folder(gbc_root, child, metas, depth + 1, lines, detail)
775
+ else: # 父登记了子文件夹但其 gbc.md 尚未建
776
+ cpad = " " * (depth + 1)
777
+ lines.append(f"{cpad}📁 {e.name}(未建 gbc.md)")
778
+ if e.desc:
779
+ lines.append(f"{cpad} {_tree_block(e.desc, cpad + ' ')}")
780
+
781
+
782
+ def _tree_other_artifacts(gbc_root: Path, rel: str) -> list[str]:
783
+ """某 .gbc 目录下除 gbc.md 与 gbc.*.json 外的文件名(主要是 .pyi 接口 stub)。
784
+
785
+ .gbc 镜像只含架构产物(md/json/pyi/SCHEMA),不含源码/config/assets,故天然无噪:
786
+ 列出来即「这里物化了哪些接口面」,缺失即接口未物化。
787
+ """
788
+ folder = (gbc_root / rel) if rel else gbc_root
789
+ if not folder.is_dir():
790
+ return []
791
+ return sorted(
792
+ f.name for f in folder.iterdir()
793
+ if f.is_file() and f.name != GBC_FILE
794
+ and not (f.name.startswith("gbc.") and f.name.endswith(".json"))
795
+ )
796
+
797
+
798
+ def _tree_registered_files(gbc_root: Path) -> set[str]:
799
+ """所有 gbc.md `# 文件` 段里登记的**文件**条目(项目相对 POSIX 路径)。"""
800
+ reg: set[str] = set()
801
+ for md in gbc_root.rglob(GBC_FILE):
802
+ rel = md.parent.relative_to(gbc_root).as_posix()
803
+ rel = "" if rel == "." else rel
804
+ for e in gbc_md.parse(md.read_text(encoding="utf-8")).entries:
805
+ if not e.is_dir:
806
+ reg.add(f"{rel}/{e.name}".lstrip("/"))
807
+ return reg
808
+
809
+
810
+ def _tree_registration_gaps(gbc_root: Path, metas: dict[str, FileMeta]) -> list[str]:
811
+ """纯图反推的登记缺口(不扫源码树,故对 config/assets/__init__ 等零误报):
812
+ - 有 json 未登记:某文件有 .gbc json 却无 gbc.md 文件条目。
813
+ - 被依赖未登记:某 depends_on 指向的 provider 文件无 gbc.md 文件条目。
814
+ """
815
+ registered = _tree_registered_files(gbc_root)
816
+ out: list[str] = []
817
+ for src_rel in sorted(metas):
818
+ if src_rel not in registered:
819
+ out.append(f"[有 json 未登记] {src_rel}")
820
+ referenced = {
821
+ dep.symbol.split(":", 1)[0]
822
+ for meta in metas.values() for dep in meta.depends_on
823
+ }
824
+ for prov in sorted(referenced):
825
+ if prov not in registered:
826
+ out.append(f"[被依赖未登记] {prov}")
827
+ return out
828
+
829
+
830
+ def render_tree(*, detail: bool = False, gaps: bool = False) -> str:
831
+ """把整棵 `.gbc` 渲染成一份 AI 可读的依赖树。
832
+
833
+ 骨架来自所有 gbc.md(意图 / 内部约束 / `# 文件` 条目,沿登记的包含关系递归);每个
834
+ 文件叶子再从其 FileMeta 折入依赖出边(depends_on)与所提供保证(provides + 反向
835
+ dependents)。一次调用替代逐个读散落的 gbc.md/json。只读,不改任何文件。
836
+
837
+ detail: 额外展开每条保证的 desc/test/heavy,并在每个 .gbc 目录列出其它产物(.pyi stub)。
838
+ gaps: 末尾附「登记缺口」——纯图反推的有 json 未登记 / 被依赖未登记(零文件系统扫描)。
839
+ """
840
+ gbc_root = get_current_project() / ".gbc"
841
+ if not gbc_root.exists():
842
+ return f"(.gbc 不存在: {gbc_root})"
843
+ metas = {src_rel: meta for src_rel, meta in _iter_all_metas()}
844
+ lines: list[str] = [_TREE_LEGEND, ""]
845
+ _tree_render_folder(gbc_root, "", metas, 0, lines, detail)
846
+ if gaps:
847
+ g = _tree_registration_gaps(gbc_root, metas)
848
+ lines.append("")
849
+ lines.append("—— 登记缺口 ——" if g else "—— 登记缺口:无 ——")
850
+ lines.extend(f" {x}" for x in g)
851
+ return "\n".join(lines)