@hupan56/wlkj 2.7.11 → 3.0.0
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.
- package/bin/cli.js +375 -78
- package/package.json +29 -29
- package/templates/.qoder/.runtime/ctx-cache-5660152f1d6dd819.md +23 -0
- package/templates/.qoder/.runtime/ctx-cache-afdce0dac06b25b0.md +23 -0
- package/templates/.qoder/.runtime/search-cache-eae7644e7b122f35.txt +1 -0
- package/templates/.qoder/learning/eval-history.jsonl +28 -0
- package/templates/data/index/wiki-index.json +8 -0
- package/templates/qoder/agents/insight-planning.md +1 -1
- package/templates/qoder/agents/insight-research.md +28 -15
- package/templates/qoder/commands/optional/wl-insight.md +159 -161
- package/templates/qoder/commands/optional/wl-report.md +4 -4
- package/templates/qoder/commands/optional/wl-status.md +2 -2
- package/templates/qoder/commands/wl-code.md +2 -2
- package/templates/qoder/commands/wl-design.md +2 -2
- package/templates/qoder/commands/wl-init.md +3 -3
- package/templates/qoder/commands/wl-prd.md +2 -2
- package/templates/qoder/commands/wl-req.md +43 -0
- package/templates/qoder/commands/wl-search.md +8 -8
- package/templates/qoder/commands/wl-task.md +17 -17
- package/templates/qoder/commands/wl-test.md +41 -15
- package/templates/qoder/config.yaml +17 -1
- package/templates/qoder/hooks/session-start.py +12 -22
- package/templates/qoder/nul +4 -0
- package/templates/qoder/rules/wl-pipeline.md +22 -48
- package/templates/qoder/scripts/README.md +139 -0
- package/templates/qoder/scripts/common/autotest_auth.py +109 -0
- package/templates/qoder/scripts/common/bootstrap.py +145 -0
- package/templates/qoder/scripts/common/check_publish.py +98 -0
- package/templates/qoder/scripts/common/cmd_registry.py +112 -0
- package/templates/qoder/scripts/common/config.py +187 -0
- package/templates/qoder/scripts/common/contract.py +317 -0
- package/templates/qoder/scripts/common/developer.py +2 -1
- package/templates/qoder/scripts/common/feishu.py +10 -9
- package/templates/qoder/scripts/common/guard.py +159 -0
- package/templates/qoder/scripts/common/identity.py +121 -2
- package/templates/qoder/scripts/common/kg_capabilities.py +182 -0
- package/templates/qoder/scripts/common/mcp_base.py +268 -0
- package/templates/qoder/scripts/common/paths.py +187 -1
- package/templates/qoder/scripts/common/result.py +223 -0
- package/templates/qoder/scripts/common/roles.py +60 -0
- package/templates/qoder/scripts/common/task_utils.py +21 -9
- package/templates/qoder/scripts/common/test_extract.py +115 -0
- package/templates/qoder/scripts/kg/__init__.py +11 -0
- package/templates/qoder/scripts/kg/build_entity_registry.py +196 -0
- package/templates/qoder/scripts/kg/build_relations.py +127 -0
- package/templates/qoder/scripts/{build_style_index.py → kg/build_style_index.py} +39 -10
- package/templates/qoder/scripts/kg/build_workflows.py +144 -0
- package/templates/qoder/scripts/{context_pack.py → kg/context_pack.py} +18 -11
- package/templates/qoder/scripts/{enrich_prompt.py → kg/enrich_prompt.py} +232 -226
- package/templates/qoder/scripts/{extract_api_params.py → kg/extract.py} +398 -246
- package/templates/qoder/scripts/{kg.py → kg/kg.py} +638 -708
- package/templates/qoder/scripts/{kg_build.py → kg/kg_build.py} +618 -612
- package/templates/qoder/scripts/{kg_build_db.py → kg/kg_build_db.py} +333 -327
- package/templates/qoder/scripts/{kg_duckdb.py → kg/kg_duckdb.py} +38 -37
- package/templates/qoder/scripts/{kg_incremental.py → kg/kg_incremental.py} +420 -393
- package/templates/qoder/scripts/{kg_link_db.py → kg/kg_link_db.py} +230 -224
- package/templates/qoder/scripts/{kg_semantic.py → kg/kg_semantic.py} +156 -150
- package/templates/qoder/scripts/kg/prefetch.py +359 -0
- package/templates/qoder/scripts/{search_index.py → kg/search_index.py} +70 -14
- package/templates/qoder/scripts/mcp/__init__.py +11 -0
- package/templates/qoder/scripts/{kg_mcp_server.py → mcp/kg_mcp_server.py} +77 -272
- package/templates/qoder/scripts/{lanhu_stdio_wrapper.py → mcp/lanhu_stdio_wrapper.py} +125 -119
- package/templates/qoder/scripts/{check_mcp.py → mcp/mcp_doctor.py} +515 -298
- package/templates/qoder/scripts/{mcp_launcher.py → mcp/mcp_launcher.py} +442 -414
- package/templates/qoder/scripts/{mysql_mcp_server.py → mcp/mysql_mcp_server.py} +347 -396
- package/templates/qoder/scripts/{zentao_mcp_server.py → mcp/zentao_mcp_server.py} +384 -424
- package/templates/qoder/scripts/report/__init__.py +11 -0
- package/templates/qoder/scripts/{add_session.py → report/add_session.py} +250 -244
- package/templates/qoder/scripts/{archive_prd.py → report/archive_prd.py} +383 -377
- package/templates/qoder/scripts/{eval_prd.py → report/eval_prd.py} +73 -11
- package/templates/qoder/scripts/{export.py → report/export.py} +63 -0
- package/templates/qoder/scripts/{fill_prototype.py → report/fill_prototype.py} +6 -0
- package/templates/qoder/scripts/{gen_design_doc.py → report/gen_design_doc.py} +400 -394
- package/templates/qoder/scripts/{learn.py → report/learn.py} +152 -146
- package/templates/qoder/scripts/{learn_aggregate.py → report/learn_aggregate.py} +207 -201
- package/templates/qoder/scripts/{report.py → report/report.py} +287 -281
- package/templates/qoder/scripts/report/req.py +222 -0
- package/templates/qoder/scripts/report/role.py +33 -0
- package/templates/qoder/scripts/{status.py → report/status.py} +634 -628
- package/templates/qoder/scripts/setup/__init__.py +11 -0
- package/templates/qoder/scripts/setup/carriers.py +662 -0
- package/templates/qoder/scripts/{init_doctor.py → setup/init_doctor.py} +63 -26
- package/templates/qoder/scripts/{install_qoderwork.py → setup/install_qoderwork.py} +36 -26
- package/templates/qoder/scripts/{platform_doctor.py → setup/platform_doctor.py} +265 -259
- package/templates/qoder/scripts/{repo_root.py → setup/repo_root.py} +112 -106
- package/templates/qoder/scripts/{setup.py → setup/setup.py} +147 -0
- package/templates/qoder/scripts/{setup_lanhu.py → setup/setup_lanhu.py} +973 -963
- package/templates/qoder/scripts/task/__init__.py +11 -0
- package/templates/qoder/scripts/{git_sync.py → task/git_sync.py} +52 -31
- package/templates/qoder/scripts/{syncgate.py → task/syncgate.py} +6 -0
- package/templates/qoder/scripts/task/task.py +221 -0
- package/templates/qoder/scripts/task/task_lifecycle.py +596 -0
- package/templates/qoder/scripts/task/task_query.py +161 -0
- package/templates/qoder/scripts/task/task_relations.py +424 -0
- package/templates/qoder/scripts/{team_sync.py → task/team_sync.py} +93 -20
- package/templates/qoder/scripts/test/__init__.py +11 -0
- package/templates/qoder/scripts/{autotest.py → test/autotest.py} +1174 -1751
- package/templates/qoder/scripts/{autotest_batch.py → test/autotest_batch.py} +242 -224
- package/templates/qoder/scripts/test/autotest_data.py +675 -0
- package/templates/qoder/scripts/{autotest_run.py → test/autotest_run.py} +309 -297
- package/templates/qoder/scripts/{benchmark.py → test/benchmark.py} +6 -0
- package/templates/qoder/scripts/{kg_auto_login.py → test/kg_auto_login.py} +202 -196
- package/templates/qoder/scripts/{kg_test_runner.py → test/kg_test_runner.py} +7 -1
- package/templates/qoder/scripts/{page_probe.py → test/page_probe.py} +465 -459
- package/templates/qoder/scripts/wlkj.py +116 -0
- package/templates/qoder/settings.json +1 -10
- package/templates/qoder/skills/design-import/SKILL.md +226 -226
- package/templates/qoder/skills/design-review/SKILL.md +82 -82
- package/templates/qoder/skills/prd-generator/SKILL.md +26 -16
- package/templates/qoder/skills/prd-review/SKILL.md +5 -5
- package/templates/qoder/skills/prototype-generator/SKILL.md +256 -256
- package/templates/qoder/skills/spec-coder/SKILL.md +4 -4
- package/templates/qoder/skills/spec-generator/SKILL.md +4 -4
- package/templates/qoder/skills/test-generator/SKILL.md +5 -5
- package/templates/qoder/skills/wl-code/SKILL.md +4 -4
- package/templates/qoder/skills/wl-commit/SKILL.md +4 -4
- package/templates/qoder/skills/wl-design/SKILL.md +3 -3
- package/templates/qoder/skills/wl-init/SKILL.md +8 -8
- package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
- package/templates/qoder/skills/wl-prd-full/SKILL.md +6 -6
- package/templates/qoder/skills/wl-prd-quick/SKILL.md +6 -6
- package/templates/qoder/skills/wl-prd-review/SKILL.md +4 -4
- package/templates/qoder/skills/wl-report/SKILL.md +7 -7
- package/templates/qoder/skills/wl-search/SKILL.md +13 -13
- package/templates/qoder/skills/wl-spec/SKILL.md +5 -5
- package/templates/qoder/skills/wl-status/SKILL.md +5 -5
- package/templates/qoder/skills/wl-task/SKILL.md +6 -6
- package/templates/qoder/skills/wl-test/SKILL.md +102 -39
- package/templates/root/AGENTS.md +39 -40
- package/templates/qoder/hooks/inject-workflow-state.py +0 -169
- package/templates/qoder/scripts/__pycache__/check_mcp_launch.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/install_qoderwork.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/mcp_launcher.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/__pycache__/platform_doctor.cpython-39.pyc +0 -0
- package/templates/qoder/scripts/check_carriers.py +0 -238
- package/templates/qoder/scripts/check_mcp_launch.py +0 -183
- package/templates/qoder/scripts/check_qoderwork_consistency.py +0 -166
- package/templates/qoder/scripts/collect_prds.py +0 -31
- package/templates/qoder/scripts/common/mentions.py +0 -134
- package/templates/qoder/scripts/common/utf8.py +0 -38
- package/templates/qoder/scripts/extract_routes.py +0 -54
- package/templates/qoder/scripts/extract_routes_tree.py +0 -78
- package/templates/qoder/scripts/handoff.py +0 -22
- package/templates/qoder/scripts/init_developer.py +0 -76
- package/templates/qoder/scripts/parse_prds.py +0 -33
- package/templates/qoder/scripts/role.py +0 -51
- package/templates/qoder/scripts/sync_carriers.py +0 -259
- package/templates/qoder/scripts/task.py +0 -1261
- package/templates/qoder/scripts/workspace_init.py +0 -102
- package/templates/qoder/skills/prompt-enrich/SKILL.md +0 -90
- package/templates/qoder/skills/prototype-generator/SKILL.md.zcode-79180-2af4721f-f9a6-412c-88db-c0af680d211b.tmp +0 -0
- /package/templates/qoder/scripts/{secure-ls.js → test/secure-ls.js} +0 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
contract.py - 契约校验器 (解耦模块的核心)
|
|
4
|
+
|
|
5
|
+
把"君子协定"变成机器可校验。每个产出 (PRD/原型/spec/design-spec/test-cases)
|
|
6
|
+
带 @contract 头, 本模块解析头部 + 按 contracts/schemas/*.schema.json 校验。
|
|
7
|
+
|
|
8
|
+
设计原则 (与方案一致):
|
|
9
|
+
- 解耦: 模块间只读契约产出, 不读对方实现。实现可任意替换。
|
|
10
|
+
- 降级而非阻塞: 校验 FAIL 只提示, 不让模块停摆 (符合 contract-header.md)
|
|
11
|
+
- 轻量: 不引 jsonschema 重依赖, 用 stdlib 实现核心校验 (类型/必填/枚举/正则)
|
|
12
|
+
- 兼容现有: 复用现有 @contract 头格式 (MD 的 HTML 注释 / JSON 的 _contract 字段)
|
|
13
|
+
|
|
14
|
+
核心 API:
|
|
15
|
+
parse_contract(path) -> (contract_dict, source_text)
|
|
16
|
+
解析文件的契约头 (MD/JSON 两种)
|
|
17
|
+
validate_contract(path) -> Result
|
|
18
|
+
解析 + 按 schema 校验, 返回统一 Result
|
|
19
|
+
extract_contract_header(md_text) -> dict | None
|
|
20
|
+
从 Markdown 正文提取 <!-- @contract ... --> 块
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
from common.contract import validate_contract
|
|
24
|
+
r = validate_contract('workspace/.../REQ-2026-001-prd.md')
|
|
25
|
+
if r.is_warn:
|
|
26
|
+
print('警告:', r.msg) # 软门禁: 带警告继续
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Dict, Optional, Tuple
|
|
35
|
+
|
|
36
|
+
# 相对导入
|
|
37
|
+
try:
|
|
38
|
+
from .result import Result, CODE
|
|
39
|
+
from .atomicio import safe_read_json
|
|
40
|
+
except ImportError:
|
|
41
|
+
_COMMON_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
42
|
+
if _COMMON_DIR not in sys.path:
|
|
43
|
+
sys.path.insert(0, _COMMON_DIR)
|
|
44
|
+
from result import Result, CODE
|
|
45
|
+
from atomicio import safe_read_json
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"parse_contract",
|
|
49
|
+
"validate_contract",
|
|
50
|
+
"extract_contract_header",
|
|
51
|
+
"extract_json_contract",
|
|
52
|
+
"SCHEMAS_DIR",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
# schemas 目录 (相对仓库根: .qoder/contracts/schemas/)
|
|
56
|
+
def _find_schemas_dir() -> Path:
|
|
57
|
+
"""定位 contracts/schemas/。向上找 .qoder/contracts/schemas/。"""
|
|
58
|
+
here = Path(__file__).resolve().parent
|
|
59
|
+
for _ in range(8):
|
|
60
|
+
cand = here / "contracts" / "schemas"
|
|
61
|
+
if cand.is_dir():
|
|
62
|
+
return cand
|
|
63
|
+
# common/ 在 .qoder/scripts/common/, schemas 在 .qoder/contracts/schemas/
|
|
64
|
+
cand2 = here.parent.parent / "contracts" / "schemas"
|
|
65
|
+
if cand2.is_dir():
|
|
66
|
+
return cand2
|
|
67
|
+
parent = here.parent
|
|
68
|
+
if parent == here:
|
|
69
|
+
break
|
|
70
|
+
here = parent
|
|
71
|
+
return Path(".qoder/contracts/schemas")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
SCHEMAS_DIR = _find_schemas_dir()
|
|
75
|
+
|
|
76
|
+
# 契约类型 → schema 文件名映射
|
|
77
|
+
_SCHEMA_FILES = {
|
|
78
|
+
"prd": "prd.schema.json",
|
|
79
|
+
"design-spec": "design-spec.schema.json",
|
|
80
|
+
"design": "design-spec.schema.json", # 别名
|
|
81
|
+
"test-cases": "test-cases.schema.json",
|
|
82
|
+
"test": "test-cases.schema.json", # 别名
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ============================================================
|
|
87
|
+
# 1. 契约头解析
|
|
88
|
+
# ============================================================
|
|
89
|
+
|
|
90
|
+
# MD 契约头: <!-- @contract prd v1\n platform: web\n ... -->
|
|
91
|
+
_MD_CONTRACT_RE = re.compile(
|
|
92
|
+
r"<!--\s*@contract\s+(\S+)(?:\s+v?(\d+))?\s*\n(.*?)-->",
|
|
93
|
+
re.DOTALL,
|
|
94
|
+
)
|
|
95
|
+
# 契约头内的 key: value 行
|
|
96
|
+
_KV_RE = re.compile(r"^\s*([\w\-]+)\s*:\s*(.+?)\s*$")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def extract_contract_header(md_text: str) -> Optional[Dict[str, Any]]:
|
|
100
|
+
"""从 Markdown 正文提取 @contract 块。
|
|
101
|
+
|
|
102
|
+
格式 (见 contract-header.md):
|
|
103
|
+
<!-- @contract prd v1
|
|
104
|
+
platform: web
|
|
105
|
+
req-id: REQ-2025-0042
|
|
106
|
+
title: 保单批改功能
|
|
107
|
+
acceptance: [批改后实时生效, 批改需审批]
|
|
108
|
+
-->
|
|
109
|
+
"""
|
|
110
|
+
m = _MD_CONTRACT_RE.search(md_text)
|
|
111
|
+
if not m:
|
|
112
|
+
return None
|
|
113
|
+
ctype = m.group(1).strip()
|
|
114
|
+
version = int(m.group(2)) if m.group(2) else 1
|
|
115
|
+
body = m.group(3)
|
|
116
|
+
fields: Dict[str, Any] = {"type": ctype, "version": version}
|
|
117
|
+
for line in body.splitlines():
|
|
118
|
+
line = line.strip()
|
|
119
|
+
if not line:
|
|
120
|
+
continue
|
|
121
|
+
kv = _KV_RE.match(line)
|
|
122
|
+
if kv:
|
|
123
|
+
key = kv.group(1).strip()
|
|
124
|
+
val = kv.group(2).strip()
|
|
125
|
+
# acceptance: [a, b] → 列表
|
|
126
|
+
if val.startswith("[") and val.endswith("]"):
|
|
127
|
+
inner = val[1:-1]
|
|
128
|
+
fields[key] = [x.strip() for x in inner.split(",") if x.strip()]
|
|
129
|
+
else:
|
|
130
|
+
fields[key] = val
|
|
131
|
+
return fields
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract_json_contract(data: dict) -> Optional[Dict[str, Any]]:
|
|
135
|
+
"""从 JSON dict 提取 _contract 字段 (design-spec / test-cases 用)。"""
|
|
136
|
+
if isinstance(data, dict) and isinstance(data.get("_contract"), dict):
|
|
137
|
+
c = dict(data["_contract"])
|
|
138
|
+
# 补默认 type/version (schema 里 required, 但容错)
|
|
139
|
+
c.setdefault("type", c.get("type", "unknown"))
|
|
140
|
+
c.setdefault("version", 1)
|
|
141
|
+
return c
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_contract(path) -> Tuple[Optional[Dict[str, Any]], str]:
|
|
146
|
+
"""解析任意文件的契约头。
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
(contract_dict, source_text)
|
|
150
|
+
- contract_dict: 解析出的契约字段, 无则 None
|
|
151
|
+
- source_text: 'md' / 'json' / 'unknown'
|
|
152
|
+
"""
|
|
153
|
+
path = Path(path)
|
|
154
|
+
if not path.is_file():
|
|
155
|
+
return None, "unknown"
|
|
156
|
+
name = path.name.lower()
|
|
157
|
+
try:
|
|
158
|
+
if name.endswith(".json"):
|
|
159
|
+
data = safe_read_json(path, default=None)
|
|
160
|
+
if data is None:
|
|
161
|
+
return None, "json"
|
|
162
|
+
return extract_json_contract(data), "json"
|
|
163
|
+
# 当作文本 (md 等)
|
|
164
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
165
|
+
hdr = extract_contract_header(text)
|
|
166
|
+
return hdr, "md"
|
|
167
|
+
except Exception:
|
|
168
|
+
return None, "unknown"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ============================================================
|
|
172
|
+
# 2. 轻量 schema 校验 (不引 jsonschema, stdlib 实现)
|
|
173
|
+
# ============================================================
|
|
174
|
+
|
|
175
|
+
def _load_schema(contract_type: str) -> Optional[dict]:
|
|
176
|
+
"""按契约类型加载对应 schema.json。"""
|
|
177
|
+
fname = _SCHEMA_FILES.get(contract_type)
|
|
178
|
+
if not fname:
|
|
179
|
+
return None
|
|
180
|
+
sp = SCHEMAS_DIR / fname
|
|
181
|
+
if not sp.is_file():
|
|
182
|
+
return None
|
|
183
|
+
try:
|
|
184
|
+
return json.loads(sp.read_text(encoding="utf-8"))
|
|
185
|
+
except Exception:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _check_field(field: str, value: Any, spec: dict, errors: list, warnings: list):
|
|
190
|
+
"""校验单个字段 (类型/枚举/正则/最小长度)。"""
|
|
191
|
+
if spec.get("const") is not None and value != spec["const"]:
|
|
192
|
+
errors.append("字段 {} 必须为常量 {!r}, 实际 {!r}".format(field, spec["const"], value))
|
|
193
|
+
return
|
|
194
|
+
t = spec.get("type")
|
|
195
|
+
if t == "string" and not isinstance(value, str):
|
|
196
|
+
errors.append("字段 {} 应为 string".format(field))
|
|
197
|
+
return
|
|
198
|
+
if t == "integer" and not isinstance(value, int):
|
|
199
|
+
errors.append("字段 {} 应为 integer".format(field))
|
|
200
|
+
return
|
|
201
|
+
if t == "array" and not isinstance(value, list):
|
|
202
|
+
errors.append("字段 {} 应为 array".format(field))
|
|
203
|
+
return
|
|
204
|
+
if t == "object" and not isinstance(value, dict):
|
|
205
|
+
errors.append("字段 {} 应为 object".format(field))
|
|
206
|
+
return
|
|
207
|
+
if "enum" in spec and value not in spec["enum"]:
|
|
208
|
+
errors.append("字段 {} 值 {!r} 不在允许范围 {}".format(field, value, spec["enum"]))
|
|
209
|
+
return
|
|
210
|
+
if "pattern" in spec and isinstance(value, str):
|
|
211
|
+
pat = spec["pattern"]
|
|
212
|
+
if not re.match(pat, value):
|
|
213
|
+
errors.append("字段 {} 值 {!r} 不匹配模式 {}".format(field, value, pat))
|
|
214
|
+
return
|
|
215
|
+
if "minLength" in spec and isinstance(value, str) and len(value) < spec["minLength"]:
|
|
216
|
+
warnings.append("字段 {} 长度 {} 小于最小 {} ".format(field, len(value), spec["minLength"]))
|
|
217
|
+
if "minItems" in spec and isinstance(value, list) and len(value) < spec["minItems"]:
|
|
218
|
+
warnings.append("字段 {} 项数 {} 小于最小 {} ".format(field, len(value), spec["minItems"]))
|
|
219
|
+
if "minimum" in spec and isinstance(value, (int, float)) and value < spec["minimum"]:
|
|
220
|
+
errors.append("字段 {} 值 {} 小于最小 {}".format(field, value, spec["minimum"]))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _validate_against_schema(data: dict, schema: dict) -> Tuple[list, list]:
|
|
224
|
+
"""按 schema 校验 data。返回 (errors, warnings)。"""
|
|
225
|
+
errors: list = []
|
|
226
|
+
warnings: list = []
|
|
227
|
+
required = schema.get("required", [])
|
|
228
|
+
props = schema.get("properties", {})
|
|
229
|
+
# required 检查
|
|
230
|
+
for f in required:
|
|
231
|
+
if f not in data:
|
|
232
|
+
errors.append("缺少必填字段: {}".format(f))
|
|
233
|
+
# 逐字段校验
|
|
234
|
+
for f, val in data.items():
|
|
235
|
+
if f in props:
|
|
236
|
+
_check_field(f, val, props[f], errors, warnings)
|
|
237
|
+
return errors, warnings
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ============================================================
|
|
241
|
+
# 3. 对外主接口
|
|
242
|
+
# ============================================================
|
|
243
|
+
|
|
244
|
+
def validate_contract(path) -> Result:
|
|
245
|
+
"""校验一个产出文件的契约。
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Result:
|
|
249
|
+
- ok + CODE.OK: 合法契约, 校验通过
|
|
250
|
+
- ok + WARN: 有警告 (如字段长度不足), 但可继续 (软门禁)
|
|
251
|
+
- fail: 有错误 (缺必填/类型错/枚举越界)
|
|
252
|
+
- ok + code='no_contract': 无契约头 (降级, 不当错误, 返回 None 提示)
|
|
253
|
+
|
|
254
|
+
降级原则: 无契约头或 schema 缺失 → 不当错误 (符合 contract-header.md)
|
|
255
|
+
"""
|
|
256
|
+
path = Path(path)
|
|
257
|
+
if not path.is_file():
|
|
258
|
+
return Result.failure(
|
|
259
|
+
"文件不存在: {}".format(path),
|
|
260
|
+
code=CODE.NOT_FOUND,
|
|
261
|
+
hint="检查路径",
|
|
262
|
+
)
|
|
263
|
+
contract, src = parse_contract(path)
|
|
264
|
+
if not contract:
|
|
265
|
+
# 无契约头: 降级, 不当错误
|
|
266
|
+
return Result.success(
|
|
267
|
+
msg="⚠️ {} 无 @contract 头, 已降级 (从头文件名/正文推断)".format(path.name),
|
|
268
|
+
code="no_contract",
|
|
269
|
+
)
|
|
270
|
+
ctype = contract.get("type", "")
|
|
271
|
+
schema = _load_schema(ctype)
|
|
272
|
+
if not schema:
|
|
273
|
+
return Result.warning(
|
|
274
|
+
"契约类型 {!r} 无对应 schema, 跳过校验".format(ctype),
|
|
275
|
+
code="no_schema",
|
|
276
|
+
)
|
|
277
|
+
errors, warnings = _validate_against_schema(contract, schema)
|
|
278
|
+
if errors:
|
|
279
|
+
return Result.failure(
|
|
280
|
+
"契约校验失败 ({}): {}".format(path.name, "; ".join(errors[:3])),
|
|
281
|
+
code=CODE.GENERIC,
|
|
282
|
+
hint="按 contracts/schemas/{} 修正".format(_SCHEMA_FILES.get(ctype, ctype + ".schema.json")),
|
|
283
|
+
)
|
|
284
|
+
if warnings:
|
|
285
|
+
return Result.warning(
|
|
286
|
+
"契约有警告 ({}): {}".format(path.name, "; ".join(warnings[:3])),
|
|
287
|
+
code=CODE.BELOW_THRESH,
|
|
288
|
+
)
|
|
289
|
+
return Result.success(
|
|
290
|
+
data={"type": ctype, "req-id": contract.get("req-id"), "platform": contract.get("platform")},
|
|
291
|
+
msg="✓ 契约校验通过 ({} v{})".format(ctype, contract.get("version", 1)),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ============================================================
|
|
296
|
+
# CLI
|
|
297
|
+
# ============================================================
|
|
298
|
+
|
|
299
|
+
if __name__ == "__main__":
|
|
300
|
+
if len(sys.argv) < 2:
|
|
301
|
+
print("用法: python contract.py <产出文件>")
|
|
302
|
+
print(" 校验 PRD/原型/spec/design-spec/test-cases 的 @contract 头")
|
|
303
|
+
sys.exit(1)
|
|
304
|
+
# 简单 CLI: 复用 graceful_error 风格
|
|
305
|
+
target = sys.argv[1]
|
|
306
|
+
r = validate_contract(target)
|
|
307
|
+
if r.is_warn:
|
|
308
|
+
print("[WARN] {}".format(r.msg))
|
|
309
|
+
if r.hint:
|
|
310
|
+
print(" -> {}".format(r.hint))
|
|
311
|
+
sys.exit(0)
|
|
312
|
+
if not r.ok:
|
|
313
|
+
print("[FAIL] {}".format(r.msg))
|
|
314
|
+
if r.hint:
|
|
315
|
+
print(" -> {}".format(r.hint))
|
|
316
|
+
sys.exit(1)
|
|
317
|
+
print(r.msg)
|
|
@@ -147,9 +147,10 @@ def init_developer(name: str, role: str | None = None,
|
|
|
147
147
|
return False
|
|
148
148
|
|
|
149
149
|
# 2. 创建个人工作空间 workspace/members/<name>/
|
|
150
|
+
# v3.0 M3: 个人目录 = member.json + drafts + outputs + journal + .private
|
|
150
151
|
personal = repo_root / "workspace" / "members" / name
|
|
151
152
|
try:
|
|
152
|
-
for sub in ("journal", "drafts", "
|
|
153
|
+
for sub in ("journal", "drafts", "outputs", ".private", ".private/secrets"):
|
|
153
154
|
(personal / sub).mkdir(parents=True, exist_ok=True)
|
|
154
155
|
except (OSError, IOError) as e:
|
|
155
156
|
print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr)
|
|
@@ -57,18 +57,19 @@ THEME_COLORS = {
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def _load_config():
|
|
60
|
-
"""加载飞书配置 (config.yaml + 环境变量)。
|
|
61
|
-
|
|
60
|
+
"""加载飞书配置 (config.yaml + 环境变量)。
|
|
61
|
+
|
|
62
|
+
v3.0: 优先用 Config 单例 (带缓存), 失败回退文本解析。
|
|
63
|
+
"""
|
|
62
64
|
config = {}
|
|
63
65
|
try:
|
|
64
|
-
|
|
65
|
-
from .
|
|
66
|
-
|
|
67
|
-
with open(cfg_path, encoding='utf-8') as f:
|
|
68
|
-
full = yaml.safe_load(f) or {}
|
|
69
|
-
config = full.get('feishu') or {}
|
|
66
|
+
# 优先: Config 单例 (mtime 缓存, 避免每次重读)
|
|
67
|
+
from .config import Config, SECTIONS
|
|
68
|
+
config = Config.load().section(SECTIONS.FEISHU, {})
|
|
70
69
|
except Exception:
|
|
71
|
-
|
|
70
|
+
pass
|
|
71
|
+
if not config:
|
|
72
|
+
# 回退: 简单文本解析 (PyYAML 不可用时)
|
|
72
73
|
try:
|
|
73
74
|
from .paths import get_repo_root
|
|
74
75
|
cfg_path = os.path.join(str(get_repo_root()), '.qoder', 'config.yaml')
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
guard.py - 角色能力边界守卫 (解耦的执行点)
|
|
4
|
+
|
|
5
|
+
把角色从"标签"变成"边界"。各角色在能力边界内自治, 互不越界 = 解耦。
|
|
6
|
+
|
|
7
|
+
现状: role.py 只查询角色名, config.yaml 的 permissions 声明了但从不强制。
|
|
8
|
+
本次: guard.py 在"产出动作前"校验角色能否产出该契约类型, 越权则降级 (不报错)。
|
|
9
|
+
|
|
10
|
+
设计原则 (与方案一致):
|
|
11
|
+
- 降级而非阻塞: 越权不报错/不崩溃, 而是降级 (转给有权限的人 / 标记待确认)
|
|
12
|
+
这与契约层的"降级不停摆"哲学一致
|
|
13
|
+
- 软边界: 默认不强制 (ensure=False), 调用方显式 ensure=True 才硬拦
|
|
14
|
+
- 可观测: 每次越权都留痕 (返回 Result + hint), 让 AI/用户知道发生了什么
|
|
15
|
+
|
|
16
|
+
核心 API:
|
|
17
|
+
check_produce(contract_type, role=None, ensure=False) -> Result
|
|
18
|
+
检查当前角色能否产出某契约
|
|
19
|
+
ensure=False (默认): 越权返回 WARN (降级提示, 不阻塞)
|
|
20
|
+
ensure=True: 越权返回 FAIL (硬拦, 给破坏性操作用)
|
|
21
|
+
current_role() -> str | None
|
|
22
|
+
当前角色名 (identity.get_role 的便捷封装)
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
from common.guard import check_produce
|
|
26
|
+
r = check_produce('unit-test')
|
|
27
|
+
if not r.ok:
|
|
28
|
+
print(r.hint) # "你的角色 pm 不能产出 unit-test, 需 dev/test 角色"
|
|
29
|
+
return # 降级: 不执行产出
|
|
30
|
+
# ... 继续产出 unit-test
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from .result import Result, CODE
|
|
38
|
+
from .identity import get_role, can_produce, can_consume, get_role_config
|
|
39
|
+
except ImportError:
|
|
40
|
+
_COMMON_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
41
|
+
if _COMMON_DIR not in sys.path:
|
|
42
|
+
sys.path.insert(0, _COMMON_DIR)
|
|
43
|
+
from result import Result, CODE
|
|
44
|
+
from identity import get_role, can_produce, can_consume, get_role_config
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"check_produce",
|
|
48
|
+
"check_consume",
|
|
49
|
+
"current_role",
|
|
50
|
+
"suggest_role_for",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# 角色中文名映射 (给提示文案用)
|
|
54
|
+
_ROLE_CN = {
|
|
55
|
+
"pm": "产品经理",
|
|
56
|
+
"design": "设计师",
|
|
57
|
+
"dev": "开发",
|
|
58
|
+
"test": "测试",
|
|
59
|
+
"admin": "管理员",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def current_role() -> str:
|
|
64
|
+
"""当前角色名。未初始化/未注册 → 回退 'pm' (软引导, 与 role.py 一致)。"""
|
|
65
|
+
return get_role() or "pm"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _role_cn(role: str) -> str:
|
|
69
|
+
return _ROLE_CN.get(role, role)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def suggest_role_for(contract_type: str) -> list:
|
|
73
|
+
"""建议"哪些角色能产出该契约", 给越权提示用。"""
|
|
74
|
+
# 反查 config.yaml 哪些角色的 can_produce 含此类型或 '*'
|
|
75
|
+
try:
|
|
76
|
+
from .identity import _load_roles_config
|
|
77
|
+
except ImportError:
|
|
78
|
+
from identity import _load_roles_config
|
|
79
|
+
roles_cfg = _load_roles_config()
|
|
80
|
+
out = []
|
|
81
|
+
for r, cfg in roles_cfg.items():
|
|
82
|
+
caps = list(cfg.get("can_produce", []) or [])
|
|
83
|
+
if "*" in caps or contract_type in caps:
|
|
84
|
+
out.append(r)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_produce(
|
|
89
|
+
contract_type: str,
|
|
90
|
+
role: str = None,
|
|
91
|
+
ensure: bool = False,
|
|
92
|
+
) -> Result:
|
|
93
|
+
"""检查角色能否产出某契约类型。
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
contract_type: 契约类型 (prd/prototype/spec/design-spec/code/unit-test/test-report)
|
|
97
|
+
role: 角色名, 不传则用当前角色
|
|
98
|
+
ensure: False=越权返回WARN(降级); True=越权返回FAIL(硬拦)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Result.ok + CODE.OK: 允许产出
|
|
102
|
+
Result (WARN): 越权但降级 (ensure=False), 带 hint 提示该找谁
|
|
103
|
+
Result (FAIL): 越权且硬拦 (ensure=True)
|
|
104
|
+
"""
|
|
105
|
+
role = role or current_role()
|
|
106
|
+
if can_produce(role, contract_type):
|
|
107
|
+
return Result.success(msg="✓ {} 可产出 {}".format(_role_cn(role), contract_type))
|
|
108
|
+
# 越权
|
|
109
|
+
suggest = suggest_role_for(contract_type)
|
|
110
|
+
suggest_cn = "、".join(_role_cn(r) for r in suggest) if suggest else "对应角色"
|
|
111
|
+
hint = "你的角色({})不能产出 {}, 需 {} 角色。用 /wl-init 切换或转交。".format(
|
|
112
|
+
_role_cn(role), contract_type, suggest_cn
|
|
113
|
+
)
|
|
114
|
+
if ensure:
|
|
115
|
+
return Result.failure(
|
|
116
|
+
"[越权] {} 不能产出 {}".format(_role_cn(role), contract_type),
|
|
117
|
+
code=CODE.AUTHZ,
|
|
118
|
+
hint=hint,
|
|
119
|
+
)
|
|
120
|
+
# 降级: 不阻塞, 但强烈提示
|
|
121
|
+
return Result.warning(
|
|
122
|
+
"⚠️ {} 通常不产出 {} (建议 {})".format(_role_cn(role), contract_type, suggest_cn),
|
|
123
|
+
code=CODE.AUTHZ,
|
|
124
|
+
hint=hint,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_consume(
|
|
129
|
+
capability: str,
|
|
130
|
+
role: str = None,
|
|
131
|
+
) -> Result:
|
|
132
|
+
"""检查角色能否读取/消费某能力。越权则只读降级 (永远不阻塞)。"""
|
|
133
|
+
role = role or current_role()
|
|
134
|
+
if can_consume(role, capability):
|
|
135
|
+
return Result.success(msg="✓ {} 可消费 {}".format(_role_cn(role), capability))
|
|
136
|
+
return Result.warning(
|
|
137
|
+
"⚠️ {} 读取 {} 超出常规职责 (只读降级)".format(_role_cn(role), capability),
|
|
138
|
+
code=CODE.AUTHZ,
|
|
139
|
+
hint="这是软提示, 仍可继续 (越权只读降级, 不阻塞)",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ============================================================
|
|
144
|
+
# CLI (供 skill 脚本诊断用)
|
|
145
|
+
# ============================================================
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
import json
|
|
149
|
+
role = current_role()
|
|
150
|
+
print("当前角色: {} ({})".format(role, _role_cn(role)))
|
|
151
|
+
cfg = get_role_config(role)
|
|
152
|
+
print(" can_produce: {}".format(cfg.get("can_produce", [])))
|
|
153
|
+
print(" can_consume: {}".format(cfg.get("can_consume", [])))
|
|
154
|
+
if len(sys.argv) > 1:
|
|
155
|
+
ct = sys.argv[1]
|
|
156
|
+
r = check_produce(ct)
|
|
157
|
+
print("\n能否产出 {}: {}".format(ct, "能" if r.ok and not r.is_warn else "否"))
|
|
158
|
+
if r.hint:
|
|
159
|
+
print(" -> {}".format(r.hint))
|
|
@@ -65,9 +65,16 @@ __all__ = [
|
|
|
65
65
|
"verify_signature",
|
|
66
66
|
"require_member",
|
|
67
67
|
"ensure_current_member",
|
|
68
|
+
# ★ 角色能力 (v3.0 合并自 role.py) ★
|
|
69
|
+
"get_role",
|
|
70
|
+
"get_role_config",
|
|
71
|
+
"get_capabilities",
|
|
72
|
+
"get_permissions",
|
|
73
|
+
"can_produce",
|
|
74
|
+
"can_consume",
|
|
68
75
|
]
|
|
69
76
|
|
|
70
|
-
# 退出码 (
|
|
77
|
+
# 退出码 (与 common/result.py 的 EXIT[AUTHZ] 对齐): 4 = authz_denied
|
|
71
78
|
EXIT_AUTHZ = 4
|
|
72
79
|
|
|
73
80
|
# 名字合法字符: 字母/数字/中文/下划线/短横, 2-32 字符
|
|
@@ -96,7 +103,12 @@ def _member_json(name: str, repo_root: Optional[Union[str, Path]] = None) -> Pat
|
|
|
96
103
|
|
|
97
104
|
|
|
98
105
|
def _key_file(name: str, repo_root: Optional[Union[str, Path]] = None) -> Path:
|
|
99
|
-
|
|
106
|
+
"""签名密钥路径 (v3.0: 收进 .private/signing_key, 向后兼容老 .signing_key)。"""
|
|
107
|
+
try:
|
|
108
|
+
from .paths import get_private_file
|
|
109
|
+
return get_private_file("signing_key", name)
|
|
110
|
+
except Exception:
|
|
111
|
+
return _member_dir(name, repo_root) / ".signing_key"
|
|
100
112
|
|
|
101
113
|
|
|
102
114
|
# ============================================================
|
|
@@ -288,3 +300,110 @@ def ensure_current_member(repo_root: Optional[Union[str, Path]] = None) -> Optio
|
|
|
288
300
|
if name and verify_member(name, repo_root):
|
|
289
301
|
return name
|
|
290
302
|
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ============================================================
|
|
306
|
+
# 角色能力 (v3.0 合并自 role.py —— 角色从"标签"升级为"能力边界")
|
|
307
|
+
#
|
|
308
|
+
# config.yaml 的 roles 段升级为能力声明:
|
|
309
|
+
# roles.<role>.can_produce: [prd, prototype, ...] 允许产出的契约类型
|
|
310
|
+
# roles.<role>.can_consume: ["*", prd, ...] 允许读取的能力
|
|
311
|
+
# guard.py 据此强制边界: 越权降级 (不报错), 各角色在边界内自治 = 解耦
|
|
312
|
+
#
|
|
313
|
+
# 向后兼容: get_role / get_permissions 保留 (老脚本可继续用)
|
|
314
|
+
# ============================================================
|
|
315
|
+
|
|
316
|
+
def _load_roles_config(repo_root: Optional[Union[str, Path]] = None) -> Dict[str, Any]:
|
|
317
|
+
"""读 config.yaml 的 roles 段。失败返回 {} (软降级, 不崩)。"""
|
|
318
|
+
if repo_root is None:
|
|
319
|
+
repo_root = get_repo_root()
|
|
320
|
+
cfg = Path(repo_root) / ".qoder" / "config.yaml"
|
|
321
|
+
if not cfg.is_file():
|
|
322
|
+
return {}
|
|
323
|
+
try:
|
|
324
|
+
import yaml
|
|
325
|
+
with open(cfg, encoding="utf-8") as f:
|
|
326
|
+
data = yaml.safe_load(f) or {}
|
|
327
|
+
return data.get("roles", {}) or {}
|
|
328
|
+
except Exception:
|
|
329
|
+
return {}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def get_role(name: Optional[str] = None, repo_root: Optional[Union[str, Path]] = None) -> Optional[str]:
|
|
333
|
+
"""返回成员的角色名 (pm/design/dev/test/admin)。
|
|
334
|
+
|
|
335
|
+
优先级:
|
|
336
|
+
1. .developer 里记录的 role (init 时写)
|
|
337
|
+
2. member.json 里的 role
|
|
338
|
+
3. None (未注册)
|
|
339
|
+
无角色默认回退 'pm' (软引导, 不报错) —— 与原 role.py 行为一致。
|
|
340
|
+
"""
|
|
341
|
+
if not name:
|
|
342
|
+
# 从 .developer 读
|
|
343
|
+
try:
|
|
344
|
+
from paths import get_developer, get_developer_info
|
|
345
|
+
except ImportError:
|
|
346
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
347
|
+
from paths import get_developer, get_developer_info # type: ignore
|
|
348
|
+
info = get_developer_info(repo_root) if "get_developer_info" in dir() else None
|
|
349
|
+
if isinstance(info, dict) and info.get("role"):
|
|
350
|
+
return info["role"]
|
|
351
|
+
name = get_developer(repo_root)
|
|
352
|
+
if not name:
|
|
353
|
+
return None
|
|
354
|
+
m = get_member(name, repo_root)
|
|
355
|
+
return m.get("role") if m else None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def get_role_config(role: Optional[str], repo_root: Optional[Union[str, Path]] = None) -> Dict[str, Any]:
|
|
359
|
+
"""返回某角色的完整配置 (can_produce/can_consume/commands/permissions/name)。
|
|
360
|
+
|
|
361
|
+
角色不存在或 config 缺失 → 返回 {} (软降级)。
|
|
362
|
+
"""
|
|
363
|
+
if not role:
|
|
364
|
+
return {}
|
|
365
|
+
return _load_roles_config(repo_root).get(role, {}) or {}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def get_permissions(role: Optional[str], repo_root: Optional[Union[str, Path]] = None) -> list:
|
|
369
|
+
"""遗留 API: 返回角色的 permissions 列表 (向后兼容)。空则 []。"""
|
|
370
|
+
return list(get_role_config(role, repo_root).get("permissions", []) or [])
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def get_capabilities(
|
|
374
|
+
role: Optional[str], kind: str = "produce", repo_root: Optional[Union[str, Path]] = None
|
|
375
|
+
) -> list:
|
|
376
|
+
"""返回角色的能力列表。
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
role: 角色名
|
|
380
|
+
kind: 'produce' (能产出哪些契约) 或 'consume' (能读哪些)
|
|
381
|
+
Returns:
|
|
382
|
+
能力列表, 含 '*' 表示无限制。未知角色返回 [] (从严, 无能力)。
|
|
383
|
+
"""
|
|
384
|
+
key = "can_produce" if kind == "produce" else "can_consume"
|
|
385
|
+
caps = get_role_config(role, repo_root).get(key, [])
|
|
386
|
+
return list(caps) if caps else []
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _has(cap_list: list, contract_type: str) -> bool:
|
|
390
|
+
"""判断 contract_type 是否在能力列表内 ('*' 通配)。"""
|
|
391
|
+
if not cap_list:
|
|
392
|
+
return False
|
|
393
|
+
if "*" in cap_list:
|
|
394
|
+
return True
|
|
395
|
+
return contract_type in cap_list
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def can_produce(
|
|
399
|
+
role: Optional[str], contract_type: str, repo_root: Optional[Union[str, Path]] = None
|
|
400
|
+
) -> bool:
|
|
401
|
+
"""该角色能否产出指定契约类型? guard.py 的核心查询。"""
|
|
402
|
+
return _has(get_capabilities(role, "produce", repo_root), contract_type)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def can_consume(
|
|
406
|
+
role: Optional[str], capability: str, repo_root: Optional[Union[str, Path]] = None
|
|
407
|
+
) -> bool:
|
|
408
|
+
"""该角色能否读取/消费指定能力? 越权则只读降级。"""
|
|
409
|
+
return _has(get_capabilities(role, "consume", repo_root), capability)
|