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/intent/base.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
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 总线 —— 唯一碰 gbc.md 磁盘的地方。
|
|
16
|
+
|
|
17
|
+
对称于 interface.base(保证引擎的总线):
|
|
18
|
+
- 路径解析(.gbc 镜像层)、单文档读写、父子意图单源投影都收在这里;
|
|
19
|
+
- 单文档操作(set_intent/set_constraints/set_file/rm_entry/show);
|
|
20
|
+
- 整树读写(read_tree/write_tree)供 web 编辑器用;
|
|
21
|
+
- 全树一致性(check/sync/migrate)。
|
|
22
|
+
|
|
23
|
+
上层表面(cli / editor)只调本模块,绝不自己碰磁盘或解析路径。
|
|
24
|
+
gbc.md 解析单源复用 gbc.app.utils.gbc_md。
|
|
25
|
+
"""
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from gbc.app.utils import gbc_md as gf
|
|
29
|
+
from gbc.app.models.errors import IntentDocError
|
|
30
|
+
|
|
31
|
+
GBC_FILE = "gbc.md"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---- 路径解析(.gbc 镜像层) --------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def resolve_gbc(root_str: str) -> tuple[Path, str]:
|
|
37
|
+
"""把用户给的路径映射成 (gbc_root, project_name)。
|
|
38
|
+
|
|
39
|
+
`.gbc` 镜像层对树是隐藏的:展示的根是**项目**目录,内容始终落在 <project>/.gbc/ 下。
|
|
40
|
+
- 路径以 `.gbc` 结尾 -> gbc_root = 该路径, project = 其父目录名
|
|
41
|
+
- 否则 -> gbc_root = 路径/.gbc, project = 该路径名
|
|
42
|
+
因此加载 `…/proj` 与 `…/proj/.gbc` 行为一致。
|
|
43
|
+
"""
|
|
44
|
+
p = Path(root_str).expanduser().resolve()
|
|
45
|
+
if p.name == ".gbc":
|
|
46
|
+
return p, p.parent.name
|
|
47
|
+
return p / ".gbc", p.name
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _norm(rel: str) -> str:
|
|
51
|
+
"""规范化文件夹相对路径:去首尾斜杠;'.' 与 '' 都表示根。"""
|
|
52
|
+
rel = (rel or "").strip().strip("/")
|
|
53
|
+
return "" if rel in ("", ".") else rel
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _doc_path(gbc_root: Path, rel: str) -> Path:
|
|
57
|
+
return (gbc_root / rel / GBC_FILE) if rel else (gbc_root / GBC_FILE)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parent_name(rel: str) -> tuple[str | None, str | None]:
|
|
61
|
+
"""(父文件夹 rel, 本文件夹名)。根 -> (None, None)。"""
|
|
62
|
+
rel = _norm(rel)
|
|
63
|
+
if not rel:
|
|
64
|
+
return None, None
|
|
65
|
+
parts = rel.split("/")
|
|
66
|
+
return "/".join(parts[:-1]), parts[-1]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _find_entry(doc: gf.ParsedDoc, name: str) -> gf.Entry | None:
|
|
70
|
+
key = name.rstrip("/")
|
|
71
|
+
for e in doc.entries:
|
|
72
|
+
if e.name.rstrip("/") == key:
|
|
73
|
+
return e
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---- 单文档读写(唯一 IO 点) ------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def read_doc(gbc_root: Path, rel: str) -> gf.ParsedDoc:
|
|
80
|
+
p = _doc_path(gbc_root, rel)
|
|
81
|
+
return gf.parse(p.read_text(encoding="utf-8")) if p.exists() else gf.ParsedDoc()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write_doc(gbc_root: Path, rel: str, doc: gf.ParsedDoc) -> Path:
|
|
85
|
+
p = _doc_path(gbc_root, rel)
|
|
86
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
p.write_text(gf.serialize(doc.intent, doc.constraints, doc.entries), encoding="utf-8")
|
|
88
|
+
return p
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---- 单文档操作 -------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def set_intent(gbc_root: Path, rel: str, text: str) -> list[Path]:
|
|
94
|
+
"""设文件夹意图,并单源投影到父文档对应的 `## <name>/` 条目。"""
|
|
95
|
+
rel = _norm(rel)
|
|
96
|
+
doc = read_doc(gbc_root, rel)
|
|
97
|
+
doc.intent = text
|
|
98
|
+
written = [write_doc(gbc_root, rel, doc)]
|
|
99
|
+
|
|
100
|
+
parent_rel, name = _parent_name(rel)
|
|
101
|
+
if name is not None: # 非根:把意图投影到父条目(单一事实源)
|
|
102
|
+
pdoc = read_doc(gbc_root, parent_rel)
|
|
103
|
+
entry = _find_entry(pdoc, name)
|
|
104
|
+
if entry is None:
|
|
105
|
+
pdoc.entries.append(gf.Entry(name=f"{name}/", is_dir=True, desc=text))
|
|
106
|
+
else:
|
|
107
|
+
entry.name = f"{name}/"
|
|
108
|
+
entry.is_dir = True
|
|
109
|
+
entry.desc = text
|
|
110
|
+
written.append(write_doc(gbc_root, parent_rel, pdoc))
|
|
111
|
+
return written
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def set_constraints(gbc_root: Path, rel: str, text: str) -> list[Path]:
|
|
115
|
+
rel = _norm(rel)
|
|
116
|
+
doc = read_doc(gbc_root, rel)
|
|
117
|
+
doc.constraints = text
|
|
118
|
+
return [write_doc(gbc_root, rel, doc)]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def set_file(gbc_root: Path, rel: str, name: str, desc: str) -> list[Path]:
|
|
122
|
+
"""新增/更新一个文件条目(name 不带斜杠)。"""
|
|
123
|
+
if name.rstrip().endswith("/"):
|
|
124
|
+
raise IntentDocError("exc.doc_filename_slash", name=name)
|
|
125
|
+
rel = _norm(rel)
|
|
126
|
+
doc = read_doc(gbc_root, rel)
|
|
127
|
+
entry = _find_entry(doc, name)
|
|
128
|
+
if entry is not None and entry.is_dir:
|
|
129
|
+
raise IntentDocError("exc.doc_name_is_folder", name=name)
|
|
130
|
+
if entry is None:
|
|
131
|
+
doc.entries.append(gf.Entry(name=name, is_dir=False, desc=desc))
|
|
132
|
+
else:
|
|
133
|
+
entry.desc = desc
|
|
134
|
+
return [write_doc(gbc_root, rel, doc)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def rm_entry(gbc_root: Path, rel: str, name: str) -> list[Path]:
|
|
138
|
+
"""从文件夹文档里删掉一个条目(只改文档,不删盘上文件/子目录,留给 git 复核)。"""
|
|
139
|
+
rel = _norm(rel)
|
|
140
|
+
doc = read_doc(gbc_root, rel)
|
|
141
|
+
before = len(doc.entries)
|
|
142
|
+
key = name.rstrip("/")
|
|
143
|
+
doc.entries = [e for e in doc.entries if e.name.rstrip("/") != key]
|
|
144
|
+
if len(doc.entries) == before:
|
|
145
|
+
raise IntentDocError("exc.doc_entry_not_found", name=name)
|
|
146
|
+
return [write_doc(gbc_root, rel, doc)]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def show(gbc_root: Path, rel: str) -> str:
|
|
150
|
+
rel = _norm(rel)
|
|
151
|
+
p = _doc_path(gbc_root, rel)
|
|
152
|
+
if not p.exists():
|
|
153
|
+
return f"(无 gbc.md) {p}"
|
|
154
|
+
doc = read_doc(gbc_root, rel)
|
|
155
|
+
lines = [f"# 文件夹: {rel or '(根)'} -> {p}", "", "[意图]", doc.intent or "(空)"]
|
|
156
|
+
if doc.constraints:
|
|
157
|
+
lines += ["", "[内部约束]", doc.constraints]
|
|
158
|
+
lines += ["", "[文件]"]
|
|
159
|
+
lines += [f" {'📁' if e.is_dir else '📄'} {e.name}: {e.desc}" for e in doc.entries] or [" (无)"]
|
|
160
|
+
return "\n".join(lines)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---- 整树读写(供 web 编辑器) -----------------------------------------------
|
|
164
|
+
|
|
165
|
+
def empty_tree(gbc_root: Path) -> dict:
|
|
166
|
+
return {"name": gbc_root.name, "path": "", "intent": "", "constraints": "", "entries": []}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def read_tree(abs_dir: Path, rel: str = "") -> dict:
|
|
170
|
+
"""把一个 .gbc 目录读成嵌套 dict 树(供前端编辑)。条目类型由名字尾部 '/' 推导。"""
|
|
171
|
+
md = abs_dir / GBC_FILE
|
|
172
|
+
doc = gf.parse(md.read_text(encoding="utf-8")) if md.exists() else gf.ParsedDoc()
|
|
173
|
+
|
|
174
|
+
entries: list[dict] = []
|
|
175
|
+
seen_dirs: set[str] = set()
|
|
176
|
+
for e in doc.entries:
|
|
177
|
+
if not e.is_dir:
|
|
178
|
+
entries.append({"name": e.name, "desc": e.desc})
|
|
179
|
+
continue
|
|
180
|
+
child_name = e.name.rstrip("/")
|
|
181
|
+
seen_dirs.add(child_name)
|
|
182
|
+
child_abs = abs_dir / child_name
|
|
183
|
+
child_rel = f"{rel}/{child_name}".lstrip("/") if rel else child_name
|
|
184
|
+
if (child_abs / GBC_FILE).exists():
|
|
185
|
+
child = read_tree(child_abs, child_rel) # 子自己的 gbc.md 是权威
|
|
186
|
+
else:
|
|
187
|
+
child = {"name": child_name, "path": child_rel,
|
|
188
|
+
"intent": e.desc, "constraints": "", "entries": []}
|
|
189
|
+
entries.append({"name": e.name, "child": child})
|
|
190
|
+
|
|
191
|
+
# 有 gbc.md 但父条目没引用的子文件夹,也一并浮现
|
|
192
|
+
for sub in sorted(p for p in abs_dir.iterdir() if p.is_dir() and (p / GBC_FILE).exists()):
|
|
193
|
+
if sub.name in seen_dirs:
|
|
194
|
+
continue
|
|
195
|
+
child_rel = f"{rel}/{sub.name}".lstrip("/") if rel else sub.name
|
|
196
|
+
entries.append({"name": f"{sub.name}/", "child": read_tree(sub, child_rel)})
|
|
197
|
+
|
|
198
|
+
return {"name": abs_dir.name, "path": rel,
|
|
199
|
+
"intent": doc.intent, "constraints": doc.constraints, "entries": entries}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _is_dir_name(name: str) -> bool:
|
|
203
|
+
return name.rstrip().endswith("/")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def write_tree(node_dir: Path, node: dict, written: list[str] | None = None) -> list[str]:
|
|
207
|
+
"""把嵌套 dict 树写回文件系统。目标目录由树结构(父目录 + 条目名)推导,
|
|
208
|
+
改名即写到新位置——绝不信任存储的 path。"""
|
|
209
|
+
if written is None:
|
|
210
|
+
written = []
|
|
211
|
+
node_dir.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
|
|
213
|
+
gf_entries: list[gf.Entry] = []
|
|
214
|
+
for e in node["entries"]:
|
|
215
|
+
if not e["name"].strip():
|
|
216
|
+
continue # 跳过空白幽灵行
|
|
217
|
+
if _is_dir_name(e["name"]):
|
|
218
|
+
child = e.get("child") or {}
|
|
219
|
+
# 单一事实源:父条目文本就是子的意图
|
|
220
|
+
gf_entries.append(gf.Entry(name=e["name"], is_dir=True, desc=child.get("intent", "")))
|
|
221
|
+
else:
|
|
222
|
+
gf_entries.append(gf.Entry(name=e["name"], is_dir=False, desc=e.get("desc", "")))
|
|
223
|
+
|
|
224
|
+
text = gf.serialize(node["intent"], node.get("constraints", ""), gf_entries)
|
|
225
|
+
(node_dir / GBC_FILE).write_text(text, encoding="utf-8")
|
|
226
|
+
written.append(str(node_dir / GBC_FILE))
|
|
227
|
+
|
|
228
|
+
for e in node["entries"]:
|
|
229
|
+
if e["name"].strip() and _is_dir_name(e["name"]) and e.get("child"):
|
|
230
|
+
child_dir = node_dir / e["name"].rstrip().rstrip("/")
|
|
231
|
+
write_tree(child_dir, e["child"], written)
|
|
232
|
+
return written
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---- 全树一致性 -------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def check(gbc_root: Path) -> tuple[list[str], list[str]]:
|
|
238
|
+
"""全树一致性体检。返回 (errors, notes):
|
|
239
|
+
|
|
240
|
+
errors = DRIFT(子有 gbc.md 且其意图与父条目描述不一致) / ORPHAN(子有 gbc.md 但父未登记);
|
|
241
|
+
notes = STUB(父登记了子文件夹条目但子无 gbc.md)——叶子文件夹的正常状态,仅提示。
|
|
242
|
+
"""
|
|
243
|
+
errors: list[str] = []
|
|
244
|
+
notes: list[str] = []
|
|
245
|
+
if not gbc_root.exists():
|
|
246
|
+
return [f"(.gbc 不存在: {gbc_root})"], []
|
|
247
|
+
|
|
248
|
+
for md in sorted(gbc_root.rglob(GBC_FILE)):
|
|
249
|
+
rel = md.parent.relative_to(gbc_root).as_posix()
|
|
250
|
+
rel = "" if rel == "." else rel
|
|
251
|
+
doc = gf.parse(md.read_text(encoding="utf-8"))
|
|
252
|
+
for e in doc.entries:
|
|
253
|
+
if not e.is_dir:
|
|
254
|
+
continue
|
|
255
|
+
child_rel = f"{rel}/{e.name.rstrip('/')}".lstrip("/")
|
|
256
|
+
child_md = gbc_root / child_rel / GBC_FILE
|
|
257
|
+
if not child_md.exists():
|
|
258
|
+
notes.append(f"[STUB] {rel or '(根)'} 的条目 '{e.name}' 没有对应 gbc.md(叶子文件夹?)")
|
|
259
|
+
continue
|
|
260
|
+
child_intent = gf.parse(child_md.read_text(encoding="utf-8")).intent.strip()
|
|
261
|
+
if child_intent != (e.desc or "").strip():
|
|
262
|
+
errors.append(f"[DRIFT] '{child_rel}' 的意图 与 父文档条目描述 不一致")
|
|
263
|
+
|
|
264
|
+
parent_rel, name = _parent_name(rel)
|
|
265
|
+
if name is not None:
|
|
266
|
+
pmd = gbc_root / (parent_rel or "") / GBC_FILE
|
|
267
|
+
if not pmd.exists():
|
|
268
|
+
errors.append(f"[ORPHAN] '{rel}' 有 gbc.md 但父 '{parent_rel or '(根)'}' 没有 gbc.md")
|
|
269
|
+
elif _find_entry(gf.parse(pmd.read_text(encoding="utf-8")), name) is None:
|
|
270
|
+
errors.append(f"[ORPHAN] '{rel}' 有 gbc.md 但父文档未登记 '{name}/' 条目")
|
|
271
|
+
return errors, notes
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def sync(gbc_root: Path) -> list[str]:
|
|
275
|
+
"""确定性修复 DRIFT/ORPHAN:把每个有 gbc.md 的子文件夹的意图(唯一事实源)重投影到
|
|
276
|
+
其父文档的 `## <name>/` 条目(缺则补、不一致则覆盖)。只动父条目,不碰子意图。"""
|
|
277
|
+
fixed: list[str] = []
|
|
278
|
+
for md in sorted(gbc_root.rglob(GBC_FILE)):
|
|
279
|
+
rel = md.parent.relative_to(gbc_root).as_posix()
|
|
280
|
+
rel = "" if rel == "." else rel
|
|
281
|
+
parent_rel, name = _parent_name(rel)
|
|
282
|
+
if name is None:
|
|
283
|
+
continue
|
|
284
|
+
child_intent = gf.parse(md.read_text(encoding="utf-8")).intent
|
|
285
|
+
pdoc = read_doc(gbc_root, parent_rel)
|
|
286
|
+
entry = _find_entry(pdoc, name)
|
|
287
|
+
if entry is None:
|
|
288
|
+
pdoc.entries.append(gf.Entry(name=f"{name}/", is_dir=True, desc=child_intent))
|
|
289
|
+
write_doc(gbc_root, parent_rel, pdoc)
|
|
290
|
+
fixed.append(f"+ 补登记 '{rel}' 到父 '{parent_rel or '(根)'}'")
|
|
291
|
+
elif (entry.desc or "").strip() != child_intent.strip():
|
|
292
|
+
entry.name, entry.is_dir, entry.desc = f"{name}/", True, child_intent
|
|
293
|
+
write_doc(gbc_root, parent_rel, pdoc)
|
|
294
|
+
fixed.append(f"~ 重投影 '{rel}' 意图到父 '{parent_rel or '(根)'}'")
|
|
295
|
+
return fixed
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def migrate(gbc_root: Path) -> list[str]:
|
|
299
|
+
"""把所有 gbc.md parse→serialize 重写一遍,升级到带 `# 文件` 段的新格式。"""
|
|
300
|
+
changed: list[str] = []
|
|
301
|
+
for md in sorted(gbc_root.rglob(GBC_FILE)):
|
|
302
|
+
old = md.read_text(encoding="utf-8")
|
|
303
|
+
doc = gf.parse(old)
|
|
304
|
+
new = gf.serialize(doc.intent, doc.constraints, doc.entries)
|
|
305
|
+
if new != old:
|
|
306
|
+
md.write_text(new, encoding="utf-8")
|
|
307
|
+
changed.append(str(md))
|
|
308
|
+
return changed
|
gbc/app/intent/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
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)的 CLI 表面 —— 薄。
|
|
16
|
+
|
|
17
|
+
`gbc doc <command>`。只做参数收集 → 调 intent.base → 渲染;不碰磁盘、不解析路径。
|
|
18
|
+
"""
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
from gbc.app.intent import base
|
|
25
|
+
|
|
26
|
+
doc_app = typer.Typer(help="cli.doc.help")
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _gbc_root(project: Optional[str]):
|
|
31
|
+
"""确定作用的项目根:显式 project > 当前 GBC 目标项目。"""
|
|
32
|
+
if project:
|
|
33
|
+
gbc_root, _ = base.resolve_gbc(project)
|
|
34
|
+
return gbc_root
|
|
35
|
+
from gbc.app.config.project import get_current_project
|
|
36
|
+
gbc_root, _ = base.resolve_gbc(str(get_current_project()))
|
|
37
|
+
return gbc_root
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@doc_app.command("show")
|
|
41
|
+
def doc_show(
|
|
42
|
+
folder: str = typer.Argument("", help="项目相对文件夹路径,根用空串"),
|
|
43
|
+
project: Optional[str] = typer.Option(None, "--project", "-C", help="目标项目根;省略则用当前项目"),
|
|
44
|
+
):
|
|
45
|
+
"""查看文件夹的意图 / 约束 / 条目。"""
|
|
46
|
+
print(base.show(_gbc_root(project), folder))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@doc_app.command("set-intent")
|
|
50
|
+
def doc_set_intent(
|
|
51
|
+
folder: str = typer.Argument(...),
|
|
52
|
+
text: str = typer.Argument(...),
|
|
53
|
+
project: Optional[str] = typer.Option(None, "--project", "-C"),
|
|
54
|
+
):
|
|
55
|
+
"""设文件夹意图(自动单源投影到父文档条目)。"""
|
|
56
|
+
for p in base.set_intent(_gbc_root(project), folder, text):
|
|
57
|
+
console.print(f"[green]written:[/green] {p}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@doc_app.command("set-constraints")
|
|
61
|
+
def doc_set_constraints(
|
|
62
|
+
folder: str = typer.Argument(...),
|
|
63
|
+
text: str = typer.Argument(...),
|
|
64
|
+
project: Optional[str] = typer.Option(None, "--project", "-C"),
|
|
65
|
+
):
|
|
66
|
+
"""设文件夹的内部约束(只活在本地,不冒泡到父节点)。"""
|
|
67
|
+
for p in base.set_constraints(_gbc_root(project), folder, text):
|
|
68
|
+
console.print(f"[green]written:[/green] {p}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@doc_app.command("set-file")
|
|
72
|
+
def doc_set_file(
|
|
73
|
+
folder: str = typer.Argument(...),
|
|
74
|
+
name: str = typer.Argument(...),
|
|
75
|
+
desc: str = typer.Argument(...),
|
|
76
|
+
project: Optional[str] = typer.Option(None, "--project", "-C"),
|
|
77
|
+
):
|
|
78
|
+
"""新增/更新一个文件条目(name 不带 /)。"""
|
|
79
|
+
for p in base.set_file(_gbc_root(project), folder, name, desc):
|
|
80
|
+
console.print(f"[green]written:[/green] {p}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@doc_app.command("rm-entry")
|
|
84
|
+
def doc_rm_entry(
|
|
85
|
+
folder: str = typer.Argument(...),
|
|
86
|
+
name: str = typer.Argument(...),
|
|
87
|
+
project: Optional[str] = typer.Option(None, "--project", "-C"),
|
|
88
|
+
):
|
|
89
|
+
"""删条目(只改文档,不删盘上文件,留给 git 复核)。"""
|
|
90
|
+
for p in base.rm_entry(_gbc_root(project), folder, name):
|
|
91
|
+
console.print(f"[green]written:[/green] {p}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@doc_app.command("check")
|
|
95
|
+
def doc_check(project: Optional[str] = typer.Option(None, "--project", "-C")):
|
|
96
|
+
"""全树意图一致性体检(DRIFT/ORPHAN 为错误,STUB 为提示)。"""
|
|
97
|
+
errors, notes = base.check(_gbc_root(project))
|
|
98
|
+
for e in errors:
|
|
99
|
+
console.print(f"[red]{e}[/red]")
|
|
100
|
+
for n in notes:
|
|
101
|
+
console.print(f"[dim]{n}[/dim]")
|
|
102
|
+
if errors:
|
|
103
|
+
raise typer.Exit(code=1)
|
|
104
|
+
console.print("[green]✔ intent tree consistent[/green]")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@doc_app.command("sync")
|
|
108
|
+
def doc_sync(project: Optional[str] = typer.Option(None, "--project", "-C")):
|
|
109
|
+
"""确定性修复 DRIFT/ORPHAN:把子意图重投影到父条目。"""
|
|
110
|
+
fixed = base.sync(_gbc_root(project))
|
|
111
|
+
for f in fixed:
|
|
112
|
+
console.print(f"[green]{f}[/green]")
|
|
113
|
+
if not fixed:
|
|
114
|
+
console.print("[dim]nothing to sync[/dim]")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@doc_app.command("migrate")
|
|
118
|
+
def doc_migrate(project: Optional[str] = typer.Option(None, "--project", "-C")):
|
|
119
|
+
"""把所有 gbc.md 升级到最新格式。"""
|
|
120
|
+
changed = base.migrate(_gbc_root(project))
|
|
121
|
+
for c in changed:
|
|
122
|
+
console.print(f"[green]migrated:[/green] {c}")
|
|
123
|
+
if not changed:
|
|
124
|
+
console.print("[dim]all up to date[/dim]")
|
gbc/app/intent/editor.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
"""意图编辑器的 web 表面 —— 薄。
|
|
16
|
+
|
|
17
|
+
只做 HTTP 收发 + 静态资源;所有 gbc.md 的解析/读写/整树读写都调 intent.base,
|
|
18
|
+
本模块不自己解析路径、不自己拼 gbc.md。`gbc editor up` 调 run_editor()。
|
|
19
|
+
"""
|
|
20
|
+
import json
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from urllib.parse import urlparse, parse_qs
|
|
24
|
+
|
|
25
|
+
from gbc.app.assets import EDITOR_FRONTEND_DIR as FRONTEND_DIR
|
|
26
|
+
from gbc.app.intent import base
|
|
27
|
+
|
|
28
|
+
# 前端预填 + 自动加载的默认项目路径(经 --root 设定)。
|
|
29
|
+
DEFAULT_ROOT = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Handler(BaseHTTPRequestHandler):
|
|
33
|
+
def _send(self, code: int, body: bytes, ctype: str) -> None:
|
|
34
|
+
self.send_response(code)
|
|
35
|
+
self.send_header("Content-Type", ctype)
|
|
36
|
+
self.send_header("Content-Length", str(len(body)))
|
|
37
|
+
self.end_headers()
|
|
38
|
+
self.wfile.write(body)
|
|
39
|
+
|
|
40
|
+
def _json(self, code: int, obj) -> None:
|
|
41
|
+
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
|
|
42
|
+
"application/json; charset=utf-8")
|
|
43
|
+
|
|
44
|
+
def do_GET(self) -> None:
|
|
45
|
+
parsed = urlparse(self.path)
|
|
46
|
+
if parsed.path == "/api/config":
|
|
47
|
+
return self._json(200, {"root": DEFAULT_ROOT})
|
|
48
|
+
if parsed.path == "/api/tree":
|
|
49
|
+
qs = parse_qs(parsed.query)
|
|
50
|
+
gbc_root, proj_name = base.resolve_gbc((qs.get("root") or [""])[0])
|
|
51
|
+
if gbc_root.exists() and not gbc_root.is_dir():
|
|
52
|
+
return self._json(404, {"error": f"not a directory: {gbc_root}"})
|
|
53
|
+
# 路径不存在也 OK:回一棵空树从头开始
|
|
54
|
+
tree = base.read_tree(gbc_root) if gbc_root.exists() else base.empty_tree(gbc_root)
|
|
55
|
+
tree["name"] = proj_name # 展示项目,而非 .gbc 层
|
|
56
|
+
return self._json(200, tree)
|
|
57
|
+
# 静态资源:根路径给 index.html,或 frontend 下任意文件
|
|
58
|
+
name = "index.html" if parsed.path == "/" else parsed.path.lstrip("/")
|
|
59
|
+
fpath = (FRONTEND_DIR / name).resolve()
|
|
60
|
+
if FRONTEND_DIR in fpath.parents and fpath.is_file():
|
|
61
|
+
ctype = "text/html; charset=utf-8" if fpath.suffix == ".html" else "text/plain"
|
|
62
|
+
return self._send(200, fpath.read_bytes(), ctype)
|
|
63
|
+
return self._json(404, {"error": "not found"})
|
|
64
|
+
|
|
65
|
+
def do_POST(self) -> None:
|
|
66
|
+
if urlparse(self.path).path != "/api/tree":
|
|
67
|
+
return self._json(404, {"error": "not found"})
|
|
68
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
69
|
+
try:
|
|
70
|
+
req = json.loads(self.rfile.read(length) or b"{}")
|
|
71
|
+
gbc_root, _ = base.resolve_gbc(req["root"])
|
|
72
|
+
if gbc_root.exists() and not gbc_root.is_dir():
|
|
73
|
+
return self._json(404, {"error": f"not a directory: {gbc_root}"})
|
|
74
|
+
gbc_root.mkdir(parents=True, exist_ok=True) # 首次保存时建 <project>/.gbc
|
|
75
|
+
written = base.write_tree(gbc_root, req["tree"])
|
|
76
|
+
return self._json(200, {"written": written, "count": len(written)})
|
|
77
|
+
except Exception as exc: # noqa: BLE001 - 报回客户端
|
|
78
|
+
return self._json(500, {"error": str(exc)})
|
|
79
|
+
|
|
80
|
+
def log_message(self, fmt, *args): # 静默控制台
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_editor(host: str = "127.0.0.1", port: int = 8765, root: str = "") -> None:
|
|
85
|
+
"""启动意图编辑器 web 服务(常驻,Ctrl-C 退出)。root 为可选默认项目路径。"""
|
|
86
|
+
global DEFAULT_ROOT
|
|
87
|
+
if root:
|
|
88
|
+
DEFAULT_ROOT = str(Path(root).expanduser())
|
|
89
|
+
srv = ThreadingHTTPServer((host, port), Handler)
|
|
90
|
+
try:
|
|
91
|
+
srv.serve_forever()
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
srv.shutdown()
|
|
@@ -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
|
+
|