kld-sdd 2.6.5 → 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.
Files changed (27) hide show
  1. package/package.json +2 -2
  2. package/skywalk-sdd/context-client.cjs +12 -1
  3. package/skywalk-sdd/ontology/archive-package.cjs +95 -6
  4. package/skywalk-sdd/ontology/artifact-parser.cjs +50 -7
  5. package/skywalk-sdd/ontology/cli.cjs +19 -0
  6. package/skywalk-sdd/ontology/external-key.cjs +159 -0
  7. package/skywalk-sdd/ontology/normalizer.cjs +4 -0
  8. package/skywalk-sdd/ontology/schema.cjs +5 -0
  9. package/skywalk-sdd/ontology/traceability-validator.cjs +175 -0
  10. package/templates/openspec/proposal.md +5 -2
  11. package/templates/openspec/spec.md +3 -3
  12. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +3 -1
  13. package/templates/skills/kld-sdd/opsx-apply/checklist.md +16 -0
  14. package/templates/skills/kld-sdd/opsx-check/SKILL.md +12 -1
  15. package/templates/skills/kld-sdd/opsx-check/checklist.md +7 -0
  16. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +13 -14
  17. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +13 -0
  18. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +26 -15
  19. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +19 -10
  20. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +8 -2
  21. package/templates/skills/kld-sdd/opsx-task/SKILL.md +8 -1
  22. package/templates/skills/kld-sdd/opsx-task/checklist.md +3 -0
  23. package/templates/skills/kld-sdd/opsx-tdd-anti-patterns/SKILL.md +5 -2
  24. package/templates/skills/kld-sdd/opsx-tdd-anti-patterns/reference.md +29 -0
  25. package/templates/skills/kld-sdd/opsx-tdd-core/checklist.md +4 -0
  26. package/templates/skills/kld-sdd/opsx-tdd-rules/SKILL.md +1 -1
  27. package/templates/skills/kld-sdd/opsx-tdd-rules/rules/multi-validation-split.md +35 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kld-sdd",
3
- "version": "2.6.5",
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']) payload.externalId = 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 externalId = String(ref.external_id || ref.externalId || '').trim();
111
- if (!system || !objectType || !externalId) continue;
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
- result.push({
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' && inheritedRequirementRefs.length > 0) {
134
- externalRefs = normalizeExternalRefs([...externalRefs, ...inheritedRequirementRefs]);
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
- requirementRefs.push({
210
+ const type = String(objectType).toLowerCase();
211
+ const entry = {
209
212
  system,
210
- object_type: String(objectType).toLowerCase(),
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
- ...structured.requirementRefs,
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
- # 外部需求键(首轮冷启动也建议写入,便于 ingest 种桥)
10
+ # 外部需求键(默认必填 ≥1 个合规 REQ;探索性/纯内部重构可走 numbering-waiver)
11
11
  requirement-refs:
12
12
  - system: requirement-mgmt
13
13
  object-type: requirement
14
- external-id: REQ-<DOMAIN>-<NNN>
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>-<NNN>:SCN-<slug>
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>-<NNN>:SCN-<slug>
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>-<NNN>:SCN-<slug>
66
+ - **external-ref**: requirement-mgmt:scenario:REQ-<DOMAIN>-<YEAR>-<SEQ>:SCN-<slug>-<NNN>
67
67
  - **当** <!-- 触发条件 -->
68
68
  - **预期** <!-- 预期结果 -->
69
69
 
@@ -211,6 +211,7 @@ g. **继续下一个层级** — 重新检查 DAG,找出依赖已满足的下
211
211
  > 合理化预防表见 opsx-tdd-core/SKILL.md §7
212
212
  > REFACTOR 检查点见 opsx-tdd-rules/rules/refactor-checklist.md
213
213
  > 异常路径覆盖门禁见 opsx-tdd-rules/rules/exception-path-coverage.md
214
+ > TDD 节奏校验(执行后校验)见 `./checklist.md` §5e.1
214
215
 
215
216
  **【S2.1 RED 测试质量标准】**(test-strategy=tdd 时强制):
216
217
 
@@ -282,6 +283,7 @@ g. **继续下一个层级** — 重新检查 DAG,找出依赖已满足的下
282
283
  - **⛔ 编译检查门禁**:每完成一个任务后必须运行编译检查,编译失败禁止标记已完成。
283
284
  - **⛔ 测试执行门禁**:根据 `test-strategy` 决定(tdd=强制, impl-first=强制补跑, none=跳过);须真实执行并留 telemetry。${HOOK_GATE_DESCRIPTION}
284
285
  - **⛔ 必须实时更新任务状态**:每完成一个任务立即改 tasks.md,两种格式同步。
286
+ - **⛔ apply 结束前 checkbox 全量同步校验**:`stage_end` 前对比 telemetry `task_update` 记录数与 tasks.md `[x]` 数量,不一致则补齐(见 `./checklist.md` §5f.1)。
285
287
  - **⛔ task_update 后必须验证 checkbox 已更新**:执行 `check-task` 确认 tasks.md 对应行已变更;未更新则手动修改。
286
288
  - **⛔ TDD RED→GREEN 严格串行**:不适用同层并行派发;RED-N 确认失败后必须执行中断声明再进入 GREEN-N;GREEN 完成后必须通过 Scope 门禁再进入下一个 RED。
287
289
  - **Git 只读策略**:禁止为了度量自动初始化 Git、创建分支或提交 commit;非 Git 项目用 `vcs_mode=no-git` 继续执行。
@@ -298,7 +300,7 @@ g. **继续下一个层级** — 重新检查 DAG,找出依赖已满足的下
298
300
 
299
301
  ## 渐进披露
300
302
 
301
- - Read `checklist.md` 仅在执行 apply 需要校验门禁/自检时 — 含 §1.2 Check 门禁检查点、§5d/§5e 编译/测试门禁自检、§6.0 单元测试真实执行自检、§6.1 worktree 收尾前置条件、Guardrails ⛔ 强制项勾选表。
303
+ - Read `checklist.md` 仅在执行 apply 需要校验门禁/自检时 — 含 §1.2 Check 门禁检查点、§5d/§5e 编译/测试门禁自检、§5e.1 TDD 节奏校验、§5f.1 checkbox 全量同步校验、§6.0 单元测试真实执行自检、§6.1 worktree 收尾前置条件、Guardrails ⛔ 强制项勾选表。
302
304
  - Read `reference.md` 仅在需要参考详细模板时 — 含 📊 Telemetry 命令模板(start/end/task_update/ai_adoption_review/worktree_finish)、§1.5 worktree 全套策略(Step 0.1-3 + record-base + 多 cap 合并顺序)、§5c 子代理派发、§5.1 AI 产出快照、§6.0 单元测试、§6.1 worktree 收尾脚本。
303
305
  - `implementer-prompt.md` 为子代理派发提示模板(§5c 派发时组合 tasks/design/overview 上下文使用)。
304
306
  - `worktree-setup.md` 为 worktree 快速参考(§1.5 策略的精简版,与 reference.md §1.5 完整版并存:reference=完整策略,worktree-setup=快速参考)。
@@ -47,6 +47,11 @@ description: opsx-apply 的阶段强制检查点与自检清单。仅在执行 a
47
47
  > 不在此内联复制,以 opsx-tdd-core/checklist.md §A 为唯一真相源。
48
48
  > 额外补充:REFACTOR 任务还需执行 `opsx-tdd-rules/rules/refactor-checklist.md`(7 项重构检查点)。
49
49
 
50
+ ⛔ **TDD 节奏校验**(RED→GREEN 严格串行的执行后校验):
51
+ - [ ] 连续的 RED-N `task_update` 与 GREEN-N `task_update` 之间有可验证的执行间隔(建议 >60 秒),若时间戳差距过小视为批量执行信号
52
+ - [ ] 每对 RED→GREEN 之间已执行 🔴 中断声明(`🔴 RED-N 确认失败,原因:XXX。现在进入 GREEN-N`)
53
+ - [ ] 若同层有多个 RED→GREEN 对,确认是逐对完成而非一次性编写多个 RED 再一次性实现多个 GREEN
54
+
50
55
  ### §5e.2 RED 测试质量门禁(仅 test-strategy=tdd 时,RED 任务完成后强制检查)
51
56
 
52
57
  ⛔ 核心原则(引用 opsx-tdd-quality/SKILL.md §2):Mock 边界,不 Mock 行为
@@ -79,6 +84,15 @@ description: opsx-apply 的阶段强制检查点与自检清单。仅在执行 a
79
84
  - [ ] 记录任务级 Telemetry(`task_update`,`--task-id=<TASK-ID>` 必填,否则 E4 指标无法计算);TDD 测试骨架任务须在 `--details-json` 带 `"task_kind":"test-skeleton"`(P3,避免红灯误判拉低 E4)
80
85
  - [ ] ⛔ **task_update 后必须验证 checkbox 已更新**:执行 `node skywalk-sdd/index.cjs check-task --project=. --change=<变更名称> --task-id=<TASK-ID>` 确认 tasks.md 中对应行已从 `- [ ]` 变为 `- [x]`;若未更新,手动修改 tasks.md 并报告
81
86
 
87
+ ### §5f.1 apply 结束前 checkbox 全量同步校验
88
+
89
+ > ⛔ 在 `stage_end` telemetry 记录前必须执行此校验,防止任务状态滞后到 archive 阶段。
90
+
91
+ - [ ] 对比 telemetry `task_update` 记录的已完成任务数与 tasks.md 中 `[x]` 数量,不一致则补齐
92
+ - [ ] tasks.md 中所有 `- [ ]` / `- [x]` 与 `**状态**: [ ]` / `[x]` 两种格式已同步
93
+ - [ ] 手动验证清单(如有)已勾选
94
+ - [ ] 文档更新项(如有)已完成或显式标注推迟
95
+
82
96
  ---
83
97
 
84
98
  ## §6.0 单元测试真实执行自检(`test-strategy` 非 `none`)
@@ -114,8 +128,10 @@ description: opsx-apply 的阶段强制检查点与自检清单。仅在执行 a
114
128
  - [ ] ⛔ **测试执行门禁**:根据 `test-strategy` 决定(tdd=强制, impl-first=强制补跑, none=跳过);须真实执行并留 telemetry,`sdd-apply-test-gate` 校验非占位数据
115
129
  - [ ] ⛔ **RED 测试质量门禁**:见 §5e.2(引用 opsx-tdd-quality + opsx-tdd-anti-patterns,不在此内联复制)
116
130
  - [ ] ⛔ **必须实时更新任务状态**:每完成一个任务立即改 tasks.md,两种格式(`- [ ]`→`- [x]` 与 `**状态**: [ ]`→`[x]`)同步
131
+ - [ ] ⛔ **apply 结束前 checkbox 全量同步校验**:见 §5f.1,`stage_end` 前对比 telemetry `task_update` 记录数与 tasks.md `[x]` 数量
117
132
  - [ ] ⛔ **task_update 后必须验证 checkbox 已更新**:执行 `check-task` 确认 tasks.md 对应行已变更;未更新则手动修改
118
133
  - [ ] ⛔ **TDD RED→GREEN 严格串行**:不适用同层并行派发;RED-N 确认失败后必须执行中断声明再进入 GREEN-N;GREEN 完成后必须通过 Scope 门禁再进入下一个 RED
134
+ - [ ] ⛔ **TDD 节奏校验**:见 §5e.1,连续 RED-N/GREEN-N 的 `task_update` 时间戳须有可验证间距
119
135
  - [ ] **Git 只读策略**:禁止为了度量自动初始化 Git、创建分支或提交 commit;非 Git 项目用 `vcs_mode=no-git` 继续执行
120
136
  - [ ] ⛔ **Step 0.1 隔离校验必做**:建 worktree / 建议分支名前必须完成 proposal + 跨 cap spec 依赖校验并输出报告;未通过不得按 full 并行策略拆 `kld-sdd/<change>/<cap>`
121
137
  - [ ] **Worktree 为加速手段,非必选项**:校验通过且解耦方可多 worktree;有依赖或共享修改面则串行
@@ -97,6 +97,10 @@ openspec list
97
97
  - [ ] spec.md 的需求项在 design.md 中 100% 被覆盖
98
98
  - [ ] design.md 的设计点在 tasks.md 中 100% 被拆解
99
99
  - [ ] 跨文档引用路径正确
100
+ - [ ] ⛔ **CON 覆盖一致性**:spec.md 中每个 CON 在 tasks.md 中有对应验证任务或显式声明间接覆盖;tasks.md 声明"100% 覆盖 CON"时必须可追溯
101
+ - [ ] ⛔ **安全/审计要求覆盖一致性**:spec.md §5.x 中的安全与审计要求在 tasks.md 中有对应任务或显式声明推迟
102
+ - [ ] ⛔ **AC 变体覆盖一致性**:spec.md 中含"或"条件的 AC 场景,其 RED 任务验收标准须列出所有变体的测试方法(规则见 `opsx-tdd-rules/rules/multi-validation-split.md` §AC 内"或"条件变体覆盖)
103
+ - [ ] ⛔ **tasks.md §4.x 验证方式表内部一致性**:§4.x 验证方式表中的测试注解/配置与任务实现步骤中的声明一致
100
104
 
101
105
  #### 4.3 算法正确性检查
102
106
 
@@ -216,7 +220,7 @@ node skywalk-sdd/log.cjs record --type=conformance_review --command=check --proj
216
220
 
217
221
  ---
218
222
 
219
- ## Continuity / external_ref 确定性门禁
223
+ ## Continuity / external_ref / 编号确定性门禁
220
224
 
221
225
  `opsx-check` **不联网提问**。Agent 应在 propose/spec 已问完;本阶段只验证并入既有 apply 前门禁:
222
226
 
@@ -224,6 +228,13 @@ node skywalk-sdd/log.cjs record --type=conformance_review --command=check --proj
224
228
  - 已写 spec 的 Capability:场景 `external-ref` 与 `continuity-resolution.json` 决议一致;同 key+同锚点未偷偷换 entity_id
225
229
  - 用户选「原对象」却仍用新 id、或选「新对象」却仍共用旧锚点 → 失败(`CONTINUITY_IDENTITY_MISMATCH` / `EXTERNAL_REF_CONFLICT`)
226
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` 级阻断
227
238
  - CI/非交互:失败即非零退出并打印修复说明,不挂起等待输入
228
239
 
229
240
  ## 本体语义关系门禁
@@ -36,6 +36,13 @@ description: "opsx-check 阶段日志自检清单 — 仅在 check 自检时读
36
36
  - [ ] 完整性、一致性、算法正确性、可执行性、TDD合规性(仅test-strategy=tdd时)五维均已输出
37
37
  - [ ] 报告问题对应修复建议(spec/design/task)
38
38
 
39
+ ## D2. 一致性补充检查(SKILL.md §4.2 扩展)
40
+
41
+ - [ ] **CON 覆盖**:spec.md 中每个 CON 在 tasks.md 中有对应验证任务或显式声明间接覆盖
42
+ - [ ] **安全/审计要求覆盖**:spec.md §5.x 中的安全与审计要求在 tasks.md 中有对应任务或显式声明推迟
43
+ - [ ] **AC 变体覆盖**:spec.md 中含"或"条件的 AC 场景,其 RED 任务验收标准列出所有变体的测试方法(引用 `opsx-tdd-rules/rules/multi-validation-split.md`)
44
+ - [ ] **§4.x 验证方式表内部一致性**:tasks.md §4.x 验证方式表中的测试注解/配置与任务实现步骤中的声明一致
45
+
39
46
  ## E. TDD 合规性检查(仅 test-strategy=tdd 时)
40
47
 
41
48
  ⛔ 执行 `opsx-tdd-core/checklist.md` §B(11 项)逐项检查。
@@ -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 Continuity】需求 / Capability 身份(只到 CAP,不做场景)
165
+ ### 6.5 【编号入场 + Continuity】需求 / Capability 身份(只到 CAP,不做场景)
166
166
 
167
- 在创建变更目录之后、写 proposal 能力列表之前(或紧接 CAP 编号分配前):
167
+ 在创建变更目录之后、写 proposal 能力列表之前(或紧接 CAP 编号分配前)。**验号必须在 resolve 之前**:
168
168
 
169
- 1. 提取 / 询问外部需求号 `REQ-*`(没有则问一次)。
170
- 2. **先加载依赖技能**:确认 `${AGENT_SKILL_DIR}/opsx-ontology-query/SKILL.md`(或当前编辑器等价 skills 路径)存在并 Read;按该技能完成 API Key / 空间与 KB 选择(写入其 `.local/state.json`)。未安装则停止本步。
171
- 3. 调用知识库 **`opsx-ontology-query`**(权威);可用薄封装,但契约以该技能为准:
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
- 4. KB 结果确认 Continuity:`iteration` / `similar-reference` / `new`;勾选本次涉及的 CAP
182
- 5. 写入 proposal frontmatter:`requirement-refs` + `continuity`(字段来自 KB:`kb-space-id` / `kb-id` / `base-capabilities[].entity-id` / `current-version-id`)。**禁止**写本地 archive 文件夹名作为 `base-archive`。
183
- 6. CAP 级「同 key + 同锚点、不同 entity_id」当场问 A/B/C;决议写入 `openspec/changes/<name>/continuity-resolution.json` `capabilities[]`。
184
- 7. KB 不可用 `degraded` 继续,**禁止**扫本地 `archive/` UUID。预期:恢复后同锚点入库可能触发 `EXTERNAL_REF_CONFLICT`。
185
- 8. **不得**在本阶段生成 STMT/AC/场景或裁决场景身份。
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`(`requirement-mgmt:scenario:REQ-…:SCN-…`)。
136
- - 场景级「同 SCN key + 同锚点、不同 entity_id」在写完该 CAP identity 后、确认文档前**当场问** A/B/C;未决不得进入下一 CAP / design。决议追加到 `continuity-resolution.json` 的 `scenarios[]`。
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。
@@ -189,7 +189,14 @@ Simple 模式或单文件能力域下,**不要拆成多个同文件任务**。
189
189
 
190
190
  ### 8. 质量红线自检
191
191
 
192
- > 逐项确认,完整 7 项自检清单 + TDD 合规性自检(结构符合模板 / 拓扑图已绘制 / 依赖字段已填写 / 无循环依赖 / 颗粒度 ≤5 分钟 / 100% 覆盖 design / 每任务有验收标准)见 `./checklist.md`「§8 质量红线自检 + §8.1 TDD 合规性自检」。如有任意一项未满足,重新生成对应章节,直至全部通过。
192
+ > 逐项确认,完整 7 项自检清单 + TDD 合规性自检(结构符合模板 / 拓扑图已绘制 / 依赖字段已填写 / 无循环依赖 / 颗粒度 ≤5 分钟 / 100% 覆盖 design / 每任务有验收标准)见 `./checklist.md`「§8 质量红线自检 + §8.1 TDD 合规性自检」。
193
+ >
194
+ > 额外强制项(见 `./checklist.md` §8):
195
+ > - ⛔ **CON 覆盖**:spec.md 中每个 CON 必须有对应验证任务或显式声明间接覆盖
196
+ > - ⛔ **安全/审计要求覆盖**:spec.md §5.x 中的安全与审计要求必须有对应任务或显式声明推迟
197
+ > - ⛔ **§4.x 验证方式表内部一致性**:§4.x 验证方式表中的测试注解/配置必须与任务实现步骤中的声明一致
198
+ >
199
+ > 如有任意一项未满足,重新生成对应章节,直至全部通过。
193
200
 
194
201
  ### 9. 确认任务并输出
195
202
 
@@ -29,6 +29,9 @@ description: opsx-task 的阶段强制检查点与自检清单。仅在执行 ta
29
29
  - [ ] 每个任务颗粒度 ≤ 5 分钟
30
30
  - [ ] 100% 覆盖 design.md 定义
31
31
  - [ ] 每个任务都有验收标准
32
+ - [ ] ⛔ **约束(CON)覆盖**:spec.md 中每个 CON 必须有对应验证任务,或在任务中显式声明"通过现有 AC 间接覆盖"并说明理由;tasks.md 质量红线声明"100% 覆盖 CON"时必须可追溯
33
+ - [ ] ⛔ **安全/审计要求覆盖**:spec.md §5.x 中的安全与审计要求(日志记录、脱敏、告警等)必须有对应任务,或显式声明推迟到后续迭代并在任务中标注
34
+ - [ ] ⛔ **§4.x 验证方式表内部一致性**:§4.x 验证方式表中的测试注解/配置必须与任务实现步骤中的声明一致,不得出现"§4.1 声明 @WebMvcTest 但实现步骤允许 @SpringBootTest"的矛盾
32
35
 
33
36
  **如有任意一项未满足,重新生成对应章节,直至全部通过。**
34
37
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: opsx-tdd-anti-patterns
3
- description: "测试反模式防护层 — 15 种反模式检测(RED 阶段 3 种 + GREEN 后 12 种),每种带门禁函数和修复方案。当编写或审查测试代码时引用本技能。"
3
+ description: "测试反模式防护层 — 16 种反模式检测(RED 阶段 3 种 + GREEN 后 13 种),每种带门禁函数和修复方案。当编写或审查测试代码时引用本技能。"
4
4
  ---
5
5
 
6
6
  # opsx-tdd-anti-patterns — 反模式防护层
@@ -32,7 +32,7 @@ description: "测试反模式防护层 — 15 种反模式检测(RED 阶段 3
32
32
  | 2 | **Mock 预定结论而非准备条件** | `when(bookMapper.countByPublisher(1L)).thenReturn(3)` 后只测 `if (count > 0) throw` — trivial 逻辑 | "我的测试是在验证完整行为链路,还是只验证一个 if 分支?" | Mock 边界依赖(Mapper)是合理的,但测试断言应验证完整行为链路(如 verify 不会执行 delete) |
33
33
  | 3 | **Given 不是真实输入** | mock 出"这个输入会导致什么结果",而非传入真实数据让被测代码自行处理 | "我的 Given 是真实数据还是 mock 出的预定结论?" | 传入真实数据(如 `"invalid"` 字符串、`null`、空对象),让被测代码自行决定结果 |
34
34
 
35
- ## §4 GREEN 后反模式(12 种)
35
+ ## §4 GREEN 后反模式(13 种)
36
36
 
37
37
  | # | 反模式 | 问题表现 | 修复方案 |
38
38
  |---|--------|---------|---------|
@@ -48,6 +48,7 @@ description: "测试反模式防护层 — 15 种反模式检测(RED 阶段 3
48
48
  | 13 | **魔法值** | 测试中使用未解释的字面值(如 `assertEquals(42, result)` 无注释说明 42 的含义) | 使用命名常量或注释解释字面值含义 |
49
49
  | 14 | **断言不足** | 只断言了部分结果,遗漏了关键属性(如只 assertNotNull 但不 assertEquals 具体值) | 每个测试至少有一个具体值断言(assertEquals),而非仅 assertNotNull |
50
50
  | 15 | **缺少负面测试** | 只测试正常路径,不测试错误条件 | 每个方法至少有一个异常路径测试(见 `opsx-tdd-rules/rules/exception-path-coverage.md`) |
51
+ | 16 | **AC 变体覆盖不足** | AC 场景描述含"或"条件(如"缺少 A 或 B 或为空"),但测试只覆盖部分变体 | AC 中每个"或"条件变体必须有对应测试方法(规则见 `opsx-tdd-rules/rules/multi-validation-split.md` §AC 内"或"条件变体覆盖) |
51
52
 
52
53
  ## §5 门禁函数
53
54
 
@@ -82,3 +83,5 @@ AFTER GREEN(GREEN 完成后、标记通过前):
82
83
  - 测试中只有 `assertNotNull` 无具体值断言
83
84
  - 测试中存在未解释的魔法数字/字符串
84
85
  - 正常路径有测试但异常路径无测试
86
+ - AC 场景描述含"或"条件但测试只覆盖部分变体
87
+ - 连续 RED/GREEN 的 task_update 时间戳差距过小(批量执行信号,违反 TDD 严格串行)
@@ -195,6 +195,35 @@ assertEquals("user-001", result.getUserId());
195
195
 
196
196
  **修复**:每个方法至少有一个异常路径测试。规则见 `opsx-tdd-rules/rules/exception-path-coverage.md`。
197
197
 
198
+ ## 反模式 16:AC 变体覆盖不足
199
+
200
+ **问题**:AC 场景描述含"或"条件(如"缺少 A 或 B 或为空"),但测试只覆盖部分变体,其余变体无测试守护。
201
+
202
+ **反例**:
203
+ ```java
204
+ // AC-004: 请求体缺少 username 或 password 字段,或字段值为空字符串
205
+ // 仅测试 1/6 变体
206
+ @Test
207
+ void login_withMissingParams_returns1001() {
208
+ mockMvc.perform(post("/login")
209
+ .content("{\"username\":\"\",\"password\":\"admin123\"}"))
210
+ .andExpect(jsonPath("$.code").value(1001));
211
+ }
212
+ ```
213
+
214
+ **正例**:
215
+ ```java
216
+ // 每个变体都有测试方法(或参数化测试)
217
+ @Test void login_withEmptyUsername_returns1001() { ... }
218
+ @Test void login_withEmptyPassword_returns1001() { ... }
219
+ @Test void login_withMissingUsernameField_returns1001() { ... }
220
+ @Test void login_withMissingPasswordField_returns1001() { ... }
221
+ @Test void login_withBothEmpty_returns1001() { ... }
222
+ @Test void login_withBothMissing_returns1001() { ... }
223
+ ```
224
+
225
+ **修复**:AC 中每个"或"条件变体必须有对应测试方法。规则见 `opsx-tdd-rules/rules/multi-validation-split.md` §AC 内"或"条件变体覆盖。
226
+
198
227
  ## TDD 如何防止这些反模式
199
228
 
200
229
  1. 先写测试 → 迫使你思考实际在测试什么
@@ -40,6 +40,9 @@ description: "opsx-tdd-core 自检清单 — TDD 执行合规自检、合规性
40
40
  - [ ] 每个 RED 任务包含测试方法名(`{method}_{state}_{outcome}` 格式)
41
41
  - [ ] 每个 GREEN 任务包含 YAGNI 围栏声明("不提前实现 [后续 RED 行为]")
42
42
  - [ ] 每个 REFACTOR 任务列出至少 2 个具体重构点
43
+ - [ ] ⛔ **CON 覆盖**:spec.md 中每个 CON 在 tasks.md 中有对应验证任务或显式声明间接覆盖
44
+ - [ ] ⛔ **安全/审计要求覆盖**:spec.md §5.x 中的安全与审计要求在 tasks.md 中有对应任务或显式声明推迟
45
+ - [ ] ⛔ **AC 变体覆盖**:spec.md 中含"或"条件的 AC 场景,其 RED 任务验收标准列出所有变体的测试方法(引用 `opsx-tdd-rules/rules/multi-validation-split.md`)
43
46
 
44
47
  ## §C 完成验证清单(8 项)
45
48
 
@@ -53,5 +56,6 @@ description: "opsx-tdd-core 自检清单 — TDD 执行合规自检、合规性
53
56
  - [ ] 输出纯净(无错误/警告)
54
57
  - [ ] 测试使用真实代码(仅在不可避免时使用 mock)
55
58
  - [ ] 边界情况和错误已覆盖
59
+ - [ ] ⛔ **AC "或"条件变体全覆盖**:AC 场景描述含"或"条件时,每个变体都有对应测试方法(引用 `opsx-tdd-rules/rules/multi-validation-split.md`)
56
60
 
57
61
  > Can't check all boxes? You skipped TDD. Start over.
@@ -24,6 +24,6 @@ description: "TDD 规则库 — DAG 生成规则、Controller 策略、任务类
24
24
  | `rules/green-yagni-fence.md` | GREEN 任务 YAGNI 围栏自动注入规则 | HIGH | opsx-task |
25
25
  | `rules/green-scope-declaration.md` | GREEN 任务 Scope 声明步骤(断言清单→流程标记→仅实现属于的步骤) | HIGH | opsx-apply |
26
26
  | `rules/des-step-annotation.md` | DES 元素步骤级标注规则(TDD 模式下标注 [GREEN-N] 归属) | MEDIUM | opsx-design |
27
- | `rules/multi-validation-split.md` | 多校验条件拆分规则(每个校验条件须有独立 AC 场景) | MEDIUM | opsx-spec |
27
+ | `rules/multi-validation-split.md` | 多校验条件拆分规则(每个校验条件须有独立 AC 场景;AC 内"或"条件变体须全覆盖) | MEDIUM | opsx-spec, opsx-task, opsx-check |
28
28
  | `rules/exception-path-coverage.md` | 异常路径测试覆盖门禁(每个 orElseThrow/边界检查须有对应 RED 测试) | HIGH | opsx-task, opsx-apply, opsx-check |
29
29
  | `rules/refactor-checklist.md` | REFACTOR 阶段检查点(public API 不变、行为保持、重构质量评估) | HIGH | opsx-apply, opsx-tdd-core |
@@ -1,7 +1,7 @@
1
1
  # 多校验拆分规则
2
2
 
3
3
  > 影响等级:MEDIUM
4
- > 引用方:opsx-spec(spec.md 生成)、opsx-spec/checklist.md(自检)
4
+ > 引用方:opsx-spec(spec.md 生成)、opsx-spec/checklist.md(自检)、opsx-task(RED 任务拆解)、opsx-check(一致性检查)
5
5
 
6
6
  ## 问题
7
7
 
@@ -34,3 +34,37 @@ AC-03: 可借数量不足(available_count = 0)
34
34
  ```
35
35
 
36
36
  > 目的:每个校验条件有独立场景 → 独立 RED 测试 → 独立 GREEN 实现,避免 AI 在一个 GREEN 中实现所有校验。
37
+
38
+ ## AC 内"或"条件变体覆盖
39
+
40
+ > 引用方:opsx-spec(spec.md 生成)、opsx-task(RED 任务拆解)、opsx-check(一致性检查)
41
+
42
+ ### 问题
43
+
44
+ 单个 AC 场景描述中包含"或"条件时(如"缺少 username **或** password 字段,或字段值为空字符串"),AI 在拆解 RED 任务时倾向于只取一个变体编写测试,导致其余变体无测试守护。
45
+
46
+ ### 规则
47
+
48
+ 当单个 AC 场景描述包含"或"条件时,必须检查:
49
+
50
+ 1. 每个"或"条件变体是否有对应的测试方法
51
+ 2. 若多个变体共享一个 RED 任务,RED 验收标准中必须列出所有变体的测试方法名
52
+ 3. 变体可合并到一个 RED 任务的多个 `@Test` 方法中,但不得遗漏任何变体
53
+
54
+ ### 反例(禁止)
55
+
56
+ ```
57
+ AC-004: 请求体缺少 username 或 password 字段,或字段值为空字符串
58
+ RED-4: login_withMissingParams_returns1001
59
+ → 仅测试 username="" 一种变体,password="" / 缺少字段 / 双空 / 双缺共 5 种变体未覆盖
60
+ ```
61
+
62
+ ### 正例(要求)
63
+
64
+ ```
65
+ AC-004: 请求体缺少 username 或 password 字段,或字段值为空字符串
66
+ RED-4: login_withMissingParams_returns1001(含 6 个 @Test 方法或参数化测试)
67
+ → username="" / password="" / 缺少 username / 缺少 password / 双空 / 双缺 均有测试
68
+ ```
69
+
70
+ > 目的:spec 中"或"条件描述的每个变体都有测试守护,防止参数校验逻辑回归。