@cyning/harness 1.0.1 → 1.1.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/CHANGELOG.md +29 -0
- package/README.md +2 -1
- package/docs/USER_GUIDE_v1.0_zh.md +58 -5
- package/docs/methodology/graph/INFORM_YAML_MIGRATION_v1_zh.md +81 -0
- package/docs/methodology/graph/PROMPT_ontology_inventory_scan_G0_v1_zh.md +313 -0
- package/docs/methodology/graph/README.md +2 -0
- package/docs/methodology/graph/inventory/ONTOLOGY_INVENTORY_ai_ink_brain_api_python_v1.yaml +283 -0
- package/docs/methodology/graph/inventory/ONTOLOGY_INVENTORY_cyning_harness_v1.yaml +233 -0
- package/examples/demo_checkout/00_main.graph.yaml +42 -0
- package/examples/demo_checkout/00_main.md +66 -0
- package/examples/demo_checkout/README.md +3 -0
- package/examples/demo_checkout/graph.json +111 -0
- package/lib/cli.js +153 -7
- package/lib/graph-yaml.js +547 -0
- package/lib/verify.js +58 -9
- package/ontology.yaml +1 -1
- package/package.json +4 -1
- package/schema/inform_graph.v3.schema.json +134 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
const SCHEMA_VERSION = 'inform_graph.v3';
|
|
6
|
+
const FREEZE_ID = 'TECH_GRAPH_S2_FREEZE_20260519_V2_3';
|
|
7
|
+
|
|
8
|
+
export class GraphYamlError extends Error {
|
|
9
|
+
constructor(message, { path: filePath = null, line = null } = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'GraphYamlError';
|
|
12
|
+
this.filePath = filePath;
|
|
13
|
+
this.line = line;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadYaml(filePath) {
|
|
18
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
19
|
+
try {
|
|
20
|
+
return yaml.load(raw);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
const line = err.mark ? err.mark.line + 1 : null;
|
|
23
|
+
throw new GraphYamlError(`YAML 解析失败: ${err.message}`, { path: filePath, line });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateGraphYaml(data, filePath = null) {
|
|
28
|
+
const errors = [];
|
|
29
|
+
|
|
30
|
+
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
|
|
31
|
+
errors.push('根节点须为 object');
|
|
32
|
+
return errors;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const required = ['graph_id', 'title', 'nodes', 'edges'];
|
|
36
|
+
for (const key of required) {
|
|
37
|
+
if (!(key in data)) errors.push(`缺少必填字段: ${key}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (data.schema_version != null && data.schema_version !== SCHEMA_VERSION) {
|
|
41
|
+
errors.push(`schema_version 建议为 ${SCHEMA_VERSION},实际为 ${data.schema_version}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (data.graph_id != null && !/^[a-zA-Z0-9_]+$/.test(String(data.graph_id))) {
|
|
45
|
+
errors.push(`graph_id 非法: ${data.graph_id}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (data.nodes != null) {
|
|
49
|
+
if (!Array.isArray(data.nodes)) {
|
|
50
|
+
errors.push('nodes 须为 array');
|
|
51
|
+
} else {
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (let i = 0; i < data.nodes.length; i += 1) {
|
|
54
|
+
const n = data.nodes[i];
|
|
55
|
+
if (n == null || typeof n !== 'object' || Array.isArray(n)) {
|
|
56
|
+
errors.push(`nodes[${i}] 须为 object`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!n.id) errors.push(`nodes[${i}] 缺少 id`);
|
|
60
|
+
else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(String(n.id))) {
|
|
61
|
+
errors.push(`nodes[${i}].id 非法: ${n.id}`);
|
|
62
|
+
}
|
|
63
|
+
if (!n.label) errors.push(`nodes[${i}] 缺少 label`);
|
|
64
|
+
if (n.id && seen.has(n.id)) errors.push(`重复节点 id: ${n.id}`);
|
|
65
|
+
if (n.id) seen.add(n.id);
|
|
66
|
+
if (n.kind != null && !['flow', 'struct', 'external'].includes(n.kind)) {
|
|
67
|
+
errors.push(`nodes[${i}].kind 非法: ${n.kind}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (data.edges != null) {
|
|
74
|
+
if (!Array.isArray(data.edges)) {
|
|
75
|
+
errors.push('edges 须为 array');
|
|
76
|
+
} else {
|
|
77
|
+
const nodeIds = new Set((data.nodes || []).map((n) => n?.id).filter(Boolean));
|
|
78
|
+
for (let i = 0; i < data.edges.length; i += 1) {
|
|
79
|
+
const e = data.edges[i];
|
|
80
|
+
if (e == null || typeof e !== 'object' || Array.isArray(e)) {
|
|
81
|
+
errors.push(`edges[${i}] 须为 object`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!e.from) errors.push(`edges[${i}] 缺少 from`);
|
|
85
|
+
if (!e.to) errors.push(`edges[${i}] 缺少 to`);
|
|
86
|
+
if (e.from && !nodeIds.has(e.from)) errors.push(`edges[${i}] 引用未知节点: ${e.from}`);
|
|
87
|
+
if (e.to && !nodeIds.has(e.to)) errors.push(`edges[${i}] 引用未知节点: ${e.to}`);
|
|
88
|
+
if (e.anchors != null) {
|
|
89
|
+
if (!Array.isArray(e.anchors)) {
|
|
90
|
+
errors.push(`edges[${i}].anchors 须为 array`);
|
|
91
|
+
} else {
|
|
92
|
+
for (let j = 0; j < e.anchors.length; j += 1) {
|
|
93
|
+
const a = e.anchors[j];
|
|
94
|
+
if (!a || typeof a !== 'object') {
|
|
95
|
+
errors.push(`edges[${i}].anchors[${j}] 须为 object`);
|
|
96
|
+
} else if (!a.path) {
|
|
97
|
+
errors.push(`edges[${i}].anchors[${j}] 缺少 path`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (errors.length > 0 && filePath) {
|
|
107
|
+
return errors.map((e) => `${filePath}: ${e}`);
|
|
108
|
+
}
|
|
109
|
+
return errors;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function allGraphIds(inputRoot) {
|
|
113
|
+
if (!fs.existsSync(inputRoot)) return [];
|
|
114
|
+
return fs
|
|
115
|
+
.readdirSync(inputRoot)
|
|
116
|
+
.filter((name) => name.endsWith('.graph.yaml'))
|
|
117
|
+
.map((name) => name.slice(0, -'.graph.yaml'.length))
|
|
118
|
+
.sort();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function yamlPathFor(inputRoot, graphId) {
|
|
122
|
+
return path.join(inputRoot, `${graphId}.graph.yaml`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function mdPathFor(inputRoot, graphId) {
|
|
126
|
+
return path.join(inputRoot, `${graphId}.md`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function utcNowIsoZ() {
|
|
130
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function classifyLabel(label) {
|
|
134
|
+
const t = String(label || '').trim();
|
|
135
|
+
if (t.startsWith('::') && t.length > 2) return [t.slice(2).trim() || 'meta', true];
|
|
136
|
+
if (t.includes('~>')) return ['async_calls', false];
|
|
137
|
+
if (t.includes('?>') || t === '?>') return ['condition', true];
|
|
138
|
+
return ['depends_on', true];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function edgeToGraphV2(edge) {
|
|
142
|
+
const mark = edge.mark || '';
|
|
143
|
+
const label = edge.label || '';
|
|
144
|
+
const explicitType = edge.type || '';
|
|
145
|
+
|
|
146
|
+
let baseMark;
|
|
147
|
+
let baseLabel;
|
|
148
|
+
let inferredType;
|
|
149
|
+
|
|
150
|
+
if (mark) {
|
|
151
|
+
if (mark === 'classDiagram') {
|
|
152
|
+
baseMark = 'classDiagram';
|
|
153
|
+
baseLabel = '';
|
|
154
|
+
inferredType = 'has_metadata';
|
|
155
|
+
} else if (mark === '~>') {
|
|
156
|
+
baseMark = '~>';
|
|
157
|
+
baseLabel = '';
|
|
158
|
+
inferredType = 'async_calls';
|
|
159
|
+
} else if (mark === '?>') {
|
|
160
|
+
baseMark = '?>';
|
|
161
|
+
baseLabel = '';
|
|
162
|
+
inferredType = 'condition';
|
|
163
|
+
} else if (mark.startsWith('::')) {
|
|
164
|
+
baseMark = mark;
|
|
165
|
+
baseLabel = '';
|
|
166
|
+
inferredType = mark.slice(2) || 'meta';
|
|
167
|
+
} else if (mark.startsWith('[') && mark.endsWith(']')) {
|
|
168
|
+
baseMark = mark;
|
|
169
|
+
baseLabel = '';
|
|
170
|
+
inferredType = 'depends_on';
|
|
171
|
+
} else if (mark === '->') {
|
|
172
|
+
baseMark = '->';
|
|
173
|
+
baseLabel = label;
|
|
174
|
+
inferredType = label ? classifyLabel(label)[0] : 'depends_on';
|
|
175
|
+
} else {
|
|
176
|
+
baseMark = mark;
|
|
177
|
+
baseLabel = label;
|
|
178
|
+
inferredType = label ? classifyLabel(label)[0] : 'depends_on';
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
baseMark = '->';
|
|
182
|
+
baseLabel = label;
|
|
183
|
+
if (!label) {
|
|
184
|
+
inferredType = 'depends_on';
|
|
185
|
+
} else if (label === 'classDiagram') {
|
|
186
|
+
baseMark = 'classDiagram';
|
|
187
|
+
baseLabel = '';
|
|
188
|
+
inferredType = 'has_metadata';
|
|
189
|
+
} else if (label === '?>') {
|
|
190
|
+
baseMark = '?>';
|
|
191
|
+
baseLabel = '';
|
|
192
|
+
inferredType = 'condition';
|
|
193
|
+
} else if (label.startsWith('::')) {
|
|
194
|
+
baseMark = label;
|
|
195
|
+
baseLabel = '';
|
|
196
|
+
inferredType = label.slice(2) || 'meta';
|
|
197
|
+
} else {
|
|
198
|
+
inferredType = classifyLabel(label)[0];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const finalType = explicitType || inferredType;
|
|
203
|
+
const sync = finalType !== 'async_calls';
|
|
204
|
+
return { mark: baseMark, type: finalType, sync, label: baseLabel };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizeAnchors(anchors) {
|
|
208
|
+
if (!anchors) return [];
|
|
209
|
+
return anchors.map((a) => {
|
|
210
|
+
const out = { path: a.path, symbol: a.symbol || '' };
|
|
211
|
+
if (a.line != null) out.line = a.line;
|
|
212
|
+
return out;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function buildGraphPayload(inputRoot, { generatedAt = null, freezeId = FREEZE_ID } = {}) {
|
|
217
|
+
if (!fs.existsSync(inputRoot)) {
|
|
218
|
+
throw new GraphYamlError(`输入目录不存在: ${inputRoot}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const graphIds = allGraphIds(inputRoot);
|
|
222
|
+
const nodes = [];
|
|
223
|
+
const edges = [];
|
|
224
|
+
const graphs = [];
|
|
225
|
+
|
|
226
|
+
for (const graphId of graphIds) {
|
|
227
|
+
const yamlPath = yamlPathFor(inputRoot, graphId);
|
|
228
|
+
const data = loadYaml(yamlPath);
|
|
229
|
+
const validationErrors = validateGraphYaml(data, yamlPath);
|
|
230
|
+
if (validationErrors.length > 0) {
|
|
231
|
+
throw new GraphYamlError(validationErrors.join('\n'));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
graphs.push({
|
|
235
|
+
id: graphId,
|
|
236
|
+
title: data.title,
|
|
237
|
+
source_yaml_path: path.relative(inputRoot, yamlPath).replace(/\\/g, '/'),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
for (const n of data.nodes) {
|
|
241
|
+
nodes.push({
|
|
242
|
+
id: n.id,
|
|
243
|
+
label: n.label,
|
|
244
|
+
graph_id: graphId,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const e of data.edges) {
|
|
249
|
+
const { mark, type, sync, label } = edgeToGraphV2(e);
|
|
250
|
+
edges.push({
|
|
251
|
+
from: e.from,
|
|
252
|
+
to: e.to,
|
|
253
|
+
mark,
|
|
254
|
+
type,
|
|
255
|
+
sync,
|
|
256
|
+
label,
|
|
257
|
+
anchors: normalizeAnchors(e.anchors),
|
|
258
|
+
graph_id: graphId,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
264
|
+
edges.sort((a, b) => {
|
|
265
|
+
const keys = ['graph_id', 'from', 'to', 'mark', 'type', 'sync', 'label'];
|
|
266
|
+
for (const k of keys) {
|
|
267
|
+
const va = a[k] ?? '';
|
|
268
|
+
const vb = b[k] ?? '';
|
|
269
|
+
if (va !== vb) return String(va).localeCompare(String(vb));
|
|
270
|
+
}
|
|
271
|
+
return 0;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
schema_version: 'graph_v2',
|
|
276
|
+
freeze_id: freezeId,
|
|
277
|
+
generated_at: generatedAt || utcNowIsoZ(),
|
|
278
|
+
nodes,
|
|
279
|
+
edges,
|
|
280
|
+
graphs,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function formatAnchorComment(anchor) {
|
|
285
|
+
const p = anchor.path || '';
|
|
286
|
+
const symbol = anchor.symbol || '';
|
|
287
|
+
const line = anchor.line;
|
|
288
|
+
if (!p) return '';
|
|
289
|
+
if (line != null) return `// → ${p}#L${line}`;
|
|
290
|
+
if (symbol) return `// → ${p}::${symbol}`;
|
|
291
|
+
return `// → ${p}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function generateMermaid(data) {
|
|
295
|
+
const nodes = new Map((data.nodes || []).map((n) => [n.id, n]));
|
|
296
|
+
const direction = data.direction || 'TD';
|
|
297
|
+
const lines = [`flowchart ${direction}`];
|
|
298
|
+
|
|
299
|
+
for (const [nid, node] of nodes) {
|
|
300
|
+
const label = node.label || nid;
|
|
301
|
+
let shape;
|
|
302
|
+
if (label.startsWith('>')) {
|
|
303
|
+
shape = `[${label}]`;
|
|
304
|
+
} else if (label.includes('子流程') || label.endsWith('子流程')) {
|
|
305
|
+
shape = `[[${label}]]`;
|
|
306
|
+
} else if (nid === 'Q' || nid === 'E') {
|
|
307
|
+
shape = `[[${label}]]`;
|
|
308
|
+
} else if (nid.includes('DOC')) {
|
|
309
|
+
shape = `[>${label}]`;
|
|
310
|
+
} else {
|
|
311
|
+
shape = `[${label}]`;
|
|
312
|
+
}
|
|
313
|
+
lines.push(` ${nid}${shape}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
lines.push('');
|
|
317
|
+
|
|
318
|
+
for (const e of data.edges || []) {
|
|
319
|
+
const src = e.from;
|
|
320
|
+
const dst = e.to;
|
|
321
|
+
const mark = e.mark || '->';
|
|
322
|
+
const label = e.label || '';
|
|
323
|
+
|
|
324
|
+
let edgeLine;
|
|
325
|
+
if (label) {
|
|
326
|
+
edgeLine = ` ${src} --"${label}"--> ${dst}`;
|
|
327
|
+
} else if (mark && mark !== '->') {
|
|
328
|
+
edgeLine = ` ${src} --"${mark}"--> ${dst}`;
|
|
329
|
+
} else {
|
|
330
|
+
edgeLine = ` ${src} --> ${dst}`;
|
|
331
|
+
}
|
|
332
|
+
lines.push(edgeLine);
|
|
333
|
+
|
|
334
|
+
for (const a of e.anchors || []) {
|
|
335
|
+
const comment = formatAnchorComment(a);
|
|
336
|
+
if (comment) lines.push(` ${comment}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
lines.push('');
|
|
341
|
+
lines.push(' classDef phase fill:#e1f5fe,stroke:#01579b,stroke-width:2px');
|
|
342
|
+
lines.push(' classDef doc fill:#fff8e1,stroke:#ff6f00,stroke-width:1px');
|
|
343
|
+
lines.push(' classDef infra fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px');
|
|
344
|
+
|
|
345
|
+
const phaseNodes = [...nodes.keys()].filter((n) => ['Q', 'E', 'U1', 'U2', 'RAG', 'T2S', 'RPC', 'FTS'].includes(n));
|
|
346
|
+
const docNodes = [...nodes.keys()].filter((n) => n.includes('DOC'));
|
|
347
|
+
const infraNodes = [...nodes.keys()].filter((n) => ['AUTH', 'EV_TYPES'].includes(n));
|
|
348
|
+
|
|
349
|
+
if (phaseNodes.length) lines.push(` class ${phaseNodes.join(',')} phase`);
|
|
350
|
+
if (docNodes.length) lines.push(` class ${docNodes.join(',')} doc`);
|
|
351
|
+
if (infraNodes.length) lines.push(` class ${infraNodes.join(',')} infra`);
|
|
352
|
+
|
|
353
|
+
return lines.join('\n');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function generateNodeTable(data) {
|
|
357
|
+
const lines = ['### Nodes', '', '| ID | Label | Kind |', '|----|-------|------|'];
|
|
358
|
+
for (const n of data.nodes || []) {
|
|
359
|
+
const label = String(n.label || '').replace(/\|/g, '\\|');
|
|
360
|
+
const kind = n.kind || '';
|
|
361
|
+
lines.push(`| ${n.id} | ${label} | ${kind} |`);
|
|
362
|
+
}
|
|
363
|
+
return lines.join('\n');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function generateEdgeTable(data) {
|
|
367
|
+
const lines = [
|
|
368
|
+
'### Edges',
|
|
369
|
+
'',
|
|
370
|
+
'| From | To | Mark | Type | Label | Anchors |',
|
|
371
|
+
'|------|----|------|------|-------|---------|',
|
|
372
|
+
];
|
|
373
|
+
for (const e of data.edges || []) {
|
|
374
|
+
const src = e.from || '';
|
|
375
|
+
const dst = e.to || '';
|
|
376
|
+
const mark = e.mark || '->';
|
|
377
|
+
const type = e.type || 'depends_on';
|
|
378
|
+
const label = String(e.label || '').replace(/\|/g, '\\|');
|
|
379
|
+
const anchors = e.anchors || [];
|
|
380
|
+
const anchorSummary = anchors.length ? `${anchors.length} anchor(s)` : '';
|
|
381
|
+
lines.push(`| ${src} | ${dst} | ${mark} | ${type} | ${label} | ${anchorSummary} |`);
|
|
382
|
+
}
|
|
383
|
+
return lines.join('\n');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function generateNotesSection(data) {
|
|
387
|
+
const notes = data.notes;
|
|
388
|
+
if (!notes) return '';
|
|
389
|
+
let body;
|
|
390
|
+
if (typeof notes === 'string') body = notes;
|
|
391
|
+
else if (Array.isArray(notes)) body = notes.join('\n\n');
|
|
392
|
+
else body = String(notes);
|
|
393
|
+
return `\n\n## Notes\n\n${body}\n`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function generateSubGraphLinks(graphId) {
|
|
397
|
+
if (graphId !== '00_main') return '';
|
|
398
|
+
return [
|
|
399
|
+
'## Sub-graph Links',
|
|
400
|
+
'',
|
|
401
|
+
"- `Struct`: [`01_struct.md`](01_struct.md)(手写 · 无 `.graph.yaml`)",
|
|
402
|
+
"- `Version`: [`02_version.md`](02_version.md)(手写 · 无 `.graph.yaml`)",
|
|
403
|
+
"- 子图编辑源见 `docs/_tech_graph/*.graph.yaml`",
|
|
404
|
+
'',
|
|
405
|
+
].join('\n');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function generateMarkdown(data, { sourcePath = null } = {}) {
|
|
409
|
+
const graphId = data.graph_id || 'main';
|
|
410
|
+
const title = data.title || graphId;
|
|
411
|
+
const description = data.description || '';
|
|
412
|
+
const version = data.version || '';
|
|
413
|
+
const generatedAt = utcNowIsoZ();
|
|
414
|
+
const src = sourcePath || `docs/_tech_graph/${graphId}.graph.yaml`;
|
|
415
|
+
|
|
416
|
+
const frontmatter = `---
|
|
417
|
+
graph_id: ${graphId}
|
|
418
|
+
version: ${version}
|
|
419
|
+
generated_at: ${generatedAt}
|
|
420
|
+
source: ${src}
|
|
421
|
+
---
|
|
422
|
+
`;
|
|
423
|
+
|
|
424
|
+
const header = `# ${title}\n\n${description}`.trim();
|
|
425
|
+
const mermaid = generateMermaid(data);
|
|
426
|
+
const subLinks = generateSubGraphLinks(graphId);
|
|
427
|
+
const notes = generateNotesSection(data);
|
|
428
|
+
|
|
429
|
+
const body = `${header}
|
|
430
|
+
|
|
431
|
+
## Mermaid
|
|
432
|
+
|
|
433
|
+
\`\`\`mermaid
|
|
434
|
+
${mermaid}
|
|
435
|
+
\`\`\`
|
|
436
|
+
|
|
437
|
+
## Structured Data
|
|
438
|
+
|
|
439
|
+
${generateNodeTable(data)}
|
|
440
|
+
|
|
441
|
+
${generateEdgeTable(data)}${notes}${subLinks ? `\n\n${subLinks}` : ''}
|
|
442
|
+
`;
|
|
443
|
+
|
|
444
|
+
return frontmatter + '\n' + body;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function compileGraph(graphId, inputRoot, outputPath = null) {
|
|
448
|
+
const yamlPath = yamlPathFor(inputRoot, graphId);
|
|
449
|
+
if (!fs.existsSync(yamlPath)) {
|
|
450
|
+
throw new GraphYamlError(`YAML 源不存在: ${yamlPath}`);
|
|
451
|
+
}
|
|
452
|
+
const data = loadYaml(yamlPath);
|
|
453
|
+
const validationErrors = validateGraphYaml(data, yamlPath);
|
|
454
|
+
if (validationErrors.length > 0) {
|
|
455
|
+
throw new GraphYamlError(validationErrors.join('\n'));
|
|
456
|
+
}
|
|
457
|
+
const md = generateMarkdown(data, { sourcePath: path.relative(inputRoot, yamlPath).replace(/\\/g, '/') });
|
|
458
|
+
const outPath = outputPath || mdPathFor(inputRoot, graphId);
|
|
459
|
+
fs.writeFileSync(outPath, md, 'utf8');
|
|
460
|
+
return outPath;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function loadGraphJson(graphJsonPath) {
|
|
464
|
+
if (!fs.existsSync(graphJsonPath)) return null;
|
|
465
|
+
const raw = fs.readFileSync(graphJsonPath, 'utf8');
|
|
466
|
+
try {
|
|
467
|
+
return JSON.parse(raw);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
throw new GraphYamlError(`graph.json 解析失败: ${err.message}`, { path: graphJsonPath });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function extractGraphJsonSlice(graphJson, graphId) {
|
|
474
|
+
const nodes = (graphJson?.nodes || []).filter((n) => n?.graph_id === graphId);
|
|
475
|
+
const edges = (graphJson?.edges || []).filter(
|
|
476
|
+
(e) => e?.graph_id === graphId && 'from' in e && 'to' in e,
|
|
477
|
+
);
|
|
478
|
+
return { nodes, edges };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function diffGraphYaml(graphId, inputRoot, graphJsonPath) {
|
|
482
|
+
const yamlPath = yamlPathFor(inputRoot, graphId);
|
|
483
|
+
if (!fs.existsSync(yamlPath)) {
|
|
484
|
+
throw new GraphYamlError(`YAML 源不存在: ${yamlPath}`);
|
|
485
|
+
}
|
|
486
|
+
const yamlData = loadYaml(yamlPath);
|
|
487
|
+
const validationErrors = validateGraphYaml(yamlData, yamlPath);
|
|
488
|
+
if (validationErrors.length > 0) {
|
|
489
|
+
throw new GraphYamlError(validationErrors.join('\n'));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (!fs.existsSync(graphJsonPath)) {
|
|
493
|
+
return { ok: false, diff: `graph.json 不存在: ${graphJsonPath}` };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const graphJson = loadGraphJson(graphJsonPath);
|
|
497
|
+
const jsonNodes = (graphJson?.nodes || []).filter((n) => n?.graph_id === graphId);
|
|
498
|
+
const jsonEdges = (graphJson?.edges || []).filter(
|
|
499
|
+
(e) => e?.graph_id === graphId && 'from' in e && 'to' in e,
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
const yamlNodes = new Map((yamlData.nodes || []).map((n) => [n.id, n]));
|
|
503
|
+
const yamlNodeIds = new Set(yamlNodes.keys());
|
|
504
|
+
const jsonNodeIds = new Set(jsonNodes.map((n) => n.id));
|
|
505
|
+
|
|
506
|
+
const diffs = [];
|
|
507
|
+
|
|
508
|
+
if (yamlNodeIds.size !== jsonNodeIds.size || ![...yamlNodeIds].every((id) => jsonNodeIds.has(id))) {
|
|
509
|
+
const onlyYaml = [...yamlNodeIds].filter((id) => !jsonNodeIds.has(id));
|
|
510
|
+
const onlyJson = [...jsonNodeIds].filter((id) => !yamlNodeIds.has(id));
|
|
511
|
+
if (onlyYaml.length) diffs.push(`Nodes only in YAML: ${onlyYaml.sort().join(', ')}`);
|
|
512
|
+
if (onlyJson.length) diffs.push(`Nodes only in JSON: ${onlyJson.sort().join(', ')}`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if ((yamlData.nodes || []).length !== jsonNodes.length) {
|
|
516
|
+
diffs.push(`Node count mismatch: YAML=${(yamlData.nodes || []).length}, JSON=${jsonNodes.length}`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const yamlEdgeSet = new Set(
|
|
520
|
+
(yamlData.edges || []).map((e) => {
|
|
521
|
+
const { mark, type } = edgeToGraphV2(e);
|
|
522
|
+
return JSON.stringify([e.from, e.to, mark, type]);
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
const jsonEdgeSet = new Set(
|
|
526
|
+
jsonEdges.map((e) => JSON.stringify([e.from, e.to, e.mark || '->', e.type || 'depends_on'])),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
if (yamlEdgeSet.size !== jsonEdgeSet.size || ![...yamlEdgeSet].every((k) => jsonEdgeSet.has(k))) {
|
|
530
|
+
const onlyYaml = [...yamlEdgeSet].filter((k) => !jsonEdgeSet.has(k)).map((k) => JSON.parse(k));
|
|
531
|
+
const onlyJson = [...jsonEdgeSet].filter((k) => !yamlEdgeSet.has(k)).map((k) => JSON.parse(k));
|
|
532
|
+
if (onlyYaml.length) diffs.push(`Edges only in YAML: ${JSON.stringify(onlyYaml)}`);
|
|
533
|
+
if (onlyJson.length) diffs.push(`Edges only in JSON: ${JSON.stringify(onlyJson)}`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if ((yamlData.edges || []).length !== jsonEdges.length) {
|
|
537
|
+
diffs.push(`Edge count mismatch: YAML=${(yamlData.edges || []).length}, JSON=${jsonEdges.length}`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (diffs.length === 0) return { ok: true, diff: '' };
|
|
541
|
+
return { ok: false, diff: diffs.join('\n') };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function checkGraph(graphId, inputRoot, graphJsonPath) {
|
|
545
|
+
const result = diffGraphYaml(graphId, inputRoot, graphJsonPath);
|
|
546
|
+
return result;
|
|
547
|
+
}
|
package/lib/verify.js
CHANGED
|
@@ -77,19 +77,68 @@ function runGateCheck(target, taskFile, graph, harnessRoot) {
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function extractBlockReason(stdout) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
function extractBlockReason(stdout, taskFile) {
|
|
81
|
+
const taskBasename = taskFile ? path.basename(taskFile) : undefined;
|
|
82
|
+
const sections = splitGateCheckTaskSections(stdout);
|
|
83
|
+
|
|
84
|
+
if (taskBasename) {
|
|
85
|
+
const section = sections.get(taskBasename) ?? stdout;
|
|
86
|
+
const arrows = extractArrowBlockLines(section);
|
|
87
|
+
if (arrows.length > 0) return arrows[0];
|
|
88
|
+
return 'gate-check blocked';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const blocked = [];
|
|
92
|
+
for (const [name, section] of sections) {
|
|
93
|
+
const arrows = extractArrowBlockLines(section);
|
|
94
|
+
if (arrows.length > 0) {
|
|
95
|
+
blocked.push({ name, reason: arrows[0] });
|
|
85
96
|
}
|
|
86
97
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
98
|
+
|
|
99
|
+
if (blocked.length === 0) return 'gate-check blocked';
|
|
100
|
+
|
|
101
|
+
const total = sections.size;
|
|
102
|
+
if (blocked.length === 1) {
|
|
103
|
+
const { name, reason } = blocked[0];
|
|
104
|
+
return total === 1 ? reason : `${name} · ${reason}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const names = blocked.map((b) => b.name).join(', ');
|
|
108
|
+
return `${blocked.length}/${total} tasks blocked · ${names}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 按 gate-check 输出的 `task: xxx.md` 切段 */
|
|
112
|
+
function splitGateCheckTaskSections(stdout) {
|
|
113
|
+
const map = new Map();
|
|
114
|
+
let current = null;
|
|
115
|
+
const lines = [];
|
|
116
|
+
|
|
117
|
+
const flush = () => {
|
|
118
|
+
if (current) map.set(current, lines.join('\n'));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (const line of stdout.split('\n')) {
|
|
122
|
+
const match = line.match(/^task: (.+\.md)\s*$/);
|
|
123
|
+
if (match) {
|
|
124
|
+
flush();
|
|
125
|
+
current = match[1];
|
|
126
|
+
lines.length = 0;
|
|
127
|
+
lines.push(line);
|
|
128
|
+
} else if (current) {
|
|
129
|
+
lines.push(line);
|
|
90
130
|
}
|
|
91
131
|
}
|
|
92
|
-
|
|
132
|
+
flush();
|
|
133
|
+
return map;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 只认 gate-check 的 `→ 30 不可开工` 行,避免误匹配表格里的 `❌ 拒 30` */
|
|
137
|
+
function extractArrowBlockLines(section) {
|
|
138
|
+
return section
|
|
139
|
+
.split('\n')
|
|
140
|
+
.filter((line) => line.trimStart().startsWith('→ 30 不可开工'))
|
|
141
|
+
.map((line) => line.trim().replace(/^→\s*/, ''));
|
|
93
142
|
}
|
|
94
143
|
|
|
95
144
|
function runGitCleanCheck(target) {
|
package/ontology.yaml
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyning/harness",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "cyning-harness discipline package · init / upgrade / check CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"js-yaml": "^4.1.0"
|
|
9
|
+
},
|
|
7
10
|
"bin": {
|
|
8
11
|
"harness": "bin/harness.js",
|
|
9
12
|
"cyning-harness": "bin/harness.js"
|