openxiangda 1.0.174 → 1.0.176

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.
@@ -0,0 +1,75 @@
1
+ function explainReleaseExecution(execution) {
2
+ if (!execution) {
3
+ return {
4
+ state: 'not-started',
5
+ resumable: false,
6
+ writeReviewRequired: false,
7
+ summary: '本地没有该 change 的 release execution journal。',
8
+ nextAction: '先运行 release publish --dry-run 检查计划,再开始正式发布。',
9
+ };
10
+ }
11
+ const steps = Array.isArray(execution.steps) ? execution.steps : [];
12
+ const failedStep = steps.find(step => step.status === 'failed');
13
+ const runningStep = steps.find(step =>
14
+ ['running', 'write-started'].includes(step.status)
15
+ );
16
+ const pendingSteps = steps.filter(step => step.status === 'pending');
17
+ const completedSteps = steps.filter(step => step.status === 'completed');
18
+ const state = execution.status || 'unknown';
19
+ const common = {
20
+ state,
21
+ resumable: state === 'staged-resumable',
22
+ writeReviewRequired: state === 'write-review-required',
23
+ progress: {
24
+ completed: completedSteps.length,
25
+ pending: pendingSteps.length,
26
+ total: steps.length,
27
+ currentStep: runningStep?.id || failedStep?.id || pendingSteps[0]?.id || null,
28
+ },
29
+ failedStep: failedStep
30
+ ? {
31
+ id: failedStep.id,
32
+ code: failedStep.errorCode || null,
33
+ message: failedStep.error || null,
34
+ }
35
+ : null,
36
+ };
37
+ if (state === 'completed') {
38
+ return {
39
+ ...common,
40
+ summary: '本次应用原子发布已完成。',
41
+ nextAction: '执行验收;确认无误后可归档 SDD change。',
42
+ };
43
+ }
44
+ if (state === 'write-review-required') {
45
+ return {
46
+ ...common,
47
+ summary: '上次写请求的结果不确定,CLI 已停止自动重试以避免重复写入。',
48
+ nextAction:
49
+ '先只读核对 staged child/App Release;确认可安全重试后追加 --resume-after-review。',
50
+ };
51
+ }
52
+ if (state === 'staged-resumable') {
53
+ return {
54
+ ...common,
55
+ summary: '已有 staged child,active head 尚未全部切换,可以从执行日志继续。',
56
+ nextAction: '重新运行同一条 release publish 命令继续,不要另起全量发布。',
57
+ };
58
+ }
59
+ if (state === 'failed') {
60
+ return {
61
+ ...common,
62
+ summary: '发布在确认没有成功写入后失败,可以修复原因后重试。',
63
+ nextAction: '修复 failedStep 后重新运行同一条 release publish 命令。',
64
+ };
65
+ }
66
+ return {
67
+ ...common,
68
+ summary: runningStep
69
+ ? `发布停在 ${runningStep.id}。`
70
+ : '发布尚未完成。',
71
+ nextAction: '使用同一 change/profile 重新运行 release publish,让执行日志决定续跑位置。',
72
+ };
73
+ }
74
+
75
+ module.exports = { explainReleaseExecution };
@@ -11,6 +11,11 @@ function uniqueSorted(values = []) {
11
11
  }
12
12
 
13
13
  function normalizeReleaseTargets(targets = {}, runtimeMode = 'legacy') {
14
+ const resourceSelectors = Object.fromEntries(
15
+ Object.entries(targets.resourceSelectors || targets.resourceTargets || {})
16
+ .map(([type, values]) => [type, uniqueSorted(values || [])])
17
+ .filter(([, values]) => values.length > 0)
18
+ );
14
19
  const logicalTargets = {
15
20
  forms: uniqueSorted(targets.forms || []),
16
21
  pages: uniqueSorted(targets.pages || []),
@@ -19,6 +24,7 @@ function normalizeReleaseTargets(targets = {}, runtimeMode = 'legacy') {
19
24
  workflows: uniqueSorted(targets.workflows || []),
20
25
  jsCodeNodes: uniqueSorted(targets.jsCodeNodes || []),
21
26
  resources: Boolean(targets.resources),
27
+ resourceSelectors,
22
28
  runtime: Boolean(targets.runtime),
23
29
  other: uniqueSorted(targets.other || []),
24
30
  };
@@ -36,6 +42,35 @@ function normalizeReleaseTargets(targets = {}, runtimeMode = 'legacy') {
36
42
  return { logicalTargets, activationTargets };
37
43
  }
38
44
 
45
+ function unsupportedAtomicReleaseTargets(targets = {}) {
46
+ const unsupported = [];
47
+ if (targets.resources) unsupported.push('generic-resources');
48
+ for (const [type, selectors] of Object.entries(targets.resourceSelectors || {})) {
49
+ if ((selectors || []).length > 0) {
50
+ unsupported.push(`${type}:${selectors.join(',')}`);
51
+ }
52
+ }
53
+ if (
54
+ (targets.jsCodeNodes || []).length > 0 &&
55
+ (targets.functions || []).length === 0 &&
56
+ (targets.automations || []).length === 0
57
+ ) {
58
+ unsupported.push(`js-code-owner:${targets.jsCodeNodes.join(',')}`);
59
+ }
60
+ return unsupported;
61
+ }
62
+
63
+ function assertAtomicReleaseTargetsSupported(targets) {
64
+ const unsupported = unsupportedAtomicReleaseTargets(targets);
65
+ if (unsupported.length === 0) return;
66
+ const error = new Error(
67
+ `RELEASE_ATOMIC_TARGET_UNSUPPORTED: 以下资源尚无 immutable staged child,禁止与原子发布混合后降级为直接写入: ${unsupported.join('; ')}`
68
+ );
69
+ error.code = 'RELEASE_ATOMIC_TARGET_UNSUPPORTED';
70
+ error.unsupportedTargets = unsupported;
71
+ throw error;
72
+ }
73
+
39
74
  function buildTargetedResourceReleaseCommands(
40
75
  targets,
41
76
  profileArg,
@@ -116,7 +151,7 @@ function buildTargetedResourceReleaseSteps(
116
151
  if (workflows.length > 0) {
117
152
  steps.push(
118
153
  createStep(
119
- 'workflow-publish',
154
+ 'workflow-stage',
120
155
  appendProfileAndChange(
121
156
  [
122
157
  'resource',
@@ -124,10 +159,12 @@ function buildTargetedResourceReleaseSteps(
124
159
  'workflow',
125
160
  '--only',
126
161
  workflows.join(','),
162
+ '--stage-only',
127
163
  ],
128
164
  profileArg,
129
165
  changeId
130
- )
166
+ ),
167
+ { stagedKind: 'WorkflowRelease' }
131
168
  )
132
169
  );
133
170
  }
@@ -159,6 +196,7 @@ function buildWorkspaceReleaseSteps(
159
196
  targetsInput,
160
197
  runtimeMode
161
198
  );
199
+ assertAtomicReleaseTargetsSupported(targets);
162
200
  const steps = [];
163
201
  const hasResourceTargets =
164
202
  Boolean(targets.resources) ||
@@ -212,6 +250,7 @@ function buildWorkspaceReleaseSteps(
212
250
  targets.forms.length > 0 ||
213
251
  targets.functions.length > 0 ||
214
252
  targets.automations.length > 0 ||
253
+ targets.workflows.length > 0 ||
215
254
  targets.runtime
216
255
  ) {
217
256
  steps.push(
@@ -223,6 +262,7 @@ function buildWorkspaceReleaseSteps(
223
262
  'app-finalize',
224
263
  '--staged-resources-json',
225
264
  `.openxiangda/releases/${changeId || 'change'}/staged-resources.json`,
265
+ '--wait',
226
266
  ],
227
267
  profileArg,
228
268
  changeId
@@ -260,6 +300,28 @@ function buildWorkspaceReleaseSteps(
260
300
  ...buildTargetedResourceReleaseSteps(targets, profileArg, changeId)
261
301
  );
262
302
  }
303
+ if (
304
+ targets.functions.length > 0 ||
305
+ targets.automations.length > 0 ||
306
+ targets.workflows.length > 0
307
+ ) {
308
+ steps.push(
309
+ createStep(
310
+ 'app-finalize',
311
+ appendProfileAndChange(
312
+ [
313
+ 'release',
314
+ 'app-finalize',
315
+ '--staged-resources-json',
316
+ `.openxiangda/releases/${changeId || 'change'}/staged-resources.json`,
317
+ '--wait',
318
+ ],
319
+ profileArg,
320
+ changeId
321
+ )
322
+ )
323
+ );
324
+ }
263
325
  return steps;
264
326
  }
265
327
 
@@ -291,4 +353,5 @@ module.exports = {
291
353
  commandFromArgs,
292
354
  normalizeReleaseTargets,
293
355
  releasePlanHash,
356
+ unsupportedAtomicReleaseTargets,
294
357
  };
package/lib/sdd.js CHANGED
@@ -166,6 +166,12 @@ function getSddGovernanceConfig(options = {}) {
166
166
  const strictDocumentation = Boolean(
167
167
  configured && /strictDocumentation\s*:\s*true/.test(block)
168
168
  );
169
+ const documentationModeMatch = block.match(
170
+ /documentationMode\s*:\s*['"]?(structured|full)['"]?/
171
+ );
172
+ const documentationMode = strictDocumentation
173
+ ? 'full'
174
+ : documentationModeMatch?.[1] || 'structured';
169
175
  const pathMatch = block.match(/path\s*:\s*['"]([^'"]+)['"]/);
170
176
  const schemaMatch = block.match(/schemaVersion\s*:\s*['"]([^'"]+)['"]/);
171
177
  const sddPath = normalizeRelPath(pathMatch?.[1] || DEFAULT_SDD_DIR);
@@ -182,6 +188,7 @@ function getSddGovernanceConfig(options = {}) {
182
188
  enabled,
183
189
  strictHighRisk,
184
190
  strictDocumentation,
191
+ documentationMode,
185
192
  path: sddPath,
186
193
  rootDir,
187
194
  initialized,
@@ -204,6 +211,7 @@ function buildDefaultConfigYaml() {
204
211
  ' coverage: coverage.json',
205
212
  ' release: release.json',
206
213
  ' metadata: change.json',
214
+ ' documentationMode: structured',
207
215
  'rules:',
208
216
  ' requireApprovalForHighRisk: true',
209
217
  ' requireAffectedCoverage: true',
@@ -324,6 +332,18 @@ function splitList(value) {
324
332
  .filter(Boolean);
325
333
  }
326
334
 
335
+ function normalizeResourceSelectors(value = {}) {
336
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
337
+ return Object.fromEntries(
338
+ Object.entries(value)
339
+ .map(([key, selectors]) => [
340
+ String(key || '').trim(),
341
+ Array.from(new Set(splitList(selectors))).sort(),
342
+ ])
343
+ .filter(([key, selectors]) => key && selectors.length > 0)
344
+ );
345
+ }
346
+
327
347
  function normalizeAffected(input = {}) {
328
348
  const affected = input.affected || input;
329
349
  return {
@@ -334,6 +354,9 @@ function normalizeAffected(input = {}) {
334
354
  workflows: splitList(affected.workflows || affected.workflow),
335
355
  jsCodeNodes: splitList(affected.jsCodeNodes || affected.jsCode || affected['js-code-nodes']),
336
356
  files: splitList(affected.files || affected.file),
357
+ resourceSelectors: normalizeResourceSelectors(
358
+ affected.resourceSelectors || affected.resourceTargets
359
+ ),
337
360
  resources: affected.resources === true || affected.resources === 'true' || splitList(affected.resources).includes('*'),
338
361
  runtime: affected.runtime === true || affected.runtime === 'true',
339
362
  backend: affected.backend === true || affected.backend === 'true',
@@ -346,6 +369,9 @@ function mergeAffectedScopes(...inputs) {
346
369
  const mergeList = key =>
347
370
  Array.from(new Set(scopes.flatMap(scope => scope[key] || []))).sort();
348
371
  const hasFlag = key => scopes.some(scope => Boolean(scope[key]));
372
+ const resourceSelectorKeys = Array.from(
373
+ new Set(scopes.flatMap(scope => Object.keys(scope.resourceSelectors || {})))
374
+ ).sort();
349
375
  return {
350
376
  forms: mergeList('forms'),
351
377
  pages: mergeList('pages'),
@@ -354,6 +380,16 @@ function mergeAffectedScopes(...inputs) {
354
380
  workflows: mergeList('workflows'),
355
381
  jsCodeNodes: mergeList('jsCodeNodes'),
356
382
  files: mergeList('files'),
383
+ resourceSelectors: Object.fromEntries(
384
+ resourceSelectorKeys.map(key => [
385
+ key,
386
+ Array.from(
387
+ new Set(
388
+ scopes.flatMap(scope => scope.resourceSelectors?.[key] || [])
389
+ )
390
+ ).sort(),
391
+ ])
392
+ ),
357
393
  resources: hasFlag('resources'),
358
394
  runtime: hasFlag('runtime'),
359
395
  backend: hasFlag('backend'),
@@ -769,6 +805,52 @@ The application SHALL provide the approved "${meta.title}" behavior for the affe
769
805
  `;
770
806
  }
771
807
 
808
+ function renderSddDocumentation(options = {}) {
809
+ const loaded = options.loaded || loadSddChange(options);
810
+ const meta = loaded.meta;
811
+ const requirementSpec = loaded.coverage?.requirements?.find(
812
+ item => typeof item?.spec === 'string' && item.spec.trim()
813
+ )?.spec;
814
+ const specRelativePath =
815
+ requirementSpec && !requirementSpec.includes('..')
816
+ ? requirementSpec.replace(/\\/g, '/')
817
+ : 'specs/app/spec.md';
818
+ const specMatch = specRelativePath.match(/^specs\/([^/]+)\/spec\.md$/);
819
+ const domain = options.domain || specMatch?.[1] || 'app';
820
+ const files = {
821
+ proposal: path.join(loaded.changeDir, 'proposal.md'),
822
+ design: path.join(loaded.changeDir, 'design.md'),
823
+ tasks: path.join(loaded.changeDir, 'tasks.md'),
824
+ evidence: path.join(loaded.changeDir, 'evidence.md'),
825
+ spec: path.join(loaded.changeDir, 'specs', domain, 'spec.md'),
826
+ };
827
+ const contents = {
828
+ proposal: createProposalContent(meta),
829
+ design: createDesignContent(meta),
830
+ tasks: createTasksContent(meta),
831
+ evidence: createEvidenceContent(meta),
832
+ spec: createDeltaSpecContent(meta, domain),
833
+ };
834
+ const rendered = [];
835
+ const skipped = [];
836
+ for (const [kind, file] of Object.entries(files)) {
837
+ if (fs.existsSync(file) && !options.force) {
838
+ skipped.push(path.relative(options.cwd || process.cwd(), file).replace(/\\/g, '/'));
839
+ continue;
840
+ }
841
+ ensureDir(path.dirname(file));
842
+ fs.writeFileSync(file, contents[kind]);
843
+ rendered.push(path.relative(options.cwd || process.cwd(), file).replace(/\\/g, '/'));
844
+ }
845
+ return {
846
+ schemaVersion: meta.schemaVersion || SDD_SCHEMA_VERSION,
847
+ changeId: loaded.changeId,
848
+ mode: 'full',
849
+ rendered,
850
+ skipped,
851
+ };
852
+ }
853
+
772
854
  function createCoverageManifest(meta, domain) {
773
855
  const reqId = `req-${slug(meta.title || meta.id, meta.id)}`;
774
856
  return {
@@ -860,25 +942,45 @@ function proposeSddChange(options = {}) {
860
942
  writeJsonFile(path.join(changeDir, CHANGE_META_FILE), meta, jsonWriteOptions);
861
943
  writeJsonFile(path.join(changeDir, COVERAGE_FILE), coverage, jsonWriteOptions);
862
944
  writeJsonFile(path.join(changeDir, RELEASE_FILE), release, jsonWriteOptions);
863
- fs.writeFileSync(path.join(changeDir, 'proposal.md'), createProposalContent(meta));
864
- fs.writeFileSync(path.join(changeDir, 'design.md'), createDesignContent(meta));
865
- fs.writeFileSync(path.join(changeDir, 'tasks.md'), createTasksContent(meta));
866
- fs.writeFileSync(path.join(changeDir, 'evidence.md'), createEvidenceContent(meta));
867
- fs.writeFileSync(
868
- path.join(changeDir, 'specs', domain, 'spec.md'),
869
- createDeltaSpecContent(meta, domain)
870
- );
945
+ const loaded = {
946
+ governance,
947
+ changeId,
948
+ changeDir,
949
+ meta,
950
+ coverage,
951
+ };
952
+ const documentation =
953
+ governance.documentationMode === 'full' || options.renderDocumentation
954
+ ? renderSddDocumentation({
955
+ cwd: options.cwd,
956
+ loaded,
957
+ domain,
958
+ force: true,
959
+ })
960
+ : {
961
+ mode: 'structured',
962
+ rendered: [],
963
+ skipped: [],
964
+ };
871
965
  return {
872
966
  schemaVersion: SDD_SCHEMA_VERSION,
873
967
  change: meta,
874
968
  coverage,
875
969
  release,
970
+ documentation,
876
971
  dir: path.relative(options.cwd || process.cwd(), changeDir).replace(/\\/g, '/'),
877
- nextActions: [
878
- `Review openspec/changes/${changeId}/proposal.md`,
879
- `Sharpen openspec/changes/${changeId}/specs/${domain}/spec.md`,
880
- `Run openxiangda sdd approve ${changeId} after user confirmation`,
881
- ],
972
+ nextActions:
973
+ documentation.mode === 'full'
974
+ ? [
975
+ `Review openspec/changes/${changeId}/proposal.md`,
976
+ `Sharpen openspec/changes/${changeId}/specs/${domain}/spec.md`,
977
+ `Run openxiangda sdd approve ${changeId} after user confirmation`,
978
+ ]
979
+ : [
980
+ `Review openspec/changes/${changeId}/change.json and coverage.json`,
981
+ `Run openxiangda sdd approve ${changeId} after user confirmation`,
982
+ `Optional: openxiangda sdd render ${changeId}`,
983
+ ],
882
984
  };
883
985
  }
884
986
 
@@ -981,6 +1083,7 @@ function createMainlineSddBundle(options = {}) {
981
1083
  changeId,
982
1084
  mainlinePolicy: 'publish-from-main-v1',
983
1085
  bundledChanges: sourceIds,
1086
+ ...(options.integration ? { integration: options.integration } : {}),
984
1087
  targets: normalizedTargets.activationTargets,
985
1088
  logicalTargets: normalizedTargets.logicalTargets,
986
1089
  changedFiles: affected.files,
@@ -1221,6 +1324,16 @@ function affectedCoversTargets(affected, targets = {}) {
1221
1324
  missing.push(`${key}:${required.filter(item => !normalized[key].includes(item)).join(',')}`);
1222
1325
  }
1223
1326
  if (targets.resources && !normalized.resources && !normalized.backend) missing.push('resources');
1327
+ for (const [key, required] of Object.entries(targets.resourceSelectors || {})) {
1328
+ if (normalized.resources || includesAll(normalized.resourceSelectors?.[key], required)) {
1329
+ continue;
1330
+ }
1331
+ missing.push(
1332
+ `resourceSelectors.${key}:${required
1333
+ .filter(item => !(normalized.resourceSelectors?.[key] || []).includes(item))
1334
+ .join(',')}`
1335
+ );
1336
+ }
1224
1337
  if (targets.runtime && !normalized.runtime) missing.push('runtime');
1225
1338
  return {
1226
1339
  covered: missing.length === 0,
@@ -1305,6 +1418,14 @@ function normalizeCoverageResources(coverage, fallbackAffected = {}, options = {
1305
1418
  resources[key] === 'true' ||
1306
1419
  requirementCovers.some(item => item[key] === true || item[key] === 'true') ||
1307
1420
  Boolean(fallback[key]);
1421
+ const selectorSources = [
1422
+ resources.resourceSelectors,
1423
+ ...requirementCovers.map(item => item.resourceSelectors),
1424
+ fallback.resourceSelectors,
1425
+ ].map(normalizeResourceSelectors);
1426
+ const selectorKeys = Array.from(
1427
+ new Set(selectorSources.flatMap(item => Object.keys(item)))
1428
+ ).sort();
1308
1429
  return {
1309
1430
  forms: Array.from(new Set(mergeList('forms'))),
1310
1431
  pages: Array.from(new Set(mergeList('pages'))),
@@ -1313,6 +1434,14 @@ function normalizeCoverageResources(coverage, fallbackAffected = {}, options = {
1313
1434
  workflows: Array.from(new Set(mergeList('workflows'))),
1314
1435
  jsCodeNodes: Array.from(new Set(mergeList('jsCodeNodes'))),
1315
1436
  files: Array.from(new Set(mergeList('files'))),
1437
+ resourceSelectors: Object.fromEntries(
1438
+ selectorKeys.map(key => [
1439
+ key,
1440
+ Array.from(
1441
+ new Set(selectorSources.flatMap(item => item[key] || []))
1442
+ ).sort(),
1443
+ ])
1444
+ ),
1316
1445
  resources: hasBool('resources'),
1317
1446
  runtime: hasBool('runtime'),
1318
1447
  backend: hasBool('backend'),
@@ -1425,6 +1554,26 @@ function getSddChangeScope(options = {}) {
1425
1554
  automations: mergeList('automations'),
1426
1555
  workflows: mergeList('workflows'),
1427
1556
  jsCodeNodes: mergeList('jsCodeNodes'),
1557
+ resourceSelectors: Object.fromEntries(
1558
+ Array.from(
1559
+ new Set([
1560
+ ...Object.keys(releaseTargets.resourceSelectors || {}),
1561
+ ...Object.keys(coverage.resourceSelectors || {}),
1562
+ ...Object.keys(affected.resourceSelectors || {}),
1563
+ ])
1564
+ )
1565
+ .sort()
1566
+ .map(key => [
1567
+ key,
1568
+ Array.from(
1569
+ new Set([
1570
+ ...splitList(releaseTargets.resourceSelectors?.[key]),
1571
+ ...splitList(coverage.resourceSelectors?.[key]),
1572
+ ...splitList(affected.resourceSelectors?.[key]),
1573
+ ])
1574
+ ).sort(),
1575
+ ])
1576
+ ),
1428
1577
  resources: Boolean(releaseTargets.resources || coverage.resources || affected.resources),
1429
1578
  runtime: Boolean(releaseTargets.runtime || coverage.runtime || affected.runtime),
1430
1579
  other: Array.from(new Set(splitList(releaseTargets.other))).sort(),
@@ -2854,6 +3003,7 @@ module.exports = {
2854
3003
  proposeQuickSddChange,
2855
3004
  proposeSddChange,
2856
3005
  recordSddReleaseSourceRevision,
3006
+ renderSddDocumentation,
2857
3007
  approveSddChange,
2858
3008
  syncSddChange,
2859
3009
  validateQuickSddScope,