plan-governance-cli 0.2.4 → 0.3.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/README.md CHANGED
@@ -173,10 +173,25 @@ npm install -g plan-governance-cli
173
173
  plan-governance-cli check . --strict-readiness
174
174
  ```
175
175
 
176
+ 功能图谱校验和影响分析:
177
+
178
+ ```bash
179
+ plan-governance-cli graph validate .
180
+ plan-governance-cli graph impact --from feature.model-lifecycle --depth 2 --format text .
181
+ plan-governance-cli graph impact --from feature.model-lifecycle --depth 2 --format json .
182
+ plan-governance-cli plan impact --input docs/graph/fixtures/plan-impact/model-lifecycle-api.json --format json .
183
+ plan-governance-cli graph code candidates --symbol APIHandler --kind class --format json .
184
+ plan-governance-cli graph code impact --repo modelpad --file Sources/ModelPadCore/API/APIServer.swift --symbol APIHandler --kind class --depth 2 --format json .
185
+ ```
186
+
187
+ 项目图谱固定放在 `docs/graph/functional.yaml`。CLI 只读,不修改 YAML、计划状态、计划正文或业务代码;图谱节点使用稳定 ID,证据使用 `kind/ref/locator`,代码对象使用带文件回退的 `code_refs`。影响分析默认两跳,按 `contains`、`orchestrates`、`exposes`、`implements`、`consumes`、`depends_on` 的契约方向传播,并输出最短路径、直接/间接分类和证据引用。代码候选命令根据 `file/symbol/kind` 搜索稳定锚点;多候选输出 `resolution: ask_user`,不自动写回。
188
+
189
+ `plan impact` 是计划治理的前置只读查询:输入文件声明 `graph_scope`、`change_kind`,默认只查询功能层;API 契约、数据、安全、外部边界或明确代码定位才升级到架构层或代码候选。输出查询层级、升级原因、行动分级、测试映射和未查询层级;失败不伪装成“无影响”,也不会触发 `gitnexus analyze`。
190
+
176
191
  也可以不全局安装,直接使用锁定版本:
177
192
 
178
193
  ```bash
179
- npx --yes --package plan-governance-cli@0.2.3 plan-governance-cli check . --strict-readiness
194
+ npx --yes --package plan-governance-cli@0.3.0 plan-governance-cli check . --strict-readiness
180
195
  ```
181
196
 
182
197
  npm 包内部仍使用版本化的 Python 检查器,但用户不需要直接调用或复制该脚本。旧项目已有本地脚本时仍可保留作为兼容或回滚路径。
@@ -307,6 +322,8 @@ python3 -m pytest
307
322
 
308
323
  进入 `待实施` 前,当前阶段必须有明确目标/范围、Step 0 基线、样本或替代基线矩阵、可执行验证方式、失败与回滚边界、无未解决阻塞项,以及最新独立准入复核“通过”。复核记录追加保留,`PLAN_MAP.md` 只链接最新有效结论。
309
324
 
325
+ 机器识别的结构化章节标题固定为 `阶段路线图`、`当前阶段`、`阶段准入摘要`、`最新独立准入复核`、`独立复核记录`,不要在这些标题中插入或添加 `阶段 1` 等编号;阶段编号由 `PLAN_MAP.md` 的当前阶段提供。严格检查发现常见编号变体时,会在输出中给出直接修正提示。
326
+
310
327
  阶段准入严格检查命令为 `--strict-readiness`。默认检查先以 `WARNING` 提示历史文档中的机械缺陷;严格模式才返回非零退出码。该检查只判断结构化准入条件,不替代业务验收。
311
328
 
312
329
  ## Codex 使用方式
@@ -18,6 +18,7 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
18
18
  const checker = resolve(packageRoot, "scripts", "check_plan_governance.py");
19
19
  const initializer = resolve(packageRoot, "scripts", "init_plan_governance.py");
20
20
  const hookRuntime = resolve(packageRoot, "scripts", "plan_governance_hook.py");
21
+ const graphRuntime = resolve(packageRoot, "scripts", "graph_governance.mjs");
21
22
  const manifestPath = resolve(packageRoot, "resources", "manifest.json");
22
23
 
23
24
  function fail(message) {
@@ -51,6 +52,24 @@ function runPython(script, args) {
51
52
  return result?.status ?? 1;
52
53
  }
53
54
 
55
+ function runNode(script, args) {
56
+ try {
57
+ accessSync(script, constants.R_OK);
58
+ } catch {
59
+ return fail(`找不到包内脚本:${script}`);
60
+ }
61
+
62
+ const result = spawnSync(process.execPath, [script, ...args], { stdio: "inherit" });
63
+ if (result.error) {
64
+ return fail(`无法启动 Node.js 脚本:${result.error.message}`);
65
+ }
66
+ if (result.signal) {
67
+ console.error(`plan-governance-cli: 脚本被信号 ${result.signal} 终止`);
68
+ return 1;
69
+ }
70
+ return result.status ?? 1;
71
+ }
72
+
54
73
  function loadManifest() {
55
74
  try {
56
75
  return JSON.parse(readFileSync(manifestPath, "utf8"));
@@ -168,6 +187,8 @@ function main() {
168
187
  if (command === "setup") return setup(args.slice(1));
169
188
  if (command === "init") return runPython(initializer, args.slice(1));
170
189
  if (command === "hook") return runPython(hookRuntime, args.slice(1));
190
+ if (command === "graph") return runNode(graphRuntime, args.slice(1));
191
+ if (command === "plan") return runNode(graphRuntime, args);
171
192
  if (command === "check") return runPython(checker, args.slice(1));
172
193
  return runPython(checker, args);
173
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plan-governance-cli",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "统一入口的计划治理检查 CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -13,7 +13,8 @@
13
13
  "resources/skill",
14
14
  "scripts/check_plan_governance.py",
15
15
  "scripts/init_plan_governance.py",
16
- "scripts/plan_governance_hook.py"
16
+ "scripts/plan_governance_hook.py",
17
+ "scripts/graph_governance.mjs"
17
18
  ],
18
19
  "engines": {
19
20
  "node": ">=20"
@@ -22,6 +23,9 @@
22
23
  "access": "public"
23
24
  },
24
25
  "scripts": {
25
- "test": "node --test tests/npm_cli.test.mjs"
26
+ "test": "node --test tests/npm_cli.test.mjs tests/graph_cli.test.mjs tests/architecture_graph_cli.test.mjs tests/plan_impact_step0.test.mjs tests/plan_impact_cli.test.mjs"
27
+ },
28
+ "dependencies": {
29
+ "yaml": "^2.9.0"
26
30
  }
27
31
  }
@@ -17,7 +17,8 @@
17
17
  "runtime": [
18
18
  "scripts/check_plan_governance.py",
19
19
  "scripts/init_plan_governance.py",
20
- "scripts/plan_governance_hook.py"
20
+ "scripts/plan_governance_hook.py",
21
+ "scripts/graph_governance.mjs"
21
22
  ],
22
23
  "hooks": []
23
24
  }
@@ -119,7 +119,7 @@ docs/
119
119
  5. 没有未解决的当前阶段阻塞项,且 `PLAN_MAP.md` 已同步。
120
120
  6. 最新独立准入复核明确为“通过”。
121
121
 
122
- 阶段准入摘要应使用固定字段:`准入状态`、`Step 0`、`样本矩阵`、`验证方式`、`失败/回滚边界`、`当前阻塞项`、`最新独立准入复核`。阶段身份以 `PLAN_MAP.md` 为准,不在计划正文中另定义阶段编号。
122
+ 机器识别的结构化章节标题必须保持固定名称:`阶段路线图`、`当前阶段`、`阶段准入摘要`、`最新独立准入复核`、`独立复核记录`。不要在这些标题前添加阶段编号(例如不要写成 `阶段 1 准入摘要` 或 `阶段 1 最新独立准入复核`);阶段身份以 `PLAN_MAP.md` 为准,不在计划正文中另定义阶段编号。若严格检查提示标题问题,优先按提示恢复固定标题。阶段准入摘要还必须使用固定字段:`准入状态`、`Step 0`、`样本矩阵`、`验证方式`、`失败/回滚边界`、`当前阻塞项`、`最新独立准入复核`。
123
123
 
124
124
  ### 独立复核记录
125
125
 
@@ -261,6 +261,34 @@ plan-governance-cli check . --stale-days 10
261
261
  - `--attest <plan-name>`:为已登记计划创建或覆盖 `docs/attestations/<plan-name>.json` 完成快照。
262
262
  - `--check-attestations`:检查完成快照中的计划文件和 `PLAN_MAP.md` hash 是否漂移;漂移、缺失或 JSON 损坏只输出 `WARNING`,不改变退出码。
263
263
 
264
+ ## 图谱与计划前置影响分析
265
+
266
+ 当项目声明了功能图谱或架构图谱时,使用 npm CLI 的只读命令查询影响范围:
267
+
268
+ ```bash
269
+ plan-governance-cli graph validate .
270
+ plan-governance-cli graph impact --from feature.model-lifecycle --depth 2 --format json .
271
+ plan-governance-cli graph code candidates --symbol APIHandler --kind class --format json .
272
+ plan-governance-cli graph code impact \
273
+ --repo modelpad \
274
+ --file Sources/ModelPadCore/API/APIServer.swift \
275
+ --symbol APIHandler \
276
+ --kind class \
277
+ --format json .
278
+ ```
279
+
280
+ 计划前置分析使用计划输入文件:
281
+
282
+ ```bash
283
+ plan-governance-cli plan impact \
284
+ --input docs/graph/fixtures/plan-impact/model-lifecycle-api.json \
285
+ --format json .
286
+ ```
287
+
288
+ `plan impact` 默认只查询功能层;API 契约、数据迁移、安全、外部边界或明确要求代码定位时,才升级到架构层或代码层。输出包含查询层级、升级原因、影响节点、行动分级、测试映射和未查询层级。`graph validate`、`graph impact`、代码查询和 `plan impact` 全部只读,不修改 YAML、计划、代码或 GitNexus 索引;失败也不能被解释为“无影响”。
289
+
290
+ GitNexus 只在需要代码定位时使用。CLI 不自动执行 `gitnexus analyze`,也不自动写回 UID 或 YAML;候选映射由 LLM 依据文件、符号、类型和测试等证据确认,证据不足或存在多个合理候选时再请求用户确认。
291
+
264
292
  `影响模块或文件` 的作用域匹配规则:
265
293
 
266
294
  - `--drift`、`--pre-commit` 和 `plan_governance_hook.py --event pre-write` 使用同一语义。
@@ -40,6 +40,7 @@
40
40
 
41
41
  待确认。若涉及 API、Schema、兼容性或迁移行为,需要在这里明确。
42
42
 
43
+ <!-- 机器识别的结构化章节标题必须保持固定名称,不要在标题前添加“阶段 1”等编号。 -->
43
44
  ## 阶段路线图
44
45
 
45
46
  | 阶段 | 目标 | 进入条件 | 验证方向 | 状态 |
@@ -52,6 +53,7 @@
52
53
 
53
54
  待补充。
54
55
 
56
+ <!-- 标题必须保持为“阶段准入摘要”,不要改成“阶段 1 准入摘要”等带编号变体;阶段身份以 PLAN_MAP.md 为准。 -->
55
57
  ### 阶段准入摘要
56
58
 
57
59
  | 字段 | 内容 |
@@ -116,6 +116,32 @@ def key_value_table(section):
116
116
  return values
117
117
 
118
118
 
119
+ def numbered_structural_heading(plan_text, heading_names):
120
+ """查找误把阶段编号写入结构化章节标题的常见形式。"""
121
+ phase_number = r"[0-9一二三四五六七八九十百]+"
122
+ variants = []
123
+ for name in heading_names:
124
+ variants.append(rf"阶段\s*{phase_number}\s+{re.escape(name)}")
125
+ if name.startswith("阶段"):
126
+ variants.append(rf"阶段\s*{phase_number}\s*{re.escape(name[2:])}")
127
+ pattern = re.compile(
128
+ rf"^#+\s+((?:{'|'.join(variants)}))\s*$",
129
+ re.MULTILINE,
130
+ )
131
+ match = pattern.search(mask_fenced_code(plan_text))
132
+ return match.group(1) if match else None
133
+
134
+
135
+ def structural_heading_hint(plan_text, heading_name):
136
+ numbered_heading = numbered_structural_heading(plan_text, [heading_name])
137
+ if not numbered_heading:
138
+ return ""
139
+ return (
140
+ f";检测到标题 `{numbered_heading}`,请改为固定标题 "
141
+ f"`{heading_name}`,阶段编号以 `PLAN_MAP.md` 的当前阶段为准"
142
+ )
143
+
144
+
119
145
  def phase_roadmap_rows(plan_text):
120
146
  section = markdown_section(plan_text, ["阶段路线图"])
121
147
  return [row for row in markdown_table_rows(section) if row and row[0] != "阶段"]
@@ -160,11 +186,12 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
160
186
  roadmap = phase_roadmap_rows(plan_text)
161
187
  matching_rows = [row for row in roadmap if row and row[0] == current_phase]
162
188
  if not matching_rows:
189
+ roadmap_hint = structural_heading_hint(plan_text, "阶段路线图")
163
190
  readiness_issue(
164
191
  warnings,
165
192
  errors,
166
193
  strict,
167
- f"{plan_name}: PLAN_MAP 当前阶段 {current_phase} 未在计划阶段路线图中找到",
194
+ f"{plan_name}: PLAN_MAP 当前阶段 {current_phase} 未在计划阶段路线图中找到{roadmap_hint}",
168
195
  )
169
196
  else:
170
197
  roadmap_status = matching_rows[-1][4] if len(matching_rows[-1]) > 4 else ""
@@ -177,16 +204,21 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
177
204
  )
178
205
 
179
206
  if markdown_section(plan_text, ["当前阶段"]) is None:
180
- readiness_issue(warnings, errors, strict, f"{plan_name}: 缺少 `## 当前阶段` 章节")
207
+ section_hint = structural_heading_hint(plan_text, "当前阶段")
208
+ readiness_issue(warnings, errors, strict, f"{plan_name}: 缺少 `## 当前阶段` 章节{section_hint}")
181
209
 
182
- summary = key_value_table(markdown_section(plan_text, ["阶段准入摘要"]))
210
+ summary_section = markdown_section(plan_text, ["阶段准入摘要"])
211
+ summary = key_value_table(summary_section)
183
212
  missing_fields = sorted(READINESS_FIELDS - set(summary))
184
213
  if missing_fields:
214
+ title_hint = ""
215
+ if summary_section is None:
216
+ title_hint = structural_heading_hint(plan_text, "阶段准入摘要")
185
217
  readiness_issue(
186
218
  warnings,
187
219
  errors,
188
220
  strict,
189
- f"{plan_name}: 阶段准入摘要缺少字段:{', '.join(missing_fields)}",
221
+ f"{plan_name}: 阶段准入摘要缺少字段:{', '.join(missing_fields)}{title_hint}",
190
222
  )
191
223
  for field in sorted(READINESS_FIELDS - {"当前阻塞项"}):
192
224
  if field in summary and is_placeholder(summary[field]):
@@ -215,11 +247,12 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
215
247
  review_fields = {"日期", "阶段", "结论", "证据", "复核者"}
216
248
  missing_review = sorted(review_fields - set(review))
217
249
  if missing_review:
250
+ review_hint = structural_heading_hint(plan_text, "最新独立准入复核")
218
251
  readiness_issue(
219
252
  warnings,
220
253
  errors,
221
254
  strict,
222
- f"{plan_name}: 最新独立准入复核缺少字段:{', '.join(missing_review)}",
255
+ f"{plan_name}: 最新独立准入复核缺少字段:{', '.join(missing_review)}{review_hint}",
223
256
  )
224
257
  if review.get("日期"):
225
258
  try:
@@ -243,7 +276,13 @@ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, war
243
276
 
244
277
  history = [row for row in review_history_rows(plan_text) if len(row) >= 4 and row[2] == current_phase]
245
278
  if not history:
246
- readiness_issue(warnings, errors, strict, f"{plan_name}: 独立复核记录缺少当前阶段 {current_phase} 的记录")
279
+ history_hint = structural_heading_hint(plan_text, "独立复核记录")
280
+ readiness_issue(
281
+ warnings,
282
+ errors,
283
+ strict,
284
+ f"{plan_name}: 独立复核记录缺少当前阶段 {current_phase} 的记录{history_hint}",
285
+ )
247
286
  else:
248
287
  latest_history = history[-1]
249
288
  if review.get("日期") and latest_history[0] != review["日期"]:
@@ -0,0 +1,988 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { isAbsolute, resolve } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { parse as parseYaml } from "yaml";
7
+
8
+ const RELATION_TYPES = new Set([
9
+ "contains",
10
+ "orchestrates",
11
+ "exposes",
12
+ "implements",
13
+ "consumes",
14
+ "depends_on",
15
+ ]);
16
+ const ARCHITECTURE_RELATION_TYPES = new Set([
17
+ "contains",
18
+ "exposes",
19
+ "crosses",
20
+ "anchors_to",
21
+ "realized_by",
22
+ ]);
23
+ const NODE_TYPES = new Set([
24
+ "business",
25
+ "function",
26
+ "process",
27
+ "api",
28
+ "external_workflow",
29
+ ]);
30
+ const ARCHITECTURE_NODE_TYPES = new Set([
31
+ "system",
32
+ "module",
33
+ "component",
34
+ "service",
35
+ "interface",
36
+ "data_boundary",
37
+ "trust_boundary",
38
+ ]);
39
+ const EVIDENCE_KINDS = new Set(["document", "code", "test", "api", "gitnexus"]);
40
+ const FORWARD_RELATIONS = new Set(["exposes", "implements"]);
41
+ const REVERSE_RELATIONS = new Set(["contains", "orchestrates", "consumes", "depends_on"]);
42
+ const PLAN_CHANGE_KINDS = new Set([
43
+ "behavior_change",
44
+ "api_contract_change",
45
+ "internal_refactor",
46
+ "data_migration",
47
+ "security_change",
48
+ ]);
49
+ const PLAN_UPGRADE_SIGNALS = new Set(["external_boundary"]);
50
+ const LEGACY_NON_FUNCTIONAL_PREFIXES = ["api.", "code.", "test.", "document."];
51
+
52
+ function usage() {
53
+ console.log(`用法:
54
+ plan-governance-cli graph validate [--layer functional|architecture] [root]
55
+ plan-governance-cli graph impact --from <graph-node-id> [--depth 2] [--format text|json] [root]
56
+ plan-governance-cli plan impact --input <plan-impact-request.json> [--format text|json] [root]
57
+ plan-governance-cli graph code candidates [--file <relative-path>] --symbol <symbol> --kind class|function [--format text|json] [root]
58
+ plan-governance-cli graph code impact --repo <gitnexus-repo> --file <relative-path> --symbol <symbol> --kind <kind> [--depth 3] [--format text|json] [root]
59
+
60
+ 功能图谱文件:<root>/docs/graph/functional.yaml
61
+ 架构图谱入口:<root>/docs/graph/architecture/index.yaml`);
62
+ }
63
+
64
+ function error(message) {
65
+ console.error(`graph: ERROR: ${message}`);
66
+ return 1;
67
+ }
68
+
69
+ function graphPath(root) {
70
+ return resolve(root, "docs", "graph", "functional.yaml");
71
+ }
72
+
73
+ function functionalIndexPath(root) {
74
+ return resolve(root, "docs", "graph", "functional", "index.yaml");
75
+ }
76
+
77
+ function architectureIndexPath(root) {
78
+ return resolve(root, "docs", "graph", "architecture", "index.yaml");
79
+ }
80
+
81
+ function readYamlFile(path, label) {
82
+ if (!existsSync(path)) throw new Error(`缺少${label}:${path}`);
83
+ try {
84
+ return parseYaml(readFileSync(path, "utf8"));
85
+ } catch (cause) {
86
+ throw new Error(`${label} YAML 解析失败:${cause.message}`);
87
+ }
88
+ }
89
+
90
+ function loadGraph(root) {
91
+ const indexPath = functionalIndexPath(root);
92
+ if (!existsSync(indexPath)) {
93
+ const path = graphPath(root);
94
+ const graph = readYamlFile(path, "图谱文件");
95
+ return { graph, path };
96
+ }
97
+ const index = readYamlFile(indexPath, "功能图谱入口");
98
+ const issues = [];
99
+ if (!index || typeof index !== "object" || Array.isArray(index)) throw new Error("功能图谱入口必须是 YAML 对象");
100
+ if (index.schema_version !== 1) issues.push(`功能图谱 schema_version 必须为 1,实际为 ${index.schema_version ?? "<missing>"}`);
101
+ if (index.layer !== "functional") issues.push(`功能图谱 layer 必须为 functional,实际为 ${index.layer ?? "<missing>"}`);
102
+ const files = asPathList(index.files, "功能图谱 files", issues);
103
+ if (issues.length > 0) throw new Error(issues.join(";"));
104
+ const documents = files.map((relativePath) => readYamlFile(resolve(indexPath, "..", relativePath), "功能图谱领域文件"));
105
+ const graph = mergeGraphDocuments(documents);
106
+ return {
107
+ graph: {
108
+ schema_version: 1,
109
+ project: index.project,
110
+ gitnexus: index.gitnexus,
111
+ nodes: graph.nodes,
112
+ relations: graph.relations,
113
+ code_refs: graph.codeRefs,
114
+ },
115
+ path: indexPath,
116
+ };
117
+ }
118
+
119
+ function asPathList(value, label, issues) {
120
+ if (!Array.isArray(value) || value.length === 0) {
121
+ issues.push(`${label} 必须是非空数组`);
122
+ return [];
123
+ }
124
+ return value.map((entry, index) => {
125
+ if (typeof entry === "string" && entry.length > 0) return entry;
126
+ if (entry && typeof entry.path === "string" && entry.path.length > 0) return entry.path;
127
+ issues.push(`${label}[${index}] 必须是路径字符串或包含 path 的对象`);
128
+ return null;
129
+ }).filter(Boolean);
130
+ }
131
+
132
+ function mergeGraphDocuments(documents) {
133
+ return documents.reduce((merged, document) => ({
134
+ nodes: [...merged.nodes, ...asArray(document.nodes)],
135
+ relations: [...merged.relations, ...asArray(document.relations)],
136
+ codeMappings: [...merged.codeMappings, ...asArray(document.code_mappings)],
137
+ }), { nodes: [], relations: [], codeMappings: [] });
138
+ }
139
+
140
+ function loadReferencedNodeIds(path, label) {
141
+ const document = readYamlFile(path, label);
142
+ const ids = new Set(asArray(document.nodes)
143
+ .map((node) => node?.id)
144
+ .filter((id) => typeof id === "string"));
145
+ if (document.files !== undefined) {
146
+ const issues = [];
147
+ const files = asPathList(document.files, `${label} files`, issues);
148
+ if (issues.length > 0) throw new Error(issues.join(";"));
149
+ files.forEach((relativePath) => {
150
+ const childPath = resolve(path, "..", relativePath);
151
+ loadReferencedNodeIds(childPath, `${label}领域文件`).forEach((id) => ids.add(id));
152
+ });
153
+ }
154
+ return ids;
155
+ }
156
+
157
+ function loadArchitectureGraph(root) {
158
+ const indexPath = architectureIndexPath(root);
159
+ const index = readYamlFile(indexPath, "架构图谱入口");
160
+ const indexIssues = [];
161
+ if (!index || typeof index !== "object" || Array.isArray(index)) {
162
+ throw new Error("架构图谱入口必须是 YAML 对象");
163
+ }
164
+ if (index.schema_version !== 1) indexIssues.push(`架构图谱 schema_version 必须为 1,实际为 ${index.schema_version ?? "<missing>"}`);
165
+ if (index.layer !== "architecture") indexIssues.push(`架构图谱 layer 必须为 architecture,实际为 ${index.layer ?? "<missing>"}`);
166
+ const files = asPathList(index.files, "架构图谱 files", indexIssues);
167
+ const documents = files.map((relativePath) => readYamlFile(resolve(indexPath, "..", relativePath), "架构图谱领域文件"));
168
+ const graph = mergeGraphDocuments(documents);
169
+ const externalIds = new Set();
170
+ const functionalReference = index.references?.functional;
171
+ if (functionalReference !== undefined) {
172
+ const referencePath = typeof functionalReference === "string"
173
+ ? functionalReference
174
+ : functionalReference?.path;
175
+ if (typeof referencePath !== "string" || referencePath.length === 0) {
176
+ indexIssues.push("架构图谱 references.functional 必须包含 path");
177
+ } else {
178
+ loadReferencedNodeIds(resolve(indexPath, "..", referencePath), "功能图谱引用")
179
+ .forEach((id) => externalIds.add(id));
180
+ }
181
+ }
182
+ if (indexIssues.length > 0) throw new Error(indexIssues.join(";"));
183
+ return {
184
+ graph: {
185
+ schema_version: 1,
186
+ nodes: graph.nodes,
187
+ relations: graph.relations,
188
+ code_mappings: graph.codeMappings,
189
+ },
190
+ path: indexPath,
191
+ externalIds,
192
+ };
193
+ }
194
+
195
+ function asArray(value) {
196
+ return Array.isArray(value) ? value : [];
197
+ }
198
+
199
+ function validateEvidence(evidence, label, root, issues) {
200
+ if (!Array.isArray(evidence) || evidence.length === 0) {
201
+ issues.push(`${label} 缺少 evidence`);
202
+ return;
203
+ }
204
+ evidence.forEach((item, index) => {
205
+ const prefix = `${label}.evidence[${index}]`;
206
+ if (!item || typeof item !== "object") {
207
+ issues.push(`${prefix} 必须是对象`);
208
+ return;
209
+ }
210
+ if (!EVIDENCE_KINDS.has(item.kind)) issues.push(`${prefix}.kind 不受支持:${item.kind ?? "<missing>"}`);
211
+ if (typeof item.ref !== "string" || item.ref.length === 0) issues.push(`${prefix}.ref 缺失`);
212
+ if (typeof item.locator !== "string" || item.locator.length === 0) issues.push(`${prefix}.locator 缺失`);
213
+ if (typeof item.ref === "string" && item.ref.length > 0 && item.kind !== "gitnexus") {
214
+ const target = isAbsolute(item.ref) ? item.ref : resolve(root, item.ref);
215
+ if (!existsSync(target)) issues.push(`${prefix}.ref 不存在:${item.ref}`);
216
+ }
217
+ });
218
+ }
219
+
220
+ function detectCycles(nodes, relations) {
221
+ const edges = new Map(nodes.map((node) => [node.id, []]));
222
+ relations.forEach((relation) => edges.get(relation.from)?.push(relation.to));
223
+ const visiting = new Set();
224
+ const visited = new Set();
225
+ const cycles = [];
226
+
227
+ function visit(node, path) {
228
+ if (visiting.has(node)) {
229
+ const start = path.indexOf(node);
230
+ cycles.push([...path.slice(start), node].join(" -> "));
231
+ return;
232
+ }
233
+ if (visited.has(node)) return;
234
+ visiting.add(node);
235
+ for (const next of edges.get(node) ?? []) visit(next, [...path, node]);
236
+ visiting.delete(node);
237
+ visited.add(node);
238
+ }
239
+
240
+ for (const node of edges.keys()) visit(node, []);
241
+ return cycles;
242
+ }
243
+
244
+ function normalizeGraph(raw, root, options = {}) {
245
+ const issues = [];
246
+ const nodeTypes = options.layer === "architecture" ? ARCHITECTURE_NODE_TYPES : NODE_TYPES;
247
+ const relationTypes = options.layer === "architecture" ? ARCHITECTURE_RELATION_TYPES : RELATION_TYPES;
248
+ const externalIds = options.externalIds ?? new Set();
249
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
250
+ return { issues: ["根文档必须是 YAML 对象"], nodes: [], relations: [] };
251
+ }
252
+ if (raw.schema_version !== 1) issues.push(`schema_version 必须为 1,实际为 ${raw.schema_version ?? "<missing>"}`);
253
+ if (raw.project !== undefined && (!raw.project || typeof raw.project.id !== "string" || typeof raw.project.name !== "string")) {
254
+ issues.push("project.id 和 project.name 必须同时存在且为字符串");
255
+ }
256
+ const nodes = asArray(raw.nodes);
257
+ const relations = asArray(raw.relations);
258
+ const codeMappings = asArray(raw.code_mappings);
259
+ if (nodes.length === 0) issues.push("nodes 不能为空");
260
+ if (!Array.isArray(raw.nodes)) issues.push("nodes 必须是数组");
261
+ if (!Array.isArray(raw.relations)) issues.push("relations 必须是数组");
262
+
263
+ const ids = new Set();
264
+ nodes.forEach((node, index) => {
265
+ const label = `nodes[${index}]`;
266
+ if (!node || typeof node !== "object") {
267
+ issues.push(`${label} 必须是对象`);
268
+ return;
269
+ }
270
+ if (typeof node.id !== "string" || node.id.length === 0) issues.push(`${label}.id 缺失`);
271
+ else if (!/^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/.test(node.id)) issues.push(`${label}.id 必须是由 . 分隔、段内仅含小写字母数字和连字符的标识`);
272
+ else if (ids.has(node.id)) issues.push(`节点 ID 重复:${node.id}`);
273
+ else ids.add(node.id);
274
+ if (!nodeTypes.has(node.type)) issues.push(`${label}.type 不受支持:${node.type ?? "<missing>"}`);
275
+ if (typeof node.name !== "string" || node.name.length === 0) issues.push(`${label}.name 缺失`);
276
+ validateEvidence(node.evidence, label, root, issues);
277
+ asArray(node.code_refs).forEach((ref, refIndex) => {
278
+ const prefix = `${label}.code_refs[${refIndex}]`;
279
+ if (!ref || typeof ref !== "object") issues.push(`${prefix} 必须是对象`);
280
+ else {
281
+ if (ref.kind !== "gitnexus_uid") issues.push(`${prefix}.kind 必须为 gitnexus_uid`);
282
+ if (typeof ref.ref !== "string" || ref.ref.length === 0) issues.push(`${prefix}.ref 缺失`);
283
+ if (typeof ref.fallback !== "string" || ref.fallback.length === 0) issues.push(`${prefix}.fallback 缺失`);
284
+ const fallbackIsRoute = typeof ref.fallback === "string" && ref.fallback.startsWith("/");
285
+ if (typeof ref.fallback === "string" && !fallbackIsRoute && !existsSync(resolve(root, ref.fallback))) {
286
+ issues.push(`${prefix}.fallback 不存在:${ref.fallback}`);
287
+ }
288
+ }
289
+ });
290
+ });
291
+
292
+ const seenRelations = new Set();
293
+ relations.forEach((relation, index) => {
294
+ const label = `relations[${index}]`;
295
+ if (!relation || typeof relation !== "object") {
296
+ issues.push(`${label} 必须是对象`);
297
+ return;
298
+ }
299
+ const externalFrom = externalIds.has(relation.from);
300
+ const externalTo = externalIds.has(relation.to);
301
+ const allowsExternalFrom = options.layer === "architecture" && relation.type === "realized_by";
302
+ if (!ids.has(relation.from) && !(allowsExternalFrom && externalFrom)) issues.push(`${label}.from 指向悬空节点:${relation.from ?? "<missing>"}`);
303
+ if (!ids.has(relation.to)) issues.push(`${label}.to 指向悬空节点:${relation.to ?? "<missing>"}`);
304
+ if (!relationTypes.has(relation.type)) issues.push(`${label}.type 不受支持:${relation.type ?? "<missing>"}`);
305
+ if (options.layer === "architecture" && relation.type === "realized_by" && !externalFrom) {
306
+ issues.push(`${label}.realized_by.from 必须指向功能图谱引用中的节点:${relation.from ?? "<missing>"}`);
307
+ }
308
+ if (options.layer === "architecture" && relation.type === "realized_by" && externalTo) {
309
+ issues.push(`${label}.realized_by.to 必须指向架构层节点:${relation.to}`);
310
+ }
311
+ if (relation.from === relation.to) issues.push(`${label} 不允许自环:${relation.from}`);
312
+ const key = `${relation.from}|${relation.type}|${relation.to}`;
313
+ if (seenRelations.has(key)) issues.push(`关系重复:${key}`);
314
+ seenRelations.add(key);
315
+ validateEvidence(relation.evidence, label, root, issues);
316
+ });
317
+
318
+ if (options.layer === "architecture") {
319
+ if (raw.code_mappings !== undefined && !Array.isArray(raw.code_mappings)) {
320
+ issues.push("code_mappings 必须是数组");
321
+ }
322
+ const seenCodeAnchors = new Set();
323
+ codeMappings.forEach((mapping, index) => {
324
+ const label = `code_mappings[${index}]`;
325
+ if (!mapping || typeof mapping !== "object") {
326
+ issues.push(`${label} 必须是对象`);
327
+ return;
328
+ }
329
+ if (typeof mapping.architecture_id !== "string" || mapping.architecture_id.length === 0) {
330
+ issues.push(`${label}.architecture_id 缺失`);
331
+ } else if (!ids.has(mapping.architecture_id)) {
332
+ issues.push(`${label}.architecture_id 指向悬空架构节点:${mapping.architecture_id}`);
333
+ }
334
+ const anchor = mapping.code_anchor;
335
+ if (!anchor || typeof anchor !== "object") {
336
+ issues.push(`${label}.code_anchor 必须是对象`);
337
+ return;
338
+ }
339
+ if (typeof anchor.file !== "string" || anchor.file.length === 0) {
340
+ issues.push(`${label}.code_anchor.file 缺失`);
341
+ } else {
342
+ const absoluteFile = isAbsolute(anchor.file) ? anchor.file : resolve(root, anchor.file);
343
+ if (!existsSync(absoluteFile)) issues.push(`${label}.code_anchor.file 不存在:${anchor.file}`);
344
+ }
345
+ if (typeof anchor.symbol !== "string" || anchor.symbol.length === 0) issues.push(`${label}.code_anchor.symbol 缺失`);
346
+ if (typeof anchor.kind !== "string" || anchor.kind.length === 0) issues.push(`${label}.code_anchor.kind 缺失`);
347
+ if (anchor.gitnexus_uid !== undefined && (typeof anchor.gitnexus_uid !== "string" || anchor.gitnexus_uid.length === 0)) {
348
+ issues.push(`${label}.code_anchor.gitnexus_uid 必须是非空字符串`);
349
+ }
350
+ if (typeof mapping.architecture_id === "string" && typeof anchor.file === "string"
351
+ && typeof anchor.symbol === "string" && typeof anchor.kind === "string") {
352
+ const key = `${mapping.architecture_id}|${anchor.file}|${anchor.symbol}|${anchor.kind}`;
353
+ if (seenCodeAnchors.has(key)) issues.push(`代码锚点重复:${key}`);
354
+ seenCodeAnchors.add(key);
355
+ }
356
+ });
357
+ }
358
+
359
+ return { issues, nodes, relations, codeMappings, meta: raw };
360
+ }
361
+
362
+ function validate(root, layer = "functional") {
363
+ const loaded = layer === "architecture" ? loadArchitectureGraph(root) : loadGraph(root);
364
+ const result = normalizeGraph(loaded.graph, root, { layer, externalIds: loaded.externalIds });
365
+ if (result.issues.length > 0) {
366
+ result.issues.forEach((issue) => console.error(`graph: ERROR: ${issue}`));
367
+ return 1;
368
+ }
369
+ const warnings = [];
370
+ const gitnexusRefs = result.nodes.flatMap((node) => asArray(node.code_refs)
371
+ .filter((ref) => ref.kind === "gitnexus_uid")
372
+ .map((ref) => ({ node, ref })));
373
+ const gitnexusRepo = result.meta.gitnexus?.repo;
374
+ if (gitnexusRefs.length > 0 && existsSync(resolve(root, ".gitnexus")) && gitnexusRepo) {
375
+ for (const { node, ref } of gitnexusRefs) {
376
+ const check = spawnSync("gitnexus", ["context", "-r", gitnexusRepo, "--uid", ref.ref], {
377
+ encoding: "utf8",
378
+ stdio: ["ignore", "pipe", "pipe"],
379
+ });
380
+ if (check.error?.code === "ENOENT") {
381
+ warnings.push("GitNexus CLI 不在 PATH 中,已跳过 UID 实体验证;保留 code_refs.fallback");
382
+ break;
383
+ }
384
+ let contextResult;
385
+ try {
386
+ contextResult = JSON.parse(check.stdout ?? "");
387
+ } catch {
388
+ contextResult = null;
389
+ }
390
+ if (check.status !== 0 || !contextResult || contextResult.status !== "found") {
391
+ const detail = contextResult?.error ?? (check.stderr || `退出码 ${check.status}`);
392
+ result.issues.push(`${node.id}.code_refs GitNexus UID 失配:${ref.ref};回退:${ref.fallback}`);
393
+ if (detail) result.issues.push(`${node.id}.code_refs GitNexus 响应:${String(detail).trim()}`);
394
+ }
395
+ }
396
+ } else if (gitnexusRefs.length > 0) {
397
+ warnings.push("当前项目没有可用的 .gitnexus 索引,GitNexus UID 标记为待解析;将使用 fallback");
398
+ }
399
+ if (result.issues.length > 0) {
400
+ result.issues.forEach((issue) => console.error(`graph: ERROR: ${issue}`));
401
+ return 1;
402
+ }
403
+ console.log(`${layer === "architecture" ? "架构图谱" : "图谱"}校验通过:${loaded.path}`);
404
+ warnings.forEach((warning) => console.log(`graph: WARNING: ${warning}`));
405
+ const mappingText = layer === "architecture" ? `,代码锚点 ${result.codeMappings.length} 个` : "";
406
+ console.log(`schema_version=1,节点 ${result.nodes.length} 个,关系 ${result.relations.length} 条,GitNexus 引用 ${gitnexusRefs.length} 个${mappingText}`);
407
+ return 0;
408
+ }
409
+
410
+ function parseValidateArgs(args) {
411
+ const options = { layer: "functional", root: process.cwd() };
412
+ const positional = [];
413
+ for (let index = 0; index < args.length; index += 1) {
414
+ const arg = args[index];
415
+ if (arg === "--layer") options.layer = args[++index];
416
+ else if (arg === "-h" || arg === "--help") return null;
417
+ else positional.push(arg);
418
+ }
419
+ if (!["functional", "architecture"].includes(options.layer)) {
420
+ throw new Error("validate --layer 只能是 functional 或 architecture");
421
+ }
422
+ if (positional.length > 1) throw new Error("validate 只能接收一个 root 路径");
423
+ if (positional.length === 1) options.root = resolve(positional[0]);
424
+ return options;
425
+ }
426
+
427
+ function parseImpactArgs(args) {
428
+ const options = { from: null, depth: 2, format: "text", root: process.cwd() };
429
+ const positional = [];
430
+ for (let index = 0; index < args.length; index += 1) {
431
+ const arg = args[index];
432
+ if (arg === "--from") options.from = args[++index];
433
+ else if (arg === "--depth") options.depth = Number(args[++index]);
434
+ else if (arg === "--format") options.format = args[++index];
435
+ else if (arg === "-h" || arg === "--help") return null;
436
+ else positional.push(arg);
437
+ }
438
+ if (positional.length > 0) options.root = resolve(positional.at(-1));
439
+ if (!options.from) throw new Error("impact 必须指定 --from <graph-node-id>");
440
+ if (!Number.isInteger(options.depth) || options.depth < 1 || options.depth > 10) throw new Error("--depth 必须是 1 到 10 的整数");
441
+ if (!["text", "json"].includes(options.format)) throw new Error("--format 只能是 text 或 json");
442
+ return options;
443
+ }
444
+
445
+ function parsePlanImpactArgs(args) {
446
+ const options = { input: null, format: "text", root: process.cwd() };
447
+ const positional = [];
448
+ for (let index = 0; index < args.length; index += 1) {
449
+ const arg = args[index];
450
+ if (arg === "--input") options.input = args[++index];
451
+ else if (arg === "--format") options.format = args[++index];
452
+ else if (arg === "-h" || arg === "--help") return null;
453
+ else positional.push(arg);
454
+ }
455
+ if (positional.length > 1) throw new Error("plan impact 只能接收一个 root 路径");
456
+ if (positional.length === 1) options.root = resolve(positional[0]);
457
+ if (!options.input) throw new Error("plan impact 必须指定 --input <plan-impact-request.json>");
458
+ if (!options.format || !["text", "json"].includes(options.format)) throw new Error("--format 只能是 text 或 json");
459
+ if (!existsSync(options.root)) throw new Error(`root 不存在:${options.root}`);
460
+ return options;
461
+ }
462
+
463
+ function loadPlanImpactRequest(root, input) {
464
+ const path = isAbsolute(input) ? input : resolve(root, input);
465
+ const request = readYamlFile(path, "计划影响输入");
466
+ if (!request || typeof request !== "object" || Array.isArray(request)) throw new Error("计划影响输入必须是 JSON/YAML 对象");
467
+ if (typeof request.graph_scope !== "string" || !request.graph_scope.startsWith("feature.")) {
468
+ throw new Error("计划影响输入 graph_scope 必须是功能节点");
469
+ }
470
+ if (!PLAN_CHANGE_KINDS.has(request.change_kind)) {
471
+ throw new Error(`计划影响输入 change_kind 不受支持:${request.change_kind ?? "<missing>"}`);
472
+ }
473
+ const upgradeSignals = request.upgrade_signals ?? [];
474
+ if (!Array.isArray(upgradeSignals) || upgradeSignals.some((signal) => !PLAN_UPGRADE_SIGNALS.has(signal))) {
475
+ throw new Error("计划影响输入 upgrade_signals 只支持 external_boundary");
476
+ }
477
+ if (request.code_locator_requested !== undefined && typeof request.code_locator_requested !== "boolean") {
478
+ throw new Error("计划影响输入 code_locator_requested 必须是布尔值");
479
+ }
480
+ if (request.code_locator_requested) {
481
+ const anchor = request.code_anchor;
482
+ if (!anchor || typeof anchor !== "object") throw new Error("code_locator_requested=true 时必须提供 code_anchor");
483
+ for (const field of ["file", "symbol", "kind"]) {
484
+ if (typeof anchor[field] !== "string" || anchor[field].length === 0) throw new Error(`code_anchor.${field} 缺失`);
485
+ }
486
+ } else if (request.code_anchor !== undefined) {
487
+ throw new Error("未请求代码定位时不得提供 code_anchor");
488
+ }
489
+ return { request, path };
490
+ }
491
+
492
+ const CODE_FILE_GLOBS = [
493
+ "*.c", "*.cc", "*.cpp", "*.cs", "*.go", "*.h", "*.java", "*.js", "*.jsx",
494
+ "*.kt", "*.m", "*.mm", "*.mjs", "*.php", "*.py", "*.rb", "*.rs", "*.swift",
495
+ "*.ts", "*.tsx",
496
+ ];
497
+
498
+ function codeCandidatePattern(kind, symbol) {
499
+ const escaped = symbol.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
500
+ const modifiers = "(?:(?:public|private|fileprivate|internal|open|final|static|abstract|sealed|override|async)\\s+)*";
501
+ if (kind === "class") return `^\\s*${modifiers}(?:class|struct|enum|actor|protocol)\\s+${escaped}\\b`;
502
+ if (kind === "function") return `^\\s*${modifiers}(?:func|def|function)\\s+${escaped}\\s*\\(`;
503
+ throw new Error("--kind 首期只能是 class 或 function");
504
+ }
505
+
506
+ function listCodeFiles(root) {
507
+ const args = ["--files", "--hidden", "-g", "!.git", "-g", "!node_modules", "-g", "!.build", "-g", "!dist"];
508
+ CODE_FILE_GLOBS.forEach((glob) => args.push("-g", glob));
509
+ const result = spawnSync("rg", args, { cwd: root, encoding: "utf8" });
510
+ if (result.error?.code === "ENOENT") throw new Error("候选报告需要 rg,但当前 PATH 中找不到 rg");
511
+ if (result.status === 1) return [];
512
+ if (result.status !== 0) throw new Error(`候选报告无法列出代码文件:${(result.stderr || `退出码 ${result.status}`).trim()}`);
513
+ return (result.stdout ?? "").split("\n").filter(Boolean);
514
+ }
515
+
516
+ function runCodeCandidateSearch(root, options) {
517
+ let files;
518
+ if (options.file && existsSync(resolve(root, options.file))) {
519
+ files = [options.file];
520
+ } else {
521
+ files = listCodeFiles(root);
522
+ }
523
+ if (files.length === 0) return [];
524
+
525
+ const args = ["--with-filename", "--no-heading", "--color", "never", "-n", "-e", codeCandidatePattern(options.kind, options.symbol), "--", ...files];
526
+ const result = spawnSync("rg", args, { cwd: root, encoding: "utf8" });
527
+ if (result.error?.code === "ENOENT") throw new Error("候选报告需要 rg,但当前 PATH 中找不到 rg");
528
+ if (result.status > 1) throw new Error(`候选报告搜索失败:${(result.stderr || `退出码 ${result.status}`).trim()}`);
529
+ return (result.stdout ?? "").split("\n").filter(Boolean).map((line) => {
530
+ const firstColon = line.indexOf(":");
531
+ const secondColon = line.indexOf(":", firstColon + 1);
532
+ return {
533
+ file: line.slice(0, firstColon),
534
+ line: Number(line.slice(firstColon + 1, secondColon)),
535
+ symbol: options.symbol,
536
+ kind: options.kind,
537
+ };
538
+ }).sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line);
539
+ }
540
+
541
+ function parseCodeCandidatesArgs(args) {
542
+ const options = { file: null, symbol: null, kind: null, format: "text", root: process.cwd() };
543
+ const positional = [];
544
+ for (let index = 0; index < args.length; index += 1) {
545
+ const arg = args[index];
546
+ if (arg === "--file") options.file = args[++index];
547
+ else if (arg === "--symbol") options.symbol = args[++index];
548
+ else if (arg === "--kind") options.kind = args[++index];
549
+ else if (arg === "--format") options.format = args[++index];
550
+ else if (arg === "-h" || arg === "--help") return null;
551
+ else positional.push(arg);
552
+ }
553
+ if (positional.length > 1) throw new Error("code candidates 只能接收一个 root 路径");
554
+ if (positional.length === 1) options.root = resolve(positional[0]);
555
+ if (!options.symbol) throw new Error("code candidates 必须指定 --symbol <symbol>");
556
+ if (!options.kind) throw new Error("code candidates 必须指定 --kind <kind>");
557
+ if (!options.file && options.file !== null) throw new Error("--file 必须指定路径");
558
+ if (!options.format || !["text", "json"].includes(options.format)) throw new Error("--format 只能是 text 或 json");
559
+ if (!existsSync(options.root)) throw new Error(`root 不存在:${options.root}`);
560
+ return options;
561
+ }
562
+
563
+ function codeCandidates(root, options) {
564
+ const candidates = runCodeCandidateSearch(root, options);
565
+ const resolution = candidates.length === 1
566
+ ? "unique_candidate"
567
+ : candidates.length > 1 ? "ask_user" : "no_candidate";
568
+ return {
569
+ schema_version: 1,
570
+ query: { file: options.file, symbol: options.symbol, kind: options.kind },
571
+ resolution,
572
+ candidates,
573
+ };
574
+ }
575
+
576
+ function printCodeCandidatesText(result) {
577
+ console.log(`代码候选报告:${result.query.symbol}(${result.query.kind})`);
578
+ console.log(`目标文件:${result.query.file ?? "未限定"}`);
579
+ console.log(`resolution: ${result.resolution}`);
580
+ if (result.candidates.length === 0) {
581
+ console.log("候选:\n- 无");
582
+ return;
583
+ }
584
+ console.log("候选:");
585
+ result.candidates.forEach((candidate) => {
586
+ console.log(`- ${candidate.file}:${candidate.line} ${candidate.symbol}(${candidate.kind})`);
587
+ });
588
+ }
589
+
590
+ function parseCodeImpactArgs(args) {
591
+ const options = { repo: null, file: null, symbol: null, kind: null, depth: 3, format: "text", root: process.cwd() };
592
+ const positional = [];
593
+ for (let index = 0; index < args.length; index += 1) {
594
+ const arg = args[index];
595
+ if (arg === "--repo") options.repo = args[++index];
596
+ else if (arg === "--file") options.file = args[++index];
597
+ else if (arg === "--symbol") options.symbol = args[++index];
598
+ else if (arg === "--kind") options.kind = args[++index];
599
+ else if (arg === "--depth") options.depth = Number(args[++index]);
600
+ else if (arg === "--format") options.format = args[++index];
601
+ else if (arg === "-h" || arg === "--help") return null;
602
+ else positional.push(arg);
603
+ }
604
+ if (positional.length > 1) throw new Error("code impact 只能接收一个 root 路径");
605
+ if (positional.length === 1) options.root = resolve(positional[0]);
606
+ ["repo", "file", "symbol", "kind"].forEach((field) => {
607
+ if (!options[field]) throw new Error(`code impact 必须指定 --${field} <value>`);
608
+ });
609
+ if (!Number.isInteger(options.depth) || options.depth < 1 || options.depth > 10) throw new Error("--depth 必须是 1 到 10 的整数");
610
+ if (!options.format || !["text", "json"].includes(options.format)) throw new Error("--format 只能是 text 或 json");
611
+ if (!existsSync(options.root)) throw new Error(`root 不存在:${options.root}`);
612
+ return options;
613
+ }
614
+
615
+ function gitnexusKind(kind) {
616
+ return kind.length > 0 ? `${kind[0].toUpperCase()}${kind.slice(1)}` : kind;
617
+ }
618
+
619
+ function codeImpact(root, options) {
620
+ const args = [
621
+ "impact",
622
+ "--repo", options.repo,
623
+ "--file", options.file,
624
+ "--kind", gitnexusKind(options.kind),
625
+ "--depth", String(options.depth),
626
+ options.symbol,
627
+ ];
628
+ const result = spawnSync("gitnexus", args, { cwd: root, encoding: "utf8" });
629
+ if (result.error?.code === "ENOENT") throw new Error("代码级影响查询需要 GitNexus CLI,但当前 PATH 中找不到 gitnexus");
630
+ if (result.status !== 0) {
631
+ const detail = (result.stderr || result.stdout || `退出码 ${result.status}`).trim();
632
+ throw new Error(`GitNexus 代码级影响查询失败:${detail}`);
633
+ }
634
+ let gitnexusResult;
635
+ try {
636
+ gitnexusResult = JSON.parse(result.stdout ?? "");
637
+ } catch (cause) {
638
+ throw new Error(`GitNexus 代码级影响查询返回了无法解析的结果:${cause.message}`);
639
+ }
640
+ if (gitnexusResult?.error) {
641
+ throw new Error(`GitNexus 代码级影响查询失败:${String(gitnexusResult.error)}`);
642
+ }
643
+ return {
644
+ schema_version: 1,
645
+ query: {
646
+ repo: options.repo,
647
+ file: options.file,
648
+ symbol: options.symbol,
649
+ kind: options.kind,
650
+ depth: options.depth,
651
+ },
652
+ source: "gitnexus",
653
+ result: gitnexusResult,
654
+ };
655
+ }
656
+
657
+ function printCodeImpactText(report) {
658
+ const result = report.result;
659
+ console.log(`代码影响分析:${report.query.file} / ${report.query.symbol}(${report.query.kind})`);
660
+ console.log(`GitNexus repo:${report.query.repo},最大 ${report.query.depth} 跳`);
661
+ console.log(`风险:${result.risk ?? "未知"};影响符号:${result.impactedCount ?? "未知"}`);
662
+ if (result.summary) {
663
+ console.log(`直接:${result.summary.direct ?? 0};间接:${result.summary.indirect ?? 0};流程:${result.summary.processes_affected ?? 0};模块:${result.summary.modules_affected ?? 0}`);
664
+ }
665
+ const processes = (result.affected_processes ?? []).map((item) => item.name).join("、") || "无";
666
+ const modules = (result.affected_modules ?? []).map((item) => item.name).join("、") || "无";
667
+ console.log(`受影响流程:${processes}`);
668
+ console.log(`受影响模块:${modules}`);
669
+ }
670
+
671
+ function impact(root, options) {
672
+ const loaded = loadGraph(root);
673
+ const graph = normalizeGraph(loaded.graph, root);
674
+ if (graph.issues.length > 0) throw new Error(`图谱校验失败,无法分析:${graph.issues.join(";")}`);
675
+ const byId = new Map(graph.nodes.map((node) => [node.id, node]));
676
+ if (!byId.has(options.from)) throw new Error(`未知图谱节点:${options.from}`);
677
+ const edges = new Map(graph.nodes.map((node) => [node.id, []]));
678
+ for (const relation of graph.relations) {
679
+ if (options.relationFilter && !options.relationFilter(relation)) continue;
680
+ if (FORWARD_RELATIONS.has(relation.type)) edges.get(relation.from)?.push({ relation, target: relation.to });
681
+ if (REVERSE_RELATIONS.has(relation.type)) edges.get(relation.to)?.push({ relation, target: relation.from });
682
+ }
683
+
684
+ const queue = [{ id: options.from, depth: 0, path: [] }];
685
+ const bestDepth = new Map([[options.from, 0]]);
686
+ const results = [];
687
+ while (queue.length) {
688
+ const current = queue.shift();
689
+ if (current.depth >= options.depth) continue;
690
+ for (const edge of edges.get(current.id) ?? []) {
691
+ if (options.nodeFilter && !options.nodeFilter(byId.get(edge.target), edge.target)) continue;
692
+ const nextDepth = current.depth + 1;
693
+ const nextPath = [...current.path, {
694
+ from: current.id,
695
+ type: edge.relation.type,
696
+ to: edge.target,
697
+ evidence: edge.relation.evidence,
698
+ }];
699
+ if (bestDepth.has(edge.target) && bestDepth.get(edge.target) <= nextDepth) continue;
700
+ bestDepth.set(edge.target, nextDepth);
701
+ queue.push({ id: edge.target, depth: nextDepth, path: nextPath });
702
+ results.push({ id: edge.target, depth: nextDepth, node: byId.get(edge.target), evidence: byId.get(edge.target).evidence, path: nextPath });
703
+ }
704
+ }
705
+
706
+ return {
707
+ schema_version: 1,
708
+ source: { id: options.from, node: byId.get(options.from) },
709
+ depth: options.depth,
710
+ direct: results.filter((item) => item.depth === 1),
711
+ indirect: results.filter((item) => item.depth > 1),
712
+ };
713
+ }
714
+
715
+ function isFunctionalImpactNode(item) {
716
+ if (!item?.node || !functionalNodeId(item.id)) return false;
717
+ return ["business", "function", "process", "external_workflow"].includes(item.node.type);
718
+ }
719
+
720
+ function functionalNodeId(id) {
721
+ return typeof id === "string" && !LEGACY_NON_FUNCTIONAL_PREFIXES.some((prefix) => id.startsWith(prefix));
722
+ }
723
+
724
+ function filterFunctionalImpact(result) {
725
+ return {
726
+ direct: result.direct.filter(isFunctionalImpactNode),
727
+ indirect: result.indirect.filter(isFunctionalImpactNode),
728
+ };
729
+ }
730
+
731
+ function planLayerDecision(request) {
732
+ const queriedLayers = ["functional"];
733
+ const upgradeReasons = {};
734
+ const architectureRequired = ["api_contract_change", "data_migration", "security_change"].includes(request.change_kind)
735
+ || (request.upgrade_signals ?? []).includes("external_boundary");
736
+ if (architectureRequired) {
737
+ queriedLayers.push("architecture");
738
+ upgradeReasons.architecture = request.change_kind === "behavior_change"
739
+ ? "upgrade_signal=external_boundary"
740
+ : `change_kind=${request.change_kind}`;
741
+ }
742
+ if (request.code_locator_requested === true) {
743
+ queriedLayers.push("code");
744
+ upgradeReasons.code = "code_locator_requested=true";
745
+ }
746
+ const unqueriedLayers = {};
747
+ if (!queriedLayers.includes("architecture")) unqueriedLayers.architecture = "未命中架构升级信号";
748
+ if (!queriedLayers.includes("code")) unqueriedLayers.code = "未要求具体代码定位";
749
+ return { queriedLayers, upgradeReasons, unqueriedLayers };
750
+ }
751
+
752
+ function architectureImpact(root, request) {
753
+ const loaded = loadArchitectureGraph(root);
754
+ const graph = normalizeGraph(loaded.graph, root, { layer: "architecture", externalIds: loaded.externalIds });
755
+ if (graph.issues.length > 0) throw new Error(`架构图谱校验失败,无法分析:${graph.issues.join(";")}`);
756
+ const byId = new Map(graph.nodes.map((node) => [node.id, node]));
757
+ const mappingRelations = graph.relations.filter((relation) => relation.type === "realized_by");
758
+ const seedRelations = mappingRelations.filter((relation) => relation.from === request.graph_scope);
759
+ if (seedRelations.length === 0) throw new Error(`功能节点没有可验证的架构映射:${request.graph_scope}`);
760
+
761
+ const selected = new Set(seedRelations.map((relation) => relation.to));
762
+ const selectedRelations = [...seedRelations];
763
+ if (request.change_kind === "api_contract_change") {
764
+ graph.relations
765
+ .filter((relation) => relation.type === "exposes" && selected.has(relation.from))
766
+ .forEach((relation) => {
767
+ selected.add(relation.to);
768
+ selectedRelations.push(relation);
769
+ });
770
+ }
771
+ if (["data_migration", "security_change"].includes(request.change_kind)) {
772
+ graph.relations
773
+ .filter((relation) => relation.type === "crosses" && (selected.has(relation.from) || selected.has(relation.to)))
774
+ .forEach((relation) => {
775
+ selected.add(relation.from);
776
+ selected.add(relation.to);
777
+ selectedRelations.push(relation);
778
+ });
779
+ }
780
+
781
+ return {
782
+ nodes: [...selected].sort().map((id) => ({
783
+ id,
784
+ node: byId.get(id),
785
+ evidence: byId.get(id)?.evidence ?? [],
786
+ paths: selectedRelations
787
+ .filter((relation) => relation.to === id || relation.from === id)
788
+ .map((relation) => ({
789
+ from: relation.from,
790
+ type: relation.type,
791
+ to: relation.to,
792
+ evidence: relation.evidence,
793
+ })),
794
+ })),
795
+ };
796
+ }
797
+
798
+ function uniqueEvidence(items) {
799
+ const seen = new Set();
800
+ return items.flatMap((item) => item?.evidence ?? [])
801
+ .filter((evidence) => evidence.kind === "test")
802
+ .filter((evidence) => {
803
+ const key = `${evidence.ref}#${evidence.locator}`;
804
+ if (seen.has(key)) return false;
805
+ seen.add(key);
806
+ return true;
807
+ });
808
+ }
809
+
810
+ function planImpact(root, options) {
811
+ const { request, path: inputPath } = loadPlanImpactRequest(root, options.input);
812
+ const decision = planLayerDecision(request);
813
+ const loaded = loadGraph(root);
814
+ const graph = normalizeGraph(loaded.graph, root);
815
+ if (graph.issues.length > 0) throw new Error(`功能图谱校验失败,无法分析:${graph.issues.join(";")}`);
816
+ const sourceNode = graph.nodes.find((node) => node.id === request.graph_scope);
817
+ if (!sourceNode) throw new Error(`未知功能图谱节点:${request.graph_scope}`);
818
+ const legacyImpact = impact(root, {
819
+ from: request.graph_scope,
820
+ depth: 2,
821
+ nodeFilter: (node, id) => functionalNodeId(id) && ["business", "function", "process", "external_workflow"].includes(node?.type),
822
+ relationFilter: (relation) => relation.type !== "exposes"
823
+ && (relation.type !== "implements" || functionalNodeId(relation.to)),
824
+ });
825
+ const functionalImpact = filterFunctionalImpact(legacyImpact);
826
+ const testImpact = impact(root, {
827
+ from: request.graph_scope,
828
+ depth: 2,
829
+ nodeFilter: (node, id) => (functionalNodeId(id)
830
+ && ["business", "function", "process", "external_workflow"].includes(node?.type)) || id.startsWith("test."),
831
+ relationFilter: (relation) => relation.type !== "exposes"
832
+ && (relation.type !== "implements" || functionalNodeId(relation.to) || relation.to.startsWith("test.")),
833
+ });
834
+ const architecture = decision.queriedLayers.includes("architecture")
835
+ ? architectureImpact(root, request)
836
+ : null;
837
+
838
+ let code = null;
839
+ if (decision.queriedLayers.includes("code")) {
840
+ const candidate = codeCandidates(root, {
841
+ file: request.code_anchor.file,
842
+ symbol: request.code_anchor.symbol,
843
+ kind: request.code_anchor.kind,
844
+ });
845
+ if (candidate.resolution !== "unique_candidate") {
846
+ throw new Error(`代码定位未唯一确认:resolution=${candidate.resolution};请使用 graph code candidates 获取候选并上升确认`);
847
+ }
848
+ code = candidate;
849
+ }
850
+
851
+ const testEvidence = uniqueEvidence([
852
+ ...testImpact.direct,
853
+ ...testImpact.indirect,
854
+ ...(architecture?.nodes ?? []),
855
+ ]);
856
+ const actions = ["必须评估", testEvidence.length > 0 ? "必须测试" : "建议检查"];
857
+ return {
858
+ schema_version: 1,
859
+ source: {
860
+ input: inputPath,
861
+ graph_scope: request.graph_scope,
862
+ change_kind: request.change_kind,
863
+ node: sourceNode,
864
+ },
865
+ queried_layers: decision.queriedLayers,
866
+ upgrade_reasons: decision.upgradeReasons,
867
+ unqueried_layers: decision.unqueriedLayers,
868
+ functional: functionalImpact,
869
+ architecture,
870
+ code,
871
+ actions,
872
+ tests: testEvidence.length > 0
873
+ ? { status: "available", evidence: testEvidence }
874
+ : { status: "unavailable", message: "无可用测试映射", evidence: [] },
875
+ writes: false,
876
+ analyze_triggered: false,
877
+ };
878
+ }
879
+
880
+ function printPlanImpactText(result) {
881
+ console.log(`计划前置影响分析:${result.source.graph_scope}(${result.source.change_kind})`);
882
+ console.log(`查询层级:${result.queried_layers.join(" → ")}`);
883
+ console.log(`升级原因:${Object.entries(result.upgrade_reasons).map(([layer, reason]) => `${layer}: ${reason}`).join(";") || "无"}`);
884
+ console.log("功能影响:");
885
+ for (const item of [...result.functional.direct, ...result.functional.indirect]) {
886
+ console.log(`- ${item.id}:${item.node.name}`);
887
+ }
888
+ if (result.architecture) {
889
+ console.log("架构影响:");
890
+ for (const item of result.architecture.nodes) console.log(`- ${item.id}:${item.node.name}`);
891
+ }
892
+ if (result.code) console.log(`代码定位:${result.code.resolution};${result.code.candidates[0].file}:${result.code.candidates[0].line}`);
893
+ console.log(`行动建议:${result.actions.join("、")}`);
894
+ console.log(`测试映射:${result.tests.status === "available" ? result.tests.evidence.map((item) => item.ref).join("、") : result.tests.message}`);
895
+ console.log(`未查询层级:${Object.entries(result.unqueried_layers).map(([layer, reason]) => `${layer}(${reason})`).join(";") || "无"}`);
896
+ }
897
+
898
+ function printText(result) {
899
+ console.log(`影响分析:${result.source.id}(最大 ${result.depth} 跳)`);
900
+ const printGroup = (title, items) => {
901
+ console.log(`${title}:`);
902
+ if (items.length === 0) console.log("- 无");
903
+ for (const item of items) {
904
+ const path = item.path.map((edge) => `${edge.from} -[${edge.type}]-> ${edge.to}`).join(";");
905
+ const pathEvidence = item.path
906
+ .flatMap((edge) => edge.evidence ?? [])
907
+ .map((entry) => `${entry.ref}#${entry.locator}`)
908
+ .join(";");
909
+ console.log(`- ${item.id}(${item.node.type}):${item.node.name}`);
910
+ console.log(` 路径:${path}`);
911
+ console.log(` 关系证据:${pathEvidence || "-"}`);
912
+ console.log(` 证据:${item.evidence.map((entry) => `${entry.ref}#${entry.locator}`).join(";")}`);
913
+ }
914
+ };
915
+ printGroup("直接影响", result.direct);
916
+ printGroup("间接影响", result.indirect);
917
+ }
918
+
919
+ function main(args) {
920
+ const command = args[0];
921
+ if (!command || command === "--help" || command === "-h") {
922
+ usage();
923
+ return command ? 0 : 1;
924
+ }
925
+ try {
926
+ if (command === "validate") {
927
+ const options = parseValidateArgs(args.slice(1));
928
+ if (options === null) {
929
+ usage();
930
+ return 0;
931
+ }
932
+ return validate(options.root, options.layer);
933
+ }
934
+ if (command === "impact") {
935
+ const options = parseImpactArgs(args.slice(1));
936
+ if (options === null) {
937
+ usage();
938
+ return 0;
939
+ }
940
+ const result = impact(options.root, options);
941
+ if (options.format === "json") console.log(JSON.stringify(result, null, 2));
942
+ else printText(result);
943
+ return 0;
944
+ }
945
+ if (command === "plan") {
946
+ if (args[1] !== "impact") return error(`plan 不支持子命令:${args[1] ?? "<missing>"}`);
947
+ const options = parsePlanImpactArgs(args.slice(2));
948
+ if (options === null) {
949
+ usage();
950
+ return 0;
951
+ }
952
+ const result = planImpact(options.root, options);
953
+ if (options.format === "json") console.log(JSON.stringify(result, null, 2));
954
+ else printPlanImpactText(result);
955
+ return 0;
956
+ }
957
+ if (command === "code") {
958
+ if (args[1] === "candidates") {
959
+ const options = parseCodeCandidatesArgs(args.slice(2));
960
+ if (options === null) {
961
+ usage();
962
+ return 0;
963
+ }
964
+ const result = codeCandidates(options.root, options);
965
+ if (options.format === "json") console.log(JSON.stringify(result, null, 2));
966
+ else printCodeCandidatesText(result);
967
+ return 0;
968
+ }
969
+ if (args[1] === "impact") {
970
+ const options = parseCodeImpactArgs(args.slice(2));
971
+ if (options === null) {
972
+ usage();
973
+ return 0;
974
+ }
975
+ const result = codeImpact(options.root, options);
976
+ if (options.format === "json") console.log(JSON.stringify(result, null, 2));
977
+ else printCodeImpactText(result);
978
+ return 0;
979
+ }
980
+ return error(`code 不支持子命令:${args[1] ?? "<missing>"}`);
981
+ }
982
+ return error(`不支持子命令:${command}`);
983
+ } catch (cause) {
984
+ return error(cause.message);
985
+ }
986
+ }
987
+
988
+ process.exitCode = main(process.argv.slice(2));
@@ -92,6 +92,7 @@ def agent_rules_body():
92
92
  - 验证方式、完成条件、失败策略和回滚或安全边界明确。
93
93
  - 没有未解决的当前阶段阻塞项,且 `PLAN_MAP.md` 已同步。
94
94
  - 最新独立准入复核明确为“通过”。
95
+ - 机器识别的结构化章节标题固定为 `阶段路线图`、`当前阶段`、`阶段准入摘要`、`最新独立准入复核`、`独立复核记录`;不要写成 `阶段 1 准入摘要` 等带编号变体,阶段编号以 `PLAN_MAP.md` 的当前阶段为准。
95
96
 
96
97
  专项计划应保留追加式独立复核记录,并显式维护最新结论的日期、阶段、结论、证据和复核者。历史记录不得覆盖;`PLAN_MAP.md` 只链接最新有效结论。
97
98