kld-sdd 2.5.2 → 2.6.1

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 (29) hide show
  1. package/README.md +33 -10
  2. package/lib/init.js +11 -1
  3. package/package.json +3 -2
  4. package/skywalk-sdd/context-client.cjs +160 -0
  5. package/skywalk-sdd/index.cjs +118 -14
  6. package/skywalk-sdd/ontology/archive-package.cjs +489 -0
  7. package/skywalk-sdd/ontology/id.cjs +16 -19
  8. package/skywalk-sdd/ontology/identity-index.cjs +25 -0
  9. package/skywalk-sdd/ontology/runtime.cjs +179 -54
  10. package/skywalk-sdd/ontology/working-artifacts.cjs +243 -0
  11. package/templates/openspec/proposal.md +1 -1
  12. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +27 -4
  13. package/templates/skills/kld-sdd/opsx-apply/checklist.md +29 -1
  14. package/templates/skills/kld-sdd/opsx-apply/implementer-prompt.md +54 -3
  15. package/templates/skills/kld-sdd/opsx-apply/reference.md +26 -0
  16. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +10 -1
  17. package/templates/skills/kld-sdd/opsx-archive/checklist.md +5 -1
  18. package/templates/skills/kld-sdd/opsx-check/SKILL.md +21 -4
  19. package/templates/skills/kld-sdd/opsx-check/checklist.md +19 -1
  20. package/templates/skills/kld-sdd/opsx-design/SKILL.md +2 -0
  21. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +2 -0
  22. package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
  23. package/templates/skills/kld-sdd/opsx-propose/reference.md +6 -4
  24. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +23 -0
  25. package/templates/skills/kld-sdd/opsx-spec/checklist.md +5 -0
  26. package/templates/skills/kld-sdd/opsx-task/SKILL.md +43 -8
  27. package/templates/skills/kld-sdd/opsx-task/checklist.md +15 -0
  28. package/templates/skills/kld-sdd/opsx-task/reference.md +73 -2
  29. package/templates/skills/kld-sdd/opsx-test/SKILL.md +17 -0
@@ -10,6 +10,11 @@ const { validateTraceability } = require('./traceability-validator.cjs');
10
10
  const { buildIdentityCatalog } = require('./identity-index.cjs');
11
11
  const { buildEffectiveGraph } = require('./effective-graph.cjs');
12
12
  const { generateUuidV7 } = require('./id.cjs');
13
+ const {
14
+ WORKING_ARTIFACT_SCHEMA_VERSION,
15
+ ARTIFACT_INDEX_SCHEMA_VERSION,
16
+ buildWorkingArtifactFacts,
17
+ } = require('./working-artifacts.cjs');
13
18
  const {
14
19
  acquireChangeLock,
15
20
  assertChangeLock,
@@ -20,26 +25,30 @@ function sha256(value) {
20
25
  return crypto.createHash('sha256').update(value).digest('hex');
21
26
  }
22
27
 
23
- function statePaths(projectRoot, changeName, revision = '') {
28
+ function resolveChangeDir(projectRoot, changeName) {
29
+ const root = path.resolve(projectRoot || process.cwd());
30
+ return path.join(root, 'openspec', 'changes', safeChangeName(changeName));
31
+ }
32
+
33
+ function changeOntologyPaths(projectRoot, changeName) {
34
+ const changeDir = resolveChangeDir(projectRoot, changeName);
35
+ return {
36
+ changeDir,
37
+ working: path.join(changeDir, 'working-ontology.json'),
38
+ diagnostics: path.join(changeDir, 'diagnostics.json'),
39
+ fileIndex: path.join(changeDir, 'file-index.json'),
40
+ artifactIndex: path.join(changeDir, 'artifact-index.json'),
41
+ artifactDir: path.join(changeDir, 'artifacts'),
42
+ };
43
+ }
44
+
45
+ function statePaths(projectRoot, changeName) {
24
46
  const root = path.resolve(projectRoot || process.cwd());
25
47
  const safeName = safeChangeName(changeName);
26
48
  const dir = path.join(root, 'skywalk-sdd', 'state', 'ontology', safeName);
27
- const revisions = path.join(dir, 'revisions');
28
- const revisionDir = revision ? path.join(revisions, revision) : '';
29
49
  return {
30
50
  dir,
31
- revisions,
32
51
  current: path.join(dir, 'current.json'),
33
- revisionDir,
34
- working: revisionDir
35
- ? path.join(revisionDir, 'working-ontology.json')
36
- : path.join(dir, 'working-ontology.json'),
37
- diagnostics: revisionDir
38
- ? path.join(revisionDir, 'diagnostics.json')
39
- : path.join(dir, 'diagnostics.json'),
40
- fileIndex: revisionDir
41
- ? path.join(revisionDir, 'file-index.json')
42
- : path.join(dir, 'file-index.json'),
43
52
  };
44
53
  }
45
54
 
@@ -48,7 +57,7 @@ function serialized(value) {
48
57
  }
49
58
 
50
59
  function atomicWriteIfChanged(filePath, value) {
51
- const content = serialized(value);
60
+ const content = typeof value === 'string' ? value : serialized(value);
52
61
  if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === content) {
53
62
  return false;
54
63
  }
@@ -74,7 +83,7 @@ function readJson(filePath, code) {
74
83
  }
75
84
 
76
85
  function readRevisionBundle(paths, expectedRevision) {
77
- for (const filePath of [paths.working, paths.diagnostics, paths.fileIndex]) {
86
+ for (const filePath of [paths.working, paths.diagnostics, paths.fileIndex, paths.artifactIndex]) {
78
87
  if (!fs.existsSync(filePath)) {
79
88
  throw stateError('SEM_STATE_INCOMPLETE', `revision ${expectedRevision} 缺少 ${path.basename(filePath)}`);
80
89
  }
@@ -82,59 +91,161 @@ function readRevisionBundle(paths, expectedRevision) {
82
91
  const working = readJson(paths.working, 'SEM_STATE_CORRUPT');
83
92
  const diagnostics = readJson(paths.diagnostics, 'SEM_STATE_CORRUPT');
84
93
  const fileIndex = readJson(paths.fileIndex, 'SEM_STATE_CORRUPT');
85
- const revisions = [working.revision, diagnostics.revision, fileIndex.revision];
94
+ const artifactIndex = readJson(paths.artifactIndex, 'SEM_STATE_CORRUPT');
95
+ const revisions = [working.revision, diagnostics.revision, fileIndex.revision, artifactIndex.revision];
86
96
  if (revisions.some((revision) => revision !== expectedRevision)) {
87
97
  throw stateError(
88
98
  'SEM_STATE_REVISION_MISMATCH',
89
99
  `current=${expectedRevision},working/diagnostics/file-index=${revisions.join('/')}`,
90
100
  );
91
101
  }
92
- return { working, diagnostics, fileIndex };
102
+ for (const artifact of artifactIndex.artifacts || []) {
103
+ const artifactPath = path.resolve(paths.changeDir, artifact.json_path || '');
104
+ const artifactRelative = path.relative(paths.changeDir, artifactPath);
105
+ if (
106
+ !artifact.json_path
107
+ || artifactRelative.startsWith('..')
108
+ || path.isAbsolute(artifactRelative)
109
+ || !fs.existsSync(artifactPath)
110
+ ) {
111
+ throw stateError(
112
+ 'SEM_STATE_INCOMPLETE',
113
+ `revision ${expectedRevision} 缺少 Artifact JSON: ${artifact.json_path || '(empty)'}`,
114
+ );
115
+ }
116
+ const artifactFacts = readJson(artifactPath, 'SEM_STATE_CORRUPT');
117
+ if (artifactFacts.revision !== expectedRevision) {
118
+ throw stateError(
119
+ 'SEM_STATE_REVISION_MISMATCH',
120
+ `current=${expectedRevision},artifact=${artifactFacts.revision}`,
121
+ );
122
+ }
123
+ }
124
+ return { working, diagnostics, fileIndex, artifactIndex };
125
+ }
126
+
127
+ function pruneStaleArtifacts(changeDir, artifactIndex) {
128
+ const artifactRoot = path.join(changeDir, 'artifacts');
129
+ if (!fs.existsSync(artifactRoot)) return;
130
+ const allowed = new Set((artifactIndex.artifacts || []).map((artifact) => artifact.json_path));
131
+ const stale = [];
132
+ (function walk(currentDir, relativePrefix) {
133
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
134
+ const relativePath = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
135
+ const absolutePath = path.join(currentDir, entry.name);
136
+ if (entry.isDirectory()) {
137
+ walk(absolutePath, relativePath);
138
+ continue;
139
+ }
140
+ if (!entry.name.endsWith('.ontology.json')) continue;
141
+ const jsonPath = `artifacts/${relativePath.replace(/\\/g, '/')}`;
142
+ if (!allowed.has(jsonPath)) stale.push(absolutePath);
143
+ }
144
+ }(artifactRoot, ''));
145
+ for (const filePath of stale) {
146
+ fs.rmSync(filePath, { force: true });
147
+ }
148
+ (function removeEmptyDirs(currentDir) {
149
+ if (!fs.existsSync(currentDir) || currentDir === artifactRoot) return;
150
+ const entries = fs.readdirSync(currentDir);
151
+ for (const entry of entries) {
152
+ const child = path.join(currentDir, entry);
153
+ if (fs.statSync(child).isDirectory()) removeEmptyDirs(child);
154
+ }
155
+ if (fs.readdirSync(currentDir).length === 0) fs.rmdirSync(currentDir);
156
+ }(artifactRoot));
93
157
  }
94
158
 
95
159
  function commitRevision(projectRoot, changeName, result, lock) {
96
160
  assertChangeLock(lock);
97
161
  const basePaths = statePaths(projectRoot, changeName);
98
- const paths = statePaths(projectRoot, changeName, result.revision);
99
- fs.mkdirSync(basePaths.revisions, { recursive: true });
162
+ const paths = changeOntologyPaths(projectRoot, changeName);
163
+ fs.mkdirSync(basePaths.dir, { recursive: true });
164
+ fs.mkdirSync(paths.changeDir, { recursive: true });
100
165
 
101
- if (fs.existsSync(paths.revisionDir)) {
102
- readRevisionBundle(paths, result.revision);
103
- } else {
104
- const stagingDir = path.join(
105
- basePaths.revisions,
106
- `.${result.revision}.${lock.token}.staging`,
107
- );
108
- fs.rmSync(stagingDir, { recursive: true, force: true });
109
- fs.mkdirSync(stagingDir, { recursive: true });
110
- const stagingPaths = {
111
- working: path.join(stagingDir, 'working-ontology.json'),
112
- diagnostics: path.join(stagingDir, 'diagnostics.json'),
113
- fileIndex: path.join(stagingDir, 'file-index.json'),
114
- };
115
- fs.writeFileSync(stagingPaths.working, serialized(result.state), 'utf8');
116
- fs.writeFileSync(stagingPaths.diagnostics, serialized({
117
- schema_version: SCHEMA_VERSION,
118
- change: changeName,
119
- profile: result.profile,
120
- valid: result.valid,
121
- revision: result.revision,
122
- diagnostics: result.diagnostics,
123
- }), 'utf8');
124
- fs.writeFileSync(stagingPaths.fileIndex, serialized(result.fileIndex), 'utf8');
125
- assertChangeLock(lock);
126
- fs.renameSync(stagingDir, paths.revisionDir);
166
+ if (fs.existsSync(paths.working)) {
167
+ const existing = readJson(paths.working, 'SEM_STATE_CORRUPT');
168
+ if (existing.revision === result.revision) {
169
+ readRevisionBundle(paths, result.revision);
170
+ const artifactIndex = readJson(paths.artifactIndex, 'SEM_STATE_CORRUPT');
171
+ return {
172
+ changed: false,
173
+ paths: {
174
+ ...paths,
175
+ current: basePaths.current,
176
+ artifactFacts: (artifactIndex.artifacts || []).map((artifact) => (
177
+ path.join(paths.changeDir, artifact.json_path)
178
+ )),
179
+ },
180
+ };
181
+ }
182
+ }
183
+
184
+ const stagingDir = path.join(basePaths.dir, `.${result.revision}.${lock.token}.staging`);
185
+ fs.rmSync(stagingDir, { recursive: true, force: true });
186
+ fs.mkdirSync(stagingDir, { recursive: true });
187
+ const artifactBundle = buildWorkingArtifactFacts(result);
188
+ const stagedFiles = {
189
+ working: path.join(stagingDir, 'working-ontology.json'),
190
+ diagnostics: path.join(stagingDir, 'diagnostics.json'),
191
+ fileIndex: path.join(stagingDir, 'file-index.json'),
192
+ artifactIndex: path.join(stagingDir, 'artifact-index.json'),
193
+ };
194
+ fs.writeFileSync(stagedFiles.working, serialized(result.state), 'utf8');
195
+ fs.writeFileSync(stagedFiles.diagnostics, serialized({
196
+ schema_version: SCHEMA_VERSION,
197
+ change: changeName,
198
+ profile: result.profile,
199
+ valid: result.valid,
200
+ revision: result.revision,
201
+ diagnostics: result.diagnostics,
202
+ }), 'utf8');
203
+ fs.writeFileSync(stagedFiles.fileIndex, serialized(result.fileIndex), 'utf8');
204
+ fs.writeFileSync(stagedFiles.artifactIndex, serialized(artifactBundle.index), 'utf8');
205
+ for (const artifactFile of artifactBundle.files) {
206
+ const targetPath = path.join(stagingDir, artifactFile.path);
207
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
208
+ fs.writeFileSync(targetPath, serialized(artifactFile.value), 'utf8');
127
209
  }
210
+ assertChangeLock(lock);
211
+ readRevisionBundle({
212
+ changeDir: stagingDir,
213
+ working: stagedFiles.working,
214
+ diagnostics: stagedFiles.diagnostics,
215
+ fileIndex: stagedFiles.fileIndex,
216
+ artifactIndex: stagedFiles.artifactIndex,
217
+ }, result.revision);
218
+
219
+ atomicWriteIfChanged(paths.working, fs.readFileSync(stagedFiles.working, 'utf8'));
220
+ atomicWriteIfChanged(paths.diagnostics, fs.readFileSync(stagedFiles.diagnostics, 'utf8'));
221
+ atomicWriteIfChanged(paths.fileIndex, fs.readFileSync(stagedFiles.fileIndex, 'utf8'));
222
+ atomicWriteIfChanged(paths.artifactIndex, fs.readFileSync(stagedFiles.artifactIndex, 'utf8'));
223
+ for (const artifactFile of artifactBundle.files) {
224
+ const stagedPath = path.join(stagingDir, artifactFile.path);
225
+ atomicWriteIfChanged(path.join(paths.changeDir, artifactFile.path), fs.readFileSync(stagedPath, 'utf8'));
226
+ }
227
+ pruneStaleArtifacts(paths.changeDir, artifactBundle.index);
228
+ fs.rmSync(stagingDir, { recursive: true, force: true });
128
229
 
129
230
  const pointer = {
130
231
  schema_version: 'kld-sdd-state-pointer/v1',
131
232
  change: safeChangeName(changeName),
132
233
  revision: result.revision,
133
- path: `revisions/${result.revision}`,
234
+ change_dir: path.relative(path.resolve(projectRoot), paths.changeDir).replace(/\\/g, '/'),
235
+ artifact_index: 'artifact-index.json',
134
236
  };
135
237
  assertChangeLock(lock);
136
238
  const changed = atomicWriteIfChanged(basePaths.current, pointer);
137
- return { changed, paths: { ...paths, current: basePaths.current, revisions: basePaths.revisions } };
239
+ return {
240
+ changed,
241
+ paths: {
242
+ ...paths,
243
+ current: basePaths.current,
244
+ artifactFacts: artifactBundle.index.artifacts.map((artifact) => (
245
+ path.join(paths.changeDir, artifact.json_path)
246
+ )),
247
+ },
248
+ };
138
249
  }
139
250
 
140
251
  function scanChange(projectRoot, changeName, options = {}) {
@@ -164,6 +275,8 @@ function scanChange(projectRoot, changeName, options = {}) {
164
275
  const reviewStatus = options.markPending && validation.valid ? 'pending' : 'draft';
165
276
  const revisionInput = {
166
277
  schema_version: SCHEMA_VERSION,
278
+ artifact_schema_version: WORKING_ARTIFACT_SCHEMA_VERSION,
279
+ artifact_index_schema_version: ARTIFACT_INDEX_SCHEMA_VERSION,
167
280
  change: changeName,
168
281
  profile,
169
282
  files: facts.files,
@@ -179,6 +292,8 @@ function scanChange(projectRoot, changeName, options = {}) {
179
292
  const revision = sha256(JSON.stringify(revisionInput));
180
293
  const state = JSON.parse(JSON.stringify({
181
294
  schema_version: SCHEMA_VERSION,
295
+ artifact_schema_version: WORKING_ARTIFACT_SCHEMA_VERSION,
296
+ artifact_index_schema_version: ARTIFACT_INDEX_SCHEMA_VERSION,
182
297
  change: changeName,
183
298
  change_id: facts.change_id,
184
299
  profile,
@@ -237,18 +352,18 @@ function reconcileChange(projectRoot, changeName, options = {}) {
237
352
 
238
353
  function readWorkingState(projectRoot, changeName) {
239
354
  const basePaths = statePaths(projectRoot, changeName);
355
+ const paths = changeOntologyPaths(projectRoot, changeName);
240
356
  if (fs.existsSync(basePaths.current)) {
241
357
  const pointer = readJson(basePaths.current, 'SEM_STATE_POINTER_CORRUPT');
242
358
  if (!pointer.revision || pointer.change !== safeChangeName(changeName)) {
243
359
  throw stateError('SEM_STATE_POINTER_CORRUPT', `current.json 不属于 Change ${changeName}`);
244
360
  }
245
- return readRevisionBundle(
246
- statePaths(projectRoot, changeName, pointer.revision),
247
- pointer.revision,
248
- ).working;
361
+ return readRevisionBundle(paths, pointer.revision).working;
249
362
  }
250
- if (!fs.existsSync(basePaths.working)) return null;
251
- return readJson(basePaths.working, 'SEM_STATE_CORRUPT');
363
+ if (!fs.existsSync(paths.working)) return null;
364
+ const working = readJson(paths.working, 'SEM_STATE_CORRUPT');
365
+ readRevisionBundle(paths, working.revision);
366
+ return working;
252
367
  }
253
368
 
254
369
  function createArchiveSnapshot(projectRoot, changeName, archiveDir, inputState) {
@@ -276,6 +391,14 @@ function createArchiveSnapshot(projectRoot, changeName, archiveDir, inputState)
276
391
  if (!state.facts_hash || state.facts_hash !== actualFacts.facts_hash) {
277
392
  throw new Error(`归档正文与待确认本体事实不一致: ${changeName}`);
278
393
  }
394
+ const artifactIndexPath = path.join(targetDir, 'artifact-index.json');
395
+ if (!fs.existsSync(artifactIndexPath)) {
396
+ throw new Error(`归档目录缺少 artifact-index.json: ${targetDir}`);
397
+ }
398
+ const artifactIndex = readJson(artifactIndexPath, 'SEM_STATE_CORRUPT');
399
+ if (artifactIndex.revision !== state.revision) {
400
+ throw new Error(`归档 artifact-index 与待确认 revision 不一致: ${changeName}`);
401
+ }
279
402
  const snapshotId = `SNAP-${sha256(`${changeName}:${state.revision}`).slice(0, 16).toUpperCase()}`;
280
403
  const targetPath = path.join(targetDir, 'archive-ontology.json');
281
404
  const previous = fs.existsSync(targetPath)
@@ -330,6 +453,8 @@ function createArchiveSnapshot(projectRoot, changeName, archiveDir, inputState)
330
453
  }
331
454
 
332
455
  module.exports = {
456
+ resolveChangeDir,
457
+ changeOntologyPaths,
333
458
  statePaths,
334
459
  atomicWriteIfChanged,
335
460
  readRevisionBundle,
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const path = require('path');
5
+
6
+ const WORKING_ARTIFACT_SCHEMA_VERSION = 'kld-sdd-working-artifact-facts/v1';
7
+ const ARTIFACT_INDEX_SCHEMA_VERSION = 'kld-sdd-working-artifact-index/v1';
8
+
9
+ function sha256(value) {
10
+ return crypto.createHash('sha256').update(value).digest('hex');
11
+ }
12
+
13
+ function prefixedHash(value) {
14
+ const normalized = String(value || '').trim().toLowerCase().replace(/^sha256:/, '');
15
+ return normalized ? `sha256:${normalized}` : '';
16
+ }
17
+
18
+ function safeRelativePath(value) {
19
+ const input = String(value || '').trim().replace(/\\/g, '/');
20
+ const normalized = path.posix.normalize(input);
21
+ if (
22
+ !input
23
+ || input.startsWith('/')
24
+ || normalized !== input
25
+ || normalized === '..'
26
+ || normalized.startsWith('../')
27
+ || input.includes('\0')
28
+ ) {
29
+ throw new Error(`工作态 Artifact 路径不安全: ${value}`);
30
+ }
31
+ return normalized;
32
+ }
33
+
34
+ function artifactJsonPath(sourcePath) {
35
+ const normalized = safeRelativePath(sourcePath);
36
+ const extension = path.posix.extname(normalized);
37
+ const withoutExtension = extension
38
+ ? normalized.slice(0, -extension.length)
39
+ : normalized;
40
+ return `artifacts/${withoutExtension}.ontology.json`;
41
+ }
42
+
43
+ function normalizedSource(source, fallbackAnchor) {
44
+ const input = source && typeof source === 'object' ? source : {};
45
+ const line = Number(input.line || 0);
46
+ return {
47
+ artifact_type: String(input.artifact_type || 'unknown').trim().toLowerCase(),
48
+ file: safeRelativePath(input.file),
49
+ ...(Number.isInteger(line) && line > 0 ? { line } : {}),
50
+ anchor_id: String(input.anchor_id || fallbackAnchor || '').trim().toUpperCase(),
51
+ content_hash: prefixedHash(input.content_hash),
52
+ };
53
+ }
54
+
55
+ function workingEntity(entity, reviewStatus) {
56
+ const anchorId = String(entity.anchor_id || entity.id || '').trim().toUpperCase();
57
+ return {
58
+ anchor_id: anchorId,
59
+ type: entity.type,
60
+ name: String(entity.name || anchorId).trim(),
61
+ entity_id: String(entity.entity_id || '').trim().toLowerCase(),
62
+ entity_version_id: String(entity.entity_version_id || entity.version_id || '').trim().toLowerCase(),
63
+ predecessor_version_id: entity.predecessor_version_id
64
+ ? String(entity.predecessor_version_id).trim().toLowerCase()
65
+ : null,
66
+ delta_state: String(entity.delta_state || 'unresolved').trim().toLowerCase(),
67
+ content_hash: String(entity.content_hash || '').trim().toLowerCase(),
68
+ fact_kind: 'semantic',
69
+ assertion_type: 'asserted',
70
+ review_status: reviewStatus,
71
+ generation_role: entity.generation_role || 'current',
72
+ ...(entity.inherited_from ? { inherited_from: entity.inherited_from } : {}),
73
+ attributes: entity.attributes && typeof entity.attributes === 'object' ? entity.attributes : {},
74
+ source: normalizedSource(entity.source, anchorId),
75
+ };
76
+ }
77
+
78
+ function normalizeRelationDirection(relation, entityByAnchor) {
79
+ const originalType = String(relation.type || '').trim();
80
+ const originalFrom = String(relation.from || '').trim().toUpperCase();
81
+ const originalTo = String(relation.to || '').trim().toUpperCase();
82
+ if (originalType === 'acceptedBy') {
83
+ return { type: 'verifiedBy', from: originalFrom, to: originalTo };
84
+ }
85
+ if (originalType !== 'constrains') {
86
+ return { type: originalType, from: originalFrom, to: originalTo };
87
+ }
88
+ const fromType = entityByAnchor.get(originalFrom)?.type;
89
+ const toType = entityByAnchor.get(originalTo)?.type;
90
+ if (fromType === 'Constraint' && toType === 'SpecificationStatement') {
91
+ return { type: 'constrainedBy', from: originalTo, to: originalFrom };
92
+ }
93
+ return { type: 'constrainedBy', from: originalFrom, to: originalTo };
94
+ }
95
+
96
+ function workingRelation(relation, entityByAnchor, reviewStatus, producerVersion) {
97
+ const normalized = normalizeRelationDirection(relation, entityByAnchor);
98
+ const fromEntity = entityByAnchor.get(normalized.from);
99
+ const toEntity = entityByAnchor.get(normalized.to);
100
+ const assertionType = String(relation.assertion_type || 'asserted').trim().toLowerCase();
101
+ const output = {
102
+ type: normalized.type,
103
+ from_anchor_id: normalized.from,
104
+ to_anchor_id: normalized.to,
105
+ from_entity_id: String(fromEntity?.entity_id || '').trim().toLowerCase(),
106
+ to_entity_id: String(toEntity?.entity_id || '').trim().toLowerCase(),
107
+ assertion_type: assertionType,
108
+ fact_kind: 'semantic',
109
+ review_status: assertionType === 'suggested' ? 'pending' : reviewStatus,
110
+ generation_role: relation.generation_role || 'current',
111
+ ...(relation.inherited_from ? { inherited_from: relation.inherited_from } : {}),
112
+ source: normalizedSource(
113
+ relation.source,
114
+ `${normalized.from}-${normalized.type}-${normalized.to}`,
115
+ ),
116
+ };
117
+ if (assertionType === 'inferred') {
118
+ output.rule_id = String(relation.rule_id || '').trim();
119
+ output.generator = relation.generator || 'kld-sdd';
120
+ output.generator_version = relation.generator_version || producerVersion;
121
+ } else if (relation.rule_id) {
122
+ output.rule_id = relation.rule_id;
123
+ }
124
+ if (assertionType === 'suggested') {
125
+ output.generator = relation.generator;
126
+ output.generator_version = relation.generator_version;
127
+ output.confidence = relation.confidence;
128
+ output.support_evidence = Array.isArray(relation.support_evidence) ? relation.support_evidence : [];
129
+ output.opposition_evidence = Array.isArray(relation.opposition_evidence) ? relation.opposition_evidence : [];
130
+ }
131
+ return output;
132
+ }
133
+
134
+ function compareFacts(left, right) {
135
+ return (
136
+ String(left.anchor_id || left.type || '').localeCompare(String(right.anchor_id || right.type || ''))
137
+ || String(left.from_entity_id || '').localeCompare(String(right.from_entity_id || ''))
138
+ || String(left.to_entity_id || '').localeCompare(String(right.to_entity_id || ''))
139
+ );
140
+ }
141
+
142
+ function buildWorkingArtifactFacts(result, options = {}) {
143
+ const state = result.state || {};
144
+ const producerVersion = String(options.producerVersion || require('../../package.json').version);
145
+ const reviewStatus = state.review_status === 'pending' ? 'pending' : 'draft';
146
+ const rawEntities = Array.isArray(state.entities) ? state.entities : [];
147
+ const entityByAnchor = new Map(rawEntities.map((entity) => [
148
+ String(entity.anchor_id || entity.id || '').trim().toUpperCase(),
149
+ entity,
150
+ ]));
151
+ const artifactFiles = [];
152
+ const artifactIndex = [];
153
+
154
+ for (const artifact of state.artifacts || []) {
155
+ const sourcePath = safeRelativePath(artifact.path);
156
+ const jsonPath = artifactJsonPath(sourcePath);
157
+ const artifactDiagnostics = (result.diagnostics || []).filter((diagnostic) => (
158
+ !diagnostic.file || String(diagnostic.file).replace(/\\/g, '/') === sourcePath
159
+ ));
160
+ const artifactEntities = rawEntities
161
+ .filter((entity) => (
162
+ entity.source && String(entity.source.file).replace(/\\/g, '/') === sourcePath
163
+ ))
164
+ .map((entity) => workingEntity(entity, reviewStatus))
165
+ .sort(compareFacts);
166
+ const artifactRelations = (state.relations || [])
167
+ .filter((relation) => (
168
+ relation.source && String(relation.source.file).replace(/\\/g, '/') === sourcePath
169
+ ))
170
+ .map((relation) => workingRelation(relation, entityByAnchor, reviewStatus, producerVersion))
171
+ .sort(compareFacts);
172
+ const inheritedReferences = (state.inherited_references || []).filter((reference) => (
173
+ reference.source && String(reference.source.file).replace(/\\/g, '/') === sourcePath
174
+ ));
175
+ const inheritedRelations = (state.inherited_relations || []).filter((relation) => (
176
+ relation.source && String(relation.source.file).replace(/\\/g, '/') === sourcePath
177
+ ));
178
+ const semanticFacts = {
179
+ entities: artifactEntities,
180
+ relations: artifactRelations,
181
+ inherited_references: inheritedReferences,
182
+ inherited_relations: inheritedRelations,
183
+ };
184
+ const artifactFacts = {
185
+ schema_version: WORKING_ARTIFACT_SCHEMA_VERSION,
186
+ producer: `kld-sdd@${producerVersion}`,
187
+ canonical: false,
188
+ change: state.change,
189
+ change_id: state.change_id,
190
+ profile: state.profile,
191
+ stage: artifact.type,
192
+ review_status: reviewStatus,
193
+ valid: !artifactDiagnostics.some((diagnostic) => diagnostic.severity === 'error'),
194
+ revision: result.revision,
195
+ source: {
196
+ artifact_type: artifact.type,
197
+ path: sourcePath,
198
+ capability_id: artifact.capability_id || null,
199
+ content_hash: prefixedHash(artifact.content_hash),
200
+ },
201
+ facts_hash: sha256(JSON.stringify(semanticFacts)),
202
+ ...semanticFacts,
203
+ diagnostics: artifactDiagnostics,
204
+ };
205
+ artifactFiles.push({ path: jsonPath, value: artifactFacts });
206
+ artifactIndex.push({
207
+ artifact_type: artifact.type,
208
+ source_path: sourcePath,
209
+ json_path: jsonPath,
210
+ capability_id: artifact.capability_id || null,
211
+ source_content_hash: prefixedHash(artifact.content_hash),
212
+ facts_hash: artifactFacts.facts_hash,
213
+ entity_count: artifactEntities.length,
214
+ relation_count: artifactRelations.length,
215
+ diagnostic_count: artifactDiagnostics.length,
216
+ });
217
+ }
218
+
219
+ return {
220
+ index: {
221
+ schema_version: ARTIFACT_INDEX_SCHEMA_VERSION,
222
+ producer: `kld-sdd@${producerVersion}`,
223
+ canonical: false,
224
+ change: state.change,
225
+ change_id: state.change_id,
226
+ profile: state.profile,
227
+ review_status: reviewStatus,
228
+ valid: Boolean(result.valid),
229
+ revision: result.revision,
230
+ working_ontology_path: 'working-ontology.json',
231
+ artifacts: artifactIndex,
232
+ },
233
+ files: artifactFiles,
234
+ };
235
+ }
236
+
237
+ module.exports = {
238
+ WORKING_ARTIFACT_SCHEMA_VERSION,
239
+ ARTIFACT_INDEX_SCHEMA_VERSION,
240
+ safeRelativePath,
241
+ artifactJsonPath,
242
+ buildWorkingArtifactFacts,
243
+ };
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  # 【用户选择配置 - 由 /opsx:propose 引导填写】
3
3
  change-id: "CHG-<CHANGE-SLUG>" # 创建时生成,后续不得修改
4
- entity-id: "<UUID>" # Change 的全局逻辑实体 UUID,added 时生成
4
+ entity-id: "<8位十六进制ID>" # Change 的全局逻辑实体 ID,added 时生成
5
5
  version-id: "<UUID>" # 本次 Change 版本 UUID
6
6
  delta-state: "added"
7
7
  predecessor-version: "" # added 留空;modified/removed 指向直接前序版本
@@ -192,11 +192,34 @@ f. **⛔ 立即更新任务状态** — tasks.md `- [ ]`→`- [x]` 与 `**状态
192
192
  g. **继续下一个层级** — 重新检查 DAG,找出依赖已满足的下一层级任务。
193
193
 
194
194
  **【S2 逐任务红绿节奏】**:每个任务完成后,在编译检查(d)和测试门禁(e)之外,如果 `test-strategy` 为 `tdd`,要求:
195
- 1. 先写/确认该任务相关的测试存在且 RED(失败原因与该任务目标直接相关)
196
- 2. 实现代码使测试 GREEN
197
- 3. 再进入下一个任务
195
+ 1. **测试-RED 任务**:编写带真实断言的测试 → 运行测试 → 确认失败(失败原因必须是功能未实现)→ 记录失败原因
196
+ 2. **实现-GREEN 任务**:读取对应 RED 的失败原因 → 写最少代码让测试通过 → 运行测试确认通过
197
+ 3. **重构-REFACTOR 任务**:在测试全绿状态下重构 → 运行全部测试确认仍绿
198
+ 4. 再进入下一个任务
198
199
 
199
- 这确保每个任务都有对应的测试保护,而非最后统一跑测试。
200
+ 这确保每个行为点都有对应的测试保护,测试真实驱动实现,而非最后统一跑测试。
201
+
202
+ **【S2.1 RED 测试质量标准】**(test-strategy=tdd 时强制):
203
+
204
+ > ⛔ 核心原则:**Mock 边界,不 Mock 行为**
205
+ >
206
+ > 测试应验证**真实行为链路**(输入→输出),而非验证"能否 catch mock 抛的异常"。
207
+
208
+ 1. **禁止 mock 被测行为本身**:测试的 Given 应准备**真实的前置条件**(如真实的 invalid token 字符串),让被测代码自然走完链路;而非直接 mock 出**期望的中间结果**(如 mock parseToken 抛异常)
209
+ - ❌ `when(jwtUtil.parseToken("invalid")).thenThrow(...)` — mock 了被测行为(token 解析失败),GREEN 只需加 try-catch
210
+ - ✅ 使用真实 JwtUtil + 真实 invalid token 字符串,让解析自然失败,测试验证完整链路
211
+
212
+ 2. **Mock 仅用于系统边界依赖**:数据库 Mapper、HTTP 客户端、消息队列等外部依赖可 mock;但被测类自身的业务逻辑、实现被测行为的工具类不可 mock
213
+ - ✅ Mock `BookMapper.countByPublisher()` — 数据库边界,需要真实 DB 环境才能验证
214
+ - ❌ Mock `JwtUtil.parseToken()` — 这是被测行为的实现,mock 它等于跳过了被测逻辑
215
+
216
+ 3. **RED 测试的 Given 必须是真实输入**:传入真实的数据(如 `"invalid"` 字符串、`null`、空对象),让被测代码自行处理;不能 mock 出"这个输入会导致什么结果"
217
+ - ❌ `when(parseToken("invalid")).thenThrow()` — 预定了"invalid 会导致抛异常"这个结论
218
+ - ✅ 传入 `"invalid"` 字符串,让真实的 parseToken 自行决定是否失败
219
+
220
+ 4. **GREEN 实现后测试不应需要修改**:测试描述行为契约(输入→期望输出),GREEN 只写让契约满足的最少代码。如果 GREEN 后需要改测试才能通过,说明 RED 测试有问题
221
+
222
+ 5. **判断标准**:如果删掉被测类的实现(方法体清空),测试是否仍然因为 mock 而通过?如果是,说明测试是假的——真实测试应该在实现缺失时失败
200
223
 
201
224
  ### 5f. 【S3 apply 结束前 checkbox 全量自检】
202
225
 
@@ -34,10 +34,37 @@ description: opsx-apply 的阶段强制检查点与自检清单。仅在执行 a
34
34
 
35
35
  ### §5e 测试执行门禁(按 `proposal.md` 的 `test-strategy`)
36
36
 
37
- - [ ] `tdd` → **⛔ 强制执行**:运行相关测试,测试失败禁止继续,必须修复
37
+ - [ ] `tdd` → **⛔ 强制执行**:
38
+ - 测试-RED 任务:运行测试确认**失败**,失败原因必须是功能未实现(非编译错误)
39
+ - 实现-GREEN 任务:运行测试确认**通过**
40
+ - 重构-REFACTOR 任务:运行**全部测试**确认仍绿
41
+ - 测试失败禁止继续,必须修复
38
42
  - [ ] `impl-first` → **⚠️ 警告模式**:运行测试,失败时显示警告但允许继续
39
43
  - [ ] `none` → **跳过**:不执行测试门禁
40
44
 
45
+ ### §5e.1 TDD 执行合规自检(仅 test-strategy=tdd 时)
46
+
47
+ - [ ] RED 任务执行后已运行测试并确认失败
48
+ - [ ] RED 任务失败原因是"功能未实现"而非编译错误
49
+ - [ ] GREEN 任务执行后已运行测试确认通过
50
+ - [ ] GREEN 任务未提前实现没有测试要求的功能
51
+ - [ ] REFACTOR 任务执行后已运行全部测试确认仍绿
52
+ - [ ] 未出现"先写生产代码再补测试"的情况
53
+
54
+ ### §5e.2 RED 测试质量门禁(仅 test-strategy=tdd 时,RED 任务完成后强制检查)
55
+
56
+ > ⛔ 核心原则:**Mock 边界,不 Mock 行为**
57
+
58
+ - [ ] **未 mock 被测行为本身**:测试的 Given 准备的是真实前置条件(如真实 invalid token 字符串),让被测代码自然走完链路;而非直接 mock 出期望的中间结果
59
+ - ❌ `when(jwtUtil.parseToken("invalid")).thenThrow(...)` — mock 了被测行为(解析失败),GREEN 只需加 try-catch
60
+ - ✅ 使用真实 JwtUtil + 真实 invalid token,让解析自然失败
61
+ - [ ] **Mock 仅用于系统边界依赖**:数据库 Mapper、HTTP 客户端等外部依赖可 mock;被测类自身的业务逻辑、实现被测行为的工具类不可 mock
62
+ - ✅ Mock `BookMapper.countByPublisher()` — 数据库边界
63
+ - ❌ Mock `JwtUtil.parseToken()` — 被测行为的实现
64
+ - [ ] **RED 测试的 Given 是真实输入**:传入真实数据让被测代码自行处理,不 mock 出"这个输入会导致什么结果"
65
+ - [ ] **删掉实现后测试仍因 mock 而通过?** 如果是 → 测试是假的,必须重写
66
+ - [ ] **GREEN 后测试不需要修改**:如果 GREEN 后需要改测试才能通过 → RED 测试有问题
67
+
41
68
  ### 任务状态实时更新
42
69
 
43
70
  - [ ] tasks.md:`- [ ]` → `- [x]`(拓扑图和任务详情中的复选框)
@@ -81,6 +108,7 @@ description: opsx-apply 的阶段强制检查点与自检清单。仅在执行 a
81
108
  - [ ] ⛔ **DAG 依赖拦截**:执行任务前必须检查依赖,前置未完成必须拦截
82
109
  - [ ] ⛔ **编译检查门禁**:每完成一个任务后必须运行编译检查,编译失败禁止标记已完成
83
110
  - [ ] ⛔ **测试执行门禁**:根据 `test-strategy` 决定(tdd=强制, impl-first=强制补跑, none=跳过);须真实执行并留 telemetry,`sdd-apply-test-gate` 校验非占位数据
111
+ - [ ] ⛔ **RED 测试质量门禁**:Mock 边界不 Mock 行为;未 mock 被测行为本身;Mock 仅用于系统边界依赖;RED 测试 Given 是真实输入
84
112
  - [ ] ⛔ **必须实时更新任务状态**:每完成一个任务立即改 tasks.md,两种格式(`- [ ]`→`- [x]` 与 `**状态**: [ ]`→`[x]`)同步
85
113
  - [ ] **Git 只读策略**:禁止为了度量自动初始化 Git、创建分支或提交 commit;非 Git 项目用 `vcs_mode=no-git` 继续执行
86
114
  - [ ] ⛔ **Step 0.1 隔离校验必做**:建 worktree / 建议分支名前必须完成 proposal + 跨 cap spec 依赖校验并输出报告;未通过不得按 full 并行策略拆 `kld-sdd/<change>/<cap>`