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,338 @@
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 / 无路径解析)。
16
+
17
+ 约定:
18
+ - 所有进出本模块的文件路径都是「项目相对 POSIX 字符串」,路径解析由 base 层负责。
19
+ - 本模块直接读写 FileMeta 模型对象;跨文件操作(注册依赖)同时收 consumer 与
20
+ provider 两个 meta,原子地维护双向一致,但落盘仍由 base 层负责。
21
+ - 「出生即绿」门禁:create / update 改动测试时当场跑测试,不过则拒绝(抛错),
22
+ 绝不留下一条没验证过的保证。
23
+ """
24
+
25
+ from gbc.app.core import executor
26
+ from gbc.app.models.errors import (
27
+ GuaranteeDuplicatedError,
28
+ GuaranteeNotFoundError,
29
+ GuaranteeTestFailedError,
30
+ GuaranteeHasDependentsError,
31
+ )
32
+ from gbc.app.models.meta import FileMeta, Guarantee, Dependency
33
+ from gbc.app.models.verify import VerifyModel, VerifySummary, SkippedGuarantee
34
+
35
+
36
+ # ============================================================================
37
+ # 内部小工具
38
+ # ============================================================================
39
+
40
+ def _run_test(guarantee: Guarantee, *, timeout: int = -1) -> VerifyModel:
41
+ """跑一条保证的测试,返回原始结果。timeout=-1 时回退到保证自带的 override。"""
42
+ effective_timeout = timeout if timeout != -1 else guarantee.timeout_override
43
+ return executor.verify_single(
44
+ guarantee.executor, guarantee.test, timeout=effective_timeout, return_model=True
45
+ )
46
+
47
+
48
+ def _gate(provider: str, gid: str, guarantee: Guarantee) -> None:
49
+ """出生即绿门禁:跑测试,不过就抛 GuaranteeTestFailedError。"""
50
+ result = _run_test(guarantee)
51
+ if result.return_code != 0:
52
+ # pytest 将断言错误输出到 stdout,stderr 通常是 warnings
53
+ details = (result.stderr or "") + "\n" + (result.stdout or "")
54
+ raise GuaranteeTestFailedError(
55
+ target_file=provider, guarantee_path=gid, failure_info=details.strip()
56
+ )
57
+
58
+
59
+ def _find_dependency(meta: FileMeta, symbol: str) -> Dependency | None:
60
+ """在 consumer meta 的 depends_on 里按 symbol 找依赖边。"""
61
+ for dep in meta.depends_on:
62
+ if dep.symbol == symbol:
63
+ return dep
64
+ return None
65
+
66
+
67
+ # ============================================================================
68
+ # Provider 侧:保证生命周期
69
+ # ============================================================================
70
+
71
+ def create_guarantee(
72
+ provider_meta: FileMeta,
73
+ provider: str,
74
+ gid: str,
75
+ *,
76
+ desc: str,
77
+ test: str,
78
+ executor_name: str,
79
+ heavy: int = 0,
80
+ timeout_override: int = -1,
81
+ disabled: bool = False,
82
+ ) -> Guarantee:
83
+ """在 provider 上新建一条具名保证。
84
+
85
+ 默认出生即绿:当场跑测试,不过则拒绝。``disabled=True`` 则**跳过门禁**新建一条停用
86
+ 占位保证(用于循环依赖 bootstrap:测试还过不了时先占住 id 与边,待两边就绪再 enable)。
87
+ 停用保证是 born-green 的逃生口,必须靠 check_consistency 响亮报出、别让它静默留存。
88
+ """
89
+ if gid in provider_meta.provides:
90
+ raise GuaranteeDuplicatedError(target_file=provider, guarantee_path=gid)
91
+
92
+ guarantee = Guarantee(
93
+ desc=desc,
94
+ test=test,
95
+ executor=executor_name,
96
+ timeout_override=timeout_override,
97
+ heavy=heavy,
98
+ dependents=[],
99
+ disabled=disabled,
100
+ )
101
+ if not disabled:
102
+ _gate(provider, gid, guarantee) # 先验证后落入模型;停用占位则不验证
103
+ provider_meta.provides[gid] = guarantee
104
+ return guarantee
105
+
106
+
107
+ def update_guarantee(
108
+ provider_meta: FileMeta,
109
+ provider: str,
110
+ gid: str,
111
+ *,
112
+ desc: str | None = None,
113
+ test: str | None = None,
114
+ executor_name: str | None = None,
115
+ heavy: int | None = None,
116
+ timeout_override: int | None = None,
117
+ ) -> Guarantee:
118
+ """更新一条保证的元数据。只传想改的字段;若改动了测试/执行方式则重新跑门禁。"""
119
+ guarantee = provider_meta.provides.get(gid)
120
+ if guarantee is None:
121
+ raise GuaranteeNotFoundError(target_file=provider, guarantee_path=gid)
122
+
123
+ # 是否动了「怎么验证」——动了就要重新证明出生即绿
124
+ runner_changed = (
125
+ (test is not None and test != guarantee.test)
126
+ or (executor_name is not None and executor_name != guarantee.executor)
127
+ or (timeout_override is not None and timeout_override != guarantee.timeout_override)
128
+ )
129
+
130
+ if desc is not None:
131
+ guarantee.desc = desc
132
+ if test is not None:
133
+ guarantee.test = test
134
+ if executor_name is not None:
135
+ guarantee.executor = executor_name
136
+ if heavy is not None:
137
+ guarantee.heavy = heavy
138
+ if timeout_override is not None:
139
+ guarantee.timeout_override = timeout_override
140
+
141
+ # 停用态下「换测试只换不跑」:runner 变了也不重证门禁——门禁等到 enable 时再补。
142
+ if runner_changed and not guarantee.disabled:
143
+ _gate(provider, gid, guarantee)
144
+
145
+ return guarantee
146
+
147
+
148
+ def disable_guarantee(provider_meta: FileMeta, provider: str, gid: str) -> Guarantee:
149
+ """停用一条保证:置 disabled=True,id 与全部边(dependents/反向边)原样保留。
150
+
151
+ 幂等:已停用再调无副作用。停用 ≠ 退休——它不删任何东西、不动依赖关系,只是让门禁与
152
+ 批量 verify 暂缓执行它。用于重构窗口/在修保证:先 disable 守住边,改完测试再 enable。
153
+ """
154
+ guarantee = provider_meta.provides.get(gid)
155
+ if guarantee is None:
156
+ raise GuaranteeNotFoundError(target_file=provider, guarantee_path=gid)
157
+ guarantee.disabled = True
158
+ return guarantee
159
+
160
+
161
+ def enable_guarantee(provider_meta: FileMeta, provider: str, gid: str) -> Guarantee:
162
+ """恢复一条停用的保证:当场补跑门禁(born-green),过了才真正置 disabled=False。
163
+
164
+ 门禁不过则抛 GuaranteeTestFailedError 且**保持 disabled 不变**(enable 失败=仍停用),
165
+ 绝不把一条没验证过的保证悄悄转正。幂等:对已启用的保证调用 = 重证一次门禁。
166
+ """
167
+ guarantee = provider_meta.provides.get(gid)
168
+ if guarantee is None:
169
+ raise GuaranteeNotFoundError(target_file=provider, guarantee_path=gid)
170
+ _gate(provider, gid, guarantee) # 不过则抛错,下面这行不执行 ⇒ 仍是 disabled
171
+ guarantee.disabled = False
172
+ return guarantee
173
+
174
+
175
+ def retire_guarantee(provider_meta: FileMeta, provider: str, gid: str) -> None:
176
+ """退休一条保证。退休保护:仍有 dependents 则拒绝(抛 GuaranteeHasDependentsError)。
177
+
178
+ 系统不替使用者反射式删掉还有人依赖的保证;必须先沿依赖线修复/迁移 dependents。
179
+ """
180
+ guarantee = provider_meta.provides.get(gid)
181
+ if guarantee is None:
182
+ raise GuaranteeNotFoundError(target_file=provider, guarantee_path=gid)
183
+
184
+ if guarantee.dependents:
185
+ raise GuaranteeHasDependentsError(
186
+ provider=provider, guarantee_id=gid, dependents=list(guarantee.dependents)
187
+ )
188
+
189
+ del provider_meta.provides[gid]
190
+
191
+
192
+ # ============================================================================
193
+ # Consumer 侧:依赖边(跨文件,双向写)
194
+ # ============================================================================
195
+
196
+ def add_dependency(
197
+ consumer_meta: FileMeta,
198
+ consumer: str,
199
+ provider_meta: FileMeta,
200
+ provider: str,
201
+ symbol_name: str,
202
+ gid: str | None = None,
203
+ ) -> None:
204
+ """登记 consumer 对 provider 的一条依赖。
205
+
206
+ - gid 为 None:symbol 级「免费依赖」——只在 consumer.depends_on 记一条 symbol 边,
207
+ 不挂保证、无反向边。
208
+ - gid 非 None:行为级依赖——gid 必须已存在于 provider.provides(消费者不能凭空要求
209
+ 新行为;要新行为先让 provider create_guarantee)。双向写:consumer 边挂上 gid,
210
+ provider 该保证的 dependents 追加 consumer。
211
+ """
212
+ symbol = f"{provider}:{symbol_name}"
213
+
214
+ # 保证 consumer 侧有这条 symbol 边
215
+ dep = _find_dependency(consumer_meta, symbol)
216
+ if dep is None:
217
+ dep = Dependency(symbol=symbol, guarantees=[])
218
+ consumer_meta.depends_on.append(dep)
219
+
220
+ if gid is None:
221
+ return # 免费依赖,到此为止
222
+
223
+ if gid not in provider_meta.provides:
224
+ raise GuaranteeNotFoundError(target_file=provider, guarantee_path=gid)
225
+
226
+ # 双向写(各自去重)
227
+ if gid not in dep.guarantees:
228
+ dep.guarantees.append(gid)
229
+ dependents = provider_meta.provides[gid].dependents
230
+ if consumer not in dependents:
231
+ dependents.append(consumer)
232
+
233
+
234
+ def remove_dependency(
235
+ consumer_meta: FileMeta,
236
+ consumer: str,
237
+ provider_meta: FileMeta,
238
+ provider: str,
239
+ symbol_name: str,
240
+ gid: str | None = None,
241
+ ) -> None:
242
+ """撤销一条依赖(add_dependency 的逆操作,同样维护双向一致)。
243
+
244
+ - gid 非 None:只摘掉这一个保证依赖(consumer 边去掉 gid,provider 反向边去掉 consumer),
245
+ symbol 边若还挂着别的保证则保留。
246
+ - gid 为 None:整条 symbol 边连同它挂的所有保证一起撤销,并从各保证的 dependents 摘除 consumer。
247
+ """
248
+ symbol = f"{provider}:{symbol_name}"
249
+ dep = _find_dependency(consumer_meta, symbol)
250
+ if dep is None:
251
+ raise GuaranteeNotFoundError(target_file=consumer, guarantee_path=symbol)
252
+
253
+ def _detach(target_gid: str) -> None:
254
+ guarantee = provider_meta.provides.get(target_gid)
255
+ if guarantee and consumer in guarantee.dependents:
256
+ guarantee.dependents.remove(consumer)
257
+
258
+ if gid is None:
259
+ for g in list(dep.guarantees):
260
+ _detach(g)
261
+ consumer_meta.depends_on.remove(dep)
262
+ return
263
+
264
+ if gid in dep.guarantees:
265
+ dep.guarantees.remove(gid)
266
+ _detach(gid)
267
+ # symbol 边变空(无保证)后是否保留:保留——它仍是一条有效的免费 symbol 依赖。
268
+
269
+
270
+ # ============================================================================
271
+ # 读 / 反查
272
+ # ============================================================================
273
+
274
+ def list_provides(provider_meta: FileMeta) -> dict[str, Guarantee]:
275
+ """provider 提供的全部保证(含各自 dependents)。"""
276
+ return dict(provider_meta.provides)
277
+
278
+
279
+ def list_depends_on(consumer_meta: FileMeta) -> list[Dependency]:
280
+ """consumer 声明的全部依赖边。"""
281
+ return list(consumer_meta.depends_on)
282
+
283
+
284
+ def dependents_of(provider_meta: FileMeta, gid: str) -> list[str]:
285
+ """谁依赖 provider 的这条保证——O(1) 直接读反向边。"""
286
+ guarantee = provider_meta.provides.get(gid)
287
+ if guarantee is None:
288
+ raise GuaranteeNotFoundError(target_file="<provider>", guarantee_path=gid)
289
+ return list(guarantee.dependents)
290
+
291
+
292
+ # ============================================================================
293
+ # 验证
294
+ # ============================================================================
295
+
296
+ def verify_provider(
297
+ provider_meta: FileMeta,
298
+ *,
299
+ auto_run_max_heavy: int = 0,
300
+ timeout: int = -1,
301
+ ) -> VerifySummary:
302
+ """批量验证 provider 提供的所有保证,按 heavy 阈值跳过并三桶汇总。
303
+
304
+ 批量只跑 heavy <= auto_run_max_heavy 的;其余进 skipped 桶并被响亮报告。
305
+ 门禁二元:green = failed 桶为空(skipped 不染红)。
306
+ """
307
+ summary = VerifySummary()
308
+ for gid, guarantee in provider_meta.provides.items():
309
+ if guarantee.disabled:
310
+ # 停用 = 缺席的另一种:不跑、不染红,进 skipped 并标 reason=disabled 响亮报出。
311
+ summary.skipped.append(
312
+ SkippedGuarantee(id=gid, heavy=guarantee.heavy, reason="disabled")
313
+ )
314
+ continue
315
+ if guarantee.heavy > auto_run_max_heavy:
316
+ summary.skipped.append(SkippedGuarantee(id=gid, heavy=guarantee.heavy))
317
+ continue
318
+ result = _run_test(guarantee, timeout=timeout)
319
+ summary.results[gid] = result
320
+ if result.return_code == 0:
321
+ summary.passed.append(gid)
322
+ else:
323
+ summary.failed.append(gid)
324
+ return summary
325
+
326
+
327
+ def verify_guarantee(
328
+ provider_meta: FileMeta,
329
+ provider: str,
330
+ gid: str,
331
+ *,
332
+ timeout: int = -1,
333
+ ) -> VerifyModel:
334
+ """点名验证单条保证——无视 heavy 阈值,永远跑(你点了名就是要跑)。"""
335
+ guarantee = provider_meta.provides.get(gid)
336
+ if guarantee is None:
337
+ raise GuaranteeNotFoundError(target_file=provider, guarantee_path=gid)
338
+ return _run_test(guarantee, timeout=timeout)
@@ -0,0 +1,43 @@
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
+ """GBC 面向用户输出的多语言层。
16
+
17
+ 对外提供三件事:
18
+ - `resolve_lang(...)` 语言判定:显式 lang > GBC_LANG > 系统 locale > 默认 en。
19
+ - `t(key, **kw)` 短消息查表(catalog),按当前语言取串并做 str.format 填充。
20
+ - `load_text(name)` 长文本(rules / init 引导)按当前语言整篇读出 Markdown 资源。
21
+
22
+ 设计取舍:短消息用**轻量 dict catalog**(见 catalog.py)而非 gettext——无编译步骤、
23
+ 随包走、透明易测;若将来规模变大可平滑迁移。
24
+ """
25
+ from gbc.app.i18n.lang import (
26
+ DEFAULT_LANG,
27
+ supported_langs,
28
+ resolve_lang,
29
+ set_lang,
30
+ current_lang,
31
+ )
32
+ from gbc.app.i18n.translate import t, load_text, catalog_keys
33
+
34
+ __all__ = [
35
+ "DEFAULT_LANG",
36
+ "supported_langs",
37
+ "resolve_lang",
38
+ "set_lang",
39
+ "current_lang",
40
+ "t",
41
+ "load_text",
42
+ "catalog_keys",
43
+ ]
gbc/app/i18n/lang.py ADDED
@@ -0,0 +1,88 @@
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
+ """语言判定 + **从文件自动发现受支持语言**。
16
+
17
+ 关键设计:受支持语言不硬编码,而是扫描资源文件推导——放一个 `catalog/ja.json`
18
+ (以及可选的 `texts/*.ja.md`)就自动支持日语,无需改代码。
19
+
20
+ 判定优先级(高→低):显式 lang 参数 > 环境变量 GBC_LANG > 系统 locale > 默认 en。
21
+ """
22
+ import locale
23
+ import os
24
+ from gbc.app.assets import I18N_CATALOG_DIR as _CATALOG_DIR
25
+
26
+ DEFAULT_LANG = "en"
27
+
28
+ # 进程内当前语言(命令入口解析一次后 set_lang 固定;库函数读 current_lang)。
29
+ _current: str = DEFAULT_LANG
30
+
31
+
32
+ def supported_langs() -> tuple[str, ...]:
33
+ """扫描 catalog 目录,把每个 `<lang>.json` 的文件名主干当作一门受支持语言。
34
+
35
+ 永远包含 DEFAULT_LANG(en 必须存在,作为兜底)。结果排序稳定,en 置首。
36
+ """
37
+ langs: set[str] = {DEFAULT_LANG}
38
+ if _CATALOG_DIR.is_dir():
39
+ for f in _CATALOG_DIR.glob("*.json"):
40
+ langs.add(f.stem)
41
+ ordered = [DEFAULT_LANG] + sorted(langs - {DEFAULT_LANG})
42
+ return tuple(ordered)
43
+
44
+
45
+ def normalize(raw: str | None) -> str | None:
46
+ """把形如 zh_CN.UTF-8 / zh-Hans / EN 的原始值归一到受支持码;不支持则 None。"""
47
+ if not raw:
48
+ return None
49
+ low = raw.strip().lower().replace("_", "-")
50
+ primary = low.split("-", 1)[0].split(".", 1)[0]
51
+ if primary in supported_langs():
52
+ return primary
53
+ return None
54
+
55
+
56
+ def resolve_lang(explicit: str | None = None) -> str:
57
+ """按优先级判定语言,永远返回一个受支持码(兜底 DEFAULT_LANG)。
58
+
59
+ 显式 explicit > 环境变量 GBC_LANG > 系统 locale > DEFAULT_LANG。
60
+ """
61
+ for candidate in (explicit, os.environ.get("GBC_LANG")):
62
+ norm = normalize(candidate)
63
+ if norm:
64
+ return norm
65
+
66
+ try:
67
+ sys_lang, _ = locale.getlocale()
68
+ except (ValueError, TypeError):
69
+ sys_lang = None
70
+ if not sys_lang:
71
+ sys_lang = os.environ.get("LANG") or os.environ.get("LC_ALL")
72
+ norm = normalize(sys_lang)
73
+ if norm:
74
+ return norm
75
+
76
+ return DEFAULT_LANG
77
+
78
+
79
+ def set_lang(lang: str) -> str:
80
+ """固定进程内当前语言(通常在命令入口解析后调用一次)。返回归一后的实际语言。"""
81
+ global _current
82
+ _current = normalize(lang) or DEFAULT_LANG
83
+ return _current
84
+
85
+
86
+ def current_lang() -> str:
87
+ """读取进程内当前语言。"""
88
+ return _current
@@ -0,0 +1,80 @@
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
+ """翻译取值:短消息 t() + 长文本 load_text()。
16
+
17
+ 短消息 catalog 从 `catalog/<lang>.json` 按需加载(带缓存);长文本从
18
+ `texts/<name>.<lang>.md` 整篇读出。两者都在缺当前语言时回退 DEFAULT_LANG。
19
+ 放文件即加语言,无需改代码。
20
+ """
21
+ import json
22
+
23
+ from gbc.app.assets import I18N_CATALOG_DIR as _CATALOG_DIR, I18N_TEXTS_DIR as _TEXTS_DIR
24
+ from gbc.app.i18n.lang import DEFAULT_LANG, current_lang
25
+
26
+ # 每语言 catalog 缓存:lang -> {key: str}
27
+ _catalog_cache: dict[str, dict[str, str]] = {}
28
+
29
+
30
+ def _load_catalog(lang: str) -> dict[str, str]:
31
+ if lang not in _catalog_cache:
32
+ path = _CATALOG_DIR / f"{lang}.json"
33
+ if path.exists():
34
+ try:
35
+ _catalog_cache[lang] = json.loads(path.read_text(encoding="utf-8"))
36
+ except (ValueError, OSError):
37
+ _catalog_cache[lang] = {}
38
+ else:
39
+ _catalog_cache[lang] = {}
40
+ return _catalog_cache[lang]
41
+
42
+
43
+ def t(key: str, *, lang: str | None = None, **kw) -> str:
44
+ """取短消息:按 key 查当前语言 catalog,缺失回退 DEFAULT_LANG,再缺回退 key 本身。
45
+
46
+ 串内 {name} 占位用 **kw 填充;填充缺参时不炸,原样保留。
47
+ """
48
+ use = lang or current_lang()
49
+ text = _load_catalog(use).get(key)
50
+ if text is None and use != DEFAULT_LANG:
51
+ text = _load_catalog(DEFAULT_LANG).get(key)
52
+ if text is None:
53
+ return key
54
+ if kw:
55
+ try:
56
+ return text.format(**kw)
57
+ except (KeyError, IndexError):
58
+ return text
59
+ return text
60
+
61
+
62
+ def load_text(name: str, *, lang: str | None = None) -> str:
63
+ """整篇读出长文本资源(rules / init 引导等)。
64
+
65
+ 找 <name>.<当前语言>.md;缺则回退 <name>.<DEFAULT_LANG>.md;再缺抛 FileNotFoundError。
66
+ """
67
+ use = lang or current_lang()
68
+ candidate = _TEXTS_DIR / f"{name}.{use}.md"
69
+ if not candidate.exists():
70
+ candidate = _TEXTS_DIR / f"{name}.{DEFAULT_LANG}.md"
71
+ if not candidate.exists():
72
+ raise FileNotFoundError(
73
+ f"i18n long-text resource not found: {name} (lang={use}, dir={_TEXTS_DIR})"
74
+ )
75
+ return candidate.read_text(encoding="utf-8")
76
+
77
+
78
+ def catalog_keys(lang: str | None = None) -> set[str]:
79
+ """某语言 catalog 的全部键(测试/校验漏译用)。默认 DEFAULT_LANG。"""
80
+ return set(_load_catalog(lang or DEFAULT_LANG).keys())
@@ -0,0 +1,20 @@
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
+ """意图文档(gbc.md)子系统。
16
+
17
+ 与保证引擎(core + interface)对称:`base` 是唯一 IO/编排点(路径解析、gbc.md
18
+ 读写、父子投影、一致性、整树读写);`cli`/`editor` 都是薄表面,只调 base、不碰磁盘。
19
+ gbc.md 解析单源复用 gbc.app.utils.gbc_md。
20
+ """