@code2knowledge/skill 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +377 -2
- package/SKILL.md +46 -0
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/agents/openai.yaml +4 -0
- package/assets/notification-config.example.yml +6 -0
- package/assets/project-context/ontology/concept-types.yaml +52 -0
- package/assets/project-context/ontology/node-types.yaml +82 -0
- package/assets/project-context/ontology/relation-types.yaml +67 -0
- package/assets/project-context/ontology/schema.md +37 -0
- package/assets/project-context/rules/sync-rules.md +15 -0
- package/assets/project-context/rules/validation-rules.yaml +92 -0
- package/package.json +50 -4
- package/references/analysis.md +33 -0
- package/references/downstream-contract.md +23 -0
- package/references/execution.md +154 -0
- package/references/knowledge-model.md +108 -0
- package/references/notifications.md +46 -0
- package/references/wizard.md +110 -0
- package/scripts/knowledge.mjs +54 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/contract.mjs +347 -0
- package/scripts/lib/knowledge.mjs +1054 -0
- package/scripts/lib/notify.mjs +90 -0
- package/scripts/lib/preflight.mjs +63 -0
- package/scripts/lib/wizard.mjs +205 -0
- package/scripts/postinstall.mjs +8 -0
- package/scripts/register.mjs +107 -0
- package/scripts/setup.mjs +9 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# 飞书通知:人工介入与最终结果
|
|
2
|
+
|
|
3
|
+
默认关闭,不读取 super-engineer 的凭证,不自动创建机器人。npm 安装钩子及 Agent 注册会自动补齐缺失的本机模板,已有配置不覆盖;--ignore-scripts 会跳过安装钩子,注册时仍会补齐。用户在本机 `~/.code2knowledge/config.yml` 配置(建议目录权限 700、文件权限 600,不提交 Git):
|
|
4
|
+
|
|
5
|
+
```yaml
|
|
6
|
+
notification:
|
|
7
|
+
feishu:
|
|
8
|
+
enabled: false
|
|
9
|
+
webhook_url: ""
|
|
10
|
+
secret: "" # 机器人开启签名校验时填写
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
启用时填入自己的飞书/Lark 自定义机器人 webhook,改 enabled 为 true。配置启用即表示授权将本任务摘要发往该机器人所在会话;通知包含任务标识和本机输出位置,不发送源码、文档正文或原始 Git 错误。不要自动沿用其他 skill 的通知配置。支持官方 open.feishu.cn / open.larksuite.com webhook。
|
|
14
|
+
|
|
15
|
+
## 人工介入
|
|
16
|
+
|
|
17
|
+
仅在确实必须停止、等待用户处理时发送,如 master 同步失败、转换依赖安装受阻、必要资料不可读。普通向导提问和可自动修复的校验错误不发送。Agent 将如下 JSON 写入工作区临时文件,然后调用:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"event": "intervention",
|
|
22
|
+
"task": "稳定的向导 ID 或 run 绝对路径",
|
|
23
|
+
"summary": "订单中心 / 退款:master 同步失败,需要人工处理",
|
|
24
|
+
"details": ["服务:order-core;步骤:快进拉取", "原因:本地分支与远程分叉", "请保留本地提交,人工处理分支后回复继续"]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node "$SKILL/scripts/knowledge.mjs" notify --workspace "$WORKSPACE" --input "$WORKSPACE/notification-input.json"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
task 使用稳定标识,同一次阻塞重试时保持摘要一致;恢复后新的阻塞需包含新的阶段/来源版本以区分事件。只发简要原因和下一步,不发送原始异常、凭证、私有 URL 或材料内容。通知后仍在当前会话明确请求人工介入,不能把通知当作用户回复,也不能自行继续。
|
|
33
|
+
|
|
34
|
+
## 最终结果
|
|
35
|
+
|
|
36
|
+
由 exportRun 在重新校验并导出成功后自动发送,Agent 不得提前手写 completed 通知。包含业务域、导出结果、本机输出位置、产物指纹及“业务仍待审核”。代码核验范围和测试限制以交付报告为准,不声称全部实现已验证。仅生成骨架、校验失败、只准备原料不会发送完成通知。
|
|
37
|
+
|
|
38
|
+
## 记录和失败
|
|
39
|
+
|
|
40
|
+
记录在 `<工作区>/notifications/<事件指纹>.json`。只有 status=sent 才表示飞书接口确认成功;skipped 表示未配置或关闭,failed 表示发送/配置异常。相同任务与事件内容已发送则跳过,导出内容变化会生成新完成事件;并发发送通过锁文件避免重复。失败可在人工排查后重试,未自动重试以免刷屏。网络超时存在消息已到达但本机未确认的可能,不能保证绝对一次投递。进程异常退出遗留 .lock 时,确认无发送进程后人工移除再重试。
|
|
41
|
+
|
|
42
|
+
通知失败不覆盖真实导出或阻塞状态,Agent 应在会话说明发送失败;如果记录写入失败,只能报告未确认,不能声称已通知。不提供在线产物托管,本机路径不是飞书可访问链接。
|
|
43
|
+
|
|
44
|
+
测试隔离:node:test 环境及其 CLI 子进程禁止真实通知传输,不读取本机通知配置;通知单测仅使用模拟传输。设置 C2K_NOTIFICATIONS_DISABLED=1 也可关闭真实发送。人工介入防重忽略每次变化的检查记录路径,同一仓库及同一阻塞内容只发送一次。
|
|
45
|
+
|
|
46
|
+
卡片样式:使用飞书 interactive 卡片,人工介入为橙色标题、完成为绿色标题,采用加粗分组标题与项目列表,依次展示任务摘要、任务结果/阻塞说明、交付位置/排查记录和下一步;移除双栏及分隔线,任务和路径使用代码样式。材料插入 Markdown 前转义,避免触发提及或格式注入。测试卡片必须醒目标记【测试】,真实发送前明确告知接收机器人和数量;自动化测试保持模拟传输隔离。
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# 轻量选择向导与决策协议
|
|
2
|
+
|
|
3
|
+
## 提问方式
|
|
4
|
+
|
|
5
|
+
常规选择必须调用宿主 Agent 可用的结构化提问工具,不另外启动网页或终端问卷。此要求贯穿所有选择步骤,尤其包括最后的导出位置;普通消息中的编号列表不等于结构化选择。前面已使用交互选择时,后续必须沿用,不能因目录较长、候选只有两项或接近结束而改成“回复编号”。
|
|
6
|
+
|
|
7
|
+
优先单选:第一项给出有依据的推荐答案,保留自定义答案入口;宿主原生支持自由输入时使用原生入口,不再添加重复的“其他”项。宿主支持多选且问题确实需要多选时才用多选。选择自定义后,仅补问实际缺失的信息;名称、边界有可靠候选时同样先选择,无可靠候选时才直接收集文本,不能虚构推荐。
|
|
8
|
+
|
|
9
|
+
按宿主实际能力选工具,例如 Claude 的 AskUserQuestion、Codex 可用的 request_user_input_async;request_user_input 只在当前模式允许时使用。不能把某个工具在当前模式不可用等同于全部提问工具不可用,应先使用其他已提供且适用的结构化工具。仅在没有可用工具、工具明确受限或调用失败且无可用替代时,才列出编号选项并停止等待,并简述实际降级原因。不得假称调用了工具。仅推荐,不代用户选择;用户已经明确表达的选项直接沿用。
|
|
10
|
+
|
|
11
|
+
每轮最多一个主题、2–3 个主要候选。候选多时先显示最近项并允许查看其余;内部 ID、时间戳目录和 JSON 不要求用户填写。显示中心名、域名、简短状态、原料日期与服务名,以区分候选。
|
|
12
|
+
|
|
13
|
+
## 启动与发现
|
|
14
|
+
|
|
15
|
+
先运行 `discover`,只读取指定工作区。默认 `<任务目录>/.code2knowledge`;用户指定旧工作区或 run 路径时使用其工作区,不搜索整个主目录。
|
|
16
|
+
|
|
17
|
+
裸启动的第一条可交互问题来自实际数据:
|
|
18
|
+
|
|
19
|
+
| 已发现数据 | 提供的选择 |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| 有未完成任务/未填完向导 | 继续上次任务、复用共享原料(存在时)、新建 |
|
|
22
|
+
| 只有共享原料 | 复用共享原料、新建 |
|
|
23
|
+
| 没有历史 | 跳过操作菜单,直接收集新建必要信息 |
|
|
24
|
+
|
|
25
|
+
`discover.runs` 只包含未导出的任务,`wizards` 为未启动的问答检查点。已导出的任务通过原料在新域中复用;明确要更新已有域时可用 `runs` 查全部任务。读不到或损坏的记录不伪装成已完成,按需说明忽略了无效记录。
|
|
26
|
+
|
|
27
|
+
用户明确说“复用订单中心原料整理退款”,直接匹配中心/原料,不再问操作类型。用户给齐中心、仓库、域名、边界说明和导出位置时,若存在唯一匹配且版本核验通过的原料,可以直接复用,不再要求用户选择纯技术性的准备方式。用户明确给定 run 或 wizard 时直接恢复。不能仅凭启动词自动选定某个旧任务。
|
|
28
|
+
|
|
29
|
+
## 三条常用路径
|
|
30
|
+
|
|
31
|
+
### 继续上次任务
|
|
32
|
+
|
|
33
|
+
- 选择“继续”后,若总共只有一个候选直接使用,多个候选让用户选任务名,不让用户输入 run 路径。
|
|
34
|
+
- 未填完的向导:读 `wizard --wizard ID`,仅补 `missingFields`。
|
|
35
|
+
- 已有 run:读取 run、计划、evidence 和版本。已校验则继续导出;校验失败则定位问题;草稿/分析阶段接着分析。不要因 `wizard.status=started` 就当作知识库已完成。
|
|
36
|
+
- 不重复问中心、仓库、范围和输出目录;用户要求调整时再补问。
|
|
37
|
+
|
|
38
|
+
### 复用共享原料,整理业务域
|
|
39
|
+
|
|
40
|
+
- 原料候选显示如“订单中心 · order-api、order-core · 11月4日”。多个中心先按中心选择,只有一个中心或版本时不叠加重复菜单。
|
|
41
|
+
- 选择原料后带入中心、仓库、资料和上下文模式。命名业务域时可列出该中心已有域供更新,并允许用户输入新域名称。已有域的范围在用户选择“沿用范围”或明确要求更新同一范围后才沿用,不能把别的域范围自动带过来。
|
|
42
|
+
- 新域名称没有可靠候选时直接问“这次整理哪个业务域?”,不要为凑选择题虚构企业业务域。
|
|
43
|
+
- 业务域名、边界说明和导出位置按下方“三项业务域设置”逐项引导。复用仅带入原料信息,不能替用户决定新业务域边界或导出位置。
|
|
44
|
+
- 原料版本检查失败,给出“刷新当前原料 / 选择其他原料”。刷新时沿用中心、仓库和已填域名/范围,将向导 intent 改为 `new`,不用重填。补充资料有变化也刷新。
|
|
45
|
+
|
|
46
|
+
### 新建中心或共享原料
|
|
47
|
+
|
|
48
|
+
- 已有中心可选或自定义新中心;没有候选时先问业务中心。业务域名独立作为下一项引导;用户已一次提供“订单中心 / 退款”时拆分记录,不重复提问。不能强迫用户确认 Agent 自动推断的名称。
|
|
49
|
+
- 当前目录是仓库时提供“使用当前仓库(推荐) / 添加或选择其他仓库”。选择前者就记录当前绝对路径,不再问路径。用户已有路径时直接使用。只有 docs 的任务可以不提供代码仓库。
|
|
50
|
+
- 业务域名、业务域边界说明、知识库导出位置按下节引导;用户已明确提供某项则跳过该项。
|
|
51
|
+
- 确定仓库后,按下方“文档资料选择”显式询问是否添加资料;资料选择和知识库导出位置都不能当作可选技术参数而跳过。其他技术参数不逐项追问。
|
|
52
|
+
- 用户明确只要准备共享原料时使用 `material` 意图,跳过域名和业务范围;不强制额外走一遍业务域创建。
|
|
53
|
+
|
|
54
|
+
## 文档资料选择(新建原料,包括仅准备原料)
|
|
55
|
+
|
|
56
|
+
确定仓库后、调用 wizard-start 前,通过宿主结构化提问工具询问:“是否添加 PRD、ERD 等文档资料参与分析?”遵守选择优先规则,提供:
|
|
57
|
+
|
|
58
|
+
- **不添加,仅分析代码**:没有文档线索时推荐;保存 `answers.rawDocsDir: ""`。
|
|
59
|
+
- **添加文档资料**:用户已提到 PRD、ERD 或其他相关资料但未给路径时推荐;选择后仅补问本地资料目录,保存绝对路径到 `answers.rawDocsDir`。没有代码仓库时不能选择仅分析代码,直接收集资料目录。
|
|
60
|
+
|
|
61
|
+
说明可加入 PRD、ERD、业务规则、接口说明和数据库设计;文档用于提取业务意图并与源码交叉核验,不能单凭文档认定代码已实现。Markdown、TXT、CSV、YAML、JSON 可直接纳入;Word、PDF、Excel、PPT、HTML 需 MarkItDown 转换。图片型 ERD、扫描 PDF 应先整理为可读文本;在线链接需先获取授权并下载到本地,不把 URL 当成本地目录。
|
|
62
|
+
|
|
63
|
+
选择添加需转换的文档时,告知将检查并按需配套安装 MarkItDown(Python/pip,非 npm 依赖);安装和验证遵循 execution.md。默认代码打包所需 Repomix 由 npm/npx 首次下载,也需提前告知。
|
|
64
|
+
|
|
65
|
+
用户已经给出目录或明确不添加时直接保存,不重复询问。用户选择添加但路径未齐全时保持等待,不能按默认空值继续。`missingFields` 在未回答时返回 rawDocsDir;默认不再填入空字符串,必须显式保存用户选择。底层创建原料同样拒绝缺省 rawDocsDir。恢复未启动向导时根据已有问答判断是否已完成选择,不重复问已回答项。开始前摘要同时展示资料目录或“无补充资料”。
|
|
66
|
+
|
|
67
|
+
复用原料或继续任务时展示已有资料并沿用,不强制重新提问;用户要求新增或替换资料时保留原料不可变,使用同一向导切换 `intent: new`、更新 `rawDocsDir` 并刷新原料,保留已填中心、仓库和业务域设置。
|
|
68
|
+
|
|
69
|
+
## 三项业务域设置(新建和复用均适用)
|
|
70
|
+
|
|
71
|
+
这三项对应原 Web 的“业务域名、业务域边界说明、知识资产导出目录”和 CLI 的 `--domain / --scope / --output`。已经给出的信息不重复问;未给出时每轮只引导一项。
|
|
72
|
+
|
|
73
|
+
### 1. 业务域名
|
|
74
|
+
|
|
75
|
+
提问:“这次要整理哪个业务域?”有该中心已有域时展示名称供更新,允许自行输入新名。没有可靠候选时提供“例如退款、订单履约”的格式示例,仅作为输入帮助,不代表自动划定业务域。中文作为展示名称保存到 `answers.domainName`,目录使用 `answers.domainSlug`。中心同理使用 `productName / productSlug`。
|
|
76
|
+
|
|
77
|
+
名称选项同时展示英文目录标识,如“退款(refund)”“订单履约(order-fulfillment)”,选择后一起保存。用户只给中文名时,由 Agent 推荐有业务含义的英文标识,提供“采用建议 / 自定义标识”,不要求用户理解 slug 或翻译英文。用户已给英文名或名称和标识时不重复问;纯英文名称可自动规范为小写连字符形式。复用已有中心或更新已有域必须沿用记录中的标识,不能根据中文名重新生成。
|
|
78
|
+
|
|
79
|
+
中心和域目录标识仅允许小写英文字母、数字及单连字符(例如 `order-center`、`refund`);不能含中文、空格、点或下划线,也不能把中文删除后用残余字符凑标识。名称与英文标识未齐全时保持收集状态,不开始任务。
|
|
80
|
+
|
|
81
|
+
### 2. 业务域边界说明
|
|
82
|
+
|
|
83
|
+
提问:“这个业务域包含哪些流程,明确不包含哪些相邻业务?”优先基于用户已有描述给出具体边界草案,并提供“采用这份边界 / 修改边界”。例如退款域草案:
|
|
84
|
+
|
|
85
|
+
> 包含退款申请、审核和原路退回,以及相关状态、金额规则与异常处理;不包含售后换货,订单支付只保留必要依赖。
|
|
86
|
+
|
|
87
|
+
这是候选文本,必须用户选择后才能使用。没有足够依据提出具体草案时,直接请用户简述覆盖范围与排除项。不要用“全部主流程”“相关功能”等泛化文字替代明确边界;相邻域不扩写为本次主知识。用户已经提供清楚的范围时直接保存为 `answers.domainScope`。
|
|
88
|
+
|
|
89
|
+
### 3. 知识库导出位置
|
|
90
|
+
|
|
91
|
+
提问:“知识库导出到哪里?”必须通过宿主结构化提问工具显示以下单选,不能只在消息中列出两项让用户回复编号(仅上述工具不可用情形允许降级):
|
|
92
|
+
|
|
93
|
+
- **当前任务目录(推荐)**:展示 `<任务目录>/code-knowledge/<product-slug>/<domain-slug>/` 的实际完整路径。
|
|
94
|
+
- **自定义目录**:用户输入导出根目录,再展示追加 `code-knowledge/<product-slug>/<domain-slug>/` 后的最终路径,避免把根目录当成最终目录。
|
|
95
|
+
|
|
96
|
+
选择当前目录后,将任务启动目录显式写入 `answers.outputRootDir`;自定义则记录用户输入并解析相对路径。不能因发现接口给出默认推荐值就视为用户已选择。用户回复“默认”可视为选择当前目录。自定义路径已明确时不再问一次选择题。
|
|
97
|
+
|
|
98
|
+
`wizard` 返回 `exportDirPreview`,开始前用它连同域名和边界说明展示摘要。资料与中间产物仍保留在工作区,不因修改导出位置而移动。用户仅准备原料时无需业务域设置;继续已有任务则沿用保存的设置并展示摘要,不重复提问。
|
|
99
|
+
|
|
100
|
+
## 启动前分支检查
|
|
101
|
+
|
|
102
|
+
文档资料选择和必填设置齐全后、wizard-start 前,按 execution.md 的 master 检查协议检查全部依赖服务:自动切换 master、从 origin 快进拉取、核对 HEAD 与 origin/master 一致。该步骤不新增选择题或开始确认。任何失败立即停止并请求人工介入,不能忽略错误继续逆向。复用和续跑同样执行;代码更新使原料过期时按刷新流程处理,不能改写历史版本证据。
|
|
103
|
+
|
|
104
|
+
## 持久化与推进
|
|
105
|
+
|
|
106
|
+
`wizard` 只保存用户已给出的答案,不提取代码。每个向导有独立 `wizardId`,存于 `.code2knowledge/wizards/`,互不覆盖。`collecting → ready → started` 只是参数收集状态,分析进度仍由 `run.json`、计划和 evidence 表达。
|
|
107
|
+
|
|
108
|
+
用户更换域名且没有同时给出新范围时,清空原范围并重新询问范围选择,不能沿用其他域的范围。更换中心或域名时也清空旧标识,重新确定对应英文标识;用户明确选择已有记录或同时给出标识时沿用。收到答案后保存;`missingFields` 为空且文档资料选择已完成时无需重复确认,显示业务域名、边界说明和 exportDirPreview 的简短摘要、完成分支检查后执行 `wizard-start`,直接进入分析。`wizard-start` 会关联生成的 run/material;中断后再次调用返回同一任务。不要再额外调用 `prepare` 造成重复任务。
|
|
109
|
+
|
|
110
|
+
同名导出目录已存在时,只有用户明确选择更新该域或已授权更新才允许覆盖;否则询问“更新已有业务域 / 另取名称”。选更新后不重复问相同授权。普通只读发现、版本检查和阶段推进不额外增加确认。
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Internal Agent tool: users interact with the Skill rather than these commands.
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
7
|
+
const [action, ...args] = argv;
|
|
8
|
+
if (!action || action === "--help") {
|
|
9
|
+
console.log("Agent helper: notify --input FILE --workspace DIR; prepare|material|domain --input FILE [--workspace DIR]; discover|list|runs|wizard|wizard-start|check|status|validate|export --workspace DIR [--material DIR|--run DIR] [--output DIR]");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const options = {};
|
|
13
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
14
|
+
const key = args[i];
|
|
15
|
+
if (!["--input", "--workspace", "--run", "--material", "--output", "--wizard"].includes(key) || !args[i + 1] || args[i + 1].startsWith("--")) {
|
|
16
|
+
throw new Error(`参数无效或缺值:${key}`);
|
|
17
|
+
}
|
|
18
|
+
options[key.slice(2)] = args[i + 1];
|
|
19
|
+
}
|
|
20
|
+
if (options.workspace) process.env.C2K_WORKSPACE = path.resolve(options.workspace);
|
|
21
|
+
const kb = await import("./lib/knowledge.mjs");
|
|
22
|
+
let input = {};
|
|
23
|
+
if (options.input) input = JSON.parse(await fs.readFile(path.resolve(options.input), "utf8"));
|
|
24
|
+
let wizard;
|
|
25
|
+
if (["discover", "runs", "wizard", "wizard-start"].includes(action)) wizard = await import("./lib/wizard.mjs");
|
|
26
|
+
let result;
|
|
27
|
+
switch (action) {
|
|
28
|
+
case "notify": {
|
|
29
|
+
const { notify } = await import("./lib/notify.mjs");
|
|
30
|
+
if (input.event !== "intervention") throw new Error("手动通知仅支持 intervention;完成通知由 export 生成");
|
|
31
|
+
result = await notify({ ...input, workspace: kb.workspaceDir });
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
case "discover": result = await wizard.discoverWorkspace(); break;
|
|
35
|
+
case "runs": result = { runs: await wizard.listRuns() }; break;
|
|
36
|
+
case "wizard": result = options.input ? await wizard.saveWizard(input) : await wizard.readWizard(options.wizard); break;
|
|
37
|
+
case "wizard-start": result = await wizard.startWizard(options.wizard); break;
|
|
38
|
+
case "prepare": result = await kb.buildKnowledgeBase(input); break;
|
|
39
|
+
case "material": result = await kb.createCenterMaterial(input); break;
|
|
40
|
+
case "domain": result = await kb.createDomainFromMaterial(input); break;
|
|
41
|
+
case "list": result = { workspaceDir: kb.workspaceDir, materials: await kb.listMaterials() }; break;
|
|
42
|
+
case "check": result = await kb.checkMaterial(options.material); break;
|
|
43
|
+
case "status": result = await kb.readRun(options.run); break;
|
|
44
|
+
case "validate": result = await kb.validateRun(options.run); break;
|
|
45
|
+
case "export": result = await kb.exportRun(options.run, options.output); break;
|
|
46
|
+
default: throw new Error(`未知操作:${action}`);
|
|
47
|
+
}
|
|
48
|
+
console.log(JSON.stringify(result, null, 2));
|
|
49
|
+
if (result.validation?.valid === false || result.reusable === false) process.exitCode = 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (process.argv[1] && await fs.realpath(process.argv[1]) === (await import("node:url")).fileURLToPath(import.meta.url)) {
|
|
53
|
+
main().catch((error) => { console.error(JSON.stringify({ error: error.message })); process.exitCode = 1; });
|
|
54
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export async function ensureConfig({ userHome = os.homedir(), dryRun = false } = {}) {
|
|
6
|
+
const configPath = path.join(userHome, '.code2knowledge', 'config.yml');
|
|
7
|
+
if (!dryRun) {
|
|
8
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
9
|
+
const template = await fs.readFile(new URL('../../assets/notification-config.example.yml', import.meta.url), 'utf8');
|
|
10
|
+
try { await fs.writeFile(configPath, template, { flag: 'wx', mode: 0o600 }); }
|
|
11
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
12
|
+
}
|
|
13
|
+
return configPath;
|
|
14
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { parse, stringify } from "yaml";
|
|
5
|
+
|
|
6
|
+
export const schemaVersion = "2.0";
|
|
7
|
+
export const assetDirs = ["ontology", "domains", "graph", "mappings", "rules"];
|
|
8
|
+
export const fullCommit = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
9
|
+
const object = (v) => v && typeof v === "object" && !Array.isArray(v);
|
|
10
|
+
const text = (v) => typeof v === "string" && !!v.trim();
|
|
11
|
+
const array = (v) => Array.isArray(v) ? v : [];
|
|
12
|
+
const list = (v) => Array.isArray(v) && v.every(text);
|
|
13
|
+
const date = (v) => typeof v === "string" && /^\d{4}-\d\d-\d\dT/.test(v) && Number.isFinite(Date.parse(v)) && Date.parse(v) <= Date.now() + 60000;
|
|
14
|
+
const relative = (v) => text(v) && !path.posix.isAbsolute(v) && !/^[A-Za-z]:|\\|[\x00-\x1f]/.test(v) && v.split("/").every((p) => p && p !== "." && p !== "..");
|
|
15
|
+
export const sha256 = (v) => crypto.createHash("sha256").update(v).digest("hex");
|
|
16
|
+
|
|
17
|
+
// Exclude audit fields, but include source versions and substantive content in review fingerprints.
|
|
18
|
+
export function contentHash(value) {
|
|
19
|
+
const clean = (v) => Array.isArray(v) ? v.map(clean) : object(v)
|
|
20
|
+
? Object.fromEntries(Object.keys(v).sort().filter((k) => !["review", "review_status", "generated_at"].includes(k)).map((k) => [k, clean(v[k])])) : v;
|
|
21
|
+
return sha256(JSON.stringify(clean(value)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sourceRecords(repositories = [], documents = []) {
|
|
25
|
+
return [
|
|
26
|
+
...repositories.map((r) => ({ kind: "code", repository: r.name, commit: fullCommit.test(r.commit || "") ? r.commit : null,
|
|
27
|
+
worktree_status: r.worktreeStatus || "unknown" })),
|
|
28
|
+
...documents.map(({ document_id, path: file, sha256: digest }) => ({ kind: "document", document_id, path: file, sha256: digest })),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function makeContext({ product, domain, repositories = [], documents = [], identities = {}, generatedAt = new Date().toISOString() }) {
|
|
33
|
+
return { schema_version: schemaVersion, product, domain, generated_at: generatedAt,
|
|
34
|
+
systems: repositories.map((r) => r.name), sources: sourceRecords(repositories, documents), identities };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function metadataFor(context, type) {
|
|
38
|
+
return { schema_version: schemaVersion, knowledge_id: context.identities?.[type] || `K:${context.product}:${context.domain}:${type}`,
|
|
39
|
+
product: context.product, domain: context.domain, systems: [...context.systems], knowledge_type: type,
|
|
40
|
+
generated_at: context.generated_at, review_status: "pending", owner: null,
|
|
41
|
+
lifecycle: "current", sources: structuredClone(context.sources) };
|
|
42
|
+
}
|
|
43
|
+
export function pendingClaim(type = "code_fact") {
|
|
44
|
+
return { claim_type: type, status: "UNVERIFIED", review_status: "pending", lifecycle: "current", evidence: [],
|
|
45
|
+
applicability: { conditions: [], versions: [], limitations: [] } };
|
|
46
|
+
}
|
|
47
|
+
export function markdown(meta, body, refs = []) {
|
|
48
|
+
return `---\n${stringify({ ...meta, claim_refs: refs })}---\n\n${body}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseAsset(file, content) {
|
|
52
|
+
if (/\.md$/i.test(file)) {
|
|
53
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
54
|
+
if (!match) throw new Error("缺少 YAML frontmatter;请按 references/knowledge-model.md 补齐,不能将旧资产直接认证为新契约");
|
|
55
|
+
const meta = parse(match[1]);
|
|
56
|
+
return { meta, body: content.slice(match[0].length), value: { meta, body: content.slice(match[0].length) } };
|
|
57
|
+
}
|
|
58
|
+
const value = parse(content);
|
|
59
|
+
if (!object(value)) throw new Error("YAML 顶层必须为对象,业务字段使用顶层 meta");
|
|
60
|
+
return { meta: value.meta, value };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Reject symlinks rather than letting an exported asset escape the selected five directories.
|
|
64
|
+
export async function assetFiles(root, dirs = assetDirs) {
|
|
65
|
+
const result = [];
|
|
66
|
+
async function walk(dir) {
|
|
67
|
+
let entries;
|
|
68
|
+
try { entries = await fs.readdir(path.join(root, dir), { withFileTypes: true }); }
|
|
69
|
+
catch (e) { if (e.code === "ENOENT") return; throw e; }
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const file = `${dir}/${entry.name}`;
|
|
72
|
+
if (entry.isSymbolicLink()) throw new Error(`知识资产不允许符号链接:${file}`);
|
|
73
|
+
if (entry.isDirectory()) await walk(file);
|
|
74
|
+
else if (entry.isFile()) result.push(file);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const dir of dirs) await walk(dir);
|
|
78
|
+
return result.sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function existingIdentities(root) {
|
|
82
|
+
const ids = {};
|
|
83
|
+
for (const file of await assetFiles(root)) {
|
|
84
|
+
if (!/\.(md|ya?ml)$/i.test(file) || /^(ontology|rules)\//.test(file)) continue;
|
|
85
|
+
try {
|
|
86
|
+
const { meta } = parseAsset(file, await fs.readFile(path.join(root, file), "utf8"));
|
|
87
|
+
if (text(meta?.knowledge_type) && text(meta?.knowledge_id)) {
|
|
88
|
+
if (ids[meta.knowledge_type] && ids[meta.knowledge_type] !== meta.knowledge_id) continue;
|
|
89
|
+
ids[meta.knowledge_type] = meta.knowledge_id;
|
|
90
|
+
}
|
|
91
|
+
} catch { /* Legacy assets are read as legacy, never silently upgraded. */ }
|
|
92
|
+
}
|
|
93
|
+
return ids;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function reviewErrors(value, label, errors, fingerprintValue = value) {
|
|
97
|
+
if (!["pending", "confirmed", "rejected"].includes(value.review_status)) errors.push(`${label}.review_status 必须为 pending/confirmed/rejected`);
|
|
98
|
+
if (value.review_status !== "pending" && ["confirmed", "rejected"].includes(value.review_status)) {
|
|
99
|
+
const r = value.review;
|
|
100
|
+
if (!object(r) || !text(r.reviewer) || !date(r.reviewed_at) || !text(r.basis) || r.content_sha256 !== contentHash(fingerprintValue)) {
|
|
101
|
+
errors.push(`${label} 人工审核缺少真实依据或内容指纹已变化:需要 review.reviewer/reviewed_at/basis/content_sha256;重新核实后填写,默认保持 pending`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Only checks recognizable accidental disclosures; not a credential or privacy certification.
|
|
107
|
+
function portable(content, label, errors) {
|
|
108
|
+
if (/(?:\/(?:Users|home|private\/var|var\/folders|tmp|Volumes)\/[^\s"'<>]+|[A-Za-z]:\\[^\s]+)/.test(content)) errors.push(`${label} 包含机器绝对路径`);
|
|
109
|
+
if (/https?:\/\/[^\s/]+@|[?&](?:access_token|token|api_key|password)=|(?:sk-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,})/i.test(content)) errors.push(`${label} 包含认证 URL 或疑似凭证`);
|
|
110
|
+
if (/(?:\.code2knowledge\/|(?:^|[\s"'(])(?:\.\.\/)?evidence\/|AI_PROMPT\.md|analysis-plan\.yaml|EVIDENCE:)/m.test(content)) errors.push(`${label} 引用内部证据或临时工作区;请将整理后的证据写入最终资产`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function validateContract(root, options = {}) {
|
|
114
|
+
const errors = [], warnings = [], assets = [], claims = new Map(), knowledgeIds = new Map(), nodes = new Map(), impact = [];
|
|
115
|
+
const fail = (label, message) => errors.push(`${label}:${message}`);
|
|
116
|
+
let files;
|
|
117
|
+
try { files = await assetFiles(root); }
|
|
118
|
+
catch (e) { return { errors: [e.message], warnings, assets: [], impact: [] }; }
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
if (!/\.(md|ya?ml)$/i.test(file)) { fail(file, "只支持 Markdown/YAML 知识资产"); continue; }
|
|
121
|
+
const content = await fs.readFile(path.join(root, file), "utf8");
|
|
122
|
+
portable(content, file, errors);
|
|
123
|
+
if (/^(ontology|rules)\//.test(file)) {
|
|
124
|
+
try {
|
|
125
|
+
const expected = file.startsWith("ontology/") ? "type_constraint" : "maintenance";
|
|
126
|
+
const role = parseAsset(file, content).meta?.knowledge_type;
|
|
127
|
+
if (role !== expected) fail(file, `维护资料需明确 meta/frontmatter.knowledge_type: ${expected}`);
|
|
128
|
+
} catch (e) { fail(file, e.message); }
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
let asset;
|
|
132
|
+
try { asset = { file, ...parseAsset(file, content) }; }
|
|
133
|
+
catch (e) { fail(file, e.message); continue; }
|
|
134
|
+
assets.push(asset);
|
|
135
|
+
const m = asset.meta;
|
|
136
|
+
if (!object(m)) { fail(file, "缺少 meta;旧资产需按知识契约补齐,不自动填造元数据"); continue; }
|
|
137
|
+
if (m.schema_version !== schemaVersion) fail(file, `schema_version 必须为字符串 ${schemaVersion};旧任务可续跑,资产需显式补齐`);
|
|
138
|
+
for (const k of ["knowledge_id", "product", "domain", "knowledge_type"]) if (!text(m[k])) fail(file, `缺少文本元数据 ${k}`);
|
|
139
|
+
for (const k of ["product", "domain"]) if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(m[k] || "")) fail(file, `${k} 必须为英文目录标识`);
|
|
140
|
+
if (options.run && (m.product !== options.run.productSlug || m.domain !== options.run.domainSlug)) fail(file, "product/domain 与任务不一致");
|
|
141
|
+
if (!list(m.systems) || new Set(m.systems).size !== m.systems?.length) fail(file, "systems 必须为不重复的服务标识列表");
|
|
142
|
+
if (!date(m.generated_at)) fail(file, "generated_at 必须为真实 ISO 时间,不能为未来时间");
|
|
143
|
+
if (!(m.owner === null || text(m.owner))) fail(file, "owner 必须为文本或 null,未知不得编造");
|
|
144
|
+
if (!["current", "needs_review", "deprecated"].includes(m.lifecycle)) fail(file, "lifecycle 必须为 current/needs_review/deprecated");
|
|
145
|
+
reviewErrors(m, `${file}.meta`, errors, asset.value);
|
|
146
|
+
if (knowledgeIds.has(m.knowledge_id)) fail(file, `knowledge_id 重复:${m.knowledge_id}(${knowledgeIds.get(m.knowledge_id)})`);
|
|
147
|
+
knowledgeIds.set(m.knowledge_id, file);
|
|
148
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(m.knowledge_id || "")) fail(file, "knowledge_id 必须为稳定标识,不能包含文件路径或空格");
|
|
149
|
+
const types = { glossary: "terms", concepts: "concepts", capabilities: "capabilities", business_rules: "rules", flow_definitions: "flows", claims: "claims", code_graph: "nodes", code_mappings: "capability_mappings" };
|
|
150
|
+
if (!["overview", "flows", "pitfalls", "business_note", ...Object.keys(types)].includes(m.knowledge_type)) fail(file, "knowledge_type 未定义");
|
|
151
|
+
if (m.knowledge_type === "code_graph" && m.verified_commits !== undefined) {
|
|
152
|
+
if (!object(m.verified_commits)) fail(file, "verified_commits 必须为仓库到完整 commit 的映射");
|
|
153
|
+
else for (const [repo, commit] of Object.entries(m.verified_commits)) if (!array(m.sources).some((s) => s?.kind === "code" && s.repository === repo && s.commit === commit && s.worktree_status === "clean") || !fullCommit.test(commit)) fail(file, `verified_commits 与代码来源不一致:${repo}`);
|
|
154
|
+
}
|
|
155
|
+
if (types[m.knowledge_type] && !Array.isArray(asset.value[types[m.knowledge_type]])) fail(file, `YAML 结构错误:${types[m.knowledge_type]} 必须为对象列表`);
|
|
156
|
+
if (asset.value.domain !== undefined && asset.value.domain !== m.domain) fail(file, "顶层 domain 与 meta.domain 不一致");
|
|
157
|
+
if (!Array.isArray(m.sources) || !m.sources.length) { fail(file, "缺少来源 sources(允许仅有业务资料来源)"); continue; }
|
|
158
|
+
const sourceKeys = new Set();
|
|
159
|
+
for (const source of m.sources) {
|
|
160
|
+
if (!object(source)) { fail(file, "sources 必须为对象列表"); continue; }
|
|
161
|
+
const key = source.kind === "code" ? `code:${source.repository}` : `document:${source.document_id}`;
|
|
162
|
+
if (sourceKeys.has(key)) fail(file, `来源重复:${key}`);
|
|
163
|
+
sourceKeys.add(key);
|
|
164
|
+
if (source.kind === "code") {
|
|
165
|
+
if (!text(source.repository) || !(source.commit === null || fullCommit.test(source.commit || "")) || !["clean", "dirty", "unknown"].includes(source.worktree_status)) fail(file, "代码来源需 repository、完整 commit(未知为 null)、worktree_status");
|
|
166
|
+
if (!array(m.systems).includes(source.repository)) fail(file, `来源仓库未列入 systems:${source.repository}`);
|
|
167
|
+
if (options.material) {
|
|
168
|
+
const recorded = options.material.repositories.find((r) => r.name === source.repository);
|
|
169
|
+
if (!recorded || source.commit !== (fullCommit.test(recorded.commit || "") ? recorded.commit : null) || source.worktree_status !== (recorded.worktreeStatus || "unknown")) fail(file, `来源仓库/commit/工作区状态与分析原料不一致:${source.repository}`);
|
|
170
|
+
}
|
|
171
|
+
} else if (source.kind === "document") {
|
|
172
|
+
if (!text(source.document_id) || !relative(source.path) || !/^[0-9a-f]{64}$/.test(source.sha256 || "")) fail(file, "资料来源需 document_id、相对 path 和真实 sha256");
|
|
173
|
+
if (options.material && !options.material.documents?.some((d) => d.document_id === source.document_id && d.path === source.path && d.sha256 === source.sha256)) fail(file, `资料来源与原料不一致:${source.document_id}`);
|
|
174
|
+
} else fail(file, "sources.kind 必须为 code/document");
|
|
175
|
+
}
|
|
176
|
+
for (const system of array(m.systems)) if (!m.sources.some((s) => s?.kind === "code" && s.repository === system)) fail(file, `systems 缺少对应代码来源:${system}`);
|
|
177
|
+
for (const [key, values] of Object.entries(asset.value)) {
|
|
178
|
+
if (key === "meta" || !Array.isArray(values)) continue;
|
|
179
|
+
for (const value of values) {
|
|
180
|
+
if (!object(value)) { fail(file, `${key} 必须为对象列表`); continue; }
|
|
181
|
+
if (["terms", "concepts", "capabilities", "rules", "flows", "claims"].includes(key)) {
|
|
182
|
+
if (!text(value.id) || claims.has(value.id)) fail(file, `业务 ID 缺失或重复:${value.id}`);
|
|
183
|
+
else claims.set(value.id, { ...value, file, meta: m, collection: key });
|
|
184
|
+
}
|
|
185
|
+
if (key === "nodes") {
|
|
186
|
+
if (!text(value.id)) fail(file, "图谱 ID 必须为非空文本");
|
|
187
|
+
if (nodes.has(value.id)) fail(file, `图谱 ID 重复:${value.id}`);
|
|
188
|
+
nodes.set(value.id, value);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// A real run always has every associated repository recorded, including multi-service context.
|
|
194
|
+
for (const a of assets.filter((a) => object(a.meta))) {
|
|
195
|
+
if (options.material) for (const repo of options.material.repositories) {
|
|
196
|
+
if (!array(a.meta.sources).some((s) => s?.kind === "code" && s.repository === repo.name)) fail(a.file, `缺少本次多仓库来源:${repo.name}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function evidenceCheck(e, label, owner, requireCode = false) {
|
|
201
|
+
if (!object(e)) { fail(label, "证据必须为结构化对象"); return; }
|
|
202
|
+
if (!text(e.supports)) fail(label, "证据缺少 supports,需说明具体支持的结论");
|
|
203
|
+
if (e.kind === "code") {
|
|
204
|
+
const source = array(owner.meta?.sources).find((s) => s?.kind === "code" && s.repository === e.repository);
|
|
205
|
+
if (!source || !fullCommit.test(e.commit || "") || source.commit !== e.commit) fail(label, "证据仓库或完整 commit 与资产来源不一致");
|
|
206
|
+
if (!relative(e.path)) fail(label, `证据路径无效:${e.path}`);
|
|
207
|
+
if (!text(e.symbol)) fail(label, "证据缺少 symbol");
|
|
208
|
+
if (!Array.isArray(e.lines) || e.lines.length !== 2 || !e.lines.every((n) => Number.isInteger(n) && n > 0) || e.lines[0] > e.lines[1]) fail(label, "证据 lines 必须为有效的 [起始行,结束行]");
|
|
209
|
+
if (e.node_id && !nodes.has(e.node_id)) fail(label, `证据引用不存在的节点:${e.node_id}`);
|
|
210
|
+
if (e.node_id && nodes.get(e.node_id)?.path !== `${e.repository}/${e.path}`) fail(label, "证据路径与引用节点不一致");
|
|
211
|
+
if (requireCode && (source?.worktree_status !== "clean" || options.currentRepositories?.find((r) => r.name === e.repository)?.matches === false)) fail(label, "VERIFIED 证据对应仓库必须在分析时与核验时均为 clean 且版本一致");
|
|
212
|
+
if (options.readCode && relative(e.path) && fullCommit.test(e.commit || "")) {
|
|
213
|
+
try {
|
|
214
|
+
const body = await options.readCode(e.repository, e.commit, e.path);
|
|
215
|
+
const count = body.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n").length;
|
|
216
|
+
if (e.lines?.[1] > count) fail(label, `证据行号超出 ${e.path} 在对应 commit 中的 ${count} 行`);
|
|
217
|
+
} catch { fail(label, `证据文件在对应 commit 中不存在或不可读取:${e.repository}/${e.path}`); }
|
|
218
|
+
}
|
|
219
|
+
impact.push({ repository: e.repository, commit: e.commit, path: e.path, symbol: e.symbol, knowledge_id: owner.meta?.knowledge_id, claim_id: owner.id, lifecycle: owner.lifecycle });
|
|
220
|
+
} else if (e.kind === "document") {
|
|
221
|
+
if (requireCode) fail(label, "业务资料不能单独证明代码已实现 VERIFIED 结论");
|
|
222
|
+
if (!array(owner.meta?.sources).some((s) => s?.kind === "document" && s.document_id === e.document_id && s.sha256 === e.sha256 && s.path === e.path) || !text(e.locator)) fail(label, "资料证据缺少有效来源版本或 locator");
|
|
223
|
+
} else fail(label, "证据 kind 必须为 code/document");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const claim of claims.values()) {
|
|
227
|
+
const label = `${claim.file}#${claim.id}`;
|
|
228
|
+
if (!["code_fact", "business_rule", "inference"].includes(claim.claim_type)) fail(label, "claim_type 必须为 code_fact/business_rule/inference");
|
|
229
|
+
if (!["VERIFIED", "UNVERIFIED"].includes(claim.status)) fail(label, "status 必须为 VERIFIED/UNVERIFIED(仅表示代码核验)");
|
|
230
|
+
if (claim.claim_type === "inference" && claim.status === "VERIFIED") fail(label, "推断 inference 不得标为 VERIFIED");
|
|
231
|
+
if (!["current", "needs_review", "deprecated"].includes(claim.lifecycle)) fail(label, "缺少有效 lifecycle");
|
|
232
|
+
if (claim.lifecycle !== "current" && claim.status === "VERIFIED") fail(label, "待复核/废弃结论不能继续作为当前 VERIFIED 事实");
|
|
233
|
+
reviewErrors(claim, label, errors, Object.fromEntries(Object.entries(claim).filter(([k]) => !["file", "meta", "collection"].includes(k))));
|
|
234
|
+
if (!object(claim.applicability) || !["conditions", "versions", "limitations"].every((k) => list(claim.applicability[k]))) fail(label, "applicability 需 conditions/versions/limitations 文本列表");
|
|
235
|
+
if (claim.status === "VERIFIED" && !claim.applicability?.versions?.length) fail(label, "VERIFIED 结论需声明适用版本");
|
|
236
|
+
if (!Array.isArray(claim.evidence)) fail(label, "缺少 evidence 列表");
|
|
237
|
+
else {
|
|
238
|
+
if (claim.status === "VERIFIED" && !claim.evidence.some((e) => e?.kind === "code")) fail(label, "VERIFIED 关键结论缺少代码证据");
|
|
239
|
+
for (const e of claim.evidence) await evidenceCheck(e, label, claim, claim.status === "VERIFIED" && e?.kind === "code");
|
|
240
|
+
}
|
|
241
|
+
if (claim.status === "UNVERIFIED" && !claim.applicability?.limitations?.length) fail(label, "UNVERIFIED 结论需明确已知限制或待核实原因");
|
|
242
|
+
if (claim.collection === "rules" && !["low", "medium", "high"].includes(claim.severity)) fail(label, "规则 severity 必须为 low/medium/high");
|
|
243
|
+
if (claim.collection === "flows") {
|
|
244
|
+
for (const k of ["trigger", "scope"]) if (!text(claim[k])) fail(label, `流程缺少 ${k}`);
|
|
245
|
+
for (const k of ["preconditions", "steps", "data_changes", "async_events", "failures", "compensation"]) if (!list(claim[k]) || !claim[k].length) fail(label, `流程需 ${k} 文本列表;无适用项时明确说明`);
|
|
246
|
+
const tests = claim.tests;
|
|
247
|
+
if (!object(tests) || !["discovered", "read", "limitations"].every((k) => list(tests[k])) || !Array.isArray(tests.executions)) fail(label, "流程需分别记录测试 discovered/read/executions/limitations");
|
|
248
|
+
else {
|
|
249
|
+
if (!tests.executions.length && !tests.limitations.length) fail(label, "未运行测试时需说明测试覆盖限制");
|
|
250
|
+
for (const r of tests.executions) if (!object(r) || !text(r.command) || !date(r.executed_at) || !Number.isInteger(r.exit_code) || !text(r.result)) fail(label, "测试执行记录需真实 command/executed_at/exit_code/result");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const key of ["rules", "concepts", "related", "capabilities"]) {
|
|
254
|
+
if (claim[key] !== undefined && !list(claim[key])) fail(label, `${key} 必须为 ID 列表`);
|
|
255
|
+
for (const id of Array.isArray(claim[key]) ? claim[key] : []) {
|
|
256
|
+
if (!claims.has(id) && !assets.some((a) => array(a.value.external_references).some((r) => object(r) && r.id === id && text(r.knowledge_id) && text(r.domain)))) fail(label, `悬空业务引用:${key} -> ${id}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const a of assets) {
|
|
261
|
+
if (!object(a.meta)) continue;
|
|
262
|
+
if (/\.md$/i.test(a.file)) {
|
|
263
|
+
if (!list(a.meta.claim_refs) || !a.meta.claim_refs.length) fail(a.file, "业务 Markdown 需 claim_refs 引用权威条目;关键结论不得只写在自由正文中");
|
|
264
|
+
for (const id of Array.isArray(a.meta.claim_refs) ? a.meta.claim_refs : []) if (!claims.has(id)) fail(a.file, `悬空 claim_refs:${id}`);
|
|
265
|
+
}
|
|
266
|
+
if (a.body) for (const token of a.body.match(/(?:CONCEPT|CAP|RULE|FLOW|CLAIM|TERM):[A-Za-z0-9:_-]+/g) || []) {
|
|
267
|
+
if (!claims.has(token)) fail(a.file, `正文引用不存在的业务 ID:${token}`);
|
|
268
|
+
}
|
|
269
|
+
for (const n of Array.isArray(a.value.nodes) ? a.value.nodes : []) {
|
|
270
|
+
if (!object(n)) { fail(a.file, "图谱节点必须为对象"); continue; }
|
|
271
|
+
if (n.path) {
|
|
272
|
+
if (!Array.isArray(n.evidence) || !n.evidence.length) fail(a.file, `代码节点缺少整理后的证据:${n.id}`);
|
|
273
|
+
for (const e of array(n.evidence)) await evidenceCheck(e, `${a.file}#${n.id}`, { ...n, meta: a.meta }, true);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const edge of Array.isArray(a.value.edges) ? a.value.edges : []) {
|
|
277
|
+
if (!object(edge)) { fail(a.file, "图谱关系必须为对象"); continue; }
|
|
278
|
+
if (!["VERIFIED", "UNVERIFIED"].includes(edge.status)) fail(a.file, "图谱关系需 status,未核实关系不应进入事实图谱");
|
|
279
|
+
if (edge.status !== "VERIFIED" || !edge.evidence?.length) fail(a.file, "图谱关系必须有 VERIFIED 代码证据;未知关系放入未确认结论");
|
|
280
|
+
for (const e of array(edge.evidence)) await evidenceCheck(e, a.file, { ...edge, meta: a.meta }, true);
|
|
281
|
+
}
|
|
282
|
+
for (const entry of array(a.value.entry_points)) {
|
|
283
|
+
if (!object(entry) || !nodes.has(entry.node_id) || (entry.capability_id && !claims.has(entry.capability_id))) fail(a.file, "entry_points 引用了不存在的节点或能力");
|
|
284
|
+
}
|
|
285
|
+
for (const key of ["capability_mappings", "rule_mappings", "flow_mappings"]) for (const m of array(a.value[key])) {
|
|
286
|
+
if (!object(m)) { fail(a.file, "映射必须为对象"); continue; }
|
|
287
|
+
const id = m.capability_id || m.rule_id || m.flow_id;
|
|
288
|
+
const claim = claims.get(id);
|
|
289
|
+
if (!claim) fail(a.file, `映射引用不存在的业务 ID:${id}`);
|
|
290
|
+
if (claim && claim.status !== "VERIFIED") warnings.push(`${a.file}:未核验结论的映射仅供定位,不代表代码已实现:${id}`);
|
|
291
|
+
if (!Array.isArray(m.evidence_refs) || !m.evidence_refs.length) fail(a.file, `映射缺少 evidence_refs:${id}`);
|
|
292
|
+
for (const ref of array(m.evidence_refs)) {
|
|
293
|
+
if (!object(ref) || ref.claim_id !== id || !Number.isInteger(ref.index) || ref.index < 0 || !claim?.evidence?.[ref.index]) { fail(a.file, `悬空证据引用:${id}`); continue; }
|
|
294
|
+
const e = claim.evidence[ref.index];
|
|
295
|
+
if (e.kind !== "code" || !array(m.graph_nodes).some((nodeId) => nodes.get(nodeId)?.path === `${e.repository}/${e.path}`)) fail(a.file, `映射证据与代码节点没有对应路径:${id}`);
|
|
296
|
+
}
|
|
297
|
+
if (!list(m.graph_nodes) || !m.graph_nodes.length || m.graph_nodes.some((id) => !nodes.has(id))) fail(a.file, `映射引用无效图谱节点:${id}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const c of claims.values()) if (c.collection === "flows" && c.status === "VERIFIED" && !assets.some((a) => array(a.value.flow_mappings).some((m) => m?.flow_id === c.id))) fail(c.file, `VERIFIED 流程缺少代码映射:${c.id}`);
|
|
301
|
+
for (const node of nodes.values()) if (node.type === "Table" && !assets.some((a) => array(a.value.edges).some((e) => e?.rel === "belongs_to" && e.from === node.id) || array(a.value.capability_mappings).some((m) => array(m?.graph_nodes).includes(node.id)))) fail("graph", `核心表缺少业务域归属或能力映射:${node.id}`);
|
|
302
|
+
// Central uniqueness is checked against the accessible current export, never historical runs.
|
|
303
|
+
if (options.peerRoot) {
|
|
304
|
+
let peers = [];
|
|
305
|
+
try { peers = await fs.readdir(options.peerRoot, { withFileTypes: true }); } catch (e) { if (e.code !== "ENOENT") throw e; }
|
|
306
|
+
for (const d of peers.filter((d) => d.isDirectory() && d.name !== options.run?.domainSlug)) {
|
|
307
|
+
const peer = path.join(options.peerRoot, d.name);
|
|
308
|
+
for (const f of await assetFiles(peer, ["domains", "graph", "mappings"])) {
|
|
309
|
+
try {
|
|
310
|
+
const other = parseAsset(f, await fs.readFile(path.join(peer, f), "utf8"));
|
|
311
|
+
if (knowledgeIds.has(other.meta?.knowledge_id)) fail(f, `同中心 knowledge_id 重复:${other.meta.knowledge_id}(业务域 ${d.name})`);
|
|
312
|
+
for (const k of ["terms", "concepts", "capabilities", "rules", "flows", "claims"]) for (const item of array(other.value[k])) if (claims.has(item.id)) fail(f, `同中心业务 ID 重复:${item.id}(业务域 ${d.name})`);
|
|
313
|
+
} catch { warnings.push(`相邻域旧资产无法完整检查:${d.name}/${f}`); }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { errors, warnings, assets: assets.map(({ file, meta }) => ({ file, ...meta })), impact };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function writeBusinessTemplates(draftsDir, domainName, context) {
|
|
321
|
+
const domain = context.domain;
|
|
322
|
+
const meta = (type) => metadataFor(context, type);
|
|
323
|
+
const pending = (type = "code_fact") => ({ ...pendingClaim(type), applicability: { conditions: [], versions: [], limitations: ["尚未分析,需沿授权业务范围核实来源与适用条件"] } });
|
|
324
|
+
const docs = {
|
|
325
|
+
"overview": `# ${domainName} - 业务域概览\n\n## 业务边界与角色\n\n需补充\n\n## 能力与主要依赖\n\n使用稳定 ID 引用能力与流程,写明业务背景。\n\n## 核验与审核\n\nVERIFIED 仅表示代码证据核验,不表示业务审核;默认 pending。\n`,
|
|
326
|
+
"flows": `# ${domainName} - 核心流程\n\n需补充\n\n详细流程维护在 flows.yaml,以 FLOW ID 引用;保留触发条件、业务对象与关键步骤摘要,规则通过 RULE ID 引用,不重复维护定义。\n`,
|
|
327
|
+
"pitfalls": `# ${domainName} - 风险与已知限制\n\n需补充\n\n风险结论维护在 claims.yaml,按稳定 ID 引用并说明适用背景、影响与未核实环节。没有测试执行记录不等于已有局部源码事实无效。\n`,
|
|
328
|
+
};
|
|
329
|
+
const files = {};
|
|
330
|
+
for (const [role, body] of Object.entries(docs)) files[`domains/${role}.md`] = markdown(meta(role), body,
|
|
331
|
+
role === "overview" ? [`CAP:${domain}:main`] : role === "flows" ? [`FLOW:${domain}:main`] : [`CLAIM:${domain}:risk`]);
|
|
332
|
+
const yaml = {
|
|
333
|
+
"domains/glossary.yaml": { meta: meta("glossary"), terms: [{ id: `TERM:${domain}:main`, term: domainName, meaning: "需补充", ...pending("business_rule") }] },
|
|
334
|
+
"domains/concepts.yaml": { meta: meta("concepts"), concepts: [{ id: `CONCEPT:${domain}`, name: domainName, type: "domain_entity", desc: "需补充", related: [], ...pending("business_rule") }] },
|
|
335
|
+
"domains/capabilities.yaml": { meta: meta("capabilities"), capabilities: [{ id: `CAP:${domain}:main`, name: "核心能力", desc: "需补充", concepts: [`CONCEPT:${domain}`], rules: [], ...pending() }] },
|
|
336
|
+
"domains/rules.yaml": { meta: meta("business_rules"), rules: [{ id: `RULE:${domain}:main`, name: "核心业务规则", subject: domainName, severity: "medium", desc: "需补充", checked_scope: "尚未核验", ...pending("business_rule") }] },
|
|
337
|
+
"domains/flows.yaml": { meta: meta("flow_definitions"), flows: [{ id: `FLOW:${domain}:main`, name: "核心流程", trigger: "需补充", scope: "需补充", preconditions: [], steps: [], data_changes: [], async_events: [], failures: [], compensation: [], capabilities: [`CAP:${domain}:main`], rules: [], tests: { discovered: [], read: [], executions: [], limitations: ["未发现、阅读或运行测试,覆盖范围待核实"] }, ...pending() }] },
|
|
338
|
+
"domains/claims.yaml": { meta: meta("claims"), claims: [{ id: `CLAIM:${domain}:risk`, name: "待核实风险", desc: "需补充", ...pending("inference") }] },
|
|
339
|
+
[`graph/curated/${domain}.graph.yaml`]: { meta: { ...meta("code_graph"), verified_commits: Object.fromEntries(context.sources.filter((s) => s.kind === "code" && s.commit && s.worktree_status === "clean").map((s) => [s.repository, s.commit])) }, nodes: [{ id: `DOMAIN:${domain}`, type: "BusinessDomain", name: domainName, desc: "需补充业务域代码事实节点" }], edges: [] },
|
|
340
|
+
[`mappings/${domain}.mapping.yaml`]: { meta: meta("code_mappings"), domain, capability_mappings: [], rule_mappings: [], flow_mappings: [], entry_points: [] },
|
|
341
|
+
};
|
|
342
|
+
for (const [file, value] of Object.entries(yaml)) files[file] = stringify(value);
|
|
343
|
+
for (const [file, content] of Object.entries(files)) {
|
|
344
|
+
try { await fs.writeFile(path.join(draftsDir, file), content, { flag: "wx" }); }
|
|
345
|
+
catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
346
|
+
}
|
|
347
|
+
}
|