openxiangda 1.0.134 → 1.0.136

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 (57) hide show
  1. package/README.md +6 -0
  2. package/lib/cli.js +947 -16
  3. package/lib/design-gates.js +40 -10
  4. package/lib/sdd.js +777 -0
  5. package/lib/workspace-init.js +3 -0
  6. package/openxiangda-skills/SKILL.md +11 -1
  7. package/openxiangda-skills/references/best-practices.md +32 -5
  8. package/openxiangda-skills/references/openxiangda-api.md +32 -6
  9. package/openxiangda-skills/references/pages/page-sdk.md +3 -0
  10. package/openxiangda-skills/references/pages/publish-flow.md +16 -0
  11. package/openxiangda-skills/references/permission-design-patterns.md +216 -0
  12. package/openxiangda-skills/references/permissions-settings.md +58 -0
  13. package/openxiangda-skills/references/platform-data-model.md +19 -4
  14. package/openxiangda-skills/references/resource-manifest-cheatsheet.md +32 -1
  15. package/openxiangda-skills/skills/openxiangda-architecture-design/SKILL.md +8 -0
  16. package/openxiangda-skills/skills/openxiangda-core/SKILL.md +2 -0
  17. package/openxiangda-skills/skills/openxiangda-permission-settings/SKILL.md +38 -1
  18. package/package.json +2 -1
  19. package/packages/sdk/dist/{ProcessPreview-Dfc4-wIq.d.mts → ProcessPreview-Ci8_UsbN.d.mts} +4 -0
  20. package/packages/sdk/dist/{ProcessPreview-Dfc4-wIq.d.ts → ProcessPreview-Ci8_UsbN.d.ts} +4 -0
  21. package/packages/sdk/dist/build/index.cjs.map +1 -1
  22. package/packages/sdk/dist/build/index.d.mts +7 -0
  23. package/packages/sdk/dist/build/index.d.ts +7 -0
  24. package/packages/sdk/dist/build/index.mjs.map +1 -1
  25. package/packages/sdk/dist/components/index.cjs +32 -4
  26. package/packages/sdk/dist/components/index.cjs.map +1 -1
  27. package/packages/sdk/dist/components/index.d.mts +8 -2
  28. package/packages/sdk/dist/components/index.d.ts +8 -2
  29. package/packages/sdk/dist/components/index.mjs +32 -4
  30. package/packages/sdk/dist/components/index.mjs.map +1 -1
  31. package/packages/sdk/dist/runtime/index.cjs +181 -28
  32. package/packages/sdk/dist/runtime/index.cjs.map +1 -1
  33. package/packages/sdk/dist/runtime/index.d.mts +2 -2
  34. package/packages/sdk/dist/runtime/index.d.ts +2 -2
  35. package/packages/sdk/dist/runtime/index.mjs +181 -28
  36. package/packages/sdk/dist/runtime/index.mjs.map +1 -1
  37. package/packages/sdk/dist/runtime/react.cjs +152 -25
  38. package/packages/sdk/dist/runtime/react.cjs.map +1 -1
  39. package/packages/sdk/dist/runtime/react.d.mts +57 -4
  40. package/packages/sdk/dist/runtime/react.d.ts +57 -4
  41. package/packages/sdk/dist/runtime/react.mjs +152 -25
  42. package/packages/sdk/dist/runtime/react.mjs.map +1 -1
  43. package/templates/openxiangda-react-spa/.cursor/rules/openxiangda.mdc +8 -1
  44. package/templates/openxiangda-react-spa/.qoder/rules/openxiangda.md +8 -1
  45. package/templates/openxiangda-react-spa/AGENTS.md +9 -1
  46. package/templates/openxiangda-react-spa/app-workspace.config.ts +14 -0
  47. package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda-resources.mdc +5 -1
  48. package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda.mdc +7 -1
  49. package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda-resources.md +6 -0
  50. package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda.md +7 -1
  51. package/templates/sy-lowcode-app-workspace/AGENTS.md +10 -1
  52. package/templates/sy-lowcode-app-workspace/app-workspace.config.ts +14 -0
  53. package/templates/sy-lowcode-app-workspace/examples/best-practices/README.md +3 -0
  54. package/templates/sy-lowcode-app-workspace/examples/best-practices/access-governance.md +239 -0
  55. package/templates/sy-lowcode-app-workspace/examples/best-practices/catalog.json +6 -0
  56. package/templates/sy-lowcode-app-workspace/examples/best-practices/decision-guide.md +25 -1
  57. package/templates/sy-lowcode-app-workspace/src/types/app-workspace.types.ts +12 -0
package/lib/sdd.js ADDED
@@ -0,0 +1,777 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const DEFAULT_SDD_DIR = 'openspec';
6
+ const SDD_CONFIG_FILE = 'config.yaml';
7
+ const CHANGE_META_FILE = 'change.json';
8
+ const BYPASS_LOG_FILE = path.join('changes', 'bypass-log.md');
9
+
10
+ const DEFAULT_SPEC_CONTENT = `# OpenXiangda Platform Specification
11
+
12
+ This file is the current behavior contract for this application workspace.
13
+
14
+ ## Requirements
15
+
16
+ ### Requirement: SDD-governed high-risk changes
17
+ High-risk OpenXiangda changes MUST be attached to an approved change before publishing.
18
+
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
+
25
+ function nowIso() {
26
+ return new Date().toISOString();
27
+ }
28
+
29
+ function normalizeRelPath(value) {
30
+ const input = String(value || '').trim();
31
+ if (!input) return DEFAULT_SDD_DIR;
32
+ const normalized = input.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
33
+ if (!normalized || normalized.includes('..') || path.isAbsolute(normalized)) {
34
+ throw new Error(`非法 SDD 目录: ${value}`);
35
+ }
36
+ return normalized;
37
+ }
38
+
39
+ function normalizeChangeId(value) {
40
+ const raw = String(value || '').trim();
41
+ if (!raw) throw new Error('缺少 change id');
42
+ const id = raw
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9._-]+/g, '-')
45
+ .replace(/^-+|-+$/g, '');
46
+ if (!id) throw new Error(`非法 change id: ${value}`);
47
+ return id;
48
+ }
49
+
50
+ function ensureDir(dir) {
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ }
53
+
54
+ function writeFileIfMissing(file, content, operations, force = false) {
55
+ const existed = fs.existsSync(file);
56
+ if (existed && !force) {
57
+ operations.push({ path: file, action: 'unchanged' });
58
+ return;
59
+ }
60
+ ensureDir(path.dirname(file));
61
+ fs.writeFileSync(file, content);
62
+ operations.push({ path: file, action: existed && force ? 'overwrite' : 'create' });
63
+ }
64
+
65
+ function readJsonFile(file, fallback = null) {
66
+ if (!fs.existsSync(file)) return fallback;
67
+ try {
68
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
69
+ } catch (error) {
70
+ return {
71
+ __invalid: true,
72
+ error: error.message,
73
+ file,
74
+ };
75
+ }
76
+ }
77
+
78
+ function writeJsonFile(file, value) {
79
+ ensureDir(path.dirname(file));
80
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
81
+ }
82
+
83
+ function extractSddBlock(configText) {
84
+ const governanceIndex = String(configText || '').indexOf('governance');
85
+ const sddIndex = String(configText || '').indexOf('sdd', governanceIndex >= 0 ? governanceIndex : 0);
86
+ if (sddIndex < 0) return '';
87
+ const start = String(configText).indexOf('{', sddIndex);
88
+ if (start < 0) return '';
89
+ let depth = 0;
90
+ for (let index = start; index < configText.length; index += 1) {
91
+ const char = configText[index];
92
+ if (char === '{') depth += 1;
93
+ if (char === '}') {
94
+ depth -= 1;
95
+ if (depth === 0) return configText.slice(start, index + 1);
96
+ }
97
+ }
98
+ return '';
99
+ }
100
+
101
+ function getSddGovernanceConfig(options = {}) {
102
+ const cwd = options.cwd || process.cwd();
103
+ const configText = options.configText || '';
104
+ const block = extractSddBlock(configText);
105
+ const configured = Boolean(block);
106
+ const enabled =
107
+ configured && /enabled\s*:\s*false/.test(block)
108
+ ? false
109
+ : configured
110
+ ? true
111
+ : fs.existsSync(path.join(cwd, DEFAULT_SDD_DIR));
112
+ const strictHighRisk =
113
+ configured && /strictHighRisk\s*:\s*false/.test(block)
114
+ ? false
115
+ : enabled;
116
+ const pathMatch = block.match(/path\s*:\s*['"]([^'"]+)['"]/);
117
+ const sddPath = normalizeRelPath(pathMatch?.[1] || DEFAULT_SDD_DIR);
118
+ const rootDir = path.join(cwd, sddPath);
119
+ const initialized =
120
+ fs.existsSync(path.join(rootDir, SDD_CONFIG_FILE)) ||
121
+ fs.existsSync(path.join(rootDir, 'specs')) ||
122
+ fs.existsSync(path.join(rootDir, 'changes'));
123
+ return {
124
+ enabled,
125
+ strictHighRisk,
126
+ path: sddPath,
127
+ rootDir,
128
+ initialized,
129
+ configured,
130
+ source: configured ? 'app-workspace.config.ts' : initialized ? 'openspec' : 'none',
131
+ };
132
+ }
133
+
134
+ function buildDefaultConfigYaml() {
135
+ return [
136
+ 'schema: openxiangda-sdd-v1',
137
+ 'description: OpenXiangda native SDD workspace records inspired by OpenSpec.',
138
+ 'artifacts:',
139
+ ' proposal: proposal.md',
140
+ ' design: design.md',
141
+ ' tasks: tasks.md',
142
+ ' evidence: evidence.md',
143
+ ' metadata: change.json',
144
+ 'rules:',
145
+ ' requireApprovalForHighRisk: true',
146
+ ' requireAffectedCoverage: true',
147
+ ' archiveMergesDeltaSpecs: true',
148
+ '',
149
+ ].join('\n');
150
+ }
151
+
152
+ function initSddWorkspace(options = {}) {
153
+ const cwd = options.cwd || process.cwd();
154
+ const sddPath = normalizeRelPath(options.path || DEFAULT_SDD_DIR);
155
+ const rootDir = path.join(cwd, sddPath);
156
+ const operations = [];
157
+ const force = Boolean(options.force);
158
+
159
+ ensureDir(rootDir);
160
+ ensureDir(path.join(rootDir, 'changes'));
161
+ ensureDir(path.join(rootDir, 'changes', 'archive'));
162
+ ensureDir(path.join(rootDir, 'specs', 'platform'));
163
+ ensureDir(path.join(rootDir, 'templates', 'change'));
164
+
165
+ writeFileIfMissing(path.join(rootDir, SDD_CONFIG_FILE), buildDefaultConfigYaml(), operations, force);
166
+ writeFileIfMissing(path.join(rootDir, 'specs', 'platform', 'spec.md'), DEFAULT_SPEC_CONTENT, operations, false);
167
+ writeFileIfMissing(path.join(rootDir, 'changes', '.gitkeep'), '', operations, false);
168
+ writeFileIfMissing(path.join(rootDir, 'changes', 'archive', '.gitkeep'), '', operations, false);
169
+ writeFileIfMissing(
170
+ path.join(rootDir, 'templates', 'change', 'README.md'),
171
+ [
172
+ '# Change Artifact Template',
173
+ '',
174
+ 'Create changes with `openxiangda sdd propose <change-id>`.',
175
+ 'High-risk implementation starts only after `openxiangda sdd approve <change-id>`.',
176
+ '',
177
+ ].join('\n'),
178
+ operations,
179
+ false
180
+ );
181
+
182
+ return {
183
+ cwd,
184
+ path: sddPath,
185
+ rootDir,
186
+ initialized: true,
187
+ operations: operations.map(item => ({
188
+ path: path.relative(cwd, item.path).replace(/\\/g, '/'),
189
+ action: item.action,
190
+ })),
191
+ };
192
+ }
193
+
194
+ function listMarkdownFiles(dir) {
195
+ if (!fs.existsSync(dir)) return [];
196
+ const result = [];
197
+ const visit = current => {
198
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
199
+ const entryPath = path.join(current, entry.name);
200
+ if (entry.isDirectory()) {
201
+ visit(entryPath);
202
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
203
+ result.push(entryPath);
204
+ }
205
+ }
206
+ };
207
+ visit(dir);
208
+ return result.sort();
209
+ }
210
+
211
+ function listChangeDirs(rootDir, includeArchive = false) {
212
+ const changesDir = path.join(rootDir, 'changes');
213
+ if (!fs.existsSync(changesDir)) return [];
214
+ const dirs = [];
215
+ for (const entry of fs.readdirSync(changesDir, { withFileTypes: true })) {
216
+ if (!entry.isDirectory()) continue;
217
+ if (entry.name === 'archive') {
218
+ if (!includeArchive) continue;
219
+ const archiveDir = path.join(changesDir, entry.name);
220
+ for (const archived of fs.readdirSync(archiveDir, { withFileTypes: true })) {
221
+ if (archived.isDirectory()) dirs.push(path.join(archiveDir, archived.name));
222
+ }
223
+ continue;
224
+ }
225
+ dirs.push(path.join(changesDir, entry.name));
226
+ }
227
+ return dirs.sort();
228
+ }
229
+
230
+ function readChange(changeDir) {
231
+ const meta = readJsonFile(path.join(changeDir, CHANGE_META_FILE), null);
232
+ const id = meta?.id || path.basename(changeDir);
233
+ return {
234
+ id,
235
+ dir: changeDir,
236
+ relativeDir: changeDir.split(path.sep).slice(-2).join('/'),
237
+ metadata: meta,
238
+ valid: Boolean(meta && !meta.__invalid),
239
+ };
240
+ }
241
+
242
+ function listChanges(rootDir, options = {}) {
243
+ return listChangeDirs(rootDir, Boolean(options.includeArchive)).map(readChange);
244
+ }
245
+
246
+ function splitList(value) {
247
+ if (value === undefined || value === null || value === false) return [];
248
+ if (Array.isArray(value)) return value.flatMap(splitList);
249
+ if (value === true) return ['*'];
250
+ return String(value)
251
+ .split(',')
252
+ .map(item => item.trim())
253
+ .filter(Boolean);
254
+ }
255
+
256
+ function normalizeAffected(input = {}) {
257
+ const affected = input.affected || input;
258
+ return {
259
+ forms: splitList(affected.forms || affected.form),
260
+ pages: splitList(affected.pages || affected.page),
261
+ resources: affected.resources === true || affected.resources === 'true' || splitList(affected.resources).includes('*'),
262
+ runtime: affected.runtime === true || affected.runtime === 'true',
263
+ backend: affected.backend === true || affected.backend === 'true',
264
+ contracts: affected.contracts === true || affected.contracts === 'true',
265
+ };
266
+ }
267
+
268
+ function createChangeMetadata(changeId, options = {}) {
269
+ return {
270
+ id: changeId,
271
+ title: options.title || changeId,
272
+ status: 'proposed',
273
+ riskLevel: options.riskLevel || options.risk || 'medium',
274
+ createdAt: nowIso(),
275
+ updatedAt: nowIso(),
276
+ approvedAt: null,
277
+ approvedBy: null,
278
+ approvalSummary: null,
279
+ affected: normalizeAffected(options),
280
+ release: {
281
+ plannedCommands: [],
282
+ lastVerifiedAt: null,
283
+ },
284
+ evidence: {
285
+ files: ['evidence.md'],
286
+ bypasses: [],
287
+ },
288
+ };
289
+ }
290
+
291
+ function createProposalContent(meta) {
292
+ return `# Proposal: ${meta.title}
293
+
294
+ ## Why
295
+
296
+ -
297
+
298
+ ## What Changes
299
+
300
+ -
301
+
302
+ ## Out Of Scope
303
+
304
+ -
305
+
306
+ ## Risks
307
+
308
+ -
309
+ `;
310
+ }
311
+
312
+ function createDesignContent(meta) {
313
+ return `# Design: ${meta.title}
314
+
315
+ ## OpenXiangda Resource Impact
316
+
317
+ - Forms: ${meta.affected.forms.join(', ') || '(none)'}
318
+ - Pages: ${meta.affected.pages.join(', ') || '(none)'}
319
+ - Resources: ${meta.affected.resources ? 'yes' : 'no'}
320
+ - Runtime: ${meta.affected.runtime ? 'yes' : 'no'}
321
+ - Backend/App Function/Automation logic: ${meta.affected.backend ? 'yes' : 'no'}
322
+ - Contracts: ${meta.affected.contracts ? 'yes' : 'no'}
323
+
324
+ ## Data And Contract Notes
325
+
326
+ -
327
+
328
+ ## Publish Plan
329
+
330
+ - Run \`openxiangda workspace plan --changed\` before publish.
331
+ `;
332
+ }
333
+
334
+ function createTasksContent() {
335
+ return [
336
+ '# Tasks',
337
+ '',
338
+ '- [ ] Confirm proposal and affected scope',
339
+ '- [ ] Implement changes',
340
+ '- [ ] Run `openxiangda sdd verify <change>`',
341
+ '- [ ] Run release plan/check commands',
342
+ '- [ ] Archive the change after release',
343
+ '',
344
+ ].join('\n');
345
+ }
346
+
347
+ function createEvidenceContent() {
348
+ return [
349
+ '# Evidence',
350
+ '',
351
+ '## Verification',
352
+ '',
353
+ '- ',
354
+ '',
355
+ '## Release',
356
+ '',
357
+ '- ',
358
+ '',
359
+ '## Bypass Log',
360
+ '',
361
+ '- None',
362
+ '',
363
+ ].join('\n');
364
+ }
365
+
366
+ function createDeltaSpecContent(meta, domain) {
367
+ return `# ${domain} Delta Spec
368
+
369
+ ## ADDED Requirements
370
+
371
+ ### Requirement: ${meta.title}
372
+ Describe the behavior that will be true after this change.
373
+
374
+ #### Scenario: Main path
375
+ - **Given**
376
+ - **When**
377
+ - **Then**
378
+ `;
379
+ }
380
+
381
+ function proposeSddChange(options = {}) {
382
+ const governance = getSddGovernanceConfig(options);
383
+ if (!governance.initialized) initSddWorkspace({ cwd: options.cwd, path: governance.path });
384
+ const changeId = normalizeChangeId(options.changeId);
385
+ const rootDir = governance.rootDir;
386
+ const changeDir = path.join(rootDir, 'changes', changeId);
387
+ if (fs.existsSync(changeDir) && !options.force) {
388
+ throw new Error(`change 已存在: ${changeId}`);
389
+ }
390
+ ensureDir(changeDir);
391
+ ensureDir(path.join(changeDir, 'specs', options.domain || 'app'));
392
+ const meta = createChangeMetadata(changeId, options);
393
+ writeJsonFile(path.join(changeDir, CHANGE_META_FILE), meta);
394
+ fs.writeFileSync(path.join(changeDir, 'proposal.md'), createProposalContent(meta));
395
+ fs.writeFileSync(path.join(changeDir, 'design.md'), createDesignContent(meta));
396
+ fs.writeFileSync(path.join(changeDir, 'tasks.md'), createTasksContent());
397
+ fs.writeFileSync(path.join(changeDir, 'evidence.md'), createEvidenceContent());
398
+ fs.writeFileSync(
399
+ path.join(changeDir, 'specs', options.domain || 'app', 'spec.md'),
400
+ createDeltaSpecContent(meta, options.domain || 'app')
401
+ );
402
+ return {
403
+ change: meta,
404
+ dir: path.relative(options.cwd || process.cwd(), changeDir).replace(/\\/g, '/'),
405
+ };
406
+ }
407
+
408
+ function loadSddChange(options = {}) {
409
+ const governance = getSddGovernanceConfig(options);
410
+ const changeId = normalizeChangeId(options.changeId);
411
+ const changeDir = path.join(governance.rootDir, 'changes', changeId);
412
+ if (!fs.existsSync(changeDir)) throw new Error(`change 不存在: ${changeId}`);
413
+ const metaFile = path.join(changeDir, CHANGE_META_FILE);
414
+ const meta = readJsonFile(metaFile, null);
415
+ if (!meta || meta.__invalid) {
416
+ throw new Error(`change metadata 无效: ${metaFile}`);
417
+ }
418
+ return { governance, changeId, changeDir, meta, metaFile };
419
+ }
420
+
421
+ function approveSddChange(options = {}) {
422
+ const loaded = loadSddChange(options);
423
+ const meta = {
424
+ ...loaded.meta,
425
+ status: 'approved',
426
+ approvedAt: nowIso(),
427
+ approvedBy: options.approvedBy || os.userInfo().username || 'unknown',
428
+ approvalSummary: options.summary || options.note || loaded.meta.approvalSummary || '',
429
+ updatedAt: nowIso(),
430
+ };
431
+ writeJsonFile(loaded.metaFile, meta);
432
+ return {
433
+ change: meta,
434
+ dir: path.relative(options.cwd || process.cwd(), loaded.changeDir).replace(/\\/g, '/'),
435
+ };
436
+ }
437
+
438
+ function includesAll(list, required) {
439
+ if (!required || required.length === 0) return true;
440
+ if (list.includes('*')) return true;
441
+ return required.every(item => list.includes(item));
442
+ }
443
+
444
+ function hasHighRiskTargets(targets = {}) {
445
+ return (
446
+ (targets.forms || []).length > 0 ||
447
+ (targets.pages || []).length > 0 ||
448
+ Boolean(targets.resources) ||
449
+ Boolean(targets.runtime)
450
+ );
451
+ }
452
+
453
+ function classifyHighRiskFiles(files = []) {
454
+ const highRisk = [];
455
+ for (const file of files || []) {
456
+ if (
457
+ file === 'app-workspace.config.ts' ||
458
+ file.startsWith('src/forms/') ||
459
+ file.startsWith('src/pages/') ||
460
+ file.startsWith('src/resources/') ||
461
+ file.startsWith('src/functions/') ||
462
+ file.startsWith('src/automations/') ||
463
+ file.startsWith('src/workflows/') ||
464
+ file.startsWith('src/js-code-nodes/') ||
465
+ file.startsWith('src/runtime/')
466
+ ) {
467
+ highRisk.push(file);
468
+ }
469
+ }
470
+ return highRisk;
471
+ }
472
+
473
+ function affectedCoversTargets(affected, targets = {}) {
474
+ const normalized = normalizeAffected(affected);
475
+ const missing = [];
476
+ if (!includesAll(normalized.forms, targets.forms || [])) {
477
+ missing.push(`forms:${(targets.forms || []).filter(item => !normalized.forms.includes(item)).join(',')}`);
478
+ }
479
+ if (!includesAll(normalized.pages, targets.pages || [])) {
480
+ missing.push(`pages:${(targets.pages || []).filter(item => !normalized.pages.includes(item)).join(',')}`);
481
+ }
482
+ if (targets.resources && !normalized.resources && !normalized.backend) missing.push('resources');
483
+ if (targets.runtime && !normalized.runtime) missing.push('runtime');
484
+ return {
485
+ covered: missing.length === 0,
486
+ missing,
487
+ };
488
+ }
489
+
490
+ function affectedCoversHighRiskFiles(affected, files = []) {
491
+ const normalized = normalizeAffected(affected);
492
+ const missing = [];
493
+ for (const file of files) {
494
+ if (file === 'app-workspace.config.ts' && !normalized.contracts && !normalized.runtime) {
495
+ missing.push(file);
496
+ } else if (file.startsWith('src/forms/')) {
497
+ const formCode = file.split('/')[2];
498
+ if (!includesAll(normalized.forms, [formCode])) missing.push(file);
499
+ } else if (file.startsWith('src/pages/')) {
500
+ const pageCode = file.split('/')[2];
501
+ if (!normalized.runtime && !includesAll(normalized.pages, [pageCode])) missing.push(file);
502
+ } else if (file.startsWith('src/resources/') && !normalized.resources) {
503
+ 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);
513
+ } else if (file.startsWith('src/runtime/') && !normalized.runtime) {
514
+ missing.push(file);
515
+ }
516
+ }
517
+ return {
518
+ covered: missing.length === 0,
519
+ missing,
520
+ };
521
+ }
522
+
523
+ function getApprovedActiveChanges(rootDir) {
524
+ return listChanges(rootDir)
525
+ .filter(item => item.valid && item.metadata.status === 'approved' && item.metadata.approvedAt)
526
+ .map(item => item.metadata);
527
+ }
528
+
529
+ function buildSddGovernanceReport(options = {}) {
530
+ const governance = getSddGovernanceConfig(options);
531
+ const targets = options.targets || {};
532
+ const files = options.files || [];
533
+ const highRiskFiles = classifyHighRiskFiles(files);
534
+ const checks = [];
535
+ const warnings = [];
536
+ const errors = [];
537
+ let activeChanges = [];
538
+
539
+ if (!governance.enabled) {
540
+ warnings.push('未启用 SDD governance;运行 openxiangda sdd init 可开启高风险变更记录。');
541
+ checks.push({ name: 'sdd', status: 'warn', message: warnings[0] });
542
+ return { governance, highRiskFiles, activeChanges, checks, warnings, errors };
543
+ }
544
+
545
+ if (!governance.initialized) {
546
+ const message = `SDD 已配置但未初始化: ${governance.path}。运行 openxiangda sdd init`;
547
+ if (governance.strictHighRisk && (hasHighRiskTargets(targets) || highRiskFiles.length > 0)) {
548
+ errors.push({ name: 'sdd-not-initialized', message });
549
+ checks.push({ name: 'sdd', status: 'error', message });
550
+ } else {
551
+ warnings.push(message);
552
+ checks.push({ name: 'sdd', status: 'warn', message });
553
+ }
554
+ return { governance, highRiskFiles, activeChanges, checks, warnings, errors };
555
+ }
556
+
557
+ activeChanges = listChanges(governance.rootDir).filter(
558
+ item => item.valid && !['archived', 'rejected'].includes(item.metadata.status)
559
+ );
560
+ const approved = getApprovedActiveChanges(governance.rootDir);
561
+ if (!governance.strictHighRisk || (!hasHighRiskTargets(targets) && highRiskFiles.length === 0)) {
562
+ checks.push({ name: 'sdd', status: 'ok', message: `${activeChanges.length} active changes` });
563
+ return { governance, highRiskFiles, activeChanges, checks, warnings, errors };
564
+ }
565
+
566
+ if (approved.length === 0) {
567
+ errors.push({
568
+ name: 'sdd-approved-change-required',
569
+ message: '高风险变更发布前必须存在 approved SDD change;先运行 openxiangda sdd propose <change> 并经 openxiangda sdd approve <change> 确认。',
570
+ });
571
+ } 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
+ });
585
+ }
586
+ }
587
+
588
+ checks.push({
589
+ name: 'sdd',
590
+ status: errors.length > 0 ? 'error' : 'ok',
591
+ message: errors.length > 0 ? `${errors.length} 个 SDD gate 错误` : `${approved.length} approved active changes`,
592
+ });
593
+ return { governance, highRiskFiles, activeChanges, checks, warnings, errors };
594
+ }
595
+
596
+ function readUncheckedTasks(changeDir) {
597
+ const file = path.join(changeDir, 'tasks.md');
598
+ if (!fs.existsSync(file)) return ['tasks.md 缺失'];
599
+ return fs
600
+ .readFileSync(file, 'utf8')
601
+ .split(/\r?\n/)
602
+ .map((line, index) => ({ line, index: index + 1 }))
603
+ .filter(item => /^-\s*\[\s\]/.test(item.line))
604
+ .map(item => `tasks.md:${item.index} ${item.line}`);
605
+ }
606
+
607
+ function verifySddChange(options = {}) {
608
+ const loaded = loadSddChange(options);
609
+ const targets = options.targets || options.releasePlan?.targets || {};
610
+ const highRiskFiles = classifyHighRiskFiles(options.files || options.releasePlan?.changedFiles || []);
611
+ const errors = [];
612
+ const warnings = [];
613
+ if (loaded.meta.status !== 'approved' || !loaded.meta.approvedAt) {
614
+ errors.push({ name: 'sdd-change-not-approved', message: `change ${loaded.changeId} 未 approve` });
615
+ }
616
+ const targetCoverage = affectedCoversTargets(loaded.meta.affected, targets);
617
+ if (!targetCoverage.covered) {
618
+ errors.push({
619
+ name: 'sdd-change-targets-uncovered',
620
+ message: `affected 未覆盖发布目标: ${targetCoverage.missing.join(', ')}`,
621
+ });
622
+ }
623
+ const fileCoverage = affectedCoversHighRiskFiles(loaded.meta.affected, highRiskFiles);
624
+ if (!fileCoverage.covered) {
625
+ errors.push({
626
+ name: 'sdd-change-files-uncovered',
627
+ message: `affected 未覆盖高风险文件: ${fileCoverage.missing.join(', ')}`,
628
+ });
629
+ }
630
+ const uncheckedTasks = readUncheckedTasks(loaded.changeDir);
631
+ if (uncheckedTasks.length > 0) {
632
+ errors.push({
633
+ name: 'sdd-unfinished-tasks',
634
+ message: `${uncheckedTasks.length} 个 tasks.md 任务未完成`,
635
+ details: uncheckedTasks,
636
+ });
637
+ }
638
+ const evidenceFile = path.join(loaded.changeDir, 'evidence.md');
639
+ if (!fs.existsSync(evidenceFile)) {
640
+ warnings.push({ name: 'sdd-evidence-missing', message: 'evidence.md 缺失' });
641
+ }
642
+ const verifiedMeta = {
643
+ ...loaded.meta,
644
+ release: {
645
+ ...(loaded.meta.release || {}),
646
+ plannedCommands: options.releasePlan?.commands || loaded.meta.release?.plannedCommands || [],
647
+ lastVerifiedAt: nowIso(),
648
+ },
649
+ updatedAt: nowIso(),
650
+ };
651
+ if (options.writeMetadata) writeJsonFile(loaded.metaFile, verifiedMeta);
652
+ return {
653
+ change: verifiedMeta,
654
+ errors,
655
+ warnings,
656
+ passed: errors.length === 0,
657
+ targets,
658
+ highRiskFiles,
659
+ };
660
+ }
661
+
662
+ function appendBypassLog(options = {}) {
663
+ const governance = getSddGovernanceConfig(options);
664
+ if (!governance.initialized) {
665
+ throw new Error('SDD bypass 需要已初始化 openspec;先运行 openxiangda sdd init');
666
+ }
667
+ const reason = String(options.reason || '').trim();
668
+ if (!reason) throw new Error('--sdd-bypass 需要同时提供 --reason "..."');
669
+ const file = path.join(governance.rootDir, BYPASS_LOG_FILE);
670
+ ensureDir(path.dirname(file));
671
+ const lines = [
672
+ `## ${nowIso()}`,
673
+ '',
674
+ `- command: ${options.command || '-'}`,
675
+ `- reason: ${reason}`,
676
+ `- targets: ${JSON.stringify(options.targets || {})}`,
677
+ `- user: ${os.userInfo().username || 'unknown'}`,
678
+ '',
679
+ ];
680
+ fs.appendFileSync(file, lines.join('\n'));
681
+ return {
682
+ file: path.relative(options.cwd || process.cwd(), file).replace(/\\/g, '/'),
683
+ reason,
684
+ };
685
+ }
686
+
687
+ function buildSddStatus(options = {}) {
688
+ const governance = getSddGovernanceConfig(options);
689
+ const specsDir = path.join(governance.rootDir, 'specs');
690
+ const changes = governance.initialized ? listChanges(governance.rootDir) : [];
691
+ const archived = governance.initialized ? listChanges(governance.rootDir, { includeArchive: true }).filter(item => item.dir.includes(`${path.sep}archive${path.sep}`)) : [];
692
+ return {
693
+ cwd: options.cwd || process.cwd(),
694
+ governance,
695
+ 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
+ })),
703
+ archivedChanges: archived.slice(-10).map(item => ({
704
+ id: item.id,
705
+ dir: path.relative(options.cwd || process.cwd(), item.dir).replace(/\\/g, '/'),
706
+ })),
707
+ bypassLog: governance.initialized && fs.existsSync(path.join(governance.rootDir, BYPASS_LOG_FILE))
708
+ ? path.join(governance.path, BYPASS_LOG_FILE).replace(/\\/g, '/')
709
+ : null,
710
+ };
711
+ }
712
+
713
+ function syncSddChange(options = {}) {
714
+ const loaded = loadSddChange(options);
715
+ const specFiles = listMarkdownFiles(path.join(loaded.changeDir, 'specs'));
716
+ const synced = [];
717
+ for (const deltaFile of specFiles) {
718
+ const rel = path.relative(path.join(loaded.changeDir, 'specs'), deltaFile);
719
+ 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, '/'));
733
+ }
734
+ return { change: loaded.meta, synced };
735
+ }
736
+
737
+ function archiveSddChange(options = {}) {
738
+ const verify = verifySddChange(options);
739
+ if (!verify.passed && !options.force) {
740
+ const first = verify.errors[0];
741
+ throw new Error(`change 不能归档: ${first.message}`);
742
+ }
743
+ const loaded = loadSddChange(options);
744
+ const sync = syncSddChange(options);
745
+ const archiveName = `${new Date().toISOString().slice(0, 10)}-${loaded.changeId}`;
746
+ const archiveDir = path.join(loaded.governance.rootDir, 'changes', 'archive', archiveName);
747
+ ensureDir(path.dirname(archiveDir));
748
+ if (fs.existsSync(archiveDir)) throw new Error(`archive 已存在: ${archiveName}`);
749
+ const meta = {
750
+ ...loaded.meta,
751
+ status: 'archived',
752
+ archivedAt: nowIso(),
753
+ updatedAt: nowIso(),
754
+ };
755
+ writeJsonFile(loaded.metaFile, meta);
756
+ fs.renameSync(loaded.changeDir, archiveDir);
757
+ return {
758
+ change: meta,
759
+ archiveDir: path.relative(options.cwd || process.cwd(), archiveDir).replace(/\\/g, '/'),
760
+ synced: sync.synced,
761
+ };
762
+ }
763
+
764
+ module.exports = {
765
+ DEFAULT_SDD_DIR,
766
+ appendBypassLog,
767
+ archiveSddChange,
768
+ buildSddGovernanceReport,
769
+ buildSddStatus,
770
+ getSddGovernanceConfig,
771
+ initSddWorkspace,
772
+ normalizeAffected,
773
+ proposeSddChange,
774
+ approveSddChange,
775
+ syncSddChange,
776
+ verifySddChange,
777
+ };