kld-sdd 2.6.6 → 2.6.8
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/bin/kld-sdd-init.js +39 -3
- package/lib/init.js +320 -45
- package/lib/workspace-layout.js +2 -0
- package/package.json +2 -2
- package/skywalk-sdd/context-client.cjs +12 -1
- package/skywalk-sdd/ontology/active-changes.cjs +297 -0
- package/skywalk-sdd/ontology/archive-package.cjs +95 -6
- package/skywalk-sdd/ontology/artifact-parser.cjs +50 -7
- package/skywalk-sdd/ontology/change-key.cjs +241 -0
- package/skywalk-sdd/ontology/cli.cjs +154 -0
- package/skywalk-sdd/ontology/external-key.cjs +159 -0
- package/skywalk-sdd/ontology/list-changes.cjs +110 -0
- package/skywalk-sdd/ontology/modules.cjs +167 -0
- package/skywalk-sdd/ontology/naming-diagnose.cjs +594 -0
- package/skywalk-sdd/ontology/normalizer.cjs +4 -0
- package/skywalk-sdd/ontology/schema.cjs +5 -0
- package/skywalk-sdd/ontology/sdd-config.cjs +335 -0
- package/skywalk-sdd/ontology/traceability-validator.cjs +175 -0
- package/skywalk-sdd/ontology/workspace-layout.cjs +194 -0
- package/templates/dot-sdd.yaml +8 -0
- package/templates/git-hooks/commit-msg-sdd-trailer.cjs +224 -0
- package/templates/modules.yaml +13 -0
- package/templates/openspec/proposal.md +12 -3
- package/templates/openspec/spec.md +3 -3
- package/templates/sdd.config.yaml +12 -0
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +3 -3
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +1 -1
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +10 -0
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +47 -3
- package/templates/skills/kld-sdd/opsx-explore/SKILL.md +37 -17
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +13 -14
- package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +13 -0
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +26 -15
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +61 -23
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +8 -2
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +1 -0
- package/templates/skills/kld-sdd/opsx-tdd-core/checklist.md +1 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const CODES = {
|
|
8
|
+
MISSING: 'SDD_YAML_MISSING',
|
|
9
|
+
FORMAT_INVALID: 'SDD_YAML_FORMAT_INVALID',
|
|
10
|
+
SPEC_PATH_MISSING: 'SDD_SPEC_PATH_MISSING',
|
|
11
|
+
SPEC_PATH_INVALID: 'SDD_SPEC_PATH_INVALID',
|
|
12
|
+
REMOTE_MISMATCH: 'SDD_SPEC_REMOTE_MISMATCH',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const LAYOUTS = {
|
|
16
|
+
MULTI: 'multi',
|
|
17
|
+
MONO: 'mono',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function stripYamlQuotes(value) {
|
|
21
|
+
const text = String(value == null ? '' : value).trim();
|
|
22
|
+
if (
|
|
23
|
+
(text.startsWith('"') && text.endsWith('"')) ||
|
|
24
|
+
(text.startsWith("'") && text.endsWith("'"))
|
|
25
|
+
) {
|
|
26
|
+
return text.slice(1, -1);
|
|
27
|
+
}
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minimal parser for .sdd.yaml,两种布局:
|
|
33
|
+
*
|
|
34
|
+
* 多仓(默认):spec 是另一个 clone
|
|
35
|
+
* version: 1
|
|
36
|
+
* spec_repository: git@host:group/repo.git
|
|
37
|
+
*
|
|
38
|
+
* 单仓:openspec 与代码同仓,无外部 spec 可指
|
|
39
|
+
* version: 1
|
|
40
|
+
* layout: mono
|
|
41
|
+
*/
|
|
42
|
+
function parseSddYaml(text) {
|
|
43
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
44
|
+
let version = null;
|
|
45
|
+
let specRepository = null;
|
|
46
|
+
let layout = null;
|
|
47
|
+
|
|
48
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
49
|
+
const raw = lines[index];
|
|
50
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
51
|
+
if (!line) continue;
|
|
52
|
+
|
|
53
|
+
const versionMatch = /^version:\s*(\d+)\s*$/.exec(line);
|
|
54
|
+
if (versionMatch) {
|
|
55
|
+
version = Number(versionMatch[1]);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const layoutMatch = /^layout:\s*(.+?)\s*$/.exec(line);
|
|
60
|
+
if (layoutMatch) {
|
|
61
|
+
const value = stripYamlQuotes(layoutMatch[1]).toLowerCase();
|
|
62
|
+
if (value !== LAYOUTS.MONO && value !== LAYOUTS.MULTI) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
code: CODES.FORMAT_INVALID,
|
|
66
|
+
message: `.sdd.yaml layout 只能是 mono 或 multi,实际: ${value}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
layout = value;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const repoMatch = /^spec_repository:\s*(.+?)\s*$/.exec(line);
|
|
74
|
+
if (repoMatch) {
|
|
75
|
+
specRepository = stripYamlQuotes(repoMatch[1]);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
code: CODES.FORMAT_INVALID,
|
|
82
|
+
message: `.sdd.yaml 第 ${index + 1} 行无法解析: ${raw}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (version == null) {
|
|
87
|
+
return { ok: false, code: CODES.FORMAT_INVALID, message: '.sdd.yaml 缺少 version' };
|
|
88
|
+
}
|
|
89
|
+
if (version !== 1) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: CODES.FORMAT_INVALID,
|
|
93
|
+
message: `不支持的 .sdd.yaml version: ${version}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const resolvedLayout = layout || LAYOUTS.MULTI;
|
|
97
|
+
// 单仓没有外部 spec 仓库可指,spec_repository 可省略
|
|
98
|
+
if (resolvedLayout === LAYOUTS.MULTI && !specRepository) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
code: CODES.FORMAT_INVALID,
|
|
102
|
+
message: '.sdd.yaml 缺少 spec_repository(单仓请写 layout: mono)',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
version,
|
|
109
|
+
layout: resolvedLayout,
|
|
110
|
+
spec_repository: specRepository || '',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function loadSddYaml(codeRepoRoot) {
|
|
115
|
+
const filePath = path.join(codeRepoRoot, '.sdd.yaml');
|
|
116
|
+
if (!fs.existsSync(filePath)) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
code: CODES.MISSING,
|
|
120
|
+
message: `.sdd.yaml 不存在: ${filePath}`,
|
|
121
|
+
path: filePath,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const parsed = parseSddYaml(fs.readFileSync(filePath, 'utf8'));
|
|
125
|
+
if (!parsed.ok) return { ...parsed, path: filePath };
|
|
126
|
+
return { ...parsed, path: filePath };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 单仓声明:.sdd.yaml 显式写了 layout: mono */
|
|
130
|
+
function isMonoLayout(repoRoot) {
|
|
131
|
+
const loaded = loadSddYaml(repoRoot);
|
|
132
|
+
return loaded.ok && loaded.layout === LAYOUTS.MONO;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function gitConfig(cwd, key) {
|
|
136
|
+
try {
|
|
137
|
+
return execFileSync('git', ['config', '--local', '--get', key], {
|
|
138
|
+
cwd,
|
|
139
|
+
encoding: 'utf8',
|
|
140
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
141
|
+
}).trim();
|
|
142
|
+
} catch {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function gitRemoteUrl(cwd, remote = 'origin') {
|
|
148
|
+
try {
|
|
149
|
+
return execFileSync('git', ['remote', 'get-url', remote], {
|
|
150
|
+
cwd,
|
|
151
|
+
encoding: 'utf8',
|
|
152
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
153
|
+
}).trim();
|
|
154
|
+
} catch {
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeRemoteUrl(url) {
|
|
160
|
+
if (!url) return '';
|
|
161
|
+
let value = String(url).trim().replace(/\\/g, '/');
|
|
162
|
+
value = value.replace(/\.git$/i, '');
|
|
163
|
+
const sshMatch = /^git@([^:]+):(.+)$/.exec(value);
|
|
164
|
+
if (sshMatch) {
|
|
165
|
+
return `${sshMatch[1].toLowerCase()}/${sshMatch[2].replace(/^\/+/, '').toLowerCase()}`;
|
|
166
|
+
}
|
|
167
|
+
const schemeMatch = /^https?:\/\/([^/]+)\/(.+)$/i.exec(value);
|
|
168
|
+
if (schemeMatch) {
|
|
169
|
+
return `${schemeMatch[1].toLowerCase()}/${schemeMatch[2].replace(/^\/+/, '').toLowerCase()}`;
|
|
170
|
+
}
|
|
171
|
+
return value.toLowerCase();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function remotesMatch(left, right) {
|
|
175
|
+
const a = normalizeRemoteUrl(left);
|
|
176
|
+
const b = normalizeRemoteUrl(right);
|
|
177
|
+
return Boolean(a && b && a === b);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveSpecPath(codeRepoRoot, env = process.env) {
|
|
181
|
+
const fromEnv = env.KLD_SDD_SPEC_PATH && String(env.KLD_SDD_SPEC_PATH).trim();
|
|
182
|
+
if (fromEnv) {
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
source: 'env',
|
|
186
|
+
path: path.resolve(fromEnv),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const fromGit = gitConfig(codeRepoRoot, 'sdd.specPath');
|
|
191
|
+
if (fromGit) {
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
source: 'git-config',
|
|
195
|
+
path: path.resolve(fromGit),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
code: CODES.SPEC_PATH_MISSING,
|
|
202
|
+
message: '未找到 spec clone 路径。请设置 KLD_SDD_SPEC_PATH 或 git config --local sdd.specPath <path>',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validateSpecPath(specPath) {
|
|
207
|
+
if (!specPath || !fs.existsSync(specPath)) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
code: CODES.SPEC_PATH_INVALID,
|
|
211
|
+
message: `spec 路径不存在: ${specPath}`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (!fs.existsSync(path.join(specPath, '.git'))) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
code: CODES.SPEC_PATH_INVALID,
|
|
218
|
+
message: `spec 路径不是 Git 仓库: ${specPath}`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, path: specPath };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function setSpecPath(codeRepoRoot, specPath) {
|
|
225
|
+
const absolute = path.resolve(specPath);
|
|
226
|
+
const valid = validateSpecPath(absolute);
|
|
227
|
+
if (!valid.ok) return valid;
|
|
228
|
+
execFileSync('git', ['config', '--local', 'sdd.specPath', absolute], {
|
|
229
|
+
cwd: codeRepoRoot,
|
|
230
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
231
|
+
});
|
|
232
|
+
return { ok: true, path: absolute };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function resolveCodeRepoContext(codeRepoRoot, env = process.env) {
|
|
236
|
+
const sddYaml = loadSddYaml(codeRepoRoot);
|
|
237
|
+
const specPathResult = resolveSpecPath(codeRepoRoot, env);
|
|
238
|
+
if (!specPathResult.ok) {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
code: specPathResult.code,
|
|
242
|
+
message: specPathResult.message,
|
|
243
|
+
sddYaml,
|
|
244
|
+
specPath: null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const pathValid = validateSpecPath(specPathResult.path);
|
|
249
|
+
if (!pathValid.ok) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
code: pathValid.code,
|
|
253
|
+
message: pathValid.message,
|
|
254
|
+
sddYaml,
|
|
255
|
+
specPath: specPathResult.path,
|
|
256
|
+
specPathSource: specPathResult.source,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const result = {
|
|
261
|
+
ok: true,
|
|
262
|
+
sddYaml,
|
|
263
|
+
specPath: pathValid.path,
|
|
264
|
+
specPathSource: specPathResult.source,
|
|
265
|
+
specRemote: gitRemoteUrl(pathValid.path),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (sddYaml.ok && result.specRemote) {
|
|
269
|
+
result.remoteMatches = remotesMatch(sddYaml.spec_repository, result.specRemote);
|
|
270
|
+
if (!result.remoteMatches) {
|
|
271
|
+
result.ok = false;
|
|
272
|
+
result.code = CODES.REMOTE_MISMATCH;
|
|
273
|
+
result.message = `.sdd.yaml spec_repository 与本地 spec remote 不一致: ${sddYaml.spec_repository} vs ${result.specRemote}`;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function gitHeadSha(repoRoot) {
|
|
281
|
+
try {
|
|
282
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
283
|
+
cwd: repoRoot,
|
|
284
|
+
encoding: 'utf8',
|
|
285
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
286
|
+
}).trim();
|
|
287
|
+
} catch {
|
|
288
|
+
return '';
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function gitWorkingTreeClean(repoRoot) {
|
|
293
|
+
try {
|
|
294
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
295
|
+
cwd: repoRoot,
|
|
296
|
+
encoding: 'utf8',
|
|
297
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
298
|
+
}).trim();
|
|
299
|
+
return status === '';
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function objectExists(repoRoot, sha) {
|
|
306
|
+
if (!sha) return false;
|
|
307
|
+
try {
|
|
308
|
+
execFileSync('git', ['cat-file', '-e', `${sha}^{commit}`], {
|
|
309
|
+
cwd: repoRoot,
|
|
310
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
311
|
+
});
|
|
312
|
+
return true;
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
CODES,
|
|
320
|
+
LAYOUTS,
|
|
321
|
+
parseSddYaml,
|
|
322
|
+
isMonoLayout,
|
|
323
|
+
loadSddYaml,
|
|
324
|
+
gitConfig,
|
|
325
|
+
gitRemoteUrl,
|
|
326
|
+
normalizeRemoteUrl,
|
|
327
|
+
remotesMatch,
|
|
328
|
+
resolveSpecPath,
|
|
329
|
+
validateSpecPath,
|
|
330
|
+
setSpecPath,
|
|
331
|
+
resolveCodeRepoContext,
|
|
332
|
+
gitHeadSha,
|
|
333
|
+
gitWorkingTreeClean,
|
|
334
|
+
objectExists,
|
|
335
|
+
};
|
|
@@ -453,8 +453,94 @@ function validateContinuityAndExternalRefs(facts, diagnostics, options = {}) {
|
|
|
453
453
|
}
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
const {
|
|
457
|
+
validate,
|
|
458
|
+
normalize,
|
|
459
|
+
parseScenario,
|
|
460
|
+
} = require('./external-key.cjs');
|
|
461
|
+
|
|
462
|
+
// 仅当 frontmatter 显式出现 requirement-refs / numbering-waiver 时启用入场门禁
|
|
463
|
+
// (新模板默认带 requirement-refs;旧夹具无该节则不强制)
|
|
464
|
+
const numberingDeclared = Boolean(facts.numbering_gate)
|
|
465
|
+
|| (facts.numbering_waiver && typeof facts.numbering_waiver === 'object');
|
|
466
|
+
const requirementRefs = Array.isArray(facts.requirement_refs) ? facts.requirement_refs : [];
|
|
467
|
+
const waiverReason = String(
|
|
468
|
+
(facts.numbering_waiver && (facts.numbering_waiver.reason || facts.numbering_waiver.Reason)) || '',
|
|
469
|
+
).trim();
|
|
470
|
+
if (numberingDeclared) {
|
|
471
|
+
if (requirementRefs.length === 0 && !waiverReason) {
|
|
472
|
+
diagnostics.push(diagnostic(
|
|
473
|
+
DIAGNOSTIC_CODES.CONTINUITY_DECISION_REQUIRED,
|
|
474
|
+
'error',
|
|
475
|
+
'缺少格式合规的 requirement-refs,且未提供 numbering-waiver.reason',
|
|
476
|
+
{
|
|
477
|
+
file: 'proposal.md',
|
|
478
|
+
suggestion: '申报 REQ 号,或填写 numbering-waiver.reason(探索性/纯内部重构)',
|
|
479
|
+
},
|
|
480
|
+
));
|
|
481
|
+
}
|
|
482
|
+
if (requirementRefs.length === 0 && waiverReason) {
|
|
483
|
+
diagnostics.push(diagnostic(
|
|
484
|
+
DIAGNOSTIC_CODES.NUMBERING_WAIVER_ACTIVE,
|
|
485
|
+
'warning',
|
|
486
|
+
`编号豁免生效:${waiverReason}(本轮不种桥)`,
|
|
487
|
+
{ file: 'proposal.md' },
|
|
488
|
+
));
|
|
489
|
+
}
|
|
490
|
+
if (requirementRefs.length > 0 && waiverReason) {
|
|
491
|
+
diagnostics.push(diagnostic(
|
|
492
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_FORMAT_INVALID,
|
|
493
|
+
'error',
|
|
494
|
+
'numbering-waiver 与非空 requirement-refs 互斥',
|
|
495
|
+
{ file: 'proposal.md', suggestion: '清空其一' },
|
|
496
|
+
));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const declaredReqIds = new Set();
|
|
501
|
+
const featureByReq = new Map();
|
|
502
|
+
for (const ref of requirementRefs) {
|
|
503
|
+
const objectType = String(ref.object_type || 'requirement').toLowerCase();
|
|
504
|
+
const format = validate(ref.external_id, objectType);
|
|
505
|
+
if (!format.ok) {
|
|
506
|
+
diagnostics.push(diagnostic(
|
|
507
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_FORMAT_INVALID,
|
|
508
|
+
'error',
|
|
509
|
+
format.message,
|
|
510
|
+
{ file: 'proposal.md', suggestion: '回需求管理系统核实/换发编号,禁止改写' },
|
|
511
|
+
));
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const normalizedReq = normalize(ref.external_id, objectType);
|
|
515
|
+
if (objectType === 'requirement') {
|
|
516
|
+
declaredReqIds.add(normalizedReq);
|
|
517
|
+
if (ref.feature_id_invalid) {
|
|
518
|
+
diagnostics.push(diagnostic(
|
|
519
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_FORMAT_INVALID,
|
|
520
|
+
'error',
|
|
521
|
+
`feature-id 不合规: ${ref.feature_id_invalid}`,
|
|
522
|
+
{ file: 'proposal.md', suggestion: '回需求管理系统核实 FEAT 号,禁止静默丢弃' },
|
|
523
|
+
));
|
|
524
|
+
} else if (ref.feature_id) {
|
|
525
|
+
if (featureByReq.has(normalizedReq) && featureByReq.get(normalizedReq) !== ref.feature_id) {
|
|
526
|
+
diagnostics.push(diagnostic(
|
|
527
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_FEATURE_UNDECLARED,
|
|
528
|
+
'error',
|
|
529
|
+
`同一 Change 内同一 REQ 申报了多个 feature-id: ${normalizedReq}`,
|
|
530
|
+
{ file: 'proposal.md' },
|
|
531
|
+
));
|
|
532
|
+
}
|
|
533
|
+
featureByReq.set(normalizedReq, normalize(ref.feature_id, 'feature'));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
456
538
|
const bindingKeys = new Map();
|
|
539
|
+
const scenarioKeysInChange = new Set();
|
|
540
|
+
const removedScenarioKeys = new Set();
|
|
457
541
|
for (const entity of facts.entities || []) {
|
|
542
|
+
const declaredFeatures = new Set();
|
|
543
|
+
const featureBindings = new Set();
|
|
458
544
|
for (const ref of entity.external_refs || []) {
|
|
459
545
|
const objectType = String(ref.object_type || '').toLowerCase();
|
|
460
546
|
const allowedTypes = allowed[objectType];
|
|
@@ -475,6 +561,59 @@ function validateContinuityAndExternalRefs(facts, diagnostics, options = {}) {
|
|
|
475
561
|
{ ...sourceContext(entity), entity_id: entity.id },
|
|
476
562
|
));
|
|
477
563
|
}
|
|
564
|
+
const format = validate(ref.external_id, objectType);
|
|
565
|
+
if (!format.ok) {
|
|
566
|
+
diagnostics.push(diagnostic(
|
|
567
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_FORMAT_INVALID,
|
|
568
|
+
'error',
|
|
569
|
+
`${format.message} (${entity.id})`,
|
|
570
|
+
{
|
|
571
|
+
...sourceContext(entity),
|
|
572
|
+
entity_id: entity.id,
|
|
573
|
+
suggestion: '编号不合规回需求系统换发;场景键回 opsx-spec 修正',
|
|
574
|
+
},
|
|
575
|
+
));
|
|
576
|
+
}
|
|
577
|
+
if (ref.feature_id) {
|
|
578
|
+
const featureFormat = validate(ref.feature_id, 'feature');
|
|
579
|
+
if (!featureFormat.ok) {
|
|
580
|
+
diagnostics.push(diagnostic(
|
|
581
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_FORMAT_INVALID,
|
|
582
|
+
'error',
|
|
583
|
+
`requirement.feature_id 不合规: ${featureFormat.message} (${entity.id})`,
|
|
584
|
+
{ ...sourceContext(entity), entity_id: entity.id },
|
|
585
|
+
));
|
|
586
|
+
} else {
|
|
587
|
+
declaredFeatures.add(normalize(ref.feature_id, 'feature'));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (objectType === 'feature') {
|
|
591
|
+
featureBindings.add(normalize(ref.external_id, 'feature'));
|
|
592
|
+
}
|
|
593
|
+
if (objectType === 'scenario') {
|
|
594
|
+
const normalizedScenario = normalize(ref.external_id, 'scenario');
|
|
595
|
+
const parts = parseScenario(normalizedScenario);
|
|
596
|
+
if (parts && declaredReqIds.size > 0 && !declaredReqIds.has(parts.requirementId)) {
|
|
597
|
+
diagnostics.push(diagnostic(
|
|
598
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_SCOPE_MISMATCH,
|
|
599
|
+
'error',
|
|
600
|
+
`场景键 REQ 前缀不在 frontmatter requirement-refs 申报集合: ${normalizedScenario}`,
|
|
601
|
+
{ ...sourceContext(entity), entity_id: entity.id },
|
|
602
|
+
));
|
|
603
|
+
}
|
|
604
|
+
if (scenarioKeysInChange.has(normalizedScenario)) {
|
|
605
|
+
diagnostics.push(diagnostic(
|
|
606
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_SEQ_REUSED,
|
|
607
|
+
'error',
|
|
608
|
+
`本 Change 内 SCN 键重复: ${normalizedScenario}`,
|
|
609
|
+
{ ...sourceContext(entity), entity_id: entity.id },
|
|
610
|
+
));
|
|
611
|
+
}
|
|
612
|
+
scenarioKeysInChange.add(normalizedScenario);
|
|
613
|
+
if (String(entity.delta_state || '').toLowerCase() === 'removed') {
|
|
614
|
+
removedScenarioKeys.add(normalizedScenario);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
478
617
|
const key = `${ref.system}|${objectType}|${ref.external_id}|${entity.anchor_id || entity.id}`;
|
|
479
618
|
const prior = bindingKeys.get(key);
|
|
480
619
|
if (prior && prior !== entity.entity_id) {
|
|
@@ -492,6 +631,42 @@ function validateContinuityAndExternalRefs(facts, diagnostics, options = {}) {
|
|
|
492
631
|
bindingKeys.set(key, entity.entity_id);
|
|
493
632
|
}
|
|
494
633
|
}
|
|
634
|
+
for (const featureId of featureBindings) {
|
|
635
|
+
if (!declaredFeatures.has(featureId)) {
|
|
636
|
+
diagnostics.push(diagnostic(
|
|
637
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_FEATURE_UNDECLARED,
|
|
638
|
+
'error',
|
|
639
|
+
`feature 绑定无法与同实体 requirement.feature_id 配对: ${featureId} (${entity.id})`,
|
|
640
|
+
{ ...sourceContext(entity), entity_id: entity.id },
|
|
641
|
+
));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
for (const featureId of declaredFeatures) {
|
|
645
|
+
if (!featureBindings.has(featureId)) {
|
|
646
|
+
diagnostics.push(diagnostic(
|
|
647
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_FEATURE_UNDECLARED,
|
|
648
|
+
'error',
|
|
649
|
+
`requirement 申报了 feature_id 但缺少对应 feature 绑定: ${featureId} (${entity.id})`,
|
|
650
|
+
{ ...sourceContext(entity), entity_id: entity.id },
|
|
651
|
+
));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
for (const entity of facts.entities || []) {
|
|
657
|
+
if (String(entity.delta_state || '').toLowerCase() === 'removed') continue;
|
|
658
|
+
for (const ref of entity.external_refs || []) {
|
|
659
|
+
if (String(ref.object_type || '').toLowerCase() !== 'scenario') continue;
|
|
660
|
+
const key = normalize(ref.external_id, 'scenario');
|
|
661
|
+
if (removedScenarioKeys.has(key) && String(entity.delta_state || '').toLowerCase() !== 'removed') {
|
|
662
|
+
diagnostics.push(diagnostic(
|
|
663
|
+
DIAGNOSTIC_CODES.EXTERNAL_KEY_SEQ_REUSED,
|
|
664
|
+
'error',
|
|
665
|
+
`复用了 removed 墓碑 SCN 号: ${key}`,
|
|
666
|
+
{ ...sourceContext(entity), entity_id: entity.id },
|
|
667
|
+
));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
495
670
|
}
|
|
496
671
|
}
|
|
497
672
|
|