openxiangda 1.0.138 → 1.0.140

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/lib/sdd.js CHANGED
@@ -5,23 +5,32 @@ const os = require('os');
5
5
  const DEFAULT_SDD_DIR = 'openspec';
6
6
  const SDD_CONFIG_FILE = 'config.yaml';
7
7
  const CHANGE_META_FILE = 'change.json';
8
+ const COVERAGE_FILE = 'coverage.json';
9
+ const RELEASE_FILE = 'release.json';
8
10
  const BYPASS_LOG_FILE = path.join('changes', 'bypass-log.md');
11
+ const SDD_SCHEMA_VERSION = 'openxiangda-sdd-v2';
12
+ const LEGACY_SDD_SCHEMA_VERSION = 'openxiangda-sdd-v1';
9
13
 
10
- const DEFAULT_SPEC_CONTENT = `# OpenXiangda Platform Specification
14
+ const DEFAULT_SPEC_CONTENT = `# Application Specification
11
15
 
12
- This file is the current behavior contract for this application workspace.
16
+ This directory is the source of truth for this application's agreed behavior.
13
17
 
14
- ## Requirements
18
+ ## Purpose
19
+
20
+ Record user-facing behavior, data rules, permissions, workflows, automations, and runtime contracts for this OpenXiangda application. Platform governance rules live in OpenXiangda skills and references, not in this application spec.
15
21
 
16
- ### Requirement: SDD-governed high-risk changes
17
- High-risk OpenXiangda changes MUST be attached to an approved change before publishing.
22
+ ## Requirements
18
23
 
19
- #### Scenario: AI starts high-risk development
20
- - **Given** a requested change touches forms, pages, resources, functions, automations, workflows, runtime, or platform contracts
21
- - **When** the AI prepares implementation or release
22
- - **Then** it reads \`openxiangda sdd context --json\`, creates or continues a change, and waits for \`openxiangda sdd approve <change>\`
23
24
  `;
24
25
 
26
+ const PLACEHOLDER_PATTERNS = [
27
+ /\bTODO\b/i,
28
+ /\bTBD\b/i,
29
+ /Describe the behavior/i,
30
+ /填写|补充|待确认/,
31
+ /-\s*\*\*(Given|When|Then)\*\*\s*$/im,
32
+ ];
33
+
25
34
  function nowIso() {
26
35
  return new Date().toISOString();
27
36
  }
@@ -47,6 +56,15 @@ function normalizeChangeId(value) {
47
56
  return id;
48
57
  }
49
58
 
59
+ function slug(value, fallback = 'item') {
60
+ const normalized = String(value || '')
61
+ .trim()
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]+/g, '-')
64
+ .replace(/^-+|-+$/g, '');
65
+ return normalized || fallback;
66
+ }
67
+
50
68
  function ensureDir(dir) {
51
69
  fs.mkdirSync(dir, { recursive: true });
52
70
  }
@@ -98,6 +116,13 @@ function extractSddBlock(configText) {
98
116
  return '';
99
117
  }
100
118
 
119
+ function readConfigYamlSchema(rootDir) {
120
+ const file = path.join(rootDir, SDD_CONFIG_FILE);
121
+ if (!fs.existsSync(file)) return null;
122
+ const content = fs.readFileSync(file, 'utf8');
123
+ return content.match(/^\s*schema\s*:\s*([^\s#]+)/m)?.[1] || null;
124
+ }
125
+
101
126
  function getSddGovernanceConfig(options = {}) {
102
127
  const cwd = options.cwd || process.cwd();
103
128
  const configText = options.configText || '';
@@ -114,12 +139,17 @@ function getSddGovernanceConfig(options = {}) {
114
139
  ? false
115
140
  : enabled;
116
141
  const pathMatch = block.match(/path\s*:\s*['"]([^'"]+)['"]/);
142
+ const schemaMatch = block.match(/schemaVersion\s*:\s*['"]([^'"]+)['"]/);
117
143
  const sddPath = normalizeRelPath(pathMatch?.[1] || DEFAULT_SDD_DIR);
118
144
  const rootDir = path.join(cwd, sddPath);
119
145
  const initialized =
120
146
  fs.existsSync(path.join(rootDir, SDD_CONFIG_FILE)) ||
121
147
  fs.existsSync(path.join(rootDir, 'specs')) ||
122
148
  fs.existsSync(path.join(rootDir, 'changes'));
149
+ const schemaVersion =
150
+ schemaMatch?.[1] ||
151
+ readConfigYamlSchema(rootDir) ||
152
+ (initialized ? LEGACY_SDD_SCHEMA_VERSION : SDD_SCHEMA_VERSION);
123
153
  return {
124
154
  enabled,
125
155
  strictHighRisk,
@@ -127,23 +157,29 @@ function getSddGovernanceConfig(options = {}) {
127
157
  rootDir,
128
158
  initialized,
129
159
  configured,
160
+ schemaVersion,
130
161
  source: configured ? 'app-workspace.config.ts' : initialized ? 'openspec' : 'none',
131
162
  };
132
163
  }
133
164
 
134
165
  function buildDefaultConfigYaml() {
135
166
  return [
136
- 'schema: openxiangda-sdd-v1',
167
+ `schema: ${SDD_SCHEMA_VERSION}`,
137
168
  'description: OpenXiangda native SDD workspace records inspired by OpenSpec.',
138
169
  'artifacts:',
139
170
  ' proposal: proposal.md',
171
+ ' specs: specs/**/*.md',
140
172
  ' design: design.md',
141
173
  ' tasks: tasks.md',
142
174
  ' evidence: evidence.md',
175
+ ' coverage: coverage.json',
176
+ ' release: release.json',
143
177
  ' metadata: change.json',
144
178
  'rules:',
145
179
  ' requireApprovalForHighRisk: true',
146
180
  ' requireAffectedCoverage: true',
181
+ ' requireCoverageManifest: true',
182
+ ' requireScenarioEvidence: true',
147
183
  ' archiveMergesDeltaSpecs: true',
148
184
  '',
149
185
  ].join('\n');
@@ -154,16 +190,16 @@ function initSddWorkspace(options = {}) {
154
190
  const sddPath = normalizeRelPath(options.path || DEFAULT_SDD_DIR);
155
191
  const rootDir = path.join(cwd, sddPath);
156
192
  const operations = [];
157
- const force = Boolean(options.force);
193
+ const force = Boolean(options.force || options.upgrade);
158
194
 
159
195
  ensureDir(rootDir);
160
196
  ensureDir(path.join(rootDir, 'changes'));
161
197
  ensureDir(path.join(rootDir, 'changes', 'archive'));
162
- ensureDir(path.join(rootDir, 'specs', 'platform'));
198
+ ensureDir(path.join(rootDir, 'specs', 'app'));
163
199
  ensureDir(path.join(rootDir, 'templates', 'change'));
164
200
 
165
201
  writeFileIfMissing(path.join(rootDir, SDD_CONFIG_FILE), buildDefaultConfigYaml(), operations, force);
166
- writeFileIfMissing(path.join(rootDir, 'specs', 'platform', 'spec.md'), DEFAULT_SPEC_CONTENT, operations, false);
202
+ writeFileIfMissing(path.join(rootDir, 'specs', 'app', 'spec.md'), DEFAULT_SPEC_CONTENT, operations, false);
167
203
  writeFileIfMissing(path.join(rootDir, 'changes', '.gitkeep'), '', operations, false);
168
204
  writeFileIfMissing(path.join(rootDir, 'changes', 'archive', '.gitkeep'), '', operations, false);
169
205
  writeFileIfMissing(
@@ -173,10 +209,11 @@ function initSddWorkspace(options = {}) {
173
209
  '',
174
210
  'Create changes with `openxiangda sdd propose <change-id>`.',
175
211
  'High-risk implementation starts only after `openxiangda sdd approve <change-id>`.',
212
+ 'Before release, `coverage.json`, `release.json`, `tasks.md`, delta specs, and `evidence.md` must pass `openxiangda sdd verify <change> --changed`.',
176
213
  '',
177
214
  ].join('\n'),
178
215
  operations,
179
- false
216
+ force
180
217
  );
181
218
 
182
219
  return {
@@ -184,6 +221,7 @@ function initSddWorkspace(options = {}) {
184
221
  path: sddPath,
185
222
  rootDir,
186
223
  initialized: true,
224
+ schemaVersion: SDD_SCHEMA_VERSION,
187
225
  operations: operations.map(item => ({
188
226
  path: path.relative(cwd, item.path).replace(/\\/g, '/'),
189
227
  action: item.action,
@@ -230,11 +268,13 @@ function listChangeDirs(rootDir, includeArchive = false) {
230
268
  function readChange(changeDir) {
231
269
  const meta = readJsonFile(path.join(changeDir, CHANGE_META_FILE), null);
232
270
  const id = meta?.id || path.basename(changeDir);
271
+ const coverage = readJsonFile(path.join(changeDir, COVERAGE_FILE), null);
233
272
  return {
234
273
  id,
235
274
  dir: changeDir,
236
275
  relativeDir: changeDir.split(path.sep).slice(-2).join('/'),
237
276
  metadata: meta,
277
+ coverage,
238
278
  valid: Boolean(meta && !meta.__invalid),
239
279
  };
240
280
  }
@@ -258,6 +298,11 @@ function normalizeAffected(input = {}) {
258
298
  return {
259
299
  forms: splitList(affected.forms || affected.form),
260
300
  pages: splitList(affected.pages || affected.page),
301
+ functions: splitList(affected.functions || affected.function),
302
+ automations: splitList(affected.automations || affected.automation),
303
+ workflows: splitList(affected.workflows || affected.workflow),
304
+ jsCodeNodes: splitList(affected.jsCodeNodes || affected.jsCode || affected['js-code-nodes']),
305
+ files: splitList(affected.files || affected.file),
261
306
  resources: affected.resources === true || affected.resources === 'true' || splitList(affected.resources).includes('*'),
262
307
  runtime: affected.runtime === true || affected.runtime === 'true',
263
308
  backend: affected.backend === true || affected.backend === 'true',
@@ -267,6 +312,7 @@ function normalizeAffected(input = {}) {
267
312
 
268
313
  function createChangeMetadata(changeId, options = {}) {
269
314
  return {
315
+ schemaVersion: SDD_SCHEMA_VERSION,
270
316
  id: changeId,
271
317
  title: options.title || changeId,
272
318
  status: 'proposed',
@@ -288,24 +334,35 @@ function createChangeMetadata(changeId, options = {}) {
288
334
  };
289
335
  }
290
336
 
337
+ function formatAffectedList(items) {
338
+ return items.length ? items.join(', ') : '(none)';
339
+ }
340
+
291
341
  function createProposalContent(meta) {
292
342
  return `# Proposal: ${meta.title}
293
343
 
294
- ## Why
344
+ ## Intent
295
345
 
296
- -
346
+ Deliver "${meta.title}" as one focused OpenXiangda application change.
297
347
 
298
- ## What Changes
348
+ ## Scope
299
349
 
300
- -
350
+ - Forms: ${formatAffectedList(meta.affected.forms)}
351
+ - Pages: ${formatAffectedList(meta.affected.pages)}
352
+ - Resources: ${meta.affected.resources ? 'yes' : 'no'}
353
+ - Runtime: ${meta.affected.runtime ? 'yes' : 'no'}
354
+ - Backend logic: ${meta.affected.backend ? 'yes' : 'no'}
301
355
 
302
356
  ## Out Of Scope
303
357
 
304
- -
358
+ - Platform-level governance changes.
359
+ - Unrelated application behavior outside the affected resources above.
305
360
 
306
- ## Risks
361
+ ## Acceptance
307
362
 
308
- -
363
+ - Requirements in \`specs/**/spec.md\` describe the user-visible behavior.
364
+ - \`coverage.json\` maps requirements and scenarios to affected resources.
365
+ - \`evidence.md\` records verification commands before release.
309
366
  `;
310
367
  }
311
368
 
@@ -314,20 +371,26 @@ function createDesignContent(meta) {
314
371
 
315
372
  ## OpenXiangda Resource Impact
316
373
 
317
- - Forms: ${meta.affected.forms.join(', ') || '(none)'}
318
- - Pages: ${meta.affected.pages.join(', ') || '(none)'}
374
+ - Forms: ${formatAffectedList(meta.affected.forms)}
375
+ - Pages: ${formatAffectedList(meta.affected.pages)}
376
+ - Functions: ${formatAffectedList(meta.affected.functions)}
377
+ - Automations: ${formatAffectedList(meta.affected.automations)}
378
+ - Workflows: ${formatAffectedList(meta.affected.workflows)}
379
+ - JS_CODE nodes: ${formatAffectedList(meta.affected.jsCodeNodes)}
319
380
  - Resources: ${meta.affected.resources ? 'yes' : 'no'}
320
381
  - Runtime: ${meta.affected.runtime ? 'yes' : 'no'}
321
- - Backend/App Function/Automation logic: ${meta.affected.backend ? 'yes' : 'no'}
322
- - Contracts: ${meta.affected.contracts ? 'yes' : 'no'}
382
+ - Contracts/config: ${meta.affected.contracts ? 'yes' : 'no'}
323
383
 
324
384
  ## Data And Contract Notes
325
385
 
326
- -
386
+ - Keep application specs focused on observable behavior; implementation details stay here or in source code.
387
+ - Resource IDs remain profile-local under \`.openxiangda/state.json\`.
327
388
 
328
389
  ## Publish Plan
329
390
 
330
- - Run \`openxiangda workspace plan --changed\` before publish.
391
+ - Run \`openxiangda sdd verify ${meta.id} --changed\`.
392
+ - Run \`openxiangda workspace check --changed\`.
393
+ - Publish with the commands recorded in \`release.json\`.
331
394
  `;
332
395
  }
333
396
 
@@ -336,8 +399,11 @@ function createTasksContent() {
336
399
  '# Tasks',
337
400
  '',
338
401
  '- [ ] Confirm proposal and affected scope',
402
+ '- [ ] Sharpen delta specs into behavior-first requirements and scenarios',
339
403
  '- [ ] Implement changes',
340
- '- [ ] Run `openxiangda sdd verify <change>`',
404
+ '- [ ] Update coverage.json if affected resources change',
405
+ '- [ ] Record verification evidence',
406
+ '- [ ] Run `openxiangda sdd verify <change> --changed`',
341
407
  '- [ ] Run release plan/check commands',
342
408
  '- [ ] Archive the change after release',
343
409
  '',
@@ -350,11 +416,12 @@ function createEvidenceContent() {
350
416
  '',
351
417
  '## Verification',
352
418
  '',
353
- '- ',
419
+ '- [ ] Record `openxiangda sdd verify <change> --changed` result',
420
+ '- [ ] Record workspace/resource/runtime check or dry-run output',
354
421
  '',
355
422
  '## Release',
356
423
  '',
357
- '- ',
424
+ '- [ ] Record publish/deploy command output or explain why this change was not released',
358
425
  '',
359
426
  '## Bypass Log',
360
427
  '',
@@ -369,39 +436,118 @@ function createDeltaSpecContent(meta, domain) {
369
436
  ## ADDED Requirements
370
437
 
371
438
  ### Requirement: ${meta.title}
372
- Describe the behavior that will be true after this change.
439
+ The application SHALL provide the approved "${meta.title}" behavior for the affected OpenXiangda resources.
373
440
 
374
441
  #### Scenario: Main path
375
- - **Given**
376
- - **When**
377
- - **Then**
442
+ - **Given** an authorized user is using the affected application area
443
+ - **When** the user follows the approved ${meta.title} workflow
444
+ - **Then** the application produces the expected business result without relying on mock data or frontend-only authorization
378
445
  `;
379
446
  }
380
447
 
448
+ function createCoverageManifest(meta, domain) {
449
+ const reqId = `req-${slug(meta.title || meta.id, meta.id)}`;
450
+ return {
451
+ schemaVersion: SDD_SCHEMA_VERSION,
452
+ changeId: meta.id,
453
+ generatedAt: nowIso(),
454
+ resources: {
455
+ forms: meta.affected.forms,
456
+ pages: meta.affected.pages,
457
+ functions: meta.affected.functions,
458
+ automations: meta.affected.automations,
459
+ workflows: meta.affected.workflows,
460
+ jsCodeNodes: meta.affected.jsCodeNodes,
461
+ resources: meta.affected.resources,
462
+ runtime: meta.affected.runtime,
463
+ backend: meta.affected.backend,
464
+ contracts: meta.affected.contracts,
465
+ files: meta.affected.files,
466
+ },
467
+ requirements: [
468
+ {
469
+ id: reqId,
470
+ title: meta.title,
471
+ spec: `specs/${domain}/spec.md`,
472
+ scenarios: [
473
+ {
474
+ id: 'main-path',
475
+ title: 'Main path',
476
+ evidence: [],
477
+ },
478
+ ],
479
+ covers: {
480
+ forms: meta.affected.forms,
481
+ pages: meta.affected.pages,
482
+ functions: meta.affected.functions,
483
+ automations: meta.affected.automations,
484
+ workflows: meta.affected.workflows,
485
+ jsCodeNodes: meta.affected.jsCodeNodes,
486
+ resources: meta.affected.resources,
487
+ runtime: meta.affected.runtime,
488
+ backend: meta.affected.backend,
489
+ contracts: meta.affected.contracts,
490
+ files: meta.affected.files,
491
+ },
492
+ },
493
+ ],
494
+ };
495
+ }
496
+
497
+ function createReleaseManifest(meta) {
498
+ return {
499
+ schemaVersion: SDD_SCHEMA_VERSION,
500
+ changeId: meta.id,
501
+ targets: {
502
+ forms: meta.affected.forms,
503
+ pages: meta.affected.pages,
504
+ resources: meta.affected.resources,
505
+ runtime: meta.affected.runtime,
506
+ other: [],
507
+ },
508
+ changedFiles: [],
509
+ commands: [],
510
+ verifiedAt: null,
511
+ };
512
+ }
513
+
381
514
  function proposeSddChange(options = {}) {
382
515
  const governance = getSddGovernanceConfig(options);
383
516
  if (!governance.initialized) initSddWorkspace({ cwd: options.cwd, path: governance.path });
384
517
  const changeId = normalizeChangeId(options.changeId);
385
518
  const rootDir = governance.rootDir;
519
+ const domain = options.domain || 'app';
386
520
  const changeDir = path.join(rootDir, 'changes', changeId);
387
521
  if (fs.existsSync(changeDir) && !options.force) {
388
522
  throw new Error(`change 已存在: ${changeId}`);
389
523
  }
390
524
  ensureDir(changeDir);
391
- ensureDir(path.join(changeDir, 'specs', options.domain || 'app'));
525
+ ensureDir(path.join(changeDir, 'specs', domain));
392
526
  const meta = createChangeMetadata(changeId, options);
527
+ const coverage = createCoverageManifest(meta, domain);
528
+ const release = createReleaseManifest(meta);
393
529
  writeJsonFile(path.join(changeDir, CHANGE_META_FILE), meta);
530
+ writeJsonFile(path.join(changeDir, COVERAGE_FILE), coverage);
531
+ writeJsonFile(path.join(changeDir, RELEASE_FILE), release);
394
532
  fs.writeFileSync(path.join(changeDir, 'proposal.md'), createProposalContent(meta));
395
533
  fs.writeFileSync(path.join(changeDir, 'design.md'), createDesignContent(meta));
396
534
  fs.writeFileSync(path.join(changeDir, 'tasks.md'), createTasksContent());
397
535
  fs.writeFileSync(path.join(changeDir, 'evidence.md'), createEvidenceContent());
398
536
  fs.writeFileSync(
399
- path.join(changeDir, 'specs', options.domain || 'app', 'spec.md'),
400
- createDeltaSpecContent(meta, options.domain || 'app')
537
+ path.join(changeDir, 'specs', domain, 'spec.md'),
538
+ createDeltaSpecContent(meta, domain)
401
539
  );
402
540
  return {
541
+ schemaVersion: SDD_SCHEMA_VERSION,
403
542
  change: meta,
543
+ coverage,
544
+ release,
404
545
  dir: path.relative(options.cwd || process.cwd(), changeDir).replace(/\\/g, '/'),
546
+ nextActions: [
547
+ `Review openspec/changes/${changeId}/proposal.md`,
548
+ `Sharpen openspec/changes/${changeId}/specs/${domain}/spec.md`,
549
+ `Run openxiangda sdd approve ${changeId} after user confirmation`,
550
+ ],
405
551
  };
406
552
  }
407
553
 
@@ -415,13 +561,28 @@ function loadSddChange(options = {}) {
415
561
  if (!meta || meta.__invalid) {
416
562
  throw new Error(`change metadata 无效: ${metaFile}`);
417
563
  }
418
- return { governance, changeId, changeDir, meta, metaFile };
564
+ const coverageFile = path.join(changeDir, COVERAGE_FILE);
565
+ const releaseFile = path.join(changeDir, RELEASE_FILE);
566
+ const coverage = readJsonFile(coverageFile, null);
567
+ const release = readJsonFile(releaseFile, null);
568
+ return {
569
+ governance,
570
+ changeId,
571
+ changeDir,
572
+ meta,
573
+ metaFile,
574
+ coverage,
575
+ coverageFile,
576
+ release,
577
+ releaseFile,
578
+ };
419
579
  }
420
580
 
421
581
  function approveSddChange(options = {}) {
422
582
  const loaded = loadSddChange(options);
423
583
  const meta = {
424
584
  ...loaded.meta,
585
+ schemaVersion: loaded.meta.schemaVersion || loaded.governance.schemaVersion || LEGACY_SDD_SCHEMA_VERSION,
425
586
  status: 'approved',
426
587
  approvedAt: nowIso(),
427
588
  approvedBy: options.approvedBy || os.userInfo().username || 'unknown',
@@ -430,15 +591,22 @@ function approveSddChange(options = {}) {
430
591
  };
431
592
  writeJsonFile(loaded.metaFile, meta);
432
593
  return {
594
+ schemaVersion: meta.schemaVersion,
433
595
  change: meta,
596
+ coverage: loaded.coverage,
434
597
  dir: path.relative(options.cwd || process.cwd(), loaded.changeDir).replace(/\\/g, '/'),
598
+ nextActions: [
599
+ `Implement only the approved scope in openspec/changes/${loaded.changeId}`,
600
+ `Complete tasks.md and evidence.md`,
601
+ `Run openxiangda sdd verify ${loaded.changeId} --changed before release`,
602
+ ],
435
603
  };
436
604
  }
437
605
 
438
606
  function includesAll(list, required) {
439
607
  if (!required || required.length === 0) return true;
440
- if (list.includes('*')) return true;
441
- return required.every(item => list.includes(item));
608
+ if ((list || []).includes('*')) return true;
609
+ return required.every(item => (list || []).includes(item));
442
610
  }
443
611
 
444
612
  function hasHighRiskTargets(targets = {}) {
@@ -491,6 +659,7 @@ function affectedCoversHighRiskFiles(affected, files = []) {
491
659
  const normalized = normalizeAffected(affected);
492
660
  const missing = [];
493
661
  for (const file of files) {
662
+ if (includesAll(normalized.files, [file])) continue;
494
663
  if (file === 'app-workspace.config.ts' && !normalized.contracts && !normalized.runtime) {
495
664
  missing.push(file);
496
665
  } else if (file.startsWith('src/forms/')) {
@@ -501,15 +670,18 @@ function affectedCoversHighRiskFiles(affected, files = []) {
501
670
  if (!normalized.runtime && !includesAll(normalized.pages, [pageCode])) missing.push(file);
502
671
  } else if (file.startsWith('src/resources/') && !normalized.resources) {
503
672
  missing.push(file);
504
- } else if (
505
- (file.startsWith('src/functions/') ||
506
- file.startsWith('src/automations/') ||
507
- file.startsWith('src/workflows/') ||
508
- file.startsWith('src/js-code-nodes/')) &&
509
- !normalized.backend &&
510
- !normalized.resources
511
- ) {
512
- missing.push(file);
673
+ } else if (file.startsWith('src/functions/')) {
674
+ const code = file.split('/')[2];
675
+ if (!normalized.backend && !normalized.resources && !includesAll(normalized.functions, [code])) missing.push(file);
676
+ } else if (file.startsWith('src/automations/')) {
677
+ const code = file.split('/')[2];
678
+ if (!normalized.backend && !normalized.resources && !includesAll(normalized.automations, [code])) missing.push(file);
679
+ } else if (file.startsWith('src/workflows/')) {
680
+ const code = file.split('/')[2];
681
+ if (!normalized.backend && !normalized.resources && !includesAll(normalized.workflows, [code])) missing.push(file);
682
+ } else if (file.startsWith('src/js-code-nodes/')) {
683
+ const code = file.split('/')[2];
684
+ if (!normalized.backend && !normalized.resources && !includesAll(normalized.jsCodeNodes, [code])) missing.push(file);
513
685
  } else if (file.startsWith('src/runtime/') && !normalized.runtime) {
514
686
  missing.push(file);
515
687
  }
@@ -520,10 +692,137 @@ function affectedCoversHighRiskFiles(affected, files = []) {
520
692
  };
521
693
  }
522
694
 
695
+ function normalizeCoverageResources(coverage, fallbackAffected = {}, options = {}) {
696
+ const fallback = options.allowFallback ? normalizeAffected(fallbackAffected) : normalizeAffected({});
697
+ const resources = coverage?.resources || {};
698
+ const requirementCovers = Array.isArray(coverage?.requirements)
699
+ ? coverage.requirements.map(item => item.covers || {})
700
+ : [];
701
+ const mergeList = key => [
702
+ ...splitList(resources[key]),
703
+ ...requirementCovers.flatMap(item => splitList(item[key])),
704
+ ...fallback[key],
705
+ ];
706
+ const hasBool = key =>
707
+ resources[key] === true ||
708
+ resources[key] === 'true' ||
709
+ requirementCovers.some(item => item[key] === true || item[key] === 'true') ||
710
+ Boolean(fallback[key]);
711
+ return {
712
+ forms: Array.from(new Set(mergeList('forms'))),
713
+ pages: Array.from(new Set(mergeList('pages'))),
714
+ functions: Array.from(new Set(mergeList('functions'))),
715
+ automations: Array.from(new Set(mergeList('automations'))),
716
+ workflows: Array.from(new Set(mergeList('workflows'))),
717
+ jsCodeNodes: Array.from(new Set(mergeList('jsCodeNodes'))),
718
+ files: Array.from(new Set(mergeList('files'))),
719
+ resources: hasBool('resources'),
720
+ runtime: hasBool('runtime'),
721
+ backend: hasBool('backend'),
722
+ contracts: hasBool('contracts'),
723
+ };
724
+ }
725
+
726
+ function coverageCoversTargets(coverage, affected, targets = {}, options = {}) {
727
+ return affectedCoversTargets(normalizeCoverageResources(coverage, affected, options), targets);
728
+ }
729
+
730
+ function coverageCoversHighRiskFiles(coverage, affected, files = [], options = {}) {
731
+ return affectedCoversHighRiskFiles(normalizeCoverageResources(coverage, affected, options), files);
732
+ }
733
+
523
734
  function getApprovedActiveChanges(rootDir) {
524
735
  return listChanges(rootDir)
525
736
  .filter(item => item.valid && item.metadata.status === 'approved' && item.metadata.approvedAt)
526
- .map(item => item.metadata);
737
+ .map(item => ({
738
+ ...item.metadata,
739
+ coverage: item.coverage,
740
+ }));
741
+ }
742
+
743
+ function summarizeSpecFile(rootDir, file) {
744
+ const content = fs.readFileSync(file, 'utf8');
745
+ const rel = path.relative(path.join(rootDir, 'specs'), file).replace(/\\/g, '/');
746
+ const title = content.match(/^#\s+(.+)$/m)?.[1] || rel;
747
+ const purposeIndex = content.indexOf('## Purpose');
748
+ let purpose = '';
749
+ if (purposeIndex >= 0) {
750
+ purpose = content
751
+ .slice(purposeIndex)
752
+ .split(/\r?\n/)
753
+ .slice(1)
754
+ .find(line => line.trim() && !line.startsWith('#')) || '';
755
+ }
756
+ return {
757
+ path: `specs/${rel}`,
758
+ title,
759
+ purpose: purpose.trim(),
760
+ requirementCount: (content.match(/^### Requirement:/gm) || []).length,
761
+ };
762
+ }
763
+
764
+ function summarizeChange(cwd, item) {
765
+ return {
766
+ id: item.id,
767
+ status: item.metadata?.status || 'invalid',
768
+ approvedAt: item.metadata?.approvedAt || null,
769
+ schemaVersion: item.metadata?.schemaVersion || LEGACY_SDD_SCHEMA_VERSION,
770
+ affected: item.metadata?.affected || null,
771
+ coverage: summarizeCoverage(item.coverage),
772
+ dir: path.relative(cwd, item.dir).replace(/\\/g, '/'),
773
+ };
774
+ }
775
+
776
+ function summarizeCoverage(coverage) {
777
+ if (!coverage || coverage.__invalid) return null;
778
+ const resources = normalizeCoverageResources(coverage, {});
779
+ return {
780
+ requirements: Array.isArray(coverage.requirements) ? coverage.requirements.length : 0,
781
+ forms: resources.forms,
782
+ pages: resources.pages,
783
+ functions: resources.functions,
784
+ automations: resources.automations,
785
+ workflows: resources.workflows,
786
+ jsCodeNodes: resources.jsCodeNodes,
787
+ resources: resources.resources,
788
+ runtime: resources.runtime,
789
+ };
790
+ }
791
+
792
+ function chooseActiveChange(changes, targets = {}, highRiskFiles = []) {
793
+ const candidates = changes
794
+ .filter(item => item.valid && !['archived', 'rejected'].includes(item.metadata.status))
795
+ .filter(item => item.metadata.status === 'approved' && item.metadata.approvedAt);
796
+ return (
797
+ candidates.find(item => {
798
+ const legacyMode = (item.metadata?.schemaVersion || LEGACY_SDD_SCHEMA_VERSION) !== SDD_SCHEMA_VERSION;
799
+ const allowFallback = legacyMode && (!item.coverage || item.coverage.__invalid);
800
+ return (
801
+ coverageCoversTargets(item.coverage, item.metadata.affected, targets, { allowFallback }).covered &&
802
+ coverageCoversHighRiskFiles(item.coverage, item.metadata.affected, highRiskFiles, { allowFallback }).covered
803
+ );
804
+ }) || candidates[0] || null
805
+ );
806
+ }
807
+
808
+ function buildNextActions(governance, activeChanges, activeChange, targets, highRiskFiles) {
809
+ if (!governance.enabled) return ['Enable SDD governance or run without SDD for low-risk work.'];
810
+ if (!governance.initialized) return ['Run `openxiangda sdd init --upgrade`.'];
811
+ if (hasHighRiskTargets(targets) || highRiskFiles.length > 0) {
812
+ if (!activeChanges.length) return ['Run `openxiangda sdd propose <change>` before editing high-risk files.'];
813
+ if (!activeChange) return ['Approve or create an SDD change whose coverage matches the changed resources.'];
814
+ if (activeChange.metadata?.status !== 'approved') {
815
+ return [`Run \`openxiangda sdd approve ${activeChange.id}\` after user confirmation.`];
816
+ }
817
+ return [
818
+ `Complete openspec/changes/${activeChange.id}/tasks.md and evidence.md`,
819
+ `Run openxiangda sdd verify ${activeChange.id} --changed`,
820
+ `Archive with openxiangda sdd archive ${activeChange.id} after release`,
821
+ ];
822
+ }
823
+ return activeChanges.length
824
+ ? ['Continue or archive active SDD changes when finished.']
825
+ : ['No high-risk SDD action required for the current release target.'];
527
826
  }
528
827
 
529
828
  function buildSddGovernanceReport(options = {}) {
@@ -535,15 +834,30 @@ function buildSddGovernanceReport(options = {}) {
535
834
  const warnings = [];
536
835
  const errors = [];
537
836
  let activeChanges = [];
837
+ let activeChange = null;
838
+ let coverage = null;
538
839
 
539
840
  if (!governance.enabled) {
540
841
  warnings.push('未启用 SDD governance;运行 openxiangda sdd init 可开启高风险变更记录。');
541
842
  checks.push({ name: 'sdd', status: 'warn', message: warnings[0] });
542
- return { governance, highRiskFiles, activeChanges, checks, warnings, errors };
843
+ return {
844
+ schemaVersion: governance.schemaVersion,
845
+ governance,
846
+ releaseTargets: targets,
847
+ highRiskFiles,
848
+ activeChange,
849
+ coverage,
850
+ activeChanges,
851
+ checks,
852
+ warnings,
853
+ errors,
854
+ blockingErrors: errors,
855
+ nextActions: buildNextActions(governance, activeChanges, activeChange, targets, highRiskFiles),
856
+ };
543
857
  }
544
858
 
545
859
  if (!governance.initialized) {
546
- const message = `SDD 已配置但未初始化: ${governance.path}。运行 openxiangda sdd init`;
860
+ const message = `SDD 已配置但未初始化: ${governance.path}。运行 openxiangda sdd init --upgrade`;
547
861
  if (governance.strictHighRisk && (hasHighRiskTargets(targets) || highRiskFiles.length > 0)) {
548
862
  errors.push({ name: 'sdd-not-initialized', message });
549
863
  checks.push({ name: 'sdd', status: 'error', message });
@@ -551,16 +865,45 @@ function buildSddGovernanceReport(options = {}) {
551
865
  warnings.push(message);
552
866
  checks.push({ name: 'sdd', status: 'warn', message });
553
867
  }
554
- return { governance, highRiskFiles, activeChanges, checks, warnings, errors };
868
+ return {
869
+ schemaVersion: governance.schemaVersion,
870
+ governance,
871
+ releaseTargets: targets,
872
+ highRiskFiles,
873
+ activeChange,
874
+ coverage,
875
+ activeChanges,
876
+ checks,
877
+ warnings,
878
+ errors,
879
+ blockingErrors: errors,
880
+ nextActions: buildNextActions(governance, activeChanges, activeChange, targets, highRiskFiles),
881
+ };
555
882
  }
556
883
 
557
- activeChanges = listChanges(governance.rootDir).filter(
884
+ const changes = listChanges(governance.rootDir);
885
+ activeChanges = changes.filter(
558
886
  item => item.valid && !['archived', 'rejected'].includes(item.metadata.status)
559
887
  );
888
+ activeChange = chooseActiveChange(activeChanges, targets, highRiskFiles);
889
+ coverage = activeChange?.coverage || null;
560
890
  const approved = getApprovedActiveChanges(governance.rootDir);
561
891
  if (!governance.strictHighRisk || (!hasHighRiskTargets(targets) && highRiskFiles.length === 0)) {
562
892
  checks.push({ name: 'sdd', status: 'ok', message: `${activeChanges.length} active changes` });
563
- return { governance, highRiskFiles, activeChanges, checks, warnings, errors };
893
+ return {
894
+ schemaVersion: governance.schemaVersion,
895
+ governance,
896
+ releaseTargets: targets,
897
+ highRiskFiles,
898
+ activeChange: activeChange ? summarizeChange(options.cwd || process.cwd(), activeChange) : null,
899
+ coverage: summarizeCoverage(coverage),
900
+ activeChanges: activeChanges.map(item => summarizeChange(options.cwd || process.cwd(), item)),
901
+ checks,
902
+ warnings,
903
+ errors,
904
+ blockingErrors: errors,
905
+ nextActions: buildNextActions(governance, activeChanges, activeChange, targets, highRiskFiles),
906
+ };
564
907
  }
565
908
 
566
909
  if (approved.length === 0) {
@@ -568,20 +911,27 @@ function buildSddGovernanceReport(options = {}) {
568
911
  name: 'sdd-approved-change-required',
569
912
  message: '高风险变更发布前必须存在 approved SDD change;先运行 openxiangda sdd propose <change> 并经 openxiangda sdd approve <change> 确认。',
570
913
  });
914
+ } else if (!activeChange) {
915
+ errors.push({
916
+ name: 'sdd-coverage-required',
917
+ message: 'approved SDD change 的 coverage.json 未覆盖本次发布目标或高风险文件。',
918
+ });
571
919
  } else {
572
- const targetCovered = approved.some(change => affectedCoversTargets(change.affected, targets).covered);
573
- const fileCovered = approved.some(change => affectedCoversHighRiskFiles(change.affected, highRiskFiles).covered);
574
- if (hasHighRiskTargets(targets) && !targetCovered) {
575
- errors.push({
576
- name: 'sdd-affected-targets-missing',
577
- message: 'approved SDD change 的 affected 未覆盖本次发布目标。',
578
- });
579
- }
580
- if (highRiskFiles.length > 0 && !fileCovered) {
581
- errors.push({
582
- name: 'sdd-affected-files-missing',
583
- message: 'approved SDD change 的 affected 未覆盖本次高风险文件变更。',
584
- });
920
+ const verify = verifySddChange({
921
+ cwd: options.cwd,
922
+ configText: options.configText,
923
+ changeId: activeChange.id,
924
+ targets,
925
+ files,
926
+ releasePlan: {
927
+ targets,
928
+ changedFiles: files,
929
+ commands: options.commands || [],
930
+ },
931
+ });
932
+ if (!verify.passed) {
933
+ errors.push(...verify.errors);
934
+ warnings.push(...verify.warnings.map(item => `${item.name}: ${item.message}`));
585
935
  }
586
936
  }
587
937
 
@@ -590,24 +940,232 @@ function buildSddGovernanceReport(options = {}) {
590
940
  status: errors.length > 0 ? 'error' : 'ok',
591
941
  message: errors.length > 0 ? `${errors.length} 个 SDD gate 错误` : `${approved.length} approved active changes`,
592
942
  });
593
- return { governance, highRiskFiles, activeChanges, checks, warnings, errors };
943
+ return {
944
+ schemaVersion: governance.schemaVersion,
945
+ governance,
946
+ releaseTargets: targets,
947
+ highRiskFiles,
948
+ activeChange: activeChange ? summarizeChange(options.cwd || process.cwd(), activeChange) : null,
949
+ coverage: summarizeCoverage(coverage),
950
+ activeChanges: activeChanges.map(item => summarizeChange(options.cwd || process.cwd(), item)),
951
+ checks,
952
+ warnings,
953
+ errors,
954
+ blockingErrors: errors,
955
+ nextActions: buildNextActions(governance, activeChanges, activeChange, targets, highRiskFiles),
956
+ };
594
957
  }
595
958
 
596
- function readUncheckedTasks(changeDir) {
597
- const file = path.join(changeDir, 'tasks.md');
598
- if (!fs.existsSync(file)) return ['tasks.md 缺失'];
959
+ function readUncheckedChecklist(file, missingMessage) {
960
+ if (!fs.existsSync(file)) return [missingMessage || `${path.basename(file)} 缺失`];
599
961
  return fs
600
962
  .readFileSync(file, 'utf8')
601
963
  .split(/\r?\n/)
602
964
  .map((line, index) => ({ line, index: index + 1 }))
603
965
  .filter(item => /^-\s*\[\s\]/.test(item.line))
604
- .map(item => `tasks.md:${item.index} ${item.line}`);
966
+ .map(item => `${path.basename(file)}:${item.index} ${item.line}`);
967
+ }
968
+
969
+ function readUncheckedTasks(changeDir) {
970
+ return readUncheckedChecklist(path.join(changeDir, 'tasks.md'), 'tasks.md 缺失');
971
+ }
972
+
973
+ function validateCoverageManifest(coverage, meta, targets, highRiskFiles, legacyMode = false) {
974
+ const errors = [];
975
+ const warnings = [];
976
+ if (!coverage || coverage.__invalid) {
977
+ const message = coverage?.__invalid
978
+ ? `coverage.json 无效: ${coverage.error}`
979
+ : 'coverage.json 缺失';
980
+ if (legacyMode) warnings.push({ name: 'sdd-coverage-missing', message });
981
+ else errors.push({ name: 'sdd-coverage-missing', message });
982
+ return { errors, warnings };
983
+ }
984
+ if (coverage.schemaVersion && coverage.schemaVersion !== SDD_SCHEMA_VERSION) {
985
+ warnings.push({
986
+ name: 'sdd-coverage-schema-version',
987
+ message: `coverage schemaVersion=${coverage.schemaVersion},建议升级为 ${SDD_SCHEMA_VERSION}`,
988
+ });
989
+ }
990
+ if (!Array.isArray(coverage.requirements) || coverage.requirements.length === 0) {
991
+ errors.push({ name: 'sdd-coverage-empty', message: 'coverage.json 必须包含 requirements 映射' });
992
+ } else {
993
+ for (const requirement of coverage.requirements) {
994
+ if (!requirement.id || !requirement.title || !requirement.spec) {
995
+ errors.push({ name: 'sdd-coverage-requirement-invalid', message: 'coverage requirement 缺少 id/title/spec' });
996
+ break;
997
+ }
998
+ if (!Array.isArray(requirement.scenarios) || requirement.scenarios.length === 0) {
999
+ errors.push({ name: 'sdd-coverage-scenarios-missing', message: `coverage requirement ${requirement.id} 缺少 scenarios` });
1000
+ break;
1001
+ }
1002
+ }
1003
+ }
1004
+ const targetCoverage = coverageCoversTargets(coverage, meta.affected, targets);
1005
+ if (!targetCoverage.covered) {
1006
+ errors.push({
1007
+ name: 'sdd-change-targets-uncovered',
1008
+ message: `coverage 未覆盖发布目标: ${targetCoverage.missing.join(', ')}`,
1009
+ });
1010
+ }
1011
+ const fileCoverage = coverageCoversHighRiskFiles(coverage, meta.affected, highRiskFiles);
1012
+ if (!fileCoverage.covered) {
1013
+ errors.push({
1014
+ name: 'sdd-change-files-uncovered',
1015
+ message: `coverage 未覆盖高风险文件: ${fileCoverage.missing.join(', ')}`,
1016
+ });
1017
+ }
1018
+ return { errors, warnings };
1019
+ }
1020
+
1021
+ function splitDeltaSections(content) {
1022
+ const sections = [];
1023
+ const regex = /^##\s+(ADDED|MODIFIED|REMOVED)\s+Requirements\b.*$/gim;
1024
+ const matches = [...content.matchAll(regex)];
1025
+ for (let index = 0; index < matches.length; index += 1) {
1026
+ const match = matches[index];
1027
+ const start = match.index + match[0].length;
1028
+ const end = matches[index + 1]?.index ?? content.length;
1029
+ sections.push({
1030
+ type: match[1].toUpperCase(),
1031
+ body: content.slice(start, end),
1032
+ });
1033
+ }
1034
+ return sections;
1035
+ }
1036
+
1037
+ function parseRequirementBlocks(sectionBody) {
1038
+ const regex = /^###\s+Requirement:\s*(.+)$/gim;
1039
+ const matches = [...sectionBody.matchAll(regex)];
1040
+ const blocks = [];
1041
+ for (let index = 0; index < matches.length; index += 1) {
1042
+ const match = matches[index];
1043
+ const start = match.index;
1044
+ const end = matches[index + 1]?.index ?? sectionBody.length;
1045
+ blocks.push({
1046
+ title: match[1].trim(),
1047
+ content: sectionBody.slice(start, end).trim(),
1048
+ });
1049
+ }
1050
+ return blocks;
1051
+ }
1052
+
1053
+ function parseDeltaSpec(content) {
1054
+ const sections = splitDeltaSections(content);
1055
+ const result = { added: [], modified: [], removed: [] };
1056
+ for (const section of sections) {
1057
+ const key = section.type.toLowerCase();
1058
+ result[key].push(...parseRequirementBlocks(section.body));
1059
+ }
1060
+ return result;
1061
+ }
1062
+
1063
+ function validateDeltaSpecFile(file, rootDir) {
1064
+ const rel = path.relative(rootDir, file).replace(/\\/g, '/');
1065
+ const content = fs.readFileSync(file, 'utf8');
1066
+ const errors = [];
1067
+ const warnings = [];
1068
+ const sections = splitDeltaSections(content);
1069
+ if (sections.length === 0) {
1070
+ errors.push({ name: 'sdd-delta-section-missing', message: `${rel} 缺少 ADDED/MODIFIED/REMOVED Requirements 段落` });
1071
+ return { errors, warnings };
1072
+ }
1073
+ const delta = parseDeltaSpec(content);
1074
+ const all = [...delta.added, ...delta.modified, ...delta.removed];
1075
+ if (all.length === 0) {
1076
+ errors.push({ name: 'sdd-delta-requirement-missing', message: `${rel} 缺少 Requirement` });
1077
+ }
1078
+ for (const requirement of [...delta.added, ...delta.modified]) {
1079
+ if (!/####\s+Scenario:/i.test(requirement.content)) {
1080
+ errors.push({ name: 'sdd-scenario-missing', message: `${rel} requirement "${requirement.title}" 缺少 Scenario` });
1081
+ continue;
1082
+ }
1083
+ if (!/\bGiven\b|\*\*Given\*\*/i.test(requirement.content) || !/\bWhen\b|\*\*When\*\*/i.test(requirement.content) || !/\bThen\b|\*\*Then\*\*/i.test(requirement.content)) {
1084
+ errors.push({ name: 'sdd-scenario-unverifiable', message: `${rel} requirement "${requirement.title}" 场景必须包含 Given/When/Then` });
1085
+ }
1086
+ if (PLACEHOLDER_PATTERNS.some(pattern => pattern.test(requirement.content))) {
1087
+ errors.push({ name: 'sdd-spec-placeholder', message: `${rel} requirement "${requirement.title}" 仍包含占位内容` });
1088
+ }
1089
+ }
1090
+ for (const requirement of delta.removed) {
1091
+ if (!/because|reason|原因|废弃|移除/i.test(requirement.content)) {
1092
+ warnings.push({ name: 'sdd-removed-reason-missing', message: `${rel} removed requirement "${requirement.title}" 建议写明原因` });
1093
+ }
1094
+ }
1095
+ return { errors, warnings };
1096
+ }
1097
+
1098
+ function validateDeltaSpecs(changeDir) {
1099
+ const specRoot = path.join(changeDir, 'specs');
1100
+ const files = listMarkdownFiles(specRoot);
1101
+ const errors = [];
1102
+ const warnings = [];
1103
+ if (files.length === 0) {
1104
+ errors.push({ name: 'sdd-delta-specs-missing', message: 'change 缺少 specs/**/*.md delta spec' });
1105
+ return { errors, warnings, files: [] };
1106
+ }
1107
+ for (const file of files) {
1108
+ const validation = validateDeltaSpecFile(file, changeDir);
1109
+ errors.push(...validation.errors);
1110
+ warnings.push(...validation.warnings);
1111
+ }
1112
+ return {
1113
+ errors,
1114
+ warnings,
1115
+ files: files.map(file => path.relative(changeDir, file).replace(/\\/g, '/')),
1116
+ };
1117
+ }
1118
+
1119
+ function updateReleaseManifest(loaded, releasePlan) {
1120
+ const release = {
1121
+ schemaVersion: SDD_SCHEMA_VERSION,
1122
+ changeId: loaded.changeId,
1123
+ ...(loaded.release && !loaded.release.__invalid ? loaded.release : {}),
1124
+ targets: releasePlan?.targets || loaded.release?.targets || {},
1125
+ changedFiles: releasePlan?.changedFiles || loaded.release?.changedFiles || [],
1126
+ commands: releasePlan?.commands || loaded.release?.commands || [],
1127
+ verifiedAt: nowIso(),
1128
+ };
1129
+ writeJsonFile(loaded.releaseFile, release);
1130
+ return release;
1131
+ }
1132
+
1133
+ function validateReleaseManifest(release, releasePlan, legacyMode = false) {
1134
+ const errors = [];
1135
+ const warnings = [];
1136
+ if (!release || release.__invalid) {
1137
+ const issue = {
1138
+ name: 'sdd-release-invalid',
1139
+ message: release?.__invalid ? `release.json 无效: ${release.error}` : 'release.json 缺失',
1140
+ };
1141
+ if (legacyMode) warnings.push(issue);
1142
+ else errors.push(issue);
1143
+ return { errors, warnings };
1144
+ }
1145
+ const targets = releasePlan?.targets || release.targets || {};
1146
+ if (releasePlan?.targets) {
1147
+ const releaseTargetCoverage = affectedCoversTargets(release.targets || {}, releasePlan.targets);
1148
+ if (!releaseTargetCoverage.covered) {
1149
+ errors.push({
1150
+ name: 'sdd-release-targets-uncovered',
1151
+ message: `release.json targets 未覆盖发布目标: ${releaseTargetCoverage.missing.join(', ')}`,
1152
+ });
1153
+ }
1154
+ }
1155
+ if (hasHighRiskTargets(targets) && !Array.isArray(release.commands)) {
1156
+ errors.push({ name: 'sdd-release-commands-missing', message: 'release.json commands 必须是数组' });
1157
+ }
1158
+ if (releasePlan?.commands?.length && (!Array.isArray(release.commands) || release.commands.length === 0)) {
1159
+ errors.push({ name: 'sdd-release-commands-empty', message: 'release.json 未记录发布命令' });
1160
+ }
1161
+ return { errors, warnings };
605
1162
  }
606
1163
 
607
1164
  function verifySddChange(options = {}) {
608
1165
  const loaded = loadSddChange(options);
609
1166
  const targets = options.targets || options.releasePlan?.targets || {};
610
1167
  const highRiskFiles = classifyHighRiskFiles(options.files || options.releasePlan?.changedFiles || []);
1168
+ const legacyMode = (loaded.meta.schemaVersion || LEGACY_SDD_SCHEMA_VERSION) !== SDD_SCHEMA_VERSION;
611
1169
  const errors = [];
612
1170
  const warnings = [];
613
1171
  if (loaded.meta.status !== 'approved' || !loaded.meta.approvedAt) {
@@ -627,6 +1185,12 @@ function verifySddChange(options = {}) {
627
1185
  message: `affected 未覆盖高风险文件: ${fileCoverage.missing.join(', ')}`,
628
1186
  });
629
1187
  }
1188
+ const coverageValidation = validateCoverageManifest(loaded.coverage, loaded.meta, targets, highRiskFiles, legacyMode);
1189
+ errors.push(...coverageValidation.errors);
1190
+ warnings.push(...coverageValidation.warnings);
1191
+ const deltaValidation = validateDeltaSpecs(loaded.changeDir);
1192
+ errors.push(...deltaValidation.errors);
1193
+ warnings.push(...deltaValidation.warnings);
630
1194
  const uncheckedTasks = readUncheckedTasks(loaded.changeDir);
631
1195
  if (uncheckedTasks.length > 0) {
632
1196
  errors.push({
@@ -636,29 +1200,52 @@ function verifySddChange(options = {}) {
636
1200
  });
637
1201
  }
638
1202
  const evidenceFile = path.join(loaded.changeDir, 'evidence.md');
639
- if (!fs.existsSync(evidenceFile)) {
640
- warnings.push({ name: 'sdd-evidence-missing', message: 'evidence.md 缺失' });
1203
+ const uncheckedEvidence = readUncheckedChecklist(evidenceFile, 'evidence.md 缺失');
1204
+ if (uncheckedEvidence.length > 0) {
1205
+ errors.push({
1206
+ name: 'sdd-evidence-incomplete',
1207
+ message: `${uncheckedEvidence.length} 个 evidence.md 证据项未完成`,
1208
+ details: uncheckedEvidence,
1209
+ });
641
1210
  }
1211
+ const release =
1212
+ options.writeMetadata && options.releasePlan && loaded.release && !loaded.release.__invalid
1213
+ ? updateReleaseManifest(loaded, options.releasePlan)
1214
+ : loaded.release;
1215
+ const releaseValidation = validateReleaseManifest(release, options.releasePlan, legacyMode);
1216
+ errors.push(...releaseValidation.errors);
1217
+ warnings.push(...releaseValidation.warnings);
642
1218
  const verifiedMeta = {
643
1219
  ...loaded.meta,
644
1220
  release: {
645
1221
  ...(loaded.meta.release || {}),
646
- plannedCommands: options.releasePlan?.commands || loaded.meta.release?.plannedCommands || [],
1222
+ plannedCommands: options.releasePlan?.commands || loaded.meta.release?.plannedCommands || release.commands || [],
647
1223
  lastVerifiedAt: nowIso(),
648
1224
  },
649
1225
  updatedAt: nowIso(),
650
1226
  };
651
1227
  if (options.writeMetadata) writeJsonFile(loaded.metaFile, verifiedMeta);
652
1228
  return {
1229
+ schemaVersion: loaded.meta.schemaVersion || loaded.governance.schemaVersion || LEGACY_SDD_SCHEMA_VERSION,
653
1230
  change: verifiedMeta,
1231
+ coverage: loaded.coverage,
1232
+ release,
1233
+ deltaSpecs: deltaValidation.files,
654
1234
  errors,
655
1235
  warnings,
1236
+ blockingErrors: errors,
656
1237
  passed: errors.length === 0,
657
1238
  targets,
658
1239
  highRiskFiles,
659
1240
  };
660
1241
  }
661
1242
 
1243
+ function appendToEvidence(changeDir, lines) {
1244
+ const file = path.join(changeDir, 'evidence.md');
1245
+ ensureDir(path.dirname(file));
1246
+ fs.appendFileSync(file, `\n${lines.join('\n')}\n`);
1247
+ }
1248
+
662
1249
  function appendBypassLog(options = {}) {
663
1250
  const governance = getSddGovernanceConfig(options);
664
1251
  if (!governance.initialized) {
@@ -668,19 +1255,33 @@ function appendBypassLog(options = {}) {
668
1255
  if (!reason) throw new Error('--sdd-bypass 需要同时提供 --reason "..."');
669
1256
  const file = path.join(governance.rootDir, BYPASS_LOG_FILE);
670
1257
  ensureDir(path.dirname(file));
671
- const lines = [
1258
+ const entry = [
672
1259
  `## ${nowIso()}`,
673
1260
  '',
674
1261
  `- command: ${options.command || '-'}`,
675
1262
  `- reason: ${reason}`,
676
1263
  `- targets: ${JSON.stringify(options.targets || {})}`,
1264
+ `- followUp: 补齐对应 change 的 evidence.md,并在发布后 archive`,
677
1265
  `- user: ${os.userInfo().username || 'unknown'}`,
678
1266
  '',
679
1267
  ];
680
- fs.appendFileSync(file, lines.join('\n'));
1268
+ fs.appendFileSync(file, entry.join('\n'));
1269
+
1270
+ const approved = listChanges(governance.rootDir).find(
1271
+ item => item.valid && item.metadata.status === 'approved' && item.metadata.approvedAt
1272
+ );
1273
+ if (approved) {
1274
+ appendToEvidence(approved.dir, [
1275
+ '## SDD Bypass',
1276
+ '',
1277
+ `- ${nowIso()} ${options.command || '-'} bypassed SDD gate: ${reason}`,
1278
+ ]);
1279
+ }
1280
+
681
1281
  return {
682
1282
  file: path.relative(options.cwd || process.cwd(), file).replace(/\\/g, '/'),
683
1283
  reason,
1284
+ evidenceChange: approved?.id || null,
684
1285
  };
685
1286
  }
686
1287
 
@@ -689,17 +1290,28 @@ function buildSddStatus(options = {}) {
689
1290
  const specsDir = path.join(governance.rootDir, 'specs');
690
1291
  const changes = governance.initialized ? listChanges(governance.rootDir) : [];
691
1292
  const archived = governance.initialized ? listChanges(governance.rootDir, { includeArchive: true }).filter(item => item.dir.includes(`${path.sep}archive${path.sep}`)) : [];
1293
+ const releasePlan = options.releasePlan || {};
1294
+ const highRiskFiles = classifyHighRiskFiles(releasePlan.changedFiles || []);
1295
+ const activeChange = chooseActiveChange(changes, releasePlan.targets || {}, highRiskFiles);
1296
+ const report = buildSddGovernanceReport({
1297
+ cwd: options.cwd,
1298
+ configText: options.configText,
1299
+ targets: releasePlan.targets || {},
1300
+ files: releasePlan.changedFiles || [],
1301
+ commands: releasePlan.commands || [],
1302
+ });
692
1303
  return {
693
1304
  cwd: options.cwd || process.cwd(),
1305
+ schemaVersion: governance.schemaVersion,
694
1306
  governance,
695
1307
  specs: listMarkdownFiles(specsDir).map(file => path.relative(governance.rootDir, file).replace(/\\/g, '/')),
696
- activeChanges: changes.map(item => ({
697
- id: item.id,
698
- status: item.metadata?.status || 'invalid',
699
- approvedAt: item.metadata?.approvedAt || null,
700
- affected: item.metadata?.affected || null,
701
- dir: path.relative(options.cwd || process.cwd(), item.dir).replace(/\\/g, '/'),
702
- })),
1308
+ specSummaries: governance.initialized
1309
+ ? listMarkdownFiles(specsDir).map(file => summarizeSpecFile(governance.rootDir, file))
1310
+ : [],
1311
+ activeChange: activeChange ? summarizeChange(options.cwd || process.cwd(), activeChange) : null,
1312
+ releaseTargets: releasePlan.targets || {},
1313
+ coverage: summarizeCoverage(activeChange?.coverage),
1314
+ activeChanges: changes.map(item => summarizeChange(options.cwd || process.cwd(), item)),
703
1315
  archivedChanges: archived.slice(-10).map(item => ({
704
1316
  id: item.id,
705
1317
  dir: path.relative(options.cwd || process.cwd(), item.dir).replace(/\\/g, '/'),
@@ -707,31 +1319,97 @@ function buildSddStatus(options = {}) {
707
1319
  bypassLog: governance.initialized && fs.existsSync(path.join(governance.rootDir, BYPASS_LOG_FILE))
708
1320
  ? path.join(governance.path, BYPASS_LOG_FILE).replace(/\\/g, '/')
709
1321
  : null,
1322
+ nextActions: report.nextActions,
1323
+ blockingErrors: report.blockingErrors,
710
1324
  };
711
1325
  }
712
1326
 
1327
+ function findRequirementBlock(content, title) {
1328
+ const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1329
+ const regex = new RegExp(`^###\\s+Requirement:\\s*${escaped}\\s*$`, 'mi');
1330
+ const match = content.match(regex);
1331
+ if (!match || match.index === undefined) return null;
1332
+ const start = match.index;
1333
+ const next = content.slice(start + match[0].length).search(/^###\s+Requirement:/mi);
1334
+ const end = next >= 0 ? start + match[0].length + next : content.length;
1335
+ return { start, end };
1336
+ }
1337
+
1338
+ function mergeDeltaIntoSpec(current, delta, targetRel) {
1339
+ let content = current.trimEnd();
1340
+ const errors = [];
1341
+ const warnings = [];
1342
+ if (!content) {
1343
+ const domain = targetRel.replace(/\/spec\.md$/, '') || 'app';
1344
+ content = `# ${domain} Specification\n\n## Purpose\n\nCurrent behavior for ${domain}.\n\n## Requirements\n`;
1345
+ }
1346
+ if (!/##\s+Requirements/i.test(content)) {
1347
+ content += '\n\n## Requirements\n';
1348
+ }
1349
+ for (const requirement of delta.added) {
1350
+ const existing = findRequirementBlock(content, requirement.title);
1351
+ if (existing) {
1352
+ warnings.push({ name: 'sdd-added-existing-requirement', message: `${targetRel} 已存在 requirement "${requirement.title}",跳过 ADDED` });
1353
+ continue;
1354
+ }
1355
+ content = `${content.trimEnd()}\n\n${requirement.content}\n`;
1356
+ }
1357
+ for (const requirement of delta.modified) {
1358
+ const existing = findRequirementBlock(content, requirement.title);
1359
+ if (!existing) {
1360
+ errors.push({ name: 'sdd-modified-requirement-missing', message: `${targetRel} 不存在要修改的 requirement "${requirement.title}"` });
1361
+ continue;
1362
+ }
1363
+ content = `${content.slice(0, existing.start).trimEnd()}\n\n${requirement.content}\n\n${content.slice(existing.end).trimStart()}`;
1364
+ }
1365
+ for (const requirement of delta.removed) {
1366
+ const existing = findRequirementBlock(content, requirement.title);
1367
+ if (!existing) {
1368
+ warnings.push({ name: 'sdd-removed-requirement-missing', message: `${targetRel} 不存在要移除的 requirement "${requirement.title}"` });
1369
+ continue;
1370
+ }
1371
+ content = `${content.slice(0, existing.start).trimEnd()}\n\n${content.slice(existing.end).trimStart()}`;
1372
+ }
1373
+ return { content: `${content.trimEnd()}\n`, errors, warnings };
1374
+ }
1375
+
713
1376
  function syncSddChange(options = {}) {
714
1377
  const loaded = loadSddChange(options);
715
- const specFiles = listMarkdownFiles(path.join(loaded.changeDir, 'specs'));
1378
+ const specRoot = path.join(loaded.changeDir, 'specs');
1379
+ const specFiles = listMarkdownFiles(specRoot);
716
1380
  const synced = [];
1381
+ const errors = [];
1382
+ const warnings = [];
1383
+ const plannedWrites = [];
717
1384
  for (const deltaFile of specFiles) {
718
- const rel = path.relative(path.join(loaded.changeDir, 'specs'), deltaFile);
1385
+ const rel = path.relative(specRoot, deltaFile).replace(/\\/g, '/');
719
1386
  const target = path.join(loaded.governance.rootDir, 'specs', rel);
720
- ensureDir(path.dirname(target));
721
- const delta = fs.readFileSync(deltaFile, 'utf8').trim();
722
- const entry = [
723
- '',
724
- `## Archived Change: ${loaded.changeId}`,
725
- '',
726
- `Archived at: ${nowIso()}`,
727
- '',
728
- delta,
729
- '',
730
- ].join('\n');
731
- fs.appendFileSync(target, fs.existsSync(target) ? entry : `${entry.trimStart()}\n`);
732
- synced.push(path.relative(loaded.governance.rootDir, target).replace(/\\/g, '/'));
1387
+ const deltaContent = fs.readFileSync(deltaFile, 'utf8');
1388
+ const delta = parseDeltaSpec(deltaContent);
1389
+ const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
1390
+ const merged = mergeDeltaIntoSpec(current, delta, rel);
1391
+ errors.push(...merged.errors);
1392
+ warnings.push(...merged.warnings);
1393
+ if (merged.errors.length === 0) {
1394
+ plannedWrites.push({ target, content: merged.content });
1395
+ }
733
1396
  }
734
- return { change: loaded.meta, synced };
1397
+ if (errors.length > 0 && !options.force) {
1398
+ const first = errors[0];
1399
+ throw new Error(`SDD sync 失败: ${first.message}`);
1400
+ }
1401
+ for (const item of plannedWrites) {
1402
+ ensureDir(path.dirname(item.target));
1403
+ fs.writeFileSync(item.target, item.content);
1404
+ synced.push(path.relative(loaded.governance.rootDir, item.target).replace(/\\/g, '/'));
1405
+ }
1406
+ return {
1407
+ schemaVersion: loaded.meta.schemaVersion || loaded.governance.schemaVersion,
1408
+ change: loaded.meta,
1409
+ synced,
1410
+ warnings,
1411
+ errors,
1412
+ };
735
1413
  }
736
1414
 
737
1415
  function archiveSddChange(options = {}) {
@@ -750,19 +1428,23 @@ function archiveSddChange(options = {}) {
750
1428
  ...loaded.meta,
751
1429
  status: 'archived',
752
1430
  archivedAt: nowIso(),
1431
+ syncedSpecs: sync.synced,
753
1432
  updatedAt: nowIso(),
754
1433
  };
755
1434
  writeJsonFile(loaded.metaFile, meta);
756
1435
  fs.renameSync(loaded.changeDir, archiveDir);
757
1436
  return {
1437
+ schemaVersion: meta.schemaVersion || loaded.governance.schemaVersion,
758
1438
  change: meta,
759
1439
  archiveDir: path.relative(options.cwd || process.cwd(), archiveDir).replace(/\\/g, '/'),
760
1440
  synced: sync.synced,
1441
+ warnings: [...verify.warnings, ...sync.warnings],
761
1442
  };
762
1443
  }
763
1444
 
764
1445
  module.exports = {
765
1446
  DEFAULT_SDD_DIR,
1447
+ SDD_SCHEMA_VERSION,
766
1448
  appendBypassLog,
767
1449
  archiveSddChange,
768
1450
  buildSddGovernanceReport,