kld-sdd 2.5.1 → 2.6.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 +118 -8
- package/kld-sdd-guide.html +1 -1
- package/lib/init.js +24 -5
- package/lib/tool-profiles.js +1 -1
- package/package.json +4 -2
- package/skywalk-sdd/context-client.cjs +160 -0
- package/skywalk-sdd/index.cjs +445 -36
- package/skywalk-sdd/ontology/archive-package.cjs +489 -0
- package/skywalk-sdd/ontology/artifact-observer.cjs +91 -0
- package/skywalk-sdd/ontology/artifact-parser.cjs +621 -0
- package/skywalk-sdd/ontology/change-lock.cjs +126 -0
- package/skywalk-sdd/ontology/cli.cjs +146 -0
- package/skywalk-sdd/ontology/effective-graph.cjs +158 -0
- package/skywalk-sdd/ontology/id.cjs +126 -0
- package/skywalk-sdd/ontology/identity-index.cjs +287 -0
- package/skywalk-sdd/ontology/normalizer.cjs +107 -0
- package/skywalk-sdd/ontology/runtime.cjs +466 -0
- package/skywalk-sdd/ontology/schema.cjs +139 -0
- package/skywalk-sdd/ontology/structural-identity.cjs +77 -0
- package/skywalk-sdd/ontology/traceability-validator.cjs +610 -0
- package/skywalk-sdd/ontology/working-artifacts.cjs +243 -0
- package/templates/openspec/design.md +18 -0
- package/templates/openspec/proposal.md +19 -6
- package/templates/openspec/spec.md +62 -8
- package/templates/openspec/tasks.md +28 -6
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +19 -1
- package/templates/skills/kld-sdd/opsx-archive/checklist.md +5 -1
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +18 -0
- package/templates/skills/kld-sdd/opsx-check/checklist.md +2 -0
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +11 -0
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +12 -0
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +34 -0
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +5 -0
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +11 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { DIAGNOSTIC_CODES, entityTypeForId } = require('./schema.cjs');
|
|
7
|
+
const { ensureStructuralIdentities } = require('./structural-identity.cjs');
|
|
8
|
+
|
|
9
|
+
const ENTITY_ID_PATTERN = /\b(?:CHG|CAP|STMT|AC|CON|DES|TASK|ART|SEC|SNAP)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi;
|
|
10
|
+
const IDENTITY_META_PATTERN = /^\s*-\s+\*\*(entity-id|version-id|predecessor-version|delta-state)\*\*\s*[::]\s*(.*?)\s*$/i;
|
|
11
|
+
|
|
12
|
+
function toPosix(value) {
|
|
13
|
+
return String(value || '').replace(/\\/g, '/');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sha256(value) {
|
|
17
|
+
return crypto.createHash('sha256').update(value).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeChangeName(changeName) {
|
|
21
|
+
const value = String(changeName || '').trim();
|
|
22
|
+
if (!value || !/^[A-Za-z0-9._-]+$/.test(value) || value === '.' || value === '..') {
|
|
23
|
+
throw new Error(`change 名称不安全: ${changeName}`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stripInlineYamlComment(value) {
|
|
29
|
+
const input = String(value || '');
|
|
30
|
+
let quote = '';
|
|
31
|
+
let escaped = false;
|
|
32
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
33
|
+
const char = input[index];
|
|
34
|
+
if (escaped) {
|
|
35
|
+
escaped = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (char === '\\' && quote === '"') {
|
|
39
|
+
escaped = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (quote) {
|
|
43
|
+
if (char === quote) quote = '';
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (char === '"' || char === "'") {
|
|
47
|
+
quote = char;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (char === '#' && (index === 0 || /\s/.test(input[index - 1]))) {
|
|
51
|
+
return input.slice(0, index).trimEnd();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return input.trimEnd();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function unwrapScalar(value) {
|
|
58
|
+
let normalized = String(value || '').trim();
|
|
59
|
+
if ((normalized.startsWith('"') && normalized.endsWith('"'))
|
|
60
|
+
|| (normalized.startsWith("'") && normalized.endsWith("'"))
|
|
61
|
+
|| (normalized.startsWith('`') && normalized.endsWith('`'))) {
|
|
62
|
+
normalized = normalized.slice(1, -1).trim();
|
|
63
|
+
}
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseFrontmatter(lines) {
|
|
68
|
+
const result = {};
|
|
69
|
+
if (!lines.length || lines[0].trim() !== '---') return result;
|
|
70
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
71
|
+
const line = lines[index].replace(/\r$/, '');
|
|
72
|
+
if (line.trim() === '---') break;
|
|
73
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
|
|
74
|
+
if (!match) continue;
|
|
75
|
+
const value = unwrapScalar(stripInlineYamlComment(match[2]));
|
|
76
|
+
result[match[1].toLowerCase()] = value;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function scanArtifactFiles(changeDir) {
|
|
82
|
+
const files = [];
|
|
83
|
+
if (!fs.existsSync(changeDir)) return files;
|
|
84
|
+
|
|
85
|
+
function walk(current) {
|
|
86
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
87
|
+
if (entry.name.startsWith('.') || entry.name === 'reports' || entry.name === 'logs') continue;
|
|
88
|
+
const full = path.join(current, entry.name);
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
walk(full);
|
|
91
|
+
} else if (/^(proposal|spec|design|tasks?)\.md$/i.test(entry.name)) {
|
|
92
|
+
const content = fs.readFileSync(full, 'utf8').replace(/^\uFEFF/, '');
|
|
93
|
+
files.push({
|
|
94
|
+
absolutePath: full,
|
|
95
|
+
relativePath: toPosix(path.relative(changeDir, full)),
|
|
96
|
+
content,
|
|
97
|
+
contentHash: sha256(content),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
walk(changeDir);
|
|
104
|
+
return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function artifactType(relativePath) {
|
|
108
|
+
const name = path.posix.basename(toPosix(relativePath)).toLowerCase();
|
|
109
|
+
if (name === 'proposal.md') return 'proposal';
|
|
110
|
+
if (name === 'spec.md') return 'spec';
|
|
111
|
+
if (name === 'design.md') return 'design';
|
|
112
|
+
if (name === 'tasks.md' || name === 'task.md') return 'tasks';
|
|
113
|
+
if (name === 'effective-spec.md') return 'projection';
|
|
114
|
+
return 'unknown';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sourceFor(file, line, anchorId) {
|
|
118
|
+
return {
|
|
119
|
+
artifact_type: artifactType(file.relativePath),
|
|
120
|
+
file: file.relativePath,
|
|
121
|
+
line,
|
|
122
|
+
anchor_id: anchorId,
|
|
123
|
+
content_hash: file.contentHash,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function addEntity(target, type, id, name, file, line, attributes = {}, identity = {}, semanticContent = '') {
|
|
128
|
+
const anchorId = String(id || '').toUpperCase();
|
|
129
|
+
const entity = {
|
|
130
|
+
id: anchorId,
|
|
131
|
+
anchor_id: anchorId,
|
|
132
|
+
type,
|
|
133
|
+
name: String(name || '').trim(),
|
|
134
|
+
entity_id: String(identity.entity_id || '').trim().toLowerCase() || undefined,
|
|
135
|
+
version_id: String(identity.version_id || '').trim().toLowerCase() || undefined,
|
|
136
|
+
predecessor_version_id: String(identity.predecessor_version_id || '').trim().toLowerCase() || undefined,
|
|
137
|
+
delta_state: String(identity.delta_state || '').trim().toLowerCase() || undefined,
|
|
138
|
+
content_hash: sha256(stripIdentityMetadata(semanticContent || `${type}\n${anchorId}\n${name}`)),
|
|
139
|
+
attributes,
|
|
140
|
+
source: sourceFor(file, line, anchorId),
|
|
141
|
+
};
|
|
142
|
+
target.entities.push(entity);
|
|
143
|
+
return entity;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function addRelation(target, type, from, to, file, line, assertionType = 'asserted', ruleId = '') {
|
|
147
|
+
target.relations.push({
|
|
148
|
+
type,
|
|
149
|
+
from: String(from || '').toUpperCase(),
|
|
150
|
+
to: String(to || '').toUpperCase(),
|
|
151
|
+
assertion_type: assertionType,
|
|
152
|
+
rule_id: ruleId || undefined,
|
|
153
|
+
source: sourceFor(file, line, `${String(from || '').toUpperCase()}-${type}-${String(to || '').toUpperCase()}`),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function extractIds(value) {
|
|
158
|
+
return (String(value || '').match(ENTITY_ID_PATTERN) || []).map((id) => id.toUpperCase());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function stripIdentityMetadata(value) {
|
|
162
|
+
return String(value || '')
|
|
163
|
+
.split('\n')
|
|
164
|
+
.filter((line) => !IDENTITY_META_PATTERN.test(line))
|
|
165
|
+
.filter((line) => !/^\s*(?:entity-id|version-id|predecessor-version|delta-state)\s*:/i.test(line))
|
|
166
|
+
.filter((line) => !/^\s*-\s+\*\*(?:realizes|implements|covers|dependsOn|constrains|约束对象|实现需求|覆盖需求|依赖)\*\*\s*[::]/i.test(line))
|
|
167
|
+
.join('\n')
|
|
168
|
+
.trim();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function identityFromValues(values = {}) {
|
|
172
|
+
const optionalValue = (value) => {
|
|
173
|
+
const normalized = String(value || '').trim();
|
|
174
|
+
return /^(?:无|none|null|-)$/i.test(normalized) ? '' : normalized;
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
entity_id: optionalValue(values['entity-id']),
|
|
178
|
+
version_id: optionalValue(values['version-id']),
|
|
179
|
+
predecessor_version_id: optionalValue(values['predecessor-version']),
|
|
180
|
+
delta_state: String(values['delta-state'] || '').trim(),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseIdentityBlock(lines, startIndex, endIndex = lines.length) {
|
|
185
|
+
const values = {};
|
|
186
|
+
let started = false;
|
|
187
|
+
for (let index = startIndex; index < endIndex; index += 1) {
|
|
188
|
+
const line = lines[index].replace(/\r$/, '');
|
|
189
|
+
if (!line.trim() && !started) continue;
|
|
190
|
+
const match = line.match(IDENTITY_META_PATTERN);
|
|
191
|
+
if (!match) break;
|
|
192
|
+
started = true;
|
|
193
|
+
values[match[1].toLowerCase()] = unwrapScalar(match[2]);
|
|
194
|
+
}
|
|
195
|
+
return identityFromValues(values);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function headingLevel(line) {
|
|
199
|
+
const match = String(line || '').match(/^\s*(#{1,6})\s+/);
|
|
200
|
+
return match ? match[1].length : 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sectionEnd(lines, startIndex, level) {
|
|
204
|
+
for (let index = startIndex + 1; index < lines.length; index += 1) {
|
|
205
|
+
const nextLevel = headingLevel(lines[index]);
|
|
206
|
+
if (nextLevel > 0 && nextLevel <= level) return index;
|
|
207
|
+
}
|
|
208
|
+
return lines.length;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function statementContentEnd(lines, startIndex) {
|
|
212
|
+
const endIndex = sectionEnd(lines, startIndex, 4);
|
|
213
|
+
for (let index = startIndex + 1; index < endIndex; index += 1) {
|
|
214
|
+
if (/^\s*#####\s+场景[::]/i.test(lines[index])) return index;
|
|
215
|
+
}
|
|
216
|
+
return endIndex;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseProposal(target, file, lines, frontmatter) {
|
|
220
|
+
target.proposalMode = String(frontmatter.mode || '').trim().toLowerCase();
|
|
221
|
+
const changeId = String(frontmatter['change-id'] || '').trim().toUpperCase();
|
|
222
|
+
if (changeId) {
|
|
223
|
+
addEntity(
|
|
224
|
+
target,
|
|
225
|
+
'Change',
|
|
226
|
+
changeId,
|
|
227
|
+
target.changeName,
|
|
228
|
+
file,
|
|
229
|
+
1,
|
|
230
|
+
{ change_name: target.changeName },
|
|
231
|
+
identityFromValues(frontmatter),
|
|
232
|
+
file.content,
|
|
233
|
+
);
|
|
234
|
+
target.changeId = changeId;
|
|
235
|
+
} else {
|
|
236
|
+
target.diagnostics.push({
|
|
237
|
+
code: DIAGNOSTIC_CODES.ID_MISSING,
|
|
238
|
+
severity: 'warning',
|
|
239
|
+
message: 'proposal.md 缺少 change-id;旧产物保持可读,但不推导稳定 Change 身份',
|
|
240
|
+
file: file.relativePath,
|
|
241
|
+
line: 1,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
246
|
+
const line = lines[index].replace(/\r$/, '');
|
|
247
|
+
const match = line.match(/^\s*-\s+(?:\[[ xX]\]\s+)?\[(CAP-[A-Z0-9-]+)\]\s+(`?[A-Za-z0-9._-]+`?)\s*:\s*(.+?)\s*$/i);
|
|
248
|
+
if (!match) continue;
|
|
249
|
+
const capabilityId = match[1].toUpperCase();
|
|
250
|
+
const identity = parseIdentityBlock(lines, index + 1);
|
|
251
|
+
const slug = unwrapScalar(match[2]);
|
|
252
|
+
addEntity(target, 'Capability', capabilityId, slug, file, index + 1, {
|
|
253
|
+
slug,
|
|
254
|
+
description: match[3],
|
|
255
|
+
}, identity, line);
|
|
256
|
+
if (target.changeId) {
|
|
257
|
+
addRelation(target, 'contains', target.changeId, capabilityId, file, index + 1, 'inferred', 'proposal-capability-membership');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseSpec(target, file, lines, frontmatter) {
|
|
263
|
+
const capabilityId = String(frontmatter['capability-id'] || '').trim().toUpperCase();
|
|
264
|
+
let currentStatement = '';
|
|
265
|
+
let currentConstraint = '';
|
|
266
|
+
|
|
267
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
268
|
+
const line = lines[index].replace(/\r$/, '');
|
|
269
|
+
let inheritedMatch = line.match(/^\s*-\s+\*\*anchor\*\*\s*[::]\s*`?((?:STMT|AC|CON)-[A-Z0-9-]+)`?\s*$/i);
|
|
270
|
+
if (inheritedMatch) {
|
|
271
|
+
const anchorId = inheritedMatch[1].toUpperCase();
|
|
272
|
+
const identity = parseIdentityBlock(lines, index + 1);
|
|
273
|
+
let source = '';
|
|
274
|
+
let sourceVersionHash = '';
|
|
275
|
+
for (let offset = index + 1; offset < Math.min(lines.length, index + 10); offset += 1) {
|
|
276
|
+
if (headingLevel(lines[offset]) > 0) break;
|
|
277
|
+
const sourceMatch = lines[offset].match(/^\s*-\s+\*\*source\*\*\s*[::]\s*(.*?)\s*$/i);
|
|
278
|
+
const hashMatch = lines[offset].match(/^\s*-\s+\*\*source-version-hash\*\*\s*[::]\s*`?([a-f0-9]{64})`?\s*$/i);
|
|
279
|
+
if (sourceMatch) source = unwrapScalar(sourceMatch[1]);
|
|
280
|
+
if (hashMatch) sourceVersionHash = hashMatch[1].toLowerCase();
|
|
281
|
+
}
|
|
282
|
+
target.inheritedReferences.push({
|
|
283
|
+
anchor_id: anchorId,
|
|
284
|
+
type: entityTypeForId(anchorId),
|
|
285
|
+
entity_id: String(identity.entity_id || '').toLowerCase() || undefined,
|
|
286
|
+
version_id: String(identity.version_id || '').toLowerCase() || undefined,
|
|
287
|
+
delta_state: String(identity.delta_state || '').toLowerCase() || undefined,
|
|
288
|
+
source_ref: source || undefined,
|
|
289
|
+
source_version_hash: sourceVersionHash || undefined,
|
|
290
|
+
source: sourceFor(file, index + 1, anchorId),
|
|
291
|
+
});
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const inheritedRelationMatch = line.match(/^\s*-\s+\*\*relation\*\*\s*[::]\s*`?((?:CHG|CAP|STMT|AC|CON|DES|TASK)-[A-Z0-9-]+)\s+(contains|acceptedBy|constrainedBy|realizes|implements|covers|dependsOn)\s+((?:CHG|CAP|STMT|AC|CON|DES|TASK)-[A-Z0-9-]+)`?\s*$/i);
|
|
295
|
+
if (inheritedRelationMatch) {
|
|
296
|
+
let source = '';
|
|
297
|
+
for (let offset = index + 1; offset < Math.min(lines.length, index + 6); offset += 1) {
|
|
298
|
+
if (headingLevel(lines[offset]) > 0) break;
|
|
299
|
+
const sourceMatch = lines[offset].match(/^\s*-\s+\*\*source\*\*\s*[::]\s*(.*?)\s*$/i);
|
|
300
|
+
if (sourceMatch) source = unwrapScalar(sourceMatch[1]);
|
|
301
|
+
}
|
|
302
|
+
target.inheritedRelations.push({
|
|
303
|
+
type: inheritedRelationMatch[2],
|
|
304
|
+
from: inheritedRelationMatch[1].toUpperCase(),
|
|
305
|
+
to: inheritedRelationMatch[3].toUpperCase(),
|
|
306
|
+
source_ref: source || undefined,
|
|
307
|
+
source: sourceFor(file, index + 1, `${inheritedRelationMatch[1]}-${inheritedRelationMatch[2]}-${inheritedRelationMatch[3]}`),
|
|
308
|
+
});
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
let match = line.match(/^\s*####\s+需求项[::]\s*[【\[]?(STMT-[A-Z0-9-]+)[】\]]?\s*(.*?)\s*$/i);
|
|
312
|
+
if (match) {
|
|
313
|
+
currentStatement = match[1].toUpperCase();
|
|
314
|
+
currentConstraint = '';
|
|
315
|
+
const endIndex = sectionEnd(lines, index, 4);
|
|
316
|
+
const contentEndIndex = statementContentEnd(lines, index);
|
|
317
|
+
addEntity(
|
|
318
|
+
target,
|
|
319
|
+
'SpecificationStatement',
|
|
320
|
+
currentStatement,
|
|
321
|
+
match[2],
|
|
322
|
+
file,
|
|
323
|
+
index + 1,
|
|
324
|
+
{ capability_id: capabilityId || undefined },
|
|
325
|
+
parseIdentityBlock(lines, index + 1, endIndex),
|
|
326
|
+
lines.slice(index, contentEndIndex).join('\n'),
|
|
327
|
+
);
|
|
328
|
+
if (capabilityId) {
|
|
329
|
+
addRelation(target, 'contains', capabilityId, currentStatement, file, index + 1, 'inferred', 'spec-capability-membership');
|
|
330
|
+
}
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
match = line.match(/^\s*#####\s+场景[::]\s*[【\[]?(AC-[A-Z0-9-]+)[】\]]?\s*(.*?)\s*$/i);
|
|
335
|
+
if (match) {
|
|
336
|
+
const acceptanceId = match[1].toUpperCase();
|
|
337
|
+
const endIndex = sectionEnd(lines, index, 5);
|
|
338
|
+
addEntity(
|
|
339
|
+
target,
|
|
340
|
+
'AcceptanceCriterion',
|
|
341
|
+
acceptanceId,
|
|
342
|
+
match[2],
|
|
343
|
+
file,
|
|
344
|
+
index + 1,
|
|
345
|
+
{ statement_id: currentStatement || undefined },
|
|
346
|
+
parseIdentityBlock(lines, index + 1, endIndex),
|
|
347
|
+
lines.slice(index, endIndex).join('\n'),
|
|
348
|
+
);
|
|
349
|
+
if (currentStatement) {
|
|
350
|
+
addRelation(target, 'acceptedBy', currentStatement, acceptanceId, file, index + 1, 'inferred', 'nested-acceptance-scenario');
|
|
351
|
+
}
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
match = line.match(/^\s*####\s+约束[::]\s*[【\[]?(CON-[A-Z0-9-]+)[】\]]?\s*(.*?)\s*$/i);
|
|
356
|
+
if (match) {
|
|
357
|
+
currentConstraint = match[1].toUpperCase();
|
|
358
|
+
currentStatement = '';
|
|
359
|
+
const endIndex = sectionEnd(lines, index, 4);
|
|
360
|
+
addEntity(
|
|
361
|
+
target,
|
|
362
|
+
'Constraint',
|
|
363
|
+
currentConstraint,
|
|
364
|
+
match[2],
|
|
365
|
+
file,
|
|
366
|
+
index + 1,
|
|
367
|
+
{ capability_id: capabilityId || undefined },
|
|
368
|
+
parseIdentityBlock(lines, index + 1, endIndex),
|
|
369
|
+
lines.slice(index, endIndex).join('\n'),
|
|
370
|
+
);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
match = line.match(/^\s*-\s+\*\*(?:constrains|约束对象)\*\*\s*[::]\s*(.*?)\s*$/i);
|
|
375
|
+
if (match && currentConstraint) {
|
|
376
|
+
for (const statementId of extractIds(match[1]).filter((id) => id.startsWith('STMT-'))) {
|
|
377
|
+
addRelation(target, 'constrainedBy', statementId, currentConstraint, file, index + 1);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function parseDesign(target, file, lines, frontmatter) {
|
|
384
|
+
const capabilityId = String(frontmatter['capability-id'] || '').trim().toUpperCase();
|
|
385
|
+
let currentDesign = '';
|
|
386
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
387
|
+
const line = lines[index].replace(/\r$/, '');
|
|
388
|
+
let match = line.match(/^\s*#{2,5}\s+\[(DES-[A-Z0-9-]+)\]\s*(.*?)\s*$/i);
|
|
389
|
+
if (match) {
|
|
390
|
+
currentDesign = match[1].toUpperCase();
|
|
391
|
+
const endIndex = sectionEnd(lines, index, headingLevel(line));
|
|
392
|
+
addEntity(
|
|
393
|
+
target,
|
|
394
|
+
'DesignElement',
|
|
395
|
+
currentDesign,
|
|
396
|
+
match[2],
|
|
397
|
+
file,
|
|
398
|
+
index + 1,
|
|
399
|
+
{ capability_id: capabilityId || undefined },
|
|
400
|
+
parseIdentityBlock(lines, index + 1, endIndex),
|
|
401
|
+
lines.slice(index, endIndex).join('\n'),
|
|
402
|
+
);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
match = line.match(/^\s*-\s+\*\*(?:realizes|实现需求)\*\*\s*[::]\s*(.*?)\s*$/i);
|
|
406
|
+
if (match && currentDesign) {
|
|
407
|
+
for (const statementId of extractIds(match[1]).filter((id) => id.startsWith('STMT-'))) {
|
|
408
|
+
addRelation(target, 'realizes', currentDesign, statementId, file, index + 1);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function parseTasks(target, file, lines, frontmatter) {
|
|
415
|
+
const capabilityId = String(frontmatter['capability-id'] || '').trim().toUpperCase();
|
|
416
|
+
let currentTask = '';
|
|
417
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
418
|
+
const line = lines[index].replace(/\r$/, '');
|
|
419
|
+
let match = line.match(/^\s*#{2,5}\s+\[(TASK-[A-Z0-9-]+)\]\s*(.*?)\s*$/i);
|
|
420
|
+
if (match) {
|
|
421
|
+
currentTask = match[1].toUpperCase();
|
|
422
|
+
const endIndex = sectionEnd(lines, index, headingLevel(line));
|
|
423
|
+
addEntity(
|
|
424
|
+
target,
|
|
425
|
+
'Task',
|
|
426
|
+
currentTask,
|
|
427
|
+
match[2],
|
|
428
|
+
file,
|
|
429
|
+
index + 1,
|
|
430
|
+
{ capability_id: capabilityId || undefined },
|
|
431
|
+
parseIdentityBlock(lines, index + 1, endIndex),
|
|
432
|
+
lines.slice(index, endIndex).join('\n'),
|
|
433
|
+
);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
match = line.match(/^\s*-\s+\*\*(implements|covers|dependsOn|实现设计|覆盖需求|依赖)\*\*\s*[::]\s*(.*?)\s*$/i);
|
|
437
|
+
if (!match || !currentTask) continue;
|
|
438
|
+
const relationName = match[1].toLowerCase();
|
|
439
|
+
const type = relationName === 'implements' || relationName === '实现设计'
|
|
440
|
+
? 'implements'
|
|
441
|
+
: relationName === 'covers' || relationName === '覆盖需求'
|
|
442
|
+
? 'covers'
|
|
443
|
+
: 'dependsOn';
|
|
444
|
+
for (const targetId of extractIds(match[2])) {
|
|
445
|
+
addRelation(target, type, currentTask, targetId, file, index + 1);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function inferProfile(files, proposalMode = '', diagnostics = []) {
|
|
451
|
+
const normalizedMode = String(proposalMode || '').trim().toLowerCase();
|
|
452
|
+
if (normalizedMode === 'simple' || normalizedMode === 'full') return normalizedMode;
|
|
453
|
+
const fullLayout = files.some((file) => /^specs\/[^/]+\/(?:spec|design|tasks?)\.md$/i.test(file.relativePath));
|
|
454
|
+
if (normalizedMode) {
|
|
455
|
+
diagnostics.push({
|
|
456
|
+
code: DIAGNOSTIC_CODES.PROFILE_INVALID,
|
|
457
|
+
severity: 'error',
|
|
458
|
+
message: `proposal.md mode 非法: ${proposalMode}`,
|
|
459
|
+
file: 'proposal.md',
|
|
460
|
+
line: 1,
|
|
461
|
+
});
|
|
462
|
+
} else {
|
|
463
|
+
diagnostics.push({
|
|
464
|
+
code: DIAGNOSTIC_CODES.PROFILE_INFERRED,
|
|
465
|
+
severity: 'warning',
|
|
466
|
+
message: 'proposal.md 缺少 mode,已按旧产物目录形态推断 profile',
|
|
467
|
+
file: 'proposal.md',
|
|
468
|
+
line: 1,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return fullLayout ? 'full' : 'simple';
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function structuralAnchor(prefix, key) {
|
|
475
|
+
return `${prefix}-${sha256(key).slice(0, 16).toUpperCase()}`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function addStructuralEntities(target, files, options = {}) {
|
|
479
|
+
const businessEntities = target.entities.slice();
|
|
480
|
+
const descriptors = [];
|
|
481
|
+
const changeScope = target.changeId || target.changeName;
|
|
482
|
+
for (const file of files) {
|
|
483
|
+
const key = `artifact:${changeScope}:${file.relativePath}`;
|
|
484
|
+
descriptors.push({
|
|
485
|
+
key,
|
|
486
|
+
anchor_id: structuralAnchor('ART', key),
|
|
487
|
+
type: 'Artifact',
|
|
488
|
+
content_hash: file.contentHash,
|
|
489
|
+
file,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
for (const entity of businessEntities) {
|
|
493
|
+
const key = `section:${changeScope}:${entity.source.file}#${entity.id}`;
|
|
494
|
+
descriptors.push({
|
|
495
|
+
key,
|
|
496
|
+
anchor_id: structuralAnchor('SEC', key),
|
|
497
|
+
type: 'DocumentSection',
|
|
498
|
+
content_hash: entity.content_hash,
|
|
499
|
+
entity,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const registry = ensureStructuralIdentities(target.changeDir, descriptors, {
|
|
504
|
+
persist: options.persistStructuralIdentities !== false,
|
|
505
|
+
});
|
|
506
|
+
const artifactByFile = new Map();
|
|
507
|
+
const sectionByEntity = new Map();
|
|
508
|
+
for (const descriptor of descriptors) {
|
|
509
|
+
const identity = registry.identities[descriptor.key];
|
|
510
|
+
if (!identity) continue;
|
|
511
|
+
if (descriptor.type === 'Artifact') {
|
|
512
|
+
const artifact = {
|
|
513
|
+
id: identity.anchor_id,
|
|
514
|
+
anchor_id: identity.anchor_id,
|
|
515
|
+
type: 'Artifact',
|
|
516
|
+
name: descriptor.file.relativePath,
|
|
517
|
+
entity_id: identity.entity_id,
|
|
518
|
+
version_id: identity.version_id,
|
|
519
|
+
delta_state: 'added',
|
|
520
|
+
content_hash: descriptor.content_hash,
|
|
521
|
+
attributes: { artifact_type: artifactType(descriptor.file.relativePath), path: descriptor.file.relativePath },
|
|
522
|
+
source: sourceFor(descriptor.file, 1, identity.anchor_id),
|
|
523
|
+
};
|
|
524
|
+
target.entities.push(artifact);
|
|
525
|
+
artifactByFile.set(descriptor.file.relativePath, artifact);
|
|
526
|
+
} else {
|
|
527
|
+
const owner = descriptor.entity;
|
|
528
|
+
const section = {
|
|
529
|
+
id: identity.anchor_id,
|
|
530
|
+
anchor_id: identity.anchor_id,
|
|
531
|
+
type: 'DocumentSection',
|
|
532
|
+
name: `${owner.source.file}#${owner.id}`,
|
|
533
|
+
entity_id: identity.entity_id,
|
|
534
|
+
version_id: identity.version_id,
|
|
535
|
+
delta_state: 'added',
|
|
536
|
+
content_hash: descriptor.content_hash,
|
|
537
|
+
attributes: { artifact_path: owner.source.file, entity_anchor: owner.id },
|
|
538
|
+
source: { ...owner.source, anchor_id: identity.anchor_id },
|
|
539
|
+
};
|
|
540
|
+
target.entities.push(section);
|
|
541
|
+
sectionByEntity.set(owner.id, section);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const filesByPath = new Map(files.map((file) => [file.relativePath, file]));
|
|
546
|
+
for (const entity of businessEntities) {
|
|
547
|
+
const artifact = artifactByFile.get(entity.source.file);
|
|
548
|
+
const section = sectionByEntity.get(entity.id);
|
|
549
|
+
const file = filesByPath.get(entity.source.file);
|
|
550
|
+
if (artifact && file) addRelation(target, 'declares', artifact.id, entity.id, file, entity.source.line);
|
|
551
|
+
if (section && file) addRelation(target, 'sourcedFrom', entity.id, section.id, file, entity.source.line);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function parseChangeArtifacts(projectRoot, changeName, options = {}) {
|
|
556
|
+
const normalizedRoot = path.resolve(projectRoot || process.cwd());
|
|
557
|
+
const safeName = safeChangeName(changeName);
|
|
558
|
+
const changeDir = options.changeDir
|
|
559
|
+
? path.resolve(options.changeDir)
|
|
560
|
+
: path.join(normalizedRoot, 'openspec', 'changes', safeName);
|
|
561
|
+
if (!fs.existsSync(changeDir)) {
|
|
562
|
+
throw new Error(`变更目录不存在: ${changeDir}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const files = scanArtifactFiles(changeDir);
|
|
566
|
+
const target = {
|
|
567
|
+
projectRoot: normalizedRoot,
|
|
568
|
+
changeDir,
|
|
569
|
+
changeName: safeName,
|
|
570
|
+
changeId: '',
|
|
571
|
+
profile: 'simple',
|
|
572
|
+
proposalMode: '',
|
|
573
|
+
artifacts: [],
|
|
574
|
+
files: files.map((file) => ({ path: file.relativePath, content_hash: file.contentHash })),
|
|
575
|
+
entities: [],
|
|
576
|
+
relations: [],
|
|
577
|
+
inheritedReferences: [],
|
|
578
|
+
inheritedRelations: [],
|
|
579
|
+
diagnostics: [],
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
for (const file of files) {
|
|
583
|
+
const type = artifactType(file.relativePath);
|
|
584
|
+
const lines = file.content.split('\n');
|
|
585
|
+
const frontmatter = parseFrontmatter(lines);
|
|
586
|
+
target.artifacts.push({
|
|
587
|
+
type,
|
|
588
|
+
path: file.relativePath,
|
|
589
|
+
content_hash: file.contentHash,
|
|
590
|
+
capability_id: String(frontmatter['capability-id'] || '').trim().toUpperCase() || undefined,
|
|
591
|
+
});
|
|
592
|
+
if (type === 'proposal') parseProposal(target, file, lines, frontmatter);
|
|
593
|
+
if (type === 'spec') parseSpec(target, file, lines, frontmatter);
|
|
594
|
+
if (type === 'design') parseDesign(target, file, lines, frontmatter);
|
|
595
|
+
if (type === 'tasks') parseTasks(target, file, lines, frontmatter);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
target.profile = inferProfile(files, target.proposalMode, target.diagnostics);
|
|
599
|
+
addStructuralEntities(target, files, options);
|
|
600
|
+
|
|
601
|
+
return target;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
module.exports = {
|
|
605
|
+
ENTITY_ID_PATTERN,
|
|
606
|
+
safeChangeName,
|
|
607
|
+
stripInlineYamlComment,
|
|
608
|
+
unwrapScalar,
|
|
609
|
+
parseFrontmatter,
|
|
610
|
+
scanArtifactFiles,
|
|
611
|
+
artifactType,
|
|
612
|
+
extractIds,
|
|
613
|
+
stripIdentityMetadata,
|
|
614
|
+
parseIdentityBlock,
|
|
615
|
+
sectionEnd,
|
|
616
|
+
statementContentEnd,
|
|
617
|
+
inferProfile,
|
|
618
|
+
structuralAnchor,
|
|
619
|
+
addStructuralEntities,
|
|
620
|
+
parseChangeArtifacts,
|
|
621
|
+
};
|