plan-governance-cli 0.2.4 → 0.3.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/README.md +46 -3
- package/bin/plan-governance-cli.mjs +28 -0
- package/package.json +7 -3
- package/resources/manifest.json +2 -1
- package/resources/skill/SKILL.md +54 -5
- package/resources/skill/agents/openai.yaml +1 -1
- package/resources/skill/assets/PLAN_MAP.template.md +2 -0
- package/resources/skill/assets/plan.template.md +35 -0
- package/scripts/check_plan_governance.py +1445 -22
- package/scripts/graph_governance.mjs +988 -0
- package/scripts/init_plan_governance.py +1 -0
- package/scripts/plan_governance_hook.py +30 -1
|
@@ -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));
|