kld-sdd 2.6.6 → 2.6.7
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/package.json +2 -2
- package/skywalk-sdd/context-client.cjs +12 -1
- package/skywalk-sdd/ontology/archive-package.cjs +95 -6
- package/skywalk-sdd/ontology/artifact-parser.cjs +50 -7
- package/skywalk-sdd/ontology/cli.cjs +19 -0
- package/skywalk-sdd/ontology/external-key.cjs +159 -0
- package/skywalk-sdd/ontology/normalizer.cjs +4 -0
- package/skywalk-sdd/ontology/schema.cjs +5 -0
- package/skywalk-sdd/ontology/traceability-validator.cjs +175 -0
- package/templates/openspec/proposal.md +5 -2
- package/templates/openspec/spec.md +3 -3
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +8 -1
- 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 +19 -10
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kld-sdd",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.7",
|
|
4
4
|
"description": "KLD SDD OpenSpec 项目初始化工具 - 一键部署 SDD skills",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"kld-sdd-init": "bin/kld-sdd-init.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs"
|
|
11
|
+
"test": "node test/external-key.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs"
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
14
14
|
"kld",
|
|
@@ -132,7 +132,18 @@ function buildPayload(args, mode) {
|
|
|
132
132
|
if (args['entity-type']) payload.entityType = args['entity-type'];
|
|
133
133
|
if (args['external-system']) payload.externalSystem = args['external-system'];
|
|
134
134
|
if (args['external-object-type']) payload.externalObjectType = args['external-object-type'];
|
|
135
|
-
if (args['external-id'])
|
|
135
|
+
if (args['external-id']) {
|
|
136
|
+
const externalKey = require('./ontology/external-key.cjs');
|
|
137
|
+
const objectType = args['external-object-type'] || 'requirement';
|
|
138
|
+
const check = externalKey.validate(args['external-id'], objectType);
|
|
139
|
+
if (!check.ok) {
|
|
140
|
+
throw new Error(`${check.code}: ${check.message}`);
|
|
141
|
+
}
|
|
142
|
+
payload.externalId = externalKey.normalize(args['external-id'], objectType);
|
|
143
|
+
if (payload.externalObjectType) {
|
|
144
|
+
payload.externalObjectType = String(payload.externalObjectType).trim().toLowerCase();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
136
147
|
if (args['canonical-key']) payload.canonicalKey = args['canonical-key'];
|
|
137
148
|
return payload;
|
|
138
149
|
}
|
|
@@ -101,26 +101,107 @@ function requiredText(value, field, owner) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
function normalizeExternalRefs(refs) {
|
|
104
|
+
const { normalize } = require('./external-key.cjs');
|
|
104
105
|
if (!Array.isArray(refs)) return [];
|
|
105
106
|
const seen = new Set();
|
|
106
107
|
const result = [];
|
|
107
108
|
for (const ref of refs) {
|
|
108
109
|
const system = String(ref.system || ref.system_name || '').trim();
|
|
109
110
|
const objectType = String(ref.object_type || ref.objectType || '').trim().toLowerCase();
|
|
110
|
-
const
|
|
111
|
-
if (!system || !objectType || !
|
|
111
|
+
const rawExternalId = String(ref.external_id || ref.externalId || '').trim();
|
|
112
|
+
if (!system || !objectType || !rawExternalId) continue;
|
|
113
|
+
const externalId = normalize(rawExternalId, objectType) || rawExternalId;
|
|
112
114
|
const key = `${system}\0${objectType}\0${externalId}`;
|
|
113
115
|
if (seen.has(key)) continue;
|
|
114
116
|
seen.add(key);
|
|
115
|
-
|
|
117
|
+
const entry = {
|
|
116
118
|
system,
|
|
117
119
|
object_type: objectType,
|
|
118
120
|
external_id: externalId,
|
|
119
|
-
}
|
|
121
|
+
};
|
|
122
|
+
const rawFeatureId = ref.feature_id || ref.featureId;
|
|
123
|
+
if (rawFeatureId && objectType === 'requirement') {
|
|
124
|
+
entry.feature_id = normalize(String(rawFeatureId), 'feature') || String(rawFeatureId).trim();
|
|
125
|
+
}
|
|
126
|
+
result.push(entry);
|
|
120
127
|
}
|
|
121
128
|
return result;
|
|
122
129
|
}
|
|
123
130
|
|
|
131
|
+
function expandRequirementRefsWithFeatures(requirementRefs) {
|
|
132
|
+
const expanded = [];
|
|
133
|
+
for (const ref of requirementRefs) {
|
|
134
|
+
expanded.push(ref);
|
|
135
|
+
if (ref.object_type === 'requirement' && ref.feature_id) {
|
|
136
|
+
expanded.push({
|
|
137
|
+
system: ref.system,
|
|
138
|
+
object_type: 'feature',
|
|
139
|
+
external_id: ref.feature_id,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return normalizeExternalRefs(expanded);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertExternalKeyFinalGate(entities) {
|
|
147
|
+
const {
|
|
148
|
+
validate,
|
|
149
|
+
normalize,
|
|
150
|
+
parseScenario,
|
|
151
|
+
CODES,
|
|
152
|
+
} = require('./external-key.cjs');
|
|
153
|
+
const packageRequirementIds = new Set();
|
|
154
|
+
for (const entity of entities) {
|
|
155
|
+
for (const ref of entity.external_refs || []) {
|
|
156
|
+
if (ref.object_type === 'requirement') {
|
|
157
|
+
packageRequirementIds.add(normalize(ref.external_id, 'requirement'));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const entity of entities) {
|
|
162
|
+
const declaredFeatures = new Set();
|
|
163
|
+
const featureBindings = new Set();
|
|
164
|
+
for (const ref of entity.external_refs || []) {
|
|
165
|
+
const format = validate(ref.external_id, ref.object_type);
|
|
166
|
+
if (!format.ok) {
|
|
167
|
+
throw new Error(`${CODES.FORMAT_INVALID}: ${format.message} @ ${entity.anchor_id}`);
|
|
168
|
+
}
|
|
169
|
+
if (ref.feature_id) {
|
|
170
|
+
const featureFormat = validate(ref.feature_id, 'feature');
|
|
171
|
+
if (!featureFormat.ok) {
|
|
172
|
+
throw new Error(`${CODES.FORMAT_INVALID}: requirement.feature_id 不合规: ${featureFormat.message} @ ${entity.anchor_id}`);
|
|
173
|
+
}
|
|
174
|
+
declaredFeatures.add(normalize(ref.feature_id, 'feature'));
|
|
175
|
+
}
|
|
176
|
+
if (ref.object_type === 'feature') {
|
|
177
|
+
featureBindings.add(normalize(ref.external_id, 'feature'));
|
|
178
|
+
}
|
|
179
|
+
if (ref.object_type === 'scenario') {
|
|
180
|
+
const parts = parseScenario(ref.external_id);
|
|
181
|
+
if (!parts || !packageRequirementIds.has(parts.requirementId)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`${CODES.SCOPE_MISMATCH}: 场景键 REQ 前缀不在同包 requirement 绑定集合中: ${ref.external_id} @ ${entity.anchor_id}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const featureId of featureBindings) {
|
|
189
|
+
if (!declaredFeatures.has(featureId)) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`${CODES.FEATURE_UNDECLARED}: feature 绑定无法与同实体 requirement.feature_id 配对: ${featureId} @ ${entity.anchor_id}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const featureId of declaredFeatures) {
|
|
196
|
+
if (!featureBindings.has(featureId)) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`${CODES.FEATURE_UNDECLARED}: requirement 申报了 feature_id 但缺少对应 feature 绑定: ${featureId} @ ${entity.anchor_id}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
124
205
|
function canonicalEntity(archiveDir, entity, inheritedRequirementRefs = []) {
|
|
125
206
|
const anchorId = requiredText(entity.anchor_id || entity.id, 'anchor_id', '本体实体').toUpperCase();
|
|
126
207
|
const versionId = requiredText(
|
|
@@ -130,8 +211,15 @@ function canonicalEntity(archiveDir, entity, inheritedRequirementRefs = []) {
|
|
|
130
211
|
).toLowerCase();
|
|
131
212
|
const entityType = requiredText(entity.type, 'type', anchorId);
|
|
132
213
|
let externalRefs = normalizeExternalRefs(entity.external_refs || entity.externalRefs || []);
|
|
133
|
-
if (entityType === 'Capability'
|
|
134
|
-
|
|
214
|
+
if (entityType === 'Capability') {
|
|
215
|
+
const requirementSources = [
|
|
216
|
+
...externalRefs.filter((ref) => ref.object_type === 'requirement'),
|
|
217
|
+
...inheritedRequirementRefs,
|
|
218
|
+
];
|
|
219
|
+
externalRefs = normalizeExternalRefs([
|
|
220
|
+
...externalRefs,
|
|
221
|
+
...expandRequirementRefsWithFeatures(requirementSources),
|
|
222
|
+
]);
|
|
135
223
|
}
|
|
136
224
|
return {
|
|
137
225
|
anchor_id: anchorId,
|
|
@@ -237,6 +325,7 @@ function buildCanonicalFacts(archiveDir, snapshot, projectId, archiveId) {
|
|
|
237
325
|
if (entities.length === 0) {
|
|
238
326
|
throw new Error('archive-ontology.json 没有可导出的实体');
|
|
239
327
|
}
|
|
328
|
+
assertExternalKeyFinalGate(entities);
|
|
240
329
|
const entityByAnchor = new Map(entities.map((entity) => [entity.anchor_id, entity]));
|
|
241
330
|
const warningCounts = new Map();
|
|
242
331
|
const seenRelations = new Set();
|
|
@@ -201,15 +201,28 @@ function identityFromValues(values = {}) {
|
|
|
201
201
|
|
|
202
202
|
function flushRequirementRef(requirementRefs, currentRef) {
|
|
203
203
|
if (!currentRef) return null;
|
|
204
|
+
const { normalize, validate } = require('./external-key.cjs');
|
|
204
205
|
const system = currentRef.system;
|
|
205
206
|
const objectType = currentRef['object-type'] || currentRef.object_type;
|
|
206
207
|
const externalId = currentRef['external-id'] || currentRef.external_id;
|
|
208
|
+
const featureId = currentRef['feature-id'] || currentRef.feature_id;
|
|
207
209
|
if (system && objectType && externalId) {
|
|
208
|
-
|
|
210
|
+
const type = String(objectType).toLowerCase();
|
|
211
|
+
const entry = {
|
|
209
212
|
system,
|
|
210
|
-
object_type:
|
|
211
|
-
external_id: externalId,
|
|
212
|
-
}
|
|
213
|
+
object_type: type,
|
|
214
|
+
external_id: normalize(externalId, type) || String(externalId).trim(),
|
|
215
|
+
};
|
|
216
|
+
if (featureId) {
|
|
217
|
+
const featureCheck = validate(featureId, 'feature');
|
|
218
|
+
if (!featureCheck.ok) {
|
|
219
|
+
entry.feature_id_invalid = featureCheck.message;
|
|
220
|
+
entry.feature_id_raw = featureId;
|
|
221
|
+
} else {
|
|
222
|
+
entry.feature_id = normalize(featureId, 'feature');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
requirementRefs.push(entry);
|
|
213
226
|
}
|
|
214
227
|
return null;
|
|
215
228
|
}
|
|
@@ -218,8 +231,10 @@ function parseStructuredFrontmatter(lines) {
|
|
|
218
231
|
const flat = {};
|
|
219
232
|
const requirementRefs = [];
|
|
220
233
|
const continuity = {};
|
|
234
|
+
const numberingWaiver = {};
|
|
235
|
+
let numberingGate = false;
|
|
221
236
|
if (!lines.length || lines[0].trim() !== '---') {
|
|
222
|
-
return { flat, requirementRefs, continuity };
|
|
237
|
+
return { flat, requirementRefs, continuity, numberingWaiver, numberingGate };
|
|
223
238
|
}
|
|
224
239
|
let section = null;
|
|
225
240
|
let currentRef = null;
|
|
@@ -238,6 +253,13 @@ function parseStructuredFrontmatter(lines) {
|
|
|
238
253
|
flat[section] = value;
|
|
239
254
|
currentCap = null;
|
|
240
255
|
if (section === 'continuity' && value) continuity.kind = value;
|
|
256
|
+
if (section === 'numbering-waiver') {
|
|
257
|
+
numberingGate = true;
|
|
258
|
+
if (value) numberingWaiver.reason = value;
|
|
259
|
+
}
|
|
260
|
+
if (section === 'requirement-refs') {
|
|
261
|
+
numberingGate = true;
|
|
262
|
+
}
|
|
241
263
|
continue;
|
|
242
264
|
}
|
|
243
265
|
|
|
@@ -253,6 +275,12 @@ function parseStructuredFrontmatter(lines) {
|
|
|
253
275
|
continue;
|
|
254
276
|
}
|
|
255
277
|
|
|
278
|
+
if (section === 'numbering-waiver' && nested) {
|
|
279
|
+
const key = nested[1].toLowerCase().replace(/-/g, '_');
|
|
280
|
+
numberingWaiver[key] = unwrapScalar(stripInlineYamlComment(nested[2]));
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
256
284
|
if (section === 'continuity' && nested) {
|
|
257
285
|
const key = nested[1].toLowerCase().replace(/-/g, '_');
|
|
258
286
|
const value = unwrapScalar(stripInlineYamlComment(nested[2]));
|
|
@@ -280,7 +308,7 @@ function parseStructuredFrontmatter(lines) {
|
|
|
280
308
|
}
|
|
281
309
|
}
|
|
282
310
|
flushRequirementRef(requirementRefs, currentRef);
|
|
283
|
-
return { flat, requirementRefs, continuity };
|
|
311
|
+
return { flat, requirementRefs, continuity, numberingWaiver, numberingGate };
|
|
284
312
|
}
|
|
285
313
|
|
|
286
314
|
function parseIdentityBlock(lines, startIndex, endIndex = lines.length) {
|
|
@@ -324,6 +352,8 @@ function parseProposal(target, file, lines, frontmatter) {
|
|
|
324
352
|
target.proposalMode = String(mergedFrontmatter.mode || '').trim().toLowerCase();
|
|
325
353
|
target.requirementRefs = structured.requirementRefs;
|
|
326
354
|
target.continuity = structured.continuity;
|
|
355
|
+
target.numberingWaiver = structured.numberingWaiver || {};
|
|
356
|
+
target.numberingGate = Boolean(structured.numberingGate);
|
|
327
357
|
const changeId = String(mergedFrontmatter['change-id'] || '').trim().toUpperCase();
|
|
328
358
|
if (changeId) {
|
|
329
359
|
addEntity(
|
|
@@ -355,9 +385,20 @@ function parseProposal(target, file, lines, frontmatter) {
|
|
|
355
385
|
const capabilityId = match[1].toUpperCase();
|
|
356
386
|
const identity = parseIdentityBlock(lines, index + 1);
|
|
357
387
|
if (structured.requirementRefs.length > 0) {
|
|
388
|
+
const expanded = [];
|
|
389
|
+
for (const ref of structured.requirementRefs) {
|
|
390
|
+
expanded.push(ref);
|
|
391
|
+
if (ref.feature_id) {
|
|
392
|
+
expanded.push({
|
|
393
|
+
system: ref.system,
|
|
394
|
+
object_type: 'feature',
|
|
395
|
+
external_id: ref.feature_id,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
358
399
|
identity.external_refs = [
|
|
359
400
|
...(identity.external_refs || []),
|
|
360
|
-
...
|
|
401
|
+
...expanded,
|
|
361
402
|
];
|
|
362
403
|
}
|
|
363
404
|
const slug = unwrapScalar(match[2]);
|
|
@@ -683,6 +724,8 @@ function parseChangeArtifacts(projectRoot, changeName, options = {}) {
|
|
|
683
724
|
profile: 'simple',
|
|
684
725
|
proposalMode: '',
|
|
685
726
|
requirementRefs: [],
|
|
727
|
+
numberingWaiver: {},
|
|
728
|
+
numberingGate: false,
|
|
686
729
|
continuity: {},
|
|
687
730
|
artifacts: [],
|
|
688
731
|
files: files.map((file) => ({ path: file.relativePath, content_hash: file.contentHash })),
|
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
} = require('./runtime.cjs');
|
|
11
11
|
const { observeChangeArtifacts } = require('./artifact-observer.cjs');
|
|
12
12
|
const { allocateIdentity } = require('./id.cjs');
|
|
13
|
+
const externalKey = require('./external-key.cjs');
|
|
13
14
|
|
|
14
15
|
function parseArgs(argv) {
|
|
15
16
|
const result = { _: [] };
|
|
@@ -53,6 +54,7 @@ function showHelp() {
|
|
|
53
54
|
node skywalk-sdd/ontology/cli.cjs identity --delta-state=added
|
|
54
55
|
node skywalk-sdd/ontology/cli.cjs identity --delta-state=modified --entity-id=<uuid> --predecessor-version=<uuid>
|
|
55
56
|
node skywalk-sdd/ontology/cli.cjs identity --delta-state=unchanged --entity-id=<uuid> --version-id=<uuid>
|
|
57
|
+
node skywalk-sdd/ontology/cli.cjs external-key --validate <id> --type <requirement|feature|scenario>
|
|
56
58
|
node skywalk-sdd/ontology/cli.cjs reconcile --project=. --change=<name> [--profile=...]
|
|
57
59
|
node skywalk-sdd/ontology/cli.cjs check --project=. --change=<name> [--profile=...]
|
|
58
60
|
node skywalk-sdd/ontology/cli.cjs status --project=. --change=<name>
|
|
@@ -73,6 +75,23 @@ function main(argv = process.argv.slice(2)) {
|
|
|
73
75
|
console.log(JSON.stringify(allocateIdentity(args), null, 2));
|
|
74
76
|
return;
|
|
75
77
|
}
|
|
78
|
+
if (command === 'external-key') {
|
|
79
|
+
const id = args.validate || args._[1];
|
|
80
|
+
const type = args.type || args.t;
|
|
81
|
+
if (!id || !type) {
|
|
82
|
+
throw new Error('用法: external-key --validate <id> --type <requirement|feature|scenario>');
|
|
83
|
+
}
|
|
84
|
+
const result = externalKey.validate(id, type);
|
|
85
|
+
console.log(JSON.stringify({
|
|
86
|
+
ok: result.ok,
|
|
87
|
+
code: result.code,
|
|
88
|
+
message: result.message,
|
|
89
|
+
normalized: externalKey.normalize(id, type),
|
|
90
|
+
objectType: String(type).toLowerCase(),
|
|
91
|
+
}, null, 2));
|
|
92
|
+
if (!result.ok) process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
76
95
|
const projectRoot = path.resolve(args.project || '.');
|
|
77
96
|
const changeName = args.change;
|
|
78
97
|
if (!changeName) throw new Error('缺少 --change 参数');
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* External key grammar for requirement / feature / scenario.
|
|
5
|
+
* Authority: kb-sdd docs/superpowers/specs/2026-07-24-external-numbering-kb-sdd-design.md §2.
|
|
6
|
+
* Regex and normalization MUST stay byte-for-byte aligned with kb-sdd ExternalKeyFormat.java.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const REGEX = {
|
|
10
|
+
requirement: /^REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}$/,
|
|
11
|
+
feature: /^FEAT-[A-Z][A-Z0-9]{1,7}-[0-9]{3,6}$/,
|
|
12
|
+
scenario: /^REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}:SCN-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$/,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SCENARIO_PARTS =
|
|
16
|
+
/^(REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}):SCN-([a-z0-9]+(?:-[a-z0-9]+)*)-([0-9]{3})$/;
|
|
17
|
+
|
|
18
|
+
const SLUG_MAX_LEN = 40;
|
|
19
|
+
const SCENARIO_KEY_MAX_LEN = 120;
|
|
20
|
+
const SCN_SEQ_MAX = 999;
|
|
21
|
+
|
|
22
|
+
const CODES = {
|
|
23
|
+
FORMAT_INVALID: 'EXTERNAL_KEY_FORMAT_INVALID',
|
|
24
|
+
SCOPE_MISMATCH: 'EXTERNAL_KEY_SCOPE_MISMATCH',
|
|
25
|
+
FEATURE_UNDECLARED: 'EXTERNAL_KEY_FEATURE_UNDECLARED',
|
|
26
|
+
SEQ_REUSED: 'EXTERNAL_KEY_SEQ_REUSED',
|
|
27
|
+
WAIVER_ACTIVE: 'NUMBERING_WAIVER_ACTIVE',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function normalizeObjectType(objectType) {
|
|
31
|
+
if (objectType == null || String(objectType).trim() === '') return null;
|
|
32
|
+
return String(objectType).trim().toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalize(id, objectType) {
|
|
36
|
+
if (id == null) return null;
|
|
37
|
+
const trimmed = String(id).trim();
|
|
38
|
+
if (!trimmed) return trimmed;
|
|
39
|
+
const type = normalizeObjectType(objectType);
|
|
40
|
+
if (type === 'scenario') {
|
|
41
|
+
const colon = trimmed.indexOf(':');
|
|
42
|
+
if (colon < 0) return trimmed.toUpperCase();
|
|
43
|
+
const reqPart = trimmed.slice(0, colon).trim().toUpperCase();
|
|
44
|
+
const rest = trimmed.slice(colon + 1).trim();
|
|
45
|
+
if (/^scn-/i.test(rest)) {
|
|
46
|
+
const afterScn = rest.slice(4);
|
|
47
|
+
const lastDash = afterScn.lastIndexOf('-');
|
|
48
|
+
if (lastDash > 0) {
|
|
49
|
+
const slug = afterScn.slice(0, lastDash).toLowerCase();
|
|
50
|
+
const seq = afterScn.slice(lastDash + 1);
|
|
51
|
+
return `${reqPart}:SCN-${slug}-${seq}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return `${reqPart}:${rest.toLowerCase()}`;
|
|
55
|
+
}
|
|
56
|
+
return trimmed.toUpperCase();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validate(id, objectType) {
|
|
60
|
+
const type = normalizeObjectType(objectType);
|
|
61
|
+
if (!type || !REGEX[type]) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
code: CODES.FORMAT_INVALID,
|
|
65
|
+
message: `未知 object_type,无法校验编号: ${objectType}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const normalized = normalize(id, type);
|
|
69
|
+
if (!normalized) {
|
|
70
|
+
return { ok: false, code: CODES.FORMAT_INVALID, message: 'external_id 为空' };
|
|
71
|
+
}
|
|
72
|
+
if (!REGEX[type].test(normalized)) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
code: CODES.FORMAT_INVALID,
|
|
76
|
+
message: `编号格式不合规 (${type}): ${id} → ${normalized}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (type === 'scenario') {
|
|
80
|
+
const parts = parseScenario(normalized);
|
|
81
|
+
if (!parts) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
code: CODES.FORMAT_INVALID,
|
|
85
|
+
message: `场景键无法解析: ${normalized}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (parts.slug.length > SLUG_MAX_LEN) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
code: CODES.FORMAT_INVALID,
|
|
92
|
+
message: `场景 SLUG 超过 ${SLUG_MAX_LEN} 字符: ${parts.slug}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (normalized.length > SCENARIO_KEY_MAX_LEN) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
code: CODES.FORMAT_INVALID,
|
|
99
|
+
message: `场景键总长超过 ${SCENARIO_KEY_MAX_LEN} 字符`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, code: null, message: null, normalized };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseScenario(key) {
|
|
107
|
+
const normalized = normalize(key, 'scenario');
|
|
108
|
+
if (!normalized) return null;
|
|
109
|
+
const match = SCENARIO_PARTS.exec(normalized);
|
|
110
|
+
if (!match) return null;
|
|
111
|
+
return {
|
|
112
|
+
requirementId: match[1],
|
|
113
|
+
slug: match[2],
|
|
114
|
+
seq: Number(match[3]),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function composeScenario(reqId, slug, seq) {
|
|
119
|
+
const req = normalize(reqId, 'requirement');
|
|
120
|
+
const normalizedSlug = String(slug || '').trim().toLowerCase();
|
|
121
|
+
const n = Number(seq);
|
|
122
|
+
if (!Number.isInteger(n) || n < 1 || n > SCN_SEQ_MAX) {
|
|
123
|
+
throw new Error(`SCN 序号必须在 1..${SCN_SEQ_MAX} 之间: ${seq}`);
|
|
124
|
+
}
|
|
125
|
+
const composed = `${req}:SCN-${normalizedSlug}-${String(n).padStart(3, '0')}`;
|
|
126
|
+
const result = validate(composed, 'scenario');
|
|
127
|
+
if (!result.ok) throw new Error(result.message);
|
|
128
|
+
return composed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function nextScenarioSeq(reqId, usedKeys) {
|
|
132
|
+
const req = normalize(reqId, 'requirement');
|
|
133
|
+
let max = 0;
|
|
134
|
+
for (const key of usedKeys || []) {
|
|
135
|
+
const parts = parseScenario(key);
|
|
136
|
+
if (!parts) continue;
|
|
137
|
+
if (parts.requirementId === req) {
|
|
138
|
+
max = Math.max(max, parts.seq);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (max >= SCN_SEQ_MAX) {
|
|
142
|
+
throw new Error(`SCN 序号已用尽 (999),请回需求管理系统拆分需求: ${req}`);
|
|
143
|
+
}
|
|
144
|
+
return max + 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
REGEX,
|
|
149
|
+
CODES,
|
|
150
|
+
SLUG_MAX_LEN,
|
|
151
|
+
SCENARIO_KEY_MAX_LEN,
|
|
152
|
+
SCN_SEQ_MAX,
|
|
153
|
+
normalize,
|
|
154
|
+
validate,
|
|
155
|
+
parseScenario,
|
|
156
|
+
composeScenario,
|
|
157
|
+
nextScenarioSeq,
|
|
158
|
+
normalizeObjectType,
|
|
159
|
+
};
|
|
@@ -82,6 +82,10 @@ function normalizeFacts(parsed) {
|
|
|
82
82
|
profile: parsed.profile,
|
|
83
83
|
proposal_mode: parsed.proposalMode || undefined,
|
|
84
84
|
requirement_refs: Array.isArray(parsed.requirementRefs) ? parsed.requirementRefs : undefined,
|
|
85
|
+
numbering_waiver: parsed.numberingWaiver && Object.keys(parsed.numberingWaiver).length > 0
|
|
86
|
+
? parsed.numberingWaiver
|
|
87
|
+
: undefined,
|
|
88
|
+
numbering_gate: parsed.numberingGate ? true : undefined,
|
|
85
89
|
continuity: parsed.continuity && Object.keys(parsed.continuity).length > 0
|
|
86
90
|
? parsed.continuity
|
|
87
91
|
: undefined,
|
|
@@ -107,6 +107,11 @@ const DIAGNOSTIC_CODES = Object.freeze({
|
|
|
107
107
|
EXTERNAL_REF_INVALID: 'EXTERNAL_REF_INVALID',
|
|
108
108
|
EXTERNAL_REF_TYPE_MISMATCH: 'EXTERNAL_REF_TYPE_MISMATCH',
|
|
109
109
|
EXTERNAL_REF_CONFLICT: 'EXTERNAL_REF_CONFLICT',
|
|
110
|
+
EXTERNAL_KEY_FORMAT_INVALID: 'EXTERNAL_KEY_FORMAT_INVALID',
|
|
111
|
+
EXTERNAL_KEY_SCOPE_MISMATCH: 'EXTERNAL_KEY_SCOPE_MISMATCH',
|
|
112
|
+
EXTERNAL_KEY_FEATURE_UNDECLARED: 'EXTERNAL_KEY_FEATURE_UNDECLARED',
|
|
113
|
+
EXTERNAL_KEY_SEQ_REUSED: 'EXTERNAL_KEY_SEQ_REUSED',
|
|
114
|
+
NUMBERING_WAIVER_ACTIVE: 'NUMBERING_WAIVER_ACTIVE',
|
|
110
115
|
CONTINUITY_DECISION_REQUIRED: 'CONTINUITY_DECISION_REQUIRED',
|
|
111
116
|
CONTINUITY_IDENTITY_MISMATCH: 'CONTINUITY_IDENTITY_MISMATCH',
|
|
112
117
|
});
|
|
@@ -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
|
|
|
@@ -7,11 +7,14 @@ delta-state: "added"
|
|
|
7
7
|
predecessor-version: "" # added 留空;modified/removed 指向直接前序版本
|
|
8
8
|
mode: "" # full=分 Capability 产物,simple=根目录精简产物
|
|
9
9
|
test-strategy: "" # tdd=测试先行, impl-first=实现优先, none=无测试
|
|
10
|
-
#
|
|
10
|
+
# 外部需求键(默认必填 ≥1 个合规 REQ;探索性/纯内部重构可走 numbering-waiver)
|
|
11
11
|
requirement-refs:
|
|
12
12
|
- system: requirement-mgmt
|
|
13
13
|
object-type: requirement
|
|
14
|
-
external-id: REQ-<DOMAIN>-<
|
|
14
|
+
external-id: REQ-<DOMAIN>-<YEAR>-<SEQ> # 例 REQ-FI-2024-001
|
|
15
|
+
feature-id: FEAT-<DOMAIN>-<SEQ> # 可选,需求所属功能(需求系统权威)
|
|
16
|
+
# numbering-waiver:
|
|
17
|
+
# reason: "探索性原型,本轮不种桥"
|
|
15
18
|
# Continuity:字段一律来自知识库 resolve,禁止本地 archive 文件夹名
|
|
16
19
|
continuity:
|
|
17
20
|
kind: new # iteration | similar-reference | new
|
|
@@ -34,7 +34,7 @@ capability-id: "CAP-<CAPABILITY>" # 必须与 proposal.md 中的 Capability ID
|
|
|
34
34
|
- **version-id**: <UUID>
|
|
35
35
|
- **delta-state**: added
|
|
36
36
|
- **predecessor-version**: 无
|
|
37
|
-
- **external-ref**: requirement-mgmt:scenario:REQ-<DOMAIN>-<
|
|
37
|
+
- **external-ref**: requirement-mgmt:scenario:REQ-<DOMAIN>-<YEAR>-<SEQ>:SCN-<slug>-<NNN>
|
|
38
38
|
- **当** <!-- 触发条件 -->
|
|
39
39
|
- **预期** <!-- 预期结果 -->
|
|
40
40
|
|
|
@@ -43,7 +43,7 @@ capability-id: "CAP-<CAPABILITY>" # 必须与 proposal.md 中的 Capability ID
|
|
|
43
43
|
- **version-id**: <UUID>
|
|
44
44
|
- **delta-state**: added
|
|
45
45
|
- **predecessor-version**: 无
|
|
46
|
-
- **external-ref**: requirement-mgmt:scenario:REQ-<DOMAIN>-<
|
|
46
|
+
- **external-ref**: requirement-mgmt:scenario:REQ-<DOMAIN>-<YEAR>-<SEQ>:SCN-<slug>-<NNN>
|
|
47
47
|
- **当** <!-- 触发条件 -->
|
|
48
48
|
- **预期** <!-- 预期结果 -->
|
|
49
49
|
|
|
@@ -63,7 +63,7 @@ capability-id: "CAP-<CAPABILITY>" # 必须与 proposal.md 中的 Capability ID
|
|
|
63
63
|
- **version-id**: <新 UUID>
|
|
64
64
|
- **delta-state**: <modified|added>
|
|
65
65
|
- **predecessor-version**: <modified 时填写;added 为无>
|
|
66
|
-
- **external-ref**: requirement-mgmt:scenario:REQ-<DOMAIN>-<
|
|
66
|
+
- **external-ref**: requirement-mgmt:scenario:REQ-<DOMAIN>-<YEAR>-<SEQ>:SCN-<slug>-<NNN>
|
|
67
67
|
- **当** <!-- 触发条件 -->
|
|
68
68
|
- **预期** <!-- 预期结果 -->
|
|
69
69
|
|
|
@@ -220,7 +220,7 @@ node skywalk-sdd/log.cjs record --type=conformance_review --command=check --proj
|
|
|
220
220
|
|
|
221
221
|
---
|
|
222
222
|
|
|
223
|
-
## Continuity / external_ref
|
|
223
|
+
## Continuity / external_ref / 编号确定性门禁
|
|
224
224
|
|
|
225
225
|
`opsx-check` **不联网提问**。Agent 应在 propose/spec 已问完;本阶段只验证并入既有 apply 前门禁:
|
|
226
226
|
|
|
@@ -228,6 +228,13 @@ node skywalk-sdd/log.cjs record --type=conformance_review --command=check --proj
|
|
|
228
228
|
- 已写 spec 的 Capability:场景 `external-ref` 与 `continuity-resolution.json` 决议一致;同 key+同锚点未偷偷换 entity_id
|
|
229
229
|
- 用户选「原对象」却仍用新 id、或选「新对象」却仍共用旧锚点 → 失败(`CONTINUITY_IDENTITY_MISMATCH` / `EXTERNAL_REF_CONFLICT`)
|
|
230
230
|
- 决议缺失 / pending / 与产物不一致 → `CONTINUITY_DECISION_REQUIRED`
|
|
231
|
+
- **编号诊断码**(并入五维报告与 apply gate):
|
|
232
|
+
- `EXTERNAL_KEY_FORMAT_INVALID` — external_id / feature_id 不合规
|
|
233
|
+
- `EXTERNAL_KEY_SCOPE_MISMATCH` — 场景键 REQ 前缀不在 `requirement-refs`
|
|
234
|
+
- `EXTERNAL_KEY_FEATURE_UNDECLARED` — feature↔requirement.`feature_id` 配对断裂
|
|
235
|
+
- `EXTERNAL_KEY_SEQ_REUSED` — 本 Change 内 SCN 重复或复用 removed 墓碑号
|
|
236
|
+
- `NUMBERING_WAIVER_ACTIVE`(warning)— 豁免生效,本轮不种桥
|
|
237
|
+
- `requirement-refs` 为空且无有效 waiver → `CONTINUITY_DECISION_REQUIRED` 级阻断
|
|
231
238
|
- CI/非交互:失败即非零退出并打印修复说明,不挂起等待输入
|
|
232
239
|
|
|
233
240
|
## 本体语义关系门禁
|
|
@@ -5,24 +5,10 @@ description: >-
|
|
|
5
5
|
(archive:ingest scope). Prompts for API key and multi-selects spaces/KBs into
|
|
6
6
|
local skill state. Supports upload, job status query, job list, and retry.
|
|
7
7
|
Use when ingesting new or updated knowledge archives into the ontology KB.
|
|
8
|
-
argument-hint: "[path-to-archive.zip]"
|
|
9
|
-
license: MIT
|
|
10
|
-
compatibility: Requires Engineering KB API (API Key with archive:ingest).
|
|
11
|
-
metadata:
|
|
12
|
-
author: sdd-team
|
|
13
|
-
version: "1.0"
|
|
14
|
-
source: "kb-sdd/skills/opsx-kb-ingest"
|
|
15
|
-
allowed-tools:
|
|
16
|
-
- Bash
|
|
17
|
-
- Read
|
|
18
|
-
- Write
|
|
19
|
-
- Edit
|
|
20
8
|
---
|
|
21
9
|
|
|
22
10
|
# 本体知识库 · 入库
|
|
23
11
|
|
|
24
|
-
> **部署说明**:本技能随 `kld-sdd-init` 安装到项目 skills 目录。权威源在工程知识库仓 `skills/opsx-kb-ingest`;`opsx-archive` **硬依赖**本技能完成收尾入库。
|
|
25
|
-
|
|
26
12
|
只负责**入库**(zip 上传)。鉴权只用 **API Key**(`Authorization: Bearer sk_sdd_…`),**禁止**走手机号登录。
|
|
27
13
|
|
|
28
14
|
> 控制台知识库页另有浮动 Ontology Agent(会话登录 + SSE);本 Skill 仍走 API Key,二者分开。
|
|
@@ -127,6 +113,9 @@ curl -sS -X POST "$API/v1/spaces/$SPACE_ID/knowledge-bases/$KB_ID/ingestions/$JO
|
|
|
127
113
|
- object_type 与实体类型匹配:`requirement`/`feature`→Capability;`scenario`→SpecificationStatement|AcceptanceCriterion
|
|
128
114
|
- 包内无重复 `(external key, entity_id)` 绑定对
|
|
129
115
|
- `project_id` / `spaceKey` 既有规则保留
|
|
116
|
+
- **编号格式**:三条正则(见 reference)+ 场景 SLUG≤40 / 键总长≤120;先归一化再匹配
|
|
117
|
+
- **场景作用域**:scenario 键的 REQ 前缀必须出现在同包 requirement 绑定集合
|
|
118
|
+
- **功能配对**:同实体每条 feature 绑定必须能与某 requirement 条目的 `feature_id` 精确配对;反向同理
|
|
130
119
|
|
|
131
120
|
详细字段说明 → [reference.md](reference.md)。
|
|
132
121
|
|
|
@@ -160,6 +149,16 @@ curl -sS -X POST "$API/v1/spaces/$SPACE_ID/knowledge-bases/$KB_ID/ingestions/$JO
|
|
|
160
149
|
- **改为新锚点**:回到 kld-sdd,分配新锚点与新 entity_id,重新 check → 归档 → 入库
|
|
161
150
|
3. **禁止**在 KB 内现场改绑或解绑。
|
|
162
151
|
|
|
152
|
+
若编号门禁失败:
|
|
153
|
+
|
|
154
|
+
| errorCode | 指引 |
|
|
155
|
+
|-----------|------|
|
|
156
|
+
| `EXTERNAL_REFERENCE_FORMAT_INVALID` | 编号不合规 → **回需求管理系统换发**;不得手改编号硬闯 |
|
|
157
|
+
| `EXTERNAL_REFERENCE_SCOPE_MISMATCH` | 场景键 REQ 前缀不在申报集合 → 回 kld-sdd spec 修正 external-ref |
|
|
158
|
+
| `EXTERNAL_REFERENCE_FEATURE_UNBOUND` | feature↔requirement.`feature_id` 配对断裂 → 回 propose/archive 修正线缆字段 |
|
|
159
|
+
|
|
160
|
+
**禁止**手改包内编号绕过门禁。
|
|
161
|
+
|
|
163
162
|
**硬规则**
|
|
164
163
|
|
|
165
164
|
- 无 `apiKey` 不得猜密钥、不得改走 login。
|
|
@@ -141,8 +141,21 @@ POST {base}/ingestions/{jobId}/retry
|
|
|
141
141
|
| `EXTERNAL_REF_CONFLICT` | 同外部键+同锚点已绑不同 entity_id;整包回滚;见 `report.details` |
|
|
142
142
|
| `EXTERNAL_REFERENCE_TYPE_MISMATCH` | 包内 object_type 与实体类型不匹配 |
|
|
143
143
|
| `EXTERNAL_REFERENCE_DUPLICATE_IN_PACKAGE` | 包内重复 `(external key, entity_id)` |
|
|
144
|
+
| `EXTERNAL_REFERENCE_FORMAT_INVALID` | 编号归一化后不合规(含 requirement.`feature_id`);回需求系统换发 |
|
|
145
|
+
| `EXTERNAL_REFERENCE_SCOPE_MISMATCH` | 场景键 REQ 前缀不在同包 requirement 集合;回 kld-sdd spec 修正 |
|
|
146
|
+
| `EXTERNAL_REFERENCE_FEATURE_UNBOUND` | feature 绑定与 requirement.`feature_id` 无法配对;回 propose/archive |
|
|
144
147
|
| `FACTS_SCHEMA_UNSUPPORTED` | canonical-facts schema 非 v1/v2 |
|
|
145
148
|
|
|
149
|
+
### 编号正则(与 KB 设计 §2 / ExternalKeyFormat 一致)
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
requirement: ^REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}$
|
|
153
|
+
feature: ^FEAT-[A-Z][A-Z0-9]{1,7}-[0-9]{3,6}$
|
|
154
|
+
scenario: ^REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}:SCN-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
requirement 条目可选 `feature_id`(FEAT 文法);配对关系随线缆走,KB 不反查 frontmatter。
|
|
158
|
+
|
|
146
159
|
### `EXTERNAL_REF_CONFLICT` 的 report.details
|
|
147
160
|
|
|
148
161
|
```json
|
|
@@ -5,24 +5,10 @@ description: >-
|
|
|
5
5
|
entity/impact/ontology-view). Prompts for API key and multi-selects spaces/KBs into
|
|
6
6
|
local skill state. Use when looking up Spec/design facts, Spec reuse, impact, or
|
|
7
7
|
citation-backed answers from the ontology KB.
|
|
8
|
-
argument-hint: "[query or continuity intent]"
|
|
9
|
-
license: MIT
|
|
10
|
-
compatibility: Requires Engineering KB API (API Key with context:read).
|
|
11
|
-
metadata:
|
|
12
|
-
author: sdd-team
|
|
13
|
-
version: "1.0"
|
|
14
|
-
source: "kb-sdd/skills/opsx-ontology-query"
|
|
15
|
-
allowed-tools:
|
|
16
|
-
- Bash
|
|
17
|
-
- Read
|
|
18
|
-
- Write
|
|
19
|
-
- Edit
|
|
20
8
|
---
|
|
21
9
|
|
|
22
10
|
# 本体知识库 · 查询
|
|
23
11
|
|
|
24
|
-
> **部署说明**:本技能随 `kld-sdd-init` 安装到项目 skills 目录。权威源在工程知识库仓 `skills/opsx-ontology-query`;`opsx-propose` / `opsx-spec` 等流程技能**硬依赖**本技能,缺失时不得用本地 archive 兜底。
|
|
25
|
-
|
|
26
12
|
只负责**查**。鉴权只用 **API Key**(`Authorization: Bearer sk_sdd_…`),**禁止**走手机号登录。
|
|
27
13
|
|
|
28
14
|
> 控制台知识库页另有浮动 Ontology Agent(会话登录 + SSE);本 Skill 仍走 API Key,二者分开。
|
|
@@ -124,6 +110,31 @@ curl -sS -X POST "$API/v1/spaces/$SPACE_ID/knowledge-bases/$KB_ID/entities/resol
|
|
|
124
110
|
|
|
125
111
|
命中时关注:`resolution=LINK_EXISTING` 且 `inheritanceAllowed=true`(可继承 n);`removedBindingCount`(已失效绑定 m);`matchType=HISTORICAL_ONLY` 表示仅有失效绑定,不可继承。
|
|
126
112
|
|
|
113
|
+
**按功能号圈能力(propose 前)**
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
curl -sS -X POST "$API/v1/spaces/$SPACE_ID/knowledge-bases/$KB_ID/entities/resolve" \
|
|
117
|
+
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
|
|
118
|
+
-d '{
|
|
119
|
+
"externalSystem":"requirement-mgmt",
|
|
120
|
+
"externalObjectType":"feature",
|
|
121
|
+
"externalId":"FEAT-FI-012",
|
|
122
|
+
"entityType":"Capability"
|
|
123
|
+
}'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
展示该功能下存活 / 失效能力清单,辅助勾选本次 CAP 范围;此查询只作范围参考,不改变 Continuity 判定优先级。
|
|
127
|
+
|
|
128
|
+
### 编号文法速查(权威在 KB 仓设计 §2)
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
requirement: ^REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}$
|
|
132
|
+
feature: ^FEAT-[A-Z][A-Z0-9]{1,7}-[0-9]{3,6}$
|
|
133
|
+
scenario: ^REQ-…:SCN-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
归一化:trim;REQ/FEAT 段大写;SCN slug 小写。编号五律摘要:REQ/FEAT 仅需求系统铸号;SCN 由 kld-sdd/opsx-spec 铸号;编号≠身份;归属权威在需求系统;历史绑定 append-only。
|
|
137
|
+
|
|
127
138
|
**match-requirement**
|
|
128
139
|
|
|
129
140
|
```bash
|
|
@@ -152,7 +163,7 @@ curl -sS -X POST "$API/v1/spaces/$SPACE_ID/knowledge-bases/$KB_ID/context/match-
|
|
|
152
163
|
|
|
153
164
|
### 命中
|
|
154
165
|
1. **{displayName}**({entityType})@ {kbName}
|
|
155
|
-
- id / version / matchType / externalRefs
|
|
166
|
+
- id / version / matchType / externalRefs(归一化形态)…
|
|
156
167
|
- 若外部键命中:可继承 n / 已失效绑定 m
|
|
157
168
|
```
|
|
158
169
|
|
|
@@ -162,13 +162,21 @@ openspec instructions proposal --change "<name>" --json
|
|
|
162
162
|
|
|
163
163
|
> 完整性检查(问题描述/目标/模块/约束 4 项)与缺失补充机制见 `./checklist.md`「§6 需求完整性检查」。发现缺失时主动询问用户补充。
|
|
164
164
|
|
|
165
|
-
### 6.5
|
|
165
|
+
### 6.5 【编号入场 + Continuity】需求 / Capability 身份(只到 CAP,不做场景)
|
|
166
166
|
|
|
167
|
-
在创建变更目录之后、写 proposal 能力列表之前(或紧接 CAP
|
|
167
|
+
在创建变更目录之后、写 proposal 能力列表之前(或紧接 CAP 编号分配前)。**验号必须在 resolve 之前**:
|
|
168
168
|
|
|
169
|
-
1.
|
|
170
|
-
2.
|
|
171
|
-
|
|
169
|
+
1. 收集 REQ 号(含可选 `feature-id`);没有则问一次。
|
|
170
|
+
2. 逐个验号(确定性入口,禁止肉眼判正则):
|
|
171
|
+
```bash
|
|
172
|
+
node skywalk-sdd/ontology/cli.cjs external-key --validate "<REQ-...>" --type requirement
|
|
173
|
+
# 若有 feature-id:
|
|
174
|
+
node skywalk-sdd/ontology/cli.cjs external-key --validate "<FEAT-...>" --type feature
|
|
175
|
+
```
|
|
176
|
+
- REQ/FEAT 不合规 → **拒绝进入 resolve**,告知「编号不合规,请回需求管理系统核实/换发」;Agent 不得猜测、补位、改写。
|
|
177
|
+
- 用户明确说「没有外部需求号」→ 走 `numbering-waiver.reason`(必填理由);`requirement-refs` 必须为空;提示本轮不种桥。
|
|
178
|
+
3. **先加载依赖技能**:确认 `${AGENT_SKILL_DIR}/opsx-ontology-query/SKILL.md` 存在并 Read;按该技能完成 API Key / 空间与 KB 选择。未安装则停止本步。
|
|
179
|
+
4. 验号通过后才 resolve:
|
|
172
180
|
```bash
|
|
173
181
|
node skywalk-sdd/context-client.cjs --mode=resolve \
|
|
174
182
|
--external-system=requirement-mgmt \
|
|
@@ -178,11 +186,12 @@ node skywalk-sdd/context-client.cjs --mode=resolve \
|
|
|
178
186
|
--space-id="$ENGINEERING_KB_SPACE_ID" \
|
|
179
187
|
--kb-id="$ENGINEERING_KB_KB_ID"
|
|
180
188
|
```
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
189
|
+
5. 若申报了 feature-id:额外 `objectType=feature` resolve 一次,展示「该功能下已有能力 n 个(存活 m / 失效 k)」辅助勾选 CAP 范围;不改变 Continuity 判定优先级。
|
|
190
|
+
6. 按 KB 结果确认 Continuity:`iteration` / `similar-reference` / `new`;勾选本次涉及的 CAP。
|
|
191
|
+
7. 写入 proposal frontmatter:`requirement-refs`(含 `feature-id`)/ `numbering-waiver` + `continuity`。**禁止**写本地 archive 文件夹名作为 `base-archive`。
|
|
192
|
+
8. CAP 级「同 key + 同锚点、不同 entity_id」当场问 A/B/C;决议写入 `continuity-resolution.json` 的 `capabilities[]`。
|
|
193
|
+
9. KB 不可用 → `degraded` 继续,**禁止**扫本地 `archive/` 抄 UUID。
|
|
194
|
+
10. **不得**在本阶段生成 STMT/AC/场景或裁决场景身份;**不得**铸/改 REQ/FEAT 号。
|
|
186
195
|
|
|
187
196
|
### 7. 【交互引导】文档拆分模式选择
|
|
188
197
|
|
|
@@ -132,10 +132,16 @@ node skywalk-sdd/context-client.cjs \
|
|
|
132
132
|
- **禁止**从本地 `archive/` 抄 UUID 当跨迭代继承源;跨迭代只认 KB current。
|
|
133
133
|
- 若返回 `available=false` / `degraded=true`,记录降级并继续,不得扫本地 archive 兜底。
|
|
134
134
|
- `INHERIT`:unchanged 写继承引用;modified 复用 entity-id + predecessor。`REFERENCE`:只参考,新开身份。
|
|
135
|
-
- reuseBundle / 实体上的 `externalRefs` 写入场景 `external-ref
|
|
136
|
-
-
|
|
135
|
+
- reuseBundle / 实体上的 `externalRefs` 写入场景 `external-ref`(完整键 `REQ-…:SCN-<slug>-<NNN>`)。
|
|
136
|
+
- **SCN 铸号(本仓唯一铸号点)**:
|
|
137
|
+
1. 继承优先:`reuseBundle.externalRefs` 已有场景键的 unchanged/modified 场景一律沿用原 SCN 号,禁止另铸。
|
|
138
|
+
2. 新场景:`SCN-<slug>-<NNN>`;slug=kebab-case 小写 ≤40;NNN=该 REQ 命名空间内 max+1(已用集合=KB 回传 ∪ 本 Change 已写键,含 removed 墓碑)。
|
|
139
|
+
3. removed 号是墓碑:永不复用、永不重排;序号达 999 → 硬错误,回需求系统拆分需求,不扩位。
|
|
140
|
+
4. 写完立即用 `cli.cjs external-key --validate … --type scenario` 校验;REQ 前缀必须 ∈ proposal `requirement-refs`。
|
|
141
|
+
- 场景级「同 SCN key + 同锚点、不同 entity_id」当场问 A/B/C;未决不得进入下一 CAP / design。决议追加到 `continuity-resolution.json` 的 `scenarios[]`;决议中的 `externalKey` 必须是归一化形态。
|
|
137
142
|
- 优先消费 `reuseBundles[].statements`;`designElements` 只作理解上下文,不能写成 Spec 的 How。
|
|
138
143
|
- 所有知识库内容均为 advisory;与用户确认 / proposal 冲突时以当前确认与 proposal 为准。
|
|
144
|
+
- **禁止**铸/改 REQ/FEAT;**禁止**自动重排/回收 SCN。
|
|
139
145
|
|
|
140
146
|
**【可选】业务知识库检索**:
|
|
141
147
|
术语含义不清且可能影响 spec 准确性时,可调用 **opsx-knowledge** skill。
|