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.
- gbc/__init__.py +21 -0
- gbc/app/__init__.py +14 -0
- gbc/app/assets.py +38 -0
- gbc/app/config/__init__.py +14 -0
- gbc/app/config/backups.py +28 -0
- gbc/app/config/base.py +29 -0
- gbc/app/config/executor.py +132 -0
- gbc/app/config/project.py +35 -0
- gbc/app/core/__init__.py +14 -0
- gbc/app/core/env.py +64 -0
- gbc/app/core/executor.py +116 -0
- gbc/app/core/guarantee.py +338 -0
- gbc/app/i18n/__init__.py +43 -0
- gbc/app/i18n/lang.py +88 -0
- gbc/app/i18n/translate.py +80 -0
- gbc/app/intent/__init__.py +20 -0
- gbc/app/intent/base.py +308 -0
- gbc/app/intent/cli.py +124 -0
- gbc/app/intent/editor.py +93 -0
- gbc/app/interface/__init__.py +14 -0
- gbc/app/interface/base.py +851 -0
- gbc/app/interface/cli.py +585 -0
- gbc/app/interface/mcp.py +616 -0
- gbc/app/models/__init__.py +14 -0
- gbc/app/models/errors.py +179 -0
- gbc/app/models/meta.py +92 -0
- gbc/app/models/verify.py +63 -0
- gbc/app/utils/__init__.py +14 -0
- gbc/app/utils/file_utils.py +24 -0
- gbc/app/utils/gbc_md.py +121 -0
- gbc/app/utils/json_model_operator.py +85 -0
- gbc/app/utils/safe_file_writer.py +158 -0
- gbc/assets/editor/index.html +299 -0
- gbc/assets/i18n/catalog/en.json +52 -0
- gbc/assets/i18n/catalog/zh.json +52 -0
- gbc/assets/i18n/texts/rules.en.md +30 -0
- gbc/assets/i18n/texts/rules.zh.md +24 -0
- gbc/assets/i18n/texts/setup.en.md +74 -0
- gbc/assets/i18n/texts/setup.zh.md +69 -0
- gbc/assets/skills/README.md +16 -0
- gbc/assets/skills/gbc-cli/SKILL.md +143 -0
- gbc/entry.py +126 -0
- guarantee_based_coding-0.2.0.dist-info/METADATA +108 -0
- guarantee_based_coding-0.2.0.dist-info/RECORD +48 -0
- guarantee_based_coding-0.2.0.dist-info/WHEEL +5 -0
- guarantee_based_coding-0.2.0.dist-info/entry_points.txt +2 -0
- guarantee_based_coding-0.2.0.dist-info/licenses/LICENSE +202 -0
- guarantee_based_coding-0.2.0.dist-info/top_level.txt +1 -0
gbc/app/models/errors.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _t(key: str, **kw) -> str:
|
|
19
|
+
"""惰性取本地化消息。在 __str__ 内部调用,避免 models 层模块级依赖 i18n,
|
|
20
|
+
保持核心数据契约层干净。i18n 不可用时回退到 key。"""
|
|
21
|
+
try:
|
|
22
|
+
from gbc.app.i18n import t
|
|
23
|
+
return t(key, **kw)
|
|
24
|
+
except Exception:
|
|
25
|
+
return key
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GBCError(Exception):
|
|
29
|
+
"""
|
|
30
|
+
所有GBC异常的基类
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
class ConfigError(GBCError):
|
|
34
|
+
"""
|
|
35
|
+
所有配置文件相关的Error
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
class ExecutorError(GBCError):
|
|
39
|
+
"""
|
|
40
|
+
执行器模块的Error
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
class GuaranteeError(GBCError):
|
|
44
|
+
"""
|
|
45
|
+
和保证相关的Error
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
class IllegalOperationError(GBCError):
|
|
49
|
+
"""
|
|
50
|
+
非法操作,操作意图层面就是错误的
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
# ======== Illegal Operation ========
|
|
54
|
+
class IntentDocError(IllegalOperationError):
|
|
55
|
+
"""意图文档(gbc.md)领域的非法操作。携带 i18n key + 参数,__str__ 时本地化。"""
|
|
56
|
+
def __init__(self, msg_key: str, **params):
|
|
57
|
+
super().__init__(msg_key)
|
|
58
|
+
self.msg_key = msg_key
|
|
59
|
+
self.params = params
|
|
60
|
+
|
|
61
|
+
def __str__(self):
|
|
62
|
+
return _t(self.msg_key, **self.params)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class IllegalFilePathError(IllegalOperationError):
|
|
66
|
+
"""
|
|
67
|
+
非法操作,操作意图层面就是错误的
|
|
68
|
+
"""
|
|
69
|
+
def __init__(self, target_file):
|
|
70
|
+
super().__init__(target_file)
|
|
71
|
+
self.target_file = target_file
|
|
72
|
+
|
|
73
|
+
def __str__(self):
|
|
74
|
+
return _t("exc.illegal_file_path", target=self.target_file)
|
|
75
|
+
|
|
76
|
+
# ======== Config ========
|
|
77
|
+
|
|
78
|
+
class ConfigNotFoundError(ConfigError):
|
|
79
|
+
def __init__(self, target_file):
|
|
80
|
+
super().__init__(target_file)
|
|
81
|
+
self.target_file = target_file
|
|
82
|
+
|
|
83
|
+
def __str__(self):
|
|
84
|
+
return _t("exc.config_not_found", target=self.target_file)
|
|
85
|
+
|
|
86
|
+
class ConfigParseError(ConfigError):
|
|
87
|
+
def __init__(self, target_file, failure_info):
|
|
88
|
+
super().__init__(target_file)
|
|
89
|
+
self.target_file = target_file
|
|
90
|
+
self.failure_info = failure_info
|
|
91
|
+
|
|
92
|
+
def __str__(self):
|
|
93
|
+
return _t("exc.config_parse", target=self.target_file, info=self.failure_info)
|
|
94
|
+
|
|
95
|
+
# ======== Project ========
|
|
96
|
+
|
|
97
|
+
class ProjectNotFoundError(GBCError):
|
|
98
|
+
def __init__(self, target_project):
|
|
99
|
+
super().__init__(target_project)
|
|
100
|
+
self.target_project = target_project
|
|
101
|
+
|
|
102
|
+
def __str__(self):
|
|
103
|
+
return _t("exc.project_not_found", target=self.target_project)
|
|
104
|
+
|
|
105
|
+
# ======== Meta ========
|
|
106
|
+
|
|
107
|
+
class MetaNotFoundError(GBCError):
|
|
108
|
+
def __init__(self, original_file, target_file):
|
|
109
|
+
super().__init__(target_file)
|
|
110
|
+
self.original_file = original_file
|
|
111
|
+
self.target_file = target_file
|
|
112
|
+
|
|
113
|
+
def __str__(self):
|
|
114
|
+
return _t("exc.meta_not_found", target=self.target_file, original=self.original_file)
|
|
115
|
+
|
|
116
|
+
# ======== Guarantee ========
|
|
117
|
+
|
|
118
|
+
class GuaranteeDuplicatedError(GuaranteeError):
|
|
119
|
+
def __init__(self, target_file: str, guarantee_path: str):
|
|
120
|
+
super().__init__(target_file)
|
|
121
|
+
self.target_file = target_file
|
|
122
|
+
self.guarantee_path = guarantee_path
|
|
123
|
+
|
|
124
|
+
def __str__(self):
|
|
125
|
+
return _t("exc.guarantee_duplicated", gid=self.guarantee_path, target=self.target_file)
|
|
126
|
+
|
|
127
|
+
class GuaranteeNotFoundError(GuaranteeError):
|
|
128
|
+
def __init__(self, target_file: str, guarantee_path: str):
|
|
129
|
+
super().__init__(target_file)
|
|
130
|
+
self.target_file = target_file
|
|
131
|
+
self.guarantee_path = guarantee_path
|
|
132
|
+
|
|
133
|
+
def __str__(self):
|
|
134
|
+
return _t("exc.guarantee_not_found", gid=self.guarantee_path, target=self.target_file)
|
|
135
|
+
|
|
136
|
+
class GuaranteeTestFailedError(GuaranteeError):
|
|
137
|
+
def __init__(self, target_file: str, guarantee_path: str, failure_info: str):
|
|
138
|
+
super().__init__(target_file)
|
|
139
|
+
self.target_file = target_file
|
|
140
|
+
self.guarantee_path = guarantee_path
|
|
141
|
+
self.failure_info = failure_info
|
|
142
|
+
|
|
143
|
+
def __str__(self):
|
|
144
|
+
return _t("exc.guarantee_test_failed", gid=self.guarantee_path, target=self.target_file, info=self.failure_info)
|
|
145
|
+
|
|
146
|
+
class GuaranteeHasDependentsError(GuaranteeError):
|
|
147
|
+
"""退休保护:拒绝删除仍有 dependents 的保证。
|
|
148
|
+
|
|
149
|
+
系统不替使用者「反射式」删掉一条还有人依赖的保证——那必然悄悄弄坏下游。
|
|
150
|
+
必须先沿依赖线把 dependents 修复/迁移掉,dependents 清空后才允许退休。
|
|
151
|
+
"""
|
|
152
|
+
def __init__(self, provider: str, guarantee_id: str, dependents: list[str]):
|
|
153
|
+
super().__init__(guarantee_id)
|
|
154
|
+
self.provider = provider
|
|
155
|
+
self.guarantee_id = guarantee_id
|
|
156
|
+
self.dependents = dependents
|
|
157
|
+
|
|
158
|
+
def __str__(self):
|
|
159
|
+
return _t("exc.guarantee_has_dependents", gid=self.guarantee_id,
|
|
160
|
+
provider=self.provider, count=len(self.dependents), dependents=self.dependents)
|
|
161
|
+
|
|
162
|
+
# ======== Executor ========
|
|
163
|
+
|
|
164
|
+
class ExecutorNotFoundError(ExecutorError):
|
|
165
|
+
def __init__(self, config):
|
|
166
|
+
super().__init__(config)
|
|
167
|
+
self.config = config
|
|
168
|
+
|
|
169
|
+
def __str__(self):
|
|
170
|
+
return _t("exc.executor_not_found", name=self.config)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ExecutorConfigInvalidError(ExecutorError):
|
|
174
|
+
def __init__(self, config):
|
|
175
|
+
super().__init__(config)
|
|
176
|
+
self.config = config
|
|
177
|
+
|
|
178
|
+
def __str__(self):
|
|
179
|
+
return _t("exc.executor_config_invalid", name=self.config)
|
gbc/app/models/meta.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
# ============================================================================
|
|
18
|
+
# .gbc 元数据模型
|
|
19
|
+
#
|
|
20
|
+
# 每个代码文件对应一份 .gbc json(FileMeta),它是「双段自包含」的:
|
|
21
|
+
# - provides : 本文件作为 provider 提供的具名保证(保证为一等公民)
|
|
22
|
+
# - depends_on : 本文件作为 consumer 声明的依赖边
|
|
23
|
+
# 两段分别承载依赖图的两个方向,由工具层负责跨文件原子地保持双向一致。
|
|
24
|
+
# ============================================================================
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Guarantee(BaseModel):
|
|
28
|
+
"""一条具名行为保证,由某个 provider 文件持有、唯一一份。
|
|
29
|
+
|
|
30
|
+
身份 = 它在 ``FileMeta.provides`` 中的 key(一个语义化点路径 id,例如
|
|
31
|
+
``"config.llm.get_model.returns_loaded"``),**不是** 测试路径——改测试
|
|
32
|
+
文件名不会改变保证的身份;多个消费者共享同一条保证时也只认这个 id。
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# 承诺了什么行为、以及为何需要它(富描述,给人和 agent 读)
|
|
36
|
+
desc: str
|
|
37
|
+
|
|
38
|
+
# 测试选择器:交给 executor 做 {file} 替换的那个 str(= 旧 guarantee_path)。
|
|
39
|
+
# 与 executor 分离——这里只说「跑哪个测试」,不说「怎么跑」。
|
|
40
|
+
test: str
|
|
41
|
+
|
|
42
|
+
# executor 配置名:决定「怎么跑」这个测试(命令/cwd/env/默认超时)。
|
|
43
|
+
executor: str
|
|
44
|
+
|
|
45
|
+
# 单条保证的超时覆写;-1 表示回退到 executor 的默认超时。
|
|
46
|
+
timeout_override: int = -1
|
|
47
|
+
|
|
48
|
+
# 成本秩 + 自动运行授权等级。0 = 普通,批量必跑;>=1 = 贵/慢(如需 LLM),
|
|
49
|
+
# 批量 verify 中按阈值跳过并响亮报告。数字越大越「别随便跑」,高到一定
|
|
50
|
+
# 程度应由人类决定是否运行。批量只跑 heavy <= 阈值的;register / 点名
|
|
51
|
+
# verify_single 无视它、永远跑。
|
|
52
|
+
heavy: int = 0
|
|
53
|
+
|
|
54
|
+
# 依赖这条保证的消费者文件路径列表(多对一:一条保证可被多个文件依赖)。
|
|
55
|
+
# 这是反向边;非空即代表「还有人靠着它」,退休保护据此拒绝删除。
|
|
56
|
+
dependents: list[str] = Field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
# 临时停用:True 时保证的 id 与全部边(dependents/反向边)原样保留,但「出生即绿」
|
|
59
|
+
# 门禁与批量 verify 都对它**暂缓执行**——不跑、不判失败、进 skipped(reason=disabled)。
|
|
60
|
+
# 用途:① 重构窗口(refactor 期间测试会暂时跑不过,先 disable 守住边,改完再 enable
|
|
61
|
+
# 重跑门禁);② 循环依赖 bootstrap(测试还过不了时先占位注册);③ 暂停在修的保证。
|
|
62
|
+
# 三者本质都是「留住 id+边,但此刻先不强制测试」。disabled 是 born-green 墙上的一个
|
|
63
|
+
# 洞,因此它必须**永远是响的**:check_consistency 始终把 disabled 保证及「依赖了
|
|
64
|
+
# disabled 保证」的边报出来(non-empty),tree 用 ⊘ 标记——藏不住,才不会悄悄烂掉。
|
|
65
|
+
disabled: bool = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Dependency(BaseModel):
|
|
69
|
+
"""本文件(作为 consumer)声明的一条依赖边。
|
|
70
|
+
|
|
71
|
+
``symbol`` 写成 ``"<provider 文件>:<符号名>"``,既标明依赖了谁的哪个符号,
|
|
72
|
+
也隐含了 provider 文件(取 ``:`` 前半段)。
|
|
73
|
+
|
|
74
|
+
``guarantees`` 列出本文件依赖的、该 provider 上的具名保证 id:
|
|
75
|
+
- 空列表 = symbol 级「免费依赖」:只依赖符号存在/签名,不依赖具体行为,
|
|
76
|
+
不需要、也不创建保证与测试。
|
|
77
|
+
- 非空 = 行为级依赖:每个 id 必须对应 provider ``provides`` 里的一条保证,
|
|
78
|
+
且该保证的 ``dependents`` 里登记了本文件(双向一致)。
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
symbol: str
|
|
82
|
+
guarantees: list[str] = Field(default_factory=list)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class FileMeta(BaseModel):
|
|
86
|
+
"""单个代码文件的 .gbc 元数据,对 provider / consumer 两种角色都自包含。"""
|
|
87
|
+
|
|
88
|
+
# 本文件作为 provider 提供的保证:key = 保证 id。
|
|
89
|
+
provides: dict[str, Guarantee] = Field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
# 本文件作为 consumer 声明的依赖边。
|
|
92
|
+
depends_on: list[Dependency] = Field(default_factory=list)
|
gbc/app/models/verify.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
# ============================================================================
|
|
18
|
+
# 验证结果模型
|
|
19
|
+
#
|
|
20
|
+
# 分两层:
|
|
21
|
+
# - VerifyModel : 单个测试跑完的「原始结果」(由 core/executor 直接产出)。
|
|
22
|
+
# - VerifySummary : 一次批量 verify 的「三桶汇总」,门禁判定从它读出。
|
|
23
|
+
#
|
|
24
|
+
# 门禁是二元的:跑了的测试只有 过(green) / 挂(red);没跑的测试不是「一种结果」
|
|
25
|
+
# 而是「缺席」(heavy 跳过)。所以 skipped 不染红,green = (failed 为空)。
|
|
26
|
+
# ============================================================================
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class VerifyModel(BaseModel):
|
|
30
|
+
"""单个测试跑完的原始结果。"""
|
|
31
|
+
|
|
32
|
+
return_code: int
|
|
33
|
+
stdout: None | str
|
|
34
|
+
stderr: None | str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SkippedGuarantee(BaseModel):
|
|
38
|
+
"""一条被跳过、未运行的保证(当前只因 heavy 超过运行阈值)。"""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
heavy: int
|
|
42
|
+
reason: str = "heavy"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class VerifySummary(BaseModel):
|
|
46
|
+
"""一次(批量)verify 的三桶汇总 + 门禁判定。
|
|
47
|
+
|
|
48
|
+
passed / failed / skipped 三个桶按保证 id 分类;``results`` 保留真正跑过的
|
|
49
|
+
保证的原始输出,供失败时排查。skipped 必须被响亮报告("X heavy skipped"),
|
|
50
|
+
让调用方知道「即便全绿,问题也可能出在这些没跑的保证上」。
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
passed: list[str] = Field(default_factory=list)
|
|
54
|
+
failed: list[str] = Field(default_factory=list)
|
|
55
|
+
skipped: list[SkippedGuarantee] = Field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
# 真正跑过的保证 id -> 原始结果(passed/failed 都收,skipped 不收)。
|
|
58
|
+
results: dict[str, VerifyModel] = Field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def green(self) -> bool:
|
|
62
|
+
"""门禁是否通过:没有任何失败即为绿(skipped 不影响)。"""
|
|
63
|
+
return not self.failed
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from gbc.app.config.project import get_current_project
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def to_gbc_json_path(provider: Path):
|
|
21
|
+
relative_path = provider.relative_to(get_current_project())
|
|
22
|
+
target_dir = get_current_project() / ".gbc" / relative_path.parent
|
|
23
|
+
target_filename = f"gbc.{relative_path.name}.json"
|
|
24
|
+
return target_dir / target_filename
|
gbc/app/utils/gbc_md.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
"""Parse / serialize a single GBC `gbc.md` file — the canonical parser.
|
|
16
|
+
|
|
17
|
+
This is the single authoritative parser/serializer for the `gbc.md` format. Both
|
|
18
|
+
`interface.base.render_tree` (engine side) and the standalone intent-editor tool
|
|
19
|
+
build on it; neither re-implements the format. Pure text handling, no IO.
|
|
20
|
+
|
|
21
|
+
Format:
|
|
22
|
+
|
|
23
|
+
# 意图
|
|
24
|
+
<intent text, may span multiple paragraphs>
|
|
25
|
+
|
|
26
|
+
# 内部约束 <- optional H1 block
|
|
27
|
+
<constraints text>
|
|
28
|
+
|
|
29
|
+
# 文件 <- container for child entries (only emitted if any)
|
|
30
|
+
## game.py <- H2 entry; no trailing "/" => plain file
|
|
31
|
+
<desc>
|
|
32
|
+
|
|
33
|
+
## maker/ <- trailing "/" => subfolder
|
|
34
|
+
<desc> (a subfolder's desc == that subfolder's own `# 意图`)
|
|
35
|
+
|
|
36
|
+
H1 blocks: 意图 (intent) + optional 内部约束 (constraints) + 文件 (files container).
|
|
37
|
+
The `# 文件` heading exists so child entries no longer visually nest under 内部约束.
|
|
38
|
+
H2 blocks (the `# 文件` children): one per child file or subfolder under this path.
|
|
39
|
+
|
|
40
|
+
Parsing is lenient: any H2 is treated as a child entry regardless of whether a
|
|
41
|
+
`# 文件` heading precedes it, so old-format docs (entries with no `# 文件` section)
|
|
42
|
+
still parse — re-serializing them upgrades them to the new format.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import re
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
|
|
49
|
+
INTENT_HEADING = "意图"
|
|
50
|
+
CONSTRAINTS_HEADING = "内部约束"
|
|
51
|
+
FILES_HEADING = "文件"
|
|
52
|
+
|
|
53
|
+
_HEADING_RE = re.compile(r"^(#{1,2})\s+(.*?)\s*$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Entry:
|
|
58
|
+
"""An H2 (`##`) child entry: a file or a subfolder."""
|
|
59
|
+
name: str # e.g. "main.py" or "app/" (trailing slash kept for dirs)
|
|
60
|
+
is_dir: bool
|
|
61
|
+
desc: str = "" # for files, the only source; for dirs, mirrors child intent
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ParsedDoc:
|
|
66
|
+
intent: str = ""
|
|
67
|
+
constraints: str = ""
|
|
68
|
+
entries: list[Entry] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse(text: str) -> ParsedDoc:
|
|
72
|
+
"""Parse gbc.md text into intent / constraints / ordered entries."""
|
|
73
|
+
doc = ParsedDoc()
|
|
74
|
+
# current sink: "intent" | "constraints" | entry-index | None (discard)
|
|
75
|
+
body: list[str] = []
|
|
76
|
+
sink: str | int | None = None
|
|
77
|
+
|
|
78
|
+
def flush() -> None:
|
|
79
|
+
content = "\n".join(body).strip()
|
|
80
|
+
if sink == "intent":
|
|
81
|
+
doc.intent = content
|
|
82
|
+
elif sink == "constraints":
|
|
83
|
+
doc.constraints = content
|
|
84
|
+
elif isinstance(sink, int):
|
|
85
|
+
doc.entries[sink].desc = content
|
|
86
|
+
|
|
87
|
+
for line in text.splitlines():
|
|
88
|
+
m = _HEADING_RE.match(line)
|
|
89
|
+
if not m:
|
|
90
|
+
body.append(line)
|
|
91
|
+
continue
|
|
92
|
+
# heading boundary: flush the previous block first
|
|
93
|
+
flush()
|
|
94
|
+
body = []
|
|
95
|
+
level, title = len(m.group(1)), m.group(2).strip()
|
|
96
|
+
if level == 1:
|
|
97
|
+
if title == CONSTRAINTS_HEADING:
|
|
98
|
+
sink = "constraints"
|
|
99
|
+
elif title == FILES_HEADING:
|
|
100
|
+
sink = None # the 文件 container heading has no body; its H2s are entries
|
|
101
|
+
else:
|
|
102
|
+
sink = "intent"
|
|
103
|
+
else: # level 2 -> child entry
|
|
104
|
+
is_dir = title.endswith("/")
|
|
105
|
+
doc.entries.append(Entry(name=title, is_dir=is_dir))
|
|
106
|
+
sink = len(doc.entries) - 1
|
|
107
|
+
flush()
|
|
108
|
+
return doc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def serialize(intent: str, constraints: str, entries: list[Entry]) -> str:
|
|
112
|
+
"""Render intent / constraints / entries back into gbc.md text."""
|
|
113
|
+
blocks: list[str] = []
|
|
114
|
+
blocks.append(f"# {INTENT_HEADING}\n{intent.strip()}".rstrip())
|
|
115
|
+
if constraints.strip():
|
|
116
|
+
blocks.append(f"# {CONSTRAINTS_HEADING}\n{constraints.strip()}".rstrip())
|
|
117
|
+
if entries:
|
|
118
|
+
blocks.append(f"# {FILES_HEADING}")
|
|
119
|
+
for e in entries:
|
|
120
|
+
blocks.append(f"## {e.name}\n{e.desc.strip()}".rstrip())
|
|
121
|
+
return "\n\n".join(blocks) + "\n"
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TypeVar
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ValidationError
|
|
20
|
+
|
|
21
|
+
from gbc.app.utils.safe_file_writer import SafeFileWriter
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T", bound=BaseModel)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_model_from_json(
|
|
27
|
+
filepath: str | Path,
|
|
28
|
+
model_class: type[T]
|
|
29
|
+
) -> T:
|
|
30
|
+
"""
|
|
31
|
+
从指定的 JSON 文件中读取并还原为 Pydantic 模型。
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
filepath: 明确的完整文件路径。
|
|
35
|
+
model_class: 用于解析的 Pydantic 模型类。
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
实例化后的模型对象。
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
FileNotFoundError: 当指定路径不存在时抛出。
|
|
42
|
+
ValidationError: 当 JSON 内容不符合模型定义时抛出。
|
|
43
|
+
"""
|
|
44
|
+
path = Path(filepath)
|
|
45
|
+
|
|
46
|
+
if not path.exists():
|
|
47
|
+
raise FileNotFoundError(f"未找到指定的模型文件: {path}")
|
|
48
|
+
|
|
49
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
50
|
+
try:
|
|
51
|
+
raw_data = json.load(f)
|
|
52
|
+
except json.JSONDecodeError as e:
|
|
53
|
+
raise ValueError(f"解析 JSON 文件失败: {path}, 错误详情: {e}")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
return model_class(**raw_data)
|
|
57
|
+
except ValidationError as e:
|
|
58
|
+
# 在这里可以记录日志或者直接抛出
|
|
59
|
+
raise e
|
|
60
|
+
|
|
61
|
+
def save_model_to_json(
|
|
62
|
+
model: BaseModel,
|
|
63
|
+
filepath: str | Path,
|
|
64
|
+
num_backups: int = 0
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
将 Pydantic 模型安全地序列化为 JSON 文件。
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
model: 要保存的 Pydantic 模型对象。
|
|
71
|
+
filepath: 明确的完整文件路径。
|
|
72
|
+
num_backups: 备份数量,由 SafeFileWriter 处理。
|
|
73
|
+
"""
|
|
74
|
+
path = Path(filepath)
|
|
75
|
+
|
|
76
|
+
# 确保父目录存在
|
|
77
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
writer = SafeFileWriter(path, num_backups=num_backups)
|
|
80
|
+
|
|
81
|
+
# 获取字典数据
|
|
82
|
+
data = model.model_dump()
|
|
83
|
+
|
|
84
|
+
with writer.open(mode='w', encoding='utf-8') as f:
|
|
85
|
+
json.dump(data, f, ensure_ascii=False, indent=4)
|