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/README.md +1 -1
- package/lib/cli.js +66 -19
- package/lib/design-gates.js +1 -1
- package/lib/sdd.js +783 -101
- package/openxiangda-skills/SKILL.md +1 -1
- package/openxiangda-skills/references/permission-design-patterns.md +5 -3
- package/openxiangda-skills/references/permissions-settings.md +6 -4
- package/openxiangda-skills/references/resource-manifest-cheatsheet.md +7 -3
- package/openxiangda-skills/skills/openxiangda-core/SKILL.md +2 -2
- package/package.json +1 -1
- package/templates/openxiangda-react-spa/.cursor/rules/openxiangda.mdc +1 -1
- package/templates/openxiangda-react-spa/.qoder/rules/openxiangda.md +1 -1
- package/templates/openxiangda-react-spa/AGENTS.md +1 -1
- package/templates/openxiangda-react-spa/app-workspace.config.ts +1 -0
- package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda.mdc +1 -1
- package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda.md +1 -1
- package/templates/sy-lowcode-app-workspace/AGENTS.md +3 -3
- package/templates/sy-lowcode-app-workspace/app-workspace.config.ts +1 -0
- package/templates/sy-lowcode-app-workspace/src/types/app-workspace.types.ts +1 -0
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 = `#
|
|
14
|
+
const DEFAULT_SPEC_CONTENT = `# Application Specification
|
|
11
15
|
|
|
12
|
-
This
|
|
16
|
+
This directory is the source of truth for this application's agreed behavior.
|
|
13
17
|
|
|
14
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
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', '
|
|
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
|
-
|
|
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
|
-
##
|
|
344
|
+
## Intent
|
|
295
345
|
|
|
296
|
-
|
|
346
|
+
Deliver "${meta.title}" as one focused OpenXiangda application change.
|
|
297
347
|
|
|
298
|
-
##
|
|
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
|
-
##
|
|
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
|
|
318
|
-
- Pages: ${meta.affected.pages
|
|
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
|
-
-
|
|
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
|
|
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
|
-
'- [ ]
|
|
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
|
-
|
|
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',
|
|
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',
|
|
400
|
-
createDeltaSpecContent(meta,
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
!normalized.backend &&
|
|
510
|
-
|
|
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 =>
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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 {
|
|
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
|
|
597
|
-
|
|
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 =>
|
|
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
|
-
|
|
640
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
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(
|
|
1385
|
+
const rel = path.relative(specRoot, deltaFile).replace(/\\/g, '/');
|
|
719
1386
|
const target = path.join(loaded.governance.rootDir, 'specs', rel);
|
|
720
|
-
|
|
721
|
-
const delta =
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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,
|